From 987e915bf2a4072c46f4272d14ee4519c55bffba Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Fri, 24 Jul 2026 09:16:28 +0200 Subject: [PATCH] =?UTF-8?q?Ind=C3=ADt=C3=B3pult=20launcher=20page=20+=20un?= =?UTF-8?q?iversal=20app=20placeholder=20icon=20(v0.163.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New /launcher page: a grid of large tappable tiles, one per openable deployed app (subdomain presence is the single openability criterion, shared with the Megnyitás button via the extracted Server.subdomainMap helper). Colored tiles (deterministic slug color or .felhom.yml brand_color), white glyph/monogram fallback, target=_blank links for operational apps, greyed unclickable tiles for stopped ones. First sidebar item; / stays the Vezérlőpult. Universal app placeholder: new AppPlaceholderSVG served at /static/app-placeholder.svg, now the default FallbackIcon on app_list_row so a logo-less app shows a placeholder instead of visibility:hidden. Brand mark is never an app placeholder. New Metadata.BrandColor; new funcmap tileColor/initial. 10 new test functions + 4 red-proofs. No agent coupling; MinAgent unchanged. --- CHANGELOG.md | 37 +++ CONTEXT.md | 26 +- controller/README.md | 22 ++ controller/internal/stacks/metadata.go | 5 +- controller/internal/web/app_row_test.go | 44 +++ controller/internal/web/funcmap.go | 42 +++ controller/internal/web/handlers.go | 103 +++++-- controller/internal/web/inframeta.go | 7 + controller/internal/web/launcher_test.go | 253 ++++++++++++++++++ controller/internal/web/server.go | 6 + .../internal/web/templates/app_row.html | 6 +- controller/internal/web/templates/icons.html | 1 + .../internal/web/templates/launcher.html | 43 +++ controller/internal/web/templates/layout.html | 1 + controller/internal/web/templates/style.css | 62 +++++ 15 files changed, 632 insertions(+), 26 deletions(-) create mode 100644 controller/internal/web/launcher_test.go create mode 100644 controller/internal/web/templates/launcher.html diff --git a/CHANGELOG.md b/CHANGELOG.md index 34d9aa0..1b1c7da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,42 @@ ## Changelog +### v0.163.0 — Indítópult (app launcher page) + universal app placeholder icon (2026-07-24) + +No agent coupling; MinAgent unchanged. Adds a customer-facing **Indítópult** launcher grid and a +generic fallback icon for logo-less apps on every list surface. + +**Indítópult (`/launcher`, new FIRST sidebar item, above Vezérlőpult):** +- A grid of large tappable tiles, one per openable deployed app. The rule is intentionally the same + one the „Megnyitás" button already uses: a tile exists **⟺** the stack has a subdomain (env + `SUBDOMAIN` > `.felhom.yml` subdomain > `protectedStackSubdomains`). The controller's own stack is + excluded by name. `/` still lands on the Vezérlőpult — the launcher is an ADDITIONAL page. +- Tiles are colored rounded squares: a deterministic per-app color (FNV-1a of the slug → HSL hue, + fixed S/L tuned for the dark theme), overridable with an optional `.felhom.yml` `brand_color` + (`#rgb`/`#rrggbb`; an invalid value silently falls back to the slug color). The existing white + monochrome logo renders on top; a logo-less app reveals the **monogram** initial underneath + (multibyte-safe — „Óra" → „Ó"). +- Operational apps are a real `` to the public URL (with + `open_path`); stopped/exited/degraded apps render a **greyed, unclickable** tile with the honest + Hungarian state badge — never a dead link. Empty state: „Még nincs telepített alkalmazás." + a link + to `/stacks`. +- New template funcs `tileColor` (returns a `template.CSS` — validated/computed in Go, because the + html/template CSS filter mangles a legitimate `hsl()` from a func pipeline) and `initial`. + +**Universal app placeholder icon:** +- New embedded `AppPlaceholderSVG` (2×2 rounded-square app-grid glyph), served at + `/static/app-placeholder.svg`. The canonical `app_list_row` now DEFAULTS its fallback to it, so a + catalog app with a missing logo shows a generic placeholder on every list surface instead of the + old `visibility:hidden` dead-end. Infra rows still override with `/static/infra-logo.svg`. +- Design ruling: the felhom brand mark is NEVER an app placeholder (brand = platform identity only). + +**Refactor (in-scope, single reason):** the subdomain-map assembly that lived inline twice +(dashboard + Alkalmazások) is extracted to `Server.subdomainMap`; both call sites plus the launcher +now share it (byte-for-byte priority unchanged). + +**Metadata:** `stacks.Metadata` gains `BrandColor` (`brand_color`, omitempty). No catalog app sets it +yet (curation is a parked follow-up). + + ### v0.162.0 — R-71(a): the apply-bridge waits for the dust to settle (settle-gate) (2026-07-24) No agent coupling; MinAgent unchanged. Origin: diff --git a/CONTEXT.md b/CONTEXT.md index 62db8d4..7ae6e98 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -7,7 +7,31 @@ > > Ask Claude Code: "Please update CONTEXT.md with what we did today" -Last updated: 2026-07-24 (v0.162.0 — R-71a: the apply-bridge settle-gate) +Last updated: 2026-07-24 (v0.163.0 — Indítópult launcher + universal app placeholder) + +> **2026-07-24 — v0.163.0 (Indítópult app launcher + universal placeholder icon).** New +> customer-facing `/launcher` page: the FIRST sidebar item (above Vezérlőpult), a grid of large +> tappable tiles for openable deployed apps. `/` stays the Vezérlőpult — the launcher is ADDITIVE. +> Design rulings recorded here: +> - **(a) The felhom brand mark is NEVER an app placeholder** — brand = platform identity only. The +> logo-less fallback everywhere is the new generic `AppPlaceholderSVG` (a 2×2 app-grid glyph, +> `/static/app-placeholder.svg`), now the DEFAULT `FallbackIcon` on `app_list_row` (was +> `visibility:hidden`). On the launcher tile the fallback is the **monogram**, not the placeholder. +> - **(b) A launcher tile exists ⟺ a „Megnyitás" button would** — subdomain presence (env `SUBDOMAIN` +> > `.felhom.yml` subdomain > `protectedStackSubdomains`) is the single openability criterion. The +> controller stack is excluded by name. The subdomain assembly was extracted to +> `Server.subdomainMap` (3 callers: dashboard, Alkalmazások, launcher; priority byte-unchanged). +> - **(c) Colored-tile + mono-glyph design.** `tileColor` = validated `.felhom.yml` `brand_color` +> (`#rgb`/`#rrggbb`, new `Metadata.BrandColor`, omitempty) OR a deterministic FNV-1a-of-slug HSL +> (fixed S/L, hue per app). Invalid `brand_color` silently falls back to the hash color (the one +> §8 exception to no-silent-failure — cosmetic). `tileColor` returns `template.CSS` (we +> validate/compute in Go; html/template's CSS filter mangles a legit `hsl()` from a func pipeline). +> - **(d) `/` remains the Vezérlőpult.** No role/auth gating — member-role gating is a future arc +> (ROADMAP: member role → launcher becomes the member landing page). No catalog app sets +> `brand_color` yet (curation parked). +> No agent coupling; MinAgent unchanged. 10 new test functions + 4 red-proofs (all observed FAIL then +> restored). Gates green (app_row_dedup / template_id / emoji). + > **2026-07-24 — v0.162.0 (R-71a), SHIPPED + deployed BOTH boxes (demo-felhom 9201 + demo-hp 9201 > via G1 break-glass), clean+healthy, settle-gate GO line captured on both.** B′ live note: both diff --git a/controller/README.md b/controller/README.md index ee56c74..77f8b11 100644 --- a/controller/README.md +++ b/controller/README.md @@ -157,6 +157,11 @@ backups, monitoring and notifications. All Proxmox/disk operations are delegated (traefik/cloudflared/filebrowser) get curated Hungarian display identity from the `inframeta.go` map (name + description + generic `/static/infra-logo.svg` fallback icon); filebrowser is the only infra stack with a customer link (`files.`). + **Universal app placeholder (v0.163.0):** `app_list_row` now DEFAULTS its fallback icon to the + embedded `AppPlaceholderSVG` (a 2×2 app-grid glyph, served at `/static/app-placeholder.svg`), so a + logo-less app shows a placeholder on every list surface instead of a hidden icon; infra rows still + override with the server glyph. The felhom brand mark is never an app placeholder (brand = platform + identity only). **Enforcement:** `scripts/template_id_gate.py` (JS element-ID integrity) + `scripts/emoji_gate.py` (no emoji) + `scripts/native_confirm_gate.py` (zero native confirm/prompt) + `scripts/app_row_dedup_gate.py` (row markup single-sourced) + `scripts/mojibake_gate.py` @@ -256,6 +261,23 @@ When a user visits a stopped or undeployed app's subdomain (e.g., `travel.demo-f - **`catchall.html`** — standalone template (no layout, inline CSS) showing the app name, status ("leállítva" / "nincs telepítve" / "nem található"), and links to the controller dashboard or the app's detail page. - **Subdomain links** on the Alkalmazások page are only shown for deployed apps (non-deployed apps have no guaranteed subdomain yet). +#### Indítópult (app launcher page) (v0.163.0) + +`/launcher` (`launcherHandler` + `templates/launcher.html`) is the **first** sidebar item, above +Vezérlőpult; `/` still lands on the Vezérlőpult. It renders a grid of large tappable tiles — one per +**openable** deployed app. Openability has a single criterion, shared with the „Megnyitás" button: the +stack has a subdomain (env `SUBDOMAIN` > `.felhom.yml` subdomain > `protectedStackSubdomains`), +resolved through the extracted `Server.subdomainMap` helper (the dashboard and Alkalmazások pages use +the same helper). The controller's own stack is excluded by name. + +Each tile is a colored rounded square: `funcmap.tileColor(slug, brand)` returns a validated +`.felhom.yml` **`brand_color`** (`#rgb`/`#rrggbb`, `Metadata.BrandColor`) or, when absent/invalid, a +deterministic FNV-1a-of-slug → HSL color (fixed S/L, hue varies per app). The white monochrome logo +renders on top of a **monogram** initial (`funcmap.initial`, multibyte-safe); if the logo fails to +load the monogram shows through (the launcher does NOT use the app-placeholder here). Operational apps +are `` links; stopped/degraded apps render greyed + unclickable with +the Hungarian state badge. Empty state links to `/stacks`. + #### Dashboard "Megnyitás" Button Running apps on the Vezérlőpult now show a "Megnyitás ↗" button that opens the app's subdomain in a new tab. The `Subdomains` map is built in `dashboardHandler` from `app.yaml` env or metadata fallback. diff --git a/controller/internal/stacks/metadata.go b/controller/internal/stacks/metadata.go index 13b9eb4..ebb3cb1 100644 --- a/controller/internal/stacks/metadata.go +++ b/controller/internal/stacks/metadata.go @@ -16,7 +16,10 @@ type Metadata struct { Description string `yaml:"description" json:"description"` Category string `yaml:"category" json:"category"` Subdomain string `yaml:"subdomain" json:"subdomain"` - Slug string `yaml:"slug" json:"slug"` + // BrandColor is an optional launcher tile color (#rgb or #rrggbb). Invalid values fall back to + // the deterministic slug-derived color (see web.tileColor); no app sets it yet. + BrandColor string `yaml:"brand_color,omitempty" json:"brand_color,omitempty"` + Slug string `yaml:"slug" json:"slug"` // Lifecycle governs whether this app is OFFERED for new installs. It never affects an app that // is already deployed — a customer running a hidden or abandoned app keeps full function, which // is the whole point: removing a template from the catalog would orphan them instead. diff --git a/controller/internal/web/app_row_test.go b/controller/internal/web/app_row_test.go index c59c956..d065864 100644 --- a/controller/internal/web/app_row_test.go +++ b/controller/internal/web/app_row_test.go @@ -75,6 +75,50 @@ func TestAppRow_RestoreLists(t *testing.T) { } } +// Group D (v0.163.0) — the app_list_row now defaults its fallback icon to the generic app +// placeholder, so a logo-less app shows a placeholder glyph instead of the old visibility:hidden +// dead-end; an infra caller still overrides it with the server glyph. +// COMPANION red-proof (REPORT): revert app_row.html's data-fallback to the old +// `{{with .FallbackIcon}}...{{end}}` (attribute absent when no caller value) → the default-present +// assertion FAILS. +func TestAppRow_DefaultPlaceholderFallback(t *testing.T) { + // No FallbackIcon → the placeholder is the default. + def := renderPartial(t, "app_list_row", map[string]interface{}{"Slug": "no-logo-app", "Name": "Logótlan"}) + if !strings.Contains(def, `data-fallback="/static/app-placeholder.svg"`) { + t.Errorf("a row without FallbackIcon must default to the app placeholder, got:\n%s", def) + } + // A caller-provided FallbackIcon (infra) still wins. + infra := renderPartial(t, "app_list_row", + map[string]interface{}{"Slug": "filebrowser", "Name": "FileBrowser", "FallbackIcon": "/static/infra-logo.svg"}) + if !strings.Contains(infra, `data-fallback="/static/infra-logo.svg"`) { + t.Errorf("an explicit FallbackIcon must be preserved, got:\n%s", infra) + } + if strings.Contains(infra, "/static/app-placeholder.svg") { + t.Error("an infra row must not also carry the app placeholder") + } +} + +// The placeholder asset is served with the SVG content-type + long cache, like the infra logo. +func TestAppPlaceholderRoute(t *testing.T) { + s := testPageServer(t) + rec := getPage(t, s, "/static/app-placeholder.svg") + if rec.Code != 200 { + t.Fatalf("GET /static/app-placeholder.svg = %d", rec.Code) + } + if ct := rec.Header().Get("Content-Type"); ct != "image/svg+xml" { + t.Errorf("content-type = %q, want image/svg+xml", ct) + } + if !strings.Contains(rec.Body.String(), " metadata > well-known fallback) - subdomains := make(map[string]string) - for _, stack := range s.stackMgr.GetStacks() { - if stack.Deployed { - if appCfg := s.stackMgr.LoadAppConfigByName(stack.Name); appCfg != nil { - if sd, ok := appCfg.Env["SUBDOMAIN"]; ok && sd != "" { - subdomains[stack.Name] = sd - continue - } - } - } - if stack.Meta.Subdomain != "" { - subdomains[stack.Name] = stack.Meta.Subdomain - } else if sd, ok := protectedStackSubdomains[stack.Name]; ok { - subdomains[stack.Name] = sd - } - } - data["Subdomains"] = subdomains + data["Subdomains"] = s.subdomainMap(s.stackMgr.GetStacks()) s.executeTemplate(w, r, "stacks", data) } diff --git a/controller/internal/web/inframeta.go b/controller/internal/web/inframeta.go index 0db05b1..537432e 100644 --- a/controller/internal/web/inframeta.go +++ b/controller/internal/web/inframeta.go @@ -52,3 +52,10 @@ func infraMetaFor(stackName string) *InfraMeta { // --text-2 stroke) — the last-resort icon for infra stacks whose logo asset is not // synced. Served at /static/infra-logo.svg; consumed via the app-row FallbackIcon slot. const InfraLogoSVG = `` + +// AppPlaceholderSVG is the generic APP placeholder icon — a 2×2 rounded-square app-grid, matching +// InfraLogoSVG's monochrome --text-2 visual language but a distinct glyph (apps, not server racks). +// It is the default FallbackIcon on every app-list row for a regular app whose logo asset is missing, +// replacing the old visibility:hidden dead-end. NEVER the felhom brand mark: the brand is platform +// identity, never an app stand-in (design ruling, CONTEXT.md). Served at /static/app-placeholder.svg. +const AppPlaceholderSVG = `` diff --git a/controller/internal/web/launcher_test.go b/controller/internal/web/launcher_test.go new file mode 100644 index 0000000..103beed --- /dev/null +++ b/controller/internal/web/launcher_test.go @@ -0,0 +1,253 @@ +package web + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" +) + +// Indítópult (launcher, v0.163.0). The launcher tile exists ⟺ a "Megnyitás" button would exist: +// subdomain presence is the single openability criterion (buildLauncherApps). These tests drive the +// pure builder (deterministic — no docker) for the filter/sort/exclusion logic, and the production +// launcher template for the rendered tile grammar (hrefs, greyed-stopped, monogram, tile color). + +// launcherStacks models Scenario A: two openable apps (env-subdomain + meta-subdomain), one protected +// app with a well-known subdomain, one deployed app with NO subdomain, and the controller itself with +// a subdomain that must still be excluded by name. +func launcherStacks() []stacks.Stack { + return []stacks.Stack{ + {Name: "worker-app", Deployed: true, State: stacks.StateRunning, + Meta: stacks.Metadata{Slug: "worker-app", DisplayName: "Worker"}}, // no subdomain → excluded + {Name: "mealie", Deployed: true, State: stacks.StateRunning, + Meta: stacks.Metadata{Slug: "mealie", DisplayName: "Mealie", Subdomain: "recept"}}, + {Name: "paperless-ngx", Deployed: true, State: stacks.StateRunning, + Meta: stacks.Metadata{Slug: "paperless-ngx", DisplayName: "Paperless"}}, // subdomain via env map + {Name: "filebrowser", Protected: true, State: stacks.StateRunning, + Meta: stacks.Metadata{Slug: "filebrowser", DisplayName: "FileBrowser"}}, // protectedStackSubdomains + {Name: "felhom-controller", Deployed: true, State: stacks.StateRunning, + Meta: stacks.Metadata{Slug: "felhom-controller", DisplayName: "Vezérlő", Subdomain: "felhom"}}, + } +} + +func launcherSubdomains() map[string]string { + return map[string]string{ + "mealie": "recept", + "paperless-ngx": "papir", + "filebrowser": "files", + "felhom-controller": "felhom", // present on purpose — must be excluded by name anyway + // worker-app: deliberately absent + } +} + +// Group A — the builder selects exactly the openable apps, sorted by DisplayName, controller excluded. +// COMPANION red-proof (REPORT): drop the `if !ok || sd == ""` continue in buildLauncherApps → +// worker-app (no subdomain) leaks in and the "worker absent" assertion FAILS. +func TestBuildLauncherApps_SelectionAndOrder(t *testing.T) { + apps := buildLauncherApps(launcherStacks(), launcherSubdomains()) + + var names []string + for _, a := range apps { + names = append(names, a.Name) + } + // Openable set = mealie, paperless-ngx, filebrowser — sorted by DisplayName: + // FileBrowser < Mealie < Paperless. + wantOrder := []string{"filebrowser", "mealie", "paperless-ngx"} + if strings.Join(names, ",") != strings.Join(wantOrder, ",") { + t.Fatalf("launcher apps = %v, want %v (alphabetical by DisplayName)", names, wantOrder) + } + for _, a := range apps { + if a.Name == "worker-app" { + t.Error("worker-app has no subdomain — it must not be launchable") + } + if a.Name == "felhom-controller" { + t.Error("the controller stack must never appear on its own launcher") + } + } + // The subdomain is resolved from the lookup, not the metadata blindly (paperless env=papir). + for _, a := range apps { + if a.Name == "paperless-ngx" && a.Subdomain != "papir" { + t.Errorf("paperless subdomain = %q, want papir (from the lookup)", a.Subdomain) + } + } +} + +// Group A (render) — each openable tile is an to its public URL with OpenPath and target=_blank. +func TestLauncherTemplate_OpenableTiles(t *testing.T) { + data := map[string]interface{}{ + "Page": "launcher", "Title": "Indítópult", "Domain": "demo-felhom.eu", + "Apps": []LauncherApp{ + {Name: "mealie", DisplayName: "Mealie", Slug: "mealie", State: stacks.StateRunning, Subdomain: "recept"}, + {Name: "gokapi", DisplayName: "Gokapi", Slug: "gokapi", State: stacks.StateRunning, Subdomain: "fajl", OpenPath: "/admin"}, + }, + } + html := renderBackupPage(t, "launcher", data) + if !strings.Contains(html, `href="https://recept.demo-felhom.eu"`) { + t.Error("mealie tile must link to its public URL") + } + if !strings.Contains(html, `href="https://fajl.demo-felhom.eu/admin"`) { + t.Error("gokapi tile must append OpenPath to its public URL") + } + if !strings.Contains(html, `target="_blank"`) || !strings.Contains(html, `rel="noopener"`) { + t.Error("openable tiles must open in a new tab with rel=noopener") + } +} + +// Group B — a stopped app renders a greyed tile with the state badge and NO href (Scenario B). +// COMPANION red-proof (REPORT): in launcher.html render the branch unconditionally (drop the +// isOperational guard) → the "no href for the stopped tile" assertion FAILS. +func TestLauncherTemplate_StoppedTileGreyedNoLink(t *testing.T) { + data := map[string]interface{}{ + "Page": "launcher", "Title": "Indítópult", "Domain": "demo-felhom.eu", + "Apps": []LauncherApp{ + {Name: "jellyfin", DisplayName: "Jellyfin", Slug: "jellyfin", State: stacks.StateStopped, Subdomain: "media"}, + }, + } + html := renderBackupPage(t, "launcher", data) + if strings.Contains(html, "media.demo-felhom.eu") { + t.Error("a stopped app must not be a link (no href to its subdomain)") + } + if !strings.Contains(html, "launch-cell--off") || !strings.Contains(html, "launch-tile--off") { + t.Error("a stopped tile must carry the greyed classes") + } + if !strings.Contains(html, "Leállítva") { + t.Error("a stopped tile must show its Hungarian stateLabel badge") + } +} + +// Group C — tile color safety: valid brand passes through, an injection payload becomes a hash HSL +// and never appears verbatim in the rendered HTML. +// COMPANION red-proof (REPORT): make tileColor return `brand` unvalidated → the payload substring +// appears in the rendered style and the "payload absent" assertion FAILS. +func TestLauncherTemplate_TileColorSafety(t *testing.T) { + const payload = "red;background:url(x)" + data := map[string]interface{}{ + "Page": "launcher", "Title": "Indítópult", "Domain": "demo-felhom.eu", + "Apps": []LauncherApp{ + {Name: "jelly", DisplayName: "Jelly", Slug: "jelly", State: stacks.StateRunning, Subdomain: "j", BrandColor: "#00A4DC"}, + {Name: "evil", DisplayName: "Evil", Slug: "evil", State: stacks.StateRunning, Subdomain: "e", BrandColor: payload}, + }, + } + html := renderBackupPage(t, "launcher", data) + if !strings.Contains(html, "background: #00A4DC") { + t.Error("a valid brand_color must pass through verbatim as the tile background") + } + if strings.Contains(html, payload) { + t.Errorf("the injection payload must never reach the rendered HTML, got it in:\n%s", html) + } + if !strings.Contains(html, "background: hsl(") { + t.Error("an invalid brand_color must fall back to the deterministic hash HSL") + } +} + +func TestTileColor(t *testing.T) { + // Valid overrides pass through verbatim. + for _, ok := range []string{"#fff", "#FFF", "#00A4DC", "#00a4dc"} { + if got := string(tileColor("slug", ok)); got != ok { + t.Errorf("tileColor(_, %q) = %q, want passthrough", ok, got) + } + } + // Invalid overrides fall back to a deterministic HSL derived from the slug only. + for _, bad := range []string{"", "red", "#12", "#1234", "red;background:url(x)", "0083D8"} { + got := string(tileColor("nextcloud", bad)) + if !strings.HasPrefix(got, "hsl(") { + t.Errorf("tileColor(_, %q) = %q, want hsl() fallback", bad, got) + } + } + // Determinism: same slug ⇒ same color; different slugs generally differ. + if tileColor("nextcloud", "") != tileColor("nextcloud", "") { + t.Error("tileColor must be deterministic for a given slug") + } + if tileColor("nextcloud", "") == tileColor("paperless-ngx", "") { + t.Error("distinct slugs collided (acceptable in theory, but these two must not for the fixture)") + } +} + +func TestInitial(t *testing.T) { + cases := map[string]string{ + "Óra": "Ó", // multibyte — must not split the byte + "nextcloud": "N", + "7days": "7", + "": "?", + "árvíztűrő": "Á", + } + for in, want := range cases { + if got := initial(in); got != want { + t.Errorf("initial(%q) = %q, want %q", in, got, want) + } + } +} + +// Group E — zero openable apps ⇒ the calm empty state with the /stacks link. +func TestLauncherTemplate_EmptyState(t *testing.T) { + data := map[string]interface{}{ + "Page": "launcher", "Title": "Indítópult", "Domain": "demo-felhom.eu", + "Apps": []LauncherApp{}, + } + html := renderBackupPage(t, "launcher", data) + if !strings.Contains(html, "Még nincs telepített alkalmazás.") { + t.Error("empty launcher must show the calm empty-state copy") + } + if !strings.Contains(html, `href="/stacks"`) { + t.Error("empty launcher must link to /stacks") + } +} + +// End-to-end wiring — GET /launcher through the real ServeHTTP route with disk-seeded stacks: +// the route is registered, the handler builds tiles, the nav marks Indítópult active, and a +// subdomain-less app never appears. State is not asserted here (docker-dependent); the deterministic +// selection/order/state assertions live in the builder + template tests above. +func TestLauncherRoute_EndToEnd(t *testing.T) { + s := testPageServer(t) + stacksDir := s.cfg.Paths.StacksDir + + // openable: meta subdomain present + writeStack(t, stacksDir, "recept-app", "display_name: Receptek\nsubdomain: recept\n", true) + // not openable: no subdomain anywhere + writeStack(t, stacksDir, "worker-app", "display_name: Worker\n", true) + + if err := s.stackMgr.ScanStacks(); err != nil { + t.Logf("ScanStacks (docker status step may fail on a docker-less host): %v", err) + } + if _, ok := s.stackMgr.GetStack("recept-app"); !ok { + t.Fatal("recept-app not discovered by ScanStacks") + } + + rec := getPage(t, s, "/launcher") + if rec.Code != 200 { + t.Fatalf("GET /launcher = %d: %s", rec.Code, rec.Body.String()) + } + body := rec.Body.String() + if !strings.Contains(body, "Receptek") { + t.Error("the openable app must appear on the launcher") + } + if strings.Contains(body, "Worker") { + t.Error("a subdomain-less app must not appear on the launcher") + } + // Nav: Indítópult present and marked active on this page. + if !strings.Contains(body, `href="/launcher" class="active"`) { + t.Error("the Indítópult nav entry must be marked active on /launcher") + } +} + +// writeStack plants a minimal deployable stack dir (.felhom.yml + compose + app.yaml deployed). +func writeStack(t *testing.T, stacksDir, name, meta string, deployed bool) { + t.Helper() + dir := filepath.Join(stacksDir, name) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, ".felhom.yml"), []byte(meta), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "docker-compose.yml"), []byte("services: {}\n"), 0o644); err != nil { + t.Fatal(err) + } + if deployed { + if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("deployed: true\nenv: {}\n"), 0o644); err != nil { + t.Fatal(err) + } + } +} diff --git a/controller/internal/web/server.go b/controller/internal/web/server.go index 56d97b9..98fc897 100644 --- a/controller/internal/web/server.go +++ b/controller/internal/web/server.go @@ -345,6 +345,8 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.handleClaimRequestNewCode(w, r) case path == "/" || path == "/dashboard": s.dashboardHandler(w, r) + case path == "/launcher": + s.launcherHandler(w, r) case path == "/stacks": s.stacksHandler(w, r) case path == "/backups": @@ -491,6 +493,10 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "image/svg+xml") w.Header().Set("Cache-Control", "public, max-age=86400") fmt.Fprint(w, InfraLogoSVG) + case path == "/static/app-placeholder.svg": + w.Header().Set("Content-Type", "image/svg+xml") + w.Header().Set("Cache-Control", "public, max-age=86400") + fmt.Fprint(w, AppPlaceholderSVG) case strings.HasPrefix(path, "/static/assets/"): s.serveAsset(w, r, strings.TrimPrefix(path, "/static/assets/")) case strings.HasPrefix(path, "/apps/"): diff --git a/controller/internal/web/templates/app_row.html b/controller/internal/web/templates/app_row.html index 3067df8..0929a10 100644 --- a/controller/internal/web/templates/app_row.html +++ b/controller/internal/web/templates/app_row.html @@ -9,13 +9,15 @@ dict keys: Slug (icon lookup), Name, Secondary (optional one-liner), RowClass (optional extra row class, e.g. stack-state-run), Href (optional data-href), - FallbackIcon (optional last-resort icon URL — infra stacks pass the generic infra SVG). + FallbackIcon (optional last-resort icon URL). DEFAULTS to /static/app-placeholder.svg (the + generic app-grid glyph) when a caller omits it — so a logo-less app shows a placeholder, not a + hidden dead-end. Infra rows pass /static/infra-logo.svg to override that with the server glyph. Do NOT hand-roll app rows — scripts/app_row_dedup_gate.py asserts this markup exists here ONCE (the backups_apps expander header is the single allowlisted aligned copy). */}} {{define "app_list_row"}}
-