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 () => {
|
||||
mockGetCurrentUser.mockResolvedValue({ id: 'usr_admin', role: 'admin' });
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('boom')));
|
||||
|
||||
@ -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>
|
||||
))}
|
||||
|
||||
@ -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}`, {
|
||||
|
||||
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:
|
||||
- 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
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
---
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user