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, + ); +}