mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 14:00:29 +00:00
Canonicalize self-hosted pricing presentation
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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
|
||||
</Show>
|
||||
</div>
|
||||
<p class="text-xs text-muted">
|
||||
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}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -152,7 +155,7 @@ export function MonitoredSystemLedgerPanel(props: MonitoredSystemLedgerPanelProp
|
||||
return (
|
||||
<SettingsPanel
|
||||
title="Monitored System Ledger"
|
||||
description="Review the monitored systems currently counting toward your Pulse Pro allocation."
|
||||
description={SELF_HOSTED_MONITORED_SYSTEM_LEDGER_DESCRIPTION}
|
||||
icon={<PulseLogoIcon class="w-5 h-5" />}
|
||||
bodyClass="space-y-4"
|
||||
>
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
<CommercialSection
|
||||
title="Usage"
|
||||
description="Self-hosted plans are sold by monitored systems. Child resources like VMs, containers, pods, disks, and backups do not count separately."
|
||||
description={SELF_HOSTED_MONITORED_SYSTEMS_DESCRIPTION}
|
||||
>
|
||||
<MonitoredSystemLedgerPanel embedded />
|
||||
</CommercialSection>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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 */}
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<TierCard tier={TIERS.community} cta={communityCta} />
|
||||
<TierCard tier={TIERS.relay} cta={relayCta} />
|
||||
<TierCard tier={SELF_HOSTED_PLAN_BY_TIER.community} cta={communityCta} />
|
||||
<TierCard tier={SELF_HOSTED_PLAN_BY_TIER.relay} cta={relayCta} />
|
||||
<TierCard
|
||||
tier={TIERS.pro}
|
||||
tier={SELF_HOSTED_PLAN_BY_TIER.pro}
|
||||
cta={proTrialOrUpgrade}
|
||||
highlighted
|
||||
badge="Most Popular"
|
||||
message={trialMessage()}
|
||||
/>
|
||||
<TierCard tier={TIERS.proPlus} cta={proPlusCta} />
|
||||
<TierCard tier={SELF_HOSTED_PLAN_BY_TIER.proPlus} cta={proPlusCta} />
|
||||
</div>
|
||||
|
||||
{/* Upsell links */}
|
||||
@@ -595,8 +392,8 @@ export default function PricingV6() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody class="bg-surface divide-y divide-border-subtle">
|
||||
<For each={FEATURE_ROWS}>
|
||||
{(row) => (
|
||||
<For each={SELF_HOSTED_FEATURE_ROWS}>
|
||||
{(row: SelfHostedFeatureRow) => (
|
||||
<TableRow>
|
||||
<TableCell class="px-4 py-2">
|
||||
<div class="text-sm font-medium text-base-content">{row.name}</div>
|
||||
|
||||
@@ -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 }) => <div>{props.children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/shared/Table', () => ({
|
||||
Table: (props: { children?: JSX.Element }) => <table>{props.children}</table>,
|
||||
TableHeader: (props: { children?: JSX.Element }) => <thead>{props.children}</thead>,
|
||||
TableBody: (props: { children?: JSX.Element }) => <tbody>{props.children}</tbody>,
|
||||
TableRow: (props: { children?: JSX.Element }) => <tr>{props.children}</tr>,
|
||||
TableHead: (props: { children?: JSX.Element }) => <th>{props.children}</th>,
|
||||
TableCell: (props: { children?: JSX.Element }) => <td>{props.children}</td>,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/shared/PageHeader', () => ({
|
||||
PageHeader: (props: { title: string; description: string }) => (
|
||||
<header>
|
||||
<h1>{props.title}</h1>
|
||||
<p>{props.description}</p>
|
||||
</header>
|
||||
),
|
||||
}));
|
||||
|
||||
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(() => <PricingV6 />);
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<SelfHostedTierKey, SelfHostedPlanDefinition> = {
|
||||
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;
|
||||
@@ -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"], [])
|
||||
|
||||
Reference in New Issue
Block a user