Files
llmgw/internal/web/web.go
T
Ilker ManapandClaude Opus 5.5 c1926be869 Tidy the dashboard and the forms around it
- Numeric column headers are right-aligned like their values; the
  stylesheet only aligned the cells, so "REQUESTS" sat nowhere near its
  numbers in every table that has them.
- Ranked bars are an HTML grid instead of a fixed-width SVG: the value
  had 96 units and "34.2k tok  114 req" did not fit, so the figure a
  reader came for was the one cut off. Labels ellipsise in CSS and give
  their full text on hover.
- Mean latency reads as 5 ms, 8.8 s or 1m 51s rather than 110695 ms.
- The "What the cluster is doing" charts sat between the Models heading
  and the models table; they now come before it.
- A fleet without GPUs shows its CPU capacity — nodes ready, memory,
  disk free for weights — instead of "No GPUs have been reported", and
  a node with no GPUs shows "—" rather than 0/0.
- The profile's forms are a form's width rather than the page's, and
  the quotas page's price warning is a boxed banner rather than a line
  of red text.

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

211 lines
6.7 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{
"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 writes a mean latency the way a person reads one: 5 ms, 8.8 s,
// 1m 51s — not 110695 ms.
"latency": func(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)
}
},
"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"
}
},
}
// 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)
}
}