Files
cameleer-server/ui/src/pages/Admin/AppConfigDetailPage.tsx
hsiegeln dadab2b5f7
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m26s
CI / docker (push) Successful in 1m13s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 49s
fix: align payloadCaptureMode default with agent (BOTH, not NONE)
Server defaultConfig() and UI fallbacks returned "NONE" for payload
capture, but the agent defaults to "BOTH". This caused unwanted
reconfiguration when users saved other settings — payload capture
would silently change from the agent's default BOTH to NONE.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 17:12:21 +02:00

474 lines
18 KiB
TypeScript

import { useEffect, useState, useMemo } from 'react';
import { useParams, useNavigate } from 'react-router';
import { ArrowLeft, Pencil, X } from 'lucide-react';
import {
Button, SectionHeader, MonoText, Badge, DataTable, Spinner, Toggle, Select, Label, useToast,
} from '@cameleer/design-system';
import type { Column } from '@cameleer/design-system';
import { useApplicationConfig, useUpdateApplicationConfig } from '../../api/queries/commands';
import type { ApplicationConfig, TapDefinition, ConfigUpdateResponse } from '../../api/queries/commands';
import { useEnvironmentStore } from '../../api/environment-store';
import { useCatalog } from '../../api/queries/catalog';
import type { CatalogApp, CatalogRoute } from '../../api/queries/catalog';
import { applyTracedProcessorUpdate, applyRouteRecordingUpdate } from '../../utils/config-draft-utils';
import styles from './AppConfigDetailPage.module.css';
import sectionStyles from '../../styles/section-card.module.css';
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 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';
case 'TRACE': return 'auto';
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 selectedEnv = useEnvironmentStore((s) => s.environment);
const { data: config, isLoading } = useApplicationConfig(appId);
const updateConfig = useUpdateApplicationConfig();
const { data: catalog } = useCatalog();
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: CatalogRoute[] = useMemo(() => {
if (!catalog || !appId) return [];
const entry = (catalog as CatalogApp[]).find((e) => e.slug === appId);
return entry?.routes ?? [];
}, [catalog, appId]);
useEffect(() => {
if (config) {
setForm({
applicationLogLevel: config.applicationLogLevel ?? 'INFO',
agentLogLevel: config.agentLogLevel ?? 'INFO',
engineLevel: config.engineLevel ?? 'REGULAR',
payloadCaptureMode: config.payloadCaptureMode ?? 'BOTH',
metricsEnabled: config.metricsEnabled,
samplingRate: config.samplingRate,
compressSuccess: config.compressSuccess,
});
setTracedDraft({ ...config.tracedProcessors });
setRouteRecordingDraft({ ...config.routeRecording });
}
}, [config]);
function startEditing() {
if (!config) return;
setForm({
applicationLogLevel: config.applicationLogLevel ?? 'INFO',
agentLogLevel: config.agentLogLevel ?? 'INFO',
engineLevel: config.engineLevel ?? 'REGULAR',
payloadCaptureMode: config.payloadCaptureMode ?? 'BOTH',
metricsEnabled: config.metricsEnabled,
samplingRate: config.samplingRate,
compressSuccess: config.compressSuccess,
});
setTracedDraft({ ...config.tracedProcessors });
setRouteRecordingDraft({ ...config.routeRecording });
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) => applyTracedProcessorUpdate(prev, processorId, mode));
}
function updateRouteRecording(routeId: string, recording: boolean) {
setRouteRecordingDraft((prev) => applyRouteRecordingUpdate(prev, routeId, recording));
}
function handleSave() {
if (!config || !form) return;
const updated: ApplicationConfig = {
...config,
...form,
tracedProcessors: tracedDraft,
routeRecording: routeRecordingDraft,
} as ApplicationConfig;
updateConfig.mutate({ config: updated, environment: selectedEnv }, {
onSuccess: (saved: ConfigUpdateResponse) => {
setEditing(false);
if (saved.pushResult.success) {
toast({ title: 'Config saved', description: `${appId} updated to v${saved.config.version} — pushed to ${saved.pushResult.total}/${saved.pushResult.total} agents`, variant: 'success' });
} else {
const failed = [...saved.pushResult.responses.filter(r => r.status !== 'SUCCESS').map(r => r.agentId), ...saved.pushResult.timedOut];
toast({ title: 'Config saved — partial push failure', description: `${saved.pushResult.responded}/${saved.pushResult.total} responded. Failed: ${failed.join(', ')}`, variant: 'warning', duration: 86_400_000 });
}
},
onError: () => {
toast({ title: 'Failed to save configuration', description: 'Could not update configuration', variant: 'error', duration: 86_400_000 });
},
});
}
// ── 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.keys(source).length;
}, [editing, tracedDraft, config?.tracedProcessors]);
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',
render: (_v, row) => {
if (row.captureMode === null) {
return <span className={styles.hint}>&mdash;</span>;
}
if (editing) {
return (
<Select
value={row.captureMode}
onChange={(e) => updateTracedProcessor(row.processorId, e.target.value)}
options={[
{ value: 'NONE', label: 'None' },
{ value: 'INPUT', label: 'Input' },
{ value: 'OUTPUT', label: 'Output' },
{ value: 'BOTH', label: 'Both' },
]}
/>
);
}
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: TracedTapRow) => {
if (row.captureMode === null) return null;
return (
<Button variant="ghost" size="sm" title="Remove" onClick={() => updateTracedProcessor(row.processorId, 'REMOVE')}>
<X size={14} />
</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>;
}
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 variant="ghost" size="sm" onClick={() => navigate('/admin/appconfig')}><ArrowLeft size={14} /> Back</Button>
{editing ? (
<div className={styles.toolbarActions}>
<Button variant="ghost" size="sm" onClick={cancelEditing}>Cancel</Button>
<Button variant="primary" size="sm" onClick={handleSave} loading={updateConfig.isPending}>Save</Button>
</div>
) : (
<Button variant="secondary" size="sm" onClick={startEditing}><Pencil size={14} /> Edit</Button>
)}
</div>
{editing && (
<div className={styles.editBanner}>
Editing configuration. Changes are not saved until you click Save.
</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>
{/* ── Settings ──────────────────────────────────────────────────── */}
<div className={sectionStyles.section}>
<SectionHeader>Settings</SectionHeader>
<div className={styles.settingsGrid}>
<div className={styles.field}>
<Label>App Log Level</Label>
{editing ? (
<Select value={String(form.applicationLogLevel)}
onChange={(e) => updateField('applicationLogLevel', e.target.value)}
options={[
{ value: 'ERROR', label: 'ERROR' },
{ value: 'WARN', label: 'WARN' },
{ value: 'INFO', label: 'INFO' },
{ value: 'DEBUG', label: 'DEBUG' },
{ value: 'TRACE', label: 'TRACE' },
]}
/>
) : (
<Badge label={String(form.applicationLogLevel)} color={logLevelColor(form.applicationLogLevel as string)} variant="filled" />
)}
</div>
<div className={styles.field}>
<Label>Agent Log Level</Label>
{editing ? (
<Select value={String(form.agentLogLevel ?? 'INFO')}
onChange={(e) => updateField('agentLogLevel', e.target.value)}
options={[
{ value: 'ERROR', label: 'ERROR' },
{ value: 'WARN', label: 'WARN' },
{ value: 'INFO', label: 'INFO' },
{ value: 'DEBUG', label: 'DEBUG' },
{ value: 'TRACE', label: 'TRACE' },
]}
/>
) : (
<Badge label={String(form.agentLogLevel ?? 'INFO')} color={logLevelColor(form.agentLogLevel as string)} variant="filled" />
)}
</div>
<div className={styles.field}>
<Label>Engine Level</Label>
{editing ? (
<Select value={String(form.engineLevel)}
onChange={(e) => updateField('engineLevel', e.target.value)}
options={[
{ value: 'NONE', label: 'None' },
{ value: 'MINIMAL', label: 'Minimal' },
{ value: 'REGULAR', label: 'Regular' },
{ value: 'COMPLETE', label: 'Complete' },
]}
/>
) : (
<Badge label={String(form.engineLevel)} color={engineLevelColor(form.engineLevel as string)} variant="filled" />
)}
</div>
<div className={styles.field}>
<Label>Payload Capture</Label>
{editing ? (
<Select value={String(form.payloadCaptureMode)}
onChange={(e) => updateField('payloadCaptureMode', e.target.value)}
options={[
{ value: 'NONE', label: 'None' },
{ value: 'INPUT', label: 'Input' },
{ value: 'OUTPUT', label: 'Output' },
{ value: 'BOTH', label: 'Both' },
]}
/>
) : (
<Badge label={String(form.payloadCaptureMode)} color={payloadColor(form.payloadCaptureMode as string)} variant="filled" />
)}
</div>
<div className={styles.field}>
<Label>Metrics</Label>
{editing ? (
<Toggle
checked={Boolean(form.metricsEnabled)}
onChange={(e) => updateField('metricsEnabled', (e.target as HTMLInputElement).checked)}
label={form.metricsEnabled ? 'Enabled' : 'Disabled'}
/>
) : (
<Badge label={form.metricsEnabled ? 'On' : 'Off'} color={form.metricsEnabled ? 'success' : 'error'} variant="filled" />
)}
</div>
<div className={styles.field}>
<Label>Sampling Rate</Label>
{editing ? (
<input type="number" className={styles.numberInput} min={0} max={1} step={0.01}
value={form.samplingRate ?? 1.0}
onChange={(e) => updateField('samplingRate', parseFloat(e.target.value) || 0)} />
) : (
<MonoText size="xs">{form.samplingRate}</MonoText>
)}
</div>
<div className={styles.field}>
<Label>Compress Success</Label>
{editing ? (
<Toggle
checked={Boolean(form.compressSuccess)}
onChange={(e) => updateField('compressSuccess', (e.target as HTMLInputElement).checked)}
label={form.compressSuccess ? 'On' : 'Off'}
/>
) : (
<Badge label={form.compressSuccess ? 'On' : 'Off'} color={form.compressSuccess ? 'success' : 'error'} variant="filled" />
)}
</div>
</div>
</div>
{/* ── Traces & Taps ─────────────────────────────────────────────── */}
<div className={sectionStyles.section}>
<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 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={sectionStyles.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>
</div>
);
}