diff --git a/ui/src/pages/Admin/AppConfigPage.module.css b/ui/src/pages/Admin/AppConfigPage.module.css index 344c37b0..e5833cf9 100644 --- a/ui/src/pages/Admin/AppConfigPage.module.css +++ b/ui/src/pages/Admin/AppConfigPage.module.css @@ -1 +1,97 @@ -/* No custom styles needed — DataTable with badges handles everything */ +.widePanel { + width: 640px !important; +} + +.panelSection { + margin-bottom: 16px; +} + +.panelSectionHeader { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-muted); + margin-bottom: 8px; +} + +.settingsGrid { + display: flex; + flex-wrap: wrap; + gap: 16px; +} + +.field { + display: flex; + flex-direction: column; + gap: 3px; +} + +.fieldLabel { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-muted); +} + +.select { + padding: 4px 8px; + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + background: var(--bg-body); + color: var(--text-primary); + font-size: 11px; + font-family: var(--font-mono); + outline: none; +} + +.select:focus { + border-color: var(--amber); +} + +.toggleRow { + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + color: var(--text-secondary); +} + +.sectionSummary { + font-size: 11px; + color: var(--text-muted); + margin-bottom: 8px; + display: block; +} + +.tapBadges { + display: flex; + gap: 4px; + flex-wrap: wrap; +} + +.hint { + color: var(--text-faint); + font-size: 11px; +} + +.removeBtn { + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + font-size: 16px; + padding: 0 4px; + line-height: 1; +} + +.removeBtn:hover { + color: var(--error); +} + +.panelMeta { + font-size: 11px; + color: var(--text-muted); + margin-bottom: 12px; +} diff --git a/ui/src/pages/Admin/AppConfigPage.tsx b/ui/src/pages/Admin/AppConfigPage.tsx index 7d6cd810..f61267c0 100644 --- a/ui/src/pages/Admin/AppConfigPage.tsx +++ b/ui/src/pages/Admin/AppConfigPage.tsx @@ -1,15 +1,20 @@ -import { useMemo } from 'react'; -import { useNavigate } from 'react-router'; -import { DataTable, Badge, MonoText } from '@cameleer/design-system'; +import { useState, useMemo, useEffect } from 'react'; +import { + DataTable, Badge, MonoText, DetailPanel, SectionHeader, Button, Toggle, Spinner, useToast, +} from '@cameleer/design-system'; import type { Column } from '@cameleer/design-system'; -import { useAllApplicationConfigs } from '../../api/queries/commands'; -import type { ApplicationConfig } from '../../api/queries/commands'; +import { useAllApplicationConfigs, 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 './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(); @@ -24,120 +29,277 @@ function timeAgo(iso?: string): string { function logLevelColor(level?: string): BadgeColor { switch (level?.toUpperCase()) { - case 'ERROR': return 'error'; - case 'WARN': return 'warning'; - case 'DEBUG': return 'running'; - default: return 'success'; + 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'; + 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'; + 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 AppConfigPage() { - const navigate = useNavigate(); - const { data: configs } = useAllApplicationConfigs(); +// ── Table columns (overview) ───────────────────────────────────────────────── - const columns: Column[] = useMemo(() => [ - { - key: 'application', - header: 'Application', - sortable: true, - render: (_val, row) => {row.application}, - }, - { - key: 'logForwardingLevel', - header: 'Log Level', - render: (_val, row) => { - const val = row.logForwardingLevel ?? 'INFO'; - return ; - }, - }, - { - key: 'engineLevel', - header: 'Engine Level', - render: (_val, row) => { - const val = row.engineLevel ?? 'REGULAR'; - return ; - }, - }, - { - key: 'payloadCaptureMode', - header: 'Payload Capture', - render: (_val, row) => { - const val = row.payloadCaptureMode ?? 'NONE'; - return ; - }, - }, - { - key: 'metricsEnabled', - header: 'Metrics', - width: '80px', - render: (_val, row) => ( - - ), - }, - { - key: 'tracedProcessors', - header: 'Traced', - width: '70px', - render: (_val, row) => { - const count = row.tracedProcessors ? Object.keys(row.tracedProcessors).length : 0; - return count > 0 - ? - : 0; - }, - }, - { - key: 'taps', - header: 'Taps', - width: '70px', - render: (_val, row) => { - const total = row.taps?.length ?? 0; - const enabled = row.taps?.filter(t => t.enabled).length ?? 0; - if (total === 0) return 0; - return ; - }, - }, - { - key: 'version', - header: 'v', - width: '40px', - render: (_val, row) => {row.version}, - }, - { - key: 'updatedAt', - header: 'Updated', - render: (_val, row) => {timeAgo(row.updatedAt)}, - }, - ], []); +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)} }, + ]; +} - function handleRowClick(row: ConfigRow) { - navigate(`/admin/appconfig/${row.application}`); +// ── Detail Panel Content ───────────────────────────────────────────────────── + +function AppConfigDetail({ appId, onClose }: { appId: string; onClose: () => void }) { + 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>({}); + + 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({ + 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' }); }, + }); + } + + // ── 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: '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]); + + // ── 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)}} +
+ + {/* Settings */} +
+
Settings
+
+
+ Log Forwarding + {editing + ? + : } +
+
+ Engine Level + {editing + ? + : } +
+
+ Payload Capture + {editing + ? + : } +
+
+ Metrics + {editing + ? updateField('metricsEnabled', (e.target as HTMLInputElement).checked)} /> + : } +
+
+ Sampling Rate + {editing + ? updateField('samplingRate', parseFloat(e.target.value) || 0)} /> + : {form.samplingRate}} +
+
+ Compress Success + {editing + ? updateField('compressSuccess', (e.target as HTMLInputElement).checked)} /> + : } +
+
+
+ + {/* Traces & Taps */} +
+
Traces & Taps
+ {tracedCount} traced · {tapCount} taps · manage taps on route pages + {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={handleRowClick} + onRowClick={(row) => setSelectedApp(row.application)} + selectedId={selectedApp ?? undefined} pageSize={50} /> + setSelectedApp(null)} + title={selectedApp ?? ''} + className={styles.widePanel} + actions={selectedApp ? setSelectedApp(null)} /> : undefined} + > + {selectedApp && setSelectedApp(null)} />} +
); } + +function PanelActions({ appId, onClose }: { appId: string; onClose: () => void }) { + // Edit/Save/Cancel actions rendered in the panel footer + const { data: config } = useApplicationConfig(appId); + const updateConfig = useUpdateApplicationConfig(); + const { toast } = useToast(); + const [editing, setEditing] = useState(false); + + // This is a simplified stub — the actual editing state is managed inside AppConfigDetail. + // For now, the actions slot is reserved for future use. + return null; +} diff --git a/ui/src/router.tsx b/ui/src/router.tsx index 0d3bfeeb..48eadc04 100644 --- a/ui/src/router.tsx +++ b/ui/src/router.tsx @@ -19,7 +19,6 @@ const OidcConfigPage = lazy(() => import('./pages/Admin/OidcConfigPage')); const DatabaseAdminPage = lazy(() => import('./pages/Admin/DatabaseAdminPage')); const OpenSearchAdminPage = lazy(() => import('./pages/Admin/OpenSearchAdminPage')); const AppConfigPage = lazy(() => import('./pages/Admin/AppConfigPage')); -const AppConfigDetailPage = lazy(() => import('./pages/Admin/AppConfigDetailPage')); const SwaggerPage = lazy(() => import('./pages/Swagger/SwaggerPage')); function SuspenseWrapper({ children }: { children: React.ReactNode }) { @@ -59,7 +58,6 @@ export const router = createBrowserRouter([ { path: 'audit', element: }, { path: 'oidc', element: }, { path: 'appconfig', element: }, - { path: 'appconfig/:appId', element: }, { path: 'database', element: }, { path: 'opensearch', element: }, ],