Remove customer commercial analytics wrappers

This commit is contained in:
rcourtman
2026-04-30 11:46:16 +01:00
parent 7a48cd4afd
commit daf825dee6
84 changed files with 289 additions and 912 deletions
@@ -558,6 +558,11 @@ profile and assignment columns, but embedded table framing must route through
first-session states must keep `Add infrastructure` visible for both
API-backed and agent-managed next systems instead of reviving separate
`Platform connections` and `Infrastructure Install` branches. The
completion panel, infrastructure installer, install state, and agent-profile
settings surfaces must also stay free of local browser commercial or
onboarding metrics wrappers. Lifecycle surfaces may navigate to canonical
destinations, but Pulse Account and server-owned reporting routes own
commercial event capture.
API-backed versus agent-managed classification must come from the governed
onboarding paths in
`docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json` through
@@ -143,6 +143,11 @@ runtime cost control, and shared AI transport surfaces.
but frontend comments, API denial messages, runtime logs, status labels,
CLI help, and commercial prompts that describe the capability must use safe
remediation or remediation wording.
12. Keep AI control-level presentation runtime-owned rather than tier-owned.
`frontend-modern/src/utils/aiControlLevelPresentation.ts` and
`frontend-modern/src/components/Settings/AIRuntimeControlsSection.tsx`
may describe approval posture, but must not add Pro-badge suffixes or
local commercial tracking around those runtime controls.
## Current State
@@ -57,6 +57,11 @@ operator-facing alert routing behavior for live runtime alerts.
2. Add typed collector/builders in `internal/alerts/alerts.go`
3. Add identity/persistence updates through canonical alert helpers only
4. Add or change alert history persistence through `internal/alerts/history.go` using normalized owned storage roots and fixed storage leaves only
5. Add or change locked alert-investigation commercial handoff behavior through
`frontend-modern/src/components/Alerts/InvestigateAlertButton.tsx` while
preserving the shared upgrade-navigation contract; the alert surface may
route to the canonical destination, but must not emit browser-local upgrade
metrics or present Pro-required copy when prompt suppression applies.
## Forbidden Paths
@@ -116,6 +116,13 @@ admin-only local commercial reporting routes.
`frontend-modern/src/utils/sourcePlatforms.ts` rather than page-local
wording, because those install choices set API-token and install-command
expectations for the operator.
That same browser/API boundary must not retain customer-side commercial or
onboarding telemetry wrappers around infrastructure operations. Pulse
Account and the server-owned commercial reporting routes own commercial
event ingestion; the infrastructure operations model and install state may
navigate to canonical destinations, but must not import or call local
`upgradeMetrics`, `conversionEvents`, or infrastructure onboarding metrics
wrappers.
13. `frontend-modern/src/components/Settings/NodeModalAuthenticationSection.tsx` shared with `agent-lifecycle`: the node setup authentication section is both an agent lifecycle control surface and a shared API-backed install/setup contract boundary.
16. `frontend-modern/src/components/Settings/NodeModalBasicInfoSection.tsx` shared with `agent-lifecycle`: the node setup basic-info section is both an agent lifecycle control surface and a shared API-backed install/setup contract boundary.
17. `frontend-modern/src/components/Settings/nodeModalModel.ts` shared with `agent-lifecycle`: the pure node setup modal model is both an agent lifecycle control surface and a shared API-backed install/setup contract boundary.
@@ -1818,7 +1825,9 @@ rather than becoming general authenticated product reads. Authenticated event
ingestion at `/api/upgrade-metrics/events` may remain only as compatibility or
admin-owned ingestion plumbing; customer frontend surfaces must not call it for
pricing, checkout, paywall, commercial funnel, or infrastructure-onboarding
signals. Reporting and control stay on the privileged settings boundary.
signals, and must not keep customer-side commercial/onboarding metrics wrapper
modules as a compatibility layer. Reporting and control stay on the privileged
settings boundary.
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
@@ -528,11 +528,12 @@ runtime gating as separate unlinked claims.
14. Keep self-hosted commercial funnel stage ownership out of the customer
frontend. `pkg/licensing/conversion_events.go` and
`pkg/licensing/conversion_store.go` may retain server/admin compatibility
handling for local commercial reporting, while
`frontend-modern/src/utils/upgradeMetrics.ts` is a compatibility no-op and
must not POST in-app `Plans & Billing`, pricing, checkout, paywall, or
onboarding events from browser product surfaces. `pulse-pro:license-server/v6_checkout.go`
owns the Pulse Account handoff equivalents bound to `portal_handoff_id`.
handling for local commercial reporting, while the customer frontend must
not contain `upgradeMetrics`, `conversionEvents`, or infrastructure
onboarding metrics wrappers or call sites at all. In-app `Plans & Billing`,
pricing, checkout, paywall, or onboarding surfaces must not emit browser
product events. `pulse-pro:license-server/v6_checkout.go` owns the Pulse
Account handoff equivalents bound to `portal_handoff_id`.
Pulse must not infer those portal stages from referrer state, and the
commercial service must keep those self-hosted handoffs on release track
`v6` even while the public site remains on `v5` before GA.
@@ -1906,16 +1907,17 @@ routes rather than a package-wide `pkg/licensing/` fallback.
That same conversion-telemetry boundary now keeps self-hosted commercial
progression out of customer-side browser analytics.
`pkg/licensing/conversion_events.go` and `pkg/licensing/conversion_store.go`
may own local server/admin reporting compatibility, while
`frontend-modern/src/utils/upgradeMetrics.ts` remains a compatibility no-op
and must not emit local `pricing_viewed`, `checkout_clicked`, paywall,
commercial funnel, or onboarding events from the in-app `Plans & Billing` plan
surface. `pulse-pro:license-server/v6_checkout.go` owns the Pulse Account
handoff equivalents bound to `portal_handoff_id` and the canonical checkout
intent. 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.
may own local server/admin reporting compatibility, while customer frontend
source must not retain `upgradeMetrics`, `conversionEvents`, infrastructure
onboarding metrics wrappers, or their call sites. The browser app must not emit
local `pricing_viewed`, `checkout_clicked`, paywall, commercial funnel, or
onboarding events from the in-app `Plans & Billing` plan surface.
`pulse-pro:license-server/v6_checkout.go` owns the Pulse Account handoff
equivalents bound to `portal_handoff_id` and the canonical checkout intent.
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 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
@@ -727,6 +727,11 @@ work extends shared components instead of creating new local variants.
`frontend-modern/src/components/Settings/selfHostedBillingPresentation.ts`
instead of importing generic commercial presentation helpers directly into
hosted settings route shells.
Contextual settings feature gates must use capability-owned presentation
helpers and neutral paid-plan copy. They must not reintroduce `Pro feature`
badge titles, Pro-suffixed option labels, monitored-system limit claims, or
browser-local commercial/onboarding metrics wrappers in SSO, audit,
reporting, AI controls, agent profiles, or shared warning banners.
8. Keep first-session dashboard empty-state copy on
`frontend-modern/src/utils/workloadEmptyStatePresentation.ts`, and make
infrastructure setup guidance name the canonical destination explicitly
@@ -1018,7 +1023,9 @@ telemetry`; maintainer commercial-event controls, upgrade-metrics labels, and
sales/onboarding reporting language must not appear in customer-facing Settings
or support diagnostics, and public configuration docs must not list their
internal compatibility switches as ordinary operator settings. Customer
frontend code must also not POST those events to `/api/upgrade-metrics/events`.
frontend code must also not import, define, or call `upgradeMetrics`,
`conversionEvents`, infrastructure onboarding metrics wrappers, or POST those
events to `/api/upgrade-metrics/events`.
The telemetry copy must describe normalized release identity rather than
falling back to ambiguous `telemetry`, `upgrade metrics`, or raw-version
wording.
@@ -1784,7 +1791,10 @@ belong in admin-owned metrics surfaces instead of Settings support UI.
`frontend-modern/scripts/settings-diagnostics-boundary-audit.mjs`, called by
the canonical frontend audit runner, enforces that boundary by failing if the
diagnostics API, diagnostics results panel, or diagnostics payload model
reintroduce those analytics fields outside the defensive strip helper.
reintroduce those analytics fields outside the defensive strip helper, and by
failing if production customer frontend source reintroduces the retired
commercial/onboarding analytics wrappers or `/api/upgrade-metrics/events`
calls.
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,
@@ -92,6 +92,10 @@ create, review, and approve cross-organization shares.
2. Keep organization settings copy and validation inside the canonical organization presentation helpers
3. Keep the shared organization and RBAC transport proof routes explicit in `registry.json`; default fallback proof routing is not allowed for this subsystem
4. Update this contract whenever a new organization settings, role-management, or organization-domain helper entry point becomes canonical runtime surface area
5. Keep RBAC feature-gate copy on the organization presentation owner. RBAC
may describe capability availability on paid self-hosted and hosted plans,
but must not use Pro-badge titles or local browser upgrade telemetry in
`RBACFeatureGateSection.tsx` or `useRBACFeatureGateState.ts`.
## Current State
@@ -212,7 +212,9 @@ concern in `frontend-modern/src/stores/systemSettings.ts`, not settings
navigation metadata or other frontend-primitives owners.
Retired local-upgrade-metrics compatibility must not become customer-side
commercial analytics emission; browser product surfaces must not POST pricing,
checkout, paywall, funnel, or onboarding signals to `/api/upgrade-metrics/events`.
checkout, paywall, funnel, or onboarding signals to `/api/upgrade-metrics/events`,
and customer frontend source must not keep `upgradeMetrics`, `conversionEvents`,
or infrastructure onboarding metrics wrappers as compatibility imports.
The security transport surfaces remain intentionally shared with
`api-contracts`: token, auth, and telemetry settings payloads are still API
@@ -16,8 +16,6 @@ function writeFixture(root, repoRoot, files) {
'export function DiagnosticsResultsPanel() { return null; }\n',
[path.join(root, 'src', 'components', 'Settings', 'diagnosticsModel.ts')]:
'export interface DiagnosticsInfo { version: string; }\n',
[path.join(root, 'src', 'utils', 'upgradeMetrics.ts')]:
'export function trackUpgradeMetricEvent() {}\n',
...files,
};
@@ -85,25 +83,25 @@ export function stripInternalAnalyticsDiagnosticsFields(payload) {
expect(collectUserDiagnosticsInternalAnalyticsFindings({ root, repoRoot })).toEqual([]);
});
it('reports product-side upgrade-metrics ingestion plumbing', () => {
it('reports production frontend commercial analytics shims', () => {
const { root, repoRoot } = makeFixture(({ root }) => ({
[path.join(root, 'src', 'utils', 'upgradeMetrics.ts')]: `
import { apiFetch } from '@/utils/apiClient';
[path.join(root, 'src', 'components', 'Settings', 'CommercialProbe.tsx')]: `
import { trackPaywallViewed } from '@/utils/upgradeMetrics';
export function trackUpgradeMetricEvent() {
void apiFetch('/api/upgrade-metrics/events');
export function CommercialProbe() {
const onboardingMetricsTracker = { recordOpened() {} };
trackPaywallViewed('rbac', 'settings_roles_panel');
onboardingMetricsTracker.recordOpened();
return null;
}
`,
}));
const findings = collectUserDiagnosticsInternalAnalyticsFindings({ root, repoRoot });
expect(findings.map((finding) => finding.rule)).toEqual([
'canonical-settings/no-product-upgrade-metrics-ingestion',
'canonical-settings/no-product-upgrade-metrics-ingestion',
'canonical-settings/no-product-upgrade-metrics-ingestion',
'canonical-settings/no-product-upgrade-metrics-endpoint',
]);
expect(findings.map((finding) => finding.rule)).toEqual(
Array(5).fill('canonical-settings/no-product-commercial-analytics-source'),
);
});
it('reports direct production frontend calls to upgrade-metrics ingestion', () => {
@@ -47,21 +47,16 @@ const INTERNAL_ANALYTICS_RULES = [
},
],
},
{
getFilePath: ({ root }) => path.join(root, 'src', 'utils', 'upgradeMetrics.ts'),
rules: [
{
rule: 'canonical-settings/no-product-upgrade-metrics-ingestion',
regex:
/\/api\/upgrade-metrics\/events|@\/utils\/apiClient|\bapiFetch\s*\(|\bfetch\s*\(|\bsendBeacon\b/g,
message:
'Do not emit maintainer/admin commercial analytics from the customer frontend. Keep upgrade-metrics ingestion out of product surfaces.',
},
],
},
{
getFilePaths: ({ root }) => listProductionSourceFiles(path.join(root, 'src')),
rules: [
{
rule: 'canonical-settings/no-product-commercial-analytics-source',
regex:
/@\/utils\/(?:conversionEvents|infrastructureOnboardingMetrics|upgradeMetrics)|['"]\.\.?\/(?:conversionEvents|infrastructureOnboardingMetrics|upgradeMetrics)['"]|\b(?:InfrastructureOnboardingMetrics|InfrastructureOnboardingMetricsTracker|UPGRADE_METRIC_EVENTS|UNIFIED_AGENT_TELEMETRY_SURFACE|clearSharedInfrastructureOnboardingMetricsTracker|createInfrastructureOnboardingMetricsTracker|getSharedInfrastructureOnboardingMetricsTracker|normalizeTelemetryPart|onboardingMetricsTracker|trackAgentFirstConnected|trackAgentInstallCommandCopied|trackAgentInstallProfileSelected|trackAgentInstallTokenGenerated|trackCheckoutClicked|trackPaywallViewed|trackPricingViewed|trackUpgradeClicked|trackUpgradeMetricEvent)\b/g,
message:
'Do not keep maintainer/admin commercial or onboarding analytics shims in production customer frontend source.',
},
{
rule: 'canonical-settings/no-product-upgrade-metrics-endpoint',
regex: /\/api\/upgrade-metrics\/events/g,
@@ -446,7 +446,7 @@ describe('AIChat', () => {
expect(screen.getByText('Default control mode')).toBeInTheDocument();
expect(screen.getByText('No commands or control actions')).toBeInTheDocument();
expect(screen.getByText('Ask before running commands')).toBeInTheDocument();
expect(screen.getByText('Executes without approval (Pro)')).toBeInTheDocument();
expect(screen.getByText('Executes without approval')).toBeInTheDocument();
});
});
@@ -7,7 +7,6 @@ import { formatAlertValue } from '@/utils/alertFormatters';
import { resolveAlertTargetType } from '@/utils/alertTargetTypes';
import { getUpgradeActionDestination } from '@/stores/licenseCommercial';
import { presentationPolicyHidesUpgradePrompts } from '@/stores/sessionPresentationPolicy';
import { trackUpgradeClicked } from '@/utils/upgradeMetrics';
interface InvestigateAlertButtonProps {
alert: Alert;
@@ -47,7 +46,6 @@ export function InvestigateAlertButton(props: InvestigateAlertButtonProps) {
if (!canShowUpgradePrompt()) {
return;
}
trackUpgradeClicked('investigate_alert_button', 'ai_alerts');
openUpgradeDestination(getUpgradeActionDestination('ai_alerts'));
return;
}
@@ -10,7 +10,6 @@ import { getPublicPricingUrl } from '@/utils/pricingHandoff';
const {
openWithPromptMock,
openUpgradeDestinationMock,
trackUpgradeClickedMock,
formatAlertValueMock,
mockAiChatStore,
getUpgradeActionDestinationMock,
@@ -20,7 +19,6 @@ const {
vi.hoisted(() => {
const openWithPromptMock = vi.fn();
const openUpgradeDestinationMock = vi.fn();
const trackUpgradeClickedMock = vi.fn();
const formatAlertValueMock = vi.fn((value?: number, _type?: string) =>
value !== undefined ? `${value.toFixed(1)}%` : 'N/A',
);
@@ -34,7 +32,6 @@ const {
return {
openWithPromptMock,
openUpgradeDestinationMock,
trackUpgradeClickedMock,
formatAlertValueMock,
mockAiChatStore,
getUpgradeActionDestinationMock,
@@ -60,10 +57,6 @@ vi.mock('@/components/shared/useUpgradeNavigation', () => ({
useUpgradeNavigation: () => openUpgradeDestinationMock,
}));
vi.mock('@/utils/upgradeMetrics', () => ({
trackUpgradeClicked: (...args: unknown[]) => trackUpgradeClickedMock(...args),
}));
vi.mock('@/utils/alertFormatters', () => ({
formatAlertValue: (...args: unknown[]) => formatAlertValueMock(...(args as [number, string])),
}));
@@ -105,7 +98,6 @@ afterEach(() => {
beforeEach(() => {
openWithPromptMock.mockReset();
trackUpgradeClickedMock.mockReset();
formatAlertValueMock.mockClear();
openUpgradeDestinationMock.mockReset();
getUpgradeActionDestinationMock.mockReset();
@@ -231,12 +223,11 @@ describe('InvestigateAlertButton', () => {
expect(button.className).toContain('opacity-60');
});
it('opens upgrade URL and tracks click when locked and clicked', async () => {
it('opens upgrade URL when locked and clicked', async () => {
render(() => <InvestigateAlertButton alert={makeAlert()} licenseLocked={true} />);
const button = screen.getByRole('button');
await fireEvent.click(button);
expect(trackUpgradeClickedMock).toHaveBeenCalledWith('investigate_alert_button', 'ai_alerts');
expect(openUpgradeDestinationMock).toHaveBeenCalledWith({
href: getPublicPricingUrl('ai_alerts'),
external: true,
@@ -257,7 +248,6 @@ describe('InvestigateAlertButton', () => {
await fireEvent.click(button);
expect(trackUpgradeClickedMock).not.toHaveBeenCalled();
expect(openUpgradeDestinationMock).not.toHaveBeenCalled();
expect(openWithPromptMock).not.toHaveBeenCalled();
});
@@ -314,10 +304,9 @@ describe('InvestigateAlertButton', () => {
});
});
it('does not call trackUpgradeClicked when unlocked', async () => {
it('does not open the upgrade destination when unlocked', async () => {
render(() => <InvestigateAlertButton alert={makeAlert()} />);
await fireEvent.click(screen.getByRole('button'));
expect(trackUpgradeClickedMock).not.toHaveBeenCalled();
expect(openUpgradeDestinationMock).not.toHaveBeenCalled();
});
@@ -16,7 +16,6 @@ import {
getAISettingsWorkloadDiscoveryHelpContent,
getAISettingsWorkloadDiscoverySummary,
} from '@/utils/aiSettingsPresentation';
import { trackUpgradeClicked } from '@/utils/upgradeMetrics';
import { UPGRADE_ACTION_LABEL } from '@/utils/upgradePresentation';
interface AIRuntimeControlsSectionProps {
@@ -256,7 +255,6 @@ export const AIRuntimeControlsSection: Component<AIRuntimeControlsSectionProps>
<Show when={showAutonomousControlOption()}>
<option value="autonomous">
Autonomous - Pulse Assistant executes without approval
{state.autoFixLocked() && state.showUpgradePrompts() ? ' (Pro)' : ''}
</option>
</Show>
</FormSelect>
@@ -289,7 +287,6 @@ export const AIRuntimeControlsSection: Component<AIRuntimeControlsSectionProps>
<UpgradeLink
class="text-blue-600 dark:text-blue-400 font-medium hover:underline"
destination={state.upgradeAutofixDestination()}
onClick={() => trackUpgradeClicked('settings_ai_patrol_autofix', 'ai_autofix')}
>
{UPGRADE_ACTION_LABEL}
</UpgradeLink>{' '}
@@ -7,6 +7,7 @@ import { UpgradeLink } from '@/components/shared/UpgradeLink';
import { SuggestProfileModal } from './SuggestProfileModal';
import { KNOWN_SETTINGS, type SelectSetting, type StringSetting } from './agentProfileSettings';
import {
getAgentProfilesFeatureGateCopy,
getAgentProfileAssignmentsEmptyState,
getAgentProfilesEmptyState,
} from '@/utils/agentProfilesPresentation';
@@ -21,6 +22,7 @@ import { PulseDataGrid } from '@/components/shared/PulseDataGrid';
import { useAgentProfilesPanelState } from './useAgentProfilesPanelState';
export const AgentProfilesPanel: Component = () => {
const featureGateCopy = getAgentProfilesFeatureGateCopy();
const {
aiAvailable,
checkingLicense,
@@ -56,7 +58,6 @@ export const AgentProfilesPanel: Component = () => {
showUpgradePrompts,
showModal,
showSuggestModal,
trackUpgradeClicked,
unknownKeys,
updateSetting,
formatRelativeTime,
@@ -87,21 +88,17 @@ export const AgentProfilesPanel: Component = () => {
<Crown class="w-5 h-5 text-amber-600 dark:text-amber-400" />
</div>
<div>
<h3 class="text-base font-semibold text-base-content">Agent Profiles</h3>
<p class="text-sm text-muted">Pro feature</p>
<h3 class="text-base font-semibold text-base-content">
{featureGateCopy.title}
</h3>
<p class="text-sm text-muted">{featureGateCopy.subtitle}</p>
</div>
</div>
<p class="text-sm text-muted">
Create reusable configuration profiles for your agents. Manage settings like Docker
monitoring, logging levels, and reporting intervals from a central location.
</p>
<p class="text-sm text-muted">{featureGateCopy.body}</p>
<div class="flex flex-wrap items-center gap-3">
<UpgradeLink
destination={getUpgradeActionDestination('agent_profiles')}
class={getUpgradeActionButtonClass({ tone: 'warning', mobileFullWidth: false })}
onClick={() =>
trackUpgradeClicked('settings_agent_profiles_panel', 'agent_profiles')
}
>
<Crown class="w-4 h-4" />
{UPGRADE_ACTION_LABEL}
@@ -50,7 +50,6 @@ export default function AuditLogPanel() {
goToLastPage,
goToNextPage,
goToPreviousPage,
handleUpgradeClick,
hasNextPage,
hasResumeEvents,
hasSignedEvents,
@@ -170,7 +169,6 @@ export default function AuditLogPanel() {
<UpgradeLink
destination={upgradeDestination()}
class={getUpgradeActionButtonClass({ mobileFullWidth: false })}
onClick={handleUpgradeClick}
>
{UPGRADE_ACTION_LABEL}
</UpgradeLink>
@@ -18,7 +18,6 @@ import {
getAuditWebhookFeatureGateCopy,
getAuditWebhookLoadingState,
} from '@/utils/auditWebhookPresentation';
import { trackUpgradeClicked } from '@/utils/upgradeMetrics';
import {
getUpgradeActionButtonClass,
UPGRADE_ACTION_LABEL,
@@ -65,9 +64,6 @@ export const AuditWebhookPanel: Component<AuditWebhookPanelProps> = (props) => {
<UpgradeLink
destination={upgradeDestination()}
class={getUpgradeActionButtonClass()}
onClick={() =>
trackUpgradeClicked('settings_audit_webhook_panel', 'audit_logging')
}
>
{UPGRADE_ACTION_LABEL}
</UpgradeLink>
@@ -1,4 +1,4 @@
import { Component, For, type JSX, Show, createEffect, createMemo, createSignal, onMount } from 'solid-js';
import { Component, For, type JSX, Show, createMemo, createSignal } from 'solid-js';
import type { ConnectionType, ProbeCandidate } from '@/api/connections';
import { AddressProbeStep } from './AddressProbeStep';
import {
@@ -7,10 +7,6 @@ import {
type ConnectionEditorState,
} from './useConnectionEditor';
import { getInfrastructureAutoDetectLabels } from '@/utils/infrastructureOnboardingPresentation';
import {
createInfrastructureOnboardingMetricsTracker,
type InfrastructureOnboardingMetricsTracker,
} from '@/utils/infrastructureOnboardingMetrics';
export type ConnectionEditorMode = 'add' | 'edit';
@@ -30,8 +26,6 @@ export interface ConnectionEditorProps {
initialAddress?: string;
initialCandidate?: ProbeCandidate | null;
showSlotHeader?: boolean;
trackInitialCatalogSelection?: boolean;
onboardingMetricsTracker?: InfrastructureOnboardingMetricsTracker | null;
onBackToCatalog?: () => void;
onSelectAgentRoute?: () => void;
onSelectCandidate?: (candidate: ProbeCandidate) => void;
@@ -52,42 +46,11 @@ export const ConnectionEditor: Component<ConnectionEditorProps> = (props) => {
const [selectedCandidate, setSelectedCandidate] = createSignal<ProbeCandidate | null>(
props.initialCandidate ?? null,
);
const ownsOnboardingMetricsTracker =
(props.mode ?? 'add') === 'add' && !props.onboardingMetricsTracker;
const onboardingMetrics =
(props.mode ?? 'add') === 'add'
? props.onboardingMetricsTracker ?? createInfrastructureOnboardingMetricsTracker()
: null;
const activeType = () => selectedType();
const showCredentialSlot = () => activeType() !== null;
const autoDetectLabels = createMemo(() => getInfrastructureAutoDetectLabels());
const recordPathSelectedForType = (type: ConnectionType) => {
onboardingMetrics?.recordPathSelected(type === 'agent' ? 'agent' : 'api');
};
onMount(() => {
if (!onboardingMetrics) return;
if (ownsOnboardingMetricsTracker) {
onboardingMetrics.recordOpened();
}
if (props.initialType) {
recordPathSelectedForType(props.initialType);
if (props.trackInitialCatalogSelection && props.initialType !== 'agent') {
onboardingMetrics.recordCatalogSelected(props.initialType);
}
}
});
createEffect(() => {
const type = selectedType();
if (!onboardingMetrics || !type) return;
onboardingMetrics.recordCredentialsOpened(type);
});
const chooseCandidate = (candidate: ProbeCandidate) => {
onboardingMetrics?.recordPathSelected('api');
if (props.onSelectCandidate) {
props.onSelectCandidate(candidate);
return;
@@ -97,14 +60,12 @@ export const ConnectionEditor: Component<ConnectionEditorProps> = (props) => {
};
const chooseManualType = (type: ConnectionType) => {
recordPathSelectedForType(type);
setSelectedCandidate(null);
setSelectedType(type);
};
const installAgent = () => {
if (props.onSelectAgentRoute) {
onboardingMetrics?.recordPathSelected('agent');
props.onSelectAgentRoute();
return;
}
@@ -175,8 +136,6 @@ export const ConnectionEditor: Component<ConnectionEditorProps> = (props) => {
onSelectCandidate={chooseCandidate}
onInstallAgent={installAgent}
onChooseSourceTypeInstead={props.onBackToCatalog}
onProbeSubmitted={() => onboardingMetrics?.recordPathSelected('api')}
onProbeResolved={(outcome) => onboardingMetrics?.recordProbeResult(outcome)}
/>
</section>
</div>
@@ -9,25 +9,11 @@ const connectionsApiMock = vi.hoisted(() => ({
setEnabled: vi.fn(),
remove: vi.fn(),
}));
const onboardingMetricsTrackerMock = vi.hoisted(() => ({
recordOpened: vi.fn(),
recordPathSelected: vi.fn(),
recordProbeResult: vi.fn(),
recordCatalogSelected: vi.fn(),
recordCredentialsOpened: vi.fn(),
}));
const createInfrastructureOnboardingMetricsTrackerMock = vi.hoisted(() =>
vi.fn(() => onboardingMetricsTrackerMock),
);
vi.mock('@/api/connections', () => ({
ConnectionsAPI: connectionsApiMock,
}));
vi.mock('@/utils/infrastructureOnboardingMetrics', () => ({
createInfrastructureOnboardingMetricsTracker: createInfrastructureOnboardingMetricsTrackerMock,
}));
const mockedProbe = vi.mocked(ConnectionsAPI.probe);
describe('ConnectionEditor', () => {
@@ -47,8 +33,6 @@ describe('ConnectionEditor', () => {
const renderSlot = vi.fn((props) => <div data-testid="slot">slot:{props.type}</div>);
render(() => <ConnectionEditor renderCredentialSlot={renderSlot} onClose={() => {}} />);
expect(createInfrastructureOnboardingMetricsTrackerMock).toHaveBeenCalledTimes(1);
expect(onboardingMetricsTrackerMock.recordOpened).toHaveBeenCalledTimes(1);
const input = screen.getByPlaceholderText(/vcenter\.lab/) as HTMLInputElement;
fireEvent.input(input, { target: { value: 'pve.lab' } });
@@ -56,15 +40,10 @@ describe('ConnectionEditor', () => {
fireEvent.click(screen.getByRole('button', { name: /probe address/i }));
await waitFor(() => expect(mockedProbe).toHaveBeenCalledWith('pve.lab'));
await waitFor(() =>
expect(onboardingMetricsTrackerMock.recordProbeResult).toHaveBeenCalledWith('detected'),
);
expect(onboardingMetricsTrackerMock.recordPathSelected).toHaveBeenCalledWith('api');
fireEvent.click((await screen.findByText('https://pve.lab:8006')).closest('button')!);
await waitFor(() => expect(screen.getByTestId('slot').textContent).toBe('slot:pve'));
expect(onboardingMetricsTrackerMock.recordCredentialsOpened).toHaveBeenCalledWith('pve');
const lastCall = renderSlot.mock.calls.at(-1)![0];
expect(lastCall.type).toBe('pve');
expect(lastCall.candidate?.host).toBe('https://pve.lab:8006');
@@ -107,7 +86,9 @@ describe('ConnectionEditor', () => {
fireEvent.click(screen.getByRole('button', { name: /probe address/i }));
await waitFor(() =>
expect(onboardingMetricsTrackerMock.recordProbeResult).toHaveBeenCalledWith('no-match'),
expect(
screen.getByText(/No supported API-backed platform detected/i),
).toBeInTheDocument(),
);
fireEvent.click(screen.getByRole('button', { name: /Choose a source type instead/i }));
@@ -132,8 +113,6 @@ describe('ConnectionEditor', () => {
fireEvent.click(agentButton);
expect(screen.getByTestId('slot').textContent).toBe('slot:agent');
expect(onboardingMetricsTrackerMock.recordPathSelected).toHaveBeenCalledWith('agent');
expect(onboardingMetricsTrackerMock.recordCredentialsOpened).toHaveBeenCalledWith('agent');
const call = renderSlot.mock.calls.at(-1)![0];
expect(call.type).toBe('agent');
expect(call.candidate).toBeNull();
@@ -166,32 +145,19 @@ describe('ConnectionEditor', () => {
expect(screen.getByText('Address probe')).toBeInTheDocument();
});
it('uses an injected tracker for direct type routes without creating another one', async () => {
const externalTracker = {
recordOpened: vi.fn(),
recordPathSelected: vi.fn(),
recordProbeResult: vi.fn(),
recordCatalogSelected: vi.fn(),
recordCredentialsOpened: vi.fn(),
};
it('opens direct type routes without the detect setup', async () => {
const renderSlot = vi.fn((props) => <div data-testid="slot">slot:{props.type}</div>);
render(() => (
<ConnectionEditor
initialType="truenas"
trackInitialCatalogSelection={true}
onboardingMetricsTracker={externalTracker}
renderCredentialSlot={renderSlot}
onClose={() => {}}
/>
));
await waitFor(() => expect(screen.getByTestId('slot').textContent).toBe('slot:truenas'));
expect(createInfrastructureOnboardingMetricsTrackerMock).not.toHaveBeenCalled();
expect(externalTracker.recordOpened).not.toHaveBeenCalled();
expect(externalTracker.recordPathSelected).toHaveBeenCalledWith('api');
expect(externalTracker.recordCatalogSelected).toHaveBeenCalledWith('truenas');
expect(externalTracker.recordCredentialsOpened).toHaveBeenCalledWith('truenas');
expect(screen.queryByRole('button', { name: /probe address/i })).not.toBeInTheDocument();
});
it('skips probe setup when an initialType is supplied in edit mode', () => {
@@ -206,7 +172,6 @@ describe('ConnectionEditor', () => {
/>
));
expect(createInfrastructureOnboardingMetricsTrackerMock).not.toHaveBeenCalled();
expect(screen.getByTestId('slot').textContent).toBe('slot:vmware');
const call = renderSlot.mock.calls.at(0)![0];
expect(call.mode).toBe('edit');
@@ -10,12 +10,9 @@ import {
getUnifiedAgentClipboardCopyErrorMessage,
getUnifiedAgentClipboardCopySuccessMessage,
} from '@/utils/unifiedAgentInventoryPresentation';
import { trackAgentInstallCommandCopied } from '@/utils/upgradeMetrics';
import {
INSTALL_PROFILE_OPTIONS,
normalizeTelemetryPart,
type InstallProfile,
UNIFIED_AGENT_TELEMETRY_SURFACE,
} from './infrastructureOperationsModel';
import { useInfrastructureOperationsContext } from './useInfrastructureOperationsState';
@@ -481,10 +478,6 @@ export const InfrastructureInstallerSection: Component = () => {
<For each={section.snippets}>
{(snippet) => {
const copyCommand = () => snippet.command;
const commandTelemetryCapability = () => {
const label = normalizeTelemetryPart(snippet.label) || 'install';
return `${section.platform}:${state.installProfile()}:${label}`;
};
return (
<div class="space-y-2">
@@ -497,10 +490,6 @@ export const InfrastructureInstallerSection: Component = () => {
onClick={async () => {
const success = await copyToClipboard(copyCommand());
if (success) {
trackAgentInstallCommandCopied(
UNIFIED_AGENT_TELEMETRY_SURFACE,
commandTelemetryCapability(),
);
notificationStore.success(
getUnifiedAgentClipboardCopySuccessMessage(),
);
@@ -41,11 +41,6 @@ import {
InfrastructureOperationsStateProvider,
useInfrastructureOperationsContext,
} from './useInfrastructureOperationsState';
import {
clearSharedInfrastructureOnboardingMetricsTracker,
getSharedInfrastructureOnboardingMetricsTracker,
type InfrastructureOnboardingMetricsTracker,
} from '@/utils/infrastructureOnboardingMetrics';
import {
getInfrastructureOnboardingProductPresentation,
getInfrastructureSourceStrategyPresentation,
@@ -74,12 +69,6 @@ const closeButtonClass =
const buttonClass =
'inline-flex min-h-10 sm:min-h-9 items-center justify-center rounded-md border border-border px-3 py-2 text-sm font-medium text-base-content transition-colors hover:bg-surface-hover disabled:cursor-not-allowed disabled:opacity-60';
const createOpenedOnboardingTracker = (): InfrastructureOnboardingMetricsTracker => {
const tracker = getSharedInfrastructureOnboardingMetricsTracker();
tracker.recordOpened();
return tracker;
};
const describeManagedSourceType = (type: ConnectionType | null): string => {
if (!type) return 'Infrastructure';
if (
@@ -119,11 +108,6 @@ const InfrastructureWorkspaceContent: Component<InfrastructureWorkspaceProps> =
if (readOnly()) return null;
return deriveAddStepFromLocation(location.pathname, location.search ?? '');
});
const [addFlowTracker, setAddFlowTracker] =
createSignal<InfrastructureOnboardingMetricsTracker | null>(
routeStep() ? createOpenedOnboardingTracker() : null,
);
let previousRouteStep: InfrastructurePanelStep | null | undefined = routeStep();
const activeAddType = createMemo<ConnectionType | null>(() => {
const step = routeStep();
if (!step || step === 'pick' || step === 'detect') return null;
@@ -198,27 +182,18 @@ const InfrastructureWorkspaceContent: Component<InfrastructureWorkspaceProps> =
const openAddFlow = (step: InfrastructurePanelStep) => {
setSelectedDiscoveredSource(null);
setSelectedProbeCandidate(null);
if (!addFlowTracker()) {
setAddFlowTracker(createOpenedOnboardingTracker());
}
navigate(buildInfrastructureOnboardingPath(step), { scroll: false });
};
const openAddFlowFromProbe = (candidate: ProbeCandidate) => {
setSelectedDiscoveredSource(null);
setSelectedProbeCandidate(candidate);
if (!addFlowTracker()) {
setAddFlowTracker(createOpenedOnboardingTracker());
}
navigate(buildInfrastructureOnboardingPath(candidate.type as ManagedAddTypeStep), {
scroll: false,
});
};
const reviewDiscoveredSource = (server: DiscoveredServer) => {
if (!addFlowTracker()) {
setAddFlowTracker(createOpenedOnboardingTracker());
}
setSelectedDiscoveredSource(server);
navigate(buildInfrastructureOnboardingPath(server.type), { scroll: false });
};
@@ -227,8 +202,6 @@ const InfrastructureWorkspaceContent: Component<InfrastructureWorkspaceProps> =
resetInlineEditorState();
setSelectedDiscoveredSource(null);
setSelectedProbeCandidate(null);
setAddFlowTracker(null);
clearSharedInfrastructureOnboardingMetricsTracker();
navigateToWorkspace(Boolean(routeStep()));
};
@@ -255,33 +228,12 @@ const InfrastructureWorkspaceContent: Component<InfrastructureWorkspaceProps> =
closeEditFlow();
};
const recordCatalogSelection = (type: InfrastructureOnboardingConnectionType) => {
const tracker = addFlowTracker();
if (!tracker) return;
tracker.recordPathSelected(type === 'agent' ? 'agent' : 'api');
if (type !== 'agent') {
tracker.recordCatalogSelected(type);
}
};
createEffect(() => {
if (activeAddType() !== 'agent') {
setShowAgentProfiles(false);
}
});
createEffect(() => {
const step = routeStep();
const tracker = addFlowTracker();
if (step && !tracker) {
setAddFlowTracker(createOpenedOnboardingTracker());
} else if (!step && tracker && previousRouteStep) {
setAddFlowTracker(null);
clearSharedInfrastructureOnboardingMetricsTracker();
}
previousRouteStep = step;
});
createEffect(() => {
if (!editingConnection()) return;
if (ledger.loading()) return;
@@ -950,7 +902,6 @@ const InfrastructureWorkspaceContent: Component<InfrastructureWorkspaceProps> =
<Match when={routeStep() === 'pick'}>
<InfrastructureSourcePicker
onSelectType={(type) => {
recordCatalogSelection(type);
openAddFlow(type === 'agent' ? 'agent' : (type as ManagedAddTypeStep));
}}
onDetectFromAddress={() => openAddFlow('detect')}
@@ -960,7 +911,6 @@ const InfrastructureWorkspaceContent: Component<InfrastructureWorkspaceProps> =
<Match when={routeStep() === 'detect'}>
<ConnectionEditor
mode="add"
onboardingMetricsTracker={addFlowTracker()}
onBackToCatalog={() => openAddFlow('pick')}
onSelectAgentRoute={() => openAddFlow('agent')}
onSelectCandidate={openAddFlowFromProbe}
@@ -978,12 +928,6 @@ const InfrastructureWorkspaceContent: Component<InfrastructureWorkspaceProps> =
initialType={activeAddType() ?? undefined}
initialCandidate={selectedProbeCandidate()}
showSlotHeader={false}
trackInitialCatalogSelection={
activeAddType() !== 'agent' &&
!selectedDiscoveredSource() &&
!selectedProbeCandidate()
}
onboardingMetricsTracker={addFlowTracker()}
onBackToCatalog={() => openAddFlow('pick')}
onClose={closeAddFlow}
onSaved={handleAddSaved}
@@ -111,9 +111,7 @@ const ProLicensePanelContent: Component = () => {
onReload={() => void state.loadPanelData()}
planSelectionPrompt={state.planSelectionPrompt()}
planComparisonSummary={state.planComparisonSummary()}
onPlanSelectionPromptClick={state.handlePlanSelectionPromptClick}
purchaseActivationAction={state.purchaseActivationAction()}
onPurchaseActivationActionClick={state.handlePurchaseActivationActionClick}
purchaseActivationNotice={state.purchaseActivationNotice()}
/>
@@ -89,13 +89,11 @@ interface ProLicensePlanSectionProps {
} | null;
};
planSelectionPrompt: ActionNotice | null;
onPlanSelectionPromptClick: () => void;
purchaseActivationNotice: Notice | null;
purchaseActivationAction: {
label: string;
destination: UpgradeDestination;
} | null;
onPurchaseActivationActionClick: () => void;
}
const formatDate = (value?: string | null) => {
@@ -154,7 +152,6 @@ export const ProLicensePlanSection: Component<ProLicensePlanSectionProps> = (pro
<UpgradeLink
class="inline-flex items-center gap-1 mt-3 min-h-10 sm:min-h-9 rounded-md border border-current/20 px-3 py-2 text-xs font-medium hover:bg-black/5 dark:hover:bg-white/5"
destination={action().destination}
onClick={props.onPurchaseActivationActionClick}
>
{action().label}
</UpgradeLink>
@@ -171,7 +168,6 @@ export const ProLicensePlanSection: Component<ProLicensePlanSectionProps> = (pro
<UpgradeLink
class="inline-flex items-center gap-1 mt-3 min-h-10 sm:min-h-9 rounded-md border border-current/20 px-3 py-2 text-xs font-medium hover:bg-black/5 dark:hover:bg-white/5"
destination={prompt().actionDestination}
onClick={props.onPlanSelectionPromptClick}
>
{prompt().actionLabel}
</UpgradeLink>
@@ -295,7 +291,6 @@ export const ProLicensePlanSection: Component<ProLicensePlanSectionProps> = (pro
<UpgradeLink
class="inline-flex items-center gap-1 mt-4 min-h-10 sm:min-h-9 rounded-md border border-border px-3 py-2 text-xs font-medium text-base-content hover:bg-surface-hover"
destination={action().destination}
onClick={props.onPlanSelectionPromptClick}
>
{action().label}
</UpgradeLink>
@@ -2,7 +2,6 @@ import { Component, Show } from 'solid-js';
import { UpgradeLink } from '@/components/shared/UpgradeLink';
import { getUpgradeActionDestination } from '@/stores/licenseCommercial';
import type { RBACFeatureGateCopy } from '@/utils/rbacPresentation';
import { trackUpgradeClicked } from '@/utils/upgradeMetrics';
import {
getUpgradeActionButtonClass,
UPGRADE_ACTION_LABEL,
@@ -27,7 +26,6 @@ export const RBACFeatureGateSection: Component<RBACFeatureGateSectionProps> = (p
<UpgradeLink
destination={getUpgradeActionDestination('rbac')}
class={getUpgradeActionButtonClass()}
onClick={() => trackUpgradeClicked(props.paywallLocation, 'rbac')}
>
{UPGRADE_ACTION_LABEL}
</UpgradeLink>
@@ -6,7 +6,6 @@ import { Toggle } from '@/components/shared/Toggle';
import { Card } from '@/components/shared/Card';
import { UpgradeLink } from '@/components/shared/UpgradeLink';
import { formField, labelClass, controlClass, formHelpText } from '@/components/shared/Form';
import { trackUpgradeClicked } from '@/utils/upgradeMetrics';
import { getSettingsConfigurationLoadingState } from '@/utils/settingsShellPresentation';
import {
RELAY_ENABLE_HELP_TEXT,
@@ -49,7 +48,6 @@ export const RelaySettingsPanel: Component<RelaySettingsPanelProps> = (props) =>
<UpgradeLink
destination={state.upgradeDestination()}
class={RELAY_PRIMARY_LINK_CLASS}
onClick={() => trackUpgradeClicked('settings_relay_panel', 'relay')}
>
View plans
</UpgradeLink>
@@ -11,7 +11,6 @@ import { UpgradeLink } from '@/components/shared/UpgradeLink';
import { useReportingPanelState } from '@/components/Settings/useReportingPanelState';
import type { ReportingFormat } from '@/components/Settings/reportingCatalogModel';
import { type ReportingRangeValue } from '@/components/Settings/reportingPanelModel';
import { trackUpgradeClicked } from '@/utils/upgradeMetrics';
import {
getUpgradeActionButtonClass,
UPGRADE_ACTION_LABEL,
@@ -153,9 +152,6 @@ export function ReportingPanel() {
<UpgradeLink
destination={upgradeDestination()}
class={getUpgradeActionButtonClass()}
onClick={() =>
trackUpgradeClicked('settings_reporting_panel', reportingCatalog()!.id)
}
>
{UPGRADE_ACTION_LABEL}
</UpgradeLink>
@@ -26,6 +26,7 @@ import {
getSSOProviderSummary,
getSSOProviderTypeBadgeClass,
getSSOProviderTypeLabel,
getSSOSamlFeatureGateCopy,
} from '@/utils/ssoProviderPresentation';
import {
getUpgradeActionButtonClass,
@@ -40,6 +41,7 @@ interface SSOProvidersPanelProps {
}
export const SSOProvidersPanel: Component<SSOProvidersPanelProps> = (props) => {
const samlFeatureGateCopy = getSSOSamlFeatureGateCopy();
const {
providers,
loading,
@@ -78,7 +80,6 @@ export const SSOProvidersPanel: Component<SSOProvidersPanelProps> = (props) => {
getUpgradeActionDestination,
runtimeCapabilitiesLoaded,
showUpgradePrompts,
trackUpgradeClicked,
} = useSSOProvidersState(props);
return (
@@ -95,7 +96,7 @@ export const SSOProvidersPanel: Component<SSOProvidersPanelProps> = (props) => {
<div class="flex items-center justify-between px-6 py-4 border-b border-border">
<div>
<h3 class="text-lg font-semibold text-base-content">Add SAML Provider</h3>
<p class="text-xs text-muted mt-0.5">Pro feature</p>
<p class="text-xs text-muted mt-0.5">{samlFeatureGateCopy.subtitle}</p>
</div>
<button
type="button"
@@ -107,7 +108,7 @@ export const SSOProvidersPanel: Component<SSOProvidersPanelProps> = (props) => {
</button>
</div>
<div class="px-6 py-5 space-y-4">
<p class="text-sm text-muted">SAML 2.0 and multi-provider SSO requires Pro.</p>
<p class="text-sm text-muted">{samlFeatureGateCopy.body}</p>
<div class="flex items-center justify-end gap-2">
<button
type="button"
@@ -119,9 +120,6 @@ export const SSOProvidersPanel: Component<SSOProvidersPanelProps> = (props) => {
<UpgradeLink
destination={getUpgradeActionDestination('advanced_sso')}
class={getUpgradeActionButtonClass({ mobileFullWidth: false })}
onClick={() =>
trackUpgradeClicked('settings_sso_providers_add_saml_gate', 'advanced_sso')
}
>
{UPGRADE_ACTION_LABEL}
<ExternalLink class="w-4 h-4" />
@@ -141,17 +139,15 @@ export const SSOProvidersPanel: Component<SSOProvidersPanelProps> = (props) => {
<div class="p-4 bg-surface-alt border border-border rounded-md">
<div class="flex flex-col sm:flex-row items-center gap-4">
<div class="flex-1">
<h4 class="text-base font-semibold text-base-content">Advanced SSO</h4>
<p class="text-sm text-muted mt-1">
SAML 2.0 and multi-provider SSO requires Pro. Basic OIDC is available in the free
tier.
</p>
<h4 class="text-base font-semibold text-base-content">
{samlFeatureGateCopy.title}
</h4>
<p class="text-sm text-muted mt-1">{samlFeatureGateCopy.body}</p>
</div>
<div class="flex flex-col sm:flex-row items-center gap-2">
<UpgradeLink
destination={getUpgradeActionDestination('advanced_sso')}
class={getUpgradeActionButtonClass({ mobileFullWidth: false })}
onClick={() => trackUpgradeClicked('settings_sso_providers_banner', 'advanced_sso')}
>
{UPGRADE_ACTION_LABEL}
<ExternalLink class="w-4 h-4" />
@@ -195,7 +191,7 @@ export const SSOProvidersPanel: Component<SSOProvidersPanelProps> = (props) => {
class="min-h-10 sm:min-h-9 px-3 py-2.5 text-sm font-medium border border-border text-base-content rounded-md hover:bg-surface-hover transition-colors flex items-center gap-1.5"
>
<Plus class="w-4 h-4" />
{getSSOProviderAddButtonLabel('saml', true)}
{getSSOProviderAddButtonLabel('saml')}
</button>
</Show>
</div>
@@ -25,8 +25,6 @@ const loadLicenseStatusMock = vi.fn();
const loadCommercialPostureMock = vi.fn();
const commercialPostureMock = vi.fn();
const entitlementsMock = vi.fn();
const trackPaywallViewedMock = vi.fn();
const trackUpgradeClickedMock = vi.fn();
const presentationPolicyHidesUpgradePromptsMock = vi.fn();
vi.mock('@/api/ai', () => ({
@@ -81,11 +79,6 @@ vi.mock('@/stores/sessionPresentationPolicy', () => ({
presentationPolicyHidesUpgradePrompts: () => presentationPolicyHidesUpgradePromptsMock(),
}));
vi.mock('@/utils/upgradeMetrics', () => ({
trackPaywallViewed: (...args: unknown[]) => trackPaywallViewedMock(...args),
trackUpgradeClicked: (...args: unknown[]) => trackUpgradeClickedMock(...args),
}));
const baseSettings = (): AISettingsType => ({
enabled: false,
model: '',
@@ -130,8 +123,6 @@ const resetAllMocks = () => {
loadCommercialPostureMock.mockReset();
commercialPostureMock.mockReset();
entitlementsMock.mockReset();
trackPaywallViewedMock.mockReset();
trackUpgradeClickedMock.mockReset();
presentationPolicyHidesUpgradePromptsMock.mockReset();
};
@@ -341,7 +332,6 @@ describe('AISettings model loading error states', () => {
});
expect(screen.queryByRole('option', { name: /Autonomous/i })).not.toBeInTheDocument();
expect(trackPaywallViewedMock).not.toHaveBeenCalled();
});
it('keeps an existing autonomous setting visible even when upgrade prompts are hidden', async () => {
@@ -90,11 +90,6 @@ vi.mock('@/utils/logger', () => ({
},
}));
vi.mock('@/utils/upgradeMetrics', () => ({
trackPaywallViewed: vi.fn(),
trackUpgradeClicked: vi.fn(),
}));
const makeAgentResource = (overrides: Partial<Resource> = {}): Resource => ({
id: 'hash-agent-resource-id',
type: 'agent',
@@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest';
import type { ConnectedInfrastructureItem } from '@/types/api';
import type { UnifiedAgentRow } from '../infrastructureOperationsModel';
import infrastructureInstallerSectionSource from '../InfrastructureInstallerSection.tsx?raw';
import infrastructureOperationsModelSource from '../infrastructureOperationsModel.tsx?raw';
import useInfrastructureInstallStateSource from '../useInfrastructureInstallState.tsx?raw';
import {
INSTALL_PROFILE_OPTIONS,
getCapabilityManagementPath,
@@ -230,6 +232,24 @@ describe('infrastructure operations model', () => {
expect(installStateSource).not.toContain("navigate('/dashboard')");
});
it('keeps infrastructure install and operations surfaces free of retired commercial telemetry wrappers', () => {
for (const source of [
infrastructureOperationsModelSource,
infrastructureInstallerSectionSource,
useInfrastructureInstallStateSource,
]) {
expect(source).not.toContain('upgradeMetrics');
expect(source).not.toContain('conversionEvents');
expect(source).not.toContain('infrastructureOnboardingMetrics');
expect(source).not.toContain('UNIFIED_AGENT_TELEMETRY_SURFACE');
expect(source).not.toContain('normalizeTelemetryPart');
expect(source).not.toContain('trackAgentInstallTokenCreated');
expect(source).not.toContain('trackAgentInstallCommandsCopied');
expect(source).not.toContain('trackAgentFirstConnected');
expect(source).not.toContain('/api/upgrade-metrics/events');
}
});
it('does not reintroduce the retired reporting state hook on the operations state', async () => {
const operationsStateSource = await import(
'../useInfrastructureOperationsState?raw'
@@ -31,29 +31,6 @@ const emptyFleetMember = vi.hoisted(
);
const navigateSpy = vi.hoisted(() => vi.fn());
const presentationPolicyIsReadOnlyMock = vi.hoisted(() => vi.fn(() => false));
const onboardingMetricsTrackers = vi.hoisted(
() =>
[] as Array<{
recordOpened: ReturnType<typeof vi.fn>;
recordPathSelected: ReturnType<typeof vi.fn>;
recordProbeResult: ReturnType<typeof vi.fn>;
recordCatalogSelected: ReturnType<typeof vi.fn>;
recordCredentialsOpened: ReturnType<typeof vi.fn>;
}>,
);
const createInfrastructureOnboardingMetricsTrackerMock = vi.hoisted(() =>
vi.fn(() => {
const tracker = {
recordOpened: vi.fn(),
recordPathSelected: vi.fn(),
recordProbeResult: vi.fn(),
recordCatalogSelected: vi.fn(),
recordCredentialsOpened: vi.fn(),
};
onboardingMetricsTrackers.push(tracker);
return tracker;
}),
);
const originalResizeObserver = globalThis.ResizeObserver;
let latestResizeObserverCallback: ResizeObserverCallback | null = null;
@@ -179,12 +156,6 @@ vi.mock('../AgentProfilesPanel', () => ({
AgentProfilesPanel: () => <div data-testid="agent-profiles">profiles</div>,
}));
vi.mock('@/utils/infrastructureOnboardingMetrics', () => ({
createInfrastructureOnboardingMetricsTracker: createInfrastructureOnboardingMetricsTrackerMock,
getSharedInfrastructureOnboardingMetricsTracker: createInfrastructureOnboardingMetricsTrackerMock,
clearSharedInfrastructureOnboardingMetricsTracker: vi.fn(),
}));
const connectionFixture = (overrides: Partial<Connection> = {}): Connection => ({
id: 'pve:zeus',
type: 'pve',
@@ -280,8 +251,6 @@ describe('InfrastructureWorkspace', () => {
navigateSpy.mockReset();
presentationPolicyIsReadOnlyMock.mockReset();
presentationPolicyIsReadOnlyMock.mockReturnValue(false);
createInfrastructureOnboardingMetricsTrackerMock.mockClear();
onboardingMetricsTrackers.length = 0;
routeState.pathname = '/settings/infrastructure';
routeState.search = '';
connectionState.connections = [connectionFixture()];
@@ -624,8 +593,6 @@ describe('InfrastructureWorkspace', () => {
expect(navigateSpy).toHaveBeenCalledWith('/settings/infrastructure?add=pve', {
scroll: false,
});
expect(createInfrastructureOnboardingMetricsTrackerMock).toHaveBeenCalledTimes(1);
expect(onboardingMetricsTrackers[0]?.recordOpened).toHaveBeenCalledTimes(1);
});
it('opens the platform picker from the Add infrastructure action', () => {
@@ -715,14 +682,12 @@ describe('InfrastructureWorkspace', () => {
expect(screen.getByTestId('install-section')).toBeInTheDocument();
});
it('creates one onboarding tracker for a direct type route before the add dialog mounts', async () => {
it('renders a direct type route before the add dialog mounts', async () => {
routeState.search = '?add=truenas';
renderWorkspace();
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument());
expect(createInfrastructureOnboardingMetricsTrackerMock).toHaveBeenCalledTimes(1);
expect(onboardingMetricsTrackers).toHaveLength(1);
expect(onboardingMetricsTrackers[0]?.recordOpened).toHaveBeenCalledTimes(1);
expect(screen.getByTestId('truenas-section')).toBeInTheDocument();
});
it('opens the manage dialog directly from an existing source card', async () => {
@@ -28,8 +28,6 @@ import {
let mockEntitlements: LicenseEntitlements | null = null;
const trackPricingViewedMock = vi.fn();
const trackCheckoutClickedMock = vi.fn();
const loadRuntimeLicenseStatusMock = vi.fn();
const loadCommercialPostureMock = vi.fn();
const loadLicenseEntitlementsMock = vi.fn();
@@ -98,11 +96,6 @@ vi.mock('@/stores/notifications', () => ({
},
}));
vi.mock('@/utils/upgradeMetrics', () => ({
trackPricingViewed: (...args: unknown[]) => trackPricingViewedMock(...args),
trackCheckoutClicked: (...args: unknown[]) => trackCheckoutClickedMock(...args),
}));
describe('ProLicensePanel', () => {
const renderPanel = () =>
render(() => (
@@ -124,8 +117,6 @@ describe('ProLicensePanel', () => {
loadRuntimeLicenseStatusMock.mockReset();
loadCommercialPostureMock.mockReset();
loadLicenseEntitlementsMock.mockReset();
trackPricingViewedMock.mockReset();
trackCheckoutClickedMock.mockReset();
licenseEntitlementsLoadErrorMock.mockReset();
startProTrialMock.mockReset();
activateLicenseMock.mockReset();
@@ -228,10 +219,9 @@ describe('ProLicensePanel', () => {
expect(screen.queryByText('What Relay adds')).not.toBeInTheDocument();
expect(screen.queryByText('What Pulse Pro adds')).not.toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'Compare plans' })).not.toBeInTheDocument();
expect(trackPricingViewedMock).not.toHaveBeenCalled();
});
it('tracks compare-plan checkout intent from the explicit self-hosted billing handoff', async () => {
it('opens compare-plan checkout from the explicit self-hosted billing handoff', async () => {
useLocationMock.mockReturnValue({
search: `?intent=${SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_INTENT}`,
pathname: '/settings/system/billing/plan',
@@ -245,11 +235,6 @@ describe('ProLicensePanel', () => {
});
await fireEvent.click(screen.getAllByRole('link', { name: 'Compare plans' })[0]);
expect(trackCheckoutClickedMock).toHaveBeenCalledWith(
'settings_self_hosted_billing_compare_prompt',
SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_INTENT,
);
});
it('keeps explicit self-hosted plan comparison focused on optional paid extras', async () => {
@@ -11,8 +11,6 @@ const startProTrialMock = vi.fn();
const commercialPostureMock = vi.fn();
const entitlementsMock = vi.fn();
const presentationPolicyHidesUpgradePromptsMock = vi.fn();
const trackPaywallViewedMock = vi.fn();
const trackUpgradeClickedMock = vi.fn();
const getRolesMock = vi.fn();
const getUsersMock = vi.fn();
const getUserPermissionsMock = vi.fn();
@@ -45,11 +43,6 @@ vi.mock('@/stores/sessionPresentationPolicy', () => ({
presentationPolicyHidesUpgradePrompts: () => presentationPolicyHidesUpgradePromptsMock(),
}));
vi.mock('@/utils/upgradeMetrics', () => ({
trackPaywallViewed: (...args: unknown[]) => trackPaywallViewedMock(...args),
trackUpgradeClicked: (...args: unknown[]) => trackUpgradeClickedMock(...args),
}));
vi.mock('@/api/rbac', () => ({
RBACAPI: {
getRoles: (...args: unknown[]) => getRolesMock(...args),
@@ -85,8 +78,6 @@ describe('RBAC paywall settings panels', () => {
commercialPostureMock.mockReset();
entitlementsMock.mockReset();
presentationPolicyHidesUpgradePromptsMock.mockReset();
trackPaywallViewedMock.mockReset();
trackUpgradeClickedMock.mockReset();
getRolesMock.mockReset();
getUsersMock.mockReset();
getUserPermissionsMock.mockReset();
@@ -131,7 +122,7 @@ describe('RBAC paywall settings panels', () => {
render(() => <RolesPanel />);
await waitFor(() => {
expect(screen.getByText('Custom Roles (Pro)')).toBeInTheDocument();
expect(screen.getByText('Custom Roles')).toBeInTheDocument();
});
expect(screen.getByRole('link', { name: 'View plans' })).toHaveAttribute(
@@ -140,7 +131,6 @@ describe('RBAC paywall settings panels', () => {
);
expect(screen.getByRole('button', { name: 'New Role' })).toBeDisabled();
expect(getRolesMock).not.toHaveBeenCalled();
expect(trackPaywallViewedMock).toHaveBeenCalledWith('rbac', 'settings_roles_panel');
});
it('keeps RBAC upgrade actions quiet when self-hosted upgrade prompts are hidden', async () => {
@@ -156,7 +146,6 @@ describe('RBAC paywall settings panels', () => {
expect(screen.queryByText('Custom Roles (Pro)')).not.toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'View plans' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Start trial' })).not.toBeInTheDocument();
expect(trackPaywallViewedMock).not.toHaveBeenCalled();
expect(getRolesMock).not.toHaveBeenCalled();
});
@@ -169,7 +158,6 @@ describe('RBAC paywall settings panels', () => {
expect(screen.queryByText('Custom Roles (Pro)')).not.toBeInTheDocument();
expect(getRolesMock).toHaveBeenCalled();
expect(trackPaywallViewedMock).not.toHaveBeenCalled();
expect(screen.getByRole('button', { name: 'New Role' })).not.toBeDisabled();
});
@@ -179,7 +167,7 @@ describe('RBAC paywall settings panels', () => {
render(() => <UserAssignmentsPanel />);
await waitFor(() => {
expect(screen.getByText('Centralized Access Control (Pro)')).toBeInTheDocument();
expect(screen.getByText('Centralized Access Control')).toBeInTheDocument();
});
expect(screen.getByRole('link', { name: 'View plans' })).toHaveAttribute(
@@ -189,7 +177,6 @@ describe('RBAC paywall settings panels', () => {
expect(screen.getByPlaceholderText('Search users...')).toBeDisabled();
expect(getUsersMock).not.toHaveBeenCalled();
expect(getRolesMock).not.toHaveBeenCalled();
expect(trackPaywallViewedMock).toHaveBeenCalledWith('rbac', 'settings_user_assignments_panel');
});
it('loads user assignments when the RBAC entitlement is granted', async () => {
@@ -202,7 +189,6 @@ describe('RBAC paywall settings panels', () => {
expect(screen.queryByText('Centralized Access Control (Pro)')).not.toBeInTheDocument();
expect(getUsersMock).toHaveBeenCalled();
expect(getRolesMock).toHaveBeenCalled();
expect(trackPaywallViewedMock).not.toHaveBeenCalled();
expect(screen.getByPlaceholderText('Search users...')).not.toBeDisabled();
});
@@ -219,7 +205,6 @@ describe('RBAC paywall settings panels', () => {
expect(screen.queryByText('Centralized Access Control (Pro)')).not.toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'View plans' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Start trial' })).not.toBeInTheDocument();
expect(trackPaywallViewedMock).not.toHaveBeenCalled();
expect(getUsersMock).not.toHaveBeenCalled();
expect(getRolesMock).not.toHaveBeenCalled();
});
@@ -262,7 +247,7 @@ describe('RBAC paywall settings panels', () => {
render(() => <RolesPanel />);
await waitFor(() => {
expect(screen.getByText('Custom Roles (Pro)')).toBeInTheDocument();
expect(screen.getByText('Custom Roles')).toBeInTheDocument();
});
expect(screen.getByRole('link', { name: 'View plans' })).toBeInTheDocument();
@@ -18,16 +18,12 @@ vi.mock('../ResourcePicker', () => ({
}): JSX.Element => <div>Mock Resource Picker</div>,
}));
vi.mock('@/utils/upgradeMetrics', () => ({
trackUpgradeClicked: vi.fn(),
}));
const baseCatalog = {
id: 'advanced_reporting',
title: 'Detailed Reporting',
description: 'Canonical reporting surfaces',
lockedState: {
title: 'Advanced Reporting (Pro)',
title: 'Advanced Reporting',
description: 'Canonical locked reporting teaser',
},
guidance: {
@@ -10,7 +10,7 @@ const baseCatalogPayload = {
title: 'Detailed Reporting',
description: 'Canonical reporting surfaces',
lockedState: {
title: 'Advanced Reporting (Pro)',
title: 'Advanced Reporting',
description: 'Canonical locked reporting teaser',
},
guidance: {
@@ -81,7 +81,7 @@ describe('reporting catalog model', () => {
it('parses the canonical reporting catalog payload', () => {
const catalog = parseReportingCatalog(baseCatalogPayload);
expect(catalog.lockedState.title).toBe('Advanced Reporting (Pro)');
expect(catalog.lockedState.title).toBe('Advanced Reporting');
expect(catalog.guidance.title).toBe('Advanced Insights');
expect(catalog.performanceReport.defaultFormat).toBe('pdf');
expect(catalog.performanceReport.ranges[0].windowHours).toBe(24);
@@ -153,7 +153,7 @@ describe('reporting catalog model', () => {
parseReportingCatalog({
...baseCatalogPayload,
lockedState: {
title: 'Advanced Reporting (Pro)',
title: 'Advanced Reporting',
},
}),
).toThrow('Invalid reporting catalog payload');
@@ -22,6 +22,7 @@ import dataHandlingPanelSource from '../DataHandlingPanel.tsx?raw';
import auditLogPanelSource from '../AuditLogPanel.tsx?raw';
import auditWebhookPanelSource from '../AuditWebhookPanel.tsx?raw';
import reportingPanelSource from '../ReportingPanel.tsx?raw';
import rbacFeatureGateSectionSource from '../RBACFeatureGateSection.tsx?raw';
import recoverySettingsPanelSource from '../RecoverySettingsPanel.tsx?raw';
import systemLogsPanelSource from '../SystemLogsPanel.tsx?raw';
import updatesSettingsPanelSource from '../UpdatesSettingsPanel.tsx?raw';
@@ -54,10 +55,18 @@ import organizationSharingCreateSectionSource from '../OrganizationSharingCreate
import rolesEditorDialogSource from '../RolesEditorDialog.tsx?raw';
import diagnosticsResultsPanelSource from '../DiagnosticsResultsPanel.tsx?raw';
import diagnosticsModelSource from '../diagnosticsModel.ts?raw';
import agentProfilesStateSource from '../useAgentProfilesPanelState.ts?raw';
import auditLogStateSource from '../useAuditLogPanelState.ts?raw';
import auditWebhookStateSource from '../useAuditWebhookPanelState.ts?raw';
import rbacFeatureGateStateSource from '../useRBACFeatureGateState.ts?raw';
import reportingStateSource from '../useReportingPanelState.ts?raw';
import ssoProvidersPanelSource from '../SSOProvidersPanel.tsx?raw';
import ssoProvidersStateSource from '../useSSOProvidersState.ts?raw';
import infrastructureOnboardingPresentationSource from '../../../utils/infrastructureOnboardingPresentation.ts?raw';
import selfHostedBillingPresentationSource from '../selfHostedBillingPresentation.ts?raw';
import systemSettingsPresentationSource from '../../../utils/systemSettingsPresentation.ts?raw';
import auditLogPresentationSource from '../../../utils/auditLogPresentation.ts?raw';
import monitoredSystemLimitWarningBannerSource from '../../shared/MonitoredSystemLimitWarningBanner.tsx?raw';
const settingsRuntimeSources = import.meta.glob(['../*.tsx', '../ConnectionEditor/**/*.tsx'], {
query: '?raw',
@@ -197,6 +206,42 @@ describe('settings architecture guardrails', () => {
expect(aiRuntimeControlsSectionSource).not.toContain('without approval (Pro)');
});
it('keeps contextual settings feature gates free of retired commercial telemetry wrappers', () => {
for (const source of [
agentProfilesPanelSource,
agentProfilesStateSource,
aiRuntimeControlsSectionSource,
auditLogPanelSource,
auditLogStateSource,
auditWebhookPanelSource,
auditWebhookStateSource,
monitoredSystemLimitWarningBannerSource,
rbacFeatureGateSectionSource,
rbacFeatureGateStateSource,
reportingPanelSource,
reportingStateSource,
ssoProvidersPanelSource,
ssoProvidersStateSource,
]) {
expect(source).not.toContain('upgradeMetrics');
expect(source).not.toContain('conversionEvents');
expect(source).not.toContain('infrastructureOnboardingMetrics');
expect(source).not.toContain('trackPaywallViewed');
expect(source).not.toContain('trackUpgradeClicked');
expect(source).not.toContain('/api/upgrade-metrics/events');
}
expect(agentProfilesPanelSource).not.toContain('Pro feature');
expect(auditWebhookPanelSource).not.toContain('Audit Webhooks (Pro)');
expect(reportingPanelSource).not.toContain('Advanced Reporting (Pro)');
expect(rbacFeatureGateSectionSource).not.toContain('Custom Roles (Pro)');
expect(rbacFeatureGateSectionSource).not.toContain('Centralized Access Control (Pro)');
expect(ssoProvidersPanelSource).not.toContain('Add SAML (Pro)');
expect(monitoredSystemLimitWarningBannerSource).not.toContain(
'Unlimited self-hosted monitoring',
);
});
it('keeps Docker and Podman update-action copy on the system settings presentation owner', () => {
expect(dockerRuntimeSettingsCardSource).toContain('DOCKER_UPDATE_ACTIONS_SECTION_TITLE');
expect(dockerRuntimeSettingsCardSource).toContain('DOCKER_UPDATE_ACTIONS_ENV_VAR');
@@ -366,12 +411,13 @@ describe('settings architecture guardrails', () => {
expect(infrastructureWorkspaceSource).not.toContain('<ConnectionsTable rows={rows} />');
expect(infrastructureWorkspaceSource).toContain('flex h-full min-h-0 flex-col');
expect(infrastructureWorkspaceSource).toContain('showSlotHeader={false}');
expect(infrastructureWorkspaceSource).toContain('trackInitialCatalogSelection={');
expect(infrastructureWorkspaceSource).toContain(
"onDetectFromAddress={() => openAddFlow('detect')}",
);
expect(infrastructureWorkspaceSource).toContain("onBackToCatalog={() => openAddFlow('pick')}");
expect(infrastructureWorkspaceSource).toContain('recordCatalogSelection(type);');
expect(infrastructureWorkspaceSource).not.toContain('trackInitialCatalogSelection');
expect(infrastructureWorkspaceSource).not.toContain('recordCatalogSelection');
expect(infrastructureWorkspaceSource).not.toContain('onboardingMetricsTracker');
expect(infrastructureWorkspaceSource).toContain('renderAgentConnectionDetails');
expect(infrastructureWorkspaceSource).not.toContain('InfrastructureOperationsController');
expect(infrastructureWorkspaceSource).not.toContain('PlatformConnectionsWorkspace');
@@ -16,8 +16,6 @@ const showErrorMock = vi.fn();
const showWarningMock = vi.fn();
const loggerErrorMock = vi.fn();
const presentationPolicyHidesUpgradePromptsMock = vi.fn();
const trackPaywallViewedMock = vi.fn();
const trackUpgradeClickedMock = vi.fn();
vi.mock('../QuickSecuritySetup', () => ({
QuickSecuritySetup: () => <div data-testid="quick-security-setup">Quick Security Setup</div>,
@@ -69,11 +67,6 @@ vi.mock('@/utils/logger', () => ({
},
}));
vi.mock('@/utils/upgradeMetrics', () => ({
trackPaywallViewed: (...args: unknown[]) => trackPaywallViewedMock(...args),
trackUpgradeClicked: (...args: unknown[]) => trackUpgradeClickedMock(...args),
}));
describe('settings read-only panel states', () => {
beforeEach(() => {
hasFeatureMock.mockReset();
@@ -86,8 +79,6 @@ describe('settings read-only panel states', () => {
showWarningMock.mockReset();
loggerErrorMock.mockReset();
presentationPolicyHidesUpgradePromptsMock.mockReset();
trackPaywallViewedMock.mockReset();
trackUpgradeClickedMock.mockReset();
hasFeatureMock.mockReturnValue(true);
presentationPolicyHidesUpgradePromptsMock.mockReturnValue(false);
@@ -180,7 +171,6 @@ describe('settings read-only panel states', () => {
expect(screen.queryByText('Audit Webhooks (Pro)')).not.toBeInTheDocument();
expect(screen.queryByText(/require Pro/i)).not.toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'View plans' })).not.toBeInTheDocument();
expect(trackPaywallViewedMock).not.toHaveBeenCalled();
expect(apiFetchJSONMock).not.toHaveBeenCalled();
});
});
@@ -16,7 +16,7 @@ const catalogPayload = {
title: 'Detailed Reporting',
description: 'Canonical reporting surfaces',
lockedState: {
title: 'Advanced Reporting (Pro)',
title: 'Advanced Reporting',
description: 'Canonical locked reporting teaser',
},
guidance: {
@@ -105,10 +105,6 @@ describe('useReportingPanelState', () => {
showWarning: vi.fn(),
}));
vi.doMock('@/utils/upgradeMetrics', () => ({
trackPaywallViewed: vi.fn(),
}));
vi.doMock('@/stores/license', () => ({
hasFeature: vi.fn(
(feature: string) => feature === 'advanced_reporting' && hasReportingFeature,
@@ -178,7 +174,7 @@ describe('useReportingPanelState', () => {
expect(apiFetchMock).toHaveBeenCalledWith('/api/admin/reports/catalog');
expect(hookState.reportingCatalog()?.title).toBe('Detailed Reporting');
expect(hookState.reportingCatalog()?.lockedState.title).toBe('Advanced Reporting (Pro)');
expect(hookState.reportingCatalog()?.lockedState.title).toBe('Advanced Reporting');
expect(hookState.isLocked()).toBe(true);
dispose();
@@ -207,10 +203,6 @@ describe('useReportingPanelState', () => {
showWarning: vi.fn(),
}));
vi.doMock('@/utils/upgradeMetrics', () => ({
trackPaywallViewed: vi.fn(),
}));
vi.doMock('@/stores/license', () => ({
hasFeature: vi.fn(() => false),
runtimeCapabilitiesLoaded: vi.fn(() => false),
@@ -267,10 +259,6 @@ describe('useReportingPanelState', () => {
showWarning: vi.fn(),
}));
vi.doMock('@/utils/upgradeMetrics', () => ({
trackPaywallViewed: vi.fn(),
}));
vi.doMock('@/stores/license', () => ({
hasFeature: vi.fn(
(feature: string) => feature === 'advanced_reporting' && hasReportingFeature,
@@ -333,10 +321,6 @@ describe('useReportingPanelState', () => {
showWarning: vi.fn(),
}));
vi.doMock('@/utils/upgradeMetrics', () => ({
trackPaywallViewed: vi.fn(),
}));
vi.doMock('@/stores/license', () => ({
hasFeature: vi.fn(
(feature: string) => feature === 'advanced_reporting' && hasReportingFeature,
@@ -388,10 +372,6 @@ describe('useReportingPanelState', () => {
showWarning: vi.fn(),
}));
vi.doMock('@/utils/upgradeMetrics', () => ({
trackPaywallViewed: vi.fn(),
}));
vi.doMock('@/stores/license', () => ({
hasFeature: vi.fn(
(feature: string) => feature === 'advanced_reporting' && hasReportingFeature,
@@ -6,7 +6,6 @@ import { type PlatformConnectionsView } from './platformConnectionsModel';
import { buildInfrastructureWorkspacePath } from './infrastructureWorkspaceModel';
export const TOKEN_PLACEHOLDER = '<api-token>';
export const UNIFIED_AGENT_TELEMETRY_SURFACE = 'settings_unified_agents';
export type AgentPlatform = 'linux' | 'macos' | 'freebsd' | 'windows';
export type UnifiedAgentStatus = 'active' | 'removed';
@@ -111,12 +110,6 @@ export const buildDefaultTokenName = () => {
return `Agent ${stamp}`;
};
export const normalizeTelemetryPart = (value: string) =>
value
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '');
export const shellQuoteArg = (value: string) => `'${value.replace(/'/g, `'\"'\"'`)}'`;
export const getCapabilitySurfaceLabel = (capability: AgentCapability) => {
@@ -70,8 +70,9 @@ export function buildLegacyReportingCatalogFallback(): ReportingCatalog {
title: 'Detailed Reporting',
description: 'Generate performance reports across infrastructure and workloads.',
lockedState: {
title: 'Advanced Reporting (Pro)',
description: 'Generate PDF and CSV performance reports across infrastructure and workload resources.',
title: 'Advanced Reporting',
description:
'Generate PDF and CSV performance reports across infrastructure and workload resources on paid self-hosted and hosted plans.',
},
guidance: {
title: 'Advanced Insights',
@@ -1,5 +1,4 @@
import type { SettingsTab } from './settingsNavigationModel';
import { trackPaywallViewed } from '@/utils/upgradeMetrics';
import { getFeatureMinTierLabel } from '@/utils/licensePresentation';
export const tabFeatureRequirements: Partial<Record<SettingsTab, string[]>> = {
@@ -41,9 +40,6 @@ export function getTabLockReason(
if (!runtimeCapabilitiesLoaded()) return null;
if (requiredFeatures.every((feature) => hasFeature(feature))) return null;
const primaryRequiredFeature = requiredFeatures[0];
if (primaryRequiredFeature) {
trackPaywallViewed(primaryRequiredFeature, 'settings_tab');
}
const tierLabel = getFeatureMinTierLabel(primaryRequiredFeature);
return `This settings section requires ${tierLabel}.`;
}
@@ -1,4 +1,4 @@
import { createEffect, createMemo, createSignal, onMount } from 'solid-js';
import { createMemo, createSignal, onMount } from 'solid-js';
import { createStore } from 'solid-js/store';
import { AIAPI } from '@/api/ai';
import { AIChatAPI, type ChatSession, type FileChange } from '@/api/aiChat';
@@ -36,7 +36,6 @@ import {
getAISettingsToggleErrorMessage,
} from '@/utils/aiSettingsPresentation';
import { logger } from '@/utils/logger';
import { trackPaywallViewed } from '@/utils/upgradeMetrics';
export const useAISettingsState = () => {
const [settings, setSettings] = createSignal<AISettingsType | null>(null);
@@ -152,14 +151,6 @@ export const useAISettingsState = () => {
clearAIRuntimeModels();
};
createEffect((wasPaywallVisible) => {
const isPaywallVisible = form.controlLevel === 'autonomous' && autoFixLocked();
if (showUpgradePrompts() && isPaywallVisible && !wasPaywallVisible) {
trackPaywallViewed('ai_autofix', 'settings_ai_patrol_autofix');
}
return isPaywallVisible;
}, false);
const resetForm = (data: AISettingsType | null) => {
if (!data) {
setForm({
@@ -1,4 +1,4 @@
import { createEffect, createMemo, createSignal, onMount } from 'solid-js';
import { createMemo, createSignal, onMount } from 'solid-js';
import { useWebSocket } from '@/contexts/appRuntime';
import {
AgentProfilesAPI,
@@ -26,7 +26,6 @@ import {
getUpgradeActionButtonClass,
UPGRADE_ACTION_LABEL,
} from '@/utils/upgradePresentation';
import { trackPaywallViewed, trackUpgradeClicked } from '@/utils/upgradeMetrics';
import { KNOWN_SETTINGS } from './agentProfileSettings';
import {
getActionableAgentIdFromResource,
@@ -205,14 +204,6 @@ export const useAgentProfilesPanelState = () => {
}
};
createEffect((wasPaywallVisible) => {
const isPaywallVisible = !checkingLicense() && !hasAgentProfiles();
if (showUpgradePrompts() && isPaywallVisible && !wasPaywallVisible) {
trackPaywallViewed('agent_profiles', 'settings_agent_profiles_panel');
}
return isPaywallVisible;
}, false);
onMount(async () => {
await loadRuntimeCapabilities();
@@ -372,7 +363,6 @@ export const useAgentProfilesPanelState = () => {
showUpgradePrompts,
showModal,
showSuggestModal,
trackUpgradeClicked,
unknownKeys,
updateSetting,
editingProfile,
@@ -11,7 +11,6 @@ import { hasFeature, runtimeCapabilitiesLoaded } from '@/stores/license';
import { getUpgradeActionDestination } from '@/stores/licenseCommercial';
import { presentationPolicyHidesUpgradePrompts } from '@/stores/sessionPresentationPolicy';
import { loadRuntimeCapabilities } from '@/stores/license';
import { trackPaywallViewed, trackUpgradeClicked } from '@/utils/upgradeMetrics';
export interface AuditEvent {
id: string;
@@ -511,18 +510,6 @@ export const useAuditLogPanelState = () => {
const goToNextPage = () => goToOffset(pageOffset() + pageSize());
const goToLastPage = () => goToOffset((totalPages() - 1) * pageSize());
const handleUpgradeClick = () => {
trackUpgradeClicked('settings_audit_log_panel', 'audit_logging');
};
createEffect((wasPaywallVisible) => {
const isPaywallVisible = showUpgradePaywall();
if (showUpgradePrompts() && isPaywallVisible && !wasPaywallVisible) {
trackPaywallViewed('audit_logging', 'settings_audit_log_panel');
}
return isPaywallVisible;
}, false);
onMount(() => {
setIsMounted(true);
void loadRuntimeCapabilities();
@@ -586,7 +573,6 @@ export const useAuditLogPanelState = () => {
goToLastPage,
goToNextPage,
goToPreviousPage,
handleUpgradeClick,
hasNextPage,
hasResumeEvents,
hasSignedEvents,
@@ -2,11 +2,10 @@ import { createEffect, createSignal, onMount } from 'solid-js';
import { apiFetchJSON } from '@/utils/apiClient';
import { logger } from '@/utils/logger';
import { showSuccess, showWarning } from '@/utils/toast';
import { hasFeature, runtimeCapabilitiesLoaded } from '@/stores/license';
import { hasFeature } from '@/stores/license';
import { getUpgradeActionDestination } from '@/stores/licenseCommercial';
import { presentationPolicyHidesUpgradePrompts } from '@/stores/sessionPresentationPolicy';
import { loadRuntimeCapabilities } from '@/stores/license';
import { trackPaywallViewed } from '@/utils/upgradeMetrics';
import {
getAuditWebhookDuplicateUrlMessage,
getAuditWebhookInvalidUrlMessage,
@@ -83,14 +82,6 @@ export const useAuditWebhookPanelState = (canManageOverride?: boolean) => {
loadRuntimeCapabilities();
});
createEffect((wasPaywallVisible: boolean) => {
const isPaywallVisible = runtimeCapabilitiesLoaded() && !hasFeature('audit_logging');
if (showUpgradePrompts() && isPaywallVisible && !wasPaywallVisible) {
trackPaywallViewed('audit_logging', 'settings_audit_webhook_panel');
}
return isPaywallVisible;
}, false);
createEffect(() => {
if (hasFeature('audit_logging')) {
void fetchWebhooks();
@@ -26,10 +26,6 @@ import {
resolveInstallerBaseUrl,
} from '@/utils/agentInstallCommand';
import { getUnifiedAgentClipboardCopyErrorMessage } from '@/utils/unifiedAgentInventoryPresentation';
import {
trackAgentInstallProfileSelected,
trackAgentInstallTokenGenerated,
} from '@/utils/upgradeMetrics';
import {
buildCommandsByPlatform,
buildDefaultTokenName,
@@ -39,7 +35,6 @@ import {
type AgentPlatform,
type InstallProfile,
type SetupHandoffState,
UNIFIED_AGENT_TELEMETRY_SURFACE,
} from './infrastructureOperationsModel';
export interface InfrastructureInstallStateOptions {
@@ -256,7 +251,6 @@ Pulse prepares the first-host install token from setup so you can move straight
setTokenName('');
setConfirmedNoToken(false);
setSetupHandoffAutoTokenFailed(false);
trackAgentInstallTokenGenerated(UNIFIED_AGENT_TELEMETRY_SURFACE, source);
if (options.notifySuccess) {
notificationStore.success(
'Token generated with Agent config + reporting, Docker, and Kubernetes permissions.',
@@ -435,7 +429,6 @@ Pulse prepares the first-host install token from setup so you can move straight
const handleInstallProfileChange = (profile: InstallProfile) => {
setInstallProfile(profile);
trackAgentInstallProfileSelected(UNIFIED_AGENT_TELEMETRY_SURFACE, profile);
};
const commandSections = createMemo(() => {
@@ -49,7 +49,6 @@ import {
SELF_HOSTED_PRO_BILLING_PURCHASE_QUERY_PARAM,
SELF_HOSTED_PRO_BILLING_USAGE_HREF,
SELF_HOSTED_PRO_BILLING_USAGE_ROUTE,
isSelfHostedPurchaseStartDestination,
type SelfHostedBillingPlanIntent,
type SelfHostedBillingSection,
} from '@/utils/pricingHandoff';
@@ -65,7 +64,6 @@ import {
getMonitoredSystemLimitUsageSummary,
resolveMonitoredSystemCapacityStatus,
} from '@/utils/monitoredSystemPresentation';
import { trackCheckoutClicked, trackPricingViewed } from '@/utils/upgradeMetrics';
import { resolveUpgradeDestination, type UpgradeDestination } from '@/utils/upgradeNavigation';
import { SELF_HOSTED_PRO_BILLING_PRESENTATION } from './selfHostedBillingPresentation';
@@ -228,27 +226,6 @@ export function useProLicensePanelState() {
navigate(SELF_HOSTED_PRO_BILLING_PLAN_ROUTE, { replace: true, scroll: false });
});
let trackedPlanPricingView = false;
createEffect(() => {
const planVisible =
panelDataSettled() &&
activeSection() === 'plan' &&
getSelfHostedBillingPlanIntent(location.search) ===
SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_INTENT;
if (!planVisible) {
trackedPlanPricingView = false;
return;
}
if (trackedPlanPricingView) {
return;
}
trackPricingViewed(
'settings_self_hosted_billing_plan',
SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_INTENT,
);
trackedPlanPricingView = true;
});
const setActiveSection = (section: string) => {
if (section !== 'plan' && section !== 'usage') {
return;
@@ -296,13 +273,6 @@ export function useProLicensePanelState() {
};
});
const handlePlanSelectionPromptClick = () => {
trackCheckoutClicked(
'settings_self_hosted_billing_compare_prompt',
SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_INTENT,
);
};
const statusPresentation = createMemo(() =>
getLicenseSubscriptionStatusPresentation(subscriptionState()),
);
@@ -477,17 +447,6 @@ export function useProLicensePanelState() {
}
});
const handlePurchaseActivationActionClick = () => {
const action = purchaseActivationAction();
if (!action || !isSelfHostedPurchaseStartDestination(action.destination.href)) {
return;
}
trackCheckoutClicked(
'settings_self_hosted_billing_purchase_return',
purchaseActivationIntent() ?? SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_INTENT,
);
};
const commercialMigrationNotice = createMemo(() =>
getCommercialMigrationNotice(entitlements()?.commercial_migration),
);
@@ -630,10 +589,8 @@ export function useProLicensePanelState() {
loading,
looksLikeLegacyLicenseKey,
planSelectionPrompt,
handlePlanSelectionPromptClick,
purchaseActivationNotice,
purchaseActivationAction,
handlePurchaseActivationActionClick,
setActiveSection,
setLicenseKey,
showUsageSection,
@@ -1,9 +1,8 @@
import { Accessor, createEffect, createMemo, onMount } from 'solid-js';
import { Accessor, createMemo, onMount } from 'solid-js';
import { hasFeature, runtimeCapabilitiesLoaded } from '@/stores/license';
import { presentationPolicyHidesUpgradePrompts } from '@/stores/sessionPresentationPolicy';
import { loadRuntimeCapabilities } from '@/stores/license';
import { getRBACFeatureGateCopy, type RBACFeatureGateCopy } from '@/utils/rbacPresentation';
import { trackPaywallViewed } from '@/utils/upgradeMetrics';
export type RBACFeatureGateKind = 'roles' | 'user-assignments';
export type RBACFeatureGateLocation = 'settings_roles_panel' | 'settings_user_assignments_panel';
@@ -29,14 +28,6 @@ export function useRBACFeatureGateState(options: UseRBACFeatureGateStateOptions)
void loadRuntimeCapabilities();
});
createEffect((wasPaywallVisible) => {
const isPaywallVisible = paywallVisible();
if (showUpgradePrompts() && isPaywallVisible && !wasPaywallVisible) {
trackPaywallViewed('rbac', options.paywallLocation);
}
return isPaywallVisible;
}, false);
return {
featureGateCopy,
licenseReady,
@@ -1,9 +1,8 @@
import { createEffect, createMemo, createSignal, onCleanup, onMount } from 'solid-js';
import { hasFeature, runtimeCapabilitiesLoaded } from '@/stores/license';
import { createMemo, createSignal, onCleanup, onMount } from 'solid-js';
import { hasFeature } from '@/stores/license';
import { getUpgradeActionDestination } from '@/stores/licenseCommercial';
import { presentationPolicyHidesUpgradePrompts } from '@/stores/sessionPresentationPolicy';
import { loadRuntimeCapabilities } from '@/stores/license';
import { trackPaywallViewed } from '@/utils/upgradeMetrics';
import { showError, showSuccess } from '@/utils/toast';
import { RelayAPI, type RelayConfig, type RelayStatus } from '@/api/relay';
import { OnboardingAPI, type OnboardingQRResponse } from '@/api/onboarding';
@@ -41,14 +40,6 @@ export function useRelaySettingsPanelState(props: RelaySettingsPanelProps) {
const statusErrorMessage = createMemo(() => getRelayStatusErrorMessage(status()));
const canShowPairing = createMemo(() => Boolean(config()?.enabled && status()?.connected));
createEffect((wasPaywallVisible: boolean) => {
const isPaywallVisible = runtimeCapabilitiesLoaded() && !relayEnabled();
if (showUpgradePrompts() && isPaywallVisible && !wasPaywallVisible) {
trackPaywallViewed('relay', 'settings_relay_panel');
}
return isPaywallVisible;
}, false);
let statusInterval: ReturnType<typeof setInterval> | undefined;
const resetPairingState = () => {
@@ -6,7 +6,6 @@ import { hasFeature, runtimeCapabilitiesLoaded } from '@/stores/license';
import { getUpgradeActionDestination } from '@/stores/licenseCommercial';
import { presentationPolicyHidesUpgradePrompts } from '@/stores/sessionPresentationPolicy';
import { loadRuntimeCapabilities } from '@/stores/license';
import { trackPaywallViewed } from '@/utils/upgradeMetrics';
import {
getReportingCatalogErrorMessage,
getReportingGenerateErrorMessage,
@@ -58,14 +57,6 @@ export const useReportingPanelState = () => {
loadRuntimeCapabilities();
});
createEffect((wasVisible: boolean) => {
const visible = isLocked();
if (showUpgradePrompts() && visible && !wasVisible) {
trackPaywallViewed(reportingFeatureId(), 'settings_reporting_panel');
}
return visible;
}, false);
const loadReportingCatalog = async () => {
if (reportingCatalogLoading()) {
return;
@@ -1,4 +1,4 @@
import { createEffect, createMemo, createSignal, onMount } from 'solid-js';
import { createMemo, createSignal, onMount } from 'solid-js';
import { createStore } from 'solid-js/store';
import { notificationStore } from '@/stores/notifications';
import { logger } from '@/utils/logger';
@@ -6,7 +6,6 @@ import { hasFeature, runtimeCapabilitiesLoaded } from '@/stores/license';
import { getUpgradeActionDestination } from '@/stores/licenseCommercial';
import { presentationPolicyHidesUpgradePrompts } from '@/stores/sessionPresentationPolicy';
import { loadRuntimeCapabilities } from '@/stores/license';
import { trackPaywallViewed, trackUpgradeClicked } from '@/utils/upgradeMetrics';
import {
getSSOCopySuccessMessage,
getSSOConnectionTestErrorMessage,
@@ -66,23 +65,6 @@ export const useSSOProvidersState = (props: SSOProvidersPanelProps) => {
const canManage = () => props.canManage !== false;
const showUpgradePrompts = () => !presentationPolicyHidesUpgradePrompts();
createEffect((wasBannerVisible) => {
const isBannerVisible =
showUpgradePrompts() && runtimeCapabilitiesLoaded() && !hasAdvancedSSO() && !loading();
if (isBannerVisible && !wasBannerVisible) {
trackPaywallViewed('advanced_sso', 'settings_sso_providers_banner');
}
return isBannerVisible;
}, false);
createEffect((wasUpsellVisible) => {
const isUpsellVisible = showUpgradePrompts() && showSamlUpsell();
if (isUpsellVisible && !wasUpsellVisible) {
trackPaywallViewed('advanced_sso', 'settings_sso_providers_add_saml_gate');
}
return isUpsellVisible;
}, false);
const loadProviders = async () => {
setLoading(true);
try {
@@ -347,6 +329,5 @@ export const useSSOProvidersState = (props: SSOProvidersPanelProps) => {
fetchMetadataPreview,
getUpgradeActionDestination,
runtimeCapabilitiesLoaded,
trackUpgradeClicked,
};
};
@@ -6,7 +6,6 @@ import { getPulseBaseUrl } from '@/utils/url';
import type { State } from '@/types/api';
import type { Resource } from '@/types/resource';
import { buildInfrastructureOnboardingPath } from '@/components/Settings/infrastructureWorkspaceModel';
import { trackAgentFirstConnected } from '@/utils/upgradeMetrics';
import type { WizardState } from '../SetupWizard';
import {
buildSetupCompletionConnectedSystems,
@@ -36,7 +35,6 @@ const SOURCE_STRATEGY_OPTIONS = [
const ADD_INFRASTRUCTURE_PATH = buildInfrastructureOnboardingPath('pick');
const AGENT_INSTALL_PATH = buildInfrastructureOnboardingPath('agent');
const SETUP_WIZARD_TELEMETRY_SURFACE = 'setup_wizard_complete';
export const SetupCompletionPanel: Component<CompleteStepProps> = (props) => {
const [copied, setCopied] = createSignal<'password' | 'admin-token' | null>(null);
@@ -44,7 +42,6 @@ export const SetupCompletionPanel: Component<CompleteStepProps> = (props) => {
const [connectedSystems, setConnectedSystems] = createSignal<
ReturnType<typeof buildSetupCompletionConnectedSystems>
>([]);
let firstAgentConnectionTracked = false;
createEffect(() => {
if (props.connectedResourcesOverride !== undefined) {
@@ -81,14 +78,6 @@ export const SetupCompletionPanel: Component<CompleteStepProps> = (props) => {
);
});
if (
!firstAgentConnectionTracked &&
nextConnectedSystems.some((system) => system.connectionPath === 'agent')
) {
trackAgentFirstConnected(SETUP_WIZARD_TELEMETRY_SURFACE, 'first_agent');
firstAgentConnectionTracked = true;
}
if (hasConnectionChanges) {
setConnectedSystems(nextConnectedSystems);
}
@@ -31,6 +31,8 @@ describe('SetupCompletionPanel guardrails', () => {
expect(setupCompletionPanelSource).not.toContain('Monitor from Anywhere');
expect(setupCompletionPanelSource).not.toContain("trackPaywallViewed('relay'");
expect(setupCompletionPanelSource).not.toContain("trackUpgradeClicked('setup_wizard'");
expect(setupCompletionPanelSource).not.toContain('infrastructureOnboardingMetrics');
expect(setupCompletionPanelSource).not.toContain('trackAgentFirstConnected');
expect(setupCompletionPanelSource).not.toContain('getUpgradeActionUrlOrFallback');
});
@@ -7,7 +7,6 @@ const copyToClipboardMock = vi.fn();
const apiFetchJSONMock = vi.fn();
const createObjectURLMock = vi.fn(() => 'blob:mock-url');
const revokeObjectURLMock = vi.fn();
const trackAgentFirstConnectedMock = vi.fn();
class MockBlob {
readonly parts: string[];
@@ -50,10 +49,6 @@ vi.mock('@/utils/logger', () => ({
},
}));
vi.mock('@/utils/upgradeMetrics', () => ({
trackAgentFirstConnected: (...args: unknown[]) => trackAgentFirstConnectedMock(...args),
}));
const baseState: WizardState = {
username: 'admin',
password: 'password',
@@ -213,10 +208,6 @@ describe('SetupCompletionPanel', () => {
expect(screen.getAllByRole('button', { name: 'Open Infrastructure' }).length).toBeGreaterThan(0);
expect(screen.getAllByRole('button', { name: 'Add infrastructure' }).length).toBeGreaterThan(0);
expect(screen.queryByRole('button', { name: 'Install Pulse Agent' })).not.toBeInTheDocument();
expect(trackAgentFirstConnectedMock).toHaveBeenCalledWith(
'setup_wizard_complete',
'first_agent',
);
const nextStepHeading = screen.getByRole('heading', { name: 'Open Infrastructure' });
const nextStepCard = nextStepHeading.closest('[aria-label="Setup next step"]');
@@ -271,7 +262,6 @@ describe('SetupCompletionPanel', () => {
expect(screen.getByRole('button', { name: 'Open Infrastructure' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Add infrastructure' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Install Pulse Agent' })).not.toBeInTheDocument();
expect(trackAgentFirstConnectedMock).not.toHaveBeenCalled();
const nextStepHeading = screen.getByRole('heading', { name: 'Open Infrastructure' });
const nextStepCard = nextStepHeading.closest('[aria-label="Setup next step"]');
@@ -375,10 +365,6 @@ describe('SetupCompletionPanel', () => {
).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Add infrastructure' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Install Pulse Agent' })).not.toBeInTheDocument();
expect(trackAgentFirstConnectedMock).toHaveBeenCalledWith(
'setup_wizard_complete',
'first_agent',
);
});
it('keeps connected governed infrastructure on local operator identity', async () => {
@@ -28,7 +28,6 @@ export const MonitoredSystemLimitWarningBanner: Component = () => {
<UpgradeLink
class="text-xs font-medium underline underline-offset-2 hover:opacity-90"
destination={state.installCollectorsDestination()}
onClick={state.handleInstallCollectorsClick}
>
{MONITORED_SYSTEM_LIMIT_INSTALL_COLLECTORS_LABEL}
</UpgradeLink>
@@ -897,7 +897,7 @@ describe('shared primitive guardrails', () => {
expect(monitoredSystemLimitWarningBannerStateSource).toContain(
'export function useMonitoredSystemLimitWarningBannerState',
);
expect(monitoredSystemLimitWarningBannerStateSource).toContain('createEffect');
expect(monitoredSystemLimitWarningBannerStateSource).not.toContain('createEffect');
expect(monitoredSystemLimitWarningBannerStateSource).toContain('createMemo');
expect(monitoredSystemLimitWarningBannerStateSource).toContain('loadRuntimeCapabilities');
expect(monitoredSystemLimitWarningBannerStateSource).toContain(
@@ -910,7 +910,7 @@ describe('shared primitive guardrails', () => {
expect(monitoredSystemLimitWarningBannerStateSource).toContain(
'presentationPolicyHidesUpgradePrompts',
);
expect(monitoredSystemLimitWarningBannerStateSource).toContain('trackUpgradeMetricEvent');
expect(monitoredSystemLimitWarningBannerStateSource).not.toContain('trackUpgradeMetricEvent');
expect(monitoredSystemLimitWarningBannerStateSource).toContain('hasMigrationGap');
expect(monitoredSystemLimitWarningBannerStateSource).not.toContain(
'scopeSelfHostedBillingDestination',
@@ -32,8 +32,6 @@ const mockLegacyConnections = vi.hoisted(() =>
kubernetes_clusters: 0,
})),
);
const mockTrackUpgradeMetricEvent = vi.hoisted(() => vi.fn());
const mockTrackUpgradeClicked = vi.hoisted(() => vi.fn());
const mockLoadRuntimeLicenseStatus = vi.hoisted(() => vi.fn());
const mockPresentationPolicyHidesCommercialSurfaces = vi.hoisted(() => vi.fn(() => false));
const mockPresentationPolicyHidesUpgradePrompts = vi.hoisted(() => vi.fn(() => false));
@@ -60,14 +58,6 @@ vi.mock('@/stores/sessionPresentationPolicy', () => ({
presentationPolicyHidesUpgradePrompts: () => mockPresentationPolicyHidesUpgradePrompts(),
}));
vi.mock('@/utils/upgradeMetrics', () => ({
UPGRADE_METRIC_EVENTS: {
LIMIT_WARNING_SHOWN: 'limit_warning_shown',
},
trackUpgradeMetricEvent: mockTrackUpgradeMetricEvent,
trackUpgradeClicked: mockTrackUpgradeClicked,
}));
describe('MonitoredSystemLimitWarningBanner', () => {
beforeEach(() => {
localStorage.clear();
@@ -126,7 +116,7 @@ describe('MonitoredSystemLimitWarningBanner', () => {
expect(monitoredSystemLimitWarningBannerStateSource).toContain(
'export function useMonitoredSystemLimitWarningBannerState',
);
expect(monitoredSystemLimitWarningBannerStateSource).toContain('createEffect');
expect(monitoredSystemLimitWarningBannerStateSource).not.toContain('createEffect');
expect(monitoredSystemLimitWarningBannerStateSource).toContain('createMemo');
expect(monitoredSystemLimitWarningBannerStateSource).toContain('loadRuntimeCapabilities');
expect(monitoredSystemLimitWarningBannerStateSource).not.toContain('loadCommercialPosture');
@@ -137,7 +127,7 @@ describe('MonitoredSystemLimitWarningBanner', () => {
expect(monitoredSystemLimitWarningBannerStateSource).toContain(
'presentationPolicyHidesUpgradePrompts',
);
expect(monitoredSystemLimitWarningBannerStateSource).toContain('trackUpgradeMetricEvent');
expect(monitoredSystemLimitWarningBannerStateSource).not.toContain('trackUpgradeMetricEvent');
expect(monitoredSystemLimitWarningBannerStateSource).toContain('hasMigrationGap');
expect(monitoredSystemLimitWarningBannerStateSource).not.toContain(
'scopeSelfHostedBillingDestination',
@@ -244,7 +234,6 @@ describe('MonitoredSystemLimitWarningBanner', () => {
expect(screen.queryByText(/Monitored systems:/i)).not.toBeInTheDocument();
expect(screen.queryByText('Review options')).not.toBeInTheDocument();
expect(mockTrackUpgradeMetricEvent).not.toHaveBeenCalled();
});
it('keeps urgent limit warnings visible with migration context', async () => {
@@ -299,7 +288,6 @@ describe('MonitoredSystemLimitWarningBanner', () => {
expect(
screen.queryByText('5 monitored systems.'),
).not.toBeInTheDocument();
expect(mockTrackUpgradeMetricEvent).not.toHaveBeenCalled();
});
it('stays hidden when self-hosted upgrade prompts are suppressed', async () => {
@@ -322,7 +310,6 @@ describe('MonitoredSystemLimitWarningBanner', () => {
expect(
screen.queryByText('5 monitored systems.'),
).not.toBeInTheDocument();
expect(mockTrackUpgradeMetricEvent).not.toHaveBeenCalled();
});
it('stays hidden for self-hosted installs even when stale continuity metadata is urgent', async () => {
@@ -343,6 +330,5 @@ describe('MonitoredSystemLimitWarningBanner', () => {
expect(
screen.queryByText('5 monitored systems.'),
).not.toBeInTheDocument();
expect(mockTrackUpgradeMetricEvent).not.toHaveBeenCalled();
});
});
@@ -1,4 +1,4 @@
import { createEffect, createMemo, onMount } from 'solid-js';
import { createMemo, onMount } from 'solid-js';
import {
presentationPolicyHidesCommercialSurfaces,
presentationPolicyHidesUpgradePrompts,
@@ -11,11 +11,6 @@ import {
} from '@/stores/license';
import { hasMigrationGap } from '@/stores/licenseCommercial';
import { resolveUpgradeDestination } from '@/utils/upgradeNavigation';
import {
trackUpgradeClicked,
trackUpgradeMetricEvent,
UPGRADE_METRIC_EVENTS,
} from '@/utils/upgradeMetrics';
import {
getMonitoredSystemBannerToneClass,
getMonitoredSystemSummary,
@@ -54,32 +49,8 @@ export function useMonitoredSystemLimitWarningBannerState() {
const installCollectorsDestination = createMemo(() =>
resolveUpgradeDestination(MONITORED_SYSTEM_LIMIT_INSTALL_COLLECTORS_HREF),
);
let wasUrgent = false;
createEffect(() => {
const urgent = isUrgent();
const visible = showBanner();
const limit = monitoredSystemLimit();
if (visible && urgent && !wasUrgent && limit) {
trackUpgradeMetricEvent({
type: UPGRADE_METRIC_EVENTS.LIMIT_WARNING_SHOWN,
surface: 'monitored_system_limit_banner',
limit_key: MONITORED_SYSTEM_LIMIT_KEY,
current_value: limit.current,
limit_value: limit.limit,
});
}
wasUrgent = visible && urgent;
});
const handleInstallCollectorsClick = () => {
trackUpgradeClicked(
'monitored_system_limit_banner_install_v6_collectors',
MONITORED_SYSTEM_LIMIT_KEY,
);
};
return {
handleInstallCollectorsClick,
installCollectorsDestination,
isUrgent,
reviewPolicyDestination,
@@ -2,8 +2,6 @@ import { For, Show } from 'solid-js';
import { A } from '@solidjs/router';
import { Card } from '@/components/shared/Card';
import { PageHeader } from '@/components/shared/PageHeader';
import { trackPaywallViewed } from '@/utils/upgradeMetrics';
import { onMount } from 'solid-js';
import {
CLOUD_COMMERCIAL_PRESENTATION,
CLOUD_PLAN_DEFINITIONS,
@@ -81,10 +79,6 @@ function CloudTierCard(props: { tier: CloudPlanDefinition }) {
// ---------------------------------------------------------------------------
export default function CloudPricing() {
onMount(() => {
trackPaywallViewed('cloud_pricing', 'cloud_pricing_page');
});
return (
<div class="space-y-8">
<PageHeader
@@ -1,7 +1,6 @@
import { Navigate, useLocation } from '@solidjs/router';
import { Show, createMemo, onMount } from 'solid-js';
import { PageHeader } from '@/components/shared/PageHeader';
import { trackPaywallViewed } from '@/utils/upgradeMetrics';
import {
getPricingRouteDestination,
handoffToExternalPricing,
@@ -25,7 +24,6 @@ export default function PricingHandoff() {
);
onMount(() => {
trackPaywallViewed('pricing', 'pricing_handoff');
if (externalDestination() || selfHostedPurchaseStartDestination()) {
handoffToExternalPricing(destination());
}
@@ -107,8 +107,6 @@ const loadLicenseStatusMock = vi.fn();
const loadCommercialPostureMock = vi.fn();
const getUpgradeActionDestinationMock = vi.fn();
const getUpgradeActionUrlOrFallbackMock = vi.fn();
const trackPaywallViewedMock = vi.fn();
const trackUpgradeClickedMock = vi.fn();
const presentationPolicyHidesUpgradePromptsMock = vi.fn();
const notificationSuccessMock = vi.fn();
const notificationErrorMock = vi.fn();
@@ -148,11 +146,6 @@ vi.mock('@/stores/licenseCommercial', () => ({
getUpgradeActionUrlOrFallback: (...args: unknown[]) => getUpgradeActionUrlOrFallbackMock(...args),
}));
vi.mock('@/utils/upgradeMetrics', () => ({
trackPaywallViewed: (...args: unknown[]) => trackPaywallViewedMock(...args),
trackUpgradeClicked: (...args: unknown[]) => trackUpgradeClickedMock(...args),
}));
vi.mock('@/stores/sessionPresentationPolicy', () => ({
presentationPolicyHidesUpgradePrompts: () => presentationPolicyHidesUpgradePromptsMock(),
}));
@@ -343,8 +336,6 @@ describe('AIIntelligence entitlement gating', () => {
loadCommercialPostureMock.mockReset();
getUpgradeActionDestinationMock.mockReset();
getUpgradeActionUrlOrFallbackMock.mockReset();
trackPaywallViewedMock.mockReset();
trackUpgradeClickedMock.mockReset();
presentationPolicyHidesUpgradePromptsMock.mockReset();
notificationSuccessMock.mockReset();
notificationErrorMock.mockReset();
@@ -583,7 +574,6 @@ describe('AIIntelligence entitlement gating', () => {
expect(screen.queryByRole('link', { name: 'Upgrade to Pro' })).not.toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'Upgrade' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /start free trial/i })).not.toBeInTheDocument();
expect(trackPaywallViewedMock).not.toHaveBeenCalled();
});
it('locks paid patrol controls without upgrade prompts in default self-hosted mode', async () => {
@@ -610,7 +600,6 @@ describe('AIIntelligence entitlement gating', () => {
expect(screen.queryByRole('link', { name: 'Upgrade to Pro' })).not.toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'Upgrade' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /start free trial/i })).not.toBeInTheDocument();
expect(trackPaywallViewedMock).not.toHaveBeenCalled();
});
it('unlocks paid patrol controls when the entitlement grants the features', async () => {
@@ -633,7 +622,6 @@ describe('AIIntelligence entitlement gating', () => {
expect(screen.getByRole('button', { name: 'Remediate' })).not.toBeDisabled();
expect(screen.queryByRole('link', { name: 'Upgrade to Pro' })).not.toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'Upgrade' })).not.toBeInTheDocument();
expect(trackPaywallViewedMock).not.toHaveBeenCalled();
});
it('renders the canonical intelligence summary card with recent changes', async () => {
@@ -85,10 +85,6 @@ vi.mock('@/utils/logger', () => ({
logger: { error: vi.fn() },
}));
vi.mock('@/utils/upgradeMetrics', () => ({
trackPaywallViewed: vi.fn(),
}));
vi.mock('@/features/alerts/OverviewTab', () => ({
OverviewTab: overviewTabSpy,
}));
@@ -1,13 +1,9 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { afterEach, describe, expect, it } from 'vitest';
import { cleanup, render, screen } from '@solidjs/testing-library';
import { Router, Route } from '@solidjs/router';
import CloudPricing from '@/pages/CloudPricing';
vi.mock('@/utils/upgradeMetrics', () => ({
trackPaywallViewed: vi.fn(),
}));
describe('CloudPricing', () => {
afterEach(() => {
cleanup();
@@ -5,13 +5,8 @@ import PricingHandoff from '@/pages/PricingHandoff';
import pricingHandoffSource from '@/pages/PricingHandoff.tsx?raw';
import { getSelfHostedPurchaseStartUrl } from '@/utils/pricingHandoff';
const trackPaywallViewedMock = vi.fn();
const handoffToExternalPricingMock = vi.fn();
vi.mock('@/utils/upgradeMetrics', () => ({
trackPaywallViewed: (...args: unknown[]) => trackPaywallViewedMock(...args),
}));
vi.mock('@/utils/pricingHandoff', async () => {
const actual =
await vi.importActual<typeof import('@/utils/pricingHandoff')>('@/utils/pricingHandoff');
@@ -23,7 +18,6 @@ vi.mock('@/utils/pricingHandoff', async () => {
describe('PricingHandoff', () => {
beforeEach(() => {
trackPaywallViewedMock.mockReset();
handoffToExternalPricingMock.mockReset();
window.scrollTo = vi.fn();
});
@@ -1,10 +1,19 @@
import { describe, expect, it } from 'vitest';
import {
getAgentProfileAssignmentsEmptyState,
getAgentProfilesFeatureGateCopy,
getAgentProfilesEmptyState,
} from '@/utils/agentProfilesPresentation';
describe('agentProfilesPresentation', () => {
it('returns capability-focused feature gate copy', () => {
expect(getAgentProfilesFeatureGateCopy()).toMatchObject({
title: 'Agent Profiles',
subtitle: 'Centralized agent configuration',
body: expect.not.stringContaining('Pro'),
});
});
it('returns canonical agent profile empty-state copy', () => {
expect(getAgentProfilesEmptyState()).toBe('No profiles yet. Create one to get started.');
expect(getAgentProfileAssignmentsEmptyState()).toBe(
@@ -40,7 +40,7 @@ describe('aiControlLevelPresentation', () => {
});
expect(getAIChatControlLevelPresentation('autonomous')).toMatchObject({
label: 'Autonomous',
description: 'Executes without approval (Pro)',
description: 'Executes without approval',
dotClassName: 'bg-red-500',
});
});
@@ -11,9 +11,10 @@ import {
describe('auditWebhookPresentation', () => {
it('returns canonical feature gate copy', () => {
expect(getAuditWebhookFeatureGateCopy()).toMatchObject({
title: 'Audit Webhooks (Pro)',
body: expect.stringContaining('require Pro'),
title: 'Audit Webhooks',
body: expect.stringContaining('paid self-hosted and hosted plans'),
});
expect(getAuditWebhookFeatureGateCopy().body).not.toContain('Pro');
});
it('returns neutral feature gate copy when commercial prompts are hidden', () => {
@@ -1650,8 +1650,13 @@ describe('frontend resource type boundaries', () => {
expect(userAssignmentsPanelSource).not.toContain('Users sync on first login');
expect(rbacFeatureGateStateSource).toContain('getRBACFeatureGateCopy');
expect(rbacPresentationSource).toContain('export function getRBACFeatureGateCopy');
expect(rbacPresentationSource).not.toContain('(Pro)');
expect(rbacPresentationSource).toContain('export function getRolesEmptyState');
expect(rbacPresentationSource).toContain('export function getUserAssignmentsEmptyStateCopy');
expect(agentProfilesPanelSource).not.toContain('Pro feature');
expect(agentProfilesPresentationSource).toContain(
'export function getAgentProfilesFeatureGateCopy',
);
expect(agentProfilesPresentationSource).toContain('export function getAgentProfilesEmptyState');
expect(agentProfilesPresentationSource).toContain(
'export function getAgentProfileAssignmentsEmptyState',
@@ -2933,10 +2938,10 @@ describe('frontend resource type boundaries', () => {
expect(monitoredSystemLimitWarningBannerSource).not.toContain('loadRuntimeCapabilities');
expect(monitoredSystemLimitWarningBannerSource).not.toContain('trackUpgradeMetricEvent');
expect(monitoredSystemLimitWarningBannerSource).not.toContain('legacyConnections()');
expect(monitoredSystemLimitWarningBannerStateSource).toContain('createEffect');
expect(monitoredSystemLimitWarningBannerStateSource).not.toContain('createEffect');
expect(monitoredSystemLimitWarningBannerStateSource).toContain('createMemo');
expect(monitoredSystemLimitWarningBannerStateSource).toContain('loadRuntimeCapabilities');
expect(monitoredSystemLimitWarningBannerStateSource).toContain('trackUpgradeMetricEvent');
expect(monitoredSystemLimitWarningBannerStateSource).not.toContain('trackUpgradeMetricEvent');
expect(monitoredSystemLimitWarningBannerStateSource).toContain('hasMigrationGap');
expect(monitoredSystemLimitWarningBannerStateSource).toContain('reviewPolicyDestination');
expect(monitoredSystemLimitWarningBannerStateSource).not.toContain('handleUpgradeClick');
@@ -4130,6 +4135,8 @@ describe('frontend resource type boundaries', () => {
expect(ssoProvidersPanelSource).not.toContain('const loadProviders = async () =>');
expect(ssoProvidersPanelSource).not.toContain('const handleSave = async (');
expect(ssoProvidersPanelSource).not.toContain('const testConnection = async () =>');
expect(ssoProvidersPanelSource).not.toContain('Pro feature');
expect(ssoProvidersPanelSource).not.toContain('requires Pro');
expect(ssoProvidersPanelSource).not.toContain('provider.type.toUpperCase()');
expect(ssoProvidersPanelSource).not.toContain("provider.type === 'oidc' ? (");
expect(ssoProviderPresentationSource).toContain('export function getSSOProviderTypeLabel');
@@ -4143,6 +4150,8 @@ describe('frontend resource type boundaries', () => {
expect(ssoProviderPresentationSource).toContain(
'export function getSSOProviderEmptyStateDescription',
);
expect(ssoProviderPresentationSource).toContain('export function getSSOSamlFeatureGateCopy');
expect(ssoProviderPresentationSource).not.toContain('(Pro)');
expect(ssoProviderPresentationSource).toContain('export function getSSOProvidersLoadingState');
expect(ssoProviderPresentationSource).toContain('export function getSSOTestResultPresentation');
expect(ssoProviderPresentationSource).toContain(
@@ -4194,6 +4203,8 @@ describe('frontend resource type boundaries', () => {
expect(auditWebhookPresentationSource).toContain(
'export function getAuditWebhookFeatureGateCopy',
);
expect(auditWebhookPresentationSource).not.toContain('Audit Webhooks (Pro)');
expect(auditWebhookPresentationSource).not.toContain('require Pro');
expect(auditWebhookPresentationSource).toContain(
'export function getAuditWebhookEmptyStateCopy',
);
@@ -4214,7 +4225,7 @@ describe('frontend resource type boundaries', () => {
expect(auditWebhookPresentationSource).toContain('AUDIT_WEBHOOK_SECURITY_NOTE_BODY');
expect(auditWebhookStateSource).toContain('export const useAuditWebhookPanelState =');
expect(auditWebhookStateSource).toContain('loadRuntimeCapabilities();');
expect(auditWebhookStateSource).toContain('trackPaywallViewed');
expect(auditWebhookStateSource).not.toContain('trackPaywallViewed');
expect(auditWebhookStateSource).toContain('const fetchWebhooks = async () =>');
expect(auditWebhookStateSource).toContain('const saveWebhooks = async (urls: string[]) =>');
expect(auditLogPanelSource).toContain('getAuditLogLoadingState');
@@ -4231,7 +4242,7 @@ describe('frontend resource type boundaries', () => {
expect(auditLogStateSource).toContain('createLocalStorageStringSignal');
expect(auditLogStateSource).toContain('const fetchAuditEvents = async (');
expect(auditLogStateSource).toContain('const verifyAllEvents = async (');
expect(auditLogStateSource).toContain('trackPaywallViewed');
expect(auditLogStateSource).not.toContain('trackPaywallViewed');
expect(auditLogPanelSource).toContain('getAuditEventTypeBadgeClass');
expect(auditLogPanelSource).toContain('getAuditVerificationBadgePresentation');
expect(auditLogPanelSource).toContain('getAuditEventStatusPresentation');
@@ -1,44 +0,0 @@
import { describe, expect, it } from 'vitest';
import {
clearSharedInfrastructureOnboardingMetricsTracker,
createInfrastructureOnboardingMetricsTracker,
getSharedInfrastructureOnboardingMetricsTracker,
} from '@/utils/infrastructureOnboardingMetrics';
import infrastructureOnboardingMetricsSource from '@/utils/infrastructureOnboardingMetrics.ts?raw';
describe('infrastructureOnboardingMetrics', () => {
it('does not bridge infrastructure onboarding to maintainer analytics', () => {
expect(infrastructureOnboardingMetricsSource).not.toContain('trackUpgradeMetricEvent');
expect(infrastructureOnboardingMetricsSource).not.toContain('UPGRADE_METRIC_EVENTS');
expect(infrastructureOnboardingMetricsSource).not.toContain('/api/upgrade-metrics/events');
expect(infrastructureOnboardingMetricsSource).not.toContain('sessionStorage');
});
it('keeps the tracker contract callable as a compatibility no-op', () => {
const tracker = createInfrastructureOnboardingMetricsTracker();
tracker.recordOpened();
tracker.recordOpened();
tracker.recordPathSelected('api');
tracker.recordPathSelected('api');
tracker.recordProbeResult('no-match');
tracker.recordProbeResult('error');
tracker.recordCatalogSelected('truenas');
tracker.recordCatalogSelected('truenas');
tracker.recordCredentialsOpened('truenas');
tracker.recordCredentialsOpened('truenas');
});
it('returns the same no-op tracker for created and shared flows', () => {
const firstTracker = createInfrastructureOnboardingMetricsTracker();
const secondTracker = getSharedInfrastructureOnboardingMetricsTracker();
expect(firstTracker).toBe(secondTracker);
clearSharedInfrastructureOnboardingMetricsTracker();
const thirdTracker = getSharedInfrastructureOnboardingMetricsTracker();
expect(thirdTracker).toBe(firstTracker);
});
});
@@ -14,10 +14,12 @@ import {
describe('rbacPresentation', () => {
it('returns canonical feature gate copy', () => {
expect(getRBACFeatureGateCopy('roles')).toMatchObject({
title: 'Custom Roles (Pro)',
title: 'Custom Roles',
body: expect.stringContaining('paid self-hosted and hosted plans'),
});
expect(getRBACFeatureGateCopy('user-assignments')).toMatchObject({
title: 'Centralized Access Control (Pro)',
title: 'Centralized Access Control',
body: expect.stringContaining('paid self-hosted and hosted plans'),
});
});
@@ -24,6 +24,7 @@ import {
getSSOProviderToggleSuccessMessage,
getSSOProviderTypeBadgeClass,
getSSOProviderTypeLabel,
getSSOSamlFeatureGateCopy,
getSSOTestResultPresentation,
} from '../ssoProviderPresentation';
@@ -32,7 +33,7 @@ describe('ssoProviderPresentation', () => {
expect(getSSOProviderTypeLabel('oidc')).toBe('OIDC');
expect(getSSOProviderTypeLabel('saml')).toBe('SAML');
expect(getSSOProviderAddButtonLabel('oidc')).toBe('Add OIDC');
expect(getSSOProviderAddButtonLabel('saml', true)).toBe('Add SAML (Pro)');
expect(getSSOProviderAddButtonLabel('saml')).toBe('Add SAML');
expect(getSSOProviderModalTitle(false, 'saml')).toBe('Add SAML Provider');
expect(getSSOProviderEmptyStateTitle()).toBe('No SSO providers configured');
expect(getSSOProviderEmptyStateDescription()).toBe(
@@ -55,6 +56,15 @@ describe('ssoProviderPresentation', () => {
).toBe('https://idp.example.com/entity');
});
it('returns capability-focused SAML feature gate copy', () => {
expect(getSSOSamlFeatureGateCopy()).toMatchObject({
title: 'Advanced SSO',
subtitle: 'Advanced SSO',
body: expect.stringContaining('paid self-hosted and hosted plans'),
});
expect(getSSOSamlFeatureGateCopy().body).not.toContain('Pro');
});
it('formats provider card and test result tones canonically', () => {
expect(getSSOProviderCardClass(true)).toContain('bg-surface');
expect(getSSOProviderCardClass(false)).toContain('opacity-60');
@@ -1,41 +0,0 @@
import { describe, expect, it } from 'vitest';
import {
trackAgentFirstConnected,
trackAgentInstallCommandCopied,
trackAgentInstallProfileSelected,
trackAgentInstallTokenGenerated,
trackCheckoutClicked,
trackPaywallViewed,
trackPricingViewed,
trackUpgradeClicked,
trackUpgradeMetricEvent,
} from '@/utils/upgradeMetrics';
import upgradeMetricsSource from '@/utils/upgradeMetrics.ts?raw';
describe('upgradeMetrics customer frontend boundary', () => {
it('does not carry browser-side ingestion plumbing for maintainer analytics', () => {
expect(upgradeMetricsSource).not.toContain('/api/upgrade-metrics/events');
expect(upgradeMetricsSource).not.toContain('@/utils/apiClient');
expect(upgradeMetricsSource).not.toContain('apiFetch(');
expect(upgradeMetricsSource).not.toContain('fetch(');
expect(upgradeMetricsSource).not.toContain('sendBeacon');
});
it('keeps compatibility wrappers callable without emitting product analytics', () => {
trackPaywallViewed('rbac', 'settings_roles_panel');
trackPricingViewed('settings_self_hosted_billing_plan', 'self_hosted_plan');
trackUpgradeClicked('settings_reporting_panel', 'reporting');
trackCheckoutClicked('settings_self_hosted_billing_compare_prompt', 'self_hosted_plan');
trackAgentInstallTokenGenerated('settings_unified_agents', 'manual');
trackAgentInstallCommandCopied('settings_unified_agents', 'linux:auto:install');
trackAgentInstallProfileSelected('settings_unified_agents', 'proxmox-pbs');
trackAgentFirstConnected('setup_wizard_complete', 'first_agent');
trackUpgradeMetricEvent({
type: 'agent_install_command_copied',
surface: 'settings_unified_agents',
capability: 'linux:auto:install:custom',
idempotencyKey: 'custom-1',
});
});
});
@@ -1,3 +1,17 @@
export interface AgentProfilesFeatureGateCopy {
title: string;
subtitle: string;
body: string;
}
export function getAgentProfilesFeatureGateCopy(): AgentProfilesFeatureGateCopy {
return {
title: 'Agent Profiles',
subtitle: 'Centralized agent configuration',
body: 'Create reusable configuration profiles for your agents. Manage Docker monitoring, logging, and reporting intervals from a central location.',
};
}
export function getAgentProfilesEmptyState(): string {
return 'No profiles yet. Create one to get started.';
}
@@ -53,7 +53,7 @@ export function getAIChatControlLevelPresentation(
case 'autonomous':
return {
label: 'Autonomous',
description: 'Executes without approval (Pro)',
description: 'Executes without approval',
pillClassName:
'border-red-200 text-red-700 bg-red-50 dark:border-red-800 dark:text-red-200 dark:bg-red-900',
dotClassName: 'bg-red-500',
@@ -38,8 +38,8 @@ export function getAuditWebhookFeatureGateCopy(
}
return {
title: 'Audit Webhooks (Pro)',
body: 'Audit webhooks are part of the audit logging feature set and require Pro.',
title: 'Audit Webhooks',
body: 'Audit webhook delivery is available on paid self-hosted and hosted plans.',
};
}
@@ -1,14 +0,0 @@
// Compatibility wrapper for retired customer-side commercial analytics exports.
// Keep these stable while older modules/tests migrate away from the names.
export type { UpgradeMetricEvent as ConversionEvent } from './upgradeMetrics';
export {
UPGRADE_METRIC_EVENTS as CONVERSION_EVENTS,
trackUpgradeMetricEvent as trackConversionEvent,
trackPaywallViewed,
trackUpgradeClicked,
trackAgentInstallTokenGenerated,
trackAgentInstallCommandCopied,
trackAgentInstallProfileSelected,
trackAgentFirstConnected,
} from './upgradeMetrics';
@@ -1,51 +0,0 @@
import type { ConnectionType } from '@/api/connections';
export type InfrastructureOnboardingPath = 'api' | 'agent';
export type InfrastructureOnboardingProbeOutcome = 'detected' | 'no-match' | 'error';
export const INFRASTRUCTURE_ONBOARDING_METRICS_SURFACE = 'settings_infrastructure_add';
export interface InfrastructureOnboardingMetricsTracker {
recordOpened: () => void;
recordPathSelected: (path: InfrastructureOnboardingPath) => void;
recordProbeResult: (outcome: InfrastructureOnboardingProbeOutcome) => void;
recordCatalogSelected: (type: ConnectionType) => void;
recordCredentialsOpened: (type: ConnectionType) => void;
}
const NOOP_INFRASTRUCTURE_ONBOARDING_METRICS_TRACKER: InfrastructureOnboardingMetricsTracker = {
recordOpened() {
// Compatibility no-op.
},
recordPathSelected(_path) {
// Compatibility no-op.
},
recordProbeResult(_outcome) {
// Compatibility no-op.
},
recordCatalogSelected(_type) {
// Compatibility no-op.
},
recordCredentialsOpened(_type) {
// Compatibility no-op.
},
};
export function createInfrastructureOnboardingMetricsTracker(
_surface = INFRASTRUCTURE_ONBOARDING_METRICS_SURFACE,
_flowId?: string,
): InfrastructureOnboardingMetricsTracker {
return NOOP_INFRASTRUCTURE_ONBOARDING_METRICS_TRACKER;
}
export function getSharedInfrastructureOnboardingMetricsTracker(
_surface = INFRASTRUCTURE_ONBOARDING_METRICS_SURFACE,
): InfrastructureOnboardingMetricsTracker {
return NOOP_INFRASTRUCTURE_ONBOARDING_METRICS_TRACKER;
}
export function clearSharedInfrastructureOnboardingMetricsTracker(
_surface = INFRASTRUCTURE_ONBOARDING_METRICS_SURFACE,
): void {
// Compatibility no-op.
}
@@ -21,19 +21,17 @@ export function getRBACFeatureGateCopy(
const showCommercialCopy = options.showCommercialCopy !== false;
if (kind === 'roles') {
return {
title: showCommercialCopy ? 'Custom Roles (Pro)' : 'Custom Roles',
title: 'Custom Roles',
body: showCommercialCopy
? 'Define granular permissions and custom access tiers for your team.'
? 'Define granular permissions and custom access tiers on paid self-hosted and hosted plans.'
: 'Define granular permissions and custom access tiers when RBAC is enabled for this instance.',
};
}
return {
title: showCommercialCopy
? 'Centralized Access Control (Pro)'
: 'Centralized Access Control',
title: 'Centralized Access Control',
body: showCommercialCopy
? 'Assign multi-tier roles to users and manage infrastructure-wide security policies.'
? 'Assign multi-tier roles to users and manage infrastructure-wide security policies on paid self-hosted and hosted plans.'
: 'Assign roles to users and review access policy when RBAC is enabled for this instance.',
};
}
@@ -1,5 +1,11 @@
export type SSOProviderType = 'oidc' | 'saml';
export interface SSOProviderFeatureGateCopy {
title: string;
subtitle: string;
body: string;
}
export interface SSOProviderSummaryLike {
type: SSOProviderType;
oidcIssuerUrl?: string;
@@ -11,10 +17,16 @@ export function getSSOProviderTypeLabel(type: SSOProviderType): string {
return type === 'oidc' ? 'OIDC' : 'SAML';
}
export function getSSOProviderAddButtonLabel(type: SSOProviderType, gated = false): string {
return gated
? `Add ${getSSOProviderTypeLabel(type)} (Pro)`
: `Add ${getSSOProviderTypeLabel(type)}`;
export function getSSOProviderAddButtonLabel(type: SSOProviderType): string {
return `Add ${getSSOProviderTypeLabel(type)}`;
}
export function getSSOSamlFeatureGateCopy(): SSOProviderFeatureGateCopy {
return {
title: 'Advanced SSO',
subtitle: 'Advanced SSO',
body: 'SAML 2.0 and multi-provider SSO are available on paid self-hosted and hosted plans. Basic OIDC remains available without a paid plan.',
};
}
export function getSSOProviderModalTitle(editing: boolean, type: SSOProviderType): string {
@@ -1,81 +0,0 @@
export interface UpgradeMetricEvent {
type: string;
capability?: string;
surface: string;
tenant_mode?: string;
limit_key?: string;
current_value?: number;
limit_value?: number;
timestamp: number;
idempotency_key: string;
}
export interface TrackUpgradeMetricEventInput {
type: string;
surface: string;
capability?: string;
tenant_mode?: string;
limit_key?: string;
current_value?: number;
limit_value?: number;
idempotencyKey?: string;
}
export const UPGRADE_METRIC_EVENTS = {
PRICING_VIEWED: 'pricing_viewed',
PAYWALL_VIEWED: 'paywall_viewed',
TRIAL_STARTED: 'trial_started',
LICENSE_ACTIVATED: 'license_activated',
UPGRADE_CLICKED: 'upgrade_clicked',
CHECKOUT_CLICKED: 'checkout_clicked',
LIMIT_WARNING_SHOWN: 'limit_warning_shown',
LIMIT_BLOCKED: 'limit_blocked',
AGENT_INSTALL_TOKEN_GENERATED: 'agent_install_token_generated',
AGENT_INSTALL_COMMAND_COPIED: 'agent_install_command_copied',
AGENT_INSTALL_PROFILE_SELECTED: 'agent_install_profile_selected',
AGENT_FIRST_CONNECTED: 'agent_first_connected',
INFRASTRUCTURE_ONBOARDING_OPENED: 'infrastructure_onboarding_opened',
INFRASTRUCTURE_ONBOARDING_PATH_SELECTED: 'infrastructure_onboarding_path_selected',
INFRASTRUCTURE_ONBOARDING_PROBE_RESULT: 'infrastructure_onboarding_probe_result',
INFRASTRUCTURE_ONBOARDING_CATALOG_SELECTED: 'infrastructure_onboarding_catalog_selected',
INFRASTRUCTURE_ONBOARDING_CREDENTIALS_OPENED: 'infrastructure_onboarding_credentials_opened',
} as const;
export function trackUpgradeMetricEvent(
_event: TrackUpgradeMetricEventInput,
): void {
// Compatibility no-op: customer frontend surfaces must not emit maintainer
// commercial, funnel, or onboarding analytics.
}
export function trackPaywallViewed(_capability: string, _surface: string): void {
// Compatibility no-op.
}
export function trackPricingViewed(_surface: string, _capability?: string): void {
// Compatibility no-op.
}
export function trackUpgradeClicked(_surface: string, _capability?: string): void {
// Compatibility no-op.
}
export function trackCheckoutClicked(_surface: string, _capability?: string): void {
// Compatibility no-op.
}
export function trackAgentInstallTokenGenerated(_surface: string, _capability?: string): void {
// Compatibility no-op.
}
export function trackAgentInstallCommandCopied(_surface: string, _capability?: string): void {
// Compatibility no-op.
}
export function trackAgentInstallProfileSelected(_surface: string, _profile: string): void {
// Compatibility no-op.
}
export function trackAgentFirstConnected(_surface: string, _capability?: string): void {
// Compatibility no-op.
}
+2 -2
View File
@@ -2497,8 +2497,8 @@ func TestContract_ReportingCatalogJSONSnapshot(t *testing.T) {
"title":"Detailed Reporting",
"description":"Generate performance reports and current-state exports across infrastructure and workloads.",
"lockedState":{
"title":"Advanced Reporting (Pro)",
"description":"Generate PDF and CSV performance reports plus current-state VM inventory exports across infrastructure and workload resources."
"title":"Advanced Reporting",
"description":"Generate PDF and CSV performance reports plus current-state VM inventory exports across infrastructure and workload resources on paid self-hosted and hosted plans."
},
"guidance":{
"title":"Advanced Insights",
+2 -2
View File
@@ -180,8 +180,8 @@ func DescribeReportingCatalog() ReportingCatalog {
Title: "Detailed Reporting",
Description: "Generate performance reports and current-state exports across infrastructure and workloads.",
LockedState: ReportingLockedStateDefinition{
Title: "Advanced Reporting (Pro)",
Description: "Generate PDF and CSV performance reports plus current-state VM inventory exports across infrastructure and workload resources.",
Title: "Advanced Reporting",
Description: "Generate PDF and CSV performance reports plus current-state VM inventory exports across infrastructure and workload resources on paid self-hosted and hosted plans.",
},
Guidance: ReportingGuidanceDefinition{
Title: "Advanced Insights",
+2 -2
View File
@@ -14,8 +14,8 @@ func TestDescribeReportingCatalog_DefinesCanonicalSurfaces(t *testing.T) {
if catalog.PerformanceReport.ID != "performance_reports" {
t.Fatalf("performance report ID = %q, want performance_reports", catalog.PerformanceReport.ID)
}
if catalog.LockedState.Title != "Advanced Reporting (Pro)" {
t.Fatalf("locked state title = %q, want Advanced Reporting (Pro)", catalog.LockedState.Title)
if catalog.LockedState.Title != "Advanced Reporting" {
t.Fatalf("locked state title = %q, want Advanced Reporting", catalog.LockedState.Title)
}
if catalog.Guidance.Title != "Advanced Insights" {
t.Fatalf("guidance title = %q, want Advanced Insights", catalog.Guidance.Title)
@@ -3606,7 +3606,7 @@ class SubsystemLookupTest(unittest.TestCase):
{
"heading": "## Shared Boundaries",
"path": "internal/api/access_control_handlers.go",
"line": 149,
"line": 156,
"heading_line": 91,
}
],