P6-03 repo size trend + agent-update UI fix + dashboard polish #21
@@ -9,6 +9,7 @@ import (
|
||||
"html/template"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RenderSparkline returns an inline SVG <svg> element of the
|
||||
@@ -99,3 +100,217 @@ func RenderSparkline(points []float64, width, height int) template.HTML {
|
||||
b.WriteString(`</svg>`)
|
||||
return template.HTML(b.String())
|
||||
}
|
||||
|
||||
// Axis selects which y-axis a Series is normalised against.
|
||||
type Axis int
|
||||
|
||||
const (
|
||||
// AxisLeft maps the series to the left y-axis.
|
||||
AxisLeft Axis = iota
|
||||
// AxisRight maps the series to the right y-axis.
|
||||
AxisRight
|
||||
)
|
||||
|
||||
// Format selects how a Series' values appear in hover tooltips.
|
||||
type Format int
|
||||
|
||||
const (
|
||||
// FormatBytes formats a value as a human-readable byte size.
|
||||
FormatBytes Format = iota
|
||||
// FormatCount formats a value as an integer count.
|
||||
FormatCount
|
||||
)
|
||||
|
||||
// Series is one labelled trace on a chart.
|
||||
type Series struct {
|
||||
Name string
|
||||
Points []float64 // NaN breaks the polyline
|
||||
Stroke string // hex colour
|
||||
Axis Axis
|
||||
Format Format
|
||||
}
|
||||
|
||||
// ChartOpts controls rendering of the full trend chart.
|
||||
type ChartOpts struct {
|
||||
Width int
|
||||
Height int
|
||||
GridBands int // default 4
|
||||
EmptyLabel string // default "no data yet"
|
||||
}
|
||||
|
||||
// RenderChart returns an inline SVG <svg> with up to two y-axes,
|
||||
// one polyline per series, hover-dot per data point, and X-axis
|
||||
// labels at start / midpoint / end. With no series or empty
|
||||
// series, renders a faint baseline + EmptyLabel centred.
|
||||
func RenderChart(series []Series, days []time.Time, opts ChartOpts) template.HTML {
|
||||
if opts.Width <= 0 {
|
||||
opts.Width = 600
|
||||
}
|
||||
if opts.Height <= 0 {
|
||||
opts.Height = 220
|
||||
}
|
||||
if opts.GridBands <= 0 {
|
||||
opts.GridBands = 4
|
||||
}
|
||||
if opts.EmptyLabel == "" {
|
||||
opts.EmptyLabel = "no data yet"
|
||||
}
|
||||
const padL, padR, padT, padB = 56, 56, 16, 28
|
||||
w, h := opts.Width, opts.Height
|
||||
innerW := w - padL - padR
|
||||
innerH := h - padT - padB
|
||||
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b,
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %d %d" class="repo-trend-chart" role="img" aria-label="repo size and snapshot count over time">`,
|
||||
w, h)
|
||||
|
||||
hasData := false
|
||||
for _, s := range series {
|
||||
for _, p := range s.Points {
|
||||
if !math.IsNaN(p) {
|
||||
hasData = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if hasData {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasData || len(days) == 0 {
|
||||
fmt.Fprintf(&b,
|
||||
`<line x1="%d" y1="%d" x2="%d" y2="%d" stroke="currentColor" stroke-opacity="0.15" stroke-dasharray="3,3"/>`,
|
||||
padL, h/2, w-padR, h/2)
|
||||
fmt.Fprintf(&b,
|
||||
`<text x="%d" y="%d" text-anchor="middle" font-size="12" fill="currentColor" fill-opacity="0.4">%s</text>`,
|
||||
w/2, h/2+4, opts.EmptyLabel)
|
||||
b.WriteString(`</svg>`)
|
||||
return template.HTML(b.String())
|
||||
}
|
||||
|
||||
for i := 0; i <= opts.GridBands; i++ {
|
||||
y := padT + innerH*i/opts.GridBands
|
||||
fmt.Fprintf(&b,
|
||||
`<line x1="%d" y1="%d" x2="%d" y2="%d" stroke="currentColor" stroke-opacity="0.08"/>`,
|
||||
padL, y, w-padR, y)
|
||||
}
|
||||
|
||||
type axBounds struct {
|
||||
min, max float64
|
||||
has bool
|
||||
}
|
||||
// Use fixed-order array to avoid map iteration non-determinism.
|
||||
var axArr [2]axBounds
|
||||
for _, s := range series {
|
||||
a := &axArr[s.Axis]
|
||||
for _, p := range s.Points {
|
||||
if math.IsNaN(p) {
|
||||
continue
|
||||
}
|
||||
if !a.has {
|
||||
a.min, a.max, a.has = p, p, true
|
||||
continue
|
||||
}
|
||||
if p < a.min {
|
||||
a.min = p
|
||||
}
|
||||
if p > a.max {
|
||||
a.max = p
|
||||
}
|
||||
}
|
||||
}
|
||||
for i := range axArr {
|
||||
if axArr[i].has && axArr[i].max == axArr[i].min {
|
||||
axArr[i].max = axArr[i].min + 1
|
||||
}
|
||||
}
|
||||
|
||||
stepX := 0.0
|
||||
if len(days) > 1 {
|
||||
stepX = float64(innerW) / float64(len(days)-1)
|
||||
}
|
||||
|
||||
for _, s := range series {
|
||||
a := &axArr[s.Axis]
|
||||
if !a.has {
|
||||
continue
|
||||
}
|
||||
var seg strings.Builder
|
||||
flush := func() {
|
||||
if seg.Len() == 0 {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(&b,
|
||||
`<polyline fill="none" stroke="%s" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" points="%s"/>`,
|
||||
s.Stroke, strings.TrimSpace(seg.String()))
|
||||
seg.Reset()
|
||||
}
|
||||
for i, v := range s.Points {
|
||||
if math.IsNaN(v) {
|
||||
flush()
|
||||
continue
|
||||
}
|
||||
x := float64(padL) + stepX*float64(i)
|
||||
y := float64(padT) + float64(innerH) - (v-a.min)/(a.max-a.min)*float64(innerH)
|
||||
fmt.Fprintf(&seg, "%.2f,%.2f ", x, y)
|
||||
d := days[i]
|
||||
fmt.Fprintf(&b,
|
||||
`<circle cx="%.2f" cy="%.2f" r="2.5" fill="%s"><title>%s · %s: %s</title></circle>`,
|
||||
x, y, s.Stroke, d.Format("2006-01-02"), s.Name, formatValue(v, s.Format))
|
||||
}
|
||||
flush()
|
||||
}
|
||||
|
||||
if axArr[AxisLeft].has {
|
||||
writeAxisLabels(&b, padL-6, padT, innerH, axArr[AxisLeft].min, axArr[AxisLeft].max, FormatBytes, "end")
|
||||
}
|
||||
if axArr[AxisRight].has {
|
||||
writeAxisLabels(&b, w-padR+6, padT, innerH, axArr[AxisRight].min, axArr[AxisRight].max, FormatCount, "start")
|
||||
}
|
||||
|
||||
xLabels := []int{0, len(days) / 2, len(days) - 1}
|
||||
anchors := []string{"start", "middle", "end"}
|
||||
for i, idx := range xLabels {
|
||||
x := float64(padL) + stepX*float64(idx)
|
||||
fmt.Fprintf(&b,
|
||||
`<text x="%.2f" y="%d" text-anchor="%s" font-size="10" fill="currentColor" fill-opacity="0.55">%s</text>`,
|
||||
x, h-padB+16, anchors[i], days[idx].Format("Jan 2"))
|
||||
}
|
||||
|
||||
b.WriteString(`</svg>`)
|
||||
return template.HTML(b.String())
|
||||
}
|
||||
|
||||
func writeAxisLabels(b *strings.Builder, x, padT, innerH int, min, max float64, f Format, anchor string) {
|
||||
const bands = 4
|
||||
for i := 0; i <= bands; i++ {
|
||||
y := padT + innerH*i/bands
|
||||
v := max - (max-min)*float64(i)/float64(bands)
|
||||
fmt.Fprintf(b,
|
||||
`<text x="%d" y="%d" text-anchor="%s" font-size="10" fill="currentColor" fill-opacity="0.55">%s</text>`,
|
||||
x, y+3, anchor, formatValue(v, f))
|
||||
}
|
||||
}
|
||||
|
||||
func formatValue(v float64, f Format) string {
|
||||
switch f {
|
||||
case FormatBytes:
|
||||
return humanBytes(v)
|
||||
default:
|
||||
return fmt.Sprintf("%.0f", v)
|
||||
}
|
||||
}
|
||||
|
||||
func humanBytes(v float64) string {
|
||||
const k = 1024.0
|
||||
units := []string{"B", "KiB", "MiB", "GiB", "TiB", "PiB"}
|
||||
i := 0
|
||||
for v >= k && i < len(units)-1 {
|
||||
v /= k
|
||||
i++
|
||||
}
|
||||
if v >= 100 {
|
||||
return fmt.Sprintf("%.0f %s", v, units[i])
|
||||
}
|
||||
return fmt.Sprintf("%.1f %s", v, units[i])
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func loadGolden(t *testing.T, name string) string {
|
||||
@@ -39,3 +40,35 @@ func TestSparkline_ThreePoints(t *testing.T) {
|
||||
t.Errorf("three_points sparkline mismatch:\nwant:\n%s\ngot:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChart_Empty(t *testing.T) {
|
||||
got := strings.TrimRight(string(RenderChart(nil, nil, ChartOpts{Width: 600, Height: 220})), "\n")
|
||||
want := loadGolden(t, "chart_empty.svg")
|
||||
if got != want {
|
||||
t.Errorf("empty chart mismatch:\nwant:\n%s\ngot:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChart_TwoSeries(t *testing.T) {
|
||||
days := []time.Time{
|
||||
time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC),
|
||||
time.Date(2026, 5, 2, 0, 0, 0, 0, time.UTC),
|
||||
time.Date(2026, 5, 3, 0, 0, 0, 0, time.UTC),
|
||||
time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC),
|
||||
}
|
||||
series := []Series{
|
||||
{
|
||||
Name: "size", Stroke: "#3b82f6", Axis: AxisLeft, Format: FormatBytes,
|
||||
Points: []float64{1024, 2048, 4096, 8192},
|
||||
},
|
||||
{
|
||||
Name: "snapshots", Stroke: "#f59e0b", Axis: AxisRight, Format: FormatCount,
|
||||
Points: []float64{1, 2, 3, 4},
|
||||
},
|
||||
}
|
||||
got := strings.TrimRight(string(RenderChart(series, days, ChartOpts{Width: 600, Height: 220})), "\n")
|
||||
want := loadGolden(t, "chart_two_series.svg")
|
||||
if got != want {
|
||||
t.Errorf("two_series chart mismatch:\nwant:\n%s\ngot:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 220" class="repo-trend-chart" role="img" aria-label="repo size and snapshot count over time"><line x1="56" y1="110" x2="544" y2="110" stroke="currentColor" stroke-opacity="0.15" stroke-dasharray="3,3"/><text x="300" y="114" text-anchor="middle" font-size="12" fill="currentColor" fill-opacity="0.4">no data yet</text></svg>
|
||||
|
After Width: | Height: | Size: 381 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 220" class="repo-trend-chart" role="img" aria-label="repo size and snapshot count over time"><line x1="56" y1="16" x2="544" y2="16" stroke="currentColor" stroke-opacity="0.08"/><line x1="56" y1="60" x2="544" y2="60" stroke="currentColor" stroke-opacity="0.08"/><line x1="56" y1="104" x2="544" y2="104" stroke="currentColor" stroke-opacity="0.08"/><line x1="56" y1="148" x2="544" y2="148" stroke="currentColor" stroke-opacity="0.08"/><line x1="56" y1="192" x2="544" y2="192" stroke="currentColor" stroke-opacity="0.08"/><circle cx="56.00" cy="192.00" r="2.5" fill="#3b82f6"><title>2026-05-01 · size: 1.0 KiB</title></circle><circle cx="218.67" cy="166.86" r="2.5" fill="#3b82f6"><title>2026-05-02 · size: 2.0 KiB</title></circle><circle cx="381.33" cy="116.57" r="2.5" fill="#3b82f6"><title>2026-05-03 · size: 4.0 KiB</title></circle><circle cx="544.00" cy="16.00" r="2.5" fill="#3b82f6"><title>2026-05-04 · size: 8.0 KiB</title></circle><polyline fill="none" stroke="#3b82f6" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" points="56.00,192.00 218.67,166.86 381.33,116.57 544.00,16.00"/><circle cx="56.00" cy="192.00" r="2.5" fill="#f59e0b"><title>2026-05-01 · snapshots: 1</title></circle><circle cx="218.67" cy="133.33" r="2.5" fill="#f59e0b"><title>2026-05-02 · snapshots: 2</title></circle><circle cx="381.33" cy="74.67" r="2.5" fill="#f59e0b"><title>2026-05-03 · snapshots: 3</title></circle><circle cx="544.00" cy="16.00" r="2.5" fill="#f59e0b"><title>2026-05-04 · snapshots: 4</title></circle><polyline fill="none" stroke="#f59e0b" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" points="56.00,192.00 218.67,133.33 381.33,74.67 544.00,16.00"/><text x="50" y="19" text-anchor="end" font-size="10" fill="currentColor" fill-opacity="0.55">8.0 KiB</text><text x="50" y="63" text-anchor="end" font-size="10" fill="currentColor" fill-opacity="0.55">6.2 KiB</text><text x="50" y="107" text-anchor="end" font-size="10" fill="currentColor" fill-opacity="0.55">4.5 KiB</text><text x="50" y="151" text-anchor="end" font-size="10" fill="currentColor" fill-opacity="0.55">2.8 KiB</text><text x="50" y="195" text-anchor="end" font-size="10" fill="currentColor" fill-opacity="0.55">1.0 KiB</text><text x="550" y="19" text-anchor="start" font-size="10" fill="currentColor" fill-opacity="0.55">4</text><text x="550" y="63" text-anchor="start" font-size="10" fill="currentColor" fill-opacity="0.55">3</text><text x="550" y="107" text-anchor="start" font-size="10" fill="currentColor" fill-opacity="0.55">2</text><text x="550" y="151" text-anchor="start" font-size="10" fill="currentColor" fill-opacity="0.55">2</text><text x="550" y="195" text-anchor="start" font-size="10" fill="currentColor" fill-opacity="0.55">1</text><text x="56.00" y="208" text-anchor="start" font-size="10" fill="currentColor" fill-opacity="0.55">May 1</text><text x="381.33" y="208" text-anchor="middle" font-size="10" fill="currentColor" fill-opacity="0.55">May 3</text><text x="544.00" y="208" text-anchor="end" font-size="10" fill="currentColor" fill-opacity="0.55">May 4</text></svg>
|
||||
|
After Width: | Height: | Size: 3.1 KiB |
Reference in New Issue
Block a user