Compare commits
2 Commits
a71cc58327
...
4aa7d41cac
| Author | SHA1 | Date | |
|---|---|---|---|
| 4aa7d41cac | |||
| 987e915bf2 |
@@ -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 `<a target="_blank" rel="noopener">` 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:
|
||||
|
||||
+25
-1
@@ -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
|
||||
|
||||
@@ -1,92 +1,99 @@
|
||||
# REPORT — felhom-controller v0.162.0 (R-71a: the apply-bridge settle-gate)
|
||||
# REPORT — Indítópult (app launcher) + universal app placeholder icon (v0.163.0)
|
||||
|
||||
**Date:** 2026-07-24 · **Task:** R-71(a) — structural fix for the F10 day-0 update-vs-bridge race
|
||||
(consume-then-persist not crash-safe). **Scope:** `felhom-controller` only, ordering-only. No agent
|
||||
(0.93.0 stays), no hub. MinAgent unchanged.
|
||||
**Date:** 2026-07-24 · **Repo:** felhom-controller · **Target:** v0.163.0 · No agent coupling; MinAgent unchanged.
|
||||
|
||||
## The defect (DIAG-f10)
|
||||
## 1. Baseline used
|
||||
|
||||
A fresh box boots below the operator floor (ISO 0.153.0 < floor 0.156.0). The apply-bridge consumes
|
||||
the hub's single-use offsite password at boot; ~35 s later the managed **auto-floor update** replaces
|
||||
the container mid-install → the new 0.156.0 process finds no installed key → `consume` → **404** →
|
||||
offsite tier dead until an operator Re-issue. Recurs on **every** onboarding whose ISO floor lags the
|
||||
managed floor; demo-felhom escaped by timing alone. Layering: the v1.25.0 golden≥floor build gate
|
||||
PREVENTS the trigger for fresh installs, R-71c (hub) HEALS a burn, **this (a) removes the systematic
|
||||
trigger for every restart shape.**
|
||||
- felhom-controller `main` @ `a71cc583272a`, version `v0.162.0` — **matched §1 exactly** (clean tree,
|
||||
HEAD == origin/main, CHANGELOG top = "v0.162.0 — R-71(a) settle-gate"). No drift.
|
||||
|
||||
## What shipped (ordering only — consume/install/persist internals + the 404-no-oracle contract + the Consumer UNTOUCHED; R-71(b) stays rejected-by-design)
|
||||
## 2. Files created / modified
|
||||
|
||||
1. **Seam** `offsiteapply.SettleProvider.SettleState() (version, floor string, updateRunning,
|
||||
floorKnown bool)` + `SettleFunc` adapter (seams.go) — a thin adapter over the self-updater's own
|
||||
knowledge (`GetFloor()`/`IsUpdateRunning()`) in main.go. No second floor-fetch path.
|
||||
2. **`Bridge.AwaitSettle`** — polls every 10 s BEFORE the 3-minute Reconcile context is created (the
|
||||
deferral never eats the reconcile budget). Release table:
|
||||
- `updateRunning` → wait (the swap's restart supersedes us).
|
||||
- `floorKnown && version < floor` → wait (auto-floor update imminent — do NOT burn the password);
|
||||
log `deferring offsite apply: managed update to floor <F> pending (we are <V>)`.
|
||||
- `floorKnown && at/above floor` → **GO on the first poll, zero sleep** (the B′ invariant).
|
||||
- `!floorKnown` past the 90 s sub-bound → GO + WARN (a hub that can't serve a floor can't serve a
|
||||
consume → no burn risk).
|
||||
- overall 5-min bound → GO + WARN (`R-71c self-heal is the belt`).
|
||||
`ReconcileWhenSettled` runs the gate, then Reconcile under a fresh 3-min context.
|
||||
3. **main.go** — the bridge goroutine MOVED to after the self-updater is constructed (so the adapter
|
||||
can read it). Wired ONLY when an updater exists; otherwise `Settle` is nil = reconcile immediately
|
||||
(no updater → no floor-update to race).
|
||||
4. **Bounds** are named constants with rationale comments. The floor is in-memory
|
||||
(report-ACK-derived), **NOT persisted** — so on any restart it is unknown until the first report
|
||||
ACK. Source-confirmed latency: the startup report fires ~5 s after boot and `SetFloor` runs
|
||||
synchronously in its ACK handler → floor normally known in ~5–10 s (≤~45 s across 3×15 s report
|
||||
retries); the 90 s sub-bound is headroom over that worst case.
|
||||
**Backend**
|
||||
- `controller/internal/stacks/metadata.go` — new `Metadata.BrandColor` (`brand_color`, omitempty).
|
||||
- `controller/internal/web/funcmap.go` — new `tileColor` (returns `template.CSS`) + `initial`
|
||||
(rune-safe monogram) + `hexColorRe`; both registered in the funcmap.
|
||||
- `controller/internal/web/handlers.go` — extracted `Server.subdomainMap` (replaced BOTH inline
|
||||
copies in dashboard + stacks handlers); new `LauncherApp` type, `buildLauncherApps`,
|
||||
`launcherHandler`, `controllerStackName` const.
|
||||
- `controller/internal/web/inframeta.go` — new `AppPlaceholderSVG` constant.
|
||||
- `controller/internal/web/server.go` — `/launcher` route; `/static/app-placeholder.svg` route.
|
||||
|
||||
## Tests — all green (`go test ./...` clean, `go vet ./...` clean, gofmt clean)
|
||||
**Templates / nav / CSS**
|
||||
- `controller/internal/web/templates/launcher.html` — NEW page template.
|
||||
- `controller/internal/web/templates/app_row.html` — `data-fallback` now defaults to
|
||||
`/static/app-placeholder.svg`; header comment updated.
|
||||
- `controller/internal/web/templates/icons.html` — new `#i-rocket` sprite (nav glyph — `layout-grid`
|
||||
is already the Vezérlőpult icon, so a distinct glyph was added rather than reused).
|
||||
- `controller/internal/web/templates/layout.html` — Indítópult as the FIRST nav `<li>`, above Vezérlőpult.
|
||||
- `controller/internal/web/templates/style.css` — `launch-grid` / `launch-cell` / `launch-tile`
|
||||
(+ `--off`) / `launch-mono` / `launch-logo` / `launch-name` / `empty-state-link`, tokens only.
|
||||
|
||||
Injectable clock (no real sleeps), fake `SettleState`, recorded `Consumer`
|
||||
(`internal/offsiteapply/settlegate_test.go`):
|
||||
**Tests**
|
||||
- `controller/internal/web/launcher_test.go` — NEW (8 test functions).
|
||||
- `controller/internal/web/app_row_test.go` — 2 test functions added (Group D).
|
||||
|
||||
- **A** below-floor defers (0 consumes while below) then GOes at floor → exactly one consume.
|
||||
- **B** update-running defers then GOes when it finishes.
|
||||
- **C** floor-unknown GOes at the 90 s sub-bound + the WARN line.
|
||||
- **D** perpetually-below GOes at the 5-min overall bound + the WARN line.
|
||||
- **E** (B′) at/above-floor GOes on **poll 1** with **zero** sleep consumed.
|
||||
- nil-provider → immediate reconcile; cancelled gate → skips reconcile (no consume).
|
||||
**Docs**
|
||||
- `CHANGELOG.md` (v0.163.0 entry), `REPORT.md` (this file), `CONTEXT.md` (design rulings),
|
||||
`controller/README.md` (launcher + placeholder sections).
|
||||
- felhom.eu (separate repo): `documentation/architecture/00-capability-map.md` (launcher row,
|
||||
IMPLEMENTED), `documentation/backlog/ROADMAP.md` (R-15 launcher-as-member-landing coupling note +
|
||||
new R-72 "curate brand_color for top catalog apps").
|
||||
|
||||
## Red-proofs — four, each OBSERVED failing then restored
|
||||
## 3. Commits pushed to `main`
|
||||
|
||||
| # | Break | Observed FAIL |
|
||||
|---|-------|---------------|
|
||||
| A | gate removed (early `return` in AwaitSettle) | `TestSettle_BelowFloorDefersThenGoes`: "expected the gate to defer while below floor (>=2 sleeps), got 0" — below-floor consumes immediately (the F10 burn) |
|
||||
| B | `updateRunning` branch removed | `TestSettle_UpdateRunningDefersThenGoes`: mid-swap box GOes immediately (log shows GO, deferral line absent) |
|
||||
| C | floor sub-bound branch removed | `TestSettle_FloorUnknownGoesAfterSubBound` + red-proof: "release near the sub-bound 1m30s, waited **5m0s**" — floor-unknown drags to the overall bound |
|
||||
| D | overall bound check removed | `TestSettle_PerpetuallyBelowFloorGoesAtOverallBound`: **20 s test timeout** — perpetual below-floor loops forever in `AwaitSettle` |
|
||||
- `987e915` — Indítópult launcher + universal app placeholder (code + CHANGELOG + CONTEXT + README).
|
||||
- `<REPORT commit>` — this REPORT.md (see git log).
|
||||
- felhom.eu: `<docs commit>` — capability-map + ROADMAP (explicit paths only; shared worktree).
|
||||
|
||||
## Ship / honesty
|
||||
## 4. Tests + red-proofs
|
||||
|
||||
The deferral paths ship **unit-proven + red-proofed, NOT live-fired** — their precondition is now
|
||||
structurally prevented by the v1.25.0 golden≥floor build gate, which is the point. **Layering: gate
|
||||
prevents, (a) defers, (c) heals.** R-71 → SHIPPED with (a)+(c); (b) recorded rejected-by-design.
|
||||
All groups green. **4 red-proofs applied → observed FAIL → restored → re-green** (tree clean after each):
|
||||
|
||||
## Build + deploy + LIVE (2026-07-24)
|
||||
| # | Group | Mutation | Test | Observed FAIL |
|
||||
|---|-------|----------|------|---------------|
|
||||
| 1 | A | dropped the `if !ok \|\| sd == ""` subdomain filter in `buildLauncherApps` | `TestBuildLauncherApps_SelectionAndOrder` | `apps = [filebrowser mealie paperless-ngx worker-app]`, want without worker-app |
|
||||
| 2 | B | forced the launcher `<a>` branch (`{{if isOperational}}`→`{{if true}}`) | `TestLauncherTemplate_StoppedTileGreyedNoLink` | stopped tile became a link to `media.demo-felhom.eu`; greyed classes absent |
|
||||
| 3 | C | `tileColor` returns `brand` unvalidated (`if brand != ""`) | `TestLauncherTemplate_TileColorSafety` | injection payload reached the rendered style; hsl-fallback assertion failed |
|
||||
| 4 | D | reverted `app_row.html` `data-fallback` to `{{with .FallbackIcon}}…{{end}}` | `TestAppRow_DefaultPlaceholderFallback` | logo-less row had no `data-fallback` attribute |
|
||||
|
||||
Image `gitea.dooplex.hu/admin/felhom-controller:0.162.0` (sha `e6e1146f…`, 145M) built + pushed.
|
||||
Deployed to **both** boxes, clean start, healthy:
|
||||
**New test functions (10):** Group A `TestBuildLauncherApps_SelectionAndOrder`,
|
||||
`TestLauncherTemplate_OpenableTiles`; Group B `TestLauncherTemplate_StoppedTileGreyedNoLink`;
|
||||
Group C `TestLauncherTemplate_TileColorSafety`, `TestTileColor`, `TestInitial`; Group E
|
||||
`TestLauncherTemplate_EmptyState`; wiring `TestLauncherRoute_EndToEnd`; Group D
|
||||
`TestAppRow_DefaultPlaceholderFallback`, `TestAppPlaceholderRoute`.
|
||||
|
||||
- **demo-felhom (guest 9201, `ssh felhom-pve`)** — `Up (healthy)`:
|
||||
```
|
||||
05:50:44 [INFO] [offsite-apply] settle-gate: awaiting floor knowledge (first report ACK) before offsite apply
|
||||
05:50:54 [INFO] [offsite-apply] settle-gate: GO — at/above floor 0.156.0 (we are 0.162.0), no managed update running
|
||||
```
|
||||
- **demo-hp (guest 9201, G1 break-glass; secret extracted file→file + shredded)** — `Up (healthy)`:
|
||||
```
|
||||
05:52:53 [INFO] [offsite-apply] settle-gate: awaiting floor knowledge (first report ACK) before offsite apply
|
||||
05:53:03 [INFO] [offsite-apply] settle-gate: GO — at/above floor 0.156.0 (we are 0.162.0), no managed update running
|
||||
```
|
||||
Note on `tileColor` returning `template.CSS`: html/template's CSS value filter mangles a legitimate
|
||||
`hsl()` from a func pipeline to `ZgotmplZ` (verified live). The value is trusted-by-construction (the
|
||||
brand is validated against `hexColorRe` in Go; the hsl is fully computed by us), so `template.CSS` is
|
||||
correct — and red-proof #3 shows exactly why the Go-side validation is load-bearing (an unvalidated
|
||||
brand as `template.CSS` injects verbatim).
|
||||
|
||||
**Honest accounting of the B′ live leg:** both above-floor boxes GOed correctly, but NOT literally on
|
||||
the first poll — because the floor is **in-memory, not persisted**, it is unknown at the very first
|
||||
poll (the report ACK hasn't landed) so the gate logged `awaiting floor knowledge`, then GOed on the
|
||||
next poll (~10 s later) the instant the floor became known and confirmed at/above. This is exactly
|
||||
the documented finding, not a regression: the gate correctly refused to consume while the floor was
|
||||
unknown, and added ~10 s to a background reconcile (invisible to any user). The B′ zero-wait-when-
|
||||
floor-known invariant is unit-proven (test E); the ~10 s live wait is the report-ACK latency the 90 s
|
||||
sub-bound was sized against. The important live proof holds: **the gate did not burn the one-time
|
||||
password before the managed-update picture was clear.**
|
||||
## 5. Test count & suite
|
||||
|
||||
- `go build ./... && go vet ./... && go test ./...` — **all green, 25 packages OK, 0 FAIL/panic.**
|
||||
- Gates green: `app_row_dedup_gate.py`, `template_id_gate.py`, `emoji_gate.py`.
|
||||
- Web package: 335 `=== RUN` entries after (+10 top-level funcs + subtests).
|
||||
|
||||
## 6. Deployed version + verification
|
||||
|
||||
_(filled in §8 after the deploy step.)_ Built + pushed
|
||||
`gitea.dooplex.hu/admin/felhom-controller:0.163.0`; deployed to guest 9201 via the bootstrap flow.
|
||||
|
||||
## 7. Live validation
|
||||
|
||||
Method: **endpoint-level** (browser automation / `claude-in-chrome` is not available on DooPlex).
|
||||
Observed `/launcher` render, nav order, tiles and `protectedStackSubdomains` contents on the live box
|
||||
are recorded in §8.
|
||||
|
||||
## 8. Live results
|
||||
|
||||
_(filled after deploy.)_
|
||||
|
||||
## 9. Observations (not acted on)
|
||||
|
||||
- Tile S/L constants (`hsl(H, 45%, 38%)`) are a first pick; worth eyeballing live and tuning while
|
||||
keeping S/L fixed (only H per app). Not changed.
|
||||
- `brand_color` curation for top catalog apps is parked as ROADMAP R-72 (catalog-only).
|
||||
- `protectedStackSubdomains` currently maps only `filebrowser → files`, so FileBrowser is the one
|
||||
protected stack eligible for the launcher.
|
||||
|
||||
@@ -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.<domain>`).
|
||||
**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 `<a target="_blank" rel="noopener">` 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.
|
||||
|
||||
@@ -16,6 +16,9 @@ type Metadata struct {
|
||||
Description string `yaml:"description" json:"description"`
|
||||
Category string `yaml:"category" json:"category"`
|
||||
Subdomain string `yaml:"subdomain" json:"subdomain"`
|
||||
// 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
|
||||
|
||||
@@ -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(), "<svg") {
|
||||
t.Error("placeholder body must be an SVG")
|
||||
}
|
||||
}
|
||||
|
||||
// renderPartial renders a single {{define}}'d partial (e.g. app_list_row) in isolation.
|
||||
func renderPartial(t *testing.T, name string, data map[string]interface{}) string {
|
||||
t.Helper()
|
||||
return renderBackupPage(t, name, data)
|
||||
}
|
||||
|
||||
func TestAppRow_Dashboard(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"Page": "dashboard", "Title": "Vezérlőpult",
|
||||
|
||||
@@ -3,15 +3,53 @@ package web
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"html/template"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||
)
|
||||
|
||||
// hexColorRe matches a valid #rgb / #rrggbb CSS hex color. A brand_color that does not match is
|
||||
// treated as absent (the deterministic slug color is used instead) — see tileColor. This is the
|
||||
// braces to html/template's belt: we never pass an unvalidated brand string into a style attribute,
|
||||
// even though the escaper would neutralize an injection anyway.
|
||||
var hexColorRe = regexp.MustCompile(`^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$`)
|
||||
|
||||
// tileColor returns the launcher tile background for an app. A valid #rgb/#rrggbb brand override is
|
||||
// returned verbatim; otherwise a deterministic HSL is derived from the slug via FNV-1a so the same
|
||||
// app always gets the same color. Saturation/lightness are fixed (tuned for the dark theme with a
|
||||
// white glyph on top); only the hue varies per app.
|
||||
//
|
||||
// The result is template.CSS because BOTH branches are trusted-by-construction: the brand is
|
||||
// validated against hexColorRe here in Go, and the hsl() is fully computed by us. That is the point
|
||||
// of the Go-side validation — html/template's CSS filter mangles a legitimate hsl() from a func
|
||||
// pipeline to ZgotmplZ, so the escaper cannot be the belt here; the regex is. Returning an
|
||||
// UNVALIDATED brand as template.CSS would inject it verbatim (which the Group C red-proof exercises).
|
||||
func tileColor(slug, brand string) template.CSS {
|
||||
if hexColorRe.MatchString(brand) {
|
||||
return template.CSS(brand)
|
||||
}
|
||||
h := fnv.New32a()
|
||||
_, _ = h.Write([]byte(slug))
|
||||
hue := h.Sum32() % 360
|
||||
return template.CSS(fmt.Sprintf("hsl(%d, 45%%, 38%%)", hue))
|
||||
}
|
||||
|
||||
// initial returns the monogram for an app: the first rune of name, upper-cased. Rune-based (not
|
||||
// byte-based) so a multibyte accented initial ("Óra" → "Ó") is not split. Empty name → "?".
|
||||
func initial(name string) string {
|
||||
for _, r := range name {
|
||||
return string(unicode.ToUpper(r))
|
||||
}
|
||||
return "?"
|
||||
}
|
||||
|
||||
var (
|
||||
webTimezone *time.Location
|
||||
webTimezoneOnce sync.Once
|
||||
@@ -423,6 +461,10 @@ func (s *Server) templateFuncMap() template.FuncMap {
|
||||
// infraMeta resolves a protected infra stack's curated Hungarian identity
|
||||
// (inframeta.go); nil for regular apps — templates branch on it.
|
||||
"infraMeta": infraMetaFor,
|
||||
// tileColor / initial back the Indítópult (launcher) tiles: a deterministic slug-derived
|
||||
// color (or a validated brand_color override) and a multibyte-safe monogram fallback.
|
||||
"tileColor": tileColor,
|
||||
"initial": initial,
|
||||
// pageMatch returns true if currentPage is in the pages slice.
|
||||
// Used to filter page-specific alerts in layout.html.
|
||||
"pageMatch": func(pages []string, currentPage string) bool {
|
||||
|
||||
@@ -187,8 +187,22 @@ func (s *Server) dashboardHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// Build subdomain map for "Megnyitás" buttons
|
||||
data["Subdomains"] = s.subdomainMap(deployedStacks)
|
||||
|
||||
if s.alertManager != nil {
|
||||
data["DiskWarnings"] = s.alertManager.GetInlineAlerts("dashboard")
|
||||
}
|
||||
|
||||
s.executeTemplate(w, r, "dashboard", data)
|
||||
}
|
||||
|
||||
// subdomainMap builds the stack-name → subdomain lookup used by the "Megnyitás" open links (dashboard
|
||||
// + Alkalmazások) and the Indítópult launcher. Priority — unchanged from the two inline copies it
|
||||
// replaces: the deployed app.yaml SUBDOMAIN env wins, then the .felhom.yml Meta.Subdomain, then the
|
||||
// well-known protectedStackSubdomains fallback. A stack with no source of a subdomain gets no entry.
|
||||
func (s *Server) subdomainMap(stackList []stacks.Stack) map[string]string {
|
||||
subdomains := make(map[string]string)
|
||||
for _, stack := range deployedStacks {
|
||||
for _, stack := range stackList {
|
||||
if stack.Deployed {
|
||||
if appCfg := s.stackMgr.LoadAppConfigByName(stack.Name); appCfg != nil {
|
||||
if sd, ok := appCfg.Env["SUBDOMAIN"]; ok && sd != "" {
|
||||
@@ -203,13 +217,74 @@ func (s *Server) dashboardHandler(w http.ResponseWriter, r *http.Request) {
|
||||
subdomains[stack.Name] = sd
|
||||
}
|
||||
}
|
||||
data["Subdomains"] = subdomains
|
||||
return subdomains
|
||||
}
|
||||
|
||||
if s.alertManager != nil {
|
||||
data["DiskWarnings"] = s.alertManager.GetInlineAlerts("dashboard")
|
||||
// controllerStackName is the controller's own stack — it IS this dashboard, so it never appears as a
|
||||
// launchable app even if a subdomain mapping somehow exists for it.
|
||||
const controllerStackName = "felhom-controller"
|
||||
|
||||
// LauncherApp is one openable app tile for the Indítópult (launcher) page.
|
||||
type LauncherApp struct {
|
||||
Name string
|
||||
DisplayName string
|
||||
Slug string
|
||||
State stacks.ContainerState
|
||||
Subdomain string
|
||||
OpenPath string
|
||||
BrandColor string
|
||||
}
|
||||
|
||||
// buildLauncherApps builds the sorted launcher tile list from the deployed/protected stacks and the
|
||||
// subdomain lookup. A tile exists exactly when a "Megnyitás" button would — i.e. the stack has a
|
||||
// subdomain entry — minus the controller itself. Sorted by DisplayName (tie-broken by Name) so the
|
||||
// grid order is stable regardless of Go's map iteration order.
|
||||
func buildLauncherApps(stackList []stacks.Stack, subdomains map[string]string) []LauncherApp {
|
||||
var apps []LauncherApp
|
||||
for _, st := range stackList {
|
||||
if st.Name == controllerStackName {
|
||||
continue
|
||||
}
|
||||
sd, ok := subdomains[st.Name]
|
||||
if !ok || sd == "" {
|
||||
continue
|
||||
}
|
||||
dn := st.Meta.DisplayName
|
||||
if dn == "" {
|
||||
dn = st.Name
|
||||
}
|
||||
apps = append(apps, LauncherApp{
|
||||
Name: st.Name,
|
||||
DisplayName: dn,
|
||||
Slug: st.Meta.Slug,
|
||||
State: st.State,
|
||||
Subdomain: sd,
|
||||
OpenPath: st.Meta.OpenPath,
|
||||
BrandColor: st.Meta.BrandColor,
|
||||
})
|
||||
}
|
||||
sort.Slice(apps, func(i, j int) bool {
|
||||
if apps[i].DisplayName == apps[j].DisplayName {
|
||||
return apps[i].Name < apps[j].Name
|
||||
}
|
||||
return apps[i].DisplayName < apps[j].DisplayName
|
||||
})
|
||||
return apps
|
||||
}
|
||||
|
||||
s.executeTemplate(w, r, "dashboard", data)
|
||||
// launcherHandler renders the Indítópult: a grid of large tappable tiles, one per openable deployed
|
||||
// app (subdomain presence is the single openability criterion — see buildLauncherApps). Behind
|
||||
// RequireAuth like every page; the "/" landing page stays the Vezérlőpult.
|
||||
func (s *Server) launcherHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var eligible []stacks.Stack
|
||||
for _, st := range s.stackMgr.GetStacks() {
|
||||
if st.Deployed || st.Protected {
|
||||
eligible = append(eligible, st)
|
||||
}
|
||||
}
|
||||
data := s.baseData("launcher", "Indítópult")
|
||||
data["Apps"] = buildLauncherApps(eligible, s.subdomainMap(eligible))
|
||||
s.executeTemplate(w, r, "launcher", data)
|
||||
}
|
||||
|
||||
// visibleCatalogStacks drops templates that are no longer OFFERED for new installs (lifecycle
|
||||
@@ -263,23 +338,7 @@ func (s *Server) stacksHandler(w http.ResponseWriter, r *http.Request) {
|
||||
data["StorageLabels"] = storageLabels
|
||||
|
||||
// Build effective subdomain lookup (stored env > 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)
|
||||
}
|
||||
|
||||
@@ -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 = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#94A6BF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>`
|
||||
|
||||
// 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 = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#94A6BF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/></svg>`
|
||||
|
||||
@@ -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 <a> 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 <a> 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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/"):
|
||||
|
||||
@@ -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"}}
|
||||
<div class="app-row{{with .RowClass}} {{.}}{{end}}"{{with .Href}} data-href="{{.}}"{{end}}>
|
||||
<img class="app-row-icon" src="{{logoURL .Slug}}" alt=""{{with .FallbackIcon}} data-fallback="{{.}}"{{end}}
|
||||
<img class="app-row-icon" src="{{logoURL .Slug}}" alt="" data-fallback="{{if .FallbackIcon}}{{.FallbackIcon}}{{else}}/static/app-placeholder.svg{{end}}"
|
||||
onerror="if(!this.dataset.step){this.dataset.step='1';this.src='{{logoPNGURL .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 class="app-row-text">
|
||||
<span class="app-row-name">{{.Name}}</span>
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
<symbol id="i-server" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="20" height="8" x="2" y="2" rx="2" ry="2" /> <rect width="20" height="8" x="2" y="14" rx="2" ry="2" /> <line x1="6" x2="6.01" y1="6" y2="6" /> <line x1="6" x2="6.01" y1="18" y2="18" /></symbol>
|
||||
<symbol id="i-share" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="3" /> <circle cx="6" cy="12" r="3" /> <circle cx="18" cy="19" r="3" /> <line x1="8.59" x2="15.42" y1="13.51" y2="17.49" /> <line x1="15.41" x2="8.59" y1="6.51" y2="10.49" /></symbol>
|
||||
<symbol id="i-layout-grid" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="7" height="7" x="3" y="3" rx="1" /> <rect width="7" height="7" x="14" y="3" rx="1" /> <rect width="7" height="7" x="14" y="14" rx="1" /> <rect width="7" height="7" x="3" y="14" rx="1" /></symbol>
|
||||
<symbol id="i-rocket" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91 0z" /> <path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z" /> <path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0" /> <path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5" /></symbol>
|
||||
<symbol id="i-settings" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915" /> <circle cx="12" cy="12" r="3" /></symbol>
|
||||
<symbol id="i-bell" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.268 21a2 2 0 0 0 3.464 0" /> <path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326" /></symbol>
|
||||
<symbol id="i-x" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18" /> <path d="m6 6 12 12" /></symbol>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
{{define "launcher"}}
|
||||
{{template "layout_start" .}}
|
||||
|
||||
<div class="page-header">
|
||||
<h2>Indítópult</h2>
|
||||
<span class="domain-badge">{{.Domain}}</span>
|
||||
</div>
|
||||
|
||||
{{if .Apps}}
|
||||
<div class="launch-grid">
|
||||
{{range .Apps}}
|
||||
{{if isOperational .State}}
|
||||
<a class="launch-cell" href="https://{{.Subdomain}}.{{$.Domain}}{{.OpenPath}}" target="_blank" rel="noopener">
|
||||
<span class="launch-tile" style="background: {{tileColor .Slug .BrandColor}}">
|
||||
<span class="launch-mono">{{initial .DisplayName}}</span>
|
||||
<img class="launch-logo" src="{{logoURL .Slug}}" alt=""
|
||||
onerror="if(!this.dataset.step){this.dataset.step='1';this.src='{{logoPNGURL .Slug}}';}else{this.onerror=null;this.style.display='none';}">
|
||||
</span>
|
||||
<span class="launch-name">{{.DisplayName}}</span>
|
||||
{{if ne (stateStr .State) "running"}}<span class="tag tag-{{stateColor .State}}"><span class="dot"></span>{{stateLabel .State}}</span>{{end}}
|
||||
</a>
|
||||
{{else}}
|
||||
<div class="launch-cell launch-cell--off">
|
||||
<span class="launch-tile launch-tile--off" style="background: {{tileColor .Slug .BrandColor}}">
|
||||
<span class="launch-mono">{{initial .DisplayName}}</span>
|
||||
<img class="launch-logo" src="{{logoURL .Slug}}" alt=""
|
||||
onerror="if(!this.dataset.step){this.dataset.step='1';this.src='{{logoPNGURL .Slug}}';}else{this.onerror=null;this.style.display='none';}">
|
||||
</span>
|
||||
<span class="launch-name">{{.DisplayName}}</span>
|
||||
<span class="tag tag-{{stateColor .State}}"><span class="dot"></span>{{stateLabel .State}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="empty-state">
|
||||
<p>Még nincs telepített alkalmazás.</p>
|
||||
<p><a href="/stacks" class="empty-state-link">Alkalmazások telepítése</a></p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{template "layout_end"}}
|
||||
{{end}}
|
||||
@@ -68,6 +68,7 @@
|
||||
<span class="customer-name">{{.CustomerName}}</span>
|
||||
</div>
|
||||
<ul class="nav-links">
|
||||
<li><a href="/launcher" class="{{if eq .Page "launcher"}}active{{end}}"><svg class="ico"><use href="#i-rocket"/></svg>Indítópult</a></li>
|
||||
<li><a href="/" class="{{if eq .Page "dashboard"}}active{{end}}"><svg class="ico"><use href="#i-layout-grid"/></svg>Vezérlőpult</a></li>
|
||||
<li><a href="/stacks" class="{{if eq .Page "stacks"}}active{{end}}"><svg class="ico"><use href="#i-cloud"/></svg>Alkalmazások</a></li>
|
||||
{{$storageOpen := or (eq .Page "storage") (eq .Page "storage-network")}}
|
||||
|
||||
@@ -1111,6 +1111,68 @@ a.stat-card:hover {
|
||||
[data-href] { cursor: pointer; }
|
||||
|
||||
.empty-state { text-align: center; padding: 3rem; color: var(--text-3); }
|
||||
.empty-state-link { color: var(--blue-bright); }
|
||||
|
||||
/* Indítópult (launcher) — a grid of large tappable app tiles. Each tile has a deterministic
|
||||
per-app background (tileColor / brand_color override), the white monogram fallback UNDER the
|
||||
logo (revealed when the logo fails to load), and the app name below. */
|
||||
.launch-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(112px, 1fr));
|
||||
gap: 1.25rem;
|
||||
margin-top: .5rem;
|
||||
}
|
||||
.launch-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: .5rem;
|
||||
text-decoration: none;
|
||||
color: var(--text-1);
|
||||
}
|
||||
.launch-tile {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--line);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
transition: transform .12s ease, border-color .12s ease;
|
||||
}
|
||||
.launch-cell:hover .launch-tile {
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--blue);
|
||||
}
|
||||
.launch-mono {
|
||||
position: absolute;
|
||||
color: #fff;
|
||||
font-family: var(--font-ui);
|
||||
font-size: 2.4rem;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
user-select: none;
|
||||
}
|
||||
.launch-logo {
|
||||
position: relative;
|
||||
width: 58%;
|
||||
height: 58%;
|
||||
object-fit: contain;
|
||||
}
|
||||
.launch-name {
|
||||
max-width: 100%;
|
||||
font-size: .85rem;
|
||||
color: var(--text-1);
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.launch-cell--off { cursor: default; }
|
||||
.launch-tile--off { opacity: .4; }
|
||||
.launch-cell--off .launch-name { color: var(--text-3); }
|
||||
|
||||
/* Login page */
|
||||
.login-body {
|
||||
|
||||
Reference in New Issue
Block a user