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)) } }, // fmtTimeStr renders an RFC3339 STRING as an absolute Budapest-local date-time. R-101: the // Tier-2 restore confirm dialog printed a raw RFC3339 stamp ("2026-07-28T01:30:00Z") at the // moment the customer decides whether to restore — a UTC machine timestamp is not something a // customer can reason about. Absolute rather than relative here on purpose: "3 napja" is fine on // a status card, but a restore decision deserves the actual date. "fmtTimeStr": func(s string) string { t, err := time.Parse(time.RFC3339, s) if err != nil { return s } return t.In(loc).Format("2006-01-02 15:04") }, "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