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