diff --git a/CHANGELOG.md b/CHANGELOG.md index c5eaf2e..f52387a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -80,6 +80,9 @@ has a matching GitHub release whose body comes from its section below. ### Fixed +- The gateway's `/gui/` board launches an engine that has a `start` command but no `gui` URL (Strata, a + llama-server). Its Launch button answered 404 and stayed on "Launching..."; it now starts the engine under a + lease and shows Running once it answers. A refused start shows its reason instead of hanging. - `berth vfio status ADDR` asks berthd for its view instead of calling it unreachable, and while the card is in a VM it says so instead of printing the last memory reading as current. - The wake page and start errors show an engine's last log line without terminal color codes diff --git a/docs/gateway.md b/docs/gateway.md index db052b8..ae6dd77 100644 --- a/docs/gateway.md +++ b/docs/gateway.md @@ -270,7 +270,9 @@ Point the bookmark (or a home-page tile) at `http://127.0.0.1:9796/gui/comfyui` - **After Launch:** the page says what the start waits for, in the broker's terms: "Waiting for strata release: finishing its current request", "Waiting for llama-swap (NInfer-27B) release: freeing its memory", then "loading comfyui". It opens the UI once ComfyUI answers. A failed start shows its reason code and a retry button. -- `/gui/` lists every engine with a wake page. +- `/gui/` lists every engine in `berth.toml` with its live state. Any engine with a `start` command gets a Launch + button there, with or without a `gui` URL, so a phone on the tailnet can switch the card to Strata or a + llama-server too. An engine with no web UI shows **Running** once it answers.

The gateway's wake page for comfyui: comfyui is offline and the card is free, with a Launch comfyui button

@@ -279,7 +281,7 @@ slots are idle (`drain_post`), ComfyUI once its queue is empty (`drain_comfyui`) once its `/slots` show idle (`drain_slots`, below). A pinned holder (a game, a render, `berth hold`) is never stopped; the page says so and the start waits until it ends. -The wake page needs no API key: it can only start an engine you gave both `start` and `gui`. It listens on +The wake page needs no API key: it can only start an engine you gave a `start` command. It listens on loopback, accepts loopback Host names plus the gateway's `--allow-host` names (for `tailscale serve` in front), and takes a start only as a POST from its own page, so another site cannot make a visitor's browser drain the card. diff --git a/internal/gateway/gui.go b/internal/gateway/gui.go index 41dd290..4a03cc9 100644 --- a/internal/gateway/gui.go +++ b/internal/gateway/gui.go @@ -27,10 +27,12 @@ import ( // it answers. A bookmark or a home-page tile can point there instead of at an // engine that may be stopped. // -// It holds no key: it can only start an engine that has a start command and -// a gui URL in berth.toml, which is what the operator asked for by writing -// both. A start is a POST that must come from the page itself (same origin), -// so another site cannot make a visitor's browser drain the card. +// It holds no key: it can only start an engine that has a start command in +// berth.toml, which is what the operator asked for by writing one. The index +// launches any such engine, with a web UI or without one (Strata, a +// llama-server), so a phone can switch the card. A start is a POST that must +// come from the page itself (same origin), so another site cannot make a +// visitor's browser drain the card. //go:embed gui var guiFS embed.FS @@ -113,6 +115,17 @@ func (g *Gateway) guiEngine(name string) (*config.Engine, bool) { return nil, false } +// launchable is the configured engine name, if Berth can start it. +func (g *Gateway) launchable(name string) (*config.Engine, bool) { + for i := range g.Engines { + e := &g.Engines[i] + if e.Name == name && len(e.Start) > 0 { + return e, true + } + } + return nil, false +} + func (g *Gateway) probe(ctx context.Context, e *config.Engine) bool { if e.Type == "comfyui" { return g.comfyAnswers(ctx, e.URL) @@ -341,9 +354,9 @@ func names(card []guiHold) string { // polls /state. A second ask while a start is under way joins it. Only the // Start button asks: opening the page never takes the card. func (g *Gateway) guiWake(w http.ResponseWriter, r *http.Request) { - e, ok := g.guiEngine(r.PathValue("name")) + e, ok := g.launchable(r.PathValue("name")) if !ok { - apiError(w, http.StatusNotFound, "no engine of that name has a gui and a start command in berth.toml") + apiError(w, http.StatusNotFound, "no engine of that name has a start command in berth.toml") return } g.mu.Lock() diff --git a/internal/gateway/gui/index.js b/internal/gateway/gui/index.js index 055e517..cc73274 100644 --- a/internal/gateway/gui/index.js +++ b/internal/gateway/gui/index.js @@ -1,5 +1,6 @@ // The gui plane's index: every engine in berth.toml as one row, its live -// state and one button per engine: Launch, Launching... then Open. +// state and one button per engine: Launch, Launching... then Open, or +// Running for an engine with no web UI. (() => { const rows = [...document.querySelectorAll(".row[data-name]")]; const gw = document.getElementById("gw"); @@ -30,6 +31,11 @@ if (!btn) return; // no start command: nothing to push switch (s.state) { case "up": + if (!li.dataset.gui) { + btn.textContent = "Running"; + btn.disabled = true; + break; + } if (launched.has(s.name)) { location.replace(li.dataset.gui); // this tab launched it: go there return; @@ -63,7 +69,13 @@ try { const r = await fetch("/gui/" + encodeURIComponent(li.dataset.name) + "/wake", { method: "POST" }); // The answer names no row (it is the engine's state); this tab knows which. - if (r.ok) showRow({ name: li.dataset.name, ...(await r.json()) }); + const body = await r.json(); + if (r.ok) { + showRow({ name: li.dataset.name, ...body }); + } else { + launched.delete(li.dataset.name); + showRow({ name: li.dataset.name, state: "failed", message: li.dataset.name + " did not start", detail: body.error?.message || "" }); + } } catch { // The gateway is not answering; the line under the heading says it. } diff --git a/internal/gateway/gui_test.go b/internal/gateway/gui_test.go index 47ddce8..43877c3 100644 --- a/internal/gateway/gui_test.go +++ b/internal/gateway/gui_test.go @@ -378,6 +378,47 @@ func TestGUIPlaneLaunchFlow(t *testing.T) { } } +// An engine with a start command and no web UI (Strata, a llama-server) +// launches from the plane like one with a UI: the board is the phone's way to +// switch the card. Its wake page does not exist, since there is no UI to send +// the browser to. +func TestGUIPlaneLaunchAPIOnly(t *testing.T) { + sock, b := daemon(t) + addr := freeAddr(t) + t.Setenv("BERTH_GW_ENGINE", addr+",strata-model,300ms") + g := &Gateway{Socket: sock, Ready: 10 * time.Second, Key: testKey, Log: slog.New(slog.NewTextHandler(io.Discard, nil)), + Engines: []config.Engine{ + {Name: "strata", Type: "llamacpp", URL: "http://" + addr, Start: []string{os.Args[0]}, Serves: []string{"strata-model"}}, + }} + srv := httptest.NewServer(g.GUIHandler()) + defer srv.Close() + + resp := guiReq(t, "POST", srv.URL+"/gui/strata/wake", map[string]string{"Sec-Fetch-Site": "same-origin"}) + resp.Body.Close() + if resp.StatusCode != http.StatusAccepted { + t.Fatalf("wake = %d", resp.StatusCode) + } + deadline := time.Now().Add(8 * time.Second) + for { + r := planeRow(guiPlaneRows(t, srv.URL), "strata") + if r.State == "up" { + break + } + if r.State == "failed" || time.Now().After(deadline) { + t.Fatalf("row never came up: %+v", r) + } + time.Sleep(100 * time.Millisecond) + } + if st := b.Status(); len(st.Leases) != 1 || st.Leases[0].Engine != "strata" { + t.Fatalf("leases = %+v", st.Leases) + } + resp = guiReq(t, "GET", srv.URL+"/gui/strata", nil) + resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("wake page for an engine with no UI = %d, want 404", resp.StatusCode) + } +} + // A rebinding name cannot read the plane's state. func TestGUIPlaneGuard(t *testing.T) { sock, b := daemon(t)