Keep self-hosted Pro prompts opt-in

This commit is contained in:
rcourtman 2026-04-28 11:23:49 +01:00
parent 6516a864b9
commit ecf8fd4299
30 changed files with 229 additions and 348 deletions

View file

@ -161,7 +161,7 @@ Self-hosted tiers:
|---|---:|---|---:|---|
| Community | Free | Unlimited | 7 days | Full self-hosted monitoring |
| Relay | $4.99/mo or $39/yr | Unlimited | 14 days | Remote access, mobile, and push notifications |
| Pro | $8.99/mo or $79/yr | Unlimited | 90 days | Root-cause analysis, safe auto-fix, and operations tooling |
| Pro | $8.99/mo or $79/yr | Unlimited | 90 days | Root-cause analysis, safe remediation workflows, and operations tooling |
Pulse still counts top-level monitored systems once no matter how they are
collected. VMs, containers, pods, disks, backups, and other child resources
@ -181,7 +181,7 @@ Runtime-aligned capability summary:
| Pulse Patrol (Background Health Checks) | ✅ | ✅ | ✅ | ✅ |
| Remote Access / Mobile / Push | — | ✅ | ✅ | ✅ |
| Alert-Triggered Root-Cause Analysis | — | — | ✅ | ✅ |
| Pulse Patrol Auto-Fix | — | — | ✅ | ✅ |
| Safe Remediation Workflows | — | — | ✅ | ✅ |
| Centralized Agent Profiles | — | — | ✅ | ✅ |
| Update Alerts (Container/Package Updates) | ✅ | ✅ | ✅ | ✅ |
| Basic SSO (OIDC) | ✅ | ✅ | ✅ | ✅ |
@ -207,7 +207,7 @@ Community installs continue with BYOK. Chat Assistant remains BYOK.
Technical highlights:
- Cross-system context (nodes, VMs, backups, containers, and metrics history)
- LLM analysis with your provider plus alert-triggered root-cause investigations (Pro / hosted Cloud)
- Optional auto-fix with command safety policies and audit trail
- Optional safe remediation execution with command safety policies and audit trail
- Centralized agent profiles for consistent fleet settings
**[Try the live demo →](https://demo.pulserelay.pro)** or **[learn more at pulserelay.pro](https://pulserelay.pro)**

View file

@ -602,7 +602,7 @@ type Config struct {
DockerRuntime string // Force container runtime: docker, podman, or auto
// Security
EnableCommands bool // Enable command execution for AI auto-fix (disabled by default)
EnableCommands bool // Enable command execution for Patrol remediation (disabled by default)
// Enrollment
Enroll bool // Exchange bootstrap token for runtime token on startup
@ -726,7 +726,7 @@ func loadConfig(args []string, getenv func(string) string) (Config, error) {
disableAutoUpdateFlag := fs.Bool("disable-auto-update", utils.ParseBool(envDisableAutoUpdate), "Disable automatic updates")
disableDockerUpdateChecksFlag := fs.Bool("disable-docker-update-checks", utils.ParseBool(envDisableDockerUpdateChecks), "Disable Docker image update detection (avoids Docker Hub rate limits)")
dockerRuntimeFlag := fs.String("docker-runtime", envDockerRuntime, "Container runtime: auto, docker, or podman (default: auto)")
enableCommandsFlag := fs.Bool("enable-commands", utils.ParseBool(envEnableCommands), "Enable command execution for AI auto-fix (disabled by default)")
enableCommandsFlag := fs.Bool("enable-commands", utils.ParseBool(envEnableCommands), "Enable command execution for Patrol remediation (disabled by default)")
disableCommandsFlag := fs.Bool("disable-commands", false, "[DEPRECATED] Commands are now disabled by default; use --enable-commands to enable")
enrollFlag := fs.Bool("enroll", false, "Exchange bootstrap token for runtime token (used by deploy wizard)")
healthAddrFlag := fs.String("health-addr", defaultHealthAddr, "Health/metrics server address (empty to disable)")

View file

@ -21,6 +21,21 @@ import (
"github.com/rs/zerolog"
)
func TestEnableCommandsHelpUsesPatrolRemediationCopy(t *testing.T) {
source, err := os.ReadFile("main.go")
if err != nil {
t.Fatalf("read pulse-agent main.go: %v", err)
}
text := string(source)
if !strings.Contains(text, "Enable command execution for Patrol remediation (disabled by default)") {
t.Fatal("expected enable-commands help to describe Patrol remediation")
}
if strings.Contains(text, "Enable command execution for AI auto-fix") {
t.Fatal("enable-commands help must not revive AI auto-fix wording")
}
}
func TestGatherTags(t *testing.T) {
tests := []struct {
name string

View file

@ -25,13 +25,13 @@ Patrol autonomy controls how aggressively Patrol responds to findings.
|-------|-----|:------:|:-----------:|:-------------:|:--------------:|------|
| **Monitor** | `monitor` | Yes | No | No | No | Community |
| **Approval** | `approval` | Yes | Yes | Approval required | Approval required | Pro / legacy Pro+ / Cloud |
| **Assisted** | `assisted` | Yes | Yes | Auto-fix | Approval required | Pro / legacy Pro+ / Cloud |
| **Full** | `full` | Yes | Yes | Auto-fix | Auto-fix | Pro / legacy Pro+ / Cloud |
| **Assisted** | `assisted` | Yes | Yes | Execute automatically | Approval required | Pro / legacy Pro+ / Cloud |
| **Full** | `full` | Yes | Yes | Execute automatically | Execute automatically | Pro / legacy Pro+ / Cloud |
- **Monitor** (default): Patrol creates findings but takes no action. This is the Community and Relay baseline. Suitable for learning what Patrol detects before enabling investigation or remediation.
- **Approval** (Pro and above): Patrol investigates findings and proposes fixes. All fixes queue for manual approval before execution.
- **Assisted** (Pro and above): Warning-level findings are auto-fixed. Critical findings still require approval. This is the recommended starting point for most Pro and legacy Pro+ users who enable fix execution.
- **Full** (Pro and above): All findings are auto-fixed without approval. Requires an explicit toggle and a Pro, legacy Pro+, or Cloud license. Recommended only for environments with thorough alert coverage.
- **Assisted** (Pro and above): Warning-level safe remediation plans can execute automatically. Critical findings still require approval. This is the recommended starting point for most Pro and legacy Pro+ users who enable fix execution.
- **Full** (Pro and above): Safe remediation plans can execute without approval. Requires an explicit toggle and a Pro, legacy Pro+, or Cloud license. Recommended only for environments with thorough alert coverage.
### Configuration
@ -148,7 +148,7 @@ For new deployments, we recommend gradually increasing autonomy:
1. **Start with Monitor** — Run Patrol for a few cycles to see what it detects. Dismiss false positives.
2. **Move to Approval where available** — Enable investigation. Review proposed fixes to build confidence.
3. **Use Assisted when fix execution is enabled** — Let Patrol auto-fix warnings while you approve critical fixes.
3. **Use Assisted when fix execution is enabled** — Let Patrol execute warning-level remediation while you approve critical fixes.
4. **Consider Full** — Only if your environment has comprehensive alerting and you trust the fix patterns.
---

View file

@ -259,6 +259,7 @@ the manifest's support-floor row.
7. Add or change the unified agent CLI entrypoint, version/help exit semantics, or startup argument/error routing through `cmd/pulse-agent/main.go`.
The CLI entrypoint owns propagation of persistence context into runtime-owned helpers. When installer-selected state roots differ from the default, `cmd/pulse-agent/main.go` must pass that exact `StateDir` through both the host-agent runtime and updater startup paths instead of letting one path silently fall back to `/var/lib/pulse-agent`.
The same runtime-owned boundary also owns Pulse control-plane URL validation for agent startup, remote config, updater continuity, and command transport. Non-loopback control-plane URLs remain HTTPS/WSS by default, but explicitly insecure agent/dev-runtime flows may use plain HTTP/WS for LAN development control planes; installer-persisted dev URLs must not be accepted by one runtime path and rejected by another.
The unified agent CLI copy follows the same Patrol remediation vocabulary as the install surface. `cmd/pulse-agent/main.go` may keep the `--enable-commands` flag name for compatibility, but the help text and inline comments must describe command execution as Patrol remediation rather than reviving AI auto-fix language.
8. Add or change installer flags, persisted service arguments, or upgrade-safe re-entry behavior through `scripts/install.sh` and `scripts/install.ps1`.
Persistence-sensitive NAS targets must keep one canonical continuity model here: installer-owned bootstraps may use flash-backed or immutable-root launch hooks only as thin trampolines, while the durable wrapper, state, and reboot-surviving binary copy stay in the governed persistent state directory that updater continuity also refreshes.
Approval-gated command execution must expose stable rejection reasons for

View file

@ -131,8 +131,9 @@ runtime cost control, and shared AI transport surfaces.
11. Keep AI runtime transport compatibility separate from operator-facing
product copy. Existing Patrol payload fields such as `fixed_count`,
`auto_fix_model`, and `patrol_auto_fix` may remain stable wire/API names,
but frontend comments, status labels, and commercial prompts that describe
the capability must use safe remediation or remediation wording.
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.
## Current State

View file

@ -338,11 +338,13 @@ the canonical monitored-system blocked payload.
25. Keep long-range workload chart transport time-proportional on the shared API surface: `internal/api/router.go`, `internal/api/contract_test.go`, and workload chart consumers must cap mixed-cadence workload history by equal-time buckets rather than raw point index for the per-workload and aggregate workload chart APIs, so 7-day and 30-day workload cards do not bunch recent samples at the right edge just because recent telemetry is stored more densely.
26. Keep chart timestamp precision canonical on that same shared API surface: when `internal/api/router.go` serializes monitoring history into infrastructure or workload chart payloads, it must preserve canonical millisecond timestamps from the shared monitoring timeline instead of rounding through whole-second conversion, so seeded mock history and live appends collapse onto one operator-visible timeline instead of appearing as duplicated tail samples.
27. Keep Patrol remediation payload naming backward-compatible without leaking
legacy automation-first wording into product copy. `frontend-modern/src/api/patrol.ts`
may continue to expose stable transport fields such as `fixed_count` and
legacy `auto_fix` capability names, but comments and presentation labels
layered on that API contract must describe the operator-visible capability
as remediation or safe remediation workflows.
legacy automation-first wording into product copy. `frontend-modern/src/api/patrol.ts`,
`internal/api/ai_handlers.go`, and `internal/api/router_routes_ai_relay.go`
may continue to expose stable transport fields such as `fixed_count`,
`auto_fix`, `autofix`, and the `ai_autofix` capability name, but comments,
API license-required messages, and presentation labels layered on that API
contract must describe the operator-visible capability as remediation or
safe remediation workflows.
27. Keep storage chart identity canonical on that same shared API surface: the shared storage charts endpoint must key pool and physical-disk series by the resolved unified-resource `MetricsTarget.ResourceID`, not by canonical resource IDs or page-local aliases, so storage rows, focused summary cards, sticky summary shells, and detail charts all address the same history series in live and mock mode.
28. Keep synthetic summary-chart fallback identity canonical on that same shared API surface: when `internal/api/router.go` has to synthesize mock summary history for infrastructure, workloads, or storage cards, it must derive the fallback from canonical `resourceType`, `resourceID`, and `metricType` ownership instead of raw min/max seed-prefix helpers, so range changes and runtime mock updates stay on one governed timeline.
The same compact chart boundary also owns aggregate-only storage summary

View file

@ -491,9 +491,10 @@ Community limit enforcement.
may keep checkout, activation, recovery, and support-only trial plumbing
available for explicit handoffs and entitled installs, but default
self-hosted browser surfaces must honor `presentationPolicy.hideUpgrade`
and suppress Relay/Pro plan comparison, Pro trial CTAs, paid-only settings
navigation, and feature upsells unless hosted mode, direct intent,
activation/recovery state, or active entitlement makes them relevant.
and suppress Relay/Pro plan comparison, Pro trial CTAs, monitored-system
limit pressure, paid-only settings navigation, and feature upsells unless
hosted mode, direct intent, activation/recovery state, or active entitlement
makes them relevant.
## Current State
@ -504,9 +505,12 @@ self-hosted billing plan page instead of the Pulse Account purchase-start
handoff. The purchase-start handoff requires a `PublicURL` and fails on local
instances; routing these keys to the in-product billing plan keeps upgrades
accessible from self-hosted environments.
The monitored-system app-shell warning CTA now follows that same self-hosted
commercial boundary by reviewing finite-policy usage on the usage ledger rather
than sending operators to the plan-selection surface with capacity-shaped copy.
The monitored-system app-shell warning CTA now follows that same commercial
boundary by rendering only in hosted mode. Ordinary self-hosted installs must
not see finite monitored-system pressure in the global app shell; when hosted
capacity policy is active, the banner reviews finite-policy usage on the usage
ledger rather than sending operators to the plan-selection surface with
capacity-shaped copy.
Cloud paid readiness is materially behind architecture work. The main concern is
contract coherence between pricing, entitlements, and runtime enforcement.
@ -1554,7 +1558,8 @@ for that feature's owning subsystem — those are user-initiated discovery
paths, not blanket funnels, and are not required to be removed.
Public AI and entitlement docs must use the same boundary: Community/Relay may
describe Patrol background findings with BYOK, while investigation, proposed
remediation, auto-fix, and higher autonomy remain paid AI-operations features.
remediation, safe remediation execution, and higher autonomy remain paid
AI-operations features.
Those docs should describe moving between available modes, not tell readers to
"upgrade" as part of an ordinary safety progression.
That same counted-unit boundary also owns the disclosure rule for retail copy:

View file

@ -795,12 +795,13 @@ work extends shared components instead of creating new local variants.
takeover between tabs.
35. Keep self-hosted paid-service prompts opt-in at the shared shell layer.
`settingsNavCatalog.ts`, `settingsNavVisibility.ts`, shared upgrade link
primitives, trial banners, and history-lock overlays must honor
`presentationPolicy.hideUpgrade` by hiding paid prompts by default on
ordinary self-hosted installs. Direct activation/recovery routes may
render their owned content, but sidebar discovery, trial CTAs, plan upsells,
and feature upgrade links must require hosted mode, explicit handoff, or
active entitlement.
primitives, trial banners, monitored-system warning banners, history-lock
overlays, and Patrol lock helpers must honor `presentationPolicy.hideUpgrade`
by hiding paid prompts by default on ordinary self-hosted installs. Direct
activation/recovery routes may render their owned content, but sidebar
discovery, trial CTAs, plan upsells, monitored-system limit pressure, and
feature upgrade links must require hosted mode, explicit handoff, or active
entitlement.
## Current State
@ -812,9 +813,10 @@ infrastructure area uses `InfrastructurePanelStep` in-page state.
The shared monitored-system warning banner now uses a neutral policy-review
CTA and `reviewPolicyDestination` state, keeping the render shell pointed at
the usage-owned policy ledger instead of plan-selection or capacity wording.
It also follows the resolved session-presentation upgrade policy, so ordinary
self-hosted sessions with `presentationPolicy.hideUpgrade` do not render the
banner, its plan-review link, or its upgrade-impression telemetry.
It also requires hosted mode and follows the resolved session-presentation
upgrade policy, so ordinary self-hosted sessions do not render the banner, its
plan-review link, or its upgrade-impression telemetry even when stale finite
policy data is present.
Shared alert presentation surfaces (`OverviewTab.tsx`, `HistoryTab.tsx`,
`AlertOverviewActiveAlertsSection.tsx`, `AlertHistoryTableSection.tsx`,
`AlertHistoryTableAlertRow.tsx`, `AlertOverviewAlertCard.tsx`) no longer accept
@ -1386,11 +1388,13 @@ parallel modal-stack bookkeeping.
The shared history chart now follows the same owner shape.
`frontend-modern/src/components/shared/HistoryChart.tsx` stays the render
shell, `frontend-modern/src/components/shared/useHistoryChartState.ts` owns
license gating, trial actions, history fetch/refresh, canvas draw lifecycle,
and hover state, and `frontend-modern/src/components/shared/historyChartModel.ts`
owns tooltip formatting, scale and axis math, and closest-point selection.
Future history-chart work should extend those owners instead of pushing fetch,
license, or canvas math back into the shared component shell.
license gating, history fetch/refresh, canvas draw lifecycle, and hover state,
and `frontend-modern/src/components/shared/historyChartModel.ts` owns tooltip
formatting, scale and axis math, and closest-point selection. Lock overlays in
ordinary self-hosted surfaces must stay informational rather than presenting
trial-start or upgrade-link actions. Future history-chart work should extend
those owners instead of pushing fetch, license, commercial trial actions, or
canvas math back into the shared component shell.
The remaining header, overlay, and tooltip render surfaces now live in
`frontend-modern/src/components/shared/HistoryChartHeader.tsx`,
`frontend-modern/src/components/shared/HistoryChartOverlay.tsx`, and
@ -2113,20 +2117,22 @@ agent-era banner filename or component name as the primary primitive.
That shared monitored-system warning banner now also follows the shell/runtime/model
owner split. `frontend-modern/src/components/shared/MonitoredSystemLimitWarningBanner.tsx`
stays the render shell, `frontend-modern/src/components/shared/useMonitoredSystemLimitWarningBannerState.ts`
owns entitlement load, warning metric emission, migration click tracking, and
policy-review plus collector-link runtime, and
owns hosted-mode eligibility, entitlement load, warning metric emission,
migration click tracking, and policy-review plus collector-link runtime, and
`frontend-modern/src/components/shared/monitoredSystemLimitWarningBannerModel.ts`
owns monitored-system warning policy, count aggregation, and tone/text-class
policy while sourcing customer-facing monitored-system copy from the canonical
`frontend-modern/src/utils/monitoredSystemPresentation.ts` helper. Future
warning-banner work should extend those owners instead of pushing entitlement
state or route selection back into the render shell. When the warning points at
the self-hosted commercial surface, the shared primitive must stay a compact
state, hosted-mode checks, or route selection back into the render shell. In
ordinary self-hosted mode the banner must remain absent rather than turning
stale finite policy data into monitored-system limit pressure. When hosted
capacity policy is active, the shared primitive must stay a compact
policy-review pointer into the usage-owned ledger rather than a plan-selection
or "capacity" CTA. The banner may signal the current monitored-system posture
and link into the owned usage surface, but the longer over-plan or continuity
explanation belongs in the bounded legacy usage ledger and commercial detail
sections owned by `cloud-paid`, not in permanent app-shell banner copy.
or "capacity" CTA. The banner may signal the current hosted monitored-system
posture and link into the owned usage surface, but the longer over-plan or
continuity explanation belongs in the bounded usage ledger and commercial
detail sections owned by `cloud-paid`, not in permanent app-shell banner copy.
That same shared warning boundary now also owns the monitored-system capacity
posture vocabulary. Shared banners, plan summaries, and ledger headers must
describe the canonical admission-freeze model from

View file

@ -169,14 +169,16 @@ commercial destinations from the shared license boundary, but they must leave
internal-versus-external navigation semantics to `frontend-primitives` once a
Patrol feature can resolve to either product-owned routes or public pricing.
That same Patrol-owned commercial boundary must also fail closed in public
demo runtimes. Patrol header and banner upgrade/trial affordances may render
for real customer workspaces, but the browser-owned trigger for that
suppression is now the shared resolved `presentationPolicy` from
demo and ordinary self-hosted runtimes. Patrol header, banner, approval, and
history-adjacent lock surfaces must remain informational unless the operator
is already in an entitled, hosted, or explicit commercial handoff context. The
browser-owned trigger for self-hosted suppression is the shared resolved
`presentationPolicy` from
`/api/security/status`, seeded by the backend capability fact
`sessionCapabilities.demoMode`. Patrol surfaces must therefore suppress
upgrade CTAs, trial nudges, and Pro-only helper copy only from that shared
policy instead of reviving local demo heuristics or issuing early commercial
reads before the policy resolves.
`sessionCapabilities.demoMode` plus the self-hosted upgrade policy. Patrol
surfaces must therefore suppress upgrade CTAs, trial nudges, checkout links,
and Pro-only helper copy from that shared policy instead of reviving local demo
heuristics or issuing early commercial reads before the policy resolves.
That same shared policy now also owns Patrol approval polling posture.
`frontend-modern/src/stores/aiIntelligence.ts` must fail
`loadPendingApprovals()` closed in public demo mode and return the canonical
@ -199,12 +201,12 @@ That same store-owned demo boundary also covers remediation artifacts.
`/api/ai/remediation/plans` paywall probes after demo posture has resolved.
That same posture split now also centralizes Patrol commercial bootstrap.
`frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts` and
`frontend-modern/src/components/patrol/ApprovalSection.tsx` may consume the
resolved commercial-posture store when Patrol needs upgrade or trial context,
but they must not trigger their own mount-time `loadCommercialPosture()`
reads. Authenticated Patrol shells inherit that bootstrap from
`frontend-modern/src/useAppRuntimeState.ts`, so Patrol-specific hooks do not
quietly retake ownership of commercial fetch timing.
`frontend-modern/src/components/patrol/ApprovalSection.tsx` must not mount
commercial trial or upgrade state merely because an ordinary self-hosted
operator opens Patrol. Authenticated Patrol shells inherit any allowed
commercial bootstrap from `frontend-modern/src/useAppRuntimeState.ts`, so
Patrol-specific hooks do not quietly retake ownership of commercial fetch
timing or reintroduce local trial-start actions.
Under ordinary self-hosted v6, Patrol commercial affordances must also honor
the shared `presentationPolicy.hideUpgrade` contract. A free self-hosted
install may show Patrol runtime availability and configuration gaps, but it

View file

@ -710,6 +710,12 @@ fields and nullable mode/kind metadata before presenting canonical item labels,
while storage detail drawers and filter controls must route summary series IDs,
source tones, and disk metrics through the shared storage helpers instead of
reconstructing them from local table state.
Storage and recovery's adjacent `internal/api/` contract must also preserve
the product-facing remediation vocabulary used by shared API denials. When
storage/recovery-adjacent browser sessions encounter AI or Patrol remediation
license responses while sharing the app shell, those API messages must use
safe remediation wording and must not revive `Auto-Fix` as customer-facing
paid copy.
That same adjacent `internal/api/` router boundary now also keeps usage-data
transport descriptive-only for storage and recovery. Shared storage/recovery
surfaces may coexist with `/api/upgrade-metrics/*` config reads and telemetry

View file

@ -9,12 +9,10 @@ import { Component, Show, createSignal, createResource, createMemo } from 'solid
import { aiIntelligenceStore } from '@/stores/aiIntelligence';
import { notificationStore } from '@/stores/notifications';
import { aiChatStore } from '@/stores/aiChat';
import { canStartCommercialTrial } from '@/stores/licenseCommercial';
import { hasFeature } from '@/stores/license';
import { AIAPI, type ApprovalRequest, type ApprovalExecutionResult } from '@/api/ai';
import { getApprovalRiskPresentation } from '@/utils/approvalRiskPresentation';
import { RemediationStatus } from './RemediationStatus';
import { runStartProTrialAction } from '@/utils/trialStartAction';
interface ApprovalSectionProps {
findingId: string;
@ -40,24 +38,6 @@ export const ApprovalSection: Component<ApprovalSectionProps> = (props) => {
const canAutoFix = createMemo(() => hasFeature('ai_autofix'));
const [startingTrial, setStartingTrial] = createSignal(false);
const canStartTrial = createMemo(() => canStartCommercialTrial());
const handleStartTrial = async (e: Event) => {
e.stopPropagation();
if (startingTrial()) return;
setStartingTrial(true);
try {
await runStartProTrialAction({
branded: true,
showSuccess: notificationStore.success,
showError: notificationStore.error,
});
} finally {
setStartingTrial(false);
}
};
const handleFixWithAssistant = (
approval: ApprovalRequest | null,
fix: {
@ -271,16 +251,6 @@ export const ApprovalSection: Component<ApprovalSectionProps> = (props) => {
</svg>
{assistantLabel}
</button>
<Show when={canStartTrial()}>
<button
type="button"
class="text-xs font-semibold text-indigo-700 dark:text-indigo-300 hover:underline disabled:opacity-60"
disabled={startingTrial()}
onClick={handleStartTrial}
>
Apply fixes automatically start a free 14-day trial
</button>
</Show>
</Show>
</div>
);
@ -380,16 +350,6 @@ export const ApprovalSection: Component<ApprovalSectionProps> = (props) => {
</svg>
Fix with Assistant
</button>
<Show when={canStartTrial()}>
<button
type="button"
class="text-xs font-semibold text-indigo-700 dark:text-indigo-300 hover:underline disabled:opacity-60"
disabled={startingTrial()}
onClick={handleStartTrial}
>
Apply fixes automatically start a free 14-day trial
</button>
</Show>
</Show>
</div>
</>

View file

@ -22,7 +22,6 @@ const denyInvestigationFixMock = vi.hoisted(() => vi.fn());
const notificationSuccessMock = vi.hoisted(() => vi.fn());
const notificationErrorMock = vi.hoisted(() => vi.fn());
const openWithPromptMock = vi.hoisted(() => vi.fn());
const startProTrialMock = vi.hoisted(() => vi.fn());
vi.mock('@/api/ai', () => ({
AIAPI: {
@ -62,13 +61,7 @@ vi.mock('@/stores/license', () => ({
}));
vi.mock('@/stores/licenseCommercial', () => ({
canStartCommercialTrial: () => {
const ent = state.entitlements;
if (!ent) return false;
if (ent.subscription_state === 'active' || ent.subscription_state === 'trial') return false;
return ent.trial_eligible !== false;
},
startProTrial: (...args: unknown[]) => startProTrialMock(...args),
canStartCommercialTrial: () => false,
}));
vi.mock('../RemediationStatus', () => ({
@ -93,15 +86,16 @@ describe('ApprovalSection', () => {
notificationSuccessMock.mockReset();
notificationErrorMock.mockReset();
openWithPromptMock.mockReset();
startProTrialMock.mockReset();
});
afterEach(() => {
cleanup();
});
it('keeps patrol trial gating on shared commercial selector helpers', () => {
expect(approvalSectionSource).toContain('canStartCommercialTrial');
it('keeps fix approvals out of commercial trial prompts', () => {
expect(approvalSectionSource).not.toContain('canStartCommercialTrial');
expect(approvalSectionSource).not.toContain('runStartProTrialAction');
expect(approvalSectionSource).not.toContain('start a free 14-day trial');
expect(approvalSectionSource).not.toContain('commercialPosture');
});

View file

@ -1,6 +1,4 @@
import { Component, Show } from 'solid-js';
import { presentationPolicyHidesUpgradePrompts } from '@/stores/sessionPresentationPolicy';
import { UpgradeLink } from './UpgradeLink';
import type { HistoryChartState } from './useHistoryChartState';
interface HistoryChartOverlayProps {
@ -74,41 +72,9 @@ export const HistoryChartOverlay: Component<HistoryChartOverlayProps> = (props)
<h3 class="text-lg font-bold text-base-content mb-1">
{props.chart.lockDays()}-Day History
</h3>
<Show
when={!presentationPolicyHidesUpgradePrompts()}
fallback={
<p class="text-sm text-muted text-center max-w-[220px] mb-4">
Historical data beyond {props.chart.lockDays()} days is not enabled on this
instance.
</p>
}
>
<p class="text-sm text-muted text-center max-w-[200px] mb-4">
Upgrade to {props.chart.lockTierLabel()} to unlock {props.chart.lockDays()} days of
historical data retention.
</p>
<div class="flex flex-col items-center gap-2">
<UpgradeLink
destination={props.chart.getUpgradeActionDestination('long_term_metrics')}
onClick={() =>
props.chart.trackUpgradeClicked('history_chart', 'long_term_metrics')
}
class="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-medium rounded-md shadow-sm transition-colors"
>
Unlock {props.chart.lockTierLabel()} Features
</UpgradeLink>
<Show when={props.chart.canStartTrial()}>
<button
type="button"
class="text-xs font-semibold text-indigo-700 dark:text-indigo-300 hover:underline disabled:opacity-60"
disabled={props.chart.startingTrial()}
onClick={props.chart.handleStartTrial}
>
Or start a free 14-day trial
</button>
</Show>
</div>
</Show>
<p class="text-sm text-muted text-center max-w-[220px] mb-4">
Historical data beyond {props.chart.lockDays()} days is not enabled on this instance.
</p>
</div>
</Show>
</>

View file

@ -663,6 +663,7 @@ describe('shared primitive guardrails', () => {
expect(monitoredSystemLimitWarningBannerStateSource).toContain(
'getRuntimeMonitoredSystemCapacity',
);
expect(monitoredSystemLimitWarningBannerStateSource).toContain('isHostedModeEnabled');
expect(monitoredSystemLimitWarningBannerStateSource).toContain(
'presentationPolicyHidesCommercialSurfaces',
);
@ -919,7 +920,11 @@ describe('shared primitive guardrails', () => {
expect(historyChartHeaderSource).not.toContain('setupCanvasDPR');
expect(historyChartOverlaySource).toContain('Collecting data... History will appear here.');
expect(historyChartOverlaySource).toContain('Unlock {props.chart.lockTierLabel()} Features');
expect(historyChartOverlaySource).toContain(
'Historical data beyond {props.chart.lockDays()} days is not enabled on this instance.',
);
expect(historyChartOverlaySource).not.toContain('Unlock {props.chart.lockTierLabel()} Features');
expect(historyChartOverlaySource).not.toContain('free 14-day trial');
expect(historyChartOverlaySource).not.toContain('ChartsAPI.getMetricsHistory');
expect(historyChartOverlaySource).not.toContain('setupCanvasDPR');

View file

@ -41,16 +41,6 @@ vi.mock('@/stores/license', () => ({
maxHistoryDays: () => 30,
}));
vi.mock('@/stores/licenseCommercial', () => ({
canStartCommercialTrial: () => false,
getUpgradeActionDestination: () => ({ href: 'https://example.com/upgrade', external: true }),
startProTrial: vi.fn(),
}));
vi.mock('@/stores/sessionPresentationPolicy', () => ({
presentationPolicyHidesUpgradePrompts: () => false,
}));
vi.mock('@/api/charts', () => ({
ChartsAPI: {
getMetricsHistory: vi.fn().mockResolvedValue({ points: [], source: 'store' }),
@ -76,10 +66,8 @@ describe('HistoryChart', () => {
expect(historyChartStateSource).toContain('export function useHistoryChartState');
expect(historyChartStateSource).toContain('HISTORY_CHART_RANGES');
expect(historyChartStateSource).toContain("'mock_synthetic' | null");
expect(historyChartStateSource).toContain(
'const canStartTrial = createMemo(() => canStartCommercialTrial());',
);
expect(historyChartStateSource).toContain('runStartProTrialAction({');
expect(historyChartStateSource).not.toContain('canStartCommercialTrial');
expect(historyChartStateSource).not.toContain('runStartProTrialAction({');
expect(historyChartStateSource).not.toContain('startProTrial()');
expect(historyChartStateSource).not.toContain('getTrialAlreadyUsedMessage()');
expect(historyChartStateSource).not.toContain('getTrialTryAgainLaterMessage()');
@ -95,8 +83,12 @@ describe('HistoryChart', () => {
expect(historyChartHeaderSource).not.toContain('setupCanvasDPR');
expect(historyChartOverlaySource).toContain('Collecting data... History will appear here.');
expect(historyChartOverlaySource).toContain('Unlock {props.chart.lockTierLabel()} Features');
expect(historyChartOverlaySource).toContain('presentationPolicyHidesUpgradePrompts');
expect(historyChartOverlaySource).toContain(
'Historical data beyond {props.chart.lockDays()} days is not enabled on this instance.',
);
expect(historyChartOverlaySource).not.toContain('Unlock {props.chart.lockTierLabel()} Features');
expect(historyChartOverlaySource).not.toContain('presentationPolicyHidesUpgradePrompts');
expect(historyChartOverlaySource).not.toContain('free 14-day trial');
expect(historyChartOverlaySource).toContain('is not enabled on this');
expect(historyChartOverlaySource).not.toContain('ChartsAPI.getMetricsHistory');
expect(historyChartOverlaySource).not.toContain('setupCanvasDPR');

View file

@ -23,6 +23,7 @@ const mockGetLimit = vi.hoisted(() =>
vi.fn<(key: string) => MockLimitRecord | undefined>(() => undefined),
);
const mockGetMonitoredSystemCapacity = vi.hoisted(() => vi.fn(() => undefined));
const mockIsHostedModeEnabled = vi.hoisted(() => vi.fn(() => false));
const mockHasMigrationGap = vi.hoisted(() => vi.fn(() => false));
const mockLegacyConnections = vi.hoisted(() =>
vi.fn(() => ({
@ -42,6 +43,7 @@ const mockGetUpgradeActionUrlOrFallback = vi.hoisted(() => vi.fn());
vi.mock('@/stores/license', () => ({
getRuntimeLimit: (key: string) => mockGetLimit(key),
getRuntimeMonitoredSystemCapacity: () => mockGetMonitoredSystemCapacity(),
isHostedModeEnabled: () => mockIsHostedModeEnabled(),
loadRuntimeCapabilities: (force?: boolean) => mockLoadRuntimeLicenseStatus(force),
}));
@ -78,6 +80,8 @@ describe('MonitoredSystemLimitWarningBanner', () => {
});
mockGetMonitoredSystemCapacity.mockReset();
mockGetMonitoredSystemCapacity.mockReturnValue(undefined);
mockIsHostedModeEnabled.mockReset();
mockIsHostedModeEnabled.mockReturnValue(false);
mockHasMigrationGap.mockReturnValue(false);
mockLegacyConnections.mockReturnValue({
proxmox_nodes: 0,
@ -129,6 +133,7 @@ describe('MonitoredSystemLimitWarningBanner', () => {
expect(monitoredSystemLimitWarningBannerStateSource).toContain(
'presentationPolicyHidesCommercialSurfaces',
);
expect(monitoredSystemLimitWarningBannerStateSource).toContain('isHostedModeEnabled');
expect(monitoredSystemLimitWarningBannerStateSource).toContain(
'presentationPolicyHidesUpgradePrompts',
);
@ -196,6 +201,7 @@ describe('MonitoredSystemLimitWarningBanner', () => {
});
it('keeps urgent limit warnings visible even without migration gap', async () => {
mockIsHostedModeEnabled.mockReturnValue(true);
mockGetLimit.mockReturnValue({
key: 'max_monitored_systems',
limit: 6,
@ -242,6 +248,7 @@ describe('MonitoredSystemLimitWarningBanner', () => {
});
it('keeps urgent limit warnings visible with migration context', async () => {
mockIsHostedModeEnabled.mockReturnValue(true);
mockHasMigrationGap.mockReturnValue(true);
mockEntitlements.mockReturnValue({ overflow_days_remaining: 14 });
mockGetLimit.mockReturnValue({
@ -273,6 +280,7 @@ describe('MonitoredSystemLimitWarningBanner', () => {
});
it('stays hidden in demo mode even when usage is urgent', async () => {
mockIsHostedModeEnabled.mockReturnValue(true);
mockPresentationPolicyHidesCommercialSurfaces.mockReturnValue(true);
mockGetLimit.mockReturnValue({
key: 'max_monitored_systems',
@ -295,6 +303,7 @@ describe('MonitoredSystemLimitWarningBanner', () => {
});
it('stays hidden when self-hosted upgrade prompts are suppressed', async () => {
mockIsHostedModeEnabled.mockReturnValue(true);
mockPresentationPolicyHidesUpgradePrompts.mockReturnValue(true);
mockGetLimit.mockReturnValue({
key: 'max_monitored_systems',
@ -315,4 +324,25 @@ describe('MonitoredSystemLimitWarningBanner', () => {
).not.toBeInTheDocument();
expect(mockTrackUpgradeMetricEvent).not.toHaveBeenCalled();
});
it('stays hidden for self-hosted installs even when a stale finite policy is urgent', async () => {
mockGetLimit.mockReturnValue({
key: 'max_monitored_systems',
limit: 6,
current: 5,
state: 'warning',
});
const mod = await import('../MonitoredSystemLimitWarningBanner');
render(() => (
<Router>
<Route path="/" component={mod.MonitoredSystemLimitWarningBanner} />
</Router>
));
expect(
screen.queryByText('1 remaining. 5 monitored, 6 included.'),
).not.toBeInTheDocument();
expect(mockTrackUpgradeMetricEvent).not.toHaveBeenCalled();
});
});

View file

@ -1,13 +1,8 @@
import { createEffect, createMemo, createSignal, onCleanup, onMount } from 'solid-js';
import { ChartsAPI, type HistoryTimeRange } from '@/api/charts';
import { isRangeLocked, loadRuntimeCapabilities, maxHistoryDays } from '@/stores/license';
import { canStartCommercialTrial, getUpgradeActionDestination } from '@/stores/licenseCommercial';
import { presentationPolicyHidesUpgradePrompts } from '@/stores/sessionPresentationPolicy';
import { calculateOptimalPoints } from '@/utils/downsample';
import { setupCanvasDPR } from '@/utils/canvasRenderQueue';
import { trackPaywallViewed, trackUpgradeClicked } from '@/utils/upgradeMetrics';
import { notificationStore } from '@/stores/notifications';
import { runStartProTrialAction } from '@/utils/trialStartAction';
import {
HISTORY_CHART_RANGES,
createHistoryChartGeometry,
@ -40,27 +35,10 @@ export function useHistoryChartState(props: HistoryChartProps, refs: HistoryChar
const [refreshTick, setRefreshTick] = createSignal(0);
const [hasLoadedOnce, setHasLoadedOnce] = createSignal(false);
const [cursorX, setCursorX] = createSignal<number | null>(null);
const [startingTrial, setStartingTrial] = createSignal(false);
const [hoveredPoint, setHoveredPoint] = createSignal<HistoryChartHoverPoint | null>(null);
const [chartWidth, setChartWidth] = createSignal(300);
const chartHeight = createMemo(() => props.height || 200);
const canStartTrial = createMemo(() => canStartCommercialTrial());
const handleStartTrial = async () => {
if (startingTrial()) return;
setStartingTrial(true);
try {
await runStartProTrialAction({
branded: true,
showSuccess: notificationStore.success,
showError: notificationStore.error,
});
} finally {
setStartingTrial(false);
}
};
const refreshIntervalMs = createMemo(() => getHistoryChartRefreshIntervalMs(range()));
onMount(() => {
@ -95,14 +73,6 @@ export function useHistoryChartState(props: HistoryChartProps, refs: HistoryChar
return 'Pro';
});
createEffect((wasVisible) => {
const visible = isLocked() && !props.hideLock;
if (!presentationPolicyHidesUpgradePrompts() && visible && !wasVisible) {
trackPaywallViewed('long_term_metrics', 'history_chart');
}
return visible;
}, false);
const dataMin = createMemo(() => getHistoryChartDataMin(data()));
const dataMax = createMemo(() => getHistoryChartDataMax(data()));
@ -373,15 +343,12 @@ export function useHistoryChartState(props: HistoryChartProps, refs: HistoryChar
};
return {
canStartTrial,
data,
dataMax,
dataMin,
error,
getUpgradeActionDestination,
handleMouseLeave,
handleMouseMove,
handleStartTrial,
chartHeight,
chartWidth,
hoveredPoint,
@ -392,8 +359,6 @@ export function useHistoryChartState(props: HistoryChartProps, refs: HistoryChar
range,
ranges: HISTORY_CHART_RANGES,
source,
startingTrial,
trackUpgradeClicked,
updateRange,
};
}

View file

@ -6,6 +6,7 @@ import {
import {
getRuntimeMonitoredSystemCapacity,
getRuntimeLimit,
isHostedModeEnabled,
loadRuntimeCapabilities,
} from '@/stores/license';
import { hasMigrationGap } from '@/stores/licenseCommercial';
@ -37,6 +38,7 @@ export function useMonitoredSystemLimitWarningBannerState() {
);
const showBanner = createMemo(
() =>
isHostedModeEnabled() &&
!presentationPolicyHidesCommercialSurfaces() &&
!presentationPolicyHidesUpgradePrompts() &&
shouldShowMonitoredSystemLimitBanner(monitoredSystemLimit(), monitoredSystemCapacity()),

View file

@ -1,11 +1,8 @@
import { Show } from 'solid-js';
import ShieldAlertIcon from 'lucide-solid/icons/shield-alert';
import SettingsIcon from 'lucide-solid/icons/settings';
import SparklesIcon from 'lucide-solid/icons/sparkles';
import { UpgradeLink } from '@/components/shared/UpgradeLink';
import { presentationPolicyHidesUpgradePrompts } from '@/stores/sessionPresentationPolicy';
import { formatRelativeTime } from '@/utils/format';
import { trackUpgradeClicked } from '@/utils/upgradeMetrics';
import type { PatrolIntelligenceState } from './usePatrolIntelligenceState';
export function PatrolIntelligenceBanners(props: { state: PatrolIntelligenceState }) {
@ -47,14 +44,8 @@ export function PatrolIntelligenceBanners(props: { state: PatrolIntelligenceStat
<div class="flex-shrink-0 bg-blue-50 dark:bg-blue-900 border-b border-blue-200 dark:border-blue-800 px-3 py-2">
<div class="flex flex-wrap items-center justify-between gap-2">
<p class="text-xs text-blue-700 dark:text-blue-300">
<UpgradeLink
class="text-indigo-600 dark:text-indigo-400 font-semibold hover:underline"
destination={state.upgradeDestination()}
onClick={() => trackUpgradeClicked('ai_intelligence_banner', 'ai_autofix')}
>
Upgrade to Pro
</UpgradeLink>{' '}
to unlock safe remediation workflows and alert-triggered root-cause analysis.
Safe remediation workflows and alert-triggered root-cause analysis are not enabled
on this plan.
</p>
</div>
</div>
@ -89,15 +80,6 @@ export function PatrolIntelligenceBanners(props: { state: PatrolIntelligenceStat
<SettingsIcon class="w-3.5 h-3.5" />
Open Patrol provider settings
</a>
<Show when={!presentationPolicyHidesUpgradePrompts() && state.licenseRequired()}>
<UpgradeLink
destination={state.upgradeDestination()}
class="inline-flex items-center justify-center gap-2 px-3 py-1.5 text-xs font-semibold text-white bg-amber-600 hover:bg-amber-700 rounded-md transition-colors"
>
<SparklesIcon class="w-3.5 h-3.5" />
Upgrade
</UpgradeLink>
</Show>
</div>
</div>
</div>

View file

@ -6,7 +6,6 @@ import XIcon from 'lucide-solid/icons/x';
import SettingsIcon from 'lucide-solid/icons/settings';
import { PulsePatrolLogo } from '@/components/Brand/PulsePatrolLogo';
import { PageHeader } from '@/components/shared/PageHeader';
import { UpgradeLink } from '@/components/shared/UpgradeLink';
import { Toggle, TogglePrimitive } from '@/components/shared/Toggle';
import { CountdownTimer } from '@/components/patrol';
import { presentationPolicyHidesUpgradePrompts } from '@/stores/sessionPresentationPolicy';
@ -274,8 +273,8 @@ export function PatrolIntelligenceHeader(props: { state: PatrolIntelligenceState
title={
!presentationPolicyHidesUpgradePrompts() && isProLocked()
? level === 'approval'
? 'Upgrade to Pro to investigate findings'
: 'Upgrade to Pro for safe remediation workflows'
? 'Investigation is not enabled on this plan'
: 'Safe remediation workflows are not enabled on this plan'
: undefined
}
class={`flex-1 py-1.5 px-2 text-xs font-semibold rounded-md transition-all duration-200 ${isActive() ? ' text-blue-600 dark:text-blue-400 shadow-[0_1px_3px_rgba(0,0,0,0.1)]' : isDisabled() ? ' ' : 'text-muted hover:text-base-content hover:bg-surface-hover'} ${isDisabled() ? 'opacity-50 cursor-not-allowed' : ''}`}
@ -292,24 +291,7 @@ export function PatrolIntelligenceHeader(props: { state: PatrolIntelligenceState
</div>
<Show when={!presentationPolicyHidesUpgradePrompts() && state.autoFixLocked()}>
<div class="pl-1 text-[11px] text-slate-500">
<UpgradeLink
destination={state.upgradeDestination()}
class="text-indigo-500 font-medium hover:underline"
>
Upgrade to Pro
</UpgradeLink>{' '}
to unlock investigation and safe remediation workflows.
<Show when={state.canStartTrial()}>
{' '}
<button
type="button"
onClick={state.handleStartTrial}
disabled={state.startingTrial()}
class="text-indigo-500 hover:underline"
>
Start free trial
</button>
</Show>
Investigation and safe remediation workflows are not enabled on this plan.
</div>
</Show>
</div>
@ -337,23 +319,7 @@ export function PatrolIntelligenceHeader(props: { state: PatrolIntelligenceState
when={!presentationPolicyHidesUpgradePrompts() && state.alertAnalysisLocked()}
>
<div class="-my-1 pl-1 text-[11px]">
<UpgradeLink
destination={state.alertAnalysisUpgradeDestination()}
class="text-indigo-500 font-medium hover:underline"
>
Upgrade
</UpgradeLink>{' '}
to enable.
<Show when={state.canStartTrial()}>
<button
type="button"
onClick={state.handleStartTrial}
disabled={state.startingTrial()}
class="ml-1 text-indigo-500 hover:underline"
>
Start free trial
</button>
</Show>
Alert-triggered analysis is not enabled on this plan.
</div>
</Show>

View file

@ -24,13 +24,9 @@ import { hasTriggeringAlert } from '@/utils/findingAlertIdentity';
import { usePatrolStream } from '@/hooks/usePatrolStream';
import { createNonSuspendingQuery } from '@/hooks/createNonSuspendingQuery';
import { hasFeature, loadRuntimeCapabilities } from '@/stores/license';
import { canStartCommercialTrial, getUpgradeActionDestination } from '@/stores/licenseCommercial';
import { presentationPolicyHidesUpgradePrompts } from '@/stores/sessionPresentationPolicy';
import type { AISettings } from '@/types/ai';
import { getCanonicalScopeResourceIds } from '@/utils/patrolFormat';
import { buildPatrolInvestigationContextSummary } from './patrolInvestigationContextModel';
import { trackPaywallViewed } from '@/utils/upgradeMetrics';
import { runStartProTrialAction } from '@/utils/trialStartAction';
type PatrolTab = 'findings' | 'history';
@ -63,7 +59,6 @@ export function usePatrolIntelligenceState() {
const [alertTriggeredAnalysis, setAlertTriggeredAnalysis] = createSignal<boolean>(false);
const [patrolAlertTriggers, setPatrolAlertTriggers] = createSignal<boolean>(true);
const [patrolAnomalyTriggers, setPatrolAnomalyTriggers] = createSignal<boolean>(true);
const [startingTrial, setStartingTrial] = createSignal(false);
const [selectedRun, setSelectedRun] = createSignal<PatrolRunRecord | null>(null);
let advancedSettingsRef: HTMLDivElement | undefined;
@ -152,22 +147,6 @@ export function usePatrolIntelligenceState() {
const alertAnalysisLocked = createMemo(() => !hasFeature('ai_alerts'));
const autoFixLocked = createMemo(() => !hasFeature('ai_autofix'));
const canStartTrial = createMemo(() => canStartCommercialTrial());
async function handleStartTrial() {
if (startingTrial()) return;
setStartingTrial(true);
try {
await runStartProTrialAction({
branded: true,
showSuccess: notificationStore.success,
showError: notificationStore.error,
});
} finally {
setStartingTrial(false);
}
}
const applyPatrolAISettings = (data: AISettings | null | undefined) => {
setPatrolModel(data?.patrol_model || '');
setDefaultModel(data?.model || '');
@ -348,10 +327,6 @@ export function usePatrolIntelligenceState() {
});
const licenseRequired = createMemo(() => patrolStatus()?.license_required ?? false);
const upgradeDestination = createMemo(() => getUpgradeActionDestination('ai_autofix'));
const alertAnalysisUpgradeDestination = createMemo(() =>
getUpgradeActionDestination('ai_alerts'),
);
const runtimeState = createMemo<PatrolRuntimeState>(() => {
if (!patrolEnabledLocal()) return 'disabled';
return patrolStatus()?.runtime_state ?? 'active';
@ -368,38 +343,6 @@ export function usePatrolIntelligenceState() {
return '';
});
createEffect((wasAutoFixLocked) => {
const isAutoFixLocked = autoFixLocked();
if (!presentationPolicyHidesUpgradePrompts() && isAutoFixLocked && !wasAutoFixLocked) {
trackPaywallViewed('ai_autofix', 'ai_intelligence');
}
return isAutoFixLocked;
}, false);
createEffect((wasAlertAnalysisLocked) => {
const isAlertAnalysisLocked = alertAnalysisLocked();
if (
!presentationPolicyHidesUpgradePrompts() &&
isAlertAnalysisLocked &&
!wasAlertAnalysisLocked
) {
trackPaywallViewed('ai_alerts', 'ai_intelligence');
}
return isAlertAnalysisLocked;
}, false);
createEffect((wasLicenseBannerVisible) => {
const isLicenseBannerVisible = licenseRequired() && !showBlockedBanner();
if (
!presentationPolicyHidesUpgradePrompts() &&
isLicenseBannerVisible &&
!wasLicenseBannerVisible
) {
trackPaywallViewed('ai_autofix', 'ai_intelligence_banner');
}
return isLicenseBannerVisible;
}, false);
const shouldShowLiveRun = createMemo(
() =>
patrolEnabledLocal() &&
@ -694,7 +637,6 @@ export function usePatrolIntelligenceState() {
activeTab,
activePatrolFindings,
activityRefreshTrigger,
alertAnalysisUpgradeDestination,
alertAnalysisLocked,
alertTriggeredAnalysis,
autonomyLevel,
@ -702,7 +644,6 @@ export function usePatrolIntelligenceState() {
availableModels,
blockedAt,
blockedReason,
canStartTrial,
canTriggerPatrol,
circuitBreakerStatus,
correlationTotal,
@ -721,7 +662,6 @@ export function usePatrolIntelligenceState() {
handlePatrolAlertTriggersChange,
handlePatrolAnomalyTriggersChange,
handleRunPatrol,
handleStartTrial,
handleTogglePatrol,
hasInvestigationContext,
initialSurfaceReady,
@ -769,10 +709,8 @@ export function usePatrolIntelligenceState() {
showBlockedBanner,
showInvestigationContext,
shouldSurfaceInvestigationContext,
startingTrial,
summaryStats,
triggerPatrolDisabledReason,
upgradeDestination,
};
}

View file

@ -555,7 +555,7 @@ describe('AIIntelligence entitlement gating', () => {
cleanup();
});
it('locks paid patrol controls and shows upgrade paths for free entitlements', async () => {
it('locks paid patrol controls without promoting checkout from ordinary Patrol workflows', async () => {
getPatrolStatusMock.mockResolvedValue(
defaultPatrolStatus({
license_required: true,
@ -576,20 +576,22 @@ describe('AIIntelligence entitlement gating', () => {
expect(screen.getByRole('button', { name: 'Investigate' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Remediate' })).toBeDisabled();
expect(
screen
.getAllByRole('link', { name: 'Upgrade to Pro' })
.some((link) => link.getAttribute('href') === getPublicPricingUrl('ai_autofix')),
).toBe(true);
expect(screen.getByRole('link', { name: 'Upgrade' })).toHaveAttribute(
'href',
getPublicPricingUrl('ai_alerts'),
);
await waitFor(() => {
expect(trackPaywallViewedMock).toHaveBeenCalledWith('ai_autofix', 'ai_intelligence');
expect(trackPaywallViewedMock).toHaveBeenCalledWith('ai_alerts', 'ai_intelligence');
expect(trackPaywallViewedMock).toHaveBeenCalledWith('ai_autofix', 'ai_intelligence_banner');
});
screen.getByText(
'Safe remediation workflows and alert-triggered root-cause analysis are not enabled on this plan.',
),
).toBeInTheDocument();
expect(
screen.getByText(
'Investigation and safe remediation workflows are not enabled on this plan.',
),
).toBeInTheDocument();
expect(
screen.getByText('Alert-triggered analysis is not enabled on this plan.'),
).toBeInTheDocument();
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 () => {

View file

@ -1006,9 +1006,9 @@ func (s *Service) StartPatrol(ctx context.Context) {
return
}
// Check license for auto-fix feature (Pro only) - patrol itself is free with BYOK
// Check license for safe remediation workflows (Pro only) - patrol itself is free with BYOK
if licenseChecker != nil && !licenseChecker.HasFeature(FeatureAIAutoFix) {
log.Info().Msg("AI Patrol Auto-Fix requires Pulse Pro license - fixes will require manual approval")
log.Info().Msg("Patrol safe remediation requires Pulse Pro license - fixes will require manual approval")
}
// Configure patrol from AI config (preserve defaults for resource types not in AI config)

View file

@ -20,6 +20,21 @@ import (
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
)
func TestServiceCommercialLogsUseSafeRemediationCopy(t *testing.T) {
source, err := os.ReadFile("service.go")
if err != nil {
t.Fatalf("read service.go: %v", err)
}
text := string(source)
if !strings.Contains(text, "Patrol safe remediation requires Pulse Pro license") {
t.Fatal("expected Patrol remediation license log copy")
}
if strings.Contains(text, "AI Patrol Auto-Fix requires Pulse Pro license") {
t.Fatal("runtime logs must not revive Patrol Auto-Fix commercial copy")
}
}
func TestService_GetToolInputDisplay(t *testing.T) {
svc := NewService(nil, nil)

View file

@ -2519,9 +2519,9 @@ func (h *AISettingsHandler) HandleUpdateAISettings(w http.ResponseWriter, r *htt
}
if req.PatrolAutoFix != nil {
// Auto-fix requires Pro license with ai_autofix feature
// Safe remediation requires Pro license with the ai_autofix feature.
if *req.PatrolAutoFix && !h.GetAIService(r.Context()).HasLicenseFeature(ai.FeatureAIAutoFix) {
WriteLicenseRequired(w, ai.FeatureAIAutoFix, "Pulse Patrol Auto-Fix requires Pulse Pro")
WriteLicenseRequired(w, ai.FeatureAIAutoFix, "Safe remediation workflows require Pulse Pro")
return
}
settings.PatrolAutoFix = *req.PatrolAutoFix
@ -3227,7 +3227,7 @@ func (h *AISettingsHandler) HandleExecute(w http.ResponseWriter, r *http.Request
useCase := strings.ToLower(strings.TrimSpace(req.UseCase))
if useCase == "autofix" || useCase == "remediation" {
if !h.GetAIService(r.Context()).HasLicenseFeature(ai.FeatureAIAutoFix) {
WriteLicenseRequired(w, ai.FeatureAIAutoFix, "Pulse Patrol Auto-Fix requires Pulse Pro")
WriteLicenseRequired(w, ai.FeatureAIAutoFix, "Safe remediation workflows require Pulse Pro")
return
}
}
@ -3408,7 +3408,7 @@ func (h *AISettingsHandler) HandleExecuteStream(w http.ResponseWriter, r *http.R
useCase := strings.ToLower(strings.TrimSpace(req.UseCase))
if useCase == "autofix" || useCase == "remediation" {
if !h.GetAIService(r.Context()).HasLicenseFeature(ai.FeatureAIAutoFix) {
WriteLicenseRequired(w, ai.FeatureAIAutoFix, "Pulse Patrol Auto-Fix requires Pulse Pro")
WriteLicenseRequired(w, ai.FeatureAIAutoFix, "Safe remediation workflows require Pulse Pro")
return
}
}
@ -3672,10 +3672,10 @@ func (h *AISettingsHandler) HandleRunCommand(w http.ResponseWriter, r *http.Requ
return
}
// Gated for AI Auto-Fix (Pro feature). Request shape is validated before the
// Gated for safe remediation workflows (Pro feature). Request shape is validated before the
// entitlement check so clients get deterministic 400s for malformed calls.
if !h.GetAIService(r.Context()).HasLicenseFeature(ai.FeatureAIAutoFix) {
WriteLicenseRequired(w, ai.FeatureAIAutoFix, "Pulse Patrol Auto-Fix requires Pulse Pro")
WriteLicenseRequired(w, ai.FeatureAIAutoFix, "Safe remediation workflows require Pulse Pro")
return
}
@ -4880,7 +4880,7 @@ type PatrolStatusResponse struct {
ErrorCount int `json:"error_count"`
Healthy bool `json:"healthy"`
IntervalMs int64 `json:"interval_ms"` // Patrol interval in milliseconds
FixedCount int `json:"fixed_count"` // Number of issues auto-fixed by Patrol
FixedCount int `json:"fixed_count"` // Number of issues remediated by Patrol
BlockedReason string `json:"blocked_reason,omitempty"`
BlockedAt *time.Time `json:"blocked_at,omitempty"`
// Quickstart credit info for Patrol quickstart mode
@ -6591,13 +6591,13 @@ func (h *AISettingsHandler) HandleApproveCommand(w http.ResponseWriter, r *http.
return
}
// Investigation fix approvals are gated by the AI auto-fix extension point.
// Investigation fix approvals are gated by the safe remediation extension point.
// The free adapter returns 402; the enterprise adapter executes the fix.
if existingReq.ToolID == "investigation_fix" {
if h.aiAutoFixEndpoints != nil {
h.aiAutoFixEndpoints.HandleApproveInvestigationFix(w, r)
} else {
WriteLicenseRequired(w, featureAIAutoFixValue, "Pulse Patrol Auto-Fix feature requires Pulse Pro")
WriteLicenseRequired(w, featureAIAutoFixValue, "Safe remediation workflows require Pulse Pro")
}
return
}

View file

@ -91,6 +91,17 @@ func persistQuickstartActivationState(t *testing.T, persistence *config.ConfigPe
}))
}
func TestAIHandlersUseSafeRemediationCommercialCopy(t *testing.T) {
files := []string{"ai_handlers.go", "router_routes_ai_relay.go"}
for _, file := range files {
source, err := os.ReadFile(file)
require.NoError(t, err)
text := string(source)
require.NotContains(t, text, "Pulse Patrol Auto-Fix requires Pulse Pro")
require.NotContains(t, text, "Auto-Fix requires Pulse Pro")
}
}
func TestAISettingsHandler_GetAndUpdateSettings_RoundTrip(t *testing.T) {
t.Parallel()

View file

@ -57,6 +57,21 @@ type resourceContractSnapshot struct {
Type string
}
func TestPatrolRemediationCommercialCopyUsesSafeRemediationWording(t *testing.T) {
files := []string{"ai_handlers.go", "router_routes_ai_relay.go"}
for _, file := range files {
source, err := os.ReadFile(file)
if err != nil {
t.Fatalf("read %s: %v", file, err)
}
text := string(source)
if strings.Contains(text, "Pulse Patrol Auto-Fix requires Pulse Pro") ||
strings.Contains(text, "Auto-Fix requires Pulse Pro") {
t.Fatalf("%s must not expose legacy Auto-Fix license copy", file)
}
}
}
func sortedVMChartKeys(values map[string]VMChartData) []string {
keys := make([]string, 0, len(values))
for key := range values {

View file

@ -214,7 +214,7 @@ func (r *Router) registerAIRelayRoutesGroup() {
r.aiSettingsHandler.SetAIAutoFixEndpoints(r.aiAutoFixEndpoints)
}
// --- AI Auto-Fix free-tier adapter ---
// --- Safe remediation free-tier adapter ---
// All methods return 402 "requires Pulse Pro". Enterprise binders replace this
// with real handler implementations.
@ -257,7 +257,7 @@ func (aiAutoFixFreeAdapter) HandleRollbackRemediationPlan(w http.ResponseWriter,
}
func (aiAutoFixFreeAdapter) HandleApproveInvestigationFix(w http.ResponseWriter, _ *http.Request) {
WriteLicenseRequired(w, featureAIAutoFixKey, "Auto-Fix requires Pulse Pro")
WriteLicenseRequired(w, featureAIAutoFixKey, "Safe remediation workflows require Pulse Pro")
}
func (a aiAutoFixFreeAdapter) HandleListApprovals(w http.ResponseWriter, req *http.Request) {

View file

@ -68,7 +68,7 @@ type BusinessHooks struct {
BindReportingAdminEndpoints extensions.BindReportingAdminEndpointsFunc
// BindAIAutoFixEndpoints allows enterprise modules to replace or decorate
// AI auto-fix endpoints (investigation, remediation, autonomy, fix execution).
// safe remediation endpoints (investigation, remediation, autonomy, fix execution).
BindAIAutoFixEndpoints extensions.BindAIAutoFixEndpointsFunc
// BindAIAlertAnalysisEndpoints allows enterprise modules to replace or decorate