Nothing in the tree could be renamed, moved or removed, and an approved person stayed in the project they were first put in — from the web or the CLI. Now: - /admin/users is the tree, each project opening onto its people and keys, with approve, move, suspend, reinstate and remove for people, revoke for keys, and add, rename, move and remove at every level, plus a search over every account. Forms post and get the page back, so it works with no script; htmx 2.0.11 (0BSD, embedded, checked against npm's integrity hash) swaps only the changed part, keeping open projects open. - llmgw org tree/add/rename/move/remove, users move/delete, and keys list --all. - Projects, teams and organizations are named by id, path (Acme/platform/gateway) or unique name; users by id or email. With a single project --project can be left out, instead of defaulting to "default", which a tree built from the web does not have. Moving a person moves the keys they made; operator keys stay with the project. Only empty things are removed, and never a project that had a key, since usage history refers to it. Also: keys init no longer reparents an existing team or project; reinstating an account with no project is refused (a rejected signup used to come back active and fail at its first key); rejecting a signup removes it; keys limits --budget takes whole units like the quotas page. Checked in headless Chrome: a move from an open project updates the page in place, keeps the project open, and the search still filters. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
198 lines
6.3 KiB
Go
198 lines
6.3 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{
|
|
"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, ", ")
|
|
},
|
|
"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])
|
|
},
|
|
// 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)
|
|
}
|
|
}
|