From fa1cf95dcf6a434c88ebed7b48bd574f1acb9996 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Fri, 17 Jul 2026 23:35:12 +0100 Subject: [PATCH] feat(relay): make Relay discoverable where users configure alerts and say what it does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Production telemetry shows 17 weekly-active installs with Relay enabled out of 6,225 — the weakest adoption of any feature flag. A funnel walk from a fresh session showed why: the only Relay surface was the last item of the last Settings section, hidden entirely from unlicensed installs, described in transport jargon, and never mentioned on the alert Notifications page where "get alerts on my phone" actually lives. - Alerts → Notifications: new "Mobile push notifications" destinations section (AlertPushDestinationsSection) presenting push via Pulse Mobile alongside email/Apprise/webhooks. Licensed installs get a pointer to the canonical /settings/system-relay panel; unlicensed installs get the shared FeatureGateSection with the relay upgrade destination, suppressed when upgrade prompts are hidden. Page description now names mobile push. - Settings nav: system-relay no longer sets hideWhenUnavailable — the Remote Access entry stays visible to free installs and the panel-owned upgrade gate communicates the paid boundary. relayRead capability hiding is unchanged. - Remote Access copy rewritten around user value: push notifications and remote monitoring through Pulse Mobile with no port forwarding or VPN; pairing help drops "provisions a dedicated relay access credential" jargon, names iOS + Android early access, and states that download-page install links appear after activation-key entry. EN/ES/DE headers updated. - Contracts updated in-commit: alerts.md (destinations catalog extension point), frontend-primitives.md (system-relay is not a deep-link-only flow), cloud-paid.md (value-first relay presentation obligation). Contract-Neutral: i18n messages edit changes settings-header/alerts-destinations copy only; no security-privacy delta; alerts/cloud-paid/frontend-primitives contracts updated in-commit --- .../v6/internal/subsystems/alerts.md | 11 +++ .../v6/internal/subsystems/cloud-paid.md | 6 ++ .../subsystems/frontend-primitives.md | 6 ++ .../Settings/RelayPairingSection.tsx | 4 +- .../RelaySettingsPanel.runtime.test.tsx | 4 +- .../__tests__/settingsArchitecture.test.ts | 8 +++ .../settingsHeaderMeta.branchcov0713.test.ts | 30 ++++---- .../__tests__/settingsLocalization.test.ts | 18 +++++ .../settingsNavVisibility.branchcov2.test.ts | 50 +++++++------ .../settingsNavigation.integration.test.tsx | 26 ++++++- .../components/Settings/settingsNavCatalog.ts | 4 +- .../AlertPushDestinationsSection.test.tsx | 72 +++++++++++++++++++ .../alerts/AlertPushDestinationsSection.tsx | 53 ++++++++++++++ .../features/alerts/tabs/DestinationsTab.tsx | 20 ++++-- frontend-modern/src/i18n/messages.de.ts | 4 +- frontend-modern/src/i18n/messages.es.ts | 4 +- frontend-modern/src/i18n/messages.ts | 4 +- .../alertDestinationsPresentation.test.ts | 20 ++++++ .../alertOverviewPresentation.test.ts | 5 +- .../utils/__tests__/relayPresentation.test.ts | 19 +++-- .../utils/alertDestinationsPresentation.ts | 18 +++-- .../src/utils/alertOverviewPresentation.ts | 2 +- .../src/utils/relayPresentation.ts | 13 ++-- 23 files changed, 324 insertions(+), 77 deletions(-) create mode 100644 frontend-modern/src/features/alerts/AlertPushDestinationsSection.test.tsx create mode 100644 frontend-modern/src/features/alerts/AlertPushDestinationsSection.tsx diff --git a/docs/release-control/v6/internal/subsystems/alerts.md b/docs/release-control/v6/internal/subsystems/alerts.md index 3cbba5e92..10a7260f6 100644 --- a/docs/release-control/v6/internal/subsystems/alerts.md +++ b/docs/release-control/v6/internal/subsystems/alerts.md @@ -164,6 +164,17 @@ inline SVG shells in alert-owned runtime components. `frontend-modern/src/utils/alertTargetTypes.ts`. Supported target types must share the unified evaluator, the canonical threshold/override identity chain, and the standard notification delivery path. +10. Add or change the alert notification destinations catalog through + `frontend-modern/src/features/alerts/tabs/DestinationsTab.tsx` and + `frontend-modern/src/utils/alertDestinationsPresentation.ts`. The + destinations surface presents mobile push (Pulse Mobile paired through + Relay) alongside email, Apprise, and webhooks so phone delivery is + discoverable where alert routing is configured. It stays a pointer + surface: it routes setup to the canonical `/settings/system-relay` Remote + Access panel rather than duplicating relay pairing state or relay API + calls, and when the `relay` feature is absent it gates through the shared + `FeatureGateSection` and upgrade-navigation contract, rendering no upgrade + call-to-action when prompt suppression applies. ## Forbidden Paths diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index b9b82e083..85c14256f 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -243,6 +243,12 @@ avoids a cloud-control-plane report data path across clients. settings surface must preserve API-owned diagnostics and clean up the temporary server-minted mobile token; it must not downgrade that response into a generic paid-state prompt or create browser-owned relay credentials. + Relay presentation on the settings surface is value-first: Remote Access + panel and pairing copy must state the user job (alert push notifications + and remote monitoring through the Pulse Mobile app with no inbound ports or + VPN), name iOS and Android early access, and state that download-page + install links appear only after activation-key entry, rather than + describing relay transport mechanics alone. 2. `frontend-modern/src/components/Settings/MonitoredSystemImpactPreview.tsx` shared with `agent-lifecycle`: the monitored-system impact preview is both a platform-connections lifecycle surface and a canonical cloud-paid monitored-system presentation boundary. 3. `frontend-modern/src/useAppRuntimeState.ts` shared with `performance-and-scalability`: the authenticated app runtime bootstrap is both a hosted commercial org-context boundary and a protected app-shell performance boundary. The app runtime may use `ssoSessionDisplayName` from security status for diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index e470ccec5..cda0945c5 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -192,6 +192,12 @@ must be reachable through the sidebar unless it is explicitly a hidden deep-link flow. The self-hosted `Plans` page is not a deep-link-only flow: `system-billing` stays visible whenever commercial presentation is allowed, while demo or commercial-suppressed sessions may still hide it. +`system-relay` (Remote Access) is likewise not a deep-link-only flow: its nav +item stays visible without the `relay` feature so Relay remains discoverable +to free installs, and the panel-owned feature gate — not nav hiding — +communicates the paid boundary. Capability-based hiding (`relayRead`) still +applies; feature-based `hideWhenUnavailable` must not be reintroduced on that +item. Candidate import-plan presentation inside the Infrastructure settings dialog is a shared primitive composition boundary. `NodeCandidateImportPlan.tsx` may use diff --git a/frontend-modern/src/components/Settings/RelayPairingSection.tsx b/frontend-modern/src/components/Settings/RelayPairingSection.tsx index edb74ddd2..3ec1084cd 100644 --- a/frontend-modern/src/components/Settings/RelayPairingSection.tsx +++ b/frontend-modern/src/components/Settings/RelayPairingSection.tsx @@ -66,8 +66,8 @@ export const RelayPairingSection: Component = (props)

- Generate a QR code that provisions a dedicated Pulse Mobile relay access credential.{' '} - {RELAY_PAIRING_APP_AVAILABILITY_TEXT}{' '} + Generate a QR code to securely link the Pulse Mobile app on your phone to this Pulse + instance. {RELAY_PAIRING_APP_AVAILABILITY_TEXT}{' '} { expect( screen.getByText( - 'Remote access is available with Relay and higher plans. Pair supported Pulse Mobile clients with this instance using a QR code or deep link.', + 'See your infrastructure and get alert push notifications anywhere with the Pulse Mobile app — no port forwarding or VPN required. Available with Relay and Pro plans.', ), ).toBeInTheDocument(); expect(screen.getByRole('link', { name: 'View plans' })).toHaveAttribute( @@ -208,7 +208,7 @@ describe('RelaySettingsPanel runtime', () => { expect(screen.getByText('Pair Pulse Mobile through Relay')).toBeInTheDocument(); expect( screen.getByText( - 'Supported Pulse Mobile clients connect to this Pulse instance with a QR code or deep link over end-to-end encrypted relay connectivity.', + 'Install the Pulse Mobile app on your phone, then scan a QR code or open a deep link here to securely link it to this Pulse instance. Paired devices receive alert push notifications over an end-to-end encrypted connection.', ), ).toBeInTheDocument(); expect(screen.getByText('Connected')).toBeInTheDocument(); diff --git a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts index a3848b426..536a866fc 100644 --- a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts @@ -275,6 +275,14 @@ describe('settings architecture guardrails', () => { expect(settingsNavCatalogSource).toContain("features: ['relay']"); expect(settingsNavCatalogSource).toContain("features: ['advanced_reporting']"); expect(settingsNavCatalogSource).toContain('hideWhenUnavailable: true'); + // system-relay deliberately stays visible without the relay feature so + // Relay remains discoverable to free installs; the panel-owned upgrade + // gate communicates the paid boundary instead of nav hiding. + const systemRelayNavBlock = settingsNavCatalogSource.match( + /id: 'system-relay',[\s\S]*?requiredCapability: 'relayRead',\n\s*},/, + ); + expect(systemRelayNavBlock?.[0]).toContain("features: ['relay']"); + expect(systemRelayNavBlock?.[0]).not.toContain('hideWhenUnavailable'); expect(settingsHeaderMetaSource).toContain( 'title: SELF_HOSTED_PRO_BILLING_PRESENTATION.shellTitle', ); diff --git a/frontend-modern/src/components/Settings/__tests__/settingsHeaderMeta.branchcov0713.test.ts b/frontend-modern/src/components/Settings/__tests__/settingsHeaderMeta.branchcov0713.test.ts index 899e13461..20b267fda 100644 --- a/frontend-modern/src/components/Settings/__tests__/settingsHeaderMeta.branchcov0713.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/settingsHeaderMeta.branchcov0713.test.ts @@ -98,8 +98,7 @@ describe('getSettingsHeaderMeta', () => { it('localizes monitoring-availability to the English baseline', () => { expect(en['monitoring-availability']).toEqual({ title: 'Availability checks', - description: - 'Monitor endpoint-only devices and services with ping, TCP, and HTTP probes.', + description: 'Monitor endpoint-only devices and services with ping, TCP, and HTTP probes.', }); }); @@ -113,8 +112,7 @@ describe('getSettingsHeaderMeta', () => { it('localizes system-network to the English baseline (sibling only pinned es/de)', () => { expect(en['system-network']).toEqual({ title: 'Network', - description: - 'Configure the public URL, CORS, embedding, and webhook network boundaries.', + description: 'Configure the public URL, CORS, embedding, and webhook network boundaries.', }); }); @@ -167,7 +165,7 @@ describe('getSettingsHeaderMeta', () => { expect(en['system-relay']).toEqual({ title: 'Remote Access', description: - 'Configure Pulse relay connectivity for secure remote access and Pulse Mobile pairing.', + 'Check on your systems and get alert push notifications anywhere with the Pulse Mobile app — no port forwarding or VPN required.', }); }); @@ -442,7 +440,7 @@ describe('getSettingsHeaderMeta', () => { expect(de['system-relay']).toEqual({ title: 'Remote-Zugriff', description: - 'Konfigurieren Sie Pulse-Relay-Konnektivitaet fuer sicheren Remote-Zugriff und Pulse-Mobile-Kopplung.', + 'Behalten Sie Ihre Systeme von ueberall im Blick und erhalten Sie Alarm-Push-Benachrichtigungen ueber die Pulse-Mobile-App — ohne Portfreigaben oder VPN.', }); }); @@ -535,8 +533,7 @@ describe('getSettingsHeaderMeta', () => { it('localizes security-auth', () => { expect(de['security-auth']).toEqual({ title: 'Authentifizierung', - description: - 'Verwalten Sie passwortbasierte Authentifizierung und Zugangsdatenrotation.', + description: 'Verwalten Sie passwortbasierte Authentifizierung und Zugangsdatenrotation.', }); }); @@ -642,7 +639,7 @@ describe('getSettingsHeaderMeta', () => { expect(es['system-relay']).toEqual({ title: 'Acceso remoto', description: - 'Configura la conectividad de relay de Pulse para acceso remoto seguro y vinculación con Pulse Mobile.', + 'Consulta tus sistemas y recibe notificaciones push de alertas desde cualquier lugar con la aplicación Pulse Mobile — sin abrir puertos ni VPN.', }); }); @@ -757,8 +754,7 @@ describe('getSettingsHeaderMeta', () => { it('localizes security-users', () => { expect(es['security-users']).toEqual({ title: 'Acceso de usuarios', - description: - 'Asigna roles a usuarios y consulta permisos efectivos en tu infraestructura.', + description: 'Asigna roles a usuarios y consulta permisos efectivos en tu infraestructura.', }); }); @@ -818,15 +814,15 @@ describe('getInfrastructureReadOnlyHeaderDescription', () => { }); it('normalizes an explicit null locale to DEFAULT_LOCALE', () => { - expect( - getInfrastructureReadOnlyHeaderDescription(null as unknown as SupportedLocale), - ).toBe(getInfrastructureReadOnlyHeaderDescription('en')); + expect(getInfrastructureReadOnlyHeaderDescription(null as unknown as SupportedLocale)).toBe( + getInfrastructureReadOnlyHeaderDescription('en'), + ); }); it('normalizes an unsupported locale string to DEFAULT_LOCALE', () => { // 'fr' is not yet a SupportedLocale, so normalizeLocale falls back to en. - expect( - getInfrastructureReadOnlyHeaderDescription('fr' as unknown as SupportedLocale), - ).toBe(getInfrastructureReadOnlyHeaderDescription('en')); + expect(getInfrastructureReadOnlyHeaderDescription('fr' as unknown as SupportedLocale)).toBe( + getInfrastructureReadOnlyHeaderDescription('en'), + ); }); }); diff --git a/frontend-modern/src/components/Settings/__tests__/settingsLocalization.test.ts b/frontend-modern/src/components/Settings/__tests__/settingsLocalization.test.ts index 1699fdb2c..b9f917b15 100644 --- a/frontend-modern/src/components/Settings/__tests__/settingsLocalization.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/settingsLocalization.test.ts @@ -49,6 +49,24 @@ describe('settings localization catalog', () => { expect(getSettingsNavItem('support-diagnostics', 'es')?.label).toBe('Diagnóstico y salud'); }); + it('localizes the value-first Remote Access header in every catalog locale', () => { + expect(SETTINGS_HEADER_META['system-relay']).toEqual({ + title: 'Remote Access', + description: + 'Check on your systems and get alert push notifications anywhere with the Pulse Mobile app — no port forwarding or VPN required.', + }); + expect(getSettingsHeaderMeta('es')['system-relay']).toEqual({ + title: 'Acceso remoto', + description: + 'Consulta tus sistemas y recibe notificaciones push de alertas desde cualquier lugar con la aplicación Pulse Mobile — sin abrir puertos ni VPN.', + }); + expect(getSettingsHeaderMeta('de')['system-relay']).toEqual({ + title: 'Remote-Zugriff', + description: + 'Behalten Sie Ihre Systeme von ueberall im Blick und erhalten Sie Alarm-Push-Benachrichtigungen ueber die Pulse-Mobile-App — ohne Portfreigaben oder VPN.', + }); + }); + it('localizes settings header metadata and read-only infrastructure copy', () => { const spanishMeta = getSettingsHeaderMeta('es'); expect(spanishMeta['system-network']).toEqual({ diff --git a/frontend-modern/src/components/Settings/__tests__/settingsNavVisibility.branchcov2.test.ts b/frontend-modern/src/components/Settings/__tests__/settingsNavVisibility.branchcov2.test.ts index e1d724dd0..cdeb06b36 100644 --- a/frontend-modern/src/components/Settings/__tests__/settingsNavVisibility.branchcov2.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/settingsNavVisibility.branchcov2.test.ts @@ -363,21 +363,18 @@ describe('shouldBlockSettingsRouteItem', () => { 'security-users', 'security-audit', 'security-webhooks', - ] as const)( - 'does NOT block panel-owned tab %s even when its features are missing', - (tab) => { - expect( - shouldBlockSettingsRouteItem( - tab, - createContext({ - hasFeature: hasFeatures([]), - presentationPolicyResolved: true, - settingsCapabilitiesResolved: false, - }), - ), - ).toBe(false); - }, - ); + ] as const)('does NOT block panel-owned tab %s even when its features are missing', (tab) => { + expect( + shouldBlockSettingsRouteItem( + tab, + createContext({ + hasFeature: hasFeatures([]), + presentationPolicyResolved: true, + settingsCapabilitiesResolved: false, + }), + ), + ).toBe(false); + }); }); describe('non-panel-owned hideWhenUnavailable tabs are feature-gated for routing', () => { @@ -456,8 +453,17 @@ describe('isSettingsNavItemLocked', () => { expect( isSettingsNavItemLocked('security-roles', createContext({ hasFeature: hasFeatures([]) })), ).toBe(false); + }); + + it('reports system-relay as locked for free installs (visible nav item, panel-owned gate)', () => { expect( isSettingsNavItemLocked('system-relay', createContext({ hasFeature: hasFeatures([]) })), + ).toBe(true); + expect( + isSettingsNavItemLocked( + 'system-relay', + createContext({ hasFeature: hasFeatures(['relay']) }), + ), ).toBe(false); }); @@ -476,11 +482,11 @@ describe('isSettingsNavItemLocked', () => { ).toBe(false); }); - it('never reports a real catalog tab as locked (every feature-gated tab also has hideWhenUnavailable)', () => { - // See GLM_REPORT: the `return isTabLocked(...)` -> true outcome is unreachable - // for real catalog items, so every real tab resolves to false here. + it('never reports other real catalog tabs as locked (their feature gates also carry hideWhenUnavailable)', () => { + // system-relay is the one deliberate exception: it stays visible without the + // relay feature and reports locked (see the dedicated test above). Every + // other feature-gated tab still hides instead of locking. const tabs: SettingsTab[] = [ - 'system-relay', 'security-webhooks', 'organization-overview', 'organization-access', @@ -492,9 +498,9 @@ describe('isSettingsNavItemLocked', () => { 'support-diagnostics', ]; for (const tab of tabs) { - expect( - isSettingsNavItemLocked(tab, createContext({ hasFeature: hasFeatures([]) })), - ).toBe(false); + expect(isSettingsNavItemLocked(tab, createContext({ hasFeature: hasFeatures([]) }))).toBe( + false, + ); } }); }); diff --git a/frontend-modern/src/components/Settings/__tests__/settingsNavigation.integration.test.tsx b/frontend-modern/src/components/Settings/__tests__/settingsNavigation.integration.test.tsx index 511976923..675a0702c 100644 --- a/frontend-modern/src/components/Settings/__tests__/settingsNavigation.integration.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/settingsNavigation.integration.test.tsx @@ -135,9 +135,32 @@ describe('settingsNavigation integration scaffold', () => { ).toBe(false); }); + it('keeps the Remote Access tab visible to free installs so Relay stays discoverable', () => { + // system-relay deliberately omits hideWhenUnavailable: the panel renders its + // own upgrade gate, so free installs see the entry instead of never learning + // the capability exists. + expect( + shouldHideSettingsNavItem('system-relay', { + hasFeature: hasFeatures([]), + runtimeCapabilitiesLoaded: () => true, + hostedModeEnabled: false, + settingsCapabilitiesResolved: true, + settingsCapabilities: { relayRead: true }, + }), + ).toBe(false); + expect( + shouldBlockSettingsRouteItem('system-relay', { + hasFeature: hasFeatures([]), + runtimeCapabilitiesLoaded: () => true, + hostedModeEnabled: false, + settingsCapabilitiesResolved: true, + settingsCapabilities: { relayRead: true }, + }), + ).toBe(false); + }); + it('hides paid-only self-hosted tabs from free installs', () => { for (const tab of [ - 'system-relay', 'security-roles', 'security-users', 'security-audit', @@ -186,7 +209,6 @@ describe('settingsNavigation integration scaffold', () => { it('keeps direct panel-owned feature gates routeable even when hidden from navigation', () => { for (const tab of [ - 'system-relay', 'support-reporting', 'security-roles', 'security-users', diff --git a/frontend-modern/src/components/Settings/settingsNavCatalog.ts b/frontend-modern/src/components/Settings/settingsNavCatalog.ts index 32a22f2f6..a944ac00b 100644 --- a/frontend-modern/src/components/Settings/settingsNavCatalog.ts +++ b/frontend-modern/src/components/Settings/settingsNavCatalog.ts @@ -280,7 +280,9 @@ export const SETTINGS_NAV_GROUPS: SettingsNavGroup[] = [ icon: RadioTower, iconProps: { strokeWidth: 2 }, features: ['relay'], - hideWhenUnavailable: true, + // Deliberately visible without the relay feature: the panel renders its + // own upgrade gate, and hiding the item made Relay undiscoverable for + // free installs. requiredCapability: 'relayRead', }, ], diff --git a/frontend-modern/src/features/alerts/AlertPushDestinationsSection.test.tsx b/frontend-modern/src/features/alerts/AlertPushDestinationsSection.test.tsx new file mode 100644 index 000000000..248e274b6 --- /dev/null +++ b/frontend-modern/src/features/alerts/AlertPushDestinationsSection.test.tsx @@ -0,0 +1,72 @@ +import { cleanup, render, screen } from '@solidjs/testing-library'; +import { Route, Router } from '@solidjs/router'; +import type { JSX } from 'solid-js'; +import { afterEach, describe, expect, it } from 'vitest'; +import { AlertPushDestinationsSection } from './AlertPushDestinationsSection'; +import { + ALERT_DESTINATIONS_PUSH_GATE_MESSAGE, + ALERT_DESTINATIONS_PUSH_READY_MESSAGE, + ALERT_DESTINATIONS_PUSH_SETUP_LINK_LABEL, +} from '@/utils/alertDestinationsPresentation'; +import type { UpgradeDestination } from '@/utils/upgradeNavigation'; + +const upgradeDestination: UpgradeDestination = { + href: '/settings/pulse-intelligence/billing', + external: false, +}; + +function renderWithRouter(ui: () => JSX.Element) { + return render(() => ( + + + + )); +} + +describe('AlertPushDestinationsSection', () => { + afterEach(() => { + cleanup(); + }); + + it('points licensed installs at Remote Access settings', () => { + renderWithRouter(() => ( + + )); + + expect(screen.getByText(ALERT_DESTINATIONS_PUSH_READY_MESSAGE)).toBeInTheDocument(); + const link = screen.getByRole('link', { + name: `${ALERT_DESTINATIONS_PUSH_SETUP_LINK_LABEL} →`, + }); + expect(link).toHaveAttribute('href', '/settings/system-relay'); + }); + + it('shows the Relay upgrade gate to unlicensed installs', () => { + renderWithRouter(() => ( + + )); + + expect(screen.getByText(ALERT_DESTINATIONS_PUSH_GATE_MESSAGE)).toBeInTheDocument(); + expect(screen.queryByText(ALERT_DESTINATIONS_PUSH_READY_MESSAGE)).not.toBeInTheDocument(); + }); + + it('hides the upgrade call-to-action when upgrade prompts are suppressed', () => { + renderWithRouter(() => ( + + )); + + expect(screen.getByText(ALERT_DESTINATIONS_PUSH_GATE_MESSAGE)).toBeInTheDocument(); + expect(screen.queryByRole('link')).not.toBeInTheDocument(); + }); +}); diff --git a/frontend-modern/src/features/alerts/AlertPushDestinationsSection.tsx b/frontend-modern/src/features/alerts/AlertPushDestinationsSection.tsx new file mode 100644 index 000000000..567054ca5 --- /dev/null +++ b/frontend-modern/src/features/alerts/AlertPushDestinationsSection.tsx @@ -0,0 +1,53 @@ +import { Show } from 'solid-js'; +import Smartphone from 'lucide-solid/icons/smartphone'; +import { SettingsPanel } from '@/components/shared/SettingsPanel'; +import { FeatureGateSection } from '@/components/shared/FeatureGateSection'; +import type { UpgradeDestination } from '@/utils/upgradeNavigation'; +import { + ALERT_DESTINATIONS_PUSH_GATE_MESSAGE, + ALERT_DESTINATIONS_PUSH_GATE_TITLE, + ALERT_DESTINATIONS_PUSH_PANEL_DESCRIPTION, + ALERT_DESTINATIONS_PUSH_PANEL_TITLE, + ALERT_DESTINATIONS_PUSH_READY_MESSAGE, + ALERT_DESTINATIONS_PUSH_SETUP_LINK_LABEL, +} from '@/utils/alertDestinationsPresentation'; + +interface AlertPushDestinationsSectionProps { + relayLicensed: boolean; + showUpgradePrompts: boolean; + upgradeDestination: UpgradeDestination; +} + +export function AlertPushDestinationsSection(props: AlertPushDestinationsSectionProps) { + return ( + + } + title={ALERT_DESTINATIONS_PUSH_GATE_TITLE} + body={ALERT_DESTINATIONS_PUSH_GATE_MESSAGE} + upgradeDestination={props.upgradeDestination} + showUpgradePrompts={props.showUpgradePrompts} + /> + } + > +

+ + + ); +} diff --git a/frontend-modern/src/features/alerts/tabs/DestinationsTab.tsx b/frontend-modern/src/features/alerts/tabs/DestinationsTab.tsx index 97b53e9ec..54ffac80a 100644 --- a/frontend-modern/src/features/alerts/tabs/DestinationsTab.tsx +++ b/frontend-modern/src/features/alerts/tabs/DestinationsTab.tsx @@ -1,11 +1,18 @@ import { Show } from 'solid-js'; +import { hasFeature } from '@/stores/license'; +import { getUpgradeActionDestination } from '@/stores/licenseCommercial'; +import { presentationPolicyHidesUpgradePrompts } from '@/stores/sessionPresentationPolicy'; import { AlertAppriseDestinationsSection } from '../AlertAppriseDestinationsSection'; import { AlertDestinationsLoadErrorCard } from '../AlertDestinationsLoadErrorCard'; import { AlertDestinationsLoadingState } from '../AlertDestinationsLoadingState'; import { AlertEmailDestinationsSection } from '../AlertEmailDestinationsSection'; +import { AlertPushDestinationsSection } from '../AlertPushDestinationsSection'; import { AlertWebhookDestinationsSection } from '../AlertWebhookDestinationsSection'; -import { useAlertDestinationsTabState, type AlertDestinationsTabStateProps } from '../useAlertDestinationsTabState'; +import { + useAlertDestinationsTabState, + type AlertDestinationsTabStateProps, +} from '../useAlertDestinationsTabState'; export interface DestinationsTabProps extends AlertDestinationsTabStateProps { setHasUnsavedChanges: (value: boolean) => void; @@ -17,10 +24,7 @@ export function DestinationsTab(props: DestinationsTabProps) { return (
- } - > + }> + +
); diff --git a/frontend-modern/src/i18n/messages.de.ts b/frontend-modern/src/i18n/messages.de.ts index dab9b515b..bb45d5524 100644 --- a/frontend-modern/src/i18n/messages.de.ts +++ b/frontend-modern/src/i18n/messages.de.ts @@ -97,7 +97,7 @@ export const DE_MESSAGE_OVERRIDES = { 'Pruefen Sie aktive Vorfaelle, Warnungsverlauf, Schwellwerte, Benachrichtigungen und Zeitplaene.', 'alerts.page.default.title': 'Warnmeldungen', 'alerts.page.destinations.description': - 'Leiten Sie Warnmeldungen an E-Mail-, Apprise- und Webhook-Ziele weiter.', + 'Leiten Sie Warnmeldungen an E-Mail-, Apprise-, Webhook- und Mobile-Push-Ziele weiter.', 'alerts.page.destinations.title': 'Benachrichtigungen', 'alerts.page.history.description': 'Suchen Sie fruehere Warnmeldungen, pruefen Sie Vorfall-Zeitleisten und beobachten Sie Warnungshaeufigkeit ueber Zeit.', @@ -423,7 +423,7 @@ export const DE_MESSAGE_OVERRIDES = { 'Verwalten Sie Backup-/Snapshot-Abfragen sowie Export- und Importablaeufe der Konfiguration.', 'settings.header.systemRecovery.title': 'Wiederherstellung', 'settings.header.systemRelay.description': - 'Konfigurieren Sie Pulse-Relay-Konnektivitaet fuer sicheren Remote-Zugriff und Pulse-Mobile-Kopplung.', + 'Behalten Sie Ihre Systeme von ueberall im Blick und erhalten Sie Alarm-Push-Benachrichtigungen ueber die Pulse-Mobile-App — ohne Portfreigaben oder VPN.', 'settings.header.systemRelay.title': 'Remote-Zugriff', 'settings.header.systemUpdates.description': 'Verwalten Sie Versionspruefungen, Update-Kanaele und automatisches Update-Verhalten.', diff --git a/frontend-modern/src/i18n/messages.es.ts b/frontend-modern/src/i18n/messages.es.ts index 5edd54d54..c7ca68010 100644 --- a/frontend-modern/src/i18n/messages.es.ts +++ b/frontend-modern/src/i18n/messages.es.ts @@ -92,7 +92,7 @@ export const ES_MESSAGE_OVERRIDES = { 'Revisa incidentes activos, el historial de alertas, umbrales, notificaciones y programaciones.', 'alerts.page.default.title': 'Alertas', 'alerts.page.destinations.description': - 'Enruta notificaciones de alerta a destinos de email, Apprise y webhook.', + 'Enruta notificaciones de alerta a destinos de email, Apprise, webhook y push móvil.', 'alerts.page.destinations.title': 'Notificaciones', 'alerts.page.history.description': 'Busca alertas anteriores, revisa lineas de tiempo de incidentes e inspecciona la frecuencia de alertas.', @@ -416,7 +416,7 @@ export const ES_MESSAGE_OVERRIDES = { 'Administra sondeos de copias/snapshots y flujos de exportación e importación de configuración.', 'settings.header.systemRecovery.title': 'Recuperación', 'settings.header.systemRelay.description': - 'Configura la conectividad de relay de Pulse para acceso remoto seguro y vinculación con Pulse Mobile.', + 'Consulta tus sistemas y recibe notificaciones push de alertas desde cualquier lugar con la aplicación Pulse Mobile — sin abrir puertos ni VPN.', 'settings.header.systemRelay.title': 'Acceso remoto', 'settings.header.systemUpdates.description': 'Administra comprobaciones de versión, canales de actualización y comportamiento de actualización automática.', diff --git a/frontend-modern/src/i18n/messages.ts b/frontend-modern/src/i18n/messages.ts index 2373526a5..a530322f4 100644 --- a/frontend-modern/src/i18n/messages.ts +++ b/frontend-modern/src/i18n/messages.ts @@ -181,7 +181,7 @@ export const EN_MESSAGES = { 'Review active incidents, inspect alert history, and manage thresholds, notifications, and schedules.', 'alerts.page.default.title': 'Alerts', 'alerts.page.destinations.description': - 'Route alert notifications to email, Apprise, and webhook destinations.', + 'Route alert notifications to email, Apprise, webhook, and mobile push destinations.', 'alerts.page.destinations.title': 'Notifications', 'alerts.page.history.description': 'Search prior alerts, review incident timelines, and inspect alert frequency over time.', @@ -408,7 +408,7 @@ export const EN_MESSAGES = { 'Manage backup/snapshot polling plus configuration export and import workflows.', 'settings.header.systemRecovery.title': 'Recovery', 'settings.header.systemRelay.description': - 'Configure Pulse relay connectivity for secure remote access and Pulse Mobile pairing.', + 'Check on your systems and get alert push notifications anywhere with the Pulse Mobile app — no port forwarding or VPN required.', 'settings.header.systemRelay.title': 'Remote Access', 'settings.header.systemUpdates.description': 'Manage version checks, update channels, and automatic update behavior.', diff --git a/frontend-modern/src/utils/__tests__/alertDestinationsPresentation.test.ts b/frontend-modern/src/utils/__tests__/alertDestinationsPresentation.test.ts index a6415de96..1e1df3a8a 100644 --- a/frontend-modern/src/utils/__tests__/alertDestinationsPresentation.test.ts +++ b/frontend-modern/src/utils/__tests__/alertDestinationsPresentation.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from 'vitest'; import { ALERT_DESTINATIONS_CONFIG_LOAD_ERROR, + ALERT_DESTINATIONS_PUSH_GATE_MESSAGE, + ALERT_DESTINATIONS_PUSH_GATE_TITLE, + ALERT_DESTINATIONS_PUSH_PANEL_DESCRIPTION, + ALERT_DESTINATIONS_PUSH_PANEL_TITLE, + ALERT_DESTINATIONS_PUSH_READY_MESSAGE, + ALERT_DESTINATIONS_PUSH_SETUP_LINK_LABEL, ALERT_DESTINATIONS_APPRISE_API_KEY_HEADER_HELP, ALERT_DESTINATIONS_APPRISE_ENABLE_FOR_TEST_ERROR, ALERT_DESTINATIONS_APPRISE_MISSING_TARGETS_ERROR, @@ -107,4 +113,18 @@ describe('alertDestinationsPresentation', () => { 'Unable to send the test notification.', ); }); + + it('returns canonical mobile push destination copy', () => { + expect(ALERT_DESTINATIONS_PUSH_PANEL_TITLE).toBe('Mobile push notifications'); + expect(ALERT_DESTINATIONS_PUSH_PANEL_DESCRIPTION).toBe( + 'Deliver alerts to your phone through the Pulse Mobile app.', + ); + expect(ALERT_DESTINATIONS_PUSH_READY_MESSAGE).toContain('Pulse Mobile devices paired'); + expect(ALERT_DESTINATIONS_PUSH_READY_MESSAGE).toContain('Remote Access settings'); + expect(ALERT_DESTINATIONS_PUSH_SETUP_LINK_LABEL).toBe('Open Remote Access settings'); + expect(ALERT_DESTINATIONS_PUSH_GATE_TITLE).toBe('Get alerts on your phone'); + expect(ALERT_DESTINATIONS_PUSH_GATE_MESSAGE).toContain('Pulse Mobile app'); + expect(ALERT_DESTINATIONS_PUSH_GATE_MESSAGE).toContain('no port forwarding or VPN'); + expect(ALERT_DESTINATIONS_PUSH_GATE_MESSAGE).toContain('Available with Relay and Pro plans'); + }); }); diff --git a/frontend-modern/src/utils/__tests__/alertOverviewPresentation.test.ts b/frontend-modern/src/utils/__tests__/alertOverviewPresentation.test.ts index ba1c430d9..5868f7418 100644 --- a/frontend-modern/src/utils/__tests__/alertOverviewPresentation.test.ts +++ b/frontend-modern/src/utils/__tests__/alertOverviewPresentation.test.ts @@ -147,7 +147,7 @@ describe('alertOverviewPresentation', () => { 'Tune thresholds and scoped overrides for infrastructure, workloads, and integrations.', ); expect(ALERTS_PAGE_DESTINATIONS_DESCRIPTION).toBe( - 'Route alert notifications to email, Apprise, and webhook destinations.', + 'Route alert notifications to email, Apprise, webhook, and mobile push destinations.', ); expect(ALERTS_PAGE_SCHEDULE_DESCRIPTION).toBe( 'Define quiet hours, grouping, cooldowns, recovery, and escalation behavior for alert delivery.', @@ -168,7 +168,8 @@ describe('alertOverviewPresentation', () => { }, destinations: { title: 'Notifications', - description: 'Route alert notifications to email, Apprise, and webhook destinations.', + description: + 'Route alert notifications to email, Apprise, webhook, and mobile push destinations.', }, schedule: { title: 'Maintenance Schedule', diff --git a/frontend-modern/src/utils/__tests__/relayPresentation.test.ts b/frontend-modern/src/utils/__tests__/relayPresentation.test.ts index a8e3e78ca..a9809f2a0 100644 --- a/frontend-modern/src/utils/__tests__/relayPresentation.test.ts +++ b/frontend-modern/src/utils/__tests__/relayPresentation.test.ts @@ -104,13 +104,22 @@ describe('relayPresentation', () => { }); it('centralizes relay availability copy', () => { - expect(RELAY_SETTINGS_DESCRIPTION).toContain('Pulse Mobile pairing'); - expect(RELAY_LICENSE_REQUIRED_MESSAGE).toContain('supported Pulse Mobile clients'); - expect(RELAY_LICENSE_REQUIRED_MESSAGE).toContain('available with Relay and higher plans'); + // Value-first copy: every surface states the user job (push notifications + // through the Pulse Mobile app, no port forwarding/VPN), not transport + // mechanics. + expect(RELAY_SETTINGS_DESCRIPTION).toContain('Pulse Mobile app'); + expect(RELAY_SETTINGS_DESCRIPTION).toContain('push notifications'); + expect(RELAY_SETTINGS_DESCRIPTION).toContain('no port forwarding or VPN'); + expect(RELAY_LICENSE_REQUIRED_MESSAGE).toContain('Pulse Mobile app'); + expect(RELAY_LICENSE_REQUIRED_MESSAGE).toContain('push notifications'); + expect(RELAY_LICENSE_REQUIRED_MESSAGE).toContain('Available with Relay and Pro plans'); expect(RELAY_LICENSE_REQUIRED_MESSAGE).not.toContain('Relay or Pro'); expect(RELAY_PAIRING_AVAILABILITY_TITLE).toBe('Pair Pulse Mobile through Relay'); - expect(RELAY_PAIRING_AVAILABILITY_MESSAGE).toContain('QR code or deep link'); - expect(RELAY_ENABLE_HELP_TEXT).toContain('Pulse Mobile pairing'); + expect(RELAY_PAIRING_AVAILABILITY_MESSAGE).toContain('QR code'); + expect(RELAY_PAIRING_AVAILABILITY_MESSAGE).toContain('deep link'); + expect(RELAY_PAIRING_AVAILABILITY_MESSAGE).toContain('push notifications'); + expect(RELAY_ENABLE_HELP_TEXT).toContain('Pulse Mobile devices'); + expect(RELAY_ENABLE_HELP_TEXT).toContain('No inbound ports'); expect(RELAY_ACTIVATION_REQUIRED_LABEL).toBe('Activation required'); expect(RELAY_ACTIVATION_REQUIRED_MESSAGE).toContain('active Relay token'); expect(RELAY_ACTIVATION_REQUIRED_MESSAGE).toContain('Relay-capable plan'); diff --git a/frontend-modern/src/utils/alertDestinationsPresentation.ts b/frontend-modern/src/utils/alertDestinationsPresentation.ts index 3924bf210..588b2d49e 100644 --- a/frontend-modern/src/utils/alertDestinationsPresentation.ts +++ b/frontend-modern/src/utils/alertDestinationsPresentation.ts @@ -61,13 +61,19 @@ export const ALERT_DESTINATIONS_APPRISE_MISSING_TARGETS_ERROR = 'Add at least one Apprise target to test CLI delivery.'; export const ALERT_DESTINATIONS_APPRISE_MISSING_SERVER_URL_ERROR = 'Enter an Apprise API server URL to test API delivery.'; -export const ALERT_DESTINATIONS_EMAIL_TEST_SUCCESS = - 'Test email sent. Check your inbox.'; +export const ALERT_DESTINATIONS_EMAIL_TEST_SUCCESS = 'Test email sent. Check your inbox.'; export const ALERT_DESTINATIONS_EMAIL_TEST_FAILURE = 'Unable to send the test email.'; -export const ALERT_DESTINATIONS_APPRISE_TEST_SUCCESS = - 'Test Apprise notification sent.'; -export const ALERT_DESTINATIONS_APPRISE_TEST_FAILURE = - 'Unable to send the test notification.'; +export const ALERT_DESTINATIONS_PUSH_PANEL_TITLE = 'Mobile push notifications'; +export const ALERT_DESTINATIONS_PUSH_PANEL_DESCRIPTION = + 'Deliver alerts to your phone through the Pulse Mobile app.'; +export const ALERT_DESTINATIONS_PUSH_READY_MESSAGE = + 'Alerts are pushed to Pulse Mobile devices paired with this instance. Manage pairing and connectivity in Remote Access settings.'; +export const ALERT_DESTINATIONS_PUSH_SETUP_LINK_LABEL = 'Open Remote Access settings'; +export const ALERT_DESTINATIONS_PUSH_GATE_TITLE = 'Get alerts on your phone'; +export const ALERT_DESTINATIONS_PUSH_GATE_MESSAGE = + 'Pair the Pulse Mobile app to receive alert push notifications and check on your systems from anywhere — no port forwarding or VPN required. Available with Relay and Pro plans.'; +export const ALERT_DESTINATIONS_APPRISE_TEST_SUCCESS = 'Test Apprise notification sent.'; +export const ALERT_DESTINATIONS_APPRISE_TEST_FAILURE = 'Unable to send the test notification.'; export function getAlertDestinationsConfigLoadError() { return ALERT_DESTINATIONS_CONFIG_LOAD_ERROR; diff --git a/frontend-modern/src/utils/alertOverviewPresentation.ts b/frontend-modern/src/utils/alertOverviewPresentation.ts index 4644f2ba4..067147256 100644 --- a/frontend-modern/src/utils/alertOverviewPresentation.ts +++ b/frontend-modern/src/utils/alertOverviewPresentation.ts @@ -31,7 +31,7 @@ export const ALERTS_PAGE_OVERVIEW_DESCRIPTION = export const ALERTS_PAGE_THRESHOLDS_DESCRIPTION = 'Tune thresholds and scoped overrides for infrastructure, workloads, and integrations.'; export const ALERTS_PAGE_DESTINATIONS_DESCRIPTION = - 'Route alert notifications to email, Apprise, and webhook destinations.'; + 'Route alert notifications to email, Apprise, webhook, and mobile push destinations.'; export const ALERTS_PAGE_SCHEDULE_DESCRIPTION = 'Define quiet hours, grouping, cooldowns, recovery, and escalation behavior for alert delivery.'; export const ALERTS_PAGE_HISTORY_DESCRIPTION = diff --git a/frontend-modern/src/utils/relayPresentation.ts b/frontend-modern/src/utils/relayPresentation.ts index cb13ba1ef..502f98c22 100644 --- a/frontend-modern/src/utils/relayPresentation.ts +++ b/frontend-modern/src/utils/relayPresentation.ts @@ -7,7 +7,8 @@ export interface RelayConnectionPresentation { pulse: boolean; } -const RELAY_MISSING_TOKEN_ERROR = /\b(?:no license token available|license token provider not configured)\b/i; +const RELAY_MISSING_TOKEN_ERROR = + /\b(?:no license token available|license token provider not configured)\b/i; export const RELAY_READONLY_NOTICE_CLASS = 'border border-blue-200 text-xs text-blue-800 dark:border-blue-800 dark:text-blue-200'; @@ -29,19 +30,19 @@ export const RELAY_QR_IMAGE_CLASS = 'rounded-md border border-border p-2'; export const RELAY_DIAGNOSTICS_WRAP_CLASS = 'space-y-2'; export const RELAY_DIAGNOSTICS_TITLE_CLASS = 'text-xs font-semibold text-base-content'; export const RELAY_SETTINGS_DESCRIPTION = - 'Configure Pulse relay connectivity for secure remote access and Pulse Mobile pairing.'; + 'Check on your systems and get alert push notifications anywhere with the Pulse Mobile app — no port forwarding or VPN required.'; export const RELAY_LICENSE_REQUIRED_MESSAGE = - 'Remote access is available with Relay and higher plans. Pair supported Pulse Mobile clients with this instance using a QR code or deep link.'; + 'See your infrastructure and get alert push notifications anywhere with the Pulse Mobile app — no port forwarding or VPN required. Available with Relay and Pro plans.'; export const RELAY_PAIRING_AVAILABILITY_TITLE = 'Pair Pulse Mobile through Relay'; export const RELAY_PAIRING_AVAILABILITY_MESSAGE = - 'Supported Pulse Mobile clients connect to this Pulse instance with a QR code or deep link over end-to-end encrypted relay connectivity.'; + 'Install the Pulse Mobile app on your phone, then scan a QR code or open a deep link here to securely link it to this Pulse instance. Paired devices receive alert push notifications over an end-to-end encrypted connection.'; export const RELAY_ENABLE_HELP_TEXT = - 'Connect this Pulse instance to the relay server for secure remote access and Pulse Mobile pairing.'; + 'Open a secure outbound connection so paired Pulse Mobile devices can reach this instance from anywhere. No inbound ports needed.'; export const RELAY_ACTIVATION_REQUIRED_LABEL = 'Activation required'; export const RELAY_ACTIVATION_REQUIRED_MESSAGE = 'Remote Access is enabled, but this instance does not have an active Relay token. Activate a Relay-capable plan or turn Remote Access off before pairing mobile clients.'; export const RELAY_PAIRING_APP_AVAILABILITY_TEXT = - 'Pulse Mobile is in early access; install links for Relay and Pro plans are on'; + 'Pulse Mobile is in early access for iOS and Android; to get the install links, enter your activation key on'; export const RELAY_PAIRING_APP_DOWNLOAD_LINK_LABEL = 'your download page'; export const RELAY_PAIRING_APP_DOWNLOAD_LINK_CLASS = 'font-medium text-blue-600 underline-offset-2 hover:underline dark:text-blue-400';