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 React, { useEffect, useMemo, useRef, useState } from 'react';
import { PlusCircle, Server } from '../icons'; import { Server } from '../icons';
import api, { type ApiError } from '../utils/api';
import { ARCHETYPES as FALLBACK_ARCHETYPES } from './MazeNET/data';
import { useToast } from './Toasts/useToast'; import { useToast } from './Toasts/useToast';
import { useServiceRegistry } from '../hooks/useServiceRegistry'; import { useServiceRegistry } from '../hooks/useServiceRegistry';
import './DeckyFleet.css'; import './DeckyFleet.css';
import type { import type { Decky, FilterKey } from './DeckyFleet/types';
Decky, import { dotFor } from './DeckyFleet/helpers';
SwarmDeckyRaw, import { useDeckyFleet } from './DeckyFleet/useDeckyFleet';
Archetype,
FilterKey,
} from './DeckyFleet/types';
import {
archetypeIcon as _archetypeIcon,
dotFor as _dotFor,
} from './DeckyFleet/helpers';
import { DeckyInspectPanel } from './DeckyFleet/DeckyInspectPanel'; import { DeckyInspectPanel } from './DeckyFleet/DeckyInspectPanel';
import { DeckyCard } from './DeckyFleet/DeckyCard'; import { DeckyCard } from './DeckyFleet/DeckyCard';
import { DeployWizard } from './DeckyFleet/DeployWizard'; import { DeployWizard } from './DeckyFleet/DeployWizard';
import { IntervalEditor } from './DeckyFleet/IntervalEditor'; import { IntervalEditor } from './DeckyFleet/IntervalEditor';
import { DeckyFilters } from './DeckyFleet/DeckyFilters';
// ─── Fleet page ────────────────────────────────────────────────────────── import { DeckyGridEmpty } from './DeckyFleet/DeckyGridEmpty';
interface FleetProps { interface FleetProps {
searchQuery?: string; searchQuery?: string;
@@ -29,26 +20,27 @@ interface FleetProps {
const DeckyFleet: React.FC<FleetProps> = ({ searchQuery = '' }) => { const DeckyFleet: React.FC<FleetProps> = ({ searchQuery = '' }) => {
const { push } = useToast(); const { push } = useToast();
const serviceRegistry = useServiceRegistry(); const serviceRegistry = useServiceRegistry();
const [deckies, setDeckies] = useState<Decky[]>([]); const fleet = useDeckyFleet();
const [loading, setLoading] = useState(true); const {
const [mutating, setMutating] = useState<string | null>(null); deckies, loading, isAdmin, deployMode, archetypes, isSwarm,
const [isAdmin, setIsAdmin] = useState(false); mutating, tearingDown,
const [deployMode, setDeployMode] = useState<{ mode: string; swarm_host_count: number } | null>(null); mutate, setMutateInterval, teardown, applyServicesChange, refresh,
} = fleet;
// Pure UI state (no data lifecycle).
const [filter, setFilter] = useState<FilterKey>('all'); const [filter, setFilter] = useState<FilterKey>('all');
const [showDeploy, setShowDeploy] = useState(false); const [showDeploy, setShowDeploy] = useState(false);
const [armed, setArmed] = useState<string | null>(null); 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 [localSearch, setLocalSearch] = useState<string>('');
const [intervalEditor, setIntervalEditor] = useState<{ name: string; current: number | null } | null>(null); const [intervalEditor, setIntervalEditor] = useState<{ name: string; current: number | null } | null>(null);
const [selectedDecky, setSelectedDecky] = useState<Decky | null>(null); const [selectedDecky, setSelectedDecky] = useState<Decky | null>(null);
const cardRefs = useRef<Map<string, HTMLDivElement>>(new Map()); 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); const lastSearchPropRef = useRef<string>(searchQuery);
if (lastSearchPropRef.current !== searchQuery) { if (lastSearchPropRef.current !== searchQuery) {
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); if (localSearch !== searchQuery) setLocalSearch(searchQuery);
} }
@@ -57,97 +49,19 @@ const DeckyFleet: React.FC<FleetProps> = ({ searchQuery = '' }) => {
window.setTimeout(() => setArmed((p) => (p === key ? null : p)), 4000); window.setTimeout(() => setArmed((p) => (p === key ? null : p)), 4000);
}; };
const fetchDeckies = async (mode?: string) => { // Toast-wrapping handlers — the hook returns discriminated results,
try { // and the page decides how to surface them in the toast lane.
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
}
};
const handleMutate = async (name: string): Promise<boolean> => { const handleMutate = async (name: string): Promise<boolean> => {
setMutating(name); const r = await mutate(name);
try { if (r.ok) {
await api.post(`/deckies/${name}/mutate`, {}, { timeout: 120000 });
await fetchDeckies(deployMode?.mode);
push({ text: `MUTATED · ${name.toUpperCase()}`, tone: 'matrix', icon: 'refresh-cw' }); push({ text: `MUTATED · ${name.toUpperCase()}`, tone: 'matrix', icon: 'refresh-cw' });
return true; 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 () => { const handleMutateAll = async () => {
@@ -180,10 +94,9 @@ const DeckyFleet: React.FC<FleetProps> = ({ searchQuery = '' }) => {
const handleIntervalSave = async (minutes: number | null) => { const handleIntervalSave = async (minutes: number | null) => {
if (!intervalEditor) return; if (!intervalEditor) return;
const { name } = intervalEditor; const { name } = intervalEditor;
try { const ok = await setMutateInterval(name, minutes);
await api.put(`/deckies/${name}/mutate-interval`, { mutate_interval: minutes }); if (ok) {
setIntervalEditor(null); setIntervalEditor(null);
fetchDeckies(deployMode?.mode);
push({ push({
text: minutes === null text: minutes === null
? `INTERVAL · ${name.toUpperCase()} · DISABLED` ? `INTERVAL · ${name.toUpperCase()} · DISABLED`
@@ -191,57 +104,28 @@ const DeckyFleet: React.FC<FleetProps> = ({ searchQuery = '' }) => {
tone: 'matrix', tone: 'matrix',
icon: 'refresh-cw', icon: 'refresh-cw',
}); });
} catch (err) { } else {
console.error('Failed to update interval', err);
push({ text: `INTERVAL UPDATE FAILED · ${name.toUpperCase()}`, tone: 'alert', icon: 'alert-triangle' }); 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) => { const handleTeardown = async (d: Decky) => {
if (!d.swarm) return; if (!d.swarm) return;
const key = `td:${d.swarm.host_uuid}:${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);
setTearingDown((prev) => new Set(prev).add(d.name)); const r = await teardown(d);
try { if (r.ok) {
await api.post(`/swarm/hosts/${d.swarm.host_uuid}/teardown`, { decky_id: d.name });
await fetchDeckies(deployMode?.mode);
push({ text: `TORN DOWN · ${d.name.toUpperCase()}`, tone: 'matrix', icon: 'check-circle' }); push({ text: `TORN DOWN · ${d.name.toUpperCase()}`, tone: 'matrix', icon: 'check-circle' });
} catch (err: unknown) { } else {
const e = err as ApiError; push({ text: `TEARDOWN FAILED · ${r.reason}`, tone: 'alert', icon: 'alert-triangle' });
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;
});
} }
}; };
const handleInspect = (d: Decky) => { // decnet:cmd bus: deploy + mutate-all are wired here because they
setSelectedDecky(d); // dispatch UI state and a toast-wrapped operation respectively.
};
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
useEffect(() => { useEffect(() => {
const onCmd = (e: Event) => { const onCmd = (e: Event) => {
const detail = (e as CustomEvent).detail as { id?: string; payload?: string }; 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 counts = useMemo(() => {
const c = { all: deckies.length, active: 0, hot: 0, idle: 0 } as Record<FilterKey, number>; const c = { all: deckies.length, active: 0, hot: 0, idle: 0 } as Record<FilterKey, number>;
for (const d of deckies) { for (const d of deckies) {
const s = _dotFor(d); const s = dotFor(d);
c[s] += 1; c[s] += 1;
} }
return c; return c;
}, [deckies]); }, [deckies]);
const visible = useMemo(() => { 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(); const q = localSearch.trim().toLowerCase();
if (!q) return base; if (!q) return base;
return base.filter((d) => return base.filter((d) =>
@@ -279,7 +163,6 @@ const DeckyFleet: React.FC<FleetProps> = ({ searchQuery = '' }) => {
|| (d.hostname || '').toLowerCase().includes(q), || (d.hostname || '').toLowerCase().includes(q),
); );
}, [deckies, filter, localSearch]); }, [deckies, filter, localSearch]);
const isSwarm = deployMode?.mode === 'swarm';
if (loading) { if (loading) {
return ( return (
@@ -306,43 +189,22 @@ const DeckyFleet: React.FC<FleetProps> = ({ searchQuery = '' }) => {
)} )}
</span> </span>
</div> </div>
<div className="actions"> <DeckyFilters
<div className="fleet-filter-group"> filter={filter}
{([['all', 'ALL'], ['active', 'ACTIVE'], ['hot', 'HOT'], ['idle', 'IDLE']] as [FilterKey, string][]).map( setFilter={setFilter}
([v, l]) => ( counts={counts}
<button isAdmin={isAdmin}
key={v} onDeploy={() => setShowDeploy(true)}
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>
</div> </div>
<div className="grid-fleet"> <div className="grid-fleet">
{visible.length === 0 ? ( {visible.length === 0 ? (
<div className="fleet-empty"> <DeckyGridEmpty
<Server size={32} className="dim" /> fleetEmpty={deckies.length === 0}
<span className="dim"> isAdmin={isAdmin}
{deckies.length === 0 onDeploy={() => setShowDeploy(true)}
? '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>
) : ( ) : (
visible.map((d) => ( visible.map((d) => (
<DeckyCard <DeckyCard
@@ -355,17 +217,13 @@ const DeckyFleet: React.FC<FleetProps> = ({ searchQuery = '' }) => {
onForce={(name) => { void handleMutate(name); }} onForce={(name) => { void handleMutate(name); }}
onTeardown={handleTeardown} onTeardown={handleTeardown}
onIntervalChange={handleIntervalChange} onIntervalChange={handleIntervalChange}
onInspect={handleInspect} onInspect={(decky) => setSelectedDecky(decky)}
innerRef={(el: HTMLDivElement | null) => { innerRef={(el: HTMLDivElement | null) => {
if (el) cardRefs.current.set(d.name, el); if (el) cardRefs.current.set(d.name, el);
else cardRefs.current.delete(d.name); else cardRefs.current.delete(d.name);
}} }}
availableServices={serviceRegistry.perDecky} availableServices={serviceRegistry.perDecky}
onServicesChanged={(name, services) => { onServicesChanged={applyServicesChange}
setDeckies((prev) => prev.map((row) =>
row.name === name ? { ...row, services } : row,
));
}}
onTarpitResult={(_name, ok, message) => { onTarpitResult={(_name, ok, message) => {
push({ push({
text: message, text: message,
@@ -385,7 +243,7 @@ const DeckyFleet: React.FC<FleetProps> = ({ searchQuery = '' }) => {
onClose={() => setShowDeploy(false)} onClose={() => setShowDeploy(false)}
onComplete={(count) => { onComplete={(count) => {
setShowDeploy(false); setShowDeploy(false);
fetchDeckies(deployMode?.mode); void refresh();
push({ push({
text: `DEPLOYED · ${count} DECK${count === 1 ? 'Y' : 'IES'}`, text: `DEPLOYED · ${count} DECK${count === 1 ? 'Y' : 'IES'}`,
tone: 'matrix', 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>
);