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 (