// 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()
},
"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.
"units": func(micros int64) string {
return fmt.Sprintf("%.2f", float64(micros)/1e6)
},
"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)
}
}