diff --git a/CHANGELOG.md b/CHANGELOG.md index cf22406b..97e93dbe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * **fleet:** Critical node detection with red badge for nodes exceeding 90% CPU or disk usage * **fleet:** Error toasts on stack and container fetch failures (replaces silent error swallowing) * **fleet:** ProGate now wraps placeholder content instead of empty children for a better upgrade preview +* **webhooks:** Custom CI/CD webhooks — create webhooks targeting specific stacks and actions (deploy, restart, stop, start, pull), trigger them from GitHub Actions, GitLab CI, or any HTTP client with HMAC-SHA256 signature authentication (Pro) +* **webhooks:** Execution history tracking — last 100 executions per webhook with status, duration, and error details +* **webhooks:** Webhook management UI in Settings with create/edit/delete, enable/disable toggle, one-time secret reveal, and copy-to-clipboard for trigger URLs ### Fixed diff --git a/backend/src/index.ts b/backend/src/index.ts index bea276bb..e80fb4cc 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -26,6 +26,7 @@ import { templateService } from './services/TemplateService'; import { ErrorParser } from './utils/ErrorParser'; import { NodeRegistry } from './services/NodeRegistry'; import { LicenseService } from './services/LicenseService'; +import { WebhookService } from './services/WebhookService'; import { isValidStackName, isValidRemoteUrl } from './utils/validation'; import YAML from 'yaml'; import fs, { promises as fsPromises } from 'fs'; @@ -142,7 +143,8 @@ app.use((req: Request, res: Response, next: NextFunction): void => { !req.path.startsWith('/api/auth/') && !req.path.startsWith('/api/nodes') && !req.path.startsWith('/api/license') && - !req.path.startsWith('/api/fleet') + !req.path.startsWith('/api/fleet') && + !req.path.startsWith('/api/webhooks') ) { // Preserve body stream for proxy piping next(); @@ -174,7 +176,8 @@ const nodeContextMiddleware = (req: Request, res: Response, next: NextFunction) !req.path.startsWith('/api/auth/') && !req.path.startsWith('/api/nodes') && !req.path.startsWith('/api/license') && - !req.path.startsWith('/api/fleet') + !req.path.startsWith('/api/fleet') && + !req.path.startsWith('/api/webhooks') ) { const node = DatabaseService.getInstance().getNode(req.nodeId); if (!node) { @@ -424,7 +427,7 @@ app.post('/api/auth/generate-node-token', authMiddleware, async (req: Request, r // Apply authentication middleware to all /api/* routes except /api/auth/* app.use('/api', (req: Request, res: Response, next: NextFunction): void => { - if (req.path.startsWith('/auth/')) { + if (req.path.startsWith('/auth/') || /^\/webhooks\/\d+\/trigger$/.test(req.path)) { next(); return; } @@ -748,6 +751,143 @@ async function fetchRemoteNodeOverview(node: Node): Promise { } } +// ─── Webhooks (Pro) ─── CRUD requires auth + Pro, trigger is public with HMAC ─── + +// Webhook CRUD (auth + Pro required) +app.get('/api/webhooks', authMiddleware, async (_req: Request, res: Response): Promise => { + if (!requirePro(_req, res)) return; + try { + const webhooks = DatabaseService.getInstance().getWebhooks(); + const svc = WebhookService.getInstance(); + res.json(webhooks.map(w => ({ ...w, secret: svc.maskSecret(w.secret) }))); + } catch (error) { + console.error('[Webhooks] List error:', error); + res.status(500).json({ error: 'Failed to list webhooks' }); + } +}); + +app.post('/api/webhooks', authMiddleware, async (req: Request, res: Response): Promise => { + if (!requirePro(req, res)) return; + try { + const { name, stack_name, action, enabled } = req.body; + if (!name || !stack_name || !action) { + res.status(400).json({ error: 'name, stack_name, and action are required' }); + return; + } + const validActions = ['deploy', 'restart', 'stop', 'start', 'pull']; + if (!validActions.includes(action)) { + res.status(400).json({ error: `action must be one of: ${validActions.join(', ')}` }); + return; + } + + const svc = WebhookService.getInstance(); + const secret = svc.generateSecret(); + const id = DatabaseService.getInstance().addWebhook({ + name, stack_name, action, secret, enabled: enabled !== false, + }); + + // Return the full secret only on creation + res.status(201).json({ id, secret }); + } catch (error) { + console.error('[Webhooks] Create error:', error); + res.status(500).json({ error: 'Failed to create webhook' }); + } +}); + +app.put('/api/webhooks/:id', authMiddleware, async (req: Request, res: Response): Promise => { + if (!requirePro(req, res)) return; + try { + const id = parseInt(req.params.id as string, 10); + const webhook = DatabaseService.getInstance().getWebhook(id); + if (!webhook) { res.status(404).json({ error: 'Webhook not found' }); return; } + + const { name, stack_name, action, enabled } = req.body; + const validActions = ['deploy', 'restart', 'stop', 'start', 'pull']; + if (action && !validActions.includes(action)) { + res.status(400).json({ error: `action must be one of: ${validActions.join(', ')}` }); + return; + } + + DatabaseService.getInstance().updateWebhook(id, { name, stack_name, action, enabled }); + res.json({ success: true }); + } catch (error) { + console.error('[Webhooks] Update error:', error); + res.status(500).json({ error: 'Failed to update webhook' }); + } +}); + +app.delete('/api/webhooks/:id', authMiddleware, async (req: Request, res: Response): Promise => { + if (!requirePro(req, res)) return; + try { + const id = parseInt(req.params.id as string, 10); + DatabaseService.getInstance().deleteWebhook(id); + res.json({ success: true }); + } catch (error) { + console.error('[Webhooks] Delete error:', error); + res.status(500).json({ error: 'Failed to delete webhook' }); + } +}); + +app.get('/api/webhooks/:id/history', authMiddleware, async (req: Request, res: Response): Promise => { + if (!requirePro(req, res)) return; + try { + const id = parseInt(req.params.id as string, 10); + const executions = DatabaseService.getInstance().getWebhookExecutions(id); + res.json(executions); + } catch (error) { + console.error('[Webhooks] History error:', error); + res.status(500).json({ error: 'Failed to fetch webhook history' }); + } +}); + +// Webhook trigger — public endpoint, authenticated via HMAC signature +app.post('/api/webhooks/:id/trigger', async (req: Request, res: Response): Promise => { + try { + const id = parseInt(req.params.id as string, 10); + const db = DatabaseService.getInstance(); + const webhook = db.getWebhook(id); + + if (!webhook || !webhook.enabled) { + res.status(404).json({ error: 'Webhook not found or disabled' }); + return; + } + + // Pro gate — trigger only works with an active Pro license + if (LicenseService.getInstance().getTier() !== 'pro') { + res.status(403).json({ error: 'This feature requires Sencho Pro.', code: 'PRO_REQUIRED' }); + return; + } + + // Validate HMAC signature + const signature = req.headers['x-webhook-signature'] as string; + if (!signature) { + res.status(401).json({ error: 'Missing X-Webhook-Signature header' }); + return; + } + + const rawBody = JSON.stringify(req.body ?? {}); + const svc = WebhookService.getInstance(); + if (!svc.validateSignature(rawBody, webhook.secret, signature)) { + res.status(401).json({ error: 'Invalid signature' }); + return; + } + + // Use action from body if provided, otherwise use webhook default + const action = req.body?.action || webhook.action; + const triggerSource = req.headers['user-agent'] || req.ip || null; + + // Execute asynchronously — return 202 immediately + res.status(202).json({ message: 'Webhook accepted', action }); + + svc.execute(id, action, triggerSource).catch(err => { + console.error(`[Webhooks] Execution error for webhook ${id}:`, err); + }); + } catch (error) { + console.error('[Webhooks] Trigger error:', error); + res.status(500).json({ error: 'Failed to process webhook' }); + } +}); + // Remote Node HTTP Proxy - single global instance. // Previously, createProxyMiddleware was called inside the request handler on every API // call, spawning a new proxy instance (and http-proxy server) each time. This caused: @@ -823,7 +963,7 @@ const remoteNodeProxy = createProxyMiddleware({ // Intercepts all /api/ requests for remote Distributed API nodes and forwards them // to the target Sencho instance. Node management and auth routes always execute locally. app.use('/api/', (req: Request, res: Response, next: NextFunction): void => { - if (req.path.startsWith('/auth/') || req.path.startsWith('/nodes') || req.path.startsWith('/license') || req.path.startsWith('/fleet')) { + if (req.path.startsWith('/auth/') || req.path.startsWith('/nodes') || req.path.startsWith('/license') || req.path.startsWith('/fleet') || req.path.startsWith('/webhooks')) { next(); return; } diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index f88c9959..d54cef46 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -37,6 +37,28 @@ export interface Node { api_token?: string; } +export interface Webhook { + id?: number; + name: string; + stack_name: string; + action: 'deploy' | 'restart' | 'stop' | 'start' | 'pull'; + secret: string; + enabled: boolean; + created_at: number; + updated_at: number; +} + +export 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 interface NotificationHistory { id?: number; level: 'info' | 'warning' | 'error'; @@ -141,6 +163,31 @@ export class DatabaseService { key TEXT PRIMARY KEY, value TEXT NOT NULL ); + + CREATE TABLE IF NOT EXISTS webhooks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + stack_name TEXT NOT NULL, + action TEXT NOT NULL DEFAULT 'deploy', + secret TEXT NOT NULL, + enabled INTEGER DEFAULT 1, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS webhook_executions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + webhook_id INTEGER NOT NULL, + action TEXT NOT NULL, + status TEXT NOT NULL, + trigger_source TEXT, + duration_ms INTEGER, + error TEXT, + executed_at INTEGER NOT NULL, + FOREIGN KEY(webhook_id) REFERENCES webhooks(id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_webhook_executions_webhook ON webhook_executions(webhook_id); `); // Apply migrations safely (ignore if columns already exist) @@ -481,4 +528,69 @@ export class DatabaseService { } return result; } + + // --- Webhooks --- + + public getWebhooks(): Webhook[] { + return this.db.prepare('SELECT * FROM webhooks ORDER BY created_at DESC').all().map((row: any) => ({ + ...row, + enabled: row.enabled === 1, + })); + } + + public getWebhook(id: number): Webhook | undefined { + const row = this.db.prepare('SELECT * FROM webhooks WHERE id = ?').get(id) as any; + if (!row) return undefined; + return { ...row, enabled: row.enabled === 1 }; + } + + public addWebhook(webhook: Omit): number { + const now = Date.now(); + const result = this.db.prepare( + 'INSERT INTO webhooks (name, stack_name, action, secret, enabled, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)' + ).run(webhook.name, webhook.stack_name, webhook.action, webhook.secret, webhook.enabled ? 1 : 0, now, now); + return result.lastInsertRowid as number; + } + + public updateWebhook(id: number, updates: Partial>): void { + const fields: string[] = []; + const values: (string | number)[] = []; + + if (updates.name !== undefined) { fields.push('name = ?'); values.push(updates.name); } + if (updates.stack_name !== undefined) { fields.push('stack_name = ?'); values.push(updates.stack_name); } + if (updates.action !== undefined) { fields.push('action = ?'); values.push(updates.action); } + if (updates.enabled !== undefined) { fields.push('enabled = ?'); values.push(updates.enabled ? 1 : 0); } + + if (fields.length === 0) return; + + fields.push('updated_at = ?'); + values.push(Date.now()); + values.push(id); + this.db.prepare(`UPDATE webhooks SET ${fields.join(', ')} WHERE id = ?`).run(...values); + } + + public deleteWebhook(id: number): void { + this.db.prepare('DELETE FROM webhooks WHERE id = ?').run(id); + } + + // --- Webhook Executions --- + + public getWebhookExecutions(webhookId: number, limit = 20): WebhookExecution[] { + return this.db.prepare( + 'SELECT * FROM webhook_executions WHERE webhook_id = ? ORDER BY executed_at DESC LIMIT ?' + ).all(webhookId, limit) as WebhookExecution[]; + } + + public addWebhookExecution(execution: Omit): number { + const result = this.db.prepare( + 'INSERT INTO webhook_executions (webhook_id, action, status, trigger_source, duration_ms, error, executed_at) VALUES (?, ?, ?, ?, ?, ?, ?)' + ).run(execution.webhook_id, execution.action, execution.status, execution.trigger_source, execution.duration_ms, execution.error, execution.executed_at); + + // Keep only last 100 executions per webhook + this.db.prepare( + 'DELETE FROM webhook_executions WHERE webhook_id = ? AND id NOT IN (SELECT id FROM webhook_executions WHERE webhook_id = ? ORDER BY executed_at DESC LIMIT 100)' + ).run(execution.webhook_id, execution.webhook_id); + + return result.lastInsertRowid as number; + } } diff --git a/backend/src/services/WebhookService.ts b/backend/src/services/WebhookService.ts new file mode 100644 index 00000000..e0dc8c3e --- /dev/null +++ b/backend/src/services/WebhookService.ts @@ -0,0 +1,114 @@ +import crypto from 'crypto'; +import { DatabaseService } from './DatabaseService'; +import { ComposeService } from './ComposeService'; +import { FileSystemService } from './FileSystemService'; +import { NodeRegistry } from './NodeRegistry'; + +export class WebhookService { + private static instance: WebhookService; + + public static getInstance(): WebhookService { + if (!WebhookService.instance) { + WebhookService.instance = new WebhookService(); + } + return WebhookService.instance; + } + + public generateSecret(): string { + return crypto.randomBytes(32).toString('hex'); + } + + public validateSignature(payload: string, secret: string, signature: string): boolean { + // Expect format: sha256= + const parts = signature.split('='); + if (parts.length !== 2 || parts[0] !== 'sha256') return false; + + const expected = crypto + .createHmac('sha256', secret) + .update(payload) + .digest('hex'); + + return crypto.timingSafeEqual( + Buffer.from(expected, 'hex'), + Buffer.from(parts[1], 'hex') + ); + } + + public async execute(webhookId: number, action: string, triggerSource: string | null): Promise<{ success: boolean; error?: string; duration_ms: number }> { + const db = DatabaseService.getInstance(); + const webhook = db.getWebhook(webhookId); + if (!webhook) throw new Error('Webhook not found'); + + const defaultNodeId = NodeRegistry.getInstance().getDefaultNodeId(); + + // Validate the stack still exists + const stacks = await FileSystemService.getInstance(defaultNodeId).getStacks(); + if (!stacks.includes(webhook.stack_name)) { + const error = `Stack "${webhook.stack_name}" not found`; + db.addWebhookExecution({ + webhook_id: webhookId, + action, + status: 'failure', + trigger_source: triggerSource, + duration_ms: 0, + error, + executed_at: Date.now(), + }); + return { success: false, error, duration_ms: 0 }; + } + + const startTime = Date.now(); + try { + const compose = ComposeService.getInstance(defaultNodeId); + switch (action) { + case 'deploy': + await compose.deployStack(webhook.stack_name); + break; + case 'restart': + await compose.runCommand(webhook.stack_name, 'restart'); + break; + case 'stop': + await compose.runCommand(webhook.stack_name, 'stop'); + break; + case 'start': + await compose.runCommand(webhook.stack_name, 'start'); + break; + case 'pull': + await compose.updateStack(webhook.stack_name); + break; + default: + throw new Error(`Unknown action: ${action}`); + } + + const duration_ms = Date.now() - startTime; + db.addWebhookExecution({ + webhook_id: webhookId, + action, + status: 'success', + trigger_source: triggerSource, + duration_ms, + error: null, + executed_at: Date.now(), + }); + return { success: true, duration_ms }; + } catch (err) { + const duration_ms = Date.now() - startTime; + const error = (err as Error).message || 'Unknown error'; + db.addWebhookExecution({ + webhook_id: webhookId, + action, + status: 'failure', + trigger_source: triggerSource, + duration_ms, + error, + executed_at: Date.now(), + }); + return { success: false, error, duration_ms }; + } + } + + public maskSecret(secret: string): string { + if (secret.length <= 8) return '••••••••'; + return '••••••••' + secret.slice(-4); + } +} diff --git a/docs/docs.json b/docs/docs.json index 1083cdc7..74056ccb 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -36,7 +36,8 @@ "features/host-console", "features/multi-node", "features/fleet-view", - "features/alerts-notifications" + "features/alerts-notifications", + "features/webhooks" ] }, { diff --git a/docs/features/overview.mdx b/docs/features/overview.mdx index 98267301..1d07835f 100644 --- a/docs/features/overview.mdx +++ b/docs/features/overview.mdx @@ -47,6 +47,10 @@ Browse 190+ pre-configured application templates. Filter by category (Media, Aut Open an interactive terminal on the host OS directly in the browser — full xterm.js emulation with color support. No SSH client required. +## Webhooks + +Trigger stack actions from external CI/CD pipelines via HTTP webhooks. Create a webhook targeting a specific stack and action, then call it from GitHub Actions, GitLab CI, or any system that can send an HTTP POST. Requests are authenticated with HMAC-SHA256 signatures — no session cookies required. [Learn more →](/features/webhooks) + ## Alerts & notifications Configure threshold-based alerts (CPU, memory, network, restart count) per stack. Route notifications to Discord, Slack, or any generic webhook endpoint. [Learn more →](/features/alerts-notifications) diff --git a/docs/features/webhooks.mdx b/docs/features/webhooks.mdx new file mode 100644 index 00000000..f3b3c9bb --- /dev/null +++ b/docs/features/webhooks.mdx @@ -0,0 +1,127 @@ +--- +title: Webhooks +description: Trigger stack actions from CI/CD pipelines via HTTP webhooks with HMAC signature authentication. +--- + + + Webhooks require a Sencho Pro license. + + +Sencho webhooks let external systems trigger stack actions over HTTP. The typical use case: your CI pipeline builds a new image, then calls a Sencho webhook to deploy the updated stack — no manual intervention required. + +## How it works + +1. You create a webhook in **Settings → Webhooks**, targeting a specific stack and action +2. Sencho generates a unique secret for HMAC-SHA256 signature validation +3. Your CI/CD system sends a `POST` request to the trigger URL with the correct signature +4. Sencho validates the signature and executes the action asynchronously + +## Creating a webhook + +Open **Settings → Webhooks** and click **Create Webhook**. Fill in: + +| Field | Description | +|-------|-------------| +| **Name** | A display name (e.g. "Deploy on push") | +| **Stack** | The target stack to act on | +| **Action** | One of: `deploy`, `restart`, `stop`, `start`, `pull` | + +After creation, Sencho shows the webhook secret **once**. Copy it immediately — it cannot be retrieved later. + +### Actions explained + +| Action | What it does | +|--------|-------------| +| **Deploy** | Runs `docker compose down` then `docker compose up -d` (full redeploy) | +| **Restart** | Runs `docker compose restart` | +| **Stop** | Runs `docker compose stop` | +| **Start** | Runs `docker compose start` | +| **Pull** | Pulls latest images and recreates changed containers | + +## Triggering a webhook + +Send a POST request to the trigger URL with an HMAC-SHA256 signature: + +``` +POST /api/webhooks/{id}/trigger +Content-Type: application/json +X-Webhook-Signature: sha256={hmac} +``` + +The signature is computed as `HMAC-SHA256(request_body, webhook_secret)` and sent in the `X-Webhook-Signature` header with a `sha256=` prefix. + +### Example with curl + +```bash +SECRET="your-webhook-secret" +BODY='{}' +SIGNATURE=$(echo -n "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | cut -d' ' -f2) + +curl -X POST https://your-sencho.com/api/webhooks/3/trigger \ + -H "Content-Type: application/json" \ + -H "X-Webhook-Signature: sha256=$SIGNATURE" \ + -d "$BODY" +``` + +### Overriding the action + +By default, the webhook executes the action configured at creation time. You can override it per-request by including an `action` field in the body: + +```json +{ "action": "restart" } +``` + +### Response + +A successful trigger returns `202 Accepted` immediately. The action executes asynchronously in the background. + +```json +{ "message": "Webhook accepted", "action": "deploy" } +``` + +## CI/CD integration examples + +### GitHub Actions + +```yaml +- name: Deploy via Sencho + run: | + BODY='{}' + SIGNATURE=$(echo -n "$BODY" | openssl dgst -sha256 -hmac "${{ secrets.SENCHO_WEBHOOK_SECRET }}" | cut -d' ' -f2) + curl -X POST "${{ secrets.SENCHO_URL }}/api/webhooks/${{ secrets.SENCHO_WEBHOOK_ID }}/trigger" \ + -H "Content-Type: application/json" \ + -H "X-Webhook-Signature: sha256=$SIGNATURE" \ + -d "$BODY" +``` + +### GitLab CI + +```yaml +deploy: + stage: deploy + script: + - BODY='{}' + - SIGNATURE=$(echo -n "$BODY" | openssl dgst -sha256 -hmac "$SENCHO_WEBHOOK_SECRET" | cut -d' ' -f2) + - 'curl -X POST "$SENCHO_URL/api/webhooks/$SENCHO_WEBHOOK_ID/trigger" + -H "Content-Type: application/json" + -H "X-Webhook-Signature: sha256=$SIGNATURE" + -d "$BODY"' +``` + +## Execution history + +Each webhook tracks its last 100 executions. Click **Recent executions** on any webhook row to see: + +- Status (success/failure) +- Action performed +- Execution time +- Duration +- Error message (if failed) + +## Security + +- Webhook secrets are 64-character hex strings generated with `crypto.randomBytes(32)` +- Signature validation uses `crypto.timingSafeEqual` to prevent timing attacks +- Secrets are shown only once at creation — API responses return masked values +- Webhook trigger endpoints are public (no session cookie required) but protected by HMAC signature validation +- Each webhook targets a single stack — there is no way to execute arbitrary commands diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 5ff750a7..7fcde88e 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -18,12 +18,13 @@ import { Badge } from '@/components/ui/badge'; import { toast } from 'sonner'; import { apiFetch } from '@/lib/api'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; -import { Shield, Activity, Bell, Code, Server, Package, RefreshCw, Database, Info, Crown, CheckCircle, XCircle, Clock } from 'lucide-react'; +import { Shield, Activity, Bell, Code, Server, Package, RefreshCw, Database, Info, Crown, CheckCircle, XCircle, Clock, Webhook, Copy, Trash2, Plus, ChevronDown, ChevronRight, History } from 'lucide-react'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { NodeManager } from './NodeManager'; import { useNodes } from '@/context/NodeContext'; import { useLicense } from '@/context/LicenseContext'; import { ProBadge } from './ProBadge'; +import { ProGate } from './ProGate'; interface Agent { type: 'discord' | 'slack' | 'webhook'; @@ -45,7 +46,29 @@ interface PatchableSettings { log_retention_days?: string; } -type SectionId = 'account' | 'license' | 'system' | 'notifications' | 'developer' | 'nodes' | 'appstore' | 'about'; +type SectionId = 'account' | 'license' | 'system' | 'notifications' | 'webhooks' | 'developer' | 'nodes' | 'appstore' | 'about'; + +interface WebhookItem { + 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; +} interface SettingsModalProps { isOpen: boolean; @@ -65,9 +88,293 @@ const DEFAULT_SETTINGS: PatchableSettings = { log_retention_days: '30', }; +function WebhooksSection({ isPro }: { isPro: boolean }) { + 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); + + // Form state + 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(); }, []); + + 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 }), + }); + 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 copyToClipboard = (text: string, label: string) => { + navigator.clipboard.writeText(text); + toast.success(`${label} copied to clipboard.`); + }; + + if (!isPro) { + return ( +
+
+

Webhooks

+

Trigger stack actions from CI/CD pipelines via HTTP.

+
+ +
+
+
+
+ +
+ ); + } + + return ( +
+
+
+

Webhooks

+

Trigger stack actions from CI/CD pipelines via HTTP.

+
+ +
+ + {/* Create Form */} + {showForm && ( +
+
+ + setFormName(e.target.value)} /> +
+
+ + +
+
+ + +
+
+ + +
+
+ )} + + {/* Secret reveal (shown once after creation) */} + {newSecret && ( +
+
+ Webhook created — copy your secret now +
+

This secret will not be shown again. Store it securely.

+
+ {newSecret.secret} + +
+ +
+ )} + + {/* Loading state */} + {loading && ( +
+ + +
+ )} + + {/* Empty state */} + {!loading && webhooks.length === 0 && !showForm && ( +
+ +

No webhooks configured yet.

+

Create one to trigger stack actions from CI/CD.

+
+ )} + + {/* Webhook list */} + {!loading && webhooks.map(wh => { + const triggerUrl = `${window.location.origin}/api/webhooks/${wh.id}/trigger`; + const isExpanded = expandedHistory === wh.id; + return ( +
+
+
+
+ + {wh.name} + {wh.action} + {wh.stack_name} +
+
+ handleToggle(wh.id!, c)} /> + +
+
+ + {/* Trigger URL */} +
+ +
+ {triggerUrl} + +
+
+ + {/* Secret (masked) */} +
+ Secret: + {wh.secret} +
+ + {/* History toggle */} + +
+ + {/* Execution history */} + {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} + )} +
+ ))} +
+ )} +
+ )} +
+ ); + })} +
+ ); +} + export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { const { activeNode } = useNodes(); - const { license, activate, deactivate } = useLicense(); + const { license, isPro, activate, deactivate } = useLicense(); const isRemote = activeNode?.type === 'remote'; const [activeSection, setActiveSection] = useState('account'); const [licenseKeyInput, setLicenseKeyInput] = useState(''); @@ -76,7 +383,7 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { // When switching to a remote node, reset to a node-scoped section if on a global-only one useEffect(() => { - if (isRemote && (activeSection === 'account' || activeSection === 'license' || activeSection === 'notifications' || activeSection === 'nodes' || activeSection === 'appstore')) { + if (isRemote && (activeSection === 'account' || activeSection === 'license' || activeSection === 'notifications' || activeSection === 'webhooks' || activeSection === 'nodes' || activeSection === 'appstore')) { setActiveSection('system'); } }, [isRemote]); // eslint-disable-line react-hooks/exhaustive-deps @@ -421,6 +728,9 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { showDot={hasSystemChanges} /> } label="Notifications" /> + {!isRemote && ( + } label="Webhooks" /> + )} } @@ -778,6 +1088,10 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
)} + {activeSection === 'webhooks' && ( + + )} + {activeSection === 'developer' && (