From 7bbeddd5b04e7dbbe9582bcbc38cc87b3a9c558f Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Tue, 6 Oct 2026 10:45:16 +0100 Subject: [PATCH] Say why vSphere datastores and networks need attention An amber vSphere datastore or network gave no reason on its row: the vCenter alarm behind it sat one click away in the drawer or one tab away on the Overview. The alarm text itself led with boilerplate ("Network network-302 has VMware alarm ... (yellow)"), so the Overview truncated the part that mattered. A vCenter incident summary is now what vCenter flagged: the alarm's own name, or "vCenter health is ". Every surface that shows it already names the resource and severity. Alert cards, notification templates, and Patrol titles carry the resource name. Patrol's seed context now names the resource on each alert line, and Patrol titles key off the incident code instead of parsing the wording, while still reading records persisted in the old form. The alert card used to find vSphere by looking for "vmware" in the message. It now routes on the incident provider, which also sends ESXi host alarms to vSphere instead of to Machines, where hosts are not listed. The card's own test becomes a registered alerts proof. The Datastores and Networks tables gain the exception-first Health column that TrueNAS storage uses. The cell moves into a shared PlatformIssueReason, so the two platforms cannot drift; TrueNAS renders the same. A row's dot and its reason now come from one classification. Hosts shows only from a 72rem container, where it reads instead of truncating, and the tablet band splits columns by weight. --- .../v6/internal/subsystems/ai-runtime.md | 11 +- .../v6/internal/subsystems/alerts.md | 6 ++ .../subsystems/frontend-primitives.md | 10 ++ .../v6/internal/subsystems/registry.json | 1 + .../internal/subsystems/unified-resources.md | 11 ++ .../a87f9ea8409f6243.json | 91 ++++++++++++++++ .../alerts/AlertOverviewAlertCard.tsx | 5 + ...ertOverviewAlertCard.system-scope.test.tsx | 44 ++++++++ .../platformPage/PlatformIssueReason.tsx | 61 +++++++++++ .../__tests__/PlatformIssueReason.test.tsx | 55 ++++++++++ .../platformOverviewLayout.guardrails.test.ts | 6 +- .../truenas/TrueNASStorageTopologyTable.tsx | 52 +++------ .../vmware/VsphereDatastoresTable.tsx | 62 ++++++++--- .../features/vmware/VsphereNetworksTable.tsx | 57 +++++++--- .../__tests__/VsphereDatastoresTable.test.tsx | 83 ++++++++++++++ .../__tests__/VsphereNetworksTable.test.tsx | 55 ++++++++++ .../src/features/vmware/vmwarePageModel.ts | 102 ++++++++++++++++++ internal/ai/attention.go | 49 +++++++-- internal/ai/attention_test.go | 59 +++++++++- internal/ai/patrol_ai.go | 17 ++- internal/ai/patrol_ai_more_test.go | 22 ++++ internal/vmware/provider.go | 48 +++------ .../vmware/provider_incident_summary_test.go | 41 +++++++ .../canonical_completion_guard_test.py | 1 + 24 files changed, 828 insertions(+), 121 deletions(-) create mode 100644 frontend-modern/browser-verification/a87f9ea8409f6243.json create mode 100644 frontend-modern/src/features/platformPage/PlatformIssueReason.tsx create mode 100644 frontend-modern/src/features/platformPage/__tests__/PlatformIssueReason.test.tsx create mode 100644 internal/vmware/provider_incident_summary_test.go diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index fea40693e1..1f82e68833 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -4629,7 +4629,16 @@ titles: provider alarm messages yield their bounded alarm subject, generic resource/storage incident types become plain issue language, and known acronyms such as CPU, ZFS, and I/O retain their canonical casing. Clients must not receive machine-title-cased placeholders such as `Resource Incident` and -repair them independently. The projection also collapses open/resolved churn: +repair them independently. VMware incidents are recognized by the alert's +`incidentCode` metadata, never by parsing message wording: a +`vmware_alarm_state` alert is titled with the alarm name its message carries, +cut before any appended consumer summary, and a `vmware_health_state` alert +reads as VMware host or datastore health. Records persisted before VMware +summaries became the alarm name still carry " has VMware alarm + ()", so the title strips that wording to the same alarm name. +Patrol's seed context lists active and recently resolved alerts by resource: +each line names the alert's resource unless the message already does, because +provider incidents such as vCenter alarms say only what was flagged. The projection also collapses open/resolved churn: when a record's timeline holds at least four open-to-resolved or resolved-to-open transitions inside the last 24 hours, the item carries a `flapping` summary (transition count, window, first and latest transition) and diff --git a/docs/release-control/v6/internal/subsystems/alerts.md b/docs/release-control/v6/internal/subsystems/alerts.md index 80c53bdb3a..3f831e45c6 100644 --- a/docs/release-control/v6/internal/subsystems/alerts.md +++ b/docs/release-control/v6/internal/subsystems/alerts.md @@ -2431,6 +2431,12 @@ mapping. Acknowledged cards keep their canonical badge and omit that redundant line; held delivery states that can surprise an operator use the attention tone, while cooldown, quiet-hours, monitor-only, and successful/pending states remain neutral. +The card's resource link resolves the owning platform page from alert +metadata, never from message wording alone: a provider incident whose +`incidentProvider` is `vmware` links to the vSphere overview even though its +message is only the alarm vCenter raised, and VMware incidents land on +canonical `vm`, `storage`, `network`, and `agent` resource types rather than a +`vmware-` prefixed type. The retired dashboard recent-alert panel must not be reintroduced as a parallel alert surface. Alert summary/tone copy belongs to the alert overview presentation owner, and any future compact alert surface must compose the diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 99e3bc7e8f..3425d3b61f 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -2041,6 +2041,16 @@ deck or VMware-local page shell. Its rows are canonical `network` resources in the shared reportable/resource vocabulary, so source badges, resource pickers, command-palette search, table chrome, and detail disclosure must all consume shared primitives before VMware-specific presentation logic. +A platform table's Health column, which says why a row's status dot is not +green, renders through `PlatformIssueReason` +(`frontend-modern/src/features/platformPage/PlatformIssueReason.tsx`): the +first reason on the row's single line in the tone's colour, a count for the +rest, every reason in the title and screen-reader text, and an empty cell for +a healthy row. TrueNAS storage and the vSphere Datastores and Networks tables +consume it, and the column stays desktop-only on each of them; a platform +table must not hand-roll its own reason cell. On vSphere Datastores and +Networks the Hosts list shows only from a 72rem container, where it reads +instead of truncating, and the drawer keeps it at every width. Patrol's primary assessment strip is descriptive only; it must not render a Patrol-authored recommended next step, suggested prompt chips, or a secondary action band inside the assessment shell. If the same assessment opens diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index d79af8c40e..de73a0f7ab 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -2599,6 +2599,7 @@ "frontend-modern/src/features/alerts/__tests__/AlertDeadManDestinationSection.test.tsx", "frontend-modern/src/features/alerts/__tests__/AlertIncidentSynthesisSummary.test.tsx", "frontend-modern/src/features/alerts/__tests__/AlertIntentPolicyPanel.test.tsx", + "frontend-modern/src/features/alerts/__tests__/AlertOverviewAlertCard.system-scope.test.tsx", "frontend-modern/src/features/alerts/__tests__/alertsConfigurationModel.test.ts", "frontend-modern/src/features/alerts/__tests__/helpers.test.ts", "frontend-modern/src/features/alerts/__tests__/OverviewTab.emptystate.test.tsx", diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 31e4da09fa..fffddc1828 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -1095,6 +1095,17 @@ The first reason renders in the desktop Health column on the row's single line, coloured by bucket, with a count for the rest and the full list in the title and screen-reader text. Phones keep the status dot and reach the reason through the row drawer. Health sorts by severity rank, not label text. +The vSphere Datastores and Networks tables follow the same exception-first +Health column. `getVmwareDatastoreIssue` and `getVmwareNetworkIssue` return +nothing for a green row; otherwise the reasons are, in order, an impaired +vCenter source, a datastore's inaccessible or maintenance state, the +resource's incident summaries worst first, and, only when no incident explains +the colour, vCenter's overall status or its active alarm count. Inaccessible, +offline, red, or critical rows take the red tone. VMware incident summaries say +only what vCenter flagged, the alarm's own name or `vCenter health is +`, without the managed object ID or alarm colour: every surface that +shows them (alert cards, notifications, the Overview and Health signal tables, +the drawer, Patrol titles) already names the resource and its severity. That shell also owns responsive width composition. Source tables may declare desktop breakpoint floors, but the phone branch must fit its selected columns inside the available container without a horizontal rail. Each unified-resource diff --git a/frontend-modern/browser-verification/a87f9ea8409f6243.json b/frontend-modern/browser-verification/a87f9ea8409f6243.json new file mode 100644 index 0000000000..1604edc537 --- /dev/null +++ b/frontend-modern/browser-verification/a87f9ea8409f6243.json @@ -0,0 +1,91 @@ +{ + "version": 1, + "base_sha": "25f4131720dad07540378f4d82aa9c33b291e75d", + "verified_at": "2026-10-06T09:35:44Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/features/alerts/AlertOverviewAlertCard.tsx", + "frontend-modern/src/features/platformPage/PlatformIssueReason.tsx", + "frontend-modern/src/features/truenas/TrueNASStorageTopologyTable.tsx", + "frontend-modern/src/features/vmware/VsphereDatastoresTable.tsx", + "frontend-modern/src/features/vmware/VsphereNetworksTable.tsx", + "frontend-modern/src/features/vmware/vmwarePageModel.ts" + ], + "content_sha256": { + "frontend-modern/src/features/alerts/AlertOverviewAlertCard.tsx": "13b98540debb4cc90930515da2ee02bfafe107dc3d12c9a1c4b493e834f228a9", + "frontend-modern/src/features/platformPage/PlatformIssueReason.tsx": "42c44157e730f97aaa7ae4aed113152a80fe0586164f9d368a716ce40ee9bbda", + "frontend-modern/src/features/truenas/TrueNASStorageTopologyTable.tsx": "39792cde214d652d18a5d89a1b4a61167839ad9dad3df49aa28f474f14f37753", + "frontend-modern/src/features/vmware/VsphereDatastoresTable.tsx": "b87fb28920d00483c8de236f95a30d8ee37931a340469b613d313f9f6ec6de4e", + "frontend-modern/src/features/vmware/VsphereNetworksTable.tsx": "c35298763d75b87586a2faa40b832fee724c6d751d4761103bf9fbd5c1444dcc", + "frontend-modern/src/features/vmware/vmwarePageModel.ts": "d64d6ea8059ecc1e68801eddf95546b185834e4288a2bf052d3eb7005b5b7af1" + }, + "routes": [ + "/vmware/networks", + "/vmware/storage", + "/vmware/overview", + "/vmware/health", + "/truenas/storage", + "/alerts/overview", + "/patrol" + ], + "viewports": [ + { + "width": 390, + "height": 844 + }, + { + "width": 600, + "height": 844 + }, + { + "width": 640, + "height": 844 + }, + { + "width": 700, + "height": 844 + }, + { + "width": 760, + "height": 844 + }, + { + "width": 768, + "height": 900 + }, + { + "width": 1024, + "height": 900 + }, + { + "width": 1280, + "height": 900 + }, + { + "width": 1440, + "height": 900 + }, + { + "width": 1920, + "height": 900 + } + ], + "states": [ + "Private mock stack on main 1896f1f2ba plus this change (rebased onto main through 25f4131720, whose Docker update-alert and Windows harness changes touch no frontend file here) (backend rebuilt from the tree), mock vCenter with two alarmed networks, an alarmed datastore and a yellow datastore and ESXi host; before = main on the same stack", + "vSphere Networks and Datastores: a Health column now says why a row is amber (Network packet loss above threshold, Distributed portgroup uplink redundancy, Datastore latency above threshold, vCenter health is yellow) and is empty on green rows; main showed only the dot, with the reason one click or one tab away", + "Column sweep against main at 390-1920: names never clip at any width (main clipped none either); Hosts clipped 7-8 rows at 600-1024 on main and now shows only from a 72rem container where at most 4 rows truncate, fewer than main at 1280 and 1440; tablet band (600-760) splits columns by weight with DC compact, Type no longer clips, only the longest alarm names truncate with the full text on hover; phones keep the dot and hide Health as TrueNAS does", + "vSphere Overview and Health tab signal columns read the alarm in full (main: 'Network network-302 has VMware alarm Network packet loss above thres...'); drawer header badge and Needs attention read the alarm name", + "Alerts overview cards read 'vCenter health is yellow' / alarm names under the resource name; every vCenter alarm card, including the ESXi host, links to /vmware/overview (main sent the host to /machines, which does not list it)", + "Patrol inbox titles unchanged in substance ('Network packet loss above threshold on Edge Stateful', 'VMware host health on esxi-07.lab.local'), now derived from the incident code", + "TrueNAS storage Health cells on the shared PlatformIssueReason: markup, classes, colours and sizes identical to main in light and dark", + "Dark mode at 1440: Health reasons amber-300 on dark rows, readable" + ], + "interactions": [ + "Sorted both tables by Health (amber rows first, reversed on second click)", + "Filtered both tables to Attention (only the two amber rows remain, each with its reason)", + "Expanded an amber datastore row: drawer spans the full table and names the alarm", + "Clicked the Edge Stateful alert card link from Alerts overview and landed on /vmware/overview", + "Expanded Patrol's full decision list and read every vSphere title", + "Measured every visible header and cell for clipping per viewport with a script, before and after" + ] +} diff --git a/frontend-modern/src/features/alerts/AlertOverviewAlertCard.tsx b/frontend-modern/src/features/alerts/AlertOverviewAlertCard.tsx index 5cf8bca210..5cb4c25676 100644 --- a/frontend-modern/src/features/alerts/AlertOverviewAlertCard.tsx +++ b/frontend-modern/src/features/alerts/AlertOverviewAlertCard.tsx @@ -86,6 +86,11 @@ export function AlertOverviewAlertCard(props: AlertOverviewAlertCardProps) { typeof props.alert.metadata?.resourceType === 'string' ? (props.alert.metadata.resourceType as string) : ''; + // vCenter incidents land on canonical agent/vm/storage/network resources + // and say only what vCenter flagged, so the incident's provider names the + // page, ahead of the generic resource-type routes: an ESXi host is an + // `agent` resource that the Machines page does not list. + if (props.alert.metadata?.incidentProvider === 'vmware') return '/vmware/overview'; if (rid.startsWith('agent:') || resourceType === 'agent') return '/machines'; if ( rid.includes('docker') || diff --git a/frontend-modern/src/features/alerts/__tests__/AlertOverviewAlertCard.system-scope.test.tsx b/frontend-modern/src/features/alerts/__tests__/AlertOverviewAlertCard.system-scope.test.tsx index f4e95fc971..954d607c65 100644 --- a/frontend-modern/src/features/alerts/__tests__/AlertOverviewAlertCard.system-scope.test.tsx +++ b/frontend-modern/src/features/alerts/__tests__/AlertOverviewAlertCard.system-scope.test.tsx @@ -92,6 +92,50 @@ describe('Pulse system-alert overview scope', () => { expect(screen.getByText('Resource monitoring policy')).toBeInTheDocument(); }); + it('links a vCenter alarm on a network to vSphere by its incident provider', () => { + // The summary is the alarm's own name, so the message no longer says VMware. + const alert = makeSystemAlert('resource-incident', { + id: 'vmware-network-alarm', + resourceId: 'network:vc-1:network-302', + resourceName: 'Edge Stateful', + message: 'Network packet loss above threshold', + metadata: { + resourceType: 'network', + incidentProvider: 'vmware', + incidentCode: 'vmware_alarm_state', + }, + }); + render(() => ( + + )); + expect(screen.getByRole('link', { name: 'Edge Stateful' })).toHaveAttribute( + 'href', + '/vmware/overview', + ); + }); + + it('links a vCenter health signal on an ESXi host to vSphere, not Machines', () => { + // ESXi hosts are canonical agent resources the Machines page does not list. + const alert = makeSystemAlert('resource-incident', { + id: 'vmware-host-health', + resourceId: 'agent:vc-1:host-107', + resourceName: 'esxi-07.lab.local', + message: 'vCenter health is yellow', + metadata: { + resourceType: 'agent', + incidentProvider: 'vmware', + incidentCode: 'vmware_health_state', + }, + }); + render(() => ( + + )); + expect(screen.getByRole('link', { name: 'esxi-07.lab.local' })).toHaveAttribute( + 'href', + '/vmware/overview', + ); + }); + it('closes the open timeline when the disclosure is collapsed', () => { const alert = makeSystemAlert('cpu', { id: 'open-timeline', resourceId: 'vm-pulse' }); const openTimeline = { diff --git a/frontend-modern/src/features/platformPage/PlatformIssueReason.tsx b/frontend-modern/src/features/platformPage/PlatformIssueReason.tsx new file mode 100644 index 0000000000..a991891876 --- /dev/null +++ b/frontend-modern/src/features/platformPage/PlatformIssueReason.tsx @@ -0,0 +1,61 @@ +import { Show, splitProps, type Component, type JSX } from 'solid-js'; + +// warning: needs attention (amber). danger: down or unreachable (red). +// muted: no verdict, such as an unknown state. +export type PlatformIssueTone = 'warning' | 'danger' | 'muted'; + +export type PlatformIssue = { + tone: PlatformIssueTone; + // The row's bucket in words ("Attention", "Offline"), read out before the + // reasons because sighted users get it from the colour. + label: string; + reasons: readonly string[]; +}; + +const ISSUE_TONE_CLASS: Record = { + warning: 'text-amber-700 dark:text-amber-300', + danger: 'text-red-600 dark:text-red-300', + muted: 'text-muted', +}; + +// The visible reason is truncated and abbreviates the rest to a count, so +// assistive technology gets the bucket and every reason instead. +const issueScreenReaderText = (issue: PlatformIssue): string => + `${issue.label}: ${issue.reasons.map((reason) => reason.replace(/[.\s]+$/, '')).join('. ')}.`; + +// A Health column cell that says why a row's status dot is not green. +// Healthy rows pass no issue and leave the cell empty: the dot already says +// healthy, and a column of green pills buries the few rows that are not. An +// exception shows its first reason on the row's single line (the shared +// platform-table rhythm), with every reason on hover and in the row drawer. +// The colour carries the bucket, so a pill repeating Attention would only take +// room from the reason. +export const PlatformIssueReason: Component< + { issue: PlatformIssue | null | undefined } & Omit< + JSX.HTMLAttributes, + 'class' | 'title' | 'children' + > +> = (props) => { + const [local, rest] = splitProps(props, ['issue']); + return ( + + {(issue) => ( +
+ + 1}> + + + {issueScreenReaderText(issue())} +
+ )} +
+ ); +}; diff --git a/frontend-modern/src/features/platformPage/__tests__/PlatformIssueReason.test.tsx b/frontend-modern/src/features/platformPage/__tests__/PlatformIssueReason.test.tsx new file mode 100644 index 0000000000..cdbb65c235 --- /dev/null +++ b/frontend-modern/src/features/platformPage/__tests__/PlatformIssueReason.test.tsx @@ -0,0 +1,55 @@ +import { cleanup, render } from '@solidjs/testing-library'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { PlatformIssueReason } from '@/features/platformPage/PlatformIssueReason'; + +afterEach(() => { + cleanup(); +}); + +describe('PlatformIssueReason', () => { + it('leaves a healthy row empty', () => { + const { container } = render(() => ); + expect(container.textContent).toBe(''); + }); + + it('shows the first reason, counts the rest, and reads every reason out', () => { + const { container } = render(() => ( + + )); + const cell = container.querySelector('[data-testid="reason"]') as HTMLElement; + expect(cell).toHaveClass('text-amber-700'); + expect(cell).toHaveAttribute( + 'title', + 'Datastore latency above threshold\nvCenter has not updated this recently.', + ); + const visible = [...cell.querySelectorAll('[aria-hidden="true"]')].map((el) => el.textContent); + expect(visible).toEqual(['Datastore latency above threshold', '+1']); + expect(cell.querySelector('.sr-only')).toHaveTextContent( + 'Attention: Datastore latency above threshold. vCenter has not updated this recently.', + ); + }); + + it('colours a down row red and omits the count for a single reason', () => { + const { container } = render(() => ( + + )); + const cell = container.querySelector('[data-testid="reason"]') as HTMLElement; + expect(cell).toHaveClass('text-red-600'); + expect(cell.querySelectorAll('[aria-hidden="true"]')).toHaveLength(1); + }); +}); diff --git a/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts b/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts index d370079969..ade759b1d2 100644 --- a/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts +++ b/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts @@ -516,10 +516,10 @@ describe('platform overview layout guardrails', () => { /sortKey="resource"[\s\S]{0,120}?class="platform-table-mobile-w-30 md:w-\[32%\]"/, ); expect(vsphereNetworksTableSource).toMatch( - /sortKey="network"[\s\S]{0,120}?class="platform-table-mobile-w-30 md:w-\[24%\]"/, + /sortKey="network"[\s\S]{0,120}?class="platform-table-mobile-w-30 md:w-\[20%\]"/, ); expect(vsphereNetworksTableSource).toMatch( - /sortKey="type"[\s\S]{0,120}?class="platform-table-mobile-w-15 md:w-\[13%\]"/, + /sortKey="type"[\s\S]{0,120}?class="platform-table-mobile-w-15 md:w-\[10%\]"/, ); expect(agentsMachinesTableSource).toContain( 'class="-my-2 min-h-11 min-w-11 sm:my-0 sm:min-h-0 sm:min-w-0"', @@ -640,7 +640,7 @@ describe('platform overview layout guardrails', () => { expect(vmwarePageSurfaceSource).toContain(' = (props) ); }; -const issueReasonClass = (issue: TrueNASStorageIssue): string => { - if (issue.status === 'attention') return 'text-amber-700 dark:text-amber-300'; - if (issue.status === 'offline') return 'text-red-600 dark:text-red-300'; - return 'text-muted'; +const ISSUE_TONES: Record = { + attention: 'warning', + offline: 'danger', + unknown: 'muted', }; -// The visible reason is truncated and abbreviates the rest to a count, so -// assistive technology gets the bucket and every reason instead. -const issueScreenReaderText = (issue: TrueNASStorageIssue): string => - `${issue.label}: ${issue.reasons.map((reason) => reason.replace(/[.\s]+$/, '')).join('. ')}.`; - -// Healthy rows leave the Health column empty: the status dot already says -// healthy, and a column of green pills buried the few rows that are not. An -// exception shows why, on the row's single line (the shared platform-table -// rhythm), with every reason on hover and in the row drawer. The colour -// carries the bucket, so a pill repeating Attention would only take room -// from the reason. -const IssueSummary: Component<{ issue: TrueNASStorageIssue | null }> = (props) => ( - - {(issue) => ( -
- - 1}> - - - {issueScreenReaderText(issue())} -
- )} -
-); +const toPlatformIssue = (issue: TrueNASStorageIssue | null): PlatformIssue | null => + issue && { tone: ISSUE_TONES[issue.status], label: issue.label, reasons: issue.reasons }; const HEALTH_SORT_RANK: Record = { attention: 0, @@ -592,7 +567,10 @@ export const TrueNASStorageTopologyTable: Component<{ - + { // Columns a user can sort by. Hosts and Consumers summarize several names at // once, so they carry no single scalar to order on. Capacity orders on the // used percentage the bar shows. -const VSPHERE_DATASTORE_SORT_KEYS = ['datastore', 'type', 'capacity', 'vms', 'datacenter'] as const; +const VSPHERE_DATASTORE_SORT_KEYS = [ + 'datastore', + 'type', + 'capacity', + 'vms', + 'datacenter', + 'health', +] as const; type VsphereDatastoreSortKey = (typeof VSPHERE_DATASTORE_SORT_KEYS)[number]; @@ -125,6 +134,8 @@ const getVsphereDatastoreSortValue = ( return consumerCount(resource); case 'datacenter': return asTrimmedString(resource.vmware?.datacenterName) || null; + case 'health': + return vmwareIssueSortRank(getVmwareDatastoreIssue(resource)); default: key satisfies never; return null; @@ -204,7 +215,8 @@ export const VsphereDatastoresTable: Component<{ kind="name" sort={sort} sortKey="datastore" - class="platform-table-mobile-w-30 md:w-[21%]" + class="platform-table-mobile-w-30 md:w-[16%]" + bandWidth={28} > Datastore @@ -212,7 +224,8 @@ export const VsphereDatastoresTable: Component<{ kind="text" sort={sort} sortKey="type" - class="platform-table-mobile-w-15 md:w-[9%]" + class="platform-table-mobile-w-15 md:w-[6%]" + bandWidth={11} > Type @@ -220,29 +233,31 @@ export const VsphereDatastoresTable: Component<{ kind="metric-bar" sort={sort} sortKey="capacity" - class="platform-table-mobile-w-25 md:w-[18%]" + class="platform-table-mobile-w-25 md:w-[16%]" + bandWidth={22} > - + Hosts VMs @@ -250,10 +265,19 @@ export const VsphereDatastoresTable: Component<{ kind="text" sort={sort} sortKey="datacenter" - class="hidden md:table-cell md:w-[10%]" + class="hidden md:table-cell md:w-[8%]" > Datacenter + + Health + } body={ @@ -262,8 +286,8 @@ export const VsphereDatastoresTable: Component<{ {(datastore) => { const hosts = createMemo(() => hostSummary(datastore)); const consumers = createMemo(() => consumerSummary(datastore)); - const displayStatus = () => getVmwareResourceDisplayStatus(datastore); - const indicator = () => getSimpleStatusIndicator(displayStatus()); + const issue = createMemo(() => getVmwareDatastoreIssue(datastore)); + const indicator = () => getVmwareRowIndicator(datastore, issue()); const name = () => datastoreName(datastore); const datacenter = () => asTrimmedString(datastore.vmware?.datacenterName) || '—'; @@ -317,7 +341,7 @@ export const VsphereDatastoresTable: Component<{ + + + drawer.close(datastore)} /> diff --git a/frontend-modern/src/features/vmware/VsphereNetworksTable.tsx b/frontend-modern/src/features/vmware/VsphereNetworksTable.tsx index f3d25e5c53..2bed2359c9 100644 --- a/frontend-modern/src/features/vmware/VsphereNetworksTable.tsx +++ b/frontend-modern/src/features/vmware/VsphereNetworksTable.tsx @@ -2,7 +2,6 @@ import { Show, createMemo, type Component, type JSX } from 'solid-js'; import { StatusDot } from '@/components/shared/StatusDot'; import { TableCell, TableRow } from '@/components/shared/Table'; import { filterChipStatusDot } from '@/components/shared/FilterBar'; -import { getSimpleStatusIndicator } from '@/utils/status'; import { asTrimmedString } from '@/utils/stringUtils'; import { PlatformWindowedRows, @@ -19,6 +18,7 @@ import { PlatformTableShell, withPlatformStatusCounts, } from '@/features/platformPage/sharedPlatformPage'; +import { PlatformIssueReason } from '@/features/platformPage/PlatformIssueReason'; import { PlatformResourceDetailToggleButton, PlatformResourceDetailTableRow, @@ -29,7 +29,9 @@ import { import type { Resource } from '@/types/resource'; import { filterVmwareNetworks, - getVmwareResourceDisplayStatus, + getVmwareNetworkIssue, + vmwareIssueSortRank, + getVmwareRowIndicator, type VmwareNetworkStatusFilter, } from './vmwarePageModel'; @@ -82,7 +84,7 @@ const vmCount = (resource: Resource): number => // Columns a user can sort by. Hosts and Connected VMs summarize several names // at once, so they carry no single scalar to order on. -const VSPHERE_NETWORK_SORT_KEYS = ['network', 'type', 'vms', 'datacenter'] as const; +const VSPHERE_NETWORK_SORT_KEYS = ['network', 'type', 'vms', 'datacenter', 'health'] as const; type VsphereNetworkSortKey = (typeof VSPHERE_NETWORK_SORT_KEYS)[number]; @@ -101,6 +103,8 @@ const getVsphereNetworkSortValue = ( return vmCount(resource); case 'datacenter': return asTrimmedString(resource.vmware?.datacenterName) || null; + case 'health': + return vmwareIssueSortRank(getVmwareNetworkIssue(resource)); default: key satisfies never; return null; @@ -180,7 +184,8 @@ export const VsphereNetworksTable: Component<{ kind="name" sort={sort} sortKey="network" - class="platform-table-mobile-w-30 md:w-[24%]" + class="platform-table-mobile-w-30 md:w-[20%]" + bandWidth={28} > Network @@ -188,14 +193,15 @@ export const VsphereNetworksTable: Component<{ kind="text" sort={sort} sortKey="type" - class="platform-table-mobile-w-15 md:w-[13%]" + class="platform-table-mobile-w-15 md:w-[10%]" + bandWidth={21} > Type Hosts @@ -203,14 +209,15 @@ export const VsphereNetworksTable: Component<{ kind="numeric-value" sort={sort} sortKey="vms" - class="platform-table-mobile-w-15 md:w-[7%]" + class="platform-table-mobile-w-15 md:w-[5%]" + bandWidth={7} > VMs @@ -218,9 +225,19 @@ export const VsphereNetworksTable: Component<{ kind="text" sort={sort} sortKey="datacenter" - class="platform-table-mobile-w-20 md:w-[12%]" + class="platform-table-mobile-w-20 md:w-[8%]" + bandWidth={14} > - + + + + Health } @@ -230,8 +247,8 @@ export const VsphereNetworksTable: Component<{ {(network) => { const hosts = createMemo(() => hostSummary(network)); const vms = createMemo(() => vmSummary(network)); - const displayStatus = () => getVmwareResourceDisplayStatus(network); - const indicator = () => getSimpleStatusIndicator(displayStatus()); + const issue = createMemo(() => getVmwareNetworkIssue(network)); + const indicator = () => getVmwareRowIndicator(network, issue()); const name = () => networkName(network); const datacenter = () => asTrimmedString(network.vmware?.datacenterName) || '—'; const networkSubtitle = () => @@ -274,12 +291,10 @@ export const VsphereNetworksTable: Component<{ - - {networkType(network)} - + {networkType(network)} + + + drawer.close(network)} /> diff --git a/frontend-modern/src/features/vmware/__tests__/VsphereDatastoresTable.test.tsx b/frontend-modern/src/features/vmware/__tests__/VsphereDatastoresTable.test.tsx index 6af1cc036c..c8aec765bc 100644 --- a/frontend-modern/src/features/vmware/__tests__/VsphereDatastoresTable.test.tsx +++ b/frontend-modern/src/features/vmware/__tests__/VsphereDatastoresTable.test.tsx @@ -96,4 +96,87 @@ describe('VsphereDatastoresTable', () => { expect(row).not.toHaveAttribute('aria-expanded'); expect(row?.querySelector('[data-row-action="true"]')).toHaveAttribute('aria-expanded', 'true'); }); + + it('says why a datastore is not green and leaves healthy rows blank', () => { + const vmware = (extra: Record) => + ({ entityType: 'datastore', datastoreAccessible: true, ...extra }) as Resource['vmware']; + const healthy = makeDatastore({ id: 'nvme-primary' }); + const alarmed = makeDatastore({ + id: 'archive-tier', + status: 'degraded', + incidents: [ + { + provider: 'vmware', + code: 'vmware_alarm_state', + severity: 'warning', + summary: 'Datastore latency above threshold', + }, + ], + vmware: vmware({ overallStatus: 'yellow' }), + }); + const yellow = makeDatastore({ + id: 'edge-cold-iscsi', + vmware: vmware({ overallStatus: 'yellow' }), + }); + const maintenance = makeDatastore({ + id: 'edge-warm-nfs', + vmware: vmware({ maintenanceMode: 'inMaintenance' }), + }); + const inaccessible = makeDatastore({ + id: 'backup-nfs', + status: 'offline', + vmware: vmware({ datastoreAccessible: false }), + }); + const stale = makeDatastore({ + id: 'edge-vsan', + platformData: { sourceStatus: { vmware: { status: 'stale' } } }, + }); + const unreachableInMaintenance = makeDatastore({ + id: 'edge-nvme-tier', + status: 'offline', + vmware: vmware({ maintenanceMode: 'inMaintenance', datastoreAccessible: false }), + }); + const all = [ + healthy, + alarmed, + yellow, + maintenance, + inaccessible, + stale, + unreachableInMaintenance, + ]; + + render(() => ( + } + emptyTitle="No datastores" + emptyDescription="No datastores" + showToolbar={false} + /> + )); + + expect(screen.getByRole('columnheader', { name: /Health/ })).toBeInTheDocument(); + const health = (name: string) => + screen.getByText(name).closest('tr')?.querySelector('[data-vsphere-health]'); + + expect(health('nvme-primary')).toBeNull(); + expect(health('archive-tier')).toHaveAttribute('title', 'Datastore latency above threshold'); + expect(health('edge-cold-iscsi')).toHaveAttribute('title', 'vCenter health is yellow'); + expect(health('edge-warm-nfs')).toHaveAttribute('title', 'In maintenance mode'); + expect(health('backup-nfs')).toHaveAttribute('data-vsphere-health', 'danger'); + expect(health('backup-nfs')).toHaveAttribute('title', 'vCenter reports it inaccessible'); + expect(health('edge-vsan')).toHaveAttribute('title', 'vCenter has not updated this recently'); + // Maintenance does not hide that vCenter cannot reach the datastore. + expect(health('edge-nvme-tier')).toHaveAttribute('data-vsphere-health', 'danger'); + expect(health('edge-nvme-tier')).toHaveAttribute( + 'title', + 'vCenter reports it inaccessible\nIn maintenance mode', + ); + const dot = (name: string) => + screen.getByText(name).closest('tr')?.querySelector('span.rounded-full[title]'); + expect(dot('edge-warm-nfs')).toHaveAttribute('title', 'Maintenance'); + expect(dot('nvme-primary')).toHaveAttribute('title', 'Online'); + }); }); diff --git a/frontend-modern/src/features/vmware/__tests__/VsphereNetworksTable.test.tsx b/frontend-modern/src/features/vmware/__tests__/VsphereNetworksTable.test.tsx index b5ca056830..08ebfd0ff6 100644 --- a/frontend-modern/src/features/vmware/__tests__/VsphereNetworksTable.test.tsx +++ b/frontend-modern/src/features/vmware/__tests__/VsphereNetworksTable.test.tsx @@ -86,4 +86,59 @@ describe('VsphereNetworksTable', () => { expect(row).not.toHaveAttribute('aria-expanded'); expect(row?.querySelector('[data-row-action="true"]')).toHaveAttribute('aria-expanded', 'true'); }); + + it('says why an amber network needs attention and leaves healthy rows blank', () => { + const healthy = makeNetwork({ id: 'VM Network' }); + const alarmed = makeNetwork({ + id: 'Edge Stateful', + status: 'degraded', + incidents: [ + { + provider: 'vmware', + code: 'vmware_alarm_state', + severity: 'warning', + summary: 'Network packet loss above threshold', + }, + ], + vmware: { entityType: 'network', overallStatus: 'yellow', activeAlarmCount: 1 }, + }); + const countedOnly = makeNetwork({ + id: 'Utility Network', + vmware: { entityType: 'network', activeAlarmCount: 2 }, + }); + const red = makeNetwork({ + id: 'Archive Network', + vmware: { entityType: 'network', overallStatus: 'red' }, + }); + + render(() => ( + } + emptyTitle="No networks" + emptyDescription="No networks" + showToolbar={false} + /> + )); + + expect(screen.getByRole('columnheader', { name: /Health/ })).toBeInTheDocument(); + const health = (name: string) => + screen.getByText(name).closest('tr')?.querySelector('[data-vsphere-health]'); + + expect(health('VM Network')).toBeNull(); + // The alarm's own name explains the dot; vCenter's yellow adds nothing. + expect(health('Edge Stateful')).toHaveAttribute('data-vsphere-health', 'warning'); + expect(health('Edge Stateful')).toHaveAttribute('title', 'Network packet loss above threshold'); + expect(health('Utility Network')).toHaveAttribute('title', '2 active vCenter alarms'); + // The dot reads from the same classification as the reason, even when the + // resource status is still online. + const dot = (name: string) => + screen.getByText(name).closest('tr')?.querySelector('span.rounded-full[title]'); + expect(dot('Utility Network')).toHaveAttribute('title', 'Attention'); + expect(dot('Archive Network')).toHaveClass('bg-red-500'); + expect(dot('VM Network')).toHaveAttribute('title', 'Online'); + expect(health('Archive Network')).toHaveAttribute('data-vsphere-health', 'danger'); + expect(health('Archive Network')).toHaveAttribute('title', 'vCenter health is red'); + }); }); diff --git a/frontend-modern/src/features/vmware/vmwarePageModel.ts b/frontend-modern/src/features/vmware/vmwarePageModel.ts index 0b8ec7c1fa..d2186de28c 100644 --- a/frontend-modern/src/features/vmware/vmwarePageModel.ts +++ b/frontend-modern/src/features/vmware/vmwarePageModel.ts @@ -2,6 +2,8 @@ import { resolveResourcePlatformType } from '@/utils/sourcePlatforms'; import { hasImpairedResourceSource } from '@/utils/resourceSourceHealth'; import { formatVmwareClusterServices } from '@/utils/vmwareDisplay'; import type { Resource, ResourceChange, ResourceIncident, ResourceType } from '@/types/resource'; +import type { PlatformIssue } from '@/features/platformPage/PlatformIssueReason'; +import { getSimpleStatusIndicator, type StatusIndicator } from '@/utils/status'; export type VmwarePageTabId = 'overview' | 'storage' | 'networks' | 'health' | 'activity'; export type VmwareDatastoreStatusFilter = @@ -415,6 +417,106 @@ export function mapVmwareNetworkStatus( return 'unknown'; } +const ATTENTION_OVERALL_STATUSES = new Set(['red', 'yellow']); + +// Why a datastore or network row is not green, in the order a user should +// read them. Stale vCenter data comes first, since everything else on the row +// may be out of date; then vCenter's own alarms, worst first. vCenter's +// overall colour only shows when no alarm explains it. +const vmwareAttentionReasons = (resource: Resource, leading: string[] = []): string[] => { + const reasons: string[] = []; + const push = (value: unknown) => { + const text = trimString(value); + if (text && !reasons.some((existing) => existing.toLowerCase() === text.toLowerCase())) { + reasons.push(text); + } + }; + if (hasImpairedResourceSource(resource, 'vmware-vsphere')) { + push('vCenter has not updated this recently'); + } + leading.forEach(push); + const incidents = (resource.incidents ?? []) + .filter(hasIncidentSignal) + .sort( + (a, b) => incidentSeverityRank(b.severity ?? '') - incidentSeverityRank(a.severity ?? ''), + ); + const beforeIncidents = reasons.length; + incidents.forEach((incident) => + push(incident.summary || vmwareIncidentLabel(resource, incident)), + ); + if (reasons.length === beforeIncidents) { + const overall = normalize(resource.vmware?.overallStatus); + const alarms = resource.vmware?.activeAlarmCount ?? 0; + if (ATTENTION_OVERALL_STATUSES.has(overall)) push(`vCenter health is ${overall}`); + else if (alarms > 0) push(`${alarms} active vCenter alarm${alarms === 1 ? '' : 's'}`); + } + return reasons; +}; + +const hasCriticalVmwareSignal = (resource: Resource): boolean => + normalize(resource.vmware?.overallStatus) === 'red' || + (resource.incidents ?? []).some( + (incident) => mapVmwareIncidentSeverity(incident.severity) === 'critical', + ); + +// A row's status dot and its Health reason come from one classification, so a +// datastore in maintenance or a network with an active alarm never shows a +// green dot beside an amber reason. +export const getVmwareRowIndicator = ( + resource: Resource, + issue: PlatformIssue | null, +): StatusIndicator => + issue + ? { variant: issue.tone === 'danger' ? 'danger' : 'warning', label: issue.label } + : getSimpleStatusIndicator(getVmwareResourceDisplayStatus(resource)); + +// Health sorts the worst rows first: red, then amber, then healthy. +export const vmwareIssueSortRank = (issue: PlatformIssue | null): number => + issue ? (issue.tone === 'danger' ? 0 : 1) : 2; + +const DATASTORE_ISSUE_LABELS = { + inaccessible: 'Inaccessible', + maintenance: 'Maintenance', + attention: 'Attention', +} as const; + +export function getVmwareDatastoreIssue(resource: Resource): PlatformIssue | null { + const bucket = mapVmwareDatastoreStatus(resource); + if (bucket === 'accessible' || bucket === 'unknown') return null; + // The bucket picks one filter, but a datastore in maintenance can also be + // unreachable or carry a critical alarm: each condition gives its reason, + // and the worst sets the colour. + const inaccessible = + resource.vmware?.datastoreAccessible === false || + normalize(getVmwareResourceDisplayStatus(resource)) === 'offline'; + const leading: string[] = []; + if (inaccessible) leading.push('vCenter reports it inaccessible'); + if (bucket === 'maintenance') { + leading.push( + normalize(resource.vmware?.maintenanceMode).includes('entering') + ? 'Entering maintenance mode' + : 'In maintenance mode', + ); + } + const reasons = vmwareAttentionReasons(resource, leading); + return { + tone: inaccessible || hasCriticalVmwareSignal(resource) ? 'danger' : 'warning', + label: DATASTORE_ISSUE_LABELS[bucket], + reasons: reasons.length > 0 ? reasons : ['vCenter reports a problem'], + }; +} + +export function getVmwareNetworkIssue(resource: Resource): PlatformIssue | null { + if (mapVmwareNetworkStatus(resource) !== 'attention') return null; + const offline = normalize(getVmwareResourceDisplayStatus(resource)) === 'offline'; + const reasons = vmwareAttentionReasons(resource); + return { + tone: offline || hasCriticalVmwareSignal(resource) ? 'danger' : 'warning', + label: 'Attention', + reasons: reasons.length > 0 ? reasons : ['vCenter reports a problem'], + }; +} + const titleize = (value: string): string => value .split(/[\s_-]+/) diff --git a/internal/ai/attention.go b/internal/ai/attention.go index 3532bf518c..68c115f692 100644 --- a/internal/ai/attention.go +++ b/internal/ai/attention.go @@ -530,7 +530,7 @@ func attentionTitle(alert alerts.Alert, resourceName string) string { if alertType == "" { return "Issue on " + resourceName } - if incidentTitle := attentionIncidentTitle(alertType, alert.Message); incidentTitle != "" { + if incidentTitle := attentionIncidentTitle(alert); incidentTitle != "" { return incidentTitle + " on " + resourceName } switch strings.ToLower(alertType) { @@ -564,30 +564,48 @@ func attentionTitle(alert alerts.Alert, resourceName string) string { return strings.Join(words, " ") + " on " + resourceName } -func attentionIncidentTitle(alertType, message string) string { +func attentionIncidentTitle(alert alerts.Alert) string { normalizedType := strings.NewReplacer("-", "_", " ", "_").Replace( - strings.ToLower(strings.TrimSpace(alertType)), + strings.ToLower(strings.TrimSpace(alert.Type)), ) if normalizedType != "resource_incident" && normalizedType != "storage_incident" { return "" } + message := strings.TrimSpace(alert.Message) if strings.Contains(strings.ToLower(message), "pg degraded") { return "Ceph placement groups degraded" } - const vmwareAlarmMarker = " has VMware alarm " - if markerIndex := strings.Index(message, vmwareAlarmMarker); markerIndex >= 0 { - issue := strings.TrimSpace(message[markerIndex+len(vmwareAlarmMarker):]) - for _, suffix := range []string{" (", ". Affects "} { - if suffixIndex := strings.Index(issue, suffix); suffixIndex >= 0 { - issue = issue[:suffixIndex] + switch attentionIncidentCode(alert) { + case "vmware_alarm_state": + // The message is the alarm's name, with the consumers it affects + // appended on storage. Strip only that appended summary: a custom + // alarm name may itself contain ". " or parentheses. + issue := message + summaryIndex := -1 + for _, consumerMarker := range []string{". Affects ", ". Puts backups for "} { + summaryIndex = max(summaryIndex, strings.LastIndex(issue, consumerMarker)) + } + if summaryIndex >= 0 { + issue = issue[:summaryIndex] + } + // Alerts recorded before the summary became the alarm name carry + // "Network network-302 has VMware alarm (yellow)", and resolved + // ones keep that text in history. + const legacyAlarmMarker = " has VMware alarm " + if markerIndex := strings.Index(issue, legacyAlarmMarker); markerIndex >= 0 { + issue = issue[markerIndex+len(legacyAlarmMarker):] + for _, status := range []string{"red", "yellow", "green", "gray", "active"} { + if trimmed, found := strings.CutSuffix(issue, " ("+status+")"); found { + issue = trimmed + break + } } } if issue = strings.TrimSpace(strings.TrimSuffix(issue, ".")); issue != "" { return issue } - } - if strings.Contains(message, " has VMware overall status ") { + case "vmware_health_state": if normalizedType == "storage_incident" { return "VMware datastore health" } @@ -599,6 +617,15 @@ func attentionIncidentTitle(alertType, message string) string { return "Infrastructure issue" } +func attentionIncidentCode(alert alerts.Alert) string { + if alert.Metadata != nil { + if value, ok := alert.Metadata["incidentCode"].(string); ok { + return strings.TrimSpace(value) + } + } + return "" +} + func attentionResourceType(alert alerts.Alert) string { if alert.Metadata != nil { if value, ok := alert.Metadata["resourceType"].(string); ok { diff --git a/internal/ai/attention_test.go b/internal/ai/attention_test.go index cc8556b0a4..ccd1230896 100644 --- a/internal/ai/attention_test.go +++ b/internal/ai/attention_test.go @@ -246,6 +246,7 @@ func TestAttentionTitleProducesHumanReadableLabels(t *testing.T) { tests := []struct { name string alertType string + incidentCode string message string resourceName string want string @@ -253,6 +254,47 @@ func TestAttentionTitleProducesHumanReadableLabels(t *testing.T) { { name: "VMware alarm uses the actual alarm", alertType: "storage-incident", + incidentCode: "vmware_alarm_state", + message: "Datastore latency above threshold. Affects 2 dependent resources", + resourceName: "archive-tier", + want: "Datastore latency above threshold on archive-tier", + }, + { + name: "VMware alarm recorded before the alarm-name summary", + alertType: "resource-incident", + incidentCode: "vmware_alarm_state", + message: "Network network-302 has VMware alarm Network packet loss above threshold (yellow)", + resourceName: "Edge Stateful", + want: "Network packet loss above threshold on Edge Stateful", + }, + { + name: "VMware alarm keeps punctuation that belongs to its name", + alertType: "storage-incident", + incidentCode: "vmware_alarm_state", + message: "Storage warning. Latency (p99) above threshold. Puts backups for 2 protected workloads at risk: db-01, db-02", + resourceName: "archive-tier", + want: "Storage warning. Latency (p99) above threshold on archive-tier", + }, + { + name: "VMware alarm strips only the one appended consumer summary", + alertType: "storage-incident", + incidentCode: "vmware_alarm_state", + message: "Latency high. Affects production. Puts backups for 2 protected workloads at risk", + resourceName: "archive-tier", + want: "Latency high. Affects production on archive-tier", + }, + { + name: "VMware legacy alarm strips only its final colour", + alertType: "resource-incident", + incidentCode: "vmware_alarm_state", + message: "Network network-302 has VMware alarm Storage (yellow) (red)", + resourceName: "Edge Stateful", + want: "Storage (yellow) on Edge Stateful", + }, + { + name: "VMware alarm recorded before the alarm-name summary on storage", + alertType: "storage-incident", + incidentCode: "vmware_alarm_state", message: "Datastore datastore-202 has VMware alarm Datastore latency above threshold (yellow). Affects 2 dependent resources", resourceName: "archive-tier", want: "Datastore latency above threshold on archive-tier", @@ -260,10 +302,19 @@ func TestAttentionTitleProducesHumanReadableLabels(t *testing.T) { { name: "VMware host status uses product language", alertType: "resource-incident", - message: "Host has VMware overall status yellow", + incidentCode: "vmware_health_state", + message: "vCenter health is yellow", resourceName: "esxi-07.lab.local", want: "VMware host health on esxi-07.lab.local", }, + { + name: "VMware datastore status names the datastore", + alertType: "storage-incident", + incidentCode: "vmware_health_state", + message: "vCenter health is yellow", + resourceName: "edge-cold-iscsi", + want: "VMware datastore health on edge-cold-iscsi", + }, { name: "generic incident avoids incident placeholder", alertType: "resource_incident", @@ -294,7 +345,11 @@ func TestAttentionTitleProducesHumanReadableLabels(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { - got := attentionTitle(alerts.Alert{Type: test.alertType, Message: test.message}, test.resourceName) + alert := alerts.Alert{Type: test.alertType, Message: test.message} + if test.incidentCode != "" { + alert.Metadata = map[string]interface{}{"incidentCode": test.incidentCode} + } + got := attentionTitle(alert, test.resourceName) if got != test.want { t.Fatalf("attentionTitle() = %q, want %q", got, test.want) } diff --git a/internal/ai/patrol_ai.go b/internal/ai/patrol_ai.go index 280f36b8f8..41e3feacfa 100644 --- a/internal/ai/patrol_ai.go +++ b/internal/ai/patrol_ai.go @@ -2626,6 +2626,19 @@ func patrolPrecomputeStorageSources(snap patrolRuntimeState, scopedSet map[strin return rows } +// seedAlertSubject names the alert's resource unless its message already does. +// Provider incidents such as a vCenter alarm ("Network packet loss above +// threshold") say only what was flagged, so without the name two resources with +// the same alarm read as one. +func seedAlertSubject(alert models.Alert) string { + message := strings.TrimSpace(alert.Message) + name := strings.TrimSpace(alert.ResourceName) + if name == "" || strings.Contains(message, name) { + return message + } + return name + ": " + message +} + func patrolActiveAlertsInScope(snap patrolRuntimeState, scopedSet map[string]bool) []models.Alert { if scopedSet == nil { return snap.ActiveAlerts @@ -3476,7 +3489,7 @@ func (p *PatrolService) seedHealthAndAlertsState(snap patrolRuntimeState, scoped sb.WriteString("# Active Alerts\n") for _, a := range alerts { since := seedFormatTimeAgo(now, a.StartTime) - sb.WriteString(fmt.Sprintf("- [%s] %s — since %s\n", a.Level, a.Message, since)) + sb.WriteString(fmt.Sprintf("- [%s] %s — since %s\n", a.Level, seedAlertSubject(a), since)) } sb.WriteString("\n") } @@ -3486,7 +3499,7 @@ func (p *PatrolService) seedHealthAndAlertsState(snap patrolRuntimeState, scoped sb.WriteString("# Recently Resolved Alerts\n") for _, r := range alerts { ago := seedFormatTimeAgo(now, r.ResolvedTime) - sb.WriteString(fmt.Sprintf("- %s — resolved %s\n", r.Alert.Message, ago)) + sb.WriteString(fmt.Sprintf("- %s — resolved %s\n", seedAlertSubject(r.Alert), ago)) } sb.WriteString("\n") } diff --git a/internal/ai/patrol_ai_more_test.go b/internal/ai/patrol_ai_more_test.go index c9c22b90e4..b7f168e86d 100644 --- a/internal/ai/patrol_ai_more_test.go +++ b/internal/ai/patrol_ai_more_test.go @@ -1212,3 +1212,25 @@ func TestSeedIntelligenceContext_WithData(t *testing.T) { } } } + +func TestSeedAlertSubjectNamesTheResourceOnce(t *testing.T) { + tests := []struct { + alert models.Alert + want string + }{ + { + alert: models.Alert{ResourceName: "Edge Stateful", Message: "Network packet loss above threshold"}, + want: "Edge Stateful: Network packet loss above threshold", + }, + { + alert: models.Alert{ResourceName: "Field Office Edge 01", Message: "Docker host 'Field Office Edge 01' is offline"}, + want: "Docker host 'Field Office Edge 01' is offline", + }, + {alert: models.Alert{Message: "CPU high"}, want: "CPU high"}, + } + for _, test := range tests { + if got := seedAlertSubject(test.alert); got != test.want { + t.Fatalf("seedAlertSubject(%+v) = %q, want %q", test.alert, got, test.want) + } + } +} diff --git a/internal/vmware/provider.go b/internal/vmware/provider.go index f5c55b0a85..f5be28ce96 100644 --- a/internal/vmware/provider.go +++ b/internal/vmware/provider.go @@ -1137,7 +1137,7 @@ func appendVMwareAlarmsAndHealthIncidents(entityType, managedObjectID, overallSt continue } nativeID := firstNonEmptyTrimmed(alarm.Alarm, alarm.Name, managedObjectID) - summary := vmwareAlarmIncidentSummary(entityType, managedObjectID, alarm) + summary := vmwareAlarmIncidentSummary(alarm) startedAt := alarm.TriggeredAt incidents = append(incidents, unifiedresources.ResourceIncident{ Provider: "vmware", @@ -1157,7 +1157,7 @@ func appendVMwareAlarmsAndHealthIncidents(entityType, managedObjectID, overallSt Code: "vmware_health_state", Severity: severity, Source: string(unifiedresources.SourceVMware), - Summary: vmwareOverallStatusSummary(entityType, overallStatus), + Summary: vmwareOverallStatusSummary(overallStatus), }) } } @@ -1175,44 +1175,26 @@ func vmwareRiskLevel(status string) (storagehealth.RiskLevel, bool) { } } -func vmwareAlarmIncidentSummary(entityType, managedObjectID string, alarm InventoryAlarm) string { - entityLabel := vmwareEntityLabel(entityType) - alarmName := firstNonEmptyTrimmed(alarm.Name, alarm.Alarm) - status := strings.ToLower(strings.TrimSpace(alarm.OverallStatus)) - if alarmName == "" { - alarmName = "VMware alarm" +// An incident summary is the alert message and the reason a row shows in +// vSphere's Health column, beside the resource's name and a severity badge. +// So it says only what vCenter flagged: the alarm's own name ("Network packet +// loss above threshold"), not the managed object ID (network-302) or the +// alarm colour, which the name and severity already carry. +func vmwareAlarmIncidentSummary(alarm InventoryAlarm) string { + if alarmName := firstNonEmptyTrimmed(alarm.Name, alarm.Alarm); alarmName != "" { + return alarmName } - if status == "" { - status = "active" - } - if ref := strings.TrimSpace(managedObjectID); ref != "" { - return fmt.Sprintf("%s %s has VMware alarm %s (%s)", entityLabel, ref, alarmName, status) - } - return fmt.Sprintf("%s has VMware alarm %s (%s)", entityLabel, alarmName, status) + return "vCenter alarm" } -func vmwareOverallStatusSummary(entityType, overallStatus string) string { - entityLabel := vmwareEntityLabel(entityType) +// Without a triggered alarm, vCenter's overall status colour is all there is: +// vCenter admins know its yellow and red. +func vmwareOverallStatusSummary(overallStatus string) string { status := strings.ToLower(strings.TrimSpace(overallStatus)) if status == "" { status = "degraded" } - return fmt.Sprintf("%s has VMware overall status %s", entityLabel, status) -} - -func vmwareEntityLabel(entityType string) string { - switch strings.ToLower(strings.TrimSpace(entityType)) { - case "host": - return "Host" - case "vm": - return "VM" - case "datastore": - return "Datastore" - case "network": - return "Network" - default: - return "Resource" - } + return "vCenter health is " + status } func vmwareAlarmSummary(alarms []InventoryAlarm) string { diff --git a/internal/vmware/provider_incident_summary_test.go b/internal/vmware/provider_incident_summary_test.go new file mode 100644 index 0000000000..45730fbf3a --- /dev/null +++ b/internal/vmware/provider_incident_summary_test.go @@ -0,0 +1,41 @@ +package vmware + +import "testing" + +// An incident summary is the alert message and the reason vSphere's Health +// column shows beside the resource name, so it says what vCenter flagged and +// nothing the row already shows: no managed object ID, no alarm colour. +func TestVMwareIncidentSummariesLeadWithWhatVCenterFlagged(t *testing.T) { + network := InventoryNetwork{ + Network: "network-302", + Name: "Edge Stateful", + OverallStatus: "yellow", + TriggeredAlarms: []InventoryAlarm{ + {Alarm: "alarm-41", Name: "Network packet loss above threshold", OverallStatus: "yellow"}, + {Alarm: "alarm-42", OverallStatus: "red"}, + {Name: "Cleared alarm", OverallStatus: "green"}, + }, + } + incidents := networkIncidents(network) + if len(incidents) != 2 { + t.Fatalf("network incidents = %+v, want the yellow and red alarms only", incidents) + } + if got := incidents[0].Summary; got != "Network packet loss above threshold" { + t.Fatalf("named alarm summary = %q, want the alarm's own name", got) + } + if got := incidents[1].Summary; got != "alarm-42" { + t.Fatalf("unnamed alarm summary = %q, want its alarm ID", got) + } + if got := vmwareAlarmIncidentSummary(InventoryAlarm{OverallStatus: "red"}); got != "vCenter alarm" { + t.Fatalf("anonymous alarm summary = %q, want vCenter alarm", got) + } + + datastore := InventoryDatastore{Datastore: "datastore-204", Name: "edge-cold-iscsi", OverallStatus: "yellow"} + incidents = datastoreIncidents(datastore) + if len(incidents) != 1 || incidents[0].Code != "vmware_health_state" { + t.Fatalf("datastore incidents = %+v, want one overall status incident", incidents) + } + if got := incidents[0].Summary; got != "vCenter health is yellow" { + t.Fatalf("overall status summary = %q, want vCenter health is yellow", got) + } +} diff --git a/scripts/release_control/canonical_completion_guard_test.py b/scripts/release_control/canonical_completion_guard_test.py index 0202ab2631..b5a72494e0 100644 --- a/scripts/release_control/canonical_completion_guard_test.py +++ b/scripts/release_control/canonical_completion_guard_test.py @@ -2466,6 +2466,7 @@ None yet. "frontend-modern/src/features/alerts/__tests__/AlertDeadManDestinationSection.test.tsx", "frontend-modern/src/features/alerts/__tests__/AlertIncidentSynthesisSummary.test.tsx", "frontend-modern/src/features/alerts/__tests__/AlertIntentPolicyPanel.test.tsx", + "frontend-modern/src/features/alerts/__tests__/AlertOverviewAlertCard.system-scope.test.tsx", "frontend-modern/src/features/alerts/__tests__/OverviewTab.emptystate.test.tsx", "frontend-modern/src/features/alerts/__tests__/OverviewTab.timelineerror.test.tsx", "frontend-modern/src/features/alerts/__tests__/OverviewTab.total24h.test.tsx",