2487681396
gofmt -w across the controller tree (46 files) so gofmt -l is empty — disarms the
formatting landmine where a targeted edit + accidental gofmt -w swept ~46 unrelated
files. Pure formatting: whitespace + gofmt's optional-semicolon removal in reflowed
inline closures. One doc comment reworded ('' -> 'the empty string') to avoid gofmt's
Go-1.19 doc-comment typographic substitition ('' -> curly quote) muddying its meaning.
No build/vet/test behavior change.
485 lines
15 KiB
Go
485 lines
15 KiB
Go
package web
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"hash/fnv"
|
||
"html/template"
|
||
"regexp"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
"unicode"
|
||
|
||
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
||
"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()
|
||
|
||
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": 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"
|
||
},
|
||
"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)
|
||
},
|
||
// 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,
|
||
// 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
|
||
},
|
||
}
|
||
}
|