// 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 (
"bytes"
"crypto/sha256"
"embed"
"encoding/hex"
"fmt"
"html/template"
"io/fs"
"net/http"
"slices"
"strconv"
"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{
// roles are a membership's roles, for a chooser.
"roles": func() []string { return store.MembershipRoles },
"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,
"pctile": pctile,
"pct1": func(v float64) string { return strconv.FormatFloat(v, 'f', 1, 64) },
"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)
}
// tmpl is the English set of pages, for what is rendered with no request to
// take a language from (the published website). A request's pages come from
// its language's own set — see parseTemplates.
var tmpl *template.Template
func init() {
parseTemplates()
tmpl = english.tmpl
}
// 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, r *http.Request, name string, data any) {
renderStatus(w, r, http.StatusOK, name, data)
}
// renderStatus is render with a status other than 200.
func renderStatus(w http.ResponseWriter, r *http.Request, code int, name string, data any) {
l := langOf(r)
// Rendered whole before anything is sent: the policy names the page's
// inline scripts by their hashes, and a template that fails halfway is
// an error page rather than half a page.
var page bytes.Buffer
if err := l.tmpl.ExecuteTemplate(&page, name, data); err != nil {
http.Error(w, "template error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Content-Language", l.Code)
// The same address answers in more than one language, chosen by these.
w.Header().Add("Vary", "Accept-Language, Cookie")
securePage(w.Header(), &page)
w.WriteHeader(code)
_, _ = page.WriteTo(w)
}
// 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 { return english.Latency(ms) }
// 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 { return english.Ago(t) }
// humanBytes writes a size in binary units, the way the shell does.
func humanBytes(n int64) string { return english.Bytes(n) }
// 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()
}