From de63a0ab5c8a3e8d461941f2ec165c73b5e7cffb Mon Sep 17 00:00:00 2001 From: anti Date: Wed, 22 Apr 2026 17:15:45 -0400 Subject: [PATCH] feat(web/fleet): DeckyFleet reskin, inspect drawer, and modal retrofit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fleet grid rewrite: richer decky cards (archetype, services, swarm chip, mutation status) with click-to-inspect. - Deploy wizard: track server-accepted deploys separately so the placeholder log stream only auto-closes on success; surface failures. - DeployWizard + IntervalEditor migrated to the shared primitive — gains ESC-close, backdrop click, Tab focus trap, and body scroll lock without changing visual design. --- decnet_web/src/components/DeckyFleet.css | 14 +- decnet_web/src/components/DeckyFleet.tsx | 351 +++++++++++++++++++---- 2 files changed, 300 insertions(+), 65 deletions(-) diff --git a/decnet_web/src/components/DeckyFleet.css b/decnet_web/src/components/DeckyFleet.css index 26d779e9..374dd0b6 100644 --- a/decnet_web/src/components/DeckyFleet.css +++ b/decnet_web/src/components/DeckyFleet.css @@ -78,7 +78,7 @@ transition: all 0.3s; position: relative; } -.decky-card:hover { border-color: var(--violet); box-shadow: var(--violet-glow); } +.decky-card:hover { border-color: var(--accent); box-shadow: var(--accent-glow); } .decky-card.hot { border-color: var(--alert); } .decky-card.hot::before { content: ''; @@ -103,6 +103,7 @@ letter-spacing: 1px; display: flex; align-items: center; + gap: 10px; } .decky-ip { font-size: 0.7rem; @@ -132,7 +133,6 @@ width: 8px; height: 8px; border-radius: 50%; - margin-right: 6px; flex-shrink: 0; } .status-dot.active { background: var(--matrix); box-shadow: 0 0 8px var(--matrix); } @@ -325,11 +325,17 @@ .fleet-empty { grid-column: 1 / -1; padding: 48px 24px; - text-align: center; - opacity: 0.5; border: 1px dashed var(--border); background: var(--panel); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 14px; + letter-spacing: 1px; + font-size: 0.85rem; } +.fleet-empty .dim { opacity: 0.5; } /* Animations */ @keyframes dfleet-pulse { from { opacity: 0.5; } to { opacity: 1; } } diff --git a/decnet_web/src/components/DeckyFleet.tsx b/decnet_web/src/components/DeckyFleet.tsx index 2a505683..45154739 100644 --- a/decnet_web/src/components/DeckyFleet.tsx +++ b/decnet_web/src/components/DeckyFleet.tsx @@ -1,10 +1,12 @@ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { Cpu, Database, Globe, Monitor, Network, PlusCircle, PowerOff, - RefreshCw, Server, Shield, Terminal, X, + RefreshCw, Server, Shield, Terminal, } from 'lucide-react'; import api from '../utils/api'; import { ARCHETYPES as FALLBACK_ARCHETYPES, DEFAULT_SERVICES } from './MazeNET/data'; +import { useToast } from './Toasts/useToast'; +import Modal from './Modal/Modal'; import './DeckyFleet.css'; // ─── Types ──────────────────────────────────────────────────────────────── @@ -126,10 +128,12 @@ interface DeckyCardProps { onForce: (name: string) => void; onTeardown: (d: Decky) => void; onIntervalChange: (name: string, current: number | null) => void; + onInspect: (d: Decky) => void; + innerRef?: React.Ref; } const DeckyCard: React.FC = ({ - decky, mutating, isAdmin, armed, tdBusy, onForce, onTeardown, onIntervalChange, + decky, mutating, isAdmin, armed, tdBusy, onForce, onTeardown, onIntervalChange, onInspect, innerRef, }) => { const dot = _dotFor(decky); const hits = _hitsFor(decky); @@ -138,7 +142,15 @@ const DeckyCard: React.FC = ({ const tdKey = decky.swarm ? `td:${decky.swarm.host_uuid}:${decky.name}` : ''; return ( -
+
{ + if ((e.target as HTMLElement).closest('button, a, input')) return; + onInspect(decky); + }} + style={{ cursor: 'pointer' }} + >
@@ -250,7 +262,7 @@ const DeckyCard: React.FC = ({ interface DeployWizardProps { open: boolean; onClose: () => void; - onComplete: () => void; + onComplete: (count: number) => void; archetypes: Archetype[]; fleetSize: number; } @@ -337,7 +349,11 @@ const DeployWizard: React.FC = ({ return out; }, [count, prefix, fleetSize, effectiveArchetypeName, effectiveServices]); - // Fake log stream during "deploying". + const [deployOk, setDeployOk] = useState(false); + const [deployFailures, setDeployFailures] = useState([]); + + // Fake log stream during "deploying" (runs as visual backdrop; real API + // lines are spliced in by startDeploy once the HTTP call resolves). useEffect(() => { if (step !== 3 || !deploying) return; const msgs = PLACEHOLDER_LINES(effectiveArchetypeName, effectiveServices, count, fleetSize); @@ -347,13 +363,14 @@ const DeployWizard: React.FC = ({ i++; if (i >= msgs.length) { window.clearInterval(t); - window.setTimeout(() => onComplete(), 500); + // Only auto-close if the server accepted. + if (deployOk) { + window.setTimeout(() => onComplete(count), 500); + } } }, 420); return () => window.clearInterval(t); - }, [step, deploying, effectiveArchetypeName, effectiveServices, count, fleetSize, onComplete]); - - if (!open) return null; + }, [step, deploying, effectiveArchetypeName, effectiveServices, count, fleetSize, onComplete, deployOk]); const canNext = step === 0 ? (pickMode === 'archetype' ? !!archetype : selectedServices.length > 0) @@ -362,13 +379,28 @@ const DeployWizard: React.FC = ({ const startDeploy = async () => { setDeployErr(null); setLog([]); + setDeployOk(false); + setDeployFailures([]); setDeploying(true); const ini = _buildIni( prefix, count, fleetSize, pickMode, archetype, selectedServices, mutate, mutateEvery, ); try { - await api.post('/deckies/deploy', { ini_content: ini }, { timeout: 180000 }); + const res = await api.post<{ failures?: { name: string; reason: string }[] }>( + '/deckies/deploy', + { ini_content: ini }, + { timeout: 180000 }, + ); + const failures = res.data?.failures ?? []; + setDeployFailures(failures.map(f => `[FAIL] ${f.name}: ${f.reason}`)); + if (failures.length > 0) { + setLog(prev => [...prev, `[OK] server accepted ${count - failures.length}/${count}`, + ...failures.map(f => `[FAIL] ${f.name}: ${f.reason}`)]); + } else { + setLog(prev => [...prev, `[OK] server accepted ${count} deckies`]); + } + setDeployOk(true); } catch (e: unknown) { const err = e as { response?: { data?: { detail?: string } }; message?: string }; setDeployErr(err?.response?.data?.detail || err?.message || 'Deploy failed'); @@ -382,18 +414,45 @@ const DeployWizard: React.FC = ({ }; return ( -
-
e.stopPropagation()}> -
-

- - DEPLOY NEW DECKIES -

- -
- +
+ {step > 0 && !deploying && ( + + )} + {step < 3 && ( + + )} + {step === 3 && !deploying && ( + + )} + {step === 3 && deploying && !deployOk && ( + + )} + {step === 3 && deployOk && deployFailures.length > 0 && ( + + )} +
+ + } + > + <>
{['ARCHETYPE', 'CONFIGURATION', 'MUTATION', 'DEPLOY'].map((l, i) => (
@@ -610,33 +669,81 @@ const DeployWizard: React.FC = ({ )}
-
+ + + ); +}; + +// ─── Interval editor modal ─────────────────────────────────────────────── + +interface IntervalEditorProps { + open: boolean; + deckyName: string; + current: number | null; + onClose: () => void; + onSave: (minutes: number | null) => void; +} + +const IntervalEditor: React.FC = ({ open, deckyName, current, onClose, onSave }) => { + const [enabled, setEnabled] = useState(current !== null); + const [minutes, setMinutes] = useState(current ?? 30); + + return ( + -
- {step > 0 && !deploying && ( - - )} - {step < 3 && ( - - )} - {step === 3 && !deploying && ( - - )} - {step === 3 && deploying && log.length < 7 && ( - - )} -
+ + + } + > +
+
+ setEnabled(e.target.checked)} + style={{ accentColor: 'var(--matrix)' }} + /> +
+ {enabled && ( +
+ + setMinutes(parseInt(e.target.value, 10))} + /> +
+ Applied on the next mutation cycle. +
+
+ )}
-
+ ); }; // ─── Fleet page ────────────────────────────────────────────────────────── -const DeckyFleet: React.FC = () => { +interface FleetProps { + searchQuery?: string; +} + +const DeckyFleet: React.FC = ({ searchQuery = '' }) => { + const { push } = useToast(); const [deckies, setDeckies] = useState([]); const [loading, setLoading] = useState(true); const [mutating, setMutating] = useState(null); @@ -647,6 +754,17 @@ const DeckyFleet: React.FC = () => { 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 cardRefs = useRef>(new Map()); + + 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); + } const arm = (key: string) => { setArmed(key); @@ -726,37 +844,70 @@ const DeckyFleet: React.FC = () => { } }; - const handleMutate = async (name: string) => { + const handleMutate = async (name: string): Promise => { setMutating(name); try { await api.post(`/deckies/${name}/mutate`, {}, { timeout: 120000 }); await fetchDeckies(deployMode?.mode); + 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 }; - if (e.code === 'ECONNABORTED') { - alert('Mutation is still running in the background but the UI timed out.'); - } else { - alert('Mutation failed'); - } + 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 handleIntervalChange = async (name: string, current: number | null) => { - const val = prompt( - `Enter new mutation interval in minutes for ${name} (leave empty to disable):`, - current?.toString() || '', - ); - if (val === null) return; - const mutate_interval = val.trim() === '' ? null : parseInt(val, 10); + 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; try { - await api.put(`/deckies/${name}/mutate-interval`, { mutate_interval }); + await api.put(`/deckies/${name}/mutate-interval`, { mutate_interval: minutes }); + setIntervalEditor(null); fetchDeckies(deployMode?.mode); + push({ + text: minutes === null + ? `INTERVAL · ${name.toUpperCase()} · DISABLED` + : `INTERVAL · ${name.toUpperCase()} · ${minutes}m`, + tone: 'matrix', + icon: 'refresh-cw', + }); } catch (err) { console.error('Failed to update interval', err); - alert('Update failed'); + push({ text: `INTERVAL UPDATE FAILED · ${name.toUpperCase()}`, tone: 'alert', icon: 'alert-triangle' }); } }; @@ -769,9 +920,14 @@ const DeckyFleet: React.FC = () => { try { 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' }); } catch (err: unknown) { const e = err as { response?: { data?: { detail?: string } } }; - alert(e?.response?.data?.detail || 'Teardown failed'); + push({ + text: `TEARDOWN FAILED · ${e?.response?.data?.detail || d.name}`, + tone: 'alert', + icon: 'alert-triangle', + }); } finally { setTearingDown((prev) => { const next = new Set(prev); @@ -781,6 +937,12 @@ const DeckyFleet: React.FC = () => { } }; + const handleInspect = (d: Decky) => { + window.dispatchEvent(new CustomEvent('decnet:cmd', { + detail: { id: 'filter-decky', payload: d.name }, + })); + }; + useEffect(() => { let cancelled = false; (async () => { @@ -795,6 +957,35 @@ const DeckyFleet: React.FC = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // Phase-2 decnet:cmd bus: deploy, mutate-all, filter-decky + 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; + } + if (detail.id === 'filter-decky' && typeof detail.payload === 'string') { + const name = detail.payload; + setLocalSearch(name); + push({ text: `FILTERING · ${name.toUpperCase()}`, tone: 'violet', icon: 'crosshair' }); + // Defer so React renders filtered grid first. + window.setTimeout(() => { + const el = cardRefs.current.get(name); + if (el) el.scrollIntoView({ block: 'center', behavior: 'smooth' }); + }, 80); + } + }; + 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; for (const d of deckies) { @@ -804,7 +995,16 @@ const DeckyFleet: React.FC = () => { return c; }, [deckies]); - const visible = filter === 'all' ? deckies : deckies.filter((d) => _dotFor(d) === filter); + 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]); const isSwarm = deployMode?.mode === 'swarm'; if (loading) { @@ -854,7 +1054,17 @@ const DeckyFleet: React.FC = () => {
{visible.length === 0 ? (
- NO DECOYS CURRENTLY DEPLOYED IN THIS SECTOR + + + {deckies.length === 0 + ? 'NO DECOYS DEPLOYED IN THIS SECTOR' + : 'NO DECOYS MATCH CURRENT FILTER'} + + {isAdmin && deckies.length === 0 && ( + + )}
) : ( visible.map((d) => ( @@ -865,9 +1075,14 @@ const DeckyFleet: React.FC = () => { isAdmin={isAdmin} armed={armed} tdBusy={tearingDown.has(d.name) || d.swarm?.state === 'tearing_down'} - onForce={handleMutate} + onForce={(name) => { void handleMutate(name); }} onTeardown={handleTeardown} onIntervalChange={handleIntervalChange} + onInspect={handleInspect} + innerRef={(el: HTMLDivElement | null) => { + if (el) cardRefs.current.set(d.name, el); + else cardRefs.current.delete(d.name); + }} /> )) )} @@ -878,11 +1093,25 @@ const DeckyFleet: React.FC = () => { archetypes={archetypes} fleetSize={deckies.length} onClose={() => setShowDeploy(false)} - onComplete={() => { + onComplete={(count) => { setShowDeploy(false); fetchDeckies(deployMode?.mode); + push({ + text: `DEPLOYED · ${count} DECK${count === 1 ? 'Y' : 'IES'}`, + tone: 'matrix', + icon: 'check-circle', + }); }} /> + + setIntervalEditor(null)} + onSave={handleIntervalSave} + />
); };