Files
llmgw/internal/web/web.go
T
Ilker ManapandClaude Opus 5 c6df0659af
CI / test (push) Successful in 1m50s
Put the cluster's own charts on its own admin page
The dashboard charted what the cluster had been billed for — requests, tokens,
spend, all from llmgw's own store — and nothing about whether it was healthy.
Saturation, queue depth, cache occupancy and replica counts were numbers in a
table at the instant the page loaded, with no way to see that one of them had
been climbing for an hour.

Four line charts now, read back from wherever the metrics were scraped to.

Read back rather than kept twice. llmgw publishes these already; what a gauge
cannot do is remember, and a ring buffer in this process would be a rival copy
of the same numbers with its own retention, lost on every restart and
disagreeing with the one an operator has on screen elsewhere. monitoring.prometheus
is empty by default and the panels are then absent rather than empty.

One axis per chart, always. Two measures of different scale belong in two
charts: a second y-axis lets any two lines be made to cross wherever the author
likes and the reader cannot tell it happened.

Four colours, handed out in fixed order, never cycled. A fifth series is summed
into "other" and drawn in grey — dropping it would make a chart that does not
add up, and inventing a hue for it would put two lines on the page that nobody
can tell apart. The fold is by timestamp, not by position: the tail's series do
not share sample times, and adding them by index slides points sideways onto
each other's clock.

A legend for every chart with more than one line, because colour alone is not
an encoding a reader can rely on.

A flat zero is drawn along the bottom rather than left blank: nothing happening
is a real answer, and an empty frame reads as a broken chart. A single sample
is not a line and says so instead of putting a dot in the corner.

One failed query fails all four panels. Three drawn and one blank invites
reading the blank one as "nothing is happening there".

NaN is dropped rather than plotted. Prometheus sends it as a string for a
sample it has no value for, Go's ParseFloat accepts it happily, and one NaN
coordinate is an SVG path that does not draw at all — taking the rest of the
line with it.

Axis maxima are rounded to a number a person would pick. An axis topping out at
0.8347 makes every gridline unreadable, and the shape is what a chart is for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 18:25:56 +02:00

188 lines
6.0 KiB
Go

// Package web serves the two HTML surfaces: a landing page on the
// client-facing port and a read-only dashboard on the admin one.
//
// Server-rendered templates with one stylesheet, embedded in the binary. The
// single-binary goal rules out a JavaScript toolchain — a build step in CI, a
// node runtime on the runner, and megabytes of bundle — and nothing on either
// page needs one. The dashboard refreshes by reloading.
//
// The split between the two pages is a security boundary, not a layout choice.
// The landing page is served to anyone who can reach the address, so it
// contains no data at all: it says how to connect, not what is here. The
// dashboard shows the cluster and belongs on the admin listener, which is bound
// to loopback — see Dashboard.
package web
import (
"embed"
"fmt"
"html/template"
"io/fs"
"net/http"
"strings"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
//go:embed templates
var files embed.FS
// staticFiles is a separate embed from the templates on purpose. Static() serves
// this whole tree, and a tree that also held the templates would serve those
// too — harmless today and the kind of thing that stops being harmless.
//
//go:embed static
var staticFiles embed.FS
var funcs = template.FuncMap{
"join": func(v []string) string {
if len(v) == 0 {
return "—"
}
return strings.Join(v, ", ")
},
"duration": func(sec int64) string {
return time.Duration(sec * int64(time.Second)).Round(time.Second).String()
},
// pct is for the usage meters, and is clamped because the two counts it
// divides can disagree for an instant — a GPU freed between one read and
// the next — and a bar wider than its column wrecks the table it is in.
"pct": func(used, total int) int {
if total <= 0 {
return 0
}
p := used * 100 / total
if p < 0 {
return 0
}
if p > 100 {
return 100
}
return p
},
"ago": func(t time.Time) string {
if t.IsZero() {
return "never"
}
return time.Since(t).Round(time.Second).String() + " ago"
},
// Spend is stored in micro-units so accumulation is exact; it is shown in
// whole units because that is the number an operator set a budget in.
// compact renders a large count the way someone says it aloud: 1.2k rather
// than 1247. Exactness is available in the tooltip and the table; a column
// of seven-digit numbers is harder to compare than one of three.
"compact": func(v int64) string {
return compact(float64(v))
},
"units": func(micros int64) string {
return fmt.Sprintf("%.2f", float64(micros)/1e6)
},
// Model weights are read in gigabytes, so that is what the mirror page
// shows. Binary units because that is what `ls -lh` and every disk-full
// message on the host will say, and a page that disagrees with the shell
// about the size of the same file starts an argument about neither.
"bytes": func(n int64) string {
const unit = 1024
if n < unit {
return fmt.Sprintf("%d B", n)
}
div, exp := int64(unit), 0
for v := n / unit; v >= unit; v /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp])
},
// dict lets one chart template be called with named arguments. Go
// templates pass a single value, and four charts differing in three
// strings each is four copies of the same markup otherwise.
"dict": func(kv ...any) map[string]any {
m := map[string]any{}
for i := 0; i+1 < len(kv); i += 2 {
k, _ := kv[i].(string)
m[k] = kv[i+1]
}
return m
},
// cssvar turns a palette name into the custom property it refers to, so a
// legend swatch follows the page's light and dark themes like the line it
// stands for.
"cssvar": cssVar,
"date": func(t time.Time) string {
if t.IsZero() {
return "—"
}
return t.Format("2006-01-02")
},
// A key's state as the profile page shows it, computed here rather than in
// the template so "expired" cannot quietly become "active" when nobody
// updates the condition.
"state": func(k store.Key) string {
switch {
case !k.RevokedAt.IsZero():
return "revoked"
case !k.ExpiresAt.IsZero() && !time.Now().Before(k.ExpiresAt):
return "expired"
default:
return "active"
}
},
}
var tmpl = template.Must(template.New("").Funcs(funcs).ParseFS(files, "templates/*.html"))
// Static serves the embedded assets, mounted on both listeners because both
// pages use them.
//
// Over its own embed, which holds nothing but assets. A file server pointed at
// the tree that also carries the templates would serve those as well.
func Static() http.Handler {
sub, err := fs.Sub(staticFiles, "static")
if err != nil {
panic("web: the static assets are missing from the binary: " + err.Error())
}
fileServer := http.StripPrefix("/_static/", http.FileServerFS(noDirs{sub}))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Everything here is embedded, so it changes only when the binary does
// and can be cached hard. The fonts in particular are 300 KB that
// should be fetched once.
w.Header().Set("Cache-Control", "public, max-age=86400")
fileServer.ServeHTTP(w, r)
})
}
// noDirs hides directories from the file server.
//
// Listing them would publish the shape of the asset tree, which is not secret
// and not anybody's business either. Nothing on any page links to a directory,
// so the only requests for one are from somebody looking around.
type noDirs struct{ fs.FS }
func (n noDirs) Open(name string) (fs.File, error) {
f, err := n.FS.Open(name)
if err != nil {
return nil, err
}
info, err := f.Stat()
if err != nil {
_ = f.Close()
return nil, err
}
if info.IsDir() {
_ = f.Close()
return nil, fs.ErrNotExist
}
return f, nil
}
func render(w http.ResponseWriter, name string, data any) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tmpl.ExecuteTemplate(w, name, data); err != nil {
// The status is already written by then, so there is nothing honest to
// send; the page simply ends short.
http.Error(w, "template error", http.StatusInternalServerError)
}
}