/install takes a reader from three bare Ubuntu machines to a first answer: what each machine needs and the commands that check it, the release fetched and its checksum checked, the eight setup screens, the system's state and the console, and a first request with a key from the account page. Every screen is from an install of v0.14.9 on three fresh VMs, recorded with tmux and kept as terminal frames: the page shows them as text, and plays a session back when asked, fetching it compressed from /install/cast/. The administrator's password and the API key were masked before the recording was kept, and a test fails if either shape reaches the page. /about gains what llmgw now does beyond routing, and its install steps are today's: the setup screens and node add, not onboard-node.sh and a model file, with a link into the guide. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
250 lines
7.4 KiB
Go
250 lines
7.4 KiB
Go
package web
|
|
|
|
import (
|
|
"bytes"
|
|
"compress/gzip"
|
|
"crypto/sha256"
|
|
_ "embed"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"html/template"
|
|
"io"
|
|
"net/http"
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
// The install guide: every step from bare machines to a first answer, each
|
|
// shown as the screen it was on. The screens are a real install, recorded on
|
|
// three fresh Ubuntu machines, kept as terminal frames rather than pictures:
|
|
// crisp at any size, a few kilobytes, text a reader can select — and played
|
|
// back, where the reader asks for it, as the session it was.
|
|
//
|
|
// The recording is guide/guide.json.gz, made by the scripts that drove the
|
|
// install; the administrator's password and every key in it were masked
|
|
// before it was written. Like /about, the page says nothing about this
|
|
// installation, only about the software, and is served without a key.
|
|
|
|
//go:embed guide/guide.json.gz
|
|
var guideGz []byte
|
|
|
|
// guideCast is one recorded session: its frames, and its last screen to show
|
|
// before anyone presses play.
|
|
type guideCast struct {
|
|
Cols int `json:"cols"`
|
|
Rows int `json:"rows"`
|
|
Frames []json.RawMessage `json:"frames"`
|
|
Poster []string `json:"poster"`
|
|
}
|
|
|
|
// guideStill is one screen of a session, picked for the step it shows.
|
|
type guideStill struct {
|
|
Cast string `json:"cast"`
|
|
Frame int `json:"frame"`
|
|
Lines []string `json:"lines"`
|
|
}
|
|
|
|
type guideFile struct {
|
|
Casts map[string]guideCast `json:"casts"`
|
|
Stills map[string]guideStill `json:"stills"`
|
|
}
|
|
|
|
// guide is read once, when the binary starts: a recording that does not
|
|
// parse is a build that should not ship, and the tests say so first.
|
|
var guide = func() guideFile {
|
|
zr, err := gzip.NewReader(bytes.NewReader(guideGz))
|
|
if err != nil {
|
|
panic("web: the install guide's recording is damaged: " + err.Error())
|
|
}
|
|
b, err := io.ReadAll(zr)
|
|
if err != nil {
|
|
panic("web: the install guide's recording is damaged: " + err.Error())
|
|
}
|
|
var g guideFile
|
|
if err := json.Unmarshal(b, &g); err != nil {
|
|
panic("web: the install guide's recording does not parse: " + err.Error())
|
|
}
|
|
return g
|
|
}()
|
|
|
|
// castFiles are the recordings as the player fetches them, compressed once:
|
|
// the page itself carries only each one's last screen, and a session is
|
|
// downloaded when someone presses play.
|
|
var castFiles = func() map[string][]byte {
|
|
out := map[string][]byte{}
|
|
for name, c := range guide.Casts {
|
|
b, err := json.Marshal(struct {
|
|
Rows int `json:"rows"`
|
|
Frames []json.RawMessage `json:"frames"`
|
|
}{c.Rows, c.Frames})
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
var z bytes.Buffer
|
|
zw, _ := gzip.NewWriterLevel(&z, gzip.BestCompression)
|
|
_, _ = zw.Write(b)
|
|
_ = zw.Close()
|
|
out[name] = z.Bytes()
|
|
}
|
|
return out
|
|
}()
|
|
|
|
// CastView is what the page needs of a recording: the screen it opens on,
|
|
// and where the player fetches the whole session.
|
|
type CastView struct {
|
|
Name string
|
|
Cols, Rows int
|
|
Poster template.HTML
|
|
Src string
|
|
}
|
|
|
|
// InstallData is what the guide is rendered with.
|
|
type InstallData struct {
|
|
Version, Release string
|
|
Site bool
|
|
}
|
|
|
|
// recorded is when the guide's install was made, and with which release: the
|
|
// screens are that release's, whatever version serves the page.
|
|
const recorded = "v0.14.9 · 2026-10-23"
|
|
|
|
// Recorded says which release the screens are of, and when they were taken.
|
|
func (InstallData) Recorded() string { return recorded }
|
|
|
|
// AboutURL is the page about llmgw: on a gateway its /about, on the website
|
|
// the site's front page beside this one.
|
|
func (d InstallData) AboutURL() string {
|
|
if d.Site {
|
|
return "./"
|
|
}
|
|
return "/about"
|
|
}
|
|
|
|
// Img is a picture of the portal, served by the gateway or, on the website,
|
|
// beside the page.
|
|
func (d InstallData) Img(name string) string {
|
|
if d.Site {
|
|
return "guide/" + name
|
|
}
|
|
return assetURL("guide/" + name)
|
|
}
|
|
|
|
// ShotView is one setup screen and the label over it.
|
|
type ShotView struct {
|
|
Label string
|
|
Screen template.HTML
|
|
}
|
|
|
|
// Shot is the setup screen called name, labelled for its step.
|
|
func (d InstallData) Shot(name, label string) ShotView {
|
|
return ShotView{Label: label, Screen: d.Still(name)}
|
|
}
|
|
|
|
// Cast is the recording called name, ready for the page. On a gateway it is
|
|
// fetched from /install/cast/, named by its content so a cache never plays an
|
|
// old one; on the website, from beside the page.
|
|
func (d InstallData) Cast(name string) CastView {
|
|
c, ok := guide.Casts[name]
|
|
if !ok {
|
|
panic("web: the install guide has no recording " + name)
|
|
}
|
|
v := CastView{Name: name, Cols: c.Cols, Rows: c.Rows, Poster: screen(c.Poster)}
|
|
if d.Site {
|
|
v.Src = "guide/" + name + ".json"
|
|
} else {
|
|
sum := sha256.Sum256(castFiles[name])
|
|
v.Src = "/install/cast/" + name + ".json?v=" + hex.EncodeToString(sum[:4])
|
|
}
|
|
return v
|
|
}
|
|
|
|
// CastFile is a recording as the website serves it beside the page: JSON,
|
|
// compressed, for the website's build to write out under guide/.
|
|
func CastFile(name string) ([]byte, bool) {
|
|
b, ok := castFiles[name]
|
|
return b, ok
|
|
}
|
|
|
|
// CastNames are the recordings the guide plays.
|
|
func CastNames() []string {
|
|
var names []string
|
|
for n := range guide.Casts {
|
|
names = append(names, n)
|
|
}
|
|
return names
|
|
}
|
|
|
|
// InstallCast serves one recording at /install/cast/{name}.json, compressed
|
|
// when the browser takes it so, as nearly every one does.
|
|
func InstallCast(w http.ResponseWriter, r *http.Request) {
|
|
name := strings.TrimSuffix(r.PathValue("file"), ".json")
|
|
b, ok := castFiles[name]
|
|
if !ok || !strings.HasSuffix(r.PathValue("file"), ".json") {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Cache-Control", "public, max-age=86400")
|
|
w.Header().Set("Vary", "Accept-Encoding")
|
|
if strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
|
|
w.Header().Set("Content-Encoding", "gzip")
|
|
_, _ = w.Write(b)
|
|
return
|
|
}
|
|
zr, err := gzip.NewReader(bytes.NewReader(b))
|
|
if err != nil {
|
|
http.Error(w, "the recording is damaged", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
_, _ = io.Copy(w, zr)
|
|
}
|
|
|
|
// Still is the screen of a step, as the recording showed it.
|
|
func (InstallData) Still(name string) template.HTML {
|
|
s, ok := guide.Stills[name]
|
|
if !ok {
|
|
panic("web: the install guide has no screen " + name)
|
|
}
|
|
return screen(s.Lines)
|
|
}
|
|
|
|
// screen joins a frame's lines. They are HTML the recording's builder made
|
|
// from the terminal's own colours — spans and escaped text, nothing a visitor
|
|
// supplied — which is why they are trusted here.
|
|
//
|
|
// Blank rows at the foot are left off: a shell session that filled a third
|
|
// of the terminal would otherwise sit above a block of black. The player
|
|
// gives the screen its full height once it plays.
|
|
func screen(lines []string) template.HTML {
|
|
n := len(lines)
|
|
for n > 0 && strings.TrimSpace(tagText.ReplaceAllString(lines[n-1], "")) == "" {
|
|
n--
|
|
}
|
|
return template.HTML(strings.Join(lines[:n], "\n"))
|
|
}
|
|
|
|
// tagText matches the markup in a screen's line, to see whether any text is left.
|
|
var tagText = regexp.MustCompile(`<[^>]*>`)
|
|
|
|
// NewInstallData fills the guide's data for a version.
|
|
func NewInstallData(version string) InstallData {
|
|
d := InstallData{Version: version}
|
|
if releaseTag.MatchString(version) {
|
|
d.Release = version
|
|
}
|
|
return d
|
|
}
|
|
|
|
// RenderInstall writes the guide, for the website's build as much as for Install.
|
|
func RenderInstall(w io.Writer, d InstallData) error {
|
|
return tmpl.ExecuteTemplate(w, "install", d)
|
|
}
|
|
|
|
// Install serves the guide at /install.
|
|
func Install(version string) http.HandlerFunc {
|
|
d := NewInstallData(version)
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
render(w, r, "install", d)
|
|
}
|
|
}
|