feat(web): Webhooks page + ALERTS nav group

New /webhooks admin page with table-based subscription management:
- CREATE WEBHOOK (inline form row — no modal) with simple-event
  checkboxes (AttackerDetail / DeckyStatus / SystemStatus) that
  expand to bus-topic patterns server-side, and an advanced-mode
  textarea for raw NATS-style patterns.
- Bulk-select + DELETE SELECTED with two-click arm pattern.
- Per-row test-ping (zap), pencil edit, and delete actions.
- Last-fired timestamp column.
- Yellow banner surfacing insecure_url warnings (WH-03): http:// is
  allowed but flagged so operators see it on every page load.
- Post-create secret modal — the secret is shown exactly once with
  a COPY button and a clear "won't see this again" notice.

Sidebar nav regrouped: /live-logs and /webhooks now live under a new
ALERTS NavGroup (Bell icon). The alertCount badge rides the Live
Logs sub-item. Command palette gains a "Webhooks" GO TO entry with
the `G W` chord.

Side-fix: useFocusSearch.ts was failing the build under
verbatimModuleSyntax (pre-existing, unrelated). Split the React
import to satisfy tsc; no behavioural change.
This commit is contained in:
2026-04-24 16:03:53 -04:00
parent c2ff8d1a4f
commit 59c405d9e5
6 changed files with 851 additions and 12 deletions

View File

@@ -5,6 +5,7 @@ import Layout from './components/Layout';
import Dashboard from './components/Dashboard';
import DeckyFleet from './components/DeckyFleet';
import LiveLogs from './components/LiveLogs';
import Webhooks from './components/Webhooks';
import Attackers from './components/Attackers';
import AttackerDetail from './components/AttackerDetail';
import Config from './components/Config';
@@ -80,6 +81,7 @@ const AuthedShell: React.FC<AuthedShellProps> = ({ onLogout, onSearch, searchQue
<Route path="/topologies" element={<Navigate to="/mazenet" replace />} />
<Route path="/mazenet" element={<MazeNETRoute />} />
<Route path="/live-logs" element={<LiveLogs />} />
<Route path="/webhooks" element={<Webhooks />} />
<Route path="/bounty" element={<Bounty />} />
<Route path="/attackers" element={<Attackers />} />
<Route path="/attackers/:id" element={<AttackerDetail />} />

View File

@@ -2,7 +2,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
LayoutDashboard, Server, Network, Terminal, Archive, Crosshair,
PlusCircle, Pause, RefreshCw, Download, HardDrive, Package, Settings,
SearchX, Keyboard,
SearchX, Keyboard, Webhook,
} from 'lucide-react';
import EmptyState from '../EmptyState/EmptyState';
import './CommandPalette.css';
@@ -22,7 +22,8 @@ const ITEMS: CmdItem[] = [
{ section: 'GO TO', label: 'Dashboard', icon: LayoutDashboard, kbd: 'G D', kind: 'nav', payload: '/' },
{ section: 'GO TO', label: 'Decoy Fleet', icon: Server, kbd: 'G F', kind: 'nav', payload: '/fleet' },
{ section: 'GO TO', label: 'MazeNET', icon: Network, kbd: 'G M', kind: 'nav', payload: '/mazenet' },
{ section: 'GO TO', label: 'Logs', icon: Terminal, kbd: 'G L', kind: 'nav', payload: '/live-logs' },
{ section: 'GO TO', label: 'Live Logs', icon: Terminal, kbd: 'G L', kind: 'nav', payload: '/live-logs' },
{ section: 'GO TO', label: 'Webhooks', icon: Webhook, kbd: 'G W', kind: 'nav', payload: '/webhooks' },
{ section: 'GO TO', label: 'Bounty Vault', icon: Archive, kbd: 'G B', kind: 'nav', payload: '/bounty' },
{ section: 'GO TO', label: 'Attackers', icon: Crosshair, kbd: 'G A', kind: 'nav', payload: '/attackers' },
{ section: 'GO TO', label: 'SWARM Hosts', icon: HardDrive, kbd: 'G S', kind: 'nav', payload: '/swarm/hosts' },

View File

@@ -3,7 +3,7 @@ import { NavLink, useLocation } from 'react-router-dom';
import {
Menu, X, Search, Activity, LayoutDashboard, Terminal, Settings, LogOut,
Server, Archive, Package, Network, ChevronDown, ChevronRight, HardDrive,
ShieldAlert,
ShieldAlert, Bell, Webhook,
} from 'lucide-react';
import './Layout.css';
@@ -25,7 +25,8 @@ const ROUTE_LABELS: Record<string, string> = {
'/': 'DASHBOARD',
'/fleet': 'FLEET',
'/mazenet': 'MAZENET',
'/live-logs': 'LOGS',
'/live-logs': 'LIVE LOGS',
'/webhooks': 'WEBHOOKS',
'/bounty': 'BOUNTY',
'/attackers': 'ATTACKERS',
'/config': 'CONFIG',
@@ -104,13 +105,23 @@ const Layout: React.FC<LayoutProps> = ({
<NavItem to="/" icon={<LayoutDashboard size={20} />} label="Dashboard" open={sidebarOpen} />
<NavItem to="/fleet" icon={<Server size={20} />} label="Decoy Fleet" open={sidebarOpen} />
<NavItem to="/mazenet" icon={<Network size={20} />} label="MazeNET" open={sidebarOpen} />
<NavItem
to="/live-logs"
icon={<Terminal size={20} />}
label="Logs"
open={sidebarOpen}
badge={alertCount}
/>
<NavGroup label="ALERTS" icon={<Bell size={20} />} open={sidebarOpen}>
<NavItem
to="/live-logs"
icon={<Terminal size={18} />}
label="Live Logs"
open={sidebarOpen}
indent
badge={alertCount}
/>
<NavItem
to="/webhooks"
icon={<Webhook size={18} />}
label="Webhooks"
open={sidebarOpen}
indent
/>
</NavGroup>
<NavItem to="/bounty" icon={<Archive size={20} />} label="Bounty" open={sidebarOpen} />
<NavItem to="/attackers" icon={<Activity size={20} />} label="Attackers" open={sidebarOpen} />
<NavGroup label="SWARM" icon={<Network size={20} />} open={sidebarOpen}>

View File

@@ -0,0 +1,239 @@
.webhooks-page {
display: flex;
flex-direction: column;
gap: 24px;
}
.webhooks-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 16px;
border-bottom: 1px solid var(--border-color);
}
.webhooks-header h2 {
margin: 0;
font-size: 0.95rem;
letter-spacing: 2px;
text-transform: uppercase;
color: var(--text-color);
}
.webhooks-header-actions {
display: flex;
gap: 12px;
}
.webhooks-warning-banner {
background: rgba(224, 160, 64, 0.1);
border: 1px solid var(--warn);
color: var(--warn);
padding: 10px 16px;
font-size: 0.78rem;
letter-spacing: 1px;
display: flex;
align-items: center;
gap: 10px;
}
.webhooks-empty {
padding: 48px;
text-align: center;
opacity: 0.5;
font-size: 0.82rem;
letter-spacing: 1.5px;
border: 1px dashed var(--border-color);
}
.webhooks-table-wrap {
overflow-x: auto;
}
.webhooks-table {
width: 100%;
border-collapse: collapse;
font-size: 0.8rem;
}
.webhooks-table thead {
font-size: 0.65rem;
letter-spacing: 1.5px;
opacity: 0.6;
}
.webhooks-table th,
.webhooks-table td {
padding: 10px 12px;
text-align: left;
border-bottom: 1px solid rgba(48, 54, 61, 0.5);
vertical-align: middle;
}
.webhooks-table tbody tr:hover {
background: rgba(0, 255, 65, 0.03);
}
.webhooks-table .col-check {
width: 32px;
}
.webhooks-table .col-actions {
width: 140px;
}
.wh-url-cell {
font-family: var(--font-mono);
max-width: 260px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wh-chip {
font-size: 0.68rem;
padding: 2px 6px;
border: 1px solid var(--accent-tint-30, rgba(0, 255, 65, 0.3));
background: var(--accent-tint-10, rgba(0, 255, 65, 0.1));
color: var(--accent-color);
letter-spacing: 0.5px;
font-family: var(--font-mono);
margin-right: 4px;
display: inline-block;
}
.wh-chip.status-disabled {
border-color: var(--border-color);
color: var(--text-color);
background: transparent;
opacity: 0.6;
}
.wh-chip.status-fail {
border-color: var(--alert, #ff4d4d);
background: rgba(255, 77, 77, 0.12);
color: var(--alert, #ff4d4d);
}
.wh-chip.status-warn {
border-color: var(--warn, #e0a040);
background: rgba(224, 160, 64, 0.1);
color: var(--warn, #e0a040);
}
.wh-actions {
display: flex;
gap: 6px;
}
/* Inline create / edit form, rendered as an expanded row. */
.wh-form-row td {
padding: 20px 12px;
background: rgba(0, 0, 0, 0.2);
}
.wh-form-grid {
display: grid;
grid-template-columns: 1fr 2fr;
gap: 14px;
max-width: 900px;
}
.wh-form-grid label {
font-size: 0.65rem;
letter-spacing: 1px;
opacity: 0.6;
align-self: center;
}
.wh-form-grid input[type="text"],
.wh-form-grid input[type="url"],
.wh-form-grid input[type="password"],
.wh-form-grid textarea {
background: #0d1117;
border: 1px solid var(--border-color);
color: var(--text-color);
padding: 8px 12px;
font-family: inherit;
font-size: 0.82rem;
width: 100%;
box-sizing: border-box;
}
.wh-form-grid textarea {
min-height: 80px;
font-family: var(--font-mono);
}
.wh-form-grid .wh-checkbox-group {
display: flex;
gap: 16px;
flex-wrap: wrap;
}
.wh-form-grid .wh-checkbox-group label {
font-size: 0.78rem;
letter-spacing: 0.5px;
opacity: 1;
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
}
.wh-form-buttons {
grid-column: 1 / -1;
display: flex;
gap: 10px;
justify-content: flex-end;
padding-top: 8px;
border-top: 1px dashed var(--border-color);
}
.wh-secret-modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.wh-secret-modal {
background: var(--secondary-color);
border: 1px solid var(--violet);
box-shadow: 0 0 24px rgba(238, 130, 238, 0.4);
padding: 28px;
max-width: 560px;
width: 100%;
font-family: var(--font-mono);
}
.wh-secret-modal h3 {
margin: 0 0 12px 0;
color: var(--violet);
letter-spacing: 2px;
font-size: 0.9rem;
}
.wh-secret-modal .wh-secret-warn {
color: var(--warn);
font-size: 0.78rem;
margin-bottom: 16px;
}
.wh-secret-modal .wh-secret-value {
background: #0d1117;
border: 1px solid var(--border-color);
padding: 10px 12px;
font-size: 0.85rem;
word-break: break-all;
margin-bottom: 16px;
}
.wh-secret-modal .wh-secret-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
}

View File

@@ -0,0 +1,585 @@
import React, { useEffect, useMemo, useState } from 'react';
import {
Plus, Trash2, Pencil, Zap, AlertTriangle, Copy, X, Save,
Check,
} from 'lucide-react';
import api from '../utils/api';
import { useToast } from './Toasts/useToast';
import './Dashboard.css';
import './Config.css';
import './Webhooks.css';
type SimpleEvent = 'AttackerDetail' | 'DeckyStatus' | 'SystemStatus';
// Server-side canonical expansions (mirrors decnet/webhook/enums.py). Kept
// in sync manually; this is the sugar layer, not the source of truth.
const SIMPLE_PRESETS: Record<SimpleEvent, string[]> = {
AttackerDetail: ['attacker.>'],
DeckyStatus: ['decky.*.state', 'decky.*.traffic'],
SystemStatus: ['system.>'],
};
interface WebhookRow {
uuid: string;
name: string;
url: string;
topic_patterns: string[];
enabled: boolean;
consecutive_failures: number;
last_success_at: string | null;
last_failure_at: string | null;
last_error: string | null;
created_at: string;
updated_at: string;
warnings: string[];
}
interface FormState {
name: string;
url: string;
secret: string; // blank = server auto-generates (create) / keep existing (edit)
simple_events: SimpleEvent[];
topic_patterns: string; // textarea: one per line
enabled: boolean;
}
const BLANK_FORM: FormState = {
name: '',
url: '',
secret: '',
simple_events: [],
topic_patterns: '',
enabled: true,
};
/** Derive which simple-event checkboxes should show as ticked for a given
* persisted pattern list. Only ticks when the intersection is exact —
* mixed custom + preset leaves everything unticked and the textarea is
* the source of truth. */
function deriveSimpleEvents(patterns: string[]): SimpleEvent[] {
const ticked: SimpleEvent[] = [];
const remaining = new Set(patterns);
for (const [name, preset] of Object.entries(SIMPLE_PRESETS) as [SimpleEvent, string[]][]) {
if (preset.every((p) => remaining.has(p))) {
ticked.push(name);
preset.forEach((p) => remaining.delete(p));
}
}
// If anything outside the presets remains, don't tick — user sees raw.
if (remaining.size > 0) return [];
return ticked;
}
function formatDate(iso: string | null): string {
if (!iso) return '—';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
const Webhooks: React.FC = () => {
const [webhooks, setWebhooks] = useState<WebhookRow[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const { push } = useToast();
const [creating, setCreating] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [form, setForm] = useState<FormState>(BLANK_FORM);
const [saving, setSaving] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [deleteArmed, setDeleteArmed] = useState(false);
const [newSecret, setNewSecret] = useState<{ name: string; secret: string } | null>(null);
const insecureCount = useMemo(
() => webhooks.filter((w) => w.warnings.some((msg) => msg.startsWith('insecure_url'))).length,
[webhooks],
);
const fetchWebhooks = async () => {
try {
const res = await api.get('/webhooks/');
setWebhooks(res.data);
setError(null);
} catch (err) {
const msg = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail || 'Failed to load webhooks';
setError(msg);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchWebhooks();
}, []);
const closeForm = () => {
setCreating(false);
setEditingId(null);
setForm(BLANK_FORM);
};
const openCreate = () => {
setEditingId(null);
setForm(BLANK_FORM);
setCreating(true);
};
const openEdit = (w: WebhookRow) => {
setCreating(false);
setEditingId(w.uuid);
const ticked = deriveSimpleEvents(w.topic_patterns);
// If ticking covers everything, leave textarea empty so the presets drive.
// Otherwise surface the raw patterns for editing.
const remaining = ticked.length
? w.topic_patterns.filter((p) =>
!ticked.some((s) => SIMPLE_PRESETS[s].includes(p)))
: w.topic_patterns;
setForm({
name: w.name,
url: w.url,
secret: '',
simple_events: ticked,
topic_patterns: remaining.join('\n'),
enabled: w.enabled,
});
};
const toggleSimpleEvent = (name: SimpleEvent) => {
setForm((f) => ({
...f,
simple_events: f.simple_events.includes(name)
? f.simple_events.filter((s) => s !== name)
: [...f.simple_events, name],
}));
};
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
if (!form.name.trim() || !form.url.trim()) return;
const rawPatterns = form.topic_patterns
.split('\n')
.map((s) => s.trim())
.filter(Boolean);
if (form.simple_events.length === 0 && rawPatterns.length === 0) {
push({ text: 'SELECT AT LEAST ONE EVENT OR PATTERN', tone: 'violet', icon: 'alert-triangle' });
return;
}
setSaving(true);
try {
if (editingId) {
await api.patch(`/webhooks/${editingId}`, {
name: form.name.trim(),
url: form.url.trim(),
secret: form.secret ? form.secret : undefined,
simple_events: form.simple_events,
topic_patterns: rawPatterns,
enabled: form.enabled,
});
push({ text: 'WEBHOOK UPDATED', tone: 'violet', icon: 'check-circle' });
} else {
const res = await api.post('/webhooks/', {
name: form.name.trim(),
url: form.url.trim(),
secret: form.secret ? form.secret : undefined,
simple_events: form.simple_events,
topic_patterns: rawPatterns,
enabled: form.enabled,
});
push({ text: 'WEBHOOK CREATED', tone: 'violet', icon: 'check-circle' });
if (res.data?.secret) {
setNewSecret({ name: res.data.name, secret: res.data.secret });
}
}
closeForm();
await fetchWebhooks();
} catch (err) {
const msg = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail || 'Save failed';
push({ text: `SAVE FAILED · ${msg.toUpperCase()}`, tone: 'violet', icon: 'alert-triangle' });
} finally {
setSaving(false);
}
};
const handleTestOne = async (uuid: string, name: string) => {
try {
const res = await api.post(`/webhooks/${uuid}/test`);
const { delivered, status_code, error: err } = res.data;
if (delivered) {
push({ text: `${name.toUpperCase()} · DELIVERED · ${status_code}`, tone: 'violet', icon: 'zap' });
} else {
push({ text: `${name.toUpperCase()} · FAILED · ${(err || 'unknown').toUpperCase()}`, tone: 'violet', icon: 'alert-triangle' });
}
fetchWebhooks();
} catch (err) {
const msg = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail || 'Test failed';
push({ text: `TEST FAILED · ${msg.toUpperCase()}`, tone: 'violet', icon: 'alert-triangle' });
}
};
const handleDeleteOne = async (uuid: string, name: string) => {
try {
await api.delete(`/webhooks/${uuid}`);
push({ text: `${name.toUpperCase()} · DELETED`, tone: 'violet', icon: 'trash' });
setSelected((s) => {
const n = new Set(s);
n.delete(uuid);
return n;
});
fetchWebhooks();
} catch (err) {
const msg = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail || 'Delete failed';
push({ text: `DELETE FAILED · ${msg.toUpperCase()}`, tone: 'violet', icon: 'alert-triangle' });
}
};
const handleDeleteSelected = async () => {
const ids = Array.from(selected);
const results = await Promise.allSettled(ids.map((id) => api.delete(`/webhooks/${id}`)));
const ok = results.filter((r) => r.status === 'fulfilled').length;
const bad = results.length - ok;
push({
text: bad === 0
? `DELETED · ${ok}`
: `DELETED · ${ok} · FAILED · ${bad}`,
tone: 'violet',
icon: bad ? 'alert-triangle' : 'trash',
});
setSelected(new Set());
setDeleteArmed(false);
fetchWebhooks();
};
const toggleSelect = (uuid: string) => {
setSelected((s) => {
const n = new Set(s);
if (n.has(uuid)) n.delete(uuid);
else n.add(uuid);
return n;
});
};
const toggleSelectAll = () => {
if (selected.size === webhooks.length) setSelected(new Set());
else setSelected(new Set(webhooks.map((w) => w.uuid)));
};
if (loading) {
return <div className="logs-section"><div className="loader">LOADING WEBHOOKS...</div></div>;
}
return (
<div className="logs-section webhooks-page">
<div className="webhooks-header">
<h2>Webhooks · External Alerting</h2>
<div className="webhooks-header-actions">
{selected.size > 0 && (
deleteArmed ? (
<>
<button className="btn alert" onClick={handleDeleteSelected}>
<Check size={14} /> CONFIRM DELETE {selected.size}
</button>
<button className="btn ghost" onClick={() => setDeleteArmed(false)}>
<X size={14} /> CANCEL
</button>
</>
) : (
<button className="btn warn" onClick={() => setDeleteArmed(true)}>
<Trash2 size={14} /> DELETE SELECTED ({selected.size})
</button>
)
)}
<button className="btn violet" onClick={openCreate} disabled={creating || editingId !== null}>
<Plus size={14} /> CREATE WEBHOOK
</button>
</div>
</div>
{error && <div className="config-error">{error}</div>}
{insecureCount > 0 && (
<div className="webhooks-warning-banner">
<AlertTriangle size={16} />
{insecureCount === 1
? '1 webhook uses an http:// URL — event bodies travel plaintext on the wire. HMAC still detects tampering.'
: `${insecureCount} webhooks use http:// URLs — event bodies travel plaintext on the wire. HMAC still detects tampering.`}
</div>
)}
{webhooks.length === 0 && !creating ? (
<div className="webhooks-empty">
NO WEBHOOKS CONFIGURED CLICK CREATE WEBHOOK TO ADD ONE.
</div>
) : (
<div className="webhooks-table-wrap">
<table className="webhooks-table users-table">
<thead>
<tr>
<th className="col-check">
<input
type="checkbox"
checked={webhooks.length > 0 && selected.size === webhooks.length}
onChange={toggleSelectAll}
/>
</th>
<th>NAME</th>
<th>URL</th>
<th>PATTERNS</th>
<th>STATUS</th>
<th>LAST FIRED</th>
<th className="col-actions">ACTIONS</th>
</tr>
</thead>
<tbody>
{creating && (
<FormRow
title="NEW WEBHOOK"
form={form}
setForm={setForm}
onSave={handleSave}
onCancel={closeForm}
saving={saving}
isEdit={false}
onToggleSimple={toggleSimpleEvent}
/>
)}
{webhooks.map((w) => (
editingId === w.uuid ? (
<FormRow
key={w.uuid}
title={`EDIT · ${w.name.toUpperCase()}`}
form={form}
setForm={setForm}
onSave={handleSave}
onCancel={closeForm}
saving={saving}
isEdit
onToggleSimple={toggleSimpleEvent}
/>
) : (
<tr key={w.uuid}>
<td className="col-check">
<input
type="checkbox"
checked={selected.has(w.uuid)}
onChange={() => toggleSelect(w.uuid)}
/>
</td>
<td>{w.name}</td>
<td className="wh-url-cell" title={w.url}>
{w.url}
</td>
<td>
{w.topic_patterns.slice(0, 2).map((p) => (
<span key={p} className="wh-chip">{p}</span>
))}
{w.topic_patterns.length > 2 && (
<span className="wh-chip" title={w.topic_patterns.slice(2).join(', ')}>
+{w.topic_patterns.length - 2}
</span>
)}
</td>
<td>
<span className={`wh-chip ${w.enabled ? '' : 'status-disabled'}`}>
{w.enabled ? 'ENABLED' : 'DISABLED'}
</span>
{w.consecutive_failures > 0 && (
<span className="wh-chip status-fail" title={w.last_error || ''}>
FAIL · {w.consecutive_failures}
</span>
)}
{w.warnings.some((m) => m.startsWith('insecure_url')) && (
<span className="wh-chip status-warn" title="URL uses http://">
HTTP
</span>
)}
</td>
<td>{formatDate(w.last_success_at)}</td>
<td>
<div className="wh-actions">
<button
className="action-btn"
onClick={() => handleTestOne(w.uuid, w.name)}
title="Send synthetic test event"
>
<Zap size={12} />
</button>
<button
className="action-btn"
onClick={() => openEdit(w)}
title="Edit"
disabled={creating || editingId !== null}
>
<Pencil size={12} />
</button>
<button
className="action-btn danger"
onClick={() => handleDeleteOne(w.uuid, w.name)}
title="Delete"
>
<Trash2 size={12} />
</button>
</div>
</td>
</tr>
)
))}
</tbody>
</table>
</div>
)}
{newSecret && (
<SecretModal
name={newSecret.name}
secret={newSecret.secret}
onClose={() => setNewSecret(null)}
/>
)}
</div>
);
};
interface FormRowProps {
title: string;
form: FormState;
setForm: React.Dispatch<React.SetStateAction<FormState>>;
onSave: (e: React.FormEvent) => void;
onCancel: () => void;
saving: boolean;
isEdit: boolean;
onToggleSimple: (n: SimpleEvent) => void;
}
const FormRow: React.FC<FormRowProps> = ({
title, form, setForm, onSave, onCancel, saving, isEdit, onToggleSimple,
}) => (
<tr className="wh-form-row">
<td colSpan={7}>
<form className="wh-form-grid" onSubmit={onSave}>
<label style={{ gridColumn: '1 / -1', fontSize: '0.7rem', color: 'var(--violet)', letterSpacing: '1.5px' }}>
{title}
</label>
<label>NAME</label>
<input
type="text"
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
placeholder="shuffle-prod"
required
maxLength={64}
/>
<label>URL</label>
<input
type="url"
value={form.url}
onChange={(e) => setForm((f) => ({ ...f, url: e.target.value }))}
placeholder="https://shuffle.example.com/api/v1/hooks/webhook_xxx"
required
/>
<label>SECRET {isEdit && <span style={{ opacity: 0.5 }}>(blank = keep existing)</span>}</label>
<input
type="password"
value={form.secret}
onChange={(e) => setForm((f) => ({ ...f, secret: e.target.value }))}
placeholder={isEdit ? '—' : 'leave blank to auto-generate'}
minLength={16}
maxLength={256}
/>
<label>SIMPLE EVENTS</label>
<div className="wh-checkbox-group">
{(['AttackerDetail', 'DeckyStatus', 'SystemStatus'] as const).map((name) => (
<label key={name}>
<input
type="checkbox"
checked={form.simple_events.includes(name)}
onChange={() => onToggleSimple(name)}
/>
{name}
</label>
))}
</div>
<label>ADVANCED PATTERNS<br /><span style={{ opacity: 0.5, fontWeight: 'normal' }}>(one per line, NATS-style)</span></label>
<textarea
value={form.topic_patterns}
onChange={(e) => setForm((f) => ({ ...f, topic_patterns: e.target.value }))}
placeholder={'attacker.>\ndecky.*.state'}
/>
<label>ENABLED</label>
<div className="wh-checkbox-group">
<label>
<input
type="checkbox"
checked={form.enabled}
onChange={(e) => setForm((f) => ({ ...f, enabled: e.target.checked }))}
/>
Receive events
</label>
</div>
<div className="wh-form-buttons">
<button type="button" className="btn ghost" onClick={onCancel} disabled={saving}>
<X size={14} /> CANCEL
</button>
<button type="submit" className="btn violet" disabled={saving}>
<Save size={14} /> {saving ? 'SAVING…' : isEdit ? 'SAVE CHANGES' : 'CREATE'}
</button>
</div>
</form>
</td>
</tr>
);
interface SecretModalProps {
name: string;
secret: string;
onClose: () => void;
}
const SecretModal: React.FC<SecretModalProps> = ({ name, secret, onClose }) => {
const [copied, setCopied] = useState(false);
const copy = async () => {
try {
await navigator.clipboard.writeText(secret);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
/* no-op — browsers without clipboard perms will just see no feedback */
}
};
return (
<div
className="wh-secret-modal-backdrop"
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div className="wh-secret-modal">
<h3>WEBHOOK SECRET · {name.toUpperCase()}</h3>
<div className="wh-secret-warn">
<AlertTriangle size={14} style={{ verticalAlign: 'middle', marginRight: 6 }} />
Copy this now it will not be shown again. The HMAC on every delivery is signed with this value.
</div>
<div className="wh-secret-value">{secret}</div>
<div className="wh-secret-actions">
<button className="btn ghost" onClick={copy}>
<Copy size={14} /> {copied ? 'COPIED' : 'COPY'}
</button>
<button className="btn violet" onClick={onClose}>
<Check size={14} /> DONE
</button>
</div>
</div>
</div>
);
};
export default Webhooks;

View File

@@ -1,4 +1,5 @@
import { useEffect, RefObject } from 'react';
import { useEffect } from 'react';
import type { RefObject } from 'react';
/**
* Focus the given input when the global `decnet:focus-search` event fires