package web import ( "fmt" "strings" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" ) // updateState is the answer to "is this app running what the catalog currently pins?". // // THE THIRD VALUE IS THE ENTIRE SAFETY PROPERTY — the same shape, and the same lesson, as // AppConfig.DesiredState (R-166): // // ABSENT MEANS UNKNOWN. IT NEVER MEANS "UP TO DATE". // // Every app.yaml written before v0.233.0 carries no installed_images, so unknown is the common value // on upgrade. An implementation that fell through to "Naprakész" would tell every customer on the // fleet that their months-old app is current — a confident wrong answer, which is worse than none. // // THE FOURTH VALUE, updateAhead, ARRIVED IN v0.260.0 (R-524). See stacks.CatalogOrder. type updateState int const ( updateUnknown updateState = iota // nothing recorded, or nothing to compare against updateCurrent // every service runs exactly what the template pins updateBehind // at least one service does not updateAhead // the box runs something NEWER than the catalog offers ) // compareInstalledToTemplate answers the question WITHOUT touching the network. // // SINCE v0.260.0 IT IS A THIN WRAPPER OVER stacks.CatalogOrder, and that move is the point of R-524: // the badge and the guarded update's downgrade refusal must reach the same verdict, and two // implementations of one comparison are two verdicts waiting to disagree. Everything the old body // said still holds and now lives in updateorder.go: // // - NO REGISTRY QUERY, deliberately: a customer's box must not depend on reaching eight upstream // registries to render a page. The comparison is reference-to-reference. // - ⚠ IT COMPARES AGAINST Stack.CatalogImages, NEVER Stack.TemplateImages, AND v0.235.0 IS WHY. // Since the freeze, a pinned app's LIVE compose file is rendered from its own stored definition // once the catalog moves past it, so installed would equal template and this function would // answer „Naprakész" on precisely the apps that are behind — with every test still green, // because the two fields have the same type and shape. // - KNOWN LIMITATION: **10 of the catalog's 66 distinct pins FLOAT** (recounted 2026-09-21; // the "23" this comment carried since v0.233.0 matched no definition the catalog supports // today). For those the reference can be identical while the image behind it has moved // upstream, and 6 of the 7 measurable ones HAVE moved since the catalog set them. Those apps // read „Naprakész" when they may not be — R-446. func compareInstalledToTemplate(s stacks.Stack) updateState { switch stacks.CatalogOrder(s) { case stacks.UpdateOrderCurrent: return updateCurrent case stacks.UpdateOrderBehind: return updateBehind case stacks.UpdateOrderAhead: return updateAhead default: return updateUnknown } } // updateBadgeAt is the pure form: `now` is injected so the age is a testable contract rather than a // property of the clock. updateBadge (the funcmap entry) is the one-line wrapper. // // It returns a *MetaBadge and calls the EXISTING meta_badge partial — no new markup and no new CSS. // metabadge.go's own comment asks for exactly that of its second user, and this is it. // // ⚠ THIS IS THE HUNGARIAN FORM AND IT KEEPS ITS LITERALS ON PURPOSE. The parity guarantee of the // localisation arc is that templateFuncMap's Hungarian output is byte-identical to what it was // before the bundle existed; the English form is rebuilt from the bundle in // i18n_web.go localeFuncs, over the SAME compareInstalledToTemplate, so only the words differ and // never the decision. A new branch here needs its twin there, and // TestLocaleFuncsHungarianBundleMatchesFuncMap fails if hu.json and these literals disagree. func updateBadgeAt(s stacks.Stack, now time.Time) *MetaBadge { // R-625 (v0.265.0). A HELD app is stopped, and the only way back is a restore — a newer catalog // version does not lift the hold (`09` §6.1), so „Frissítés elérhető" beside a button that answers // 409 was two verdicts disagreeing. One verdict, read by the badge and by the button (stacks.html // renders no Update button while HoldReason is set), exactly as R-524 did it for Ahead. True for // both hold kinds: a failed restore holds the app stopped too, and the way back is also a restore. if s.HoldReason != "" { return &MetaBadge{ Label: "Megállítva — visszaállítás szükséges", Class: "tag-error", Title: firstSentence(s.HoldReason), } } switch compareInstalledToTemplate(s) { case updateCurrent: return &MetaBadge{ Label: "Naprakész", Class: "tag-ok", Title: "Ez az alkalmazás a legfrissebb elérhető változatot futtatja.", } case updateAhead: // R-524. THE PILL SAYS THE SAME WORD AS updateCurrent, and that is the ruling: an app that // runs something newer than the catalog has nothing for the household to do, so it must not // wear a warning. The note that says WHY goes in the title, which is the explanation slot // this type exists for — a badge that only says a word is a riddle. No version number // reaches the customer here either. return &MetaBadge{ Label: "Naprakész", Class: "tag-ok", Title: "Ez az alkalmazás a katalógusnál újabb változatot futtat, ezért nincs teendőd.", } case updateBehind: label := "Frissítés elérhető" if days, ok := s.Meta.CatalogSinceAge(now); ok { if days == 0 { label += " — ma" } else { label += fmt.Sprintf(" — %d napja", days) } } return &MetaBadge{ Label: label, Class: "tag-warn", Title: "Újabb változat érhető el ehhez az alkalmazáshoz. " + "A frissítés indításához nyomd meg a Frissítés gombot.", } default: // UNKNOWN renders NOTHING. Not a grey "ismeretlen" pill: a badge on an app we cannot judge // is a question the customer cannot answer, and the record fills itself in on the next // restart or update anyway. return nil } } // updateBadge is the funcmap entry. NO version number appears in any string it produces — the // operator ruled that a household cannot act on "26.05.2", only on "you are behind, by this long". // Version strings stay in the logs, the API and the hub. // // It is INFORMATION ONLY. It is wired to no action, and the Frissítés button is untouched. func updateBadge(s stacks.Stack) *MetaBadge { return updateBadgeAt(s, time.Now().UTC()) } // firstSentence is the hold sentence's opening clause, for a badge title (R-625): up to and including // the first full stop that ends a sentence; the whole text when it has none. func firstSentence(s string) string { if i := strings.Index(s, ". "); i >= 0 { return s[:i+1] } return s }