mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-09 18:15:50 +00:00
@@ -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';
|
||||
|
||||
@@ -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} />
|
||||
|
||||
{/* 5–6. Customizable widgets: Trend Charts, Recent Alerts */}
|
||||
{/* 4–5. 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)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user