ui(deploy): Traces & Taps + Route Recording tabs with live banner

Ports the ConfigSubTab traces/taps and route recording content into
standalone tab components. Each write goes straight to live agents via
useUpdateApplicationConfig (apply='live'). A local draft state prevents
stale reads during the async flush. LiveBanner is rendered at the top of
both tabs to communicate the live-apply semantics.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-22 23:00:14 +02:00
parent b7c0a225f5
commit e96c3cd0cf
2 changed files with 308 additions and 0 deletions

View File

@@ -0,0 +1,121 @@
import { useMemo, useState } from 'react';
import { DataTable, EmptyState, MonoText, SectionHeader, Toggle } from '@cameleer/design-system';
import type { Column } from '@cameleer/design-system';
import { LiveBanner } from './LiveBanner';
import { useApplicationConfig, useUpdateApplicationConfig } from '../../../../api/queries/commands';
import { useCatalog } from '../../../../api/queries/catalog';
import { applyRouteRecordingUpdate } from '../../../../utils/config-draft-utils';
import type { CatalogApp, CatalogRoute } from '../../../../api/queries/catalog';
import type { App } from '../../../../api/queries/admin/apps';
import type { Environment } from '../../../../api/queries/admin/environments';
import sectionStyles from '../../../../styles/section-card.module.css';
import appsStyles from '../../AppsTab.module.css';
interface RouteRecordingRow {
id: string;
routeId: string;
recording: boolean;
}
interface Props {
app: App;
environment: Environment;
}
export function RouteRecordingTab({ app, environment }: Props) {
const envSlug = environment.slug;
const { data: agentConfig } = useApplicationConfig(app.slug, envSlug);
const updateAgentConfig = useUpdateApplicationConfig();
const { data: catalog } = useCatalog(envSlug);
// Local draft — each toggle is immediately flushed to live agents
const [recordingDraft, setRecordingDraft] = useState<Record<string, boolean> | null>(null);
// Use draft if in-flight, otherwise reflect server state
const effectiveRecording = recordingDraft ?? agentConfig?.routeRecording ?? {};
const appRoutes: CatalogRoute[] = useMemo(() => {
if (!catalog) return [];
const entry = (catalog as CatalogApp[]).find((e) => e.slug === app.slug);
return entry?.routes ?? [];
}, [catalog, app.slug]);
async function updateRouteRecording(routeId: string, recording: boolean) {
if (!agentConfig) return;
const next = applyRouteRecordingUpdate(effectiveRecording, routeId, recording);
setRecordingDraft(next);
try {
await updateAgentConfig.mutateAsync({
config: { ...agentConfig, routeRecording: next },
environment: envSlug,
apply: 'live',
});
} finally {
setRecordingDraft(null);
}
}
const routeRecordingRows: RouteRecordingRow[] = useMemo(
() =>
appRoutes.map((r) => ({
id: r.routeId,
routeId: r.routeId,
recording: effectiveRecording[r.routeId] !== false,
})),
// eslint-disable-next-line react-hooks/exhaustive-deps
[effectiveRecording, appRoutes],
);
const recordingCount = routeRecordingRows.filter((r) => r.recording).length;
const routeRecordingColumns: Column<RouteRecordingRow>[] = useMemo(
() => [
{
key: 'routeId',
header: 'Route',
render: (_v: unknown, row: RouteRecordingRow) => (
<MonoText size="xs">{row.routeId}</MonoText>
),
},
{
key: 'recording',
header: 'Recording',
width: '100px',
render: (_v: unknown, row: RouteRecordingRow) => (
<Toggle
checked={row.recording}
onChange={() => updateRouteRecording(row.routeId, !row.recording)}
disabled={updateAgentConfig.isPending}
/>
),
},
],
// eslint-disable-next-line react-hooks/exhaustive-deps
[updateAgentConfig.isPending, effectiveRecording],
);
return (
<div>
<LiveBanner />
<div className={sectionStyles.section}>
<SectionHeader>Route Recording</SectionHeader>
<span className={appsStyles.sectionSummary}>
{recordingCount} of {routeRecordingRows.length} routes recording
</span>
{routeRecordingRows.length > 0 ? (
<DataTable<RouteRecordingRow>
columns={routeRecordingColumns}
data={routeRecordingRows}
pageSize={20}
flush
/>
) : (
<EmptyState
title="No routes"
description="No routes found for this application. Routes appear once agents report data."
/>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,187 @@
import { useMemo, useState } from 'react';
import { Badge, DataTable, EmptyState, MonoText, SectionHeader } from '@cameleer/design-system';
import type { Column } from '@cameleer/design-system';
import { LiveBanner } from './LiveBanner';
import { useApplicationConfig, useUpdateApplicationConfig, useProcessorRouteMapping } from '../../../../api/queries/commands';
import type { TapDefinition } from '../../../../api/queries/commands';
import { useCatalog } from '../../../../api/queries/catalog';
import { applyTracedProcessorUpdate } from '../../../../utils/config-draft-utils';
import type { App } from '../../../../api/queries/admin/apps';
import type { Environment } from '../../../../api/queries/admin/environments';
import sectionStyles from '../../../../styles/section-card.module.css';
import appsStyles from '../../AppsTab.module.css';
interface TracedTapRow {
id: string;
processorId: string;
captureMode: string | null;
taps: TapDefinition[];
}
interface Props {
app: App;
environment: Environment;
}
export function TracesTapsTab({ app, environment }: Props) {
const envSlug = environment.slug;
const { data: agentConfig } = useApplicationConfig(app.slug, envSlug);
const updateAgentConfig = useUpdateApplicationConfig();
const { data: processorToRoute = {} } = useProcessorRouteMapping(app.slug, envSlug);
const { data: catalog } = useCatalog(envSlug);
// Local draft — each change is immediately flushed to live agents
const [tracedDraft, setTracedDraft] = useState<Record<string, string> | null>(null);
// Use draft if in-flight, otherwise reflect server state
const effectiveTraced = tracedDraft ?? agentConfig?.tracedProcessors ?? {};
async function updateTracedProcessor(processorId: string, mode: string) {
if (!agentConfig) return;
const next = applyTracedProcessorUpdate(effectiveTraced, processorId, mode);
setTracedDraft(next);
try {
await updateAgentConfig.mutateAsync({
config: { ...agentConfig, tracedProcessors: next },
environment: envSlug,
apply: 'live',
});
} finally {
setTracedDraft(null);
}
}
const tracedTapRows: TracedTapRow[] = useMemo(() => {
const taps = agentConfig?.taps ?? [];
const pids = new Set<string>([
...Object.keys(effectiveTraced),
...taps.map((t) => t.processorId),
]);
return Array.from(pids)
.sort()
.map((pid) => ({
id: pid,
processorId: pid,
captureMode: effectiveTraced[pid] ?? null,
taps: taps.filter((t) => t.processorId === pid),
}));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [effectiveTraced, agentConfig?.taps]);
const tracedCount = Object.keys(effectiveTraced).length;
const tapCount = agentConfig?.taps?.length ?? 0;
const tracedTapColumns: Column<TracedTapRow>[] = useMemo(
() => [
{
key: 'route' as any,
header: 'Route',
render: (_v: unknown, row: TracedTapRow) => {
const routeId = processorToRoute[row.processorId];
return routeId ? (
<span className={appsStyles.routeLabel}>{routeId}</span>
) : (
<span className={appsStyles.hint}>&mdash;</span>
);
},
},
{
key: 'processorId',
header: 'Processor',
render: (_v: unknown, row: TracedTapRow) => (
<MonoText size="xs">{row.processorId}</MonoText>
),
},
{
key: 'captureMode',
header: 'Capture',
render: (_v: unknown, row: TracedTapRow) => {
if (row.captureMode === null) return <span className={appsStyles.hint}>&mdash;</span>;
return (
<select
className={appsStyles.nativeSelect}
value={row.captureMode}
onChange={(e) => updateTracedProcessor(row.processorId, e.target.value)}
disabled={updateAgentConfig.isPending}
>
<option value="NONE">None</option>
<option value="INPUT">Input</option>
<option value="OUTPUT">Output</option>
<option value="BOTH">Both</option>
</select>
);
},
},
{
key: 'taps',
header: 'Taps',
render: (_v: unknown, row: TracedTapRow) =>
row.taps.length === 0 ? (
<span className={appsStyles.hint}>&mdash;</span>
) : (
<div className={appsStyles.tapBadges}>
{row.taps.map((t) => (
<button
key={t.tapId}
className={appsStyles.tapBadgeLink}
title="Manage tap on route page"
>
<Badge
label={t.attributeName}
color={t.enabled ? 'success' : 'auto'}
variant="filled"
/>
</button>
))}
</div>
),
},
{
key: '_remove' as const,
header: '',
width: '36px',
render: (_v: unknown, row: TracedTapRow) =>
row.captureMode === null ? null : (
<button
className={appsStyles.removeBtn}
title="Remove"
onClick={() => updateTracedProcessor(row.processorId, 'REMOVE')}
disabled={updateAgentConfig.isPending}
>
&times;
</button>
),
},
],
// eslint-disable-next-line react-hooks/exhaustive-deps
[processorToRoute, updateAgentConfig.isPending, effectiveTraced],
);
// catalog is needed only to satisfy the import (keeps the same data shape as legacy ConfigSubTab)
void catalog;
return (
<div>
<LiveBanner />
<div className={sectionStyles.section}>
<SectionHeader>Traces &amp; Taps</SectionHeader>
<span className={appsStyles.sectionSummary}>
{tracedCount} traced &middot; {tapCount} taps
</span>
{tracedTapRows.length > 0 ? (
<DataTable<TracedTapRow>
columns={tracedTapColumns}
data={tracedTapRows}
pageSize={20}
flush
/>
) : (
<EmptyState
title="No traces or taps"
description="No processor traces or taps configured."
/>
)}
</div>
</div>
);
}