mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
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:
@@ -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 ledger’s nested status explanation, but they
|
||||
@@ -2102,8 +2086,8 @@ as agent or sibling API ownership, rather than briefly freeing a slot or
|
||||
double-counting the same monitored system.
|
||||
When storage- or recovery-adjacent settings or support flows need to explain
|
||||
that result, they must rely on the shared monitored-system ledger preview
|
||||
contract for current/projected grouped systems and enforced limit verdicts
|
||||
instead of reconstructing preview copy from page-local recovery inventory or
|
||||
contract for current/projected grouped systems instead of reconstructing
|
||||
preview copy, limit verdicts, or cap copy from page-local recovery inventory or
|
||||
provider-local connection details.
|
||||
That same adjacent preview contract also treats disabled provider connections
|
||||
as non-counting candidates. Storage- or recovery-adjacent flows may use the
|
||||
@@ -2115,8 +2099,9 @@ readiness. Storage- or recovery-adjacent transport flows may not interpret the
|
||||
first store-backed monitor view as commercial truth when provider-owned
|
||||
supplemental platforms such as TrueNAS or VMware are still between initial
|
||||
connection wiring and the first rebuilt canonical store; until that baseline
|
||||
settles, adjacent surfaces must preserve `current_available=false` and avoid
|
||||
sealing any migration or admission decision against a transient undercount.
|
||||
settles, adjacent surfaces must use the canonical ledger/preview unavailable
|
||||
state and avoid sealing any migration or support decision against a transient
|
||||
undercount.
|
||||
That same shared `internal/api/` dependency also assumes session-carried OIDC
|
||||
refresh tokens stay fail-closed at rest: `session_store.go` may only persist
|
||||
or recover those tokens through encrypted-at-rest session payloads, and any
|
||||
@@ -2702,9 +2687,9 @@ platform-connections surface, but storage and recovery must treat that data as
|
||||
setup/runtime context only, not as proof that VMware has joined the canonical
|
||||
recovery artifact or restore plane.
|
||||
The same rule applies to `/api/truenas/connections*/preview` and
|
||||
`/api/vmware/connections*/preview`: monitored-system admission previews may
|
||||
surface current/projected grouped systems and enforced limit verdicts for setup
|
||||
and support clarity, but they do not imply recovery-local onboarding, recovery
|
||||
`/api/vmware/connections*/preview`: monitored-system previews may surface
|
||||
current/projected grouped systems for setup and support clarity, but they do
|
||||
not imply commercial limit verdicts, recovery-local onboarding, recovery
|
||||
artifact ownership, or restore support.
|
||||
That same bounded phase-1 slice now also includes the shared unified-resource
|
||||
adapter floor. `frontend-modern/src/hooks/useUnifiedResources.ts`,
|
||||
@@ -2774,8 +2759,8 @@ That same adjacent platform-connections boundary now also assumes direct
|
||||
TrueNAS and VMware connection writes fail closed while canonical
|
||||
monitored-system usage is unavailable. Storage and recovery may depend on the
|
||||
resulting provider setup state only after `internal/api/truenas_handlers.go`,
|
||||
`internal/api/vmware_handlers.go`, and the shared monitored-system admission
|
||||
helpers have returned a safe capacity verdict; VMware write admission must not
|
||||
`internal/api/vmware_handlers.go`, and the shared monitored-system grouping
|
||||
helpers have returned a safe preview verdict; VMware write handling must not
|
||||
collect external vCenter inventory before that canonical usage state is safe.
|
||||
Storage and recovery browser helpers now also keep one transport-tolerant
|
||||
normalization edge. Recovery display models must accept legacy subject-label
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>,
|
||||
|
||||
+1
-93
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+7
-20
@@ -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', () => {
|
||||
|
||||
+3
-4
@@ -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()
|
||||
|
||||
+3
-4
@@ -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()
|
||||
|
||||
+8
-22
@@ -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();
|
||||
|
||||
|
||||
+8
-13
@@ -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();
|
||||
});
|
||||
+4
-64
@@ -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.",
|
||||
);
|
||||
|
||||
+34
-54
@@ -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
-67
@@ -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'");
|
||||
|
||||
-334
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 +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;
|
||||
|
||||
|
||||
@@ -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'),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -124,7 +124,6 @@ func sanitizeRuntimeCapabilitiesPayloadForPublicDemo(
|
||||
) RuntimeCapabilitiesPayload {
|
||||
sanitized := payload
|
||||
sanitized.Limits = sanitizeLimitStatusesForPublicDemo(payload.Limits)
|
||||
sanitized.MonitoredSystemCapacity = nil
|
||||
if sanitized.Capabilities == nil {
|
||||
sanitized.Capabilities = []string{}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user