From 9da6f6983ef925976470a38341a6918f16d397da Mon Sep 17 00:00:00 2001 From: anti Date: Sat, 9 May 2026 05:05:31 -0400 Subject: [PATCH] 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. --- decnet_web/src/components/DeckyFleet.tsx | 248 ++++-------------- .../DeckyFleet/DeckyFilters.test.tsx | 67 +++++ .../components/DeckyFleet/DeckyFilters.tsx | 44 ++++ .../DeckyFleet/DeckyGridEmpty.test.tsx | 41 +++ .../components/DeckyFleet/DeckyGridEmpty.tsx | 28 ++ 5 files changed, 233 insertions(+), 195 deletions(-) create mode 100644 decnet_web/src/components/DeckyFleet/DeckyFilters.test.tsx create mode 100644 decnet_web/src/components/DeckyFleet/DeckyFilters.tsx create mode 100644 decnet_web/src/components/DeckyFleet/DeckyGridEmpty.test.tsx create mode 100644 decnet_web/src/components/DeckyFleet/DeckyGridEmpty.tsx diff --git a/decnet_web/src/components/DeckyFleet.tsx b/decnet_web/src/components/DeckyFleet.tsx index e9d53641..a597368d 100644 --- a/decnet_web/src/components/DeckyFleet.tsx +++ b/decnet_web/src/components/DeckyFleet.tsx @@ -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 = ({ searchQuery = '' }) => { const { push } = useToast(); const serviceRegistry = useServiceRegistry(); - const [deckies, setDeckies] = useState([]); - const [loading, setLoading] = useState(true); - const [mutating, setMutating] = useState(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('all'); const [showDeploy, setShowDeploy] = useState(false); const [armed, setArmed] = useState(null); - const [tearingDown, setTearingDown] = useState>(new Set()); - const [archetypes, setArchetypes] = useState(FALLBACK_ARCHETYPES); const [localSearch, setLocalSearch] = useState(''); const [intervalEditor, setIntervalEditor] = useState<{ name: string; current: number | null } | null>(null); const [selectedDecky, setSelectedDecky] = useState(null); const cardRefs = useRef>(new Map()); + // Mirror the topbar search prop into local state; filter-decky events + // can override it in-session. const lastSearchPropRef = useRef(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 = ({ searchQuery = '' }) => { window.setTimeout(() => setArmed((p) => (p === key ? null : p)), 4000); }; - const fetchDeckies = async (mode?: string) => { - try { - if (mode === 'swarm') { - const res = await api.get('/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('/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 => { - 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 = ({ 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 = ({ 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 = ({ searchQuery = '' }) => { const counts = useMemo(() => { const c = { all: deckies.length, active: 0, hot: 0, idle: 0 } as Record; 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 = ({ 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 = ({ searchQuery = '' }) => { )} -
-
- {([['all', 'ALL'], ['active', 'ACTIVE'], ['hot', 'HOT'], ['idle', 'IDLE']] as [FilterKey, string][]).map( - ([v, l]) => ( - - ), - )} -
- {isAdmin && ( - - )} -
+ setShowDeploy(true)} + />
{visible.length === 0 ? ( -
- - - {deckies.length === 0 - ? 'NO DECOYS DEPLOYED IN THIS SECTOR' - : 'NO DECOYS MATCH CURRENT FILTER'} - - {isAdmin && deckies.length === 0 && ( - - )} -
+ setShowDeploy(true)} + /> ) : ( visible.map((d) => ( = ({ 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 = ({ searchQuery = '' }) => { onClose={() => setShowDeploy(false)} onComplete={(count) => { setShowDeploy(false); - fetchDeckies(deployMode?.mode); + void refresh(); push({ text: `DEPLOYED · ${count} DECK${count === 1 ? 'Y' : 'IES'}`, tone: 'matrix', diff --git a/decnet_web/src/components/DeckyFleet/DeckyFilters.test.tsx b/decnet_web/src/components/DeckyFleet/DeckyFilters.test.tsx new file mode 100644 index 00000000..c8f4acb1 --- /dev/null +++ b/decnet_web/src/components/DeckyFleet/DeckyFilters.test.tsx @@ -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( + {}} + 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( + {}} + />, + ); + 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( + {}} + counts={counts} + isAdmin={false} + onDeploy={onDeploy} + />, + ); + expect(screen.queryByText(/DEPLOY DECKIES/)).not.toBeInTheDocument(); + + rerender( + {}} + counts={counts} + isAdmin + onDeploy={onDeploy} + />, + ); + const user = userEvent.setup(); + await user.click(screen.getByText(/DEPLOY DECKIES/)); + expect(onDeploy).toHaveBeenCalled(); + }); +}); diff --git a/decnet_web/src/components/DeckyFleet/DeckyFilters.tsx b/decnet_web/src/components/DeckyFleet/DeckyFilters.tsx new file mode 100644 index 00000000..2956de64 --- /dev/null +++ b/decnet_web/src/components/DeckyFleet/DeckyFilters.tsx @@ -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; + isAdmin: boolean; + onDeploy: () => void; +} + +const FILTER_BUTTONS: ReadonlyArray = [ + ['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 = ({ + filter, setFilter, counts, isAdmin, onDeploy, +}) => ( +
+
+ {FILTER_BUTTONS.map(([v, l]) => ( + + ))} +
+ {isAdmin && ( + + )} +
+); diff --git a/decnet_web/src/components/DeckyFleet/DeckyGridEmpty.test.tsx b/decnet_web/src/components/DeckyFleet/DeckyGridEmpty.test.tsx new file mode 100644 index 00000000..0752d3f9 --- /dev/null +++ b/decnet_web/src/components/DeckyFleet/DeckyGridEmpty.test.tsx @@ -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( + {}} />, + ); + expect(screen.getByText('NO DECOYS DEPLOYED IN THIS SECTOR')).toBeInTheDocument(); + }); + + it('shows the filtered-empty copy when fleetEmpty is false', () => { + render( + {}} />, + ); + 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( + {}} />, + ); + expect(screen.queryByText(/DEPLOY DECKIES/)).not.toBeInTheDocument(); + + rerender( + {}} />, + ); + expect(screen.queryByText(/DEPLOY DECKIES/)).not.toBeInTheDocument(); + + const onDeploy = vi.fn(); + rerender( + , + ); + const user = userEvent.setup(); + return user.click(screen.getByText(/DEPLOY DECKIES/)).then(() => { + expect(onDeploy).toHaveBeenCalled(); + }); + }); +}); diff --git a/decnet_web/src/components/DeckyFleet/DeckyGridEmpty.tsx b/decnet_web/src/components/DeckyFleet/DeckyGridEmpty.tsx new file mode 100644 index 00000000..4e1cf735 --- /dev/null +++ b/decnet_web/src/components/DeckyFleet/DeckyGridEmpty.tsx @@ -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 = ({ fleetEmpty, isAdmin, onDeploy }) => ( +
+ + + {fleetEmpty + ? 'NO DECOYS DEPLOYED IN THIS SECTOR' + : 'NO DECOYS MATCH CURRENT FILTER'} + + {isAdmin && fleetEmpty && ( + + )} +
+);