diff --git a/ui/src/pages/AppsTab/AppDeploymentPage/ConfigTabs/RouteRecordingTab.tsx b/ui/src/pages/AppsTab/AppDeploymentPage/ConfigTabs/RouteRecordingTab.tsx new file mode 100644 index 00000000..7f6bac38 --- /dev/null +++ b/ui/src/pages/AppsTab/AppDeploymentPage/ConfigTabs/RouteRecordingTab.tsx @@ -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 | 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[] = useMemo( + () => [ + { + key: 'routeId', + header: 'Route', + render: (_v: unknown, row: RouteRecordingRow) => ( + {row.routeId} + ), + }, + { + key: 'recording', + header: 'Recording', + width: '100px', + render: (_v: unknown, row: RouteRecordingRow) => ( + updateRouteRecording(row.routeId, !row.recording)} + disabled={updateAgentConfig.isPending} + /> + ), + }, + ], + // eslint-disable-next-line react-hooks/exhaustive-deps + [updateAgentConfig.isPending, effectiveRecording], + ); + + return ( +
+ +
+ Route Recording + + {recordingCount} of {routeRecordingRows.length} routes recording + + {routeRecordingRows.length > 0 ? ( + + columns={routeRecordingColumns} + data={routeRecordingRows} + pageSize={20} + flush + /> + ) : ( + + )} +
+
+ ); +} diff --git a/ui/src/pages/AppsTab/AppDeploymentPage/ConfigTabs/TracesTapsTab.tsx b/ui/src/pages/AppsTab/AppDeploymentPage/ConfigTabs/TracesTapsTab.tsx new file mode 100644 index 00000000..ad8f7a7b --- /dev/null +++ b/ui/src/pages/AppsTab/AppDeploymentPage/ConfigTabs/TracesTapsTab.tsx @@ -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 | 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([ + ...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[] = useMemo( + () => [ + { + key: 'route' as any, + header: 'Route', + render: (_v: unknown, row: TracedTapRow) => { + const routeId = processorToRoute[row.processorId]; + return routeId ? ( + {routeId} + ) : ( + + ); + }, + }, + { + key: 'processorId', + header: 'Processor', + render: (_v: unknown, row: TracedTapRow) => ( + {row.processorId} + ), + }, + { + key: 'captureMode', + header: 'Capture', + render: (_v: unknown, row: TracedTapRow) => { + if (row.captureMode === null) return ; + return ( + + ); + }, + }, + { + key: 'taps', + header: 'Taps', + render: (_v: unknown, row: TracedTapRow) => + row.taps.length === 0 ? ( + + ) : ( +
+ {row.taps.map((t) => ( + + ))} +
+ ), + }, + { + key: '_remove' as const, + header: '', + width: '36px', + render: (_v: unknown, row: TracedTapRow) => + row.captureMode === null ? null : ( + + ), + }, + ], + // 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 ( +
+ +
+ Traces & Taps + + {tracedCount} traced · {tapCount} taps + + {tracedTapRows.length > 0 ? ( + + columns={tracedTapColumns} + data={tracedTapRows} + pageSize={20} + flush + /> + ) : ( + + )} +
+
+ ); +}