|
| 1 | +'use client' |
| 2 | + |
| 3 | +import Link from 'next/link' |
| 4 | +import { useCallback, useEffect, useState } from 'react' |
| 5 | + |
| 6 | +type WebhookEvent = 'task.completed' | 'task.failed' | 'session.started' | 'session.ended' | 'agent.started' | 'agent.stopped' | 'backup.completed' | 'deploy.completed' |
| 7 | +type Webhook = { id: string; name: string; url: string; events: WebhookEvent[]; enabled: boolean; secret?: string; createdAt: number; lastTriggeredAt?: number; lastStatus?: number } |
| 8 | + |
| 9 | +const ALL_EVENTS: WebhookEvent[] = ['task.completed','task.failed','session.started','session.ended','agent.started','agent.stopped','backup.completed','deploy.completed'] |
| 10 | + |
| 11 | +function fmtDate(ms?: number) { |
| 12 | + if (!ms) return '—' |
| 13 | + return new Date(ms).toLocaleString('it-IT', { dateStyle: 'short', timeStyle: 'short' }) |
| 14 | +} |
| 15 | + |
| 16 | +function StatusPill({ status }: { status?: number }) { |
| 17 | + if (!status) return <span className="text-[9px] text-[var(--color-dim)]">—</span> |
| 18 | + const ok = status >= 200 && status < 300 |
| 19 | + return <span className="text-[9px] font-mono" style={{ color: ok ? 'var(--color-green)' : 'var(--color-red)' }}>{status}</span> |
| 20 | +} |
| 21 | + |
| 22 | +export default function WebhooksPage() { |
| 23 | + const [webhooks, setWebhooks] = useState<Webhook[]>([]) |
| 24 | + const [loading, setLoading] = useState(true) |
| 25 | + const [showForm, setShowForm] = useState(false) |
| 26 | + const [name, setName] = useState('') |
| 27 | + const [url, setUrl] = useState('') |
| 28 | + const [secret, setSecret] = useState('') |
| 29 | + const [events, setEvents] = useState<WebhookEvent[]>([]) |
| 30 | + const [creating, setCreating] = useState(false) |
| 31 | + const [pinging, setPinging] = useState<string | null>(null) |
| 32 | + const [pingResult, setPingResult] = useState<Record<string, { ok: boolean; status?: number }>>({}) |
| 33 | + |
| 34 | + const fetchData = useCallback(async () => { |
| 35 | + const res = await fetch('/api/webhooks').catch(() => null) |
| 36 | + if (res?.ok) { const d = await res.json(); setWebhooks(d.webhooks ?? []) } |
| 37 | + setLoading(false) |
| 38 | + }, []) |
| 39 | + |
| 40 | + useEffect(() => { fetchData() }, [fetchData]) |
| 41 | + |
| 42 | + const toggle = async (wh: Webhook) => { |
| 43 | + await fetch('/api/webhooks', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: wh.id, enabled: !wh.enabled }) }).catch(() => null) |
| 44 | + fetchData() |
| 45 | + } |
| 46 | + |
| 47 | + const del = async (id: string) => { |
| 48 | + await fetch(`/api/webhooks?id=${id}`, { method: 'DELETE' }).catch(() => null) |
| 49 | + setWebhooks(prev => prev.filter(w => w.id !== id)) |
| 50 | + } |
| 51 | + |
| 52 | + const ping = async (id: string) => { |
| 53 | + setPinging(id) |
| 54 | + const res = await fetch('/api/webhooks', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'test', id }) }).catch(() => null) |
| 55 | + if (res) { const d = await res.json(); setPingResult(prev => ({ ...prev, [id]: { ok: d.ok, status: d.status } })) } |
| 56 | + setPinging(null) |
| 57 | + fetchData() |
| 58 | + } |
| 59 | + |
| 60 | + const create = async () => { |
| 61 | + if (!name.trim() || !url.trim()) return |
| 62 | + setCreating(true) |
| 63 | + await fetch('/api/webhooks', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: name.trim(), url: url.trim(), events, secret: secret.trim() || undefined }) }).catch(() => null) |
| 64 | + setName(''); setUrl(''); setSecret(''); setEvents([]); setShowForm(false); setCreating(false) |
| 65 | + fetchData() |
| 66 | + } |
| 67 | + |
| 68 | + const toggleEvent = (e: WebhookEvent) => setEvents(prev => prev.includes(e) ? prev.filter(x => x !== e) : [...prev, e]) |
| 69 | + |
| 70 | + return ( |
| 71 | + <div style={{ animation: 'fade-in 0.35s ease both' }}> |
| 72 | + <div className="mb-8 pb-6 border-b border-[var(--color-border)]"> |
| 73 | + <div className="flex items-center gap-2 mb-1"> |
| 74 | + <Link href="/dashboard" className="text-[10px] text-[var(--color-dim)] hover:text-[var(--color-muted)] no-underline transition-colors">Dashboard</Link> |
| 75 | + <span className="text-[var(--color-border)]">/</span> |
| 76 | + <span className="text-[10px] text-[var(--color-muted)]">Webhook</span> |
| 77 | + </div> |
| 78 | + <div className="mt-3 flex items-center justify-between flex-wrap gap-3"> |
| 79 | + <div> |
| 80 | + <h1 className="text-2xl font-bold tracking-tight text-[var(--color-white)]">Webhook</h1> |
| 81 | + <p className="text-[var(--color-muted)] text-[11px] mt-1">{webhooks.filter(w => w.enabled).length} attivi · {webhooks.length} totali</p> |
| 82 | + </div> |
| 83 | + <button onClick={() => setShowForm(v => !v)} className="px-4 py-2 rounded-lg text-[11px] font-bold tracking-wide transition-all" |
| 84 | + style={{ background: showForm ? 'var(--color-border)' : 'var(--color-green)', color: showForm ? 'var(--color-muted)' : '#000', cursor: 'pointer' }}> |
| 85 | + {showForm ? '✕ annulla' : '+ nuovo webhook'} |
| 86 | + </button> |
| 87 | + </div> |
| 88 | + </div> |
| 89 | + |
| 90 | + {showForm && ( |
| 91 | + <div className="mb-6 p-4 rounded-lg border border-[var(--color-border)] bg-[var(--color-panel)]" style={{ animation: 'fade-in 0.2s ease both' }}> |
| 92 | + <div className="flex flex-col gap-3"> |
| 93 | + <div className="flex gap-2"> |
| 94 | + <input value={name} onChange={e => setName(e.target.value)} placeholder="Nome…" className="flex-1 text-[12px]" style={{ color: 'var(--color-bright)' }} /> |
| 95 | + <input value={secret} onChange={e => setSecret(e.target.value)} placeholder="Secret HMAC (opzionale)" className="flex-1 text-[12px]" style={{ color: 'var(--color-bright)' }} /> |
| 96 | + </div> |
| 97 | + <input value={url} onChange={e => setUrl(e.target.value)} placeholder="https://…" className="text-[12px]" style={{ color: 'var(--color-bright)' }} /> |
| 98 | + <div className="flex flex-wrap gap-1.5"> |
| 99 | + {ALL_EVENTS.map(ev => ( |
| 100 | + <button key={ev} onClick={() => toggleEvent(ev)} className="px-2 py-1 rounded text-[9px] font-mono cursor-pointer transition-all" |
| 101 | + style={{ border: `1px solid ${events.includes(ev) ? 'var(--color-green)' : 'var(--color-border)'}`, color: events.includes(ev) ? 'var(--color-green)' : 'var(--color-dim)', background: events.includes(ev) ? 'rgba(0,232,122,0.08)' : 'transparent' }}> |
| 102 | + {ev} |
| 103 | + </button> |
| 104 | + ))} |
| 105 | + </div> |
| 106 | + <button onClick={create} disabled={!name.trim() || !url.trim() || creating} |
| 107 | + className="self-end px-5 py-2 rounded-lg text-[11px] font-bold" |
| 108 | + style={{ background: name.trim() && url.trim() ? 'var(--color-green)' : 'var(--color-border)', color: name.trim() && url.trim() ? '#000' : 'var(--color-dim)', cursor: name.trim() && url.trim() ? 'pointer' : 'default' }}> |
| 109 | + {creating ? '…' : 'crea'} |
| 110 | + </button> |
| 111 | + </div> |
| 112 | + </div> |
| 113 | + )} |
| 114 | + |
| 115 | + {loading && <div className="flex justify-center py-16"><span className="text-[var(--color-dim)] text-[12px]">Caricamento…</span></div>} |
| 116 | + {!loading && ( |
| 117 | + <div className="border border-[var(--color-border)] rounded-lg overflow-hidden bg-[var(--color-panel)]"> |
| 118 | + {webhooks.length === 0 ? ( |
| 119 | + <div className="flex flex-col items-center justify-center py-16 gap-3"> |
| 120 | + <span className="text-4xl">🪝</span> |
| 121 | + <p className="text-[12px] font-semibold text-[var(--color-muted)]">Nessun webhook</p> |
| 122 | + <p className="text-[10px] text-[var(--color-dim)]">Registra webhook per ricevere notifiche sugli eventi.</p> |
| 123 | + </div> |
| 124 | + ) : webhooks.map((wh, i) => ( |
| 125 | + <div key={wh.id} className={`flex items-start gap-3 px-5 py-4 ${i < webhooks.length - 1 ? 'border-b' : ''}`} style={{ borderColor: 'var(--color-border)' }}> |
| 126 | + <div className="flex-1 min-w-0"> |
| 127 | + <div className="flex items-center gap-2 mb-0.5 flex-wrap"> |
| 128 | + <span className="text-[12px] font-semibold text-[var(--color-bright)]">{wh.name}</span> |
| 129 | + <span className="badge text-[9px]" style={{ color: wh.enabled ? 'var(--color-green)' : 'var(--color-dim)', border: `1px solid ${wh.enabled ? 'rgba(0,232,122,0.3)' : 'var(--color-border)'}`, background: wh.enabled ? 'rgba(0,232,122,0.08)' : 'transparent' }}> |
| 130 | + {wh.enabled ? 'attivo' : 'disattivo'} |
| 131 | + </span> |
| 132 | + <StatusPill status={pingResult[wh.id]?.status ?? wh.lastStatus} /> |
| 133 | + </div> |
| 134 | + <p className="text-[10px] font-mono text-[var(--color-dim)] truncate mb-1">{wh.url}</p> |
| 135 | + <div className="flex flex-wrap gap-1"> |
| 136 | + {wh.events.map(ev => <span key={ev} className="text-[8px] font-mono px-1.5 py-0.5 rounded" style={{ background: 'var(--color-border)', color: 'var(--color-muted)' }}>{ev}</span>)} |
| 137 | + {wh.events.length === 0 && <span className="text-[9px] text-[var(--color-border)]">nessun evento</span>} |
| 138 | + </div> |
| 139 | + <p className="text-[9px] text-[var(--color-dim)] mt-1">ultimo trigger: {fmtDate(wh.lastTriggeredAt)}</p> |
| 140 | + </div> |
| 141 | + <div className="flex gap-1 flex-shrink-0 mt-0.5"> |
| 142 | + <button onClick={() => ping(wh.id)} disabled={pinging === wh.id} className="px-2 py-1 rounded text-[9px] cursor-pointer" style={{ border: '1px solid var(--color-border)', color: 'var(--color-muted)', background: 'transparent' }}> |
| 143 | + {pinging === wh.id ? '…' : 'ping'} |
| 144 | + </button> |
| 145 | + <button onClick={() => toggle(wh)} className="px-2 py-1 rounded text-[9px] cursor-pointer" style={{ border: '1px solid var(--color-border)', color: 'var(--color-muted)', background: 'transparent' }}> |
| 146 | + {wh.enabled ? 'off' : 'on'} |
| 147 | + </button> |
| 148 | + <button onClick={() => del(wh.id)} className="px-2 py-1 rounded text-[9px] cursor-pointer" style={{ border: '1px solid rgba(255,69,96,0.2)', color: 'var(--color-red)', background: 'transparent' }}>✕</button> |
| 149 | + </div> |
| 150 | + </div> |
| 151 | + ))} |
| 152 | + </div> |
| 153 | + )} |
| 154 | + </div> |
| 155 | + ) |
| 156 | +} |
0 commit comments