mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-02 20:43:36 +00:00
This update standardizes the loading indicators by replacing all instances of Loader2 with the new Spinner component. The Spinner component provides a consistent look and feel for loading states throughout the UI, enhancing the user experience. Changes include: - Updated loading indicators in various components such as popovers, modals, and views. - Ensured that the Spinner component is used with appropriate sizes for different contexts. No functional changes were made; this is purely a visual and structural improvement.
347 lines
12 KiB
TypeScript
347 lines
12 KiB
TypeScript
import { useState, useEffect, useCallback } from 'react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Spinner } from '@/components/ui/spinner';
|
|
import { cn } from '@/lib/utils';
|
|
import {
|
|
History,
|
|
RefreshCw,
|
|
Trash2,
|
|
Play,
|
|
ChevronDown,
|
|
ChevronRight,
|
|
CheckCircle,
|
|
XCircle,
|
|
Clock,
|
|
AlertCircle,
|
|
} from 'lucide-react';
|
|
import { useAppStore } from '@/store/app-store';
|
|
import type { StoredEventSummary, StoredEvent, EventHookTrigger } from '@automaker/types';
|
|
import { EVENT_HOOK_TRIGGER_LABELS } from '@automaker/types';
|
|
import { getHttpApiClient } from '@/lib/http-api-client';
|
|
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
|
|
|
export function EventHistoryView() {
|
|
const currentProject = useAppStore((state) => state.currentProject);
|
|
const projectPath = currentProject?.path;
|
|
const [events, setEvents] = useState<StoredEventSummary[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [expandedEvent, setExpandedEvent] = useState<string | null>(null);
|
|
const [expandedEventData, setExpandedEventData] = useState<StoredEvent | null>(null);
|
|
const [replayingEvent, setReplayingEvent] = useState<string | null>(null);
|
|
const [clearDialogOpen, setClearDialogOpen] = useState(false);
|
|
|
|
const loadEvents = useCallback(async () => {
|
|
if (!projectPath) return;
|
|
|
|
setLoading(true);
|
|
try {
|
|
const api = getHttpApiClient();
|
|
const result = await api.eventHistory.list(projectPath, { limit: 100 });
|
|
if (result.success && result.events) {
|
|
setEvents(result.events);
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load events:', error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [projectPath]);
|
|
|
|
useEffect(() => {
|
|
loadEvents();
|
|
}, [loadEvents]);
|
|
|
|
const handleExpand = async (eventId: string) => {
|
|
if (expandedEvent === eventId) {
|
|
setExpandedEvent(null);
|
|
setExpandedEventData(null);
|
|
return;
|
|
}
|
|
|
|
if (!projectPath) return;
|
|
|
|
setExpandedEvent(eventId);
|
|
try {
|
|
const api = getHttpApiClient();
|
|
const result = await api.eventHistory.get(projectPath, eventId);
|
|
if (result.success && result.event) {
|
|
setExpandedEventData(result.event);
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load event details:', error);
|
|
}
|
|
};
|
|
|
|
const handleReplay = async (eventId: string) => {
|
|
if (!projectPath) return;
|
|
|
|
setReplayingEvent(eventId);
|
|
try {
|
|
const api = getHttpApiClient();
|
|
const result = await api.eventHistory.replay(projectPath, eventId);
|
|
if (result.success && result.result) {
|
|
const { hooksTriggered, hookResults } = result.result;
|
|
const successCount = hookResults.filter((r) => r.success).length;
|
|
const failCount = hookResults.filter((r) => !r.success).length;
|
|
|
|
if (hooksTriggered === 0) {
|
|
alert('No matching hooks found for this event trigger.');
|
|
} else if (failCount === 0) {
|
|
alert(`Successfully ran ${successCount} hook(s).`);
|
|
} else {
|
|
alert(`Ran ${hooksTriggered} hook(s): ${successCount} succeeded, ${failCount} failed.`);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to replay event:', error);
|
|
alert('Failed to replay event. Check console for details.');
|
|
} finally {
|
|
setReplayingEvent(null);
|
|
}
|
|
};
|
|
|
|
const handleDelete = async (eventId: string) => {
|
|
if (!projectPath) return;
|
|
|
|
try {
|
|
const api = getHttpApiClient();
|
|
const result = await api.eventHistory.delete(projectPath, eventId);
|
|
if (result.success) {
|
|
setEvents((prev) => prev.filter((e) => e.id !== eventId));
|
|
if (expandedEvent === eventId) {
|
|
setExpandedEvent(null);
|
|
setExpandedEventData(null);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to delete event:', error);
|
|
}
|
|
};
|
|
|
|
const handleClearAll = async () => {
|
|
if (!projectPath) return;
|
|
|
|
try {
|
|
const api = getHttpApiClient();
|
|
const result = await api.eventHistory.clear(projectPath);
|
|
if (result.success) {
|
|
setEvents([]);
|
|
setExpandedEvent(null);
|
|
setExpandedEventData(null);
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to clear events:', error);
|
|
}
|
|
setClearDialogOpen(false);
|
|
};
|
|
|
|
const getTriggerIcon = (trigger: EventHookTrigger) => {
|
|
switch (trigger) {
|
|
case 'feature_created':
|
|
return <Clock className="w-4 h-4 text-blue-500" />;
|
|
case 'feature_success':
|
|
return <CheckCircle className="w-4 h-4 text-green-500" />;
|
|
case 'feature_error':
|
|
return <XCircle className="w-4 h-4 text-red-500" />;
|
|
case 'auto_mode_complete':
|
|
return <CheckCircle className="w-4 h-4 text-purple-500" />;
|
|
case 'auto_mode_error':
|
|
return <AlertCircle className="w-4 h-4 text-orange-500" />;
|
|
default:
|
|
return <History className="w-4 h-4 text-muted-foreground" />;
|
|
}
|
|
};
|
|
|
|
const formatTimestamp = (timestamp: string) => {
|
|
const date = new Date(timestamp);
|
|
const now = new Date();
|
|
const diffMs = now.getTime() - date.getTime();
|
|
const diffMins = Math.floor(diffMs / 60000);
|
|
const diffHours = Math.floor(diffMs / 3600000);
|
|
const diffDays = Math.floor(diffMs / 86400000);
|
|
|
|
if (diffMins < 1) return 'Just now';
|
|
if (diffMins < 60) return `${diffMins}m ago`;
|
|
if (diffHours < 24) return `${diffHours}h ago`;
|
|
if (diffDays < 7) return `${diffDays}d ago`;
|
|
return date.toLocaleDateString();
|
|
};
|
|
|
|
if (!projectPath) {
|
|
return (
|
|
<div className="text-center py-8 text-muted-foreground">
|
|
<History className="w-12 h-12 mx-auto mb-3 opacity-30" />
|
|
<p className="text-sm">Select a project to view event history</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Header with actions */}
|
|
<div className="flex items-center justify-between">
|
|
<p className="text-sm text-muted-foreground">
|
|
{events.length} event{events.length !== 1 ? 's' : ''} recorded
|
|
</p>
|
|
<div className="flex items-center gap-2">
|
|
<Button variant="outline" size="sm" onClick={loadEvents} disabled={loading}>
|
|
{loading ? (
|
|
<Spinner size="sm" className="mr-2" />
|
|
) : (
|
|
<RefreshCw className="w-4 h-4 mr-2" />
|
|
)}
|
|
Refresh
|
|
</Button>
|
|
{events.length > 0 && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="text-destructive hover:text-destructive"
|
|
onClick={() => setClearDialogOpen(true)}
|
|
>
|
|
<Trash2 className="w-4 h-4 mr-2" />
|
|
Clear All
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Events list */}
|
|
{events.length === 0 ? (
|
|
<div className="text-center py-8 text-muted-foreground">
|
|
<History className="w-12 h-12 mx-auto mb-3 opacity-30" />
|
|
<p className="text-sm">No events recorded yet</p>
|
|
<p className="text-xs mt-1">
|
|
Events will appear here when features are created or completed
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{events.map((event) => (
|
|
<div
|
|
key={event.id}
|
|
className={cn(
|
|
'rounded-lg border bg-background/50',
|
|
expandedEvent === event.id && 'ring-1 ring-brand-500/30'
|
|
)}
|
|
>
|
|
{/* Event header */}
|
|
<div
|
|
className="flex items-center gap-3 p-3 cursor-pointer hover:bg-muted/30 transition-colors"
|
|
onClick={() => handleExpand(event.id)}
|
|
>
|
|
<button className="p-0.5">
|
|
{expandedEvent === event.id ? (
|
|
<ChevronDown className="w-4 h-4 text-muted-foreground" />
|
|
) : (
|
|
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
|
)}
|
|
</button>
|
|
|
|
{getTriggerIcon(event.trigger)}
|
|
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-medium truncate">
|
|
{EVENT_HOOK_TRIGGER_LABELS[event.trigger]}
|
|
</p>
|
|
{event.featureName && (
|
|
<p className="text-xs text-muted-foreground truncate">{event.featureName}</p>
|
|
)}
|
|
</div>
|
|
|
|
<span className="text-xs text-muted-foreground">
|
|
{formatTimestamp(event.timestamp)}
|
|
</span>
|
|
|
|
{/* Actions */}
|
|
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7"
|
|
onClick={() => handleReplay(event.id)}
|
|
disabled={replayingEvent === event.id}
|
|
title="Replay event (trigger matching hooks)"
|
|
>
|
|
<Play
|
|
className={cn('w-3.5 h-3.5', replayingEvent === event.id && 'animate-pulse')}
|
|
/>
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7 text-destructive hover:text-destructive"
|
|
onClick={() => handleDelete(event.id)}
|
|
title="Delete event"
|
|
>
|
|
<Trash2 className="w-3.5 h-3.5" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Expanded details */}
|
|
{expandedEvent === event.id && expandedEventData && (
|
|
<div className="px-4 pb-4 pt-0 border-t border-border/50">
|
|
<div className="mt-3 space-y-2 text-xs">
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<div>
|
|
<span className="text-muted-foreground">Event ID:</span>
|
|
<p className="font-mono text-[10px] truncate">{expandedEventData.id}</p>
|
|
</div>
|
|
<div>
|
|
<span className="text-muted-foreground">Timestamp:</span>
|
|
<p>{new Date(expandedEventData.timestamp).toLocaleString()}</p>
|
|
</div>
|
|
{expandedEventData.featureId && (
|
|
<div>
|
|
<span className="text-muted-foreground">Feature ID:</span>
|
|
<p className="font-mono text-[10px] truncate">
|
|
{expandedEventData.featureId}
|
|
</p>
|
|
</div>
|
|
)}
|
|
{expandedEventData.passes !== undefined && (
|
|
<div>
|
|
<span className="text-muted-foreground">Passed:</span>
|
|
<p>{expandedEventData.passes ? 'Yes' : 'No'}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
{expandedEventData.error && (
|
|
<div>
|
|
<span className="text-muted-foreground">Error:</span>
|
|
<p className="text-red-400 mt-1 p-2 bg-red-500/10 rounded text-[10px] font-mono whitespace-pre-wrap">
|
|
{expandedEventData.error}
|
|
</p>
|
|
</div>
|
|
)}
|
|
<div>
|
|
<span className="text-muted-foreground">Project:</span>
|
|
<p className="font-mono text-[10px] truncate">
|
|
{expandedEventData.projectPath}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Clear confirmation dialog */}
|
|
<ConfirmDialog
|
|
open={clearDialogOpen}
|
|
onOpenChange={setClearDialogOpen}
|
|
onConfirm={handleClearAll}
|
|
title="Clear Event History"
|
|
description={`This will permanently delete all ${events.length} recorded events. This action cannot be undone.`}
|
|
icon={Trash2}
|
|
iconClassName="text-destructive"
|
|
confirmText="Clear All"
|
|
confirmVariant="destructive"
|
|
/>
|
|
</div>
|
|
);
|
|
}
|