From 5fdd2039fd9190575ab3317e689a2ca831991d90 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Tue, 21 Jul 2026 16:20:24 +0200 Subject: [PATCH] catalog: the lifecycle implementation itself (fixes the previous commit) The previous commit landed only the new test/badge files: a 'git stash' used to compare REUSE.md ref-check output silently dropped the staged index, so every modification to an existing file was left behind and that commit does not build. This adds the metadata field, the predicates, the fail-closed deploy gate, the catalog filter, the funcmap entries, the template edits and the docs that those tests exercise. --- CHANGELOG.md | 50 +++++++++++++++++ REUSE.md | 5 +- controller/README.md | 34 +++++++++++ controller/internal/api/router.go | 15 +++++ controller/internal/stacks/deploy.go | 10 ++++ controller/internal/stacks/metadata.go | 56 +++++++++++++++++++ controller/internal/web/funcmap.go | 8 +++ controller/internal/web/handlers.go | 23 +++++++- .../internal/web/templates/app_info.html | 11 +++- .../internal/web/templates/dashboard.html | 1 + controller/internal/web/templates/stacks.html | 5 +- 11 files changed, 213 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aa9b58d..97e4809 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,55 @@ ## Changelog +### v0.158.0 — apps get a lifecycle: available / hidden / abandoned (2026-07-21) + +No agent coupling; MinAgent unchanged. + +Until now the catalog knew only two states: a template is present, or it is gone. "Gone" is not a +usable way to withdraw an app, because **it orphans every customer already running it** — their app +gets flagged `Elavult` and offered a Törlés button, for software that works fine. That is what the +short-lived `retired/` directory move (2026-07-21, same day) would have done, and it is why this +replaces it. + +`.felhom.yml` gains an optional top-level `lifecycle:`: + +- **`available`** — the default. Absent or empty means this, so all 52 existing templates are + unchanged. +- **`hidden`** — not offered for new installs. Nothing is shown to anyone already running it; "we + stopped offering this" is not their problem. +- **`abandoned`** — not offered for new installs, AND every box already running it carries a + permanent „Nem karbantartott" badge plus a notice on the app page: *„Az alkalmazás fejlesztője + felhagyott a fejlesztéssel. A telepített verzió továbbra is használható, de frissítések és + biztonsági javítások már nem érkeznek hozzá."* + +**A deployed instance keeps full function in every state.** Lifecycle governs what is OFFERED, never +what runs. + +- **The deploy gate is server-side and fail-closed** (`api.deployStack`, before any mutation), with + the ruled Hungarian refusal „Ez az alkalmazás jelenleg nem telepíthető." Hiding a button is not a + gate — a stale link, a bookmarked deploy form or a direct POST must all be refused. A second check + in `stacks.DeployStack` covers any future caller that does not route through the API. +- **The unknown-value posture is fail-OPEN, deliberately, and it is the opposite of the gate's.** An + unrecognised value degrades to `available` with one WARN. A typo — or a state added in a later + catalog than this controller understands — must never silently pull a working app out of every + customer's catalog. The gate that actually protects installation reads the same + `EffectiveLifecycle`, so the two can never disagree. +- **Orphan detection is untouched, and that is asserted.** Withdrawn templates stay in the catalog + tree; `getCatalogTemplateSlugs` never looks at lifecycle. A red-proof adds that filter and shows + the abandoned app immediately reading as an orphan. +- **Badge plumbing is generic**: `MetaBadge` + the `meta_badge` partial + a `lifecycleBadge` funcmap + entry. R-56's difficulty labels are meant to be a sibling funcmap function returning the same type + — no new markup, no new CSS. +- **plant-it returns to `templates/`** as the first `abandoned` app, so the mechanism is proven on + the case that motivated it. Its compose is deliberately left as-is: the app is not installable, and + rewriting it would imply it is. + +**Red-proofs, all four run:** removing the API gate → the wiring test reports the gate INERT; +dropping the `Deployed ||` clause from the catalog filter → a customer's running app vanishes from +their own Alkalmazások page; removing the badge line → the abandoned app renders unmarked; making +orphan detection lifecycle-aware → `catalog set = map[bookstack:true]`, the two withdrawn apps read +as orphans. The wiring test walks the AST, not `strings.Contains`, because a commented-out call +still contains the string; it also asserts the gate precedes `DeployStack`. + ### v0.157.1 — anchor the `controller` .gitignore entry (2026-07-21) Tooling only; no behaviour change, no rebuild needed. diff --git a/REUSE.md b/REUSE.md index 51c4c9a..7d964e0 100644 --- a/REUSE.md +++ b/REUSE.md @@ -85,6 +85,7 @@ | `Manager.EnsureBaseStack` | controller/internal/stacks/infra.go | `() error` | Traefik/cloudflared/FileBrowser infra convergence | Renders from `internal/infra` templates | | `appbackup.ClassifyBinds` / `ValidateBackupSpec` | controller/internal/appbackup/classify.go | `(spec, binds) ([]ClassifiedBind, bool)` / `(spec, binds) error` | Backup-classification (Task 2, referential coupling) — pure | Two-level default: explicit wins over `:ro`; unlisted writable→mandatory, unlisted `:ro`→excluded; nil spec→legacy/false. Validate REJECTS the WHOLE block on any defect (whole-block semantics). INERT — no tier consumes it yet | | `ParseComposeClassifiableBinds` | controller/internal/stacks/classify_binds.go | `(composePath) []appbackup.ComposeBind` | `${VAR}`-relative binds + `:ro` for classification | Do NOT use `ParseComposeHDDMounts`/`ExportDataMounts` as classifier input (§traps) — they resolve absolutes, drop `:ro`, or union the userdata ROOT. Short-syntax only | +| `Metadata.EffectiveLifecycle` / `CanInstall` / `IsAbandoned` + `web.lifecycleBadge` / `web.visibleCatalogStacks` | controller/internal/stacks/metadata.go, controller/internal/web/metabadge.go, controller/internal/web/handlers.go | `meta.CanInstall() bool` | app lifecycle: `available` / `hidden` / `abandoned` (v0.158.0) | THE single interpretation of `.felhom.yml` `lifecycle:` — every surface must go through these, never compare the raw string. Listing drops `!Deployed && !Protected && !CanInstall()`; `api.deployStack` refuses server-side BEFORE any mutation (hiding a button is not a gate), `stacks.DeployStack` repeats it for non-API callers. **Unknown value fails OPEN** (→ available + one WARN) — opposite to the gate on purpose: a typo must never pull a working app out of every catalog. **NEVER let lifecycle reach orphan detection** (`getCatalogTemplateSlugs`) — a withdrawn template stays in the tree, or every deployed instance reads as `Elavult` and gets a Törlés button. Badges: `MetaBadge` + `meta_badge` partial, built generic for R-56 difficulty labels | | `Manager.ClassifiedBinds` + `StackDataProvider.GetStackClassifiedBinds` | controller/internal/stacks/metadata.go, appbackup/appdata.go | `(name) ([]appbackup.ClassifiedBind, bool)` | Per-stack classification through the REAL LoadMetadata validate path | The wired seam Task 3 consumes; LoadMetadata is the SINGLE validation choke point (bad block → nil + one ERROR → legacy) | | `backup.Manager.DumpAppVolumesSafe` | controller/internal/backup/backup.go | `(stackName) error` | Volume tar of a live app | Stops → dumps → restarts; surfaces BOTH errors (app may be left stopped). Check `GetDockerVolumes()!=0` + `IsProtectedStack` BEFORE calling — it stops the stack before its own volume check (see `runVolumeDumps`) | | `backup.Manager.ListRestorePoints` | controller/internal/backup/restore_points.go | `(stackName) ([]RestorePoint, bool)` | Restorable keep-side backups (the /api/backup/snapshots payload) | ONE point per app (the current unit); tier always 1 — never list Tier-2 (not restorable via /backup/restore) | @@ -242,7 +243,7 @@ | `dumpVolumesSafe` (func seam) | controller/internal/backup/backup.go | nil → real `DumpAppVolumesSafe` | injected in controller/internal/backup/volume_dumps_test.go (gating tests without Docker) | | `generateSecret` (func seam) | controller/internal/backup/backup.go | `stacks.Manager.GenerateSecretForField` via `SetSecretGenerator` (main.go) | injected in controller/internal/backup/restore_secrets_gen_test.go | | `restoreFilesCopier` (func seam) | controller/internal/backup/backup.go | nil → real `rsyncRestoreMissing` | injected in controller/internal/backup/tier2_restore_test.go (orchestration without rsync) | -| `tier2Mirror` (func seam) | controller/internal/backup/backup.go | nil → real `rsyncMirror` | both RunTier2 rsync legs; injected in controller/internal/backup/tier2_appdata_test.go (resolve→mirror without rsync) | +| `tier2Mirror` (func seam) | controller/internal/backup/backup.go | nil → real `rsyncMirror` | both RunTier2 rsync legs; injected in controller/internal/backup/tier2_test.go (resolve→mirror without rsync) | | `migSeams.resolveNames` (func seam) | controller/internal/stacks/migrate.go | nil → real `ResolveAppDataDirNames` (compose-derived) | injected in controller/internal/stacks/migrate_fs3_test.go (F-S3 appdata dir-name resolution) | Cross-repo edges: @@ -260,7 +261,7 @@ Cross-repo edges: - **Docker volume tar streaming (v0.125.0)**: `appexport.dockerExec` (seam, package var) + `withVolumeHelper`/`exportVolumeTar`/`importVolumeTar` — stream volume content via `docker cp` through a stopped helper container. NEVER `docker run -v ` — the daemon resolves `-v` host-side and strands the data when the controller is containerized (the v0.124.0 HIGH finding); `controller/scripts/docker_run_volume_path_gate.py` enforces (every `"-v"` allowlisted with its WHY). - **Guarded file download (v0.124.0)**: `handler_export_download.go` — the canonical shape for streaming a server-side file to the browser: accept a BASENAME only (shape regexp + no separators/`..`), `filepath.Join` then assert `filepath.Dir(path) == dir`, `io.Copy` (never ReadAll), `Content-Disposition: attachment`, remove after a successful stream, TTL sweep (`sweepFabDownloads(dir, now, maxAge, logger)` — now injected for tests). Red-proof the guard by loosening to prefix-matching (the `..` case must fail). - **Backups sub-page data**: `backupsCommonData(page, title, r)` + `backupsOffboxData(data)` (handlers.go) — the ONLY builders for the four `/backups*` pages; a new backups section extends these, never re-derives in a page handler. (The one-shot v0.124.0 move gate `backups_split_move_check.py` was retired in v0.126.0.) -- **App-list row (v0.126.0)**: `app_list_row`/`app_list_row_end` in `templates/app_row.html` is THE canonical list pattern — icon+name(+secondary) left, caller action block right; open with `dict "Slug" ... "Name" ...` (optional `Secondary`/`RowClass`/`Href`/`FallbackIcon`), close with `app_list_row_end`. Do NOT hand-roll app rows — `scripts/app_row_dedup_gate.py` enforces single-sourcing (the backups_apps expander header is the one allowlisted aligned copy). Infra display identity: `inframeta.go` map + `infraMeta` func (filebrowser is the only Linked stack). +- **App-list row (v0.126.0)**: `app_list_row`/`app_list_row_end` in `controller/internal/web/templates/app_row.html` is THE canonical list pattern — icon+name(+secondary) left, caller action block right; open with `dict "Slug" ... "Name" ...` (optional `Secondary`/`RowClass`/`Href`/`FallbackIcon`), close with `app_list_row_end`. Do NOT hand-roll app rows — `controller/scripts/app_row_dedup_gate.py` enforces single-sourcing (the backups_apps expander header is the one allowlisted aligned copy). Infra display identity: `inframeta.go` map + `infraMeta` func (filebrowser is the only Linked stack). - **Consequential-action confirm (LIGHT)**: `felhomConfirm(el, question, onYes)` in layout.html (v0.123.0) — the trigger swaps in place to "kérdés + Igen/Mégse"; form buttons opt in with `data-confirm="…"` (delegated listener, `requestSubmit` keeps formaction/name-value). NEVER native `confirm()`/`prompt()` (OS-modals freeze browser automation — drill F-11; `native_confirm_gate.py` enforces). Heavy destructive flows keep the `.confirm-overlay` `openDialog` pattern. - **New hub event**: typed `Notify*` wrapper on Notifier + hub allowlist entry (cross-repo). - **New app integration**: `integrations.Manager.RegisterHandler` with `IntegrationKey(provider, target)`. diff --git a/controller/README.md b/controller/README.md index ab2e192..12da7f6 100644 --- a/controller/README.md +++ b/controller/README.md @@ -248,6 +248,40 @@ Running apps on the Vezérlőpult now show a "Megnyitás ↗" button that opens The optional `.felhom.yml` **`open_path`** field (`Metadata.OpenPath`) is appended to that URL for apps whose UI isn't at `/` — e.g. `gokapi` → `/admin`, `ghost` → `/ghost/`. Empty = bare root. Rendered via `.Meta.OpenPath` in `dashboard.html`, `deploy.html`, and `app_info.html` (the same field flows through all three open-link sites; no handler change needed). Must start with `/`; cosmetic only (does not affect routing). +#### App lifecycle — withdrawing an app without orphaning anyone (v0.158.0) + +`.felhom.yml` carries an optional top-level **`lifecycle:`** (`Metadata.Lifecycle`), the catalog's +answer to "stop offering this app" that does not punish the customers already running it. + +| value | offered for new installs? | shown to someone already running it | +|---|---|---| +| `available` (default; absent/empty ≡ this) | yes | nothing special | +| `hidden` | **no** | nothing — "we stopped offering this" is not their problem | +| `abandoned` | **no** | „Nem karbantartott" badge + a notice on the app page that updates and security fixes will no longer arrive | + +**A deployed instance keeps full function in every state.** Lifecycle governs what is OFFERED, never +what runs — deleting a template instead would mark every deployed instance `Elavult` and offer a +Törlés button for working software. + +Three predicates on `Metadata` are the single interpretation of the field — every surface goes +through them: `EffectiveLifecycle()`, `CanInstall()`, `IsAbandoned()`. + +- **Listing** — `web.visibleCatalogStacks` drops a template that is not installable AND not deployed + here (`Deployed || Protected || CanInstall()`). +- **Deploy gate** — `api.deployStack` refuses server-side before any mutation with + „Ez az alkalmazás jelenleg nem telepíthető." (409). `stacks.DeployStack` repeats the check for any + caller that does not route through the API. Hiding the button is not a gate. +- **Unknown values fail OPEN** (→ `available` + one WARN), deliberately opposite to the gate's + fail-closed posture: a typo, or a state from a newer catalog than this controller, must never pull + a working app out of every customer's catalog. Both read the same `EffectiveLifecycle`, so they + cannot disagree. +- **Orphan detection must never see this field.** `getCatalogTemplateSlugs` keys on directory + + compose presence only; withdrawn templates stay in the catalog tree. Asserted by + `TestCatalogTemplateSlugs_IgnoresLifecycle` with a red-proof. +- **Badges are generic plumbing**: `web.MetaBadge` + the `meta_badge` template partial + the + `lifecycleBadge` funcmap entry. R-56's difficulty labels are intended as a sibling funcmap + function returning the same `*MetaBadge` — no new markup or CSS. + #### App Info Pages Each app can define rich metadata in `.felhom.yml`: diff --git a/controller/internal/api/router.go b/controller/internal/api/router.go index ddb30b7..15eb29a 100644 --- a/controller/internal/api/router.go +++ b/controller/internal/api/router.go @@ -410,6 +410,21 @@ func (r *Router) deployStack(w http.ResponseWriter, req *http.Request, name stri return } + // Lifecycle gate: an app withdrawn from the catalog (`lifecycle: hidden` / `abandoned`) is not + // installable. FAIL-CLOSED and server-side on purpose — the catalog page already omits these, so + // anything reaching here is a stale link, a bookmarked deploy form, or a direct POST, and a gate + // that only hides the button is not a gate. Deliberately BEFORE every mutation. + // + // This does NOT touch an already-deployed instance: it is on the deploy path only, and the + // manager refuses a redeploy of an existing stack through its own "already deployed" check. + if st, ok := r.stackMgr.GetStack(name); ok && !st.Meta.CanInstall() { + r.logger.Printf("[WARN] [api] Deploy refused for %s: lifecycle=%s (not offered for new installs)", + name, st.Meta.EffectiveLifecycle()) + writeJSON(w, http.StatusConflict, apiResponse{OK: false, + Error: "Ez az alkalmazás jelenleg nem telepíthető."}) + return + } + // Prevention layer (storage-split): refuse a deploy when the Docker-data volume is at/under its // reserved buffer, so customer apps can't fill the volume the infra containers (controller, // traefik, cloudflared, filebrowser) depend on. Fail-OPEN on a measurement error — the buffer is diff --git a/controller/internal/stacks/deploy.go b/controller/internal/stacks/deploy.go index 82aa22d..35db2d5 100644 --- a/controller/internal/stacks/deploy.go +++ b/controller/internal/stacks/deploy.go @@ -160,6 +160,16 @@ func (m *Manager) DeployStack(req DeployRequest) (string, error) { stackDir := filepath.Dir(stack.ComposePath) meta := LoadMetadata(stackDir) + // --- Lifecycle gate (defence in depth) --- + // The API handler refuses this first, with the customer-facing Hungarian message. This second + // check exists because DeployStack is the manager-level choke point EVERY caller goes through, + // and metadata is already loaded here — so a future caller that does not route through the API + // cannot bypass the rule by simply not knowing about it. Deliberately before the first mutation. + if !meta.CanInstall() { + clearDeploying() + return "", fmt.Errorf("stack %q is not installable (lifecycle: %s)", req.StackName, meta.EffectiveLifecycle()) + } + // --- Memory validation --- var deployWarning string reservedMB := m.cfg.System.ReservedMemoryMB diff --git a/controller/internal/stacks/metadata.go b/controller/internal/stacks/metadata.go index bf78213..c8a0017 100644 --- a/controller/internal/stacks/metadata.go +++ b/controller/internal/stacks/metadata.go @@ -17,6 +17,16 @@ type Metadata struct { Category string `yaml:"category" json:"category"` Subdomain string `yaml:"subdomain" json:"subdomain"` 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. + // ""/"available" — normal. + // "hidden" — not offered for new installs. No explanation owed. + // "abandoned" — not offered for new installs, AND every box already running it shows a + // permanent notice that updates and security fixes will no longer arrive. + // An UNKNOWN value degrades to available with one WARN (see LoadMetadata) — a typo in a catalog + // push must never brick a template. + Lifecycle string `yaml:"lifecycle,omitempty" json:"lifecycle,omitempty"` // OpenPath is appended to the app's public URL for the "Megnyitás" (open) link, for apps whose UI // isn't at "/" (e.g. Gokapi → "/admin"). Empty = bare root. Must start with "/". OpenPath string `yaml:"open_path,omitempty" json:"open_path,omitempty"` @@ -193,6 +203,44 @@ type HealthCheckExpect struct { BodyContains string `yaml:"body_contains" json:"body_contains"` // string that must appear in response body } +// Lifecycle values. Absent/empty ≡ LifecycleAvailable. +const ( + LifecycleAvailable = "available" + LifecycleHidden = "hidden" + LifecycleAbandoned = "abandoned" +) + +// EffectiveLifecycle normalises Metadata.Lifecycle. It is the SINGLE definition of "what state is +// this app in" — every caller (catalog listing, deploy gate, badge, notice) must go through it, so +// an unknown value can only ever be interpreted one way. +// +// Fail-OPEN is deliberate here, and it is the opposite of the deploy gate's posture on purpose: +// an unrecognised value means the catalog is newer than this controller, and the safe reading of +// "I do not know what this state is" is "leave the app alone" — the alternative would let a typo, +// or a state added in a later release, silently pull a working app out of every customer's catalog. +// The gate that actually protects against installing something is `CanInstall`, and it is fed by +// this same function, so the two can never disagree. +func (m *Metadata) EffectiveLifecycle() string { + switch m.Lifecycle { + case "", LifecycleAvailable: + return LifecycleAvailable + case LifecycleHidden: + return LifecycleHidden + case LifecycleAbandoned: + return LifecycleAbandoned + default: + return LifecycleAvailable + } +} + +// CanInstall reports whether this app may be offered/installed. The catalog listing and the deploy +// endpoint MUST both use this — a template excluded from the list but accepted by a direct POST +// would be a gate in name only. +func (m *Metadata) CanInstall() bool { return m.EffectiveLifecycle() == LifecycleAvailable } + +// IsAbandoned reports whether a DEPLOYED instance should carry the "no longer maintained" notice. +func (m *Metadata) IsAbandoned() bool { return m.EffectiveLifecycle() == LifecycleAbandoned } + // LoadMetadata reads .felhom.yml from a stack directory. // Returns default metadata if the file doesn't exist. func LoadMetadata(stackDir string) Metadata { @@ -229,6 +277,14 @@ func LoadMetadata(stackDir string) Metadata { meta.Category = "tools" } + // Lifecycle: warn ONCE on an unrecognised value, then let EffectiveLifecycle degrade it to + // available. Logged here rather than in EffectiveLifecycle because that is called on every + // render — this is the one place per load, so a typo is visible without flooding the log. + if meta.Lifecycle != "" && meta.EffectiveLifecycle() == LifecycleAvailable && meta.Lifecycle != LifecycleAvailable { + log.Printf("[WARN] [stacks] %s: unknown lifecycle %q in .felhom.yml — treating as %q (known: %s, %s, %s)", + dirName, meta.Lifecycle, LifecycleAvailable, LifecycleAvailable, LifecycleHidden, LifecycleAbandoned) + } + // Default healthcheck fields if meta.HealthCheck != nil { if meta.HealthCheck.Interval == "" { diff --git a/controller/internal/web/funcmap.go b/controller/internal/web/funcmap.go index 3a09dc1..1983884 100644 --- a/controller/internal/web/funcmap.go +++ b/controller/internal/web/funcmap.go @@ -412,6 +412,14 @@ func (s *Server) templateFuncMap() template.FuncMap { } return s.cfg.AppPageURL(slug) }, + // lifecycleBadge returns the catalog-metadata pill for an app's lifecycle, or nil when + // there is nothing to say. Pair it with the `meta_badge` partial, which no-ops on nil. + // R-56's difficulty badge is meant to be a sibling entry returning the same *MetaBadge. + "lifecycleBadge": lifecycleBadge, + // canInstall reports whether a catalog template may be OFFERED for a new install. The + // server-side deploy gate uses the same stacks.Metadata.CanInstall, so the button and the + // endpoint can never disagree. + "canInstall": func(m stacks.Metadata) bool { return m.CanInstall() }, // infraMeta resolves a protected infra stack's curated Hungarian identity // (inframeta.go); nil for regular apps — templates branch on it. "infraMeta": infraMetaFor, diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go index 7e3ba0d..70b3e12 100644 --- a/controller/internal/web/handlers.go +++ b/controller/internal/web/handlers.go @@ -211,9 +211,30 @@ func (s *Server) dashboardHandler(w http.ResponseWriter, r *http.Request) { s.executeTemplate(w, r, "dashboard", data) } +// visibleCatalogStacks drops templates that are no longer OFFERED for new installs (lifecycle +// `hidden` or `abandoned`) AND are not deployed on this box. +// +// The `Deployed || Protected` half is the load-bearing part: a customer already running an app must +// keep seeing and managing it, whatever the catalog now says about offering it to new customers. +// Withdrawing an app must never take a working app away from someone — that is precisely the failure +// the short-lived `retired/` directory move would have caused, and why lifecycle is a metadata field +// rather than a deletion. +// +// Filtered here, in the handler, rather than in the template: the template already carries five +// conditional badges per card, and a visibility rule buried among them is a rule nobody can test. +func visibleCatalogStacks(in []stacks.Stack) []stacks.Stack { + out := make([]stacks.Stack, 0, len(in)) + for _, st := range in { + if st.Deployed || st.Protected || st.Meta.CanInstall() { + out = append(out, st) + } + } + return out +} + func (s *Server) stacksHandler(w http.ResponseWriter, r *http.Request) { data := s.baseData("stacks", "Alkalmazások") - allStacks := s.stackMgr.GetStacks() + allStacks := visibleCatalogStacks(s.stackMgr.GetStacks()) data["Stacks"] = allStacks data["MissingStorage"] = s.missingStorageMap(allStacks) nw, ns := s.networkStorageWarnings(allStacks) // NAS unreachable (recoverable) / guest-side stub (defect) diff --git a/controller/internal/web/templates/app_info.html b/controller/internal/web/templates/app_info.html index 0e21803..2270384 100644 --- a/controller/internal/web/templates/app_info.html +++ b/controller/internal/web/templates/app_info.html @@ -10,6 +10,7 @@ {{if .Stack.Deployed}} {{stateLabel .Stack.State}} {{if .Stack.Orphaned}}Elavult{{end}} + {{template "meta_badge" (lifecycleBadge .Meta)}} {{if .EffectiveSubdomain}}Megnyitás ↗{{end}} Napló {{if .Stack.Orphaned}} @@ -19,7 +20,9 @@ Beállítások {{end}} {{else}} - Telepítés + {{/* Server-side the deploy endpoint refuses a non-installable template; hide the button + so the page never offers an action that would be rejected. */}} + {{if canInstall .Meta}}Telepítés{{end}} {{end}} @@ -49,6 +52,12 @@ {{if .Meta.Resources.PiCompatible}}Pi kompatibilis{{else}}Csak x86{{end}} {{if .Meta.Resources.HungarianUI}}Magyar felület{{end}} + {{if .Meta.IsAbandoned}} +
+ Az alkalmazás fejlesztője felhagyott a fejlesztéssel. A telepített verzió továbbra is használható, + de frissítések és biztonsági javítások már nem érkeznek hozzá. +
+ {{end}} diff --git a/controller/internal/web/templates/dashboard.html b/controller/internal/web/templates/dashboard.html index 91bedb7..152eb5d 100644 --- a/controller/internal/web/templates/dashboard.html +++ b/controller/internal/web/templates/dashboard.html @@ -154,6 +154,7 @@ {{end}} {{stateLabel .State}} {{if .Orphaned}}Elavult{{end}} + {{template "meta_badge" (lifecycleBadge .Meta)}} {{$ms := index $.MissingStorage .Name}}{{if $ms}}Hiányzó tárhely: {{$ms}}{{end}} {{$ns := index $.NetworkStubs .Name}}{{if $ns}}Hálózati tárhely hibás — az alkalmazás nem a NAS-t látja{{end}} {{$nw := index $.NetworkWarnings .Name}}{{if $nw}}Hálózati tárhely nem elérhető: {{$nw}}{{end}} diff --git a/controller/internal/web/templates/stacks.html b/controller/internal/web/templates/stacks.html index 9bd75d9..f79ae7f 100644 --- a/controller/internal/web/templates/stacks.html +++ b/controller/internal/web/templates/stacks.html @@ -39,6 +39,7 @@ {{stateLabel .State}} {{if .Orphaned}}Elavult{{end}} + {{template "meta_badge" (lifecycleBadge .Meta)}} {{$ms := index $.MissingStorage .Name}}{{if $ms}}Hiányzó tárhely: {{$ms}}{{end}} {{$ns := index $.NetworkStubs .Name}}{{if $ns}}Hálózati tárhely hibás — az alkalmazás nem a NAS-t látja{{end}} {{$nw := index $.NetworkWarnings .Name}}{{if $nw}}Hálózati tárhely nem elérhető: {{$nw}}{{end}} @@ -79,7 +80,9 @@ Részletek {{end}} {{else if not .Deployed}} - Telepítés + {{/* The endpoint refuses a non-installable template server-side; hiding the button + here keeps the two consistent rather than offering an action that will fail. */}} + {{if canInstall .Meta}}Telepítés{{end}} Részletek {{else}} {{if isOperational .State}}