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:
@@ -78,7 +78,7 @@
|
|||||||
transition: all 0.3s;
|
transition: all 0.3s;
|
||||||
position: relative;
|
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 { border-color: var(--alert); }
|
||||||
.decky-card.hot::before {
|
.decky-card.hot::before {
|
||||||
content: '';
|
content: '';
|
||||||
@@ -103,6 +103,7 @@
|
|||||||
letter-spacing: 1px;
|
letter-spacing: 1px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
}
|
}
|
||||||
.decky-ip {
|
.decky-ip {
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
@@ -132,7 +133,6 @@
|
|||||||
width: 8px;
|
width: 8px;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
margin-right: 6px;
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.status-dot.active { background: var(--matrix); box-shadow: 0 0 8px var(--matrix); }
|
.status-dot.active { background: var(--matrix); box-shadow: 0 0 8px var(--matrix); }
|
||||||
@@ -325,11 +325,17 @@
|
|||||||
.fleet-empty {
|
.fleet-empty {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
padding: 48px 24px;
|
padding: 48px 24px;
|
||||||
text-align: center;
|
|
||||||
opacity: 0.5;
|
|
||||||
border: 1px dashed var(--border);
|
border: 1px dashed var(--border);
|
||||||
background: var(--panel);
|
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 */
|
/* Animations */
|
||||||
@keyframes dfleet-pulse { from { opacity: 0.5; } to { opacity: 1; } }
|
@keyframes dfleet-pulse { from { opacity: 0.5; } to { opacity: 1; } }
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Cpu, Database, Globe, Monitor, Network, PlusCircle, PowerOff,
|
Cpu, Database, Globe, Monitor, Network, PlusCircle, PowerOff,
|
||||||
RefreshCw, Server, Shield, Terminal, X,
|
RefreshCw, Server, Shield, Terminal,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import api from '../utils/api';
|
import api from '../utils/api';
|
||||||
import { ARCHETYPES as FALLBACK_ARCHETYPES, DEFAULT_SERVICES } from './MazeNET/data';
|
import { ARCHETYPES as FALLBACK_ARCHETYPES, DEFAULT_SERVICES } from './MazeNET/data';
|
||||||
|
import { useToast } from './Toasts/useToast';
|
||||||
|
import Modal from './Modal/Modal';
|
||||||
import './DeckyFleet.css';
|
import './DeckyFleet.css';
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────
|
||||||
@@ -126,10 +128,12 @@ interface DeckyCardProps {
|
|||||||
onForce: (name: string) => void;
|
onForce: (name: string) => void;
|
||||||
onTeardown: (d: Decky) => void;
|
onTeardown: (d: Decky) => void;
|
||||||
onIntervalChange: (name: string, current: number | null) => void;
|
onIntervalChange: (name: string, current: number | null) => void;
|
||||||
|
onInspect: (d: Decky) => void;
|
||||||
|
innerRef?: React.Ref<HTMLDivElement>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DeckyCard: React.FC<DeckyCardProps> = ({
|
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 dot = _dotFor(decky);
|
||||||
const hits = _hitsFor(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}` : '';
|
const tdKey = decky.swarm ? `td:${decky.swarm.host_uuid}:${decky.name}` : '';
|
||||||
|
|
||||||
return (
|
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-head">
|
||||||
<div className="decky-name">
|
<div className="decky-name">
|
||||||
<span className={`status-dot ${dotClass}`} />
|
<span className={`status-dot ${dotClass}`} />
|
||||||
@@ -250,7 +262,7 @@ const DeckyCard: React.FC<DeckyCardProps> = ({
|
|||||||
interface DeployWizardProps {
|
interface DeployWizardProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onComplete: () => void;
|
onComplete: (count: number) => void;
|
||||||
archetypes: Archetype[];
|
archetypes: Archetype[];
|
||||||
fleetSize: number;
|
fleetSize: number;
|
||||||
}
|
}
|
||||||
@@ -337,7 +349,11 @@ const DeployWizard: React.FC<DeployWizardProps> = ({
|
|||||||
return out;
|
return out;
|
||||||
}, [count, prefix, fleetSize, effectiveArchetypeName, effectiveServices]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (step !== 3 || !deploying) return;
|
if (step !== 3 || !deploying) return;
|
||||||
const msgs = PLACEHOLDER_LINES(effectiveArchetypeName, effectiveServices, count, fleetSize);
|
const msgs = PLACEHOLDER_LINES(effectiveArchetypeName, effectiveServices, count, fleetSize);
|
||||||
@@ -347,13 +363,14 @@ const DeployWizard: React.FC<DeployWizardProps> = ({
|
|||||||
i++;
|
i++;
|
||||||
if (i >= msgs.length) {
|
if (i >= msgs.length) {
|
||||||
window.clearInterval(t);
|
window.clearInterval(t);
|
||||||
window.setTimeout(() => onComplete(), 500);
|
// Only auto-close if the server accepted.
|
||||||
|
if (deployOk) {
|
||||||
|
window.setTimeout(() => onComplete(count), 500);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, 420);
|
}, 420);
|
||||||
return () => window.clearInterval(t);
|
return () => window.clearInterval(t);
|
||||||
}, [step, deploying, effectiveArchetypeName, effectiveServices, count, fleetSize, onComplete]);
|
}, [step, deploying, effectiveArchetypeName, effectiveServices, count, fleetSize, onComplete, deployOk]);
|
||||||
|
|
||||||
if (!open) return null;
|
|
||||||
|
|
||||||
const canNext = step === 0
|
const canNext = step === 0
|
||||||
? (pickMode === 'archetype' ? !!archetype : selectedServices.length > 0)
|
? (pickMode === 'archetype' ? !!archetype : selectedServices.length > 0)
|
||||||
@@ -362,13 +379,28 @@ const DeployWizard: React.FC<DeployWizardProps> = ({
|
|||||||
const startDeploy = async () => {
|
const startDeploy = async () => {
|
||||||
setDeployErr(null);
|
setDeployErr(null);
|
||||||
setLog([]);
|
setLog([]);
|
||||||
|
setDeployOk(false);
|
||||||
|
setDeployFailures([]);
|
||||||
setDeploying(true);
|
setDeploying(true);
|
||||||
const ini = _buildIni(
|
const ini = _buildIni(
|
||||||
prefix, count, fleetSize, pickMode, archetype, selectedServices,
|
prefix, count, fleetSize, pickMode, archetype, selectedServices,
|
||||||
mutate, mutateEvery,
|
mutate, mutateEvery,
|
||||||
);
|
);
|
||||||
try {
|
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) {
|
} catch (e: unknown) {
|
||||||
const err = e as { response?: { data?: { detail?: string } }; message?: string };
|
const err = e as { response?: { data?: { detail?: string } }; message?: string };
|
||||||
setDeployErr(err?.response?.data?.detail || err?.message || 'Deploy failed');
|
setDeployErr(err?.response?.data?.detail || err?.message || 'Deploy failed');
|
||||||
@@ -382,18 +414,45 @@ const DeployWizard: React.FC<DeployWizardProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modal-backdrop" onClick={onClose}>
|
<Modal
|
||||||
<div className="modal wide violet" onClick={(e) => e.stopPropagation()}>
|
open={open}
|
||||||
<div className="modal-head">
|
onClose={onClose}
|
||||||
<h3>
|
title="DEPLOY NEW DECKIES"
|
||||||
<PlusCircle size={14} />
|
icon={PlusCircle}
|
||||||
DEPLOY NEW DECKIES
|
accent="violet"
|
||||||
</h3>
|
width="wide"
|
||||||
<button className="close-btn" onClick={onClose} aria-label="Close">
|
footer={
|
||||||
<X size={16} />
|
<>
|
||||||
|
<button
|
||||||
|
className="btn ghost"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={deploying && !deployOk}
|
||||||
|
>
|
||||||
|
{deployOk ? 'CLOSE' : 'CANCEL'}
|
||||||
</button>
|
</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">
|
<div className="wizard-steps">
|
||||||
{['ARCHETYPE', 'CONFIGURATION', 'MUTATION', 'DEPLOY'].map((l, i) => (
|
{['ARCHETYPE', 'CONFIGURATION', 'MUTATION', 'DEPLOY'].map((l, i) => (
|
||||||
<div key={l} className={`wizard-step ${i === step ? 'active' : i < step ? 'done' : ''}`}>
|
<div key={l} className={`wizard-step ${i === step ? 'active' : i < step ? 'done' : ''}`}>
|
||||||
@@ -610,33 +669,81 @@ const DeployWizard: React.FC<DeployWizardProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
<button className="btn ghost" onClick={onClose}>CANCEL</button>
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
<button className="btn violet" onClick={() => onSave(enabled ? minutes : null)}>SAVE</button>
|
||||||
{step > 0 && !deploying && (
|
</>
|
||||||
<button className="btn ghost" onClick={() => setStep((s) => s - 1)}>← BACK</button>
|
}
|
||||||
)}
|
>
|
||||||
{step < 3 && (
|
<div className="modal-body">
|
||||||
<button className="btn" disabled={!canNext} onClick={() => setStep((s) => s + 1)}>
|
<div style={{ display: 'flex', gap: 10, alignItems: 'center', padding: 14, border: '1px solid var(--border)' }}>
|
||||||
NEXT →
|
<input
|
||||||
</button>
|
id="interval-enable"
|
||||||
)}
|
type="checkbox"
|
||||||
{step === 3 && !deploying && (
|
checked={enabled}
|
||||||
<button className="btn violet" onClick={startDeploy}>ESTABLISH FLEET</button>
|
onChange={(e) => setEnabled(e.target.checked)}
|
||||||
)}
|
style={{ accentColor: 'var(--matrix)' }}
|
||||||
{step === 3 && deploying && log.length < 7 && (
|
/>
|
||||||
<button className="btn" disabled>DEPLOYING...</button>
|
<label htmlFor="interval-enable" style={{ fontSize: '0.8rem', letterSpacing: 1 }}>
|
||||||
)}
|
ENABLE PERIODIC MUTATION
|
||||||
</div>
|
</label>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</Modal>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Fleet page ──────────────────────────────────────────────────────────
|
// ─── 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 [deckies, setDeckies] = useState<Decky[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [mutating, setMutating] = useState<string | null>(null);
|
const [mutating, setMutating] = useState<string | null>(null);
|
||||||
@@ -647,6 +754,17 @@ const DeckyFleet: React.FC = () => {
|
|||||||
const [armed, setArmed] = useState<string | null>(null);
|
const [armed, setArmed] = useState<string | null>(null);
|
||||||
const [tearingDown, setTearingDown] = useState<Set<string>>(new Set());
|
const [tearingDown, setTearingDown] = useState<Set<string>>(new Set());
|
||||||
const [archetypes, setArchetypes] = useState<Archetype[]>(FALLBACK_ARCHETYPES);
|
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) => {
|
const arm = (key: string) => {
|
||||||
setArmed(key);
|
setArmed(key);
|
||||||
@@ -726,37 +844,70 @@ const DeckyFleet: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMutate = async (name: string) => {
|
const handleMutate = async (name: string): Promise<boolean> => {
|
||||||
setMutating(name);
|
setMutating(name);
|
||||||
try {
|
try {
|
||||||
await api.post(`/deckies/${name}/mutate`, {}, { timeout: 120000 });
|
await api.post(`/deckies/${name}/mutate`, {}, { timeout: 120000 });
|
||||||
await fetchDeckies(deployMode?.mode);
|
await fetchDeckies(deployMode?.mode);
|
||||||
|
push({ text: `MUTATED · ${name.toUpperCase()}`, tone: 'matrix', icon: 'refresh-cw' });
|
||||||
|
return true;
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.error('Failed to mutate', err);
|
console.error('Failed to mutate', err);
|
||||||
const e = err as { code?: string };
|
const e = err as { code?: string };
|
||||||
if (e.code === 'ECONNABORTED') {
|
const msg = e.code === 'ECONNABORTED'
|
||||||
alert('Mutation is still running in the background but the UI timed out.');
|
? `MUTATION TIMED OUT · ${name.toUpperCase()}`
|
||||||
} else {
|
: `MUTATION FAILED · ${name.toUpperCase()}`;
|
||||||
alert('Mutation failed');
|
push({ text: msg, tone: 'alert', icon: 'alert-triangle' });
|
||||||
}
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
setMutating(null);
|
setMutating(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleIntervalChange = async (name: string, current: number | null) => {
|
const handleMutateAll = async () => {
|
||||||
const val = prompt(
|
if (!isAdmin) {
|
||||||
`Enter new mutation interval in minutes for ${name} (leave empty to disable):`,
|
push({ text: 'ADMIN REQUIRED', tone: 'alert', icon: 'alert-triangle' });
|
||||||
current?.toString() || '',
|
return;
|
||||||
);
|
}
|
||||||
if (val === null) return;
|
const targets = deckies.filter(d => !d.swarm || d.swarm.state === 'running');
|
||||||
const mutate_interval = val.trim() === '' ? null : parseInt(val, 10);
|
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 {
|
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);
|
fetchDeckies(deployMode?.mode);
|
||||||
|
push({
|
||||||
|
text: minutes === null
|
||||||
|
? `INTERVAL · ${name.toUpperCase()} · DISABLED`
|
||||||
|
: `INTERVAL · ${name.toUpperCase()} · ${minutes}m`,
|
||||||
|
tone: 'matrix',
|
||||||
|
icon: 'refresh-cw',
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to update interval', 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 {
|
try {
|
||||||
await api.post(`/swarm/hosts/${d.swarm.host_uuid}/teardown`, { decky_id: d.name });
|
await api.post(`/swarm/hosts/${d.swarm.host_uuid}/teardown`, { decky_id: d.name });
|
||||||
await fetchDeckies(deployMode?.mode);
|
await fetchDeckies(deployMode?.mode);
|
||||||
|
push({ text: `TORN DOWN · ${d.name.toUpperCase()}`, tone: 'matrix', icon: 'check-circle' });
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const e = err as { response?: { data?: { detail?: string } } };
|
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 {
|
} finally {
|
||||||
setTearingDown((prev) => {
|
setTearingDown((prev) => {
|
||||||
const next = new Set(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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -795,6 +957,35 @@ const DeckyFleet: React.FC = () => {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// 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 counts = useMemo(() => {
|
||||||
const c = { all: deckies.length, active: 0, hot: 0, idle: 0 } as Record<FilterKey, number>;
|
const c = { all: deckies.length, active: 0, hot: 0, idle: 0 } as Record<FilterKey, number>;
|
||||||
for (const d of deckies) {
|
for (const d of deckies) {
|
||||||
@@ -804,7 +995,16 @@ const DeckyFleet: React.FC = () => {
|
|||||||
return c;
|
return c;
|
||||||
}, [deckies]);
|
}, [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';
|
const isSwarm = deployMode?.mode === 'swarm';
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -854,7 +1054,17 @@ const DeckyFleet: React.FC = () => {
|
|||||||
<div className="grid-fleet">
|
<div className="grid-fleet">
|
||||||
{visible.length === 0 ? (
|
{visible.length === 0 ? (
|
||||||
<div className="fleet-empty">
|
<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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
visible.map((d) => (
|
visible.map((d) => (
|
||||||
@@ -865,9 +1075,14 @@ const DeckyFleet: React.FC = () => {
|
|||||||
isAdmin={isAdmin}
|
isAdmin={isAdmin}
|
||||||
armed={armed}
|
armed={armed}
|
||||||
tdBusy={tearingDown.has(d.name) || d.swarm?.state === 'tearing_down'}
|
tdBusy={tearingDown.has(d.name) || d.swarm?.state === 'tearing_down'}
|
||||||
onForce={handleMutate}
|
onForce={(name) => { void handleMutate(name); }}
|
||||||
onTeardown={handleTeardown}
|
onTeardown={handleTeardown}
|
||||||
onIntervalChange={handleIntervalChange}
|
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}
|
archetypes={archetypes}
|
||||||
fleetSize={deckies.length}
|
fleetSize={deckies.length}
|
||||||
onClose={() => setShowDeploy(false)}
|
onClose={() => setShowDeploy(false)}
|
||||||
onComplete={() => {
|
onComplete={(count) => {
|
||||||
setShowDeploy(false);
|
setShowDeploy(false);
|
||||||
fetchDeckies(deployMode?.mode);
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user