UI overhaul: unified sidebar layout with app-scoped views
Some checks failed
CI / build (push) Failing after 48s
CI / docker (push) Has been skipped
CI / deploy (push) Has been skipped

Replace disconnected Transactions/Applications pages with a persistent
collapsible sidebar listing apps by health status. Add app-scoped view
(/apps/:group) with filtered stats, route chips, and scoped table.
Merge Processor Tree into diagram detail panel with Inspector/Tree
toggle and resizable divider. Remove max-width constraint for full
viewport usage. All view states are deep-linkable via URL.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-15 15:47:33 +01:00
parent 0b56590e3f
commit 7fd8a787d0
16 changed files with 1111 additions and 327 deletions

View File

@@ -0,0 +1,184 @@
import { useState, useMemo, useCallback } from 'react';
import { useParams, useNavigate, NavLink } from 'react-router';
import { useAgents } from '../../api/queries/agents';
import { useSearchExecutions, useExecutionStats, useStatsTimeseries } from '../../api/queries/executions';
import { StatCard } from '../../components/shared/StatCard';
import { ResultsTable } from '../executions/ResultsTable';
import { Pagination } from '../../components/shared/Pagination';
import { FilterChip } from '../../components/shared/FilterChip';
import type { SearchRequest } from '../../api/types';
import styles from './AppScopedView.module.css';
function todayMidnight(): string {
const d = new Date();
d.setHours(0, 0, 0, 0);
const pad = (n: number) => n.toString().padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T00:00`;
}
function formatCompact(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
return n.toLocaleString();
}
function pctChange(current: number, previous: number): { text: string; direction: 'up' | 'down' | 'neutral' } {
if (previous === 0) return { text: 'no prior data', direction: 'neutral' };
const pct = ((current - previous) / previous) * 100;
if (Math.abs(pct) < 0.5) return { text: '~0% vs yesterday', direction: 'neutral' };
const arrow = pct > 0 ? '\u2191' : '\u2193';
return { text: `${arrow} ${Math.abs(pct).toFixed(1)}% vs yesterday`, direction: pct > 0 ? 'up' : 'down' };
}
export function AppScopedView() {
const { group } = useParams<{ group: string }>();
const navigate = useNavigate();
const { data: agents } = useAgents();
const [selectedRoute, setSelectedRoute] = useState<string | null>(null);
const [status, setStatus] = useState<string[]>(['COMPLETED', 'FAILED']);
const [live, setLive] = useState(true);
const [offset, setOffset] = useState(0);
const limit = 25;
// Find agents belonging to this group
const groupAgents = useMemo(() => {
if (!agents || !group) return [];
return agents.filter((a) => (a.group ?? 'default') === group);
}, [agents, group]);
const liveCount = groupAgents.filter((a) => a.status === 'LIVE').length;
const staleCount = groupAgents.filter((a) => a.status === 'STALE').length;
const deadCount = groupAgents.filter((a) => a.status === 'DEAD').length;
// Collect unique routes from agents
const routeIds = useMemo(() => {
const set = new Set<string>();
for (const a of groupAgents) {
if (a.routeIds) for (const rid of a.routeIds) set.add(rid);
}
return Array.from(set).sort();
}, [groupAgents]);
// Build search request scoped to this group
const timeFrom = todayMidnight();
const timeFromIso = new Date(timeFrom).toISOString();
const searchRequest: SearchRequest = useMemo(() => ({
group: group || undefined,
routeId: selectedRoute || undefined,
status: status.length > 0 && status.length < 3 ? status.join(',') : undefined,
timeFrom: timeFromIso,
offset,
limit,
sortField: 'startTime',
sortDir: 'desc',
}), [group, selectedRoute, status, timeFromIso, offset, limit]);
const { data, isLoading, isFetching } = useSearchExecutions(searchRequest, live);
const { data: stats } = useExecutionStats(timeFromIso, undefined, selectedRoute || undefined, group);
const { data: timeseries } = useStatsTimeseries(timeFromIso, undefined, selectedRoute || undefined, group);
const sparkTotal = timeseries?.buckets.map((b) => b.totalCount) ?? [];
const sparkFailed = timeseries?.buckets.map((b) => b.failedCount) ?? [];
const sparkAvgDuration = timeseries?.buckets.map((b) => b.avgDurationMs) ?? [];
const sparkP99 = timeseries?.buckets.map((b) => b.p99DurationMs) ?? [];
const sparkActive = timeseries?.buckets.map((b) => b.activeCount) ?? [];
const total = data?.total ?? 0;
const results = data?.data ?? [];
const failureRate = stats && stats.totalCount > 0
? (stats.failedCount / stats.totalCount) * 100 : 0;
const prevFailureRate = stats && stats.prevTotalCount > 0
? (stats.prevFailedCount / stats.prevTotalCount) * 100 : 0;
const avgChange = stats ? pctChange(stats.avgDurationMs, stats.prevAvgDurationMs) : null;
const failRateChange = stats ? pctChange(failureRate, prevFailureRate) : null;
const p99Change = stats ? pctChange(stats.p99LatencyMs, stats.prevP99LatencyMs) : null;
const showFrom = total > 0 ? offset + 1 : 0;
const showTo = Math.min(offset + limit, total);
const toggleRoute = useCallback((rid: string) => {
setSelectedRoute((prev) => prev === rid ? null : rid);
setOffset(0);
}, []);
if (!group) {
return <div className={styles.loading}>Missing group parameter</div>;
}
return (
<>
{/* Breadcrumb */}
<nav className={styles.breadcrumb}>
<NavLink to="/executions" className={styles.breadcrumbLink}>All</NavLink>
<span className={styles.breadcrumbSep}>/</span>
<span className={styles.breadcrumbCurrent}>{group}</span>
</nav>
{/* App Header */}
<div className={styles.appHeader}>
<div className={styles.appTitle}>{group}</div>
<div className={styles.agentSummary}>
{liveCount > 0 && <span className={styles.agentLive}>{liveCount} live</span>}
{staleCount > 0 && <span className={styles.agentStale}>{staleCount} stale</span>}
{deadCount > 0 && <span className={styles.agentDead}>{deadCount} dead</span>}
{groupAgents.length === 0 && <span className={styles.agentDead}>no agents</span>}
</div>
</div>
{/* Stats Bar */}
<div className={styles.statsBar}>
<StatCard label="Total Matches" value={total.toLocaleString()} accent="amber" change={stats ? `of ${formatCompact(stats.totalToday)} today` : 'from current search'} sparkData={sparkTotal} />
<StatCard label="Avg Duration" value={stats ? `${stats.avgDurationMs.toLocaleString()}ms` : '--'} accent="cyan" change={avgChange?.text} changeDirection={avgChange?.direction} sparkData={sparkAvgDuration} />
<StatCard label="Failure Rate" value={stats ? `${failureRate.toFixed(1)}%` : '--'} accent="rose" change={failRateChange?.text} changeDirection={failRateChange?.direction} sparkData={sparkFailed} />
<StatCard label="P99 Latency" value={stats ? `${stats.p99LatencyMs.toLocaleString()}ms` : '--'} accent="green" change={p99Change?.text} changeDirection={p99Change?.direction} sparkData={sparkP99} />
<StatCard label="In-Flight" value={stats ? stats.activeCount.toLocaleString() : '--'} accent="blue" change="running executions" sparkData={sparkActive} />
</div>
{/* Route Chips + Status Filters */}
<div className={styles.filterBar}>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>Status</label>
<FilterChip label="Completed" accent="green" active={status.includes('COMPLETED')} onClick={() => setStatus((s) => s.includes('COMPLETED') ? s.filter((x) => x !== 'COMPLETED') : [...s, 'COMPLETED'])} />
<FilterChip label="Failed" accent="rose" active={status.includes('FAILED')} onClick={() => setStatus((s) => s.includes('FAILED') ? s.filter((x) => x !== 'FAILED') : [...s, 'FAILED'])} />
<FilterChip label="Running" accent="blue" active={status.includes('RUNNING')} onClick={() => setStatus((s) => s.includes('RUNNING') ? s.filter((x) => x !== 'RUNNING') : [...s, 'RUNNING'])} />
</div>
<button className={`${styles.liveToggle} ${live ? styles.liveOn : styles.liveOff}`} onClick={() => setLive(!live)}>
<span className={styles.liveDot} />
{live ? 'LIVE' : 'PAUSED'}
</button>
</div>
{/* Route Chips */}
{routeIds.length > 0 && (
<div className={styles.routeChips}>
{routeIds.map((rid) => (
<button
key={rid}
className={`${styles.routeChip} ${selectedRoute === rid ? styles.routeChipActive : ''}`}
onClick={() => toggleRoute(rid)}
>
{rid}
</button>
))}
</div>
)}
{/* Results Header */}
<div className={styles.resultsHeader}>
<span className={styles.resultsCount}>
Showing <strong>{showFrom}{showTo}</strong> of <strong>{total.toLocaleString()}</strong> results
{isFetching && !isLoading && ' · updating...'}
</span>
</div>
{/* Results Table */}
<ResultsTable results={results} loading={isLoading} />
{/* Pagination */}
<Pagination total={total} offset={offset} limit={limit} onChange={setOffset} />
</>
);
}