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:
8
ui/package-lock.json
generated
8
ui/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user