package ui import ( "encoding/base64" "encoding/json" "fmt" "html/template" "strconv" "strings" "time" ) // funcMap returns the template functions every page can call. // Kept small on purpose: anything fancier belongs in the handler, // which can pre-compute and pass primitives into the view. func funcMap() template.FuncMap { return template.FuncMap{ "bytes": formatBytes, "relTime": formatRelTime, "comma": formatComma, "deref": derefStr, "timeNotZero": func(t *time.Time) bool { return t != nil && !t.IsZero() }, "joinDot": func(parts []string) string { return strings.Join(parts, " · ") }, "absTime": func(t time.Time) string { if t.IsZero() { return "—" } return t.Format("2006-01-02 15:04:05") }, // b64 encodes a json.RawMessage (or any []byte / string) as // base64 — used by audit.html to stash arbitrary JSON in a // data- attribute without fighting html/template's contextual // escaping. JS atob() decodes on click. "b64": func(v any) string { switch x := v.(type) { case json.RawMessage: return base64.StdEncoding.EncodeToString(x) case []byte: return base64.StdEncoding.EncodeToString(x) case string: return base64.StdEncoding.EncodeToString([]byte(x)) default: return base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%v", x))) } }, // sortDir computes the dir param for a sort-header link: // click the active column → toggle asc/desc; click any other // column → start at desc (newest-first / Z→A) since that's // the conventional default for date and frequency-style data. "sortDir": func(thisCol, currentCol, currentDir string) string { if thisCol != currentCol { return "desc" } if currentDir == "asc" { return "desc" } return "asc" }, // sortGlyph returns the unicode arrow glyph for the sort // header — empty string for inactive columns so they don't // shout. "sortGlyph": func(thisCol, currentCol, currentDir string) string { if thisCol != currentCol { return "" } if currentDir == "asc" { return "↑" } return "↓" }, "derefInt": func(p *int) int { if p == nil { return 0 } return *p }, "sub": func(a, b int) int { return a - b }, // durationHuman formats the elapsed time between two *time.Time // values as a short human string: "350ms", "4.2s", "2m 15s", // "1h 4m". Returns "—" when either pointer is nil. "durationHuman": func(start, end *time.Time) string { if start == nil || end == nil { return "—" } d := end.Sub(*start) if d < 0 { d = -d } if d < time.Second { return fmt.Sprintf("%dms", d.Milliseconds()) } if d < time.Minute { return fmt.Sprintf("%.1fs", d.Seconds()) } if d < time.Hour { return fmt.Sprintf("%dm %ds", int(d.Minutes()), int(d.Seconds())%60) } return fmt.Sprintf("%dh %dm", int(d.Hours()), int(d.Minutes())%60) }, // joinComma joins a slice with ", ". Used by the schedule list // to render retention summaries. "joinComma": func(parts []string) string { return strings.Join(parts, ", ") }, // list packs strings into a slice — handy for inline ranges // in templates (e.g. quick-pick cron presets). "list": func(items ...string) []string { return items }, // dict builds a map[string]any from alternating key-value pairs. // Useful for passing multiple named values to a sub-template: // {{template "foo" (dict "A" $a "B" $b)}} "dict": func(pairs ...any) map[string]any { m := make(map[string]any, len(pairs)/2) for i := 0; i+1 < len(pairs); i += 2 { if k, ok := pairs[i].(string); ok { m[k] = pairs[i+1] } } return m }, // mapGet retrieves a string value from a map[string]string by key. // Returns "" when the key is absent or the map is nil. Used by the // alert_row partial to resolve host_id → host name. "mapGet": func(m map[string]string, key *string) string { if m == nil || key == nil { return "" } return m[*key] }, // alertStatus derives the display status of an alert from its DB // fields: "open", "acknowledged", or "resolved". // Accepts any value — returns "" for unrecognised input so templates // can still render safely. "alertStatus": func(resolvedAt, acknowledgedAt any) string { isSet := func(v any) bool { if v == nil { return false } switch t := v.(type) { case *time.Time: return t != nil } return false } if isSet(resolvedAt) { return "resolved" } if isSet(acknowledgedAt) { return "acknowledged" } return "open" }, // stillHappening returns true when last_seen_at is within the last // 60 seconds — used to render the "still happening · Ns ago" pill // on alert rows where the signal is still firing. "stillHappening": func(v any) bool { var t time.Time switch x := v.(type) { case time.Time: t = x case *time.Time: if x == nil { return false } t = *x default: return false } return time.Since(t) < 60*time.Second }, } } // formatBytes renders a byte count as a short human string — // "412 GB", "3.7 TB", "8.4 GB". Single decimal place for sub-1000 // values, none above. Returns "—" for zero so the dashboard's // "never run" rows read clean. func formatBytes(n int64) template.HTML { if n == 0 { return template.HTML(``) } const ( kb = 1000 mb = 1000 * kb gb = 1000 * mb tb = 1000 * gb ) var ( val float64 unit string ) switch { case n >= tb: val, unit = float64(n)/float64(tb), "TB" case n >= gb: val, unit = float64(n)/float64(gb), "GB" case n >= mb: val, unit = float64(n)/float64(mb), "MB" case n >= kb: val, unit = float64(n)/float64(kb), "kB" default: return template.HTML(fmt.Sprintf(`%d B`, n)) } num := strconv.FormatFloat(val, 'f', -1, 64) if val < 100 && !strings.Contains(num, ".") { num += ".0" } else if val < 100 && strings.Contains(num, ".") { // One decimal max, e.g. "3.7" not "3.74". idx := strings.Index(num, ".") if len(num) > idx+2 { num = num[:idx+2] } } else { // Above 100, no decimals — "412" not "412.4". if idx := strings.Index(num, "."); idx > 0 { num = num[:idx] } } return template.HTML(fmt.Sprintf(`%s %s`, num, unit)) } // formatRelTime renders a time as a short relative string like // "3m ago" / "2d ago" / "5w ago". Future times render as // "in 5m"-style. Accepts *time.Time or time.Time so templates can // pass either without fighting Go's lack of an address-of operator. // Anything else returns "—". // // The output is wrapped in a