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:
@@ -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); }
|
||||
|
||||
@@ -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 <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">
|
||||
<div className="webhooks-root">
|
||||
<div className="page-header">
|
||||
<div className="page-title-group">
|
||||
<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 && (
|
||||
deleteArmed ? (
|
||||
<>
|
||||
<button className="btn alert" onClick={handleDeleteSelected}>
|
||||
<Check size={14} /> CONFIRM DELETE {selected.size}
|
||||
<Check size={12} /> CONFIRM DELETE {selected.size}
|
||||
</button>
|
||||
<button className="btn ghost" onClick={() => setDeleteArmed(false)}>
|
||||
<X size={14} /> CANCEL
|
||||
<X size={12} /> CANCEL
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button className="btn warn" onClick={() => setDeleteArmed(true)}>
|
||||
<Trash2 size={14} /> DELETE SELECTED ({selected.size})
|
||||
<Trash2 size={12} /> DELETE SELECTED ({selected.size})
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
<button className="btn violet" onClick={openCreate} disabled={creating || editingId !== null}>
|
||||
<Plus size={14} /> CREATE WEBHOOK
|
||||
<button
|
||||
className="btn violet"
|
||||
onClick={openCreate}
|
||||
disabled={creating || editingId !== null}
|
||||
>
|
||||
<Plus size={12} /> CREATE WEBHOOK
|
||||
</button>
|
||||
</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">
|
||||
<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.`}
|
||||
<AlertTriangle size={14} />
|
||||
<span>
|
||||
{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.`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{webhooks.length === 0 && !creating ? (
|
||||
<div className="webhooks-empty">
|
||||
NO WEBHOOKS CONFIGURED — CLICK CREATE WEBHOOK TO ADD ONE.
|
||||
<div className="logs-section">
|
||||
<div className="section-header">
|
||||
<div className="section-title">
|
||||
<WebhookIcon size={14} />
|
||||
<span>SUBSCRIPTIONS</span>
|
||||
</div>
|
||||
<div className="section-actions">
|
||||
<span>SHOWING {webhooks.length}</span>
|
||||
</div>
|
||||
</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 ? (
|
||||
|
||||
{loading ? (
|
||||
<div className="webhooks-empty">LOADING WEBHOOKS…</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
|
||||
key={w.uuid}
|
||||
title={`EDIT · ${w.name.toUpperCase()}`}
|
||||
title="NEW WEBHOOK"
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
onSave={handleSave}
|
||||
onCancel={closeForm}
|
||||
saving={saving}
|
||||
isEdit
|
||||
isEdit={false}
|
||||
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}
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{newSecret && (
|
||||
<SecretModal
|
||||
@@ -461,9 +499,7 @@ const FormRow: React.FC<FormRowProps> = ({
|
||||
<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 className="wh-form-title">{title}</label>
|
||||
|
||||
<label>NAME</label>
|
||||
<input
|
||||
@@ -484,7 +520,9 @@ const FormRow: React.FC<FormRowProps> = ({
|
||||
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
|
||||
type="password"
|
||||
value={form.secret}
|
||||
@@ -508,7 +546,11 @@ const FormRow: React.FC<FormRowProps> = ({
|
||||
))}
|
||||
</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
|
||||
value={form.topic_patterns}
|
||||
onChange={(e) => setForm((f) => ({ ...f, topic_patterns: e.target.value }))}
|
||||
@@ -529,10 +571,10 @@ const FormRow: React.FC<FormRowProps> = ({
|
||||
|
||||
<div className="wh-form-buttons">
|
||||
<button type="button" className="btn ghost" onClick={onCancel} disabled={saving}>
|
||||
<X size={14} /> CANCEL
|
||||
<X size={12} /> CANCEL
|
||||
</button>
|
||||
<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>
|
||||
</div>
|
||||
</form>
|
||||
@@ -565,16 +607,16 @@ const SecretModal: React.FC<SecretModalProps> = ({ name, secret, 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.
|
||||
<AlertTriangle size={14} />
|
||||
<span>COPY THIS NOW — IT WILL NOT BE SHOWN AGAIN. THE HMAC ON EVERY DELIVERY IS SIGNED WITH THIS VALUE.</span>
|
||||
</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'}
|
||||
<Copy size={12} /> {copied ? 'COPIED' : 'COPY'}
|
||||
</button>
|
||||
<button className="btn violet" onClick={onClose}>
|
||||
<Check size={14} /> DONE
|
||||
<Check size={12} /> DONE
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user