Pulse/pkg/licensing/self_hosted_feature_catalog.go
rcourtman 97f894b009 Retire Relay labels and upsells inside Pulse
Relay left public checkout on 2026-09-29: it only ever connected the
Pulse Mobile app, which retires on 31 March 2027, and existing Relay
subscribers now carry Pro entitlements. The app still sold it. Every
Community install saw a "Get alerts on your phone ... Available with
Relay and Pro plans" upgrade panel on Alerts destinations, the plan
screen offered a Relay card with "Remote web access via Relay", and the
settings section was called Remote Access although Relay never reached
the web UI.

The Alerts push panel now renders only on instances that have the relay
feature, with no upsell. The settings section, nav, header and locale
catalogs say Pulse Mobile and carry the retirement date. Community sees
only the Pro comparison card and Relay-tier licenses see none; gated
mobile features name Pro as their minimum plan. The relay feature is
labelled "Pulse Relay (Mobile Connection)" in the catalog, plan copy no
longer claims remote web access, the backend pairing diagnostics point
at Settings > Pulse Mobile, and the user docs, including PRIVACY.md,
state that Relay does not provide remote web UI access.
2026-09-29 13:19:54 +01:00

386 lines
13 KiB
Go

package licensing
import "sort"
type SelfHostedFeatureRole string
const (
SelfHostedFeatureRoleHidden SelfHostedFeatureRole = "hidden"
SelfHostedFeatureRoleIncluded SelfHostedFeatureRole = "included"
SelfHostedFeatureRolePrimaryPillar SelfHostedFeatureRole = "primary_pillar"
SelfHostedFeatureRoleIncludedExtra SelfHostedFeatureRole = "included_extra"
SelfHostedFeatureRoleCompatibilityOnly SelfHostedFeatureRole = "compatibility_only"
)
type SelfHostedFeatureRoles struct {
Community SelfHostedFeatureRole
Relay SelfHostedFeatureRole
Pro SelfHostedFeatureRole
}
type FeatureMetadata struct {
Key string
DisplayName string
ComparisonName string
ShowInComparisonTable bool
DisplayableInPlanUI bool
SelfHostedRoles SelfHostedFeatureRoles
UpgradeReason string
UpgradePriority int
}
var orderedFeatureMetadataKeys = []string{
FeatureUpdateAlerts,
FeatureSSO,
FeatureAIPatrol,
FeatureRelay,
FeatureMobileApp,
FeaturePushNotifications,
FeatureAIAlerts,
FeatureAIAutoFix,
FeatureLongTermMetrics,
FeatureAdvancedSSO,
FeatureRBAC,
FeatureAuditLogging,
FeatureAdvancedReporting,
FeatureAgentProfiles,
FeatureExternalProbe,
FeatureKubernetesAI,
FeatureMultiUser,
FeatureWhiteLabel,
FeatureMultiTenant,
FeatureUnlimited,
FeatureDemoFixtures,
}
var featureMetadataCatalog = map[string]FeatureMetadata{
FeatureUpdateAlerts: {
Key: FeatureUpdateAlerts,
DisplayName: "Update Alerts (Container/Package Updates)",
ComparisonName: "Update Alerts",
ShowInComparisonTable: true,
DisplayableInPlanUI: true,
SelfHostedRoles: SelfHostedFeatureRoles{
Community: SelfHostedFeatureRoleIncluded,
Relay: SelfHostedFeatureRoleIncluded,
Pro: SelfHostedFeatureRoleIncluded,
},
},
FeatureSSO: {
Key: FeatureSSO,
DisplayName: "Core SSO (OIDC/SAML)",
ComparisonName: "Core SSO (OIDC/SAML)",
ShowInComparisonTable: true,
DisplayableInPlanUI: true,
SelfHostedRoles: SelfHostedFeatureRoles{
Community: SelfHostedFeatureRoleIncluded,
Relay: SelfHostedFeatureRoleIncluded,
Pro: SelfHostedFeatureRoleIncluded,
},
},
FeatureAIPatrol: {
Key: FeatureAIPatrol,
DisplayName: "Pulse Patrol (Background Health Checks)",
ComparisonName: "Pulse Patrol",
ShowInComparisonTable: true,
DisplayableInPlanUI: true,
SelfHostedRoles: SelfHostedFeatureRoles{
Community: SelfHostedFeatureRoleIncluded,
Relay: SelfHostedFeatureRoleIncluded,
Pro: SelfHostedFeatureRoleIncluded,
},
},
FeatureRelay: {
Key: FeatureRelay,
DisplayName: "Pulse Relay (Mobile Connection)",
ComparisonName: "Pulse Relay (Mobile Connection)",
ShowInComparisonTable: true,
DisplayableInPlanUI: true,
SelfHostedRoles: SelfHostedFeatureRoles{
Community: SelfHostedFeatureRoleHidden,
Relay: SelfHostedFeatureRolePrimaryPillar,
Pro: SelfHostedFeatureRoleIncluded,
},
UpgradeReason: "Get Relay so Pulse stays reachable securely from anywhere instead of only on the local dashboard.",
UpgradePriority: 1,
},
FeatureMobileApp: {
Key: FeatureMobileApp,
DisplayName: "Pulse Mobile Pairing",
ComparisonName: "Pulse Mobile Pairing",
ShowInComparisonTable: true,
DisplayableInPlanUI: true,
SelfHostedRoles: SelfHostedFeatureRoles{
Community: SelfHostedFeatureRoleHidden,
Relay: SelfHostedFeatureRolePrimaryPillar,
Pro: SelfHostedFeatureRoleIncluded,
},
UpgradeReason: "Get Relay so Pulse Mobile can pair with this instance for secure handoff and notifications.",
UpgradePriority: 1,
},
FeaturePushNotifications: {
Key: FeaturePushNotifications,
DisplayName: "Push Notifications",
ComparisonName: "Push Notifications",
ShowInComparisonTable: true,
DisplayableInPlanUI: true,
SelfHostedRoles: SelfHostedFeatureRoles{
Community: SelfHostedFeatureRoleHidden,
Relay: SelfHostedFeatureRolePrimaryPillar,
Pro: SelfHostedFeatureRoleIncluded,
},
UpgradeReason: "Get Relay so important alerts reach you immediately on mobile instead of waiting for you to reopen Pulse.",
UpgradePriority: 1,
},
FeatureLongTermMetrics: {
Key: FeatureLongTermMetrics,
DisplayName: "Extended Metric History",
ComparisonName: "Extended Metric History",
ShowInComparisonTable: false,
DisplayableInPlanUI: true,
SelfHostedRoles: SelfHostedFeatureRoles{
Community: SelfHostedFeatureRoleHidden,
Relay: SelfHostedFeatureRolePrimaryPillar,
Pro: SelfHostedFeatureRolePrimaryPillar,
},
UpgradeReason: "Get Relay for 14 days of history, or Pro for 90 days, so you can see what changed before and after an incident.",
UpgradePriority: 2,
},
FeatureAIAlerts: {
Key: FeatureAIAlerts,
DisplayName: "Patrol Investigates Issues and Explains the Root Cause",
ComparisonName: "Patrol Investigates Issues and Explains the Root Cause",
ShowInComparisonTable: true,
DisplayableInPlanUI: true,
SelfHostedRoles: SelfHostedFeatureRoles{
Community: SelfHostedFeatureRoleHidden,
Relay: SelfHostedFeatureRoleHidden,
Pro: SelfHostedFeatureRolePrimaryPillar,
},
UpgradeReason: "Upgrade to Pro so Patrol can investigate issues instead of handing you a stack of symptoms.",
UpgradePriority: 4,
},
FeatureAIAutoFix: {
Key: FeatureAIAutoFix,
DisplayName: "Patrol Applies Safe Fixes and Verifies the Result",
ComparisonName: "Patrol Applies Safe Fixes and Verifies the Result",
ShowInComparisonTable: true,
DisplayableInPlanUI: true,
SelfHostedRoles: SelfHostedFeatureRoles{
Community: SelfHostedFeatureRoleHidden,
Relay: SelfHostedFeatureRoleHidden,
Pro: SelfHostedFeatureRolePrimaryPillar,
},
UpgradeReason: "Upgrade to Pro so Patrol can investigate issues, apply safe fixes within Patrol mode, and verify the result.",
UpgradePriority: 3,
},
FeatureKubernetesAI: {
Key: FeatureKubernetesAI,
DisplayName: "Kubernetes AI Analysis (Compatibility)",
ComparisonName: "Kubernetes AI Analysis (Compatibility)",
ShowInComparisonTable: false,
DisplayableInPlanUI: false,
SelfHostedRoles: SelfHostedFeatureRoles{
Community: SelfHostedFeatureRoleHidden,
Relay: SelfHostedFeatureRoleHidden,
Pro: SelfHostedFeatureRoleCompatibilityOnly,
},
},
FeatureAgentProfiles: {
Key: FeatureAgentProfiles,
DisplayName: "Centralized Agent Profiles",
ComparisonName: "Centralized Agent Profiles",
ShowInComparisonTable: true,
DisplayableInPlanUI: true,
SelfHostedRoles: SelfHostedFeatureRoles{
Community: SelfHostedFeatureRoleHidden,
Relay: SelfHostedFeatureRoleHidden,
Pro: SelfHostedFeatureRoleIncludedExtra,
},
UpgradeReason: "Upgrade to Pro to standardize agent behavior across systems without reconfiguring every install by hand.",
UpgradePriority: 6,
},
FeatureRBAC: {
Key: FeatureRBAC,
DisplayName: "Role-Based Access Control (RBAC)",
ComparisonName: "Role-Based Access Control (RBAC)",
ShowInComparisonTable: true,
DisplayableInPlanUI: true,
SelfHostedRoles: SelfHostedFeatureRoles{
Community: SelfHostedFeatureRoleHidden,
Relay: SelfHostedFeatureRoleHidden,
Pro: SelfHostedFeatureRoleIncludedExtra,
},
UpgradeReason: "Upgrade to Pro when more than one operator needs safe access boundaries around infrastructure changes.",
UpgradePriority: 5,
},
FeatureAuditLogging: {
Key: FeatureAuditLogging,
DisplayName: "Audit Logging",
ComparisonName: "Audit Logging",
ShowInComparisonTable: true,
DisplayableInPlanUI: true,
SelfHostedRoles: SelfHostedFeatureRoles{
Community: SelfHostedFeatureRoleHidden,
Relay: SelfHostedFeatureRoleHidden,
Pro: SelfHostedFeatureRoleIncludedExtra,
},
UpgradeReason: "Upgrade to Pro to keep a trustworthy action trail for incident review, accountability, and compliance.",
UpgradePriority: 8,
},
FeatureAdvancedSSO: {
Key: FeatureAdvancedSSO,
DisplayName: "Multi-Provider SSO",
ComparisonName: "Multi-Provider SSO",
ShowInComparisonTable: false,
DisplayableInPlanUI: false,
SelfHostedRoles: SelfHostedFeatureRoles{
Community: SelfHostedFeatureRoleIncluded,
Relay: SelfHostedFeatureRoleIncluded,
Pro: SelfHostedFeatureRoleIncluded,
},
},
FeatureAdvancedReporting: {
Key: FeatureAdvancedReporting,
DisplayName: "PDF/CSV Reporting",
ComparisonName: "PDF/CSV Reporting",
ShowInComparisonTable: true,
DisplayableInPlanUI: true,
SelfHostedRoles: SelfHostedFeatureRoles{
Community: SelfHostedFeatureRoleHidden,
Relay: SelfHostedFeatureRoleHidden,
Pro: SelfHostedFeatureRoleIncludedExtra,
},
UpgradeReason: "Upgrade to Pro to turn live infrastructure state into shareable reports without manual screenshot work.",
UpgradePriority: 9,
},
FeatureExternalProbe: {
Key: FeatureExternalProbe,
DisplayName: "External Probes",
ComparisonName: "External Probes",
ShowInComparisonTable: true,
DisplayableInPlanUI: true,
SelfHostedRoles: SelfHostedFeatureRoles{
Community: SelfHostedFeatureRoleHidden,
Relay: SelfHostedFeatureRoleHidden,
Pro: SelfHostedFeatureRoleIncludedExtra,
},
UpgradeReason: "Upgrade to Pro to run availability checks from an agent outside the monitored site and report the results back to Pulse for alerting.",
UpgradePriority: 7,
},
FeatureMultiUser: {
Key: FeatureMultiUser,
DisplayName: "Multi-User Mode",
ComparisonName: "Multi-User Mode",
DisplayableInPlanUI: false,
SelfHostedRoles: SelfHostedFeatureRoles{
Community: SelfHostedFeatureRoleHidden,
Relay: SelfHostedFeatureRoleHidden,
Pro: SelfHostedFeatureRoleHidden,
},
},
FeatureWhiteLabel: {
Key: FeatureWhiteLabel,
DisplayName: "White-Label Branding",
ComparisonName: "White-Label Branding",
DisplayableInPlanUI: false,
SelfHostedRoles: SelfHostedFeatureRoles{
Community: SelfHostedFeatureRoleHidden,
Relay: SelfHostedFeatureRoleHidden,
Pro: SelfHostedFeatureRoleHidden,
},
},
FeatureMultiTenant: {
Key: FeatureMultiTenant,
DisplayName: "Multi-Tenant Mode",
ComparisonName: "Multi-Tenant Mode",
DisplayableInPlanUI: false,
SelfHostedRoles: SelfHostedFeatureRoles{
Community: SelfHostedFeatureRoleHidden,
Relay: SelfHostedFeatureRoleHidden,
Pro: SelfHostedFeatureRoleHidden,
},
},
FeatureUnlimited: {
Key: FeatureUnlimited,
DisplayName: "Hosted Capacity Policy",
ComparisonName: "Hosted Capacity Policy",
DisplayableInPlanUI: false,
SelfHostedRoles: SelfHostedFeatureRoles{
Community: SelfHostedFeatureRoleHidden,
Relay: SelfHostedFeatureRoleHidden,
Pro: SelfHostedFeatureRoleHidden,
},
},
FeatureDemoFixtures: {
Key: FeatureDemoFixtures,
DisplayName: "Demo Fixtures (Internal)",
ComparisonName: "Demo Fixtures (Internal)",
DisplayableInPlanUI: false,
SelfHostedRoles: SelfHostedFeatureRoles{
Community: SelfHostedFeatureRoleHidden,
Relay: SelfHostedFeatureRoleHidden,
Pro: SelfHostedFeatureRoleHidden,
},
},
}
func AllFeatureMetadata() []FeatureMetadata {
metadata := make([]FeatureMetadata, 0, len(orderedFeatureMetadataKeys))
for _, key := range orderedFeatureMetadataKeys {
entry, ok := featureMetadataCatalog[key]
if !ok {
continue
}
metadata = append(metadata, entry)
}
return metadata
}
func GetFeatureMetadata(feature string) (FeatureMetadata, bool) {
entry, ok := featureMetadataCatalog[feature]
return entry, ok
}
func GetSelfHostedFeatureRole(feature string, tier Tier) SelfHostedFeatureRole {
entry, ok := featureMetadataCatalog[feature]
if !ok {
return SelfHostedFeatureRoleHidden
}
switch tier {
case TierFree:
return entry.SelfHostedRoles.Community
case TierRelay:
return entry.SelfHostedRoles.Relay
case TierPro, TierProPlus, TierProAnnual, TierLifetime:
return entry.SelfHostedRoles.Pro
default:
return SelfHostedFeatureRoleHidden
}
}
func IsCompatibilityOnlyFeature(feature string) bool {
entry, ok := featureMetadataCatalog[feature]
if !ok {
return false
}
return entry.SelfHostedRoles.Community == SelfHostedFeatureRoleCompatibilityOnly ||
entry.SelfHostedRoles.Relay == SelfHostedFeatureRoleCompatibilityOnly ||
entry.SelfHostedRoles.Pro == SelfHostedFeatureRoleCompatibilityOnly
}
func GenericUpgradeFeatureMetadata() []FeatureMetadata {
out := make([]FeatureMetadata, 0)
for _, entry := range AllFeatureMetadata() {
if entry.UpgradeReason != "" {
out = append(out, entry)
}
}
sort.SliceStable(out, func(i, j int) bool {
if out[i].UpgradePriority == out[j].UpgradePriority {
return out[i].Key < out[j].Key
}
return out[i].UpgradePriority < out[j].UpgradePriority
})
return out
}