import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Rocket, RefreshCcw, CircleStop, Play, ArrowUp, Activity, Loader2, AlertCircle, } from 'lucide-react'; import type { LucideIcon } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { toast } from '@/components/ui/toast-store'; import { apiFetch } from '@/lib/api'; import { formatTimeAgo } from '@/lib/relativeTime'; import type { NotificationItem } from '@/components/dashboard/types'; type ActivityLevel = 'info' | 'warning' | 'error'; const ACTIVITY_LEVELS: readonly string[] = ['info', 'warning', 'error']; interface ActivityEvent { id: number; level: ActivityLevel; category?: string; message: string; timestamp: number; stack_name?: string; actor_username?: string | null; } interface StackActivityTimelineProps { stackName: string; liveEvents?: NotificationItem[]; } const CATEGORY_ICON: Record = { deploy_success: Rocket, stack_restarted: RefreshCcw, stack_stopped: CircleStop, stack_started: Play, image_update_applied: ArrowUp, }; const DAY_MS = 86_400_000; const PAGE_SIZE = 50; const DAY_BUCKET_REFRESH_MS = 60_000; const SYSTEM_ACTOR_LABEL: Record = { 'system': 'System', 'system:autoheal': 'Auto-Heal', 'system:scheduler': 'Scheduler', 'system:image-update': 'Image Update', 'system:docker-events': 'Docker', 'system:blueprint': 'Blueprint', 'system:monitor': 'Monitor', 'system:policy': 'Policy', }; function formatActor(actor: string): { label: string; isSystem: boolean } { const isSystem = actor === 'system' || actor.startsWith('system:'); return { label: SYSTEM_ACTOR_LABEL[actor] ?? actor, isSystem }; } function isActivityEvent(value: unknown): value is ActivityEvent { if (!value || typeof value !== 'object') return false; const v = value as Record; return typeof v.id === 'number' && typeof v.timestamp === 'number' && typeof v.message === 'string' && typeof v.level === 'string' && ACTIVITY_LEVELS.includes(v.level); } function dayLabel(ts: number, now: number): 'Today' | 'Yesterday' | 'Earlier' { const todayStart = new Date(now); todayStart.setHours(0, 0, 0, 0); const todayMs = todayStart.getTime(); if (ts >= todayMs) return 'Today'; if (ts >= todayMs - DAY_MS) return 'Yesterday'; return 'Earlier'; } function groupEvents(events: ActivityEvent[], now: number): { label: string; events: ActivityEvent[] }[] { const groups: Record = {}; const order: string[] = []; for (const e of events) { const label = dayLabel(e.timestamp, now); if (!groups[label]) { groups[label] = []; order.push(label); } groups[label].push(e); } return order.map(label => ({ label, events: groups[label] })); } export function StackActivityTimeline({ stackName, liveEvents }: StackActivityTimelineProps) { const [events, setEvents] = useState([]); const [loading, setLoading] = useState(true); const [loadingMore, setLoadingMore] = useState(false); const [hasMore, setHasMore] = useState(true); const [error, setError] = useState(false); const [reloadKey, setReloadKey] = useState(0); const [now, setNow] = useState(() => Date.now()); const [liveDisconnected, setLiveDisconnected] = useState(false); const seenIdsRef = useRef(new Set()); const mergeEvents = useCallback((incoming: ActivityEvent[]) => { setEvents(prev => { const next = [...prev]; let added = false; for (const e of incoming) { if (seenIdsRef.current.has(e.id)) continue; seenIdsRef.current.add(e.id); next.push(e); added = true; } if (!added) return prev; next.sort((a, b) => b.timestamp - a.timestamp || b.id - a.id); return next; }); }, []); useEffect(() => { let cancelled = false; setLoading(true); setError(false); seenIdsRef.current = new Set(); setEvents([]); setHasMore(true); apiFetch(`/stacks/${stackName}/activity?limit=${PAGE_SIZE + 1}`) .then(async r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json() as Promise<{ events: ActivityEvent[] }>; }) .then(data => { if (cancelled) return; const more = data.events.length > PAGE_SIZE; const trimmed = more ? data.events.slice(0, PAGE_SIZE) : data.events; setHasMore(more); trimmed.forEach(e => seenIdsRef.current.add(e.id)); setEvents(trimmed); }) .catch(() => { if (cancelled) return; setError(true); setHasMore(false); }) .finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; }, [stackName, reloadKey]); useEffect(() => { if (!liveEvents || liveEvents.length === 0) return; const safe = liveEvents.filter(isActivityEvent); if (safe.length === 0) return; mergeEvents(safe); }, [liveEvents, mergeEvents]); useEffect(() => { // Skip the day-bucket refresh while the tab is hidden so a backgrounded // panel does not re-render every minute for no visible effect. const id = window.setInterval(() => { if (typeof document !== 'undefined' && document.hidden) return; setNow(Date.now()); }, DAY_BUCKET_REFRESH_MS); return () => window.clearInterval(id); }, []); useEffect(() => { // Upstream useNotifications dispatches this event when its WebSocket // flips. A single layout-level listener keeps the timeline honest about // whether new events would actually arrive. const handler = (e: Event) => { const detail = (e as CustomEvent<{ connected: boolean }>).detail; setLiveDisconnected(detail?.connected === false); }; window.addEventListener('sencho:notifications-connection', handler); return () => window.removeEventListener('sencho:notifications-connection', handler); }, []); const loadMore = useCallback(async () => { const oldestEvent = events[events.length - 1]; if (!oldestEvent) return; setLoadingMore(true); try { const params = new URLSearchParams({ limit: String(PAGE_SIZE + 1), before: String(oldestEvent.timestamp), beforeId: String(oldestEvent.id), }); const r = await apiFetch(`/stacks/${stackName}/activity?${params.toString()}`); if (!r.ok) { toast.error('Failed to load more activity'); // 5xx is likely server-side; stop offering a button that keeps failing. if (r.status >= 500) setHasMore(false); return; } const data: { events: ActivityEvent[] } = await r.json(); const more = data.events.length > PAGE_SIZE; const trimmed = more ? data.events.slice(0, PAGE_SIZE) : data.events; setHasMore(more); mergeEvents(trimmed); } catch (err) { console.error('[StackActivity] loadMore failed:', err); toast.error('Failed to load more activity'); setHasMore(false); } finally { setLoadingMore(false); } }, [events, stackName, mergeEvents]); const groups = useMemo(() => groupEvents(events, now), [events, now]); if (loading) { return (
); } if (error && events.length === 0) { return (
Activity unavailable
); } if (events.length === 0) { return (
No activity recorded yet
); } return (
{liveDisconnected && (
Live updates offline; reconnecting…
)} {groups.map(g => (
{g.label}
{g.events.map(e => { const Icon = CATEGORY_ICON[e.category ?? ''] ?? Activity; const actor = e.actor_username ? formatActor(e.actor_username) : null; return (
{e.message} {actor && ( {actor.isSystem ? `via ${actor.label}` : `by ${actor.label}`} )}
{formatTimeAgo(e.timestamp)}
); })}
))} {hasMore && ( )}
); }