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 = ` + +
+ + +