mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 14:00:29 +00:00
feat(relay): make Relay discoverable where users configure alerts and say what it does
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
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -66,8 +66,8 @@ export const RelayPairingSection: Component<RelayPairingSectionProps> = (props)
|
||||
</div>
|
||||
|
||||
<p class={formHelpText}>
|
||||
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}{' '}
|
||||
<a
|
||||
href={PULSE_PRO_DOWNLOAD_URL}
|
||||
target="_blank"
|
||||
|
||||
+2
-2
@@ -189,7 +189,7 @@ describe('RelaySettingsPanel runtime', () => {
|
||||
|
||||
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();
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
|
||||
+13
-17
@@ -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'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
+28
-22
@@ -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,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+24
-2
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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(() => (
|
||||
<Router>
|
||||
<Route path="/" component={ui} />
|
||||
</Router>
|
||||
));
|
||||
}
|
||||
|
||||
describe('AlertPushDestinationsSection', () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('points licensed installs at Remote Access settings', () => {
|
||||
renderWithRouter(() => (
|
||||
<AlertPushDestinationsSection
|
||||
relayLicensed={true}
|
||||
showUpgradePrompts={true}
|
||||
upgradeDestination={upgradeDestination}
|
||||
/>
|
||||
));
|
||||
|
||||
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(() => (
|
||||
<AlertPushDestinationsSection
|
||||
relayLicensed={false}
|
||||
showUpgradePrompts={true}
|
||||
upgradeDestination={upgradeDestination}
|
||||
/>
|
||||
));
|
||||
|
||||
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(() => (
|
||||
<AlertPushDestinationsSection
|
||||
relayLicensed={false}
|
||||
showUpgradePrompts={false}
|
||||
upgradeDestination={upgradeDestination}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(screen.getByText(ALERT_DESTINATIONS_PUSH_GATE_MESSAGE)).toBeInTheDocument();
|
||||
expect(screen.queryByRole('link')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<SettingsPanel
|
||||
title={ALERT_DESTINATIONS_PUSH_PANEL_TITLE}
|
||||
description={ALERT_DESTINATIONS_PUSH_PANEL_DESCRIPTION}
|
||||
class="min-w-0"
|
||||
bodyClass=""
|
||||
>
|
||||
<Show
|
||||
when={props.relayLicensed}
|
||||
fallback={
|
||||
<FeatureGateSection
|
||||
icon={<Smartphone size={20} strokeWidth={2} />}
|
||||
title={ALERT_DESTINATIONS_PUSH_GATE_TITLE}
|
||||
body={ALERT_DESTINATIONS_PUSH_GATE_MESSAGE}
|
||||
upgradeDestination={props.upgradeDestination}
|
||||
showUpgradePrompts={props.showUpgradePrompts}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="text-sm text-muted">{ALERT_DESTINATIONS_PUSH_READY_MESSAGE}</p>
|
||||
<a
|
||||
href="/settings/system-relay"
|
||||
class="text-sm font-medium text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
{ALERT_DESTINATIONS_PUSH_SETUP_LINK_LABEL} →
|
||||
</a>
|
||||
</div>
|
||||
</Show>
|
||||
</SettingsPanel>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div class="flex w-full max-w-full flex-col gap-6 md:gap-8">
|
||||
<Show
|
||||
when={!state.isLoading()}
|
||||
fallback={<AlertDestinationsLoadingState />}
|
||||
>
|
||||
<Show when={!state.isLoading()} fallback={<AlertDestinationsLoadingState />}>
|
||||
<Show when={state.hasLoadError()}>
|
||||
<AlertDestinationsLoadErrorCard
|
||||
error={props.configLoadError() || state.webhookLoadError() || ''}
|
||||
@@ -53,6 +57,12 @@ export function DestinationsTab(props: DestinationsTabProps) {
|
||||
testWebhook={state.testWebhook}
|
||||
testingWebhook={state.testingWebhook()}
|
||||
/>
|
||||
|
||||
<AlertPushDestinationsSection
|
||||
relayLicensed={hasFeature('relay')}
|
||||
showUpgradePrompts={!presentationPolicyHidesUpgradePrompts()}
|
||||
upgradeDestination={getUpgradeActionDestination('relay')}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user