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';
|
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({
|
export default defineConfig({
|
||||||
testDir: './e2e',
|
testDir: './e2e',
|
||||||
fullyParallel: true,
|
fullyParallel: true,
|
||||||
@ -8,7 +11,7 @@ export default defineConfig({
|
|||||||
workers: process.env.CI ? 1 : undefined,
|
workers: process.env.CI ? 1 : undefined,
|
||||||
reporter: 'html',
|
reporter: 'html',
|
||||||
use: {
|
use: {
|
||||||
baseURL: 'http://localhost:3001',
|
baseURL,
|
||||||
trace: 'on-first-retry',
|
trace: 'on-first-retry',
|
||||||
},
|
},
|
||||||
projects: [
|
projects: [
|
||||||
@ -18,9 +21,9 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
webServer: {
|
webServer: {
|
||||||
command: 'npm run dev',
|
command: `pnpm exec next dev -H 127.0.0.1 -p ${port}`,
|
||||||
url: 'http://localhost:3001',
|
url: baseURL,
|
||||||
reuseExistingServer: !process.env.CI,
|
reuseExistingServer: process.env.ADMIN_WEB_REUSE_SERVER === '1',
|
||||||
timeout: 30_000,
|
timeout: 60_000,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import {
|
|||||||
ArrowDownRight,
|
ArrowDownRight,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Cpu,
|
Cpu,
|
||||||
|
AlertCircle,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@ -218,6 +219,7 @@ export default function DashboardPage() {
|
|||||||
const [revenue, setRevenue] = useState<RevenueAnalytics | null>(null);
|
const [revenue, setRevenue] = useState<RevenueAnalytics | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
||||||
|
|
||||||
const fetchData = useCallback(async (isRefresh = false) => {
|
const fetchData = useCallback(async (isRefresh = false) => {
|
||||||
@ -230,15 +232,29 @@ export default function DashboardPage() {
|
|||||||
apiGetRevenueAnalytics(6),
|
apiGetRevenueAnalytics(6),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
let successfulSections = 0;
|
||||||
|
const failures: string[] = [];
|
||||||
|
|
||||||
if (statsRes.status === 'fulfilled' && statsRes.value.data) {
|
if (statsRes.status === 'fulfilled' && statsRes.value.data) {
|
||||||
|
successfulSections += 1;
|
||||||
setStats(prev => mergeApiStats(prev, statsRes.value.data!));
|
setStats(prev => mergeApiStats(prev, statsRes.value.data!));
|
||||||
|
} else {
|
||||||
|
failures.push('dashboard stats');
|
||||||
}
|
}
|
||||||
if (usageRes.status === 'fulfilled' && usageRes.value.data?.records?.length) {
|
if (usageRes.status === 'fulfilled' && usageRes.value.data?.records?.length) {
|
||||||
|
successfulSections += 1;
|
||||||
const metrics = usageRecordsToDailyMetrics(usageRes.value.data.records);
|
const metrics = usageRecordsToDailyMetrics(usageRes.value.data.records);
|
||||||
setDailyMetrics(metrics);
|
setDailyMetrics(metrics);
|
||||||
setModelUsage(buildModelUsage(usageRes.value.data.records));
|
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) {
|
if (usersRes.status === 'fulfilled' && usersRes.value.data?.users?.length) {
|
||||||
|
successfulSections += 1;
|
||||||
setRecentUsers(
|
setRecentUsers(
|
||||||
usersRes.value.data.users
|
usersRes.value.data.users
|
||||||
.sort((a, b) => b.lastActive.localeCompare(a.lastActive))
|
.sort((a, b) => b.lastActive.localeCompare(a.lastActive))
|
||||||
@ -256,9 +272,22 @@ export default function DashboardPage() {
|
|||||||
monthlySpend: u.monthlySpend,
|
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) {
|
if (revenueRes.status === 'fulfilled' && revenueRes.value.data) {
|
||||||
|
successfulSections += 1;
|
||||||
setRevenue(revenueRes.value.data);
|
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());
|
setLastUpdated(new Date());
|
||||||
} finally {
|
} finally {
|
||||||
@ -303,6 +332,24 @@ export default function DashboardPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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 */}
|
{/* KPI Cards */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user