diff --git a/decnet_web/src/App.tsx b/decnet_web/src/App.tsx index 3a780e11..2c1a688d 100644 --- a/decnet_web/src/App.tsx +++ b/decnet_web/src/App.tsx @@ -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 = ({ onLogout, onSearch, searchQue } /> } /> } /> + } /> } /> } /> } /> diff --git a/decnet_web/src/components/CommandPalette/CommandPalette.tsx b/decnet_web/src/components/CommandPalette/CommandPalette.tsx index 5a03ec97..5c064c8b 100644 --- a/decnet_web/src/components/CommandPalette/CommandPalette.tsx +++ b/decnet_web/src/components/CommandPalette/CommandPalette.tsx @@ -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' }, diff --git a/decnet_web/src/components/Layout.tsx b/decnet_web/src/components/Layout.tsx index ea958b05..ccde37ea 100644 --- a/decnet_web/src/components/Layout.tsx +++ b/decnet_web/src/components/Layout.tsx @@ -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 = { '/': '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 = ({ } label="Dashboard" open={sidebarOpen} /> } label="Decoy Fleet" open={sidebarOpen} /> } label="MazeNET" open={sidebarOpen} /> - } - label="Logs" - open={sidebarOpen} - badge={alertCount} - /> + } open={sidebarOpen}> + } + label="Live Logs" + open={sidebarOpen} + indent + badge={alertCount} + /> + } + label="Webhooks" + open={sidebarOpen} + indent + /> + } label="Bounty" open={sidebarOpen} /> } label="Attackers" open={sidebarOpen} /> } open={sidebarOpen}> diff --git a/decnet_web/src/components/Webhooks.css b/decnet_web/src/components/Webhooks.css new file mode 100644 index 00000000..207203b6 --- /dev/null +++ b/decnet_web/src/components/Webhooks.css @@ -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; +} diff --git a/decnet_web/src/components/Webhooks.tsx b/decnet_web/src/components/Webhooks.tsx new file mode 100644 index 00000000..58864d0a --- /dev/null +++ b/decnet_web/src/components/Webhooks.tsx @@ -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 = { + 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([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const { push } = useToast(); + + const [creating, setCreating] = useState(false); + const [editingId, setEditingId] = useState(null); + const [form, setForm] = useState(BLANK_FORM); + const [saving, setSaving] = useState(false); + + const [selected, setSelected] = useState>(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
LOADING WEBHOOKS...
; + } + + return ( +
+
+

Webhooks · External Alerting

+
+ {selected.size > 0 && ( + deleteArmed ? ( + <> + + + + ) : ( + + ) + )} + +
+
+ + {error &&
{error}
} + + {insecureCount > 0 && ( +
+ + {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.`} +
+ )} + + {webhooks.length === 0 && !creating ? ( +
+ NO WEBHOOKS CONFIGURED — CLICK CREATE WEBHOOK TO ADD ONE. +
+ ) : ( +
+ + + + + + + + + + + + + + {creating && ( + + )} + {webhooks.map((w) => ( + editingId === w.uuid ? ( + + ) : ( + + + + + + + + + + ) + ))} + +
+ 0 && selected.size === webhooks.length} + onChange={toggleSelectAll} + /> + NAMEURLPATTERNSSTATUSLAST FIREDACTIONS
+ toggleSelect(w.uuid)} + /> + {w.name} + {w.url} + + {w.topic_patterns.slice(0, 2).map((p) => ( + {p} + ))} + {w.topic_patterns.length > 2 && ( + + +{w.topic_patterns.length - 2} + + )} + + + {w.enabled ? 'ENABLED' : 'DISABLED'} + + {w.consecutive_failures > 0 && ( + + FAIL · {w.consecutive_failures} + + )} + {w.warnings.some((m) => m.startsWith('insecure_url')) && ( + + HTTP + + )} + {formatDate(w.last_success_at)} +
+ + + +
+
+
+ )} + + {newSecret && ( + setNewSecret(null)} + /> + )} +
+ ); +}; + +interface FormRowProps { + title: string; + form: FormState; + setForm: React.Dispatch>; + onSave: (e: React.FormEvent) => void; + onCancel: () => void; + saving: boolean; + isEdit: boolean; + onToggleSimple: (n: SimpleEvent) => void; +} + +const FormRow: React.FC = ({ + title, form, setForm, onSave, onCancel, saving, isEdit, onToggleSimple, +}) => ( + + +
+ + + + setForm((f) => ({ ...f, name: e.target.value }))} + placeholder="shuffle-prod" + required + maxLength={64} + /> + + + setForm((f) => ({ ...f, url: e.target.value }))} + placeholder="https://shuffle.example.com/api/v1/hooks/webhook_xxx" + required + /> + + + setForm((f) => ({ ...f, secret: e.target.value }))} + placeholder={isEdit ? '—' : 'leave blank to auto-generate'} + minLength={16} + maxLength={256} + /> + + +
+ {(['AttackerDetail', 'DeckyStatus', 'SystemStatus'] as const).map((name) => ( + + ))} +
+ + +