Files
llmgw/internal/web/web.go
T
Ilker ManapandClaude Opus 5.5 bd36798b15 Sample GPU utilisation and memory every heartbeat, and chart them
They were read once, at agent start, and published unchanged since.
Each sample now carries utilisation, memory and bandwidth in use, the SM
clock and uncorrected ECC errors. The node page charts each card, the
cluster page the fleet by class, Grafana has a GPU row, and three GPU
alerts are added.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 15:42:17 +02:00

339 lines
11 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 (
"crypto/sha256"
"embed"
"encoding/hex"
"fmt"
"html/template"
"io/fs"
"net/http"
"slices"
"strings"
"sync"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/control"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/nvlink"
"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
// stylesheetURL names the stylesheet by its content. The assets are cached for a
// day, and under a fixed name a deploy that changed the page's look reached
// people a day later — or reached the markup at once and the styles not at
// all, which is worse than either.
var stylesheetURL = func() string {
b, err := staticFiles.ReadFile("static/base.css")
if err != nil {
panic("web: the stylesheet is missing from the binary: " + err.Error())
}
sum := sha256.Sum256(b)
return "/_static/base.css?v=" + hex.EncodeToString(sum[:4])
}()
// assetURL names any static file by its content, for the same reason as the
// stylesheet: cached for a day, a changed script under its old name reached
// people a day after the page that needed it.
func assetURL(name string) string {
assetMu.Lock()
defer assetMu.Unlock()
if u, ok := assetURLs[name]; ok {
return u
}
b, err := staticFiles.ReadFile("static/" + name)
if err != nil {
panic("web: the asset " + name + " is missing from the binary: " + err.Error())
}
sum := sha256.Sum256(b)
u := "/_static/" + name + "?v=" + hex.EncodeToString(sum[:4])
assetURLs[name] = u
return u
}
var (
assetMu sync.Mutex
assetURLs = map[string]string{}
)
var funcs = template.FuncMap{
"stylesheet": func() string { return stylesheetURL },
"asset": assetURL,
"lower": strings.ToLower,
"sub": func(a, b int) int { return a - b },
"join": func(v []string) string {
if len(v) == 0 {
return "—"
}
return strings.Join(v, ", ")
},
"latency": humanLatency,
"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": ago,
// gib is mebibytes as gibibytes, for a GPU's memory.
"gib": func(mib float64) float64 { return mib / 1024 },
// percent is part of whole, 0 to 100, for a bar.
"percent": func(part, whole float64) float64 {
if whole <= 0 {
return 0
}
return part * 100 / whole
},
// shortNode is a node's name without what every node's name starts with.
"shortNode": strings.TrimPrefix,
// engineBuilds are the builds an engine can be given on a node, and
// buildLabel says one in words.
"engineBuilds": control.EngineBuilds,
"buildLabel": func(b string) string {
switch b {
case "auto":
return "auto: what the hardware runs"
case "cu13":
return "CUDA 13"
case "cu129":
return "CUDA 12.9"
case "cu12":
return "CUDA 12"
case "none":
return "none"
}
return b
},
"age": age,
// pctbar is a percentage as the minibar template takes it: whole, and
// held to 0..100 so a measurement a little over does not draw past its box.
"pctbar": func(v float64) struct{ P int } {
return struct{ P int }{int(max(0, min(v, 100)) + 0.5)}
},
// 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": humanBytes,
"u64": func(v uint64) int64 { return int64(min(v, 1<<62)) },
"has": func(list []string, v string) bool { return slices.Contains(list, v) },
"hasInt": func(list []int, v int) bool { return slices.Contains(list, v) },
"shortDomain": nvlink.Short,
// 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"
}
},
}
// unitsOf renders micro-units as the whole units a budget is written in.
//
// A function rather than only a template helper, because the usage report
// formats the same figure into a table row in Go and the two must not drift.
func unitsOf(micros int64) string {
return fmt.Sprintf("%.2f", float64(micros)/1e6)
}
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)
}
}
// humanLatency writes a mean latency the way a person reads one: 5 ms, 8.8 s,
// 1m 51s — not 110695 ms.
func humanLatency(ms float64) string {
switch {
case ms < 1000:
return fmt.Sprintf("%.0f ms", ms)
case ms < 60_000:
return fmt.Sprintf("%.1f s", ms/1000)
default:
d := time.Duration(ms) * time.Millisecond
return fmt.Sprintf("%dm %02ds", int(d.Minutes()), int(d.Seconds())%60)
}
}
// ago says how long ago something was the way a person would: to the unit
// that matters and no finer. "3h7m25s ago" is precise about seconds nobody
// asked about and slower to read than "3 hours ago".
func ago(t time.Time) string {
if t.IsZero() {
return "never"
}
d := time.Since(t)
unit := func(n int, one string) string {
if n == 1 {
return "1 " + one + " ago"
}
return fmt.Sprintf("%d %ss ago", n, one)
}
switch {
case d < 45*time.Second:
return "just now"
case d < time.Hour:
return unit(int(d.Minutes()+0.5), "minute")
case d < 36*time.Hour:
return unit(int(d.Hours()+0.5), "hour")
case d < 14*24*time.Hour:
return unit(int(d.Hours()/24+0.5), "day")
case d < 60*24*time.Hour:
return unit(int(d.Hours()/24/7+0.5), "week")
default:
return unit(int(d.Hours()/24/30+0.5), "month")
}
}
// humanBytes writes a size in binary units, the way the shell does.
func humanBytes(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])
}
// windowWord names a chart window the way a sentence would: "hour", "6
// hours", "30 minutes", rather than Go's 1h0m0s.
func windowWord(d time.Duration) string {
switch {
case d == time.Hour:
return "hour"
case d%time.Hour == 0:
return fmt.Sprintf("%d hours", int(d.Hours()))
case d%time.Minute == 0:
return fmt.Sprintf("%d minutes", int(d.Minutes()))
}
return d.String()
}