feat: App Config slide-in with Route column, clickable taps, and edit toolbar
- 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:
@@ -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}>—</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}>—</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')}>×</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 && <> · 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 && <> · 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">✎</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 · {tapCount} taps · manage taps on route pages</span>
|
||||
<span className={styles.sectionSummary}>{tracedCount} traced · {tapCount} taps · 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>}
|
||||
|
||||
Reference in New Issue
Block a user