From 9ec8f85af2ac228571cdfb1d68d014ab3e198d48 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Mon, 23 Mar 2026 16:21:08 +0000 Subject: [PATCH] Canonicalize self-hosted pricing presentation --- .../v6/internal/subsystems/cloud-paid.md | 38 ++- .../v6/internal/subsystems/registry.json | 33 ++- .../Settings/MonitoredSystemLedgerPanel.tsx | 9 +- .../components/Settings/ProLicensePanel.tsx | 3 +- .../MonitoredSystemLedgerPanel.test.tsx | 3 + .../__tests__/ProLicensePanel.test.tsx | 18 +- frontend-modern/src/pages/PricingV6.tsx | 241 ++---------------- .../src/pages/__tests__/PricingV6.test.tsx | 75 ++++++ .../utils/__tests__/selfHostedPlans.test.ts | 43 ++++ frontend-modern/src/utils/selfHostedPlans.ts | 234 +++++++++++++++++ .../release_control/subsystem_lookup_test.py | 59 ++++- 11 files changed, 505 insertions(+), 251 deletions(-) create mode 100644 frontend-modern/src/pages/__tests__/PricingV6.test.tsx create mode 100644 frontend-modern/src/utils/__tests__/selfHostedPlans.test.ts create mode 100644 frontend-modern/src/utils/selfHostedPlans.ts diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index abdf9b286..46b1d6b34 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -52,19 +52,23 @@ agreement, and cloud-specific enforcement rules. 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` +33. `frontend-modern/src/components/Settings/MonitoredSystemLedgerPanel.tsx` +34. `frontend-modern/src/components/Settings/ProLicensePanel.tsx` +35. `frontend-modern/src/components/Settings/ProLicensePlanSection.tsx` +36. `frontend-modern/src/components/Settings/CommercialBillingSections.tsx` +37. `frontend-modern/src/components/Settings/SelfHostedCommercialActivationSection.tsx` +38. `frontend-modern/src/components/Settings/RelaySettingsPanel.tsx` +39. `frontend-modern/src/components/Settings/RelayPairingSection.tsx` +40. `frontend-modern/src/components/Settings/useBillingAdminPanelState.ts` +41. `frontend-modern/src/components/Settings/useOrganizationBillingPanelState.ts` +42. `frontend-modern/src/components/Settings/useProLicensePanelState.ts` +43. `frontend-modern/src/components/Settings/useRelaySettingsPanelState.ts` +44. `frontend-modern/src/pages/CloudPricing.tsx` +45. `frontend-modern/src/pages/PricingV6.tsx` +46. `frontend-modern/src/utils/apiClient.ts` +47. `frontend-modern/src/utils/cloudPlans.ts` +48. `frontend-modern/src/utils/commercialBillingModel.ts` +49. `frontend-modern/src/utils/selfHostedPlans.ts` ## Shared Boundaries @@ -299,6 +303,14 @@ the monitored-system model as well. `ProLicensePanel.tsx`, retail capacity as monitored systems rather than agents for Community, Relay, Pro, and Pro+, while leaving Cloud/MSP pricing semantics unchanged and preserving grandfathered v5 continuity copy as an explicit boundary policy. +That same pricing boundary now also owns the shared frontend plan-definition +models. `frontend-modern/src/utils/cloudPlans.ts` and +`frontend-modern/src/utils/selfHostedPlans.ts` are the canonical frontend +sources for cloud and self-hosted plan copy, limits, and comparison metadata, +while `frontend-modern/src/pages/CloudPricing.tsx`, +`frontend-modern/src/pages/HostedSignup.tsx`, `frontend-modern/src/pages/PricingV6.tsx`, +and the self-hosted billing settings surfaces must consume those shared owners +instead of redefining retail plan facts or counted-unit policy locally. That contract applies to both plan summary labels and upgrade/paywall copy: current v6 self-hosted pricing may not drift back to the older `$49/yr Relay`, `$99/yr Pro`, or monitored-system-count marketing drift that contradicts the diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index 6bf75432e..4f92c8787 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -1283,6 +1283,7 @@ "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/MonitoredSystemLedgerPanel.tsx", "frontend-modern/src/components/Settings/OrganizationBillingLoadingState.tsx", "frontend-modern/src/components/Settings/OrganizationBillingPanel.tsx", "frontend-modern/src/components/Settings/ProLicensePanel.tsx", @@ -1295,8 +1296,12 @@ "frontend-modern/src/components/Settings/useRelaySettingsPanelState.ts", "frontend-modern/src/pages/CloudPricing.tsx", "frontend-modern/src/pages/HostedSignup.tsx", + "frontend-modern/src/pages/PricingV6.tsx", "frontend-modern/src/useAppRuntimeState.ts", "frontend-modern/src/utils/apiClient.ts", + "frontend-modern/src/utils/cloudPlans.ts", + "frontend-modern/src/utils/commercialBillingModel.ts", + "frontend-modern/src/utils/selfHostedPlans.ts", "internal/api/public_signup_handlers.go", "internal/cloudcp/account/tenant_handlers.go", "internal/cloudcp/entitlements/service.go", @@ -1712,6 +1717,7 @@ "label": "pro license surface proof", "match_prefixes": [], "match_files": [ + "frontend-modern/src/components/Settings/MonitoredSystemLedgerPanel.tsx", "frontend-modern/src/components/Settings/ProLicensePanel.tsx", "frontend-modern/src/components/Settings/ProLicensePlanSection.tsx", "frontend-modern/src/components/Settings/useProLicensePanelState.ts" @@ -1719,9 +1725,30 @@ "allow_same_subsystem_tests": false, "test_prefixes": [], "exact_files": [ + "frontend-modern/src/components/Settings/__tests__/MonitoredSystemLedgerPanel.test.tsx", "frontend-modern/src/components/Settings/__tests__/ProLicensePanel.test.tsx" ] }, + { + "id": "commercial-plan-models", + "label": "commercial plan model proof", + "match_prefixes": [], + "match_files": [ + "frontend-modern/src/utils/cloudPlans.ts", + "frontend-modern/src/utils/commercialBillingModel.ts", + "frontend-modern/src/utils/selfHostedPlans.ts" + ], + "allow_same_subsystem_tests": false, + "test_prefixes": [], + "exact_files": [ + "frontend-modern/src/components/Settings/__tests__/OrganizationBillingPanel.test.tsx", + "frontend-modern/src/components/Settings/__tests__/ProLicensePanel.test.tsx", + "frontend-modern/src/pages/__tests__/CloudPricing.test.tsx", + "frontend-modern/src/pages/__tests__/HostedSignup.test.tsx", + "frontend-modern/src/pages/__tests__/PricingV6.test.tsx", + "frontend-modern/src/utils/__tests__/selfHostedPlans.test.ts" + ] + }, { "id": "relay-frontend-surfaces", "label": "relay frontend surface proof", @@ -1781,14 +1808,16 @@ "match_prefixes": [], "match_files": [ "frontend-modern/src/pages/CloudPricing.tsx", - "frontend-modern/src/pages/HostedSignup.tsx" + "frontend-modern/src/pages/HostedSignup.tsx", + "frontend-modern/src/pages/PricingV6.tsx" ], "allow_same_subsystem_tests": false, "test_prefixes": [], "exact_files": [ "frontend-modern/src/api/__tests__/hostedSignup.test.ts", "frontend-modern/src/pages/__tests__/CloudPricing.test.tsx", - "frontend-modern/src/pages/__tests__/HostedSignup.test.tsx" + "frontend-modern/src/pages/__tests__/HostedSignup.test.tsx", + "frontend-modern/src/pages/__tests__/PricingV6.test.tsx" ] } ] diff --git a/frontend-modern/src/components/Settings/MonitoredSystemLedgerPanel.tsx b/frontend-modern/src/components/Settings/MonitoredSystemLedgerPanel.tsx index 18590deb3..fde59d15b 100644 --- a/frontend-modern/src/components/Settings/MonitoredSystemLedgerPanel.tsx +++ b/frontend-modern/src/components/Settings/MonitoredSystemLedgerPanel.tsx @@ -18,6 +18,10 @@ import { getMonitoredSystemLedgerLoadingState, } from '@/utils/unifiedAgentInventoryPresentation'; import { PulseLogoIcon } from '@/components/icons/PulseLogoIcon'; +import { + SELF_HOSTED_MONITORED_SYSTEM_LEDGER_DESCRIPTION, + SELF_HOSTED_MONITORED_SYSTEMS_DESCRIPTION, +} from '@/utils/selfHostedPlans'; interface MonitoredSystemLedgerPanelProps { embedded?: boolean; @@ -58,8 +62,7 @@ export function MonitoredSystemLedgerPanel(props: MonitoredSystemLedgerPanelProp

- Pulse sells monitored systems, not everything underneath them. VMs, containers, pods, - disks, backups, and other child resources do not count separately. + {SELF_HOSTED_MONITORED_SYSTEMS_DESCRIPTION}

@@ -152,7 +155,7 @@ export function MonitoredSystemLedgerPanel(props: MonitoredSystemLedgerPanelProp return ( } bodyClass="space-y-4" > diff --git a/frontend-modern/src/components/Settings/ProLicensePanel.tsx b/frontend-modern/src/components/Settings/ProLicensePanel.tsx index 3cd84901f..e5f83bb5d 100644 --- a/frontend-modern/src/components/Settings/ProLicensePanel.tsx +++ b/frontend-modern/src/components/Settings/ProLicensePanel.tsx @@ -6,6 +6,7 @@ import { CommercialBillingShell, CommercialSection } from './CommercialBillingSe import { ProLicensePlanSection } from './ProLicensePlanSection'; import { SelfHostedCommercialActivationSection } from './SelfHostedCommercialActivationSection'; import { useProLicensePanelState } from './useProLicensePanelState'; +import { SELF_HOSTED_MONITORED_SYSTEMS_DESCRIPTION } from '@/utils/selfHostedPlans'; export const ProLicensePanel: Component = () => { const state = useProLicensePanelState(); @@ -51,7 +52,7 @@ export const ProLicensePanel: Component = () => { diff --git a/frontend-modern/src/components/Settings/__tests__/MonitoredSystemLedgerPanel.test.tsx b/frontend-modern/src/components/Settings/__tests__/MonitoredSystemLedgerPanel.test.tsx index af338052e..fe6b0e97c 100644 --- a/frontend-modern/src/components/Settings/__tests__/MonitoredSystemLedgerPanel.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/MonitoredSystemLedgerPanel.test.tsx @@ -147,6 +147,9 @@ describe('MonitoredSystemLedgerPanel', () => { 'Review the monitored systems currently counting toward your Pulse Pro allocation.', ), ).toBeInTheDocument(); + expect( + screen.getByText(/a monitored system is a top-level machine or cluster pulse actively monitors/i), + ).toBeInTheDocument(); expect(screen.getByText('server-b')).toBeInTheDocument(); expect(screen.getByText('2 / 10')).toBeInTheDocument(); expect(screen.queryByText('Failed to load monitored system ledger.')).not.toBeInTheDocument(); diff --git a/frontend-modern/src/components/Settings/__tests__/ProLicensePanel.test.tsx b/frontend-modern/src/components/Settings/__tests__/ProLicensePanel.test.tsx index 071e57b21..24b0947fa 100644 --- a/frontend-modern/src/components/Settings/__tests__/ProLicensePanel.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/ProLicensePanel.test.tsx @@ -148,15 +148,15 @@ describe('ProLicensePanel', () => { expect(screen.getByText('Plan Terms')).toBeInTheDocument(); }); - expect(screen.getByText('5 / 12')).toBeInTheDocument(); - expect(screen.getByText('7')).toBeInTheDocument(); - expect(screen.getAllByText('Active').length).toBeGreaterThan(0); - expect(screen.getByText('V5 Lifetime Grandfathered')).toBeInTheDocument(); - expect(screen.getByText('Included Monitored Systems')).toBeInTheDocument(); - expect( - screen.getByText(/self-hosted plans are sold by monitored systems/i), - ).toBeInTheDocument(); - }); + expect(screen.getByText('5 / 12')).toBeInTheDocument(); + expect(screen.getByText('7')).toBeInTheDocument(); + expect(screen.getAllByText('Active').length).toBeGreaterThan(0); + expect(screen.getByText('V5 Lifetime Grandfathered')).toBeInTheDocument(); + expect(screen.getByText('Included Monitored Systems')).toBeInTheDocument(); + expect( + screen.getAllByText(/a monitored system is a top-level machine or cluster/i).length, + ).toBeGreaterThan(0); + }); it('shows recurring grandfathered pricing continuity for migrated v5 Pro plans', async () => { const tests = [ diff --git a/frontend-modern/src/pages/PricingV6.tsx b/frontend-modern/src/pages/PricingV6.tsx index d23e6c4a9..ca1d05d70 100644 --- a/frontend-modern/src/pages/PricingV6.tsx +++ b/frontend-modern/src/pages/PricingV6.tsx @@ -18,220 +18,13 @@ import { } from '@/stores/license'; import { showToast } from '@/utils/toast'; import { logger } from '@/utils/logger'; - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -type TierColumn = 'community' | 'relay' | 'pro' | 'proPlus'; - -type FeatureRow = { - key: string; - name: string; - community: boolean | string; - relay: boolean | string; - pro: boolean | string; - proPlus: boolean | string; -}; - -// --------------------------------------------------------------------------- -// Tier pricing data -// --------------------------------------------------------------------------- - -const TIERS = { - community: { - name: 'Community', - price: 'Free forever', - subline: 'Monitor up to 5 systems for free', - highlights: [ - 'Real-time monitoring', - '7-day metric history', - 'AI Patrol (BYOK + 25 quickstart credits)', - 'Update alerts', - 'Basic SSO (OIDC)', - 'Community support', - ], - }, - relay: { - name: 'Relay', - price: '$4.99/month', - subline: 'or $39/year', - highlights: [ - 'Everything in Community', - 'Remote access via Relay', - 'Mobile app access', - 'Push notifications', - 'Custom URL (yourlab.pulserelay.pro)', - '8 monitored systems · 14-day history', - ], - }, - pro: { - name: 'Pro', - price: '$8.99/month', - subline: 'or $79/year', - highlights: [ - 'Everything in Relay', - 'AI Auto-Fix & investigation', - 'AI alert analysis', - 'Kubernetes AI analysis', - '90-day metric history', - 'RBAC, audit logging, SAML SSO', - 'Agent profiles · PDF/CSV reports', - '15 monitored systems', - ], - }, - proPlus: { - name: 'Pro+', - price: '$14.99/month', - subline: 'or $129/year', - highlights: ['Everything in Pro', '50 monitored systems'], - }, -} as const; - -// --------------------------------------------------------------------------- -// Feature comparison matrix for self-hosted tiers (Community → Pro+). -// Based on ENTITLEMENT_MATRIX.md with display-only rows (hosts, history) added -// and enterprise-only rows (multi_user, white_label, multi_tenant, unlimited) omitted. -// --------------------------------------------------------------------------- - -const FEATURE_ROWS: FeatureRow[] = [ - // -- Monitoring & basics -- - { - key: 'monitoring', - name: 'Real-time Monitoring', - community: true, - relay: true, - pro: true, - proPlus: true, - }, - { - key: 'hosts', - name: 'Monitored Systems', - community: '5', - relay: '8', - pro: '15', - proPlus: '50', - }, - { - key: 'history', - name: 'Metric History', - community: '7 days', - relay: '14 days', - pro: '90 days', - proPlus: '90 days', - }, - { - key: 'update_alerts', - name: 'Update Alerts (Container/Package)', - community: true, - relay: true, - pro: true, - proPlus: true, - }, - { key: 'sso', name: 'Basic SSO (OIDC)', community: true, relay: true, pro: true, proPlus: true }, - - // -- Relay features -- - { - key: 'relay', - name: 'Remote Access (Relay)', - community: false, - relay: true, - pro: true, - proPlus: true, - }, - { - key: 'mobile_app', - name: 'Mobile App Access', - community: false, - relay: true, - pro: true, - proPlus: true, - }, - { - key: 'push_notifications', - name: 'Push Notifications', - community: false, - relay: true, - pro: true, - proPlus: true, - }, - - // -- AI features -- - { - key: 'ai_patrol', - name: 'AI Patrol (Background Health Checks)', - community: true, - relay: true, - pro: true, - proPlus: true, - }, - { - key: 'ai_autofix', - name: 'AI Patrol Auto-Fix', - community: false, - relay: false, - pro: true, - proPlus: true, - }, - { - key: 'ai_alerts', - name: 'AI Alert Analysis', - community: false, - relay: false, - pro: true, - proPlus: true, - }, - { - key: 'kubernetes_ai', - name: 'Kubernetes AI Analysis', - community: false, - relay: false, - pro: true, - proPlus: true, - }, - - // -- Team & compliance -- - { - key: 'rbac', - name: 'Role-Based Access Control (RBAC)', - community: false, - relay: false, - pro: true, - proPlus: true, - }, - { - key: 'audit_logging', - name: 'Audit Logging', - community: false, - relay: false, - pro: true, - proPlus: true, - }, - { - key: 'advanced_sso', - name: 'Advanced SSO (SAML/Multi-Provider)', - community: false, - relay: false, - pro: true, - proPlus: true, - }, - { - key: 'agent_profiles', - name: 'Centralized Agent Profiles', - community: false, - relay: false, - pro: true, - proPlus: true, - }, - { - key: 'advanced_reporting', - name: 'PDF/CSV Reporting', - community: false, - relay: false, - pro: true, - proPlus: true, - }, -]; +import { + SELF_HOSTED_FEATURE_ROWS, + SELF_HOSTED_PLAN_BY_TIER, + type SelfHostedFeatureRow, + type SelfHostedPlanDefinition, + type SelfHostedTierKey, +} from '@/utils/selfHostedPlans'; // --------------------------------------------------------------------------- // Shared sub-components @@ -263,7 +56,11 @@ function TierCtaLabel(props: { children: string }) { ); } -function CheckCell(props: { value: boolean | string; tier: TierColumn; featureKey: string }) { +function CheckCell(props: { + value: boolean | string; + tier: SelfHostedTierKey; + featureKey: string; +}) { if (typeof props.value === 'string') { const label = `${props.tier} tier: ${props.value}`; return ( @@ -314,7 +111,7 @@ function BulletList(props: { items: readonly string[] }) { // --------------------------------------------------------------------------- function TierCard(props: { - tier: (typeof TIERS)[keyof typeof TIERS]; + tier: SelfHostedPlanDefinition; cta: () => import('solid-js').JSX.Element; highlighted?: boolean; badge?: string; @@ -530,16 +327,16 @@ export default function PricingV6() { {/* Tier cards — 4 columns on desktop, 2 on tablet, 1 on mobile */}
- - + + - +
{/* Upsell links */} @@ -595,8 +392,8 @@ export default function PricingV6() { - - {(row) => ( + + {(row: SelfHostedFeatureRow) => (
{row.name}
diff --git a/frontend-modern/src/pages/__tests__/PricingV6.test.tsx b/frontend-modern/src/pages/__tests__/PricingV6.test.tsx new file mode 100644 index 000000000..524b4f1ac --- /dev/null +++ b/frontend-modern/src/pages/__tests__/PricingV6.test.tsx @@ -0,0 +1,75 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@solidjs/testing-library'; +import type { JSX } from 'solid-js'; + +import PricingV6 from '../PricingV6'; +import pricingV6Source from '../PricingV6.tsx?raw'; + +vi.mock('@/components/shared/Card', () => ({ + Card: (props: { children?: JSX.Element }) =>
{props.children}
, +})); + +vi.mock('@/components/shared/Table', () => ({ + Table: (props: { children?: JSX.Element }) => {props.children}
, + TableHeader: (props: { children?: JSX.Element }) => {props.children}, + TableBody: (props: { children?: JSX.Element }) => {props.children}, + TableRow: (props: { children?: JSX.Element }) => {props.children}, + TableHead: (props: { children?: JSX.Element }) => {props.children}, + TableCell: (props: { children?: JSX.Element }) => {props.children}, +})); + +vi.mock('@/components/shared/PageHeader', () => ({ + PageHeader: (props: { title: string; description: string }) => ( +
+

{props.title}

+

{props.description}

+
+ ), +})); + +vi.mock('@/stores/license', () => ({ + entitlements: () => ({ + subscription_state: 'expired', + tier: 'free', + trial_eligible: false, + }), + getUpgradeActionUrlOrFallback: (tier: string) => `/${tier}`, + loadLicenseStatus: vi.fn().mockResolvedValue(undefined), + startProTrial: vi.fn(), +})); + +vi.mock('@/utils/upgradeMetrics', () => ({ + trackPaywallViewed: vi.fn(), +})); + +vi.mock('@/utils/toast', () => ({ + showToast: vi.fn(), +})); + +vi.mock('@/utils/logger', () => ({ + logger: { + debug: vi.fn(), + warn: vi.fn(), + }, +})); + +describe('PricingV6', () => { + it('renders self-hosted plan tiers from the shared pricing model', () => { + render(() => ); + + expect(screen.getAllByText('Community').length).toBeGreaterThan(0); + expect(screen.getAllByText('Relay').length).toBeGreaterThan(0); + expect(screen.getAllByText('Pro').length).toBeGreaterThan(0); + expect(screen.getAllByText('Pro+').length).toBeGreaterThan(0); + expect(screen.getByText('8 monitored systems · 14-day history')).toBeInTheDocument(); + expect(screen.getByText('15 monitored systems')).toBeInTheDocument(); + expect(screen.getByText('50 monitored systems')).toBeInTheDocument(); + expect(screen.getByText('Feature Comparison')).toBeInTheDocument(); + }); + + it('imports the shared self-hosted pricing model instead of redefining it locally', () => { + expect(pricingV6Source).toContain("@/utils/selfHostedPlans"); + expect(pricingV6Source).not.toContain('const TIERS ='); + expect(pricingV6Source).not.toContain('const FEATURE_ROWS'); + }); +}); diff --git a/frontend-modern/src/utils/__tests__/selfHostedPlans.test.ts b/frontend-modern/src/utils/__tests__/selfHostedPlans.test.ts new file mode 100644 index 000000000..f1144fcd6 --- /dev/null +++ b/frontend-modern/src/utils/__tests__/selfHostedPlans.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; + +import { + SELF_HOSTED_FEATURE_ROWS, + SELF_HOSTED_MONITORED_SYSTEMS_DESCRIPTION, + SELF_HOSTED_PLAN_BY_TIER, + SELF_HOSTED_PLAN_DEFINITIONS, +} from '../selfHostedPlans'; + +describe('selfHostedPlans', () => { + it('keeps the monitored-system definition explicit and child-resource inclusive', () => { + expect(SELF_HOSTED_MONITORED_SYSTEMS_DESCRIPTION).toContain( + 'top-level machine or cluster', + ); + expect(SELF_HOSTED_MONITORED_SYSTEMS_DESCRIPTION).toContain('counts once'); + expect(SELF_HOSTED_MONITORED_SYSTEMS_DESCRIPTION).toContain('VMs'); + expect(SELF_HOSTED_MONITORED_SYSTEMS_DESCRIPTION).toContain('services'); + }); + + it('keeps self-hosted plan limits aligned across tier cards and comparison rows', () => { + expect(SELF_HOSTED_PLAN_DEFINITIONS.map((tier) => tier.name)).toEqual([ + 'Community', + 'Relay', + 'Pro', + 'Pro+', + ]); + + expect(SELF_HOSTED_PLAN_BY_TIER.community.monitoredSystems).toBe(5); + expect(SELF_HOSTED_PLAN_BY_TIER.relay.monitoredSystems).toBe(8); + expect(SELF_HOSTED_PLAN_BY_TIER.pro.monitoredSystems).toBe(15); + expect(SELF_HOSTED_PLAN_BY_TIER.proPlus.monitoredSystems).toBe(50); + + const hostsRow = SELF_HOSTED_FEATURE_ROWS.find((row) => row.key === 'hosts'); + expect(hostsRow).toEqual({ + key: 'hosts', + name: 'Monitored Systems', + community: '5', + relay: '8', + pro: '15', + proPlus: '50', + }); + }); +}); diff --git a/frontend-modern/src/utils/selfHostedPlans.ts b/frontend-modern/src/utils/selfHostedPlans.ts new file mode 100644 index 000000000..4674e49f8 --- /dev/null +++ b/frontend-modern/src/utils/selfHostedPlans.ts @@ -0,0 +1,234 @@ +export type SelfHostedTierKey = 'community' | 'relay' | 'pro' | 'proPlus'; + +export interface SelfHostedPlanDefinition { + tier: SelfHostedTierKey; + name: string; + price: string; + subline: string; + monitoredSystems: number; + metricHistoryDays: number; + highlights: readonly string[]; +} + +export interface SelfHostedFeatureRow { + key: string; + name: string; + community: boolean | string; + relay: boolean | string; + pro: boolean | string; + proPlus: boolean | string; +} + +export const SELF_HOSTED_MONITORED_SYSTEMS_DESCRIPTION = + 'A monitored system is a top-level machine or cluster Pulse actively monitors. Each system counts once no matter how Pulse collects it. Child resources like VMs, containers, pods, disks, backups, and services are included.'; + +export const SELF_HOSTED_MONITORED_SYSTEM_LEDGER_DESCRIPTION = + 'Review the monitored systems currently counting toward your Pulse Pro allocation.'; + +export const SELF_HOSTED_PLAN_DEFINITIONS: readonly SelfHostedPlanDefinition[] = [ + { + tier: 'community', + name: 'Community', + price: 'Free forever', + subline: 'Monitor up to 5 systems for free', + monitoredSystems: 5, + metricHistoryDays: 7, + highlights: [ + 'Real-time monitoring', + '7-day metric history', + 'AI Patrol (BYOK + 25 quickstart credits)', + 'Update alerts', + 'Basic SSO (OIDC)', + 'Community support', + ], + }, + { + tier: 'relay', + name: 'Relay', + price: '$4.99/month', + subline: 'or $39/year', + monitoredSystems: 8, + metricHistoryDays: 14, + highlights: [ + 'Everything in Community', + 'Remote access via Relay', + 'Mobile app access', + 'Push notifications', + 'Custom URL (yourlab.pulserelay.pro)', + '8 monitored systems · 14-day history', + ], + }, + { + tier: 'pro', + name: 'Pro', + price: '$8.99/month', + subline: 'or $79/year', + monitoredSystems: 15, + metricHistoryDays: 90, + highlights: [ + 'Everything in Relay', + 'AI Auto-Fix & investigation', + 'AI alert analysis', + 'Kubernetes AI analysis', + '90-day metric history', + 'RBAC, audit logging, SAML SSO', + 'Agent profiles · PDF/CSV reports', + '15 monitored systems', + ], + }, + { + tier: 'proPlus', + name: 'Pro+', + price: '$14.99/month', + subline: 'or $129/year', + monitoredSystems: 50, + metricHistoryDays: 90, + highlights: ['Everything in Pro', '50 monitored systems'], + }, +] as const; + +export const SELF_HOSTED_PLAN_BY_TIER: Record = { + community: SELF_HOSTED_PLAN_DEFINITIONS[0], + relay: SELF_HOSTED_PLAN_DEFINITIONS[1], + pro: SELF_HOSTED_PLAN_DEFINITIONS[2], + proPlus: SELF_HOSTED_PLAN_DEFINITIONS[3], +}; + +export const SELF_HOSTED_FEATURE_ROWS: readonly SelfHostedFeatureRow[] = [ + { + key: 'monitoring', + name: 'Real-time Monitoring', + community: true, + relay: true, + pro: true, + proPlus: true, + }, + { + key: 'hosts', + name: 'Monitored Systems', + community: String(SELF_HOSTED_PLAN_BY_TIER.community.monitoredSystems), + relay: String(SELF_HOSTED_PLAN_BY_TIER.relay.monitoredSystems), + pro: String(SELF_HOSTED_PLAN_BY_TIER.pro.monitoredSystems), + proPlus: String(SELF_HOSTED_PLAN_BY_TIER.proPlus.monitoredSystems), + }, + { + key: 'history', + name: 'Metric History', + community: `${SELF_HOSTED_PLAN_BY_TIER.community.metricHistoryDays} days`, + relay: `${SELF_HOSTED_PLAN_BY_TIER.relay.metricHistoryDays} days`, + pro: `${SELF_HOSTED_PLAN_BY_TIER.pro.metricHistoryDays} days`, + proPlus: `${SELF_HOSTED_PLAN_BY_TIER.proPlus.metricHistoryDays} days`, + }, + { + key: 'update_alerts', + name: 'Update Alerts (Container/Package)', + community: true, + relay: true, + pro: true, + proPlus: true, + }, + { + key: 'sso', + name: 'Basic SSO (OIDC)', + community: true, + relay: true, + pro: true, + proPlus: true, + }, + { + key: 'relay', + name: 'Remote Access (Relay)', + community: false, + relay: true, + pro: true, + proPlus: true, + }, + { + key: 'mobile_app', + name: 'Mobile App Access', + community: false, + relay: true, + pro: true, + proPlus: true, + }, + { + key: 'push_notifications', + name: 'Push Notifications', + community: false, + relay: true, + pro: true, + proPlus: true, + }, + { + key: 'ai_patrol', + name: 'AI Patrol (Background Health Checks)', + community: true, + relay: true, + pro: true, + proPlus: true, + }, + { + key: 'ai_autofix', + name: 'AI Patrol Auto-Fix', + community: false, + relay: false, + pro: true, + proPlus: true, + }, + { + key: 'ai_alerts', + name: 'AI Alert Analysis', + community: false, + relay: false, + pro: true, + proPlus: true, + }, + { + key: 'kubernetes_ai', + name: 'Kubernetes AI Analysis', + community: false, + relay: false, + pro: true, + proPlus: true, + }, + { + key: 'rbac', + name: 'Role-Based Access Control (RBAC)', + community: false, + relay: false, + pro: true, + proPlus: true, + }, + { + key: 'audit_logging', + name: 'Audit Logging', + community: false, + relay: false, + pro: true, + proPlus: true, + }, + { + key: 'advanced_sso', + name: 'Advanced SSO (SAML/Multi-Provider)', + community: false, + relay: false, + pro: true, + proPlus: true, + }, + { + key: 'agent_profiles', + name: 'Centralized Agent Profiles', + community: false, + relay: false, + pro: true, + proPlus: true, + }, + { + key: 'advanced_reporting', + name: 'PDF/CSV Reporting', + community: false, + relay: false, + pro: true, + proPlus: true, + }, +] as const; diff --git a/scripts/release_control/subsystem_lookup_test.py b/scripts/release_control/subsystem_lookup_test.py index 6c808f80b..6b9a95767 100644 --- a/scripts/release_control/subsystem_lookup_test.py +++ b/scripts/release_control/subsystem_lookup_test.py @@ -483,7 +483,10 @@ class SubsystemLookupTest(unittest.TestCase): ) self.assertEqual( match["verification_requirement"]["exact_files"], - ["frontend-modern/src/components/Settings/__tests__/ProLicensePanel.test.tsx"], + [ + "frontend-modern/src/components/Settings/__tests__/MonitoredSystemLedgerPanel.test.tsx", + "frontend-modern/src/components/Settings/__tests__/ProLicensePanel.test.tsx", + ], ) def test_lookup_paths_assigns_pro_license_state_owner_to_cloud_paid(self) -> None: @@ -507,6 +510,60 @@ class SubsystemLookupTest(unittest.TestCase): "pro-license-surface", ) + def test_lookup_paths_assigns_monitored_system_ledger_panel_to_cloud_paid(self) -> None: + result = lookup_paths(["frontend-modern/src/components/Settings/MonitoredSystemLedgerPanel.tsx"]) + 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"], "pro-license-surface") + + def test_lookup_paths_assigns_pricing_v6_to_cloud_paid(self) -> None: + result = lookup_paths(["frontend-modern/src/pages/PricingV6.tsx"]) + 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"], "pricing-pages") + + def test_lookup_paths_assigns_self_hosted_plan_model_to_cloud_paid(self) -> None: + result = lookup_paths(["frontend-modern/src/utils/selfHostedPlans.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"], "commercial-plan-models") + def test_lookup_paths_assigns_relay_settings_state_owner_to_cloud_paid(self) -> None: result = lookup_paths(["frontend-modern/src/components/Settings/useRelaySettingsPanelState.ts"]) self.assertEqual(result["unowned_runtime_files"], [])