diff --git a/dashboards/admin-web/src/__tests__/timeline.test.ts b/dashboards/admin-web/src/__tests__/timeline.test.ts new file mode 100644 index 00000000..d367db73 --- /dev/null +++ b/dashboards/admin-web/src/__tests__/timeline.test.ts @@ -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(); + }); +}); diff --git a/dashboards/admin-web/src/app/(dashboard)/timeline/page.tsx b/dashboards/admin-web/src/app/(dashboard)/timeline/page.tsx new file mode 100644 index 00000000..3e0ed529 --- /dev/null +++ b/dashboards/admin-web/src/app/(dashboard)/timeline/page.tsx @@ -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([]); + 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 ( +
+
+
+

Ecosystem Timeline

+

+ Unified cross-product activity stream from the canonical timeline service +

+
+ +
+ +
+ + + Items + + +
{items.length}
+
+
+ + + Products + + +
{stats.uniqueProducts}
+
+
+ + + + Correlations + + + +
{stats.uniqueCorrelations}
+
+
+ + + + With Artifacts + + + +
{stats.withArtifacts}
+
+
+
+ + + + + + Filters + + + +
+ + setUserId(event.target.value)} + className="pl-9" + /> +
+ setCorrelationId(event.target.value)} + /> + setArtifactId(event.target.value)} + /> + + +
+
+ + + + {loading ? ( +
Loading timeline...
+ ) : items.length === 0 ? ( +
+ + No timeline items matched the current filters. +
+ ) : ( +
+ {items.map(item => ( +
+
+
+
+

{item.title}

+ + {item.actorType} + + {item.productId} + {item.eventName} +
+ {item.summary && ( +

{item.summary}

+ )} +
+
+ {formatDate(item.occurredAt)} +
+
+ +
+
+
+ Correlation +
+
{item.correlationId || '—'}
+
+
+
+ Artifact Refs +
+
+ {item.artifactRefs.length === 0 ? ( +
None
+ ) : ( + item.artifactRefs.map(ref => ( +
+ {ref} +
+ )) + )} +
+
+
+
+ Related Events +
+
+ {item.relatedEventIds.length === 0 ? ( +
None
+ ) : ( + item.relatedEventIds.map(ref => ( +
+ + {ref} +
+ )) + )} +
+
+
+
+ ))} +
+ )} +
+
+
+ ); +} diff --git a/dashboards/admin-web/src/app/api/timeline/route.ts b/dashboards/admin-web/src/app/api/timeline/route.ts new file mode 100644 index 00000000..cedb4fe0 --- /dev/null +++ b/dashboards/admin-web/src/app/api/timeline/route.ts @@ -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 = { + '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 }); + } +} diff --git a/dashboards/admin-web/src/components/sidebar-nav.tsx b/dashboards/admin-web/src/components/sidebar-nav.tsx index 007727f6..d67ab61c 100644 --- a/dashboards/admin-web/src/components/sidebar-nav.tsx +++ b/dashboards/admin-web/src/components/sidebar-nav.tsx @@ -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 },