Files
deploy-felhom-compose/controller/internal/web/funcmap.go
T
admin d160c6c06d v0.12.4 — 15 bug fixes (CRITICAL/HIGH/MEDIUM)
CRITICAL:
- C1: SetAppBackupBulk data loss + nil map panic (settings.go)
- C2: UpdateStackConfig nil Env map panic (deploy.go)
- C3: ValidateDump missing scanner.Err() check (dbdump.go)

HIGH:
- H1: nextDailyRun DST bug — use time.Date(day+1) not Add(24h)
- H2: Cache Europe/Budapest timezone with sync.Once in scheduler
- H3: settings.save() leaks .tmp file on WriteFile failure
- H4: SetNotificationPrefs nil pointer panic
- H5: appDirSize + getDirSizeBytes ignore Sscanf return value
- H6: getDirSizeBytes has no timeout — add 30s context
- H7: dbdump.go tmpFile not using defer Close
- H8: UpdateCrossDriveStatus misleading comment

MEDIUM:
- M1: Replace custom containsBytes with strings.Contains
- M2: scheduler.Every() validates interval > 0
- M3: executeJob panic recovery now sets LastRun
- M4: logPostStartStatus copies env slice before goroutine
- M5: Cache timezone in web package via getTimezone() sync.Once

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-18 07:50:02 +01:00

310 lines
7.3 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 (
"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
}
// templateFuncMap returns the FuncMap used by all HTML templates.
func (s *Server) templateFuncMap() template.FuncMap {
loc := getTimezone()
return template.FuncMap{
"stateColor": func(state stacks.ContainerState) string {
switch state {
case stacks.StateRunning:
return "green"
case stacks.StateStarting:
return "orange"
case stacks.StateUnhealthy:
return "yellow"
case stacks.StateStopped, stacks.StateExited:
return "red"
case stacks.StateRestarting:
return "yellow"
default:
return "gray"
}
},
"stateLabel": func(state stacks.ContainerState) string {
switch state {
case stacks.StateRunning:
return "Fut"
case stacks.StateStarting:
return "Indulás..."
case stacks.StateUnhealthy:
return "Nem egészséges"
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:
return "◐"
case stacks.StateUnhealthy:
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:
return true
default:
return false
}
},
"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": func(percent float64) string {
if percent >= 85 {
return "red"
}
if percent >= 70 {
return "yellow"
}
return "green"
},
"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": func(celsius float64) string {
if celsius > 75 {
return "red"
}
if celsius >= 60 {
return "yellow"
}
return "green"
},
"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:
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))
}
},
"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
},
}
}