Files
stackpilot/frontend/src/pages/StackDetail.tsx
T
menzeljandClaude Opus 5 fb2eefb0e1
CI / check (push) Successful in 7m7s
CI / build-and-push (push) Successful in 1m39s
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
2026-08-31 15:25:10 +02:00

286 lines
9.3 KiB
TypeScript

import { useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
Play,
Square,
RotateCw,
DownloadCloud,
ArrowUpCircle,
Pencil,
Power,
Trash2,
LayoutTemplate,
} from "lucide-react";
import { toast } from "sonner";
import { Badge, Button, Card, Input, Spinner, StatusDot } from "@/components/ui";
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
import { LogViewer } from "@/components/stacks/LogViewer";
import { ContainerCard } from "@/components/stacks/ContainerCard";
import { ActionStatusList } from "@/components/stacks/ActionStatusBanner";
import { AutoUpdatePanel } from "@/components/stacks/AutoUpdatePanel";
import { SecretsPanel } from "@/components/stacks/SecretsPanel";
import { BackupButton } from "@/components/stacks/BackupRestore";
import { stacksApi } from "@/api/stacks";
import { templatesApi } from "@/api/templates";
import { apiErrorMessage } from "@/api/client";
import { useAuthStore } from "@/store/auth";
import { useStackActions } from "@/hooks/useStackActions";
import type { ContainerInfo } from "@/types";
const TABS = ["Overview", "Logs", "Environment", "Compose", "Secrets"] as const;
type Tab = (typeof TABS)[number];
export function StackDetail() {
const { id = "" } = useParams();
const isAdmin = useAuthStore((s) => s.user?.role === "admin");
const [tab, setTab] = useState<Tab>("Overview");
const actions = useStackActions();
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ["stack", id],
queryFn: () => stacksApi.get(id),
// Container state arrives over /ws/events; this is the fallback.
refetchInterval: 30000,
});
if (isLoading || !data) return <Spinner />;
const busy = actions.isBusy(id);
return (
<div className="flex h-full flex-col space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<StatusDot status={data.status} />
<h1 className="sp-heading text-xl">{data.name}</h1>
<Badge status={data.status}>{data.status}</Badge>
</div>
{data.description && (
<p className="mt-1 text-sm text-slate-500">{data.description}</p>
)}
</div>
{isAdmin && (
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => actions.start(id)} loading={busy}>
<Play className="h-4 w-4 text-green-500" /> Start
</Button>
<Button variant="outline" onClick={() => actions.stop(id)} loading={busy}>
<Square className="h-4 w-4 text-red-500" /> Stop
</Button>
<Button variant="outline" onClick={() => actions.restart(id)} loading={busy}>
<RotateCw className="h-4 w-4 text-sky-500" /> Restart
</Button>
<Button variant="outline" onClick={() => actions.pull(id)} loading={busy}>
<DownloadCloud className="h-4 w-4" /> Pull
</Button>
<Button variant="outline" onClick={() => actions.updateImages(id)} loading={busy}>
<ArrowUpCircle className="h-4 w-4" /> Update
</Button>
<Button variant="outline" onClick={() => actions.down(id)} loading={busy}>
<Power className="h-4 w-4" /> Down
</Button>
<BackupButton stackId={id} />
<SaveAsTemplateButton stackId={id} defaultName={data.name} />
<Link to={`/stacks/${id}/edit`}>
<Button>
<Pencil className="h-4 w-4" /> Edit
</Button>
</Link>
<DeleteStackButton stackId={id} />
</div>
)}
</div>
<ActionStatusList statuses={actions.statuses} onDismiss={actions.dismissStatus} />
{/* Tabs */}
<div className="flex gap-1 border-b border-slate-200 dark:border-slate-700">
{TABS.map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={
tab === t
? "border-b-2 border-accent px-4 py-2 text-sm font-medium text-accent dark:border-accent-dark dark:text-accent-dark"
: "px-4 py-2 text-sm text-slate-500 hover:text-slate-700 dark:hover:text-slate-300"
}
>
{t}
</button>
))}
</div>
<div className="flex-1 overflow-hidden">
{tab === "Overview" && (
<Overview
data={data}
isAdmin={isAdmin}
onChanged={() => queryClient.invalidateQueries({ queryKey: ["stack", id] })}
/>
)}
{tab === "Logs" && <LogViewer stackId={id} />}
{tab === "Environment" && <EnvView env={data.env} />}
{tab === "Compose" && <ComposeView yaml={data.yaml} />}
{tab === "Secrets" && (
<SecretsPanel
stackId={id}
yaml={data.yaml}
isAdmin={isAdmin}
onChanged={() => queryClient.invalidateQueries({ queryKey: ["stack", id] })}
/>
)}
</div>
</div>
);
}
function Overview({
data,
isAdmin,
onChanged,
}: {
data: ReturnType<typeof Object> & any;
isAdmin: boolean;
onChanged: () => void;
}) {
return (
<div className="space-y-2 overflow-auto">
<AutoUpdatePanel stackId={data.id} isAdmin={isAdmin} />
{data.containers.length === 0 && (
<Card>
<p className="text-sm text-slate-500">
No containers running. Start the stack to see services.
</p>
</Card>
)}
{data.containers.map((c: ContainerInfo) => (
<ContainerCard key={c.id} container={c} isAdmin={isAdmin} onChanged={onChanged} />
))}
</div>
);
}
function EnvView({ env }: { env: string }) {
return (
<Card className="h-full overflow-auto">
{env ? (
<pre className="whitespace-pre-wrap font-mono text-xs">{env}</pre>
) : (
<p className="text-sm text-slate-500">No .env file for this stack.</p>
)}
</Card>
);
}
function ComposeView({ yaml }: { yaml: string }) {
return (
<Card className="h-full overflow-auto">
<pre className="whitespace-pre-wrap font-mono text-xs">{yaml}</pre>
</Card>
);
}
function DeleteStackButton({ stackId }: { stackId: string }) {
const navigate = useNavigate();
const qc = useQueryClient();
const [open, setOpen] = useState(false);
const [deleteFiles, setDeleteFiles] = useState(true);
const [busy, setBusy] = useState(false);
const remove = async () => {
setBusy(true);
const t = toast.loading(`Deleting ${stackId}…`);
try {
await stacksApi.remove(stackId, deleteFiles);
toast.success(`Deleted ${stackId}`, { id: t });
qc.invalidateQueries({ queryKey: ["stacks"] });
navigate("/stacks");
} catch (e) {
toast.error(apiErrorMessage(e), { id: t });
setBusy(false);
}
};
return (
<>
<Button variant="danger" onClick={() => setOpen(true)}>
<Trash2 className="h-4 w-4" /> Delete
</Button>
{open && (
<ConfirmDialog
title={`Delete stack “${stackId}”?`}
message="The stack is stopped and removed. This cannot be undone."
confirmLabel="Delete stack"
danger
busy={busy}
onConfirm={remove}
onCancel={() => setOpen(false)}
>
<label className="flex items-start gap-2 text-sm">
<input
type="checkbox"
checked={deleteFiles}
onChange={(e) => setDeleteFiles(e.target.checked)}
className="mt-0.5 h-4 w-4"
/>
<span>
Also delete the compose files from disk
<span className="block text-xs text-slate-500">
Uncheck to keep <code>{stackId}/</code> on disk (it can be re-discovered later).
</span>
</span>
</label>
</ConfirmDialog>
)}
</>
);
}
function SaveAsTemplateButton({ stackId, defaultName }: { stackId: string; defaultName: string }) {
const [open, setOpen] = useState(false);
const [name, setName] = useState(defaultName);
const [busy, setBusy] = useState(false);
const save = async () => {
if (!name.trim()) {
toast.error("Template name required");
return;
}
setBusy(true);
try {
await templatesApi.saveFromStack({ stack_id: stackId, name: name.trim() });
toast.success(`Saved template “${name.trim()}”`);
setOpen(false);
} catch (e) {
toast.error(apiErrorMessage(e));
} finally {
setBusy(false);
}
};
return (
<>
<Button variant="outline" onClick={() => setOpen(true)}>
<LayoutTemplate className="h-4 w-4" /> Save as template
</Button>
{open && (
<ConfirmDialog
title="Save as template"
message="Snapshots this stack's compose and .env into a reusable custom template."
confirmLabel="Save template"
busy={busy}
onConfirm={save}
onCancel={() => setOpen(false)}
>
<label className="block space-y-1">
<span className="text-xs font-medium text-slate-500">Template name</span>
<Input value={name} onChange={(e) => setName(e.target.value)} />
</label>
</ConfirmDialog>
)}
</>
);
}