orchard
mirrorEvery site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.
blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics
1package main
2
3import (
4 "encoding/json"
5 "fmt"
6 "html/template"
7 "math"
8 "strconv"
9 "strings"
10 "time"
11)
12
13var templateFuncs = template.FuncMap{
14 "dict": dict,
15 "json": jsonBlock,
16 "naturaltime": naturalTime,
17 "num": formatNum,
18 "msSavings": msSavings,
19 "urlPath": urlPath,
20 "pct": pct,
21 "pct1": pct1,
22 "metricClass": metricClass,
23 "scoreClass": scoreClass,
24 "uptimeClass": uptimeClass,
25 "lighthouseClass": lighthouseClass,
26 "countClass": countClass,
27 "seq": seq,
28 "add": func(a, b int) int { return a + b },
29 "upper": strings.ToUpper,
30 "hasPrefix": strings.HasPrefix,
31}
32
33// dict lets a partial take more than one value; a template has a single dot.
34func dict(pairs ...any) (map[string]any, error) {
35 if len(pairs)%2 != 0 {
36 return nil, fmt.Errorf("dict: odd number of arguments (%d)", len(pairs))
37 }
38 out := make(map[string]any, len(pairs)/2)
39 for i := 0; i < len(pairs); i += 2 {
40 key, ok := pairs[i].(string)
41 if !ok {
42 return nil, fmt.Errorf("dict: key %d is %T, want string", i, pairs[i])
43 }
44 out[key] = pairs[i+1]
45 }
46 return out, nil
47}
48
49// jsonBlock renders a value for a <script type="application/json"> block.
50// HTML escaping stays on so a value containing "</script>" cannot close the
51// block. html/template cannot work that out inside a non-JavaScript script
52// type, and template.JS turns its escaping off anyway, so this is the only
53// thing standing between a stored string and the end of the element.
54func jsonBlock(v any) (template.JS, error) {
55 buf, err := json.Marshal(v)
56 if err != nil {
57 return "", err
58 }
59 return template.JS(buf), nil
60}
61
62// naturalTime renders a timestamp as a relative phrase. A nil time is "never",
63// which reads differently from a field that failed to load.
64func naturalTime(t *time.Time) string {
65 if t == nil {
66 return "never"
67 }
68
69 d := time.Since(*t)
70 // Future timestamps are normal here: next_run_at is one.
71 suffix := "ago"
72 if d < 0 {
73 d = -d
74 suffix = "from now"
75 }
76
77 // Rounded, not truncated: 47h59m59.9s truncates to "1 day from now".
78 switch secs := int64(math.Round(d.Seconds())); {
79 case secs < 60:
80 if suffix == "ago" {
81 return "just now"
82 }
83 return "in a moment"
84 case secs < 3600:
85 return plural(secs/60, "minute", suffix)
86 case secs < 86_400:
87 return plural(secs/3600, "hour", suffix)
88 case secs < 86_400*30:
89 return plural(secs/86_400, "day", suffix)
90 case secs < 86_400*365:
91 return plural(secs/(86_400*30), "month", suffix)
92 default:
93 return plural(secs/(86_400*365), "year", suffix)
94 }
95}
96
97func plural(n int64, unit, suffix string) string {
98 if n == 1 {
99 return fmt.Sprintf("1 %s %s", unit, suffix)
100 }
101 return fmt.Sprintf("%d %ss %s", n, unit, suffix)
102}
103
104// formatNum groups thousands with commas.
105func formatNum(v any) string {
106 var n int64
107 switch t := v.(type) {
108 case int:
109 n = int64(t)
110 case int64:
111 n = t
112 case *int64:
113 if t == nil {
114 return "0"
115 }
116 n = *t
117 case float64:
118 n = int64(t)
119 default:
120 return fmt.Sprint(v)
121 }
122
123 s := strconv.FormatInt(n, 10)
124 negative := strings.HasPrefix(s, "-")
125 s = strings.TrimPrefix(s, "-")
126
127 var b strings.Builder
128 for i, digit := range s {
129 if i > 0 && (len(s)-i)%3 == 0 {
130 b.WriteByte(',')
131 }
132 b.WriteRune(digit)
133 }
134 if negative {
135 return "-" + b.String()
136 }
137 return b.String()
138}
139
140// msSavings renders a Lighthouse saving as "1.2 s" or "420 ms". Zero renders as
141// the empty string, so the template can show a placeholder instead.
142func msSavings(v any) string {
143 var ms float64
144 switch t := v.(type) {
145 case float64:
146 ms = t
147 case int64:
148 ms = float64(t)
149 case int:
150 ms = float64(t)
151 default:
152 return ""
153 }
154
155 switch {
156 case ms <= 0:
157 return ""
158 case ms >= 1000:
159 return fmt.Sprintf("%.1f s", ms/1000)
160 default:
161 return fmt.Sprintf("%.0f ms", ms)
162 }
163}
164
165// urlPath reduces an absolute URL to its path and query.
166func urlPath(raw string) string {
167 u, err := parseHTTPURL(raw)
168 if err != nil {
169 return raw
170 }
171 path := u.EscapedPath()
172 if path == "" {
173 path = "/"
174 }
175 if u.RawQuery != "" {
176 path += "?" + u.RawQuery
177 }
178 return path
179}
180
181// pct renders count as a whole-number percentage of total.
182func pct(count, total int64) int64 {
183 if total <= 0 {
184 return 0
185 }
186 return count * 100 / total
187}
188
189// pct1 renders a nullable percentage with one decimal, keeping the column aligned.
190func pct1(v *float64) string {
191 if v == nil {
192 return "—"
193 }
194 return strconv.FormatFloat(*v, 'f', 1, 64)
195}
196
197// seq is a counted loop, which templates otherwise cannot express: range needs
198// something to range over.
199func seq(n int) []struct{} { return make([]struct{}, n) }
200
201// uptimeClass bands a recent-uptime percentage; higher is better. It takes a
202// pointer because the value is nullable and a template cannot rebind the dot
203// inside an {{if}}.
204func uptimeClass(pct *float64) string {
205 if pct == nil {
206 return "muted"
207 }
208 switch {
209 case *pct >= 99:
210 return "ok"
211 case *pct < 95:
212 return "down"
213 default:
214 return "warn"
215 }
216}
217
218// lighthouseClass bands the average of the four Lighthouse scores. 90 and 80,
219// not Lighthouse's 90 and 50, because an average of 60 across four categories is
220// worse than 60 in one; scoreClass keeps Lighthouse's bands.
221func lighthouseClass(score *int64) string {
222 if score == nil {
223 return "muted"
224 }
225 switch {
226 case *score >= 90:
227 return "ok"
228 case *score >= 80:
229 return "warn"
230 default:
231 return "down"
232 }
233}
234
235// countClass bands a finding count. Lower is better, so the comparisons run the
236// opposite way to uptimeClass.
237func countClass(n, warnAbove, downAbove int) string {
238 switch {
239 case n > downAbove:
240 return "down"
241 case n > warnAbove:
242 return "warn"
243 default:
244 return "ok"
245 }
246}
247
248// metricClass bands one weighted metric's 0-to-1 score. A nil score means
249// Lighthouse could not measure it, which is not the same as measuring it bad.
250func metricClass(score *float64) string {
251 switch {
252 case score == nil:
253 return "muted"
254 case *score >= 0.9:
255 return "green"
256 case *score >= 0.5:
257 return "amber"
258 default:
259 return "danger"
260 }
261}
262
263// scoreClass maps a Lighthouse score to Lighthouse's own three bands, so the
264// dashboard colours match the tool the numbers came from.
265func scoreClass(score int64) string {
266 switch {
267 case score >= 90:
268 return "good"
269 case score >= 50:
270 return "average"
271 default:
272 return "poor"
273 }
274}