import { useEffect, useState, useMemo } from 'react'; import { useParams, useNavigate } from 'react-router'; import { ArrowLeft, Pencil, X } from 'lucide-react'; import { Button, SectionHeader, MonoText, Badge, DataTable, Spinner, Toggle, useToast, } from '@cameleer/design-system'; import type { Column } from '@cameleer/design-system'; import { useApplicationConfig, useUpdateApplicationConfig } from '../../api/queries/commands'; import type { ApplicationConfig, TapDefinition } from '../../api/queries/commands'; import { useRouteCatalog } from '../../api/queries/catalog'; import type { AppCatalogEntry, RouteSummary } from '../../api/types'; import styles from './AppConfigDetailPage.module.css'; type BadgeColor = 'primary' | 'success' | 'warning' | 'error' | 'running' | 'auto'; interface TracedTapRow { id: string; processorId: string; captureMode: string | null; taps: TapDefinition[]; } interface RouteRecordingRow { id: string; routeId: string; recording: boolean; } function formatTimestamp(iso?: string): string { if (!iso) return '\u2014'; return new Date(iso).toLocaleString('en-GB', { day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit', }); } function logLevelColor(level?: string): BadgeColor { switch (level?.toUpperCase()) { case 'ERROR': return 'error'; case 'WARN': return 'warning'; case 'DEBUG': return 'running'; case 'TRACE': return 'auto'; default: return 'success'; } } function engineLevelColor(level?: string): BadgeColor { switch (level?.toUpperCase()) { case 'NONE': return 'error'; case 'MINIMAL': return 'warning'; case 'COMPLETE': return 'running'; default: return 'success'; } } function payloadColor(mode?: string): BadgeColor { switch (mode?.toUpperCase()) { case 'INPUT': case 'OUTPUT': return 'warning'; case 'BOTH': return 'running'; default: return 'auto'; } } function captureColor(mode: string): BadgeColor { switch (mode?.toUpperCase()) { case 'INPUT': case 'OUTPUT': return 'warning'; case 'BOTH': return 'running'; default: return 'auto'; } } export default function AppConfigDetailPage() { const { appId } = useParams<{ appId: string }>(); const navigate = useNavigate(); const { toast } = useToast(); const { data: config, isLoading } = useApplicationConfig(appId); const updateConfig = useUpdateApplicationConfig(); const { data: catalog } = useRouteCatalog(); const [editing, setEditing] = useState(false); const [form, setForm] = useState | null>(null); const [tracedDraft, setTracedDraft] = useState>({}); const [routeRecordingDraft, setRouteRecordingDraft] = useState>({}); // Find routes for this application from the catalog const appRoutes: RouteSummary[] = useMemo(() => { if (!catalog || !appId) return []; const entry = (catalog as AppCatalogEntry[]).find((e) => e.appId === appId); return entry?.routes ?? []; }, [catalog, appId]); useEffect(() => { if (config) { setForm({ applicationLogLevel: config.applicationLogLevel ?? 'INFO', agentLogLevel: config.agentLogLevel ?? 'INFO', engineLevel: config.engineLevel ?? 'REGULAR', payloadCaptureMode: config.payloadCaptureMode ?? 'NONE', metricsEnabled: config.metricsEnabled, samplingRate: config.samplingRate, compressSuccess: config.compressSuccess, }); setTracedDraft({ ...config.tracedProcessors }); setRouteRecordingDraft({ ...config.routeRecording }); } }, [config]); function startEditing() { if (!config) return; setForm({ applicationLogLevel: config.applicationLogLevel ?? 'INFO', agentLogLevel: config.agentLogLevel ?? 'INFO', engineLevel: config.engineLevel ?? 'REGULAR', payloadCaptureMode: config.payloadCaptureMode ?? 'NONE', metricsEnabled: config.metricsEnabled, samplingRate: config.samplingRate, compressSuccess: config.compressSuccess, }); setTracedDraft({ ...config.tracedProcessors }); setRouteRecordingDraft({ ...config.routeRecording }); setEditing(true); } function cancelEditing() { setEditing(false); } function updateField(key: K, value: ApplicationConfig[K]) { setForm((prev) => prev ? { ...prev, [key]: value } : prev); } function updateTracedProcessor(processorId: string, mode: string) { setTracedDraft((prev) => { if (mode === 'REMOVE') { const next = { ...prev }; delete next[processorId]; return next; } return { ...prev, [processorId]: mode }; }); } function updateRouteRecording(routeId: string, recording: boolean) { setRouteRecordingDraft((prev) => ({ ...prev, [routeId]: recording })); } function handleSave() { if (!config || !form) return; const updated: ApplicationConfig = { ...config, ...form, tracedProcessors: tracedDraft, routeRecording: routeRecordingDraft, } as ApplicationConfig; updateConfig.mutate(updated, { onSuccess: (saved) => { setEditing(false); toast({ title: 'Config saved', description: `${appId} updated to v${saved.config.version}`, variant: 'success' }); }, onError: () => { toast({ title: 'Save failed', description: 'Could not update configuration', variant: 'error' }); }, }); } // ── Traces & Taps merged rows ────────────────────────────────────────────── const tracedTapRows: TracedTapRow[] = useMemo(() => { const traced = editing ? tracedDraft : (config?.tracedProcessors ?? {}); const taps = config?.taps ?? []; // Collect all unique processor IDs const processorIds = new Set([ ...Object.keys(traced), ...taps.map((t) => t.processorId), ]); return Array.from(processorIds).sort().map((pid) => ({ id: pid, processorId: pid, captureMode: traced[pid] ?? null, taps: taps.filter((t) => t.processorId === pid), })); }, [editing, tracedDraft, config?.tracedProcessors, config?.taps]); const tracedCount = useMemo(() => { const source = editing ? tracedDraft : (config?.tracedProcessors ?? {}); return Object.keys(source).length; }, [editing, tracedDraft, config?.tracedProcessors]); const tapCount = config?.taps?.length ?? 0; const tracedTapColumns: Column[] = useMemo(() => [ { key: 'processorId', header: 'Processor', render: (_v, row) => {row.processorId}, }, { key: 'captureMode', header: 'Capture', render: (_v, row) => { if (row.captureMode === null) { return ; } if (editing) { return ( ); } return ; }, }, { key: 'taps', header: 'Taps', render: (_v, row) => { if (row.taps.length === 0) { return ; } return (
{row.taps.map((t) => ( ))}
); }, }, ...(editing ? [{ key: '_remove' as const, header: '', width: '36px', render: (_v: unknown, row: TracedTapRow) => { if (row.captureMode === null) return null; return ( ); }, }] : []), ], [editing]); // ── Route Recording rows ─────────────────────────────────────────────────── const routeRecordingRows: RouteRecordingRow[] = useMemo(() => { const recording = editing ? routeRecordingDraft : (config?.routeRecording ?? {}); return appRoutes.map((r) => ({ id: r.routeId, routeId: r.routeId, recording: recording[r.routeId] !== false, })); }, [editing, routeRecordingDraft, config?.routeRecording, appRoutes]); const recordingCount = routeRecordingRows.filter((r) => r.recording).length; const routeRecordingColumns: Column[] = useMemo(() => [ { key: 'routeId', header: 'Route', render: (_v, row) => {row.routeId}, }, { key: 'recording', header: 'Recording', width: '100px', render: (_v, row) => ( { if (editing) updateRouteRecording(row.routeId, !row.recording); }} disabled={!editing} /> ), }, ], [editing, routeRecordingDraft]); // ── Render ───────────────────────────────────────────────────────────────── if (isLoading) { return
; } if (!config || !form) { return
No configuration found for "{appId}".
; } return (
{editing ? (
) : ( )}

{appId}

Version {config.version} {config.updatedAt && <> · Updated {formatTimestamp(config.updatedAt)}}
{/* ── Settings ──────────────────────────────────────────────────── */}
Settings
{editing ? ( ) : ( )}
{editing ? ( ) : ( )}
{editing ? ( ) : ( )}
{editing ? ( ) : ( )}
{editing ? ( ) : ( )}
{editing ? ( updateField('samplingRate', parseFloat(e.target.value) || 0)} /> ) : ( {form.samplingRate} )}
{editing ? ( ) : ( )}
{/* ── Traces & Taps ─────────────────────────────────────────────── */}
Traces & Taps {tracedCount} traced · {tapCount} taps · manage taps on route pages {tracedTapRows.length > 0 ? ( columns={tracedTapColumns} data={tracedTapRows} pageSize={20} /> ) : ( No processors are individually traced and no taps are defined. {!editing && ' Enable tracing per-processor on the exchange detail page, or add taps on route pages.'} )}
{/* ── Route Recording ───────────────────────────────────────────── */}
Route Recording {recordingCount} of {routeRecordingRows.length} routes recording {routeRecordingRows.length > 0 ? ( columns={routeRecordingColumns} data={routeRecordingRows} pageSize={20} /> ) : ( No routes found for this application. Routes appear once agents report data. )}
); }