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