import { useState, useMemo, useEffect } from 'react'; import { useNavigate } from 'react-router'; import { DataTable, Badge, MonoText, DetailPanel, SectionHeader, Button, Toggle, Spinner, useToast, } from '@cameleer/design-system'; import type { Column } from '@cameleer/design-system'; import { useAllApplicationConfigs, useApplicationConfig, useUpdateApplicationConfig, useProcessorRouteMapping } 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 './AppConfigPage.module.css'; type ConfigRow = ApplicationConfig & { id: string }; 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 timeAgo(iso?: string): string { if (!iso) return '\u2014'; const diff = Date.now() - new Date(iso).getTime(); const secs = Math.floor(diff / 1000); if (secs < 60) return `${secs}s ago`; const mins = Math.floor(secs / 60); if (mins < 60) return `${mins}m ago`; const hours = Math.floor(mins / 60); if (hours < 24) return `${hours}h ago`; return `${Math.floor(hours / 24)}d ago`; } function logLevelColor(level?: string): BadgeColor { switch (level?.toUpperCase()) { case 'ERROR': return 'error'; case 'WARN': return 'warning'; case 'DEBUG': return 'running'; 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'; } } // ── Table columns (overview) ───────────────────────────────────────────────── function buildColumns(): Column[] { return [ { key: 'application', header: 'Application', sortable: true, render: (_v, row) => {row.application} }, { key: 'logForwardingLevel', header: 'Log Level', render: (_v, row) => { const val = row.logForwardingLevel ?? 'INFO'; return ; } }, { key: 'engineLevel', header: 'Engine Level', render: (_v, row) => { const val = row.engineLevel ?? 'REGULAR'; return ; } }, { key: 'payloadCaptureMode', header: 'Payload Capture', render: (_v, row) => { const val = row.payloadCaptureMode ?? 'NONE'; return ; } }, { key: 'metricsEnabled', header: 'Metrics', width: '80px', render: (_v, row) => }, { key: 'tracedProcessors', header: 'Traced', width: '70px', render: (_v, row) => { const c = row.tracedProcessors ? Object.keys(row.tracedProcessors).length : 0; return c > 0 ? : 0; } }, { key: 'taps', header: 'Taps', width: '70px', render: (_v, row) => { const t = row.taps?.length ?? 0; const e = row.taps?.filter(x => x.enabled).length ?? 0; return t === 0 ? 0 : ; } }, { key: 'version', header: 'v', width: '40px', render: (_v, row) => {row.version} }, { key: 'updatedAt', header: 'Updated', render: (_v, row) => {timeAgo(row.updatedAt)} }, ]; } // ── Detail Panel Content ───────────────────────────────────────────────────── function AppConfigDetail({ appId, onClose }: { appId: string; onClose: () => void }) { const { toast } = useToast(); const navigate = useNavigate(); 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>({}); const appRoutes: RouteSummary[] = useMemo(() => { if (!catalog || !appId) return []; const entry = (catalog as AppCatalogEntry[]).find((e) => e.appId === appId); return entry?.routes ?? []; }, [catalog, appId]); // processorId → routeId mapping from backend const { data: processorToRoute = {} } = useProcessorRouteMapping(appId); useEffect(() => { if (config) { setForm({ logForwardingLevel: config.logForwardingLevel ?? '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({ logForwardingLevel: config.logForwardingLevel ?? '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 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 = { ...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.version}`, variant: 'success' }); }, onError: () => { toast({ title: 'Save failed', description: 'Could not update configuration', variant: 'error' }); }, }); } function navigateToTaps(processorId: string) { const routeId = processorToRoute[processorId]; onClose(); if (routeId) { navigate(`/routes/${appId}/${routeId}?tab=taps`); } else { navigate(`/routes/${appId}`); } } // ── Traces & Taps merged rows const tracedTapRows: TracedTapRow[] = useMemo(() => { const traced = editing ? tracedDraft : (config?.tracedProcessors ?? {}); const taps = config?.taps ?? []; const pids = new Set([...Object.keys(traced), ...taps.map(t => t.processorId)]); return Array.from(pids).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(() => Object.keys(editing ? tracedDraft : (config?.tracedProcessors ?? {})).length, [editing, tracedDraft, config?.tracedProcessors]); const tapCount = config?.taps?.length ?? 0; const tracedTapColumns: Column[] = useMemo(() => [ { key: 'route' as any, header: 'Route', render: (_v, row) => { const routeId = processorToRoute[row.processorId]; return routeId ? {routeId} : ; }}, { 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) => row.taps.length === 0 ? :
{row.taps.map(t => ( ))}
, }, ...(editing ? [{ key: '_remove' as const, header: '', width: '36px', render: (_v: unknown, row: TracedTapRow) => row.captureMode === null ? null : ( ), }] : []), ], [editing, processorToRoute]); // ── Route Recording rows const routeRecordingRows: RouteRecordingRow[] = useMemo(() => { const rec = editing ? routeRecordingDraft : (config?.routeRecording ?? {}); return appRoutes.map(r => ({ id: r.routeId, routeId: r.routeId, recording: rec[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]); if (isLoading) return
; if (!config || !form) return
No configuration found.
; return ( <>
Version {config.version} {config.updatedAt && <> · Updated {timeAgo(config.updatedAt)}}
{editing ? (
) : ( )}
{/* Settings */}
Settings
Log Forwarding {editing ? : }
Engine Level {editing ? : }
Payload Capture {editing ? : }
Metrics {editing ? updateField('metricsEnabled', (e.target as HTMLInputElement).checked)} /> : }
Compress Success {editing ? updateField('compressSuccess', (e.target as HTMLInputElement).checked)} /> : }
Sampling Rate {editing ? updateField('samplingRate', parseFloat(e.target.value) || 0)} /> : {form.samplingRate}}
{/* Traces & Taps */}
Traces & Taps
{tracedCount} traced · {tapCount} taps · click tap to manage {tracedTapRows.length > 0 ? columns={tracedTapColumns} data={tracedTapRows} pageSize={20} flush /> : No processor traces or taps configured.}
{/* Route Recording */}
Route Recording
{recordingCount} of {routeRecordingRows.length} routes recording {routeRecordingRows.length > 0 ? columns={routeRecordingColumns} data={routeRecordingRows} pageSize={20} flush /> : No routes found for this application.}
); } // ── Main Page ──────────────────────────────────────────────────────────────── export default function AppConfigPage() { const { data: configs } = useAllApplicationConfigs(); const [selectedApp, setSelectedApp] = useState(null); const columns = useMemo(buildColumns, []); return (
columns={columns} data={(configs ?? []).map(c => ({ ...c, id: c.application }))} onRowClick={(row) => setSelectedApp(row.application)} selectedId={selectedApp ?? undefined} pageSize={50} /> setSelectedApp(null)} title={selectedApp ?? ''} className={styles.widePanel} > {selectedApp && setSelectedApp(null)} />}
); }