feat: App Config slide-in with Route column, clickable taps, and edit toolbar
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m4s
CI / docker (push) Successful in 1m19s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 27s

- Add Route column to Traces & Taps table (diagram-based mapping, pending backend fix)
- Make tap badges clickable to navigate to route's Taps tab
- Add edit/save/cancel toolbar with design system Button components
- Move Sampling Rate to last position in settings grid
- Support ?tab= URL param on RouteDetail for direct tab navigation
- Bump @cameleer/design-system to 0.1.15 (DetailPanel overlay + backdrop)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-26 22:26:28 +01:00
parent ef9ec6069f
commit bd63a8ce95
5 changed files with 128 additions and 20 deletions

View File

@@ -1,4 +1,6 @@
import { useState, useMemo, useEffect } from 'react';
import { useNavigate } from 'react-router';
import { useQueries } from '@tanstack/react-query';
import {
DataTable, Badge, MonoText, DetailPanel, SectionHeader, Button, Toggle, Spinner, useToast,
} from '@cameleer/design-system';
@@ -6,6 +8,7 @@ import type { Column } from '@cameleer/design-system';
import { useAllApplicationConfigs, useApplicationConfig, useUpdateApplicationConfig } from '../../api/queries/commands';
import type { ApplicationConfig, TapDefinition } from '../../api/queries/commands';
import { useRouteCatalog } from '../../api/queries/catalog';
import { api } from '../../api/client';
import type { AppCatalogEntry, RouteSummary } from '../../api/types';
import styles from './AppConfigPage.module.css';
@@ -68,6 +71,7 @@ function buildColumns(): Column<ConfigRow>[] {
function AppConfigDetail({ appId, onClose }: { appId: string; onClose: () => void }) {
const { toast } = useToast();
const navigate = useNavigate();
const { data: config, isLoading } = useApplicationConfig(appId);
const updateConfig = useUpdateApplicationConfig();
const { data: catalog } = useRouteCatalog();
@@ -83,6 +87,33 @@ function AppConfigDetail({ appId, onClose }: { appId: string; onClose: () => voi
return entry?.routes ?? [];
}, [catalog, appId]);
// Fetch diagrams for all routes to build processorId → routeId mapping
const diagramQueries = useQueries({
queries: appRoutes.map((r) => ({
queryKey: ['diagrams', 'byRoute', appId, r.routeId],
queryFn: async () => {
const { data, error } = await api.GET('/diagrams', {
params: { query: { application: appId!, routeId: r.routeId } },
});
if (error) return { routeId: r.routeId, nodes: [] as Array<{ id?: string }> };
return { routeId: r.routeId, nodes: (data as any)?.nodes ?? [] };
},
enabled: !!appId,
staleTime: 60_000,
})),
});
const processorToRoute = useMemo(() => {
const map: Record<string, string> = {};
for (const q of diagramQueries) {
if (!q.data) continue;
for (const node of q.data.nodes) {
if (node.id) map[node.id] = q.data.routeId;
}
}
return map;
}, [diagramQueries.map(q => q.data)]);
useEffect(() => {
if (config) {
setForm({
@@ -137,6 +168,16 @@ function AppConfigDetail({ appId, onClose }: { appId: string; onClose: () => voi
});
}
function navigateToTaps(processorId: string) {
const routeId = processorToRoute[processorId];
onClose();
if (routeId) {
navigate(`/routes/${appId}/${routeId}?tab=taps`);
} else {
navigate(`/routes/${appId}`);
}
}
// ── Traces & Taps merged rows
const tracedTapRows: TracedTapRow[] = useMemo(() => {
const traced = editing ? tracedDraft : (config?.tracedProcessors ?? {});
@@ -149,6 +190,10 @@ function AppConfigDetail({ appId, onClose }: { appId: string; onClose: () => voi
const tapCount = config?.taps?.length ?? 0;
const tracedTapColumns: Column<TracedTapRow>[] = useMemo(() => [
{ key: 'route' as any, header: 'Route', render: (_v, row) => {
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, row) => <MonoText size="xs">{row.processorId}</MonoText> },
{
key: 'captureMode', header: 'Capture',
@@ -166,7 +211,11 @@ function AppConfigDetail({ appId, onClose }: { appId: string; onClose: () => voi
key: 'taps', header: 'Taps',
render: (_v, row) => row.taps.length === 0
? <span className={styles.hint}>&mdash;</span>
: <div className={styles.tapBadges}>{row.taps.map(t => <Badge key={t.tapId} label={t.attributeName} color={t.enabled ? 'success' : 'auto'} variant="filled" />)}</div>,
: <div className={styles.tapBadges}>{row.taps.map(t => (
<button key={t.tapId} className={styles.tapBadgeLink} onClick={() => navigateToTaps(row.processorId)} title={`Manage on route page${processorToRoute[row.processorId] ? ` (${processorToRoute[row.processorId]})` : ''}`}>
<Badge label={t.attributeName} color={t.enabled ? 'success' : 'auto'} variant="filled" />
</button>
))}</div>,
},
...(editing ? [{
key: '_remove' as const, header: '', width: '36px',
@@ -174,7 +223,7 @@ function AppConfigDetail({ appId, onClose }: { appId: string; onClose: () => voi
<button className={styles.removeBtn} title="Remove" onClick={() => updateTracedProcessor(row.processorId, 'REMOVE')}>&times;</button>
),
}] : []),
], [editing]);
], [editing, processorToRoute]);
// ── Route Recording rows
const routeRecordingRows: RouteRecordingRow[] = useMemo(() => {
@@ -194,9 +243,21 @@ function AppConfigDetail({ appId, onClose }: { appId: string; onClose: () => voi
return (
<>
<div className={styles.panelMeta}>
Version <MonoText size="xs">{config.version}</MonoText>
{config.updatedAt && <> &middot; Updated <MonoText size="xs">{timeAgo(config.updatedAt)}</MonoText></>}
<div className={styles.panelToolbar}>
<div className={styles.panelMeta}>
Version <MonoText size="xs">{config.version}</MonoText>
{config.updatedAt && <> &middot; Updated <MonoText size="xs">{timeAgo(config.updatedAt)}</MonoText></>}
</div>
{editing ? (
<div className={styles.panelActions}>
<Button variant="secondary" size="sm" onClick={() => setEditing(false)}>Cancel</Button>
<Button size="sm" onClick={handleSave} disabled={updateConfig.isPending}>
{updateConfig.isPending ? 'Saving\u2026' : 'Save'}
</Button>
</div>
) : (
<button className={styles.editBtn} onClick={startEditing} title="Edit configuration">&#x270E;</button>
)}
</div>
{/* Settings */}
@@ -227,25 +288,25 @@ function AppConfigDetail({ appId, onClose }: { appId: string; onClose: () => voi
? <Toggle checked={Boolean(form.metricsEnabled)} onChange={(e) => updateField('metricsEnabled', (e.target as HTMLInputElement).checked)} />
: <Badge label={form.metricsEnabled ? 'On' : 'Off'} color={form.metricsEnabled ? 'success' : 'error'} variant="filled" />}
</div>
<div className={styles.field}>
<span className={styles.fieldLabel}>Sampling Rate</span>
{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}>
<span className={styles.fieldLabel}>Compress Success</span>
{editing
? <Toggle checked={Boolean(form.compressSuccess)} onChange={(e) => updateField('compressSuccess', (e.target as HTMLInputElement).checked)} />
: <Badge label={form.compressSuccess ? 'On' : 'Off'} color={form.compressSuccess ? 'success' : 'error'} variant="filled" />}
</div>
<div className={styles.field}>
<span className={styles.fieldLabel}>Sampling Rate</span>
{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>
</div>
{/* Traces & Taps */}
<div className={styles.panelSection}>
<div className={styles.panelSectionHeader}>Traces & Taps</div>
<span className={styles.sectionSummary}>{tracedCount} traced &middot; {tapCount} taps &middot; manage taps on route pages</span>
<span className={styles.sectionSummary}>{tracedCount} traced &middot; {tapCount} taps &middot; click tap to manage</span>
{tracedTapRows.length > 0
? <DataTable<TracedTapRow> columns={tracedTapColumns} data={tracedTapRows} pageSize={20} flush />
: <span className={styles.hint}>No processor traces or taps configured.</span>}