diff --git a/docs/release-control/v6/internal/subsystems/alerts.md b/docs/release-control/v6/internal/subsystems/alerts.md index 14bc5f9f6..b15e3ee7d 100644 --- a/docs/release-control/v6/internal/subsystems/alerts.md +++ b/docs/release-control/v6/internal/subsystems/alerts.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 8a1b3002f..e26bda297 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -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`, diff --git a/frontend-modern/src/components/Alerts/RecentAlertsPanel.tsx b/frontend-modern/src/components/Alerts/RecentAlertsPanel.tsx index f2c03e6fc..c5b0e76b5 100644 --- a/frontend-modern/src/components/Alerts/RecentAlertsPanel.tsx +++ b/frontend-modern/src/components/Alerts/RecentAlertsPanel.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, diff --git a/frontend-modern/src/components/Alerts/__tests__/RecentAlertsPanel.test.tsx b/frontend-modern/src/components/Alerts/__tests__/RecentAlertsPanel.test.tsx index 4aec8acff..295a2184f 100644 --- a/frontend-modern/src/components/Alerts/__tests__/RecentAlertsPanel.test.tsx +++ b/frontend-modern/src/components/Alerts/__tests__/RecentAlertsPanel.test.tsx @@ -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(() => ( + + )); + + 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(() => ( - + )); await fireEvent.click(screen.getAllByText('Ack')[0]); @@ -89,7 +121,9 @@ describe('RecentAlertsPanel', () => { } as never); render(() => ( - + )); await fireEvent.click(screen.getByText('Ack All')); diff --git a/frontend-modern/src/features/dashboardOverview/KPIStrip.tsx b/frontend-modern/src/features/dashboardOverview/KPIStrip.tsx index fc38548de..5c86bdc1c 100644 --- a/frontend-modern/src/features/dashboardOverview/KPIStrip.tsx +++ b/frontend-modern/src/features/dashboardOverview/KPIStrip.tsx @@ -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 ( -
- - + + -
-
-

- {infrastructurePresentation.label} + +

+
+

+ {infrastructurePresentation.label} +

+
+

+ {props.infrastructure.total} +

+

+ + {props.infrastructure.online} + {' '} + online + {props.infrastructure.attention !== undefined && + props.infrastructure.attention > 0 ? ( + <> + {' · '} + + {props.infrastructure.attention} + {' '} + attention + + ) : null}

-
-

- {props.infrastructure.total} -

-

- - {props.infrastructure.online} - {' '} - online -

-
- -
+ + + - - -
- - - + + + - - -
- - - + + + - - - ); } diff --git a/frontend-modern/src/features/dashboardOverview/ProblemResourcesTable.tsx b/frontend-modern/src/features/dashboardOverview/ProblemResourcesTable.tsx index c72e77cda..ec68c45bb 100644 --- a/frontend-modern/src/features/dashboardOverview/ProblemResourcesTable.tsx +++ b/frontend-modern/src/features/dashboardOverview/ProblemResourcesTable.tsx @@ -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(); + + 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 ( 0}> @@ -65,33 +141,51 @@ export function ProblemResourcesTable(props: ProblemResourcesTableProps) { - - {(pr) => ( + + {(group) => ( = 200} + pulse={group.worstValue >= 200} /> - 1} + fallback={ + + {group.displayName} + + } > - {getPreferredResourceDisplayName(pr.resource)} - + + {group.resources.length}{' '} + {pluralizeTypeLabel(group.resources.length, group.typeLabel)} + + + {group.memberDisplayNames.join(', ')} + +
- + {(problem) => { const indicator = getSimpleStatusIndicator(problem); return ( diff --git a/frontend-modern/src/features/dashboardOverview/PulseBriefPanel.tsx b/frontend-modern/src/features/dashboardOverview/PulseBriefPanel.tsx index 93d5964ec..b582b1844 100644 --- a/frontend-modern/src/features/dashboardOverview/PulseBriefPanel.tsx +++ b/frontend-modern/src/features/dashboardOverview/PulseBriefPanel.tsx @@ -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 = { @@ -27,10 +34,14 @@ export function PulseBriefPanel(props: PulseBriefPanelProps) { return ( -
+
@@ -49,7 +60,9 @@ export function PulseBriefPanel(props: PulseBriefPanelProps) {
-

{props.brief.body}

+

+ {props.brief.body} +

@@ -62,7 +75,11 @@ export function PulseBriefPanel(props: PulseBriefPanelProps) {
-
+