Files
cameleer-server/ui/src/pages/Admin/AppConfigDetailPage.tsx

468 lines
18 KiB
TypeScript
Raw Normal View History

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 ?? '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) => 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: 'Save failed', 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>
<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>
);
}