From 4d10eba7a78e74485b25e0f3a93d8ad3c104d525 Mon Sep 17 00:00:00 2001 From: anti Date: Fri, 24 Apr 2026 16:11:20 -0400 Subject: [PATCH] fix(web/webhooks): match LiveLogs page-header convention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The webhooks page used a bespoke .webhooks-header wrapper that didn't line up with the rest of the dashboard (Fleet / Logs / Swarm all use the .-root + .page-header + .page-title-group + .actions pattern). Swapped to that convention: - .webhooks-root wrapper, matching .logs-root / .fleet-root spacing. - H1 "WEBHOOKS" in .page-title-group; subtitle shows `N CONFIGURED · M ENABLED [· K FAILING] [· L INSECURE]` in .page-sub, same voice as the LOGS stream summary. - Actions (CREATE WEBHOOK, DELETE SELECTED) sit in .actions. - Table lives in a proper .logs-section shell with a .section-header carrying the Webhook icon + "SUBSCRIPTIONS" title. - All scoped button overrides (violet/alert/warn/ghost) copied from the LiveLogs scope so theme switches behave identically. Also improve error messaging: extractErrorDetail now maps 401 to "Session expired" and 403 to "Insufficient permissions (admin only)" instead of falling through to the generic "Failed to load webhooks". Helps users who hit the page as viewer or with a stale token see why it failed. --- decnet_web/src/components/Webhooks.css | 243 ++++++++++++------ decnet_web/src/components/Webhooks.tsx | 342 ++++++++++++++----------- 2 files changed, 359 insertions(+), 226 deletions(-) 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 /> - + = ({ ))}
- +