mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-04-28 03:20:11 +00:00
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:
parent
0dae66e9c7
commit
643db3f378
12 changed files with 707 additions and 10 deletions
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
||||
{/* 4–5. Customizable widgets: Trend Charts, Recent Alerts */}
|
||||
{/* 5–6. Customizable widgets: Trend Charts, Recent Alerts */}
|
||||
<For each={widgetGroups()}>
|
||||
{(group) =>
|
||||
group.type === 'full' ? (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue