// 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": humanBytes, // 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() }