diff --git a/frontend-modern/src/App.tsx b/frontend-modern/src/App.tsx index 965b18226..632251e64 100644 --- a/frontend-modern/src/App.tsx +++ b/frontend-modern/src/App.tsx @@ -35,6 +35,7 @@ import { updateStore } from './stores/updates'; import { UpdateBanner } from './components/UpdateBanner'; import { DemoBanner } from './components/DemoBanner'; import { GitHubStarBanner } from './components/GitHubStarBanner'; +import { ReleaseAnnouncementBanner } from './components/ReleaseAnnouncementBanner'; import { createTooltipSystem } from './components/shared/Tooltip'; import type { State, Alert } from '@/types/api'; import { ProxmoxIcon } from '@/components/icons/ProxmoxIcon'; @@ -922,6 +923,10 @@ function App() { + diff --git a/frontend-modern/src/components/ReleaseAnnouncementBanner.tsx b/frontend-modern/src/components/ReleaseAnnouncementBanner.tsx new file mode 100644 index 000000000..2202a7690 --- /dev/null +++ b/frontend-modern/src/components/ReleaseAnnouncementBanner.tsx @@ -0,0 +1,115 @@ +import type { Accessor } from 'solid-js'; +import { createMemo, Show } from 'solid-js'; +import { useLocation } from '@solidjs/router'; +import type { VersionInfo } from '@/api/updates'; +import type { SecurityStatus } from '@/types/config'; +import { + V6_RC_ANNOUNCEMENT, + shouldShowV6RcAnnouncement, +} from '@/constants/releaseAnnouncements'; +import { createLocalStorageStringSignal, STORAGE_KEYS } from '@/utils/localStorage'; +import FlaskConicalIcon from 'lucide-solid/icons/flask-conical'; +import ExternalLinkIcon from 'lucide-solid/icons/external-link'; +import XIcon from 'lucide-solid/icons/x'; + +interface ReleaseAnnouncementBannerProps { + versionInfo: Accessor; + securityStatus: Accessor; +} + +export function ReleaseAnnouncementBanner(props: ReleaseAnnouncementBannerProps) { + const location = useLocation(); + const [dismissedAnnouncementId, setDismissedAnnouncementId] = + createLocalStorageStringSignal(STORAGE_KEYS.RELEASE_ANNOUNCEMENT_DISMISSED, ''); + + const isDismissed = createMemo( + () => dismissedAnnouncementId() === V6_RC_ANNOUNCEMENT.id, + ); + + const shouldShow = createMemo(() => { + if (isDismissed()) { + return false; + } + + return shouldShowV6RcAnnouncement({ + version: props.versionInfo()?.version, + pathname: location.pathname, + securityStatus: props.securityStatus(), + }); + }); + + const dismiss = () => { + setDismissedAnnouncementId(V6_RC_ANNOUNCEMENT.id); + }; + + return ( + +
+
+
+
+
+ +
+
+
+ Pulse v6 RC testing + + {V6_RC_ANNOUNCEMENT.tag} + +
+

+ + 5.1.x + {' '} + remains the current stable line. Pulse v6 changes the runtime, + upgrade path, navigation, and product model substantially. If you rely on + Pulse today, test v6 in a staging or non-production environment and report + any issues before the stable cut. +

+ +
+
+ +
+
+
+
+ ); +} diff --git a/frontend-modern/src/components/Settings/UpdatesSettingsPanel.tsx b/frontend-modern/src/components/Settings/UpdatesSettingsPanel.tsx index 8c54afc4d..31ff0e78a 100644 --- a/frontend-modern/src/components/Settings/UpdatesSettingsPanel.tsx +++ b/frontend-modern/src/components/Settings/UpdatesSettingsPanel.tsx @@ -7,6 +7,10 @@ import ArrowRight from 'lucide-solid/icons/arrow-right'; import Package from 'lucide-solid/icons/package'; import Download from 'lucide-solid/icons/download'; import type { UpdateInfo, VersionInfo, UpdatePlan } from '@/api/updates'; +import { + V6_RC_ANNOUNCEMENT, + isV5ReleaseLine, +} from '@/constants/releaseAnnouncements'; interface UpdatesSettingsPanelProps { versionInfo: Accessor; @@ -39,6 +43,60 @@ export const UpdatesSettingsPanel: Component = (props >
+ +
+
+
+
+ + Pulse v6 RC testing + + + {V6_RC_ANNOUNCEMENT.tag} + +
+

+ + 5.1.x + {' '} + remains the stable line. If you can, test v6 in a staging or + non-production environment and use the changelog before upgrading so the + move from v5 is deliberate rather than guesswork. +

+
+ +
+
+
+ {/* Version Status Section */}
{/* Version Grid */} diff --git a/frontend-modern/src/constants/__tests__/releaseAnnouncements.test.ts b/frontend-modern/src/constants/__tests__/releaseAnnouncements.test.ts new file mode 100644 index 000000000..f41e8bc7c --- /dev/null +++ b/frontend-modern/src/constants/__tests__/releaseAnnouncements.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; +import type { SecurityStatus } from '@/types/config'; +import { + canSeeAdminReleaseAnnouncement, + isV5ReleaseLine, + shouldShowV6RcAnnouncement, +} from '@/constants/releaseAnnouncements'; + +describe('releaseAnnouncements', () => { + it('detects the v5 release line from semver strings', () => { + expect(isV5ReleaseLine('v5.1.27')).toBe(true); + expect(isV5ReleaseLine('5.1.28')).toBe(true); + expect(isV5ReleaseLine('v6.0.0-rc.1')).toBe(false); + expect(isV5ReleaseLine('')).toBe(false); + }); + + it('allows the announcement for non-proxy-auth sessions', () => { + const status = { + hasProxyAuth: false, + proxyAuthIsAdmin: false, + } as SecurityStatus; + + expect(canSeeAdminReleaseAnnouncement(status)).toBe(true); + }); + + it('blocks the announcement for non-admin proxy-auth sessions', () => { + const status = { + hasProxyAuth: true, + proxyAuthIsAdmin: false, + } as SecurityStatus; + + expect(canSeeAdminReleaseAnnouncement(status)).toBe(false); + }); + + it('shows the announcement only on the supported v5 surfaces', () => { + expect( + shouldShowV6RcAnnouncement({ + version: 'v5.1.27', + pathname: '/proxmox/overview', + }), + ).toBe(true); + + expect( + shouldShowV6RcAnnouncement({ + version: 'v5.1.27', + pathname: '/settings/updates', + }), + ).toBe(true); + + expect( + shouldShowV6RcAnnouncement({ + version: 'v5.1.27', + pathname: '/alerts/overview', + }), + ).toBe(false); + + expect( + shouldShowV6RcAnnouncement({ + version: 'v6.0.0-rc.1', + pathname: '/settings/updates', + }), + ).toBe(false); + }); +}); diff --git a/frontend-modern/src/constants/releaseAnnouncements.ts b/frontend-modern/src/constants/releaseAnnouncements.ts new file mode 100644 index 000000000..1e0fe3d6f --- /dev/null +++ b/frontend-modern/src/constants/releaseAnnouncements.ts @@ -0,0 +1,60 @@ +import type { SecurityStatus } from '@/types/config'; + +export const V6_RC_ANNOUNCEMENT = { + id: 'v6-rc-testing-v6.0.0-rc.1', + tag: 'v6.0.0-rc.1', + releaseUrl: 'https://github.com/rcourtman/Pulse/releases/tag/v6.0.0-rc.1', + changelogUrl: 'https://github.com/rcourtman/Pulse/blob/pulse/v6-release/docs/releases/V6_CHANGELOG.md', + demoUrl: 'https://v6-demo.pulserelay.pro', +} as const; + +function parseMajorVersion(version?: string | null): number | null { + const match = String(version || '').trim().match(/^v?(\d+)/i); + if (!match) { + return null; + } + + const major = Number(match[1]); + return Number.isFinite(major) ? major : null; +} + +export function isV5ReleaseLine(version?: string | null): boolean { + return parseMajorVersion(version) === 5; +} + +export function canSeeAdminReleaseAnnouncement( + securityStatus?: SecurityStatus | null, +): boolean { + if (!securityStatus) { + return true; + } + + if (!securityStatus.hasProxyAuth) { + return true; + } + + return securityStatus.proxyAuthIsAdmin === true; +} + +export function shouldShowV6RcAnnouncement(opts: { + version?: string | null; + pathname?: string | null; + securityStatus?: SecurityStatus | null; +}): boolean { + if (!isV5ReleaseLine(opts.version)) { + return false; + } + + if (!canSeeAdminReleaseAnnouncement(opts.securityStatus)) { + return false; + } + + const path = opts.pathname || ''; + return ( + path === '/' || + path === '/proxmox' || + path === '/proxmox/overview' || + path === '/settings' || + path.startsWith('/settings/') + ); +} diff --git a/frontend-modern/src/utils/localStorage.ts b/frontend-modern/src/utils/localStorage.ts index a0c13e0a0..3ee237c57 100644 --- a/frontend-modern/src/utils/localStorage.ts +++ b/frontend-modern/src/utils/localStorage.ts @@ -164,6 +164,7 @@ export const STORAGE_KEYS = { // Feature discovery DISMISSED_FEATURE_TIPS: 'pulse-dismissed-feature-tips', + RELEASE_ANNOUNCEMENT_DISMISSED: 'pulse-release-announcement-dismissed', // GitHub star prompt GITHUB_STAR_DISMISSED: 'pulse-github-star-dismissed',