/admin/mirror, behind the same admin session as the other admin pages. What is held, what is arriving with its byte count, what failed and why; start a fetch, retry a failed one, remove a version. Not mounted at all without a mirror configured. The right page for a deployment with no mirror is no page, rather than an empty one pointing at nothing. It reloads itself only while something is downloading. A reload at any other moment takes with it whatever is half-typed into the fetch form, and there is nothing to watch when nothing is moving. A failed row offers Retry, not Remove. A fetch that failed left nothing on disk, so the remove could only answer "the mirror holds nothing for this" — a button whose single outcome is a confusing error. Starting the same download again is what somebody looking at that row wants, and the mirror allows it: only a download still running is refused. An unreachable mirror is said outright instead of rendering as an empty table. The two look nearly identical and mean opposite things: one is a mirror waiting to be filled, the other is a machine to go and look at. Dashboard takes NavExtras rather than a second bool. Two adjacent booleans at a call site get swapped silently and are discovered by somebody clicking the wrong link. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
173 lines
5.4 KiB
Go
173 lines
5.4 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{
|
|
"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)
|
|
}
|
|
}
|