feat(ui): tarpit controls on DeckyCard — three-dot dropdown + enable/disable

This commit is contained in:
2026-04-29 20:56:51 -04:00
parent 07b32e2abe
commit f84c66cf9b
2 changed files with 227 additions and 3 deletions

View File

@@ -127,6 +127,78 @@
}
.decky-hits { font-variant-numeric: tabular-nums; }
/* Tarpit three-dot menu */
.tarpit-menu-wrap {
position: relative;
display: inline-flex;
}
.tarpit-menu-btn {
font-size: 1rem;
line-height: 1;
padding: 2px 8px;
border-color: var(--border);
color: var(--text-dim, rgba(255,255,255,0.5));
letter-spacing: 0;
}
.tarpit-menu-btn:hover {
border-color: var(--alert);
color: var(--alert);
background: transparent;
box-shadow: none;
}
.tarpit-dropdown {
position: absolute;
bottom: calc(100% + 4px);
right: 0;
z-index: 120;
background: var(--bg, #0d1117);
border: 1px solid var(--border);
box-shadow: 0 4px 16px rgba(0,0,0,0.6);
min-width: 160px;
display: flex;
flex-direction: column;
}
.tarpit-dropdown-item {
background: transparent;
border: none;
padding: 8px 14px;
font-size: 0.72rem;
font-family: inherit;
letter-spacing: 1px;
color: var(--matrix);
text-align: left;
cursor: pointer;
border-bottom: 1px solid var(--border);
}
.tarpit-dropdown-item:last-child { border-bottom: none; }
.tarpit-dropdown-item:hover { background: rgba(57,255,20,0.08); }
.tarpit-dropdown-item.alert { color: var(--alert); }
.tarpit-dropdown-item.alert:hover { background: rgba(255,65,65,0.08); }
/* Tarpit enable form — rendered below the card footer */
.tarpit-form {
margin-top: 8px;
padding: 10px;
border: 1px solid var(--alert);
background: rgba(255,65,65,0.04);
display: flex;
flex-direction: column;
gap: 8px;
font-size: 0.72rem;
}
.tarpit-form-row {
display: flex;
align-items: center;
gap: 8px;
}
.tarpit-form input.input {
font-size: 0.72rem;
padding: 4px 6px;
background: rgba(255,255,255,0.04);
border: 1px solid var(--border);
color: var(--text-color);
}
/* Info banner — used in the deploy wizard and elsewhere a small
contextual note belongs. Page-unscoped so it works inside the
Modal portal; PersonaGeneration.css scopes its own copy under

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Cpu, Database, Globe, Monitor, Network, PlusCircle, PowerOff,
RefreshCw, Server, Shield, Terminal, Plus, X,
@@ -143,11 +143,13 @@ interface DeckyCardProps {
/** 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,
innerRef, availableServices, onServicesChanged, onTarpitResult,
}) => {
const dot = _dotFor(decky);
const hits = _hitsFor(decky);
@@ -169,6 +171,62 @@ const DeckyCard: React.FC<DeckyCardProps> = ({
// 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 { response?: { data?: { detail?: string } } })?.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 { response?: { data?: { detail?: string } } })?.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);
@@ -394,7 +452,7 @@ const DeckyCard: React.FC<DeckyCardProps> = ({
{hits}
</span>
</span>
<div style={{ display: 'flex', gap: 6 }}>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
{!decky.swarm && isAdmin && (
<button
className="btn violet small"
@@ -419,8 +477,95 @@ const DeckyCard: React.FC<DeckyCardProps> = ({
: 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)}
@@ -1368,6 +1513,13 @@ const DeckyFleet: React.FC<FleetProps> = ({ searchQuery = '' }) => {
row.name === name ? { ...row, services } : row,
));
}}
onTarpitResult={(_name, ok, message) => {
push({
text: message,
tone: ok ? 'matrix' : 'alert',
icon: ok ? 'shield' : 'alert-triangle',
});
}}
/>
))
)}