Files
felhom-controller/controller/internal/web/funcmap.go
T
admin 285dd1032f feat(v0.156.0): dead-primary alerting (R-51) + boot desired-state reconciliation (R-52)
R-51: aggregateState's mixed branch returned StateRunning ("partial"), so a stack whose
MAIN container was dead behind live helpers alerted on nothing — immich-server sat Exited
for 18 h, 100 % unreachable, no banner and no app_start_failed (audit F4). New
StateDegraded: a DOWN member whose docker restart policy is always/unless-stopped is a
fault (degraded, a down state); no/on-failure is a finished one-shot and stays benign; an
unreadable policy fails CLOSED. The unhealthy/restarting/paused/unknown exclusions are
byte-identical — folding unhealthy into down is the flapping fix-3 avoided.

R-52: new internal/bootrecon — one bounded start-once sweep at startup (2 attempts, 30 s
apart) for apps an interrupted boot left behind, inside the 90 s boot grace so a success
is silent and a failure still alerts. A zero-container stack is NEVER touched: the UI's
Stop is compose down, so a deliberate stop survives a reboot.

Both features carry a production-path wiring test (the v0.154.0 / v0.91.0 inert-seam
class). The main() assertion is an AST walk, not strings.Contains — the substring version
passed its own red-proof, because a commented-out call still contains the string.

Red-proofs run and restored: mix branch reverted -> "running" on the immich fixture;
boot hook commented out -> wiring test fails; zero-container gate dropped -> the
user-stopped app gets started.

NOTE: controller/cmd/controller/ is matched by .gitignore's `controller` entry, so new
files there need `git add -f` (and ripgrep silently skips main.go without --no-ignore).
2026-07-21 12:27:33 +02:00

430 lines
12 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"
"html/template"
"strings"
"sync"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
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
}
}
// templateFuncMap returns the FuncMap used by all HTML templates.
func (s *Server) templateFuncMap() template.FuncMap {
loc := getTimezone()
return 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"
}
},
"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": func(state stacks.ContainerState) bool {
switch state {
case stacks.StateRunning, stacks.StateStarting, stacks.StateUnhealthy, stacks.StateRestarting, stacks.StateDegraded:
return true
default:
return false
}
},
"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 7085, red ≥85 (v2).
"usageColor": func(percent float64) string {
if percent >= 85 {
return "crit"
}
if percent >= 70 {
return "warn"
}
return "nominal"
},
"fmtMB": func(mb uint64) string {
if mb >= 1024 {
gb := float64(mb) / 1024.0
if gb >= 10 {
return fmt.Sprintf("%.0f GB", gb)
}
return fmt.Sprintf("%.1f GB", gb)
}
return fmt.Sprintf("%d MB", mb)
},
"fmtGB": func(gb float64) string {
if gb >= 100 {
return fmt.Sprintf("%.0f GB", gb)
}
if gb >= 10 {
return fmt.Sprintf("%.1f GB", gb)
}
return fmt.Sprintf("%.2f GB", gb)
},
"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)
},
"fmtLoad": func(load float64) string {
return fmt.Sprintf("%.2f", load)
},
"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": func(t time.Time) string {
if t.IsZero() {
return ""
}
return t.In(loc).Format("2006-01-02 15:04")
},
"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 lt.Format("01-02 15:04")
},
"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 lt.Format("2006-01-02") + " " + timeStr
},
"pruneLabel": func(s string) string {
switch strings.ToLower(s) {
case "weekly":
return "vasárnap"
case "daily":
return "naponta"
case "sunday":
return "vasárnap"
default:
return s
}
},
"nextPruneLabel": func(schedule string) string {
now := time.Now().In(loc)
var next time.Time
switch strings.ToLower(schedule) {
case "daily":
next = now.Add(24 * time.Hour)
default: // weekly/sunday
daysUntilSunday := (7 - int(now.Weekday())) % 7
if daysUntilSunday == 0 {
if now.Hour() >= 4 {
daysUntilSunday = 7 // Already ran today, next week
} else {
return "ma" // Today (Sunday), hasn't run yet
}
}
next = now.AddDate(0, 0, daysUntilSunday)
}
return next.Format("2006-01-02")
},
"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)
},
"fmtBytes": func(b int64) string {
const (
kb = 1024
mb = 1024 * kb
gb = 1024 * mb
)
switch {
case b >= int64(gb):
return fmt.Sprintf("%.1f GB", float64(b)/float64(gb))
case b >= int64(mb):
return fmt.Sprintf("%.1f MB", float64(b)/float64(mb))
case b >= int64(kb):
return fmt.Sprintf("%.1f KB", float64(b)/float64(kb))
default:
return fmt.Sprintf("%d B", b)
}
},
"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)
},
// infraMeta resolves a protected infra stack's curated Hungarian identity
// (inframeta.go); nil for regular apps — templates branch on it.
"infraMeta": infraMetaFor,
// 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
},
}
}