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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
This commit is contained in:
@@ -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
|
## 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
|
**MinAgent: 0.131.0** (unchanged — the new `trigger=manual` query is ignored by an older agent, which keeps running the
|
||||||
|
|||||||
@@ -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 |
|
| `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.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) |
|
| `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 |
|
| `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
|
### HTTP/JSON envelopes + flash messages
|
||||||
|
|||||||
@@ -338,8 +338,40 @@ func (am *AlertManager) Refresh(report *monitor.HealthReport, cfg *config.Config
|
|||||||
am.mu.Unlock()
|
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 {
|
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()
|
am.mu.RLock()
|
||||||
defer am.mu.RUnlock()
|
defer am.mu.RUnlock()
|
||||||
|
|
||||||
@@ -377,6 +409,19 @@ func (am *AlertManager) GetAlerts(lang string, excludeIDs ...string) []Alert {
|
|||||||
result = append(result, a)
|
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
|
// Cap at 5 visible alerts
|
||||||
if len(result) > 5 {
|
if len(result) > 5 {
|
||||||
overflow := len(result) - 5
|
overflow := len(result) - 5
|
||||||
|
|||||||
@@ -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(`<a class="subdomain-link" href="https://([^"]+)" target="_blank" title="([^"]*)">\s*<span class="subdomain-text">([^<]*)</span> ↗\s*</a>`)
|
||||||
|
for _, lang := range []string{"hu", "en"} {
|
||||||
|
page := renderI18nCase(t, s, lang, full)
|
||||||
|
cards := strings.Split(page, `<div class="stack-detail-header">`)[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, `<div class="metarows">`)]
|
||||||
|
title := strings.Index(head, `<div class="stack-title-row">`)
|
||||||
|
tags := strings.Index(head, `<div class="stack-tags">`)
|
||||||
|
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], `<div class="stack-title-text">`) {
|
||||||
|
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, `<div class="page-header">`)
|
||||||
|
if i < 0 {
|
||||||
|
t.Fatalf("%s: launcher has no .page-header", lang)
|
||||||
|
}
|
||||||
|
head := page[i : i+strings.Index(page[i:], "</div>")]
|
||||||
|
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")
|
||||||
|
}
|
||||||
@@ -139,7 +139,7 @@ func (s *Server) baseData(page, title string) map[string]interface{} {
|
|||||||
if s.alertManager != nil {
|
if s.alertManager != nil {
|
||||||
// Hungarian here; addLanguageData re-renders the set in the request's language, because
|
// 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).
|
// 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
|
return data
|
||||||
}
|
}
|
||||||
@@ -917,7 +917,7 @@ func (s *Server) monitoringHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
data["StorageBars"] = s.buildStorageBars(s.langFor(r))
|
data["StorageBars"] = s.buildStorageBars(s.langFor(r))
|
||||||
|
|
||||||
if s.alertManager != nil {
|
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))
|
data["DiskWarnings"] = s.alertManager.GetInlineAlerts("monitoring", s.langFor(r))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,12 +24,12 @@
|
|||||||
<div class="stack-title-row">
|
<div class="stack-title-row">
|
||||||
<img class="stack-logo-lg" src="{{logoURL .Meta.Slug}}" alt="" data-fallback="{{if $im}}/static/infra-logo.svg{{else}}/static/app-placeholder.svg{{end}}"
|
<img class="stack-logo-lg" src="{{logoURL .Meta.Slug}}" alt="" data-fallback="{{if $im}}/static/infra-logo.svg{{else}}/static/app-placeholder.svg{{end}}"
|
||||||
onerror="if(!this.dataset.step){this.dataset.step='1';this.src='{{logoPNGURL .Meta.Slug}}';}else if(this.dataset.fallback&&this.dataset.step==='1'){this.dataset.step='2';this.src=this.dataset.fallback;}else{this.onerror=null;this.style.visibility='hidden';}">
|
onerror="if(!this.dataset.step){this.dataset.step='1';this.src='{{logoPNGURL .Meta.Slug}}';}else if(this.dataset.fallback&&this.dataset.step==='1'){this.dataset.step='2';this.src=this.dataset.fallback;}else{this.onerror=null;this.style.visibility='hidden';}">
|
||||||
<div>
|
<div class="stack-title-text">
|
||||||
<h3>{{if $im}}{{$im.DisplayName}}{{else}}{{.Meta.DisplayName}}{{end}}</h3>
|
<h3>{{if $im}}{{$im.DisplayName}}{{else}}{{.Meta.DisplayName}}{{end}}</h3>
|
||||||
{{$subdomain := index $.Subdomains .Name}}
|
{{$subdomain := index $.Subdomains .Name}}
|
||||||
{{if and $subdomain (or .Deployed .Protected)}}
|
{{if and $subdomain (or .Deployed .Protected)}}
|
||||||
<a class="subdomain-link" href="https://{{$subdomain}}.{{$.Domain}}" target="_blank">
|
<a class="subdomain-link" href="https://{{$subdomain}}.{{$.Domain}}" target="_blank" title="{{$subdomain}}.{{$.Domain}}">
|
||||||
{{$subdomain}}.{{$.Domain}} ↗
|
<span class="subdomain-text">{{$subdomain}}.{{$.Domain}}</span> ↗
|
||||||
</a>
|
</a>
|
||||||
{{if and .Deployed (routeUnpublished .State)}}
|
{{if and .Deployed (routeUnpublished .State)}}
|
||||||
<span class="route-unpublished" title="{{T "stacks.a_proxy_traefik_csak_egeszseges"}}"><svg class="ico ico-sm"><use href="#i-triangle-alert"/></svg> {{T "stacks.url_nem_elerheto_utvonal_nincs"}}</span>
|
<span class="route-unpublished" title="{{T "stacks.a_proxy_traefik_csak_egeszseges"}}"><svg class="ico ico-sm"><use href="#i-triangle-alert"/></svg> {{T "stacks.url_nem_elerheto_utvonal_nincs"}}</span>
|
||||||
@@ -37,6 +37,7 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="stack-tags">
|
||||||
<span class="tag tag-{{stateColor .State}}"><span class="dot"></span>{{stateLabel .State}}</span>
|
<span class="tag tag-{{stateColor .State}}"><span class="dot"></span>{{stateLabel .State}}</span>
|
||||||
{{if .Orphaned}}<span class="tag tag-warn">{{T "common.elavult"}}</span>{{end}}
|
{{if .Orphaned}}<span class="tag tag-warn">{{T "common.elavult"}}</span>{{end}}
|
||||||
{{template "meta_badge" (lifecycleBadge .Meta)}}
|
{{template "meta_badge" (lifecycleBadge .Meta)}}
|
||||||
@@ -44,6 +45,7 @@
|
|||||||
{{$ms := index $.MissingStorage .Name}}{{if $ms}}<span class="tag tag-warn" title="{{T "stacks.az_alkalmazas_adattaroloja_nem_elerheto"}}"><svg class="ico ico-sm"><use href="#i-triangle-alert"/></svg>{{T "stacks.hianyzo_tarhely"}}</span>{{end}}
|
{{$ms := index $.MissingStorage .Name}}{{if $ms}}<span class="tag tag-warn" title="{{T "stacks.az_alkalmazas_adattaroloja_nem_elerheto"}}"><svg class="ico ico-sm"><use href="#i-triangle-alert"/></svg>{{T "stacks.hianyzo_tarhely"}}</span>{{end}}
|
||||||
{{$ns := index $.NetworkStubs .Name}}{{if $ns}}<span class="tag tag-warn" title="{{T "stacks.az_alkalmazas_kornyezeteben_a_halozati"}}"><svg class="ico ico-sm"><use href="#i-triangle-alert"/></svg>{{T "stacks.halozati_tarhely_hibas_az_alkalmazas"}}</span>{{end}}
|
{{$ns := index $.NetworkStubs .Name}}{{if $ns}}<span class="tag tag-warn" title="{{T "stacks.az_alkalmazas_kornyezeteben_a_halozati"}}"><svg class="ico ico-sm"><use href="#i-triangle-alert"/></svg>{{T "stacks.halozati_tarhely_hibas_az_alkalmazas"}}</span>{{end}}
|
||||||
{{$nw := index $.NetworkWarnings .Name}}{{if $nw}}<span class="tag tag-warn" title="{{T "stacks.a_halozati_tarhely_nas_jelenleg"}}"><svg class="ico ico-sm"><use href="#i-triangle-alert"/></svg>{{T "stacks.halozati_tarhely_nem_elerheto"}}</span>{{end}}
|
{{$nw := index $.NetworkWarnings .Name}}{{if $nw}}<span class="tag tag-warn" title="{{T "stacks.a_halozati_tarhely_nas_jelenleg"}}"><svg class="ico ico-sm"><use href="#i-triangle-alert"/></svg>{{T "stacks.halozati_tarhely_nem_elerheto"}}</span>{{end}}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{if $im}}
|
{{if $im}}
|
||||||
|
|||||||
@@ -447,24 +447,48 @@ h3 {
|
|||||||
.stack-detail-card.stack-state-run::before,
|
.stack-detail-card.stack-state-run::before,
|
||||||
.stack-detail-card.stack-state-progress::before { background: var(--blue); }
|
.stack-detail-card.stack-state-progress::before { background: var(--blue); }
|
||||||
.stack-detail-card.stack-state-warn::before { background: var(--warn); }
|
.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 {
|
.stack-detail-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
gap: .6rem;
|
||||||
margin-bottom: .75rem;
|
margin-bottom: .75rem;
|
||||||
}
|
}
|
||||||
.stack-title-row {
|
.stack-title-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
gap: .75rem;
|
gap: .75rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.stack-title-text {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
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 {
|
.subdomain-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: .3rem;
|
||||||
|
max-width: 100%;
|
||||||
font-size: .8rem;
|
font-size: .8rem;
|
||||||
color: var(--blue-bright);
|
color: var(--blue-bright);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: color 0.2s ease;
|
transition: color 0.2s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.subdomain-text {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
.subdomain-link:hover { text-decoration: underline; }
|
.subdomain-link:hover { text-decoration: underline; }
|
||||||
/* Tag — square 2px state chip with a 6px dot. Color only on deviation:
|
/* 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 { flex-wrap: wrap; }
|
||||||
.app-row-actions { width: 100%; justify-content: flex-end; }
|
.app-row-actions { width: 100%; justify-content: flex-end; }
|
||||||
.stack-grid { grid-template-columns: 1fr; }
|
.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); }
|
.stats-grid { grid-template-columns: repeat(3, 1fr); }
|
||||||
.deploy-info { flex-direction: column; }
|
.deploy-info { flex-direction: column; }
|
||||||
.system-info-items { flex-direction: column; gap: 1rem; }
|
.system-info-items { flex-direction: column; gap: 1rem; }
|
||||||
|
|||||||
Reference in New Issue
Block a user