Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
145 changes: 145 additions & 0 deletions internal/httpapi/board.go
Original file line number Diff line number Diff line change
@@ -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
}
2 changes: 1 addition & 1 deletion internal/httpapi/cards_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"} {
Expand Down
18 changes: 10 additions & 8 deletions internal/httpapi/dragdrop_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}

Expand Down
48 changes: 38 additions & 10 deletions internal/httpapi/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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
}

Expand All @@ -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
}
Expand All @@ -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)
Expand All @@ -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
}
Expand Down
14 changes: 14 additions & 0 deletions internal/httpapi/handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading