From 1d5aa99c0bdd35cddbefbef0f3cdc4f0af086fce Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sat, 21 Mar 2026 22:03:30 +0000 Subject: [PATCH] Extract relay onboarding runtime owner --- .../v6/internal/subsystems/cloud-paid.md | 44 +++-- .../v6/internal/subsystems/registry.json | 2 + .../Dashboard/RelayOnboardingCard.tsx | 160 ++---------------- .../__tests__/RelayOnboardingCard.test.tsx | 14 ++ .../Dashboard/useRelayOnboardingCardState.ts | 147 ++++++++++++++++ .../monitoredSystemModelGuardrails.test.ts | 28 ++- .../frontendResourceTypeBoundaries.test.ts | 8 + .../release_control/subsystem_lookup_test.py | 21 +++ 8 files changed, 257 insertions(+), 167 deletions(-) create mode 100644 frontend-modern/src/components/Dashboard/useRelayOnboardingCardState.ts diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index 1af1911c4..abdf9b286 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -47,23 +47,24 @@ agreement, and cloud-specific enforcement rules. 25. `frontend-modern/src/AppLayout.tsx` 26. `frontend-modern/src/useAppRuntimeState.ts` 27. `frontend-modern/src/components/Dashboard/RelayOnboardingCard.tsx` -28. `frontend-modern/src/components/Settings/BillingAdminPanel.tsx` -29. `frontend-modern/src/components/Settings/BillingAdminOrganizationsTable.tsx` -30. `frontend-modern/src/components/Settings/OrganizationBillingPanel.tsx` -31. `frontend-modern/src/components/Settings/OrganizationBillingLoadingState.tsx` -32. `frontend-modern/src/components/Settings/ProLicensePanel.tsx` -33. `frontend-modern/src/components/Settings/ProLicensePlanSection.tsx` -34. `frontend-modern/src/components/Settings/CommercialBillingSections.tsx` -35. `frontend-modern/src/components/Settings/SelfHostedCommercialActivationSection.tsx` -36. `frontend-modern/src/components/Settings/RelaySettingsPanel.tsx` -37. `frontend-modern/src/components/Settings/RelayPairingSection.tsx` -38. `frontend-modern/src/components/Settings/useBillingAdminPanelState.ts` -39. `frontend-modern/src/components/Settings/useOrganizationBillingPanelState.ts` -40. `frontend-modern/src/components/Settings/useProLicensePanelState.ts` -41. `frontend-modern/src/components/Settings/useRelaySettingsPanelState.ts` -42. `frontend-modern/src/pages/CloudPricing.tsx` -43. `frontend-modern/src/utils/apiClient.ts` -44. `frontend-modern/src/utils/commercialBillingModel.ts` +28. `frontend-modern/src/components/Dashboard/useRelayOnboardingCardState.ts` +29. `frontend-modern/src/components/Settings/BillingAdminPanel.tsx` +30. `frontend-modern/src/components/Settings/BillingAdminOrganizationsTable.tsx` +31. `frontend-modern/src/components/Settings/OrganizationBillingPanel.tsx` +32. `frontend-modern/src/components/Settings/OrganizationBillingLoadingState.tsx` +33. `frontend-modern/src/components/Settings/ProLicensePanel.tsx` +34. `frontend-modern/src/components/Settings/ProLicensePlanSection.tsx` +35. `frontend-modern/src/components/Settings/CommercialBillingSections.tsx` +36. `frontend-modern/src/components/Settings/SelfHostedCommercialActivationSection.tsx` +37. `frontend-modern/src/components/Settings/RelaySettingsPanel.tsx` +38. `frontend-modern/src/components/Settings/RelayPairingSection.tsx` +39. `frontend-modern/src/components/Settings/useBillingAdminPanelState.ts` +40. `frontend-modern/src/components/Settings/useOrganizationBillingPanelState.ts` +41. `frontend-modern/src/components/Settings/useProLicensePanelState.ts` +42. `frontend-modern/src/components/Settings/useRelaySettingsPanelState.ts` +43. `frontend-modern/src/pages/CloudPricing.tsx` +44. `frontend-modern/src/utils/apiClient.ts` +45. `frontend-modern/src/utils/commercialBillingModel.ts` ## Shared Boundaries @@ -88,7 +89,7 @@ agreement, and cloud-specific enforcement rules. 12. Add or change shared commercial plan/usage presentation through `frontend-modern/src/components/Settings/CommercialBillingSections.tsx` and `frontend-modern/src/utils/commercialBillingModel.ts` 13. Add or change organization billing and usage presentation through `frontend-modern/src/components/Settings/OrganizationBillingPanel.tsx`, `frontend-modern/src/components/Settings/OrganizationBillingLoadingState.tsx`, and `frontend-modern/src/components/Settings/useOrganizationBillingPanelState.ts` 14. Add or change self-hosted Pro activation, trial, and entitlement actions through `frontend-modern/src/components/Settings/ProLicensePanel.tsx`, `frontend-modern/src/components/Settings/ProLicensePlanSection.tsx`, `frontend-modern/src/components/Settings/SelfHostedCommercialActivationSection.tsx`, and `frontend-modern/src/components/Settings/useProLicensePanelState.ts` -15. Add or change paid relay settings and onboarding presentation through `frontend-modern/src/components/Settings/RelaySettingsPanel.tsx`, `frontend-modern/src/components/Settings/RelayPairingSection.tsx`, `frontend-modern/src/components/Settings/useRelaySettingsPanelState.ts`, and `frontend-modern/src/components/Dashboard/RelayOnboardingCard.tsx` +15. Add or change paid relay settings and onboarding presentation through `frontend-modern/src/components/Settings/RelaySettingsPanel.tsx`, `frontend-modern/src/components/Settings/RelayPairingSection.tsx`, `frontend-modern/src/components/Settings/useRelaySettingsPanelState.ts`, `frontend-modern/src/components/Dashboard/RelayOnboardingCard.tsx`, and `frontend-modern/src/components/Dashboard/useRelayOnboardingCardState.ts` 16. Add or change cloud plan presentation through `frontend-modern/src/pages/CloudPricing.tsx` 17. Add contract tests where runtime and pricing need to stay aligned 18. Add or change hosted browser org-context bootstrap through `frontend-modern/src/App.tsx`, `frontend-modern/src/AppLayout.tsx`, `frontend-modern/src/useAppRuntimeState.ts`, and `frontend-modern/src/utils/apiClient.ts` @@ -320,6 +321,13 @@ owns relay config/status polling, trial, and pairing runtime, and `frontend-modern/src/components/Settings/RelayPairingSection.tsx` owns the QR pairing surface. Future relay settings work must extend that split instead of pulling polling and QR-generation lifecycle back into the shell component. +The dashboard relay onboarding surface now follows the same rule: +`frontend-modern/src/components/Dashboard/RelayOnboardingCard.tsx` is the +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. That relay pairing boundary now also includes ephemeral device-token lifecycle: when the settings surface generates a mobile pairing QR, it must mint a fresh scoped API token for that pairing attempt, fetch the onboarding payload through diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index b21816d91..335dca66c 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -1195,6 +1195,7 @@ "frontend-modern/src/App.tsx", "frontend-modern/src/AppLayout.tsx", "frontend-modern/src/components/Dashboard/RelayOnboardingCard.tsx", + "frontend-modern/src/components/Dashboard/useRelayOnboardingCardState.ts", "frontend-modern/src/components/Settings/BillingAdminOrganizationsTable.tsx", "frontend-modern/src/components/Settings/BillingAdminPanel.tsx", "frontend-modern/src/components/Settings/OrganizationBillingLoadingState.tsx", @@ -1642,6 +1643,7 @@ "match_prefixes": [], "match_files": [ "frontend-modern/src/components/Dashboard/RelayOnboardingCard.tsx", + "frontend-modern/src/components/Dashboard/useRelayOnboardingCardState.ts", "frontend-modern/src/components/Settings/RelayPairingSection.tsx", "frontend-modern/src/components/Settings/RelaySettingsPanel.tsx", "frontend-modern/src/components/Settings/useRelaySettingsPanelState.ts" diff --git a/frontend-modern/src/components/Dashboard/RelayOnboardingCard.tsx b/frontend-modern/src/components/Dashboard/RelayOnboardingCard.tsx index 3dc8e61cf..3baf3334e 100644 --- a/frontend-modern/src/components/Dashboard/RelayOnboardingCard.tsx +++ b/frontend-modern/src/components/Dashboard/RelayOnboardingCard.tsx @@ -1,20 +1,8 @@ -import { Component, Show, createEffect, createMemo, createSignal, onMount } from 'solid-js'; -import { useNavigate } from '@solidjs/router'; +import { Component, Show } from 'solid-js'; import Smartphone from 'lucide-solid/icons/smartphone'; import X from 'lucide-solid/icons/x'; import { Card } from '@/components/shared/Card'; -import { RelayAPI, type RelayStatus } from '@/api/relay'; -import { - getUpgradeActionUrlOrFallback, - hasFeature, - loadLicenseStatus, - licenseLoaded, - startProTrial, -} from '@/stores/license'; -import { showError, showSuccess } from '@/utils/toast'; -import { logger } from '@/utils/logger'; -import { trackPaywallViewed, trackUpgradeClicked } from '@/utils/upgradeMetrics'; -import { isUpsellSnoozed, snoozeUpsell } from '@/utils/snooze'; +import { getUpgradeActionUrlOrFallback } from '@/stores/license'; import { RELAY_ONBOARDING_DESCRIPTION, RELAY_ONBOARDING_DISCONNECTED_LABEL, @@ -24,131 +12,13 @@ import { RELAY_ONBOARDING_TRIAL_STARTING_LABEL, RELAY_ONBOARDING_UPGRADE_LABEL, } from '@/utils/relayPresentation'; - -const SNOOZE_KEY = 'pulse_relay_onboarding_snoozed'; -const RELAY_SETTINGS_PATH = '/settings/system-relay'; +import { useRelayOnboardingCardState } from './useRelayOnboardingCardState'; export const RelayOnboardingCard: Component = () => { - const navigate = useNavigate(); - const [dismissed, setDismissed] = createSignal(isUpsellSnoozed(SNOOZE_KEY)); - const [licenseReady, setLicenseReady] = createSignal(licenseLoaded()); - - const [status, setStatus] = createSignal(null); - const [statusLoaded, setStatusLoaded] = createSignal(false); - const [statusLoading, setStatusLoading] = createSignal(false); - - const [trialStarting, setTrialStarting] = createSignal(false); - - const hasRelay = createMemo(() => hasFeature('relay')); - - const relayHasActiveConnections = createMemo(() => { - const st = status(); - if (!st) return false; - // "Active relay connections" on the status payload are exposed as active_channels. - // Treat disconnected as no active connections. - if (!st.connected) return false; - return st.active_channels > 0; - }); - - const shouldShowPaywall = createMemo(() => licenseReady() && !dismissed() && !hasRelay()); - - const shouldShowSetup = createMemo( - () => - licenseReady() && - !dismissed() && - hasRelay() && - statusLoaded() && - !relayHasActiveConnections(), - ); - - const shouldShow = createMemo(() => shouldShowPaywall() || shouldShowSetup()); - - const loadRelayStatusOnce = async () => { - if (statusLoading()) return; - if (statusLoaded()) return; - if (!hasRelay()) return; - - setStatusLoading(true); - try { - const st = await RelayAPI.getStatus(); - setStatus(st); - } catch (err) { - logger.warn('[RelayOnboardingCard] Failed to load relay status', err); - setStatus(null); - } finally { - setStatusLoaded(true); - setStatusLoading(false); - } - }; - - onMount(async () => { - try { - await loadLicenseStatus(); - } finally { - setLicenseReady(true); - } - // If relay is available, fetch the status so we can decide whether it’s already paired. - void loadRelayStatusOnce(); - }); - - createEffect(() => { - if (!licenseReady()) return; - // If the user starts a trial (or otherwise upgrades) while on the page, we need status - // to decide whether the onboarding card should still show. - if (hasRelay() && !statusLoaded() && !statusLoading()) { - void loadRelayStatusOnce(); - } - }); - - createEffect(() => { - if (shouldShowPaywall()) { - trackPaywallViewed('relay', 'dashboard_onboarding'); - } - }); - - const dismiss = () => { - snoozeUpsell(SNOOZE_KEY); - setDismissed(true); - }; - - const handleSetupRelay = () => { - navigate(RELAY_SETTINGS_PATH); - }; - - const handleStartTrial = async () => { - trackUpgradeClicked('dashboard_onboarding', 'relay'); - if (trialStarting()) return; - - setTrialStarting(true); - try { - const result = await startProTrial(); - if (result?.outcome === 'redirect') { - if (typeof window !== 'undefined') { - window.location.href = result.actionUrl; - } - return; - } - - showSuccess('Trial started. Relay is now available.'); - await loadLicenseStatus(true); - - // Re-fetch relay status now that the feature may be enabled. - setStatusLoaded(false); - void loadRelayStatusOnce(); - } catch (err) { - logger.warn('[RelayOnboardingCard] Failed to start trial; falling back to upgrade URL', err); - showError('Unable to start trial. Redirecting to upgrade options...'); - const upgradeUrl = getUpgradeActionUrlOrFallback('relay'); - if (typeof window !== 'undefined') { - window.location.href = upgradeUrl; - } - } finally { - setTrialStarting(false); - } - }; + const state = useRelayOnboardingCardState(); return ( - +
@@ -156,7 +26,7 @@ export const RelayOnboardingCard: Component = () => { @@ -203,13 +73,17 @@ export const RelayOnboardingCard: Component = () => { - + {RELAY_ONBOARDING_DISCONNECTED_LABEL}
diff --git a/frontend-modern/src/components/Dashboard/__tests__/RelayOnboardingCard.test.tsx b/frontend-modern/src/components/Dashboard/__tests__/RelayOnboardingCard.test.tsx index b2fcdc72e..69956664a 100644 --- a/frontend-modern/src/components/Dashboard/__tests__/RelayOnboardingCard.test.tsx +++ b/frontend-modern/src/components/Dashboard/__tests__/RelayOnboardingCard.test.tsx @@ -162,6 +162,20 @@ describe('RelayOnboardingCard', () => { }); }); + it('tracks upgrade clicks from the paywall link without starting a trial', async () => { + setupWithoutRelayFeature(); + render(() => ); + + await waitFor(() => { + expect(screen.getByText(/Get Relay/)).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText(/Get Relay/)); + + expect(trackUpgradeClickedMock).toHaveBeenCalledWith('dashboard_onboarding', 'relay'); + expect(startProTrialMock).not.toHaveBeenCalled(); + }); + it('does not render the "Set Up Relay" button', async () => { setupWithoutRelayFeature(); render(() => ); diff --git a/frontend-modern/src/components/Dashboard/useRelayOnboardingCardState.ts b/frontend-modern/src/components/Dashboard/useRelayOnboardingCardState.ts new file mode 100644 index 000000000..6f1d29b54 --- /dev/null +++ b/frontend-modern/src/components/Dashboard/useRelayOnboardingCardState.ts @@ -0,0 +1,147 @@ +import { createEffect, createMemo, createSignal, onMount } from 'solid-js'; +import { useNavigate } from '@solidjs/router'; +import { RelayAPI, type RelayStatus } from '@/api/relay'; +import { + getUpgradeActionUrlOrFallback, + hasFeature, + licenseLoaded, + loadLicenseStatus, + startProTrial, +} from '@/stores/license'; +import { logger } from '@/utils/logger'; +import { isUpsellSnoozed, snoozeUpsell } from '@/utils/snooze'; +import { showError, showSuccess } from '@/utils/toast'; +import { trackPaywallViewed, trackUpgradeClicked } from '@/utils/upgradeMetrics'; + +const SNOOZE_KEY = 'pulse_relay_onboarding_snoozed'; +const RELAY_SETTINGS_PATH = '/settings/system-relay'; + +export function useRelayOnboardingCardState() { + const navigate = useNavigate(); + const [dismissed, setDismissed] = createSignal(isUpsellSnoozed(SNOOZE_KEY)); + const [licenseReady, setLicenseReady] = createSignal(licenseLoaded()); + const [status, setStatus] = createSignal(null); + const [statusLoaded, setStatusLoaded] = createSignal(false); + const [statusLoading, setStatusLoading] = createSignal(false); + const [trialStarting, setTrialStarting] = createSignal(false); + + const hasRelay = createMemo(() => hasFeature('relay')); + const relayHasActiveConnections = createMemo(() => { + const nextStatus = status(); + if (!nextStatus || !nextStatus.connected) { + return false; + } + return nextStatus.active_channels > 0; + }); + const shouldShowPaywall = createMemo(() => licenseReady() && !dismissed() && !hasRelay()); + const shouldShowSetup = createMemo( + () => + licenseReady() && + !dismissed() && + hasRelay() && + statusLoaded() && + !relayHasActiveConnections(), + ); + const shouldShow = createMemo(() => shouldShowPaywall() || shouldShowSetup()); + + const loadRelayStatusOnce = async () => { + if (statusLoading() || statusLoaded() || !hasRelay()) { + return; + } + + setStatusLoading(true); + try { + const nextStatus = await RelayAPI.getStatus(); + setStatus(nextStatus); + } catch (error) { + logger.warn('[RelayOnboardingCard] Failed to load relay status', error); + setStatus(null); + } finally { + setStatusLoaded(true); + setStatusLoading(false); + } + }; + + onMount(async () => { + try { + await loadLicenseStatus(); + } finally { + setLicenseReady(true); + } + void loadRelayStatusOnce(); + }); + + createEffect(() => { + if (!licenseReady()) { + return; + } + if (hasRelay() && !statusLoaded() && !statusLoading()) { + void loadRelayStatusOnce(); + } + }); + + createEffect((wasPaywallVisible: boolean) => { + const isPaywallVisible = shouldShowPaywall(); + if (isPaywallVisible && !wasPaywallVisible) { + trackPaywallViewed('relay', 'dashboard_onboarding'); + } + return isPaywallVisible; + }, false); + + const dismiss = () => { + snoozeUpsell(SNOOZE_KEY); + setDismissed(true); + }; + + const handleSetupRelay = () => { + navigate(RELAY_SETTINGS_PATH); + }; + + const handleUpgradeClick = () => { + trackUpgradeClicked('dashboard_onboarding', 'relay'); + }; + + const handleStartTrial = async () => { + handleUpgradeClick(); + if (trialStarting()) { + return; + } + + setTrialStarting(true); + try { + const result = await startProTrial(); + if (result?.outcome === 'redirect') { + if (typeof window !== 'undefined') { + window.location.href = result.actionUrl; + } + return; + } + + showSuccess('Trial started. Relay is now available.'); + await loadLicenseStatus(true); + setStatusLoaded(false); + void loadRelayStatusOnce(); + } catch (error) { + logger.warn('[RelayOnboardingCard] Failed to start trial; falling back to upgrade URL', error); + showError('Unable to start trial. Redirecting to upgrade options...'); + const upgradeUrl = getUpgradeActionUrlOrFallback('relay'); + if (typeof window !== 'undefined') { + window.location.href = upgradeUrl; + } + } finally { + setTrialStarting(false); + } + }; + + return { + dismiss, + handleSetupRelay, + handleStartTrial, + handleUpgradeClick, + hasRelay, + shouldShow, + status, + statusLoaded, + trialStarting, + }; +} diff --git a/frontend-modern/src/components/Settings/__tests__/monitoredSystemModelGuardrails.test.ts b/frontend-modern/src/components/Settings/__tests__/monitoredSystemModelGuardrails.test.ts index 5d82cbd99..7e85b19a4 100644 --- a/frontend-modern/src/components/Settings/__tests__/monitoredSystemModelGuardrails.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/monitoredSystemModelGuardrails.test.ts @@ -144,7 +144,10 @@ import dashboardGuestPresentationSource from '@/utils/dashboardGuestPresentation import containerUpdatesSource from '@/stores/containerUpdates.ts?raw'; import websocketStoreSource from '@/stores/websocket.ts?raw'; import guestRowSource from '@/components/Dashboard/GuestRow.tsx?raw'; +import guestRowCellsSource from '@/components/Dashboard/GuestRowCells.tsx?raw'; +import guestRowStateSource from '@/components/Dashboard/useGuestRowState.ts?raw'; import dashboardDiskListSource from '@/components/Dashboard/DiskList.tsx?raw'; +import dashboardDiskListStateSource from '@/components/Dashboard/useDiskListState.ts?raw'; import guestDrawerSource from '@/components/Dashboard/GuestDrawer.tsx?raw'; import resourcePickerSource from '../ResourcePicker.tsx?raw'; import reportableResourceTypesSource from '@/utils/reportableResourceTypes.ts?raw'; @@ -167,6 +170,7 @@ import proxmoxSettingsPanelSource from '../ProxmoxSettingsPanel.tsx?raw'; import proxmoxSettingsModelSource from '../proxmoxSettingsModel.ts?raw'; import proxmoxDirectWorkspaceStateSource from '../useProxmoxDirectWorkspaceState.ts?raw'; import relayOnboardingCardSource from '@/components/Dashboard/RelayOnboardingCard.tsx?raw'; +import relayOnboardingCardStateSource from '@/components/Dashboard/useRelayOnboardingCardState.ts?raw'; import generalSettingsPanelSource from '../GeneralSettingsPanel.tsx?raw'; import networkBoundarySettingsSectionSource from '../NetworkBoundarySettingsSection.tsx?raw'; import networkDiscoverySectionSource from '../NetworkDiscoverySection.tsx?raw'; @@ -401,6 +405,7 @@ describe('monitored-system model guardrails', () => { expect(infrastructureSettingsModelSource).toContain('collectConfiguredInfrastructureHosts'); expect(infrastructureSettingsModelSource).toContain('matchConfiguredNodeToResource'); expect(relayOnboardingCardSource).toContain('@/utils/relayPresentation'); + expect(relayOnboardingCardSource).toContain('./useRelayOnboardingCardState'); expect(relayOnboardingCardSource).toContain('RELAY_ONBOARDING_TITLE'); expect(relayOnboardingCardSource).toContain('RELAY_ONBOARDING_DESCRIPTION'); expect(relayOnboardingCardSource).toContain('RELAY_ONBOARDING_UPGRADE_LABEL'); @@ -408,8 +413,16 @@ describe('monitored-system model guardrails', () => { expect(relayOnboardingCardSource).toContain('RELAY_ONBOARDING_TRIAL_STARTING_LABEL'); expect(relayOnboardingCardSource).toContain('RELAY_ONBOARDING_SETUP_LABEL'); expect(relayOnboardingCardSource).toContain('RELAY_ONBOARDING_DISCONNECTED_LABEL'); + expect(relayOnboardingCardSource).not.toContain('createSignal('); + expect(relayOnboardingCardSource).not.toContain('loadLicenseStatus()'); + expect(relayOnboardingCardSource).not.toContain('RelayAPI.getStatus()'); + expect(relayOnboardingCardSource).not.toContain('startProTrial()'); expect(relayOnboardingCardSource).not.toContain('Pair Your Mobile Device'); expect(relayOnboardingCardSource).not.toContain('Relay is currently disconnected.'); + expect(relayOnboardingCardStateSource).toContain('loadLicenseStatus()'); + expect(relayOnboardingCardStateSource).toContain('RelayAPI.getStatus()'); + expect(relayOnboardingCardStateSource).toContain('trackPaywallViewed'); + expect(relayOnboardingCardStateSource).toContain('startProTrial()'); expect(infrastructureInstallStateSource).toContain('STORAGE_KEYS.SETUP_HANDOFF'); expect(infrastructureInstallerSectionSource).toContain( 'Security configured. Save these first-run credentials now.', @@ -844,9 +857,9 @@ describe('monitored-system model guardrails', () => { ); expect(infrastructureSelectorSource).toContain("resource.type === 'truenas'"); expect(infrastructureSelectorSource).not.toContain('if (hostLikeResources.length === 0'); - expect(guestRowSource).toContain('getDashboardGuestBackupStatusPresentation'); - expect(guestRowSource).toContain('getDashboardGuestBackupTooltip'); - expect(guestRowSource).toContain('getDashboardGuestNetworkEmptyState'); + expect(guestRowCellsSource).toContain('getDashboardGuestBackupStatusPresentation'); + expect(guestRowCellsSource).toContain('getDashboardGuestBackupTooltip'); + expect(guestRowCellsSource).toContain('getDashboardGuestNetworkEmptyState'); expect(guestRowSource).toContain('getDashboardGuestDiskStatusMessage'); expect(guestRowSource).not.toContain('const BACKUP_STATUS_CONFIG: Record<'); expect(guestRowSource).not.toContain('No backup found'); @@ -854,7 +867,8 @@ describe('monitored-system model guardrails', () => { expect(guestRowSource).not.toContain( 'No filesystems found. VM may be booting or using a Live ISO.', ); - expect(dashboardDiskListSource).toContain('getDashboardGuestDiskStatusMessage'); + expect(dashboardDiskListSource).toContain('./useDiskListState'); + expect(dashboardDiskListStateSource).toContain('getDashboardGuestDiskStatusMessage'); expect(dashboardDiskListSource).not.toContain( 'No filesystems found. VM may be booting or using a Live ISO.', ); @@ -1362,8 +1376,10 @@ describe('monitored-system model guardrails', () => { expect(websocketStoreSource).not.toContain('syncWithHostCommand(hostId, command as any)'); expect(websocketStoreSource).toContain('markDockerRuntimesTokenRevoked'); expect(websocketStoreSource).toContain("markTokenRevoked('dockerRuntimes', tokenId, agentIds)"); - expect(guestRowSource).toContain('agentId={getWorkloadDockerHostId(props.guest)}'); - expect(guestRowSource).not.toContain('hostId={getWorkloadDockerHostId(props.guest)}'); + expect(guestRowSource).toContain('agentId={dockerHostId()}'); + expect(guestRowStateSource).toContain('getWorkloadDockerHostId(props.guest)'); + expect(guestRowSource).not.toContain('hostId={dockerHostId()}'); + expect(guestRowStateSource).not.toContain('getWorkloadDockerServerId'); expect(guestDrawerSource).toContain('agentId={discoveryAgentId()}'); expect(guestDrawerSource).not.toContain('hostId={discoveryHostId()}'); }); diff --git a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts index cb2213d28..f5f8a1c37 100644 --- a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts +++ b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts @@ -216,6 +216,7 @@ import k8sNamespacePresentationSource from '@/utils/k8sNamespacePresentation.ts? import k8sStatusPresentationSource from '@/utils/k8sStatusPresentation.ts?raw'; import raidCardSource from '@/components/shared/cards/RaidCard.tsx?raw'; import raidPresentationSource from '@/utils/raidPresentation.ts?raw'; +import relayOnboardingCardSource from '@/components/Dashboard/RelayOnboardingCard.tsx?raw'; import proLicensePanelSource from '@/components/Settings/ProLicensePanel.tsx?raw'; import proLicensePlanSectionSource from '@/components/Settings/ProLicensePlanSection.tsx?raw'; import securityPostureSummarySource from '@/components/Settings/SecurityPostureSummary.tsx?raw'; @@ -281,6 +282,7 @@ import storageDomainSource from '@/features/storageBackups/storageDomain.ts?raw' import storagePoolDetailPresentationSource from '@/features/storageBackups/storagePoolDetailPresentation.ts?raw'; import storageBarPresentationSource from '@/features/storageBackups/storageBarPresentation.ts?raw'; import storagePagePresentationSource from '@/features/storageBackups/storagePagePresentation.ts?raw'; +import relayOnboardingCardStateSource from '@/components/Dashboard/useRelayOnboardingCardState.ts?raw'; import proLicensePanelStateSource from '@/components/Settings/useProLicensePanelState.ts?raw'; import storagePageStatusSource from '@/features/storageBackups/storagePageStatus.ts?raw'; import storageRowPresentationSource from '@/features/storageBackups/rowPresentation.ts?raw'; @@ -1084,6 +1086,12 @@ describe('frontend resource type boundaries', () => { expect(organizationBillingStateSource).toContain('@/utils/organizationSettingsPresentation'); expect(billingAdminPanelSource).toContain('./useBillingAdminPanelState'); expect(billingAdminPanelSource).toContain('./BillingAdminOrganizationsTable'); + expect(relayOnboardingCardSource).toContain('./useRelayOnboardingCardState'); + expect(relayOnboardingCardSource).not.toContain('createSignal('); + expect(relayOnboardingCardSource).not.toContain('RelayAPI.getStatus()'); + expect(relayOnboardingCardStateSource).toContain('RelayAPI.getStatus()'); + expect(relayOnboardingCardStateSource).toContain('loadLicenseStatus()'); + expect(relayOnboardingCardStateSource).toContain('startProTrial()'); expect(organizationBillingPanelSource).not.toContain('normalizeOrgScope(getOrgID())'); expect(organizationBillingPanelSource).not.toContain('createSignal('); expect(billingAdminPanelSource).not.toContain('createSignal('); diff --git a/scripts/release_control/subsystem_lookup_test.py b/scripts/release_control/subsystem_lookup_test.py index 6d6de6204..94c860deb 100644 --- a/scripts/release_control/subsystem_lookup_test.py +++ b/scripts/release_control/subsystem_lookup_test.py @@ -157,6 +157,27 @@ class SubsystemLookupTest(unittest.TestCase): "relay-frontend-surfaces", ) + def test_lookup_paths_assigns_relay_onboarding_state_owner_to_cloud_paid(self) -> None: + result = lookup_paths(["frontend-modern/src/components/Dashboard/useRelayOnboardingCardState.ts"]) + self.assertEqual(result["unowned_runtime_files"], []) + self.assertEqual( + {item["subsystem"] for item in result["impacted_subsystems"]}, + {"cloud-paid"}, + ) + file_entry = result["files"][0] + self.assertEqual(file_entry["classification"], "runtime") + self.assertEqual( + {match["subsystem"] for match in file_entry["matches"]}, + {"cloud-paid"}, + ) + match = file_entry["matches"][0] + self.assertEqual(match["contract"], "docs/release-control/v6/internal/subsystems/cloud-paid.md") + self.assertEqual(match["lane_context"]["lane_id"], "L3") + self.assertEqual( + match["verification_requirement"]["id"], + "relay-frontend-surfaces", + ) + def test_lookup_paths_assigns_recovery_route_to_storage_recovery(self) -> None: result = lookup_paths(["frontend-modern/src/pages/RecoveryRoute.tsx"]) self.assertEqual(result["unowned_runtime_files"], [])