From 357b1a505e80bb1cafbfc9cb02f36f96182c7b57 Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:16:21 +0100 Subject: [PATCH] Explain why the Container update risk switch is locked Patrol settings showed the Container update risk switch dimmed on installs without ai_alerts and gave no reason, so a free install could not tell a plan limit from a broken setting. The card now renders the canonical Patrol availability presentation. patrolAutonomyAvailability.ts keeps one precedence (runtime lock, then hidden commercial surfaces, then plan lock) and the only action labels, while Patrol mode and container update risk each supply their lock state and copy. Links appear only when the session allows upgrade prompts, and installs that hide commercial surfaces get a neutral line with no plan or Pro wording. The explanation is tied to the switch with aria-describedby, because the shared Toggle does not render its locked/lockedMessage props. --- .../v6/internal/subsystems/api-contracts.md | 5 + .../subsystems/frontend-primitives.md | 5 + .../subsystems/patrol-intelligence.md | 10 ++ .../b254486b940deb2e.json | 42 ++++++ .../src/components/Settings/AISettings.tsx | 46 ++++++- .../Settings/__tests__/AISettings.test.tsx | 110 +++++++++++++++- .../__tests__/settingsArchitecture.test.ts | 29 +++++ .../PatrolIntelligenceHeader.test.ts | 38 ++++++ .../patrolAutonomyAvailability.test.ts | 120 ++++++++++++++++++ .../patrol/patrolAutonomyAvailability.ts | 97 +++++++++++--- 10 files changed, 481 insertions(+), 21 deletions(-) create mode 100644 frontend-modern/browser-verification/b254486b940deb2e.json diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 12452c53b1..0fd38347b5 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -3187,6 +3187,11 @@ a new API state machine, queue contract, or verification-accounting field. proof. The default API-backed presentation must stay on the selected control level and its plain summary; it must not render a separate Limits disclosure, hard-limit matrix, or always-on explanatory matrix beside the mode picker. + and the Patrol container update risk lock, so `/api/settings/ai` + `alert_triggered_analysis` stays the licence-masked API value while the + runtime-capabilities `ai_alerts` grant or `paid_runtime_required` block + decides which lock explanation the Patrol settings card renders, without a + new API field and the Patrol run-record copy, so `finding_ids` remains the API-owned fail-closed scoping input while the frontend presents selected history as a Patrol run record instead of a generic findings filter or snapshot workflow diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index e077067776..b2eb84d4c2 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -4738,6 +4738,11 @@ stable accessible label and route unconfigured installs into the canonical provider setup modal instead of falling back to generic "first pressed toggle" selectors, provider-model-load heuristics, legacy hosted-model enablement, or in-app trial acquisition. +A settings toggle disabled by a plan or runtime lock must explain the lock in +the card that owns it, not through the shared `Toggle` `locked` and +`lockedMessage` props, which the primitive does not render. `AISettings.tsx` +renders the Container update risk lock from the canonical Patrol availability +presentation and links that explanation to the switch with `ariaDescribedBy`. That same route-owned presentation rule also governs Patrol findings empty states: shared section shells under `frontend-modern/src/features/patrol/` must not render a green healthy empty state from `0 active findings` alone diff --git a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md index dfbf50ed95..e04084427b 100644 --- a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md +++ b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md @@ -725,6 +725,16 @@ attention`, `approval needed`, `outcome verified`, `no active work`) instead sentence across the header and mode control, repeatedly restate infrastructure-unchanged caveats in the default view, or imply Patrol's main value is a manual review chore. + Plan-gated Patrol capabilities share that one availability presentation: + `patrolAutonomyAvailability.ts` owns the precedence (runtime lock, then + hidden commercial surfaces, then plan lock) and the only action labels + (`Open Pro downloads`, `Plans & Billing`), while each capability supplies + its lock state and copy. The Patrol settings Container update risk trigger + (`ai_alerts`) uses `getPatrolAlertAnalysisAvailabilityPresentation`, so a + locked install sees why the trigger is unavailable beside the disabled + switch: a higher license plan, a missing Pulse Pro runtime, or, when + commercial surfaces are hidden, a neutral not-available line with no plan + or Pro wording. Actions appear only when upgrade prompts are allowed. 5. Add or change Patrol header schedule and runtime presentation through `frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx`, `frontend-modern/src/utils/aiPatrolSchedulePresentation.ts`, and `frontend-modern/src/utils/patrolRuntimePresentation.ts`. Patrol must not surface retired hosted-model credit badges or trial-like activation prompts in the normal self-hosted GA app, even when legacy transport fields are still present. 6. Keep Patrol and chat identifier-label presentation aligned through the shared `frontend-modern/src/utils/textPresentation.ts` diff --git a/frontend-modern/browser-verification/b254486b940deb2e.json b/frontend-modern/browser-verification/b254486b940deb2e.json new file mode 100644 index 0000000000..f4f8ebe47f --- /dev/null +++ b/frontend-modern/browser-verification/b254486b940deb2e.json @@ -0,0 +1,42 @@ +{ + "version": 1, + "base_sha": "1dcf9418aae87f9427dbfc6bcae67dbde114bf8a", + "verified_at": "2026-10-05T21:16:11Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/Settings/AISettings.tsx", + "frontend-modern/src/features/patrol/patrolAutonomyAvailability.ts" + ], + "content_sha256": { + "frontend-modern/src/components/Settings/AISettings.tsx": "d3966224e497b9f8f3cd701bea1661212c5f32c9cc15ff795c4001f851fae6ca", + "frontend-modern/src/features/patrol/patrolAutonomyAvailability.ts": "240fe0f1988d881da4344811aa3fdcc8e452182da24414ca6c941ea72e8e545f" + }, + "routes": [ + "/settings/pulse-intelligence/patrol", + "/settings/pulse-intelligence/billing/plan" + ], + "viewports": [ + { + "width": 1280, + "height": 900 + }, + { + "width": 390, + "height": 844 + } + ], + "states": [ + "Private stacks built from this tree: community runtime (7782/5282), community DEMO_MODE runtime (7783/5283) and a Pro runtime built from pulse-enterprise against this tree (7784/5284), mock data, dev-signed Pro and Enterprise licences. Playwright Chromium at 1280x900 dark and 390x844 light, plus the in-app browser pane.", + "Free community install (no licence, hideUpgrade true, hideCommercial false, ai_alerts absent, /api/settings/ai alert_triggered_analysis false via a real persisted save): switch disabled and off, card reads 'Higher license plan required. This install's plan does not include container update risk.' with no link; the switch's aria-describedby points at that line.", + "Unlicensed Pro runtime (prompts allowed, ai_alerts not granted, backend licence checker masks the setting to false): same plan-lock line plus a Plans & Billing link to /settings/pulse-intelligence/billing/plan.", + "Pro licence on the community runtime (ai_alerts blocked as paid_runtime_required): 'Pulse Pro runtime required. This install is running Pulse Community runtime. Install the Pulse Pro runtime to use container update risk.' plus Open Pro downloads (https://pulserelay.pro/download.html, new tab, noopener).", + "Hidden commercial surfaces via DEMO_MODE (hideCommercial and hideUpgrade true; read-only demo, so GET /api/settings/ai intercepted to alert_triggered_analysis false): 'Not available. This install does not include container update risk.' with no plan or Pro wording and no link.", + "Enterprise licence on the community runtime (white-label, hideCommercial true, ai_alerts runtime-blocked): runtime lock copy without the download link, which is the canonical runtime-before-commercial precedence shared with Patrol mode.", + "Pro licence on the Pro runtime: switch enabled, no lock line, no aria-describedby. No horizontal overflow and no page errors in any state; the disabled track styling is the pre-#2535 shared Toggle and is unchanged here." + ], + "interactions": [ + "Clicked the Plans & Billing link from the locked card in the browser pane; it routed in-app to /settings/pulse-intelligence/billing/plan.", + "On the licensed Pro runtime, turned the container update risk switch on and saved Patrol settings; GET /api/settings/ai then returned alert_triggered_analysis true.", + "Switched licence state live (activate Pro, activate Enterprise, clear) against the same backend and reloaded the page for each state." + ] +} diff --git a/frontend-modern/src/components/Settings/AISettings.tsx b/frontend-modern/src/components/Settings/AISettings.tsx index 931653fdec..a6f5c75a1e 100644 --- a/frontend-modern/src/components/Settings/AISettings.tsx +++ b/frontend-modern/src/components/Settings/AISettings.tsx @@ -1,4 +1,4 @@ -import { Component, For, createMemo, Show } from 'solid-js'; +import { Component, For, createMemo, createUniqueId, Show } from 'solid-js'; import { useNavigate } from '@solidjs/router'; import { AIChatMaintenanceSection } from '@/components/Settings/AIChatMaintenanceSection'; import { AISettingsDialogs } from '@/components/Settings/AISettingsDialogs'; @@ -18,8 +18,13 @@ import { FormSelect } from '@/components/shared/FormSelect'; import { LoadingSpinner } from '@/components/shared/LoadingSpinner'; import SettingsPanel from '@/components/shared/SettingsPanel'; import { Toggle } from '@/components/shared/Toggle'; +import { UpgradeLink } from '@/components/shared/UpgradeLink'; import { PATROL_PATH } from '@/routing/resourceLinks'; -import { getPatrolAutonomyAvailabilityPresentation } from '@/features/patrol/patrolAutonomyAvailability'; +import { + PATROL_ALERT_ANALYSIS_FEATURE_KEY, + getPatrolAlertAnalysisAvailabilityPresentation, + getPatrolAutonomyAvailabilityPresentation, +} from '@/features/patrol/patrolAutonomyAvailability'; import { getRuntimeCapabilityBlock, hasFeature, runtimeCapabilities } from '@/stores/license'; import { getUpgradeActionDestination } from '@/stores/licenseCommercial'; import { @@ -115,6 +120,17 @@ const PatrolSettingsContent: Component<{ state: ReturnType patrolModeAvailability().kind === 'plan_locked'); + const alertAnalysisAvailability = createMemo(() => + getPatrolAlertAnalysisAvailabilityPresentation({ + alertAnalysisLocked: props.state.alertAnalysisLocked(), + commercialSurfacesHidden: presentationPolicyHidesCommercialSurfaces(), + upgradePromptsHidden: presentationPolicyHidesUpgradePrompts(), + runtimeCapabilityBlock: getRuntimeCapabilityBlock(PATROL_ALERT_ANALYSIS_FEATURE_KEY), + runtime: runtimeCapabilities()?.runtime, + planUpgradeDestination: getUpgradeActionDestination(PATROL_ALERT_ANALYSIS_FEATURE_KEY), + }), + ); + const alertAnalysisLockId = createUniqueId(); const intervalOptions = [ { value: 0, label: 'Manual only' }, { value: 60, label: 'Every hour' }, @@ -270,14 +286,38 @@ const PatrolSettingsContent: Component<{ state: ReturnType Assess risk when container-update alerts fire.

+ +

+ + {alertAnalysisAvailability().title}. + {' '} + {alertAnalysisAvailability().body} + + {(destination) => ( + <> + {' '} + + {alertAnalysisAvailability().actionLabel} + + + )} + +

+
props.state.setForm('alertTriggeredAnalysis', event.currentTarget.checked) } - disabled={props.state.saving() || props.state.alertAnalysisLocked()} + disabled={props.state.saving() || alertAnalysisAvailability().locked} ariaLabel="Enable container update risk analysis" + ariaDescribedBy={ + alertAnalysisAvailability().locked ? alertAnalysisLockId : undefined + } /> diff --git a/frontend-modern/src/components/Settings/__tests__/AISettings.test.tsx b/frontend-modern/src/components/Settings/__tests__/AISettings.test.tsx index 9625418385..55ce073d9f 100644 --- a/frontend-modern/src/components/Settings/__tests__/AISettings.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/AISettings.test.tsx @@ -37,6 +37,8 @@ const loadCommercialPostureMock = vi.fn(); const commercialPostureMock = vi.fn(); const entitlementsMock = vi.fn(); const presentationPolicyHidesUpgradePromptsMock = vi.fn(); +const presentationPolicyHidesCommercialSurfacesMock = vi.fn(); +const getRuntimeCapabilityBlockMock = vi.fn(); vi.mock('@/api/ai', () => ({ AIAPI: { @@ -95,7 +97,7 @@ vi.mock('@/utils/logger', () => ({ vi.mock('@/stores/license', () => ({ hasFeature: (...args: unknown[]) => hasFeatureMock(...args), - getRuntimeCapabilityBlock: () => undefined, + getRuntimeCapabilityBlock: (...args: unknown[]) => getRuntimeCapabilityBlockMock(...args), loadRuntimeCapabilities: (...args: unknown[]) => loadLicenseStatusMock(...args), runtimeCapabilities: () => ({ capabilities: [], runtime: undefined }), })); @@ -110,7 +112,7 @@ vi.mock('@/stores/licenseCommercial', () => ({ })); vi.mock('@/stores/sessionPresentationPolicy', () => ({ - presentationPolicyHidesCommercialSurfaces: () => false, + presentationPolicyHidesCommercialSurfaces: () => presentationPolicyHidesCommercialSurfacesMock(), presentationPolicyHidesUpgradePrompts: () => presentationPolicyHidesUpgradePromptsMock(), })); @@ -181,6 +183,8 @@ const resetAllMocks = () => { commercialPostureMock.mockReset(); entitlementsMock.mockReset(); presentationPolicyHidesUpgradePromptsMock.mockReset(); + presentationPolicyHidesCommercialSurfacesMock.mockReset(); + getRuntimeCapabilityBlockMock.mockReset(); }; const setupDefaultMocks = () => { @@ -212,6 +216,8 @@ const setupDefaultMocks = () => { listSessionsMock.mockResolvedValue([]); summarizeSessionMock.mockResolvedValue(undefined); presentationPolicyHidesUpgradePromptsMock.mockReturnValue(false); + presentationPolicyHidesCommercialSurfacesMock.mockReturnValue(false); + getRuntimeCapabilityBlockMock.mockReturnValue(undefined); }; describe('AISettings model loading error states', () => { @@ -350,6 +356,106 @@ describe('AISettings model loading error states', () => { expect(screen.getByRole('button', { name: /Open Patrol/i })).toBeInTheDocument(); }); + describe('container update risk lock', () => { + const lockedPatrolSettings = () => ({ + ...baseSettings(), + enabled: true, + configured: true, + // The backend masks this to false when the install lacks ai_alerts. + alert_triggered_analysis: false, + }); + const containerRiskToggle = () => + screen.getByLabelText('Enable container update risk analysis'); + const lockExplanation = () => { + const describedBy = containerRiskToggle().getAttribute('aria-describedby'); + expect(describedBy).toBeTruthy(); + return document.getElementById(describedBy!)!; + }; + + it('explains a plan lock without an upgrade link on a free install', async () => { + hasFeatureMock.mockImplementation((feature: string) => feature !== 'ai_alerts'); + presentationPolicyHidesUpgradePromptsMock.mockReturnValue(true); + getSettingsMock.mockResolvedValue(lockedPatrolSettings()); + + renderComponent('patrol'); + + await screen.findByLabelText('Enable container update risk analysis'); + expect(containerRiskToggle()).toBeDisabled(); + expect(lockExplanation()).toHaveTextContent( + "Higher license plan required. This install's plan does not include container update risk.", + ); + expect(within(lockExplanation()).queryByRole('link')).toBeNull(); + expect(screen.queryByRole('link', { name: 'Plans & Billing' })).toBeNull(); + }); + + it('links Plans & Billing when upgrade prompts are allowed', async () => { + hasFeatureMock.mockImplementation((feature: string) => feature !== 'ai_alerts'); + getSettingsMock.mockResolvedValue(lockedPatrolSettings()); + + renderComponent('patrol'); + + await screen.findByLabelText('Enable container update risk analysis'); + expect(containerRiskToggle()).toBeDisabled(); + const link = within(lockExplanation()).getByRole('link', { name: 'Plans & Billing' }); + expect(link).toHaveAttribute('href', 'https://example.com/upgrade'); + }); + + it('drops plan and Pro wording when commercial surfaces are hidden', async () => { + hasFeatureMock.mockImplementation((feature: string) => feature !== 'ai_alerts'); + presentationPolicyHidesCommercialSurfacesMock.mockReturnValue(true); + presentationPolicyHidesUpgradePromptsMock.mockReturnValue(true); + getSettingsMock.mockResolvedValue(lockedPatrolSettings()); + + renderComponent('patrol'); + + await screen.findByLabelText('Enable container update risk analysis'); + expect(containerRiskToggle()).toBeDisabled(); + expect(lockExplanation()).toHaveTextContent( + 'Not available. This install does not include container update risk.', + ); + expect(lockExplanation().textContent).not.toMatch(/plan|Pro\b/i); + expect(within(lockExplanation()).queryByRole('link')).toBeNull(); + }); + + it('points a licensed community runtime at the Pro runtime download', async () => { + hasFeatureMock.mockImplementation((feature: string) => feature !== 'ai_alerts'); + getRuntimeCapabilityBlockMock.mockImplementation((feature: string) => + feature === 'ai_alerts' + ? { + key: 'ai_alerts', + reason: 'paid_runtime_required', + action_url: 'https://pulserelay.pro/download.html', + } + : undefined, + ); + getSettingsMock.mockResolvedValue(lockedPatrolSettings()); + + renderComponent('patrol'); + + await screen.findByLabelText('Enable container update risk analysis'); + expect(lockExplanation()).toHaveTextContent( + 'Pulse Pro runtime required. This install is running this runtime. Install the Pulse Pro runtime to use container update risk.', + ); + expect( + within(lockExplanation()).getByRole('link', { name: 'Open Pro downloads' }), + ).toHaveAttribute('href', 'https://pulserelay.pro/download.html'); + }); + + it('shows no lock explanation when the install includes the feature', async () => { + getSettingsMock.mockResolvedValue({ + ...lockedPatrolSettings(), + alert_triggered_analysis: true, + }); + + renderComponent('patrol'); + + await screen.findByLabelText('Enable container update risk analysis'); + expect(containerRiskToggle()).toBeEnabled(); + expect(containerRiskToggle()).not.toHaveAttribute('aria-describedby'); + expect(screen.queryByText(/Higher license plan required/)).toBeNull(); + }); + }); + it('saves Patrol trigger settings from Pulse Intelligence Patrol settings', async () => { getSettingsMock.mockResolvedValue({ ...baseSettings(), diff --git a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts index 768cf83f60..226f053d19 100644 --- a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts @@ -1459,6 +1459,35 @@ describe('settings architecture guardrails', () => { ); }); + it('explains the container update risk lock through the canonical Patrol availability policy', () => { + expect(aiSettingsSource).toContain('getPatrolAlertAnalysisAvailabilityPresentation({'); + expect(aiSettingsSource).toContain( + 'commercialSurfacesHidden: presentationPolicyHidesCommercialSurfaces(),', + ); + expect(aiSettingsSource).toContain( + 'upgradePromptsHidden: presentationPolicyHidesUpgradePrompts(),', + ); + expect(aiSettingsSource).toContain( + 'runtimeCapabilityBlock: getRuntimeCapabilityBlock(PATROL_ALERT_ANALYSIS_FEATURE_KEY),', + ); + expect(aiSettingsSource).toContain( + 'planUpgradeDestination: getUpgradeActionDestination(PATROL_ALERT_ANALYSIS_FEATURE_KEY),', + ); + expect(aiSettingsSource).toContain( + 'disabled={props.state.saving() || alertAnalysisAvailability().locked}', + ); + expect(aiSettingsSource).toContain( + '

', + ); + expect(aiSettingsSource).toContain( + 'alertAnalysisAvailability().locked ? alertAnalysisLockId : undefined', + ); + // The shared Toggle declares locked/lockedMessage but never renders them, + // so the card must carry the explanation itself. + expect(aiSettingsSource).not.toContain('lockedMessage='); + expect(aiSettingsSource).not.toContain('Requires Pulse Pro'); + }); + it('keeps system AI save feedback tied to provider and Patrol readiness context', () => { expect(aiSettingsSource).toContain('Choose a Patrol mode on the Patrol page.'); expect(aiSettingsSource).toContain('Open Patrol'); diff --git a/frontend-modern/src/features/patrol/__tests__/PatrolIntelligenceHeader.test.ts b/frontend-modern/src/features/patrol/__tests__/PatrolIntelligenceHeader.test.ts index d3e17b1700..c46e349e8f 100644 --- a/frontend-modern/src/features/patrol/__tests__/PatrolIntelligenceHeader.test.ts +++ b/frontend-modern/src/features/patrol/__tests__/PatrolIntelligenceHeader.test.ts @@ -8,6 +8,7 @@ import { getPatrolConfigurationFailureInlineDetails, } from '../PatrolIntelligenceHeader'; import { + getPatrolAlertAnalysisAvailabilityPresentation, getPatrolAutonomyAvailabilityPresentation, PATROL_AUTONOMY_RUNTIME_REQUIRED_REASON, } from '../patrolAutonomyAvailability'; @@ -312,6 +313,43 @@ describe('PatrolIntelligenceHeader', () => { }); }); + it('explains the container update risk lock through the same presentation policy', () => { + const planUpgradeDestination = { + href: '/settings/pulse-intelligence/billing/plan', + external: false, + }; + expect( + getPatrolAlertAnalysisAvailabilityPresentation({ + alertAnalysisLocked: true, + planUpgradeDestination, + }), + ).toMatchObject({ + kind: 'plan_locked', + title: 'Higher license plan required', + actionLabel: 'Plans & Billing', + destination: planUpgradeDestination, + }); + expect( + getPatrolAlertAnalysisAvailabilityPresentation({ + alertAnalysisLocked: true, + upgradePromptsHidden: true, + planUpgradeDestination, + }), + ).not.toHaveProperty('actionLabel'); + expect( + getPatrolAlertAnalysisAvailabilityPresentation({ + alertAnalysisLocked: true, + commercialSurfacesHidden: true, + planUpgradeDestination, + }), + ).toEqual({ + kind: 'plan_locked', + locked: true, + title: 'Not available', + body: 'This install does not include container update risk.', + }); + }); + it('keeps trust counters out of the page header chrome', () => { // Needs Attention and deliberate history review own trust // state. The header should stay focused on title, recency, and controls. diff --git a/frontend-modern/src/features/patrol/__tests__/patrolAutonomyAvailability.test.ts b/frontend-modern/src/features/patrol/__tests__/patrolAutonomyAvailability.test.ts index 100d81e519..c8a6f090ed 100644 --- a/frontend-modern/src/features/patrol/__tests__/patrolAutonomyAvailability.test.ts +++ b/frontend-modern/src/features/patrol/__tests__/patrolAutonomyAvailability.test.ts @@ -3,8 +3,10 @@ import { describe, expect, it } from 'vitest'; import type { LicenseRuntimeCapabilityBlock, LicenseRuntimeIdentity } from '@/api/license'; import { + PATROL_ALERT_ANALYSIS_FEATURE_KEY, PATROL_AUTONOMY_FEATURE_KEY, PATROL_AUTONOMY_RUNTIME_REQUIRED_REASON, + getPatrolAlertAnalysisAvailabilityPresentation, getPatrolAutonomyAvailabilityPresentation, } from '../patrolAutonomyAvailability'; import type { UpgradeDestination } from '@/utils/upgradeNavigation'; @@ -53,6 +55,10 @@ describe('patrolAutonomyAvailability', () => { expect(PATROL_AUTONOMY_FEATURE_KEY).toBe('ai_autofix'); }); + it('exposes the ai_alerts feature key', () => { + expect(PATROL_ALERT_ANALYSIS_FEATURE_KEY).toBe('ai_alerts'); + }); + it('exposes the paid_runtime_required reason', () => { expect(PATROL_AUTONOMY_RUNTIME_REQUIRED_REASON).toBe('paid_runtime_required'); }); @@ -319,4 +325,118 @@ describe('patrolAutonomyAvailability', () => { }); }); }); + + describe('getPatrolAlertAnalysisAvailabilityPresentation', () => { + const alertRuntimeBlock = runtimeBlock({ key: PATROL_ALERT_ANALYSIS_FEATURE_KEY }); + + it('is available when alert analysis is not locked, ignoring every other field', () => { + expect( + getPatrolAlertAnalysisAvailabilityPresentation({ + alertAnalysisLocked: false, + runtimeCapabilityBlock: alertRuntimeBlock, + commercialSurfacesHidden: true, + planUpgradeDestination: planDestination, + }), + ).toEqual({ + kind: 'available', + locked: false, + title: 'Container update risk available', + body: 'Assess risk when container-update alerts fire.', + }); + }); + + it('explains a plan lock and offers Plans & Billing when surfaces are visible', () => { + expect( + getPatrolAlertAnalysisAvailabilityPresentation({ + alertAnalysisLocked: true, + planUpgradeDestination: planDestination, + }), + ).toEqual({ + kind: 'plan_locked', + locked: true, + title: 'Higher license plan required', + body: "This install's plan does not include container update risk.", + actionLabel: 'Plans & Billing', + destination: planDestination, + }); + }); + + it('keeps the plan explanation but drops the action when upgrade prompts are hidden', () => { + const result = getPatrolAlertAnalysisAvailabilityPresentation({ + alertAnalysisLocked: true, + upgradePromptsHidden: true, + planUpgradeDestination: planDestination, + }); + + expect(result).toEqual({ + kind: 'plan_locked', + locked: true, + title: 'Higher license plan required', + body: "This install's plan does not include container update risk.", + }); + expect(result).not.toHaveProperty('actionLabel'); + expect(result).not.toHaveProperty('destination'); + }); + + it('uses neutral copy with no action when commercial surfaces are hidden', () => { + const result = getPatrolAlertAnalysisAvailabilityPresentation({ + alertAnalysisLocked: true, + commercialSurfacesHidden: true, + planUpgradeDestination: planDestination, + }); + + expect(result).toEqual({ + kind: 'plan_locked', + locked: true, + title: 'Not available', + body: 'This install does not include container update risk.', + }); + expect(`${result.title} ${result.body}`).not.toMatch(/plan|Pro\b/i); + }); + + it('shares the canonical runtime lock with Patrol mode', () => { + const input = { + runtimeCapabilityBlock: runtimeBlock({ + key: PATROL_ALERT_ANALYSIS_FEATURE_KEY, + action_url: 'https://pro.example/downloads', + }), + runtime: runtime({ label: 'Pulse Community runtime' }), + planUpgradeDestination: planDestination, + }; + const alertAnalysis = getPatrolAlertAnalysisAvailabilityPresentation({ + ...input, + alertAnalysisLocked: true, + }); + const patrolMode = getPatrolAutonomyAvailabilityPresentation({ + ...input, + autoFixLocked: true, + }); + + expect(alertAnalysis).toEqual({ + kind: 'runtime_locked', + locked: true, + title: 'Pulse Pro runtime required', + body: 'This install is running Pulse Community runtime. Install the Pulse Pro runtime to use container update risk.', + actionLabel: 'Open Pro downloads', + destination: externalDestination('https://pro.example/downloads'), + }); + expect(alertAnalysis.title).toBe(patrolMode.title); + expect(alertAnalysis.actionLabel).toBe(patrolMode.actionLabel); + expect(alertAnalysis.destination).toEqual(patrolMode.destination); + }); + + it('omits the runtime download action when upgrade prompts are hidden', () => { + const result = getPatrolAlertAnalysisAvailabilityPresentation({ + alertAnalysisLocked: true, + upgradePromptsHidden: true, + runtimeCapabilityBlock: alertRuntimeBlock, + runtime: runtime(), + planUpgradeDestination: planDestination, + }); + + expect(result.kind).toBe('runtime_locked'); + expect(result).not.toHaveProperty('actionLabel'); + expect(result).not.toHaveProperty('destination'); + }); + }); }); diff --git a/frontend-modern/src/features/patrol/patrolAutonomyAvailability.ts b/frontend-modern/src/features/patrol/patrolAutonomyAvailability.ts index f23791bd33..c683fc9720 100644 --- a/frontend-modern/src/features/patrol/patrolAutonomyAvailability.ts +++ b/frontend-modern/src/features/patrol/patrolAutonomyAvailability.ts @@ -3,12 +3,16 @@ import type { UpgradeDestination } from '@/utils/upgradeNavigation'; import { resolveUpgradeDestination } from '@/utils/upgradeNavigation'; export const PATROL_AUTONOMY_FEATURE_KEY = 'ai_autofix'; +export const PATROL_ALERT_ANALYSIS_FEATURE_KEY = 'ai_alerts'; export const PATROL_AUTONOMY_RUNTIME_REQUIRED_REASON = 'paid_runtime_required'; -export type PatrolAutonomyAvailabilityKind = 'available' | 'plan_locked' | 'runtime_locked'; +export type PatrolFeatureAvailabilityKind = 'available' | 'plan_locked' | 'runtime_locked'; -export interface PatrolAutonomyAvailabilityInput { - autoFixLocked: boolean; +// Presentation policy shared by every plan-gated Patrol capability. Each +// capability supplies only its lock state and its own copy; the precedence +// (runtime lock, then hidden commercial surfaces, then plan lock) and the +// action labels stay canonical here. +interface PatrolFeatureAvailabilityPolicy { upgradePromptsHidden?: boolean; commercialSurfacesHidden?: boolean; runtimeCapabilityBlock?: LicenseRuntimeCapabilityBlock; @@ -16,8 +20,16 @@ export interface PatrolAutonomyAvailabilityInput { planUpgradeDestination: UpgradeDestination; } -export interface PatrolAutonomyAvailabilityPresentation { - kind: PatrolAutonomyAvailabilityKind; +export interface PatrolAutonomyAvailabilityInput extends PatrolFeatureAvailabilityPolicy { + autoFixLocked: boolean; +} + +export interface PatrolAlertAnalysisAvailabilityInput extends PatrolFeatureAvailabilityPolicy { + alertAnalysisLocked: boolean; +} + +export interface PatrolFeatureAvailabilityPresentation { + kind: PatrolFeatureAvailabilityKind; locked: boolean; title: string; body: string; @@ -25,6 +37,44 @@ export interface PatrolAutonomyAvailabilityPresentation { destination?: UpgradeDestination; } +interface PatrolFeatureAvailabilityCopy { + available: { title: string; body: string }; + planLocked: { title: string; body: string }; + // Plan-locked copy for sessions that hide commercial surfaces. Defaults to + // planLocked, so it is only needed when that copy mentions plans. + planLockedCommercialHidden?: { title: string; body: string }; + // Completes "Install the Pulse Pro runtime to use ...". + runtimeFeatureLabel: string; +} + +const PATROL_AUTONOMY_COPY: PatrolFeatureAvailabilityCopy = { + available: { + title: 'Patrol mode available', + body: 'Choose the mode for this install.', + }, + planLocked: { + title: 'Watch only', + body: 'This install watches infrastructure and shows issues.', + }, + runtimeFeatureLabel: 'Patrol modes', +}; + +const PATROL_ALERT_ANALYSIS_COPY: PatrolFeatureAvailabilityCopy = { + available: { + title: 'Container update risk available', + body: 'Assess risk when container-update alerts fire.', + }, + planLocked: { + title: 'Higher license plan required', + body: "This install's plan does not include container update risk.", + }, + planLockedCommercialHidden: { + title: 'Not available', + body: 'This install does not include container update risk.', + }, + runtimeFeatureLabel: 'container update risk', +}; + function getRuntimeDownloadDestination( block: LicenseRuntimeCapabilityBlock | undefined, runtime: LicenseRuntimeIdentity | undefined, @@ -37,15 +87,16 @@ function getRuntimeLabel(runtime: LicenseRuntimeIdentity | undefined): string { return runtime?.label?.trim() || 'this runtime'; } -export function getPatrolAutonomyAvailabilityPresentation( - input: PatrolAutonomyAvailabilityInput, -): PatrolAutonomyAvailabilityPresentation { - if (!input.autoFixLocked) { +function getPatrolFeatureAvailabilityPresentation( + locked: boolean, + input: PatrolFeatureAvailabilityPolicy, + copy: PatrolFeatureAvailabilityCopy, +): PatrolFeatureAvailabilityPresentation { + if (!locked) { return { kind: 'available', locked: false, - title: 'Patrol mode available', - body: 'Choose the mode for this install.', + ...copy.available, }; } @@ -54,7 +105,7 @@ export function getPatrolAutonomyAvailabilityPresentation( kind: 'runtime_locked', locked: true, title: 'Pulse Pro runtime required', - body: `This install is running ${getRuntimeLabel(input.runtime)}. Install the Pulse Pro runtime to use Patrol modes.`, + body: `This install is running ${getRuntimeLabel(input.runtime)}. Install the Pulse Pro runtime to use ${copy.runtimeFeatureLabel}.`, ...(input.upgradePromptsHidden ? {} : { @@ -70,16 +121,14 @@ export function getPatrolAutonomyAvailabilityPresentation( return { kind: 'plan_locked', locked: true, - title: 'Watch only', - body: 'This install watches infrastructure and shows issues.', + ...(copy.planLockedCommercialHidden ?? copy.planLocked), }; } return { kind: 'plan_locked', locked: true, - title: 'Watch only', - body: 'This install watches infrastructure and shows issues.', + ...copy.planLocked, ...(input.upgradePromptsHidden ? {} : { @@ -88,3 +137,19 @@ export function getPatrolAutonomyAvailabilityPresentation( }), }; } + +export function getPatrolAutonomyAvailabilityPresentation( + input: PatrolAutonomyAvailabilityInput, +): PatrolFeatureAvailabilityPresentation { + return getPatrolFeatureAvailabilityPresentation(input.autoFixLocked, input, PATROL_AUTONOMY_COPY); +} + +export function getPatrolAlertAnalysisAvailabilityPresentation( + input: PatrolAlertAnalysisAvailabilityInput, +): PatrolFeatureAvailabilityPresentation { + return getPatrolFeatureAvailabilityPresentation( + input.alertAnalysisLocked, + input, + PATROL_ALERT_ANALYSIS_COPY, + ); +}