Polish dashboard estate summary flow

Move the dashboard customizer into the header, combine Estate/KPI/Pulse Brief into a less repetitive first viewport, and keep fallback estate copy in system terms.

Group repeated problem-resource rows while preserving underlying counts and broad destination links, and rank structural alert warnings above state-only workload warnings.
This commit is contained in:
rcourtman
2026-04-26 00:00:01 +01:00
parent a2c101379a
commit 315aa9d81a
13 changed files with 523 additions and 176 deletions
@@ -479,6 +479,10 @@ route through that same alert overview presentation owner and the alert-owned
`frontend-modern/src/components/Alerts/RecentAlertsPanel.tsx` surface instead
of living as a dashboard-page-local panel plus a second dashboard-only alert
presentation helper.
That same dashboard recent-alert surface must rank structural health warnings
above state-only warning messages such as powered-off, stopped, or paused
workloads before falling back to recency, so chosen workload state does not
hide active infrastructure or storage problems on the first dashboard screen.
Alert threshold and schedule surfaces must now also treat
`discoveryTarget` as optional frontend input and keep grouping-card state on
@@ -1642,6 +1642,10 @@ 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.
That compact fallback must keep speaking in system terms. When the connected
projection has not arrived yet, estate orientation copy may say how many
systems are reporting or syncing, but it must not slide back to generic
resource-count language that blurs the v6 system model.
That first-viewport copy must distinguish system-level estate health from
resource, alert, storage, or recovery issues that remain elsewhere on the
dashboard, and partial/empty dashboard states must describe synchronization or
@@ -1755,6 +1759,11 @@ recovery data warms.
that same dashboard overview boundary so the problem-resource severity contract
stays shared with `ProblemResourcesTable.tsx` instead of floating as an
unowned helper.
Problem-resource table readability belongs to that same owner. Repeated rows
may collapse only when they share the same governed display label, resource
type, and problem signal; the header count and Pulse Brief counts must continue
to represent the underlying affected resources, and grouped links must route to
the broad owning surface rather than inventing a synthetic resource target.
That same dashboard overview boundary must consume the governed Patrol finding
presentation helpers when it surfaces Patrol findings in compact form. In
`frontend-modern/src/features/dashboardOverview/ActionRequiredPanel.tsx`,
@@ -21,9 +21,33 @@ interface RecentAlertsPanelProps {
alerts: Alert[];
}
function sortByStartTimeDesc(alerts: Alert[]): Alert[] {
// "State-only" alerts describe a state the operator typically chose (a VM that
// is stopped, a container that is powered off). Mixed alongside structural
// health signals like "ZFS device /dev/sda4 has errors" they drown out the
// signal that actually requires attention. We still surface them below
// structural warnings of the same level so the first screen leads with real
// problems.
const STATE_ONLY_MESSAGE_PATTERNS: RegExp[] = [/is powered off/i, /is stopped/i, /is paused/i];
function isStateOnlyAlert(alert: Alert): boolean {
const message = alert.message ?? '';
return STATE_ONLY_MESSAGE_PATTERNS.some((pattern) => pattern.test(message));
}
function alertRank(alert: Alert): number {
// Lower rank = higher priority in the list.
if (alert.level === 'critical') return 0;
if (alert.level === 'warning') {
return isStateOnlyAlert(alert) ? 3 : 1;
}
return 2;
}
function sortAlertsForDashboard(alerts: Alert[]): Alert[] {
const sorted = [...alerts];
sorted.sort((a, b) => {
const rankDelta = alertRank(a) - alertRank(b);
if (rankDelta !== 0) return rankDelta;
const aMs = Date.parse(a.startTime) || 0;
const bMs = Date.parse(b.startTime) || 0;
return bMs - aMs;
@@ -42,7 +66,7 @@ export function RecentAlertsPanel(props: RecentAlertsPanelProps) {
} = useAlertAcknowledgementState({
alerts: () => props.alerts,
});
const recent = createMemo(() => sortByStartTimeDesc(effectiveAlerts()).slice(0, MAX_SHOWN));
const recent = createMemo(() => sortAlertsForDashboard(effectiveAlerts()).slice(0, MAX_SHOWN));
const activeCriticalCount = createMemo(
() =>
effectiveAlerts().filter((alert) => !alert.acknowledged && alert.level === 'critical').length,
@@ -65,11 +65,43 @@ describe('RecentAlertsPanel', () => {
expect(screen.getByText('No active alerts')).toBeInTheDocument();
});
it('keeps structural warnings above state-only warnings of the same severity', () => {
render(() => (
<RecentAlertsPanel
alerts={[
makeAlert({
id: 'state-only',
level: 'warning',
resourceName: 'tails-anon',
message: "VM 'tails-anon' is powered off",
startTime: '2026-03-08T12:00:00Z',
}),
makeAlert({
id: 'structural',
level: 'warning',
resourceName: 'local-zfs',
message: 'ZFS device /dev/sda4 has errors',
startTime: '2026-03-08T09:00:00Z',
}),
]}
/>
));
const structuralWarning = screen.getByText('ZFS device /dev/sda4 has errors');
const stateOnlyWarning = screen.getByText("VM 'tails-anon' is powered off");
expect(structuralWarning.compareDocumentPosition(stateOnlyWarning)).toBe(
Node.DOCUMENT_POSITION_FOLLOWING,
);
});
it('routes single acknowledge actions through the shared alert acknowledgement owner', async () => {
vi.mocked(AlertsAPI.acknowledge).mockResolvedValue(undefined as never);
render(() => (
<RecentAlertsPanel alerts={[makeAlert(), makeAlert({ id: 'alert-2', message: 'Memory high' })]} />
<RecentAlertsPanel
alerts={[makeAlert(), makeAlert({ id: 'alert-2', message: 'Memory high' })]}
/>
));
await fireEvent.click(screen.getAllByText('Ack')[0]);
@@ -89,7 +121,9 @@ describe('RecentAlertsPanel', () => {
} as never);
render(() => (
<RecentAlertsPanel alerts={[makeAlert(), makeAlert({ id: 'alert-2', message: 'Memory high' })]} />
<RecentAlertsPanel
alerts={[makeAlert(), makeAlert({ id: 'alert-2', message: 'Memory high' })]}
/>
));
await fireEvent.click(screen.getByText('Ack All'));
@@ -1,3 +1,4 @@
import { Show } from 'solid-js';
import { Card } from '@/components/shared/Card';
import {
ALERTS_OVERVIEW_PATH,
@@ -10,14 +11,18 @@ import { getDashboardKpiPresentation } from '@/utils/dashboardKpiPresentation';
import { getAlertSeverityTextClass } from '@/utils/alertSeverityPresentation';
import { formatBytes } from '@/utils/format';
export type KPIStripCardId = 'infrastructure' | 'workloads' | 'storage' | 'alerts';
interface KPIStripProps {
infrastructure: {
total: number;
online: number;
attention?: number;
};
workloads: {
total: number;
running: number;
stopped?: number;
};
storage: {
capacityPercent: number;
@@ -29,6 +34,9 @@ interface KPIStripProps {
activeWarning: number;
total: number;
};
// Cards to hide. Use when another panel on the same page already carries the
// same datum (e.g. Pulse Brief + Estate cover Infrastructure + Alerts counts).
exclude?: KPIStripCardId[];
}
export function KPIStrip(props: KPIStripProps) {
@@ -41,116 +49,147 @@ export function KPIStrip(props: KPIStripProps) {
const StorageIcon = storagePresentation.icon;
const AlertsIcon = alertsPresentation.icon;
const isVisible = (id: KPIStripCardId) => !props.exclude?.includes(id);
const visibleCount = () =>
(['infrastructure', 'workloads', 'storage', 'alerts'] as const).filter(isVisible).length;
const gridCols = () => {
const count = visibleCount();
if (count <= 1) return 'grid-cols-1';
if (count === 2) return 'grid-cols-2';
if (count === 3) return 'grid-cols-1 sm:grid-cols-3';
return 'grid-cols-2 lg:grid-cols-4';
};
return (
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3">
<a href={INFRASTRUCTURE_PATH} class="group block" data-testid="dashboard-kpi-infrastructure">
<Card
hoverable
padding="none"
class={infrastructurePresentation.cardClassName}
<div class={`grid gap-3 ${gridCols()}`}>
<Show when={isVisible('infrastructure')}>
<a
href={INFRASTRUCTURE_PATH}
class="group block"
data-testid="dashboard-kpi-infrastructure"
>
<div class="px-3.5 py-3">
<div class="flex items-center justify-between">
<p class="text-[11px] font-medium text-muted uppercase tracking-wide">
{infrastructurePresentation.label}
<Card hoverable padding="none" class={infrastructurePresentation.cardClassName}>
<div class="px-3.5 py-3">
<div class="flex items-center justify-between">
<p class="text-[11px] font-medium text-muted uppercase tracking-wide">
{infrastructurePresentation.label}
</p>
<InfrastructureIcon
class={infrastructurePresentation.iconClassName}
aria-hidden="true"
/>
</div>
<p class="text-2xl font-mono font-semibold text-base-content mt-1">
{props.infrastructure.total}
</p>
<p class="text-xs text-muted mt-0.5">
<span class="font-mono font-medium text-emerald-600 dark:text-emerald-400">
{props.infrastructure.online}
</span>{' '}
online
{props.infrastructure.attention !== undefined &&
props.infrastructure.attention > 0 ? (
<>
{' · '}
<span class="font-mono font-medium text-amber-600 dark:text-amber-400">
{props.infrastructure.attention}
</span>{' '}
attention
</>
) : null}
</p>
<InfrastructureIcon class={infrastructurePresentation.iconClassName} aria-hidden="true" />
</div>
<p class="text-2xl font-mono font-semibold text-base-content mt-1">
{props.infrastructure.total}
</p>
<p class="text-xs text-muted mt-0.5">
<span class="font-mono font-medium text-emerald-600 dark:text-emerald-400">
{props.infrastructure.online}
</span>{' '}
online
</p>
</div>
</Card>
</a>
</Card>
</a>
</Show>
<a href={WORKLOADS_PATH} class="group block">
<Card
hoverable
padding="none"
class={workloadsPresentation.cardClassName}
>
<div class="px-3.5 py-3">
<div class="flex items-center justify-between">
<p class="text-[11px] font-medium text-muted uppercase tracking-wide">
{workloadsPresentation.label}
<Show when={isVisible('workloads')}>
<a href={WORKLOADS_PATH} class="group block" data-testid="dashboard-kpi-workloads">
<Card hoverable padding="none" class={workloadsPresentation.cardClassName}>
<div class="px-3.5 py-3">
<div class="flex items-center justify-between">
<p class="text-[11px] font-medium text-muted uppercase tracking-wide">
{workloadsPresentation.label}
</p>
<WorkloadsIcon class={workloadsPresentation.iconClassName} aria-hidden="true" />
</div>
<p class="text-2xl font-mono font-semibold text-base-content mt-1">
{props.workloads.total}
</p>
<p class="text-[11px] text-muted mt-0.5">{workloadsPresentation.supportingText}</p>
<p class="text-xs text-muted mt-0.5">
<span class="font-mono font-medium text-emerald-600 dark:text-emerald-400">
{props.workloads.running}
</span>{' '}
running
{props.workloads.stopped !== undefined && props.workloads.stopped > 0 ? (
<>
{' · '}
<span class="font-mono font-medium text-base-content">
{props.workloads.stopped}
</span>{' '}
stopped
</>
) : null}
</p>
<WorkloadsIcon class={workloadsPresentation.iconClassName} aria-hidden="true" />
</div>
<p class="text-2xl font-mono font-semibold text-base-content mt-1">
{props.workloads.total}
</p>
<p class="text-[11px] text-muted mt-0.5">
{workloadsPresentation.supportingText}
</p>
<p class="text-xs text-muted mt-0.5">
<span class="font-mono font-medium text-emerald-600 dark:text-emerald-400">
{props.workloads.running}
</span>{' '}
running
</p>
</div>
</Card>
</a>
</Card>
</a>
</Show>
<a href={buildStoragePath()} class="group block">
<Card
hoverable
padding="none"
class={storagePresentation.cardClassName}
>
<div class="px-3.5 py-3">
<div class="flex items-center justify-between">
<p class="text-[11px] font-medium text-muted uppercase tracking-wide">
{storagePresentation.label}
<Show when={isVisible('storage')}>
<a href={buildStoragePath()} class="group block" data-testid="dashboard-kpi-storage">
<Card hoverable padding="none" class={storagePresentation.cardClassName}>
<div class="px-3.5 py-3">
<div class="flex items-center justify-between">
<p class="text-[11px] font-medium text-muted uppercase tracking-wide">
{storagePresentation.label}
</p>
<StorageIcon class={storagePresentation.iconClassName} aria-hidden="true" />
</div>
<p class="text-2xl font-mono font-semibold text-base-content mt-1">
{Math.round(props.storage.capacityPercent)}%
</p>
<p class="text-xs text-muted mt-0.5">
{formatBytes(props.storage.totalUsed)} / {formatBytes(props.storage.totalCapacity)}
</p>
<StorageIcon class={storagePresentation.iconClassName} aria-hidden="true" />
</div>
<p class="text-2xl font-mono font-semibold text-base-content mt-1">
{Math.round(props.storage.capacityPercent)}%
</p>
<p class="text-xs text-muted mt-0.5">
{formatBytes(props.storage.totalUsed)} / {formatBytes(props.storage.totalCapacity)}
</p>
</div>
</Card>
</a>
</Card>
</a>
</Show>
<a href={ALERTS_OVERVIEW_PATH} class="group block">
<Card
hoverable
tone={getDashboardAlertTone(props.alerts)}
padding="none"
class={alertsPresentation.cardClassName}
>
<div class="px-3.5 py-3">
<div class="flex items-center justify-between">
<p class="text-[11px] font-medium text-muted uppercase tracking-wide">
{alertsPresentation.label}
<Show when={isVisible('alerts')}>
<a href={ALERTS_OVERVIEW_PATH} class="group block" data-testid="dashboard-kpi-alerts">
<Card
hoverable
tone={getDashboardAlertTone(props.alerts)}
padding="none"
class={alertsPresentation.cardClassName}
>
<div class="px-3.5 py-3">
<div class="flex items-center justify-between">
<p class="text-[11px] font-medium text-muted uppercase tracking-wide">
{alertsPresentation.label}
</p>
<AlertsIcon class={alertsPresentation.iconClassName} aria-hidden="true" />
</div>
<p class="text-2xl font-mono font-semibold text-base-content mt-1">
{props.alerts.total}
</p>
<p class="text-xs text-muted mt-0.5">
<span class={`font-mono font-medium ${getAlertSeverityTextClass('critical')}`}>
{props.alerts.activeCritical}
</span>{' '}
critical ·{' '}
<span class={`font-mono font-medium ${getAlertSeverityTextClass('warning')}`}>
{props.alerts.activeWarning}
</span>{' '}
warning
</p>
<AlertsIcon class={alertsPresentation.iconClassName} aria-hidden="true" />
</div>
<p class="text-2xl font-mono font-semibold text-base-content mt-1">
{props.alerts.total}
</p>
<p class="text-xs text-muted mt-0.5">
<span class={`font-mono font-medium ${getAlertSeverityTextClass('critical')}`}>
{props.alerts.activeCritical}
</span>{' '}
critical ·{' '}
<span class={`font-mono font-medium ${getAlertSeverityTextClass('warning')}`}>
{props.alerts.activeWarning}
</span>{' '}
warning
</p>
</div>
</Card>
</a>
</Card>
</a>
</Show>
</div>
);
}
@@ -1,4 +1,4 @@
import { For, Show } from 'solid-js';
import { createMemo, For, Show } from 'solid-js';
import { Card } from '@/components/shared/Card';
import { StatusDot } from '@/components/shared/StatusDot';
import {
@@ -28,6 +28,15 @@ interface ProblemResourcesTableProps {
problems: ProblemResource[];
}
interface ProblemResourceGroup {
representative: ProblemResource;
resources: ProblemResource[];
displayName: string;
typeLabel: string;
worstValue: number;
memberDisplayNames: string[];
}
function resourceLink(pr: ProblemResource): string {
if (isInfrastructure(pr.resource)) {
return buildInfrastructureResourceHref(pr.resource.id) ?? INFRASTRUCTURE_PATH;
@@ -38,7 +47,74 @@ function resourceLink(pr: ProblemResource): string {
return buildWorkloadsPath({ resource: pr.resource.id });
}
function normalizeGroupValue(value: string): string {
return value.trim().toLowerCase();
}
function problemResourceDisplayName(pr: ProblemResource): string {
return getPreferredResourceDisplayName(pr.resource) || pr.resource.id;
}
function problemGroupKey(pr: ProblemResource): string {
const displayName = problemResourceDisplayName(pr);
const problems = pr.problems.map(normalizeGroupValue).sort().join('|');
return [normalizeGroupValue(displayName), pr.resource.type, problems].join('::');
}
function problemResourceGroups(problems: ProblemResource[]): ProblemResourceGroup[] {
const groups = new Map<string, ProblemResourceGroup>();
for (const problem of problems) {
const key = problemGroupKey(problem);
const existing = groups.get(key);
if (existing) {
existing.resources.push(problem);
existing.worstValue = Math.max(existing.worstValue, problem.worstValue);
const memberName = problemResourceDisplayName(problem);
if (!existing.memberDisplayNames.includes(memberName)) {
existing.memberDisplayNames.push(memberName);
}
continue;
}
const displayName = problemResourceDisplayName(problem);
groups.set(key, {
representative: problem,
resources: [problem],
displayName,
typeLabel: getResourceTypeLabel(problem.resource.type) || problem.resource.type,
worstValue: problem.worstValue,
memberDisplayNames: [displayName || problem.resource.id],
});
}
return Array.from(groups.values());
}
function groupedResourceLink(group: ProblemResourceGroup): string {
if (group.resources.length === 1) {
return resourceLink(group.representative);
}
if (group.resources.every((problem) => isStorage(problem.resource))) {
return buildStoragePath();
}
if (group.resources.every((problem) => isInfrastructure(problem.resource))) {
return INFRASTRUCTURE_PATH;
}
return buildWorkloadsPath({ type: group.representative.resource.type });
}
function pluralizeTypeLabel(count: number, label: string): string {
const normalized = (label || 'resource').trim().toLowerCase();
if (count === 1) return normalized;
if (normalized.endsWith('s')) return normalized;
if (normalized.endsWith('y')) return `${normalized.slice(0, -1)}ies`;
return `${normalized}s`;
}
export function ProblemResourcesTable(props: ProblemResourcesTableProps) {
const groupedProblems = createMemo(() => problemResourceGroups(props.problems));
return (
<Show when={props.problems.length > 0}>
<Card padding="none" tone="default" class="overflow-hidden">
@@ -65,33 +141,51 @@ export function ProblemResourcesTable(props: ProblemResourcesTableProps) {
</TableRow>
</TableHeader>
<TableBody>
<For each={props.problems}>
{(pr) => (
<For each={groupedProblems()}>
{(group) => (
<TableRow>
<TableCell>
<StatusDot
variant={getProblemResourceStatusVariant(pr.worstValue)}
variant={getProblemResourceStatusVariant(group.worstValue)}
size="sm"
pulse={pr.worstValue >= 200}
pulse={group.worstValue >= 200}
/>
</TableCell>
<TableCell>
<a
href={resourceLink(pr)}
class="text-xs font-medium text-base-content hover:underline truncate block max-w-[200px]"
title={getPreferredResourceDisplayName(pr.resource)}
<Show
when={group.resources.length > 1}
fallback={
<a
href={groupedResourceLink(group)}
class="text-xs font-medium text-base-content hover:underline truncate block max-w-[200px]"
title={group.displayName}
>
{group.displayName}
</a>
}
>
{getPreferredResourceDisplayName(pr.resource)}
</a>
<a
href={groupedResourceLink(group)}
class="text-xs font-medium text-base-content hover:underline truncate block max-w-[200px]"
title={group.memberDisplayNames.join(', ')}
>
{group.resources.length}{' '}
{pluralizeTypeLabel(group.resources.length, group.typeLabel)}
</a>
<span
class="mt-0.5 block text-[10px] text-muted truncate max-w-[220px]"
title={group.memberDisplayNames.join(', ')}
>
{group.memberDisplayNames.join(', ')}
</span>
</Show>
</TableCell>
<TableCell class="hidden sm:table-cell">
<span class="text-xs text-muted">
{getResourceTypeLabel(pr.resource.type) || pr.resource.type}
</span>
<span class="text-xs text-muted">{group.typeLabel}</span>
</TableCell>
<TableCell>
<div class="flex items-center gap-1.5 flex-wrap">
<For each={pr.problems}>
<For each={group.representative.problems}>
{(problem) => {
const indicator = getSimpleStatusIndicator(problem);
return (
@@ -9,6 +9,13 @@ import MessageCircleIcon from 'lucide-solid/icons/message-circle';
interface PulseBriefPanelProps {
brief: DashboardPulseBrief;
onAskAssistant: () => void;
/**
* When true, the panel stacks its content, actions, and evidence vertically
* instead of flowing the actions to the right of the narrative. Use when
* placing the Brief in a narrow column alongside another header card.
*/
compact?: boolean;
class?: string;
}
const toneClass: Record<DashboardPulseBriefTone, string> = {
@@ -27,10 +34,14 @@ export function PulseBriefPanel(props: PulseBriefPanelProps) {
return (
<Card
padding="none"
class={`overflow-hidden border-l-[3px] ${toneClass[props.brief.tone]}`}
class={`overflow-hidden border-l-[3px] ${toneClass[props.brief.tone]} ${props.class ?? ''}`.trim()}
data-testid="dashboard-pulse-brief"
>
<div class="flex flex-col gap-3 px-4 py-3 lg:flex-row lg:items-start lg:justify-between">
<div
class={`flex h-full flex-col gap-3 px-4 py-3 ${
props.compact ? '' : 'lg:flex-row lg:items-start lg:justify-between'
}`}
>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<span class="inline-flex h-7 w-7 items-center justify-center rounded-md bg-cyan-50 text-cyan-700 dark:bg-cyan-900/40 dark:text-cyan-300">
@@ -49,7 +60,9 @@ export function PulseBriefPanel(props: PulseBriefPanelProps) {
</span>
</div>
<p class="mt-2 max-w-5xl text-sm leading-6 text-base-content">{props.brief.body}</p>
<p class={`mt-2 text-sm leading-6 text-base-content ${props.compact ? '' : 'max-w-5xl'}`}>
{props.brief.body}
</p>
<div class="mt-2 flex flex-wrap gap-1.5">
<For each={props.brief.evidence}>
@@ -62,7 +75,11 @@ export function PulseBriefPanel(props: PulseBriefPanelProps) {
</div>
</div>
<div class="flex shrink-0 flex-wrap items-center gap-2 lg:justify-end">
<div
class={`flex shrink-0 flex-wrap items-center gap-2 ${
props.compact ? '' : 'lg:justify-end'
}`}
>
<button
type="button"
onClick={() => props.onAskAssistant()}
@@ -55,6 +55,66 @@ describe('ProblemResourcesTable', () => {
expect(storageLink.getAttribute('href')).toBe('/storage');
});
it('groups repeated resource labels with the same type and problem signal', () => {
render(() => (
<ProblemResourcesTable
problems={['one', 'two', 'three'].map((id) => ({
resource: makeResource({
id: `container-${id}`,
type: 'system-container',
name: `container-${id}`,
displayName: 'Duplicate Container',
}),
problems: ['Offline'],
worstValue: 200,
}))}
/>
));
// Grouped row shows count-led primary label linking to the overflow page.
const resourceLink = screen.getByText('3 containers');
expect(resourceLink.tagName).toBe('A');
expect(resourceLink.getAttribute('href')).toBe('/workloads?type=system-container');
// The individual member display name is shown on the sub-line so the user
// can see what the group actually is. Duplicate names collapse to one.
const memberLine = screen.getByText('Duplicate Container');
expect(memberLine.tagName).toBe('SPAN');
expect(screen.getAllByText('Offline')).toHaveLength(1);
});
it('lists distinct member names on the sub-line when they differ', () => {
render(() => (
<ProblemResourcesTable
problems={[
{
resource: makeResource({
id: 'storage-delly',
type: 'storage',
name: 'storage-delly',
displayName: 'storage',
}),
problems: ['Offline'],
worstValue: 200,
},
{
resource: makeResource({
id: 'storage-minipc',
type: 'storage',
name: 'storage-minipc',
displayName: 'storage',
}),
problems: ['Offline'],
worstValue: 200,
},
]}
/>
));
// Two storages with the same displayName group together; the primary
// reads "2 storages" instead of collapsing to "storage".
expect(screen.getByText('2 storages')).toBeInTheDocument();
});
it('renders governed labels for policy-aware problem resources', () => {
render(() => (
<ProblemResourcesTable
@@ -63,8 +63,8 @@ describe('buildDashboardEstateSummary', () => {
expect(summary.totalSystems).toBe(4);
expect(summary.healthySystems).toBe(3);
expect(summary.unknownSystems).toBe(1);
expect(summary.headline).toBe('4 resources reporting');
expect(summary.detail).toBe('3 resources online while the system map syncs');
expect(summary.headline).toBe('4 systems reporting');
expect(summary.detail).toBe('3 systems online while the system map syncs');
});
it('counts each active system needing attention once', () => {
@@ -95,7 +95,7 @@ function buildBody(input: DashboardPulseBriefInput, attentionParts: string[]): s
const review =
topProblem !== null
? `Review ${topProblem} first because it is the strongest resource-level signal.`
? `Review ${topProblem} first; it is the top-ranked problem resource.`
: criticalAlerts > 0
? `Start with ${pluralize(criticalAlerts, 'critical alert')} before reviewing lower-severity work.`
: activeAlerts > 0
@@ -118,12 +118,12 @@ function buildFallbackSummary(fallback: DashboardEstateFallback): DashboardEstat
outdatedSystems: 0,
attentionSystems: unknown,
headline:
total === 0 ? 'No infrastructure reporting' : `${pluralize(total, 'resource')} reporting`,
total === 0 ? 'No infrastructure reporting' : `${pluralize(total, 'system')} reporting`,
detail:
total === 0
? 'Connected systems appear here after the first infrastructure source reports.'
: unknown > 0
? `${pluralize(healthy, 'resource')} online while the system map syncs`
? `${pluralize(healthy, 'system')} online while the system map syncs`
: 'System map still syncing',
tone,
surfaces: [],
+104 -49
View File
@@ -225,9 +225,22 @@ export default function Dashboard() {
<PageHeader
title="Dashboard"
description="Track infrastructure health, active risks, storage pressure, and recovery readiness from one overview."
actions={
<Show when={initialLoadComplete() && hasCachedData() && !hasConnectionError()}>
<DashboardCustomizer
allWidgets={layout.allWidgetsOrdered}
isHidden={layout.isHidden}
toggleWidget={layout.toggleWidget}
moveUp={layout.moveUp}
moveDown={layout.moveDown}
resetToDefaults={layout.resetToDefaults}
isDefault={layout.isDefault}
/>
</Show>
}
/>
{/* Connection warning banner shown above all content, NOT a full-page takeover */}
{/* Connection warning banner: shown above all content, NOT a full-page takeover */}
<Show when={hasConnectionError() && initialLoadComplete()}>
<div
class="flex items-center justify-between gap-3 rounded-md border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-950 px-4 py-2.5"
@@ -330,50 +343,103 @@ export default function Dashboard() {
<Match when={initialLoadComplete() && hasCachedData()}>
<section class="space-y-5">
{/* 1. Action Required Panel only when actions exist */}
{/* 1. Action Required Panel: only when actions exist */}
<ActionRequiredPanel
pendingApprovals={actions.pendingApprovals()}
unackedCriticalAlerts={actions.unackedCriticalAlerts()}
findingsNeedingAttention={actions.findingsNeedingAttention()}
/>
{/* 2. Estate orientation — always visible once resources exist */}
<EstateSummaryPanel
summary={estateSummary()}
resourceIssueCount={overview().problemResources.length}
activeAlertCount={overview().alerts.total}
/>
{/* 3. Optional Pulse Brief — shown only when Assistant and Patrol are configured */}
<Show when={pulseBrief()}>
{/* 2+3+4. Estate orientation, KPI snapshot, and optional Pulse Brief.
When the AI brief is configured, Estate + KPI become the left
"snapshot" column and Brief becomes the right-rail companion so
the three stacked summary layers collapse into one horizontal
band with balanced column heights. */}
<Show
when={pulseBrief()}
fallback={
<>
<EstateSummaryPanel
summary={estateSummary()}
resourceIssueCount={overview().problemResources.length}
activeAlertCount={overview().alerts.total}
/>
<KPIStrip
infrastructure={{
total: estateSummary().totalSystems,
online: estateSummary().healthySystems,
attention: estateSummary().attentionSystems,
}}
workloads={{
total: overview().workloads.total,
running: overview().workloads.running,
stopped: overview().workloads.stopped,
}}
storage={{
capacityPercent: storageCapacityPercent(),
totalUsed: overview().storage.totalUsed,
totalCapacity: overview().storage.totalCapacity,
}}
alerts={{
activeCritical: overview().alerts.activeCritical,
activeWarning: overview().alerts.activeWarning,
total: overview().alerts.total,
}}
/>
</>
}
>
{(brief) => (
<PulseBriefPanel brief={brief()} onAskAssistant={openPulseBriefAssistant} />
<div class="grid grid-cols-1 lg:grid-cols-3 gap-5 items-stretch">
<div class="lg:col-span-2 min-w-0 space-y-5">
<EstateSummaryPanel
summary={estateSummary()}
resourceIssueCount={overview().problemResources.length}
activeAlertCount={overview().alerts.total}
/>
{/* When the Brief is present, Estate already covers
infrastructure counts and the Alerts panel header below
covers alert counts, so hide the infrastructure and
alerts KPI cards to avoid showing the same datum three
times on the first screen. Workloads + Storage stay
because their details live further down the page. */}
<KPIStrip
exclude={['infrastructure', 'alerts']}
infrastructure={{
total: estateSummary().totalSystems,
online: estateSummary().healthySystems,
attention: estateSummary().attentionSystems,
}}
workloads={{
total: overview().workloads.total,
running: overview().workloads.running,
stopped: overview().workloads.stopped,
}}
storage={{
capacityPercent: storageCapacityPercent(),
totalUsed: overview().storage.totalUsed,
totalCapacity: overview().storage.totalCapacity,
}}
alerts={{
activeCritical: overview().alerts.activeCritical,
activeWarning: overview().alerts.activeWarning,
total: overview().alerts.total,
}}
/>
</div>
<div class="lg:col-span-1 min-w-0 flex">
<PulseBriefPanel
brief={brief()}
onAskAssistant={openPulseBriefAssistant}
compact
class="flex-1"
/>
</div>
</div>
)}
</Show>
{/* 4. KPI Strip — always visible */}
<KPIStrip
infrastructure={{
total: estateSummary().totalSystems,
online: estateSummary().healthySystems,
}}
workloads={{
total: overview().workloads.total,
running: overview().workloads.running,
}}
storage={{
capacityPercent: storageCapacityPercent(),
totalUsed: overview().storage.totalUsed,
totalCapacity: overview().storage.totalCapacity,
}}
alerts={{
activeCritical: overview().alerts.activeCritical,
activeWarning: overview().alerts.activeWarning,
total: overview().alerts.total,
}}
/>
{/* 5. Problem Resources Table — only when problems exist */}
{/* 5. Problem Resources Table: only when problems exist */}
<Show when={overview().problemResources.length > 0}>
<section
id={DASHBOARD_PROBLEM_RESOURCES_SECTION_ID}
@@ -385,7 +451,9 @@ export default function Dashboard() {
</section>
</Show>
{/* 67. Customizable widgets: Trend Charts, Recent Alerts */}
{/* 6-7. Customizable widgets: Trend Charts, Recent Alerts.
The Customize control itself lives in the PageHeader actions
slot above so layout controls stay near the page title. */}
<For each={widgetGroups()}>
{(group) =>
group.type === 'full' ? (
@@ -397,19 +465,6 @@ export default function Dashboard() {
)
}
</For>
{/* Customize button at the bottom-right of widget area */}
<div class="flex justify-end">
<DashboardCustomizer
allWidgets={layout.allWidgetsOrdered}
isHidden={layout.isHidden}
toggleWidget={layout.toggleWidget}
moveUp={layout.moveUp}
moveDown={layout.moveDown}
resetToDefaults={layout.resetToDefaults}
isDefault={layout.isDefault}
/>
</div>
</section>
</Match>
</Switch>
@@ -446,7 +446,6 @@ describe('Dashboard page module contract', () => {
const brief = screen.getByTestId('dashboard-pulse-brief');
const estateHeading = screen.getByRole('heading', { name: 'Connected infrastructure' });
const kpiLabel = screen.getByText('Infrastructure');
expect(brief).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Pulse Brief' })).toBeInTheDocument();
@@ -454,7 +453,19 @@ describe('Dashboard page module contract', () => {
expect(estateHeading.compareDocumentPosition(brief) & Node.DOCUMENT_POSITION_FOLLOWING).toBe(
Node.DOCUMENT_POSITION_FOLLOWING,
);
expect(brief.compareDocumentPosition(kpiLabel) & Node.DOCUMENT_POSITION_FOLLOWING).toBe(
// When Brief is shown, Estate already carries the infrastructure count
// and the Alerts panel header carries the alert count, so those two KPI
// cards are suppressed to avoid triple-counting the same signal on the
// first screen. Workloads + Storage stay because their details live
// further down the page.
expect(screen.queryByTestId('dashboard-kpi-infrastructure')).toBeNull();
expect(screen.queryByTestId('dashboard-kpi-alerts')).toBeNull();
const workloadsKpi = screen.getByTestId('dashboard-kpi-workloads');
expect(workloadsKpi).toBeInTheDocument();
expect(screen.getByTestId('dashboard-kpi-storage')).toBeInTheDocument();
// Snapshot column (Estate + trimmed KPI) still precedes Brief in DOM
// order so screen readers read concrete counts before the narrative.
expect(workloadsKpi.compareDocumentPosition(brief) & Node.DOCUMENT_POSITION_FOLLOWING).toBe(
Node.DOCUMENT_POSITION_FOLLOWING,
);