diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index c45740bb3..56ae46ba3 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -205,7 +205,10 @@ management, and fleet control surfaces. grandfathering: lifecycle surfaces may react to the resulting license or entitlements payloads, but they must not cache their own pre-activation host counts, synthesize a second grandfather floor, or treat install-time - fleet inventory as the authority for commercial continuity. + fleet inventory as the authority for commercial continuity. They also must + not depend on a status or entitlements read to seal pending grandfather + continuity, or reinterpret continuity-verification payloads as a real + `0 / limit` monitored-system state. ## Forbidden Paths diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 33405b57a..0b7483505 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -1204,6 +1204,14 @@ first non-nil read-state. If provider-owned supplemental inventories are still between initial wiring and the first canonical store rebuild, the API must keep the grandfather floor uncaptured and expose usage as unavailable rather than sealing continuity against a partial startup graph. +That continuity capture is owned by the shared licensing reconciler rather +than ordinary read handlers. `/api/license/status` and +`/api/license/entitlements` may expose `monitored_system_continuity` +(`plan_limit`, `effective_limit`, optional `grandfathered_floor`, +`capture_pending`, `captured_at`) and limit-level +`current_unavailable_reason`, but those request paths must not seal the +grandfather floor synchronously just because a billing read happened to arrive +after the canonical usage view became available. That same configured-path contract now also has an explicit shared owner for manual auth env files: `internal/api/auth_env_path.go` must remain the only place that derives `.env` from configured runtime paths, and neighboring diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index 012df9890..a95edbcb2 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -792,6 +792,12 @@ supplemental inventories such as TrueNAS or VMware are still between initial provider wiring and the first canonical store rebuild after their baseline poll, cloud-paid continuity must keep usage unavailable and delay floor capture rather than sealing a lower count forever. +That continuity capture is reconciler-owned rather than read-owned. Ordinary +status or entitlement reads may expose pending continuity state, but they must +not persist the grandfather floor directly from the request path once a +migrated installation is running. The owning licensing reconciler may backfill +the floor asynchronously after canonical monitored-system usage becomes +settled. That continuity rule cannot depend on webhook metadata being perfect. The canonical Stripe price-to-plan lookup in `pkg/licensing/features.go` and `pkg/licensing/stripe_subscription.go` must recognize the still-renewing @@ -811,6 +817,12 @@ 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 settings-owned presentation must make monitored-system continuity +explicit when migrated estates are involved: the plan surface must render the +base plan limit, the effective monitored-system limit, any grandfathered +floor, and whether continuity capture is still pending. When monitored-system +usage is unavailable during continuity verification, the shell must present a +verification state rather than implying `0 / limit`. 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 diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index 69b776fba..731a824c5 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -39,15 +39,16 @@ truth for live infrastructure data. 15. `docker-entrypoint.sh` 16. `internal/monitoring/truenas_poller.go` 17. `internal/monitoring/vmware_poller.go` -18. `internal/dockeragent/swarm.go` -19. `internal/monitoring/guest_memory_sources.go` -20. `internal/monitoring/guest_memory_stability.go` -21. `internal/monitoring/monitor_polling_vm.go` -22. `internal/monitoring/monitor_pve_guest_builders.go` -23. `internal/monitoring/monitor_pve_guest_poll.go` -24. `internal/monitoring/guest_disk_stability.go` -25. `internal/monitoring/mock_metrics_history.go` -26. `internal/monitoring/mock_chart_history.go` +18. `internal/monitoring/monitored_system_usage.go` +19. `internal/dockeragent/swarm.go` +20. `internal/monitoring/guest_memory_sources.go` +21. `internal/monitoring/guest_memory_stability.go` +22. `internal/monitoring/monitor_polling_vm.go` +23. `internal/monitoring/monitor_pve_guest_builders.go` +24. `internal/monitoring/monitor_pve_guest_poll.go` +25. `internal/monitoring/guest_disk_stability.go` +26. `internal/monitoring/mock_metrics_history.go` +27. `internal/monitoring/mock_chart_history.go` ## Shared Boundaries @@ -101,6 +102,13 @@ settling: monitoring must fail closed until every active connection in that provider has reached an initial baseline and the canonical monitor store has rebuilt at or after that provider watermark, otherwise billing and upgrade continuity can freeze against a transient startup undercount. +That same monitoring boundary also owns the machine-readable unavailable-state +contract for monitored-system usage. `internal/monitoring/monitored_system_usage.go` +must emit canonical reason codes such as +`monitor_state_unavailable`, `supplemental_inventory_unsettled`, and +`supplemental_inventory_rebuild_pending` when usage cannot yet be resolved, so +commercial surfaces can show verification or recovery state without inventing +their own readiness heuristics or falling back to a fake `0 / limit`. VMware vSphere now also has a locked phase-1 ingestion boundary under this lane. The admitted direction is vCenter-only in phase 1, and monitoring must stay API-first through the diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 058e4c8e3..64e0a8a95 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -175,7 +175,9 @@ querying, and the operator-facing storage health presentation layer. but they must not infer a second capacity floor from protected inventory, backup counts, or recovery-point presence when commercial continuity is already defined by the canonical monitored-system resolver and activation - persistence. + persistence. They also must not rely on billing-status reads to finalize a + pending grandfather floor or collapse continuity-verification payloads + into a real `0 / limit` monitored-system reading. ## Forbidden Paths diff --git a/frontend-modern/src/api/__tests__/license.test.ts b/frontend-modern/src/api/__tests__/license.test.ts index ff881f457..ba3306bd7 100644 --- a/frontend-modern/src/api/__tests__/license.test.ts +++ b/frontend-modern/src/api/__tests__/license.test.ts @@ -49,6 +49,42 @@ describe('LicenseAPI', () => { }); }); + it('preserves monitored-system continuity fields from the entitlement payload', async () => { + vi.mocked(apiFetchJSON).mockResolvedValueOnce({ + tier: 'pro', + subscription_state: 'active', + capabilities: ['relay'], + limits: [ + { + key: 'max_monitored_systems', + limit: 10, + current: 0, + current_available: false, + current_unavailable_reason: 'supplemental_inventory_unsettled', + state: 'ok', + }, + ], + upgrade_reasons: [], + monitored_system_continuity: { + plan_limit: 10, + effective_limit: 10, + capture_pending: true, + }, + }); + + const result = await LicenseAPI.getCommercialEntitlements(); + + expect(result.monitored_system_continuity).toMatchObject({ + plan_limit: 10, + effective_limit: 10, + capture_pending: true, + }); + expect(result.limits[0]).toMatchObject({ + current_available: false, + current_unavailable_reason: 'supplemental_inventory_unsettled', + }); + }); + it('reads commercial posture from the public-safe commercial endpoint', async () => { vi.mocked(apiFetchJSON).mockResolvedValueOnce({ tier: 'pro', diff --git a/frontend-modern/src/api/license.ts b/frontend-modern/src/api/license.ts index 693e01c41..9959f61e2 100644 --- a/frontend-modern/src/api/license.ts +++ b/frontend-modern/src/api/license.ts @@ -13,6 +13,7 @@ export interface LicenseStatus { max_guests?: number; in_grace_period?: boolean; grace_period_end?: string | null; + monitored_system_continuity?: MonitoredSystemContinuityStatus; } export interface EntitlementLimitStatus { @@ -20,6 +21,8 @@ export interface EntitlementLimitStatus { // 0 means unlimited limit: number; current: number; + current_available?: boolean; + current_unavailable_reason?: string; // "ok" | "warning" | "enforced" (string for forward-compat) state: string; } @@ -43,6 +46,14 @@ export interface CommercialMigrationStatus { recommended_action?: string; } +export interface MonitoredSystemContinuityStatus { + plan_limit: number; + grandfathered_floor?: number; + effective_limit: number; + capture_pending: boolean; + captured_at?: number; +} + // Mirrors internal/api/subscription_entitlements.go:RuntimeCapabilitiesPayload export interface LicenseRuntimeCapabilities { capabilities: string[]; @@ -80,6 +91,7 @@ export interface LicenseCommercialEntitlements extends LicenseCommercialPosture in_grace_period?: boolean; grace_period_end?: string; max_history_days?: number; + monitored_system_continuity?: MonitoredSystemContinuityStatus; } export type LicenseEntitlements = LicenseCommercialEntitlements; diff --git a/frontend-modern/src/components/Settings/ProLicensePanel.tsx b/frontend-modern/src/components/Settings/ProLicensePanel.tsx index 66ecb9021..c188f48f9 100644 --- a/frontend-modern/src/components/Settings/ProLicensePanel.tsx +++ b/frontend-modern/src/components/Settings/ProLicensePanel.tsx @@ -67,6 +67,7 @@ export const ProLicensePanel: Component = () => { hasLicenseDetails={state.hasLicenseDetails()} hasPaidFeatures={state.hasPaidFeatures()} loading={state.loading()} + monitoredSystemContinuityNotice={state.monitoredSystemContinuityNotice()} onReload={() => void state.loadPanelData()} purchaseActivationNotice={state.purchaseActivationNotice()} showMonitoredSystemUpgradeArrival={state.showMonitoredSystemUpgradeArrival()} diff --git a/frontend-modern/src/components/Settings/ProLicensePlanSection.tsx b/frontend-modern/src/components/Settings/ProLicensePlanSection.tsx index 9a889958e..df583c977 100644 --- a/frontend-modern/src/components/Settings/ProLicensePlanSection.tsx +++ b/frontend-modern/src/components/Settings/ProLicensePlanSection.tsx @@ -34,6 +34,7 @@ interface ProLicensePlanSectionProps { hasLicenseDetails: boolean; hasPaidFeatures: boolean; loading: boolean; + monitoredSystemContinuityNotice: Notice | null; onReload: () => void; onStartTrial: () => void; purchaseActivationNotice: Notice | null; @@ -132,6 +133,14 @@ export const ProLicensePlanSection: Component = (pro )} + + {(notice) => ( +
+

{notice().title}

+

{notice().body}

+
+ )} +

{trialEndedNotice?.title}

diff --git a/frontend-modern/src/components/Settings/__tests__/ProLicensePanel.test.tsx b/frontend-modern/src/components/Settings/__tests__/ProLicensePanel.test.tsx index 6249f25a6..f95e9906e 100644 --- a/frontend-modern/src/components/Settings/__tests__/ProLicensePanel.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/ProLicensePanel.test.tsx @@ -272,6 +272,93 @@ describe('ProLicensePanel', () => { } }); + it('shows continuity verification while the migrated monitored-system floor is still pending', async () => { + mockEntitlements = { + capabilities: ['relay'], + limits: [ + { + key: 'max_monitored_systems', + limit: 10, + current: 0, + current_available: false, + current_unavailable_reason: 'supplemental_inventory_unsettled', + state: 'ok', + }, + ], + subscription_state: 'active', + upgrade_reasons: [], + tier: 'pro', + plan_version: 'v5_pro_monthly_grandfathered', + licensed_email: 'owner@example.com', + trial_eligible: false, + monitored_system_continuity: { + plan_limit: 10, + effective_limit: 10, + capture_pending: true, + }, + }; + + renderPanel(); + + await waitFor(() => { + expect(screen.getByText('Migration continuity verification pending')).toBeInTheDocument(); + }); + + expect( + screen.getByText(/still collecting the first supplemental inventory baseline/i), + ).toBeInTheDocument(); + expect(screen.getByText('Verifying…')).toBeInTheDocument(); + expect(screen.getByText('Unavailable')).toBeInTheDocument(); + expect(screen.getByText('Plan Monitored System Limit')).toBeInTheDocument(); + expect(screen.getByText('Effective Monitored System Limit')).toBeInTheDocument(); + expect(screen.getByText('Continuity Capture')).toBeInTheDocument(); + expect(screen.getByText('Pending')).toBeInTheDocument(); + expect(screen.queryByText('0 / 10')).not.toBeInTheDocument(); + }); + + it('shows grandfathered monitored-system continuity once the migrated floor is captured', async () => { + mockEntitlements = { + capabilities: ['relay'], + limits: [ + { + key: 'max_monitored_systems', + limit: 23, + current: 23, + current_available: true, + state: 'enforced', + }, + ], + subscription_state: 'active', + upgrade_reasons: [], + tier: 'pro', + plan_version: 'v5_pro_monthly_grandfathered', + licensed_email: 'owner@example.com', + trial_eligible: false, + monitored_system_continuity: { + plan_limit: 10, + grandfathered_floor: 23, + effective_limit: 23, + capture_pending: false, + captured_at: 1_768_000_000, + }, + }; + + renderPanel(); + + await waitFor(() => { + expect(screen.getByText('Grandfathered monitored-system floor')).toBeInTheDocument(); + }); + + expect( + screen.getByText(/keeps an effective monitored-system limit of 23/i), + ).toBeInTheDocument(); + expect(screen.getByText('23 / 23')).toBeInTheDocument(); + expect(screen.getByText('Plan Monitored System Limit')).toBeInTheDocument(); + expect(screen.getByText('Effective Monitored System Limit')).toBeInTheDocument(); + expect(screen.getByText('Grandfathered Floor')).toBeInTheDocument(); + expect(screen.queryByText('Included Monitored Systems')).not.toBeInTheDocument(); + }); + it('renders all capability strings as human-readable labels (no raw snake_case)', async () => { mockEntitlements = { capabilities: [ diff --git a/frontend-modern/src/components/Settings/useProLicensePanelState.ts b/frontend-modern/src/components/Settings/useProLicensePanelState.ts index b631d94cf..3c735b2ed 100644 --- a/frontend-modern/src/components/Settings/useProLicensePanelState.ts +++ b/frontend-modern/src/components/Settings/useProLicensePanelState.ts @@ -15,6 +15,7 @@ import { getCommercialMigrationNotice, getGrandfatheredPriceContinuityNotice, getLicenseFeatureLabel, + getMonitoredSystemContinuityNotice, getPurchaseActivationNotice, getLicenseSubscriptionStatusPresentation, getLicenseTierLabel, @@ -212,13 +213,38 @@ export function useProLicensePanelState() { const limitStatus = (key: string) => entitlements()?.limits?.find((entry) => entry.key === key); - const monitoredSystemUsage = createMemo(() => limitStatus('max_monitored_systems')?.current ?? 0); - const monitoredSystemLimit = createMemo(() => limitStatus('max_monitored_systems')?.limit ?? 0); + const monitoredSystemLimitStatus = createMemo(() => limitStatus('max_monitored_systems')); + const monitoredSystemUsageAvailable = createMemo( + () => monitoredSystemLimitStatus()?.current_available !== false, + ); + const monitoredSystemUsage = createMemo(() => monitoredSystemLimitStatus()?.current ?? 0); + const monitoredSystemLimit = createMemo(() => monitoredSystemLimitStatus()?.limit ?? 0); + const monitoredSystemUsageSummary = createMemo(() => { + if (!monitoredSystemUsageAvailable()) { + return 'Verifying…'; + } + const limit = monitoredSystemLimit(); + if (limit > 0) { + return `${monitoredSystemUsage()} / ${limit}`; + } + return monitoredSystemUsage(); + }); const remainingSystemCapacity = createMemo(() => { + if (!monitoredSystemUsageAvailable()) return 'Unavailable'; const limit = monitoredSystemLimit(); if (limit <= 0) return 'Unlimited'; return Math.max(limit - monitoredSystemUsage(), 0); }); + const monitoredSystemContinuity = createMemo(() => entitlements()?.monitored_system_continuity); + const monitoredSystemContinuityNotice = createMemo(() => + getMonitoredSystemContinuityNotice(monitoredSystemContinuity(), monitoredSystemLimitStatus()), + ); + const continuityCapturedAt = createMemo(() => { + const capturedAt = monitoredSystemContinuity()?.captured_at; + return typeof capturedAt === 'number' && capturedAt > 0 + ? formatUnixDate(capturedAt) + : undefined; + }); const trialEnded = createMemo( () => @@ -257,8 +283,7 @@ export function useProLicensePanelState() { planTerms: formattedPlanTerms() || undefined, expires: displayedExpiry(), daysRemaining: displayedDaysRemaining() ?? 'Unknown', - monitoredSystems: monitoredSystemUsage(), - monitoredSystemLimit: monitoredSystemLimit() > 0 ? monitoredSystemLimit() : undefined, + monitoredSystemsSummary: monitoredSystemUsageSummary(), remainingSystemCapacity: remainingSystemCapacity(), maxMonitoredSystems: typeof limitStatus('max_monitored_systems')?.limit === 'number' && @@ -269,6 +294,8 @@ export function useProLicensePanelState() { typeof limitStatus('max_guests')?.limit === 'number' && limitStatus('max_guests')!.limit > 0 ? limitStatus('max_guests')!.limit : 'Unlimited', + monitoredSystemContinuity: monitoredSystemContinuity() ?? null, + continuityCapturedAt: continuityCapturedAt(), }), ); @@ -327,6 +354,7 @@ export function useProLicensePanelState() { clearing, commercialMigrationNotice, commercialPlanModel, + monitoredSystemContinuityNotice, entitlements, formattedFeatures, grandfatheredPriceNotice, diff --git a/frontend-modern/src/utils/__tests__/licensePresentation.test.ts b/frontend-modern/src/utils/__tests__/licensePresentation.test.ts index 9b453e279..3763ba546 100644 --- a/frontend-modern/src/utils/__tests__/licensePresentation.test.ts +++ b/frontend-modern/src/utils/__tests__/licensePresentation.test.ts @@ -12,6 +12,7 @@ import { getLicenseStatusLoadingState, getLicenseSubscriptionStatusPresentation, getLicenseTierLabel, + getMonitoredSystemContinuityNotice, getNoActiveProLicenseState, getOrganizationBillingLicenseStatusLabel, getInactiveProUpsellNotice, @@ -156,11 +157,51 @@ describe('licensePresentation', () => { title: 'Grandfathered v5 pricing', tone: expect.stringContaining('green'), }); - expect(getGrandfatheredPriceContinuityNotice('v5_pro_annual_grandfathered', 'grace')).toMatchObject({ + expect( + getGrandfatheredPriceContinuityNotice('v5_pro_annual_grandfathered', 'grace'), + ).toMatchObject({ title: 'Grandfathered v5 pricing', }); expect(getGrandfatheredPriceContinuityNotice('v5_lifetime_grandfathered', 'active')).toBeNull(); - expect(getGrandfatheredPriceContinuityNotice('v5_pro_monthly_grandfathered', 'expired')).toBeNull(); + expect( + getGrandfatheredPriceContinuityNotice('v5_pro_monthly_grandfathered', 'expired'), + ).toBeNull(); + }); + + it('returns monitored-system continuity notices for pending verification and captured grandfathering', () => { + expect( + getMonitoredSystemContinuityNotice( + { + plan_limit: 10, + effective_limit: 10, + capture_pending: true, + }, + { + current_available: false, + current_unavailable_reason: 'supplemental_inventory_unsettled', + }, + ), + ).toMatchObject({ + title: 'Migration continuity verification pending', + tone: expect.stringContaining('amber'), + }); + expect( + getMonitoredSystemContinuityNotice( + { + plan_limit: 10, + grandfathered_floor: 23, + effective_limit: 23, + capture_pending: false, + captured_at: 123, + }, + { + current_available: true, + }, + ), + ).toMatchObject({ + title: 'Grandfathered monitored-system floor', + tone: expect.stringContaining('green'), + }); }); it('returns canonical trial activation notices', () => { @@ -221,12 +262,12 @@ describe('licensePresentation', () => { } as never), ).toContain('Trial (ends'); expect(getBillingAdminTrialStatus({ subscription_state: 'active' } as never)).toBe('No trial'); - expect(getBillingAdminOrganizationBadges({ soft_deleted: true, suspended: true } as never)).toMatchObject([ - { label: 'soft-deleted' }, - ]); - expect(getBillingAdminOrganizationBadges({ soft_deleted: false, suspended: true } as never)).toMatchObject([ - { label: 'suspended' }, - ]); + expect( + getBillingAdminOrganizationBadges({ soft_deleted: true, suspended: true } as never), + ).toMatchObject([{ label: 'soft-deleted' }]); + expect( + getBillingAdminOrganizationBadges({ soft_deleted: false, suspended: true } as never), + ).toMatchObject([{ label: 'suspended' }]); expect(getBillingAdminStateUpdateSuccessMessage('active')).toBe( 'Organization billing activated', ); diff --git a/frontend-modern/src/utils/commercialBillingModel.ts b/frontend-modern/src/utils/commercialBillingModel.ts index cd6e8ebd2..368518f29 100644 --- a/frontend-modern/src/utils/commercialBillingModel.ts +++ b/frontend-modern/src/utils/commercialBillingModel.ts @@ -1,4 +1,5 @@ import type { LicenseStatus } from '@/api/license'; +import type { MonitoredSystemContinuityStatus } from '@/api/license'; export interface CommercialStatValue { label: string; @@ -28,15 +29,19 @@ export interface SelfHostedCommercialModelInput { planTerms?: string; expires: string; daysRemaining: string | number; - monitoredSystems: number; - monitoredSystemLimit?: number; + monitoredSystemsSummary: string | number; remainingSystemCapacity: string | number; maxMonitoredSystems: string | number; maxGuests: string | number; + monitoredSystemContinuity?: MonitoredSystemContinuityStatus | null; + continuityCapturedAt?: string; } export interface HostedCommercialModelInput { - status?: Pick | null; + status?: Pick< + LicenseStatus, + 'email' | 'is_lifetime' | 'expires_at' | 'max_monitored_systems' | 'max_guests' + > | null; tierLabel: string; licenseStatusLabel: string; organizationCount: number; @@ -55,10 +60,7 @@ export const buildSelfHostedCommercialPlanModel = ( summary: [ { label: 'Monitored Systems', - value: - typeof input.monitoredSystemLimit === 'number' && input.monitoredSystemLimit > 0 - ? `${input.monitoredSystems} / ${input.monitoredSystemLimit}` - : input.monitoredSystems, + value: input.monitoredSystemsSummary, }, { label: 'Remaining System Capacity', @@ -94,10 +96,44 @@ export const buildSelfHostedCommercialPlanModel = ( label: 'Days Remaining', value: input.daysRemaining, }, - { - label: 'Included Monitored Systems', - value: input.maxMonitoredSystems, - }, + ...(input.monitoredSystemContinuity + ? [ + { + label: 'Plan Monitored System Limit', + value: + input.monitoredSystemContinuity.plan_limit > 0 + ? input.monitoredSystemContinuity.plan_limit + : 'Unlimited', + }, + { + label: 'Effective Monitored System Limit', + value: + input.monitoredSystemContinuity.effective_limit > 0 + ? input.monitoredSystemContinuity.effective_limit + : 'Unlimited', + }, + ...(typeof input.monitoredSystemContinuity.grandfathered_floor === 'number' && + input.monitoredSystemContinuity.grandfathered_floor > 0 + ? [ + { + label: 'Grandfathered Floor', + value: input.monitoredSystemContinuity.grandfathered_floor, + }, + ] + : []), + { + label: 'Continuity Capture', + value: input.monitoredSystemContinuity.capture_pending + ? 'Pending' + : input.continuityCapturedAt || 'Captured', + }, + ] + : [ + { + label: 'Included Monitored Systems', + value: input.maxMonitoredSystems, + }, + ]), { label: 'Max Guests', value: input.maxGuests, diff --git a/frontend-modern/src/utils/licensePresentation.ts b/frontend-modern/src/utils/licensePresentation.ts index 491ada3e4..52a417c6c 100644 --- a/frontend-modern/src/utils/licensePresentation.ts +++ b/frontend-modern/src/utils/licensePresentation.ts @@ -1,5 +1,10 @@ import type { BillingState, HostedOrganizationSummary } from '@/api/billingAdmin'; -import type { CommercialMigrationStatus, LicenseStatus } from '@/api/license'; +import type { + CommercialMigrationStatus, + EntitlementLimitStatus, + LicenseStatus, + MonitoredSystemContinuityStatus, +} from '@/api/license'; import { CLOUD_PLAN_LABELS } from '@/utils/cloudPlans'; import { titleCaseDelimitedLabel } from '@/utils/textPresentation'; @@ -154,6 +159,51 @@ export const getGrandfatheredPriceContinuityNotice = ( }; }; +const monitoredSystemUnavailableReasonBody = (reason?: string): string => { + switch ((reason || '').trim().toLowerCase()) { + case 'supplemental_inventory_unsettled': + return 'Pulse is still collecting the first supplemental inventory baseline for this installation. Current monitored-system usage will appear after that baseline completes.'; + case 'supplemental_inventory_rebuild_pending': + return 'Pulse has collected supplemental inventory, but it is still rebuilding the canonical monitored-system ledger. Current usage will appear when that rebuild finishes.'; + case 'monitor_state_unavailable': + default: + return 'Pulse cannot currently verify monitored-system usage for this installation. Refresh after the monitoring runtime settles.'; + } +}; + +export const getMonitoredSystemContinuityNotice = ( + continuity?: MonitoredSystemContinuityStatus | null, + limit?: Pick | null, +): LicenseInlineNotice | null => { + const currentAvailable = limit?.current_available !== false; + if (!currentAvailable) { + const title = + continuity?.capture_pending === true + ? 'Migration continuity verification pending' + : 'Monitored-system usage unavailable'; + return { + tone: 'border-amber-200 dark:border-amber-900 bg-amber-50 dark:bg-amber-900 text-amber-900 dark:text-amber-100', + title, + body: monitoredSystemUnavailableReasonBody(limit?.current_unavailable_reason), + }; + } + + if ( + continuity && + typeof continuity.grandfathered_floor === 'number' && + continuity.grandfathered_floor > 0 && + continuity.effective_limit > continuity.plan_limit + ) { + return { + tone: 'border-green-200 dark:border-green-900 bg-green-50 dark:bg-green-900 text-green-900 dark:text-green-100', + title: 'Grandfathered monitored-system floor', + body: `This migrated v5 installation keeps an effective monitored-system limit of ${continuity.effective_limit}. The current plan includes ${continuity.plan_limit}, and the observed legacy estate was grandfathered at ${continuity.grandfathered_floor}.`, + }; + } + + return null; +}; + export const getCommercialMigrationActionText = (action?: string): string => { switch (action) { case 'retry_activation': diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 10fea4c72..79a5ea068 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -4824,7 +4824,14 @@ func TestContract_EntitlementPayloadMonitoredSystemUsageUnavailableJSONSnapshot( Tier: pkglicensing.TierPro, Features: append([]string(nil), pkglicensing.TierFeatures[pkglicensing.TierPro]...), MaxMonitoredSystems: 15, - }, string(pkglicensing.SubStateActive), entitlementUsageSnapshot{}, nil) + MonitoredSystemContinuity: &pkglicensing.MonitoredSystemContinuityStatus{ + PlanLimit: 15, + EffectiveLimit: 15, + CapturePending: true, + }, + }, string(pkglicensing.SubStateActive), entitlementUsageSnapshot{ + MonitoredSystemsUnavailableReason: "supplemental_inventory_unsettled", + }, nil) got, err := json.Marshal(payload) if err != nil { @@ -4833,7 +4840,7 @@ func TestContract_EntitlementPayloadMonitoredSystemUsageUnavailableJSONSnapshot( const want = `{ "capabilities":["update_alerts","sso","ai_patrol","relay","mobile_app","push_notifications","long_term_metrics","ai_alerts","ai_autofix","kubernetes_ai","agent_profiles","advanced_sso","rbac","audit_logging","advanced_reporting"], - "limits":[{"key":"max_monitored_systems","limit":15,"current":0,"current_available":false,"state":"ok"}], + "limits":[{"key":"max_monitored_systems","limit":15,"current":0,"current_available":false,"current_unavailable_reason":"supplemental_inventory_unsettled","state":"ok"}], "subscription_state":"active", "upgrade_reasons":[], "tier":"pro", @@ -4844,7 +4851,8 @@ func TestContract_EntitlementPayloadMonitoredSystemUsageUnavailableJSONSnapshot( "trial_eligible":false, "max_history_days":90, "legacy_connections":{"proxmox_nodes":0,"docker_hosts":0,"kubernetes_clusters":0}, - "has_migration_gap":false + "has_migration_gap":false, + "monitored_system_continuity":{"plan_limit":15,"effective_limit":15,"capture_pending":true} }` assertJSONSnapshot(t, got, want) @@ -4973,42 +4981,64 @@ func TestContract_LegacyMigrationGrandfatherFloorJSONSnapshot(t *testing.T) { if err := json.Unmarshal(entRec.Body.Bytes(), &payload); err != nil { t.Fatalf("decode entitlements: %v", err) } + statusContinuity := status.MonitoredSystemContinuity + if statusContinuity != nil { + copied := *statusContinuity + if copied.CapturedAt > 0 { + copied.CapturedAt = 123 + } + statusContinuity = &copied + } + payloadContinuity := payload.MonitoredSystemContinuity + if payloadContinuity != nil { + copied := *payloadContinuity + if copied.CapturedAt > 0 { + copied.CapturedAt = 123 + } + payloadContinuity = &copied + } got, err := json.Marshal(struct { Status struct { - Tier pkglicensing.Tier `json:"tier"` - PlanVersion string `json:"plan_version"` - MaxMonitoredSystems int `json:"max_monitored_systems"` - Valid bool `json:"valid"` + Tier pkglicensing.Tier `json:"tier"` + PlanVersion string `json:"plan_version"` + MaxMonitoredSystems int `json:"max_monitored_systems"` + Valid bool `json:"valid"` + MonitoredSystemContinuity *pkglicensing.MonitoredSystemContinuityStatus `json:"monitored_system_continuity,omitempty"` } `json:"status"` Entitlements struct { - Tier string `json:"tier"` - PlanVersion string `json:"plan_version"` - SubscriptionState string `json:"subscription_state"` - Limits []pkglicensing.LimitStatus `json:"limits"` + Tier string `json:"tier"` + PlanVersion string `json:"plan_version"` + SubscriptionState string `json:"subscription_state"` + Limits []pkglicensing.LimitStatus `json:"limits"` + MonitoredSystemContinuity *pkglicensing.MonitoredSystemContinuityStatus `json:"monitored_system_continuity,omitempty"` } `json:"entitlements"` }{ Status: struct { - Tier pkglicensing.Tier `json:"tier"` - PlanVersion string `json:"plan_version"` - MaxMonitoredSystems int `json:"max_monitored_systems"` - Valid bool `json:"valid"` + Tier pkglicensing.Tier `json:"tier"` + PlanVersion string `json:"plan_version"` + MaxMonitoredSystems int `json:"max_monitored_systems"` + Valid bool `json:"valid"` + MonitoredSystemContinuity *pkglicensing.MonitoredSystemContinuityStatus `json:"monitored_system_continuity,omitempty"` }{ - Tier: status.Tier, - PlanVersion: status.PlanVersion, - MaxMonitoredSystems: status.MaxMonitoredSystems, - Valid: status.Valid, + Tier: status.Tier, + PlanVersion: status.PlanVersion, + MaxMonitoredSystems: status.MaxMonitoredSystems, + Valid: status.Valid, + MonitoredSystemContinuity: statusContinuity, }, Entitlements: struct { - Tier string `json:"tier"` - PlanVersion string `json:"plan_version"` - SubscriptionState string `json:"subscription_state"` - Limits []pkglicensing.LimitStatus `json:"limits"` + Tier string `json:"tier"` + PlanVersion string `json:"plan_version"` + SubscriptionState string `json:"subscription_state"` + Limits []pkglicensing.LimitStatus `json:"limits"` + MonitoredSystemContinuity *pkglicensing.MonitoredSystemContinuityStatus `json:"monitored_system_continuity,omitempty"` }{ - Tier: payload.Tier, - PlanVersion: payload.PlanVersion, - SubscriptionState: payload.SubscriptionState, - Limits: payload.Limits, + Tier: payload.Tier, + PlanVersion: payload.PlanVersion, + SubscriptionState: payload.SubscriptionState, + Limits: payload.Limits, + MonitoredSystemContinuity: payloadContinuity, }, }) if err != nil { @@ -5020,13 +5050,15 @@ func TestContract_LegacyMigrationGrandfatherFloorJSONSnapshot(t *testing.T) { "tier":"pro", "plan_version":"v5_pro_monthly_grandfathered", "max_monitored_systems":23, - "valid":true + "valid":true, + "monitored_system_continuity":{"plan_limit":10,"grandfathered_floor":23,"effective_limit":23,"capture_pending":false,"captured_at":123} }, "entitlements":{ "tier":"pro", "plan_version":"v5_pro_monthly_grandfathered", "subscription_state":"active", - "limits":[{"key":"max_monitored_systems","limit":23,"current":23,"current_available":true,"state":"enforced"}] + "limits":[{"key":"max_monitored_systems","limit":23,"current":23,"current_available":true,"state":"enforced"}], + "monitored_system_continuity":{"plan_limit":10,"grandfathered_floor":23,"effective_limit":23,"capture_pending":false,"captured_at":123} } }` diff --git a/internal/api/legacy_grandfather_reconcile.go b/internal/api/legacy_grandfather_reconcile.go new file mode 100644 index 000000000..35412343f --- /dev/null +++ b/internal/api/legacy_grandfather_reconcile.go @@ -0,0 +1,180 @@ +package api + +import ( + "context" + "sync" + "time" + + "github.com/rs/zerolog/log" +) + +const legacyGrandfatherReconcileInterval = 5 * time.Second + +type legacyGrandfatherReconcileLoop struct { + mu sync.Mutex + cancel context.CancelFunc + wg sync.WaitGroup + running bool +} + +func (l *legacyGrandfatherReconcileLoop) isRunning() bool { + if l == nil { + return false + } + l.mu.Lock() + defer l.mu.Unlock() + return l.running +} + +func (h *LicenseHandlers) legacyGrandfatherReconcileLoop(orgID string) *legacyGrandfatherReconcileLoop { + if h == nil { + return nil + } + if loop, ok := h.legacyGrandfatherReconcile.Load(orgID); ok { + if typed, ok := loop.(*legacyGrandfatherReconcileLoop); ok { + return typed + } + } + loop := &legacyGrandfatherReconcileLoop{} + actual, _ := h.legacyGrandfatherReconcile.LoadOrStore(orgID, loop) + if typed, ok := actual.(*legacyGrandfatherReconcileLoop); ok { + return typed + } + return loop +} + +func (h *LicenseHandlers) ensureLegacyGrandfatherReconcileLoop(orgID string, service *licenseService) { + if h == nil || service == nil { + return + } + orgID = normalizeHostedEntitlementOrgID(orgID) + if !service.NeedsLegacyMonitoredSystemCapture() { + h.stopLegacyGrandfatherReconcileLoop(orgID) + return + } + + loop := h.legacyGrandfatherReconcileLoop(orgID) + if loop == nil { + return + } + if loop.isRunning() { + return + } + + loop.mu.Lock() + defer loop.mu.Unlock() + if loop.running { + return + } + + ctx, cancel := context.WithCancel(context.Background()) + loop.cancel = cancel + loop.running = true + loop.wg.Add(1) + go func() { + defer func() { + loop.mu.Lock() + loop.running = false + loop.mu.Unlock() + h.legacyGrandfatherReconcile.Delete(orgID) + loop.wg.Done() + }() + h.runLegacyGrandfatherReconcileLoop(ctx, orgID, service) + }() +} + +func (h *LicenseHandlers) stopLegacyGrandfatherReconcileLoop(orgID string) { + if h == nil { + return + } + orgID = normalizeHostedEntitlementOrgID(orgID) + value, ok := h.legacyGrandfatherReconcile.Load(orgID) + if !ok { + return + } + loop, ok := value.(*legacyGrandfatherReconcileLoop) + if !ok || loop == nil { + h.legacyGrandfatherReconcile.Delete(orgID) + return + } + + loop.mu.Lock() + if !loop.running { + loop.mu.Unlock() + h.legacyGrandfatherReconcile.Delete(orgID) + return + } + cancel := loop.cancel + loop.running = false + loop.mu.Unlock() + + if cancel != nil { + cancel() + } + loop.wg.Wait() + h.legacyGrandfatherReconcile.Delete(orgID) +} + +func (h *LicenseHandlers) runLegacyGrandfatherReconcileLoop( + ctx context.Context, + orgID string, + service *licenseService, +) { + ticker := time.NewTicker(legacyGrandfatherReconcileInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + reconcileCtx := context.WithValue(context.Background(), OrgIDContextKey, orgID) + h.reconcileLegacyMigrationGrandfatherFloor(reconcileCtx, orgID, service) + if !service.NeedsLegacyMonitoredSystemCapture() { + return + } + } + } +} + +func (h *LicenseHandlers) reconcileLegacyMigrationGrandfatherFloor( + ctx context.Context, + orgID string, + service *licenseService, +) { + if h == nil || service == nil { + return + } + + orgID = normalizeHostedEntitlementOrgID(orgID) + if !service.NeedsLegacyMonitoredSystemCapture() { + return + } + + if ctx == nil { + ctx = context.Background() + } + if resolved := GetOrgID(ctx); resolved == "" { + ctx = context.WithValue(ctx, OrgIDContextKey, orgID) + } + + count, ok := h.canonicalMonitoredSystemGrandfatherFloor(ctx) + if !ok { + h.ensureLegacyGrandfatherReconcileLoop(orgID, service) + return + } + + if err := service.CaptureLegacyMonitoredSystemGrandfatherFloor(count); err != nil { + log.Warn(). + Str("org_id", orgID). + Int("monitored_systems", count). + Err(err). + Msg("Failed to persist migrated monitored-system grandfather floor") + h.ensureLegacyGrandfatherReconcileLoop(orgID, service) + return + } + + if service.NeedsLegacyMonitoredSystemCapture() { + h.ensureLegacyGrandfatherReconcileLoop(orgID, service) + } +} diff --git a/internal/api/licensing_handlers.go b/internal/api/licensing_handlers.go index 70fbc2d0e..c78c8a486 100644 --- a/internal/api/licensing_handlers.go +++ b/internal/api/licensing_handlers.go @@ -53,20 +53,21 @@ func revocationFeedToken() string { // LicenseHandlers handles license management API endpoints. type LicenseHandlers struct { - mtPersistence *config.MultiTenantPersistence - hostedMode bool - cfg *config.Config - services sync.Map // map[string]*licenseService - trialLimiter *RateLimiter - trialReplay *jtiReplayStore - trialInitiations *trialSignupInitiationStore - purchaseHandoffs *purchaseCheckoutHandoffStore - trialRedeemer func(token string) (*hostedTrialRedemptionResponse, error) - monitor *monitoring.Monitor - mtMonitor *monitoring.MultiTenantMonitor - conversionRecorder *conversionRecorder - conversionHealth *conversionPipelineHealth - hostedLeaseRefresh sync.Map // map[string]*hostedEntitlementRefreshLoop + mtPersistence *config.MultiTenantPersistence + hostedMode bool + cfg *config.Config + services sync.Map // map[string]*licenseService + trialLimiter *RateLimiter + trialReplay *jtiReplayStore + trialInitiations *trialSignupInitiationStore + purchaseHandoffs *purchaseCheckoutHandoffStore + trialRedeemer func(token string) (*hostedTrialRedemptionResponse, error) + monitor *monitoring.Monitor + mtMonitor *monitoring.MultiTenantMonitor + conversionRecorder *conversionRecorder + conversionHealth *conversionPipelineHealth + hostedLeaseRefresh sync.Map // map[string]*hostedEntitlementRefreshLoop + legacyGrandfatherReconcile sync.Map // map[string]*legacyGrandfatherReconcileLoop } // NewLicenseHandlers creates a new license handlers instance. @@ -165,6 +166,12 @@ func (h *LicenseHandlers) StopAllBackgroundLoops() { } return true }) + h.legacyGrandfatherReconcile.Range(func(key, value any) bool { + if orgID, ok := key.(string); ok { + h.stopLegacyGrandfatherReconcileLoop(orgID) + } + return true + }) h.services.Range(func(_, value any) bool { if svc, ok := value.(*licenseService); ok { svc.StopGrantRefresh() @@ -515,7 +522,7 @@ func (h *LicenseHandlers) getTenantComponents(ctx context.Context) (*licenseServ if err := h.ensureEvaluatorForOrg(orgID, svc); err != nil { log.Warn().Str("org_id", orgID).Err(err).Msg("Failed to refresh license evaluator for org") } - h.ensureLegacyMigrationGrandfatherFloor(ctx, svc) + h.ensureLegacyGrandfatherReconcileLoop(orgID, svc) h.ensureHostedEntitlementRefreshForOrg(orgID, svc) // We need persistence too, reconstruct it or cache it? // Reconstructing persistence is cheap (just a struct with path). @@ -553,7 +560,7 @@ func (h *LicenseHandlers) getTenantComponents(ctx context.Context) (*licenseServ if err := service.RestoreActivation(activationState); err != nil { log.Warn().Str("org_id", orgID).Err(err).Msg("Failed to restore activation") } else { - h.ensureLegacyMigrationGrandfatherFloor(ctx, service) + h.ensureLegacyGrandfatherReconcileLoop(orgID, service) if clearErr := h.setCommercialMigrationState(orgID, nil); clearErr != nil { log.Warn().Str("org_id", orgID).Err(clearErr).Msg("Failed to clear commercial migration state after activation restore") } @@ -583,7 +590,7 @@ func (h *LicenseHandlers) getTenantComponents(ctx context.Context) (*licenseServ } log.Warn().Str("org_id", orgID).Err(err).Msg("Failed to auto-exchange persisted legacy license") } else if service.IsActivated() { - h.ensureLegacyMigrationGrandfatherFloor(ctx, service) + h.reconcileLegacyMigrationGrandfatherFloor(ctx, orgID, service) if clearErr := h.setCommercialMigrationState(orgID, nil); clearErr != nil { log.Warn().Str("org_id", orgID).Err(clearErr).Msg("Failed to clear commercial migration state after successful auto-exchange") } @@ -608,11 +615,13 @@ func (h *LicenseHandlers) getTenantComponents(ctx context.Context) (*licenseServ service.StopGrantRefresh() // stop our orphaned refresh loop if started service.StopRevocationPoll() // stop our orphaned revocation poller if started svc := actual.(*licenseService) + h.ensureLegacyGrandfatherReconcileLoop(orgID, svc) h.ensureHostedEntitlementRefreshForOrg(orgID, svc) p, pErr := h.getPersistenceForOrg(orgID) return svc, p, pErr } + h.ensureLegacyGrandfatherReconcileLoop(orgID, service) h.ensureHostedEntitlementRefreshForOrg(orgID, service) return service, persistence, nil @@ -629,29 +638,6 @@ func (h *LicenseHandlers) canonicalMonitoredSystemGrandfatherFloor(ctx context.C return int(usage.MonitoredSystems), true } -func (h *LicenseHandlers) ensureLegacyMigrationGrandfatherFloor(ctx context.Context, service *licenseService) { - if h == nil || service == nil { - return - } - - count, ok := h.canonicalMonitoredSystemGrandfatherFloor(ctx) - if !ok { - return - } - - if err := service.CaptureLegacyMonitoredSystemGrandfatherFloor(count); err != nil { - orgID := GetOrgID(ctx) - if orgID == "" { - orgID = "default" - } - log.Warn(). - Str("org_id", orgID). - Int("monitored_systems", count). - Err(err). - Msg("Failed to persist migrated monitored-system grandfather floor") - } -} - func (h *LicenseHandlers) ensureEvaluatorForOrg(orgID string, service *licenseService) error { if h == nil || service == nil || h.mtPersistence == nil { return nil @@ -1350,7 +1336,7 @@ func (h *LicenseHandlers) activateLicenseKey(ctx context.Context, licenseKey str if service.IsActivated() { if migratedLegacyKey { - h.ensureLegacyMigrationGrandfatherFloor(ctx, service) + h.reconcileLegacyMigrationGrandfatherFloor(ctx, orgID, service) } h.stopHostedEntitlementRefreshLoop(orgID) if clearErr := h.setCommercialMigrationState(orgID, nil); clearErr != nil { @@ -1813,6 +1799,7 @@ func (h *LicenseHandlers) HandleClearLicense(w http.ResponseWriter, r *http.Requ // Preserve trial_started_at and free-tier bookkeeping so the effective trial // ends immediately but trial reuse remains blocked. if h != nil && h.mtPersistence != nil { + h.stopLegacyGrandfatherReconcileLoop(orgID) h.stopHostedEntitlementRefreshLoop(orgID) billingStore := config.NewFileBillingStore(h.mtPersistence.BaseDataDir()) existing, err := billingStore.GetBillingState(orgID) diff --git a/internal/api/licensing_handlers_auto_migrate_test.go b/internal/api/licensing_handlers_auto_migrate_test.go index f030f4789..caa6fde92 100644 --- a/internal/api/licensing_handlers_auto_migrate_test.go +++ b/internal/api/licensing_handlers_auto_migrate_test.go @@ -652,15 +652,60 @@ func TestGetTenantComponents_DelaysGrandfatherFloorUntilSupplementalInventorySet handlers := NewLicenseHandlers(mtp, false) handlers.SetMonitors(monitor, nil) + t.Cleanup(handlers.StopAllBackgroundLoops) ctx := context.WithValue(context.Background(), OrgIDContextKey, "default") - svc := handlers.Service(ctx) - if svc == nil { - t.Fatal("expected non-nil service") + readStatus := func() pkglicensing.LicenseStatus { + req := httptest.NewRequest(http.MethodGet, "/api/license/status", nil).WithContext(ctx) + rec := httptest.NewRecorder() + handlers.HandleLicenseStatus(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("license status=%d, want %d: %s", rec.Code, http.StatusOK, rec.Body.String()) + } + var status pkglicensing.LicenseStatus + if err := json.Unmarshal(rec.Body.Bytes(), &status); err != nil { + t.Fatalf("decode license status: %v", err) + } + return status } - if got := svc.Status().MaxMonitoredSystems; got != 10 { + readEntitlements := func() EntitlementPayload { + req := httptest.NewRequest(http.MethodGet, "/api/license/entitlements", nil).WithContext(ctx) + rec := httptest.NewRecorder() + handlers.HandleEntitlements(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("license entitlements=%d, want %d: %s", rec.Code, http.StatusOK, rec.Body.String()) + } + var payload EntitlementPayload + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode entitlements: %v", err) + } + return payload + } + + status := readStatus() + if got := status.MaxMonitoredSystems; got != 10 { t.Fatalf("initial status.MaxMonitoredSystems=%d, want 10 while supplemental inventory is unsettled", got) } + if status.MonitoredSystemContinuity == nil || !status.MonitoredSystemContinuity.CapturePending { + t.Fatalf("expected pending continuity in status payload, got %+v", status.MonitoredSystemContinuity) + } + if status.MonitoredSystemContinuity.PlanLimit != 10 || status.MonitoredSystemContinuity.EffectiveLimit != 10 { + t.Fatalf("unexpected pending continuity status payload: %+v", status.MonitoredSystemContinuity) + } + + payload := readEntitlements() + if payload.MonitoredSystemContinuity == nil || !payload.MonitoredSystemContinuity.CapturePending { + t.Fatalf("expected pending continuity in entitlements payload, got %+v", payload.MonitoredSystemContinuity) + } + if len(payload.Limits) != 1 { + t.Fatalf("expected one monitored-system limit, got %+v", payload.Limits) + } + if payload.Limits[0].CurrentAvailable == nil || *payload.Limits[0].CurrentAvailable { + t.Fatalf("expected unavailable monitored-system usage while supplemental inventory is unsettled, got %+v", payload.Limits[0]) + } + if payload.Limits[0].CurrentUnavailableReason != "supplemental_inventory_unsettled" { + t.Fatalf("CurrentUnavailableReason=%q, want %q", payload.Limits[0].CurrentUnavailableReason, "supplemental_inventory_unsettled") + } activationState, err := persistence.LoadActivationState() if err != nil { @@ -674,32 +719,55 @@ func TestGetTenantComponents_DelaysGrandfatherFloorUntilSupplementalInventorySet } provider.settle(23) - svc = handlers.Service(ctx) - if got := svc.Status().MaxMonitoredSystems; got != 10 { + status = readStatus() + if got := status.MaxMonitoredSystems; got != 10 { t.Fatalf("stale supplemental store should not capture grandfather floor yet, got %d", got) } monitor.SetSupplementalRecordsProvider(unifiedresources.SourceTrueNAS, provider) - svc = handlers.Service(ctx) - if got := svc.Status().MaxMonitoredSystems; got != 23 { - t.Fatalf("status.MaxMonitoredSystems=%d, want 23 after supplemental store rebuild", got) + status = readStatus() + if got := status.MaxMonitoredSystems; got != 10 { + t.Fatalf("status read should not capture grandfather floor directly after canonical store rebuild, got %d", got) } - activationState, err = persistence.LoadActivationState() - if err != nil { - t.Fatalf("reload activation state: %v", err) - } - if activationState == nil { - t.Fatal("expected activation state after grandfather capture") - } - if activationState.Continuity.GrandfatheredMaxMonitoredSystems != 23 { - t.Fatalf("GrandfatheredMaxMonitoredSystems=%d, want 23", activationState.Continuity.GrandfatheredMaxMonitoredSystems) - } - if activationState.Continuity.GrandfatheredMonitoredSystemsCapturedAt == 0 { - t.Fatal("expected grandfather capture timestamp after supplemental store rebuild") + deadline := time.Now().Add(8 * time.Second) + for { + activationState, err = persistence.LoadActivationState() + if err != nil { + t.Fatalf("reload activation state: %v", err) + } + if activationState != nil && + activationState.Continuity.GrandfatheredMaxMonitoredSystems == 23 && + activationState.Continuity.GrandfatheredMonitoredSystemsCapturedAt != 0 { + break + } + if time.Now().After(deadline) { + t.Fatalf("expected async grandfather capture after canonical store rebuild, last activation state=%+v", activationState) + } + time.Sleep(100 * time.Millisecond) } - handlers.StopAllBackgroundLoops() + status = readStatus() + if got := status.MaxMonitoredSystems; got != 23 { + t.Fatalf("status.MaxMonitoredSystems=%d, want 23 after async grandfather capture", got) + } + if status.MonitoredSystemContinuity == nil || status.MonitoredSystemContinuity.CapturePending { + t.Fatalf("expected settled continuity in status payload after async capture, got %+v", status.MonitoredSystemContinuity) + } + if status.MonitoredSystemContinuity.GrandfatheredFloor != 23 || status.MonitoredSystemContinuity.EffectiveLimit != 23 { + t.Fatalf("unexpected settled continuity status payload: %+v", status.MonitoredSystemContinuity) + } + + payload = readEntitlements() + if payload.MonitoredSystemContinuity == nil || payload.MonitoredSystemContinuity.CapturePending { + t.Fatalf("expected settled continuity in entitlements payload after async capture, got %+v", payload.MonitoredSystemContinuity) + } + if len(payload.Limits) != 1 || payload.Limits[0].Current != 23 { + t.Fatalf("expected settled monitored-system usage in entitlements payload, got %+v", payload.Limits) + } + if payload.Limits[0].CurrentAvailable == nil || !*payload.Limits[0].CurrentAvailable { + t.Fatalf("expected available monitored-system usage after async capture, got %+v", payload.Limits[0]) + } } func buildGrandfatherFloorMonitor(count int) *monitoring.Monitor { diff --git a/internal/api/subscription_entitlements.go b/internal/api/subscription_entitlements.go index 7fca7c833..bfa346c3c 100644 --- a/internal/api/subscription_entitlements.go +++ b/internal/api/subscription_entitlements.go @@ -174,6 +174,8 @@ func (h *LicenseHandlers) entitlementUsageSnapshot(ctx context.Context) entitlem usage.MonitoredSystemsAvailable = true usage.LegacyConnections = legacyConnectionCountsFromReadState(state.ReadState) monitorResolved = true + } else if usage.MonitoredSystemsUnavailableReason == "" { + usage.MonitoredSystemsUnavailableReason = state.UnavailableReason } } } @@ -183,6 +185,8 @@ func (h *LicenseHandlers) entitlementUsageSnapshot(ctx context.Context) entitlem usage.MonitoredSystems = int64(state.Count) usage.MonitoredSystemsAvailable = true usage.LegacyConnections = legacyConnectionCountsFromReadState(state.ReadState) + } else if usage.MonitoredSystemsUnavailableReason == "" { + usage.MonitoredSystemsUnavailableReason = state.UnavailableReason } } diff --git a/internal/monitoring/canonical_guardrails_test.go b/internal/monitoring/canonical_guardrails_test.go index 40ae70af3..f85e3abac 100644 --- a/internal/monitoring/canonical_guardrails_test.go +++ b/internal/monitoring/canonical_guardrails_test.go @@ -98,11 +98,17 @@ func TestMonitoredSystemUsageReadinessGuardrailsRemainCanonical(t *testing.T) { "SupplementalInventoryReadyAt(m *Monitor, orgID string) (time.Time, bool)", }, "monitored_system_usage.go": { + "MonitoredSystemUsageUnavailableMonitorState", + "MonitoredSystemUsageUnavailableSupplementalInventoryUnsettled", + "MonitoredSystemUsageUnavailableSupplementalInventoryRebuildPending", "func (m *Monitor) MonitoredSystemUsage() MonitoredSystemUsageSnapshot {", "readyAt, settled := m.supplementalInventoryReadyAt(orgID)", "if !settled {", + "UnavailableReason: MonitoredSystemUsageUnavailableSupplementalInventoryUnsettled,", "if freshness.IsZero() || freshness.Before(readyAt) {", - "return MonitoredSystemUsageSnapshot{ReadState: readState}", + "UnavailableReason: MonitoredSystemUsageUnavailableSupplementalInventoryRebuildPending,", + "Count: unifiedresources.MonitoredSystemCount(readState),", + "Available: true,", }, "truenas_poller.go": { "func (p *TrueNASPoller) SupplementalInventoryReadyAt(_ *Monitor, orgID string) (time.Time, bool) {", diff --git a/internal/monitoring/monitored_system_usage.go b/internal/monitoring/monitored_system_usage.go index 730515fcf..e41be5d20 100644 --- a/internal/monitoring/monitored_system_usage.go +++ b/internal/monitoring/monitored_system_usage.go @@ -11,34 +11,47 @@ import ( // supply a canonical monitored-system count that is safe for billing and // admission enforcement to consume. type MonitoredSystemUsageSnapshot struct { - Count int - ReadState unifiedresources.ReadState - Available bool + Count int + ReadState unifiedresources.ReadState + Available bool + UnavailableReason string } +const ( + MonitoredSystemUsageUnavailableMonitorState = "monitor_state_unavailable" + MonitoredSystemUsageUnavailableSupplementalInventoryUnsettled = "supplemental_inventory_unsettled" + MonitoredSystemUsageUnavailableSupplementalInventoryRebuildPending = "supplemental_inventory_rebuild_pending" +) + // MonitoredSystemUsage returns the canonical monitored-system count only when // the current unified view is settled enough for billing boundaries. When // supplemental provider-owned sources are still settling, the result fails // closed with Available=false. func (m *Monitor) MonitoredSystemUsage() MonitoredSystemUsageSnapshot { if m == nil { - return MonitoredSystemUsageSnapshot{} + return MonitoredSystemUsageSnapshot{UnavailableReason: MonitoredSystemUsageUnavailableMonitorState} } readState := m.GetUnifiedReadStateOrSnapshot() if readState == nil { - return MonitoredSystemUsageSnapshot{} + return MonitoredSystemUsageSnapshot{UnavailableReason: MonitoredSystemUsageUnavailableMonitorState} } orgID := normalizedMonitorUsageOrgID(m) readyAt, settled := m.supplementalInventoryReadyAt(orgID) if !settled { - return MonitoredSystemUsageSnapshot{ReadState: readState} + return MonitoredSystemUsageSnapshot{ + ReadState: readState, + UnavailableReason: MonitoredSystemUsageUnavailableSupplementalInventoryUnsettled, + } } if !readyAt.IsZero() { freshness := m.currentUnifiedResourceFreshness() if freshness.IsZero() || freshness.Before(readyAt) { - return MonitoredSystemUsageSnapshot{ReadState: readState} + return MonitoredSystemUsageSnapshot{ + ReadState: readState, + UnavailableReason: MonitoredSystemUsageUnavailableSupplementalInventoryRebuildPending, + } } } diff --git a/pkg/licensing/entitlement_payload.go b/pkg/licensing/entitlement_payload.go index 6409136c0..d9b14f82f 100644 --- a/pkg/licensing/entitlement_payload.go +++ b/pkg/licensing/entitlement_payload.go @@ -82,6 +82,10 @@ type EntitlementPayload struct { // CommercialMigration reports unresolved paid-license migration work entering // from v5-era commercial state. CommercialMigration *CommercialMigrationStatus `json:"commercial_migration,omitempty"` + + // MonitoredSystemContinuity exposes migrated monitored-system continuity + // state for billing and support-grade plan-limit presentation. + MonitoredSystemContinuity *MonitoredSystemContinuityStatus `json:"monitored_system_continuity,omitempty"` } // CommercialPosturePayload is the canonical non-billing commercial contract @@ -158,6 +162,10 @@ type LimitStatus struct { // usage value rather than an unavailable best-effort fallback. CurrentAvailable *bool `json:"current_available,omitempty"` + // CurrentUnavailableReason explains why Current is unavailable when + // CurrentAvailable is false. + CurrentUnavailableReason string `json:"current_unavailable_reason,omitempty"` + // State describes the over-limit UX state. // Values: "ok", "warning", "enforced" State string `json:"state"` @@ -186,11 +194,12 @@ func (c LegacyConnectionCounts) Total() int64 { } type EntitlementUsageSnapshot struct { - MonitoredSystems int64 - MonitoredSystemsAvailable bool - Nodes int64 // legacy compatibility alias for monitored systems - Guests int64 - LegacyConnections LegacyConnectionCounts + MonitoredSystems int64 + MonitoredSystemsAvailable bool + MonitoredSystemsUnavailableReason string + Nodes int64 // legacy compatibility alias for monitored systems + Guests int64 + LegacyConnections LegacyConnectionCounts } func (s EntitlementUsageSnapshot) monitoredSystemCount() int64 { @@ -207,6 +216,10 @@ func (s EntitlementUsageSnapshot) monitoredSystemCountAvailable() bool { return s.MonitoredSystems > 0 || s.Nodes > 0 } +func (s EntitlementUsageSnapshot) monitoredSystemCountUnavailableReason() string { + return s.MonitoredSystemsUnavailableReason +} + // BuildEntitlementPayload constructs the normalized payload from LicenseStatus. func BuildEntitlementPayload(status *LicenseStatus, subscriptionState string) EntitlementPayload { return BuildEntitlementPayloadWithUsage(status, subscriptionState, EntitlementUsageSnapshot{}, nil) @@ -334,6 +347,10 @@ func BuildEntitlementPayloadWithUsage( LegacyConnections: usage.LegacyConnections, HasMigrationGap: false, } + if status.MonitoredSystemContinuity != nil { + continuity := *status.MonitoredSystemContinuity + payload.MonitoredSystemContinuity = &continuity + } if payload.Capabilities == nil { payload.Capabilities = []string{} @@ -363,13 +380,17 @@ func BuildEntitlementPayloadWithUsage( // Build limits. if status.MaxMonitoredSystems > 0 { currentSystems := usage.monitoredSystemCount() - payload.Limits = append(payload.Limits, LimitStatus{ + limit := LimitStatus{ Key: MaxMonitoredSystemsLicenseGateKey, Limit: int64(status.MaxMonitoredSystems), Current: currentSystems, CurrentAvailable: boolPointer(usage.monitoredSystemCountAvailable()), State: LimitState(currentSystems, int64(status.MaxMonitoredSystems)), - }) + } + if !usage.monitoredSystemCountAvailable() { + limit.CurrentUnavailableReason = usage.monitoredSystemCountUnavailableReason() + } + payload.Limits = append(payload.Limits, limit) } if status.MaxGuests > 0 { payload.Limits = append(payload.Limits, LimitStatus{ diff --git a/pkg/licensing/entitlement_payload_test.go b/pkg/licensing/entitlement_payload_test.go index d2db1c63e..212a16c34 100644 --- a/pkg/licensing/entitlement_payload_test.go +++ b/pkg/licensing/entitlement_payload_test.go @@ -156,7 +156,9 @@ func TestBuildEntitlementPayloadWithUsage_MonitoredSystemUsageUnavailable(t *tes MaxMonitoredSystems: 50, } - payload := BuildEntitlementPayloadWithUsage(status, "", EntitlementUsageSnapshot{}, nil) + payload := BuildEntitlementPayloadWithUsage(status, "", EntitlementUsageSnapshot{ + MonitoredSystemsUnavailableReason: "supplemental_inventory_unsettled", + }, nil) if len(payload.Limits) != 1 { t.Fatalf("expected one limit, got %d", len(payload.Limits)) } @@ -166,6 +168,46 @@ func TestBuildEntitlementPayloadWithUsage_MonitoredSystemUsageUnavailable(t *tes if payload.Limits[0].CurrentAvailable == nil || *payload.Limits[0].CurrentAvailable { t.Fatalf("expected unresolved current availability to be false, got %+v", payload.Limits[0].CurrentAvailable) } + if payload.Limits[0].CurrentUnavailableReason != "supplemental_inventory_unsettled" { + t.Fatalf("CurrentUnavailableReason=%q, want %q", payload.Limits[0].CurrentUnavailableReason, "supplemental_inventory_unsettled") + } +} + +func TestBuildEntitlementPayloadWithUsage_CopiesMonitoredSystemContinuity(t *testing.T) { + status := &LicenseStatus{ + Valid: true, + Tier: TierPro, + Features: append([]string(nil), TierFeatures[TierPro]...), + MaxMonitoredSystems: 23, + MonitoredSystemContinuity: &MonitoredSystemContinuityStatus{ + PlanLimit: 10, + GrandfatheredFloor: 23, + EffectiveLimit: 23, + CapturePending: false, + CapturedAt: 123, + }, + } + + payload := BuildEntitlementPayloadWithUsage(status, "", EntitlementUsageSnapshot{ + MonitoredSystems: 23, + MonitoredSystemsAvailable: true, + }, nil) + + if payload.MonitoredSystemContinuity == nil { + t.Fatal("expected monitored-system continuity to be copied") + } + if payload.MonitoredSystemContinuity.PlanLimit != 10 { + t.Fatalf("PlanLimit=%d, want %d", payload.MonitoredSystemContinuity.PlanLimit, 10) + } + if payload.MonitoredSystemContinuity.EffectiveLimit != 23 { + t.Fatalf("EffectiveLimit=%d, want %d", payload.MonitoredSystemContinuity.EffectiveLimit, 23) + } + if payload.MonitoredSystemContinuity.GrandfatheredFloor != 23 { + t.Fatalf("GrandfatheredFloor=%d, want %d", payload.MonitoredSystemContinuity.GrandfatheredFloor, 23) + } + if payload.MonitoredSystemContinuity.CapturePending { + t.Fatal("expected continuity capture to be settled") + } } func TestBuildEntitlementPayload_CopiesStatusDisplayFields(t *testing.T) { diff --git a/pkg/licensing/models.go b/pkg/licensing/models.go index 53cf55918..2b77be0b6 100644 --- a/pkg/licensing/models.go +++ b/pkg/licensing/models.go @@ -194,16 +194,27 @@ const ( // LicenseStatus is the JSON response for license status API. type LicenseStatus struct { - Valid bool `json:"valid"` - Tier Tier `json:"tier"` - PlanVersion string `json:"plan_version,omitempty"` - Email string `json:"email,omitempty"` - ExpiresAt *string `json:"expires_at,omitempty"` - IsLifetime bool `json:"is_lifetime"` - DaysRemaining int `json:"days_remaining"` - Features []string `json:"features"` - MaxMonitoredSystems int `json:"max_monitored_systems,omitempty"` - MaxGuests int `json:"max_guests,omitempty"` - InGracePeriod bool `json:"in_grace_period,omitempty"` - GracePeriodEnd *string `json:"grace_period_end,omitempty"` + Valid bool `json:"valid"` + Tier Tier `json:"tier"` + PlanVersion string `json:"plan_version,omitempty"` + Email string `json:"email,omitempty"` + ExpiresAt *string `json:"expires_at,omitempty"` + IsLifetime bool `json:"is_lifetime"` + DaysRemaining int `json:"days_remaining"` + Features []string `json:"features"` + MaxMonitoredSystems int `json:"max_monitored_systems,omitempty"` + MaxGuests int `json:"max_guests,omitempty"` + InGracePeriod bool `json:"in_grace_period,omitempty"` + GracePeriodEnd *string `json:"grace_period_end,omitempty"` + MonitoredSystemContinuity *MonitoredSystemContinuityStatus `json:"monitored_system_continuity,omitempty"` +} + +// MonitoredSystemContinuityStatus describes the effective monitored-system +// limit continuity applied to a migrated legacy installation. +type MonitoredSystemContinuityStatus struct { + PlanLimit int `json:"plan_limit"` + GrandfatheredFloor int `json:"grandfathered_floor,omitempty"` + EffectiveLimit int `json:"effective_limit"` + CapturePending bool `json:"capture_pending"` + CapturedAt int64 `json:"captured_at,omitempty"` } diff --git a/pkg/licensing/models_test.go b/pkg/licensing/models_test.go index ff66c65da..89c5e8b26 100644 --- a/pkg/licensing/models_test.go +++ b/pkg/licensing/models_test.go @@ -230,6 +230,48 @@ func TestClaims_EffectiveLimitsPreservesNonCloudPlanLimits(t *testing.T) { } } +func TestLicenseStatusJSON_EncodesMonitoredSystemContinuity(t *testing.T) { + status := LicenseStatus{ + Valid: true, + Tier: TierPro, + MaxMonitoredSystems: 23, + MonitoredSystemContinuity: &MonitoredSystemContinuityStatus{ + PlanLimit: 10, + GrandfatheredFloor: 23, + EffectiveLimit: 23, + CapturePending: false, + CapturedAt: 123, + }, + } + + data, err := json.Marshal(status) + if err != nil { + t.Fatalf("marshal status: %v", err) + } + + var decoded map[string]any + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("decode status json: %v", err) + } + + continuity, ok := decoded["monitored_system_continuity"].(map[string]any) + if !ok { + t.Fatalf("expected monitored_system_continuity object, got %#v", decoded["monitored_system_continuity"]) + } + if got := continuity["plan_limit"]; got != float64(10) { + t.Fatalf("plan_limit=%v, want %v", got, float64(10)) + } + if got := continuity["effective_limit"]; got != float64(23) { + t.Fatalf("effective_limit=%v, want %v", got, float64(23)) + } + if got := continuity["grandfathered_floor"]; got != float64(23) { + t.Fatalf("grandfathered_floor=%v, want %v", got, float64(23)) + } + if got := continuity["capture_pending"]; got != false { + t.Fatalf("capture_pending=%v, want false", got) + } +} + func TestClaims_EffectiveLimitsMissingCloudPlanFailsClosed(t *testing.T) { claims := Claims{ Tier: TierCloud, diff --git a/pkg/licensing/service.go b/pkg/licensing/service.go index 1960d0ba7..9510dcdc5 100644 --- a/pkg/licensing/service.go +++ b/pkg/licensing/service.go @@ -469,6 +469,59 @@ func (s *Service) CaptureLegacyMonitoredSystemGrandfatherFloor(count int) error return nil } +func (s *Service) needsLegacyMonitoredSystemCaptureLocked() bool { + if s == nil || s.activationState == nil { + return false + } + return normalizeActivationContinuity(s.activationState.Continuity).needsLegacyMonitoredSystemCapture() +} + +// NeedsLegacyMonitoredSystemCapture reports whether a migrated activation is +// still waiting for its one-time monitored-system continuity capture. +func (s *Service) NeedsLegacyMonitoredSystemCapture() bool { + if s == nil { + return false + } + s.mu.Lock() + defer s.mu.Unlock() + return s.needsLegacyMonitoredSystemCaptureLocked() +} + +func (s *Service) monitoredSystemContinuityStatusLocked() *MonitoredSystemContinuityStatus { + if s == nil || s.activationState == nil { + return nil + } + + continuity := normalizeActivationContinuity(s.activationState.Continuity) + if !continuity.LegacyMigration { + return nil + } + + planLimit := 0 + if gc, err := verifyAndParseGrantJWT(s.activationState.GrantJWT); err == nil && gc != nil { + planLimit = gc.MaxMonitoredSystems + } + + effectiveLimit := planLimit + if s.license != nil { + effectiveLimit = monitoredSystemLimitFromClaims(s.license.Claims) + } + if effectiveLimit <= 0 { + effectiveLimit = planLimit + } + + status := &MonitoredSystemContinuityStatus{ + PlanLimit: planLimit, + EffectiveLimit: effectiveLimit, + CapturePending: continuity.needsLegacyMonitoredSystemCapture(), + CapturedAt: continuity.GrandfatheredMonitoredSystemsCapturedAt, + } + if continuity.GrandfatheredMaxMonitoredSystems > 0 { + status.GrandfatheredFloor = continuity.GrandfatheredMaxMonitoredSystems + } + return status +} + // Clear removes the current license. // If an activation-key license is present, it also stops the refresh loop and clears the state. func (s *Service) Clear() { @@ -704,6 +757,7 @@ func (s *Service) Status() *LicenseStatus { if isDemoMode() || isDevMode() { status.Features = devModeFeatures() } + status.MonitoredSystemContinuity = s.monitoredSystemContinuityStatusLocked() return status } @@ -720,6 +774,7 @@ func (s *Service) Status() *LicenseStatus { if maxGuests, ok := s.license.Claims.EffectiveLimits()["max_guests"]; ok { status.MaxGuests = safeIntFromInt64(maxGuests) } + status.MonitoredSystemContinuity = s.monitoredSystemContinuityStatusLocked() // Apply the tier default monitored-system limit when claims don't specify one. // For recognized tiers, use their defined limit (0 = unlimited for Cloud/MSP/Enterprise). diff --git a/pkg/licensing/service_activate_test.go b/pkg/licensing/service_activate_test.go index 0ac8a8433..40dcc590a 100644 --- a/pkg/licensing/service_activate_test.go +++ b/pkg/licensing/service_activate_test.go @@ -266,6 +266,92 @@ func TestServiceCaptureLegacyMonitoredSystemGrandfatherFloorPersistsAndUpdatesSt } } +func TestServiceStatus_ExposesMonitoredSystemContinuity(t *testing.T) { + setupTestPublicKey(t) + + grantJWT := makeTestGrantJWT(t, &GrantClaims{ + LicenseID: "lic_continuity_status", + Tier: "pro", + PlanKey: "v5_pro_monthly_grandfathered", + State: "active", + MaxMonitoredSystems: 10, + IssuedAt: time.Now().Unix(), + ExpiresAt: time.Now().Add(72 * time.Hour).Unix(), + }) + + tests := []struct { + name string + continuity ActivationContinuity + wantPlanLimit int + wantEffectiveLimit int + wantFloor int + wantCapturePending bool + wantMaxSystems int + }{ + { + name: "pending capture", + continuity: ActivationContinuity{ + LegacyMigration: true, + }, + wantPlanLimit: 10, + wantEffectiveLimit: 10, + wantFloor: 0, + wantCapturePending: true, + wantMaxSystems: 10, + }, + { + name: "captured floor", + continuity: ActivationContinuity{ + LegacyMigration: true, + GrandfatheredMaxMonitoredSystems: 23, + GrandfatheredMonitoredSystemsCapturedAt: 123, + }, + wantPlanLimit: 10, + wantEffectiveLimit: 23, + wantFloor: 23, + wantCapturePending: false, + wantMaxSystems: 23, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + svc := NewService() + if err := svc.RestoreActivation(&ActivationState{ + InstallationID: "inst_continuity_status", + InstallationToken: "pit_live_continuity_status", + LicenseID: "lic_continuity_status", + GrantJWT: grantJWT, + GrantJTI: "grant_continuity_status", + InstanceFingerprint: "fp-continuity-status", + Continuity: tt.continuity, + }); err != nil { + t.Fatalf("RestoreActivation: %v", err) + } + + status := svc.Status() + if status.MaxMonitoredSystems != tt.wantMaxSystems { + t.Fatalf("status.MaxMonitoredSystems=%d, want %d", status.MaxMonitoredSystems, tt.wantMaxSystems) + } + if status.MonitoredSystemContinuity == nil { + t.Fatal("expected monitored-system continuity in status") + } + if status.MonitoredSystemContinuity.PlanLimit != tt.wantPlanLimit { + t.Fatalf("PlanLimit=%d, want %d", status.MonitoredSystemContinuity.PlanLimit, tt.wantPlanLimit) + } + if status.MonitoredSystemContinuity.EffectiveLimit != tt.wantEffectiveLimit { + t.Fatalf("EffectiveLimit=%d, want %d", status.MonitoredSystemContinuity.EffectiveLimit, tt.wantEffectiveLimit) + } + if status.MonitoredSystemContinuity.GrandfatheredFloor != tt.wantFloor { + t.Fatalf("GrandfatheredFloor=%d, want %d", status.MonitoredSystemContinuity.GrandfatheredFloor, tt.wantFloor) + } + if status.MonitoredSystemContinuity.CapturePending != tt.wantCapturePending { + t.Fatalf("CapturePending=%v, want %v", status.MonitoredSystemContinuity.CapturePending, tt.wantCapturePending) + } + }) + } +} + func TestServiceActivate_RejectsMalformedLegacyKeyOutsideDevMode(t *testing.T) { t.Setenv("PULSE_LICENSE_DEV_MODE", "false")