Say why vSphere datastores and networks need attention (#2559)

An amber vSphere datastore or network gave no reason on its row. The
vCenter alarm behind it was 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 Network packet loss above thres…",
so the Overview cut off the part that mattered.

**What changes**
- **Wording.** A vCenter incident summary is now what vCenter flagged:
the alarm's own name ("Network packet loss above threshold"), or
"vCenter health is yellow" when there is no alarm. Every surface that
shows it already names the resource and severity: alert cards, email and
Apprise titles, every webhook template, the Overview and Health signal
tables, the drawer and Patrol titles.
- **Patrol.** Titles key off the alert's `incidentCode` instead of
parsing the sentence, and still read records stored in the old wording.
Each alert line in Patrol's seed context now names its resource, so two
resources with the same alarm stay distinguishable.
- **Alert links.** The alert card used to find vSphere by searching the
message for "vmware". It now routes on the incident provider. ESXi host
alarms now link to vSphere too; on main they linked to Machines, which
does not list hosts. The card's test is now a registered alerts proof.
- **Rows that explain their dot.** vSphere Datastores and Networks get
the exception-first Health column that TrueNAS storage has. The cell
moves into a shared `PlatformIssueReason`, and TrueNAS now uses it with
identical markup, classes, colours and sizes in light and dark. A row's
dot and its reason come from one classification. An inaccessible
datastore in maintenance stays red and gives both reasons.
- **Layout.** Hosts shows only from a 72rem container, where it reads
instead of truncating; the drawer keeps it at every width. The tablet
band splits columns by weight.

**Verification**
- **Private mock stack, before and after.**
  - Column sweep from 390 to 1920px: names never clip.
- Hosts no longer clips at 600–1024, where main clipped 7–8 rows, and
clips less than main at 1280 and 1440.
- Only the longest alarm names truncate in the tablet band, with the
full text on hover.
- **Exercised live:** Health sort, the Attention filter, row drawers,
dark mode, the Alerts overview links, Patrol's full inbox and the
vSphere Health tab.
- **Tests:** 3,305 frontend tests and the `internal/ai`,
`internal/vmware`, `internal/unifiedresources`, alerts and monitoring
packages pass, along with the shared-template audit and the completion
guard.
- **Review:** a second reviewer found eight issues over three passes,
and all are fixed with tests:
  - the vSphere alert link;
  - the seed prompt identity;
  - maintenance masking an inaccessible datastore;
  - the dot disagreeing with the reason;
  - stored old-format titles;
  - the ESXi host link;
  - two title-trimming edge cases.

One suggestion was left on purpose: acknowledged cards hide severity for
every alert type by design.
This commit is contained in:
rcourtman
2026-10-06 11:11:52 +01:00
committed by GitHub
24 changed files with 828 additions and 121 deletions
@@ -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 "<entity> <moref> has VMware alarm
<name> (<colour>)", 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
@@ -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
@@ -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
@@ -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",
@@ -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
<colour>`, 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
@@ -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"
]
}
@@ -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') ||
@@ -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(() => (
<AlertOverviewAlertCard alert={alert} state={state} timelineState={timelineState} />
));
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(() => (
<AlertOverviewAlertCard alert={alert} state={state} timelineState={timelineState} />
));
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 = {
@@ -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<PlatformIssueTone, string> = {
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<HTMLDivElement>,
'class' | 'title' | 'children'
>
> = (props) => {
const [local, rest] = splitProps(props, ['issue']);
return (
<Show when={local.issue}>
{(issue) => (
<div
{...rest}
class={`flex min-w-0 items-center gap-1 text-[11px] font-medium ${ISSUE_TONE_CLASS[issue().tone]}`}
title={issue().reasons.join('\n')}
>
<span aria-hidden="true" class="min-w-0 truncate">
{issue().reasons[0] ?? issue().label}
</span>
<Show when={issue().reasons.length > 1}>
<span aria-hidden="true" class="shrink-0 tabular-nums">
+{issue().reasons.length - 1}
</span>
</Show>
<span class="sr-only">{issueScreenReaderText(issue())}</span>
</div>
)}
</Show>
);
};
@@ -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(() => <PlatformIssueReason issue={null} />);
expect(container.textContent).toBe('');
});
it('shows the first reason, counts the rest, and reads every reason out', () => {
const { container } = render(() => (
<PlatformIssueReason
issue={{
tone: 'warning',
label: 'Attention',
reasons: ['Datastore latency above threshold', 'vCenter has not updated this recently.'],
}}
data-testid="reason"
/>
));
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(() => (
<PlatformIssueReason
issue={{
tone: 'danger',
label: 'Inaccessible',
reasons: ['vCenter reports it inaccessible'],
}}
data-testid="reason"
/>
));
const cell = container.querySelector('[data-testid="reason"]') as HTMLElement;
expect(cell).toHaveClass('text-red-600');
expect(cell.querySelectorAll('[aria-hidden="true"]')).toHaveLength(1);
});
});
@@ -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('<VsphereDatastoresTable');
expect(vmwarePageSurfaceSource).toContain('<VsphereNetworksTable');
expect(vsphereNetworksTableSource).toMatch(
/sortKey="vms"[\s\S]{0,120}?class="platform-table-mobile-w-15 md:w-\[7%\]"/,
/sortKey="vms"[\s\S]{0,120}?class="platform-table-mobile-w-15 md:w-\[5%\]"/,
);
expect(vmwarePageSurfaceSource).toContain('<VsphereActivityTable');
expect(vmwarePageSurfaceSource).not.toContain('<StorageSurface');
@@ -25,6 +25,11 @@ import {
PlatformTableShell,
withPlatformStatusCounts,
} from '@/features/platformPage/sharedPlatformPage';
import {
PlatformIssueReason,
type PlatformIssue,
type PlatformIssueTone,
} from '@/features/platformPage/PlatformIssueReason';
import {
PlatformResourceDetailToggleButton,
PlatformResourceDetailTableRow,
@@ -193,44 +198,14 @@ const DiskEnduranceCell: Component<{ row: TrueNASStorageTopologyRow }> = (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<TrueNASStorageIssue['status'], PlatformIssueTone> = {
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) => (
<Show when={props.issue}>
{(issue) => (
<div
class={`flex min-w-0 items-center gap-1 text-[11px] font-medium ${issueReasonClass(issue())}`}
title={issue().reasons.join('\n')}
data-truenas-storage-health={issue().status}
>
<span aria-hidden="true" class="min-w-0 truncate">
{issue().reasons[0] ?? issue().label}
</span>
<Show when={issue().reasons.length > 1}>
<span aria-hidden="true" class="shrink-0 tabular-nums">
+{issue().reasons.length - 1}
</span>
</Show>
<span class="sr-only">{issueScreenReaderText(issue())}</span>
</div>
)}
</Show>
);
const toPlatformIssue = (issue: TrueNASStorageIssue | null): PlatformIssue | null =>
issue && { tone: ISSUE_TONES[issue.status], label: issue.label, reasons: issue.reasons };
const HEALTH_SORT_RANK: Record<TrueNASStorageIssue['status'] | 'healthy', number> = {
attention: 0,
@@ -592,7 +567,10 @@ export const TrueNASStorageTopologyTable: Component<{
<TableCell
class={`${getPlatformTableCellClassForKind('text')} platform-table-phone-hidden`}
>
<IssueSummary issue={issue()} />
<PlatformIssueReason
issue={toPlatformIssue(issue())}
data-truenas-storage-health={issue()?.status}
/>
</TableCell>
</TableRow>
<PlatformResourceDetailTableRow
@@ -3,7 +3,6 @@ import { StatusDot } from '@/components/shared/StatusDot';
import { StackedDiskBar } from '@/components/Workloads/StackedDiskBar';
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,
@@ -22,6 +21,7 @@ import {
PlatformTableShell,
withPlatformStatusCounts,
} from '@/features/platformPage/sharedPlatformPage';
import { PlatformIssueReason } from '@/features/platformPage/PlatformIssueReason';
import {
PlatformResourceDetailToggleButton,
PlatformResourceDetailTableRow,
@@ -32,7 +32,9 @@ import {
import type { Resource } from '@/types/resource';
import {
filterVmwareDatastores,
getVmwareResourceDisplayStatus,
getVmwareDatastoreIssue,
vmwareIssueSortRank,
getVmwareRowIndicator,
type VmwareDatastoreStatusFilter,
} from './vmwarePageModel';
@@ -104,7 +106,14 @@ const hasCapacityMetric = (resource: Resource): boolean => {
// 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
</PlatformSortableTableHead>
@@ -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
</PlatformSortableTableHead>
@@ -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}
>
<PlatformResponsiveTableLabel compact="Cap" full="Capacity" />
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="text"
sort={sort}
class="platform-table-phone-hidden md:w-[14%]"
class="hidden md:table-cell md:w-[16%]"
>
<PlatformResponsiveTableLabel compact="H" full="Hosts" />
Hosts
</PlatformSortableTableHead>
<PlatformSortableTableHead
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={9}
>
VMs
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="text"
sort={sort}
class="hidden lg:table-cell md:w-[13%]"
class="hidden lg:table-cell md:w-[15%]"
>
Consumers
</PlatformSortableTableHead>
@@ -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
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="text"
sort={sort}
sortKey="health"
class="platform-table-phone-hidden md:w-[18%]"
bandWidth={30}
>
Health
</PlatformSortableTableHead>
</>
}
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<{
</Show>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} platform-table-phone-hidden text-base-content`}
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content md:table-cell`}
title={hosts().title}
>
<span class="block truncate">{hosts().label}</span>
@@ -339,12 +363,20 @@ export const VsphereDatastoresTable: Component<{
>
<span class="block truncate">{datacenter()}</span>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} platform-table-phone-hidden`}
>
<PlatformIssueReason
issue={issue()}
data-vsphere-health={issue()?.tone}
/>
</TableCell>
</TableRow>
<PlatformResourceDetailTableRow
resource={datastore}
open={isExpanded()}
detailRowId={detailRowId()}
colSpan={7}
colSpan={8}
resolveResourceLabel={resolveResourceLabel}
onClose={() => drawer.close(datastore)}
/>
@@ -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
</PlatformSortableTableHead>
@@ -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
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="text"
sort={sort}
class="platform-table-phone-hidden md:w-[18%]"
class="hidden md:table-cell md:w-[18%]"
>
Hosts
</PlatformSortableTableHead>
@@ -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
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="text"
sort={sort}
class="hidden lg:table-cell md:w-[18%]"
class="hidden lg:table-cell md:w-[20%]"
>
Connected VMs
</PlatformSortableTableHead>
@@ -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}
>
<PlatformResponsiveTableLabel compact="DC" full="Datacenter" />
<PlatformResponsiveTableLabel compact="DC" full="Datacenter" compactInBand />
</PlatformSortableTableHead>
<PlatformSortableTableHead
kind="text"
sort={sort}
sortKey="health"
class="platform-table-phone-hidden md:w-[19%]"
bandWidth={30}
>
Health
</PlatformSortableTableHead>
</>
}
@@ -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<{
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
>
<span class="font-mono text-[11px] text-base-content">
{networkType(network)}
</span>
<span class="block truncate">{networkType(network)}</span>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} platform-table-phone-hidden text-base-content`}
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content md:table-cell`}
title={hosts().title}
>
<span class="block truncate">{hosts().label}</span>
@@ -301,12 +316,20 @@ export const VsphereNetworksTable: Component<{
>
<span class="block truncate">{datacenter()}</span>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} platform-table-phone-hidden`}
>
<PlatformIssueReason
issue={issue()}
data-vsphere-health={issue()?.tone}
/>
</TableCell>
</TableRow>
<PlatformResourceDetailTableRow
resource={network}
open={isExpanded()}
detailRowId={detailRowId()}
colSpan={6}
colSpan={7}
resolveResourceLabel={resolveResourceLabel}
onClose={() => drawer.close(network)}
/>
@@ -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<string, unknown>) =>
({ 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(() => (
<VsphereDatastoresTable
datastores={all}
scope={all}
emptyIcon={<span />}
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');
});
});
@@ -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(() => (
<VsphereNetworksTable
networks={[healthy, alarmed, countedOnly, red]}
scope={[healthy, alarmed, countedOnly, red]}
emptyIcon={<span />}
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');
});
});
@@ -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_-]+/)
+38 -11
View File
@@ -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 <name> (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 {
+57 -2
View File
@@ -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)
}
+15 -2
View File
@@ -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")
}
+22
View File
@@ -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)
}
}
}
+15 -33
View File
@@ -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 {
@@ -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)
}
}
@@ -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",