refactor(decnet_web/DeckyFleet): wire hook + extract filter UI

Final integration step. The page shell is now a thin composition
of the hook + the previously-extracted children:

- DeckyFleet.tsx: 1,674 -> 274 LOC. Page owns only the
  pure-UI state (filter, search, armed-confirm, modal visibility,
  selected-card-for-inspect) and the toast-wrapping handlers that
  translate hook results into toast tone. Polling, REST plumbing,
  role lookup, and archetype catalog all moved to useDeckyFleet
  in the prior commit.
- New DeckyFilters.tsx (header pill row + DEPLOY shortcut) +
  DeckyGridEmpty.tsx (fleet-empty vs. filter-empty copy).
- DeckyFilters.test.tsx + DeckyGridEmpty.test.tsx cover count
  rendering, filter-click callbacks, and admin-gated DEPLOY
  visibility.

Two-step teardown arming logic stays in the page (it's pure UI).
Toast tone branching on { ok, reason } from useDeckyFleet
results moves the policy decision out of the data layer.
This commit is contained in:
2026-05-09 05:05:31 -04:00
parent 9ddeb1a08c
commit 9da6f6983e
5 changed files with 233 additions and 195 deletions

View File

@@ -1,26 +1,17 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { PlusCircle, Server } from '../icons';
import api, { type ApiError } from '../utils/api';
import { ARCHETYPES as FALLBACK_ARCHETYPES } from './MazeNET/data';
import { Server } from '../icons';
import { useToast } from './Toasts/useToast';
import { useServiceRegistry } from '../hooks/useServiceRegistry';
import './DeckyFleet.css';
import type {
Decky,
SwarmDeckyRaw,
Archetype,
FilterKey,
} from './DeckyFleet/types';
import {
archetypeIcon as _archetypeIcon,
dotFor as _dotFor,
} from './DeckyFleet/helpers';
import type { Decky, FilterKey } from './DeckyFleet/types';
import { dotFor } from './DeckyFleet/helpers';
import { useDeckyFleet } from './DeckyFleet/useDeckyFleet';
import { DeckyInspectPanel } from './DeckyFleet/DeckyInspectPanel';
import { DeckyCard } from './DeckyFleet/DeckyCard';
import { DeployWizard } from './DeckyFleet/DeployWizard';
import { IntervalEditor } from './DeckyFleet/IntervalEditor';
// ─── Fleet page ──────────────────────────────────────────────────────────
import { DeckyFilters } from './DeckyFleet/DeckyFilters';
import { DeckyGridEmpty } from './DeckyFleet/DeckyGridEmpty';
interface FleetProps {
searchQuery?: string;
@@ -29,26 +20,27 @@ interface FleetProps {
const DeckyFleet: React.FC<FleetProps> = ({ searchQuery = '' }) => {
const { push } = useToast();
const serviceRegistry = useServiceRegistry();
const [deckies, setDeckies] = useState<Decky[]>([]);
const [loading, setLoading] = useState(true);
const [mutating, setMutating] = useState<string | null>(null);
const [isAdmin, setIsAdmin] = useState(false);
const [deployMode, setDeployMode] = useState<{ mode: string; swarm_host_count: number } | null>(null);
const fleet = useDeckyFleet();
const {
deckies, loading, isAdmin, deployMode, archetypes, isSwarm,
mutating, tearingDown,
mutate, setMutateInterval, teardown, applyServicesChange, refresh,
} = fleet;
// Pure UI state (no data lifecycle).
const [filter, setFilter] = useState<FilterKey>('all');
const [showDeploy, setShowDeploy] = useState(false);
const [armed, setArmed] = useState<string | null>(null);
const [tearingDown, setTearingDown] = useState<Set<string>>(new Set());
const [archetypes, setArchetypes] = useState<Archetype[]>(FALLBACK_ARCHETYPES);
const [localSearch, setLocalSearch] = useState<string>('');
const [intervalEditor, setIntervalEditor] = useState<{ name: string; current: number | null } | null>(null);
const [selectedDecky, setSelectedDecky] = useState<Decky | null>(null);
const cardRefs = useRef<Map<string, HTMLDivElement>>(new Map());
// Mirror the topbar search prop into local state; filter-decky events
// can override it in-session.
const lastSearchPropRef = useRef<string>(searchQuery);
if (lastSearchPropRef.current !== searchQuery) {
lastSearchPropRef.current = searchQuery;
// Mirror the topbar search into local state; filter-decky events can
// override it in-session.
if (localSearch !== searchQuery) setLocalSearch(searchQuery);
}
@@ -57,97 +49,19 @@ const DeckyFleet: React.FC<FleetProps> = ({ searchQuery = '' }) => {
window.setTimeout(() => setArmed((p) => (p === key ? null : p)), 4000);
};
const fetchDeckies = async (mode?: string) => {
try {
if (mode === 'swarm') {
const res = await api.get<SwarmDeckyRaw[]>('/swarm/deckies');
const normalized: Decky[] = res.data.map((s) => ({
name: s.decky_name,
ip: s.decky_ip || '—',
services: s.services || [],
distro: s.distro || 'unknown',
hostname: s.hostname || '—',
archetype: s.archetype,
service_config: s.service_config || {},
mutate_interval: s.mutate_interval,
last_mutated: s.last_mutated || 0,
swarm: {
host_uuid: s.host_uuid,
host_name: s.host_name,
host_address: s.host_address,
host_status: s.host_status,
state: s.state,
last_error: s.last_error,
last_seen: s.last_seen,
},
}));
setDeckies(normalized);
} else {
const res = await api.get<Decky[]>('/deckies');
setDeckies(res.data);
}
} catch (err) {
console.error('Failed to fetch decky fleet', err);
} finally {
setLoading(false);
}
};
const fetchRole = async () => {
try {
const res = await api.get('/config');
setIsAdmin(res.data.role === 'admin');
} catch {
setIsAdmin(false);
}
};
const fetchDeployMode = async () => {
try {
const res = await api.get('/system/deployment-mode');
setDeployMode({ mode: res.data.mode, swarm_host_count: res.data.swarm_host_count });
return res.data.mode as string;
} catch {
setDeployMode(null);
return undefined;
}
};
const fetchArchetypes = async () => {
try {
const res = await api.get<{ archetypes: { slug: string; display_name: string; services: string[] }[] }>(
'/topologies/archetypes',
);
const list: Archetype[] = res.data.archetypes.map((a) => ({
slug: a.slug,
name: a.display_name,
services: a.services,
icon: _archetypeIcon(a.slug),
}));
if (list.length) setArchetypes(list);
} catch {
// fall back to bundled list
}
};
// Toast-wrapping handlers — the hook returns discriminated results,
// and the page decides how to surface them in the toast lane.
const handleMutate = async (name: string): Promise<boolean> => {
setMutating(name);
try {
await api.post(`/deckies/${name}/mutate`, {}, { timeout: 120000 });
await fetchDeckies(deployMode?.mode);
const r = await mutate(name);
if (r.ok) {
push({ text: `MUTATED · ${name.toUpperCase()}`, tone: 'matrix', icon: 'refresh-cw' });
return true;
} catch (err: unknown) {
console.error('Failed to mutate', err);
const e = err as { code?: string };
const msg = e.code === 'ECONNABORTED'
? `MUTATION TIMED OUT · ${name.toUpperCase()}`
: `MUTATION FAILED · ${name.toUpperCase()}`;
push({ text: msg, tone: 'alert', icon: 'alert-triangle' });
return false;
} finally {
setMutating(null);
}
const msg = r.reason === 'timeout'
? `MUTATION TIMED OUT · ${name.toUpperCase()}`
: `MUTATION FAILED · ${name.toUpperCase()}`;
push({ text: msg, tone: 'alert', icon: 'alert-triangle' });
return false;
};
const handleMutateAll = async () => {
@@ -180,10 +94,9 @@ const DeckyFleet: React.FC<FleetProps> = ({ searchQuery = '' }) => {
const handleIntervalSave = async (minutes: number | null) => {
if (!intervalEditor) return;
const { name } = intervalEditor;
try {
await api.put(`/deckies/${name}/mutate-interval`, { mutate_interval: minutes });
const ok = await setMutateInterval(name, minutes);
if (ok) {
setIntervalEditor(null);
fetchDeckies(deployMode?.mode);
push({
text: minutes === null
? `INTERVAL · ${name.toUpperCase()} · DISABLED`
@@ -191,57 +104,28 @@ const DeckyFleet: React.FC<FleetProps> = ({ searchQuery = '' }) => {
tone: 'matrix',
icon: 'refresh-cw',
});
} catch (err) {
console.error('Failed to update interval', err);
} else {
push({ text: `INTERVAL UPDATE FAILED · ${name.toUpperCase()}`, tone: 'alert', icon: 'alert-triangle' });
}
};
// 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}`;
if (armed !== key) { arm(key); return; }
setArmed(null);
setTearingDown((prev) => new Set(prev).add(d.name));
try {
await api.post(`/swarm/hosts/${d.swarm.host_uuid}/teardown`, { decky_id: d.name });
await fetchDeckies(deployMode?.mode);
const r = await teardown(d);
if (r.ok) {
push({ text: `TORN DOWN · ${d.name.toUpperCase()}`, tone: 'matrix', icon: 'check-circle' });
} catch (err: unknown) {
const e = err as ApiError;
push({
text: `TEARDOWN FAILED · ${e?.response?.data?.detail || d.name}`,
tone: 'alert',
icon: 'alert-triangle',
});
} finally {
setTearingDown((prev) => {
const next = new Set(prev);
next.delete(d.name);
return next;
});
} else {
push({ text: `TEARDOWN FAILED · ${r.reason}`, tone: 'alert', icon: 'alert-triangle' });
}
};
const handleInspect = (d: Decky) => {
setSelectedDecky(d);
};
useEffect(() => {
let cancelled = false;
(async () => {
const mode = await fetchDeployMode();
if (cancelled) return;
await Promise.all([fetchDeckies(mode), fetchRole(), fetchArchetypes()]);
})();
const interval = window.setInterval(() => {
fetchDeployMode().then((m) => fetchDeckies(m));
}, 10000);
return () => { cancelled = true; window.clearInterval(interval); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Phase-2 decnet:cmd bus: deploy, mutate-all, filter-decky
// decnet:cmd bus: deploy + mutate-all are wired here because they
// dispatch UI state and a toast-wrapped operation respectively.
useEffect(() => {
const onCmd = (e: Event) => {
const detail = (e as CustomEvent).detail as { id?: string; payload?: string };
@@ -263,14 +147,14 @@ const DeckyFleet: React.FC<FleetProps> = ({ searchQuery = '' }) => {
const counts = useMemo(() => {
const c = { all: deckies.length, active: 0, hot: 0, idle: 0 } as Record<FilterKey, number>;
for (const d of deckies) {
const s = _dotFor(d);
const s = dotFor(d);
c[s] += 1;
}
return c;
}, [deckies]);
const visible = useMemo(() => {
const base = filter === 'all' ? deckies : deckies.filter((d) => _dotFor(d) === filter);
const base = filter === 'all' ? deckies : deckies.filter((d) => dotFor(d) === filter);
const q = localSearch.trim().toLowerCase();
if (!q) return base;
return base.filter((d) =>
@@ -279,7 +163,6 @@ const DeckyFleet: React.FC<FleetProps> = ({ searchQuery = '' }) => {
|| (d.hostname || '').toLowerCase().includes(q),
);
}, [deckies, filter, localSearch]);
const isSwarm = deployMode?.mode === 'swarm';
if (loading) {
return (
@@ -306,43 +189,22 @@ const DeckyFleet: React.FC<FleetProps> = ({ searchQuery = '' }) => {
)}
</span>
</div>
<div className="actions">
<div className="fleet-filter-group">
{([['all', 'ALL'], ['active', 'ACTIVE'], ['hot', 'HOT'], ['idle', 'IDLE']] as [FilterKey, string][]).map(
([v, l]) => (
<button
key={v}
onClick={() => setFilter(v)}
className={`fleet-filter-btn ${filter === v ? 'active' : ''}`}
>
{l} {counts[v]}
</button>
),
)}
</div>
{isAdmin && (
<button className="btn violet" onClick={() => setShowDeploy(true)}>
<PlusCircle size={12} /> DEPLOY DECKIES
</button>
)}
</div>
<DeckyFilters
filter={filter}
setFilter={setFilter}
counts={counts}
isAdmin={isAdmin}
onDeploy={() => setShowDeploy(true)}
/>
</div>
<div className="grid-fleet">
{visible.length === 0 ? (
<div className="fleet-empty">
<Server size={32} className="dim" />
<span className="dim">
{deckies.length === 0
? 'NO DECOYS DEPLOYED IN THIS SECTOR'
: 'NO DECOYS MATCH CURRENT FILTER'}
</span>
{isAdmin && deckies.length === 0 && (
<button className="btn violet" onClick={() => setShowDeploy(true)}>
<PlusCircle size={12} /> DEPLOY DECKIES
</button>
)}
</div>
<DeckyGridEmpty
fleetEmpty={deckies.length === 0}
isAdmin={isAdmin}
onDeploy={() => setShowDeploy(true)}
/>
) : (
visible.map((d) => (
<DeckyCard
@@ -355,17 +217,13 @@ const DeckyFleet: React.FC<FleetProps> = ({ searchQuery = '' }) => {
onForce={(name) => { void handleMutate(name); }}
onTeardown={handleTeardown}
onIntervalChange={handleIntervalChange}
onInspect={handleInspect}
onInspect={(decky) => setSelectedDecky(decky)}
innerRef={(el: HTMLDivElement | null) => {
if (el) cardRefs.current.set(d.name, el);
else cardRefs.current.delete(d.name);
}}
availableServices={serviceRegistry.perDecky}
onServicesChanged={(name, services) => {
setDeckies((prev) => prev.map((row) =>
row.name === name ? { ...row, services } : row,
));
}}
onServicesChanged={applyServicesChange}
onTarpitResult={(_name, ok, message) => {
push({
text: message,
@@ -385,7 +243,7 @@ const DeckyFleet: React.FC<FleetProps> = ({ searchQuery = '' }) => {
onClose={() => setShowDeploy(false)}
onComplete={(count) => {
setShowDeploy(false);
fetchDeckies(deployMode?.mode);
void refresh();
push({
text: `DEPLOYED · ${count} DECK${count === 1 ? 'Y' : 'IES'}`,
tone: 'matrix',

View File

@@ -0,0 +1,67 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { DeckyFilters } from './DeckyFilters';
const counts = { all: 7, active: 4, hot: 2, idle: 1 } as const;
describe('DeckyFilters', () => {
it('renders one button per filter with the matching count', () => {
render(
<DeckyFilters
filter="all"
setFilter={() => {}}
counts={counts}
isAdmin={false}
onDeploy={() => {}}
/>,
);
expect(screen.getByText('ALL 7')).toBeInTheDocument();
expect(screen.getByText('ACTIVE 4')).toBeInTheDocument();
expect(screen.getByText('HOT 2')).toBeInTheDocument();
expect(screen.getByText('IDLE 1')).toBeInTheDocument();
});
it('clicking a filter button invokes setFilter with its key', async () => {
const setFilter = vi.fn();
const user = userEvent.setup();
render(
<DeckyFilters
filter="all"
setFilter={setFilter}
counts={counts}
isAdmin={false}
onDeploy={() => {}}
/>,
);
await user.click(screen.getByText('HOT 2'));
expect(setFilter).toHaveBeenCalledWith('hot');
});
it('hides DEPLOY DECKIES for non-admins, shows it for admins', async () => {
const onDeploy = vi.fn();
const { rerender } = render(
<DeckyFilters
filter="all"
setFilter={() => {}}
counts={counts}
isAdmin={false}
onDeploy={onDeploy}
/>,
);
expect(screen.queryByText(/DEPLOY DECKIES/)).not.toBeInTheDocument();
rerender(
<DeckyFilters
filter="all"
setFilter={() => {}}
counts={counts}
isAdmin
onDeploy={onDeploy}
/>,
);
const user = userEvent.setup();
await user.click(screen.getByText(/DEPLOY DECKIES/));
expect(onDeploy).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,44 @@
import React from 'react';
import { PlusCircle } from '../../icons';
import type { FilterKey } from './types';
interface Props {
filter: FilterKey;
setFilter: (k: FilterKey) => void;
counts: Record<FilterKey, number>;
isAdmin: boolean;
onDeploy: () => void;
}
const FILTER_BUTTONS: ReadonlyArray<readonly [FilterKey, string]> = [
['all', 'ALL'],
['active', 'ACTIVE'],
['hot', 'HOT'],
['idle', 'IDLE'],
];
/** Filter pill row + DEPLOY DECKIES action used in the page header.
* Counts feed badge text inside each pill so users can see fleet
* health without filtering first. */
export const DeckyFilters: React.FC<Props> = ({
filter, setFilter, counts, isAdmin, onDeploy,
}) => (
<div className="actions">
<div className="fleet-filter-group">
{FILTER_BUTTONS.map(([v, l]) => (
<button
key={v}
onClick={() => setFilter(v)}
className={`fleet-filter-btn ${filter === v ? 'active' : ''}`}
>
{l} {counts[v]}
</button>
))}
</div>
{isAdmin && (
<button className="btn violet" onClick={onDeploy}>
<PlusCircle size={12} /> DEPLOY DECKIES
</button>
)}
</div>
);

View File

@@ -0,0 +1,41 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { DeckyGridEmpty } from './DeckyGridEmpty';
describe('DeckyGridEmpty', () => {
it('shows the fleet-empty copy when fleetEmpty is true', () => {
render(
<DeckyGridEmpty fleetEmpty isAdmin={false} onDeploy={() => {}} />,
);
expect(screen.getByText('NO DECOYS DEPLOYED IN THIS SECTOR')).toBeInTheDocument();
});
it('shows the filtered-empty copy when fleetEmpty is false', () => {
render(
<DeckyGridEmpty fleetEmpty={false} isAdmin={false} onDeploy={() => {}} />,
);
expect(screen.getByText('NO DECOYS MATCH CURRENT FILTER')).toBeInTheDocument();
});
it('only renders the DEPLOY shortcut for admins on a truly empty fleet', () => {
const { rerender } = render(
<DeckyGridEmpty fleetEmpty isAdmin={false} onDeploy={() => {}} />,
);
expect(screen.queryByText(/DEPLOY DECKIES/)).not.toBeInTheDocument();
rerender(
<DeckyGridEmpty fleetEmpty={false} isAdmin onDeploy={() => {}} />,
);
expect(screen.queryByText(/DEPLOY DECKIES/)).not.toBeInTheDocument();
const onDeploy = vi.fn();
rerender(
<DeckyGridEmpty fleetEmpty isAdmin onDeploy={onDeploy} />,
);
const user = userEvent.setup();
return user.click(screen.getByText(/DEPLOY DECKIES/)).then(() => {
expect(onDeploy).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,28 @@
import React from 'react';
import { PlusCircle, Server } from '../../icons';
interface Props {
/** True when the underlying fleet itself is empty (vs. just filtered down). */
fleetEmpty: boolean;
isAdmin: boolean;
onDeploy: () => void;
}
/** Empty-state shown inside the grid when no cards match. Distinguishes
* a genuinely empty fleet (offers a DEPLOY shortcut for admins) from a
* filter that hid everything (just nudges the user to widen). */
export const DeckyGridEmpty: React.FC<Props> = ({ fleetEmpty, isAdmin, onDeploy }) => (
<div className="fleet-empty">
<Server size={32} className="dim" />
<span className="dim">
{fleetEmpty
? 'NO DECOYS DEPLOYED IN THIS SECTOR'
: 'NO DECOYS MATCH CURRENT FILTER'}
</span>
{isAdmin && fleetEmpty && (
<button className="btn violet" onClick={onDeploy}>
<PlusCircle size={12} /> DEPLOY DECKIES
</button>
)}
</div>
);