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:
2026-10-08 14:32:06 +02:00
parent 05e129267a
commit d5f2e47ea1
8 changed files with 330 additions and 9 deletions
+28
View File
@@ -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
+1
View File
@@ -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
+46 -1
View File
@@ -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")
}
+2 -2
View File
@@ -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}}
+31 -3
View File
@@ -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; }