From 0796092b802bfdd9143ae844794ad4111238b271 Mon Sep 17 00:00:00 2001 From: mram Date: Tue, 22 Sep 2026 18:48:28 +0200 Subject: [PATCH] Add optional web UI mirroring --monitor (LISTEN_UI, off by default) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A single self-contained page polls /api/status once a second and renders the same lines as the terminal monitor (downstreams with models/VRAM and busy markers, lock, queue, GPU stats); the u/r buttons and keys trigger update-now and reload-env through the same rate-limited command handler the control pipe uses. Unauthenticated by design — the sample config tells users to keep it on localhost. --- README.md | 1 + SPEC.md | 1 + cmd/gpu-turnstile/main.go | 49 +++++--- cmd/gpu-turnstile/webui.go | 198 ++++++++++++++++++++++++++++++++ cmd/gpu-turnstile/webui_test.go | 114 ++++++++++++++++++ internal/config/config.go | 2 + internal/config/sample.go | 1 + 7 files changed, 351 insertions(+), 15 deletions(-) create mode 100644 cmd/gpu-turnstile/webui.go create mode 100644 cmd/gpu-turnstile/webui_test.go diff --git a/README.md b/README.md index 708160f..ce4779f 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,7 @@ override file values. Invalid values fail at startup. |---|---|---| | `LISTEN_OLLAMA` | `:11434` | Listener for Ollama-compatible clients | | `LISTEN_COMFY` | `:8188` | Listener for ComfyUI clients | +| `LISTEN_UI` | _(empty = disabled)_ | Web UI listener mirroring `--monitor` (live status, update/reload buttons). No auth — keep it on localhost | | `OLLAMA_URL` | _(empty = disabled)_ | Ollama upstream; set to enable the Ollama consumer | | `COMFY_URL` | _(empty = disabled)_ | ComfyUI upstream; set to enable the ComfyUI consumer | | `UNLOAD_TIMEOUT` | `60s` | Wait for Ollama to unload before an image job | diff --git a/SPEC.md b/SPEC.md index 7ac7544..7781eeb 100644 --- a/SPEC.md +++ b/SPEC.md @@ -221,6 +221,7 @@ override file values. A missing file is fine; a malformed one is fatal. |---|---|---| | `LISTEN_OLLAMA` | `:11434` | listener for Ollama-compatible clients | | `LISTEN_COMFY` | `:8188` | listener for ComfyUI clients | +| `LISTEN_UI` | _(empty = disabled)_ | web UI listener mirroring `--monitor` (live status, update/reload buttons); unauthenticated — keep it on localhost | | `OLLAMA_URL` | _(empty = disabled)_ | Ollama upstream; set to enable the Ollama consumer | | `COMFY_URL` | _(empty = disabled)_ | ComfyUI upstream; set to enable the ComfyUI consumer | | `UNLOAD_TIMEOUT` | `60s` | wait for Ollama to unload | diff --git a/cmd/gpu-turnstile/main.go b/cmd/gpu-turnstile/main.go index 1c73dbf..073b0c3 100644 --- a/cmd/gpu-turnstile/main.go +++ b/cmd/gpu-turnstile/main.go @@ -567,6 +567,7 @@ func run(ctx context.Context, cfg config.Config, log *slog.Logger, logOut io.Wri "version", version, "listen_ollama", cfg.ListenOllama, "listen_comfy", cfg.ListenComfy, + "listen_ui", orDisabled(cfg.ListenUI), "ollama_url", orDisabled(cfg.OllamaURL), "comfy_url", orDisabled(cfg.ComfyURL), "unload_timeout", cfg.UnloadTimeout, @@ -808,13 +809,24 @@ func run(ctx context.Context, cfg config.Config, log *slog.Logger, logOut io.Wri } } - // The control channel (status for --monitor, update-now and reload-env - // triggers) is served whenever running as a service, independent of - // AUTO_UPDATE. + // The command handler backs both the local control channel (served + // whenever running as a service) and the optional web UI (LISTEN_UI), + // independent of AUTO_UPDATE. + handler := controlHandler(ctx, u, exePath, applyStaged, + statusProvider(cfg, lk, comfySup, health, started, gw, ollamaClient), + reloadHandler(cfg, configPath, restartWhenIdle)) if isService { - serveControl(ctx, log, u, exePath, applyStaged, - statusProvider(cfg, lk, comfySup, health, started, gw, ollamaClient), - reloadHandler(cfg, configPath, restartWhenIdle)) + serveControl(ctx, log, handler) + } + if cfg.ListenUI != "" { + ln, err := net.Listen("tcp", cfg.ListenUI) + if err != nil { + return fmt.Errorf("listen ui on %s: %w", cfg.ListenUI, err) + } + uiSrv := &http.Server{Addr: cfg.ListenUI, Handler: uiHandler(handler)} + servers = append(servers, uiSrv) + log.Warn("listening", "consumer", "ui", "addr", cfg.ListenUI) + go func() { errCh <- uiSrv.Serve(ln) }() } select { @@ -1024,17 +1036,18 @@ func updateLoop(ctx context.Context, interval time.Duration, log *slog.Logger, u } } -// serveControl opens the local control channel (named pipe on Windows, -// unix socket on Linux) so unprivileged local users can query status -// (--monitor), trigger an update check (--force-update/--update-now) and -// poke a config reload (--reload-env) without admin rights. The update -// payload is signature-verified regardless of who asks; update triggers -// are rate-limited to one per minute so the channel cannot be used to spam -// restarts. u is nil when AUTO_UPDATE=false. -func serveControl(ctx context.Context, log *slog.Logger, u *update.Updater, exePath string, applyStaged func(to string), status func() string, reload func() string) { +// controlHandler builds the command handler shared by the control channel +// (named pipe / unix socket) and the web UI's action endpoints: status for +// --monitor / the UI, an update check (--force-update/--update-now) and a +// config reload (--reload-env), all safe for unprivileged local users. The +// update payload is signature-verified regardless of who asks; update +// triggers are rate-limited to one per minute and reloads to one per two +// seconds so neither can be used to spam restarts or disk churn. u is nil +// when AUTO_UPDATE=false. +func controlHandler(ctx context.Context, u *update.Updater, exePath string, applyStaged func(to string), status func() string, reload func() string) control.Handler { var mu sync.Mutex var lastTrigger, lastReload time.Time - h := func(cmd string) string { + return func(cmd string) string { switch cmd { case control.CmdStatus: return "OK " + status() @@ -1075,6 +1088,12 @@ func serveControl(ctx context.Context, log *slog.Logger, u *update.Updater, exeP applyStaged(to) return "OK updated from " + version + " to " + to + "; the service restarts once the GPU is idle" } +} + +// serveControl opens the local control channel (named pipe on Windows, +// unix socket on Linux) for unprivileged local users (--monitor, +// --force-update/--update-now, --reload-env). +func serveControl(ctx context.Context, log *slog.Logger, h control.Handler) { if err := control.Serve(ctx, h, log); err != nil { log.Warn("control channel disabled", "err", err) } diff --git a/cmd/gpu-turnstile/webui.go b/cmd/gpu-turnstile/webui.go new file mode 100644 index 0000000..950decd --- /dev/null +++ b/cmd/gpu-turnstile/webui.go @@ -0,0 +1,198 @@ +package main + +import ( + "fmt" + "net/http" + "strings" + + "gpu-turnstile/internal/control" +) + +// uiHandler serves the web UI (LISTEN_UI): a single page mirroring +// --monitor, its status JSON (the same snapshot the control channel +// serves) and action endpoints for the update/reload buttons. There is no +// auth — like the control pipe, it is meant for localhost only. +func uiHandler(h control.Handler) http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprint(w, uiPage) + }) + mux.HandleFunc("GET /api/status", func(w http.ResponseWriter, r *http.Request) { + msg := strings.TrimPrefix(h(control.CmdStatus), "OK ") + w.Header().Set("Content-Type", "application/json") + if strings.HasPrefix(msg, "ERR ") { + w.WriteHeader(http.StatusInternalServerError) + msg = strings.TrimPrefix(msg, "ERR ") + } + fmt.Fprint(w, msg) + }) + mux.HandleFunc("POST /api/action/{cmd}", func(w http.ResponseWriter, r *http.Request) { + cmd := r.PathValue("cmd") + if cmd != control.CmdUpdateNow && cmd != control.CmdReloadEnv { + http.NotFound(w, r) + return + } + msg := strings.TrimPrefix(h(cmd), "OK ") + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + if strings.HasPrefix(msg, "ERR ") { + w.WriteHeader(http.StatusConflict) + msg = strings.TrimPrefix(msg, "ERR ") + } + fmt.Fprint(w, msg) + }) + return mux +} + +// uiPage is the whole web UI: a terminal-styled page that polls /api/status +// once a second and renders the same lines as --monitor. The buttons (and +// the u/r keys) hit the action endpoints, like the monitor's hotkeys. +const uiPage = ` + + + + +gpu-turnstile + + + +
gpu-turnstile
+
+
+
+
+ + + + +` diff --git a/cmd/gpu-turnstile/webui_test.go b/cmd/gpu-turnstile/webui_test.go new file mode 100644 index 0000000..345ca0d --- /dev/null +++ b/cmd/gpu-turnstile/webui_test.go @@ -0,0 +1,114 @@ +package main + +import ( + "net/http/httptest" + "strings" + "testing" + + "gpu-turnstile/internal/control" +) + +func testUIHandler() *httptest.Server { + h := uiHandler(func(cmd string) string { + switch cmd { + case control.CmdStatus: + return `OK {"version":"v9.9.9","uptime_s":5}` + case control.CmdUpdateNow: + return "OK v9.9.9 is up to date" + default: + return "ERR unknown command: " + cmd + } + }) + return httptest.NewServer(h) +} + +func TestUIPage(t *testing.T) { + srv := testUIHandler() + defer srv.Close() + resp, err := srv.Client().Get(srv.URL + "/") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + body := make([]byte, 1024*64) + n, _ := resp.Body.Read(body) + if resp.StatusCode != 200 { + t.Fatalf("status %d", resp.StatusCode) + } + if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "text/html") { + t.Errorf("Content-Type %q", ct) + } + if !strings.Contains(string(body[:n]), "gpu-turnstile") { + t.Error("page does not mention gpu-turnstile") + } +} + +func TestUIStatus(t *testing.T) { + srv := testUIHandler() + defer srv.Close() + resp, err := srv.Client().Get(srv.URL + "/api/status") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + body := make([]byte, 4096) + n, _ := resp.Body.Read(body) + if resp.StatusCode != 200 { + t.Fatalf("status %d", resp.StatusCode) + } + if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "application/json") { + t.Errorf("Content-Type %q", ct) + } + if got := strings.TrimSpace(string(body[:n])); got != `{"version":"v9.9.9","uptime_s":5}` { + t.Errorf("body %q", got) + } +} + +func TestUIActions(t *testing.T) { + srv := testUIHandler() + defer srv.Close() + + resp, err := srv.Client().Post(srv.URL+"/api/action/update-now", "", nil) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + body := make([]byte, 4096) + n, _ := resp.Body.Read(body) + if resp.StatusCode != 200 || string(body[:n]) != "v9.9.9 is up to date" { + t.Errorf("update-now: %d %q", resp.StatusCode, body[:n]) + } + + // The handler answers ERR for reload-env: mapped to 409 with the + // message stripped. + resp2, err := srv.Client().Post(srv.URL+"/api/action/reload-env", "", nil) + if err != nil { + t.Fatal(err) + } + defer resp2.Body.Close() + n, _ = resp2.Body.Read(body) + if resp2.StatusCode != 409 || !strings.HasPrefix(string(body[:n]), "unknown command") { + t.Errorf("reload-env: %d %q", resp2.StatusCode, body[:n]) + } + + // Unknown actions are not routed to the handler. + resp3, err := srv.Client().Post(srv.URL+"/api/action/reboot", "", nil) + if err != nil { + t.Fatal(err) + } + resp3.Body.Close() + if resp3.StatusCode != 404 { + t.Errorf("bogus action: %d, want 404", resp3.StatusCode) + } + + // GET on an action path falls through to the root handler's guard, + // which only serves "/" itself. + resp4, err := srv.Client().Get(srv.URL + "/api/action/update-now") + if err != nil { + t.Fatal(err) + } + resp4.Body.Close() + if resp4.StatusCode != 404 { + t.Errorf("GET action: %d, want 404", resp4.StatusCode) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index e773504..a040d99 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -19,6 +19,7 @@ import ( type Config struct { ListenOllama string `env:"LISTEN_OLLAMA"` ListenComfy string `env:"LISTEN_COMFY"` + ListenUI string `env:"LISTEN_UI"` OllamaURL string `env:"OLLAMA_URL"` ComfyURL string `env:"COMFY_URL"` UnloadTimeout time.Duration `env:"UNLOAD_TIMEOUT"` @@ -196,6 +197,7 @@ func Load(getenv func(string) string) (Config, error) { }{ {"LISTEN_OLLAMA", &cfg.ListenOllama}, {"LISTEN_COMFY", &cfg.ListenComfy}, + {"LISTEN_UI", &cfg.ListenUI}, {"OLLAMA_URL", &cfg.OllamaURL}, {"COMFY_URL", &cfg.ComfyURL}, {"WARM_MODEL", &cfg.WarmModel}, diff --git a/internal/config/sample.go b/internal/config/sample.go index 0ed205a..6ce569a 100644 --- a/internal/config/sample.go +++ b/internal/config/sample.go @@ -25,6 +25,7 @@ func sampleEntries(logFile string) []sampleEntry { return []sampleEntry{ {"LISTEN_OLLAMA", ":11434", "Listen address for Ollama-compatible clients (gpu-turnstile poses as Ollama here)", false}, {"LISTEN_COMFY", ":8188", "Listen address for ComfyUI clients (gpu-turnstile poses as ComfyUI here)", false}, + {"LISTEN_UI", "127.0.0.1:7860", "Web UI listen address: live status like --monitor, with update/reload buttons (default: empty = disabled; keep it on localhost — there is no auth)", false}, {"OLLAMA_URL", "http://127.0.0.1:11434", "Ollama upstream URL; setting it enables the Ollama consumer (default: empty = disabled)", false}, {"COMFY_URL", "http://127.0.0.1:8188", "ComfyUI upstream URL; setting it enables the ComfyUI consumer (default: empty = disabled)", false}, {"WARM_MODEL", "", "Optional model to reload after an image job (default: empty = none)", false},