From f84c66cf9be6e17d37775db8359aea12fc720d9d Mon Sep 17 00:00:00 2001 From: anti Date: Wed, 29 Apr 2026 20:56:51 -0400 Subject: [PATCH] =?UTF-8?q?feat(ui):=20tarpit=20controls=20on=20DeckyCard?= =?UTF-8?q?=20=E2=80=94=20three-dot=20dropdown=20+=20enable/disable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- decnet_web/src/components/DeckyFleet.css | 72 +++++++++++ decnet_web/src/components/DeckyFleet.tsx | 158 ++++++++++++++++++++++- 2 files changed, 227 insertions(+), 3 deletions(-) diff --git a/decnet_web/src/components/DeckyFleet.css b/decnet_web/src/components/DeckyFleet.css index 2d1b3dfd..931041f7 100644 --- a/decnet_web/src/components/DeckyFleet.css +++ b/decnet_web/src/components/DeckyFleet.css @@ -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 diff --git a/decnet_web/src/components/DeckyFleet.tsx b/decnet_web/src/components/DeckyFleet.tsx index ea5f274e..76a63e37 100644 --- a/decnet_web/src/components/DeckyFleet.tsx +++ b/decnet_web/src/components/DeckyFleet.tsx @@ -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 = ({ 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 = ({ // 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(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 = ({ {hits} -
+
{!decky.swarm && isAdmin && ( )} + {liveServicesEnabled && ( +
+ + {tarpitMenuOpen && ( +
+ + +
+ )} +
+ )}
+ + {liveServicesEnabled && tarpitFormOpen && ( +
e.stopPropagation()} + > +
+ + setTarpitPorts(e.target.value)} + style={{ flex: 1 }} + /> +
+
+ + setTarpitDelayMs(parseInt(e.target.value, 10))} + style={{ flex: 1 }} + /> + + {tarpitDelayMs >= 1000 ? `${(tarpitDelayMs / 1000).toFixed(1)}s` : `${tarpitDelayMs}ms`} + +
+
+ + +
+
+ )} setPendingAdd(null)} @@ -1368,6 +1513,13 @@ const DeckyFleet: React.FC = ({ searchQuery = '' }) => { row.name === name ? { ...row, services } : row, )); }} + onTarpitResult={(_name, ok, message) => { + push({ + text: message, + tone: ok ? 'matrix' : 'alert', + icon: ok ? 'shield' : 'alert-triangle', + }); + }} /> )) )}