mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-04-28 03:20:11 +00:00
Restore dashboard relay onboarding proof
This commit is contained in:
parent
c259e04199
commit
15042dbe99
7 changed files with 121 additions and 1 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue