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"}}
-
{{.Name}} diff --git a/controller/internal/web/templates/icons.html b/controller/internal/web/templates/icons.html index df0ac88..8667297 100644 --- a/controller/internal/web/templates/icons.html +++ b/controller/internal/web/templates/icons.html @@ -14,6 +14,7 @@ + diff --git a/controller/internal/web/templates/launcher.html b/controller/internal/web/templates/launcher.html new file mode 100644 index 0000000..1f087e5 --- /dev/null +++ b/controller/internal/web/templates/launcher.html @@ -0,0 +1,43 @@ +{{define "launcher"}} +{{template "layout_start" .}} + + + +{{if .Apps}} + +{{else}} +
+

Még nincs telepített alkalmazás.

+

Alkalmazások telepítése

+
+{{end}} + +{{template "layout_end"}} +{{end}} diff --git a/controller/internal/web/templates/layout.html b/controller/internal/web/templates/layout.html index c2f6c85..2260c95 100644 --- a/controller/internal/web/templates/layout.html +++ b/controller/internal/web/templates/layout.html @@ -68,6 +68,7 @@ {{.CustomerName}}