Restore dashboard relay onboarding proof

This commit is contained in:
rcourtman 2026-04-11 00:19:48 +01:00
parent c259e04199
commit 15042dbe99
7 changed files with 121 additions and 1 deletions

View file

@ -368,6 +368,7 @@ Companion drill:
`go test ./internal/relay -run 'TestClient_E2E_MultiMobileClientRelay|TestClient_AbruptDisconnectCancelsInFlightHandlers|TestClient_AbruptDisconnectMultipleChannelCleanup|TestClient_DrainDuringInFlightData|TestClient_DrainWithMultipleInFlightChannels|TestClientRegister_SessionResumeRejectionClearsCachedSession|TestRunLoop_SessionResumeRejectionFallsBackToFreshRegister' -count=1`
`go test ./internal/api -run 'TestRelayEndpointsRequireLicenseFeature|TestRelayOnboardingEndpointsRequireLicenseFeature|TestRelayLicenseGatingResponseFormat|TestOnboardingQRPayloadStructure|TestOnboardingValidateSuccessAndFailure|TestOnboardingDeepLinkFormat' -count=1`
`cd frontend-modern && npx vitest run src/components/Dashboard/__tests__/RelayOnboardingCard.test.tsx src/components/Settings/__tests__/RelaySettingsPanel.runtime.test.tsx src/components/Settings/__tests__/settingsReadOnlyPanels.test.tsx`
`cd tests/integration && PULSE_E2E_SKIP_DOCKER=1 PULSE_BASE_URL=http://127.0.0.1:7655 PLAYWRIGHT_BASE_URL=http://127.0.0.1:4174 npm test -- tests/59-dashboard-relay-onboarding-trial-rate-limit.spec.ts --project=chromium`
`cd /Volumes/Development/pulse/repos/pulse-mobile && npm test -- --runTestsByPath src/relay/__tests__/client.test.ts src/relay/__tests__/client-hardening.test.ts src/relay/__tests__/protocol-contract.test.ts`
- Manual scenario:
1. Register a fresh relay client.

View file

@ -1090,7 +1090,10 @@ dashboard shell, while
`frontend-modern/src/components/Dashboard/useRelayOnboardingCardState.ts`
owns license readiness, relay status polling, snooze state, and trial start
runtime. Future onboarding changes must extend that split instead of pulling
license and relay runtime back into the card shell.
license and relay runtime back into the card shell. Live-route regressions in
that dashboard composition must be caught by
`tests/integration/tests/59-dashboard-relay-onboarding-trial-rate-limit.spec.ts`
instead of relying only on isolated component tests.
That relay pairing boundary now also includes backend-owned mobile credential
lifecycle: when the settings surface generates a mobile pairing QR, it must ask
the server for a fresh scoped Pulse Mobile relay access token, fetch the

View file

@ -206,6 +206,12 @@ regression protection.
list hydration and must not turn dashboard landing on `frontend-modern/src/App.tsx`
into another summary-fetch or org-bootstrap hot path.
31. Keep the dashboard overview hot path compact and route-owned. `frontend-modern/src/pages/Dashboard.tsx`, `frontend-modern/src/api/resources.ts`, and `frontend-modern/src/hooks/useDashboardOverview.ts` must hydrate KPI cards, problem-resource rows, and top-infrastructure identities through the compact dashboard-summary API contract owned by the adjacent `api-contracts` and `unified-resources` surfaces, rather than booting the full unfiltered paginated unified-resource list just to derive summary cards.
Commercial or relay-owned dashboard affordances such as
`frontend-modern/src/components/Dashboard/RelayOnboardingCard.tsx` may be
composed into that route, but they must remain additive shells on top of
the same compact summary payload instead of reintroducing route-local
`all-resources` fetches, summary recomputation, or page-level layout churn
that displaces the protected overview widgets.
32. Keep infrastructure summary consumers on the compact dashboard overview rather than reopening the all-resources hook. `frontend-modern/src/hooks/useDashboardTrends.ts`, `frontend-modern/src/components/Infrastructure/useInfrastructureSummaryState.ts`, and adjacent dashboard summary consumers may derive chart identity and storage presence from the overview payload they were already given, but they must not call `useResources()` or mount a second unfiltered unified-resource fetch path inside the dashboard hot path. That rule also applies to globally mounted helpers such as `frontend-modern/src/components/AI/Chat/index.tsx`: closed assistant surfaces must read the live websocket snapshot or existing unified-resource cache rather than forcing the dashboard to pay for `all-resources` just because the shell component is mounted.
33. Keep hidden workload-route selector shells off the hot path. When the
workloads route keeps `frontend-modern/src/components/shared/InfrastructureSelector.tsx`

View file

@ -201,6 +201,12 @@ querying, and the operator-facing storage health presentation layer.
they were already given, but they must not reopen paginated
`useUnifiedResources()` transport or reintroduce per-pool
`/api/metrics-store/history` fan-out under the dashboard hot path.
Route-owned dashboard additions such as
`frontend-modern/src/components/Dashboard/RelayOnboardingCard.tsx` may sit
above those cards, but they must remain adjacent composition only: the
relay surface must not replace the recovery/storage dashboard panels,
suppress the governed no-resources handoff, or move storage/recovery
summary ownership out of the compact dashboard route.
35. Keep shared `frontend-modern/src/App.tsx` public-route ownership explicit by
surface. Storage/recovery preview entrypoints such as
`/preview/setup-complete` may remain public app-shell routes, but unrelated

View file

@ -31,6 +31,7 @@ import {
TrendCharts,
} from '@/features/dashboardOverview';
import { RecentAlertsPanel } from '@/components/Alerts/RecentAlertsPanel';
import { RelayOnboardingCard } from '@/components/Dashboard/RelayOnboardingCard';
import { DashboardRecoveryStatusPanel } from '@/components/Recovery/DashboardRecoveryStatusPanel';
import { DashboardStoragePanel } from '@/components/Storage/DashboardStoragePanel';
import type { DashboardWidgetDef, DashboardWidgetId } from '@/features/dashboardOverview/dashboardWidgets';
@ -271,6 +272,8 @@ export default function Dashboard() {
<Match when={initialLoadComplete() && hasCachedData()}>
<section class="space-y-5">
<RelayOnboardingCard />
{/* 1. Action Required Panel — only when actions exist */}
<ActionRequiredPanel
pendingApprovals={actions.pendingApprovals()}

View file

@ -11,6 +11,7 @@ let wsConnected = true;
let wsReconnecting = false;
const reconnectSpy = vi.fn();
const navigateSpy = vi.hoisted(() => vi.fn());
const relayOnboardingCardSpy = vi.hoisted(() => vi.fn(() => <div data-testid="relay-onboarding-card" />));
const recoverySummaryMock: DashboardRecoverySummary = {
totalProtected: 3,
byOutcome: { success: 2, failed: 1 },
@ -110,6 +111,10 @@ vi.mock('@/hooks/useDashboardRecovery', () => ({
useDashboardRecovery: () => () => recoverySummaryMock,
}));
vi.mock('@/components/Dashboard/RelayOnboardingCard', () => ({
RelayOnboardingCard: relayOnboardingCardSpy,
}));
describe('Dashboard page module contract', () => {
beforeEach(() => {
overviewLoading = false;
@ -118,6 +123,7 @@ describe('Dashboard page module contract', () => {
wsReconnecting = false;
reconnectSpy.mockReset();
navigateSpy.mockReset();
relayOnboardingCardSpy.mockClear();
overviewMock.health.totalResources = 0;
overviewMock.storage.total = 0;
overviewMock.storage.totalCapacity = 0;
@ -139,6 +145,8 @@ describe('Dashboard page module contract', () => {
it('routes dashboard overview panels through the dashboard overview feature owner', () => {
expect(dashboardPageSource).toContain("from '@/features/dashboardOverview'");
expect(dashboardPageSource).toContain("from '@/components/Dashboard/RelayOnboardingCard'");
expect(dashboardPageSource).toContain('<RelayOnboardingCard />');
expect(dashboardPageSource).toContain(
'ActionRequiredPanel,\n DashboardCustomizer,\n KPIStrip,\n ProblemResourcesTable,\n TrendCharts,',
);
@ -171,6 +179,7 @@ describe('Dashboard page module contract', () => {
render(() => <DashboardPage />);
expect(screen.getByRole('heading', { name: 'No resources yet' })).toBeInTheDocument();
expect(screen.queryByTestId('relay-onboarding-card')).toBeNull();
expect(
screen.getByText(
'Start by opening Settings → Infrastructure → Install on a host and connecting the first system you want Pulse to monitor. Your dashboard overview will appear here once that system starts reporting.',
@ -192,6 +201,7 @@ describe('Dashboard page module contract', () => {
render(() => <DashboardPage />);
expect(screen.getByTestId('relay-onboarding-card')).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Recovery Status' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: /Storage/ })).toBeInTheDocument();
expect(screen.getByText('Last recovery point over 24 hours ago')).toBeInTheDocument();

View file

@ -0,0 +1,91 @@
import { expect, test, type Page } from '@playwright/test';
import { ensureAuthenticated, getMockMode, setMockMode } from './helpers';
const FREE_RUNTIME_CAPABILITIES = {
capabilities: ['update_alerts', 'sso', 'ai_patrol'],
limits: [],
hosted_mode: false,
max_history_days: 7,
};
async function waitForDashboardReady(page: Page) {
await expect(page).toHaveURL(/\/dashboard(?:\?.*)?$/);
await expect(page.getByTestId('dashboard-page')).toBeVisible();
await page.waitForFunction(
() => !document.querySelector('[data-testid="dashboard-loading"]'),
undefined,
{ timeout: 30_000 },
);
}
test.describe.serial('Dashboard relay onboarding trial rate limit', () => {
test('shows Retry-After guidance on the live dashboard relay onboarding CTA', async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name.startsWith('mobile-'),
'Desktop-only dashboard relay onboarding coverage',
);
await page.route('**/api/license/runtime-capabilities', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(FREE_RUNTIME_CAPABILITIES),
});
});
await page.route('**/api/license/trial/start', async (route) => {
await route.fulfill({
status: 429,
headers: {
'Content-Type': 'application/json',
'Retry-After': '120',
},
body: JSON.stringify({
code: 'trial_rate_limited',
error: 'Trial start rate limit exceeded',
details: {
retry_after_seconds: '45',
},
}),
});
});
await ensureAuthenticated(page);
let initialMockMode: { enabled: boolean } | null = null;
try {
initialMockMode = await getMockMode(page);
if (!initialMockMode.enabled) {
await setMockMode(page, true);
}
} catch (error) {
console.warn(`[relay-onboarding] unable to read/set mock mode: ${String(error)}`);
}
try {
await page.goto('/dashboard');
await waitForDashboardReady(page);
await expect(page.getByRole('heading', { name: 'Pair Your Mobile Device' })).toBeVisible();
const startTrialButton = page.getByRole('button', { name: /or start a pro trial/i });
await expect(startTrialButton).toBeVisible();
await startTrialButton.click();
await expect(page.getByText('Try again in about 2 minutes')).toBeVisible();
await expect(page.getByText('Try again in about a minute')).toHaveCount(0);
} finally {
if (initialMockMode && !initialMockMode.enabled) {
try {
await setMockMode(page, false);
} catch (error) {
console.warn(
`[relay-onboarding] unable to restore mock mode, continuing: ${String(error)}`,
);
}
}
}
});
});