feat(admin-web): add ecosystem timeline dashboard
This commit is contained in:
parent
9d7817788e
commit
6f3a563edf
80
dashboards/admin-web/src/__tests__/timeline.test.ts
Normal file
80
dashboards/admin-web/src/__tests__/timeline.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
285
dashboards/admin-web/src/app/(dashboard)/timeline/page.tsx
Normal file
285
dashboards/admin-web/src/app/(dashboard)/timeline/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
dashboards/admin-web/src/app/api/timeline/route.ts
Normal file
35
dashboards/admin-web/src/app/api/timeline/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@ -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 },
|
||||
|
||||
Loading…
Reference in New Issue
Block a user