Improve dashboard estate orientation

Add a connected-infrastructure estate summary above the dashboard KPI strip, use that canonical system count for the infrastructure KPI, and cover the landing-page orientation contract with unit and browser integration proof.
This commit is contained in:
rcourtman 2026-04-23 21:52:34 +01:00
parent 0dae66e9c7
commit 643db3f378
12 changed files with 707 additions and 10 deletions

View file

@ -2744,10 +2744,30 @@
"path": "frontend-modern/src/components/SetupWizard/SetupWizard.tsx",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/features/dashboardOverview/estateSummaryModel.ts",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/features/dashboardOverview/EstateSummaryPanel.tsx",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/pages/Dashboard.tsx",
"kind": "file"
},
{
"repo": "pulse",
"path": "tests/integration/tests/11-first-session.spec.ts",
"kind": "file"
},
{
"repo": "pulse",
"path": "tests/integration/tests/71-dashboard-estate-orientation.spec.ts",
"kind": "file"
}
]
},
@ -3907,7 +3927,7 @@
},
{
"id": "first-session-post-rc-polish",
"summary": "Track broader first-session polish and parity work that is intentionally outside the RC stabilization floor.",
"summary": "Track broader first-session polish and parity work that is intentionally outside the RC stabilization floor, including the dashboard-home requirement that the first viewport preserves immediate estate orientation for v5-upgrade users through the canonical connected-infrastructure projection.",
"owner": "project-owner",
"status": "planned",
"recorded_at": "2026-03-13",
@ -4840,6 +4860,18 @@
"L9"
]
},
{
"id": "dashboard-home-estate-orientation-contract",
"summary": "The v6 dashboard remains the default landing surface only if its first viewport immediately proves that Pulse sees the operator's estate: the page must surface canonical connected-infrastructure system count, health, source coverage, freshness, and an explicit Infrastructure handoff before detailed dashboard rows, without restoring platform-special navigation or widening the dashboard hot path.",
"kind": "contract",
"decided_at": "2026-04-23",
"subsystem_ids": [
"frontend-primitives"
],
"lane_ids": [
"L8"
]
},
{
"id": "self-hosted-paid-surface-classification",
"summary": "Current v6 self-hosted paid surfaces now classify commercial capabilities explicitly: only the primary Pro pillars (root-cause analysis, safe remediation, 90-day history, and included admin extras) may be marketed in customer-facing copy and upgrade prompts; compatibility-only gates such as `kubernetes_ai` remain valid runtime facts but non-marketed, and legacy claims such as `incident memory`, `scheduled remediations`, and `execution audit trail` stay retired unless rebuilt into first-class product surfaces.",

View file

@ -1567,6 +1567,14 @@ 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 overview boundary owns the first-viewport estate
orientation contract for the v6 landing page. `EstateSummaryPanel.tsx` and
`estateSummaryModel.ts` in `frontend-modern/src/features/dashboardOverview/`
must derive system count, health, source coverage, and freshness from the
canonical connected-infrastructure projection, fall back only to the compact
dashboard summary that the route already owns, and keep the explicit
Infrastructure handoff above detailed problem, storage, recovery, or trend
rows without restoring platform-special navigation.
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

View file

@ -161,6 +161,12 @@ regression protection.
filesystem walks, or other heavy work just to compute whether an attached
agent is current.
5. Extend dashboard hot-path filter, sort, grouping, and stats math through `frontend-modern/src/components/Dashboard/workloadSelectors.ts`, and extend workload identity, discovery routing, and node-topology helpers through `frontend-modern/src/components/Dashboard/workloadTopology.ts`, rather than duplicating selector or topology logic in `frontend-modern/src/components/Dashboard/Dashboard.tsx`
Dashboard landing-page estate orientation is part of that same hot-path
discipline. First-viewport system count, health, source coverage, and
freshness must be derived from app-runtime connected-infrastructure state
and the already-loaded compact dashboard summary fallback; it must not
introduce a new `useUnifiedResources()` subscription, platform-specific
fetch, or chart/history request just to make the dashboard feel oriented.
6. Normalize dashboard workload view-mode aliases through `frontend-modern/src/utils/workloads.ts` instead of keeping local URL/storage parsing in `frontend-modern/src/components/Dashboard/Dashboard.tsx`
7. Deduplicate dashboard workload rows by canonical workload ID from `frontend-modern/src/utils/workloads.ts` rather than via local pass-through wrappers in `frontend-modern/src/components/Dashboard/Dashboard.tsx`
8. Render dashboard row identity directly from the shared canonical workload helper so row selection, hover, and fallback metadata lookup stay aligned with the same workload contract

View file

@ -319,6 +319,12 @@ querying, and the operator-facing storage health presentation layer.
adjacent composition only: they must not replace the recovery/storage
dashboard panels, suppress the governed no-resources handoff, or move
storage/recovery summary ownership out of the compact dashboard route.
Dashboard estate-orientation additions follow that same rule: they may
compose above the storage/recovery cards to prove Pulse sees the connected
estate, but they must consume `appRuntime` connected-infrastructure state
and the existing compact dashboard summary fallback rather than adding new
storage/recovery fetches or reclassifying storage/recovery widgets as
dashboard-owned summary panels.
35. Keep shared `frontend-modern/src/App.tsx` public-route ownership explicit by
surface. Storage/recovery preview entrypoints such as
`/preview/setup-complete` may remain public app-shell routes, but unrelated

View file

@ -0,0 +1,152 @@
import { For, Show } from 'solid-js';
import { Card } from '@/components/shared/Card';
import { INFRASTRUCTURE_PATH } from '@/routing/resourceLinks';
import { formatRelativeTime } from '@/utils/format';
import type {
DashboardEstateHealthTone,
DashboardEstateSummary,
DashboardEstateSurfaceSummary,
} from './estateSummaryModel';
import ActivityIcon from 'lucide-solid/icons/activity';
import ArrowRightIcon from 'lucide-solid/icons/arrow-right';
import ClockIcon from 'lucide-solid/icons/clock';
import ServerIcon from 'lucide-solid/icons/server';
interface EstateSummaryPanelProps {
summary: DashboardEstateSummary;
}
const toneDotClass: Record<DashboardEstateHealthTone, string> = {
healthy: 'bg-emerald-500',
warning: 'bg-amber-500',
danger: 'bg-red-500',
muted: 'bg-slate-400',
};
const toneTextClass: Record<DashboardEstateHealthTone, string> = {
healthy: 'text-emerald-600 dark:text-emerald-400',
warning: 'text-amber-600 dark:text-amber-400',
danger: 'text-red-600 dark:text-red-400',
muted: 'text-muted',
};
function formatSurfaceSummary(surfaces: DashboardEstateSurfaceSummary[]): string {
if (surfaces.length === 0) return 'No source coverage yet';
return surfaces
.slice(0, 4)
.map((surface) =>
surface.label === 'Agent' && surface.count !== 1
? `${surface.count} agents`
: `${surface.count} ${surface.label}`,
)
.join(' · ');
}
export function EstateSummaryPanel(props: EstateSummaryPanelProps) {
const latestSeenLabel = () =>
props.summary.latestSeen
? formatRelativeTime(props.summary.latestSeen, { compact: true })
: 'Waiting for signal';
return (
<Card
padding="none"
tone="default"
class="overflow-hidden"
data-testid="dashboard-estate-summary"
>
<div class="flex flex-col gap-3 border-b border-border px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
<div class="flex min-w-0 items-center gap-3">
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-blue-50 text-blue-600 dark:bg-blue-900/40 dark:text-blue-300">
<ServerIcon class="h-4 w-4" aria-hidden="true" />
</div>
<div class="min-w-0">
<div class="flex items-center gap-2">
<h2 class="text-sm font-semibold text-base-content">Connected infrastructure</h2>
<span class={`h-2 w-2 rounded-full ${toneDotClass[props.summary.tone]}`} />
</div>
<p class="mt-0.5 text-xs text-muted">
<span class={`font-medium ${toneTextClass[props.summary.tone]}`}>
{props.summary.headline}
</span>
<span> · {props.summary.detail}</span>
</p>
</div>
</div>
<a
href={INFRASTRUCTURE_PATH}
class="inline-flex shrink-0 items-center gap-1.5 self-start rounded-md border border-border px-2.5 py-1.5 text-xs font-medium text-base-content hover:bg-surface-hover sm:self-auto"
>
View infrastructure
<ArrowRightIcon class="h-3.5 w-3.5" aria-hidden="true" />
</a>
</div>
<div class="grid grid-cols-1 gap-3 px-4 py-3 sm:grid-cols-3">
<div>
<p class="text-[11px] font-medium uppercase tracking-wide text-muted">Systems</p>
<p class="mt-1 text-xl font-mono font-semibold text-base-content">
{props.summary.totalSystems}
</p>
<p class="mt-0.5 text-xs text-muted">
{props.summary.hasCanonicalProjection
? `${props.summary.activeSystems} active`
: 'Resource summary fallback'}
</p>
</div>
<div>
<p class="text-[11px] font-medium uppercase tracking-wide text-muted">Source coverage</p>
<p class="mt-1 truncate text-sm font-medium text-base-content">
{formatSurfaceSummary(props.summary.surfaces)}
</p>
<p class="mt-0.5 text-xs text-muted">Grouped by monitored system</p>
</div>
<div>
<p class="text-[11px] font-medium uppercase tracking-wide text-muted">Latest signal</p>
<p class="mt-1 inline-flex items-center gap-1.5 text-sm font-medium text-base-content">
<ClockIcon class="h-3.5 w-3.5 text-muted" aria-hidden="true" />
{latestSeenLabel()}
</p>
<p class="mt-0.5 text-xs text-muted">
{props.summary.attentionSystems > 0
? `${props.summary.attentionSystems} needing review`
: 'No system-level attention'}
</p>
</div>
</div>
<Show when={props.summary.systems.length > 0}>
<div class="border-t border-border px-4 py-2.5">
<div class="flex flex-wrap items-center gap-2">
<For each={props.summary.systems.slice(0, 5)}>
{(system) => (
<a
href={INFRASTRUCTURE_PATH}
class="inline-flex max-w-full items-center gap-1.5 rounded border border-border-subtle bg-base px-2 py-1 text-xs text-base-content hover:bg-surface-hover"
title={`${system.name} · ${system.statusLabel}`}
>
<span class={`h-1.5 w-1.5 shrink-0 rounded-full ${toneDotClass[system.tone]}`} />
<span class="max-w-[12rem] truncate font-medium">{system.name}</span>
<span class="text-muted">{system.statusLabel}</span>
</a>
)}
</For>
<Show when={props.summary.systems.length > 5}>
<a
href={INFRASTRUCTURE_PATH}
class="inline-flex items-center gap-1 text-xs font-medium text-blue-600 hover:underline dark:text-blue-400"
>
+{props.summary.systems.length - 5} more
<ActivityIcon class="h-3 w-3" aria-hidden="true" />
</a>
</Show>
</div>
</div>
</Show>
</Card>
);
}
export default EstateSummaryPanel;

View file

@ -43,7 +43,7 @@ export function KPIStrip(props: KPIStripProps) {
return (
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3">
<a href={INFRASTRUCTURE_PATH} class="group block">
<a href={INFRASTRUCTURE_PATH} class="group block" data-testid="dashboard-kpi-infrastructure">
<Card
hoverable
padding="none"

View file

@ -0,0 +1,82 @@
import { describe, expect, it } from 'vitest';
import type { ConnectedInfrastructureItem } from '@/types/api';
import { buildDashboardEstateSummary } from '../estateSummaryModel';
const item = (
overrides: Partial<ConnectedInfrastructureItem> = {},
): ConnectedInfrastructureItem => ({
id: 'host-1',
name: 'host-1',
status: 'active',
healthStatus: 'online',
lastSeen: Date.parse('2026-04-23T10:00:00.000Z'),
surfaces: [{ id: 'agent:host-1', kind: 'agent', label: 'Host telemetry' }],
...overrides,
});
describe('buildDashboardEstateSummary', () => {
it('summarizes connected infrastructure as monitored systems, not platform tabs', () => {
const summary = buildDashboardEstateSummary([
item({
id: 'pve-1',
name: 'pve-1',
surfaces: [
{ id: 'agent:pve-1', kind: 'agent', label: 'Host telemetry' },
{ id: 'proxmox:pve-1', kind: 'proxmox', label: 'Proxmox VE data' },
],
}),
item({
id: 'nas-1',
name: 'nas-1',
healthStatus: 'degraded',
surfaces: [{ id: 'truenas:nas-1', kind: 'truenas', label: 'TrueNAS API data' }],
}),
item({
id: 'k8s-1',
name: 'k8s-1',
surfaces: [
{ id: 'kubernetes:k8s-1', kind: 'kubernetes', label: 'Kubernetes cluster data' },
],
}),
]);
expect(summary.totalSystems).toBe(3);
expect(summary.activeSystems).toBe(3);
expect(summary.healthySystems).toBe(2);
expect(summary.degradedSystems).toBe(1);
expect(summary.headline).toBe('1 system needs attention');
expect(summary.surfaces.map((surface) => surface.label)).toEqual([
'Agent',
'Kubernetes',
'Proxmox',
'TrueNAS',
]);
});
it('uses the compact dashboard fallback when the connected projection has not arrived yet', () => {
const summary = buildDashboardEstateSummary([], {
total: 4,
online: 3,
});
expect(summary.hasCanonicalProjection).toBe(false);
expect(summary.totalSystems).toBe(4);
expect(summary.healthySystems).toBe(3);
expect(summary.unknownSystems).toBe(1);
expect(summary.detail).toBe('3 resources online, 1 resource not classified yet.');
});
it('counts each active system needing attention once', () => {
const summary = buildDashboardEstateSummary([
item({ id: 'pve-1', healthStatus: 'degraded', isOutdatedBinary: true }),
item({ id: 'ignored-1', status: 'ignored', isOutdatedBinary: true }),
]);
expect(summary.totalSystems).toBe(2);
expect(summary.degradedSystems).toBe(1);
expect(summary.outdatedSystems).toBe(1);
expect(summary.ignoredSystems).toBe(1);
expect(summary.attentionSystems).toBe(1);
expect(summary.headline).toBe('1 system needs attention');
});
});

View file

@ -0,0 +1,269 @@
import type { ConnectedInfrastructureItem, ConnectedInfrastructureSurface } from '@/types/api';
import { getMonitoredSystemSourceLabel } from '@/utils/monitoredSystemPresentation';
export type DashboardEstateHealthTone = 'healthy' | 'warning' | 'danger' | 'muted';
export interface DashboardEstateFallback {
total: number;
online: number;
}
export interface DashboardEstateSurfaceSummary {
kind: ConnectedInfrastructureSurface['kind'];
label: string;
count: number;
}
export interface DashboardEstateSystemSummary {
id: string;
name: string;
statusLabel: string;
tone: DashboardEstateHealthTone;
lastSeen?: number;
surfaces: DashboardEstateSurfaceSummary[];
}
export interface DashboardEstateSummary {
hasCanonicalProjection: boolean;
totalSystems: number;
activeSystems: number;
healthySystems: number;
degradedSystems: number;
offlineSystems: number;
unknownSystems: number;
ignoredSystems: number;
outdatedSystems: number;
attentionSystems: number;
latestSeen?: number;
headline: string;
detail: string;
tone: DashboardEstateHealthTone;
surfaces: DashboardEstateSurfaceSummary[];
systems: DashboardEstateSystemSummary[];
}
const OFFLINE_HEALTH_STATUSES = new Set(['offline', 'stopped', 'down', 'unreachable']);
const DEGRADED_HEALTH_STATUSES = new Set(['critical', 'degraded', 'error', 'failed', 'warning']);
const HEALTHY_HEALTH_STATUSES = new Set(['active', 'healthy', 'ok', 'online', 'ready', 'running']);
const normalizeValue = (value: string | undefined): string => value?.trim().toLowerCase() ?? '';
const pluralize = (count: number, singular: string, plural = `${singular}s`): string =>
`${count} ${count === 1 ? singular : plural}`;
function classifyHealth(item: ConnectedInfrastructureItem): {
label: string;
tone: DashboardEstateHealthTone;
bucket: 'healthy' | 'degraded' | 'offline' | 'unknown' | 'ignored';
} {
if (item.status === 'ignored') {
return { label: 'Ignored', tone: 'muted', bucket: 'ignored' };
}
const health = normalizeValue(item.healthStatus);
if (OFFLINE_HEALTH_STATUSES.has(health)) {
return { label: 'Offline', tone: 'danger', bucket: 'offline' };
}
if (DEGRADED_HEALTH_STATUSES.has(health)) {
return { label: 'Degraded', tone: 'warning', bucket: 'degraded' };
}
if (health === '' || HEALTHY_HEALTH_STATUSES.has(health)) {
return { label: 'Online', tone: 'healthy', bucket: 'healthy' };
}
return { label: 'Unknown', tone: 'warning', bucket: 'unknown' };
}
function surfaceLabel(kind: ConnectedInfrastructureSurface['kind']): string {
const label = getMonitoredSystemSourceLabel(kind);
return label || kind;
}
function summarizeSurfaces(
surfaces: ConnectedInfrastructureSurface[],
): DashboardEstateSurfaceSummary[] {
const counts = new Map<ConnectedInfrastructureSurface['kind'], number>();
for (const surface of surfaces) {
counts.set(surface.kind, (counts.get(surface.kind) ?? 0) + 1);
}
return Array.from(counts.entries())
.map(([kind, count]) => ({
kind,
label: surfaceLabel(kind),
count,
}))
.sort((left, right) => {
if (right.count !== left.count) return right.count - left.count;
return left.label.localeCompare(right.label);
});
}
function buildFallbackSummary(fallback: DashboardEstateFallback): DashboardEstateSummary {
const total = Math.max(0, Math.trunc(fallback.total));
const healthy = Math.max(0, Math.min(total, Math.trunc(fallback.online)));
const unknown = Math.max(0, total - healthy);
const tone: DashboardEstateHealthTone =
total === 0 ? 'muted' : unknown > 0 ? 'warning' : 'healthy';
return {
hasCanonicalProjection: false,
totalSystems: total,
activeSystems: total,
healthySystems: healthy,
degradedSystems: 0,
offlineSystems: 0,
unknownSystems: unknown,
ignoredSystems: 0,
outdatedSystems: 0,
attentionSystems: unknown,
headline:
total === 0
? 'No infrastructure reporting'
: `${pluralize(total, 'infrastructure resource')} reporting`,
detail:
total === 0
? 'Connected systems appear here after the first infrastructure source reports.'
: unknown > 0
? `${pluralize(healthy, 'resource')} online, ${pluralize(unknown, 'resource')} not classified yet.`
: 'All reporting resources are online.',
tone,
surfaces: [],
systems: [],
};
}
export function buildDashboardEstateSummary(
items: ConnectedInfrastructureItem[],
fallback?: DashboardEstateFallback,
): DashboardEstateSummary {
if (items.length === 0 && fallback) {
return buildFallbackSummary(fallback);
}
let activeSystems = 0;
let healthySystems = 0;
let degradedSystems = 0;
let offlineSystems = 0;
let unknownSystems = 0;
let ignoredSystems = 0;
let outdatedSystems = 0;
let latestSeen: number | undefined;
const attentionSystemIds = new Set<string>();
const allSurfaces: ConnectedInfrastructureSurface[] = [];
const systems = items
.map((item): DashboardEstateSystemSummary => {
const health = classifyHealth(item);
const name =
item.displayName?.trim() || item.name?.trim() || item.hostname?.trim() || item.id;
const itemSurfaces = summarizeSurfaces(item.surfaces ?? []);
if (item.status === 'ignored') {
ignoredSystems += 1;
} else {
activeSystems += 1;
}
switch (health.bucket) {
case 'healthy':
healthySystems += 1;
break;
case 'degraded':
degradedSystems += 1;
break;
case 'offline':
offlineSystems += 1;
break;
case 'unknown':
unknownSystems += 1;
break;
case 'ignored':
break;
}
if (item.isOutdatedBinary && item.status !== 'ignored') {
outdatedSystems += 1;
}
if (
item.status !== 'ignored' &&
(health.bucket === 'degraded' ||
health.bucket === 'offline' ||
health.bucket === 'unknown' ||
item.isOutdatedBinary)
) {
attentionSystemIds.add(item.id);
}
if (typeof item.lastSeen === 'number' && Number.isFinite(item.lastSeen)) {
latestSeen = Math.max(latestSeen ?? 0, item.lastSeen);
}
allSurfaces.push(...(item.surfaces ?? []));
return {
id: item.id,
name,
statusLabel: item.isOutdatedBinary ? `${health.label} · update available` : health.label,
tone: item.isOutdatedBinary && health.tone === 'healthy' ? 'warning' : health.tone,
lastSeen: item.lastSeen,
surfaces: itemSurfaces,
};
})
.sort((left, right) => {
const toneRank: Record<DashboardEstateHealthTone, number> = {
danger: 0,
warning: 1,
muted: 2,
healthy: 3,
};
if (toneRank[left.tone] !== toneRank[right.tone]) {
return toneRank[left.tone] - toneRank[right.tone];
}
return left.name.localeCompare(right.name);
});
const attentionSystems = attentionSystemIds.size;
const tone: DashboardEstateHealthTone =
offlineSystems > 0
? 'danger'
: attentionSystems > 0
? 'warning'
: activeSystems > 0
? 'healthy'
: 'muted';
const headline =
items.length === 0
? 'No infrastructure reporting'
: attentionSystems > 0
? `${pluralize(attentionSystems, 'system')} ${
attentionSystems === 1 ? 'needs' : 'need'
} attention`
: `${pluralize(activeSystems, 'system')} reporting`;
const statusParts: string[] = [];
if (healthySystems > 0) statusParts.push(`${healthySystems} online`);
if (degradedSystems > 0) statusParts.push(`${degradedSystems} degraded`);
if (offlineSystems > 0) statusParts.push(`${offlineSystems} offline`);
if (unknownSystems > 0) statusParts.push(`${unknownSystems} unknown`);
if (ignoredSystems > 0) statusParts.push(`${ignoredSystems} ignored`);
if (outdatedSystems > 0) statusParts.push(`${outdatedSystems} update available`);
return {
hasCanonicalProjection: true,
totalSystems: items.length,
activeSystems,
healthySystems,
degradedSystems,
offlineSystems,
unknownSystems,
ignoredSystems,
outdatedSystems,
attentionSystems,
latestSeen,
headline,
detail: statusParts.length > 0 ? statusParts.join(' · ') : 'Waiting for system status.',
tone,
surfaces: summarizeSurfaces(allSurfaces),
systems,
};
}

View file

@ -1,5 +1,6 @@
export { ActionRequiredPanel } from './ActionRequiredPanel';
export { DashboardCustomizer } from './DashboardCustomizer';
export { EstateSummaryPanel } from './EstateSummaryPanel';
export { KPIStrip } from './KPIStrip';
export { ProblemResourcesTable } from './ProblemResourcesTable';
export { TrendCharts } from './TrendCharts';

View file

@ -27,10 +27,12 @@ import type { Alert } from '@/types/api';
import {
ActionRequiredPanel,
DashboardCustomizer,
EstateSummaryPanel,
KPIStrip,
ProblemResourcesTable,
TrendCharts,
} from '@/features/dashboardOverview';
import { buildDashboardEstateSummary } from '@/features/dashboardOverview/estateSummaryModel';
import { RecentAlertsPanel } from '@/components/Alerts/RecentAlertsPanel';
import { DashboardRecoveryStatusPanel } from '@/components/Recovery/DashboardRecoveryStatusPanel';
import { DashboardStoragePanel } from '@/components/Storage/DashboardStoragePanel';
@ -38,7 +40,8 @@ import type { DashboardWidgetDef, DashboardWidgetId } from '@/features/dashboard
export default function Dashboard() {
const navigate = useNavigate();
const { connected, reconnecting, reconnect, activeAlerts } = useWebSocket();
const ws = useWebSocket();
const { connected, reconnecting, reconnect, activeAlerts } = ws;
const alertsList = createMemo<Alert[]>(() =>
Object.values(activeAlerts as Record<string, Alert | undefined>).filter(
@ -53,6 +56,15 @@ export default function Dashboard() {
const layout = useDashboardLayout();
const actions = useDashboardActions(alertsList);
const recoverySummary = useDashboardRecovery();
const connectedInfrastructure = createMemo(() =>
Array.isArray(ws.state.connectedInfrastructure) ? ws.state.connectedInfrastructure : [],
);
const estateSummary = createMemo(() =>
buildDashboardEstateSummary(connectedInfrastructure(), {
total: overview().infrastructure.total,
online: overview().infrastructure.byStatus.online ?? 0,
}),
);
// Loading timeout: if REST fetch takes >30s, treat as connection error.
const [loadingTimedOut, setLoadingTimedOut] = createSignal(false);
@ -285,11 +297,14 @@ export default function Dashboard() {
findingsNeedingAttention={actions.findingsNeedingAttention()}
/>
{/* 2. KPI Strip — always visible */}
{/* 2. Estate orientation — always visible once resources exist */}
<EstateSummaryPanel summary={estateSummary()} />
{/* 3. KPI Strip — always visible */}
<KPIStrip
infrastructure={{
total: overview().infrastructure.total,
online: overview().infrastructure.byStatus.online ?? 0,
total: estateSummary().totalSystems,
online: estateSummary().healthySystems,
}}
workloads={{
total: overview().workloads.total,
@ -307,10 +322,10 @@ export default function Dashboard() {
}}
/>
{/* 3. Problem Resources Table — only when problems exist */}
{/* 4. Problem Resources Table — only when problems exist */}
<ProblemResourcesTable problems={overview().problemResources} />
{/* 45. Customizable widgets: Trend Charts, Recent Alerts */}
{/* 56. Customizable widgets: Trend Charts, Recent Alerts */}
<For each={widgetGroups()}>
{(group) =>
group.type === 'full' ? (

View file

@ -9,6 +9,14 @@ let overviewLoading = false;
let overviewError: unknown = undefined;
let wsConnected = true;
let wsReconnecting = false;
const connectedInfrastructureMock: Array<{
id: string;
name: string;
status: 'active' | 'ignored';
healthStatus?: string;
lastSeen?: number;
surfaces: Array<{ id: string; kind: 'agent' | 'proxmox' | 'truenas'; label: string }>;
}> = [];
const reconnectSpy = vi.fn();
const navigateSpy = vi.hoisted(() => vi.fn());
const recoverySummaryMock: DashboardRecoverySummary = {
@ -55,7 +63,12 @@ const overviewMock: DashboardOverview = {
vi.mock('@/contexts/appRuntime', () => ({
useWebSocket: () => ({
state: { resources: [] },
state: {
resources: [],
get connectedInfrastructure() {
return connectedInfrastructureMock;
},
},
activeAlerts: {},
connected: () => wsConnected,
reconnecting: () => wsReconnecting,
@ -130,6 +143,7 @@ describe('Dashboard page module contract', () => {
overviewMock.storage.totalUsed = 0;
overviewMock.storage.warningCount = 0;
overviewMock.storage.criticalCount = 0;
connectedInfrastructureMock.length = 0;
});
it('exports a default component function', () => {
@ -148,7 +162,7 @@ describe('Dashboard page module contract', () => {
expect(dashboardPageSource).not.toContain("from '@/components/Dashboard/RelayOnboardingCard'");
expect(dashboardPageSource).not.toContain('<RelayOnboardingCard />');
expect(dashboardPageSource).toContain(
'ActionRequiredPanel,\n DashboardCustomizer,\n KPIStrip,\n ProblemResourcesTable,\n TrendCharts,',
'ActionRequiredPanel,\n DashboardCustomizer,\n EstateSummaryPanel,\n KPIStrip,\n ProblemResourcesTable,\n TrendCharts,',
);
});
@ -192,6 +206,8 @@ describe('Dashboard page module contract', () => {
it('renders the governed storage and recovery dashboard panels', () => {
overviewMock.health.totalResources = 4;
overviewMock.infrastructure.total = 4;
overviewMock.infrastructure.byStatus = { online: 4 };
overviewMock.storage.total = 4;
overviewMock.storage.totalCapacity = 4000;
overviewMock.storage.totalUsed = 2000;
@ -207,6 +223,64 @@ describe('Dashboard page module contract', () => {
expect(screen.getAllByText(/1\.95 KB \/ 3\.91 KB/i)).toHaveLength(2);
});
it('renders estate orientation before detailed dashboard problem rows', () => {
overviewMock.health.totalResources = 5;
overviewMock.infrastructure.total = 4;
overviewMock.infrastructure.byStatus = { online: 4 };
overviewMock.workloads.total = 9;
overviewMock.workloads.running = 7;
connectedInfrastructureMock.push(
{
id: 'homelab',
name: 'homelab',
status: 'active',
healthStatus: 'online',
lastSeen: Date.now(),
surfaces: [
{ id: 'agent:homelab', kind: 'agent', label: 'Host telemetry' },
{ id: 'proxmox:homelab', kind: 'proxmox', label: 'Proxmox VE data' },
],
},
{
id: 'nas',
name: 'nas',
status: 'active',
healthStatus: 'degraded',
lastSeen: Date.now(),
surfaces: [{ id: 'truenas:nas', kind: 'truenas', label: 'TrueNAS API data' }],
},
);
overviewMock.problemResources = [
{
resource: {
id: 'storage-1',
type: 'storage',
name: 'Storage 1',
displayName: 'Storage 1',
platformId: 'storage-1',
platformType: 'truenas',
sourceType: 'api',
status: 'offline',
} as DashboardOverview['problemResources'][number]['resource'],
problems: ['Offline'],
worstValue: 200,
},
];
render(() => <DashboardPage />);
const estateHeading = screen.getByRole('heading', { name: 'Connected infrastructure' });
const problemHeading = screen.getByRole('heading', { name: 'Problem Resources' });
expect(screen.getByText('1 system needs attention')).toBeInTheDocument();
expect(screen.getByText('2 active')).toBeInTheDocument();
expect(screen.getByText(/Proxmox/)).toBeInTheDocument();
expect(screen.getByText(/TrueNAS/)).toBeInTheDocument();
expect(
estateHeading.compareDocumentPosition(problemHeading) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
});
it('keeps the KPI strip above problem resources so the dashboard snapshot reads before detail', () => {
overviewMock.health.totalResources = 5;
overviewMock.infrastructure.total = 5;

View file

@ -0,0 +1,52 @@
import { expect, test, type Page } from '@playwright/test';
import { ensureAuthenticated, getMockMode, setMockMode } from './helpers';
const waitForDashboardEstateSummary = async (page: Page) => {
await expect(page).toHaveURL(/\/dashboard(?:\?.*)?$/);
await expect(page.getByTestId('dashboard-page')).toBeVisible();
await expect(page.getByTestId('dashboard-estate-summary')).toBeVisible();
await expect(page.getByRole('heading', { name: 'Connected infrastructure' })).toBeVisible();
};
test('dashboard first viewport preserves connected infrastructure orientation', 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-estate] unable to read/set mock mode, continuing: ${String(error)}`);
}
try {
await page.goto('/dashboard');
await waitForDashboardEstateSummary(page);
const estateSummary = page.getByTestId('dashboard-estate-summary');
await expect(estateSummary.getByRole('link', { name: 'View infrastructure' })).toBeVisible();
await expect(estateSummary.getByText(/systems? reporting|systems? need/)).toBeVisible();
await expect(estateSummary.getByText('Resource summary fallback')).toHaveCount(0);
const estateBox = await estateSummary.boundingBox();
const kpiBox = await page.getByTestId('dashboard-kpi-infrastructure').boundingBox();
expect(estateBox?.y ?? 0).toBeLessThan(kpiBox?.y ?? Number.POSITIVE_INFINITY);
const hasHorizontalOverflow = await page.evaluate(
() => document.documentElement.scrollWidth > document.documentElement.clientWidth + 1,
);
expect(hasHorizontalOverflow).toBe(false);
} finally {
if (initialMockMode && !initialMockMode.enabled) {
try {
await setMockMode(page, false);
} catch (error) {
console.warn(`[dashboard-estate] unable to restore mock mode, continuing: ${String(error)}`);
}
}
}
});