diff --git a/README.md b/README.md index 35bdc83..9104992 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,24 @@ as intuitive as Dockge, as capable as Portainer for Compose workflows. > (Auto-update) + Phase 23 (Secrets & configs) + Phase 24 (Design System v2) > complete. +## Upgrading to 0.49.0 — nothing to do + +The UI now refreshes when Docker changes instead of asking every few seconds. +One WebSocket (`/ws/events`) carries container, image, network and volume +events; the client drops the matching query caches and re-renders. The polling +intervals stay behind it as a safety net for a dropped socket, at 30–60s rather +than 5s. + +Live CPU and memory keep their fast poll on purpose — usage drifts continuously +and Docker emits no event for it. That request is served from a 4-second +server-side cache, so it costs one sample per interval no matter how many tabs +are open. + +The endpoint existed before this release but nothing used it, and it was not +usable as it stood: it forwarded every event including three `exec_*` per web +terminal session, never said *what* had changed, and leaked its reader thread on +every disconnect. All three are fixed. + ## Upgrading to 0.48.0 — remote hosts are gone The multi-host feature (the `stackpilot-agent` sidecar and everything that @@ -134,6 +152,11 @@ it is what your saved destination credentials are encrypted with. holds across workers and across a restart) and a second one gets `409` while it is held; auto-update skips a stack somebody is already deploying. Locks carry an expiry, so a worker killed mid-deploy does not strand a stack. +- **Event-driven UI** — a single `/ws/events` connection carries Docker's own + container / image / network / volume events; the client drops the matching + caches so pages refresh the moment something changes, instead of every page + polling on a timer. The intervals remain as a slow fallback. Live CPU and + memory still poll, because usage drifts with no event to announce it. - **Real-time logs** — streamed over WebSocket, color-coded per service. - **Live deploy console** — deploying from the editor streams `compose up` output (image pulls, container creation) over a WebSocket in real time instead @@ -509,7 +532,7 @@ Same three commands the CI runs — `build-and-push` only starts once they pass. ```bash cd backend pip install -r requirements-dev.txt -pytest # 735 tests, no Docker daemon needed +pytest # 758 tests, no Docker daemon needed ruff check . cd ../frontend && npx tsc --noEmit -p tsconfig.json ``` @@ -533,6 +556,13 @@ HTTP. `tests/test_schema_migration.py` builds a database with the *old* user table and asserts the added column is backfilled rather than left NULL, which is what would otherwise have signed out every user on every install. +`tests/test_docker_events.py` drives the event stream against a fake daemon: that +`exec_*` noise is dropped before it reaches the client, that the payload names +the resource so the client knows what to invalidate, and that the stream is +closed on disconnect — cancelling the executor future does not interrupt a +thread already inside a blocking read, so without that close every page load +leaked one. + `tests/test_stack_locking.py` and `tests/test_runtime_state.py` cover the state that moved into the database: that a busy stack answers 409 without ever reaching Docker, that an expired lock is taken over rather than stranding the diff --git a/backend/routers/ws.py b/backend/routers/ws.py index 336b154..0f48708 100644 --- a/backend/routers/ws.py +++ b/backend/routers/ws.py @@ -268,12 +268,39 @@ async def ws_update( update_service.refresh_stack_local(stack_id) +#: Docker event types worth telling the UI about. Filtered daemon-side, so the +#: bulk of the firehose never crosses the socket. +_EVENT_TYPES = ["container", "image", "network", "volume"] + +#: Container actions that say nothing about state a page renders. exec_* alone +#: is three events per web-terminal keystroke session, and `top`/`attach` fire +#: whenever something inspects a container — invalidating queries on those would +#: make the stream noisier than the polling it replaces. +_IGNORED_ACTIONS = { + "exec_create", "exec_start", "exec_die", "exec_detach", + "attach", "top", "resize", "archive-path", "extract-to-dir", +} + + @router.websocket("/ws/events") async def ws_events( websocket: WebSocket, token: str | None = Query(default=None), ): - """Stream global Docker events (decoded subset).""" + """Stream Docker events so the UI can refresh on change instead of polling. + + Every page used to poll its own endpoint every few seconds. Almost all of + that state only changes when Docker does something, which is exactly what + this reports — so the client refreshes on an event and keeps a slow poll as + a safety net. + + Payload per event:: + + {"type": "event", "resource": "container", "action": "start", + "container": "jellyfin", "stack": "jellyfin"} + + ``resource`` is what the client needs to decide which queries to drop. + """ await websocket.accept() if not await _authorize(websocket, token): return @@ -281,28 +308,40 @@ async def ws_events( loop = asyncio.get_event_loop() queue: asyncio.Queue = asyncio.Queue() - stop = asyncio.Event() + stream = None def reader(): + """Blocking read of the event stream, handed to the loop thread-safely.""" + nonlocal stream try: - client = get_client() - for event in client.events(decode=True): - if stop.is_set(): - break + stream = get_client().events(decode=True, filters={"type": _EVENT_TYPES}) + for event in stream: loop.call_soon_threadsafe(queue.put_nowait, event) - except Exception: # noqa: BLE001 + except Exception: # noqa: BLE001 - a closed stream lands here on teardown pass + finally: + loop.call_soon_threadsafe(queue.put_nowait, None) task = loop.run_in_executor(None, reader) try: + await websocket.send_text(json.dumps({"type": "ready"})) while True: event = await queue.get() + if event is None: # reader finished — daemon gone or stream closed + await websocket.send_text( + json.dumps({"type": "error", "detail": "Docker event stream ended"}) + ) + break + action = (event.get("Action") or "").split(":")[0] + if action in _IGNORED_ACTIONS: + continue actor = event.get("Actor", {}) or {} attrs = actor.get("Attributes", {}) or {} await websocket.send_text( json.dumps( { "type": "event", + "resource": event.get("Type"), "action": event.get("Action"), "container": attrs.get("name"), "stack": attrs.get("com.docker.compose.project"), @@ -311,8 +350,17 @@ async def ws_events( ) except WebSocketDisconnect: pass + except Exception as exc: # noqa: BLE001 + with contextlib.suppress(Exception): + await websocket.send_text(json.dumps({"type": "error", "detail": str(exc)})) finally: - stop.set() + # Closing the stream is what actually unblocks the reader thread. + # Cancelling the executor future does not: a thread already inside a + # blocking read keeps that read, and the thread leaks for the life of + # the process — once per page load, with the socket held open. + if stream is not None: + with contextlib.suppress(Exception): + stream.close() task.cancel() diff --git a/backend/tests/test_docker_events.py b/backend/tests/test_docker_events.py new file mode 100644 index 0000000..d002283 --- /dev/null +++ b/backend/tests/test_docker_events.py @@ -0,0 +1,223 @@ +"""The Docker event stream behind the UI's refresh (F16). + +Every page used to poll its own endpoint every few seconds for state that only +changes when Docker does something — which the daemon already announces. The +endpoint existed but nothing consumed it, and it was not usable as it stood: + +* it forwarded the whole firehose, including three ``exec_*`` events per web + terminal session, so a client would have invalidated its cache more often than + the polling it replaces; +* it never told the client *what* changed, so there was nothing to decide which + queries to drop; +* and it leaked its reader thread — cancelling the executor future does not + interrupt a thread already inside a blocking read, so every page load left one + behind holding a socket open. + +These tests pin the shape of the payload, the filtering, and the teardown. +""" +from __future__ import annotations + +import pytest + + +@pytest.fixture +def ws_module(): + from routers import ws + + return ws + + +# --------------------------------------------------------------------------- # +# Filtering +# --------------------------------------------------------------------------- # + + +def test_only_ui_relevant_resource_types_are_requested(ws_module): + """Filtered daemon-side, so the bulk never crosses the socket.""" + assert set(ws_module._EVENT_TYPES) == {"container", "image", "network", "volume"} + + +@pytest.mark.parametrize( + "action", ["exec_create", "exec_start", "exec_die", "attach", "top", "resize"] +) +def test_noise_actions_are_ignored(ws_module, action): + assert action in ws_module._IGNORED_ACTIONS + + +@pytest.mark.parametrize( + "action", + ["create", "start", "stop", "die", "destroy", "restart", "health_status", "pull"], +) +def test_state_changing_actions_are_not_ignored(ws_module, action): + assert action not in ws_module._IGNORED_ACTIONS + + +def test_parameterised_actions_still_match_the_ignore_list(ws_module): + """Docker reports these as ``exec_create: /bin/sh``, not a bare verb. + + Matching the whole string would let every one of them through. + """ + raw = "exec_create: /bin/sh -c 'ls'" + assert raw.split(":")[0] in ws_module._IGNORED_ACTIONS + + +# --------------------------------------------------------------------------- # +# The stream, driven against a fake daemon +# --------------------------------------------------------------------------- # + + +class _FakeStream: + """Stands in for docker-py's CancellableStream. + + ``close()`` is what actually unblocks the reader thread; this records that + it was called, which is the whole point of the teardown test. + """ + + def __init__(self, events): + self._events = list(events) + self.closed = False + + def __iter__(self): + yield from self._events + + def close(self): + self.closed = True + + +@pytest.fixture +def fake_docker(monkeypatch): + """Point the endpoint at a canned event stream instead of a daemon.""" + import docker_client + + holder = {} + + def make(events): + stream = _FakeStream(events) + holder["stream"] = stream + + class Client: + def events(self, decode=True, filters=None): + holder["filters"] = filters + return stream + + monkeypatch.setattr(docker_client, "get_client", lambda: Client()) + return holder + + return make + + +def _event(resource, action, name=None, project=None): + return { + "Type": resource, + "Action": action, + "Actor": {"Attributes": {"name": name, "com.docker.compose.project": project}}, + } + + +def _collect(client, token, expected): + """Read frames off the socket until ``expected`` events have arrived.""" + import json + + frames = [] + with client.websocket_connect(f"/ws/events?token={token}") as socket: + ready = json.loads(socket.receive_text()) + assert ready == {"type": "ready"}, "clients rely on this to know it is live" + for _ in range(expected): + frames.append(json.loads(socket.receive_text())) + return frames + + +def test_an_event_carries_what_the_client_needs(client, admin_token, fake_docker): + fake_docker([_event("container", "start", "jellyfin", "jellyfin")]) + + (frame,) = _collect(client, admin_token, 1) + assert frame == { + "type": "event", + "resource": "container", + "action": "start", + "container": "jellyfin", + "stack": "jellyfin", + } + + +def test_noise_is_dropped_before_it_reaches_the_client(client, admin_token, fake_docker): + fake_docker( + [ + _event("container", "exec_create: /bin/sh", "jellyfin"), + _event("container", "exec_start: /bin/sh", "jellyfin"), + _event("container", "exec_die", "jellyfin"), + _event("container", "die", "jellyfin", "jellyfin"), + ] + ) + + (frame,) = _collect(client, admin_token, 1) + assert frame["action"] == "die", "only the state change should survive" + + +def test_the_daemon_is_asked_to_filter(client, admin_token, fake_docker): + holder = fake_docker([_event("container", "start", "x")]) + _collect(client, admin_token, 1) + assert holder["filters"] == {"type": ["container", "image", "network", "volume"]} + + +def test_the_stream_is_closed_on_disconnect(client, admin_token, fake_docker): + """Otherwise the reader thread stays blocked for the life of the process. + + Cancelling the executor future does not interrupt a thread already inside a + blocking read — closing the underlying stream is what does. + """ + holder = fake_docker([_event("container", "start", "x")]) + _collect(client, admin_token, 1) + assert holder["stream"].closed, "a disconnected client must not leak its reader" + + +def test_an_ending_stream_is_reported_not_silently_dropped( + client, admin_token, fake_docker +): + """A daemon restart ends the iterator; the client should hear about it so it + reconnects rather than sitting on a dead socket believing it is live.""" + import json + + fake_docker([]) # iterator finishes immediately + with client.websocket_connect(f"/ws/events?token={admin_token}") as socket: + assert json.loads(socket.receive_text())["type"] == "ready" + frame = json.loads(socket.receive_text()) + assert frame["type"] == "error" + + +def test_events_require_a_token(client): + from starlette.websockets import WebSocketDisconnect + + with pytest.raises(WebSocketDisconnect) as excinfo: + with client.websocket_connect("/ws/events") as socket: + socket.receive_text() + assert excinfo.value.code == 4401 + + +def test_a_revoked_session_is_refused(client, user_token, db): + """The socket resolves against the live user, like every other endpoint.""" + from sqlmodel import Session, select + from starlette.websockets import WebSocketDisconnect + + import auth as auth_mod + from database import engine + from models.user import User + + with Session(engine) as session: + user = session.exec(select(User).where(User.username == "test-user")).one() + auth_mod.bump_token_version(user) + session.add(user) + session.commit() + + try: + with pytest.raises(WebSocketDisconnect) as excinfo: + with client.websocket_connect(f"/ws/events?token={user_token}") as socket: + socket.receive_text() + assert excinfo.value.code == 4401 + finally: + # Leave the shared fixture user usable for the rest of the session. + with Session(engine) as session: + user = session.exec(select(User).where(User.username == "test-user")).one() + user.token_version = 1 + session.add(user) + session.commit() diff --git a/backend/version.py b/backend/version.py index b759fc7..4fa02f8 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,3 +1,3 @@ """Single source of truth for the StackPilot release version.""" -APP_VERSION = "0.48.0" +APP_VERSION = "0.49.0" diff --git a/frontend/package.json b/frontend/package.json index fe53bed..a5fbecc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "stackpilot-frontend", "private": true, - "version": "0.48.0", + "version": "0.49.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/components/layout/AppShell.tsx b/frontend/src/components/layout/AppShell.tsx index 2ccd2c8..ca67531 100644 --- a/frontend/src/components/layout/AppShell.tsx +++ b/frontend/src/components/layout/AppShell.tsx @@ -1,5 +1,6 @@ import { Outlet, useLocation } from "react-router-dom"; import { TopNav } from "./TopNav"; +import { useDockerEvents } from "@/hooks/useDockerEvents"; /** Top-level routes get a display-weight title here; the Dashboard ("/") * and detail pages render their own headers. */ @@ -19,6 +20,10 @@ export function AppShell() { const { pathname } = useLocation(); const title = TITLES[pathname]; + // One connection for the whole signed-in session: Docker events drop the + // query cache so pages refresh on change rather than on a timer. + useDockerEvents(); + return (
diff --git a/frontend/src/hooks/useDockerEvents.ts b/frontend/src/hooks/useDockerEvents.ts new file mode 100644 index 0000000..782d7b9 --- /dev/null +++ b/frontend/src/hooks/useDockerEvents.ts @@ -0,0 +1,114 @@ +import { useEffect, useRef } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { useAuthStore } from "@/store/auth"; + +/** + * Refresh the UI when Docker changes, instead of asking it every few seconds. + * + * Almost everything the app renders — stack status, container state, images, + * networks, volumes — only changes when the daemon does something, and the + * daemon already announces that. So one WebSocket replaces the per-page polling + * as the *primary* refresh mechanism; the intervals stay behind it as a slow + * safety net for the cases an event cannot cover (a container quietly eating + * memory, a registry gaining a newer image, a socket that dropped unnoticed). + * + * The result is both cheaper and faster: no fixed floor of requests per second + * against the daemon, and a stack that finishes starting shows up immediately + * rather than up to five seconds later. + */ + +/** Query keys to drop, per Docker event resource type. */ +const INVALIDATES: Record = { + container: [["stacks"], ["stack"], ["stack-stats"], ["dashboard-fleet"], ["container"]], + image: [["images"], ["stack-updates"], ["dashboard-fleet"]], + network: [["networks"], ["network-containers"]], + volume: [["volumes"], ["volume-sizes"]], +}; + +/** + * How long to gather events before refreshing. + * + * A single `compose up` emits a burst — create, start, network connect, health + * check — per service. Without this, a ten-service stack would fire dozens of + * refetches in a second, which is precisely the load this is meant to remove. + */ +const COALESCE_MS = 300; + +/** Backoff between reconnect attempts, capped so a long outage still recovers. */ +const RECONNECT_MIN_MS = 1000; +const RECONNECT_MAX_MS = 30000; + +export function useDockerEvents() { + const token = useAuthStore((s) => s.accessToken); + const qc = useQueryClient(); + // Kept in a ref so the reconnect loop is not torn down by re-renders. + const pending = useRef(new Set()); + const timer = useRef(null); + + useEffect(() => { + if (!token) return; + + let socket: WebSocket | null = null; + let retry: number | null = null; + let delay = RECONNECT_MIN_MS; + let closed = false; + + const flush = () => { + timer.current = null; + const resources = [...pending.current]; + pending.current.clear(); + const keys = new Map(); + for (const resource of resources) { + for (const key of INVALIDATES[resource] ?? []) keys.set(key.join("/"), key); + } + for (const key of keys.values()) { + // Prefix match: ["stack"] also drops ["stack", ""]. + qc.invalidateQueries({ queryKey: key }); + } + }; + + const connect = () => { + const proto = window.location.protocol === "https:" ? "wss" : "ws"; + socket = new WebSocket( + `${proto}://${window.location.host}/ws/events?token=${encodeURIComponent(token)}` + ); + + socket.onopen = () => { + delay = RECONNECT_MIN_MS; + }; + + socket.onmessage = (ev) => { + try { + const msg = JSON.parse(ev.data); + if (msg.type !== "event" || !msg.resource) return; + pending.current.add(msg.resource); + if (timer.current === null) { + timer.current = window.setTimeout(flush, COALESCE_MS); + } + } catch { + /* ignore malformed frames */ + } + }; + + // Any close reconnects with backoff — including 4401, since the access + // token is short-lived and the API client refreshes it out from under us. + socket.onclose = () => { + if (closed) return; + retry = window.setTimeout(connect, delay); + delay = Math.min(delay * 2, RECONNECT_MAX_MS); + }; + }; + + connect(); + + return () => { + closed = true; + if (retry !== null) window.clearTimeout(retry); + if (timer.current !== null) { + window.clearTimeout(timer.current); + timer.current = null; + } + socket?.close(); + }; + }, [token, qc]); +} diff --git a/frontend/src/pages/Audit.tsx b/frontend/src/pages/Audit.tsx index cb0cf90..186147a 100644 --- a/frontend/src/pages/Audit.tsx +++ b/frontend/src/pages/Audit.tsx @@ -18,7 +18,7 @@ export function Audit() { api .get(`/api/audit?limit=${PAGE}&offset=${offset}`) .then((r) => r.data), - refetchInterval: 15000, + refetchInterval: 30000, }); const rows = (data ?? []).filter((a) => { diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index d7e7d7e..1631efa 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -25,17 +25,20 @@ export function Dashboard() { const fleet = useQuery({ queryKey: ["dashboard-fleet"], queryFn: () => dashboardApi.fleet(), - refetchInterval: 20000, + // Rolls up resource pressure as well as state, so it keeps a timer; + // container events invalidate it on top of that. + refetchInterval: 30000, }); - const stacks = useQuery({ queryKey: ["stacks"], queryFn: stacksApi.list, refetchInterval: 5000 }); + const stacks = useQuery({ queryKey: ["stacks"], queryFn: stacksApi.list, refetchInterval: 60000 }); const stats = useQuery({ queryKey: ["stack-stats"], queryFn: stacksApi.stats, refetchInterval: 5000 }); const info = useQuery({ queryKey: ["system"], queryFn: systemApi.info, refetchInterval: 30000 }); // The audit log is admin-only on the API; don't poll it into a 403 for others. const audit = useQuery({ queryKey: ["audit"], queryFn: () => systemApi.audit(10), - refetchInterval: 10000, + // Audit entries come from people, not from Docker — no event covers them. + refetchInterval: 30000, enabled: isAdmin, }); diff --git a/frontend/src/pages/Networks.tsx b/frontend/src/pages/Networks.tsx index ca362b9..1882f91 100644 --- a/frontend/src/pages/Networks.tsx +++ b/frontend/src/pages/Networks.tsx @@ -30,7 +30,7 @@ function NetworksSection({ isAdmin }: { isAdmin: boolean }) { const { data, isLoading } = useQuery({ queryKey: ["networks"], queryFn: () => networksApi.list(), - refetchInterval: 10000, + refetchInterval: 60000, }); const [creating, setCreating] = useState(false); const [toDelete, setToDelete] = useState(null); @@ -190,7 +190,7 @@ function NetworkDetail({ const { data, isLoading } = useQuery({ queryKey: ["network-containers", network.id], queryFn: () => networksApi.containers(network.id), - refetchInterval: 10000, + refetchInterval: 60000, }); const refresh = () => { qc.invalidateQueries({ queryKey: ["network-containers", network.id] }); diff --git a/frontend/src/pages/StackDetail.tsx b/frontend/src/pages/StackDetail.tsx index d1f849d..985a16e 100644 --- a/frontend/src/pages/StackDetail.tsx +++ b/frontend/src/pages/StackDetail.tsx @@ -41,7 +41,8 @@ export function StackDetail() { const { data, isLoading } = useQuery({ queryKey: ["stack", id], queryFn: () => stacksApi.get(id), - refetchInterval: 5000, + // Container state arrives over /ws/events; this is the fallback. + refetchInterval: 30000, }); if (isLoading || !data) return ; diff --git a/frontend/src/pages/Stacks.tsx b/frontend/src/pages/Stacks.tsx index 777b819..626cb19 100644 --- a/frontend/src/pages/Stacks.tsx +++ b/frontend/src/pages/Stacks.tsx @@ -34,12 +34,17 @@ export function Stacks() { const { data, isLoading } = useQuery({ queryKey: ["stacks"], queryFn: stacksApi.list, - refetchInterval: 5000, + // Status changes arrive over /ws/events; this is the safety net for a + // dropped socket, not the mechanism. + refetchInterval: 60000, }); const stats = useQuery({ queryKey: ["stack-stats"], queryFn: stacksApi.stats, + // Deliberately still fast: CPU and memory drift continuously and Docker + // emits no event for it. The server caches each sweep for 4s, so this + // costs one sample per interval no matter how many tabs are open. refetchInterval: 5000, }); const updates = useQuery({ @@ -50,7 +55,8 @@ export function Stacks() { const info = useQuery({ queryKey: ["system"], queryFn: systemApi.info, - refetchInterval: 5000, + // Host CPU/RAM/disk — drift, not events. + refetchInterval: 30000, }); diff --git a/frontend/src/pages/Volumes.tsx b/frontend/src/pages/Volumes.tsx index 45e82c1..b97cf72 100644 --- a/frontend/src/pages/Volumes.tsx +++ b/frontend/src/pages/Volumes.tsx @@ -24,7 +24,7 @@ function VolumesSection({ isAdmin }: { isAdmin: boolean }) { const { data, isLoading } = useQuery({ queryKey: ["volumes"], queryFn: () => volumesApi.list(), - refetchInterval: 10000, + refetchInterval: 60000, }); // Sizes are expensive (docker system df walks volume contents), so they are // loaded on demand via the "Compute sizes" button rather than polled.