feat(admin-web): add dashboard retry state
Some checks failed
Publish @bytelyst/* packages / publish (push) Failing after 11s
CI — Common Platform / Build, Test & Typecheck (push) Successful in 42s

This commit is contained in:
Saravana Kumar 2026-05-30 21:02:21 +00:00
parent e69ffadb4b
commit f77797881b
3 changed files with 160 additions and 5 deletions

View 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();
});
});

View File

@ -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,
},
});

View File

@ -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">