Files
cameleer-server/ui/src/pages/dashboard/AppScopedView.tsx
hsiegeln 4ea6814bb3
All checks were successful
CI / build (push) Successful in 1m8s
CI / docker (push) Successful in 53s
CI / deploy (push) Successful in 34s
Fix unused import in AppScopedView
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 15:49:35 +01:00

184 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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} />
</>
);
}