Replace v6 banner with guided welcome tour

Refs #1429
This commit is contained in:
rcourtman
2026-04-19 21:26:54 +01:00
parent 9bd5d37f6a
commit 3ff7c2f2e3
18 changed files with 560 additions and 310 deletions
@@ -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
@@ -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
@@ -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
@@ -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.
@@ -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' },
@@ -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', () => {
@@ -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 <LayoutDashboardIcon class="h-4 w-4" />;
case 'infrastructure':
return <ServerIcon class="h-4 w-4" />;
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 (
<Dialog
isOpen={state.isOpen()}
onClose={state.handleClose}
panelClass="max-w-2xl"
ariaLabelledBy="whats-new-title"
>
<div class="flex max-h-[90vh] flex-col overflow-hidden">
<div class="flex-shrink-0 flex items-start justify-between border-b border-border px-6 py-4">
<div>
<h2 id="whats-new-title" class="text-xl sm:text-2xl font-semibold text-base-content">
{WHATS_NEW_TITLE}
</h2>
<p class="mt-1 text-sm text-muted">{WHATS_NEW_SUBTITLE}</p>
</div>
<button
onClick={state.handleClose}
class="rounded-md p-1.5 text-slate-400 transition-colors hover:bg-surface-hover hover:text-muted"
aria-label={WHATS_NEW_CLOSE_LABEL}
type="button"
>
<XIcon class="h-5 w-5" />
</button>
</div>
<div class="flex-1 overflow-y-auto space-y-4 sm:space-y-6 px-4 sm:px-6 py-4 sm:py-5">
<div class="grid gap-3 sm:gap-4 sm:grid-cols-2">
<For each={WHATS_NEW_FEATURE_CARDS}>
{(card) => (
<div class={`rounded-md border p-3 sm:p-4 ${card.accent}`}>
<div class="flex items-center gap-2 text-sm font-semibold text-inherit">
<WhatsNewFeatureIcon card={card} />
{card.title}
</div>
<p class="mt-1.5 sm:mt-2 text-xs text-inherit">{card.description}</p>
</div>
)}
</For>
</div>
<div class="rounded-md border border-sky-200 bg-sky-50 p-3 sm:p-4 dark:border-sky-800 dark:bg-sky-900/40">
<div class="flex items-center gap-2 text-sm font-medium text-sky-900 dark:text-sky-100">
<ChartBarIcon class="h-4 w-4 flex-shrink-0" />
{WHATS_NEW_TELEMETRY_TITLE}
</div>
<p class="mt-1.5 text-xs text-sky-900 dark:text-sky-200">{WHATS_NEW_TELEMETRY_COPY[0]}</p>
<p class="mt-1.5 text-xs text-sky-900 dark:text-sky-200">
{WHATS_NEW_TELEMETRY_COPY[1]} You can disable it any time in{' '}
<span class="font-medium">{WHATS_NEW_TELEMETRY_SETTINGS_PATH}</span> or by setting{' '}
<code class="rounded bg-sky-100 px-1 py-0.5 text-[10px] font-mono dark:bg-sky-800">
{WHATS_NEW_TELEMETRY_ENV_VAR}
</code>
.{' '}
<a
href={WHATS_NEW_PRIVACY_URL}
target="_blank"
rel="noopener noreferrer"
class="underline hover:text-sky-700 dark:hover:text-sky-100"
>
{WHATS_NEW_TELEMETRY_PRIVACY_LABEL}
</a>
</p>
</div>
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 sm:gap-4">
<label class="flex items-center gap-2 text-sm text-muted">
<input
type="checkbox"
checked={state.dontShowAgain()}
onChange={(event) => state.setDontShowAgain(event.currentTarget.checked)}
class="h-4 w-4 rounded border-slate-300 text-blue-600 focus:ring-blue-500 focus:ring-2"
<Show when={state.isOpen()}>
<Portal mount={document.body}>
<div class="fixed inset-0 z-[1000]">
<div class="absolute inset-0" data-dialog-backdrop onClick={dialogState.handleBackdropClick} />
<Show when={state.spotlightStyle()}>
{(style) => (
<div
data-tour-spotlight=""
data-tour-step={step().target}
class="pointer-events-none absolute rounded-[1.25rem] border border-blue-300/80 bg-white/5 transition-all duration-200 dark:border-blue-300/60"
style={style()}
/>
{WHATS_NEW_DO_NOT_SHOW_LABEL}
</label>
)}
</Show>
<div
ref={setPanelRef}
data-tour-panel=""
data-tour-step={step().target}
role="dialog"
aria-modal="true"
aria-labelledby="whats-new-title"
tabindex="-1"
class="fixed z-[1001] max-h-[min(90vh,42rem)] overflow-y-auto rounded-2xl border border-border bg-surface shadow-2xl focus:outline-none"
style={state.panelStyle()}
onClick={(event) => event.stopPropagation()}
>
<div class="flex items-start justify-between border-b border-border px-5 py-4">
<div>
<div class="text-xs font-semibold uppercase tracking-[0.18em] text-blue-600 dark:text-blue-300">
Step {state.stepIndex() + 1} of {state.stepCount()}
</div>
<h2 id="whats-new-title" class="mt-1 text-xl font-semibold text-base-content">
{WHATS_NEW_TITLE}
</h2>
<p class="mt-1 text-sm text-muted">{WHATS_NEW_SUBTITLE}</p>
</div>
<button
onClick={state.handleClose}
class="rounded-md p-1.5 text-slate-400 transition-colors hover:bg-surface-hover hover:text-muted"
aria-label={WHATS_NEW_CLOSE_LABEL}
type="button"
>
<XIcon class="h-5 w-5" />
</button>
</div>
<a
href={WHATS_NEW_DOCS_URL}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 text-sm font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
{WHATS_NEW_DOCS_LABEL}
<ExternalLinkIcon class="h-4 w-4" />
</a>
<a
href={recoveryEventsHref}
class="inline-flex items-center gap-1 text-sm font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
{WHATS_NEW_RECOVERY_LINK_LABEL}
</a>
<div class="space-y-5 px-5 py-5">
<div class={`rounded-2xl border p-4 ${step().accent}`}>
<div class="flex items-center gap-2 text-sm font-semibold text-inherit">
<WhatsNewFeatureIcon card={step()} />
{step().title}
</div>
<p class="mt-2 text-sm leading-6 text-inherit">{step().description}</p>
</div>
<div class="grid gap-2 sm:grid-cols-5">
<For each={WHATS_NEW_FEATURE_CARDS}>
{(card, index) => (
<div
class={`rounded-xl border px-2.5 py-2 text-left text-xs ${
index() === state.stepIndex()
? 'border-blue-300 bg-blue-50 text-blue-900 dark:border-blue-700 dark:bg-blue-950 dark:text-blue-100'
: 'border-border bg-surface-hover text-muted'
}`}
>
{card.title}
</div>
)}
</For>
</div>
<div class="rounded-xl border border-sky-200 bg-sky-50 p-3 dark:border-sky-800 dark:bg-sky-900/40">
<div class="flex items-center gap-2 text-sm font-medium text-sky-900 dark:text-sky-100">
<ChartBarIcon class="h-4 w-4 flex-shrink-0" />
{WHATS_NEW_TELEMETRY_TITLE}
</div>
<p class="mt-1.5 text-xs leading-5 text-sky-900 dark:text-sky-200">
{WHATS_NEW_TELEMETRY_COPY[0]}
</p>
<p class="mt-1.5 text-xs leading-5 text-sky-900 dark:text-sky-200">
{WHATS_NEW_TELEMETRY_COPY[1]} You can disable it any time in{' '}
<span class="font-medium">{WHATS_NEW_TELEMETRY_SETTINGS_PATH}</span> or by
setting{' '}
<code class="rounded bg-sky-100 px-1 py-0.5 text-[10px] font-mono dark:bg-sky-800">
{WHATS_NEW_TELEMETRY_ENV_VAR}
</code>
.{' '}
<a
href={WHATS_NEW_PRIVACY_URL}
target="_blank"
rel="noopener noreferrer"
class="underline hover:text-sky-700 dark:hover:text-sky-100"
>
{WHATS_NEW_TELEMETRY_PRIVACY_LABEL}
</a>
</p>
</div>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<label class="flex items-center gap-2 text-sm text-muted">
<input
type="checkbox"
checked={state.dontShowAgain()}
onChange={(event) => state.setDontShowAgain(event.currentTarget.checked)}
class="h-4 w-4 rounded border-slate-300 text-blue-600 focus:ring-2 focus:ring-blue-500"
/>
{WHATS_NEW_DO_NOT_SHOW_LABEL}
</label>
<div class="flex items-center gap-4">
<a
href={WHATS_NEW_DOCS_URL}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 text-sm font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
{WHATS_NEW_DOCS_LABEL}
<ExternalLinkIcon class="h-4 w-4" />
</a>
</div>
</div>
</div>
<div class="flex items-center justify-between border-t border-border bg-surface-hover px-5 py-4">
<button
type="button"
onClick={state.handleClose}
class="text-sm font-medium text-muted transition-colors hover:text-base-content"
>
{WHATS_NEW_SKIP_LABEL}
</button>
<div class="flex items-center gap-2">
<button
type="button"
onClick={state.handlePrevious}
disabled={state.isFirstStep()}
class="rounded-md border border-border px-3 py-2 text-sm font-medium text-base-content transition-colors hover:bg-surface disabled:cursor-not-allowed disabled:opacity-50"
>
{WHATS_NEW_BACK_LABEL}
</button>
<button
onClick={state.handleNext}
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-blue-700"
type="button"
>
{state.isLastStep() ? WHATS_NEW_PRIMARY_ACTION_LABEL : WHATS_NEW_NEXT_LABEL}
</button>
</div>
</div>
</div>
</div>
<div class="flex-shrink-0 flex items-center justify-end border-t border-border bg-surface-hover px-4 sm:px-6 py-3 sm:py-4">
<button
onClick={state.handleClose}
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-blue-700"
type="button"
>
{WHATS_NEW_PRIMARY_ACTION_LABEL}
</button>
</div>
</div>
</Dialog>
</Portal>
</Show>
);
}
@@ -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(() => <WhatsNewModal />);
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(() => <WhatsNewModal />);
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(() => <WhatsNewModal />);
const docsLink = await screen.findByRole('link', { name: 'Migration guide' });
expect(docsLink).toHaveAttribute('href', '/docs/MIGRATION_UNIFIED_NAV.md');
});
});
@@ -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<HTMLDivElement | null>(null);
const [spotlightRect, setSpotlightRect] = createSignal<SpotlightRect | null>(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,
};
}
@@ -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';
+3 -22
View File
@@ -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<Alert[]>(() =>
Object.values(activeAlerts as Record<string, Alert | undefined>).filter(
@@ -282,23 +273,13 @@ export default function Dashboard() {
<Match when={initialLoadComplete() && hasCachedData()}>
<section class="space-y-5">
{/* 1. Action Required Panel — only when actions exist */}
<Show when={!dashboardMigrationNoticeDismissed()}>
<MigrationNoticeBanner
title={DASHBOARD_MIGRATION_NOTICE_TITLE}
message={DASHBOARD_MIGRATION_NOTICE_MESSAGE}
learnMoreHref={MIGRATION_GUIDE_DOC_URL}
onDismiss={() => setDashboardMigrationNoticeDismissed(true)}
/>
</Show>
{/* 2. Action Required Panel — only when actions exist */}
<ActionRequiredPanel
pendingApprovals={actions.pendingApprovals()}
unackedCriticalAlerts={actions.unackedCriticalAlerts()}
findingsNeedingAttention={actions.findingsNeedingAttention()}
/>
{/* 3. KPI Strip — always visible */}
{/* 2. KPI Strip — always visible */}
<KPIStrip
infrastructure={{
total: overview().infrastructure.total,
@@ -320,10 +301,10 @@ export default function Dashboard() {
}}
/>
{/* 4. Problem Resources Table — only when problems exist */}
{/* 3. Problem Resources Table — only when problems exist */}
<ProblemResourcesTable problems={overview().problemResources} />
{/* 56. Customizable widgets: Trend Charts, Recent Alerts */}
{/* 45. Customizable widgets: Trend Charts, Recent Alerts */}
<For each={widgetGroups()}>
{(group) =>
group.type === 'full' ? (
@@ -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(() => <DashboardPage />);
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(() => <DashboardPage />);
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;
@@ -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' },
@@ -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');
@@ -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',
@@ -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();
@@ -69,11 +69,11 @@ async function ensureMockModeEnabled(page: import('@playwright/test').Page): Pro
}
async function dismissWhatsNewModal(page: import('@playwright/test').Page): Promise<void> {
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);
}
@@ -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)}`);
}
}
}
});