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.

@@ -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)