feat(web): wire local-decky teardown to DELETE /deckies/{name}

The Fleet UI only showed TEARDOWN for swarm-pinned deckies (POST
/swarm/hosts/{uuid}/teardown). Local deckies had no delete control though
the API now exposes DELETE /deckies/{name}.

teardown() branches on swarm vs local; the card's two-step arm/CONFIRM
button renders for any admin, keyed td:${host_uuid ?? 'local'}:${name}.
This commit is contained in:
2026-06-16 12:15:48 -04:00
parent 0c10869e26
commit 5505de782f
4 changed files with 23 additions and 8 deletions

View File

@@ -113,8 +113,7 @@ const DeckyFleet: React.FC<FleetProps> = ({ searchQuery = '' }) => {
// Two-step teardown: first click arms the button, second click within
// 4s actually fires the POST. Keeps swarm hosts safe from misclicks.
const handleTeardown = async (d: Decky) => {
if (!d.swarm) return;
const key = `td:${d.swarm.host_uuid}:${d.name}`;
const key = `td:${d.swarm?.host_uuid ?? 'local'}:${d.name}`;
if (armed !== key) { arm(key); return; }
setArmed(null);
const r = await teardown(d);

View File

@@ -98,6 +98,19 @@ describe('DeckyCard', () => {
expect(screen.getByText('CONFIRM')).toBeInTheDocument();
});
it('shows TEARDOWN for admin on a local (non-swarm) decky, keyed td:local:', () => {
const local = makeDecky({ name: 'decoy-local' });
const { rerender } = render(
<DeckyCard {...baseProps} isAdmin decky={local} />,
);
expect(screen.getByText('TEARDOWN')).toBeInTheDocument();
rerender(
<DeckyCard {...baseProps} isAdmin armed="td:local:decoy-local" decky={local} />,
);
expect(screen.getByText('CONFIRM')).toBeInTheDocument();
});
it('clicking the card body fires onInspect', async () => {
const onInspect = vi.fn();
const user = userEvent.setup();

View File

@@ -38,7 +38,7 @@ export const DeckyCard: React.FC<Props> = ({
const hits = hitsFor(decky);
const hot = dot === 'hot';
const dotClass = mutating ? 'mutating' : dot;
const tdKey = decky.swarm ? `td:${decky.swarm.host_uuid}:${decky.name}` : '';
const tdKey = `td:${decky.swarm?.host_uuid ?? 'local'}:${decky.name}`;
// Live service mutation is local-only (admin, non-swarm). Swarm
// deckies live on a remote agent — the W3 path runs docker compose
@@ -347,12 +347,12 @@ export const DeckyCard: React.FC<Props> = ({
{mutating ? 'MUTATING' : 'FORCE MUTATE'}
</button>
)}
{decky.swarm && isAdmin && (
{isAdmin && (
<button
className="btn alert small"
disabled={tdBusy}
onClick={() => onTeardown(decky)}
title="Stop this decky on its host"
title={decky.swarm ? 'Stop this decky on its host' : 'Tear down this decky'}
>
<PowerOff size={10} />
{tdBusy

View File

@@ -38,7 +38,7 @@ export interface UseDeckyFleetResult {
mutate: (name: string) => Promise<MutateResult>;
/** Update or clear a decky's periodic mutate interval. */
setMutateInterval: (name: string, minutes: number | null) => Promise<boolean>;
/** Tear down a swarm-pinned decky on its host. */
/** Tear down a decky — swarm-pinned on its host, or local via DELETE. */
teardown: (d: Decky) => Promise<TeardownResult>;
/** Optimistically apply a server-returned services list to a card
* (used by DeckyCard's add/remove-service flow). */
@@ -171,10 +171,13 @@ export function useDeckyFleet(): UseDeckyFleetResult {
);
const teardown = useCallback(async (d: Decky): Promise<TeardownResult> => {
if (!d.swarm) return { ok: false, reason: 'not a swarm decky' };
setTearingDown((prev) => new Set(prev).add(d.name));
try {
await api.post(`/swarm/hosts/${d.swarm.host_uuid}/teardown`, { decky_id: d.name });
if (d.swarm) {
await api.post(`/swarm/hosts/${d.swarm.host_uuid}/teardown`, { decky_id: d.name });
} else {
await api.delete(`/deckies/${encodeURIComponent(d.name)}`);
}
await fetchDeckies(deployMode?.mode);
return { ok: true };
} catch (err: unknown) {