feat(admin-dashboard): add Session Detail View (Phase 3.2)

- New /ops/debug-sessions/[id]/page.tsx with 5 tabs

- Session header with status badge and action buttons (Extend, Stop, Download)

- Info cards: User, Device, Duration, Collection Level

- Timeline tab: Session lifecycle breadcrumbs

- Logs tab: Level filter (debug/info/warn/error/fatal)

- Network tab: Request list with method/status/duration

- Traces tab: Trace spans with status and timing

- Screenshots tab: Placeholder for captured screenshots

Features:

- Auto-refresh every 5 seconds

- Live data fetching for traces and logs

- Color-coded status badges and log levels
This commit is contained in:
saravanakumardb1 2026-03-03 09:47:34 -08:00
parent e955668e6a
commit e2e5e2cece

View File

@ -0,0 +1,559 @@
'use client';
import { useState, useEffect } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Separator } from '@/components/ui/separator';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
ArrowLeft,
Clock,
Download,
StopCircle,
Plus,
MoreHorizontal,
Calendar,
User,
Smartphone,
Activity,
Network,
Camera,
FileText,
} from 'lucide-react';
import {
createDiagnosticsClient,
type DebugSession,
type TraceSpan,
type LogEntry,
} from '@/lib/diagnostics-client';
const statusColors: Record<string, string> = {
pending: 'bg-yellow-500',
active: 'bg-green-500',
paused: 'bg-orange-500',
completed: 'bg-blue-500',
cancelled: 'bg-red-500',
};
const levelColors: Record<string, string> = {
debug: 'text-gray-500',
info: 'text-blue-500',
warn: 'text-yellow-500',
error: 'text-red-500',
fatal: 'text-red-700 font-bold',
};
export default function SessionDetailPage() {
const params = useParams();
const router = useRouter();
const sessionId = params.id as string;
const [session, setSession] = useState<DebugSession | null>(null);
const [traces, setTraces] = useState<TraceSpan[]>([]);
const [logs, setLogs] = useState<LogEntry[]>([]);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState('timeline');
const [logLevelFilter, setLogLevelFilter] = useState<string>('all');
const getAuthToken = () => {
if (typeof window === 'undefined') return null;
return localStorage.getItem('admin_access_token');
};
const client = createDiagnosticsClient({
baseUrl: process.env.NEXT_PUBLIC_PLATFORM_SERVICE_URL || 'http://localhost:4003',
productId: 'lysnrai',
getAuthToken,
});
const fetchSession = async () => {
try {
const data = await client.getSession(sessionId);
setSession(data);
} catch (error) {
console.error('Failed to fetch session:', error);
}
};
const fetchTraces = async () => {
try {
const result = await client.getTraces({
sessionId,
productId: 'lysnrai',
limit: 100,
});
setTraces(result.traces);
} catch (error) {
console.error('Failed to fetch traces:', error);
}
};
const fetchLogs = async () => {
try {
const result = await client.getLogs({
sessionId,
productId: 'lysnrai',
levels:
logLevelFilter === 'all'
? undefined
: [logLevelFilter as 'debug' | 'info' | 'warn' | 'error' | 'fatal'],
limit: 100,
});
setLogs(result.logs);
} catch (error) {
console.error('Failed to fetch logs:', error);
}
};
useEffect(() => {
const loadData = async () => {
setLoading(true);
await fetchSession();
await Promise.all([fetchTraces(), fetchLogs()]);
setLoading(false);
};
loadData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessionId, activeTab, logLevelFilter]);
const handleExtend = async () => {
if (!session) return;
try {
await client.updateSession(sessionId, {
maxDurationMinutes: session.maxDurationMinutes + 30,
});
fetchSession();
} catch (error) {
console.error('Failed to extend session:', error);
}
};
const handleStop = async () => {
try {
await client.cancelSession(sessionId, 'Stopped by admin');
fetchSession();
} catch (error) {
console.error('Failed to stop session:', error);
}
};
const handleDownload = () => {
// TODO: Implement download of session data as JSON
console.log('Download session data');
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-muted-foreground">Loading session...</div>
</div>
);
}
if (!session) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-muted-foreground">Session not found</div>
</div>
);
}
const formatDuration = (minutes: number) => {
if (minutes < 60) return `${minutes}m`;
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
};
// Mock breadcrumbs for timeline (would come from API in real implementation)
const breadcrumbs = [
{ timestamp: session.createdAt, category: 'session', message: 'Session created' },
...(session.startedAt
? [{ timestamp: session.startedAt, category: 'session', message: 'Session activated' }]
: []),
];
// Mock network requests for demo
const networkRequests = [
{
id: '1',
method: 'GET',
url: '/api/user/profile',
status: 200,
durationMs: 145,
timestamp: new Date().toISOString(),
},
{
id: '2',
method: 'POST',
url: '/api/diagnostics/ingest',
status: 200,
durationMs: 89,
timestamp: new Date().toISOString(),
},
];
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-start justify-between">
<div className="space-y-1">
<Button
variant="ghost"
size="sm"
className="mb-2 -ml-2"
onClick={() => router.push('/debug-sessions')}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Sessions
</Button>
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold tracking-tight font-mono">{session.id}</h1>
<Badge className={`${statusColors[session.status]} text-white`}>{session.status}</Badge>
</div>
<p className="text-muted-foreground">
Created {new Date(session.createdAt).toLocaleString()}
</p>
</div>
<div className="flex items-center gap-2">
{session.status === 'active' && (
<>
<Button variant="outline" onClick={handleExtend}>
<Plus className="mr-2 h-4 w-4" />
Extend (+30m)
</Button>
<Button variant="destructive" onClick={handleStop}>
<StopCircle className="mr-2 h-4 w-4" />
Stop
</Button>
</>
)}
<Button variant="outline" onClick={handleDownload}>
<Download className="mr-2 h-4 w-4" />
Download
</Button>
</div>
</div>
{/* Session Info Cards */}
<div className="grid grid-cols-4 gap-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<User className="h-4 w-4" />
Target User
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-sm font-medium">{session.targetUserId || 'Anonymous'}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<Smartphone className="h-4 w-4" />
Device
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-sm font-medium">{session.targetDeviceId || 'Unknown'}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<Clock className="h-4 w-4" />
Duration
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-sm font-medium">{formatDuration(session.maxDurationMinutes)}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<Activity className="h-4 w-4" />
Collection
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-sm font-medium capitalize">{session.collectionLevel}</div>
<div className="text-xs text-muted-foreground mt-1">
{session.captureLogs && 'Logs '}
{session.captureNetwork && 'Network '}
{session.captureScreenshots && 'Screenshots'}
</div>
</CardContent>
</Card>
</div>
{/* Tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-5">
<TabsTrigger value="timeline" className="flex items-center gap-2">
<Calendar className="h-4 w-4" />
Timeline
</TabsTrigger>
<TabsTrigger value="logs" className="flex items-center gap-2">
<FileText className="h-4 w-4" />
Logs
</TabsTrigger>
<TabsTrigger value="network" className="flex items-center gap-2">
<Network className="h-4 w-4" />
Network
</TabsTrigger>
<TabsTrigger value="traces" className="flex items-center gap-2">
<Activity className="h-4 w-4" />
Traces
</TabsTrigger>
<TabsTrigger value="screenshots" className="flex items-center gap-2">
<Camera className="h-4 w-4" />
Screenshots
</TabsTrigger>
</TabsList>
{/* Timeline Tab */}
<TabsContent value="timeline" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Session Timeline</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{breadcrumbs.map((crumb, index) => (
<div key={index} className="flex items-start gap-4">
<div className="flex flex-col items-center">
<div className="w-3 h-3 rounded-full bg-primary" />
{index < breadcrumbs.length - 1 && (
<div className="w-0.5 h-8 bg-border mt-1" />
)}
</div>
<div className="flex-1 pb-4">
<div className="text-sm font-medium">{crumb.message}</div>
<div className="text-xs text-muted-foreground">
{new Date(crumb.timestamp).toLocaleString()}
</div>
<Badge variant="outline" className="mt-1 text-xs">
{crumb.category}
</Badge>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</TabsContent>
{/* Logs Tab */}
<TabsContent value="logs" className="space-y-4">
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle>Log Entries ({logs.length})</CardTitle>
<Select value={logLevelFilter} onValueChange={setLogLevelFilter}>
<SelectTrigger className="w-[130px]">
<SelectValue placeholder="Level" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Levels</SelectItem>
<SelectItem value="debug">Debug</SelectItem>
<SelectItem value="info">Info</SelectItem>
<SelectItem value="warn">Warn</SelectItem>
<SelectItem value="error">Error</SelectItem>
<SelectItem value="fatal">Fatal</SelectItem>
</SelectContent>
</Select>
</div>
</CardHeader>
<CardContent>
<div className="h-[400px] overflow-auto">
<div className="space-y-2">
{logs.length === 0 ? (
<div className="text-center text-muted-foreground py-8">No logs yet</div>
) : (
logs.map(log => (
<div
key={log.id}
className="p-3 rounded-lg border hover:bg-muted/50 transition-colors"
>
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<span
className={`text-xs font-mono uppercase ${levelColors[log.level]}`}
>
{log.level}
</span>
<span className="text-xs text-muted-foreground">{log.module}</span>
</div>
<span className="text-xs text-muted-foreground">
{new Date(log.timestamp).toLocaleTimeString()}
</span>
</div>
<p className="text-sm mt-1">{log.message}</p>
{log.file && (
<p className="text-xs text-muted-foreground mt-1">
{log.file}:{log.line}
</p>
)}
</div>
))
)}
</div>
</div>
</CardContent>
</Card>
</TabsContent>
{/* Network Tab */}
<TabsContent value="network" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Network Requests ({networkRequests.length})</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Method</TableHead>
<TableHead>URL</TableHead>
<TableHead>Status</TableHead>
<TableHead>Duration</TableHead>
<TableHead>Time</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{networkRequests.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground py-8">
No network requests yet
</TableCell>
</TableRow>
) : (
networkRequests.map(req => (
<TableRow key={req.id}>
<TableCell>
<Badge
variant={req.method === 'GET' ? 'secondary' : 'default'}
className="text-xs"
>
{req.method}
</Badge>
</TableCell>
<TableCell className="font-mono text-xs max-w-[300px] truncate">
{req.url}
</TableCell>
<TableCell>
<Badge
variant={req.status < 400 ? 'secondary' : 'destructive'}
className="text-xs"
>
{req.status}
</Badge>
</TableCell>
<TableCell>{req.durationMs}ms</TableCell>
<TableCell className="text-xs text-muted-foreground">
{new Date(req.timestamp).toLocaleTimeString()}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
{/* Traces Tab */}
<TabsContent value="traces" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Trace Spans ({traces.length})</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[400px] overflow-auto">
<div className="space-y-2">
{traces.length === 0 ? (
<div className="text-center text-muted-foreground py-8">No traces yet</div>
) : (
traces.map(trace => (
<div
key={trace.id}
className="p-3 rounded-lg border hover:bg-muted/50 transition-colors"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{trace.name}</span>
<Badge
variant={trace.status === 'ok' ? 'secondary' : 'destructive'}
className="text-xs"
>
{trace.status}
</Badge>
</div>
<span className="text-xs text-muted-foreground">
{trace.durationMs?.toFixed(2)}ms
</span>
</div>
<div className="text-xs text-muted-foreground mt-1">
{new Date(trace.startTime).toLocaleTimeString()}
</div>
{trace.statusMessage && (
<p className="text-xs text-red-500 mt-1">{trace.statusMessage}</p>
)}
</div>
))
)}
</div>
</div>
</CardContent>
</Card>
</TabsContent>
{/* Screenshots Tab */}
<TabsContent value="screenshots" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Screenshots ({session.screenshotCount})</CardTitle>
</CardHeader>
<CardContent>
{session.screenshotCount === 0 ? (
<div className="text-center text-muted-foreground py-16">
<Camera className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p>No screenshots captured yet</p>
<p className="text-sm mt-1">
Screenshots are captured automatically when errors occur (if enabled)
</p>
</div>
) : (
<div className="grid grid-cols-3 gap-4">
{/* Screenshot thumbnails would go here */}
<div className="aspect-video bg-muted rounded-lg flex items-center justify-center">
<span className="text-muted-foreground text-sm">Screenshot placeholder</span>
</div>
</div>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}