From d5f2e47ea198397314e6839ceaf30e1a80848736 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Thu, 8 Oct 2026 14:32:06 +0200 Subject: [PATCH] Dashboard layout: Apps card header (R-909), launcher header wraps on a phone (R-907), overflow counted after the page filter (R-906) - Apps card: title row (logo top-aligned, name, address cut with an ellipsis and full in title) over a wrapping .stack-tags row; the tags no longer share a space-between row with the name. - Phone (768px block): .page-header wraps; the share button drops to its own line, text whole. - GetBannerAlerts(page, lang): the layout's filter before the cap; baseData and /monitoring use it. - Tests: TestStackCardHeaderLayout, TestR907_PageHeaderWrapsOnAPhone, TestR906_* (all red against 05e12921). - No Hungarian string changed. The three stacks parity fixtures are re-captured in the next commit (this commit alone fails TestI18nParity on them; both are pushed together). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb --- CHANGELOG.md | 28 ++++ REUSE.md | 1 + controller/internal/web/alerts.go | 47 +++++- .../internal/web/dashboard_layout_test.go | 137 ++++++++++++++++++ controller/internal/web/handlers.go | 4 +- controller/internal/web/r906_overflow_test.go | 80 ++++++++++ controller/internal/web/templates/stacks.html | 8 +- controller/internal/web/templates/style.css | 34 ++++- 8 files changed, 330 insertions(+), 9 deletions(-) create mode 100644 controller/internal/web/dashboard_layout_test.go create mode 100644 controller/internal/web/r906_overflow_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index ce2088f..f0180f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,31 @@ +## Unreleased (2026-10-08, afternoon) — three dashboard layout fixes: the Apps card header (R-909), the launcher on a phone (R-907), the empty „+5 more warnings" (R-906) — ships with tomorrow's release + +**MinAgent: 0.131.0** (unchanged — nothing on the wire, nothing the agent sees). Hungarian text: not one string +changed; three Apps-page parity fixtures re-captured for the markup alone (below). + +- **R-909 — the Apps card header.** The title row and every tag were siblings in ONE `space-between` flex row; with + two or more tags (any installed app) the one-word name ran under them, and the logo was centred on the name block, + so a card with an address carried its logo lower. Now every card has the same two rows: logo + name (+ the address + under it, top-aligned), then the tags in their own `.stack-tags` row, left-aligned and wrapping. The address is one + line cut with an ellipsis (`.subdomain-text`, `min-width: 0`), full address in `title`, „↗" on the same line; the + unpublished-route warning stays under it. `TestStackCardHeaderLayout` (both languages, all 8 `stacks_full` cards + + the CSS rules; red against 05e12921: no tags row, no address title, `space-between`, `align-items: center`). +- **R-907 — the launcher's share button on a phone.** In the 768 px block `.page-header` wraps, so the button drops + to its own line under the title and the domain chip, its text kept whole (`white-space: nowrap`). It applies to + every page header on a phone (a header that fits is unchanged). `TestR907_PageHeaderWrapsOnAPhone` (red against + 05e12921: the phone block had no `.page-header` rule). +- **R-906 — „+ 5 további figyelmeztetés" with nothing above it.** `GetAlerts` capped the list at five over ALL alerts, + and the layout then dropped the Inline and other-page ones; on 9202 the five visible ones were inline „not on a + separate drive" warnings, so only the overflow line rendered and the hub-off warning was cut. New + `AlertManager.GetBannerAlerts(page, lang)` filters with the layout's own rule (`bannerShows`) BEFORE the cap; + `baseData` and the monitoring page use it. `GetAlerts` is unchanged for the debug dump. Tests `TestR906_*` (the 9202 + case on /launcher, /stacks, /backups, /monitoring, hu and en: no overflow line, the hub-off banner shown; red with + the two calls pointed back at `GetAlerts` — every page showed the overflow line and no warning). +- **Parity fixtures re-captured, in their own commit:** `stacks_full`, `stacks_unhealthy_stop`, + `stacks_install_interrupted`. Measured: each equals its predecessor with exactly the template change applied + (wrapper `stack-title-text`, the address span + `title`, the `stack-tags` row) — byte-for-byte, 3 of 3 — and the + visible text is identical. No other fixture moved. + ## Unreleased (2026-10-08) — a daytime press never cancels the night's whole-guest backup (R-899; operator ruling 2026-10-08, option A); an honest answer when older recovery packages were not checked, and the operator's mail (R-304) — ships with tomorrow's release **MinAgent: 0.131.0** (unchanged — the new `trigger=manual` query is ignored by an older agent, which keeps running the diff --git a/REUSE.md b/REUSE.md index 5245cf3..833bc2b 100644 --- a/REUSE.md +++ b/REUSE.md @@ -63,6 +63,7 @@ | `backup.ErrOffsiteQuota` | controller/internal/backup/offbox.go | sentinel | `ClassifyOffsiteFailure` telling a quota over-run apart | The other arms of that switch stay TEXT matches on purpose — they are restic's and ssh's own English output, which we neither write nor translate | | `monitor.WarnKind*` + `HealthReport.addWarning` / `WarningKindAt` | controller/internal/monitor/healthcheck.go | `(text, kind string)` | a health warning whose PLACEMENT the dashboard decides | Internal only: `internal/report/builder.go` copies Status/Issues/Warnings, so kinds never reach the hub (pinned) | | `monitor.MsgRef` + `HealthReport.addWarningMsg` / `addIssue` / `WarningMsgAt` / `IssueMsgAt` | controller/internal/monitor/healthcheck.go | `MsgRef{Key, Args}` beside the wire text | a health warning/issue whose BANNER must follow the household's language (R-516 item 10) | The wire text (report health.*) stays byte-identical; `AlertManager.Refresh` renders the key and falls back to the text when Key is empty. Every issue goes through `addIssue` so the parallel slices cannot drift (pinned) | +| `AlertManager.GetBannerAlerts(page, lang)` + `bannerShows` (R-906, 2026-10-08) | controller/internal/web/alerts.go | the layout banner's list for ONE page, capped AFTER the page filter | any page that fills `data["Alerts"]` (`baseData` does it for you) | `GetAlerts` keeps Inline and other pages' alerts (debug dump, placement tests) and must not feed a banner: its cap counted alerts the layout then dropped, so a page showed „+ 5 további figyelmeztetés" and nothing above it. `bannerShows` mirrors the layout's own filter (pinned: `TestR906_*`) | | `settings.OffboxTarget.LastWarningKind` + `backup.OffboxWarnNoAppsSelected` | controller/internal/settings/settings.go | persisted string | the Távoli mentés page's stale-note substitution | Written and cleared with `LastWarning`; the text fallback in `offboxWarningDisplay` is LEGACY only (kind == "") and is removed when R-570 closes | ### HTTP/JSON envelopes + flash messages diff --git a/controller/internal/web/alerts.go b/controller/internal/web/alerts.go index 072a43c..0dd93c7 100644 --- a/controller/internal/web/alerts.go +++ b/controller/internal/web/alerts.go @@ -338,8 +338,40 @@ func (am *AlertManager) Refresh(report *monitor.HealthReport, cfg *config.Config am.mu.Unlock() } -// GetAlerts returns a copy of the current alerts, optionally excluding specific IDs. +// GetAlerts returns a copy of the current alerts, optionally excluding specific IDs. It keeps the +// Inline and page-bound alerts (the debug dump and the placement tests read them); a page's banner +// reads GetBannerAlerts instead. func (am *AlertManager) GetAlerts(lang string, excludeIDs ...string) []Alert { + return am.collectAlerts(lang, nil, excludeIDs...) +} + +// GetBannerAlerts returns only the alerts the layout banner shows on page — no Inline alert, no alert +// whose PageOnly leaves page out — and caps THAT list. R-906: the cap used to count every alert and +// the layout dropped the other pages' alerts afterwards, so a page could show „+ 5 további +// figyelmeztetés" with no warning above it (9202, nine inline disk warnings). Pinned by +// TestR906_OverflowCountsOnlyThisPagesBannerAlerts. +func (am *AlertManager) GetBannerAlerts(page, lang string) []Alert { + return am.collectAlerts(lang, func(a Alert) bool { return bannerShows(a, page) }) +} + +// bannerShows is the layout's own filter (layout.html: not Inline, and PageOnly empty or naming the +// page), in Go, so the cap and the template agree on what a page shows. +func bannerShows(a Alert, page string) bool { + if a.Inline { + return false + } + if len(a.PageOnly) == 0 { + return true + } + for _, p := range a.PageOnly { + if p == page { + return true + } + } + return false +} + +func (am *AlertManager) collectAlerts(lang string, keep func(Alert) bool, excludeIDs ...string) []Alert { am.mu.RLock() defer am.mu.RUnlock() @@ -377,6 +409,19 @@ func (am *AlertManager) GetAlerts(lang string, excludeIDs ...string) []Alert { result = append(result, a) } + if keep != nil { + kept := result[:0] + for _, a := range result { + if keep(a) { + kept = append(kept, a) + } + } + result = kept + if len(result) == 0 { + return nil + } + } + // Cap at 5 visible alerts if len(result) > 5 { overflow := len(result) - 5 diff --git a/controller/internal/web/dashboard_layout_test.go b/controller/internal/web/dashboard_layout_test.go new file mode 100644 index 0000000..cea4e63 --- /dev/null +++ b/controller/internal/web/dashboard_layout_test.go @@ -0,0 +1,137 @@ +package web + +import ( + "regexp" + "strings" + "testing" +) + +// Dashboard layout fixes, 2026-10-08 (REPORT-dashboard-layout.md). A Go test cannot measure pixels; these pin the +// markup and the rules the layout stands on, and the screenshots (felhom.eu/documentation/audits/ +// dashboard-layout-2026-10-08/) show the consequence. + +// cssRule returns the body of the top-level rule whose selector line is exactly `selector {`. +func cssRule(t *testing.T, css, selector string) string { + t.Helper() + i := strings.Index(css, "\n"+selector+" {") + if i < 0 { + t.Fatalf("style.css has no top-level %q rule", selector) + } + body := css[i+len(selector)+3:] + return body[:strings.IndexByte(body, '}')] +} + +// The Apps card header. Before: the title row and every tag were siblings in ONE space-between flex row; with two +// or more tags (any installed app) the one-word name ran under them, and the logo was centred on the name block, +// so a card with an address carried it lower than one without. +// +// RED-PROOF (REPORT): against 05e12921's stacks.html and style.css this fails — no .stack-tags, a tag before it, +// no address title, and the header rule still `justify-content: space-between`. +func TestStackCardHeaderLayout(t *testing.T) { + s := i18nTestServer(t) + var full i18nCase + for _, c := range i18nCases() { + if c.name == "stacks_full" { + full = c + } + } + if full.name == "" { + t.Fatal("no stacks_full parity case") + } + addrRe := regexp.MustCompile(`\s*([^<]*) ↗\s*`) + for _, lang := range []string{"hu", "en"} { + page := renderI18nCase(t, s, lang, full) + cards := strings.Split(page, `
`)[1:] + if len(cards) != 8 { + t.Fatalf("%s: %d card headers, want 8", lang, len(cards)) + } + addresses := 0 + for i, c := range cards { + head := c[:strings.Index(c, `
`)] + title := strings.Index(head, `
`) + tags := strings.Index(head, `
`) + if title < 0 || tags < 0 || tags < title { + t.Errorf("%s card %d: want the title row, then a .stack-tags row (title %d, tags %d)", lang, i, title, tags) + continue + } + if first := strings.Index(head, `class="tag `); first < tags { + t.Errorf("%s card %d: a tag outside the tags row (at %d, row at %d)", lang, i, first, tags) + } + if !strings.Contains(head[title:tags], `
`) { + t.Errorf("%s card %d: the name block has no .stack-title-text (it carries min-width: 0)", lang, i) + } + if strings.Contains(head, "subdomain-link") { + m := addrRe.FindStringSubmatch(head) + if m == nil || m[1] != m[2] || m[2] != m[3] { + t.Errorf("%s card %d: the address must be one ellipsis span with the full address in title: %q", lang, i, m) + } + addresses++ + } + } + if addresses != 2 { + t.Errorf("%s: %d addresses, want 2 (filebrowser, privatebin)", lang, addresses) + } + } + + data, err := StyleCSS() + if err != nil { + t.Fatal(err) + } + css := string(data) + if h := cssRule(t, css, ".stack-detail-header"); strings.Contains(h, "space-between") || !strings.Contains(h, "flex-direction: column") { + t.Errorf(".stack-detail-header must stack the title row over the tags, not spread them on one row: %s", h) + } + if r := cssRule(t, css, ".stack-title-row"); !strings.Contains(r, "align-items: flex-start") { + t.Errorf(".stack-title-row must align the logo to the top of the name: %s", r) + } + if r := cssRule(t, css, ".stack-title-text"); !strings.Contains(r, "min-width: 0") { + t.Errorf(".stack-title-text needs min-width: 0, or a long address cannot shrink: %s", r) + } + if r := cssRule(t, css, ".stack-tags"); !strings.Contains(r, "flex-wrap: wrap") { + t.Errorf(".stack-tags must wrap (English tags are longer): %s", r) + } + if r := cssRule(t, css, ".subdomain-text"); !strings.Contains(r, "text-overflow: ellipsis") || !strings.Contains(r, "overflow: hidden") { + t.Errorf(".subdomain-text must cut a long address with an ellipsis: %s", r) + } + if r := cssRule(t, css, ".subdomain-link"); !strings.Contains(r, "white-space: nowrap") { + t.Errorf(".subdomain-link must keep the arrow on the address line: %s", r) + } +} + +// R-907 — on a phone the launcher's title, domain chip and share button sat on one row that does not wrap; the +// button ran off the right edge (390 px, demo-hp 9201, 2026-10-08). +// +// RED-PROOF (REPORT): against 05e12921's style.css this fails — the 768px block has no .page-header rule. +func TestR907_PageHeaderWrapsOnAPhone(t *testing.T) { + data, err := StyleCSS() + if err != nil { + t.Fatal(err) + } + block := mediaBlock768(t, string(data)) + if !regexp.MustCompile(`\.page-header \{[^}]*flex-wrap: wrap`).MatchString(block) { + t.Error("the phone block must let .page-header wrap") + } + if !regexp.MustCompile(`\.share-open-btn \{[^}]*white-space: nowrap`).MatchString(block) { + t.Error("the share button's text must stay whole when it drops to its own line") + } + // The button is still in the header it wraps inside, in both languages. + s := i18nTestServer(t) + for _, c := range i18nCases() { + if c.name != "launcher_full" { + continue + } + for _, lang := range []string{"hu", "en"} { + page := renderI18nCase(t, s, lang, c) + i := strings.Index(page, `")] + if !strings.Contains(head, "share-open-btn") { + t.Errorf("%s: the share button left the launcher header", lang) + } + } + return + } + t.Fatal("no launcher_full parity case") +} diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go index c545ccd..ccfa37e 100644 --- a/controller/internal/web/handlers.go +++ b/controller/internal/web/handlers.go @@ -139,7 +139,7 @@ func (s *Server) baseData(page, title string) map[string]interface{} { if s.alertManager != nil { // Hungarian here; addLanguageData re-renders the set in the request's language, because // baseData has no request and every page goes through executeTemplate (v0.252.0, R-557). - data["Alerts"] = s.alertManager.GetAlerts(i18n.Default) + data["Alerts"] = s.alertManager.GetBannerAlerts(page, i18n.Default) } return data } @@ -917,7 +917,7 @@ func (s *Server) monitoringHandler(w http.ResponseWriter, r *http.Request) { data["StorageBars"] = s.buildStorageBars(s.langFor(r)) if s.alertManager != nil { - data["Alerts"] = s.alertManager.GetAlerts(s.langFor(r)) + data["Alerts"] = s.alertManager.GetBannerAlerts("monitoring", s.langFor(r)) data["DiskWarnings"] = s.alertManager.GetInlineAlerts("monitoring", s.langFor(r)) } diff --git a/controller/internal/web/r906_overflow_test.go b/controller/internal/web/r906_overflow_test.go new file mode 100644 index 0000000..1d5f41f --- /dev/null +++ b/controller/internal/web/r906_overflow_test.go @@ -0,0 +1,80 @@ +package web + +import ( + "fmt" + "io" + "log" + "strings" + "testing" + "time" + + "gitea.dooplex.hu/admin/felhom-controller/internal/config" + "gitea.dooplex.hu/admin/felhom-controller/internal/monitor" +) + +// R-906 — the banner showed „+ 5 további figyelmeztetés" with no warning above it. 9202 (2026-10-08) carried nine +// „not on a separate drive" warnings (Inline, PageOnly dashboard/monitoring) and the hub-off warning: the cap took the +// first five of all ten, counted five into the overflow line, and the layout then dropped all five visible ones, +// because they belong under the storage bars, not in the banner. Every page showed only the overflow line, and the +// hub-off warning — the one that DID belong in the banner — was cut. +// +// RED-PROOF (REPORT-dashboard-layout.md): point baseData and the monitoring handler back at GetAlerts and every page fails +// (overflow line present, hub-off banner missing). +func TestR906_OverflowCountsOnlyThisPagesBannerAlerts(t *testing.T) { + s := newDashboardServer(t, time.Time{}) + am := NewAlertManager(log.New(io.Discard, "", 0)) + var warnings, kinds []string + for i := 0; i < 9; i++ { + warnings = append(warnings, fmt.Sprintf("Az adattároló (/mnt/hdd/app%d) nem külön meghajtón van", i)) + kinds = append(kinds, monitor.WarnKindStorageNotSeparate) + } + cfg := &config.Config{} + cfg.Hub.Enabled = false // the hub-off warning: the one banner alert this box has + cfg.Backup.Enabled = true + am.Refresh(&monitor.HealthReport{Status: "warn", Warnings: warnings, WarningKinds: kinds}, cfg, nil, false, "") + s.alertManager = am + + if n := len(am.alerts); n != 10 { + t.Fatalf("setup: %d alerts, want 10 (nine inline disk warnings + hub off)", n) + } + + for _, path := range []string{"/launcher", "/stacks", "/backups", "/monitoring", "/launcher?lang=en", "/stacks?lang=en"} { + rec := getPage(t, s, path) + if rec.Code != 200 { + t.Fatalf("GET %s = %d", path, rec.Code) + } + body := rec.Body.String() + if strings.Contains(body, "alert-banner-info") { + t.Errorf("%s: an overflow line is shown, but this page shows no more than one banner alert", path) + } + if got := strings.Count(body, "alert-banner-warning"); got != 1 { + t.Errorf("%s: %d warning banners, want 1 (the hub-off warning)", path, got) + } + } +} + +// The cap still works: six alerts that ALL belong in the banner show five and „+ 1". +func TestR906_CapStillCountsBannerAlerts(t *testing.T) { + am := NewAlertManager(log.New(io.Discard, "", 0)) + var warnings []string + for i := 0; i < 5; i++ { + warnings = append(warnings, fmt.Sprintf("CPU warning %d", i)) + } + cfg := &config.Config{} + cfg.Hub.Enabled = false + cfg.Backup.Enabled = true + am.Refresh(&monitor.HealthReport{Status: "warn", Warnings: warnings, WarningKinds: make([]string, 5)}, cfg, nil, false, "") + + got := am.GetBannerAlerts("launcher", "hu") + if len(got) != 6 || got[5].ID != "overflow" || !strings.Contains(got[5].Message, "1") { + t.Fatalf("six banner alerts: got %d, last %+v — want five and an overflow of 1", len(got), got[len(got)-1]) + } + // An Inline/PageOnly alert never enters the banner list, on any page. + for _, page := range []string{"dashboard", "monitoring", "launcher"} { + for _, a := range am.GetBannerAlerts(page, "hu") { + if !bannerShows(a, page) && a.ID != "overflow" { + t.Errorf("%s: %q is not a banner alert on this page", page, a.ID) + } + } + } +} diff --git a/controller/internal/web/templates/stacks.html b/controller/internal/web/templates/stacks.html index 2e35061..570ed1d 100644 --- a/controller/internal/web/templates/stacks.html +++ b/controller/internal/web/templates/stacks.html @@ -24,12 +24,12 @@
-
+

{{if $im}}{{$im.DisplayName}}{{else}}{{.Meta.DisplayName}}{{end}}

{{$subdomain := index $.Subdomains .Name}} {{if and $subdomain (or .Deployed .Protected)}} - - {{$subdomain}}.{{$.Domain}} ↗ + + {{$subdomain}}.{{$.Domain}} ↗ {{if and .Deployed (routeUnpublished .State)}} {{T "stacks.url_nem_elerheto_utvonal_nincs"}} @@ -37,6 +37,7 @@ {{end}}
+
{{stateLabel .State}} {{if .Orphaned}}{{T "common.elavult"}}{{end}} {{template "meta_badge" (lifecycleBadge .Meta)}} @@ -44,6 +45,7 @@ {{$ms := index $.MissingStorage .Name}}{{if $ms}}{{T "stacks.hianyzo_tarhely"}}{{end}} {{$ns := index $.NetworkStubs .Name}}{{if $ns}}{{T "stacks.halozati_tarhely_hibas_az_alkalmazas"}}{{end}} {{$nw := index $.NetworkWarnings .Name}}{{if $nw}}{{T "stacks.halozati_tarhely_nem_elerheto"}}{{end}} +
{{if $im}} diff --git a/controller/internal/web/templates/style.css b/controller/internal/web/templates/style.css index 4fbbe8a..c9f71cf 100644 --- a/controller/internal/web/templates/style.css +++ b/controller/internal/web/templates/style.css @@ -447,24 +447,48 @@ h3 { .stack-detail-card.stack-state-run::before, .stack-detail-card.stack-state-progress::before { background: var(--blue); } .stack-detail-card.stack-state-warn::before { background: var(--warn); } +/* Card header, the same on every card (2026-10-08): row 1 = logo + name (+ the address under it), row 2 = + the tags, left-aligned and wrapping. The tags used to share one space-between row with the name and + covered it once an installed app had two of them; the logo was centred on the name block, so a card + with an address carried it lower. Pinned by TestStackCardHeaderLayout. */ .stack-detail-header { display: flex; - justify-content: space-between; - align-items: flex-start; + flex-direction: column; + gap: .6rem; margin-bottom: .75rem; } .stack-title-row { display: flex; - align-items: center; + align-items: flex-start; gap: .75rem; + min-width: 0; +} +.stack-title-text { flex: 1; min-width: 0; } +.stack-title-text h3 { margin-bottom: .25rem; overflow-wrap: anywhere; } +.stack-title-text .route-unpublished { white-space: normal; } +.stack-tags { + display: flex; + flex-wrap: wrap; + gap: .4rem; +} .subdomain-link { + display: flex; + align-items: baseline; + gap: .3rem; + max-width: 100%; font-size: .8rem; color: var(--blue-bright); text-decoration: none; transition: color 0.2s ease; + white-space: nowrap; +} +.subdomain-text { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; } .subdomain-link:hover { text-decoration: underline; } /* Tag — square 2px state chip with a 6px dot. Color only on deviation: @@ -2675,6 +2699,10 @@ a.stat-card:hover { .app-row { flex-wrap: wrap; } .app-row-actions { width: 100%; justify-content: flex-end; } .stack-grid { grid-template-columns: 1fr; } + /* R-907: a page header (title, domain chip, buttons) wraps instead of running off the screen; on the + launcher the share button drops to its own line. Pinned by TestR907_PageHeaderWrapsOnAPhone. */ + .page-header { flex-wrap: wrap; row-gap: .6rem; } + .share-open-btn { margin-left: 0; white-space: nowrap; } .stats-grid { grid-template-columns: repeat(3, 1fr); } .deploy-info { flex-direction: column; } .system-info-items { flex-direction: column; gap: 1rem; }