diff --git a/apps/web/app/api/dashboard/activity/route.ts b/apps/web/app/api/dashboard/activity/route.ts index c8e2547b..8f3c1025 100644 --- a/apps/web/app/api/dashboard/activity/route.ts +++ b/apps/web/app/api/dashboard/activity/route.ts @@ -12,18 +12,21 @@ export async function GET(request: NextRequest) { const searchParams = request.nextUrl.searchParams; const limit = parseInt(searchParams.get('limit') || '20'); - // Get all projects (for now, using projectId 1 as default) - // TODO: Query across all user's projects - const projectId = 1; + // Support optional projectId parameter + const projectIdParam = searchParams.get('projectId'); + const projectId = projectIdParam ? parseInt(projectIdParam) : undefined; const eventService = AgentEventService.getInstance(projectId); await eventService.initialize(); + // Build event filter + const eventFilter: any = { limit }; + if (projectId !== undefined) { + eventFilter.projectId = projectId; + } + // Get recent events - const events = await eventService.getEvents({ - projectId, - limit, - }); + const events = await eventService.getEvents(eventFilter); return NextResponse.json({ success: true, diff --git a/apps/web/app/api/dashboard/stats/route.ts b/apps/web/app/api/dashboard/stats/route.ts index fe4b2d2e..84f4f3da 100644 --- a/apps/web/app/api/dashboard/stats/route.ts +++ b/apps/web/app/api/dashboard/stats/route.ts @@ -13,9 +13,12 @@ import { AgentSessionService, AgentEventService } from '@codervisor/devlog-core/ export async function GET(request: NextRequest) { try { - // Get all projects (for now, using projectId 1 as default) - // TODO: Query across all user's projects - const projectId = 1; + const searchParams = request.nextUrl.searchParams; + + // Support optional projectId parameter + // If not provided, query across all projects (pass undefined) + const projectIdParam = searchParams.get('projectId'); + const projectId = projectIdParam ? parseInt(projectIdParam) : undefined; const sessionService = AgentSessionService.getInstance(projectId); const eventService = AgentEventService.getInstance(projectId); @@ -34,28 +37,41 @@ export async function GET(request: NextRequest) { const tomorrow = new Date(today); tomorrow.setDate(tomorrow.getDate() + 1); - // Get events from today - const todayEvents = await eventService.getEvents({ - projectId, + // Build event filter + const eventFilter: any = { startTime: today, endTime: tomorrow, - }); + }; + if (projectId !== undefined) { + eventFilter.projectId = projectId; + } + + // Get events from today + const todayEvents = await eventService.getEvents(eventFilter); // Calculate events per minute (based on last hour) const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000); - const recentEvents = await eventService.getEvents({ - projectId, + const recentFilter: any = { startTime: oneHourAgo, - }); + }; + if (projectId !== undefined) { + recentFilter.projectId = projectId; + } + const recentEvents = await eventService.getEvents(recentFilter); const eventsPerMinute = recentEvents.length > 0 ? (recentEvents.length / 60).toFixed(2) : '0'; - // Get session stats for average duration - const sessionStats = await sessionService.getSessionStats({ - projectId, + // Build session stats filter + const statsFilter: any = { startTimeFrom: today, - }); + }; + if (projectId !== undefined) { + statsFilter.projectId = projectId; + } + + // Get session stats for average duration + const sessionStats = await sessionService.getSessionStats(statsFilter); return NextResponse.json({ success: true, diff --git a/apps/web/app/api/events/stream/route.ts b/apps/web/app/api/events/stream/route.ts new file mode 100644 index 00000000..d94dfe88 --- /dev/null +++ b/apps/web/app/api/events/stream/route.ts @@ -0,0 +1,72 @@ +/** + * Server-Sent Events (SSE) endpoint for real-time updates + * + * Provides a persistent connection that streams updates about: + * - New agent sessions + * - Session status changes + * - New agent events + * - Dashboard metrics updates + */ + +import { NextRequest } from 'next/server'; +import { EventBroadcaster } from '@/lib/realtime/event-broadcaster'; + +// Mark this route as dynamic to prevent static generation +export const dynamic = 'force-dynamic'; +export const runtime = 'nodejs'; + +// Keep-alive interval in milliseconds +const KEEP_ALIVE_INTERVAL = 30000; // 30 seconds + +export async function GET(request: NextRequest) { + const broadcaster = EventBroadcaster.getInstance(); + + // Create a readable stream for SSE + const stream = new ReadableStream({ + start(controller) { + const encoder = new TextEncoder(); + + // Send initial connection message + const connectionMessage = `event: connected\ndata: ${JSON.stringify({ + timestamp: new Date().toISOString(), + clientCount: broadcaster.getClientCount() + 1 + })}\n\n`; + controller.enqueue(encoder.encode(connectionMessage)); + + // Add this client to the broadcaster + broadcaster.addClient(controller); + + // Set up keep-alive heartbeat + const keepAliveInterval = setInterval(() => { + try { + const heartbeat = `: heartbeat ${Date.now()}\n\n`; + controller.enqueue(encoder.encode(heartbeat)); + } catch (error) { + console.error('Error sending heartbeat:', error); + clearInterval(keepAliveInterval); + broadcaster.removeClient(controller); + } + }, KEEP_ALIVE_INTERVAL); + + // Clean up when client disconnects + request.signal.addEventListener('abort', () => { + clearInterval(keepAliveInterval); + broadcaster.removeClient(controller); + try { + controller.close(); + } catch (error) { + // Controller already closed + } + }); + }, + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache, no-transform', + 'Connection': 'keep-alive', + 'X-Accel-Buffering': 'no', // Disable nginx buffering + }, + }); +} diff --git a/apps/web/app/api/sessions/[id]/events/route.ts b/apps/web/app/api/sessions/[id]/events/route.ts new file mode 100644 index 00000000..d51dfc3d --- /dev/null +++ b/apps/web/app/api/sessions/[id]/events/route.ts @@ -0,0 +1,64 @@ +/** + * API endpoint for session events timeline + * + * Returns all events associated with a specific session + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { AgentEventService } from '@codervisor/devlog-core/server'; + +export async function GET( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const { id: sessionId } = params; + const searchParams = request.nextUrl.searchParams; + + // Parse query parameters + const eventType = searchParams.get('eventType') || undefined; + const severity = searchParams.get('severity') || undefined; + const limit = parseInt(searchParams.get('limit') || '100'); + const offset = parseInt(searchParams.get('offset') || '0'); + + // Get event service + const eventService = AgentEventService.getInstance(); + await eventService.initialize(); + + // Get events for this session + const allEvents = await eventService.getEventsBySession(sessionId); + + // Apply additional filters + let filteredEvents = allEvents; + + if (eventType) { + filteredEvents = filteredEvents.filter(e => e.type === eventType); + } + + if (severity) { + filteredEvents = filteredEvents.filter(e => e.severity === severity); + } + + // Apply pagination + const paginatedEvents = filteredEvents.slice(offset, offset + limit); + + return NextResponse.json({ + success: true, + data: paginatedEvents, + pagination: { + limit, + offset, + total: filteredEvents.length, + }, + }); + } catch (error) { + console.error('Error fetching session events:', error); + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : 'Failed to fetch session events', + }, + { status: 500 } + ); + } +} diff --git a/apps/web/app/api/sessions/[id]/route.ts b/apps/web/app/api/sessions/[id]/route.ts new file mode 100644 index 00000000..8a57bdcd --- /dev/null +++ b/apps/web/app/api/sessions/[id]/route.ts @@ -0,0 +1,48 @@ +/** + * API endpoint for individual session details + * + * Returns complete session information including context, metrics, and outcome + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { AgentSessionService } from '@codervisor/devlog-core/server'; + +export async function GET( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const { id } = params; + + // Get session service (projectId not required for getSession) + const sessionService = AgentSessionService.getInstance(); + await sessionService.initialize(); + + // Get session by ID + const session = await sessionService.getSession(id); + + if (!session) { + return NextResponse.json( + { + success: false, + error: `Session not found: ${id}`, + }, + { status: 404 } + ); + } + + return NextResponse.json({ + success: true, + data: session, + }); + } catch (error) { + console.error('Error fetching session details:', error); + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : 'Failed to fetch session details', + }, + { status: 500 } + ); + } +} diff --git a/apps/web/app/api/sessions/route.ts b/apps/web/app/api/sessions/route.ts index 59a824b6..2d8e9db6 100644 --- a/apps/web/app/api/sessions/route.ts +++ b/apps/web/app/api/sessions/route.ts @@ -20,15 +20,18 @@ export async function GET(request: NextRequest) { const limit = parseInt(searchParams.get('limit') || '50'); const offset = parseInt(searchParams.get('offset') || '0'); - // Get all projects (for now, using projectId 1 as default) - // TODO: Query across all user's projects - const projectId = 1; + // Support optional projectId parameter + const projectIdParam = searchParams.get('projectId'); + const projectId = projectIdParam ? parseInt(projectIdParam) : undefined; const sessionService = AgentSessionService.getInstance(projectId); await sessionService.initialize(); // Build filter - const filter: any = { projectId, limit, offset }; + const filter: any = { limit, offset }; + if (projectId !== undefined) { + filter.projectId = projectId; + } if (agentId) filter.agentId = agentId; if (outcome) filter.outcome = outcome; if (startTimeFrom) filter.startTimeFrom = new Date(startTimeFrom); diff --git a/apps/web/app/dashboard/page.tsx b/apps/web/app/dashboard/page.tsx index 3c8a5e5e..76cbece7 100644 --- a/apps/web/app/dashboard/page.tsx +++ b/apps/web/app/dashboard/page.tsx @@ -6,12 +6,17 @@ import { Suspense } from 'react'; import { Skeleton } from '@/components/ui/skeleton'; -import { DashboardStats, RecentActivity, ActiveSessions } from '@/components/agent-observability/dashboard'; +import { DashboardStatsWrapper, RecentActivity, ActiveSessions } from '@/components/agent-observability/dashboard'; +import { ProjectSelector } from '@/components/agent-observability/project-selector'; -export default function DashboardPage() { +interface DashboardPageProps { + searchParams?: { [key: string]: string | string[] | undefined }; +} + +export default function DashboardPage({ searchParams }: DashboardPageProps) { return (
- {/* Header */} + {/* Header with Project Selector */}

Agent Activity Dashboard

@@ -19,21 +24,22 @@ export default function DashboardPage() { Monitor AI coding agents in real-time across all your projects

+
- {/* Overview Stats */} + {/* Overview Stats with Live Updates */} }> - + {/* Recent Activity */} }> - + {/* Active Sessions */} }> - +
); diff --git a/apps/web/app/sessions/[id]/page.tsx b/apps/web/app/sessions/[id]/page.tsx new file mode 100644 index 00000000..810c6e0a --- /dev/null +++ b/apps/web/app/sessions/[id]/page.tsx @@ -0,0 +1,101 @@ +/** + * Session Details Page + * + * Displays complete information about a specific agent session including + * metrics, timeline, and full event history + */ + +import { Suspense } from 'react'; +import { notFound } from 'next/navigation'; +import Link from 'next/link'; +import { ArrowLeft } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Skeleton } from '@/components/ui/skeleton'; +import { SessionHeader, SessionMetrics, EventTimeline } from '@/components/agent-observability/session-details'; +import type { AgentSession, AgentEvent } from '@codervisor/devlog-core'; + +interface SessionDetailsPageProps { + params: { id: string }; +} + +async function fetchSession(id: string): Promise { + try { + const baseUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3200'; + const response = await fetch(`${baseUrl}/api/sessions/${id}`, { + cache: 'no-store', + }); + + if (!response.ok) { + return null; + } + + const result = await response.json(); + return result.success ? result.data : null; + } catch (error) { + console.error('Error fetching session:', error); + return null; + } +} + +async function fetchSessionEvents(id: string): Promise { + try { + const baseUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3200'; + const response = await fetch(`${baseUrl}/api/sessions/${id}/events`, { + cache: 'no-store', + }); + + if (!response.ok) { + return []; + } + + const result = await response.json(); + return result.success ? result.data : []; + } catch (error) { + console.error('Error fetching session events:', error); + return []; + } +} + +export default async function SessionDetailsPage({ params }: SessionDetailsPageProps) { + const { id } = params; + + // Fetch session and events in parallel + const [session, events] = await Promise.all([ + fetchSession(id), + fetchSessionEvents(id), + ]); + + // If session not found, show 404 + if (!session) { + notFound(); + } + + return ( +
+ {/* Back Navigation */} +
+ + + +
+ + {/* Session Header */} + }> + + + + {/* Session Metrics */} + }> + + + + {/* Event Timeline */} + }> + + +
+ ); +} diff --git a/apps/web/app/sessions/page.tsx b/apps/web/app/sessions/page.tsx index d54210bb..119a8ad7 100644 --- a/apps/web/app/sessions/page.tsx +++ b/apps/web/app/sessions/page.tsx @@ -7,11 +7,16 @@ import { Suspense } from 'react'; import { Skeleton } from '@/components/ui/skeleton'; import { SessionsList } from '@/components/agent-observability/sessions'; +import { ProjectSelector } from '@/components/agent-observability/project-selector'; -export default function SessionsPage() { +interface SessionsPageProps { + searchParams?: { [key: string]: string | string[] | undefined }; +} + +export default function SessionsPage({ searchParams }: SessionsPageProps) { return (
- {/* Header */} + {/* Header with Project Selector */}

Agent Sessions

@@ -19,16 +24,17 @@ export default function SessionsPage() { View and manage AI coding agent sessions across all projects

+
{/* Active Sessions */} }> - + {/* Recent Sessions */} }> - +
); diff --git a/apps/web/components/agent-observability/dashboard/active-sessions.tsx b/apps/web/components/agent-observability/dashboard/active-sessions.tsx index 384625fb..eed4066e 100644 --- a/apps/web/components/agent-observability/dashboard/active-sessions.tsx +++ b/apps/web/components/agent-observability/dashboard/active-sessions.tsx @@ -16,10 +16,21 @@ interface AgentSession { outcome?: string; } -async function fetchActiveSessions(): Promise { +interface ActiveSessionsProps { + searchParams?: { [key: string]: string | string[] | undefined }; +} + +async function fetchActiveSessions(projectId?: string): Promise { try { const baseUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3200'; - const response = await fetch(`${baseUrl}/api/sessions?status=active`, { + const url = new URL(`${baseUrl}/api/sessions`); + url.searchParams.set('status', 'active'); + + if (projectId) { + url.searchParams.set('projectId', projectId); + } + + const response = await fetch(url.toString(), { cache: 'no-store', }); @@ -47,8 +58,9 @@ function formatDuration(startTime: string): string { return `${diffHours}h ${diffMins % 60}m`; } -export async function ActiveSessions() { - const sessions = await fetchActiveSessions(); +export async function ActiveSessions({ searchParams }: ActiveSessionsProps) { + const projectId = searchParams?.projectId as string | undefined; + const sessions = await fetchActiveSessions(projectId); if (sessions.length === 0) { return ( diff --git a/apps/web/components/agent-observability/dashboard/dashboard-stats-wrapper.tsx b/apps/web/components/agent-observability/dashboard/dashboard-stats-wrapper.tsx new file mode 100644 index 00000000..58279746 --- /dev/null +++ b/apps/web/components/agent-observability/dashboard/dashboard-stats-wrapper.tsx @@ -0,0 +1,61 @@ +/** + * Dashboard Stats Wrapper + * + * Server component that fetches initial data and passes to client component for live updates + */ + +import { LiveDashboardStats } from './live-dashboard-stats'; + +interface DashboardStats { + activeSessions: number; + totalEventsToday: number; + averageDuration: number; + eventsPerMinute: number; +} + +interface DashboardStatsWrapperProps { + searchParams?: { [key: string]: string | string[] | undefined }; +} + +async function fetchDashboardStats(projectId?: string): Promise { + try { + // Use absolute URL for server-side fetch + const baseUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3200'; + const url = new URL(`${baseUrl}/api/dashboard/stats`); + + // Add projectId if provided + if (projectId) { + url.searchParams.set('projectId', projectId); + } + + const response = await fetch(url.toString(), { + cache: 'no-store', // Always fetch fresh data + }); + + if (!response.ok) { + console.error('Failed to fetch dashboard stats:', response.statusText); + return null; + } + + const result = await response.json(); + return result.success ? result.data : null; + } catch (error) { + console.error('Error fetching dashboard stats:', error); + return null; + } +} + +export async function DashboardStatsWrapper({ searchParams }: DashboardStatsWrapperProps) { + const projectId = searchParams?.projectId as string | undefined; + const stats = await fetchDashboardStats(projectId); + + // Fallback to zero values if fetch fails + const initialStats = stats || { + activeSessions: 0, + totalEventsToday: 0, + averageDuration: 0, + eventsPerMinute: 0, + }; + + return ; +} diff --git a/apps/web/components/agent-observability/dashboard/index.ts b/apps/web/components/agent-observability/dashboard/index.ts index fdf2020c..0c1c226f 100644 --- a/apps/web/components/agent-observability/dashboard/index.ts +++ b/apps/web/components/agent-observability/dashboard/index.ts @@ -1,3 +1,4 @@ export { DashboardStats } from './dashboard-stats'; +export { DashboardStatsWrapper } from './dashboard-stats-wrapper'; export { RecentActivity } from './recent-activity'; export { ActiveSessions } from './active-sessions'; diff --git a/apps/web/components/agent-observability/dashboard/live-dashboard-stats.tsx b/apps/web/components/agent-observability/dashboard/live-dashboard-stats.tsx new file mode 100644 index 00000000..9a2d3879 --- /dev/null +++ b/apps/web/components/agent-observability/dashboard/live-dashboard-stats.tsx @@ -0,0 +1,163 @@ +/** + * Live Dashboard Statistics Component + * + * Client component that displays real-time dashboard metrics with SSE updates + */ + +'use client'; + +import { useEffect, useState } from 'react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Activity, Zap, Clock, TrendingUp, Wifi, WifiOff } from 'lucide-react'; +import { useRealtimeEvents } from '@/lib/hooks/use-realtime-events'; + +interface DashboardStats { + activeSessions: number; + totalEventsToday: number; + averageDuration: number; + eventsPerMinute: number; +} + +interface LiveDashboardStatsProps { + initialStats: DashboardStats; +} + +function formatDuration(ms: number): string { + if (ms === 0) return '-'; + const minutes = Math.floor(ms / 60000); + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(minutes / 60); + return `${hours}h ${minutes % 60}m`; +} + +export function LiveDashboardStats({ initialStats }: LiveDashboardStatsProps) { + const [stats, setStats] = useState(initialStats); + const { status, subscribe } = useRealtimeEvents({ + onConnected: () => console.log('[Dashboard] Connected to real-time updates'), + onError: (error) => console.error('[Dashboard] SSE error:', error), + }); + + // Subscribe to stats updates + useEffect(() => { + const unsubscribe = subscribe('stats.updated', (data: DashboardStats) => { + console.log('[Dashboard] Stats updated:', data); + setStats(data); + }); + + return unsubscribe; + }, [subscribe]); + + // Subscribe to session events + useEffect(() => { + const unsubscribeCreated = subscribe('session.created', () => { + setStats((prev) => ({ + ...prev, + activeSessions: prev.activeSessions + 1, + })); + }); + + const unsubscribeCompleted = subscribe('session.completed', () => { + setStats((prev) => ({ + ...prev, + activeSessions: Math.max(0, prev.activeSessions - 1), + })); + }); + + return () => { + unsubscribeCreated(); + unsubscribeCompleted(); + }; + }, [subscribe]); + + // Subscribe to event creation + useEffect(() => { + const unsubscribe = subscribe('event.created', () => { + setStats((prev) => ({ + ...prev, + totalEventsToday: prev.totalEventsToday + 1, + })); + }); + + return unsubscribe; + }, [subscribe]); + + return ( +
+ {/* Connection Status */} +
+ {status.connected ? ( + + + Live Updates + + ) : status.reconnecting ? ( + + + Reconnecting... + + ) : ( + + + Disconnected + + )} +
+ + {/* Stats Cards */} +
+ + + Active Sessions + + + +
{stats.activeSessions}
+

+ {stats.activeSessions === 0 ? 'No active agent sessions' : 'Currently running'} +

+
+
+ + + + Total Events Today + + + +
{stats.totalEventsToday}
+

+ {stats.totalEventsToday === 0 ? 'No events logged' : 'Agent events logged'} +

+
+
+ + + + Avg Session Duration + + + +
{formatDuration(stats.averageDuration)}
+

+ {stats.averageDuration === 0 ? 'No sessions yet' : 'Average completion time'} +

+
+
+ + + + Events Per Minute + + + +
{stats.eventsPerMinute.toFixed(1)}
+

+ {stats.eventsPerMinute === 0 ? 'No activity' : 'Current rate'} +

+
+
+
+
+ ); +} diff --git a/apps/web/components/agent-observability/dashboard/recent-activity.tsx b/apps/web/components/agent-observability/dashboard/recent-activity.tsx index b3696319..a243fc58 100644 --- a/apps/web/components/agent-observability/dashboard/recent-activity.tsx +++ b/apps/web/components/agent-observability/dashboard/recent-activity.tsx @@ -16,10 +16,21 @@ interface AgentEvent { context?: Record; } -async function fetchRecentActivity(): Promise { +interface RecentActivityProps { + searchParams?: { [key: string]: string | string[] | undefined }; +} + +async function fetchRecentActivity(projectId?: string): Promise { try { const baseUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3200'; - const response = await fetch(`${baseUrl}/api/dashboard/activity?limit=10`, { + const url = new URL(`${baseUrl}/api/dashboard/activity`); + url.searchParams.set('limit', '10'); + + if (projectId) { + url.searchParams.set('projectId', projectId); + } + + const response = await fetch(url.toString(), { cache: 'no-store', }); @@ -61,8 +72,9 @@ function getEventColor(eventType: string): string { return colors[eventType] || 'bg-gray-500'; } -export async function RecentActivity() { - const events = await fetchRecentActivity(); +export async function RecentActivity({ searchParams }: RecentActivityProps) { + const projectId = searchParams?.projectId as string | undefined; + const events = await fetchRecentActivity(projectId); if (events.length === 0) { return ( diff --git a/apps/web/components/agent-observability/project-selector.tsx b/apps/web/components/agent-observability/project-selector.tsx new file mode 100644 index 00000000..8dc7e505 --- /dev/null +++ b/apps/web/components/agent-observability/project-selector.tsx @@ -0,0 +1,102 @@ +/** + * Project Selector Component + * + * Dropdown selector for filtering dashboard and sessions by project + */ + +'use client'; + +import { useState, useEffect } from 'react'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { useRouter, useSearchParams } from 'next/navigation'; + +interface Project { + id: number; + name: string; + description?: string; +} + +interface ProjectSelectorProps { + className?: string; +} + +export function ProjectSelector({ className }: ProjectSelectorProps) { + const router = useRouter(); + const searchParams = useSearchParams(); + const [projects, setProjects] = useState([]); + const [loading, setLoading] = useState(true); + + // Get selected project from URL, memoize to prevent infinite loops + const selectedProject = searchParams.get('projectId') || 'all'; + + useEffect(() => { + async function fetchProjects() { + try { + const response = await fetch('/api/projects'); + if (response.ok) { + const result = await response.json(); + if (result.success) { + setProjects(result.data); + } + } + } catch (error) { + console.error('Error fetching projects:', error); + } finally { + setLoading(false); + } + } + + fetchProjects(); + }, []); + + const handleProjectChange = (value: string) => { + // Update URL with the new project filter + const current = new URLSearchParams(Array.from(searchParams.entries())); + + if (value === 'all') { + current.delete('projectId'); + } else { + current.set('projectId', value); + } + + // Construct the new URL + const search = current.toString(); + const query = search ? `?${search}` : ''; + + router.push(`${window.location.pathname}${query}`); + }; + + if (loading) { + return ( +
+ +
+ ); + } + + if (projects.length === 0) { + return null; // Don't show selector if no projects + } + + return ( +
+ +
+ ); +} diff --git a/apps/web/components/agent-observability/session-details/event-timeline.tsx b/apps/web/components/agent-observability/session-details/event-timeline.tsx new file mode 100644 index 00000000..69cb8d94 --- /dev/null +++ b/apps/web/components/agent-observability/session-details/event-timeline.tsx @@ -0,0 +1,229 @@ +/** + * Event Timeline Component + * + * Displays chronological list of events for a session with filtering + */ + +'use client'; + +import { useState } from 'react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Input } from '@/components/ui/input'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Search, FileText, Terminal, Code, AlertCircle, CheckCircle, Info } from 'lucide-react'; +import type { AgentEvent } from '@codervisor/devlog-core'; + +interface EventTimelineProps { + events: AgentEvent[]; +} + +function getEventIcon(type: string) { + const iconMap: Record> = { + file_write: FileText, + file_read: FileText, + command_execute: Terminal, + llm_request: Code, + error: AlertCircle, + success: CheckCircle, + info: Info, + }; + return iconMap[type] || Info; +} + +function getSeverityBadge(severity?: string) { + if (!severity) return null; + + const variants: Record = { + critical: 'destructive', + error: 'destructive', + warning: 'secondary', + info: 'outline', + debug: 'outline', + }; + + const colors: Record = { + critical: 'bg-red-600', + error: 'bg-red-500', + warning: 'bg-yellow-500', + info: 'bg-blue-500', + debug: 'bg-gray-500', + }; + + return ( + + {severity.toUpperCase()} + + ); +} + +function formatTimestamp(date: Date): string { + return new Date(date).toLocaleString('en-US', { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); +} + +export function EventTimeline({ events }: EventTimelineProps) { + const [searchTerm, setSearchTerm] = useState(''); + const [typeFilter, setTypeFilter] = useState('all'); + const [severityFilter, setSeverityFilter] = useState('all'); + + // Get unique event types and severities for filters + const eventTypes = Array.from(new Set(events.map(e => e.type))); + const severities = Array.from(new Set(events.map(e => e.severity).filter(Boolean))); + + // Apply filters + const filteredEvents = events.filter(event => { + // Search filter + const searchMatch = searchTerm === '' || + event.type.toLowerCase().includes(searchTerm.toLowerCase()) || + JSON.stringify(event.data).toLowerCase().includes(searchTerm.toLowerCase()) || + JSON.stringify(event.context).toLowerCase().includes(searchTerm.toLowerCase()); + + // Type filter + const typeMatch = typeFilter === 'all' || event.type === typeFilter; + + // Severity filter + const severityMatch = severityFilter === 'all' || event.severity === severityFilter; + + return searchMatch && typeMatch && severityMatch; + }); + + return ( + + + Event Timeline + + {/* Filters */} +
+
+ + setSearchTerm(e.target.value)} + className="pl-8" + /> +
+ + + + +
+
+ + + {filteredEvents.length === 0 ? ( +
+ +

No events found matching your filters

+
+ ) : ( +
+ {filteredEvents.map((event) => { + const Icon = getEventIcon(event.type); + return ( +
+ + +
+
+
+ {event.type} + {getSeverityBadge(event.severity)} + {event.tags && event.tags.length > 0 && ( +
+ {event.tags.map(tag => ( + + {tag} + + ))} +
+ )} +
+ + {formatTimestamp(event.timestamp)} + +
+ + {/* Context */} + {event.context?.filePath && ( +

+ 📁 {event.context.filePath} +

+ )} + + {event.context?.workingDirectory && ( +

+ 📂 {event.context.workingDirectory} +

+ )} + + {/* Data preview */} + {Object.keys(event.data).length > 0 && ( +
+ + View data + +
+                          {JSON.stringify(event.data, null, 2)}
+                        
+
+ )} + + {/* Metrics */} + {event.metrics && ( +
+ {event.metrics.tokenCount && ( + ⚡ {event.metrics.tokenCount} tokens + )} + {event.metrics.duration && ( + ⏱️ {event.metrics.duration}ms + )} + {event.metrics.linesChanged && ( + 📝 {event.metrics.linesChanged} lines + )} +
+ )} +
+
+ ); + })} +
+ )} + + {/* Results count */} +
+ Showing {filteredEvents.length} of {events.length} events +
+
+
+ ); +} diff --git a/apps/web/components/agent-observability/session-details/index.ts b/apps/web/components/agent-observability/session-details/index.ts new file mode 100644 index 00000000..f1538869 --- /dev/null +++ b/apps/web/components/agent-observability/session-details/index.ts @@ -0,0 +1,9 @@ +/** + * Session Details Components + * + * Components for displaying detailed session information + */ + +export { SessionHeader } from './session-header'; +export { SessionMetrics } from './session-metrics'; +export { EventTimeline } from './event-timeline'; diff --git a/apps/web/components/agent-observability/session-details/session-header.tsx b/apps/web/components/agent-observability/session-details/session-header.tsx new file mode 100644 index 00000000..fd69c65b --- /dev/null +++ b/apps/web/components/agent-observability/session-details/session-header.tsx @@ -0,0 +1,129 @@ +/** + * Session Header Component + * + * Displays session overview including objective, status, duration, and outcome + */ + +import { Card, CardContent } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Clock, Calendar, Activity } from 'lucide-react'; +import type { AgentSession } from '@codervisor/devlog-core'; + +interface SessionHeaderProps { + session: AgentSession; +} + +function formatDuration(seconds: number | undefined): string { + if (!seconds) return '-'; + + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = seconds % 60; + + if (hours > 0) { + return `${hours}h ${minutes}m ${secs}s`; + } else if (minutes > 0) { + return `${minutes}m ${secs}s`; + } else { + return `${secs}s`; + } +} + +function formatDate(date: Date): string { + return new Date(date).toLocaleString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +} + +function getOutcomeBadge(outcome?: string) { + if (!outcome) { + return In Progress; + } + + const variants: Record = { + success: 'default', + partial: 'secondary', + failure: 'destructive', + cancelled: 'outline', + }; + + return ( + + {outcome.charAt(0).toUpperCase() + outcome.slice(1)} + + ); +} + +export function SessionHeader({ session }: SessionHeaderProps) { + const objective = session.context?.objective || 'No objective specified'; + const isActive = !session.endTime; + + return ( + + +
+ {/* Session ID and Status */} +
+
+

{objective}

+

Session ID: {session.id}

+
+ {getOutcomeBadge(session.outcome)} +
+ + {/* Agent Info */} +
+ + {session.agentId} + v{session.agentVersion} +
+ + {/* Timing Info */} +
+
+ +
+

Started

+

{formatDate(session.startTime)}

+
+
+ + {session.endTime && ( +
+ +
+

Ended

+

{formatDate(session.endTime)}

+
+
+ )} + +
+ +
+

Duration

+

+ {isActive ? 'In progress...' : formatDuration(session.duration)} +

+
+
+
+ + {/* Quality Score */} + {session.qualityScore !== undefined && ( +
+
+ Quality Score: + {session.qualityScore}/100 +
+
+ )} +
+
+
+ ); +} diff --git a/apps/web/components/agent-observability/session-details/session-metrics.tsx b/apps/web/components/agent-observability/session-details/session-metrics.tsx new file mode 100644 index 00000000..8f5790c6 --- /dev/null +++ b/apps/web/components/agent-observability/session-details/session-metrics.tsx @@ -0,0 +1,102 @@ +/** + * Session Metrics Component + * + * Displays quantitative metrics for the session + */ + +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { FileText, Code, Zap, Terminal, AlertCircle, TestTube, Package } from 'lucide-react'; +import type { AgentSession } from '@codervisor/devlog-core'; + +interface SessionMetricsProps { + session: AgentSession; +} + +export function SessionMetrics({ session }: SessionMetricsProps) { + const metrics = session.metrics; + + const metricCards = [ + { + title: 'Events', + value: metrics.eventsCount, + icon: Zap, + description: 'Total events logged', + }, + { + title: 'Files Modified', + value: metrics.filesModified, + icon: FileText, + description: 'Files changed', + }, + { + title: 'Lines Added', + value: metrics.linesAdded, + icon: Code, + description: 'New lines of code', + color: 'text-green-600', + }, + { + title: 'Lines Removed', + value: metrics.linesRemoved, + icon: Code, + description: 'Lines deleted', + color: 'text-red-600', + }, + { + title: 'Tokens Used', + value: metrics.tokensUsed, + icon: Zap, + description: 'LLM tokens consumed', + }, + { + title: 'Commands', + value: metrics.commandsExecuted, + icon: Terminal, + description: 'Commands executed', + }, + { + title: 'Errors', + value: metrics.errorsEncountered, + icon: AlertCircle, + description: 'Errors encountered', + color: metrics.errorsEncountered > 0 ? 'text-red-600' : undefined, + }, + { + title: 'Tests Run', + value: metrics.testsRun, + icon: TestTube, + description: `${metrics.testsPassed} passed`, + }, + { + title: 'Builds', + value: metrics.buildAttempts, + icon: Package, + description: `${metrics.buildSuccesses} successful`, + }, + ]; + + return ( + + + Session Metrics + + +
+ {metricCards.map((metric) => { + const Icon = metric.icon; + return ( +
+ +
+

{metric.title}

+

{metric.value}

+

{metric.description}

+
+
+ ); + })} +
+
+
+ ); +} diff --git a/apps/web/components/agent-observability/sessions/sessions-list.tsx b/apps/web/components/agent-observability/sessions/sessions-list.tsx index 46ae3f47..f096757b 100644 --- a/apps/web/components/agent-observability/sessions/sessions-list.tsx +++ b/apps/web/components/agent-observability/sessions/sessions-list.tsx @@ -18,14 +18,19 @@ interface AgentSession { summary?: string; } -async function fetchSessions(status?: string): Promise { +async function fetchSessions(status?: string, projectId?: string): Promise { try { const baseUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3200'; - const url = status - ? `${baseUrl}/api/sessions?status=${status}` - : `${baseUrl}/api/sessions`; + const url = new URL(`${baseUrl}/api/sessions`); - const response = await fetch(url, { + if (status) { + url.searchParams.set('status', status); + } + if (projectId) { + url.searchParams.set('projectId', projectId); + } + + const response = await fetch(url.toString(), { cache: 'no-store', }); @@ -85,10 +90,12 @@ function getOutcomeBadge(outcome?: string) { interface SessionsListProps { status?: string; title: string; + searchParams?: { [key: string]: string | string[] | undefined }; } -export async function SessionsList({ status, title }: SessionsListProps) { - const sessions = await fetchSessions(status); +export async function SessionsList({ status, title, searchParams }: SessionsListProps) { + const projectId = searchParams?.projectId as string | undefined; + const sessions = await fetchSessions(status, projectId); if (sessions.length === 0) { return ( diff --git a/apps/web/lib/hooks/use-realtime-events.ts b/apps/web/lib/hooks/use-realtime-events.ts new file mode 100644 index 00000000..4a37c744 --- /dev/null +++ b/apps/web/lib/hooks/use-realtime-events.ts @@ -0,0 +1,261 @@ +/** + * React hook for consuming real-time events via Server-Sent Events (SSE) + * + * Provides automatic reconnection and event handling + */ + +'use client'; + +import { useEffect, useState, useCallback, useRef } from 'react'; + +export interface RealtimeEventData { + [key: string]: any; +} + +export interface UseRealtimeEventsOptions { + /** + * Callback fired when connected to the stream + */ + onConnected?: () => void; + + /** + * Callback fired when disconnected from the stream + */ + onDisconnected?: () => void; + + /** + * Callback fired when an error occurs + */ + onError?: (error: Error) => void; + + /** + * Whether to automatically reconnect on disconnect + * @default true + */ + autoReconnect?: boolean; + + /** + * Reconnection delay in milliseconds + * @default 3000 + */ + reconnectDelay?: number; + + /** + * Maximum number of reconnection attempts + * @default 10 + */ + maxReconnectAttempts?: number; +} + +export interface RealtimeConnectionStatus { + connected: boolean; + reconnecting: boolean; + reconnectAttempts: number; + error: Error | null; +} + +/** + * Hook to consume real-time events from the SSE endpoint + * + * @example + * ```tsx + * function DashboardStats() { + * const [stats, setStats] = useState(initialStats); + * const { status, subscribe } = useRealtimeEvents({ + * onConnected: () => console.log('Connected!'), + * }); + * + * useEffect(() => { + * const unsubscribe = subscribe('stats.updated', (data) => { + * setStats(data); + * }); + * return unsubscribe; + * }, [subscribe]); + * + * return
Active Sessions: {stats.activeSessions}
; + * } + * ``` + */ +export function useRealtimeEvents(options: UseRealtimeEventsOptions = {}) { + const { + onConnected, + onDisconnected, + onError, + autoReconnect = true, + reconnectDelay = 3000, + maxReconnectAttempts = 10, + } = options; + + const [status, setStatus] = useState({ + connected: false, + reconnecting: false, + reconnectAttempts: 0, + error: null, + }); + + const eventSourceRef = useRef(null); + const reconnectTimeoutRef = useRef(null); + const listenersRef = useRef void>>>(new Map()); + const reconnectAttemptsRef = useRef(0); + + const connect = useCallback(() => { + // Clean up existing connection + if (eventSourceRef.current) { + eventSourceRef.current.close(); + } + + try { + const eventSource = new EventSource('/api/events/stream'); + eventSourceRef.current = eventSource; + + eventSource.addEventListener('connected', (event) => { + const data = JSON.parse(event.data); + console.log('[SSE] Connected:', data); + + setStatus({ + connected: true, + reconnecting: false, + reconnectAttempts: 0, + error: null, + }); + + reconnectAttemptsRef.current = 0; + onConnected?.(); + }); + + eventSource.onerror = (error) => { + console.error('[SSE] Error:', error); + + const errorObj = new Error('EventSource connection failed'); + setStatus((prev) => ({ + ...prev, + connected: false, + error: errorObj, + })); + + onError?.(errorObj); + + // Attempt to reconnect if enabled + if (autoReconnect && reconnectAttemptsRef.current < maxReconnectAttempts) { + reconnectAttemptsRef.current++; + + setStatus((prev) => ({ + ...prev, + reconnecting: true, + reconnectAttempts: reconnectAttemptsRef.current, + })); + + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + } + + reconnectTimeoutRef.current = setTimeout(() => { + console.log(`[SSE] Reconnecting... (attempt ${reconnectAttemptsRef.current})`); + connect(); + }, reconnectDelay); + } else if (reconnectAttemptsRef.current >= maxReconnectAttempts) { + console.error('[SSE] Max reconnection attempts reached'); + setStatus((prev) => ({ + ...prev, + reconnecting: false, + })); + } + }; + + eventSource.onopen = () => { + console.log('[SSE] Connection opened'); + }; + + // Set up event listeners for all subscribed events + for (const [eventType, callbacks] of listenersRef.current.entries()) { + eventSource.addEventListener(eventType, (event) => { + try { + const data = JSON.parse(event.data); + callbacks.forEach((callback) => callback(data)); + } catch (error) { + console.error('[SSE] Error parsing event data:', error); + } + }); + } + } catch (error) { + console.error('[SSE] Error creating EventSource:', error); + const errorObj = error instanceof Error ? error : new Error('Failed to create EventSource'); + setStatus({ + connected: false, + reconnecting: false, + reconnectAttempts: reconnectAttemptsRef.current, + error: errorObj, + }); + onError?.(errorObj); + } + }, [autoReconnect, maxReconnectAttempts, reconnectDelay, onConnected, onError]); + + const disconnect = useCallback(() => { + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; + } + + if (eventSourceRef.current) { + eventSourceRef.current.close(); + eventSourceRef.current = null; + } + + setStatus({ + connected: false, + reconnecting: false, + reconnectAttempts: 0, + error: null, + }); + + onDisconnected?.(); + }, [onDisconnected]); + + const subscribe = useCallback((eventType: string, callback: (data: any) => void) => { + if (!listenersRef.current.has(eventType)) { + listenersRef.current.set(eventType, new Set()); + } + + listenersRef.current.get(eventType)!.add(callback); + + // If already connected, add the listener to the existing EventSource + if (eventSourceRef.current && eventSourceRef.current.readyState === EventSource.OPEN) { + eventSourceRef.current.addEventListener(eventType, (event) => { + try { + const data = JSON.parse(event.data); + callback(data); + } catch (error) { + console.error('[SSE] Error parsing event data:', error); + } + }); + } + + // Return unsubscribe function + return () => { + const listeners = listenersRef.current.get(eventType); + if (listeners) { + listeners.delete(callback); + if (listeners.size === 0) { + listenersRef.current.delete(eventType); + } + } + }; + }, []); + + // Connect on mount + useEffect(() => { + connect(); + + // Disconnect on unmount + return () => { + disconnect(); + }; + }, [connect, disconnect]); + + return { + status, + subscribe, + disconnect, + reconnect: connect, + }; +} diff --git a/apps/web/lib/realtime/event-broadcaster.ts b/apps/web/lib/realtime/event-broadcaster.ts new file mode 100644 index 00000000..5b35b14e --- /dev/null +++ b/apps/web/lib/realtime/event-broadcaster.ts @@ -0,0 +1,70 @@ +/** + * Event Broadcaster + * + * Simple in-memory event emitter for real-time updates via Server-Sent Events. + * In production, this should use Redis pub/sub or similar for multi-instance support. + */ + +export class EventBroadcaster { + private static instance: EventBroadcaster; + private clients: Set = new Set(); + + private constructor() {} + + static getInstance(): EventBroadcaster { + if (!EventBroadcaster.instance) { + EventBroadcaster.instance = new EventBroadcaster(); + } + return EventBroadcaster.instance; + } + + addClient(controller: ReadableStreamDefaultController) { + this.clients.add(controller); + } + + removeClient(controller: ReadableStreamDefaultController) { + this.clients.delete(controller); + } + + broadcast(event: string, data: any) { + const message = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; + const encoder = new TextEncoder(); + const encoded = encoder.encode(message); + + // Send to all connected clients + for (const controller of this.clients) { + try { + controller.enqueue(encoded); + } catch (error) { + console.error('Error sending to client:', error); + this.clients.delete(controller); + } + } + } + + getClientCount(): number { + return this.clients.size; + } +} + +/** + * Helper function to broadcast events from other parts of the application + * + * Usage example: + * ```typescript + * import { broadcastEvent } from '@/lib/realtime/event-broadcaster'; + * + * // When a new session is created + * broadcastEvent('session.created', { sessionId: '123', agentId: 'copilot' }); + * + * // When session completes + * broadcastEvent('session.completed', { sessionId: '123', outcome: 'success' }); + * + * // When new event is logged + * broadcastEvent('event.created', { sessionId: '123', type: 'file_write' }); + * ``` + */ +export function broadcastEvent(event: string, data: any) { + const broadcaster = EventBroadcaster.getInstance(); + broadcaster.broadcast(event, data); +}