feat(web/fleet): DeckyFleet reskin, inspect drawer, and modal retrofit

- 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 <Modal>
  primitive — gains ESC-close, backdrop click, Tab focus trap, and
  body scroll lock without changing visual design.
This commit is contained in:
2026-04-22 17:15:45 -04:00
parent e14527b382
commit de63a0ab5c
2 changed files with 300 additions and 65 deletions

View File

@@ -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; } }

View File

@@ -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<HTMLDivElement>;
}
const DeckyCard: React.FC<DeckyCardProps> = ({
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<DeckyCardProps> = ({
const tdKey = decky.swarm ? `td:${decky.swarm.host_uuid}:${decky.name}` : '';
return (
<div className={`decky-card ${hot ? 'hot' : ''}`}>
<div
ref={innerRef}
className={`decky-card ${hot ? 'hot' : ''}`}
onClick={(e) => {
if ((e.target as HTMLElement).closest('button, a, input')) return;
onInspect(decky);
}}
style={{ cursor: 'pointer' }}
>
<div className="decky-head">
<div className="decky-name">
<span className={`status-dot ${dotClass}`} />
@@ -250,7 +262,7 @@ const DeckyCard: React.FC<DeckyCardProps> = ({
interface DeployWizardProps {
open: boolean;
onClose: () => void;
onComplete: () => void;
onComplete: (count: number) => void;
archetypes: Archetype[];
fleetSize: number;
}
@@ -337,7 +349,11 @@ const DeployWizard: React.FC<DeployWizardProps> = ({
return out;
}, [count, prefix, fleetSize, effectiveArchetypeName, effectiveServices]);
// Fake log stream during "deploying".
const [deployOk, setDeployOk] = useState(false);
const [deployFailures, setDeployFailures] = useState<string[]>([]);
// 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<DeployWizardProps> = ({
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<DeployWizardProps> = ({
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<DeployWizardProps> = ({
};
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal wide violet" onClick={(e) => e.stopPropagation()}>
<div className="modal-head">
<h3>
<PlusCircle size={14} />
DEPLOY NEW DECKIES
</h3>
<button className="close-btn" onClick={onClose} aria-label="Close">
<X size={16} />
<Modal
open={open}
onClose={onClose}
title="DEPLOY NEW DECKIES"
icon={PlusCircle}
accent="violet"
width="wide"
footer={
<>
<button
className="btn ghost"
onClick={onClose}
disabled={deploying && !deployOk}
>
{deployOk ? 'CLOSE' : 'CANCEL'}
</button>
</div>
<div style={{ display: 'flex', gap: 8 }}>
{step > 0 && !deploying && (
<button className="btn ghost" onClick={() => setStep((s) => s - 1)}> BACK</button>
)}
{step < 3 && (
<button className="btn" disabled={!canNext} onClick={() => setStep((s) => s + 1)}>
NEXT
</button>
)}
{step === 3 && !deploying && (
<button className="btn violet" onClick={startDeploy}>ESTABLISH FLEET</button>
)}
{step === 3 && deploying && !deployOk && (
<button className="btn" disabled>DEPLOYING...</button>
)}
{step === 3 && deployOk && deployFailures.length > 0 && (
<button className="btn alert" disabled>{deployFailures.length} FAILED</button>
)}
</div>
</>
}
>
<>
<div className="wizard-steps">
{['ARCHETYPE', 'CONFIGURATION', 'MUTATION', 'DEPLOY'].map((l, i) => (
<div key={l} className={`wizard-step ${i === step ? 'active' : i < step ? 'done' : ''}`}>
@@ -610,33 +669,81 @@ const DeployWizard: React.FC<DeployWizardProps> = ({
)}
</div>
<div className="modal-foot">
</>
</Modal>
);
};
// ─── Interval editor modal ───────────────────────────────────────────────
interface IntervalEditorProps {
open: boolean;
deckyName: string;
current: number | null;
onClose: () => void;
onSave: (minutes: number | null) => void;
}
const IntervalEditor: React.FC<IntervalEditorProps> = ({ open, deckyName, current, onClose, onSave }) => {
const [enabled, setEnabled] = useState<boolean>(current !== null);
const [minutes, setMinutes] = useState<number>(current ?? 30);
return (
<Modal
open={open}
onClose={onClose}
title={`MUTATION INTERVAL · ${deckyName}`}
icon={RefreshCw}
accent="violet"
footer={
<>
<button className="btn ghost" onClick={onClose}>CANCEL</button>
<div style={{ display: 'flex', gap: 8 }}>
{step > 0 && !deploying && (
<button className="btn ghost" onClick={() => setStep((s) => s - 1)}>← BACK</button>
)}
{step < 3 && (
<button className="btn" disabled={!canNext} onClick={() => setStep((s) => s + 1)}>
NEXT →
</button>
)}
{step === 3 && !deploying && (
<button className="btn violet" onClick={startDeploy}>ESTABLISH FLEET</button>
)}
{step === 3 && deploying && log.length < 7 && (
<button className="btn" disabled>DEPLOYING...</button>
)}
</div>
<button className="btn violet" onClick={() => onSave(enabled ? minutes : null)}>SAVE</button>
</>
}
>
<div className="modal-body">
<div style={{ display: 'flex', gap: 10, alignItems: 'center', padding: 14, border: '1px solid var(--border)' }}>
<input
id="interval-enable"
type="checkbox"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
style={{ accentColor: 'var(--matrix)' }}
/>
<label htmlFor="interval-enable" style={{ fontSize: '0.8rem', letterSpacing: 1 }}>
ENABLE PERIODIC MUTATION
</label>
</div>
{enabled && (
<div className="tweak-group">
<label>INTERVAL ({minutes} minutes)</label>
<input
type="range"
min={5}
max={240}
step={5}
value={minutes}
onChange={(e) => setMinutes(parseInt(e.target.value, 10))}
/>
<div className="dim" style={{ fontSize: '0.65rem', letterSpacing: 1 }}>
Applied on the next mutation cycle.
</div>
</div>
)}
</div>
</div>
</Modal>
);
};
// ─── Fleet page ──────────────────────────────────────────────────────────
const DeckyFleet: React.FC = () => {
interface FleetProps {
searchQuery?: string;
}
const DeckyFleet: React.FC<FleetProps> = ({ searchQuery = '' }) => {
const { push } = useToast();
const [deckies, setDeckies] = useState<Decky[]>([]);
const [loading, setLoading] = useState(true);
const [mutating, setMutating] = useState<string | null>(null);
@@ -647,6 +754,17 @@ const DeckyFleet: React.FC = () => {
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 [intervalEditor, setIntervalEditor] = useState<{ name: string; current: number | null } | null>(null);
const cardRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const lastSearchPropRef = useRef<string>(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<boolean> => {
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<FilterKey, number>;
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 = () => {
<div className="grid-fleet">
{visible.length === 0 ? (
<div className="fleet-empty">
<span className="dim">NO DECOYS CURRENTLY DEPLOYED IN THIS SECTOR</span>
<Server size={32} className="dim" />
<span className="dim">
{deckies.length === 0
? '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) => (
@@ -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',
});
}}
/>
<IntervalEditor
key={intervalEditor?.name ?? 'closed'}
open={intervalEditor !== null}
deckyName={intervalEditor?.name ?? ''}
current={intervalEditor?.current ?? null}
onClose={() => setIntervalEditor(null)}
onSave={handleIntervalSave}
/>
</div>
);
};