UI overhaul: unified sidebar layout with app-scoped views
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:
184
ui/src/pages/dashboard/AppScopedView.tsx
Normal file
184
ui/src/pages/dashboard/AppScopedView.tsx
Normal 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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user