Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
168 changes: 87 additions & 81 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
"@aws-sdk/client-textract": "^3.901.0",
"@aws-sdk/util-stream-node": "^3.370.0",
"@azure/search-documents": "^12.2.0",
"@cognipeer/agent-sdk": "^0.9.3",
"@cognipeer/agent-sdk": "^0.9.4",
"@cognipeer/to-markdown": "^3.1.0",
"@elastic/elasticsearch": "^8.19.1",
"@fastify/websocket": "^11.2.0",
Expand Down
211 changes: 211 additions & 0 deletions src/app/dashboard/tracing/agents/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
'use client';

import { useCallback, useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Button, Text, Tooltip } from '@mantine/core';
import { IconBook, IconEye, IconRobot } from '@tabler/icons-react';
import PageContainer, { PageHeader } from '@/components/common/ui/PageContainer';
import DataGrid, { type DataGridColumn } from '@/components/common/ui/DataGrid';
import StatusBadge from '@/components/common/ui/StatusBadge';
import { formatNumber, formatRelativeTime } from '@/lib/utils/tracingUtils';
import { useDocsDrawer } from '@/components/docs/DocsDrawerContext';

interface AgentRecord {
name: string;
sessionsCount: number;
latestSessionAt: string | null;
latestStatus: string | null;
}

interface AgentsResponse {
agents: AgentRecord[];
total: number;
}

// The directory endpoint is a lightweight "top N by recent activity" list
// (sub-nav/pickers use it too), not a paginated one — a generous cap covers
// realistic per-tenant agent counts without adding server-side pagination.
const DIRECTORY_LIMIT = 200;

export default function TracingAgentsPage() {
const router = useRouter();
const { openDocs } = useDocsDrawer();
const [agents, setAgents] = useState<AgentRecord[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [query, setQuery] = useState('');
const [statusFilter, setStatusFilter] = useState('all');

const fetchAgents = useCallback(
async (isRefresh = false, signal?: AbortSignal) => {
try {
if (isRefresh) setRefreshing(true);
else setLoading(true);

const response = await fetch(
`/api/tracing/agents?limit=${DIRECTORY_LIMIT}`,
{ signal, cache: 'no-store' },
);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || 'Failed to fetch agents');
}
const data: AgentsResponse = await response.json();
setAgents(data.agents || []);
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') return;
console.error('Failed to load agents:', error);
setAgents([]);
} finally {
if (!signal?.aborted) {
setLoading(false);
setRefreshing(false);
}
}
},
[],
);

useEffect(() => {
const controller = new AbortController();
void fetchAgents(false, controller.signal);
return () => controller.abort();
}, [fetchAgents]);

const filteredAgents = useMemo(() => {
const q = query.trim().toLowerCase();
return agents.filter((a) => {
if (q && !a.name.toLowerCase().includes(q)) return false;
if (statusFilter !== 'all' && (a.latestStatus || 'unknown') !== statusFilter) return false;
return true;
});
}, [agents, query, statusFilter]);

const columns: DataGridColumn<AgentRecord>[] = [
{
key: 'name',
label: 'Agent',
render: (a) => (
<Tooltip label={a.name} withArrow>
<span
className="ds-row ds-gap-xs"
style={{ fontSize: 12.5, color: 'var(--ds-text)' }}
>
<IconRobot size={14} stroke={1.7} />
{a.name}
</span>
</Tooltip>
),
},
{
key: 'sessions',
label: 'Sessions',
align: 'right',
render: (a) => (
<span
className="ds-mono"
style={{ fontSize: 12.5, fontVariantNumeric: 'tabular-nums' }}
>
{formatNumber(a.sessionsCount)}
</span>
),
},
{
key: 'status',
label: 'Latest status',
render: (a) => (
<StatusBadge
status={
a.latestStatus === 'success'
? 'ok'
: a.latestStatus === 'error'
? 'err'
: a.latestStatus === 'running'
? 'info'
: 'paused'
}
label={(a.latestStatus || 'unknown').toUpperCase()}
/>
),
},
{
key: 'latest',
label: 'Last session',
render: (a) => (
<span className="ds-faint" style={{ fontSize: 12.5 }}>
{a.latestSessionAt ? formatRelativeTime(a.latestSessionAt) : '—'}
</span>
),
},
];

return (
<PageContainer>
<PageHeader
eyebrow="Operate · Tracing"
title="Agents"
subtitle="Every agent that has reported a tracing session in the last 30 days, ranked by recent activity."
actions={
<Button
variant="default"
size="sm"
leftSection={<IconBook size={14} stroke={1.7} />}
onClick={() => openDocs('api-tracing')}
>
Docs
</Button>
}
/>

<DataGrid<AgentRecord>
records={filteredAgents}
loading={loading}
rowKey={(a) => a.name}
onRowClick={(a) =>
router.push(`/dashboard/tracing/agents/${encodeURIComponent(a.name)}`)
}
columns={columns}
search={{
value: query,
onChange: setQuery,
placeholder: 'Search by agent name…',
}}
filters={[
{
value: statusFilter,
onChange: setStatusFilter,
ariaLabel: 'Filter by latest status',
width: 160,
options: [
{ value: 'all', label: 'All statuses' },
{ value: 'success', label: 'Success' },
{ value: 'error', label: 'Error' },
{ value: 'running', label: 'Running' },
],
},
]}
onRefresh={() => void fetchAgents(true)}
refreshing={refreshing}
empty={{
icon: <IconRobot size={26} stroke={1.7} />,
title: 'No agents found',
description: 'Agents will appear once they report their first tracing session.',
}}
footerLeft={
<Text size="xs" c="dimmed">
{formatNumber(filteredAgents.length)} of {formatNumber(agents.length)} agents
</Text>
}
rowActions={(a) => [
{
id: 'view',
label: 'View agent',
icon: <IconEye size={14} />,
onClick: () =>
router.push(`/dashboard/tracing/agents/${encodeURIComponent(a.name)}`),
},
]}
/>
</PageContainer>
);
}
92 changes: 7 additions & 85 deletions src/components/layout/launcher/ServiceSubNav.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
'use client';

import { useEffect, useState } from 'react';
import { useTranslations } from '@/lib/i18n';
import type { DashboardServiceDefinition } from '@/lib/utils/dashboardServices';
import { ActionIcon, Text, Tooltip } from '@mantine/core';
Expand Down Expand Up @@ -260,6 +259,13 @@ export const SUBNAV_CONFIG: Record<string, SubNavItem[]> = {
icon: IconMessage,
matcher: (p) => p.startsWith('/dashboard/tracing/threads'),
},
{
id: 'agents',
label: 'Agents',
href: '/dashboard/tracing/agents',
icon: IconRobot,
matcher: (p) => p.startsWith('/dashboard/tracing/agents'),
},
],
cost: [
{
Expand Down Expand Up @@ -617,63 +623,6 @@ export const SUBNAV_CONFIG: Record<string, SubNavItem[]> = {
],
};

// ── Dynamic agent directory for the Tracing sub-nav ────────────────────────
// The observability landing page intentionally has NO agent list — agents
// live here in the left menu instead. Fetched once per page load (module
// cache, 5-minute TTL) so navigating between tracing pages doesn't refetch.

interface TracingNavAgent {
name: string;
sessionsCount: number;
}

const TRACING_AGENT_NAV_CAP = 20;
const TRACING_AGENT_CACHE_TTL_MS = 5 * 60 * 1000;
let tracingAgentCache: { at: number; agents: TracingNavAgent[] } | null = null;
let tracingAgentInflight: Promise<TracingNavAgent[]> | null = null;

async function fetchTracingNavAgents(): Promise<TracingNavAgent[]> {
if (tracingAgentCache && Date.now() - tracingAgentCache.at < TRACING_AGENT_CACHE_TTL_MS) {
return tracingAgentCache.agents;
}
if (!tracingAgentInflight) {
tracingAgentInflight = (async () => {
try {
const res = await fetch(`/api/tracing/agents?limit=${TRACING_AGENT_NAV_CAP}`, {
cache: 'no-store',
});
if (!res.ok) return tracingAgentCache?.agents ?? [];
const payload = (await res.json()) as { agents?: TracingNavAgent[] };
const agents = (payload.agents ?? []).filter((a) => a.name);
tracingAgentCache = { at: Date.now(), agents };
return agents;
} catch {
return tracingAgentCache?.agents ?? [];
} finally {
tracingAgentInflight = null;
}
})();
}
return tracingAgentInflight;
}

function useTracingNavAgents(enabled: boolean): TracingNavAgent[] {
const [agents, setAgents] = useState<TracingNavAgent[]>(
() => tracingAgentCache?.agents ?? [],
);
useEffect(() => {
if (!enabled) return;
let alive = true;
void fetchTracingNavAgents().then((result) => {
if (alive) setAgents(result);
});
return () => {
alive = false;
};
}, [enabled]);
return agents;
}

interface ServiceSubNavProps {
service: DashboardServiceDefinition;
pathname: string;
Expand All @@ -700,7 +649,6 @@ export default function ServiceSubNav({
const tNav = useTranslations('navigation');
const ServiceIcon = service.icon;
const navItems = items ?? SUBNAV_CONFIG[service.id] ?? [];
const tracingAgents = useTracingNavAgents(service.id === 'tracing');

return (
<aside className={classes.subnav}>
Expand Down Expand Up @@ -756,32 +704,6 @@ export default function ServiceSubNav({
);
})}

{service.id === 'tracing' && tracingAgents.length > 0 ? (
<>
<div className={classes.subnavSectionTitle}>Agents</div>
{tracingAgents.map((agent) => {
const href = `/dashboard/tracing/agents/${encodeURIComponent(agent.name)}`;
const active = pathname === href
|| pathname === `/dashboard/tracing/agents/${agent.name}`;
return (
<button
key={agent.name}
type="button"
className={`${classes.subnavItem} ${active ? classes.subnavItemActive : ''}`}
onClick={() => router.push(href)}
aria-current={active ? 'page' : undefined}
title={agent.name}>
<IconRobot size={15} stroke={1.7} />
<span className={classes.subnavItemLabel}>{agent.name}</span>
<span className={classes.subnavBadge}>
{agent.sessionsCount.toLocaleString()}
</span>
</button>
);
})}
</>
) : null}

<div className={classes.subnavSectionTitle}>Resources</div>
<button
type="button"
Expand Down
14 changes: 12 additions & 2 deletions src/lib/database/mongodb/usage.mixin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ export function UsageRollupMixin<TBase extends Constructor<MongoDBProviderBase>>
// v2 adds the agentKey dimension — drop the pre-agent unique index,
// which would otherwise reject rows differing only in agentKey.
await col.dropIndex('uniq_usage_daily_dims').catch(() => undefined);
// v3 adds the metadataKey dimension (free-form caller-supplied
// attribution tags) — drop v2, which would otherwise reject rows
// differing only in metadataKey.
await col.dropIndex('uniq_usage_daily_dims_v2').catch(() => undefined);
await col.createIndex(
{
tenantId: 1,
Expand All @@ -49,9 +53,10 @@ export function UsageRollupMixin<TBase extends Constructor<MongoDBProviderBase>>
service: 1,
refKey: 1,
agentKey: 1,
metadataKey: 1,
day: 1,
},
{ unique: true, name: 'uniq_usage_daily_dims_v2' },
{ unique: true, name: 'uniq_usage_daily_dims_v3' },
);
await col.createIndex(
{ tenantId: 1, day: -1 },
Expand Down Expand Up @@ -98,11 +103,16 @@ export function UsageRollupMixin<TBase extends Constructor<MongoDBProviderBase>>
service: row.service,
refKey: row.refKey,
agentKey: row.agentKey ?? '',
metadataKey: row.metadataKey ?? '',
day: row.day,
},
update: {
$inc: inc,
$set: { updatedAt: new Date() },
// Same metadataKey ⇒ same metadata object by construction
// (metadataKey is its canonical serialization), so overwriting
// on every increment is safe and keeps a stale object from
// lingering if the shape changed upstream.
$set: { updatedAt: new Date(), metadata: row.metadata ?? {} },
$setOnInsert: {
actorType: row.actorType,
// Real Date for the reports engine's range filters/bucketing.
Expand Down
Loading
Loading