feat: Claude Code Monitor — lanes, pipelines and a merged workspace

Internal SmartGift build of a Claude Code monitoring dashboard.

Lanes: a durable unit of parallel agent work, one per working directory,
tracked across session restarts. Managed lanes are git worktrees the
dashboard provisions and can reset or remove behind a three-check destroy
guard and a counted preflight; adopted lanes are directories you already
own and are never destroyable.

Pipelines: a lane moves through pipeline stages. A stage the agent declares
with evidence renders green; a stage inferred from the tool-event stream
renders dashed amber and never counts as done. Detection is forward-only
within a 30-minute window, and never writes the declared stage.

Workspace: one page at /run with a lane grid, the selected lane's pipeline,
and a full Claude console behind a disclosure.
This commit is contained in:
2026-07-29 17:07:45 +07:00
commit 57dc91585d
783 changed files with 221743 additions and 0 deletions
+826
View File
@@ -0,0 +1,826 @@
/**
* @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ĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
* 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.
*
* ## Observability
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
* Docker Compose profiles are documented in `monitoring/README.md`.
*
* ## 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<Record<WebhookType, string>> = {
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-gray-300 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<string, string>;
scopeAll: boolean;
ruleIds: string[];
enabled: boolean;
}
function defaultsFor(provider: WebhookProvider | undefined): Record<string, string> {
const out: Record<string, string> = {};
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 (
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={label}
onClick={() => onChange(!checked)}
className={`relative inline-flex h-5 w-9 flex-shrink-0 items-center rounded-full transition-colors ${
checked ? "bg-blue-500" : "bg-surface-4"
}`}
>
<span
className="inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform"
style={{ transform: checked ? "translateX(18px)" : "translateX(3px)" }}
/>
</button>
);
}
export function WebhookSettings() {
const { t } = useTranslation("settings");
const [targets, setTargets] = useState<WebhookTarget[]>([]);
const [providers, setProviders] = useState<WebhookProvider[]>([]);
const [rules, setRules] = useState<AlertRule[]>([]);
const [loading, setLoading] = useState(true);
const [formOpen, setFormOpen] = useState(false);
const [form, setForm] = useState<FormState | null>(null);
const [formError, setFormError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState<string | null>(null);
const [testResult, setTestResult] = useState<Record<string, WebhookTestResult>>({});
const [confirmDelete, setConfirmDelete] = useState<string | null>(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<FormState>) =>
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<string, string> = {};
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<string, string> | undefined => {
if (!form || !provider || provider.fields.length === 0) return undefined;
const out: Record<string, string> = {};
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<string, string> => {
if (!form) return {};
const out: Record<string, string> = {};
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<typeof api.webhooks.update>[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 (
<div className="card p-5 space-y-4">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 text-xs text-gray-500">
<Webhook className="w-3.5 h-3.5" />
{t("webhooks.count", { count: targets.length })}
</div>
{!formOpen && (
<button
onClick={openCreate}
disabled={providers.length === 0}
className="btn-ghost border border-border inline-flex items-center gap-1.5 text-xs disabled:opacity-50"
>
<Plus className="w-3.5 h-3.5" />
{t("webhooks.add")}
</button>
)}
</div>
{/* Target list */}
{loading ? (
<p className="text-xs text-gray-500">{t("webhooks.loading")}</p>
) : targets.length === 0 && !formOpen ? (
<div className="flex items-center gap-2 text-xs text-gray-500 py-2">
<Webhook className="w-3.5 h-3.5" />
{t("webhooks.empty")}
</div>
) : (
<div className="space-y-2">
{targets.map((target) => {
const result = testResult[target.id];
return (
<div
key={target.id}
className="bg-surface-2 border border-border rounded-lg px-3.5 py-3 space-y-2"
>
<div className="flex items-center gap-3 flex-wrap">
<span
className={`inline-flex items-center gap-1 text-[10px] font-semibold uppercase tracking-wide px-2 py-0.5 rounded border ${
TYPE_STYLES[target.type] || NEUTRAL_STYLE
}`}
>
{labelOf(target.type)}
</span>
<span className="text-sm text-gray-200 font-medium">{target.name}</span>
<code className="text-[11px] text-gray-500 font-mono truncate max-w-[220px]">
{target.url_preview}
</code>
{target.rule_ids && target.rule_ids.length > 0 && (
<span className="text-[10px] text-amber-400/80">
{t("webhooks.scopedTo", { count: target.rule_ids.length })}
</span>
)}
<div className="ml-auto flex items-center gap-2">
{target.last_delivery && (
<span
title={target.last_delivery.error || undefined}
className={`inline-flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded-full ${
target.last_delivery.status === "success"
? "text-emerald-400 bg-emerald-500/10"
: "text-red-400 bg-red-500/10"
}`}
>
{target.last_delivery.status === "success" ? (
<CheckCircle className="w-3 h-3" />
) : (
<XCircle className="w-3 h-3" />
)}
{timeAgo(target.last_delivery.created_at)}
</span>
)}
<Toggle
checked={target.enabled}
onChange={() => onToggle(target)}
label={t("webhooks.enabled")}
/>
</div>
</div>
<div className="flex items-center gap-2 flex-wrap">
<button
onClick={() => onTest(target.id)}
disabled={testing === target.id}
className="inline-flex items-center gap-1.5 text-[11px] px-2.5 py-1 rounded-md text-gray-400 hover:text-gray-200 hover:bg-surface-4 border border-border transition-colors disabled:opacity-50"
>
{testing === target.id ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<Zap className="w-3 h-3" />
)}
{t("webhooks.test")}
</button>
<button
onClick={() => openEdit(target)}
className="inline-flex items-center gap-1.5 text-[11px] px-2.5 py-1 rounded-md text-gray-400 hover:text-gray-200 hover:bg-surface-4 border border-border transition-colors"
>
<Pencil className="w-3 h-3" />
{t("webhooks.edit")}
</button>
<button
onClick={() => setConfirmDelete(target.id)}
className="inline-flex items-center gap-1.5 text-[11px] px-2.5 py-1 rounded-md text-gray-500 hover:text-red-400 hover:bg-red-500/10 border border-border transition-colors"
>
<Trash2 className="w-3 h-3" />
{t("webhooks.delete")}
</button>
{result && (
<span
className={`inline-flex items-center gap-1 text-[11px] ${
result.ok ? "text-emerald-400" : "text-red-400"
}`}
>
{result.ok ? (
<CheckCircle className="w-3 h-3" />
) : (
<AlertTriangle className="w-3 h-3" />
)}
{result.ok
? t("webhooks.testOk", { status: result.status ?? 200 })
: t("webhooks.testFail", {
error: result.error || `HTTP ${result.status ?? "?"}`,
})}
</span>
)}
</div>
</div>
);
})}
</div>
)}
{/* Create / edit form */}
{formOpen && form && provider && (
<div className="border border-border rounded-lg p-4 space-y-3 bg-surface-1">
<div className="flex items-center justify-between">
<h4 className="text-xs font-semibold text-gray-300 uppercase tracking-wide">
{isEdit ? t("webhooks.editTitle") : t("webhooks.addTitle")}
</h4>
<button onClick={closeForm} className="text-gray-500 hover:text-gray-300">
<X className="w-4 h-4" />
</button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<label className="block">
<span className="text-[11px] text-gray-500">{t("webhooks.fieldName")}</span>
<input
value={form.name}
onChange={(e) => set({ name: e.target.value })}
placeholder={t("webhooks.fieldNamePlaceholder")}
className="input w-full mt-1 py-1.5 text-[11px] leading-normal"
/>
</label>
<label className="block">
<span className="text-[11px] text-gray-500">{t("webhooks.fieldType")}</span>
<div className="mt-1">
<Select<WebhookType>
value={form.type}
disabled={isEdit}
onChange={(type) =>
set({
type,
config: defaultsFor(providerOf(type)),
url: "",
secret: "",
headerRows: [],
})
}
options={providers.map((p) => ({ value: p.type, label: p.label }))}
/>
</div>
</label>
</div>
{/* URL (hidden for providers that derive their own URL) */}
{showUrl && (
<label className="block">
<span className="text-[11px] text-gray-500">
{t("webhooks.fieldUrl")}
{isEdit ? (
<span className="text-gray-600"> - {t("webhooks.urlKeepHint")}</span>
) : urlOptional ? (
<span className="text-gray-600"> - {t("webhooks.urlOptional")}</span>
) : null}
</span>
<input
value={form.url}
onChange={(e) => set({ url: e.target.value })}
placeholder={
isEdit ? t("webhooks.urlKeepPlaceholder") : provider.url_hint || "https://…"
}
className="input w-full mt-1 py-1.5 text-[11px] leading-normal font-mono"
/>
</label>
)}
{!showUrl && (
<p className="text-[11px] text-gray-600 flex items-center gap-1.5">
<Webhook className="w-3 h-3" />
{t("webhooks.urlAuto")}
</p>
)}
{/* Collapsible per-provider setup guide */}
<div className="rounded-lg border border-border bg-surface-2/40 overflow-hidden">
<button
type="button"
onClick={() => setGuideOpen((o) => !o)}
className="w-full flex items-center justify-between gap-2 px-3 py-2 text-[11px] text-gray-300 hover:bg-surface-3 transition-colors"
>
<span className="inline-flex items-center gap-1.5">
<BookOpen className="w-3.5 h-3.5 text-gray-500" />
{t("webhooks.guideToggle", { provider: provider.label })}
</span>
<ChevronDown
className={`w-3.5 h-3.5 text-gray-500 transition-transform ${guideOpen ? "rotate-180" : ""}`}
/>
</button>
{guideOpen && (
<div className="px-3 pb-3 pt-2 space-y-2.5 border-t border-border">
<ol className="list-decimal list-inside space-y-1 text-[11px] leading-relaxed text-gray-400 marker:text-gray-600">
{(t(`webhookGuides.${form.type}.steps`, { returnObjects: true }) as string[]).map(
(s, i) => (
<li key={i}>{s}</li>
)
)}
</ol>
{WEBHOOK_DOCS[form.type] && (
<a
href={WEBHOOK_DOCS[form.type]}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-[11px] text-accent hover:underline"
>
<ExternalLink className="w-3 h-3" />
{t("webhooks.guideDocs", { provider: provider.label })}
</a>
)}
<p className="flex items-start gap-1.5 text-[10px] text-gray-500 leading-relaxed pt-2 border-t border-border">
<Info className="w-3 h-3 mt-0.5 flex-shrink-0" />
{t("webhooks.guideStaleNote")}
</p>
</div>
)}
</div>
{/* Provider-specific config fields */}
{provider.fields.length > 0 && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 pt-1 border-t border-border">
{provider.fields.map((f) => (
<label key={f.key} className="block">
<span className="text-[11px] text-gray-500">
{f.label}
{f.required && <span className="text-red-400"> *</span>}
</span>
{f.type === "enum" && f.options ? (
<div className="mt-1">
<Select
value={String(form.config[f.key] ?? f.default ?? "")}
onChange={(v) => set({ config: { ...form.config, [f.key]: v } })}
options={f.options.map((o) => ({ value: o, label: o }))}
/>
</div>
) : (
<input
type={f.secret ? "password" : "text"}
value={form.config[f.key] ?? ""}
onChange={(e) => set({ config: { ...form.config, [f.key]: e.target.value } })}
placeholder={f.secret && isEdit ? t("webhooks.secretKeepPlaceholder") : ""}
className="input w-full mt-1 py-1.5 text-[11px] leading-normal font-mono"
/>
)}
</label>
))}
</div>
)}
{/* Generic family: HMAC secret + custom headers */}
{provider.supports_secret && (
<div className="space-y-3 pt-1 border-t border-border">
<label className="block">
<span className="text-[11px] text-gray-500">{t("webhooks.fieldSecret")}</span>
<input
type="password"
value={form.secret}
onChange={(e) => set({ secret: e.target.value })}
placeholder={
isEdit ? t("webhooks.secretKeepPlaceholder") : t("webhooks.secretPlaceholder")
}
className="input w-full mt-1 py-1.5 text-[11px] leading-normal font-mono"
/>
<span className="text-[10px] text-gray-600">{t("webhooks.secretHint")}</span>
</label>
<div>
<div className="flex items-center justify-between">
<span className="text-[11px] text-gray-500">{t("webhooks.fieldHeaders")}</span>
{isEdit && (
<Checkbox
checked={form.replaceHeaders}
onChange={(v) => set({ replaceHeaders: v })}
label={t("webhooks.replaceHeaders")}
labelClassName="text-[10px] text-gray-500 group-hover:text-gray-400"
/>
)}
</div>
{(!isEdit || form.replaceHeaders) && (
<div className="space-y-1.5 mt-1.5">
{form.headerRows.map((row, i) => (
<div key={i} className="flex items-center gap-1.5">
<input
value={row.key}
onChange={(e) =>
set({
headerRows: form.headerRows.map((r, j) =>
j === i ? { ...r, key: e.target.value } : r
),
})
}
placeholder={t("webhooks.headerKey")}
className="input flex-1 py-1.5 text-[11px] leading-normal font-mono"
/>
<input
value={row.value}
onChange={(e) =>
set({
headerRows: form.headerRows.map((r, j) =>
j === i ? { ...r, value: e.target.value } : r
),
})
}
placeholder={t("webhooks.headerValue")}
className="input flex-1 py-1.5 text-[11px] leading-normal font-mono"
/>
<button
onClick={() =>
set({ headerRows: form.headerRows.filter((_, j) => j !== i) })
}
className="text-gray-600 hover:text-red-400 p-1"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
))}
<button
onClick={() =>
set({ headerRows: [...form.headerRows, { key: "", value: "" }] })
}
className="inline-flex items-center gap-1 text-[10px] text-gray-500 hover:text-gray-300"
>
<Plus className="w-3 h-3" />
{t("webhooks.addHeader")}
</button>
</div>
)}
</div>
</div>
)}
{/* Optional per-rule scoping */}
{rules.length > 0 && (
<div className="pt-1 border-t border-border">
<Checkbox
checked={form.scopeAll}
onChange={(v) => set({ scopeAll: v })}
label={t("webhooks.scopeAll")}
labelClassName="text-[11px] text-gray-400 group-hover:text-gray-300"
/>
{!form.scopeAll && (
<div className="mt-2 grid grid-cols-1 sm:grid-cols-2 gap-1">
{rules.map((rule) => (
<Checkbox
key={rule.id}
checked={form.ruleIds.includes(rule.id)}
onChange={(checked) =>
set({
ruleIds: checked
? [...form.ruleIds, rule.id]
: form.ruleIds.filter((id) => id !== rule.id),
})
}
label={rule.name}
labelClassName="text-[11px] text-gray-400 group-hover:text-gray-300 truncate"
/>
))}
</div>
)}
</div>
)}
<div className="flex items-center gap-3 pt-1">
<label className="inline-flex items-center gap-2 text-[11px] text-gray-400 cursor-pointer">
<Toggle checked={form.enabled} onChange={(v) => set({ enabled: v })} />
{t("webhooks.enabledOnSave")}
</label>
</div>
{formError && (
<div className="flex items-center gap-1.5 text-[11px] text-red-400">
<AlertTriangle className="w-3.5 h-3.5" />
{formError}
</div>
)}
<div className="flex items-center gap-2 pt-1">
<button
onClick={onSubmit}
disabled={!canSubmit || saving}
className="btn-primary inline-flex items-center gap-1.5 text-xs disabled:opacity-50"
>
{saving ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Check className="w-3.5 h-3.5" />
)}
{isEdit ? t("webhooks.save") : t("webhooks.create")}
</button>
<button onClick={closeForm} className="btn-ghost border border-border text-xs">
{t("webhooks.cancel")}
</button>
</div>
</div>
)}
<ConfirmModal
open={!!confirmDelete}
title={t("webhooks.deleteTitle")}
message={t("webhooks.deleteMessage", {
name: targets.find((x) => x.id === confirmDelete)?.name ?? "",
})}
confirmLabel={t("webhooks.delete")}
cancelLabel={t("webhooks.cancel")}
onCancel={() => setConfirmDelete(null)}
onConfirm={() => confirmDelete && onDelete(confirmDelete)}
/>
</div>
);
}