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