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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions docs/gateway.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<p align="center"><img src="https://raw.githubusercontent.com/T-Crypt/berth/main/assets/preview-wake.png" alt="The gateway's wake page for comfyui: comfyui is offline and the card is free, with a Launch comfyui button" width="560"></p>

Expand All @@ -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.
Expand Down
25 changes: 19 additions & 6 deletions internal/gateway/gui.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down
16 changes: 14 additions & 2 deletions internal/gateway/gui/index.js
Original file line number Diff line number Diff line change
@@ -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");
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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.
}
Expand Down
41 changes: 41 additions & 0 deletions internal/gateway/gui_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading