refactor(decnet_web/DeckyFleet): move DeckyCard out
Lift the per-decky tile (~430 LOC) into its own file. Tarpit controls, live add/remove service flow, and the per-service config toggle stay inside the card — those are tile-local UI concerns and only ever rendered from this component anyway. - New DeckyFleet/DeckyCard.tsx - DeckyCard.test.tsx covers identity row + services rendering, admin-gated FORCE MUTATE visibility, the FORCE MUTATE callback, TEARDOWN -> CONFIRM toggle when armed matches, and card-body click firing onInspect. AddServiceConfigModal + ServiceConfigForm are vi.mock'd so we don't need MSW handlers for their unrelated network fetches. - DeckyFleet.tsx loses the inline component plus the now-unused imports it dragged in (Network/PowerOff/RefreshCw/Plus/X icons, ServiceConfigForm, AddServiceConfigModal, useCallback).
This commit is contained in:
@@ -1,15 +1,10 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import { PlusCircle, Server } from '../icons';
|
||||||
Network, PlusCircle, PowerOff,
|
|
||||||
RefreshCw, Server, Plus, X,
|
|
||||||
} from '../icons';
|
|
||||||
import api, { type ApiError } from '../utils/api';
|
import api, { type ApiError } 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 { useToast } from './Toasts/useToast';
|
||||||
import Modal from './Modal/Modal';
|
import Modal from './Modal/Modal';
|
||||||
import { useServiceRegistry } from '../hooks/useServiceRegistry';
|
import { useServiceRegistry } from '../hooks/useServiceRegistry';
|
||||||
import ServiceConfigForm from './ServiceConfigForm';
|
|
||||||
import AddServiceConfigModal from './AddServiceConfigModal';
|
|
||||||
import ServiceConfigFields, {
|
import ServiceConfigFields, {
|
||||||
type FormState as SvcFormState,
|
type FormState as SvcFormState,
|
||||||
type ServiceConfigFieldDTO as SvcFieldDTO,
|
type ServiceConfigFieldDTO as SvcFieldDTO,
|
||||||
@@ -31,456 +26,7 @@ import {
|
|||||||
} from './DeckyFleet/helpers';
|
} from './DeckyFleet/helpers';
|
||||||
|
|
||||||
import { DeckyInspectPanel } from './DeckyFleet/DeckyInspectPanel';
|
import { DeckyInspectPanel } from './DeckyFleet/DeckyInspectPanel';
|
||||||
|
import { DeckyCard } from './DeckyFleet/DeckyCard';
|
||||||
// ─── 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<HTMLDivElement>;
|
|
||||||
/** 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<DeckyCardProps> = ({
|
|
||||||
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<string | null>(null);
|
|
||||||
const [opError, setOpError] = useState<string | null>(null);
|
|
||||||
const [openCfgSvc, setOpenCfgSvc] = useState<string | null>(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<HTMLDivElement>(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<string, unknown>) => {
|
|
||||||
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 (
|
|
||||||
<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}`} />
|
|
||||||
{decky.name}
|
|
||||||
</div>
|
|
||||||
<span className="decky-ip">{decky.ip}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{decky.swarm && (
|
|
||||||
<div className="decky-swarm-row">
|
|
||||||
<span className="decky-swarm-chip">
|
|
||||||
<Network size={10} className="dim" />
|
|
||||||
<span className="dim">{decky.swarm.host_name}</span>
|
|
||||||
<span style={{ opacity: 0.5 }}>@ {decky.swarm.host_address || '—'}</span>
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className="decky-swarm-state"
|
|
||||||
style={{
|
|
||||||
borderColor: _stateColor(decky.swarm.state),
|
|
||||||
color: _stateColor(decky.swarm.state),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{decky.swarm.state.toUpperCase()}
|
|
||||||
</span>
|
|
||||||
{decky.swarm.last_error && (
|
|
||||||
<span className="alert-text" title={decky.swarm.last_error} style={{ fontSize: '0.65rem' }}>
|
|
||||||
⚠ {decky.swarm.last_error.slice(0, 48)}
|
|
||||||
{decky.swarm.last_error.length > 48 ? '…' : ''}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="decky-meta">
|
|
||||||
<div className="row"><span className="label">HOST</span><span>{decky.hostname}</span></div>
|
|
||||||
<div className="row"><span className="label">DISTRO</span><span className="dim">{decky.distro}</span></div>
|
|
||||||
<div className="row">
|
|
||||||
<span className="label">ARCHETYPE</span>
|
|
||||||
<span className="violet-accent">{decky.archetype || '—'}</span>
|
|
||||||
</div>
|
|
||||||
<div className="row">
|
|
||||||
<span className="label">MUTATE</span>
|
|
||||||
{!decky.swarm && isAdmin ? (
|
|
||||||
<span
|
|
||||||
className={decky.mutate_interval ? 'violet-accent' : 'dim'}
|
|
||||||
style={{ cursor: 'pointer', textDecoration: 'underline' }}
|
|
||||||
onClick={() => onIntervalChange(decky.name, decky.mutate_interval)}
|
|
||||||
>
|
|
||||||
{decky.mutate_interval ? `EVERY ${decky.mutate_interval}m` : 'DISABLED'}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className={decky.mutate_interval ? 'violet-accent' : 'dim'}>
|
|
||||||
{decky.mutate_interval ? `EVERY ${decky.mutate_interval}m` : 'DISABLED'}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div className="type-label" style={{ marginBottom: 6 }}>EXPOSED</div>
|
|
||||||
<div className="decky-services">
|
|
||||||
{decky.services.map((s) => (
|
|
||||||
<span key={s} className="service-tag" style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
|
||||||
{liveServicesEnabled ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="svc-cfg-toggle-btn"
|
|
||||||
title={`Configure ${s}`}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setOpenCfgSvc((cur) => (cur === s ? null : s));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{s}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<span>{s}</span>
|
|
||||||
)}
|
|
||||||
{liveServicesEnabled && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
title={`Remove ${s}`}
|
|
||||||
disabled={busy === s}
|
|
||||||
onClick={(e) => { e.stopPropagation(); removeService(s); }}
|
|
||||||
style={{
|
|
||||||
background: 'transparent', border: 'none', padding: 0,
|
|
||||||
color: 'inherit', cursor: busy === s ? 'wait' : 'pointer',
|
|
||||||
opacity: busy === s ? 0.4 : 0.7, lineHeight: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<X size={9} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
{liveServicesEnabled && !addOpen && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="service-tag"
|
|
||||||
onClick={(e) => { e.stopPropagation(); setAddOpen(true); setAddSlug(''); }}
|
|
||||||
style={{ cursor: 'pointer', borderStyle: 'dashed' }}
|
|
||||||
title="Add service (live)"
|
|
||||||
>
|
|
||||||
<Plus size={10} /> ADD
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{liveServicesEnabled && addOpen && (
|
|
||||||
<div
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
style={{ display: 'flex', gap: 6, marginTop: 6, alignItems: 'center' }}
|
|
||||||
>
|
|
||||||
<select
|
|
||||||
value={addSlug}
|
|
||||||
onChange={(e) => setAddSlug(e.target.value)}
|
|
||||||
style={{
|
|
||||||
flex: 1, fontSize: '0.75rem', padding: '4px 6px',
|
|
||||||
background: 'var(--matrix-tint-10)',
|
|
||||||
border: '1px solid var(--border-color, #30363d)',
|
|
||||||
color: 'var(--text-color)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="">— pick a service —</option>
|
|
||||||
{availableServices
|
|
||||||
.filter((s) => !decky.services.includes(s))
|
|
||||||
.map((s) => <option key={s} value={s}>{s}</option>)}
|
|
||||||
</select>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={!addSlug || busy === addSlug}
|
|
||||||
onClick={beginAdd}
|
|
||||||
className="btn violet small"
|
|
||||||
>
|
|
||||||
{busy === addSlug ? 'ADDING' : 'ADD'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => { setAddOpen(false); setAddSlug(''); }}
|
|
||||||
className="btn small"
|
|
||||||
>
|
|
||||||
CANCEL
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{opError && (
|
|
||||||
<div className="alert-text" style={{ fontSize: '0.7rem', marginTop: 6 }}>{opError}</div>
|
|
||||||
)}
|
|
||||||
{liveServicesEnabled && openCfgSvc && decky.services.includes(openCfgSvc) && (
|
|
||||||
<div onClick={(e) => e.stopPropagation()}>
|
|
||||||
<ServiceConfigForm
|
|
||||||
key={`${decky.name}:${openCfgSvc}`}
|
|
||||||
deckyName={decky.name}
|
|
||||||
serviceSlug={openCfgSvc}
|
|
||||||
currentConfig={decky.service_config?.[openCfgSvc] ?? {}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="decky-footer">
|
|
||||||
<span className="decky-hits">
|
|
||||||
<span className="dim">HITS 24h: </span>
|
|
||||||
<span
|
|
||||||
className={hot ? 'alert-text' : 'matrix-text'}
|
|
||||||
style={{ fontWeight: 700 }}
|
|
||||||
>
|
|
||||||
{hits}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
|
||||||
{!decky.swarm && isAdmin && (
|
|
||||||
<button
|
|
||||||
className="btn violet small"
|
|
||||||
disabled={mutating}
|
|
||||||
onClick={() => onForce(decky.name)}
|
|
||||||
title="Force a mutation now"
|
|
||||||
>
|
|
||||||
<RefreshCw size={10} className={mutating ? 'fx-spin' : ''} />
|
|
||||||
{mutating ? 'MUTATING' : 'FORCE MUTATE'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{decky.swarm && isAdmin && (
|
|
||||||
<button
|
|
||||||
className="btn alert small"
|
|
||||||
disabled={tdBusy}
|
|
||||||
onClick={() => onTeardown(decky)}
|
|
||||||
title="Stop this decky on its host"
|
|
||||||
>
|
|
||||||
<PowerOff size={10} />
|
|
||||||
{tdBusy
|
|
||||||
? 'TEARING DOWN…'
|
|
||||||
: armed === tdKey ? 'CONFIRM' : 'TEARDOWN'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{liveServicesEnabled && (
|
|
||||||
<div className="tarpit-menu-wrap" ref={tarpitMenuRef}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn small tarpit-menu-btn"
|
|
||||||
title="Tarpit controls"
|
|
||||||
disabled={tarpitBusy}
|
|
||||||
onClick={() => {
|
|
||||||
setTarpitMenuOpen((o) => !o);
|
|
||||||
setTarpitFormOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{tarpitBusy ? '…' : '⋮'}
|
|
||||||
</button>
|
|
||||||
{tarpitMenuOpen && (
|
|
||||||
<div className="tarpit-dropdown">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="tarpit-dropdown-item"
|
|
||||||
onClick={() => {
|
|
||||||
setTarpitMenuOpen(false);
|
|
||||||
setTarpitFormOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
ENABLE TARPIT
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="tarpit-dropdown-item alert"
|
|
||||||
onClick={() => void disableTarpit()}
|
|
||||||
>
|
|
||||||
DISABLE TARPIT
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{liveServicesEnabled && tarpitFormOpen && (
|
|
||||||
<div
|
|
||||||
className="tarpit-form"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<div className="tarpit-form-row">
|
|
||||||
<label className="type-label" style={{ minWidth: 70 }}>PORTS</label>
|
|
||||||
<input
|
|
||||||
className="input"
|
|
||||||
value={tarpitPorts}
|
|
||||||
placeholder="22,80,443"
|
|
||||||
onChange={(e) => setTarpitPorts(e.target.value)}
|
|
||||||
style={{ flex: 1 }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="tarpit-form-row">
|
|
||||||
<label className="type-label" style={{ minWidth: 70 }}>DELAY</label>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={100}
|
|
||||||
max={60000}
|
|
||||||
step={100}
|
|
||||||
value={tarpitDelayMs}
|
|
||||||
onChange={(e) => setTarpitDelayMs(parseInt(e.target.value, 10))}
|
|
||||||
style={{ flex: 1 }}
|
|
||||||
/>
|
|
||||||
<span className="dim" style={{ fontSize: '0.7rem', minWidth: 52, textAlign: 'right' }}>
|
|
||||||
{tarpitDelayMs >= 1000 ? `${(tarpitDelayMs / 1000).toFixed(1)}s` : `${tarpitDelayMs}ms`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', gap: 6, justifyContent: 'flex-end', marginTop: 4 }}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn small"
|
|
||||||
onClick={() => setTarpitFormOpen(false)}
|
|
||||||
>
|
|
||||||
CANCEL
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn alert small"
|
|
||||||
disabled={tarpitBusy || !tarpitPorts.trim()}
|
|
||||||
onClick={() => void enableTarpit()}
|
|
||||||
>
|
|
||||||
{tarpitBusy ? 'APPLYING…' : 'APPLY'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<AddServiceConfigModal
|
|
||||||
pending={pendingAdd}
|
|
||||||
onCancel={() => setPendingAdd(null)}
|
|
||||||
onConfirm={confirmAdd}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Deploy wizard ────────────────────────────────────────────────────────
|
// ─── Deploy wizard ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
115
decnet_web/src/components/DeckyFleet/DeckyCard.test.tsx
Normal file
115
decnet_web/src/components/DeckyFleet/DeckyCard.test.tsx
Normal file
@@ -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(
|
||||||
|
<DeckyCard
|
||||||
|
{...baseProps}
|
||||||
|
decky={makeDecky({ name: 'decoy-99', ip: '10.0.0.99', services: ['ssh', 'http'] })}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<DeckyCard {...baseProps} decky={makeDecky()} />,
|
||||||
|
);
|
||||||
|
expect(screen.queryByText(/FORCE MUTATE/)).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<DeckyCard {...baseProps} isAdmin decky={makeDecky()} />,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<DeckyCard
|
||||||
|
{...baseProps}
|
||||||
|
isAdmin
|
||||||
|
onForce={onForce}
|
||||||
|
decky={makeDecky({ name: 'decoy-77' })}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<DeckyCard {...baseProps} isAdmin decky={swarmDecky} />,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('TEARDOWN')).toBeInTheDocument();
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<DeckyCard
|
||||||
|
{...baseProps}
|
||||||
|
isAdmin
|
||||||
|
armed="td:h-1:decoy-swarm"
|
||||||
|
decky={swarmDecky}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('CONFIRM')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking the card body fires onInspect', async () => {
|
||||||
|
const onInspect = vi.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<DeckyCard
|
||||||
|
{...baseProps}
|
||||||
|
onInspect={onInspect}
|
||||||
|
decky={makeDecky({ name: 'decoy-hit' })}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
// 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
458
decnet_web/src/components/DeckyFleet/DeckyCard.tsx
Normal file
458
decnet_web/src/components/DeckyFleet/DeckyCard.tsx
Normal file
@@ -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<HTMLDivElement>;
|
||||||
|
/** 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<Props> = ({
|
||||||
|
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<string | null>(null);
|
||||||
|
const [opError, setOpError] = useState<string | null>(null);
|
||||||
|
const [openCfgSvc, setOpenCfgSvc] = useState<string | null>(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<HTMLDivElement>(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<string, unknown>) => {
|
||||||
|
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 (
|
||||||
|
<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}`} />
|
||||||
|
{decky.name}
|
||||||
|
</div>
|
||||||
|
<span className="decky-ip">{decky.ip}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{decky.swarm && (
|
||||||
|
<div className="decky-swarm-row">
|
||||||
|
<span className="decky-swarm-chip">
|
||||||
|
<Network size={10} className="dim" />
|
||||||
|
<span className="dim">{decky.swarm.host_name}</span>
|
||||||
|
<span style={{ opacity: 0.5 }}>@ {decky.swarm.host_address || '—'}</span>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="decky-swarm-state"
|
||||||
|
style={{
|
||||||
|
borderColor: stateColor(decky.swarm.state),
|
||||||
|
color: stateColor(decky.swarm.state),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{decky.swarm.state.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
{decky.swarm.last_error && (
|
||||||
|
<span className="alert-text" title={decky.swarm.last_error} style={{ fontSize: '0.65rem' }}>
|
||||||
|
⚠ {decky.swarm.last_error.slice(0, 48)}
|
||||||
|
{decky.swarm.last_error.length > 48 ? '…' : ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="decky-meta">
|
||||||
|
<div className="row"><span className="label">HOST</span><span>{decky.hostname}</span></div>
|
||||||
|
<div className="row"><span className="label">DISTRO</span><span className="dim">{decky.distro}</span></div>
|
||||||
|
<div className="row">
|
||||||
|
<span className="label">ARCHETYPE</span>
|
||||||
|
<span className="violet-accent">{decky.archetype || '—'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="row">
|
||||||
|
<span className="label">MUTATE</span>
|
||||||
|
{!decky.swarm && isAdmin ? (
|
||||||
|
<span
|
||||||
|
className={decky.mutate_interval ? 'violet-accent' : 'dim'}
|
||||||
|
style={{ cursor: 'pointer', textDecoration: 'underline' }}
|
||||||
|
onClick={() => onIntervalChange(decky.name, decky.mutate_interval)}
|
||||||
|
>
|
||||||
|
{decky.mutate_interval ? `EVERY ${decky.mutate_interval}m` : 'DISABLED'}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className={decky.mutate_interval ? 'violet-accent' : 'dim'}>
|
||||||
|
{decky.mutate_interval ? `EVERY ${decky.mutate_interval}m` : 'DISABLED'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="type-label" style={{ marginBottom: 6 }}>EXPOSED</div>
|
||||||
|
<div className="decky-services">
|
||||||
|
{decky.services.map((s) => (
|
||||||
|
<span key={s} className="service-tag" style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
{liveServicesEnabled ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="svc-cfg-toggle-btn"
|
||||||
|
title={`Configure ${s}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setOpenCfgSvc((cur) => (cur === s ? null : s));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{s}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span>{s}</span>
|
||||||
|
)}
|
||||||
|
{liveServicesEnabled && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={`Remove ${s}`}
|
||||||
|
disabled={busy === s}
|
||||||
|
onClick={(e) => { e.stopPropagation(); removeService(s); }}
|
||||||
|
style={{
|
||||||
|
background: 'transparent', border: 'none', padding: 0,
|
||||||
|
color: 'inherit', cursor: busy === s ? 'wait' : 'pointer',
|
||||||
|
opacity: busy === s ? 0.4 : 0.7, lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X size={9} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{liveServicesEnabled && !addOpen && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="service-tag"
|
||||||
|
onClick={(e) => { e.stopPropagation(); setAddOpen(true); setAddSlug(''); }}
|
||||||
|
style={{ cursor: 'pointer', borderStyle: 'dashed' }}
|
||||||
|
title="Add service (live)"
|
||||||
|
>
|
||||||
|
<Plus size={10} /> ADD
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{liveServicesEnabled && addOpen && (
|
||||||
|
<div
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{ display: 'flex', gap: 6, marginTop: 6, alignItems: 'center' }}
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
value={addSlug}
|
||||||
|
onChange={(e) => setAddSlug(e.target.value)}
|
||||||
|
style={{
|
||||||
|
flex: 1, fontSize: '0.75rem', padding: '4px 6px',
|
||||||
|
background: 'var(--matrix-tint-10)',
|
||||||
|
border: '1px solid var(--border-color, #30363d)',
|
||||||
|
color: 'var(--text-color)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">— pick a service —</option>
|
||||||
|
{availableServices
|
||||||
|
.filter((s) => !decky.services.includes(s))
|
||||||
|
.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!addSlug || busy === addSlug}
|
||||||
|
onClick={beginAdd}
|
||||||
|
className="btn violet small"
|
||||||
|
>
|
||||||
|
{busy === addSlug ? 'ADDING' : 'ADD'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setAddOpen(false); setAddSlug(''); }}
|
||||||
|
className="btn small"
|
||||||
|
>
|
||||||
|
CANCEL
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{opError && (
|
||||||
|
<div className="alert-text" style={{ fontSize: '0.7rem', marginTop: 6 }}>{opError}</div>
|
||||||
|
)}
|
||||||
|
{liveServicesEnabled && openCfgSvc && decky.services.includes(openCfgSvc) && (
|
||||||
|
<div onClick={(e) => e.stopPropagation()}>
|
||||||
|
<ServiceConfigForm
|
||||||
|
key={`${decky.name}:${openCfgSvc}`}
|
||||||
|
deckyName={decky.name}
|
||||||
|
serviceSlug={openCfgSvc}
|
||||||
|
currentConfig={decky.service_config?.[openCfgSvc] ?? {}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="decky-footer">
|
||||||
|
<span className="decky-hits">
|
||||||
|
<span className="dim">HITS 24h: </span>
|
||||||
|
<span
|
||||||
|
className={hot ? 'alert-text' : 'matrix-text'}
|
||||||
|
style={{ fontWeight: 700 }}
|
||||||
|
>
|
||||||
|
{hits}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||||
|
{!decky.swarm && isAdmin && (
|
||||||
|
<button
|
||||||
|
className="btn violet small"
|
||||||
|
disabled={mutating}
|
||||||
|
onClick={() => onForce(decky.name)}
|
||||||
|
title="Force a mutation now"
|
||||||
|
>
|
||||||
|
<RefreshCw size={10} className={mutating ? 'fx-spin' : ''} />
|
||||||
|
{mutating ? 'MUTATING' : 'FORCE MUTATE'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{decky.swarm && isAdmin && (
|
||||||
|
<button
|
||||||
|
className="btn alert small"
|
||||||
|
disabled={tdBusy}
|
||||||
|
onClick={() => onTeardown(decky)}
|
||||||
|
title="Stop this decky on its host"
|
||||||
|
>
|
||||||
|
<PowerOff size={10} />
|
||||||
|
{tdBusy
|
||||||
|
? 'TEARING DOWN…'
|
||||||
|
: armed === tdKey ? 'CONFIRM' : 'TEARDOWN'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{liveServicesEnabled && (
|
||||||
|
<div className="tarpit-menu-wrap" ref={tarpitMenuRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn small tarpit-menu-btn"
|
||||||
|
title="Tarpit controls"
|
||||||
|
disabled={tarpitBusy}
|
||||||
|
onClick={() => {
|
||||||
|
setTarpitMenuOpen((o) => !o);
|
||||||
|
setTarpitFormOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tarpitBusy ? '…' : '⋮'}
|
||||||
|
</button>
|
||||||
|
{tarpitMenuOpen && (
|
||||||
|
<div className="tarpit-dropdown">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="tarpit-dropdown-item"
|
||||||
|
onClick={() => {
|
||||||
|
setTarpitMenuOpen(false);
|
||||||
|
setTarpitFormOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
ENABLE TARPIT
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="tarpit-dropdown-item alert"
|
||||||
|
onClick={() => void disableTarpit()}
|
||||||
|
>
|
||||||
|
DISABLE TARPIT
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{liveServicesEnabled && tarpitFormOpen && (
|
||||||
|
<div
|
||||||
|
className="tarpit-form"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="tarpit-form-row">
|
||||||
|
<label className="type-label" style={{ minWidth: 70 }}>PORTS</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={tarpitPorts}
|
||||||
|
placeholder="22,80,443"
|
||||||
|
onChange={(e) => setTarpitPorts(e.target.value)}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="tarpit-form-row">
|
||||||
|
<label className="type-label" style={{ minWidth: 70 }}>DELAY</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={100}
|
||||||
|
max={60000}
|
||||||
|
step={100}
|
||||||
|
value={tarpitDelayMs}
|
||||||
|
onChange={(e) => setTarpitDelayMs(parseInt(e.target.value, 10))}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<span className="dim" style={{ fontSize: '0.7rem', minWidth: 52, textAlign: 'right' }}>
|
||||||
|
{tarpitDelayMs >= 1000 ? `${(tarpitDelayMs / 1000).toFixed(1)}s` : `${tarpitDelayMs}ms`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 6, justifyContent: 'flex-end', marginTop: 4 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn small"
|
||||||
|
onClick={() => setTarpitFormOpen(false)}
|
||||||
|
>
|
||||||
|
CANCEL
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn alert small"
|
||||||
|
disabled={tarpitBusy || !tarpitPorts.trim()}
|
||||||
|
onClick={() => void enableTarpit()}
|
||||||
|
>
|
||||||
|
{tarpitBusy ? 'APPLYING…' : 'APPLY'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<AddServiceConfigModal
|
||||||
|
pending={pendingAdd}
|
||||||
|
onCancel={() => setPendingAdd(null)}
|
||||||
|
onConfirm={confirmAdd}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user