feat(admin-web): harden timeline review console

This commit is contained in:
Saravana Achu Mac 2026-04-04 11:40:56 -07:00
parent 977d41486a
commit d328c7ad68
7 changed files with 312 additions and 89 deletions

View File

@ -0,0 +1,77 @@
import { describe, expect, it } from 'vitest';
import {
buildTimelineGroups,
getProductLabel,
getTimelineStats,
type TimelineItem,
} from '@/lib/timeline-view';
const baseItems: TimelineItem[] = [
{
itemId: 'timeline_1',
occurredAt: '2026-04-04T10:02:00.000Z',
eventName: 'artifact.created',
productId: 'notelett',
title: 'Note created',
summary: 'Latest note',
artifactRefs: ['art_note_1'],
relatedEventIds: ['evt_2'],
actorType: 'user',
visibility: 'private',
correlationId: 'corr_shared',
},
{
itemId: 'timeline_2',
occurredAt: '2026-04-04T10:01:00.000Z',
eventName: 'capture.transcript.created',
productId: 'lysnrai',
title: 'Transcript captured',
summary: null,
artifactRefs: ['art_transcript_1'],
relatedEventIds: [],
actorType: 'user',
visibility: 'private',
correlationId: 'corr_shared',
},
{
itemId: 'timeline_3',
occurredAt: '2026-04-04T09:59:00.000Z',
eventName: 'memory.entry.created',
productId: 'mindlyst',
title: 'Memory candidate proposed',
summary: null,
artifactRefs: [],
relatedEventIds: [],
actorType: 'agent',
visibility: 'private',
correlationId: null,
},
];
describe('timeline view helpers', () => {
it('groups correlation-linked items together and sorts them newest-first', () => {
const groups = buildTimelineGroups(baseItems);
expect(groups).toHaveLength(2);
expect(groups[0]).toMatchObject({
correlationId: 'corr_shared',
latestOccurredAt: '2026-04-04T10:02:00.000Z',
productIds: ['notelett', 'lysnrai'],
});
expect(groups[0]?.items.map(item => item.itemId)).toEqual(['timeline_1', 'timeline_2']);
});
it('computes timeline stats over grouped and ungrouped items', () => {
expect(getTimelineStats(baseItems)).toEqual({
uniqueProducts: 3,
uniqueCorrelations: 1,
withArtifacts: 2,
groupedChains: 1,
});
});
it('returns readable labels for known products', () => {
expect(getProductLabel('lysnrai')).toBe('LysnrAI');
expect(getProductLabel('custom-tool')).toBe('custom-tool');
});
});

View File

@ -68,6 +68,29 @@ describe('GET /api/timeline', () => {
);
});
it('allows an explicit product override for internal timeline review', async () => {
mockGetCurrentUser.mockResolvedValue({ id: 'usr_admin', role: 'admin' });
const fetchMock = vi.fn().mockResolvedValue({
status: 200,
json: async () => ({ items: [], count: 0 }),
});
vi.stubGlobal('fetch', fetchMock);
const res = await callTimeline('?productId=flowmonk&limit=10');
expect(res.status).toBe(200);
expect(fetchMock).toHaveBeenCalledWith(
'http://localhost:4003/api/timeline?limit=10',
expect.objectContaining({
method: 'GET',
headers: expect.objectContaining({
'x-user-id': 'usr_admin',
'x-product-id': 'flowmonk',
}),
})
);
});
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')));

View File

@ -1,7 +1,7 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Activity, Filter, Link2, RefreshCw, Search } from 'lucide-react';
import { Activity, Filter, GitBranch, Link2, RefreshCw, Search } from 'lucide-react';
import { createProxyFetch } from '@/lib/proxy-fetch';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@ -14,20 +14,13 @@ import {
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;
};
import {
buildTimelineGroups,
getProductLabel,
getTimelineStats,
TIMELINE_PRODUCT_OPTIONS,
type TimelineItem,
} from '@/lib/timeline-view';
const apiFetch = createProxyFetch('/api/timeline');
@ -60,6 +53,7 @@ export default function TimelinePage() {
const [correlationId, setCorrelationId] = useState('');
const [eventName, setEventName] = useState('all');
const [artifactId, setArtifactId] = useState('');
const [productId, setProductId] = useState('current');
const [limit, setLimit] = useState('50');
const loadData = useCallback(async () => {
@ -69,32 +63,21 @@ export default function TimelinePage() {
if (correlationId.trim()) params.set('correlationId', correlationId.trim());
if (artifactId.trim()) params.set('artifactId', artifactId.trim());
if (eventName !== 'all') params.set('eventName', eventName);
if (productId !== 'current') params.set('productId', productId);
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]);
}, [artifactId, correlationId, eventName, limit, productId, 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]);
const stats = useMemo(() => getTimelineStats(items), [items]);
const groups = useMemo(() => buildTimelineGroups(items), [items]);
return (
<div className="space-y-6">
@ -102,7 +85,8 @@ export default function TimelinePage() {
<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
Canonical timeline stream for the selected product scope, with correlation-chain
grouping
</p>
</div>
<Button variant="outline" onClick={() => void loadData()}>
@ -129,12 +113,10 @@ export default function TimelinePage() {
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Correlations
</CardTitle>
<CardTitle className="text-sm font-medium text-muted-foreground">Chains</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.uniqueCorrelations}</div>
<div className="text-2xl font-bold">{stats.groupedChains}</div>
</CardContent>
</Card>
<Card>
@ -156,7 +138,7 @@ export default function TimelinePage() {
Filters
</CardTitle>
</CardHeader>
<CardContent className="grid gap-3 md:grid-cols-5">
<CardContent className="grid gap-3 md:grid-cols-6">
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
@ -176,6 +158,18 @@ export default function TimelinePage() {
value={artifactId}
onChange={event => setArtifactId(event.target.value)}
/>
<Select value={productId} onValueChange={setProductId}>
<SelectTrigger>
<SelectValue placeholder="Product scope" />
</SelectTrigger>
<SelectContent>
{TIMELINE_PRODUCT_OPTIONS.map(option => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={eventName} onValueChange={setEventName}>
<SelectTrigger>
<SelectValue placeholder="Event type" />
@ -212,67 +206,100 @@ export default function TimelinePage() {
</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">
{groups.map(group => (
<div key={group.groupId} className="space-y-4 p-4">
<div className="flex flex-col gap-3 rounded-lg border bg-muted/20 p-3 md:flex-row md:items-start md:justify-between">
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<h2 className="font-semibold">{item.title}</h2>
<Badge className={badgeClassForActor(item.actorType)}>
{item.actorType}
<Badge variant="secondary" className="gap-1">
<GitBranch className="h-3 w-3" />
{group.items.length > 1 ? `${group.items.length} events` : 'single event'}
</Badge>
<Badge variant="outline">{item.productId}</Badge>
<Badge variant="outline">{item.eventName}</Badge>
{group.productIds.map(product => (
<Badge key={product} variant="outline">
{getProductLabel(product)}
</Badge>
))}
</div>
<div className="text-sm text-muted-foreground">
{group.correlationId
? `Correlation ${group.correlationId}`
: 'Standalone item without correlation ID'}
</div>
{item.summary && (
<p className="text-sm text-muted-foreground">{item.summary}</p>
)}
</div>
<div className="text-xs text-muted-foreground">
{formatDate(item.occurredAt)}
Latest activity {formatDate(group.latestOccurredAt)}
</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 className="space-y-3">
{group.items.map(item => (
<div key={item.itemId} className="space-y-3 rounded-lg border 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">{getProductLabel(item.productId)}</Badge>
<Badge variant="outline">{item.eventName}</Badge>
</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}
{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>
))}
</div>
</div>
))}

View File

@ -13,12 +13,18 @@ export async function GET(req: NextRequest) {
const caller = await getCurrentUserFromRequest(req);
if (!caller) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const qs = new URL(req.url).search;
const url = new URL(req.url);
const productOverride = url.searchParams.get('productId')?.trim();
if (productOverride) {
url.searchParams.delete('productId');
}
const qs = 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',
'x-product-id':
productOverride || req.headers.get('x-product-id') || process.env.PRODUCT_ID || 'lysnrai',
};
const res = await fetch(`${PLATFORM_URL}/api/timeline${qs}`, {

View File

@ -0,0 +1,85 @@
export 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;
};
export type TimelineGroup = {
groupId: string;
correlationId: string | null;
items: TimelineItem[];
productIds: string[];
latestOccurredAt: string;
};
export const TIMELINE_PRODUCT_OPTIONS = [
{ value: 'current', label: 'Current Admin Product' },
{ value: 'lysnrai', label: 'LysnrAI' },
{ value: 'notelett', label: 'NoteLett' },
{ value: 'mindlyst', label: 'MindLyst' },
{ value: 'flowmonk', label: 'FlowMonk' },
{ value: 'chronomind', label: 'ChronoMind' },
{ value: 'efforise', label: 'EffoRise' },
{ value: 'actiontrail', label: 'ActionTrail' },
{ value: 'cowork', label: 'Cowork' },
] as const;
function compareDescendingIso(a: string, b: string) {
return new Date(b).getTime() - new Date(a).getTime();
}
export function getTimelineStats(items: TimelineItem[]) {
return {
uniqueProducts: new Set(items.map(item => item.productId)).size,
uniqueCorrelations: new Set(
items
.map(item => item.correlationId)
.filter((value): value is string => typeof value === 'string' && value.length > 0)
).size,
withArtifacts: items.filter(item => item.artifactRefs.length > 0).length,
groupedChains: buildTimelineGroups(items).filter(group => group.items.length > 1).length,
};
}
export function buildTimelineGroups(items: TimelineItem[]): TimelineGroup[] {
const groups = new Map<string, TimelineItem[]>();
for (const item of items) {
const groupKey = item.correlationId?.trim()
? `corr:${item.correlationId}`
: `item:${item.itemId}`;
const bucket = groups.get(groupKey);
if (bucket) {
bucket.push(item);
} else {
groups.set(groupKey, [item]);
}
}
return [...groups.entries()]
.map(([groupId, groupItems]) => {
const itemsSorted = [...groupItems].sort((left, right) =>
compareDescendingIso(left.occurredAt, right.occurredAt)
);
return {
groupId,
correlationId: itemsSorted[0]?.correlationId?.trim() || null,
items: itemsSorted,
productIds: [...new Set(itemsSorted.map(item => item.productId))],
latestOccurredAt: itemsSorted[0]?.occurredAt ?? new Date(0).toISOString(),
};
})
.sort((left, right) => compareDescendingIso(left.latestOccurredAt, right.latestOccurredAt));
}
export function getProductLabel(productId: string) {
return TIMELINE_PRODUCT_OPTIONS.find(option => option.value === productId)?.label ?? productId;
}

View File

@ -211,6 +211,7 @@ These should be resolved before claiming the ecosystem docs are fully implementa
Status note:
- admin-web now exposes `/timeline` over the shared platform timeline API
- the first hosted internal timeline UI supports user, artifact, correlation, and event-name filtering
- the timeline hardening pass adds explicit product-scope review, correlation-chain grouping, and focused helper tests
---

View File

@ -32,6 +32,8 @@ This phase now establishes the shared data contract, the first persisted service
- `dashboards/admin-web/src/app/api/timeline/route.ts`
- `dashboards/admin-web/src/app/(dashboard)/timeline/page.tsx`
- `dashboards/admin-web/src/__tests__/timeline.test.ts`
- `dashboards/admin-web/src/__tests__/timeline-view.test.ts`
- `dashboards/admin-web/src/lib/timeline-view.ts`
---
@ -58,6 +60,8 @@ This is enough to render:
- `cd learning_ai_common_plat && pnpm --filter @bytelyst/events build`
- `cd learning_ai_common_plat && pnpm --filter @lysnrai/platform-service exec vitest run src/modules/timeline/routes.test.ts src/server.test.ts`
- `cd learning_ai_common_plat && pnpm --filter @lysnrai/platform-service exec tsc --noEmit`
- `cd learning_ai_common_plat/dashboards/admin-web && npm run test -- src/__tests__/timeline.test.ts src/__tests__/timeline-view.test.ts`
- `cd learning_ai_common_plat/dashboards/admin-web && npm run typecheck`
Observed baseline:
@ -73,7 +77,7 @@ Observed baseline:
- platform-service now exposes `GET /api/timeline` for unified timeline query
- non-admin JWT callers are scoped to their own timeline while admin and API-key callers can query broader product timelines
- admin-web now exposes the first hosted internal timeline UI at `/timeline`
- the hosted UI supports filtering by user, correlation, artifact, event name, and page size
- the hosted UI now supports explicit product scope selection, correlation-chain grouping, user/artifact/event filters, and focused timeline helper tests
---