feat(ui): tarpit controls on DeckyCard — three-dot dropdown + enable/disable
This commit is contained in:
@@ -127,6 +127,78 @@
|
|||||||
}
|
}
|
||||||
.decky-hits { font-variant-numeric: tabular-nums; }
|
.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
|
/* Info banner — used in the deploy wizard and elsewhere a small
|
||||||
contextual note belongs. Page-unscoped so it works inside the
|
contextual note belongs. Page-unscoped so it works inside the
|
||||||
Modal portal; PersonaGeneration.css scopes its own copy under
|
Modal portal; PersonaGeneration.css scopes its own copy under
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useCallback, 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, Plus, X,
|
RefreshCw, Server, Shield, Terminal, Plus, X,
|
||||||
@@ -143,11 +143,13 @@ interface DeckyCardProps {
|
|||||||
/** Called after a successful live add/remove so the parent can
|
/** Called after a successful live add/remove so the parent can
|
||||||
* optimistically apply the response's services list. */
|
* optimistically apply the response's services list. */
|
||||||
onServicesChanged: (deckyName: string, services: string[]) => void;
|
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> = ({
|
const DeckyCard: React.FC<DeckyCardProps> = ({
|
||||||
decky, mutating, isAdmin, armed, tdBusy, onForce, onTeardown, onIntervalChange, onInspect,
|
decky, mutating, isAdmin, armed, tdBusy, onForce, onTeardown, onIntervalChange, onInspect,
|
||||||
innerRef, availableServices, onServicesChanged,
|
innerRef, availableServices, onServicesChanged, onTarpitResult,
|
||||||
}) => {
|
}) => {
|
||||||
const dot = _dotFor(decky);
|
const dot = _dotFor(decky);
|
||||||
const hits = _hitsFor(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.
|
// will either auto-fire onConfirm (no schema fields) or show the form.
|
||||||
const [pendingAdd, setPendingAdd] = useState<{ deckyName: string; slug: string } | null>(null);
|
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) => {
|
const removeService = async (slug: string) => {
|
||||||
setOpError(null);
|
setOpError(null);
|
||||||
setBusy(slug);
|
setBusy(slug);
|
||||||
@@ -394,7 +452,7 @@ const DeckyCard: React.FC<DeckyCardProps> = ({
|
|||||||
{hits}
|
{hits}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<div style={{ display: 'flex', gap: 6 }}>
|
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||||
{!decky.swarm && isAdmin && (
|
{!decky.swarm && isAdmin && (
|
||||||
<button
|
<button
|
||||||
className="btn violet small"
|
className="btn violet small"
|
||||||
@@ -419,8 +477,95 @@ const DeckyCard: React.FC<DeckyCardProps> = ({
|
|||||||
: armed === tdKey ? 'CONFIRM' : 'TEARDOWN'}
|
: armed === tdKey ? 'CONFIRM' : 'TEARDOWN'}
|
||||||
</button>
|
</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>
|
||||||
</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
|
<AddServiceConfigModal
|
||||||
pending={pendingAdd}
|
pending={pendingAdd}
|
||||||
onCancel={() => setPendingAdd(null)}
|
onCancel={() => setPendingAdd(null)}
|
||||||
@@ -1368,6 +1513,13 @@ const DeckyFleet: React.FC<FleetProps> = ({ searchQuery = '' }) => {
|
|||||||
row.name === name ? { ...row, services } : row,
|
row.name === name ? { ...row, services } : row,
|
||||||
));
|
));
|
||||||
}}
|
}}
|
||||||
|
onTarpitResult={(_name, ok, message) => {
|
||||||
|
push({
|
||||||
|
text: message,
|
||||||
|
tone: ok ? 'matrix' : 'alert',
|
||||||
|
icon: ok ? 'shield' : 'alert-triangle',
|
||||||
|
});
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user