Files
llmgw/internal/web/web.go
T
Ilker ManapandClaude Opus 5.5 daed835d88 Measure each node's CPU and memory, and give every node a page
The nodes are CPU machines, and nothing measured how busy one was: the
heartbeat carried GPU memory and disk, and llmgw_node_memory_bytes is the
total. The agent now reads /proc between heartbeats - CPU across all
cores, load averages, memory used and available, and each engine's
process group's cores and resident memory - and sends it with the
heartbeat. Nothing new runs on the nodes and Prometheus needs no new
target: the control plane exports it with the rest of the fleet, as
llmgw_node_cpu_utilization_percent, llmgw_node_cpus, llmgw_node_load,
llmgw_node_memory_used_bytes, llmgw_node_memory_available_bytes,
llmgw_shard_cpu_cores and llmgw_shard_memory_bytes. A measurement more
than a minute old is left out rather than repeated.

The cluster page's node table shows CPU and RAM as bars, and each node
links to a page of its own: current CPU, RAM, load and disk, the engines
on it with their cores and memory, and an hour of CPU, RAM, load,
per-model cores and memory and queue depth from Prometheus. It refreshes
in place.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 23:21:23 +02:00

259 lines
8.3 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"
"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
// 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])
}()
var funcs = template.FuncMap{
"stylesheet": func() string { return stylesheetURL },
"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,
// 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": 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"
}
},
}
// 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")
}
}