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:
@@ -113,8 +113,7 @@ const DeckyFleet: React.FC<FleetProps> = ({ searchQuery = '' }) => {
|
|||||||
// Two-step teardown: first click arms the button, second click within
|
// Two-step teardown: first click arms the button, second click within
|
||||||
// 4s actually fires the POST. Keeps swarm hosts safe from misclicks.
|
// 4s actually fires the POST. Keeps swarm hosts safe from misclicks.
|
||||||
const handleTeardown = async (d: Decky) => {
|
const handleTeardown = async (d: Decky) => {
|
||||||
if (!d.swarm) return;
|
const key = `td:${d.swarm?.host_uuid ?? 'local'}:${d.name}`;
|
||||||
const key = `td:${d.swarm.host_uuid}:${d.name}`;
|
|
||||||
if (armed !== key) { arm(key); return; }
|
if (armed !== key) { arm(key); return; }
|
||||||
setArmed(null);
|
setArmed(null);
|
||||||
const r = await teardown(d);
|
const r = await teardown(d);
|
||||||
|
|||||||
@@ -98,6 +98,19 @@ describe('DeckyCard', () => {
|
|||||||
expect(screen.getByText('CONFIRM')).toBeInTheDocument();
|
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 () => {
|
it('clicking the card body fires onInspect', async () => {
|
||||||
const onInspect = vi.fn();
|
const onInspect = vi.fn();
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export const DeckyCard: React.FC<Props> = ({
|
|||||||
const hits = hitsFor(decky);
|
const hits = hitsFor(decky);
|
||||||
const hot = dot === 'hot';
|
const hot = dot === 'hot';
|
||||||
const dotClass = mutating ? 'mutating' : dot;
|
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
|
// Live service mutation is local-only (admin, non-swarm). Swarm
|
||||||
// deckies live on a remote agent — the W3 path runs docker compose
|
// 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'}
|
{mutating ? 'MUTATING' : 'FORCE MUTATE'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{decky.swarm && isAdmin && (
|
{isAdmin && (
|
||||||
<button
|
<button
|
||||||
className="btn alert small"
|
className="btn alert small"
|
||||||
disabled={tdBusy}
|
disabled={tdBusy}
|
||||||
onClick={() => onTeardown(decky)}
|
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} />
|
<PowerOff size={10} />
|
||||||
{tdBusy
|
{tdBusy
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export interface UseDeckyFleetResult {
|
|||||||
mutate: (name: string) => Promise<MutateResult>;
|
mutate: (name: string) => Promise<MutateResult>;
|
||||||
/** Update or clear a decky's periodic mutate interval. */
|
/** Update or clear a decky's periodic mutate interval. */
|
||||||
setMutateInterval: (name: string, minutes: number | null) => Promise<boolean>;
|
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>;
|
teardown: (d: Decky) => Promise<TeardownResult>;
|
||||||
/** Optimistically apply a server-returned services list to a card
|
/** Optimistically apply a server-returned services list to a card
|
||||||
* (used by DeckyCard's add/remove-service flow). */
|
* (used by DeckyCard's add/remove-service flow). */
|
||||||
@@ -171,10 +171,13 @@ export function useDeckyFleet(): UseDeckyFleetResult {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const teardown = useCallback(async (d: Decky): Promise<TeardownResult> => {
|
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));
|
setTearingDown((prev) => new Set(prev).add(d.name));
|
||||||
try {
|
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);
|
await fetchDeckies(deployMode?.mode);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
|
|||||||
Reference in New Issue
Block a user