2026-03-26 21:44:30 +01:00
import { useState , useMemo , useEffect } from 'react' ;
2026-03-26 22:26:28 +01:00
import { useNavigate } from 'react-router' ;
2026-03-26 21:44:30 +01:00
import {
DataTable , Badge , MonoText , DetailPanel , SectionHeader , Button , Toggle , Spinner , useToast ,
} from '@cameleer/design-system' ;
2026-03-26 12:51:07 +01:00
import type { Column } from '@cameleer/design-system' ;
2026-03-26 22:44:07 +01:00
import { useAllApplicationConfigs , useApplicationConfig , useUpdateApplicationConfig , useProcessorRouteMapping } from '../../api/queries/commands' ;
2026-03-26 21:44:30 +01:00
import type { ApplicationConfig , TapDefinition } from '../../api/queries/commands' ;
import { useRouteCatalog } from '../../api/queries/catalog' ;
import type { AppCatalogEntry , RouteSummary } from '../../api/types' ;
2026-03-26 12:51:07 +01:00
import styles from './AppConfigPage.module.css' ;
2026-03-26 12:55:19 +01:00
type ConfigRow = ApplicationConfig & { id : string } ;
2026-03-26 16:15:27 +01:00
type BadgeColor = 'primary' | 'success' | 'warning' | 'error' | 'running' | 'auto' ;
2026-03-26 21:44:30 +01:00
interface TracedTapRow { id : string ; processorId : string ; captureMode : string | null ; taps : TapDefinition [ ] ; }
interface RouteRecordingRow { id : string ; routeId : string ; recording : boolean ; }
2026-03-26 12:51:07 +01:00
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 ` ;
}
2026-03-26 13:12:56 +01:00
function logLevelColor ( level? : string ) : BadgeColor {
2026-03-26 12:51:07 +01:00
switch ( level ? . toUpperCase ( ) ) {
2026-03-26 21:44:30 +01:00
case 'ERROR' : return 'error' ; case 'WARN' : return 'warning' ; case 'DEBUG' : return 'running' ; default : return 'success' ;
2026-03-26 12:51:07 +01:00
}
}
2026-03-26 13:12:56 +01:00
function engineLevelColor ( level? : string ) : BadgeColor {
switch ( level ? . toUpperCase ( ) ) {
2026-03-26 21:44:30 +01:00
case 'NONE' : return 'error' ; case 'MINIMAL' : return 'warning' ; case 'COMPLETE' : return 'running' ; default : return 'success' ;
2026-03-26 13:12:56 +01:00
}
}
function payloadColor ( mode? : string ) : BadgeColor {
switch ( mode ? . toUpperCase ( ) ) {
2026-03-26 21:44:30 +01:00
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' ;
2026-03-26 13:12:56 +01:00
}
}
2026-03-26 21:44:30 +01:00
// ── Table columns (overview) ─────────────────────────────────────────────────
2026-03-26 12:51:07 +01:00
2026-03-26 21:44:30 +01:00
function buildColumns ( ) : Column < ConfigRow > [ ] {
return [
{ key : 'application' , header : 'Application' , sortable : true , render : ( _v , row ) = > < MonoText size = "sm" > { row . application } < / MonoText > } ,
{ key : 'logForwardingLevel' , header : 'Log Level' , render : ( _v , row ) = > { const val = row . logForwardingLevel ? ? 'INFO' ; return < Badge label = { val } color = { logLevelColor ( val ) } variant = "filled" / > ; } } ,
{ key : 'engineLevel' , header : 'Engine Level' , render : ( _v , row ) = > { const val = row . engineLevel ? ? 'REGULAR' ; return < Badge label = { val } color = { engineLevelColor ( val ) } variant = "filled" / > ; } } ,
{ key : 'payloadCaptureMode' , header : 'Payload Capture' , render : ( _v , row ) = > { const val = row . payloadCaptureMode ? ? 'NONE' ; return < Badge label = { val } color = { payloadColor ( val ) } variant = "filled" / > ; } } ,
{ key : 'metricsEnabled' , header : 'Metrics' , width : '80px' , render : ( _v , row ) = > < Badge label = { row . metricsEnabled ? 'On' : 'Off' } color = { row . metricsEnabled ? 'success' : 'error' } variant = "filled" / > } ,
{ key : 'tracedProcessors' , header : 'Traced' , width : '70px' , render : ( _v , row ) = > { const c = row . tracedProcessors ? Object . keys ( row . tracedProcessors ) . length : 0 ; return c > 0 ? < Badge label = { ` ${ c } ` } color = "running" variant = "filled" / > : < MonoText size = "xs" > 0 < / MonoText > ; } } ,
{ 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 ? < MonoText size = "xs" > 0 < / MonoText > : < Badge label = { ` ${ e } / ${ t } ` } color = "running" variant = "filled" / > ; } } ,
{ key : 'version' , header : 'v' , width : '40px' , render : ( _v , row ) = > < MonoText size = "xs" > { row . version } < / MonoText > } ,
{ key : 'updatedAt' , header : 'Updated' , render : ( _v , row ) = > < MonoText size = "xs" > { timeAgo ( row . updatedAt ) } < / MonoText > } ,
] ;
}
// ── Detail Panel Content ─────────────────────────────────────────────────────
function AppConfigDetail ( { appId , onClose } : { appId : string ; onClose : ( ) = > void } ) {
const { toast } = useToast ( ) ;
2026-03-26 22:26:28 +01:00
const navigate = useNavigate ( ) ;
2026-03-26 21:44:30 +01:00
const { data : config , isLoading } = useApplicationConfig ( appId ) ;
const updateConfig = useUpdateApplicationConfig ( ) ;
const { data : catalog } = useRouteCatalog ( ) ;
const [ editing , setEditing ] = useState ( false ) ;
const [ form , setForm ] = useState < Partial < ApplicationConfig > | null > ( null ) ;
const [ tracedDraft , setTracedDraft ] = useState < Record < string , string > > ( { } ) ;
const [ routeRecordingDraft , setRouteRecordingDraft ] = useState < Record < string , boolean > > ( { } ) ;
const appRoutes : RouteSummary [ ] = useMemo ( ( ) = > {
if ( ! catalog || ! appId ) return [ ] ;
const entry = ( catalog as AppCatalogEntry [ ] ) . find ( ( e ) = > e . appId === appId ) ;
return entry ? . routes ? ? [ ] ;
} , [ catalog , appId ] ) ;
2026-03-26 22:44:07 +01:00
// processorId → routeId mapping from backend
const { data : processorToRoute = { } } = useProcessorRouteMapping ( appId ) ;
2026-03-26 22:26:28 +01:00
2026-03-26 21:44:30 +01:00
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 < K extends keyof ApplicationConfig > ( 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' } ) ; } ,
} ) ;
}
2026-03-26 22:26:28 +01:00
function navigateToTaps ( processorId : string ) {
const routeId = processorToRoute [ processorId ] ;
onClose ( ) ;
if ( routeId ) {
navigate ( ` /routes/ ${ appId } / ${ routeId } ?tab=taps ` ) ;
} else {
navigate ( ` /routes/ ${ appId } ` ) ;
}
}
2026-03-26 21:44:30 +01:00
// ── Traces & Taps merged rows
const tracedTapRows : TracedTapRow [ ] = useMemo ( ( ) = > {
const traced = editing ? tracedDraft : ( config ? . tracedProcessors ? ? { } ) ;
const taps = config ? . taps ? ? [ ] ;
const pids = new Set < string > ( [ . . . 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 < TracedTapRow > [ ] = useMemo ( ( ) = > [
2026-03-26 22:26:28 +01:00
{ key : 'route' as any , header : 'Route' , render : ( _v , row ) = > {
const routeId = processorToRoute [ row . processorId ] ;
return routeId ? < span className = { styles . routeLabel } > { routeId } < / span > : < span className = { styles . hint } > & mdash ; < / span > ;
} } ,
2026-03-26 21:44:30 +01:00
{ key : 'processorId' , header : 'Processor' , render : ( _v , row ) = > < MonoText size = "xs" > { row . processorId } < / MonoText > } ,
2026-03-26 12:51:07 +01:00
{
2026-03-26 21:44:30 +01:00
key : 'captureMode' , header : 'Capture' ,
render : ( _v , row ) = > {
if ( row . captureMode === null ) return < span className = { styles . hint } > & mdash ; < / span > ;
if ( editing ) return (
< select className = { styles . select } value = { row . captureMode } onChange = { ( e ) = > updateTracedProcessor ( row . processorId , e . target . value ) } >
< option value = "NONE" > None < / option > < option value = "INPUT" > Input < / option > < option value = "OUTPUT" > Output < / option > < option value = "BOTH" > Both < / option >
< / select >
) ;
return < Badge label = { row . captureMode } color = { captureColor ( row . captureMode ) } variant = "filled" / > ;
2026-03-26 13:12:56 +01:00
} ,
2026-03-26 12:51:07 +01:00
} ,
{
2026-03-26 21:44:30 +01:00
key : 'taps' , header : 'Taps' ,
render : ( _v , row ) = > row . taps . length === 0
? < span className = { styles . hint } > & mdash ; < / span >
2026-03-26 22:26:28 +01:00
: < div className = { styles . tapBadges } > { row . taps . map ( t = > (
< button key = { t . tapId } className = { styles . tapBadgeLink } onClick = { ( ) = > navigateToTaps ( row . processorId ) } title = { ` Manage on route page ${ processorToRoute [ row . processorId ] ? ` ( ${ processorToRoute [ row . processorId ] } ) ` : '' } ` } >
< Badge label = { t . attributeName } color = { t . enabled ? 'success' : 'auto' } variant = "filled" / >
< / button >
) ) } < / div > ,
2026-03-26 12:51:07 +01:00
} ,
2026-03-26 21:44:30 +01:00
. . . ( editing ? [ {
key : '_remove' as const , header : '' , width : '36px' ,
render : ( _v : unknown , row : TracedTapRow ) = > row . captureMode === null ? null : (
< button className = { styles . removeBtn } title = "Remove" onClick = { ( ) = > updateTracedProcessor ( row . processorId , 'REMOVE' ) } > & times ; < / button >
2026-03-26 16:15:27 +01:00
) ,
2026-03-26 21:44:30 +01:00
} ] : [ ] ) ,
2026-03-26 22:26:28 +01:00
] , [ editing , processorToRoute ] ) ;
2026-03-26 16:15:27 +01:00
2026-03-26 21:44:30 +01:00
// ── 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 < RouteRecordingRow > [ ] = useMemo ( ( ) = > [
{ key : 'routeId' , header : 'Route' , render : ( _v , row ) = > < MonoText size = "xs" > { row . routeId } < / MonoText > } ,
{ key : 'recording' , header : 'Recording' , width : '100px' , render : ( _v , row ) = > < Toggle checked = { row . recording } onChange = { ( ) = > { if ( editing ) updateRouteRecording ( row . routeId , ! row . recording ) ; } } disabled = { ! editing } / > } ,
] , [ editing , routeRecordingDraft ] ) ;
if ( isLoading ) return < div style = { { padding : 24 , textAlign : 'center' } } > < Spinner size = "sm" / > < / div > ;
if ( ! config || ! form ) return < div style = { { padding : 16 } } > No configuration found . < / div > ;
return (
< >
2026-03-26 22:26:28 +01:00
< div className = { styles . panelToolbar } >
< div className = { styles . panelMeta } >
Version < MonoText size = "xs" > { config . version } < / MonoText >
{ config . updatedAt && < > & middot ; Updated < MonoText size = "xs" > { timeAgo ( config . updatedAt ) } < / MonoText > < / > }
< / div >
{ editing ? (
< div className = { styles . panelActions } >
< Button variant = "secondary" size = "sm" onClick = { ( ) = > setEditing ( false ) } > Cancel < / Button >
< Button size = "sm" onClick = { handleSave } disabled = { updateConfig . isPending } >
{ updateConfig . isPending ? 'Saving\u2026' : 'Save' }
< / Button >
< / div >
) : (
< button className = { styles . editBtn } onClick = { startEditing } title = "Edit configuration" > & # x270E ; < / button >
) }
2026-03-26 21:44:30 +01:00
< / div >
{ /* Settings */ }
< div className = { styles . panelSection } >
< div className = { styles . panelSectionHeader } > Settings < / div >
< div className = { styles . settingsGrid } >
< div className = { styles . field } >
< span className = { styles . fieldLabel } > Log Forwarding < / span >
{ editing
? < select className = { styles . select } value = { String ( form . logForwardingLevel ) } onChange = { ( e ) = > updateField ( 'logForwardingLevel' , e . target . value ) } > < option value = "ERROR" > ERROR < / option > < option value = "WARN" > WARN < / option > < option value = "INFO" > INFO < / option > < option value = "DEBUG" > DEBUG < / option > < / select >
: < Badge label = { String ( form . logForwardingLevel ) } color = { logLevelColor ( form . logForwardingLevel as string ) } variant = "filled" / > }
< / div >
< div className = { styles . field } >
< span className = { styles . fieldLabel } > Engine Level < / span >
{ editing
? < select className = { styles . select } value = { String ( form . engineLevel ) } onChange = { ( e ) = > updateField ( 'engineLevel' , e . target . value ) } > < option value = "NONE" > None < / option > < option value = "MINIMAL" > Minimal < / option > < option value = "REGULAR" > Regular < / option > < option value = "COMPLETE" > Complete < / option > < / select >
: < Badge label = { String ( form . engineLevel ) } color = { engineLevelColor ( form . engineLevel as string ) } variant = "filled" / > }
< / div >
< div className = { styles . field } >
< span className = { styles . fieldLabel } > Payload Capture < / span >
{ editing
? < select className = { styles . select } value = { String ( form . payloadCaptureMode ) } onChange = { ( e ) = > updateField ( 'payloadCaptureMode' , e . target . value ) } > < option value = "NONE" > None < / option > < option value = "INPUT" > Input < / option > < option value = "OUTPUT" > Output < / option > < option value = "BOTH" > Both < / option > < / select >
: < Badge label = { String ( form . payloadCaptureMode ) } color = { payloadColor ( form . payloadCaptureMode as string ) } variant = "filled" / > }
< / div >
< div className = { styles . field } >
< span className = { styles . fieldLabel } > Metrics < / span >
{ editing
? < Toggle checked = { Boolean ( form . metricsEnabled ) } onChange = { ( e ) = > updateField ( 'metricsEnabled' , ( e . target as HTMLInputElement ) . checked ) } / >
: < Badge label = { form . metricsEnabled ? 'On' : 'Off' } color = { form . metricsEnabled ? 'success' : 'error' } variant = "filled" / > }
< / div >
< div className = { styles . field } >
< span className = { styles . fieldLabel } > Compress Success < / span >
{ editing
? < Toggle checked = { Boolean ( form . compressSuccess ) } onChange = { ( e ) = > updateField ( 'compressSuccess' , ( e . target as HTMLInputElement ) . checked ) } / >
: < Badge label = { form . compressSuccess ? 'On' : 'Off' } color = { form . compressSuccess ? 'success' : 'error' } variant = "filled" / > }
< / div >
2026-03-26 22:26:28 +01:00
< div className = { styles . field } >
< span className = { styles . fieldLabel } > Sampling Rate < / span >
{ editing
? < input type = "number" className = { styles . select } min = { 0 } max = { 1 } step = { 0.01 } value = { form . samplingRate ? ? 1.0 } onChange = { ( e ) = > updateField ( 'samplingRate' , parseFloat ( e . target . value ) || 0 ) } / >
: < MonoText size = "xs" > { form . samplingRate } < / MonoText > }
< / div >
2026-03-26 21:44:30 +01:00
< / div >
< / div >
{ /* Traces & Taps */ }
< div className = { styles . panelSection } >
< div className = { styles . panelSectionHeader } > Traces & Taps < / div >
2026-03-26 22:26:28 +01:00
< span className = { styles . sectionSummary } > { tracedCount } traced & middot ; { tapCount } taps & middot ; click tap to manage < / span >
2026-03-26 21:44:30 +01:00
{ tracedTapRows . length > 0
? < DataTable < TracedTapRow > columns = { tracedTapColumns } data = { tracedTapRows } pageSize = { 20 } flush / >
: < span className = { styles . hint } > No processor traces or taps configured . < / span > }
< / div >
{ /* Route Recording */ }
< div className = { styles . panelSection } >
< div className = { styles . panelSectionHeader } > Route Recording < / div >
< span className = { styles . sectionSummary } > { recordingCount } of { routeRecordingRows . length } routes recording < / span >
{ routeRecordingRows . length > 0
? < DataTable < RouteRecordingRow > columns = { routeRecordingColumns } data = { routeRecordingRows } pageSize = { 20 } flush / >
: < span className = { styles . hint } > No routes found for this application . < / span > }
< / div >
< / >
) ;
}
// ── Main Page ────────────────────────────────────────────────────────────────
export default function AppConfigPage() {
const { data : configs } = useAllApplicationConfigs ( ) ;
const [ selectedApp , setSelectedApp ] = useState < string | null > ( null ) ;
const columns = useMemo ( buildColumns , [ ] ) ;
2026-03-26 12:51:07 +01:00
return (
< div >
2026-03-26 12:55:19 +01:00
< DataTable < ConfigRow >
2026-03-26 12:51:07 +01:00
columns = { columns }
2026-03-26 12:55:19 +01:00
data = { ( configs ? ? [ ] ) . map ( c = > ( { . . . c , id : c.application } ) ) }
2026-03-26 21:44:30 +01:00
onRowClick = { ( row ) = > setSelectedApp ( row . application ) }
selectedId = { selectedApp ? ? undefined }
2026-03-26 12:51:07 +01:00
pageSize = { 50 }
/ >
2026-03-26 21:44:30 +01:00
< DetailPanel
open = { ! ! selectedApp }
onClose = { ( ) = > setSelectedApp ( null ) }
title = { selectedApp ? ? '' }
className = { styles . widePanel }
>
{ selectedApp && < AppConfigDetail appId = { selectedApp } onClose = { ( ) = > setSelectedApp ( null ) } / > }
< / DetailPanel >
2026-03-26 12:51:07 +01:00
< / div >
) ;
}