feat(admin-web): add ecosystem timeline dashboard

This commit is contained in:
Saravana Achu Mac 2026-04-04 01:40:20 -07:00
parent 9d7817788e
commit 6f3a563edf
4 changed files with 402 additions and 0 deletions

View File

@ -0,0 +1,80 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mockGetCurrentUser = vi.fn();
vi.mock('@/lib/auth-server', () => ({
getCurrentUser: (...args: unknown[]) => mockGetCurrentUser(...args),
getCurrentUserFromRequest: (...args: unknown[]) => mockGetCurrentUser(...args),
}));
const mockLogError = vi.fn();
vi.mock('@/lib/logger', () => ({
logError: (...args: unknown[]) => mockLogError(...args),
}));
import { GET } from '@/app/api/timeline/route';
async function callTimeline(qs = '') {
const { NextRequest } = await import('next/server');
return GET(
new NextRequest(
new Request(`http://localhost:3001/api/timeline${qs}`, {
headers: { Authorization: 'Bearer test', 'x-product-id': 'lysnrai' },
})
)
);
}
describe('GET /api/timeline', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('returns 401 when unauthenticated', async () => {
mockGetCurrentUser.mockResolvedValue(null);
const res = await callTimeline();
expect(res.status).toBe(401);
});
it('forwards query params to platform-service and returns timeline items', async () => {
mockGetCurrentUser.mockResolvedValue({ id: 'usr_admin', role: 'admin' });
const fetchMock = vi.fn().mockResolvedValue({
status: 200,
json: async () => ({
items: [
{
itemId: 'timeline_evt_1',
title: 'Transcript captured',
},
],
count: 1,
}),
});
vi.stubGlobal('fetch', fetchMock);
const res = await callTimeline('?limit=20&eventName=artifact.created');
expect(res.status).toBe(200);
const data = await res.json();
expect(data.count).toBe(1);
expect(fetchMock).toHaveBeenCalledWith(
'http://localhost:4003/api/timeline?limit=20&eventName=artifact.created',
expect.objectContaining({
method: 'GET',
headers: expect.objectContaining({
'x-user-id': 'usr_admin',
'x-product-id': 'lysnrai',
}),
})
);
});
it('returns 502 when platform-service proxying fails', async () => {
mockGetCurrentUser.mockResolvedValue({ id: 'usr_admin', role: 'admin' });
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('boom')));
const res = await callTimeline();
expect(res.status).toBe(502);
expect(mockLogError).toHaveBeenCalled();
});
});

View File

@ -0,0 +1,285 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Activity, Filter, Link2, RefreshCw, Search } from 'lucide-react';
import { createProxyFetch } from '@/lib/proxy-fetch';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
type TimelineItem = {
itemId: string;
occurredAt: string;
eventName: string;
productId: string;
title: string;
summary?: string | null;
artifactRefs: string[];
relatedEventIds: string[];
actorType: 'user' | 'agent' | 'system' | 'device';
visibility: 'private' | 'org' | 'shared' | 'local-only';
correlationId?: string | null;
};
const apiFetch = createProxyFetch('/api/timeline');
function formatDate(iso: string) {
return new Date(iso).toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
function badgeClassForActor(actorType: TimelineItem['actorType']) {
switch (actorType) {
case 'agent':
return 'bg-blue-50 text-blue-700 border-0';
case 'system':
return 'bg-amber-50 text-amber-700 border-0';
case 'device':
return 'bg-violet-50 text-violet-700 border-0';
default:
return 'bg-emerald-50 text-emerald-700 border-0';
}
}
export default function TimelinePage() {
const [items, setItems] = useState<TimelineItem[]>([]);
const [loading, setLoading] = useState(true);
const [userId, setUserId] = useState('');
const [correlationId, setCorrelationId] = useState('');
const [eventName, setEventName] = useState('all');
const [artifactId, setArtifactId] = useState('');
const [limit, setLimit] = useState('50');
const loadData = useCallback(async () => {
setLoading(true);
const params = new URLSearchParams();
if (userId.trim()) params.set('userId', userId.trim());
if (correlationId.trim()) params.set('correlationId', correlationId.trim());
if (artifactId.trim()) params.set('artifactId', artifactId.trim());
if (eventName !== 'all') params.set('eventName', eventName);
params.set('limit', limit);
const query = params.toString();
const data = await apiFetch(query ? `?${query}` : '');
setItems(Array.isArray(data?.items) ? data.items : []);
setLoading(false);
}, [artifactId, correlationId, eventName, limit, userId]);
useEffect(() => {
void loadData();
}, [loadData]);
const stats = useMemo(() => {
const uniqueProducts = new Set(items.map(item => item.productId)).size;
const uniqueCorrelations = new Set(
items
.map(item => item.correlationId)
.filter((value): value is string => typeof value === 'string' && value.length > 0)
).size;
const withArtifacts = items.filter(item => item.artifactRefs.length > 0).length;
return {
uniqueProducts,
uniqueCorrelations,
withArtifacts,
};
}, [items]);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Ecosystem Timeline</h1>
<p className="text-sm text-muted-foreground">
Unified cross-product activity stream from the canonical timeline service
</p>
</div>
<Button variant="outline" onClick={() => void loadData()}>
<RefreshCw className="mr-2 h-4 w-4" /> Refresh
</Button>
</div>
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Items</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{items.length}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Products</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.uniqueProducts}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Correlations
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.uniqueCorrelations}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
With Artifacts
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.withArtifacts}</div>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Filter className="h-4 w-4" />
Filters
</CardTitle>
</CardHeader>
<CardContent className="grid gap-3 md:grid-cols-5">
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="User ID"
value={userId}
onChange={event => setUserId(event.target.value)}
className="pl-9"
/>
</div>
<Input
placeholder="Correlation ID"
value={correlationId}
onChange={event => setCorrelationId(event.target.value)}
/>
<Input
placeholder="Artifact ID"
value={artifactId}
onChange={event => setArtifactId(event.target.value)}
/>
<Select value={eventName} onValueChange={setEventName}>
<SelectTrigger>
<SelectValue placeholder="Event type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All events</SelectItem>
<SelectItem value="capture.transcript.created">capture.transcript.created</SelectItem>
<SelectItem value="artifact.created">artifact.created</SelectItem>
<SelectItem value="artifact.linked">artifact.linked</SelectItem>
<SelectItem value="memory.entry.created">memory.entry.created</SelectItem>
</SelectContent>
</Select>
<Select value={limit} onValueChange={setLimit}>
<SelectTrigger>
<SelectValue placeholder="Limit" />
</SelectTrigger>
<SelectContent>
<SelectItem value="20">20</SelectItem>
<SelectItem value="50">50</SelectItem>
<SelectItem value="100">100</SelectItem>
</SelectContent>
</Select>
</CardContent>
</Card>
<Card>
<CardContent className="p-0">
{loading ? (
<div className="p-8 text-center text-muted-foreground">Loading timeline...</div>
) : items.length === 0 ? (
<div className="p-8 text-center text-muted-foreground">
<Activity className="mx-auto mb-2 h-8 w-8 opacity-40" />
No timeline items matched the current filters.
</div>
) : (
<div className="divide-y">
{items.map(item => (
<div key={item.itemId} className="space-y-3 p-4">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-2">
<h2 className="font-semibold">{item.title}</h2>
<Badge className={badgeClassForActor(item.actorType)}>
{item.actorType}
</Badge>
<Badge variant="outline">{item.productId}</Badge>
<Badge variant="outline">{item.eventName}</Badge>
</div>
{item.summary && (
<p className="text-sm text-muted-foreground">{item.summary}</p>
)}
</div>
<div className="text-xs text-muted-foreground">
{formatDate(item.occurredAt)}
</div>
</div>
<div className="grid gap-3 text-sm md:grid-cols-3">
<div>
<div className="mb-1 text-xs uppercase tracking-wide text-muted-foreground">
Correlation
</div>
<div className="font-mono text-xs">{item.correlationId || '—'}</div>
</div>
<div>
<div className="mb-1 text-xs uppercase tracking-wide text-muted-foreground">
Artifact Refs
</div>
<div className="space-y-1">
{item.artifactRefs.length === 0 ? (
<div className="text-xs text-muted-foreground">None</div>
) : (
item.artifactRefs.map(ref => (
<div key={ref} className="font-mono text-xs">
{ref}
</div>
))
)}
</div>
</div>
<div>
<div className="mb-1 text-xs uppercase tracking-wide text-muted-foreground">
Related Events
</div>
<div className="space-y-1">
{item.relatedEventIds.length === 0 ? (
<div className="text-xs text-muted-foreground">None</div>
) : (
item.relatedEventIds.map(ref => (
<div key={ref} className="flex items-center gap-1 font-mono text-xs">
<Link2 className="h-3 w-3 text-muted-foreground" />
{ref}
</div>
))
)}
</div>
</div>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,35 @@
/**
* Timeline proxy route forwards timeline queries to platform-service.
*/
import { NextRequest, NextResponse } from 'next/server';
import { getCurrentUserFromRequest } from '@/lib/auth-server';
import { logError } from '@/lib/logger';
const PLATFORM_URL = process.env.PLATFORM_SERVICE_URL || 'http://localhost:4003';
export async function GET(req: NextRequest) {
try {
const caller = await getCurrentUserFromRequest(req);
if (!caller) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const qs = new URL(req.url).search;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'x-request-id': req.headers.get('x-request-id') || crypto.randomUUID(),
'x-user-id': caller.id,
'x-product-id': req.headers.get('x-product-id') || process.env.PRODUCT_ID || 'lysnrai',
};
const res = await fetch(`${PLATFORM_URL}/api/timeline${qs}`, {
method: 'GET',
headers,
});
const data = await res.json().catch(() => null);
return NextResponse.json(data ?? { error: res.statusText }, { status: res.status });
} catch (error) {
logError('Timeline proxy error', error);
return NextResponse.json({ error: 'Service unavailable' }, { status: 502 });
}
}

View File

@ -31,6 +31,7 @@ import {
MessageSquare,
Megaphone,
ClipboardList,
Workflow,
Beaker,
Crosshair,
Bug,
@ -81,6 +82,7 @@ const navItems = [
{ href: '/flags', label: 'Feature Flags', icon: Settings },
{ href: '/audit', label: 'Audit Log', icon: ScrollText },
{ href: '/actiontrail', label: 'ActionTrail', icon: Crosshair },
{ href: '/timeline', label: 'Timeline', icon: Workflow },
{ href: '/organizations', label: 'Organizations', icon: Building2 },
{ href: '/support', label: 'Support Cases', icon: LifeBuoy },
{ href: '/ai-budgets', label: 'AI Budgets', icon: Coins },