From 911296fa9d7b1b8100458ca6ea90b9abf11c8d95 Mon Sep 17 00:00:00 2001
From: rcourtman <8825017+rcourtman@users.noreply.github.com>
Date: Tue, 6 Oct 2026 14:13:42 +0100
Subject: [PATCH] Stamp alert platforms so cards stop guessing the policy owner
Two reviews of #2560 found alert cards that still linked to the
wrong platform page and named the wrong inventory owner in the
Monitoring policy menu, because the card guessed the platform.
Metric alerts put a display label in metadata.resourceType ("vSphere
VM", "Kubernetes Pod", "TrueNAS Pool") and carried no platform, so
every Kubernetes, TrueNAS and vSphere metric alert fell through to
Proxmox. A vSphere VM CPU alert said "Proxmox remains the inventory
owner". The unified metric and capacity evaluators now stamp the
canonical platformType beside the label, and connection-degraded
alerts stamp it from their connection type (a TrueNAS connection
alert also linked to Proxmox). The card already reads platformType
ahead of every fallback.
For alerts with no platform metadata, ids and messages carry names
the user chose: a Proxmox guest id embeds its cluster and node names
and a powered-off message names the guest. A VM on node docker-01
went to Docker and named the container runtime (a regression from
#2560's precedence change), a VM in a cluster named agent went to
Machines, and a guest named vmware-test went to vSphere. The card's
fallback now routes by resource type first and lets the id prefix,
id substring and message decide only when the alert has no type.
Libvirt VMs and Unraid arrays arrive through the agent, so their
drawers got "Retiring this machine ... Agent removal remains
available from Machines", which points at the host's agent. The
agent copy is now kept to the agent's own machine; everything else
an agent reports gets the neutral source-system copy. Agent
disk-usage, SMART disk and Unraid array alerts still carry their
host's agent metadata and are left to a separate change. Alerts
restored from before this change gain platformType at their next
full evaluation.
The menu also opened sentences with a lowercase owner label
("container runtime remains the inventory owner"); it now
capitalises the label at sentence start.
---
.../v6/internal/subsystems/alerts.md | 25 ++++++-
.../subsystems/frontend-primitives.md | 7 +-
.../e80596c28c0384f3.json | 44 +++++++++++++
.../alerts/AlertOverviewAlertCard.tsx | 19 ++++--
.../alerts/ResourceMonitoringPolicyAction.tsx | 9 ++-
...ertOverviewAlertCard.system-scope.test.tsx | 66 ++++++++++++++++++-
.../ResourceMonitoringPolicyAction.test.tsx | 20 ++++++
.../resourceMonitoringPolicy.test.ts | 14 +++-
.../src/utils/resourceMonitoringPolicy.ts | 9 ++-
internal/alerts/capacity_forecast.go | 3 +
internal/alerts/connection.go | 22 +++++++
internal/alerts/operational_contract_test.go | 66 +++++++++++++++++++
internal/alerts/unified_eval.go | 32 ++++++++-
internal/alerts/unified_eval_test.go | 10 +++
14 files changed, 330 insertions(+), 16 deletions(-)
create mode 100644 frontend-modern/browser-verification/e80596c28c0384f3.json
diff --git a/docs/release-control/v6/internal/subsystems/alerts.md b/docs/release-control/v6/internal/subsystems/alerts.md
index 61495979e6..522bd27934 100644
--- a/docs/release-control/v6/internal/subsystems/alerts.md
+++ b/docs/release-control/v6/internal/subsystems/alerts.md
@@ -2459,8 +2459,29 @@ page through `PRIMARY_PLATFORM_NAV_SCOPE_IDS` and the `routing/resourceLinks.ts`
path builders, and the same canonical platform id goes to the Monitoring
policy menu, so the link and the named inventory owner cannot disagree. Only
an alert whose metadata names no platform falls back to its resource type
-(`agent`, `docker-*`, `k8s-*`, `truenas-*`, `vmware-*`) and finally the message;
-agent alerts open `/standalone/machines`, because no `/machines` route exists.
+(`agent`, `docker-*`, `app-container`, `k8s-*`, `truenas-*`, `vmware-*`)
+and finally the message; agent alerts open `/standalone/machines`, because no
+`/machines` route exists. A resource-id prefix or substring and the
+message may pick the page only when the alert names no resource type at all,
+because Proxmox guest ids embed the user's cluster and node names and messages
+name the guest: VM alerts on node `docker-01`, in a cluster named `agent`, or
+for a guest named `vmware-test` still link to Proxmox and name Proxmox as their
+owner. Metric alerts put a display label in `resourceType` (`vSphere VM`,
+`Kubernetes Pod`), so the unified metric and capacity evaluators
+(`internal/alerts/unified_eval.go`, `internal/alerts/capacity_forecast.go`)
+stamp the canonical `platformType` beside it, and connection-degraded alerts
+(`internal/alerts/connection.go`) stamp it from the connection type; the card
+reads that ahead of every fallback. The menu's owner copy comes from
+`frontend-modern/src/utils/resourceMonitoringPolicy.ts`, where an explicit
+platform outranks the resource-type guess and the agent-removal copy is kept
+to the agent's own machine: libvirt VMs and Unraid arrays in resource drawers
+get the neutral source-system copy. Agent disk-usage, SMART disk and Unraid
+array alerts still carry their host's `agent` metadata, so their menu names
+the Pulse agent until those producers emit child-resource metadata. Alerts
+restored from before `platformType` existed gain it at their next full
+evaluation; a metric alert held between its clear and trigger thresholds keeps
+the earlier fallback until then. Menu sentences open with the owner
+label capitalised.
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 308d1470e2..b1e8f4e3a4 100644
--- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md
+++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
@@ -4347,7 +4347,12 @@ maps it to a page through `PRIMARY_PLATFORM_NAV_SCOPE_IDS` in
with the `routing/resourceLinks.ts` path builders. It must not freeze raw
route strings or keep its own platform alias table, because those drifted
before: agent alerts linked to a `/machines` route that never existed, and
-TrueNAS incidents linked to Proxmox.
+TrueNAS incidents linked to Proxmox. Its fallback for alerts without platform
+metadata reads the resource type before any resource-id prefix, substring or
+message text, because a Proxmox guest id carries user-chosen cluster and node
+names and a message names the guest; those guesses sent a VM on node
+`docker-01` to the Docker page, one in a cluster named `agent` to Machines, and
+a guest named `vmware-test` to vSphere.
Command palette and keyboard shortcuts moved to platform-first on 2026-05-16,
and top-level aggregate workspace routes were retired on 2026-05-25
diff --git a/frontend-modern/browser-verification/e80596c28c0384f3.json b/frontend-modern/browser-verification/e80596c28c0384f3.json
new file mode 100644
index 0000000000..4bf79ec163
--- /dev/null
+++ b/frontend-modern/browser-verification/e80596c28c0384f3.json
@@ -0,0 +1,44 @@
+{
+ "version": 1,
+ "base_sha": "13d604e1c71a4b8388ecaf04ea4dbdcd731f7e0b",
+ "verified_at": "2026-10-06T13:13:33Z",
+ "result": "passed",
+ "changed_paths": [
+ "frontend-modern/src/features/alerts/AlertOverviewAlertCard.tsx",
+ "frontend-modern/src/features/alerts/ResourceMonitoringPolicyAction.tsx",
+ "frontend-modern/src/utils/resourceMonitoringPolicy.ts"
+ ],
+ "content_sha256": {
+ "frontend-modern/src/features/alerts/AlertOverviewAlertCard.tsx": "8d69d7fb76016cbfff3051435a9a4c23d4444b21d115837115dc2d739bee132a",
+ "frontend-modern/src/features/alerts/ResourceMonitoringPolicyAction.tsx": "2f912423bc39e2a6d20ea49e1e00002d44923b7f2daf6993ae6a8257c9083a9a",
+ "frontend-modern/src/utils/resourceMonitoringPolicy.ts": "9e095a25929149463758ebd767b0a1e83f3ec7750e5f640a91cc3c45e510805d"
+ },
+ "routes": [
+ "/alerts/overview",
+ "/standalone/machines"
+ ],
+ "viewports": [
+ {
+ "width": 1440,
+ "height": 900
+ },
+ {
+ "width": 390,
+ "height": 844
+ }
+ ],
+ "states": [
+ "Alerts > Overview on a private mock stack built from this branch at main d4315cef51 (rebased onto 13d604e1c7 before landing; the new commit only touches node.go and alerts.md elsewhere) (backend binary includes the platformType stamping; Vite serves the frontend). Mock mode did not raise unified vSphere/Kubernetes metric alerts even with CPU thresholds lowered to 1%, so six alerts were cloned from the mock's real powered-off VM alert, reshaped to exactly what the producers emit (unified metric: resourceType 'vSphere VM' / 'Kubernetes Pod' plus platformType; connection-degraded: resourceType connection, connectionType truenas plus platformType; guest alerts: resourceType vm / VM), and appended to the live websocket activeAlerts snapshots. The vSphere VM and pod alerts point at real mock resources (finance-jump-01, checkout-api-6c746d5bcf-c7z2p)",
+ "With the change, at 1440 and 390: the vSphere VM CPU alert links to /vmware/overview and its More > Monitoring menu names VMware vSphere; the Kubernetes pod CPU alert links to /kubernetes/overview and names Kubernetes; the TrueNAS connection alert links to /truenas/overview and names TrueNAS; VM alerts in a cluster named agent, for a guest named vmware-test, and on a cluster named docker-lab all link to /proxmox/overview and name Proxmox",
+ "Controls, not injected: the real powered-off VM postgres-replica-01 links to Proxmox and names Proxmox; the real Docker container influxdb (app-container) and Docker service infra-rancher (docker-service) link to /docker/overview and read 'Container runtime remains the inventory owner', capitalised",
+ "Same session with main's AlertOverviewAlertCard.tsx, ResourceMonitoringPolicyAction.tsx and resourceMonitoringPolicy.ts swapped back in under Vite and the injected alerts without platformType (main's backend output): vSphere VM, pod and TrueNAS connection alerts linked to /proxmox/overview naming Proxmox; the agent-cluster VM linked to /standalone/machines naming Pulse agent; vmware-test linked to /vmware/overview naming VMware vSphere; the docker-lab VM linked to /docker/overview with lowercase 'container runtime'",
+ "Machines: the real agent-only host Apollo-114 drawer Manage tab still reads 'Retiring this machine ... Agent removal remains available from Machines' on this build. The mock has no libvirt domains, so the libvirt VM and Unraid array drawer copy is covered by unit tests only",
+ "Monitoring menu at 390 opens as a body portal within the viewport"
+ ],
+ "interactions": [
+ "Proxied the real websocket with Playwright routeWebSocket and appended the six producer-shaped alerts to each activeAlerts array; also appended them to the /api/alerts/active REST response",
+ "For each of the nine cards: read the resource link href, clicked More then Monitoring, read the menu header, closed with Escape, clicked Less",
+ "Lowered vmwareDefaults/kubernetesDefaults CPU to 1% via PUT /api/alerts/config to try for real unified metric alerts, then restored the original config",
+ "Swapped the three frontend runtime files to main's versions under the running Vite and dropped platformType from the injected alerts for a same-session before, then restored the change"
+ ]
+}
diff --git a/frontend-modern/src/features/alerts/AlertOverviewAlertCard.tsx b/frontend-modern/src/features/alerts/AlertOverviewAlertCard.tsx
index 2930ad9942..9c82fe010e 100644
--- a/frontend-modern/src/features/alerts/AlertOverviewAlertCard.tsx
+++ b/frontend-modern/src/features/alerts/AlertOverviewAlertCard.tsx
@@ -152,17 +152,26 @@ export function AlertOverviewAlertCard(props: AlertOverviewAlertCardProps) {
typeof props.alert.metadata?.resourceType === 'string'
? (props.alert.metadata.resourceType as string)
: '';
- if (rid.startsWith('agent:') || resourceType === 'agent') return buildStandalonePath();
+ // Ids and messages carry names the user chose: a Proxmox guest id embeds
+ // its cluster and node names (docker-01:docker-01:100, agent:pve1:100) and
+ // a powered-off message names the guest. They may only decide the page
+ // when the alert names no resource type at all.
+ const typeless = !resourceType;
+ if (resourceType === 'agent' || (typeless && rid.startsWith('agent:')))
+ return buildStandalonePath();
if (
- rid.includes('docker') ||
- resourceType === 'docker-container' ||
- resourceType === 'docker-host'
+ resourceType.startsWith('docker-') ||
+ resourceType === 'app-container' ||
+ (typeless && rid.includes('docker'))
)
return buildDockerPath();
if (resourceType === 'kubernetes' || resourceType.startsWith('k8s-'))
return buildKubernetesPath();
if (resourceType.startsWith('truenas-')) return buildTrueNASPath();
- if (resourceType.startsWith('vmware-') || props.alert.message?.toLowerCase().includes('vmware'))
+ if (
+ resourceType.startsWith('vmware-') ||
+ (typeless && props.alert.message?.toLowerCase().includes('vmware'))
+ )
return buildVmwarePath();
return buildProxmoxPath();
};
diff --git a/frontend-modern/src/features/alerts/ResourceMonitoringPolicyAction.tsx b/frontend-modern/src/features/alerts/ResourceMonitoringPolicyAction.tsx
index 07b9a2eb4e..55c6d6349c 100644
--- a/frontend-modern/src/features/alerts/ResourceMonitoringPolicyAction.tsx
+++ b/frontend-modern/src/features/alerts/ResourceMonitoringPolicyAction.tsx
@@ -79,6 +79,11 @@ export function ResourceMonitoringPolicyAction(props: ResourceMonitoringPolicyAc
const ownership = () =>
describeResourceInventoryOwnership(props.resourceType, props.platformType);
+ // Owner labels such as "container runtime" open sentences in this menu.
+ const ownerSubject = () => {
+ const label = ownership().ownerLabel;
+ return label.charAt(0).toUpperCase() + label.slice(1);
+ };
const choices = (): PolicyChoice[] => [
{
label: 'Normal monitoring',
@@ -100,7 +105,7 @@ export function ResourceMonitoringPolicyAction(props: ResourceMonitoringPolicyAc
},
{
label: 'Retire from monitoring',
- description: `${ownership().ownerLabel} keeps the inventory record. Pulse stops attention and automation.`,
+ description: `${ownerSubject()} keeps the inventory record. Pulse stops attention and automation.`,
lifecycleState: 'retired',
},
];
@@ -185,7 +190,7 @@ export function ResourceMonitoringPolicyAction(props: ResourceMonitoringPolicyAc
}}
>
- This changes Pulse policy only. {ownership().ownerLabel} remains the inventory owner.
+ This changes Pulse policy only. {ownerSubject()} remains the inventory owner.
{(choice) => (
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 44bd58031d..31b9436d66 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
@@ -181,6 +181,35 @@ describe('Pulse system-alert overview scope', () => {
href: '/proxmox/overview',
platformType: 'proxmox-pbs',
},
+ {
+ // Metric alerts carry a display label in resourceType; the backend
+ // stamps the canonical platform beside it.
+ name: 'a Kubernetes pod metric alert',
+ resourceId: 'k8s:prod/ns:default/pod:api-7d9f',
+ resourceName: 'api-7d9f',
+ message: 'Kubernetes Pod api-7d9f disk at 93%',
+ metadata: { resourceType: 'Kubernetes Pod', platformType: 'kubernetes' },
+ href: '/kubernetes/overview',
+ platformType: 'kubernetes',
+ },
+ {
+ name: 'a vSphere VM metric alert',
+ resourceId: 'vm:vc-1:vm-2041',
+ resourceName: 'sql-prod-01',
+ message: 'vSphere VM sql-prod-01 CPU at 95%',
+ metadata: { resourceType: 'vSphere VM', platformType: 'vmware-vsphere' },
+ href: '/vmware/overview',
+ platformType: 'vmware-vsphere',
+ },
+ {
+ name: 'a TrueNAS connection alert',
+ resourceId: 'truenas:nas',
+ resourceName: 'NAS',
+ message: "Connection 'NAS' is unreachable",
+ metadata: { resourceType: 'connection', connectionType: 'truenas', platformType: 'truenas' },
+ href: '/truenas/overview',
+ platformType: 'truenas',
+ },
])(
'links $name to its platform page and policy owner',
({ resourceId, resourceName, message, metadata, href, platformType }) => {
@@ -208,6 +237,7 @@ describe('Pulse system-alert overview scope', () => {
name: 'an agent host metric alert to Machines',
resourceId: 'agent:host-12',
resourceType: 'agent',
+ message: 'Usage at 92%',
href: '/standalone/machines',
platformType: 'agent',
},
@@ -215,15 +245,49 @@ describe('Pulse system-alert overview scope', () => {
name: 'a TrueNAS pool metric alert to TrueNAS',
resourceId: 'truenas-1:pool:tank',
resourceType: 'truenas-pool',
+ message: 'Usage at 92%',
href: '/truenas/overview',
platformType: 'truenas',
},
+ {
+ // Guest ids embed the cluster and node names the user chose.
+ name: 'a Proxmox VM alert on a node named docker-01 to Proxmox',
+ resourceId: 'docker-01:docker-01:100',
+ resourceType: 'vm',
+ message: "VM 'billing-db-01' is powered off",
+ href: '/proxmox/overview',
+ platformType: 'proxmox',
+ },
+ {
+ name: 'a Proxmox VM alert in a cluster named agent to Proxmox',
+ resourceId: 'agent:pve1:100',
+ resourceType: 'VM',
+ message: 'VM CPU at 95%',
+ href: '/proxmox/overview',
+ platformType: 'proxmox',
+ },
+ {
+ name: 'a Proxmox VM alert for a guest named vmware-test to Proxmox',
+ resourceId: 'lab:pve1:101',
+ resourceType: 'vm',
+ message: "VM 'vmware-test' is powered off",
+ href: '/proxmox/overview',
+ platformType: 'proxmox',
+ },
+ {
+ name: 'a Docker container alert to Docker by its type alone',
+ resourceId: 'app-container-7c1e',
+ resourceType: 'app-container',
+ message: "Docker container 'nginx' is Paused",
+ href: '/docker/overview',
+ platformType: 'docker',
+ },
])('links $name when metadata names no platform', (example) => {
const alert = makeSystemAlert('usage', {
id: example.resourceId,
resourceId: example.resourceId,
resourceName: 'monitored-resource',
- message: 'Usage at 92%',
+ message: example.message,
metadata: { resourceType: example.resourceType },
});
render(() => (
diff --git a/frontend-modern/src/features/alerts/__tests__/ResourceMonitoringPolicyAction.test.tsx b/frontend-modern/src/features/alerts/__tests__/ResourceMonitoringPolicyAction.test.tsx
index 6a2e6870f9..471058e469 100644
--- a/frontend-modern/src/features/alerts/__tests__/ResourceMonitoringPolicyAction.test.tsx
+++ b/frontend-modern/src/features/alerts/__tests__/ResourceMonitoringPolicyAction.test.tsx
@@ -54,6 +54,26 @@ describe('ResourceMonitoringPolicyAction', () => {
});
});
+ it('opens menu sentences with a capitalised owner label', () => {
+ operatorStateMock.get.mockResolvedValue(null);
+ render(() => (
+
+ ));
+
+ fireEvent.click(screen.getByText('Monitoring'));
+ expect(
+ screen.getByText(
+ 'This changes Pulse policy only. Container runtime remains the inventory owner.',
+ ),
+ ).toBeInTheDocument();
+ expect(screen.getByText(/^Container runtime keeps the inventory record\./)).toBeInTheDocument();
+ });
+
it('renders the policy menu through a body portal so scroll containers cannot clip it', () => {
operatorStateMock.get.mockResolvedValue(null);
render(() => (
diff --git a/frontend-modern/src/utils/__tests__/resourceMonitoringPolicy.test.ts b/frontend-modern/src/utils/__tests__/resourceMonitoringPolicy.test.ts
index a745bb5724..87cfbbc271 100644
--- a/frontend-modern/src/utils/__tests__/resourceMonitoringPolicy.test.ts
+++ b/frontend-modern/src/utils/__tests__/resourceMonitoringPolicy.test.ts
@@ -24,7 +24,19 @@ describe('describeResourceInventoryOwnership', () => {
expect(ownerOf('vm', 'truenas')).toBe('TrueNAS');
expect(ownerOf('vm', 'kubernetes')).toBe('Kubernetes');
expect(ownerOf('system-container', 'docker')).toBe('container runtime');
- expect(ownerOf('vm', 'agent')).toBe('Pulse agent');
+ expect(ownerOf('agent', 'agent')).toBe('Pulse agent');
+ });
+
+ it('keeps agent-removal copy to the agent machine, not what the agent reports', () => {
+ // libvirt VMs and Unraid arrays arrive through the agent but are not its machine.
+ for (const type of ['vm', 'storage']) {
+ const ownership = describeResourceInventoryOwnership(type, 'agent');
+ expect(ownership.ownerLabel).toBe('source system');
+ expect(ownership.retirementDescription).not.toContain('Machines');
+ }
+ expect(describeResourceInventoryOwnership('', 'agent').retirementDescription).toContain(
+ 'Agent removal remains available from Machines',
+ );
});
it('accepts canonical platform keys as well as platform families', () => {
diff --git a/frontend-modern/src/utils/resourceMonitoringPolicy.ts b/frontend-modern/src/utils/resourceMonitoringPolicy.ts
index add36dd359..cb28ca02b9 100644
--- a/frontend-modern/src/utils/resourceMonitoringPolicy.ts
+++ b/frontend-modern/src/utils/resourceMonitoringPolicy.ts
@@ -61,10 +61,16 @@ function ownershipForPlatform(platform: string): ResourceInventoryOwnership | un
if (platform === 'kubernetes') return KUBERNETES_OWNERSHIP;
if (platform === 'docker') return CONTAINER_RUNTIME_OWNERSHIP;
if (platform === 'truenas') return TRUENAS_OWNERSHIP;
- if (platform === 'agent') return AGENT_OWNERSHIP;
return undefined;
}
+// The agent copy speaks about the agent's own machine and points at agent
+// removal. Everything else an agent reports (libvirt VMs, Unraid arrays) is a
+// record the host keeps, so it gets the neutral source-system copy instead.
+function ownershipForAgentPlatform(type: string): ResourceInventoryOwnership {
+ return type === '' || type === 'agent' ? AGENT_OWNERSHIP : SOURCE_SYSTEM_OWNERSHIP;
+}
+
// Fallback for callers that know only the resource type. Resource types such
// as 'vm' are shared across platforms, so this guess must never outrank an
// explicit platform.
@@ -85,5 +91,6 @@ export function describeResourceInventoryOwnership(
const type = (resourceType || '').toLowerCase();
const platform = (platformType || '').trim().toLowerCase();
+ if (platform === 'agent') return ownershipForAgentPlatform(type);
return ownershipForPlatform(platform) ?? ownershipForResourceType(type);
}
diff --git a/internal/alerts/capacity_forecast.go b/internal/alerts/capacity_forecast.go
index e7c2cc7d0c..c4c1d500f5 100644
--- a/internal/alerts/capacity_forecast.go
+++ b/internal/alerts/capacity_forecast.go
@@ -381,6 +381,9 @@ func (m *Manager) evaluateCapacityForecast(input *UnifiedResourceInput, threshol
if len(input.StorageAliases) > 0 {
metadata[storagePolicyAliasesKey] = append([]string(nil), input.StorageAliases...)
}
+ if platformType := unifiedAlertPlatformType(input.Type); platformType != "" {
+ metadata[alertPlatformTypeKey] = platformType
+ }
_, _ = m.evaluateCanonicalLifecycleAlert(canonicalLifecycleAlertParams{
Spec: spec,
Evidence: alertspecs.AlertEvidence{
diff --git a/internal/alerts/connection.go b/internal/alerts/connection.go
index 0876921825..9c0e7e855a 100644
--- a/internal/alerts/connection.go
+++ b/internal/alerts/connection.go
@@ -40,6 +40,25 @@ const (
ConnectionTypeTrueNAS ConnectionType = "truenas"
)
+// platformType returns the canonical platform id of the connection's source,
+// so a connection alert links to and names the platform it belongs to.
+func (t ConnectionType) platformType() string {
+ switch t {
+ case ConnectionTypePVE:
+ return "proxmox-pve"
+ case ConnectionTypePBS:
+ return "proxmox-pbs"
+ case ConnectionTypePMG:
+ return "proxmox-pmg"
+ case ConnectionTypeVMware:
+ return "vmware-vsphere"
+ case ConnectionTypeTrueNAS:
+ return "truenas"
+ default:
+ return ""
+ }
+}
+
// ConnectionErrorSnapshot mirrors the api.ConnectionError shape that the
// aggregator computes per target. Only the fields used in alert metadata are
// carried.
@@ -221,6 +240,9 @@ func (m *Manager) CheckConnection(snap ConnectionSnapshot) {
"connectionType": string(snap.Type),
"state": string(snap.State),
}
+ if platformType := snap.Type.platformType(); platformType != "" {
+ metadata[alertPlatformTypeKey] = platformType
+ }
var correlation *AlertCorrelation
if snap.Type == ConnectionTypePVE {
correlation = NewSharedSystemAlertCorrelation(
diff --git a/internal/alerts/operational_contract_test.go b/internal/alerts/operational_contract_test.go
index 444cb7aba3..56cee31783 100644
--- a/internal/alerts/operational_contract_test.go
+++ b/internal/alerts/operational_contract_test.go
@@ -744,3 +744,69 @@ func TestResolvedOccurrenceCallbackSurvivesLegacyPanic(t *testing.T) {
t.Fatal("legacy panic prevented occurrence-qualified recovery")
}
}
+
+func TestConnectionDegradedAlertNamesItsPlatform(t *testing.T) {
+ tests := []struct {
+ connectionType ConnectionType
+ wantPlatform string
+ }{
+ {connectionType: ConnectionTypePVE, wantPlatform: "proxmox-pve"},
+ {connectionType: ConnectionTypePBS, wantPlatform: "proxmox-pbs"},
+ {connectionType: ConnectionTypePMG, wantPlatform: "proxmox-pmg"},
+ {connectionType: ConnectionTypeVMware, wantPlatform: "vmware-vsphere"},
+ {connectionType: ConnectionTypeTrueNAS, wantPlatform: "truenas"},
+ }
+
+ for _, test := range tests {
+ t.Run(string(test.connectionType), func(t *testing.T) {
+ m := newTestManager(t)
+ snap := ConnectionSnapshot{
+ ID: string(test.connectionType) + ":lab",
+ Name: "Lab",
+ Type: test.connectionType,
+ State: ConnectionStateUnreachable,
+ Enabled: true,
+ }
+ for range 5 {
+ m.CheckConnection(snap)
+ }
+
+ alert := testRequireActiveAlert(t, m, canonicalDiscreteStateStateID(snap.ID, connectionDegradedStateKey))
+ if got, _ := alert.Metadata["platformType"].(string); got != test.wantPlatform {
+ t.Fatalf("platformType metadata = %q, want %q", got, test.wantPlatform)
+ }
+ })
+ }
+}
+
+func TestCapacityForecastAlertNamesItsPlatform(t *testing.T) {
+ m := newTestManager(t)
+ m.ClearActiveAlerts()
+ m.mu.Lock()
+ m.config.TimeThresholds = map[string]int{}
+ m.config.VMwareDefaults.Usage = &HysteresisThreshold{Trigger: 85, Clear: 75}
+ m.mu.Unlock()
+
+ input := &UnifiedResourceInput{
+ ID: "vmware:vc-1/datastore:ds-ssd",
+ Type: "vmware-datastore",
+ Name: "ds-ssd",
+ Node: "Lab DC",
+ Instance: "Lab vCenter",
+ Disk: &UnifiedResourceMetric{Percent: 70},
+ }
+ trend := CapacityTrendObservation{
+ Ready: true, Reason: "increasing", ObservedAt: time.Now(), DailyChange: 7,
+ Confidence: 0.99, SampleCount: 300, BucketCount: 48, CoverageSpan: 48 * time.Hour,
+ }
+ m.CheckUnifiedResourceWithCapacityTrend(input, trend)
+ m.CheckUnifiedResourceWithCapacityTrend(input, trend)
+
+ alert := testRequireActiveAlert(t, m, canonicalMetricStateID(input.ID, "usage"))
+ if got := alert.Metadata[capacityAlertOriginKey]; got != capacityAlertOriginForecast {
+ t.Fatalf("capacity origin = %v, want forecast", got)
+ }
+ if got := alert.Metadata[alertPlatformTypeKey]; got != "vmware-vsphere" {
+ t.Fatalf("platformType metadata = %v, want vmware-vsphere", got)
+ }
+}
diff --git a/internal/alerts/unified_eval.go b/internal/alerts/unified_eval.go
index bcffe8100e..c7120cf079 100644
--- a/internal/alerts/unified_eval.go
+++ b/internal/alerts/unified_eval.go
@@ -155,6 +155,26 @@ func isUnifiedModernPlatformAlertType(typeKey string) bool {
return isUnifiedKubernetesAlertType(typeKey) || isUnifiedTrueNASAlertType(typeKey) || isUnifiedVMwareAlertType(typeKey)
}
+// alertPlatformTypeKey carries the canonical platform id of the resource an
+// alert is about. Metric alerts put a display label such as "vSphere VM" in
+// resourceType, so consumers that link or name the owning platform read this.
+const alertPlatformTypeKey = "platformType"
+
+// unifiedAlertPlatformType returns the canonical platform id for a unified
+// alert type key, or "" when the key does not name one.
+func unifiedAlertPlatformType(typeKey string) string {
+ switch {
+ case isUnifiedKubernetesAlertType(typeKey):
+ return "kubernetes"
+ case strings.HasPrefix(typeKey, "truenas-"):
+ return "truenas"
+ case strings.HasPrefix(typeKey, "vmware-"):
+ return "vmware-vsphere"
+ default:
+ return ""
+ }
+}
+
func (m *Manager) unifiedPlatformAlertsDisabledNoLock(typeKey string) bool {
switch {
case isUnifiedKubernetesAlertType(typeKey):
@@ -261,16 +281,22 @@ func (m *Manager) evaluateUnifiedMetrics(input *UnifiedResourceInput, thresholds
}
opts = metricOptionsWithTags(opts, input.Tags)
- if len(input.StorageAliases) > 0 {
+ platformType := unifiedAlertPlatformType(input.Type)
+ if len(input.StorageAliases) > 0 || platformType != "" {
merged := metricOptions{}
if opts != nil {
merged = *opts
}
- metadata := make(map[string]interface{}, len(merged.Metadata)+1)
+ metadata := make(map[string]interface{}, len(merged.Metadata)+2)
for k, v := range merged.Metadata {
metadata[k] = v
}
- metadata[storagePolicyAliasesKey] = append([]string(nil), input.StorageAliases...)
+ if len(input.StorageAliases) > 0 {
+ metadata[storagePolicyAliasesKey] = append([]string(nil), input.StorageAliases...)
+ }
+ if platformType != "" {
+ metadata[alertPlatformTypeKey] = platformType
+ }
merged.Metadata = metadata
opts = &merged
}
diff --git a/internal/alerts/unified_eval_test.go b/internal/alerts/unified_eval_test.go
index de010d214c..dbf4db3508 100644
--- a/internal/alerts/unified_eval_test.go
+++ b/internal/alerts/unified_eval_test.go
@@ -2,6 +2,7 @@ package alerts
import (
"sort"
+ "strings"
"testing"
"time"
@@ -423,6 +424,15 @@ func TestCheckUnifiedResourceSupportsKubernetesTrueNASAndVMwareMetricTargets(t *
if got := alert.Metadata["resourceType"]; got != tt.wantResourceType {
t.Fatalf("resourceType metadata = %v, want %s", got, tt.wantResourceType)
}
+ // resourceType is a display label, so the owning platform travels separately.
+ wantPlatform := map[string]string{
+ "Kubernetes": "kubernetes",
+ "TrueNAS": "truenas",
+ "vSphere": "vmware-vsphere",
+ }[strings.Fields(tt.wantResourceType)[0]]
+ if got := alert.Metadata["platformType"]; got != wantPlatform {
+ t.Fatalf("platformType metadata = %v, want %s", got, wantPlatform)
+ }
})
}
}