feat(admin-web): add dashboard retry state
This commit is contained in:
parent
e69ffadb4b
commit
f77797881b
105
dashboards/admin-web/e2e/dashboard-reliability.spec.ts
Normal file
105
dashboards/admin-web/e2e/dashboard-reliability.spec.ts
Normal file
@ -0,0 +1,105 @@
|
||||
import { test, expect, type Page, type Route } from '@playwright/test';
|
||||
|
||||
function authenticate(page: Page) {
|
||||
return page.addInitScript(() => {
|
||||
localStorage.setItem('admin_access_token', 'mock-token');
|
||||
localStorage.setItem('admin_refresh_token', 'mock-refresh');
|
||||
localStorage.setItem(
|
||||
'admin_auth_user',
|
||||
JSON.stringify({
|
||||
email: 'admin@example.com',
|
||||
name: 'Admin User',
|
||||
role: 'super_admin',
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function fulfillJson(route: Route, body: unknown, status = 200) {
|
||||
await route.fulfill({
|
||||
status,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
test.describe('Admin dashboard reliability', () => {
|
||||
test('shows an in-page retry path when dashboard APIs fail', async ({ page }) => {
|
||||
await authenticate(page);
|
||||
|
||||
let recover = false;
|
||||
await page.route('**/api/dashboard/stats', route =>
|
||||
recover
|
||||
? fulfillJson(route, {
|
||||
users: { total: 42, byPlan: { pro: 20 } },
|
||||
tokens: { active: 7 },
|
||||
usage: { totalWords: 8400, totalDictations: 120, totalCost: 12.34 },
|
||||
audit: { total: 2, failedLogins: 0 },
|
||||
})
|
||||
: fulfillJson(route, { error: 'stats unavailable' }, 503)
|
||||
);
|
||||
await page.route('**/api/usage**', route =>
|
||||
recover
|
||||
? fulfillJson(route, {
|
||||
records: [
|
||||
{
|
||||
date: '2026-05-30',
|
||||
tokensUsed: 8400,
|
||||
dictations: 120,
|
||||
costUsd: 12.34,
|
||||
model: 'gpt-4o-mini',
|
||||
},
|
||||
],
|
||||
})
|
||||
: fulfillJson(route, { error: 'usage unavailable' }, 503)
|
||||
);
|
||||
await page.route('**/api/users**', route =>
|
||||
recover
|
||||
? fulfillJson(route, {
|
||||
users: [
|
||||
{
|
||||
id: 'u1',
|
||||
name: 'Admin User',
|
||||
email: 'admin@example.com',
|
||||
plan: 'pro',
|
||||
status: 'active',
|
||||
createdAt: '2026-05-01T00:00:00Z',
|
||||
lastActive: '2026-05-30T00:00:00Z',
|
||||
totalTokensUsed: 8400,
|
||||
totalRequests: 120,
|
||||
monthlySpend: 12.34,
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
byPlan: { pro: 1 },
|
||||
})
|
||||
: fulfillJson(route, { error: 'users unavailable' }, 503)
|
||||
);
|
||||
await page.route('**/api/analytics/revenue**', route =>
|
||||
recover
|
||||
? fulfillJson(route, {
|
||||
mrr: 199,
|
||||
arr: 2388,
|
||||
mrrChange: 8,
|
||||
totalRevenue: 2388,
|
||||
revenueByMonth: [],
|
||||
churnRate: 2,
|
||||
churnCount: 1,
|
||||
ltv: 1200,
|
||||
arpu: 99,
|
||||
newSubscriptions: 3,
|
||||
canceledSubscriptions: 1,
|
||||
})
|
||||
: fulfillJson(route, { error: 'revenue unavailable' }, 503)
|
||||
);
|
||||
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('heading', { name: /could not load dashboard/i })).toBeVisible();
|
||||
|
||||
recover = true;
|
||||
await page.getByRole('button', { name: /retry/i }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
|
||||
await expect(page.getByText('42', { exact: true }).first()).toBeVisible();
|
||||
await expect(page.getByRole('main').getByText('Admin User')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@ -1,5 +1,8 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
const port = Number(process.env.ADMIN_WEB_E2E_PORT ?? 3101);
|
||||
const baseURL = process.env.ADMIN_WEB_E2E_URL ?? `http://127.0.0.1:${port}`;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
fullyParallel: true,
|
||||
@ -8,7 +11,7 @@ export default defineConfig({
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: 'html',
|
||||
use: {
|
||||
baseURL: 'http://localhost:3001',
|
||||
baseURL,
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
projects: [
|
||||
@ -18,9 +21,9 @@ export default defineConfig({
|
||||
},
|
||||
],
|
||||
webServer: {
|
||||
command: 'npm run dev',
|
||||
url: 'http://localhost:3001',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 30_000,
|
||||
command: `pnpm exec next dev -H 127.0.0.1 -p ${port}`,
|
||||
url: baseURL,
|
||||
reuseExistingServer: process.env.ADMIN_WEB_REUSE_SERVER === '1',
|
||||
timeout: 60_000,
|
||||
},
|
||||
});
|
||||
|
||||
@ -12,6 +12,7 @@ import {
|
||||
ArrowDownRight,
|
||||
RefreshCw,
|
||||
Cpu,
|
||||
AlertCircle,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@ -218,6 +219,7 @@ export default function DashboardPage() {
|
||||
const [revenue, setRevenue] = useState<RevenueAnalytics | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
||||
|
||||
const fetchData = useCallback(async (isRefresh = false) => {
|
||||
@ -230,15 +232,29 @@ export default function DashboardPage() {
|
||||
apiGetRevenueAnalytics(6),
|
||||
]);
|
||||
|
||||
let successfulSections = 0;
|
||||
const failures: string[] = [];
|
||||
|
||||
if (statsRes.status === 'fulfilled' && statsRes.value.data) {
|
||||
successfulSections += 1;
|
||||
setStats(prev => mergeApiStats(prev, statsRes.value.data!));
|
||||
} else {
|
||||
failures.push('dashboard stats');
|
||||
}
|
||||
if (usageRes.status === 'fulfilled' && usageRes.value.data?.records?.length) {
|
||||
successfulSections += 1;
|
||||
const metrics = usageRecordsToDailyMetrics(usageRes.value.data.records);
|
||||
setDailyMetrics(metrics);
|
||||
setModelUsage(buildModelUsage(usageRes.value.data.records));
|
||||
} else if (usageRes.status === 'fulfilled' && usageRes.value.data) {
|
||||
successfulSections += 1;
|
||||
setDailyMetrics([]);
|
||||
setModelUsage([]);
|
||||
} else {
|
||||
failures.push('usage');
|
||||
}
|
||||
if (usersRes.status === 'fulfilled' && usersRes.value.data?.users?.length) {
|
||||
successfulSections += 1;
|
||||
setRecentUsers(
|
||||
usersRes.value.data.users
|
||||
.sort((a, b) => b.lastActive.localeCompare(a.lastActive))
|
||||
@ -256,9 +272,22 @@ export default function DashboardPage() {
|
||||
monthlySpend: u.monthlySpend,
|
||||
}))
|
||||
);
|
||||
} else if (usersRes.status === 'fulfilled' && usersRes.value.data) {
|
||||
successfulSections += 1;
|
||||
setRecentUsers([]);
|
||||
} else {
|
||||
failures.push('users');
|
||||
}
|
||||
if (revenueRes.status === 'fulfilled' && revenueRes.value.data) {
|
||||
successfulSections += 1;
|
||||
setRevenue(revenueRes.value.data);
|
||||
} else {
|
||||
failures.push('revenue');
|
||||
}
|
||||
if (successfulSections === 0) {
|
||||
setLoadError(`Could not load ${failures.join(', ')}. Check the API connection and retry.`);
|
||||
} else {
|
||||
setLoadError(null);
|
||||
}
|
||||
setLastUpdated(new Date());
|
||||
} finally {
|
||||
@ -303,6 +332,24 @@ export default function DashboardPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{loadError && !loading && (
|
||||
<Card className="border-destructive/40 bg-destructive/5">
|
||||
<CardContent className="flex flex-col gap-4 p-6 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex gap-3">
|
||||
<AlertCircle className="mt-0.5 h-5 w-5 shrink-0 text-destructive" aria-hidden />
|
||||
<div>
|
||||
<h2 className="font-semibold text-destructive">Could not load dashboard</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{loadError}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => fetchData(true)} disabled={refreshing}>
|
||||
<RefreshCw className={`mr-2 h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
Retry
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* KPI Cards */}
|
||||
{loading ? (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
|
||||
Loading…
Reference in New Issue
Block a user