diff --git a/apps/dashboard/src/api/client.ts b/apps/dashboard/src/api/client.ts index 90f42db..dbb88f6 100644 --- a/apps/dashboard/src/api/client.ts +++ b/apps/dashboard/src/api/client.ts @@ -75,6 +75,43 @@ export async function getDaemons(): Promise<{ daemons: unknown[] }> { return result.value as { daemons: unknown[] }; } +export async function getDaemonDetail(role: string): Promise<{ + role: string; + description: string; + status: 'active' | 'paused' | 'stopped'; + trigger: Record & { type: string }; + stats: Record; +}> { + const result = await getClient().daemons.get(role); + if (result.isErr()) throw result.error; + return result.value as ReturnType extends Promise ? T : never; +} + +export async function createDaemon(request: Record): Promise { + const result = await getClient().daemons.create( + request as Parameters[0], + ); + if (result.isErr()) throw result.error; + return result.value; +} + +export async function updateDaemon( + role: string, + request: Record, +): Promise { + const result = await getClient().daemons.update( + role, + request as Parameters[1], + ); + if (result.isErr()) throw result.error; + return result.value; +} + +export async function deleteDaemon(role: string): Promise { + const result = await getClient().daemons.delete(role); + if (result.isErr()) throw result.error; +} + // --- Snapshot Configs --- function apiKeyHeaders(): Record { diff --git a/apps/dashboard/src/components/DaemonForm.tsx b/apps/dashboard/src/components/DaemonForm.tsx new file mode 100644 index 0000000..c85f878 --- /dev/null +++ b/apps/dashboard/src/components/DaemonForm.tsx @@ -0,0 +1,693 @@ +import { useEffect, useState } from 'react'; +import { toast } from 'sonner'; +import { X } from 'lucide-react'; + +import { Button } from './ui/button.js'; +import { Input } from './ui/input.js'; +import { Label } from './ui/label.js'; +import { Textarea } from './ui/textarea.js'; +import { + Sheet, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from './ui/sheet.js'; +import { Badge } from './ui/badge.js'; +import { createDaemon, updateDaemon, getDaemonDetail, getSnapshotConfigs } from '../api/client.js'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +type TriggerType = 'github' | 'webhook' | 'schedule' | 'watch'; +type DaemonStatus = 'active' | 'paused' | 'stopped'; + +interface DaemonFormData { + role: string; + description: string; + status: DaemonStatus; + triggerType: TriggerType; + // GitHub trigger + githubRepos: string[]; + githubEvents: string[]; + githubCommand: string; + // Webhook trigger + webhookEvents: string[]; + webhookSecret: string; + webhookSignatureScheme: 'hmac-sha256' | 'slack-v0' | 'none'; + webhookSignatureHeader: string; + // Schedule trigger + cron: string; + // Watch trigger + watchCondition: string; + watchIntervalMs: number; + // Execution + snapshot: string; + script: string; + timeoutMs: number; + vcpus: number; + memoryMB: number; + // Governance + maxActionsPerHour: number; + auditLog: boolean; +} + +const DEFAULT_FORM: DaemonFormData = { + role: '', + description: '', + status: 'active', + triggerType: 'github', + githubRepos: [], + githubEvents: ['issue_comment'], + githubCommand: '', + webhookEvents: [], + webhookSecret: '', + webhookSignatureScheme: 'hmac-sha256', + webhookSignatureHeader: 'x-hub-signature-256', + cron: '', + watchCondition: '', + watchIntervalMs: 60000, + snapshot: '', + script: '', + timeoutMs: 300000, + vcpus: 2, + memoryMB: 4096, + maxActionsPerHour: 20, + auditLog: true, +}; + +interface DaemonFormProps { + open: boolean; + onOpenChange: (open: boolean) => void; + /** When set, form is in edit mode */ + editRole?: string | undefined; + onSaved: () => void; +} + +// --------------------------------------------------------------------------- +// GitHub event options +// --------------------------------------------------------------------------- + +const GITHUB_EVENT_OPTIONS = [ + { value: 'pull_request.opened', label: 'PR opened' }, + { value: 'pull_request.synchronize', label: 'PR synchronized' }, + { value: 'pull_request.closed', label: 'PR closed' }, + { value: 'issue_comment', label: 'Issue comment' }, + { value: 'push', label: 'Push' }, + { value: 'issues.opened', label: 'Issue opened' }, +]; + +// --------------------------------------------------------------------------- +// Tag Input +// --------------------------------------------------------------------------- + +function TagInput({ + tags, + onChange, + placeholder, +}: { + tags: string[]; + onChange: (tags: string[]) => void; + placeholder?: string; +}) { + const [input, setInput] = useState(''); + + function handleKeyDown(e: React.KeyboardEvent) { + if ((e.key === 'Enter' || e.key === ',') && input.trim()) { + e.preventDefault(); + const value = input.trim().replace(/,$/, ''); + if (value && !tags.includes(value)) { + onChange([...tags, value]); + } + setInput(''); + } + if (e.key === 'Backspace' && !input && tags.length > 0) { + onChange(tags.slice(0, -1)); + } + } + + function removeTag(tag: string) { + onChange(tags.filter((t) => t !== tag)); + } + + return ( +
+ {tags.map((tag) => ( + + {tag} + + + ))} + setInput(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={tags.length === 0 ? placeholder : ''} + className="flex-1 min-w-24 bg-transparent text-sm text-zinc-100 placeholder-zinc-600 outline-none" + /> +
+ ); +} + +// --------------------------------------------------------------------------- +// DaemonForm +// --------------------------------------------------------------------------- + +export function DaemonForm({ open, onOpenChange, editRole, onSaved }: DaemonFormProps) { + const [form, setForm] = useState(DEFAULT_FORM); + const [saving, setSaving] = useState(false); + const [loading, setLoading] = useState(false); + const [snapshots, setSnapshots] = useState([]); + + const isEdit = Boolean(editRole); + + // Fetch snapshots for dropdown + useEffect(() => { + if (!open) return; + getSnapshotConfigs() + .then((configs) => setSnapshots(configs.map((c) => c.id))) + .catch(() => { + // Silently fail - user can type manually + }); + }, [open]); + + // Load daemon data when editing + useEffect(() => { + if (!open || !editRole) { + if (!editRole) setForm(DEFAULT_FORM); + return; + } + setLoading(true); + getDaemonDetail(editRole) + .then((daemon) => { + // eslint-disable-next-line typescript/no-explicit-any + const trigger = daemon.trigger as Record; + const triggerType = trigger.type as TriggerType; + const partial: Partial = { + role: daemon.role as string, + description: (daemon.description as string) ?? '', + status: daemon.status as DaemonStatus, + triggerType, + }; + + if (triggerType === 'github') { + partial.githubRepos = (trigger.repos as string[]) ?? []; + partial.githubEvents = (trigger.events as string[]) ?? ['issue_comment']; + partial.githubCommand = (trigger.command as string) ?? ''; + } else if (triggerType === 'webhook') { + partial.webhookEvents = (trigger.events as string[]) ?? []; + partial.webhookSecret = (trigger.secret as string) ?? ''; + partial.webhookSignatureScheme = + (trigger.signatureScheme as 'hmac-sha256' | 'slack-v0' | 'none') ?? 'hmac-sha256'; + partial.webhookSignatureHeader = + (trigger.signatureHeader as string) ?? 'x-hub-signature-256'; + } else if (triggerType === 'schedule') { + partial.cron = trigger.cron as string; + } else if (triggerType === 'watch') { + partial.watchCondition = trigger.condition as string; + partial.watchIntervalMs = (trigger.intervalMs as number) ?? 60000; + } + + setForm({ ...DEFAULT_FORM, ...partial }); + }) + .catch((err: unknown) => { + const message = err instanceof Error ? err.message : String(err); + toast.error(`Failed to load daemon: ${message}`); + }) + .finally(() => setLoading(false)); + }, [open, editRole]); + + function update(key: K, value: DaemonFormData[K]) { + setForm((prev) => ({ ...prev, [key]: value })); + } + + function buildTrigger() { + switch (form.triggerType) { + case 'github': + return { + type: 'github' as const, + repos: form.githubRepos, + events: form.githubEvents, + ...(form.githubCommand ? { command: form.githubCommand } : {}), + }; + case 'webhook': + return { + type: 'webhook' as const, + events: form.webhookEvents, + signatureScheme: form.webhookSignatureScheme, + ...(form.webhookSecret ? { secret: form.webhookSecret } : {}), + ...(form.webhookSignatureHeader ? { signatureHeader: form.webhookSignatureHeader } : {}), + }; + case 'schedule': + return { + type: 'schedule' as const, + cron: form.cron, + }; + case 'watch': + return { + type: 'watch' as const, + condition: form.watchCondition, + intervalMs: form.watchIntervalMs, + }; + } + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setSaving(true); + + try { + const trigger = buildTrigger(); + + if (isEdit && editRole) { + await updateDaemon(editRole, { + description: form.description, + trigger, + ...(form.script + ? { workload: { type: 'script' as const, script: form.script, env: {} } } + : {}), + resources: { vcpus: form.vcpus, memoryMB: form.memoryMB }, + governance: { + maxActionsPerHour: form.maxActionsPerHour, + requiresApproval: [], + auditLog: form.auditLog, + }, + }); + toast.success(`Daemon "${editRole}" updated`); + } else { + await createDaemon({ + role: form.role, + description: form.description, + snapshot: form.snapshot, + trigger, + workload: { type: 'script' as const, script: form.script, env: {} }, + resources: { vcpus: form.vcpus, memoryMB: form.memoryMB }, + governance: { + maxActionsPerHour: form.maxActionsPerHour, + requiresApproval: [], + auditLog: form.auditLog, + }, + }); + toast.success(`Daemon "${form.role}" created`); + } + + onSaved(); + onOpenChange(false); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + toast.error(message); + } finally { + setSaving(false); + } + } + + const isValid = + form.role.length > 0 && + form.snapshot.length > 0 && + form.script.length > 0 && + (form.triggerType !== 'github' || form.githubRepos.length > 0) && + (form.triggerType !== 'schedule' || form.cron.length > 0) && + (form.triggerType !== 'watch' || form.watchCondition.length > 0); + + const triggerTypes: { value: TriggerType; label: string }[] = [ + { value: 'github', label: 'GitHub' }, + { value: 'webhook', label: 'Webhook' }, + { value: 'schedule', label: 'Schedule' }, + { value: 'watch', label: 'Watch' }, + ]; + + return ( + + + + {isEdit ? 'Edit Daemon' : 'Create Daemon'} + + {isEdit + ? 'Update daemon configuration' + : 'Configure a persistent agent role that responds to triggers'} + + + + {loading ? ( +
Loading...
+ ) : ( +
+ {/* Basic */} +
+

+ Basic +

+
+ + update('role', e.target.value)} + placeholder="code-reviewer" + required + disabled={isEdit} + className="bg-zinc-800 border-zinc-700 text-zinc-100 placeholder-zinc-600 focus-visible:border-emerald-400/50 focus-visible:ring-emerald-400/20" + /> +

Unique identifier for this daemon

+
+
+ +