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

8
ui/package-lock.json generated
View File

@@ -8,7 +8,7 @@
"name": "ui",
"version": "0.0.0",
"dependencies": {
"@cameleer/design-system": "^0.1.14",
"@cameleer/design-system": "^0.1.15",
"@tanstack/react-query": "^5.90.21",
"openapi-fetch": "^0.17.0",
"react": "^19.2.4",
@@ -276,9 +276,9 @@
}
},
"node_modules/@cameleer/design-system": {
"version": "0.1.14",
"resolved": "https://gitea.siegeln.net/api/packages/cameleer/npm/%40cameleer%2Fdesign-system/-/0.1.14/design-system-0.1.14.tgz",
"integrity": "sha512-kdWdpep9odlQoHCfUU98ZlMzM98tfu9LWOweCYnUR53V9aAdeytZcB2hEP7waIJrrFAGgjD/62s3sLkrzl1LXA==",
"version": "0.1.15",
"resolved": "https://gitea.siegeln.net/api/packages/cameleer/npm/%40cameleer%2Fdesign-system/-/0.1.15/design-system-0.1.15.tgz",
"integrity": "sha512-+Lt9uu6jKQ+BsJRWwxwdYR5xdDMiDVxopNOE9kiUwiu1GOjEs1s/jz7rnjXwqsuMb9zOZADwu9MLLpoYeivJpw==",
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0",

View File

@@ -14,7 +14,7 @@
"generate-api:live": "curl -s http://localhost:8081/api/v1/api-docs -o src/api/openapi.json && openapi-typescript src/api/openapi.json -o src/api/schema.d.ts"
},
"dependencies": {
"@cameleer/design-system": "^0.1.14",
"@cameleer/design-system": "^0.1.15",
"@tanstack/react-query": "^5.90.21",
"openapi-fetch": "^0.17.0",
"react": "^19.2.4",

View File

@@ -76,6 +76,24 @@
font-size: 11px;
}
.routeLabel {
font-size: 11px;
color: var(--text-secondary);
}
.tapBadgeLink {
background: none;
border: none;
padding: 0;
cursor: pointer;
border-radius: var(--radius-sm);
transition: opacity 0.15s;
}
.tapBadgeLink:hover {
opacity: 0.75;
}
.removeBtn {
background: none;
border: none;
@@ -90,8 +108,36 @@
color: var(--error);
}
.panelToolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.panelMeta {
font-size: 11px;
color: var(--text-muted);
margin-bottom: 12px;
}
.panelActions {
display: flex;
gap: 8px;
align-items: center;
}
.editBtn {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
font-size: 14px;
padding: 4px 6px;
border-radius: var(--radius-sm);
transition: color 0.15s;
}
.editBtn:hover {
color: var(--text-primary);
}

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>}

View File

@@ -1,5 +1,5 @@
import { useState, useMemo, useCallback } from 'react';
import { useParams, useNavigate, Link } from 'react-router';
import { useParams, useNavigate, useSearchParams, Link } from 'react-router';
import {
KpiStrip,
Badge,
@@ -270,7 +270,8 @@ export default function RouteDetail() {
const timeFrom = timeRange.start.toISOString();
const timeTo = timeRange.end.toISOString();
const [activeTab, setActiveTab] = useState('performance');
const [searchParams] = useSearchParams();
const [activeTab, setActiveTab] = useState(searchParams.get('tab') || 'performance');
const [recentSortField, setRecentSortField] = useState<string>('startTime');
const [recentSortDir, setRecentSortDir] = useState<'asc' | 'desc'>('desc');