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:
@@ -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 />} />
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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}>
|
||||
|
||||
239
decnet_web/src/components/Webhooks.css
Normal file
239
decnet_web/src/components/Webhooks.css
Normal 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;
|
||||
}
|
||||
585
decnet_web/src/components/Webhooks.tsx
Normal file
585
decnet_web/src/components/Webhooks.tsx
Normal 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;
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user