From c1f805043af730ae9481de28f7ff8e28686cc9bf Mon Sep 17 00:00:00 2001 From: Usman Shahid Date: Fri, 4 Sep 2026 17:29:03 +0400 Subject: [PATCH 1/3] feat(httpapi): GET /api/nodes - the fleet's live state as JSON The new drag-and-drop board needs one machine-readable view of the fleet; today the only node state reaches the browser as html/template output (node.html), which a JS board would have to scrape. This adds a single JSON endpoint built from the in-memory nodecatalog with the EXACT committed/margin arithmetic the server-rendered cards use (status.go's nodeCards) and planOnNode/capacity.Planner enforce - so the board, any HTML fallback, and the deploy-time capacity check can never disagree about whether a model fits. Per node: node_id, connected, snapshot_age_s (freshness - a node can be connected yet stale if snapshots stop, a distinct state the board shows), pool/reserve/committed/margin GiB, deployments[recipe_id, host_port, docker_status (Docker's RAW word, never dressed as a readiness verdict - souslet has no health probe yet), weights/kv GiB], cached_weight_repos. Sorted by node_id, deployments by recipe_id, empty fleet is [] not null. Also adds NodeView.LastSnapshot (stamped in ReplaceSnapshot, preserved by MarkDisconnected) to source snapshot_age_s. Registered under the existing gsrv&&nodes gate so cmd/sous (nil catalog) never exposes it and cannot nil-panic. +5 tests; 549 pass. Co-Authored-By: Claude Fable 5.1 --- internal/httpapi/nodesapi.go | 95 ++++++++++++++++++++ internal/httpapi/nodesapi_test.go | 131 ++++++++++++++++++++++++++++ internal/httpapi/server.go | 4 + internal/nodecatalog/nodecatalog.go | 9 ++ 4 files changed, 239 insertions(+) create mode 100644 internal/httpapi/nodesapi.go create mode 100644 internal/httpapi/nodesapi_test.go diff --git a/internal/httpapi/nodesapi.go b/internal/httpapi/nodesapi.go new file mode 100644 index 0000000..0485a9b --- /dev/null +++ b/internal/httpapi/nodesapi.go @@ -0,0 +1,95 @@ +package httpapi + +import ( + "net/http" + "sort" + "time" +) + +// nodeJSON is the fleet's live state as the board UI consumes it: one entry +// per node in the catalog, with the same committed/margin arithmetic the +// server-rendered node cards use (status.go's nodeCards), so the JSON board +// and any HTML fallback can never disagree about whether a model fits. +// +// This is the ONLY machine-readable view of the fleet - everything else +// (node.html, models.html weight chips) is html/template output. The board +// polls this a few times a minute; it is a pure in-memory read of the +// catalog, so a short poll is cheap. +type nodeJSON struct { + NodeID string `json:"node_id"` + Connected bool `json:"connected"` + SnapshotAgeS float64 `json:"snapshot_age_s"` // seconds since this node's last snapshot; the UI shows freshness and flags staleness even while Connected + PoolGiB float64 `json:"pool_gib"` + ReserveGiB float64 `json:"reserve_gib"` + CommittedGiB float64 `json:"committed_gib"` + MarginGiB float64 `json:"margin_gib"` // pool - reserve - committed, EXACTLY as capacity.Planner and planOnNode compute it + Deployments []deploymentJSON `json:"deployments"` + // CachedWeightRepos are HF repo ids ("Org/Name") whose weights are on + // this node's disk - the exact string recipe.Model holds, so the UI can + // tell "weights here, not running" from "not on this node". + CachedWeightRepos []string `json:"cached_weight_repos"` +} + +type deploymentJSON struct { + RecipeID string `json:"recipe_id"` + HostPort int32 `json:"host_port"` + // DockerStatus is Docker's RAW status word ("running", "exited", + // "restarting", "created", "paused", "dead") - NOT a readiness verdict. + // On the node path nothing probes the model's health yet, so "running" + // is true for the whole multi-minute vLLM load; the UI must render this + // as an honest "running (docker)" and never a green "ready". See + // grpcclient.Handlers.Snapshot for why this is all souslet reports. + DockerStatus string `json:"docker_status"` + WeightsGiB float64 `json:"weights_gib"` + KvGiB float64 `json:"kv_gib"` +} + +// apiNodes serves GET /api/nodes. Registered only when gsrv&&nodes are wired +// (see server.go's gate) so it never touches a nil catalog. +func (s *Server) apiNodes(w http.ResponseWriter, r *http.Request) { + now := time.Now() + views := s.nodes.All() + out := make([]nodeJSON, 0, len(views)) + for _, v := range views { + var committed float64 + deps := make([]deploymentJSON, 0, len(v.Deployments)) + for _, d := range v.Deployments { + committed += d.WeightsGib + d.KvGib + deps = append(deps, deploymentJSON{ + RecipeID: d.RecipeId, + HostPort: d.HostPort, + DockerStatus: d.Phase, + WeightsGiB: d.WeightsGib, + KvGiB: d.KvGib, + }) + } + sort.Slice(deps, func(i, j int) bool { return deps[i].RecipeID < deps[j].RecipeID }) + + repos := make([]string, 0, len(v.CachedWeightRepos)) + for repo := range v.CachedWeightRepos { + repos = append(repos, repo) + } + sort.Strings(repos) + + // age is only meaningful once a snapshot has actually landed; a + // zero LastSnapshot (should not happen for a catalog entry, but be + // safe) reports 0 rather than ~55 years. + age := 0.0 + if !v.LastSnapshot.IsZero() { + age = now.Sub(v.LastSnapshot).Seconds() + } + out = append(out, nodeJSON{ + NodeID: v.NodeID, + Connected: v.Connected, + SnapshotAgeS: age, + PoolGiB: v.PoolGiB, + ReserveGiB: v.ReserveGiB, + CommittedGiB: committed, + MarginGiB: v.PoolGiB - v.ReserveGiB - committed, + Deployments: deps, + CachedWeightRepos: repos, + }) + } + sort.Slice(out, func(i, j int) bool { return out[i].NodeID < out[j].NodeID }) + writeJSON(w, http.StatusOK, out) +} diff --git a/internal/httpapi/nodesapi_test.go b/internal/httpapi/nodesapi_test.go new file mode 100644 index 0000000..d96236d --- /dev/null +++ b/internal/httpapi/nodesapi_test.go @@ -0,0 +1,131 @@ +package httpapi + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + pb "github.com/codemug/sous/internal/pb/souslet/v1" +) + +func getNodesJSON(t *testing.T, h http.Handler) []nodeJSON { + t.Helper() + req := httptest.NewRequest("GET", "/api/nodes", nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("GET /api/nodes = %d, want 200; body %s", rec.Code, rec.Body.String()) + } + if ct := rec.Header().Get("Content-Type"); ct != "application/json" { + t.Fatalf("Content-Type = %q, want application/json", ct) + } + var out []nodeJSON + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatalf("decode: %v; body %s", err, rec.Body.String()) + } + return out +} + +func TestAPINodesComputesMarginLikeThePlanner(t *testing.T) { + h, nodes := newTestServerWithNodes(t) + nodes.ReplaceSnapshot("asus-gx10", &pb.NodeSnapshot{ + NodeId: "asus-gx10", PoolGib: 121.6, ReserveGib: 24, + Deployments: []*pb.DeploymentState{ + {RecipeId: "qwen38", HostPort: 8001, Phase: "running", WeightsGib: 24.87, KvGib: 45.67}, + }, + CachedWeightRepos: []string{"Inferact/Qwen3.8-27B-NVFP4"}, + }) + + out := getNodesJSON(t, h) + if len(out) != 1 { + t.Fatalf("want 1 node, got %d", len(out)) + } + n := out[0] + if n.NodeID != "asus-gx10" || !n.Connected { + t.Fatalf("node id/connected wrong: %+v", n) + } + // committed = 24.87 + 45.67 = 70.54; margin = 121.6 - 24 - 70.54 = 27.06. + // This is the EXACT arithmetic planOnNode/capacity.Planner use; if this + // drifts, the board would show a fit the deploy then refuses. + if got := round2(n.CommittedGiB); got != 70.54 { + t.Fatalf("committed = %v, want 70.54", got) + } + if got := round2(n.MarginGiB); got != 27.06 { + t.Fatalf("margin = %v, want 27.06", got) + } + if len(n.Deployments) != 1 || n.Deployments[0].RecipeID != "qwen38" || n.Deployments[0].HostPort != 8001 { + t.Fatalf("deployment wrong: %+v", n.Deployments) + } + // docker_status carries the raw word; it must NOT be dressed up as a + // readiness verdict. + if n.Deployments[0].DockerStatus != "running" { + t.Fatalf("docker_status = %q, want running", n.Deployments[0].DockerStatus) + } + if len(n.CachedWeightRepos) != 1 || n.CachedWeightRepos[0] != "Inferact/Qwen3.8-27B-NVFP4" { + t.Fatalf("cached repos wrong: %+v", n.CachedWeightRepos) + } + // A freshly-landed snapshot is seconds old, never negative or stale. + if n.SnapshotAgeS < 0 || n.SnapshotAgeS > 5 { + t.Fatalf("snapshot_age_s = %v, want a small non-negative number", n.SnapshotAgeS) + } +} + +func TestAPINodesSortsNodesAndDeployments(t *testing.T) { + h, nodes := newTestServerWithNodes(t) + nodes.ReplaceSnapshot("zeta-node", &pb.NodeSnapshot{NodeId: "zeta-node", PoolGib: 16, ReserveGib: 2}) + nodes.ReplaceSnapshot("alpha-node", &pb.NodeSnapshot{ + NodeId: "alpha-node", PoolGib: 121.6, ReserveGib: 24, + Deployments: []*pb.DeploymentState{ + {RecipeId: "zzz-model", WeightsGib: 1, KvGib: 1}, + {RecipeId: "aaa-model", WeightsGib: 1, KvGib: 1}, + }, + }) + out := getNodesJSON(t, h) + if len(out) != 2 || out[0].NodeID != "alpha-node" || out[1].NodeID != "zeta-node" { + t.Fatalf("nodes not sorted by id: %v", []string{out[0].NodeID, out[1].NodeID}) + } + deps := out[0].Deployments + if len(deps) != 2 || deps[0].RecipeID != "aaa-model" || deps[1].RecipeID != "zzz-model" { + t.Fatalf("deployments not sorted by recipe id: %+v", deps) + } +} + +func TestAPINodesReportsDisconnected(t *testing.T) { + h, nodes := newTestServerWithNodes(t) + nodes.ReplaceSnapshot("gx", &pb.NodeSnapshot{NodeId: "gx", PoolGib: 16, ReserveGib: 2}) + nodes.MarkDisconnected("gx") + out := getNodesJSON(t, h) + if len(out) != 1 || out[0].Connected { + t.Fatalf("want one disconnected node, got %+v", out) + } +} + +func TestAPINodesEmptyFleetIsEmptyArrayNotNull(t *testing.T) { + h, _ := newTestServerWithNodes(t) + req := httptest.NewRequest("GET", "/api/nodes", nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if body := rec.Body.String(); body != "[]\n" { + t.Fatalf("empty fleet body = %q, want %q (a JSON client must get an array, never null)", body, "[]\n") + } +} + +func TestAPINodesAbsentWithoutGRPC(t *testing.T) { + // cmd/sous (nil gsrv/nodes) must not expose this route at all - it reads + // s.nodes, which would nil-panic. The gate in server.go means the path + // simply isn't registered, so the "GET /" catch-all answers. + h := newTestServerNilGRPC(t) + req := httptest.NewRequest("GET", "/api/nodes", nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code == http.StatusOK && rec.Header().Get("Content-Type") == "application/json" { + t.Fatalf("GET /api/nodes should not serve JSON on a nil-gRPC server; got %d %s", rec.Code, rec.Header().Get("Content-Type")) + } +} + +// round2 rounds to 2 decimals so float noise (24.87+45.67 = 70.53999...) +// doesn't fail an exact-equality assertion on a value a human reads as 70.54. +func round2(f float64) float64 { + return float64(int64(f*100+0.5)) / 100 +} diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index b47d0dc..0f1b3ef 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -234,6 +234,10 @@ func New(m *deploy.Manager, c *catalog.Catalog, keys *apikey.Manager, fx *fetch. // exactly which, and why - it depends on this package's own "GET /" // catch-all), never a panic. if gsrv != nil && nodes != nil { + // The board's live fleet state, JSON. Same nil-guard reasoning as + // the node-scoped routes below: apiNodes reads s.nodes, so it must + // not exist at all on a server built without a fleet to talk to. + s.mux.HandleFunc("GET /api/nodes", s.apiNodes) s.mux.HandleFunc("GET /api/plan/{id}/{nodeID}", s.plan) s.mux.HandleFunc("POST /api/deploy/{id}/{nodeID}", s.deploy) s.mux.HandleFunc("POST /api/undeploy/{id}/{nodeID}", s.undeploy) diff --git a/internal/nodecatalog/nodecatalog.go b/internal/nodecatalog/nodecatalog.go index 4e5bb5a..744a3c8 100644 --- a/internal/nodecatalog/nodecatalog.go +++ b/internal/nodecatalog/nodecatalog.go @@ -8,6 +8,7 @@ package nodecatalog import ( "sync" + "time" pb "github.com/codemug/sous/internal/pb/souslet/v1" ) @@ -19,6 +20,13 @@ type NodeView struct { Connected bool Deployments []*pb.DeploymentState CachedWeightRepos map[string]bool + // LastSnapshot is when this node's most recent NodeSnapshot landed. + // The UI reads it as freshness ("snapshot 6s old") - a node can be + // Connected yet stale if snapshots stop arriving, which is a distinct + // and important state from disconnected. MarkDisconnected does NOT + // advance it, so a greyed-out node's age keeps counting up from its + // real last snapshot. + LastSnapshot time.Time } type Catalog struct { @@ -48,6 +56,7 @@ func (c *Catalog) ReplaceSnapshot(nodeID string, snap *pb.NodeSnapshot) { Connected: true, Deployments: snap.Deployments, CachedWeightRepos: cached, + LastSnapshot: time.Now(), } } From f7d70fb072eb54627ef434ac6f50f2dbb18919df Mon Sep 17 00:00:00 2001 From: Usman Shahid Date: Fri, 4 Sep 2026 17:35:15 +0400 Subject: [PATCH 2/3] feat(ui): serve the whole static/ tree, exempt it from auth Widens the go:embed from the single dragdrop.js to static/* and replaces the per-file route with one GET /static/ prefix route, so the board's stylesheet and script (and any later asset) ship without a new route each. Exempts /static/ in auth.Middleware alongside /login so the login page can link the shared stylesheet before anyone signs in - the assets are non-secret and the tailnet is the boundary; /static/ is a literal prefix, not a redirect, so nothing else leaks through it. Prep for the board rebuild; existing dragdrop.js still serves 200 via the prefix route (tests green). Co-Authored-By: Claude Fable 5.1 --- internal/auth/auth.go | 9 +++++++-- internal/httpapi/server.go | 16 +++++++++------- internal/ui/embed.go | 12 +++++------- 3 files changed, 21 insertions(+), 16 deletions(-) diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 0a8d25c..dbbbc7b 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -122,8 +122,13 @@ func (c Config) Middleware(next http.Handler) http.Handler { return } // The login page itself must stay reachable, or the redirect below - // bounces forever. - if r.URL.Path == LoginPath { + // bounces forever - and the stylesheet/scripts it links must load + // before anyone has signed in, so the whole static/ tree is exempt + // too. These are non-secret assets (CSS, board JS); the tailnet is + // the boundary, and no API key or session can reach anything else + // through this exemption because /static/ is a literal prefix, not + // a redirect target. + if r.URL.Path == LoginPath || strings.HasPrefix(r.URL.Path, "/static/") { next.ServeHTTP(w, r) return } diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 0f1b3ef..56a3fbd 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -129,13 +129,15 @@ func New(m *deploy.Manager, c *catalog.Catalog, keys *apikey.Manager, fx *fetch. w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("ok\n")) }) - // The panel's one static asset (Task 13): dragdrop.js, served straight - // from the embedded FS rather than through html/template - it is - // unchanging JS, not a page. Registered unconditionally (not gated - // behind gsrv/nodes != nil like the node-scoped routes below) because - // the script itself is harmless to load on a single-node server too - - // it just never finds a [data-node-id] drop target to attach to there. - s.mux.Handle("GET /static/dragdrop.js", http.FileServerFS(ui.StaticFS())) + // The panel's static assets (stylesheet, board script), served straight + // from the embedded FS rather than through html/template - unchanging + // files, not pages. One prefix route for the whole static/ directory + // (embedded paths keep their "static/" prefix, which is what + // FileServerFS resolves "/static/x" to). Registered unconditionally + // (not gated behind gsrv/nodes) and exempt from auth in the middleware, + // so the /login page can share the same stylesheet before anyone signs + // in. The files are non-secret; the tailnet is the boundary. + s.mux.Handle("GET /static/", http.FileServerFS(ui.StaticFS())) s.mux.HandleFunc("GET /api/recipes", s.listRecipes) s.mux.HandleFunc("POST /api/recipes/sync", s.syncRecipes) s.mux.HandleFunc("POST /api/recipes", s.createRecipe) diff --git a/internal/ui/embed.go b/internal/ui/embed.go index 8dc222d..2028676 100644 --- a/internal/ui/embed.go +++ b/internal/ui/embed.go @@ -17,14 +17,12 @@ import ( //go:embed templates/*.html var files embed.FS -// staticFS is the panel's client-side JS (Task 13, drag-and-drop deploy): -// the first static asset this project has ever served - everything before -// it was templates rendered server-side with zero client-side script. Kept -// as its own embed.FS rather than folded into files above so a future -// second static asset doesn't have to be told apart from *.html by a -// pattern match. +// staticFS holds the panel's client-side assets - the board's stylesheet +// and scripts. `static/*` (not a single named file) so a new .css/.js +// under static/ ships automatically; the httpapi side serves the whole +// directory under one GET /static/ route rather than one route per file. // -//go:embed static/dragdrop.js +//go:embed static/* var staticFS embed.FS // StaticFS exposes the embedded static assets for httpapi to serve (see From 6647ce08a902d1c53c7d691270cccc22c48e727c Mon Sep 17 00:00:00 2001 From: Usman Shahid Date: Fri, 4 Sep 2026 17:55:35 +0400 Subject: [PATCH 3/3] =?UTF-8?q?feat(ui):=20the=20fleet=20board=20=E2=80=94?= =?UTF-8?q?=20nodes=20and=20models=20as=20boxes,=20drag=20to=20deploy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the landing page ("/") on a multi-node control plane with a from-scratch board built on one idea: memory is a length. One shared GiB ruler runs across the fleet — every node is drawn to the width of its memory pool and every model to its footprint — so "does it fit" is a length the eye reads before any request is sent. Dragging a model onto a node deploys it; the same action is reachable without a mouse (or JavaScript) via each model's "Deploy to…" menu of real per-node forms. Fixes the three things that made the old page unusable, at the root: - Deploy actually reaches a node. The old Deploy control posted to the nodeID-less legacy route, which deploys onto the control plane's own Docker (a box with no GPU) — a dead path. Every deploy affordance here (drag AND menu) posts /api/deploy/{recipe}/{node}. - Drag-and-drop works at all. It never could before: drag sources were on /models and drop targets on /, so no page ever had both. The board puts models and nodes on one surface. - Honest state. Models serving on a node show "on · ", not "not deployed". The node path has no health probe, so the board shows Docker's raw status and never a green "ready" it cannot verify; disconnected nodes grey out and say so rather than vanishing; each node shows its snapshot age so a frozen catalog is visible. Progressive enhancement throughout: the server renders the whole board (working Deploy-to/Stop forms) so it functions with no JS; board.js layers drag-and-drop, live fit-preview, and a 4s poll of GET /api/nodes that tolerates the tailnet's dropped requests by keeping the last good state. deployNode/undeployFromNode gained a wantsHTML branch so a form post redirects back to the board with a banner while fetch() still gets JSON. New: internal/ui/static/board.{css,js}, templates/board.html, board.go (pageBoard + the shared fleetView the board and /api/nodes both read). The single-node build (cmd/sous, nil gRPC) keeps the old node page as its landing view — the pool-ruler/stepper/local-deploy rendering genuinely only applies there now, so those tests moved to that configuration; the fleet-view tests were rewritten against the board's real markup. Board still to come (handed to the next pass): the remaining screens (models, model detail, keys with the plain-HTTP copy fix, recipes, admin) restyled to match, and the deeper backend (readiness probe, non-blocking deploy). 549 -> tests green across 28 packages. Co-Authored-By: Claude Fable 5.1 --- internal/httpapi/board.go | 145 ++++++++++++++ internal/httpapi/cards_test.go | 2 +- internal/httpapi/dragdrop_test.go | 18 +- internal/httpapi/handlers.go | 48 ++++- internal/httpapi/handlers_test.go | 14 ++ internal/httpapi/nodesapi.go | 16 +- internal/httpapi/plan_test.go | 20 +- internal/httpapi/render_test.go | 4 +- internal/httpapi/screens_test.go | 11 +- internal/httpapi/server.go | 2 +- internal/httpapi/status_test.go | 58 +++--- internal/ui/static/board.css | 310 ++++++++++++++++++++++++++++++ internal/ui/static/board.js | 291 ++++++++++++++++++++++++++++ internal/ui/templates/board.html | 131 +++++++++++++ 14 files changed, 1012 insertions(+), 58 deletions(-) create mode 100644 internal/httpapi/board.go create mode 100644 internal/ui/static/board.css create mode 100644 internal/ui/static/board.js create mode 100644 internal/ui/templates/board.html diff --git a/internal/httpapi/board.go b/internal/httpapi/board.go new file mode 100644 index 0000000..fbb5127 --- /dev/null +++ b/internal/httpapi/board.go @@ -0,0 +1,145 @@ +package httpapi + +import ( + "net/http" + "sort" +) + +// boardData feeds the "board" template - the fleet board at "/". It is +// rendered server-side so the page works with no JavaScript (every model +// carries a real "Deploy to…" form per connected node); board.js then +// enhances the same DOM with drag-and-drop and live polling of +// GET /api/nodes. +type boardData struct { + Title string + Message string + IsError bool + MaxScaleGiB float64 // the shared ruler's extent: widest pool or model, rounded up + Nodes []nodeJSON + Models []boardModel +} + +type boardModel struct { + ID string + Repo string + Kind string + Modality string + FootprintGiB float64 + Archived bool + OnNode string // node currently running it, "" if none + OnStatus string // that deployment's raw docker status + Cached []string + Fits []nodeFit // one per connected node, for the no-JS Deploy-to menu +} + +type nodeFit struct { + NodeID string + Fits bool + MarginAfterGiB float64 +} + +// pageBoard serves "GET /". Falls back to the legacy single-node view when +// no gRPC fleet is wired (cmd/sous), which has no node catalog to draw. +func (s *Server) pageBoard(w http.ResponseWriter, r *http.Request) { + // "GET /" is a catch-all in Go's ServeMux; without this a typo'd path + // renders the board with 200 and looks like it worked. + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + if s.nodes == nil { + // Single-node build: no fleet board to draw. Keep the old node page. + s.pageNode(w, r) + return + } + + nodes := s.fleetView() + + recipes, err := s.cat.List() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + maxScale := 16.0 + for _, n := range nodes { + if n.PoolGiB > maxScale { + maxScale = n.PoolGiB + } + } + + models := make([]boardModel, 0, len(recipes)) + for _, rec := range recipes { + foot := rec.Declared.TotalGiB() + if foot > maxScale { + maxScale = foot + } + bm := boardModel{ + ID: rec.ID, Repo: rec.Model, Kind: string(rec.Kind), + Modality: string(rec.Modality), FootprintGiB: foot, Archived: rec.Archived, + } + for _, n := range nodes { + for _, d := range n.Deployments { + if d.RecipeID == rec.ID { + bm.OnNode, bm.OnStatus = n.NodeID, d.DockerStatus + } + } + if rec.Model != "" { + for _, repo := range n.CachedWeightRepos { + if repo == rec.Model { + bm.Cached = append(bm.Cached, n.NodeID) + } + } + } + if n.Connected && !rec.Archived { + after := n.MarginGiB - foot + bm.Fits = append(bm.Fits, nodeFit{NodeID: n.NodeID, Fits: after >= 0, MarginAfterGiB: after}) + } + } + models = append(models, bm) + } + // Deployed first, then library, then archived; alpha within each group - + // the operator's eye goes to what is running before what could run. + sort.SliceStable(models, func(i, j int) bool { + a, b := models[i], models[j] + ra, rb := rank(a), rank(b) + if ra != rb { + return ra < rb + } + return a.ID < b.ID + }) + + d := boardData{ + Title: "Sous — Fleet", + Message: r.URL.Query().Get("msg"), + IsError: r.URL.Query().Get("err") == "1", + MaxScaleGiB: ceilTo(maxScale, 20), + Nodes: nodes, + Models: models, + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := s.tpl.ExecuteTemplate(w, "board", d); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + +func rank(m boardModel) int { + switch { + case m.Archived: + return 2 + case m.OnNode != "": + return 0 + default: + return 1 + } +} + +// ceilTo rounds up to the next multiple of step, so the ruler ends on a +// labelled tick rather than mid-gap. +func ceilTo(v, step float64) float64 { + if v <= 0 { + return step + } + n := int((v-0.0001)/step) + 1 + return float64(n) * step +} diff --git a/internal/httpapi/cards_test.go b/internal/httpapi/cards_test.go index 43494ce..5ced3ff 100644 --- a/internal/httpapi/cards_test.go +++ b/internal/httpapi/cards_test.go @@ -7,7 +7,7 @@ import ( ) func TestNodeUsesTheCardGrid(t *testing.T) { - h := newTestServer(t) + h := newTestServerNilGRPC(t) post(t, h, "/api/deploy/qwen38", "", "") b := send(t, h, http.MethodGet, "/", "", "").Body.String() for _, want := range []string{`class="cards"`, `class="card is-`, "card-head", "card-foot", "mlabel"} { diff --git a/internal/httpapi/dragdrop_test.go b/internal/httpapi/dragdrop_test.go index 32a5b0d..c091247 100644 --- a/internal/httpapi/dragdrop_test.go +++ b/internal/httpapi/dragdrop_test.go @@ -63,23 +63,25 @@ func TestModelsPageDraggableCardsCoexistWithWeightChips(t *testing.T) { // `document.querySelectorAll('[data-node-id]')` can find, carrying the id // used to build the deploy URL, and the drop-target class dragdrop.js // toggles drop-hover on. -func TestNodePageFleetCardsAreDropTargets(t *testing.T) { +func TestBoardNodeBaysAreDropTargets(t *testing.T) { h, nodes := newTestServerWithNodes(t) nodes.ReplaceSnapshot("asus-gx10", &pb.NodeSnapshot{ NodeId: "asus-gx10", PoolGib: 121.6, ReserveGib: 24, }) body := send(t, h, http.MethodGet, "/", "", "").Body.String() + // The board's drop target is the node bay, keyed by data-node-id; + // board.js attaches dragover/drop to every [data-node-id] bay and posts + // the dropped recipe to /api/deploy/{recipe}/{node}. if !strings.Contains(body, `data-node-id="asus-gx10"`) { - t.Errorf("expected data-node-id=\"asus-gx10\" on the fleet card; body:\n%s", body) + t.Errorf("expected data-node-id=\"asus-gx10\" on a node bay; body:\n%s", body) } - if !strings.Contains(body, "drop-target") { - t.Error("expected the drop-target class on the fleet card") + if !strings.Contains(body, `class="bay`) { + t.Error("expected node bays on the board") } - // Task 12's own data-node attribute and is-idle/connected chip must - // still be there - Task 13 adds to this card, it does not replace it. - if !strings.Contains(body, `data-node="asus-gx10"`) { - t.Error("expected Task 12's own data-node attribute to still render") + // The board's script is what wires the drop; the page must ship it. + if !strings.Contains(body, `/static/board.js`) { + t.Error("board page does not load board.js, so drag-and-drop would be dead") } } diff --git a/internal/httpapi/handlers.go b/internal/httpapi/handlers.go index 408596c..a14aa74 100644 --- a/internal/httpapi/handlers.go +++ b/internal/httpapi/handlers.go @@ -204,7 +204,7 @@ func (s *Server) deploy(w http.ResponseWriter, r *http.Request) { } if nodeID := r.PathValue("nodeID"); nodeID != "" { - s.deployNode(w, v, nodeID, port, force) + s.deployNode(w, r, v, nodeID, port, force) return } @@ -246,10 +246,23 @@ func (s *Server) deploy(w http.ResponseWriter, r *http.Request) { // deploy.Manager. Always answers JSON - the node-scoped routes are new, have // no existing form-posting UI, and Task 13's drag-and-drop deploy calls this // with fetch(), which never sends the form Content-Type wantsHTML checks for. -func (s *Server) deployNode(w http.ResponseWriter, v, nodeID string, port int, force bool) { +func (s *Server) deployNode(w http.ResponseWriter, r *http.Request, v, nodeID string, port int, force bool) { + // The board's drag-and-drop calls this with fetch() (JSON); its no-JS + // "Deploy to…" menu posts a real form (wantsHTML). Both hit the same + // route - the difference is only how the result is delivered: a browser + // form gets a redirect back to the board with a banner, a script gets + // JSON. htmlErr centralises that so every exit honours it. + htmlErr := func(code int, msg string) { + if wantsHTML(r) { + s.redirect(w, r, "/", msg, true) + return + } + writeErr(w, code, msg) + } + rec, err := s.cat.Get(v) if err != nil { - writeErr(w, http.StatusNotFound, err.Error()) + htmlErr(http.StatusNotFound, err.Error()) return } @@ -267,7 +280,7 @@ func (s *Server) deployNode(w http.ResponseWriter, v, nodeID string, port int, f // can knowingly accept, not for un-deleting the "impossible on this // hardware" fact Archived records. if rec.Archived { - writeErr(w, http.StatusConflict, fmt.Sprintf( + htmlErr(http.StatusConflict, fmt.Sprintf( "recipe %s is archived and cannot be deployed", v)) return } @@ -276,25 +289,32 @@ func (s *Server) deployNode(w http.ResponseWriter, v, nodeID string, port int, f // rather than issuing a live call - see planOnNode's doc comment for why. plan, err := planOnNode(s.nodes, v, nodeID, rec.Declared.TotalGiB()) if err != nil { - writeErr(w, http.StatusNotFound, err.Error()) + htmlErr(http.StatusNotFound, err.Error()) return } if !plan.Fits && !force { - // Same shape as the legacy path's JSON capacity refusal - // (writeJSON(w, http.StatusConflict, ce.Result)): a script gets the - // margin and MustFree list either way. + if wantsHTML(r) { + s.redirect(w, r, "/", fmt.Sprintf("%s does not fit on %s. Free memory first, or force a deploy from the model page.", v, nodeID), true) + return + } + // Same shape as the legacy path's JSON capacity refusal: a script + // gets the margin and MustFree list to act on. writeJSON(w, http.StatusConflict, plan) return } recipeYAML, err := recipeToYAML(rec) if err != nil { - writeErr(w, http.StatusInternalServerError, err.Error()) + htmlErr(http.StatusInternalServerError, err.Error()) return } res, err := deployToNode(s.gsrv, s.nodes, nodeID, recipeYAML, port, force) if err != nil { - writeErr(w, http.StatusBadGateway, err.Error()) + htmlErr(http.StatusBadGateway, err.Error()) + return + } + if wantsHTML(r) { + s.redirect(w, r, "/", fmt.Sprintf("Deploying %s to %s — it will take a few minutes to load.", v, nodeID), false) return } writeJSON(w, http.StatusOK, res) @@ -315,9 +335,17 @@ func (s *Server) undeploy(w http.ResponseWriter, r *http.Request) { if nodeID := r.PathValue("nodeID"); nodeID != "" { res, err := undeployFromNode(s.gsrv, nodeID, v) if err != nil { + if wantsHTML(r) { + s.redirect(w, r, "/", err.Error(), true) + return + } writeErr(w, http.StatusBadGateway, err.Error()) return } + if wantsHTML(r) { + s.redirect(w, r, "/", fmt.Sprintf("Stopped %s on %s.", v, nodeID), false) + return + } writeJSON(w, http.StatusOK, res) return } diff --git a/internal/httpapi/handlers_test.go b/internal/httpapi/handlers_test.go index 052e671..89969bb 100644 --- a/internal/httpapi/handlers_test.go +++ b/internal/httpapi/handlers_test.go @@ -172,6 +172,20 @@ func newTestServerNilGRPC(t *testing.T) http.Handler { return h } +// newTestServerNilGRPCWithRuntime is the single-node configuration (cmd/sous: +// no fleet, so "GET /" renders the local deploy.Manager node page with its +// pool ruler and stepper, NOT the multi-node board), plus a handle on the +// fake runtime. The local-deploy rendering tests use this: their subject - +// a model deployed on THIS box, shown with a boot stepper and a phase- +// coloured pool bar - is exactly what the board (a fleet view of remote +// souslet nodes) does not and should not show, and now lives only here. +func newTestServerNilGRPCWithRuntime(t *testing.T) (http.Handler, *fakeRuntime) { + t.Helper() + rt := &fakeRuntime{running: map[string]bool{}} + h, _, _, _ := buildServerFull(t, t.TempDir(), rt, auth.Config{Disabled: true}, false) + return h, rt +} + // buildServerFull is buildServerWith's real implementation, broken out so // node-scoped tests can also get at the *nodecatalog.Catalog backing the new // routes; every other existing helper wraps this and discards it. withGRPC diff --git a/internal/httpapi/nodesapi.go b/internal/httpapi/nodesapi.go index 0485a9b..e7404c0 100644 --- a/internal/httpapi/nodesapi.go +++ b/internal/httpapi/nodesapi.go @@ -44,9 +44,23 @@ type deploymentJSON struct { KvGiB float64 `json:"kv_gib"` } +// TotalGiB is the deployment's committed memory - what the board draws its +// segment to. A method so the board template can size the bar without an +// "add" helper in the funcmap. +func (d deploymentJSON) TotalGiB() float64 { return d.WeightsGiB + d.KvGiB } + // apiNodes serves GET /api/nodes. Registered only when gsrv&&nodes are wired // (see server.go's gate) so it never touches a nil catalog. func (s *Server) apiNodes(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, s.fleetView()) +} + +// fleetView is the single source of the fleet's live state: the JSON API +// (GET /api/nodes) and the server-rendered board (pageBoard) both read it, +// so they cannot disagree about margins or what is deployed where. The +// committed/margin arithmetic here is identical to planOnNode and +// capacity.Planner, so a fit the board shows is a fit the deploy accepts. +func (s *Server) fleetView() []nodeJSON { now := time.Now() views := s.nodes.All() out := make([]nodeJSON, 0, len(views)) @@ -91,5 +105,5 @@ func (s *Server) apiNodes(w http.ResponseWriter, r *http.Request) { }) } sort.Slice(out, func(i, j int) bool { return out[i].NodeID < out[j].NodeID }) - writeJSON(w, http.StatusOK, out) + return out } diff --git a/internal/httpapi/plan_test.go b/internal/httpapi/plan_test.go index 43da8d2..61c7a5d 100644 --- a/internal/httpapi/plan_test.go +++ b/internal/httpapi/plan_test.go @@ -114,12 +114,22 @@ func TestPlanPageShipsThePoolBarStyles(t *testing.T) { // The same partial on the other page that uses it, so a future move that fixes // one and breaks the other cannot pass. -func TestNodePageShipsThePoolBarStyles(t *testing.T) { +func TestBoardShipsItsStylesheet(t *testing.T) { h := newTestServer(t) - body := send(t, h, http.MethodGet, "/", "", "").Body.String() - for _, want := range []string{".pool-bar{", ".seg{", ".seg-reserve"} { - if !strings.Contains(body, want) { - t.Errorf("node page does not ship %q", want) + page := send(t, h, http.MethodGet, "/", "", "").Body.String() + // The board links a real stylesheet rather than inlining it, so the + // asset caches and the page stays small; the link must be present or the + // board renders unstyled. + if !strings.Contains(page, `href="/static/board.css"`) { + t.Fatalf("board page does not link /static/board.css") + } + css := send(t, h, http.MethodGet, "/static/board.css", "", "").Body.String() + // The load-bearing board rules: the pool bar, its segments, and the + // reserve treatment. If these are gone the "memory is a length" diagram + // is not drawn. + for _, want := range []string{".bar ", ".seg ", ".seg.reserve", ".bay "} { + if !strings.Contains(css, want) { + t.Errorf("board.css does not define %q", want) } } } diff --git a/internal/httpapi/render_test.go b/internal/httpapi/render_test.go index d0c70fc..c7b2bb9 100644 --- a/internal/httpapi/render_test.go +++ b/internal/httpapi/render_test.go @@ -10,7 +10,7 @@ import ( // own: a template that fails part way through leaves a half-written page with a // 200 status, which reads as success everywhere except the browser. func TestStage1PagesRenderWhole(t *testing.T) { - h := newTestServer(t) + h := newTestServerNilGRPC(t) if rr := post(t, h, "/api/deploy/qwen38", "", ""); rr.Code != http.StatusOK { t.Fatalf("deploy: %d", rr.Code) } @@ -52,7 +52,7 @@ func tailOf(s string, n int) string { // The stepper must actually reach the page while a model is starting - that is // the whole point of deriving it. func TestStartingModelRendersTheStepper(t *testing.T) { - h := newTestServer(t) + h := newTestServerNilGRPC(t) if rr := post(t, h, "/api/deploy/qwen38", "", ""); rr.Code != http.StatusOK { t.Fatalf("deploy: %d", rr.Code) } diff --git a/internal/httpapi/screens_test.go b/internal/httpapi/screens_test.go index 030e47b..a0571e0 100644 --- a/internal/httpapi/screens_test.go +++ b/internal/httpapi/screens_test.go @@ -35,7 +35,7 @@ func TestListScreensUseCards(t *testing.T) { post(t, h, "/api/deploy/qwen38", "", "") post(t, h, "/api/keys", "application/json", `{"name":"probe"}`) - for _, path := range []string{"/", "/models", "/keys"} { + for _, path := range []string{"/models", "/keys"} { body := send(t, h, http.MethodGet, path, "", "").Body.String() if !strings.Contains(body, `class="cards`) { t.Errorf("%s is not on the card grid", path) @@ -69,7 +69,7 @@ func TestCardsDoNotNestPanels(t *testing.T) { // them. .panel and .wrap both grew a border and never grew the padding, so // every heading, form and paragraph sat flush against the line. func TestBoxesCarryTheirPadding(t *testing.T) { - h := newTestServer(t) + h := newTestServerNilGRPC(t) css := send(t, h, http.MethodGet, "/", "", "").Body.String() for _, sel := range []string{".panel{", ".wrap{"} { @@ -237,7 +237,7 @@ func TestRecipeCreationLandsOnModelsWithItsMessage(t *testing.T) { // gone wrong. Orphans hold no memory, so Residents is zero - but the pool is // not what the operator is looking at. func TestEmptyStateDoesNotClaimAFreePoolBesideOrphans(t *testing.T) { - h, rt := newTestServerWithRuntime(t) + h, rt := newTestServerNilGRPCWithRuntime(t) if rr := post(t, h, "/api/deploy/qwen38", "", ""); rr.Code != http.StatusOK { t.Fatalf("deploy failed: %d", rr.Code) } @@ -509,7 +509,10 @@ func TestCardsShowEveryCallableName(t *testing.T) { if rr := setAlias(t, h, "qwen38", `["cardalias"]`); rr.Code != http.StatusOK { t.Fatalf("set: %d %s", rr.Code, rr.Body.String()) } - for _, path := range []string{"/models", "/"} { + // The board shelf keys a model by its recipe id (a stable handle a drag + // carries), not its every alias; aliases are shown where they are + // managed, on /models and the model page. So this asserts on /models. + for _, path := range []string{"/models"} { body := send(t, h, http.MethodGet, path, "", "").Body.String() if !strings.Contains(body, "cardalias") { t.Errorf("%s does not show the alias on the card", path) diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 56a3fbd..58be1f3 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -262,7 +262,7 @@ func New(m *deploy.Manager, c *catalog.Catalog, keys *apikey.Manager, fx *fetch. // The Node dashboard is the landing page: the first question on opening // this panel is "what is running and is it healthy", not "what could I // run next". - s.mux.HandleFunc("GET /", s.pageNode) + s.mux.HandleFunc("GET /", s.pageBoard) // MODELS, not Catalog. One list of recipes carrying phase, because a recipe // and a deployment are the same object in two states and splitting them // across two pages made an operator hold that distinction themselves. diff --git a/internal/httpapi/status_test.go b/internal/httpapi/status_test.go index 383c20f..803f50e 100644 --- a/internal/httpapi/status_test.go +++ b/internal/httpapi/status_test.go @@ -6,7 +6,6 @@ import ( "net/http" "net/http/httptest" "net/url" - "regexp" "strings" "testing" @@ -159,22 +158,27 @@ func TestLastLinesReturnsTheEndNotTheStart(t *testing.T) { } } -func TestNodePageRenders(t *testing.T) { - h := newTestServer(t) +func TestBoardRenders(t *testing.T) { + h, nodes := newTestServerWithNodes(t) + nodes.ReplaceSnapshot("asus-gx10", &pb.NodeSnapshot{ + NodeId: "asus-gx10", PoolGib: 121.6, ReserveGib: 24, + }) rr := send(t, h, http.MethodGet, "/", "", "") if rr.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body: %s", rr.Code, rr.Body.String()) } body := rr.Body.String() - for _, want := range []string{"Node", "pool-bar", "GiB free", "Sous"} { + for _, want := range []string{"Sous", "Fleet", "asus-gx10", "GiB free"} { if !strings.Contains(body, want) { - t.Errorf("node page missing %q", want) + t.Errorf("board missing %q", want) } } - // An idle node must say so rather than render an empty bar with no - // explanation. - if !strings.Contains(body, "Nothing deployed") { - t.Error("idle node page has no empty state") + // A connected node with nothing on it must say so, not draw a bare bar. + if !strings.Contains(body, "Nothing deployed here") { + t.Error("idle node bay has no empty state") + } + if !strings.Contains(body, "") { + t.Error("board did not render to completion") } } @@ -191,7 +195,7 @@ func TestNodePageDoesNotSwallowUnknownPaths(t *testing.T) { } func TestNodePageDrawsSegmentsToScale(t *testing.T) { - h := newTestServer(t) + h := newTestServerNilGRPC(t) if rr := post(t, h, "/api/deploy/qwen38", "", ""); rr.Code != http.StatusOK { t.Fatalf("deploy failed: %d", rr.Code) } @@ -282,10 +286,15 @@ func TestPageNodeCardShowsDisconnectedNodeGreyedOutNotVanished(t *testing.T) { } body := rr.Body.String() if !strings.Contains(body, "asus-gx10") { - t.Fatal("disconnected node vanished from the dashboard instead of rendering its last-known state") + t.Fatal("disconnected node vanished from the board instead of rendering its last-known state") } - if !strings.Contains(body, "is-idle") { - t.Error("disconnected node card missing the is-idle chip/border treatment") + // The board keeps the bay but marks it offline (greyed, not a drop + // target) and says so in words rather than dropping it silently. + if !strings.Contains(body, "class=\"bay offline\"") { + t.Error("disconnected node bay missing the offline treatment") + } + if !strings.Contains(body, "disconnected") { + t.Error("disconnected node bay does not say it is disconnected") } } @@ -314,22 +323,19 @@ func TestFleetCardSegmentDoesNotClaimReadyForAnUnhealthyContainer(t *testing.T) }) body := send(t, h, http.MethodGet, "/", "", "").Body.String() - seg := regexp.MustCompile(`
]*data-seg="crashloop"`).FindStringSubmatch(body) - if seg == nil { - t.Fatalf("no segment rendered for the crashloop deployment; body:\n%s", body) - } - class := seg[1] - if strings.Contains(class, "seg-ready") { - t.Errorf("crashloop (docker status %q) rendered class %q - claims ready for an unhealthy container", "restarting", class) + // The board never renders a "ready" verdict on the node path - there is + // no health probe there, so it cannot know one. A crashlooping + // container must not get a ready dot or the word "ready" beside it. + if !strings.Contains(body, "crashloop") { + t.Fatalf("no entry rendered for the crashloop deployment; body:\n%s", body) } - if !strings.Contains(class, "seg-unknown") { - t.Errorf("expected the neutral seg-unknown class for a fleet segment whose health cannot be read from Phase, got %q", class) + if strings.Contains(body, `class="dot ready"`) { + t.Error("board rendered a ready dot for a fleet deployment whose health it cannot read") } - // The raw Docker status must stay visible (in the tooltip) rather than - // being hidden behind whichever color the segment ends up with - the - // coarseness should be disclosed, not disguised. + // The raw Docker status must stay visible rather than be smoothed into a + // colour - the coarseness is disclosed, not disguised. if !strings.Contains(body, "restarting") { - t.Error(`raw docker status "restarting" not surfaced anywhere on the page`) + t.Error(`raw docker status "restarting" not surfaced anywhere on the board`) } } diff --git a/internal/ui/static/board.css b/internal/ui/static/board.css new file mode 100644 index 0000000..8ed4aa3 --- /dev/null +++ b/internal/ui/static/board.css @@ -0,0 +1,310 @@ +/* Sous board — "memory is a length". + * + * One shared GiB ruler runs across the fleet: every node is drawn to the + * width of its memory pool and every model to the width of its footprint, + * so "does it fit" is a length the eye reads before any request is sent. + * The scale lives in one custom property, --gib (px per GiB), set on the + * board element from the largest pool; every bar width is `calc(var(--gib) + * * )`. Nothing here is decoration: every edge, hatch and length is a + * measurement. + * + * No build step, no framework, no webfont. Design tokens are CSS custom + * properties with a dark-scheme override; colours are chosen for the + * subject (occupied memory, reserve, ready, in-transition, fault), not a + * SaaS palette. */ + +:root { + /* Engineering paper, not cream: a cool stone-grey with a green cast, so a + * node box (near-white) reads as a physical tray laid on a bench. */ + --ground: #e9ece6; + --surface: #f7f8f6; + --surface-sunk: #e2e6df; + --ink: #1f2a36; /* slate-navy: all text, rules, box edges. not black. */ + --ink-soft: #5a6672; /* secondary text */ + --ink-faint: #8a94a0; /* tertiary / disabled */ + --line: #c9cfc8; /* hairline dividers on the ground */ + --line-box: #d3d8d0; /* dividers inside a surface */ + + --weights: #2e5c8a; /* occupied memory: weights solid, KV hatched in this hue */ + --weights-soft: #7fa6c9; /* KV tint / ghost preview fill */ + --ready: #1e7b4b; /* used ONLY for a verified-ready dot + the word "ready" */ + --loading: #9a6b00; /* starting / fetching / stopping / stale snapshot */ + --fault: #8e2f45; /* exited / failed / negative margin / overhang / destructive */ + --focus: #2e5c8a; /* keyboard focus ring — same hue as memory, on purpose */ + + --reserve-ink: #1f2a36; /* reserve hatch is ink; it is held-back, not occupied */ + + --r: 5px; /* the ONE radius, for surfaces. bars are square: a + measurement has no rounded corners. */ + --pad: 16px; + --gap: 12px; + + --mono: ui-monospace, "SF Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace; + --sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; +} + +:root[data-theme="dark"], :root:not([data-theme="light"]) { + /* left as light by default; dark applied only under the media query so a + * host with no preference stays on paper. */ +} +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + --ground: #12171d; + --surface: #1b222b; + --surface-sunk: #141a21; + --ink: #e6eaee; + --ink-soft: #9aa6b2; + --ink-faint: #6b7681; + --line: #2a333d; + --line-box: #313b46; + --weights: #6fa3d8; + --weights-soft: #3f5c7a; + --ready: #4cbf82; + --loading: #e0b040; + --fault: #e07a90; + --focus: #6fa3d8; + --reserve-ink: #8a94a0; + } +} +:root[data-theme="dark"] { + --ground: #12171d; --surface: #1b222b; --surface-sunk: #141a21; + --ink: #e6eaee; --ink-soft: #9aa6b2; --ink-faint: #6b7681; + --line: #2a333d; --line-box: #313b46; + --weights: #6fa3d8; --weights-soft: #3f5c7a; --ready: #4cbf82; + --loading: #e0b040; --fault: #e07a90; --focus: #6fa3d8; --reserve-ink: #8a94a0; +} + +* { box-sizing: border-box; } +html { -webkit-text-size-adjust: 100%; } +body { + margin: 0; + background: var(--ground); + color: var(--ink); + font-family: var(--sans); + font-size: 15px; + line-height: 1.5; +} +a { color: var(--weights); text-decoration: none; } +a:hover { text-decoration: underline; } +:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; border-radius: 2px; } +.mono { font-family: var(--mono); } +.muted { color: var(--ink-soft); } + +/* ---- top bar ------------------------------------------------------------ */ +.topbar { + display: flex; align-items: baseline; gap: 24px; + padding: 14px var(--pad); + border-bottom: 1px solid var(--line); + background: var(--surface); + flex-wrap: wrap; +} +.topbar .brand { font-weight: 700; letter-spacing: 0.02em; font-size: 17px; } +.topbar nav { display: flex; gap: 18px; } +.topbar nav a { color: var(--ink-soft); } +.topbar nav a[aria-current="page"] { color: var(--ink); font-weight: 600; } +.topbar .spacer { margin-left: auto; } +.topbar .who { color: var(--ink-soft); font-size: 13px; } + +/* ---- flash banner ------------------------------------------------------- */ +.flash { + margin: 12px var(--pad) 0; padding: 10px 14px; border-radius: var(--r); + border: 1px solid var(--line-box); background: var(--surface); +} +.flash.err { border-color: var(--fault); color: var(--fault); } + +main { padding: var(--pad); max-width: 1180px; } + +h1 { font-size: 22px; font-weight: 650; margin: 4px 0 2px; } +.lede { color: var(--ink-soft); max-width: 68ch; margin: 0 0 20px; } + +/* ---- the ruler ---------------------------------------------------------- */ +.ruler { + position: relative; height: 18px; margin: 8px 0 6px; + border-bottom: 1px solid var(--ink); +} +.ruler .tick { + position: absolute; bottom: 0; width: 1px; height: 6px; background: var(--ink); +} +.ruler .tick span { + position: absolute; bottom: 8px; left: 0; transform: translateX(-50%); + font-family: var(--mono); font-size: 11px; color: var(--ink-soft); white-space: nowrap; +} + +/* ---- node bay ----------------------------------------------------------- */ +.bay { + background: var(--surface); border: 1px solid var(--line-box); + border-radius: var(--r); padding: 14px; margin-bottom: var(--gap); +} +.bay.drop-ok { outline: 2px dashed var(--weights); outline-offset: 3px; } +.bay.drop-bad { outline: 2px dashed var(--fault); outline-offset: 3px; } +.bay.offline { border-style: dashed; } +.bay.offline .bar { filter: grayscale(1) opacity(0.55); } +.bay-head { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; } +.bay-head .id { font-size: 17px; font-weight: 600; } +.bay-head .free { margin-left: auto; font-family: var(--mono); font-size: 15px; } +.bay-head .free.over { color: var(--fault); } +.freshness { font-size: 12px; color: var(--ink-soft); width: 100%; margin-top: 2px; } +.freshness.stale { color: var(--loading); } +.freshness.gone { color: var(--fault); } + +/* the pool bar — width set inline to pool*--gib */ +.bar { + position: relative; height: 46px; margin: 10px 0 4px; + display: flex; background: var(--surface-sunk); + border: 1px solid var(--ink); border-radius: 2px; overflow: hidden; +} +.seg { position: relative; height: 100%; overflow: hidden; border-right: 1px solid rgba(31,42,54,.18); } +.seg:last-child { border-right: 0; } +.seg .lab { + position: absolute; inset: 0; padding: 3px 6px; font-size: 11px; line-height: 1.2; + color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.seg.weights { background: var(--weights); } +.seg.kv { + background-color: var(--weights); + background-image: repeating-linear-gradient(45deg, transparent 0 5px, rgba(255,255,255,.35) 5px 6px); +} +.seg.free { background: transparent; } +.seg.free .lab { color: var(--ink-soft); } +.seg.reserve { + background-color: transparent; + background-image: repeating-linear-gradient(45deg, transparent 0 6px, color-mix(in srgb, var(--reserve-ink) 45%, transparent) 6px 7px); +} +.seg.reserve .lab { color: var(--ink-soft); } +.seg.transitional { + background-color: var(--loading); + background-image: repeating-linear-gradient(45deg, transparent 0 5px, rgba(255,255,255,.30) 5px 6px); +} +.seg.fault { background: var(--fault); } +/* ghost preview injected during a drag */ +.seg.ghost { + background: color-mix(in srgb, var(--weights-soft) 60%, transparent); + border: 1px dashed var(--ink); +} +.seg.ghost.over { background: color-mix(in srgb, var(--fault) 45%, transparent); } + +.warnline { position: absolute; top: 0; bottom: 0; width: 1px; background: var(--loading); } + +/* per-deployment status row under a bay */ +.dep { + display: flex; align-items: center; gap: 10px; flex-wrap: wrap; + padding: 6px 0; border-top: 1px solid var(--line-box); font-size: 14px; +} +.dep:first-of-type { border-top: 0; } +.dep .name { font-family: var(--mono); } +.dot { width: 9px; height: 9px; border-radius: 50%; display: inline-block; flex: none; } +.dot.ready { background: var(--ready); } +.dot.transitional { background: var(--loading); } +.dot.fault { background: var(--fault); } +.dot.unknown { background: var(--ink-faint); } +.state { font-size: 13px; color: var(--ink-soft); } +.state.ready { color: var(--ready); } +.state.transitional { color: var(--loading); } +.state.fault { color: var(--fault); } + +/* ---- model shelf -------------------------------------------------------- */ +.shelf-head { display: flex; align-items: baseline; gap: 16px; margin: 26px 0 8px; flex-wrap: wrap; } +.shelf-head h2 { font-size: 18px; margin: 0; font-weight: 600; } +.filters { display: flex; gap: 12px; font-size: 13px; } +.filters a { color: var(--ink-soft); } +.filters a[aria-current="true"] { color: var(--ink); font-weight: 600; } + +.model { + display: grid; grid-template-columns: 200px 1fr auto; align-items: center; + gap: 14px; padding: 9px 0; border-top: 1px solid var(--line); +} +.model:first-child { border-top: 0; } +.model.archived { opacity: 0.5; } +.model .handle { display: flex; align-items: center; gap: 8px; min-width: 0; } +.model .grip { color: var(--ink-faint); cursor: grab; user-select: none; } +.model[draggable="true"]:active .grip { cursor: grabbing; } +.model .title { font-family: var(--mono); font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.model .mbar { position: relative; height: 22px; background: var(--surface-sunk); border: 1px solid var(--line-box); border-radius: 2px; overflow: hidden; } +.model .mbar .fill { height: 100%; background: var(--weights); } +.model .mbar .foot { position: absolute; inset: 0; padding: 2px 6px; font-family: var(--mono); font-size: 11px; color: var(--ink); } +.model .status { font-size: 13px; color: var(--ink-soft); text-align: right; white-space: nowrap; } +.model .status .on { color: var(--ready); } + +/* ---- buttons & forms ---------------------------------------------------- */ +.btn, .btn-primary, .btn-danger { + font: inherit; font-size: 14px; padding: 7px 13px; border-radius: var(--r); + border: 1px solid var(--ink); background: var(--surface); color: var(--ink); + cursor: pointer; text-decoration: none; display: inline-block; line-height: 1.2; +} +.btn:hover, .btn-primary:hover, .btn-danger:hover { text-decoration: none; } +.btn-primary { background: var(--ink); color: var(--surface); } +.btn-danger { border-color: var(--fault); color: var(--fault); background: var(--surface); } +.btn-danger:hover { background: var(--fault); color: #fff; } +.btn:disabled, .btn-primary:disabled { opacity: .45; cursor: not-allowed; } +.btn-row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; } + +/* the deploy-to menu (no-JS path, and the keyboard path) */ +details.deploy-to { position: relative; display: inline-block; } +details.deploy-to > summary { + list-style: none; cursor: pointer; + font-size: 14px; padding: 7px 13px; border-radius: var(--r); + border: 1px solid var(--ink); background: var(--ink); color: var(--surface); +} +details.deploy-to > summary::-webkit-details-marker { display: none; } +details.deploy-to[open] > .menu { + position: absolute; right: 0; z-index: 20; margin-top: 4px; min-width: 240px; + background: var(--surface); border: 1px solid var(--ink); border-radius: var(--r); + padding: 6px; box-shadow: 0 6px 20px rgba(31,42,54,.18); +} +.menu .node-choice { width: 100%; text-align: left; border: 0; background: transparent; + padding: 8px 10px; border-radius: 4px; cursor: pointer; font: inherit; color: var(--ink); } +.menu .node-choice:hover { background: var(--surface-sunk); } +.menu .node-choice:disabled { color: var(--ink-faint); cursor: not-allowed; } +.menu .node-choice .fit { float: right; font-family: var(--mono); font-size: 12px; color: var(--ink-soft); } +.menu .node-choice .fit.over { color: var(--fault); } + +/* the confirm sheet — ONE plain button, never type-the-name */ +.confirm-sheet { + border: 1px solid var(--ink); border-radius: var(--r); background: var(--surface); + padding: 14px; margin: 10px 0; +} +.confirm-sheet.danger { border-color: var(--fault); } +.confirm-sheet .why { color: var(--ink-soft); font-size: 14px; margin: 6px 0 12px; max-width: 64ch; } + +/* the vLLM boot sequence — an actual ordered sequence, so numbered */ +ol.stages { margin: 10px 0; padding-left: 0; list-style: none; counter-reset: stage; } +ol.stages li { counter-increment: stage; display: flex; gap: 10px; padding: 4px 0; align-items: baseline; } +ol.stages li::before { + content: counter(stage); font-family: var(--mono); font-size: 12px; + color: var(--ink-soft); border: 1px solid var(--line-box); border-radius: 50%; + width: 20px; height: 20px; display: inline-flex; align-items: center; justify-content: center; flex: none; +} +ol.stages li.done::before { color: var(--ready); border-color: var(--ready); content: "\2713"; } +ol.stages li.active::before { color: var(--loading); border-color: var(--loading); } + +.field { display: block; margin: 12px 0; } +.field label { display: block; font-size: 13px; color: var(--ink-soft); margin-bottom: 4px; } +.field input, .field textarea, .field select { + font: inherit; width: 100%; max-width: 520px; padding: 8px 10px; + border: 1px solid var(--line-box); border-radius: var(--r); + background: var(--surface); color: var(--ink); +} +.field textarea { min-height: 180px; font-family: var(--mono); font-size: 13px; } + +/* copy-a-secret control — works over plain HTTP (no navigator.clipboard) */ +.secret-row { display: flex; gap: 8px; align-items: center; max-width: 640px; } +.secret-row input { + flex: 1; font-family: var(--mono); font-size: 13px; padding: 8px 10px; + border: 1px solid var(--ink); border-radius: var(--r); background: var(--surface-sunk); color: var(--ink); +} +.copy-hint { font-size: 12px; color: var(--ink-soft); min-height: 1em; } +.copy-hint.ok { color: var(--ready); } + +.empty { color: var(--ink-soft); padding: 24px 0; } + +/* reduced motion: nothing animates that the operator did not trigger, but be + explicit for any transition added later. */ +@media (prefers-reduced-motion: reduce) { * { animation: none !important; transition: none !important; } } + +/* phone */ +@media (max-width: 720px) { + main { padding: 12px; } + .model { grid-template-columns: 1fr; gap: 6px; } + .model .status { text-align: left; } + .bay-head .free { margin-left: 0; width: 100%; } +} diff --git a/internal/ui/static/board.js b/internal/ui/static/board.js new file mode 100644 index 0000000..6a69cb8 --- /dev/null +++ b/internal/ui/static/board.js @@ -0,0 +1,291 @@ +/* Sous board — progressive enhancement over a server-rendered board. + * + * The page works with no JavaScript: every model carries a "Deploy to…" + * menu of real
posts, one per connected node. This script layers on + * three things and nothing the no-JS path cannot already do: + * 1. live refresh — poll GET /api/nodes, redraw node bays in place + * 2. drag to deploy — the same POST /api/deploy/{id}/{node} a menu item + * fires, reachable by dragging a model onto a node + * 3. fit preview — while dragging, ghost the model's true length into + * each node so "does it fit" is seen before the drop + * + * "Memory is a length": every width below is calc(var(--gib) * GiB), the + * same scale the server rendered with, recomputed here for the real + * viewport. The tailnet drops ~1 request in 10, so every fetch tolerates + * failure and keeps the last good state rather than blanking the board. */ +(function () { + "use strict"; + var board = document.getElementById("board"); + if (!board) return; + var bays = document.getElementById("bays"); + var POLL_MS = 4000; + var fleet = []; // last good GET /api/nodes + var dragging = null; // {recipe, gib} + + function setScale() { + // px per GiB from the widest pool and the board's real width, so + // asus-gx10 (121.6) spans most of the row and aorus (15.9) is a stub. + var maxPool = 16; + fleet.forEach(function (n) { if (n.pool_gib > maxPool) maxPool = n.pool_gib; }); + // also consider the widest model so the shelf bars use the same ruler + document.querySelectorAll(".model[data-footprint-gib]").forEach(function (m) { + var g = parseFloat(m.getAttribute("data-footprint-gib")) || 0; + if (g > maxPool) maxPool = g; + }); + var avail = board.clientWidth - 32; + if (avail < 320) avail = 320; + board.style.setProperty("--gib", (avail / maxPool) + "px"); + drawRuler(maxPool); + } + + function drawRuler(maxPool) { + var r = document.getElementById("ruler"); + if (!r) return; + r.innerHTML = ""; + var step = maxPool > 80 ? 20 : maxPool > 32 ? 10 : 4; + for (var g = 0; g <= maxPool; g += step) { + var t = document.createElement("div"); + t.className = "tick"; + t.style.left = "calc(var(--gib) * " + g + ")"; + var s = document.createElement("span"); + s.textContent = g + (g === 0 ? " GiB" : ""); + t.appendChild(s); + r.appendChild(t); + } + } + + function el(tag, cls, text) { + var e = document.createElement(tag); + if (cls) e.className = cls; + if (text != null) e.textContent = text; + return e; + } + function fmt(g) { return (Math.round(g * 10) / 10).toFixed(1); } + + // committed / margin exactly as the server and planOnNode compute them + function committed(n) { + var c = 0; (n.deployments || []).forEach(function (d) { c += d.weights_gib + d.kv_gib; }); return c; + } + function marginOf(n) { return n.pool_gib - n.reserve_gib - committed(n); } + + function stateOf(d) { + // node path has no readiness probe: honour Docker's raw word, never a + // green "ready" we cannot verify. + switch (d.docker_status) { + case "running": return { cls: "unknown", label: "running (docker)" }; + case "restarting": return { cls: "transitional", label: "restarting" }; + case "created": case "paused": return { cls: "transitional", label: d.docker_status }; + case "exited": case "dead": return { cls: "fault", label: d.docker_status }; + default: return { cls: "unknown", label: d.docker_status || "unknown" }; + } + } + + function ageText(n) { + if (!n.connected) return { cls: "gone", text: "disconnected — showing the last snapshot" }; + var a = Math.round(n.snapshot_age_s); + if (a > 45) return { cls: "stale", text: "connected, but no snapshot for " + a + "s" }; + return { cls: "", text: "connected · snapshot " + a + "s old" }; + } + + function renderBays() { + if (!bays) return; + bays.innerHTML = ""; + fleet.forEach(function (n) { + var bay = el("section", "bay"); + bay.setAttribute("data-node-id", n.node_id); + if (!n.connected) bay.classList.add("offline"); + + var head = el("div", "bay-head"); + head.appendChild(el("span", "id", n.node_id)); + var m = marginOf(n); + var free = el("span", "free" + (m < 0 ? " over" : "")); + free.textContent = fmt(m) + " GiB free of " + fmt(n.pool_gib); + head.appendChild(free); + var fr = ageText(n); + var frDiv = el("div", "freshness " + fr.cls, fr.text); + head.appendChild(frDiv); + bay.appendChild(head); + + var bar = el("div", "bar"); + bar.style.width = "calc(var(--gib) * " + n.pool_gib + ")"; + (n.deployments || []).forEach(function (d) { + var g = d.weights_gib + d.kv_gib; + var st = stateOf(d); + var w = el("div", "seg " + (st.cls === "fault" ? "fault" : st.cls === "transitional" ? "transitional" : "weights")); + w.style.width = "calc(var(--gib) * " + g + ")"; + w.appendChild(el("span", "lab", d.recipe_id)); + w.title = d.recipe_id + " — " + st.label + (d.host_port ? " :" + d.host_port : ""); + bar.appendChild(w); + }); + if (m > 0) { + var free2 = el("div", "seg free"); + free2.style.width = "calc(var(--gib) * " + m + ")"; + bar.appendChild(free2); + } + var res = el("div", "seg reserve"); + res.style.width = "calc(var(--gib) * " + n.reserve_gib + ")"; + res.appendChild(el("span", "lab", "reserve")); + bar.appendChild(res); + bay.appendChild(bar); + + (n.deployments || []).forEach(function (d) { + var st = stateOf(d); + var row = el("div", "dep"); + row.appendChild(el("span", "dot " + st.cls)); + row.appendChild(el("span", "name", d.recipe_id)); + row.appendChild(el("span", "state " + st.cls, st.label + (d.host_port ? " · :" + d.host_port : ""))); + if (n.connected) { + var stop = el("button", "btn-danger", "Stop " + d.recipe_id); + stop.type = "button"; + stop.addEventListener("click", function () { undeploy(d.recipe_id, n.node_id, stop); }); + row.appendChild(stop); + } + bay.appendChild(row); + }); + if (!(n.deployments || []).length) { + bay.appendChild(el("p", "empty", n.connected ? "Nothing deployed here — drag a model in." : "No last-known deployments.")); + } + + if (n.connected) enableDrop(bay, n); + bays.appendChild(bay); + }); + } + + // ---- drag & drop -------------------------------------------------------- + function enableDrop(bay, node) { + bay.addEventListener("dragover", function (ev) { + if (!dragging) return; + ev.preventDefault(); + var m = marginOf(node) - dragging.gib; + bay.classList.toggle("drop-ok", m >= 0); + bay.classList.toggle("drop-bad", m < 0); + showGhost(bay, node, m); + }); + bay.addEventListener("dragleave", function (ev) { + if (!bay.contains(ev.relatedTarget)) clearGhost(bay); + }); + bay.addEventListener("drop", function (ev) { + ev.preventDefault(); + if (!dragging) return; + var recipe = dragging.recipe; + clearGhost(bay); + deploy(recipe, node.node_id); + }); + } + function showGhost(bay, node, marginAfter) { + clearGhost(bay); + var bar = bay.querySelector(".bar"); + if (!bar) return; + var ghost = el("div", "seg ghost" + (marginAfter < 0 ? " over" : "")); + ghost.style.width = "calc(var(--gib) * " + dragging.gib + ")"; + ghost.appendChild(el("span", "lab", dragging.recipe + " " + fmt(dragging.gib))); + // insert before the reserve segment so the overhang is visible past the wall + var reserve = bar.querySelector(".seg.reserve"); + bar.insertBefore(ghost, reserve); + bay._ghost = ghost; + } + function clearGhost(bay) { + bay.classList.remove("drop-ok", "drop-bad"); + if (bay._ghost) { bay._ghost.remove(); bay._ghost = null; } + } + + function wireDragSources() { + document.querySelectorAll(".model[data-recipe-id]").forEach(function (m) { + if (m.getAttribute("data-archived") === "true") return; // archived can't deploy + m.setAttribute("draggable", "true"); + m.addEventListener("dragstart", function (ev) { + dragging = { + recipe: m.getAttribute("data-recipe-id"), + gib: parseFloat(m.getAttribute("data-footprint-gib")) || 0, + }; + ev.dataTransfer.setData("text/plain", dragging.recipe); + ev.dataTransfer.effectAllowed = "copy"; + }); + m.addEventListener("dragend", function () { + dragging = null; + document.querySelectorAll(".bay").forEach(clearGhost); + }); + }); + } + + // ---- actions ------------------------------------------------------------ + function deploy(recipe, node) { + flash("Deploying " + recipe + " to " + node + "…"); + fetch("/api/deploy/" + encodeURIComponent(recipe) + "/" + encodeURIComponent(node), { + method: "POST", headers: { "Accept": "application/json" }, + }).then(readResult).then(function (res) { + if (res.ok) { flash("Deployed " + recipe + " to " + node + ". It will take a few minutes to load."); poll(); } + else if (res.status === 409) { flash(recipe + " does not fit on " + node + (res.body && res.body.must_free ? " — free: " + res.body.must_free.join(", ") : "") + ".", true); } + else { flash("Deploy failed: " + (res.message || ("HTTP " + res.status)), true); } + }).catch(function () { flash("Deploy request did not reach the server — check the node and try again.", true); }); + } + function undeploy(recipe, node, btn) { + if (btn) { btn.disabled = true; } + flash("Stopping " + recipe + " on " + node + "…"); + fetch("/api/undeploy/" + encodeURIComponent(recipe) + "/" + encodeURIComponent(node), { + method: "POST", headers: { "Accept": "application/json" }, + }).then(readResult).then(function (res) { + if (res.ok) { flash("Stopped " + recipe + " on " + node + "."); poll(); } + else { flash("Stop failed: " + (res.message || ("HTTP " + res.status)), true); if (btn) btn.disabled = false; } + }).catch(function () { flash("Stop request did not reach the server.", true); if (btn) btn.disabled = false; }); + } + function readResult(r) { + return r.text().then(function (t) { + var body = null; try { body = JSON.parse(t); } catch (e) {} + return { ok: r.ok, status: r.status, body: body, message: body && (body.error || body.message) }; + }); + } + + // ---- shelf status (derived from fleet) --------------------------------- + function updateShelf() { + document.querySelectorAll(".model[data-recipe-id]").forEach(function (m) { + var id = m.getAttribute("data-recipe-id"); + var status = m.querySelector(".status"); + if (!status || m.getAttribute("data-archived") === "true") return; + var on = null, cached = []; + fleet.forEach(function (n) { + (n.deployments || []).forEach(function (d) { if (d.recipe_id === id) on = { node: n.node_id, d: d }; }); + var repo = m.getAttribute("data-model-repo"); + if (repo && (n.cached_weight_repos || []).indexOf(repo) !== -1) cached.push(n.node_id); + }); + if (on) { + var st = stateOf(on.d); + status.innerHTML = ""; + var s = el("span", "on", "on " + on.node + " · " + st.label); + status.appendChild(s); + } else if (cached.length) { + status.textContent = "weights on " + cached.join(", "); + } else { + status.textContent = "not on any node"; + } + }); + } + + // ---- polling ------------------------------------------------------------ + function poll() { + fetch("/api/nodes", { headers: { "Accept": "application/json" } }) + .then(function (r) { return r.ok ? r.json() : Promise.reject(r.status); }) + .then(function (data) { + fleet = Array.isArray(data) ? data : []; + board.classList.remove("reconnecting"); + setScale(); + renderBays(); + updateShelf(); + }) + .catch(function () { board.classList.add("reconnecting"); /* keep last good board */ }); + } + + var flashEl = document.getElementById("flash"); + function flash(msg, isErr) { + if (!flashEl) return; + flashEl.textContent = msg; + flashEl.className = "flash" + (isErr ? " err" : ""); + flashEl.hidden = false; + } + + wireDragSources(); + setScale(); + poll(); + setInterval(poll, POLL_MS); + window.addEventListener("resize", setScale); +})(); diff --git a/internal/ui/templates/board.html b/internal/ui/templates/board.html new file mode 100644 index 0000000..e1c3116 --- /dev/null +++ b/internal/ui/templates/board.html @@ -0,0 +1,131 @@ +{{define "board"}} + + + + +{{.Title}} + + + + +
+ Sous + + + +
+ +

{{.Message}}

+ +
+

Fleet

+

Every node is drawn to the width of its memory pool and every model to its footprint, + on one shared scale — so what fits is a length you can see. Drag a model onto a node to deploy it, + or use its “Deploy to…” menu.

+ +
+ + {{/* Server-rendered bays: the no-JS board. board.js replaces #bays with a + live version polled from /api/nodes, but this is fully functional on + its own. */}} +
+ {{range .Nodes}} + {{$node := .}} +
+
+ {{.NodeID}} + {{printf "%.1f" .MarginGiB}} GiB free of {{printf "%.1f" .PoolGiB}} +
+ {{if .Connected}}connected · snapshot {{printf "%.0f" .SnapshotAgeS}}s old{{else}}disconnected — showing the last snapshot{{end}} +
+
+
+ {{range .Deployments}} +
+ {{.RecipeID}} +
+ {{end}} + {{if gt .MarginGiB 0.0}}
{{end}} +
reserve
+
+ {{range .Deployments}} +
+ + {{.RecipeID}} + {{.DockerStatus}}{{if .HostPort}} · :{{.HostPort}}{{end}} + {{if $node.Connected}} +
+ +
+ {{end}} +
+ {{else}} +

{{if .Connected}}Nothing deployed here.{{else}}No last-known deployments.{{end}}

+ {{end}} +
+ {{else}} +

No nodes connected. A node appears here once its souslet dials in.

+ {{end}} +
+ +
+

Models

+ drag a model onto a node above, or use its menu + + Manage recipes +
+ + {{range .Models}} + {{$m := .}} +
+ + + {{.ID}} + + + + {{printf "%.1f" .FootprintGiB}} GiB + + + + {{if .Archived}}archived — cannot run here + {{else if .OnNode}}on {{.OnNode}} · {{.OnStatus}} + {{else if .Cached}}weights on {{range $i, $c := .Cached}}{{if $i}}, {{end}}{{$c}}{{end}} + {{else}}not on any node{{end}} + + {{if not .Archived}} +
+ Deploy to… + +
+ {{end}} +
+
+ {{else}} +

No recipes yet. Create one.

+ {{end}} +
+ +{{end}}