feat: implement server logging and event hook features

- Introduced server log level configuration and HTTP request logging settings, allowing users to control the verbosity of server logs and enable or disable request logging at runtime.
- Added an Event Hook Service to execute custom actions based on system events, supporting shell commands and HTTP webhooks.
- Enhanced the UI with new sections for managing server logging preferences and event hooks, including a dialog for creating and editing hooks.
- Updated global settings to include server log level and request logging options, ensuring persistence across sessions.

These changes aim to improve debugging capabilities and provide users with customizable event-driven actions within the application.
This commit is contained in:
webdevcody
2026-01-16 00:21:49 -05:00
parent 874c5a36de
commit 7465017600
15 changed files with 1160 additions and 17 deletions

View File

@@ -19,6 +19,7 @@ import { WorktreesSection } from './settings-view/worktrees';
import { DangerZoneSection } from './settings-view/danger-zone/danger-zone-section';
import { AccountSection } from './settings-view/account';
import { SecuritySection } from './settings-view/security';
import { DeveloperSection } from './settings-view/developer/developer-section';
import {
ClaudeSettingsTab,
CursorSettingsTab,
@@ -27,6 +28,7 @@ import {
} from './settings-view/providers';
import { MCPServersSection } from './settings-view/mcp-servers';
import { PromptCustomizationSection } from './settings-view/prompts';
import { EventHooksSection } from './settings-view/event-hooks';
import type { Project as SettingsProject, Theme } from './settings-view/shared/types';
import type { Project as ElectronProject } from '@/lib/electron';
@@ -183,6 +185,8 @@ export function SettingsView() {
return (
<AudioSection muteDoneSound={muteDoneSound} onMuteDoneSoundChange={setMuteDoneSound} />
);
case 'event-hooks':
return <EventHooksSection />;
case 'defaults':
return (
<FeatureDefaultsSection
@@ -215,6 +219,8 @@ export function SettingsView() {
onSkipSandboxWarningChange={setSkipSandboxWarning}
/>
);
case 'developer':
return <DeveloperSection />;
case 'danger':
return (
<DangerZoneSection

View File

@@ -16,6 +16,8 @@ import {
Shield,
Cpu,
GitBranch,
Code2,
Webhook,
} from 'lucide-react';
import { AnthropicIcon, CursorIcon, OpenAIIcon, OpenCodeIcon } from '@/components/ui/provider-icon';
import type { SettingsViewId } from '../hooks/use-settings-view';
@@ -63,6 +65,7 @@ export const GLOBAL_NAV_GROUPS: NavigationGroup[] = [
{ id: 'terminal', label: 'Terminal', icon: SquareTerminal },
{ id: 'keyboard', label: 'Keyboard Shortcuts', icon: Settings2 },
{ id: 'audio', label: 'Audio', icon: Volume2 },
{ id: 'event-hooks', label: 'Event Hooks', icon: Webhook },
],
},
{
@@ -72,6 +75,10 @@ export const GLOBAL_NAV_GROUPS: NavigationGroup[] = [
{ id: 'security', label: 'Security', icon: Shield },
],
},
{
label: 'Advanced',
items: [{ id: 'developer', label: 'Developer', icon: Code2 }],
},
];
// Flat list of all global nav items for backwards compatibility

View File

@@ -0,0 +1,91 @@
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Code2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useAppStore, type ServerLogLevel } from '@/store/app-store';
import { toast } from 'sonner';
const LOG_LEVEL_OPTIONS: { value: ServerLogLevel; label: string; description: string }[] = [
{ value: 'error', label: 'Error', description: 'Only show error messages' },
{ value: 'warn', label: 'Warning', description: 'Show warnings and errors' },
{ value: 'info', label: 'Info', description: 'Show general information (default)' },
{ value: 'debug', label: 'Debug', description: 'Show all messages including debug' },
];
export function DeveloperSection() {
const { serverLogLevel, setServerLogLevel, enableRequestLogging, setEnableRequestLogging } =
useAppStore();
return (
<div
className={cn(
'rounded-2xl overflow-hidden',
'border border-border/50',
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
'shadow-sm shadow-black/5'
)}
>
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center gap-3 mb-2">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-purple-500/20 to-purple-600/10 flex items-center justify-center border border-purple-500/20">
<Code2 className="w-5 h-5 text-purple-500" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">Developer</h2>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">
Advanced settings for debugging and development.
</p>
</div>
<div className="p-6 space-y-6">
{/* Server Log Level */}
<div className="space-y-3">
<Label className="text-foreground font-medium">Server Log Level</Label>
<p className="text-xs text-muted-foreground">
Control the verbosity of API server logs. Set to "Error" to only see error messages in
the server console.
</p>
<select
value={serverLogLevel}
onChange={(e) => {
setServerLogLevel(e.target.value as ServerLogLevel);
toast.success(`Log level changed to ${e.target.value}`, {
description: 'Server logging verbosity updated',
});
}}
className={cn(
'w-full px-3 py-2 rounded-lg',
'bg-accent/30 border border-border/50',
'text-foreground text-sm',
'focus:outline-none focus:ring-2 focus:ring-purple-500/30'
)}
>
{LOG_LEVEL_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label} - {option.description}
</option>
))}
</select>
</div>
{/* HTTP Request Logging */}
<div className="flex items-center justify-between pt-4 border-t border-border/30">
<div className="space-y-1">
<Label className="text-foreground font-medium">HTTP Request Logging</Label>
<p className="text-xs text-muted-foreground">
Log all HTTP requests (method, URL, status) to the server console.
</p>
</div>
<Switch
checked={enableRequestLogging}
onCheckedChange={(checked) => {
setEnableRequestLogging(checked);
toast.success(checked ? 'Request logging enabled' : 'Request logging disabled', {
description: 'HTTP request logging updated',
});
}}
/>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,289 @@
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Terminal, Globe } from 'lucide-react';
import type {
EventHook,
EventHookTrigger,
EventHookHttpMethod,
EventHookShellAction,
EventHookHttpAction,
} from '@automaker/types';
import { EVENT_HOOK_TRIGGER_LABELS } from '@automaker/types';
interface EventHookDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
editingHook: EventHook | null;
onSave: (hook: EventHook) => void;
}
type ActionType = 'shell' | 'http';
const TRIGGER_OPTIONS: EventHookTrigger[] = [
'feature_success',
'feature_error',
'auto_mode_complete',
'auto_mode_error',
];
const HTTP_METHODS: EventHookHttpMethod[] = ['POST', 'GET', 'PUT', 'PATCH'];
export function EventHookDialog({ open, onOpenChange, editingHook, onSave }: EventHookDialogProps) {
// Form state
const [name, setName] = useState('');
const [trigger, setTrigger] = useState<EventHookTrigger>('feature_success');
const [actionType, setActionType] = useState<ActionType>('shell');
// Shell action state
const [command, setCommand] = useState('');
const [timeout, setTimeout] = useState('30000');
// HTTP action state
const [url, setUrl] = useState('');
const [method, setMethod] = useState<EventHookHttpMethod>('POST');
const [headers, setHeaders] = useState('');
const [body, setBody] = useState('');
// Reset form when dialog opens/closes or editingHook changes
useEffect(() => {
if (open) {
if (editingHook) {
// Populate form with existing hook data
setName(editingHook.name || '');
setTrigger(editingHook.trigger);
setActionType(editingHook.action.type);
if (editingHook.action.type === 'shell') {
const shellAction = editingHook.action as EventHookShellAction;
setCommand(shellAction.command);
setTimeout(String(shellAction.timeout || 30000));
// Reset HTTP fields
setUrl('');
setMethod('POST');
setHeaders('');
setBody('');
} else {
const httpAction = editingHook.action as EventHookHttpAction;
setUrl(httpAction.url);
setMethod(httpAction.method);
setHeaders(httpAction.headers ? JSON.stringify(httpAction.headers, null, 2) : '');
setBody(httpAction.body || '');
// Reset shell fields
setCommand('');
setTimeout('30000');
}
} else {
// Reset to defaults for new hook
setName('');
setTrigger('feature_success');
setActionType('shell');
setCommand('');
setTimeout('30000');
setUrl('');
setMethod('POST');
setHeaders('');
setBody('');
}
}
}, [open, editingHook]);
const handleSave = () => {
const hook: EventHook = {
id: editingHook?.id || crypto.randomUUID(),
name: name.trim() || undefined,
trigger,
enabled: editingHook?.enabled ?? true,
action:
actionType === 'shell'
? {
type: 'shell',
command,
timeout: parseInt(timeout, 10) || 30000,
}
: {
type: 'http',
url,
method,
headers: headers.trim() ? JSON.parse(headers) : undefined,
body: body.trim() || undefined,
},
};
onSave(hook);
};
const isValid = actionType === 'shell' ? command.trim().length > 0 : url.trim().length > 0;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{editingHook ? 'Edit Event Hook' : 'Add Event Hook'}</DialogTitle>
<DialogDescription>
Configure an action to run when a specific event occurs.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Name (optional) */}
<div className="space-y-2">
<Label htmlFor="hook-name">Name (optional)</Label>
<Input
id="hook-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="My notification hook"
/>
</div>
{/* Trigger selection */}
<div className="space-y-2">
<Label htmlFor="hook-trigger">Trigger Event</Label>
<Select value={trigger} onValueChange={(v) => setTrigger(v as EventHookTrigger)}>
<SelectTrigger id="hook-trigger">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TRIGGER_OPTIONS.map((t) => (
<SelectItem key={t} value={t}>
{EVENT_HOOK_TRIGGER_LABELS[t]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Action type tabs */}
<div className="space-y-2">
<Label>Action Type</Label>
<Tabs value={actionType} onValueChange={(v) => setActionType(v as ActionType)}>
<TabsList className="w-full">
<TabsTrigger value="shell" className="flex-1 gap-2">
<Terminal className="w-4 h-4" />
Shell Command
</TabsTrigger>
<TabsTrigger value="http" className="flex-1 gap-2">
<Globe className="w-4 h-4" />
HTTP Request
</TabsTrigger>
</TabsList>
{/* Shell command form */}
<TabsContent value="shell" className="space-y-4 mt-4">
<div className="space-y-2">
<Label htmlFor="shell-command">Command</Label>
<Textarea
id="shell-command"
value={command}
onChange={(e) => setCommand(e.target.value)}
placeholder='echo "Feature {{featureId}} completed!"'
className="font-mono text-sm"
rows={3}
/>
<p className="text-xs text-muted-foreground">
Use {'{{variable}}'} syntax for dynamic values
</p>
</div>
<div className="space-y-2">
<Label htmlFor="shell-timeout">Timeout (ms)</Label>
<Input
id="shell-timeout"
type="number"
value={timeout}
onChange={(e) => setTimeout(e.target.value)}
placeholder="30000"
/>
</div>
</TabsContent>
{/* HTTP request form */}
<TabsContent value="http" className="space-y-4 mt-4">
<div className="space-y-2">
<Label htmlFor="http-url">URL</Label>
<Input
id="http-url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://api.example.com/webhook"
/>
</div>
<div className="space-y-2">
<Label htmlFor="http-method">Method</Label>
<Select value={method} onValueChange={(v) => setMethod(v as EventHookHttpMethod)}>
<SelectTrigger id="http-method">
<SelectValue />
</SelectTrigger>
<SelectContent>
{HTTP_METHODS.map((m) => (
<SelectItem key={m} value={m}>
{m}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="http-headers">Headers (JSON, optional)</Label>
<Textarea
id="http-headers"
value={headers}
onChange={(e) => setHeaders(e.target.value)}
placeholder={'{\n "Authorization": "Bearer {{token}}"\n}'}
className="font-mono text-sm"
rows={3}
/>
</div>
<div className="space-y-2">
<Label htmlFor="http-body">Body (JSON, optional)</Label>
<Textarea
id="http-body"
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder={'{\n "feature": "{{featureId}}",\n "status": "{{eventType}}"\n}'}
className="font-mono text-sm"
rows={4}
/>
<p className="text-xs text-muted-foreground">
Leave empty for default body with all event context
</p>
</div>
</TabsContent>
</Tabs>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleSave} disabled={!isValid}>
{editingHook ? 'Save Changes' : 'Add Hook'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,202 @@
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { cn } from '@/lib/utils';
import { Webhook, Plus, Trash2, Pencil, Terminal, Globe } from 'lucide-react';
import { useAppStore } from '@/store/app-store';
import type { EventHook, EventHookTrigger } from '@automaker/types';
import { EVENT_HOOK_TRIGGER_LABELS } from '@automaker/types';
import { EventHookDialog } from './event-hook-dialog';
export function EventHooksSection() {
const { eventHooks, setEventHooks } = useAppStore();
const [dialogOpen, setDialogOpen] = useState(false);
const [editingHook, setEditingHook] = useState<EventHook | null>(null);
const handleAddHook = () => {
setEditingHook(null);
setDialogOpen(true);
};
const handleEditHook = (hook: EventHook) => {
setEditingHook(hook);
setDialogOpen(true);
};
const handleDeleteHook = (hookId: string) => {
setEventHooks(eventHooks.filter((h) => h.id !== hookId));
};
const handleToggleHook = (hookId: string, enabled: boolean) => {
setEventHooks(eventHooks.map((h) => (h.id === hookId ? { ...h, enabled } : h)));
};
const handleSaveHook = (hook: EventHook) => {
if (editingHook) {
// Update existing
setEventHooks(eventHooks.map((h) => (h.id === hook.id ? hook : h)));
} else {
// Add new
setEventHooks([...eventHooks, hook]);
}
setDialogOpen(false);
setEditingHook(null);
};
// Group hooks by trigger type for better organization
const hooksByTrigger = eventHooks.reduce(
(acc, hook) => {
if (!acc[hook.trigger]) {
acc[hook.trigger] = [];
}
acc[hook.trigger].push(hook);
return acc;
},
{} as Record<EventHookTrigger, EventHook[]>
);
return (
<div
className={cn(
'rounded-2xl overflow-hidden',
'border border-border/50',
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
'shadow-sm shadow-black/5'
)}
>
{/* Header */}
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
<Webhook className="w-5 h-5 text-brand-500" />
</div>
<div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">Event Hooks</h2>
<p className="text-sm text-muted-foreground/80">
Run custom commands or webhooks when events occur
</p>
</div>
</div>
<Button onClick={handleAddHook} size="sm" className="gap-2">
<Plus className="w-4 h-4" />
Add Hook
</Button>
</div>
</div>
{/* Content */}
<div className="p-6">
{eventHooks.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<Webhook className="w-12 h-12 mx-auto mb-3 opacity-30" />
<p className="text-sm">No event hooks configured</p>
<p className="text-xs mt-1">
Add hooks to run commands or send webhooks when features complete
</p>
</div>
) : (
<div className="space-y-6">
{/* Group by trigger type */}
{Object.entries(hooksByTrigger).map(([trigger, hooks]) => (
<div key={trigger} className="space-y-3">
<h3 className="text-sm font-medium text-muted-foreground">
{EVENT_HOOK_TRIGGER_LABELS[trigger as EventHookTrigger]}
</h3>
<div className="space-y-2">
{hooks.map((hook) => (
<HookCard
key={hook.id}
hook={hook}
onEdit={() => handleEditHook(hook)}
onDelete={() => handleDeleteHook(hook.id)}
onToggle={(enabled) => handleToggleHook(hook.id, enabled)}
/>
))}
</div>
</div>
))}
</div>
)}
</div>
{/* Variable reference */}
<div className="px-6 pb-6">
<div className="rounded-lg bg-muted/30 p-4 text-xs text-muted-foreground">
<p className="font-medium mb-2">Available variables:</p>
<code className="text-[10px] leading-relaxed">
{'{{featureId}}'} {'{{featureName}}'} {'{{projectPath}}'} {'{{projectName}}'}{' '}
{'{{error}}'} {'{{timestamp}}'} {'{{eventType}}'}
</code>
</div>
</div>
{/* Dialog */}
<EventHookDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
editingHook={editingHook}
onSave={handleSaveHook}
/>
</div>
);
}
interface HookCardProps {
hook: EventHook;
onEdit: () => void;
onDelete: () => void;
onToggle: (enabled: boolean) => void;
}
function HookCard({ hook, onEdit, onDelete, onToggle }: HookCardProps) {
const isShell = hook.action.type === 'shell';
return (
<div
className={cn(
'flex items-center gap-3 p-3 rounded-lg border',
'bg-background/50 hover:bg-background/80 transition-colors',
!hook.enabled && 'opacity-60'
)}
>
{/* Type icon */}
<div
className={cn(
'w-8 h-8 rounded-lg flex items-center justify-center',
isShell ? 'bg-amber-500/10 text-amber-500' : 'bg-blue-500/10 text-blue-500'
)}
>
{isShell ? <Terminal className="w-4 h-4" /> : <Globe className="w-4 h-4" />}
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">
{hook.name || (isShell ? 'Shell Command' : 'HTTP Webhook')}
</p>
<p className="text-xs text-muted-foreground truncate">
{isShell
? (hook.action as { type: 'shell'; command: string }).command
: (hook.action as { type: 'http'; url: string }).url}
</p>
</div>
{/* Actions */}
<div className="flex items-center gap-2">
<Switch checked={hook.enabled} onCheckedChange={onToggle} />
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onEdit}>
<Pencil className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={onDelete}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1 @@
export { EventHooksSection } from './event-hooks-section';

View File

@@ -15,10 +15,12 @@ export type SettingsViewId =
| 'terminal'
| 'keyboard'
| 'audio'
| 'event-hooks'
| 'defaults'
| 'worktrees'
| 'account'
| 'security'
| 'developer'
| 'danger';
interface UseSettingsViewOptions {