import { useState, useEffect } from 'react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { TogglePill } from '@/components/ui/toggle-pill'; import { Skeleton } from '@/components/ui/skeleton'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { toast } from '@/components/ui/toast-store'; import { useAuth } from '@/context/AuthContext'; import { useNodes } from '@/context/NodeContext'; import { apiFetch } from '@/lib/api'; import { copyToClipboard } from '@/lib/clipboard'; import { RefreshCw, CheckCircle, XCircle, Webhook, Copy, Trash2, Plus, ChevronDown, ChevronRight, History, } from 'lucide-react'; import { SettingsSection } from './SettingsSection'; import { SettingsField } from './SettingsField'; import { SettingsCallout } from './SettingsCallout'; import { SettingsActions, SettingsPrimaryButton } from './SettingsActions'; import { useMastheadStats } from './MastheadStatsContext'; interface WebhookItem { id: number; node_id: number; name: string; stack_name: string; action: string; secret: string; enabled: boolean; created_at: number; updated_at: number; } interface WebhookExecution { id: number; webhook_id: number; action: string; status: 'success' | 'failure'; trigger_source: string | null; duration_ms: number | null; error: string | null; executed_at: number; } export function WebhooksSection() { const { isAdmin } = useAuth(); const { activeNode, nodes } = useNodes(); const [webhooks, setWebhooks] = useState([]); const [loading, setLoading] = useState(true); const [creating, setCreating] = useState(false); const [showForm, setShowForm] = useState(false); const [newSecret, setNewSecret] = useState<{ id: number; secret: string } | null>(null); const [expandedHistory, setExpandedHistory] = useState(null); const [history, setHistory] = useState>({}); const [loadingHistory, setLoadingHistory] = useState(null); const [formName, setFormName] = useState(''); const [formStack, setFormStack] = useState(''); const [formAction, setFormAction] = useState('deploy'); const [stacks, setStacks] = useState([]); const fetchWebhooks = async () => { try { const res = await apiFetch('/webhooks', { localOnly: true }); if (res.ok) setWebhooks(await res.json()); } catch { /* ignore */ } finally { setLoading(false); } }; const fetchStacks = async () => { try { const res = await apiFetch('/stacks'); if (res.ok) setStacks(await res.json()); } catch { /* ignore */ } }; useEffect(() => { fetchWebhooks(); fetchStacks(); }, [activeNode?.id]); useEffect(() => { if (!isAdmin) setShowForm(false); }, [isAdmin]); const enabledCount = webhooks.filter(w => w.enabled).length; useMastheadStats( loading ? null : [ { label: 'WEBHOOKS', value: `${webhooks.length}` }, { label: 'ENABLED', value: `${enabledCount}`, tone: enabledCount > 0 ? 'value' : 'subtitle', }, ], ); const handleCreate = async () => { if (!formName || !formStack || !formAction) { toast.error('All fields are required.'); return; } setCreating(true); try { const res = await apiFetch('/webhooks', { method: 'POST', localOnly: true, body: JSON.stringify({ name: formName, stack_name: formStack, action: formAction, node_id: activeNode?.id }), }); if (res.ok) { const data = await res.json(); setNewSecret({ id: data.id, secret: data.secret }); setShowForm(false); setFormName(''); setFormStack(''); setFormAction('deploy'); fetchWebhooks(); toast.success('Webhook created.'); } else { const err = await res.json().catch(() => ({})); toast.error(err?.error || err?.message || 'Failed to create webhook.'); } } catch (e: unknown) { toast.error((e as Error)?.message || 'Network error.'); } finally { setCreating(false); } }; const handleDelete = async (id: number) => { try { const res = await apiFetch(`/webhooks/${id}`, { method: 'DELETE', localOnly: true }); if (res.ok) { toast.success('Webhook deleted.'); fetchWebhooks(); } else { const err = await res.json().catch(() => ({})); toast.error(err?.error || 'Failed to delete.'); } } catch { toast.error('Network error.'); } }; const handleToggle = async (id: number, enabled: boolean) => { try { const res = await apiFetch(`/webhooks/${id}`, { method: 'PUT', localOnly: true, body: JSON.stringify({ enabled }), }); if (res.ok) fetchWebhooks(); } catch { /* ignore */ } }; const fetchHistory = async (webhookId: number) => { if (expandedHistory === webhookId) { setExpandedHistory(null); return; } setExpandedHistory(webhookId); setLoadingHistory(webhookId); try { const res = await apiFetch(`/webhooks/${webhookId}/history`, { localOnly: true }); if (res.ok) { const data = await res.json(); setHistory(prev => ({ ...prev, [webhookId]: data })); } } catch { /* ignore */ } finally { setLoadingHistory(null); } }; const handleCopy = async (text: string, label: string) => { try { await copyToClipboard(text); toast.success(`${label} copied to clipboard.`); } catch { toast.error('Failed to copy to clipboard.'); } }; return (
{isAdmin && (
setShowForm(!showForm)}> Create webhook
)} {isAdmin && showForm && ( setFormName(e.target.value)} /> {activeNode && (
{activeNode.name}
)} {creating ? <>Creating : 'Create'}
)} {newSecret && ( } title="Webhook created. Copy your secret now." subtitle={
This secret will not be shown again. Store it securely.
{newSecret.secret}
} action={ } /> )} {loading && (
)} {!loading && webhooks.length === 0 && !showForm && ( } title="No webhooks yet" subtitle={isAdmin ? 'Create one to trigger stack actions from CI/CD.' : 'An admin operator can create webhooks for this instance.'} /> )} {!loading && webhooks.length > 0 && (
{webhooks.map(wh => { const triggerUrl = `${window.location.origin}/api/webhooks/${wh.id}/trigger`; const isExpanded = expandedHistory === wh.id; const nodeName = nodes.find(n => n.id === wh.node_id)?.name ?? `Node ${wh.node_id}`; return (
{wh.name} {wh.action} {wh.stack_name} {nodeName}
{isAdmin ? ( <> handleToggle(wh.id!, c)} /> ) : ( {wh.enabled ? 'On' : 'Off'} )}
Trigger URL
{triggerUrl}
Secret {wh.secret}
{isExpanded && (
{loadingHistory === wh.id ? ( ) : (history[wh.id!] ?? []).length === 0 ? (

No executions yet.

) : (
{(history[wh.id!] ?? []).map(ex => (
{ex.status === 'success' ? : } {ex.action} {new Date(ex.executed_at).toLocaleString()} {ex.duration_ms !== null && ( {(ex.duration_ms / 1000).toFixed(1)}s )} {ex.error && ( {ex.error} )}
))}
)}
)}
); })}
)}
); }