/** * @file WebhookSettings.tsx * @description Settings-page panel for universal webhook notifications across 14 * first-class providers (Slack, Discord, Teams, Google Chat, Mattermost, * Rocket.Chat, Telegram, PagerDuty, Opsgenie, Splunk On-Call, Zapier, Make, n8n, * Pipedream) plus a generic endpoint. The form is driven by provider metadata * fetched from the server (`/api/webhooks/providers`): each provider declares * whether it needs a URL and which credential fields to render, so adding a * provider server-side surfaces here with no UI change. Secrets are never * returned by the API - URLs are masked and re-entered to change. * @author Nguyễn Ngọc Trí Vĩ */ /* ============================================================================= * MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed) * ============================================================================= * **Purpose:** React hook: isolates side effects and subscription wiring so presentational components stay declarative. * * ## Design constraints * - Local-first: no telemetry leaves the machine unless the user configures webhooks. * - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that * philosophy by degrading gracefully (empty states, stale badges, reconnect loops). * - Destructive flows stay behind explicit confirmation modals and server-side gates. * - Internationalization: user-visible strings belong in i18n JSON, not literals here. * * ## Remote data & SSH * Remote Data Sources let operators aggregate multiple machines. SSH entries describe * how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every * scoped GET via `?sources=`. Health checks and import history surface in Settings. * * ## Internal dependencies * - `../lib/api` * - `./Select` * - `./ConfirmModal` * - `./Checkbox` * - `./webhookGuides` * - `../lib/format` * - `../lib/types` * * ## Public surface * - `WebhookSettings` — exported API; see TSDoc on the symbol for behavior. * * ## Testing pointers * - Prefer colocated `__tests__` with Vitest + Testing Library for UI. * - Server contract changes require `npm run test:server` and OpenAPI sync. * - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`. * * ## Related docs * - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline. * - `docs/API.md` — REST reference. * - `.claude/skills/file-headers/` — mandatory `@author` header policy. * ============================================================================= */ /* ----------------------------------------------------------------------------- * EXPORT CATALOG — quick index of symbols defined below (documentation only). * ----------------------------------------------------------------------------- * **WebhookSettings** * Part of this module's public contract. Downstream imports should treat * the signature and return type as stable unless release notes say otherwise. * When behavior changes, update the `@file` overview and relevant tests. * * ----------------------------------------------------------------------------- */ import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { Webhook, Plus, Trash2, X, Pencil, Zap, Check, AlertTriangle, CheckCircle, XCircle, Loader2, BookOpen, ChevronDown, ExternalLink, Info, } from "lucide-react"; import { api } from "../lib/api"; import { Select } from "./Select"; import { ConfirmModal } from "./ConfirmModal"; import { Checkbox } from "./Checkbox"; import { WEBHOOK_DOCS } from "./webhookGuides"; import { timeAgo } from "../lib/format"; import type { AlertRule, WebhookProvider, WebhookTarget, WebhookType, WebhookTestResult, } from "../lib/types"; // Brand-ish accent per provider type; anything unmapped falls back to neutral. const TYPE_STYLES: Partial> = { slack: "text-[#E01E5A] bg-[#E01E5A]/10 border-[#E01E5A]/20", discord: "text-[#5865F2] bg-[#5865F2]/10 border-[#5865F2]/20", teams: "text-[#6264A7] bg-[#6264A7]/10 border-[#6264A7]/20", google_chat: "text-[#1A73E8] bg-[#1A73E8]/10 border-[#1A73E8]/20", mattermost: "text-[#0058CC] bg-[#0058CC]/10 border-[#0058CC]/20", rocketchat: "text-[#F5455C] bg-[#F5455C]/10 border-[#F5455C]/20", telegram: "text-[#26A5E4] bg-[#26A5E4]/10 border-[#26A5E4]/20", pagerduty: "text-[#06AC38] bg-[#06AC38]/10 border-[#06AC38]/20", opsgenie: "text-[#2684FF] bg-[#2684FF]/10 border-[#2684FF]/20", splunk_oncall: "text-[#F99D1C] bg-[#F99D1C]/10 border-[#F99D1C]/20", }; const NEUTRAL_STYLE = "text-fg-secondary bg-surface-2 border-border"; interface HeaderRow { key: string; value: string; } interface FormState { id: string | null; name: string; type: WebhookType; url: string; secret: string; headerRows: HeaderRow[]; replaceHeaders: boolean; config: Record; scopeAll: boolean; ruleIds: string[]; enabled: boolean; } function defaultsFor(provider: WebhookProvider | undefined): Record { const out: Record = {}; if (!provider) return out; for (const f of provider.fields) if (f.default != null) out[f.key] = f.default; return out; } function Toggle({ checked, onChange, label, }: { checked: boolean; onChange: (v: boolean) => void; label?: string; }) { return ( ); } export function WebhookSettings() { const { t } = useTranslation("settings"); const [targets, setTargets] = useState([]); const [providers, setProviders] = useState([]); const [rules, setRules] = useState([]); const [loading, setLoading] = useState(true); const [formOpen, setFormOpen] = useState(false); const [form, setForm] = useState(null); const [formError, setFormError] = useState(null); const [saving, setSaving] = useState(false); const [testing, setTesting] = useState(null); const [testResult, setTestResult] = useState>({}); const [confirmDelete, setConfirmDelete] = useState(null); const [guideOpen, setGuideOpen] = useState(false); const providerOf = useCallback( (type: WebhookType) => providers.find((p) => p.type === type), [providers] ); const load = useCallback(async () => { setLoading(true); try { const res = await api.webhooks.list(); setTargets(res.targets); } catch (err) { console.error("Failed to load webhook targets:", err); } finally { setLoading(false); } }, []); useEffect(() => { load(); }, [load]); useEffect(() => { api.webhooks .providers() .then((res) => setProviders(res.providers)) .catch(() => setProviders([])); api.alerts.rules .list() .then((res) => setRules(res.rules)) .catch(() => setRules([])); }, []); const set = (patch: Partial) => setForm((prev) => (prev ? { ...prev, ...patch } : prev)); const openCreate = () => { const first = providers[0]; setForm({ id: null, name: "", type: (first?.type as WebhookType) || "slack", url: "", secret: "", headerRows: [], replaceHeaders: true, config: defaultsFor(first), scopeAll: true, ruleIds: [], enabled: true, }); setFormError(null); setFormOpen(true); }; const openEdit = (target: WebhookTarget) => { const provider = providerOf(target.type); // Prefill non-secret config (region, chat_id, severity, …); leave secret // fields blank - they're redacted and re-entered only to change. const config: Record = {}; for (const f of provider?.fields || []) { if (f.secret) continue; const v = target.config?.[f.key]; config[f.key] = v != null ? String(v) : (f.default ?? ""); } setForm({ id: target.id, name: target.name, type: target.type, url: "", secret: "", headerRows: [], replaceHeaders: false, config, scopeAll: !target.rule_ids || target.rule_ids.length === 0, ruleIds: target.rule_ids || [], enabled: target.enabled, }); setFormError(null); setFormOpen(true); }; const closeForm = () => { setFormOpen(false); setForm(null); setFormError(null); }; const provider = form ? providerOf(form.type) : undefined; const isEdit = !!form?.id; const showUrl = !!provider && (provider.url_required || provider.has_default_url); const urlOptional = !!provider && !provider.url_required; const canSubmit = useMemo(() => { if (!form || !provider) return false; if (!form.name.trim()) return false; if (isEdit) return true; // server merge - existing values fill the gaps if (provider.url_required && !form.url.trim()) return false; for (const f of provider.fields) { if (f.required && f.type !== "enum" && !(form.config[f.key] || "").trim()) return false; } return true; }, [form, provider, isEdit]); const buildConfigObj = (): Record | undefined => { if (!form || !provider || provider.fields.length === 0) return undefined; const out: Record = {}; for (const f of provider.fields) { const v = (form.config[f.key] ?? "").toString(); if (f.type === "enum") { if (v) out[f.key] = v; } else if (v.trim()) { out[f.key] = v.trim(); } } return out; }; const buildHeaders = (): Record => { if (!form) return {}; const out: Record = {}; for (const r of form.headerRows) if (r.key.trim()) out[r.key.trim()] = r.value; return out; }; const onSubmit = async () => { if (!form || !provider || saving || !canSubmit) return; setSaving(true); setFormError(null); try { const ruleIds = form.scopeAll ? [] : form.ruleIds; const config = buildConfigObj(); const genericFamily = provider.supports_secret || provider.supports_headers; if (isEdit && form.id) { const patch: Parameters[1] = { name: form.name.trim(), enabled: form.enabled, rule_ids: ruleIds, }; if (form.url.trim()) patch.url = form.url.trim(); if (config) patch.config = config; if (genericFamily && form.secret.trim()) patch.secret = form.secret.trim(); if (provider.supports_headers && form.replaceHeaders) patch.headers = buildHeaders(); await api.webhooks.update(form.id, patch); } else { await api.webhooks.create({ name: form.name.trim(), type: form.type, url: form.url.trim() || undefined, enabled: form.enabled, secret: genericFamily && form.secret.trim() ? form.secret.trim() : undefined, headers: provider.supports_headers ? buildHeaders() : undefined, config, rule_ids: ruleIds.length ? ruleIds : undefined, }); } closeForm(); load(); } catch (err) { setFormError(err instanceof Error ? err.message : String(err)); } finally { setSaving(false); } }; const onToggle = async (target: WebhookTarget) => { try { await api.webhooks.update(target.id, { enabled: !target.enabled }); load(); } catch (err) { console.error("Failed to toggle webhook:", err); } }; const onDelete = async (id: string) => { try { await api.webhooks.remove(id); setConfirmDelete(null); load(); } catch (err) { console.error("Failed to delete webhook:", err); } }; const onTest = async (id: string) => { setTesting(id); setTestResult((prev) => { const next = { ...prev }; delete next[id]; return next; }); try { const result = await api.webhooks.test(id); setTestResult((prev) => ({ ...prev, [id]: result })); } catch (err) { setTestResult((prev) => ({ ...prev, [id]: { ok: false, status: null, attempts: 0, error: String(err) }, })); } finally { setTesting(null); load(); } }; const labelOf = (type: WebhookType) => providerOf(type)?.label || type; return (
{t("webhooks.count", { count: targets.length })}
{!formOpen && ( )}
{/* Target list */} {loading ? (

{t("webhooks.loading")}

) : targets.length === 0 && !formOpen ? (
{t("webhooks.empty")}
) : (
{targets.map((target) => { const result = testResult[target.id]; return (
{labelOf(target.type)} {target.name} {target.url_preview} {target.rule_ids && target.rule_ids.length > 0 && ( {t("webhooks.scopedTo", { count: target.rule_ids.length })} )}
{target.last_delivery && ( {target.last_delivery.status === "success" ? ( ) : ( )} {timeAgo(target.last_delivery.created_at)} )} onToggle(target)} label={t("webhooks.enabled")} />
{result && ( {result.ok ? ( ) : ( )} {result.ok ? t("webhooks.testOk", { status: result.status ?? 200 }) : t("webhooks.testFail", { error: result.error || `HTTP ${result.status ?? "?"}`, })} )}
); })}
)} {/* Create / edit form */} {formOpen && form && provider && (

{isEdit ? t("webhooks.editTitle") : t("webhooks.addTitle")}

{/* URL (hidden for providers that derive their own URL) */} {showUrl && ( )} {!showUrl && (

{t("webhooks.urlAuto")}

)} {/* Collapsible per-provider setup guide */}
{guideOpen && (
    {(t(`webhookGuides.${form.type}.steps`, { returnObjects: true }) as string[]).map( (s, i) => (
  1. {s}
  2. ) )}
{WEBHOOK_DOCS[form.type] && ( {t("webhooks.guideDocs", { provider: provider.label })} )}

{t("webhooks.guideStaleNote")}

)}
{/* Provider-specific config fields */} {provider.fields.length > 0 && (
{provider.fields.map((f) => (
)} x.id === confirmDelete)?.name ?? "", })} confirmLabel={t("webhooks.delete")} cancelLabel={t("webhooks.cancel")} onCancel={() => setConfirmDelete(null)} onConfirm={() => confirmDelete && onDelete(confirmDelete)} />
); }