mission-control/src/components/panels/webhook-panel.tsx

532 lines
21 KiB
TypeScript

'use client'
import { useState, useEffect, useCallback } from 'react'
import { useSmartPoll } from '@/lib/use-smart-poll'
import { useMissionControl } from '@/store'
interface Webhook {
id: number
name: string
url: string
secret: string | null
events: string[]
enabled: boolean
last_fired_at: number | null
last_status: number | null
total_deliveries: number
successful_deliveries: number
failed_deliveries: number
created_at: number
updated_at: number
}
interface Delivery {
id: number
webhook_id: number
webhook_name: string
webhook_url: string
event_type: string
payload: string
status_code: number | null
response_body: string | null
error: string | null
duration_ms: number
created_at: number
}
interface SchedulerTask {
id: string
name: string
enabled: boolean
lastRun: number | null
nextRun: number | null
running: boolean
lastResult?: { ok: boolean; message: string; timestamp: number }
}
const AVAILABLE_EVENTS = [
{ value: '*', label: 'All events', description: 'Receive all event types' },
{ value: 'agent.error', label: 'Agent error', description: 'Agent enters error state' },
{ value: 'agent.status_change', label: 'Agent status change', description: 'Any agent status transition' },
{ value: 'security.login_failed', label: 'Login failed', description: 'Failed login attempt' },
{ value: 'security.user_created', label: 'User created', description: 'New user account created' },
{ value: 'security.user_deleted', label: 'User deleted', description: 'User account deleted' },
{ value: 'security.password_change', label: 'Password changed', description: 'User password modified' },
{ value: 'notification.mention', label: 'Mention', description: 'Agent was @mentioned' },
{ value: 'notification.assignment', label: 'Assignment', description: 'Task assigned to agent' },
{ value: 'activity.task_created', label: 'Task created', description: 'New task added' },
{ value: 'activity.task_updated', label: 'Task updated', description: 'Task status changed' },
]
export function WebhookPanel() {
const { dashboardMode } = useMissionControl()
const isLocalMode = dashboardMode === 'local'
const [webhooks, setWebhooks] = useState<Webhook[]>([])
const [webhookAutomations, setWebhookAutomations] = useState<SchedulerTask[]>([])
const [deliveries, setDeliveries] = useState<Delivery[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [showCreate, setShowCreate] = useState(false)
const [selectedWebhook, setSelectedWebhook] = useState<number | null>(null)
const [testingId, setTestingId] = useState<number | null>(null)
const [testResult, setTestResult] = useState<any>(null)
const [newSecret, setNewSecret] = useState<string | null>(null)
const [runningAutomationId, setRunningAutomationId] = useState<string | null>(null)
const fetchWebhooks = useCallback(async () => {
try {
setLoading(true)
const res = await fetch('/api/webhooks')
if (!res.ok) {
const data = await res.json()
setError(data.error || 'Failed to fetch webhooks')
return
}
const data = await res.json()
setWebhooks(data.webhooks || [])
setError('')
} catch {
setError('Network error')
} finally {
setLoading(false)
}
}, [])
const fetchDeliveries = useCallback(async () => {
if (!selectedWebhook) return
try {
const res = await fetch(`/api/webhooks/deliveries?webhook_id=${selectedWebhook}&limit=20`)
if (res.ok) {
const data = await res.json()
setDeliveries(data.deliveries || [])
}
} catch { /* silent */ }
}, [selectedWebhook])
const fetchWebhookAutomations = useCallback(async () => {
if (!isLocalMode) {
setWebhookAutomations([])
return
}
try {
const res = await fetch('/api/scheduler')
if (!res.ok) return
const data = await res.json()
const tasks = Array.isArray(data.tasks) ? data.tasks : []
const webhookTasks = tasks.filter((task: SchedulerTask) =>
typeof task.id === 'string' && task.id.includes('webhook')
)
setWebhookAutomations(webhookTasks)
} catch {
// Keep UI usable if scheduler endpoint is unavailable.
}
}, [isLocalMode])
useEffect(() => { fetchWebhooks() }, [fetchWebhooks])
useEffect(() => { fetchDeliveries() }, [fetchDeliveries])
useEffect(() => { fetchWebhookAutomations() }, [fetchWebhookAutomations])
useSmartPoll(fetchWebhooks, 60000, { pauseWhenDisconnected: true })
useSmartPoll(fetchWebhookAutomations, 60000, { pauseWhenDisconnected: true })
async function handleCreate(form: { name: string; url: string; events: string[] }) {
try {
const res = await fetch('/api/webhooks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...form, generate_secret: true }),
})
const data = await res.json()
if (!res.ok) { setError(data.error); return }
setNewSecret(data.secret)
setShowCreate(false)
fetchWebhooks()
} catch { setError('Failed to create webhook') }
}
async function handleToggle(id: number, enabled: boolean) {
await fetch('/api/webhooks', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, enabled }),
})
fetchWebhooks()
}
async function handleDelete(id: number) {
await fetch(`/api/webhooks?id=${id}`, { method: 'DELETE' })
if (selectedWebhook === id) setSelectedWebhook(null)
fetchWebhooks()
}
async function handleTest(id: number) {
setTestingId(id)
setTestResult(null)
try {
const res = await fetch('/api/webhooks/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id }),
})
const data = await res.json()
setTestResult(data)
fetchWebhooks()
if (selectedWebhook === id) fetchDeliveries()
} catch {
setTestResult({ error: 'Network error' })
} finally {
setTestingId(null)
}
}
async function handleRunAutomation(taskId: string) {
setRunningAutomationId(taskId)
try {
const res = await fetch('/api/scheduler', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ task_id: taskId }),
})
const data = await res.json()
setTestResult({
success: !!data.ok && res.ok,
error: data.error || (!data.ok ? data.message : null),
duration_ms: undefined,
status_code: res.status,
})
await fetchWebhookAutomations()
} catch {
setTestResult({ success: false, error: 'Failed to run local automation' })
} finally {
setRunningAutomationId(null)
}
}
function formatTime(ts: number) {
return new Date(ts * 1000).toLocaleString(undefined, {
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit',
})
}
return (
<div className="p-5 space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-base font-semibold text-foreground">Webhooks</h2>
<p className="text-xs text-muted-foreground mt-0.5">
{webhooks.length} webhook{webhooks.length !== 1 ? 's' : ''} configured
</p>
</div>
<button
onClick={() => setShowCreate(true)}
className="h-8 px-3 rounded-md text-xs font-medium bg-primary text-primary-foreground hover:bg-primary/90 transition-smooth"
>
+ Add Webhook
</button>
</div>
{error && (
<div className="text-xs text-red-400 bg-red-500/10 border border-red-500/20 rounded-md px-3 py-2">
{error}
</div>
)}
{/* Secret reveal (after creation) */}
{newSecret && (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-4 space-y-2">
<p className="text-xs font-semibold text-amber-400">Webhook Secret (save now - shown only once)</p>
<code className="block text-xs font-mono bg-secondary rounded px-2 py-1.5 text-foreground break-all select-all">
{newSecret}
</code>
<button
onClick={() => setNewSecret(null)}
className="text-xs text-muted-foreground hover:text-foreground transition-smooth"
>
Dismiss
</button>
</div>
)}
{/* Test result */}
{testResult && (
<div className={`rounded-lg border p-3 space-y-1 ${
testResult.success ? 'border-green-500/30 bg-green-500/5' : 'border-red-500/30 bg-red-500/5'
}`}>
<div className="flex items-center justify-between">
<p className="text-xs font-semibold">
{testResult.success ? (
<span className="text-green-400">Test successful</span>
) : (
<span className="text-red-400">Test failed</span>
)}
</p>
<button onClick={() => setTestResult(null)} className="text-xs text-muted-foreground">
Dismiss
</button>
</div>
<div className="text-xs text-muted-foreground space-y-0.5">
{testResult.status_code && <p>Status: <span className="font-mono">{testResult.status_code}</span></p>}
{testResult.duration_ms && <p>Duration: <span className="font-mono">{testResult.duration_ms}ms</span></p>}
{testResult.error && <p className="text-red-400">Error: {testResult.error}</p>}
</div>
</div>
)}
{/* Create form */}
{showCreate && (
<CreateWebhookForm
onSubmit={handleCreate}
onCancel={() => setShowCreate(false)}
/>
)}
{/* Webhook list */}
<div className="space-y-2">
{isLocalMode && webhookAutomations.length > 0 && (
<div className="rounded-lg border border-cyan-500/30 bg-cyan-500/5 p-3">
<h3 className="text-sm font-semibold text-cyan-200">Local Webhook Automations</h3>
<p className="text-2xs text-cyan-300/80 mt-0.5 mb-2">
Local scheduler tasks that support webhook delivery and retries
</p>
<div className="space-y-2">
{webhookAutomations.map((task) => (
<div key={task.id} className="rounded border border-cyan-500/20 bg-background/30 p-2.5">
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${task.running ? 'bg-blue-400' : task.enabled ? 'bg-green-500' : 'bg-muted-foreground/40'}`} />
<span className="text-xs font-medium text-foreground truncate">{task.name}</span>
<span className="px-1.5 py-0.5 text-[10px] rounded bg-cyan-500/15 text-cyan-300 font-mono">{task.id}</span>
</div>
<div className="text-2xs text-muted-foreground mt-1">
{task.nextRun ? `Next run ${formatTime(task.nextRun / 1000)}` : 'No next run scheduled'}
{task.lastResult?.message ? ` · ${task.lastResult.message}` : ''}
</div>
</div>
<button
onClick={() => handleRunAutomation(task.id)}
disabled={runningAutomationId === task.id}
className="h-7 px-2.5 text-2xs font-medium text-cyan-300 hover:text-cyan-200 hover:bg-cyan-500/10 rounded transition-smooth disabled:opacity-50"
>
{runningAutomationId === task.id ? 'Running...' : 'Run'}
</button>
</div>
</div>
))}
</div>
</div>
)}
{loading && webhooks.length === 0 ? (
<div className="space-y-2">
{[...Array(3)].map((_, i) => <div key={i} className="h-16 rounded-lg shimmer" />)}
</div>
) : webhooks.length === 0 ? (
<div className="py-12 text-center">
<p className="text-xs text-muted-foreground">No webhooks configured</p>
<p className="text-2xs text-muted-foreground/60 mt-1">
Add a webhook to receive HTTP notifications for events
</p>
</div>
) : (
webhooks.map((wh) => (
<div
key={wh.id}
className={`rounded-lg border p-3 transition-smooth ${
selectedWebhook === wh.id ? 'border-primary/40 bg-primary/5' : 'border-border'
}`}
>
<div className="flex items-start justify-between gap-3">
<div
className="flex-1 min-w-0 cursor-pointer"
onClick={() => setSelectedWebhook(selectedWebhook === wh.id ? null : wh.id)}
>
<div className="flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${wh.enabled ? 'bg-green-500' : 'bg-muted-foreground/30'}`} />
<span className="text-sm font-medium text-foreground">{wh.name}</span>
{wh.last_status !== null && (
<span className={`text-2xs font-mono px-1.5 py-0.5 rounded ${
wh.last_status >= 200 && wh.last_status < 300
? 'bg-green-500/10 text-green-400'
: 'bg-red-500/10 text-red-400'
}`}>
{wh.last_status}
</span>
)}
</div>
<p className="text-xs text-muted-foreground font-mono truncate mt-0.5">{wh.url}</p>
<div className="flex items-center gap-3 mt-1.5 text-2xs text-muted-foreground">
<span>{wh.events.includes('*') ? 'All events' : `${wh.events.length} event${wh.events.length !== 1 ? 's' : ''}`}</span>
<span>{wh.total_deliveries} deliveries</span>
{wh.failed_deliveries > 0 && (
<span className="text-red-400">{wh.failed_deliveries} failed</span>
)}
{wh.last_fired_at && (
<span>Last fired {formatTime(wh.last_fired_at)}</span>
)}
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
<button
onClick={() => handleTest(wh.id)}
disabled={testingId === wh.id}
className="h-7 px-2 text-2xs font-medium text-muted-foreground hover:text-foreground hover:bg-secondary rounded transition-smooth disabled:opacity-50"
title="Send test event"
>
{testingId === wh.id ? 'Testing...' : 'Test'}
</button>
<button
onClick={() => handleToggle(wh.id, !wh.enabled)}
className={`h-7 px-2 text-2xs font-medium rounded transition-smooth ${
wh.enabled
? 'text-amber-400 hover:bg-amber-500/10'
: 'text-green-400 hover:bg-green-500/10'
}`}
>
{wh.enabled ? 'Disable' : 'Enable'}
</button>
<button
onClick={() => handleDelete(wh.id)}
className="h-7 px-2 text-2xs font-medium text-red-400 hover:bg-red-500/10 rounded transition-smooth"
>
Delete
</button>
</div>
</div>
{/* Delivery log (expanded) */}
{selectedWebhook === wh.id && (
<div className="mt-3 pt-3 border-t border-border space-y-2">
<h4 className="text-xs font-semibold text-foreground">Recent Deliveries</h4>
{deliveries.length === 0 ? (
<p className="text-2xs text-muted-foreground">No deliveries recorded yet</p>
) : (
<div className="space-y-1 max-h-60 overflow-y-auto">
{deliveries.map((d) => (
<div key={d.id} className="flex items-center gap-2 text-2xs py-1 px-2 rounded hover:bg-secondary/50">
<span className={`w-1.5 h-1.5 rounded-full shrink-0 ${
d.status_code && d.status_code >= 200 && d.status_code < 300
? 'bg-green-500'
: 'bg-red-500'
}`} />
<span className="font-mono text-muted-foreground w-16 shrink-0">
{d.event_type}
</span>
<span className={`font-mono w-8 shrink-0 ${
d.status_code && d.status_code >= 200 && d.status_code < 300
? 'text-green-400'
: 'text-red-400'
}`}>
{d.status_code ?? 'ERR'}
</span>
<span className="text-muted-foreground font-mono">
{d.duration_ms}ms
</span>
{d.error && (
<span className="text-red-400 truncate">{d.error}</span>
)}
<span className="text-muted-foreground/50 ml-auto shrink-0">
{formatTime(d.created_at)}
</span>
</div>
))}
</div>
)}
</div>
)}
</div>
))
)}
</div>
</div>
)
}
function CreateWebhookForm({
onSubmit,
onCancel,
}: {
onSubmit: (form: { name: string; url: string; events: string[] }) => void
onCancel: () => void
}) {
const [name, setName] = useState('')
const [url, setUrl] = useState('')
const [selectedEvents, setSelectedEvents] = useState<string[]>(['*'])
function toggleEvent(value: string) {
if (value === '*') {
setSelectedEvents(['*'])
return
}
setSelectedEvents((prev) => {
const without = prev.filter((e) => e !== '*' && e !== value)
if (prev.includes(value)) return without.length === 0 ? ['*'] : without
return [...without, value]
})
}
return (
<div className="rounded-lg border border-border p-4 space-y-3">
<h3 className="text-sm font-semibold text-foreground">New Webhook</h3>
<div>
<label className="block text-xs text-muted-foreground mb-1">Name</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Slack alerts"
className="w-full h-8 px-2.5 rounded-md bg-secondary border border-border text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<div>
<label className="block text-xs text-muted-foreground mb-1">URL</label>
<input
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://hooks.slack.com/services/..."
className="w-full h-8 px-2.5 rounded-md bg-secondary border border-border text-sm text-foreground font-mono focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<div>
<label className="block text-xs text-muted-foreground mb-1.5">Events</label>
<div className="flex flex-wrap gap-1.5">
{AVAILABLE_EVENTS.map((ev) => (
<button
key={ev.value}
type="button"
onClick={() => toggleEvent(ev.value)}
title={ev.description}
className={`h-6 px-2 rounded text-2xs font-medium transition-smooth ${
selectedEvents.includes(ev.value)
? 'bg-primary text-primary-foreground'
: 'bg-secondary text-muted-foreground hover:text-foreground'
}`}
>
{ev.label}
</button>
))}
</div>
</div>
<div className="flex gap-2 pt-1">
<button
onClick={onCancel}
className="flex-1 h-8 rounded-md text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-secondary border border-border transition-smooth"
>
Cancel
</button>
<button
onClick={() => onSubmit({ name, url, events: selectedEvents })}
disabled={!name || !url}
className="flex-1 h-8 rounded-md text-xs font-medium bg-primary text-primary-foreground hover:bg-primary/90 transition-smooth disabled:opacity-50"
>
Create Webhook
</button>
</div>
</div>
)
}