feat: migrate traces/taps and route recording into Deployments config
Some checks failed
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m19s
CI / deploy (push) Has been cancelled
CI / deploy-feature (push) Has been cancelled
CI / docker (push) Has been cancelled

ConfigSubTab now uses inner tabs (Agent / Infrastructure):
- Agent: observability settings, compress success, traces & taps table,
  route recording toggles
- Infrastructure: container resources, exposed ports, environment variables

This completes the Config tab consolidation — all features from the
standalone Config page now live in the Deployments tab.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-08 18:09:12 +02:00
parent 0a0733def7
commit 967156d41b
2 changed files with 281 additions and 109 deletions

View File

@@ -380,3 +380,55 @@
opacity: 0.3; opacity: 0.3;
cursor: default; cursor: default;
} }
/* Traces & Taps */
.sectionSummary {
font-size: 12px;
color: var(--text-muted);
margin-bottom: 8px;
display: block;
}
.hint {
color: var(--text-muted);
font-size: 12px;
font-style: italic;
}
.routeLabel {
font-size: 12px;
color: var(--text-muted);
}
.tapBadges {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.tapBadgeLink {
background: none;
border: none;
padding: 0;
cursor: pointer;
border-radius: 4px;
transition: opacity 0.15s;
}
.tapBadgeLink:hover {
opacity: 0.75;
}
.removeBtn {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
font-size: 16px;
padding: 0 4px;
line-height: 1;
}
.removeBtn:hover {
color: var(--error);
}

View File

@@ -31,8 +31,10 @@ import {
} from '../../api/queries/admin/apps'; } from '../../api/queries/admin/apps';
import type { App, AppVersion, Deployment } from '../../api/queries/admin/apps'; import type { App, AppVersion, Deployment } from '../../api/queries/admin/apps';
import type { Environment } from '../../api/queries/admin/environments'; import type { Environment } from '../../api/queries/admin/environments';
import { useApplicationConfig, useUpdateApplicationConfig } from '../../api/queries/commands'; import { useApplicationConfig, useUpdateApplicationConfig, useProcessorRouteMapping } 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 './AppsTab.module.css'; import styles from './AppsTab.module.css';
function formatBytes(bytes: number): string { function formatBytes(bytes: number): string {
@@ -456,13 +458,26 @@ function VersionRow({ version, environments, onDeploy }: { version: AppVersion;
// CONFIGURATION SUB-TAB // CONFIGURATION SUB-TAB
// ═══════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════
interface TracedTapRow { id: string; processorId: string; captureMode: string | null; taps: TapDefinition[]; }
interface RouteRecordingRow { id: string; routeId: string; recording: boolean; }
function ConfigSubTab({ app, environment }: { app: App; environment?: Environment }) { function ConfigSubTab({ app, environment }: { app: App; environment?: Environment }) {
const { toast } = useToast(); const { toast } = useToast();
const navigate = useNavigate();
const { data: agentConfig } = useApplicationConfig(app.slug); const { data: agentConfig } = useApplicationConfig(app.slug);
const updateAgentConfig = useUpdateApplicationConfig(); const updateAgentConfig = useUpdateApplicationConfig();
const updateContainerConfig = useUpdateContainerConfig(); const updateContainerConfig = useUpdateContainerConfig();
const { data: catalog } = useRouteCatalog();
const { data: processorToRoute = {} } = useProcessorRouteMapping(app.slug);
const isProd = environment?.production ?? false; const isProd = environment?.production ?? false;
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [configTab, setConfigTab] = useState<'agent' | 'infra'>('agent');
const appRoutes: RouteSummary[] = useMemo(() => {
if (!catalog) return [];
const entry = (catalog as AppCatalogEntry[]).find((e) => e.appId === app.slug);
return entry?.routes ?? [];
}, [catalog, app.slug]);
// Agent config state // Agent config state
const [engineLevel, setEngineLevel] = useState('REGULAR'); const [engineLevel, setEngineLevel] = useState('REGULAR');
@@ -476,6 +491,9 @@ function ConfigSubTab({ app, environment }: { app: App; environment?: Environmen
const [samplingRate, setSamplingRate] = useState('1.0'); const [samplingRate, setSamplingRate] = useState('1.0');
const [replayEnabled, setReplayEnabled] = useState(true); const [replayEnabled, setReplayEnabled] = useState(true);
const [routeControlEnabled, setRouteControlEnabled] = useState(true); const [routeControlEnabled, setRouteControlEnabled] = useState(true);
const [compressSuccess, setCompressSuccess] = useState(false);
const [tracedDraft, setTracedDraft] = useState<Record<string, string>>({});
const [routeRecordingDraft, setRouteRecordingDraft] = useState<Record<string, boolean>>({});
// Container config state // Container config state
const defaults = environment?.defaultContainerConfig ?? {}; const defaults = environment?.defaultContainerConfig ?? {};
@@ -493,12 +511,14 @@ function ConfigSubTab({ app, environment }: { app: App; environment?: Environmen
if (agentConfig) { if (agentConfig) {
setEngineLevel(agentConfig.engineLevel ?? 'REGULAR'); setEngineLevel(agentConfig.engineLevel ?? 'REGULAR');
setPayloadCapture(agentConfig.payloadCaptureMode ?? 'BOTH'); setPayloadCapture(agentConfig.payloadCaptureMode ?? 'BOTH');
const raw = agentConfig.payloadCaptureMode !== undefined ? 4096 : 4096; // TODO: read from config when available
setPayloadSize('4'); setPayloadUnit('KB'); setPayloadSize('4'); setPayloadUnit('KB');
setAppLogLevel(agentConfig.applicationLogLevel ?? 'INFO'); setAppLogLevel(agentConfig.applicationLogLevel ?? 'INFO');
setAgentLogLevel(agentConfig.agentLogLevel ?? 'INFO'); setAgentLogLevel(agentConfig.agentLogLevel ?? 'INFO');
setMetricsEnabled(agentConfig.metricsEnabled); setMetricsEnabled(agentConfig.metricsEnabled);
setSamplingRate(String(agentConfig.samplingRate)); setSamplingRate(String(agentConfig.samplingRate));
setCompressSuccess(agentConfig.compressSuccess);
setTracedDraft({ ...agentConfig.tracedProcessors });
setRouteRecordingDraft({ ...agentConfig.routeRecording });
} }
setMemoryLimit(String(merged.memoryLimitMb ?? 512)); setMemoryLimit(String(merged.memoryLimitMb ?? 512));
setMemoryReserve(String(merged.memoryReserveMb ?? '')); setMemoryReserve(String(merged.memoryReserveMb ?? ''));
@@ -516,11 +536,15 @@ function ConfigSubTab({ app, environment }: { app: App; environment?: Environmen
setEditing(false); setEditing(false);
} }
function payloadSizeToBytes(): number { function updateTracedProcessor(processorId: string, mode: string) {
const val = parseFloat(payloadSize) || 0; setTracedDraft((prev) => {
if (payloadUnit === 'KB') return val * 1024; if (mode === 'REMOVE') { const next = { ...prev }; delete next[processorId]; return next; }
if (payloadUnit === 'MB') return val * 1048576; return { ...prev, [processorId]: mode };
return val; });
}
function updateRouteRecording(routeId: string, recording: boolean) {
setRouteRecordingDraft((prev) => ({ ...prev, [routeId]: recording }));
} }
async function handleSave() { async function handleSave() {
@@ -532,6 +556,9 @@ function ConfigSubTab({ app, environment }: { app: App; environment?: Environmen
engineLevel, payloadCaptureMode: payloadCapture, engineLevel, payloadCaptureMode: payloadCapture,
applicationLogLevel: appLogLevel, agentLogLevel, applicationLogLevel: appLogLevel, agentLogLevel,
metricsEnabled, samplingRate: parseFloat(samplingRate) || 1.0, metricsEnabled, samplingRate: parseFloat(samplingRate) || 1.0,
compressSuccess,
tracedProcessors: tracedDraft,
routeRecording: routeRecordingDraft,
}); });
} catch { toast({ title: 'Failed to save agent config', variant: 'error', duration: 86_400_000 }); return; } } catch { toast({ title: 'Failed to save agent config', variant: 'error', duration: 86_400_000 }); return; }
} }
@@ -557,6 +584,66 @@ function ConfigSubTab({ app, environment }: { app: App; environment?: Environmen
if (p && !ports.includes(p)) { setPorts([...ports, p]); setNewPort(''); } if (p && !ports.includes(p)) { setPorts([...ports, p]); setNewPort(''); }
} }
// Traces & Taps
const tracedTapRows: TracedTapRow[] = useMemo(() => {
const traced = editing ? tracedDraft : (agentConfig?.tracedProcessors ?? {});
const taps = agentConfig?.taps ?? [];
const pids = new Set<string>([...Object.keys(traced), ...taps.map(t => t.processorId)]);
return Array.from(pids).sort().map(pid => ({ id: pid, processorId: pid, captureMode: traced[pid] ?? null, taps: taps.filter(t => t.processorId === pid) }));
}, [editing, tracedDraft, agentConfig?.tracedProcessors, agentConfig?.taps]);
const tracedCount = useMemo(() => Object.keys(editing ? tracedDraft : (agentConfig?.tracedProcessors ?? {})).length, [editing, tracedDraft, agentConfig?.tracedProcessors]);
const tapCount = agentConfig?.taps?.length ?? 0;
const tracedTapColumns: Column<TracedTapRow>[] = useMemo(() => [
{ key: 'route' as any, header: 'Route', render: (_v: unknown, row: TracedTapRow) => {
const routeId = processorToRoute[row.processorId];
return routeId ? <span className={styles.routeLabel}>{routeId}</span> : <span className={styles.hint}>&mdash;</span>;
}},
{ key: 'processorId', header: 'Processor', render: (_v: unknown, row: TracedTapRow) => <MonoText size="xs">{row.processorId}</MonoText> },
{
key: 'captureMode', header: 'Capture',
render: (_v: unknown, row: TracedTapRow) => {
if (row.captureMode === null) return <span className={styles.hint}>&mdash;</span>;
if (editing) return (
<select className={styles.nativeSelect} 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={row.captureMode === 'BOTH' ? 'running' : row.captureMode === 'NONE' ? 'auto' : 'warning'} variant="filled" />;
},
},
{
key: 'taps', header: 'Taps',
render: (_v: unknown, row: TracedTapRow) => row.taps.length === 0
? <span className={styles.hint}>&mdash;</span>
: <div className={styles.tapBadges}>{row.taps.map(t => (
<button key={t.tapId} className={styles.tapBadgeLink} title={`Manage tap on route page`}>
<Badge label={t.attributeName} color={t.enabled ? 'success' : 'auto'} variant="filled" />
</button>
))}</div>,
},
...(editing ? [{
key: '_remove' as const, header: '', width: '36px',
render: (_v: unknown, row: TracedTapRow) => row.captureMode === null ? null : (
<button className={styles.removeBtn} title="Remove" onClick={() => updateTracedProcessor(row.processorId, 'REMOVE')}>&times;</button>
),
}] : []),
], [editing, processorToRoute]);
// Route Recording
const routeRecordingRows: RouteRecordingRow[] = useMemo(() => {
const rec = editing ? routeRecordingDraft : (agentConfig?.routeRecording ?? {});
return appRoutes.map(r => ({ id: r.routeId, routeId: r.routeId, recording: rec[r.routeId] !== false }));
}, [editing, routeRecordingDraft, agentConfig?.routeRecording, appRoutes]);
const recordingCount = routeRecordingRows.filter(r => r.recording).length;
const routeRecordingColumns: Column<RouteRecordingRow>[] = useMemo(() => [
{ key: 'routeId', header: 'Route', render: (_v: unknown, row: RouteRecordingRow) => <MonoText size="xs">{row.routeId}</MonoText> },
{ key: 'recording', header: 'Recording', width: '100px', render: (_v: unknown, row: RouteRecordingRow) => <Toggle checked={row.recording} onChange={() => { if (editing) updateRouteRecording(row.routeId, !row.recording); }} disabled={!editing} /> },
], [editing, routeRecordingDraft]);
return ( return (
<> <>
{!editing && ( {!editing && (
@@ -576,8 +663,15 @@ function ConfigSubTab({ app, environment }: { app: App; environment?: Environmen
</div> </div>
)} )}
{/* Agent Observability */} <div className={styles.subTabs}>
<SectionHeader>Agent Observability</SectionHeader> <button className={`${styles.subTab} ${configTab === 'agent' ? styles.subTabActive : ''}`} onClick={() => setConfigTab('agent')}>Agent</button>
<button className={`${styles.subTab} ${configTab === 'infra' ? styles.subTabActive : ''}`} onClick={() => setConfigTab('infra')}>Infrastructure</button>
</div>
{configTab === 'agent' && (
<>
{/* Observability Settings */}
<SectionHeader>Observability</SectionHeader>
<div className={styles.configGrid}> <div className={styles.configGrid}>
<span className={styles.configLabel}>Engine Level</span> <span className={styles.configLabel}>Engine Level</span>
<Select disabled={!editing} value={engineLevel} onChange={(e) => setEngineLevel(e.target.value)} <Select disabled={!editing} value={engineLevel} onChange={(e) => setEngineLevel(e.target.value)}
@@ -614,6 +708,12 @@ function ConfigSubTab({ app, environment }: { app: App; environment?: Environmen
<span className={styles.configLabel}>Sampling Rate</span> <span className={styles.configLabel}>Sampling Rate</span>
<Input disabled={!editing} value={samplingRate} onChange={(e) => setSamplingRate(e.target.value)} style={{ width: 80 }} /> <Input disabled={!editing} value={samplingRate} onChange={(e) => setSamplingRate(e.target.value)} style={{ width: 80 }} />
<span className={styles.configLabel}>Compress Success</span>
<div className={styles.configInline}>
<Toggle checked={compressSuccess} onChange={() => editing && setCompressSuccess(!compressSuccess)} disabled={!editing} />
<span className={compressSuccess ? styles.toggleEnabled : styles.toggleDisabled}>{compressSuccess ? 'Enabled' : 'Disabled'}</span>
</div>
<span className={styles.configLabel}>Replay</span> <span className={styles.configLabel}>Replay</span>
<div className={styles.configInline}> <div className={styles.configInline}>
<Toggle checked={replayEnabled} onChange={() => editing && setReplayEnabled(!replayEnabled)} disabled={!editing} /> <Toggle checked={replayEnabled} onChange={() => editing && setReplayEnabled(!replayEnabled)} disabled={!editing} />
@@ -627,6 +727,24 @@ function ConfigSubTab({ app, environment }: { app: App; environment?: Environmen
</div> </div>
</div> </div>
{/* Traces & Taps */}
<SectionHeader>Traces & Taps</SectionHeader>
<span className={styles.sectionSummary}>{tracedCount} traced &middot; {tapCount} taps</span>
{tracedTapRows.length > 0
? <DataTable<TracedTapRow> columns={tracedTapColumns} data={tracedTapRows} pageSize={20} flush />
: <p className={styles.emptyNote}>No processor traces or taps configured.</p>}
{/* Route Recording */}
<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} flush />
: <p className={styles.emptyNote}>No routes found for this application.</p>}
</>
)}
{configTab === 'infra' && (
<>
{/* Container Resources */} {/* Container Resources */}
<SectionHeader>Container Resources</SectionHeader> <SectionHeader>Container Resources</SectionHeader>
<div className={styles.configGrid}> <div className={styles.configGrid}>
@@ -687,5 +805,7 @@ function ConfigSubTab({ app, environment }: { app: App; environment?: Environmen
<Button size="sm" variant="secondary" onClick={() => setEnvVars([...envVars, { key: '', value: '' }])}>+ Add Variable</Button> <Button size="sm" variant="secondary" onClick={() => setEnvVars([...envVars, { key: '', value: '' }])}>+ Add Variable</Button>
)} )}
</> </>
)}
</>
); );
} }