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
+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.