Files
cameleer-server/ui/src/pages/Admin/AppConfigDetailPage.tsx
hsiegeln c3b4f70913
Some checks failed
CI / cleanup-branch (push) Has been skipped
CI / build (push) Failing after 30s
CI / docker (push) Has been skipped
CI / deploy (push) Has been skipped
CI / deploy-feature (push) Has been skipped
feat(#116): update command hooks for synchronous group response
Add CommandGroupResponse and ConfigUpdateResponse types. Switch
useSendGroupCommand and useSendRouteCommand from openapi-fetch to authFetch
returning CommandGroupResponse. Update useUpdateApplicationConfig to return
ConfigUpdateResponse and fix all consumer onSuccess callbacks to access
saved.config.version instead of saved.version.

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

460 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, useToast,
} from '@cameleer/design-system';
import type { Column } from '@cameleer/design-system';
import { useApplicationConfig, useUpdateApplicationConfig } 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 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 { 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) {
setForm({
applicationLogLevel: config.applicationLogLevel ?? 'INFO',
agentLogLevel: config.agentLogLevel ?? '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]);
function startEditing() {
if (!config) return;
setForm({
applicationLogLevel: config.applicationLogLevel ?? 'INFO',
agentLogLevel: config.agentLogLevel ?? 'INFO',
engineLevel: config.engineLevel ?? 'REGULAR',
payloadCaptureMode: config.payloadCaptureMode ?? 'NONE',
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) => {
if (mode === 'REMOVE') {
const next = { ...prev };
delete next[processorId];
return next;
}
return { ...prev, [processorId]: mode };
});
}
function updateRouteRecording(routeId: string, recording: boolean) {
setRouteRecordingDraft((prev) => ({ ...prev, [routeId]: recording }));
}
function handleSave() {
if (!config || !form) return;
const updated: ApplicationConfig = {
...config,
...form,
tracedProcessors: tracedDraft,
routeRecording: routeRecordingDraft,
} as ApplicationConfig;
updateConfig.mutate(updated, {
onSuccess: (saved) => {
setEditing(false);
toast({ title: 'Config saved', description: `${appId} updated to v${saved.config.version}`, variant: 'success' });
},
onError: () => {
toast({ title: 'Save failed', description: 'Could not update configuration', variant: 'error' });
},
});
}
// ── 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
className={styles.select}
value={row.captureMode}
onChange={(e) => updateTracedProcessor(row.processorId, 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={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 className={styles.removeBtn} 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 className={styles.backBtn} onClick={() => navigate('/admin/appconfig')}><ArrowLeft size={14} /> 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}><Pencil size={14} /> 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>
{/* ── Settings ──────────────────────────────────────────────────── */}
<div className={styles.section}>
<SectionHeader>Settings</SectionHeader>
<div className={styles.settingsGrid}>
<div className={styles.field}>
<label className={styles.label}>App Log Level</label>
{editing ? (
<select className={styles.select} value={String(form.applicationLogLevel)}
onChange={(e) => updateField('applicationLogLevel', e.target.value)}>
<option value="ERROR">ERROR</option>
<option value="WARN">WARN</option>
<option value="INFO">INFO</option>
<option value="DEBUG">DEBUG</option>
<option value="TRACE">TRACE</option>
</select>
) : (
<Badge label={String(form.applicationLogLevel)} color={logLevelColor(form.applicationLogLevel as string)} variant="filled" />
)}
</div>
<div className={styles.field}>
<label className={styles.label}>Agent Log Level</label>
{editing ? (
<select className={styles.select} value={String(form.agentLogLevel ?? 'INFO')}
onChange={(e) => updateField('agentLogLevel', e.target.value)}>
<option value="ERROR">ERROR</option>
<option value="WARN">WARN</option>
<option value="INFO">INFO</option>
<option value="DEBUG">DEBUG</option>
<option value="TRACE">TRACE</option>
</select>
) : (
<Badge label={String(form.agentLogLevel ?? 'INFO')} color={logLevelColor(form.agentLogLevel 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)} />
) : (
<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>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={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>
</div>
);
}