diff --git a/decnet_web/src/components/Webhooks.css b/decnet_web/src/components/Webhooks.css index 207203b6..5a64d8fa 100644 --- a/decnet_web/src/components/Webhooks.css +++ b/decnet_web/src/components/Webhooks.css @@ -1,88 +1,125 @@ -.webhooks-page { +/* Webhooks page — mirrors the .logs-root / .fleet-root / .swarm-root shape. */ + +.webhooks-root { display: flex; flex-direction: column; gap: 24px; } -.webhooks-header { +.webhooks-root .page-title-group { display: flex; - justify-content: space-between; - align-items: center; - padding-bottom: 16px; - border-bottom: 1px solid var(--border-color); + flex-direction: column; + gap: 6px; } -.webhooks-header h2 { +.webhooks-root .page-header h1 { + font-size: 1.3rem; + letter-spacing: 4px; + font-weight: 700; margin: 0; - font-size: 0.95rem; - letter-spacing: 2px; - text-transform: uppercase; - color: var(--text-color); + color: var(--matrix); } -.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; +.webhooks-root .page-sub { + font-size: 0.7rem; + opacity: 0.5; letter-spacing: 1px; +} + +.webhooks-root .page-header .actions { + display: flex; + gap: 10px; + align-items: center; +} + +/* Canonical buttons (copy LiveLogs' scoped rules so theme/accent behaves). */ +.webhooks-root .btn { + cursor: pointer; + background: transparent; + border: 1px solid var(--matrix); + color: var(--matrix); + padding: 7px 14px; + font-family: inherit; + font-size: 0.78rem; + letter-spacing: 1.5px; + display: inline-flex; + align-items: center; + gap: 8px; + transition: all 0.3s ease; +} +.webhooks-root .btn:hover { + background: var(--matrix); + color: #000; + box-shadow: var(--matrix-glow); +} +.webhooks-root .btn.violet { border-color: var(--violet); color: var(--violet); } +.webhooks-root .btn.violet:hover { background: var(--violet); color: #000; box-shadow: var(--violet-glow); } +.webhooks-root .btn.alert { border-color: var(--alert, #ff4d4d); color: var(--alert, #ff4d4d); } +.webhooks-root .btn.alert:hover { background: var(--alert, #ff4d4d); color: #000; box-shadow: 0 0 10px rgba(255, 77, 77, 0.5); } +.webhooks-root .btn.warn { border-color: var(--warn, #e0a040); color: var(--warn, #e0a040); } +.webhooks-root .btn.warn:hover { background: var(--warn, #e0a040); color: #000; box-shadow: 0 0 10px rgba(224, 160, 64, 0.5); } +.webhooks-root .btn.ghost { border-color: var(--border-color); color: var(--matrix); opacity: 0.7; } +.webhooks-root .btn.ghost:hover { opacity: 1; border-color: var(--matrix); background: transparent; box-shadow: var(--matrix-glow); } +.webhooks-root .btn:disabled { opacity: 0.3; cursor: not-allowed; } + +.webhooks-root .webhooks-error { + margin: 0; +} + +.webhooks-root .webhooks-warning-banner { + background: rgba(224, 160, 64, 0.08); + border: 1px solid var(--warn, #e0a040); + color: var(--warn, #e0a040); + padding: 10px 14px; + font-size: 0.7rem; + letter-spacing: 1.5px; display: flex; align-items: center; gap: 10px; } -.webhooks-empty { - padding: 48px; +/* Table container — reuse the .logs-section shell from Dashboard.css. */ + +.webhooks-root .webhooks-empty { + padding: 40px; text-align: center; opacity: 0.5; - font-size: 0.82rem; + font-size: 0.78rem; letter-spacing: 1.5px; - border: 1px dashed var(--border-color); } -.webhooks-table-wrap { +.webhooks-root .webhooks-table-wrap { overflow-x: auto; } -.webhooks-table { +.webhooks-root .webhooks-table { width: 100%; border-collapse: collapse; - font-size: 0.8rem; + font-size: 0.78rem; } -.webhooks-table thead { - font-size: 0.65rem; +.webhooks-root .webhooks-table thead { + font-size: 0.62rem; letter-spacing: 1.5px; opacity: 0.6; } -.webhooks-table th, -.webhooks-table td { - padding: 10px 12px; +.webhooks-root .webhooks-table th, +.webhooks-root .webhooks-table td { + padding: 10px 14px; text-align: left; border-bottom: 1px solid rgba(48, 54, 61, 0.5); vertical-align: middle; } -.webhooks-table tbody tr:hover { +.webhooks-root .webhooks-table tbody tr:hover { background: rgba(0, 255, 65, 0.03); } -.webhooks-table .col-check { - width: 32px; -} +.webhooks-root .webhooks-table .col-check { width: 28px; } +.webhooks-root .webhooks-table .col-actions { width: 140px; } -.webhooks-table .col-actions { - width: 140px; -} - -.wh-url-cell { +.webhooks-root .wh-url-cell { font-family: var(--font-mono); max-width: 260px; overflow: hidden; @@ -90,66 +127,86 @@ white-space: nowrap; } -.wh-chip { - font-size: 0.68rem; - padding: 2px 6px; +/* Chips — mirror the canonical chip spec from UI-Things.md. */ + +.webhooks-root .wh-chip { + font-size: 0.66rem; + padding: 2px 7px; 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); + color: var(--accent); letter-spacing: 0.5px; font-family: var(--font-mono); margin-right: 4px; display: inline-block; } -.wh-chip.status-disabled { +.webhooks-root .wh-chip.status-disabled { border-color: var(--border-color); color: var(--text-color); background: transparent; opacity: 0.6; } -.wh-chip.status-fail { +.webhooks-root .wh-chip.status-fail { border-color: var(--alert, #ff4d4d); background: rgba(255, 77, 77, 0.12); color: var(--alert, #ff4d4d); } -.wh-chip.status-warn { +.webhooks-root .wh-chip.status-warn { border-color: var(--warn, #e0a040); background: rgba(224, 160, 64, 0.1); color: var(--warn, #e0a040); } -.wh-actions { +.webhooks-root .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); +/* Inline form row (create + edit). */ + +.webhooks-root .wh-form-row td { + padding: 20px 20px; + background: rgba(0, 0, 0, 0.25); } -.wh-form-grid { +.webhooks-root .wh-form-grid { display: grid; - grid-template-columns: 1fr 2fr; - gap: 14px; - max-width: 900px; + grid-template-columns: 160px 1fr; + gap: 14px 16px; + max-width: 920px; } -.wh-form-grid label { - font-size: 0.65rem; - letter-spacing: 1px; +.webhooks-root .wh-form-grid label { + font-size: 0.62rem; + letter-spacing: 1.5px; opacity: 0.6; align-self: center; + text-transform: uppercase; } -.wh-form-grid input[type="text"], -.wh-form-grid input[type="url"], -.wh-form-grid input[type="password"], -.wh-form-grid textarea { +.webhooks-root .wh-form-title { + grid-column: 1 / -1; + font-size: 0.7rem; + color: var(--violet); + letter-spacing: 2px; + opacity: 1; + margin: 0 0 4px 0; +} + +.webhooks-root .wh-form-hint { + opacity: 0.5; + font-weight: normal; + letter-spacing: 0.5px; + text-transform: none; +} + +.webhooks-root .wh-form-grid input[type="text"], +.webhooks-root .wh-form-grid input[type="url"], +.webhooks-root .wh-form-grid input[type="password"], +.webhooks-root .wh-form-grid textarea { background: #0d1117; border: 1px solid var(--border-color); color: var(--text-color); @@ -160,36 +217,47 @@ box-sizing: border-box; } -.wh-form-grid textarea { - min-height: 80px; +.webhooks-root .wh-form-grid textarea { + min-height: 72px; font-family: var(--font-mono); } -.wh-form-grid .wh-checkbox-group { +.webhooks-root .wh-form-grid input:focus, +.webhooks-root .wh-form-grid textarea:focus { + outline: none; + border-color: var(--accent); + box-shadow: var(--accent-glow, 0 0 10px rgba(0, 255, 65, 0.5)); +} + +.webhooks-root .wh-checkbox-group { display: flex; gap: 16px; flex-wrap: wrap; + align-items: center; } -.wh-form-grid .wh-checkbox-group label { - font-size: 0.78rem; - letter-spacing: 0.5px; +.webhooks-root .wh-checkbox-group label { + font-size: 0.72rem; + letter-spacing: 1px; opacity: 1; display: flex; align-items: center; gap: 6px; cursor: pointer; + text-transform: none; } -.wh-form-buttons { +.webhooks-root .wh-form-buttons { grid-column: 1 / -1; display: flex; gap: 10px; justify-content: flex-end; - padding-top: 8px; + padding-top: 10px; border-top: 1px dashed var(--border-color); } +/* Secret-modal — one-shot display after create. */ + .wh-secret-modal-backdrop { position: fixed; inset: 0; @@ -218,9 +286,13 @@ } .wh-secret-modal .wh-secret-warn { - color: var(--warn); - font-size: 0.78rem; + color: var(--warn, #e0a040); + font-size: 0.7rem; + letter-spacing: 1px; margin-bottom: 16px; + display: flex; + align-items: center; + gap: 8px; } .wh-secret-modal .wh-secret-value { @@ -237,3 +309,22 @@ gap: 10px; justify-content: flex-end; } + +.wh-secret-modal .btn { + cursor: pointer; + background: transparent; + border: 1px solid var(--matrix); + color: var(--matrix); + padding: 7px 14px; + font-family: inherit; + font-size: 0.78rem; + letter-spacing: 1.5px; + display: inline-flex; + align-items: center; + gap: 8px; + transition: all 0.3s ease; +} +.wh-secret-modal .btn.violet { border-color: var(--violet); color: var(--violet); } +.wh-secret-modal .btn.violet:hover { background: var(--violet); color: #000; box-shadow: var(--violet-glow); } +.wh-secret-modal .btn.ghost { border-color: var(--border-color); color: var(--matrix); opacity: 0.7; } +.wh-secret-modal .btn.ghost:hover { opacity: 1; border-color: var(--matrix); background: transparent; box-shadow: var(--matrix-glow); } diff --git a/decnet_web/src/components/Webhooks.tsx b/decnet_web/src/components/Webhooks.tsx index 58864d0a..cc79ce22 100644 --- a/decnet_web/src/components/Webhooks.tsx +++ b/decnet_web/src/components/Webhooks.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useMemo, useState } from 'react'; import { Plus, Trash2, Pencil, Zap, AlertTriangle, Copy, X, Save, - Check, + Check, Webhook as WebhookIcon, } from 'lucide-react'; import api from '../utils/api'; import { useToast } from './Toasts/useToast'; @@ -52,6 +52,18 @@ const BLANK_FORM: FormState = { enabled: true, }; +function extractErrorDetail(err: unknown, fallback: string): string { + const e = err as { + response?: { status?: number; data?: { detail?: string } }; + message?: string; + }; + if (e?.response?.data?.detail) return e.response.data.detail; + if (e?.response?.status === 403) return 'Insufficient permissions (admin only)'; + if (e?.response?.status === 401) return 'Session expired — please log in again'; + if (e?.message) return e.message; + return fallback; +} + /** 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 @@ -99,14 +111,19 @@ const Webhooks: React.FC = () => { [webhooks], ); + const enabledCount = useMemo(() => webhooks.filter((w) => w.enabled).length, [webhooks]); + const failCount = useMemo( + () => webhooks.filter((w) => w.consecutive_failures > 0).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); + setError(extractErrorDetail(err, 'Failed to load webhooks')); } finally { setLoading(false); } @@ -132,8 +149,6 @@ const Webhooks: React.FC = () => { 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))) @@ -198,7 +213,7 @@ const Webhooks: React.FC = () => { closeForm(); await fetchWebhooks(); } catch (err) { - const msg = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail || 'Save failed'; + const msg = extractErrorDetail(err, 'Save failed'); push({ text: `SAVE FAILED · ${msg.toUpperCase()}`, tone: 'violet', icon: 'alert-triangle' }); } finally { setSaving(false); @@ -216,7 +231,7 @@ const Webhooks: React.FC = () => { } fetchWebhooks(); } catch (err) { - const msg = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail || 'Test failed'; + const msg = extractErrorDetail(err, 'Test failed'); push({ text: `TEST FAILED · ${msg.toUpperCase()}`, tone: 'violet', icon: 'alert-triangle' }); } }; @@ -232,7 +247,7 @@ const Webhooks: React.FC = () => { }); fetchWebhooks(); } catch (err) { - const msg = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail || 'Delete failed'; + const msg = extractErrorDetail(err, 'Delete failed'); push({ text: `DELETE FAILED · ${msg.toUpperCase()}`, tone: 'violet', icon: 'alert-triangle' }); } }; @@ -268,170 +283,193 @@ const Webhooks: React.FC = () => { else setSelected(new Set(webhooks.map((w) => w.uuid))); }; - if (loading) { - return
LOADING WEBHOOKS...
; - } - return ( -
-
-

Webhooks · External Alerting

-
+
+
+
+

WEBHOOKS

+ + {webhooks.length} CONFIGURED · {enabledCount} ENABLED + {failCount > 0 && ` · ${failCount} FAILING`} + {insecureCount > 0 && ` · ${insecureCount} INSECURE`} + +
+
{selected.size > 0 && ( deleteArmed ? ( <> ) : ( ) )} -
- {error &&
{error}
} + {error &&
{error}
} - {insecureCount > 0 && ( + {insecureCount > 0 && !error && (
- - {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.`} + + + {insecureCount === 1 + ? '1 WEBHOOK USING HTTP:// — EVENT BODIES TRAVEL PLAINTEXT. HMAC STILL DETECTS TAMPERING.' + : `${insecureCount} WEBHOOKS USING HTTP:// — EVENT BODIES TRAVEL PLAINTEXT. HMAC STILL DETECTS TAMPERING.`} +
)} - {webhooks.length === 0 && !creating ? ( -
- NO WEBHOOKS CONFIGURED — CLICK CREATE WEBHOOK TO ADD ONE. +
+
+
+ + SUBSCRIPTIONS +
+
+ SHOWING {webhooks.length} +
- ) : ( -
- - - - - - - - - - - - - - {creating && ( - - )} - {webhooks.map((w) => ( - editingId === w.uuid ? ( + + {loading ? ( +
LOADING WEBHOOKS…
+ ) : webhooks.length === 0 && !creating ? ( +
+ NO WEBHOOKS CONFIGURED — CLICK CREATE WEBHOOK TO ADD ONE. +
+ ) : ( +
+
- 0 && selected.size === webhooks.length} - onChange={toggleSelectAll} - /> - NAMEURLPATTERNSSTATUSLAST FIREDACTIONS
+ + + + + + + + + + + + + {creating && ( - ) : ( - - - - - + + + + + - - - - - ) - ))} - -
+ 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} + )} + {webhooks.map((w) => ( + editingId === w.uuid ? ( + + ) : ( +
+ 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.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)} -
- - - -
-
-
- )} + {w.consecutive_failures > 0 && ( + + FAIL · {w.consecutive_failures} + + )} + {w.warnings.some((m) => m.startsWith('insecure_url')) && ( + + HTTP + + )} + + {formatDate(w.last_success_at)} + +
+ + + +
+ + + ) + ))} + + +
+ )} +
{newSecret && ( = ({
- + = ({ required /> - + = ({ ))}
- +