mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
Expand anonymous telemetry adoption coverage
This commit is contained in:
parent
cd278bf5e2
commit
a84867c044
18 changed files with 554 additions and 49 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -186,9 +186,11 @@ export const GeneralSettingsPanel: Component<GeneralSettingsPanelProps> = (props
|
|||
</div>
|
||||
<p class="text-xs text-muted leading-relaxed">
|
||||
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.{' '}
|
||||
<a
|
||||
href={PRIVACY_DOC_URL}
|
||||
target="_blank"
|
||||
|
|
|
|||
|
|
@ -266,6 +266,18 @@ describe('settings architecture guardrails', () => {
|
|||
);
|
||||
});
|
||||
|
||||
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');
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<base>\d+\.\d+\.\d+(?:-[0-9A-Za-z\.-]+)?)-(?P<count>\d+)-g(?P<sha>[0-9a-fA-F]+)(?P<dirty>-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)
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue