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, stacks.StateDeploying: 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.StateDeploying: return "Telepíté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, stacks.StateDeploying: 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 }, // 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 } }, // 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 }, } }