feat(ui): restructure AppConfigDetailPage into 3 sections
Some checks failed
CI / cleanup-branch (push) Has been skipped
CI / build (push) Failing after 32s
CI / docker (push) Has been skipped
CI / deploy (push) Has been skipped
CI / deploy-feature (push) Has been skipped

Merge Logging + Observability into unified "Settings" section with
flex-wrap badge grid including new compressSuccess toggle. Merge
Traced Processors with Taps into "Traces & Taps" section showing
capture mode and tap badges per processor. Add "Route Recording"
section with per-route toggles sourced from route catalog. All new
fields (compressSuccess, routeRecording) included in form state
and save payload.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-26 18:48:14 +01:00
parent 78813ea15f
commit 64b677696e
2 changed files with 263 additions and 94 deletions

View File

@@ -1,5 +1,5 @@
.page {
max-width: 640px;
max-width: 720px;
margin: 0 auto;
}
@@ -115,11 +115,23 @@
padding: 16px 20px;
}
.sectionSummary {
font-size: 12px;
color: var(--text-muted);
}
.settingsGrid {
display: flex;
flex-wrap: wrap;
gap: 16px 24px;
}
.field {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
min-width: 140px;
}
.label {
@@ -158,6 +170,12 @@
cursor: pointer;
}
.tapBadges {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.hint {
font-size: 11px;
color: var(--text-muted);

View File

@@ -1,15 +1,29 @@
import { useEffect, useState, useMemo } from 'react';
import { useParams, useNavigate } from 'react-router';
import {
Button, SectionHeader, MonoText, Badge, DataTable, Spinner, useToast,
Button, SectionHeader, MonoText, Badge, DataTable, Spinner, Toggle, 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 type { ApplicationConfig, TapDefinition } from '../../api/queries/commands';
import { useRouteCatalog } from '../../api/queries/catalog';
import type { AppCatalogEntry, RouteSummary } from '../../api/types';
import styles from './AppConfigDetailPage.module.css';
type BadgeColor = 'primary' | 'success' | 'warning' | 'error' | 'running' | 'auto';
interface TracedRow { id: string; processorId: string; captureMode: string }
interface TracedTapRow {
id: string;
processorId: string;
captureMode: string | null;
taps: TapDefinition[];
}
interface RouteRecordingRow {
id: string;
routeId: string;
recording: boolean;
}
function formatTimestamp(iso?: string): string {
if (!iso) return '\u2014';
@@ -59,22 +73,32 @@ export default function AppConfigDetailPage() {
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<Partial<ApplicationConfig> | null>(null);
const [tracedDraft, setTracedDraft] = useState<Record<string, string>>({});
const [routeRecordingDraft, setRouteRecordingDraft] = useState<Record<string, boolean>>({});
// Find routes for this application from the catalog
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) {
// 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,
compressSuccess: config.compressSuccess,
});
setTracedDraft({ ...config.tracedProcessors });
setRouteRecordingDraft({ ...config.routeRecording });
}
}, [config]);
@@ -86,8 +110,10 @@ export default function AppConfigDetailPage() {
payloadCaptureMode: config.payloadCaptureMode ?? 'NONE',
metricsEnabled: config.metricsEnabled,
samplingRate: config.samplingRate,
compressSuccess: config.compressSuccess,
});
setTracedDraft({ ...config.tracedProcessors });
setRouteRecordingDraft({ ...config.routeRecording });
setEditing(true);
}
@@ -110,9 +136,18 @@ export default function AppConfigDetailPage() {
});
}
function updateRouteRecording(routeId: string, recording: boolean) {
setRouteRecordingDraft((prev) => ({ ...prev, [routeId]: recording }));
}
function handleSave() {
if (!config || !form) return;
const updated = { ...config, ...form, tracedProcessors: tracedDraft };
const updated: ApplicationConfig = {
...config,
...form,
tracedProcessors: tracedDraft,
routeRecording: routeRecordingDraft,
} as ApplicationConfig;
updateConfig.mutate(updated, {
onSuccess: (saved) => {
setEditing(false);
@@ -124,24 +159,53 @@ export default function AppConfigDetailPage() {
});
}
const tracedRows: TracedRow[] = useMemo(() => {
// ── Traces & Taps merged rows ──────────────────────────────────────────────
const tracedTapRows: TracedTapRow[] = useMemo(() => {
const traced = editing ? tracedDraft : (config?.tracedProcessors ?? {});
const taps = config?.taps ?? [];
// Collect all unique processor IDs
const processorIds = new Set<string>([
...Object.keys(traced),
...taps.map((t) => t.processorId),
]);
return Array.from(processorIds).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(() => {
const source = editing ? tracedDraft : (config?.tracedProcessors ?? {});
return Object.entries(source).map(
([pid, mode]) => ({ id: pid, processorId: pid, captureMode: mode }),
);
return Object.keys(source).length;
}, [editing, tracedDraft, config?.tracedProcessors]);
const tracedColumns: Column<TracedRow>[] = useMemo(() => [
{ key: 'processorId', header: 'Processor ID', render: (_v, row) => <MonoText size="xs">{row.processorId}</MonoText> },
const tapCount = config?.taps?.length ?? 0;
const tracedTapColumns: Column<TracedTapRow>[] = useMemo(() => [
{
key: 'processorId',
header: 'Processor',
render: (_v, row) => <MonoText size="xs">{row.processorId}</MonoText>,
},
{
key: 'captureMode',
header: 'Capture Mode',
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}
<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>
@@ -152,18 +216,79 @@ export default function AppConfigDetailPage() {
return <Badge label={row.captureMode} color={captureColor(row.captureMode)} variant="filled" />;
},
},
{
key: 'taps',
header: 'Taps',
render: (_v, row) => {
if (row.taps.length === 0) {
return <span className={styles.hint}>&mdash;</span>;
}
return (
<div className={styles.tapBadges}>
{row.taps.map((t) => (
<Badge
key={t.tapId}
label={t.attributeName}
color={t.enabled ? 'success' : 'auto'}
variant="filled"
/>
))}
</div>
);
},
},
...(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>
),
render: (_v: unknown, row: TracedTapRow) => {
if (row.captureMode === null) return null;
return (
<button className={styles.removeBtn} title="Remove" onClick={() => updateTracedProcessor(row.processorId, 'REMOVE')}>
&times;
</button>
);
},
}] : []),
], [editing]);
// ── Route Recording rows ───────────────────────────────────────────────────
const routeRecordingRows: RouteRecordingRow[] = useMemo(() => {
const recording = editing ? routeRecordingDraft : (config?.routeRecording ?? {});
return appRoutes.map((r) => ({
id: r.routeId,
routeId: r.routeId,
recording: recording[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]);
// ── Render ─────────────────────────────────────────────────────────────────
if (isLoading) {
return <div className={styles.loading}><Spinner size="lg" /></div>;
}
@@ -196,90 +321,116 @@ export default function AppConfigDetailPage() {
</div>
</div>
{/* ── Settings ──────────────────────────────────────────────────── */}
<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 ? (
<>
<SectionHeader>Settings</SectionHeader>
<div className={styles.settingsGrid}>
<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" />
)}
</div>
<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</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}>
<label className={styles.label}>Payload Capture</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</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}>
<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>
)}
) : (
<MonoText size="xs">{form.samplingRate}</MonoText>
)}
</div>
<div className={styles.field}>
<label className={styles.label}>Compress Success</label>
{editing ? (
<label className={styles.toggleRow}>
<input type="checkbox" checked={Boolean(form.compressSuccess)}
onChange={(e) => updateField('compressSuccess', e.target.checked)} />
<span>{form.compressSuccess ? 'On' : 'Off'}</span>
</label>
) : (
<Badge label={form.compressSuccess ? 'On' : 'Off'} color={form.compressSuccess ? 'success' : 'error'} variant="filled" />
)}
</div>
</div>
</div>
{/* ── Traces & Taps ─────────────────────────────────────────────── */}
<div className={styles.section}>
<SectionHeader>Traced Processors ({tracedRows.length})</SectionHeader>
{tracedRows.length > 0 ? (
<DataTable<TracedRow> columns={tracedColumns} data={tracedRows} pageSize={20} />
<SectionHeader>Traces &amp; Taps</SectionHeader>
<span className={styles.sectionSummary}>
{tracedCount} traced &middot; {tapCount} taps &middot; manage taps on route pages
</span>
{tracedTapRows.length > 0 ? (
<DataTable<TracedTapRow> columns={tracedTapColumns} data={tracedTapRows} pageSize={20} />
) : (
<span className={styles.hint}>
No processors are individually traced.
{!editing && ' Enable tracing per-processor on the exchange detail page.'}
No processors are individually traced and no taps are defined.
{!editing && ' Enable tracing per-processor on the exchange detail page, or add taps on route pages.'}
</span>
)}
</div>
{/* ── Route Recording ───────────────────────────────────────────── */}
<div className={styles.section}>
<SectionHeader>Route Recording</SectionHeader>
<span className={styles.sectionSummary}>
{recordingCount} of {routeRecordingRows.length} routes recording
</span>
{routeRecordingRows.length > 0 ? (
<DataTable<RouteRecordingRow> columns={routeRecordingColumns} data={routeRecordingRows} pageSize={20} />
) : (
<span className={styles.hint}>
No routes found for this application. Routes appear once agents report data.
</span>
)}
</div>