mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
Keep self-hosted Pro prompts opt-in
This commit is contained in:
parent
6516a864b9
commit
ecf8fd4299
30 changed files with 229 additions and 348 deletions
|
|
@ -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)**
|
||||
|
|
|
|||
|
|
@ -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)")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue