Remove internal analytics from diagnostics

This commit is contained in:
rcourtman
2026-04-30 09:23:40 +01:00
parent d4896c8a9f
commit c6bcad255a
17 changed files with 304 additions and 1338 deletions
@@ -1659,12 +1659,11 @@ for lifecycle-adjacent diagnostics surfaces, legacy aliases and empty
fallback-reason fields must still normalize onto the governed canonical reason
contract instead of depending on monitor-owned snapshot accessors to have run
first.
That same shared diagnostics dependency now also assumes any local commercial
funnel diagnostics stay org-scoped at the cache boundary: when
`internal/api/diagnostics.go` adds self-hosted upgrade-metric summaries beside
lifecycle diagnostics, the shared diagnostics cache must key by authenticated
org context so lifecycle-adjacent admin surfaces do not cross-tenant leak local
commerce telemetry.
That same shared diagnostics dependency now also assumes local commercial and
onboarding analytics stay out of user diagnostics entirely: lifecycle-adjacent
admin surfaces may consume operational diagnostics, but they must not restore
self-hosted upgrade-metric summaries or infrastructure-onboarding analytics to
`internal/api/diagnostics.go` or the settings diagnostics panel.
Lifecycle-adjacent Docker and Podman agent diagnostics are part of that same
shared backend dependency. When `internal/api/diagnostics.go` emits agent
health notes for Docker and Podman, the copy must keep Infrastructure as the
@@ -559,6 +559,11 @@ the canonical monitored-system blocked payload.
21. Telemetry preview or reset endpoints drifting from the exact server-owned telemetry runtime contract instead of reusing the same source-of-truth snapshot and install-ID state the background sender uses
22. Shared SSO test or metadata-preview handlers open-coding outbound metadata/discovery URLs, allowing userinfo-bearing HTTP(S) inputs, or rebuilding `/.well-known/openid-configuration` with origin-root string concatenation instead of the shared validated URL helpers before any outbound request
23. AI settings handlers echoing raw provider secrets or testing the wrong provider model: `/api/settings/ai` may expose masked provider-auth presence such as `ollama_password_set`, but backend payloads must never echo stored secrets back to clients, and provider-specific test routes must stay bound to the selected provider's own configured model instead of whichever other provider currently owns the default `model` field
24. `/api/diagnostics` exposing maintainer/admin analytics such as commercial
funnel, sales funnel, pricing/checkout conversion, or infrastructure
onboarding telemetry. Customer diagnostics may expose runtime health,
supportability, and sanitized troubleshooting state; admin analytics must
stay behind admin-owned metrics routes.
## Completion Obligations
@@ -1792,14 +1797,12 @@ That same diagnostics boundary must also backfill canonical fallback reasons
when a raw snapshot reaches the API layer without one, so
`buildMemorySourceDiagnostics` stays self-consistent even if a caller bypasses
`GetDiagnosticSnapshots()` and hands diagnostics a legacy alias directly.
That same diagnostics boundary now also owns org-scoped local commercial funnel
serialization when the self-hosted privacy contract allows it: if
`internal/api/diagnostics.go` exposes local upgrade-metric summaries, daily
buckets, or surface/capability breakdowns, it must read them from the local
conversion store through the licensing bridge, keep diagnostics caching scoped
to the authenticated org context, and preserve the canonical camelCase
diagnostics payload shape instead of leaking pkg/licensing types or inferring
hosted checkout stages from the local API layer.
That same diagnostics boundary now explicitly excludes maintainer analytics.
`internal/api/diagnostics.go` must not serialize commercial funnel, sales
funnel, pricing/checkout conversion, or infrastructure onboarding telemetry in
`/api/diagnostics`; local upgrade/onboarding metrics remain owned by the
licensing/admin metrics routes and their admin gates rather than the customer
support diagnostics contract.
That same public-demo API boundary must also hide runtime-admin operations
surfaces instead of treating them as harmless reads. Demo sessions must receive
`404` for `/api/diagnostics`, `/api/diagnostics/docker/prepare-token`, and the
@@ -533,13 +533,13 @@ runtime gating as separate unlinked claims.
those self-hosted handoffs on release track `v6` even while the public
site remains on `v5` before GA.
15. Keep local commercial funnel reporting inside the self-hosted privacy
boundary: `internal/api/diagnostics.go` and
`frontend-modern/src/components/Settings/DiagnosticsResultsPanel.tsx`
may expose org-scoped local upgrade-metric summaries, daily buckets, and
surface/capability breakdowns to authenticated admins, but they must read
from the local conversion store instead of exporting those event rows to
the commercial service or reconstructing them from hosted checkout
telemetry.
boundary, not user diagnostics: local upgrade-metric summaries, daily
buckets, surface/capability breakdowns, and infrastructure-onboarding
analytics may remain in the dedicated local reporting/privacy path, but
`internal/api/diagnostics.go`,
`frontend-modern/src/components/Settings/DiagnosticsResultsPanel.tsx`, and
`frontend-modern/src/components/Settings/diagnosticsModel.ts` must not
expose them in the customer support diagnostics payload or panel.
16. Keep ordinary self-hosted v6 commercial prompts opt-in. Cloud-paid runtime
may keep checkout, activation, recovery, and support-only trial plumbing
available for explicit handoffs and entitled installs, but default
@@ -1903,11 +1903,12 @@ The browser app must not try to recreate those Pulse Account stages from
referrer state, and the commercial service must not collapse self-hosted v6
handoffs back onto the public-site release track when production public GA is
still on v5.
That same local conversion store is now the canonical read model for
self-hosted commercial diagnostics too: the admin-only diagnostics surface
reads a structured 30-day local funnel report with daily, surface, and
capability breakdowns directly from `pkg/licensing/conversion_store.go`
without exporting those per-event rows outside the Pulse instance.
That same local conversion store remains the canonical read model for
self-hosted commercial reporting, but not for user support diagnostics:
admin-owned reporting surfaces may read structured local funnel reports from
`pkg/licensing/conversion_store.go`, while `/api/diagnostics` and the Settings
support diagnostics panel must not expose pricing, checkout, conversion, or
infrastructure-onboarding analytics.
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
@@ -676,6 +676,10 @@ work extends shared components instead of creating new local variants.
4. Top-level settings panels introducing bespoke page-level headers or outer
framing instead of the canonical settings shell and `SettingsPanel`
contract
5. User-facing diagnostics or settings panels rendering maintainer/admin
analytics such as commercial funnel, sales funnel, pricing/checkout
conversion, or infrastructure onboarding telemetry. Those signals belong in
admin-owned metrics surfaces, not the product diagnostics UI.
## Completion Obligations
@@ -1747,12 +1751,12 @@ run/export lifecycle, results rendering, sanitization/model helpers, and
customer-facing diagnostics copy. The shell must not re-accumulate inline API
calls, export-download plumbing, diagnostics-card composition, or diagnostics
surface copy.
That same diagnostics owner split now also covers local commercial funnel
rendering: if diagnostics surfaces expose self-hosted pricing, checkout, or
activation summaries, `DiagnosticsResultsPanel.tsx` and `diagnosticsModel.ts`
must own the card composition, label humanization, and typed payload shape,
while the shell remains a layout/composition owner and does not reintroduce
inline diagnostics fetches or commerce-specific rendering logic.
That same diagnostics owner split also keeps maintainer analytics out of the
customer diagnostics surface. `DiagnosticsResultsPanel.tsx`,
`diagnosticsModel.ts`, and the diagnostics export path must not render or
preserve commercial funnel, sales funnel, pricing/checkout conversion, or
infrastructure onboarding telemetry from `/api/diagnostics`; those signals
belong in admin-owned metrics surfaces instead of Settings support UI.
Diagnostics cards that summarize Docker and Podman agent coverage must use the
shared `docker` source-platform label from
`frontend-modern/src/utils/sourcePlatforms.ts` for their heading and body copy,
@@ -595,11 +595,11 @@ querying, and the operator-facing storage health presentation layer.
pulling the full storage-page `/api/storage-charts` payload, or by
inventing a dashboard-only storage history transport.
15a. Keep shared diagnostics cache scope honest when storage/recovery-adjacent
surfaces reuse `internal/api/diagnostics.go`. If the shared diagnostics
payload includes local commercial funnel summaries, those values must stay
scoped to the authenticated org and sourced from the local conversion
store, so recovery-adjacent diagnostics do not inherit cross-tenant
leakage or hosted/local telemetry drift through the shared backend route.
surfaces reuse `internal/api/diagnostics.go`. The shared diagnostics
payload must not include local commercial funnel summaries or
infrastructure-onboarding analytics, so recovery-adjacent diagnostics do
not inherit commerce telemetry, cross-tenant leakage, or hosted/local
semantic drift through the shared backend route.
16. Keep storage summary interaction scoped through the same canonical IDs.
17. Keep adjacent AI settings persistence vendor-neutral on the shared
`internal/api/` boundary. When storage- or recovery-adjacent hosted flows
@@ -1902,11 +1902,10 @@ source breakdowns backfill canonical fallback reasons even when a raw legacy
snapshot reaches `internal/api/diagnostics.go` without one, so
recovery-adjacent consumers do not observe alias-normalized sources paired
with empty or drifted fallback-reason payloads.
That same shared `internal/api/` dependency now also assumes any local
commercial funnel diagnostics remain cache-scoped to the authenticated org and
read from the local conversion store instead of a hosted telemetry surrogate,
so recovery-adjacent diagnostics surfaces can safely share the backend
diagnostics route without inheriting cross-tenant leakage or hosted/local
That same shared `internal/api/` dependency now also assumes local commercial
and onboarding analytics remain outside the shared diagnostics payload, so
recovery-adjacent diagnostics surfaces can safely share the backend route
without inheriting commerce telemetry, cross-tenant leakage, or hosted/local
semantic drift.
That same shared `internal/api/` dependency now also assumes auth persistence
compatibility stays on an explicit migration/import boundary: legacy
@@ -5,7 +5,6 @@ import { Card } from '@/components/shared/Card';
import Activity from 'lucide-solid/icons/activity';
import AlertTriangle from 'lucide-solid/icons/alert-triangle';
import CheckCircle from 'lucide-solid/icons/check-circle';
import CreditCard from 'lucide-solid/icons/credit-card';
import Cpu from 'lucide-solid/icons/cpu';
import Database from 'lucide-solid/icons/database';
import HardDrive from 'lucide-solid/icons/hard-drive';
@@ -18,21 +17,13 @@ import XCircle from 'lucide-solid/icons/x-circle';
import { StatusDot } from '@/components/shared/StatusDot';
import { getSimpleStatusIndicator, getStatusIndicatorBadgeToneClasses } from '@/utils/status';
import { getSemanticTonePresentation } from '@/utils/semanticTonePresentation';
import { getInfrastructureOnboardingProductPresentation } from '@/utils/infrastructureOnboardingPresentation';
import {
DIAGNOSTICS_EMPTY_PBS_MESSAGE,
DIAGNOSTICS_EMPTY_STATE_COPY,
DIAGNOSTICS_PANEL_COPY,
} from '@/utils/diagnosticsPresentation';
import { getSourcePlatformLabel } from '@/utils/sourcePlatforms';
import { titleCaseDelimitedLabel } from '@/utils/textPresentation';
import type {
CommercialFunnelDimensionBreakdown,
CommercialFunnelStageCounts,
DiagnosticsData,
InfrastructureOnboardingPlatformBreakdown,
InfrastructureOnboardingStageCounts,
} from '@/components/Settings/diagnosticsModel';
import type { DiagnosticsData } from '@/components/Settings/diagnosticsModel';
const DOCKER_PODMAN_SOURCE_LABEL = getSourcePlatformLabel('docker');
@@ -91,122 +82,6 @@ const MetricRow: Component<{
</div>
);
const formatCommercialBreakdownLabel = (value?: string): string =>
titleCaseDelimitedLabel(value, { fallback: 'Unknown' });
const formatCommercialDayLabel = (day?: string): string => {
if (!day) return 'Unknown';
const parsed = new Date(`${day}T00:00:00Z`);
if (Number.isNaN(parsed.getTime())) return day;
return parsed.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
};
const getDiagnosticsActivityBadge = (
status?: string,
): {
badgeStatus: 'online' | 'offline' | 'warning' | 'unknown';
label: string;
} => {
switch (status) {
case 'active':
return { badgeStatus: 'online', label: 'Active' };
case 'warning':
return { badgeStatus: 'warning', label: 'Needs Review' };
case 'error':
return { badgeStatus: 'offline', label: 'Error' };
case 'unavailable':
return { badgeStatus: 'offline', label: 'Unavailable' };
default:
return { badgeStatus: 'unknown', label: 'Idle' };
}
};
const formatCommercialBreakdownSummary = (entry: CommercialFunnelDimensionBreakdown): string => {
const segments: string[] = [];
if (entry.pricing_viewed > 0) {
segments.push(`Pricing ${entry.pricing_viewed}`);
}
if (entry.checkout_clicked > 0) {
segments.push(`Checkout ${entry.checkout_clicked}`);
}
if (entry.trial_started > 0) {
segments.push(`Trials ${entry.trial_started}`);
}
if (entry.license_activated > 0) {
segments.push(`Activated ${entry.license_activated}`);
}
return segments.join(' • ') || 'No recorded activity';
};
const totalCommercialSignals = (summary?: CommercialFunnelStageCounts | null): number => {
if (!summary) return 0;
return (
summary.pricing_viewed +
summary.paywall_viewed +
summary.trial_started +
summary.upgrade_clicked +
summary.checkout_clicked +
summary.checkout_started +
summary.checkout_completed +
summary.license_activated +
summary.license_activation_failed
);
};
const formatInfrastructureOnboardingPathLabel = (value?: string): string => {
switch (value) {
case 'api':
return 'API';
case 'agent':
return 'Agent';
default:
return titleCaseDelimitedLabel(value, { fallback: 'Unknown' });
}
};
const formatInfrastructureOnboardingPlatformLabel = (value?: string): string => {
switch (value) {
case 'vmware':
case 'truenas':
case 'pve':
case 'pbs':
case 'pmg':
case 'agent':
return getInfrastructureOnboardingProductPresentation(value).label;
default:
return titleCaseDelimitedLabel(value, { fallback: 'Unknown' });
}
};
const formatInfrastructureOnboardingPlatformSummary = (
entry: InfrastructureOnboardingPlatformBreakdown,
): string => {
const segments: string[] = [];
if (entry.catalog_selected > 0) {
segments.push(`Catalog ${entry.catalog_selected}`);
}
if (entry.credentials_opened > 0) {
segments.push(`Credentials ${entry.credentials_opened}`);
}
return segments.join(' • ') || 'No recorded activity';
};
const totalInfrastructureOnboardingSignals = (
summary?: InfrastructureOnboardingStageCounts | null,
): number => {
if (!summary) return 0;
return (
summary.opened +
summary.api_path_selected +
summary.agent_path_selected +
summary.probe_detected +
summary.probe_no_match +
summary.probe_error +
summary.catalog_selected +
summary.credentials_opened
);
};
interface DiagnosticsResultsPanelProps {
diagnosticsData: DiagnosticsData | null;
loading: boolean;
@@ -224,9 +99,7 @@ export const DiagnosticsResultsPanel: Component<DiagnosticsResultsPanelProps> =
<h3 class="mb-2 text-lg font-medium text-base-content">
{DIAGNOSTICS_EMPTY_STATE_COPY.title}
</h3>
<p class="mb-6 text-sm text-muted">
{DIAGNOSTICS_EMPTY_STATE_COPY.description}
</p>
<p class="mb-6 text-sm text-muted">{DIAGNOSTICS_EMPTY_STATE_COPY.description}</p>
<button
type="button"
onClick={props.onRunDiagnostics}
@@ -250,13 +123,18 @@ export const DiagnosticsResultsPanel: Component<DiagnosticsResultsPanelProps> =
<MetricRow label="Go Runtime" value={props.diagnosticsData?.system?.goVersion} mono />
<MetricRow label="CPU Cores" value={props.diagnosticsData?.system?.numCPU} />
<MetricRow label="Goroutines" value={props.diagnosticsData?.system?.numGoroutine} />
<MetricRow label="Memory" value={`${props.diagnosticsData?.system?.memoryMB || 0} MB`} />
<MetricRow
label="Memory"
value={`${props.diagnosticsData?.system?.memoryMB || 0} MB`}
/>
</DiagnosticCard>
<DiagnosticCard
title="PVE Nodes"
icon={Server}
status={props.diagnosticsData?.nodes?.every((node) => node.connected) ? 'success' : 'warning'}
status={
props.diagnosticsData?.nodes?.every((node) => node.connected) ? 'success' : 'warning'
}
>
<div class="mb-2 flex items-center justify-between">
<span>Total Nodes</span>
@@ -291,7 +169,9 @@ export const DiagnosticsResultsPanel: Component<DiagnosticsResultsPanelProps> =
>
<Show
when={(props.diagnosticsData?.pbs?.length || 0) > 0}
fallback={<div class="py-4 text-center text-muted">{DIAGNOSTICS_EMPTY_PBS_MESSAGE}</div>}
fallback={
<div class="py-4 text-center text-muted">{DIAGNOSTICS_EMPTY_PBS_MESSAGE}</div>
}
>
<div class="mb-2 flex items-center justify-between">
<span>Total Instances</span>
@@ -426,326 +306,6 @@ export const DiagnosticsResultsPanel: Component<DiagnosticsResultsPanelProps> =
</Card>
</Show>
<Show when={props.diagnosticsData?.commercialFunnel}>
<Card padding="md" class="lg:col-span-2">
<div class="mb-4 flex items-center gap-3 border-b border-border pb-3">
<div class="rounded-md bg-blue-100 p-2 dark:bg-blue-900">
<CreditCard class="h-4 w-4 text-blue-600 dark:text-blue-400" />
</div>
<div>
<h4 class="text-sm font-semibold text-base-content">Commercial Funnel</h4>
<p class="text-xs text-muted">
Local pricing, checkout, and activation activity over the last{' '}
{props.diagnosticsData?.commercialFunnel?.windowDays ?? 0} days.
</p>
</div>
<div class="ml-auto">
<StatusBadge
status={getDiagnosticsActivityBadge(props.diagnosticsData?.commercialFunnel?.status).badgeStatus}
label={getDiagnosticsActivityBadge(props.diagnosticsData?.commercialFunnel?.status).label}
/>
</div>
</div>
<div class="grid gap-4 xl:grid-cols-[minmax(0,220px)_minmax(0,1fr)_minmax(0,1fr)]">
<div class="space-y-2 text-xs">
<MetricRow
label="Signals"
value={totalCommercialSignals(props.diagnosticsData?.commercialFunnel?.summary)}
/>
<MetricRow
label="Pricing Views"
value={props.diagnosticsData?.commercialFunnel?.summary?.pricing_viewed ?? 0}
/>
<MetricRow
label="Checkout Clicks"
value={props.diagnosticsData?.commercialFunnel?.summary?.checkout_clicked ?? 0}
/>
<MetricRow
label="Checkout Starts"
value={props.diagnosticsData?.commercialFunnel?.summary?.checkout_started ?? 0}
/>
<MetricRow
label="Activations"
value={props.diagnosticsData?.commercialFunnel?.summary?.license_activated ?? 0}
/>
<MetricRow
label="Activation Failures"
value={
props.diagnosticsData?.commercialFunnel?.summary?.license_activation_failed ?? 0
}
/>
</div>
<div>
<div class="mb-2 flex items-center justify-between">
<h5 class="text-xs font-semibold uppercase tracking-wide text-muted">
Recent Daily Trend
</h5>
<span class="text-[11px] text-muted">UTC day buckets</span>
</div>
<div class="space-y-2">
<Show
when={(props.diagnosticsData?.commercialFunnel?.daily?.length || 0) > 0}
fallback={<p class="text-xs text-muted">No daily activity recorded.</p>}
>
<For each={props.diagnosticsData?.commercialFunnel?.daily?.slice(-7) || []}>
{(bucket) => (
<div class="rounded-md border border-border-subtle px-3 py-2 text-xs">
<div class="flex items-center justify-between gap-3">
<span class="font-medium text-base-content">
{formatCommercialDayLabel(bucket.day)}
</span>
<span class="text-muted">
Pricing {bucket.pricing_viewed} Checkout {bucket.checkout_clicked}
Activated {bucket.license_activated}
</span>
</div>
</div>
)}
</For>
</Show>
</div>
</div>
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-1">
<div>
<div class="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">
Top Surfaces
</div>
<div class="space-y-2">
<Show
when={(props.diagnosticsData?.commercialFunnel?.surfaces?.length || 0) > 0}
fallback={<p class="text-xs text-muted">No surface attribution recorded.</p>}
>
<For each={props.diagnosticsData?.commercialFunnel?.surfaces?.slice(0, 4) || []}>
{(entry) => (
<div class="rounded-md border border-border-subtle px-3 py-2 text-xs">
<div class="font-medium text-base-content">
{formatCommercialBreakdownLabel(entry.key)}
</div>
<div class="mt-1 text-muted">
{formatCommercialBreakdownSummary(entry)}
</div>
</div>
)}
</For>
</Show>
</div>
</div>
<div>
<div class="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">
Top Capabilities
</div>
<div class="space-y-2">
<Show
when={(props.diagnosticsData?.commercialFunnel?.capabilities?.length || 0) > 0}
fallback={<p class="text-xs text-muted">No capability attribution recorded.</p>}
>
<For each={props.diagnosticsData?.commercialFunnel?.capabilities?.slice(0, 4) || []}>
{(entry) => (
<div class="rounded-md border border-border-subtle px-3 py-2 text-xs">
<div class="font-medium text-base-content">
{formatCommercialBreakdownLabel(entry.key)}
</div>
<div class="mt-1 text-muted">
{formatCommercialBreakdownSummary(entry)}
</div>
</div>
)}
</For>
</Show>
</div>
</div>
</div>
</div>
<Show when={(props.diagnosticsData?.commercialFunnel?.notes?.length || 0) > 0}>
<div class="mt-4 rounded-md border border-amber-200 bg-amber-50 p-2 dark:border-amber-800 dark:bg-amber-900">
<div class="flex items-start gap-2 text-xs text-amber-700 dark:text-amber-300">
<AlertTriangle class="mt-0.5 h-4 w-4 flex-shrink-0" />
<div class="space-y-1">
<For each={props.diagnosticsData?.commercialFunnel?.notes || []}>
{(note) => <div>{note}</div>}
</For>
</div>
</div>
</div>
</Show>
<Show when={props.diagnosticsData?.commercialFunnel?.error}>
<div class="mt-3 rounded-md border border-red-200 bg-red-50 p-2 text-xs text-red-700 dark:border-red-800 dark:bg-red-900 dark:text-red-300">
{props.diagnosticsData?.commercialFunnel?.error}
</div>
</Show>
</Card>
</Show>
<Show when={props.diagnosticsData?.infrastructureOnboarding}>
<Card padding="md" class="lg:col-span-2">
<div class="mb-4 flex items-center gap-3 border-b border-border pb-3">
<div class="rounded-md bg-blue-100 p-2 dark:bg-blue-900">
<Server class="h-4 w-4 text-blue-600 dark:text-blue-400" />
</div>
<div>
<h4 class="text-sm font-semibold text-base-content">Infrastructure Onboarding</h4>
<p class="text-xs text-muted">
Local add-infrastructure activity over the last{' '}
{props.diagnosticsData?.infrastructureOnboarding?.windowDays ?? 0} days.
</p>
</div>
<div class="ml-auto">
<StatusBadge
status={
getDiagnosticsActivityBadge(props.diagnosticsData?.infrastructureOnboarding?.status)
.badgeStatus
}
label={
getDiagnosticsActivityBadge(props.diagnosticsData?.infrastructureOnboarding?.status)
.label
}
/>
</div>
</div>
<div class="grid gap-4 xl:grid-cols-[minmax(0,220px)_minmax(0,1fr)_minmax(0,1fr)]">
<div class="space-y-2 text-xs">
<MetricRow
label="Signals"
value={totalInfrastructureOnboardingSignals(
props.diagnosticsData?.infrastructureOnboarding?.summary,
)}
/>
<MetricRow
label="Opens"
value={props.diagnosticsData?.infrastructureOnboarding?.summary?.opened ?? 0}
/>
<MetricRow
label="API Paths"
value={props.diagnosticsData?.infrastructureOnboarding?.summary?.api_path_selected ?? 0}
/>
<MetricRow
label="Agent Paths"
value={props.diagnosticsData?.infrastructureOnboarding?.summary?.agent_path_selected ?? 0}
/>
<MetricRow
label="Detected Probes"
value={props.diagnosticsData?.infrastructureOnboarding?.summary?.probe_detected ?? 0}
/>
<MetricRow
label="No-Match Probes"
value={props.diagnosticsData?.infrastructureOnboarding?.summary?.probe_no_match ?? 0}
/>
<MetricRow
label="Credentials Opened"
value={props.diagnosticsData?.infrastructureOnboarding?.summary?.credentials_opened ?? 0}
/>
</div>
<div>
<div class="mb-2 flex items-center justify-between">
<h5 class="text-xs font-semibold uppercase tracking-wide text-muted">
Recent Daily Trend
</h5>
<span class="text-[11px] text-muted">UTC day buckets</span>
</div>
<div class="space-y-2">
<Show
when={(props.diagnosticsData?.infrastructureOnboarding?.daily?.length || 0) > 0}
fallback={<p class="text-xs text-muted">No daily onboarding activity recorded.</p>}
>
<For each={props.diagnosticsData?.infrastructureOnboarding?.daily?.slice(-7) || []}>
{(bucket) => (
<div class="rounded-md border border-border-subtle px-3 py-2 text-xs">
<div class="flex items-center justify-between gap-3">
<span class="font-medium text-base-content">
{formatCommercialDayLabel(bucket.day)}
</span>
<span class="text-muted">
Opens {bucket.opened} Credentials {bucket.credentials_opened} No Match{' '}
{bucket.probe_no_match}
</span>
</div>
</div>
)}
</For>
</Show>
</div>
</div>
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-1">
<div>
<div class="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">
Path Choices
</div>
<div class="space-y-2">
<Show
when={(props.diagnosticsData?.infrastructureOnboarding?.paths?.length || 0) > 0}
fallback={<p class="text-xs text-muted">No path selection recorded.</p>}
>
<For each={props.diagnosticsData?.infrastructureOnboarding?.paths?.slice(0, 4) || []}>
{(entry) => (
<div class="rounded-md border border-border-subtle px-3 py-2 text-xs">
<div class="font-medium text-base-content">
{formatInfrastructureOnboardingPathLabel(entry.key)}
</div>
<div class="mt-1 text-muted">{entry.count} selections</div>
</div>
)}
</For>
</Show>
</div>
</div>
<div>
<div class="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">
Top Platforms
</div>
<div class="space-y-2">
<Show
when={(props.diagnosticsData?.infrastructureOnboarding?.platforms?.length || 0) > 0}
fallback={<p class="text-xs text-muted">No platform selection recorded.</p>}
>
<For each={props.diagnosticsData?.infrastructureOnboarding?.platforms?.slice(0, 4) || []}>
{(entry) => (
<div class="rounded-md border border-border-subtle px-3 py-2 text-xs">
<div class="font-medium text-base-content">
{formatInfrastructureOnboardingPlatformLabel(entry.key)}
</div>
<div class="mt-1 text-muted">
{formatInfrastructureOnboardingPlatformSummary(entry)}
</div>
</div>
)}
</For>
</Show>
</div>
</div>
</div>
</div>
<Show when={(props.diagnosticsData?.infrastructureOnboarding?.notes?.length || 0) > 0}>
<div class="mt-4 rounded-md border border-amber-200 bg-amber-50 p-2 dark:border-amber-800 dark:bg-amber-900">
<div class="flex items-start gap-2 text-xs text-amber-700 dark:text-amber-300">
<AlertTriangle class="mt-0.5 h-4 w-4 flex-shrink-0" />
<div class="space-y-1">
<For each={props.diagnosticsData?.infrastructureOnboarding?.notes || []}>
{(note) => <div>{note}</div>}
</For>
</div>
</div>
</div>
</Show>
<Show when={props.diagnosticsData?.infrastructureOnboarding?.error}>
<div class="mt-3 rounded-md border border-red-200 bg-red-50 p-2 text-xs text-red-700 dark:border-red-800 dark:bg-red-900 dark:text-red-300">
{props.diagnosticsData?.infrastructureOnboarding?.error}
</div>
</Show>
</Card>
</Show>
<Show when={props.diagnosticsData?.apiTokens}>
<Card padding="md">
<div class="mb-4 flex items-center gap-3 border-b border-border pb-3">
@@ -834,21 +394,17 @@ export const DiagnosticsResultsPanel: Component<DiagnosticsResultsPanelProps> =
</div>
<div class="flex flex-wrap gap-2">
<span
class={`rounded px-2 py-1 text-xs font-medium ${
getStatusIndicatorBadgeToneClasses(
props.diagnosticsData?.alerts?.missingCooldown ? 'warning' : 'success',
)
}`}
class={`rounded px-2 py-1 text-xs font-medium ${getStatusIndicatorBadgeToneClasses(
props.diagnosticsData?.alerts?.missingCooldown ? 'warning' : 'success',
)}`}
>
Cooldown:{' '}
{props.diagnosticsData?.alerts?.missingCooldown ? 'Missing' : 'Configured'}
</span>
<span
class={`rounded px-2 py-1 text-xs font-medium ${
getStatusIndicatorBadgeToneClasses(
props.diagnosticsData?.alerts?.missingGroupingWindow ? 'warning' : 'success',
)
}`}
class={`rounded px-2 py-1 text-xs font-medium ${getStatusIndicatorBadgeToneClasses(
props.diagnosticsData?.alerts?.missingGroupingWindow ? 'warning' : 'success',
)}`}
>
Grouping:{' '}
{props.diagnosticsData?.alerts?.missingGroupingWindow ? 'Disabled' : 'Enabled'}
@@ -8,8 +8,8 @@ describe('DiagnosticsResultsPanel', () => {
cleanup();
});
it('renders the commercial funnel card with readable breakdown labels', () => {
const diagnosticsData: DiagnosticsData = {
it('does not render internal analytics fields from diagnostics payloads', () => {
const diagnosticsData = {
version: '6.0.0',
runtime: 'go',
uptime: 3600,
@@ -162,7 +162,7 @@ describe('DiagnosticsResultsPanel', () => {
agentsNeedingAttention: 1,
},
errors: [],
};
} as DiagnosticsData;
render(() => (
<DiagnosticsResultsPanel
@@ -172,16 +172,16 @@ describe('DiagnosticsResultsPanel', () => {
/>
));
expect(screen.getByText('Commercial Funnel')).toBeInTheDocument();
expect(screen.getByText('Pricing Views')).toBeInTheDocument();
expect(screen.getByText('Checkout Clicks')).toBeInTheDocument();
expect(screen.getByText('Self Hosted Plan')).toBeInTheDocument();
expect(screen.getByText('Settings Self Hosted Billing Compare Prompt')).toBeInTheDocument();
expect(screen.getByText(/Pricing 3/i)).toBeInTheDocument();
expect(screen.getByText('Infrastructure Onboarding')).toBeInTheDocument();
expect(screen.getByText('Credentials Opened')).toBeInTheDocument();
expect(screen.getByText('TrueNAS SCALE')).toBeInTheDocument();
expect(screen.getByText('API')).toBeInTheDocument();
expect(screen.queryByText('Commercial Funnel')).not.toBeInTheDocument();
expect(screen.queryByText('Pricing Views')).not.toBeInTheDocument();
expect(screen.queryByText('Checkout Clicks')).not.toBeInTheDocument();
expect(screen.queryByText('Self Hosted Plan')).not.toBeInTheDocument();
expect(
screen.queryByText('Settings Self Hosted Billing Compare Prompt'),
).not.toBeInTheDocument();
expect(screen.queryByText('Infrastructure Onboarding')).not.toBeInTheDocument();
expect(screen.queryByText('Credentials Opened')).not.toBeInTheDocument();
expect(screen.queryByText('TrueNAS SCALE')).not.toBeInTheDocument();
expect(screen.getByText('Docker / Podman agents')).toBeInTheDocument();
expect(screen.getByText('Agent-backed Docker / Podman monitoring')).toBeInTheDocument();
expect(screen.queryByText('Container Runtime Agents')).not.toBeInTheDocument();
@@ -2,102 +2,108 @@ import { describe, expect, it } from 'vitest';
import {
buildDiagnosticsExportFilename,
sanitizeDiagnosticsData,
stripInternalAnalyticsDiagnosticsFields,
type DiagnosticsData,
} from '@/components/Settings/diagnosticsModel';
const createDiagnosticsData = (): DiagnosticsData => ({
version: '6.0.0',
runtime: 'go',
uptime: 3600,
nodes: [
{
id: 'node-raw-id',
name: 'pve-01',
host: '10.0.0.5',
type: 'pve',
authMethod: 'token',
connected: false,
error: 'dial tcp 10.0.0.5:8006: connect: connection refused',
},
],
pbs: [
{
id: 'pbs-raw-id',
name: 'pbs-01',
host: '10.0.0.15',
connected: false,
error: 'Get https://10.0.0.15:8007: EOF',
},
],
system: {
os: 'linux',
arch: 'amd64',
goVersion: 'go1.25',
numCPU: 8,
numGoroutine: 32,
memoryMB: 128,
},
discovery: {
enabled: true,
configuredSubnet: '10.0.0.0/24',
activeSubnet: '10.0.1.0/24',
environmentOverride: 'PULSE_DISCOVERY_SUBNET=10.0.2.0/24',
subnetAllowlist: ['10.0.0.0/24'],
subnetBlocklist: ['10.0.3.0/24'],
history: [
const createDiagnosticsData = (): DiagnosticsData =>
({
version: '6.0.0',
runtime: 'go',
uptime: 3600,
nodes: [
{
startedAt: '2026-04-20T10:00:00Z',
completedAt: '2026-04-20T10:00:10Z',
duration: '10s',
durationMs: 10000,
subnet: '10.0.0.0/24',
serverCount: 4,
errorCount: 1,
blocklistLength: 1,
status: 'completed',
id: 'node-raw-id',
name: 'pve-01',
host: '10.0.0.5',
type: 'pve',
authMethod: 'token',
connected: false,
error: 'dial tcp 10.0.0.5:8006: connect: connection refused',
},
],
},
infrastructureOnboarding: {
enabled: true,
status: 'warning',
windowDays: 30,
summary: {
opened: 4,
api_path_selected: 2,
agent_path_selected: 1,
probe_detected: 1,
probe_no_match: 2,
probe_error: 0,
catalog_selected: 2,
credentials_opened: 1,
period: {
from: '2026-03-19T00:00:00Z',
to: '2026-04-18T00:00:00Z',
},
},
daily: [
pbs: [
{
day: '2026-04-18',
opened: 2,
api_path_selected: 1,
agent_path_selected: 0,
id: 'pbs-raw-id',
name: 'pbs-01',
host: '10.0.0.15',
connected: false,
error: 'Get https://10.0.0.15:8007: EOF',
},
],
system: {
os: 'linux',
arch: 'amd64',
goVersion: 'go1.25',
numCPU: 8,
numGoroutine: 32,
memoryMB: 128,
},
discovery: {
enabled: true,
configuredSubnet: '10.0.0.0/24',
activeSubnet: '10.0.1.0/24',
environmentOverride: 'PULSE_DISCOVERY_SUBNET=10.0.2.0/24',
subnetAllowlist: ['10.0.0.0/24'],
subnetBlocklist: ['10.0.3.0/24'],
history: [
{
startedAt: '2026-04-20T10:00:00Z',
completedAt: '2026-04-20T10:00:10Z',
duration: '10s',
durationMs: 10000,
subnet: '10.0.0.0/24',
serverCount: 4,
errorCount: 1,
blocklistLength: 1,
status: 'completed',
},
],
},
commercialFunnel: {
enabled: true,
summary: { pricing_viewed: 2, checkout_clicked: 1 },
},
infrastructureOnboarding: {
enabled: true,
status: 'warning',
windowDays: 30,
summary: {
opened: 4,
api_path_selected: 2,
agent_path_selected: 1,
probe_detected: 1,
probe_no_match: 1,
probe_no_match: 2,
probe_error: 0,
catalog_selected: 1,
catalog_selected: 2,
credentials_opened: 1,
period: {
from: '2026-03-19T00:00:00Z',
to: '2026-04-18T00:00:00Z',
},
},
],
paths: [{ key: 'api', count: 2 }],
platforms: [{ key: 'truenas', catalog_selected: 2, credentials_opened: 1 }],
notes: ['Some probed addresses did not match a supported API-backed platform.'],
},
errors: ['probe failed for 10.0.0.10 after timeout'],
});
daily: [
{
day: '2026-04-18',
opened: 2,
api_path_selected: 1,
agent_path_selected: 0,
probe_detected: 1,
probe_no_match: 1,
probe_error: 0,
catalog_selected: 1,
credentials_opened: 1,
},
],
paths: [{ key: 'api', count: 2 }],
platforms: [{ key: 'truenas', catalog_selected: 2, credentials_opened: 1 }],
notes: ['Some probed addresses did not match a supported API-backed platform.'],
},
errors: ['probe failed for 10.0.0.10 after timeout'],
}) as DiagnosticsData;
describe('diagnosticsModel', () => {
it('sanitizes infrastructure diagnostics without stripping onboarding analytics', () => {
it('sanitizes infrastructure diagnostics while stripping internal analytics fields', () => {
const raw = createDiagnosticsData();
const sanitized = sanitizeDiagnosticsData(raw);
@@ -134,13 +140,24 @@ describe('diagnosticsModel', () => {
}),
);
expect(sanitized.errors).toEqual(['probe failed for [REDACTED_IP] after timeout']);
expect(sanitized.infrastructureOnboarding).toEqual(raw.infrastructureOnboarding);
expect(sanitized).not.toHaveProperty('commercialFunnel');
expect(sanitized).not.toHaveProperty('infrastructureOnboarding');
});
it('strips internal analytics fields before diagnostics state or full export use', () => {
const stripped = stripInternalAnalyticsDiagnosticsFields(createDiagnosticsData());
expect(stripped).not.toHaveProperty('commercialFunnel');
expect(stripped).not.toHaveProperty('infrastructureOnboarding');
expect(stripped.nodes[0].host).toBe('10.0.0.5');
});
it('builds stable diagnostics export filenames', () => {
const now = new Date('2026-04-22T12:34:56Z');
expect(buildDiagnosticsExportFilename(false, now)).toBe('pulse-diagnostics-full-2026-04-22.json');
expect(buildDiagnosticsExportFilename(false, now)).toBe(
'pulse-diagnostics-full-2026-04-22.json',
);
expect(buildDiagnosticsExportFilename(true, now)).toBe(
'pulse-diagnostics-sanitized-2026-04-22.json',
);
@@ -504,15 +504,21 @@ describe('settings architecture guardrails', () => {
);
});
it('keeps diagnostics funnel rendering on the shared results/model boundary', () => {
expect(diagnosticsResultsPanelSource).toContain('Commercial Funnel');
expect(diagnosticsResultsPanelSource).toContain('Infrastructure Onboarding');
expect(diagnosticsResultsPanelSource).toContain('titleCaseDelimitedLabel');
it('keeps internal analytics off the user diagnostics boundary', () => {
expect(diagnosticsResultsPanelSource).not.toContain('Commercial Funnel');
expect(diagnosticsResultsPanelSource).not.toContain('Infrastructure Onboarding');
expect(diagnosticsResultsPanelSource).not.toContain('commercialFunnel');
expect(diagnosticsResultsPanelSource).not.toContain('infrastructureOnboarding');
expect(diagnosticsResultsPanelSource).not.toContain("apiFetchJSON('/api/diagnostics')");
expect(diagnosticsModelSource).toContain('export interface CommercialFunnelDiagnostic');
expect(diagnosticsModelSource).toContain('export interface CommercialFunnelSummary');
expect(diagnosticsModelSource).toContain('export interface InfrastructureOnboardingDiagnostic');
expect(diagnosticsModelSource).toContain('export interface InfrastructureOnboardingSummary');
expect(diagnosticsModelSource).toContain('stripInternalAnalyticsDiagnosticsFields');
expect(diagnosticsModelSource).not.toContain('export interface CommercialFunnelDiagnostic');
expect(diagnosticsModelSource).not.toContain('export interface CommercialFunnelSummary');
expect(diagnosticsModelSource).not.toContain(
'export interface InfrastructureOnboardingDiagnostic',
);
expect(diagnosticsModelSource).not.toContain(
'export interface InfrastructureOnboardingSummary',
);
});
});
@@ -8,63 +8,50 @@ type URLStaticWithBlobMethods = {
revokeObjectURL?: typeof URL.revokeObjectURL;
};
const createDiagnosticsData = (): DiagnosticsData => ({
version: '6.0.0',
runtime: 'go',
uptime: 3600,
nodes: [
{
id: 'node-a',
name: 'pve-01',
host: '10.0.0.5',
type: 'pve',
authMethod: 'token',
connected: false,
error: 'dial tcp 10.0.0.5:8006: connect: connection refused',
},
],
pbs: [],
system: {
os: 'linux',
arch: 'amd64',
goVersion: 'go1.25',
numCPU: 8,
numGoroutine: 32,
memoryMB: 128,
},
discovery: {
enabled: true,
configuredSubnet: '10.0.0.0/24',
activeSubnet: '10.0.1.0/24',
environmentOverride: 'PULSE_DISCOVERY_SUBNET=10.0.2.0/24',
subnetAllowlist: ['10.0.0.0/24'],
subnetBlocklist: [],
},
infrastructureOnboarding: {
enabled: true,
status: 'warning',
windowDays: 30,
summary: {
opened: 4,
api_path_selected: 2,
agent_path_selected: 1,
probe_detected: 1,
probe_no_match: 2,
probe_error: 0,
catalog_selected: 2,
credentials_opened: 1,
period: {
from: '2026-03-19T00:00:00Z',
to: '2026-04-18T00:00:00Z',
const createDiagnosticsData = (): DiagnosticsData =>
({
version: '6.0.0',
runtime: 'go',
uptime: 3600,
nodes: [
{
id: 'node-a',
name: 'pve-01',
host: '10.0.0.5',
type: 'pve',
authMethod: 'token',
connected: false,
error: 'dial tcp 10.0.0.5:8006: connect: connection refused',
},
],
pbs: [],
system: {
os: 'linux',
arch: 'amd64',
goVersion: 'go1.25',
numCPU: 8,
numGoroutine: 32,
memoryMB: 128,
},
daily: [],
paths: [{ key: 'api', count: 2 }],
platforms: [{ key: 'truenas', catalog_selected: 2, credentials_opened: 1 }],
notes: ['Some probed addresses did not match a supported API-backed platform.'],
},
errors: ['probe failed for 10.0.0.10 after timeout'],
});
discovery: {
enabled: true,
configuredSubnet: '10.0.0.0/24',
activeSubnet: '10.0.1.0/24',
environmentOverride: 'PULSE_DISCOVERY_SUBNET=10.0.2.0/24',
subnetAllowlist: ['10.0.0.0/24'],
subnetBlocklist: [],
},
errors: ['probe failed for 10.0.0.10 after timeout'],
commercialFunnel: {
enabled: true,
summary: { pricing_viewed: 4, checkout_clicked: 1 },
},
infrastructureOnboarding: {
enabled: true,
summary: { opened: 4, credentials_opened: 1 },
platforms: [{ key: 'truenas', catalog_selected: 2, credentials_opened: 1 }],
},
}) as DiagnosticsData;
const readBlobText = async (blob: Blob): Promise<string> =>
await new Promise((resolve, reject) => {
@@ -153,7 +140,7 @@ describe('useDiagnosticsPanelState', () => {
vi.resetModules();
});
it('exports full diagnostics with infrastructure onboarding analytics intact', async () => {
it('exports full diagnostics without internal analytics fields', async () => {
const diagnosticsData = createDiagnosticsData();
apiFetchJSONMock.mockResolvedValue(diagnosticsData);
@@ -161,9 +148,11 @@ describe('useDiagnosticsPanelState', () => {
await result.runDiagnostics();
await waitFor(() => expect(result.diagnosticsData()).toEqual(diagnosticsData));
await waitFor(() => expect(result.diagnosticsData()).not.toBeNull());
expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/diagnostics');
expect(showSuccessMock).toHaveBeenCalledWith('Diagnostics completed');
expect(result.diagnosticsData()).not.toHaveProperty('commercialFunnel');
expect(result.diagnosticsData()).not.toHaveProperty('infrastructureOnboarding');
await result.exportDiagnostics(false);
@@ -175,20 +164,14 @@ describe('useDiagnosticsPanelState', () => {
const payload = JSON.parse(
await readBlobText(createObjectURLMock.mock.calls[0][0] as Blob),
) as DiagnosticsData;
) as DiagnosticsData & Record<string, unknown>;
expect(payload.infrastructureOnboarding?.summary.credentials_opened).toBe(1);
expect(payload.infrastructureOnboarding?.platforms).toEqual([
expect.objectContaining({
key: 'truenas',
catalog_selected: 2,
credentials_opened: 1,
}),
]);
expect(payload.commercialFunnel).toBeUndefined();
expect(payload.infrastructureOnboarding).toBeUndefined();
expect(payload.nodes[0].host).toBe('10.0.0.5');
});
it('exports sanitized diagnostics while preserving onboarding funnel counts', async () => {
it('exports sanitized diagnostics without internal analytics fields', async () => {
apiFetchJSONMock.mockResolvedValue(createDiagnosticsData());
const { result } = renderHook(() => useDiagnosticsPanelState());
@@ -199,14 +182,12 @@ describe('useDiagnosticsPanelState', () => {
await result.exportDiagnostics(true);
expect(anchorClickMock).toHaveBeenCalledOnce();
expect(createdAnchor?.download).toBe(
`pulse-diagnostics-sanitized-${currentExportDate()}.json`,
);
expect(createdAnchor?.download).toBe(`pulse-diagnostics-sanitized-${currentExportDate()}.json`);
expect(showSuccessMock).toHaveBeenCalledWith('Diagnostics exported (sanitized)');
const payload = JSON.parse(
await readBlobText(createObjectURLMock.mock.calls[0][0] as Blob),
) as DiagnosticsData;
) as DiagnosticsData & Record<string, unknown>;
expect(payload.nodes[0]).toEqual(
expect.objectContaining({
@@ -222,20 +203,8 @@ describe('useDiagnosticsPanelState', () => {
}),
);
expect(payload.errors).toEqual(['probe failed for [REDACTED_IP] after timeout']);
expect(payload.infrastructureOnboarding?.summary).toEqual(
expect.objectContaining({
opened: 4,
api_path_selected: 2,
credentials_opened: 1,
}),
);
expect(payload.infrastructureOnboarding?.platforms).toEqual([
expect.objectContaining({
key: 'truenas',
catalog_selected: 2,
credentials_opened: 1,
}),
]);
expect(payload.commercialFunnel).toBeUndefined();
expect(payload.infrastructureOnboarding).toBeUndefined();
});
it('blocks export until diagnostics have been run', async () => {
@@ -101,90 +101,6 @@ export interface MetricsStoreDiagnostic {
error?: string;
}
export interface CommercialFunnelStageCounts {
pricing_viewed: number;
paywall_viewed: number;
trial_started: number;
upgrade_clicked: number;
checkout_clicked: number;
checkout_started: number;
checkout_completed: number;
license_activated: number;
license_activation_failed: number;
}
export interface CommercialFunnelSummary extends CommercialFunnelStageCounts {
period: {
from: string;
to: string;
};
}
export interface CommercialFunnelDayBreakdown extends CommercialFunnelStageCounts {
day: string;
}
export interface CommercialFunnelDimensionBreakdown extends CommercialFunnelStageCounts {
key: string;
}
export interface CommercialFunnelDiagnostic {
enabled: boolean;
status: 'active' | 'idle' | 'warning' | 'error' | 'unavailable';
windowDays: number;
summary: CommercialFunnelSummary;
daily: CommercialFunnelDayBreakdown[];
surfaces: CommercialFunnelDimensionBreakdown[];
capabilities: CommercialFunnelDimensionBreakdown[];
notes?: string[];
error?: string;
}
export interface InfrastructureOnboardingStageCounts {
opened: number;
api_path_selected: number;
agent_path_selected: number;
probe_detected: number;
probe_no_match: number;
probe_error: number;
catalog_selected: number;
credentials_opened: number;
}
export interface InfrastructureOnboardingSummary extends InfrastructureOnboardingStageCounts {
period: {
from: string;
to: string;
};
}
export interface InfrastructureOnboardingDayBreakdown extends InfrastructureOnboardingStageCounts {
day: string;
}
export interface InfrastructureOnboardingPathBreakdown {
key: string;
count: number;
}
export interface InfrastructureOnboardingPlatformBreakdown {
key: string;
catalog_selected: number;
credentials_opened: number;
}
export interface InfrastructureOnboardingDiagnostic {
enabled: boolean;
status: 'active' | 'idle' | 'warning' | 'error' | 'unavailable';
windowDays: number;
summary: InfrastructureOnboardingSummary;
daily: InfrastructureOnboardingDayBreakdown[];
paths: InfrastructureOnboardingPathBreakdown[];
platforms: InfrastructureOnboardingPlatformBreakdown[];
notes?: string[];
error?: string;
}
export interface AIChatDiagnostic {
enabled: boolean;
running: boolean;
@@ -205,8 +121,6 @@ export interface DiagnosticsData {
pbs: DiagnosticsPBS[];
system: SystemDiagnostic;
metricsStore?: MetricsStoreDiagnostic | null;
commercialFunnel?: CommercialFunnelDiagnostic | null;
infrastructureOnboarding?: InfrastructureOnboardingDiagnostic | null;
apiTokens?: APITokenDiagnostic | null;
dockerAgents?: DockerAgentDiagnostic | null;
alerts?: AlertsDiagnostic | null;
@@ -215,6 +129,19 @@ export interface DiagnosticsData {
errors: string[];
}
const INTERNAL_ANALYTICS_DIAGNOSTICS_FIELDS = [
'commercialFunnel',
'infrastructureOnboarding',
] as const;
export function stripInternalAnalyticsDiagnosticsFields(raw: DiagnosticsData): DiagnosticsData {
const data = JSON.parse(JSON.stringify(raw)) as DiagnosticsData & Record<string, unknown>;
for (const field of INTERNAL_ANALYTICS_DIAGNOSTICS_FIELDS) {
delete data[field];
}
return data;
}
export function formatUptime(seconds: number): string {
if (seconds < 60) return `${seconds}s`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
@@ -226,7 +153,7 @@ export function formatUptime(seconds: number): string {
}
export function sanitizeDiagnosticsData(raw: DiagnosticsData): DiagnosticsData {
const data: DiagnosticsData = JSON.parse(JSON.stringify(raw));
const data = stripInternalAnalyticsDiagnosticsFields(raw);
const ipv4Re = /\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(\/\d{1,2})?\b/g;
const redactString = (value: string): string => value.replace(ipv4Re, '[REDACTED_IP]');
@@ -4,6 +4,7 @@ import { showError, showSuccess } from '@/utils/toast';
import {
buildDiagnosticsExportFilename,
sanitizeDiagnosticsData,
stripInternalAnalyticsDiagnosticsFields,
type DiagnosticsData,
} from '@/components/Settings/diagnosticsModel';
@@ -16,7 +17,7 @@ export const useDiagnosticsPanelState = () => {
setLoading(true);
try {
const data = (await apiFetchJSON('/api/diagnostics')) as DiagnosticsData;
setDiagnosticsData(data);
setDiagnosticsData(stripInternalAnalyticsDiagnosticsFields(data));
showSuccess('Diagnostics completed');
} catch (error) {
showError(error instanceof Error ? error.message : 'Failed to run diagnostics');
@@ -34,7 +35,9 @@ export const useDiagnosticsPanelState = () => {
return;
}
const exportData = sanitize ? sanitizeDiagnosticsData(data) : data;
const exportData = sanitize
? sanitizeDiagnosticsData(data)
: stripInternalAnalyticsDiagnosticsFields(data);
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
-36
View File
@@ -7372,21 +7372,6 @@ func TestContract_HostedRelayConfigResponseJSONSnapshot(t *testing.T) {
func TestContract_DiagnosticsInfoJSONSnapshot(t *testing.T) {
payload := EmptyDiagnosticsInfo()
commercialFunnel := (&CommercialFunnelDiagnostic{
Enabled: true,
Status: "idle",
WindowDays: 30,
Summary: conversionFunnelSummary{
Period: struct {
From time.Time `json:"from"`
To time.Time `json:"to"`
}{
From: time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC),
To: time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC),
},
},
}).NormalizeCollections()
payload.CommercialFunnel = &commercialFunnel
got, err := json.Marshal(payload.NormalizeCollections())
if err != nil {
@@ -7400,27 +7385,6 @@ func TestContract_DiagnosticsInfoJSONSnapshot(t *testing.T) {
"nodes":[],
"pbs":[],
"system":{"os":"","arch":"","goVersion":"","numCPU":0,"numGoroutine":0,"memoryMB":0},
"commercialFunnel":{
"enabled":true,
"status":"idle",
"windowDays":30,
"summary":{
"pricing_viewed":0,
"paywall_viewed":0,
"trial_started":0,
"upgrade_clicked":0,
"checkout_clicked":0,
"checkout_started":0,
"checkout_completed":0,
"license_activated":0,
"license_activation_failed":0,
"period":{"from":"2026-04-01T00:00:00Z","to":"2026-05-01T00:00:00Z"}
},
"daily":[],
"surfaces":[],
"capabilities":[],
"notes":[]
},
"errors":[],
"nodeSnapshots":[],
"guestSnapshots":[],
+13 -234
View File
@@ -28,21 +28,19 @@ import (
// DiagnosticsInfo contains comprehensive diagnostic information
type DiagnosticsInfo struct {
Version string `json:"version"`
Runtime string `json:"runtime"`
Uptime float64 `json:"uptime"`
Nodes []NodeDiagnostic `json:"nodes"`
PBS []PBSDiagnostic `json:"pbs"`
System SystemDiagnostic `json:"system"`
MetricsStore *MetricsStoreDiagnostic `json:"metricsStore,omitempty"`
CommercialFunnel *CommercialFunnelDiagnostic `json:"commercialFunnel,omitempty"`
InfrastructureOnboarding *InfrastructureOnboardingDiagnostic `json:"infrastructureOnboarding,omitempty"`
Discovery *DiscoveryDiagnostic `json:"discovery,omitempty"`
APITokens *APITokenDiagnostic `json:"apiTokens,omitempty"`
DockerAgents *DockerAgentDiagnostic `json:"dockerAgents,omitempty"`
Alerts *AlertsDiagnostic `json:"alerts,omitempty"`
AIChat *AIChatDiagnostic `json:"aiChat,omitempty"`
Errors []string `json:"errors"`
Version string `json:"version"`
Runtime string `json:"runtime"`
Uptime float64 `json:"uptime"`
Nodes []NodeDiagnostic `json:"nodes"`
PBS []PBSDiagnostic `json:"pbs"`
System SystemDiagnostic `json:"system"`
MetricsStore *MetricsStoreDiagnostic `json:"metricsStore,omitempty"`
Discovery *DiscoveryDiagnostic `json:"discovery,omitempty"`
APITokens *APITokenDiagnostic `json:"apiTokens,omitempty"`
DockerAgents *DockerAgentDiagnostic `json:"dockerAgents,omitempty"`
Alerts *AlertsDiagnostic `json:"alerts,omitempty"`
AIChat *AIChatDiagnostic `json:"aiChat,omitempty"`
Errors []string `json:"errors"`
// NodeSnapshots captures the raw memory payload and derived usage Pulse last observed per node.
NodeSnapshots []monitoring.NodeMemorySnapshot `json:"nodeSnapshots"`
// GuestSnapshots captures recent per-guest memory breakdowns (VM/LXC) with the raw Proxmox fields.
@@ -89,14 +87,6 @@ func (d DiagnosticsInfo) NormalizeCollections() DiagnosticsInfo {
normalized := d.MetricsStore.NormalizeCollections()
d.MetricsStore = &normalized
}
if d.CommercialFunnel != nil {
normalized := d.CommercialFunnel.NormalizeCollections()
d.CommercialFunnel = &normalized
}
if d.InfrastructureOnboarding != nil {
normalized := d.InfrastructureOnboarding.NormalizeCollections()
d.InfrastructureOnboarding = &normalized
}
if d.Discovery != nil {
normalized := d.Discovery.NormalizeCollections()
d.Discovery = &normalized
@@ -214,62 +204,6 @@ func (d MetricsStoreDiagnostic) NormalizeCollections() MetricsStoreDiagnostic {
return d
}
type CommercialFunnelDiagnostic struct {
Enabled bool `json:"enabled"`
Status string `json:"status"`
WindowDays int `json:"windowDays"`
Summary conversionFunnelSummary `json:"summary"`
Daily []conversionFunnelDayBreakdown `json:"daily"`
Surfaces []conversionFunnelDimensionBreakdown `json:"surfaces"`
Capabilities []conversionFunnelDimensionBreakdown `json:"capabilities"`
Notes []string `json:"notes"`
Error string `json:"error,omitempty"`
}
func (d CommercialFunnelDiagnostic) NormalizeCollections() CommercialFunnelDiagnostic {
if d.Daily == nil {
d.Daily = []conversionFunnelDayBreakdown{}
}
if d.Surfaces == nil {
d.Surfaces = []conversionFunnelDimensionBreakdown{}
}
if d.Capabilities == nil {
d.Capabilities = []conversionFunnelDimensionBreakdown{}
}
if d.Notes == nil {
d.Notes = []string{}
}
return d
}
type InfrastructureOnboardingDiagnostic struct {
Enabled bool `json:"enabled"`
Status string `json:"status"`
WindowDays int `json:"windowDays"`
Summary conversionInfrastructureOnboardingSummary `json:"summary"`
Daily []conversionInfrastructureOnboardingDayBreakdown `json:"daily"`
Paths []conversionInfrastructureOnboardingPathBreakdown `json:"paths"`
Platforms []conversionInfrastructureOnboardingPlatformBreakdown `json:"platforms"`
Notes []string `json:"notes"`
Error string `json:"error,omitempty"`
}
func (d InfrastructureOnboardingDiagnostic) NormalizeCollections() InfrastructureOnboardingDiagnostic {
if d.Daily == nil {
d.Daily = []conversionInfrastructureOnboardingDayBreakdown{}
}
if d.Paths == nil {
d.Paths = []conversionInfrastructureOnboardingPathBreakdown{}
}
if d.Platforms == nil {
d.Platforms = []conversionInfrastructureOnboardingPlatformBreakdown{}
}
if d.Notes == nil {
d.Notes = []string{}
}
return d
}
func isFallbackMemorySource(source string) bool {
return monitoring.MemorySourceIsFallback(source)
}
@@ -447,153 +381,7 @@ func buildMetricsStoreDiagnostic(monitor *monitoring.Monitor) *MetricsStoreDiagn
}
}
func buildCommercialFunnelDiagnostic(ctx context.Context, store *conversionStore, now time.Time) *CommercialFunnelDiagnostic {
diag := (&CommercialFunnelDiagnostic{
Enabled: store != nil,
Status: "idle",
WindowDays: commercialFunnelDiagnosticsWindowDays,
Notes: []string{},
}).NormalizeCollections()
if store == nil {
diag.Status = "unavailable"
diag.Error = "conversion store not initialized"
diag.Notes = append(diag.Notes, "Local upgrade metrics are unavailable on this instance.")
return &diag
}
to := now.UTC().Truncate(24 * time.Hour).Add(24 * time.Hour)
from := to.AddDate(0, 0, -commercialFunnelDiagnosticsWindowDays)
orgID := diagnosticsCommercialOrgID(ctx)
report, err := store.FunnelReport(orgID, from, to)
if err != nil {
diag.Status = "error"
diag.Error = "failed to query local upgrade metrics"
diag.Notes = append(diag.Notes, "Diagnostics could not read the local upgrade funnel store.")
return &diag
}
diag.Summary = report.Summary
diag.Daily = report.Daily
diag.Surfaces = report.Surfaces
diag.Capabilities = report.Capabilities
totalSignal := report.Summary.PricingViewed +
report.Summary.PaywallViewed +
report.Summary.TrialStarted +
report.Summary.UpgradeClicked +
report.Summary.CheckoutClicked +
report.Summary.CheckoutStarted +
report.Summary.CheckoutCompleted +
report.Summary.LicenseActivated +
report.Summary.LicenseActivationFailed
switch {
case totalSignal == 0:
diag.Status = "idle"
diag.Notes = append(diag.Notes, "No local upgrade funnel activity was recorded in the last 30 days.")
case report.Summary.LicenseActivationFailed > 0:
diag.Status = "warning"
diag.Notes = append(diag.Notes, "At least one local activation attempt failed in the current window.")
case report.Summary.CheckoutClicked > 0 && report.Summary.LicenseActivated == 0:
diag.Status = "warning"
diag.Notes = append(diag.Notes, "Checkout interest is present, but no local activation completed in the current window.")
case report.Summary.LicenseActivated > 0:
diag.Status = "active"
diag.Notes = append(diag.Notes, "Local pricing and activation events show at least one completed conversion in the current window.")
default:
diag.Status = "active"
}
if report.Summary.PricingViewed > 0 && report.Summary.CheckoutClicked == 0 {
diag.Notes = append(diag.Notes, "Pricing views are not yet turning into checkout clicks.")
}
if report.Summary.CheckoutClicked > report.Summary.CheckoutStarted {
diag.Notes = append(diag.Notes, "Some checkout clicks leave the app before a local checkout-start event is recorded.")
}
return &diag
}
func buildInfrastructureOnboardingDiagnostic(
ctx context.Context,
store *conversionStore,
now time.Time,
) *InfrastructureOnboardingDiagnostic {
diag := (&InfrastructureOnboardingDiagnostic{
Enabled: store != nil,
Status: "idle",
WindowDays: infrastructureOnboardingDiagnosticsWindowDays,
Notes: []string{},
}).NormalizeCollections()
if store == nil {
diag.Status = "unavailable"
diag.Error = "conversion store not initialized"
diag.Notes = append(diag.Notes, "Local infrastructure onboarding analytics are unavailable on this instance.")
return &diag
}
to := now.UTC().Truncate(24 * time.Hour).Add(24 * time.Hour)
from := to.AddDate(0, 0, -infrastructureOnboardingDiagnosticsWindowDays)
orgID := diagnosticsCommercialOrgID(ctx)
report, err := store.InfrastructureOnboardingReport(orgID, from, to)
if err != nil {
diag.Status = "error"
diag.Error = "failed to query local infrastructure onboarding analytics"
diag.Notes = append(diag.Notes, "Diagnostics could not read the local infrastructure onboarding report.")
return &diag
}
diag.Summary = report.Summary
diag.Daily = report.Daily
diag.Paths = report.Paths
diag.Platforms = report.Platforms
totalSignal := report.Summary.Opened +
report.Summary.APIPathSelected +
report.Summary.AgentPathSelected +
report.Summary.ProbeDetected +
report.Summary.ProbeNoMatch +
report.Summary.ProbeError +
report.Summary.CatalogSelected +
report.Summary.CredentialsOpened
switch {
case totalSignal == 0:
diag.Status = "idle"
diag.Notes = append(diag.Notes, "No infrastructure onboarding activity was recorded in the last 30 days.")
case report.Summary.Opened > 0 && report.Summary.CredentialsOpened == 0:
diag.Status = "warning"
diag.Notes = append(diag.Notes, "Infrastructure onboarding sessions are not reaching credential handoff in the current window.")
case report.Summary.ProbeNoMatch > report.Summary.ProbeDetected:
diag.Status = "warning"
diag.Notes = append(diag.Notes, "More probed addresses miss than detect a supported API-backed platform in the current window.")
case report.Summary.CredentialsOpened > 0:
diag.Status = "active"
diag.Notes = append(diag.Notes, "Infrastructure onboarding reached at least one credential handoff in the current window.")
default:
diag.Status = "active"
}
if report.Summary.ProbeNoMatch > 0 {
diag.Notes = append(diag.Notes, "Some probed addresses did not match a supported API-backed platform.")
}
if report.Summary.Opened > report.Summary.CredentialsOpened && report.Summary.CredentialsOpened > 0 {
diag.Notes = append(diag.Notes, "Some onboarding sessions drop before credentials open.")
}
if report.Summary.AgentPathSelected > 0 && report.Summary.APIPathSelected == 0 {
diag.Notes = append(diag.Notes, "Current onboarding activity is exclusively using the agent path.")
}
return &diag
}
const diagnosticsCacheTTL = 45 * time.Second
const commercialFunnelDiagnosticsWindowDays = 30
const infrastructureOnboardingDiagnosticsWindowDays = 30
type cachedDiagnosticsEntry struct {
diag DiagnosticsInfo
@@ -636,13 +424,6 @@ func diagnosticsScopeKey(ctx context.Context) string {
return "__default__"
}
func diagnosticsCommercialOrgID(ctx context.Context) string {
if orgID := strings.TrimSpace(GetOrgID(ctx)); orgID != "" {
return orgID
}
return "default"
}
// NodeDiagnostic contains diagnostic info for a Proxmox node
type NodeDiagnostic struct {
ID string `json:"id"`
@@ -1004,8 +785,6 @@ func (r *Router) computeDiagnostics(ctx context.Context) DiagnosticsInfo {
diag.APITokens = buildAPITokenDiagnostic(r.config, r.monitor)
diag.MetricsStore = buildMetricsStoreDiagnostic(r.monitor)
diag.CommercialFunnel = buildCommercialFunnelDiagnostic(ctx, r.conversionStore, time.Now().UTC())
diag.InfrastructureOnboarding = buildInfrastructureOnboardingDiagnostic(ctx, r.conversionStore, time.Now().UTC())
// Test each configured node
for _, node := range r.config.PVEInstances {
+10 -154
View File
@@ -5,7 +5,6 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
@@ -13,7 +12,6 @@ import (
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
agentsdocker "github.com/rcourtman/pulse-go-rewrite/pkg/agents/docker"
pkglicensing "github.com/rcourtman/pulse-go-rewrite/pkg/licensing"
"github.com/rcourtman/pulse-go-rewrite/pkg/proxmox"
)
@@ -209,12 +207,6 @@ func TestComputeDiagnostics_Basic(t *testing.T) {
if diag.MetricsStore == nil {
t.Fatalf("expected metrics store diagnostics")
}
if diag.CommercialFunnel == nil {
t.Fatalf("expected commercial funnel diagnostics")
}
if diag.InfrastructureOnboarding == nil {
t.Fatalf("expected infrastructure onboarding diagnostics")
}
if diag.APITokens == nil {
t.Fatalf("expected api token diagnostics")
}
@@ -224,158 +216,22 @@ func TestComputeDiagnostics_Basic(t *testing.T) {
if diag.AIChat == nil {
t.Fatalf("expected ai chat diagnostics")
}
}
func TestComputeDiagnostics_CommercialFunnelUsesOrgScopedConversionReport(t *testing.T) {
cfg := &config.Config{DataPath: t.TempDir()}
monitor := newMonitorForDiagnostics(t, cfg)
store, err := pkglicensing.NewConversionStore(filepath.Join(t.TempDir(), "conversion.db"))
payload, err := json.Marshal(diag.NormalizeCollections())
if err != nil {
t.Fatalf("NewConversionStore() error = %v", err)
t.Fatalf("marshal diagnostics: %v", err)
}
defer store.Close()
base := time.Now().UTC().Truncate(time.Hour).Add(-6 * time.Hour)
for _, event := range []pkglicensing.StoredConversionEvent{
{
OrgID: "org-a",
EventType: pkglicensing.EventPricingViewed,
Surface: "settings_self_hosted_billing_plan",
Capability: "self_hosted_plan",
IdempotencyKey: "org-a:pricing",
CreatedAt: base,
},
{
OrgID: "org-a",
EventType: pkglicensing.EventCheckoutClicked,
Surface: "settings_self_hosted_billing_compare_prompt",
Capability: "self_hosted_plan",
IdempotencyKey: "org-a:click",
CreatedAt: base.Add(time.Hour),
},
{
OrgID: "org-a",
EventType: pkglicensing.EventLicenseActivated,
Surface: "license_api",
Capability: "self_hosted_plan",
IdempotencyKey: "org-a:activated",
CreatedAt: base.Add(2 * time.Hour),
},
{
OrgID: "org-b",
EventType: pkglicensing.EventPricingViewed,
Surface: "paywall_modal",
Capability: "relay",
IdempotencyKey: "org-b:pricing",
CreatedAt: base,
},
for _, forbidden := range []string{
"commercialFunnel",
"infrastructureOnboarding",
"pricing_viewed",
"checkout_clicked",
"credentials_opened",
} {
if err := store.Record(event); err != nil {
t.Fatalf("Record(%s) error = %v", event.IdempotencyKey, err)
if strings.Contains(string(payload), forbidden) {
t.Fatalf("customer diagnostics leaked internal analytics field %q: %s", forbidden, payload)
}
}
router := &Router{config: cfg, monitor: monitor, conversionStore: store}
ctx := context.WithValue(context.Background(), OrgIDContextKey, "org-a")
diag := router.computeDiagnostics(ctx)
if diag.CommercialFunnel == nil {
t.Fatalf("expected commercial funnel diagnostics")
}
if diag.CommercialFunnel.Summary.PricingViewed != 1 {
t.Fatalf("PricingViewed = %d, want 1", diag.CommercialFunnel.Summary.PricingViewed)
}
if diag.CommercialFunnel.Summary.CheckoutClicked != 1 {
t.Fatalf("CheckoutClicked = %d, want 1", diag.CommercialFunnel.Summary.CheckoutClicked)
}
if diag.CommercialFunnel.Summary.LicenseActivated != 1 {
t.Fatalf("LicenseActivated = %d, want 1", diag.CommercialFunnel.Summary.LicenseActivated)
}
if len(diag.CommercialFunnel.Surfaces) == 0 || diag.CommercialFunnel.Surfaces[0].Key != "settings_self_hosted_billing_compare_prompt" {
t.Fatalf("unexpected surfaces breakdown: %+v", diag.CommercialFunnel.Surfaces)
}
}
func TestComputeDiagnostics_InfrastructureOnboardingUsesOrgScopedReport(t *testing.T) {
cfg := &config.Config{DataPath: t.TempDir()}
monitor := newMonitorForDiagnostics(t, cfg)
store, err := pkglicensing.NewConversionStore(filepath.Join(t.TempDir(), "conversion.db"))
if err != nil {
t.Fatalf("NewConversionStore() error = %v", err)
}
defer store.Close()
base := time.Now().UTC().Truncate(time.Hour).Add(-6 * time.Hour)
for _, event := range []pkglicensing.StoredConversionEvent{
{
OrgID: "org-a",
EventType: pkglicensing.EventInfrastructureOnboardingOpened,
Surface: "settings_infrastructure_add",
IdempotencyKey: "org-a:opened",
CreatedAt: base,
},
{
OrgID: "org-a",
EventType: pkglicensing.EventInfrastructureOnboardingPathSelected,
Surface: "settings_infrastructure_add",
Capability: "api",
IdempotencyKey: "org-a:path",
CreatedAt: base.Add(time.Hour),
},
{
OrgID: "org-a",
EventType: pkglicensing.EventInfrastructureOnboardingProbeResult,
Surface: "settings_infrastructure_add",
Capability: "no-match",
IdempotencyKey: "org-a:probe",
CreatedAt: base.Add(2 * time.Hour),
},
{
OrgID: "org-a",
EventType: pkglicensing.EventInfrastructureOnboardingCredentialsOpened,
Surface: "settings_infrastructure_add",
Capability: "agent",
IdempotencyKey: "org-a:credentials",
CreatedAt: base.Add(3 * time.Hour),
},
{
OrgID: "org-b",
EventType: pkglicensing.EventInfrastructureOnboardingOpened,
Surface: "settings_infrastructure_add",
IdempotencyKey: "org-b:opened",
CreatedAt: base,
},
} {
if err := store.Record(event); err != nil {
t.Fatalf("Record(%s) error = %v", event.IdempotencyKey, err)
}
}
router := &Router{config: cfg, monitor: monitor, conversionStore: store}
ctx := context.WithValue(context.Background(), OrgIDContextKey, "org-a")
diag := router.computeDiagnostics(ctx)
if diag.InfrastructureOnboarding == nil {
t.Fatalf("expected infrastructure onboarding diagnostics")
}
if diag.InfrastructureOnboarding.Summary.Opened != 1 {
t.Fatalf("Opened = %d, want 1", diag.InfrastructureOnboarding.Summary.Opened)
}
if diag.InfrastructureOnboarding.Summary.APIPathSelected != 1 {
t.Fatalf("APIPathSelected = %d, want 1", diag.InfrastructureOnboarding.Summary.APIPathSelected)
}
if diag.InfrastructureOnboarding.Summary.ProbeNoMatch != 1 {
t.Fatalf("ProbeNoMatch = %d, want 1", diag.InfrastructureOnboarding.Summary.ProbeNoMatch)
}
if diag.InfrastructureOnboarding.Summary.CredentialsOpened != 1 {
t.Fatalf("CredentialsOpened = %d, want 1", diag.InfrastructureOnboarding.Summary.CredentialsOpened)
}
if len(diag.InfrastructureOnboarding.Paths) == 0 || diag.InfrastructureOnboarding.Paths[0].Key != "api" {
t.Fatalf("unexpected path breakdown: %+v", diag.InfrastructureOnboarding.Paths)
}
if diag.InfrastructureOnboarding.Status != "warning" {
t.Fatalf("Status = %q, want warning", diag.InfrastructureOnboarding.Status)
}
}
func TestBuildAPITokenDiagnostic_WithDockerUsage(t *testing.T) {
+13 -53
View File
@@ -2,66 +2,26 @@ package api
import (
"encoding/json"
"strings"
"testing"
"time"
)
func TestContract_DiagnosticsInfoInfrastructureOnboardingJSONSnapshot(t *testing.T) {
func TestContract_DiagnosticsInfoExcludesInternalAnalytics(t *testing.T) {
payload := EmptyDiagnosticsInfo()
infrastructureOnboarding := (&InfrastructureOnboardingDiagnostic{
Enabled: true,
Status: "idle",
WindowDays: 30,
Summary: conversionInfrastructureOnboardingSummary{
Period: struct {
From time.Time `json:"from"`
To time.Time `json:"to"`
}{
From: time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC),
To: time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC),
},
},
}).NormalizeCollections()
payload.InfrastructureOnboarding = &infrastructureOnboarding
got, err := json.Marshal(payload.NormalizeCollections())
if err != nil {
t.Fatalf("marshal diagnostics onboarding info: %v", err)
t.Fatalf("marshal diagnostics info: %v", err)
}
const want = `{
"version":"",
"runtime":"",
"uptime":0,
"nodes":[],
"pbs":[],
"system":{"os":"","arch":"","goVersion":"","numCPU":0,"numGoroutine":0,"memoryMB":0},
"infrastructureOnboarding":{
"enabled":true,
"status":"idle",
"windowDays":30,
"summary":{
"opened":0,
"api_path_selected":0,
"agent_path_selected":0,
"probe_detected":0,
"probe_no_match":0,
"probe_error":0,
"catalog_selected":0,
"credentials_opened":0,
"period":{"from":"2026-04-01T00:00:00Z","to":"2026-05-01T00:00:00Z"}
},
"daily":[],
"paths":[],
"platforms":[],
"notes":[]
},
"errors":[],
"nodeSnapshots":[],
"guestSnapshots":[],
"memorySources":[],
"memorySourceBreakdown":[]
}`
assertJSONSnapshot(t, got, want)
for _, forbidden := range []string{
"commercialFunnel",
"infrastructureOnboarding",
"pricing_viewed",
"credentials_opened",
} {
if strings.Contains(string(got), forbidden) {
t.Fatalf("diagnostics contract leaked internal analytics field %q: %s", forbidden, got)
}
}
}
-77
View File
@@ -2,7 +2,6 @@ package api
import (
"context"
"path/filepath"
"reflect"
"testing"
"time"
@@ -12,7 +11,6 @@ import (
discoverysvc "github.com/rcourtman/pulse-go-rewrite/internal/discovery"
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
pkgdiscovery "github.com/rcourtman/pulse-go-rewrite/pkg/discovery"
pkglicensing "github.com/rcourtman/pulse-go-rewrite/pkg/licensing"
)
func TestClassifyMemorySourceTrust(t *testing.T) {
@@ -346,81 +344,6 @@ func TestComputeDiagnostics_DiscoveryUsesStructuredErrorOwnership(t *testing.T)
}
}
func TestBuildCommercialFunnelDiagnostic_UsesScopedLocalReport(t *testing.T) {
store, err := pkglicensing.NewConversionStore(filepath.Join(t.TempDir(), "conversion.db"))
if err != nil {
t.Fatalf("NewConversionStore() error = %v", err)
}
defer store.Close()
now := time.Now().UTC().Truncate(time.Hour)
windowEnd := now.Truncate(24 * time.Hour).Add(24 * time.Hour)
windowStart := windowEnd.AddDate(0, 0, -2)
for _, event := range []pkglicensing.StoredConversionEvent{
{
OrgID: "org-a",
EventType: pkglicensing.EventPricingViewed,
Surface: "settings_self_hosted_billing_plan",
Capability: "self_hosted_plan",
IdempotencyKey: "diag:pricing",
CreatedAt: windowStart.Add(2 * time.Hour),
},
{
OrgID: "org-a",
EventType: pkglicensing.EventCheckoutClicked,
Surface: "settings_self_hosted_billing_compare_prompt",
Capability: "self_hosted_plan",
IdempotencyKey: "diag:checkout",
CreatedAt: windowStart.Add(3 * time.Hour),
},
{
OrgID: "org-a",
EventType: pkglicensing.EventLicenseActivated,
Surface: "license_api",
Capability: "self_hosted_plan",
IdempotencyKey: "diag:activated",
CreatedAt: windowStart.Add(4 * time.Hour),
},
{
OrgID: "org-b",
EventType: pkglicensing.EventPricingViewed,
Surface: "paywall_modal",
Capability: "relay",
IdempotencyKey: "diag:other-org",
CreatedAt: windowStart.Add(2 * time.Hour),
},
} {
if err := store.Record(event); err != nil {
t.Fatalf("Record(%s) error = %v", event.IdempotencyKey, err)
}
}
diag := buildCommercialFunnelDiagnostic(
context.WithValue(context.Background(), OrgIDContextKey, "org-a"),
store,
now,
)
if diag == nil {
t.Fatal("expected commercial funnel diagnostics")
}
if diag.Status != "active" {
t.Fatalf("Status = %q, want active", diag.Status)
}
if diag.Summary.PricingViewed != 1 || diag.Summary.CheckoutClicked != 1 || diag.Summary.LicenseActivated != 1 {
t.Fatalf("unexpected summary: %+v", diag.Summary)
}
if len(diag.Daily) < 2 {
t.Fatalf("len(Daily) = %d, want >= 2", len(diag.Daily))
}
if len(diag.Surfaces) == 0 || diag.Surfaces[0].Key != "settings_self_hosted_billing_compare_prompt" {
t.Fatalf("unexpected surfaces breakdown: %+v", diag.Surfaces)
}
if len(diag.Capabilities) == 0 || diag.Capabilities[0].Key != "self_hosted_plan" {
t.Fatalf("unexpected capabilities breakdown: %+v", diag.Capabilities)
}
}
func setDiagnosticsUnexportedField(t *testing.T, target interface{}, fieldName string, value interface{}) {
t.Helper()