mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Remove internal analytics from diagnostics
This commit is contained in:
@@ -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'}
|
||||
|
||||
+13
-13
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+54
-85
@@ -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');
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user