CI / test (push) Successful in 1m19s
Two things the dashboard was missing: it could not act, and it was a column of numbers. Account administration moves to /admin/users — approving into a project, suspending, reinstating. That is the work an operator does constantly and it should not need a terminal. Three things stay on the CLI, and the line is not arbitrary: promoting someone to admin, resetting a password, and issuing a key on another person's behalf each turn a stolen admin session into more than it was — permanent access, or somebody else's account. Approving and suspending do not. Suspending signs the account out everywhere, or suspension is advisory until the session happens to expire, and an admin cannot suspend themselves, which is not a thing an interface should let you do by misclicking. The charts are inline SVG generated in Go. A charting library would mean a JavaScript toolchain in CI and a megabyte of bundle for what is a list of rectangles, and the single-binary goal rules that out. Hourly over a day: fine enough to show a working day, coarse enough that one slow minute is not an incident. Empty hours are filled in, because a chart with hours missing where nothing happened reads as a chart with hours missing from the data. The SVG is marked as trusted HTML, so everything that reaches it is either a number this package computed or escaped explicitly, and a bar's colour can only be one of four names — a colour that could be anything is a way to write CSS into every page the chart appears on. There are tests for both. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
126 lines
3.6 KiB
Go
126 lines
3.6 KiB
Go
package web
|
|
|
|
import (
|
|
"fmt"
|
|
"html/template"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Inline SVG, generated here.
|
|
//
|
|
// A chart is a shape, and a column of numbers makes the reader compute the
|
|
// shape themselves. Drawing it costs about a hundred lines and no dependency:
|
|
// a charting library would mean a JavaScript toolchain in CI, a build step, and
|
|
// a megabyte of bundle for what is fundamentally a list of rectangles.
|
|
//
|
|
// The output is marked template.HTML, so everything interpolated into it must
|
|
// be a number this file produced. No caller-supplied string is ever written
|
|
// into the markup except through escapeText.
|
|
|
|
// Series is one set of bars.
|
|
type Series struct {
|
|
Values []float64
|
|
Labels []string
|
|
// Colour is a CSS custom property name, so the chart follows the page's
|
|
// light and dark palettes instead of pinning its own.
|
|
Colour string
|
|
}
|
|
|
|
const (
|
|
chartW = 720.0
|
|
chartH = 120.0
|
|
chartPad = 18.0
|
|
)
|
|
|
|
// BarChart renders a series as bars with a baseline and a peak label.
|
|
//
|
|
// Empty and all-zero series render as an empty frame rather than nothing: a
|
|
// missing chart reads as a broken page, and a flat one reads as no traffic,
|
|
// which is the truth.
|
|
func BarChart(s Series, unit string) template.HTML {
|
|
if s.Colour == "" {
|
|
s.Colour = "--accent"
|
|
}
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, `<svg class="chart" viewBox="0 0 %.0f %.0f" preserveAspectRatio="none" role="img">`,
|
|
chartW, chartH)
|
|
|
|
maxV := 0.0
|
|
for _, v := range s.Values {
|
|
if v > maxV {
|
|
maxV = v
|
|
}
|
|
}
|
|
// Baseline first so bars sit on it.
|
|
fmt.Fprintf(&b, `<line x1="0" y1="%.1f" x2="%.0f" y2="%.1f" class="axis"/>`,
|
|
chartH-chartPad, chartW, chartH-chartPad)
|
|
|
|
if n := len(s.Values); n > 0 && maxV > 0 {
|
|
slot := chartW / float64(n)
|
|
width := slot * 0.72
|
|
for i, v := range s.Values {
|
|
h := (v / maxV) * (chartH - chartPad*2)
|
|
if h < 1 && v > 0 {
|
|
// A bar for a real value must be visible, or an hour with
|
|
// traffic looks identical to one without.
|
|
h = 1
|
|
}
|
|
x := float64(i)*slot + (slot-width)/2
|
|
y := chartH - chartPad - h
|
|
title := ""
|
|
if i < len(s.Labels) {
|
|
title = fmt.Sprintf("<title>%s</title>", escapeText(s.Labels[i]))
|
|
}
|
|
fmt.Fprintf(&b,
|
|
`<rect x="%.2f" y="%.2f" width="%.2f" height="%.2f" style="fill:var(%s)">%s</rect>`,
|
|
x, y, width, h, cssVar(s.Colour), title)
|
|
}
|
|
}
|
|
b.WriteString(`</svg>`)
|
|
|
|
peak := fmt.Sprintf("peak %s %s", compact(maxV), unit)
|
|
if maxV == 0 {
|
|
peak = "nothing recorded"
|
|
}
|
|
return template.HTML(`<div class="chart-wrap">` + b.String() + //nolint:gosec // built above
|
|
`<span class="chart-peak">` + string(escapeText(peak)) + `</span></div>`)
|
|
}
|
|
|
|
// cssVar restricts what can reach a style attribute to names this package
|
|
// defines, so a colour can never become a way to inject CSS.
|
|
func cssVar(name string) string {
|
|
switch name {
|
|
case "--accent", "--ok", "--warn", "--bad":
|
|
return name
|
|
default:
|
|
return "--accent"
|
|
}
|
|
}
|
|
|
|
// escapeText is the only path by which non-numeric content enters the markup.
|
|
func escapeText(s string) template.HTML {
|
|
return template.HTML(template.HTMLEscapeString(s)) //nolint:gosec // escaped on the line above
|
|
}
|
|
|
|
// compact renders a number the way a person reads one at a glance.
|
|
func compact(v float64) string {
|
|
switch {
|
|
case v >= 1e9:
|
|
return fmt.Sprintf("%.1fB", v/1e9)
|
|
case v >= 1e6:
|
|
return fmt.Sprintf("%.1fM", v/1e6)
|
|
case v >= 1e3:
|
|
return fmt.Sprintf("%.1fk", v/1e3)
|
|
case v == float64(int64(v)):
|
|
return fmt.Sprintf("%d", int64(v))
|
|
default:
|
|
return fmt.Sprintf("%.1f", v)
|
|
}
|
|
}
|
|
|
|
// hourLabel is what a bar's tooltip says.
|
|
func hourLabel(t time.Time, v float64, unit string) string {
|
|
return fmt.Sprintf("%s — %s %s", t.Local().Format("Mon 15:04"), compact(v), unit)
|
|
}
|