Improve auto-loop event emission and add ntfy notifications (#821)

This commit is contained in:
gsxdsm
2026-03-01 00:12:22 -08:00
parent 2d5f76a336
commit 76499224dc
53 changed files with 4620 additions and 255 deletions

View File

@@ -19,16 +19,19 @@ import {
SelectValue,
} from '@/components/ui/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Terminal, Globe } from 'lucide-react';
import { Terminal, Globe, Bell } from 'lucide-react';
import type {
EventHook,
EventHookTrigger,
EventHookHttpMethod,
EventHookShellAction,
EventHookHttpAction,
EventHookNtfyAction,
} from '@automaker/types';
import { EVENT_HOOK_TRIGGER_LABELS } from '@automaker/types';
import { generateUUID } from '@/lib/utils';
import { useAppStore } from '@/store/app-store';
import { toast } from 'sonner';
interface EventHookDialogProps {
open: boolean;
@@ -37,7 +40,7 @@ interface EventHookDialogProps {
onSave: (hook: EventHook) => void;
}
type ActionType = 'shell' | 'http';
type ActionType = 'shell' | 'http' | 'ntfy';
const TRIGGER_OPTIONS: EventHookTrigger[] = [
'feature_created',
@@ -49,7 +52,17 @@ const TRIGGER_OPTIONS: EventHookTrigger[] = [
const HTTP_METHODS: EventHookHttpMethod[] = ['POST', 'GET', 'PUT', 'PATCH'];
const PRIORITY_OPTIONS = [
{ value: 1, label: 'Min (no sound/vibration)' },
{ value: 2, label: 'Low' },
{ value: 3, label: 'Default' },
{ value: 4, label: 'High' },
{ value: 5, label: 'Urgent (max)' },
];
export function EventHookDialog({ open, onOpenChange, editingHook, onSave }: EventHookDialogProps) {
const ntfyEndpoints = useAppStore((state) => state.ntfyEndpoints);
// Form state
const [name, setName] = useState('');
const [trigger, setTrigger] = useState<EventHookTrigger>('feature_success');
@@ -65,6 +78,15 @@ export function EventHookDialog({ open, onOpenChange, editingHook, onSave }: Eve
const [headers, setHeaders] = useState('');
const [body, setBody] = useState('');
// Ntfy action state
const [ntfyEndpointId, setNtfyEndpointId] = useState('');
const [ntfyTitle, setNtfyTitle] = useState('');
const [ntfyBody, setNtfyBody] = useState('');
const [ntfyTags, setNtfyTags] = useState('');
const [ntfyEmoji, setNtfyEmoji] = useState('');
const [ntfyClickUrl, setNtfyClickUrl] = useState('');
const [ntfyPriority, setNtfyPriority] = useState<1 | 2 | 3 | 4 | 5>(3);
// Reset form when dialog opens/closes or editingHook changes
useEffect(() => {
if (open) {
@@ -72,68 +94,131 @@ export function EventHookDialog({ open, onOpenChange, editingHook, onSave }: Eve
// Populate form with existing hook data
setName(editingHook.name || '');
setTrigger(editingHook.trigger);
setActionType(editingHook.action.type);
setActionType(editingHook.action.type as ActionType);
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 {
// Reset other fields
resetHttpFields();
resetNtfyFields();
} else if (editingHook.action.type === 'http') {
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');
// Reset other fields
resetShellFields();
resetNtfyFields();
} else if (editingHook.action.type === 'ntfy') {
const ntfyAction = editingHook.action as EventHookNtfyAction;
setNtfyEndpointId(ntfyAction.endpointId);
setNtfyTitle(ntfyAction.title || '');
setNtfyBody(ntfyAction.body || '');
setNtfyTags(ntfyAction.tags || '');
setNtfyEmoji(ntfyAction.emoji || '');
setNtfyClickUrl(ntfyAction.clickUrl || '');
setNtfyPriority(ntfyAction.priority || 3);
// Reset other fields
resetShellFields();
resetHttpFields();
}
} else {
// Reset to defaults for new hook
setName('');
setTrigger('feature_success');
setActionType('shell');
setCommand('');
setTimeout('30000');
setUrl('');
setMethod('POST');
setHeaders('');
setBody('');
resetShellFields();
resetHttpFields();
resetNtfyFields();
}
}
}, [open, editingHook]);
const resetShellFields = () => {
setCommand('');
setTimeout('30000');
};
const resetHttpFields = () => {
setUrl('');
setMethod('POST');
setHeaders('');
setBody('');
};
const resetNtfyFields = () => {
setNtfyEndpointId('');
setNtfyTitle('');
setNtfyBody('');
setNtfyTags('');
setNtfyEmoji('');
setNtfyClickUrl('');
setNtfyPriority(3);
};
const handleSave = () => {
let action: EventHook['action'];
if (actionType === 'shell') {
action = {
type: 'shell',
command,
timeout: parseInt(timeout, 10) || 30000,
};
} else if (actionType === 'http') {
// Parse headers JSON with error handling
let parsedHeaders: Record<string, string> | undefined;
if (headers.trim()) {
try {
parsedHeaders = JSON.parse(headers);
} catch {
// If JSON is invalid, show error and don't save
toast.error('Invalid JSON in Headers field');
return;
}
}
action = {
type: 'http',
url,
method,
headers: parsedHeaders,
body: body.trim() || undefined,
};
} else {
action = {
type: 'ntfy',
endpointId: ntfyEndpointId,
title: ntfyTitle.trim() || undefined,
body: ntfyBody.trim() || undefined,
tags: ntfyTags.trim() || undefined,
emoji: ntfyEmoji.trim() || undefined,
clickUrl: ntfyClickUrl.trim() || undefined,
priority: ntfyPriority,
};
}
const hook: EventHook = {
id: editingHook?.id || generateUUID(),
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,
},
action,
};
onSave(hook);
};
const isValid = actionType === 'shell' ? command.trim().length > 0 : url.trim().length > 0;
const selectedEndpoint = ntfyEndpoints.find((e) => e.id === ntfyEndpointId);
const isValid = (() => {
if (actionType === 'shell') return command.trim().length > 0;
if (actionType === 'http') return url.trim().length > 0;
if (actionType === 'ntfy') return Boolean(selectedEndpoint);
return false;
})();
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -179,13 +264,17 @@ export function EventHookDialog({ open, onOpenChange, editingHook, onSave }: Eve
<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">
<TabsTrigger value="shell" className="flex-1 gap-1">
<Terminal className="w-4 h-4" />
Shell Command
<span className="sr-only sm:inline">Shell</span>
</TabsTrigger>
<TabsTrigger value="http" className="flex-1 gap-2">
<TabsTrigger value="http" className="flex-1 gap-1">
<Globe className="w-4 h-4" />
HTTP Request
<span className="sr-only sm:inline">HTTP</span>
</TabsTrigger>
<TabsTrigger value="ntfy" className="flex-1 gap-1">
<Bell className="w-4 h-4" />
<span className="sr-only sm:inline">Ntfy</span>
</TabsTrigger>
</TabsList>
@@ -273,6 +362,139 @@ export function EventHookDialog({ open, onOpenChange, editingHook, onSave }: Eve
</p>
</div>
</TabsContent>
{/* Ntfy notification form */}
<TabsContent value="ntfy" className="space-y-4 mt-4">
{ntfyEndpoints.length === 0 ? (
<div className="rounded-lg bg-muted/50 p-4 text-center">
<Bell className="w-8 h-8 mx-auto mb-2 text-muted-foreground opacity-50" />
<p className="text-sm text-muted-foreground">No ntfy endpoints configured.</p>
<p className="text-xs text-muted-foreground mt-1">
Add an endpoint in the "Endpoints" tab first.
</p>
</div>
) : (
<>
<div className="space-y-2">
<Label htmlFor="ntfy-endpoint">Endpoint *</Label>
<Select value={ntfyEndpointId} onValueChange={setNtfyEndpointId}>
<SelectTrigger id="ntfy-endpoint">
<SelectValue placeholder="Select an endpoint" />
</SelectTrigger>
<SelectContent>
{ntfyEndpoints
.filter((e) => e.enabled)
.map((endpoint) => (
<SelectItem key={endpoint.id} value={endpoint.id}>
{endpoint.name} ({endpoint.topic})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{selectedEndpoint && (
<div className="rounded-lg bg-muted/30 p-3 text-xs text-muted-foreground">
<p>
<strong>Server:</strong> {selectedEndpoint.serverUrl}
</p>
{selectedEndpoint.defaultTags && (
<p>
<strong>Default Tags:</strong> {selectedEndpoint.defaultTags}
</p>
)}
{selectedEndpoint.defaultEmoji && (
<p>
<strong>Default Emoji:</strong> {selectedEndpoint.defaultEmoji}
</p>
)}
</div>
)}
<div className="space-y-2">
<Label htmlFor="ntfy-title">Title (optional)</Label>
<Input
id="ntfy-title"
value={ntfyTitle}
onChange={(e) => setNtfyTitle(e.target.value)}
placeholder="Feature {{featureName}} completed"
/>
<p className="text-xs text-muted-foreground">
Defaults to event name. Use {'{{variable}}'} for dynamic values.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="ntfy-body">Message (optional)</Label>
<Textarea
id="ntfy-body"
value={ntfyBody}
onChange={(e) => setNtfyBody(e.target.value)}
placeholder="Feature {{featureId}} completed at {{timestamp}}"
className="font-mono text-sm"
rows={3}
/>
<p className="text-xs text-muted-foreground">
Defaults to event details. Leave empty for auto-generated message.
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="ntfy-tags">Tags (optional)</Label>
<Input
id="ntfy-tags"
value={ntfyTags}
onChange={(e) => setNtfyTags(e.target.value)}
placeholder="warning,skull"
/>
</div>
<div className="space-y-2">
<Label htmlFor="ntfy-emoji">Emoji</Label>
<Input
id="ntfy-emoji"
value={ntfyEmoji}
onChange={(e) => setNtfyEmoji(e.target.value)}
placeholder="tada"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="ntfy-click">Click URL (optional)</Label>
<Input
id="ntfy-click"
value={ntfyClickUrl}
onChange={(e) => setNtfyClickUrl(e.target.value)}
placeholder="https://example.com"
/>
<p className="text-xs text-muted-foreground">
URL to open when notification is clicked. Defaults to endpoint setting.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="ntfy-priority">Priority</Label>
<Select
value={String(ntfyPriority)}
onValueChange={(v) => setNtfyPriority(Number(v) as 1 | 2 | 3 | 4 | 5)}
>
<SelectTrigger id="ntfy-priority">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PRIORITY_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={String(opt.value)}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</>
)}
</TabsContent>
</Tabs>
</div>
</div>