Skip to content

Commit f384d3f

Browse files
fix(dashboard): differentiate API error states — offline, timeout, server, unauthorized (#3373) (#3395)
- Add getErrorVariant() utility that maps errors to ErrorState variants: - No statusCode + 'Failed to fetch' → offline - No statusCode + 'timeout'/'AbortError' → timeout - 403 → unauthorized - 429 → rate-limited - 404 → not-found - 5xx → server-5xx - AnalyticsPage: replace inline error div with ErrorState + retry button - MetricsPage: replace inline error div with ErrorState + retry button - ErrorState: add role='alert' for a11y compliance All 1300 tests pass. TypeScript strict clean.
1 parent 2b70316 commit f384d3f

4 files changed

Lines changed: 98 additions & 11 deletions

File tree

dashboard/src/components/ErrorState.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ export function ErrorState({ variant, message, onRetry }: ErrorStateProps): Reac
8282
<div
8383
className="flex flex-col items-center justify-center gap-4 py-16 px-6 text-center"
8484
data-testid="error-state"
85+
role="alert"
8586
data-variant={variant}
8687
>
8788
<div className="rounded-full border border-[var(--color-void-lighter)] bg-[var(--color-surface)] p-4">

dashboard/src/pages/AnalyticsPage.tsx

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ import { ModelDistributionBar } from '../components/analytics/ModelDistributionB
2828
import { getAnalyticsSummary, getRateLimitAnalytics } from '../api/client';
2929
import { formatCurrency } from '../utils/formatNumber';
3030
import { formatDateShort } from '../utils/formatDate';
31+
import { ErrorState } from '../components/ErrorState';
32+
import { getErrorVariant } from '../utils/getErrorVariant';
3133
import type { AnalyticsSummary, RateLimitAnalyticsResponse } from '../types';
3234
import { RateLimitChart } from '../components/analytics/RateLimitChart';
3335
import { RateLimitForecastCard } from '../components/analytics/RateLimitForecastCard';
@@ -85,7 +87,7 @@ export default function AnalyticsPage() {
8587
const t = useT();
8688
const [data, setData] = useState<AnalyticsSummary | null>(null);
8789
const [rateLimitData, setRateLimitData] = useState<RateLimitAnalyticsResponse | null>(null);
88-
const [error, setError] = useState<string | null>(null);
90+
const [error, setError] = useState<{ raw: unknown; message: string } | null>(null);
8991
const [loading, setLoading] = useState(true);
9092

9193
const fetchData = useCallback(async () => {
@@ -98,11 +100,11 @@ export default function AnalyticsPage() {
98100
setData(summary.value);
99101
setError(null);
100102
} else {
101-
setError(summary.reason instanceof Error ? summary.reason.message : t('analytics.loadError'));
103+
setError({ raw: summary.reason, message: summary.reason instanceof Error ? summary.reason.message : t('analytics.loadError') });
102104
}
103105
if (rateLimits.status === 'fulfilled') setRateLimitData(rateLimits.value);
104106
} catch (e) {
105-
setError(e instanceof Error ? e.message : t('analytics.loadError'));
107+
setError({ raw: e, message: e instanceof Error ? e.message : t('analytics.loadError') });
106108
} finally {
107109
setLoading(false);
108110
}
@@ -123,9 +125,11 @@ export default function AnalyticsPage() {
123125

124126
if (error) {
125127
return (
126-
<div className="rounded-lg border border-red-500/30 bg-red-500/10 p-4 text-sm text-red-400" role="alert">
127-
Failed to load analytics: {error}
128-
</div>
128+
<ErrorState
129+
variant={getErrorVariant(error.raw)}
130+
message={error.message}
131+
onRetry={() => { void fetchData(); }}
132+
/>
129133
);
130134
}
131135

dashboard/src/pages/MetricsPage.tsx

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ import { formatDateShort } from '../utils/formatDate';
2323
import { downloadCSV } from '../utils/csv-export';
2424
import { ChartFrame } from '../components/shared/ChartFrame';
2525
import { sanitizeErrorMessage } from '../utils/sanitizeErrorMessage';
26+
import { ErrorState } from '../components/ErrorState';
27+
import { getErrorVariant } from '../utils/getErrorVariant';
2628

2729
type RangePreset = '7d' | '30d' | '90d';
2830
type Granularity = 'day' | 'hour' | 'key';
@@ -77,7 +79,7 @@ function generateCSV(data: AggregateMetricsResponse): string {
7779
export default function MetricsPage() {
7880
const t = useT();
7981
const [data, setData] = useState<AggregateMetricsResponse | null>(null);
80-
const [error, setError] = useState<string | null>(null);
82+
const [error, setError] = useState<{ raw: unknown; message: string } | null>(null);
8183
const [range, setRange] = useState<RangePreset>('7d');
8284
const [granularity, setGranularity] = useState<Granularity>('day');
8385
const sseConnected = useStore((s) => s.sseConnected);
@@ -90,7 +92,7 @@ export default function MetricsPage() {
9092
const result = await getMetricsAggregate({ from, to: now.toISOString(), groupBy: granularity });
9193
setData(result);
9294
} catch (err) {
93-
setError(sanitizeErrorMessage(err, t('metrics.loadError')));
95+
setError({ raw: err, message: sanitizeErrorMessage(err, t('metrics.loadError')) });
9496
}
9597
}, [range, granularity]);
9698

@@ -177,9 +179,11 @@ export default function MetricsPage() {
177179

178180
{/* Error state */}
179181
{error && (
180-
<div className="rounded-lg border border-red-500/30 bg-red-500/10 p-4 text-sm text-red-300" role="alert">
181-
{error}
182-
</div>
182+
<ErrorState
183+
variant={getErrorVariant(error.raw)}
184+
message={error.message}
185+
onRetry={() => { void fetchData(); }}
186+
/>
183187
)}
184188

185189
{/* Summary cards */}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/**
2+
* utils/getErrorVariant.ts — Map API/network errors to ErrorState variant.
3+
*
4+
* The API client (api/client.ts) attaches `statusCode` to thrown errors.
5+
* Network failures throw plain Error without statusCode.
6+
* This utility maps both patterns to the correct ErrorState variant.
7+
*/
8+
9+
import type { ErrorVariant } from '../components/ErrorState';
10+
11+
interface ApiError extends Error {
12+
statusCode?: number;
13+
}
14+
15+
/**
16+
* Map an error to the appropriate ErrorState variant.
17+
*
18+
* - No statusCode + "Failed to fetch" → offline
19+
* - No statusCode + "timeout" / "AbortError" → timeout
20+
* - 403 → unauthorized
21+
* - 429 → rate-limited
22+
* - 404 → not-found
23+
* - 5xx → server-5xx
24+
* - Other → server-5xx (safe default)
25+
*/
26+
export function getErrorVariant(error: unknown): ErrorVariant {
27+
const err = error as ApiError;
28+
29+
// Network errors (no statusCode)
30+
if (!err?.statusCode) {
31+
const msg = (err?.message ?? '').toLowerCase();
32+
33+
if (msg.includes('failed to fetch') || msg.includes('network') || msg.includes('net::')) {
34+
return 'offline';
35+
}
36+
37+
if (msg.includes('timeout') || msg.includes('timed out') || msg.includes('abort')) {
38+
return 'timeout';
39+
}
40+
41+
// Unknown error without status code — assume server issue
42+
return 'server-5xx';
43+
}
44+
45+
// HTTP status code based mapping
46+
const status = err.statusCode;
47+
48+
if (status === 403) return 'unauthorized';
49+
if (status === 404) return 'not-found';
50+
if (status === 429) return 'rate-limited';
51+
if (status >= 500) return 'server-5xx';
52+
53+
// 4xx other than 403/404/429 — treat as server error for UX
54+
return 'server-5xx';
55+
}
56+
57+
/**
58+
* Get a human-readable error message from an API error.
59+
* Uses sanitizeErrorMessage for technical error cleanup.
60+
*/
61+
export function getUserErrorMessage(error: unknown, fallback?: string): string {
62+
const err = error as ApiError;
63+
const raw = err?.message ?? '';
64+
65+
// Network errors
66+
if (!err?.statusCode) {
67+
const msg = raw.toLowerCase();
68+
if (msg.includes('failed to fetch') || msg.includes('network')) {
69+
return 'Unable to reach the Aegis server. Check your connection.';
70+
}
71+
if (msg.includes('timeout') || msg.includes('timed out')) {
72+
return 'The request timed out. The server may be under load.';
73+
}
74+
}
75+
76+
// For status-coded errors, use the raw message (already sanitized by API client)
77+
return raw || fallback || 'Something went wrong. Please try again.';
78+
}

0 commit comments

Comments
 (0)