From 9205bb5d28ad50e98f1efddeedcda63f6edb0182 Mon Sep 17 00:00:00 2001 From: T-Crypt <132922589+T-Crypt@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:16:13 -0600 Subject: [PATCH] gateway: the /gui/ board launches engines that have no web UI The board drew a Launch button for every engine with a start command, but the wake endpoint also required a gui URL, so Launch on Strata or a llama-server answered 404 and the button stayed on "Launching...". The endpoint now takes any engine with a start command; the per-engine wake page still needs a gui URL, since it exists to send the browser there. On the page, an engine with no web UI shows Running once it answers instead of an Open button that navigated to "undefined", and a refused start shows its reason. --- CHANGELOG.md | 3 +++ docs/gateway.md | 6 +++-- internal/gateway/gui.go | 25 ++++++++++++++++----- internal/gateway/gui/index.js | 16 ++++++++++++-- internal/gateway/gui_test.go | 41 +++++++++++++++++++++++++++++++++++ 5 files changed, 81 insertions(+), 10 deletions(-) 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)