diff --git a/decnet_web/src/components/DeckyFleet.tsx b/decnet_web/src/components/DeckyFleet.tsx index 46265e07..3bac8af1 100644 --- a/decnet_web/src/components/DeckyFleet.tsx +++ b/decnet_web/src/components/DeckyFleet.tsx @@ -1,15 +1,10 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { - Network, PlusCircle, PowerOff, - RefreshCw, Server, Plus, X, -} from '../icons'; +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, DEFAULT_SERVICES } from './MazeNET/data'; import { useToast } from './Toasts/useToast'; import Modal from './Modal/Modal'; import { useServiceRegistry } from '../hooks/useServiceRegistry'; -import ServiceConfigForm from './ServiceConfigForm'; -import AddServiceConfigModal from './AddServiceConfigModal'; import ServiceConfigFields, { type FormState as SvcFormState, type ServiceConfigFieldDTO as SvcFieldDTO, @@ -31,456 +26,7 @@ import { } from './DeckyFleet/helpers'; import { DeckyInspectPanel } from './DeckyFleet/DeckyInspectPanel'; - -// ─── Decky card ─────────────────────────────────────────────────────────── - -interface DeckyCardProps { - decky: Decky; - mutating: boolean; - isAdmin: boolean; - armed: string | null; - tdBusy: boolean; - onForce: (name: string) => void; - onTeardown: (d: Decky) => void; - onIntervalChange: (name: string, current: number | null) => void; - onInspect: (d: Decky) => void; - innerRef?: React.Ref; - /** Per-decky-eligible service slugs from useServiceRegistry. */ - availableServices: string[]; - /** Called after a successful live add/remove so the parent can - * optimistically apply the response's services list. */ - onServicesChanged: (deckyName: string, services: string[]) => void; - /** Called after a tarpit enable/disable with success or error text. */ - onTarpitResult: (deckyName: string, ok: boolean, message: string) => void; -} - -const DeckyCard: React.FC = ({ - decky, mutating, isAdmin, armed, tdBusy, onForce, onTeardown, onIntervalChange, onInspect, - innerRef, availableServices, onServicesChanged, onTarpitResult, -}) => { - const dot = _dotFor(decky); - const hits = _hitsFor(decky); - const hot = dot === 'hot'; - const dotClass = mutating ? 'mutating' : dot; - const tdKey = decky.swarm ? `td:${decky.swarm.host_uuid}:${decky.name}` : ''; - - // Live service mutation is local-only (admin, non-swarm). Swarm - // deckies live on a remote agent — the W3 path runs docker compose - // locally and won't reach the agent's containers (same gap as the - // canary planter has for agent-pinned topologies; out of scope here). - const liveServicesEnabled = isAdmin && !decky.swarm; - const [addOpen, setAddOpen] = useState(false); - const [addSlug, setAddSlug] = useState(''); - const [busy, setBusy] = useState(null); - const [opError, setOpError] = useState(null); - const [openCfgSvc, setOpenCfgSvc] = useState(null); - // Pending add — when non-null, AddServiceConfigModal is mounted and - // will either auto-fire onConfirm (no schema fields) or show the form. - const [pendingAdd, setPendingAdd] = useState<{ deckyName: string; slug: string } | null>(null); - - // Tarpit controls — admin + non-swarm only (same gate as liveServicesEnabled) - const [tarpitMenuOpen, setTarpitMenuOpen] = useState(false); - const [tarpitFormOpen, setTarpitFormOpen] = useState(false); - const [tarpitBusy, setTarpitBusy] = useState(false); - const [tarpitPorts, setTarpitPorts] = useState('22'); - const [tarpitDelayMs, setTarpitDelayMs] = useState(30000); - const tarpitMenuRef = useRef(null); - - useEffect(() => { - if (!tarpitMenuOpen) return; - const handler = (e: MouseEvent) => { - if (tarpitMenuRef.current && !tarpitMenuRef.current.contains(e.target as Node)) { - setTarpitMenuOpen(false); - } - }; - document.addEventListener('mousedown', handler); - return () => document.removeEventListener('mousedown', handler); - }, [tarpitMenuOpen]); - - const enableTarpit = useCallback(async () => { - const ports = tarpitPorts - .split(',') - .map((p) => parseInt(p.trim(), 10)) - .filter((p) => !isNaN(p) && p > 0 && p <= 65535); - if (ports.length === 0) return; - setTarpitBusy(true); - try { - await api.post(`/deckies/${encodeURIComponent(decky.name)}/tarpit`, { - ports, - delay_ms: tarpitDelayMs, - }); - setTarpitFormOpen(false); - setTarpitMenuOpen(false); - onTarpitResult(decky.name, true, `TARPIT ON · ${decky.name.toUpperCase()} · ${ports.join(',')} / ${tarpitDelayMs}ms`); - } catch (err) { - const msg = (err as ApiError)?.response?.data?.detail ?? 'Tarpit enable failed'; - onTarpitResult(decky.name, false, msg); - } finally { - setTarpitBusy(false); - } - }, [decky.name, tarpitPorts, tarpitDelayMs, onTarpitResult]); - - const disableTarpit = useCallback(async () => { - setTarpitBusy(true); - setTarpitMenuOpen(false); - try { - await api.delete(`/deckies/${encodeURIComponent(decky.name)}/tarpit`); - onTarpitResult(decky.name, true, `TARPIT OFF · ${decky.name.toUpperCase()}`); - } catch (err) { - const msg = (err as ApiError)?.response?.data?.detail ?? 'Tarpit disable failed'; - onTarpitResult(decky.name, false, msg); - } finally { - setTarpitBusy(false); - } - }, [decky.name, onTarpitResult]); - - const removeService = async (slug: string) => { - setOpError(null); - setBusy(slug); - try { - const { data } = await api.delete<{ services: string[] }>( - `/deckies/${encodeURIComponent(decky.name)}/services/${encodeURIComponent(slug)}`, - ); - onServicesChanged(decky.name, data.services); - } catch (err) { - const msg = (err as ApiError)?.response?.data?.detail - ?? 'Remove failed.'; - setOpError(msg); - } finally { - setBusy(null); - } - }; - - const beginAdd = () => { - if (!addSlug) return; - setOpError(null); - setPendingAdd({ deckyName: decky.name, slug: addSlug }); - }; - - const confirmAdd = async (deckyName: string, slug: string, cfg: Record) => { - setBusy(slug); - try { - const { data } = await api.post<{ services: string[] }>( - `/deckies/${encodeURIComponent(deckyName)}/services`, - { name: slug, config: cfg }, - ); - onServicesChanged(deckyName, data.services); - setPendingAdd(null); - setAddOpen(false); - setAddSlug(''); - } catch (err) { - // Re-raise so the modal can surface the error in its own status row. - // Also mirror onto opError for the inline picker case. - const msg = (err as ApiError)?.response?.data?.detail - ?? 'Add failed.'; - setOpError(msg); - throw err; - } finally { - setBusy(null); - } - }; - - return ( -
{ - if ((e.target as HTMLElement).closest('button, a, input')) return; - onInspect(decky); - }} - style={{ cursor: 'pointer' }} - > -
-
- - {decky.name} -
- {decky.ip} -
- - {decky.swarm && ( -
- - - {decky.swarm.host_name} - @ {decky.swarm.host_address || '—'} - - - {decky.swarm.state.toUpperCase()} - - {decky.swarm.last_error && ( - - ⚠ {decky.swarm.last_error.slice(0, 48)} - {decky.swarm.last_error.length > 48 ? '…' : ''} - - )} -
- )} - -
-
HOST{decky.hostname}
-
DISTRO{decky.distro}
-
- ARCHETYPE - {decky.archetype || '—'} -
-
- MUTATE - {!decky.swarm && isAdmin ? ( - onIntervalChange(decky.name, decky.mutate_interval)} - > - {decky.mutate_interval ? `EVERY ${decky.mutate_interval}m` : 'DISABLED'} - - ) : ( - - {decky.mutate_interval ? `EVERY ${decky.mutate_interval}m` : 'DISABLED'} - - )} -
-
- -
-
EXPOSED
-
- {decky.services.map((s) => ( - - {liveServicesEnabled ? ( - - ) : ( - {s} - )} - {liveServicesEnabled && ( - - )} - - ))} - {liveServicesEnabled && !addOpen && ( - - )} -
- {liveServicesEnabled && addOpen && ( -
e.stopPropagation()} - style={{ display: 'flex', gap: 6, marginTop: 6, alignItems: 'center' }} - > - - - -
- )} - {opError && ( -
{opError}
- )} - {liveServicesEnabled && openCfgSvc && decky.services.includes(openCfgSvc) && ( -
e.stopPropagation()}> - -
- )} -
- -
- - HITS 24h: - - {hits} - - -
- {!decky.swarm && isAdmin && ( - - )} - {decky.swarm && isAdmin && ( - - )} - {liveServicesEnabled && ( -
- - {tarpitMenuOpen && ( -
- - -
- )} -
- )} -
-
- - {liveServicesEnabled && tarpitFormOpen && ( -
e.stopPropagation()} - > -
- - setTarpitPorts(e.target.value)} - style={{ flex: 1 }} - /> -
-
- - setTarpitDelayMs(parseInt(e.target.value, 10))} - style={{ flex: 1 }} - /> - - {tarpitDelayMs >= 1000 ? `${(tarpitDelayMs / 1000).toFixed(1)}s` : `${tarpitDelayMs}ms`} - -
-
- - -
-
- )} - setPendingAdd(null)} - onConfirm={confirmAdd} - /> -
- ); -}; +import { DeckyCard } from './DeckyFleet/DeckyCard'; // ─── Deploy wizard ──────────────────────────────────────────────────────── diff --git a/decnet_web/src/components/DeckyFleet/DeckyCard.test.tsx b/decnet_web/src/components/DeckyFleet/DeckyCard.test.tsx new file mode 100644 index 00000000..c6d350f2 --- /dev/null +++ b/decnet_web/src/components/DeckyFleet/DeckyCard.test.tsx @@ -0,0 +1,115 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { DeckyCard } from './DeckyCard'; +import { makeDecky } from '../../test/fixtures'; + +// AddServiceConfigModal hits the network for schema; ServiceConfigForm +// also fetches. Both are unrelated to what DeckyCard's own tests cover. +vi.mock('../AddServiceConfigModal', () => ({ + default: () => null, +})); +vi.mock('../ServiceConfigForm', () => ({ + default: () => null, +})); + +const baseProps = { + mutating: false, + isAdmin: false, + armed: null, + tdBusy: false, + onForce: () => {}, + onTeardown: () => {}, + onIntervalChange: () => {}, + onInspect: () => {}, + availableServices: [], + onServicesChanged: () => {}, + onTarpitResult: () => {}, +}; + +describe('DeckyCard', () => { + it('renders the decky name + IP and the rendered service tags', () => { + render( + , + ); + expect(screen.getByText('decoy-99')).toBeInTheDocument(); + expect(screen.getByText('10.0.0.99')).toBeInTheDocument(); + expect(screen.getByText('ssh')).toBeInTheDocument(); + expect(screen.getByText('http')).toBeInTheDocument(); + }); + + it('renders FORCE MUTATE only for admins on non-swarm deckies', () => { + const { rerender } = render( + , + ); + expect(screen.queryByText(/FORCE MUTATE/)).not.toBeInTheDocument(); + + rerender( + , + ); + expect(screen.getByText('FORCE MUTATE')).toBeInTheDocument(); + }); + + it('FORCE MUTATE click invokes onForce with the decky name', async () => { + const onForce = vi.fn(); + const user = userEvent.setup(); + render( + , + ); + await user.click(screen.getByText('FORCE MUTATE')); + expect(onForce).toHaveBeenCalledWith('decoy-77'); + }); + + it('shows TEARDOWN (admin + swarm) and CONFIRM when armed key matches', () => { + const swarmDecky = makeDecky({ + name: 'decoy-swarm', + swarm: { + host_uuid: 'h-1', + host_name: 'edge-1', + host_address: 'edge-1.example', + host_status: 'ok', + state: 'running', + last_error: null, + last_seen: null, + }, + }); + const { rerender } = render( + , + ); + expect(screen.getByText('TEARDOWN')).toBeInTheDocument(); + + rerender( + , + ); + expect(screen.getByText('CONFIRM')).toBeInTheDocument(); + }); + + it('clicking the card body fires onInspect', async () => { + const onInspect = vi.fn(); + const user = userEvent.setup(); + render( + , + ); + // Click on a non-button element inside the card. + await user.click(screen.getByText('decoy-hit')); + expect(onInspect).toHaveBeenCalled(); + expect(onInspect.mock.calls[0][0].name).toBe('decoy-hit'); + }); +}); diff --git a/decnet_web/src/components/DeckyFleet/DeckyCard.tsx b/decnet_web/src/components/DeckyFleet/DeckyCard.tsx new file mode 100644 index 00000000..cbb09523 --- /dev/null +++ b/decnet_web/src/components/DeckyFleet/DeckyCard.tsx @@ -0,0 +1,458 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { Network, Plus, PowerOff, RefreshCw, X } from '../../icons'; +import api, { type ApiError } from '../../utils/api'; +import AddServiceConfigModal from '../AddServiceConfigModal'; +import ServiceConfigForm from '../ServiceConfigForm'; +import { dotFor, hitsFor, stateColor } from './helpers'; +import type { Decky } from './types'; + +interface Props { + decky: Decky; + mutating: boolean; + isAdmin: boolean; + armed: string | null; + tdBusy: boolean; + onForce: (name: string) => void; + onTeardown: (d: Decky) => void; + onIntervalChange: (name: string, current: number | null) => void; + onInspect: (d: Decky) => void; + innerRef?: React.Ref; + /** Per-decky-eligible service slugs from useServiceRegistry. */ + availableServices: string[]; + /** Called after a successful live add/remove so the parent can + * optimistically apply the response's services list. */ + onServicesChanged: (deckyName: string, services: string[]) => void; + /** Called after a tarpit enable/disable with success or error text. */ + onTarpitResult: (deckyName: string, ok: boolean, message: string) => void; +} + +/** Single decky tile rendered inside the fleet grid. Owns its own + * add-service / tarpit / per-service-config local UI state; all + * data + lifecycle decisions come in via props from the parent. */ +export const DeckyCard: React.FC = ({ + decky, mutating, isAdmin, armed, tdBusy, onForce, onTeardown, onIntervalChange, onInspect, + innerRef, availableServices, onServicesChanged, onTarpitResult, +}) => { + const dot = dotFor(decky); + const hits = hitsFor(decky); + const hot = dot === 'hot'; + const dotClass = mutating ? 'mutating' : dot; + const tdKey = decky.swarm ? `td:${decky.swarm.host_uuid}:${decky.name}` : ''; + + // Live service mutation is local-only (admin, non-swarm). Swarm + // deckies live on a remote agent — the W3 path runs docker compose + // locally and won't reach the agent's containers (same gap as the + // canary planter has for agent-pinned topologies; out of scope here). + const liveServicesEnabled = isAdmin && !decky.swarm; + const [addOpen, setAddOpen] = useState(false); + const [addSlug, setAddSlug] = useState(''); + const [busy, setBusy] = useState(null); + const [opError, setOpError] = useState(null); + const [openCfgSvc, setOpenCfgSvc] = useState(null); + // Pending add — when non-null, AddServiceConfigModal is mounted and + // will either auto-fire onConfirm (no schema fields) or show the form. + const [pendingAdd, setPendingAdd] = useState<{ deckyName: string; slug: string } | null>(null); + + // Tarpit controls — admin + non-swarm only (same gate as liveServicesEnabled) + const [tarpitMenuOpen, setTarpitMenuOpen] = useState(false); + const [tarpitFormOpen, setTarpitFormOpen] = useState(false); + const [tarpitBusy, setTarpitBusy] = useState(false); + const [tarpitPorts, setTarpitPorts] = useState('22'); + const [tarpitDelayMs, setTarpitDelayMs] = useState(30000); + const tarpitMenuRef = useRef(null); + + useEffect(() => { + if (!tarpitMenuOpen) return; + const handler = (e: MouseEvent) => { + if (tarpitMenuRef.current && !tarpitMenuRef.current.contains(e.target as Node)) { + setTarpitMenuOpen(false); + } + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [tarpitMenuOpen]); + + const enableTarpit = useCallback(async () => { + const ports = tarpitPorts + .split(',') + .map((p) => parseInt(p.trim(), 10)) + .filter((p) => !isNaN(p) && p > 0 && p <= 65535); + if (ports.length === 0) return; + setTarpitBusy(true); + try { + await api.post(`/deckies/${encodeURIComponent(decky.name)}/tarpit`, { + ports, + delay_ms: tarpitDelayMs, + }); + setTarpitFormOpen(false); + setTarpitMenuOpen(false); + onTarpitResult(decky.name, true, `TARPIT ON · ${decky.name.toUpperCase()} · ${ports.join(',')} / ${tarpitDelayMs}ms`); + } catch (err) { + const msg = (err as ApiError)?.response?.data?.detail ?? 'Tarpit enable failed'; + onTarpitResult(decky.name, false, msg); + } finally { + setTarpitBusy(false); + } + }, [decky.name, tarpitPorts, tarpitDelayMs, onTarpitResult]); + + const disableTarpit = useCallback(async () => { + setTarpitBusy(true); + setTarpitMenuOpen(false); + try { + await api.delete(`/deckies/${encodeURIComponent(decky.name)}/tarpit`); + onTarpitResult(decky.name, true, `TARPIT OFF · ${decky.name.toUpperCase()}`); + } catch (err) { + const msg = (err as ApiError)?.response?.data?.detail ?? 'Tarpit disable failed'; + onTarpitResult(decky.name, false, msg); + } finally { + setTarpitBusy(false); + } + }, [decky.name, onTarpitResult]); + + const removeService = async (slug: string) => { + setOpError(null); + setBusy(slug); + try { + const { data } = await api.delete<{ services: string[] }>( + `/deckies/${encodeURIComponent(decky.name)}/services/${encodeURIComponent(slug)}`, + ); + onServicesChanged(decky.name, data.services); + } catch (err) { + const msg = (err as ApiError)?.response?.data?.detail + ?? 'Remove failed.'; + setOpError(msg); + } finally { + setBusy(null); + } + }; + + const beginAdd = () => { + if (!addSlug) return; + setOpError(null); + setPendingAdd({ deckyName: decky.name, slug: addSlug }); + }; + + const confirmAdd = async (deckyName: string, slug: string, cfg: Record) => { + setBusy(slug); + try { + const { data } = await api.post<{ services: string[] }>( + `/deckies/${encodeURIComponent(deckyName)}/services`, + { name: slug, config: cfg }, + ); + onServicesChanged(deckyName, data.services); + setPendingAdd(null); + setAddOpen(false); + setAddSlug(''); + } catch (err) { + // Re-raise so the modal can surface the error in its own status row. + // Also mirror onto opError for the inline picker case. + const msg = (err as ApiError)?.response?.data?.detail + ?? 'Add failed.'; + setOpError(msg); + throw err; + } finally { + setBusy(null); + } + }; + + return ( +
{ + if ((e.target as HTMLElement).closest('button, a, input')) return; + onInspect(decky); + }} + style={{ cursor: 'pointer' }} + > +
+
+ + {decky.name} +
+ {decky.ip} +
+ + {decky.swarm && ( +
+ + + {decky.swarm.host_name} + @ {decky.swarm.host_address || '—'} + + + {decky.swarm.state.toUpperCase()} + + {decky.swarm.last_error && ( + + ⚠ {decky.swarm.last_error.slice(0, 48)} + {decky.swarm.last_error.length > 48 ? '…' : ''} + + )} +
+ )} + +
+
HOST{decky.hostname}
+
DISTRO{decky.distro}
+
+ ARCHETYPE + {decky.archetype || '—'} +
+
+ MUTATE + {!decky.swarm && isAdmin ? ( + onIntervalChange(decky.name, decky.mutate_interval)} + > + {decky.mutate_interval ? `EVERY ${decky.mutate_interval}m` : 'DISABLED'} + + ) : ( + + {decky.mutate_interval ? `EVERY ${decky.mutate_interval}m` : 'DISABLED'} + + )} +
+
+ +
+
EXPOSED
+
+ {decky.services.map((s) => ( + + {liveServicesEnabled ? ( + + ) : ( + {s} + )} + {liveServicesEnabled && ( + + )} + + ))} + {liveServicesEnabled && !addOpen && ( + + )} +
+ {liveServicesEnabled && addOpen && ( +
e.stopPropagation()} + style={{ display: 'flex', gap: 6, marginTop: 6, alignItems: 'center' }} + > + + + +
+ )} + {opError && ( +
{opError}
+ )} + {liveServicesEnabled && openCfgSvc && decky.services.includes(openCfgSvc) && ( +
e.stopPropagation()}> + +
+ )} +
+ +
+ + HITS 24h: + + {hits} + + +
+ {!decky.swarm && isAdmin && ( + + )} + {decky.swarm && isAdmin && ( + + )} + {liveServicesEnabled && ( +
+ + {tarpitMenuOpen && ( +
+ + +
+ )} +
+ )} +
+
+ + {liveServicesEnabled && tarpitFormOpen && ( +
e.stopPropagation()} + > +
+ + setTarpitPorts(e.target.value)} + style={{ flex: 1 }} + /> +
+
+ + setTarpitDelayMs(parseInt(e.target.value, 10))} + style={{ flex: 1 }} + /> + + {tarpitDelayMs >= 1000 ? `${(tarpitDelayMs / 1000).toFixed(1)}s` : `${tarpitDelayMs}ms`} + +
+
+ + +
+
+ )} + setPendingAdd(null)} + onConfirm={confirmAdd} + /> +
+ ); +};