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.
275 lines
9.5 KiB
TypeScript
275 lines
9.5 KiB
TypeScript
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||
import { Server } from '../icons';
|
||
import { useToast } from './Toasts/useToast';
|
||
import { useServiceRegistry } from '../hooks/useServiceRegistry';
|
||
import './DeckyFleet.css';
|
||
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';
|
||
import { DeckyFilters } from './DeckyFleet/DeckyFilters';
|
||
import { DeckyGridEmpty } from './DeckyFleet/DeckyGridEmpty';
|
||
|
||
interface FleetProps {
|
||
searchQuery?: string;
|
||
}
|
||
|
||
const DeckyFleet: React.FC<FleetProps> = ({ searchQuery = '' }) => {
|
||
const { push } = useToast();
|
||
const serviceRegistry = useServiceRegistry();
|
||
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 [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;
|
||
if (localSearch !== searchQuery) setLocalSearch(searchQuery);
|
||
}
|
||
|
||
const arm = (key: string) => {
|
||
setArmed(key);
|
||
window.setTimeout(() => setArmed((p) => (p === key ? null : p)), 4000);
|
||
};
|
||
|
||
// 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> => {
|
||
const r = await mutate(name);
|
||
if (r.ok) {
|
||
push({ text: `MUTATED · ${name.toUpperCase()}`, tone: 'matrix', icon: 'refresh-cw' });
|
||
return true;
|
||
}
|
||
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 () => {
|
||
if (!isAdmin) {
|
||
push({ text: 'ADMIN REQUIRED', tone: 'alert', icon: 'alert-triangle' });
|
||
return;
|
||
}
|
||
const targets = deckies.filter(d => !d.swarm || d.swarm.state === 'running');
|
||
if (targets.length === 0) {
|
||
push({ text: 'NO DECKIES TO MUTATE', tone: 'violet', icon: 'info' });
|
||
return;
|
||
}
|
||
push({ text: `MUTATING FLEET · ${targets.length} DECKIES`, tone: 'violet', icon: 'refresh-cw' });
|
||
let failed = 0;
|
||
for (const d of targets) {
|
||
const ok = await handleMutate(d.name);
|
||
if (!ok) failed++;
|
||
}
|
||
if (failed === 0) {
|
||
push({ text: 'FLEET MUTATED', tone: 'matrix', icon: 'check-circle' });
|
||
} else {
|
||
push({ text: `FLEET MUTATED · ${failed} FAILED`, tone: 'alert', icon: 'alert-triangle' });
|
||
}
|
||
};
|
||
|
||
const handleIntervalChange = (name: string, current: number | null) => {
|
||
setIntervalEditor({ name, current });
|
||
};
|
||
|
||
const handleIntervalSave = async (minutes: number | null) => {
|
||
if (!intervalEditor) return;
|
||
const { name } = intervalEditor;
|
||
const ok = await setMutateInterval(name, minutes);
|
||
if (ok) {
|
||
setIntervalEditor(null);
|
||
push({
|
||
text: minutes === null
|
||
? `INTERVAL · ${name.toUpperCase()} · DISABLED`
|
||
: `INTERVAL · ${name.toUpperCase()} · ${minutes}m`,
|
||
tone: 'matrix',
|
||
icon: 'refresh-cw',
|
||
});
|
||
} 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);
|
||
const r = await teardown(d);
|
||
if (r.ok) {
|
||
push({ text: `TORN DOWN · ${d.name.toUpperCase()}`, tone: 'matrix', icon: 'check-circle' });
|
||
} else {
|
||
push({ text: `TEARDOWN FAILED · ${r.reason}`, tone: 'alert', icon: 'alert-triangle' });
|
||
}
|
||
};
|
||
|
||
// 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 };
|
||
if (!detail?.id) return;
|
||
if (detail.id === 'deploy') {
|
||
setShowDeploy(true);
|
||
return;
|
||
}
|
||
if (detail.id === 'mutate-all') {
|
||
void handleMutateAll();
|
||
return;
|
||
}
|
||
};
|
||
window.addEventListener('decnet:cmd', onCmd);
|
||
return () => window.removeEventListener('decnet:cmd', onCmd);
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [deckies, isAdmin]);
|
||
|
||
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);
|
||
c[s] += 1;
|
||
}
|
||
return c;
|
||
}, [deckies]);
|
||
|
||
const visible = useMemo(() => {
|
||
const base = filter === 'all' ? deckies : deckies.filter((d) => dotFor(d) === filter);
|
||
const q = localSearch.trim().toLowerCase();
|
||
if (!q) return base;
|
||
return base.filter((d) =>
|
||
d.name.toLowerCase().includes(q)
|
||
|| (d.ip || '').toLowerCase().includes(q)
|
||
|| (d.hostname || '').toLowerCase().includes(q),
|
||
);
|
||
}, [deckies, filter, localSearch]);
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="fleet-root">
|
||
<div className="dim" style={{ padding: '40px', textAlign: 'center', letterSpacing: 2 }}>
|
||
SCANNING NETWORK FOR DECOYS...
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="fleet-root">
|
||
<div className="page-header">
|
||
<div className="page-title-group">
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||
<Server size={22} className="violet-accent" />
|
||
<h1>DECOY FLEET</h1>
|
||
</div>
|
||
<span className="page-sub">
|
||
{deckies.length} DECKIES DEPLOYED · {counts.active + counts.hot} ACTIVE · {counts.hot} UNDER SIEGE
|
||
{deployMode && (
|
||
<> · [{isSwarm ? `SWARM × ${deployMode.swarm_host_count}` : 'UNIHOST'}]</>
|
||
)}
|
||
</span>
|
||
</div>
|
||
<DeckyFilters
|
||
filter={filter}
|
||
setFilter={setFilter}
|
||
counts={counts}
|
||
isAdmin={isAdmin}
|
||
onDeploy={() => setShowDeploy(true)}
|
||
/>
|
||
</div>
|
||
|
||
<div className="grid-fleet">
|
||
{visible.length === 0 ? (
|
||
<DeckyGridEmpty
|
||
fleetEmpty={deckies.length === 0}
|
||
isAdmin={isAdmin}
|
||
onDeploy={() => setShowDeploy(true)}
|
||
/>
|
||
) : (
|
||
visible.map((d) => (
|
||
<DeckyCard
|
||
key={d.name}
|
||
decky={d}
|
||
mutating={mutating === d.name}
|
||
isAdmin={isAdmin}
|
||
armed={armed}
|
||
tdBusy={tearingDown.has(d.name) || d.swarm?.state === 'tearing_down'}
|
||
onForce={(name) => { void handleMutate(name); }}
|
||
onTeardown={handleTeardown}
|
||
onIntervalChange={handleIntervalChange}
|
||
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={applyServicesChange}
|
||
onTarpitResult={(_name, ok, message) => {
|
||
push({
|
||
text: message,
|
||
tone: ok ? 'matrix' : 'alert',
|
||
icon: ok ? 'shield' : 'alert-triangle',
|
||
});
|
||
}}
|
||
/>
|
||
))
|
||
)}
|
||
</div>
|
||
|
||
<DeployWizard
|
||
open={showDeploy}
|
||
archetypes={archetypes}
|
||
fleetSize={deckies.length}
|
||
onClose={() => setShowDeploy(false)}
|
||
onComplete={(count) => {
|
||
setShowDeploy(false);
|
||
void refresh();
|
||
push({
|
||
text: `DEPLOYED · ${count} DECK${count === 1 ? 'Y' : 'IES'}`,
|
||
tone: 'matrix',
|
||
icon: 'check-circle',
|
||
});
|
||
}}
|
||
/>
|
||
|
||
<IntervalEditor
|
||
key={intervalEditor?.name ?? 'closed'}
|
||
open={intervalEditor !== null}
|
||
deckyName={intervalEditor?.name ?? ''}
|
||
current={intervalEditor?.current ?? null}
|
||
onClose={() => setIntervalEditor(null)}
|
||
onSave={handleIntervalSave}
|
||
/>
|
||
|
||
{selectedDecky && (
|
||
<DeckyInspectPanel
|
||
decky={selectedDecky}
|
||
onClose={() => setSelectedDecky(null)}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default DeckyFleet;
|