feat(admin-web): harden timeline review console
This commit is contained in:
parent
977d41486a
commit
d328c7ad68
77
dashboards/admin-web/src/__tests__/timeline-view.test.ts
Normal file
77
dashboards/admin-web/src/__tests__/timeline-view.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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 () => {
|
it('returns 502 when platform-service proxying fails', async () => {
|
||||||
mockGetCurrentUser.mockResolvedValue({ id: 'usr_admin', role: 'admin' });
|
mockGetCurrentUser.mockResolvedValue({ id: 'usr_admin', role: 'admin' });
|
||||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('boom')));
|
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('boom')));
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
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 { createProxyFetch } from '@/lib/proxy-fetch';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@ -14,20 +14,13 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
|
import {
|
||||||
type TimelineItem = {
|
buildTimelineGroups,
|
||||||
itemId: string;
|
getProductLabel,
|
||||||
occurredAt: string;
|
getTimelineStats,
|
||||||
eventName: string;
|
TIMELINE_PRODUCT_OPTIONS,
|
||||||
productId: string;
|
type TimelineItem,
|
||||||
title: string;
|
} from '@/lib/timeline-view';
|
||||||
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');
|
const apiFetch = createProxyFetch('/api/timeline');
|
||||||
|
|
||||||
@ -60,6 +53,7 @@ export default function TimelinePage() {
|
|||||||
const [correlationId, setCorrelationId] = useState('');
|
const [correlationId, setCorrelationId] = useState('');
|
||||||
const [eventName, setEventName] = useState('all');
|
const [eventName, setEventName] = useState('all');
|
||||||
const [artifactId, setArtifactId] = useState('');
|
const [artifactId, setArtifactId] = useState('');
|
||||||
|
const [productId, setProductId] = useState('current');
|
||||||
const [limit, setLimit] = useState('50');
|
const [limit, setLimit] = useState('50');
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
@ -69,32 +63,21 @@ export default function TimelinePage() {
|
|||||||
if (correlationId.trim()) params.set('correlationId', correlationId.trim());
|
if (correlationId.trim()) params.set('correlationId', correlationId.trim());
|
||||||
if (artifactId.trim()) params.set('artifactId', artifactId.trim());
|
if (artifactId.trim()) params.set('artifactId', artifactId.trim());
|
||||||
if (eventName !== 'all') params.set('eventName', eventName);
|
if (eventName !== 'all') params.set('eventName', eventName);
|
||||||
|
if (productId !== 'current') params.set('productId', productId);
|
||||||
params.set('limit', limit);
|
params.set('limit', limit);
|
||||||
|
|
||||||
const query = params.toString();
|
const query = params.toString();
|
||||||
const data = await apiFetch(query ? `?${query}` : '');
|
const data = await apiFetch(query ? `?${query}` : '');
|
||||||
setItems(Array.isArray(data?.items) ? data.items : []);
|
setItems(Array.isArray(data?.items) ? data.items : []);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}, [artifactId, correlationId, eventName, limit, userId]);
|
}, [artifactId, correlationId, eventName, limit, productId, userId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadData();
|
void loadData();
|
||||||
}, [loadData]);
|
}, [loadData]);
|
||||||
|
|
||||||
const stats = useMemo(() => {
|
const stats = useMemo(() => getTimelineStats(items), [items]);
|
||||||
const uniqueProducts = new Set(items.map(item => item.productId)).size;
|
const groups = useMemo(() => buildTimelineGroups(items), [items]);
|
||||||
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@ -102,7 +85,8 @@ export default function TimelinePage() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">Ecosystem Timeline</h1>
|
<h1 className="text-2xl font-bold">Ecosystem Timeline</h1>
|
||||||
<p className="text-sm text-muted-foreground">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" onClick={() => void loadData()}>
|
<Button variant="outline" onClick={() => void loadData()}>
|
||||||
@ -129,12 +113,10 @@ export default function TimelinePage() {
|
|||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
<CardTitle className="text-sm font-medium text-muted-foreground">Chains</CardTitle>
|
||||||
Correlations
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">{stats.uniqueCorrelations}</div>
|
<div className="text-2xl font-bold">{stats.groupedChains}</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
@ -156,7 +138,7 @@ export default function TimelinePage() {
|
|||||||
Filters
|
Filters
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid gap-3 md:grid-cols-5">
|
<CardContent className="grid gap-3 md:grid-cols-6">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
@ -176,6 +158,18 @@ export default function TimelinePage() {
|
|||||||
value={artifactId}
|
value={artifactId}
|
||||||
onChange={event => setArtifactId(event.target.value)}
|
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}>
|
<Select value={eventName} onValueChange={setEventName}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Event type" />
|
<SelectValue placeholder="Event type" />
|
||||||
@ -212,67 +206,100 @@ export default function TimelinePage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="divide-y">
|
<div className="divide-y">
|
||||||
{items.map(item => (
|
{groups.map(group => (
|
||||||
<div key={item.itemId} className="space-y-3 p-4">
|
<div key={group.groupId} className="space-y-4 p-4">
|
||||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
<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-1">
|
<div className="space-y-2">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<h2 className="font-semibold">{item.title}</h2>
|
<Badge variant="secondary" className="gap-1">
|
||||||
<Badge className={badgeClassForActor(item.actorType)}>
|
<GitBranch className="h-3 w-3" />
|
||||||
{item.actorType}
|
{group.items.length > 1 ? `${group.items.length} events` : 'single event'}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge variant="outline">{item.productId}</Badge>
|
{group.productIds.map(product => (
|
||||||
<Badge variant="outline">{item.eventName}</Badge>
|
<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>
|
</div>
|
||||||
{item.summary && (
|
|
||||||
<p className="text-sm text-muted-foreground">{item.summary}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
{formatDate(item.occurredAt)}
|
Latest activity {formatDate(group.latestOccurredAt)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-3 text-sm md:grid-cols-3">
|
<div className="space-y-3">
|
||||||
<div>
|
{group.items.map(item => (
|
||||||
<div className="mb-1 text-xs uppercase tracking-wide text-muted-foreground">
|
<div key={item.itemId} className="space-y-3 rounded-lg border p-4">
|
||||||
Correlation
|
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||||
</div>
|
<div className="space-y-1">
|
||||||
<div className="font-mono text-xs">{item.correlationId || '—'}</div>
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
</div>
|
<h2 className="font-semibold">{item.title}</h2>
|
||||||
<div>
|
<Badge className={badgeClassForActor(item.actorType)}>
|
||||||
<div className="mb-1 text-xs uppercase tracking-wide text-muted-foreground">
|
{item.actorType}
|
||||||
Artifact Refs
|
</Badge>
|
||||||
</div>
|
<Badge variant="outline">{getProductLabel(item.productId)}</Badge>
|
||||||
<div className="space-y-1">
|
<Badge variant="outline">{item.eventName}</Badge>
|
||||||
{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>
|
||||||
))
|
{item.summary && (
|
||||||
)}
|
<p className="text-sm text-muted-foreground">{item.summary}</p>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="text-xs text-muted-foreground">
|
||||||
<div className="mb-1 text-xs uppercase tracking-wide text-muted-foreground">
|
{formatDate(item.occurredAt)}
|
||||||
Related Events
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
|
||||||
{item.relatedEventIds.length === 0 ? (
|
<div className="grid gap-3 text-sm md:grid-cols-3">
|
||||||
<div className="text-xs text-muted-foreground">None</div>
|
<div>
|
||||||
) : (
|
<div className="mb-1 text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
item.relatedEventIds.map(ref => (
|
Correlation
|
||||||
<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 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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -13,12 +13,18 @@ export async function GET(req: NextRequest) {
|
|||||||
const caller = await getCurrentUserFromRequest(req);
|
const caller = await getCurrentUserFromRequest(req);
|
||||||
if (!caller) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
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> = {
|
const headers: Record<string, string> = {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'x-request-id': req.headers.get('x-request-id') || crypto.randomUUID(),
|
'x-request-id': req.headers.get('x-request-id') || crypto.randomUUID(),
|
||||||
'x-user-id': caller.id,
|
'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}`, {
|
const res = await fetch(`${PLATFORM_URL}/api/timeline${qs}`, {
|
||||||
|
|||||||
85
dashboards/admin-web/src/lib/timeline-view.ts
Normal file
85
dashboards/admin-web/src/lib/timeline-view.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -211,6 +211,7 @@ These should be resolved before claiming the ecosystem docs are fully implementa
|
|||||||
Status note:
|
Status note:
|
||||||
- admin-web now exposes `/timeline` over the shared platform timeline API
|
- 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 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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -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/api/timeline/route.ts`
|
||||||
- `dashboards/admin-web/src/app/(dashboard)/timeline/page.tsx`
|
- `dashboards/admin-web/src/app/(dashboard)/timeline/page.tsx`
|
||||||
- `dashboards/admin-web/src/__tests__/timeline.test.ts`
|
- `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 @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 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 && 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:
|
Observed baseline:
|
||||||
|
|
||||||
@ -73,7 +77,7 @@ Observed baseline:
|
|||||||
- platform-service now exposes `GET /api/timeline` for unified timeline query
|
- 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
|
- 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`
|
- 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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user