Skip to content

Commit b812c3f

Browse files
committed
feat(webhooks): API /api/webhooks CRUD+ping + pagina /webhooks toggle, test ping, selezione eventi
2 parents c3da299 + 850dcec commit b812c3f

2 files changed

Lines changed: 280 additions & 0 deletions

File tree

web/app/api/webhooks/route.ts

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { NextRequest, NextResponse } from 'next/server'
2+
import * as fs from 'node:fs'
3+
import * as path from 'node:path'
4+
import * as os from 'node:os'
5+
import { randomUUID } from 'node:crypto'
6+
7+
export const dynamic = 'force-dynamic'
8+
9+
const WEBHOOKS_PATH = path.join(os.homedir(), '.jht', 'webhooks.json')
10+
11+
export type WebhookEvent =
12+
| 'task.completed' | 'task.failed' | 'session.started' | 'session.ended'
13+
| 'agent.started' | 'agent.stopped' | 'backup.completed' | 'deploy.completed'
14+
15+
export interface Webhook {
16+
id: string
17+
name: string
18+
url: string
19+
events: WebhookEvent[]
20+
enabled: boolean
21+
secret?: string
22+
createdAt: number
23+
lastTriggeredAt?: number
24+
lastStatus?: number
25+
}
26+
27+
interface WebhookStore { version: number; webhooks: Webhook[] }
28+
29+
function load(): WebhookStore {
30+
try {
31+
const p = JSON.parse(fs.readFileSync(WEBHOOKS_PATH, 'utf-8')) as WebhookStore
32+
return p?.webhooks ? p : { version: 1, webhooks: [] }
33+
} catch (e: any) {
34+
if (e.code === 'ENOENT') return { version: 1, webhooks: [] }
35+
throw e
36+
}
37+
}
38+
39+
function save(store: WebhookStore): void {
40+
fs.mkdirSync(path.dirname(WEBHOOKS_PATH), { recursive: true })
41+
const tmp = WEBHOOKS_PATH + '.tmp'
42+
fs.writeFileSync(tmp, JSON.stringify(store, null, 2) + '\n', 'utf-8')
43+
fs.renameSync(tmp, WEBHOOKS_PATH)
44+
}
45+
46+
/** GET — lista webhook */
47+
export async function GET() {
48+
const store = load()
49+
return NextResponse.json({ webhooks: store.webhooks, total: store.webhooks.length })
50+
}
51+
52+
/** POST — crea webhook o esegui test ping */
53+
export async function POST(req: NextRequest) {
54+
let body: { name?: string; url?: string; events?: WebhookEvent[]; secret?: string; action?: string; id?: string } = {}
55+
try { body = await req.json() } catch { /* ignore */ }
56+
57+
// Test ping
58+
if (body.action === 'test') {
59+
const store = load()
60+
const wh = store.webhooks.find(w => w.id === body.id)
61+
if (!wh) return NextResponse.json({ ok: false, error: 'webhook non trovato' }, { status: 404 })
62+
try {
63+
const res = await fetch(wh.url, {
64+
method: 'POST',
65+
headers: { 'Content-Type': 'application/json', 'X-JHT-Event': 'ping' },
66+
body: JSON.stringify({ event: 'ping', webhookId: wh.id, timestamp: Date.now() }),
67+
signal: AbortSignal.timeout(5000),
68+
})
69+
wh.lastTriggeredAt = Date.now()
70+
wh.lastStatus = res.status
71+
save(store)
72+
return NextResponse.json({ ok: true, status: res.status })
73+
} catch (err: any) {
74+
return NextResponse.json({ ok: false, error: err.message ?? 'timeout' }, { status: 502 })
75+
}
76+
}
77+
78+
// Crea nuovo webhook
79+
const url = body.url?.trim()
80+
const name = body.name?.trim()
81+
if (!url || !name) return NextResponse.json({ ok: false, error: 'name e url obbligatori' }, { status: 400 })
82+
83+
const webhook: Webhook = {
84+
id: randomUUID(), name, url,
85+
events: body.events ?? [],
86+
enabled: true,
87+
secret: body.secret?.trim() || undefined,
88+
createdAt: Date.now(),
89+
}
90+
const store = load()
91+
store.webhooks.push(webhook)
92+
save(store)
93+
return NextResponse.json({ ok: true, webhook }, { status: 201 })
94+
}
95+
96+
/** PUT — aggiorna webhook */
97+
export async function PUT(req: NextRequest) {
98+
let body: Partial<Webhook> & { id?: string } = {}
99+
try { body = await req.json() } catch { /* ignore */ }
100+
if (!body.id) return NextResponse.json({ ok: false, error: 'id obbligatorio' }, { status: 400 })
101+
102+
const store = load()
103+
const idx = store.webhooks.findIndex(w => w.id === body.id)
104+
if (idx === -1) return NextResponse.json({ ok: false, error: 'webhook non trovato' }, { status: 404 })
105+
106+
const { id: _, createdAt: __, ...fields } = body
107+
Object.assign(store.webhooks[idx]!, fields)
108+
save(store)
109+
return NextResponse.json({ ok: true, webhook: store.webhooks[idx] })
110+
}
111+
112+
/** DELETE — elimina webhook */
113+
export async function DELETE(req: NextRequest) {
114+
const id = req.nextUrl.searchParams.get('id')
115+
if (!id) return NextResponse.json({ ok: false, error: 'id obbligatorio' }, { status: 400 })
116+
117+
const store = load()
118+
const idx = store.webhooks.findIndex(w => w.id === id)
119+
if (idx === -1) return NextResponse.json({ ok: false, error: 'webhook non trovato' }, { status: 404 })
120+
121+
store.webhooks.splice(idx, 1)
122+
save(store)
123+
return NextResponse.json({ ok: true })
124+
}

web/app/webhooks/page.tsx

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
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

Comments
 (0)