mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
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:
@@ -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()}
|
||||
|
||||
+60
@@ -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: [],
|
||||
|
||||
@@ -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>
|
||||
|
||||
{/* 6–7. 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,
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user