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))) } }, "derefInt": func(p *int) int { if p == nil { return 0 } return *p }, "sub": func(a, b int) int { return a - b }, // 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 "—". func formatRelTime(v any) string { var t time.Time switch x := v.(type) { case time.Time: t = x case *time.Time: if x == nil { return "—" } t = *x default: return "—" } if t.IsZero() { return "—" } d := time.Since(t) suffix := "ago" if d < 0 { d = -d suffix = "from now" } switch { case d < time.Minute: return fmt.Sprintf("%ds %s", int(d.Seconds()), suffix) case d < time.Hour: return fmt.Sprintf("%dm %s", int(d.Minutes()), suffix) case d < 24*time.Hour: return fmt.Sprintf("%dh %s", int(d.Hours()), suffix) case d < 7*24*time.Hour: return fmt.Sprintf("%dd %s", int(d.Hours()/24), suffix) default: return fmt.Sprintf("%dw %s", int(d.Hours()/(24*7)), suffix) } } // formatComma renders 1847 as "1,847". Used for snapshot counts and // any other count that benefits from grouping at this scale. // Accepts int / int64 / int32 — anything else returns "—". func formatComma(v any) string { var n int64 switch x := v.(type) { case int: n = int64(x) case int32: n = int64(x) case int64: n = x default: return "—" } s := strconv.FormatInt(n, 10) if n < 1000 && n > -1000 { return s } // Hand-rolled grouping; Go has no builtin and we don't want a // dep for this. Negative numbers handled by stripping the sign, // grouping, then putting it back. neg := false if s[0] == '-' { neg = true s = s[1:] } var b strings.Builder for i, c := range s { if i > 0 && (len(s)-i)%3 == 0 { b.WriteByte(',') } b.WriteRune(c) } if neg { return "-" + b.String() } return b.String() } // derefStr returns the string a pointer points to, or "" if nil. // Avoids "" appearing in templates that hit a missing FK. func derefStr(p *string) string { if p == nil { return "" } return *p }