mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-24 12:23:07 +00:00
Improve auto-loop event emission and add ntfy notifications (#821)
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user