diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
index 3425d3b61f..269e8b4e05 100644
--- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md
+++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
@@ -5427,18 +5427,24 @@ Compact platform table timestamps follow the same rule.
`PlatformTableDateTimeValue` and `formatPlatformTableDateTimeValue` own compact
date-time parsing, invalid/empty markers, optional minimum-year filtering, Intl
format options, and tabular-number styling for dense timestamp cells. TrueNAS
-protection completed-time cells and vSphere activity "When" cells must compose
-that primitive instead of declaring local compact `toLocaleString` helpers, and
-timestamp columns use the canonical `numeric-value` alignment kind because they
-are scannable scalar values.
+protection completed-time cells must compose that primitive instead of
+declaring local compact `toLocaleString` helpers, and timestamp columns use the
+canonical `numeric-value` alignment kind because they are scannable scalar
+values. vSphere activity "When" cells read as an age instead: the absolute
+"Oct 4, 11:22 PM" clipped in its column at most widths, and recent vCenter
+activity is scanned by how long ago it happened, as Kubernetes events are. They
+compose `PlatformTableRelativeTimeValue` with the exact time in the cell's
+hover title and in the row drawer, and a vCenter time before 2000 (its unset
+marker) stays an empty marker rather than a decades-old age.
Relative timestamp-age cells follow the same rule.
`PlatformTableRelativeTimeValue` and `formatPlatformTableRelativeTimeValue` own
the repeated `formatRelativeTime` composition, compact-label default,
invalid/empty markers, and tabular-number styling for dense platform table
cells. Docker / Podman volume created-at cells, Kubernetes deployment age
cells, Kubernetes event observed-time cells, Proxmox backup created-age cells,
-Proxmox replication last-sync cells, Standalone machine last-seen cells, and
-Standalone availability-check checked-at cells must compose that primitive
+Proxmox replication last-sync cells, Standalone machine last-seen cells,
+Standalone availability-check checked-at cells, and vSphere activity "When"
+cells must compose that primitive
instead of importing `formatRelativeTime` directly or declaring local
timestamp-age helpers in table files.
Duration and interval cells follow the same rule.
diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md
index f5493038c3..4494318107 100644
--- a/docs/release-control/v6/internal/subsystems/unified-resources.md
+++ b/docs/release-control/v6/internal/subsystems/unified-resources.md
@@ -1577,6 +1577,15 @@ legacy/direct object routes fall back to `Overview` when the requested workflow
has no rows for the current setup. Signals outside unified-resource inventory,
such as TrueNAS recovery protection points or vSphere activity timeline rows,
must be treated as explicit tab evidence rather than permanent navigation.
+A vSphere event activity row reads as vCenter's own sentence. The VMware
+client asks `QueryEvents` for each event's `fullFormattedMessage` explicitly,
+since vCenter leaves it out when `disableFullMessage` is unset. The provider
+titles the event change with that message and falls back to the event class
+(`HostConnectedEvent`) only when vCenter sends none. The class stays in
+`vmwareEventType` metadata, where activity search and the row drawer's Event
+type row read it. Rows stored before that titling still carry the class as
+their title, so the page model prefers an event's `vmwareEventMessage` when it
+has one.
Primary platform navigation is also resource-evidence gated: runtime lenses such
as Docker / Podman must be admitted from explicit Docker source scopes, Docker
host or service resource types, or concrete runtime identity/inventory evidence
diff --git a/frontend-modern/browser-verification/fc97004258673c56.json b/frontend-modern/browser-verification/fc97004258673c56.json
new file mode 100644
index 0000000000..453ef8069f
--- /dev/null
+++ b/frontend-modern/browser-verification/fc97004258673c56.json
@@ -0,0 +1,64 @@
+{
+ "version": 1,
+ "base_sha": "af4e6a493cbca1e6c7d4397964efb146e950fca2",
+ "verified_at": "2026-10-06T10:34:56Z",
+ "result": "passed",
+ "changed_paths": [
+ "frontend-modern/src/features/vmware/VsphereActivityTable.tsx",
+ "frontend-modern/src/features/vmware/vmwarePageModel.ts"
+ ],
+ "content_sha256": {
+ "frontend-modern/src/features/vmware/VsphereActivityTable.tsx": "389e3884c3a14f608d024661cd02b5f5a10e8bb2f0c331fe3ba519f09c769078",
+ "frontend-modern/src/features/vmware/vmwarePageModel.ts": "df4ae1bc1d02ec68471f78500942e1d0176ebfebf59038ffeb0b2f3c89cc42b2"
+ },
+ "routes": [
+ "/vmware/activity",
+ "/vmware/health"
+ ],
+ "viewports": [
+ {
+ "width": 390,
+ "height": 844
+ },
+ {
+ "width": 600,
+ "height": 844
+ },
+ {
+ "width": 768,
+ "height": 1000
+ },
+ {
+ "width": 1024,
+ "height": 1000
+ },
+ {
+ "width": 1280,
+ "height": 1000
+ },
+ {
+ "width": 1440,
+ "height": 1000
+ },
+ {
+ "width": 1920,
+ "height": 1000
+ }
+ ],
+ "states": [
+ "Private mock stack on main af4e6a493c (which now carries the vSphere Health column, #2559, and the ownership fix, #2560) plus this change, backend rebuilt from the tree; mock vCenter with three tasks and two events whose change records were stored before the change (event class as their title)",
+ "Before (main): event rows read 'HostConnectedEvent' and 'HostProfileAppliedEvent'; the When column clipped 'Oct 4, 11:22 PM' on every row at 390, 768, 1024, 1280 and 1440",
+ "After: event rows read vCenter's own sentence ('Host reconnected to vCenter and is reporting healthy telemetry') including the records stored before the change; tasks keep their task names",
+ "When reads '1d ago' and clips at no width; its hover title is the exact time ('Oct 4, 2026, 11:22 PM'); a vCenter time before 2000 renders '-' (unit test)",
+ "Row drawer: Activity row carries the sentence, a new Event type row carries the class (HostConnectedEvent), and the Message row no longer repeats the title",
+ "Longer event sentences truncate in the Activity column on phones and tablets (390-768) with the full sentence on hover and in the drawer; none clip from 1280",
+ "Light and dark mode at 1440 checked",
+ "After rebasing onto that main: the Activity sweep is identical to the pre-rebase run, and the Datastores and Networks Health columns measure exactly as verified for #2559 (no names clip; only the longest alarm name truncates at 768)"
+ ],
+ "interactions": [
+ "Measured every visible header and cell for clipping at seven widths before and after",
+ "Opened an event row's drawer in light and dark mode and read every row",
+ "Read the When cell hover titles",
+ "Searched by event class in the model (unit test): the row it no longer titles is still found"
+ ]
+}
diff --git a/frontend-modern/scripts/shared-template-registry.json b/frontend-modern/scripts/shared-template-registry.json
index ebe7107d1d..e4cb1a9da1 100644
--- a/frontend-modern/scripts/shared-template-registry.json
+++ b/frontend-modern/scripts/shared-template-registry.json
@@ -2546,20 +2546,12 @@
"export": "PlatformTableDateTimeValue"
},
"requiredConsumers": [
- { "path": "src/features/truenas/TrueNASProtectionTable.tsx" },
- { "path": "src/features/vmware/VsphereActivityTable.tsx" }
+ { "path": "src/features/truenas/TrueNASProtectionTable.tsx" }
],
"forbiddenPatterns": [
{
"path": "src/features/truenas/TrueNASProtectionTable.tsx",
"patterns": ["const formatPointTime", "formatPointTime(point)"]
- },
- {
- "path": "src/features/vmware/VsphereActivityTable.tsx",
- "patterns": [
- "const formatActivityDate",
- "formatActivityDate(activity.occurredAt || activity.observedAt)"
- ]
}
],
"proof": [
@@ -2583,7 +2575,8 @@
{ "path": "src/features/proxmox/proxmoxBackupsTableShared.tsx" },
{ "path": "src/features/proxmox/ProxmoxReplicationTable.tsx" },
{ "path": "src/features/standalone/AvailabilityChecksTable.tsx" },
- { "path": "src/features/standalone/AgentsMachinesTable.tsx" }
+ { "path": "src/features/standalone/AgentsMachinesTable.tsx" },
+ { "path": "src/features/vmware/VsphereActivityTable.tsx" }
],
"forbiddenPatterns": [
{
@@ -2618,6 +2611,13 @@
{
"path": "src/features/standalone/AvailabilityChecksTable.tsx",
"patterns": ["formatRelativeTime(availability?.lastChecked", "const formatChecked"]
+ },
+ {
+ "path": "src/features/vmware/VsphereActivityTable.tsx",
+ "patterns": [
+ "const formatActivityDate",
+ "formatActivityDate(activity.occurredAt || activity.observedAt)"
+ ]
}
],
"proof": [
@@ -4464,7 +4464,7 @@
"summary": "vSphere activity tables must not recreate compact activity-date helpers for timestamp cells.",
"canonical": {
"path": "src/features/platformPage/sharedPlatformPage.tsx",
- "export": "PlatformTableDateTimeValue"
+ "export": "PlatformTableRelativeTimeValue"
},
"scopes": ["src/features/vmware/VsphereActivityTable.tsx"],
"extensions": [".tsx"],
@@ -4473,7 +4473,7 @@
"toLocaleString(undefined, {",
"minute: '2-digit'"
],
- "legacyReason": "Retired migration debt. Compact platform timestamp cells must compose PlatformTableDateTimeValue so date parsing, empty markers, minimum-year handling, and tabular styling stay shared.",
+ "legacyReason": "Retired migration debt. vSphere activity reads as an age through PlatformTableRelativeTimeValue, with the exact time on hover and in the drawer, so date parsing, empty markers, and tabular styling stay shared.",
"allowedPaths": [],
"ignoredPaths": [],
"proof": [
diff --git a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts
index 967c3be171..7973df8bb9 100644
--- a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts
+++ b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts
@@ -6283,7 +6283,6 @@ describe('shared primitive guardrails', () => {
);
const platformDateTimeConsumers: Array<[string, string]> = [
['src/features/truenas/TrueNASProtectionTable.tsx', truenasProtectionTableSource],
- ['src/features/vmware/VsphereActivityTable.tsx', vsphereActivityTableSource],
];
const platformDateTimeConsumerPaths = platformDateTimeConsumers.map(([path]) => path);
@@ -6299,13 +6298,6 @@ describe('shared primitive guardrails', () => {
path: 'src/features/truenas/TrueNASProtectionTable.tsx',
patterns: ['const formatPointTime', 'formatPointTime(point)'],
},
- {
- path: 'src/features/vmware/VsphereActivityTable.tsx',
- patterns: [
- 'const formatActivityDate',
- 'formatActivityDate(activity.occurredAt || activity.observedAt)',
- ],
- },
]);
expect(truenasDateGuard?.canonical?.path).toBe(
'src/features/platformPage/sharedPlatformPage.tsx',
@@ -6322,7 +6314,7 @@ describe('shared primitive guardrails', () => {
expect(vsphereDateGuard?.canonical?.path).toBe(
'src/features/platformPage/sharedPlatformPage.tsx',
);
- expect(vsphereDateGuard?.canonical?.export).toBe('PlatformTableDateTimeValue');
+ expect(vsphereDateGuard?.canonical?.export).toBe('PlatformTableRelativeTimeValue');
expect(vsphereDateGuard?.allPatterns).toEqual([
'const formatActivityDate',
'toLocaleString(undefined, {',
@@ -6346,9 +6338,10 @@ describe('shared primitive guardrails', () => {
}
// The When header renders through the shared sortable head; the
- // numeric-value alignment intent now lives on its kind prop.
+ // numeric-value alignment intent now lives on its kind prop. vCenter's
+ // unset times (before 2000) stay an empty marker, not a decades-old age.
expect(vsphereActivityTableSource).toContain('kind="numeric-value"');
- expect(vsphereActivityTableSource).toContain('minYear={2000}');
+ expect(vsphereActivityTableSource).toContain('getUTCFullYear() < 2000');
});
it('keeps platform table relative timestamps on the shared primitive', () => {
@@ -6384,6 +6377,7 @@ describe('shared primitive guardrails', () => {
['src/features/proxmox/ProxmoxReplicationTable.tsx', proxmoxReplicationTableSource],
['src/features/standalone/AvailabilityChecksTable.tsx', availabilityChecksTableSource],
['src/features/standalone/AgentsMachinesTable.tsx', agentsMachinesTableSource],
+ ['src/features/vmware/VsphereActivityTable.tsx', vsphereActivityTableSource],
];
const platformRelativeTimeConsumerPaths = platformRelativeTimeConsumers.map(([path]) => path);
@@ -6428,6 +6422,13 @@ describe('shared primitive guardrails', () => {
path: 'src/features/standalone/AvailabilityChecksTable.tsx',
patterns: ['formatRelativeTime(availability?.lastChecked', 'const formatChecked'],
},
+ {
+ path: 'src/features/vmware/VsphereActivityTable.tsx',
+ patterns: [
+ 'const formatActivityDate',
+ 'formatActivityDate(activity.occurredAt || activity.observedAt)',
+ ],
+ },
]);
expect(localHelperGuard?.canonical?.path).toBe(
'src/features/platformPage/sharedPlatformPage.tsx',
diff --git a/frontend-modern/src/features/vmware/VsphereActivityTable.tsx b/frontend-modern/src/features/vmware/VsphereActivityTable.tsx
index 34a21c61c8..6fabf2dcc7 100644
--- a/frontend-modern/src/features/vmware/VsphereActivityTable.tsx
+++ b/frontend-modern/src/features/vmware/VsphereActivityTable.tsx
@@ -14,7 +14,7 @@ import { filterChipStatusDot } from '@/components/shared/FilterBar';
import {
PlatformWindowedRows,
PlatformSortableTableHead,
- PlatformTableDateTimeValue,
+ PlatformTableRelativeTimeValue,
PlatformTableEmptyState,
PlatformTableToolbar,
createPlatformTableFilterState,
@@ -145,6 +145,20 @@ const activityTone = (bucket: VmwareActivityStateBucket): ActivityDetailTone =>
return 'muted';
};
+// An event's title is vCenter's own message when it sent one, so the message
+// and description rows would only repeat it.
+const distinctFromTitle = (activity: VmwareActivityRow, value: string): string =>
+ value.trim() && value.trim() !== activity.title.trim() ? value : '';
+
+// Recent activity reads as an age ("3h ago"); the exact time is on hover and
+// in the drawer. vCenter reports unset times as dates before 2000.
+const activityTimestamp = (activity: VmwareActivityRow): string | undefined => {
+ const value = activity.occurredAt || activity.observedAt;
+ if (!value) return undefined;
+ const parsed = new Date(value);
+ return Number.isNaN(parsed.getTime()) || parsed.getUTCFullYear() < 2000 ? undefined : value;
+};
+
const buildActivityDetailSections = (activity: VmwareActivityRow): ActivityDetailSection[] => {
const meta = activity.resource.vmware;
return compactDetailSections([
@@ -156,8 +170,9 @@ const buildActivityDetailSections = (activity: VmwareActivityRow): ActivityDetai
tone: activityTone(activity.stateBucket),
}),
detailRow('Activity', activity.title),
- detailRow('Message', activity.message),
- detailRow('Description', activity.description),
+ detailRow('Event type', activity.eventType),
+ detailRow('Message', distinctFromTitle(activity, activity.message)),
+ detailRow('Description', distinctFromTitle(activity, activity.description)),
]),
},
{
@@ -421,7 +436,9 @@ export const VsphereActivityTable: Component<{
class="block truncate text-base-content"
title={[
activity.title,
- activity.message || activity.description || activity.nativeId,
+ distinctFromTitle(activity, activity.message) ||
+ distinctFromTitle(activity, activity.description) ||
+ activity.nativeId,
]
.filter(Boolean)
.join(' · ')}
@@ -461,11 +478,12 @@ export const VsphereActivityTable: Component<{
-
+
+
+
diff --git a/frontend-modern/src/features/vmware/__tests__/VsphereActivityTable.test.tsx b/frontend-modern/src/features/vmware/__tests__/VsphereActivityTable.test.tsx
index 7c4d27f893..77a6f15af0 100644
--- a/frontend-modern/src/features/vmware/__tests__/VsphereActivityTable.test.tsx
+++ b/frontend-modern/src/features/vmware/__tests__/VsphereActivityTable.test.tsx
@@ -2,7 +2,7 @@ import { cleanup, fireEvent, render, screen, within } from '@solidjs/testing-lib
import { afterEach, describe, expect, it } from 'vitest';
import { VsphereActivityTable } from '@/features/vmware/VsphereActivityTable';
-import { buildVmwareActivityRows } from '@/features/vmware/vmwarePageModel';
+import { buildVmwareActivityRows, filterVmwareActivity } from '@/features/vmware/vmwarePageModel';
import type { Resource } from '@/types/resource';
const makeVm = (overrides: Partial = {}): Resource =>
@@ -55,11 +55,13 @@ const makeVm = (overrides: Partial = {}): Resource =>
sourceAdapter: 'vmware_adapter',
confidence: 'high',
actor: 'administrator@vsphere.local',
- reason: 'VmPoweredOnEvent',
+ reason: 'Virtual machine warehouse-api-01 was powered on',
metadata: {
activity_type: 'vmware_event',
activity_native_id: 'event-501',
- activity_title: 'VmPoweredOnEvent',
+ // The provider titles an event with vCenter's message, keeping the
+ // event class in vmwareEventType.
+ activity_title: 'Virtual machine warehouse-api-01 was powered on',
activity_message: 'Virtual machine warehouse-api-01 was powered on',
vmwareEventType: 'VmPoweredOnEvent',
vmwareEventMessage: 'Virtual machine warehouse-api-01 was powered on',
@@ -103,7 +105,8 @@ describe('VsphereActivityTable', () => {
screen.getByTitle('Reconfigure virtual machine · Permission denied while reconfiguring VM'),
).toBeInTheDocument();
expect(screen.getByText('Error')).toBeInTheDocument();
- expect(screen.getByText('VmPoweredOnEvent')).toBeInTheDocument();
+ expect(screen.getByText('Virtual machine warehouse-api-01 was powered on')).toBeInTheDocument();
+ expect(screen.queryByText('VmPoweredOnEvent')).not.toBeInTheDocument();
expect(screen.getByText('administrator@vsphere.local')).toBeInTheDocument();
const row = screen.getByText('Reconfigure virtual machine').closest('tr');
@@ -135,4 +138,61 @@ describe('VsphereActivityTable', () => {
'false',
);
});
+ it("reads an event as vCenter's message and its time as an age", async () => {
+ const vm = makeVm();
+ const unset = {
+ ...vm.recentChanges![1],
+ id: 'activity-event-unset-time',
+ observedAt: '1970-01-01T00:00:00Z',
+ occurredAt: '1970-01-01T00:00:00Z',
+ reason: 'Alarm status changed on warehouse-api-01',
+ metadata: {
+ ...vm.recentChanges![1].metadata,
+ activity_native_id: 'event-502',
+ // Stored before the provider titled events with vCenter's message.
+ activity_title: 'AlarmStatusChangedEvent',
+ activity_message: 'Alarm status changed on warehouse-api-01',
+ vmwareEventType: 'AlarmStatusChangedEvent',
+ vmwareEventMessage: 'Alarm status changed on warehouse-api-01',
+ },
+ };
+ const activity = buildVmwareActivityRows([
+ makeVm({ recentChanges: [...vm.recentChanges!, unset] }),
+ ]);
+
+ render(() => (
+ }
+ emptyTitle="No activity"
+ emptyDescription="No activity"
+ showToolbar={false}
+ />
+ ));
+
+ // Searching by the event class still finds the row it no longer titles.
+ expect(
+ filterVmwareActivity(activity, 'VmPoweredOnEvent', 'all').map((row) => row.nativeId),
+ ).toEqual(['event-501']);
+
+ const powered = screen.getByText('Virtual machine warehouse-api-01 was powered on');
+ // The hover title does not repeat the message the row already shows.
+ expect(powered).toHaveAttribute(
+ 'title',
+ 'Virtual machine warehouse-api-01 was powered on · event-501',
+ );
+ const poweredRow = powered.closest('tr')!;
+ const when = poweredRow.querySelector('td:last-child span[title]');
+ expect(when?.textContent).toMatch(/ago$/);
+ expect(when?.getAttribute('title')).toMatch(/2026/);
+
+ const unsetRow = screen.getByText('Alarm status changed on warehouse-api-01').closest('tr')!;
+ expect(unsetRow.querySelector('td:last-child')?.textContent).toBe('-');
+
+ await fireEvent.click(poweredRow);
+ const detail = within(screen.getByTestId('vsphere-activity-detail'));
+ expect(detail.getByText('Event type')).toBeInTheDocument();
+ expect(detail.getByText('VmPoweredOnEvent')).toBeInTheDocument();
+ expect(detail.queryByText('Message')).not.toBeInTheDocument();
+ });
});
diff --git a/frontend-modern/src/features/vmware/__tests__/vmwarePageModel.test.ts b/frontend-modern/src/features/vmware/__tests__/vmwarePageModel.test.ts
index 1201018f67..72ea8920ee 100644
--- a/frontend-modern/src/features/vmware/__tests__/vmwarePageModel.test.ts
+++ b/frontend-modern/src/features/vmware/__tests__/vmwarePageModel.test.ts
@@ -522,10 +522,13 @@ describe('vmwarePageModel', () => {
expect(mapVmwareActivityStateBucket('error')).toBe('failed');
expect(mapVmwareActivityStateBucket('success')).toBe('success');
+ // An event reads as vCenter's message even when its stored title is the
+ // event class; the class stays on the row for search and the drawer.
expect(rows.map((row) => row.title)).toEqual([
'Reconfigure virtual machine',
- 'VmPoweredOnEvent',
+ 'Virtual machine warehouse-api-01 was powered on',
]);
+ expect(rows[1]?.eventType).toBe('VmPoweredOnEvent');
expect(rows[0]).toMatchObject({
resourceName: 'warehouse-api-01',
activityKind: 'task',
diff --git a/frontend-modern/src/features/vmware/vmwarePageModel.ts b/frontend-modern/src/features/vmware/vmwarePageModel.ts
index d2186de28c..5c1d0d142c 100644
--- a/frontend-modern/src/features/vmware/vmwarePageModel.ts
+++ b/frontend-modern/src/features/vmware/vmwarePageModel.ts
@@ -86,6 +86,9 @@ export type VmwareActivityRow = {
activityType: string;
stateBucket: VmwareActivityStateBucket;
title: string;
+ // vCenter's event class (HostConnectedEvent); the title reads as vCenter's
+ // own message when it sent one, so the class is kept for search and detail.
+ eventType: string;
state: string;
message: string;
description: string;
@@ -795,7 +798,10 @@ const buildActivityRow = (
const metadata = change.metadata;
const activityType = metadataString(metadata, 'activity_type');
const activityKind = mapVmwareActivityKind(activityType, metadata);
+ // An event reads as vCenter's own message. The provider titles new events
+ // with it; records stored before that carry the event class as their title.
const title =
+ (activityKind === 'event' ? metadataString(metadata, 'vmwareEventMessage') : '') ||
metadataString(metadata, 'activity_title', 'vmwareTaskName', 'vmwareEventType') ||
trimString(change.reason) ||
'vSphere activity';
@@ -805,6 +811,7 @@ const buildActivityRow = (
metadataString(metadata, 'activity_message', 'vmwareTaskError', 'vmwareEventMessage') ||
trimString(change.reason);
const description = metadataString(metadata, 'vmwareTaskDescription', 'vmwareEventMessage');
+ const eventType = metadataString(metadata, 'vmwareEventType');
const actor = trimString(change.actor) || metadataString(metadata, 'vmwareEventUser');
const nativeId = metadataString(metadata, 'activity_native_id', 'vmwareTask', 'vmwareEvent');
const observedAt = trimString(change.observedAt);
@@ -829,6 +836,7 @@ const buildActivityRow = (
activityType,
stateBucket: mapVmwareActivityStateBucket(state),
title,
+ eventType,
state,
message,
description,
@@ -1112,6 +1120,7 @@ const activitySearchHaystack = (row: VmwareActivityRow): string =>
row.activityKind,
row.activityType,
row.title,
+ row.eventType,
row.state,
row.message,
row.description,
diff --git a/internal/vmware/activity_changes.go b/internal/vmware/activity_changes.go
index 5f600f9266..2ce1f1aabd 100644
--- a/internal/vmware/activity_changes.go
+++ b/internal/vmware/activity_changes.go
@@ -88,10 +88,13 @@ func entityActivityChanges(resourceID, connectionID, entityType, managedObjectID
SourceAdapter: unifiedresources.AdapterVMware,
ActivityType: "vmware_event",
NativeID: strings.TrimSpace(event.Event),
- Title: firstNonEmptyTrimmed(event.Type),
- Message: strings.TrimSpace(event.Message),
- Actor: strings.TrimSpace(event.User),
- OccurredAt: event.CreatedAt,
+ // vCenter's own sentence ("Host esxi-01 in Primary DC is connected")
+ // reads where the event class (HostConnectedEvent) does not; the
+ // class stays in vmwareEventType for search and the drawer.
+ Title: firstNonEmptyTrimmed(event.Message, event.Type),
+ Message: strings.TrimSpace(event.Message),
+ Actor: strings.TrimSpace(event.User),
+ OccurredAt: event.CreatedAt,
Metadata: map[string]any{
"vmwareConnectionId": strings.TrimSpace(connectionID),
"vmwareEntityType": strings.TrimSpace(entityType),
diff --git a/internal/vmware/activity_event_title_test.go b/internal/vmware/activity_event_title_test.go
new file mode 100644
index 0000000000..bdb48cfc08
--- /dev/null
+++ b/internal/vmware/activity_event_title_test.go
@@ -0,0 +1,39 @@
+package vmware
+
+import (
+ "testing"
+
+ "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
+)
+
+// An event reads as vCenter's own sentence; the event class is the fallback
+// when vCenter sends no message, and it stays in metadata for search.
+func TestVMwareEventActivityReadsAsVCenterMessage(t *testing.T) {
+ changes := entityActivityChanges("agent-host-101", "vc-1", "host", "host-101", nil, []InventoryEvent{
+ {Event: "event-1", Type: "HostConnectedEvent", Message: "Host esxi-01 in Primary DC is connected"},
+ {Event: "event-2", Type: "HostProfileAppliedEvent"},
+ })
+ if len(changes) != 2 {
+ t.Fatalf("changes = %+v, want two event changes", changes)
+ }
+
+ byEvent := map[string]unifiedresources.ResourceChange{}
+ for _, change := range changes {
+ byEvent[change.Metadata["vmwareEvent"].(string)] = change
+ }
+
+ messaged := byEvent["event-1"]
+ if messaged.Reason != "Host esxi-01 in Primary DC is connected" {
+ t.Fatalf("messaged event reason = %q, want vCenter's message", messaged.Reason)
+ }
+ if got := messaged.Metadata[unifiedresources.MetadataActivityTitle]; got != "Host esxi-01 in Primary DC is connected" {
+ t.Fatalf("messaged event title = %v, want vCenter's message", got)
+ }
+ if got := messaged.Metadata["vmwareEventType"]; got != "HostConnectedEvent" {
+ t.Fatalf("event class = %v, want it kept in metadata", got)
+ }
+
+ if got := byEvent["event-2"].Reason; got != "HostProfileAppliedEvent" {
+ t.Fatalf("unmessaged event reason = %q, want the event class", got)
+ }
+}
diff --git a/internal/vmware/client_signals.go b/internal/vmware/client_signals.go
index ed194380bc..7d10a2218f 100644
--- a/internal/vmware/client_signals.go
+++ b/internal/vmware/client_signals.go
@@ -47,6 +47,9 @@ type viJSONEventFilterSpec struct {
TypeName string `json:"_typeName,omitempty"`
Entity *viJSONEventFilterEntity `json:"entity,omitempty"`
MaxCount int `json:"maxCount,omitempty"`
+ // vCenter omits each event's fullFormattedMessage unless the filter sets
+ // this explicitly, so it is always sent: false asks for the message.
+ DisableFullMessage bool `json:"disableFullMessage"`
}
type viJSONEventFilterEntity struct {
diff --git a/internal/vmware/client_vi_json_request_test.go b/internal/vmware/client_vi_json_request_test.go
index 01baa02a6d..c878b884db 100644
--- a/internal/vmware/client_vi_json_request_test.go
+++ b/internal/vmware/client_vi_json_request_test.go
@@ -96,6 +96,8 @@ func TestQueryEventsRequestCarriesTypeNameDiscriminators(t *testing.T) {
`"_typeName":"EventFilterSpec"`,
`"_typeName":"EventFilterSpecByEntity"`,
`"_typeName":"ManagedObjectReference"`,
+ // Unset, vCenter leaves out every event's readable message.
+ `"disableFullMessage":false`,
} {
if !strings.Contains(body, want) {
t.Fatalf("QueryEvents request %s missing %s", body, want)