285dd1032f
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).
430 lines
12 KiB
Go
430 lines
12 KiB
Go
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 70–85, 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
|
||
},
|
||
}
|
||
}
|