Refresh the UI from Docker events instead of polling (0.49.0)
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:
@@ -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 />
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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] });
|
||||
|
||||
@@ -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 />;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user