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:
parent
e955668e6a
commit
e2e5e2cece
@ -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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user