mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
feat: outbound webhooks — signed event delivery + delivery log
Every audited action now emits a "resource.action" event (bare actions get resource-qualified; already-qualified actions like service.clone pass through). Webhooks subscribe per-user with exact names, resource.* wildcards, or *. - schema: outbound_webhooks + webhook_deliveries (sqlc + goose) - dispatcher: 4 workers, 512-queue, 3 attempts with backoff, 10s timeout; deliveries record status/http-code/body(4KiB)/duration - signing: X-Containr-Signature sha256=<hmac> over raw body; X-Containr-Event + X-Containr-Delivery headers; custom headers per hook - api: CRUD + /deliveries + /test (ping) under authed group - fix: SetAuditDB never called — LogAudit was a silent no-op at every call site; now wired at boot - coverage: service create/delete audited (previously uninstrumented) - cli: containr webhooks list|create|update|delete|deliveries|test - mcp: 6 tools, delete requires confirm - frontend: /settings/webhooks page — list/create/expand-deliveries/ test/disable/delete, secret-shown-once banner; nav + account menu - openapi + regenerated types; CLI.md + docs snapshot; roadmap ticks
This commit is contained in:
1 parent
5c2f2861bd
commit
f499678964
23 files changed
+2278
-7
No files matched your search
@@ -9,6 +9,7 @@ import { ServiceMetricsDashboard } from '@/features/service/pages/ServiceMetrics
|
||||
import { BuildsPage } from '@/features/builds/pages/BuildsPage';
|
||||
import { TemplatesPage } from '@/features/templates/pages/TemplatesPage';
|
||||
import { AuditLogsPage } from '@/features/audit/pages/AuditLogsPage';
|
||||
import { WebhooksPage } from '@/features/webhooks/pages/WebhooksPage';
|
||||
import { AdminPage } from '@/features/admin/pages/AdminPage';
|
||||
import { LandingPage } from '@/features/landing/LandingPage';
|
||||
import {
|
||||
@@ -151,6 +152,7 @@ export default function App() {
|
||||
<Route path="/people" element={<PeoplePage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/settings/audit-logs" element={<AuditLogsPage />} />
|
||||
<Route path="/settings/webhooks" element={<WebhooksPage />} />
|
||||
<Route path="/showcase" element={<ComponentShowcase />} />
|
||||
</Route>
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
UploadCloud,
|
||||
User,
|
||||
Users,
|
||||
Webhook,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
getCurrentUserProfile,
|
||||
@@ -67,6 +68,7 @@ const navSections: NavSection[] = [
|
||||
items: [
|
||||
{ label: 'People', href: '/people', icon: Users, hint: 'Members & access' },
|
||||
{ label: 'Audit Logs', href: '/settings/audit-logs', icon: ScrollText, hint: 'Activity trail' },
|
||||
{ label: 'Webhooks', href: '/settings/webhooks', icon: Webhook, hint: 'Signed event delivery' },
|
||||
{ label: 'Settings', href: '/settings', icon: Settings, hint: 'Platform preferences' },
|
||||
{ label: 'Docs', href: '/docs', icon: BookOpen, hint: 'Guides & reference' },
|
||||
],
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
createWebhook,
|
||||
deleteWebhook,
|
||||
listWebhookDeliveries,
|
||||
listWebhooks,
|
||||
testWebhook,
|
||||
updateWebhook,
|
||||
type OutboundWebhook,
|
||||
type WebhookDelivery,
|
||||
} from '@/lib/api-client';
|
||||
import { useDemoMode } from '@/lib/demo-mode';
|
||||
import { formatRelative } from '@/lib/time';
|
||||
import { DemoRestricted, useToast } from '@/shared/components';
|
||||
import {
|
||||
Webhook,
|
||||
Plus,
|
||||
Trash2,
|
||||
Send,
|
||||
Loader2,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Copy,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
|
||||
const inputClass =
|
||||
'h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all';
|
||||
|
||||
const eventSuggestions = ['*', 'service.*', 'project.*', 'database.*', 'deployment.*', 'template.*'];
|
||||
|
||||
function statusClass(status: WebhookDelivery['status']): string {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return 'text-[var(--success)]';
|
||||
case 'failed':
|
||||
return 'text-[var(--error)]';
|
||||
default:
|
||||
return 'text-[var(--warning)]';
|
||||
}
|
||||
}
|
||||
|
||||
function WebhookRow({
|
||||
webhook,
|
||||
onDelete,
|
||||
onToggle,
|
||||
onTest,
|
||||
testing,
|
||||
}: {
|
||||
webhook: OutboundWebhook;
|
||||
onDelete: () => void;
|
||||
onToggle: () => void;
|
||||
onTest: () => void;
|
||||
testing: boolean;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const deliveriesQuery = useQuery({
|
||||
queryKey: ['webhook-deliveries', webhook.id],
|
||||
queryFn: () => listWebhookDeliveries(webhook.id),
|
||||
enabled: expanded,
|
||||
refetchInterval: expanded ? 10000 : false,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="panel p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={() => setExpanded((v) => !v)} className="text-[var(--text-tertiary)]">
|
||||
{expanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||
</button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-[var(--text-primary)]">{webhook.name}</span>
|
||||
{!webhook.enabled && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-[var(--surface-muted)] text-[var(--text-muted)]">
|
||||
disabled
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-[var(--text-muted)] mono truncate">{webhook.url}</p>
|
||||
</div>
|
||||
<div className="hidden md:flex gap-1">
|
||||
{webhook.events.map((ev) => (
|
||||
<span
|
||||
key={ev}
|
||||
className="text-[10px] px-1.5 py-0.5 rounded bg-[var(--accent-primary-soft)] text-[var(--accent-primary)] mono"
|
||||
>
|
||||
{ev}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
onClick={onTest}
|
||||
disabled={testing || !webhook.enabled}
|
||||
className="p-2 rounded-[var(--radius-md)] text-[var(--text-tertiary)] hover:text-[var(--accent-primary)] disabled:opacity-40 transition-colors"
|
||||
title="Send test ping"
|
||||
>
|
||||
{testing ? <Loader2 size={15} className="animate-spin" /> : <Send size={15} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="p-2 rounded-[var(--radius-md)] text-[var(--text-tertiary)] hover:text-[var(--text-primary)] transition-colors text-xs"
|
||||
>
|
||||
{webhook.enabled ? 'Disable' : 'Enable'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="p-2 rounded-[var(--radius-md)] text-[var(--text-tertiary)] hover:text-[var(--error)] transition-colors"
|
||||
title="Delete webhook"
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{expanded && (
|
||||
<div className="mt-3 border-t border-[var(--border-subtle)] pt-3">
|
||||
<p className="text-[11px] uppercase tracking-wider text-[var(--text-muted)] mb-2">
|
||||
Recent deliveries
|
||||
</p>
|
||||
{deliveriesQuery.isLoading ? (
|
||||
<Loader2 size={14} className="animate-spin text-[var(--text-muted)]" />
|
||||
) : (deliveriesQuery.data ?? []).length === 0 ? (
|
||||
<p className="text-xs text-[var(--text-muted)]">No deliveries yet.</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{(deliveriesQuery.data ?? []).slice(0, 20).map((d) => (
|
||||
<div key={d.id} className="flex items-center gap-3 text-xs mono">
|
||||
<span className={statusClass(d.status)}>{d.status}</span>
|
||||
<span className="text-[var(--text-secondary)]">{d.event}</span>
|
||||
{d.response_status != null && (
|
||||
<span className="text-[var(--text-muted)]">HTTP {d.response_status}</span>
|
||||
)}
|
||||
<span className="text-[var(--text-muted)]">{d.attempts} tries</span>
|
||||
{d.duration_ms != null && (
|
||||
<span className="text-[var(--text-muted)]">{d.duration_ms}ms</span>
|
||||
)}
|
||||
<span className="text-[var(--text-muted)] ml-auto">{formatRelative(d.created_at)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function WebhooksPage() {
|
||||
const isDemoMode = useDemoMode();
|
||||
const queryClient = useQueryClient();
|
||||
const toast = useToast();
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [form, setForm] = useState({ name: '', url: '', events: [] as string[] });
|
||||
const [secret, setSecret] = useState<string | null>(null);
|
||||
const [testingId, setTestingId] = useState<string | null>(null);
|
||||
|
||||
const webhooksQuery = useQuery({
|
||||
queryKey: ['webhooks'],
|
||||
queryFn: listWebhooks,
|
||||
enabled: !isDemoMode,
|
||||
});
|
||||
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['webhooks'] });
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () => createWebhook(form),
|
||||
onSuccess: (created) => {
|
||||
invalidate();
|
||||
setCreating(false);
|
||||
setForm({ name: '', url: '', events: [] });
|
||||
if (created.secret) {
|
||||
setSecret(created.secret);
|
||||
}
|
||||
},
|
||||
onError: (err) => toast.showToast(err instanceof Error ? err.message : 'Failed to create webhook', 'error'),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => deleteWebhook(id),
|
||||
onSuccess: invalidate,
|
||||
onError: (err) => toast.showToast(err instanceof Error ? err.message : 'Delete failed', 'error'),
|
||||
});
|
||||
|
||||
const toggleMutation = useMutation({
|
||||
mutationFn: (w: OutboundWebhook) => updateWebhook(w.id, { enabled: !w.enabled }),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const testMutation = useMutation({
|
||||
mutationFn: (id: string) => testWebhook(id),
|
||||
onMutate: (id) => setTestingId(id),
|
||||
onSettled: () => setTestingId(null),
|
||||
onSuccess: () => toast.showToast('Test ping queued', 'info'),
|
||||
onError: (err) => toast.showToast(err instanceof Error ? err.message : 'Test failed', 'error'),
|
||||
});
|
||||
|
||||
if (isDemoMode) {
|
||||
return <DemoRestricted feature="Outbound webhooks" />;
|
||||
}
|
||||
|
||||
const toggleEvent = (ev: string) =>
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
events: f.events.includes(ev) ? f.events.filter((e) => e !== ev) : [...f.events, ev],
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<div className="border-b border-[var(--border-subtle)]">
|
||||
<div className="w-full px-8 py-5 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="v-title">Webhooks<span className="v-cursor">_</span></h1>
|
||||
<p className="v-mono mt-1.5 text-[11px] text-[var(--text-tertiary)]">
|
||||
Signed HTTP delivery for every platform event — deploys, restarts, deletes
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setCreating(true)}
|
||||
className="flex items-center gap-1.5 h-9 px-4 rounded-[var(--radius-md)] text-sm font-semibold text-[var(--accent-on)]"
|
||||
style={{ background: 'var(--accent-primary)' }}
|
||||
>
|
||||
<Plus size={15} />
|
||||
New webhook
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full px-8 py-6 space-y-4 max-w-4xl">
|
||||
{secret && (
|
||||
<div className="panel-soft p-4 flex items-center gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">Signing secret — shown once</p>
|
||||
<p className="text-xs text-[var(--text-muted)]">
|
||||
Verify deliveries with <span className="mono">X-Containr-Signature</span>
|
||||
</p>
|
||||
<code className="block mt-2 text-xs mono text-[var(--accent-primary)] break-all">{secret}</code>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard?.writeText(secret);
|
||||
toast.showToast('Secret copied', 'success');
|
||||
}}
|
||||
className="p-2 text-[var(--text-tertiary)] hover:text-[var(--text-primary)]"
|
||||
>
|
||||
<Copy size={15} />
|
||||
</button>
|
||||
<button onClick={() => setSecret(null)} className="p-2 text-[var(--text-tertiary)]">
|
||||
<X size={15} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{webhooksQuery.isLoading ? (
|
||||
<Loader2 size={18} className="animate-spin text-[var(--text-muted)]" />
|
||||
) : (webhooksQuery.data ?? []).length === 0 ? (
|
||||
<div className="panel p-8 text-center">
|
||||
<Webhook size={28} className="mx-auto text-[var(--text-muted)] mb-3" />
|
||||
<p className="text-sm text-[var(--text-secondary)]">No webhooks yet.</p>
|
||||
<p className="text-xs text-[var(--text-muted)] mt-1">
|
||||
Subscribe an HTTP endpoint to platform events like <span className="mono">service.*</span> or{' '}
|
||||
<span className="mono">deployment.fail</span>.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
(webhooksQuery.data ?? []).map((w) => (
|
||||
<WebhookRow
|
||||
key={w.id}
|
||||
webhook={w}
|
||||
testing={testingId === w.id}
|
||||
onTest={() => testMutation.mutate(w.id)}
|
||||
onToggle={() => toggleMutation.mutate(w)}
|
||||
onDelete={() => {
|
||||
if (window.confirm(`Delete webhook "${w.name}"?`)) {
|
||||
deleteMutation.mutate(w.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{creating && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-[var(--bg-void)]/80 backdrop-blur-sm" onClick={() => setCreating(false)} />
|
||||
<div className="relative w-full max-w-md panel p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">New webhook</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||
className={`${inputClass} w-full`}
|
||||
placeholder="deploy-alerts"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
|
||||
Endpoint URL
|
||||
</label>
|
||||
<input
|
||||
value={form.url}
|
||||
onChange={(e) => setForm((f) => ({ ...f, url: e.target.value }))}
|
||||
className={`${inputClass} w-full mono`}
|
||||
placeholder="https://example.com/hooks/containr"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
|
||||
Events
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{eventSuggestions.map((ev) => (
|
||||
<button
|
||||
key={ev}
|
||||
onClick={() => toggleEvent(ev)}
|
||||
className={`px-2.5 py-1 rounded-[var(--radius-md)] border text-xs mono transition-colors ${
|
||||
form.events.includes(ev)
|
||||
? 'border-[var(--accent-primary)] bg-[var(--accent-primary-soft)] text-[var(--accent-primary)]'
|
||||
: 'border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-tertiary)]'
|
||||
}`}
|
||||
>
|
||||
{ev}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-2 text-[11px] text-[var(--text-muted)]">
|
||||
Events are <span className="mono">resource.action</span> — wildcards like{' '}
|
||||
<span className="mono">service.*</span> match the whole resource.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setCreating(false)}
|
||||
className="h-10 px-4 rounded-[var(--radius-md)] text-sm text-[var(--text-secondary)]"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => createMutation.mutate()}
|
||||
disabled={
|
||||
createMutation.isPending ||
|
||||
!form.name.trim() ||
|
||||
!form.url.trim() ||
|
||||
form.events.length === 0
|
||||
}
|
||||
className="h-10 px-5 rounded-[var(--radius-md)] text-sm font-semibold text-[var(--accent-on)] disabled:opacity-50 flex items-center gap-2"
|
||||
style={{ background: 'var(--accent-primary)' }}
|
||||
>
|
||||
{createMutation.isPending && <Loader2 size={14} className="animate-spin" />}
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3507,6 +3507,256 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/webhooks": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** List outbound webhooks */
|
||||
get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Webhooks for the caller (secrets masked) */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
webhooks?: components["schemas"]["OutboundWebhook"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
put?: never;
|
||||
/**
|
||||
* Create an outbound webhook
|
||||
* @description Receives signed POST deliveries for every audited action matching
|
||||
* the event list — "resource.action" names, "resource.*" wildcards,
|
||||
* or "*" for everything. The secret is returned once; deliveries
|
||||
* carry X-Containr-Signature: sha256=<hmac> over the raw body.
|
||||
*/
|
||||
post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": {
|
||||
name: string;
|
||||
url: string;
|
||||
/** @description Signing secret (auto-generated when omitted) */
|
||||
secret?: string;
|
||||
/**
|
||||
* @example [
|
||||
* "service.*",
|
||||
* "deployment.fail"
|
||||
* ]
|
||||
*/
|
||||
events: string[];
|
||||
headers?: {
|
||||
[key: string]: string;
|
||||
};
|
||||
/** @default true */
|
||||
enabled?: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Webhook created (secret revealed once) */
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["OutboundWebhook"];
|
||||
};
|
||||
};
|
||||
/** @description Invalid name, URL, or empty event list */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/webhooks/{id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post?: never;
|
||||
/** Delete a webhook */
|
||||
delete: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Deleted */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["MessageResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
options?: never;
|
||||
head?: never;
|
||||
/** Update a webhook */
|
||||
patch: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": {
|
||||
name?: string;
|
||||
url?: string;
|
||||
secret?: string;
|
||||
events?: string[];
|
||||
headers?: {
|
||||
[key: string]: string;
|
||||
};
|
||||
enabled?: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Updated webhook */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["OutboundWebhook"];
|
||||
};
|
||||
};
|
||||
/** @description Webhook not found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
trace?: never;
|
||||
};
|
||||
"/webhooks/{id}/deliveries": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** List delivery attempts for a webhook */
|
||||
get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Recent deliveries (newest first, max 100) */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
deliveries?: components["schemas"]["WebhookDelivery"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/webhooks/{id}/test": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Queue a test ping delivery */
|
||||
post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Delivery queued */
|
||||
202: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["MessageResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/audit-logs": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -9923,6 +10173,43 @@ export interface components {
|
||||
notifications?: components["schemas"]["Notification"][];
|
||||
unread?: number;
|
||||
};
|
||||
OutboundWebhook: {
|
||||
/** Format: uuid */
|
||||
id?: string;
|
||||
name?: string;
|
||||
url?: string;
|
||||
/** @description Only present on create/update responses */
|
||||
secret?: string;
|
||||
events?: string[];
|
||||
headers?: {
|
||||
[key: string]: string;
|
||||
};
|
||||
enabled?: boolean;
|
||||
/** Format: date-time */
|
||||
created_at?: string;
|
||||
/** Format: date-time */
|
||||
updated_at?: string;
|
||||
};
|
||||
WebhookDelivery: {
|
||||
/** Format: uuid */
|
||||
id?: string;
|
||||
/** Format: uuid */
|
||||
webhook_id?: string;
|
||||
event?: string;
|
||||
payload?: {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
/** @enum {string} */
|
||||
status?: "pending" | "success" | "failed";
|
||||
response_status?: number | null;
|
||||
response_body?: string | null;
|
||||
attempts?: number;
|
||||
duration_ms?: number | null;
|
||||
/** Format: date-time */
|
||||
created_at?: string;
|
||||
/** Format: date-time */
|
||||
delivered_at?: string | null;
|
||||
};
|
||||
AuditLogListResponse: {
|
||||
audit_logs?: components["schemas"]["AuditLog"][];
|
||||
};
|
||||
|
||||
@@ -40,7 +40,7 @@ export const bundledDocs: BundledDoc[] = [
|
||||
"path": "guides/CLI.md",
|
||||
"section": "guides",
|
||||
"title": "Containr CLI",
|
||||
"markdown": "# Containr CLI\n\nThe CLI controls every part of a Containr instance — the same API surface\nthe web UI uses. Build it with `scripts/build-cli.sh` or\n`go build ./cmd/cli` from `app/backend`.\n\n## Authentication\n\nCreate a personal access token in the web UI under\n**Settings → Personal Access Tokens** (or `containr tokens create`), then:\n\n```bash\ncontainr auth login cnp_... # verify + store\ncontainr auth login cnp_... --url https://containr.example.com\ncontainr auth login cnp_... --name production # named profile\ncontainr auth status # show active profile + identity\ncontainr auth logout\n```\n\nResolution order for every command: `--token`/`--api-url` flags >\n`CONTAINR_TOKEN`/`CONTAINR_API_URL` env > active profile >\n`http://localhost:8080/api/v1`. The active profile is `--profile` >\n`CONTAINR_PROFILE` > `current_profile` in `~/.containr.yaml`.\n\n## Output and scripting\n\n- `--json` prints the raw API response — safe to pipe and parse.\n- `--yes` / `-y` skips confirmation on destructive commands (delete,\n revoke, restore, failover). Non-interactive shells refuse destructive\n commands without it.\n- Exit codes are stable: `0` ok, `1` error, `2` auth/permission,\n `3` not found, `4` validation, `5` unreachable.\n\n## Commands\n\n```\nauth login|logout|status token auth and profiles\ntokens list|create|revoke personal access tokens\nprojects list|get|create|delete\nservices list|get|create|update|delete|start|stop|restart|redeploy\nservices clone|move duplicate config or re-parent a service\nservices domains list|add|remove|default|check multi-domain + DNS preflight\nregistries list|add|update|remove private-image pull credentials\nvolumes list|delete docker volume inventory + cleanup (admin)\ndeploy <service-id> trigger a deployment (--no-cache, --commit-hash, --branch)\ndeployments list|get|logs|rollback|cancel\nlogs <service-id> [--follow] runtime logs (SSE follow)\nexec <service-id> -- <cmd> one-off container command\nvariables list|set|unset env vars (secrets masked; --redeploy applies them)\nvariables shared list|set|unset project shared vars — referenced as ${{shared.KEY}}\ndatabases list|get|create|update|delete|action|backup|restore|download-backup|import-backup\n import-backup <id> <file.tar.gz|-> uploads an archive as a restore\n point; ships offsite too when a backup target is assigned\ndatabases list|get|create|update|delete|action|backup|restore|download-backup\n create --public exposes the port on all interfaces;\n update <id> --public/--public=false toggles it (container recreates, data persists)\ndatabases register <name> register an external database (--type --host --port --database --username --password --ssl; probed before storing)\ndatabases test-connection probe a connection — by id, or ad-hoc flags\nbackup-targets list|add|update|remove|test\n S3-compatible archive destinations; add/update probe the bucket\n first, empty --access-key/--secret-key on update keep stored keys;\n update <db-id> --backup-target assigns a target (\"\" clears it)\ncron list|get|create|delete|trigger|executions\ntemplates list|get|create|delete|deploy|plan|import-compose|deploy-graph\n deploy --name --var K=V; plan dry-resolves expressions;\n import-compose <compose.yml|-> converts a compose file into\n a v2 graph config; deploy-graph <project-id> <config.json|->\n deploys an ad-hoc service graph\nimport compose <project-id> <compose.yml|->\n deploys a docker-compose file straight into a project —\n no template row. DB images become managed databases\n attached under their service name; --var K=V overrides\n template variables\nnodes list|get|delete|tokens node agents + onboarding tokens (admin)\nscaling status|policies|services|scale\nha status|alerts|health|policies|enable|disable|failover\nsecurity scan|vulnerabilities|metrics\ngateway services|keys|analytics (admin)\nadmin overview|users|settings|audit-logs\nnotifications list|read\nup deploy the current directory\n```\n\n## containr up\n\nRun inside a git checkout: uses `remote.origin.url` + the current branch\nas the build source, creates a service named after the directory when none\nexists, then triggers a deployment.\n\n```bash\ncontainr up --project <id>\ncontainr up --project <id> --image ghcr.io/example/app:latest # image, no build\n```\n\nSet `project_id` on the active profile to omit `-Line truncated
|
||||
"markdown": "# Containr CLI\n\nThe CLI controls every part of a Containr instance — the same API surface\nthe web UI uses. Build it with `scripts/build-cli.sh` or\n`go build ./cmd/cli` from `app/backend`.\n\n## Authentication\n\nCreate a personal access token in the web UI under\n**Settings → Personal Access Tokens** (or `containr tokens create`), then:\n\n```bash\ncontainr auth login cnp_... # verify + store\ncontainr auth login cnp_... --url https://containr.example.com\ncontainr auth login cnp_... --name production # named profile\ncontainr auth status # show active profile + identity\ncontainr auth logout\n```\n\nResolution order for every command: `--token`/`--api-url` flags >\n`CONTAINR_TOKEN`/`CONTAINR_API_URL` env > active profile >\n`http://localhost:8080/api/v1`. The active profile is `--profile` >\n`CONTAINR_PROFILE` > `current_profile` in `~/.containr.yaml`.\n\n## Output and scripting\n\n- `--json` prints the raw API response — safe to pipe and parse.\n- `--yes` / `-y` skips confirmation on destructive commands (delete,\n revoke, restore, failover). Non-interactive shells refuse destructive\n commands without it.\n- Exit codes are stable: `0` ok, `1` error, `2` auth/permission,\n `3` not found, `4` validation, `5` unreachable.\n\n## Commands\n\n```\nauth login|logout|status token auth and profiles\ntokens list|create|revoke personal access tokens\nprojects list|get|create|delete\nservices list|get|create|update|delete|start|stop|restart|redeploy\nservices clone|move duplicate config or re-parent a service\nservices domains list|add|remove|default|check multi-domain + DNS preflight\nregistries list|add|update|remove private-image pull credentials\nvolumes list|delete docker volume inventory + cleanup (admin)\ndeploy <service-id> trigger a deployment (--no-cache, --commit-hash, --branch)\ndeployments list|get|logs|rollback|cancel\nlogs <service-id> [--follow] runtime logs (SSE follow)\nexec <service-id> -- <cmd> one-off container command\nvariables list|set|unset env vars (secrets masked; --redeploy applies them)\nvariables shared list|set|unset project shared vars — referenced as ${{shared.KEY}}\ndatabases list|get|create|update|delete|action|backup|restore|download-backup|import-backup\n import-backup <id> <file.tar.gz|-> uploads an archive as a restore\n point; ships offsite too when a backup target is assigned\ndatabases list|get|create|update|delete|action|backup|restore|download-backup\n create --public exposes the port on all interfaces;\n update <id> --public/--public=false toggles it (container recreates, data persists)\ndatabases register <name> register an external database (--type --host --port --database --username --password --ssl; probed before storing)\ndatabases test-connection probe a connection — by id, or ad-hoc flags\nbackup-targets list|add|update|remove|test\n S3-compatible archive destinations; add/update probe the bucket\n first, empty --access-key/--secret-key on update keep stored keys;\n update <db-id> --backup-target assigns a target (\"\" clears it)\ncron list|get|create|delete|trigger|executions\ntemplates list|get|create|delete|deploy|plan|import-compose|deploy-graph\n deploy --name --var K=V; plan dry-resolves expressions;\n import-compose <compose.yml|-> converts a compose file into\n a v2 graph config; deploy-graph <project-id> <config.json|->\n deploys an ad-hoc service graph\nimport compose <project-id> <compose.yml|->\n deploys a docker-compose file straight into a project —\n no template row. DB images become managed databases\n attached under their service name; --var K=V overrides\n template variables\nnodes list|get|delete|tokens node agents + onboarding tokens (admin)\nscaling status|policies|services|scale\nha status|alerts|health|policies|enable|disable|failover\nsecurity scan|vulnerabilities|metrics\ngateway services|keys|analytics (admin)\nadmin overview|users|settings|audit-logs\nnotifications list|read\nwebhooks list|create|update|delete|deliveries|test\n outbound signed webhooks — create <name> <url>\n --event 'service.*' (repeatable, '*' = all);\n deliveries are HMAC-signed (X-Containr-Signature)\nup deploy the current directory\n```\n\n## containr up\n\nRun inside a git checkout: uses `remote.origin.url` + the current branch\Line truncated
|
||||
},
|
||||
{
|
||||
"path": "guides/CLOUDFLARE_SETUP.md",
|
||||
|
||||
@@ -1767,6 +1767,66 @@ export async function importComposeIntoProject(
|
||||
return { created: payload.created ?? [], warnings: payload.warnings ?? [] };
|
||||
}
|
||||
|
||||
export type OutboundWebhook = {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
secret?: string;
|
||||
events: string[];
|
||||
headers?: Record<string, string>;
|
||||
enabled: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type WebhookDelivery = {
|
||||
id: string;
|
||||
webhook_id: string;
|
||||
event: string;
|
||||
status: 'pending' | 'success' | 'failed';
|
||||
response_status?: number | null;
|
||||
response_body?: string | null;
|
||||
attempts: number;
|
||||
duration_ms?: number | null;
|
||||
created_at: string;
|
||||
delivered_at?: string | null;
|
||||
};
|
||||
|
||||
export async function listWebhooks(): Promise<OutboundWebhook[]> {
|
||||
const payload = await requestJson<{ webhooks?: OutboundWebhook[] }>('/webhooks');
|
||||
return payload.webhooks ?? [];
|
||||
}
|
||||
|
||||
export type WebhookInput = {
|
||||
name: string;
|
||||
url: string;
|
||||
events: string[];
|
||||
secret?: string;
|
||||
headers?: Record<string, string>;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
export async function createWebhook(input: WebhookInput): Promise<OutboundWebhook> {
|
||||
return requestJson<OutboundWebhook>('/webhooks', { method: 'POST', body: JSON.stringify(input) });
|
||||
}
|
||||
|
||||
export async function updateWebhook(id: string, input: Partial<WebhookInput>): Promise<OutboundWebhook> {
|
||||
return requestJson<OutboundWebhook>(`/webhooks/${id}`, { method: 'PATCH', body: JSON.stringify(input) });
|
||||
}
|
||||
|
||||
export async function deleteWebhook(id: string): Promise<void> {
|
||||
await requestJson(`/webhooks/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export async function listWebhookDeliveries(id: string): Promise<WebhookDelivery[]> {
|
||||
const payload = await requestJson<{ deliveries?: WebhookDelivery[] }>(`/webhooks/${id}/deliveries`);
|
||||
return payload.deliveries ?? [];
|
||||
}
|
||||
|
||||
export async function testWebhook(id: string): Promise<void> {
|
||||
await requestJson(`/webhooks/${id}/test`, { method: 'POST', body: JSON.stringify({}) });
|
||||
}
|
||||
|
||||
export function serviceStatusClass(status: ServiceStatus): string {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
|
||||
Reference in new issue
Block a user