From 0e6de69cd9c72f2278cdf631bdc416d2e4b2639c Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Thu, 26 Mar 2026 16:15:27 +0100 Subject: [PATCH] feat: add App Config detail page with view/edit mode Click a row in the admin App Config table to navigate to a dedicated detail page at /admin/appconfig/:appId. Shows all config fields as badges in view mode; pencil toggles to edit mode with dropdowns. Traced processors are now editable (capture mode dropdown + remove button per processor). Sections and header use card styling for visual contrast. OidcConfigPage gets the same card treatment. List page simplified to read-only badge overview with row click navigation. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Admin/AppConfigDetailPage.module.css | 165 ++++++++++ ui/src/pages/Admin/AppConfigDetailPage.tsx | 288 ++++++++++++++++++ ui/src/pages/Admin/AppConfigPage.module.css | 82 +---- ui/src/pages/Admin/AppConfigPage.tsx | 162 +--------- ui/src/pages/Admin/OidcConfigPage.module.css | 7 +- ui/src/router.tsx | 2 + 6 files changed, 476 insertions(+), 230 deletions(-) create mode 100644 ui/src/pages/Admin/AppConfigDetailPage.module.css create mode 100644 ui/src/pages/Admin/AppConfigDetailPage.tsx diff --git a/ui/src/pages/Admin/AppConfigDetailPage.module.css b/ui/src/pages/Admin/AppConfigDetailPage.module.css new file mode 100644 index 00000000..0c7696da --- /dev/null +++ b/ui/src/pages/Admin/AppConfigDetailPage.module.css @@ -0,0 +1,165 @@ +.page { + max-width: 640px; + margin: 0 auto; +} + +.loading { + display: flex; + justify-content: center; + padding: 4rem; +} + +.toolbar { + display: flex; + gap: 8px; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; +} + +.backBtn { + background: none; + border: none; + color: var(--amber); + cursor: pointer; + font-size: 13px; + font-weight: 500; + padding: 4px 0; +} + +.backBtn:hover { + color: var(--amber-deep); + text-decoration: underline; +} + +.toolbarActions { + display: flex; + gap: 8px; + align-items: center; +} + +.editBtn { + background: none; + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + color: var(--text-secondary); + cursor: pointer; + font-size: 12px; + padding: 5px 12px; +} + +.editBtn:hover { + border-color: var(--amber); + color: var(--amber); +} + +.cancelBtn { + background: none; + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + color: var(--text-muted); + cursor: pointer; + font-size: 12px; + padding: 5px 12px; +} + +.cancelBtn:hover { + border-color: var(--text-faint); + color: var(--text-primary); +} + +.removeBtn { + background: none; + border: none; + color: var(--text-faint); + cursor: pointer; + font-size: 16px; + padding: 0 4px; + line-height: 1; +} + +.removeBtn:hover { + color: var(--error); +} + +.header { + margin-bottom: 16px; + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-card); + padding: 16px 20px; +} + +.title { + font-size: 18px; + font-weight: 600; + color: var(--text-primary); + margin: 0 0 4px; +} + +.meta { + font-size: 11px; + color: var(--text-muted); +} + +.section { + margin-bottom: 16px; + display: flex; + flex-direction: column; + gap: 12px; + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-card); + padding: 16px 20px; +} + +.field { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; +} + +.label { + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); +} + +.select { + padding: 6px 10px; + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + background: var(--bg-body); + color: var(--text-primary); + font-size: 13px; + font-family: var(--font-mono); + outline: none; + max-width: 360px; +} + +.select:focus { + border-color: var(--amber); +} + +.toggleRow { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--text-secondary); + cursor: pointer; +} + +.toggleRow input { + accent-color: var(--amber); + cursor: pointer; +} + +.hint { + font-size: 11px; + color: var(--text-muted); + font-family: var(--font-body); +} diff --git a/ui/src/pages/Admin/AppConfigDetailPage.tsx b/ui/src/pages/Admin/AppConfigDetailPage.tsx new file mode 100644 index 00000000..833c364d --- /dev/null +++ b/ui/src/pages/Admin/AppConfigDetailPage.tsx @@ -0,0 +1,288 @@ +import { useEffect, useState, useMemo } from 'react'; +import { useParams, useNavigate } from 'react-router'; +import { + Button, SectionHeader, MonoText, Badge, DataTable, Spinner, useToast, +} from '@cameleer/design-system'; +import type { Column } from '@cameleer/design-system'; +import { useApplicationConfig, useUpdateApplicationConfig } from '../../api/queries/commands'; +import type { ApplicationConfig } from '../../api/queries/commands'; +import styles from './AppConfigDetailPage.module.css'; + +type BadgeColor = 'primary' | 'success' | 'warning' | 'error' | 'running' | 'auto'; +interface TracedRow { id: string; processorId: string; captureMode: string } + +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'; + 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 [editing, setEditing] = useState(false); + const [form, setForm] = useState | null>(null); + const [tracedDraft, setTracedDraft] = useState>({}); + + useEffect(() => { + if (config) { + // Reset form when server data arrives (after save or initial load) + setForm({ + logForwardingLevel: config.logForwardingLevel ?? 'INFO', + engineLevel: config.engineLevel ?? 'REGULAR', + payloadCaptureMode: config.payloadCaptureMode ?? 'NONE', + metricsEnabled: config.metricsEnabled, + samplingRate: config.samplingRate, + }); + setTracedDraft({ ...config.tracedProcessors }); + } + }, [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, + }); + setTracedDraft({ ...config.tracedProcessors }); + 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 handleSave() { + if (!config || !form) return; + const updated = { ...config, ...form, tracedProcessors: tracedDraft }; + 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' }); + }, + }); + } + + const tracedRows: TracedRow[] = useMemo(() => { + const source = editing ? tracedDraft : (config?.tracedProcessors ?? {}); + return Object.entries(source).map( + ([pid, mode]) => ({ id: pid, processorId: pid, captureMode: mode }), + ); + }, [editing, tracedDraft, config?.tracedProcessors]); + + const tracedColumns: Column[] = useMemo(() => [ + { key: 'processorId', header: 'Processor ID', render: (_v, row) => {row.processorId} }, + { + key: 'captureMode', + header: 'Capture Mode', + render: (_v, row) => { + if (editing) { + return ( + + ); + } + return ; + }, + }, + ...(editing ? [{ + key: '_remove' as const, + header: '', + width: '36px', + render: (_v: unknown, row: TracedRow) => ( + + ), + }] : []), + ], [editing]); + + if (isLoading) { + return
; + } + + if (!config || !form) { + return
No configuration found for "{appId}".
; + } + + return ( +
+
+ + {editing ? ( +
+ + +
+ ) : ( + + )} +
+ +
+

{appId}

+
+ Version {config.version} + {config.updatedAt && <> · Updated {formatTimestamp(config.updatedAt)}} +
+
+ +
+ Logging +
+ + {editing ? ( + + ) : ( + + )} + Minimum log level forwarded from agents to the server +
+
+ +
+ Observability +
+ + {editing ? ( + + ) : ( + + )} +
+
+ + {editing ? ( + + ) : ( + + )} +
+
+ + {editing ? ( + + ) : ( + + )} +
+
+ + {editing ? ( + <> + updateField('samplingRate', parseFloat(e.target.value) || 0)} /> + 0.0 = sample nothing, 1.0 = capture all executions + + ) : ( + {form.samplingRate} + )} +
+
+ +
+ Traced Processors ({tracedRows.length}) + {tracedRows.length > 0 ? ( + columns={tracedColumns} data={tracedRows} pageSize={20} /> + ) : ( + + No processors are individually traced. + {!editing && ' Enable tracing per-processor on the exchange detail page.'} + + )} +
+
+ ); +} diff --git a/ui/src/pages/Admin/AppConfigPage.module.css b/ui/src/pages/Admin/AppConfigPage.module.css index d0485d5a..344c37b0 100644 --- a/ui/src/pages/Admin/AppConfigPage.module.css +++ b/ui/src/pages/Admin/AppConfigPage.module.css @@ -1,81 +1 @@ -.inspectLink { - background: transparent; - border: none; - color: var(--text-faint); - opacity: 0.75; - cursor: pointer; - font-size: 13px; - padding: 2px 4px; - border-radius: var(--radius-sm); - line-height: 1; - display: inline-flex; -} - -.inspectLink:hover { - color: var(--text-primary); - opacity: 1; -} - -.editBtn { - background: transparent; - border: none; - color: var(--text-faint); - opacity: 0.75; - cursor: pointer; - font-size: 13px; - padding: 2px 4px; - border-radius: var(--radius-sm); - line-height: 1; - display: inline-flex; -} - -.editBtn:hover { - color: var(--text-primary); - opacity: 1; -} - -.editBtn:disabled { - opacity: 0.3; - cursor: not-allowed; -} - -.editActions { - display: inline-flex; - gap: 2px; -} - -.inlineSelect { - padding: 3px 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; - cursor: pointer; -} - -.inlineSelect:focus { - border-color: var(--amber); -} - -.inlineSelect:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -.inlineToggle { - display: flex; - align-items: center; - gap: 5px; - font-size: 11px; - font-family: var(--font-mono); - color: var(--text-secondary); - cursor: pointer; -} - -.inlineToggle input { - accent-color: var(--amber); - cursor: pointer; -} +/* No custom styles needed — DataTable with badges handles everything */ diff --git a/ui/src/pages/Admin/AppConfigPage.tsx b/ui/src/pages/Admin/AppConfigPage.tsx index 9fda46a4..080e32c4 100644 --- a/ui/src/pages/Admin/AppConfigPage.tsx +++ b/ui/src/pages/Admin/AppConfigPage.tsx @@ -1,13 +1,15 @@ -import { useState, useMemo, useCallback } from 'react'; +import { useMemo } from 'react'; import { useNavigate } from 'react-router'; -import { DataTable, Badge, MonoText, useToast } from '@cameleer/design-system'; +import { DataTable, Badge, MonoText } from '@cameleer/design-system'; import type { Column } from '@cameleer/design-system'; -import { useAllApplicationConfigs, useUpdateApplicationConfig } from '../../api/queries/commands'; +import { useAllApplicationConfigs } from '../../api/queries/commands'; import type { ApplicationConfig } from '../../api/queries/commands'; import styles from './AppConfigPage.module.css'; type ConfigRow = ApplicationConfig & { id: string }; +type BadgeColor = 'primary' | 'success' | 'warning' | 'error' | 'running' | 'auto'; + function timeAgo(iso?: string): string { if (!iso) return '\u2014'; const diff = Date.now() - new Date(iso).getTime(); @@ -20,8 +22,6 @@ function timeAgo(iso?: string): string { return `${Math.floor(hours / 24)}d ago`; } -type BadgeColor = 'primary' | 'success' | 'warning' | 'error' | 'running' | 'auto'; - function logLevelColor(level?: string): BadgeColor { switch (level?.toUpperCase()) { case 'ERROR': return 'error'; @@ -50,94 +50,9 @@ function payloadColor(mode?: string): BadgeColor { export default function AppConfigPage() { const navigate = useNavigate(); - const { toast } = useToast(); const { data: configs } = useAllApplicationConfigs(); - const updateConfig = useUpdateApplicationConfig(); - const [editingApp, setEditingApp] = useState(null); - const [draft, setDraft] = useState>({}); - - const startEditing = useCallback((row: ConfigRow) => { - setEditingApp(row.application); - setDraft({ - logForwardingLevel: row.logForwardingLevel ?? 'INFO', - engineLevel: row.engineLevel ?? 'REGULAR', - payloadCaptureMode: row.payloadCaptureMode ?? 'NONE', - metricsEnabled: row.metricsEnabled, - }); - }, []); - - const cancelEditing = useCallback(() => { - setEditingApp(null); - setDraft({}); - }, []); - - const saveEditing = useCallback((row: ConfigRow) => { - const updated = { ...row, ...draft }; - updateConfig.mutate(updated, { - onSuccess: (saved) => { - setEditingApp(null); - setDraft({}); - toast({ title: 'Config updated', description: `${row.application} (v${saved.version})`, variant: 'success' }); - }, - onError: () => { - toast({ title: 'Config update failed', description: row.application, variant: 'error' }); - }, - }); - }, [draft, updateConfig, toast]); const columns: Column[] = useMemo(() => [ - { - key: '_inspect', - header: '', - width: '36px', - render: (_val, row) => ( - - ), - }, - { - key: '_edit', - header: '', - width: '36px', - render: (_val, row) => { - const isEditing = editingApp === row.application; - return isEditing ? ( - - - - - ) : ( - - ); - }, - }, { key: 'application', header: 'Application', @@ -149,20 +64,6 @@ export default function AppConfigPage() { header: 'Log Level', render: (_val, row) => { const val = row.logForwardingLevel ?? 'INFO'; - if (editingApp === row.application) { - return ( - - ); - } return ; }, }, @@ -171,20 +72,6 @@ export default function AppConfigPage() { header: 'Engine Level', render: (_val, row) => { const val = row.engineLevel ?? 'REGULAR'; - if (editingApp === row.application) { - return ( - - ); - } return ; }, }, @@ -193,20 +80,6 @@ export default function AppConfigPage() { header: 'Payload Capture', render: (_val, row) => { const val = row.payloadCaptureMode ?? 'NONE'; - if (editingApp === row.application) { - return ( - - ); - } return ; }, }, @@ -214,21 +87,9 @@ export default function AppConfigPage() { key: 'metricsEnabled', header: 'Metrics', width: '80px', - render: (_val, row) => { - if (editingApp === row.application) { - return ( - - ); - } - return ; - }, + render: (_val, row) => ( + + ), }, { key: 'tracedProcessors', @@ -252,13 +113,18 @@ export default function AppConfigPage() { header: 'Updated', render: (_val, row) => {timeAgo(row.updatedAt)}, }, - ], [navigate, editingApp, draft, startEditing, cancelEditing, saveEditing, updateConfig.isPending]); + ], []); + + function handleRowClick(row: ConfigRow) { + navigate(`/admin/appconfig/${row.application}`); + } return (
columns={columns} data={(configs ?? []).map(c => ({ ...c, id: c.application }))} + onRowClick={handleRowClick} pageSize={50} />
diff --git a/ui/src/pages/Admin/OidcConfigPage.module.css b/ui/src/pages/Admin/OidcConfigPage.module.css index fe6b86e2..d8e23cc4 100644 --- a/ui/src/pages/Admin/OidcConfigPage.module.css +++ b/ui/src/pages/Admin/OidcConfigPage.module.css @@ -11,10 +11,15 @@ } .section { - margin-bottom: 24px; + margin-bottom: 16px; display: flex; flex-direction: column; gap: 12px; + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-card); + padding: 16px 20px; } .toggleRow { diff --git a/ui/src/router.tsx b/ui/src/router.tsx index 48eadc04..0d3bfeeb 100644 --- a/ui/src/router.tsx +++ b/ui/src/router.tsx @@ -19,6 +19,7 @@ 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 }) { @@ -58,6 +59,7 @@ export const router = createBrowserRouter([ { path: 'audit', element: }, { path: 'oidc', element: }, { path: 'appconfig', element: }, + { path: 'appconfig/:appId', element: }, { path: 'database', element: }, { path: 'opensearch', element: }, ],