diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index e0a3bd443..98e010e3c 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -1307,14 +1307,16 @@ pushing listener lifecycle or trigger policy back into the shared shell. The shared what's-new modal now follows that same owner split. `frontend-modern/src/components/shared/WhatsNewModal.tsx` stays the render shell, `frontend-modern/src/components/shared/useWhatsNewModalState.ts` owns -local-storage dismissal, session dismissal, and close behavior, and +local-storage dismissal, session dismissal, step progression, spotlight target +resolution, and overlay placement/runtime behavior, and `frontend-modern/src/components/shared/whatsNewModalModel.ts` owns the feature -card catalog, telemetry copy, labels, and canonical docs/privacy links. Future +tour catalog, telemetry copy, labels, and canonical docs/privacy links. Future what's-new work should extend those owners instead of pushing dismissal state, -product copy, or external links back into the shared shell. Internal product -navigation from that shell should still route through canonical shared helpers -such as `frontend-modern/src/routing/resourceLinks.ts` rather than freezing raw -`/recovery?...` route strings into the modal itself. +spotlight runtime, product copy, or external links back into the shared shell. +The v6 welcome surface is one guided spotlight tour, not a modal plus a second +dashboard-only migration hint: it must dim the live app, glow the real +primary-navigation target being described, and keep route-orientation copy on +the existing welcome flow instead of layering a duplicate in-product banner. That state owner now also owns public-demo suppression: the modal must stay closed until `sessionPresentationPolicyResolved()` is true and must fail closed when `presentationPolicyIsDemoMode()` resolves true, so the public demo does @@ -1419,14 +1421,6 @@ owns the dashboard-specific action, KPI, problem-resource, trend, and customization surfaces. Lane-owned widgets like recent alerts, storage, and recovery must continue to route through their own subsystem owners instead of drifting back into a page-local dashboard panel cluster. -That same dashboard route shell also owns the v5-to-v6 navigation continuity -notice. When the populated dashboard needs to orient operators who still -expect the old Proxmox, Docker, or Hosts tabs, `frontend-modern/src/pages/Dashboard.tsx` -must render the shared `MigrationNoticeBanner` with a dismissible local-storage -backing key, route the learn-more affordance to the shipped -`/docs/MIGRATION_UNIFIED_NAV.md` guide, and keep the mapping explicit that -Infrastructure now holds platform roots while Workloads now holds VMs, -containers, pods, and container update status. The recovery feature shell now also depends on the shared `frontend-modern/src/components/shared/Subtabs.tsx` primitive for its primary protected-items versus recovery-events workspace switch. The recovery lane may diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index cb1cbed3a..d9eca073f 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -244,12 +244,6 @@ regression protection. timing steady-state reads, so one-time retention or auto-vacuum cleanup does not masquerade as summary-route or chart-batch regression latency. 31. Keep the dashboard overview hot path compact and route-owned. `frontend-modern/src/pages/Dashboard.tsx`, `frontend-modern/src/api/resources.ts`, and `frontend-modern/src/hooks/useDashboardOverview.ts` must hydrate KPI cards, problem-resource rows, and top-infrastructure identities through the compact dashboard-summary API contract owned by the adjacent `api-contracts` and `unified-resources` surfaces, rather than booting the full unfiltered paginated unified-resource list just to derive summary cards. -32. Keep dashboard migration continuity on the same hot path passive. When - `frontend-modern/src/pages/Dashboard.tsx` renders the dismissible v5-to-v6 - navigation notice, that surface must read and persist only a local - dismissal flag and shipped-doc link state; it must not introduce another - dashboard fetch, route-blocking readiness check, or legacy topology scan - just to decide whether the notice appears. Any commercial or relay-owned dashboard affordances composed into that route must remain additive shells on top of the same compact summary payload instead of reintroducing route-local `all-resources` fetches, @@ -364,12 +358,6 @@ page-local “selected row” overlays on top of already downsampled summary history. Hovering a sparkline or density map for one entity must promote that entity into the shared active series so sibling cards highlight the same object at once rather than maintaining chart-local hover state, and the synchronized -The dashboard route shell may now also carry a dismissible migration notice for -operators coming from v5 navigation, but that continuity affordance stays on -the same protected hot path. The notice must remain a static route-shell -projection layered above the existing action/KPI widgets, with dismissal backed -by local storage only; it must not fork a second dashboard readiness state, -delay KPI hydration, or displace the governed summary-before-detail scan order. hover timestamp must remain visible across those sibling cards even when the active entity has no samples for one metric in the current range. Those sibling cards should expose the synchronized value through one compact diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 8cc200113..9615cd180 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -1292,14 +1292,6 @@ problem-resources table, the route must keep the operator snapshot above the detail table so the page reads as action queue, summary, then problem detail rather than dropping operators straight into a long exception list before the dashboard baseline is visible. -That same dashboard route shell may also surface a dismissible migration -continuity notice for operators coming from v5 Proxmox, Docker, and Hosts -tabs, but the notice must preserve the same scan order: continuity notice -first when present, action-required summary next, KPI strip after that, and -problem detail below the snapshot layer. The notice may explain that platform -roots live under Infrastructure and workloads plus container update status live -under Workloads, but it must stay read-only and must not push storage/recovery -widgets or the KPI summary below the problem-resources table again. The shared recovery type contract must be pinned the same way: `frontend-modern/src/types/recovery.ts` must stay on the explicit `recovery-product-surface` proof path instead of riding indirectly on route or diff --git a/frontend-modern/public/docs/MIGRATION_UNIFIED_NAV.md b/frontend-modern/public/docs/MIGRATION_UNIFIED_NAV.md new file mode 100644 index 000000000..fcb781d40 --- /dev/null +++ b/frontend-modern/public/docs/MIGRATION_UNIFIED_NAV.md @@ -0,0 +1,63 @@ +# Migration Guide: Unified Navigation + +This guide explains what changed in unified navigation and where legacy pages moved in v6. + +## What Changed +- Navigation is now organized by **task** (Infrastructure, Workloads, Storage, Recovery) instead of by platform. +- Legacy pages (Proxmox Overview, Hosts, Docker, Services, Kubernetes) were replaced by unified views. +- Global search and keyboard shortcuts make navigation faster across all resources. +- Kubernetes is now split by intent: + - **Infrastructure** shows Kubernetes clusters and nodes. + - **Workloads** shows Kubernetes pods with the same filters/grouping as VMs and containers. + +## Why This Change +- A unified resource model enables one inventory and one search across platforms. +- Filters, drawers, and workflows stay consistent, instead of being re-implemented per platform page. +- New integrations can be added without expanding the top-level navigation indefinitely. + +## Legacy Aliases and Redirects +- Legacy aliases have been fully removed; update bookmarks and runbooks to canonical routes. +- Optional migration aid: enable the **Classic shortcuts** bar in the main navigation (Settings → System → General). +- Plan automation/bookmarks to use canonical routes now: + - `/infrastructure?source=pmg` + - `/workloads?type=k8s` + +## Where Old Pages Moved + +| Legacy Page | New Location | +|------------|--------------| +| Proxmox Overview | `/infrastructure` | +| Hosts | `/infrastructure` | +| Docker | `/workloads` (containers) + `/infrastructure` (hosts) | +| Proxmox Storage | `/storage` | +| Proxmox Backups | `/recovery` | +| Proxmox Replication | `/recovery?view=events&mode=remote` | +| Proxmox Ceph | `/ceph` (summary also visible in Storage) | +| Proxmox Mail Gateway | `/infrastructure?source=pmg` | +| Services | `/infrastructure?source=pmg` | +| Kubernetes | `/workloads?type=k8s` | + +## New Features to Know + +### Global Search +- Press `/` to focus search. +- Search by name, node, type, tags, or status. +- Results navigate directly to the relevant view. + - Use `Cmd/Ctrl+K` for the command palette. + +### Keyboard Shortcuts +- `g i` → Infrastructure +- `g w` → Workloads +- `g s` → Storage +- `g b` → Recovery +- `g a` → Alerts +- `g t` → Settings +- `?` → Shortcut help + +### Debug Drawer (Optional) +- Enable with localStorage key `pulse_debug_mode` for raw JSON in resource drawer. + +## Tips +- If you used Docker and Hosts pages before, start with **Infrastructure** (hosts) and **Workloads** (containers). +- If you used the Kubernetes page before, use **Infrastructure** for cluster/node health and **Workloads** for pod-level operations. +- The new pages support unified filters, tags, and search across all sources. diff --git a/frontend-modern/scripts/sync-public-docs.mjs b/frontend-modern/scripts/sync-public-docs.mjs index 45371fd53..361dac3f5 100644 --- a/frontend-modern/scripts/sync-public-docs.mjs +++ b/frontend-modern/scripts/sync-public-docs.mjs @@ -10,6 +10,7 @@ const targetDocsDir = path.join(frontendRoot, 'public', 'docs'); const shippedDocs = [ { source: path.join(repoRoot, 'docs', 'README.md'), target: 'README.md' }, + { source: path.join(repoRoot, 'docs', 'MIGRATION_UNIFIED_NAV.md'), target: 'MIGRATION_UNIFIED_NAV.md' }, { source: path.join(repoRoot, 'docs', 'PRIVACY.md'), target: 'PRIVACY.md' }, { source: path.join(repoRoot, 'docs', 'CONFIGURATION.md'), target: 'CONFIGURATION.md' }, { source: path.join(repoRoot, 'docs', 'PROXY_AUTH.md'), target: 'PROXY_AUTH.md' }, diff --git a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts index 9442086d1..14c06d746 100644 --- a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts +++ b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts @@ -1120,11 +1120,13 @@ describe('shared primitive guardrails', () => { it('keeps whats new modal on shell, runtime, and model owners', () => { expect(whatsNewModalSource).toContain('useWhatsNewModalState'); + expect(whatsNewModalSource).toContain('useDialogState'); expect(whatsNewModalSource).toContain('WHATS_NEW_FEATURE_CARDS'); + expect(whatsNewModalSource).toContain('Portal'); expect(whatsNewModalSource).not.toContain('createLocalStorageBooleanSignal'); expect(whatsNewModalSource).not.toContain('createSignal'); expect(whatsNewModalSource).not.toContain('WHATS_NEW_NAV_V2_SHOWN'); - expect(whatsNewModalSource).not.toContain('Documentation'); + expect(whatsNewModalSource).not.toContain('Migration guide'); expect(whatsNewModalSource).not.toContain( 'https://github.com/rcourtman/Pulse/blob/main/docs/PRIVACY.md', ); @@ -1132,17 +1134,21 @@ describe('shared primitive guardrails', () => { expect(whatsNewModalStateSource).toContain('export function useWhatsNewModalState'); expect(whatsNewModalStateSource).toContain('createLocalStorageBooleanSignal'); expect(whatsNewModalStateSource).toContain('createSignal'); + expect(whatsNewModalStateSource).toContain('createMemo'); expect(whatsNewModalStateSource).toContain('STORAGE_KEYS.WHATS_NEW_NAV_V2_SHOWN'); expect(whatsNewModalStateSource).toContain('sessionPresentationPolicyResolved'); expect(whatsNewModalStateSource).toContain('presentationPolicyIsDemoMode'); expect(whatsNewModalStateSource).toContain('handleClose'); + expect(whatsNewModalStateSource).toContain('handleNext'); + expect(whatsNewModalStateSource).toContain('spotlightStyle'); expect(whatsNewModalModelSource).toContain('WHATS_NEW_FEATURE_CARDS'); expect(whatsNewModalModelSource).toContain('WHATS_NEW_TELEMETRY_TITLE'); expect(whatsNewModalModelSource).toContain('WHATS_NEW_DOCS_URL'); expect(whatsNewModalModelSource).toContain('WHATS_NEW_PRIVACY_URL'); expect(whatsNewModalModelSource).toContain('WHATS_NEW_DOCS_LABEL'); - expect(whatsNewModalModelSource).toContain("title: 'Infrastructure'"); + expect(whatsNewModalModelSource).toContain('MIGRATION_GUIDE_DOC_URL'); + expect(whatsNewModalModelSource).toContain("title: 'Dashboard'"); }); it('keeps dialog stack visibility in the shared dialog runtime', () => { diff --git a/frontend-modern/src/components/shared/WhatsNewModal.tsx b/frontend-modern/src/components/shared/WhatsNewModal.tsx index 5c5183d1f..17baa270d 100644 --- a/frontend-modern/src/components/shared/WhatsNewModal.tsx +++ b/frontend-modern/src/components/shared/WhatsNewModal.tsx @@ -1,5 +1,6 @@ -import { For } from 'solid-js'; -import { Dialog } from '@/components/shared/Dialog'; +import { For, Show } from 'solid-js'; +import { Portal } from 'solid-js/web'; +import LayoutDashboardIcon from 'lucide-solid/icons/layout-dashboard'; import ServerIcon from 'lucide-solid/icons/server'; import BoxesIcon from 'lucide-solid/icons/boxes'; import HardDriveIcon from 'lucide-solid/icons/hard-drive'; @@ -7,16 +8,17 @@ import ShieldCheckIcon from 'lucide-solid/icons/shield-check'; import ChartBarIcon from 'lucide-solid/icons/chart-bar'; import ExternalLinkIcon from 'lucide-solid/icons/external-link'; import XIcon from 'lucide-solid/icons/x'; -import { buildRecoveryPath } from '@/routing/resourceLinks'; import { + WHATS_NEW_BACK_LABEL, WHATS_NEW_CLOSE_LABEL, WHATS_NEW_DOCS_LABEL, WHATS_NEW_DOCS_URL, WHATS_NEW_DO_NOT_SHOW_LABEL, WHATS_NEW_FEATURE_CARDS, + WHATS_NEW_NEXT_LABEL, WHATS_NEW_PRIMARY_ACTION_LABEL, WHATS_NEW_PRIVACY_URL, - WHATS_NEW_RECOVERY_LINK_LABEL, + WHATS_NEW_SKIP_LABEL, WHATS_NEW_SUBTITLE, WHATS_NEW_TELEMETRY_COPY, WHATS_NEW_TELEMETRY_ENV_VAR, @@ -26,10 +28,13 @@ import { WHATS_NEW_TITLE, type WhatsNewFeatureCard, } from './whatsNewModalModel'; +import { useDialogState } from './useDialogState'; import { useWhatsNewModalState } from './useWhatsNewModalState'; function WhatsNewFeatureIcon(props: { card: WhatsNewFeatureCard }) { switch (props.card.icon) { + case 'dashboard': + return ; case 'infrastructure': return ; case 'workloads': @@ -43,112 +48,172 @@ function WhatsNewFeatureIcon(props: { card: WhatsNewFeatureCard }) { export function WhatsNewModal() { const state = useWhatsNewModalState(); - const recoveryEventsHref = buildRecoveryPath({ view: 'events', mode: 'remote' }); + const dialogState = useDialogState({ + get isOpen() { + return state.isOpen(); + }, + onClose: state.handleClose, + }); + const step = () => state.currentStep(); + const setPanelRef = (element: HTMLDivElement) => { + state.setPanelRef(element); + dialogState.setPanelRef(element); + }; return ( - -
-
-
-

- {WHATS_NEW_TITLE} -

-

{WHATS_NEW_SUBTITLE}

-
- -
- -
-
- - {(card) => ( -
-
- - {card.title} -
-

{card.description}

-
- )} -
-
- -
-
- - {WHATS_NEW_TELEMETRY_TITLE} -
-

{WHATS_NEW_TELEMETRY_COPY[0]}

-

- {WHATS_NEW_TELEMETRY_COPY[1]} You can disable it any time in{' '} - {WHATS_NEW_TELEMETRY_SETTINGS_PATH} or by setting{' '} - - {WHATS_NEW_TELEMETRY_ENV_VAR} - - .{' '} - - {WHATS_NEW_TELEMETRY_PRIVACY_LABEL} - -

-
- -
-
+ + ); } diff --git a/frontend-modern/src/components/shared/__tests__/WhatsNewModal.test.tsx b/frontend-modern/src/components/shared/__tests__/WhatsNewModal.test.tsx index 6482ff215..100f91f53 100644 --- a/frontend-modern/src/components/shared/__tests__/WhatsNewModal.test.tsx +++ b/frontend-modern/src/components/shared/__tests__/WhatsNewModal.test.tsx @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library'; +import { cleanup, fireEvent, render, screen, waitFor, within } from '@solidjs/testing-library'; import { WhatsNewModal } from '@/components/shared/WhatsNewModal'; import whatsNewModalSource from '@/components/shared/WhatsNewModal.tsx?raw'; import whatsNewModalModelSource from '@/components/shared/whatsNewModalModel.ts?raw'; @@ -27,19 +27,22 @@ describe('WhatsNewModal', () => { it('keeps whats new modal on shell, runtime, and model owners', () => { expect(whatsNewModalSource).toContain('useWhatsNewModalState'); + expect(whatsNewModalSource).toContain('useDialogState'); expect(whatsNewModalSource).toContain('WHATS_NEW_FEATURE_CARDS'); + expect(whatsNewModalSource).toContain('Portal'); expect(whatsNewModalSource).not.toContain('createLocalStorageBooleanSignal'); expect(whatsNewModalSource).not.toContain('createSignal'); expect(whatsNewModalSource).not.toContain('WHATS_NEW_NAV_V2_SHOWN'); - expect(whatsNewModalSource).not.toContain('Infrastructure'); - expect(whatsNewModalSource).not.toContain('Documentation'); - expect(whatsNewModalSource).toContain('buildRecoveryPath'); - expect(whatsNewModalSource).not.toContain('href="/recovery?view=events&mode=remote"'); + expect(whatsNewModalSource).not.toContain('Migration guide'); expect(whatsNewModalSource).not.toContain('https://github.com/rcourtman/Pulse/blob/main/docs/PRIVACY.md'); expect(whatsNewModalStateSource).toContain('export function useWhatsNewModalState'); expect(whatsNewModalStateSource).toContain('createLocalStorageBooleanSignal'); expect(whatsNewModalStateSource).toContain('createSignal'); + expect(whatsNewModalStateSource).toContain('createMemo'); + expect(whatsNewModalStateSource).toContain('handleNext'); + expect(whatsNewModalStateSource).toContain('handlePrevious'); + expect(whatsNewModalStateSource).toContain('spotlightStyle'); expect(whatsNewModalStateSource).toContain('STORAGE_KEYS.WHATS_NEW_NAV_V2_SHOWN'); expect(whatsNewModalStateSource).toContain('sessionPresentationPolicyResolved'); expect(whatsNewModalStateSource).toContain('presentationPolicyIsDemoMode'); @@ -49,7 +52,7 @@ describe('WhatsNewModal', () => { expect(whatsNewModalModelSource).toContain('WHATS_NEW_TELEMETRY_TITLE'); expect(whatsNewModalModelSource).toContain('WHATS_NEW_DOCS_URL'); expect(whatsNewModalModelSource).toContain('WHATS_NEW_PRIVACY_URL'); - expect(whatsNewModalModelSource).toContain('README_DOC_URL'); + expect(whatsNewModalModelSource).toContain('MIGRATION_GUIDE_DOC_URL'); expect(whatsNewModalModelSource).toContain('PRIVACY_DOC_URL'); expect(whatsNewModalModelSource).toContain('rotating install ID'); expect(whatsNewModalModelSource).toContain('normalized release identity'); @@ -57,16 +60,17 @@ describe('WhatsNewModal', () => { expect(whatsNewModalModelSource).not.toContain('https://github.com/rcourtman/Pulse/blob/main/docs/README.md'); expect(whatsNewModalModelSource).not.toContain('https://github.com/rcourtman/Pulse/blob/main/docs/PRIVACY.md'); expect(whatsNewModalModelSource).toContain('WHATS_NEW_DOCS_LABEL'); - expect(whatsNewModalModelSource).toContain("title: 'Infrastructure'"); + expect(whatsNewModalModelSource).toContain("title: 'Dashboard'"); }); it('renders when the navigation modal has not been seen yet', async () => { render(() => ); - await waitFor(() => { - expect(screen.getByRole('dialog')).toBeInTheDocument(); - }); - expect(screen.getByText('Welcome to the New Navigation!')).toBeInTheDocument(); + const dialog = await screen.findByRole('dialog'); + expect(dialog).toBeInTheDocument(); + expect(within(dialog).getByText('Welcome to Pulse v6')).toBeInTheDocument(); + expect(within(dialog).getByText('Step 1 of 5')).toBeInTheDocument(); + expect(within(dialog).getAllByText('Dashboard')).toHaveLength(2); }); it('stays hidden for public demo sessions', async () => { @@ -116,10 +120,25 @@ describe('WhatsNewModal', () => { }); }); - it('routes the recovery CTA through the canonical recovery link helper', async () => { + it('advances through the guided tour and finishes on the last step', async () => { render(() => ); - const recoveryLink = await screen.findByRole('link', { name: 'Recovery events' }); - expect(recoveryLink).toHaveAttribute('href', '/recovery?view=events&mode=remote'); + expect(await screen.findByText(/problem-focused summary/i)).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Next' })); + expect(await screen.findByText(/platform roots live here/i)).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Next' })); + expect(await screen.findByText(/docker update status now share/i)).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Next' })); + expect(await screen.findByText(/Storage is now a top-level destination/i)).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Next' })); + expect(await screen.findByText(/Recovery events \(backups, snapshots, and replication\)/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: "Let's go" })).toBeInTheDocument(); + }); + + it('routes the docs CTA through the migration guide', async () => { + render(() => ); + + const docsLink = await screen.findByRole('link', { name: 'Migration guide' }); + expect(docsLink).toHaveAttribute('href', '/docs/MIGRATION_UNIFIED_NAV.md'); }); }); diff --git a/frontend-modern/src/components/shared/useWhatsNewModalState.ts b/frontend-modern/src/components/shared/useWhatsNewModalState.ts index 3d438f0ad..fc9552b5e 100644 --- a/frontend-modern/src/components/shared/useWhatsNewModalState.ts +++ b/frontend-modern/src/components/shared/useWhatsNewModalState.ts @@ -1,9 +1,55 @@ -import { createSignal } from 'solid-js'; +import { createEffect, createMemo, createSignal, onCleanup } from 'solid-js'; import { createLocalStorageBooleanSignal, STORAGE_KEYS } from '@/utils/localStorage'; import { presentationPolicyIsDemoMode, sessionPresentationPolicyResolved, } from '@/stores/sessionPresentationPolicy'; +import { WHATS_NEW_FEATURE_CARDS } from './whatsNewModalModel'; + +type SpotlightRect = { + top: number; + left: number; + width: number; + height: number; +}; + +const DESKTOP_TAB_SELECTOR_BY_TARGET = { + dashboard: '[role="tab"][title="Environment overview and command center"]', + infrastructure: '[role="tab"][title="All agents and nodes across platforms"]', + workloads: '[role="tab"][title="VMs, containers, and Kubernetes workloads"]', + storage: '[role="tab"][title="Storage pools, disks, and datastores"]', + recovery: '[role="tab"][title="Backup, snapshot, and replication activity"]', +} as const; + +const MOBILE_TAB_SELECTOR_BY_TARGET = { + dashboard: 'button[data-tab-id="dashboard"]', + infrastructure: 'button[data-tab-id="infrastructure"]', + workloads: 'button[data-tab-id="workloads"]', + storage: 'button[data-tab-id="storage"]', + recovery: 'button[data-tab-id="recovery"]', +} as const; + +const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max); + +const isVisibleElement = (element: Element | null): element is HTMLElement => { + if (!(element instanceof HTMLElement)) return false; + const rect = element.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) return false; + const style = window.getComputedStyle(element); + return style.display !== 'none' && style.visibility !== 'hidden'; +}; + +const selectFirstVisible = (...selectors: string[]): HTMLElement | null => { + for (const selector of selectors) { + const visible = Array.from(document.querySelectorAll(selector)).find((candidate) => + isVisibleElement(candidate), + ); + if (visible && visible instanceof HTMLElement) { + return visible; + } + } + return null; +}; export function useWhatsNewModalState() { const [hasSeen, setHasSeen] = createLocalStorageBooleanSignal( @@ -12,6 +58,9 @@ export function useWhatsNewModalState() { ); const [dontShowAgain, setDontShowAgain] = createSignal(true); const [dismissedForSession, setDismissedForSession] = createSignal(false); + const [stepIndex, setStepIndex] = createSignal(0); + const [panelRef, setPanelRef] = createSignal(null); + const [spotlightRect, setSpotlightRect] = createSignal(null); const isOpen = () => sessionPresentationPolicyResolved() && @@ -19,7 +68,21 @@ export function useWhatsNewModalState() { !hasSeen() && !dismissedForSession(); - const handleClose = () => { + const currentStep = createMemo(() => { + const index = clamp(stepIndex(), 0, WHATS_NEW_FEATURE_CARDS.length - 1); + return WHATS_NEW_FEATURE_CARDS[index]; + }); + + const isFirstStep = createMemo(() => stepIndex() === 0); + const isLastStep = createMemo(() => stepIndex() >= WHATS_NEW_FEATURE_CARDS.length - 1); + + const resetTourState = () => { + setStepIndex(0); + setSpotlightRect(null); + }; + + const closeTour = () => { + resetTourState(); if (dontShowAgain()) { setHasSeen(true); return; @@ -28,10 +91,136 @@ export function useWhatsNewModalState() { setDismissedForSession(true); }; + const handleClose = () => { + closeTour(); + }; + + const handleNext = () => { + if (isLastStep()) { + closeTour(); + return; + } + setStepIndex((current) => clamp(current + 1, 0, WHATS_NEW_FEATURE_CARDS.length - 1)); + }; + + const handlePrevious = () => { + setStepIndex((current) => clamp(current - 1, 0, WHATS_NEW_FEATURE_CARDS.length - 1)); + }; + + createEffect(() => { + if (!isOpen()) return; + + const updateSpotlight = () => { + const step = currentStep(); + const target = selectFirstVisible( + DESKTOP_TAB_SELECTOR_BY_TARGET[step.target], + MOBILE_TAB_SELECTOR_BY_TARGET[step.target], + ); + if (!target) { + setSpotlightRect(null); + return; + } + + const rect = target.getBoundingClientRect(); + const padding = 10; + setSpotlightRect({ + top: Math.max(12, rect.top - padding), + left: Math.max(12, rect.left - padding), + width: rect.width + padding * 2, + height: rect.height + padding * 2, + }); + }; + + updateSpotlight(); + + const resizeObserver = + typeof ResizeObserver === 'undefined' + ? null + : new ResizeObserver(() => { + updateSpotlight(); + }); + const panel = panelRef(); + if (resizeObserver && panel) { + resizeObserver.observe(panel); + } + + window.addEventListener('resize', updateSpotlight); + window.addEventListener('scroll', updateSpotlight, true); + + onCleanup(() => { + resizeObserver?.disconnect(); + window.removeEventListener('resize', updateSpotlight); + window.removeEventListener('scroll', updateSpotlight, true); + }); + }); + + const panelStyle = createMemo(() => { + if (typeof window === 'undefined') { + return { + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + }; + } + + const rect = spotlightRect(); + const panel = panelRef(); + const panelWidth = Math.min(384, window.innerWidth - 32); + const panelHeight = panel?.offsetHeight ?? 340; + + if (!rect) { + return { + width: `${panelWidth}px`, + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + }; + } + + const spaceBelow = window.innerHeight - (rect.top + rect.height); + const prefersAbove = spaceBelow < panelHeight + 24 && rect.top > panelHeight + 24; + const unclampedTop = prefersAbove ? rect.top - panelHeight - 20 : rect.top + rect.height + 20; + const maxTop = Math.max(16, window.innerHeight - panelHeight - 16); + const top = clamp(unclampedTop, 16, maxTop); + const unclampedLeft = rect.left + rect.width / 2 - panelWidth / 2; + const maxLeft = Math.max(16, window.innerWidth - panelWidth - 16); + const left = clamp(unclampedLeft, 16, maxLeft); + + return { + width: `${panelWidth}px`, + top: `${top}px`, + left: `${left}px`, + }; + }); + + const spotlightStyle = createMemo(() => { + const rect = spotlightRect(); + if (!rect) return null; + + return { + top: `${rect.top}px`, + left: `${rect.left}px`, + width: `${rect.width}px`, + height: `${rect.height}px`, + 'box-shadow': + '0 0 0 9999px rgba(15, 23, 42, 0.68), 0 0 0 1px rgba(255, 255, 255, 0.18), 0 0 28px rgba(96, 165, 250, 0.55)', + }; + }); + return { + currentStep, dontShowAgain, handleClose, + handleNext, + handlePrevious, + isFirstStep, + isLastStep, isOpen, + panelStyle, setDontShowAgain, + setPanelRef, + spotlightStyle, + stepCount: () => WHATS_NEW_FEATURE_CARDS.length, + stepIndex, }; } diff --git a/frontend-modern/src/components/shared/whatsNewModalModel.ts b/frontend-modern/src/components/shared/whatsNewModalModel.ts index 1fa611a3c..4f681e643 100644 --- a/frontend-modern/src/components/shared/whatsNewModalModel.ts +++ b/frontend-modern/src/components/shared/whatsNewModalModel.ts @@ -1,32 +1,46 @@ -import { PRIVACY_DOC_URL, README_DOC_URL } from '@/utils/docsLinks'; +import { MIGRATION_GUIDE_DOC_URL, PRIVACY_DOC_URL } from '@/utils/docsLinks'; export interface WhatsNewFeatureCard { accent: string; description: string; - icon: 'infrastructure' | 'workloads' | 'storage' | 'recovery'; + icon: 'dashboard' | 'infrastructure' | 'workloads' | 'storage' | 'recovery'; + target: 'dashboard' | 'infrastructure' | 'workloads' | 'storage' | 'recovery'; title: string; } -export const WHATS_NEW_DOCS_URL = README_DOC_URL; +export const WHATS_NEW_DOCS_URL = MIGRATION_GUIDE_DOC_URL; export const WHATS_NEW_PRIVACY_URL = PRIVACY_DOC_URL; export const WHATS_NEW_FEATURE_CARDS: WhatsNewFeatureCard[] = [ + { + accent: 'border-indigo-200 bg-indigo-50 dark:border-indigo-800 dark:bg-indigo-900', + description: + 'Start here for a problem-focused summary. This is the landing page now, not the old Proxmox overview.', + icon: 'dashboard', + target: 'dashboard', + title: 'Dashboard', + }, { accent: 'border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-900', - description: 'Proxmox nodes, agents, and container runtimes live together in one unified view.', + description: + 'Platform roots live here: Proxmox nodes, Docker hosts, Kubernetes clusters, PBS, PMG, TrueNAS, and more.', icon: 'infrastructure', + target: 'infrastructure', title: 'Infrastructure', }, { accent: 'border-purple-200 bg-purple-50 dark:border-purple-800 dark:bg-purple-900', - description: 'All VMs, containers, and Kubernetes workloads now share a single list.', + description: + 'VMs, containers, pods, and Docker update status now share one unified workloads surface.', icon: 'workloads', + target: 'workloads', title: 'Workloads', }, { accent: 'border-emerald-200 bg-emerald-50 dark:border-emerald-800 dark:bg-emerald-900', description: 'Storage is now a top-level destination across all systems.', icon: 'storage', + target: 'storage', title: 'Storage', }, { @@ -34,11 +48,12 @@ export const WHATS_NEW_FEATURE_CARDS: WhatsNewFeatureCard[] = [ description: 'Recovery events (backups, snapshots, and replication) are now first-class pages.', icon: 'recovery', + target: 'recovery', title: 'Recovery', }, ]; -export const WHATS_NEW_TITLE = 'Welcome to the New Navigation!'; +export const WHATS_NEW_TITLE = 'Welcome to Pulse v6'; export const WHATS_NEW_SUBTITLE = 'Everything is now organized by what you want to do, not where the data comes from.'; export const WHATS_NEW_TELEMETRY_TITLE = 'Anonymous outbound telemetry'; @@ -49,8 +64,10 @@ export const WHATS_NEW_TELEMETRY_COPY = [ export const WHATS_NEW_TELEMETRY_SETTINGS_PATH = 'Settings → System → General'; export const WHATS_NEW_TELEMETRY_ENV_VAR = 'PULSE_TELEMETRY=false'; export const WHATS_NEW_TELEMETRY_PRIVACY_LABEL = 'Full details'; -export const WHATS_NEW_PRIMARY_ACTION_LABEL = "Let's go"; +export const WHATS_NEW_BACK_LABEL = 'Back'; export const WHATS_NEW_CLOSE_LABEL = 'Close'; -export const WHATS_NEW_DOCS_LABEL = 'Documentation'; -export const WHATS_NEW_RECOVERY_LINK_LABEL = 'Recovery events'; +export const WHATS_NEW_DOCS_LABEL = 'Migration guide'; export const WHATS_NEW_DO_NOT_SHOW_LABEL = "Don't show again"; +export const WHATS_NEW_NEXT_LABEL = 'Next'; +export const WHATS_NEW_PRIMARY_ACTION_LABEL = "Let's go"; +export const WHATS_NEW_SKIP_LABEL = 'Skip tour'; diff --git a/frontend-modern/src/pages/Dashboard.tsx b/frontend-modern/src/pages/Dashboard.tsx index 4fe1dcc21..1c87ae085 100644 --- a/frontend-modern/src/pages/Dashboard.tsx +++ b/frontend-modern/src/pages/Dashboard.tsx @@ -33,20 +33,11 @@ import { import { RecentAlertsPanel } from '@/components/Alerts/RecentAlertsPanel'; import { DashboardRecoveryStatusPanel } from '@/components/Recovery/DashboardRecoveryStatusPanel'; import { DashboardStoragePanel } from '@/components/Storage/DashboardStoragePanel'; -import { MigrationNoticeBanner } from '@/components/shared/MigrationNoticeBanner'; import type { DashboardWidgetDef, DashboardWidgetId } from '@/features/dashboardOverview/dashboardWidgets'; -import { MIGRATION_GUIDE_DOC_URL } from '@/utils/docsLinks'; -import { createLocalStorageBooleanSignal, STORAGE_KEYS } from '@/utils/localStorage'; - -const DASHBOARD_MIGRATION_NOTICE_TITLE = 'Looking for Proxmox, Docker, and Hosts?'; -const DASHBOARD_MIGRATION_NOTICE_MESSAGE = - 'Use Infrastructure for Proxmox nodes, Docker hosts, clusters, and other systems. Use Workloads for VMs, containers, pods, and Docker update status.'; export default function Dashboard() { const navigate = useNavigate(); const { connected, reconnecting, reconnect, activeAlerts } = useWebSocket(); - const [dashboardMigrationNoticeDismissed, setDashboardMigrationNoticeDismissed] = - createLocalStorageBooleanSignal(STORAGE_KEYS.DASHBOARD_MIGRATION_NOTICE_DISMISSED, false); const alertsList = createMemo(() => Object.values(activeAlerts as Record).filter( @@ -282,23 +273,13 @@ export default function Dashboard() {
{/* 1. Action Required Panel — only when actions exist */} - - setDashboardMigrationNoticeDismissed(true)} - /> - - - {/* 2. Action Required Panel — only when actions exist */} - {/* 3. KPI Strip — always visible */} + {/* 2. KPI Strip — always visible */} - {/* 4. Problem Resources Table — only when problems exist */} + {/* 3. Problem Resources Table — only when problems exist */} - {/* 5–6. Customizable widgets: Trend Charts, Recent Alerts */} + {/* 4–5. Customizable widgets: Trend Charts, Recent Alerts */} {(group) => group.type === 'full' ? ( diff --git a/frontend-modern/src/pages/__tests__/DashboardPage.test.tsx b/frontend-modern/src/pages/__tests__/DashboardPage.test.tsx index c663ee956..884ce4b86 100644 --- a/frontend-modern/src/pages/__tests__/DashboardPage.test.tsx +++ b/frontend-modern/src/pages/__tests__/DashboardPage.test.tsx @@ -4,7 +4,6 @@ import type { DashboardOverview } from '@/hooks/useDashboardOverview'; import type { DashboardRecoverySummary } from '@/hooks/useDashboardRecovery'; import DashboardPage from '@/pages/Dashboard'; import dashboardPageSource from '@/pages/Dashboard.tsx?raw'; -import { STORAGE_KEYS } from '@/utils/localStorage'; let overviewLoading = false; let overviewError: unknown = undefined; @@ -119,7 +118,6 @@ describe('Dashboard page module contract', () => { wsReconnecting = false; reconnectSpy.mockReset(); navigateSpy.mockReset(); - localStorage.clear(); overviewMock.health.totalResources = 0; overviewMock.infrastructure.total = 0; overviewMock.infrastructure.byStatus = {}; @@ -209,45 +207,6 @@ describe('Dashboard page module contract', () => { expect(screen.getAllByText(/1\.95 KB \/ 3\.91 KB/i)).toHaveLength(2); }); - it('shows a dismissible v5-to-v6 migration notice on the populated dashboard', async () => { - overviewMock.health.totalResources = 5; - overviewMock.infrastructure.total = 5; - overviewMock.infrastructure.byStatus = { online: 5 }; - overviewMock.workloads.total = 12; - overviewMock.workloads.running = 9; - - render(() => ); - - expect(screen.getByText('Looking for Proxmox, Docker, and Hosts?')).toBeInTheDocument(); - expect( - screen.getByText( - 'Use Infrastructure for Proxmox nodes, Docker hosts, clusters, and other systems. Use Workloads for VMs, containers, pods, and Docker update status.', - ), - ).toBeInTheDocument(); - expect(screen.getByRole('link', { name: 'See full migration guide' })).toHaveAttribute( - 'href', - '/docs/MIGRATION_UNIFIED_NAV.md', - ); - - await fireEvent.click(screen.getByRole('button', { name: 'Dismiss navigation notice' })); - - expect(localStorage.getItem(STORAGE_KEYS.DASHBOARD_MIGRATION_NOTICE_DISMISSED)).toBe('true'); - expect(screen.queryByText('Looking for Proxmox, Docker, and Hosts?')).not.toBeInTheDocument(); - }); - - it('keeps the dashboard migration notice hidden once dismissed', () => { - localStorage.setItem(STORAGE_KEYS.DASHBOARD_MIGRATION_NOTICE_DISMISSED, 'true'); - overviewMock.health.totalResources = 5; - overviewMock.infrastructure.total = 5; - overviewMock.infrastructure.byStatus = { online: 5 }; - overviewMock.workloads.total = 12; - overviewMock.workloads.running = 9; - - render(() => ); - - expect(screen.queryByText('Looking for Proxmox, Docker, and Hosts?')).not.toBeInTheDocument(); - }); - it('keeps the KPI strip above problem resources so the dashboard snapshot reads before detail', () => { overviewMock.health.totalResources = 5; overviewMock.infrastructure.total = 5; diff --git a/frontend-modern/src/utils/__tests__/docsLinks.test.ts b/frontend-modern/src/utils/__tests__/docsLinks.test.ts index 0f7f2b438..e3c849fc7 100644 --- a/frontend-modern/src/utils/__tests__/docsLinks.test.ts +++ b/frontend-modern/src/utils/__tests__/docsLinks.test.ts @@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url'; import { API_TOKEN_SCOPES_DOC_URL, CONFIGURATION_DOC_URL, + MIGRATION_GUIDE_DOC_URL, PRIVACY_DOC_URL, PROXY_AUTH_DOC_URL, README_DOC_URL, @@ -63,6 +64,7 @@ describe('docsLinks', () => { expect(getShippedDocUrl('PRIVACY.md')).toBe('/docs/PRIVACY.md'); expect(PRIVACY_DOC_URL).toBe('/docs/PRIVACY.md'); expect(README_DOC_URL).toBe('/docs/README.md'); + expect(MIGRATION_GUIDE_DOC_URL).toBe('/docs/MIGRATION_UNIFIED_NAV.md'); expect(CONFIGURATION_DOC_URL).toBe('/docs/CONFIGURATION.md'); expect(PROXY_AUTH_DOC_URL).toBe('/docs/PROXY_AUTH.md'); expect(SECURITY_DOC_URL).toBe('/docs/SECURITY.md'); @@ -73,6 +75,7 @@ describe('docsLinks', () => { it('keeps shipped docs content synced with repo docs', () => { const docPairs = [ { source: path.join(repoRoot, 'docs', 'README.md'), target: 'README.md' }, + { source: path.join(repoRoot, 'docs', 'MIGRATION_UNIFIED_NAV.md'), target: 'MIGRATION_UNIFIED_NAV.md' }, { source: path.join(repoRoot, 'docs', 'PRIVACY.md'), target: 'PRIVACY.md' }, { source: path.join(repoRoot, 'docs', 'CONFIGURATION.md'), target: 'CONFIGURATION.md' }, { source: path.join(repoRoot, 'docs', 'PROXY_AUTH.md'), target: 'PROXY_AUTH.md' }, diff --git a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts index 34b4b8e8c..1eb523387 100644 --- a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts +++ b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts @@ -3003,23 +3003,29 @@ describe('frontend resource type boundaries', () => { 'export function getMonitoredSystemDisclosureToggleLabel', ); expect(whatsNewModalSource).toContain('useWhatsNewModalState'); + expect(whatsNewModalSource).toContain('useDialogState'); expect(whatsNewModalSource).toContain('WHATS_NEW_FEATURE_CARDS'); + expect(whatsNewModalSource).toContain('Portal'); expect(whatsNewModalSource).not.toContain('createLocalStorageBooleanSignal'); expect(whatsNewModalSource).not.toContain('createSignal'); expect(whatsNewModalSource).not.toContain('WHATS_NEW_NAV_V2_SHOWN'); - expect(whatsNewModalSource).not.toContain('Documentation'); + expect(whatsNewModalSource).not.toContain('Migration guide'); expect(whatsNewModalSource).not.toContain( 'https://github.com/rcourtman/Pulse/blob/main/docs/PRIVACY.md', ); expect(whatsNewModalStateSource).toContain('createLocalStorageBooleanSignal'); expect(whatsNewModalStateSource).toContain('createSignal'); + expect(whatsNewModalStateSource).toContain('createMemo'); expect(whatsNewModalStateSource).toContain('STORAGE_KEYS.WHATS_NEW_NAV_V2_SHOWN'); expect(whatsNewModalStateSource).toContain('handleClose'); + expect(whatsNewModalStateSource).toContain('handleNext'); + expect(whatsNewModalStateSource).toContain('spotlightStyle'); expect(whatsNewModalModelSource).toContain('WHATS_NEW_FEATURE_CARDS'); expect(whatsNewModalModelSource).toContain('WHATS_NEW_TELEMETRY_TITLE'); expect(whatsNewModalModelSource).toContain('WHATS_NEW_DOCS_URL'); expect(whatsNewModalModelSource).toContain('WHATS_NEW_PRIVACY_URL'); expect(whatsNewModalModelSource).toContain('WHATS_NEW_DOCS_LABEL'); + expect(whatsNewModalModelSource).toContain('MIGRATION_GUIDE_DOC_URL'); expect(tooltipSource).toContain('useTooltipState'); expect(tooltipSource).toContain('createTooltipSystemState'); expect(tooltipSource).not.toContain('createSignal'); diff --git a/frontend-modern/src/utils/localStorage.ts b/frontend-modern/src/utils/localStorage.ts index 274b31fc6..486a91f53 100644 --- a/frontend-modern/src/utils/localStorage.ts +++ b/frontend-modern/src/utils/localStorage.ts @@ -216,7 +216,6 @@ export const STORAGE_KEYS = { // Billing / migration guidance AGENT_MIGRATION_NOTICE_DISMISSED: 'pulse-agent-migration-notice-dismissed', - DASHBOARD_MIGRATION_NOTICE_DISMISSED: 'pulse-dashboard-migration-notice-dismissed', // Audit log AUDIT_AUTO_VERIFY: 'pulse-audit-auto-verify', diff --git a/tests/integration/tests/19-telemetry-disclosure.spec.ts b/tests/integration/tests/19-telemetry-disclosure.spec.ts index bdeb4976c..46e2d1459 100644 --- a/tests/integration/tests/19-telemetry-disclosure.spec.ts +++ b/tests/integration/tests/19-telemetry-disclosure.spec.ts @@ -50,6 +50,27 @@ async function expectPopupDoc( await popup.close(); } +async function expectSpotlightAround(spotlight: Locator, target: Locator) { + await expect(spotlight).toBeVisible(); + await expect(target).toBeVisible(); + await expect + .poll(async () => { + const spotlightBox = await spotlight.boundingBox(); + const targetBox = await target.boundingBox(); + if (!spotlightBox || !targetBox) { + return false; + } + + return ( + spotlightBox.x <= targetBox.x + 1 && + spotlightBox.y <= targetBox.y + 1 && + spotlightBox.x + spotlightBox.width >= targetBox.x + targetBox.width - 1 && + spotlightBox.y + spotlightBox.height >= targetBox.y + targetBox.height - 1 + ); + }) + .toBe(true); +} + async function readTelemetryPreview(page: Page) { const preview = page.locator('pre[aria-label="Telemetry payload preview"]'); await expect(preview).toBeVisible(); @@ -112,7 +133,7 @@ test.describe('Telemetry disclosure', () => { .not.toBe(initialPreview.install_id); }); - test('whats-new modal opens shipped privacy and documentation pages', async ({ page }, testInfo) => { + test('whats-new tour opens shipped privacy and migration guide pages', async ({ page }, testInfo) => { test.skip(testInfo.project.name.startsWith('mobile-'), 'Desktop-only telemetry disclosure coverage'); await page.addInitScript(() => { @@ -122,9 +143,20 @@ test.describe('Telemetry disclosure', () => { await page.goto('/dashboard', { waitUntil: 'domcontentloaded' }); const dialog = page.getByRole('dialog'); + const spotlight = page.locator('[data-tour-spotlight]'); const assistantLauncher = page.getByRole('button', { name: 'Expand Pulse Assistant' }); - await expect(dialog.getByText('Welcome to the New Navigation!')).toBeVisible(); + const dashboardTab = page.locator( + '[role="tab"][title="Environment overview and command center"]', + ); + const infrastructureTab = page.locator( + '[role="tab"][title="All agents and nodes across platforms"]', + ); + await expect(dialog.getByText('Welcome to Pulse v6')).toBeVisible(); + await expect(dialog.getByText('Step 1 of 5')).toBeVisible(); await expect(assistantLauncher).toBeHidden(); + await expect(spotlight).toHaveAttribute('data-tour-step', 'dashboard'); + await expect(dialog).toHaveAttribute('data-tour-step', 'dashboard'); + await expectSpotlightAround(spotlight, dashboardTab); await expect( dialog.getByText( /rotating install ID, normalized release identity, platform, resource counts, and feature flags/i, @@ -143,15 +175,23 @@ test.describe('Telemetry disclosure', () => { 'Pulse currently has two usage-data scopes', ); - const docsLink = dialog.getByRole('link', { name: 'Documentation' }); - await expect(docsLink).toHaveAttribute('href', '/docs/README.md'); + const docsLink = dialog.getByRole('link', { name: 'Migration guide' }); + await expect(docsLink).toHaveAttribute('href', '/docs/MIGRATION_UNIFIED_NAV.md'); await expectPopupDoc( page, docsLink, - '/docs/README.md', - 'Welcome to the Pulse documentation portal.', + '/docs/MIGRATION_UNIFIED_NAV.md', + 'Migration Guide: Unified Navigation', ); + await dialog.getByRole('button', { name: 'Next' }).click(); + await expect(dialog).toHaveAttribute('data-tour-step', 'infrastructure'); + await expect(spotlight).toHaveAttribute('data-tour-step', 'infrastructure'); + await expectSpotlightAround(spotlight, infrastructureTab); + + for (let step = 0; step < 3; step += 1) { + await dialog.getByRole('button', { name: 'Next' }).click(); + } await dialog.getByRole('button', { name: "Let's go" }).click(); await expect(dialog).not.toBeVisible(); await expect(assistantLauncher).toBeVisible(); diff --git a/tests/integration/tests/45-workloads-memory-tail.spec.ts b/tests/integration/tests/45-workloads-memory-tail.spec.ts index a102669a8..1358f5dbd 100644 --- a/tests/integration/tests/45-workloads-memory-tail.spec.ts +++ b/tests/integration/tests/45-workloads-memory-tail.spec.ts @@ -69,11 +69,11 @@ async function ensureMockModeEnabled(page: import('@playwright/test').Page): Pro } async function dismissWhatsNewModal(page: import('@playwright/test').Page): Promise { - const modalTitle = page.getByText('Welcome to the New Navigation!'); + const modalTitle = page.getByText('Welcome to Pulse v6'); if (!(await modalTitle.isVisible().catch(() => false))) { return; } - await page.getByRole('button', { name: "Let's go" }).click(); + await page.getByRole('button', { name: 'Skip tour' }).click(); await expect(modalTitle).toHaveCount(0); } diff --git a/tests/integration/tests/58-dashboard-migration-notice.spec.ts b/tests/integration/tests/58-dashboard-migration-notice.spec.ts deleted file mode 100644 index ee988fec9..000000000 --- a/tests/integration/tests/58-dashboard-migration-notice.spec.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { expect, test, type Page } from '@playwright/test'; -import { ensureAuthenticated, getMockMode, setMockMode } from './helpers'; - -const waitForDashboardReady = async (page: Page) => { - await expect(page).toHaveURL(/\/dashboard(?:\?.*)?$/); - await expect(page.getByTestId('dashboard-page')).toBeVisible(); - await page.waitForFunction( - () => !document.querySelector('[data-testid="dashboard-loading"]'), - undefined, - { timeout: 30_000 }, - ); -}; - -test('dashboard keeps the v6 migration notice visible until the operator dismisses it', async ({ - page, -}) => { - await ensureAuthenticated(page); - - let initialMockMode: { enabled: boolean } | null = null; - try { - initialMockMode = await getMockMode(page); - if (!initialMockMode.enabled) { - await setMockMode(page, true); - } - } catch (error) { - console.warn(`[dashboard-migration] unable to read/set mock mode: ${String(error)}`); - } - - try { - await page.addInitScript(() => { - localStorage.setItem('pulse_whats_new_v2_shown', 'true'); - if (!sessionStorage.getItem('pulse-dashboard-migration-notice-test-initialized')) { - localStorage.removeItem('pulse-dashboard-migration-notice-dismissed'); - sessionStorage.setItem('pulse-dashboard-migration-notice-test-initialized', 'true'); - } - }); - - await page.goto('/dashboard', { waitUntil: 'domcontentloaded' }); - await waitForDashboardReady(page); - - await expect(page.getByText('Looking for Proxmox, Docker, and Hosts?')).toBeVisible(); - await expect( - page.getByText( - 'Use Infrastructure for Proxmox nodes, Docker hosts, clusters, and other systems. Use Workloads for VMs, containers, pods, and Docker update status.', - ), - ).toBeVisible(); - await expect(page.getByRole('link', { name: 'See full migration guide' })).toHaveAttribute( - 'href', - '/docs/MIGRATION_UNIFIED_NAV.md', - ); - - await page.getByRole('button', { name: 'Dismiss navigation notice' }).click(); - - await expect(page.getByText('Looking for Proxmox, Docker, and Hosts?')).toHaveCount(0); - await expect - .poll(() => page.evaluate(() => localStorage.getItem('pulse-dashboard-migration-notice-dismissed'))) - .toBe('true'); - - await page.reload({ waitUntil: 'domcontentloaded' }); - await waitForDashboardReady(page); - - await expect(page.getByText('Looking for Proxmox, Docker, and Hosts?')).toHaveCount(0); - } finally { - if (initialMockMode && !initialMockMode.enabled) { - try { - await setMockMode(page, false); - } catch (error) { - console.warn(`[dashboard-migration] unable to restore mock mode: ${String(error)}`); - } - } - } -});