feat: add App Config detail page with view/edit mode
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 53s
CI / docker (push) Successful in 52s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 38s

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) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-26 16:15:27 +01:00
parent e53274bcb9
commit 0e6de69cd9
6 changed files with 476 additions and 230 deletions

View File

@@ -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);
}

View File

@@ -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<Partial<ApplicationConfig> | null>(null);
const [tracedDraft, setTracedDraft] = useState<Record<string, string>>({});
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<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 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<TracedRow>[] = useMemo(() => [
{ key: 'processorId', header: 'Processor ID', render: (_v, row) => <MonoText size="xs">{row.processorId}</MonoText> },
{
key: 'captureMode',
header: 'Capture Mode',
render: (_v, row) => {
if (editing) {
return (
<select className={styles.select} value={row.captureMode}
onChange={(e) => updateTracedProcessor(row.processorId, e.target.value)}
style={{ maxWidth: 160 }}>
<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" />;
},
},
...(editing ? [{
key: '_remove' as const,
header: '',
width: '36px',
render: (_v: unknown, row: TracedRow) => (
<button className={styles.removeBtn} title="Remove" onClick={() => updateTracedProcessor(row.processorId, 'REMOVE')}>
&times;
</button>
),
}] : []),
], [editing]);
if (isLoading) {
return <div className={styles.loading}><Spinner size="lg" /></div>;
}
if (!config || !form) {
return <div className={styles.page}>No configuration found for &quot;{appId}&quot;.</div>;
}
return (
<div className={styles.page}>
<div className={styles.toolbar}>
<button className={styles.backBtn} onClick={() => navigate('/admin/appconfig')}>&larr; Back</button>
{editing ? (
<div className={styles.toolbarActions}>
<Button onClick={handleSave} disabled={updateConfig.isPending}>
{updateConfig.isPending ? 'Saving\u2026' : 'Save'}
</Button>
<button className={styles.cancelBtn} onClick={cancelEditing}>Cancel</button>
</div>
) : (
<button className={styles.editBtn} onClick={startEditing}>&#x270E; Edit</button>
)}
</div>
<div className={styles.header}>
<h2 className={styles.title}><MonoText size="md">{appId}</MonoText></h2>
<div className={styles.meta}>
Version <MonoText size="xs">{config.version}</MonoText>
{config.updatedAt && <> &middot; Updated {formatTimestamp(config.updatedAt)}</>}
</div>
</div>
<div className={styles.section}>
<SectionHeader>Logging</SectionHeader>
<div className={styles.field}>
<label className={styles.label}>Log Forwarding Level</label>
{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" />
)}
<span className={styles.hint}>Minimum log level forwarded from agents to the server</span>
</div>
</div>
<div className={styles.section}>
<SectionHeader>Observability</SectionHeader>
<div className={styles.field}>
<label className={styles.label}>Engine Level</label>
{editing ? (
<select className={styles.select} value={String(form.engineLevel)}
onChange={(e) => updateField('engineLevel', e.target.value)}>
<option value="NONE">None agent dormant</option>
<option value="MINIMAL">Minimal route timing only</option>
<option value="REGULAR">Regular routes with snapshots</option>
<option value="COMPLETE">Complete full processor tracing</option>
</select>
) : (
<Badge label={String(form.engineLevel)} color={engineLevelColor(form.engineLevel as string)} variant="filled" />
)}
</div>
<div className={styles.field}>
<label className={styles.label}>Payload Capture Mode</label>
{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 only</option>
<option value="OUTPUT">Output only</option>
<option value="BOTH">Both input and output</option>
</select>
) : (
<Badge label={String(form.payloadCaptureMode)} color={payloadColor(form.payloadCaptureMode as string)} variant="filled" />
)}
</div>
<div className={styles.field}>
<label className={styles.label}>Metrics</label>
{editing ? (
<label className={styles.toggleRow}>
<input type="checkbox" checked={Boolean(form.metricsEnabled)}
onChange={(e) => updateField('metricsEnabled', e.target.checked)} />
<span>{form.metricsEnabled ? 'Enabled' : 'Disabled'}</span>
</label>
) : (
<Badge label={form.metricsEnabled ? 'On' : 'Off'} color={form.metricsEnabled ? 'success' : 'error'} variant="filled" />
)}
</div>
<div className={styles.field}>
<label className={styles.label}>Sampling Rate</label>
{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)} />
<span className={styles.hint}>0.0 = sample nothing, 1.0 = capture all executions</span>
</>
) : (
<MonoText size="xs">{form.samplingRate}</MonoText>
)}
</div>
</div>
<div className={styles.section}>
<SectionHeader>Traced Processors ({tracedRows.length})</SectionHeader>
{tracedRows.length > 0 ? (
<DataTable<TracedRow> columns={tracedColumns} data={tracedRows} pageSize={20} />
) : (
<span className={styles.hint}>
No processors are individually traced.
{!editing && ' Enable tracing per-processor on the exchange detail page.'}
</span>
)}
</div>
</div>
);
}

View File

@@ -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 */

View File

@@ -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<string | null>(null);
const [draft, setDraft] = useState<Partial<ApplicationConfig>>({});
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<ConfigRow>[] = useMemo(() => [
{
key: '_inspect',
header: '',
width: '36px',
render: (_val, row) => (
<button
className={styles.inspectLink}
title="Open agent page"
onClick={(e) => {
e.stopPropagation();
navigate(`/agents/${row.application}`);
}}
>
&#x2197;
</button>
),
},
{
key: '_edit',
header: '',
width: '36px',
render: (_val, row) => {
const isEditing = editingApp === row.application;
return isEditing ? (
<span className={styles.editActions}>
<button
className={styles.editBtn}
title="Save"
onClick={(e) => { e.stopPropagation(); saveEditing(row); }}
disabled={updateConfig.isPending}
>
&#x2713;
</button>
<button
className={styles.editBtn}
title="Cancel"
onClick={(e) => { e.stopPropagation(); cancelEditing(); }}
>
&#x2715;
</button>
</span>
) : (
<button
className={styles.editBtn}
title="Edit config"
onClick={(e) => { e.stopPropagation(); startEditing(row); }}
>
&#x270E;
</button>
);
},
},
{
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 (
<select
className={styles.inlineSelect}
value={draft.logForwardingLevel ?? val}
onChange={(e) => { e.stopPropagation(); setDraft(d => ({ ...d, 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>
);
}
return <Badge label={val} color={logLevelColor(val)} variant="filled" />;
},
},
@@ -171,20 +72,6 @@ export default function AppConfigPage() {
header: 'Engine Level',
render: (_val, row) => {
const val = row.engineLevel ?? 'REGULAR';
if (editingApp === row.application) {
return (
<select
className={styles.inlineSelect}
value={draft.engineLevel ?? val}
onChange={(e) => { e.stopPropagation(); setDraft(d => ({ ...d, 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>
);
}
return <Badge label={val} color={engineLevelColor(val)} variant="filled" />;
},
},
@@ -193,20 +80,6 @@ export default function AppConfigPage() {
header: 'Payload Capture',
render: (_val, row) => {
const val = row.payloadCaptureMode ?? 'NONE';
if (editingApp === row.application) {
return (
<select
className={styles.inlineSelect}
value={draft.payloadCaptureMode ?? val}
onChange={(e) => { e.stopPropagation(); setDraft(d => ({ ...d, 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>
);
}
return <Badge label={val} color={payloadColor(val)} variant="filled" />;
},
},
@@ -214,21 +87,9 @@ export default function AppConfigPage() {
key: 'metricsEnabled',
header: 'Metrics',
width: '80px',
render: (_val, row) => {
if (editingApp === row.application) {
return (
<label className={styles.inlineToggle} onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={draft.metricsEnabled ?? row.metricsEnabled}
onChange={(e) => setDraft(d => ({ ...d, metricsEnabled: e.target.checked }))}
/>
<span>{(draft.metricsEnabled ?? row.metricsEnabled) ? 'On' : 'Off'}</span>
</label>
);
}
return <Badge label={row.metricsEnabled ? 'On' : 'Off'} color={row.metricsEnabled ? 'success' : 'error'} variant="filled" />;
},
render: (_val, row) => (
<Badge label={row.metricsEnabled ? 'On' : 'Off'} color={row.metricsEnabled ? 'success' : 'error'} variant="filled" />
),
},
{
key: 'tracedProcessors',
@@ -252,13 +113,18 @@ export default function AppConfigPage() {
header: 'Updated',
render: (_val, row) => <MonoText size="xs">{timeAgo(row.updatedAt)}</MonoText>,
},
], [navigate, editingApp, draft, startEditing, cancelEditing, saveEditing, updateConfig.isPending]);
], []);
function handleRowClick(row: ConfigRow) {
navigate(`/admin/appconfig/${row.application}`);
}
return (
<div>
<DataTable<ConfigRow>
columns={columns}
data={(configs ?? []).map(c => ({ ...c, id: c.application }))}
onRowClick={handleRowClick}
pageSize={50}
/>
</div>

View File

@@ -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 {

View File

@@ -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: <SuspenseWrapper><AuditLogPage /></SuspenseWrapper> },
{ path: 'oidc', element: <SuspenseWrapper><OidcConfigPage /></SuspenseWrapper> },
{ path: 'appconfig', element: <SuspenseWrapper><AppConfigPage /></SuspenseWrapper> },
{ path: 'appconfig/:appId', element: <SuspenseWrapper><AppConfigDetailPage /></SuspenseWrapper> },
{ path: 'database', element: <SuspenseWrapper><DatabaseAdminPage /></SuspenseWrapper> },
{ path: 'opensearch', element: <SuspenseWrapper><OpenSearchAdminPage /></SuspenseWrapper> },
],