Refresh the UI from Docker events instead of polling (0.49.0)
CI / check (push) Successful in 7m7s
CI / build-and-push (push) Successful in 1m39s

F16 — /ws/events was implemented and nothing consumed it, while thirty polling
intervals across the pages asked for state that only changes when Docker does
something. The endpoint was the answer; it just was not usable as it stood, so
this is three fixes and a client, not a wiring job.

The endpoint forwarded the whole firehose. Three exec_* events fire per web
terminal session and top/attach fire whenever anything inspects a container, so
a client invalidating on each would have been noisier than the polling it
replaces. Now the daemon filters by resource type and the handler drops the
actions that say nothing about rendered state — matching on the verb before the
colon, since Docker reports these as "exec_create: /bin/sh".

It never said *what* changed, so there was nothing to decide which caches to
drop. The payload now carries the resource type.

And it leaked its reader thread. Cancelling the executor future does not
interrupt a thread already inside a blocking read; closing the underlying
CancellableStream is what does. Every page load left one behind holding a socket
open. A test asserts the close, because this is invisible until the process has
been up for a week.

Client side, useDockerEvents holds one connection for the session and maps
resource types to query keys. Bursts are coalesced over 300ms — a ten-service
compose up emits dozens of events in a second, and refetching per event would
reintroduce exactly the load being removed. Reconnects back off to 30s, and any
close reconnects including 4401, since the access token is short-lived and gets
refreshed out from under the socket.

Intervals drop from the mechanism to the safety net: 5s becomes 30-60s. Two
deliberately stay fast. Live CPU/memory drifts continuously with no event to
announce it, and that one is served from the 4s server-side cache added in
0.47.0, so it costs one sample per interval regardless of how many tabs are
open. The audit feed polls because its entries come from people, not Docker.

Net effect 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.

23 new tests (758 total), driven against a fake daemon. Both nets were checked
by reverting the fix: dropping the filter fails one, dropping the stream close
fails the leak test.

Not covered: the hook itself has no test — there is no frontend test runner yet.
Its contract with the backend is tested; its own behaviour is only typechecked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dk43rmEeRfYi5wsLDbmfyG
This commit is contained in:
menzelj
2026-08-31 15:25:10 +02:00
co-authored by Claude Opus 5
parent 51d1998307
commit fb2eefb0e1
13 changed files with 451 additions and 21 deletions
+31 -1
View File
@@ -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 3060s 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
+56 -8
View File
@@ -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()
+223
View File
@@ -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()
+1 -1
View File
@@ -1,3 +1,3 @@
"""Single source of truth for the StackPilot release version."""
APP_VERSION = "0.48.0"
APP_VERSION = "0.49.0"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "stackpilot-frontend",
"private": true,
"version": "0.48.0",
"version": "0.49.0",
"type": "module",
"scripts": {
"dev": "vite",
@@ -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 (
<div className="min-h-screen bg-sp-bg text-slate-900 dark:text-slate-100">
<TopNav />
+114
View File
@@ -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<string, string[][]> = {
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<string>());
const timer = useRef<number | null>(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<string, string[]>();
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", "<id>"].
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]);
}
+1 -1
View File
@@ -18,7 +18,7 @@ export function Audit() {
api
.get<AuditEntry[]>(`/api/audit?limit=${PAGE}&offset=${offset}`)
.then((r) => r.data),
refetchInterval: 15000,
refetchInterval: 30000,
});
const rows = (data ?? []).filter((a) => {
+6 -3
View File
@@ -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,
});
+2 -2
View File
@@ -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<NetworkInfo | null>(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] });
+2 -1
View File
@@ -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 <Spinner />;
+8 -2
View File
@@ -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,
});
+1 -1
View File
@@ -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.