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:
2026-05-09 04:58:25 -04:00
parent b6ff288dcf
commit 849caffaf1
3 changed files with 576 additions and 457 deletions

View File

@@ -1,15 +1,10 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Network, PlusCircle, PowerOff,
RefreshCw, Server, Plus, X,
} from '../icons';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { PlusCircle, Server } from '../icons';
import api, { type ApiError } from '../utils/api';
import { ARCHETYPES as FALLBACK_ARCHETYPES, DEFAULT_SERVICES } from './MazeNET/data';
import { useToast } from './Toasts/useToast';
import Modal from './Modal/Modal';
import { useServiceRegistry } from '../hooks/useServiceRegistry';
import ServiceConfigForm from './ServiceConfigForm';
import AddServiceConfigModal from './AddServiceConfigModal';
import ServiceConfigFields, {
type FormState as SvcFormState,
type ServiceConfigFieldDTO as SvcFieldDTO,
@@ -31,456 +26,7 @@ import {
} from './DeckyFleet/helpers';
import { DeckyInspectPanel } from './DeckyFleet/DeckyInspectPanel';
// ─── Decky card ───────────────────────────────────────────────────────────
interface DeckyCardProps {
decky: Decky;
mutating: boolean;
isAdmin: boolean;
armed: string | null;
tdBusy: boolean;
onForce: (name: string) => void;
onTeardown: (d: Decky) => void;
onIntervalChange: (name: string, current: number | null) => void;
onInspect: (d: Decky) => void;
innerRef?: React.Ref<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>
);
};
import { DeckyCard } from './DeckyFleet/DeckyCard';
// ─── Deploy wizard ────────────────────────────────────────────────────────

View 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');
});
});

View 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>
);
};