diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index d171fd622..50ecd7aca 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -16,7 +16,7 @@ third-party analytics, support diagnostics, or ordinary Settings surfaces. Pulse includes anonymous outbound telemetry that is **enabled by default**. It sends a lightweight ping on startup and once every 24 hours to help me understand how many active installations exist, which releases are actually deployed, and which features are in use. -No hostnames, credentials, IP addresses, or personally identifiable information is ever sent. See the full field list below. +No hostnames, credentials, infrastructure identifiers, IP addresses, prompts, chat messages, or personally identifiable information is ever sent. See the full field list below. #### How to disable @@ -51,9 +51,29 @@ Every field is listed below with the reason it exists — nothing else leaves yo | PMG instances | `0` | Understand Proxmox Mail Gateway adoption in aggregate | | VMs | `25` | Understand approximate infrastructure scale in aggregate | | Containers | `12` | Understand approximate LXC usage in aggregate | +| Agent hosts | `4` | Understand Pulse Agent adoption for node-local telemetry in aggregate | | Docker hosts | `2` | Understand Docker monitoring adoption in aggregate | +| Docker containers | `18` | Understand Docker / Podman workload scale in aggregate | | Kubernetes clusters | `0` | Understand Kubernetes monitoring adoption in aggregate | +| Kubernetes nodes | `3` | Understand Kubernetes node scale in aggregate | +| Kubernetes pods | `42` | Understand Kubernetes workload scale in aggregate | +| Kubernetes deployments | `8` | Understand Kubernetes deployment adoption in aggregate | +| Storage pools | `6` | Understand storage monitoring adoption in aggregate | +| Physical disks | `24` | Understand disk-health monitoring adoption in aggregate | +| Ceph clusters | `1` | Understand Ceph monitoring adoption in aggregate | +| Network shares | `5` | Understand NAS/share monitoring adoption in aggregate | +| TrueNAS systems | `1` | Understand TrueNAS integration adoption in aggregate | +| TrueNAS VMs | `2` | Understand TrueNAS VM visibility adoption in aggregate | +| TrueNAS apps | `7` | Understand TrueNAS app visibility adoption in aggregate | +| VMware hosts | `3` | Understand VMware vSphere host monitoring adoption in aggregate | +| VMware VMs | `35` | Understand VMware vSphere VM monitoring adoption in aggregate | +| VMware datastores | `4` | Understand VMware datastore visibility adoption in aggregate | +| Availability targets | `9` | Understand agentless availability-check adoption in aggregate | | AI enabled | `true`/`false` | See whether AI features are actually used before expanding or removing them | +| Patrol enabled | `true`/`false` | See whether proactive AI health patrol is used | +| Discovery enabled | `true`/`false` | See whether network or AI-assisted discovery is used | +| Notifications enabled | `true`/`false` | See whether alert notification delivery is configured | +| AI actions enabled | `true`/`false` | See whether AI control tools are enabled without sending action history or command content | | Active alerts | `4` | Understand how noisy or quiet installations are in aggregate | | Relay enabled | `true`/`false` | See whether remote-access features are being used | | SSO enabled | `true`/`false` | See whether single-sign-on support is being used | diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index d43874064..4ff6b0a34 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -2024,6 +2024,12 @@ 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 preview contract now includes the complete anonymous telemetry +payload shape, including aggregate self-hosted adoption counters for monitored +platforms, workloads, storage, and availability targets plus coarse feature +booleans. Browser callers may display or copy the exact payload, but they must +not derive hostnames, infrastructure identifiers, prompt/chat content, license +tiers, or API-token counts from it. 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 diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 66721e0c1..fdd0a4399 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -235,6 +235,11 @@ columns. 2. `frontend-modern/src/components/Settings/DataHandlingPanel.tsx` shared with `security-privacy`: the data-handling settings surface is both a security/privacy trust surface and a canonical settings-shell presentation boundary. 3. `frontend-modern/src/components/Settings/dataHandlingPanelModel.ts` shared with `security-privacy`: the data-handling settings model is both a security/privacy posture projection and a canonical settings-shell presentation boundary. 4. `frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx` shared with `security-privacy`: the general settings privacy panel is both a security/privacy control surface and a canonical settings-shell presentation boundary. + The panel owns compact settings-shell framing for anonymous telemetry, but + its vocabulary must stay aligned with `security-privacy`: aggregate + self-hosted adoption counts and coarse feature flags may be named, while + hostnames, credentials, infrastructure identifiers, prompts, chat messages, + and personal information must stay explicitly excluded. 5. `frontend-modern/src/components/Settings/SecurityAuthPanel.tsx` shared with `security-privacy`: the authentication settings surface is both a security/privacy control surface and a canonical settings-shell presentation boundary. 6. `frontend-modern/src/components/Settings/SecurityOverviewPanel.tsx` shared with `security-privacy`: the security overview settings surface is both a security/privacy control surface and a canonical settings-shell presentation boundary. 7. `frontend-modern/src/routing/routePreload.ts` shared with `performance-and-scalability`: the app-shell route preload registry is both a canonical frontend shell boundary and an authenticated hot-path performance boundary. @@ -3279,6 +3284,12 @@ That shared shell boundary now also covers version-matched docs-link framing: customer-facing privacy disclosures in shared settings surfaces must route through `frontend-modern/src/utils/docsLinks.ts` rather than panel-local external URLs. +That same shared-shell framing also covers the concise telemetry summary in +General settings. The shell may present the privacy contract in compact product +copy, but the vocabulary for anonymous outbound telemetry must stay aligned +with `security-privacy`: aggregate self-hosted adoption counts and coarse +feature flags are allowed, while hostnames, credentials, infrastructure +identifiers, prompts, chat messages, and personal information are not. That same docs-link boundary also governs local legal docs surfaced from the settings shell: shared settings surfaces such as `AIRuntimeControlsSection.tsx` must route Terms-of-Service links through the diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index ab2fe3af6..4f29e0ca5 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -778,6 +778,13 @@ Install-wide telemetry counts are also monitoring-owned now. Any telemetry or reporting surface that claims installation totals must aggregate across the provisioned tenant set through the reloadable multi-tenant monitor boundary, not by reading `GetMonitor()`'s default-org compatibility shim. +Those install-wide counts are now the canonical aggregate adoption signal for +anonymous telemetry: monitoring owns the source counts for agent hosts, Docker +and Kubernetes workloads, storage pools and physical disks, Ceph, network +shares, TrueNAS systems/VMs/apps, VMware hosts/VMs/datastores, availability +targets, and active alerts. Telemetry callers may consume those coarse totals, +but they must not bypass monitoring to read provider-local identifiers or +tenant-local resource names. Consumer packages already use `ReadState`, but the monitoring core still has dual truth between unified resources and `StateSnapshot`. This is the main diff --git a/docs/release-control/v6/internal/subsystems/security-privacy.md b/docs/release-control/v6/internal/subsystems/security-privacy.md index e43cb5a52..e2b3e7c09 100644 --- a/docs/release-control/v6/internal/subsystems/security-privacy.md +++ b/docs/release-control/v6/internal/subsystems/security-privacy.md @@ -362,6 +362,13 @@ server-side telemetry rows may preserve the canonical normalized version identity plus those same coarse booleans, but they must not retain legacy exact commercial tier or exact API-token count fields as first-class analytics dimensions just because older clients once sent them. +That same anonymous telemetry floor now also permits only privacy-safe +aggregate self-hosted adoption counters: counts of monitored platforms, +workloads, storage resources, physical disks, Ceph clusters, network shares, +TrueNAS and VMware resource categories, availability targets, and active +alerts. Those counts may describe scale and feature adoption, but they must not +include hostnames, resource IDs, infrastructure identifiers, credentials, +prompts, chat messages, or personal information. That same anonymous telemetry contract also treats `install_id` as a rotating pseudonymous identifier, not a lifetime install handle. The runtime may keep a local rotating UUID so startup and heartbeat pings can still represent an diff --git a/frontend-modern/public/docs/PRIVACY.md b/frontend-modern/public/docs/PRIVACY.md index d171fd622..50ecd7aca 100644 --- a/frontend-modern/public/docs/PRIVACY.md +++ b/frontend-modern/public/docs/PRIVACY.md @@ -16,7 +16,7 @@ third-party analytics, support diagnostics, or ordinary Settings surfaces. Pulse includes anonymous outbound telemetry that is **enabled by default**. It sends a lightweight ping on startup and once every 24 hours to help me understand how many active installations exist, which releases are actually deployed, and which features are in use. -No hostnames, credentials, IP addresses, or personally identifiable information is ever sent. See the full field list below. +No hostnames, credentials, infrastructure identifiers, IP addresses, prompts, chat messages, or personally identifiable information is ever sent. See the full field list below. #### How to disable @@ -51,9 +51,29 @@ Every field is listed below with the reason it exists — nothing else leaves yo | PMG instances | `0` | Understand Proxmox Mail Gateway adoption in aggregate | | VMs | `25` | Understand approximate infrastructure scale in aggregate | | Containers | `12` | Understand approximate LXC usage in aggregate | +| Agent hosts | `4` | Understand Pulse Agent adoption for node-local telemetry in aggregate | | Docker hosts | `2` | Understand Docker monitoring adoption in aggregate | +| Docker containers | `18` | Understand Docker / Podman workload scale in aggregate | | Kubernetes clusters | `0` | Understand Kubernetes monitoring adoption in aggregate | +| Kubernetes nodes | `3` | Understand Kubernetes node scale in aggregate | +| Kubernetes pods | `42` | Understand Kubernetes workload scale in aggregate | +| Kubernetes deployments | `8` | Understand Kubernetes deployment adoption in aggregate | +| Storage pools | `6` | Understand storage monitoring adoption in aggregate | +| Physical disks | `24` | Understand disk-health monitoring adoption in aggregate | +| Ceph clusters | `1` | Understand Ceph monitoring adoption in aggregate | +| Network shares | `5` | Understand NAS/share monitoring adoption in aggregate | +| TrueNAS systems | `1` | Understand TrueNAS integration adoption in aggregate | +| TrueNAS VMs | `2` | Understand TrueNAS VM visibility adoption in aggregate | +| TrueNAS apps | `7` | Understand TrueNAS app visibility adoption in aggregate | +| VMware hosts | `3` | Understand VMware vSphere host monitoring adoption in aggregate | +| VMware VMs | `35` | Understand VMware vSphere VM monitoring adoption in aggregate | +| VMware datastores | `4` | Understand VMware datastore visibility adoption in aggregate | +| Availability targets | `9` | Understand agentless availability-check adoption in aggregate | | AI enabled | `true`/`false` | See whether AI features are actually used before expanding or removing them | +| Patrol enabled | `true`/`false` | See whether proactive AI health patrol is used | +| Discovery enabled | `true`/`false` | See whether network or AI-assisted discovery is used | +| Notifications enabled | `true`/`false` | See whether alert notification delivery is configured | +| AI actions enabled | `true`/`false` | See whether AI control tools are enabled without sending action history or command content | | Active alerts | `4` | Understand how noisy or quiet installations are in aggregate | | Relay enabled | `true`/`false` | See whether remote-access features are being used | | SSO enabled | `true`/`false` | See whether single-sign-on support is being used | diff --git a/frontend-modern/src/api/__tests__/settings.test.ts b/frontend-modern/src/api/__tests__/settings.test.ts index a2ad7af13..37ac3b032 100644 --- a/frontend-modern/src/api/__tests__/settings.test.ts +++ b/frontend-modern/src/api/__tests__/settings.test.ts @@ -61,9 +61,29 @@ describe('SettingsAPI', () => { pmg_instances: 0, vms: 2, containers: 3, + agent_hosts: 1, docker_hosts: 0, + docker_containers: 0, kubernetes_clusters: 0, + kubernetes_nodes: 0, + kubernetes_pods: 0, + kubernetes_deployments: 0, + storage_pools: 1, + physical_disks: 2, + ceph_clusters: 0, + network_shares: 0, + truenas_systems: 0, + truenas_vms: 0, + truenas_apps: 0, + vmware_hosts: 0, + vmware_vms: 0, + vmware_datastores: 0, + availability_targets: 1, ai_enabled: false, + patrol_enabled: false, + discovery_enabled: false, + notifications_enabled: false, + ai_actions_enabled: false, active_alerts: 0, relay_enabled: false, sso_enabled: false, diff --git a/frontend-modern/src/api/settings.ts b/frontend-modern/src/api/settings.ts index 9cd286041..cf4132553 100644 --- a/frontend-modern/src/api/settings.ts +++ b/frontend-modern/src/api/settings.ts @@ -22,9 +22,29 @@ export interface TelemetryPingPreview { pmg_instances: number; vms: number; containers: number; + agent_hosts: number; docker_hosts: number; + docker_containers: number; kubernetes_clusters: number; + kubernetes_nodes: number; + kubernetes_pods: number; + kubernetes_deployments: number; + storage_pools: number; + physical_disks: number; + ceph_clusters: number; + network_shares: number; + truenas_systems: number; + truenas_vms: number; + truenas_apps: number; + vmware_hosts: number; + vmware_vms: number; + vmware_datastores: number; + availability_targets: number; ai_enabled: boolean; + patrol_enabled: boolean; + discovery_enabled: boolean; + notifications_enabled: boolean; + ai_actions_enabled: boolean; active_alerts: number; relay_enabled: boolean; sso_enabled: boolean; diff --git a/frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx b/frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx index 14c9f3629..05f6d33b1 100644 --- a/frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx +++ b/frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx @@ -186,9 +186,11 @@ export const GeneralSettingsPanel: Component = (props

Help improve Pulse by sharing anonymous outbound usage data: a rotating install ID, - normalized release identity, platform, resource counts, and feature flags. No - hostnames, credentials, or personal information are sent. Telemetry rows are - retained for up to 90 days, and IP addresses are not stored in telemetry rows.{' '} + normalized release identity, runtime platform, aggregate self-hosted adoption + counts, and coarse feature flags. No hostnames, credentials, infrastructure + identifiers, prompts, chat messages, or personal information are sent. Telemetry + rows are retained for up to 90 days, and IP addresses are not stored in telemetry + rows.{' '} { ); }); + it('keeps telemetry disclosure aligned with the security privacy contract', () => { + expect(generalSettingsPanelSource).toContain('aggregate self-hosted adoption'); + expect(generalSettingsPanelSource).toContain('counts, and coarse feature flags'); + expect(generalSettingsPanelSource).toContain( + 'identifiers, prompts, chat messages, or personal information are sent.', + ); + expect(generalSettingsPanelSource).toContain('Telemetry payload preview'); + expect(generalSettingsPanelSource).toContain('Reset ID'); + expect(generalSettingsPanelSource).not.toContain('license_tier'); + expect(generalSettingsPanelSource).not.toContain('api_tokens'); + }); + it('keeps panel-owned gated settings routes separate from sidebar visibility', () => { expect(settingsNavVisibilitySource).toContain('const PANEL_OWNED_FEATURE_GATE_TABS'); expect(settingsNavVisibilitySource).toContain('shouldBlockSettingsRouteItem'); diff --git a/frontend-modern/src/components/Settings/__tests__/useSystemSettingsState.test.ts b/frontend-modern/src/components/Settings/__tests__/useSystemSettingsState.test.ts index 4a4fbd36e..0536b58e2 100644 --- a/frontend-modern/src/components/Settings/__tests__/useSystemSettingsState.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/useSystemSettingsState.test.ts @@ -318,9 +318,29 @@ describe('useSystemSettingsState', () => { pmg_instances: 0, vms: 2, containers: 3, + agent_hosts: 1, docker_hosts: 0, + docker_containers: 0, kubernetes_clusters: 0, + kubernetes_nodes: 0, + kubernetes_pods: 0, + kubernetes_deployments: 0, + storage_pools: 1, + physical_disks: 2, + ceph_clusters: 0, + network_shares: 0, + truenas_systems: 0, + truenas_vms: 0, + truenas_apps: 0, + vmware_hosts: 0, + vmware_vms: 0, + vmware_datastores: 0, + availability_targets: 1, ai_enabled: false, + patrol_enabled: false, + discovery_enabled: false, + notifications_enabled: false, + ai_actions_enabled: false, active_alerts: 0, relay_enabled: false, sso_enabled: false, @@ -359,9 +379,29 @@ describe('useSystemSettingsState', () => { pmg_instances: 0, vms: 2, containers: 3, + agent_hosts: 1, docker_hosts: 0, + docker_containers: 0, kubernetes_clusters: 0, + kubernetes_nodes: 0, + kubernetes_pods: 0, + kubernetes_deployments: 0, + storage_pools: 1, + physical_disks: 2, + ceph_clusters: 0, + network_shares: 0, + truenas_systems: 0, + truenas_vms: 0, + truenas_apps: 0, + vmware_hosts: 0, + vmware_vms: 0, + vmware_datastores: 0, + availability_targets: 1, ai_enabled: false, + patrol_enabled: false, + discovery_enabled: false, + notifications_enabled: false, + ai_actions_enabled: false, active_alerts: 0, relay_enabled: false, sso_enabled: false, @@ -405,9 +445,29 @@ describe('useSystemSettingsState', () => { pmg_instances: 0, vms: 0, containers: 0, + agent_hosts: 0, docker_hosts: 0, + docker_containers: 0, kubernetes_clusters: 0, + kubernetes_nodes: 0, + kubernetes_pods: 0, + kubernetes_deployments: 0, + storage_pools: 0, + physical_disks: 0, + ceph_clusters: 0, + network_shares: 0, + truenas_systems: 0, + truenas_vms: 0, + truenas_apps: 0, + vmware_hosts: 0, + vmware_vms: 0, + vmware_datastores: 0, + availability_targets: 0, ai_enabled: false, + patrol_enabled: false, + discovery_enabled: false, + notifications_enabled: false, + ai_actions_enabled: false, active_alerts: 0, relay_enabled: false, sso_enabled: false, diff --git a/internal/monitoring/canonical_guardrails_test.go b/internal/monitoring/canonical_guardrails_test.go index fff99d4bf..b2e2d47f3 100644 --- a/internal/monitoring/canonical_guardrails_test.go +++ b/internal/monitoring/canonical_guardrails_test.go @@ -514,6 +514,31 @@ func TestMonitoredSystemUsageReadinessGuardrailsRemainCanonical(t *testing.T) { } } +func TestInstallTelemetrySnapshotCountsStayOnMonitoringBoundary(t *testing.T) { + data, err := os.ReadFile("reload.go") + if err != nil { + t.Fatalf("failed to read reload.go: %v", err) + } + source := string(data) + + for _, snippet := range []string{ + "AgentHosts int", + "DockerContainers int", + "KubernetesPods int", + "TrueNASSystems int", + "VMwareDatastores int", + "AvailabilityTargets int", + "resources, _ := monitor.UnifiedResourceSnapshot()", + "accumulateInstallSnapshotUnifiedResourceCounts(counts, resources)", + "case unifiedresources.ResourceTypeNetworkShare:", + "resource.Availability != nil && resource.Availability.Enabled", + } { + if !strings.Contains(source, snippet) { + t.Fatalf("reload.go must contain %q", snippet) + } + } +} + func TestMonitoredSystemUsageStaysInventoryOnly(t *testing.T) { data, err := os.ReadFile("monitored_system_usage.go") if err != nil { diff --git a/internal/monitoring/reload.go b/internal/monitoring/reload.go index 1e8cf5327..1cef457ef 100644 --- a/internal/monitoring/reload.go +++ b/internal/monitoring/reload.go @@ -8,6 +8,7 @@ import ( "time" "github.com/rcourtman/pulse-go-rewrite/internal/config" + "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" "github.com/rcourtman/pulse-go-rewrite/internal/websocket" "github.com/rs/zerolog/log" ) @@ -15,14 +16,30 @@ import ( // InstallSnapshotCounts holds install-wide resource and alert counts aggregated // across tenant monitors. type InstallSnapshotCounts struct { - PVENodes int - PBSInstances int - PMGInstances int - VMs int - Containers int - DockerHosts int - KubernetesClusters int - ActiveAlerts int + PVENodes int + PBSInstances int + PMGInstances int + VMs int + Containers int + AgentHosts int + DockerHosts int + DockerContainers int + KubernetesClusters int + KubernetesNodes int + KubernetesPods int + KubernetesDeployments int + StoragePools int + PhysicalDisks int + CephClusters int + NetworkShares int + TrueNASSystems int + TrueNASVMs int + TrueNASApps int + VMwareHosts int + VMwareVMs int + VMwareDatastores int + AvailabilityTargets int + ActiveAlerts int } // ReloadableMonitor wraps a Monitor with reload capability @@ -254,12 +271,62 @@ func accumulateInstallSnapshotCounts(counts *InstallSnapshotCounts, monitor *Mon counts.PMGInstances += len(readState.PMGInstances()) counts.VMs += len(readState.VMs()) counts.Containers += len(readState.Containers()) + counts.AgentHosts += len(readState.Hosts()) counts.DockerHosts += len(readState.DockerHosts()) + counts.DockerContainers += len(readState.DockerContainers()) counts.KubernetesClusters += len(readState.K8sClusters()) + counts.KubernetesNodes += len(readState.K8sNodes()) + counts.KubernetesPods += len(readState.Pods()) + counts.KubernetesDeployments += len(readState.K8sDeployments()) + counts.StoragePools += len(readState.StoragePools()) + counts.PhysicalDisks += len(readState.PhysicalDisks()) } + resources, _ := monitor.UnifiedResourceSnapshot() + accumulateInstallSnapshotUnifiedResourceCounts(counts, resources) counts.ActiveAlerts += len(monitor.ActiveAlertsSnapshot()) } +func accumulateInstallSnapshotUnifiedResourceCounts(counts *InstallSnapshotCounts, resources []unifiedresources.Resource) { + if counts == nil { + return + } + for _, resource := range resources { + resourceType := unifiedresources.CanonicalResourceType(resource.Type) + switch resourceType { + case unifiedresources.ResourceTypeCeph: + counts.CephClusters++ + case unifiedresources.ResourceTypeNetworkShare: + counts.NetworkShares++ + } + + if resource.Availability != nil && resource.Availability.Enabled { + counts.AvailabilityTargets++ + } + + if resource.TrueNAS != nil { + switch resourceType { + case unifiedresources.ResourceTypeAgent: + counts.TrueNASSystems++ + case unifiedresources.ResourceTypeVM: + counts.TrueNASVMs++ + case unifiedresources.ResourceTypeAppContainer: + counts.TrueNASApps++ + } + } + + if resource.VMware != nil { + switch resourceType { + case unifiedresources.ResourceTypeAgent: + counts.VMwareHosts++ + case unifiedresources.ResourceTypeVM: + counts.VMwareVMs++ + case unifiedresources.ResourceTypeStorage: + counts.VMwareDatastores++ + } + } + } +} + // Stop stops the monitor func (rm *ReloadableMonitor) Stop() { rm.mu.Lock() diff --git a/internal/monitoring/reload_test.go b/internal/monitoring/reload_test.go index b8d4e0f2f..2dd04b222 100644 --- a/internal/monitoring/reload_test.go +++ b/internal/monitoring/reload_test.go @@ -7,6 +7,7 @@ import ( "github.com/rcourtman/pulse-go-rewrite/internal/config" "github.com/rcourtman/pulse-go-rewrite/internal/models" + "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -139,6 +140,32 @@ func TestReloadableMonitorAggregateInstallSnapshotCountsIncludesProvisionedTenan assert.Equal(t, 6, counts.ActiveAlerts) } +func TestAccumulateInstallSnapshotUnifiedResourceCountsUsesCoarseV6AdoptionSignals(t *testing.T) { + counts := InstallSnapshotCounts{} + accumulateInstallSnapshotUnifiedResourceCounts(&counts, []unifiedresources.Resource{ + {Type: unifiedresources.ResourceTypeAgent, TrueNAS: &unifiedresources.TrueNASData{}}, + {Type: unifiedresources.ResourceTypeVM, TrueNAS: &unifiedresources.TrueNASData{}}, + {Type: unifiedresources.ResourceTypeAppContainer, TrueNAS: &unifiedresources.TrueNASData{}}, + {Type: unifiedresources.ResourceTypeAgent, VMware: &unifiedresources.VMwareData{}}, + {Type: unifiedresources.ResourceTypeVM, VMware: &unifiedresources.VMwareData{}}, + {Type: unifiedresources.ResourceTypeStorage, VMware: &unifiedresources.VMwareData{}}, + {Type: unifiedresources.ResourceTypeCeph}, + {Type: unifiedresources.ResourceTypeNetworkShare}, + {Type: unifiedresources.ResourceTypeNetworkEndpoint, Availability: &unifiedresources.AvailabilityData{Enabled: true}}, + {Type: unifiedresources.ResourceTypeNetworkEndpoint, Availability: &unifiedresources.AvailabilityData{Enabled: false}}, + }) + + assert.Equal(t, 1, counts.TrueNASSystems) + assert.Equal(t, 1, counts.TrueNASVMs) + assert.Equal(t, 1, counts.TrueNASApps) + assert.Equal(t, 1, counts.VMwareHosts) + assert.Equal(t, 1, counts.VMwareVMs) + assert.Equal(t, 1, counts.VMwareDatastores) + assert.Equal(t, 1, counts.CephClusters) + assert.Equal(t, 1, counts.NetworkShares) + assert.Equal(t, 1, counts.AvailabilityTargets) +} + func testTelemetryMonitor( nodes []models.Node, vms []models.VM, diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go index 13d2f287c..d581f82b3 100644 --- a/internal/telemetry/telemetry.go +++ b/internal/telemetry/telemetry.go @@ -15,10 +15,13 @@ // Scale (counts only, no names): // - Number of PVE nodes, PBS instances, PMG instances // - Number of VMs, LXC containers -// - Number of Docker hosts and Kubernetes clusters +// - Number of Pulse Agent hosts, Docker hosts/containers, and Kubernetes clusters/nodes/pods/deployments +// - Number of storage resources, physical disks, Ceph clusters, and network shares +// - Number of TrueNAS systems/VMs/apps, VMware hosts/VMs/datastores, and availability targets // // Feature usage (booleans and counts, no content): // - Whether AI features are enabled +// - Whether Patrol, discovery, notifications, or AI action capability are enabled // - Number of active alerts // - Whether relay/remote access is enabled // - Whether SSO/OIDC is configured @@ -110,42 +113,82 @@ type Ping struct { Event string `json:"event"` // "startup" or "heartbeat" // Scale (counts only — no names, IPs, or identifiers) - PVENodes int `json:"pve_nodes"` - PBSInstances int `json:"pbs_instances"` - PMGInstances int `json:"pmg_instances"` - VMs int `json:"vms"` - Containers int `json:"containers"` - DockerHosts int `json:"docker_hosts"` - KubernetesClusters int `json:"kubernetes_clusters"` + PVENodes int `json:"pve_nodes"` + PBSInstances int `json:"pbs_instances"` + PMGInstances int `json:"pmg_instances"` + VMs int `json:"vms"` + Containers int `json:"containers"` + AgentHosts int `json:"agent_hosts"` + DockerHosts int `json:"docker_hosts"` + DockerContainers int `json:"docker_containers"` + KubernetesClusters int `json:"kubernetes_clusters"` + KubernetesNodes int `json:"kubernetes_nodes"` + KubernetesPods int `json:"kubernetes_pods"` + KubernetesDeployments int `json:"kubernetes_deployments"` + StoragePools int `json:"storage_pools"` + PhysicalDisks int `json:"physical_disks"` + CephClusters int `json:"ceph_clusters"` + NetworkShares int `json:"network_shares"` + TrueNASSystems int `json:"truenas_systems"` + TrueNASVMs int `json:"truenas_vms"` + TrueNASApps int `json:"truenas_apps"` + VMwareHosts int `json:"vmware_hosts"` + VMwareVMs int `json:"vmware_vms"` + VMwareDatastores int `json:"vmware_datastores"` + AvailabilityTargets int `json:"availability_targets"` // Feature usage (booleans and counts — no content) - AIEnabled bool `json:"ai_enabled"` - ActiveAlerts int `json:"active_alerts"` - RelayEnabled bool `json:"relay_enabled"` - SSOEnabled bool `json:"sso_enabled"` - MultiTenant bool `json:"multi_tenant"` - PaidLicense bool `json:"paid_license"` - HasAPITokens bool `json:"has_api_tokens"` + AIEnabled bool `json:"ai_enabled"` + PatrolEnabled bool `json:"patrol_enabled"` + DiscoveryEnabled bool `json:"discovery_enabled"` + NotificationsEnabled bool `json:"notifications_enabled"` + AIActionsEnabled bool `json:"ai_actions_enabled"` + ActiveAlerts int `json:"active_alerts"` + RelayEnabled bool `json:"relay_enabled"` + SSOEnabled bool `json:"sso_enabled"` + MultiTenant bool `json:"multi_tenant"` + PaidLicense bool `json:"paid_license"` + HasAPITokens bool `json:"has_api_tokens"` } // Snapshot holds the dynamic state gathered at ping time. // The telemetry package calls a user-provided SnapshotFunc to populate this, // keeping the package decoupled from monitor/config internals. type Snapshot struct { - PVENodes int - PBSInstances int - PMGInstances int - VMs int - Containers int - DockerHosts int - KubernetesClusters int - AIEnabled bool - ActiveAlerts int - RelayEnabled bool - SSOEnabled bool - MultiTenant bool - PaidLicense bool - HasAPITokens bool + PVENodes int + PBSInstances int + PMGInstances int + VMs int + Containers int + AgentHosts int + DockerHosts int + DockerContainers int + KubernetesClusters int + KubernetesNodes int + KubernetesPods int + KubernetesDeployments int + StoragePools int + PhysicalDisks int + CephClusters int + NetworkShares int + TrueNASSystems int + TrueNASVMs int + TrueNASApps int + VMwareHosts int + VMwareVMs int + VMwareDatastores int + AvailabilityTargets int + AIEnabled bool + PatrolEnabled bool + DiscoveryEnabled bool + NotificationsEnabled bool + AIActionsEnabled bool + ActiveAlerts int + RelayEnabled bool + SSOEnabled bool + MultiTenant bool + PaidLicense bool + HasAPITokens bool } // SnapshotFunc returns the current state snapshot for telemetry. @@ -322,9 +365,29 @@ func applySnapshot(base Ping, fn SnapshotFunc) Ping { ping.PMGInstances = s.PMGInstances ping.VMs = s.VMs ping.Containers = s.Containers + ping.AgentHosts = s.AgentHosts ping.DockerHosts = s.DockerHosts + ping.DockerContainers = s.DockerContainers ping.KubernetesClusters = s.KubernetesClusters + ping.KubernetesNodes = s.KubernetesNodes + ping.KubernetesPods = s.KubernetesPods + ping.KubernetesDeployments = s.KubernetesDeployments + ping.StoragePools = s.StoragePools + ping.PhysicalDisks = s.PhysicalDisks + ping.CephClusters = s.CephClusters + ping.NetworkShares = s.NetworkShares + ping.TrueNASSystems = s.TrueNASSystems + ping.TrueNASVMs = s.TrueNASVMs + ping.TrueNASApps = s.TrueNASApps + ping.VMwareHosts = s.VMwareHosts + ping.VMwareVMs = s.VMwareVMs + ping.VMwareDatastores = s.VMwareDatastores + ping.AvailabilityTargets = s.AvailabilityTargets ping.AIEnabled = s.AIEnabled + ping.PatrolEnabled = s.PatrolEnabled + ping.DiscoveryEnabled = s.DiscoveryEnabled + ping.NotificationsEnabled = s.NotificationsEnabled + ping.AIActionsEnabled = s.AIActionsEnabled ping.ActiveAlerts = s.ActiveAlerts ping.RelayEnabled = s.RelayEnabled ping.SSOEnabled = s.SSOEnabled diff --git a/internal/telemetry/telemetry_test.go b/internal/telemetry/telemetry_test.go index e1dc67827..a4ba5661d 100644 --- a/internal/telemetry/telemetry_test.go +++ b/internal/telemetry/telemetry_test.go @@ -152,13 +152,26 @@ func TestApplySnapshot(t *testing.T) { snap := func() Snapshot { return Snapshot{ - PVENodes: 3, - VMs: 10, - Containers: 5, - AIEnabled: true, - ActiveAlerts: 2, - PaidLicense: true, - HasAPITokens: true, + PVENodes: 3, + VMs: 10, + Containers: 5, + AgentHosts: 2, + DockerContainers: 12, + KubernetesPods: 18, + StoragePools: 4, + PhysicalDisks: 9, + TrueNASSystems: 1, + TrueNASApps: 3, + VMwareHosts: 2, + AvailabilityTargets: 6, + AIEnabled: true, + PatrolEnabled: true, + DiscoveryEnabled: true, + NotificationsEnabled: true, + AIActionsEnabled: true, + ActiveAlerts: 2, + PaidLicense: true, + HasAPITokens: true, } } @@ -173,9 +186,21 @@ func TestApplySnapshot(t *testing.T) { if ping.VMs != 10 { t.Fatalf("VMs = %d, want 10", ping.VMs) } + if ping.AgentHosts != 2 || ping.DockerContainers != 12 || ping.KubernetesPods != 18 { + t.Fatalf("expanded workload counts not applied: %#v", ping) + } + if ping.StoragePools != 4 || ping.PhysicalDisks != 9 { + t.Fatalf("expanded storage counts not applied: %#v", ping) + } + if ping.TrueNASSystems != 1 || ping.TrueNASApps != 3 || ping.VMwareHosts != 2 || ping.AvailabilityTargets != 6 { + t.Fatalf("expanded platform counts not applied: %#v", ping) + } if !ping.AIEnabled { t.Fatal("AIEnabled should be true") } + if !ping.PatrolEnabled || !ping.DiscoveryEnabled || !ping.NotificationsEnabled || !ping.AIActionsEnabled { + t.Fatalf("expanded feature flags not applied: %#v", ping) + } if !ping.PaidLicense { t.Fatal("PaidLicense should be true") } diff --git a/pkg/server/server.go b/pkg/server/server.go index 8b542de8f..3e8ec573a 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -459,13 +459,33 @@ func Run(ctx context.Context, version string) error { snap.PMGInstances = counts.PMGInstances snap.VMs = counts.VMs snap.Containers = counts.Containers + snap.AgentHosts = counts.AgentHosts snap.DockerHosts = counts.DockerHosts + snap.DockerContainers = counts.DockerContainers snap.KubernetesClusters = counts.KubernetesClusters + snap.KubernetesNodes = counts.KubernetesNodes + snap.KubernetesPods = counts.KubernetesPods + snap.KubernetesDeployments = counts.KubernetesDeployments + snap.StoragePools = counts.StoragePools + snap.PhysicalDisks = counts.PhysicalDisks + snap.CephClusters = counts.CephClusters + snap.NetworkShares = counts.NetworkShares + snap.TrueNASSystems = counts.TrueNASSystems + snap.TrueNASVMs = counts.TrueNASVMs + snap.TrueNASApps = counts.TrueNASApps + snap.VMwareHosts = counts.VMwareHosts + snap.VMwareVMs = counts.VMwareVMs + snap.VMwareDatastores = counts.VMwareDatastores + snap.AvailabilityTargets = counts.AvailabilityTargets snap.ActiveAlerts = counts.ActiveAlerts + snap.DiscoveryEnabled = currentCfg.DiscoveryEnabled // Feature flags from persisted config (using pre-created persistence). if aiCfg, err := telemetryPersistence.LoadAIConfig(); err == nil && aiCfg != nil { snap.AIEnabled = aiCfg.Enabled + snap.PatrolEnabled = aiCfg.IsPatrolEnabled() + snap.DiscoveryEnabled = snap.DiscoveryEnabled || aiCfg.IsDiscoveryEnabled() + snap.AIActionsEnabled = aiCfg.IsControlEnabled() } if relayCfg, err := telemetryPersistence.LoadRelayConfig(); err == nil { snap.RelayEnabled = relayCfg.Enabled @@ -475,6 +495,24 @@ func Run(ctx context.Context, version string) error { if ssoCfg, err := telemetryPersistence.LoadSSOConfig(); err == nil && ssoCfg != nil { snap.SSOEnabled = ssoCfg.HasEnabledProviders() } + if emailCfg, err := telemetryPersistence.LoadEmailConfig(); err == nil && emailCfg != nil && emailCfg.Enabled { + snap.NotificationsEnabled = true + } + if !snap.NotificationsEnabled { + if appriseCfg, err := telemetryPersistence.LoadAppriseConfig(); err == nil && appriseCfg != nil && appriseCfg.Enabled { + snap.NotificationsEnabled = true + } + } + if !snap.NotificationsEnabled { + if webhooks, err := telemetryPersistence.LoadWebhooks(); err == nil { + for _, webhook := range webhooks { + if webhook.Enabled { + snap.NotificationsEnabled = true + break + } + } + } + } // Coarse commercial posture only; telemetry does not send exact tiers. if router != nil && router.GetLicenseHandlers() != nil { diff --git a/scripts/telemetry_adoption_report.py b/scripts/telemetry_adoption_report.py index 0ccff6649..12a6565b1 100644 --- a/scripts/telemetry_adoption_report.py +++ b/scripts/telemetry_adoption_report.py @@ -28,6 +28,44 @@ DEFAULT_LATEST_INSTALL_WINDOWS = ( ("72h", timedelta(hours=72)), ("7d", timedelta(days=7)), ) +ADOPTION_COUNT_FIELDS = ( + ("pve_nodes", "PVE nodes"), + ("pbs_instances", "PBS instances"), + ("pmg_instances", "PMG instances"), + ("vms", "VMs"), + ("containers", "LXC containers"), + ("agent_hosts", "Agent hosts"), + ("docker_hosts", "Docker hosts"), + ("docker_containers", "Docker containers"), + ("kubernetes_clusters", "Kubernetes clusters"), + ("kubernetes_nodes", "Kubernetes nodes"), + ("kubernetes_pods", "Kubernetes pods"), + ("kubernetes_deployments", "Kubernetes deployments"), + ("storage_pools", "Storage pools"), + ("physical_disks", "Physical disks"), + ("ceph_clusters", "Ceph clusters"), + ("network_shares", "Network shares"), + ("truenas_systems", "TrueNAS systems"), + ("truenas_vms", "TrueNAS VMs"), + ("truenas_apps", "TrueNAS apps"), + ("vmware_hosts", "VMware hosts"), + ("vmware_vms", "VMware VMs"), + ("vmware_datastores", "VMware datastores"), + ("availability_targets", "Availability targets"), + ("active_alerts", "Active alerts"), +) +FEATURE_BOOL_FIELDS = ( + ("ai_enabled", "AI enabled"), + ("patrol_enabled", "Patrol enabled"), + ("discovery_enabled", "Discovery enabled"), + ("notifications_enabled", "Notifications enabled"), + ("ai_actions_enabled", "AI actions enabled"), + ("relay_enabled", "Relay enabled"), + ("sso_enabled", "SSO enabled"), + ("multi_tenant", "Multi-tenant"), + ("paid_license", "Paid license"), + ("has_api_tokens", "Has API tokens"), +) GIT_DESCRIBE_RE = re.compile( r"^(?P\d+\.\d+\.\d+(?:-[0-9A-Za-z\.-]+)?)-(?P\d+)-g(?P[0-9a-fA-F]+)(?P-dirty)?$" ) @@ -130,6 +168,16 @@ def parse_optional_bool(value: Any) -> bool | None: return None +def parse_optional_nonnegative_int(value: Any) -> int: + if value is None: + return 0 + try: + parsed = int(value) + except (TypeError, ValueError): + return 0 + return max(parsed, 0) + + def classify_row_version(row: dict[str, Any], published_versions: set[str]) -> ClassifiedVersion: raw_version = str(row.get("version") or "") identity = classify_reported_version(raw_version, published_versions) @@ -321,6 +369,8 @@ def summarize_latest_install_windows( published_split: Counter[str] = Counter() non_release_split: Counter[str] = Counter() platform_split: Counter[str] = Counter() + adoption_counts: Counter[str] = Counter() + feature_counts: Counter[str] = Counter() for row in latest_by_install.values(): received_at = parse_received_at(str(row["received_at"])) @@ -332,6 +382,11 @@ def summarize_latest_install_windows( platform_split[platform] += 1 target = published_split if identity.is_published_release else non_release_split target[identity.version] += 1 + for key, _ in ADOPTION_COUNT_FIELDS: + adoption_counts[key] += parse_optional_nonnegative_int(row.get(key)) + for key, _ in FEATURE_BOOL_FIELDS: + if parse_optional_bool(row.get(key)): + feature_counts[key] += 1 summary[label] = { "active_installs": sum(version_split.values()), @@ -339,6 +394,14 @@ def summarize_latest_install_windows( "published_versions": counter_entries(published_split, "version"), "non_release_versions": counter_entries(non_release_split, "version"), "platforms": counter_entries(platform_split, "platform"), + "adoption_counts": [ + {"field": key, "label": label, "total": adoption_counts[key]} + for key, label in ADOPTION_COUNT_FIELDS + ], + "feature_enabled_installs": [ + {"field": key, "label": label, "installs": feature_counts[key]} + for key, label in FEATURE_BOOL_FIELDS + ], } return summary @@ -409,6 +472,18 @@ def format_text(summary: dict[str, Any], repo: str, since_days: int) -> str: lines.extend(f" - {entry['platform']}: {entry['installs']}" for entry in window_summary["platforms"]) else: lines.append(" - none") + lines.append("- aggregate adoption counts:") + adoption_counts = [entry for entry in window_summary["adoption_counts"] if entry["total"] > 0] + if adoption_counts: + lines.extend(f" - {entry['label']}: {entry['total']}" for entry in adoption_counts) + else: + lines.append(" - none") + lines.append("- feature-enabled installs:") + feature_counts = [entry for entry in window_summary["feature_enabled_installs"] if entry["installs"] > 0] + if feature_counts: + lines.extend(f" - {entry['label']}: {entry['installs']}" for entry in feature_counts) + else: + lines.append(" - none") return "\n".join(lines)