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:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
// 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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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">
|
||||
<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';}">
|
||||
<div>
|
||||
<div class="stack-title-text">
|
||||
<h3>{{if $im}}{{$im.DisplayName}}{{else}}{{.Meta.DisplayName}}{{end}}</h3>
|
||||
{{$subdomain := index $.Subdomains .Name}}
|
||||
{{if and $subdomain (or .Deployed .Protected)}}
|
||||
<a class="subdomain-link" href="https://{{$subdomain}}.{{$.Domain}}" target="_blank">
|
||||
{{$subdomain}}.{{$.Domain}} ↗
|
||||
<a class="subdomain-link" href="https://{{$subdomain}}.{{$.Domain}}" target="_blank" title="{{$subdomain}}.{{$.Domain}}">
|
||||
<span class="subdomain-text">{{$subdomain}}.{{$.Domain}}</span> ↗
|
||||
</a>
|
||||
{{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>
|
||||
@@ -37,6 +37,7 @@
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="stack-tags">
|
||||
<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}}
|
||||
{{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}}
|
||||
{{$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}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if $im}}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user