fix(web/webhooks): match LiveLogs page-header convention

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 .<page>-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.
This commit is contained in:
2026-04-24 16:11:20 -04:00
parent 59c405d9e5
commit 4d10eba7a7
2 changed files with 359 additions and 226 deletions

View File

@@ -1,88 +1,125 @@
.webhooks-page { /* Webhooks page — mirrors the .logs-root / .fleet-root / .swarm-root shape. */
.webhooks-root {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 24px; gap: 24px;
} }
.webhooks-header { .webhooks-root .page-title-group {
display: flex; display: flex;
justify-content: space-between; flex-direction: column;
align-items: center; gap: 6px;
padding-bottom: 16px;
border-bottom: 1px solid var(--border-color);
} }
.webhooks-header h2 { .webhooks-root .page-header h1 {
font-size: 1.3rem;
letter-spacing: 4px;
font-weight: 700;
margin: 0; margin: 0;
font-size: 0.95rem; color: var(--matrix);
letter-spacing: 2px;
text-transform: uppercase;
color: var(--text-color);
} }
.webhooks-header-actions { .webhooks-root .page-sub {
display: flex; font-size: 0.7rem;
gap: 12px; opacity: 0.5;
}
.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; 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; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
} }
.webhooks-empty { /* Table container — reuse the .logs-section shell from Dashboard.css. */
padding: 48px;
.webhooks-root .webhooks-empty {
padding: 40px;
text-align: center; text-align: center;
opacity: 0.5; opacity: 0.5;
font-size: 0.82rem; font-size: 0.78rem;
letter-spacing: 1.5px; letter-spacing: 1.5px;
border: 1px dashed var(--border-color);
} }
.webhooks-table-wrap { .webhooks-root .webhooks-table-wrap {
overflow-x: auto; overflow-x: auto;
} }
.webhooks-table { .webhooks-root .webhooks-table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
font-size: 0.8rem; font-size: 0.78rem;
} }
.webhooks-table thead { .webhooks-root .webhooks-table thead {
font-size: 0.65rem; font-size: 0.62rem;
letter-spacing: 1.5px; letter-spacing: 1.5px;
opacity: 0.6; opacity: 0.6;
} }
.webhooks-table th, .webhooks-root .webhooks-table th,
.webhooks-table td { .webhooks-root .webhooks-table td {
padding: 10px 12px; padding: 10px 14px;
text-align: left; text-align: left;
border-bottom: 1px solid rgba(48, 54, 61, 0.5); border-bottom: 1px solid rgba(48, 54, 61, 0.5);
vertical-align: middle; vertical-align: middle;
} }
.webhooks-table tbody tr:hover { .webhooks-root .webhooks-table tbody tr:hover {
background: rgba(0, 255, 65, 0.03); background: rgba(0, 255, 65, 0.03);
} }
.webhooks-table .col-check { .webhooks-root .webhooks-table .col-check { width: 28px; }
width: 32px; .webhooks-root .webhooks-table .col-actions { width: 140px; }
}
.webhooks-table .col-actions { .webhooks-root .wh-url-cell {
width: 140px;
}
.wh-url-cell {
font-family: var(--font-mono); font-family: var(--font-mono);
max-width: 260px; max-width: 260px;
overflow: hidden; overflow: hidden;
@@ -90,66 +127,86 @@
white-space: nowrap; white-space: nowrap;
} }
.wh-chip { /* Chips — mirror the canonical chip spec from UI-Things.md. */
font-size: 0.68rem;
padding: 2px 6px; .webhooks-root .wh-chip {
font-size: 0.66rem;
padding: 2px 7px;
border: 1px solid var(--accent-tint-30, rgba(0, 255, 65, 0.3)); border: 1px solid var(--accent-tint-30, rgba(0, 255, 65, 0.3));
background: var(--accent-tint-10, rgba(0, 255, 65, 0.1)); background: var(--accent-tint-10, rgba(0, 255, 65, 0.1));
color: var(--accent-color); color: var(--accent);
letter-spacing: 0.5px; letter-spacing: 0.5px;
font-family: var(--font-mono); font-family: var(--font-mono);
margin-right: 4px; margin-right: 4px;
display: inline-block; display: inline-block;
} }
.wh-chip.status-disabled { .webhooks-root .wh-chip.status-disabled {
border-color: var(--border-color); border-color: var(--border-color);
color: var(--text-color); color: var(--text-color);
background: transparent; background: transparent;
opacity: 0.6; opacity: 0.6;
} }
.wh-chip.status-fail { .webhooks-root .wh-chip.status-fail {
border-color: var(--alert, #ff4d4d); border-color: var(--alert, #ff4d4d);
background: rgba(255, 77, 77, 0.12); background: rgba(255, 77, 77, 0.12);
color: var(--alert, #ff4d4d); color: var(--alert, #ff4d4d);
} }
.wh-chip.status-warn { .webhooks-root .wh-chip.status-warn {
border-color: var(--warn, #e0a040); border-color: var(--warn, #e0a040);
background: rgba(224, 160, 64, 0.1); background: rgba(224, 160, 64, 0.1);
color: var(--warn, #e0a040); color: var(--warn, #e0a040);
} }
.wh-actions { .webhooks-root .wh-actions {
display: flex; display: flex;
gap: 6px; gap: 6px;
} }
/* Inline create / edit form, rendered as an expanded row. */ /* Inline form row (create + edit). */
.wh-form-row td {
padding: 20px 12px; .webhooks-root .wh-form-row td {
background: rgba(0, 0, 0, 0.2); padding: 20px 20px;
background: rgba(0, 0, 0, 0.25);
} }
.wh-form-grid { .webhooks-root .wh-form-grid {
display: grid; display: grid;
grid-template-columns: 1fr 2fr; grid-template-columns: 160px 1fr;
gap: 14px; gap: 14px 16px;
max-width: 900px; max-width: 920px;
} }
.wh-form-grid label { .webhooks-root .wh-form-grid label {
font-size: 0.65rem; font-size: 0.62rem;
letter-spacing: 1px; letter-spacing: 1.5px;
opacity: 0.6; opacity: 0.6;
align-self: center; align-self: center;
text-transform: uppercase;
} }
.wh-form-grid input[type="text"], .webhooks-root .wh-form-title {
.wh-form-grid input[type="url"], grid-column: 1 / -1;
.wh-form-grid input[type="password"], font-size: 0.7rem;
.wh-form-grid textarea { 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; background: #0d1117;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
color: var(--text-color); color: var(--text-color);
@@ -160,36 +217,47 @@
box-sizing: border-box; box-sizing: border-box;
} }
.wh-form-grid textarea { .webhooks-root .wh-form-grid textarea {
min-height: 80px; min-height: 72px;
font-family: var(--font-mono); 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; display: flex;
gap: 16px; gap: 16px;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center;
} }
.wh-form-grid .wh-checkbox-group label { .webhooks-root .wh-checkbox-group label {
font-size: 0.78rem; font-size: 0.72rem;
letter-spacing: 0.5px; letter-spacing: 1px;
opacity: 1; opacity: 1;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
cursor: pointer; cursor: pointer;
text-transform: none;
} }
.wh-form-buttons { .webhooks-root .wh-form-buttons {
grid-column: 1 / -1; grid-column: 1 / -1;
display: flex; display: flex;
gap: 10px; gap: 10px;
justify-content: flex-end; justify-content: flex-end;
padding-top: 8px; padding-top: 10px;
border-top: 1px dashed var(--border-color); border-top: 1px dashed var(--border-color);
} }
/* Secret-modal — one-shot display after create. */
.wh-secret-modal-backdrop { .wh-secret-modal-backdrop {
position: fixed; position: fixed;
inset: 0; inset: 0;
@@ -218,9 +286,13 @@
} }
.wh-secret-modal .wh-secret-warn { .wh-secret-modal .wh-secret-warn {
color: var(--warn); color: var(--warn, #e0a040);
font-size: 0.78rem; font-size: 0.7rem;
letter-spacing: 1px;
margin-bottom: 16px; margin-bottom: 16px;
display: flex;
align-items: center;
gap: 8px;
} }
.wh-secret-modal .wh-secret-value { .wh-secret-modal .wh-secret-value {
@@ -237,3 +309,22 @@
gap: 10px; gap: 10px;
justify-content: flex-end; 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); }

View File

@@ -1,7 +1,7 @@
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { import {
Plus, Trash2, Pencil, Zap, AlertTriangle, Copy, X, Save, Plus, Trash2, Pencil, Zap, AlertTriangle, Copy, X, Save,
Check, Check, Webhook as WebhookIcon,
} from 'lucide-react'; } from 'lucide-react';
import api from '../utils/api'; import api from '../utils/api';
import { useToast } from './Toasts/useToast'; import { useToast } from './Toasts/useToast';
@@ -52,6 +52,18 @@ const BLANK_FORM: FormState = {
enabled: true, 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 /** Derive which simple-event checkboxes should show as ticked for a given
* persisted pattern list. Only ticks when the intersection is exact — * persisted pattern list. Only ticks when the intersection is exact —
* mixed custom + preset leaves everything unticked and the textarea is * mixed custom + preset leaves everything unticked and the textarea is
@@ -99,14 +111,19 @@ const Webhooks: React.FC = () => {
[webhooks], [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 () => { const fetchWebhooks = async () => {
try { try {
const res = await api.get('/webhooks/'); const res = await api.get('/webhooks/');
setWebhooks(res.data); setWebhooks(res.data);
setError(null); setError(null);
} catch (err) { } catch (err) {
const msg = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail || 'Failed to load webhooks'; setError(extractErrorDetail(err, 'Failed to load webhooks'));
setError(msg);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -132,8 +149,6 @@ const Webhooks: React.FC = () => {
setCreating(false); setCreating(false);
setEditingId(w.uuid); setEditingId(w.uuid);
const ticked = deriveSimpleEvents(w.topic_patterns); 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 const remaining = ticked.length
? w.topic_patterns.filter((p) => ? w.topic_patterns.filter((p) =>
!ticked.some((s) => SIMPLE_PRESETS[s].includes(p))) !ticked.some((s) => SIMPLE_PRESETS[s].includes(p)))
@@ -198,7 +213,7 @@ const Webhooks: React.FC = () => {
closeForm(); closeForm();
await fetchWebhooks(); await fetchWebhooks();
} catch (err) { } 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' }); push({ text: `SAVE FAILED · ${msg.toUpperCase()}`, tone: 'violet', icon: 'alert-triangle' });
} finally { } finally {
setSaving(false); setSaving(false);
@@ -216,7 +231,7 @@ const Webhooks: React.FC = () => {
} }
fetchWebhooks(); fetchWebhooks();
} catch (err) { } 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' }); push({ text: `TEST FAILED · ${msg.toUpperCase()}`, tone: 'violet', icon: 'alert-triangle' });
} }
}; };
@@ -232,7 +247,7 @@ const Webhooks: React.FC = () => {
}); });
fetchWebhooks(); fetchWebhooks();
} catch (err) { } 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' }); 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))); else setSelected(new Set(webhooks.map((w) => w.uuid)));
}; };
if (loading) {
return <div className="logs-section"><div className="loader">LOADING WEBHOOKS...</div></div>;
}
return ( return (
<div className="logs-section webhooks-page"> <div className="webhooks-root">
<div className="webhooks-header"> <div className="page-header">
<h2>Webhooks · External Alerting</h2> <div className="page-title-group">
<div className="webhooks-header-actions"> <h1>WEBHOOKS</h1>
<span className="page-sub">
{webhooks.length} CONFIGURED · {enabledCount} ENABLED
{failCount > 0 && ` · ${failCount} FAILING`}
{insecureCount > 0 && ` · ${insecureCount} INSECURE`}
</span>
</div>
<div className="actions">
{selected.size > 0 && ( {selected.size > 0 && (
deleteArmed ? ( deleteArmed ? (
<> <>
<button className="btn alert" onClick={handleDeleteSelected}> <button className="btn alert" onClick={handleDeleteSelected}>
<Check size={14} /> CONFIRM DELETE {selected.size} <Check size={12} /> CONFIRM DELETE {selected.size}
</button> </button>
<button className="btn ghost" onClick={() => setDeleteArmed(false)}> <button className="btn ghost" onClick={() => setDeleteArmed(false)}>
<X size={14} /> CANCEL <X size={12} /> CANCEL
</button> </button>
</> </>
) : ( ) : (
<button className="btn warn" onClick={() => setDeleteArmed(true)}> <button className="btn warn" onClick={() => setDeleteArmed(true)}>
<Trash2 size={14} /> DELETE SELECTED ({selected.size}) <Trash2 size={12} /> DELETE SELECTED ({selected.size})
</button> </button>
) )
)} )}
<button className="btn violet" onClick={openCreate} disabled={creating || editingId !== null}> <button
<Plus size={14} /> CREATE WEBHOOK className="btn violet"
onClick={openCreate}
disabled={creating || editingId !== null}
>
<Plus size={12} /> CREATE WEBHOOK
</button> </button>
</div> </div>
</div> </div>
{error && <div className="config-error">{error}</div>} {error && <div className="config-error webhooks-error">{error}</div>}
{insecureCount > 0 && ( {insecureCount > 0 && !error && (
<div className="webhooks-warning-banner"> <div className="webhooks-warning-banner">
<AlertTriangle size={16} /> <AlertTriangle size={14} />
{insecureCount === 1 <span>
? '1 webhook uses an http:// URL — event bodies travel plaintext on the wire. HMAC still detects tampering.' {insecureCount === 1
: `${insecureCount} webhooks use http:// URLs — event bodies travel plaintext on the wire. HMAC still detects tampering.`} ? '1 WEBHOOK USING HTTP:// — EVENT BODIES TRAVEL PLAINTEXT. HMAC STILL DETECTS TAMPERING.'
: `${insecureCount} WEBHOOKS USING HTTP:// — EVENT BODIES TRAVEL PLAINTEXT. HMAC STILL DETECTS TAMPERING.`}
</span>
</div> </div>
)} )}
{webhooks.length === 0 && !creating ? ( <div className="logs-section">
<div className="webhooks-empty"> <div className="section-header">
NO WEBHOOKS CONFIGURED CLICK CREATE WEBHOOK TO ADD ONE. <div className="section-title">
<WebhookIcon size={14} />
<span>SUBSCRIPTIONS</span>
</div>
<div className="section-actions">
<span>SHOWING {webhooks.length}</span>
</div>
</div> </div>
) : (
<div className="webhooks-table-wrap"> {loading ? (
<table className="webhooks-table users-table"> <div className="webhooks-empty">LOADING WEBHOOKS</div>
<thead> ) : webhooks.length === 0 && !creating ? (
<tr> <div className="webhooks-empty">
<th className="col-check"> NO WEBHOOKS CONFIGURED CLICK CREATE WEBHOOK TO ADD ONE.
<input </div>
type="checkbox" ) : (
checked={webhooks.length > 0 && selected.size === webhooks.length} <div className="webhooks-table-wrap">
onChange={toggleSelectAll} <table className="webhooks-table users-table">
/> <thead>
</th> <tr>
<th>NAME</th> <th className="col-check">
<th>URL</th> <input
<th>PATTERNS</th> type="checkbox"
<th>STATUS</th> checked={webhooks.length > 0 && selected.size === webhooks.length}
<th>LAST FIRED</th> onChange={toggleSelectAll}
<th className="col-actions">ACTIONS</th> />
</tr> </th>
</thead> <th>NAME</th>
<tbody> <th>URL</th>
{creating && ( <th>PATTERNS</th>
<FormRow <th>STATUS</th>
title="NEW WEBHOOK" <th>LAST FIRED</th>
form={form} <th className="col-actions">ACTIONS</th>
setForm={setForm} </tr>
onSave={handleSave} </thead>
onCancel={closeForm} <tbody>
saving={saving} {creating && (
isEdit={false}
onToggleSimple={toggleSimpleEvent}
/>
)}
{webhooks.map((w) => (
editingId === w.uuid ? (
<FormRow <FormRow
key={w.uuid} title="NEW WEBHOOK"
title={`EDIT · ${w.name.toUpperCase()}`}
form={form} form={form}
setForm={setForm} setForm={setForm}
onSave={handleSave} onSave={handleSave}
onCancel={closeForm} onCancel={closeForm}
saving={saving} saving={saving}
isEdit isEdit={false}
onToggleSimple={toggleSimpleEvent} onToggleSimple={toggleSimpleEvent}
/> />
) : ( )}
<tr key={w.uuid}> {webhooks.map((w) => (
<td className="col-check"> editingId === w.uuid ? (
<input <FormRow
type="checkbox" key={w.uuid}
checked={selected.has(w.uuid)} title={`EDIT · ${w.name.toUpperCase()}`}
onChange={() => toggleSelect(w.uuid)} form={form}
/> setForm={setForm}
</td> onSave={handleSave}
<td>{w.name}</td> onCancel={closeForm}
<td className="wh-url-cell" title={w.url}> saving={saving}
{w.url} isEdit
</td> onToggleSimple={toggleSimpleEvent}
<td> />
{w.topic_patterns.slice(0, 2).map((p) => ( ) : (
<span key={p} className="wh-chip">{p}</span> <tr key={w.uuid}>
))} <td className="col-check">
{w.topic_patterns.length > 2 && ( <input
<span className="wh-chip" title={w.topic_patterns.slice(2).join(', ')}> type="checkbox"
+{w.topic_patterns.length - 2} 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> </span>
)} {w.consecutive_failures > 0 && (
</td> <span className="wh-chip status-fail" title={w.last_error || ''}>
<td> FAIL · {w.consecutive_failures}
<span className={`wh-chip ${w.enabled ? '' : 'status-disabled'}`}> </span>
{w.enabled ? 'ENABLED' : 'DISABLED'} )}
</span> {w.warnings.some((m) => m.startsWith('insecure_url')) && (
{w.consecutive_failures > 0 && ( <span className="wh-chip status-warn" title="URL uses http://">
<span className="wh-chip status-fail" title={w.last_error || ''}> HTTP
FAIL · {w.consecutive_failures} </span>
</span> )}
)} </td>
{w.warnings.some((m) => m.startsWith('insecure_url')) && ( <td>{formatDate(w.last_success_at)}</td>
<span className="wh-chip status-warn" title="URL uses http://"> <td>
HTTP <div className="wh-actions">
</span> <button
)} className="action-btn"
</td> onClick={() => handleTestOne(w.uuid, w.name)}
<td>{formatDate(w.last_success_at)}</td> title="Send synthetic test event"
<td> >
<div className="wh-actions"> <Zap size={12} />
<button </button>
className="action-btn" <button
onClick={() => handleTestOne(w.uuid, w.name)} className="action-btn"
title="Send synthetic test event" onClick={() => openEdit(w)}
> title="Edit"
<Zap size={12} /> disabled={creating || editingId !== null}
</button> >
<button <Pencil size={12} />
className="action-btn" </button>
onClick={() => openEdit(w)} <button
title="Edit" className="action-btn danger"
disabled={creating || editingId !== null} onClick={() => handleDeleteOne(w.uuid, w.name)}
> title="Delete"
<Pencil size={12} /> >
</button> <Trash2 size={12} />
<button </button>
className="action-btn danger" </div>
onClick={() => handleDeleteOne(w.uuid, w.name)} </td>
title="Delete" </tr>
> )
<Trash2 size={12} /> ))}
</button> </tbody>
</div> </table>
</td> </div>
</tr> )}
) </div>
))}
</tbody>
</table>
</div>
)}
{newSecret && ( {newSecret && (
<SecretModal <SecretModal
@@ -461,9 +499,7 @@ const FormRow: React.FC<FormRowProps> = ({
<tr className="wh-form-row"> <tr className="wh-form-row">
<td colSpan={7}> <td colSpan={7}>
<form className="wh-form-grid" onSubmit={onSave}> <form className="wh-form-grid" onSubmit={onSave}>
<label style={{ gridColumn: '1 / -1', fontSize: '0.7rem', color: 'var(--violet)', letterSpacing: '1.5px' }}> <label className="wh-form-title">{title}</label>
{title}
</label>
<label>NAME</label> <label>NAME</label>
<input <input
@@ -484,7 +520,9 @@ const FormRow: React.FC<FormRowProps> = ({
required required
/> />
<label>SECRET {isEdit && <span style={{ opacity: 0.5 }}>(blank = keep existing)</span>}</label> <label>
SECRET {isEdit && <span className="wh-form-hint">(blank = keep existing)</span>}
</label>
<input <input
type="password" type="password"
value={form.secret} value={form.secret}
@@ -508,7 +546,11 @@ const FormRow: React.FC<FormRowProps> = ({
))} ))}
</div> </div>
<label>ADVANCED PATTERNS<br /><span style={{ opacity: 0.5, fontWeight: 'normal' }}>(one per line, NATS-style)</span></label> <label>
ADVANCED PATTERNS
<br />
<span className="wh-form-hint">(one per line, NATS-style)</span>
</label>
<textarea <textarea
value={form.topic_patterns} value={form.topic_patterns}
onChange={(e) => setForm((f) => ({ ...f, topic_patterns: e.target.value }))} onChange={(e) => setForm((f) => ({ ...f, topic_patterns: e.target.value }))}
@@ -529,10 +571,10 @@ const FormRow: React.FC<FormRowProps> = ({
<div className="wh-form-buttons"> <div className="wh-form-buttons">
<button type="button" className="btn ghost" onClick={onCancel} disabled={saving}> <button type="button" className="btn ghost" onClick={onCancel} disabled={saving}>
<X size={14} /> CANCEL <X size={12} /> CANCEL
</button> </button>
<button type="submit" className="btn violet" disabled={saving}> <button type="submit" className="btn violet" disabled={saving}>
<Save size={14} /> {saving ? 'SAVING…' : isEdit ? 'SAVE CHANGES' : 'CREATE'} <Save size={12} /> {saving ? 'SAVING…' : isEdit ? 'SAVE CHANGES' : 'CREATE'}
</button> </button>
</div> </div>
</form> </form>
@@ -565,16 +607,16 @@ const SecretModal: React.FC<SecretModalProps> = ({ name, secret, onClose }) => {
<div className="wh-secret-modal"> <div className="wh-secret-modal">
<h3>WEBHOOK SECRET · {name.toUpperCase()}</h3> <h3>WEBHOOK SECRET · {name.toUpperCase()}</h3>
<div className="wh-secret-warn"> <div className="wh-secret-warn">
<AlertTriangle size={14} style={{ verticalAlign: 'middle', marginRight: 6 }} /> <AlertTriangle size={14} />
Copy this now it will not be shown again. The HMAC on every delivery is signed with this value. <span>COPY THIS NOW IT WILL NOT BE SHOWN AGAIN. THE HMAC ON EVERY DELIVERY IS SIGNED WITH THIS VALUE.</span>
</div> </div>
<div className="wh-secret-value">{secret}</div> <div className="wh-secret-value">{secret}</div>
<div className="wh-secret-actions"> <div className="wh-secret-actions">
<button className="btn ghost" onClick={copy}> <button className="btn ghost" onClick={copy}>
<Copy size={14} /> {copied ? 'COPIED' : 'COPY'} <Copy size={12} /> {copied ? 'COPIED' : 'COPY'}
</button> </button>
<button className="btn violet" onClick={onClose}> <button className="btn violet" onClick={onClose}>
<Check size={14} /> DONE <Check size={12} /> DONE
</button> </button>
</div> </div>
</div> </div>