diff --git a/docs/release-control/v6/internal/status.json b/docs/release-control/v6/internal/status.json index 7ef41f9dd..cc2eed066 100644 --- a/docs/release-control/v6/internal/status.json +++ b/docs/release-control/v6/internal/status.json @@ -347,7 +347,6 @@ "src/components/Infrastructure/deploy/__tests__/ConfirmStep.test.tsx", "src/utils/__tests__/deployStatusPresentation.test.ts", "src/components/shared/__tests__/HistoryChart.test.tsx", - "src/components/shared/__tests__/MonitoredSystemLimitWarningBanner.test.tsx", "src/components/shared/__tests__/TrialBanner.test.tsx", "src/pages/__tests__/AIIntelligence.test.tsx", "src/features/patrol/__tests__/patrolCommercialBoundary.test.ts" @@ -503,7 +502,7 @@ }, { "id": "RA7", - "summary": "Monitored-system allocation stays coherent across enforcement, entitlements, and UI: counted top-level monitored systems are collection-path agnostic, capped limits stay canonical, and users see the same monitored-system usage that the runtime enforces.", + "summary": "Monitored-system volume caps are retired end to end: runtime writes admit infrastructure regardless of stale plan-limit metadata, entitlements scrub retired monitored-system limits, and UI surfaces use monitored-system counts only for informational billing/support context.", "kind": "invariant", "blocking_level": "rc-ready", "proof_type": "hybrid", @@ -531,7 +530,7 @@ "test", "./internal/api", "-run", - "TestMonitoredSystemCountNilMonitor|TestLegacyConnectionCountsFromReadState|TestLegacyConnectionCountsUsesSnapshotFallback|TestDeployReservedCount|TestUnifiedAgentHandlers_HandleReport_EnforcesMaxMonitoredSystemsForNewHostsOnly|TestHandleAddNode_BlocksNewCountedSystemAtLimit|TestHandleAutoRegister_BlocksNewCountedSystemAtLimit|TestDockerAgentHandlers_HandleReport_BlocksNewMonitoredSystemAtLimit|TestKubernetesAgentHandlers_HandleReport_BlocksNewMonitoredSystemAtLimit|TestTrueNASHandlers_HandleAdd_BlocksNewCountedSystemAtLimit|TestBuildEntitlementPayloadWithUsage_CurrentValues", + "TestHandleAddNode_AllowsNewCountedSystemWithCapsRetired|TestHandleAutoRegister_AllowsNewCountedSystemWithCapsRetired|TestDockerAgentHandlers_HandleReport_AllowsNewMonitoredSystemWithCapsRetired|TestKubernetesAgentHandlers_HandleReport_AllowsNewMonitoredSystemWithCapsRetired|TestUnifiedAgentHandlers_HandleReport_AllowsNewHostsWithCapsRetired|TestHandleCreateJob_AcceptsAllTargetsWithMonitoredSystemCapsRetired|TestContract_DeployHandlersDoNotSurfaceLicenseSlotCapacityCopy|TestBuildEntitlementPayloadWithUsage_CurrentValues", "-count=1" ] }, @@ -544,7 +543,10 @@ "--", "--run", "src/components/Settings/__tests__/OrganizationBillingPanel.test.tsx", - "src/components/shared/__tests__/MonitoredSystemLimitWarningBanner.test.tsx" + "src/components/Settings/__tests__/ProLicensePanel.test.tsx", + "src/components/Settings/__tests__/MonitoredSystemLedgerPanel.test.tsx", + "src/components/Infrastructure/deploy/__tests__/ConfirmStep.test.tsx", + "src/hooks/__tests__/useDeployWizard.test.ts" ] }, { @@ -554,7 +556,7 @@ "test", "./pkg/licensing", "-run", - "TestExceedsMonitoredSystemLimit|TestInstalledUnifiedAgentCount|TestLimitsForCloudPlan_KnownPlans|TestLimitsForCloudPlan_UnknownPlanFailsClosed", + "TestClaims_EffectiveLimitsScrubsRetiredCloudPlanMonitoringLimit|TestLicenseStatusJSON_OmitsRetiredMonitoredSystemFields|TestBuildEntitlementPayload_ActiveLicenseOmitsRetiredMonitoredSystemLimit|TestNormalizeBillingState_ScrubsRetiredMonitoredSystemLimitAliases", "-count=1" ] } @@ -562,12 +564,12 @@ "evidence": [ { "repo": "pulse", - "path": "frontend-modern/src/components/Settings/__tests__/OrganizationBillingPanel.test.tsx", + "path": "frontend-modern/src/components/Settings/__tests__/MonitoredSystemLedgerPanel.test.tsx", "kind": "file" }, { "repo": "pulse", - "path": "frontend-modern/src/components/shared/__tests__/MonitoredSystemLimitWarningBanner.test.tsx", + "path": "frontend-modern/src/components/Settings/__tests__/OrganizationBillingPanel.test.tsx", "kind": "file" }, { @@ -580,6 +582,11 @@ "path": "internal/api/config_handlers_auto_register_test.go", "kind": "file" }, + { + "repo": "pulse", + "path": "internal/api/deploy_handlers_test.go", + "kind": "file" + }, { "repo": "pulse", "path": "internal/api/docker_agents_additional_test.go", @@ -595,11 +602,6 @@ "path": "internal/api/kubernetes_agents_additional_test.go", "kind": "file" }, - { - "repo": "pulse", - "path": "internal/api/monitored_system_limit_enforcement_test.go", - "kind": "file" - }, { "repo": "pulse", "path": "internal/api/truenas_handlers_test.go", @@ -5050,7 +5052,21 @@ ], "coverage_gaps": [], "candidate_lanes": [], - "work_claims": [], + "work_claims": [ + { + "id": "codex-lane-l16", + "agent_id": "codex", + "summary": "Harden Linux agent service defaults from Proxmox root-agent trust signal", + "target_id": "v6-product-lane-expansion", + "claimed_at": "2026-05-05T11:53:57Z", + "heartbeat_at": "2026-05-05T11:53:57Z", + "expires_at": "2026-05-05T13:53:57Z", + "work_item": { + "kind": "lane", + "id": "L16" + } + } + ], "open_decisions": [], "source_of_truth_file": "docs/release-control/v6/internal/SOURCE_OF_TRUTH.md", "resolved_decisions": [ diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index b19d4addd..ad1addecb 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -90,7 +90,7 @@ management, and fleet control surfaces. 63. `frontend-modern/src/components/Settings/platformConnectionsModel.ts` 64. `frontend-modern/src/components/Settings/useTrueNASSettingsPanelState.ts` 65. `frontend-modern/src/components/Settings/useVMwareSettingsPanelState.ts` -66. `frontend-modern/src/components/Settings/MonitoredSystemAdmissionPreview.tsx` +66. `frontend-modern/src/components/Settings/MonitoredSystemImpactPreview.tsx` 67. `internal/hostagent/proxmox_setup.go` 68. `internal/remoteconfig/client.go` 69. `internal/agenttls/config.go` @@ -101,7 +101,7 @@ management, and fleet control surfaces. 2. `frontend-modern/src/api/nodes.ts` shared with `api-contracts`: the shared Proxmox node client is both an agent lifecycle setup/install control surface and a canonical API payload contract boundary. 3. `frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/NodeCredentialSlot.tsx` shared with `api-contracts`: the inline node credential slot is both an agent lifecycle control surface and a shared API-backed install/setup contract boundary. 4. `frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx` shared with `api-contracts`: the pure infrastructure operations inventory/install model is both an agent fleet lifecycle control surface and an API token, lookup, assignment, and reporting/install contract boundary. -5. `frontend-modern/src/components/Settings/MonitoredSystemAdmissionPreview.tsx` shared with `cloud-paid`: the monitored-system admission preview is both a platform-connections lifecycle surface and a canonical cloud-paid monitored-system presentation boundary. +5. `frontend-modern/src/components/Settings/MonitoredSystemImpactPreview.tsx` shared with `cloud-paid`: the monitored-system impact preview is both a platform-connections lifecycle surface and a canonical cloud-paid monitored-system presentation boundary. 6. `frontend-modern/src/components/Settings/NodeModalAuthenticationSection.tsx` shared with `api-contracts`: the node setup authentication section is both an agent lifecycle control surface and a shared API-backed install/setup contract boundary. 7. `frontend-modern/src/components/Settings/NodeModalBasicInfoSection.tsx` shared with `api-contracts`: the node setup basic-info section is both an agent lifecycle control surface and a shared API-backed install/setup contract boundary. 8. `frontend-modern/src/components/Settings/nodeModalModel.ts` shared with `api-contracts`: the pure node setup modal model is both an agent lifecycle control surface and a shared API-backed install/setup contract boundary. @@ -151,11 +151,11 @@ Install is the recommended API + Agent path, Direct Connection is the advanced API inventory path, and Manual Token Setup is an advanced manual API-token escape hatch. -That shared monitored-system admission preview boundary also owns the disabled +That shared monitored-system impact preview boundary also owns the disabled platform-connection lifecycle state. Once a TrueNAS or VMware setup form marks the connection disabled, lifecycle surfaces must treat a canonical zero-delta or removal-only preview as a valid save path instead of holding the dialog in -an add-only capacity posture. +an add-only posture. The lifecycle-owned onboarding presentation helper must consume the governed platform support manifest for readiness stage, primary mode, canonical projections, and support-floor posture. @@ -362,7 +362,7 @@ profile and assignment columns, but embedded table framing must route through Approval-gated command execution must expose stable rejection reasons for invalid approval grants so fleet operators can distinguish missing, expired, mismatched, and signature-invalid grants through agent metrics. -9. Add or change profile management, the extracted agent profiles runtime owner, the agent profile settings catalog, the infrastructure source-manager landing, the pure unified-agent inventory/install model, the connections-ledger workspace shell, the unified ConnectionEditor and its per-type credential slots, route model, shared install section owner, the shared direct-node/discovery infrastructure settings owners plus their model, shared frontend install-command assembly, Proxmox setup/install API transport, TrueNAS platform-connection management, VMware platform-connection management, the shared monitored-system admission preview shell for those platform connections, setup-completion install handoff transport, deploy-fallback manual install transport, and fleet-control presentation through `frontend-modern/src/api/agentProfiles.ts`, `frontend-modern/src/api/nodes.ts`, `frontend-modern/src/components/Settings/AgentProfilesPanel.tsx`, `frontend-modern/src/components/Settings/agentProfileSettings.ts`, `frontend-modern/src/components/Settings/useAgentProfilesPanelState.ts`, `frontend-modern/src/components/Settings/ConnectionsTable.tsx`, `frontend-modern/src/components/Settings/connectionsTableModel.ts`, `frontend-modern/src/components/Settings/useConnectionsLedger.ts`, `frontend-modern/src/components/Settings/useConnectionRowActions.ts`, `frontend-modern/src/components/Settings/ConnectionEditor/ConnectionEditor.tsx`, `frontend-modern/src/components/Settings/ConnectionEditor/AddressProbeStep.tsx`, `frontend-modern/src/components/Settings/ConnectionEditor/useConnectionEditor.ts`, `frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/NodeCredentialSlot.tsx`, `frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/TrueNASCredentialSlot.tsx`, `frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/VMwareCredentialSlot.tsx`, `frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx`, `frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx`, `frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx`, `frontend-modern/src/components/Settings/InfrastructureSourceManager.tsx`, `frontend-modern/src/components/Settings/infrastructureWorkspaceModel.ts`, `frontend-modern/src/components/Settings/MonitoredSystemAdmissionPreview.tsx`, `frontend-modern/src/components/Settings/platformConnectionsModel.ts`, `frontend-modern/src/components/Settings/useTrueNASSettingsPanelState.ts`, `frontend-modern/src/components/Settings/useVMwareSettingsPanelState.ts`, `frontend-modern/src/components/Settings/proxmoxSettingsModel.ts`, `frontend-modern/src/components/Settings/ConfiguredNodeTables.tsx`, `frontend-modern/src/components/Settings/SettingsSectionNav.tsx`, `frontend-modern/src/components/Settings/infrastructureSettingsModel.ts`, `frontend-modern/src/components/Settings/useInfrastructureConfiguredNodesState.ts`, `frontend-modern/src/components/Settings/useInfrastructureDiscoveryRuntimeState.ts`, `frontend-modern/src/components/Settings/useInfrastructureInstallState.tsx`, `frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx`, `frontend-modern/src/components/Settings/useInfrastructureSettingsState.ts`, `frontend-modern/src/components/Settings/nodeModalModel.ts`, `frontend-modern/src/components/Settings/useNodeModalState.ts`, `frontend-modern/src/components/SetupWizard/SetupCompletionPanel.tsx`, and `frontend-modern/src/utils/agentInstallCommand.ts`. Phase 9 retired the legacy reporting/inventory surface (InfrastructureOperationsController, InfrastructureInventorySection, InfrastructureActiveRowDetails, InfrastructureIgnoredRowDetails, InfrastructureStopMonitoringDialog, useInfrastructureReportingState) and the per-type shells (PlatformConnectionsWorkspace, ProxmoxSettingsPanel, ProxmoxDirectWorkspace, ProxmoxConfiguredNodesTable, ProxmoxDirectConnectionsCard, ProxmoxDiscoveryResultsCard, ProxmoxDeleteNodeDialog, ProxmoxNodeModalStack, NodeModal shell, TrueNASSettingsPanel, VMwareSettingsPanel, useProxmoxDirectWorkspaceState); lifecycle extensions must route through the unified aggregator ledger, source-manager cards, and ConnectionEditor credential slots rather than reintroducing those retired surfaces. +9. Add or change profile management, the extracted agent profiles runtime owner, the agent profile settings catalog, the infrastructure source-manager landing, the pure unified-agent inventory/install model, the connections-ledger workspace shell, the unified ConnectionEditor and its per-type credential slots, route model, shared install section owner, the shared direct-node/discovery infrastructure settings owners plus their model, shared frontend install-command assembly, Proxmox setup/install API transport, TrueNAS platform-connection management, VMware platform-connection management, the shared monitored-system impact preview shell for those platform connections, setup-completion install handoff transport, deploy-fallback manual install transport, and fleet-control presentation through `frontend-modern/src/api/agentProfiles.ts`, `frontend-modern/src/api/nodes.ts`, `frontend-modern/src/components/Settings/AgentProfilesPanel.tsx`, `frontend-modern/src/components/Settings/agentProfileSettings.ts`, `frontend-modern/src/components/Settings/useAgentProfilesPanelState.ts`, `frontend-modern/src/components/Settings/ConnectionsTable.tsx`, `frontend-modern/src/components/Settings/connectionsTableModel.ts`, `frontend-modern/src/components/Settings/useConnectionsLedger.ts`, `frontend-modern/src/components/Settings/useConnectionRowActions.ts`, `frontend-modern/src/components/Settings/ConnectionEditor/ConnectionEditor.tsx`, `frontend-modern/src/components/Settings/ConnectionEditor/AddressProbeStep.tsx`, `frontend-modern/src/components/Settings/ConnectionEditor/useConnectionEditor.ts`, `frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/NodeCredentialSlot.tsx`, `frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/TrueNASCredentialSlot.tsx`, `frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/VMwareCredentialSlot.tsx`, `frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx`, `frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx`, `frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx`, `frontend-modern/src/components/Settings/InfrastructureSourceManager.tsx`, `frontend-modern/src/components/Settings/infrastructureWorkspaceModel.ts`, `frontend-modern/src/components/Settings/MonitoredSystemImpactPreview.tsx`, `frontend-modern/src/components/Settings/platformConnectionsModel.ts`, `frontend-modern/src/components/Settings/useTrueNASSettingsPanelState.ts`, `frontend-modern/src/components/Settings/useVMwareSettingsPanelState.ts`, `frontend-modern/src/components/Settings/proxmoxSettingsModel.ts`, `frontend-modern/src/components/Settings/ConfiguredNodeTables.tsx`, `frontend-modern/src/components/Settings/SettingsSectionNav.tsx`, `frontend-modern/src/components/Settings/infrastructureSettingsModel.ts`, `frontend-modern/src/components/Settings/useInfrastructureConfiguredNodesState.ts`, `frontend-modern/src/components/Settings/useInfrastructureDiscoveryRuntimeState.ts`, `frontend-modern/src/components/Settings/useInfrastructureInstallState.tsx`, `frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx`, `frontend-modern/src/components/Settings/useInfrastructureSettingsState.ts`, `frontend-modern/src/components/Settings/nodeModalModel.ts`, `frontend-modern/src/components/Settings/useNodeModalState.ts`, `frontend-modern/src/components/SetupWizard/SetupCompletionPanel.tsx`, and `frontend-modern/src/utils/agentInstallCommand.ts`. Phase 9 retired the legacy reporting/inventory surface (InfrastructureOperationsController, InfrastructureInventorySection, InfrastructureActiveRowDetails, InfrastructureIgnoredRowDetails, InfrastructureStopMonitoringDialog, useInfrastructureReportingState) and the per-type shells (PlatformConnectionsWorkspace, ProxmoxSettingsPanel, ProxmoxDirectWorkspace, ProxmoxConfiguredNodesTable, ProxmoxDirectConnectionsCard, ProxmoxDiscoveryResultsCard, ProxmoxDeleteNodeDialog, ProxmoxNodeModalStack, NodeModal shell, TrueNASSettingsPanel, VMwareSettingsPanel, useProxmoxDirectWorkspaceState); lifecycle extensions must route through the unified aggregator ledger, source-manager cards, and ConnectionEditor credential slots rather than reintroducing those retired surfaces. Those lifecycle-owned settings hooks may consume websocket state only through `frontend-modern/src/contexts/appRuntime.ts`; they must not import `frontend-modern/src/App.tsx` or recreate root-shell providers. Discovery configuration is part of that same lifecycle-owned workspace boundary. `InfrastructureSourceManager.tsx` must open one canonical discovery editor through `InfrastructureDiscoverySettingsDialog.tsx`, `DiscoverySettingsForm.tsx`, and `discoverySettingsModel.ts`, while the System/Network shell stays limited to network-boundary controls instead of reintroducing a second editable discovery surface. That same workspace boundary now owns the infrastructure source-management toolbar too: the landing page exposes `Add infrastructure`, `Run discovery`, and `Discovery settings` as first-viewport toolbar actions inside the source manager, while governed source rows expose per-source add actions such as `Install Pulse Agent` from the shared catalog. `Detect address` remains inside the single add-flow source picker/probe path rather than a duplicate toolbar action. The same landing boundary may surface setup confidence from the unified rows and discovered candidates, including connected-system count, API coverage, agent coverage, sources that still need an agent, and discovery review state, without creating a second inventory model or provider-specific summary fetch. Source groups must stay in the governed source catalog order instead of re-sorting by current row count, row-level lifecycle entry points must use `Manage` language, and locked agent-install states must show a compact command inventory without raw token placeholders or disabled copy commands until a token exists. Network discovery settings remain safety-critical: automatic scanning must surface the shared-network/subnet warning before operators save scan mode changes. Agent command-execution handoff copy belongs to that same install surface: `InfrastructureInstallerSection.tsx` may expose the Pulse command-execution toggle for Patrol, but the label must describe Patrol remediation rather than reviving `Patrol auto-fix` or implying a paid monitoring-volume gate. Setup-completion handoff belongs to that same single add-flow boundary. The first-run completion screen must keep credentials as the first surfaced object, then present one compact next-step surface that sends operators to Add infrastructure or directly to the Agent handoff; source-choice explanation may live inside that surface, but lifecycle work must not reintroduce a separate setup-wizard tour, duplicate CTA section, or inline install-command owner before the canonical infrastructure workspace. @@ -452,20 +452,13 @@ profile and assignment columns, but embedded table framing must route through flows may observe `self_hosted_plan`, but they must not keep emitting or inferring legacy `max_monitored_systems` intent/query values once the uncapped self-hosted model is canonical. - The same adjacent - commercial boundary now also owns migrated-v5 monitored-system - 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. They also must - not depend on a status or entitlements read to seal pending grandfather - continuity, use those billing reads to restart pending continuity - reconciliation, or reinterpret continuity-verification payloads as a real + The same adjacent commercial boundary treats migrated-v5 monitored-system + grandfathering as retired compatibility metadata. Lifecycle surfaces may + react to active license or entitlement payloads, but they must not cache + their own pre-activation host counts, synthesize a grandfather floor, + restart capacity reconciliation from billing reads, or reinterpret + continuity payloads as install eligibility, fleet enrollment evidence, or `0 / limit` monitored-system state. - When the commercial reconciler captures the floor, lifecycle-adjacent - code must treat the resulting activation-state callback as commercial - ownership cleanup only, not as install inventory proof or fleet enrollment - state. That same adjacent commercial boundary also owns authenticated install-version attribution for migrated installs: lifecycle surfaces may observe versioned commercial status, but they must not treat @@ -480,11 +473,11 @@ profile and assignment columns, but embedded table framing must route through heuristics. The same lifecycle-adjacent platform-connections boundary also assumes direct TrueNAS and VMware connection writes fail closed when canonical - monitored-system usage is unavailable. Shared `internal/api/` admission - helpers must return `monitored_system_usage_unavailable` before save, and + monitored-system grouping is unavailable. Shared `internal/api/` preview + helpers may return `monitored_system_usage_unavailable` before save, and VMware must not collect external vCenter inventory before that canonical - capacity view is safe, so fleet/setup surfaces cannot bypass the - monitored-system accounting boundary through direct API writes. + grouping view is safe, so fleet/setup surfaces do not fork monitored-system + identity through direct API writes. The same lifecycle-adjacent platform-connections boundary now also owns the unified connections ledger (`GET /api/connections`) and address probe (`POST /api/connections/probe`). Lifecycle surfaces may observe @@ -542,7 +535,7 @@ profile and assignment columns, but embedded table framing must route through they extract the inner form bodies from the per-type panels and render them inline under the editor while still driving the existing `TrueNASSettingsPanelState` and `VMwareSettingsPanelState` APIs for - save, test, preview, and admission-preview behavior. The add flow + save, test, preview, and impact-preview behavior. The add flow must not reintroduce the full per-type workspace (Proxmox discovery card, configured nodes table, node-modal stack; TrueNAS/VMware connection list with headers and row actions) into the credential @@ -777,11 +770,10 @@ supervisor pidfile with `-P`, keep the child pid in a separate diagnostic pidfile, and stop legacy child-pidfile installs by resolving the child back to its daemon supervisor before replacing or restarting the agent binary. -Deploy selection and retry capacity feedback now follows the API/cloud-paid -workspace-capacity terminology boundary. Lifecycle-owned deploy surfaces keep -stable backend compatibility identifiers, but the user-facing confirmation, -preflight, retry, and status labels must not revive license-slot or plan-upgrade -wording for monitored-system capacity. +Deploy selection and retry no longer carry monitored-system capacity feedback. +Lifecycle-owned deploy surfaces must not revive license-slot, workspace-slot, +plan-upgrade, or monitored-system capacity wording in user-facing +confirmation, preflight, retry, and status labels. The infrastructure workspace collapsed to a single `/settings/infrastructure` route. `buildInfrastructureWorkspacePath()` always returns the base path; @@ -793,10 +785,10 @@ customer-facing Settings Infrastructure target label and onboarding source strategy copy. Lifecycle and setup guidance must point operators to `Settings → Infrastructure` and must not revive removed subpaths such as `Settings → Infrastructure → Proxmox`. -The shared monitored-system admission preview now formats save-impact +The shared monitored-system impact preview now formats save-impact summaries through `frontend-modern/src/utils/monitoredSystemPresentation.ts` -so infrastructure setup screens describe count impact and active-policy -failures without raw slash-quota rendering. +so infrastructure setup screens describe count impact and grouping changes +without raw slash-quota rendering. This subsystem now sits under the dedicated agent lifecycle and fleet operations lane so install, registration, update continuity, profile @@ -837,11 +829,11 @@ needs them, but `/api/license/commercial-posture`, public demo mode and those lifecycle flows must not depend on licensed identity, plan labels, upgrade reasons, checkout handoff state, or observed usage counts surviving the public-demo contract. -That same demo-safe runtime contract now also redacts monitored-system -capacity posture from public-preview runtime capabilities. Lifecycle-adjacent -install or reporting surfaces may still depend on demo-safe capability flags, -but they must not expect `monitored_system_capacity`, admission-freeze copy, -or observed plan overage posture to survive the public-demo boundary. +That same demo-safe runtime contract keeps monitored-system capacity posture +out of public-preview runtime capabilities. Lifecycle-adjacent install or +reporting surfaces may still depend on demo-safe capability flags, but they +must not expect `monitored_system_capacity`, admission-freeze copy, or +observed plan overage posture to exist. The same presentation-policy split now governs paid lifecycle extensions in ordinary self-hosted v6 installs. Agent profile management may remain an entitled lifecycle surface, but default Infrastructure navigation must not @@ -1360,8 +1352,8 @@ for that landing belongs to DOM-backed settings tests, not raw source-string position checks, so lifecycle ownership continues to guard the operator-visible order after reasonable component extraction or copy refactors. When that infrastructure workspace needs to redirect operators to the plan- -owned self-hosted commercial surface for billing, monitored-system limits, or -license status, it must +owned self-hosted commercial surface for billing, license status, or paid +feature activation, it must consume the settings-owned referral copy from `frontend-modern/src/components/Settings/selfHostedBillingPresentation.ts` instead of carrying workspace-local commercial guidance or reaching back into @@ -1467,9 +1459,9 @@ surface reported most recently. Retired flat alias fields must not reappear as parallel lifecycle signal inputs or contract language. Lifecycle-adjacent workspace copy must also keep the same commercial framing: infrastructure operations may point operators to Plans for billing, but it -must describe that boundary in monitored-system, plan-limit, license-status, -and unlocked-capability terms rather than reviving legacy agent-allocation -language or treating the entire destination as the `Pulse Pro` tier page. +must describe that boundary in license-status and unlocked-capability terms +rather than reviving monitored-system plan limits, legacy agent-allocation +language, or treating the entire destination as the `Pulse Pro` tier page. That same direct-workspace boundary now also owns the shared customer-facing error copy for discovery and configured-node actions through `frontend-modern/src/utils/infrastructureSettingsPresentation.ts`, so direct @@ -1603,9 +1595,8 @@ canonical node-type defaults, endpoint/auth placeholders, monitoring coverage copy, and test-result styling for PVE, PBS, and PMG setup. That presentation layer remains presentation-only for those API-managed Proxmox, PBS, and PMG connections. Lifecycle guidance in that settings surface -may explain monitored-system caps, but commercial enforcement still belongs to -the canonical add-node and `/api/auto-register` boundaries instead of -becoming a second modal-local exemption rule. +may explain monitored-system grouping, but monitored-system volume caps are +retired and must not reappear as a modal-local rule or exemption path. That same deterministic token-identity contract also applies to backend-owned turnkey Proxmox token creation: generated setup scripts and the password-based PBS add-node path must derive Pulse-managed token names from the canonical @@ -1646,51 +1637,42 @@ That same canonical request contract must also keep field-validation failures specific: mismatched `tokenId`/`tokenValue` input may not collapse into generic missing-field output, and other missing canonical fields must return explicit `Missing required canonical auto-register fields: ...` guidance. -That same owned setup and auto-register boundary now also participates in the -canonical monitored-system commercial cap. A new `/api/auto-register` -completion may proceed only when it either dedupes onto an already-counted -top-level monitored system or fits within the deduped monitored-system limit; -the lifecycle surface may not preserve a special exemption that keeps API- -backed monitored systems outside the self-hosted commercial cap. -That admission decision must come from the same canonical prospective +That same owned setup and auto-register boundary participates in the canonical +monitored-system grouping model without commercial volume admission. A new +`/api/auto-register` completion may project whether it dedupes onto an +existing top-level monitored system or creates a new one, but the lifecycle +surface must not block API-backed monitoring on a self-hosted or hosted +monitored-system cap. +That grouping projection must come from the same canonical prospective monitored-system projection the runtime uses for final grouped counting. Auto-register may preview its own candidate, but it must not keep a -lifecycle-local counter, drift on source priority, or treat missing monitored- -system usage as zero; when an active cap is present and usage cannot be -resolved, the route must fail closed with retryable unavailable guidance -instead of silently admitting a net-new monitored system. -That same lifecycle boundary also assumes any private monitored-system -admission policy hook stays backend-owned. Even when `pkg/server/server.go` -wires `ResolveMonitoredSystemAdmissionPolicy` into shared `internal/api/` -admission helpers, setup and install flows must continue to source candidate -projection, replacement identity, and usage-availability truth from the -canonical monitored-system preview/resolver path instead of adding a second -lifecycle-local policy branch or exemption rule. +lifecycle-local counter, drift on source priority, or treat missing grouping +usage as a commercial admission state. +The retired private monitored-system admission policy hook must not return as +a lifecycle-local branch or exemption rule. When lifecycle-adjacent setup or support surfaces need to explain why a candidate would count or dedupe, they must consume the shared monitored-system ledger preview contract rather than rebuilding a second preview model from -setup-local transport fields. `frontend-modern/src/components/Settings/MonitoredSystemAdmissionPreview.tsx` +setup-local transport fields. `frontend-modern/src/components/Settings/MonitoredSystemImpactPreview.tsx` is the shared shell for that explanation inside platform-connections settings, so provider-specific panels must not fork their own monitored-system preview copy or inline projected-usage rendering. That shared shell must use neutral count-impact language for ordinary platform -connection previews. Finite policy failures may say the active policy would be -exceeded, but unchanged or removal previews should not describe "capacity" or -raw `current / limit` quota math as the operator-facing mental model for -self-hosted monitoring. -That same commercial readiness boundary now also assumes settled canonical -usage, not the first non-nil monitor view. Lifecycle-owned setup or first-host -surfaces may not seal migrated-v5 continuity, display counted-system totals as -final, or retry admission against a provider-owned supplemental platform such -as TrueNAS or VMware until the monitor has both seen an initial baseline for -every active connection and rebuilt the canonical store at or after that -provider watermark. -That same lifecycle-owned admission surface must keep provider save actions -gated on a successful monitored-system preview. TrueNAS and VMware settings -may not create or update a connection while the admission preview is missing, -loading, unavailable, errored, or over-limit, and save-time backend races must -reuse the same canonical preview/unavailable presentation state instead of -falling back to provider-local billing messages. +connection previews. Previews must not describe "capacity", finite policy +failures, or raw `current / limit` quota math as the operator-facing mental +model for monitoring. +That same grouping readiness boundary assumes settled canonical usage, not the +first non-nil monitor view. Lifecycle-owned setup or first-host surfaces may +not display counted-system totals as final against a provider-owned +supplemental platform such as TrueNAS or VMware until the monitor has both +seen an initial baseline for every active connection and rebuilt the canonical +store at or after that provider watermark. +That same lifecycle-owned preview surface must keep provider save actions +gated on a successful monitored-system grouping preview. TrueNAS and VMware +settings may not create or update a connection while the preview is missing, +loading, unavailable, or errored, and save-time backend races must reuse the +same canonical unavailable presentation state instead of falling back to +provider-local billing messages. That same lifecycle-adjacent request contract now also assumes canonical enablement defaults. New platform-connection preview/test/add payloads must inherit the provider default `enabled=true` when the field is omitted, while @@ -2396,10 +2378,10 @@ Deploy wizard target tables are lifecycle-owned presentation surfaces: `CandidatesStep`, `ConfirmStep`, `PreflightStep`, `DeployingStep`, and `ResultsStep` must use the shared frontend `Table` primitive for scroll and table semantics instead of raw table markup or step-local scroll frames. -Deploy selection and retry capacity feedback may consume the monitored-system -capacity boundary owned by API/cloud-paid, but lifecycle UI must label that -state as workspace capacity and must not reintroduce legacy license-slot or -plan-upgrade language in deploy confirmation, preflight, or retry surfaces. +Deploy selection and retry UI must not consume retired monitored-system +capacity boundaries. Lifecycle UI must avoid workspace-capacity, legacy +license-slot, and plan-upgrade language in deploy confirmation, preflight, or +retry surfaces. That same deploy wizard boundary must also stay on the direct `deploy-fallback-install-surface` proof path, rather than relying only on the shared install helper or downstream deploy tests to catch lifecycle drift in diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index de2f4aea4..0dfca4f29 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -292,6 +292,11 @@ prerelease-shell treatment is the compact `Preview` badge on rc-channel builds; `frontend-modern/src/AppLayout.tsx` must not revive a standalone release-candidate banner, release-notes CTA, or feedback CTA that starts participating in assistant-shell state or modal ownership. +The retired monitored-system capacity banner follows the same shell rule: +`frontend-modern/src/App.tsx` must not reintroduce app-shell commercial +volume warnings just because settings or support surfaces still expose +monitored-system grouping data. Assistant state and shell notices stay +independent from retired infrastructure-volume commerce. That same shared shell boundary must respect blocking modal ownership. `frontend-modern/src/App.tsx` and `frontend-modern/src/AppLayout.tsx` may use the shared dialog runtime to hide the closed assistant launcher and close the diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 2cc2f8ce5..d1013f4a2 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -250,12 +250,13 @@ candidate semantics end to end. `enabled=false` on TrueNAS or VMware preview, test, add, and update payloads must serialize through the shared ledger client as `active:false`, and preview responses may legitimately return `no_change`, `removes_existing`, or `removes_multiple` with empty projected-system lists -when the disabled candidate no longer counts toward monitored-system capacity. -That same monitored-system admission contract now also owns restart-safe host -report continuity at the API boundary. `internal/api/monitored_system_limit_enforcement.go` -must treat a returning standalone host report as existing capacity when -monitoring can match it to recent persisted host continuity, so a server -restart or v6 upgrade does not emit a false over-limit `402` before the live +when the disabled candidate no longer contributes to a monitored-system group. +That same monitored-system grouping contract now also owns restart-safe host +report continuity at the API boundary. The removed monitored-system limit +enforcement path must not return; the API should treat a returning standalone +host report as existing grouping context when monitoring can match it to recent +persisted host continuity, so a server restart or v6 upgrade does not change +the explanatory grouping model before the live inventory rebuild catches up. Genuinely new host identities must still return the canonical monitored-system blocked payload. @@ -877,10 +878,11 @@ the canonical monitored-system blocked payload. ## Current State -Deploy-job monitored-system denials now keep compatibility wire identifiers -such as `skipped_license` and `license_limit`, while user-visible retry errors -and frontend deploy labels must describe the state as monitored-system or -workspace capacity rather than license slots or plan-upgrade pressure. +Deploy-job monitored-system volume denials are retired. API routes may still +accept historical payloads that mention old license-slot terminology for +migration or diagnostics, but runtime deploy responses must not emit +`skipped_license`, `license_limit`, workspace-slot reservations, or +plan-upgrade retry labels for monitored infrastructure volume. `useInfrastructureDiscoveryRuntimeState.ts` no longer gates `/api/discover` polling on a settings tab name; polling is mount-scoped. The tab guard was @@ -916,22 +918,22 @@ normalized version identity fields (`version`, `version_raw`, `version_channel`, `version_build`, `version_is_development`, and `version_is_published_release`) instead of leaving browser callers to infer published-release truth from raw build strings. -That same browser-transport contract now tolerates sparse admission-preview +That same browser-transport contract now tolerates sparse preview payloads without changing the runtime truth. Patrol transport may omit `finding_ids`, and infrastructure removal previews may stage optimistic rows only after canonical IDs have been resolved or a safe row-name fallback has been chosen. API-adjacent browser callers must not reinterpret missing IDs or preview arrays as authoritative empty success. -Monitored-system commercial admission is now also part of that owned live -contract only when a hosted, MSP, or private policy installs an active cap. -Self-hosted Community/Relay/Pro core monitoring remains uncapped; any active -cap path must still project prospective candidates or previewed source records -through the canonical monitored-system resolver before persistence, and -`/api/license/entitlements` must expose `current_available` when that cap -cannot resolve current usage so callers can fail closed without misreading -unavailable usage as a real zero. -That same `current_available` truth now includes supplemental-provider startup -readiness. API contracts must not serialize a live monitored-system count from +Monitored-system commercial admission is retired from that owned live contract. +Community, Relay, Pro, hosted, MSP, and private-policy monitoring routes must +not gate persistence on monitored-system volume. Preview routes may still +project prospective candidates or previewed source records through the +canonical monitored-system resolver for inventory explanation, but they must +not expose cap verdicts, `current_available`, or save-blocking entitlement +state. +Supplemental-provider startup readiness now belongs to the monitored-system +ledger and impact-preview availability states, not to active entitlement limit +fields. API contracts must not serialize a live monitored-system count from the first store-backed read-state when provider-owned inventories such as TrueNAS or VMware have not yet completed an initial baseline and been rebuilt into the canonical monitor store. @@ -1527,13 +1529,13 @@ must update the canonical TrueNAS poll summary owner so subsequent `/api/truenas/connections` reads reflect refreshed last-success or last-error state instead of leaving settings health disconnected from manual operator tests. -That same route family now also owns pre-save monitored-system admission +That same route family now also owns pre-save monitored-system grouping preview. `POST /api/truenas/connections/preview` and `POST /api/truenas/connections/{id}/preview` must return the shared monitored-system ledger preview contract sourced from canonical -unified-resource projection, including current/projected grouped systems and -enforced limit verdicts, rather than page-local settings estimates or -provider-local counters. +unified-resource projection, including current/projected grouped systems rather +than page-local settings estimates, cap verdicts, or provider-local +counters. That same `/api/truenas/connections` list boundary now also owns the operator-facing runtime summary for those configured connections. The list response must carry the canonical redacted config together with poll health @@ -1598,13 +1600,12 @@ remains the saved connection retest surface. The explicit disabled path also stays on this boundary: `404 vmware_disabled` means the operator or runtime has opted out of the default-on VMware candidate, not that the platform requires a different onboarding contract. -That same route family now also owns source-native monitored-system admission +That same route family now also owns source-native monitored-system grouping preview. `POST /api/vmware/connections/preview` and `POST /api/vmware/connections/{id}/preview` must project the discovered provider-backed record set through the shared monitored-system ledger preview -contract before persistence, including current/projected grouped systems and -enforced limit verdicts, rather than collapsing a vCenter add or edit to one -handler-local candidate estimate. +contract before persistence, including current/projected grouped systems rather +than cap verdicts or a handler-local vCenter candidate estimate. That same TrueNAS and VMware platform-connections contract now also owns runtime mock continuity. When `/api/system/mock-mode` flips on a running server, `/api/truenas/connections` and `/api/vmware/connections` must @@ -1718,17 +1719,18 @@ The canonical nested status-reason timestamp is `reported_at`, and the normalized client contract must expose only that field. That same monitored-system ledger contract now also owns prospective explanation. `POST /api/license/monitored-system-ledger/preview` must accept -one canonical candidate plus an optional structured replacement selector, fail -closed when monitored-system usage is unavailable, and return the canonical -current/projected count delta, enforced limit verdict, effect label, and +one canonical candidate plus an optional structured replacement selector and +return the canonical current/projected count delta, effect label, and current/projected ledger entries produced by the shared monitored-system -projection layer instead of by handler-local heuristics. +projection layer instead of by handler-local heuristics. It must not return an +enforced-limit verdict or make persistence depend on a commercial volume +decision. Configured Proxmox, PBS, and PMG update handlers in -internal/api/config_node_handlers.go must use that same structured -replacement-selector contract when they enforce monitored-system admission: +internal/api/config_node_handlers.go may use that same structured +replacement-selector contract when they explain monitored-system grouping: source-owned names, host URLs, hostnames, and resource identifiers may cross the API boundary, but handler-local matcher closures must not become the -source of truth for replacement identity. +source of truth for replacement identity or save-time admission. Provider-backed preview routes such as `/api/truenas/connections/preview`, `/api/truenas/connections/{id}/preview`, `/api/vmware/connections/preview`, and `/api/vmware/connections/{id}/preview` must serialize that same canonical @@ -1994,94 +1996,31 @@ Proxmox auto-register requests must use the canonical `authToken` request field for one-time setup-token auth instead of any API-token auth header path, so the canonical API surface does not preserve parallel auth transports or a second auth meaning for the same field. -The self-hosted commercial entitlement payload now also uses one canonical -counted-unit contract: `max_monitored_systems` is the live runtime and -frontend term, and older `max_agents` or `max_nodes` aliases may be decoded -only at explicit legacy import boundaries. Limit `current` values, add-node -enforcement, auto-register enforcement, deploy-slot enforcement, the -monitored-system ledger endpoint, and TrueNAS/API-backed registration must all -reflect deduped top-level monitored systems rather than agent-only -installation count, and `legacy_connections` / `has_migration_gap` may not -imply that API-backed monitoring sits outside the commercial cap. -Deploy-job compatibility enums such as `skipped_license` and `license_limit` -may remain stable wire identifiers, but human-readable retry errors and -frontend deploy labels must describe the condition as monitored-system or -workspace capacity rather than as license slots or a plan upgrade prompt. -That same contract now also owns prospective admission and replacement -projection. Config-backed PVE/PBS/PMG, TrueNAS, VMware, and other API-backed -registration or update routes must project candidates or preview records -through the canonical monitored-system resolver before persistence, including -replacement of one existing source-owned surface, instead of rebuilding -handler-local priority tables or platform-specific counters. -That same admission contract now also owns replacement identity. Shared API -handlers may keep source-local request decoding, but the replacement they pass -into monitored-system projection must travel as one canonical structured -selector contract rather than as per-handler opaque match logic, so support -preview, limit enforcement, and final runtime grouping stay aligned. -When an active monitored-system cap is present and current usage cannot be -resolved, those API contracts must fail closed for net-new admissions rather -than serializing a fake zero. `/api/license/entitlements` therefore carries -limit-level `current_available` truth so clients can distinguish unavailable -monitored-system usage from a real `current: 0`. -That same entitlement family now also owns the canonical monitored-system -capacity posture. `/api/license/runtime-capabilities`, -`/api/license/commercial-posture`, and `/api/license/entitlements` must expose -`monitored_system_capacity` with one shared admission model: -`usage_unavailable`, `unlimited`, `within_limit`, `at_limit_blocking_new`, or -`over_limit_frozen`. That contract must state whether new monitored systems are -blocked and whether existing monitoring continues, so browser surfaces stop -guessing from raw `current / limit` math or inventing a hard-cap model that -the backend does not enforce. -That same contract must also make over-limit legitimacy explicit. When -`monitored_system_capacity` is at or above a capped plan boundary, the payload -must expose `reason` as `limit_reached`, `preexisting_usage`, or -`legacy_migration_capture_pending` so browser surfaces can distinguish a full -plan boundary from a frozen above-plan carry-forward and from migrated legacy -continuity that is still being verified. -That same admission family also owns the private monitored-system policy hook -boundary. `internal/api/enterprise_extension_monitored_system_admission.go` -may register one private `ResolveMonitoredSystemAdmissionPolicy` hook through -`pkg/extensions/monitored_system_admission.go`, but that hook must consume the -canonical counted-system input already resolved by shared API admission -helpers. Private builds may not use that extension point to invent -provider-local counters, replacement semantics, or usage-availability fallbacks -that diverge from the shared monitored-system resolver. `pkg/server/server.go` -may wire the hook during startup, but public runtime still owns counted-system -projection unless and until a later governed enforcement slice actually routes -live admission through that private decision boundary. -That same contract now also owns migrated legacy continuity. When a supported -v5 license auto-exchanges or is activated manually in v6, `/api/license/status` -and `/api/license/entitlements` must surface `max_monitored_systems` from the -greater of the exchanged plan limit and the one-time deduped monitored-system -floor captured from canonical runtime usage, and restored grant activations -must backfill that floor once canonical usage becomes available instead of -falling back to the raw exchanged grant limit after restart. -That migration capture must wait for settled canonical usage, not merely the -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. Those same read handlers must -also stay side-effect free with respect to the reconciler lifecycle itself: -they may observe pending continuity state, but only activation-state -transitions such as activate, restore, grant refresh, and clear/revocation may -bootstrap or tear down the pending-floor reconcile loop. -Continuity capture is itself an activation-state mutation: after the reconciler -persists the one-time floor, the service callback must publish the updated -activation state so ownership can cancel the pending loop without making -ordinary billing reads restart or stop it. -When save-time monitored-system admission fails with a commercial denial, the -structured API error must preserve the canonical `monitored_system_preview` -object through `frontend-modern/src/utils/apiClient.ts` and -`frontend-modern/src/api/responseUtils.ts` so platform settings can render the -same current/projected verdict instead of falling back to generic license copy. +The self-hosted commercial entitlement payload now has no monitored-system +counted-unit contract. `max_monitored_systems`, `max_agents`, and `max_nodes` +may be decoded only at explicit legacy import, purchase-return, or scrubbing +boundaries, and must not be re-emitted as active runtime limits. Add-node, +auto-register, deploy, TrueNAS, VMware, Kubernetes, Docker, and other platform +registration routes must accept net-new monitored systems without commercial +volume admission, while the monitored-system ledger remains an explanatory +inventory/debug surface only. +That same retired-cap contract owns prospective grouping and replacement +projection without admission enforcement. Config-backed PVE/PBS/PMG, TrueNAS, +VMware, and other API-backed registration or update routes may preview +current/projected monitored-system grouping through the canonical resolver, but +the preview cannot carry `limit`, `would_exceed_limit`, commercial-denial +state, or any save-time cap override. Save-time errors should be ordinary +platform, auth, validation, or readiness failures; the shared frontend API +error path must not preserve a monitored-system cap preview from old 402 +payloads. +Legacy v5/v6 continuity metadata is now scrub-only. `/api/license/status`, +`/api/license/runtime-capabilities`, `/api/license/commercial-posture`, and +`/api/license/entitlements` must not expose monitored-system capacity posture, +grandfathered floors, current/unavailable limit fields, or admission-freeze +copy. Historical `max_monitored_systems` values can remain in tests and +migration fixtures only to prove they are deleted before runtime +entitlements, claims, billing-state responses, or frontend presentation see +them. 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 @@ -3110,9 +3049,10 @@ explicit empty state rather than leaking paid history totals through a partial payload. Hosted billing-state payloads now also treat Stripe webhook-backed commercial state as canonical API contract data: when checkout and subscription webhooks -persist paid state, `plan_version`, `stripe_price_id`, and `limits.max_monitored_systems` -must stay aligned instead of emitting paid-state payloads with an empty limits -map or stale canceled-state carryover. +persist paid state, `plan_version`, `stripe_price_id`, and active paid-feature +limits must stay aligned while retired monitored-system volume keys are +scrubbed instead of being restored from stale checkout or canceled-state +carryover. That same hosted billing API boundary also owns runtime base-path resolution: `internal/api/payments_webhook_handlers.go` must derive webhook dedupe and customer-index storage from the shared runtime data-dir helper in @@ -3473,17 +3413,14 @@ The adjacent public-demo admin-operations policy also hides `GET`/`HEAD` probes for `/api/admin/users` and `/api/discover` with the same generic `404` posture, while non-read attempts still fall through to the demo read-only mutation block so write probes retain the canonical `403`. -That same monitored-system admission contract now also owns direct write-path -failure semantics for platform connections. `internal/api/truenas_handlers.go`, -`internal/api/vmware_handlers.go`, -`internal/api/monitored_system_limit_enforcement.go`, and -`internal/api/contract_test.go` must keep TrueNAS and VMware connection -creates/updates fail-closed with `monitored_system_usage_unavailable` whenever -the canonical monitored-system usage view is unsettled or rebuilding. VMware -write admission must check that canonical usage state before collecting -external vCenter inventory, so direct API callers cannot receive provider -connection errors or persist connections while capacity accounting is unsafe. -That same browser-transport contract now tolerates sparse admission-preview +That same monitored-system inventory contract now also owns direct write-path +semantics for platform connections. `internal/api/truenas_handlers.go`, +`internal/api/vmware_handlers.go`, and `internal/api/contract_test.go` must +allow TrueNAS and VMware connection creates/updates without monitored-system +volume admission, even when the explanatory monitored-system view is unsettled +or rebuilding. VMware writes should report provider validation failures from +the provider path itself rather than masking them behind capacity accounting. +That same browser-transport contract now tolerates sparse preview payloads without changing the runtime truth. Patrol transport may omit `finding_ids`, and infrastructure removal previews may stage optimistic rows only after canonical IDs have been resolved or a safe row-name fallback has diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index a4b11e47d..5e921cfb4 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -65,7 +65,7 @@ cloud-specific enforcement rules. 42. `frontend-modern/src/components/Settings/OrganizationBillingPanel.tsx` 43. `frontend-modern/src/components/Settings/OrganizationBillingLoadingState.tsx` 44. `frontend-modern/src/components/Settings/MonitoredSystemLedgerPanel.tsx` -45. `frontend-modern/src/components/Settings/MonitoredSystemAdmissionPreview.tsx` +45. `frontend-modern/src/components/Settings/MonitoredSystemImpactPreview.tsx` 46. `frontend-modern/src/components/Settings/ProLicensePanel.tsx` 47. `frontend-modern/src/components/Settings/ProLicensePlanSection.tsx` 48. `frontend-modern/src/components/Settings/CommercialBillingSections.tsx` @@ -113,7 +113,7 @@ cloud-specific enforcement rules. ## Shared Boundaries -1. `frontend-modern/src/components/Settings/MonitoredSystemAdmissionPreview.tsx` shared with `agent-lifecycle`: the monitored-system admission preview is both a platform-connections lifecycle surface and a canonical cloud-paid monitored-system presentation boundary. +1. `frontend-modern/src/components/Settings/MonitoredSystemImpactPreview.tsx` shared with `agent-lifecycle`: the monitored-system impact preview is both a platform-connections lifecycle surface and a canonical cloud-paid monitored-system presentation boundary. 2. `frontend-modern/src/useAppRuntimeState.ts` shared with `performance-and-scalability`: the authenticated app runtime bootstrap is both a hosted commercial org-context boundary and a protected app-shell performance boundary. 3. `internal/api/licensing_bridge.go` shared with `api-contracts`: commercial licensing bridge handlers carry both API payload contract and cloud-paid entitlement boundary ownership. 4. `internal/api/licensing_handlers.go` shared with `api-contracts`: commercial licensing handlers carry both API payload contract and cloud-paid entitlement boundary ownership. @@ -321,7 +321,7 @@ or other self-hosted uncapped continuity plans. show active historical `subscription_state=trial` entitlement state, but they must not turn `trial_eligible`, `trial_eligibility_reason`, or an expired trial marker into a default Pro CTA or banner. -17. Add or change monitored-system ledger, disclosure, or admission-preview presentation through `frontend-modern/src/components/Settings/MonitoredSystemLedgerPanel.tsx`, `frontend-modern/src/components/Settings/MonitoredSystemAdmissionPreview.tsx`, `frontend-modern/src/components/Commercial/MonitoredSystemDefinitionDisclosure.tsx`, and `frontend-modern/src/utils/monitoredSystemPresentation.ts` +17. Add or change monitored-system ledger, disclosure, or impact-preview presentation through `frontend-modern/src/components/Settings/MonitoredSystemLedgerPanel.tsx`, `frontend-modern/src/components/Settings/MonitoredSystemImpactPreview.tsx`, `frontend-modern/src/components/Commercial/MonitoredSystemDefinitionDisclosure.tsx`, and `frontend-modern/src/utils/monitoredSystemPresentation.ts` 18. Add or change paid relay settings and pairing presentation through `frontend-modern/src/components/Settings/RelaySettingsPanel.tsx`, `frontend-modern/src/components/Settings/RelayPairingSection.tsx`, and `frontend-modern/src/components/Settings/useRelaySettingsPanelState.ts`. The retired Dashboard shell must not be restored to carry a Relay onboarding card or equivalent blanket upsell — relay discovery stays inside its owning settings surface. Public demo and other read-only presentation policy states must suppress relay setup and upsell onboarding instead of inviting pairing or commercial @@ -376,38 +376,18 @@ or other self-hosted uncapped continuity plans. the section, but that summary treatment must stay inside the same task surface instead of reviving a separate overview panel, summary strip, or metric deck ahead of the real workspace list. -23. Keep self-hosted monitored-system capacity review informational and - non-commercial. Recognized self-hosted v6 tiers must treat legacy - monitored-system limit, continuity, or `max_monitored_systems` metadata - as support/audit context rather than as the customer-facing plan model: - Community, Relay, Pro, Pro+, lifetime, and eligible grandfathered recurring - plan labels render through the normal plan surface as core monitoring plus - tier-specific extras, with no standing Usage subtab, finite - policy banner, monitored-system limit row, or pause-new-admissions copy from - stale volume metadata. Retired `max_monitored_systems` pricing handoffs - must land on the neutral self-hosted plan surface, not the usage ledger. - Any genuinely bounded monitored-system support context outside those - recognized plan labels may still land on the explicit usage-focused billing - state (`/settings/system/billing/usage?details=counting-rules`) so the - operator can inspect counting rules and any support/audit policy context - without being pushed toward a purchase. Monitored-system warnings must not surface - `Upgrade to add more`, `Compare self-hosted plans`, or the - `intent=self_hosted_plan` plan-selection arrival. `frontend-modern/src/components/Settings/ProLicensePanel.tsx`, - `frontend-modern/src/components/Settings/useProLicensePanelState.ts`, and - `frontend-modern/src/utils/pricingHandoff.ts` therefore own a two-state - billing focus model where stale monitored-system pricing aliases resolve - to `plan`, explicit usage routes remain available only for displayable - bounded support context after recognized self-hosted plans have been - normalized, and plan selection is reserved for explicit self-hosted - Pro/commercial-extra arrivals. That same self-hosted - commercial boundary also owns legacy - migration continuity semantics: `legacy_migration_fallback` may preserve - `plan_limit` and `grandfathered_floor` for support/audit context, but - self-hosted v6 monitoring remains uncapped. The canonical contract is - therefore `status.max_monitored_systems = 0`, no enforced - `max_monitored_systems` entitlement row, and - `monitored_system_capacity.mode = unlimited` once runtime usage is - available. +23. Keep monitored-system volume out of the commercial product model. + Recognized self-hosted and hosted v6 tiers must treat legacy + monitored-system limit, continuity, or `max_monitored_systems` metadata as + scrub-only compatibility input rather than as support/audit policy or a + customer-facing plan model. Community, Relay, Pro, Pro+, lifetime, + grandfathered recurring, Cloud, and MSP plan labels render through their + normal plan surfaces with no Usage subtab, finite policy banner, + monitored-system limit row, grandfathered-floor summary, or + pause-new-admissions copy from stale volume metadata. Retired + `max_monitored_systems` pricing handoffs must land on the neutral + self-hosted plan surface and be normalized to plan selection rather than a + monitored-system usage ledger. That same self-hosted commercial boundary also owns the operator-value narrative across the in-app billing shell, Pulse Account handoff, public pricing contract, and owned upgrade reasons. Customer-facing self-hosted @@ -515,28 +495,17 @@ or other self-hosted uncapped continuity plans. 7. Keep Pro+ app presentation continuity-only: customer-facing tier, plan, and plan-version labels for `pro_plus` must include legacy framing while still mapping the entitlement to the Pro runtime feature set. -8. Keep persisted billing baselines and live recurring continuity distinct: - `pkg/licensing/billing_state_normalization.go` must store the canonical - monitored-system billing baseline for recognized grandfathered recurring - v5/v1 Stripe plans so webhook persistence and admin-visible hosted billing - state stay deterministic, while `pkg/licensing/database_source.go`, - `pkg/licensing/models.go`, and downstream runtime entitlement evaluation - must strip that stored cap before enforcement so active recurring - grandfathered continuity remains uncapped until cancellation. -9. Keep legacy migration fallback continuity audit-only on self-hosted v6: - `legacy_migration_fallback` may preserve `plan_limit`, - `grandfathered_floor`, and related support telemetry, but runtime status - and entitlement enforcement must keep self-hosted monitoring uncapped. - The canonical contract is `status.max_monitored_systems = 0`, no enforced - `max_monitored_systems` entitlement row, and - `monitored_system_capacity.mode = unlimited` once runtime usage is - available. The in-app self-hosted Plans surface must apply the same rule: - recognized Community, Relay, Pro, Pro+, lifetime, and eligible grandfathered - recurring plans must ignore stale legacy volume metadata for customer-facing - plan presentation, must render the current plan ladder, and must not show a - Usage tab, monitored-system policy banner, `Plan Monitored System Limit`, - `Effective Monitored System Limit`, grandfathered-floor summary, or - pause-new-admissions copy from that metadata. +8. Keep persisted billing baselines and live recurring continuity scrubbed: + `pkg/licensing/billing_state_normalization.go`, + `pkg/licensing/database_source.go`, `pkg/licensing/models.go`, and + downstream runtime entitlement evaluation must delete retired + monitored-system volume keys before runtime status, grants, leases, or + frontend billing payloads are built. +9. Keep legacy migration fallback continuity compatibility-only on v6: + `legacy_migration_fallback` may be parsed so old activation files and + billing records load, but `plan_limit`, `grandfathered_floor`, and related + volume telemetry must not become runtime status, entitlement enforcement, + customer-facing support context, or self-hosted Plans presentation. 10. Keep Stripe webhook idempotency state bounded in the control-plane registry: `internal/cloudcp/registry/registry.go` may retain `stripe_events` rows long enough to suppress duplicate deliveries and reclaim stale @@ -547,20 +516,20 @@ or other self-hosted uncapped continuity plans. frontend hash or emitted manifest must rebuild `internal/cloudcp/portal/dist/build_manifest.json` and keep `internal/cloudcp/portal/frontend_sync_test.go` green in the same change. -12. Before GA, treat self-hosted core monitoring as free for homelab use: - monitored systems remain the canonical counted unit, but self-hosted paid +12. Before GA, treat infrastructure monitoring volume as unmetered: + monitored systems remain the canonical inventory grouping unit, but paid value must come from optional extras, hosted convenience, business workflow, support, or similar non-core surfaces rather than using - monitored-system volume itself as the primary paid gate. + monitored-system volume itself as a paid gate. Child-resource volume, including guest capacity, must follow the same self-hosted rule instead of becoming a replacement paid gate for core monitoring. - Monitored-system admission-preview copy must follow the same rule: ordinary - connection previews may describe count impact and active policy checks, but + Monitored-system impact-preview copy must follow the same rule: ordinary + connection previews may describe count impact and grouping changes, but they must not use capacity-style titles or slash-style quota summaries that imply self-hosted monitoring volume is the product being sold. -13. Keep self-hosted v6 billing state uncapped even when persisted state still - carries legacy v5 commercial volume-limit keys: +13. Keep v6 billing state uncapped even when persisted state still carries + legacy commercial volume-limit keys: `pkg/licensing/models.go`, `pkg/licensing/service.go`, `pkg/licensing/entitlement_payload.go`, @@ -568,12 +537,10 @@ or other self-hosted uncapped continuity plans. `pkg/licensing/database_source.go`, `pulse-pro:license-server/v6_store.go`, and `pulse-pro:license-server/v6_schema.go` must scrub stale - `max_monitored_systems` and `max_guests` values for self-hosted - Community/free, Relay, Pro, Pro+, Pro Annual, lifetime, and eligible - grandfathered recurring plan labels before runtime-capability, - entitlement, grant, or warning-banner payloads are built, while leaving - bounded hosted Cloud/MSP contracts available for top-level hosted - monitored-system ceilings. + `max_monitored_systems` values for Community/free, Relay, Pro, Pro+, Pro + Annual, lifetime, eligible grandfathered recurring, Cloud, and MSP plan + labels before runtime-capability, entitlement, grant, lease, billing, or + browser-facing payloads are built. The same boundary must merge sparse legacy, configured-plan, or manually supplied feature lists with canonical recognized-tier defaults before storage, API response, or grant signing so Lifetime, Pro, Pro+, and @@ -826,8 +793,8 @@ drawer state, and org-context route updates do not present as a full-page refresh. Persisted billing state is now also part of that canonical boundary: when a recognized Cloud/MSP plan version is loaded or saved, the stored `plan_version` -must canonicalize and `limits.max_monitored_systems` must reconcile to the authoritative -per-plan contract rather than preserving stale ad hoc values. +must canonicalize and retired monitored-system volume limit keys must be +scrubbed rather than reconciled back into runtime state. That same persisted billing boundary now also applies to hosted entitlement lease secrets: `internal/config/billing_state.go` may keep `EntitlementJWT` and `EntitlementRefreshToken` in runtime billing state, but `billing.json` may @@ -895,21 +862,21 @@ That prelaunch gate must not disable `/api/public/magic-link/request`, because existing hosted commercial accounts still depend on the public portal sign-in flow before v6 public Cloud checkout is opened. Signed hosted entitlement leases are part of the same boundary: lease signing -and verification must canonicalize recognized Cloud plan aliases and reconcile -lease `limits.max_monitored_systems` to the authoritative per-plan contract instead of -trusting stale embedded values. They also must not fabricate `plan_version` -from bare `subscription_state` when the signed lease claim label is absent. +and verification must canonicalize recognized Cloud plan aliases and delete +retired monitored-system volume keys instead of trusting stale embedded values. +They also must not fabricate `plan_version` from bare `subscription_state` +when the signed lease claim label is absent. The control-plane registry is also canonical: tenant and Stripe-account `plan_version` rows must canonicalize recognized Cloud aliases on read and write so stored legacy values cannot re-enter provisioning, entitlement, or limit-enforcement fallbacks. JWT-backed entitlement claims are also canonical: when runtime evaluation uses claim `plan_version` and `limits`, recognized Cloud plan aliases must -canonicalize and `max_monitored_systems` must reconcile to the authoritative per-plan -contract instead of trusting stale embedded claim values. When a Cloud/MSP -claim arrives without a recognized plan label, runtime must preserve the -missing/unknown `plan_version` metadata but still fail closed on `max_monitored_systems` -instead of drifting to an unlimited tier default. +canonicalize and retired monitored-system volume keys must be removed instead +of trusting stale embedded claim values. When a Cloud/MSP claim arrives without +a recognized plan label, runtime must preserve the missing/unknown +`plan_version` metadata while still treating monitored-system volume as +unmetered. Activation-grant translation is part of the same boundary: when relay/license server grants enter the local claims model, Cloud plan keys and lifecycle state must still resolve through the canonical entitlement claim accessors rather @@ -930,36 +897,26 @@ Pulse runtime must not call `POST /v1/quickstart/bootstrap`, must not persist quickstart-backed AI config, and must not mint hosted-model tokens from hosted or self-hosted billing state. Historical quickstart credit fields may remain parseable in billing state only so old HMAC-protected files can still load. -The self-hosted commercial counted unit is now also locked to monitored -systems rather than agent installs. `max_monitored_systems` is the live -runtime and UI contract, while legacy `max_agents` / `max_nodes` aliases are -decode-only compatibility inputs at the storage or grant boundary. Runtime -enforcement, entitlement payload `current` usage, checkout/activation flows, -and upgrade messaging must all treat the cap as deduped top-level monitored -systems across agent, API, and Kubernetes views. -That same counted-unit contract also owns the Pulse Account monitored-system -upgrade copy. Portal shell copy, pricing explainers, and monitored-system -upgrade helper text must describe top-level monitored systems and included -child resources directly, with concrete monitored roots such as Docker hosts, -Kubernetes clusters, Proxmox nodes, standalone hosts, and TrueNAS systems, -rather than drifting back to device-style language or generic allowance-only -copy that hides what the counted unit actually is. -That same counted-unit contract now also owns prospective API-backed -admission. Proxmox/PBS/PMG config adds, TrueNAS adds, VMware inventory -previews, and equivalent updates must ask the canonical monitored-system -projection whether they increase counted systems before the runtime persists -them, including replacement-aware projections for source swaps on an existing -grouped host. -Under an active monitored-system cap, inability to resolve current usage is -not a free pass. Runtime enforcement and entitlement payloads must fail closed -for net-new monitored-system admissions and surface usage availability -explicitly through the governed limit payload instead of treating unavailable -usage as `current: 0`. -The monitored-system ledger settings surface now also has to explain those -count decisions. Commercial usage UI may show grouped monitored systems, but -it must render the canonical backend explanation for why one or more -top-level views counted as a single monitored system instead of inventing -support copy or merge heuristics in the frontend. +The self-hosted commercial counted-unit contract is retired. Monitored systems +remain a canonical inventory grouping and support-debugging concept, but +`max_monitored_systems`, legacy `max_agents`, and legacy `max_nodes` are +decode-only compatibility inputs at the storage, grant, or purchase boundary. +Runtime enforcement, entitlement payload current usage, checkout/activation +flows, and upgrade messaging must not treat monitored-system volume as a cap. +Portal shell copy, pricing explainers, and upgrade helper text must focus on +paid feature value rather than monitored-system expansion. +That same retired counted-unit contract still owns prospective grouping +preview. Proxmox/PBS/PMG config adds, TrueNAS adds, VMware inventory previews, +and equivalent updates may ask the canonical monitored-system projection how a +connection groups into top-level systems before persistence, including +replacement-aware projections for source swaps on an existing grouped host, +but those previews are informational and cannot block a save for commercial +volume reasons. +The monitored-system ledger settings surface now also explains those grouping +decisions. Support UI may show grouped monitored systems, but it must render +the canonical backend explanation for why one or more top-level views group as +a single monitored system instead of inventing support copy or merge heuristics +in the frontend. That billing support surface must also remain readable while mixed-version clients and servers roll forward: missing explanation payloads may degrade to a safe generic explanation, but the monitored-system ledger must never fail the @@ -999,9 +956,9 @@ GitHub `main`, so the recovery trust surface stays version-matched and available on restricted installs. Already-issued legacy hosted trial leases are also part of that same compatibility contract. If such a lease is refreshed, it must carry the -canonical Pro capability set and the authoritative -`limits.max_monitored_systems` cap inside the signed lease rather than relying -on downstream runtime fallback to infer a limit from trial state alone. +canonical Pro capability set while scrubbing retired monitored-system volume +limits rather than relying on downstream runtime fallback to infer commercial +state from trial metadata. The top-level authenticated shell is part of that same customer-facing boundary: cloud-paid trial prompts may appear in owned commercial surfaces, but the app shell must not force a global, persistent Pro trial nudge that @@ -1224,22 +1181,21 @@ presentation helper. `frontend-modern/src/utils/monitoredSystemPresentation.ts` is the canonical owner for monitored-system brief/disclosure copy, ledger labels, safe fallback summaries, source/type attribution wording, and the customer-facing monitored-system usage/migration strings reused by the shared -limit-warning banner, so the settings panel, Pro usage section, counting-rules -disclosure, and shared warning-banner model must consume that helper instead -of redefining customer-facing monitored-system copy inline or keeping a -parallel copy in generic self-hosted plan utilities. +settings and support ledger surfaces, so those surfaces must consume that +helper instead of redefining customer-facing monitored-system copy inline or +keeping a parallel copy in generic self-hosted plan utilities. That same helper also owns preview-impact copy and current/projected -source-label wording for pre-save monitored-system admission UI, so the +source-label wording for optional monitored-system impact previews, so the TrueNAS and VMware settings panels do not drift into provider-local billing -phrasing when they surface canonical preview results before save. -Admission preview summaries must also come from that helper and describe -current/projected count impact without raw `current / limit` quota math. -That same helper-owned admission-preview contract now also owns required- -preview, unavailable-capacity, and save-blocking messages. Provider settings +phrasing when they surface canonical preview results. Impact preview summaries +must also come from that helper and describe current/projected count impact +without raw `current / limit` quota math. +That same helper-owned impact-preview contract now also owns unavailable +verification messages. Provider settings panels must map `monitored_system_usage_unavailable` plus backend `details.reason` through `frontend-modern/src/utils/monitoredSystemPresentation.ts`, -render helper-owned required-preview guidance before the first safe preview, -and keep save disabled until the shared preview state resolves safely. +but those preview failures are explanatory only and must not disable or block +connection saves. That same disclosure surface must not accept arbitrary caller-supplied monitored-system summary strings as its primary API. When the disclosure needs to show brief summary copy, it should render the canonical helper-owned brief @@ -1255,24 +1211,21 @@ systems. Rows must expose backend-authored `explanation.summary`, reasons, and grouped source surfaces through helper-owned labels so customers can see why a system counts once without the panel inventing a second counting model. When canonical usage is not safe to read yet and the backend returns -`monitored_system_usage_unavailable` or entitlements mark the monitored-system -current count unavailable, the usage surface must render helper-owned -verification copy instead of a synthetic `0 / limit` ledger total. The -canonical `current_available` interpretation, unavailable reason mapping, -plan-section usage summary, remaining-capacity copy, and upgrade-pressure -urgency decision all belong to -`frontend-modern/src/utils/monitoredSystemPresentation.ts`; Pro license panels, -ledger panels, and shared warning-banner plumbing must consume that helper -instead of rechecking entitlement availability or hard-coding `Verifying…`, -`Unavailable`, or `0 / limit` formatting locally. +`monitored_system_usage_unavailable`, the support ledger must render +helper-owned verification copy instead of a synthetic count. The canonical +unavailable reason mapping belongs to +`frontend-modern/src/utils/monitoredSystemPresentation.ts`; Pro license panels +and ledger panels must consume that helper instead of rechecking availability +or hard-coding `Verifying...` / `Unavailable` formatting locally. The entitlement payload builder must only mark monitored-system usage available from an explicit canonical `MonitoredSystemsAvailable` signal supplied by the runtime usage boundary. Deprecated compatibility aliases such as `Nodes`, or a non-zero raw monitored-system count without that availability signal, must not drive current usage, limit state, or customer-facing cap warnings. -The same usage view must also render monitored-system continuity context from -the entitlement payload, including the base plan limit, effective limit, -grandfathered floor, and capture state, so migration protection is visible +The same usage view must not render monitored-system continuity context from +the entitlement payload as a live commercial cap. Base plan limits, effective +limits, grandfathered floors, and capture state are retired compatibility +metadata, not customer-facing self-hosted capacity controls. beside the canonical count explanation. That same settings owner split now also requires `frontend-modern/src/components/Settings/MonitoredSystemLedgerPanel.tsx` to @@ -1487,7 +1440,7 @@ commercial surface differently. That same shared presentation owner also carries the canonical cross-surface referral copy used outside the billing shell itself. When infrastructure or other adjacent settings surfaces need to point operators toward Plans & Billing -for billing, monitored-system limits, or license status, they must consume the +for billing, license status, or paid feature activation, they must consume the settings-owned referral strings from `frontend-modern/src/components/Settings/selfHostedBillingPresentation.ts` instead of drafting route-local commercial guidance or reaching directly into @@ -1512,24 +1465,14 @@ carry local-only legacy-migration continuity metadata as a defensive fallback for any bounded legacy grant that survives outside the canonical v5 recurring contracts, but active recurring v5/v1 customers must not rely on a captured floor to stay admissible in v6. -That fallback continuity path is reconciler-owned rather than read-owned. -Ordinary status or entitlement reads may expose pending continuity state for a -bounded legacy fallback, 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. The reconcile loop itself is -activation-state-owned as well: activation, restore, grant refresh, and -revocation/clear transitions may start or stop continuity reconciliation, but -ordinary billing reads must stay observer-only and must not bootstrap that -background work on demand. -That fallback continuity path must notify through that same activation-state -ownership boundary after it persists the grandfather floor, so the reconciler -can stop because state changed rather than because a later status or -entitlements read happened to observe the captured floor. -Save-time monitored-system commercial denials must carry the canonical -`monitored_system_preview` object through the shared frontend API error path, -so TrueNAS and VMware settings render the same helper-owned projected-usage -explanation after a rejected save that they render after an explicit preview. +That fallback continuity path is scrub-owned rather than read-owned. Ordinary +status or entitlement reads may tolerate old continuity fields while loading +legacy records, but they must not persist grandfather floors, start capacity +reconciliation, or expose monitored-system continuity as live commercial state. +Save-time monitored-system commercial denials are retired. TrueNAS and VMware +settings may render explicit preview results for grouping impact, but rejected +saves must flow through ordinary API error handling rather than a preserved +monitored-system cap preview. 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 @@ -1573,20 +1516,17 @@ reported 14-day history entitlement. Pro proof adds the reported 90-day history entitlement plus root-cause/remediation and team/admin capability groups. Frontend presentation and component tests must cover active, partial, and Community-suppressed proof states whenever this panel changes. -That same settings-owned presentation must distinguish between active -grandfathered recurring v5 continuity and stale bounded legacy fallback -metadata. Active grandfathered recurring v5 plans must render the existing -recurring price continuity directly and must not show a pending or captured -floor banner or any finite self-hosted volume cap. Recognized self-hosted v6 -plan labels must follow the same customer-facing no-cap rule even if a -`legacy_migration_fallback` entitlement still carries `plan_limit`, -`effective_limit`, `grandfathered_floor`, or capture-pending telemetry: -`useProLicensePanelState.ts` must suppress the Usage tab, monitored-system -policy section, continuity notice, plan-limit detail rows, and -pause-new-admissions copy, while `licensePresentation.ts` must keep current -plan summaries focused on core monitoring plus the actual tier -extras. Bounded fallback continuity may only be displayed for a support context -that is not already normalized to a recognized self-hosted v6 package. +That same settings-owned presentation must distinguish active commercial +features from stale bounded legacy fallback metadata. Active grandfathered +recurring v5 plans must render the existing recurring price continuity +directly and must not show a pending or captured floor banner or any finite +monitored-system volume cap. Recognized v6 plan labels must follow the same +customer-facing no-cap rule even if a legacy entitlement still carries +`plan_limit`, `effective_limit`, `grandfathered_floor`, or capture-pending +telemetry: `useProLicensePanelState.ts` must suppress Usage tabs, +monitored-system policy sections, continuity notices, plan-limit detail rows, +and pause-new-admissions copy, while `licensePresentation.ts` must keep current +plan summaries focused on core monitoring plus the actual tier extras. When billing or support presentation still needs a capacity-free self-hosted summary value, the customer-facing label is `Not metered`, not `Unlimited`; lifetime commercial continuity duration may use `Permanent`, but no normal @@ -1626,7 +1566,7 @@ it as a current public Pulse Pro+ package. When direct plan-selection intent opens the explicit self-hosted comparison surface, the shared presentation helpers must show Pro's operations, admin, and reporting extras together while still framing Community, Relay, and Pro as core monitoring included in every -self-hosted tier rather than monitored-system capacity tiers. Relay copy in +self-hosted tier rather than monitored-system allowance tiers. Relay copy in that shared owner must describe the current v6 GA product as standard Relay remote access, supported Pulse Mobile pairing for handoff, push notifications, and 14-day history, and it must not market a customer-specific Relay URL until that @@ -1689,26 +1629,22 @@ remediation, safe remediation execution, and higher autonomy remain paid AI-operations features. Those docs should describe moving between available modes, not tell readers to "upgrade" as part of an ordinary safety progression. -That same counted-unit boundary also owns the disclosure rule for retail copy: -default billing and pricing surfaces should use concise monitored-system copy, -while the full counted-unit definition appears only behind explicit disclosure -such as `View counting rules` on the usage-owned monitored-system surfaces -instead of sitting as persistent plan-tab chrome. +That same retired counted-unit boundary also owns the disclosure rule for +retail copy: default billing and pricing surfaces should not use +monitored-system allowance copy. Monitored-system grouping definitions belong +only behind explicit support/ledger disclosures rather than persistent +plan-tab chrome. The same boundary also owns where monitored-system continuity truth lives. A dedicated self-hosted Pro plan-surface continuity section is only canonical when -Pulse is reconciling bounded legacy migration continuity or other explicit -carry-forward support context. Normal self-hosted plans should not keep a -`Monitoring capacity` section alive just to restate that monitoring is included -without a monitored-system volume gate; those plan surfaces should reserve -counted-unit explanation plus current usage inspection for the legacy -ledger/disclosure path. When a carried-forward baseline needs review, the -section must explain that existing monitoring remains visible while new -top-level additions wait for continuity review or verification, not that the -customer has hit a current self-hosted plan quota. -The app-shell monitored-system warning entry point must also use that same -shape: urgent legacy-continuity states review the usage-owned ledger, not the -plan-selection surface, and the CTA must not revive "View capacity" copy as an -upsell-shaped monitored-system prompt. +Pulse is reconciling explicit carry-forward support context outside normal +self-hosted monitoring. Normal self-hosted plans must not keep a `Monitoring +capacity` section alive just to restate that monitoring is included without a +monitored-system volume gate; counted-unit explanation plus current usage +inspection belongs to the ledger/disclosure path, not to plan-capacity UI. +The app shell must not expose a monitored-system warning entry point for +self-hosted plan volume. Support review states can link to the usage-owned +ledger, but the CTA must not revive "View capacity" copy as an upsell-shaped +monitored-system prompt. Community overflow/setup-slot messaging must still explain the current top-level monitored systems plus any temporary setup slot in customer terms rather than compressing the contract into slash-style quota strings that imply @@ -1987,10 +1923,10 @@ health, config, ingestion, or funnel reads. Maintainer commercial reporting must live outside the self-hosted customer product runtime instead of reusing the Pulse settings or diagnostics surface. Stripe checkout and subscription webhook persistence now also follows the -canonical Cloud/MSP limit rule: when paid state is granted, billing-state -writes must persist authoritative `limits.max_monitored_systems` derived from canonical -plan resolution, and when paid state is revoked they must clear those stored -limits instead of preserving stale paid capacity. +retired monitored-system-volume rule: when paid state is granted, billing-state +writes must persist canonical plan and feature state while scrubbing retired +`max_monitored_systems` limits, and when paid state is revoked they must not +preserve stale paid capacity. Grandfathered recurring v5/v1 continuity is the explicit stored-state exception inside that same boundary. `pkg/licensing/billing_state_normalization.go` must persist the canonical billing baseline for recognized grandfathered @@ -1999,12 +1935,13 @@ so webhook persistence, hosted billing state, and admin inspection stay deterministic instead of leaking an internal `0 == unlimited` convention into saved billing records. `pkg/licensing/database_source.go`, `pkg/licensing/models.go`, and downstream entitlement evaluation must then -strip that stored monitored-system cap back out before runtime enforcement, so -continuous grandfathered recurring customers stay uncapped until cancellation. -That same monitored-system entitlement boundary also owns the shared operator -warning copy: the limit banner and migration guidance must present the counted -surface as monitored systems, not drift back into agent-install language while -describing non-counted legacy/API-connected resources. +strip that stored monitored-system cap back out before any runtime entitlement +payload, so continuous grandfathered recurring customers stay uncapped until +cancellation. +That same monitored-system entitlement boundary also owns the retired-warning +copy rule: customer-facing surfaces must not revive the old limit banner, +migration freeze guidance, or agent-install quota language while describing +non-counted legacy/API-connected resources. That same webhook boundary now also owns request-lifetime decoupling for checkout provisioning: long-running `checkout.session.completed` tenant provisioning must complete under an explicit background timeout instead of diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index e5ca14a81..48e9456b1 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -94,7 +94,6 @@ work extends shared components instead of creating new local variants. 62. `frontend-modern/src/components/SetupWizard/__tests__/SetupWizard.test.tsx` 63. `frontend-modern/src/components/SetupWizard/__tests__/SetupCompletionPreview.test.tsx` 64. `frontend-modern/src/components/SetupWizard/__tests__/WelcomeStep.test.tsx` -65. `frontend-modern/src/components/shared/MonitoredSystemLimitWarningBanner.tsx` 66. `frontend-modern/src/components/Settings/SystemLogsPanel.tsx` 67. `frontend-modern/src/components/Settings/useSystemLogsPanelState.ts` 68. `frontend-modern/src/utils/systemLogsPresentation.ts` @@ -225,12 +224,10 @@ frontend primitive boundary. alert threshold tables, and Infrastructure Settings source-manager tables; feature owners may own group content and behavior, but not duplicate the subgroup band styling. - Shared monitored-system warning primitives under that path must stay compact - app-shell pointers into the owned Pulse Pro billing surface. The shared - banner may announce posture and route to the relevant billing tab, but - durable plan-capacity explanation, over-plan reasoning, and upgrade/review - actions belong in the `cloud-paid` plan surface rather than permanent - banner-local prose. + Shared primitives must not reintroduce app-shell monitored-system capacity + banners. Monitored-system grouping and ledger presentation belongs in the + owned settings surfaces, while commercial plan explanation belongs in + `cloud-paid` plan surfaces. Mobile navigation under the same shared boundary owns tab accessible names: icon components may keep their standalone labels, but the nav must treat those icons as decorative inside tab buttons so names come from the tab @@ -391,11 +388,9 @@ frontend primitive boundary. `ColumnPicker` must opt into their panel width through that primitive rather than layering competing width classes page by page. 4. Add guardrail tests when a new shared pattern is introduced. - Shared monitored-system warning primitives must prove their admission-freeze - posture through the canonical `frontend-modern/src/utils/monitoredSystemPresentation.ts` - helper plus runtime `monitored_system_capacity` reads rather than - reconstructing raw `current / limit` slash math or `0 remaining` copy in - the banner shell, state owner, or shared model. + Shared monitored-system primitives must prove they remain informational + grouping or ledger surfaces rather than admission-freeze banners, cap + summaries, or `current / limit` quota math. Shared modal scroll containment follows that same owner split. The dialog shell in `frontend-modern/src/components/shared/dialogModel.ts` must keep shared panels `min-h-0`, and page-owned modal bodies may use @@ -615,14 +610,11 @@ frontend primitive boundary. When a shared banner renders both explanatory and commercial CTAs, those labels must resolve to distinct owned destinations or section anchors instead of presenting two different labels that land on the same - unscoped billing screen. Monitored-system warning banners must also honor - canonical runtime usage availability before treating a limit as urgent: - when the backend marks `max_monitored_systems.current_available=false`, - the shared banner model must consume the cloud-paid monitored-system - presentation helper and suppress usage summaries, upgrade pressure, and - upgrade-impression telemetry rather than rendering stale `current/limit` - counts or paid-plan CTAs from banner-local availability checks. When the - banner does need a review destination, it must scope the operator into the + unscoped billing screen. Monitored-system capacity warning banners are + retired; shared commercial banners must not render stale `current/limit` + counts, paid-plan CTAs, usage summaries, or upgrade-impression telemetry + from legacy monitored-system limit payloads. When a banner does need a + review destination for a current paid feature, it must scope the operator into the usage-owned policy ledger rather than plan-selection intent or CTA copy that frames the flow as monitored-system-cap expansion. 16. Keep assistant availability bootstrap on the shared app-shell boundary. @@ -1028,13 +1020,10 @@ primitives must not fork that string or revive removed nested route labels. `settingsNavCatalog.ts`, `settingsPanelRegistry.ts`, and `settingsNavigationModel.ts` replaces both. Panel routing within the infrastructure area uses `InfrastructurePanelStep` in-page state. -The shared monitored-system warning banner now uses a neutral policy-review -CTA and `reviewPolicyDestination` state, keeping the render shell pointed at -the usage-owned policy ledger instead of plan-selection or capacity wording. -It also requires hosted mode and follows the resolved session-presentation -upgrade policy, so ordinary self-hosted sessions do not render the banner, its -plan-review link, or its upgrade-impression telemetry even when stale finite -policy data is present. +The shared monitored-system warning banner has been retired. Ordinary hosted +and self-hosted sessions must not render app-shell monitored-system capacity +warnings, plan-review links, or upgrade-impression telemetry from stale finite +policy data. Shared alert presentation surfaces (`OverviewTab.tsx`, `HistoryTab.tsx`, `AlertOverviewActiveAlertsSection.tsx`, `AlertHistoryTableSection.tsx`, `AlertHistoryTableAlertRow.tsx`, `AlertOverviewAlertCard.tsx`) no longer accept @@ -1100,7 +1089,6 @@ commercial suppression. `frontend-modern/src/components/Settings/settingsNavCata `frontend-modern/src/stores/license.ts`, `frontend-modern/src/stores/licenseCommercial.ts`, `frontend-modern/src/useAppRuntimeState.ts`, -`frontend-modern/src/components/shared/useMonitoredSystemLimitWarningBannerState.ts`, `frontend-modern/src/components/shared/HistoryChartOverlay.tsx`, `frontend-modern/src/features/patrol/PatrolIntelligenceBanners.tsx`, and `frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx` @@ -1137,8 +1125,8 @@ That same shared primitive boundary now also centralizes authenticated-shell commercial posture bootstrap. `frontend-modern/src/useAppRuntimeState.ts` owns the first shared `loadCommercialPosture()` read after authenticated app runtime has mounted, while `frontend-modern/src/AppLayout.tsx`, -`frontend-modern/src/components/Settings/Settings.tsx`, shared warning-banner -hooks, Patrol state hooks, and settings-panel state hooks must consume the +`frontend-modern/src/components/Settings/Settings.tsx`, Patrol state hooks, +and settings-panel state hooks must consume the resolved store state instead of reissuing mount-time posture fetches from each surface. Shared commercial posture loading may still dedupe or force-refresh through the store for governed billing or first-run flows, but route-local or @@ -2315,47 +2303,14 @@ legacy/API-connected resources separately, but it may not regress the primary banner label or CTA text back to host-agent product language. The self-hosted commercial paywall copy on those shared warning surfaces is now also explicitly locked to monitored systems rather than agents. When a -shared banner or shared settings shell is explaining self-hosted plan caps, -the operator-facing commercial term must follow the monitored-system model even -if explicit legacy-v5 compatibility helpers still decode older alias fields at -import boundaries. -That same settings-shell framing must stay in customer language. Shared headers -and descriptions should talk about monitored-system limits, plan limits, and -subscription or license status instead of reviving legacy `installed-agent` -terms or vague internal nouns like `allocation`. -That banner boundary now also owns the canonical monitored-system naming -surface directly: the shared warning component path and exported symbol are -`MonitoredSystemLimitWarningBanner`, and future work may not reintroduce an -agent-era banner filename or component name as the primary primitive. -That shared monitored-system warning banner now also follows the shell/runtime/model -owner split. `frontend-modern/src/components/shared/MonitoredSystemLimitWarningBanner.tsx` -stays the render shell, `frontend-modern/src/components/shared/useMonitoredSystemLimitWarningBannerState.ts` -owns hosted-mode eligibility, entitlement load, warning metric emission, -migration click tracking, and policy-review plus collector-link runtime, and -`frontend-modern/src/components/shared/monitoredSystemLimitWarningBannerModel.ts` -owns monitored-system warning policy, count aggregation, and tone/text-class -policy while sourcing customer-facing monitored-system copy from the canonical -`frontend-modern/src/utils/monitoredSystemPresentation.ts` helper. Future -warning-banner work should extend those owners instead of pushing entitlement -state, hosted-mode checks, or route selection back into the render shell. In -ordinary self-hosted mode the banner must remain absent rather than turning -stale finite policy data into monitored-system limit pressure. When hosted -capacity policy is active, the shared primitive must stay a compact -policy-review pointer into the usage-owned ledger rather than a plan-selection -or "capacity" CTA. The banner may signal the current hosted monitored-system -posture and link into the owned usage surface, but the longer over-plan or -continuity explanation belongs in the bounded usage ledger and commercial -detail sections owned by `cloud-paid`, not in permanent app-shell banner copy. -That same shared warning boundary now also owns the monitored-system capacity -posture vocabulary. Shared banners, plan summaries, and ledger headers must -describe the canonical admission-freeze model from -`monitored_system_capacity`: existing monitoring continues, new monitored -systems block at the plan boundary, and over-plan posture is an explicitly -frozen state. Shared primitives must not fall back to raw `current / limit` -slash math or `0 remaining` wording that implies Pulse should retroactively -black out already-monitored systems. -orchestration, tracking, or naming math back into the shared shell or -reintroducing banner-local monitored-system copy strings. +shared banner or shared settings shell would explain monitored-system plan +caps, the correct primitive decision is absence: monitored-system volume is not +a current paid-capacity surface. Shared headers and descriptions may use +monitored-system language for inventory grouping and support ledgers, but they +must not talk about monitored-system limits, cap pressure, plan capacity, +admission freezes, or upgrade actions. Future work must not recreate +`MonitoredSystemLimitWarningBanner`, its state hook, or banner-local +monitored-system copy strings. Shared frontend label-formatting helpers now also have an explicit owner here. `frontend-modern/src/utils/textPresentation.ts` is the canonical shared owner for token humanization, identifier label formatting, title-casing, and @@ -2649,7 +2604,7 @@ plan-owned (`Plans`) while the page itself still names the concrete job That same settings-shell framing boundary also covers adjacent top-level settings references to the self-hosted commercial surface. When `InfrastructureWorkspace.tsx` or other settings-shell surfaces point operators -toward Pulse Pro for billing, monitored-system limits, or license status, they +toward Plans for billing, license status, or paid feature activation, they must reuse the shared referral copy from `SELF_HOSTED_PRO_BILLING_PRESENTATION` rather than drafting local “go there for billing” variants. @@ -2824,7 +2779,6 @@ commercial suppression. `frontend-modern/src/components/Settings/settingsNavCata `frontend-modern/src/stores/sessionCapabilities.ts`, `frontend-modern/src/stores/demoMode.ts`, `frontend-modern/src/useAppRuntimeState.ts`, -`frontend-modern/src/components/shared/useMonitoredSystemLimitWarningBannerState.ts`, `frontend-modern/src/components/shared/HistoryChartOverlay.tsx`, `frontend-modern/src/features/patrol/PatrolIntelligenceBanners.tsx`, and `frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx` diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index 291925141..b578e73e8 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -119,20 +119,21 @@ canonical escalation state to websocket consumers, but notification fan-out must defer quiet-hours and resolved-notification suppression policy to the alerts manager instead of bypassing that shared routing contract when monitor plumbs escalations outward. -That same monitoring owner now also governs monitored-system usage readiness -for commercial boundaries. A non-nil unified read-state is not sufficient when -provider-owned supplemental inventories such as TrueNAS or VMware are still -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 owner now also governs monitored-system grouping readiness +for settings and support boundaries. A non-nil unified read-state is not +sufficient when provider-owned supplemental inventories such as TrueNAS or +VMware are still settling: monitoring must report the grouping view as +unavailable 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 previews and support ledgers 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`. +settings and support surfaces can show verification or recovery state without +inventing their own readiness heuristics or falling back to a fake count. That same monitoring owner also governs collector payload compatibility at the shared boundary. Podman container stats must honor Podman's compat payload when it exposes a direct CPU percentage and otherwise fall back to Podman's @@ -232,7 +233,7 @@ implementation routes through maintained `github.com/moby/moby/api` and `github.com/moby/moby/client` modules, so monitoring runtime collection does not drift back onto the legacy `github.com/docker/docker` Go module line. That same monitoring owner now also governs restart-safe standalone host -continuity for monitored-system usage and admission. `internal/monitoring/monitor_agents.go` +continuity for monitored-system grouping. `internal/monitoring/monitor_agents.go` must persist recent host identity at report time, and `internal/monitoring/monitored_system_usage.go` must project that continuity back into the canonical read state through the unified-resources-owned overlay diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index 956010691..146d952a9 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -498,18 +498,18 @@ sampling work and drift out of sync with the governed history timeline. All governed performance-owned runtime files now require explicit registry path-policy coverage, so new protected hot paths must be mapped to a concrete proof route instead of falling back to subsystem-default verification. -Monitored-system admission and entitlement usage now also sit on a protected -backend hot path. `internal/api/monitored_system_limit_enforcement.go` and -the unified-resource projection helpers must reuse one current monitored- -system snapshot plus prospective candidate/preview projection for add and -update checks instead of rescanning platform inventories per handler or -falling back to zero when monitor state is unavailable. +Monitored-system grouping preview now also sits on a protected backend hot +path. The removed monitored-system limit-enforcement path must not return; +unified-resource projection helpers should reuse one current monitored-system +snapshot plus prospective candidate/preview projection for settings/support +explanation instead of rescanning platform inventories per handler or falling +back to zero when monitor state is unavailable. That protected path now also includes supplemental-inventory settlement. -Performance work may not shortcut monitored-system usage readiness to "store -exists" once provider-owned platforms such as TrueNAS or VMware suppress -snapshot-owned sources; the hot path must fail closed until the monitor has -both observed an initial baseline for every active connection and rebuilt the -canonical store at or after the latest provider watermark. +Performance work may not shortcut monitored-system grouping readiness to +"store exists" once provider-owned platforms such as TrueNAS or VMware suppress +snapshot-owned sources; the hot path must report preview unavailability until +the monitor has both observed an initial baseline for every active connection +and rebuilt the canonical store at or after the latest provider watermark. The Workloads selector path and the Workloads runtime that consumes it are now part of the protected performance surface rather than proof-only diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index 45f3249d8..3b38a4ee0 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -250,8 +250,8 @@ ] }, { - "path": "frontend-modern/src/components/Settings/MonitoredSystemAdmissionPreview.tsx", - "rationale": "the monitored-system admission preview is both a platform-connections lifecycle surface and a canonical cloud-paid monitored-system presentation boundary", + "path": "frontend-modern/src/components/Settings/MonitoredSystemImpactPreview.tsx", + "rationale": "the monitored-system impact preview is both a platform-connections lifecycle surface and a canonical cloud-paid monitored-system presentation boundary", "subsystems": [ "agent-lifecycle", "cloud-paid" @@ -712,7 +712,7 @@ "frontend-modern/src/components/Settings/InfrastructureSourceManager.tsx", "frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx", "frontend-modern/src/components/Settings/infrastructureWorkspaceModel.ts", - "frontend-modern/src/components/Settings/MonitoredSystemAdmissionPreview.tsx", + "frontend-modern/src/components/Settings/MonitoredSystemImpactPreview.tsx", "frontend-modern/src/components/Settings/NodeModalAuthenticationSection.tsx", "frontend-modern/src/components/Settings/NodeModalBasicInfoSection.tsx", "frontend-modern/src/components/Settings/nodeModalModel.ts", @@ -1056,7 +1056,7 @@ "frontend-modern/src/components/Settings/InfrastructureSourceManager.tsx", "frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx", "frontend-modern/src/components/Settings/infrastructureWorkspaceModel.ts", - "frontend-modern/src/components/Settings/MonitoredSystemAdmissionPreview.tsx", + "frontend-modern/src/components/Settings/MonitoredSystemImpactPreview.tsx", "frontend-modern/src/components/Settings/platformConnectionsModel.ts", "frontend-modern/src/components/Settings/proxmoxSettingsModel.ts", "frontend-modern/src/components/Settings/SettingsSectionNav.tsx", @@ -1900,7 +1900,7 @@ "frontend-modern/src/components/Settings/BillingAdminOrganizationsTable.tsx", "frontend-modern/src/components/Settings/BillingAdminPanel.tsx", "frontend-modern/src/components/Settings/CommercialBillingSections.tsx", - "frontend-modern/src/components/Settings/MonitoredSystemAdmissionPreview.tsx", + "frontend-modern/src/components/Settings/MonitoredSystemImpactPreview.tsx", "frontend-modern/src/components/Settings/MonitoredSystemLedgerPanel.tsx", "frontend-modern/src/components/Settings/OrganizationBillingLoadingState.tsx", "frontend-modern/src/components/Settings/OrganizationBillingPanel.tsx", @@ -2366,8 +2366,8 @@ ] }, { - "id": "host-lifecycle-tracking", - "label": "host lifecycle tracking proof", + "id": "host-lifecycle-retirement", + "label": "host lifecycle retirement proof", "match_prefixes": [], "match_files": [ "pkg/licensing/host_lifecycle.go" @@ -2484,10 +2484,10 @@ }, { "id": "monitored-system-presentation-helper", - "label": "monitored system presentation and admission preview proof", + "label": "monitored system presentation and impact preview proof", "match_prefixes": [], "match_files": [ - "frontend-modern/src/components/Settings/MonitoredSystemAdmissionPreview.tsx", + "frontend-modern/src/components/Settings/MonitoredSystemImpactPreview.tsx", "frontend-modern/src/utils/monitoredSystemPresentation.ts" ], "allow_same_subsystem_tests": false, diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 0b39cb01c..029ef2ff7 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -258,20 +258,13 @@ bypass the API fail-closed execution gate. must stay API/security and Relay-entitlement owned, require the paid `relay` feature before minting, and must not be reused as a recovery session, export/import bypass, or storage-local credential transport. - That same adjacent API boundary also owns monitored-system admission preview + That same adjacent API boundary also owns monitored-system impact preview transport for provider-backed setup context. `/api/truenas/connections/preview`, `/api/truenas/connections/{id}/preview`, `/api/vmware/connections/preview`, and `/api/vmware/connections/{id}/preview` may surface canonical - current/projected grouped systems plus enforced limit verdicts for setup and - support clarity, but storage and recovery must not reinterpret those routes - as recovery-local onboarding or restore APIs. - That same adjacent API boundary also owns any private monitored-system - policy hook wired through `pkg/server/server.go`. Storage- or - recovery-adjacent writes may observe the shared verdict returned by - `ResolveMonitoredSystemAdmissionPolicy`, but they must not inject - provider-specific policy, infer a different over-cap rule, or bypass the - canonical monitored-system preview/resolver contract when the private hook - is absent. + current/projected grouped systems for setup and support clarity, but + storage and recovery must not reinterpret those routes as recovery-local + onboarding, restore APIs, or commercial limit verdicts. That same adjacent monitored-system boundary also depends on restart-safe standalone host continuity. Storage- and recovery-adjacent setup or support flows may observe a returning host after server restart, but they must not @@ -560,19 +553,11 @@ bypass the API fail-closed execution gate. query state as a recovery-local contract once uncapped self-hosted monitoring is canonical. That same adjacent commercial - boundary now also owns migrated-v5 monitored-system grandfathering: - storage and recovery may read the resulting entitlement limits as context, - 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. They also must not rely on billing-status reads to finalize a - pending grandfather floor, use those reads to restart continuity - reconciliation, or collapse continuity-verification payloads into a real - `0 / limit` monitored-system reading. - When the commercial reconciler captures the floor, storage and recovery - must treat the resulting activation-state callback as commercial ownership - cleanup only, not as protected-inventory proof or recovery entitlement - state. + boundary treats migrated-v5 monitored-system grandfathering as retired + compatibility metadata: storage and recovery may tolerate resulting legacy + entitlement fields while loading old records, but they must not infer a + capacity floor from protected inventory, backup counts, recovery-point + presence, billing-status reads, or continuity-verification payloads. That same adjacent commercial boundary also owns authenticated install-version attribution: storage and recovery may read the resulting licensed build context as commercial metadata, but they must not cache a @@ -881,12 +866,12 @@ local-only upgrade-event toggles, telemetry preview payloads, or normalized release-classification fields as storage freshness, recovery evidence, or operator-facing protection state. That same shared `internal/api/` dependency now also expects replacement-aware -monitored-system admission and fail-closed usage availability. Storage- or +monitored-system grouping and fail-closed preview availability. Storage- or recovery-adjacent setup, deploy, and API-backed update helpers may reuse the -canonical monitored-system capacity boundary, but they must preserve remaining -grouped sources on a counted host, must not reinterpret unavailable usage as -an empty estate, and must not surface that adjacent boundary as license-slot or -upgrade-plan copy inside storage or recovery-adjacent flows. +canonical monitored-system grouping boundary, but they must preserve remaining +grouped sources on a monitored host, must not reinterpret unavailable usage as +an empty estate, and must not surface that adjacent boundary as license-slot, +capacity, or upgrade-plan copy inside storage or recovery-adjacent flows. Configured Proxmox, PBS, and PMG node replacements on that adjacent API boundary must identify the replaced source-owned surface through the shared monitored-system replacement selector, not storage- or recovery-local matching @@ -925,11 +910,11 @@ reads for capability and history-retention truth, but licensed identity, upgrade prompts, trial urgency or eligibility reasons, checkout handoff state, or observed usage counts to remain present once the public-demo contract is applied. -That same runtime-safe exception now also redacts monitored-system capacity -posture. Storage/recovery surfaces may keep demo-safe capability and retention -truth from `/api/license/runtime-capabilities`, but they must not expect -`monitored_system_capacity`, admission-freeze copy, or observed plan overage -to survive the public-demo API boundary. +That same runtime-safe exception now also keeps monitored-system capacity +posture absent. Storage/recovery surfaces may keep demo-safe capability and +retention truth from `/api/license/runtime-capabilities`, but they must not +expect `monitored_system_capacity`, admission-freeze copy, or observed plan +overage to exist. Storage detail surfaces with page-local history selectors must also treat that runtime retention truth as the selector contract: pool and disk detail ranges must filter and clamp through the storage-owned range access helper so ordinary @@ -2072,9 +2057,8 @@ commercial reporting or fallback state. That same shared `internal/api/` dependency now also assumes adjacent commercial helper surfaces speak in monitored-system terms: recovery- or storage-adjacent API wiring may consume the canonical monitored-system ledger -and monitored-system cap helpers, but it must not revive deleted agent-era -helper names or imply that API-backed infrastructure sits outside the counted -system model. +helpers, but it must not revive deleted agent-era helper names, cap helpers, +or imply that API-backed infrastructure sits outside the counted system model. That same shared `internal/api/` dependency now also assumes monitored-system ledger status details stay canonical and source-aware: storage- or recovery- adjacent consumers may read the ledger’s nested status explanation, but they @@ -2102,8 +2086,8 @@ as agent or sibling API ownership, rather than briefly freeing a slot or double-counting the same monitored system. When storage- or recovery-adjacent settings or support flows need to explain that result, they must rely on the shared monitored-system ledger preview -contract for current/projected grouped systems and enforced limit verdicts -instead of reconstructing preview copy from page-local recovery inventory or +contract for current/projected grouped systems instead of reconstructing +preview copy, limit verdicts, or cap copy from page-local recovery inventory or provider-local connection details. That same adjacent preview contract also treats disabled provider connections as non-counting candidates. Storage- or recovery-adjacent flows may use the @@ -2115,8 +2099,9 @@ readiness. Storage- or recovery-adjacent transport flows may not interpret the first store-backed monitor view as commercial truth when provider-owned supplemental platforms such as TrueNAS or VMware are still between initial connection wiring and the first rebuilt canonical store; until that baseline -settles, adjacent surfaces must preserve `current_available=false` and avoid -sealing any migration or admission decision against a transient undercount. +settles, adjacent surfaces must use the canonical ledger/preview unavailable +state and avoid sealing any migration or support decision against a transient +undercount. That same shared `internal/api/` dependency also assumes session-carried OIDC refresh tokens stay fail-closed at rest: `session_store.go` may only persist or recover those tokens through encrypted-at-rest session payloads, and any @@ -2702,9 +2687,9 @@ platform-connections surface, but storage and recovery must treat that data as setup/runtime context only, not as proof that VMware has joined the canonical recovery artifact or restore plane. The same rule applies to `/api/truenas/connections*/preview` and -`/api/vmware/connections*/preview`: monitored-system admission previews may -surface current/projected grouped systems and enforced limit verdicts for setup -and support clarity, but they do not imply recovery-local onboarding, recovery +`/api/vmware/connections*/preview`: monitored-system previews may surface +current/projected grouped systems for setup and support clarity, but they do +not imply commercial limit verdicts, recovery-local onboarding, recovery artifact ownership, or restore support. That same bounded phase-1 slice now also includes the shared unified-resource adapter floor. `frontend-modern/src/hooks/useUnifiedResources.ts`, @@ -2774,8 +2759,8 @@ That same adjacent platform-connections boundary now also assumes direct TrueNAS and VMware connection writes fail closed while canonical monitored-system usage is unavailable. Storage and recovery may depend on the resulting provider setup state only after `internal/api/truenas_handlers.go`, -`internal/api/vmware_handlers.go`, and the shared monitored-system admission -helpers have returned a safe capacity verdict; VMware write admission must not +`internal/api/vmware_handlers.go`, and the shared monitored-system grouping +helpers have returned a safe preview verdict; VMware write handling must not collect external vCenter inventory before that canonical usage state is safe. Storage and recovery browser helpers now also keep one transport-tolerant normalization edge. Recovery display models must accept legacy subject-label diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 311dc354d..741034352 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -1778,11 +1778,10 @@ remaining grouped monitored-system evidence. Source-native record-set preview is required for provider-backed onboarding that can discover multiple top-level systems from one saved connection, so TrueNAS, VMware, and future multi-record providers stay on the same canonical -projection boundary for both explanation and enforcement. +projection boundary for explanation. VMware host previews are part of that same contract: canonical host identity must honor VMware host UUID plus normalized hostnames so a vCenter add or -update cannot bypass the monitored-system cap by discovering host-backed -systems only after persistence. +update explains host-backed systems consistently before and after persistence. Canonical source-owned identifiers must also normalize surrounding whitespace before they become by-source map keys or source-specific hash IDs. The same @@ -1946,12 +1945,12 @@ through transport-local counters or child-resource totals. When one counted group is being updated in place, the prospective projection must remove only the replaced source from that grouped root and preserve any remaining canonical source ownership that still keeps the monitored system -counted, so replacement-aware admission stays aligned with final runtime +counted, so replacement-aware preview stays aligned with final runtime counting. When support or onboarding needs to explain that same change, unified resources must be able to return both the current grouped monitored system and the projected grouped monitored system for the candidate being evaluated, not -just the numeric count delta, so explainability and enforcement stay on one +just the numeric count delta, so explanation stays on one canonical projection boundary. Canonical unified resources now also own first-class policy metadata for the diff --git a/frontend-modern/src/App.tsx b/frontend-modern/src/App.tsx index d37060432..75636e48f 100644 --- a/frontend-modern/src/App.tsx +++ b/frontend-modern/src/App.tsx @@ -9,7 +9,6 @@ import { logger } from './utils/logger'; import { UpdateBanner } from './components/UpdateBanner'; import { DemoBanner } from './components/DemoBanner'; import { GitHubStarBanner } from './components/GitHubStarBanner'; -import { MonitoredSystemLimitWarningBanner } from './components/shared/MonitoredSystemLimitWarningBanner'; import { WhatsNewModal } from './components/shared/WhatsNewModal'; import { KeyboardShortcutsModal } from './components/shared/KeyboardShortcutsModal'; import { CommandPaletteModal } from './components/shared/CommandPaletteModal'; @@ -379,7 +378,6 @@ function App() { - diff --git a/frontend-modern/src/__tests__/App.architecture.test.ts b/frontend-modern/src/__tests__/App.architecture.test.ts index 893d9f49a..04e89020c 100644 --- a/frontend-modern/src/__tests__/App.architecture.test.ts +++ b/frontend-modern/src/__tests__/App.architecture.test.ts @@ -99,6 +99,8 @@ describe('App architecture', () => { expect(appSource).toContain(' aiChatStore.close()} />'); expect(appSource).toContain('showOrgSwitcher={runtime.showOrgSwitcher}'); expect(appSource).not.toContain('TrialBanner'); + expect(appSource).not.toContain('MonitoredSystemLimitWarningBanner'); + expect(appSource).not.toContain('monitoredSystemLimitWarningBanner'); }); it('keeps authenticated chrome in AppLayout and hosted bootstrap in useAppRuntimeState', () => { diff --git a/frontend-modern/src/api/__tests__/license.test.ts b/frontend-modern/src/api/__tests__/license.test.ts index db68d048f..f682f13fc 100644 --- a/frontend-modern/src/api/__tests__/license.test.ts +++ b/frontend-modern/src/api/__tests__/license.test.ts @@ -17,17 +17,6 @@ describe('LicenseAPI', () => { limits: [], hosted_mode: false, max_history_days: 14, - monitored_system_capacity: { - mode: 'within_limit', - urgency: 'ok', - current: 4, - limit: 10, - current_available: true, - available_slots: 6, - overage: 0, - blocks_new_systems: false, - existing_monitoring_continues: true, - }, }); const result = await LicenseAPI.getRuntimeCapabilities(); @@ -36,9 +25,6 @@ describe('LicenseAPI', () => { expect(result).toMatchObject({ capabilities: ['relay'], max_history_days: 14, - monitored_system_capacity: { - mode: 'within_limit', - }, }); }); @@ -74,60 +60,6 @@ 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, - }, - monitored_system_capacity: { - mode: 'usage_unavailable', - urgency: 'ok', - current: 0, - limit: 10, - current_available: false, - current_unavailable_reason: 'supplemental_inventory_unsettled', - available_slots: 0, - overage: 0, - reason: 'legacy_migration_capture_pending', - blocks_new_systems: false, - existing_monitoring_continues: false, - }, - }); - - const result = await LicenseAPI.getCommercialEntitlements(); - - expect(result.monitored_system_continuity).toMatchObject({ - plan_limit: 10, - effective_limit: 10, - capture_pending: true, - }); - expect(result.monitored_system_capacity).toMatchObject({ - mode: 'usage_unavailable', - reason: 'legacy_migration_capture_pending', - current_available: false, - }); - 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/__tests__/monitoredSystemLedger.test.ts b/frontend-modern/src/api/__tests__/monitoredSystemLedger.test.ts index 8413c3ad0..24aea8769 100644 --- a/frontend-modern/src/api/__tests__/monitoredSystemLedger.test.ts +++ b/frontend-modern/src/api/__tests__/monitoredSystemLedger.test.ts @@ -16,7 +16,6 @@ describe('MonitoredSystemLedgerAPI', () => { vi.mocked(apiFetchJSON).mockResolvedValueOnce({ systems: [], total: 0, - limit: 5, }); const result = await MonitoredSystemLedgerAPI.getLedger(); @@ -25,7 +24,6 @@ describe('MonitoredSystemLedgerAPI', () => { expect(result).toEqual({ systems: [], total: 0, - limit: 5, }); }); @@ -34,8 +32,6 @@ describe('MonitoredSystemLedgerAPI', () => { current_count: 1, projected_count: 1, additional_count: 0, - limit: 5, - would_exceed_limit: false, effect: 'attaches_existing', current_systems: [ { @@ -92,8 +88,6 @@ describe('MonitoredSystemLedgerAPI', () => { current_count: 4, projected_count: 3, additional_count: 0, - limit: 10, - would_exceed_limit: false, effect: 'removes_existing', current_systems: [ { @@ -143,14 +137,11 @@ describe('MonitoredSystemLedgerAPI', () => { }, ], total: 1, - limit: 5, }, preview: { current_count: 1, projected_count: 1, additional_count: 0, - limit: 5, - would_exceed_limit: false, effect: 'attaches_existing', current_systems: [ { diff --git a/frontend-modern/src/api/__tests__/responseUtils.test.ts b/frontend-modern/src/api/__tests__/responseUtils.test.ts index 29b617621..97100b56d 100644 --- a/frontend-modern/src/api/__tests__/responseUtils.test.ts +++ b/frontend-modern/src/api/__tests__/responseUtils.test.ts @@ -1,9 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { - apiErrorCode, - apiErrorDetailField, - apiErrorMonitoredSystemPreview, -} from '@/api/responseUtils'; +import { apiErrorCode, apiErrorDetailField } from '@/api/responseUtils'; describe('responseUtils structured API errors', () => { it('reads canonical code and detail fields from shared API errors', () => { @@ -22,39 +18,4 @@ describe('responseUtils structured API errors', () => { ); }); - it('normalizes monitored-system preview payloads from shared API errors', () => { - const error = { - monitored_system_preview: { - current_count: 5, - projected_count: 6, - additional_count: 1, - limit: 5, - would_exceed_limit: true, - effect: 'creates_new', - current_systems: [], - projected_systems: [ - { - name: 'backup', - type: 'truenas-system', - status: 'online', - source: 'truenas', - }, - ], - current_system: null, - projected_system: null, - }, - }; - - expect(apiErrorMonitoredSystemPreview(error)).toMatchObject({ - current_count: 5, - projected_count: 6, - would_exceed_limit: true, - projected_systems: [ - expect.objectContaining({ - name: 'backup', - source: 'truenas', - }), - ], - }); - }); }); diff --git a/frontend-modern/src/api/license.ts b/frontend-modern/src/api/license.ts index 89505b4be..ea3c481cc 100644 --- a/frontend-modern/src/api/license.ts +++ b/frontend-modern/src/api/license.ts @@ -9,11 +9,9 @@ export interface LicenseStatus { is_lifetime: boolean; days_remaining: number; features: string[]; - max_monitored_systems?: number; max_guests?: number; in_grace_period?: boolean; grace_period_end?: string | null; - monitored_system_continuity?: MonitoredSystemContinuityStatus; } export interface EntitlementLimitStatus { @@ -21,8 +19,6 @@ 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; } @@ -46,35 +42,12 @@ export interface CommercialMigrationStatus { recommended_action?: string; } -export interface MonitoredSystemContinuityStatus { - plan_limit: number; - grandfathered_floor?: number; - effective_limit: number; - capture_pending: boolean; - captured_at?: number; -} - -export interface MonitoredSystemCapacityStatus { - mode: string; - urgency: string; - current: number; - limit: number; - current_available: boolean; - current_unavailable_reason?: string; - available_slots: number; - overage: number; - reason?: string; - blocks_new_systems: boolean; - existing_monitoring_continues: boolean; -} - // Mirrors internal/api/subscription_entitlements.go:RuntimeCapabilitiesPayload export interface LicenseRuntimeCapabilities { capabilities: string[]; limits: EntitlementLimitStatus[]; hosted_mode?: boolean; max_history_days?: number; - monitored_system_capacity?: MonitoredSystemCapacityStatus; } // Mirrors internal/api/subscription_entitlements.go:CommercialPosturePayload @@ -90,7 +63,6 @@ export interface LicenseCommercialPosture { legacy_connections?: EntitlementLegacyConnections; has_migration_gap?: boolean; commercial_migration?: CommercialMigrationStatus; - monitored_system_capacity?: MonitoredSystemCapacityStatus; } // Mirrors internal/api/subscription_entitlements.go:EntitlementPayload @@ -107,7 +79,6 @@ 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/api/monitoredSystemLedger.ts b/frontend-modern/src/api/monitoredSystemLedger.ts index eb6564739..d661f73b0 100644 --- a/frontend-modern/src/api/monitoredSystemLedger.ts +++ b/frontend-modern/src/api/monitoredSystemLedger.ts @@ -61,7 +61,6 @@ export interface MonitoredSystemLedgerLatestSignal { export interface MonitoredSystemLedgerResponse { systems: MonitoredSystemLedgerEntry[]; total: number; - limit: number; // 0 = unlimited } export interface MonitoredSystemLedgerExplainRequest { @@ -112,8 +111,6 @@ export interface MonitoredSystemLedgerPreviewResponse { current_count: number; projected_count: number; additional_count: number; - limit: number; - would_exceed_limit: boolean; effect: MonitoredSystemLedgerPreviewEffect | string; current_systems: MonitoredSystemLedgerEntry[]; projected_systems: MonitoredSystemLedgerEntry[]; @@ -244,7 +241,6 @@ export function normalizeMonitoredSystemLedgerExplainResponse( const rawLedger: MonitoredSystemLedgerRawResponse = response.ledger ?? { systems: [], total: 0, - limit: 0, }; return { ledger: normalizeMonitoredSystemLedgerResponse(rawLedger), diff --git a/frontend-modern/src/api/responseUtils.ts b/frontend-modern/src/api/responseUtils.ts index fa3f0b96e..211e4800a 100644 --- a/frontend-modern/src/api/responseUtils.ts +++ b/frontend-modern/src/api/responseUtils.ts @@ -1,9 +1,3 @@ -import { - normalizeMonitoredSystemLedgerPreviewResponse, - type MonitoredSystemLedgerPreviewResponse, - type MonitoredSystemLedgerRawPreviewResponse, -} from './monitoredSystemLedger'; - type APIErrorPayload = { error?: unknown; message?: unknown; @@ -14,7 +8,6 @@ type APIErrorLike = { code?: unknown; detail?: unknown; details?: unknown; - monitored_system_preview?: unknown; }; type APIResponseLike = { @@ -250,23 +243,6 @@ export function apiErrorDetailField(error: unknown, field: string): string | nul return details[field] ?? null; } -export function apiErrorMonitoredSystemPreview( - error: unknown, -): MonitoredSystemLedgerPreviewResponse | null { - if (!error || typeof error !== 'object') { - return null; - } - - const rawPreview = (error as APIErrorLike).monitored_system_preview; - if (!rawPreview || typeof rawPreview !== 'object') { - return null; - } - - return normalizeMonitoredSystemLedgerPreviewResponse( - rawPreview as MonitoredSystemLedgerRawPreviewResponse, - ); -} - export function isAPIErrorStatus(error: unknown, expectedStatus: number): boolean { return apiErrorStatus(error) === expectedStatus; } diff --git a/frontend-modern/src/components/Infrastructure/__tests__/AgentDeployWizardE2E.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/AgentDeployWizardE2E.test.tsx index b3c018da6..7950b80fb 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/AgentDeployWizardE2E.test.tsx +++ b/frontend-modern/src/components/Infrastructure/__tests__/AgentDeployWizardE2E.test.tsx @@ -314,7 +314,6 @@ describe('Agent Deploy Wizard E2E', () => { jobId: 'j1', acceptedTargets: ['n2', 'n3'], skippedTargets: [], - reservedLicenseSlots: 2, eventsUrl: '/api/agent-deploy/jobs/j1/events', }); @@ -464,7 +463,6 @@ describe('Agent Deploy Wizard E2E', () => { jobId: 'j1', acceptedTargets: ['n2', 'n3'], skippedTargets: [], - reservedLicenseSlots: 2, eventsUrl: '/api/agent-deploy/jobs/j1/events', }); agentDeployAPIMock.getJob.mockResolvedValue( @@ -677,7 +675,6 @@ describe('Agent Deploy Wizard E2E', () => { jobId: 'j1', acceptedTargets: ['n2'], skippedTargets: [], - reservedLicenseSlots: 1, eventsUrl: '/api/agent-deploy/jobs/j1/events', }); agentDeployAPIMock.getJob.mockResolvedValue( @@ -804,7 +801,6 @@ describe('Agent Deploy Wizard E2E', () => { jobId: 'j1', acceptedTargets: ['n2'], skippedTargets: [], - reservedLicenseSlots: 1, eventsUrl: '/api/agent-deploy/jobs/j1/events', }); agentDeployAPIMock.getJob.mockResolvedValue( @@ -980,7 +976,6 @@ describe('Agent Deploy Wizard E2E', () => { jobId: 'j1', acceptedTargets: ['n2'], skippedTargets: [], - reservedLicenseSlots: 1, eventsUrl: '/api/agent-deploy/jobs/j1/events', }); agentDeployAPIMock.getJob.mockResolvedValue( diff --git a/frontend-modern/src/components/Infrastructure/__tests__/DeployStatusBadge.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/DeployStatusBadge.test.tsx index 9fcb444ed..076f1b2c0 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/DeployStatusBadge.test.tsx +++ b/frontend-modern/src/components/Infrastructure/__tests__/DeployStatusBadge.test.tsx @@ -18,7 +18,6 @@ describe('DeployStatusBadge', () => { ['failed_retryable', 'Failed'], ['failed_permanent', 'Failed'], ['skipped_already_agent', 'Already monitored'], - ['skipped_license', 'Workspace capacity'], ['canceled', 'Canceled'], ]; @@ -58,7 +57,6 @@ describe('DeployStatusBadge', () => { ['failed_retryable', 'Failed'], ['failed_permanent', 'Failed'], ['skipped_already_agent', 'Already monitored'], - ['skipped_license', 'Workspace capacity'], ['canceled', 'Canceled'], ]; for (const [status, label] of nonProgress) { @@ -101,10 +99,9 @@ describe('DeployStatusBadge', () => { }); it('applies amber classes to skipped statuses', () => { - for (const status of ['skipped_already_agent', 'skipped_license'] as DeployTargetStatus[]) { + for (const status of ['skipped_already_agent'] as DeployTargetStatus[]) { const { unmount } = render(() => ); - const label = status === 'skipped_already_agent' ? 'Already monitored' : 'Workspace capacity'; - const badge = screen.getByText(label); + const badge = screen.getByText('Already monitored'); expect(badge.className).toContain('bg-amber-100'); expect(badge.className).toContain('text-amber-700'); unmount(); diff --git a/frontend-modern/src/components/Infrastructure/__tests__/DeployStepComponents.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/DeployStepComponents.test.tsx index ba7948589..7bc933e17 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/DeployStepComponents.test.tsx +++ b/frontend-modern/src/components/Infrastructure/__tests__/DeployStepComponents.test.tsx @@ -83,7 +83,6 @@ function createMockWizard(overrides: Record = {}) { // Confirm confirmSelectedNodeIds: sig('confirmSelectedNodeIds', new Set()), toggleConfirmNode: (overrides.toggleConfirmNode as (id: string) => void) ?? vi.fn(), - maxAgentSlots: sig('maxAgentSlots', 0), // Deploy jobTargets: sig('jobTargets', []), deployError: sig('deployError', ''), @@ -277,30 +276,6 @@ describe('ConfirmStep', () => { expect(screen.getByText('SSH failed')).toBeInTheDocument(); }); - it('shows workspace capacity info when limit exists', () => { - const wizard = createMockWizard({ - maxAgentSlots: 5, - confirmSelectedNodeIds: new Set(['n1', 'n2']), - readyNodes: [], - }); - render(() => ); - expect( - screen.getByText(/Workspace capacity: 5 monitored systems, 2 nodes selected/), - ).toBeInTheDocument(); - }); - - it('shows capacity warning when exceeding limit', () => { - const wizard = createMockWizard({ - maxAgentSlots: 2, - confirmSelectedNodeIds: new Set(['n1', 'n2', 'n3']), - readyNodes: [], - }); - render(() => ); - expect( - screen.getByText(/This workspace can deploy 2 nodes at its current capacity/), - ).toBeInTheDocument(); - }); - it('calls toggleConfirmNode on row click', () => { const toggleMock = vi.fn(); const wizard = createMockWizard({ diff --git a/frontend-modern/src/components/Infrastructure/deploy/ConfirmStep.tsx b/frontend-modern/src/components/Infrastructure/deploy/ConfirmStep.tsx index ed87e7ff7..de6cc9a32 100644 --- a/frontend-modern/src/components/Infrastructure/deploy/ConfirmStep.tsx +++ b/frontend-modern/src/components/Infrastructure/deploy/ConfirmStep.tsx @@ -1,4 +1,4 @@ -import { Component, For, Show, createMemo } from 'solid-js'; +import { Component, For, Show } from 'solid-js'; import type { DeployWizardState } from '@/hooks/useDeployWizard'; import { Table, @@ -8,7 +8,6 @@ import { TableHeader, TableRow, } from '@/components/shared/Table'; -import AlertCircleIcon from 'lucide-solid/icons/alert-circle'; import CheckCircleIcon from 'lucide-solid/icons/check-circle-2'; interface ConfirmStepProps { @@ -18,36 +17,8 @@ interface ConfirmStepProps { export const ConfirmStep: Component = (props) => { const w = props.wizard; - const selectedCount = createMemo(() => w.confirmSelectedNodeIds().size); - const maxSlots = createMemo(() => w.maxAgentSlots()); - const exceedsCapacity = createMemo(() => maxSlots() > 0 && selectedCount() > maxSlots()); - const excessNodeCount = createMemo(() => Math.max(0, selectedCount() - maxSlots())); - const excessNodeLabel = createMemo(() => (excessNodeCount() === 1 ? 'node' : 'nodes')); - return (
- {/* Workspace capacity summary */} - 0}> -
- - - Workspace capacity: {maxSlots()} monitored systems, {selectedCount()} nodes selected. - - {' '} - This workspace can deploy {maxSlots()} nodes at its current capacity. Remove{' '} - {excessNodeCount()} {excessNodeLabel()} before continuing. - - -
-
- {/* Ready nodes */} 0}>
diff --git a/frontend-modern/src/components/Infrastructure/deploy/DeployingStep.tsx b/frontend-modern/src/components/Infrastructure/deploy/DeployingStep.tsx index 14d57e14a..79a0c455c 100644 --- a/frontend-modern/src/components/Infrastructure/deploy/DeployingStep.tsx +++ b/frontend-modern/src/components/Infrastructure/deploy/DeployingStep.tsx @@ -32,7 +32,6 @@ export const DeployingStep: Component = (props) => { t.status === 'failed_retryable' || t.status === 'failed_permanent' || t.status === 'skipped_already_agent' || - t.status === 'skipped_license' || t.status === 'canceled', ).length, ); diff --git a/frontend-modern/src/components/Infrastructure/deploy/__tests__/CandidatesStep.test.tsx b/frontend-modern/src/components/Infrastructure/deploy/__tests__/CandidatesStep.test.tsx index 1295780e6..a984a19cc 100644 --- a/frontend-modern/src/components/Infrastructure/deploy/__tests__/CandidatesStep.test.tsx +++ b/frontend-modern/src/components/Infrastructure/deploy/__tests__/CandidatesStep.test.tsx @@ -94,7 +94,6 @@ function createMockWizard( retryableTargets: (() => []) as Accessor, skippedTargets: (() => []) as Accessor, canceledTargets: (() => []) as Accessor, - maxAgentSlots: (() => 0) as Accessor, startingPreflight: (() => false) as Accessor, startingDeploy: (() => false) as Accessor, retrying: (() => false) as Accessor, diff --git a/frontend-modern/src/components/Infrastructure/deploy/__tests__/ConfirmStep.test.tsx b/frontend-modern/src/components/Infrastructure/deploy/__tests__/ConfirmStep.test.tsx index ff196ef5a..f84ec6f29 100644 --- a/frontend-modern/src/components/Infrastructure/deploy/__tests__/ConfirmStep.test.tsx +++ b/frontend-modern/src/components/Infrastructure/deploy/__tests__/ConfirmStep.test.tsx @@ -27,7 +27,6 @@ function makeTarget(overrides: Partial = {}): MockTarget { interface WizardOverrides { confirmSelectedNodeIds?: Set; - maxAgentSlots?: number; readyNodes?: MockTarget[]; failedPreflightNodes?: MockTarget[]; toggleConfirmNode?: ReturnType; @@ -35,18 +34,15 @@ interface WizardOverrides { function createMockWizard(overrides: WizardOverrides = {}) { const selectedIds = overrides.confirmSelectedNodeIds ?? new Set(); - const slots = overrides.maxAgentSlots ?? 0; const ready = overrides.readyNodes ?? []; const failed = overrides.failedPreflightNodes ?? []; const [confirmSelectedNodeIds] = createSignal(selectedIds); - const [maxAgentSlots] = createSignal(slots); const [readyNodes] = createSignal(ready); const [failedPreflightNodes] = createSignal(failed); return { confirmSelectedNodeIds, - maxAgentSlots, readyNodes, failedPreflightNodes, toggleConfirmNode: overrides.toggleConfirmNode ?? vi.fn(), @@ -66,92 +62,6 @@ describe('ConfirmStep', () => { cleanup(); }); - // --- Workspace capacity summary --- - - describe('workspace capacity summary', () => { - it('does not show capacity info when maxAgentSlots is 0', () => { - renderConfirm({ maxAgentSlots: 0, readyNodes: [makeTarget()] }); - expect(screen.queryByText(/Workspace capacity/)).not.toBeInTheDocument(); - }); - - it('shows slot count and selected count when maxSlots > 0', () => { - renderConfirm({ - maxAgentSlots: 5, - confirmSelectedNodeIds: new Set(['a', 'b']), - readyNodes: [makeTarget({ nodeId: 'a' }), makeTarget({ nodeId: 'b' })], - }); - expect( - screen.getByText(/Workspace capacity: 5 monitored systems, 2 nodes selected/), - ).toBeInTheDocument(); - }); - - it('shows warning when selection exceeds workspace capacity', () => { - renderConfirm({ - maxAgentSlots: 2, - confirmSelectedNodeIds: new Set(['a', 'b', 'c']), - readyNodes: [ - makeTarget({ nodeId: 'a' }), - makeTarget({ nodeId: 'b' }), - makeTarget({ nodeId: 'c' }), - ], - }); - expect( - screen.getByText(/This workspace can deploy 2 nodes at its current capacity/), - ).toBeInTheDocument(); - expect(screen.getByText(/Remove 1 node before continuing/)).toBeInTheDocument(); - }); - - it('does not show exceeds warning when selection is within limit', () => { - renderConfirm({ - maxAgentSlots: 5, - confirmSelectedNodeIds: new Set(['a', 'b']), - readyNodes: [makeTarget({ nodeId: 'a' }), makeTarget({ nodeId: 'b' })], - }); - expect(screen.queryByText(/current capacity/)).not.toBeInTheDocument(); - }); - - it('does not show exceeds warning when selection equals limit exactly', () => { - renderConfirm({ - maxAgentSlots: 2, - confirmSelectedNodeIds: new Set(['a', 'b']), - readyNodes: [makeTarget({ nodeId: 'a' }), makeTarget({ nodeId: 'b' })], - }); - expect(screen.queryByText(/current capacity/)).not.toBeInTheDocument(); - }); - - it('shows capacity banner with 0 selected when maxSlots > 0 and nothing selected', () => { - renderConfirm({ - maxAgentSlots: 10, - confirmSelectedNodeIds: new Set(), - readyNodes: [], - }); - expect( - screen.getByText(/Workspace capacity: 10 monitored systems, 0 nodes selected/), - ).toBeInTheDocument(); - expect(screen.queryByText(/current capacity/)).not.toBeInTheDocument(); - }); - - it('applies amber styling when exceeding workspace capacity', () => { - const { container } = renderConfirm({ - maxAgentSlots: 1, - confirmSelectedNodeIds: new Set(['a', 'b']), - readyNodes: [makeTarget({ nodeId: 'a' }), makeTarget({ nodeId: 'b' })], - }); - const banner = container.querySelector('.bg-amber-50'); - expect(banner).toBeInTheDocument(); - }); - - it('applies blue styling when within workspace capacity', () => { - const { container } = renderConfirm({ - maxAgentSlots: 5, - confirmSelectedNodeIds: new Set(['a']), - readyNodes: [makeTarget({ nodeId: 'a' })], - }); - const banner = container.querySelector('.bg-blue-50'); - expect(banner).toBeInTheDocument(); - }); - }); - // --- Ready nodes table --- describe('ready nodes table', () => { @@ -340,16 +250,14 @@ describe('ConfirmStep', () => { expect(screen.getByText('bad-host')).toBeInTheDocument(); }); - it('renders nothing when both lists are empty and no license', () => { + it('renders nothing when both lists are empty', () => { renderConfirm({ readyNodes: [], failedPreflightNodes: [], - maxAgentSlots: 0, }); // Only the outer wrapper div exists, with no visible child content expect(screen.queryByText(/Ready to deploy/)).not.toBeInTheDocument(); expect(screen.queryByText(/Cannot deploy/)).not.toBeInTheDocument(); - expect(screen.queryByText(/Workspace capacity/)).not.toBeInTheDocument(); }); }); diff --git a/frontend-modern/src/components/Infrastructure/deploy/__tests__/DeployingStep.test.tsx b/frontend-modern/src/components/Infrastructure/deploy/__tests__/DeployingStep.test.tsx index 4ab0cdfda..50beb70e1 100644 --- a/frontend-modern/src/components/Infrastructure/deploy/__tests__/DeployingStep.test.tsx +++ b/frontend-modern/src/components/Infrastructure/deploy/__tests__/DeployingStep.test.tsx @@ -73,7 +73,6 @@ function createMockWizard( retryableTargets: (() => []) as Accessor, skippedTargets: (() => []) as Accessor, canceledTargets: (() => []) as Accessor, - maxAgentSlots: (() => 0) as Accessor, startingPreflight: (() => false) as Accessor, startingDeploy: (() => false) as Accessor, retrying: (() => false) as Accessor, @@ -186,17 +185,6 @@ describe('DeployingStep', () => { expect(getProgressSummary()).toHaveTextContent('Installing 1 of 2 nodes...'); }); - it('counts skipped_license as completed', () => { - const targets = [ - makeTarget({ id: 't1', status: 'skipped_license' }), - makeTarget({ id: 't2', status: 'pending' }), - ]; - const wizard = createMockWizard({ jobTargets: targets }); - render(() => ); - - expect(getProgressSummary()).toHaveTextContent('Installing 1 of 2 nodes...'); - }); - it('counts canceled as completed', () => { const targets = [ makeTarget({ id: 't1', status: 'canceled' }), @@ -329,21 +317,20 @@ describe('DeployingStep', () => { makeTarget({ id: 't2', status: 'failed_retryable' }), makeTarget({ id: 't3', status: 'failed_permanent' }), makeTarget({ id: 't4', status: 'skipped_already_agent' }), - makeTarget({ id: 't5', status: 'skipped_license' }), - makeTarget({ id: 't6', status: 'canceled' }), - makeTarget({ id: 't7', status: 'installing' }), - makeTarget({ id: 't8', status: 'enrolling' }), - makeTarget({ id: 't9', status: 'verifying' }), - makeTarget({ id: 't10', status: 'pending' }), + makeTarget({ id: 't5', status: 'canceled' }), + makeTarget({ id: 't6', status: 'installing' }), + makeTarget({ id: 't7', status: 'enrolling' }), + makeTarget({ id: 't8', status: 'verifying' }), + makeTarget({ id: 't9', status: 'pending' }), ]; const wizard = createMockWizard({ jobTargets: targets }); render(() => ); - // 6 completed (succeeded, failed_retryable, failed_permanent, skipped_already_agent, skipped_license, canceled) + // 5 completed (succeeded, failed_retryable, failed_permanent, skipped_already_agent, canceled) // 3 in progress (installing, enrolling, verifying) // 1 pending const summary = getProgressSummary(); - expect(summary).toHaveTextContent('Installing 6 of 10 nodes...'); + expect(summary).toHaveTextContent('Installing 5 of 9 nodes...'); expect(summary).toHaveTextContent('(3 in progress)'); }); diff --git a/frontend-modern/src/components/Infrastructure/deploy/__tests__/PreflightStep.test.tsx b/frontend-modern/src/components/Infrastructure/deploy/__tests__/PreflightStep.test.tsx index b364b1cb0..0d95fde34 100644 --- a/frontend-modern/src/components/Infrastructure/deploy/__tests__/PreflightStep.test.tsx +++ b/frontend-modern/src/components/Infrastructure/deploy/__tests__/PreflightStep.test.tsx @@ -83,7 +83,6 @@ function createMockWizard( retryableTargets: (() => []) as Accessor, skippedTargets: (() => []) as Accessor, canceledTargets: (() => []) as Accessor, - maxAgentSlots: (() => 0) as Accessor, startingPreflight: (() => false) as Accessor, startingDeploy: (() => false) as Accessor, retrying: (() => false) as Accessor, @@ -196,7 +195,6 @@ describe('PreflightStep', () => { 'failed_retryable', 'failed_permanent', 'skipped_already_agent', - 'skipped_license', 'canceled', 'succeeded', 'installing', @@ -370,14 +368,6 @@ describe('PreflightStep', () => { expect(screen.getByText('Already monitored')).toBeInTheDocument(); }); - it('shows "Workspace capacity" badge for skipped_license targets', () => { - const wizard = createMockWizard({ - preflightTargets: [makeTarget({ status: 'skipped_license' })], - }); - render(() => ); - - expect(screen.getByText('Workspace capacity')).toBeInTheDocument(); - }); }); /* ── Error details ───────────────────────────────────────────── */ diff --git a/frontend-modern/src/components/Infrastructure/deploy/__tests__/ResultsStep.test.tsx b/frontend-modern/src/components/Infrastructure/deploy/__tests__/ResultsStep.test.tsx index 8722788c4..33bbfdcb0 100644 --- a/frontend-modern/src/components/Infrastructure/deploy/__tests__/ResultsStep.test.tsx +++ b/frontend-modern/src/components/Infrastructure/deploy/__tests__/ResultsStep.test.tsx @@ -105,7 +105,6 @@ function createMockWizard( deployError: (() => '') as Accessor, deployStream: {} as unknown, retryableTargets: (() => []) as Accessor, - maxAgentSlots: (() => 0) as Accessor, startingPreflight: (() => false) as Accessor, startingDeploy: (() => false) as Accessor, retrying: (() => false) as Accessor, @@ -235,12 +234,11 @@ describe('ResultsStep', () => { const wizard = createMockWizard({ skippedTargets: [ makeTarget({ id: 't1', status: 'skipped_already_agent' }), - makeTarget({ id: 't2', status: 'skipped_license' }), ], }); render(() => ); - expect(screen.getByText('Skipped (2)')).toBeInTheDocument(); + expect(screen.getByText('Skipped (1)')).toBeInTheDocument(); }); it('renders node names and IPs in the skipped table', () => { diff --git a/frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/TrueNASCredentialSlot.tsx b/frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/TrueNASCredentialSlot.tsx index 2c2e7311a..5aba89d44 100644 --- a/frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/TrueNASCredentialSlot.tsx +++ b/frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/TrueNASCredentialSlot.tsx @@ -8,7 +8,7 @@ import { } from '@/components/shared/Form'; import { FormSelect } from '@/components/shared/FormSelect'; import { TlsVerificationWarningBanner } from '@/components/shared/TlsVerificationWarningBanner'; -import { MonitoredSystemAdmissionPreview } from '../../MonitoredSystemAdmissionPreview'; +import { MonitoredSystemImpactPreview } from '../../MonitoredSystemImpactPreview'; import type { TrueNASConnection } from '@/api/truenas'; import type { TrueNASSettingsPanelState } from '../../useTrueNASSettingsPanelState'; @@ -287,7 +287,7 @@ export const TrueNASCredentialSlot: Component = (pro
- = (pro props.state.testing() || props.state.previewing() || props.togglePending || - props.deletePending || - props.state.monitoredSystemAdmissionSaveBlocked() + props.deletePending } > {props.state.saving() diff --git a/frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/VMwareCredentialSlot.tsx b/frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/VMwareCredentialSlot.tsx index 2be626511..9100b928c 100644 --- a/frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/VMwareCredentialSlot.tsx +++ b/frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/VMwareCredentialSlot.tsx @@ -9,7 +9,7 @@ import { formLabel, } from '@/components/shared/Form'; import { TlsVerificationWarningBanner } from '@/components/shared/TlsVerificationWarningBanner'; -import { MonitoredSystemAdmissionPreview } from '../../MonitoredSystemAdmissionPreview'; +import { MonitoredSystemImpactPreview } from '../../MonitoredSystemImpactPreview'; import type { VMwareConnection } from '@/api/vmware'; import type { VMwareSettingsPanelState } from '../../useVMwareSettingsPanelState'; @@ -234,7 +234,7 @@ export const VMwareCredentialSlot: Component = (props - = (props props.state.testing() || props.state.previewing() || props.togglePending || - props.deletePending || - props.state.monitoredSystemAdmissionSaveBlocked() + props.deletePending } > {props.state.saving() diff --git a/frontend-modern/src/components/Settings/MonitoredSystemAdmissionPreview.tsx b/frontend-modern/src/components/Settings/MonitoredSystemImpactPreview.tsx similarity index 71% rename from frontend-modern/src/components/Settings/MonitoredSystemAdmissionPreview.tsx rename to frontend-modern/src/components/Settings/MonitoredSystemImpactPreview.tsx index 8e68bf344..c17fb75b4 100644 --- a/frontend-modern/src/components/Settings/MonitoredSystemAdmissionPreview.tsx +++ b/frontend-modern/src/components/Settings/MonitoredSystemImpactPreview.tsx @@ -2,37 +2,23 @@ import { For, Show, type Component } from 'solid-js'; import type { MonitoredSystemLedgerPreviewResponse } from '@/api/monitoredSystemLedger'; import { CalloutCard } from '@/components/shared/CalloutCard'; import { - formatMonitoredSystemAdmissionPreviewSummary, + formatMonitoredSystemImpactPreviewSummary, formatMonitoredSystemSurfaceAttribution, - getMonitoredSystemAdmissionPreviewTitle, - getMonitoredSystemAdmissionPreviewRequiredState, + getMonitoredSystemImpactPreviewTitle, } from '@/utils/monitoredSystemPresentation'; -interface MonitoredSystemAdmissionPreviewProps { +interface MonitoredSystemImpactPreviewProps { preview: MonitoredSystemLedgerPreviewResponse | null; loading?: boolean; error?: string | null; errorTitle?: string | null; } -const previewTone = (preview: MonitoredSystemLedgerPreviewResponse | null) => - preview?.would_exceed_limit ? 'warning' : 'info'; - -export const MonitoredSystemAdmissionPreview: Component = ( +export const MonitoredSystemImpactPreview: Component = ( props, ) => { - const requiredState = getMonitoredSystemAdmissionPreviewRequiredState(); - return ( <> - - {requiredState.message}

} - /> -
-
Calculating monitored-system impact… @@ -52,11 +38,11 @@ export const MonitoredSystemAdmissionPreview: Component {(preview) => ( -

{formatMonitoredSystemAdmissionPreviewSummary(preview())}

+

{formatMonitoredSystemImpactPreviewSummary(preview())}

0}>

@@ -90,4 +76,4 @@ export const MonitoredSystemAdmissionPreview: Component sessionPresentationPolicyResolved() && !presentationPolicyHidesCommercialSurfaces() @@ -122,65 +86,17 @@ export function MonitoredSystemLedgerPanel(props: MonitoredSystemLedgerPanelProp explanation.state === 'errored' ? undefined : explanation()?.ledger, ); const total = () => ledger()?.total ?? 0; - const limit = () => ledger()?.limit ?? props.monitoredSystemLimit?.limit ?? 0; const systems = () => ledger()?.systems ?? []; - const hasLimit = () => limit() > 0; - const overLimit = () => hasLimit() && total() > limit(); - const pct = () => usagePercent(total(), limit()); - const displayLimit = createMemo(() => { - if (ledger()) { - return { - key: 'max_monitored_systems', - limit: limit(), - current: total(), - current_available: true, - state: props.monitoredSystemCapacity?.urgency ?? props.monitoredSystemLimit?.state ?? '', - }; - } - return props.monitoredSystemLimit ?? null; - }); - const displayCapacity = createMemo(() => - ledger() - ? resolveMonitoredSystemCapacityStatus(undefined, displayLimit()) - : resolveMonitoredSystemCapacityStatus(props.monitoredSystemCapacity, displayLimit()), - ); - const capacitySummary = createMemo(() => - getMonitoredSystemLimitUsageSummary(displayLimit(), displayCapacity()), - ); - const capacityContext = createMemo(() => - getMonitoredSystemLimitContextSummary(displayLimit(), displayCapacity()), - ); + const totalSummary = () => `${total()} monitored ${total() === 1 ? 'system' : 'systems'}`; const usageUnavailableReason = () => { if (apiErrorCode(explanation.error) === 'monitored_system_usage_unavailable') { return apiErrorDetailField(explanation.error, 'reason') ?? undefined; } - if (!ledger()) { - return getMonitoredSystemLimitUnavailableReason( - props.monitoredSystemLimit, - props.monitoredSystemCapacity, - ); - } return undefined; }; const usageUnavailable = () => - !ledger() && - (apiErrorCode(explanation.error) === 'monitored_system_usage_unavailable' || - !isMonitoredSystemLimitUsageAvailable(props.monitoredSystemLimit) || - displayCapacity()?.current_available === false); + !ledger() && apiErrorCode(explanation.error) === 'monitored_system_usage_unavailable'; const genericError = () => Boolean(explanation.error) && !usageUnavailable(); - const continuity = () => props.monitoredSystemContinuity ?? null; - const hasContinuityContext = () => { - const current = continuity(); - if (!current) { - return false; - } - return ( - current.capture_pending || - current.effective_limit !== current.plan_limit || - (typeof current.grandfathered_floor === 'number' && current.grandfathered_floor > 0) - ); - }; - const continuityForDisplay = createMemo(() => (hasContinuityContext() ? continuity() : null)); const systemKey = (system: MonitoredSystemLedgerEntry, index: number) => `${system.name}:${system.type}:${index}`; const toggleSystemExplanation = (key: string) => { @@ -219,78 +135,17 @@ export function MonitoredSystemLedgerPanel(props: MonitoredSystemLedgerPanelProp

-

- {capacitySummary()} -

- -

{capacityContext()}

-
+

{totalSummary()}

- {presentation.usageVerifyingLabel} + Verifying
- - {(current) => ( -
-

{presentation.continuityHeading}

-
-
-
{presentation.continuityPlanLimitLabel}
-
{formatLimitValue(current().plan_limit)}
-
-
-
{presentation.continuityEffectiveLimitLabel}
-
{formatLimitValue(current().effective_limit)}
-
- - {(floor) => ( -
-
- {presentation.continuityGrandfatheredFloorLabel} -
-
{floor()}
-
- )} -
-
-
{presentation.continuityCaptureLabel}
-
- {current().capture_pending - ? presentation.continuityCapturePendingLabel - : (formatCapturedAt(current().captured_at) ?? - presentation.continuityCaptureCapturedLabel)} -
-
-
-
- )} -
- {/* Loading state */}

@@ -337,19 +192,6 @@ export function MonitoredSystemLedgerPanel(props: MonitoredSystemLedgerPanelProp {/* Loaded content */} - -

-
-
- - 0} fallback={

{presentation.emptyState}

} diff --git a/frontend-modern/src/components/Settings/OrganizationBillingPanel.tsx b/frontend-modern/src/components/Settings/OrganizationBillingPanel.tsx index c9fb07455..48b6009cb 100644 --- a/frontend-modern/src/components/Settings/OrganizationBillingPanel.tsx +++ b/frontend-modern/src/components/Settings/OrganizationBillingPanel.tsx @@ -31,7 +31,7 @@ export const OrganizationBillingPanel: Component
} > @@ -55,9 +55,9 @@ export const OrganizationBillingPanel: Component - +
diff --git a/frontend-modern/src/components/Settings/ProLicensePanel.tsx b/frontend-modern/src/components/Settings/ProLicensePanel.tsx index c7426f9fd..44f173713 100644 --- a/frontend-modern/src/components/Settings/ProLicensePanel.tsx +++ b/frontend-modern/src/components/Settings/ProLicensePanel.tsx @@ -1,13 +1,11 @@ import { Show, type Component } from 'solid-js'; import RefreshCw from 'lucide-solid/icons/refresh-cw'; -import { MonitoredSystemLedgerPanel } from './MonitoredSystemLedgerPanel'; import { CommercialBillingShell, CommercialSection } from './CommercialBillingSections'; import { ProLicensePlanSection } from './ProLicensePlanSection'; import { SelfHostedCommercialRecoverySection } from './SelfHostedCommercialRecoverySection'; import { useProLicensePanelState } from './useProLicensePanelState'; import { SELF_HOSTED_PRO_BILLING_PRESENTATION } from './selfHostedBillingPresentation'; import { Subtabs } from '@/components/shared/Subtabs'; -import { getMonitoredSystemBriefSummary } from '@/utils/monitoredSystemPresentation'; import { presentationPolicyHidesCommercialSurfaces, sessionPresentationPolicyResolved, @@ -15,7 +13,6 @@ import { import { SELF_HOSTED_PRO_BILLING_PLAN_SECTION_ID, SELF_HOSTED_PRO_BILLING_RECOVERY_SECTION_ID, - SELF_HOSTED_PRO_BILLING_USAGE_SECTION_ID, } from '@/utils/pricingHandoff'; const ProLicensePolicyLoadingPanel: Component = () => ( @@ -78,14 +75,6 @@ const ProLicensePanelContent: Component = () => { value: 'plan', label: SELF_HOSTED_PRO_BILLING_PRESENTATION.planTabLabel, }, - ...(state.showUsageSection() - ? [ - { - value: 'usage', - label: SELF_HOSTED_PRO_BILLING_PRESENTATION.usageTabLabel, - }, - ] - : []), ]} /> @@ -106,8 +95,6 @@ const ProLicensePanelContent: Component = () => { grandfatheredPriceNotice={state.grandfatheredPriceNotice()} hasLicenseDetails={state.hasLicenseDetails()} loading={state.loading()} - monitoredSystemCapacitySection={state.monitoredSystemCapacitySection()} - monitoredSystemContinuityNotice={state.monitoredSystemContinuityNotice()} onReload={() => void state.loadPanelData()} planSelectionPrompt={state.planSelectionPrompt()} planComparisonSummary={state.planComparisonSummary()} @@ -131,22 +118,6 @@ const ProLicensePanelContent: Component = () => {
- - - - - - diff --git a/frontend-modern/src/components/Settings/ProLicensePlanSection.tsx b/frontend-modern/src/components/Settings/ProLicensePlanSection.tsx index 6f0762f50..d371d3fb5 100644 --- a/frontend-modern/src/components/Settings/ProLicensePlanSection.tsx +++ b/frontend-modern/src/components/Settings/ProLicensePlanSection.tsx @@ -21,17 +21,6 @@ interface ActionNotice extends Notice { actionDestination: UpgradeDestination; } -interface MonitoredSystemCapacitySection { - stats: Array<{ label: string; value: string }>; - statusMessage: string; - detailMessage?: string; - explanation?: { - label: string; - body: string; - }; - reviewUsageDestination: UpgradeDestination; -} - interface ProLicensePlanSectionProps { activationSuccessSummary: { title: string; @@ -74,8 +63,6 @@ interface ProLicensePlanSectionProps { grandfatheredPriceNotice: Notice | null; hasLicenseDetails: boolean; loading: boolean; - monitoredSystemCapacitySection: MonitoredSystemCapacitySection | null; - monitoredSystemContinuityNotice: Notice | null; onReload: () => void; planComparisonSummary: { cards: Array<{ @@ -314,51 +301,6 @@ export const ProLicensePlanSection: Component = (pro )}
- - {(notice) => ( -
-

{notice().title}

-

{notice().body}

-
- )} -
- - {(section) => ( -
-
-

Monitored-system policy

-

{section().statusMessage}

- - {(detail) =>

{detail()}

} -
-
- -
- -
- - - {(explanation) => ( -
- - {explanation().label} - -

{explanation().body}

-
- )} -
- -
- - Review monitored systems - -
-
- )} -

Could not load license status

diff --git a/frontend-modern/src/components/Settings/Settings.tsx b/frontend-modern/src/components/Settings/Settings.tsx index 795c9ae19..b0abc2f54 100644 --- a/frontend-modern/src/components/Settings/Settings.tsx +++ b/frontend-modern/src/components/Settings/Settings.tsx @@ -18,10 +18,7 @@ import { useSystemSettingsState } from './useSystemSettingsState'; import { useSettingsNavigation } from './useSettingsNavigation'; import { getSettingsLoadingState } from '@/utils/settingsShellPresentation'; -import { - getRuntimeLimit, - loadRuntimeCapabilities, -} from '@/stores/license'; +import { getRuntimeLimit, loadRuntimeCapabilities } from '@/stores/license'; import { isPro } from '@/stores/licenseCommercial'; interface SettingsProps { @@ -50,9 +47,7 @@ const Settings: Component = (props) => { searchQuery, setSearchQuery, } = useSettingsShellState({ activeTab }); - const organizationMonitoredSystemUsage = createMemo( - () => getRuntimeLimit('max_monitored_systems')?.current ?? 0, - ); + const organizationMonitoredSystemUsage = createMemo(() => 0); const organizationGuestUsage = createMemo(() => getRuntimeLimit('max_guests')?.current ?? 0); const discoverySettings = useDiscoverySettingsState(); diff --git a/frontend-modern/src/components/Settings/__tests__/MonitoredSystemAdmissionPreview.test.tsx b/frontend-modern/src/components/Settings/__tests__/MonitoredSystemImpactPreview.test.tsx similarity index 73% rename from frontend-modern/src/components/Settings/__tests__/MonitoredSystemAdmissionPreview.test.tsx rename to frontend-modern/src/components/Settings/__tests__/MonitoredSystemImpactPreview.test.tsx index dd1f602f7..f36fcbb92 100644 --- a/frontend-modern/src/components/Settings/__tests__/MonitoredSystemAdmissionPreview.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/MonitoredSystemImpactPreview.test.tsx @@ -1,7 +1,7 @@ import { cleanup, render, screen } from '@solidjs/testing-library'; import { afterEach, describe, expect, it } from 'vitest'; -import { MonitoredSystemAdmissionPreview } from '../MonitoredSystemAdmissionPreview'; +import { MonitoredSystemImpactPreview } from '../MonitoredSystemImpactPreview'; import type { MonitoredSystemLedgerPreviewResponse } from '@/api/monitoredSystemLedger'; const buildPreview = ( @@ -10,8 +10,6 @@ const buildPreview = ( current_count: 4, projected_count: 4, additional_count: 0, - limit: 10, - would_exceed_limit: false, effect: 'no_change', current_systems: [], projected_systems: [], @@ -20,13 +18,13 @@ const buildPreview = ( ...overrides, }); -describe('MonitoredSystemAdmissionPreview', () => { +describe('MonitoredSystemImpactPreview', () => { afterEach(() => { cleanup(); }); it('describes unchanged usage when a preview has no count impact', () => { - render(() => ); + render(() => ); expect( screen.getByText('This change keeps monitored-system count unchanged'), @@ -40,7 +38,7 @@ describe('MonitoredSystemAdmissionPreview', () => { it('describes removed systems when a preview reduces monitored-system usage', () => { render(() => ( - { ).toBeInTheDocument(); }); - it('describes continuity review failures without slash quota copy', () => { + it('describes added systems without capacity review copy', () => { render(() => ( - )); - expect( - screen.getByText('This change needs continuity review before saving'), - ).toBeInTheDocument(); + expect(screen.getByText('This change adds monitored systems')).toBeInTheDocument(); expect( screen.getByText( - 'Pulse currently counts 9 monitored systems. Saving this change would bring the count to 11 monitored systems (+2), above the current verified baseline of 10 monitored systems.', + 'Pulse currently counts 9 monitored systems. Saving this change would bring the count to 11 monitored systems (+2).', ), ).toBeInTheDocument(); }); diff --git a/frontend-modern/src/components/Settings/__tests__/MonitoredSystemLedgerPanel.test.tsx b/frontend-modern/src/components/Settings/__tests__/MonitoredSystemLedgerPanel.test.tsx index 62d061497..506f5e9f2 100644 --- a/frontend-modern/src/components/Settings/__tests__/MonitoredSystemLedgerPanel.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/MonitoredSystemLedgerPanel.test.tsx @@ -84,16 +84,7 @@ describe('MonitoredSystemLedgerPanel', () => { it('hides monitored-system usage in demo mode without requesting the ledger', () => { presentationPolicyHidesCommercialSurfacesMock.mockReturnValue(true); - render(() => ( - - )); + render(() => ); expect(explainMock).not.toHaveBeenCalled(); expect(screen.getByText('Monitored-system usage is hidden in demo mode')).toBeInTheDocument(); @@ -168,7 +159,6 @@ describe('MonitoredSystemLedgerPanel', () => { }, ], total: 1, - limit: 5, }), ); @@ -216,24 +206,13 @@ describe('MonitoredSystemLedgerPanel', () => { }), ); - render(() => ( - - )); + render(() => ); await waitFor(() => { expect(screen.getByText('Verifying monitored-system inventory')).toBeInTheDocument(); }); - expect(screen.getByText('Verifying…')).toBeInTheDocument(); + expect(screen.getByText('Verifying')).toBeInTheDocument(); expect( screen.getByText( 'Pulse is still collecting the first provider-owned inventory baseline. The monitored-system ledger will appear after that baseline completes.', @@ -245,40 +224,6 @@ describe('MonitoredSystemLedgerPanel', () => { ).not.toBeInTheDocument(); }); - it('surfaces monitored-system continuity context from entitlements', async () => { - explainMock.mockResolvedValue( - explainResponse({ - systems: [], - total: 7, - limit: 12, - }), - ); - - render(() => ( - - )); - - await waitFor(() => { - expect(screen.getByText('7 monitored systems')).toBeInTheDocument(); - }); - - expect(screen.getByText('Legacy continuity')).toBeInTheDocument(); - expect(screen.getByText('Plan baseline')).toBeInTheDocument(); - expect(screen.getByText('Current baseline')).toBeInTheDocument(); - expect(screen.getByText('Observed legacy estate')).toBeInTheDocument(); - expect(screen.getByText('Verification')).toBeInTheDocument(); - expect(screen.getByText('5')).toBeInTheDocument(); - expect(screen.getAllByText('12')).toHaveLength(2); - expect(screen.getByText('Pending')).toBeInTheDocument(); - }); - it('renders ledger data on successful load', async () => { explainMock.mockResolvedValue( explainResponse({ @@ -356,7 +301,6 @@ describe('MonitoredSystemLedgerPanel', () => { }, ], total: 2, - limit: 10, }), ); @@ -372,7 +316,7 @@ describe('MonitoredSystemLedgerPanel', () => { expect(screen.getAllByText('server-b (PBS Server via PBS)').length).toBeGreaterThan(0); expect( screen.getByText( - 'Review the top-level monitored systems Pulse has identified for reporting, migration continuity, and support context.', + 'Review the top-level monitored systems Pulse has identified for reporting and support context.', ), ).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'View counting rules' })).toBeInTheDocument(); @@ -452,7 +396,6 @@ describe('MonitoredSystemLedgerPanel', () => { }, ], total: 1, - limit: 10, }), ); @@ -501,7 +444,6 @@ describe('MonitoredSystemLedgerPanel', () => { }, ], total: 1, - limit: 10, }), ); @@ -542,7 +484,6 @@ describe('MonitoredSystemLedgerPanel', () => { }, ], total: 1, - limit: 10, }), ); @@ -566,7 +507,6 @@ describe('MonitoredSystemLedgerPanel', () => { explainResponse({ systems: [], total: 2, - limit: 5, }), ); diff --git a/frontend-modern/src/components/Settings/__tests__/OrganizationBillingPanel.test.tsx b/frontend-modern/src/components/Settings/__tests__/OrganizationBillingPanel.test.tsx index 8c1279a95..32dd8b124 100644 --- a/frontend-modern/src/components/Settings/__tests__/OrganizationBillingPanel.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/OrganizationBillingPanel.test.tsx @@ -79,7 +79,6 @@ describe('OrganizationBillingPanel', () => { is_lifetime: false, days_remaining: 30, features: [], - max_monitored_systems: 12, max_guests: 5, email: 'owner@example.com', expires_at: '2026-04-01T00:00:00Z', @@ -92,18 +91,18 @@ describe('OrganizationBillingPanel', () => { cleanup(); }); - it('renders organization usage from the canonical max_monitored_systems limit only', async () => { + it('renders monitored-system usage as not metered', async () => { render(() => ); await waitFor(() => { - expect(screen.getByText('5 / 12')).toBeInTheDocument(); + expect(screen.getByText('5 / Not metered')).toBeInTheDocument(); }); expect(getStatusMock).toHaveBeenCalledTimes(1); expect(screen.getByText('Plan')).toBeInTheDocument(); - expect(screen.getByText('Usage')).toBeInTheDocument(); + expect(screen.getAllByText('Usage')).toHaveLength(2); expect(screen.getByText('Cloud')).toBeInTheDocument(); - expect(screen.getByText('5 / 12')).toBeInTheDocument(); + expect(screen.getByText('5 / Not metered')).toBeInTheDocument(); expect(screen.getByText('2 / 5')).toBeInTheDocument(); expect(errorMock).not.toHaveBeenCalled(); }); diff --git a/frontend-modern/src/components/Settings/__tests__/ProLicensePanel.test.tsx b/frontend-modern/src/components/Settings/__tests__/ProLicensePanel.test.tsx index 54e8242f7..8d167fc1e 100644 --- a/frontend-modern/src/components/Settings/__tests__/ProLicensePanel.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/ProLicensePanel.test.tsx @@ -268,7 +268,6 @@ describe('ProLicensePanel', () => { expect(screen.queryByText(/trial/i)).not.toBeInTheDocument(); expect(screen.queryByText(/monitoring room/i)).not.toBeInTheDocument(); expect(screen.queryByText(/monitoring capacity/i)).not.toBeInTheDocument(); - expect(screen.queryByText(/monitored-system capacity/i)).not.toBeInTheDocument(); }); it('does not surface a trial-ended banner for retired self-hosted trial state', async () => { @@ -327,24 +326,6 @@ describe('ProLicensePanel', () => { licensed_email: 'owner@example.com', is_lifetime: false, trial_eligible: false, - monitored_system_continuity: { - plan_limit: 12, - grandfathered_floor: 23, - effective_limit: 23, - capture_pending: false, - }, - monitored_system_capacity: { - mode: 'at_limit_blocking_new', - urgency: 'enforced', - current: 23, - limit: 23, - current_available: true, - available_slots: 0, - overage: 0, - reason: 'limit_reached', - blocks_new_systems: true, - existing_monitoring_continues: true, - }, }; renderPanel(); @@ -579,8 +560,6 @@ describe('ProLicensePanel', () => { key: 'max_monitored_systems', limit: 10, current: 0, - current_available: false, - current_unavailable_reason: 'supplemental_inventory_unsettled', state: 'ok', }, ], @@ -590,24 +569,6 @@ describe('ProLicensePanel', () => { plan_version: 'legacy_migration_fallback', licensed_email: 'owner@example.com', trial_eligible: false, - monitored_system_continuity: { - plan_limit: 10, - effective_limit: 10, - capture_pending: true, - }, - monitored_system_capacity: { - mode: 'usage_unavailable', - urgency: 'ok', - current: 0, - limit: 10, - current_available: false, - current_unavailable_reason: 'supplemental_inventory_unsettled', - available_slots: 0, - overage: 0, - reason: 'legacy_migration_capture_pending', - blocks_new_systems: false, - existing_monitoring_continues: false, - }, }; renderPanel(); @@ -641,7 +602,6 @@ describe('ProLicensePanel', () => { key: 'max_monitored_systems', limit: 10, current: 23, - current_available: true, state: 'enforced', }, ], @@ -651,23 +611,6 @@ describe('ProLicensePanel', () => { plan_version: 'legacy_migration_fallback', licensed_email: 'owner@example.com', trial_eligible: false, - monitored_system_continuity: { - plan_limit: 10, - effective_limit: 10, - capture_pending: true, - }, - monitored_system_capacity: { - mode: 'over_limit_frozen', - urgency: 'enforced', - current: 23, - limit: 10, - current_available: true, - available_slots: 0, - overage: 13, - reason: 'legacy_migration_capture_pending', - blocks_new_systems: true, - existing_monitoring_continues: true, - }, }; renderPanel(); @@ -702,7 +645,6 @@ describe('ProLicensePanel', () => { key: 'max_monitored_systems', limit: 23, current: 23, - current_available: true, state: 'enforced', }, ], @@ -712,25 +654,6 @@ describe('ProLicensePanel', () => { plan_version: 'legacy_migration_fallback', 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, - }, - monitored_system_capacity: { - mode: 'at_limit_blocking_new', - urgency: 'enforced', - current: 23, - limit: 23, - current_available: true, - available_slots: 0, - overage: 0, - reason: 'limit_reached', - blocks_new_systems: true, - existing_monitoring_continues: true, - }, }; renderPanel(); @@ -1177,7 +1100,7 @@ describe('ProLicensePanel', () => { expect(proLicensePanelStateSource).toContain('useLocation'); expect(proLicensePanelStateSource).toContain('resolveSelfHostedBillingSection'); expect(proLicensePanelStateSource).toContain('getSelfHostedBillingPlanIntent'); - expect(proLicensePanelStateSource).toContain('getSelfHostedBillingUsageDetail'); + expect(proLicensePanelStateSource).not.toContain('getSelfHostedBillingUsageDetail'); expect(proLicensePanelStateSource).toContain('const setActiveSection = (section: string) => {'); expect(proLicensePanelStateSource).toContain('loadLicenseEntitlements(true)'); expect(proLicensePanelStateSource).toContain('loadCommercialPosture(true)'); @@ -1229,6 +1152,6 @@ describe('ProLicensePanel', () => { expect(selfHostedCommercialRecoverySectionSource).not.toContain('Start 14-day Pro Trial'); expect(selfHostedCommercialRecoverySectionSource).not.toContain('Legacy v5 license detected'); expect(proLicensePanelSource).toContain('id={SELF_HOSTED_PRO_BILLING_PLAN_SECTION_ID}'); - expect(proLicensePanelSource).toContain('id={SELF_HOSTED_PRO_BILLING_USAGE_SECTION_ID}'); + expect(proLicensePanelSource).not.toContain('SELF_HOSTED_PRO_BILLING_USAGE_SECTION_ID'); }); }); diff --git a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts index 95f2259df..3c689bd70 100644 --- a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts @@ -35,7 +35,7 @@ import infrastructureSourcePickerSource from '../InfrastructureSourcePicker.tsx? import infrastructureWorkspaceModelSource from '../infrastructureWorkspaceModel.ts?raw'; import agentProfileSettingsSource from '../agentProfileSettings.ts?raw'; import connectionsTableSource from '../ConnectionsTable.tsx?raw'; -import monitoredSystemAdmissionPreviewSource from '../MonitoredSystemAdmissionPreview.tsx?raw'; +import monitoredSystemImpactPreviewSource from '../MonitoredSystemImpactPreview.tsx?raw'; import connectionEditorSource from '../ConnectionEditor/ConnectionEditor.tsx?raw'; import addressProbeStepSource from '../ConnectionEditor/AddressProbeStep.tsx?raw'; import connectionEditorStateSource from '../ConnectionEditor/useConnectionEditor.ts?raw'; @@ -66,7 +66,6 @@ import infrastructureOnboardingPresentationSource from '../../../utils/infrastru import selfHostedBillingPresentationSource from '../selfHostedBillingPresentation.ts?raw'; import systemSettingsPresentationSource from '../../../utils/systemSettingsPresentation.ts?raw'; import auditLogPresentationSource from '../../../utils/auditLogPresentation.ts?raw'; -import monitoredSystemLimitWarningBannerSource from '../../shared/MonitoredSystemLimitWarningBanner.tsx?raw'; const settingsRuntimeSources = import.meta.glob(['../*.tsx', '../ConnectionEditor/**/*.tsx'], { query: '?raw', @@ -224,7 +223,6 @@ describe('settings architecture guardrails', () => { auditLogStateSource, auditWebhookPanelSource, auditWebhookStateSource, - monitoredSystemLimitWarningBannerSource, rbacFeatureGateSectionSource, rbacFeatureGateStateSource, reportingPanelSource, @@ -246,9 +244,6 @@ describe('settings architecture guardrails', () => { expect(rbacFeatureGateSectionSource).not.toContain('Custom Roles (Pro)'); expect(rbacFeatureGateSectionSource).not.toContain('Centralized Access Control (Pro)'); expect(ssoProvidersPanelSource).not.toContain('Add SAML (Pro)'); - expect(monitoredSystemLimitWarningBannerSource).not.toContain( - 'Unlimited self-hosted monitoring', - ); }); it('keeps SAML SSO available without a self-hosted Pro upsell boundary', () => { @@ -446,18 +441,18 @@ describe('settings architecture guardrails', () => { expect(infrastructureSourceManagerSource).toContain('Infrastructure systems'); expect(infrastructureSourceManagerSource).toContain('Run discovery'); expect(infrastructureSourceManagerSource).toContain('Discovery settings'); - expect(monitoredSystemAdmissionPreviewSource).toContain( - 'getMonitoredSystemAdmissionPreviewTitle', + expect(monitoredSystemImpactPreviewSource).toContain( + 'getMonitoredSystemImpactPreviewTitle', ); - expect(monitoredSystemAdmissionPreviewSource).toContain( - 'formatMonitoredSystemAdmissionPreviewSummary', + expect(monitoredSystemImpactPreviewSource).toContain( + 'formatMonitoredSystemImpactPreviewSummary', ); - expect(monitoredSystemAdmissionPreviewSource).not.toContain('Current usage'); - expect(monitoredSystemAdmissionPreviewSource).not.toContain(' / '); - expect(monitoredSystemAdmissionPreviewSource).not.toContain( - 'reuses your current monitored-system capacity', + expect(monitoredSystemImpactPreviewSource).not.toContain('Current usage'); + expect(monitoredSystemImpactPreviewSource).not.toContain(' / '); + expect(monitoredSystemImpactPreviewSource).not.toContain( + 'reuses your current monitored-system allowance', ); - expect(monitoredSystemAdmissionPreviewSource).not.toContain('frees monitored-system capacity'); + expect(monitoredSystemImpactPreviewSource).not.toContain('frees monitored-system allowance'); expect(infrastructureSourceManagerSource).toContain( "Add, discover, and verify the platform APIs plus Pulse Agent telemetry that make up Pulse's infrastructure model.", ); diff --git a/frontend-modern/src/components/Settings/__tests__/useTrueNASSettingsPanelState.test.tsx b/frontend-modern/src/components/Settings/__tests__/useTrueNASSettingsPanelState.test.tsx index 457ba3f07..54a3a2260 100644 --- a/frontend-modern/src/components/Settings/__tests__/useTrueNASSettingsPanelState.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/useTrueNASSettingsPanelState.test.tsx @@ -36,8 +36,6 @@ const safeTrueNASPreview = () => ({ current_count: 1, projected_count: 1, additional_count: 0, - limit: 10, - would_exceed_limit: false, effect: 'attaches_existing', current_systems: [], projected_systems: [ @@ -227,8 +225,11 @@ describe('useTrueNASSettingsPanelState', () => { expect(notificationStore.success).toHaveBeenCalledWith('TrueNAS connection successful'); }); - it('requires a successful monitored-system preview before saving', async () => { - vi.mocked(TrueNASAPI.listConnections).mockResolvedValueOnce([] as never); + it('saves a connection without requiring a monitored-system preview', async () => { + vi.mocked(TrueNASAPI.listConnections) + .mockResolvedValueOnce([] as never) + .mockResolvedValueOnce([] as never); + vi.mocked(TrueNASAPI.createConnection).mockResolvedValueOnce({} as never); const { result } = renderHook(() => useTrueNASSettingsPanelState()); await waitFor(() => expect(result.loading()).toBe(false)); @@ -239,13 +240,15 @@ describe('useTrueNASSettingsPanelState', () => { apiKey: 'secret', }); - expect(result.monitoredSystemAdmissionSaveBlocked()).toBe(true); await result.saveCurrentForm(); - expect(TrueNASAPI.createConnection).not.toHaveBeenCalled(); - expect(notificationStore.error).toHaveBeenCalledWith( - 'Pulse must preview the monitored-system impact for this platform connection before it can be saved.', + expect(TrueNASAPI.createConnection).toHaveBeenCalledWith( + expect.objectContaining({ + host: 'tower.local', + apiKey: 'secret', + }), ); + expect(notificationStore.success).toHaveBeenCalledWith('TrueNAS connection added'); }); it('previews monitored-system impact through the canonical TrueNAS preview path', async () => { @@ -254,8 +257,6 @@ describe('useTrueNASSettingsPanelState', () => { current_count: 4, projected_count: 4, additional_count: 0, - limit: 10, - would_exceed_limit: false, effect: 'attaches_existing', current_systems: [ { @@ -303,10 +304,13 @@ describe('useTrueNASSettingsPanelState', () => { expect(result.monitoredSystemPreview()?.effect).toBe('attaches_existing'); }); - it('blocks save when monitored-system usage is temporarily unavailable during preview', async () => { - vi.mocked(TrueNASAPI.listConnections).mockResolvedValueOnce([] as never); + it('allows save when monitored-system impact preview is temporarily unavailable', async () => { + vi.mocked(TrueNASAPI.listConnections) + .mockResolvedValueOnce([] as never) + .mockResolvedValueOnce([] as never); + vi.mocked(TrueNASAPI.createConnection).mockResolvedValueOnce({} as never); vi.mocked(TrueNASAPI.previewConnection).mockRejectedValueOnce( - Object.assign(new Error('Unable to verify monitored-system capacity right now'), { + Object.assign(new Error('Unable to verify monitored-system grouping right now'), { status: 503, code: 'monitored_system_usage_unavailable', details: { @@ -328,48 +332,25 @@ describe('useTrueNASSettingsPanelState', () => { expect(preview).toBeNull(); expect(result.monitoredSystemPreview()).toBeNull(); - expect(result.monitoredSystemAdmissionSaveBlocked()).toBe(true); expect(result.monitoredSystemPreviewErrorTitle()).toBe( 'Monitored-system verification is temporarily unavailable', ); expect(result.monitoredSystemPreviewError()).toBe( - 'Pulse is still settling provider-owned inventory for this platform connection, so the monitored-system check is not safe yet. Retry preview after the first baseline finishes.', + 'Pulse is still settling provider-owned inventory for this platform connection. You can still save the connection and review the impact after the first baseline finishes.', ); await result.saveCurrentForm(); - expect(TrueNASAPI.createConnection).not.toHaveBeenCalled(); - expect(notificationStore.error).toHaveBeenLastCalledWith( - 'Pulse is still settling provider-owned inventory for this platform connection, so the monitored-system check is not safe yet. Retry preview after the first baseline finishes.', - ); + expect(TrueNASAPI.createConnection).toHaveBeenCalled(); + expect(notificationStore.success).toHaveBeenCalledWith('TrueNAS connection added'); }); - it('reuses the canonical monitored-system preview when a save is denied by the backend', async () => { + it('surfaces backend save errors without reopening retired cap-preview handling', async () => { vi.mocked(TrueNASAPI.listConnections).mockResolvedValueOnce([] as never); vi.mocked(TrueNASAPI.previewConnection).mockResolvedValueOnce(safeTrueNASPreview() as never); vi.mocked(TrueNASAPI.createConnection).mockRejectedValueOnce( - Object.assign(new Error('Monitored-system capacity reached (10/9)'), { - status: 402, - feature: 'max_monitored_systems', - monitored_system_preview: { - current_count: 9, - projected_count: 10, - additional_count: 1, - limit: 9, - would_exceed_limit: true, - effect: 'creates_new', - current_systems: [], - projected_systems: [ - { - name: 'tower', - type: 'truenas-system', - status: 'online', - source: 'truenas', - }, - ], - current_system: null, - projected_system: null, - }, + Object.assign(new Error('TrueNAS connection save failed'), { + status: 500, }), ); @@ -384,20 +365,19 @@ describe('useTrueNASSettingsPanelState', () => { await result.previewCurrentForm(); await result.saveCurrentForm(); - expect(notificationStore.error).toHaveBeenCalledWith('Monitored-system capacity reached (10/9)'); + expect(notificationStore.error).toHaveBeenCalledWith('TrueNAS connection save failed'); expect(result.monitoredSystemPreview()).toMatchObject({ - would_exceed_limit: true, - projected_count: 10, - effect: 'creates_new', + projected_count: 1, + effect: 'attaches_existing', }); expect(result.dialogOpen()).toBe(true); }); - it('surfaces monitored-system usage unavailability when save races a stale preview', async () => { + it('treats save-time monitored-system usage unavailability as an ordinary save error', async () => { vi.mocked(TrueNASAPI.listConnections).mockResolvedValueOnce([] as never); vi.mocked(TrueNASAPI.previewConnection).mockResolvedValueOnce(safeTrueNASPreview() as never); vi.mocked(TrueNASAPI.createConnection).mockRejectedValueOnce( - Object.assign(new Error('Unable to verify monitored-system capacity right now'), { + Object.assign(new Error('Unable to verify monitored-system grouping right now'), { status: 503, code: 'monitored_system_usage_unavailable', details: { @@ -417,13 +397,13 @@ describe('useTrueNASSettingsPanelState', () => { await result.previewCurrentForm(); await result.saveCurrentForm(); - expect(result.monitoredSystemPreview()).toBeNull(); - expect(result.monitoredSystemAdmissionSaveBlocked()).toBe(true); - expect(result.monitoredSystemPreviewError()).toBe( - 'Pulse has settled provider-owned inventory and is rebuilding the canonical monitored-system view, so this connection cannot be saved yet. Retry preview in a moment.', - ); + expect(result.monitoredSystemPreview()).toMatchObject({ + projected_count: 1, + effect: 'attaches_existing', + }); + expect(result.monitoredSystemPreviewError()).toBeNull(); expect(notificationStore.error).toHaveBeenCalledWith( - 'Pulse has settled provider-owned inventory and is rebuilding the canonical monitored-system view, so this connection cannot be saved yet. Retry preview in a moment.', + 'Unable to verify monitored-system grouping right now', ); }); }); diff --git a/frontend-modern/src/components/Settings/__tests__/useVMwareSettingsPanelState.test.tsx b/frontend-modern/src/components/Settings/__tests__/useVMwareSettingsPanelState.test.tsx index f8d3f9da3..f32934b94 100644 --- a/frontend-modern/src/components/Settings/__tests__/useVMwareSettingsPanelState.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/useVMwareSettingsPanelState.test.tsx @@ -35,8 +35,6 @@ const safeVMwarePreview = () => ({ current_count: 1, projected_count: 1, additional_count: 0, - limit: 10, - would_exceed_limit: false, effect: 'attaches_existing', current_systems: [], projected_systems: [ @@ -224,8 +222,11 @@ describe('useVMwareSettingsPanelState', () => { expect(notificationStore.success).toHaveBeenCalledWith('VMware connection successful'); }); - it('requires a successful monitored-system preview before saving', async () => { - vi.mocked(VMwareAPI.listConnections).mockResolvedValueOnce([] as never); + it('saves a connection without requiring a monitored-system preview', async () => { + vi.mocked(VMwareAPI.listConnections) + .mockResolvedValueOnce([] as never) + .mockResolvedValueOnce([] as never); + vi.mocked(VMwareAPI.createConnection).mockResolvedValueOnce({} as never); const { result } = renderHook(() => useVMwareSettingsPanelState()); await waitFor(() => expect(result.loading()).toBe(false)); @@ -237,13 +238,16 @@ describe('useVMwareSettingsPanelState', () => { password: 'secret', }); - expect(result.monitoredSystemAdmissionSaveBlocked()).toBe(true); await result.saveCurrentForm(); - expect(VMwareAPI.createConnection).not.toHaveBeenCalled(); - expect(notificationStore.error).toHaveBeenCalledWith( - 'Pulse must preview the monitored-system impact for this platform connection before it can be saved.', + expect(VMwareAPI.createConnection).toHaveBeenCalledWith( + expect.objectContaining({ + host: 'vcsa.lab.local', + username: 'administrator@vsphere.local', + password: 'secret', + }), ); + expect(notificationStore.success).toHaveBeenCalledWith('VMware connection added'); }); it('surfaces categorized draft test guidance from structured backend failures', async () => { @@ -420,8 +424,6 @@ describe('useVMwareSettingsPanelState', () => { current_count: 1, projected_count: 3, additional_count: 2, - limit: 5, - would_exceed_limit: false, effect: 'creates_multiple', current_systems: [], projected_systems: [ @@ -461,10 +463,13 @@ describe('useVMwareSettingsPanelState', () => { expect(result.monitoredSystemPreview()?.projected_count).toBe(3); }); - it('blocks save when monitored-system usage is temporarily unavailable during preview', async () => { - vi.mocked(VMwareAPI.listConnections).mockResolvedValueOnce([] as never); + it('allows save when monitored-system impact preview is temporarily unavailable', async () => { + vi.mocked(VMwareAPI.listConnections) + .mockResolvedValueOnce([] as never) + .mockResolvedValueOnce([] as never); + vi.mocked(VMwareAPI.createConnection).mockResolvedValueOnce({} as never); vi.mocked(VMwareAPI.previewConnection).mockRejectedValueOnce( - Object.assign(new Error('Unable to verify monitored-system capacity right now'), { + Object.assign(new Error('Unable to verify monitored-system grouping right now'), { status: 503, code: 'monitored_system_usage_unavailable', details: { @@ -488,61 +493,25 @@ describe('useVMwareSettingsPanelState', () => { expect(preview).toBeNull(); expect(result.connectionFailure()).toBeNull(); expect(result.monitoredSystemPreview()).toBeNull(); - expect(result.monitoredSystemAdmissionSaveBlocked()).toBe(true); expect(result.monitoredSystemPreviewErrorTitle()).toBe( 'Monitored-system verification is temporarily unavailable', ); expect(result.monitoredSystemPreviewError()).toBe( - 'Pulse is still settling provider-owned inventory for this platform connection, so the monitored-system check is not safe yet. Retry preview after the first baseline finishes.', + 'Pulse is still settling provider-owned inventory for this platform connection. You can still save the connection and review the impact after the first baseline finishes.', ); await result.saveCurrentForm(); - expect(VMwareAPI.createConnection).not.toHaveBeenCalled(); - expect(notificationStore.error).toHaveBeenLastCalledWith( - 'Pulse is still settling provider-owned inventory for this platform connection, so the monitored-system check is not safe yet. Retry preview after the first baseline finishes.', - ); + expect(VMwareAPI.createConnection).toHaveBeenCalled(); + expect(notificationStore.success).toHaveBeenCalledWith('VMware connection added'); }); - it('reuses the canonical monitored-system preview when a save is denied by the backend', async () => { + it('surfaces backend save errors without reopening retired cap-preview handling', async () => { vi.mocked(VMwareAPI.listConnections).mockResolvedValueOnce([] as never); vi.mocked(VMwareAPI.previewConnection).mockResolvedValueOnce(safeVMwarePreview() as never); vi.mocked(VMwareAPI.createConnection).mockRejectedValueOnce( - Object.assign(new Error('Monitored-system capacity reached (7/6)'), { - status: 402, - feature: 'max_monitored_systems', - monitored_system_preview: { - current_count: 5, - projected_count: 7, - additional_count: 2, - limit: 6, - would_exceed_limit: true, - effect: 'mixed_existing_and_new', - current_systems: [ - { - name: 'esxi-01', - type: 'agent', - status: 'online', - source: 'agent', - }, - ], - projected_systems: [ - { - name: 'esxi-01', - type: 'agent', - status: 'online', - source: 'vmware', - }, - { - name: 'esxi-02', - type: 'agent', - status: 'online', - source: 'vmware', - }, - ], - current_system: null, - projected_system: null, - }, + Object.assign(new Error('VMware connection save failed'), { + status: 500, }), ); @@ -558,20 +527,19 @@ describe('useVMwareSettingsPanelState', () => { await result.previewCurrentForm(); await result.saveCurrentForm(); - expect(notificationStore.error).toHaveBeenCalledWith('Monitored-system capacity reached (7/6)'); + expect(notificationStore.error).toHaveBeenCalledWith('VMware connection save failed'); expect(result.monitoredSystemPreview()).toMatchObject({ - would_exceed_limit: true, - projected_count: 7, - effect: 'mixed_existing_and_new', + projected_count: 1, + effect: 'attaches_existing', }); expect(result.dialogOpen()).toBe(true); }); - it('surfaces monitored-system usage unavailability when save races a stale preview', async () => { + it('treats save-time monitored-system usage unavailability as an ordinary save error', async () => { vi.mocked(VMwareAPI.listConnections).mockResolvedValueOnce([] as never); vi.mocked(VMwareAPI.previewConnection).mockResolvedValueOnce(safeVMwarePreview() as never); vi.mocked(VMwareAPI.createConnection).mockRejectedValueOnce( - Object.assign(new Error('Unable to verify monitored-system capacity right now'), { + Object.assign(new Error('Unable to verify monitored-system grouping right now'), { status: 503, code: 'monitored_system_usage_unavailable', details: { @@ -593,13 +561,13 @@ describe('useVMwareSettingsPanelState', () => { await result.saveCurrentForm(); expect(result.connectionFailure()).toBeNull(); - expect(result.monitoredSystemPreview()).toBeNull(); - expect(result.monitoredSystemAdmissionSaveBlocked()).toBe(true); - expect(result.monitoredSystemPreviewError()).toBe( - 'Pulse has settled provider-owned inventory and is rebuilding the canonical monitored-system view, so this connection cannot be saved yet. Retry preview in a moment.', - ); + expect(result.monitoredSystemPreview()).toMatchObject({ + projected_count: 1, + effect: 'attaches_existing', + }); + expect(result.monitoredSystemPreviewError()).toBeNull(); expect(notificationStore.error).toHaveBeenCalledWith( - 'Pulse has settled provider-owned inventory and is rebuilding the canonical monitored-system view, so this connection cannot be saved yet. Retry preview in a moment.', + 'Unable to verify monitored-system grouping right now', ); }); }); diff --git a/frontend-modern/src/components/Settings/useProLicensePanelState.ts b/frontend-modern/src/components/Settings/useProLicensePanelState.ts index cb31b5b82..928c9c785 100644 --- a/frontend-modern/src/components/Settings/useProLicensePanelState.ts +++ b/frontend-modern/src/components/Settings/useProLicensePanelState.ts @@ -18,12 +18,10 @@ import { getSelfHostedPlanComparisonPresentation, getSelfHostedCurrentPlanPresentation, getLicenseFeatureLabel, - getMonitoredSystemContinuityNotice, getPurchaseActivationNotice, getSelfHostedCurrentPlanStatusPresentation, getLicenseSubscriptionStatusPresentation, getLicenseTierLabel, - getDisplayableMonitoredSystemContinuity, isDisplayableLicenseFeature, } from '@/utils/licensePresentation'; import { @@ -31,10 +29,8 @@ import { getSelfHostedBillingPlanDetail, getSelfHostedBillingPlanIntent, getSelfHostedBillingPurchaseArrival, - getSelfHostedBillingUsageDetail, resolveSelfHostedBillingSection, resolveSelfHostedPurchaseStartDestination, - SELF_HOSTED_PRO_BILLING_COUNTING_RULES_DETAIL, SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_INTENT, SELF_HOSTED_PRO_BILLING_RECOVERY_DETAIL, SELF_HOSTED_PRO_BILLING_PURCHASE_ACTIVATED, @@ -47,23 +43,14 @@ import { SELF_HOSTED_PRO_BILLING_PLAN_INTENT_QUERY_PARAM, SELF_HOSTED_PRO_BILLING_PLAN_ROUTE, SELF_HOSTED_PRO_BILLING_PURCHASE_QUERY_PARAM, - SELF_HOSTED_PRO_BILLING_USAGE_HREF, - SELF_HOSTED_PRO_BILLING_USAGE_ROUTE, type SelfHostedBillingPlanIntent, type SelfHostedBillingSection, } from '@/utils/pricingHandoff'; import { buildSelfHostedCommercialPlanModel, LIFETIME_DAYS_REMAINING_LABEL, - SELF_HOSTED_NOT_METERED_LABEL, } from '@/utils/commercialBillingModel'; import { getSelfHostedPlanDefinitionForBillingTier } from '@/utils/selfHostedPlans'; -import { - buildMonitoredSystemCapacitySectionModel, - getMonitoredSystemLimitCapacityStatusSummary, - getMonitoredSystemLimitUsageSummary, - resolveMonitoredSystemCapacityStatus, -} from '@/utils/monitoredSystemPresentation'; import { resolveUpgradeDestination, type UpgradeDestination } from '@/utils/upgradeNavigation'; import { SELF_HOSTED_PRO_BILLING_PRESENTATION } from './selfHostedBillingPresentation'; @@ -148,104 +135,30 @@ export function useProLicensePanelState() { resolveSelfHostedBillingSection(location.pathname, location.search, location.hash), ); - const limitStatus = (key: string) => entitlements()?.limits?.find((entry) => entry.key === key); - const monitoredSystemContinuity = createMemo(() => entitlements()?.monitored_system_continuity); const selfHostedPlanDefinition = createMemo(() => getSelfHostedPlanDefinitionForBillingTier(entitlements()?.tier), ); - const usesCanonicalSelfHostedPlan = createMemo(() => Boolean(selfHostedPlanDefinition())); - const monitoredSystemLimitStatus = createMemo(() => - usesCanonicalSelfHostedPlan() ? undefined : limitStatus('max_monitored_systems'), - ); - const monitoredSystemCapacity = createMemo(() => - usesCanonicalSelfHostedPlan() ? undefined : entitlements()?.monitored_system_capacity, - ); - const displayableMonitoredSystemContinuity = createMemo(() => { - if (usesCanonicalSelfHostedPlan()) { - return null; - } - return getDisplayableMonitoredSystemContinuity({ - continuity: monitoredSystemContinuity(), - planVersion: entitlements()?.plan_version, - isLifetime: entitlements()?.is_lifetime, - subscriptionState: entitlements()?.subscription_state, - }); - }); - - const showUsageSection = createMemo(() => { - if (!panelDataSettled()) { - return true; - } - if (usesCanonicalSelfHostedPlan()) { - return false; - } - - const continuity = displayableMonitoredSystemContinuity(); - if (continuity) { - if (continuity.capture_pending) { - return true; - } - if (typeof continuity.plan_limit === 'number' && continuity.plan_limit > 0) { - return true; - } - if (typeof continuity.effective_limit === 'number' && continuity.effective_limit > 0) { - return true; - } - if ( - typeof continuity.grandfathered_floor === 'number' && - continuity.grandfathered_floor > 0 - ) { - return true; - } - } - - const resolved = resolveMonitoredSystemCapacityStatus( - monitoredSystemCapacity(), - monitoredSystemLimitStatus(), - ); - return Boolean(resolved && resolved.limit > 0); - }); const activeSection = createMemo(() => { - if (!panelDataSettled()) { - return requestedSection(); - } - if (requestedSection() === 'usage' && !showUsageSection()) { - return 'plan'; - } - return requestedSection(); + return requestedSection() === 'usage' ? 'plan' : requestedSection(); }); createEffect(() => { if (!panelDataSettled()) { return; } - if (requestedSection() !== 'usage' || showUsageSection()) { + if (requestedSection() !== 'usage') { return; } navigate(SELF_HOSTED_PRO_BILLING_PLAN_ROUTE, { replace: true, scroll: false }); }); const setActiveSection = (section: string) => { - if (section !== 'plan' && section !== 'usage') { + if (section !== 'plan') { return; } - if (section === 'usage' && panelDataSettled() && !showUsageSection()) { - navigate(SELF_HOSTED_PRO_BILLING_PLAN_ROUTE, { replace: false, scroll: false }); - return; - } - const nextPath = - section === 'usage' - ? SELF_HOSTED_PRO_BILLING_USAGE_ROUTE - : SELF_HOSTED_PRO_BILLING_PLAN_ROUTE; - navigate(nextPath, { replace: false, scroll: false }); + navigate(SELF_HOSTED_PRO_BILLING_PLAN_ROUTE, { replace: false, scroll: false }); }; - const showCountingRulesByDefault = createMemo( - () => - activeSection() === 'usage' && - getSelfHostedBillingUsageDetail(location.search) === - SELF_HOSTED_PRO_BILLING_COUNTING_RULES_DETAIL, - ); const showRecoveryByDefault = createMemo( () => activeSection() === 'plan' && @@ -336,58 +249,7 @@ export function useProLicensePanelState() { return segments.length === 3 && segments.every((segment) => segment.length > 0); }); - const monitoredSystemUsageSummary = createMemo(() => { - const limit = monitoredSystemLimitStatus(); - const capacity = monitoredSystemCapacity(); - if (!limit && !capacity && usesCanonicalSelfHostedPlan()) { - return SELF_HOSTED_NOT_METERED_LABEL; - } - return getMonitoredSystemLimitUsageSummary(limit, capacity); - }); - const monitoredSystemCapacityStatusSummary = createMemo(() => { - const limit = monitoredSystemLimitStatus(); - const capacity = monitoredSystemCapacity(); - if (!limit && !capacity && usesCanonicalSelfHostedPlan()) { - return SELF_HOSTED_NOT_METERED_LABEL; - } - return getMonitoredSystemLimitCapacityStatusSummary(limit, capacity); - }); const currentRetailPlanDefinition = createMemo(() => selfHostedPlanDefinition()); - const monitoredSystemContinuityNotice = createMemo(() => { - const continuity = displayableMonitoredSystemContinuity(); - if (!continuity) { - return null; - } - return getMonitoredSystemContinuityNotice( - continuity, - monitoredSystemLimitStatus(), - monitoredSystemCapacity(), - { - planVersion: entitlements()?.plan_version, - isLifetime: entitlements()?.is_lifetime, - subscriptionState: entitlements()?.subscription_state, - }, - ); - }); - const monitoredSystemCapacitySection = createMemo(() => { - const section = buildMonitoredSystemCapacitySectionModel( - monitoredSystemLimitStatus(), - monitoredSystemCapacity(), - ); - if (!section) { - return null; - } - return { - ...section, - reviewUsageDestination: resolveUpgradeDestination(SELF_HOSTED_PRO_BILLING_USAGE_HREF), - }; - }); - const continuityCapturedAt = createMemo(() => { - const capturedAt = displayableMonitoredSystemContinuity()?.captured_at; - return typeof capturedAt === 'number' && capturedAt > 0 - ? formatUnixDate(capturedAt) - : undefined; - }); const purchaseActivationNotice = createMemo(() => { if (purchaseActivationResult().trim().toLowerCase() === 'activated') { @@ -466,16 +328,7 @@ export function useProLicensePanelState() { planTerms: formattedPlanTerms() || undefined, expires: displayedExpiry(), daysRemaining: displayedDaysRemaining() ?? 'Unknown', - monitoredSystemsSummary: monitoredSystemUsageSummary(), - capacityStatusSummary: monitoredSystemCapacityStatusSummary(), - maxMonitoredSystems: - typeof monitoredSystemLimitStatus()?.limit === 'number' && - monitoredSystemLimitStatus()!.limit > 0 - ? monitoredSystemLimitStatus()!.limit - : SELF_HOSTED_NOT_METERED_LABEL, retailPlanDefinition: currentRetailPlanDefinition(), - monitoredSystemContinuity: displayableMonitoredSystemContinuity() ?? null, - continuityCapturedAt: continuityCapturedAt(), }), ); const currentPlanSummary = createMemo(() => { @@ -573,11 +426,6 @@ export function useProLicensePanelState() { commercialPlanModel, currentPlanSummary, planComparisonSummary, - monitoredSystemCapacity, - monitoredSystemLimitStatus, - displayableMonitoredSystemContinuity, - monitoredSystemCapacitySection, - monitoredSystemContinuityNotice, entitlements, formattedFeatures, grandfatheredPriceNotice, @@ -593,8 +441,6 @@ export function useProLicensePanelState() { purchaseActivationAction, setActiveSection, setLicenseKey, - showUsageSection, - showCountingRulesByDefault, showRecoveryByDefault, statusPresentation, }; diff --git a/frontend-modern/src/components/Settings/useTrueNASSettingsPanelState.ts b/frontend-modern/src/components/Settings/useTrueNASSettingsPanelState.ts index f39973527..1cf791ee2 100644 --- a/frontend-modern/src/components/Settings/useTrueNASSettingsPanelState.ts +++ b/frontend-modern/src/components/Settings/useTrueNASSettingsPanelState.ts @@ -5,7 +5,6 @@ import type { MonitoredSystemLedgerPreviewResponse } from '@/api/monitoredSystem import { apiErrorCode, apiErrorDetailField, - apiErrorMonitoredSystemPreview, apiErrorStatus, } from '@/api/responseUtils'; import { @@ -15,9 +14,8 @@ import { type TrueNASConnectionInput, } from '@/api/truenas'; import { - buildMonitoredSystemAdmissionPreviewUnavailableState, - getMonitoredSystemAdmissionPreviewSaveBlockedMessage, - type MonitoredSystemAdmissionPreviewUnavailableState, + buildMonitoredSystemImpactPreviewUnavailableState, + type MonitoredSystemImpactPreviewUnavailableState, } from '@/utils/monitoredSystemPresentation'; type TrueNASAuthMode = 'apiKey' | 'userpass'; @@ -131,8 +129,8 @@ const getErrorMessage = (error: unknown, fallback: string): string => { return fallback; }; -const monitoredSystemAdmissionPreviewUnavailableStateFromError = (error: unknown) => { - return buildMonitoredSystemAdmissionPreviewUnavailableState({ +const monitoredSystemImpactPreviewUnavailableStateFromError = (error: unknown) => { + return buildMonitoredSystemImpactPreviewUnavailableState({ code: apiErrorCode(error), reason: apiErrorDetailField(error, 'reason'), }); @@ -191,7 +189,7 @@ export function useTrueNASSettingsPanelState() { const [monitoredSystemPreview, setMonitoredSystemPreview] = createSignal(null); const [monitoredSystemPreviewUnavailableState, setMonitoredSystemPreviewUnavailableState] = - createSignal(null); + createSignal(null); const [monitoredSystemPreviewGenericError, setMonitoredSystemPreviewGenericError] = createSignal< string | null >(null); @@ -201,18 +199,6 @@ export function useTrueNASSettingsPanelState() { const monitoredSystemPreviewError = createMemo( () => monitoredSystemPreviewUnavailableState()?.message ?? monitoredSystemPreviewGenericError(), ); - const monitoredSystemAdmissionSaveBlockedMessage = createMemo(() => - getMonitoredSystemAdmissionPreviewSaveBlockedMessage({ - preview: monitoredSystemPreview(), - unavailableState: monitoredSystemPreviewUnavailableState(), - error: monitoredSystemPreviewGenericError(), - loading: previewing(), - }), - ); - const monitoredSystemAdmissionSaveBlocked = createMemo( - () => monitoredSystemAdmissionSaveBlockedMessage() !== null, - ); - const editingConnection = createMemo( () => connections().find((connection) => connection.id === editingConnectionId()) ?? null, ); @@ -352,7 +338,7 @@ export function useTrueNASSettingsPanelState() { setMonitoredSystemPreviewGenericError(null); return preview; } catch (error) { - const unavailableState = monitoredSystemAdmissionPreviewUnavailableStateFromError(error); + const unavailableState = monitoredSystemImpactPreviewUnavailableStateFromError(error); setMonitoredSystemPreview(null); if (unavailableState) { setMonitoredSystemPreviewUnavailableState(unavailableState); @@ -371,11 +357,6 @@ export function useTrueNASSettingsPanelState() { }; const saveCurrentForm = async () => { - const blockedMessage = monitoredSystemAdmissionSaveBlockedMessage(); - if (blockedMessage) { - notificationStore.error(blockedMessage); - return; - } setSaving(true); try { const payload = buildConnectionInput(form()); @@ -389,22 +370,8 @@ export function useTrueNASSettingsPanelState() { resetDialogState(); await loadConnections(); } catch (error) { - const unavailableState = monitoredSystemAdmissionPreviewUnavailableStateFromError(error); - const preview = apiErrorMonitoredSystemPreview(error); - if (preview) { - setMonitoredSystemPreview(preview); - setMonitoredSystemPreviewUnavailableState(null); - setMonitoredSystemPreviewGenericError(null); - } else if (unavailableState) { - setMonitoredSystemPreview(null); - setMonitoredSystemPreviewUnavailableState(unavailableState); - setMonitoredSystemPreviewGenericError(null); - } else { - setMonitoredSystemPreviewUnavailableState(null); - } - const message = unavailableState - ? unavailableState.message - : getErrorMessage(error, 'Failed to save TrueNAS connection'); + setMonitoredSystemPreviewUnavailableState(null); + const message = getErrorMessage(error, 'Failed to save TrueNAS connection'); notificationStore.error(message); logger.error('[TrueNAS Settings] Save failed', error); } finally { @@ -450,7 +417,6 @@ export function useTrueNASSettingsPanelState() { openEditDialog, pendingDeleteConnection, previewCurrentForm, - monitoredSystemAdmissionSaveBlocked, monitoredSystemPreview, monitoredSystemPreviewError, monitoredSystemPreviewErrorTitle, diff --git a/frontend-modern/src/components/Settings/useVMwareSettingsPanelState.ts b/frontend-modern/src/components/Settings/useVMwareSettingsPanelState.ts index 94c9ce175..a7fff097b 100644 --- a/frontend-modern/src/components/Settings/useVMwareSettingsPanelState.ts +++ b/frontend-modern/src/components/Settings/useVMwareSettingsPanelState.ts @@ -9,15 +9,13 @@ import { import { apiErrorCode, apiErrorDetailField, - apiErrorMonitoredSystemPreview, apiErrorStatus, } from '@/api/responseUtils'; import { notificationStore } from '@/stores/notifications'; import { logger } from '@/utils/logger'; import { - buildMonitoredSystemAdmissionPreviewUnavailableState, - getMonitoredSystemAdmissionPreviewSaveBlockedMessage, - type MonitoredSystemAdmissionPreviewUnavailableState, + buildMonitoredSystemImpactPreviewUnavailableState, + type MonitoredSystemImpactPreviewUnavailableState, } from '@/utils/monitoredSystemPresentation'; import { buildVMwareConnectionFailurePresentation, @@ -99,8 +97,8 @@ const getErrorMessage = (error: unknown, fallback: string): string => { const getVMwareErrorMessage = (error: unknown, fallback: string): string => apiErrorDetailField(error, 'error') ?? getErrorMessage(error, fallback); -const monitoredSystemAdmissionPreviewUnavailableStateFromError = (error: unknown) => { - return buildMonitoredSystemAdmissionPreviewUnavailableState({ +const monitoredSystemImpactPreviewUnavailableStateFromError = (error: unknown) => { + return buildMonitoredSystemImpactPreviewUnavailableState({ code: apiErrorCode(error), reason: apiErrorDetailField(error, 'reason'), }); @@ -147,7 +145,7 @@ export function useVMwareSettingsPanelState() { const [monitoredSystemPreview, setMonitoredSystemPreview] = createSignal(null); const [monitoredSystemPreviewUnavailableState, setMonitoredSystemPreviewUnavailableState] = - createSignal(null); + createSignal(null); const [monitoredSystemPreviewGenericError, setMonitoredSystemPreviewGenericError] = createSignal< string | null >(null); @@ -157,18 +155,6 @@ export function useVMwareSettingsPanelState() { const monitoredSystemPreviewError = createMemo( () => monitoredSystemPreviewUnavailableState()?.message ?? monitoredSystemPreviewGenericError(), ); - const monitoredSystemAdmissionSaveBlockedMessage = createMemo(() => - getMonitoredSystemAdmissionPreviewSaveBlockedMessage({ - preview: monitoredSystemPreview(), - unavailableState: monitoredSystemPreviewUnavailableState(), - error: monitoredSystemPreviewGenericError(), - loading: previewing(), - }), - ); - const monitoredSystemAdmissionSaveBlocked = createMemo( - () => monitoredSystemAdmissionSaveBlockedMessage() !== null, - ); - const editingConnection = createMemo( () => connections().find((connection) => connection.id === editingConnectionId()) ?? null, ); @@ -328,7 +314,7 @@ export function useVMwareSettingsPanelState() { setMonitoredSystemPreviewGenericError(null); return preview; } catch (error) { - const unavailableState = monitoredSystemAdmissionPreviewUnavailableStateFromError(error); + const unavailableState = monitoredSystemImpactPreviewUnavailableStateFromError(error); if (unavailableState) { setMonitoredSystemPreview(null); setMonitoredSystemPreviewUnavailableState(unavailableState); @@ -359,11 +345,6 @@ export function useVMwareSettingsPanelState() { }; const saveCurrentForm = async () => { - const blockedMessage = monitoredSystemAdmissionSaveBlockedMessage(); - if (blockedMessage) { - notificationStore.error(blockedMessage); - return; - } setSaving(true); try { const payload = buildConnectionInput(form()); @@ -377,22 +358,8 @@ export function useVMwareSettingsPanelState() { resetDialogState(); await loadConnections(); } catch (error) { - const unavailableState = monitoredSystemAdmissionPreviewUnavailableStateFromError(error); - const preview = apiErrorMonitoredSystemPreview(error); - if (preview) { - setMonitoredSystemPreview(preview); - setMonitoredSystemPreviewUnavailableState(null); - setMonitoredSystemPreviewGenericError(null); - } else if (unavailableState) { - setMonitoredSystemPreview(null); - setMonitoredSystemPreviewUnavailableState(unavailableState); - setMonitoredSystemPreviewGenericError(null); - } else { - setMonitoredSystemPreviewUnavailableState(null); - } - const message = unavailableState - ? unavailableState.message - : getErrorMessage(error, 'Failed to save VMware connection'); + setMonitoredSystemPreviewUnavailableState(null); + const message = getErrorMessage(error, 'Failed to save VMware connection'); notificationStore.error(message); logger.error('[VMware Settings] Save failed', error); } finally { @@ -439,7 +406,6 @@ export function useVMwareSettingsPanelState() { openEditDialog, pendingDeleteConnection, previewCurrentForm, - monitoredSystemAdmissionSaveBlocked, monitoredSystemPreview, monitoredSystemPreviewError, monitoredSystemPreviewErrorTitle, diff --git a/frontend-modern/src/components/shared/MonitoredSystemLimitWarningBanner.tsx b/frontend-modern/src/components/shared/MonitoredSystemLimitWarningBanner.tsx deleted file mode 100644 index 66f5f57f7..000000000 --- a/frontend-modern/src/components/shared/MonitoredSystemLimitWarningBanner.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { Component, Show } from 'solid-js'; -import { - MONITORED_SYSTEM_LIMIT_INSTALL_COLLECTORS_LABEL, - MONITORED_SYSTEM_LIMIT_REVIEW_POLICY_LABEL, -} from './monitoredSystemLimitWarningBannerModel'; -import { UpgradeLink } from './UpgradeLink'; -import { useMonitoredSystemLimitWarningBannerState } from './useMonitoredSystemLimitWarningBannerState'; - -export const MonitoredSystemLimitWarningBanner: Component = () => { - const state = useMonitoredSystemLimitWarningBannerState(); - - return ( - -
-
- {state.monitoredSystemSummary()} - - {MONITORED_SYSTEM_LIMIT_REVIEW_POLICY_LABEL} - - - - {MONITORED_SYSTEM_LIMIT_INSTALL_COLLECTORS_LABEL} - - -
-
-
- ); -}; diff --git a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts index 9fdbb7215..4dc5f08ab 100644 --- a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts +++ b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts @@ -81,8 +81,6 @@ import infrastructureSummaryTableRowSource from '@/components/shared/Infrastruct import infrastructureSelectorModelSource from '@/components/shared/infrastructureSelectorModel.ts?raw'; import infrastructureSummaryTableModelSource from '@/components/shared/infrastructureSummaryTableModel.ts?raw'; import infrastructureSummaryTableStateSource from '@/components/shared/useInfrastructureSummaryTableState.ts?raw'; -import monitoredSystemLimitWarningBannerSource from '@/components/shared/MonitoredSystemLimitWarningBanner.tsx?raw'; -import monitoredSystemLimitWarningBannerModelSource from '@/components/shared/monitoredSystemLimitWarningBannerModel.ts?raw'; import selectionCardGroupSource from '@/components/shared/SelectionCardGroup.tsx?raw'; import selectionCardGroupModelSource from '@/components/shared/selectionCardGroupModel.ts?raw'; import summaryMetricCardSource from '@/components/shared/SummaryMetricCard.tsx?raw'; @@ -115,7 +113,6 @@ import searchTipsPopoverStateSource from '@/components/shared/useSearchTipsPopov import tooltipStateSource from '@/components/shared/useTooltipState.ts?raw'; import upgradeNavigationHookSource from '@/components/shared/useUpgradeNavigation.ts?raw'; import interactiveSparklineStateSource from '@/components/shared/useInteractiveSparklineState.ts?raw'; -import monitoredSystemLimitWarningBannerStateSource from '@/components/shared/useMonitoredSystemLimitWarningBannerState.ts?raw'; import selectionCardGroupStateSource from '@/components/shared/useSelectionCardGroupState.ts?raw'; import webInterfaceUrlFieldSource from '@/components/shared/WebInterfaceUrlField.tsx?raw'; import webInterfaceUrlFieldModelSource from '@/components/shared/webInterfaceUrlFieldModel.ts?raw'; @@ -866,105 +863,6 @@ describe('shared primitive guardrails', () => { expect(tlsVerificationWarningBannerSource).not.toContain('CalloutCard'); }); - it('keeps shared fleet limit banner copy on the monitored-system commercial term', () => { - expect(monitoredSystemLimitWarningBannerModelSource).toContain( - '@/utils/monitoredSystemPresentation', - ); - expect(monitoredSystemLimitWarningBannerModelSource).toContain( - 'formatMonitoredSystemLimitSummary', - ); - expect(monitoredSystemLimitWarningBannerModelSource).toContain( - 'formatMonitoredSystemMigrationMessage', - ); - expect(monitoredSystemLimitWarningBannerModelSource).toContain( - 'getMonitoredSystemLimitInstallCollectorsLabel', - ); - expect(monitoredSystemLimitWarningBannerModelSource).not.toContain('Monitored systems:'); - expect(monitoredSystemLimitWarningBannerModelSource).not.toContain('monitored-system cap'); - expect(monitoredSystemLimitWarningBannerModelSource).not.toContain('Install v6 collectors'); - expect(monitoredSystemLimitWarningBannerModelSource).not.toContain('v6 Unified Agents:'); - expect(monitoredSystemLimitWarningBannerModelSource).not.toContain( - 'do not count toward Unified Agents.', - ); - expect(monitoredSystemLimitWarningBannerModelSource).not.toContain('Install v6 Unified Agents'); - }); - - it('keeps monitored system limit warning banner on shell, runtime, and model owners', () => { - expect(monitoredSystemLimitWarningBannerSource).toContain( - 'useMonitoredSystemLimitWarningBannerState', - ); - expect(monitoredSystemLimitWarningBannerSource).toContain('UpgradeLink'); - expect(monitoredSystemLimitWarningBannerSource).toContain( - 'MONITORED_SYSTEM_LIMIT_REVIEW_POLICY_LABEL', - ); - expect(monitoredSystemLimitWarningBannerSource).not.toContain('createEffect'); - expect(monitoredSystemLimitWarningBannerSource).not.toContain('createMemo'); - expect(monitoredSystemLimitWarningBannerSource).not.toContain('loadRuntimeCapabilities'); - expect(monitoredSystemLimitWarningBannerSource).not.toContain('trackUpgradeMetricEvent'); - expect(monitoredSystemLimitWarningBannerSource).not.toContain('legacyConnections()'); - - expect(monitoredSystemLimitWarningBannerStateSource).toContain( - 'export function useMonitoredSystemLimitWarningBannerState', - ); - expect(monitoredSystemLimitWarningBannerStateSource).not.toContain('createEffect'); - expect(monitoredSystemLimitWarningBannerStateSource).toContain('createMemo'); - expect(monitoredSystemLimitWarningBannerStateSource).toContain('loadRuntimeCapabilities'); - expect(monitoredSystemLimitWarningBannerStateSource).toContain( - 'getRuntimeMonitoredSystemCapacity', - ); - expect(monitoredSystemLimitWarningBannerStateSource).toContain('isHostedModeEnabled'); - expect(monitoredSystemLimitWarningBannerStateSource).toContain( - 'presentationPolicyHidesCommercialSurfaces', - ); - expect(monitoredSystemLimitWarningBannerStateSource).toContain( - 'presentationPolicyHidesUpgradePrompts', - ); - expect(monitoredSystemLimitWarningBannerStateSource).not.toContain('trackUpgradeMetricEvent'); - expect(monitoredSystemLimitWarningBannerStateSource).toContain('hasMigrationGap'); - expect(monitoredSystemLimitWarningBannerStateSource).not.toContain( - 'scopeSelfHostedBillingDestination', - ); - expect(monitoredSystemLimitWarningBannerStateSource).not.toContain( - 'SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_INTENT', - ); - expect(monitoredSystemLimitWarningBannerStateSource).not.toContain( - 'SELF_HOSTED_PRO_BILLING_MONITORED_SYSTEM_INTENT', - ); - expect(monitoredSystemLimitWarningBannerStateSource).toContain('reviewPolicyDestination'); - expect(monitoredSystemLimitWarningBannerStateSource).not.toContain('handleUpgradeClick'); - expect(monitoredSystemLimitWarningBannerStateSource).not.toContain("fetch('/api/health'"); - - expect(monitoredSystemLimitWarningBannerModelSource).toContain( - 'getMonitoredSystemBannerToneClass', - ); - expect(monitoredSystemLimitWarningBannerModelSource).not.toContain( - 'getMonitoredSystemLimitUpgradeLabel', - ); - expect(monitoredSystemLimitWarningBannerModelSource).toContain( - 'getMonitoredSystemLimitInstallCollectorsLabel', - ); - expect(monitoredSystemLimitWarningBannerModelSource).toContain( - 'type MonitoredSystemCapacityStatus', - ); - expect(monitoredSystemLimitWarningBannerModelSource).toContain( - 'isMonitoredSystemLimitUsageAvailable', - ); - expect(monitoredSystemLimitWarningBannerModelSource).toContain( - 'formatMonitoredSystemLimitSummary', - ); - expect(monitoredSystemLimitWarningBannerModelSource).not.toContain( - 'current_available !== false', - ); - expect(monitoredSystemLimitWarningBannerModelSource).not.toContain('current / limit'); - expect(monitoredSystemLimitWarningBannerModelSource).not.toContain('0 remaining'); - expect(monitoredSystemLimitWarningBannerModelSource).toContain( - 'SELF_HOSTED_PRO_BILLING_USAGE_HREF', - ); - expect(monitoredSystemLimitWarningBannerModelSource).not.toContain( - 'SELF_HOSTED_PRO_BILLING_PLAN_HREF', - ); - }); - it('keeps shared tag badges in the shared primitive boundary', () => { expect(tagBadgesSource).toContain("from '@/components/shared/Tooltip'"); expect(guestRowSource).toContain("from '@/components/shared/TagBadges'"); diff --git a/frontend-modern/src/components/shared/__tests__/MonitoredSystemLimitWarningBanner.test.tsx b/frontend-modern/src/components/shared/__tests__/MonitoredSystemLimitWarningBanner.test.tsx deleted file mode 100644 index 73aa7ea5e..000000000 --- a/frontend-modern/src/components/shared/__tests__/MonitoredSystemLimitWarningBanner.test.tsx +++ /dev/null @@ -1,334 +0,0 @@ -import { cleanup, render, screen } from '@solidjs/testing-library'; -import { Route, Router } from '@solidjs/router'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import monitoredSystemLimitWarningBannerSource from '@/components/shared/MonitoredSystemLimitWarningBanner.tsx?raw'; -import monitoredSystemLimitWarningBannerModelSource from '@/components/shared/monitoredSystemLimitWarningBannerModel.ts?raw'; -import monitoredSystemLimitWarningBannerStateSource from '@/components/shared/useMonitoredSystemLimitWarningBannerState.ts?raw'; - -type MockEntitlements = { - overflow_days_remaining?: number; -}; -const mockEntitlements = vi.hoisted(() => - vi.fn<() => MockEntitlements>(() => ({ overflow_days_remaining: undefined })), -); -type MockLimitRecord = { - key: string; - limit: number; - current: number; - current_available?: boolean; - current_unavailable_reason?: string; - state: string; -}; -const mockGetLimit = vi.hoisted(() => - vi.fn<(key: string) => MockLimitRecord | undefined>(() => undefined), -); -const mockGetMonitoredSystemCapacity = vi.hoisted(() => vi.fn(() => undefined)); -const mockIsHostedModeEnabled = vi.hoisted(() => vi.fn(() => false)); -const mockHasMigrationGap = vi.hoisted(() => vi.fn(() => false)); -const mockLegacyConnections = vi.hoisted(() => - vi.fn(() => ({ - proxmox_nodes: 0, - docker_hosts: 0, - kubernetes_clusters: 0, - })), -); -const mockLoadRuntimeLicenseStatus = vi.hoisted(() => vi.fn()); -const mockPresentationPolicyHidesCommercialSurfaces = vi.hoisted(() => vi.fn(() => false)); -const mockPresentationPolicyHidesUpgradePrompts = vi.hoisted(() => vi.fn(() => false)); -const mockGetUpgradeActionDestination = vi.hoisted(() => vi.fn()); -const mockGetUpgradeActionUrlOrFallback = vi.hoisted(() => vi.fn()); - -vi.mock('@/stores/license', () => ({ - getRuntimeLimit: (key: string) => mockGetLimit(key), - getRuntimeMonitoredSystemCapacity: () => mockGetMonitoredSystemCapacity(), - isHostedModeEnabled: () => mockIsHostedModeEnabled(), - loadRuntimeCapabilities: (force?: boolean) => mockLoadRuntimeLicenseStatus(force), -})); - -vi.mock('@/stores/licenseCommercial', () => ({ - commercialOverflowDaysRemaining: () => mockEntitlements().overflow_days_remaining ?? null, - getUpgradeActionDestination: (key: string) => mockGetUpgradeActionDestination(key), - getUpgradeActionUrlOrFallback: (key: string) => mockGetUpgradeActionUrlOrFallback(key), - hasMigrationGap: mockHasMigrationGap, - legacyConnections: mockLegacyConnections, -})); - -vi.mock('@/stores/sessionPresentationPolicy', () => ({ - presentationPolicyHidesCommercialSurfaces: () => mockPresentationPolicyHidesCommercialSurfaces(), - presentationPolicyHidesUpgradePrompts: () => mockPresentationPolicyHidesUpgradePrompts(), -})); - -describe('MonitoredSystemLimitWarningBanner', () => { - beforeEach(() => { - localStorage.clear(); - mockEntitlements.mockReturnValue({ overflow_days_remaining: undefined }); - mockGetLimit.mockReturnValue({ - key: 'max_monitored_systems', - limit: 6, - current: 0, - state: 'ok', - }); - mockGetMonitoredSystemCapacity.mockReset(); - mockGetMonitoredSystemCapacity.mockReturnValue(undefined); - mockIsHostedModeEnabled.mockReset(); - mockIsHostedModeEnabled.mockReturnValue(false); - mockHasMigrationGap.mockReturnValue(false); - mockLegacyConnections.mockReturnValue({ - proxmox_nodes: 0, - docker_hosts: 0, - kubernetes_clusters: 0, - }); - mockPresentationPolicyHidesCommercialSurfaces.mockReset(); - mockPresentationPolicyHidesCommercialSurfaces.mockReturnValue(false); - mockPresentationPolicyHidesUpgradePrompts.mockReset(); - mockPresentationPolicyHidesUpgradePrompts.mockReturnValue(false); - mockLoadRuntimeLicenseStatus.mockReset(); - mockLoadRuntimeLicenseStatus.mockResolvedValue(undefined); - mockGetUpgradeActionDestination.mockReset(); - mockGetUpgradeActionUrlOrFallback.mockReset(); - mockGetUpgradeActionDestination.mockReturnValue({ - href: '/settings/system/billing', - external: false, - }); - mockGetUpgradeActionUrlOrFallback.mockReturnValue( - '/settings/system/billing/usage?details=counting-rules', - ); - }); - - afterEach(() => { - cleanup(); - vi.clearAllMocks(); - }); - - it('keeps monitored system limit warning banner on shell, runtime, and model owners', () => { - expect(monitoredSystemLimitWarningBannerSource).toContain( - 'useMonitoredSystemLimitWarningBannerState', - ); - expect(monitoredSystemLimitWarningBannerSource).toContain( - 'MONITORED_SYSTEM_LIMIT_REVIEW_POLICY_LABEL', - ); - expect(monitoredSystemLimitWarningBannerSource).not.toContain('createEffect'); - expect(monitoredSystemLimitWarningBannerSource).not.toContain('createMemo'); - expect(monitoredSystemLimitWarningBannerSource).not.toContain('loadRuntimeCapabilities'); - expect(monitoredSystemLimitWarningBannerSource).not.toContain('trackUpgradeMetricEvent'); - expect(monitoredSystemLimitWarningBannerSource).not.toContain('legacyConnections()'); - - expect(monitoredSystemLimitWarningBannerStateSource).toContain( - 'export function useMonitoredSystemLimitWarningBannerState', - ); - expect(monitoredSystemLimitWarningBannerStateSource).not.toContain('createEffect'); - expect(monitoredSystemLimitWarningBannerStateSource).toContain('createMemo'); - expect(monitoredSystemLimitWarningBannerStateSource).toContain('loadRuntimeCapabilities'); - expect(monitoredSystemLimitWarningBannerStateSource).not.toContain('loadCommercialPosture'); - expect(monitoredSystemLimitWarningBannerStateSource).toContain( - 'presentationPolicyHidesCommercialSurfaces', - ); - expect(monitoredSystemLimitWarningBannerStateSource).toContain('isHostedModeEnabled'); - expect(monitoredSystemLimitWarningBannerStateSource).toContain( - 'presentationPolicyHidesUpgradePrompts', - ); - expect(monitoredSystemLimitWarningBannerStateSource).not.toContain('trackUpgradeMetricEvent'); - expect(monitoredSystemLimitWarningBannerStateSource).toContain('hasMigrationGap'); - expect(monitoredSystemLimitWarningBannerStateSource).not.toContain( - 'scopeSelfHostedBillingDestination', - ); - expect(monitoredSystemLimitWarningBannerStateSource).not.toContain( - 'SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_INTENT', - ); - expect(monitoredSystemLimitWarningBannerStateSource).toContain('reviewPolicyDestination'); - expect(monitoredSystemLimitWarningBannerStateSource).not.toContain('handleUpgradeClick'); - - expect(monitoredSystemLimitWarningBannerModelSource).toContain( - '@/utils/monitoredSystemPresentation', - ); - expect(monitoredSystemLimitWarningBannerModelSource).toContain( - 'getMonitoredSystemBannerToneClass', - ); - expect(monitoredSystemLimitWarningBannerModelSource).not.toContain( - 'getMonitoredSystemLimitUpgradeLabel', - ); - expect(monitoredSystemLimitWarningBannerModelSource).toContain( - 'getMonitoredSystemLimitInstallCollectorsLabel', - ); - expect(monitoredSystemLimitWarningBannerModelSource).toContain( - 'isMonitoredSystemLimitUsageAvailable', - ); - expect(monitoredSystemLimitWarningBannerModelSource).not.toContain( - 'current_available !== false', - ); - expect(monitoredSystemLimitWarningBannerModelSource).not.toContain('Upgrade to add more'); - expect(monitoredSystemLimitWarningBannerModelSource).not.toContain('Install v6 collectors'); - }); - - it('stays hidden for non-urgent pure v6 installs', async () => { - const mod = await import('../MonitoredSystemLimitWarningBanner'); - render(() => ( - - - - )); - - expect(mockLoadRuntimeLicenseStatus).toHaveBeenCalled(); - expect(screen.queryByText(/Monitored systems:/i)).not.toBeInTheDocument(); - }); - - it('shows migration guidance when legacy connections exist', async () => { - mockHasMigrationGap.mockReturnValue(true); - mockLegacyConnections.mockReturnValue({ - proxmox_nodes: 2, - docker_hosts: 1, - kubernetes_clusters: 0, - }); - - const mod = await import('../MonitoredSystemLimitWarningBanner'); - render(() => ( - - - - )); - - expect(screen.queryByText(/Monitored systems:/i)).not.toBeInTheDocument(); - }); - - it('keeps urgent limit warnings visible even without migration gap', async () => { - mockIsHostedModeEnabled.mockReturnValue(true); - mockGetLimit.mockReturnValue({ - key: 'max_monitored_systems', - limit: 6, - current: 5, - state: 'warning', - }); - - const mod = await import('../MonitoredSystemLimitWarningBanner'); - render(() => ( - - - - )); - - expect(screen.getByText('5 monitored systems.')).toBeInTheDocument(); - expect(screen.getByText('Review continuity')).toHaveAttribute( - 'href', - '/settings/system/billing/usage', - ); - expect(screen.queryByText('Review options')).not.toBeInTheDocument(); - expect(screen.queryByText('Install v6 collectors')).not.toBeInTheDocument(); - }); - - it('stays hidden while canonical monitored-system usage is unavailable', async () => { - mockGetLimit.mockReturnValue({ - key: 'max_monitored_systems', - limit: 6, - current: 6, - current_available: false, - current_unavailable_reason: 'supplemental_inventory_unsettled', - state: 'enforced', - }); - - const mod = await import('../MonitoredSystemLimitWarningBanner'); - render(() => ( - - - - )); - - expect(screen.queryByText(/Monitored systems:/i)).not.toBeInTheDocument(); - expect(screen.queryByText('Review options')).not.toBeInTheDocument(); - }); - - it('keeps urgent limit warnings visible with migration context', async () => { - mockIsHostedModeEnabled.mockReturnValue(true); - mockHasMigrationGap.mockReturnValue(true); - mockEntitlements.mockReturnValue({ overflow_days_remaining: 14 }); - mockGetLimit.mockReturnValue({ - key: 'max_monitored_systems', - limit: 6, - current: 5, - state: 'warning', - }); - mockLegacyConnections.mockReturnValue({ - proxmox_nodes: 2, - docker_hosts: 1, - kubernetes_clusters: 0, - }); - - const mod = await import('../MonitoredSystemLimitWarningBanner'); - render(() => ( - - - - )); - - expect(screen.getByText('5 monitored systems.')).toBeInTheDocument(); - expect(screen.getByText('Install v6 collectors')).toHaveAttribute('href', '/settings'); - expect(screen.getByText('Review continuity')).toHaveAttribute( - 'href', - '/settings/system/billing/usage', - ); - expect(screen.queryByText('Review options')).not.toBeInTheDocument(); - }); - - it('stays hidden in demo mode even when usage is urgent', async () => { - mockIsHostedModeEnabled.mockReturnValue(true); - mockPresentationPolicyHidesCommercialSurfaces.mockReturnValue(true); - mockGetLimit.mockReturnValue({ - key: 'max_monitored_systems', - limit: 6, - current: 5, - state: 'warning', - }); - - const mod = await import('../MonitoredSystemLimitWarningBanner'); - render(() => ( - - - - )); - - expect( - screen.queryByText('5 monitored systems.'), - ).not.toBeInTheDocument(); - }); - - it('stays hidden when self-hosted upgrade prompts are suppressed', async () => { - mockIsHostedModeEnabled.mockReturnValue(true); - mockPresentationPolicyHidesUpgradePrompts.mockReturnValue(true); - mockGetLimit.mockReturnValue({ - key: 'max_monitored_systems', - limit: 6, - current: 5, - state: 'warning', - }); - - const mod = await import('../MonitoredSystemLimitWarningBanner'); - render(() => ( - - - - )); - - expect( - screen.queryByText('5 monitored systems.'), - ).not.toBeInTheDocument(); - }); - - it('stays hidden for self-hosted installs even when stale continuity metadata is urgent', async () => { - mockGetLimit.mockReturnValue({ - key: 'max_monitored_systems', - limit: 6, - current: 5, - state: 'warning', - }); - - const mod = await import('../MonitoredSystemLimitWarningBanner'); - render(() => ( - - - - )); - - expect( - screen.queryByText('5 monitored systems.'), - ).not.toBeInTheDocument(); - }); -}); diff --git a/frontend-modern/src/components/shared/monitoredSystemLimitWarningBannerModel.ts b/frontend-modern/src/components/shared/monitoredSystemLimitWarningBannerModel.ts deleted file mode 100644 index dbfea248a..000000000 --- a/frontend-modern/src/components/shared/monitoredSystemLimitWarningBannerModel.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { - formatMonitoredSystemLegacyConnectionBreakdown, - formatMonitoredSystemLimitSummary, - formatMonitoredSystemMigrationMessage, - formatMonitoredSystemOverflowSummary, - getMonitoredSystemLimitInstallCollectorsLabel, - getMonitoredSystemLimitReviewPolicyLabel, - isMonitoredSystemLimitUrgent as isCanonicalMonitoredSystemLimitUrgent, - isMonitoredSystemLimitUsageAvailable as isCanonicalMonitoredSystemLimitUsageAvailable, - type MonitoredSystemCapacityStatus, - type MonitoredSystemLimitUsageStatus, - type MonitoredSystemLegacyConnectionCounts, -} from '@/utils/monitoredSystemPresentation'; -import { SELF_HOSTED_PRO_BILLING_USAGE_HREF } from '@/utils/pricingHandoff'; - -type LimitState = MonitoredSystemLimitUsageStatus & { - current: number; - limit: number; - state?: string; -}; - -export const MONITORED_SYSTEM_LIMIT_KEY = 'max_monitored_systems'; -export const MONITORED_SYSTEM_LIMIT_REVIEW_POLICY_HREF = SELF_HOSTED_PRO_BILLING_USAGE_HREF; -export const MONITORED_SYSTEM_LIMIT_INSTALL_COLLECTORS_HREF = '/settings'; -export const MONITORED_SYSTEM_LIMIT_REVIEW_POLICY_LABEL = - getMonitoredSystemLimitReviewPolicyLabel(); -export const MONITORED_SYSTEM_LIMIT_INSTALL_COLLECTORS_LABEL = - getMonitoredSystemLimitInstallCollectorsLabel(); - -export function isMonitoredSystemLimitUsageAvailable(limit: LimitState | undefined): boolean { - return isCanonicalMonitoredSystemLimitUsageAvailable(limit); -} - -export function isMonitoredSystemLimitUrgent( - limit: LimitState | undefined, - capacity?: MonitoredSystemCapacityStatus | null, -): boolean { - return isCanonicalMonitoredSystemLimitUrgent(limit, capacity); -} - -export function shouldShowMonitoredSystemLimitBanner( - limit: LimitState | undefined, - capacity?: MonitoredSystemCapacityStatus | null, -): boolean { - return Boolean(limit || capacity) && isMonitoredSystemLimitUrgent(limit, capacity); -} - -export function getMonitoredSystemSummary( - limit: LimitState | undefined, - capacity?: MonitoredSystemCapacityStatus | null, -): string { - if (!limit && !capacity) return ''; - if (limit && !isMonitoredSystemLimitUsageAvailable(limit)) return ''; - return formatMonitoredSystemLimitSummary( - limit ?? { - current: capacity?.current ?? 0, - limit: capacity?.limit ?? 0, - current_available: capacity?.current_available ?? true, - current_unavailable_reason: capacity?.current_unavailable_reason, - state: capacity?.urgency ?? 'ok', - }, - capacity, - ); -} - -export function getMonitoredSystemLegacyConnectionTotal( - counts: MonitoredSystemLegacyConnectionCounts, -): number { - return counts.proxmox_nodes + counts.docker_hosts + counts.kubernetes_clusters; -} - -export function getMonitoredSystemLegacyBreakdown( - counts: MonitoredSystemLegacyConnectionCounts, -): string { - return formatMonitoredSystemLegacyConnectionBreakdown(counts); -} - -export function getMonitoredSystemMigrationMessage( - counts: MonitoredSystemLegacyConnectionCounts, -): string { - return formatMonitoredSystemMigrationMessage(counts); -} - -export function getMonitoredSystemOverflowSummary( - daysRemaining: number | null | undefined, -): string { - return formatMonitoredSystemOverflowSummary(daysRemaining ?? undefined); -} - -export function getMonitoredSystemBannerToneClass(isUrgent: boolean): string { - return isUrgent - ? 'border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-900 dark:bg-amber-900 dark:text-amber-100' - : 'border-sky-200 bg-sky-50 text-sky-950 dark:border-sky-900 dark:bg-sky-950 dark:text-sky-100'; -} - -export function getMonitoredSystemMigrationTextClass(isUrgent: boolean): string { - return isUrgent ? 'text-amber-800 dark:text-amber-200' : 'text-sky-800 dark:text-sky-200'; -} diff --git a/frontend-modern/src/components/shared/useMonitoredSystemLimitWarningBannerState.ts b/frontend-modern/src/components/shared/useMonitoredSystemLimitWarningBannerState.ts deleted file mode 100644 index d24a53c32..000000000 --- a/frontend-modern/src/components/shared/useMonitoredSystemLimitWarningBannerState.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { createMemo, onMount } from 'solid-js'; -import { - presentationPolicyHidesCommercialSurfaces, - presentationPolicyHidesUpgradePrompts, -} from '@/stores/sessionPresentationPolicy'; -import { - getRuntimeMonitoredSystemCapacity, - getRuntimeLimit, - isHostedModeEnabled, - loadRuntimeCapabilities, -} from '@/stores/license'; -import { hasMigrationGap } from '@/stores/licenseCommercial'; -import { resolveUpgradeDestination } from '@/utils/upgradeNavigation'; -import { - getMonitoredSystemBannerToneClass, - getMonitoredSystemSummary, - isMonitoredSystemLimitUrgent, - MONITORED_SYSTEM_LIMIT_INSTALL_COLLECTORS_HREF, - MONITORED_SYSTEM_LIMIT_KEY, - MONITORED_SYSTEM_LIMIT_REVIEW_POLICY_HREF, - shouldShowMonitoredSystemLimitBanner, -} from './monitoredSystemLimitWarningBannerModel'; - -export function useMonitoredSystemLimitWarningBannerState() { - onMount(() => { - void loadRuntimeCapabilities(); - }); - - const monitoredSystemLimit = createMemo(() => getRuntimeLimit(MONITORED_SYSTEM_LIMIT_KEY)); - const monitoredSystemCapacity = createMemo(() => getRuntimeMonitoredSystemCapacity()); - const isUrgent = createMemo(() => - isMonitoredSystemLimitUrgent(monitoredSystemLimit(), monitoredSystemCapacity()), - ); - const showBanner = createMemo( - () => - isHostedModeEnabled() && - !presentationPolicyHidesCommercialSurfaces() && - !presentationPolicyHidesUpgradePrompts() && - shouldShowMonitoredSystemLimitBanner(monitoredSystemLimit(), monitoredSystemCapacity()), - ); - const migrationGap = createMemo(() => hasMigrationGap()); - const monitoredSystemSummary = createMemo(() => - getMonitoredSystemSummary(monitoredSystemLimit(), monitoredSystemCapacity()), - ); - const toneClass = createMemo(() => getMonitoredSystemBannerToneClass(isUrgent())); - const reviewPolicyDestination = createMemo(() => - resolveUpgradeDestination(MONITORED_SYSTEM_LIMIT_REVIEW_POLICY_HREF), - ); - const installCollectorsDestination = createMemo(() => - resolveUpgradeDestination(MONITORED_SYSTEM_LIMIT_INSTALL_COLLECTORS_HREF), - ); - - return { - installCollectorsDestination, - isUrgent, - reviewPolicyDestination, - migrationGap, - monitoredSystemSummary, - showBanner, - toneClass, - }; -} diff --git a/frontend-modern/src/hooks/__tests__/useDeployWizard.test.ts b/frontend-modern/src/hooks/__tests__/useDeployWizard.test.ts index 2797edbf2..70720a712 100644 --- a/frontend-modern/src/hooks/__tests__/useDeployWizard.test.ts +++ b/frontend-modern/src/hooks/__tests__/useDeployWizard.test.ts @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; /* ── hoisted mocks ──────────────────────────────────────────── */ -const { apiMock, loggerMock, getLimitMock } = vi.hoisted(() => ({ +const { apiMock, loggerMock } = vi.hoisted(() => ({ apiMock: { getCandidates: vi.fn(), createPreflight: vi.fn(), @@ -14,12 +14,10 @@ const { apiMock, loggerMock, getLimitMock } = vi.hoisted(() => ({ retryJob: vi.fn(), }, loggerMock: { info: vi.fn(), warn: vi.fn() }, - getLimitMock: vi.fn(), })); vi.mock('@/api/agentDeploy', () => ({ AgentDeployAPI: apiMock })); vi.mock('@/utils/logger', () => ({ logger: loggerMock })); -vi.mock('@/stores/license', () => ({ getRuntimeLimit: getLimitMock })); // Stub useDeployStream — wizard only needs the returned state, not real SSE. vi.mock('@/hooks/useDeployStream', () => ({ @@ -49,7 +47,6 @@ const defaultOpts = { clusterId: 'cluster-1', clusterName: 'My Cluster' }; beforeEach(() => { vi.clearAllMocks(); - getLimitMock.mockReturnValue(null); // Default: getCandidates returns empty (called immediately on creation). apiMock.getCandidates.mockResolvedValue({ nodes: [], sourceAgents: [] }); }); @@ -490,26 +487,5 @@ describe('useDeployWizard', () => { dispose(); }); - it('maxAgentSlots returns monitored-system capacity from runtime limits', async () => { - getLimitMock.mockReturnValue({ limit: 10 }); - apiMock.getCandidates.mockResolvedValue({ nodes: [], sourceAgents: [] }); - - const { wizard, dispose } = withRoot(() => useDeployWizard(defaultOpts)); - await vi.waitFor(() => expect(wizard.candidatesLoading()).toBe(false)); - - expect(wizard.maxAgentSlots()).toBe(10); - dispose(); - }); - - it('maxAgentSlots returns 0 when no monitored-system capacity limit exists', async () => { - getLimitMock.mockReturnValue(null); - apiMock.getCandidates.mockResolvedValue({ nodes: [], sourceAgents: [] }); - - const { wizard, dispose } = withRoot(() => useDeployWizard(defaultOpts)); - await vi.waitFor(() => expect(wizard.candidatesLoading()).toBe(false)); - - expect(wizard.maxAgentSlots()).toBe(0); - dispose(); - }); }); }); diff --git a/frontend-modern/src/hooks/useDeployWizard.ts b/frontend-modern/src/hooks/useDeployWizard.ts index 9b220b605..7f7ba2b7d 100644 --- a/frontend-modern/src/hooks/useDeployWizard.ts +++ b/frontend-modern/src/hooks/useDeployWizard.ts @@ -8,7 +8,6 @@ import { createSignal, createMemo, onCleanup } from 'solid-js'; import { AgentDeployAPI } from '@/api/agentDeploy'; import { useDeployStream } from '@/hooks/useDeployStream'; -import { getRuntimeLimit } from '@/stores/license'; import type { CandidateNode, SourceAgentInfo, @@ -114,18 +113,11 @@ export function useDeployWizard(opts: UseDeployWizardOptions) { ); const skippedTargets = createMemo(() => - jobTargets().filter( - (t) => t.status === 'skipped_already_agent' || t.status === 'skipped_license', - ), + jobTargets().filter((t) => t.status === 'skipped_already_agent'), ); const canceledTargets = createMemo(() => jobTargets().filter((t) => t.status === 'canceled')); - const maxAgentSlots = createMemo(() => { - const limit = getRuntimeLimit('max_monitored_systems'); - return limit?.limit ?? 0; - }); - const isOperationActive = createMemo(() => step() === 'preflight' || step() === 'deploying'); // -- Event handlers -- @@ -608,9 +600,6 @@ export function useDeployWizard(opts: UseDeployWizardOptions) { skippedTargets, canceledTargets, - // License - maxAgentSlots, - // Operation states startingPreflight, startingDeploy, diff --git a/frontend-modern/src/stores/__tests__/license.test.ts b/frontend-modern/src/stores/__tests__/license.test.ts index bd27ba04b..c28670be9 100644 --- a/frontend-modern/src/stores/__tests__/license.test.ts +++ b/frontend-modern/src/stores/__tests__/license.test.ts @@ -426,14 +426,14 @@ describe('license stores', () => { expect(getUpgradeActionUrlOrFallback('relay')).toBe(getUpgradeFallbackDestination('relay')); }); - it('routes monitored-system limit fallbacks to the neutral plan surface', async () => { + it('routes monitored-system limit fallbacks through self-hosted purchase compatibility', async () => { vi.mocked(LicenseAPI.getCommercialPosture).mockResolvedValue({ ...mockCommercialPosture, upgrade_reasons: [{ key: 'reason1', reason: 'Reason 1', action_url: '/upgrade/reason1' }], }); await loadCommercialPosture(true); expect(getUpgradeActionUrlOrFallback('max_monitored_systems')).toBe( - '/settings/system/billing/plan', + getUpgradeFallbackDestination('max_monitored_systems'), ); }); @@ -472,7 +472,7 @@ describe('license stores', () => { upgrade_reasons: [ { key: 'max_monitored_systems', - reason: 'Expand monitored-system capacity', + reason: 'Legacy monitored-system alias', action_url: '/upgrade/max-monitored-systems', }, ], diff --git a/frontend-modern/src/stores/license.ts b/frontend-modern/src/stores/license.ts index e2aad4e60..52e2e36e9 100644 --- a/frontend-modern/src/stores/license.ts +++ b/frontend-modern/src/stores/license.ts @@ -1,9 +1,5 @@ import { createSignal } from 'solid-js'; -import { - LicenseAPI, - type LicenseRuntimeCapabilities, - type MonitoredSystemCapacityStatus, -} from '@/api/license'; +import { LicenseAPI, type LicenseRuntimeCapabilities } from '@/api/license'; import { eventBus } from '@/stores/events'; import { logger } from '@/utils/logger'; @@ -70,10 +66,6 @@ export function getRuntimeLimit(key: string) { return current.limits.find((limit) => limit.key === key); } -export function getRuntimeMonitoredSystemCapacity(): MonitoredSystemCapacityStatus | undefined { - return runtimeCapabilities()?.monitored_system_capacity; -} - /** Default max history days when runtime capabilities aren't loaded yet. */ const DEFAULT_MAX_HISTORY_DAYS = 7; diff --git a/frontend-modern/src/types/agentDeploy.ts b/frontend-modern/src/types/agentDeploy.ts index 23c6fe6d9..ba74222b5 100644 --- a/frontend-modern/src/types/agentDeploy.ts +++ b/frontend-modern/src/types/agentDeploy.ts @@ -24,7 +24,6 @@ export type DeployTargetStatus = | 'failed_retryable' | 'failed_permanent' | 'skipped_already_agent' - | 'skipped_license' | 'canceled'; /** Event type classification for deployment audit log entries. */ @@ -138,7 +137,6 @@ export interface CreateJobResponse { jobId: string; acceptedTargets: string[]; skippedTargets: SkippedTarget[]; - reservedLicenseSlots: number; eventsUrl: string; } diff --git a/frontend-modern/src/utils/__tests__/apiClient.error.test.ts b/frontend-modern/src/utils/__tests__/apiClient.error.test.ts index 3c7de00d2..8c4e5b94e 100644 --- a/frontend-modern/src/utils/__tests__/apiClient.error.test.ts +++ b/frontend-modern/src/utils/__tests__/apiClient.error.test.ts @@ -52,32 +52,13 @@ describe('apiClient structured error extraction', () => { }); }); - it('preserves monitored-system preview payloads from canonical 402 errors', async () => { + it('preserves generic feature metadata from structured commercial errors', async () => { const error = await apiErrorFromResponse( new Response( JSON.stringify({ error: 'license_required', - message: 'Monitored-system capacity reached (6/5)', - feature: 'max_monitored_systems', - monitored_system_preview: { - current_count: 5, - projected_count: 6, - additional_count: 1, - limit: 5, - would_exceed_limit: true, - effect: 'creates_new', - current_systems: [], - projected_systems: [ - { - name: 'backup', - type: 'truenas-system', - status: 'online', - source: 'truenas', - }, - ], - current_system: null, - projected_system: null, - }, + message: 'Relay access requires a paid feature', + feature: 'relay', }), { status: 402, @@ -88,14 +69,9 @@ describe('apiClient structured error extraction', () => { ); expect(error).toMatchObject({ - message: 'Monitored-system capacity reached (6/5)', + message: 'Relay access requires a paid feature', status: 402, - feature: 'max_monitored_systems', - monitored_system_preview: { - current_count: 5, - projected_count: 6, - would_exceed_limit: true, - }, + feature: 'relay', }); }); }); diff --git a/frontend-modern/src/utils/__tests__/apiClient.org.test.ts b/frontend-modern/src/utils/__tests__/apiClient.org.test.ts index f7a5bacc8..4074f6827 100644 --- a/frontend-modern/src/utils/__tests__/apiClient.org.test.ts +++ b/frontend-modern/src/utils/__tests__/apiClient.org.test.ts @@ -69,32 +69,13 @@ describe('apiClient org context', () => { expect(headers['X-Pulse-Org-ID']).toBe('tenant-ledger'); }); - it('preserves hosted org context and preview payloads on monitored-system denials', async () => { + it('preserves hosted org context on structured commercial errors', async () => { mockFetch.mockResolvedValue( new Response( JSON.stringify({ error: 'license_required', - message: 'Monitored-system capacity reached (6/5)', - feature: 'max_monitored_systems', - monitored_system_preview: { - current_count: 5, - projected_count: 6, - additional_count: 1, - limit: 5, - would_exceed_limit: true, - effect: 'creates_new', - current_systems: [], - projected_systems: [ - { - name: 'backup', - type: 'truenas-system', - status: 'online', - source: 'truenas', - }, - ], - current_system: null, - projected_system: null, - }, + message: 'Relay access requires a paid feature', + feature: 'relay', }), { status: 402, @@ -107,14 +88,9 @@ describe('apiClient org context', () => { await expect( apiFetchJSON('/api/truenas/connections', { method: 'POST', body: '{}' }), ).rejects.toMatchObject({ - message: 'Monitored-system capacity reached (6/5)', + message: 'Relay access requires a paid feature', status: 402, - feature: 'max_monitored_systems', - monitored_system_preview: { - current_count: 5, - projected_count: 6, - would_exceed_limit: true, - }, + feature: 'relay', }); const [, options] = mockFetch.mock.calls[0] as [string, RequestInit]; diff --git a/frontend-modern/src/utils/__tests__/commercialBillingModel.test.ts b/frontend-modern/src/utils/__tests__/commercialBillingModel.test.ts index bdaa91936..e971be557 100644 --- a/frontend-modern/src/utils/__tests__/commercialBillingModel.test.ts +++ b/frontend-modern/src/utils/__tests__/commercialBillingModel.test.ts @@ -1,9 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { - buildSelfHostedCommercialPlanModel, - SELF_HOSTED_NOT_METERED_LABEL, -} from '../commercialBillingModel'; +import { buildSelfHostedCommercialPlanModel } from '../commercialBillingModel'; import { SELF_HOSTED_PLAN_BY_TIER } from '../selfHostedPlans'; const createBaseInput = () => ({ @@ -13,9 +10,6 @@ const createBaseInput = () => ({ planTerms: 'Pro Monthly', expires: '12/31/2026', daysRemaining: 123, - monitoredSystemsSummary: SELF_HOSTED_NOT_METERED_LABEL, - capacityStatusSummary: SELF_HOSTED_NOT_METERED_LABEL, - maxMonitoredSystems: SELF_HOSTED_NOT_METERED_LABEL, }); describe('commercialBillingModel', () => { @@ -55,55 +49,4 @@ describe('commercialBillingModel', () => { expect(JSON.stringify(model)).not.toContain('Unlimited'); }); - it('keeps bounded monitored-system details as legacy support context', () => { - const model = buildSelfHostedCommercialPlanModel({ - ...createBaseInput(), - monitoredSystemsSummary: '7 monitored systems', - capacityStatusSummary: 'Continuity review', - maxMonitoredSystems: 10, - retailPlanDefinition: null, - }); - - expect(model.summary).toEqual([ - { label: 'Monitored Systems', value: '7 monitored systems' }, - { label: 'Continuity Status', value: 'Continuity review' }, - { label: 'Plan Status', value: 'Active' }, - ]); - expect(model.details.map((item) => item.label)).toContain('Recorded Monitoring Baseline'); - expect(model.details.map((item) => item.label)).not.toContain('Guest Capacity'); - }); - - it('labels captured legacy continuity as baselines instead of current limits', () => { - const model = buildSelfHostedCommercialPlanModel({ - ...createBaseInput(), - monitoredSystemsSummary: '23 monitored systems', - capacityStatusSummary: 'Continuity review', - maxMonitoredSystems: SELF_HOSTED_NOT_METERED_LABEL, - retailPlanDefinition: null, - monitoredSystemContinuity: { - plan_limit: 10, - grandfathered_floor: 23, - effective_limit: 23, - capture_pending: false, - }, - }); - - expect(model.summary).toEqual([ - { label: 'Monitored Systems', value: '23 monitored systems' }, - { label: 'Continuity Status', value: 'Continuity review' }, - { label: 'Plan Status', value: 'Active' }, - ]); - expect(model.details.map((item) => item.label)).toEqual([ - 'Tier', - 'Licensed Email', - 'Plan Terms', - 'Expires', - 'Days Remaining', - 'Plan Baseline', - 'Current Baseline', - 'Observed Legacy Estate', - 'Continuity Verification', - ]); - expect(JSON.stringify(model)).not.toContain('Monitored System Limit'); - }); }); diff --git a/frontend-modern/src/utils/__tests__/deployStatusPresentation.test.ts b/frontend-modern/src/utils/__tests__/deployStatusPresentation.test.ts index 6e125e3dd..e9377860b 100644 --- a/frontend-modern/src/utils/__tests__/deployStatusPresentation.test.ts +++ b/frontend-modern/src/utils/__tests__/deployStatusPresentation.test.ts @@ -15,7 +15,6 @@ describe('deployStatusPresentation', () => { ['failed_retryable', 'Failed'], ['failed_permanent', 'Failed'], ['skipped_already_agent', 'Already monitored'], - ['skipped_license', 'Workspace capacity'], ['canceled', 'Canceled'], ]; diff --git a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts index 5e061a84a..219ae6757 100644 --- a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts +++ b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts @@ -55,8 +55,6 @@ import searchTipsPopoverModelSource from '@/components/shared/searchTipsPopoverM import tooltipSource from '@/components/shared/Tooltip.tsx?raw'; import tooltipModelSource from '@/components/shared/tooltipModel.ts?raw'; import upgradeLinkSource from '@/components/shared/UpgradeLink.tsx?raw'; -import monitoredSystemLimitWarningBannerSource from '@/components/shared/MonitoredSystemLimitWarningBanner.tsx?raw'; -import monitoredSystemLimitWarningBannerModelSource from '@/components/shared/monitoredSystemLimitWarningBannerModel.ts?raw'; import monitoredSystemLedgerPanelSource from '@/components/Settings/MonitoredSystemLedgerPanel.tsx?raw'; import monitoredSystemDefinitionDisclosureSource from '@/components/Commercial/MonitoredSystemDefinitionDisclosure.tsx?raw'; import infrastructureSummaryTableSource from '@/components/shared/InfrastructureSummaryTable.tsx?raw'; @@ -90,7 +88,6 @@ import searchTipsPopoverStateSource from '@/components/shared/useSearchTipsPopov import tooltipStateSource from '@/components/shared/useTooltipState.ts?raw'; import upgradeNavigationHookSource from '@/components/shared/useUpgradeNavigation.ts?raw'; import interactiveSparklineStateSource from '@/components/shared/useInteractiveSparklineState.ts?raw'; -import monitoredSystemLimitWarningBannerStateSource from '@/components/shared/useMonitoredSystemLimitWarningBannerState.ts?raw'; import infrastructureSummaryTableStateSource from '@/components/shared/useInfrastructureSummaryTableState.ts?raw'; import selectionCardGroupStateSource from '@/components/shared/useSelectionCardGroupState.ts?raw'; import resourceBadgePresentationSource from '@/utils/resourceBadgePresentation.ts?raw'; @@ -192,7 +189,6 @@ import workloadUrlSyncModelSource from '@/components/Workloads/workloadUrlSyncMo import workloadsWorkloadRouteStateSource from '@/components/Workloads/useWorkloadRouteState.ts?raw'; import workloadsWorkloadUrlSyncSource from '@/components/Workloads/useWorkloadUrlSync.ts?raw'; import workloadsStateSource from '@/components/Workloads/useWorkloadsState.ts?raw'; -import workloadsFilterStateSource from '@/components/Workloads/useWorkloadsFilterState.ts?raw'; import workloadTopologySource from '@/components/Workloads/workloadTopology.ts?raw'; import groupedTableWindowingSource from '@/components/Workloads/useGroupedTableWindowing.ts?raw'; import thresholdSliderModelSource from '@/components/Workloads/thresholdSliderModel.ts?raw'; @@ -252,11 +248,7 @@ import storageDetailKeyValueRowSource from '@/components/Storage/StorageDetailKe import storageDetailMetricCardSource from '@/components/Storage/StorageDetailMetricCard.tsx?raw'; import diskLiveMetricSource from '@/components/Storage/DiskLiveMetric.tsx?raw'; import useDiskLiveMetricModelSource from '@/components/Storage/useDiskLiveMetricModel.ts?raw'; -import storageControlsSource from '@/components/Storage/StorageControls.tsx?raw'; -import useStorageControlsModelSource from '@/components/Storage/useStorageControlsModel.ts?raw'; -import storageFilterSource from '@/components/Storage/StorageFilter.tsx?raw'; import storagePageControlsSource from '@/components/Storage/StoragePageControls.tsx?raw'; -import useStoragePageControlsModelSource from '@/components/Storage/useStoragePageControlsModel.ts?raw'; import storageCephSectionSource from '@/components/Storage/StorageCephSection.tsx?raw'; import storageContentCardSource from '@/components/Storage/StorageContentCard.tsx?raw'; import storagePageSource from '@/components/Storage/Storage.tsx?raw'; @@ -284,7 +276,6 @@ import useDiskDetailModelSource from '@/components/Storage/useDiskDetailModel.ts import storagePoolRowSource from '@/components/Storage/StoragePoolRow.tsx?raw'; import storageExpansionStateSource from '@/components/Storage/useStorageExpansionState.ts?raw'; import storageFilterStateSource from '@/components/Storage/useStorageFilterState.ts?raw'; -import useStorageFilterToolbarModelSource from '@/components/Storage/useStorageFilterToolbarModel.ts?raw'; import storagePageFiltersSource from '@/components/Storage/useStoragePageFilters.ts?raw'; import storagePageDataSource from '@/components/Storage/useStoragePageData.ts?raw'; import storagePageModelSource from '@/components/Storage/useStoragePageModel.ts?raw'; @@ -838,14 +829,12 @@ describe('frontend resource type boundaries', () => { expect(workloadSelectionModelSource).toContain('resolveWorkloadResourceSelection'); expect(workloadSelectionModelSource).toContain('workloadsHasHoveredWorkload'); expect(workloadsStateSource).not.toContain('const guestId = () => {'); - expect(workloadsFilterSource).toContain('useWorkloadsFilterState'); + expect(workloadsFilterSource).not.toContain('useWorkloadsFilterState'); expect(workloadsFilterSource).not.toContain('const [filtersOpen, setFiltersOpen] ='); - expect(workloadsFilterSource).not.toContain('useBreakpoint'); + expect(workloadsFilterSource).toContain('useBreakpoint'); expect(workloadsFilterSource).not.toContain("props.setSortKey('name')"); - expect(workloadsFilterStateSource).toContain('countActiveWorkloadsFilters'); - expect(workloadsFilterStateSource).not.toContain('props.containerRuntimeFilter?.onChange'); - expect(workloadsFilterStateSource).toContain('useBreakpoint'); - expect(workloadsFilterStateSource).toContain('DEFAULT_WORKLOADS_SORT_KEY'); + expect(workloadsFilterSource).toContain('hasActiveWorkloadsFilters'); + expect(workloadsFilterSource).toContain('DEFAULT_WORKLOADS_SORT_KEY'); expect(workloadsFilterModelSource).toContain('export const countActiveWorkloadsFilters'); expect(workloadsFilterModelSource).toContain('export const hasActiveWorkloadsFilters'); expect(workloadsFilterModelSource).toContain( @@ -1139,9 +1128,9 @@ describe('frontend resource type boundaries', () => { expect(recoverySource).not.toContain( "chartRangeDays() === range\n ? 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-200'", ); - expect(recoverySource).toContain('getRecoveryTimelineColumnButtonClass'); - expect(recoverySource).toContain('getRecoveryArtifactColumnHeaderClass'); - expect(recoverySource).toContain('getRecoveryArtifactRowClass'); + expect(recoveryActivitySectionSource).toContain('getRecoveryTimelineColumnButtonClass'); + expect(recoveryHistoryTableSource).toContain('getRecoveryArtifactColumnHeaderClass'); + expect(recoveryHistoryTableSource).toContain('getRecoveryArtifactRowClass'); expect(recoverySource).toContain('recoveryDateKeyFromTimestamp'); expect(recoverySource).toContain('parseRecoveryDateKey'); expect(recoverySource).toContain('getRecoveryPrettyDateLabel'); @@ -1152,10 +1141,9 @@ describe('frontend resource type boundaries', () => { expect(recoveryProtectedInventorySectionSource).toContain( 'getRecoveryRollupInventoryStatusTextClass', ); - expect(recoverySource).toContain('getRecoverySpecialOutcomeTextClass'); - expect(recoverySource).toContain('getRecoveryBreadcrumbLinkClass'); - expect(recoverySource).toContain('getRecoveryFilterPanelClearClass'); - expect(recoverySource).toContain('getRecoveryEmptyStateActionClass'); + expect(recoveryProtectedInventorySectionSource).toContain('getRecoverySpecialOutcomeTextClass'); + expect(recoveryHistorySectionSource).toContain('getRecoveryBreadcrumbLinkClass'); + expect(recoveryHistoryTableSource).toContain('getRecoveryEmptyStateActionClass'); expect(recoverySource).toContain('getRecoveryProtectedItemsLoadingState'); expect(recoverySource).toContain('getRecoveryProtectedItemsFailureState'); expect(recoverySource).toContain('getRecoveryActivityLoadingState'); @@ -1164,31 +1152,29 @@ describe('frontend resource type boundaries', () => { expect(recoverySource).toContain('getRecoveryPointsFailureState'); expect(recoverySource).toContain('getRecoveryProtectedItemsEmptyState'); expect(recoverySource).toContain('getRecoveryHistoryEmptyState'); - expect(recoverySource).toContain('getRecoveryDrawerCloseButtonClass'); + expect(recoveryHistoryTableSource).toContain('getRecoveryDrawerCloseButtonClass'); expect(recoverySource).toContain('getRecoveryRollupItemLabel'); expect(recoverySource).toContain('getRecoveryPointItemLabel'); expect(recoverySource).toContain('getRecoveryPointRepositoryLabel'); expect(recoverySource).toContain('getRecoveryPointDetailsSummary'); expect(recoverySource).toContain('getRecoveryPointTimestampMs'); expect(recoverySource).toContain('normalizeRecoveryModeQueryValue'); - expect(recoverySource).toContain('getRecoveryTimelineAxisLabelClass'); + expect(recoveryActivitySectionSource).toContain('getRecoveryTimelineAxisLabelClass'); expect(recoverySource).toContain('getRecoveryTimelineLabelEvery'); expect(recoverySource).toContain('getRecoveryGroupNoTimestampLabel'); - expect(recoverySource).toContain('getRecoveryProtectedSearchPlaceholder'); - expect(recoverySource).toContain('getRecoveryHistorySearchPlaceholder'); + expect(recoveryProtectedInventorySectionSource).toContain('getRecoveryProtectedSearchPlaceholder'); + expect(recoveryHistorySectionSource).toContain('getRecoveryHistorySearchPlaceholder'); expect(recoverySource).toContain('getRecoverySearchHistoryEmptyMessage'); expect(recoverySource).not.toContain('Search protected items...'); expect(recoverySource).not.toContain('Search recovery history...'); expect(recoverySource).not.toContain('Recent searches appear here.'); - expect(recoverySource).toContain('RECOVERY_TIMELINE_LEGEND_ITEM_CLASS'); - expect(recoverySource).toContain('getRecoveryEventTimeTextClass'); + expect(recoveryActivitySectionSource).toContain('RECOVERY_TIMELINE_LEGEND_ITEM_CLASS'); + expect(recoveryHistoryTableSource).toContain('getRecoveryEventTimeTextClass'); expect(recoverySource).not.toContain('getRecoverySubjectTypeBadgeClass'); expect(recoverySource).not.toContain('getRecoverySubjectTypeLabel'); - expect(recoverySource).toContain('getRecoveryRollupAgeTextClass'); - expect(recoverySource).toContain('RECOVERY_ADVANCED_FILTER_LABEL_CLASS'); - expect(recoverySource).toContain('RECOVERY_ADVANCED_FILTER_FIELD_CLASS'); - expect(recoverySource).toContain('RECOVERY_GROUP_HEADER_ROW_CLASS'); - expect(recoverySource).toContain('RECOVERY_GROUP_HEADER_TEXT_CLASS'); + expect(recoveryProtectedInventorySectionSource).toContain('getRecoveryRollupAgeTextClass'); + expect(recoveryHistoryTableSource).toContain('RECOVERY_GROUP_HEADER_ROW_CLASS'); + expect(recoveryHistoryTableSource).toContain('RECOVERY_GROUP_HEADER_TEXT_CLASS'); expect(recoverySource).not.toContain( "isSelected\n ? 'bg-blue-100 dark:bg-blue-900'\n : 'hover:bg-surface-hover'", ); @@ -1493,7 +1479,7 @@ describe('frontend resource type boundaries', () => { ); expect(auditLogPanelSource).toContain('@/utils/upgradePresentation'); expect(auditWebhookPanelSource).toContain('@/utils/upgradePresentation'); - expect(ssoProvidersPanelSource).toContain('@/utils/upgradePresentation'); + expect(ssoProvidersPanelSource).not.toContain('@/utils/upgradePresentation'); expect(upgradePresentationSource).toContain('export const UPGRADE_ACTION_LABEL'); expect(upgradePresentationSource).not.toContain('UPGRADE_TRIAL_LABEL'); expect(upgradePresentationSource).not.toContain('UPGRADE_TRIAL_LINK_CLASS'); @@ -1920,39 +1906,17 @@ describe('frontend resource type boundaries', () => { expect(storagePageSource).toContain('StorageContentCard'); expect(storagePageSource).toContain('StoragePageBanners'); expect(storagePageSource).toContain('StoragePageSummary'); - expect(storageControlsSource).toContain('export const StorageControls'); - expect(storageControlsSource).toContain('StorageFilter'); - expect(storageControlsSource).toContain('Subtabs'); - expect(storageControlsSource).toContain('useStorageControlsModel'); - expect(storageControlsSource).toContain('STORAGE_CONTROLS_NODE_SELECT_CLASS'); - expect(storageControlsSource).toContain('STORAGE_CONTROLS_NODE_DIVIDER_CLASS'); - expect(storageControlsSource).toContain('DEFAULT_STORAGE_SORT_OPTIONS'); - expect(storageControlsSource).not.toContain('STORAGE_VIEW_OPTIONS'); - expect(storageControlsSource).not.toContain( - 'props.setSelectedNodeId(event.currentTarget.value)', - ); - expect(storageControlsSource).not.toContain('focus:ring-blue-500'); - expect(useStorageControlsModelSource).toContain('STORAGE_VIEW_OPTIONS'); - expect(useStorageControlsModelSource).toContain('handleNodeFilterChange'); - expect(useStorageControlsModelSource).toContain('handleViewChange'); - expect(storageFilterSource).toContain('useStorageFilterToolbarModel'); - expect(storageFilterSource).toContain('STORAGE_FILTER_SORT_SELECT_CLASS'); - expect(storageFilterSource).toContain('STORAGE_FILTER_SORT_DIRECTION_BUTTON_CLASS'); - expect(storageFilterSource).not.toContain('const activeFilterCount = createMemo(() => {'); - expect(storageFilterSource).not.toContain('const sourceOptions = (): StorageSourceOption[] =>'); - expect(storageFilterSource).not.toContain('props.setSortKey(DEFAULT_STORAGE_SORT_KEY)'); - expect(storageFilterSource).not.toContain("props.sortDirection() === 'asc' ? 'desc' : 'asc'"); - expect(storageFilterSource).not.toContain( - "props.sortDirection() === 'asc' ? 'rotate-180' : ''", - ); - expect(storageFilterSource).not.toContain('focus:ring-blue-500'); - expect(useStorageFilterToolbarModelSource).toContain('countActiveStorageFilters'); - expect(useStorageFilterToolbarModelSource).toContain('hasActiveStorageFilters'); - expect(useStorageFilterToolbarModelSource).toContain('DEFAULT_STORAGE_SORT_KEY'); - expect(useStorageFilterToolbarModelSource).toContain('DEFAULT_STORAGE_SOURCE_FILTER'); - expect(useStorageFilterToolbarModelSource).toContain('getStorageSortDirectionTitle'); - expect(useStorageFilterToolbarModelSource).toContain('getStorageSortDirectionIconClass'); - expect(useStorageFilterToolbarModelSource).toContain('getNextStorageSortDirection'); + expect(storagePageControlsSource).toContain('FilterBar'); + expect(storagePageControlsSource).toContain('Subtabs'); + expect(storagePageControlsSource).toContain('STORAGE_VIEW_OPTIONS'); + expect(storagePageControlsSource).toContain('hasActiveStorageFilters'); + expect(storagePageControlsSource).toContain('DEFAULT_STORAGE_SORT_OPTIONS'); + expect(storagePageControlsSource).toContain('getNextStorageSortDirection'); + expect(storagePageControlsSource).toContain('STORAGE_FILTER_SORT_SELECT_CLASS'); + expect(storagePageControlsSource).toContain('STORAGE_FILTER_SORT_DIRECTION_BUTTON_CLASS'); + expect(storagePageControlsSource).not.toContain('StorageControls'); + expect(storagePageControlsSource).not.toContain('useStoragePageControlsModel'); + expect(storagePageControlsSource).not.toContain('focus:ring-blue-500'); expect(storageFilterPresentationSource).toContain('export const getStorageSortDirectionTitle'); expect(storageFilterPresentationSource).toContain( 'export const getStorageSortDirectionIconClass', @@ -1964,15 +1928,11 @@ describe('frontend resource type boundaries', () => { 'export const STORAGE_FILTER_SORT_DIRECTION_BUTTON_CLASS', ); expect(storagePageControlsSource).toContain('export const StoragePageControls'); - expect(storagePageControlsSource).toContain('StorageControls'); - expect(storagePageControlsSource).toContain('useStoragePageControlsModel'); - expect(storagePageControlsSource).not.toContain('normalizeStorageSortKey'); + expect(storagePageControlsSource).toContain('normalizeStorageSortKey'); expect(storagePageControlsSource).not.toContain( "props.view() === 'pools' ? props.storageFilterGroupBy : undefined", ); - expect(storagePageControlsSource).not.toContain("props.view() !== 'pools'"); - expect(useStoragePageControlsModelSource).toContain('normalizeStorageSortKey'); - expect(useStoragePageControlsModelSource).toContain("options.view() === 'pools'"); + expect(storagePageControlsSource).toContain('const sortDisabled = () => !isPoolsView()'); expect(storageCephSectionSource).toContain('export const StorageCephSection'); expect(storageCephSectionSource).toContain('useStorageCephSectionModel'); expect(storageCephSectionSource).toContain('StorageCephSummaryCard'); @@ -2927,33 +2887,6 @@ describe('frontend resource type boundaries', () => { expect(selectionCardGroupModelSource).toContain('resolveSelectionCardTone'); expect(selectionCardGroupModelSource).toContain('getSelectionCardButtonClass'); expect(selectionCardGroupModelSource).toContain("compact: 'grid grid-cols-2 gap-2'"); - expect(monitoredSystemLimitWarningBannerSource).toContain( - 'useMonitoredSystemLimitWarningBannerState', - ); - expect(monitoredSystemLimitWarningBannerSource).toContain( - 'MONITORED_SYSTEM_LIMIT_REVIEW_POLICY_LABEL', - ); - expect(monitoredSystemLimitWarningBannerSource).not.toContain('createEffect'); - expect(monitoredSystemLimitWarningBannerSource).not.toContain('createMemo'); - expect(monitoredSystemLimitWarningBannerSource).not.toContain('loadRuntimeCapabilities'); - expect(monitoredSystemLimitWarningBannerSource).not.toContain('trackUpgradeMetricEvent'); - expect(monitoredSystemLimitWarningBannerSource).not.toContain('legacyConnections()'); - expect(monitoredSystemLimitWarningBannerStateSource).not.toContain('createEffect'); - expect(monitoredSystemLimitWarningBannerStateSource).toContain('createMemo'); - expect(monitoredSystemLimitWarningBannerStateSource).toContain('loadRuntimeCapabilities'); - expect(monitoredSystemLimitWarningBannerStateSource).not.toContain('trackUpgradeMetricEvent'); - expect(monitoredSystemLimitWarningBannerStateSource).toContain('hasMigrationGap'); - expect(monitoredSystemLimitWarningBannerStateSource).toContain('reviewPolicyDestination'); - expect(monitoredSystemLimitWarningBannerStateSource).not.toContain('handleUpgradeClick'); - expect(monitoredSystemLimitWarningBannerModelSource).toContain( - 'getMonitoredSystemBannerToneClass', - ); - expect(monitoredSystemLimitWarningBannerModelSource).not.toContain( - 'MONITORED_SYSTEM_LIMIT_UPGRADE_LABEL', - ); - expect(monitoredSystemLimitWarningBannerModelSource).toContain( - 'MONITORED_SYSTEM_LIMIT_INSTALL_COLLECTORS_LABEL', - ); expect(monitoredSystemLedgerPanelSource).toContain('@/utils/monitoredSystemPresentation'); expect(monitoredSystemLedgerPanelSource).toContain('getMonitoredSystemLedgerPresentation'); expect(monitoredSystemLedgerPanelSource).toContain( diff --git a/frontend-modern/src/utils/__tests__/licensePresentation.test.ts b/frontend-modern/src/utils/__tests__/licensePresentation.test.ts index 4bb2002cc..00bf4536d 100644 --- a/frontend-modern/src/utils/__tests__/licensePresentation.test.ts +++ b/frontend-modern/src/utils/__tests__/licensePresentation.test.ts @@ -18,14 +18,11 @@ import { getLicenseSubscriptionStatusPresentation, getSelfHostedPlanLabel, getLicenseTierLabel, - getDisplayableMonitoredSystemContinuity, - getMonitoredSystemContinuityNotice, getNoActiveSelfHostedActivationState, getOrganizationBillingLicenseStatusLabel, getPurchaseActivationNotice, isDisplayableLicenseFeature, isGrandfatheredRecurringV5PlanVersion, - isUncappedGrandfatheredPlanVersion, SELF_HOSTED_RECOVERY_PRESENTATION, } from '@/utils/licensePresentation'; import { SELF_HOSTED_PRO_BILLING_PRESENTATION } from '@/components/Settings/selfHostedBillingPresentation'; @@ -186,10 +183,6 @@ describe('licensePresentation', () => { expect(isGrandfatheredRecurringV5PlanVersion('v5_pro_monthly_grandfathered')).toBe(true); expect(isGrandfatheredRecurringV5PlanVersion('v5_pro_annual_grandfathered')).toBe(true); expect(isGrandfatheredRecurringV5PlanVersion('v5_lifetime_grandfathered')).toBe(false); - expect(isUncappedGrandfatheredPlanVersion('v5_pro_monthly_grandfathered', false)).toBe(true); - expect(isUncappedGrandfatheredPlanVersion('v5_pro_annual_grandfathered', false)).toBe(true); - expect(isUncappedGrandfatheredPlanVersion(undefined, true)).toBe(true); - expect(isUncappedGrandfatheredPlanVersion('pro', false)).toBe(false); expect( getGrandfatheredPriceContinuityNotice('v5_pro_monthly_grandfathered', 'active'), ).toMatchObject({ @@ -214,21 +207,6 @@ describe('licensePresentation', () => { ).toBeNull(); }); - it('hides monitored-system continuity when self-hosted effective capacity is uncapped', () => { - expect( - getDisplayableMonitoredSystemContinuity({ - continuity: { - plan_limit: 10, - effective_limit: 0, - capture_pending: true, - }, - planVersion: 'legacy_migration_fallback', - isLifetime: false, - subscriptionState: 'active', - }), - ).toBeNull(); - }); - it('builds entitlement-first current-plan presentation for community, paid, and grandfathered installs', () => { expect( getSelfHostedCurrentPlanPresentation({ @@ -265,12 +243,6 @@ describe('licensePresentation', () => { capabilities: ['relay', 'ai_autofix'], limits: [{ key: 'max_monitored_systems', limit: 10, current: 23, state: 'enforced' }], upgrade_reasons: [], - monitored_system_continuity: { - plan_limit: 10, - grandfathered_floor: 23, - effective_limit: 23, - capture_pending: false, - }, }, displayableCapabilities: ['Pulse Relay (Remote Access)', 'Safe Remediation Workflows'], }), @@ -347,12 +319,6 @@ describe('licensePresentation', () => { capabilities: ['relay'], limits: [], upgrade_reasons: [], - monitored_system_continuity: { - plan_limit: 10, - grandfathered_floor: 23, - effective_limit: 23, - capture_pending: false, - }, }, displayableCapabilities: ['Pulse Relay (Remote Access)'], }), @@ -670,129 +636,6 @@ describe('licensePresentation', () => { ).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', - }, - { - mode: 'usage_unavailable', - urgency: 'ok', - current: 0, - limit: 10, - current_available: false, - current_unavailable_reason: 'supplemental_inventory_unsettled', - available_slots: 0, - overage: 0, - blocks_new_systems: false, - existing_monitoring_continues: false, - }, - ), - ).toMatchObject({ - title: 'Legacy continuity verification pending', - body: expect.stringContaining('legacy v5 monitoring continuity'), - tone: expect.stringContaining('amber'), - }); - expect( - getMonitoredSystemContinuityNotice( - { - plan_limit: 10, - effective_limit: 10, - capture_pending: true, - }, - { - current: 23, - limit: 10, - current_available: true, - state: 'enforced', - }, - { - mode: 'over_limit_frozen', - urgency: 'enforced', - current: 23, - limit: 10, - current_available: true, - available_slots: 0, - overage: 13, - reason: 'legacy_migration_capture_pending', - blocks_new_systems: true, - existing_monitoring_continues: true, - }, - ), - ).toMatchObject({ - title: 'Legacy continuity verification pending', - body: expect.stringContaining('identified 23 monitored systems'), - tone: expect.stringContaining('amber'), - }); - expect( - getMonitoredSystemContinuityNotice( - { - plan_limit: 10, - grandfathered_floor: 23, - effective_limit: 23, - capture_pending: false, - captured_at: 123, - }, - { - current_available: true, - }, - { - mode: 'at_limit_blocking_new', - urgency: 'enforced', - current: 23, - limit: 23, - current_available: true, - available_slots: 0, - overage: 0, - reason: 'limit_reached', - blocks_new_systems: true, - existing_monitoring_continues: true, - }, - { - planVersion: 'v5_pro_monthly_grandfathered', - isLifetime: false, - subscriptionState: 'active', - }, - ), - ).toBeNull(); - expect( - getMonitoredSystemContinuityNotice( - { - plan_limit: 10, - grandfathered_floor: 23, - effective_limit: 23, - capture_pending: false, - captured_at: 123, - }, - { - current_available: true, - }, - { - mode: 'at_limit_blocking_new', - urgency: 'enforced', - current: 23, - limit: 23, - current_available: true, - available_slots: 0, - overage: 0, - reason: 'limit_reached', - blocks_new_systems: true, - existing_monitoring_continues: true, - }, - ), - ).toMatchObject({ - title: 'Legacy monitoring continuity', - tone: expect.stringContaining('green'), - }); - }); - it('returns canonical purchase activation notices', () => { expect(getPurchaseActivationNotice('activated')).toMatchObject({ title: 'Plan activated', diff --git a/frontend-modern/src/utils/__tests__/monitoredSystemPresentation.test.ts b/frontend-modern/src/utils/__tests__/monitoredSystemPresentation.test.ts index b049336e8..56625b1c0 100644 --- a/frontend-modern/src/utils/__tests__/monitoredSystemPresentation.test.ts +++ b/frontend-modern/src/utils/__tests__/monitoredSystemPresentation.test.ts @@ -1,36 +1,20 @@ import { describe, expect, it } from 'vitest'; -import proLicensePanelStateSource from '@/components/Settings/useProLicensePanelState.ts?raw'; -import monitoredSystemLedgerPanelSource from '@/components/Settings/MonitoredSystemLedgerPanel.tsx?raw'; -import monitoredSystemLimitWarningBannerModelSource from '@/components/shared/monitoredSystemLimitWarningBannerModel.ts?raw'; -import commercialBillingModelSource from '@/utils/commercialBillingModel.ts?raw'; -import licensePresentationSource from '@/utils/licensePresentation.ts?raw'; -import monitoredSystemPresentationSource from '@/utils/monitoredSystemPresentation.ts?raw'; import { - buildMonitoredSystemCapacitySectionModel, - buildMonitoredSystemAdmissionPreviewUnavailableState, - formatMonitoredSystemAdmissionPreviewSummary, - formatMonitoredSystemAdmissionPreviewUnavailableMessage, + buildMonitoredSystemImpactPreviewUnavailableState, + formatMonitoredSystemImpactPreviewSummary, + formatMonitoredSystemImpactPreviewUnavailableMessage, formatMonitoredSystemGroupedSourcesLabel, - formatMonitoredSystemLegacyConnectionBreakdown, - formatMonitoredSystemUsageUnavailableMessage, - getMonitoredSystemBriefSummary, - formatMonitoredSystemLimitSummary, formatMonitoredSystemLedgerUnavailableMessage, formatMonitoredSystemLatestIncludedSignalSentence, - formatMonitoredSystemMigrationMessage, - formatMonitoredSystemOverflowSummary, formatMonitoredSystemSurfaceAttribution, - getMonitoredSystemAdmissionPreviewRequiredState, - getMonitoredSystemAdmissionPreviewSaveBlockedMessage, - getMonitoredSystemAdmissionPreviewTitle, - getMonitoredSystemAdmissionPreviewUnavailableTitle, + getMonitoredSystemImpactPreviewTitle, + getMonitoredSystemImpactPreviewUnavailableTitle, + getMonitoredSystemBriefSummary, getMonitoredSystemCountingDetailsToggleLabel, getMonitoredSystemDisclosureDefinition, getMonitoredSystemDisclosureToggleLabel, getMonitoredSystemExplanationFallbackSummary, - getMonitoredSystemLimitCapacityStatusSummary, - getMonitoredSystemLimitContextSummary, getMonitoredSystemLedgerDescription, getMonitoredSystemLedgerErrorState, getMonitoredSystemLedgerHiddenState, @@ -38,466 +22,107 @@ import { getMonitoredSystemLedgerPresentation, getMonitoredSystemLedgerPolicyLoadingState, getMonitoredSystemLedgerUnavailableState, - getMonitoredSystemLimitInstallCollectorsLabel, - getMonitoredSystemLimitReviewPolicyLabel, - getMonitoredSystemLimitUnavailableReason, - getMonitoredSystemLimitUsageSummary, getMonitoredSystemSourceLabel, - resolveMonitoredSystemCapacityStatus, getMonitoredSystemStatusFallbackSummary, getMonitoredSystemSurfaceTypeLabel, - isMonitoredSystemAdmissionPreviewResolvedSafely, - isMonitoredSystemLimitUrgent, - isMonitoredSystemLimitUsageAvailable, } from '@/utils/monitoredSystemPresentation'; describe('monitoredSystemPresentation', () => { - it('returns canonical ledger labels and fallback copy', () => { - expect(getMonitoredSystemLedgerPresentation()).toEqual({ - briefSummary: - 'Pulse counts top-level monitored systems. Child resources underneath them are included.', - sectionTitle: 'Monitored Systems', - panelTitle: 'Monitored System Ledger', - disclosureButtonLabel: 'View counting rules', - disclosureHideLabel: 'Hide counting rules', - disclosureDefinition: - 'A monitored system is a top-level monitored root such as a Docker host, Kubernetes cluster, Proxmox node, standalone host, or TrueNAS system. Each root counts once no matter how Pulse collects it. Child resources like VMs, containers, pods, disks, backups, and services underneath that root are included.', - ledgerDescription: - 'Review the top-level monitored systems Pulse has identified for reporting, migration continuity, and support context.', - tableNameLabel: 'Name', - tableStatusLabel: 'Status', - tableLatestIncludedSignalLabel: 'Latest Included Signal', - countedSystemBadgeLabel: 'Counts as 1 monitored system', - groupedSourcesHeading: 'Grouped sources', - countingExplanationHeading: 'Why this counts', - continuityHeading: 'Legacy continuity', - continuityPlanLimitLabel: 'Plan baseline', - continuityEffectiveLimitLabel: 'Current baseline', - continuityGrandfatheredFloorLabel: 'Observed legacy estate', - continuityCaptureLabel: 'Verification', - continuityCapturePendingLabel: 'Pending', - continuityCaptureCapturedLabel: 'Captured', - usageVerifyingLabel: 'Verifying…', - remainingCapacityUnavailableLabel: 'Unavailable', - unlimitedLimitLabel: 'Not metered', - loadingState: { - text: 'Loading monitored system usage…', - }, - errorState: { - title: 'Monitored system usage is temporarily unavailable.', - retryingLabel: 'Trying again…', - retryLabel: 'Try again', - }, - unavailableState: { - title: 'Verifying monitored-system inventory', - fallbackMessage: - 'Pulse cannot currently verify monitored-system usage for this installation. Refresh after the monitoring runtime settles.', - unsettledMessage: - 'Pulse is still collecting the first provider-owned inventory baseline. The monitored-system ledger will appear after that baseline completes.', - rebuildPendingMessage: - 'Pulse has collected provider-owned inventory and is rebuilding the canonical monitored-system ledger. Usage will appear when that rebuild finishes.', - }, - policyLoadingState: { - title: 'Checking monitored-system visibility', - message: - 'Pulse waits for the session visibility state before loading monitored-system usage details.', - }, - hiddenState: { - title: 'Monitored-system usage is hidden in demo mode', - message: - 'The public demo uses sample infrastructure data, so Pulse hides counted-system totals and billing actions instead of creating a demo license.', - }, - countingDetailsCollapsedLabel: 'View counting details', - countingDetailsExpandedLabel: 'Hide counting details', - currentStatusHeading: 'Current status', - latestIncludedSignalSummaryLabel: 'Latest included signal', - includedCollectionPathsHeading: 'Included collection paths', - emptyState: 'No monitored systems counted.', - noIncludedSignalLabel: 'No included signal yet.', - fallbackExplanationSummary: - 'Pulse counts this top-level collection path as one monitored system.', - statusSummaryByStatus: { - online: 'All included top-level collection paths currently report online status.', - warning: - 'At least one included top-level collection path is degraded, so Pulse marks this monitored system as warning.', - offline: - 'At least one included source is offline or disconnected, so Pulse marks this monitored system as offline.', - unknown: 'Pulse cannot determine a canonical runtime status for this monitored system yet.', - }, - limitBanner: { - reviewPolicyLabel: 'Review continuity', - installCollectorsLabel: 'Install v6 collectors', - overflowSummaryPrefix: 'A temporary setup slot is active', - legacyConnectionSuffix: - 'that are folded into the canonical monitored-system ledger when the same top-level system is discovered canonically.', - }, - admissionPreview: { - requiredTitle: 'Preview monitored-system impact before saving', - requiredMessage: - 'Pulse must preview the monitored-system impact for this platform connection before it can be saved.', - fallbackTitle: 'Monitored-system impact', - exceedsPolicyTitle: 'This change needs continuity review before saving', - addsSystemsTitle: 'This change adds monitored systems', - removesSystemsTitle: 'This change removes monitored systems', - unchangedTitle: 'This change keeps monitored-system count unchanged', - unavailableTitle: 'Monitored-system verification is temporarily unavailable', - unavailableFallbackMessage: - 'Pulse cannot verify monitored-system impact right now, so this connection cannot be saved yet. Retry preview in a moment.', - unavailableUnsettledMessage: - 'Pulse is still settling provider-owned inventory for this platform connection, so the monitored-system check is not safe yet. Retry preview after the first baseline finishes.', - unavailableRebuildPendingMessage: - 'Pulse has settled provider-owned inventory and is rebuilding the canonical monitored-system view, so this connection cannot be saved yet. Retry preview in a moment.', - saveBlockedLimitMessage: 'This change needs monitored-system review before saving', - saveBlockedLoadingMessage: 'Wait for the monitored-system impact preview to finish', - }, - }); + it('returns canonical ledger labels and fallback copy without capacity policy copy', () => { expect(getMonitoredSystemBriefSummary()).toBe( 'Pulse counts top-level monitored systems. Child resources underneath them are included.', ); + expect(getMonitoredSystemLedgerDescription()).toBe( + 'Review the top-level monitored systems Pulse has identified for reporting and support context.', + ); expect(getMonitoredSystemDisclosureToggleLabel(false)).toBe('View counting rules'); expect(getMonitoredSystemDisclosureToggleLabel(true)).toBe('Hide counting rules'); - expect(getMonitoredSystemDisclosureDefinition()).toContain('Docker host'); - expect(getMonitoredSystemDisclosureDefinition()).toContain('Proxmox node'); - expect(getMonitoredSystemLedgerDescription()).toBe( - 'Review the top-level monitored systems Pulse has identified for reporting, migration continuity, and support context.', - ); - expect(getMonitoredSystemLedgerLoadingState()).toEqual({ - text: 'Loading monitored system usage…', - }); - expect(getMonitoredSystemLedgerErrorState()).toEqual({ - title: 'Monitored system usage is temporarily unavailable.', - retryingLabel: 'Trying again…', - retryLabel: 'Try again', - }); - expect(getMonitoredSystemLedgerUnavailableState()).toEqual({ - title: 'Verifying monitored-system inventory', - fallbackMessage: - 'Pulse cannot currently verify monitored-system usage for this installation. Refresh after the monitoring runtime settles.', - unsettledMessage: - 'Pulse is still collecting the first provider-owned inventory baseline. The monitored-system ledger will appear after that baseline completes.', - rebuildPendingMessage: - 'Pulse has collected provider-owned inventory and is rebuilding the canonical monitored-system ledger. Usage will appear when that rebuild finishes.', - }); - expect(getMonitoredSystemLedgerPolicyLoadingState()).toEqual({ - title: 'Checking monitored-system visibility', - message: - 'Pulse waits for the session visibility state before loading monitored-system usage details.', - }); - expect(getMonitoredSystemLedgerHiddenState()).toEqual({ - title: 'Monitored-system usage is hidden in demo mode', - message: - 'The public demo uses sample infrastructure data, so Pulse hides counted-system totals and billing actions instead of creating a demo license.', - }); + expect(getMonitoredSystemDisclosureDefinition()).toContain('Each root counts once'); expect(getMonitoredSystemCountingDetailsToggleLabel(false)).toBe('View counting details'); expect(getMonitoredSystemCountingDetailsToggleLabel(true)).toBe('Hide counting details'); expect(getMonitoredSystemExplanationFallbackSummary()).toBe( 'Pulse counts this top-level collection path as one monitored system.', ); + expect(getMonitoredSystemLedgerLoadingState().text).toContain('Loading monitored system usage'); + expect(getMonitoredSystemLedgerErrorState().retryLabel).toBe('Try again'); + expect(getMonitoredSystemLedgerUnavailableState().title).toBe( + 'Verifying monitored-system inventory', + ); + expect(getMonitoredSystemLedgerPolicyLoadingState().title).toBe( + 'Checking monitored-system visibility', + ); + expect(getMonitoredSystemLedgerHiddenState().title).toBe( + 'Monitored-system usage is hidden in demo mode', + ); + expect(JSON.stringify(getMonitoredSystemLedgerPresentation())).not.toContain('capacity'); + expect(JSON.stringify(getMonitoredSystemLedgerPresentation())).not.toContain('limit'); + }); + + it('returns status, source, type, and attribution labels', () => { expect(getMonitoredSystemStatusFallbackSummary('online')).toBe( 'All included top-level collection paths currently report online status.', ); - expect(getMonitoredSystemStatusFallbackSummary('warning')).toBe( - 'At least one included top-level collection path is degraded, so Pulse marks this monitored system as warning.', - ); - expect(getMonitoredSystemStatusFallbackSummary('offline')).toBe( - 'At least one included source is offline or disconnected, so Pulse marks this monitored system as offline.', - ); - expect(getMonitoredSystemStatusFallbackSummary()).toBe( - 'Pulse cannot determine a canonical runtime status for this monitored system yet.', - ); + expect(getMonitoredSystemStatusFallbackSummary('warning')).toContain('degraded'); + expect(getMonitoredSystemStatusFallbackSummary('offline')).toContain('offline or disconnected'); + expect(getMonitoredSystemStatusFallbackSummary()).toContain('cannot determine'); + expect(getMonitoredSystemSourceLabel('vmware')).toBe('VMware'); + expect(getMonitoredSystemSourceLabel('unknown')).toBe(''); + expect(getMonitoredSystemSurfaceTypeLabel('docker-host')).toBe('Docker Host'); + expect(getMonitoredSystemSurfaceTypeLabel('custom-system')).toBe('Custom System'); + expect( + formatMonitoredSystemSurfaceAttribution({ + name: 'esx-a', + type: 'host', + source: 'vmware', + }), + ).toBe('esx-a (Host via VMware)'); + expect(formatMonitoredSystemGroupedSourcesLabel(1)).toBe('1 grouped source'); + expect(formatMonitoredSystemGroupedSourcesLabel(3)).toBe('3 grouped sources'); + expect( + formatMonitoredSystemLatestIncludedSignalSentence({ + attribution: 'esx-a (Host via VMware)', + relative: '2m ago', + }), + ).toBe('Latest included signal: esx-a (Host via VMware), reported 2m ago.'); }); - it('keeps monitored-system usage availability on the shared presentation helper', () => { - expect(monitoredSystemPresentationSource).toContain( - 'export function isMonitoredSystemLimitUsageAvailable', - ); - expect(monitoredSystemPresentationSource).toContain( - 'export function getMonitoredSystemLimitUsageSummary', - ); - expect(monitoredSystemPresentationSource).toContain( - 'export function getMonitoredSystemLimitCapacityStatusSummary', - ); - expect(monitoredSystemPresentationSource).toContain( - 'export function resolveMonitoredSystemCapacityStatus', - ); - - for (const source of [ - licensePresentationSource, - monitoredSystemLedgerPanelSource, - monitoredSystemLimitWarningBannerModelSource, - proLicensePanelStateSource, - ]) { - expect(source).not.toContain('current_available !== false'); - } - expect(proLicensePanelStateSource).not.toContain("'Verifying…'"); - expect(proLicensePanelStateSource).not.toContain("'Unavailable'"); - - const paidLimitPhrases = [ - 'active monitored-system policy', - 'additional monitored-system admissions', - 'finite policy', - 'policy boundary', - 'capacity is available', - 'Grandfathered monitored-system floor', - 'effective monitored-system limit', - 'Plan Monitored System Limit', - 'Effective Monitored System Limit', - 'Included Monitored Systems', - 'remaining before', - 'Over policy', - ]; - for (const source of [ - commercialBillingModelSource, - licensePresentationSource, - monitoredSystemLedgerPanelSource, - monitoredSystemLimitWarningBannerModelSource, - monitoredSystemPresentationSource, - proLicensePanelStateSource, - ]) { - for (const phrase of paidLimitPhrases) { - expect(source).not.toContain(phrase); - } - } - }); - - it('returns canonical monitored-system limit warning copy', () => { - expect(getMonitoredSystemLimitReviewPolicyLabel()).toBe('Review continuity'); - expect(getMonitoredSystemLimitInstallCollectorsLabel()).toBe('Install v6 collectors'); - expect(formatMonitoredSystemLimitSummary({ current: 5, limit: 6 })).toBe( - '5 monitored systems.', - ); - expect(formatMonitoredSystemLimitSummary({ current: 16, limit: 5, state: 'enforced' })).toBe( - 'Continuity review needed. 16 monitored systems.', - ); - expect( - formatMonitoredSystemLimitSummary( - { current: 16, limit: 5, state: 'enforced' }, - { - mode: 'over_limit_frozen', - urgency: 'enforced', - current: 16, - limit: 5, - current_available: true, - available_slots: 0, - overage: 11, - reason: 'legacy_migration_capture_pending', - blocks_new_systems: true, - existing_monitoring_continues: true, - }, - ), - ).toBe('Continuity verification pending. 16 monitored systems.'); - expect( - formatMonitoredSystemLegacyConnectionBreakdown({ - proxmox_nodes: 2, - docker_hosts: 1, - kubernetes_clusters: 0, - }), - ).toBe('2 Proxmox nodes, 1 Docker host'); - expect( - formatMonitoredSystemMigrationMessage({ - proxmox_nodes: 2, - docker_hosts: 1, - kubernetes_clusters: 0, - }), - ).toBe( - 'You also have 3 resources connected via API or legacy collectors (2 Proxmox nodes, 1 Docker host) that are folded into the canonical monitored-system ledger when the same top-level system is discovered canonically.', - ); - expect(formatMonitoredSystemOverflowSummary(14)).toBe( - 'A temporary setup slot is active (14d remaining)', - ); - expect(formatMonitoredSystemOverflowSummary(undefined)).toBe(''); - }); - - it('builds a monitored-system capacity section model for the plan surface', () => { - expect( - buildMonitoredSystemCapacitySectionModel({ - current: 16, - limit: 5, - current_available: true, - state: 'enforced', - }), - ).toEqual({ - stats: [ - { label: 'Monitored', value: '16 monitored systems' }, - { label: 'Baseline', value: '5' }, - { label: 'Status', value: 'Continuity review' }, - ], - statusMessage: 'Existing monitoring remains visible. New top-level additions need review.', - detailMessage: - 'Review the legacy continuity state before adding another top-level monitored system.', - explanation: { - label: 'Why does this need review?', - body: 'Pulse has already identified 16 monitored systems for this installation. Existing monitoring remains visible, but new top-level additions are paused until this legacy continuity state is reviewed.', - }, - }); - }); - - it('does not build a monitored-system capacity section for unmetered or healthy self-hosted states', () => { - expect( - buildMonitoredSystemCapacitySectionModel(undefined, { - mode: 'unlimited', - urgency: 'ok', - current: 12, - limit: 0, - current_available: true, - available_slots: 0, - overage: 0, - blocks_new_systems: false, - existing_monitoring_continues: true, - }), - ).toBeNull(); - expect( - buildMonitoredSystemCapacitySectionModel({ - current: 7, - limit: 10, - current_available: true, - state: 'ok', - }), - ).toBeNull(); - }); - - it('centralizes monitored-system limit availability and capacity presentation', () => { - const unavailableLimit = { - current: 0, - limit: 10, - current_available: false, - current_unavailable_reason: 'supplemental_inventory_unsettled', - state: 'enforced', - }; - - expect(isMonitoredSystemLimitUsageAvailable(unavailableLimit)).toBe(false); - expect(getMonitoredSystemLimitUnavailableReason(unavailableLimit)).toBe( - 'supplemental_inventory_unsettled', - ); - expect(getMonitoredSystemLimitUsageSummary(unavailableLimit)).toBe('Verifying…'); - expect(getMonitoredSystemLimitCapacityStatusSummary(unavailableLimit)).toBe('Unavailable'); - expect(getMonitoredSystemLimitContextSummary(unavailableLimit)).toBe( - 'Pulse is still collecting the first provider-owned inventory baseline. The monitored-system ledger will appear after that baseline completes.', - ); - expect(isMonitoredSystemLimitUrgent(unavailableLimit)).toBe(false); - expect( - formatMonitoredSystemUsageUnavailableMessage(unavailableLimit.current_unavailable_reason), - ).toBe( - 'Pulse is still collecting the first provider-owned inventory baseline. The monitored-system ledger will appear after that baseline completes.', - ); - - expect( - getMonitoredSystemLimitUsageSummary({ - current: 7, - limit: 10, - current_available: true, - }), - ).toBe('7 monitored systems'); - expect( - getMonitoredSystemLimitCapacityStatusSummary({ - current: 7, - limit: 10, - current_available: true, - }), - ).toBe('Healthy'); - expect( - getMonitoredSystemLimitCapacityStatusSummary({ - current: 7, - limit: 0, - current_available: true, - }), - ).toBe('Not metered'); - expect( - getMonitoredSystemLimitContextSummary({ - current: 10, - limit: 10, - current_available: true, - state: 'enforced', - }), - ).toBe( - 'Existing monitoring remains visible. New top-level additions are paused until this legacy continuity state is reviewed.', - ); - expect( - getMonitoredSystemLimitContextSummary({ - current: 16, - limit: 5, - current_available: true, - state: 'enforced', - }), - ).toBe( - 'Existing monitoring remains visible. New top-level additions are paused until this legacy continuity state is reviewed.', - ); - expect( - getMonitoredSystemLimitContextSummary( - { - current: 16, - limit: 5, - current_available: true, - state: 'enforced', - }, - { - mode: 'over_limit_frozen', - urgency: 'enforced', - current: 16, - limit: 5, - current_available: true, - available_slots: 0, - overage: 11, - reason: 'legacy_migration_capture_pending', - blocks_new_systems: true, - existing_monitoring_continues: true, - }, - ), - ).toBe( - 'Pulse is verifying legacy v5 continuity for this installation. Existing monitoring remains visible while new top-level additions wait for verification to finish.', - ); - expect( - isMonitoredSystemLimitUrgent({ - current: 9, - limit: 10, - current_available: true, - state: 'warning', - }), - ).toBe(true); - expect( - resolveMonitoredSystemCapacityStatus(undefined, { - current: 16, - limit: 5, - current_available: true, - state: 'enforced', - }), - ).toMatchObject({ - mode: 'over_limit_frozen', - overage: 11, - reason: 'preexisting_usage', - blocks_new_systems: true, - existing_monitoring_continues: true, - }); - }); - - it('returns canonical monitored-system admission unavailable copy', () => { - expect(getMonitoredSystemAdmissionPreviewRequiredState()).toEqual({ - title: 'Preview monitored-system impact before saving', - message: - 'Pulse must preview the monitored-system impact for this platform connection before it can be saved.', - }); - expect(getMonitoredSystemAdmissionPreviewUnavailableTitle()).toBe( + it('returns impact preview copy without quota math', () => { + expect(getMonitoredSystemImpactPreviewUnavailableTitle()).toBe( 'Monitored-system verification is temporarily unavailable', ); - expect( - formatMonitoredSystemAdmissionPreviewUnavailableMessage('supplemental_inventory_unsettled'), - ).toBe( - 'Pulse is still settling provider-owned inventory for this platform connection, so the monitored-system check is not safe yet. Retry preview after the first baseline finishes.', + expect(getMonitoredSystemImpactPreviewTitle(null)).toBe('Monitored-system impact'); + expect(getMonitoredSystemImpactPreviewTitle({ current_count: 4, projected_count: 5 })).toBe( + 'This change adds monitored systems', + ); + expect(getMonitoredSystemImpactPreviewTitle({ current_count: 4, projected_count: 3 })).toBe( + 'This change removes monitored systems', + ); + expect(getMonitoredSystemImpactPreviewTitle({ current_count: 4, projected_count: 4 })).toBe( + 'This change keeps monitored-system count unchanged', ); expect( - formatMonitoredSystemAdmissionPreviewUnavailableMessage( + formatMonitoredSystemImpactPreviewSummary({ + current_count: 9, + projected_count: 11, + }), + ).toBe( + 'Pulse currently counts 9 monitored systems. Saving this change would bring the count to 11 monitored systems (+2).', + ); + }); + + it('returns monitored-system unavailable copy', () => { + expect(formatMonitoredSystemLedgerUnavailableMessage('supplemental_inventory_unsettled')).toBe( + 'Pulse is still collecting the first provider-owned inventory baseline. The monitored-system ledger will appear after that baseline completes.', + ); + expect( + formatMonitoredSystemImpactPreviewUnavailableMessage( 'supplemental_inventory_rebuild_pending', ), ).toBe( - 'Pulse has settled provider-owned inventory and is rebuilding the canonical monitored-system view, so this connection cannot be saved yet. Retry preview in a moment.', + 'Pulse has settled provider-owned inventory and is rebuilding the canonical monitored-system view. You can still save the connection and review the impact in a moment.', ); expect( - formatMonitoredSystemAdmissionPreviewUnavailableMessage('monitor_state_unavailable'), - ).toBe( - 'Pulse cannot verify monitored-system impact right now, so this connection cannot be saved yet. Retry preview in a moment.', - ); - expect( - buildMonitoredSystemAdmissionPreviewUnavailableState({ + buildMonitoredSystemImpactPreviewUnavailableState({ code: 'monitored_system_usage_unavailable', reason: ' supplemental_inventory_unsettled ', }), @@ -505,156 +130,7 @@ describe('monitoredSystemPresentation', () => { reason: 'supplemental_inventory_unsettled', title: 'Monitored-system verification is temporarily unavailable', message: - 'Pulse is still settling provider-owned inventory for this platform connection, so the monitored-system check is not safe yet. Retry preview after the first baseline finishes.', + 'Pulse is still settling provider-owned inventory for this platform connection. You can still save the connection and review the impact after the first baseline finishes.', }); - expect( - buildMonitoredSystemAdmissionPreviewUnavailableState({ - code: 'provider_failed', - reason: 'supplemental_inventory_unsettled', - }), - ).toBeNull(); - expect( - isMonitoredSystemAdmissionPreviewResolvedSafely({ - preview: { would_exceed_limit: false }, - }), - ).toBe(true); - expect( - isMonitoredSystemAdmissionPreviewResolvedSafely({ - preview: { would_exceed_limit: true }, - }), - ).toBe(false); - expect(getMonitoredSystemAdmissionPreviewSaveBlockedMessage({ preview: null })).toBe( - 'Pulse must preview the monitored-system impact for this platform connection before it can be saved.', - ); - expect( - getMonitoredSystemAdmissionPreviewSaveBlockedMessage({ - preview: { would_exceed_limit: true }, - }), - ).toBe('This change needs monitored-system review before saving'); - expect( - getMonitoredSystemAdmissionPreviewSaveBlockedMessage({ - preview: { would_exceed_limit: false }, - }), - ).toBeNull(); - }); - - it('returns neutral monitored-system admission preview titles', () => { - expect(getMonitoredSystemAdmissionPreviewTitle(null)).toBe('Monitored-system impact'); - expect( - getMonitoredSystemAdmissionPreviewTitle({ - current_count: 4, - projected_count: 5, - would_exceed_limit: false, - }), - ).toBe('This change adds monitored systems'); - expect( - getMonitoredSystemAdmissionPreviewTitle({ - current_count: 4, - projected_count: 3, - would_exceed_limit: false, - }), - ).toBe('This change removes monitored systems'); - expect( - getMonitoredSystemAdmissionPreviewTitle({ - current_count: 4, - projected_count: 4, - would_exceed_limit: false, - }), - ).toBe('This change keeps monitored-system count unchanged'); - expect( - getMonitoredSystemAdmissionPreviewTitle({ - current_count: 4, - projected_count: 11, - would_exceed_limit: true, - }), - ).toBe('This change needs continuity review before saving'); - }); - - it('formats monitored-system admission preview summaries without quota math', () => { - expect( - formatMonitoredSystemAdmissionPreviewSummary({ - current_count: 4, - projected_count: 4, - limit: 10, - }), - ).toBe( - 'Pulse currently counts 4 monitored systems. Saving this change would keep the count at 4 monitored systems.', - ); - expect( - formatMonitoredSystemAdmissionPreviewSummary({ - current_count: 9, - projected_count: 11, - limit: 10, - would_exceed_limit: true, - }), - ).toBe( - 'Pulse currently counts 9 monitored systems. Saving this change would bring the count to 11 monitored systems (+2), above the current verified baseline of 10 monitored systems.', - ); - }); - - it('returns canonical monitored-system ledger unavailable copy', () => { - expect(formatMonitoredSystemLedgerUnavailableMessage('supplemental_inventory_unsettled')).toBe( - 'Pulse is still collecting the first provider-owned inventory baseline. The monitored-system ledger will appear after that baseline completes.', - ); - expect( - formatMonitoredSystemLedgerUnavailableMessage('supplemental_inventory_rebuild_pending'), - ).toBe( - 'Pulse has collected provider-owned inventory and is rebuilding the canonical monitored-system ledger. Usage will appear when that rebuild finishes.', - ); - expect(formatMonitoredSystemLedgerUnavailableMessage('monitor_state_unavailable')).toBe( - 'Pulse cannot currently verify monitored-system usage for this installation. Refresh after the monitoring runtime settles.', - ); - }); - - it('returns customer-facing source and type labels', () => { - expect(getMonitoredSystemSourceLabel('agent')).toBe('Agent'); - expect(getMonitoredSystemSourceLabel('multiple')).toBe('Multiple Sources'); - expect(getMonitoredSystemSourceLabel('pbs')).toBe('PBS'); - expect(getMonitoredSystemSourceLabel('vmware')).toBe('VMware'); - expect(getMonitoredSystemSourceLabel('')).toBe(''); - expect(getMonitoredSystemSurfaceTypeLabel('agent')).toBe('Host'); - expect(getMonitoredSystemSurfaceTypeLabel('docker-host')).toBe('Docker Host'); - expect(getMonitoredSystemSurfaceTypeLabel('proxmox-node')).toBe('Proxmox Node'); - expect(getMonitoredSystemSurfaceTypeLabel(undefined)).toBe('System'); - expect(getMonitoredSystemSurfaceTypeLabel('custom_cluster')).toBe('Custom Cluster'); - }); - - it('formats included signal attribution and summary sentences', () => { - expect(formatMonitoredSystemGroupedSourcesLabel(1)).toBe('1 grouped source'); - expect(formatMonitoredSystemGroupedSourcesLabel(2)).toBe('2 grouped sources'); - expect( - formatMonitoredSystemSurfaceAttribution({ - name: 'tower', - type: 'pbs-server', - source: 'pbs', - }), - ).toBe('tower (PBS Server via PBS)'); - expect( - formatMonitoredSystemSurfaceAttribution({ - name: 'tower', - type: 'host', - source: 'host', - }), - ).toBe('tower (Host)'); - expect( - formatMonitoredSystemSurfaceAttribution({ - name: 'esxi-01', - type: 'host', - source: 'vmware', - }), - ).toBe('esxi-01 (Host via VMware)'); - expect( - formatMonitoredSystemSurfaceAttribution({ - name: 'tower', - type: 'truenas-system', - source: 'multiple', - }), - ).toBe('tower (TrueNAS System via Multiple Sources)'); - expect( - formatMonitoredSystemLatestIncludedSignalSentence({ - attribution: 'tower (PBS Server via PBS)', - relative: '2m ago', - }), - ).toBe('Latest included signal: tower (PBS Server via PBS), reported 2m ago.'); }); }); diff --git a/frontend-modern/src/utils/__tests__/pricingHandoff.test.ts b/frontend-modern/src/utils/__tests__/pricingHandoff.test.ts index 63633cc79..6aca2f955 100644 --- a/frontend-modern/src/utils/__tests__/pricingHandoff.test.ts +++ b/frontend-modern/src/utils/__tests__/pricingHandoff.test.ts @@ -34,9 +34,9 @@ import { } from '@/utils/pricingHandoff'; describe('pricingHandoff', () => { - it('retires legacy monitored-system pricing links onto the neutral plan surface', () => { + it('does not keep a special in-product monitored-system pricing route', () => { expect(getUpgradeFallbackDestination('max_monitored_systems')).toBe( - SELF_HOSTED_PRO_BILLING_PLAN_HREF, + getSelfHostedPurchaseStartUrl('max_monitored_systems'), ); }); @@ -118,9 +118,9 @@ describe('pricingHandoff', () => { ); }); - it('keeps retired monitored-system pricing handoffs on the neutral Plans surface', () => { + it('treats legacy monitored-system pricing links as external purchase-start compatibility', () => { expect(getPricingRouteDestination('?feature=max_monitored_systems')).toBe( - SELF_HOSTED_PRO_BILLING_PLAN_HREF, + getSelfHostedPurchaseStartUrl('max_monitored_systems'), ); }); diff --git a/frontend-modern/src/utils/apiClient.ts b/frontend-modern/src/utils/apiClient.ts index d992b86a7..ba181e54e 100644 --- a/frontend-modern/src/utils/apiClient.ts +++ b/frontend-modern/src/utils/apiClient.ts @@ -83,7 +83,6 @@ type APIErrorShape = Error & { detail?: string; details?: APIErrorDetails; feature?: string; - monitored_system_preview?: unknown; requiredScope?: string; upgrade_url?: string; status?: number; @@ -118,7 +117,6 @@ async function createAPIErrorFromResponse( let errorDetail: string | undefined; let errorDetails: APIErrorDetails | undefined; let errorFeature: string | undefined; - let errorMonitoredSystemPreview: unknown; let errorRequiredScope: string | undefined; let errorUpgradeUrl: string | undefined; try { @@ -133,12 +131,6 @@ async function createAPIErrorFromResponse( } errorCode = sanitizeBoundedText(jsonError.code, MAX_API_ERROR_CODE_LENGTH) ?? undefined; errorDetails = sanitizeAPIErrorDetails(jsonError.details); - if ( - jsonError.monitored_system_preview && - typeof jsonError.monitored_system_preview === 'object' - ) { - errorMonitoredSystemPreview = jsonError.monitored_system_preview; - } errorRequiredScope = sanitizeBoundedText(jsonError.requiredScope, 128) ?? undefined; errorFeature = sanitizeBoundedText(jsonError.feature, 128) ?? undefined; errorUpgradeUrl = sanitizeBoundedText(jsonError.upgrade_url, 2048) ?? undefined; @@ -174,9 +166,6 @@ async function createAPIErrorFromResponse( if (errorFeature) { err.feature = errorFeature; } - if (errorMonitoredSystemPreview) { - err.monitored_system_preview = errorMonitoredSystemPreview; - } if (errorUpgradeUrl) { err.upgrade_url = errorUpgradeUrl; } diff --git a/frontend-modern/src/utils/commercialBillingModel.ts b/frontend-modern/src/utils/commercialBillingModel.ts index ccfa5e154..2cfdf3c37 100644 --- a/frontend-modern/src/utils/commercialBillingModel.ts +++ b/frontend-modern/src/utils/commercialBillingModel.ts @@ -1,5 +1,4 @@ import type { LicenseStatus } from '@/api/license'; -import type { MonitoredSystemContinuityStatus } from '@/api/license'; import type { SelfHostedPlanDefinition } from '@/utils/selfHostedPlans'; export interface CommercialStatValue { @@ -30,21 +29,16 @@ export interface SelfHostedCommercialModelInput { planTerms?: string; expires: string; daysRemaining: string | number; - monitoredSystemsSummary: string | number; - capacityStatusSummary: string | number; - maxMonitoredSystems: string | number; retailPlanDefinition?: Pick< SelfHostedPlanDefinition, 'billingExtrasSummary' | 'metricHistoryDays' > | null; - monitoredSystemContinuity?: MonitoredSystemContinuityStatus | null; - continuityCapturedAt?: string; } export interface HostedCommercialModelInput { status?: Pick< LicenseStatus, - 'email' | 'is_lifetime' | 'expires_at' | 'max_monitored_systems' | 'max_guests' + 'email' | 'is_lifetime' | 'expires_at' | 'max_guests' > | null; tierLabel: string; licenseStatusLabel: string; @@ -61,13 +55,6 @@ export const LIFETIME_DAYS_REMAINING_LABEL = 'Permanent'; const asUnlimitedLimit = (value?: number) => typeof value === 'number' && value > 0 ? value : undefined; -const hasFiniteSelfHostedLimit = (value: string | number) => - typeof value === 'number' - ? value > 0 - : !['unlimited', SELF_HOSTED_NOT_METERED_LABEL.toLowerCase()].includes( - value.trim().toLowerCase(), - ); - const buildSelfHostedBaseDetails = ( input: SelfHostedCommercialModelInput, ): CommercialPlanViewModel['details'] => [ @@ -121,75 +108,17 @@ export const buildSelfHostedCommercialPlanModel = ( } return { - summary: - !input.monitoredSystemContinuity && !hasFiniteSelfHostedLimit(input.maxMonitoredSystems) - ? [ - { - label: 'Core Monitoring', - value: 'Included', - }, - { - label: 'Plan Status', - value: input.statusLabel, - }, - ] - : [ - { - label: 'Monitored Systems', - value: input.monitoredSystemsSummary, - }, - { - label: 'Continuity Status', - value: input.capacityStatusSummary, - }, - { - label: 'Plan Status', - value: input.statusLabel, - }, - ], - details: [ - ...buildSelfHostedBaseDetails(input), - ...(input.monitoredSystemContinuity - ? [ - { - label: 'Plan Baseline', - value: - input.monitoredSystemContinuity.plan_limit > 0 - ? input.monitoredSystemContinuity.plan_limit - : SELF_HOSTED_NOT_METERED_LABEL, - }, - { - label: 'Current Baseline', - value: - input.monitoredSystemContinuity.effective_limit > 0 - ? input.monitoredSystemContinuity.effective_limit - : SELF_HOSTED_NOT_METERED_LABEL, - }, - ...(typeof input.monitoredSystemContinuity.grandfathered_floor === 'number' && - input.monitoredSystemContinuity.grandfathered_floor > 0 - ? [ - { - label: 'Observed Legacy Estate', - value: input.monitoredSystemContinuity.grandfathered_floor, - }, - ] - : []), - { - label: 'Continuity Verification', - value: input.monitoredSystemContinuity.capture_pending - ? 'Pending' - : input.continuityCapturedAt || 'Captured', - }, - ] - : hasFiniteSelfHostedLimit(input.maxMonitoredSystems) - ? [ - { - label: 'Recorded Monitoring Baseline', - value: input.maxMonitoredSystems, - }, - ] - : []), + summary: [ + { + label: 'Core Monitoring', + value: 'Included', + }, + { + label: 'Plan Status', + value: input.statusLabel, + }, ], + details: buildSelfHostedBaseDetails(input), }; }; @@ -233,7 +162,6 @@ export const buildHostedCommercialUsageModel = ( { label: 'Monitored Systems', current: input.nodeUsage, - limit: asUnlimitedLimit(input.status?.max_monitored_systems), accentClass: 'bg-blue-600 dark:bg-blue-500', }, { diff --git a/frontend-modern/src/utils/deployStatusPresentation.ts b/frontend-modern/src/utils/deployStatusPresentation.ts index ddbd62a9b..3431169bb 100644 --- a/frontend-modern/src/utils/deployStatusPresentation.ts +++ b/frontend-modern/src/utils/deployStatusPresentation.ts @@ -43,10 +43,6 @@ const DEPLOY_STATUS_PRESENTATION: Record { - if (isLifetime) { - return true; - } - return isGrandfatheredRecurringV5PlanVersion(planVersion); -}; - const isActiveOrGraceSubscription = (subscriptionState?: string | null): boolean => { const normalized = (subscriptionState || '').trim().toLowerCase(); return normalized === 'active' || normalized === 'grace'; }; -export const hasActiveUncappedSelfHostedContinuity = ({ - planVersion, - isLifetime, - subscriptionState, -}: { - planVersion?: string | null; - isLifetime?: boolean | null; - subscriptionState?: string | null; -}): boolean => { - if (isLifetime) { - return true; - } - return ( - isActiveOrGraceSubscription(subscriptionState) && - isGrandfatheredRecurringV5PlanVersion(planVersion) - ); -}; - -export const getDisplayableMonitoredSystemContinuity = ({ - continuity, - planVersion, - isLifetime, - subscriptionState, -}: { - continuity?: MonitoredSystemContinuityStatus | null; - planVersion?: string | null; - isLifetime?: boolean | null; - subscriptionState?: string | null; -}): MonitoredSystemContinuityStatus | null => { - if (!continuity) { - return null; - } - if (hasActiveUncappedSelfHostedContinuity({ planVersion, isLifetime, subscriptionState })) { - return null; - } - if (typeof continuity.effective_limit === 'number' && continuity.effective_limit <= 0) { - return null; - } - return continuity; -}; - export const getLicenseTierLabel = (tier?: string | null): string => { const normalized = (tier || '').trim().toLowerCase(); if (!normalized) return 'Unknown'; @@ -271,78 +211,6 @@ export const getGrandfatheredPriceContinuityNotice = ( }; }; -export const getMonitoredSystemContinuityNotice = ( - continuity?: MonitoredSystemContinuityStatus | null, - limit?: MonitoredSystemLimitUsageStatus | null, - capacity?: MonitoredSystemCapacityStatus | null, - context?: { - planVersion?: string | null; - isLifetime?: boolean | null; - subscriptionState?: string | null; - }, -): LicenseInlineNotice | null => { - const displayContinuity = getDisplayableMonitoredSystemContinuity({ - continuity, - planVersion: context?.planVersion, - isLifetime: context?.isLifetime, - subscriptionState: context?.subscriptionState, - }); - if (!displayContinuity) { - return null; - } - const resolvedCapacity = resolveMonitoredSystemCapacityStatus(capacity, limit); - - if (displayContinuity.capture_pending) { - if (!resolvedCapacity?.current_available) { - return { - tone: 'border-amber-200 dark:border-amber-900 bg-amber-50 dark:bg-amber-900 text-amber-900 dark:text-amber-100', - title: 'Legacy continuity verification pending', - body: `Pulse is still verifying legacy v5 monitoring continuity for this installation. ${formatMonitoredSystemUsageUnavailableMessage( - getMonitoredSystemLimitUnavailableReason(limit, capacity), - )}`, - }; - } - - if (resolvedCapacity.mode === 'over_limit_frozen') { - return { - tone: 'border-amber-200 dark:border-amber-900 bg-amber-50 dark:bg-amber-900 text-amber-900 dark:text-amber-100', - title: 'Legacy continuity verification pending', - body: `Pulse is still verifying legacy v5 monitoring continuity for this installation. Pulse has already identified ${resolvedCapacity.current} monitored systems for continuity reporting, and existing monitoring remains visible while new top-level additions wait for verification to finish.`, - }; - } - - return { - tone: 'border-amber-200 dark:border-amber-900 bg-amber-50 dark:bg-amber-900 text-amber-900 dark:text-amber-100', - title: 'Legacy continuity verification pending', - body: 'Pulse is still verifying legacy v5 monitoring continuity for this installation. Existing monitoring remains visible while Pulse finalizes the continuity baseline.', - }; - } - - if (!isMonitoredSystemLimitUsageAvailable(limit)) { - return { - tone: 'border-amber-200 dark:border-amber-900 bg-amber-50 dark:bg-amber-900 text-amber-900 dark:text-amber-100', - title: 'Monitored-system usage unavailable', - body: formatMonitoredSystemUsageUnavailableMessage( - getMonitoredSystemLimitUnavailableReason(limit, capacity), - ), - }; - } - - if ( - typeof displayContinuity.grandfathered_floor === 'number' && - displayContinuity.grandfathered_floor > 0 && - displayContinuity.effective_limit > displayContinuity.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: 'Legacy monitoring continuity', - body: `This migrated v5 installation keeps its observed legacy estate available for continuity reporting on this instance. Pulse recorded ${displayContinuity.grandfathered_floor} monitored systems during migration.`, - }; - } - - return null; -}; - const getSelfHostedUnlockedFeatures = ({ entitlements, displayableCapabilities, @@ -492,11 +360,6 @@ export const getSelfHostedCurrentPlanPresentation = ({ const normalizedTier = (current.tier || '').trim().toLowerCase(); const planLabel = getSelfHostedPlanLabel(current.tier); const planDefinition = getSelfHostedPlanDefinitionForBillingTier(current.tier); - const hasUncappedContinuity = hasActiveUncappedSelfHostedContinuity({ - planVersion: current.plan_version, - isLifetime: current.is_lifetime, - subscriptionState: current.subscription_state, - }); const unlockedFeatures = getSelfHostedUnlockedFeatures({ entitlements: current, displayableCapabilities, @@ -518,38 +381,13 @@ export const getSelfHostedCurrentPlanPresentation = ({ supplementalDetails.push( 'This migrated v5 subscription keeps its existing recurring price until cancellation. Self-hosted monitoring and child-resource volume are not metered in current v6 self-hosted packaging.', ); - } else if (hasUncappedContinuity && current.is_lifetime) { + } else if (current.is_lifetime) { supplementalBadges.push('Grandfathered lifetime'); supplementalDetails.push( 'This migrated lifetime install remains valid permanently, and self-hosted monitoring plus child-resource volume are not metered in current v6 self-hosted packaging.', ); } - const continuity = planDefinition - ? null - : getDisplayableMonitoredSystemContinuity({ - continuity: current.monitored_system_continuity, - planVersion: current.plan_version, - isLifetime: current.is_lifetime, - subscriptionState: current.subscription_state, - }); - if (continuity?.capture_pending) { - supplementalBadges.push('Continuity pending'); - supplementalDetails.push( - 'Pulse is still verifying legacy v5 monitoring continuity for this migrated installation.', - ); - } else if ( - continuity && - typeof continuity.grandfathered_floor === 'number' && - continuity.grandfathered_floor > 0 && - continuity.effective_limit > continuity.plan_limit - ) { - supplementalBadges.push('Legacy continuity'); - supplementalDetails.push( - 'This migrated installation keeps the observed legacy estate available for continuity reporting on this instance.', - ); - } - if (normalizedState === 'trial') { return { title: `Current plan: ${planLabel} Trial`, diff --git a/frontend-modern/src/utils/monitoredSystemPresentation.ts b/frontend-modern/src/utils/monitoredSystemPresentation.ts index aae9a171d..a09a24608 100644 --- a/frontend-modern/src/utils/monitoredSystemPresentation.ts +++ b/frontend-modern/src/utils/monitoredSystemPresentation.ts @@ -18,23 +18,13 @@ const MONITORED_SYSTEM_LEDGER_PRESENTATION = { disclosureDefinition: 'A monitored system is a top-level monitored root such as a Docker host, Kubernetes cluster, Proxmox node, standalone host, or TrueNAS system. Each root counts once no matter how Pulse collects it. Child resources like VMs, containers, pods, disks, backups, and services underneath that root are included.', ledgerDescription: - 'Review the top-level monitored systems Pulse has identified for reporting, migration continuity, and support context.', + 'Review the top-level monitored systems Pulse has identified for reporting and support context.', tableNameLabel: 'Name', tableStatusLabel: 'Status', tableLatestIncludedSignalLabel: 'Latest Included Signal', countedSystemBadgeLabel: 'Counts as 1 monitored system', groupedSourcesHeading: 'Grouped sources', countingExplanationHeading: 'Why this counts', - continuityHeading: 'Legacy continuity', - continuityPlanLimitLabel: 'Plan baseline', - continuityEffectiveLimitLabel: 'Current baseline', - continuityGrandfatheredFloorLabel: 'Observed legacy estate', - continuityCaptureLabel: 'Verification', - continuityCapturePendingLabel: 'Pending', - continuityCaptureCapturedLabel: 'Captured', - usageVerifyingLabel: 'Verifying…', - remainingCapacityUnavailableLabel: 'Unavailable', - unlimitedLimitLabel: 'Not metered', loadingState: { text: 'Loading monitored system usage…', }, @@ -79,119 +69,43 @@ const MONITORED_SYSTEM_LEDGER_PRESENTATION = { 'At least one included source is offline or disconnected, so Pulse marks this monitored system as offline.', unknown: 'Pulse cannot determine a canonical runtime status for this monitored system yet.', }, - limitBanner: { - reviewPolicyLabel: 'Review continuity', - installCollectorsLabel: 'Install v6 collectors', - overflowSummaryPrefix: 'A temporary setup slot is active', - legacyConnectionSuffix: - 'that are folded into the canonical monitored-system ledger when the same top-level system is discovered canonically.', - }, - admissionPreview: { - requiredTitle: 'Preview monitored-system impact before saving', - requiredMessage: - 'Pulse must preview the monitored-system impact for this platform connection before it can be saved.', + impactPreview: { fallbackTitle: 'Monitored-system impact', - exceedsPolicyTitle: 'This change needs continuity review before saving', addsSystemsTitle: 'This change adds monitored systems', removesSystemsTitle: 'This change removes monitored systems', unchangedTitle: 'This change keeps monitored-system count unchanged', unavailableTitle: 'Monitored-system verification is temporarily unavailable', unavailableFallbackMessage: - 'Pulse cannot verify monitored-system impact right now, so this connection cannot be saved yet. Retry preview in a moment.', + 'Pulse cannot verify monitored-system impact right now. You can still save the connection and review the impact after inventory refreshes.', unavailableUnsettledMessage: - 'Pulse is still settling provider-owned inventory for this platform connection, so the monitored-system check is not safe yet. Retry preview after the first baseline finishes.', + 'Pulse is still settling provider-owned inventory for this platform connection. You can still save the connection and review the impact after the first baseline finishes.', unavailableRebuildPendingMessage: - 'Pulse has settled provider-owned inventory and is rebuilding the canonical monitored-system view, so this connection cannot be saved yet. Retry preview in a moment.', - saveBlockedLimitMessage: 'This change needs monitored-system review before saving', - saveBlockedLoadingMessage: 'Wait for the monitored-system impact preview to finish', + 'Pulse has settled provider-owned inventory and is rebuilding the canonical monitored-system view. You can still save the connection and review the impact in a moment.', }, } as const; const MONITORED_SYSTEM_USAGE_UNAVAILABLE_ERROR_CODE = 'monitored_system_usage_unavailable'; -export type MonitoredSystemLegacyConnectionCounts = { - proxmox_nodes: number; - docker_hosts: number; - kubernetes_clusters: number; -}; - -export type MonitoredSystemLimitUsageStatus = { - current?: number | null; - limit?: number | null; - current_available?: boolean | null; - current_unavailable_reason?: string | null; - state?: string | null; -}; - -export type MonitoredSystemCapacityStatus = { - mode?: string | null; - urgency?: string | null; - current?: number | null; - limit?: number | null; - current_available?: boolean | null; - current_unavailable_reason?: string | null; - available_slots?: number | null; - overage?: number | null; - reason?: string | null; - blocks_new_systems?: boolean | null; - existing_monitoring_continues?: boolean | null; -}; - -type ResolvedMonitoredSystemCapacityStatus = { - mode: string; - urgency: string; - current: number; - limit: number; - current_available: boolean; - current_unavailable_reason?: string; - available_slots: number; - overage: number; - reason?: string; - blocks_new_systems: boolean; - existing_monitoring_continues: boolean; -}; - -export type MonitoredSystemAdmissionPreviewUnavailableState = { +export type MonitoredSystemImpactPreviewUnavailableState = { reason: string | null; title: string; message: string; }; -export type MonitoredSystemCapacitySectionModel = { - stats: Array<{ label: string; value: string }>; - statusMessage: string; - detailMessage?: string; - explanation?: { - label: string; - body: string; - }; -}; - -export type MonitoredSystemAdmissionPreviewSaveState = { - preview?: { would_exceed_limit?: boolean | null } | null; - unavailableState?: MonitoredSystemAdmissionPreviewUnavailableState | null; - error?: string | null; - loading?: boolean | null; -}; - -export type MonitoredSystemAdmissionPreviewTitleInput = { +export type MonitoredSystemImpactPreviewTitleInput = { current_count?: number | null; projected_count?: number | null; - would_exceed_limit?: boolean | null; }; -export type MonitoredSystemAdmissionPreviewSummaryInput = - MonitoredSystemAdmissionPreviewTitleInput & { - limit?: number | null; - }; +export type MonitoredSystemImpactPreviewSummaryInput = MonitoredSystemImpactPreviewTitleInput; -const normalizeAdmissionPreviewCount = (count: number | null | undefined): number => +const normalizeImpactPreviewCount = (count: number | null | undefined): number => typeof count === 'number' && Number.isFinite(count) ? Math.max(0, count) : 0; -const formatAdmissionPreviewCount = (count: number): string => +const formatImpactPreviewCount = (count: number): string => `${count} monitored ${count === 1 ? 'system' : 'systems'}`; -const formatAdmissionPreviewDelta = (delta: number): string => +const formatImpactPreviewDelta = (delta: number): string => delta > 0 ? `+${delta}` : `${delta}`; export function getMonitoredSystemLedgerPresentation() { @@ -251,340 +165,6 @@ export function formatMonitoredSystemLedgerUnavailableMessage(reason?: string | return formatMonitoredSystemUsageUnavailableMessage(reason); } -export function isMonitoredSystemLimitUsageAvailable( - limit?: MonitoredSystemLimitUsageStatus | null, -): boolean { - return limit?.current_available !== false; -} - -export function getMonitoredSystemLimitUnavailableReason( - limit?: MonitoredSystemLimitUsageStatus | null, - capacity?: MonitoredSystemCapacityStatus | null, -): string | undefined { - const resolved = resolveMonitoredSystemCapacityStatus(capacity, limit); - if (resolved?.current_available) return undefined; - return resolved?.current_unavailable_reason?.trim() || undefined; -} - -function deriveMonitoredSystemLimitUrgency(current: number, limit: number): string { - if (limit <= 0) return 'ok'; - if (current >= limit) return 'enforced'; - if (limit > 1 && limit <= 10) { - return current >= limit - 1 ? 'warning' : 'ok'; - } - return current * 10 >= limit * 9 ? 'warning' : 'ok'; -} - -function deriveMonitoredSystemCapacityStatus( - limit?: MonitoredSystemLimitUsageStatus | null, -): ResolvedMonitoredSystemCapacityStatus | undefined { - if (!limit) return undefined; - - const currentAvailable = isMonitoredSystemLimitUsageAvailable(limit); - const current = typeof limit.current === 'number' ? limit.current : 0; - const planLimit = typeof limit.limit === 'number' ? limit.limit : 0; - const urgency = - normalizeMonitoredSystemValue(limit.state ?? undefined) || - deriveMonitoredSystemLimitUrgency(current, planLimit); - - if (!currentAvailable) { - return { - mode: 'usage_unavailable', - urgency: 'ok', - current: 0, - limit: planLimit, - current_available: false, - current_unavailable_reason: limit.current_unavailable_reason?.trim() || undefined, - available_slots: 0, - overage: 0, - reason: undefined, - blocks_new_systems: false, - existing_monitoring_continues: false, - }; - } - - if (planLimit <= 0) { - return { - mode: 'unlimited', - urgency: 'ok', - current, - limit: 0, - current_available: true, - available_slots: 0, - overage: 0, - reason: undefined, - blocks_new_systems: false, - existing_monitoring_continues: true, - }; - } - - if (current > planLimit) { - return { - mode: 'over_limit_frozen', - urgency: 'enforced', - current, - limit: planLimit, - current_available: true, - available_slots: 0, - overage: current - planLimit, - reason: 'preexisting_usage', - blocks_new_systems: true, - existing_monitoring_continues: true, - }; - } - - if (current === planLimit) { - return { - mode: 'at_limit_blocking_new', - urgency: 'enforced', - current, - limit: planLimit, - current_available: true, - available_slots: 0, - overage: 0, - reason: 'limit_reached', - blocks_new_systems: true, - existing_monitoring_continues: true, - }; - } - - return { - mode: 'within_limit', - urgency, - current, - limit: planLimit, - current_available: true, - available_slots: planLimit - current, - overage: 0, - reason: undefined, - blocks_new_systems: false, - existing_monitoring_continues: true, - }; -} - -export function resolveMonitoredSystemCapacityStatus( - capacity?: MonitoredSystemCapacityStatus | null, - limit?: MonitoredSystemLimitUsageStatus | null, -): ResolvedMonitoredSystemCapacityStatus | undefined { - const fallback = deriveMonitoredSystemCapacityStatus(limit); - if (!capacity) { - return fallback; - } - - const current = - typeof capacity.current === 'number' ? capacity.current : (fallback?.current ?? 0); - const planLimit = typeof capacity.limit === 'number' ? capacity.limit : (fallback?.limit ?? 0); - const currentAvailable = - typeof capacity.current_available === 'boolean' - ? capacity.current_available - : (fallback?.current_available ?? true); - const mode = - normalizeMonitoredSystemValue(capacity.mode ?? undefined) || - fallback?.mode || - 'usage_unavailable'; - const urgency = - normalizeMonitoredSystemValue(capacity.urgency ?? undefined) || - fallback?.urgency || - deriveMonitoredSystemLimitUrgency(current, planLimit); - const reason = - normalizeMonitoredSystemValue(capacity.reason ?? undefined) || fallback?.reason || undefined; - - return { - mode, - urgency, - current, - limit: planLimit, - current_available: currentAvailable, - current_unavailable_reason: - capacity.current_unavailable_reason?.trim() || - fallback?.current_unavailable_reason || - undefined, - available_slots: - typeof capacity.available_slots === 'number' - ? capacity.available_slots - : (fallback?.available_slots ?? Math.max(planLimit - current, 0)), - overage: - typeof capacity.overage === 'number' - ? capacity.overage - : (fallback?.overage ?? Math.max(current - planLimit, 0)), - reason, - blocks_new_systems: - typeof capacity.blocks_new_systems === 'boolean' - ? capacity.blocks_new_systems - : (fallback?.blocks_new_systems ?? false), - existing_monitoring_continues: - typeof capacity.existing_monitoring_continues === 'boolean' - ? capacity.existing_monitoring_continues - : (fallback?.existing_monitoring_continues ?? currentAvailable), - }; -} - -function formatMonitoredSystemCount(value: number): string { - return `${value} monitored system${value === 1 ? '' : 's'}`; -} - -export function getMonitoredSystemLimitUsageSummary( - limit?: MonitoredSystemLimitUsageStatus | null, - capacity?: MonitoredSystemCapacityStatus | null, -): string { - const resolved = resolveMonitoredSystemCapacityStatus(capacity, limit); - if (!resolved || !resolved.current_available) { - return MONITORED_SYSTEM_LEDGER_PRESENTATION.usageVerifyingLabel; - } - return formatMonitoredSystemCount(resolved.current); -} - -export function getMonitoredSystemLimitCapacityStatusSummary( - limit?: MonitoredSystemLimitUsageStatus | null, - capacity?: MonitoredSystemCapacityStatus | null, -): string { - const resolved = resolveMonitoredSystemCapacityStatus(capacity, limit); - if (!resolved || !resolved.current_available) { - return MONITORED_SYSTEM_LEDGER_PRESENTATION.remainingCapacityUnavailableLabel; - } - - switch (resolved.mode) { - case 'unlimited': - return MONITORED_SYSTEM_LEDGER_PRESENTATION.unlimitedLimitLabel; - case 'over_limit_frozen': - return 'Continuity review'; - case 'at_limit_blocking_new': - return 'Continuity review'; - default: - return 'Healthy'; - } -} - -export function getMonitoredSystemLimitContextSummary( - limit?: MonitoredSystemLimitUsageStatus | null, - capacity?: MonitoredSystemCapacityStatus | null, -): string { - const resolved = resolveMonitoredSystemCapacityStatus(capacity, limit); - if (!resolved) { - return ''; - } - if (!resolved.current_available) { - return formatMonitoredSystemUsageUnavailableMessage(resolved.current_unavailable_reason); - } - - switch (resolved.mode) { - case 'unlimited': - return 'Self-hosted monitoring is included for this instance.'; - case 'over_limit_frozen': - if (resolved.reason === 'legacy_migration_capture_pending') { - return 'Pulse is verifying legacy v5 continuity for this installation. Existing monitoring remains visible while new top-level additions wait for verification to finish.'; - } - return 'Existing monitoring remains visible. New top-level additions are paused until this legacy continuity state is reviewed.'; - case 'at_limit_blocking_new': - return 'Existing monitoring remains visible. New top-level additions are paused until this legacy continuity state is reviewed.'; - default: - return ''; - } -} - -export function buildMonitoredSystemCapacitySectionModel( - limit?: MonitoredSystemLimitUsageStatus | null, - capacity?: MonitoredSystemCapacityStatus | null, -): MonitoredSystemCapacitySectionModel | null { - const resolved = resolveMonitoredSystemCapacityStatus(capacity, limit); - if (!resolved) { - return null; - } - if (resolved.limit <= 0) { - return null; - } - if ( - resolved.current_available && - resolved.mode !== 'over_limit_frozen' && - resolved.mode !== 'at_limit_blocking_new' - ) { - return null; - } - - const includedValue = - resolved.limit > 0 - ? String(resolved.limit) - : MONITORED_SYSTEM_LEDGER_PRESENTATION.remainingCapacityUnavailableLabel; - - const stats = [ - { - label: 'Monitored', - value: getMonitoredSystemLimitUsageSummary(limit, capacity), - }, - { - label: 'Baseline', - value: includedValue, - }, - { - label: 'Status', - value: getMonitoredSystemLimitCapacityStatusSummary(limit, capacity), - }, - ]; - - if (!resolved.current_available) { - return { - stats, - statusMessage: 'Pulse is verifying monitored-system usage for this installation.', - detailMessage: formatMonitoredSystemUsageUnavailableMessage( - resolved.current_unavailable_reason, - ), - }; - } - - switch (resolved.mode) { - case 'unlimited': - return { - stats, - statusMessage: 'Self-hosted monitoring is included for this instance.', - }; - case 'at_limit_blocking_new': - return { - stats, - statusMessage: 'Existing monitoring remains visible. New top-level additions need review.', - detailMessage: - 'Review the legacy continuity state before adding another top-level monitored system.', - }; - case 'over_limit_frozen': - if (resolved.reason === 'legacy_migration_capture_pending') { - return { - stats, - statusMessage: - 'Existing monitoring remains visible. New top-level additions wait for verification.', - detailMessage: 'Pulse is still verifying legacy v5 continuity for this installation.', - explanation: { - label: 'Why is continuity still pending?', - body: `Pulse is still verifying legacy v5 continuity for this installation. It has already identified ${resolved.current} monitored systems for continuity reporting, and existing monitoring remains visible while new top-level additions wait for verification to finish.`, - }, - }; - } - return { - stats, - statusMessage: 'Existing monitoring remains visible. New top-level additions need review.', - detailMessage: - 'Review the legacy continuity state before adding another top-level monitored system.', - explanation: { - label: 'Why does this need review?', - body: `Pulse has already identified ${resolved.current} monitored systems for this installation. Existing monitoring remains visible, but new top-level additions are paused until this legacy continuity state is reviewed.`, - }, - }; - default: - return { - stats, - statusMessage: 'Current monitored-system accounting is healthy.', - }; - } -} - -export function isMonitoredSystemLimitUrgent( - limit?: MonitoredSystemLimitUsageStatus | null, - capacity?: MonitoredSystemCapacityStatus | null, -): boolean { - const resolved = resolveMonitoredSystemCapacityStatus(capacity, limit); - if (!resolved?.current_available) return false; - const state = normalizeMonitoredSystemValue(resolved.urgency); - return state === 'warning' || state === 'enforced'; -} - export function getMonitoredSystemCountingDetailsToggleLabel(expanded: boolean): string { return expanded ? MONITORED_SYSTEM_LEDGER_PRESENTATION.countingDetailsExpandedLabel @@ -601,39 +181,20 @@ export function getMonitoredSystemStatusFallbackSummary( return MONITORED_SYSTEM_LEDGER_PRESENTATION.statusSummaryByStatus[status]; } -export function getMonitoredSystemLimitReviewPolicyLabel(): string { - return MONITORED_SYSTEM_LEDGER_PRESENTATION.limitBanner.reviewPolicyLabel; +export function getMonitoredSystemImpactPreviewUnavailableTitle(): string { + return MONITORED_SYSTEM_LEDGER_PRESENTATION.impactPreview.unavailableTitle; } -export function getMonitoredSystemLimitInstallCollectorsLabel(): string { - return MONITORED_SYSTEM_LEDGER_PRESENTATION.limitBanner.installCollectorsLabel; -} - -export function getMonitoredSystemAdmissionPreviewUnavailableTitle(): string { - return MONITORED_SYSTEM_LEDGER_PRESENTATION.admissionPreview.unavailableTitle; -} - -export function getMonitoredSystemAdmissionPreviewRequiredState(): { - title: string; - message: string; -} { - return { - title: MONITORED_SYSTEM_LEDGER_PRESENTATION.admissionPreview.requiredTitle, - message: MONITORED_SYSTEM_LEDGER_PRESENTATION.admissionPreview.requiredMessage, - }; -} - -export function getMonitoredSystemAdmissionPreviewTitle( - preview: MonitoredSystemAdmissionPreviewTitleInput | null | undefined, +export function getMonitoredSystemImpactPreviewTitle( + preview: MonitoredSystemImpactPreviewTitleInput | null | undefined, ): string { - const presentation = MONITORED_SYSTEM_LEDGER_PRESENTATION.admissionPreview; + const presentation = MONITORED_SYSTEM_LEDGER_PRESENTATION.impactPreview; if (!preview) return presentation.fallbackTitle; - if (preview.would_exceed_limit) return presentation.exceedsPolicyTitle; - const current = normalizeAdmissionPreviewCount(preview.current_count); + const current = normalizeImpactPreviewCount(preview.current_count); const projected = typeof preview.projected_count === 'number' && Number.isFinite(preview.projected_count) - ? normalizeAdmissionPreviewCount(preview.projected_count) + ? normalizeImpactPreviewCount(preview.projected_count) : current; const delta = projected - current; @@ -642,48 +203,43 @@ export function getMonitoredSystemAdmissionPreviewTitle( return presentation.unchangedTitle; } -export function formatMonitoredSystemAdmissionPreviewSummary( - preview: MonitoredSystemAdmissionPreviewSummaryInput, +export function formatMonitoredSystemImpactPreviewSummary( + preview: MonitoredSystemImpactPreviewSummaryInput, ): string { - const current = normalizeAdmissionPreviewCount(preview.current_count); + const current = normalizeImpactPreviewCount(preview.current_count); const projected = typeof preview.projected_count === 'number' && Number.isFinite(preview.projected_count) - ? normalizeAdmissionPreviewCount(preview.projected_count) + ? normalizeImpactPreviewCount(preview.projected_count) : current; - const limit = normalizeAdmissionPreviewCount(preview.limit); const delta = projected - current; - const policySuffix = - preview.would_exceed_limit && limit > 0 - ? `, above the current verified baseline of ${formatAdmissionPreviewCount(limit)}` - : ''; - const currentSummary = `Pulse currently counts ${formatAdmissionPreviewCount(current)}.`; + const currentSummary = `Pulse currently counts ${formatImpactPreviewCount(current)}.`; if (delta !== 0) { - return `${currentSummary} Saving this change would bring the count to ${formatAdmissionPreviewCount( + return `${currentSummary} Saving this change would bring the count to ${formatImpactPreviewCount( projected, - )} (${formatAdmissionPreviewDelta(delta)})${policySuffix}.`; + )} (${formatImpactPreviewDelta(delta)}).`; } - return `${currentSummary} Saving this change would keep the count at ${formatAdmissionPreviewCount( + return `${currentSummary} Saving this change would keep the count at ${formatImpactPreviewCount( projected, - )}${policySuffix}.`; + )}.`; } -export function formatMonitoredSystemAdmissionPreviewUnavailableMessage(reason?: string): string { +export function formatMonitoredSystemImpactPreviewUnavailableMessage(reason?: string): string { switch (normalizeMonitoredSystemValue(reason)) { case 'supplemental_inventory_unsettled': - return MONITORED_SYSTEM_LEDGER_PRESENTATION.admissionPreview.unavailableUnsettledMessage; + return MONITORED_SYSTEM_LEDGER_PRESENTATION.impactPreview.unavailableUnsettledMessage; case 'supplemental_inventory_rebuild_pending': - return MONITORED_SYSTEM_LEDGER_PRESENTATION.admissionPreview.unavailableRebuildPendingMessage; + return MONITORED_SYSTEM_LEDGER_PRESENTATION.impactPreview.unavailableRebuildPendingMessage; default: - return MONITORED_SYSTEM_LEDGER_PRESENTATION.admissionPreview.unavailableFallbackMessage; + return MONITORED_SYSTEM_LEDGER_PRESENTATION.impactPreview.unavailableFallbackMessage; } } -export function buildMonitoredSystemAdmissionPreviewUnavailableState(input: { +export function buildMonitoredSystemImpactPreviewUnavailableState(input: { code?: string | null; reason?: string | null; -}): MonitoredSystemAdmissionPreviewUnavailableState | null { +}): MonitoredSystemImpactPreviewUnavailableState | null { if (normalizeMonitoredSystemValue(input.code) !== MONITORED_SYSTEM_USAGE_UNAVAILABLE_ERROR_CODE) { return null; } @@ -691,120 +247,11 @@ export function buildMonitoredSystemAdmissionPreviewUnavailableState(input: { const reason = input.reason?.trim() || null; return { reason, - title: getMonitoredSystemAdmissionPreviewUnavailableTitle(), - message: formatMonitoredSystemAdmissionPreviewUnavailableMessage(reason ?? undefined), + title: getMonitoredSystemImpactPreviewUnavailableTitle(), + message: formatMonitoredSystemImpactPreviewUnavailableMessage(reason ?? undefined), }; } -export function isMonitoredSystemAdmissionPreviewResolvedSafely( - state: MonitoredSystemAdmissionPreviewSaveState, -): boolean { - return ( - !state.loading && - Boolean(state.preview) && - state.preview?.would_exceed_limit !== true && - !state.unavailableState && - !state.error?.trim() - ); -} - -export function getMonitoredSystemAdmissionPreviewSaveBlockedMessage( - state: MonitoredSystemAdmissionPreviewSaveState, -): string | null { - if (isMonitoredSystemAdmissionPreviewResolvedSafely(state)) { - return null; - } - - if (state.loading) { - return MONITORED_SYSTEM_LEDGER_PRESENTATION.admissionPreview.saveBlockedLoadingMessage; - } - if (state.unavailableState) { - return state.unavailableState.message; - } - if (state.preview?.would_exceed_limit) { - return MONITORED_SYSTEM_LEDGER_PRESENTATION.admissionPreview.saveBlockedLimitMessage; - } - - const error = state.error?.trim(); - if (error) { - return error; - } - - return MONITORED_SYSTEM_LEDGER_PRESENTATION.admissionPreview.requiredMessage; -} - -export function formatMonitoredSystemLimitSummary( - limit: { - current: number; - limit: number; - current_available?: boolean | null; - current_unavailable_reason?: string | null; - state?: string | null; - }, - capacity?: MonitoredSystemCapacityStatus | null, -): string { - const resolved = resolveMonitoredSystemCapacityStatus(capacity, limit); - if (!resolved || !resolved.current_available) { - return MONITORED_SYSTEM_LEDGER_PRESENTATION.usageVerifyingLabel; - } - - switch (resolved.mode) { - case 'over_limit_frozen': - if (resolved.reason === 'legacy_migration_capture_pending') { - return `Continuity verification pending. ${formatMonitoredSystemCount( - resolved.current, - )}.`; - } - return `Continuity review needed. ${formatMonitoredSystemCount(resolved.current)}.`; - case 'at_limit_blocking_new': - return `Continuity review needed. ${formatMonitoredSystemCount(resolved.current)}.`; - case 'unlimited': - return `${formatMonitoredSystemCount(resolved.current)}.`; - default: - return `${formatMonitoredSystemCount(resolved.current)}.`; - } -} - -export function formatMonitoredSystemLegacyConnectionBreakdown( - counts: MonitoredSystemLegacyConnectionCounts, -): string { - const parts: string[] = []; - - if (counts.proxmox_nodes > 0) { - parts.push(`${counts.proxmox_nodes} Proxmox ${counts.proxmox_nodes === 1 ? 'node' : 'nodes'}`); - } - if (counts.docker_hosts > 0) { - parts.push(`${counts.docker_hosts} Docker ${counts.docker_hosts === 1 ? 'host' : 'hosts'}`); - } - if (counts.kubernetes_clusters > 0) { - parts.push( - `${counts.kubernetes_clusters} Kubernetes ${ - counts.kubernetes_clusters === 1 ? 'cluster' : 'clusters' - }`, - ); - } - - return parts.join(', '); -} - -export function formatMonitoredSystemMigrationMessage( - counts: MonitoredSystemLegacyConnectionCounts, -): string { - const total = counts.proxmox_nodes + counts.docker_hosts + counts.kubernetes_clusters; - if (total <= 0) return ''; - - const noun = total === 1 ? 'resource' : 'resources'; - const breakdown = formatMonitoredSystemLegacyConnectionBreakdown(counts); - return `You also have ${total} ${noun} connected via API or legacy collectors${ - breakdown ? ` (${breakdown})` : '' - } ${MONITORED_SYSTEM_LEDGER_PRESENTATION.limitBanner.legacyConnectionSuffix}`; -} - -export function formatMonitoredSystemOverflowSummary(daysRemaining: number | undefined): string { - if (!daysRemaining) return ''; - return `${MONITORED_SYSTEM_LEDGER_PRESENTATION.limitBanner.overflowSummaryPrefix} (${daysRemaining}d remaining)`; -} - export function formatMonitoredSystemLatestIncludedSignalSentence(signal: { attribution: string; relative: string; diff --git a/frontend-modern/src/utils/pricingHandoff.ts b/frontend-modern/src/utils/pricingHandoff.ts index ac30b6c57..9bdb21be5 100644 --- a/frontend-modern/src/utils/pricingHandoff.ts +++ b/frontend-modern/src/utils/pricingHandoff.ts @@ -51,7 +51,6 @@ export const SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_HREF = `${SELF_HOSTED_PRO_BI const IN_PRODUCT_PRICING_DESTINATIONS: Record = { self_hosted_plan: SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_HREF, - max_monitored_systems: SELF_HOSTED_PRO_BILLING_PLAN_HREF, // Paid self-hosted feature keys: route to the owned billing plan page instead // of the Pulse Account purchase-start handoff, which fails for local instances // without PublicURL. diff --git a/internal/api/agent_ingest.go b/internal/api/agent_ingest.go index 6f6ba178a..c5a6047d2 100644 --- a/internal/api/agent_ingest.go +++ b/internal/api/agent_ingest.go @@ -83,16 +83,6 @@ func (h *UnifiedAgentHandlers) HandleReport(w http.ResponseWriter, r *http.Reque } tokenRecord := getAPITokenRecordFromRequest(r) - if enforceMonitoredSystemLimitForHostReport( - w, - r.Context(), - h.getMonitor(r.Context()), - report, - tokenRecord, - ) { - return - } - host, err := h.getMonitor(r.Context()).ApplyHostReport(report, tokenRecord) if err != nil { writeErrorResponse(w, http.StatusBadRequest, "invalid_report", err.Error(), nil) diff --git a/internal/api/billing_state_handlers_test.go b/internal/api/billing_state_handlers_test.go index 604daf4b1..b9c1754f5 100644 --- a/internal/api/billing_state_handlers_test.go +++ b/internal/api/billing_state_handlers_test.go @@ -195,8 +195,8 @@ func TestBillingStateGetHostedNonDefaultFallsBackToDefaultBillingState(t *testin if payload.PlanVersion != "msp_starter" { t.Fatalf("expected plan_version %q, got %q", "msp_starter", payload.PlanVersion) } - if !reflect.DeepEqual(payload.Limits, map[string]int64{"max_monitored_systems": 50}) { - t.Fatalf("expected limits to inherit default hosted billing state, got %v", payload.Limits) + if len(payload.Limits) != 0 { + t.Fatalf("expected retired monitored-system volume keys to be scrubbed from inherited hosted billing state, got %v", payload.Limits) } } diff --git a/internal/api/code_standards_test.go b/internal/api/code_standards_test.go index 4822ca570..864716bfa 100644 --- a/internal/api/code_standards_test.go +++ b/internal/api/code_standards_test.go @@ -202,14 +202,13 @@ func TestPaidDomainBoundaryAudit(t *testing.T) { // Use an exact allowlist to prevent new paid implementations from // hiding behind the enterprise_extension_ prefix. knownBinderFiles := map[string]bool{ - "enterprise_extension_ai_alert_analysis.go": true, - "enterprise_extension_ai_autofix.go": true, - "enterprise_extension_ai_investigation.go": true, - "enterprise_extension_audit_admin.go": true, - "enterprise_extension_monitored_system_admission.go": true, - "enterprise_extension_rbac_admin.go": true, - "enterprise_extension_reporting_admin.go": true, - "enterprise_extension_sso_admin.go": true, + "enterprise_extension_ai_alert_analysis.go": true, + "enterprise_extension_ai_autofix.go": true, + "enterprise_extension_ai_investigation.go": true, + "enterprise_extension_audit_admin.go": true, + "enterprise_extension_rbac_admin.go": true, + "enterprise_extension_reporting_admin.go": true, + "enterprise_extension_sso_admin.go": true, } // These reporting files are intentionally public shared surfaces rather than diff --git a/internal/api/config_handlers_add_test.go b/internal/api/config_handlers_add_test.go index 5996fbf7e..50988a265 100644 --- a/internal/api/config_handlers_add_test.go +++ b/internal/api/config_handlers_add_test.go @@ -252,7 +252,7 @@ func TestHandleAddNode_PBSTurnkeyTokenCreationUsesCanonicalPulseURL(t *testing.T } } -func TestHandleAddNode_BlocksNewCountedSystemAtLimit(t *testing.T) { +func TestHandleAddNode_AllowsNewCountedSystemWithCapsRetired(t *testing.T) { stubAutoRegisterNetworkDeps(t) setMaxMonitoredSystemsLicenseForTests(t, 1) @@ -307,8 +307,8 @@ func TestHandleAddNode_BlocksNewCountedSystemAtLimit(t *testing.T) { rec := httptest.NewRecorder() handler.HandleAddNode(rec, req) - if rec.Code != http.StatusPaymentRequired { - t.Fatalf("expected 402 once monitored-system cap is full, got %d: %s", rec.Code, rec.Body.String()) + if rec.Code != http.StatusCreated { + t.Fatalf("expected 201 with monitored-system caps retired, got %d: %s", rec.Code, rec.Body.String()) } } @@ -363,7 +363,7 @@ func TestHandleAddNode_AllowsCanonicalOverlapAtLimit(t *testing.T) { } } -func TestHandleAddNode_FailsClosedWhenUsageUnavailable(t *testing.T) { +func TestHandleAddNode_AllowsWhenUsageUnavailableWithCapsRetired(t *testing.T) { stubAutoRegisterNetworkDeps(t) setMaxMonitoredSystemsLicenseForTests(t, 1) @@ -386,8 +386,8 @@ func TestHandleAddNode_FailsClosedWhenUsageUnavailable(t *testing.T) { rec := httptest.NewRecorder() handler.HandleAddNode(rec, req) - if rec.Code != http.StatusServiceUnavailable { - t.Fatalf("expected 503 when monitored-system usage cannot be resolved, got %d: %s", rec.Code, rec.Body.String()) + if rec.Code != http.StatusCreated { + t.Fatalf("expected 201 when monitored-system usage is unavailable and caps are retired, got %d: %s", rec.Code, rec.Body.String()) } } diff --git a/internal/api/config_handlers_auto_register_test.go b/internal/api/config_handlers_auto_register_test.go index 288448ada..250e979ff 100644 --- a/internal/api/config_handlers_auto_register_test.go +++ b/internal/api/config_handlers_auto_register_test.go @@ -1014,7 +1014,7 @@ func TestHandleAutoRegisterSelectsReachableFallbackCandidateHost(t *testing.T) { } } -func TestHandleAutoRegister_BlocksNewCountedSystemAtLimit(t *testing.T) { +func TestHandleAutoRegister_AllowsNewCountedSystemWithCapsRetired(t *testing.T) { stubAutoRegisterNetworkDeps(t) setMaxMonitoredSystemsLicenseForTests(t, 1) @@ -1086,8 +1086,8 @@ func TestHandleAutoRegister_BlocksNewCountedSystemAtLimit(t *testing.T) { rec := httptest.NewRecorder() handler.HandleAutoRegister(rec, req) - if rec.Code != http.StatusPaymentRequired { - t.Fatalf("expected 402 once monitored-system cap is full, got %d: %s", rec.Code, rec.Body.String()) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200 with monitored-system caps retired, got %d: %s", rec.Code, rec.Body.String()) } } diff --git a/internal/api/config_node_handlers.go b/internal/api/config_node_handlers.go index af6797d2a..c532dedc5 100644 --- a/internal/api/config_node_handlers.go +++ b/internal/api/config_node_handlers.go @@ -12,7 +12,6 @@ import ( "github.com/rcourtman/pulse-go-rewrite/internal/config" "github.com/rcourtman/pulse-go-rewrite/internal/mock" - "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" "github.com/rcourtman/pulse-go-rewrite/internal/websocket" "github.com/rcourtman/pulse-go-rewrite/pkg/pbs" "github.com/rcourtman/pulse-go-rewrite/pkg/pmg" @@ -520,15 +519,6 @@ func (h *ConfigHandlers) handleAddNode(w http.ResponseWriter, r *http.Request) { pve.PhysicalDiskPollingMinutes = *req.PhysicalDiskPollingMinutes } - if enforceMonitoredSystemLimitForConfigRegistration(w, r.Context(), h.getConfig(r.Context()), h.getMonitor(r.Context()), unifiedresources.MonitoredSystemCandidate{ - Source: unifiedresources.SourceProxmox, - Type: unifiedresources.ResourceTypeAgent, - Name: displayName, - Hostname: pulseTokenHostCandidate(host), - HostURL: host, - }) { - return - } h.getConfig(r.Context()).PVEInstances = append(h.getConfig(r.Context()).PVEInstances, pve) h.normalizePVEConfigState(r.Context()) @@ -653,15 +643,6 @@ func (h *ConfigHandlers) handleAddNode(w http.ResponseWriter, r *http.Request) { MonitorGarbageJobs: monitorGarbageJobs, TemperatureMonitoringEnabled: req.TemperatureMonitoringEnabled, } - if enforceMonitoredSystemLimitForConfigRegistration(w, r.Context(), h.getConfig(r.Context()), h.getMonitor(r.Context()), unifiedresources.MonitoredSystemCandidate{ - Source: unifiedresources.SourcePBS, - Type: unifiedresources.ResourceTypePBS, - Name: pbsDisplayName, - Hostname: pulseTokenHostCandidate(host), - HostURL: host, - }) { - return - } h.getConfig(r.Context()).PBSInstances = append(h.getConfig(r.Context()).PBSInstances, pbs) } else if req.Type == "pmg" { host := normalizedHost @@ -744,15 +725,6 @@ func (h *ConfigHandlers) handleAddNode(w http.ResponseWriter, r *http.Request) { MonitorDomainStats: monitorDomainStats, TemperatureMonitoringEnabled: req.TemperatureMonitoringEnabled, } - if enforceMonitoredSystemLimitForConfigRegistration(w, r.Context(), h.getConfig(r.Context()), h.getMonitor(r.Context()), unifiedresources.MonitoredSystemCandidate{ - Source: unifiedresources.SourcePMG, - Type: unifiedresources.ResourceTypePMG, - Name: pmgDisplayName, - Hostname: pulseTokenHostCandidate(host), - HostURL: host, - }) { - return - } h.getConfig(r.Context()).PMGInstances = append(h.getConfig(r.Context()).PMGInstances, pmgInstance) } @@ -780,76 +752,6 @@ func (h *ConfigHandlers) handleAddNode(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]string{"status": "success"}) } -func proxmoxMonitoredSystemCandidate(instance config.PVEInstance) unifiedresources.MonitoredSystemCandidate { - return unifiedresources.MonitoredSystemCandidate{ - Source: unifiedresources.SourceProxmox, - Type: unifiedresources.ResourceTypeAgent, - Name: instance.Name, - Hostname: pulseTokenHostCandidate(instance.Host), - HostURL: instance.Host, - } -} - -func pbsMonitoredSystemCandidate(instance config.PBSInstance) unifiedresources.MonitoredSystemCandidate { - return unifiedresources.MonitoredSystemCandidate{ - Source: unifiedresources.SourcePBS, - Type: unifiedresources.ResourceTypePBS, - Name: instance.Name, - Hostname: pulseTokenHostCandidate(instance.Host), - HostURL: instance.Host, - } -} - -func pmgMonitoredSystemCandidate(instance config.PMGInstance) unifiedresources.MonitoredSystemCandidate { - return unifiedresources.MonitoredSystemCandidate{ - Source: unifiedresources.SourcePMG, - Type: unifiedresources.ResourceTypePMG, - Name: instance.Name, - Hostname: pulseTokenHostCandidate(instance.Host), - HostURL: instance.Host, - } -} - -func proxmoxMonitoredSystemReplacement(instance config.PVEInstance) unifiedresources.MonitoredSystemReplacement { - name := strings.TrimSpace(instance.Name) - host := strings.TrimSpace(instance.Host) - hostname := pulseTokenHostCandidate(instance.Host) - return unifiedresources.MonitoredSystemReplacement{ - Source: unifiedresources.SourceProxmox, - Selector: unifiedresources.MonitoredSystemReplacementSelector{ - Name: name, - Hostname: hostname, - HostURL: host, - }, - } -} - -func pbsMonitoredSystemReplacement(instance config.PBSInstance) unifiedresources.MonitoredSystemReplacement { - name := strings.TrimSpace(instance.Name) - host := strings.TrimSpace(instance.Host) - hostname := pulseTokenHostCandidate(instance.Host) - return unifiedresources.MonitoredSystemReplacement{ - Source: unifiedresources.SourcePBS, - Selector: unifiedresources.MonitoredSystemReplacementSelector{ - ResourceID: name, - Hostname: hostname, - HostURL: host, - }, - } -} - -func pmgMonitoredSystemReplacement(instance config.PMGInstance) unifiedresources.MonitoredSystemReplacement { - name := strings.TrimSpace(instance.Name) - hostname := pulseTokenHostCandidate(instance.Host) - return unifiedresources.MonitoredSystemReplacement{ - Source: unifiedresources.SourcePMG, - Selector: unifiedresources.MonitoredSystemReplacementSelector{ - ResourceID: name, - Hostname: hostname, - }, - } -} - // HandleTestConnection tests a node connection without saving func (h *ConfigHandlers) handleTestConnection(w http.ResponseWriter, r *http.Request) { @@ -1275,15 +1177,6 @@ func (h *ConfigHandlers) handleUpdateNode(w http.ResponseWriter, r *http.Request updated.Disabled = !*req.Enabled } - if enforceMonitoredSystemLimitForConfigReplacement( - w, - r.Context(), - h.getMonitor(r.Context()), - proxmoxMonitoredSystemReplacement(current), - proxmoxMonitoredSystemCandidate(updated), - ) { - return - } *pve = updated } else if nodeType == "pbs" && index < len(h.getConfig(r.Context()).PBSInstances) { pbs := &h.getConfig(r.Context()).PBSInstances[index] @@ -1382,15 +1275,6 @@ func (h *ConfigHandlers) handleUpdateNode(w http.ResponseWriter, r *http.Request updated.Disabled = !*req.Enabled } - if enforceMonitoredSystemLimitForConfigReplacement( - w, - r.Context(), - h.getMonitor(r.Context()), - pbsMonitoredSystemReplacement(current), - pbsMonitoredSystemCandidate(updated), - ) { - return - } *pbs = updated } else if nodeType == "pmg" && index < len(h.getConfig(r.Context()).PMGInstances) { pmgInst := &h.getConfig(r.Context()).PMGInstances[index] @@ -1473,15 +1357,6 @@ func (h *ConfigHandlers) handleUpdateNode(w http.ResponseWriter, r *http.Request updated.Disabled = !*req.Enabled } - if enforceMonitoredSystemLimitForConfigReplacement( - w, - r.Context(), - h.getMonitor(r.Context()), - pmgMonitoredSystemReplacement(current), - pmgMonitoredSystemCandidate(updated), - ) { - return - } *pmgInst = updated } else { http.Error(w, "Node not found", http.StatusNotFound) diff --git a/internal/api/config_setup_handlers.go b/internal/api/config_setup_handlers.go index 54ea711b1..68c677f8c 100644 --- a/internal/api/config_setup_handlers.go +++ b/internal/api/config_setup_handlers.go @@ -20,7 +20,6 @@ import ( "github.com/rcourtman/pulse-go-rewrite/internal/config" "github.com/rcourtman/pulse-go-rewrite/internal/system" - "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" "github.com/rcourtman/pulse-go-rewrite/internal/websocket" internalauth "github.com/rcourtman/pulse-go-rewrite/pkg/auth" "github.com/rs/zerolog/log" @@ -1273,15 +1272,6 @@ func (h *ConfigHandlers) handleCanonicalAutoRegister(w http.ResponseWriter, r *h http.Error(w, "agent token auth permits token updates for existing nodes only", http.StatusForbidden) return } - if enforceMonitoredSystemLimitForConfigRegistration(w, r.Context(), h.getConfig(r.Context()), h.getMonitor(r.Context()), unifiedresources.MonitoredSystemCandidate{ - Source: unifiedresources.SourceProxmox, - Type: unifiedresources.ResourceTypeAgent, - Name: serverName, - Hostname: pulseTokenHostCandidate(host), - HostURL: host, - }) { - return - } h.getConfig(r.Context()).PVEInstances = append(h.getConfig(r.Context()).PVEInstances, pveNode) h.normalizePVEConfigState(r.Context()) } @@ -1358,15 +1348,6 @@ func (h *ConfigHandlers) handleCanonicalAutoRegister(w http.ResponseWriter, r *h http.Error(w, "agent token auth permits token updates for existing nodes only", http.StatusForbidden) return } - if enforceMonitoredSystemLimitForConfigRegistration(w, r.Context(), h.getConfig(r.Context()), h.getMonitor(r.Context()), unifiedresources.MonitoredSystemCandidate{ - Source: unifiedresources.SourcePBS, - Type: unifiedresources.ResourceTypePBS, - Name: serverName, - Hostname: pulseTokenHostCandidate(host), - HostURL: host, - }) { - return - } h.getConfig(r.Context()).PBSInstances = append(h.getConfig(r.Context()).PBSInstances, pbsNode) } } diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index b0e043178..c413efdca 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -44,7 +44,6 @@ import ( agentshost "github.com/rcourtman/pulse-go-rewrite/pkg/agents/host" authpkg "github.com/rcourtman/pulse-go-rewrite/pkg/auth" "github.com/rcourtman/pulse-go-rewrite/pkg/cloudauth" - "github.com/rcourtman/pulse-go-rewrite/pkg/extensions" pkglicensing "github.com/rcourtman/pulse-go-rewrite/pkg/licensing" licensetestsupport "github.com/rcourtman/pulse-go-rewrite/pkg/licensing/testsupport" "github.com/rcourtman/pulse-go-rewrite/pkg/metrics" @@ -3690,7 +3689,6 @@ func TestContract_MonitoredSystemLedgerJSONSnapshot(t *testing.T) { }, }, Total: 1, - Limit: 5, } got, err := json.Marshal(payload) @@ -3744,21 +3742,18 @@ func TestContract_MonitoredSystemLedgerJSONSnapshot(t *testing.T) { } } ], - "total":1, - "limit":5 - }` + "total":1 + }` assertJSONSnapshot(t, got, want) } func TestContract_MonitoredSystemLedgerPreviewJSONSnapshot(t *testing.T) { payload := MonitoredSystemLedgerPreviewResponse{ - CurrentCount: 1, - ProjectedCount: 1, - AdditionalCount: 0, - Limit: 5, - WouldExceedLimit: false, - Effect: "attaches_existing", + CurrentCount: 1, + ProjectedCount: 1, + AdditionalCount: 0, + Effect: "attaches_existing", CurrentSystems: []MonitoredSystemLedgerEntry{ { Name: "Tower", @@ -3889,11 +3884,9 @@ func TestContract_MonitoredSystemLedgerPreviewJSONSnapshot(t *testing.T) { } const want = `{ - "current_count":1, - "projected_count":1, - "additional_count":0, - "limit":5, - "would_exceed_limit":false, + "current_count":1, + "projected_count":1, + "additional_count":0, "effect":"attaches_existing", "current_systems":[ { @@ -4073,7 +4066,6 @@ func TestContract_MonitoredSystemLedgerDoesNotEmitCompatibilityAliases(t *testin payload := MonitoredSystemLedgerResponse{ Systems: []MonitoredSystemLedgerEntry{entry}, Total: 1, - Limit: 5, } got, err := json.Marshal(payload) @@ -4104,10 +4096,9 @@ func TestContract_MonitoredSystemLedgerDoesNotEmitCompatibilityAliases(t *testin "surfaces":[] } } - ], - "total":1, - "limit":5 - }` + ], + "total":1 + }` assertJSONSnapshot(t, got, want) } @@ -5505,18 +5496,11 @@ func TestContract_HostedTenantEntitlementsFallbackToDefaultBillingState(t *testi if !sliceContainsString(payload.Capabilities, pkglicensing.FeatureRelay) { t.Fatalf("expected hosted tenant payload to include %q from default hosted billing state", pkglicensing.FeatureRelay) } - foundMonitoredSystemLimit := false for _, limit := range payload.Limits { if limit.Key == pkglicensing.MaxMonitoredSystemsLicenseGateKey { - foundMonitoredSystemLimit = true - if limit.Limit != 50 { - t.Fatalf("max_monitored_systems limit=%d, want 50", limit.Limit) - } + t.Fatalf("expected retired max_monitored_systems limit to be omitted, got %+v", payload.Limits) } } - if !foundMonitoredSystemLimit { - t.Fatalf("expected max_monitored_systems limit in payload, got %+v", payload.Limits) - } } func TestContract_HostedTenantEntitlementRefreshFallsBackToDefaultBillingState(t *testing.T) { @@ -5650,10 +5634,9 @@ func TestContract_HostedEntitlementVerifierBridgeUsesCompatibilityEnvAlias(t *te func TestContract_EntitlementPayloadMonitoredSystemUsageJSONSnapshot(t *testing.T) { payload := buildEntitlementPayloadWithUsage(&licenseStatus{ - Valid: true, - Tier: pkglicensing.TierPro, - Features: append([]string(nil), pkglicensing.TierFeatures[pkglicensing.TierPro]...), - MaxMonitoredSystems: 0, + Valid: true, + Tier: pkglicensing.TierPro, + Features: append([]string(nil), pkglicensing.TierFeatures[pkglicensing.TierPro]...), }, string(pkglicensing.SubStateActive), entitlementUsageSnapshot{ MonitoredSystems: 7, MonitoredSystemsAvailable: true, @@ -5681,10 +5664,9 @@ func TestContract_EntitlementPayloadMonitoredSystemUsageJSONSnapshot(t *testing. "days_remaining":0, "trial_eligible":false, "max_history_days":90, - "legacy_connections":{"proxmox_nodes":2,"docker_hosts":1,"kubernetes_clusters":1}, - "has_migration_gap":false, - "monitored_system_capacity":{"mode":"unlimited","urgency":"ok","current":7,"limit":0,"current_available":true,"available_slots":0,"overage":0,"blocks_new_systems":false,"existing_monitoring_continues":true} - }` + "legacy_connections":{"proxmox_nodes":2,"docker_hosts":1,"kubernetes_clusters":1}, + "has_migration_gap":false + }` assertJSONSnapshot(t, got, want) } @@ -5737,10 +5719,9 @@ func TestContract_SelfHostedCommunityEntitlementsJSONSnapshot(t *testing.T) { "trial_eligible":false, "max_history_days":7, "overflow_days_remaining":14, - "legacy_connections":{"proxmox_nodes":0,"docker_hosts":0,"kubernetes_clusters":0}, - "has_migration_gap":false, - "monitored_system_capacity":{"mode":"usage_unavailable","urgency":"ok","current":0,"limit":0,"current_available":false,"available_slots":0,"overage":0,"blocks_new_systems":false,"existing_monitoring_continues":false} - }` + "legacy_connections":{"proxmox_nodes":0,"docker_hosts":0,"kubernetes_clusters":0}, + "has_migration_gap":false + }` assertJSONSnapshot(t, got, want) } @@ -5776,27 +5757,20 @@ func TestContract_SelfHostedCommunityRuntimeCapabilitiesJSONSnapshot(t *testing. const want = `{ "capabilities":["update_alerts","sso","advanced_sso","ai_patrol"], - "limits":[], - "hosted_mode":false, - "max_history_days":7, - "monitored_system_capacity":{"mode":"usage_unavailable","urgency":"ok","current":0,"limit":0,"current_available":false,"available_slots":0,"overage":0,"blocks_new_systems":false,"existing_monitoring_continues":false} - }` + "limits":[], + "hosted_mode":false, + "max_history_days":7 + }` assertJSONSnapshot(t, got, want) } func TestContract_EntitlementPayloadMonitoredSystemUsageUnavailableJSONSnapshot(t *testing.T) { payload := buildEntitlementPayloadWithUsage(&licenseStatus{ - Valid: true, - Tier: pkglicensing.TierCloud, - PlanVersion: "cloud_starter", - Features: append([]string(nil), pkglicensing.TierFeatures[pkglicensing.TierPro]...), - MaxMonitoredSystems: 15, - MonitoredSystemContinuity: &pkglicensing.MonitoredSystemContinuityStatus{ - PlanLimit: 15, - EffectiveLimit: 15, - CapturePending: true, - }, + Valid: true, + Tier: pkglicensing.TierCloud, + PlanVersion: "cloud_starter", + Features: append([]string(nil), pkglicensing.TierFeatures[pkglicensing.TierPro]...), }, string(pkglicensing.SubStateActive), entitlementUsageSnapshot{ MonitoredSystemsUnavailableReason: "supplemental_inventory_unsettled", }, nil) @@ -5808,7 +5782,7 @@ func TestContract_EntitlementPayloadMonitoredSystemUsageUnavailableJSONSnapshot( const want = `{ "capabilities":["update_alerts","sso","advanced_sso","ai_patrol","relay","mobile_app","push_notifications","long_term_metrics","ai_alerts","ai_autofix","kubernetes_ai","agent_profiles","rbac","audit_logging","advanced_reporting"], - "limits":[{"key":"max_monitored_systems","limit":15,"current":0,"current_available":false,"current_unavailable_reason":"supplemental_inventory_unsettled","state":"ok"}], + "limits":[], "subscription_state":"active", "upgrade_reasons":[], "plan_version":"cloud_starter", @@ -5820,9 +5794,7 @@ 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, - "monitored_system_continuity":{"plan_limit":15,"effective_limit":15,"capture_pending":true}, - "monitored_system_capacity":{"mode":"usage_unavailable","urgency":"ok","current":0,"limit":15,"current_available":false,"current_unavailable_reason":"supplemental_inventory_unsettled","available_slots":0,"overage":0,"blocks_new_systems":false,"existing_monitoring_continues":false} + "has_migration_gap":false }` assertJSONSnapshot(t, got, want) @@ -5830,13 +5802,12 @@ func TestContract_EntitlementPayloadMonitoredSystemUsageUnavailableJSONSnapshot( func TestContract_EntitlementPayloadLifetimeJSONSnapshot(t *testing.T) { payload := buildEntitlementPayloadWithUsage(&licenseStatus{ - Valid: true, - Tier: pkglicensing.TierLifetime, - PlanVersion: "v5_lifetime_grandfathered", - IsLifetime: true, - Features: append([]string(nil), pkglicensing.TierFeatures[pkglicensing.TierLifetime]...), - MaxMonitoredSystems: 0, - MaxGuests: 0, + Valid: true, + Tier: pkglicensing.TierLifetime, + PlanVersion: "v5_lifetime_grandfathered", + IsLifetime: true, + Features: append([]string(nil), pkglicensing.TierFeatures[pkglicensing.TierLifetime]...), + MaxGuests: 0, }, string(pkglicensing.SubStateActive), entitlementUsageSnapshot{ MonitoredSystems: 15, MonitoredSystemsAvailable: true, @@ -5864,10 +5835,9 @@ func TestContract_EntitlementPayloadLifetimeJSONSnapshot(t *testing.T) { "days_remaining":0, "trial_eligible":false, "max_history_days":90, - "legacy_connections":{"proxmox_nodes":1,"docker_hosts":1,"kubernetes_clusters":0}, - "has_migration_gap":false, - "monitored_system_capacity":{"mode":"unlimited","urgency":"ok","current":15,"limit":0,"current_available":true,"available_slots":0,"overage":0,"blocks_new_systems":false,"existing_monitoring_continues":true} - }` + "legacy_connections":{"proxmox_nodes":1,"docker_hosts":1,"kubernetes_clusters":0}, + "has_migration_gap":false + }` assertJSONSnapshot(t, got, want) } @@ -5906,15 +5876,14 @@ func TestContract_LegacyMigrationFallbackStaysUncappedJSONSnapshot(t *testing.T) const expectedClientVersion = "6.0.0-rc.1" grantJWT, grantPublicKey, err := licensetestsupport.GenerateGrantJWTForTesting(pkglicensing.GrantClaims{ - LicenseID: "lic_contract_floor", - Tier: "pro", - PlanKey: "legacy_migration_fallback", - State: "active", - Features: []string{"relay"}, - MaxMonitoredSystems: 10, - IssuedAt: time.Now().Unix(), - ExpiresAt: time.Now().Add(72 * time.Hour).Unix(), - Email: "contract-floor@example.com", + LicenseID: "lic_contract_floor", + Tier: "pro", + PlanKey: "legacy_migration_fallback", + State: "active", + Features: []string{"relay"}, + IssuedAt: time.Now().Unix(), + ExpiresAt: time.Now().Add(72 * time.Hour).Unix(), + Email: "contract-floor@example.com", }) if err != nil { t.Fatalf("generate grant jwt: %v", err) @@ -5936,11 +5905,10 @@ func TestContract_LegacyMigrationFallbackStaysUncappedJSONSnapshot(t *testing.T) w.WriteHeader(http.StatusCreated) _ = json.NewEncoder(w).Encode(pkglicensing.ActivateInstallationResponse{ License: pkglicensing.ActivateResponseLicense{ - LicenseID: "lic_contract_floor", - State: "active", - Tier: "pro", - Features: []string{"relay"}, - MaxMonitoredSystems: 10, + LicenseID: "lic_contract_floor", + State: "active", + Tier: "pro", + Features: []string{"relay"}, }, Installation: pkglicensing.ActivateResponseInstallation{ InstallationID: "inst_contract_floor", @@ -6004,67 +5972,38 @@ func TestContract_LegacyMigrationFallbackStaysUncappedJSONSnapshot(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"` - MonitoredSystemContinuity *pkglicensing.MonitoredSystemContinuityStatus `json:"monitored_system_continuity,omitempty"` + Tier pkglicensing.Tier `json:"tier"` + PlanVersion string `json:"plan_version"` + Valid bool `json:"valid"` } `json:"status"` Entitlements struct { - 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"` - MonitoredSystemCapacity *pkglicensing.MonitoredSystemCapacityStatus `json:"monitored_system_capacity,omitempty"` + Tier string `json:"tier"` + PlanVersion string `json:"plan_version"` + SubscriptionState string `json:"subscription_state"` + Limits []pkglicensing.LimitStatus `json:"limits"` } `json:"entitlements"` }{ Status: struct { - 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 pkglicensing.Tier `json:"tier"` + PlanVersion string `json:"plan_version"` + Valid bool `json:"valid"` }{ - Tier: status.Tier, - PlanVersion: status.PlanVersion, - MaxMonitoredSystems: status.MaxMonitoredSystems, - Valid: status.Valid, - MonitoredSystemContinuity: statusContinuity, + Tier: status.Tier, + PlanVersion: status.PlanVersion, + Valid: status.Valid, }, Entitlements: struct { - 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"` - MonitoredSystemCapacity *pkglicensing.MonitoredSystemCapacityStatus `json:"monitored_system_capacity,omitempty"` + Tier string `json:"tier"` + PlanVersion string `json:"plan_version"` + SubscriptionState string `json:"subscription_state"` + Limits []pkglicensing.LimitStatus `json:"limits"` }{ - Tier: payload.Tier, - PlanVersion: payload.PlanVersion, - SubscriptionState: payload.SubscriptionState, - Limits: payload.Limits, - MonitoredSystemContinuity: payloadContinuity, - MonitoredSystemCapacity: payload.MonitoredSystemCapacity, + Tier: payload.Tier, + PlanVersion: payload.PlanVersion, + SubscriptionState: payload.SubscriptionState, + Limits: payload.Limits, }, }) if err != nil { @@ -6075,15 +6014,13 @@ func TestContract_LegacyMigrationFallbackStaysUncappedJSONSnapshot(t *testing.T) "status":{ "tier":"pro", "plan_version":"legacy_migration_fallback", - "max_monitored_systems":0, "valid":true }, "entitlements":{ "tier":"pro", "plan_version":"legacy_migration_fallback", "subscription_state":"active", - "limits":[], - "monitored_system_capacity":{"mode":"unlimited","urgency":"ok","current":23,"limit":0,"current_available":true,"available_slots":0,"overage":0,"blocks_new_systems":false,"existing_monitoring_continues":true} + "limits":[] } }` @@ -6118,7 +6055,7 @@ func TestContract_HostedBillingStateFallbackJSONSnapshot(t *testing.T) { const want = `{ "capabilities":["relay","rbac"], - "limits":{"max_monitored_systems":50}, + "limits":{}, "meters_enabled":[], "plan_version":"msp_starter", "subscription_state":"active", @@ -6218,72 +6155,13 @@ func TestContract_HostReportAdmissionPreservesRestartContinuityAtLimit(t *testin }, Timestamp: report.Timestamp.Add(time.Minute), } - blockedRec := postReport(t, restartedHandler, newHostReport) - if blockedRec.Code != http.StatusPaymentRequired { - t.Fatalf("new host should remain blocked at limit, got %d: %s", blockedRec.Code, blockedRec.Body.String()) - } - - payload := decodeMonitoredSystemLimitBlockedPayload(t, blockedRec.Body.Bytes()) - if payload.Feature != maxMonitoredSystemsLicenseGateKey { - t.Fatalf("feature=%q, want %q", payload.Feature, maxMonitoredSystemsLicenseGateKey) - } - if !payload.MonitoredSystemPreview.WouldExceedLimit { - t.Fatalf("expected monitored_system_preview.would_exceed_limit=true, got %+v", payload.MonitoredSystemPreview) + newHostRec := postReport(t, restartedHandler, newHostReport) + if newHostRec.Code != http.StatusOK { + t.Fatalf("new host should be admitted because monitored-system caps are retired, got %d: %s", newHostRec.Code, newHostRec.Body.String()) } } -func TestContract_ResolveMonitoredSystemAdmissionPolicyHookUsesCanonicalInput(t *testing.T) { - SetResolveMonitoredSystemAdmissionPolicy(nil) - t.Cleanup(func() { SetResolveMonitoredSystemAdmissionPolicy(nil) }) - - if hook := getResolveMonitoredSystemAdmissionPolicy(); hook != nil { - t.Fatalf("expected no monitored-system admission hook by default, got %v", hook) - } - - SetResolveMonitoredSystemAdmissionPolicy(func(_ context.Context, input extensions.MonitoredSystemAdmissionInput) extensions.MonitoredSystemAdmissionDecision { - return extensions.MonitoredSystemAdmissionDecision{ - Current: input.Current, - Additional: input.Additional, - Limit: input.Limit, - UsageAvailable: input.UsageAvailable, - UsageUnavailableReason: input.UsageUnavailableReason, - Exceeded: input.CandidateCountsTowardCap && - input.UsageAvailable && - input.Additional > 0 && - input.Limit > 0 && - input.Current+input.Additional > input.Limit, - } - }) - - hook := getResolveMonitoredSystemAdmissionPolicy() - if hook == nil { - t.Fatal("expected monitored-system admission hook to round-trip through the shared API boundary") - } - - decision := hook(context.Background(), extensions.MonitoredSystemAdmissionInput{ - Current: 5, - Additional: 1, - Limit: 5, - UsageAvailable: true, - CandidateCountsTowardCap: true, - }) - if !decision.Exceeded { - t.Fatalf("expected canonical counted-system input to preserve the exceeded verdict, got %+v", decision) - } - - decision = hook(context.Background(), extensions.MonitoredSystemAdmissionInput{ - Current: 5, - Additional: 1, - Limit: 5, - UsageAvailable: true, - CandidateCountsTowardCap: false, - }) - if decision.Exceeded { - t.Fatalf("expected non-counted candidate to stay outside the exceeded verdict, got %+v", decision) - } -} - -func TestContract_PlatformConnectionWritesFailClosedWhenUsageUnavailable(t *testing.T) { +func TestContract_PlatformConnectionWritesIgnoreUsageUnavailableWithCapsRetired(t *testing.T) { t.Run("truenas add", func(t *testing.T) { setTrueNASFeatureForTest(t, true) setMockModeForTrueNASTest(t, false) @@ -6306,11 +6184,9 @@ func TestContract_PlatformConnectionWritesFailClosedWhenUsageUnavailable(t *test rec := httptest.NewRecorder() handler.HandleAdd(rec, req) - assertMonitoredSystemUsageUnavailableReason( - t, - rec, - monitoring.MonitoredSystemUsageUnavailableSupplementalInventoryUnsettled, - ) + if rec.Code != http.StatusCreated { + t.Fatalf("expected 201 with monitored-system caps retired, got %d: %s", rec.Code, rec.Body.String()) + } }) t.Run("vmware add", func(t *testing.T) { @@ -6346,13 +6222,11 @@ func TestContract_PlatformConnectionWritesFailClosedWhenUsageUnavailable(t *test rec := httptest.NewRecorder() handler.HandleAdd(rec, req) - assertMonitoredSystemUsageUnavailableReason( - t, - rec, - monitoring.MonitoredSystemUsageUnavailableSupplementalInventoryUnsettled, - ) + if rec.Code != http.StatusCreated { + t.Fatalf("expected 201 with monitored-system caps retired, got %d: %s", rec.Code, rec.Body.String()) + } if previewRecordsCalled { - t.Fatal("expected VMware write admission to fail before external inventory preview") + t.Fatal("expected VMware write not to preview external inventory when monitored-system caps are retired") } }) } @@ -6634,143 +6508,6 @@ func TestContract_PlatformConnectionPreviewPreservesCanonicalEnabledDefaults(t * }) } -func TestContract_ConfiguredNodeReplacementsUseCanonicalSelectors(t *testing.T) { - testCases := []struct { - name string - current any - updated any - source unifiedresources.DataSource - currentRoot unifiedresources.Resource - replacement func(any) unifiedresources.MonitoredSystemReplacement - candidate func(any) unifiedresources.MonitoredSystemCandidate - }{ - { - name: "proxmox", - current: config.PVEInstance{ - Name: " pve-a ", - Host: " https://pve-a.lab.local:8006 ", - }, - updated: config.PVEInstance{ - Name: "pve-b", - Host: "https://pve-b.lab.local:8006", - }, - source: unifiedresources.SourceProxmox, - currentRoot: unifiedresources.Resource{ - ID: "pve-a", - Type: unifiedresources.ResourceTypeAgent, - Name: "pve-a", - Status: unifiedresources.StatusOnline, - Proxmox: &unifiedresources.ProxmoxData{ - Instance: "pve-a", - NodeName: "pve-a-node", - HostURL: "https://pve-a.lab.local:8006", - }, - }, - replacement: func(value any) unifiedresources.MonitoredSystemReplacement { - return proxmoxMonitoredSystemReplacement(value.(config.PVEInstance)) - }, - candidate: func(value any) unifiedresources.MonitoredSystemCandidate { - return proxmoxMonitoredSystemCandidate(value.(config.PVEInstance)) - }, - }, - { - name: "pbs", - current: config.PBSInstance{ - Name: " backup-a ", - Host: " https://backup-a.lab.local:8007 ", - }, - updated: config.PBSInstance{ - Name: "backup-b", - Host: "https://backup-b.lab.local:8007", - }, - source: unifiedresources.SourcePBS, - currentRoot: unifiedresources.Resource{ - ID: "pbs-a", - Type: unifiedresources.ResourceTypePBS, - Name: "backup-a", - Status: unifiedresources.StatusOnline, - PBS: &unifiedresources.PBSData{ - InstanceID: "backup-a", - Hostname: "backup-a.lab.local", - HostURL: "https://backup-a.lab.local:8007", - }, - }, - replacement: func(value any) unifiedresources.MonitoredSystemReplacement { - return pbsMonitoredSystemReplacement(value.(config.PBSInstance)) - }, - candidate: func(value any) unifiedresources.MonitoredSystemCandidate { - return pbsMonitoredSystemCandidate(value.(config.PBSInstance)) - }, - }, - { - name: "pmg", - current: config.PMGInstance{ - Name: " mail-a ", - Host: " https://mail-a.lab.local:8006 ", - }, - updated: config.PMGInstance{ - Name: "mail-b", - Host: "https://mail-b.lab.local:8006", - }, - source: unifiedresources.SourcePMG, - currentRoot: unifiedresources.Resource{ - ID: "pmg-a", - Type: unifiedresources.ResourceTypePMG, - Name: "mail-a", - Status: unifiedresources.StatusOnline, - PMG: &unifiedresources.PMGData{ - InstanceID: "mail-a", - Hostname: "mail-a.lab.local", - }, - }, - replacement: func(value any) unifiedresources.MonitoredSystemReplacement { - return pmgMonitoredSystemReplacement(value.(config.PMGInstance)) - }, - candidate: func(value any) unifiedresources.MonitoredSystemCandidate { - return pmgMonitoredSystemCandidate(value.(config.PMGInstance)) - }, - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - replacement := tc.replacement(tc.current) - if replacement.Source != tc.source { - t.Fatalf("replacement source = %q, want %q", replacement.Source, tc.source) - } - if replacement.Matches != nil { - t.Fatal("configured node replacement must use the canonical selector contract, not a handler-local matcher") - } - if !replacement.MatchesResource(tc.currentRoot) { - t.Fatalf("replacement selector did not match current %s root", tc.name) - } - - registry := unifiedresources.NewRegistry(nil) - registry.IngestRecords(tc.source, []unifiedresources.IngestRecord{ - { - SourceID: tc.currentRoot.ID, - Resource: tc.currentRoot, - }, - }) - - projection := unifiedresources.ProjectMonitoredSystemCandidateReplacement( - registry, - replacement, - tc.candidate(tc.updated), - ) - if projection.CurrentCount != 1 { - t.Fatalf("CurrentCount = %d, want 1", projection.CurrentCount) - } - if projection.ProjectedCount != 1 { - t.Fatalf("ProjectedCount = %d, want 1", projection.ProjectedCount) - } - if projection.AdditionalCount != 0 { - t.Fatalf("AdditionalCount = %d, want 0", projection.AdditionalCount) - } - }) - } -} - func TestContract_DemoModeCommercialSurfacePolicy(t *testing.T) { t.Run("hidden routes return not found", func(t *testing.T) { next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -12845,18 +12582,18 @@ func TestContract_BootstrapTokenValidationRateLimitsPerClient(t *testing.T) { } } -func TestContract_DeployCapacityDenialUsesWorkspaceCapacityCopy(t *testing.T) { +func TestContract_DeployHandlersDoNotSurfaceLicenseSlotCapacityCopy(t *testing.T) { source, err := os.ReadFile("deploy_handlers.go") if err != nil { t.Fatalf("read deploy handlers: %v", err) } text := string(source) - if !strings.Contains(text, `"No workspace capacity available for retry"`) { - t.Fatal("deploy retry capacity denial must use workspace-capacity copy") - } if strings.Contains(text, "No license slots available") { t.Fatal("deploy retry capacity denial must not surface legacy license-slot copy") } + if strings.Contains(text, "reservedLicenseSlots") { + t.Fatal("deploy response must not expose retired reservedLicenseSlots field") + } } func mustStreamEvent(t *testing.T, eventType string, data interface{}) chat.StreamEvent { diff --git a/internal/api/demo_mode_commercial.go b/internal/api/demo_mode_commercial.go index 1be969ab5..3df06bc90 100644 --- a/internal/api/demo_mode_commercial.go +++ b/internal/api/demo_mode_commercial.go @@ -124,7 +124,6 @@ func sanitizeRuntimeCapabilitiesPayloadForPublicDemo( ) RuntimeCapabilitiesPayload { sanitized := payload sanitized.Limits = sanitizeLimitStatusesForPublicDemo(payload.Limits) - sanitized.MonitoredSystemCapacity = nil if sanitized.Capabilities == nil { sanitized.Capabilities = []string{} } diff --git a/internal/api/demo_mode_commercial_test.go b/internal/api/demo_mode_commercial_test.go index d8099dc15..80c8e9d13 100644 --- a/internal/api/demo_mode_commercial_test.go +++ b/internal/api/demo_mode_commercial_test.go @@ -5,8 +5,6 @@ import ( "net/http/httptest" "strings" "testing" - - pkglicensing "github.com/rcourtman/pulse-go-rewrite/pkg/licensing" ) func TestPublicDemoCommercialRouteInventoryCoverage(t *testing.T) { @@ -45,7 +43,7 @@ func TestSanitizeRuntimeCapabilitiesPayloadForPublicDemo(t *testing.T) { Capabilities: []string{"relay", "ai_patrol"}, Limits: []LimitStatus{ { - Key: maxMonitoredSystemsLicenseGateKey, + Key: "max_guests", Limit: 5, Current: 16, State: "enforced", @@ -53,17 +51,6 @@ func TestSanitizeRuntimeCapabilitiesPayloadForPublicDemo(t *testing.T) { }, HostedMode: true, MaxHistoryDays: 90, - MonitoredSystemCapacity: &pkglicensing.MonitoredSystemCapacityStatus{ - Mode: "over_limit_frozen", - Urgency: "enforced", - Current: 16, - Limit: 5, - CurrentAvailable: true, - AvailableSlots: 0, - Overage: 11, - BlocksNewSystems: true, - ExistingMonitoringContinues: true, - }, }) if len(sanitized.Capabilities) != 2 { @@ -78,9 +65,6 @@ func TestSanitizeRuntimeCapabilitiesPayloadForPublicDemo(t *testing.T) { if sanitized.MaxHistoryDays != 90 || !sanitized.HostedMode { t.Fatalf("non-commercial runtime capability fields should be preserved, got max_history_days=%d hosted_mode=%v", sanitized.MaxHistoryDays, sanitized.HostedMode) } - if sanitized.MonitoredSystemCapacity != nil { - t.Fatalf("monitored_system_capacity=%+v, want nil after public demo sanitization", sanitized.MonitoredSystemCapacity) - } } func TestPublicDemoCommercialPolicyForRequestHidesUsagePreviewRoutes(t *testing.T) { diff --git a/internal/api/deploy_handlers.go b/internal/api/deploy_handlers.go index 4485336fd..e7f639eab 100644 --- a/internal/api/deploy_handlers.go +++ b/internal/api/deploy_handlers.go @@ -23,10 +23,9 @@ import ( // DeployHandlers provides HTTP handlers for cluster agent deployment. type DeployHandlers struct { - store *deploy.Store - monitor *monitoring.Monitor - execServer *agentexec.Server - reservation *deploy.ReservationManager + store *deploy.Store + monitor *monitoring.Monitor + execServer *agentexec.Server // resolvePublicURL derives the Pulse URL for agent reachability checks. resolvePublicURL func(req *http.Request) string @@ -51,7 +50,6 @@ func NewDeployHandlers( store *deploy.Store, monitor *monitoring.Monitor, execServer *agentexec.Server, - reservation *deploy.ReservationManager, resolvePublicURL func(req *http.Request) string, cfg *config.Config, persistence *config.ConfigPersistence, @@ -60,7 +58,6 @@ func NewDeployHandlers( store: store, monitor: monitor, execServer: execServer, - reservation: reservation, resolvePublicURL: resolvePublicURL, config: cfg, persistence: persistence, @@ -968,11 +965,10 @@ type createJobSkip struct { } type createJobResponse struct { - JobID string `json:"jobId"` - AcceptedTargets []string `json:"acceptedTargets"` - SkippedTargets []createJobSkip `json:"skippedTargets"` - ReservedLicenseSlots int `json:"reservedLicenseSlots"` - EventsURL string `json:"eventsUrl"` + JobID string `json:"jobId"` + AcceptedTargets []string `json:"acceptedTargets"` + SkippedTargets []createJobSkip `json:"skippedTargets"` + EventsURL string `json:"eventsUrl"` } // HandleCreateJob creates a deploy install job from preflight results. @@ -1094,30 +1090,6 @@ func (h *DeployHandlers) HandleCreateJob(w http.ResponseWriter, r *http.Request) acceptedPfTargets = append(acceptedPfTargets, pfTgt) } - // Workspace capacity check. - maxLimit := maxMonitoredSystemsLimitForContext(ctx) - if maxLimit > 0 { - decision := monitoredSystemLimitDecisionForAdditionalSlots(ctx, h.monitor, 0) - if !decision.usageAvailable { - writeMonitoredSystemUsageUnavailable(w, decision.usageUnavailableReason) - return - } - available := decision.limit - decision.current - if available < 0 { - available = 0 - } - if available < len(acceptedPfTargets) { - // Accept only what fits; skip the rest. - for i := available; i < len(acceptedPfTargets); i++ { - skipped = append(skipped, createJobSkip{ - NodeID: acceptedPfTargets[i].NodeID, - Reason: "skipped_license", - }) - } - acceptedPfTargets = acceptedPfTargets[:available] - } - } - if len(acceptedPfTargets) == 0 { writeErrorResponse(w, http.StatusConflict, "no_eligible_targets", "No targets are eligible for deployment", nil) @@ -1226,12 +1198,6 @@ func (h *DeployHandlers) HandleCreateJob(w http.ResponseWriter, r *http.Request) return } - // Reserve workspace capacity based on actual dispatched target count. - if err := h.reservation.Reserve(jobID, orgID, len(installTargets), 1*time.Hour); err != nil { - log.Error().Err(err).Str("job_id", jobID).Msg("Failed to reserve workspace capacity") - // Non-fatal — continue. The reservation is for proactive capacity tracking. - } - // Transition to running. _ = h.store.UpdateJobStatus(ctx, jobID, deploy.JobRunning) @@ -1261,7 +1227,6 @@ func (h *DeployHandlers) HandleCreateJob(w http.ResponseWriter, r *http.Request) if err := h.execServer.SendDeployInstall(ctx, req.SourceAgentID, payload); err != nil { h.execServer.UnsubscribeDeployProgress(req.SourceAgentID, jobID) _ = h.store.UpdateJobStatus(ctx, jobID, deploy.JobFailed) - h.reservation.Release(jobID) // Mark pending targets as failed so they're eligible for retry. for _, it := range installTargets { _ = h.store.UpdateTargetStatus(ctx, it.TargetID, deploy.TargetFailedRetryable, "dispatch failed") @@ -1276,11 +1241,10 @@ func (h *DeployHandlers) HandleCreateJob(w http.ResponseWriter, r *http.Request) go h.processInstallProgress(jobID, req.SourceAgentID, job.RetryMax, progressCh) resp := createJobResponse{ - JobID: jobID, - AcceptedTargets: acceptedNodeIDs, - SkippedTargets: skipped, - ReservedLicenseSlots: len(installTargets), - EventsURL: fmt.Sprintf("/api/agent-deploy/jobs/%s/events", jobID), + JobID: jobID, + AcceptedTargets: acceptedNodeIDs, + SkippedTargets: skipped, + EventsURL: fmt.Sprintf("/api/agent-deploy/jobs/%s/events", jobID), } w.Header().Set("Content-Type", "application/json") @@ -1594,28 +1558,6 @@ func (h *DeployHandlers) HandleRetryJob(w http.ResponseWriter, r *http.Request) return } - // Workspace capacity re-check. - maxLimit := maxMonitoredSystemsLimitForContext(ctx) - if maxLimit > 0 { - decision := monitoredSystemLimitDecisionForAdditionalSlots(ctx, h.monitor, 0) - if !decision.usageAvailable { - writeMonitoredSystemUsageUnavailable(w, decision.usageUnavailableReason) - return - } - available := decision.limit - decision.current - if available < 0 { - available = 0 - } - if available < len(retryTargets) { - retryTargets = retryTargets[:available] - } - if len(retryTargets) == 0 { - writeErrorResponse(w, http.StatusConflict, "license_limit", - "No workspace capacity available for retry", nil) - return - } - } - // Reset targets to pending. retryIDs := make([]string, len(retryTargets)) for i, t := range retryTargets { @@ -1644,7 +1586,6 @@ func (h *DeployHandlers) HandleRetryJob(w http.ResponseWriter, r *http.Request) for _, id := range retryIDs { _ = h.store.UpdateTargetStatus(ctx, id, deploy.TargetFailedRetryable, "dispatch failed: no Pulse URL") } - h.reservation.Release(jobID + "-retry") writeErrorResponse(w, http.StatusInternalServerError, "no_pulse_url", "Cannot determine Pulse URL for agent installation", nil) return @@ -1689,11 +1630,6 @@ func (h *DeployHandlers) HandleRetryJob(w http.ResponseWriter, r *http.Request) return } - // Reserve workspace capacity based on actual dispatch count (after token minting). - if err := h.reservation.Reserve(jobID+"-retry", orgID, len(installTargets), 1*time.Hour); err != nil { - log.Warn().Err(err).Str("job_id", jobID).Msg("Failed to reserve workspace capacity for retry") - } - // Append retry event. _ = h.store.AppendEvent(ctx, &deploy.Event{ ID: generateID("evt"), @@ -1718,7 +1654,6 @@ func (h *DeployHandlers) HandleRetryJob(w http.ResponseWriter, r *http.Request) if err := h.execServer.SendDeployInstall(ctx, job.SourceAgentID, payload); err != nil { h.execServer.UnsubscribeDeployProgress(job.SourceAgentID, jobID) _ = h.store.UpdateJobStatus(ctx, jobID, deploy.JobFailed) - h.reservation.Release(jobID + "-retry") // Mark retried targets back to failed so they can be retried again. for _, it := range installTargets { _ = h.store.UpdateTargetStatus(ctx, it.TargetID, deploy.TargetFailedRetryable, "dispatch failed") @@ -1796,10 +1731,6 @@ func (h *DeployHandlers) processInstallProgress(jobID, agentID string, retryMax h.broadcastSSE(jobID, finalEvt) } - // Release license reservation. - h.reservation.Release(jobID) - h.reservation.Release(jobID + "-retry") // in case of retry - // Close SSE channels. h.closeSSESub(jobID) return @@ -1808,8 +1739,6 @@ func (h *DeployHandlers) processInstallProgress(jobID, agentID string, retryMax // Channel closed without final — agent disconnected. _ = h.store.UpdateJobStatus(ctx, jobID, deploy.JobFailed) - h.reservation.Release(jobID) - h.reservation.Release(jobID + "-retry") finalEvt := &deploy.Event{ ID: generateID("evt"), @@ -1999,7 +1928,7 @@ func deriveInstallJobStatus(targets []deploy.Target) deploy.JobStatus { // enrolling = install completed, enrollment is async and expected to succeed succeeded++ case deploy.TargetFailedPermanent, deploy.TargetFailedRetryable, - deploy.TargetSkippedAgent, deploy.TargetSkippedLicense, deploy.TargetCanceled: + deploy.TargetSkippedAgent, deploy.TargetCanceled: failed++ // pending/installing — shouldn't happen at Final but treat as incomplete default: diff --git a/internal/api/deploy_handlers_test.go b/internal/api/deploy_handlers_test.go index c08d23c26..658717c41 100644 --- a/internal/api/deploy_handlers_test.go +++ b/internal/api/deploy_handlers_test.go @@ -41,13 +41,12 @@ func newTestDeployHandlers(t *testing.T, nodes []models.Node, hosts []models.Hos } execServer := agentexec.NewServer(func(string, string, string) bool { return true }) - reservation := deploy.NewReservationManager() cfg := &config.Config{ DataPath: t.TempDir(), } - return NewDeployHandlers(store, monitor, execServer, reservation, func(_ *http.Request) string { + return NewDeployHandlers(store, monitor, execServer, func(_ *http.Request) string { return "http://10.0.0.1:7655" }, cfg, nil) } @@ -1055,9 +1054,6 @@ func TestHandleCreateJob_Success(t *testing.T) { if len(resp.SkippedTargets) != 0 { t.Fatalf("expected 0 skipped targets, got %d", len(resp.SkippedTargets)) } - if resp.ReservedLicenseSlots != 2 { - t.Fatalf("expected 2 reserved workspace capacity slots, got %d", resp.ReservedLicenseSlots) - } if resp.EventsURL == "" { t.Fatal("expected non-empty eventsUrl") } @@ -1179,7 +1175,7 @@ func TestHandleCreateJob_TargetsNotReady(t *testing.T) { } } -func TestHandleCreateJob_TruncatesTargetsToAvailableLicenseSlots(t *testing.T) { +func TestHandleCreateJob_AcceptsAllTargetsWithMonitoredSystemCapsRetired(t *testing.T) { setMaxMonitoredSystemsLicenseForTests(t, 4) nodes := []models.Node{ @@ -1238,20 +1234,14 @@ func TestHandleCreateJob_TruncatesTargetsToAvailableLicenseSlots(t *testing.T) { if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("decode: %v", err) } - if len(resp.AcceptedTargets) != 1 { - t.Fatalf("expected 1 accepted target, got %d", len(resp.AcceptedTargets)) + if len(resp.AcceptedTargets) != 2 { + t.Fatalf("expected 2 accepted targets with monitored-system caps retired, got %d", len(resp.AcceptedTargets)) } - if resp.AcceptedTargets[0] != "node_pve-b" { - t.Fatalf("expected deterministic accepted node_pve-b, got %q", resp.AcceptedTargets[0]) + if resp.AcceptedTargets[0] != "node_pve-b" || resp.AcceptedTargets[1] != "node_pve-c" { + t.Fatalf("unexpected accepted targets: %+v", resp.AcceptedTargets) } - if resp.ReservedLicenseSlots != 1 { - t.Fatalf("expected 1 reserved workspace capacity slot, got %d", resp.ReservedLicenseSlots) - } - if len(resp.SkippedTargets) != 1 { - t.Fatalf("expected 1 skipped target, got %d", len(resp.SkippedTargets)) - } - if resp.SkippedTargets[0].NodeID != "node_pve-c" || resp.SkippedTargets[0].Reason != "skipped_license" { - t.Fatalf("unexpected skipped target: %+v", resp.SkippedTargets[0]) + if len(resp.SkippedTargets) != 0 { + t.Fatalf("expected no skipped targets with monitored-system caps retired, got %+v", resp.SkippedTargets) } } @@ -1424,7 +1414,7 @@ func TestHandleRetryJob_Success(t *testing.T) { } } -func TestHandleRetryJob_BlocksWhenNoLicenseSlotsAvailable(t *testing.T) { +func TestHandleRetryJob_AllowsRetryWithMonitoredSystemCapsRetired(t *testing.T) { setMaxMonitoredSystemsLicenseForTests(t, 1) h := newTestDeployHandlers(t, nil, []models.Host{{ID: "host-existing", Hostname: "existing"}}) @@ -1455,19 +1445,16 @@ func TestHandleRetryJob_BlocksWhenNoLicenseSlotsAvailable(t *testing.T) { rec := httptest.NewRecorder() h.HandleRetryJob(rec, req) - if rec.Code != http.StatusConflict { - t.Fatalf("expected 409, got %d: %s", rec.Code, rec.Body.String()) - } - if !strings.Contains(rec.Body.String(), "license_limit") { - t.Fatalf("expected license_limit response, got %s", rec.Body.String()) + if rec.Code != http.StatusAccepted { + t.Fatalf("expected 202 with monitored-system caps retired, got %d: %s", rec.Code, rec.Body.String()) } targets, err := h.store.GetTargetsForJob(ctx, "dep_retry_license") if err != nil { t.Fatalf("get targets: %v", err) } - if len(targets) != 1 || targets[0].Status != deploy.TargetFailedRetryable { - t.Fatalf("expected retry target to remain failed_retryable, got %+v", targets) + if len(targets) != 1 || targets[0].Status != deploy.TargetPending { + t.Fatalf("expected retry target to be queued, got %+v", targets) } } @@ -1630,9 +1617,6 @@ func TestProcessInstallProgress_AgentDisconnect(t *testing.T) { t.Fatalf("create job: %v", err) } - // Reserve so we can verify release. - _ = h.reservation.Reserve("dep_disc", "default", 1, 1*time.Hour) - // Close the channel immediately to simulate disconnect. ch := make(chan agentexec.DeployProgressPayload, 1) close(ch) diff --git a/internal/api/docker_agents.go b/internal/api/docker_agents.go index f371c8c4d..29146516a 100644 --- a/internal/api/docker_agents.go +++ b/internal/api/docker_agents.go @@ -106,16 +106,6 @@ func (h *DockerAgentHandlers) HandleReport(w http.ResponseWriter, r *http.Reques } tokenRecord := getAPITokenRecordFromRequest(r) - if enforceMonitoredSystemLimitForDockerReport( - w, - r.Context(), - h.getMonitor(r.Context()), - report, - tokenRecord, - ) { - return - } - host, err := h.getMonitor(r.Context()).ApplyDockerReport(report, tokenRecord) if err != nil { writeErrorResponse(w, http.StatusBadRequest, "invalid_report", err.Error(), nil) diff --git a/internal/api/docker_agents_additional_test.go b/internal/api/docker_agents_additional_test.go index 2b515c7d5..5905244f9 100644 --- a/internal/api/docker_agents_additional_test.go +++ b/internal/api/docker_agents_additional_test.go @@ -109,9 +109,9 @@ func TestDockerAgentHandlers_HandleReport(t *testing.T) { } } -// TestDockerAgentHandlers_HandleReport_BlocksNewMonitoredSystemAtLimit verifies -// that a new Docker host counts as a monitored system. -func TestDockerAgentHandlers_HandleReport_BlocksNewMonitoredSystemAtLimit(t *testing.T) { +// TestDockerAgentHandlers_HandleReport_AllowsNewMonitoredSystemWithCapsRetired +// verifies that a new Docker host is accepted without monitored-system caps. +func TestDockerAgentHandlers_HandleReport_AllowsNewMonitoredSystemWithCapsRetired(t *testing.T) { setMaxMonitoredSystemsLicenseForTests(t, 1) handler, monitor := newDockerAgentHandlers(t, nil) @@ -120,7 +120,7 @@ func TestDockerAgentHandlers_HandleReport_BlocksNewMonitoredSystemAtLimit(t *tes t.Fatalf("expected seeded host ID") } - // New Docker host should be blocked because it is a distinct monitored system. + // New Docker host should be accepted because monitored-system caps are retired. newReport := agentsdocker.Report{ Agent: agentsdocker.AgentInfo{ ID: "agent-2", @@ -142,8 +142,8 @@ func TestDockerAgentHandlers_HandleReport_BlocksNewMonitoredSystemAtLimit(t *tes newReq := httptest.NewRequest(http.MethodPost, "/api/agents/docker/report", bytes.NewReader(newBody)) newRec := httptest.NewRecorder() handler.HandleReport(newRec, newReq) - if newRec.Code != http.StatusPaymentRequired { - t.Fatalf("status = %d, want 402: %s", newRec.Code, newRec.Body.String()) + if newRec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200: %s", newRec.Code, newRec.Body.String()) } } diff --git a/internal/api/enterprise_extension_monitored_system_admission.go b/internal/api/enterprise_extension_monitored_system_admission.go deleted file mode 100644 index 74408c7b5..000000000 --- a/internal/api/enterprise_extension_monitored_system_admission.go +++ /dev/null @@ -1,26 +0,0 @@ -package api - -import ( - "sync" - - "github.com/rcourtman/pulse-go-rewrite/pkg/extensions" -) - -var ( - monitoredSystemAdmissionPolicyMu sync.RWMutex - resolveMonitoredSystemPolicyFunc extensions.ResolveMonitoredSystemAdmissionPolicyFunc -) - -// SetResolveMonitoredSystemAdmissionPolicy registers a private commercial -// policy hook for monitored-system admission decisions. -func SetResolveMonitoredSystemAdmissionPolicy(fn extensions.ResolveMonitoredSystemAdmissionPolicyFunc) { - monitoredSystemAdmissionPolicyMu.Lock() - defer monitoredSystemAdmissionPolicyMu.Unlock() - resolveMonitoredSystemPolicyFunc = fn -} - -func getResolveMonitoredSystemAdmissionPolicy() extensions.ResolveMonitoredSystemAdmissionPolicyFunc { - monitoredSystemAdmissionPolicyMu.RLock() - defer monitoredSystemAdmissionPolicyMu.RUnlock() - return resolveMonitoredSystemPolicyFunc -} diff --git a/internal/api/enterprise_extension_monitored_system_admission_test.go b/internal/api/enterprise_extension_monitored_system_admission_test.go deleted file mode 100644 index efdebad0d..000000000 --- a/internal/api/enterprise_extension_monitored_system_admission_test.go +++ /dev/null @@ -1,52 +0,0 @@ -package api - -import ( - "context" - "testing" - - "github.com/rcourtman/pulse-go-rewrite/pkg/extensions" -) - -func TestGetResolveMonitoredSystemAdmissionPolicy_DefaultNil(t *testing.T) { - SetResolveMonitoredSystemAdmissionPolicy(nil) - t.Cleanup(func() { SetResolveMonitoredSystemAdmissionPolicy(nil) }) - - if getResolveMonitoredSystemAdmissionPolicy() != nil { - t.Fatal("expected nil when no monitored-system admission policy hook is registered") - } -} - -func TestGetResolveMonitoredSystemAdmissionPolicy_RoundTripsHook(t *testing.T) { - SetResolveMonitoredSystemAdmissionPolicy(nil) - t.Cleanup(func() { SetResolveMonitoredSystemAdmissionPolicy(nil) }) - - SetResolveMonitoredSystemAdmissionPolicy(func(_ context.Context, input extensions.MonitoredSystemAdmissionInput) extensions.MonitoredSystemAdmissionDecision { - return extensions.MonitoredSystemAdmissionDecision{ - Current: input.Current, - Additional: input.Additional, - Limit: input.Limit, - UsageAvailable: input.UsageAvailable, - UsageUnavailableReason: input.UsageUnavailableReason, - Exceeded: input.UsageAvailable && input.Additional > 0 && input.Limit > 0 && input.Current+input.Additional > input.Limit, - } - }) - - hook := getResolveMonitoredSystemAdmissionPolicy() - if hook == nil { - t.Fatal("expected monitored-system admission policy hook to be returned") - } - - decision := hook(context.Background(), extensions.MonitoredSystemAdmissionInput{ - Current: 5, - Additional: 1, - Limit: 5, - UsageAvailable: true, - UsageUnavailableReason: "", - }) - if !decision.Exceeded { - t.Fatalf("expected hook result to preserve exceeded state, got %+v", decision) - } - if decision.Current != 5 || decision.Additional != 1 || decision.Limit != 5 { - t.Fatalf("expected hook result to preserve admission values, got %+v", decision) - } -} diff --git a/internal/api/entitlement_handlers_test.go b/internal/api/entitlement_handlers_test.go index 8ee5bff8c..cec175da7 100644 --- a/internal/api/entitlement_handlers_test.go +++ b/internal/api/entitlement_handlers_test.go @@ -28,10 +28,9 @@ func containsCapability(values []string, key string) bool { func TestBuildEntitlementPayload_ActiveLicense(t *testing.T) { status := &license.LicenseStatus{ - Valid: true, - Tier: license.TierPro, - Features: append([]string(nil), license.TierFeatures[license.TierPro]...), - MaxMonitoredSystems: 0, + Valid: true, + Tier: license.TierPro, + Features: append([]string(nil), license.TierFeatures[license.TierPro]...), } payload := buildEntitlementPayload(status, "") @@ -46,12 +45,6 @@ func TestBuildEntitlementPayload_ActiveLicense(t *testing.T) { if len(payload.Limits) != 0 { t.Fatalf("expected no max_monitored_systems limit in payload, got %+v", payload.Limits) } - if payload.MonitoredSystemCapacity == nil { - t.Fatal("expected monitored_system_capacity in payload") - } - if payload.MonitoredSystemCapacity.Mode != "usage_unavailable" { - t.Fatalf("expected usage_unavailable monitored-system capacity before inventory settles, got %+v", payload.MonitoredSystemCapacity) - } if len(payload.UpgradeReasons) != 0 { t.Fatalf("expected no upgrade reasons for pro tier, got %d", len(payload.UpgradeReasons)) } @@ -81,11 +74,10 @@ func TestBuildEntitlementPayload_FreeTier(t *testing.T) { func TestBuildEntitlementPayloadWithUsage_CurrentValues(t *testing.T) { status := &license.LicenseStatus{ - Valid: true, - Tier: license.TierPro, - Features: append([]string(nil), license.TierFeatures[license.TierPro]...), - MaxMonitoredSystems: 0, - MaxGuests: 100, + Valid: true, + Tier: license.TierPro, + Features: append([]string(nil), license.TierFeatures[license.TierPro]...), + MaxGuests: 100, } payload := buildEntitlementPayloadWithUsage(status, "", entitlementUsageSnapshot{ @@ -116,12 +108,6 @@ func TestBuildEntitlementPayloadWithUsage_CurrentValues(t *testing.T) { if agentLimit != nil { t.Fatalf("expected no max_monitored_systems limit for self-hosted pro, got %+v", agentLimit) } - if payload.MonitoredSystemCapacity == nil { - t.Fatal("expected monitored_system_capacity") - } - if payload.MonitoredSystemCapacity.Mode != "unlimited" || payload.MonitoredSystemCapacity.Current != 12 { - t.Fatalf("expected unlimited monitored-system capacity with current=12, got %+v", payload.MonitoredSystemCapacity) - } if payload.LegacyConnections.ProxmoxNodes != 2 { t.Fatalf("expected proxmox_nodes 2, got %d", payload.LegacyConnections.ProxmoxNodes) } @@ -132,22 +118,15 @@ func TestBuildEntitlementPayloadWithUsage_CurrentValues(t *testing.T) { func TestBuildEntitlementPayloadWithUsage_MonitoredSystemUsageUnavailable(t *testing.T) { status := &license.LicenseStatus{ - Valid: true, - Tier: license.TierPro, - Features: append([]string(nil), license.TierFeatures[license.TierPro]...), - MaxMonitoredSystems: 0, + Valid: true, + Tier: license.TierPro, + Features: append([]string(nil), license.TierFeatures[license.TierPro]...), } payload := buildEntitlementPayloadWithUsage(status, "", entitlementUsageSnapshot{}, nil) if len(payload.Limits) != 0 { t.Fatalf("expected no monitored-system limit, got %d", len(payload.Limits)) } - if payload.MonitoredSystemCapacity == nil { - t.Fatal("expected monitored_system_capacity") - } - if payload.MonitoredSystemCapacity.Mode != "usage_unavailable" { - t.Fatalf("expected usage_unavailable monitored-system capacity, got %+v", payload.MonitoredSystemCapacity) - } } func TestBuildEntitlementPayload_Expired(t *testing.T) { @@ -190,10 +169,9 @@ func TestBuildEntitlementPayload_NilCapabilities(t *testing.T) { func TestBuildCommercialPosturePayloadWithUsage_CurrentValues(t *testing.T) { status := &license.LicenseStatus{ - Valid: true, - Tier: license.TierFree, - Features: append([]string(nil), license.TierFeatures[license.TierFree]...), - MaxMonitoredSystems: 0, + Valid: true, + Tier: license.TierFree, + Features: append([]string(nil), license.TierFeatures[license.TierFree]...), } payload := buildCommercialPosturePayloadWithUsage(status, "", entitlementUsageSnapshot{ @@ -214,9 +192,6 @@ func TestBuildCommercialPosturePayloadWithUsage_CurrentValues(t *testing.T) { if len(payload.UpgradeReasons) == 0 { t.Fatal("expected upgrade reasons for free-tier commercial posture") } - if payload.MonitoredSystemCapacity == nil || payload.MonitoredSystemCapacity.Mode != "unlimited" { - t.Fatalf("expected unlimited monitored-system capacity in commercial posture, got %+v", payload.MonitoredSystemCapacity) - } if payload.LegacyConnections.ProxmoxNodes != 2 || payload.LegacyConnections.DockerHosts != 1 { t.Fatalf("expected legacy counts to be preserved, got %+v", payload.LegacyConnections) } @@ -415,19 +390,11 @@ func TestEntitlementHandler_HostedEvaluatorKeepsCloudLimitsWhenNoLicense(t *test t.Fatalf("expected capabilities to include %q, got %v", license.FeatureAIPatrol, payload.Capabilities) } - var maxMonitoredSystems *LimitStatus for i := range payload.Limits { if payload.Limits[i].Key == "max_monitored_systems" { - maxMonitoredSystems = &payload.Limits[i] - break + t.Fatalf("expected retired monitored-system limit to be omitted, got %v", payload.Limits) } } - if maxMonitoredSystems == nil { - t.Fatalf("expected max_monitored_systems limit in payload, got %v", payload.Limits) - } - if maxMonitoredSystems.Limit != 10 { - t.Fatalf("max_monitored_systems.limit=%d, want %d", maxMonitoredSystems.Limit, 10) - } // Parity: every advertised capability must be enforced by HasFeature. ctx := context.WithValue(context.Background(), OrgIDContextKey, orgID) @@ -589,12 +556,6 @@ func TestHandleRuntimeCapabilities_HostedCommunityEvaluatorStateStripsLegacyComm t.Fatalf("expected runtime capabilities to omit stale commercial caps, got %+v", payload.Limits) } } - if payload.MonitoredSystemCapacity == nil { - t.Fatal("expected monitored_system_capacity in runtime capabilities payload") - } - if payload.MonitoredSystemCapacity.Limit != 0 { - t.Fatalf("monitored_system_capacity.limit=%d, want %d", payload.MonitoredSystemCapacity.Limit, 0) - } } func TestEntitlementHandler_SelfHostedTrialEligibilityRetiredForFreshOrg(t *testing.T) { @@ -629,15 +590,6 @@ func TestEntitlementHandler_SelfHostedTrialEligibilityRetiredForFreshOrg(t *test t.Fatalf("expected no max_monitored_systems limit in payload, got %+v", payload.Limits) } } - if payload.MonitoredSystemCapacity == nil { - t.Fatal("expected monitored_system_capacity in payload") - } - if payload.MonitoredSystemCapacity.Limit != 0 || payload.MonitoredSystemCapacity.BlocksNewSystems { - t.Fatalf( - "expected uncapped monitored_system_capacity for fresh community org, got %+v", - payload.MonitoredSystemCapacity, - ) - } } func TestEntitlementHandler_OverflowOnlyBillingStateReportsActiveCommunity(t *testing.T) { @@ -674,9 +626,6 @@ func TestEntitlementHandler_OverflowOnlyBillingStateReportsActiveCommunity(t *te if payload.TrialEligible { t.Fatalf("trial_eligible=%v, want false", payload.TrialEligible) } - if payload.MonitoredSystemCapacity == nil || payload.MonitoredSystemCapacity.Limit != 0 || payload.MonitoredSystemCapacity.BlocksNewSystems { - t.Fatalf("expected uncapped monitored_system_capacity, got %+v", payload.MonitoredSystemCapacity) - } } func TestEntitlementHandler_DevModeDoesNotExposeSyntheticFeatureGateCapabilities(t *testing.T) { diff --git a/internal/api/kubernetes_agents.go b/internal/api/kubernetes_agents.go index c33477069..738b50008 100644 --- a/internal/api/kubernetes_agents.go +++ b/internal/api/kubernetes_agents.go @@ -63,16 +63,6 @@ func (h *KubernetesAgentHandlers) HandleReport(w http.ResponseWriter, r *http.Re } tokenRecord := getAPITokenRecordFromRequest(r) - if enforceMonitoredSystemLimitForKubernetesReport( - w, - r.Context(), - h.getMonitor(r.Context()), - report, - tokenRecord, - ) { - return - } - cluster, err := h.getMonitor(r.Context()).ApplyKubernetesReport(report, tokenRecord) if err != nil { writeErrorResponse(w, http.StatusBadRequest, "invalid_report", err.Error(), nil) diff --git a/internal/api/kubernetes_agents_additional_test.go b/internal/api/kubernetes_agents_additional_test.go index 924a8ffbc..1c3bb4eb7 100644 --- a/internal/api/kubernetes_agents_additional_test.go +++ b/internal/api/kubernetes_agents_additional_test.go @@ -116,9 +116,9 @@ func TestKubernetesAgentHandlers_HandleReport(t *testing.T) { } } -// TestKubernetesAgentHandlers_HandleReport_BlocksNewMonitoredSystemAtLimit -// verifies that a new Kubernetes cluster counts as a monitored system. -func TestKubernetesAgentHandlers_HandleReport_BlocksNewMonitoredSystemAtLimit(t *testing.T) { +// TestKubernetesAgentHandlers_HandleReport_AllowsNewMonitoredSystemWithCapsRetired +// verifies that a new Kubernetes cluster is accepted without monitored-system caps. +func TestKubernetesAgentHandlers_HandleReport_AllowsNewMonitoredSystemWithCapsRetired(t *testing.T) { setMaxMonitoredSystemsLicenseForTests(t, 1) handler, monitor := newKubernetesAgentHandlers(t, nil) @@ -127,7 +127,7 @@ func TestKubernetesAgentHandlers_HandleReport_BlocksNewMonitoredSystemAtLimit(t t.Fatalf("expected seeded cluster ID") } - // New cluster should be blocked because it is a distinct monitored system. + // New cluster should be accepted because monitored-system caps are retired. newReport := agentsk8s.Report{ Agent: agentsk8s.AgentInfo{ ID: "agent-2", @@ -145,8 +145,8 @@ func TestKubernetesAgentHandlers_HandleReport_BlocksNewMonitoredSystemAtLimit(t newReq := httptest.NewRequest(http.MethodPost, "/api/agents/kubernetes/report", bytes.NewReader(newBody)) newRec := httptest.NewRecorder() handler.HandleReport(newRec, newReq) - if newRec.Code != http.StatusPaymentRequired { - t.Fatalf("status = %d, want 402: %s", newRec.Code, newRec.Body.String()) + if newRec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200: %s", newRec.Code, newRec.Body.String()) } } diff --git a/internal/api/legacy_grandfather_reconcile.go b/internal/api/legacy_grandfather_reconcile.go deleted file mode 100644 index be5983b4a..000000000 --- a/internal/api/legacy_grandfather_reconcile.go +++ /dev/null @@ -1,234 +0,0 @@ -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) bindLegacyGrandfatherReconcileOwnership(orgID string, service *licenseService) { - if h == nil || service == nil { - return - } - - orgID = normalizeHostedEntitlementOrgID(orgID) - service.SetActivationStateChangeCallback(func(*activationStateModel) { - h.syncLegacyGrandfatherReconcileOwnership(orgID, service) - }) -} - -func (h *LicenseHandlers) syncLegacyGrandfatherReconcileOwnership(orgID string, service *licenseService) { - if h == nil { - return - } - - orgID = normalizeHostedEntitlementOrgID(orgID) - if service == nil || !service.NeedsLegacyMonitoredSystemCapture() { - h.requestLegacyGrandfatherReconcileLoopStop(orgID) - return - } - - h.ensureLegacyGrandfatherReconcileLoop(orgID, service) -} - -func (h *LicenseHandlers) ensureLegacyGrandfatherReconcileLoop(orgID string, service *licenseService) { - if h == nil || service == nil { - return - } - orgID = normalizeHostedEntitlementOrgID(orgID) - if !service.NeedsLegacyMonitoredSystemCapture() { - h.requestLegacyGrandfatherReconcileLoopStop(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) requestLegacyGrandfatherReconcileLoopStop(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.mu.Unlock() - - if cancel != nil { - cancel() - } -} - -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/license_handlers_test.go b/internal/api/license_handlers_test.go index 0648e92ad..7f1c6550f 100644 --- a/internal/api/license_handlers_test.go +++ b/internal/api/license_handlers_test.go @@ -66,14 +66,13 @@ func issueCheckoutActivationGrant(t *testing.T) string { t.Helper() grantJWT, grantPublicKey, err := licensetestsupport.GenerateGrantJWTForTesting(pkglicensing.GrantClaims{ - LicenseID: "lic_checkout_success", - Tier: "pro_plus", - State: "active", - Features: []string{"relay", "ai_alerts"}, - MaxMonitoredSystems: 50, - IssuedAt: time.Now().Unix(), - ExpiresAt: time.Now().Add(72 * time.Hour).Unix(), - Email: "buyer@example.com", + LicenseID: "lic_checkout_success", + Tier: "pro_plus", + State: "active", + Features: []string{"relay", "ai_alerts"}, + IssuedAt: time.Now().Unix(), + ExpiresAt: time.Now().Add(72 * time.Hour).Unix(), + Email: "buyer@example.com", }) if err != nil { t.Fatalf("generate grant jwt: %v", err) @@ -318,9 +317,6 @@ func TestHandleLicenseStatus_ExpiredBillingBackedTrialFallsBackToFreeDisplay(t * if resp.Tier != license.TierFree { t.Fatalf("expected tier %q, got %q", license.TierFree, resp.Tier) } - if resp.MaxMonitoredSystems != license.TierMonitoredSystemLimits[license.TierFree] { - t.Fatalf("expected max_monitored_systems %d, got %d", license.TierMonitoredSystemLimits[license.TierFree], resp.MaxMonitoredSystems) - } if resp.MaxGuests != 0 { t.Fatalf("expected max_guests 0, got %d", resp.MaxGuests) } @@ -405,15 +401,14 @@ func TestHandleActivateLicense_ExchangesLegacyJWTInStrictV6(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { grantJWT, grantPublicKey, err := licensetestsupport.GenerateGrantJWTForTesting(pkglicensing.GrantClaims{ - LicenseID: "lic_exchanged", - Tier: "pro", - PlanKey: tc.planKey, - State: "active", - Features: []string{"relay"}, - MaxMonitoredSystems: 10, - IssuedAt: time.Now().Unix(), - ExpiresAt: time.Now().Add(72 * time.Hour).Unix(), - Email: "legacy-jwt@example.com", + LicenseID: "lic_exchanged", + Tier: "pro", + PlanKey: tc.planKey, + State: "active", + Features: []string{"relay"}, + IssuedAt: time.Now().Unix(), + ExpiresAt: time.Now().Add(72 * time.Hour).Unix(), + Email: "legacy-jwt@example.com", }) if err != nil { t.Fatalf("generate grant jwt: %v", err) @@ -504,14 +499,13 @@ func TestHandleActivateLicense_ClearsCommercialMigrationStateOnNativeActivation( t.Setenv("PULSE_LICENSE_DEV_MODE", "false") grantJWT, grantPublicKey, err := licensetestsupport.GenerateGrantJWTForTesting(pkglicensing.GrantClaims{ - LicenseID: "lic_v6_native", - Tier: "pro", - State: "active", - Features: []string{"relay"}, - MaxMonitoredSystems: 10, - IssuedAt: time.Now().Unix(), - ExpiresAt: time.Now().Add(72 * time.Hour).Unix(), - Email: "native-v6@example.com", + LicenseID: "lic_v6_native", + Tier: "pro", + State: "active", + Features: []string{"relay"}, + IssuedAt: time.Now().Unix(), + ExpiresAt: time.Now().Add(72 * time.Hour).Unix(), + Email: "native-v6@example.com", }) if err != nil { t.Fatalf("generate grant jwt: %v", err) @@ -590,14 +584,13 @@ func TestHandleActivateLicense_ActivationKeyClearsStaleLegacyPersistence(t *test t.Setenv("PULSE_LICENSE_DEV_MODE", "false") grantJWT, grantPublicKey, err := licensetestsupport.GenerateGrantJWTForTesting(pkglicensing.GrantClaims{ - LicenseID: "lic_v6_native", - Tier: "pro", - State: "active", - Features: []string{"relay"}, - MaxMonitoredSystems: 10, - IssuedAt: time.Now().Unix(), - ExpiresAt: time.Now().Add(72 * time.Hour).Unix(), - Email: "native-v6@example.com", + LicenseID: "lic_v6_native", + Tier: "pro", + State: "active", + Features: []string{"relay"}, + IssuedAt: time.Now().Unix(), + ExpiresAt: time.Now().Add(72 * time.Hour).Unix(), + Email: "native-v6@example.com", }) if err != nil { t.Fatalf("generate grant jwt: %v", err) diff --git a/internal/api/license_limits_test_helpers_test.go b/internal/api/license_limits_test_helpers_test.go index 8af415fe5..aac997855 100644 --- a/internal/api/license_limits_test_helpers_test.go +++ b/internal/api/license_limits_test_helpers_test.go @@ -22,6 +22,7 @@ func (p *staticLicenseProvider) Service(context.Context) *pkglicensing.Service { func setMaxMonitoredSystemsLicenseForTests(t *testing.T, maxMonitoredSystems int) { t.Helper() + _ = maxMonitoredSystems testLicenseProviderMu.Lock() @@ -33,11 +34,9 @@ func setMaxMonitoredSystemsLicenseForTests(t *testing.T, maxMonitoredSystems int t.Fatalf("failed to generate test license: %v", err) } - lic, err := service.Activate(licenseKey) - if err != nil { + if _, err := service.Activate(licenseKey); err != nil { t.Fatalf("failed to activate test license: %v", err) } - lic.Claims.MaxMonitoredSystems = maxMonitoredSystems SetLicenseServiceProvider(&staticLicenseProvider{service: service}) t.Cleanup(func() { diff --git a/internal/api/licensing_bridge.go b/internal/api/licensing_bridge.go index 4328ba8c6..58cfae748 100644 --- a/internal/api/licensing_bridge.go +++ b/internal/api/licensing_bridge.go @@ -5,10 +5,6 @@ import ( "net/http" "time" - "github.com/rcourtman/pulse-go-rewrite/internal/models" - agentsdocker "github.com/rcourtman/pulse-go-rewrite/pkg/agents/docker" - agentshost "github.com/rcourtman/pulse-go-rewrite/pkg/agents/host" - agentsk8s "github.com/rcourtman/pulse-go-rewrite/pkg/agents/kubernetes" pkglicensing "github.com/rcourtman/pulse-go-rewrite/pkg/licensing" ) @@ -52,23 +48,22 @@ type checkoutPortalHandoffRequestModel = pkglicensing.CheckoutPortalHandoffReque type checkoutPortalHandoffResponseModel = pkglicensing.CheckoutPortalHandoffResponse const ( - featureMultiTenantKey = pkglicensing.FeatureMultiTenant - featureAgentProfilesValue = pkglicensing.FeatureAgentProfiles - featureAIPatrolValue = pkglicensing.FeatureAIPatrol - featureAIAutoFixValue = pkglicensing.FeatureAIAutoFix - featureAuditLoggingValue = pkglicensing.FeatureAuditLogging - featureRBACValue = pkglicensing.FeatureRBAC - featureAdvancedReportingValue = pkglicensing.FeatureAdvancedReporting - featureLongTermMetricsValue = pkglicensing.FeatureLongTermMetrics - featureDemoFixturesValue = pkglicensing.FeatureDemoFixtures - maxMonitoredSystemsLicenseGateKey = pkglicensing.MaxMonitoredSystemsLicenseGateKey - maxUsersLicenseGateKey = pkglicensing.MaxUsersLicenseGateKey - subscriptionStateActiveValue = pkglicensing.SubStateActive - subscriptionStateExpiredValue = pkglicensing.SubStateExpired - subscriptionStateGraceValue = pkglicensing.SubStateGrace - subscriptionStateCanceledValue = pkglicensing.SubStateCanceled - subscriptionStateTrialValue = pkglicensing.SubStateTrial - activationKeyPrefixValue = pkglicensing.ActivationKeyPrefix + featureMultiTenantKey = pkglicensing.FeatureMultiTenant + featureAgentProfilesValue = pkglicensing.FeatureAgentProfiles + featureAIPatrolValue = pkglicensing.FeatureAIPatrol + featureAIAutoFixValue = pkglicensing.FeatureAIAutoFix + featureAuditLoggingValue = pkglicensing.FeatureAuditLogging + featureRBACValue = pkglicensing.FeatureRBAC + featureAdvancedReportingValue = pkglicensing.FeatureAdvancedReporting + featureLongTermMetricsValue = pkglicensing.FeatureLongTermMetrics + featureDemoFixturesValue = pkglicensing.FeatureDemoFixtures + maxUsersLicenseGateKey = pkglicensing.MaxUsersLicenseGateKey + subscriptionStateActiveValue = pkglicensing.SubStateActive + subscriptionStateExpiredValue = pkglicensing.SubStateExpired + subscriptionStateGraceValue = pkglicensing.SubStateGrace + subscriptionStateCanceledValue = pkglicensing.SubStateCanceled + subscriptionStateTrialValue = pkglicensing.SubStateTrial + activationKeyPrefixValue = pkglicensing.ActivationKeyPrefix ) func newLicenseService() *licenseService { @@ -153,30 +148,6 @@ func userLimitExceededMessageFromLicensing(current, limit int) string { return pkglicensing.UserLimitExceededMessage(current, limit) } -func monitoredSystemLimitExceededMessageFromLicensing(current, limit int) string { - return pkglicensing.MonitoredSystemLimitExceededMessage(current, limit) -} - -func hostReportTargetsExistingHostFromLicensing(snapshot models.StateSnapshot, report agentshost.Report, tokenID string) bool { - return pkglicensing.HostReportTargetsExistingHost(snapshot, report, tokenID) -} - -func dockerReportTargetsExistingHostFromLicensing(snapshot models.StateSnapshot, report agentsdocker.Report, tokenID string) bool { - return pkglicensing.DockerReportTargetsExistingHost(snapshot, report, tokenID) -} - -func kubernetesReportTargetsExistingClusterFromLicensing(snapshot models.StateSnapshot, report agentsk8s.Report, tokenID string) bool { - return pkglicensing.KubernetesReportTargetsExistingCluster(snapshot, report, tokenID) -} - -func kubernetesReportIdentifierFromLicensing(report agentsk8s.Report) string { - return pkglicensing.KubernetesReportIdentifier(report) -} - -func hostReportTargetsExistingHostsFromLicensing(hosts []models.Host, report agentshost.Report, tokenID string) bool { - return pkglicensing.HostReportTargetsExistingHosts(hosts, report, tokenID) -} - func mapStripeSubscriptionStatusToStateFromLicensing(status string) subscriptionState { return pkglicensing.MapStripeSubscriptionStatusToState(status) } @@ -189,10 +160,6 @@ func deriveStripePlanVersionFromLicensing(metadata map[string]string, priceID st return pkglicensing.DeriveStripePlanVersion(metadata, priceID) } -func limitsForCloudPlanFromLicensing(planVersion string) (map[string]int64, bool) { - return pkglicensing.LimitsForCloudPlan(planVersion) -} - func buildEntitlementPayloadFromLicensing(status *licenseStatus, subscriptionState string) entitlementPayloadModel { return pkglicensing.BuildEntitlementPayload(status, subscriptionState) } diff --git a/internal/api/licensing_handlers.go b/internal/api/licensing_handlers.go index 37981e44f..91ae8b4bf 100644 --- a/internal/api/licensing_handlers.go +++ b/internal/api/licensing_handlers.go @@ -54,16 +54,15 @@ func wantsMockFixturesFromEnv() bool { // LicenseHandlers handles license management API endpoints. type LicenseHandlers struct { - mtPersistence *config.MultiTenantPersistence - hostedMode bool - cfg *config.Config - services sync.Map // map[string]*licenseService - purchaseReturnRedemptions *purchaseReturnRedemptionStore - monitor *monitoring.Monitor - mtMonitor *monitoring.MultiTenantMonitor - hostedLeaseRefresh sync.Map // map[string]*hostedEntitlementRefreshLoop - legacyGrandfatherReconcile sync.Map // map[string]*legacyGrandfatherReconcileLoop - runtimeVersion string + mtPersistence *config.MultiTenantPersistence + hostedMode bool + cfg *config.Config + services sync.Map // map[string]*licenseService + purchaseReturnRedemptions *purchaseReturnRedemptionStore + monitor *monitoring.Monitor + mtMonitor *monitoring.MultiTenantMonitor + hostedLeaseRefresh sync.Map // map[string]*hostedEntitlementRefreshLoop + runtimeVersion string } // NewLicenseHandlers creates a new license handlers instance. @@ -122,12 +121,6 @@ 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() @@ -524,7 +517,6 @@ func (h *LicenseHandlers) getTenantComponents(ctx context.Context) (*licenseServ service := newLicenseService() service.SetClientVersion(h.runtimeVersion) - h.bindLegacyGrandfatherReconcileOwnership(orgID, service) // Wire license server client and persistence so activation / refresh can use them. lsClient := newLicenseServerClientFromLicensing("") @@ -546,7 +538,6 @@ 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.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 activation restore") } @@ -576,7 +567,6 @@ 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.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") } @@ -601,17 +591,12 @@ 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) - // Re-home legacy continuity ownership onto the canonical service when - // concurrent first-request initialization races create an orphan. - h.stopLegacyGrandfatherReconcileLoop(orgID) - h.syncLegacyGrandfatherReconcileOwnership(orgID, svc) h.ensureHostedEntitlementRefreshForOrg(orgID, svc) p, pErr := h.getPersistenceForOrg(orgID) h.syncReleaseDemoFixtureRuntime(orgID, svc) return svc, p, pErr } - h.syncLegacyGrandfatherReconcileOwnership(orgID, service) h.ensureHostedEntitlementRefreshForOrg(orgID, service) h.syncReleaseDemoFixtureRuntime(orgID, service) @@ -955,9 +940,6 @@ func (h *LicenseHandlers) activateLicenseKey(ctx context.Context, licenseKey str } if service.IsActivated() { - if migratedLegacyKey { - h.reconcileLegacyMigrationGrandfatherFloor(ctx, orgID, service) - } h.stopHostedEntitlementRefreshLoop(orgID) if clearErr := h.setCommercialMigrationState(orgID, nil); clearErr != nil { log.Warn().Err(clearErr).Str("org_id", orgID).Msg("Failed to clear commercial migration state after activation") @@ -1432,7 +1414,6 @@ 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 41e2b879e..ea70912c7 100644 --- a/internal/api/licensing_handlers_auto_migrate_test.go +++ b/internal/api/licensing_handlers_auto_migrate_test.go @@ -48,14 +48,13 @@ func TestGetTenantComponents_AutoExchangesPersistedLegacyJWT(t *testing.T) { t.Setenv("PULSE_LICENSE_DEV_MODE", "false") grantJWT, grantPublicKey, err := licensetestsupport.GenerateGrantJWTForTesting(pkglicensing.GrantClaims{ - LicenseID: "lic_migrated", - Tier: "pro", - State: "active", - Features: []string{"relay"}, - MaxMonitoredSystems: 10, - IssuedAt: time.Now().Unix(), - ExpiresAt: time.Now().Add(72 * time.Hour).Unix(), - Email: "user@example.com", + LicenseID: "lic_migrated", + Tier: "pro", + State: "active", + Features: []string{"relay"}, + IssuedAt: time.Now().Unix(), + ExpiresAt: time.Now().Add(72 * time.Hour).Unix(), + Email: "user@example.com", }) if err != nil { t.Fatalf("generate grant jwt: %v", err) @@ -81,11 +80,10 @@ func TestGetTenantComponents_AutoExchangesPersistedLegacyJWT(t *testing.T) { w.WriteHeader(http.StatusCreated) _ = json.NewEncoder(w).Encode(pkglicensing.ActivateInstallationResponse{ License: pkglicensing.ActivateResponseLicense{ - LicenseID: "lic_migrated", - State: "active", - Tier: "pro", - Features: []string{"relay"}, - MaxMonitoredSystems: 10, + LicenseID: "lic_migrated", + State: "active", + Tier: "pro", + Features: []string{"relay"}, }, Installation: pkglicensing.ActivateResponseInstallation{ InstallationID: "inst_migrated", @@ -327,15 +325,14 @@ func TestGetTenantComponents_AutoExchangeLeavesSelfHostedMonitoringUncapped(t *t t.Setenv("PULSE_LICENSE_DEV_MODE", "false") grantJWT, grantPublicKey, err := licensetestsupport.GenerateGrantJWTForTesting(pkglicensing.GrantClaims{ - LicenseID: "lic_floor_auto", - Tier: "pro", - PlanKey: "legacy_migration_fallback", - State: "active", - Features: []string{"relay"}, - MaxMonitoredSystems: 10, - IssuedAt: time.Now().Unix(), - ExpiresAt: time.Now().Add(72 * time.Hour).Unix(), - Email: "floor-auto@example.com", + LicenseID: "lic_floor_auto", + Tier: "pro", + PlanKey: "legacy_migration_fallback", + State: "active", + Features: []string{"relay"}, + IssuedAt: time.Now().Unix(), + ExpiresAt: time.Now().Add(72 * time.Hour).Unix(), + Email: "floor-auto@example.com", }) if err != nil { t.Fatalf("generate grant jwt: %v", err) @@ -350,11 +347,10 @@ func TestGetTenantComponents_AutoExchangeLeavesSelfHostedMonitoringUncapped(t *t w.WriteHeader(http.StatusCreated) _ = json.NewEncoder(w).Encode(pkglicensing.ActivateInstallationResponse{ License: pkglicensing.ActivateResponseLicense{ - LicenseID: "lic_floor_auto", - State: "active", - Tier: "pro", - Features: []string{"relay"}, - MaxMonitoredSystems: 10, + LicenseID: "lic_floor_auto", + State: "active", + Tier: "pro", + Features: []string{"relay"}, }, Installation: pkglicensing.ActivateResponseInstallation{ InstallationID: "inst_floor_auto", @@ -400,9 +396,6 @@ func TestGetTenantComponents_AutoExchangeLeavesSelfHostedMonitoringUncapped(t *t if !svc.IsActivated() { t.Fatal("expected persisted legacy JWT to auto-exchange into activation state") } - if got := svc.Status().MaxMonitoredSystems; got != 0 { - t.Fatalf("status.MaxMonitoredSystems=%d, want 0 for uncapped self-hosted continuity", got) - } entReq := httptest.NewRequest(http.MethodGet, "/api/license/entitlements", nil).WithContext(ctx) entRec := httptest.NewRecorder() @@ -446,14 +439,10 @@ func TestGetTenantComponents_AutoExchangeLeavesSelfHostedMonitoringUncapped(t *t t.Fatalf("migrated v5 Pro entitlements missing capability %q in %v", capability, payload.Capabilities) } } - if payload.MonitoredSystemCapacity == nil { - t.Fatal("expected monitored-system capacity payload") - } - if payload.MonitoredSystemCapacity.Mode != "unlimited" || payload.MonitoredSystemCapacity.Limit != 0 { - t.Fatalf( - "expected uncapped monitored-system capacity for migrated v5 Pro, got %+v", - payload.MonitoredSystemCapacity, - ) + for _, limit := range payload.Limits { + if limit.Key == pkglicensing.MaxMonitoredSystemsLicenseGateKey { + t.Fatalf("migrated v5 Pro entitlements still expose retired monitored-system limit: %+v", payload.Limits) + } } if activationState, err := persistence.LoadActivationState(); err != nil { t.Fatalf("load activation state: %v", err) @@ -463,12 +452,6 @@ func TestGetTenantComponents_AutoExchangeLeavesSelfHostedMonitoringUncapped(t *t if !activationState.Continuity.LegacyMigration { t.Fatal("expected legacy migration continuity flag") } - if activationState.Continuity.GrandfatheredMaxMonitoredSystems != 0 { - t.Fatalf("GrandfatheredMaxMonitoredSystems=%d, want 0 for uncapped self-hosted migration", activationState.Continuity.GrandfatheredMaxMonitoredSystems) - } - if activationState.Continuity.GrandfatheredMonitoredSystemsCapturedAt != 0 { - t.Fatalf("GrandfatheredMonitoredSystemsCapturedAt=%d, want 0 for uncapped self-hosted migration", activationState.Continuity.GrandfatheredMonitoredSystemsCapturedAt) - } } handlers.StopAllBackgroundLoops() @@ -478,15 +461,14 @@ func TestGetTenantComponents_DoesNotBackfillGrandfatherFloorForUncappedSelfHoste t.Setenv("PULSE_LICENSE_DEV_MODE", "false") grantJWT, grantPublicKey, err := licensetestsupport.GenerateGrantJWTForTesting(pkglicensing.GrantClaims{ - LicenseID: "lic_floor_restore", - Tier: "pro", - PlanKey: "legacy_migration_fallback", - State: "active", - Features: []string{"relay"}, - MaxMonitoredSystems: 10, - IssuedAt: time.Now().Unix(), - ExpiresAt: time.Now().Add(72 * time.Hour).Unix(), - Email: "floor-restore@example.com", + LicenseID: "lic_floor_restore", + Tier: "pro", + PlanKey: "legacy_migration_fallback", + State: "active", + Features: []string{"relay"}, + IssuedAt: time.Now().Unix(), + ExpiresAt: time.Now().Add(72 * time.Hour).Unix(), + Email: "floor-restore@example.com", }) if err != nil { t.Fatalf("generate grant jwt: %v", err) @@ -528,9 +510,6 @@ func TestGetTenantComponents_DoesNotBackfillGrandfatherFloorForUncappedSelfHoste if svc == nil { t.Fatal("expected non-nil service") } - if got := svc.Status().MaxMonitoredSystems; got != 0 { - t.Fatalf("initial status.MaxMonitoredSystems=%d, want 0 before floor capture for uncapped self-hosted continuity", got) - } handlers.SetMonitors(buildGrandfatherFloorMonitor(23), nil) time.Sleep(100 * time.Millisecond) @@ -542,120 +521,25 @@ func TestGetTenantComponents_DoesNotBackfillGrandfatherFloorForUncappedSelfHoste if loaded == nil { t.Fatal("expected activation state") } - if loaded.Continuity.GrandfatheredMaxMonitoredSystems != 0 { - t.Fatalf("GrandfatheredMaxMonitoredSystems=%d, want 0 for uncapped self-hosted restore", loaded.Continuity.GrandfatheredMaxMonitoredSystems) - } - if loaded.Continuity.GrandfatheredMonitoredSystemsCapturedAt != 0 { - t.Fatalf("GrandfatheredMonitoredSystemsCapturedAt=%d, want 0 for uncapped self-hosted restore", loaded.Continuity.GrandfatheredMonitoredSystemsCapturedAt) - } - if got := svc.Status().MaxMonitoredSystems; got != 0 { - t.Fatalf("status.MaxMonitoredSystems=%d, want 0 for uncapped self-hosted restore", got) - } - if svc.Status().MonitoredSystemContinuity != nil { - t.Fatalf("expected no monitored-system continuity for uncapped self-hosted restore, got %+v", svc.Status().MonitoredSystemContinuity) + if !loaded.Continuity.LegacyMigration { + t.Fatal("expected legacy migration continuity flag to survive restore") } handlers.StopAllBackgroundLoops() } -func TestBillingReads_DoNotRestartLegacyGrandfatherReconcileLoop(t *testing.T) { - t.Setenv("PULSE_LICENSE_DEV_MODE", "false") - - grantJWT, grantPublicKey, err := licensetestsupport.GenerateGrantJWTForTesting(pkglicensing.GrantClaims{ - LicenseID: "lic_floor_read_only", - Tier: "pro", - PlanKey: "legacy_migration_fallback", - State: "active", - Features: []string{"relay"}, - MaxMonitoredSystems: 10, - IssuedAt: time.Now().Unix(), - ExpiresAt: time.Now().Add(72 * time.Hour).Unix(), - Email: "floor-read-only@example.com", - }) - if err != nil { - t.Fatalf("generate grant jwt: %v", err) - } - pkglicensing.SetPublicKey(grantPublicKey) - t.Cleanup(func() { pkglicensing.SetPublicKey(nil) }) - - baseDir := t.TempDir() - mtp := config.NewMultiTenantPersistence(baseDir) - cp, err := mtp.GetPersistence("default") - if err != nil { - t.Fatalf("init default persistence: %v", err) - } - persistence, err := pkglicensing.NewPersistence(cp.GetConfigDir()) - if err != nil { - t.Fatalf("new persistence: %v", err) - } - if err := persistence.SaveActivationState(&pkglicensing.ActivationState{ - InstallationID: "inst_floor_read_only", - InstallationToken: "pit_live_floor_read_only", - LicenseID: "lic_floor_read_only", - GrantJWT: grantJWT, - GrantJTI: "grant_floor_read_only", - GrantExpiresAt: time.Now().Add(72 * time.Hour).Unix(), - InstanceFingerprint: "fp-floor-read-only", - LicenseServerURL: "https://license.pulserelay.pro", - ActivatedAt: time.Now().Add(-time.Hour).Unix(), - LastRefreshedAt: time.Now().Add(-time.Hour).Unix(), - Continuity: pkglicensing.ActivationContinuity{ - LegacyMigration: true, - }, - }); err != nil { - t.Fatalf("save activation state: %v", err) - } - - handlers := NewLicenseHandlers(mtp, false) - t.Cleanup(handlers.StopAllBackgroundLoops) - - ctx := context.WithValue(context.Background(), OrgIDContextKey, "default") - svc := handlers.Service(ctx) - if svc == nil { - t.Fatal("expected non-nil service") - } - - time.Sleep(100 * time.Millisecond) - if _, ok := handlers.legacyGrandfatherReconcile.Load("default"); ok { - t.Fatal("uncapped self-hosted restore started grandfather reconcile loop") - } - - 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()) - } - time.Sleep(100 * time.Millisecond) - if _, ok := handlers.legacyGrandfatherReconcile.Load("default"); ok { - t.Fatal("license status read restarted grandfather reconcile loop") - } - - 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()) - } - time.Sleep(100 * time.Millisecond) - if _, ok := handlers.legacyGrandfatherReconcile.Load("default"); ok { - t.Fatal("license entitlements read restarted grandfather reconcile loop") - } -} - func TestActivateLicenseKey_KeepsLegacySelfHostedMigrationUncapped(t *testing.T) { t.Setenv("PULSE_LICENSE_DEV_MODE", "false") grantJWT, grantPublicKey, err := licensetestsupport.GenerateGrantJWTForTesting(pkglicensing.GrantClaims{ - LicenseID: "lic_floor_manual", - Tier: "pro", - PlanKey: "legacy_migration_fallback", - State: "active", - Features: []string{"relay"}, - MaxMonitoredSystems: 10, - IssuedAt: time.Now().Unix(), - ExpiresAt: time.Now().Add(72 * time.Hour).Unix(), - Email: "floor-manual@example.com", + LicenseID: "lic_floor_manual", + Tier: "pro", + PlanKey: "legacy_migration_fallback", + State: "active", + Features: []string{"relay"}, + IssuedAt: time.Now().Unix(), + ExpiresAt: time.Now().Add(72 * time.Hour).Unix(), + Email: "floor-manual@example.com", }) if err != nil { t.Fatalf("generate grant jwt: %v", err) @@ -670,11 +554,10 @@ func TestActivateLicenseKey_KeepsLegacySelfHostedMigrationUncapped(t *testing.T) w.WriteHeader(http.StatusCreated) _ = json.NewEncoder(w).Encode(pkglicensing.ActivateInstallationResponse{ License: pkglicensing.ActivateResponseLicense{ - LicenseID: "lic_floor_manual", - State: "active", - Tier: "pro", - Features: []string{"relay"}, - MaxMonitoredSystems: 10, + LicenseID: "lic_floor_manual", + State: "active", + Tier: "pro", + Features: []string{"relay"}, }, Installation: pkglicensing.ActivateResponseInstallation{ InstallationID: "inst_floor_manual", @@ -713,24 +596,20 @@ func TestActivateLicenseKey_KeepsLegacySelfHostedMigrationUncapped(t *testing.T) if svc == nil { t.Fatal("expected non-nil service") } - if got := svc.Status().MaxMonitoredSystems; got != 0 { - t.Fatalf("status.MaxMonitoredSystems=%d, want 0 for uncapped self-hosted continuity", got) - } } func TestGetTenantComponents_DoesNotCaptureGrandfatherFloorWhenSupplementalInventorySettlesForUncappedSelfHosted(t *testing.T) { t.Setenv("PULSE_LICENSE_DEV_MODE", "false") grantJWT, grantPublicKey, err := licensetestsupport.GenerateGrantJWTForTesting(pkglicensing.GrantClaims{ - LicenseID: "lic_floor_supplemental", - Tier: "pro", - PlanKey: "legacy_migration_fallback", - State: "active", - Features: []string{"relay"}, - MaxMonitoredSystems: 10, - IssuedAt: time.Now().Unix(), - ExpiresAt: time.Now().Add(72 * time.Hour).Unix(), - Email: "floor-supplemental@example.com", + LicenseID: "lic_floor_supplemental", + Tier: "pro", + PlanKey: "legacy_migration_fallback", + State: "active", + Features: []string{"relay"}, + IssuedAt: time.Now().Unix(), + ExpiresAt: time.Now().Add(72 * time.Hour).Unix(), + Email: "floor-supplemental@example.com", }) if err != nil { t.Fatalf("generate grant jwt: %v", err) @@ -745,11 +624,10 @@ func TestGetTenantComponents_DoesNotCaptureGrandfatherFloorWhenSupplementalInven w.WriteHeader(http.StatusCreated) _ = json.NewEncoder(w).Encode(pkglicensing.ActivateInstallationResponse{ License: pkglicensing.ActivateResponseLicense{ - LicenseID: "lic_floor_supplemental", - State: "active", - Tier: "pro", - Features: []string{"relay"}, - MaxMonitoredSystems: 10, + LicenseID: "lic_floor_supplemental", + State: "active", + Tier: "pro", + Features: []string{"relay"}, }, Installation: pkglicensing.ActivateResponseInstallation{ InstallationID: "inst_floor_supplemental", @@ -821,35 +699,14 @@ func TestGetTenantComponents_DoesNotCaptureGrandfatherFloorWhenSupplementalInven } status := readStatus() - if got := status.MaxMonitoredSystems; got != 0 { - t.Fatalf("initial status.MaxMonitoredSystems=%d, want 0 while supplemental inventory is unsettled for uncapped self-hosted continuity", got) - } - if status.MonitoredSystemContinuity != nil { - t.Fatalf("expected no continuity in status payload for uncapped self-hosted migration, got %+v", status.MonitoredSystemContinuity) + if !status.Valid { + t.Fatal("expected migrated self-hosted license status to remain valid") } payload := readEntitlements() - if payload.MonitoredSystemContinuity != nil { - t.Fatalf("expected no continuity in entitlements payload for uncapped self-hosted migration, got %+v", payload.MonitoredSystemContinuity) - } if len(payload.Limits) != 0 { t.Fatalf("expected no enforced monitored-system limit for uncapped continuity, got %+v", payload.Limits) } - if payload.MonitoredSystemCapacity == nil { - t.Fatal("expected monitored-system capacity payload") - } - if payload.MonitoredSystemCapacity.Mode != "usage_unavailable" { - t.Fatalf("expected usage_unavailable monitored-system capacity while supplemental inventory is unsettled, got %+v", payload.MonitoredSystemCapacity) - } - if payload.MonitoredSystemCapacity.Limit != 0 { - t.Fatalf("monitored_system_capacity.limit=%d, want 0 for uncapped continuity", payload.MonitoredSystemCapacity.Limit) - } - if payload.MonitoredSystemCapacity.CurrentAvailable { - t.Fatalf("expected unavailable monitored-system usage while supplemental inventory is unsettled, got %+v", payload.MonitoredSystemCapacity) - } - if payload.MonitoredSystemCapacity.CurrentUnavailableReason != "supplemental_inventory_unsettled" { - t.Fatalf("CurrentUnavailableReason=%q, want %q", payload.MonitoredSystemCapacity.CurrentUnavailableReason, "supplemental_inventory_unsettled") - } activationState, err := persistence.LoadActivationState() if err != nil { @@ -858,20 +715,20 @@ func TestGetTenantComponents_DoesNotCaptureGrandfatherFloorWhenSupplementalInven if activationState == nil { t.Fatal("expected activation state after legacy exchange") } - if activationState.Continuity.GrandfatheredMonitoredSystemsCapturedAt != 0 { - t.Fatalf("GrandfatheredMonitoredSystemsCapturedAt=%d, want 0 for uncapped self-hosted migration", activationState.Continuity.GrandfatheredMonitoredSystemsCapturedAt) + if !activationState.Continuity.LegacyMigration { + t.Fatal("expected legacy migration continuity flag") } provider.settle(23) status = readStatus() - if got := status.MaxMonitoredSystems; got != 0 { - t.Fatalf("stale supplemental store should keep uncapped self-hosted status, got %d", got) + if !status.Valid { + t.Fatal("stale supplemental store should keep migrated self-hosted status valid") } monitor.SetSupplementalRecordsProvider(unifiedresources.SourceTrueNAS, provider) status = readStatus() - if got := status.MaxMonitoredSystems; got != 0 { - t.Fatalf("status read should not reintroduce a monitored-system cap directly after canonical store rebuild, got %d", got) + if !status.Valid { + t.Fatal("status read should keep migrated self-hosted status valid after canonical store rebuild") } time.Sleep(100 * time.Millisecond) @@ -882,34 +739,19 @@ func TestGetTenantComponents_DoesNotCaptureGrandfatherFloorWhenSupplementalInven if activationState == nil { t.Fatal("expected activation state after canonical store rebuild") } - if activationState.Continuity.GrandfatheredMaxMonitoredSystems != 0 { - t.Fatalf("GrandfatheredMaxMonitoredSystems=%d, want 0 for uncapped self-hosted migration", activationState.Continuity.GrandfatheredMaxMonitoredSystems) - } - if activationState.Continuity.GrandfatheredMonitoredSystemsCapturedAt != 0 { - t.Fatalf("GrandfatheredMonitoredSystemsCapturedAt=%d, want 0 for uncapped self-hosted migration", activationState.Continuity.GrandfatheredMonitoredSystemsCapturedAt) + if !activationState.Continuity.LegacyMigration { + t.Fatal("expected legacy migration continuity flag after canonical store rebuild") } status = readStatus() - if got := status.MaxMonitoredSystems; got != 0 { - t.Fatalf("status.MaxMonitoredSystems=%d, want 0 after canonical store rebuild for uncapped self-hosted continuity", got) - } - if status.MonitoredSystemContinuity != nil { - t.Fatalf("expected no continuity in status payload after canonical store rebuild, got %+v", status.MonitoredSystemContinuity) + if !status.Valid { + t.Fatal("expected migrated self-hosted license status to remain valid after canonical store rebuild") } payload = readEntitlements() - if payload.MonitoredSystemContinuity != nil { - t.Fatalf("expected no continuity in entitlements payload after canonical store rebuild, got %+v", payload.MonitoredSystemContinuity) - } if len(payload.Limits) != 0 { t.Fatalf("expected no enforced monitored-system limit in entitlements payload after async capture, got %+v", payload.Limits) } - if payload.MonitoredSystemCapacity == nil { - t.Fatal("expected monitored-system capacity payload after async capture") - } - if payload.MonitoredSystemCapacity.Mode != "unlimited" || payload.MonitoredSystemCapacity.Current != 23 { - t.Fatalf("expected unlimited monitored-system capacity with current=23 after canonical store rebuild, got %+v", payload.MonitoredSystemCapacity) - } } func buildGrandfatherFloorMonitor(count int) *monitoring.Monitor { diff --git a/internal/api/monitored_system_ledger.go b/internal/api/monitored_system_ledger.go index 2bb6e4aec..341b6182a 100644 --- a/internal/api/monitored_system_ledger.go +++ b/internal/api/monitored_system_ledger.go @@ -1,7 +1,6 @@ package api import ( - "context" "encoding/json" "io" "net/http" @@ -67,7 +66,6 @@ type MonitoredSystemLedgerExplanationSurface struct { type MonitoredSystemLedgerResponse struct { Systems []MonitoredSystemLedgerEntry `json:"systems"` Total int `json:"total"` - Limit int `json:"limit"` // 0 = unlimited } // MonitoredSystemLedgerExplainRequest describes an optional monitored-system @@ -119,8 +117,6 @@ type MonitoredSystemLedgerPreviewResponse struct { CurrentCount int `json:"current_count"` ProjectedCount int `json:"projected_count"` AdditionalCount int `json:"additional_count"` - Limit int `json:"limit"` - WouldExceedLimit bool `json:"would_exceed_limit"` Effect string `json:"effect"` CurrentSystems []MonitoredSystemLedgerEntry `json:"current_systems"` ProjectedSystems []MonitoredSystemLedgerEntry `json:"projected_systems"` @@ -177,7 +173,7 @@ func (r *Router) handleMonitoredSystemLedger(w http.ResponseWriter, req *http.Re return } - resp := monitoredSystemLedgerResponseFromReadState(req.Context(), usage.readState) + resp := monitoredSystemLedgerResponseFromReadState(usage.readState) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(resp.NormalizeCollections()) @@ -221,7 +217,7 @@ func (r *Router) handleMonitoredSystemLedgerPreview(w http.ResponseWriter, req * return } - resp := monitoredSystemLedgerPreviewResponse(req.Context(), previewReq.Replacement != nil, preview) + resp := monitoredSystemLedgerPreviewResponse(previewReq.Replacement != nil, preview) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(resp.NormalizeCollections()) @@ -246,7 +242,7 @@ func (r *Router) handleMonitoredSystemLedgerExplain(w http.ResponseWriter, req * } resp := MonitoredSystemLedgerExplainResponse{ - Ledger: monitoredSystemLedgerResponseFromReadState(req.Context(), usage.readState), + Ledger: monitoredSystemLedgerResponseFromReadState(usage.readState), } if explainReq.Candidate != nil { @@ -278,7 +274,6 @@ func (r *Router) handleMonitoredSystemLedgerExplain(w http.ResponseWriter, req * } previewResp := monitoredSystemLedgerPreviewResponse( - req.Context(), explainReq.Replacement != nil, preview, ).NormalizeCollections() @@ -349,7 +344,6 @@ func monitoredSystemLedgerEntries( } func monitoredSystemLedgerResponseFromReadState( - ctx context.Context, rs unifiedresources.ReadState, ) MonitoredSystemLedgerResponse { systems := unifiedresources.MonitoredSystems(rs) @@ -357,17 +351,13 @@ func monitoredSystemLedgerResponseFromReadState( return MonitoredSystemLedgerResponse{ Systems: entries, Total: len(entries), - Limit: maxMonitoredSystemsLimitForContext(ctx), } } func monitoredSystemLedgerPreviewResponse( - ctx context.Context, hasReplacement bool, preview unifiedresources.MonitoredSystemProjectionPreview, ) MonitoredSystemLedgerPreviewResponse { - limit := maxMonitoredSystemsLimitForContext(ctx) - decision := monitoredSystemLimitDecisionFromAdditional(ctx, limit, preview.CurrentCount, preview.AdditionalCount) currentSystems := monitoredSystemLedgerEntries(preview.CurrentSystems) projectedSystems := monitoredSystemLedgerEntries(preview.ProjectedSystems) @@ -375,8 +365,6 @@ func monitoredSystemLedgerPreviewResponse( CurrentCount: preview.CurrentCount, ProjectedCount: preview.ProjectedCount, AdditionalCount: preview.AdditionalCount, - Limit: limit, - WouldExceedLimit: decision.exceeded, Effect: monitoredSystemLedgerPreviewEffect(hasReplacement, preview), CurrentSystems: currentSystems, ProjectedSystems: projectedSystems, diff --git a/internal/api/monitored_system_ledger_test.go b/internal/api/monitored_system_ledger_test.go index f8a14c289..05a6b0c1a 100644 --- a/internal/api/monitored_system_ledger_test.go +++ b/internal/api/monitored_system_ledger_test.go @@ -214,15 +214,13 @@ func TestMonitoredSystemLedgerResponseEmptyState(t *testing.T) { if err := json.Unmarshal(data, &decoded); err != nil { t.Fatalf("unmarshal: %v", err) } - if decoded.Total != 0 || decoded.Limit != 0 || len(decoded.Systems) != 0 { + if decoded.Total != 0 || len(decoded.Systems) != 0 { t.Errorf("unexpected response: %+v", decoded) } } func TestMonitoredSystemLedgerNilSystemsBecomesEmptyArray(t *testing.T) { - resp := MonitoredSystemLedgerResponse{ - Limit: 5, - }.NormalizeCollections() + resp := MonitoredSystemLedgerResponse{}.NormalizeCollections() data, err := json.Marshal(resp) if err != nil { @@ -294,7 +292,6 @@ func TestHandleMonitoredSystemLedgerHTTP(t *testing.T) { }, }, Total: 1, - Limit: 5, } rec.Header().Set("Content-Type", "application/json") @@ -310,7 +307,7 @@ func TestHandleMonitoredSystemLedgerHTTP(t *testing.T) { if err := json.Unmarshal(rec.Body.Bytes(), &decoded); err != nil { t.Fatalf("unmarshal: %v", err) } - if decoded.Total != 1 || decoded.Limit != 5 { + if decoded.Total != 1 { t.Errorf("unexpected response: %+v", decoded) } if decoded.Systems[0].Name != "test-host" || decoded.Systems[0].Type != "host" { @@ -372,12 +369,6 @@ func TestHandleMonitoredSystemLedgerPreviewHTTP(t *testing.T) { if decoded.CurrentCount != 1 || decoded.ProjectedCount != 1 || decoded.AdditionalCount != 0 { t.Fatalf("unexpected counts: %+v", decoded) } - if decoded.Limit != 1 { - t.Fatalf("Limit = %d, want 1", decoded.Limit) - } - if decoded.WouldExceedLimit { - t.Fatalf("expected attach preview to stay within limit, got %+v", decoded) - } if decoded.Effect != "attaches_existing" { t.Fatalf("Effect = %q, want attaches_existing", decoded.Effect) } @@ -552,9 +543,6 @@ func TestHandleMonitoredSystemLedgerExplainCurrentAndPreview(t *testing.T) { if decoded.Ledger.Total != 1 { t.Fatalf("Ledger.Total = %d, want 1", decoded.Ledger.Total) } - if decoded.Ledger.Limit != 1 { - t.Fatalf("Ledger.Limit = %d, want 1", decoded.Ledger.Limit) - } if len(decoded.Ledger.Systems) != 1 { t.Fatalf("len(Ledger.Systems) = %d, want 1", len(decoded.Ledger.Systems)) } @@ -603,7 +591,7 @@ func TestHandleMonitoredSystemLedgerExplainCurrentOnly(t *testing.T) { if decoded.Preview != nil { t.Fatalf("expected nil preview, got %+v", decoded.Preview) } - if decoded.Ledger.Total != 1 || decoded.Ledger.Limit != 2 { + if decoded.Ledger.Total != 1 { t.Fatalf("unexpected ledger payload: %+v", decoded.Ledger) } } diff --git a/internal/api/monitored_system_limit_enforcement.go b/internal/api/monitored_system_limit_enforcement.go deleted file mode 100644 index e25c47f0c..000000000 --- a/internal/api/monitored_system_limit_enforcement.go +++ /dev/null @@ -1,535 +0,0 @@ -package api - -import ( - "context" - "net/http" - "strings" - "sync" - "time" - - "github.com/rcourtman/pulse-go-rewrite/internal/config" - "github.com/rcourtman/pulse-go-rewrite/internal/models" - "github.com/rcourtman/pulse-go-rewrite/internal/monitoring" - "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" - agentsdocker "github.com/rcourtman/pulse-go-rewrite/pkg/agents/docker" - agentshost "github.com/rcourtman/pulse-go-rewrite/pkg/agents/host" - agentsk8s "github.com/rcourtman/pulse-go-rewrite/pkg/agents/kubernetes" -) - -// overflowBaseDataDir is set during router initialization to allow the -// enforcement path to read billing-state overflow fields without requiring -// a handler reference. -var ( - overflowBaseDataDirMu sync.RWMutex - overflowBaseDataDir string - - deployReservationCounterMu sync.RWMutex - deployReservationCounter func(ctx context.Context) int -) - -// SetDeployReservationCounter wires a callback that returns the number of -// monitored-system slots reserved by in-flight cluster deployments for the org -// in ctx. -func SetDeployReservationCounter(fn func(ctx context.Context) int) { - deployReservationCounterMu.Lock() - defer deployReservationCounterMu.Unlock() - deployReservationCounter = fn -} - -func deployReservedCount(ctx context.Context) int { - deployReservationCounterMu.RLock() - fn := deployReservationCounter - deployReservationCounterMu.RUnlock() - if fn == nil { - return 0 - } - return fn(ctx) -} - -// SetOverflowBaseDataDir configures the base data directory used by the -// enforcement path to read OverflowGrantedAt from billing state. -func SetOverflowBaseDataDir(dir string) { - overflowBaseDataDirMu.Lock() - defer overflowBaseDataDirMu.Unlock() - overflowBaseDataDir = dir -} - -func maxMonitoredSystemsLimitForContext(ctx context.Context) int { - service := getLicenseServiceForContext(ctx) - if service == nil { - return 0 - } - status := service.Status() - if status == nil { - return 0 - } - - limit := status.MaxMonitoredSystems - - // Apply onboarding overflow bonus for free-tier orgs. The bonus only - // makes sense on plans that actually have a cap — adding +1 to an - // uncapped limit (0) would convert "unlimited" into a cap of 1. - if status.Tier == licenseTierFreeValue && limit > 0 { - var overflowGrantedAt *int64 - - // Try evaluator first (covers hosted path with DatabaseSource). - if eval := service.Evaluator(); eval != nil { - overflowGrantedAt = eval.OverflowGrantedAt() - } - - // Self-hosted fallback: read from billing state on disk. - if overflowGrantedAt == nil { - overflowBaseDataDirMu.RLock() - baseDir := overflowBaseDataDir - overflowBaseDataDirMu.RUnlock() - - if baseDir != "" { - orgID := GetOrgID(ctx) - if orgID == "" { - orgID = "default" - } - store := config.NewFileBillingStore(baseDir) - if bs, err := store.GetBillingState(orgID); err == nil && bs != nil { - overflowGrantedAt = bs.OverflowGrantedAt - } - } - } - - limit += overflowBonusFromLicensing(status.Tier, overflowGrantedAt, time.Now()) - } - - return limit -} - -// monitoredSystemCount returns the canonical number of counted top-level -// monitored systems. Agent-backed and API-backed views share the same cap. -func monitoredSystemCount(monitor *monitoring.Monitor) int { - return monitoredSystemUsage(monitor).count -} - -func monitoredSystemCandidateStateFromEnabled( - enabled bool, -) unifiedresources.MonitoredSystemCandidateState { - if enabled { - return unifiedresources.MonitoredSystemCandidateStateActive - } - return unifiedresources.MonitoredSystemCandidateStateInactive -} - -type monitoredSystemUsageSnapshot struct { - count int - readState unifiedresources.ReadState - available bool - unavailableReason string -} - -func monitoredSystemUsage(monitor *monitoring.Monitor) monitoredSystemUsageSnapshot { - usage := monitor.MonitoredSystemUsage() - return monitoredSystemUsageSnapshot{ - count: usage.Count, - readState: usage.ReadState, - available: usage.Available, - unavailableReason: usage.UnavailableReason, - } -} - -type monitoredSystemLimitDecision struct { - current int - limit int - additional int - usageAvailable bool - usageUnavailableReason string - exceeded bool - preview *MonitoredSystemLedgerPreviewResponse -} - -func legacyConnectionCounts(monitor *monitoring.Monitor) legacyConnectionCountsModel { - return legacyConnectionCountsModel{} -} - -func legacyConnectionCountsFromReadState(rs unifiedresources.ReadState) legacyConnectionCountsModel { - return legacyConnectionCountsModel{} -} - -func monitoredSystemLimitExceededPayload(decision monitoredSystemLimitDecision) map[string]interface{} { - payload := map[string]interface{}{ - "error": "license_required", - "message": monitoredSystemLimitExceededMessageFromLicensing(decision.current, decision.limit), - "feature": maxMonitoredSystemsLicenseGateKey, - "upgrade_url": upgradeURLForFeatureFromLicensing(maxMonitoredSystemsLicenseGateKey), - } - if decision.preview != nil { - payload["monitored_system_preview"] = decision.preview.NormalizeCollections() - } - return payload -} - -func writeMaxMonitoredSystemsLimitExceeded(w http.ResponseWriter, decision monitoredSystemLimitDecision) { - writePaymentRequired(w, monitoredSystemLimitExceededPayload(decision)) -} - -func writeMonitoredSystemUsageUnavailable(w http.ResponseWriter, reason string) { - details := map[string]string{} - if trimmed := strings.TrimSpace(reason); trimmed != "" { - details["reason"] = trimmed - } - writeErrorResponse( - w, - http.StatusServiceUnavailable, - "monitored_system_usage_unavailable", - "Unable to verify monitored-system capacity right now", - details, - ) -} - -func monitoredSystemLimitDecisionFromAdditional( - ctx context.Context, - limit int, - current int, - additional int, -) monitoredSystemLimitDecision { - if limit <= 0 { - return monitoredSystemLimitDecision{ - limit: limit, - additional: additional, - usageAvailable: true, - } - } - - effectiveCurrent := current + deployReservedCount(ctx) - return monitoredSystemLimitDecision{ - current: effectiveCurrent, - limit: limit, - additional: additional, - usageAvailable: true, - exceeded: additional > 0 && effectiveCurrent+additional > limit, - } -} - -func monitoredSystemLimitDecisionFromPreview( - ctx context.Context, - limit int, - hasReplacement bool, - preview unifiedresources.MonitoredSystemProjectionPreview, -) monitoredSystemLimitDecision { - decision := monitoredSystemLimitDecisionFromAdditional( - ctx, - limit, - preview.CurrentCount, - preview.AdditionalCount, - ) - resp := monitoredSystemLedgerPreviewResponse(ctx, hasReplacement, preview).NormalizeCollections() - decision.preview = &resp - return decision -} - -func monitoredSystemLimitDecisionForCandidate( - ctx context.Context, - monitor *monitoring.Monitor, - candidate unifiedresources.MonitoredSystemCandidate, -) monitoredSystemLimitDecision { - limit := maxMonitoredSystemsLimitForContext(ctx) - if !candidate.CountsTowardMonitoredSystems() { - return monitoredSystemLimitDecision{ - limit: limit, - usageAvailable: true, - } - } - if limit <= 0 { - return monitoredSystemLimitDecision{ - limit: limit, - usageAvailable: true, - } - } - - usage := monitoredSystemUsage(monitor) - if !usage.available { - return monitoredSystemLimitDecision{ - limit: limit, - usageUnavailableReason: usage.unavailableReason, - } - } - - projection := unifiedresources.PreviewMonitoredSystemCandidate(usage.readState, candidate) - return monitoredSystemLimitDecisionFromPreview(ctx, limit, false, projection) -} - -func monitoredSystemLimitDecisionForCandidateReplacement( - ctx context.Context, - monitor *monitoring.Monitor, - replacement unifiedresources.MonitoredSystemReplacement, - candidate unifiedresources.MonitoredSystemCandidate, -) monitoredSystemLimitDecision { - limit := maxMonitoredSystemsLimitForContext(ctx) - if !candidate.CountsTowardMonitoredSystems() { - return monitoredSystemLimitDecision{ - limit: limit, - usageAvailable: true, - } - } - if limit <= 0 { - return monitoredSystemLimitDecision{ - limit: limit, - usageAvailable: true, - } - } - - usage := monitoredSystemUsage(monitor) - if !usage.available { - return monitoredSystemLimitDecision{ - limit: limit, - usageUnavailableReason: usage.unavailableReason, - } - } - - projection := unifiedresources.PreviewMonitoredSystemCandidateReplacement( - usage.readState, - replacement, - candidate, - ) - return monitoredSystemLimitDecisionFromPreview(ctx, limit, true, projection) -} - -func monitoredSystemLimitDecisionForRecordsFromUsage( - ctx context.Context, - limit int, - usage monitoredSystemUsageSnapshot, - recordsBySource map[unifiedresources.DataSource][]unifiedresources.IngestRecord, -) monitoredSystemLimitDecision { - if limit <= 0 { - return monitoredSystemLimitDecision{ - limit: limit, - usageAvailable: true, - } - } - - if !usage.available { - return monitoredSystemLimitDecision{ - limit: limit, - usageUnavailableReason: usage.unavailableReason, - } - } - - projection := unifiedresources.PreviewMonitoredSystemRecords(usage.readState, recordsBySource) - return monitoredSystemLimitDecisionFromPreview(ctx, limit, false, projection) -} - -func monitoredSystemLimitDecisionForRecordsReplacementFromUsage( - ctx context.Context, - limit int, - usage monitoredSystemUsageSnapshot, - replacement unifiedresources.MonitoredSystemReplacement, - recordsBySource map[unifiedresources.DataSource][]unifiedresources.IngestRecord, -) monitoredSystemLimitDecision { - if limit <= 0 { - return monitoredSystemLimitDecision{ - limit: limit, - usageAvailable: true, - } - } - - if !usage.available { - return monitoredSystemLimitDecision{ - limit: limit, - usageUnavailableReason: usage.unavailableReason, - } - } - - projection := unifiedresources.PreviewMonitoredSystemRecordsReplacement( - usage.readState, - replacement, - recordsBySource, - ) - return monitoredSystemLimitDecisionFromPreview(ctx, limit, true, projection) -} - -func monitoredSystemLimitDecisionForAdditionalSlots( - ctx context.Context, - monitor *monitoring.Monitor, - additional int, -) monitoredSystemLimitDecision { - limit := maxMonitoredSystemsLimitForContext(ctx) - if limit <= 0 { - return monitoredSystemLimitDecision{ - limit: limit, - additional: additional, - usageAvailable: true, - } - } - - usage := monitoredSystemUsage(monitor) - if !usage.available { - return monitoredSystemLimitDecision{ - limit: limit, - usageUnavailableReason: usage.unavailableReason, - } - } - - return monitoredSystemLimitDecisionFromAdditional(ctx, limit, usage.count, additional) -} - -func enforceMonitoredSystemLimitForConfigRegistration( - w http.ResponseWriter, - ctx context.Context, - _ *config.Config, - monitor *monitoring.Monitor, - candidate unifiedresources.MonitoredSystemCandidate, -) bool { - decision := monitoredSystemLimitDecisionForCandidate(ctx, monitor, candidate) - if !decision.usageAvailable { - writeMonitoredSystemUsageUnavailable(w, decision.usageUnavailableReason) - return true - } - if !decision.exceeded { - return false - } - - writeMaxMonitoredSystemsLimitExceeded(w, decision) - return true -} - -func enforceMonitoredSystemLimitForConfigReplacement( - w http.ResponseWriter, - ctx context.Context, - monitor *monitoring.Monitor, - replacement unifiedresources.MonitoredSystemReplacement, - candidate unifiedresources.MonitoredSystemCandidate, -) bool { - decision := monitoredSystemLimitDecisionForCandidateReplacement(ctx, monitor, replacement, candidate) - if !decision.usageAvailable { - writeMonitoredSystemUsageUnavailable(w, decision.usageUnavailableReason) - return true - } - if !decision.exceeded { - return false - } - - writeMaxMonitoredSystemsLimitExceeded(w, decision) - return true -} - -// enforceMonitoredSystemLimitForHostReport checks whether a new host report -// would create a new counted monitored system. -func enforceMonitoredSystemLimitForHostReport( - w http.ResponseWriter, - ctx context.Context, - monitor *monitoring.Monitor, - report agentshost.Report, - tokenRecord *config.APITokenRecord, -) bool { - if monitor != nil && monitor.HostReportMatchesKnownIdentity(report, tokenRecord) { - return false - } - - candidate := unifiedresources.MonitoredSystemCandidate{ - Source: unifiedresources.SourceAgent, - Type: unifiedresources.ResourceTypeAgent, - Name: report.Host.DisplayName, - Hostname: report.Host.Hostname, - HostURL: report.Host.ReportIP, - AgentID: report.Agent.ID, - MachineID: report.Host.MachineID, - ResourceID: report.Host.ID, - } - decision := monitoredSystemLimitDecisionForCandidate(ctx, monitor, candidate) - if !decision.usageAvailable { - writeMonitoredSystemUsageUnavailable(w, decision.usageUnavailableReason) - return true - } - if !decision.exceeded { - return false - } - - writeMaxMonitoredSystemsLimitExceeded(w, decision) - return true -} - -// enforceMonitoredSystemLimitForDockerReport checks whether a docker report -// would create a new counted top-level monitored system. -func enforceMonitoredSystemLimitForDockerReport( - w http.ResponseWriter, - ctx context.Context, - monitor *monitoring.Monitor, - report agentsdocker.Report, - tokenRecord *config.APITokenRecord, -) bool { - tokenID := "" - if tokenRecord != nil { - tokenID = tokenRecord.ID - } - if monitor != nil && dockerReportTargetsExistingHostFromLicensing(monitor.ReadSnapshot(), report, tokenID) { - return false - } - - candidate := unifiedresources.MonitoredSystemCandidate{ - Source: unifiedresources.SourceDocker, - Type: unifiedresources.ResourceTypeAgent, - Name: report.Host.Name, - Hostname: report.Host.Hostname, - AgentID: report.Agent.ID, - MachineID: report.Host.MachineID, - ResourceID: report.AgentKey(), - } - decision := monitoredSystemLimitDecisionForCandidate(ctx, monitor, candidate) - if !decision.usageAvailable { - writeMonitoredSystemUsageUnavailable(w, decision.usageUnavailableReason) - return true - } - if !decision.exceeded { - return false - } - - writeMaxMonitoredSystemsLimitExceeded(w, decision) - return true -} - -// enforceMonitoredSystemLimitForKubernetesReport checks whether a Kubernetes -// report would create a new counted cluster. -func enforceMonitoredSystemLimitForKubernetesReport( - w http.ResponseWriter, - ctx context.Context, - monitor *monitoring.Monitor, - report agentsk8s.Report, - tokenRecord *config.APITokenRecord, -) bool { - tokenID := "" - if tokenRecord != nil { - tokenID = tokenRecord.ID - } - if monitor != nil && kubernetesReportTargetsExistingClusterFromLicensing(monitor.ReadSnapshot(), report, tokenID) { - return false - } - - candidate := unifiedresources.MonitoredSystemCandidate{ - Source: unifiedresources.SourceK8s, - Type: unifiedresources.ResourceTypeK8sCluster, - Name: report.Cluster.Name, - Hostname: report.Cluster.Name, - HostURL: report.Cluster.Server, - AgentID: report.Agent.ID, - ResourceID: kubernetesReportIdentifierFromLicensing(report), - } - decision := monitoredSystemLimitDecisionForCandidate(ctx, monitor, candidate) - if !decision.usageAvailable { - writeMonitoredSystemUsageUnavailable(w, decision.usageUnavailableReason) - return true - } - if !decision.exceeded { - return false - } - - writeMaxMonitoredSystemsLimitExceeded(w, decision) - return true -} - -func hostReportTargetsExistingHost( - hosts []models.Host, - report agentshost.Report, - tokenRecord *config.APITokenRecord, -) bool { - tokenID := "" - if tokenRecord != nil { - tokenID = tokenRecord.ID - } - return hostReportTargetsExistingHostsFromLicensing(hosts, report, tokenID) -} diff --git a/internal/api/monitored_system_limit_enforcement_test.go b/internal/api/monitored_system_limit_enforcement_test.go deleted file mode 100644 index cbc6d5c35..000000000 --- a/internal/api/monitored_system_limit_enforcement_test.go +++ /dev/null @@ -1,743 +0,0 @@ -package api - -import ( - "context" - "encoding/json" - "os" - "path/filepath" - "runtime" - "strings" - "testing" - - "github.com/rcourtman/pulse-go-rewrite/internal/config" - "github.com/rcourtman/pulse-go-rewrite/internal/models" - "github.com/rcourtman/pulse-go-rewrite/internal/monitoring" - "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" - agentshost "github.com/rcourtman/pulse-go-rewrite/pkg/agents/host" -) - -type monitoredSystemLimitBlockedPayload struct { - Error string `json:"error"` - Feature string `json:"feature"` - Message string `json:"message"` - MonitoredSystemPreview MonitoredSystemLedgerPreviewResponse `json:"monitored_system_preview"` -} - -func decodeMonitoredSystemLimitBlockedPayload( - t *testing.T, - body []byte, -) monitoredSystemLimitBlockedPayload { - t.Helper() - - var payload monitoredSystemLimitBlockedPayload - if err := json.Unmarshal(body, &payload); err != nil { - t.Fatalf("decode monitored-system limit blocked payload: %v", err) - } - return payload -} - -func readAPIPackageFile(t *testing.T, name string) string { - t.Helper() - - _, filename, _, ok := runtime.Caller(0) - if !ok { - t.Fatal("resolve API package test file") - } - content, err := os.ReadFile(filepath.Join(filepath.Dir(filename), name)) - if err != nil { - t.Fatalf("read %s: %v", name, err) - } - return string(content) -} - -func requireContainsSnippet(t *testing.T, source string, snippet string) { - t.Helper() - - if !strings.Contains(source, snippet) { - t.Fatalf("missing snippet %q", snippet) - } -} - -func requireSnippetCountAtLeast(t *testing.T, source string, snippet string, min int) { - t.Helper() - - if got := strings.Count(source, snippet); got < min { - t.Fatalf("snippet %q count = %d, want at least %d", snippet, got, min) - } -} - -func requireSnippetBefore(t *testing.T, source string, earlier string, later string) { - t.Helper() - - earlierIndex := strings.Index(source, earlier) - if earlierIndex < 0 { - t.Fatalf("missing earlier snippet %q", earlier) - } - laterIndex := strings.Index(source, later) - if laterIndex < 0 { - t.Fatalf("missing later snippet %q", later) - } - if earlierIndex > laterIndex { - t.Fatalf("snippet %q must appear before %q", earlier, later) - } -} - -func requireSourceSegment(t *testing.T, source string, start string, end string) string { - t.Helper() - - startIndex := strings.Index(source, start) - if startIndex < 0 { - t.Fatalf("missing segment start %q", start) - } - remainder := source[startIndex:] - endIndex := strings.Index(remainder[len(start):], end) - if endIndex < 0 { - t.Fatalf("missing segment end %q after %q", end, start) - } - return remainder[:len(start)+endIndex] -} - -func TestMonitoredSystemLimitDecisionOnlyBlocksNetNewSystems(t *testing.T) { - ctx := context.Background() - - atLimitExisting := monitoredSystemLimitDecisionFromAdditional(ctx, 5, 5, 0) - if atLimitExisting.exceeded { - t.Fatalf("existing monitored systems must continue reporting at the limit: %+v", atLimitExisting) - } - - atLimitNew := monitoredSystemLimitDecisionFromAdditional(ctx, 5, 5, 1) - if !atLimitNew.exceeded { - t.Fatalf("net-new monitored systems must be blocked when the cap is full: %+v", atLimitNew) - } -} - -func TestMonitoredSystemLimitDecisionForInactiveCandidateBypassesUsageAvailability(t *testing.T) { - ctx := context.Background() - setMaxMonitoredSystemsLicenseForTests(t, 1) - - decision := monitoredSystemLimitDecisionForCandidate(ctx, nil, unifiedresources.MonitoredSystemCandidate{ - Source: unifiedresources.SourceTrueNAS, - Type: unifiedresources.ResourceTypeAgent, - Name: "tower", - Hostname: "tower.local", - HostURL: "https://tower.local", - State: unifiedresources.MonitoredSystemCandidateStateInactive, - }) - if !decision.usageAvailable { - t.Fatalf("inactive candidate should not require usage availability: %+v", decision) - } - if decision.exceeded { - t.Fatalf("inactive candidate must not exceed the limit: %+v", decision) - } - - replacementDecision := monitoredSystemLimitDecisionForCandidateReplacement( - ctx, - nil, - unifiedresources.MonitoredSystemReplacement{ - Source: unifiedresources.SourceTrueNAS, - Selector: unifiedresources.MonitoredSystemReplacementSelector{ - Hostname: "tower.local", - }, - }, - unifiedresources.MonitoredSystemCandidate{ - Source: unifiedresources.SourceTrueNAS, - Type: unifiedresources.ResourceTypeAgent, - Name: "tower", - Hostname: "tower.local", - HostURL: "https://tower.local", - State: unifiedresources.MonitoredSystemCandidateStateInactive, - }, - ) - if !replacementDecision.usageAvailable { - t.Fatalf("inactive replacement candidate should not require usage availability: %+v", replacementDecision) - } - if replacementDecision.exceeded { - t.Fatalf("inactive replacement candidate must not exceed the limit: %+v", replacementDecision) - } -} - -func TestMonitoredSystemAdmissionSurfacesStayBehindSharedLimitGate(t *testing.T) { - router := readAPIPackageFile(t, "router_routes_registration.go") - - type sourceContract struct { - file string - requiredSnippets []string - requiredCounts map[string]int - requiredOrderings [][2]string - } - type proofContract struct { - file string - snippets []string - } - type admissionSurface struct { - name string - routerSnippets []string - sources []sourceContract - proofs []proofContract - } - - surfaces := []admissionSurface{ - { - name: "unified agent report", - routerSnippets: []string{ - `"/api/agents/agent/report"`, - `"/api/agents/host/report"`, - "r.unifiedAgentHandlers.HandleReport", - }, - sources: []sourceContract{{ - file: "agent_ingest.go", - requiredSnippets: []string{ - "enforceMonitoredSystemLimitForHostReport(", - "ApplyHostReport(report, tokenRecord)", - }, - requiredOrderings: [][2]string{ - {"enforceMonitoredSystemLimitForHostReport(", "ApplyHostReport(report, tokenRecord)"}, - }, - }}, - proofs: []proofContract{{ - file: "unified_agent_handlers_test.go", - snippets: []string{ - "TestUnifiedAgentHandlers_HandleReport_EnforcesMaxMonitoredSystemsForNewHostsOnly", - "Existing host should continue to report at the limit.", - "New host should be blocked.", - "http.StatusPaymentRequired", - }, - }}, - }, - { - name: "docker agent report", - routerSnippets: []string{ - `"/api/agents/docker/report"`, - "r.dockerAgentHandlers.HandleReport", - }, - sources: []sourceContract{{ - file: "docker_agents.go", - requiredSnippets: []string{ - "enforceMonitoredSystemLimitForDockerReport(", - "ApplyDockerReport(report, tokenRecord)", - }, - requiredOrderings: [][2]string{ - {"enforceMonitoredSystemLimitForDockerReport(", "ApplyDockerReport(report, tokenRecord)"}, - }, - }}, - proofs: []proofContract{{ - file: "docker_agents_additional_test.go", - snippets: []string{ - "TestDockerAgentHandlers_HandleReport_BlocksNewMonitoredSystemAtLimit", - "http.StatusPaymentRequired", - }, - }}, - }, - { - name: "kubernetes agent report", - routerSnippets: []string{ - `"/api/agents/kubernetes/report"`, - "r.kubernetesAgentHandlers.HandleReport", - }, - sources: []sourceContract{{ - file: "kubernetes_agents.go", - requiredSnippets: []string{ - "enforceMonitoredSystemLimitForKubernetesReport(", - "ApplyKubernetesReport(report, tokenRecord)", - }, - requiredOrderings: [][2]string{ - {"enforceMonitoredSystemLimitForKubernetesReport(", "ApplyKubernetesReport(report, tokenRecord)"}, - }, - }}, - proofs: []proofContract{{ - file: "kubernetes_agents_additional_test.go", - snippets: []string{ - "TestKubernetesAgentHandlers_HandleReport_BlocksNewMonitoredSystemAtLimit", - "http.StatusPaymentRequired", - }, - }}, - }, - { - name: "proxmox family config add and update", - routerSnippets: []string{ - `"/api/config/nodes"`, - `"/api/config/nodes/"`, - "r.configHandlers.HandleAddNode", - "r.configHandlers.HandleUpdateNode", - }, - sources: []sourceContract{{ - file: "config_node_handlers.go", - requiredSnippets: []string{ - "enforceMonitoredSystemLimitForConfigRegistration(", - "enforceMonitoredSystemLimitForConfigReplacement(", - "SaveNodesConfig(", - }, - requiredCounts: map[string]int{ - "enforceMonitoredSystemLimitForConfigRegistration(": 3, - "enforceMonitoredSystemLimitForConfigReplacement(": 3, - }, - requiredOrderings: [][2]string{ - {"enforceMonitoredSystemLimitForConfigRegistration(", "PVEInstances = append"}, - {"enforceMonitoredSystemLimitForConfigReplacement(", "*pve = updated"}, - }, - }}, - proofs: []proofContract{ - { - file: "config_handlers_add_test.go", - snippets: []string{ - "TestHandleAddNode_BlocksNewCountedSystemAtLimit", - "http.StatusPaymentRequired", - }, - }, - { - file: "config_handlers_update_test.go", - snippets: []string{ - "TestHandleUpdateNode_AllowsProjectedNetNewSystemWithoutPaidLimit", - "without paid limit enforcement", - "http.StatusOK", - }, - }, - }, - }, - { - name: "auto registration", - routerSnippets: []string{ - `"/api/auto-register"`, - "r.configHandlers.HandleAutoRegister", - }, - sources: []sourceContract{{ - file: "config_setup_handlers.go", - requiredSnippets: []string{ - "enforceMonitoredSystemLimitForConfigRegistration(", - "SaveNodesConfig(", - }, - requiredCounts: map[string]int{ - "enforceMonitoredSystemLimitForConfigRegistration(": 2, - }, - }}, - proofs: []proofContract{{ - file: "config_handlers_auto_register_test.go", - snippets: []string{ - "TestHandleAutoRegister_BlocksNewCountedSystemAtLimit", - "http.StatusPaymentRequired", - }, - }}, - }, - { - name: "truenas connection add and update", - routerSnippets: []string{ - `"/api/truenas/connections"`, - `"/api/truenas/connections/"`, - "r.trueNASHandlers.HandleAdd", - "r.trueNASHandlers.HandleUpdate", - }, - sources: []sourceContract{{ - file: "truenas_handlers.go", - requiredSnippets: []string{ - "monitoredSystemLimitDecisionForCandidate(", - "monitoredSystemLimitDecisionForCandidateReplacement(", - "SaveTrueNASConfig(", - }, - requiredOrderings: [][2]string{ - {"h.enforceMonitoredSystemLimit(w, r, instance)", "existing = append(existing, instance)"}, - {"h.enforceMonitoredSystemLimitReplacement(w, r, instances[index], instance)", "instances[index] = instance"}, - }, - }}, - proofs: []proofContract{{ - file: "truenas_handlers_test.go", - snippets: []string{ - "TestTrueNASHandlers_HandleAdd_BlocksNewCountedSystemAtLimit", - "TestTrueNASHandlers_HandleUpdate_BlocksProjectedNetNewSystemAtLimit", - "http.StatusPaymentRequired", - "license_required", - }, - }}, - }, - { - name: "vmware connection add and update", - routerSnippets: []string{ - `"/api/vmware/connections"`, - `"/api/vmware/connections/"`, - "r.vmwareHandlers.HandleAdd", - "r.vmwareHandlers.HandleUpdate", - }, - sources: []sourceContract{{ - file: "vmware_handlers.go", - requiredSnippets: []string{ - "monitoredSystemLimitDecisionForRecordsFromUsage(", - "monitoredSystemLimitDecisionForRecordsReplacementFromUsage(", - "SaveVMwareConfig(", - }, - requiredOrderings: [][2]string{ - {"h.enforceMonitoredSystemLimit(w, r, instance)", "instances = append(instances, instance)"}, - {"h.enforceMonitoredSystemLimitReplacement(w, r, instances[index], instance)", "instances[index] = instance"}, - }, - }}, - proofs: []proofContract{{ - file: "vmware_handlers_test.go", - snippets: []string{ - "TestVMwareHandlers_HandleAdd_BlocksProjectedNetNewSystemsAtLimit", - "TestVMwareHandlers_HandleUpdate_BlocksProjectedNetNewSystemsAtLimit", - "http.StatusPaymentRequired", - "license_required", - }, - }}, - }, - { - name: "agent deployment jobs", - routerSnippets: []string{ - `"/api/clusters/"`, - `"/api/agent-deploy/jobs/"`, - "r.deployHandlers.HandleCreateJob", - "r.deployHandlers.HandleRetryJob", - }, - sources: []sourceContract{{ - file: "deploy_handlers.go", - requiredSnippets: []string{ - "monitoredSystemLimitDecisionForAdditionalSlots(ctx, h.monitor, 0)", - `Reason: "skipped_license"`, - `"license_limit"`, - }, - requiredCounts: map[string]int{ - "monitoredSystemLimitDecisionForAdditionalSlots(ctx, h.monitor, 0)": 2, - }, - requiredOrderings: [][2]string{ - {"monitoredSystemLimitDecisionForAdditionalSlots(ctx, h.monitor, 0)", `Reason: "skipped_license"`}, - {"monitoredSystemLimitDecisionForAdditionalSlots(ctx, h.monitor, 0)", `"license_limit"`}, - }, - }}, - proofs: []proofContract{{ - file: "deploy_handlers_test.go", - snippets: []string{ - "TestHandleCreateJob_TruncatesTargetsToAvailableLicenseSlots", - "TestHandleRetryJob_BlocksWhenNoLicenseSlotsAvailable", - "skipped_license", - "license_limit", - }, - }}, - }, - } - - for _, surface := range surfaces { - t.Run(surface.name, func(t *testing.T) { - for _, snippet := range surface.routerSnippets { - requireContainsSnippet(t, router, snippet) - } - for _, contract := range surface.sources { - source := readAPIPackageFile(t, contract.file) - for _, snippet := range contract.requiredSnippets { - requireContainsSnippet(t, source, snippet) - } - for snippet, min := range contract.requiredCounts { - requireSnippetCountAtLeast(t, source, snippet, min) - } - for _, ordering := range contract.requiredOrderings { - requireSnippetBefore(t, source, ordering[0], ordering[1]) - } - } - for _, proof := range surface.proofs { - source := readAPIPackageFile(t, proof.file) - for _, snippet := range proof.snippets { - requireContainsSnippet(t, source, snippet) - } - } - }) - } -} - -func TestVMwareAdmissionEnforcementChecksUsageBeforeExternalInventory(t *testing.T) { - source := readAPIPackageFile(t, "vmware_handlers.go") - - addEnforcement := requireSourceSegment( - t, - source, - "func (h *VMwareHandlers) enforceMonitoredSystemLimit(", - "func (h *VMwareHandlers) enforceMonitoredSystemLimitReplacement(", - ) - requireSnippetBefore( - t, - addEnforcement, - "usage := monitoredSystemUsage(monitor)", - "records, invalidConfig, err := h.previewMonitoredSystemRecords(r.Context(), instance)", - ) - requireSnippetBefore( - t, - addEnforcement, - "if !usage.available", - "records, invalidConfig, err := h.previewMonitoredSystemRecords(r.Context(), instance)", - ) - - replacementEnforcement := requireSourceSegment( - t, - source, - "func (h *VMwareHandlers) enforceMonitoredSystemLimitReplacement(", - "func (h *VMwareHandlers) previewMonitoredSystemRecords(", - ) - requireSnippetBefore( - t, - replacementEnforcement, - "candidate := vmwareMonitoredSystemCandidate(next)", - "usage := monitoredSystemUsage(monitor)", - ) - requireSnippetBefore( - t, - replacementEnforcement, - "if !candidate.CountsTowardMonitoredSystems()", - "usage := monitoredSystemUsage(monitor)", - ) - requireSnippetBefore( - t, - replacementEnforcement, - "usage := monitoredSystemUsage(monitor)", - "records, invalidConfig, err := h.previewMonitoredSystemRecords(r.Context(), next)", - ) - requireSnippetBefore( - t, - replacementEnforcement, - "if !usage.available", - "records, invalidConfig, err := h.previewMonitoredSystemRecords(r.Context(), next)", - ) -} - -func TestVMwareAdmissionEnforcementSkipsUsageAndInventoryForDisabledConnections(t *testing.T) { - source := readAPIPackageFile(t, "vmware_handlers.go") - - addEnforcement := requireSourceSegment( - t, - source, - "func (h *VMwareHandlers) enforceMonitoredSystemLimit(", - "func (h *VMwareHandlers) enforceMonitoredSystemLimitReplacement(", - ) - requireSnippetBefore( - t, - addEnforcement, - "candidate := vmwareMonitoredSystemCandidate(instance)", - "usage := monitoredSystemUsage(monitor)", - ) - requireSnippetBefore( - t, - addEnforcement, - "if !candidate.CountsTowardMonitoredSystems()", - "usage := monitoredSystemUsage(monitor)", - ) - requireSnippetBefore( - t, - addEnforcement, - "if !candidate.CountsTowardMonitoredSystems()", - "records, invalidConfig, err := h.previewMonitoredSystemRecords(r.Context(), instance)", - ) - - replacementEnforcement := requireSourceSegment( - t, - source, - "func (h *VMwareHandlers) enforceMonitoredSystemLimitReplacement(", - "func (h *VMwareHandlers) previewMonitoredSystemRecords(", - ) - requireSnippetBefore( - t, - replacementEnforcement, - "candidate := vmwareMonitoredSystemCandidate(next)", - "usage := monitoredSystemUsage(monitor)", - ) - requireSnippetBefore( - t, - replacementEnforcement, - "if !candidate.CountsTowardMonitoredSystems()", - "usage := monitoredSystemUsage(monitor)", - ) - requireSnippetBefore( - t, - replacementEnforcement, - "if !candidate.CountsTowardMonitoredSystems()", - "records, invalidConfig, err := h.previewMonitoredSystemRecords(r.Context(), next)", - ) -} - -func TestOnboardingOverflowBonusSkipsUncappedPlans(t *testing.T) { - enforcement := readAPIPackageFile(t, "monitored_system_limit_enforcement.go") - overflowSegment := requireSourceSegment( - t, - enforcement, - "func maxMonitoredSystemsLimitForContext", - "\n}\n", - ) - requireSnippetBefore( - t, - overflowSegment, - "limit > 0", - "overflowBonusFromLicensing", - ) - - entitlements := readAPIPackageFile(t, "subscription_entitlements.go") - requireSnippetCountAtLeast( - t, - entitlements, - "if status.MaxMonitoredSystems > 0 {", - 2, - ) -} - -func TestMonitoredSystemCountNilMonitor(t *testing.T) { - got := monitoredSystemCount(nil) - if got != 0 { - t.Fatalf("expected 0 for nil monitor, got %d", got) - } -} - -func TestLegacyConnectionCountsFromReadState(t *testing.T) { - registry := unifiedresources.NewRegistry(nil) - registry.IngestRecords(unifiedresources.SourceProxmox, []unifiedresources.IngestRecord{ - { - SourceID: "pve-1", - Resource: unifiedresources.Resource{ - ID: "pve-1", - Name: "pve-1", - Type: unifiedresources.ResourceTypeAgent, - Status: unifiedresources.StatusOnline, - Proxmox: &unifiedresources.ProxmoxData{}, - }, - }, - { - SourceID: "pve-2", - Resource: unifiedresources.Resource{ - ID: "pve-2", - Name: "pve-2", - Type: unifiedresources.ResourceTypeAgent, - Status: unifiedresources.StatusOnline, - Proxmox: &unifiedresources.ProxmoxData{}, - Agent: &unifiedresources.AgentData{}, - }, - }, - }) - registry.IngestRecords(unifiedresources.SourceDocker, []unifiedresources.IngestRecord{ - { - SourceID: "docker-1", - Resource: unifiedresources.Resource{ - ID: "docker-1", - Name: "docker-1", - Type: unifiedresources.ResourceTypeAgent, - Status: unifiedresources.StatusOnline, - Docker: &unifiedresources.DockerData{}, - }, - }, - { - SourceID: "docker-2", - Resource: unifiedresources.Resource{ - ID: "docker-2", - Name: "docker-2", - Type: unifiedresources.ResourceTypeAgent, - Status: unifiedresources.StatusOnline, - Docker: &unifiedresources.DockerData{}, - Agent: &unifiedresources.AgentData{}, - }, - }, - }) - registry.IngestRecords(unifiedresources.SourceK8s, []unifiedresources.IngestRecord{ - { - SourceID: "k8s-1", - Resource: unifiedresources.Resource{ - ID: "k8s-1", - Name: "prod", - Type: unifiedresources.ResourceTypeK8sCluster, - Status: unifiedresources.StatusOnline, - Kubernetes: &unifiedresources.K8sData{AgentID: "legacy-k8s-1"}, - }, - }, - }) - - counts := legacyConnectionCountsFromReadState(unifiedresources.NewMonitorAdapter(registry)) - if counts.KubernetesClusters != 0 { - t.Fatalf("expected kubernetes_clusters=0, got %d", counts.KubernetesClusters) - } - if counts.ProxmoxNodes != 0 || counts.DockerHosts != 0 { - t.Fatalf("expected legacy connection counts to stay zero under monitored-system counting, got %+v", counts) - } -} - -func TestLegacyConnectionCountsUsesSnapshotFallback(t *testing.T) { - registry := unifiedresources.NewRegistry(nil) - registry.IngestRecords(unifiedresources.SourceProxmox, []unifiedresources.IngestRecord{ - { - SourceID: "pve-1", - Resource: unifiedresources.Resource{ - ID: "pve-1", - Name: "pve-1", - Type: unifiedresources.ResourceTypeAgent, - Status: unifiedresources.StatusOnline, - Proxmox: &unifiedresources.ProxmoxData{}, - }, - }, - }) - registry.IngestRecords(unifiedresources.SourceK8s, []unifiedresources.IngestRecord{ - { - SourceID: "k8s-1", - Resource: unifiedresources.Resource{ - ID: "k8s-1", - Name: "prod", - Type: unifiedresources.ResourceTypeK8sCluster, - Status: unifiedresources.StatusOnline, - Kubernetes: &unifiedresources.K8sData{AgentID: "legacy-k8s-1"}, - }, - }, - }) - - monitor := &monitoring.Monitor{} - monitor.SetResourceStore(unifiedresources.NewMonitorAdapter(registry)) - - counts := legacyConnectionCounts(monitor) - if counts.ProxmoxNodes != 0 || counts.DockerHosts != 0 || counts.KubernetesClusters != 0 { - t.Fatalf("expected legacy connection counts to stay zero, got %+v", counts) - } -} - -func TestHostReportTargetsExistingHostBridge(t *testing.T) { - snapshot := models.StateSnapshot{ - Hosts: []models.Host{{ID: "host-1"}}, - } - - t.Run("matches_existing", func(t *testing.T) { - report := agentshost.Report{ - Host: agentshost.HostInfo{ID: "host-1"}, - } - if !hostReportTargetsExistingHost(snapshot.Hosts, report, nil) { - t.Fatal("expected match by host ID") - } - }) - - t.Run("no_match_for_new_host", func(t *testing.T) { - report := agentshost.Report{ - Host: agentshost.HostInfo{ID: "host-new", Hostname: "new-server"}, - } - if hostReportTargetsExistingHost(snapshot.Hosts, report, nil) { - t.Fatal("expected no match for unknown host") - } - }) - - t.Run("token_record_forwarded", func(t *testing.T) { - snapshot := models.StateSnapshot{ - Hosts: []models.Host{{Hostname: "srv-1", TokenID: "token-a"}}, - } - report := agentshost.Report{ - Host: agentshost.HostInfo{Hostname: "srv-1"}, - } - token := &config.APITokenRecord{ID: "token-a"} - if !hostReportTargetsExistingHost(snapshot.Hosts, report, token) { - t.Fatal("expected match with matching token") - } - wrongToken := &config.APITokenRecord{ID: "token-b"} - if hostReportTargetsExistingHost(snapshot.Hosts, report, wrongToken) { - t.Fatal("expected no match with different token") - } - }) -} - -func TestDeployReservedCount(t *testing.T) { - // Nil counter returns 0. - SetDeployReservationCounter(nil) - if got := deployReservedCount(context.Background()); got != 0 { - t.Fatalf("expected 0 with nil counter, got %d", got) - } - - // Wired counter returns value. - SetDeployReservationCounter(func(_ context.Context) int { return 5 }) - t.Cleanup(func() { SetDeployReservationCounter(nil) }) - - if got := deployReservedCount(context.Background()); got != 5 { - t.Fatalf("expected 5, got %d", got) - } -} diff --git a/internal/api/monitored_system_usage.go b/internal/api/monitored_system_usage.go new file mode 100644 index 000000000..5763b5d87 --- /dev/null +++ b/internal/api/monitored_system_usage.go @@ -0,0 +1,57 @@ +package api + +import ( + "net/http" + "strings" + + "github.com/rcourtman/pulse-go-rewrite/internal/monitoring" + "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" +) + +func monitoredSystemCandidateStateFromEnabled( + enabled bool, +) unifiedresources.MonitoredSystemCandidateState { + if enabled { + return unifiedresources.MonitoredSystemCandidateStateActive + } + return unifiedresources.MonitoredSystemCandidateStateInactive +} + +type monitoredSystemUsageSnapshot struct { + count int + readState unifiedresources.ReadState + available bool + unavailableReason string +} + +func monitoredSystemUsage(monitor *monitoring.Monitor) monitoredSystemUsageSnapshot { + usage := monitor.MonitoredSystemUsage() + return monitoredSystemUsageSnapshot{ + count: usage.Count, + readState: usage.ReadState, + available: usage.Available, + unavailableReason: usage.UnavailableReason, + } +} + +func writeMonitoredSystemUsageUnavailable(w http.ResponseWriter, reason string) { + details := map[string]string{} + if trimmed := strings.TrimSpace(reason); trimmed != "" { + details["reason"] = trimmed + } + writeErrorResponse( + w, + http.StatusServiceUnavailable, + "monitored_system_usage_unavailable", + "Unable to verify monitored-system inventory right now", + details, + ) +} + +func legacyConnectionCounts(monitor *monitoring.Monitor) legacyConnectionCountsModel { + return legacyConnectionCountsModel{} +} + +func legacyConnectionCountsFromReadState(rs unifiedresources.ReadState) legacyConnectionCountsModel { + return legacyConnectionCountsModel{} +} diff --git a/internal/api/payments_webhook_handlers.go b/internal/api/payments_webhook_handlers.go index 4dfdc7640..1e3f39884 100644 --- a/internal/api/payments_webhook_handlers.go +++ b/internal/api/payments_webhook_handlers.go @@ -293,11 +293,10 @@ func (h *StripeWebhookHandlers) handleCheckoutSessionCompleted(ctx context.Conte } planVersion := derivePlanVersion(session.Metadata, "") - limits, _ := limitsForCloudPlanFromLicensing(planVersion) state := &billingState{ Capabilities: cloudCapabilitiesFromLicensing(), - Limits: limits, + Limits: map[string]int64{}, MetersEnabled: []string{}, PlanVersion: planVersion, SubscriptionState: subscriptionStateActiveValue, @@ -389,8 +388,7 @@ func (h *StripeWebhookHandlers) handleSubscriptionUpdated(ctx context.Context, s if shouldGrantPaidCapabilities(subState) { state.Capabilities = cloudCapabilitiesFromLicensing() - limits, _ := limitsForCloudPlanFromLicensing(state.PlanVersion) - state.Limits = limits + state.Limits = map[string]int64{} } else { state.Capabilities = []string{} state.Limits = map[string]int64{} diff --git a/internal/api/router.go b/internal/api/router.go index 5ad3dde23..dd4774b5b 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -433,8 +433,6 @@ func (r *Router) setupRoutes() { }) // Wire license service provider so middleware can access per-tenant license services SetLicenseServiceProvider(r.licenseHandlers) - // Wire base data dir for overflow enforcement (reads OverflowGrantedAt from billing state). - SetOverflowBaseDataDir(r.config.DataPath) r.reportingHandlers = NewReportingHandlers(r.mtMonitor, recoveryManager) r.logHandlers = NewLogHandlers(r.config, r.persistence) rbacHandlers := NewRBACHandlers(r.config, rbacProvider) @@ -531,30 +529,7 @@ func (r *Router) setupRoutes() { if deployStore != nil { r.deployStore = deployStore if r.monitor != nil { - reservation := deploy.NewReservationManager() - r.deployHandlers = NewDeployHandlers(deployStore, r.monitor, r.agentExecServer, reservation, r.resolvePublicURL, r.config, r.persistence) - - SetDeployReservationCounter(func(ctx context.Context) int { - orgID := GetOrgID(ctx) - if orgID == "" { - orgID = "default" - } - return reservation.ReservedForOrg(orgID) - }) - - // Periodic cleanup of expired reservations. - go func() { - ticker := time.NewTicker(5 * time.Minute) - defer ticker.Stop() - for { - select { - case <-r.lifecycleCtx.Done(): - return - case <-ticker.C: - reservation.CleanExpired() - } - } - }() + r.deployHandlers = NewDeployHandlers(deployStore, r.monitor, r.agentExecServer, r.resolvePublicURL, r.config, r.persistence) } } diff --git a/internal/api/stripe_webhook_handlers_additional_test.go b/internal/api/stripe_webhook_handlers_additional_test.go index e9df00f5a..79959b3eb 100644 --- a/internal/api/stripe_webhook_handlers_additional_test.go +++ b/internal/api/stripe_webhook_handlers_additional_test.go @@ -109,8 +109,8 @@ func TestStripeWebhook_SubscriptionUpdated_BackfillsIndexAndAppliesState(t *test if state.PlanVersion != "stripe_price:price_gold" { t.Fatalf("plan_version=%q, want %q", state.PlanVersion, "stripe_price:price_gold") } - if got := state.Limits["max_monitored_systems"]; got != 10 { - t.Fatalf("limits[max_monitored_systems]=%d, want %d", got, 10) + if _, ok := state.Limits["max_monitored_systems"]; ok { + t.Fatalf("expected retired max_monitored_systems limit to be omitted, got %+v", state.Limits) } if state.StripeSubscriptionID != "sub_new" { t.Fatalf("stripe_subscription_id=%q, want %q", state.StripeSubscriptionID, "sub_new") diff --git a/internal/api/stripe_webhook_handlers_test.go b/internal/api/stripe_webhook_handlers_test.go index 973c12ece..305a47017 100644 --- a/internal/api/stripe_webhook_handlers_test.go +++ b/internal/api/stripe_webhook_handlers_test.go @@ -266,8 +266,8 @@ func TestStripeWebhook_CheckoutCompleted_IdempotentProvisioning(t *testing.T) { if state.PlanVersion != "cloud_starter" { t.Fatalf("plan_version=%q, want %q", state.PlanVersion, "cloud_starter") } - if got := state.Limits["max_monitored_systems"]; got != 10 { - t.Fatalf("limits[max_monitored_systems]=%d, want %d", got, 10) + if _, ok := state.Limits["max_monitored_systems"]; ok { + t.Fatalf("expected retired max_monitored_systems limit to be omitted, got %+v", state.Limits) } if state.StripeCustomerID != "cus_abc" { t.Fatalf("stripe_customer_id=%q, want %q", state.StripeCustomerID, "cus_abc") diff --git a/internal/api/subscription_entitlements.go b/internal/api/subscription_entitlements.go index 9c7b4de5b..9fc3ea76e 100644 --- a/internal/api/subscription_entitlements.go +++ b/internal/api/subscription_entitlements.go @@ -40,16 +40,8 @@ func (h *LicenseHandlers) buildCommercialEntitlementPayload( usage := h.entitlementUsageSnapshot(ctx) trialEndsAtUnix := trialEndsAtUnixFromService(svc) - // Onboarding overflow: +1 agent for 14 days on free tier. Only applies - // on plans that have an actual cap — adding the bonus to an uncapped - // limit (0) would surface as a cap of 1 to the UI. overflowGrantedAt := h.ensureOnboardingOverflow(ctx, status.Tier) now := time.Now() - if status.MaxMonitoredSystems > 0 { - if bonus := overflowBonusFromLicensing(status.Tier, overflowGrantedAt, now); bonus > 0 { - status.MaxMonitoredSystems += bonus - } - } payload := buildEntitlementPayloadWithUsage( status, @@ -129,14 +121,6 @@ func (h *LicenseHandlers) HandleRuntimeCapabilities(w http.ResponseWriter, r *ht status := svc.Status() usage := h.entitlementUsageSnapshot(r.Context()) - overflowGrantedAt := h.ensureOnboardingOverflow(r.Context(), status.Tier) - now := time.Now() - if status.MaxMonitoredSystems > 0 { - if bonus := overflowBonusFromLicensing(status.Tier, overflowGrantedAt, now); bonus > 0 { - status.MaxMonitoredSystems += bonus - } - } - payload := buildRuntimeCapabilitiesPayloadWithUsage( status, h.payloadSubscriptionStateForService(svc), @@ -177,9 +161,8 @@ func trialEndsAtUnixFromService(svc *licenseService) *int64 { type entitlementUsageSnapshot = entitlementUsageSnapshotModel -// entitlementUsageSnapshot returns best-effort runtime usage counts for limits. -// The monitored-system cap applies to canonical top-level monitored systems -// regardless of collection path. +// entitlementUsageSnapshot returns best-effort runtime usage counts for +// entitlement payloads and billing/support context. func (h *LicenseHandlers) entitlementUsageSnapshot(ctx context.Context) entitlementUsageSnapshot { usage := entitlementUsageSnapshot{} if h == nil { diff --git a/internal/api/truenas_handlers.go b/internal/api/truenas_handlers.go index cef3950c9..4e3761c90 100644 --- a/internal/api/truenas_handlers.go +++ b/internal/api/truenas_handlers.go @@ -73,10 +73,6 @@ func (h *TrueNASHandlers) HandleAdd(w http.ResponseWriter, r *http.Request) { return } - if h.enforceMonitoredSystemLimit(w, r, instance) { - return - } - existing, err := persistence.LoadTrueNASConfig() if err != nil { writeErrorResponse(w, http.StatusInternalServerError, "truenas_load_failed", "Failed to load TrueNAS configuration", map[string]string{"error": err.Error()}) @@ -241,10 +237,6 @@ func (h *TrueNASHandlers) HandleUpdate(w http.ResponseWriter, r *http.Request) { writeErrorResponse(w, http.StatusBadRequest, "validation_error", err.Error(), nil) return } - if h.enforceMonitoredSystemLimitReplacement(w, r, instances[index], instance) { - return - } - instances[index] = instance if err := persistence.SaveTrueNASConfig(instances); err != nil { writeErrorResponse(w, http.StatusInternalServerError, "truenas_save_failed", "Failed to save TrueNAS configuration", map[string]string{"error": err.Error()}) @@ -286,7 +278,7 @@ func (h *TrueNASHandlers) HandlePreviewConnection(w http.ResponseWriter, r *http return } - writeJSON(w, http.StatusOK, monitoredSystemLedgerPreviewResponse(r.Context(), false, preview).NormalizeCollections()) + writeJSON(w, http.StatusOK, monitoredSystemLedgerPreviewResponse(false, preview).NormalizeCollections()) } // HandlePreviewSavedConnection projects the monitored-system impact of an edit @@ -360,7 +352,7 @@ func (h *TrueNASHandlers) HandlePreviewSavedConnection(w http.ResponseWriter, r return } - writeJSON(w, http.StatusOK, monitoredSystemLedgerPreviewResponse(r.Context(), true, preview).NormalizeCollections()) + writeJSON(w, http.StatusOK, monitoredSystemLedgerPreviewResponse(true, preview).NormalizeCollections()) return } @@ -592,59 +584,6 @@ func (h *TrueNASHandlers) persistenceForRequest(w http.ResponseWriter, ctx conte return persistence } -func (h *TrueNASHandlers) enforceMonitoredSystemLimit( - w http.ResponseWriter, - r *http.Request, - instance config.TrueNASInstance, -) bool { - var monitor *monitoring.Monitor - if h != nil && h.getMonitor != nil { - monitor = h.getMonitor(r.Context()) - } - - decision := monitoredSystemLimitDecisionForCandidate(r.Context(), monitor, trueNASMonitoredSystemCandidate(instance)) - if !decision.usageAvailable { - writeMonitoredSystemUsageUnavailable(w, decision.usageUnavailableReason) - return true - } - if !decision.exceeded { - return false - } - - writeMaxMonitoredSystemsLimitExceeded(w, decision) - return true -} - -func (h *TrueNASHandlers) enforceMonitoredSystemLimitReplacement( - w http.ResponseWriter, - r *http.Request, - current config.TrueNASInstance, - next config.TrueNASInstance, -) bool { - var monitor *monitoring.Monitor - if h != nil && h.getMonitor != nil { - monitor = h.getMonitor(r.Context()) - } - - replacementHost := pulseTokenHostCandidate(current.Host) - decision := monitoredSystemLimitDecisionForCandidateReplacement(r.Context(), monitor, unifiedresources.MonitoredSystemReplacement{ - Source: unifiedresources.SourceTrueNAS, - Selector: unifiedresources.MonitoredSystemReplacementSelector{ - Hostname: replacementHost, - }, - }, trueNASMonitoredSystemCandidate(next)) - if !decision.usageAvailable { - writeMonitoredSystemUsageUnavailable(w, decision.usageUnavailableReason) - return true - } - if !decision.exceeded { - return false - } - - writeMaxMonitoredSystemsLimitExceeded(w, decision) - return true -} - func trueNASMonitoredSystemCandidate(instance config.TrueNASInstance) unifiedresources.MonitoredSystemCandidate { return unifiedresources.MonitoredSystemCandidate{ Source: unifiedresources.SourceTrueNAS, diff --git a/internal/api/truenas_handlers_test.go b/internal/api/truenas_handlers_test.go index ec37b4586..b225c84db 100644 --- a/internal/api/truenas_handlers_test.go +++ b/internal/api/truenas_handlers_test.go @@ -159,35 +159,16 @@ func TestTrueNASHandlers_HandleAdd_BlocksNewCountedSystemAtLimit(t *testing.T) { rec := httptest.NewRecorder() handler.HandleAdd(rec, req) - if rec.Code != http.StatusPaymentRequired { - t.Fatalf("expected 402 once monitored-system cap is full, got %d: %s", rec.Code, rec.Body.String()) - } - payload := decodeMonitoredSystemLimitBlockedPayload(t, rec.Body.Bytes()) - if payload.Error != "license_required" { - t.Fatalf("error=%q, want license_required", payload.Error) - } - if payload.Feature != maxMonitoredSystemsLicenseGateKey { - t.Fatalf("feature=%q, want %q", payload.Feature, maxMonitoredSystemsLicenseGateKey) - } - if !payload.MonitoredSystemPreview.WouldExceedLimit { - t.Fatalf("expected monitored_system_preview.would_exceed_limit=true, got %+v", payload.MonitoredSystemPreview) - } - if payload.MonitoredSystemPreview.Effect != "creates_new" { - t.Fatalf("effect=%q, want creates_new", payload.MonitoredSystemPreview.Effect) - } - if payload.MonitoredSystemPreview.AdditionalCount != 1 { - t.Fatalf("additional_count=%d, want 1", payload.MonitoredSystemPreview.AdditionalCount) - } - if len(payload.MonitoredSystemPreview.ProjectedSystems) != 1 { - t.Fatalf("len(projected_systems)=%d, want 1", len(payload.MonitoredSystemPreview.ProjectedSystems)) + if rec.Code != http.StatusCreated { + t.Fatalf("expected 201 with monitored-system caps retired, got %d: %s", rec.Code, rec.Body.String()) } stored, err := persistence.LoadTrueNASConfig() if err != nil { t.Fatalf("load persisted config: %v", err) } - if len(stored) != 1 { - t.Fatalf("expected blocked TrueNAS add not to persist, got %d instances", len(stored)) + if len(stored) != 2 { + t.Fatalf("expected TrueNAS add to persist with monitored-system caps retired, got %d instances", len(stored)) } } @@ -222,14 +203,16 @@ func TestTrueNASHandlers_HandleAdd_ReturnsUnavailableWhenSupplementalInventoryNo rec := httptest.NewRecorder() handler.HandleAdd(rec, req) - assertMonitoredSystemUsageUnavailableReason(t, rec, tc.reason) + if rec.Code != http.StatusCreated { + t.Fatalf("expected 201 with monitored-system caps retired, got %d: %s", rec.Code, rec.Body.String()) + } stored, err := persistence.LoadTrueNASConfig() if err != nil { t.Fatalf("load truenas config: %v", err) } - if len(stored) != 0 { - t.Fatalf("expected unavailable monitored-system usage not to persist add, got %d connections", len(stored)) + if len(stored) != 1 { + t.Fatalf("expected add to persist with monitored-system caps retired, got %d connections", len(stored)) } }) } @@ -716,32 +699,16 @@ func TestTrueNASHandlers_HandleUpdate_BlocksProjectedNetNewSystemAtLimit(t *test rec := httptest.NewRecorder() handler.HandleUpdate(rec, req) - if rec.Code != http.StatusPaymentRequired { - t.Fatalf("expected 402 when update would add a new monitored system, got %d: %s", rec.Code, rec.Body.String()) - } - payload := decodeMonitoredSystemLimitBlockedPayload(t, rec.Body.Bytes()) - if !payload.MonitoredSystemPreview.WouldExceedLimit { - t.Fatalf("expected monitored_system_preview.would_exceed_limit=true, got %+v", payload.MonitoredSystemPreview) - } - if payload.MonitoredSystemPreview.Effect != "splits_existing" { - t.Fatalf("effect=%q, want splits_existing", payload.MonitoredSystemPreview.Effect) - } - if payload.MonitoredSystemPreview.AdditionalCount != 1 { - t.Fatalf("additional_count=%d, want 1", payload.MonitoredSystemPreview.AdditionalCount) - } - if len(payload.MonitoredSystemPreview.CurrentSystems) != 1 { - t.Fatalf("len(current_systems)=%d, want 1", len(payload.MonitoredSystemPreview.CurrentSystems)) - } - if len(payload.MonitoredSystemPreview.ProjectedSystems) != 1 { - t.Fatalf("len(projected_systems)=%d, want 1", len(payload.MonitoredSystemPreview.ProjectedSystems)) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200 with monitored-system caps retired, got %d: %s", rec.Code, rec.Body.String()) } stored, err := persistence.LoadTrueNASConfig() if err != nil { t.Fatalf("load persisted config: %v", err) } - if stored[0].Host != "archive.local" { - t.Fatalf("expected blocked update to preserve original host, got %+v", stored[0]) + if stored[0].Host != "backup.local" { + t.Fatalf("expected update to persist with monitored-system caps retired, got %+v", stored[0]) } } @@ -790,14 +757,16 @@ func TestTrueNASHandlers_HandleUpdate_ReturnsUnavailableWhenSupplementalInventor rec := httptest.NewRecorder() handler.HandleUpdate(rec, req) - assertMonitoredSystemUsageUnavailableReason(t, rec, tc.reason) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200 with monitored-system caps retired, got %d: %s", rec.Code, rec.Body.String()) + } stored, err := persistence.LoadTrueNASConfig() if err != nil { t.Fatalf("load truenas config: %v", err) } - if len(stored) != 1 || stored[0].Host != "archive.local" { - t.Fatalf("expected unavailable monitored-system usage to preserve stored connection, got %+v", stored) + if len(stored) != 1 || stored[0].Host != "backup.local" { + t.Fatalf("expected update to persist with monitored-system caps retired, got %+v", stored) } }) } diff --git a/internal/api/unified_agent_handlers_test.go b/internal/api/unified_agent_handlers_test.go index 908af47df..90265a02d 100644 --- a/internal/api/unified_agent_handlers_test.go +++ b/internal/api/unified_agent_handlers_test.go @@ -98,7 +98,7 @@ func TestUnifiedAgentHandlers_HandleReport(t *testing.T) { } } -func TestUnifiedAgentHandlers_HandleReport_EnforcesMaxMonitoredSystemsForNewHostsOnly(t *testing.T) { +func TestUnifiedAgentHandlers_HandleReport_AllowsNewHostsWithCapsRetired(t *testing.T) { setMaxMonitoredSystemsLicenseForTests(t, 1) handler, monitor := newUnifiedAgentHandlers(t, nil) @@ -107,7 +107,7 @@ func TestUnifiedAgentHandlers_HandleReport_EnforcesMaxMonitoredSystemsForNewHost t.Fatalf("expected seeded host ID") } - // Existing host should continue to report at the limit. + // Existing host should continue to report with monitored-system caps retired. existingReport := agentshost.Report{ Agent: agentshost.AgentInfo{ ID: "agent-1", @@ -128,7 +128,7 @@ func TestUnifiedAgentHandlers_HandleReport_EnforcesMaxMonitoredSystemsForNewHost t.Fatalf("existing host report should pass at limit, got %d: %s", existingRec.Code, existingRec.Body.String()) } - // New host should be blocked. + // New host should also be accepted because monitored-system caps are retired. newReport := agentshost.Report{ Agent: agentshost.AgentInfo{ ID: "agent-2", @@ -145,12 +145,12 @@ func TestUnifiedAgentHandlers_HandleReport_EnforcesMaxMonitoredSystemsForNewHost newReq := httptest.NewRequest(http.MethodPost, "/api/agents/agent/report", bytes.NewReader(newBody)) newRec := httptest.NewRecorder() handler.HandleReport(newRec, newReq) - if newRec.Code != http.StatusPaymentRequired { - t.Fatalf("new host should be blocked at limit, got %d: %s", newRec.Code, newRec.Body.String()) + if newRec.Code != http.StatusOK { + t.Fatalf("new host should be accepted with monitored-system caps retired, got %d: %s", newRec.Code, newRec.Body.String()) } } -func TestUnifiedAgentHandlers_HandleReport_PreservesExistingHostLimitContinuityAcrossRestart(t *testing.T) { +func TestUnifiedAgentHandlers_HandleReport_AcceptsNewHostAcrossRestartWithCapsRetired(t *testing.T) { setMaxMonitoredSystemsLicenseForTests(t, 1) cfg := &config.Config{DataPath: t.TempDir()} @@ -191,7 +191,7 @@ func TestUnifiedAgentHandlers_HandleReport_PreservesExistingHostLimitContinuityA restartReport.Timestamp = report.Timestamp.Add(30 * time.Second) restartRec := postReport(t, restartedHandler, restartReport) if restartRec.Code != http.StatusOK { - t.Fatalf("existing host should still report after restart at limit, got %d: %s", restartRec.Code, restartRec.Body.String()) + t.Fatalf("existing host should still report after restart with monitored-system caps retired, got %d: %s", restartRec.Code, restartRec.Body.String()) } newReport := agentshost.Report{ @@ -208,8 +208,8 @@ func TestUnifiedAgentHandlers_HandleReport_PreservesExistingHostLimitContinuityA Timestamp: time.Now().UTC(), } newRec := postReport(t, restartedHandler, newReport) - if newRec.Code != http.StatusPaymentRequired { - t.Fatalf("new host should still be blocked at limit after restart, got %d: %s", newRec.Code, newRec.Body.String()) + if newRec.Code != http.StatusOK { + t.Fatalf("new host should still be accepted after restart with monitored-system caps retired, got %d: %s", newRec.Code, newRec.Body.String()) } } diff --git a/internal/api/vmware_handlers.go b/internal/api/vmware_handlers.go index f27bd0345..db4f20823 100644 --- a/internal/api/vmware_handlers.go +++ b/internal/api/vmware_handlers.go @@ -81,10 +81,6 @@ func (h *VMwareHandlers) HandleAdd(w http.ResponseWriter, r *http.Request) { if persistence == nil { return } - if h.enforceMonitoredSystemLimit(w, r, instance) { - return - } - instances, err := persistence.LoadVMwareConfig() if err != nil { writeErrorResponse(w, http.StatusInternalServerError, "vmware_load_failed", "Failed to load VMware configuration", map[string]string{"error": err.Error()}) @@ -255,10 +251,6 @@ func (h *VMwareHandlers) HandleUpdate(w http.ResponseWriter, r *http.Request) { writeErrorResponse(w, http.StatusBadRequest, "validation_error", err.Error(), nil) return } - if h.enforceMonitoredSystemLimitReplacement(w, r, instances[index], instance) { - return - } - instances[index] = instance if err := persistence.SaveVMwareConfig(instances); err != nil { writeErrorResponse(w, http.StatusInternalServerError, "vmware_save_failed", "Failed to save VMware configuration", map[string]string{"error": err.Error()}) @@ -296,7 +288,7 @@ func (h *VMwareHandlers) HandlePreviewConnection(w http.ResponseWriter, r *http. candidate := vmwareMonitoredSystemCandidate(instance) if !candidate.CountsTowardMonitoredSystems() { preview := unifiedresources.PreviewMonitoredSystemCandidate(usage.readState, candidate) - writeJSON(w, http.StatusOK, monitoredSystemLedgerPreviewResponse(r.Context(), false, preview).NormalizeCollections()) + writeJSON(w, http.StatusOK, monitoredSystemLedgerPreviewResponse(false, preview).NormalizeCollections()) return } @@ -314,7 +306,7 @@ func (h *VMwareHandlers) HandlePreviewConnection(w http.ResponseWriter, r *http. return } - writeJSON(w, http.StatusOK, monitoredSystemLedgerPreviewResponse(r.Context(), false, preview).NormalizeCollections()) + writeJSON(w, http.StatusOK, monitoredSystemLedgerPreviewResponse(false, preview).NormalizeCollections()) } // HandlePreviewSavedConnection projects the monitored-system impact of editing @@ -384,7 +376,7 @@ func (h *VMwareHandlers) HandlePreviewSavedConnection(w http.ResponseWriter, r * replacement, candidate, ) - writeJSON(w, http.StatusOK, monitoredSystemLedgerPreviewResponse(r.Context(), true, preview).NormalizeCollections()) + writeJSON(w, http.StatusOK, monitoredSystemLedgerPreviewResponse(true, preview).NormalizeCollections()) return } @@ -406,7 +398,7 @@ func (h *VMwareHandlers) HandlePreviewSavedConnection(w http.ResponseWriter, r * return } - writeJSON(w, http.StatusOK, monitoredSystemLedgerPreviewResponse(r.Context(), true, preview).NormalizeCollections()) + writeJSON(w, http.StatusOK, monitoredSystemLedgerPreviewResponse(true, preview).NormalizeCollections()) return } @@ -670,99 +662,6 @@ func (h *VMwareHandlers) monitorForRequest(ctx context.Context) *monitoring.Moni return h.getMonitor(ctx) } -func (h *VMwareHandlers) enforceMonitoredSystemLimit( - w http.ResponseWriter, - r *http.Request, - instance config.VMwareVCenterInstance, -) bool { - candidate := vmwareMonitoredSystemCandidate(instance) - if !candidate.CountsTowardMonitoredSystems() { - return false - } - - limit := maxMonitoredSystemsLimitForContext(r.Context()) - if limit <= 0 { - return false - } - - monitor := h.monitorForRequest(r.Context()) - usage := monitoredSystemUsage(monitor) - if !usage.available { - writeMonitoredSystemUsageUnavailable(w, usage.unavailableReason) - return true - } - - records, invalidConfig, err := h.previewMonitoredSystemRecords(r.Context(), instance) - if err != nil { - h.writeConnectionFailure(w, invalidConfig, err) - return true - } - - decision := monitoredSystemLimitDecisionForRecordsFromUsage(r.Context(), limit, usage, map[unifiedresources.DataSource][]unifiedresources.IngestRecord{ - unifiedresources.SourceVMware: records, - }) - if !decision.usageAvailable { - writeMonitoredSystemUsageUnavailable(w, decision.usageUnavailableReason) - return true - } - if !decision.exceeded { - return false - } - - writeMaxMonitoredSystemsLimitExceeded(w, decision) - return true -} - -func (h *VMwareHandlers) enforceMonitoredSystemLimitReplacement( - w http.ResponseWriter, - r *http.Request, - current config.VMwareVCenterInstance, - next config.VMwareVCenterInstance, -) bool { - candidate := vmwareMonitoredSystemCandidate(next) - if !candidate.CountsTowardMonitoredSystems() { - return false - } - - limit := maxMonitoredSystemsLimitForContext(r.Context()) - if limit <= 0 { - return false - } - - monitor := h.monitorForRequest(r.Context()) - usage := monitoredSystemUsage(monitor) - if !usage.available { - writeMonitoredSystemUsageUnavailable(w, usage.unavailableReason) - return true - } - - records, invalidConfig, err := h.previewMonitoredSystemRecords(r.Context(), next) - if err != nil { - h.writeConnectionFailure(w, invalidConfig, err) - return true - } - - replacementID := strings.TrimSpace(current.ID) - decision := monitoredSystemLimitDecisionForRecordsReplacementFromUsage(r.Context(), limit, usage, unifiedresources.MonitoredSystemReplacement{ - Source: unifiedresources.SourceVMware, - Selector: unifiedresources.MonitoredSystemReplacementSelector{ - ResourceID: replacementID, - }, - }, map[unifiedresources.DataSource][]unifiedresources.IngestRecord{ - unifiedresources.SourceVMware: records, - }) - if !decision.usageAvailable { - writeMonitoredSystemUsageUnavailable(w, decision.usageUnavailableReason) - return true - } - if !decision.exceeded { - return false - } - - writeMaxMonitoredSystemsLimitExceeded(w, decision) - return true -} - func (h *VMwareHandlers) previewMonitoredSystemRecords( ctx context.Context, instance config.VMwareVCenterInstance, diff --git a/internal/api/vmware_handlers_test.go b/internal/api/vmware_handlers_test.go index f1eaf3310..d8c1e00aa 100644 --- a/internal/api/vmware_handlers_test.go +++ b/internal/api/vmware_handlers_test.go @@ -187,35 +187,16 @@ func TestVMwareHandlers_HandleAdd_BlocksProjectedNetNewSystemsAtLimit(t *testing rec := httptest.NewRecorder() handler.HandleAdd(rec, req) - if rec.Code != http.StatusPaymentRequired { - t.Fatalf("expected 402 once projected VMware inventory exceeds the cap, got %d: %s", rec.Code, rec.Body.String()) - } - payload := decodeMonitoredSystemLimitBlockedPayload(t, rec.Body.Bytes()) - if payload.Error != "license_required" { - t.Fatalf("error=%q, want license_required", payload.Error) - } - if payload.Feature != maxMonitoredSystemsLicenseGateKey { - t.Fatalf("feature=%q, want %q", payload.Feature, maxMonitoredSystemsLicenseGateKey) - } - if !payload.MonitoredSystemPreview.WouldExceedLimit { - t.Fatalf("expected monitored_system_preview.would_exceed_limit=true, got %+v", payload.MonitoredSystemPreview) - } - if payload.MonitoredSystemPreview.Effect != "creates_new" { - t.Fatalf("effect=%q, want creates_new", payload.MonitoredSystemPreview.Effect) - } - if payload.MonitoredSystemPreview.AdditionalCount != 1 { - t.Fatalf("additional_count=%d, want 1", payload.MonitoredSystemPreview.AdditionalCount) - } - if len(payload.MonitoredSystemPreview.ProjectedSystems) != 1 { - t.Fatalf("len(projected_systems)=%d, want 1", len(payload.MonitoredSystemPreview.ProjectedSystems)) + if rec.Code != http.StatusCreated { + t.Fatalf("expected 201 with monitored-system caps retired, got %d: %s", rec.Code, rec.Body.String()) } stored, err := persistence.LoadVMwareConfig() if err != nil { t.Fatalf("load vmware config: %v", err) } - if len(stored) != 0 { - t.Fatalf("expected blocked VMware add not to persist, got %d connections", len(stored)) + if len(stored) != 1 { + t.Fatalf("expected VMware add to persist with monitored-system caps retired, got %d connections", len(stored)) } } @@ -261,17 +242,19 @@ func TestVMwareHandlers_HandleAdd_ReturnsUnavailableBeforePreviewingInventory(t rec := httptest.NewRecorder() handler.HandleAdd(rec, req) - assertMonitoredSystemUsageUnavailableReason(t, rec, tc.reason) + if rec.Code != http.StatusCreated { + t.Fatalf("expected 201 with monitored-system caps retired, got %d: %s", rec.Code, rec.Body.String()) + } if previewRecordsCalled { - t.Fatal("expected VMware add not to preview external inventory while monitored-system usage is unavailable") + t.Fatal("expected VMware add not to preview external inventory when monitored-system caps are retired") } stored, err := persistence.LoadVMwareConfig() if err != nil { t.Fatalf("load vmware config: %v", err) } - if len(stored) != 0 { - t.Fatalf("expected unavailable monitored-system usage not to persist add, got %d connections", len(stored)) + if len(stored) != 1 { + t.Fatalf("expected add to persist with monitored-system caps retired, got %d connections", len(stored)) } }) } @@ -838,32 +821,16 @@ func TestVMwareHandlers_HandleUpdate_BlocksProjectedNetNewSystemsAtLimit(t *test rec := httptest.NewRecorder() handler.HandleUpdate(rec, req) - if rec.Code != http.StatusPaymentRequired { - t.Fatalf("expected 402 when update would add a new monitored system, got %d: %s", rec.Code, rec.Body.String()) - } - payload := decodeMonitoredSystemLimitBlockedPayload(t, rec.Body.Bytes()) - if !payload.MonitoredSystemPreview.WouldExceedLimit { - t.Fatalf("expected monitored_system_preview.would_exceed_limit=true, got %+v", payload.MonitoredSystemPreview) - } - if payload.MonitoredSystemPreview.Effect != "splits_existing" { - t.Fatalf("effect=%q, want splits_existing", payload.MonitoredSystemPreview.Effect) - } - if payload.MonitoredSystemPreview.AdditionalCount != 1 { - t.Fatalf("additional_count=%d, want 1", payload.MonitoredSystemPreview.AdditionalCount) - } - if len(payload.MonitoredSystemPreview.CurrentSystems) != 1 { - t.Fatalf("len(current_systems)=%d, want 1", len(payload.MonitoredSystemPreview.CurrentSystems)) - } - if len(payload.MonitoredSystemPreview.ProjectedSystems) != 1 { - t.Fatalf("len(projected_systems)=%d, want 1", len(payload.MonitoredSystemPreview.ProjectedSystems)) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200 with monitored-system caps retired, got %d: %s", rec.Code, rec.Body.String()) } stored, err := persistence.LoadVMwareConfig() if err != nil { t.Fatalf("load persisted config: %v", err) } - if stored[0].Host != "vc-a.lab.local" { - t.Fatalf("expected blocked update to preserve original host, got %+v", stored[0]) + if stored[0].Host != "vc-b.lab.local" { + t.Fatalf("expected update to persist with monitored-system caps retired, got %+v", stored[0]) } } @@ -922,17 +889,19 @@ func TestVMwareHandlers_HandleUpdate_ReturnsUnavailableBeforePreviewingInventory rec := httptest.NewRecorder() handler.HandleUpdate(rec, req) - assertMonitoredSystemUsageUnavailableReason(t, rec, tc.reason) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200 with monitored-system caps retired, got %d: %s", rec.Code, rec.Body.String()) + } if previewRecordsCalled { - t.Fatal("expected VMware update not to preview external inventory while monitored-system usage is unavailable") + t.Fatal("expected VMware update not to preview external inventory when monitored-system caps are retired") } stored, err := persistence.LoadVMwareConfig() if err != nil { t.Fatalf("load vmware config: %v", err) } - if len(stored) != 1 || stored[0].Host != "vc-a.lab.local" { - t.Fatalf("expected unavailable monitored-system usage to preserve stored connection, got %+v", stored) + if len(stored) != 1 || stored[0].Host != "vc-b.lab.local" { + t.Fatalf("expected update to persist with monitored-system caps retired, got %+v", stored) } }) } @@ -1202,9 +1171,6 @@ func TestVMwareHandlers_HandlePreviewConnection_ReturnsCanonicalMultiSystemImpac if preview.CurrentCount != 1 || preview.ProjectedCount != 2 || preview.AdditionalCount != 1 { t.Fatalf("unexpected preview counts: %+v", preview) } - if !preview.WouldExceedLimit { - t.Fatalf("expected preview to report limit overrun, got %+v", preview) - } if len(preview.CurrentSystems) != 1 { t.Fatalf("len(CurrentSystems) = %d, want 1", len(preview.CurrentSystems)) } diff --git a/internal/cloudcp/entitlements/service.go b/internal/cloudcp/entitlements/service.go index b14d65b7d..20950347d 100644 --- a/internal/cloudcp/entitlements/service.go +++ b/internal/cloudcp/entitlements/service.go @@ -12,7 +12,6 @@ import ( "github.com/golang-jwt/jwt/v5" "github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/registry" pkglicensing "github.com/rcourtman/pulse-go-rewrite/pkg/licensing" - "github.com/rs/zerolog/log" ) const defaultCloudPlanVersion = "cloud_starter" @@ -344,15 +343,6 @@ func tenantSubscriptionState(tenant *registry.Tenant) pkglicensing.SubscriptionS } func buildPaidEntitlementLeaseClaims(ctx *tenantLeaseContext, instanceHost string, now time.Time) pkglicensing.EntitlementLeaseClaims { - limits, known := pkglicensing.LimitsForCloudPlan(ctx.planVersion) - if !known && ctx.tenant != nil { - log.Warn(). - Str("tenant_id", ctx.tenant.ID). - Str("plan_version", ctx.planVersion). - Int64("default_max_monitored_systems", limits["max_monitored_systems"]). - Msg("Unknown plan version during hosted entitlement lease build; applying safe default monitored-system limit") - } - var capabilities []string if pkglicensing.ShouldGrantPaidCapabilities(ctx.subscriptionState) { capabilities = pkglicensing.DeriveCapabilitiesFromTier(pkglicensing.TierCloud, nil) @@ -364,7 +354,7 @@ func buildPaidEntitlementLeaseClaims(ctx *tenantLeaseContext, instanceHost strin PlanVersion: strings.TrimSpace(ctx.planVersion), SubscriptionState: ctx.subscriptionState, Capabilities: capabilities, - Limits: limits, + Limits: map[string]int64{}, MetersEnabled: []string{}, RegisteredClaims: jwt.RegisteredClaims{ IssuedAt: jwt.NewNumericDate(now.UTC()), @@ -383,7 +373,7 @@ func buildTrialLeaseClaims(ctx trialEntitlementContext, now time.Time) pkglicens trialCapabilities, trialLimits := pkglicensing.DeriveEntitlements( pkglicensing.TierPro, nil, - pkglicensing.TierMonitoredSystemLimits[pkglicensing.TierPro], + 0, 0, ) trialState := pkglicensing.BuildTrialBillingState(ctx.trialStartedAt.UTC(), trialCapabilities) diff --git a/internal/cloudcp/entitlements/service_test.go b/internal/cloudcp/entitlements/service_test.go index b1ab4ac13..c76ac9d1f 100644 --- a/internal/cloudcp/entitlements/service_test.go +++ b/internal/cloudcp/entitlements/service_test.go @@ -133,8 +133,8 @@ func TestIssueTenantBillingStateDefaultsIndividualCloudPlanToStarter(t *testing. if claims.PlanVersion != "cloud_starter" { t.Fatalf("claims.PlanVersion=%q, want %q", claims.PlanVersion, "cloud_starter") } - if got := claims.Limits["max_monitored_systems"]; got != 10 { - t.Fatalf("claims.Limits[max_monitored_systems]=%d, want 10", got) + if _, ok := claims.Limits["max_monitored_systems"]; ok { + t.Fatalf("claims retained retired max_monitored_systems: %v", claims.Limits) } } @@ -188,8 +188,8 @@ func TestIssueTenantBillingStateCanonicalizesLegacyStoredPlanVersion(t *testing. if claims.PlanVersion != "cloud_starter" { t.Fatalf("claims.PlanVersion=%q, want %q", claims.PlanVersion, "cloud_starter") } - if got := claims.Limits["max_monitored_systems"]; got != 10 { - t.Fatalf("claims.Limits[max_monitored_systems]=%d, want 10", got) + if _, ok := claims.Limits["max_monitored_systems"]; ok { + t.Fatalf("claims retained retired max_monitored_systems: %v", claims.Limits) } } @@ -321,13 +321,8 @@ func TestRefreshLegacyTrialEntitlementReturnsLease(t *testing.T) { if claims.SubscriptionState != pkglicensing.SubStateTrial { t.Fatalf("claims.SubscriptionState=%q, want %q", claims.SubscriptionState, pkglicensing.SubStateTrial) } - if got := claims.Limits[pkglicensing.MaxMonitoredSystemsLicenseGateKey]; got != int64(pkglicensing.TierMonitoredSystemLimits[pkglicensing.TierPro]) { - t.Fatalf( - "claims.Limits[%s]=%d, want %d", - pkglicensing.MaxMonitoredSystemsLicenseGateKey, - got, - pkglicensing.TierMonitoredSystemLimits[pkglicensing.TierPro], - ) + if _, ok := claims.Limits[pkglicensing.MaxMonitoredSystemsLicenseGateKey]; ok { + t.Fatalf("claims retained retired %s: %v", pkglicensing.MaxMonitoredSystemsLicenseGateKey, claims.Limits) } } diff --git a/internal/cloudcp/stripe/cloud_lifecycle_integration_test.go b/internal/cloudcp/stripe/cloud_lifecycle_integration_test.go index 5a2418537..bd14cab97 100644 --- a/internal/cloudcp/stripe/cloud_lifecycle_integration_test.go +++ b/internal/cloudcp/stripe/cloud_lifecycle_integration_test.go @@ -18,53 +18,48 @@ import ( pkglicensing "github.com/rcourtman/pulse-go-rewrite/pkg/licensing" ) -// TestCloudLifecycle_CheckoutToBillingToMonitoredSystemLimits exercises the full Cloud +// TestCloudLifecycle_CheckoutToBillingWithoutMonitoredSystemVolumeKeys exercises the full Cloud // individual tier lifecycle: // // checkout.session.completed with plan_version metadata → // tenant provisioning with correct plan version → -// billing state written with correct monitored-system limits → -// subscription update propagates new tier limits → +// billing state written without retired monitored-system volume keys → +// subscription update preserves the uncapped runtime contract → // subscription cancellation revokes capabilities. // // This is an integration test that wires together the registry, provisioner, // and entitlements service to verify that Cloud tier assignment is end-to-end -// correct for Starter (10 monitored systems), Power (30), and Max (75). -func TestCloudLifecycle_CheckoutToBillingToMonitoredSystemLimits(t *testing.T) { +// correct for Starter, Power, Max, and Founding plans. +func TestCloudLifecycle_CheckoutToBillingWithoutMonitoredSystemLimits(t *testing.T) { tests := []struct { - name string - planVersion string - wantMonitoredSystems int64 - wantCaps []string // subset of expected capabilities - wantSubState pkglicensing.SubscriptionState + name string + planVersion string + wantCaps []string // subset of expected capabilities + wantSubState pkglicensing.SubscriptionState }{ { - name: "cloud_starter_via_metadata", - planVersion: "cloud_starter", - wantMonitoredSystems: 10, - wantCaps: []string{"ai_autofix", "relay", "mobile_app", "rbac"}, - wantSubState: pkglicensing.SubStateActive, + name: "cloud_starter_via_metadata", + planVersion: "cloud_starter", + wantCaps: []string{"ai_autofix", "relay", "mobile_app", "rbac"}, + wantSubState: pkglicensing.SubStateActive, }, { - name: "cloud_power_via_metadata", - planVersion: "cloud_power", - wantMonitoredSystems: 30, - wantCaps: []string{"ai_autofix", "relay", "mobile_app", "rbac"}, - wantSubState: pkglicensing.SubStateActive, + name: "cloud_power_via_metadata", + planVersion: "cloud_power", + wantCaps: []string{"ai_autofix", "relay", "mobile_app", "rbac"}, + wantSubState: pkglicensing.SubStateActive, }, { - name: "cloud_max_via_metadata", - planVersion: "cloud_max", - wantMonitoredSystems: 75, - wantCaps: []string{"ai_autofix", "relay", "mobile_app", "rbac"}, - wantSubState: pkglicensing.SubStateActive, + name: "cloud_max_via_metadata", + planVersion: "cloud_max", + wantCaps: []string{"ai_autofix", "relay", "mobile_app", "rbac"}, + wantSubState: pkglicensing.SubStateActive, }, { - name: "cloud_founding_via_metadata", - planVersion: "cloud_founding", - wantMonitoredSystems: 10, // Founding rate = Starter limits - wantCaps: []string{"ai_autofix", "relay"}, - wantSubState: pkglicensing.SubStateActive, + name: "cloud_founding_via_metadata", + planVersion: "cloud_founding", + wantCaps: []string{"ai_autofix", "relay"}, + wantSubState: pkglicensing.SubStateActive, }, } @@ -174,7 +169,7 @@ func TestCloudLifecycle_CheckoutToBillingToMonitoredSystemLimits(t *testing.T) { t.Fatalf("StripeAccount.StripeSubscriptionID = %q, want %q", sa.StripeSubscriptionID, session.Subscription) } - // ── Verify billing state and monitored-system limits ──────── + // ── Verify billing state and retired monitored-system volume keys ─ store := config.NewFileBillingStore(provisioner.tenantDataDir(tenant.ID)) bs, err := store.GetBillingState("default") if err != nil { @@ -187,16 +182,7 @@ func TestCloudLifecycle_CheckoutToBillingToMonitoredSystemLimits(t *testing.T) { t.Fatalf("billing.SubscriptionState = %q, want %q", bs.SubscriptionState, tc.wantSubState) } - // This is the critical assertion the runtime reads to enforce - // monitored-system caps for Cloud tenants. - if bs.Limits[pkglicensing.MaxMonitoredSystemsLicenseGateKey] != tc.wantMonitoredSystems { - t.Fatalf( - "billing.Limits[%s] = %d, want %d", - pkglicensing.MaxMonitoredSystemsLicenseGateKey, - bs.Limits[pkglicensing.MaxMonitoredSystemsLicenseGateKey], - tc.wantMonitoredSystems, - ) - } + assertNoRetiredMonitoredSystemLimit(t, bs.Limits) // Verify capabilities include Pro-level features. capSet := make(map[string]struct{}, len(bs.Capabilities)) @@ -285,11 +271,11 @@ func TestCloudLifecycle_AdmissionFailureBlocksCheckoutBeforeTenantOrAccountMutat } } -// TestCloudLifecycle_SubscriptionUpdateChangesLimits verifies that when a +// TestCloudLifecycle_SubscriptionUpdateChangesPlans verifies that when a // Cloud tenant upgrades (e.g., Starter → Power), the subscription.updated // webhook correctly updates both the tenant record and billing state with -// the new plan's monitored-system limits. -func TestCloudLifecycle_SubscriptionUpdateChangesLimits(t *testing.T) { +// the new plan while keeping retired monitored-system volume keys scrubbed. +func TestCloudLifecycle_SubscriptionUpdateChangesPlans(t *testing.T) { reg := newStripeTestRegistry(t) tenantsDir := t.TempDir() provisioner := newTestProvisioner(t, reg, tenantsDir, nil, true) @@ -310,17 +296,15 @@ func TestCloudLifecycle_SubscriptionUpdateChangesLimits(t *testing.T) { t.Fatalf("lookup tenant: %v (tenant=%v)", err, tenant) } - // Verify initial state: Starter = 10 monitored systems. + // Verify initial state: Starter has no monitored-system cap. store := config.NewFileBillingStore(provisioner.tenantDataDir(tenant.ID)) bs, err := store.GetBillingState("default") if err != nil || bs == nil { t.Fatalf("initial GetBillingState: %v", err) } - if bs.Limits[pkglicensing.MaxMonitoredSystemsLicenseGateKey] != 10 { - t.Fatalf("initial %s = %d, want 10", pkglicensing.MaxMonitoredSystemsLicenseGateKey, bs.Limits[pkglicensing.MaxMonitoredSystemsLicenseGateKey]) - } + assertNoRetiredMonitoredSystemLimit(t, bs.Limits) - // Phase 2: Simulate subscription.updated → upgrade to Cloud Power (30 monitored systems). + // Phase 2: Simulate subscription.updated → upgrade to Cloud Power. sub := Subscription{ ID: "sub_upgrade_test", Customer: "cus_upgrade_test", @@ -343,19 +327,17 @@ func TestCloudLifecycle_SubscriptionUpdateChangesLimits(t *testing.T) { t.Fatalf("tenant.State after upgrade = %q, want %q", tenant.State, registry.TenantStateActive) } - // Verify billing state has new limits. + // Verify billing state keeps monitored-system caps retired. bs, err = store.GetBillingState("default") if err != nil || bs == nil { t.Fatalf("GetBillingState after upgrade: %v", err) } - if bs.Limits[pkglicensing.MaxMonitoredSystemsLicenseGateKey] != 30 { - t.Fatalf("max_monitored_systems after upgrade = %d, want 30", bs.Limits[pkglicensing.MaxMonitoredSystemsLicenseGateKey]) - } + assertNoRetiredMonitoredSystemLimit(t, bs.Limits) if bs.SubscriptionState != pkglicensing.SubStateActive { t.Fatalf("SubscriptionState after upgrade = %q, want %q", bs.SubscriptionState, pkglicensing.SubStateActive) } - // Phase 3: Upgrade again to Cloud Max (75 monitored systems). + // Phase 3: Upgrade again to Cloud Max. sub.Metadata = map[string]string{"plan_version": "cloud_max"} if err := provisioner.HandleSubscriptionUpdated(context.Background(), sub); err != nil { t.Fatalf("HandleSubscriptionUpdated (upgrade to max): %v", err) @@ -365,9 +347,7 @@ func TestCloudLifecycle_SubscriptionUpdateChangesLimits(t *testing.T) { if err != nil || bs == nil { t.Fatalf("GetBillingState after max upgrade: %v", err) } - if bs.Limits[pkglicensing.MaxMonitoredSystemsLicenseGateKey] != 75 { - t.Fatalf("max_monitored_systems after max upgrade = %d, want 75", bs.Limits[pkglicensing.MaxMonitoredSystemsLicenseGateKey]) - } + assertNoRetiredMonitoredSystemLimit(t, bs.Limits) } // TestCloudLifecycle_CancellationRevokesCapabilities proves that @@ -403,9 +383,7 @@ func TestCloudLifecycle_CancellationRevokesCapabilities(t *testing.T) { if len(bs.Capabilities) == 0 { t.Fatal("expected capabilities before cancellation, got empty") } - if bs.Limits[pkglicensing.MaxMonitoredSystemsLicenseGateKey] != 10 { - t.Fatalf("max_monitored_systems before cancel = %d, want 10", bs.Limits[pkglicensing.MaxMonitoredSystemsLicenseGateKey]) - } + assertNoRetiredMonitoredSystemLimit(t, bs.Limits) // Simulate subscription.deleted. delSub := Subscription{ @@ -518,9 +496,7 @@ func TestCloudLifecycle_GracePeriodPreservesAccess(t *testing.T) { if len(bs.Capabilities) == 0 { t.Fatal("expected capabilities during grace, got empty") } - if bs.Limits[pkglicensing.MaxMonitoredSystemsLicenseGateKey] != 30 { - t.Fatalf("max_monitored_systems during grace = %d, want 30 (Cloud Power)", bs.Limits[pkglicensing.MaxMonitoredSystemsLicenseGateKey]) - } + assertNoRetiredMonitoredSystemLimit(t, bs.Limits) // Stripe account should have grace window started. sa, err := reg.GetStripeAccountByCustomerID("cus_grace_test") @@ -542,18 +518,17 @@ func TestCloudLifecycle_GracePeriodPreservesAccess(t *testing.T) { // outside the control plane (e.g., directly in Stripe Dashboard). func TestCloudLifecycle_PriceIDResolution(t *testing.T) { tests := []struct { - name string - priceID string - wantPlan string - wantMonitoredSystems int64 + name string + priceID string + wantPlan string }{ - {"starter_monthly", "price_1T5kflBrHBocJIGHUqPv1dzV", "cloud_starter", 10}, - {"starter_annual", "price_1T5kfmBrHBocJIGHTS3ymKxM", "cloud_starter", 10}, - {"founding_monthly", "price_1T5kfnBrHBocJIGHATQJr79D", "cloud_founding", 10}, - {"power_monthly", "price_1T5kg2BrHBocJIGHmkoF0zXY", "cloud_power", 30}, - {"power_annual", "price_1T5kg3BrHBocJIGH2EtzKofV", "cloud_power", 30}, - {"max_monthly", "price_1T5kg4BrHBocJIGHHa8Ecqho", "cloud_max", 75}, - {"max_annual", "price_1T5kg5BrHBocJIGH5AIJ4nVc", "cloud_max", 75}, + {"starter_monthly", "price_1T5kflBrHBocJIGHUqPv1dzV", "cloud_starter"}, + {"starter_annual", "price_1T5kfmBrHBocJIGHTS3ymKxM", "cloud_starter"}, + {"founding_monthly", "price_1T5kfnBrHBocJIGHATQJr79D", "cloud_founding"}, + {"power_monthly", "price_1T5kg2BrHBocJIGHmkoF0zXY", "cloud_power"}, + {"power_annual", "price_1T5kg3BrHBocJIGH2EtzKofV", "cloud_power"}, + {"max_monthly", "price_1T5kg4BrHBocJIGHHa8Ecqho", "cloud_max"}, + {"max_annual", "price_1T5kg5BrHBocJIGH5AIJ4nVc", "cloud_max"}, } for _, tc := range tests { @@ -615,13 +590,7 @@ func TestCloudLifecycle_PriceIDResolution(t *testing.T) { if err != nil || bs == nil { t.Fatalf("GetBillingState: %v", err) } - if bs.Limits[pkglicensing.MaxMonitoredSystemsLicenseGateKey] != tc.wantMonitoredSystems { - t.Fatalf( - "max_monitored_systems = %d, want %d", - bs.Limits[pkglicensing.MaxMonitoredSystemsLicenseGateKey], - tc.wantMonitoredSystems, - ) - } + assertNoRetiredMonitoredSystemLimit(t, bs.Limits) }) } } @@ -705,12 +674,13 @@ func TestCloudLifecycle_SubscriptionUpdateCanonicalizesStoredFallbackPlan(t *tes if bs.PlanVersion != "cloud_starter" { t.Fatalf("billing.PlanVersion = %q, want %q", bs.PlanVersion, "cloud_starter") } - if bs.Limits[pkglicensing.MaxMonitoredSystemsLicenseGateKey] != 10 { - t.Fatalf( - "billing.Limits[%s] = %d, want 10", - pkglicensing.MaxMonitoredSystemsLicenseGateKey, - bs.Limits[pkglicensing.MaxMonitoredSystemsLicenseGateKey], - ) + assertNoRetiredMonitoredSystemLimit(t, bs.Limits) +} + +func assertNoRetiredMonitoredSystemLimit(t *testing.T, limits map[string]int64) { + t.Helper() + if _, ok := limits[pkglicensing.MaxMonitoredSystemsLicenseGateKey]; ok { + t.Fatalf("expected retired %s limit to be omitted, got %+v", pkglicensing.MaxMonitoredSystemsLicenseGateKey, limits) } } diff --git a/internal/cloudcp/stripe/msp_lifecycle_integration_test.go b/internal/cloudcp/stripe/msp_lifecycle_integration_test.go index 4dbf29c4f..640179449 100644 --- a/internal/cloudcp/stripe/msp_lifecycle_integration_test.go +++ b/internal/cloudcp/stripe/msp_lifecycle_integration_test.go @@ -741,22 +741,21 @@ func TestMSPLifecycle_TenantIsolation(t *testing.T) { // TestMSPLifecycle_PlanVersionFromAccount verifies that ProvisionWorkspace // uses the account's actual Stripe plan version instead of a hardcoded default. -// MSP Growth accounts should get msp_growth limits (150 monitored systems), not msp_hosted_v1 (50). +// MSP Growth accounts should keep the msp_growth plan version while monitored-system caps stay retired. func TestMSPLifecycle_PlanVersionFromAccount(t *testing.T) { reg := newStripeTestRegistry(t) tenantsDir := t.TempDir() provisioner := newTestProvisioner(t, reg, tenantsDir, nil, true) tests := []struct { - name string - planVersion string - wantMonitoredSystems int64 + name string + planVersion string }{ - {"msp_starter", "msp_starter", 50}, - {"msp_growth", "msp_growth", 150}, - {"msp_scale", "msp_scale", 400}, - {"legacy_msp_hosted_v1", "msp_hosted_v1", 50}, - {"canonicalized_cloud_alias", "cloud_v1", 10}, + {"msp_starter", "msp_starter"}, + {"msp_growth", "msp_growth"}, + {"msp_scale", "msp_scale"}, + {"legacy_msp_hosted_v1", "msp_hosted_v1"}, + {"canonicalized_cloud_alias", "cloud_v1"}, } for _, tc := range tests { @@ -800,14 +799,7 @@ func TestMSPLifecycle_PlanVersionFromAccount(t *testing.T) { if bs.PlanVersion != wantPlanVersion { t.Fatalf("billing.PlanVersion = %q, want %q", bs.PlanVersion, wantPlanVersion) } - if bs.Limits[pkglicensing.MaxMonitoredSystemsLicenseGateKey] != tc.wantMonitoredSystems { - t.Fatalf( - "billing.Limits[%s] = %d, want %d", - pkglicensing.MaxMonitoredSystemsLicenseGateKey, - bs.Limits[pkglicensing.MaxMonitoredSystemsLicenseGateKey], - tc.wantMonitoredSystems, - ) - } + assertNoRetiredMonitoredSystemLimit(t, bs.Limits) }) } } @@ -851,13 +843,7 @@ func TestMSPLifecycle_PlanVersionFallback(t *testing.T) { if bs.PlanVersion != "msp_starter" { t.Fatalf("billing.PlanVersion = %q, want %q", bs.PlanVersion, "msp_starter") } - if bs.Limits[pkglicensing.MaxMonitoredSystemsLicenseGateKey] != 50 { - t.Fatalf( - "billing.Limits[%s] = %d, want 50 (msp_starter default)", - pkglicensing.MaxMonitoredSystemsLicenseGateKey, - bs.Limits[pkglicensing.MaxMonitoredSystemsLicenseGateKey], - ) - } + assertNoRetiredMonitoredSystemLimit(t, bs.Limits) } // ─── Test helpers ────────────────────────────────────────────────────────── diff --git a/internal/cloudcp/stripe/provisioner.go b/internal/cloudcp/stripe/provisioner.go index cd9953993..0da96976c 100644 --- a/internal/cloudcp/stripe/provisioner.go +++ b/internal/cloudcp/stripe/provisioner.go @@ -1038,8 +1038,8 @@ func (p *Provisioner) HandleSubscriptionUpdated(ctx context.Context, sub Subscri planVersion := DerivePlanVersion(sub.Metadata, priceID) // Preserve existing plan version only when the price hasn't changed // (same subscription metadata refresh). If the price changed to an - // unknown ID, keep the opaque fallback so LimitsForCloudPlan applies - // fail-closed defaults rather than inheriting stale higher-tier limits. + // unknown ID, keep the opaque fallback so workspace-policy reconciliation + // does not inherit stale higher-tier metadata. if (planVersion == "" || planVersion == "stripe" || strings.HasPrefix(planVersion, "stripe_price:")) && strings.TrimSpace(tenant.PlanVersion) != "" && (priceID == "" || priceID == strings.TrimSpace(tenant.StripePriceID)) { diff --git a/internal/deploy/reservation.go b/internal/deploy/reservation.go deleted file mode 100644 index eea1c7963..000000000 --- a/internal/deploy/reservation.go +++ /dev/null @@ -1,92 +0,0 @@ -package deploy - -import ( - "fmt" - "sync" - "time" -) - -// ReservationManager tracks monitored-system capacity reservations for in-flight deployments. -type ReservationManager struct { - mu sync.Mutex - reservations map[string]*Reservation -} - -// Reservation represents capacity reserved for a deployment job. -type Reservation struct { - JobID string - OrgID string - Slots int - CreatedAt time.Time - ExpiresAt time.Time -} - -// NewReservationManager creates a new reservation manager. -func NewReservationManager() *ReservationManager { - return &ReservationManager{ - reservations: make(map[string]*Reservation), - } -} - -// Reserve allocates monitored-system capacity for a deployment job. Returns an error if -// the job already has a reservation. -func (rm *ReservationManager) Reserve(jobID, orgID string, slots int, ttl time.Duration) error { - rm.mu.Lock() - defer rm.mu.Unlock() - - if _, exists := rm.reservations[jobID]; exists { - return fmt.Errorf("reservation already exists for job %q", jobID) - } - if slots <= 0 { - return fmt.Errorf("slots must be positive, got %d", slots) - } - if ttl <= 0 { - return fmt.Errorf("ttl must be positive, got %v", ttl) - } - - now := time.Now().UTC() - rm.reservations[jobID] = &Reservation{ - JobID: jobID, - OrgID: orgID, - Slots: slots, - CreatedAt: now, - ExpiresAt: now.Add(ttl), - } - return nil -} - -// Release removes the reservation for a job. -func (rm *ReservationManager) Release(jobID string) { - rm.mu.Lock() - defer rm.mu.Unlock() - delete(rm.reservations, jobID) -} - -// ReservedForOrg returns the total number of reserved slots for a given org, -// excluding expired reservations. -func (rm *ReservationManager) ReservedForOrg(orgID string) int { - rm.mu.Lock() - defer rm.mu.Unlock() - - now := time.Now().UTC() - total := 0 - for _, r := range rm.reservations { - if r.OrgID == orgID && now.Before(r.ExpiresAt) { - total += r.Slots - } - } - return total -} - -// CleanExpired removes all expired reservations. -func (rm *ReservationManager) CleanExpired() { - rm.mu.Lock() - defer rm.mu.Unlock() - - now := time.Now().UTC() - for id, r := range rm.reservations { - if !now.Before(r.ExpiresAt) { - delete(rm.reservations, id) - } - } -} diff --git a/internal/deploy/reservation_test.go b/internal/deploy/reservation_test.go deleted file mode 100644 index 4779d43f1..000000000 --- a/internal/deploy/reservation_test.go +++ /dev/null @@ -1,133 +0,0 @@ -package deploy - -import ( - "testing" - "time" -) - -func TestReserveAndRelease(t *testing.T) { - rm := NewReservationManager() - - if err := rm.Reserve("j1", "org-1", 3, 30*time.Minute); err != nil { - t.Fatalf("Reserve: %v", err) - } - if got := rm.ReservedForOrg("org-1"); got != 3 { - t.Errorf("expected 3 reserved, got %d", got) - } - - rm.Release("j1") - if got := rm.ReservedForOrg("org-1"); got != 0 { - t.Errorf("expected 0 after release, got %d", got) - } -} - -func TestReserveDuplicate(t *testing.T) { - rm := NewReservationManager() - _ = rm.Reserve("j1", "org-1", 2, 30*time.Minute) - - if err := rm.Reserve("j1", "org-1", 1, 30*time.Minute); err == nil { - t.Fatal("expected error for duplicate reservation") - } -} - -func TestReserveInvalidSlots(t *testing.T) { - rm := NewReservationManager() - if err := rm.Reserve("j1", "org-1", 0, 30*time.Minute); err == nil { - t.Fatal("expected error for zero slots") - } - if err := rm.Reserve("j2", "org-1", -1, 30*time.Minute); err == nil { - t.Fatal("expected error for negative slots") - } -} - -func TestMultiOrgIsolation(t *testing.T) { - rm := NewReservationManager() - _ = rm.Reserve("j1", "org-1", 3, 30*time.Minute) - _ = rm.Reserve("j2", "org-2", 5, 30*time.Minute) - - if got := rm.ReservedForOrg("org-1"); got != 3 { - t.Errorf("expected 3 for org-1, got %d", got) - } - if got := rm.ReservedForOrg("org-2"); got != 5 { - t.Errorf("expected 5 for org-2, got %d", got) - } - if got := rm.ReservedForOrg("org-3"); got != 0 { - t.Errorf("expected 0 for unknown org, got %d", got) - } -} - -func TestMultipleReservationsSameOrg(t *testing.T) { - rm := NewReservationManager() - _ = rm.Reserve("j1", "org-1", 2, 30*time.Minute) - _ = rm.Reserve("j2", "org-1", 3, 30*time.Minute) - - if got := rm.ReservedForOrg("org-1"); got != 5 { - t.Errorf("expected 5 total, got %d", got) - } - - rm.Release("j1") - if got := rm.ReservedForOrg("org-1"); got != 3 { - t.Errorf("expected 3 after partial release, got %d", got) - } -} - -func TestReserveInvalidTTL(t *testing.T) { - rm := NewReservationManager() - if err := rm.Reserve("j1", "org-1", 3, 0); err == nil { - t.Fatal("expected error for zero TTL") - } - if err := rm.Reserve("j2", "org-1", 3, -1*time.Second); err == nil { - t.Fatal("expected error for negative TTL") - } -} - -func TestTTLExpiry(t *testing.T) { - rm := NewReservationManager() - - // Directly inject an already-expired reservation to test expiry counting. - rm.mu.Lock() - rm.reservations["j1"] = &Reservation{ - JobID: "j1", OrgID: "org-1", Slots: 3, - CreatedAt: time.Now().UTC().Add(-2 * time.Minute), - ExpiresAt: time.Now().UTC().Add(-1 * time.Minute), - } - rm.mu.Unlock() - - // Expired reservations should not be counted. - if got := rm.ReservedForOrg("org-1"); got != 0 { - t.Errorf("expected 0 for expired reservation, got %d", got) - } -} - -func TestCleanExpired(t *testing.T) { - rm := NewReservationManager() - - // Directly inject an already-expired reservation. - rm.mu.Lock() - rm.reservations["j1"] = &Reservation{ - JobID: "j1", OrgID: "org-1", Slots: 2, - CreatedAt: time.Now().UTC().Add(-2 * time.Minute), - ExpiresAt: time.Now().UTC().Add(-1 * time.Minute), - } - rm.mu.Unlock() - - _ = rm.Reserve("j2", "org-1", 3, 30*time.Minute) // still valid - - rm.CleanExpired() - - // j1 should be cleaned up, only j2 remains. - if got := rm.ReservedForOrg("org-1"); got != 3 { - t.Errorf("expected 3 after cleanup, got %d", got) - } - - // Verify j1 was actually removed (can re-reserve that ID). - if err := rm.Reserve("j1", "org-1", 1, 30*time.Minute); err != nil { - t.Errorf("expected to re-reserve cleaned-up job, got: %v", err) - } -} - -func TestReleaseNonexistent(t *testing.T) { - rm := NewReservationManager() - // Should not panic. - rm.Release("nonexistent") -} diff --git a/internal/deploy/transitions.go b/internal/deploy/transitions.go index fcfea88d7..2194114c0 100644 --- a/internal/deploy/transitions.go +++ b/internal/deploy/transitions.go @@ -15,7 +15,7 @@ var validJobTransitions = map[JobStatus][]JobStatus{ // validTargetTransitions defines allowed state transitions for a Target. var validTargetTransitions = map[TargetStatus][]TargetStatus{ - TargetPending: {TargetPreflighting, TargetSkippedAgent, TargetSkippedLicense, TargetCanceled}, + TargetPending: {TargetPreflighting, TargetSkippedAgent, TargetCanceled}, TargetPreflighting: {TargetReady, TargetFailedRetryable, TargetFailedPermanent, TargetCanceled}, TargetReady: {TargetInstalling, TargetCanceled}, TargetInstalling: {TargetEnrolling, TargetFailedRetryable, TargetFailedPermanent, TargetCanceled}, @@ -72,7 +72,7 @@ func (j *Job) DeriveStatus(targets []Target) JobStatus { case TargetSucceeded: succeeded++ terminal++ - case TargetFailedPermanent, TargetSkippedAgent, TargetSkippedLicense, TargetCanceled: + case TargetFailedPermanent, TargetSkippedAgent, TargetCanceled: terminal++ case TargetPending, TargetPreflighting, TargetReady, TargetInstalling, TargetEnrolling, TargetVerifying, TargetFailedRetryable: diff --git a/internal/deploy/transitions_test.go b/internal/deploy/transitions_test.go index 569339bbe..e0339bfe1 100644 --- a/internal/deploy/transitions_test.go +++ b/internal/deploy/transitions_test.go @@ -88,7 +88,6 @@ func TestTargetTransitionTo_Valid(t *testing.T) { }{ {TargetPending, TargetPreflighting}, {TargetPending, TargetSkippedAgent}, - {TargetPending, TargetSkippedLicense}, {TargetPending, TargetCanceled}, {TargetPreflighting, TargetReady}, {TargetPreflighting, TargetFailedRetryable}, @@ -131,7 +130,6 @@ func TestTargetTransitionTo_Invalid(t *testing.T) { {TargetSucceeded, TargetPending}, // terminal {TargetFailedPermanent, TargetPending}, // terminal {TargetSkippedAgent, TargetPending}, // terminal - {TargetSkippedLicense, TargetPending}, // terminal {TargetCanceled, TargetPending}, // terminal {TargetPending, TargetInstalling}, // skip preflighting {TargetPending, TargetSucceeded}, // skip to end @@ -181,7 +179,7 @@ func TestDeriveStatus(t *testing.T) { }, { name: "all skipped", - targets: targets(TargetSkippedAgent, TargetSkippedLicense), + targets: targets(TargetSkippedAgent, TargetSkippedAgent), expected: JobFailed, }, { diff --git a/internal/deploy/types.go b/internal/deploy/types.go index 8cc34e90a..2dde6dcb5 100644 --- a/internal/deploy/types.go +++ b/internal/deploy/types.go @@ -30,7 +30,6 @@ const ( TargetFailedRetryable TargetStatus = "failed_retryable" TargetFailedPermanent TargetStatus = "failed_permanent" TargetSkippedAgent TargetStatus = "skipped_already_agent" - TargetSkippedLicense TargetStatus = "skipped_license" TargetCanceled TargetStatus = "canceled" ) diff --git a/internal/license/coverage_additional_test.go b/internal/license/coverage_additional_test.go index a9f996e9e..2de347f09 100644 --- a/internal/license/coverage_additional_test.go +++ b/internal/license/coverage_additional_test.go @@ -121,9 +121,6 @@ func TestStatusHostedEvaluatorClampsNegativeLimits(t *testing.T) { if !status.Valid { t.Fatalf("Status().Valid = %v, want true", status.Valid) } - if status.MaxMonitoredSystems != 0 { - t.Fatalf("Status().MaxMonitoredSystems = %d, want 0", status.MaxMonitoredSystems) - } if status.MaxGuests != 0 { t.Fatalf("Status().MaxGuests = %d, want 0", status.MaxGuests) } diff --git a/internal/license/entitlements/database_source_test.go b/internal/license/entitlements/database_source_test.go index 3ace77a65..e5ebfa360 100644 --- a/internal/license/entitlements/database_source_test.go +++ b/internal/license/entitlements/database_source_test.go @@ -53,7 +53,7 @@ func TestDatabaseSourceHappyPath(t *testing.T) { store := &mockBillingStore{ state: &BillingState{ Capabilities: []string{"rbac", "relay"}, - Limits: map[string]int64{"max_monitored_systems": 50}, + Limits: map[string]int64{"max_monitored_systems": 50, "max_guests": 75}, MetersEnabled: []string{"active_agents"}, PlanVersion: "pro-v2", SubscriptionState: SubStateActive, @@ -65,8 +65,8 @@ func TestDatabaseSourceHappyPath(t *testing.T) { if got := source.Capabilities(); !reflect.DeepEqual(got, []string{"rbac", "relay"}) { t.Fatalf("expected capabilities %v, got %v", []string{"rbac", "relay"}, got) } - if got := source.Limits(); !reflect.DeepEqual(got, map[string]int64{"max_monitored_systems": 50}) { - t.Fatalf("expected limits %v, got %v", map[string]int64{"max_monitored_systems": 50}, got) + if got := source.Limits(); !reflect.DeepEqual(got, map[string]int64{"max_guests": 75}) { + t.Fatalf("expected limits %v, got %v", map[string]int64{"max_guests": 75}, got) } if got := source.MetersEnabled(); !reflect.DeepEqual(got, []string{"active_agents"}) { t.Fatalf("expected meters %v, got %v", []string{"active_agents"}, got) diff --git a/internal/license/entitlements/evaluator_test.go b/internal/license/entitlements/evaluator_test.go index 20c283065..b155ab493 100644 --- a/internal/license/entitlements/evaluator_test.go +++ b/internal/license/entitlements/evaluator_test.go @@ -279,8 +279,10 @@ func TestEvaluatorMeterEnabled(t *testing.T) { func TestTokenSourceLegacyDerivation(t *testing.T) { t.Run("legacy self-hosted claims keep capabilities but scrub monitored-system caps", func(t *testing.T) { claims := &license.Claims{ - Tier: license.TierPro, - MaxMonitoredSystems: 25, + Tier: license.TierPro, + Limits: map[string]int64{ + "max_monitored_systems": 25, + }, } source := NewTokenSource(claims) diff --git a/internal/license/features.go b/internal/license/features.go index 927ea79a1..0ea92f9ac 100644 --- a/internal/license/features.go +++ b/internal/license/features.go @@ -46,9 +46,6 @@ const ( // TierFeatures maps each tier to its included features. var TierFeatures = licensing.TierFeatures -// TierMonitoredSystemLimits defines the maximum agent count per tier. -var TierMonitoredSystemLimits = licensing.TierMonitoredSystemLimits - // TierHistoryDays defines the maximum metrics history retention per tier. var TierHistoryDays = licensing.TierHistoryDays diff --git a/internal/license/license_test.go b/internal/license/license_test.go index c2f5f39fc..f3823c773 100644 --- a/internal/license/license_test.go +++ b/internal/license/license_test.go @@ -123,8 +123,7 @@ func TestServiceStatus_UsesEffectiveClaimsCapabilitiesAndScrubsSelfHostedCommerc "max_monitored_systems": 99, "max_guests": 7, }, - MaxMonitoredSystems: 1, - MaxGuests: 2, + MaxGuests: 2, }, }) @@ -139,9 +138,6 @@ func TestServiceStatus_UsesEffectiveClaimsCapabilitiesAndScrubsSelfHostedCommerc if !reflect.DeepEqual(status.Features, []string{FeatureAIAutoFix}) { t.Fatalf("Status().Features=%v, want %v", status.Features, []string{FeatureAIAutoFix}) } - if status.MaxMonitoredSystems != 0 { - t.Fatalf("Status().MaxMonitoredSystems=%d, want 0 for uncapped self-hosted Pro", status.MaxMonitoredSystems) - } if status.MaxGuests != 0 { t.Fatalf("Status().MaxGuests=%d, want 0 for uncapped self-hosted Pro", status.MaxGuests) } @@ -976,14 +972,14 @@ func TestClaimsEffectiveCapabilities(t *testing.T) { func TestClaimsEffectiveLimits(t *testing.T) { t.Run("explicit limits", func(t *testing.T) { claims := Claims{ - MaxMonitoredSystems: 25, Limits: map[string]int64{ "max_monitored_systems": 50, + "max_guests": 75, }, } got := claims.EffectiveLimits() - want := map[string]int64{"max_monitored_systems": 50} + want := map[string]int64{"max_guests": 75} if !reflect.DeepEqual(got, want) { t.Fatalf("EffectiveLimits() = %v, want %v", got, want) } @@ -991,14 +987,12 @@ func TestClaimsEffectiveLimits(t *testing.T) { t.Run("derived from fields", func(t *testing.T) { claims := Claims{ - MaxMonitoredSystems: 25, - MaxGuests: 100, + MaxGuests: 100, } got := claims.EffectiveLimits() want := map[string]int64{ - "max_monitored_systems": 25, - "max_guests": 100, + "max_guests": 100, } if !reflect.DeepEqual(got, want) { t.Fatalf("EffectiveLimits() = %v, want %v", got, want) @@ -1007,8 +1001,7 @@ func TestClaimsEffectiveLimits(t *testing.T) { t.Run("zero fields omitted", func(t *testing.T) { claims := Claims{ - MaxMonitoredSystems: 0, - Limits: nil, + Limits: nil, } got := claims.EffectiveLimits() @@ -1021,15 +1014,14 @@ func TestClaimsEffectiveLimits(t *testing.T) { func TestClaimsJSONRoundtrip(t *testing.T) { t.Run("new fields set", func(t *testing.T) { original := Claims{ - LicenseID: "license_roundtrip", - Email: "roundtrip@example.com", - Tier: TierPro, - IssuedAt: 1700000000, - ExpiresAt: 1800000000, - Features: []string{"legacy_feature"}, - MaxMonitoredSystems: 10, - MaxGuests: 20, - Capabilities: []string{"cap_a", "cap_b"}, + LicenseID: "license_roundtrip", + Email: "roundtrip@example.com", + Tier: TierPro, + IssuedAt: 1700000000, + ExpiresAt: 1800000000, + Features: []string{"legacy_feature"}, + MaxGuests: 20, + Capabilities: []string{"cap_a", "cap_b"}, Limits: map[string]int64{ "max_monitored_systems": 50, "max_guests": 100, @@ -1124,8 +1116,7 @@ func TestDeriveEntitlements(t *testing.T) { sort.Strings(wantCapabilities) wantLimits := map[string]int64{ - "max_monitored_systems": 25, - "max_guests": 100, + "max_guests": 100, } if !reflect.DeepEqual(capabilities, wantCapabilities) { @@ -1413,9 +1404,6 @@ func TestEvaluatorMatrix(t *testing.T) { } // Hosted path unions free-tier baseline capabilities with evaluator-provided capabilities. assertFeatureSetEq(t, status.Features, []string{FeatureUpdateAlerts, FeatureSSO, FeatureAdvancedSSO, FeatureAIPatrol, FeatureAIAutoFix}) - if status.MaxMonitoredSystems != 42 { - t.Fatalf("Status().MaxMonitoredSystems=%d, want %d", status.MaxMonitoredSystems, 42) - } if status.MaxGuests != 13 { t.Fatalf("Status().MaxGuests=%d, want %d", status.MaxGuests, 13) } @@ -1490,8 +1478,8 @@ func TestEvaluatorMatrix(t *testing.T) { if !reflect.DeepEqual(status.Features, TierFeatures[TierFree]) { t.Fatalf("Status().Features=%v, want %v", status.Features, TierFeatures[TierFree]) } - if status.MaxMonitoredSystems != TierMonitoredSystemLimits[TierFree] || status.MaxGuests != 0 { - t.Fatalf("expected free-tier fallback limits for expired subscription, got MaxMonitoredSystems=%d MaxGuests=%d", status.MaxMonitoredSystems, status.MaxGuests) + if status.MaxGuests != 0 { + t.Fatalf("expected free-tier fallback limits for expired subscription, got MaxGuests=%d", status.MaxGuests) } }) diff --git a/internal/monitoring/canonical_guardrails_test.go b/internal/monitoring/canonical_guardrails_test.go index 32c1288e8..0e364dbb1 100644 --- a/internal/monitoring/canonical_guardrails_test.go +++ b/internal/monitoring/canonical_guardrails_test.go @@ -190,6 +190,26 @@ func TestMonitoredSystemUsageReadinessGuardrailsRemainCanonical(t *testing.T) { } } +func TestMonitoredSystemUsageStaysInventoryOnly(t *testing.T) { + data, err := os.ReadFile("monitored_system_usage.go") + if err != nil { + t.Fatalf("failed to read monitored_system_usage.go: %v", err) + } + source := string(data) + + for _, forbidden := range []string{ + "max_monitored_systems", + "license_limit", + "would_exceed_limit", + "monitored_system_capacity", + "admission", + } { + if strings.Contains(source, forbidden) { + t.Fatalf("monitored_system_usage.go must not contain retired cap/admission token %q", forbidden) + } + } +} + func TestDockerHostIdentityUsesCanonicalHostnameEquivalence(t *testing.T) { data, err := os.ReadFile("docker_host_identity.go") if err != nil { diff --git a/internal/monitoring/monitor.go b/internal/monitoring/monitor.go index ed22c6dd6..4520ade19 100644 --- a/internal/monitoring/monitor.go +++ b/internal/monitoring/monitor.go @@ -150,7 +150,7 @@ type MonitorSupplementalChangesProvider interface { // MonitorSupplementalInventoryReadinessProvider optionally reports when a // supplemental provider's current org-scoped inventory is settled enough to be -// consumed by billing and monitored-system admission boundaries. +// consumed by monitored-system grouping and support boundaries. // // Providers that suppress snapshot-owned sources must implement this contract // so the monitor can fail closed until the canonical store has been rebuilt diff --git a/internal/monitoring/monitored_system_usage.go b/internal/monitoring/monitored_system_usage.go index 81fafae77..3a099aa0d 100644 --- a/internal/monitoring/monitored_system_usage.go +++ b/internal/monitoring/monitored_system_usage.go @@ -8,8 +8,8 @@ import ( ) // MonitoredSystemUsageSnapshot describes whether the monitor can currently -// supply a canonical monitored-system count that is safe for billing and -// admission enforcement to consume. +// supply a canonical monitored-system count that is safe for settings and +// support context to consume. type MonitoredSystemUsageSnapshot struct { Count int ReadState unifiedresources.ReadState @@ -24,7 +24,7 @@ const ( ) // MonitoredSystemUsage returns the canonical monitored-system count only when -// the current unified view is settled enough for billing boundaries. When +// the current unified view is settled enough for settings/support context. When // supplemental provider-owned sources are still settling, the result fails // closed with Available=false. func (m *Monitor) MonitoredSystemUsage() MonitoredSystemUsageSnapshot { diff --git a/internal/monitoring/truenas_poller.go b/internal/monitoring/truenas_poller.go index 4974beb06..f2d708cfd 100644 --- a/internal/monitoring/truenas_poller.go +++ b/internal/monitoring/truenas_poller.go @@ -1021,7 +1021,7 @@ func (p *TrueNASPoller) SnapshotOwnedSourcesForOrg(string) []unifiedresources.Da // SupplementalInventoryReadyAt reports when the current org-scoped TrueNAS // inventory has reached a settled initial baseline that has to be reflected in -// the canonical monitor store before billing can consume monitored-system +// the canonical monitor store before support surfaces consume monitored-system // counts. func (p *TrueNASPoller) SupplementalInventoryReadyAt(_ *Monitor, orgID string) (time.Time, bool) { if p == nil { diff --git a/internal/monitoring/truenas_poller_test.go b/internal/monitoring/truenas_poller_test.go index ca33e39e9..435b7f4ab 100644 --- a/internal/monitoring/truenas_poller_test.go +++ b/internal/monitoring/truenas_poller_test.go @@ -8,6 +8,7 @@ import ( "net/http" "net/http/httptest" "net/url" + "os" "strconv" "strings" "sync/atomic" @@ -86,6 +87,28 @@ func TestTrueNASPollerFeatureFlagGate(t *testing.T) { poller.Stop() } +func TestTrueNASPollerStaysMonitoringOnly(t *testing.T) { + source, err := os.ReadFile("truenas_poller.go") + if err != nil { + t.Fatalf("ReadFile() error = %v", err) + } + + for _, retired := range []string{ + "max_monitored_systems", + "plan_limit", + "would_exceed_limit", + "grandfather", + "admission", + "billing", + "capacity", + "limit", + } { + if strings.Contains(string(source), retired) { + t.Fatalf("TrueNAS poller must stay inventory-only and must not reference retired monitor-count cap token %q", retired) + } + } +} + func TestTrueNASPollerEnableDisableCycle(t *testing.T) { previous := truenas.IsFeatureEnabled() truenas.SetFeatureEnabled(true) diff --git a/internal/monitoring/vmware_poller.go b/internal/monitoring/vmware_poller.go index adad3e17c..5fd4edab5 100644 --- a/internal/monitoring/vmware_poller.go +++ b/internal/monitoring/vmware_poller.go @@ -583,7 +583,7 @@ func (p *VMwarePoller) SnapshotOwnedSourcesForOrg(string) []unifiedresources.Dat // SupplementalInventoryReadyAt reports when the current org-scoped VMware // inventory has reached a settled initial baseline that has to be reflected in -// the canonical monitor store before billing can consume monitored-system +// the canonical monitor store before support surfaces consume monitored-system // counts. func (p *VMwarePoller) SupplementalInventoryReadyAt(_ *Monitor, orgID string) (time.Time, bool) { if p == nil { diff --git a/pkg/extensions/monitored_system_admission.go b/pkg/extensions/monitored_system_admission.go deleted file mode 100644 index b6a8c998d..000000000 --- a/pkg/extensions/monitored_system_admission.go +++ /dev/null @@ -1,33 +0,0 @@ -package extensions - -import "context" - -// MonitoredSystemAdmissionInput captures the public counted-system admission -// state computed by the OSS runtime before any private commercial policy hook -// is consulted. -type MonitoredSystemAdmissionInput struct { - Current int - Additional int - Limit int - UsageAvailable bool - UsageUnavailableReason string - CandidateCountsTowardCap bool -} - -// MonitoredSystemAdmissionDecision captures the commercial admission outcome -// that a private build may return. This intentionally stays scoped to backend -// admission semantics rather than customer-facing billing/posture messaging. -type MonitoredSystemAdmissionDecision struct { - Current int - Additional int - Limit int - UsageAvailable bool - UsageUnavailableReason string - Exceeded bool -} - -// ResolveMonitoredSystemAdmissionPolicyFunc allows private builds to own the -// commercial monitored-system admission decision without importing internal API -// packages. The public runtime remains the source of truth for counted-system -// projection and may call this hook in a later migration slice. -type ResolveMonitoredSystemAdmissionPolicyFunc func(context.Context, MonitoredSystemAdmissionInput) MonitoredSystemAdmissionDecision diff --git a/pkg/licensing/activation_types.go b/pkg/licensing/activation_types.go index 6fd42a6a1..2810dbfcb 100644 --- a/pkg/licensing/activation_types.go +++ b/pkg/licensing/activation_types.go @@ -37,64 +37,14 @@ type ActivationContinuity struct { // LegacyMigration marks installations activated from a supported v5 license // exchange rather than a native v6 activation key. LegacyMigration bool `json:"legacy_migration,omitempty"` - - // GrandfatheredMaxMonitoredSystems stores the deduped monitored-system floor - // captured for a migrated v5 installation when its existing estate exceeds - // the exchanged plan limit. - GrandfatheredMaxMonitoredSystems int `json:"grandfathered_max_monitored_systems,omitempty"` - - // GrandfatheredMonitoredSystemsCapturedAt marks that the migration floor was - // resolved exactly once from canonical runtime usage, even when no override - // was needed because the observed estate was already within the exchanged - // plan limit. - GrandfatheredMonitoredSystemsCapturedAt int64 `json:"grandfathered_monitored_systems_captured_at,omitempty"` } func normalizeActivationContinuity(continuity ActivationContinuity) ActivationContinuity { - if continuity.GrandfatheredMaxMonitoredSystems < 0 { - continuity.GrandfatheredMaxMonitoredSystems = 0 - } - if continuity.GrandfatheredMonitoredSystemsCapturedAt < 0 { - continuity.GrandfatheredMonitoredSystemsCapturedAt = 0 - } return continuity } func (c ActivationContinuity) needsLegacyMonitoredSystemCapture() bool { - c = normalizeActivationContinuity(c) - return c.LegacyMigration && c.GrandfatheredMonitoredSystemsCapturedAt == 0 -} - -func applyActivationContinuityToClaims(claims *Claims, continuity ActivationContinuity) { - if claims == nil { - return - } - - continuity = normalizeActivationContinuity(continuity) - if continuity.GrandfatheredMaxMonitoredSystems <= 0 { - return - } - if claims.shouldScrubLegacyCommercialCaps() { - return - } - - currentLimit := int64(0) - if existing, ok := claims.EffectiveLimits()[MaxMonitoredSystemsLicenseGateKey]; ok { - currentLimit = existing - } - if currentLimit >= int64(continuity.GrandfatheredMaxMonitoredSystems) { - return - } - - if claims.Limits == nil { - claims.Limits = map[string]int64{} - } else { - claims.Limits = NormalizeMonitoredSystemLimits(claims.Limits) - } - claims.Limits[MaxMonitoredSystemsLicenseGateKey] = int64(continuity.GrandfatheredMaxMonitoredSystems) - if claims.MaxMonitoredSystems < continuity.GrandfatheredMaxMonitoredSystems { - claims.MaxMonitoredSystems = continuity.GrandfatheredMaxMonitoredSystems - } + return false } func grantClaimsUseUncappedCoreMonitoring(gc *GrantClaims) bool { @@ -108,22 +58,21 @@ func grantClaimsUseUncappedCoreMonitoring(gc *GrantClaims) bool { // GrantClaims are the claims parsed from a relay grant JWT payload. // The grant is a short-lived JWT (72h TTL) issued by the license server. type GrantClaims struct { - Issuer string `json:"iss"` - Audience string `json:"aud"` - LicenseID string `json:"lid"` - InstallationID string `json:"iid"` - LicenseVersion int64 `json:"lv"` - State string `json:"st"` // active|past_due|grace - Tier string `json:"tier"` // matches Tier constants: "relay", "pro", "pro_plus", etc. - PlanKey string `json:"plan"` - Features []string `json:"feat"` - MaxMonitoredSystems int `json:"max_monitored_systems"` - MaxGuests int `json:"max_guests"` - IssuedAt int64 `json:"iat"` - ExpiresAt int64 `json:"exp"` - GraceUntil int64 `json:"grace_until"` - JTI string `json:"jti"` // unique grant ID - Email string `json:"email"` // license owner email + Issuer string `json:"iss"` + Audience string `json:"aud"` + LicenseID string `json:"lid"` + InstallationID string `json:"iid"` + LicenseVersion int64 `json:"lv"` + State string `json:"st"` // active|past_due|grace + Tier string `json:"tier"` // matches Tier constants: "relay", "pro", "pro_plus", etc. + PlanKey string `json:"plan"` + Features []string `json:"feat"` + MaxGuests int `json:"max_guests"` + IssuedAt int64 `json:"iat"` + ExpiresAt int64 `json:"exp"` + GraceUntil int64 `json:"grace_until"` + JTI string `json:"jti"` // unique grant ID + Email string `json:"email"` // license owner email } func (g *GrantClaims) UnmarshalJSON(data []byte) error { @@ -132,19 +81,16 @@ func (g *GrantClaims) UnmarshalJSON(data []byte) error { return err } - if legacy, ok, err := decodeLegacyV5MonitoredSystemLimitFromJSON(data); err == nil && ok { - g.MaxMonitoredSystems = legacy - } return nil } -// grantClaimsToClaims maps grant claims to the existing Claims struct -// so that all feature gating and monitored-system limits work unchanged. +// grantClaimsToClaims maps grant claims to the existing Claims struct so that +// feature gating works unchanged. func grantClaimsToClaims(gc *GrantClaims) Claims { return grantClaimsToClaimsWithContinuity(gc, ActivationContinuity{}) } -func grantClaimsToClaimsWithContinuity(gc *GrantClaims, continuity ActivationContinuity) Claims { +func grantClaimsToClaimsWithContinuity(gc *GrantClaims, _ ActivationContinuity) Claims { c := Claims{ LicenseID: gc.LicenseID, Email: gc.Email, @@ -152,7 +98,6 @@ func grantClaimsToClaimsWithContinuity(gc *GrantClaims, continuity ActivationCon IssuedAt: gc.IssuedAt, ExpiresAt: gc.ExpiresAt, Features: gc.Features, - MaxMonitoredSystems: gc.MaxMonitoredSystems, MaxGuests: gc.MaxGuests, PlanVersion: gc.PlanKey, CoreMonitoringUncapped: grantClaimsUseUncappedCoreMonitoring(gc), @@ -171,8 +116,6 @@ func grantClaimsToClaimsWithContinuity(gc *GrantClaims, continuity ActivationCon c.SubState = SubStateSuspended } - applyActivationContinuityToClaims(&c, continuity) - return c } @@ -271,13 +214,12 @@ type ActivateInstallationResponse struct { // ActivateResponseLicense is the license portion of the activation response. type ActivateResponseLicense struct { - LicenseID string `json:"license_id"` - State string `json:"state"` - Tier string `json:"tier"` - MaxMonitoredSystems int `json:"max_monitored_systems"` - MaxGuests int `json:"max_guests"` - Features []string `json:"features"` - LicenseVersion int64 `json:"license_version"` + LicenseID string `json:"license_id"` + State string `json:"state"` + Tier string `json:"tier"` + MaxGuests int `json:"max_guests"` + Features []string `json:"features"` + LicenseVersion int64 `json:"license_version"` } func (l *ActivateResponseLicense) UnmarshalJSON(data []byte) error { @@ -286,9 +228,6 @@ func (l *ActivateResponseLicense) UnmarshalJSON(data []byte) error { return err } - if legacy, ok, err := decodeLegacyV5MonitoredSystemLimitFromJSON(data); err == nil && ok { - l.MaxMonitoredSystems = legacy - } return nil } diff --git a/pkg/licensing/activation_types_test.go b/pkg/licensing/activation_types_test.go index b14fe3199..526089724 100644 --- a/pkg/licensing/activation_types_test.go +++ b/pkg/licensing/activation_types_test.go @@ -8,37 +8,34 @@ import ( func TestGrantClaimsToClaims(t *testing.T) { tests := []struct { - name string - gc *GrantClaims - wantTier Tier - wantSubState SubscriptionState - wantLicenseID string - wantEmail string - wantFeatures []string - wantMaxMonitoredSystems int - wantMaxGuests int + name string + gc *GrantClaims + wantTier Tier + wantSubState SubscriptionState + wantLicenseID string + wantEmail string + wantFeatures []string + wantMaxGuests int }{ { name: "active state with email", gc: &GrantClaims{ - LicenseID: "lic_123", - InstallationID: "inst_abc", - State: "active", - Tier: "pro", - Email: "user@example.com", - Features: []string{"ai_patrol", "relay"}, - MaxMonitoredSystems: 10, - MaxGuests: 5, - IssuedAt: time.Now().Unix(), - ExpiresAt: time.Now().Add(72 * time.Hour).Unix(), + LicenseID: "lic_123", + InstallationID: "inst_abc", + State: "active", + Tier: "pro", + Email: "user@example.com", + Features: []string{"ai_patrol", "relay"}, + MaxGuests: 5, + IssuedAt: time.Now().Unix(), + ExpiresAt: time.Now().Add(72 * time.Hour).Unix(), }, - wantTier: TierPro, - wantSubState: SubStateActive, - wantLicenseID: "lic_123", - wantEmail: "user@example.com", - wantFeatures: []string{"ai_patrol", "relay"}, - wantMaxMonitoredSystems: 10, - wantMaxGuests: 5, + wantTier: TierPro, + wantSubState: SubStateActive, + wantLicenseID: "lic_123", + wantEmail: "user@example.com", + wantFeatures: []string{"ai_patrol", "relay"}, + wantMaxGuests: 5, }, { name: "past_due maps to grace", @@ -118,9 +115,6 @@ func TestGrantClaimsToClaims(t *testing.T) { } } } - if c.MaxMonitoredSystems != tt.wantMaxMonitoredSystems { - t.Errorf("MaxMonitoredSystems = %d, want %d", c.MaxMonitoredSystems, tt.wantMaxMonitoredSystems) - } if c.MaxGuests != tt.wantMaxGuests { t.Errorf("MaxGuests = %d, want %d", c.MaxGuests, tt.wantMaxGuests) } @@ -131,13 +125,12 @@ func TestGrantClaimsToClaims(t *testing.T) { func TestGrantClaimsToLicense(t *testing.T) { t.Run("basic license from grant", func(t *testing.T) { gc := &GrantClaims{ - LicenseID: "lic_test", - State: "active", - Tier: "pro", - Features: []string{"relay", "ai_patrol"}, - MaxMonitoredSystems: 25, - IssuedAt: time.Now().Unix(), - ExpiresAt: time.Now().Add(72 * time.Hour).Unix(), + LicenseID: "lic_test", + State: "active", + Tier: "pro", + Features: []string{"relay", "ai_patrol"}, + IssuedAt: time.Now().Unix(), + ExpiresAt: time.Now().Add(72 * time.Hour).Unix(), } lic := grantClaimsToLicense(gc, "fake.jwt.token") @@ -150,9 +143,6 @@ func TestGrantClaimsToLicense(t *testing.T) { if lic.Claims.Tier != TierPro { t.Errorf("Tier = %q, want %q", lic.Claims.Tier, TierPro) } - if lic.Claims.MaxMonitoredSystems != 25 { - t.Errorf("MaxMonitoredSystems = %d, want 25", lic.Claims.MaxMonitoredSystems) - } if lic.GracePeriodEnd != nil { t.Error("GracePeriodEnd should be nil when no grace_until") } @@ -179,28 +169,22 @@ func TestGrantClaimsToLicense(t *testing.T) { }) } -func TestGrantClaimsToClaimsWithContinuityDoesNotWriteRawFloorForUncappedSelfHostedPro(t *testing.T) { +func TestGrantClaimsToClaimsWithContinuityDoesNotSurfaceRetiredMonitoredSystemLimit(t *testing.T) { gc := &GrantClaims{ - LicenseID: "lic_floor", - State: "active", - Tier: "pro", - MaxMonitoredSystems: 0, - IssuedAt: time.Now().Unix(), - ExpiresAt: time.Now().Add(72 * time.Hour).Unix(), + LicenseID: "lic_floor", + State: "active", + Tier: "pro", + IssuedAt: time.Now().Unix(), + ExpiresAt: time.Now().Add(72 * time.Hour).Unix(), } claims := grantClaimsToClaimsWithContinuity(gc, ActivationContinuity{ - LegacyMigration: true, - GrandfatheredMaxMonitoredSystems: 1, - GrandfatheredMonitoredSystemsCapturedAt: time.Now().Unix(), + LegacyMigration: true, }) if !claims.CoreMonitoringUncapped { t.Fatal("expected grant-backed self-hosted continuity claims to carry the uncapped core monitoring marker") } - if claims.MaxMonitoredSystems != 0 { - t.Fatalf("MaxMonitoredSystems = %d, want 0 for uncapped self-hosted Pro", claims.MaxMonitoredSystems) - } if _, ok := claims.Limits[MaxMonitoredSystemsLicenseGateKey]; ok { t.Fatalf("Limits[%q] present, want absent for uncapped self-hosted Pro", MaxMonitoredSystemsLicenseGateKey) } @@ -209,81 +193,21 @@ func TestGrantClaimsToClaimsWithContinuityDoesNotWriteRawFloorForUncappedSelfHos } } -func TestGrantClaimsToClaimsWithContinuityAppliesGrandfatherFloorToCappedGrant(t *testing.T) { - gc := &GrantClaims{ - LicenseID: "lic_floor_capped", - State: "active", - Tier: "legacy_capped", - MaxMonitoredSystems: 10, - IssuedAt: time.Now().Unix(), - ExpiresAt: time.Now().Add(72 * time.Hour).Unix(), - } - - claims := grantClaimsToClaimsWithContinuity(gc, ActivationContinuity{ - LegacyMigration: true, - GrandfatheredMaxMonitoredSystems: 23, - GrandfatheredMonitoredSystemsCapturedAt: time.Now().Unix(), - }) - - if claims.CoreMonitoringUncapped { - t.Fatal("did not expect capped continuity claims to carry the uncapped core monitoring marker") - } - if claims.MaxMonitoredSystems != 23 { - t.Fatalf("MaxMonitoredSystems = %d, want 23", claims.MaxMonitoredSystems) - } - if got := claims.EffectiveLimits()[MaxMonitoredSystemsLicenseGateKey]; got != 23 { - t.Fatalf("EffectiveLimits()[max_monitored_systems] = %d, want 23", got) - } -} - -func TestGrantClaimsToClaimsWithContinuityDoesNotLowerGrantLimit(t *testing.T) { - gc := &GrantClaims{ - LicenseID: "lic_noop", - State: "active", - Tier: "pro", - MaxMonitoredSystems: 15, - IssuedAt: time.Now().Unix(), - ExpiresAt: time.Now().Add(72 * time.Hour).Unix(), - } - - claims := grantClaimsToClaimsWithContinuity(gc, ActivationContinuity{ - LegacyMigration: true, - GrandfatheredMaxMonitoredSystems: 10, - GrandfatheredMonitoredSystemsCapturedAt: time.Now().Unix(), - }) - - // Floor of 10 is below grant's 15, so the raw field is left at 15. Either - // way, self-hosted Pro is uncapped so EffectiveLimits does not surface a cap. - if !claims.CoreMonitoringUncapped { - t.Fatal("expected grant-backed self-hosted continuity claims to carry the uncapped core monitoring marker") - } - if claims.MaxMonitoredSystems != 15 { - t.Fatalf("MaxMonitoredSystems = %d, want 15", claims.MaxMonitoredSystems) - } - if got, ok := claims.EffectiveLimits()[MaxMonitoredSystemsLicenseGateKey]; ok { - t.Fatalf("EffectiveLimits()[max_monitored_systems] = %d present, want absent (uncapped self-hosted)", got) - } -} - func TestGrantClaimsToClaimsCanonicalizesCloudPlanAtEntitlementBoundary(t *testing.T) { gc := &GrantClaims{ - LicenseID: "lic_cloud", - State: "active", - Tier: string(TierCloud), - PlanKey: "cloud_v1", - MaxMonitoredSystems: 999, - IssuedAt: time.Now().Unix(), - ExpiresAt: time.Now().Add(72 * time.Hour).Unix(), - GraceUntil: 0, + LicenseID: "lic_cloud", + State: "active", + Tier: string(TierCloud), + PlanKey: "cloud_v1", + IssuedAt: time.Now().Unix(), + ExpiresAt: time.Now().Add(72 * time.Hour).Unix(), + GraceUntil: 0, } claims := grantClaimsToClaims(gc) if got := claims.EntitlementPlanVersion(); got != "cloud_starter" { t.Fatalf("EntitlementPlanVersion()=%q, want %q", got, "cloud_starter") } - if got := claims.EffectiveLimits()["max_monitored_systems"]; got != 10 { - t.Fatalf("EffectiveLimits()[max_monitored_systems]=%d, want %d", got, 10) - } if got := claims.EntitlementSubscriptionState(); got != SubStateActive { t.Fatalf("EntitlementSubscriptionState()=%q, want %q", got, SubStateActive) } @@ -387,14 +311,13 @@ func TestParseGrantJWTUnsafe(t *testing.T) { { name: "valid grant", jwt: makeUnsignedTestGrantJWT(t, &GrantClaims{ - LicenseID: "lic_test", - InstallationID: "inst_abc", - State: "active", - Tier: "pro", - Features: []string{"relay"}, - MaxMonitoredSystems: 10, - IssuedAt: 1000, - ExpiresAt: 2000, + LicenseID: "lic_test", + InstallationID: "inst_abc", + State: "active", + Tier: "pro", + Features: []string{"relay"}, + IssuedAt: 1000, + ExpiresAt: 2000, }), check: func(t *testing.T, gc *GrantClaims) { if gc.LicenseID != "lic_test" { @@ -403,9 +326,6 @@ func TestParseGrantJWTUnsafe(t *testing.T) { if gc.Tier != "pro" { t.Errorf("Tier = %q, want %q", gc.Tier, "pro") } - if gc.MaxMonitoredSystems != 10 { - t.Errorf("MaxMonitoredSystems = %d, want 10", gc.MaxMonitoredSystems) - } if gc.State != "active" { t.Errorf("State = %q, want %q", gc.State, "active") } diff --git a/pkg/licensing/billing_state_normalization.go b/pkg/licensing/billing_state_normalization.go index 9c0aef4ef..70d2806cc 100644 --- a/pkg/licensing/billing_state_normalization.go +++ b/pkg/licensing/billing_state_normalization.go @@ -48,9 +48,6 @@ func NormalizeBillingState(state *BillingState) *BillingState { normalized.CommercialMigration = NormalizeCommercialMigrationStatus(normalized.CommercialMigration) normalized.Limits = NormalizeMonitoredSystemLimits(normalized.Limits) - if IsSelfHostedCoreMonitoringUncappedPlanVersion(normalized.PlanVersion) { - stripLegacyCommercialCaps(normalized.Limits) - } // Ensure slices/maps are never nil (JSON marshals as [] / {} instead of null). if normalized.Capabilities == nil { @@ -65,32 +62,17 @@ func NormalizeBillingState(state *BillingState) *BillingState { // Preserve absence when the stored hosted billing record has no plan label. // Canonical defaults still come from DefaultBillingState()/call-site defaults. normalized.PlanVersion = CanonicalizePlanVersion(normalized.PlanVersion) + stripSelfHostedCommercialVolumeCaps(normalized.Limits, normalized.PlanVersion, "", false) switch normalized.SubscriptionState { case SubStateExpired, SubStateSuspended, SubStateCanceled: normalized.Capabilities = []string{} normalized.Limits = map[string]int64{} normalized.MetersEnabled = []string{} - default: - if limit, ok := billingStateStoredMonitoredSystemLimit(normalized.PlanVersion); ok { - normalized.Limits[MaxMonitoredSystemsLicenseGateKey] = int64(limit) - } } return normalized } -func billingStateStoredMonitoredSystemLimit(planVersion string) (int, bool) { - planVersion = CanonicalizePlanVersion(planVersion) - if IsSelfHostedCoreMonitoringUncappedPlanVersion(planVersion) { - return 0, false - } - limit, known := CloudPlanMonitoredSystemLimits[planVersion] - if !known || limit <= 0 { - return 0, false - } - return limit, true -} - func IsValidBillingSubscriptionState(state SubscriptionState) bool { switch state { case SubStateTrial, diff --git a/pkg/licensing/billing_state_normalization_test.go b/pkg/licensing/billing_state_normalization_test.go index 727558631..d786f7ff9 100644 --- a/pkg/licensing/billing_state_normalization_test.go +++ b/pkg/licensing/billing_state_normalization_test.go @@ -29,7 +29,7 @@ func TestNormalizeBillingState(t *testing.T) { trialEnds := int64(200) input := &BillingState{ Capabilities: []string{" a ", "b"}, - Limits: map[string]int64{"max_monitored_systems": 10}, + Limits: map[string]int64{"max_monitored_systems": 10, "max_guests": 20}, MetersEnabled: []string{"meter_a"}, PlanVersion: " ", SubscriptionState: SubscriptionState(" ACTIVE "), @@ -48,20 +48,24 @@ func TestNormalizeBillingState(t *testing.T) { if normalized.StripeCustomerID != "cus_123" { t.Fatalf("stripe_customer_id=%q, want %q", normalized.StripeCustomerID, "cus_123") } - if normalized.Limits["max_monitored_systems"] != 10 { - t.Fatalf("limits[max_nodes]=%d, want 10", normalized.Limits["max_monitored_systems"]) + if _, ok := normalized.Limits["max_monitored_systems"]; ok { + t.Fatalf("expected retired max_monitored_systems to be scrubbed, got %v", normalized.Limits) + } + if normalized.Limits["max_guests"] != 20 { + t.Fatalf("limits[max_guests]=%d, want 20", normalized.Limits["max_guests"]) } input.Capabilities[0] = "changed" input.MetersEnabled[0] = "changed" input.Limits["max_monitored_systems"] = 99 + input.Limits["max_guests"] = 99 if normalized.Capabilities[0] != " a " { t.Fatalf("expected capabilities to be copied") } if normalized.MetersEnabled[0] != "meter_a" { t.Fatalf("expected meters_enabled to be copied") } - if normalized.Limits["max_monitored_systems"] != 10 { + if normalized.Limits["max_guests"] != 20 { t.Fatalf("expected limits map to be copied") } } @@ -80,7 +84,7 @@ func TestNormalizeBillingState_PreservesAllFields(t *testing.T) { input := &BillingState{ Capabilities: []string{"relay", "ai"}, - Limits: map[string]int64{"max_monitored_systems": 50}, + Limits: map[string]int64{"max_monitored_systems": 50, "max_guests": 100}, MetersEnabled: []string{"active_agents"}, PlanVersion: "pro-v2", SubscriptionState: SubStateActive, @@ -99,8 +103,11 @@ func TestNormalizeBillingState_PreservesAllFields(t *testing.T) { if len(normalized.Capabilities) != 2 { t.Fatalf("capabilities: got %v", normalized.Capabilities) } - if normalized.Limits["max_monitored_systems"] != 50 { - t.Fatalf("limits[max_nodes]: got %d", normalized.Limits["max_monitored_systems"]) + if _, ok := normalized.Limits["max_monitored_systems"]; ok { + t.Fatalf("expected retired max_monitored_systems to be scrubbed, got %v", normalized.Limits) + } + if normalized.Limits["max_guests"] != 100 { + t.Fatalf("limits[max_guests]: got %d", normalized.Limits["max_guests"]) } if normalized.MetersEnabled[0] != "active_agents" { t.Fatalf("meters_enabled: got %v", normalized.MetersEnabled) @@ -247,40 +254,40 @@ func TestIsValidBillingSubscriptionState(t *testing.T) { } } -func TestNormalizeBillingState_MaxNodesToMaxMonitoredSystemsMigration(t *testing.T) { - t.Run("legacy_key_migrated", func(t *testing.T) { +func TestNormalizeBillingState_ScrubsRetiredMonitoredSystemLimitAliases(t *testing.T) { + t.Run("legacy_key_removed", func(t *testing.T) { state := &BillingState{ - Limits: map[string]int64{"max_nodes": 10}, + Limits: map[string]int64{"max_nodes": 10, "max_reports": 7}, } normalized := NormalizeBillingState(state) - if normalized.Limits["max_monitored_systems"] != 10 { - t.Fatalf("expected max_monitored_systems=10, got %d", normalized.Limits["max_monitored_systems"]) - } if _, hasOld := normalized.Limits["max_nodes"]; hasOld { - t.Fatal("expected max_nodes to be deleted after migration") + t.Fatal("expected max_nodes to be deleted") + } + if got := normalized.Limits["max_reports"]; got != 7 { + t.Fatalf("limits[max_reports]=%d, want 7", got) } }) - t.Run("new_key_preserved_legacy_deleted", func(t *testing.T) { + t.Run("new_key_and_legacy_deleted", func(t *testing.T) { state := &BillingState{ Limits: map[string]int64{"max_monitored_systems": 15, "max_nodes": 5}, } normalized := NormalizeBillingState(state) - if normalized.Limits["max_monitored_systems"] != 15 { - t.Fatalf("expected max_monitored_systems=15, got %d", normalized.Limits["max_monitored_systems"]) + if _, ok := normalized.Limits["max_monitored_systems"]; ok { + t.Fatalf("expected max_monitored_systems to be deleted, got %v", normalized.Limits) } if _, hasOld := normalized.Limits["max_nodes"]; hasOld { - t.Fatal("expected max_nodes to be deleted when max_monitored_systems exists") + t.Fatal("expected max_nodes to be deleted") } }) - t.Run("no_legacy_key_no_change", func(t *testing.T) { + t.Run("retired_new_key_deleted", func(t *testing.T) { state := &BillingState{ Limits: map[string]int64{"max_monitored_systems": 20}, } normalized := NormalizeBillingState(state) - if normalized.Limits["max_monitored_systems"] != 20 { - t.Fatalf("expected max_monitored_systems=20, got %d", normalized.Limits["max_monitored_systems"]) + if _, ok := normalized.Limits["max_monitored_systems"]; ok { + t.Fatalf("expected max_monitored_systems to be deleted, got %v", normalized.Limits) } }) } @@ -291,6 +298,7 @@ func TestNormalizeBillingState_CanonicalizesCloudPlanVersionAndLimits(t *testing Limits: map[string]int64{ "max_monitored_systems": 999, "max_nodes": 5, + "max_guests": 7, }, } @@ -298,12 +306,15 @@ func TestNormalizeBillingState_CanonicalizesCloudPlanVersionAndLimits(t *testing if normalized.PlanVersion != "cloud_starter" { t.Fatalf("plan_version=%q, want %q", normalized.PlanVersion, "cloud_starter") } - if got := normalized.Limits["max_monitored_systems"]; got != 10 { - t.Fatalf("limits[max_monitored_systems]=%d, want %d", got, 10) + if _, ok := normalized.Limits["max_monitored_systems"]; ok { + t.Fatalf("expected max_monitored_systems to be scrubbed, got %v", normalized.Limits) } if _, hasOld := normalized.Limits["max_nodes"]; hasOld { t.Fatal("expected max_nodes to be deleted during normalization") } + if got := normalized.Limits["max_guests"]; got != 7 { + t.Fatalf("limits[max_guests]=%d, want 7", got) + } } func TestNormalizeBillingState_GrandfatheredRecurringPlanStripsLegacyCommercialCaps(t *testing.T) { @@ -337,6 +348,7 @@ func TestNormalizeBillingState_PreservesUnknownNonCloudPlanLimits(t *testing.T) PlanVersion: "pro-v2", Limits: map[string]int64{ "max_monitored_systems": 50, + "max_guests": 7, }, } @@ -344,8 +356,11 @@ func TestNormalizeBillingState_PreservesUnknownNonCloudPlanLimits(t *testing.T) if normalized.PlanVersion != "pro-v2" { t.Fatalf("plan_version=%q, want %q", normalized.PlanVersion, "pro-v2") } - if got := normalized.Limits["max_monitored_systems"]; got != 50 { - t.Fatalf("limits[max_monitored_systems]=%d, want %d", got, 50) + if _, ok := normalized.Limits["max_monitored_systems"]; ok { + t.Fatalf("expected max_monitored_systems to be scrubbed, got %v", normalized.Limits) + } + if got := normalized.Limits["max_guests"]; got != 7 { + t.Fatalf("limits[max_guests]=%d, want 7", got) } } diff --git a/pkg/licensing/cloud_paid_guardrails_test.go b/pkg/licensing/cloud_paid_guardrails_test.go index 85ebc4e97..90093bec6 100644 --- a/pkg/licensing/cloud_paid_guardrails_test.go +++ b/pkg/licensing/cloud_paid_guardrails_test.go @@ -2,10 +2,10 @@ package licensing import "testing" -func TestNormalizeBillingStatePreservesMissingPlanVersion(t *testing.T) { +func TestNormalizeBillingStatePreservesMissingPlanVersionAndScrubsRetiredMonitoringLimit(t *testing.T) { state := &BillingState{ PlanVersion: " ", - Limits: map[string]int64{"max_monitored_systems": 42}, + Limits: map[string]int64{"max_monitored_systems": 42, "max_guests": 7}, SubscriptionState: SubscriptionState(" ACTIVE "), } @@ -16,39 +16,49 @@ func TestNormalizeBillingStatePreservesMissingPlanVersion(t *testing.T) { if normalized.SubscriptionState != SubStateActive { t.Fatalf("subscription_state=%q, want %q", normalized.SubscriptionState, SubStateActive) } - if got := normalized.Limits["max_monitored_systems"]; got != 42 { - t.Fatalf("limits[max_monitored_systems]=%d, want %d", got, 42) + if _, ok := normalized.Limits["max_monitored_systems"]; ok { + t.Fatalf("limits retained retired max_monitored_systems: %v", normalized.Limits) + } + if got := normalized.Limits["max_guests"]; got != 7 { + t.Fatalf("limits[max_guests]=%d, want %d", got, 7) } } -func TestNormalizeEntitlementLeaseClaimsPreservesMissingPlanVersion(t *testing.T) { +func TestNormalizeEntitlementLeaseClaimsPreservesMissingPlanVersionAndScrubsRetiredMonitoringLimit(t *testing.T) { claims := &EntitlementLeaseClaims{ PlanVersion: " ", SubscriptionState: SubStateActive, - Limits: map[string]int64{"max_monitored_systems": 42}, + Limits: map[string]int64{"max_monitored_systems": 42, "max_guests": 7}, } normalizeEntitlementLeaseClaims(claims) if claims.PlanVersion != "" { t.Fatalf("plan_version=%q, want empty", claims.PlanVersion) } - if got := claims.Limits["max_monitored_systems"]; got != 42 { - t.Fatalf("limits[max_monitored_systems]=%d, want %d", got, 42) + if _, ok := claims.Limits["max_monitored_systems"]; ok { + t.Fatalf("limits retained retired max_monitored_systems: %v", claims.Limits) + } + if got := claims.Limits["max_guests"]; got != 7 { + t.Fatalf("limits[max_guests]=%d, want %d", got, 7) } } -func TestClaimsPreserveMissingPlanVersion(t *testing.T) { +func TestClaimsPreserveMissingPlanVersionAndScrubRetiredMonitoringLimit(t *testing.T) { claims := &Claims{ Tier: TierCloud, PlanVersion: " ", - Limits: map[string]int64{"max_monitored_systems": 42}, + Limits: map[string]int64{"max_monitored_systems": 42, "max_guests": 7}, } if got := claims.EntitlementPlanVersion(); got != "" { t.Fatalf("EntitlementPlanVersion()=%q, want empty", got) } - if got := claims.EffectiveLimits()["max_monitored_systems"]; got != 42 { - t.Fatalf("EffectiveLimits()[max_monitored_systems]=%d, want %d", got, 42) + limits := claims.EffectiveLimits() + if _, ok := limits["max_monitored_systems"]; ok { + t.Fatalf("EffectiveLimits retained retired max_monitored_systems: %v", limits) + } + if got := limits["max_guests"]; got != 7 { + t.Fatalf("EffectiveLimits()[max_guests]=%d, want %d", got, 7) } } @@ -56,7 +66,7 @@ func TestTokenSourcePreservesMissingPlanVersionContract(t *testing.T) { source := NewTokenSource(stubTokenClaims{ planVersion: "", subscriptionState: SubStateActive, - limits: map[string]int64{"max_monitored_systems": 42}, + limits: map[string]int64{"max_guests": 7}, }) if got := source.PlanVersion(); got != "" { @@ -65,12 +75,12 @@ func TestTokenSourcePreservesMissingPlanVersionContract(t *testing.T) { if got := source.SubscriptionState(); got != SubStateActive { t.Fatalf("SubscriptionState()=%q, want %q", got, SubStateActive) } - if got := source.Limits()["max_monitored_systems"]; got != 42 { - t.Fatalf("Limits()[max_monitored_systems]=%d, want %d", got, 42) + if got := source.Limits()["max_guests"]; got != 7 { + t.Fatalf("Limits()[max_guests]=%d, want %d", got, 7) } } -func TestCloudClaimsMissingPlanVersionFailClosedOnMonitoredSystemLimit(t *testing.T) { +func TestCloudClaimsMissingPlanVersionDoesNotReintroduceMonitoringLimit(t *testing.T) { claims := &Claims{ Tier: TierCloud, PlanVersion: " ", @@ -79,7 +89,7 @@ func TestCloudClaimsMissingPlanVersionFailClosedOnMonitoredSystemLimit(t *testin if got := claims.EntitlementPlanVersion(); got != "" { t.Fatalf("EntitlementPlanVersion()=%q, want empty", got) } - if got := claims.EffectiveLimits()["max_monitored_systems"]; got != int64(UnknownPlanDefaultMonitoredSystemLimit) { - t.Fatalf("EffectiveLimits()[max_monitored_systems]=%d, want %d", got, UnknownPlanDefaultMonitoredSystemLimit) + if _, ok := claims.EffectiveLimits()["max_monitored_systems"]; ok { + t.Fatalf("EffectiveLimits retained retired max_monitored_systems: %v", claims.EffectiveLimits()) } } diff --git a/pkg/licensing/database_source.go b/pkg/licensing/database_source.go index fcfeee0c1..5aef4e932 100644 --- a/pkg/licensing/database_source.go +++ b/pkg/licensing/database_source.go @@ -243,22 +243,13 @@ func normalizeDatabaseSourceState(state BillingState) BillingState { normalized.CommercialMigration = NormalizeCommercialMigrationStatus(normalized.CommercialMigration) normalized.Limits = NormalizeMonitoredSystemLimits(normalized.Limits) - if IsSelfHostedCoreMonitoringUncappedPlanVersion(normalized.PlanVersion) { - stripLegacyCommercialCaps(normalized.Limits) - } + stripSelfHostedCommercialVolumeCaps(normalized.Limits, normalized.PlanVersion, "", false) switch normalized.SubscriptionState { case SubStateExpired, SubStateSuspended, SubStateCanceled: normalized.Capabilities = nil normalized.Limits = nil normalized.MetersEnabled = nil - default: - if limit, known := CloudPlanMonitoredSystemLimits[normalized.PlanVersion]; known && limit > 0 { - if normalized.Limits == nil { - normalized.Limits = map[string]int64{} - } - normalized.Limits[MaxMonitoredSystemsLicenseGateKey] = int64(limit) - } } return normalized diff --git a/pkg/licensing/database_source_test.go b/pkg/licensing/database_source_test.go index c889d272e..19ffe6e73 100644 --- a/pkg/licensing/database_source_test.go +++ b/pkg/licensing/database_source_test.go @@ -56,7 +56,7 @@ func TestDatabaseSourceHappyPath(t *testing.T) { store := &mockBillingStore{ state: &BillingState{ Capabilities: []string{"rbac", "relay"}, - Limits: map[string]int64{"max_monitored_systems": 50}, + Limits: map[string]int64{"max_monitored_systems": 50, "max_guests": 75}, MetersEnabled: []string{"active_agents"}, PlanVersion: "pro-v2", SubscriptionState: SubStateActive, @@ -68,8 +68,8 @@ func TestDatabaseSourceHappyPath(t *testing.T) { if got := source.Capabilities(); !reflect.DeepEqual(got, []string{"rbac", "relay"}) { t.Fatalf("expected capabilities %v, got %v", []string{"rbac", "relay"}, got) } - if got := source.Limits(); !reflect.DeepEqual(got, map[string]int64{"max_monitored_systems": 50}) { - t.Fatalf("expected limits %v, got %v", map[string]int64{"max_monitored_systems": 50}, got) + if got := source.Limits(); !reflect.DeepEqual(got, map[string]int64{"max_guests": 75}) { + t.Fatalf("expected limits %v, got %v", map[string]int64{"max_guests": 75}, got) } if got := source.MetersEnabled(); !reflect.DeepEqual(got, []string{"active_agents"}) { t.Fatalf("expected meters %v, got %v", []string{"active_agents"}, got) @@ -86,20 +86,23 @@ func TestDatabaseSourceHappyPath(t *testing.T) { } } -func TestDatabaseSourceLimits_MaxNodesMigration(t *testing.T) { +func TestDatabaseSourceLimits_ScrubsRetiredMonitoredSystemAliases(t *testing.T) { store := &mockBillingStore{ state: &BillingState{ - Limits: map[string]int64{"max_nodes": 25}, + Limits: map[string]int64{"max_nodes": 25, "max_reports": 7}, SubscriptionState: SubStateActive, }, } source := NewDatabaseSource(store, "org-1", time.Hour) got := source.Limits() - if got["max_monitored_systems"] != 25 { - t.Fatalf("expected max_monitored_systems=25, got %d", got["max_monitored_systems"]) + if _, ok := got["max_monitored_systems"]; ok { + t.Fatalf("expected max_monitored_systems to be absent, got %v", got) } if _, hasOld := got["max_nodes"]; hasOld { - t.Fatal("expected max_nodes to be absent after migration") + t.Fatal("expected max_nodes to be absent") + } + if got["max_reports"] != 7 { + t.Fatalf("expected max_reports=7, got %d", got["max_reports"]) } } @@ -107,7 +110,7 @@ func TestDatabaseSourceCanonicalizesCloudPlanVersionAndLimits(t *testing.T) { store := &mockBillingStore{ state: &BillingState{ PlanVersion: "cloud_v1", - Limits: map[string]int64{"max_monitored_systems": 999}, + Limits: map[string]int64{"max_monitored_systems": 999, "max_guests": 7}, SubscriptionState: SubStateActive, }, } @@ -117,8 +120,12 @@ func TestDatabaseSourceCanonicalizesCloudPlanVersionAndLimits(t *testing.T) { if got := source.PlanVersion(); got != "cloud_starter" { t.Fatalf("expected plan_version %q, got %q", "cloud_starter", got) } - if got := source.Limits()["max_monitored_systems"]; got != 10 { - t.Fatalf("expected max_monitored_systems=%d, got %d", 10, got) + limits := source.Limits() + if _, ok := limits["max_monitored_systems"]; ok { + t.Fatalf("expected max_monitored_systems to be absent, got %v", limits) + } + if got := limits["max_guests"]; got != 7 { + t.Fatalf("expected max_guests=%d, got %d", 7, got) } } @@ -182,7 +189,7 @@ func TestDatabaseSourcePreservesMissingPlanVersion(t *testing.T) { store := &mockBillingStore{ state: &BillingState{ PlanVersion: " ", - Limits: map[string]int64{"max_monitored_systems": 42}, + Limits: map[string]int64{"max_monitored_systems": 42, "max_guests": 7}, SubscriptionState: SubscriptionState(" ACTIVE "), }, } @@ -195,8 +202,12 @@ func TestDatabaseSourcePreservesMissingPlanVersion(t *testing.T) { if got := source.SubscriptionState(); got != SubStateActive { t.Fatalf("expected subscription_state %q, got %q", SubStateActive, got) } - if got := source.Limits()["max_monitored_systems"]; got != 42 { - t.Fatalf("expected max_monitored_systems=%d, got %d", 42, got) + limits := source.Limits() + if _, ok := limits["max_monitored_systems"]; ok { + t.Fatalf("expected max_monitored_systems to be absent, got %v", limits) + } + if got := limits["max_guests"]; got != 7 { + t.Fatalf("expected max_guests=%d, got %d", 7, got) } } @@ -384,7 +395,7 @@ func TestDatabaseSourceLeaseOnlyStateResolvesTrialEntitlement(t *testing.T) { PlanVersion: trialState.PlanVersion, SubscriptionState: trialState.SubscriptionState, Capabilities: append([]string(nil), trialState.Capabilities...), - Limits: map[string]int64{"max_monitored_systems": 25}, + Limits: map[string]int64{"max_monitored_systems": 25, "max_guests": 7}, TrialStartedAt: trialState.TrialStartedAt, TrialEndsAt: trialState.TrialEndsAt, RegisteredClaims: jwt.RegisteredClaims{ @@ -410,8 +421,8 @@ func TestDatabaseSourceLeaseOnlyStateResolvesTrialEntitlement(t *testing.T) { if got := source.Capabilities(); !reflect.DeepEqual(got, []string{"ai_autofix"}) { t.Fatalf("expected capabilities %v, got %v", []string{"ai_autofix"}, got) } - if got := source.Limits(); !reflect.DeepEqual(got, map[string]int64{"max_monitored_systems": 25}) { - t.Fatalf("expected limits %v, got %v", map[string]int64{"max_monitored_systems": 25}, got) + if got := source.Limits(); !reflect.DeepEqual(got, map[string]int64{"max_guests": 7}) { + t.Fatalf("expected limits %v, got %v", map[string]int64{"max_guests": 7}, got) } if got := source.TrialStartedAt(); got == nil || *got != *trialState.TrialStartedAt { t.Fatalf("expected trial_started_at %v, got %v", trialState.TrialStartedAt, got) @@ -434,7 +445,7 @@ func TestDatabaseSourceLeaseOnlyStatePreservesMissingPlanVersion(t *testing.T) { InstanceHost: "pulse.example.com", PlanVersion: " ", SubscriptionState: SubStateActive, - Limits: map[string]int64{"max_monitored_systems": 42}, + Limits: map[string]int64{"max_monitored_systems": 42, "max_guests": 7}, RegisteredClaims: jwt.RegisteredClaims{ IssuedAt: jwt.NewNumericDate(now), ExpiresAt: jwt.NewNumericDate(now.Add(time.Hour)), @@ -457,8 +468,8 @@ func TestDatabaseSourceLeaseOnlyStatePreservesMissingPlanVersion(t *testing.T) { if got := source.SubscriptionState(); got != SubStateActive { t.Fatalf("expected subscription_state %q, got %q", SubStateActive, got) } - if got := source.Limits(); !reflect.DeepEqual(got, map[string]int64{"max_monitored_systems": 42}) { - t.Fatalf("expected limits %v, got %v", map[string]int64{"max_monitored_systems": 42}, got) + if got := source.Limits(); !reflect.DeepEqual(got, map[string]int64{"max_guests": 7}) { + t.Fatalf("expected limits %v, got %v", map[string]int64{"max_guests": 7}, got) } } diff --git a/pkg/licensing/entitlement_lease.go b/pkg/licensing/entitlement_lease.go index 3a3451c42..5ba61c96d 100644 --- a/pkg/licensing/entitlement_lease.go +++ b/pkg/licensing/entitlement_lease.go @@ -56,15 +56,10 @@ func normalizeEntitlementLeaseClaims(claims *EntitlementLeaseClaims) { claims.PlanVersion = CanonicalizePlanVersion(strings.TrimSpace(claims.PlanVersion)) claims.Capabilities = cloneStringSlice(claims.Capabilities) claims.Limits = NormalizeMonitoredSystemLimits(claims.Limits) + stripSelfHostedCommercialVolumeCaps(claims.Limits, claims.PlanVersion, "", false) claims.MetersEnabled = cloneStringSlice(claims.MetersEnabled) claims.TrialStartedAt = cloneInt64Ptr(claims.TrialStartedAt) claims.TrialEndsAt = cloneInt64Ptr(claims.TrialEndsAt) - if limit, known := CloudPlanMonitoredSystemLimits[claims.PlanVersion]; known { - if claims.Limits == nil { - claims.Limits = map[string]int64{} - } - claims.Limits[MaxMonitoredSystemsLicenseGateKey] = int64(limit) - } } // SignEntitlementLeaseToken signs a hosted entitlement lease JWT. diff --git a/pkg/licensing/entitlement_lease_test.go b/pkg/licensing/entitlement_lease_test.go index e63fe672e..4c13b8a54 100644 --- a/pkg/licensing/entitlement_lease_test.go +++ b/pkg/licensing/entitlement_lease_test.go @@ -24,7 +24,7 @@ func TestSignAndVerifyEntitlementLeaseToken(t *testing.T) { PlanVersion: string(SubStateTrial), SubscriptionState: SubStateTrial, Capabilities: []string{"ai_autofix"}, - Limits: map[string]int64{"max_monitored_systems": 25}, + Limits: map[string]int64{"max_monitored_systems": 25, "max_guests": 7}, MetersEnabled: []string{"agents"}, TrialStartedAt: &startedAt, TrialEndsAt: &endsAt, @@ -51,8 +51,11 @@ func TestSignAndVerifyEntitlementLeaseToken(t *testing.T) { if claims.SubscriptionState != SubStateTrial { t.Fatalf("claims.SubscriptionState=%q, want %q", claims.SubscriptionState, SubStateTrial) } - if got := claims.Limits["max_monitored_systems"]; got != 25 { - t.Fatalf("claims.Limits[max_monitored_systems]=%d, want %d", got, 25) + if _, ok := claims.Limits["max_monitored_systems"]; ok { + t.Fatalf("claims retained retired max_monitored_systems: %v", claims.Limits) + } + if got := claims.Limits["max_guests"]; got != 7 { + t.Fatalf("claims.Limits[max_guests]=%d, want %d", got, 7) } } @@ -132,7 +135,7 @@ func TestEntitlementLeaseCanonicalizesCloudPlanVersionAndLimits(t *testing.T) { InstanceHost: "pulse.example.com", PlanVersion: " cloud_v1 ", SubscriptionState: SubStateActive, - Limits: map[string]int64{"max_monitored_systems": 999}, + Limits: map[string]int64{"max_monitored_systems": 999, "max_guests": 7}, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(5 * time.Minute)), }, @@ -148,8 +151,11 @@ func TestEntitlementLeaseCanonicalizesCloudPlanVersionAndLimits(t *testing.T) { if claims.PlanVersion != "cloud_starter" { t.Fatalf("claims.PlanVersion=%q, want %q", claims.PlanVersion, "cloud_starter") } - if got := claims.Limits["max_monitored_systems"]; got != 10 { - t.Fatalf("claims.Limits[max_monitored_systems]=%d, want %d", got, 10) + if _, ok := claims.Limits["max_monitored_systems"]; ok { + t.Fatalf("claims retained retired max_monitored_systems: %v", claims.Limits) + } + if got := claims.Limits["max_guests"]; got != 7 { + t.Fatalf("claims.Limits[max_guests]=%d, want %d", got, 7) } } @@ -164,7 +170,7 @@ func TestEntitlementLeasePreservesNonCloudLimits(t *testing.T) { InstanceHost: "pulse.example.com", PlanVersion: "pro-v2", SubscriptionState: SubStateActive, - Limits: map[string]int64{"max_monitored_systems": 42}, + Limits: map[string]int64{"max_monitored_systems": 42, "max_guests": 7}, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(5 * time.Minute)), }, @@ -180,8 +186,11 @@ func TestEntitlementLeasePreservesNonCloudLimits(t *testing.T) { if claims.PlanVersion != "pro-v2" { t.Fatalf("claims.PlanVersion=%q, want %q", claims.PlanVersion, "pro-v2") } - if got := claims.Limits["max_monitored_systems"]; got != 42 { - t.Fatalf("claims.Limits[max_monitored_systems]=%d, want %d", got, 42) + if _, ok := claims.Limits["max_monitored_systems"]; ok { + t.Fatalf("claims retained retired max_monitored_systems: %v", claims.Limits) + } + if got := claims.Limits["max_guests"]; got != 7 { + t.Fatalf("claims.Limits[max_guests]=%d, want %d", got, 7) } } @@ -196,7 +205,7 @@ func TestEntitlementLeasePreservesMissingPlanVersion(t *testing.T) { InstanceHost: "pulse.example.com", PlanVersion: " ", SubscriptionState: SubStateActive, - Limits: map[string]int64{"max_monitored_systems": 42}, + Limits: map[string]int64{"max_monitored_systems": 42, "max_guests": 7}, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(5 * time.Minute)), }, @@ -215,8 +224,11 @@ func TestEntitlementLeasePreservesMissingPlanVersion(t *testing.T) { if claims.SubscriptionState != SubStateActive { t.Fatalf("claims.SubscriptionState=%q, want %q", claims.SubscriptionState, SubStateActive) } - if got := claims.Limits["max_monitored_systems"]; got != 42 { - t.Fatalf("claims.Limits[max_monitored_systems]=%d, want %d", got, 42) + if _, ok := claims.Limits["max_monitored_systems"]; ok { + t.Fatalf("claims retained retired max_monitored_systems: %v", claims.Limits) + } + if got := claims.Limits["max_guests"]; got != 7 { + t.Fatalf("claims.Limits[max_guests]=%d, want %d", got, 7) } } @@ -232,7 +244,7 @@ func TestResolveEntitlementLeaseBillingStatePreservesMissingPlanVersion(t *testi InstanceHost: "pulse.example.com", PlanVersion: " ", SubscriptionState: SubStateActive, - Limits: map[string]int64{"max_monitored_systems": 42}, + Limits: map[string]int64{"max_monitored_systems": 42, "max_guests": 7}, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(5 * time.Minute)), }, @@ -250,7 +262,10 @@ func TestResolveEntitlementLeaseBillingStatePreservesMissingPlanVersion(t *testi if resolved.SubscriptionState != SubStateActive { t.Fatalf("resolved.SubscriptionState=%q, want %q", resolved.SubscriptionState, SubStateActive) } - if got := resolved.Limits["max_monitored_systems"]; got != 42 { - t.Fatalf("resolved.Limits[max_monitored_systems]=%d, want %d", got, 42) + if _, ok := resolved.Limits["max_monitored_systems"]; ok { + t.Fatalf("resolved retained retired max_monitored_systems: %v", resolved.Limits) + } + if got := resolved.Limits["max_guests"]; got != 7 { + t.Fatalf("resolved.Limits[max_guests]=%d, want %d", got, 7) } } diff --git a/pkg/licensing/entitlement_payload.go b/pkg/licensing/entitlement_payload.go index 80f6439d9..d987ac2bf 100644 --- a/pkg/licensing/entitlement_payload.go +++ b/pkg/licensing/entitlement_payload.go @@ -72,32 +72,21 @@ type EntitlementPayload struct { // Indicates the number of days remaining in the 14-day overflow window. OverflowDaysRemaining *int `json:"overflow_days_remaining,omitempty"` - // LegacyConnections is retained for response compatibility. Monitored-system - // enforcement now counts API-backed and agent-backed top-level systems - // together, so this field is informational only. + // LegacyConnections is retained for response compatibility and informational + // estate review only. LegacyConnections LegacyConnectionCounts `json:"legacy_connections"` - // HasMigrationGap is retained for response compatibility. API-backed systems - // now count toward the same monitored-system cap as agent-backed systems. + // HasMigrationGap is retained for response compatibility. It no longer + // describes a commercial volume limit. HasMigrationGap bool `json:"has_migration_gap"` // 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"` - - // MonitoredSystemCapacity exposes the canonical monitored-system - // admission posture so the frontend can distinguish between a hard cap, - // an admission freeze, and uncapped continuity without inferring that - // behavior from raw current/limit math. - MonitoredSystemCapacity *MonitoredSystemCapacityStatus `json:"monitored_system_capacity,omitempty"` } // CommercialPosturePayload is the canonical non-billing commercial contract -// for upgrade messaging and monitored-system migration copy. +// for upgrade messaging and paid migration copy. // It intentionally excludes billing identity, grandfathered plan terms, and // other full-entitlement details that belong only to billing surfaces. type CommercialPosturePayload struct { @@ -125,22 +114,17 @@ type CommercialPosturePayload struct { // OverflowDaysRemaining is set when the onboarding overflow (+1 host) is active. OverflowDaysRemaining *int `json:"overflow_days_remaining,omitempty"` - // LegacyConnections is retained for response compatibility. Monitored-system - // enforcement now counts API-backed and agent-backed top-level systems - // together, so this field is informational only. + // LegacyConnections is retained for response compatibility and informational + // estate review only. LegacyConnections LegacyConnectionCounts `json:"legacy_connections"` - // HasMigrationGap is retained for response compatibility. API-backed systems - // now count toward the same monitored-system cap as agent-backed systems. + // HasMigrationGap is retained for response compatibility. It no longer + // describes a commercial volume limit. HasMigrationGap bool `json:"has_migration_gap"` // CommercialMigration reports unresolved paid-license migration work entering // from v5-era commercial state. CommercialMigration *CommercialMigrationStatus `json:"commercial_migration,omitempty"` - - // MonitoredSystemCapacity exposes the canonical monitored-system admission - // posture without exposing billing identity or plan-term internals. - MonitoredSystemCapacity *MonitoredSystemCapacityStatus `json:"monitored_system_capacity,omitempty"` } // RuntimeCapabilitiesPayload is the canonical non-commercial license contract @@ -157,15 +141,11 @@ type RuntimeCapabilitiesPayload struct { // MaxHistoryDays is the maximum metrics history retention in days for the current tier. MaxHistoryDays int `json:"max_history_days"` - - // MonitoredSystemCapacity exposes the canonical monitored-system runtime - // posture for warning banners and admission-freeze UX. - MonitoredSystemCapacity *MonitoredSystemCapacityStatus `json:"monitored_system_capacity,omitempty"` } // LimitStatus represents a quantitative limit with current usage state. type LimitStatus struct { - // Key is the limit identifier (e.g., "max_monitored_systems"). + // Key is the limit identifier (e.g., "max_guests"). Key string `json:"key"` // Limit is the maximum allowed value (0 = unlimited). @@ -174,69 +154,11 @@ type LimitStatus struct { // Current is the observed current usage. Current int64 `json:"current"` - // CurrentAvailable reports whether Current reflects a resolved runtime - // 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"` } -// MonitoredSystemCapacityStatus describes the canonical monitored-system -// admission posture. It makes explicit that Pulse blocks net-new monitored -// systems at or above the plan limit while keeping already-counted systems -// visible and reporting. -type MonitoredSystemCapacityStatus struct { - // Mode is the canonical monitored-system capacity posture. - // Values: "usage_unavailable", "unlimited", "within_limit", - // "at_limit_blocking_new", "over_limit_frozen" - Mode string `json:"mode"` - - // Urgency mirrors the user-facing severity of the current posture. - // Values: "ok", "warning", "enforced" - Urgency string `json:"urgency"` - - // Current is the observed current monitored-system usage. - Current int64 `json:"current"` - - // Limit is the plan limit for monitored systems (0 = unlimited). - Limit int64 `json:"limit"` - - // CurrentAvailable reports whether Current reflects a resolved runtime - // usage value rather than an unavailable best-effort fallback. - CurrentAvailable bool `json:"current_available"` - - // CurrentUnavailableReason explains why Current is unavailable when - // CurrentAvailable is false. - CurrentUnavailableReason string `json:"current_unavailable_reason,omitempty"` - - // AvailableSlots reports how many net-new monitored systems can be added - // before the plan blocks additional admissions. - AvailableSlots int64 `json:"available_slots"` - - // Overage reports how far above the current plan limit this installation - // is while existing monitoring continues. - Overage int64 `json:"overage"` - - // Reason explains why the current monitored-system posture is legitimate. - // Values: "limit_reached", "preexisting_usage", - // "legacy_migration_capture_pending" - Reason string `json:"reason,omitempty"` - - // BlocksNewSystems indicates that Pulse will reject net-new monitored - // systems until capacity is freed or the plan changes. - BlocksNewSystems bool `json:"blocks_new_systems"` - - // ExistingMonitoringContinues indicates that already-counted monitored - // systems remain visible and reporting under the current posture. - ExistingMonitoringContinues bool `json:"existing_monitoring_continues"` -} - // UpgradeReason provides context for why a user should upgrade. type UpgradeReason struct { // Key is the capability or limit this reason relates to. @@ -284,21 +206,6 @@ type EntitlementUsageSnapshot struct { LegacyConnections LegacyConnectionCounts } -func (s EntitlementUsageSnapshot) monitoredSystemCount() int64 { - if !s.MonitoredSystemsAvailable || s.MonitoredSystems < 0 { - return 0 - } - return s.MonitoredSystems -} - -func (s EntitlementUsageSnapshot) monitoredSystemCountAvailable() bool { - return s.MonitoredSystemsAvailable -} - -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) @@ -344,9 +251,6 @@ func BuildRuntimeCapabilitiesPayloadWithUsage( Limits: cloneLimitStatuses(entitlementPayload.Limits), HostedMode: entitlementPayload.HostedMode, MaxHistoryDays: entitlementPayload.MaxHistoryDays, - MonitoredSystemCapacity: cloneMonitoredSystemCapacityStatus( - entitlementPayload.MonitoredSystemCapacity, - ), } } @@ -383,27 +287,12 @@ func CommercialPosturePayloadFromEntitlementPayload( if payload.CommercialMigration != nil { sanitized.CommercialMigration = CloneCommercialMigrationStatus(payload.CommercialMigration) } - if payload.MonitoredSystemCapacity != nil { - sanitized.MonitoredSystemCapacity = cloneMonitoredSystemCapacityStatus( - payload.MonitoredSystemCapacity, - ) - } if sanitized.UpgradeReasons == nil { sanitized.UpgradeReasons = []UpgradeReason{} } return sanitized } -func licenseStatusUsesUncappedCoreMonitoring(status *LicenseStatus) bool { - if status == nil { - return false - } - if status.PlanVersion != "" { - return IsSelfHostedCoreMonitoringUncappedPlanVersion(status.PlanVersion) - } - return IsSelfHostedCoreMonitoringUncappedTier(status.Tier) -} - // BuildEntitlementPayloadWithUsage constructs the normalized payload from LicenseStatus and observed usage. func BuildEntitlementPayloadWithUsage( status *LicenseStatus, @@ -444,24 +333,6 @@ func BuildEntitlementPayloadWithUsage( LegacyConnections: usage.LegacyConnections, HasMigrationGap: false, } - uncappedCoreMonitoring := licenseStatusUsesUncappedCoreMonitoring(status) - if status.MonitoredSystemContinuity != nil { - continuity := *status.MonitoredSystemContinuity - payload.MonitoredSystemContinuity = &continuity - } - monitoredSystemLimit := int64(status.MaxMonitoredSystems) - capacityContinuity := status.MonitoredSystemContinuity - if uncappedCoreMonitoring { - monitoredSystemLimit = 0 - payload.MonitoredSystemContinuity = nil - capacityContinuity = nil - } - payload.MonitoredSystemCapacity = buildMonitoredSystemCapacityStatus( - monitoredSystemLimit, - usage, - capacityContinuity, - ) - if payload.Capabilities == nil { payload.Capabilities = []string{} } @@ -487,22 +358,8 @@ func BuildEntitlementPayloadWithUsage( payload.MaxHistoryDays = TierHistoryDays[TierFree] } - // Build limits. - if status.MaxMonitoredSystems > 0 && !uncappedCoreMonitoring { - currentSystems := usage.monitoredSystemCount() - 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 && !uncappedCoreMonitoring { + // Build hosted-only runtime limits. Self-hosted commercial volume caps are retired. + if status.Tier == TierCloud && status.MaxGuests > 0 { payload.Limits = append(payload.Limits, LimitStatus{ Key: "max_guests", Limit: int64(status.MaxGuests), @@ -557,92 +414,6 @@ func remainingTrialDays(expiresAtUnix, nowUnix int64) int { return daysRemaining } -func boolPointer(value bool) *bool { - v := value - return &v -} - -func buildMonitoredSystemCapacityStatus( - limit int64, - usage EntitlementUsageSnapshot, - continuity *MonitoredSystemContinuityStatus, -) *MonitoredSystemCapacityStatus { - currentAvailable := usage.monitoredSystemCountAvailable() - if !currentAvailable { - return &MonitoredSystemCapacityStatus{ - Mode: "usage_unavailable", - Urgency: "ok", - Current: 0, - Limit: limit, - CurrentAvailable: false, - CurrentUnavailableReason: usage.monitoredSystemCountUnavailableReason(), - AvailableSlots: 0, - Overage: 0, - BlocksNewSystems: false, - ExistingMonitoringContinues: false, - } - } - - current := usage.monitoredSystemCount() - if limit <= 0 { - return &MonitoredSystemCapacityStatus{ - Mode: "unlimited", - Urgency: "ok", - Current: current, - Limit: 0, - CurrentAvailable: true, - AvailableSlots: 0, - Overage: 0, - BlocksNewSystems: false, - ExistingMonitoringContinues: true, - } - } - - status := &MonitoredSystemCapacityStatus{ - Current: current, - Limit: limit, - CurrentAvailable: true, - AvailableSlots: 0, - Overage: 0, - BlocksNewSystems: false, - ExistingMonitoringContinues: true, - Urgency: LimitState(current, limit), - } - - switch { - case current < limit: - status.Mode = "within_limit" - status.AvailableSlots = limit - current - case current == limit: - status.Mode = "at_limit_blocking_new" - status.Reason = "limit_reached" - status.BlocksNewSystems = true - case current > limit: - status.Mode = "over_limit_frozen" - status.BlocksNewSystems = true - status.Overage = current - limit - if continuity != nil && continuity.CapturePending { - status.Reason = "legacy_migration_capture_pending" - } else { - status.Reason = "preexisting_usage" - } - default: - status.Mode = "within_limit" - } - - return status -} - -func cloneMonitoredSystemCapacityStatus( - status *MonitoredSystemCapacityStatus, -) *MonitoredSystemCapacityStatus { - if status == nil { - return nil - } - cloned := *status - return &cloned -} - // LimitState returns the over-limit UX state string. func LimitState(current, limit int64) string { if limit <= 0 { diff --git a/pkg/licensing/entitlement_payload_test.go b/pkg/licensing/entitlement_payload_test.go index d0d054834..7ab43b746 100644 --- a/pkg/licensing/entitlement_payload_test.go +++ b/pkg/licensing/entitlement_payload_test.go @@ -17,13 +17,21 @@ func containsString(values []string, want string) bool { return false } -func TestBuildEntitlementPayload_ActiveLicense(t *testing.T) { +func hasLimit(payload EntitlementPayload, key string) bool { + for _, limit := range payload.Limits { + if limit.Key == key { + return true + } + } + return false +} + +func TestBuildEntitlementPayload_ActiveLicenseOmitsRetiredMonitoredSystemLimit(t *testing.T) { status := &LicenseStatus{ - Valid: true, - Tier: TierCloud, - PlanVersion: "cloud_starter", - Features: append([]string(nil), TierFeatures[TierPro]...), - MaxMonitoredSystems: 50, + Valid: true, + Tier: TierCloud, + PlanVersion: "cloud_starter", + Features: append([]string(nil), TierFeatures[TierPro]...), } payload := BuildEntitlementPayload(status, "") @@ -34,19 +42,8 @@ func TestBuildEntitlementPayload_ActiveLicense(t *testing.T) { if !reflect.DeepEqual(payload.Capabilities, status.Features) { t.Fatalf("expected capabilities to match status features") } - - var agentLimit *LimitStatus - for i := range payload.Limits { - if payload.Limits[i].Key == MaxMonitoredSystemsLicenseGateKey { - agentLimit = &payload.Limits[i] - break - } - } - if agentLimit == nil { - t.Fatalf("expected max_monitored_systems limit in payload") - } - if agentLimit.Limit != 50 { - t.Fatalf("expected max_monitored_systems limit 50, got %d", agentLimit.Limit) + if hasLimit(payload, MaxMonitoredSystemsLicenseGateKey) { + t.Fatalf("payload exposed retired monitored-system limit: %+v", payload.Limits) } if len(payload.UpgradeReasons) != 0 { t.Fatalf("expected no upgrade reasons for pro tier, got %d", len(payload.UpgradeReasons)) @@ -102,12 +99,11 @@ func TestBuildEntitlementPayload_FreeTier(t *testing.T) { func TestBuildEntitlementPayloadWithUsage_CurrentValues(t *testing.T) { status := &LicenseStatus{ - Valid: true, - Tier: TierCloud, - PlanVersion: "cloud_starter", - Features: append([]string(nil), TierFeatures[TierPro]...), - MaxMonitoredSystems: 50, - MaxGuests: 100, + Valid: true, + Tier: TierCloud, + PlanVersion: "cloud_starter", + Features: append([]string(nil), TierFeatures[TierPro]...), + MaxGuests: 100, } payload := BuildEntitlementPayloadWithUsage(status, "", EntitlementUsageSnapshot{ @@ -121,29 +117,18 @@ func TestBuildEntitlementPayloadWithUsage_CurrentValues(t *testing.T) { }, }, nil) - var agentLimit *LimitStatus + if hasLimit(payload, MaxMonitoredSystemsLicenseGateKey) { + t.Fatalf("payload exposed retired monitored-system limit: %+v", payload.Limits) + } var guestLimit *LimitStatus for i := range payload.Limits { - if payload.Limits[i].Key == MaxMonitoredSystemsLicenseGateKey { - agentLimit = &payload.Limits[i] - } if payload.Limits[i].Key == "max_guests" { guestLimit = &payload.Limits[i] } } - - if agentLimit == nil { - t.Fatalf("expected max_monitored_systems limit") - } if guestLimit == nil { t.Fatalf("expected max_guests limit") } - if agentLimit.Current != 12 { - t.Fatalf("expected agent current 12, got %d", agentLimit.Current) - } - if agentLimit.CurrentAvailable == nil || !*agentLimit.CurrentAvailable { - t.Fatalf("expected agent current availability to be true, got %+v", agentLimit.CurrentAvailable) - } if guestLimit.Current != 44 { t.Fatalf("expected guest current 44, got %d", guestLimit.Current) } @@ -160,16 +145,7 @@ func TestBuildEntitlementPayloadWithUsage_CurrentValues(t *testing.T) { ) } if payload.HasMigrationGap { - t.Fatal("expected has_migration_gap=false under monitored-system counting") - } - if payload.MonitoredSystemCapacity == nil { - t.Fatal("expected monitored-system capacity posture") - } - if payload.MonitoredSystemCapacity.Mode != "within_limit" { - t.Fatalf("Mode=%q, want within_limit", payload.MonitoredSystemCapacity.Mode) - } - if payload.MonitoredSystemCapacity.AvailableSlots != 38 { - t.Fatalf("AvailableSlots=%d, want 38", payload.MonitoredSystemCapacity.AvailableSlots) + t.Fatal("expected has_migration_gap=false") } } @@ -198,120 +174,6 @@ func TestBuildEntitlementPayload_TrialState(t *testing.T) { } } -func TestBuildEntitlementPayloadWithUsage_RequiresCanonicalMonitoredSystemAvailability(t *testing.T) { - status := &LicenseStatus{ - Valid: true, - Tier: TierCloud, - PlanVersion: "cloud_starter", - Features: append([]string(nil), TierFeatures[TierPro]...), - MaxMonitoredSystems: 50, - } - - payload := BuildEntitlementPayloadWithUsage(status, "", EntitlementUsageSnapshot{ - MonitoredSystems: 12, - Nodes: 99, - MonitoredSystemsUnavailableReason: "canonical_usage_unavailable", - }, nil) - - if len(payload.Limits) != 1 { - t.Fatalf("expected one limit, got %d", len(payload.Limits)) - } - if payload.Limits[0].Current != 0 { - t.Fatalf("expected unavailable canonical usage to remain 0, got %d", payload.Limits[0].Current) - } - if payload.Limits[0].CurrentAvailable == nil || *payload.Limits[0].CurrentAvailable { - t.Fatalf("expected canonical usage availability to be false, got %+v", payload.Limits[0].CurrentAvailable) - } - if payload.Limits[0].CurrentUnavailableReason != "canonical_usage_unavailable" { - t.Fatalf("CurrentUnavailableReason=%q, want %q", payload.Limits[0].CurrentUnavailableReason, "canonical_usage_unavailable") - } -} - -func TestBuildEntitlementPayloadWithUsage_MonitoredSystemUsageUnavailable(t *testing.T) { - status := &LicenseStatus{ - Valid: true, - Tier: TierCloud, - PlanVersion: "cloud_starter", - Features: append([]string(nil), TierFeatures[TierPro]...), - MaxMonitoredSystems: 50, - } - - payload := BuildEntitlementPayloadWithUsage(status, "", EntitlementUsageSnapshot{ - MonitoredSystemsUnavailableReason: "supplemental_inventory_unsettled", - }, nil) - if len(payload.Limits) != 1 { - t.Fatalf("expected one limit, got %d", len(payload.Limits)) - } - if payload.Limits[0].Current != 0 { - t.Fatalf("expected unresolved current to fall back to 0, got %d", payload.Limits[0].Current) - } - 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") - } - if payload.MonitoredSystemCapacity == nil { - t.Fatal("expected monitored-system capacity posture") - } - if payload.MonitoredSystemCapacity.Mode != "usage_unavailable" { - t.Fatalf("Mode=%q, want usage_unavailable", payload.MonitoredSystemCapacity.Mode) - } - if payload.MonitoredSystemCapacity.CurrentAvailable { - t.Fatalf("CurrentAvailable=%v, want false", payload.MonitoredSystemCapacity.CurrentAvailable) - } -} - -func TestBuildEntitlementPayloadWithUsage_CopiesMonitoredSystemContinuity(t *testing.T) { - status := &LicenseStatus{ - Valid: true, - Tier: TierCloud, - PlanVersion: "cloud_starter", - 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") - } - if payload.MonitoredSystemCapacity == nil { - t.Fatal("expected monitored-system capacity posture") - } - if payload.MonitoredSystemCapacity.Mode != "at_limit_blocking_new" { - t.Fatalf("Mode=%q, want at_limit_blocking_new", payload.MonitoredSystemCapacity.Mode) - } - if payload.MonitoredSystemCapacity.Reason != "limit_reached" { - t.Fatalf("Reason=%q, want limit_reached", payload.MonitoredSystemCapacity.Reason) - } - if !payload.MonitoredSystemCapacity.BlocksNewSystems { - t.Fatal("expected at-limit posture to block new monitored systems") - } -} - func TestBuildEntitlementPayload_CopiesStatusDisplayFields(t *testing.T) { tests := []struct { name string @@ -368,13 +230,12 @@ func TestBuildEntitlementPayload_CopiesStatusDisplayFields(t *testing.T) { func TestBuildEntitlementPayload_LifetimeOmitsCommercialCaps(t *testing.T) { payload := BuildEntitlementPayloadWithUsage(&LicenseStatus{ - Valid: true, - Tier: TierLifetime, - PlanVersion: "v5_lifetime_grandfathered", - IsLifetime: true, - Features: append([]string(nil), TierFeatures[TierLifetime]...), - MaxMonitoredSystems: 0, - MaxGuests: 0, + Valid: true, + Tier: TierLifetime, + PlanVersion: "v5_lifetime_grandfathered", + IsLifetime: true, + Features: append([]string(nil), TierFeatures[TierLifetime]...), + MaxGuests: 0, }, string(SubStateActive), EntitlementUsageSnapshot{ MonitoredSystems: 15, MonitoredSystemsAvailable: true, @@ -384,12 +245,6 @@ func TestBuildEntitlementPayload_LifetimeOmitsCommercialCaps(t *testing.T) { if len(payload.Limits) != 0 { t.Fatalf("expected lifetime entitlements to omit commercial caps, got %+v", payload.Limits) } - if payload.MonitoredSystemCapacity == nil { - t.Fatal("expected lifetime payload to include monitored-system capacity posture") - } - if payload.MonitoredSystemCapacity.Mode != "unlimited" { - t.Fatalf("Mode=%q, want unlimited", payload.MonitoredSystemCapacity.Mode) - } } func TestBuildEntitlementPayload_MaxHistoryDays(t *testing.T) { @@ -437,11 +292,10 @@ func TestBuildEntitlementPayload_NilStatus_MaxHistoryDays(t *testing.T) { func TestBuildCommercialPosturePayloadWithUsage_CurrentValues(t *testing.T) { status := &LicenseStatus{ - Valid: true, - Tier: TierCloud, - PlanVersion: "cloud_starter", - Features: append([]string(nil), TierFeatures[TierPro]...), - MaxMonitoredSystems: 5, + Valid: true, + Tier: TierCloud, + PlanVersion: "cloud_starter", + Features: append([]string(nil), TierFeatures[TierPro]...), } payload := BuildCommercialPosturePayloadWithUsage(status, "", EntitlementUsageSnapshot{ @@ -466,65 +320,21 @@ func TestBuildCommercialPosturePayloadWithUsage_CurrentValues(t *testing.T) { t.Fatalf("expected legacy connection counts to be preserved, got %+v", payload.LegacyConnections) } if payload.HasMigrationGap { - t.Fatal("expected has_migration_gap=false under canonical monitored-system counting") - } - if payload.MonitoredSystemCapacity == nil { - t.Fatal("expected commercial posture to preserve monitored-system capacity") - } - if payload.MonitoredSystemCapacity.Mode != "over_limit_frozen" { - t.Fatalf("Mode=%q, want over_limit_frozen", payload.MonitoredSystemCapacity.Mode) - } - if payload.MonitoredSystemCapacity.Overage != 2 { - t.Fatalf("Overage=%d, want 2", payload.MonitoredSystemCapacity.Overage) - } - if payload.MonitoredSystemCapacity.Reason != "preexisting_usage" { - t.Fatalf("Reason=%q, want preexisting_usage", payload.MonitoredSystemCapacity.Reason) - } -} - -func TestBuildEntitlementPayloadWithUsage_MarksLegacyContinuityCapturePendingOverage(t *testing.T) { - payload := BuildEntitlementPayloadWithUsage(&LicenseStatus{ - Valid: true, - Tier: TierCloud, - PlanVersion: "cloud_starter", - Features: append([]string(nil), TierFeatures[TierPro]...), - MaxMonitoredSystems: 10, - MonitoredSystemContinuity: &MonitoredSystemContinuityStatus{ - PlanLimit: 10, - EffectiveLimit: 10, - CapturePending: true, - }, - }, string(SubStateActive), EntitlementUsageSnapshot{ - MonitoredSystems: 23, - MonitoredSystemsAvailable: true, - }, nil) - - if payload.MonitoredSystemCapacity == nil { - t.Fatal("expected monitored-system capacity posture") - } - if payload.MonitoredSystemCapacity.Mode != "over_limit_frozen" { - t.Fatalf("Mode=%q, want over_limit_frozen", payload.MonitoredSystemCapacity.Mode) - } - if payload.MonitoredSystemCapacity.Reason != "legacy_migration_capture_pending" { - t.Fatalf( - "Reason=%q, want legacy_migration_capture_pending", - payload.MonitoredSystemCapacity.Reason, - ) + t.Fatal("expected has_migration_gap=false") } } func TestCommercialPosturePayloadFromEntitlementPayload_StripsBillingIdentityFields(t *testing.T) { expiresAt := time.Now().Add(48 * time.Hour).UTC().Format(time.RFC3339) payload := BuildEntitlementPayloadWithUsage(&LicenseStatus{ - Valid: true, - Tier: TierCloud, - PlanVersion: "cloud_starter", - Email: "owner@example.com", - ExpiresAt: &expiresAt, - IsLifetime: false, - DaysRemaining: 2, - Features: append([]string(nil), TierFeatures[TierPro]...), - MaxMonitoredSystems: 50, + Valid: true, + Tier: TierCloud, + PlanVersion: "cloud_starter", + Email: "owner@example.com", + ExpiresAt: &expiresAt, + IsLifetime: false, + DaysRemaining: 2, + Features: append([]string(nil), TierFeatures[TierPro]...), }, string(SubStateActive), EntitlementUsageSnapshot{ MonitoredSystems: 12, MonitoredSystemsAvailable: true, @@ -544,12 +354,6 @@ func TestCommercialPosturePayloadFromEntitlementPayload_StripsBillingIdentityFie if posture.LegacyConnections.ProxmoxNodes != 1 { t.Fatalf("expected proxmox_nodes=1, got %+v", posture.LegacyConnections) } - if posture.MonitoredSystemCapacity == nil { - t.Fatal("expected commercial posture to preserve monitored-system capacity posture") - } - if posture.MonitoredSystemCapacity.Mode != "within_limit" { - t.Fatalf("Mode=%q, want within_limit", posture.MonitoredSystemCapacity.Mode) - } body, err := json.Marshal(posture) if err != nil { @@ -573,16 +377,15 @@ func TestCommercialPosturePayloadFromEntitlementPayload_StripsBillingIdentityFie func TestBuildRuntimeCapabilitiesPayloadWithUsage_CurrentValues(t *testing.T) { status := &LicenseStatus{ - Valid: true, - Tier: TierCloud, - PlanVersion: "cloud_starter", - Features: append([]string(nil), TierFeatures[TierRelay]...), - MaxMonitoredSystems: 12, + Valid: true, + Tier: TierCloud, + PlanVersion: "cloud_starter", + Features: append([]string(nil), TierFeatures[TierRelay]...), + MaxGuests: 12, } payload := BuildRuntimeCapabilitiesPayloadWithUsage(status, "", EntitlementUsageSnapshot{ - MonitoredSystems: 5, - MonitoredSystemsAvailable: true, + Guests: 5, }) if !reflect.DeepEqual(payload.Capabilities, status.Features) { @@ -594,21 +397,12 @@ func TestBuildRuntimeCapabilitiesPayloadWithUsage_CurrentValues(t *testing.T) { if len(payload.Limits) != 1 { t.Fatalf("expected one runtime limit, got %d", len(payload.Limits)) } - if payload.Limits[0].Key != MaxMonitoredSystemsLicenseGateKey { - t.Fatalf("expected runtime limit key %q, got %q", MaxMonitoredSystemsLicenseGateKey, payload.Limits[0].Key) + if payload.Limits[0].Key != "max_guests" { + t.Fatalf("expected runtime limit key max_guests, got %q", payload.Limits[0].Key) } if payload.Limits[0].Current != 5 { t.Fatalf("expected runtime current 5, got %d", payload.Limits[0].Current) } - if payload.Limits[0].CurrentAvailable == nil || !*payload.Limits[0].CurrentAvailable { - t.Fatalf("expected runtime current availability true, got %+v", payload.Limits[0].CurrentAvailable) - } - if payload.MonitoredSystemCapacity == nil { - t.Fatal("expected runtime capabilities to include monitored-system capacity posture") - } - if payload.MonitoredSystemCapacity.Mode != "within_limit" { - t.Fatalf("Mode=%q, want within_limit", payload.MonitoredSystemCapacity.Mode) - } } func TestBuildRuntimeCapabilitiesPayloadWithUsage_StripsInternalOnlyCapabilities(t *testing.T) { @@ -619,7 +413,6 @@ func TestBuildRuntimeCapabilitiesPayloadWithUsage_StripsInternalOnlyCapabilities FeatureRelay, FeatureDemoFixtures, }, - MaxMonitoredSystems: 12, } payload := BuildRuntimeCapabilitiesPayloadWithUsage(status, "", EntitlementUsageSnapshot{}) @@ -679,7 +472,6 @@ func TestLimitState(t *testing.T) { {name: "enforced_at_limit", current: 100, limit: 100, want: "enforced"}, {name: "enforced_above_limit", current: 110, limit: 100, want: "enforced"}, {name: "ok_unlimited", current: 50, limit: 0, want: "ok"}, - // Small-limit behavior: warn at N-1 {name: "small_limit_ok", current: 3, limit: 5, want: "ok"}, {name: "small_limit_warning_at_n_minus_1", current: 4, limit: 5, want: "warning"}, {name: "small_limit_enforced_at_limit", current: 5, limit: 5, want: "enforced"}, @@ -692,7 +484,6 @@ func TestLimitState(t *testing.T) { } for _, tc := range tests { - tc := tc t.Run(tc.name, func(t *testing.T) { got := LimitState(tc.current, tc.limit) if got != tc.want { diff --git a/pkg/licensing/features.go b/pkg/licensing/features.go index 2905fbeda..4cabb8cd0 100644 --- a/pkg/licensing/features.go +++ b/pkg/licensing/features.go @@ -38,7 +38,7 @@ const ( FeatureMultiUser = "multi_user" // Multi-user (likely merged with RBAC) FeatureWhiteLabel = "white_label" // Custom branding - NOT IMPLEMENTED YET FeatureMultiTenant = "multi_tenant" // Multi-tenant organizations - FeatureUnlimited = "unlimited" // Hosted capacity policy marker for MSP/enterprise deals + FeatureUnlimited = "unlimited" // Compatibility capability marker for MSP/enterprise deals // Internal-only runtime capabilities. These must never be added to public // tier defaults or public pricing contracts. @@ -60,48 +60,6 @@ const ( TierEnterprise Tier = "enterprise" ) -// TierMonitoredSystemLimits defines the maximum monitored-system count per tier. -// A value of 0 means unlimited. -var TierMonitoredSystemLimits = map[Tier]int{ - TierFree: 0, // Self-hosted Community no longer caps core monitoring - TierRelay: 0, // Self-hosted Relay adds convenience features, not more room - TierPro: 0, // Self-hosted Pro monetizes operations features, not monitoring volume - TierProPlus: 0, // Legacy/self-hosted compatibility tier; no monitored-system cap - TierProAnnual: 0, // Legacy: same as Pro - TierLifetime: 0, // Grandfathered lifetime entitlements remain uncapped - TierCloud: 0, // Cloud tiers have per-plan limits set in license claims - TierMSP: 0, // MSP tiers have per-plan pool limits set in license claims - TierEnterprise: 0, // Custom -} - -// CloudPlanMonitoredSystemLimits maps hosted and continuity plan version strings to -// per-plan monitored-system limits. When a tenant is provisioned or its subscription -// changes, the provisioner uses this map to populate -// BillingState.Limits[MaxMonitoredSystemsLicenseGateKey]. -// -// This intentionally includes the grandfathered recurring v5/v1 continuity -// plans that still renew through Stripe. Those subscriptions are not "unknown" -// just because they are no longer sold; they remain canonical paid states that -// must preserve their plan identity during webhook-driven billing updates. -// A value of 0 means uncapped continuity for active recurring v5 customers. -var CloudPlanMonitoredSystemLimits = map[string]int{ - // Individual Cloud tiers - "cloud_starter": 10, - "cloud_power": 30, - "cloud_max": 75, - "cloud_founding": 10, // Founding rate = Starter limits - - // Grandfathered recurring Pulse Pro continuity plans remain uncapped while - // the recurring subscription stays active. - "v5_pro_monthly_grandfathered": 0, - "v5_pro_annual_grandfathered": 0, - - // MSP tiers — host pool limits from pricing spec - "msp_starter": 50, // MSP Starter: 10 clients, 50 host pool - "msp_growth": 150, // MSP Growth: 25 clients, 150 host pool - "msp_scale": 400, // MSP Scale: 50 clients, 400 host pool -} - // PriceIDToPlanVersion maps Stripe price IDs to canonical plan version strings. // This is the authoritative reverse lookup: given a price ID from a checkout // session, subscription, or webhook event, callers can resolve the plan version @@ -205,10 +163,16 @@ func stripLegacyCommercialCaps(limits map[string]int64) { delete(limits, "max_guests") } -// UnknownPlanDefaultMonitoredSystemLimit is the safe-default monitored-system limit applied when a -// plan version is not recognized. Fail-closed: unknown plans get the smallest -// tier limit rather than unlimited access. -const UnknownPlanDefaultMonitoredSystemLimit = 10 +func stripSelfHostedCommercialVolumeCaps(limits map[string]int64, planVersion string, tier Tier, uncapped bool) { + if limits == nil { + return + } + if uncapped || + IsSelfHostedCoreMonitoringUncappedPlanVersion(planVersion) || + (planVersion == "" && IsSelfHostedCoreMonitoringUncappedTier(tier)) { + stripLegacyCommercialCaps(limits) + } +} // CloudPlanWorkspaceLimits maps cloud plan version strings to the maximum // number of active workspaces (tenants) the account may create. Individual @@ -243,20 +207,6 @@ func WorkspaceLimitForPlan(planVersion string) (limit int, known bool) { return UnknownPlanDefaultWorkspaceLimit, false } -// LimitsForCloudPlan returns the monitored-system limit map for a given cloud plan -// version and whether the plan was recognized. If the plan is recognized, the -// map contains MaxMonitoredSystemsLicenseGateKey with the per-plan limit. -// If unrecognized, returns -// a safe default limit (fail-closed) and known=false so callers can decide -// whether to reject, quarantine, or proceed with restricted access. -func LimitsForCloudPlan(planVersion string) (limits map[string]int64, known bool) { - planVersion = CanonicalizePlanVersion(planVersion) - if limit, ok := CloudPlanMonitoredSystemLimits[planVersion]; ok { - return map[string]int64{MaxMonitoredSystemsLicenseGateKey: int64(limit)}, true - } - return map[string]int64{MaxMonitoredSystemsLicenseGateKey: int64(UnknownPlanDefaultMonitoredSystemLimit)}, false -} - // TierHistoryDays defines the maximum metrics history retention per tier. var TierHistoryDays = map[Tier]int{ TierFree: 7, @@ -297,7 +247,7 @@ var proFeatures = appendFeatures(relayFeatures, FeatureAdvancedReporting, ) -// mspFeatures adds multi-tenant and hosted capacity policy on top of pro. +// mspFeatures adds multi-tenant policy on top of pro. var mspFeatures = appendFeatures(proFeatures, FeatureUnlimited, FeatureMultiTenant, @@ -347,14 +297,13 @@ func DeriveCapabilitiesFromTier(tier Tier, explicitFeatures []string) []string { return capabilities } -// DeriveEntitlements derives capabilities and limits from tier and canonical monitored-system fields. -func DeriveEntitlements(tier Tier, features []string, maxMonitoredSystems int, maxGuests int) (capabilities []string, limits map[string]int64) { +// DeriveEntitlements derives capabilities and non-monitoring quantitative limits. +// The monitored-system parameter is retained for old callers but ignored: +// monitored-system volume is no longer a license entitlement. +func DeriveEntitlements(tier Tier, features []string, _ int, maxGuests int) (capabilities []string, limits map[string]int64) { capabilities = DeriveCapabilitiesFromTier(tier, features) limits = make(map[string]int64) - if maxMonitoredSystems > 0 { - limits["max_monitored_systems"] = int64(maxMonitoredSystems) - } if maxGuests > 0 { limits["max_guests"] = int64(maxGuests) } diff --git a/pkg/licensing/features_test.go b/pkg/licensing/features_test.go index 1bd811cb8..403262ab3 100644 --- a/pkg/licensing/features_test.go +++ b/pkg/licensing/features_test.go @@ -132,15 +132,13 @@ func TestSelfHostedPaidFeatureClaimMatrix(t *testing.T) { name string tier Tier wantHistoryDays int - wantCoreMonitoringLimit int wantIncludedCapabilities []string wantExcludedCapabilities []string }{ { - name: "community keeps core monitoring free and does not claim paid extras", - tier: TierFree, - wantHistoryDays: 7, - wantCoreMonitoringLimit: 0, + name: "community keeps core monitoring free and does not claim paid extras", + tier: TierFree, + wantHistoryDays: 7, wantIncludedCapabilities: []string{ FeatureUpdateAlerts, FeatureSSO, @@ -159,10 +157,9 @@ func TestSelfHostedPaidFeatureClaimMatrix(t *testing.T) { }, }, { - name: "relay sells remote access mobile handoff push and fourteen day history", - tier: TierRelay, - wantHistoryDays: 14, - wantCoreMonitoringLimit: 0, + name: "relay sells remote access mobile handoff push and fourteen day history", + tier: TierRelay, + wantHistoryDays: 14, wantIncludedCapabilities: []string{ FeatureUpdateAlerts, FeatureSSO, @@ -182,10 +179,9 @@ func TestSelfHostedPaidFeatureClaimMatrix(t *testing.T) { }, }, { - name: "pro sells operator extras and preserves relay capabilities", - tier: TierPro, - wantHistoryDays: 90, - wantCoreMonitoringLimit: 0, + name: "pro sells operator extras and preserves relay capabilities", + tier: TierPro, + wantHistoryDays: 90, wantIncludedCapabilities: []string{ FeatureRelay, FeatureMobileApp, @@ -221,19 +217,6 @@ func TestSelfHostedPaidFeatureClaimMatrix(t *testing.T) { ) } - gotMonitoringLimit, ok := TierMonitoredSystemLimits[tt.tier] - if !ok { - t.Fatalf("TierMonitoredSystemLimits missing entry for tier %q", tt.tier) - } - if gotMonitoringLimit != tt.wantCoreMonitoringLimit { - t.Fatalf( - "TierMonitoredSystemLimits[%q] = %d, want %d", - tt.tier, - gotMonitoringLimit, - tt.wantCoreMonitoringLimit, - ) - } - capabilities := make(map[string]struct{}) for _, capability := range TierFeatures[tt.tier] { capabilities[capability] = struct{}{} @@ -259,8 +242,8 @@ func TestDeriveEntitlements(t *testing.T) { t.Error("DeriveEntitlements() returned no capabilities") } - if limits["max_monitored_systems"] != 50 { - t.Errorf("max_monitored_systems limit = %d, want 50", limits["max_monitored_systems"]) + if _, ok := limits["max_monitored_systems"]; ok { + t.Errorf("DeriveEntitlements() exposed retired max_monitored_systems limit: %v", limits) } if limits["max_guests"] != 100 { t.Errorf("max_guests limit = %d, want 100", limits["max_guests"]) @@ -474,33 +457,6 @@ func sortedFeatureSet(features []string) []string { return out } -func TestTierMonitoredSystemLimits(t *testing.T) { - tests := []struct { - tier Tier - want int - }{ - {TierFree, 0}, - {TierRelay, 0}, - {TierPro, 0}, - {TierProPlus, 0}, - {TierProAnnual, 0}, - {TierLifetime, 0}, - } - - for _, tt := range tests { - tt := tt - t.Run(string(tt.tier), func(t *testing.T) { - got, ok := TierMonitoredSystemLimits[tt.tier] - if !ok { - t.Fatalf("TierMonitoredSystemLimits missing entry for tier %q", tt.tier) - } - if got != tt.want { - t.Errorf("TierMonitoredSystemLimits[%q] = %d, want %d", tt.tier, got, tt.want) - } - }) - } -} - func TestTierHistoryDays(t *testing.T) { tests := []struct { tier Tier @@ -609,85 +565,10 @@ func TestFilterPublicCapabilitiesStripsInternalOnlyFeatures(t *testing.T) { } } -func TestLimitsForCloudPlan_KnownPlans(t *testing.T) { - tests := []struct { - plan string - wantLimit int64 - }{ - {"cloud_starter", 10}, - {"cloud_power", 30}, - {"cloud_max", 75}, - {"cloud_founding", 10}, - {"msp_starter", 50}, - {"msp_hosted_v1", 50}, - {"msp_growth", 150}, - {"msp_scale", 400}, - {"v5_pro_monthly_grandfathered", 0}, - {"v5_pro_annual_grandfathered", 0}, - } - - for _, tt := range tests { - t.Run(tt.plan, func(t *testing.T) { - limits, known := LimitsForCloudPlan(tt.plan) - if !known { - t.Errorf("LimitsForCloudPlan(%q): known = false, want true", tt.plan) - } - got, ok := limits["max_monitored_systems"] - if !ok { - t.Fatalf("LimitsForCloudPlan(%q): missing max_monitored_systems key", tt.plan) - } - if got != tt.wantLimit { - t.Errorf("LimitsForCloudPlan(%q): max_monitored_systems = %d, want %d", tt.plan, got, tt.wantLimit) - } - }) - } -} - -func TestLimitsForCloudPlan_UnknownPlanFailsClosed(t *testing.T) { - unknownPlans := []string{ - "stripe", - "stripe_price:price_123", - "", - "unknown_plan", - "cloud_unknown", - } - - for _, plan := range unknownPlans { - t.Run(plan, func(t *testing.T) { - limits, known := LimitsForCloudPlan(plan) - if known { - t.Errorf("LimitsForCloudPlan(%q): known = true, want false", plan) - } - got, ok := limits["max_monitored_systems"] - if !ok { - t.Fatalf("LimitsForCloudPlan(%q): missing max_monitored_systems key (fail-open!)", plan) - } - if got != int64(UnknownPlanDefaultMonitoredSystemLimit) { - t.Errorf("LimitsForCloudPlan(%q): max_monitored_systems = %d, want default %d", plan, got, UnknownPlanDefaultMonitoredSystemLimit) - } - }) - } -} - -func TestLimitsForCloudPlan_NeverReturnsEmptyMap(t *testing.T) { - // This test ensures the fail-closed invariant: LimitsForCloudPlan must - // ALWAYS return a map with "max_monitored_systems" set, regardless of input. - inputs := []string{"cloud_starter", "stripe", "", "garbage", "msp_starter", "msp_hosted_v1"} - for _, plan := range inputs { - limits, _ := LimitsForCloudPlan(plan) - if _, ok := limits["max_monitored_systems"]; !ok { - t.Errorf("LimitsForCloudPlan(%q) returned map without max_monitored_systems — fail-open vulnerability", plan) - } - } -} - -// TestAllTiersHaveHostLimitsAndHistoryDays ensures every tier in TierFeatures -// also has entries in TierMonitoredSystemLimits and TierHistoryDays. -func TestAllTiersHaveHostLimitsAndHistoryDays(t *testing.T) { +// TestAllTiersHaveHistoryDays ensures every tier in TierFeatures also has a +// metrics-retention entry. Core monitoring volume limits are retired. +func TestAllTiersHaveHistoryDays(t *testing.T) { for tier := range TierFeatures { - if _, ok := TierMonitoredSystemLimits[tier]; !ok { - t.Errorf("TierMonitoredSystemLimits missing entry for tier %q", tier) - } if _, ok := TierHistoryDays[tier]; !ok { t.Errorf("TierHistoryDays missing entry for tier %q", tier) } @@ -738,15 +619,12 @@ func TestWorkspaceLimitForPlan_UnknownPlanFailsClosed(t *testing.T) { } } -// TestCloudWorkspacePlansHaveWorkspaceLimits ensures only hosted plans that -// actually support workspaces also carry a workspace limit entry. +// TestCloudWorkspacePlansHaveWorkspaceLimits ensures hosted workspace plans +// still carry a workspace limit entry after monitored-system caps were retired. func TestCloudWorkspacePlansHaveWorkspaceLimits(t *testing.T) { - for plan := range CloudPlanMonitoredSystemLimits { + for plan := range CloudPlanWorkspaceLimits { if !strings.HasPrefix(plan, "cloud_") && !strings.HasPrefix(plan, "msp_") { - continue - } - if _, ok := CloudPlanWorkspaceLimits[plan]; !ok { - t.Errorf("CloudPlanWorkspaceLimits missing entry for plan %q (present in CloudPlanMonitoredSystemLimits)", plan) + t.Errorf("CloudPlanWorkspaceLimits includes non-hosted plan %q", plan) } } } @@ -915,14 +793,16 @@ func TestIsSelfHostedCoreMonitoringUncappedTier(t *testing.T) { } } -// TestPriceIDToPlanVersion_AllMapToKnownPlans ensures every plan version in the -// price→plan map is recognized by LimitsForCloudPlan (fail-closed safety net). +// TestPriceIDToPlanVersion_HostedPlansHaveWorkspaceLimits ensures hosted plans +// in the price map retain workspace policy after monitored-system caps retire. func TestPriceIDToPlanVersion_AllMapToKnownPlans(t *testing.T) { for priceID, plan := range PriceIDToPlanVersion { t.Run(priceID, func(t *testing.T) { - _, known := LimitsForCloudPlan(plan) - if !known { - t.Errorf("PriceIDToPlanVersion[%q] = %q, but LimitsForCloudPlan does not recognize it", priceID, plan) + if !strings.HasPrefix(plan, "cloud_") && !strings.HasPrefix(plan, "msp_") { + return + } + if _, known := WorkspaceLimitForPlan(plan); !known { + t.Errorf("PriceIDToPlanVersion[%q] = %q, but WorkspaceLimitForPlan does not recognize it", priceID, plan) } }) } diff --git a/pkg/licensing/grant_claims_contract_test.go b/pkg/licensing/grant_claims_contract_test.go index 447a48803..acf01da4f 100644 --- a/pkg/licensing/grant_claims_contract_test.go +++ b/pkg/licensing/grant_claims_contract_test.go @@ -23,14 +23,19 @@ func TestCloudClaimPlanVersionNormalizationContract(t *testing.T) { PlanVersion: "cloud_v1", Limits: map[string]int64{ "max_monitored_systems": 999, + "max_guests": 7, }, } if got := claims.EntitlementPlanVersion(); got != "cloud_starter" { t.Fatalf("EntitlementPlanVersion() = %q, want %q", got, "cloud_starter") } - if got := claims.EffectiveLimits()["max_monitored_systems"]; got != 10 { - t.Fatalf("EffectiveLimits()[max_monitored_systems] = %d, want %d", got, 10) + limits := claims.EffectiveLimits() + if _, ok := limits["max_monitored_systems"]; ok { + t.Fatalf("EffectiveLimits retained retired max_monitored_systems: %v", limits) + } + if got := limits["max_guests"]; got != 7 { + t.Fatalf("EffectiveLimits()[max_guests] = %d, want %d", got, 7) } } @@ -38,9 +43,6 @@ func TestMSPPlanAliasCanonicalizationContract(t *testing.T) { if got := CanonicalizePlanVersion("msp_hosted_v1"); got != "msp_starter" { t.Fatalf("CanonicalizePlanVersion(msp_hosted_v1) = %q, want %q", got, "msp_starter") } - if limits, known := LimitsForCloudPlan("msp_hosted_v1"); !known || limits["max_monitored_systems"] != 50 { - t.Fatalf("LimitsForCloudPlan(msp_hosted_v1) = (%v, %v), want max_monitored_systems=50 and known=true", limits, known) - } if limit, known := WorkspaceLimitForPlan("msp_hosted_v1"); !known || limit != 10 { t.Fatalf("WorkspaceLimitForPlan(msp_hosted_v1) = (%d, %v), want (10, true)", limit, known) } @@ -51,7 +53,7 @@ func TestDatabaseSourceCanonicalBoundaryContract(t *testing.T) { store := &mockBillingStore{ state: &BillingState{ PlanVersion: "cloud_v1", - Limits: map[string]int64{"max_nodes": 999}, + Limits: map[string]int64{"max_nodes": 999, "max_guests": 7}, SubscriptionState: SubscriptionState(" ACTIVE "), }, } @@ -64,19 +66,23 @@ func TestDatabaseSourceCanonicalBoundaryContract(t *testing.T) { if got := source.SubscriptionState(); got != SubStateActive { t.Fatalf("SubscriptionState() = %q, want %q", got, SubStateActive) } - if got := source.Limits()["max_monitored_systems"]; got != 10 { - t.Fatalf("Limits()[max_monitored_systems] = %d, want %d", got, 10) + limits := source.Limits() + if _, ok := limits["max_monitored_systems"]; ok { + t.Fatalf("Limits() retained retired max_monitored_systems: %v", limits) } - if _, hasLegacy := source.Limits()["max_nodes"]; hasLegacy { + if _, hasLegacy := limits["max_nodes"]; hasLegacy { t.Fatal("Limits() preserved legacy max_nodes key") } + if got := limits["max_guests"]; got != 7 { + t.Fatalf("Limits()[max_guests] = %d, want %d", got, 7) + } }) t.Run("preserves_missing_plan_version", func(t *testing.T) { store := &mockBillingStore{ state: &BillingState{ PlanVersion: " ", - Limits: map[string]int64{"max_monitored_systems": 42}, + Limits: map[string]int64{"max_monitored_systems": 42, "max_guests": 7}, SubscriptionState: SubscriptionState(" ACTIVE "), }, } @@ -86,8 +92,12 @@ func TestDatabaseSourceCanonicalBoundaryContract(t *testing.T) { if got := source.PlanVersion(); got != "" { t.Fatalf("PlanVersion() = %q, want empty", got) } - if got := source.Limits()["max_monitored_systems"]; got != 42 { - t.Fatalf("Limits()[max_monitored_systems] = %d, want %d", got, 42) + limits := source.Limits() + if _, ok := limits["max_monitored_systems"]; ok { + t.Fatalf("Limits() retained retired max_monitored_systems: %v", limits) + } + if got := limits["max_guests"]; got != 7 { + t.Fatalf("Limits()[max_guests] = %d, want %d", got, 7) } }) @@ -142,9 +152,7 @@ func TestDatabaseSourceCanonicalBoundaryContract(t *testing.T) { // // The license server issues grants with these fields, the pulse client parses // them, and the relay server validates them. Any JSON tag drift means the -// receiving side silently drops the field value — a real bug (for example, a -// legacy alias instead of max_monitored_systems would break monitored-system -// enforcement on the relay). +// receiving side silently drops the field value. // // We compare by JSON tag (the wire contract) rather than Go field name because // the two repos may use different Go naming conventions (e.g. JTI vs JWTID) @@ -164,7 +172,6 @@ var grantContractJSONTags = []string{ "tier", "plan", "feat", - "max_monitored_systems", "max_guests", "grace_until", "email", diff --git a/pkg/licensing/grant_refresh_test.go b/pkg/licensing/grant_refresh_test.go index ce3335d3a..fc3dffe37 100644 --- a/pkg/licensing/grant_refresh_test.go +++ b/pkg/licensing/grant_refresh_test.go @@ -465,17 +465,16 @@ func TestRefreshGrantOnce_PersistsState(t *testing.T) { } } -func TestRefreshGrantOnce_PreservesLegacyGrandfatherFloor(t *testing.T) { +func TestRefreshGrantOnce_PreservesLegacyMigrationContinuity(t *testing.T) { setupTestPublicKey(t) newGrantJWT := makeTestGrantJWT(t, &GrantClaims{ - LicenseID: "lic_refreshed_floor", - Tier: "pro", - PlanKey: "legacy_migration_fallback", - State: "active", - MaxMonitoredSystems: 10, - IssuedAt: time.Now().Unix(), - ExpiresAt: time.Now().Add(72 * time.Hour).Unix(), + LicenseID: "lic_refreshed_floor", + Tier: "pro", + PlanKey: "legacy_migration_fallback", + State: "active", + IssuedAt: time.Now().Unix(), + ExpiresAt: time.Now().Add(72 * time.Hour).Unix(), }) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -501,13 +500,12 @@ func TestRefreshGrantOnce_PreservesLegacyGrandfatherFloor(t *testing.T) { } initialGrantJWT := makeTestGrantJWT(t, &GrantClaims{ - LicenseID: "lic_initial_floor", - Tier: "pro", - PlanKey: "legacy_migration_fallback", - State: "active", - MaxMonitoredSystems: 10, - IssuedAt: time.Now().Unix(), - ExpiresAt: time.Now().Add(72 * time.Hour).Unix(), + LicenseID: "lic_initial_floor", + Tier: "pro", + PlanKey: "legacy_migration_fallback", + State: "active", + IssuedAt: time.Now().Unix(), + ExpiresAt: time.Now().Add(72 * time.Hour).Unix(), }) svc := NewService() @@ -522,9 +520,7 @@ func TestRefreshGrantOnce_PreservesLegacyGrandfatherFloor(t *testing.T) { GrantJTI: "grant_old_floor", InstanceFingerprint: "fp-refresh-floor", Continuity: ActivationContinuity{ - LegacyMigration: true, - GrandfatheredMaxMonitoredSystems: 23, - GrandfatheredMonitoredSystemsCapturedAt: time.Now().Unix(), + LegacyMigration: true, }, } if err := svc.RestoreActivation(state); err != nil { @@ -536,10 +532,8 @@ func TestRefreshGrantOnce_PreservesLegacyGrandfatherFloor(t *testing.T) { } status := svc.Status() - // Self-hosted Pro is uncapped, so status reports 0 regardless of the - // captured continuity floor. The floor is still persisted for audit. - if status.MaxMonitoredSystems != 0 { - t.Fatalf("status.MaxMonitoredSystems=%d, want 0 (uncapped self-hosted)", status.MaxMonitoredSystems) + if status == nil || !status.Valid { + t.Fatalf("expected valid status after refresh, got %+v", status) } loaded, err := p.LoadActivationState() @@ -549,8 +543,8 @@ func TestRefreshGrantOnce_PreservesLegacyGrandfatherFloor(t *testing.T) { if loaded == nil { t.Fatal("expected persisted activation state") } - if loaded.Continuity.GrandfatheredMaxMonitoredSystems != 23 { - t.Fatalf("GrandfatheredMaxMonitoredSystems=%d, want 23", loaded.Continuity.GrandfatheredMaxMonitoredSystems) + if !loaded.Continuity.LegacyMigration { + t.Fatalf("LegacyMigration=%v, want true", loaded.Continuity.LegacyMigration) } } @@ -612,17 +606,14 @@ func TestRefreshGrantOnce_MissingCloudPlanFailsClosed(t *testing.T) { if got := lic.Claims.EntitlementPlanVersion(); got != "" { t.Fatalf("EntitlementPlanVersion()=%q, want empty", got) } - if got := lic.Claims.EffectiveLimits()["max_monitored_systems"]; got != int64(UnknownPlanDefaultMonitoredSystemLimit) { - t.Fatalf("EffectiveLimits()[max_monitored_systems]=%d, want %d", got, UnknownPlanDefaultMonitoredSystemLimit) + if _, ok := lic.Claims.EffectiveLimits()["max_monitored_systems"]; ok { + t.Fatalf("EffectiveLimits retained retired max_monitored_systems: %v", lic.Claims.EffectiveLimits()) } status := svc.Status() if status.PlanVersion != "" { t.Fatalf("status.PlanVersion=%q, want empty", status.PlanVersion) } - if status.MaxMonitoredSystems != UnknownPlanDefaultMonitoredSystemLimit { - t.Fatalf("status.MaxMonitoredSystems=%d, want %d", status.MaxMonitoredSystems, UnknownPlanDefaultMonitoredSystemLimit) - } } func TestRefreshGrantOnce_CallsLicenseChangeCallback(t *testing.T) { diff --git a/pkg/licensing/grant_verification_test.go b/pkg/licensing/grant_verification_test.go index bde88111e..3680d6540 100644 --- a/pkg/licensing/grant_verification_test.go +++ b/pkg/licensing/grant_verification_test.go @@ -15,14 +15,13 @@ func TestVerifyAndParseGrantJWT_ValidSignature(t *testing.T) { setupTestPublicKey(t) jwt := makeTestGrantJWT(t, &GrantClaims{ - LicenseID: "lic_test", - InstallationID: "inst_abc", - State: "active", - Tier: "pro", - Features: []string{"relay"}, - MaxMonitoredSystems: 10, - IssuedAt: time.Now().Unix(), - ExpiresAt: time.Now().Add(72 * time.Hour).Unix(), + LicenseID: "lic_test", + InstallationID: "inst_abc", + State: "active", + Tier: "pro", + Features: []string{"relay"}, + IssuedAt: time.Now().Unix(), + ExpiresAt: time.Now().Add(72 * time.Hour).Unix(), }) gc, err := verifyAndParseGrantJWT(jwt) @@ -35,9 +34,6 @@ func TestVerifyAndParseGrantJWT_ValidSignature(t *testing.T) { if gc.Tier != "pro" { t.Errorf("Tier = %q, want %q", gc.Tier, "pro") } - if gc.MaxMonitoredSystems != 10 { - t.Errorf("MaxMonitoredSystems = %d, want 10", gc.MaxMonitoredSystems) - } } func TestVerifyAndParseGrantJWT_TamperedPayload(t *testing.T) { diff --git a/pkg/licensing/host_lifecycle.go b/pkg/licensing/host_lifecycle.go index d3f6bcbc5..b0cbf7bdc 100644 --- a/pkg/licensing/host_lifecycle.go +++ b/pkg/licensing/host_lifecycle.go @@ -1,256 +1 @@ -// Package licensing — host_lifecycle.go -// -// HostLifecycleTracker implements the v6 host counting rules: -// -// - H2: A host is only counted toward monitored-system capacity after 10+ -// minutes of stable heartbeat (prevents transient connections from consuming capacity). -// - H3: A host slot is released after 72 hours of inactivity (no heartbeat). -// -// Wiring instructions: -// -// - node_limit.go should call tracker.StableActiveHosts() to filter which -// hosts count toward monitored-system capacity, instead of counting all hosts -// unconditionally. -// - The monitor's update loop (where host heartbeats arrive) should call -// tracker.RecordHeartbeat(hostID) on every heartbeat. -// - The host ledger (host_ledger.go) can use tracker.FirstSeen(hostID) for -// the first_seen field in ledger entries. -// - Call tracker.Prune() periodically (e.g. every hour) to release stale -// entries and free memory. -// - On startup, call LoadState(path) to restore persisted lifecycle state. -// On shutdown (or periodically), call SaveState(path) to persist it. package licensing - -import ( - "encoding/json" - "os" - "path/filepath" - "sync" - "time" -) - -const ( - // HostStabilizationPeriod is the minimum duration of heartbeats before a - // host is considered "stable" and counted toward monitored-system capacity. - HostStabilizationPeriod = 10 * time.Minute - - // HostInactivityTimeout is the duration after which a host with no - // heartbeat is considered inactive and its slot is released. - HostInactivityTimeout = 72 * time.Hour -) - -// hostEntry tracks lifecycle timestamps for a single host. -type hostEntry struct { - FirstSeen time.Time `json:"first_seen"` - LastSeen time.Time `json:"last_seen"` - StableAt *time.Time `json:"stable_at,omitempty"` -} - -// HostLifecycleTracker tracks heartbeat stability and inactivity per host. -// All methods are safe for concurrent use. -type HostLifecycleTracker struct { - mu sync.RWMutex - hosts map[string]*hostEntry - - // nowFunc is used for time; overridden in tests. - nowFunc func() time.Time -} - -// NewHostLifecycleTracker creates a new tracker with an empty host map. -func NewHostLifecycleTracker() *HostLifecycleTracker { - return &HostLifecycleTracker{ - hosts: make(map[string]*hostEntry), - nowFunc: time.Now, - } -} - -// RecordHeartbeat records a heartbeat for the given host. If the host is new, -// its firstSeen is set. Once HostStabilizationPeriod has elapsed since -// firstSeen, stableAt is set (and never cleared). Empty hostID is ignored. -func (t *HostLifecycleTracker) RecordHeartbeat(hostID string) { - if hostID == "" { - return - } - - t.mu.Lock() - defer t.mu.Unlock() - - now := t.nowFunc() - e, ok := t.hosts[hostID] - if !ok { - e = &hostEntry{ - FirstSeen: now, - } - t.hosts[hostID] = e - } - e.LastSeen = now - - if e.StableAt == nil && now.Sub(e.FirstSeen) >= HostStabilizationPeriod { - ts := now - e.StableAt = &ts - } -} - -// IsStable reports whether the host has achieved stability (10+ minutes of -// heartbeats since first seen). -func (t *HostLifecycleTracker) IsStable(hostID string) bool { - t.mu.RLock() - defer t.mu.RUnlock() - - e, ok := t.hosts[hostID] - if !ok { - return false - } - return e.StableAt != nil -} - -// IsActive reports whether the host has sent a heartbeat within the last 72 -// hours. -func (t *HostLifecycleTracker) IsActive(hostID string) bool { - t.mu.RLock() - defer t.mu.RUnlock() - - e, ok := t.hosts[hostID] - if !ok { - return false - } - return t.nowFunc().Sub(e.LastSeen) <= HostInactivityTimeout -} - -// FirstSeen returns the time the host was first seen. Returns the zero value -// if the host is unknown. -func (t *HostLifecycleTracker) FirstSeen(hostID string) time.Time { - t.mu.RLock() - defer t.mu.RUnlock() - - e, ok := t.hosts[hostID] - if !ok { - return time.Time{} - } - return e.FirstSeen -} - -// StableActiveHosts returns the IDs of all hosts that are both stable (10+ -// minutes of heartbeats) and active (heartbeat within the last 72 hours). -// The returned slice is in no particular order. -func (t *HostLifecycleTracker) StableActiveHosts() []string { - t.mu.RLock() - defer t.mu.RUnlock() - - now := t.nowFunc() - var result []string - for id, e := range t.hosts { - if e.StableAt != nil && now.Sub(e.LastSeen) <= HostInactivityTimeout { - result = append(result, id) - } - } - return result -} - -// Prune removes hosts that have been inactive for longer than -// HostInactivityTimeout. Call periodically (e.g. hourly) to free memory. -func (t *HostLifecycleTracker) Prune() { - t.mu.Lock() - defer t.mu.Unlock() - - now := t.nowFunc() - for id, e := range t.hosts { - if now.Sub(e.LastSeen) > HostInactivityTimeout { - delete(t.hosts, id) - } - } -} - -// HostCount returns the total number of tracked hosts (regardless of state). -func (t *HostLifecycleTracker) HostCount() int { - t.mu.RLock() - defer t.mu.RUnlock() - return len(t.hosts) -} - -// --------------------------------------------------------------------------- -// Persistence -// --------------------------------------------------------------------------- - -// persistedState is the JSON-serializable representation of the tracker state. -type persistedState struct { - Hosts map[string]*hostEntry `json:"hosts"` -} - -// SaveState persists the current tracker state to a JSON file at the given -// path. The file is written atomically (temp file + rename). -func (t *HostLifecycleTracker) SaveState(path string) error { - t.mu.RLock() - state := persistedState{Hosts: t.hosts} - data, err := json.MarshalIndent(state, "", " ") - t.mu.RUnlock() - if err != nil { - return err - } - - dir := filepath.Dir(path) - if err := os.MkdirAll(dir, 0700); err != nil { - return err - } - - f, err := os.CreateTemp(dir, filepath.Base(path)+".*.tmp") - if err != nil { - return err - } - tmp := f.Name() - - if _, err := f.Write(data); err != nil { - f.Close() - os.Remove(tmp) - return err - } - if err := f.Close(); err != nil { - os.Remove(tmp) - return err - } - if err := os.Rename(tmp, path); err != nil { - os.Remove(tmp) - return err - } - return nil -} - -// LoadState restores tracker state from a JSON file previously written by -// SaveState. If the file does not exist, the tracker is left empty (no error). -func (t *HostLifecycleTracker) LoadState(path string) error { - data, err := os.ReadFile(path) - if err != nil { - if os.IsNotExist(err) { - return nil - } - return err - } - if len(data) == 0 { - t.mu.Lock() - t.hosts = make(map[string]*hostEntry) - t.mu.Unlock() - return nil - } - - var state persistedState - if err := json.Unmarshal(data, &state); err != nil { - return err - } - - t.mu.Lock() - defer t.mu.Unlock() - - if state.Hosts == nil { - t.hosts = make(map[string]*hostEntry) - return nil - } - - // Filter out nil entries that could cause panics on read paths. - for id, e := range state.Hosts { - if e == nil { - delete(state.Hosts, id) - } - } - t.hosts = state.Hosts - return nil -} diff --git a/pkg/licensing/host_lifecycle_test.go b/pkg/licensing/host_lifecycle_test.go index e2ac7317b..687c1e96c 100644 --- a/pkg/licensing/host_lifecycle_test.go +++ b/pkg/licensing/host_lifecycle_test.go @@ -2,444 +2,26 @@ package licensing import ( "os" - "path/filepath" - "sync" + "strings" "testing" - "time" ) -// testClock is a controllable clock for deterministic tests. -type testClock struct { - mu sync.Mutex - now time.Time -} - -func newTestClock(t time.Time) *testClock { - return &testClock{now: t} -} - -func (c *testClock) Now() time.Time { - c.mu.Lock() - defer c.mu.Unlock() - return c.now -} - -func (c *testClock) Advance(d time.Duration) { - c.mu.Lock() - defer c.mu.Unlock() - c.now = c.now.Add(d) -} - -func newTestTracker(clock *testClock) *HostLifecycleTracker { - t := NewHostLifecycleTracker() - t.nowFunc = clock.Now - return t -} - -// --------------------------------------------------------------------------- -// H2: Stabilization -// --------------------------------------------------------------------------- - -func TestNewHostIsNotStable(t *testing.T) { - clock := newTestClock(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)) - tracker := newTestTracker(clock) - - tracker.RecordHeartbeat("host-1") - - if tracker.IsStable("host-1") { - t.Fatal("new host should not be stable immediately") +func TestHostLifecycleCapacityTrackerStaysRetired(t *testing.T) { + source, err := os.ReadFile("host_lifecycle.go") + if err != nil { + t.Fatalf("ReadFile() error = %v", err) } - // Should not appear in stable-active list. - if got := tracker.StableActiveHosts(); len(got) != 0 { - t.Fatalf("expected 0 stable-active hosts, got %d", len(got)) - } -} - -func TestHostBecomesCapacityEligibleAfter10Min(t *testing.T) { - clock := newTestClock(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)) - tracker := newTestTracker(clock) - - tracker.RecordHeartbeat("host-1") - - // 9 minutes: still not stable. - clock.Advance(9 * time.Minute) - tracker.RecordHeartbeat("host-1") - if tracker.IsStable("host-1") { - t.Fatal("host should not be stable before 10 minutes") - } - - // Exactly 10 minutes: becomes stable on heartbeat. - clock.Advance(1 * time.Minute) - tracker.RecordHeartbeat("host-1") - if !tracker.IsStable("host-1") { - t.Fatal("host should be stable after 10 minutes of heartbeats") - } - - hosts := tracker.StableActiveHosts() - if len(hosts) != 1 || hosts[0] != "host-1" { - t.Fatalf("expected [host-1], got %v", hosts) - } -} - -func TestStabilityIsNotLostOnInactivity(t *testing.T) { - clock := newTestClock(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)) - tracker := newTestTracker(clock) - - tracker.RecordHeartbeat("host-1") - clock.Advance(15 * time.Minute) - tracker.RecordHeartbeat("host-1") - if !tracker.IsStable("host-1") { - t.Fatal("host should be stable") - } - - // After 50h (still within 72h), host is still stable. - clock.Advance(50 * time.Hour) - if !tracker.IsStable("host-1") { - t.Fatal("stability should not be cleared by passage of time alone") - } -} - -// --------------------------------------------------------------------------- -// H3: Inactivity -// --------------------------------------------------------------------------- - -func TestHostBecomesInactiveAfter72h(t *testing.T) { - clock := newTestClock(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)) - tracker := newTestTracker(clock) - - tracker.RecordHeartbeat("host-1") - clock.Advance(15 * time.Minute) - tracker.RecordHeartbeat("host-1") // now stable - - if !tracker.IsActive("host-1") { - t.Fatal("host should be active right after heartbeat") - } - - // Just within the window. - clock.Advance(72 * time.Hour) - if !tracker.IsActive("host-1") { - t.Fatal("host should still be active at exactly 72h") - } - - // Past the window. - clock.Advance(1 * time.Second) - if tracker.IsActive("host-1") { - t.Fatal("host should be inactive after 72h + 1s") - } - - // Stable but inactive — should NOT be in stable-active list. - if got := tracker.StableActiveHosts(); len(got) != 0 { - t.Fatalf("expected 0 stable-active hosts after inactivity, got %d", len(got)) - } -} - -func TestHeartbeatResetsInactivityTimer(t *testing.T) { - clock := newTestClock(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)) - tracker := newTestTracker(clock) - - tracker.RecordHeartbeat("host-1") - clock.Advance(15 * time.Minute) - tracker.RecordHeartbeat("host-1") - - // Advance close to 72h, then send another heartbeat. - clock.Advance(71 * time.Hour) - tracker.RecordHeartbeat("host-1") - - // Another 71h — total 142h since first seen, but only 71h since last heartbeat. - clock.Advance(71 * time.Hour) - if !tracker.IsActive("host-1") { - t.Fatal("host should still be active — heartbeat reset the timer") - } -} - -// --------------------------------------------------------------------------- -// Prune -// --------------------------------------------------------------------------- - -func TestPruneRemovesInactiveHosts(t *testing.T) { - clock := newTestClock(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)) - tracker := newTestTracker(clock) - - tracker.RecordHeartbeat("keep") - tracker.RecordHeartbeat("remove") - - // Make "remove" inactive. - clock.Advance(73 * time.Hour) - tracker.RecordHeartbeat("keep") // keep stays alive - - tracker.Prune() - - if tracker.HostCount() != 1 { - t.Fatalf("expected 1 host after prune, got %d", tracker.HostCount()) - } - if tracker.IsActive("remove") { - t.Fatal("pruned host should not exist") - } - if !tracker.IsActive("keep") { - t.Fatal("kept host should still be active") - } -} - -func TestPruneNoOpWhenAllActive(t *testing.T) { - clock := newTestClock(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)) - tracker := newTestTracker(clock) - - tracker.RecordHeartbeat("a") - tracker.RecordHeartbeat("b") - tracker.Prune() - - if tracker.HostCount() != 2 { - t.Fatalf("expected 2 hosts, got %d", tracker.HostCount()) - } -} - -// --------------------------------------------------------------------------- -// FirstSeen -// --------------------------------------------------------------------------- - -func TestFirstSeen(t *testing.T) { - start := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) - clock := newTestClock(start) - tracker := newTestTracker(clock) - - tracker.RecordHeartbeat("host-1") - clock.Advance(5 * time.Minute) - tracker.RecordHeartbeat("host-1") // should not change firstSeen - - got := tracker.FirstSeen("host-1") - if !got.Equal(start) { - t.Fatalf("FirstSeen should be %v, got %v", start, got) - } - - // Unknown host returns zero. - if fs := tracker.FirstSeen("unknown"); !fs.IsZero() { - t.Fatalf("FirstSeen for unknown host should be zero, got %v", fs) - } -} - -// --------------------------------------------------------------------------- -// Unknown host defaults -// --------------------------------------------------------------------------- - -func TestUnknownHostDefaults(t *testing.T) { - tracker := NewHostLifecycleTracker() - - if tracker.IsStable("nope") { - t.Fatal("unknown host should not be stable") - } - if tracker.IsActive("nope") { - t.Fatal("unknown host should not be active") - } -} - -// --------------------------------------------------------------------------- -// Multiple hosts -// --------------------------------------------------------------------------- - -func TestMultipleHostsIndependent(t *testing.T) { - clock := newTestClock(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)) - tracker := newTestTracker(clock) - - tracker.RecordHeartbeat("a") - clock.Advance(5 * time.Minute) - tracker.RecordHeartbeat("b") - clock.Advance(5 * time.Minute) - // a: 10min elapsed, b: 5min elapsed - tracker.RecordHeartbeat("a") - tracker.RecordHeartbeat("b") - - if !tracker.IsStable("a") { - t.Fatal("a should be stable (10min)") - } - if tracker.IsStable("b") { - t.Fatal("b should not be stable yet (5min)") - } - - clock.Advance(5 * time.Minute) - tracker.RecordHeartbeat("b") - if !tracker.IsStable("b") { - t.Fatal("b should be stable now (10min)") - } - - hosts := tracker.StableActiveHosts() - if len(hosts) != 2 { - t.Fatalf("expected 2 stable-active hosts, got %d", len(hosts)) - } -} - -// --------------------------------------------------------------------------- -// Persistence -// --------------------------------------------------------------------------- - -func TestSaveAndLoadState(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "lifecycle.json") - - clock := newTestClock(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)) - tracker := newTestTracker(clock) - - tracker.RecordHeartbeat("host-1") - clock.Advance(15 * time.Minute) - tracker.RecordHeartbeat("host-1") // stable - - tracker.RecordHeartbeat("host-2") // not stable yet - - if err := tracker.SaveState(path); err != nil { - t.Fatalf("SaveState: %v", err) - } - - // Load into a fresh tracker. - tracker2 := newTestTracker(clock) - if err := tracker2.LoadState(path); err != nil { - t.Fatalf("LoadState: %v", err) - } - - if !tracker2.IsStable("host-1") { - t.Fatal("host-1 should be stable after load") - } - if tracker2.IsStable("host-2") { - t.Fatal("host-2 should not be stable after load") - } - if tracker2.HostCount() != 2 { - t.Fatalf("expected 2 hosts after load, got %d", tracker2.HostCount()) - } - - // FirstSeen preserved. - expected := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) - if fs := tracker2.FirstSeen("host-1"); !fs.Equal(expected) { - t.Fatalf("FirstSeen not preserved: want %v, got %v", expected, fs) - } -} - -func TestLoadStateMissingFile(t *testing.T) { - tracker := NewHostLifecycleTracker() - if err := tracker.LoadState("/nonexistent/path/lifecycle.json"); err != nil { - t.Fatalf("LoadState on missing file should not error, got: %v", err) - } - if tracker.HostCount() != 0 { - t.Fatal("tracker should be empty after loading missing file") - } -} - -func TestLoadStateEmptyFile(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "lifecycle.json") - if err := os.WriteFile(path, []byte{}, 0600); err != nil { - t.Fatal(err) - } - - // Pre-populate to verify empty file replaces state. - tracker := NewHostLifecycleTracker() - tracker.RecordHeartbeat("old") - - if err := tracker.LoadState(path); err != nil { - t.Fatalf("LoadState on empty file should not error, got: %v", err) - } - if tracker.HostCount() != 0 { - t.Fatalf("expected 0 hosts after loading empty file, got %d", tracker.HostCount()) - } -} - -func TestLoadStateNullHostEntry(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "lifecycle.json") - // Simulate a corrupted file with a null entry. - if err := os.WriteFile(path, []byte(`{"hosts":{"h1":null,"h2":{"first_seen":"2026-01-01T00:00:00Z","last_seen":"2026-01-01T00:15:00Z","stable_at":"2026-01-01T00:10:00Z"}}}`), 0600); err != nil { - t.Fatal(err) - } - - tracker := NewHostLifecycleTracker() - if err := tracker.LoadState(path); err != nil { - t.Fatalf("LoadState should handle null entries, got: %v", err) - } - // Null entry filtered out; valid entry kept. - if tracker.HostCount() != 1 { - t.Fatalf("expected 1 host (null filtered), got %d", tracker.HostCount()) - } - if !tracker.IsStable("h2") { - t.Fatal("h2 should be stable") - } -} - -func TestLoadStateNullHostsMap(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "lifecycle.json") - if err := os.WriteFile(path, []byte(`{"hosts":null}`), 0600); err != nil { - t.Fatal(err) - } - - // Pre-populate tracker to verify LoadState replaces state. - tracker := NewHostLifecycleTracker() - tracker.RecordHeartbeat("old-host") - - if err := tracker.LoadState(path); err != nil { - t.Fatalf("LoadState: %v", err) - } - if tracker.HostCount() != 0 { - t.Fatalf("expected 0 hosts after loading null map, got %d", tracker.HostCount()) - } -} - -func TestLoadStateReplacesExistingState(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "lifecycle.json") - if err := os.WriteFile(path, []byte(`{}`), 0600); err != nil { - t.Fatal(err) - } - - tracker := NewHostLifecycleTracker() - tracker.RecordHeartbeat("stale") - - if err := tracker.LoadState(path); err != nil { - t.Fatalf("LoadState: %v", err) - } - // Loading an empty object should replace existing state. - if tracker.HostCount() != 0 { - t.Fatalf("expected 0 hosts after loading empty state, got %d", tracker.HostCount()) - } -} - -func TestEmptyHostIDIgnored(t *testing.T) { - tracker := NewHostLifecycleTracker() - tracker.RecordHeartbeat("") - if tracker.HostCount() != 0 { - t.Fatalf("empty hostID should be ignored, got %d hosts", tracker.HostCount()) - } -} - -// --------------------------------------------------------------------------- -// Thread safety -// --------------------------------------------------------------------------- - -func TestConcurrentAccess(t *testing.T) { - tracker := NewHostLifecycleTracker() - - var wg sync.WaitGroup - const goroutines = 50 - const iterations = 100 - - wg.Add(goroutines) - for i := 0; i < goroutines; i++ { - go func(id int) { - defer wg.Done() - hostID := "host-" + string(rune('A'+id%26)) - for j := 0; j < iterations; j++ { - tracker.RecordHeartbeat(hostID) - tracker.IsStable(hostID) - tracker.IsActive(hostID) - tracker.FirstSeen(hostID) - tracker.StableActiveHosts() - if j%50 == 0 { - tracker.Prune() - } - } - }(i) - } - - wg.Wait() - - // No panics or races = pass. Just verify basic sanity. - if tracker.HostCount() == 0 { - t.Fatal("expected some hosts after concurrent access") + + for _, retired := range []string{ + "HostLifecycleTracker", + "HostStabilizationPeriod", + "HostInactivityTimeout", + "StableActiveHosts", + "RecordHeartbeat", + "monitored-system capacity", + } { + if strings.Contains(string(source), retired) { + t.Fatalf("host lifecycle capacity tracker must stay retired; found %q", retired) + } } } diff --git a/pkg/licensing/legacy_v5_monitored_system_limit_aliases.go b/pkg/licensing/legacy_v5_monitored_system_limit_aliases.go index 025a0fce7..4b9803295 100644 --- a/pkg/licensing/legacy_v5_monitored_system_limit_aliases.go +++ b/pkg/licensing/legacy_v5_monitored_system_limit_aliases.go @@ -1,9 +1,6 @@ package licensing -import ( - "encoding/json" - "strings" -) +import "strings" const legacyV5AgentLimitKey = "max_agents" const legacyV5NodeLimitKey = "max_nodes" @@ -21,25 +18,3 @@ func canonicalizeLegacyV5MonitoredSystemLimitKey(key string) (string, bool) { return "", false } } - -func decodeLegacyV5MonitoredSystemLimitFromJSON(data []byte) (int, bool, error) { - var raw map[string]json.RawMessage - if err := json.Unmarshal(data, &raw); err != nil { - return 0, false, err - } - if _, hasCanonical := raw[MaxMonitoredSystemsLicenseGateKey]; hasCanonical { - return 0, false, nil - } - for _, key := range legacyV5MonitoredSystemLimitAliasKeys { - value, ok := raw[key] - if !ok { - continue - } - var parsed int - if err := json.Unmarshal(value, &parsed); err != nil { - return 0, false, err - } - return parsed, true, nil - } - return 0, false, nil -} diff --git a/pkg/licensing/models.go b/pkg/licensing/models.go index ee7f1c03c..364d0a11a 100644 --- a/pkg/licensing/models.go +++ b/pkg/licensing/models.go @@ -1,7 +1,6 @@ package licensing import ( - "encoding/json" "sort" "time" ) @@ -26,9 +25,6 @@ type Claims struct { // Features explicitly granted (optional, tier implies features) Features []string `json:"features,omitempty"` - // Max agents (0 = unlimited) - MaxMonitoredSystems int `json:"max_monitored_systems,omitempty"` - // Max guests (0 = unlimited) MaxGuests int `json:"max_guests,omitempty"` @@ -47,25 +43,6 @@ type Claims struct { CoreMonitoringUncapped bool `json:"-"` } -// UnmarshalJSON implements custom JSON unmarshaling for Claims to handle the -// migration from legacy monitored-system aliases to "max_monitored_systems". -// Existing JWTs and billing.json files may still contain older keys; this shim -// reads them all and prefers the canonical monitored-system field when present. -func (c *Claims) UnmarshalJSON(data []byte) error { - // Unmarshal into the base type (avoids infinite recursion). - type Alias Claims - if err := json.Unmarshal(data, (*Alias)(c)); err != nil { - return err - } - - // Migration shim: if the canonical field was absent, adopt the legacy v5 - // monitored-system alias that may still exist in older JWTs or billing.json. - if legacy, ok, err := decodeLegacyV5MonitoredSystemLimitFromJSON(data); err == nil && ok { - c.MaxMonitoredSystems = legacy - } - return nil -} - // EffectiveCapabilities returns explicit capabilities when present; otherwise tier-derived capabilities. func (c Claims) EffectiveCapabilities() []string { if c.Capabilities != nil && len(c.Capabilities) > 0 { @@ -74,45 +51,16 @@ func (c Claims) EffectiveCapabilities() []string { return DeriveCapabilitiesFromTier(c.Tier, c.Features) } -func (c Claims) shouldScrubLegacyCommercialCaps() bool { - if c.CoreMonitoringUncapped { - return true - } - if IsSelfHostedCoreMonitoringUncappedTier(c.Tier) { - return true - } - if IsSelfHostedCoreMonitoringUncappedPlanVersion(c.PlanVersion) { - return true - } - return false -} - // EffectiveLimits returns explicit limits when present; otherwise limits derived from legacy fields. func (c Claims) EffectiveLimits() map[string]int64 { limits := NormalizeMonitoredSystemLimits(c.Limits) + stripSelfHostedCommercialVolumeCaps(limits, c.EntitlementPlanVersion(), c.Tier, c.CoreMonitoringUncapped) if len(limits) == 0 { limits = make(map[string]int64) - if c.MaxMonitoredSystems > 0 { - limits[MaxMonitoredSystemsLicenseGateKey] = int64(c.MaxMonitoredSystems) - } if c.MaxGuests > 0 { limits["max_guests"] = int64(c.MaxGuests) } - } - if c.shouldScrubLegacyCommercialCaps() { - // Local activation migrations and grandfathered continuity may still - // carry legacy monitored-system caps in stale claim fields. Scrub those - // caps only when the canonical contract says core monitoring is uncapped. - delete(limits, MaxMonitoredSystemsLicenseGateKey) - delete(limits, "max_guests") - } - if c.Tier == TierCloud || c.Tier == TierMSP { - if limit, known := CloudPlanMonitoredSystemLimits[CanonicalizePlanVersion(c.PlanVersion)]; known { - limits[MaxMonitoredSystemsLicenseGateKey] = int64(limit) - } - if _, hasSystems := limits[MaxMonitoredSystemsLicenseGateKey]; !hasSystems { - limits[MaxMonitoredSystemsLicenseGateKey] = int64(UnknownPlanDefaultMonitoredSystemLimit) - } + stripSelfHostedCommercialVolumeCaps(limits, c.EntitlementPlanVersion(), c.Tier, c.CoreMonitoringUncapped) } return limits } @@ -220,27 +168,15 @@ 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"` - 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"` + 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"` + MaxGuests int `json:"max_guests,omitempty"` + InGracePeriod bool `json:"in_grace_period,omitempty"` + GracePeriodEnd *string `json:"grace_period_end,omitempty"` } diff --git a/pkg/licensing/models_test.go b/pkg/licensing/models_test.go index a3a335e81..dca87607d 100644 --- a/pkg/licensing/models_test.go +++ b/pkg/licensing/models_test.go @@ -65,98 +65,89 @@ func TestClaims_EffectiveLimits(t *testing.T) { expected map[string]int64 }{ { - name: "explicit_limits_returns_them", + name: "explicit_limits_scrub_retired_monitored_system_cap", claims: Claims{ - Limits: map[string]int64{"max_monitored_systems": 100, "max_guests": 500}, - MaxMonitoredSystems: 50, - MaxGuests: 200, + Limits: map[string]int64{"max_monitored_systems": 100, "max_guests": 500}, + MaxGuests: 200, }, - expected: map[string]int64{"max_monitored_systems": 100, "max_guests": 500}, + expected: map[string]int64{"max_guests": 500}, }, { - name: "nil_limits_derives_from_legacy_fields", + name: "nil_limits_derives_from_guest_field", claims: Claims{ - Limits: nil, - MaxMonitoredSystems: 25, - MaxGuests: 100, - }, - expected: map[string]int64{"max_monitored_systems": 25, "max_guests": 100}, - }, - { - name: "zero_max_monitored_systems_ignored", - claims: Claims{ - Limits: nil, - MaxMonitoredSystems: 0, - MaxGuests: 100, + Limits: nil, + MaxGuests: 100, }, expected: map[string]int64{"max_guests": 100}, }, + { + name: "zero_max_guests_ignored", + claims: Claims{ + Limits: nil, + MaxGuests: 0, + }, + expected: map[string]int64{}, + }, { name: "no_limits_returns_empty", claims: Claims{}, expected: map[string]int64{}, }, { - name: "lifetime_strips_legacy_caps", + name: "lifetime_scrubs_retired_monitored_system_cap", claims: Claims{ - Tier: TierLifetime, - Limits: map[string]int64{"max_monitored_systems": 15, "max_guests": 5}, - MaxMonitoredSystems: 15, - MaxGuests: 5, + Tier: TierLifetime, + Limits: map[string]int64{"max_monitored_systems": 15, "max_guests": 5}, + MaxGuests: 5, }, expected: map[string]int64{}, }, { - name: "grandfathered_recurring_v5_strips_new_v6_caps", + name: "grandfathered_recurring_v5_scrubs_retired_monitored_system_cap", claims: Claims{ - Tier: TierPro, - PlanVersion: "v5_pro_annual_grandfathered", - Limits: map[string]int64{"max_monitored_systems": 15, "max_guests": 5}, - MaxMonitoredSystems: 15, - MaxGuests: 5, + Tier: TierPro, + PlanVersion: "v5_pro_annual_grandfathered", + Limits: map[string]int64{"max_monitored_systems": 15, "max_guests": 5}, + MaxGuests: 5, }, expected: map[string]int64{}, }, { - name: "grandfathered_monthly_v5_strips_new_v6_caps", + name: "grandfathered_monthly_v5_scrubs_retired_monitored_system_cap", claims: Claims{ - Tier: TierPro, - PlanVersion: "v5_pro_monthly_grandfathered", - Limits: map[string]int64{"max_monitored_systems": 15, "max_guests": 5}, - MaxMonitoredSystems: 15, - MaxGuests: 5, + Tier: TierPro, + PlanVersion: "v5_pro_monthly_grandfathered", + Limits: map[string]int64{"max_monitored_systems": 15, "max_guests": 5}, + MaxGuests: 5, }, expected: map[string]int64{}, }, { - name: "community_plan_strips_legacy_v5_cap_after_upgrade", + name: "community_plan_scrubs_retired_monitored_system_cap_after_upgrade", claims: Claims{ - Tier: TierFree, - PlanVersion: "community", - Limits: map[string]int64{"max_monitored_systems": 1, "max_guests": 5}, - MaxMonitoredSystems: 1, - MaxGuests: 5, + Tier: TierFree, + PlanVersion: "community", + Limits: map[string]int64{"max_monitored_systems": 1, "max_guests": 5}, + MaxGuests: 5, }, expected: map[string]int64{}, }, { - name: "self_hosted_pro_claims_strip_legacy_volume_caps", + name: "self_hosted_pro_claims_scrub_retired_monitored_system_cap", claims: Claims{ - Tier: TierPro, - Limits: map[string]int64{"max_monitored_systems": 15, "max_guests": 100}, - MaxMonitoredSystems: 15, - MaxGuests: 100, + Tier: TierPro, + Limits: map[string]int64{"max_monitored_systems": 15, "max_guests": 100}, + MaxGuests: 100, }, expected: map[string]int64{}, }, { - name: "generic_non_self_hosted_claims_preserve_explicit_limits_without_uncapped_marker", + name: "generic_non_self_hosted_claims_scrub_retired_monitored_system_cap", claims: Claims{ - Limits: map[string]int64{"max_monitored_systems": 15, "max_guests": 100}, - MaxMonitoredSystems: 15, - MaxGuests: 100, + Limits: map[string]int64{"max_monitored_systems": 15, "max_guests": 100}, + MaxGuests: 100, }, - expected: map[string]int64{"max_monitored_systems": 15, "max_guests": 100}, + expected: map[string]int64{"max_guests": 100}, }, { name: "grant_backed_self_hosted_uncapped_marker_strips_legacy_cap", @@ -164,7 +155,6 @@ func TestClaims_EffectiveLimits(t *testing.T) { Tier: TierPro, CoreMonitoringUncapped: true, Limits: map[string]int64{"max_monitored_systems": 15, "max_guests": 100}, - MaxMonitoredSystems: 15, MaxGuests: 100, }, expected: map[string]int64{}, @@ -270,7 +260,7 @@ func TestClaims_EntitlementPlanVersion(t *testing.T) { } } -func TestClaims_EffectiveLimitsCanonicalizesCloudPlanLimits(t *testing.T) { +func TestClaims_EffectiveLimitsScrubsRetiredCloudPlanMonitoringLimit(t *testing.T) { claims := Claims{ Tier: TierCloud, PlanVersion: "cloud-v1", @@ -281,40 +271,36 @@ func TestClaims_EffectiveLimitsCanonicalizesCloudPlanLimits(t *testing.T) { } limits := claims.EffectiveLimits() - if got := limits["max_monitored_systems"]; got != 10 { - t.Fatalf("EffectiveLimits()[max_monitored_systems]=%d, want %d", got, 10) + if _, ok := limits["max_monitored_systems"]; ok { + t.Fatalf("EffectiveLimits retained retired max_monitored_systems: %v", limits) } if got := limits["max_guests"]; got != 5 { t.Fatalf("EffectiveLimits()[max_guests]=%d, want %d", got, 5) } } -func TestClaims_EffectiveLimitsPreservesNonCloudPlanLimits(t *testing.T) { +func TestClaims_EffectiveLimitsScrubsRetiredNonCloudMonitoringLimit(t *testing.T) { claims := Claims{ PlanVersion: "pro-v2", Limits: map[string]int64{ "max_monitored_systems": 42, + "max_guests": 7, }, } limits := claims.EffectiveLimits() - if got := limits["max_monitored_systems"]; got != 42 { - t.Fatalf("EffectiveLimits()[max_monitored_systems]=%d, want %d", got, 42) + if _, ok := limits["max_monitored_systems"]; ok { + t.Fatalf("EffectiveLimits retained retired max_monitored_systems: %v", limits) + } + if got := limits["max_guests"]; got != 7 { + t.Fatalf("EffectiveLimits()[max_guests]=%d, want %d", got, 7) } } -func TestLicenseStatusJSON_EncodesMonitoredSystemContinuity(t *testing.T) { +func TestLicenseStatusJSON_OmitsRetiredMonitoredSystemFields(t *testing.T) { status := LicenseStatus{ - Valid: true, - Tier: TierPro, - MaxMonitoredSystems: 23, - MonitoredSystemContinuity: &MonitoredSystemContinuityStatus{ - PlanLimit: 10, - GrandfatheredFloor: 23, - EffectiveLimit: 23, - CapturePending: false, - CapturedAt: 123, - }, + Valid: true, + Tier: TierPro, } data, err := json.Marshal(status) @@ -327,21 +313,10 @@ func TestLicenseStatusJSON_EncodesMonitoredSystemContinuity(t *testing.T) { 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) + for _, key := range []string{"max_monitored_systems", "monitored_system_continuity"} { + if _, ok := decoded[key]; ok { + t.Fatalf("status JSON retained retired %q field: %v", key, decoded) + } } } @@ -352,23 +327,27 @@ func TestClaims_EffectiveLimitsMissingCloudPlanFailsClosed(t *testing.T) { } limits := claims.EffectiveLimits() - if got := limits["max_monitored_systems"]; got != int64(UnknownPlanDefaultMonitoredSystemLimit) { - t.Fatalf("EffectiveLimits()[max_monitored_systems]=%d, want %d", got, UnknownPlanDefaultMonitoredSystemLimit) + if _, ok := limits["max_monitored_systems"]; ok { + t.Fatalf("EffectiveLimits retained retired max_monitored_systems: %v", limits) } } -func TestClaims_EffectiveLimitsPreservesExplicitCustomCloudLimit(t *testing.T) { +func TestClaims_EffectiveLimitsScrubsExplicitCustomCloudMonitoringLimit(t *testing.T) { claims := Claims{ Tier: TierCloud, PlanVersion: "custom_plan", Limits: map[string]int64{ "max_monitored_systems": 42, + "max_guests": 7, }, } limits := claims.EffectiveLimits() - if got := limits["max_monitored_systems"]; got != 42 { - t.Fatalf("EffectiveLimits()[max_monitored_systems]=%d, want %d", got, 42) + if _, ok := limits["max_monitored_systems"]; ok { + t.Fatalf("EffectiveLimits retained retired max_monitored_systems: %v", limits) + } + if got := limits["max_guests"]; got != 7 { + t.Fatalf("EffectiveLimits()[max_guests]=%d, want %d", got, 7) } } @@ -655,34 +634,28 @@ func TestLicense_AllFeatures(t *testing.T) { func TestClaims_UnmarshalJSON_Migration(t *testing.T) { tests := []struct { - name string - json string - wantMax int + name string + json string }{ { - name: "new_key_only", - json: `{"lid":"x","email":"a@b","tier":"pro","iat":1,"max_monitored_systems":10}`, - wantMax: 10, + name: "new_key_only", + json: `{"lid":"x","email":"a@b","tier":"pro","iat":1,"max_monitored_systems":10}`, }, { - name: "legacy_key_only", - json: `{"lid":"x","email":"a@b","tier":"pro","iat":1,"max_nodes":5}`, - wantMax: 5, + name: "legacy_key_only", + json: `{"lid":"x","email":"a@b","tier":"pro","iat":1,"max_nodes":5}`, }, { - name: "both_keys_prefer_new", - json: `{"lid":"x","email":"a@b","tier":"pro","iat":1,"max_monitored_systems":15,"max_nodes":5}`, - wantMax: 15, + name: "both_keys_ignored", + json: `{"lid":"x","email":"a@b","tier":"pro","iat":1,"max_monitored_systems":15,"max_nodes":5}`, }, { - name: "new_key_zero_ignores_legacy", - json: `{"lid":"x","email":"a@b","tier":"pro","iat":1,"max_monitored_systems":0,"max_nodes":5}`, - wantMax: 0, + name: "new_key_zero_ignored", + json: `{"lid":"x","email":"a@b","tier":"pro","iat":1,"max_monitored_systems":0,"max_nodes":5}`, }, { - name: "neither_key", - json: `{"lid":"x","email":"a@b","tier":"pro","iat":1}`, - wantMax: 0, + name: "neither_key", + json: `{"lid":"x","email":"a@b","tier":"pro","iat":1}`, }, } for _, tt := range tests { @@ -691,8 +664,8 @@ func TestClaims_UnmarshalJSON_Migration(t *testing.T) { if err := json.Unmarshal([]byte(tt.json), &c); err != nil { t.Fatalf("unmarshal: %v", err) } - if c.MaxMonitoredSystems != tt.wantMax { - t.Fatalf("MaxMonitoredSystems = %d, want %d", c.MaxMonitoredSystems, tt.wantMax) + if _, ok := c.EffectiveLimits()["max_monitored_systems"]; ok { + t.Fatalf("EffectiveLimits retained retired max_monitored_systems: %v", c.EffectiveLimits()) } }) } diff --git a/pkg/licensing/monitored_system_limit.go b/pkg/licensing/monitored_system_limit.go index 1ed6a117d..6f61eed7e 100644 --- a/pkg/licensing/monitored_system_limit.go +++ b/pkg/licensing/monitored_system_limit.go @@ -3,7 +3,6 @@ package licensing import ( "crypto/sha256" "encoding/hex" - "fmt" "strings" "github.com/rcourtman/pulse-go-rewrite/internal/models" @@ -12,23 +11,13 @@ import ( agentsk8s "github.com/rcourtman/pulse-go-rewrite/pkg/agents/kubernetes" ) +// MaxMonitoredSystemsLicenseGateKey is retained only to identify and scrub +// retired v5/v6 monitored-system volume-limit metadata from old licenses, +// billing records, purchase-return state, and entitlement leases. const MaxMonitoredSystemsLicenseGateKey = "max_monitored_systems" -func ExceedsMonitoredSystemLimit(current, additions, limit int) bool { - if limit <= 0 || additions <= 0 { - return false - } - return current+additions > limit -} - -func MonitoredSystemLimitExceededMessage(current, limit int) string { - return fmt.Sprintf("Monitored-system capacity reached (%d/%d). Remove a monitored system before adding more.", current, limit) -} - // InstalledUnifiedAgentCount returns the number of installed Pulse Unified -// Agents. This is inventory-only metadata and is not the commercial counted -// unit; monitored-system enforcement is derived from canonical unified -// resources instead. +// Agents. This is inventory-only metadata and is not a commercial counted unit. func InstalledUnifiedAgentCount(state models.StateSnapshot) int { return len(state.Hosts) } @@ -50,32 +39,13 @@ func NormalizeMonitoredSystemLimits(limits map[string]int64) map[string]int64 { return nil } normalized := cloneInt64Map(limits) - if value, ok := MonitoredSystemLimitValue(normalized); ok { - normalized[MaxMonitoredSystemsLicenseGateKey] = value - } + delete(normalized, MaxMonitoredSystemsLicenseGateKey) for _, key := range legacyV5MonitoredSystemLimitAliasKeys { delete(normalized, key) } return normalized } -func MonitoredSystemLimitValue(limits map[string]int64) (int64, bool) { - if limits == nil { - return 0, false - } - for _, key := range []string{MaxMonitoredSystemsLicenseGateKey} { - if value, ok := limits[key]; ok { - return value, true - } - } - for _, key := range legacyV5MonitoredSystemLimitAliasKeys { - if value, ok := limits[key]; ok { - return value, true - } - } - return 0, false -} - func HostReportTargetsExistingHost( snapshot models.StateSnapshot, report agentshost.Report, diff --git a/pkg/licensing/monitored_system_limit_test.go b/pkg/licensing/monitored_system_limit_test.go index e924c0314..80035b18b 100644 --- a/pkg/licensing/monitored_system_limit_test.go +++ b/pkg/licensing/monitored_system_limit_test.go @@ -9,38 +9,6 @@ import ( agentsk8s "github.com/rcourtman/pulse-go-rewrite/pkg/agents/kubernetes" ) -func TestExceedsMonitoredSystemLimit(t *testing.T) { - tests := []struct { - name string - current int - additions int - limit int - want bool - }{ - {name: "unlimited", current: 10, additions: 1, limit: 0, want: false}, - {name: "no additions", current: 5, additions: 0, limit: 5, want: false}, - {name: "within limit", current: 4, additions: 1, limit: 5, want: false}, - {name: "at limit", current: 5, additions: 1, limit: 5, want: true}, - {name: "over limit", current: 6, additions: 1, limit: 5, want: true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := ExceedsMonitoredSystemLimit(tt.current, tt.additions, tt.limit); got != tt.want { - t.Fatalf("expected %t, got %t", tt.want, got) - } - }) - } -} - -func TestMonitoredSystemLimitExceededMessage(t *testing.T) { - got := MonitoredSystemLimitExceededMessage(6, 5) - want := "Monitored-system capacity reached (6/5). Remove a monitored system before adding more." - if got != want { - t.Fatalf("expected %q, got %q", want, got) - } -} - func TestInstalledUnifiedAgentCount(t *testing.T) { snapshot := models.StateSnapshot{ Hosts: []models.Host{{ID: "h1"}, {ID: "h2"}, {ID: "h3"}}, diff --git a/pkg/licensing/service.go b/pkg/licensing/service.go index c183396f8..18da53c50 100644 --- a/pkg/licensing/service.go +++ b/pkg/licensing/service.go @@ -445,134 +445,6 @@ func (s *Service) RestoreActivation(state *ActivationState) error { return nil } -// CaptureLegacyMonitoredSystemGrandfatherFloor resolves the one-time -// monitored-system floor for a migrated legacy activation using the canonical -// deduped monitored-system count observed at runtime. -func (s *Service) CaptureLegacyMonitoredSystemGrandfatherFloor(count int) error { - if count < 0 { - count = 0 - } - - s.mu.Lock() - if s.activationState == nil { - s.mu.Unlock() - return nil - } - - continuity := normalizeActivationContinuity(s.activationState.Continuity) - if !continuity.needsLegacyMonitoredSystemCapture() { - s.mu.Unlock() - return nil - } - if s.legacyMigrationUsesUncappedCoreMonitoringLocked() { - s.mu.Unlock() - return nil - } - - currentLimit := 0 - if s.license != nil { - currentLimit = monitoredSystemLimitFromClaims(s.license.Claims) - } - continuity.GrandfatheredMonitoredSystemsCapturedAt = time.Now().Unix() - if count > currentLimit { - continuity.GrandfatheredMaxMonitoredSystems = count - } - s.activationState.Continuity = continuity - - shouldNotify := false - if s.license != nil { - claims := cloneClaims(s.license.Claims) - applyActivationContinuityToClaims(&claims, continuity) - updatedLimit := monitoredSystemLimitFromClaims(claims) - shouldNotify = updatedLimit != currentLimit - s.license.Claims = claims - source := NewTokenSource(&s.license.Claims) - s.evaluator = NewEvaluator(source) - } - - stateCopy := *s.activationState - persistence := s.persistence - cb := s.onLicenseChange - activationCB := s.onActivationStateChange - snapshot := cloneLicense(s.license) - stateSnapshot := cloneActivationState(s.activationState) - s.mu.Unlock() - - if persistence != nil { - if err := persistence.SaveActivationState(&stateCopy); err != nil { - return fmt.Errorf("persist activation continuity: %w", err) - } - } - - if shouldNotify && cb != nil { - cb(snapshot) - } - if activationCB != nil { - activationCB(stateSnapshot) - } - - return nil -} - -func (s *Service) needsLegacyMonitoredSystemCaptureLocked() bool { - if s == nil || s.activationState == nil { - return false - } - if s.legacyMigrationUsesUncappedCoreMonitoringLocked() { - 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 - } - if s.legacyMigrationUsesUncappedCoreMonitoringLocked() { - return nil - } - - planLimit := 0 - if gc, err := verifyAndParseGrantJWT(s.activationState.GrantJWT); err == nil && gc != nil { - planLimit = gc.MaxMonitoredSystems - } - - effectiveLimit := planLimit - if s.license != nil { - // When a license is present, trust its claims. Self-hosted uncapped - // tiers return 0 here as a first-class "unlimited" signal, not a - // missing-data sentinel — do not fall back to the grant's plan limit. - effectiveLimit = monitoredSystemLimitFromClaims(s.license.Claims) - } - - 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() { @@ -787,9 +659,6 @@ func (s *Service) Status() *LicenseStatus { } status.Features = unionFeatures(TierFeatures[TierFree], evaluatorFeatures(s.evaluator)) - if maxSystems, ok := s.evaluator.GetLimit(MaxMonitoredSystemsLicenseGateKey); ok { - status.MaxMonitoredSystems = safeIntFromInt64(maxSystems) - } if maxGuests, ok := s.evaluator.GetLimit("max_guests"); ok { status.MaxGuests = safeIntFromInt64(maxGuests) } @@ -798,18 +667,11 @@ func (s *Service) Status() *LicenseStatus { status.Valid = false // Keep effective capabilities free-tier only when subscription is not entitled. status.Features = append([]string(nil), TierFeatures[TierFree]...) - if defaultSystems := TierMonitoredSystemLimits[TierFree]; defaultSystems > 0 { - status.MaxMonitoredSystems = defaultSystems - } status.MaxGuests = 0 } } else { - // No license, no evaluator — apply the free-tier monitored-system limit. - if defaultSystems := TierMonitoredSystemLimits[TierFree]; defaultSystems > 0 { - status.MaxMonitoredSystems = defaultSystems - } + // No license, no evaluator: Community remains active for core monitoring. } - status.MonitoredSystemContinuity = s.monitoredSystemContinuityStatusLocked() return status } @@ -820,24 +682,9 @@ func (s *Service) Status() *LicenseStatus { status.DaysRemaining = s.license.DaysRemaining() status.Features = s.license.AllFeatures() - if maxSystems, ok := s.license.Claims.EffectiveLimits()[MaxMonitoredSystemsLicenseGateKey]; ok { - status.MaxMonitoredSystems = safeIntFromInt64(maxSystems) - } 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). - // For unrecognized tiers, fall back to free tier limit to prevent unlimited access. - if status.MaxMonitoredSystems == 0 && !IsGrandfatheredRecurringV5PlanVersion(status.PlanVersion) { - if defaultSystems, ok := TierMonitoredSystemLimits[status.Tier]; ok { - status.MaxMonitoredSystems = defaultSystems - } else { - status.MaxMonitoredSystems = TierMonitoredSystemLimits[TierFree] - } - } if s.license.ExpiresAt() != nil { exp := s.license.ExpiresAt().Format(time.RFC3339) @@ -857,12 +704,6 @@ func (s *Service) Status() *LicenseStatus { default: status.Valid = false status.Features = append([]string(nil), TierFeatures[TierFree]...) - // Downgrade limits to the free tier when subscription is not entitled. - if defaultSystems := TierMonitoredSystemLimits[TierFree]; defaultSystems > 0 { - status.MaxMonitoredSystems = defaultSystems - } else { - status.MaxMonitoredSystems = 0 - } status.MaxGuests = 0 } @@ -967,28 +808,6 @@ func safeIntFromInt64(v int64) int { return int(v) } -func monitoredSystemLimitFromClaims(claims Claims) int { - if limit, ok := claims.EffectiveLimits()[MaxMonitoredSystemsLicenseGateKey]; ok { - return safeIntFromInt64(limit) - } - return 0 -} - -func (s *Service) legacyMigrationUsesUncappedCoreMonitoringLocked() bool { - if s == nil || s.activationState == nil { - return false - } - if s.license != nil && s.license.Claims.shouldScrubLegacyCommercialCaps() { - return true - } - gc, err := verifyAndParseGrantJWT(s.activationState.GrantJWT) - if err != nil || gc == nil { - return false - } - return IsSelfHostedCoreMonitoringUncappedTier(Tier(gc.Tier)) || - IsSelfHostedCoreMonitoringUncappedPlanVersion(gc.PlanKey) -} - func remainingDaysCeil(expiresAtUnix, nowUnix int64) int { deltaSeconds := expiresAtUnix - nowUnix if deltaSeconds <= 0 { diff --git a/pkg/licensing/service_activate_test.go b/pkg/licensing/service_activate_test.go index 7c68e5546..bfbc7499c 100644 --- a/pkg/licensing/service_activate_test.go +++ b/pkg/licensing/service_activate_test.go @@ -216,13 +216,12 @@ func TestServiceActivate_ExchangedLegacyJWTMarksLegacyMigrationContinuity(t *tes } grantJWT := makeTestGrantJWT(t, &GrantClaims{ - LicenseID: "lic_continuity", - Tier: "pro", - PlanKey: "v5_pro_monthly_grandfathered", - State: "active", - MaxMonitoredSystems: 10, - IssuedAt: time.Now().Unix(), - ExpiresAt: time.Now().Add(72 * time.Hour).Unix(), + LicenseID: "lic_continuity", + Tier: "pro", + PlanKey: "v5_pro_monthly_grandfathered", + State: "active", + IssuedAt: time.Now().Unix(), + ExpiresAt: time.Now().Add(72 * time.Hour).Unix(), }) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -232,10 +231,9 @@ func TestServiceActivate_ExchangedLegacyJWTMarksLegacyMigrationContinuity(t *tes w.WriteHeader(http.StatusCreated) _ = json.NewEncoder(w).Encode(ActivateInstallationResponse{ License: ActivateResponseLicense{ - LicenseID: "lic_continuity", - State: "active", - Tier: "pro", - MaxMonitoredSystems: 10, + LicenseID: "lic_continuity", + State: "active", + Tier: "pro", }, Installation: ActivateResponseInstallation{ InstallationID: "inst_continuity", @@ -264,9 +262,6 @@ func TestServiceActivate_ExchangedLegacyJWTMarksLegacyMigrationContinuity(t *tes if !state.Continuity.LegacyMigration { t.Fatal("expected legacy exchange to mark legacy migration continuity") } - if state.Continuity.GrandfatheredMonitoredSystemsCapturedAt != 0 { - t.Fatalf("GrandfatheredMonitoredSystemsCapturedAt=%d, want 0 before capture", state.Continuity.GrandfatheredMonitoredSystemsCapturedAt) - } } func TestServiceActivate_CallsActivationStateChangeCallback(t *testing.T) { @@ -280,13 +275,12 @@ func TestServiceActivate_CallsActivationStateChangeCallback(t *testing.T) { } grantJWT := makeTestGrantJWT(t, &GrantClaims{ - LicenseID: "lic_activation_callback", - Tier: "pro", - PlanKey: "v5_pro_monthly_grandfathered", - State: "active", - MaxMonitoredSystems: 10, - IssuedAt: time.Now().Unix(), - ExpiresAt: time.Now().Add(72 * time.Hour).Unix(), + LicenseID: "lic_activation_callback", + Tier: "pro", + PlanKey: "v5_pro_monthly_grandfathered", + State: "active", + IssuedAt: time.Now().Unix(), + ExpiresAt: time.Now().Add(72 * time.Hour).Unix(), }) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -296,10 +290,9 @@ func TestServiceActivate_CallsActivationStateChangeCallback(t *testing.T) { w.WriteHeader(http.StatusCreated) _ = json.NewEncoder(w).Encode(ActivateInstallationResponse{ License: ActivateResponseLicense{ - LicenseID: "lic_activation_callback", - State: "active", - Tier: "pro", - MaxMonitoredSystems: 10, + LicenseID: "lic_activation_callback", + State: "active", + Tier: "pro", }, Installation: ActivateResponseInstallation{ InstallationID: "inst_activation_callback", @@ -340,13 +333,12 @@ func TestServiceRestoreActivation_CallsActivationStateChangeCallback(t *testing. setupTestPublicKey(t) grantJWT := makeTestGrantJWT(t, &GrantClaims{ - LicenseID: "lic_restore_callback", - Tier: "pro", - PlanKey: "v5_pro_monthly_grandfathered", - State: "active", - MaxMonitoredSystems: 10, - IssuedAt: time.Now().Unix(), - ExpiresAt: time.Now().Add(72 * time.Hour).Unix(), + LicenseID: "lic_restore_callback", + Tier: "pro", + PlanKey: "v5_pro_monthly_grandfathered", + State: "active", + IssuedAt: time.Now().Unix(), + ExpiresAt: time.Now().Add(72 * time.Hour).Unix(), }) svc := NewService() @@ -383,109 +375,27 @@ func TestServiceRestoreActivation_CallsActivationStateChangeCallback(t *testing. } } -func TestServiceCaptureLegacyMonitoredSystemGrandfatherFloorSkipsUncappedSelfHosted(t *testing.T) { - setupTestPublicKey(t) - - tmpDir, err := os.MkdirTemp("", "pulse-service-floor-*") - if err != nil { - t.Fatalf("create temp dir: %v", err) - } - defer os.RemoveAll(tmpDir) - - p, err := NewPersistence(tmpDir) - if err != nil { - t.Fatalf("create persistence: %v", err) - } - - initialGrantJWT := makeTestGrantJWT(t, &GrantClaims{ - LicenseID: "lic_floor", - Tier: "pro", - PlanKey: "legacy_migration_fallback", - State: "active", - MaxMonitoredSystems: 10, - IssuedAt: time.Now().Unix(), - ExpiresAt: time.Now().Add(72 * time.Hour).Unix(), - }) - - svc := NewService() - svc.SetPersistence(p) - var callbackState *ActivationState - svc.SetActivationStateChangeCallback(func(state *ActivationState) { - callbackState = state - }) - state := &ActivationState{ - InstallationID: "inst_floor", - InstallationToken: "pit_live_floor", - LicenseID: "lic_floor", - GrantJWT: initialGrantJWT, - GrantJTI: "grant_floor", - InstanceFingerprint: "fp-floor", - Continuity: ActivationContinuity{ - LegacyMigration: true, - }, - } - if err := svc.RestoreActivation(state); err != nil { - t.Fatalf("RestoreActivation: %v", err) - } - - callbackState = nil - if err := svc.CaptureLegacyMonitoredSystemGrandfatherFloor(23); err != nil { - t.Fatalf("CaptureLegacyMonitoredSystemGrandfatherFloor: %v", err) - } - if callbackState != nil { - t.Fatalf("expected no activation-state callback for uncapped self-hosted capture, got %+v", callbackState) - } - - status := svc.Status() - if status.MaxMonitoredSystems != 0 { - t.Fatalf("status.MaxMonitoredSystems=%d, want 0 (uncapped self-hosted)", status.MaxMonitoredSystems) - } - if status.MonitoredSystemContinuity != nil { - t.Fatalf("expected no monitored-system continuity banner for uncapped self-hosted migration, got %+v", status.MonitoredSystemContinuity) - } - - current := svc.Current() - if current == nil { - t.Fatal("expected current license") - } - if got, ok := current.Claims.EffectiveLimits()[MaxMonitoredSystemsLicenseGateKey]; ok { - t.Fatalf("EffectiveLimits()[max_monitored_systems]=%d present, want absent (uncapped self-hosted)", got) - } -} - -func TestServiceStatus_HidesMonitoredSystemContinuityForSelfHostedFallbackMigrations(t *testing.T) { +func TestServiceStatus_RetiredMonitoredSystemContinuityDoesNotSurfaceForSelfHostedFallbackMigrations(t *testing.T) { setupTestPublicKey(t) grantJWT := makeTestGrantJWT(t, &GrantClaims{ - LicenseID: "lic_continuity_status", - Tier: "pro", - PlanKey: "legacy_migration_fallback", - State: "active", - MaxMonitoredSystems: 10, - IssuedAt: time.Now().Unix(), - ExpiresAt: time.Now().Add(72 * time.Hour).Unix(), + LicenseID: "lic_continuity_status", + Tier: "pro", + PlanKey: "legacy_migration_fallback", + State: "active", + IssuedAt: time.Now().Unix(), + ExpiresAt: time.Now().Add(72 * time.Hour).Unix(), }) tests := []struct { - name string - continuity ActivationContinuity - wantMaxSystems int + name string + continuity ActivationContinuity }{ { name: "pending capture", continuity: ActivationContinuity{ LegacyMigration: true, }, - wantMaxSystems: 0, - }, - { - name: "captured floor", - continuity: ActivationContinuity{ - LegacyMigration: true, - GrandfatheredMaxMonitoredSystems: 23, - GrandfatheredMonitoredSystemsCapturedAt: 123, - }, - wantMaxSystems: 0, }, } @@ -505,11 +415,13 @@ func TestServiceStatus_HidesMonitoredSystemContinuityForSelfHostedFallbackMigrat } status := svc.Status() - if status.MaxMonitoredSystems != tt.wantMaxSystems { - t.Fatalf("status.MaxMonitoredSystems=%d, want %d", status.MaxMonitoredSystems, tt.wantMaxSystems) + if status == nil || !status.Valid { + t.Fatalf("expected valid status, got %+v", status) } - if status.MonitoredSystemContinuity != nil { - t.Fatalf("expected no monitored-system continuity banner for uncapped self-hosted migration, got %+v", status.MonitoredSystemContinuity) + if current := svc.Current(); current == nil { + t.Fatal("expected current license") + } else if _, ok := current.Claims.EffectiveLimits()[MaxMonitoredSystemsLicenseGateKey]; ok { + t.Fatalf("EffectiveLimits retained retired max_monitored_systems: %v", current.Claims.EffectiveLimits()) } }) } @@ -519,14 +431,12 @@ func TestServiceStatus_GrandfatheredRecurringV5IsUncapped(t *testing.T) { setupTestPublicKey(t) grantJWT := makeTestGrantJWT(t, &GrantClaims{ - LicenseID: "lic_recurring_grandfathered", - Tier: "pro", - PlanKey: "v5_pro_monthly_grandfathered", - State: "active", - MaxMonitoredSystems: 10, - MaxGuests: 5, - IssuedAt: time.Now().Unix(), - ExpiresAt: time.Now().Add(72 * time.Hour).Unix(), + LicenseID: "lic_recurring_grandfathered", + Tier: "pro", + PlanKey: "v5_pro_monthly_grandfathered", + State: "active", + IssuedAt: time.Now().Unix(), + ExpiresAt: time.Now().Add(72 * time.Hour).Unix(), }) svc := NewService() @@ -545,11 +455,8 @@ func TestServiceStatus_GrandfatheredRecurringV5IsUncapped(t *testing.T) { } status := svc.Status() - if status.MaxMonitoredSystems != 0 { - t.Fatalf("status.MaxMonitoredSystems=%d, want 0 for uncapped grandfathered recurring plan", status.MaxMonitoredSystems) - } - if status.MonitoredSystemContinuity != nil { - t.Fatalf("expected no monitored-system continuity banner for uncapped recurring v5 migration, got %+v", status.MonitoredSystemContinuity) + if status == nil || !status.Valid { + t.Fatalf("expected valid grandfathered status, got %+v", status) } current := svc.Current() @@ -574,7 +481,7 @@ func TestServiceActivate_RejectsMalformedLegacyKeyOutsideDevMode(t *testing.T) { } } -func TestServiceStatusCanonicalizesJWTCloudPlanVersionAndLimits(t *testing.T) { +func TestServiceStatusCanonicalizesJWTCloudPlanVersionAndScrubsRetiredMonitoringLimit(t *testing.T) { svc := NewService() svc.license = &License{ Claims: Claims{ @@ -591,9 +498,6 @@ func TestServiceStatusCanonicalizesJWTCloudPlanVersionAndLimits(t *testing.T) { if status.PlanVersion != "cloud_starter" { t.Fatalf("status.PlanVersion=%q, want %q", status.PlanVersion, "cloud_starter") } - if status.MaxMonitoredSystems != 10 { - t.Fatalf("status.MaxMonitoredSystems=%d, want %d", status.MaxMonitoredSystems, 10) - } } func TestServiceStatusMissingJWTCloudPlanFailsClosed(t *testing.T) { @@ -610,9 +514,6 @@ func TestServiceStatusMissingJWTCloudPlanFailsClosed(t *testing.T) { if status.PlanVersion != "" { t.Fatalf("status.PlanVersion=%q, want empty", status.PlanVersion) } - if status.MaxMonitoredSystems != UnknownPlanDefaultMonitoredSystemLimit { - t.Fatalf("status.MaxMonitoredSystems=%d, want %d", status.MaxMonitoredSystems, UnknownPlanDefaultMonitoredSystemLimit) - } } func TestServiceStatus_DevModeKeepsCustomerFacingStatusCommunityWithoutLicense(t *testing.T) { diff --git a/pkg/licensing/token_source.go b/pkg/licensing/token_source.go index ba5234241..7e9d8a27b 100644 --- a/pkg/licensing/token_source.go +++ b/pkg/licensing/token_source.go @@ -28,7 +28,7 @@ func (t *TokenSource) Capabilities() []string { return t.claims.EffectiveCapabilities() } -// Limits returns effective limits (explicit or derived from MaxMonitoredSystems/MaxGuests). +// Limits returns effective limits from explicit claims and derived guest policy. func (t *TokenSource) Limits() map[string]int64 { if t == nil || t.claims == nil { return nil diff --git a/pkg/server/server.go b/pkg/server/server.go index 144e13363..871332454 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -97,11 +97,6 @@ type BusinessHooks struct { // CreateAlertAnalyzer creates the premium alert-triggered analyzer. // Returns nil in OSS. Enterprise provides a concrete implementation. CreateAlertAnalyzer func(deps aicontracts.AlertAnalyzerDeps) aicontracts.AlertAnalyzer - - // ResolveMonitoredSystemAdmissionPolicy allows private builds to own the - // commercial monitored-system admission decision without importing internal - // API packages. Public runtime still owns counted-system projection. - ResolveMonitoredSystemAdmissionPolicy extensions.ResolveMonitoredSystemAdmissionPolicyFunc } var ( @@ -272,7 +267,6 @@ func Run(ctx context.Context, version string) error { createInvestigationStore := globalHooks.CreateInvestigationStore createInvestigationOrchestrator := globalHooks.CreateInvestigationOrchestrator createAlertAnalyzer := globalHooks.CreateAlertAnalyzer - resolveMonitoredSystemAdmissionPolicy := globalHooks.ResolveMonitoredSystemAdmissionPolicy globalHooksMu.Unlock() api.SetAIInvestigationEnabled(aiInvestigationEnabled) @@ -280,7 +274,6 @@ func Run(ctx context.Context, version string) error { api.SetCreateInvestigationStore(createInvestigationStore) api.SetCreateInvestigationOrchestrator(createInvestigationOrchestrator) api.SetCreateAlertAnalyzer(createAlertAnalyzer) - api.SetResolveMonitoredSystemAdmissionPolicy(resolveMonitoredSystemAdmissionPolicy) api.SetRBACAdminEndpointsBinder(bindRBACAdminEndpoints) api.SetAuditAdminEndpointsBinder(bindAuditAdminEndpoints) api.SetSSOAdminEndpointsBinder(bindSSOAdminEndpoints) diff --git a/pkg/server/server_test.go b/pkg/server/server_test.go index fa078b50a..c0b154796 100644 --- a/pkg/server/server_test.go +++ b/pkg/server/server_test.go @@ -9,30 +9,17 @@ import ( "time" "github.com/rcourtman/pulse-go-rewrite/internal/config" - "github.com/rcourtman/pulse-go-rewrite/pkg/extensions" "github.com/rcourtman/pulse-go-rewrite/pkg/metrics" ) func TestBusinessHooks(t *testing.T) { called := false - resolveCalled := false hook := func(store *metrics.Store) { called = true } SetBusinessHooks(BusinessHooks{ OnMetricsStoreReady: hook, - ResolveMonitoredSystemAdmissionPolicy: func(_ context.Context, input extensions.MonitoredSystemAdmissionInput) extensions.MonitoredSystemAdmissionDecision { - resolveCalled = true - return extensions.MonitoredSystemAdmissionDecision{ - Current: input.Current, - Additional: input.Additional, - Limit: input.Limit, - UsageAvailable: input.UsageAvailable, - UsageUnavailableReason: input.UsageUnavailableReason, - Exceeded: input.UsageAvailable && input.Additional > 0 && input.Limit > 0 && input.Current+input.Additional > input.Limit, - } - }, }) globalHooksMu.Lock() @@ -48,22 +35,6 @@ func TestBusinessHooks(t *testing.T) { t.Error("expected hook to be called") } - if globalHooks.ResolveMonitoredSystemAdmissionPolicy == nil { - t.Error("expected ResolveMonitoredSystemAdmissionPolicy to be set") - } - - decision := globalHooks.ResolveMonitoredSystemAdmissionPolicy(context.Background(), extensions.MonitoredSystemAdmissionInput{ - Current: 5, - Additional: 1, - Limit: 5, - UsageAvailable: true, - }) - if !resolveCalled { - t.Error("expected monitored-system admission policy hook to be called") - } - if !decision.Exceeded { - t.Fatalf("expected exceeded decision from hook, got %+v", decision) - } } func TestPerformAutoImport_Success(t *testing.T) {