184 lines
8.5 KiB
TypeScript
184 lines
8.5 KiB
TypeScript
import { useState, useMemo, useCallback } from 'react';
|
||
import { useParams, 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 { 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} />
|
||
</>
|
||
);
|
||
}
|