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:
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}>—</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}>—</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}>—</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}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</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 & Taps</SectionHeader>
|
||||||
|
<span className={appsStyles.sectionSummary}>
|
||||||
|
{tracedCount} traced · {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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user