A GB200 NVL72 rack or an NVLink Switch system is a scale-up island: its GPUs reach each other over NVLink whatever node they are in. Each GPU now reports its fabric from nvidia-smi -q - state, status, ClusterUUID, CliqueId, bandwidth - at registration and once a minute after, since a fabric manager restarting drops every card out at once; and each node reports nvidia-imex and its channels, without which the domain's GPUs cannot share memory across nodes. A single machine's NVSwitch, which reports a cluster of zeros, is no domain. A replica spanning nodes is placed inside one domain first, on nodes whose every card is joined and whose IMEX is up, and its shards are told NCCL_MNNVL_ENABLE=1; InfiniBand is the fallback. /admin/nvlink draws each domain as a rack - the NVLink Switch band on top, light running down a backplane, trays of GPU chips that glow when joined, pulse red when not, turn amber on degraded bandwidth - and lists what keeps a tray out. A node's page shows its GPU-by-GPU NVLink matrix, each card's NUMA node and nearest adapter, and its domain and IMEX. doctor checks joining, bandwidth, IMEX and its channel; metrics llmgw_gpu_nvlink_joined and llmgw_imex_up; /api/v1/nvlink. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
308 lines
9.8 KiB
Go
308 lines
9.8 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/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,
|
|
// 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()
|
|
}
|