From 47b6d0fb1c26fb64e48dc4ced369bf7d028cf73e Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sat, 11 Apr 2026 18:25:53 +0100 Subject: [PATCH] Align release header audit with publish contract --- .github/workflows/release-dry-run.yml | 3 + .../v6/internal/subsystems/cloud-paid.md | 9 ++ .../subsystems/deployment-installability.md | 5 + .../subsystems/frontend-primitives.md | 11 ++ .../subsystems/performance-and-scalability.md | 8 ++ .../internal/subsystems/storage-recovery.md | 7 ++ .../internal/subsystems/unified-resources.md | 7 ++ frontend-modern/scripts/header-audit.mjs | 106 +++++++++++++++--- .../src/components/Storage/Storage.tsx | 6 + .../InfrastructurePageSurface.tsx | 6 + ...frastructurePageSurface.guardrails.test.ts | 3 + .../operations/OperationsPageSurface.tsx | 6 + frontend-modern/src/pages/Dashboard.tsx | 6 + frontend-modern/src/pages/PricingHandoff.tsx | 28 +++-- .../pages/__tests__/DashboardPage.test.tsx | 4 + .../__tests__/Operations.helpers.test.ts | 3 + .../pages/__tests__/PricingHandoff.test.tsx | 7 ++ .../pages/__tests__/Storage.helpers.test.ts | 3 + .../release_promotion_policy_test.py | 2 + 19 files changed, 203 insertions(+), 27 deletions(-) diff --git a/.github/workflows/release-dry-run.yml b/.github/workflows/release-dry-run.yml index 4598bb755..652b29439 100644 --- a/.github/workflows/release-dry-run.yml +++ b/.github/workflows/release-dry-run.yml @@ -168,6 +168,9 @@ jobs: - name: Lint frontend run: npm --prefix frontend-modern run lint + - name: Audit header composition + run: npm --prefix frontend-modern run lint:headers + - name: Check frontend copy-paste duplication run: npm --prefix frontend-modern run lint:cpd diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index 7eb44ba06..d14692267 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -241,6 +241,15 @@ ledger reads until that policy resolves and suppress them while commercial surfaces are hidden. Public demo mode therefore renders a redacted presentation-policy state instead of creating a fake entitlement, probing hidden commercial endpoints, or showing monitored-system usage pressure. +That same commercial/public browser boundary also owns pricing-handoff +framing. `frontend-modern/src/pages/PricingHandoff.tsx` may keep the +operator-visible handoff on `/pricing`, but it must render the shared +`PageHeader` shell while `frontend-modern/src/utils/pricingHandoff.ts` +continues to own destination resolution, self-hosted Pulse Account handoff, +and public-pricing fallback truth. The route must not fork a raw top-level +heading, duplicate destination logic in the page shell, or let commercial +handoff framing drift away from the same shared browser chrome used by the +rest of the product. The governed browser proof for that posture lives in `tests/integration/tests/53-demo-mode-commercial-boundary.spec.ts` and is expected to stay runnable through diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 8605b67c1..8c6aec2df 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -186,6 +186,11 @@ state. When `.github/workflows/create-release.yml` runs in `draft_only` mode, it must pass the real draft state into `.github/workflows/validate-release-assets.yml` so validation blocks or annotates the draft release as a draft, rather than misclassifying the run as post-publish revalidation. +That same frontend-release boundary also owns shared header-composition proof. +`.github/workflows/release-dry-run.yml` and `.github/workflows/create-release.yml` +must both run the same `lint:headers` audit so a branch that would be rejected +by the real publish workflow cannot pass the governed dry run only because the +rehearsal skipped that header-composition gate. That same governed demo-deployment boundary now owns target separation between the public stable demo and the opt-in v6 preview demo. `.github/workflows/create-release.yml`, `.github/workflows/update-demo-server.yml`, and `.github/workflows/deploy-demo-server.yml` diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index c51c58fdc..92a27be23 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -373,6 +373,17 @@ connections` visible as the API-backed alternative for Proxmox and such as `truenas_checked` instead of letting feature-local fixtures or fallback objects collapse API-backed TrueNAS systems back into generic agent-host presentation. + That same shared route-shell boundary also owns header-composition audit. + `frontend-modern/scripts/header-audit.mjs`, + `.github/workflows/release-dry-run.yml`, and + `.github/workflows/create-release.yml` must prove the same shared + top-level page-header contract before publication. The audit may follow + local imports when a route shell composes `PageHeader` through a nested + surface, and settings coverage must stay limited to top-level registry + panels rather than every helper `*Panel.tsx` file. Route shells such as + `frontend-modern/src/features/operations/OperationsPageSurface.tsx` must + therefore keep the shared `PageHeader` above owned subtabs instead of + drifting back to page-local `

` framing. 23. Keep the authenticated app root aligned with that same first-session path. That same shared-primitive ownership now includes contextual row focus. `frontend-modern/src/components/shared/contextualFocus.ts` is the canonical diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index a2a2207d4..0d879a80d 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -236,6 +236,14 @@ regression protection. must not hydrate `all-resources` or recovery rollups behind a hidden node summary; selector-owned data hooks must be explicitly visibility-gated so `/workloads` only pays for workload-owned transports. +34. Keep dashboard page-header framing additive on the compact hot path. + `frontend-modern/src/pages/Dashboard.tsx` may render the shared + `PageHeader` for route-level shell consistency, but that header must stay + pure presentation on top of the existing compact overview, trends, + actions, and recovery/storage widget hydration. It must not introduce a + second dashboard data load, widen suspense ownership, or force dashboard + summaries back through full-resource fetch paths just to satisfy page + chrome. ## Forbidden Paths diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index e83122338..b920054a4 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -292,6 +292,13 @@ querying, and the operator-facing storage health presentation layer. `/api/storage-charts` payload once per additional dashboard resource page or invent a dashboard-only storage summary transport path outside the canonical cache owners. +38. Keep storage and recovery route framing additive and owner-neutral. + `frontend-modern/src/components/Storage/Storage.tsx` and storage/recovery- + adjacent dashboard composition may use the shared `PageHeader` shell for + top-level route framing, but that header must stay additive on top of the + canonical storage page model, recovery presenters, and shared summary + caches. Header chrome must not become a second owner for storage filters, + recovery posture, commercial purchase state, or transport selection. ## Forbidden Paths diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 8c7382067..5996b266d 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -337,6 +337,13 @@ assembly branch. compact `/api/charts/storage-summary` contract instead of rebuilding page-local per-resource storage history fetches, storage-type aliases, or full storage-page `/api/storage-charts` fetches. +19. Keep infrastructure page-header framing presentation-only on the page + shell. `frontend-modern/src/features/infrastructure/InfrastructurePageSurface.tsx` + may render the shared `PageHeader`, but canonical source/status/search + state, summary scope, and row selection must remain on + `frontend-modern/src/features/infrastructure/useInfrastructurePageState.ts` + and the unified-resource selectors it composes. The header must not become + a second state owner, scope banner, or provider-local filter surface. ## Current State diff --git a/frontend-modern/scripts/header-audit.mjs b/frontend-modern/scripts/header-audit.mjs index b55174649..56d4167c3 100644 --- a/frontend-modern/scripts/header-audit.mjs +++ b/frontend-modern/scripts/header-audit.mjs @@ -11,8 +11,7 @@ const REQUIRED_PAGE_HEADERS = new Map([ ['src/pages/Infrastructure.tsx', 'PageHeader'], ['src/pages/Operations.tsx', 'PageHeader'], ['src/pages/NotFound.tsx', 'PageHeader'], - ['src/pages/Pricing.tsx', 'PageHeader'], - ['src/pages/MigrationGuide.tsx', 'PageHeader'], + ['src/pages/PricingHandoff.tsx', 'PageHeader'], ['src/components/Settings/Settings.tsx', 'PageHeader'], ]); @@ -41,6 +40,75 @@ function hasPrimitive(content, primitive) { return new RegExp(`<${primitive}\\b`).test(content); } +function resolveImport(specifier, fromFile) { + let basePath = null; + if (specifier.startsWith('@/')) { + basePath = path.join(ROOT, 'src', specifier.slice(2)); + } else if (specifier.startsWith('.')) { + basePath = path.resolve(path.dirname(path.join(ROOT, fromFile)), specifier); + } + + if (!basePath) { + return null; + } + + const candidates = [ + basePath, + `${basePath}.tsx`, + `${basePath}.ts`, + path.join(basePath, 'index.tsx'), + path.join(basePath, 'index.ts'), + ]; + + for (const candidate of candidates) { + if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) { + return path.relative(ROOT, candidate); + } + } + + return null; +} + +function getImportedLocalFiles(relPath) { + const content = readFileSafe(relPath); + if (!content) { + return []; + } + + const imports = new Set(); + const importPattern = /from\s+['"]([^'"]+)['"]|import\(\s*['"]([^'"]+)['"]\s*\)/g; + let match; + while ((match = importPattern.exec(content)) !== null) { + const specifier = match[1] ?? match[2]; + const resolved = resolveImport(specifier, relPath); + if (resolved) { + imports.add(resolved); + } + } + + return Array.from(imports); +} + +function collectPrimitiveUsage(relPath, visited = new Set()) { + if (visited.has(relPath)) { + return new Set(); + } + visited.add(relPath); + + const content = readFileSafe(relPath); + const primitives = new Set( + HEADER_PRIMITIVES.filter((primitive) => hasPrimitive(content, primitive)), + ); + + for (const importedFile of getImportedLocalFiles(relPath)) { + for (const primitive of collectPrimitiveUsage(importedFile, visited)) { + primitives.add(primitive); + } + } + + return primitives; +} + function listTopLevelPages() { const dir = path.join(ROOT, 'src/pages'); return fs @@ -50,13 +118,20 @@ function listTopLevelPages() { .sort(); } -function listSettingsPanels() { - const dir = path.join(ROOT, 'src/components/Settings'); - return fs - .readdirSync(dir, { withFileTypes: true }) - .filter((entry) => entry.isFile() && entry.name.endsWith('Panel.tsx')) - .map((entry) => `src/components/Settings/${entry.name}`) - .sort(); +function listTopLevelSettingsPanels() { + const registryFile = 'src/components/Settings/settingsPanelRegistryLoaders.ts'; + const content = readFileSafe(registryFile); + const panels = new Set(); + const importPattern = /import\('\.\/([^']+)'\)/g; + let match; + while ((match = importPattern.exec(content)) !== null) { + const moduleName = match[1]; + if (!moduleName.endsWith('Panel')) { + continue; + } + panels.add(`src/components/Settings/${moduleName}.tsx`); + } + return Array.from(panels).sort(); } const failures = []; @@ -67,7 +142,8 @@ for (const [file, requiredPrimitive] of REQUIRED_PAGE_HEADERS.entries()) { failures.push(`${file}: missing file`); continue; } - if (!hasPrimitive(content, requiredPrimitive)) { + const primitives = collectPrimitiveUsage(file); + if (!primitives.has(requiredPrimitive)) { failures.push(`${file}: must use <${requiredPrimitive}>`); } } @@ -86,7 +162,7 @@ for (const [file, requiredPrimitive] of REQUIRED_OPERATIONS_WRAPPERS.entries()) const pageInventory = []; for (const pageFile of listTopLevelPages()) { const content = readFileSafe(pageFile); - const primitives = HEADER_PRIMITIVES.filter((primitive) => hasPrimitive(content, primitive)); + const primitives = Array.from(collectPrimitiveUsage(pageFile)); const hasRawH1 = / or `); diff --git a/frontend-modern/src/components/Storage/Storage.tsx b/frontend-modern/src/components/Storage/Storage.tsx index 5cbedc2a5..049436dca 100644 --- a/frontend-modern/src/components/Storage/Storage.tsx +++ b/frontend-modern/src/components/Storage/Storage.tsx @@ -4,6 +4,7 @@ import StorageContentCard from '@/components/Storage/StorageContentCard'; import StoragePageBanners from '@/components/Storage/StoragePageBanners'; import StoragePageControls from '@/components/Storage/StoragePageControls'; import StoragePageSummary from '@/components/Storage/StoragePageSummary'; +import { PageHeader } from '@/components/shared/PageHeader'; import { StickySummarySection } from '@/components/shared/StickySummarySection'; import { isStorageRecordCeph } from './storagePageState'; import { useStoragePageModel } from './useStoragePageModel'; @@ -73,6 +74,11 @@ const Storage: Component = () => { class="space-y-4" data-testid="storage-page" > + + filteredRecords().length} diff --git a/frontend-modern/src/features/infrastructure/InfrastructurePageSurface.tsx b/frontend-modern/src/features/infrastructure/InfrastructurePageSurface.tsx index c2d4dc9a8..89ae8b284 100644 --- a/frontend-modern/src/features/infrastructure/InfrastructurePageSurface.tsx +++ b/frontend-modern/src/features/infrastructure/InfrastructurePageSurface.tsx @@ -4,6 +4,7 @@ import { buildInfrastructureWorkspacePath } from '@/components/Settings/infrastr import { EmptyState } from '@/components/shared/EmptyState'; import { Card } from '@/components/shared/Card'; import { FilterSegmentedControl, LabeledFilterSelect } from '@/components/shared/FilterToolbar'; +import { PageHeader } from '@/components/shared/PageHeader'; import { PageControls } from '@/components/shared/PageControls'; import { SearchInput } from '@/components/shared/SearchInput'; import { StickySummarySection } from '@/components/shared/StickySummarySection'; @@ -86,6 +87,11 @@ export function InfrastructurePageSurface() { data-testid="infrastructure-page" class="space-y-4" > + + { it('keeps the feature shell separate from route-sync and page-model ownership', () => { expect(infrastructurePageSurfaceSource).toContain('useInfrastructurePageState'); expect(infrastructurePageSurfaceSource).toContain('useNavigate'); + expect(infrastructurePageSurfaceSource).toContain("import { PageHeader } from '@/components/shared/PageHeader';"); + expect(infrastructurePageSurfaceSource).toContain('
+ +
+ + {/* Connection warning banner — shown above all content, NOT a full-page takeover */}
} >
-
-

- Redirecting to {handoffLabel()} -

-

- If the handoff does not start automatically,{' '} - - {handoffLinkLabel()} - - . -

+
+ + If the handoff does not start automatically,{' '} + + {handoffLinkLabel()} + + . + + } + class="items-center text-center" + titleClass="text-lg" + descriptionClass="text-sm" + />
diff --git a/frontend-modern/src/pages/__tests__/DashboardPage.test.tsx b/frontend-modern/src/pages/__tests__/DashboardPage.test.tsx index 5a0ac48eb..f6ca0cf25 100644 --- a/frontend-modern/src/pages/__tests__/DashboardPage.test.tsx +++ b/frontend-modern/src/pages/__tests__/DashboardPage.test.tsx @@ -146,6 +146,9 @@ 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("from '@/components/shared/PageHeader'"); + expect(dashboardPageSource).toContain(''); expect(dashboardPageSource).toContain( 'ActionRequiredPanel,\n DashboardCustomizer,\n KPIStrip,\n ProblemResourcesTable,\n TrendCharts,', @@ -178,6 +181,7 @@ describe('Dashboard page module contract', () => { it('routes the empty dashboard state to infrastructure install', () => { render(() => ); + expect(screen.getByRole('heading', { name: 'Dashboard' })).toBeInTheDocument(); expect(screen.getByRole('heading', { name: 'No resources yet' })).toBeInTheDocument(); expect(screen.queryByTestId('relay-onboarding-card')).toBeNull(); expect( diff --git a/frontend-modern/src/pages/__tests__/Operations.helpers.test.ts b/frontend-modern/src/pages/__tests__/Operations.helpers.test.ts index be2e34969..c26d2ab87 100644 --- a/frontend-modern/src/pages/__tests__/Operations.helpers.test.ts +++ b/frontend-modern/src/pages/__tests__/Operations.helpers.test.ts @@ -15,6 +15,9 @@ describe('operations page route shell', () => { expect(operationsPageRouteSource).not.toContain('useNavigate'); expect(operationsPageRouteSource).not.toContain('createSignal'); expect(operationsPageSurfaceSource).toContain('@/components/shared/Subtabs'); + expect(operationsPageSurfaceSource).toContain("import { PageHeader } from '@/components/shared/PageHeader';"); + expect(operationsPageSurfaceSource).toContain(' { ); }); + it('keeps the pricing handoff on the shared page-header shell', () => { + expect(pricingHandoffSource).toContain("import { PageHeader } from '@/components/shared/PageHeader';"); + expect(pricingHandoffSource).toContain(' { window.history.replaceState({}, '', '/pricing?feature=max_monitored_systems'); diff --git a/frontend-modern/src/pages/__tests__/Storage.helpers.test.ts b/frontend-modern/src/pages/__tests__/Storage.helpers.test.ts index ee1acf55b..ddafd5f7a 100644 --- a/frontend-modern/src/pages/__tests__/Storage.helpers.test.ts +++ b/frontend-modern/src/pages/__tests__/Storage.helpers.test.ts @@ -14,6 +14,9 @@ describe('storage page route shell', () => { ); expect(storagePageRouteSource).toContain(''); expect(storagePageRouteSource).not.toContain('useStoragePageModel'); + expect(storageSurfaceSource).toContain("import { PageHeader } from '@/components/shared/PageHeader';"); + expect(storageSurfaceSource).toContain('