Remove monitored-system volume caps

Retire runtime/API/UI monitored-system volume enforcement now that infrastructure monitoring is no longer capped.

Keep only legacy metadata scrubbing and purchase-start compatibility for old max_monitored_systems references.

Rename the remaining preview surface to monitored-system impact and make previews explanatory rather than save-blocking.

Update subsystem contracts and RA7 evidence for the caps-retired invariant.
This commit is contained in:
rcourtman
2026-05-05 12:59:59 +01:00
parent aa5472553f
commit 81b31e4d3b
162 changed files with 1840 additions and 10239 deletions
+29 -13
View File
@@ -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": [
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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`
@@ -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
@@ -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
@@ -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,
@@ -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 ledgers 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
@@ -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
-2
View File
@@ -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() {
<SecurityWarning />
<DemoBanner />
<UpdateBanner />
<MonitoredSystemLimitWarningBanner />
<GitHubStarBanner />
<WhatsNewModal />
<GlobalUpdateProgressWatcher />
@@ -99,6 +99,8 @@ describe('App architecture', () => {
expect(appSource).toContain('<AIChat onClose={() => 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', () => {
@@ -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',
@@ -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: [
{
@@ -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',
}),
],
});
});
});
-29
View File
@@ -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;
@@ -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),
-24
View File
@@ -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;
}
@@ -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(
@@ -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(() => <DeployStatusBadge status={status} />);
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();
@@ -83,7 +83,6 @@ function createMockWizard(overrides: Record<string, unknown> = {}) {
// Confirm
confirmSelectedNodeIds: sig('confirmSelectedNodeIds', new Set<string>()),
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(() => <ConfirmStep wizard={wizard} />);
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(() => <ConfirmStep wizard={wizard} />);
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({
@@ -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<ConfirmStepProps> = (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 (
<div class="space-y-4">
{/* Workspace capacity summary */}
<Show when={maxSlots() > 0}>
<div
role="status"
class={`rounded-md p-3 text-sm flex items-start gap-2 ${
exceedsCapacity()
? 'bg-amber-50 dark:bg-amber-900/20 text-amber-700 dark:text-amber-300'
: 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300'
}`}
>
<AlertCircleIcon class="w-4 h-4 mt-0.5 shrink-0" />
<span>
Workspace capacity: {maxSlots()} monitored systems, {selectedCount()} nodes selected.
<Show when={exceedsCapacity()}>
{' '}
This workspace can deploy {maxSlots()} nodes at its current capacity. Remove{' '}
{excessNodeCount()} {excessNodeLabel()} before continuing.
</Show>
</span>
</div>
</Show>
{/* Ready nodes */}
<Show when={w.readyNodes().length > 0}>
<div class="space-y-2">
@@ -32,7 +32,6 @@ export const DeployingStep: Component<DeployingStepProps> = (props) => {
t.status === 'failed_retryable' ||
t.status === 'failed_permanent' ||
t.status === 'skipped_already_agent' ||
t.status === 'skipped_license' ||
t.status === 'canceled',
).length,
);
@@ -94,7 +94,6 @@ function createMockWizard(
retryableTargets: (() => []) as Accessor<unknown[]>,
skippedTargets: (() => []) as Accessor<unknown[]>,
canceledTargets: (() => []) as Accessor<unknown[]>,
maxAgentSlots: (() => 0) as Accessor<number>,
startingPreflight: (() => false) as Accessor<boolean>,
startingDeploy: (() => false) as Accessor<boolean>,
retrying: (() => false) as Accessor<boolean>,
@@ -27,7 +27,6 @@ function makeTarget(overrides: Partial<MockTarget> = {}): MockTarget {
interface WizardOverrides {
confirmSelectedNodeIds?: Set<string>;
maxAgentSlots?: number;
readyNodes?: MockTarget[];
failedPreflightNodes?: MockTarget[];
toggleConfirmNode?: ReturnType<typeof vi.fn>;
@@ -35,18 +34,15 @@ interface WizardOverrides {
function createMockWizard(overrides: WizardOverrides = {}) {
const selectedIds = overrides.confirmSelectedNodeIds ?? new Set<string>();
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();
});
});
@@ -73,7 +73,6 @@ function createMockWizard(
retryableTargets: (() => []) as Accessor<unknown[]>,
skippedTargets: (() => []) as Accessor<unknown[]>,
canceledTargets: (() => []) as Accessor<unknown[]>,
maxAgentSlots: (() => 0) as Accessor<number>,
startingPreflight: (() => false) as Accessor<boolean>,
startingDeploy: (() => false) as Accessor<boolean>,
retrying: (() => false) as Accessor<boolean>,
@@ -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(() => <DeployingStep wizard={wizard} />);
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(() => <DeployingStep wizard={wizard} />);
// 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)');
});
@@ -83,7 +83,6 @@ function createMockWizard(
retryableTargets: (() => []) as Accessor<unknown[]>,
skippedTargets: (() => []) as Accessor<unknown[]>,
canceledTargets: (() => []) as Accessor<unknown[]>,
maxAgentSlots: (() => 0) as Accessor<number>,
startingPreflight: (() => false) as Accessor<boolean>,
startingDeploy: (() => false) as Accessor<boolean>,
retrying: (() => false) as Accessor<boolean>,
@@ -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(() => <PreflightStep wizard={wizard} />);
expect(screen.getByText('Workspace capacity')).toBeInTheDocument();
});
});
/* ── Error details ───────────────────────────────────────────── */
@@ -105,7 +105,6 @@ function createMockWizard(
deployError: (() => '') as Accessor<string>,
deployStream: {} as unknown,
retryableTargets: (() => []) as Accessor<unknown[]>,
maxAgentSlots: (() => 0) as Accessor<number>,
startingPreflight: (() => false) as Accessor<boolean>,
startingDeploy: (() => false) as Accessor<boolean>,
retrying: (() => false) as Accessor<boolean>,
@@ -235,12 +234,11 @@ describe('ResultsStep', () => {
const wizard = createMockWizard({
skippedTargets: [
makeTarget({ id: 't1', status: 'skipped_already_agent' }),
makeTarget({ id: 't2', status: 'skipped_license' }),
],
});
render(() => <ResultsStep wizard={wizard} />);
expect(screen.getByText('Skipped (2)')).toBeInTheDocument();
expect(screen.getByText('Skipped (1)')).toBeInTheDocument();
});
it('renders node names and IPs in the skipped table', () => {
@@ -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<TrueNASCredentialSlotProps> = (pro
</div>
</div>
<MonitoredSystemAdmissionPreview
<MonitoredSystemImpactPreview
preview={props.state.monitoredSystemPreview()}
loading={props.state.previewing()}
error={props.state.monitoredSystemPreviewError()}
@@ -400,8 +400,7 @@ export const TrueNASCredentialSlot: Component<TrueNASCredentialSlotProps> = (pro
props.state.testing() ||
props.state.previewing() ||
props.togglePending ||
props.deletePending ||
props.state.monitoredSystemAdmissionSaveBlocked()
props.deletePending
}
>
{props.state.saving()
@@ -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<VMwareCredentialSlotProps> = (props
</div>
</div>
<MonitoredSystemAdmissionPreview
<MonitoredSystemImpactPreview
preview={props.state.monitoredSystemPreview()}
loading={props.state.previewing()}
error={props.state.monitoredSystemPreviewError()}
@@ -349,8 +349,7 @@ export const VMwareCredentialSlot: Component<VMwareCredentialSlotProps> = (props
props.state.testing() ||
props.state.previewing() ||
props.togglePending ||
props.deletePending ||
props.state.monitoredSystemAdmissionSaveBlocked()
props.deletePending
}
>
{props.state.saving()
@@ -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<MonitoredSystemAdmissionPreviewProps> = (
export const MonitoredSystemImpactPreview: Component<MonitoredSystemImpactPreviewProps> = (
props,
) => {
const requiredState = getMonitoredSystemAdmissionPreviewRequiredState();
return (
<>
<Show when={!props.loading && !props.error && !props.preview}>
<CalloutCard
tone="info"
title={requiredState.title}
description={<p>{requiredState.message}</p>}
/>
</Show>
<Show when={props.loading}>
<div class="rounded-md border border-border bg-surface-alt px-4 py-3 text-sm text-muted">
Calculating monitored-system impact
@@ -52,11 +38,11 @@ export const MonitoredSystemAdmissionPreview: Component<MonitoredSystemAdmission
<Show when={!props.loading && props.preview}>
{(preview) => (
<CalloutCard
tone={previewTone(preview())}
title={getMonitoredSystemAdmissionPreviewTitle(preview())}
tone="info"
title={getMonitoredSystemImpactPreviewTitle(preview())}
description={
<div class="space-y-3 text-sm">
<p>{formatMonitoredSystemAdmissionPreviewSummary(preview())}</p>
<p>{formatMonitoredSystemImpactPreviewSummary(preview())}</p>
<Show when={preview().current_systems.length > 0}>
<div class="space-y-1">
<p class="text-xs font-medium uppercase tracking-wide text-muted">
@@ -90,4 +76,4 @@ export const MonitoredSystemAdmissionPreview: Component<MonitoredSystemAdmission
);
};
export default MonitoredSystemAdmissionPreview;
export default MonitoredSystemImpactPreview;
@@ -15,21 +15,13 @@ import type {
MonitoredSystemLedgerEntry,
MonitoredSystemLedgerExplanationSurface,
} from '@/api/monitoredSystemLedger';
import type {
EntitlementLimitStatus,
MonitoredSystemCapacityStatus,
MonitoredSystemContinuityStatus,
} from '@/api/license';
import { apiErrorCode, apiErrorDetailField } from '@/api/responseUtils';
import { getSimpleStatusIndicator } from '@/utils/status';
import {
formatMonitoredSystemGroupedSourcesLabel,
formatMonitoredSystemLedgerUnavailableMessage,
getMonitoredSystemLimitContextSummary,
formatMonitoredSystemLatestIncludedSignalSentence,
formatMonitoredSystemSurfaceAttribution,
getMonitoredSystemLimitUsageSummary,
getMonitoredSystemLimitUnavailableReason,
getMonitoredSystemLedgerErrorState,
getMonitoredSystemLedgerDescription,
getMonitoredSystemLedgerHiddenState,
@@ -38,8 +30,6 @@ import {
getMonitoredSystemLedgerUnavailableState,
getMonitoredSystemCountingDetailsToggleLabel,
getMonitoredSystemLedgerPresentation,
isMonitoredSystemLimitUsageAvailable,
resolveMonitoredSystemCapacityStatus,
} from '@/utils/monitoredSystemPresentation';
import { MonitoredSystemDefinitionDisclosure } from '@/components/Commercial/MonitoredSystemDefinitionDisclosure';
import {
@@ -49,17 +39,9 @@ import {
interface MonitoredSystemLedgerPanelProps {
embedded?: boolean;
monitoredSystemCapacity?: MonitoredSystemCapacityStatus | null;
monitoredSystemContinuity?: MonitoredSystemContinuityStatus | null;
monitoredSystemLimit?: EntitlementLimitStatus | null;
showCountingRulesByDefault?: boolean;
}
function usagePercent(total: number, limit: number): number {
if (limit <= 0) return 0;
return Math.min(100, Math.round((total / limit) * 100));
}
function latestIncludedSignalSummary(system: MonitoredSystemLedgerEntry): {
relative: string;
attribution: string;
@@ -89,24 +71,6 @@ function includedSurfaces(
];
}
function formatLimitValue(value: number | undefined): string {
if (typeof value !== 'number' || value <= 0) {
return getMonitoredSystemLedgerPresentation().unlimitedLimitLabel;
}
return String(value);
}
function formatCapturedAt(value: number | undefined): string | null {
if (typeof value !== 'number' || value <= 0) {
return null;
}
const date = new Date(value * 1000);
if (Number.isNaN(date.getTime())) {
return null;
}
return date.toLocaleDateString();
}
export function MonitoredSystemLedgerPanel(props: MonitoredSystemLedgerPanelProps = {}) {
const ledgerRequestKey = () =>
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<EntitlementLimitStatus | null>(() => {
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
</div>
<Show when={ledger()}>
<div class="text-right">
<p
class="text-sm font-medium"
classList={{
'text-base-content':
displayCapacity()?.urgency !== 'warning' &&
displayCapacity()?.mode !== 'over_limit_frozen',
'text-amber-700 dark:text-amber-300':
displayCapacity()?.urgency === 'warning' ||
displayCapacity()?.mode === 'at_limit_blocking_new',
'text-red-600 dark:text-red-400': displayCapacity()?.mode === 'over_limit_frozen',
}}
>
{capacitySummary()}
</p>
<Show when={capacityContext()}>
<p class="max-w-xs text-xs text-muted">{capacityContext()}</p>
</Show>
<p class="text-sm font-medium text-base-content">{totalSummary()}</p>
</div>
</Show>
<Show when={!ledger() && usageUnavailable()}>
<span class="text-sm font-medium text-amber-700 dark:text-amber-300">
{presentation.usageVerifyingLabel}
Verifying
</span>
</Show>
</div>
</div>
<Show when={continuityForDisplay()}>
{(current) => (
<div
class="rounded-lg border px-3 py-3 text-xs"
classList={{
'border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-100':
current().capture_pending,
'border-green-200 bg-green-50 text-green-900 dark:border-green-900 dark:bg-green-950/40 dark:text-green-100':
!current().capture_pending,
}}
>
<p class="font-semibold">{presentation.continuityHeading}</p>
<dl class="mt-2 grid gap-2 sm:grid-cols-4">
<div>
<dt class="text-[11px] opacity-75">{presentation.continuityPlanLimitLabel}</dt>
<dd class="font-medium">{formatLimitValue(current().plan_limit)}</dd>
</div>
<div>
<dt class="text-[11px] opacity-75">{presentation.continuityEffectiveLimitLabel}</dt>
<dd class="font-medium">{formatLimitValue(current().effective_limit)}</dd>
</div>
<Show when={current().grandfathered_floor}>
{(floor) => (
<div>
<dt class="text-[11px] opacity-75">
{presentation.continuityGrandfatheredFloorLabel}
</dt>
<dd class="font-medium">{floor()}</dd>
</div>
)}
</Show>
<div>
<dt class="text-[11px] opacity-75">{presentation.continuityCaptureLabel}</dt>
<dd class="font-medium">
{current().capture_pending
? presentation.continuityCapturePendingLabel
: (formatCapturedAt(current().captured_at) ??
presentation.continuityCaptureCapturedLabel)}
</dd>
</div>
</dl>
</div>
)}
</Show>
{/* Loading state */}
<Show when={explanation.loading && !usageUnavailable()}>
<p class="text-sm text-muted py-4 text-center">
@@ -337,19 +192,6 @@ export function MonitoredSystemLedgerPanel(props: MonitoredSystemLedgerPanelProp
{/* Loaded content */}
<Show when={!explanation.loading && !genericError() && ledger()}>
<Show when={hasLimit()}>
<div class="h-2 w-full rounded-full bg-surface-alt overflow-hidden">
<div
class="h-full rounded-full transition-all duration-300"
classList={{
'bg-blue-500': !overLimit(),
'bg-red-500': overLimit(),
}}
style={{ width: `${pct()}%` }}
/>
</div>
</Show>
<Show
when={systems().length > 0}
fallback={<p class="text-sm text-muted py-4 text-center">{presentation.emptyState}</p>}
@@ -31,7 +31,7 @@ export const OrganizationBillingPanel: Component<OrganizationBillingPanelProps>
<div class="space-y-6">
<CommercialBillingShell
title="Billing & Usage"
description="Review your organization plan, usage against limits, and available upgrade paths."
description="Review your organization plan, current usage, and available upgrade paths."
loading={loading()}
loadingFallback={<OrganizationBillingLoadingState />}
>
@@ -55,9 +55,9 @@ export const OrganizationBillingPanel: Component<OrganizationBillingPanelProps>
<CommercialSection
title="Usage"
description="Compare current agent and guest usage against the active organization allocation."
description="Review current organization usage for billing and support context."
>
<CommercialUsageMeters title="Usage vs Plan Limits" items={commercialUsageModel().meters} />
<CommercialUsageMeters title="Usage" items={commercialUsageModel().meters} />
</CommercialSection>
</CommercialBillingShell>
</div>
@@ -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 = () => {
</div>
</CommercialSection>
</Show>
<Show when={state.showUsageSection() && state.activeSection() === 'usage'}>
<CommercialSection
id={SELF_HOSTED_PRO_BILLING_USAGE_SECTION_ID}
title={SELF_HOSTED_PRO_BILLING_PRESENTATION.usageSectionTitle}
description={getMonitoredSystemBriefSummary()}
>
<MonitoredSystemLedgerPanel
embedded
monitoredSystemCapacity={state.monitoredSystemCapacity()}
monitoredSystemContinuity={state.displayableMonitoredSystemContinuity()}
monitoredSystemLimit={state.monitoredSystemLimitStatus() ?? null}
showCountingRulesByDefault={state.showCountingRulesByDefault()}
/>
</CommercialSection>
</Show>
</div>
</CommercialBillingShell>
</div>
@@ -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<ProLicensePlanSectionProps> = (pro
</div>
)}
</Show>
<Show when={props.monitoredSystemContinuityNotice}>
{(notice) => (
<div class={`mb-4 rounded-md border p-3 text-sm ${notice().tone}`}>
<p class="font-medium">{notice().title}</p>
<p class="mt-1 text-xs opacity-90">{notice().body}</p>
</div>
)}
</Show>
<Show when={props.monitoredSystemCapacitySection}>
{(section) => (
<div class="mb-4 rounded-md border border-border bg-surface-alt p-4">
<div class="space-y-2">
<p class="text-sm font-medium text-base-content">Monitored-system policy</p>
<p class="text-xs text-base-content">{section().statusMessage}</p>
<Show when={section().detailMessage}>
{(detail) => <p class="text-xs text-muted">{detail()}</p>}
</Show>
</div>
<div class="mt-4">
<CommercialStatGrid items={section().stats} />
</div>
<Show when={section().explanation}>
{(explanation) => (
<details class="mt-4 rounded-md border border-border bg-surface px-3 py-2">
<summary class="cursor-pointer text-xs font-medium text-base-content">
{explanation().label}
</summary>
<p class="mt-2 text-xs text-muted">{explanation().body}</p>
</details>
)}
</Show>
<div class="mt-4 flex flex-wrap items-center gap-3">
<UpgradeLink
class="inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline"
destination={section().reviewUsageDestination}
>
Review monitored systems
</UpgradeLink>
</div>
</div>
)}
</Show>
<Show when={licenseEntitlementsLoadError()}>
<div class="rounded-md border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900 p-3 text-sm text-amber-800 dark:text-amber-200">
<p class="font-medium">Could not load license status</p>
@@ -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<SettingsProps> = (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();
@@ -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(() => <MonitoredSystemAdmissionPreview preview={buildPreview()} />);
render(() => <MonitoredSystemImpactPreview preview={buildPreview()} />);
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(() => (
<MonitoredSystemAdmissionPreview
<MonitoredSystemImpactPreview
preview={buildPreview({
current_count: 1,
projected_count: 0,
@@ -57,25 +55,22 @@ describe('MonitoredSystemAdmissionPreview', () => {
).toBeInTheDocument();
});
it('describes continuity review failures without slash quota copy', () => {
it('describes added systems without capacity review copy', () => {
render(() => (
<MonitoredSystemAdmissionPreview
<MonitoredSystemImpactPreview
preview={buildPreview({
current_count: 9,
projected_count: 11,
additional_count: 2,
would_exceed_limit: true,
effect: 'creates_multiple',
})}
/>
));
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();
});
@@ -84,16 +84,7 @@ describe('MonitoredSystemLedgerPanel', () => {
it('hides monitored-system usage in demo mode without requesting the ledger', () => {
presentationPolicyHidesCommercialSurfacesMock.mockReturnValue(true);
render(() => (
<MonitoredSystemLedgerPanel
monitoredSystemLimit={{
key: 'max_monitored_systems',
limit: 5,
current: 16,
state: 'enforced',
}}
/>
));
render(() => <MonitoredSystemLedgerPanel />);
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(() => (
<MonitoredSystemLedgerPanel
monitoredSystemLimit={{
key: 'max_monitored_systems',
limit: 5,
current: 16,
current_available: false,
current_unavailable_reason: 'supplemental_inventory_unsettled',
state: 'enforced',
}}
/>
));
render(() => <MonitoredSystemLedgerPanel />);
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(() => (
<MonitoredSystemLedgerPanel
monitoredSystemContinuity={{
plan_limit: 5,
grandfathered_floor: 12,
effective_limit: 12,
capture_pending: true,
}}
/>
));
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,
}),
);
@@ -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(() => <OrganizationBillingPanel nodeUsage={5} guestUsage={2} />);
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();
});
@@ -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');
});
});
@@ -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.",
);
@@ -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',
);
});
});
@@ -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',
);
});
});
@@ -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<SelfHostedBillingSection>(() => {
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,
};
@@ -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<MonitoredSystemLedgerPreviewResponse | null>(null);
const [monitoredSystemPreviewUnavailableState, setMonitoredSystemPreviewUnavailableState] =
createSignal<MonitoredSystemAdmissionPreviewUnavailableState | null>(null);
createSignal<MonitoredSystemImpactPreviewUnavailableState | null>(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,
@@ -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<MonitoredSystemLedgerPreviewResponse | null>(null);
const [monitoredSystemPreviewUnavailableState, setMonitoredSystemPreviewUnavailableState] =
createSignal<MonitoredSystemAdmissionPreviewUnavailableState | null>(null);
createSignal<MonitoredSystemImpactPreviewUnavailableState | null>(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,
@@ -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 (
<Show when={state.showBanner()}>
<div
class={`mb-2 rounded-md border px-3 py-2 text-sm ${state.toneClass()}`}
role="status"
aria-live="polite"
>
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
<span class={state.isUrgent() ? 'font-medium' : ''}>{state.monitoredSystemSummary()}</span>
<UpgradeLink
class="text-xs font-medium underline underline-offset-2 hover:opacity-90"
destination={state.reviewPolicyDestination()}
>
{MONITORED_SYSTEM_LIMIT_REVIEW_POLICY_LABEL}
</UpgradeLink>
<Show when={state.migrationGap()}>
<UpgradeLink
class="text-xs font-medium underline underline-offset-2 hover:opacity-90"
destination={state.installCollectorsDestination()}
>
{MONITORED_SYSTEM_LIMIT_INSTALL_COLLECTORS_LABEL}
</UpgradeLink>
</Show>
</div>
</div>
</Show>
);
};
@@ -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'");
@@ -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(() => (
<Router>
<Route path="/" component={mod.MonitoredSystemLimitWarningBanner} />
</Router>
));
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(() => (
<Router>
<Route path="/" component={mod.MonitoredSystemLimitWarningBanner} />
</Router>
));
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(() => (
<Router>
<Route path="/" component={mod.MonitoredSystemLimitWarningBanner} />
</Router>
));
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(() => (
<Router>
<Route path="/" component={mod.MonitoredSystemLimitWarningBanner} />
</Router>
));
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(() => (
<Router>
<Route path="/" component={mod.MonitoredSystemLimitWarningBanner} />
</Router>
));
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(() => (
<Router>
<Route path="/" component={mod.MonitoredSystemLimitWarningBanner} />
</Router>
));
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(() => (
<Router>
<Route path="/" component={mod.MonitoredSystemLimitWarningBanner} />
</Router>
));
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(() => (
<Router>
<Route path="/" component={mod.MonitoredSystemLimitWarningBanner} />
</Router>
));
expect(
screen.queryByText('5 monitored systems.'),
).not.toBeInTheDocument();
});
});
@@ -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';
}
@@ -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,
};
}
@@ -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();
});
});
});
+1 -12
View File
@@ -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,
@@ -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',
},
],
+1 -9
View File
@@ -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;
-2
View File
@@ -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;
}
@@ -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',
});
});
});
@@ -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];
@@ -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');
});
});
@@ -15,7 +15,6 @@ describe('deployStatusPresentation', () => {
['failed_retryable', 'Failed'],
['failed_permanent', 'Failed'],
['skipped_already_agent', 'Already monitored'],
['skipped_license', 'Workspace capacity'],
['canceled', 'Canceled'],
];
@@ -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(
@@ -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',
@@ -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.');
});
});
@@ -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'),
);
});
-11
View File
@@ -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;
}
@@ -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',
},
{
@@ -43,10 +43,6 @@ const DEPLOY_STATUS_PRESENTATION: Record<DeployTargetStatus, DeployStatusPresent
label: 'Already monitored',
className: 'bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300',
},
skipped_license: {
label: 'Workspace capacity',
className: 'bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300',
},
canceled: { label: 'Canceled', className: 'bg-surface-alt text-muted' },
};
@@ -215,7 +215,7 @@ export const INFRASTRUCTURE_ONBOARDING_PATHS: Record<
title: 'Connect a supported platform',
description:
'Use a management API when the platform exposes one. Pulse validates the endpoint, requests credentials, and then starts collecting platform inventory and health.',
bestFor: 'TrueNAS, Proxmox, and the current VMware vCenter admission path',
bestFor: 'TrueNAS, Proxmox, and the current VMware vCenter integration path',
coverage: 'Platform inventory, workloads, storage, backups, and health',
},
agent: {
@@ -3,17 +3,8 @@ import type {
CommercialMigrationStatus,
LicenseCommercialEntitlements,
LicenseStatus,
MonitoredSystemContinuityStatus,
} from '@/api/license';
import { CLOUD_PLAN_LABELS } from '@/utils/cloudPlans';
import {
formatMonitoredSystemUsageUnavailableMessage,
getMonitoredSystemLimitUnavailableReason,
isMonitoredSystemLimitUsageAvailable,
resolveMonitoredSystemCapacityStatus,
type MonitoredSystemCapacityStatus,
type MonitoredSystemLimitUsageStatus,
} from '@/utils/monitoredSystemPresentation';
import {
getSelfHostedPlanDefinitionForBillingTier,
getSelfHostedPlanEntitlementSummary,
@@ -152,62 +143,11 @@ export const isGrandfatheredRecurringV5PlanVersion = (planVersion?: string | nul
);
};
export const isUncappedGrandfatheredPlanVersion = (
planVersion?: string | null,
isLifetime?: boolean | null,
): boolean => {
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`,
@@ -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;
@@ -51,7 +51,6 @@ export const SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_HREF = `${SELF_HOSTED_PRO_BI
const IN_PRODUCT_PRICING_DESTINATIONS: Record<string, string> = {
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.
-10
View File
@@ -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)
+2 -2
View File
@@ -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)
}
}
+7 -8
View File
@@ -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
+6 -6
View File
@@ -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())
}
}
@@ -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())
}
}
-125
View File
@@ -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)
-19
View File
@@ -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)
}
}
+91 -354
View File
@@ -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 {
-1
View File
@@ -124,7 +124,6 @@ func sanitizeRuntimeCapabilitiesPayloadForPublicDemo(
) RuntimeCapabilitiesPayload {
sanitized := payload
sanitized.Limits = sanitizeLimitStatusesForPublicDemo(payload.Limits)
sanitized.MonitoredSystemCapacity = nil
if sanitized.Capabilities == nil {
sanitized.Capabilities = []string{}
}
+1 -17
View File
@@ -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) {
+12 -83
View File
@@ -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:
+13 -29
View File
@@ -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)
-10
View File
@@ -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)
@@ -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())
}
}
@@ -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
}
@@ -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)
}
}
+14 -65
View File
@@ -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) {
-10
View File
@@ -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)
@@ -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())
}
}
@@ -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)
}
}
+29 -36
View File
@@ -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)
@@ -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() {
+16 -49
View File
@@ -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)
}
+9 -28
View File
@@ -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)
@@ -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 {
+3 -15
View File
@@ -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,
+4 -16
View File
@@ -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)
}
}
@@ -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)
}

Some files were not shown because too many files have changed in this diff Show More