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}}