Files
felhom-controller/controller/internal/web/funcmap.go
T
admin fd28d70bff v0.308.0: controller batch 2026-10-12 — clear-address warning (R-922), secret sweep over every page (R-255), read_failed (R-266), no NVMe heat band (R-333), share globe (R-577), Hungarian dates and numbers (R-562), lost-drive option pinned (R-521)
Operator rulings 2026-10-12, 09 §3 decisions 196, 204-207, 223, 227. MinAgent 0.131.0 unchanged.
R-255's sweep found one leak, fixed here: an optional-config secret_input was rendered into its
settings page; it is now revealed on demand. Hungarian parity fixtures re-captured once for R-562.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-12 15:13:59 +02:00

543 lines
19 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package web
import (
"encoding/json"
"fmt"
"hash/fnv"
"html/template"
"regexp"
"sync"
"time"
"unicode"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
"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
)
// getTimezone returns the Europe/Budapest timezone, cached after first load.
// Falls back to UTC if tzdata is unavailable.
func getTimezone() *time.Location {
webTimezoneOnce.Do(func() {
loc, err := time.LoadLocation("Europe/Budapest")
if err != nil {
loc = time.UTC
}
webTimezone = loc
})
return webTimezone
}
// routeUnpublished reports whether the reverse proxy (Traefik) is withholding a deployed stack's public
// route because the container is not healthy. Traefik's docker provider only publishes a route to a
// container that is healthy (or has no healthcheck); an unhealthy or restarting container yields a 404
// at its URL even though the card "looks deployed". Templates use this to surface that distinctly (F5),
// so an unhealthy app with a dead URL isn't mistaken for a merely-degraded-but-reachable one.
func routeUnpublished(state stacks.ContainerState) bool {
switch state {
// StateDegraded (R-51): the dead member is typically the one Traefik routes to, so the public
// URL 404s exactly as it does for an unhealthy container.
case stacks.StateUnhealthy, stacks.StateRestarting, stacks.StateDegraded:
return true
default:
return false
}
}
// isOperationalState reports whether a stack has running containers (not stopped/exited/not-deployed).
// Shared by the funcmap "isOperational" and the guest launcher's clickability rule (v0.165.0), so both
// answer "is there something to open here" from a single source.
func isOperationalState(state stacks.ContainerState) bool {
switch state {
case stacks.StateRunning, stacks.StateStarting, stacks.StateUnhealthy, stacks.StateRestarting, stacks.StateDegraded:
return true
default:
return false
}
}
// templateFuncMap returns the FuncMap used by all HTML templates.
func (s *Server) templateFuncMap() template.FuncMap {
loc := getTimezone()
fm := template.FuncMap{
// stateColor maps container states to design-system-v2 semantic tokens,
// consumed as class suffixes (tag-run, stack-state-run, state-text-run...).
// Exception-color principle: a customer-stopped app is neutral, NOT red —
// genuine failures surface via unhealthy state + the alert system.
"stateColor": func(state stacks.ContainerState) string {
switch state {
case stacks.StateRunning:
return "run"
case stacks.StateStarting, stacks.StateDeploying:
return "progress"
case stacks.StateUnhealthy:
return "warn"
case stacks.StateRestarting:
// a restart loop is a problem, not progress
return "warn"
case stacks.StateDegraded:
// R-51: a supervised member is dead — a genuine failure, not a user action
return "warn"
case stacks.StateStopped, stacks.StateExited:
return "neutral"
case stacks.StatePaused:
return "neutral"
case stacks.StateNotDeployed:
return "off"
default:
return "off"
}
},
// v0.264.0 (R-606): the update path's sentences, rendered per reader. These are the HUNGARIAN
// forms — exactly what the templates printed before (.UpdatePhaseLabel / .UpdateError /
// .HoldReason); localeFuncs overrides all three for another language.
"updatePhaseText": func(st interface{}) string { return stackOf(st).UpdatePhaseLabel },
"updateErrorText": func(st interface{}) string { return stackOf(st).UpdateError },
"holdText": func(st interface{}) string { return stackOf(st).HoldReason },
"stateLabel": func(state stacks.ContainerState) string {
switch state {
case stacks.StateRunning:
return "Fut"
case stacks.StateStarting:
return "Indulás..."
case stacks.StateDeploying:
return "Telepítés..."
case stacks.StateUnhealthy:
return "Nem egészséges"
case stacks.StateDegraded:
return "Részlegesen leállt"
case stacks.StateStopped, stacks.StateExited:
return "Leállítva"
case stacks.StateRestarting:
return "Újraindítás..."
case stacks.StateNotDeployed:
return "Nincs telepítve"
case stacks.StatePaused:
return "Szüneteltetve"
default:
return "Ismeretlen"
}
},
"stateIcon": func(state stacks.ContainerState) string {
switch state {
case stacks.StateRunning:
return "●"
case stacks.StateStarting, stacks.StateDeploying:
return "◐"
case stacks.StateUnhealthy, stacks.StateDegraded:
return "◑"
case stacks.StateStopped, stacks.StateExited:
return "○"
case stacks.StateRestarting:
return "◐"
default:
return "◌"
}
},
"stateStr": func(state stacks.ContainerState) string {
return string(state)
},
// isOperational returns true for any state where the stack has containers
// and is not stopped/exited — used by templates for showing action buttons
"isOperational": isOperationalState,
"routeUnpublished": routeUnpublished,
"logoURL": func(slug string) string {
return s.cfg.AppLogoURL(slug)
},
"logoPNGURL": func(slug string) string {
return s.cfg.AppLogoPNGURL(slug)
},
"appPageURL": func(slug string) string {
return s.cfg.AppPageURL(slug)
},
// usageColor: capacity meters are blue below 70%, amber 70–85, red ≥85 (v2).
"usageColor": func(percent float64) string {
if percent >= 85 {
return "crit"
}
if percent >= 70 {
return "warn"
}
return "nominal"
},
"subtract": func(a, b int) int {
r := a - b
if r < 0 {
return 0
}
return r
},
"screenshotURL": func(slug string, index int) string {
return s.cfg.AppScreenshotURL(slug, index)
},
"seq": func(n int) []int {
result := make([]int, n)
for i := range result {
result[i] = i + 1
}
return result
},
// tempColor: thresholds unchanged; outputs remapped to v2 tokens.
"tempColor": func(celsius float64) string {
if celsius > 75 {
return "crit"
}
if celsius >= 60 {
return "warn"
}
return "nominal"
},
"fmtTemp": func(celsius float64) string {
return fmt.Sprintf("%.0f°C", celsius)
},
"filterCategory": func(state stacks.ContainerState, deployed bool) string {
switch state {
case stacks.StateRunning, stacks.StateStarting, stacks.StateUnhealthy, stacks.StateRestarting:
return "running"
case stacks.StateStopped, stacks.StateExited, stacks.StatePaused, stacks.StateDegraded:
// R-51: degraded filters with the stopped set — the customer's question is
// "is it working", and a stack with a dead supervised member is not.
return "stopped"
default:
if deployed {
return "stopped"
}
return "available"
}
},
"timeAgo": func(t time.Time) string {
if t.IsZero() {
return "–"
}
now := time.Now().In(loc)
d := now.Sub(t.In(loc))
switch {
case d < time.Minute:
return "most"
case d < time.Hour:
return fmt.Sprintf("%d perce", int(d.Minutes()))
case d < 24*time.Hour:
return fmt.Sprintf("%d órája", int(d.Hours()))
case d < 48*time.Hour:
return "tegnap"
default:
return fmt.Sprintf("%d napja", int(d.Hours()/24))
}
},
// timeAgoStr is timeAgo for RFC3339 string timestamps (e.g. OffboxTarget.LastRun,
// which persists as a string in settings.json). Found during D0: the backups page
// passed the raw string to timeAgo and 500'd once an off-box backup had ever run.
"timeAgoStr": func(s string) string {
t, err := time.Parse(time.RFC3339, s)
if err != nil {
return s
}
now := time.Now().In(loc)
d := now.Sub(t.In(loc))
switch {
case d < time.Minute:
return "most"
case d < time.Hour:
return fmt.Sprintf("%d perce", int(d.Minutes()))
case d < 24*time.Hour:
return fmt.Sprintf("%d órája", int(d.Hours()))
case d < 48*time.Hour:
return "tegnap"
default:
return fmt.Sprintf("%d napja", int(d.Hours()/24))
}
},
// fmtTime / fmtTimeStr / fmtTimeShort / fmtMB / fmtGB / fmtBytes / fmtLoad / humanBytes:
// formatFuncs below (R-562 — per language).
"dbTypeLabel": func(t backup.DBType) string {
switch t {
case backup.DBTypePostgres:
return "PostgreSQL"
case backup.DBTypeMariaDB:
return "MariaDB"
default:
return string(t)
}
},
"nextRunLabel": func(t time.Time) string {
if t.IsZero() {
return "–"
}
lt := t.In(loc)
now := time.Now().In(loc)
timeStr := lt.Format("15:04")
if lt.Year() == now.Year() && lt.YearDay() == now.YearDay() {
return "ma " + timeStr
}
if lt.Year() == now.Year() && lt.YearDay() == now.YearDay()+1 {
return "holnap " + timeStr
}
return i18n.FormatDate(i18n.Default, lt) + " " + timeStr
},
// R-572 (v0.258.0) — `pruneLabel` and `nextPruneLabel` WERE HERE, and they are deleted
// rather than translated. The row said "a template calling either renders Hungarian on an
// English page"; measured 2026-09-2X, NO template and no Go file called either — they were
// dead func-map entries returning „vasárnap" / „naponta" / „ma". Translating dead code
// would have added machinery with no reader and a test pinning a fiction.
//
// Deleting is FAIL-LOUD, which is why it is the safe direction: html/template refuses to
// parse a template naming an undefined function, and loadTemplates parses every template
// once per supported language at startup. A future template that needs a weekday will get
// "function ... not defined" at boot rather than Hungarian on an English page.
"fmtDuration": func(d time.Duration) string {
if d < time.Second {
return "< 1s"
}
if d < time.Minute {
return fmt.Sprintf("%ds", int(d.Seconds()))
}
return fmt.Sprintf("%dm %ds", int(d.Minutes()), int(d.Seconds())%60)
},
"shortID": func(id string) string {
if len(id) > 8 {
return id[:8]
}
return id
},
// statusText maps DR restore status codes to Hungarian labels.
"statusText": func(status string) string {
switch status {
case "pending":
return "Várakozik"
case "restoring":
return "Visszaállítás..."
case "done":
return "Kész"
case "failed":
return "Sikertelen"
case "skipped":
return "Kihagyva"
default:
return status
}
},
// json marshals a value to JSON for embedding in <script> blocks.
"json": func(v interface{}) template.JS {
b, _ := json.Marshal(v)
return template.JS(b)
},
// dict builds a map from key/value pairs — the argument carrier for the shared
// app_list_row partial (app_row.html). Keys must be strings.
"dict": func(pairs ...interface{}) (map[string]interface{}, error) {
if len(pairs)%2 != 0 {
return nil, fmt.Errorf("dict: odd argument count %d", len(pairs))
}
m := make(map[string]interface{}, len(pairs)/2)
for i := 0; i < len(pairs); i += 2 {
k, ok := pairs[i].(string)
if !ok {
return nil, fmt.Errorf("dict: key %d is not a string", i)
}
m[k] = pairs[i+1]
}
return m, nil
},
// appHref is appPageURL that yields "" for a slug-less stack, so the shared row
// partial's {{with .Href}} skips the data-href attribute entirely.
"appHref": func(slug string) string {
if slug == "" {
return ""
}
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,
// updateBadge is the SECOND *MetaBadge-returning entry the type was built for: it says
// whether a deployed app is running what the catalog currently pins, and for how long it
// has not been. Pair it with the same `meta_badge` partial. Renders NOTHING when there is
// no record — absent means unknown, never "up to date". Information only: it is wired to
// no action and changes no button.
"updateBadge": updateBadge,
// 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,
// 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 {
for _, p := range pages {
if p == currentPage {
return true
}
}
return false
},
}
// R-562: the date, size and number formatters, in Hungarian here; loadTemplates swaps in each
// language's own set (formatFuncs).
for name, fn := range formatFuncs(i18n.Default) {
fm[name] = fn
}
return fm
}
// fmtRFC3339Local renders an RFC3339 STRING as an absolute Budapest-local date-time in lang (R-562),
// returning the input unchanged when it does not parse. The ONE implementation behind the
// `fmtTimeStr` template helper and every Go-side caller.
//
// R-101: the Tier-2 restore confirm dialog printed a raw RFC3339 stamp at the moment the customer
// decides whether to restore — a UTC machine timestamp is not something a customer can reason about.
// R-102: a HANDLER renders the same date the same way (the confirm in the template and the outcome in
// Go name one copy's date; two renderings that could disagree is how a customer confirms one date and
// is told another).
func fmtRFC3339Local(lang, s string) string {
t, err := time.Parse(time.RFC3339, s)
if err != nil {
// A date another package already wrote as "2006-01-02 15:04" (appexport's bundle list) is
// re-laid in lang, its clock untouched.
if lt, err2 := time.Parse("2006-01-02 15:04", s); err2 == nil {
return i18n.FormatDateTime(lang, lt)
}
return s
}
return i18n.FormatDateTime(lang, t.In(getTimezone()))
}
// fmtLocalTime renders a time as a Budapest-local date-time in lang (R-562). "–" for a zero time.
func fmtLocalTime(lang string, t time.Time) string {
if t.IsZero() {
return "–"
}
return i18n.FormatDateTime(lang, t.In(getTimezone()))
}
// fmtSizeBytes is the dashboard's B/KB/MB/GB size, one decimal, in lang's decimal separator.
func fmtSizeBytes(lang string, b int64) string {
const (
kb = 1024
mb = 1024 * kb
gb = 1024 * mb
)
switch {
case b >= int64(gb):
return i18n.FormatDecimal(lang, float64(b)/float64(gb), 1) + " GB"
case b >= int64(mb):
return i18n.FormatDecimal(lang, float64(b)/float64(mb), 1) + " MB"
case b >= int64(kb):
return i18n.FormatDecimal(lang, float64(b)/float64(kb), 1) + " KB"
default:
return fmt.Sprintf("%d B", b)
}
}
// formatFuncs (R-562, `09` §3 decision 223) — every template func that prints a date, a size or a
// decimal number, for ONE language: Hungarian „2026. 10. 10. 15:04" and a decimal comma, English
// „2026-10-10 15:04" and a decimal point. The layouts live in internal/i18n (FormatDateTime and
// siblings); this map only binds them to the names the templates call. Pinned by
// TestFormatFuncs_PerLanguage.
func formatFuncs(lang string) template.FuncMap {
loc := getTimezone()
return template.FuncMap{
"fmtMB": func(mb uint64) string {
if mb >= 1024 {
gb := float64(mb) / 1024.0
if gb >= 10 {
return i18n.FormatDecimal(lang, gb, 0) + " GB"
}
return i18n.FormatDecimal(lang, gb, 1) + " GB"
}
return fmt.Sprintf("%d MB", mb)
},
"fmtGB": func(gb float64) string {
if gb >= 100 {
return i18n.FormatDecimal(lang, gb, 0) + " GB"
}
if gb >= 10 {
return i18n.FormatDecimal(lang, gb, 1) + " GB"
}
return i18n.FormatDecimal(lang, gb, 2) + " GB"
},
"fmtLoad": func(load float64) string { return i18n.FormatDecimal(lang, load, 2) },
"fmtTimeStr": func(s string) string { return fmtRFC3339Local(lang, s) },
"fmtTime": func(t time.Time) string { return fmtLocalTime(lang, t) },
// humanBytes (v0.200.0, R-193) shares the backup package's formatter so the recovery page's
// listing reads like every other size; its decimal point follows the language here.
"humanBytes": func(n int64) string { return i18n.LocalizeDecimals(lang, backup.HumanizeBytes(n)) },
"fmtTimeShort": func(t time.Time) string {
if t.IsZero() {
return "–"
}
lt := t.In(loc)
now := time.Now().In(loc)
if lt.Year() == now.Year() && lt.YearDay() == now.YearDay() {
return lt.Format("15:04")
}
return i18n.FormatMonthDayTime(lang, lt)
},
"fmtBytes": func(b int64) string { return fmtSizeBytes(lang, b) },
// numText rewrites the decimal point of a size another package already formatted („1.5 GB",
// the *Human fields) into this language's separator.
"numText": func(s string) string { return i18n.LocalizeDecimals(lang, s) },
}
}
// stackOf lets the update-path template funcs take a stack the way each page holds it (app_info: a
// *stacks.Stack; stacks: a stacks.Stack in a range). Anything else is an empty stack — the func then
// prints an empty string, which a render test would see.
func stackOf(v interface{}) stacks.Stack {
switch st := v.(type) {
case stacks.Stack:
return st
case *stacks.Stack:
if st != nil {
return *st
}
}
return stacks.Stack{}
}