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 }) => ,
+ 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"], [])