// 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{ "join": func(v []string) string { if len(v) == 0 { return "—" } return strings.Join(v, ", ") }, "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]) }, "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" } }, } 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) } }