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,
|
MessageSquare,
|
||||||
Megaphone,
|
Megaphone,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
|
Workflow,
|
||||||
Beaker,
|
Beaker,
|
||||||
Crosshair,
|
Crosshair,
|
||||||
Bug,
|
Bug,
|
||||||
@ -81,6 +82,7 @@ const navItems = [
|
|||||||
{ href: '/flags', label: 'Feature Flags', icon: Settings },
|
{ href: '/flags', label: 'Feature Flags', icon: Settings },
|
||||||
{ href: '/audit', label: 'Audit Log', icon: ScrollText },
|
{ href: '/audit', label: 'Audit Log', icon: ScrollText },
|
||||||
{ href: '/actiontrail', label: 'ActionTrail', icon: Crosshair },
|
{ href: '/actiontrail', label: 'ActionTrail', icon: Crosshair },
|
||||||
|
{ href: '/timeline', label: 'Timeline', icon: Workflow },
|
||||||
{ href: '/organizations', label: 'Organizations', icon: Building2 },
|
{ href: '/organizations', label: 'Organizations', icon: Building2 },
|
||||||
{ href: '/support', label: 'Support Cases', icon: LifeBuoy },
|
{ href: '/support', label: 'Support Cases', icon: LifeBuoy },
|
||||||
{ href: '/ai-budgets', label: 'AI Budgets', icon: Coins },
|
{ href: '/ai-budgets', label: 'AI Budgets', icon: Coins },
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user