Add Kubernetes, TrueNAS, and vSphere alert targets

This commit is contained in:
rcourtman 2026-05-28 17:38:50 +01:00
parent 75d1a0477b
commit 05abf07219
57 changed files with 4226 additions and 302 deletions

View file

@ -84,6 +84,12 @@ operator-facing alert routing behavior for live runtime alerts.
62. `frontend-modern/src/components/Alerts/IncidentAssistantHandoffButton.tsx`
63. `frontend-modern/src/components/Alerts/incidentAssistantHandoffModel.ts`
64. `internal/alerts/storage_override_identity.go`
65. `internal/alerts/unified_eval.go`
66. `frontend-modern/src/components/Alerts/ThresholdsTable.tsx`
67. `frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsData.ts`
68. `frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsPlatformData.ts`
69. `frontend-modern/src/utils/alertTargetTypes.ts`
70. `frontend-modern/src/types/alerts.ts`
## Shared Boundaries
@ -127,6 +133,12 @@ operator-facing alert routing behavior for live runtime alerts.
handoffs must not add suggested prompt chips or route-owned remediation
instructions; the configured model owns investigation and next-step
reasoning after it receives the context.
9. Add or change alert target resource types through
`internal/alerts/specs/types.go`, `internal/alerts/config/identity.go`,
`internal/alerts/unified_eval.go`, and
`frontend-modern/src/utils/alertTargetTypes.ts`. Supported target types
must share the unified evaluator, the canonical threshold/override identity
chain, and the standard notification delivery path.
## Forbidden Paths
@ -167,6 +179,22 @@ alert feature gating from the shared entitlements layer, not from a per-surface
Canonical alert identity and evaluation are the live runtime model. Remaining
legacy references should exist only in explicit migration or negative test
boundaries.
All v6 platform alert targets must enter runtime threshold evaluation through
`UnifiedResourceInput` and `internal/alerts/unified_eval.go`: Proxmox guests/
nodes/storage, Docker hosts/containers/services, Kubernetes clusters/nodes/
namespaces/deployments/pods, TrueNAS systems/pools/datasets/disks, VMware
vSphere hosts/VMs/datastores/networks, PBS, PMG, and standalone host agents.
Adding a platform-local evaluator branch for these resource families is
forbidden.
Per-platform defaults, per-resource overrides, global disables, active-alert
reevaluation, history persistence, and notification delivery must use the same
alert configuration shape rather than a platform-specific sidecar.
The browser thresholds surface is also platform-shaped: Proxmox, Docker,
Kubernetes, TrueNAS, vSphere, PBS, PMG, and Systems. It must use the shared
FilterBar chip and "+ Filter" pattern for resource filtering, and alert tables
must use the canonical platform table column-kind alignment helpers from
`frontend-modern/src/features/platformPage/` rather than hard-coded table
alignment classes.
Guest metric canonical state remains resource-backed and therefore node-scoped
for Proxmox guests, so node moves must not strand active alert state on the
previous resource ID. When a guest metric alert survives a node move, alerts
@ -625,19 +653,25 @@ shell composition for search/help/nav plus bulk-edit dialog flow, while the
tab render owners live in
`frontend-modern/src/components/Alerts/ThresholdsTableProxmoxTab.tsx`,
`frontend-modern/src/components/Alerts/ThresholdsTablePMGTab.tsx`,
`frontend-modern/src/components/Alerts/ThresholdsTableAgentsTab.tsx`, and
`frontend-modern/src/components/Alerts/ThresholdsTableDockerTab.tsx`. New
`frontend-modern/src/components/Alerts/ThresholdsTableAgentsTab.tsx`,
`frontend-modern/src/components/Alerts/ThresholdsTableDockerTab.tsx`,
`frontend-modern/src/components/Alerts/ThresholdsTableKubernetesTab.tsx`,
`frontend-modern/src/components/Alerts/ThresholdsTableTrueNASTab.tsx`,
`frontend-modern/src/components/Alerts/ThresholdsTableVMwareTab.tsx`, and
`frontend-modern/src/components/Alerts/ThresholdsTablePBSTab.tsx`. New
threshold row grouping, override-ID compatibility, resource normalization,
thresholds-table controller logic, or per-tab runtime should land in those
feature hooks and tab owners rather than being rebuilt inside the shell.
The shell-owned thresholds sub-routes are now the neutral user-facing paths
`/alerts/thresholds/infrastructure`, `/alerts/thresholds/systems`,
`/alerts/thresholds/mail-gateway`, and `/alerts/thresholds/containers`.
Legacy `/alerts/thresholds/proxmox` and `/alerts/thresholds/agents` links
must redirect to the neutral infrastructure and systems routes so API-backed
platforms like TrueNAS do not remain stranded behind provider-specific deep
links.
Within the infrastructure tab, render-heavy ownership now further routes through
The shell-owned thresholds sub-routes are platform-shaped user-facing paths:
`/alerts/thresholds/proxmox`, `/alerts/thresholds/docker`,
`/alerts/thresholds/kubernetes`, `/alerts/thresholds/truenas`,
`/alerts/thresholds/vmware`, `/alerts/thresholds/pbs`,
`/alerts/thresholds/pmg`, and `/alerts/thresholds/systems`. Legacy neutral
links like `/alerts/thresholds/infrastructure`,
`/alerts/thresholds/containers`, and `/alerts/thresholds/mail-gateway` must
redirect to the matching platform-shaped route. Legacy
`/alerts/thresholds/agents` links must continue to resolve to Systems.
Within the Proxmox tab, render-heavy ownership now further routes through
`frontend-modern/src/components/Alerts/ThresholdsTableProxmoxNodesSection.tsx`,
`frontend-modern/src/components/Alerts/ThresholdsTableProxmoxPBSSection.tsx`,
`frontend-modern/src/components/Alerts/ThresholdsTableProxmoxGuestsSection.tsx`,
@ -668,15 +702,18 @@ links into infrastructure, workloads, storage, and recovery. Pulse does not
promise a TrueNAS-only alert workflow or provider-specific alert management
surface beyond the shared alerts product.
At the current locked VMware floor, alert support must mean the same shared
alert surfaces can evaluate, show, and drill into vSphere alarm and health
signals on canonical `agent`, `vm`, and `storage` resources, with related
event/task context routed through the shared incident and resource links.
Pulse must not grow a VMware-only alert shell, alarm editor, or direct alarm-
control surface in phase 1.
alert surfaces can evaluate, show, and drill into vSphere alarm, health, and
threshold signals on canonical `agent`, `vm`, `storage`, and `network`
resources, with related event/task context routed through the shared incident
and resource links. This is the alerts support floor for admitted vSphere
resources; it does not by itself promote the broader VMware platform readiness
state beyond the separately governed platform-admission floor. Pulse must not
grow a VMware-only alert shell, alarm editor, or direct alarm-control surface
in phase 1.
That same VMware alert rule now also includes the topology boundary. Alarm
context that originates on a datacenter, cluster, folder, or resource pool may
inform a shared incident, but it must still resolve onto canonical `agent`,
`vm`, or `storage` investigation paths rather than creating synthetic
`vm`, `storage`, or `network` investigation paths rather than creating synthetic
top-level VMware incident resources. If that attachment cannot be done
honestly for a given signal, the signal should remain supporting context
instead of inflating the support claim.

View file

@ -161,6 +161,13 @@ work extends shared components instead of creating new local variants.
## Shared Boundaries
Alert thresholds consume the shared FilterBar primitive and route state, while
the alerts subsystem owns the resource data and platform-specific threshold
tabs. The thresholds platform IA is platform-shaped: Proxmox, Docker,
Kubernetes, TrueNAS, vSphere, PBS, PMG, and Systems. Frontend primitives own
the chip, reset, "+ Filter", and route-backed shell pattern; alerts must not
replace that with page-local search/tab chrome or legacy neutral buckets.
The System Updates install guide is a shared settings primitive, not a
deployment-lane-only panel. `UpdateInstallGuide` must render the canonical
update-plan readiness verdict inline with the update action, and a blocked
@ -341,6 +348,10 @@ filter chip or an explicit page-owned advanced selector. Platform-owned filter
selectors must also exclude facet options from other platform scopes, even when
the underlying shared surface is mounted from the same Workloads or Storage
component.
Alert configuration tables follow the same primitive boundary: the alerts
owner supplies platform-specific threshold groups and filter catalog values,
while the shared FilterBar owns the chip, reset, and "+ Filter" interaction
shape so thresholds do not reintroduce page-local search/tab chrome.
Platform sub-routes that add native provider inventory must stay on the shared
platform page and table primitives. The vSphere Networks surface routes through
`/vmware/networks`, the shared platform tab model, the command palette
@ -2946,17 +2957,23 @@ is now explicitly a shell consumer rather than the data or controller owner,
and the tab render owners live in
`frontend-modern/src/components/Alerts/ThresholdsTableProxmoxTab.tsx`,
`frontend-modern/src/components/Alerts/ThresholdsTablePMGTab.tsx`,
`frontend-modern/src/components/Alerts/ThresholdsTableAgentsTab.tsx`, and
`frontend-modern/src/components/Alerts/ThresholdsTableDockerTab.tsx`.
`frontend-modern/src/components/Alerts/ThresholdsTableAgentsTab.tsx`,
`frontend-modern/src/components/Alerts/ThresholdsTableDockerTab.tsx`,
`frontend-modern/src/components/Alerts/ThresholdsTableKubernetesTab.tsx`,
`frontend-modern/src/components/Alerts/ThresholdsTableTrueNASTab.tsx`,
`frontend-modern/src/components/Alerts/ThresholdsTableVMwareTab.tsx`, and
`frontend-modern/src/components/Alerts/ThresholdsTablePBSTab.tsx`.
`frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsTableState.ts`
owns the neutral thresholds sub-route contract:
`/alerts/thresholds/infrastructure`, `/alerts/thresholds/systems`,
`/alerts/thresholds/mail-gateway`, and `/alerts/thresholds/containers`.
Legacy `/alerts/thresholds/proxmox` and `/alerts/thresholds/agents` links
must redirect to the neutral infrastructure and systems routes so API-backed
platforms such as TrueNAS stay on canonical page language rather than
provider-specific aliases.
The infrastructure tab is itself now a shell that composes
owns the platform-shaped thresholds sub-route contract:
`/alerts/thresholds/proxmox`, `/alerts/thresholds/docker`,
`/alerts/thresholds/kubernetes`, `/alerts/thresholds/truenas`,
`/alerts/thresholds/vmware`, `/alerts/thresholds/pbs`,
`/alerts/thresholds/pmg`, and `/alerts/thresholds/systems`. Legacy neutral
links such as `/alerts/thresholds/infrastructure`,
`/alerts/thresholds/containers`, and `/alerts/thresholds/mail-gateway` must
redirect to the matching platform-shaped route; legacy
`/alerts/thresholds/agents` links must continue to resolve to Systems.
The Proxmox tab is itself now a shell that composes
`frontend-modern/src/components/Alerts/ThresholdsTableProxmoxNodesSection.tsx`,
`frontend-modern/src/components/Alerts/ThresholdsTableProxmoxPBSSection.tsx`,
`frontend-modern/src/components/Alerts/ThresholdsTableProxmoxGuestsSection.tsx`,

View file

@ -72,6 +72,7 @@ truth for live infrastructure data.
48. `internal/truenas/provider.go`
49. `internal/models/ceph_cluster_identity.go`
50. `internal/truenas/types.go`
51. `internal/monitoring/monitor_alert_sync.go`
## Shared Boundaries
@ -240,6 +241,12 @@ truth for live infrastructure data.
fields locally. Monitoring must preserve those objects as native cluster
inventory instead of flattening them into pods, deployments, or generic
networking, storage, configuration, or controller rows.
19. Add or change unified-resource alert synchronization through
`internal/monitoring/monitor_alert_sync.go` and the alerts subsystem
contract together. Monitoring may pass the current unified-resource snapshot
into the alert manager, but threshold selection, override identity, active
alert state, and notification delivery remain alerts-owned. The monitoring
sync bridge must not introduce per-platform evaluator branches.
## Forbidden Paths

View file

@ -1228,6 +1228,7 @@
"owned_files": [
"frontend-modern/src/pages/Alerts.tsx",
"frontend-modern/src/stores/alertsActivation.ts",
"frontend-modern/src/types/alerts.ts",
"frontend-modern/src/utils/alertActivationPresentation.ts",
"frontend-modern/src/utils/alertAdministrationPresentation.ts",
"frontend-modern/src/utils/alertBulkEditPresentation.ts",
@ -1243,6 +1244,7 @@
"frontend-modern/src/utils/alertSchedulePresentation.ts",
"frontend-modern/src/utils/alertSeverityPresentation.ts",
"frontend-modern/src/utils/alertTabsPresentation.ts",
"frontend-modern/src/utils/alertTargetTypes.ts",
"frontend-modern/src/utils/alertThresholdDefaults.ts",
"frontend-modern/src/utils/alertThresholdsPresentation.ts",
"frontend-modern/src/utils/alertThresholdsSectionPresentation.ts",
@ -1303,6 +1305,7 @@
],
"match_files": [
"frontend-modern/src/pages/Alerts.tsx",
"frontend-modern/src/types/alerts.ts",
"frontend-modern/src/utils/alertOverviewPresentation.ts"
],
"allow_same_subsystem_tests": false,
@ -1325,7 +1328,21 @@
"frontend-modern/src/features/alerts/thresholds/__tests__/helpers.test.ts",
"frontend-modern/src/features/alerts/thresholds/hooks/__tests__/useThresholdsTableState.test.tsx",
"frontend-modern/src/pages/__tests__/Alerts.helpers.test.ts",
"frontend-modern/src/utils/__tests__/alertOverviewPresentation.test.ts"
"frontend-modern/src/utils/__tests__/alertOverviewPresentation.test.ts",
"frontend-modern/src/utils/__tests__/alertTargetTypes.test.ts"
]
},
{
"id": "alert-target-type-resolution",
"label": "alert target type resolver proof",
"match_prefixes": [],
"match_files": [
"frontend-modern/src/utils/alertTargetTypes.ts"
],
"allow_same_subsystem_tests": false,
"test_prefixes": [],
"exact_files": [
"frontend-modern/src/utils/__tests__/alertTargetTypes.test.ts"
]
},
{

View file

@ -17,6 +17,10 @@ import {
getGroupedTableRowCellClass,
getGroupedTableRowClass,
} from '@/components/shared/groupedTableRowPresentation';
import {
getPlatformTableCellClassForKind,
getPlatformTableHeadClassForKind,
} from '@/features/platformPage/sharedPlatformPage';
import { AlertResourceTableRow } from './AlertResourceTableRow';
import { AlertResourceGroupHeader } from './AlertResourceGroupHeader';
import {
@ -31,6 +35,7 @@ import {
getAlertResourceTableNoResultsState,
} from '@/utils/alertResourceTablePresentation';
import {
getAlertResourceColumnKind,
getAlertResourceColumnHeaderTooltip,
getAlertResourceEnabledDefault,
getAlertResourceMetricBounds,
@ -115,7 +120,9 @@ export function AlertResourceTableDesktop(props: AlertResourceTableDesktopProps)
<TableHeader>
<TableRow class="text-muted">
<Show when={props.table.onBulkEdit}>
<TableHead class="text-center w-10 px-2 border-r border-border">
<TableHead
class={`${getPlatformTableHeadClassForKind('badge')} w-10 border-r border-border`}
>
<input
type="checkbox"
checked={props.allSelected()}
@ -130,12 +137,16 @@ export function AlertResourceTableDesktop(props: AlertResourceTableDesktopProps)
/>
</TableHead>
</Show>
<TableHead class="text-center w-16">Alerts</TableHead>
<TableHead class="text-left w-1/4">Resource</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('badge')} w-16`}>
Alerts
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('name')} w-1/4`}>
Resource
</TableHead>
<For each={props.table.columns}>
{(column) => (
<TableHead
class="text-center whitespace-normal break-words"
class={`${getPlatformTableHeadClassForKind(getAlertResourceColumnKind(column))} whitespace-normal break-words`}
title={getAlertResourceColumnHeaderTooltip(column)}
>
{column}
@ -143,11 +154,14 @@ export function AlertResourceTableDesktop(props: AlertResourceTableDesktopProps)
)}
</For>
<Show when={props.table.showOfflineAlertsColumn}>
<TableHead class="text-center" title={OFFLINE_ALERTS_TOOLTIP}>
<TableHead
class={getPlatformTableHeadClassForKind('badge')}
title={OFFLINE_ALERTS_TOOLTIP}
>
Offline Alerts
</TableHead>
</Show>
<TableHead class="text-center">Actions</TableHead>
<TableHead class={getPlatformTableHeadClassForKind('badge')}>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody class="divide-y divide-border">
@ -162,12 +176,18 @@ export function AlertResourceTableDesktop(props: AlertResourceTableDesktopProps)
class={`bg-surface-alt ${props.table.globalDisableFlag?.() ? 'opacity-40' : ''}`}
>
<Show when={props.table.onBulkEdit}>
<TableCell class="p-1 px-2 border-r border-border" />
<TableCell
class={`${getPlatformTableCellClassForKind('badge')} border-r border-border`}
/>
</Show>
<TableCell class="p-1 px-2 text-center align-middle">
<TableCell class={`${getPlatformTableCellClassForKind('badge')} align-middle`}>
<Show
when={props.table.onToggleGlobalDisable}
fallback={<span class="text-sm text-slate-400" aria-hidden="true">-</span>}
fallback={
<span class="text-sm text-slate-400" aria-hidden="true">
-
</span>
}
>
<div class="flex items-center justify-center">
<TogglePrimitive
@ -184,7 +204,7 @@ export function AlertResourceTableDesktop(props: AlertResourceTableDesktopProps)
</div>
</Show>
</TableCell>
<TableCell class="p-1 px-2">
<TableCell class={getPlatformTableCellClassForKind('name')}>
<div class="flex items-center gap-2">
<span class="text-sm font-semibold text-base-content">Global Defaults</span>
<Show when={props.hasCustomGlobalDefaults()}>
@ -202,7 +222,9 @@ export function AlertResourceTableDesktop(props: AlertResourceTableDesktopProps)
const isOff = () => value() === -1;
return (
<TableCell class="p-1 px-2 text-center align-middle">
<TableCell
class={`${getPlatformTableCellClassForKind(getAlertResourceColumnKind(column))} align-middle`}
>
<div class="relative flex justify-center w-full">
<input
type="number"
@ -249,13 +271,17 @@ export function AlertResourceTableDesktop(props: AlertResourceTableDesktopProps)
}}
</For>
<Show when={props.table.showOfflineAlertsColumn}>
<TableCell class="p-1 px-2 text-center align-middle">
<TableCell class={`${getPlatformTableCellClassForKind('badge')} align-middle`}>
<Show
when={props.table.onSetGlobalOfflineState}
fallback={
<Show
when={props.table.onToggleGlobalDisableOffline}
fallback={<span class="text-sm text-slate-400" aria-hidden="true">-</span>}
fallback={
<span class="text-sm text-slate-400" aria-hidden="true">
-
</span>
}
>
{(() => {
const defaultDisabled = props.table.globalDisableOfflineFlag?.() ?? false;
@ -280,8 +306,7 @@ export function AlertResourceTableDesktop(props: AlertResourceTableDesktopProps)
>
{(() => {
const disabledGlobally = props.table.globalDisableFlag?.() ?? false;
const defaultDisabled =
props.table.globalDisableOfflineFlag?.() ?? false;
const defaultDisabled = props.table.globalDisableOfflineFlag?.() ?? false;
const defaultSeverity = props.table.globalOfflineSeverity ?? 'warning';
const state: OfflineState = defaultDisabled
? 'off'
@ -298,7 +323,7 @@ export function AlertResourceTableDesktop(props: AlertResourceTableDesktopProps)
</Show>
</TableCell>
</Show>
<TableCell class="p-1 px-2 text-center align-middle">
<TableCell class={`${getPlatformTableCellClassForKind('badge')} align-middle`}>
<div class="flex items-center justify-center gap-1">
<Show
when={
@ -355,7 +380,9 @@ export function AlertResourceTableDesktop(props: AlertResourceTableDesktopProps)
</button>
</Show>
<Show when={!props.table.showDelayColumn && !props.hasCustomGlobalDefaults()}>
<span class="text-sm text-slate-400" aria-hidden="true">-</span>
<span class="text-sm text-slate-400" aria-hidden="true">
-
</span>
</Show>
</div>
</TableCell>
@ -372,12 +399,16 @@ export function AlertResourceTableDesktop(props: AlertResourceTableDesktopProps)
class={`bg-surface-alt ${props.table.globalDisableFlag?.() ? 'opacity-40' : ''}`}
>
<Show when={props.table.onBulkEdit}>
<TableCell class="p-1 px-2 border-r border-border" />
<TableCell
class={`${getPlatformTableCellClassForKind('badge')} border-r border-border`}
/>
</Show>
<TableCell class="p-1 px-2 text-center align-middle">
<span class="text-sm" aria-hidden="true">-</span>
<TableCell class={`${getPlatformTableCellClassForKind('badge')} align-middle`}>
<span class="text-sm" aria-hidden="true">
-
</span>
</TableCell>
<TableCell class="p-1 px-2 align-middle">
<TableCell class={`${getPlatformTableCellClassForKind('name')} align-middle`}>
<span class="text-xs font-semibold uppercase tracking-wide text-muted inline-flex items-center gap-1">
{getAlertResourceTableAlertDelayLabel()}
<HelpIcon contentId="alerts.thresholds.delay" size="xs" />
@ -393,7 +424,9 @@ export function AlertResourceTableDesktop(props: AlertResourceTableDesktopProps)
);
return (
<TableCell class="p-1 px-2 text-center align-middle">
<TableCell
class={`${getPlatformTableCellClassForKind(getAlertResourceColumnKind(column))} align-middle`}
>
<div class="relative flex justify-center w-full">
<input
type="number"
@ -428,12 +461,16 @@ export function AlertResourceTableDesktop(props: AlertResourceTableDesktopProps)
}}
</For>
<Show when={props.table.showOfflineAlertsColumn}>
<TableCell class="p-1 px-2 text-center align-middle">
<span class="text-sm" aria-hidden="true">-</span>
<TableCell class={`${getPlatformTableCellClassForKind('badge')} align-middle`}>
<span class="text-sm" aria-hidden="true">
-
</span>
</TableCell>
</Show>
<TableCell class="p-1 px-2 text-center align-middle">
<span class="text-sm" aria-hidden="true">-</span>
<TableCell class={`${getPlatformTableCellClassForKind('badge')} align-middle`}>
<span class="text-sm" aria-hidden="true">
-
</span>
</TableCell>
</TableRow>
</Show>
@ -446,10 +483,7 @@ export function AlertResourceTableDesktop(props: AlertResourceTableDesktopProps)
{([nodeName, resources]) => (
<>
<TableRow class={getGroupedTableRowClass()}>
<TableCell
colspan={totalColumnCount()}
class={getGroupedTableRowCellClass()}
>
<TableCell colspan={totalColumnCount()} class={getGroupedTableRowCellClass()}>
<AlertResourceGroupHeader
groupKey={nodeName}
meta={props.table.groupHeaderMeta?.[nodeName]}
@ -485,9 +519,7 @@ export function AlertResourceTableDesktop(props: AlertResourceTableDesktopProps)
setActiveMetricInput={props.setActiveMetricInput}
showBulkSelection={Boolean(props.table.onBulkEdit)}
selected={props.selectedIds().has(resource.id)}
onToggleSelection={(checked) =>
props.toggleSelection(resource.id, checked)
}
onToggleSelection={(checked) => props.toggleSelection(resource.id, checked)}
/>
)}
</For>
@ -528,9 +560,7 @@ export function AlertResourceTableDesktop(props: AlertResourceTableDesktopProps)
setActiveMetricInput={props.setActiveMetricInput}
showBulkSelection={Boolean(props.table.onBulkEdit)}
selected={props.selectedIds().has(resource.id)}
onToggleSelection={(checked) =>
props.toggleSelection(resource.id, checked)
}
onToggleSelection={(checked) => props.toggleSelection(resource.id, checked)}
/>
)}
</For>
@ -539,7 +569,7 @@ export function AlertResourceTableDesktop(props: AlertResourceTableDesktopProps)
<TableRow>
<TableCell
colspan={totalColumnCount()}
class="px-4 py-8 text-center text-sm text-muted"
class={`${getPlatformTableCellClassForKind('badge')} px-4 py-8 text-sm text-muted`}
>
{getAlertResourceTableNoResultsState(props.table.title)}
</TableCell>
@ -548,7 +578,10 @@ export function AlertResourceTableDesktop(props: AlertResourceTableDesktopProps)
</Show>
<Show when={props.hasRows() === false}>
<TableRow>
<TableCell colspan={totalColumnCount()} class="px-4 py-6 text-sm text-center text-muted">
<TableCell
colspan={totalColumnCount()}
class={`${getPlatformTableCellClassForKind('badge')} px-4 py-6 text-sm text-muted`}
>
{getAlertResourceTableEmptyState(props.table.emptyMessage)}
</TableCell>
</TableRow>

View file

@ -5,6 +5,7 @@ import { TogglePrimitive } from '@/components/shared/Toggle';
import { StatusBadge } from '@/components/shared/StatusBadge';
import { ThresholdSlider } from '@/components/Workloads/ThresholdSlider';
import { TableCell, TableRow } from '@/components/shared/Table';
import { getPlatformTableCellClassForKind } from '@/features/platformPage/sharedPlatformPage';
import {
getAlertResourceTableCustomBadgeLabel,
getAlertResourceTableEditMetricTitle,
@ -18,6 +19,7 @@ import {
import {
ALERT_RESOURCE_TABLE_SLIDER_METRICS,
alertResourceSupportsMetric,
getAlertResourceColumnKind,
getAlertResourceLabel,
getAlertResourceMetricBounds,
getAlertResourceMetricDisplayValue,
@ -69,7 +71,8 @@ interface AlertResourceTableRowProps {
export function AlertResourceTableRow(props: AlertResourceTableRowProps) {
const isEditing = () => props.editingId() === props.resource.id;
const thresholds = () => (isEditing() ? props.editingThresholds() : (props.resource.thresholds ?? {}));
const thresholds = () =>
isEditing() ? props.editingThresholds() : (props.resource.thresholds ?? {});
const resourceLabel = () => getAlertResourceLabel(props.resource);
const displayValue = (metric: string) =>
getAlertResourceMetricDisplayValue(
@ -78,8 +81,7 @@ export function AlertResourceTableRow(props: AlertResourceTableRowProps) {
props.editingThresholds(),
isEditing(),
);
const isOverridden = (metric: string) =>
isAlertResourceMetricOverridden(props.resource, metric);
const isOverridden = (metric: string) => isAlertResourceMetricOverridden(props.resource, metric);
const getThresholds = (): AlertResourceThresholdMap => thresholds();
@ -196,7 +198,9 @@ export function AlertResourceTableRow(props: AlertResourceTableRowProps) {
class={`transition-colors ${props.resource.disabled || props.globalDisableFlag?.() ? 'opacity-40' : ''} ${props.resource.hasOverride ? 'bg-sky-50/50 hover:bg-sky-50/80 dark:bg-sky-900/20 dark:hover:bg-sky-900/30 border-l-[3px] border-l-sky-400 dark:border-l-sky-500' : 'hover:bg-surface-hover'}`}
>
<Show when={props.showBulkSelection}>
<TableCell class="p-1 px-2 text-center align-middle border-r border-border">
<TableCell
class={`${getPlatformTableCellClassForKind('badge')} align-middle border-r border-border`}
>
<input
type="checkbox"
checked={props.selected}
@ -207,7 +211,7 @@ export function AlertResourceTableRow(props: AlertResourceTableRowProps) {
</TableCell>
</Show>
<TableCell class="p-1 px-2 text-center align-middle">
<TableCell class={`${getPlatformTableCellClassForKind('badge')} align-middle`}>
<Show when={props.onToggleDisabled}>
{(() => {
const globallyDisabled = props.globalDisableFlag?.() ?? false;
@ -239,7 +243,7 @@ export function AlertResourceTableRow(props: AlertResourceTableRowProps) {
</Show>
</TableCell>
<TableCell class="p-1 px-2">
<TableCell class={getPlatformTableCellClassForKind('name')}>
<div class="flex items-center gap-2 min-w-0">
<Show
when={props.resource.type === 'agent'}
@ -334,10 +338,14 @@ export function AlertResourceTableRow(props: AlertResourceTableRowProps) {
const titlePrefix = metric === 'backup' ? 'Backup' : 'Snapshot';
return (
<TableCell class="p-1 px-2 text-center align-middle">
<TableCell class={`${getPlatformTableCellClassForKind('badge')} align-middle`}>
<Show
when={onToggle}
fallback={<span class="text-sm text-slate-400" aria-hidden="true">-</span>}
fallback={
<span class="text-sm text-slate-400" aria-hidden="true">
-
</span>
}
>
<div class="flex items-center justify-center">
<StatusBadge
@ -357,10 +365,16 @@ export function AlertResourceTableRow(props: AlertResourceTableRowProps) {
};
return (
<TableCell class="p-1 px-2 text-center align-middle">
<TableCell
class={`${getPlatformTableCellClassForKind(getAlertResourceColumnKind(column))} align-middle`}
>
<Show
when={alertResourceSupportsMetric(props.resource.type, metric)}
fallback={<span class="text-sm text-muted" aria-hidden="true">-</span>}
fallback={
<span class="text-sm text-muted" aria-hidden="true">
-
</span>
}
>
<Show
when={isEditing()}
@ -410,10 +424,7 @@ export function AlertResourceTableRow(props: AlertResourceTableRowProps) {
return Math.round(editingVal);
}
const currentDisplayValue = displayValue(metric);
if (
typeof currentDisplayValue === 'number' &&
currentDisplayValue >= 0
) {
if (typeof currentDisplayValue === 'number' && currentDisplayValue >= 0) {
return Math.round(currentDisplayValue);
}
return defaultSliderValue();
@ -422,10 +433,7 @@ export function AlertResourceTableRow(props: AlertResourceTableRowProps) {
return (
<div class="w-36">
<ThresholdSlider
value={Math.max(
sliderMin,
Math.min(sliderMax, currentSliderValue()),
)}
value={Math.max(sliderMin, Math.min(sliderMax, currentSliderValue()))}
onChange={(val) => {
props.setEditingThresholds({
...props.editingThresholds(),
@ -498,7 +506,7 @@ export function AlertResourceTableRow(props: AlertResourceTableRowProps) {
</For>
<Show when={props.showOfflineAlertsColumn}>
<TableCell class="p-1 px-2 text-center align-middle">
<TableCell class={`${getPlatformTableCellClassForKind('badge')} align-middle`}>
{(() => {
const disabledGlobally = props.globalDisableFlag?.() ?? false;
const supportsTriState =
@ -528,7 +536,11 @@ export function AlertResourceTableRow(props: AlertResourceTableRowProps) {
}
if (!props.onToggleNodeConnectivity) {
return <span class="text-sm text-slate-400" aria-hidden="true">-</span>;
return (
<span class="text-sm text-slate-400" aria-hidden="true">
-
</span>
);
}
const globalOfflineDisabled = props.globalDisableOfflineFlag?.() ?? false;
@ -539,17 +551,15 @@ export function AlertResourceTableRow(props: AlertResourceTableRowProps) {
if (disabledGlobally) return;
props.onToggleNodeConnectivity?.(props.resource.id);
},
titleEnabled:
'Offline alerts enabled. Click to disable for this resource.',
titleDisabled:
'Offline alerts disabled. Click to enable for this resource.',
titleEnabled: 'Offline alerts enabled. Click to disable for this resource.',
titleDisabled: 'Offline alerts disabled. Click to enable for this resource.',
titleWhenDisabled: 'Offline alerts controlled globally',
});
})()}
</TableCell>
</Show>
<TableCell class="p-1 px-2">
<TableCell class={getPlatformTableCellClassForKind('badge')}>
<div class="flex items-center justify-center gap-1">
<Show
when={!isEditing()}
@ -581,7 +591,14 @@ export function AlertResourceTableRow(props: AlertResourceTableRowProps) {
props.resource.disableConnectivity);
return (
<Show when={showEdit || showRevert} fallback={<span class="text-xs text-muted" aria-hidden="true"></span>}>
<Show
when={showEdit || showRevert}
fallback={
<span class="text-xs text-muted" aria-hidden="true">
</span>
}
>
<Show when={showEdit}>
<button
type="button"

View file

@ -291,13 +291,9 @@ describe('ResourceTable', () => {
);
expect(alertResourceTableDesktopSource).toContain('AlertResourceTableRow');
expect(alertResourceTableDesktopSource).toContain('AlertResourceGroupHeader');
expect(alertResourceTableMobileSource).toContain(
'export function AlertResourceTableMobile',
);
expect(alertResourceTableMobileSource).toContain('export function AlertResourceTableMobile');
expect(alertResourceTableMobileSource).toContain('AlertResourceGroupHeader');
expect(alertResourceGroupHeaderSource).toContain(
'export function AlertResourceGroupHeader',
);
expect(alertResourceGroupHeaderSource).toContain('export function AlertResourceGroupHeader');
expect(alertResourceTableRowSource).toContain('export function AlertResourceTableRow');
expect(alertResourceTableRowSource).toContain('alertResourceSupportsMetric');
expect(alertResourceTableStateSource).toContain('export function useAlertResourceTableState');
@ -316,6 +312,10 @@ describe('ResourceTable', () => {
expect(normalizeAlertResourceMetricKey('CPU %')).toBe('cpu');
expect(alertResourceSupportsMetric('agent', 'networkIn')).toBe(false);
expect(alertResourceSupportsMetric('pbs', 'disk')).toBe(false);
expect(alertResourceSupportsMetric('vmwareHost', 'networkIn')).toBe(true);
expect(alertResourceSupportsMetric('vmwareHost', 'disk')).toBe(false);
expect(alertResourceSupportsMetric('vmwareDatastore', 'usage')).toBe(true);
expect(alertResourceSupportsMetric('vmwareNetwork', 'cpu')).toBe(false);
expect(
getAlertResourceMetricDisplayValue(
makeResource({
@ -531,15 +531,11 @@ describe('ResourceTable', () => {
expect(disabledOff).toBeTruthy();
// formatMetricValue should NOT be called for disabled metrics (they show "Off" directly)
const cpuCalls = formatMetricValue.mock.calls.filter(
([metric]) => metric === 'cpu',
);
const cpuCalls = formatMetricValue.mock.calls.filter(([metric]) => metric === 'cpu');
expect(cpuCalls.length).toBe(0);
// Memory (85) should still be formatted normally
const memoryCalls = formatMetricValue.mock.calls.filter(
([metric]) => metric === 'memory',
);
const memoryCalls = formatMetricValue.mock.calls.filter(([metric]) => metric === 'memory');
expect(memoryCalls.length).toBeGreaterThanOrEqual(1);
});
@ -954,9 +950,7 @@ describe('ResourceTable', () => {
render(() => <ResourceTable {...props} />);
// Storage only supports 'usage' — cpu and memory should NOT be formatted
const usageCalls = formatMetricValue.mock.calls.filter(
(args) => args[0] === 'usage',
);
const usageCalls = formatMetricValue.mock.calls.filter((args) => args[0] === 'usage');
expect(usageCalls.length).toBeGreaterThanOrEqual(1);
const cpuCalls = formatMetricValue.mock.calls.filter((args) => args[0] === 'cpu');

View file

@ -1,34 +1,91 @@
import { Show } from 'solid-js';
import { SearchInput } from '@/components/shared/SearchInput';
import Server from 'lucide-solid/icons/server';
import Mail from 'lucide-solid/icons/mail';
import Users from 'lucide-solid/icons/users';
import Boxes from 'lucide-solid/icons/boxes';
import Cpu from 'lucide-solid/icons/cpu';
import Database from 'lucide-solid/icons/database';
import HardDrive from 'lucide-solid/icons/hard-drive';
import Network from 'lucide-solid/icons/network';
import { FilterBar, type FilterDef } from '@/components/shared/FilterBar';
import { useBreakpoint } from '@/hooks/useBreakpoint';
import { BulkEditDialog } from './BulkEditDialog';
import { ThresholdsTableAgentsTab } from './ThresholdsTableAgentsTab';
import { ThresholdsTableDockerTab } from './ThresholdsTableDockerTab';
import { ThresholdsTableKubernetesTab } from './ThresholdsTableKubernetesTab';
import { ThresholdsTablePBSTab } from './ThresholdsTablePBSTab';
import { ThresholdsTablePMGTab } from './ThresholdsTablePMGTab';
import { ThresholdsTableProxmoxTab } from './ThresholdsTableProxmoxTab';
import { ThresholdsTableTrueNASTab } from './ThresholdsTableTrueNASTab';
import { ThresholdsTableVMwareTab } from './ThresholdsTableVMwareTab';
import type { ThresholdsTableProps } from '@/features/alerts/thresholds/types';
import type { ThresholdsActiveTab } from '@/features/alerts/thresholds/tableTypes';
import { useThresholdsTableState } from '@/features/alerts/thresholds/hooks/useThresholdsTableState';
export function ThresholdsTable(props: ThresholdsTableProps) {
const state = useThresholdsTableState(props);
const { isMobile } = useBreakpoint();
const filters = (): FilterDef[] => [
{
id: 'alerts-platform',
label: 'Platform',
group: 'scope',
inline: true,
value: state.activeTab,
setValue: (value) => state.handleTabClick(value as ThresholdsActiveTab),
defaultValue: 'proxmox',
options: () => [
{ value: 'proxmox', label: 'Proxmox', icon: Server },
{ value: 'docker', label: 'Docker', icon: Boxes },
{ value: 'kubernetes', label: 'Kubernetes', icon: Network },
{ value: 'truenas', label: 'TrueNAS', icon: HardDrive },
{ value: 'vmware', label: 'vSphere', icon: Cpu },
{ value: 'pbs', label: 'PBS', icon: Database },
{ value: 'pmg', label: 'PMG', icon: Mail },
{ value: 'systems', label: 'Systems', icon: Users },
],
},
{
id: 'alerts-overrides',
label: 'Overrides',
group: 'properties',
value: state.overrideFilter,
setValue: state.setOverrideFilter,
defaultValue: 'all',
options: () => [
{ value: 'all', label: 'All resources' },
{ value: 'custom', label: 'Custom only' },
{ value: 'disabled', label: 'Disabled only' },
],
},
];
return (
<div class="space-y-4">
<div class="relative">
<SearchInput
value={state.searchTerm}
onChange={state.setSearchTerm}
placeholder={state.getAlertThresholdsSearchPlaceholder()}
class="w-full"
onBeforeAutoFocus={() => Boolean(state.editingId())}
focusOnShortcut
clearOnEscape
shortcutHint="Ctrl+F"
/>
</div>
<FilterBar
role="group"
ariaLabel="Alert threshold filters"
isMobile={isMobile}
search={{
value: state.searchTerm,
setValue: state.setSearchTerm,
placeholder: state.getAlertThresholdsSearchPlaceholder(),
clearOnEscape: true,
onBeforeAutoFocus: () => Boolean(state.editingId()),
}}
filters={filters()}
showClearAll={() =>
state.searchTerm().trim().length > 0 ||
state.overrideFilter() !== 'all' ||
state.activeTab() !== 'proxmox'
}
onClearAll={() => {
state.setSearchTerm('');
state.setOverrideFilter('all');
state.handleTabClick('proxmox');
}}
/>
<Show when={!state.helpBannerDismissed()}>
<div class="rounded-md border border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-900 p-3 relative group">
@ -83,45 +140,7 @@ export function ThresholdsTable(props: ThresholdsTableProps) {
</div>
</Show>
<div class="border-b border-border">
<nav class="-mb-px flex gap-4 sm:gap-6" aria-label="Tabs">
<button
type="button"
onClick={() => state.handleTabClick('infrastructure')}
class={`py-3 px-1 border-b-2 font-medium text-sm transition-colors cursor-pointer flex items-center gap-1.5 ${state.activeTab() === 'infrastructure' ? 'border-blue-500 text-blue-600 dark:text-blue-400' : 'border-transparent text-muted hover:text-base-content hover:border-slate-300'}`}
>
<Server class="w-4 h-4" />
<span>Infrastructure</span>
</button>
<button
type="button"
onClick={() => state.handleTabClick('pmg')}
class={`py-3 px-1 border-b-2 font-medium text-sm transition-colors cursor-pointer flex items-center gap-1.5 ${state.activeTab() === 'pmg' ? 'border-blue-500 text-blue-600 dark:text-blue-400' : 'border-transparent text-muted hover:text-base-content hover:border-slate-300'}`}
>
<Mail class="w-4 h-4" />
<span class="hidden sm:inline">Mail Gateway</span>
<span class="sm:hidden">Mail</span>
</button>
<button
type="button"
onClick={() => state.handleTabClick('systems')}
class={`py-3 px-1 border-b-2 font-medium text-sm transition-colors cursor-pointer flex items-center gap-1.5 ${state.activeTab() === 'systems' ? 'border-blue-500 text-blue-600 dark:text-blue-400' : 'border-transparent text-muted hover:text-base-content hover:border-slate-300'}`}
>
<Users class="w-4 h-4" />
<span>Systems</span>
</button>
<button
type="button"
onClick={() => state.handleTabClick('docker')}
class={`py-3 px-1 border-b-2 font-medium text-sm transition-colors cursor-pointer flex items-center gap-1.5 ${state.activeTab() === 'docker' ? 'border-blue-500 text-blue-600 dark:text-blue-400' : 'border-transparent text-muted hover:text-base-content hover:border-slate-300'}`}
>
<Boxes class="w-4 h-4" />
<span>Containers</span>
</button>
</nav>
</div>
<Show when={state.activeTab() === 'infrastructure'}>
<Show when={state.activeTab() === 'proxmox'}>
<div class="flex justify-end gap-2">
<button
type="button"
@ -142,10 +161,30 @@ export function ThresholdsTable(props: ThresholdsTableProps) {
</Show>
<div class="space-y-6">
<Show when={state.activeTab() === 'infrastructure'}>
<Show when={state.activeTab() === 'proxmox'}>
<ThresholdsTableProxmoxTab state={state} tableProps={props} />
</Show>
<Show when={state.activeTab() === 'docker'}>
<ThresholdsTableDockerTab state={state} tableProps={props} />
</Show>
<Show when={state.activeTab() === 'kubernetes'}>
<ThresholdsTableKubernetesTab state={state} tableProps={props} />
</Show>
<Show when={state.activeTab() === 'truenas'}>
<ThresholdsTableTrueNASTab state={state} tableProps={props} />
</Show>
<Show when={state.activeTab() === 'vmware'}>
<ThresholdsTableVMwareTab state={state} tableProps={props} />
</Show>
<Show when={state.activeTab() === 'pbs'}>
<ThresholdsTablePBSTab state={state} tableProps={props} />
</Show>
<Show when={state.activeTab() === 'pmg'}>
<ThresholdsTablePMGTab state={state} tableProps={props} />
</Show>
@ -153,10 +192,6 @@ export function ThresholdsTable(props: ThresholdsTableProps) {
<Show when={state.activeTab() === 'systems'}>
<ThresholdsTableAgentsTab state={state} tableProps={props} />
</Show>
<Show when={state.activeTab() === 'docker'}>
<ThresholdsTableDockerTab state={state} tableProps={props} />
</Show>
</div>
<BulkEditDialog

View file

@ -0,0 +1,149 @@
import { Show, type JSX } from 'solid-js';
import Boxes from 'lucide-solid/icons/boxes';
import Network from 'lucide-solid/icons/network';
import Server from 'lucide-solid/icons/server';
import SquareStack from 'lucide-solid/icons/square-stack';
import Tags from 'lucide-solid/icons/tags';
import { ResourceTable } from './ResourceTable';
import { CollapsibleSection } from './Thresholds/sections/CollapsibleSection';
import { formatMetricValue } from '@/features/alerts/thresholds/helpers';
import type { ThresholdsTableSectionProps } from '@/features/alerts/thresholds/thresholdsTableSectionProps';
import type { Resource } from '@/features/alerts/thresholds/tableTypes';
const KUBERNETES_WORKLOAD_COLUMNS = [
'CPU %',
'Memory %',
'Disk %',
'Disk R MB/s',
'Disk W MB/s',
'Net In MB/s',
'Net Out MB/s',
];
const KUBERNETES_NODE_COLUMNS = ['CPU %', 'Memory %', 'Disk %'];
function KubernetesResourceSection(
props: ThresholdsTableSectionProps & {
id: string;
title: string;
resources: () => Resource[];
columns: string[];
icon: JSX.Element;
typeKey: 'k8s-cluster' | 'k8s-node' | 'k8s-namespace' | 'k8s-deployment' | 'pod';
defaults?: Record<string, number | undefined>;
},
) {
const { state, tableProps } = props;
return (
<Show when={state.hasSection(props.id)}>
<CollapsibleSection
id={props.id}
title={props.title}
resourceCount={props.resources().length}
collapsed={state.isCollapsed(props.id)}
onToggle={() => state.toggleSection(props.id)}
icon={props.icon}
isGloballyDisabled={tableProps.disableAllKubernetes()}
emptyMessage="No Kubernetes alert targets match the current filters."
>
<div ref={state.registerSection(props.id)} class="scroll-mt-24">
<ResourceTable
title=""
resources={props.resources()}
columns={props.columns}
activeAlerts={tableProps.activeAlerts}
emptyMessage="No Kubernetes alert targets match the current filters."
onEdit={state.startEditing}
onSaveEdit={state.saveEdit}
onCancelEdit={state.cancelEdit}
onRemoveOverride={state.removeOverride}
onToggleDisabled={state.toggleDisabled}
showOfflineAlertsColumn={false}
editingId={state.editingId}
editingThresholds={state.editingThresholds}
setEditingThresholds={state.setEditingThresholds}
editingNote={state.editingNote}
setEditingNote={state.setEditingNote}
onBulkEdit={(ids) => state.handleBulkEdit(ids, props.columns)}
formatMetricValue={formatMetricValue}
hasActiveAlert={state.hasActiveAlert}
globalDefaults={props.defaults}
setGlobalDefaults={props.defaults ? tableProps.setKubernetesDefaults : undefined}
setHasUnsavedChanges={tableProps.setHasUnsavedChanges}
globalDisableFlag={tableProps.disableAllKubernetes}
onToggleGlobalDisable={() =>
tableProps.setDisableAllKubernetes(!tableProps.disableAllKubernetes())
}
showDelayColumn={props.columns.length > 0}
globalDelaySeconds={tableProps.timeThresholds()[props.typeKey]}
metricDelaySeconds={tableProps.metricTimeThresholds()[props.typeKey] ?? {}}
onMetricDelayChange={(metric, value) =>
state.updateMetricDelay(props.typeKey, metric, value)
}
factoryDefaults={props.defaults ? tableProps.factoryKubernetesDefaults : undefined}
onResetDefaults={props.defaults ? tableProps.resetKubernetesDefaults : undefined}
/>
</div>
</CollapsibleSection>
</Show>
);
}
export function ThresholdsTableKubernetesTab(props: ThresholdsTableSectionProps) {
const defaults = () => props.tableProps.kubernetesDefaults ?? {};
return (
<>
<KubernetesResourceSection
{...props}
id="kubernetesClusters"
title="Clusters"
resources={props.state.kubernetesClustersWithOverrides}
columns={KUBERNETES_WORKLOAD_COLUMNS}
icon={<Network class="w-5 h-5" />}
typeKey="k8s-cluster"
defaults={defaults()}
/>
<KubernetesResourceSection
{...props}
id="kubernetesNodes"
title="Nodes"
resources={props.state.kubernetesNodesWithOverrides}
columns={KUBERNETES_NODE_COLUMNS}
icon={<Server class="w-5 h-5" />}
typeKey="k8s-node"
defaults={defaults()}
/>
<KubernetesResourceSection
{...props}
id="kubernetesNamespaces"
title="Namespaces"
resources={props.state.kubernetesNamespacesWithOverrides}
columns={[]}
icon={<Tags class="w-5 h-5" />}
typeKey="k8s-namespace"
/>
<KubernetesResourceSection
{...props}
id="kubernetesDeployments"
title="Deployments"
resources={props.state.kubernetesDeploymentsWithOverrides}
columns={KUBERNETES_WORKLOAD_COLUMNS}
icon={<SquareStack class="w-5 h-5" />}
typeKey="k8s-deployment"
defaults={defaults()}
/>
<KubernetesResourceSection
{...props}
id="kubernetesPods"
title="Pods"
resources={props.state.kubernetesPodsWithOverrides}
columns={KUBERNETES_WORKLOAD_COLUMNS}
icon={<Boxes class="w-5 h-5" />}
typeKey="pod"
defaults={defaults()}
/>
</>
);
}

View file

@ -0,0 +1,6 @@
import { ThresholdsTableProxmoxPBSSection } from './ThresholdsTableProxmoxPBSSection';
import type { ThresholdsTableSectionProps } from '@/features/alerts/thresholds/thresholdsTableSectionProps';
export function ThresholdsTablePBSTab(props: ThresholdsTableSectionProps) {
return <ThresholdsTableProxmoxPBSSection {...props} />;
}

View file

@ -2,7 +2,6 @@ import { ThresholdsTableProxmoxBackupsSection } from './ThresholdsTableProxmoxBa
import { ThresholdsTableProxmoxGuestFilteringSection } from './ThresholdsTableProxmoxGuestFilteringSection';
import { ThresholdsTableProxmoxGuestsSection } from './ThresholdsTableProxmoxGuestsSection';
import { ThresholdsTableProxmoxNodesSection } from './ThresholdsTableProxmoxNodesSection';
import { ThresholdsTableProxmoxPBSSection } from './ThresholdsTableProxmoxPBSSection';
import { ThresholdsTableProxmoxSnapshotsSection } from './ThresholdsTableProxmoxSnapshotsSection';
import { ThresholdsTableProxmoxStorageSection } from './ThresholdsTableProxmoxStorageSection';
import type { ThresholdsTableSectionProps } from '@/features/alerts/thresholds/thresholdsTableSectionProps';
@ -11,7 +10,6 @@ export function ThresholdsTableProxmoxTab(props: ThresholdsTableSectionProps) {
return (
<>
<ThresholdsTableProxmoxNodesSection {...props} />
<ThresholdsTableProxmoxPBSSection {...props} />
<ThresholdsTableProxmoxGuestsSection {...props} />
<ThresholdsTableProxmoxGuestFilteringSection {...props} />
<ThresholdsTableProxmoxBackupsSection {...props} />

View file

@ -0,0 +1,181 @@
import { Show, type JSX } from 'solid-js';
import Database from 'lucide-solid/icons/database';
import HardDrive from 'lucide-solid/icons/hard-drive';
import Server from 'lucide-solid/icons/server';
import { ResourceTable } from './ResourceTable';
import { CollapsibleSection } from './Thresholds/sections/CollapsibleSection';
import { formatMetricValue } from '@/features/alerts/thresholds/helpers';
import type { ThresholdsTableSectionProps } from '@/features/alerts/thresholds/thresholdsTableSectionProps';
import type { Resource } from '@/features/alerts/thresholds/tableTypes';
const TRUENAS_SYSTEM_COLUMNS = [
'CPU %',
'Memory %',
'Disk %',
'Temp °C',
'Disk R MB/s',
'Disk W MB/s',
'Net In MB/s',
'Net Out MB/s',
];
const TRUENAS_STORAGE_COLUMNS = ['Usage %'];
const TRUENAS_DISK_COLUMNS = ['Temp °C'];
function TrueNASResourceSection(
props: ThresholdsTableSectionProps & {
id: string;
title: string;
resources: () => Resource[];
columns: string[];
icon: JSX.Element;
typeKey: 'truenas-system' | 'truenas-pool' | 'truenas-dataset' | 'truenas-disk';
defaults: Record<string, number | undefined>;
factoryDefaults?: Record<string, number | undefined>;
setGlobalDefaults?: (
value:
| Record<string, number | undefined>
| ((prev: Record<string, number | undefined>) => Record<string, number | undefined>),
) => void;
onResetDefaults?: () => void;
},
) {
const { state, tableProps } = props;
return (
<Show when={state.hasSection(props.id)}>
<CollapsibleSection
id={props.id}
title={props.title}
resourceCount={props.resources().length}
collapsed={state.isCollapsed(props.id)}
onToggle={() => state.toggleSection(props.id)}
icon={props.icon}
isGloballyDisabled={tableProps.disableAllTrueNAS()}
emptyMessage="No TrueNAS alert targets match the current filters."
>
<div ref={state.registerSection(props.id)} class="scroll-mt-24">
<ResourceTable
title=""
resources={props.resources()}
columns={props.columns}
activeAlerts={tableProps.activeAlerts}
emptyMessage="No TrueNAS alert targets match the current filters."
onEdit={state.startEditing}
onSaveEdit={state.saveEdit}
onCancelEdit={state.cancelEdit}
onRemoveOverride={state.removeOverride}
onToggleDisabled={state.toggleDisabled}
showOfflineAlertsColumn={false}
editingId={state.editingId}
editingThresholds={state.editingThresholds}
setEditingThresholds={state.setEditingThresholds}
editingNote={state.editingNote}
setEditingNote={state.setEditingNote}
onBulkEdit={(ids) => state.handleBulkEdit(ids, props.columns)}
formatMetricValue={formatMetricValue}
hasActiveAlert={state.hasActiveAlert}
globalDefaults={props.defaults}
setGlobalDefaults={props.setGlobalDefaults}
setHasUnsavedChanges={tableProps.setHasUnsavedChanges}
globalDisableFlag={tableProps.disableAllTrueNAS}
onToggleGlobalDisable={() =>
tableProps.setDisableAllTrueNAS(!tableProps.disableAllTrueNAS())
}
showDelayColumn={true}
globalDelaySeconds={tableProps.timeThresholds()[props.typeKey]}
metricDelaySeconds={tableProps.metricTimeThresholds()[props.typeKey] ?? {}}
onMetricDelayChange={(metric, value) =>
state.updateMetricDelay(props.typeKey, metric, value)
}
factoryDefaults={props.factoryDefaults}
onResetDefaults={props.onResetDefaults}
/>
</div>
</CollapsibleSection>
</Show>
);
}
export function ThresholdsTableTrueNASTab(props: ThresholdsTableSectionProps) {
const trueNASDefaults = () => props.tableProps.trueNASDefaults ?? {};
const trueNASStorageDefaults = () => ({ usage: trueNASDefaults().usage ?? 85 });
const trueNASDiskDefaults = () => props.tableProps.trueNASDiskDefaults ?? {};
return (
<>
<TrueNASResourceSection
{...props}
id="trueNASSystems"
title="Systems"
resources={props.state.trueNASSystemsWithOverrides}
columns={TRUENAS_SYSTEM_COLUMNS}
icon={<Server class="w-5 h-5" />}
typeKey="truenas-system"
defaults={trueNASDefaults()}
factoryDefaults={props.tableProps.factoryTrueNASDefaults}
setGlobalDefaults={props.tableProps.setTrueNASDefaults}
onResetDefaults={props.tableProps.resetTrueNASDefaults}
/>
<TrueNASResourceSection
{...props}
id="trueNASPools"
title="Pools"
resources={props.state.trueNASPoolsWithOverrides}
columns={TRUENAS_STORAGE_COLUMNS}
icon={<Database class="w-5 h-5" />}
typeKey="truenas-pool"
defaults={trueNASStorageDefaults()}
factoryDefaults={
props.tableProps.factoryTrueNASDefaults?.usage !== undefined
? { usage: props.tableProps.factoryTrueNASDefaults.usage }
: undefined
}
setGlobalDefaults={(value) => {
if (!props.tableProps.setTrueNASDefaults) return;
props.tableProps.setTrueNASDefaults((prev) => {
const next = typeof value === 'function' ? value({ usage: prev.usage }) : value;
return { ...prev, usage: next.usage ?? 85 };
});
}}
onResetDefaults={props.tableProps.resetTrueNASDefaults}
/>
<TrueNASResourceSection
{...props}
id="trueNASDatasets"
title="Datasets"
resources={props.state.trueNASDatasetsWithOverrides}
columns={TRUENAS_STORAGE_COLUMNS}
icon={<Database class="w-5 h-5" />}
typeKey="truenas-dataset"
defaults={trueNASStorageDefaults()}
factoryDefaults={
props.tableProps.factoryTrueNASDefaults?.usage !== undefined
? { usage: props.tableProps.factoryTrueNASDefaults.usage }
: undefined
}
setGlobalDefaults={(value) => {
if (!props.tableProps.setTrueNASDefaults) return;
props.tableProps.setTrueNASDefaults((prev) => {
const next = typeof value === 'function' ? value({ usage: prev.usage }) : value;
return { ...prev, usage: next.usage ?? 85 };
});
}}
onResetDefaults={props.tableProps.resetTrueNASDefaults}
/>
<TrueNASResourceSection
{...props}
id="trueNASDisks"
title="Disks"
resources={props.state.trueNASDisksWithOverrides}
columns={TRUENAS_DISK_COLUMNS}
icon={<HardDrive class="w-5 h-5" />}
typeKey="truenas-disk"
defaults={trueNASDiskDefaults()}
factoryDefaults={props.tableProps.factoryTrueNASDiskDefaults}
setGlobalDefaults={props.tableProps.setTrueNASDiskDefaults}
onResetDefaults={props.tableProps.resetTrueNASDiskDefaults}
/>
</>
);
}

View file

@ -0,0 +1,179 @@
import { Show, type JSX } from 'solid-js';
import Cpu from 'lucide-solid/icons/cpu';
import Database from 'lucide-solid/icons/database';
import Monitor from 'lucide-solid/icons/monitor';
import Network from 'lucide-solid/icons/network';
import { ResourceTable } from './ResourceTable';
import { CollapsibleSection } from './Thresholds/sections/CollapsibleSection';
import { formatMetricValue } from '@/features/alerts/thresholds/helpers';
import type { ThresholdsTableSectionProps } from '@/features/alerts/thresholds/thresholdsTableSectionProps';
import type { Resource } from '@/features/alerts/thresholds/tableTypes';
const VMWARE_HOST_COLUMNS = [
'CPU %',
'Memory %',
'Disk R MB/s',
'Disk W MB/s',
'Net In MB/s',
'Net Out MB/s',
];
const VMWARE_VM_COLUMNS = [
'CPU %',
'Memory %',
'Disk %',
'Disk R MB/s',
'Disk W MB/s',
'Net In MB/s',
'Net Out MB/s',
];
const VMWARE_DATASTORE_COLUMNS = ['Usage %'];
function VMwareResourceSection(
props: ThresholdsTableSectionProps & {
id: string;
title: string;
resources: () => Resource[];
columns: string[];
icon: JSX.Element;
typeKey: 'vmware-host' | 'vmware-vm' | 'vmware-datastore' | 'vmware-network';
defaults?: Record<string, number | undefined>;
factoryDefaults?: Record<string, number | undefined>;
setGlobalDefaults?: (
value:
| Record<string, number | undefined>
| ((prev: Record<string, number | undefined>) => Record<string, number | undefined>),
) => void;
onResetDefaults?: () => void;
},
) {
const { state, tableProps } = props;
return (
<Show when={state.hasSection(props.id)}>
<CollapsibleSection
id={props.id}
title={props.title}
resourceCount={props.resources().length}
collapsed={state.isCollapsed(props.id)}
onToggle={() => state.toggleSection(props.id)}
icon={props.icon}
isGloballyDisabled={tableProps.disableAllVMware()}
emptyMessage="No vSphere alert targets match the current filters."
>
<div ref={state.registerSection(props.id)} class="scroll-mt-24">
<ResourceTable
title=""
resources={props.resources()}
columns={props.columns}
activeAlerts={tableProps.activeAlerts}
emptyMessage="No vSphere alert targets match the current filters."
onEdit={state.startEditing}
onSaveEdit={state.saveEdit}
onCancelEdit={state.cancelEdit}
onRemoveOverride={state.removeOverride}
onToggleDisabled={state.toggleDisabled}
showOfflineAlertsColumn={false}
editingId={state.editingId}
editingThresholds={state.editingThresholds}
setEditingThresholds={state.setEditingThresholds}
editingNote={state.editingNote}
setEditingNote={state.setEditingNote}
onBulkEdit={(ids) => state.handleBulkEdit(ids, props.columns)}
formatMetricValue={formatMetricValue}
hasActiveAlert={state.hasActiveAlert}
globalDefaults={props.defaults}
setGlobalDefaults={props.setGlobalDefaults}
setHasUnsavedChanges={tableProps.setHasUnsavedChanges}
globalDisableFlag={tableProps.disableAllVMware}
onToggleGlobalDisable={() =>
tableProps.setDisableAllVMware(!tableProps.disableAllVMware())
}
showDelayColumn={props.columns.length > 0}
globalDelaySeconds={tableProps.timeThresholds()[props.typeKey]}
metricDelaySeconds={tableProps.metricTimeThresholds()[props.typeKey] ?? {}}
onMetricDelayChange={(metric, value) =>
state.updateMetricDelay(props.typeKey, metric, value)
}
factoryDefaults={props.factoryDefaults}
onResetDefaults={props.onResetDefaults}
/>
</div>
</CollapsibleSection>
</Show>
);
}
export function ThresholdsTableVMwareTab(props: ThresholdsTableSectionProps) {
const vmwareDefaults = () => props.tableProps.vmwareDefaults ?? {};
const datastoreDefaults = () => ({ usage: vmwareDefaults().usage ?? 85 });
const setDatastoreDefaults = (
value:
| Record<string, number | undefined>
| ((prev: Record<string, number | undefined>) => Record<string, number | undefined>),
) => {
if (!props.tableProps.setVMwareDefaults) return;
props.tableProps.setVMwareDefaults((prev) => {
const next = typeof value === 'function' ? value({ usage: prev.usage }) : value;
return { ...prev, usage: next.usage ?? 85 };
});
};
return (
<>
<VMwareResourceSection
{...props}
id="vmwareHosts"
title="Hosts"
resources={props.state.vmwareHostsWithOverrides}
columns={VMWARE_HOST_COLUMNS}
icon={<Cpu class="w-5 h-5" />}
typeKey="vmware-host"
defaults={vmwareDefaults()}
factoryDefaults={props.tableProps.factoryVMwareDefaults}
setGlobalDefaults={props.tableProps.setVMwareDefaults}
onResetDefaults={props.tableProps.resetVMwareDefaults}
/>
<VMwareResourceSection
{...props}
id="vmwareVMs"
title="Virtual Machines"
resources={props.state.vmwareVMsWithOverrides}
columns={VMWARE_VM_COLUMNS}
icon={<Monitor class="w-5 h-5" />}
typeKey="vmware-vm"
defaults={vmwareDefaults()}
factoryDefaults={props.tableProps.factoryVMwareDefaults}
setGlobalDefaults={props.tableProps.setVMwareDefaults}
onResetDefaults={props.tableProps.resetVMwareDefaults}
/>
<VMwareResourceSection
{...props}
id="vmwareDatastores"
title="Datastores"
resources={props.state.vmwareDatastoresWithOverrides}
columns={VMWARE_DATASTORE_COLUMNS}
icon={<Database class="w-5 h-5" />}
typeKey="vmware-datastore"
defaults={datastoreDefaults()}
factoryDefaults={
props.tableProps.factoryVMwareDefaults?.usage !== undefined
? { usage: props.tableProps.factoryVMwareDefaults.usage }
: undefined
}
setGlobalDefaults={setDatastoreDefaults}
onResetDefaults={props.tableProps.resetVMwareDefaults}
/>
<VMwareResourceSection
{...props}
id="vmwareNetworks"
title="Networks"
resources={props.state.vmwareNetworksWithOverrides}
columns={[]}
icon={<Network class="w-5 h-5" />}
typeKey="vmware-network"
/>
</>
);
}

View file

@ -7,7 +7,7 @@ import { normalizeDockerIgnoredInput } from '@/features/alerts/thresholds/helper
import type { PMGThresholdDefaults, SnapshotAlertConfig, BackupAlertConfig } from '@/types/alerts';
import type { Agent, Alert } from '@/types/api';
const [getPathname, setPathname] = createSignal('/alerts/thresholds/containers');
const [getPathname, setPathname] = createSignal('/alerts/thresholds/docker');
const mockNavigate = vi.fn();
vi.mock('@solidjs/router', () => ({
@ -93,7 +93,7 @@ afterEach(() => {
});
beforeEach(() => {
setPathname('/alerts/thresholds/containers');
setPathname('/alerts/thresholds/docker');
vi.clearAllMocks();
});
@ -139,6 +139,7 @@ const baseProps = () => ({
storage: [],
containerRuntimes: [],
dockerHosts: [],
allResources: [],
pbsInstances: [],
pmgInstances: [],
pmgThresholds: () => DEFAULT_PMG_THRESHOLDS,
@ -151,6 +152,43 @@ const baseProps = () => ({
setGuestPoweredOffSeverity: vi.fn(),
nodeDefaults: {},
setNodeDefaults: vi.fn(),
pbsDefaults: { cpu: 80, memory: 85 },
setPBSDefaults: vi.fn(),
kubernetesDefaults: {
cpu: 80,
memory: 85,
disk: 90,
diskRead: -1,
diskWrite: -1,
networkIn: -1,
networkOut: -1,
},
setKubernetesDefaults: vi.fn(),
trueNASDefaults: {
cpu: 80,
memory: 85,
disk: 85,
usage: 85,
temperature: 80,
diskRead: -1,
diskWrite: -1,
networkIn: -1,
networkOut: -1,
},
setTrueNASDefaults: vi.fn(),
trueNASDiskDefaults: { temperature: 55 },
setTrueNASDiskDefaults: vi.fn(),
vmwareDefaults: {
cpu: 80,
memory: 85,
disk: 90,
usage: 85,
diskRead: -1,
diskWrite: -1,
networkIn: -1,
networkOut: -1,
},
setVMwareDefaults: vi.fn(),
agentDefaults: { cpu: 80, memory: 85, disk: 90 },
setAgentDefaults: vi.fn(),
dockerDefaults: DEFAULT_DOCKER_DEFAULTS,
@ -163,12 +201,49 @@ const baseProps = () => ({
setStorageDefault: vi.fn(),
resetGuestDefaults: vi.fn(),
resetNodeDefaults: vi.fn(),
resetPBSDefaults: vi.fn(),
resetKubernetesDefaults: vi.fn(),
resetTrueNASDefaults: vi.fn(),
resetTrueNASDiskDefaults: vi.fn(),
resetVMwareDefaults: vi.fn(),
resetAgentDefaults: vi.fn(),
resetDockerDefaults: vi.fn(),
resetDockerIgnoredPrefixes: vi.fn(),
resetStorageDefault: vi.fn(),
factoryGuestDefaults: {},
factoryNodeDefaults: {},
factoryPBSDefaults: { cpu: 80, memory: 85 },
factoryKubernetesDefaults: {
cpu: 80,
memory: 85,
disk: 90,
diskRead: -1,
diskWrite: -1,
networkIn: -1,
networkOut: -1,
},
factoryTrueNASDefaults: {
cpu: 80,
memory: 85,
disk: 85,
usage: 85,
temperature: 80,
diskRead: -1,
diskWrite: -1,
networkIn: -1,
networkOut: -1,
},
factoryTrueNASDiskDefaults: { temperature: 55 },
factoryVMwareDefaults: {
cpu: 80,
memory: 85,
disk: 90,
usage: 85,
diskRead: -1,
diskWrite: -1,
networkIn: -1,
networkOut: -1,
},
factoryAgentDefaults: { cpu: 80, memory: 85, disk: 90 },
factoryDockerDefaults: DEFAULT_DOCKER_DEFAULTS,
factoryStorageDefault: 85,
@ -192,7 +267,26 @@ const baseProps = () => ({
criticalSizeGiB: 0,
} as SnapshotAlertConfig,
resetSnapshotDefaults: vi.fn(),
timeThresholds: () => ({ guest: 5, node: 5, storage: 5, pbs: 5, agent: 5 }),
timeThresholds: () => ({
guest: 5,
node: 5,
storage: 5,
pbs: 5,
agent: 5,
'k8s-cluster': 5,
'k8s-node': 5,
'k8s-deployment': 5,
'k8s-namespace': 5,
pod: 5,
'truenas-system': 5,
'truenas-pool': 5,
'truenas-dataset': 5,
'truenas-disk': 5,
'vmware-host': 5,
'vmware-vm': 5,
'vmware-datastore': 5,
'vmware-network': 5,
}),
metricTimeThresholds: () => ({}),
setMetricTimeThresholds: vi.fn(),
activeAlerts: {},
@ -215,6 +309,12 @@ const baseProps = () => ({
setDisableAllDockerServices: vi.fn(),
disableAllDockerContainers: () => false,
setDisableAllDockerContainers: vi.fn(),
disableAllKubernetes: () => false,
setDisableAllKubernetes: vi.fn(),
disableAllTrueNAS: () => false,
setDisableAllTrueNAS: vi.fn(),
disableAllVMware: () => false,
setDisableAllVMware: vi.fn(),
disableAllNodesOffline: () => false,
setDisableAllNodesOffline: vi.fn(),
disableAllGuestsOffline: () => false,
@ -259,18 +359,18 @@ describe('ThresholdsTable basics', () => {
});
describe('ThresholdsTable navigation and redirection', () => {
it('redirects from base path to infrastructure', () => {
it('redirects from base path to Proxmox', () => {
setPathname('/alerts/thresholds');
render(() => <ThresholdsTable {...(baseProps() as any)} />);
expect(mockNavigate).toHaveBeenCalledWith('/alerts/thresholds/infrastructure', {
expect(mockNavigate).toHaveBeenCalledWith('/alerts/thresholds/proxmox', {
replace: true,
});
});
it('redirects legacy thresholds sub-routes onto canonical infrastructure and systems paths', () => {
setPathname('/alerts/thresholds/proxmox');
it('redirects legacy thresholds sub-routes onto canonical platform paths', () => {
setPathname('/alerts/thresholds/infrastructure');
render(() => <ThresholdsTable {...(baseProps() as any)} />);
expect(mockNavigate).toHaveBeenCalledWith('/alerts/thresholds/infrastructure', {
expect(mockNavigate).toHaveBeenCalledWith('/alerts/thresholds/proxmox', {
replace: true,
});
@ -303,11 +403,11 @@ describe('ThresholdsTable navigation and redirection', () => {
it('navigates to correct route when tabs are clicked', () => {
render(() => <ThresholdsTable {...(baseProps() as any)} />);
const infrastructureTab = screen
const proxmoxTab = screen
.getAllByRole('button')
.find((el) => el.textContent?.includes('Infrastructure'));
if (infrastructureTab) fireEvent.click(infrastructureTab);
expect(mockNavigate).toHaveBeenCalledWith('/alerts/thresholds/infrastructure');
.find((el) => el.textContent?.includes('Proxmox'));
if (proxmoxTab) fireEvent.click(proxmoxTab);
expect(mockNavigate).toHaveBeenCalledWith('/alerts/thresholds/proxmox');
const systemsTab = screen
.getAllByRole('button')
@ -315,11 +415,15 @@ describe('ThresholdsTable navigation and redirection', () => {
if (systemsTab) fireEvent.click(systemsTab);
expect(mockNavigate).toHaveBeenCalledWith('/alerts/thresholds/systems');
const mailTab = screen
.getAllByRole('button')
.find((el) => el.textContent?.includes('Mail Gateway'));
const mailTab = screen.getAllByRole('button').find((el) => el.textContent?.includes('PMG'));
if (mailTab) fireEvent.click(mailTab);
expect(mockNavigate).toHaveBeenCalledWith('/alerts/thresholds/mail-gateway');
expect(mockNavigate).toHaveBeenCalledWith('/alerts/thresholds/pmg');
const vmwareTab = screen
.getAllByRole('button')
.find((el) => el.textContent?.includes('vSphere'));
if (vmwareTab) fireEvent.click(vmwareTab);
expect(mockNavigate).toHaveBeenCalledWith('/alerts/thresholds/vmware');
});
});
@ -400,9 +504,7 @@ describe('ThresholdsTable Resource Rendering', () => {
});
expect(screen.getByTestId('group-header-0')).toHaveTextContent('TrueNAS Main');
expect(
screen.getByTestId('resource-row-agent:truenas-main/disk:mnt-tank'),
).toBeInTheDocument();
expect(screen.getByTestId('resource-row-agent:truenas-main/disk:mnt-tank')).toBeInTheDocument();
});
it('renders infrastructure hosts and guests correctly', async () => {
@ -523,7 +625,9 @@ describe('ThresholdsTable Resource Rendering', () => {
});
expect(screen.getByTestId('resource-name-storage1')).toHaveTextContent('Secret Datastore');
expect(screen.getByTestId('resource-name-storage1')).not.toHaveTextContent('redacted by policy');
expect(screen.getByTestId('resource-name-storage1')).not.toHaveTextContent(
'redacted by policy',
);
});
it('renders policy-redacted docker containers with their raw display name in operator-local UI', async () => {
@ -567,7 +671,9 @@ describe('ThresholdsTable Resource Rendering', () => {
));
await waitFor(() => {
expect(screen.getByTestId('resource-name-docker:docker-host-1/container-governed')).toBeTruthy();
expect(
screen.getByTestId('resource-name-docker:docker-host-1/container-governed'),
).toBeTruthy();
});
expect(
@ -617,9 +723,9 @@ describe('ThresholdsTable Resource Rendering', () => {
});
expect(screen.getByTestId('resource-name-truenas-resource')).toHaveTextContent('TrueNAS');
expect(screen.getByTestId('resource-name-docker:truenas-resource/ix-nextcloud')).toHaveTextContent(
'Nextcloud',
);
expect(
screen.getByTestId('resource-name-docker:truenas-resource/ix-nextcloud'),
).toHaveTextContent('Nextcloud');
expect(screen.queryByText('Ignored container prefixes')).not.toBeInTheDocument();
expect(screen.queryByText('Swarm service alerts')).not.toBeInTheDocument();
});
@ -659,6 +765,106 @@ describe('ThresholdsTable Resource Rendering', () => {
screen.getByTestId('resource-node-agent:agent-governed/disk:var-lib'),
).not.toHaveTextContent('redacted by policy');
});
it('renders vSphere alert targets from canonical VMware resources', async () => {
setPathname('/alerts/thresholds/vmware');
const resources = [
{
id: 'vmware:vc-1:host:host-101',
type: 'agent',
name: 'esxi-01.lab.local',
displayName: 'ESXi 01',
platformId: 'vc-1',
platformType: 'vmware-vsphere',
sourceType: 'api',
sources: ['vmware'],
status: 'online',
lastSeen: 123,
vmware: {
connectionName: 'Lab vCenter',
clusterName: 'Prod Compute',
managedObjectId: 'host-101',
entityType: 'host',
},
},
{
id: 'vmware:vc-1:vm:vm-201',
type: 'vm',
name: 'app-01',
displayName: 'App 01',
platformId: 'vc-1',
platformType: 'vmware-vsphere',
sourceType: 'api',
sources: ['vmware'],
status: 'online',
lastSeen: 123,
parentName: 'esxi-01.lab.local',
vmware: {
connectionName: 'Lab vCenter',
runtimeHostName: 'esxi-01.lab.local',
managedObjectId: 'vm-201',
entityType: 'vm',
},
},
{
id: 'vmware:vc-1:datastore:datastore-301',
type: 'storage',
name: 'nvme-primary',
displayName: 'nvme-primary',
platformId: 'vc-1',
platformType: 'vmware-vsphere',
sourceType: 'api',
sources: ['vmware'],
status: 'online',
lastSeen: 123,
storage: { platform: 'vmware-vsphere', topology: 'datastore' },
vmware: {
connectionName: 'Lab vCenter',
datacenterName: 'Lab Datacenter',
managedObjectId: 'datastore-301',
entityType: 'datastore',
},
},
{
id: 'vmware:vc-1:network:network-401',
type: 'network',
name: 'VM Network',
displayName: 'VM Network',
platformId: 'vc-1',
platformType: 'vmware-vsphere',
sourceType: 'api',
sources: ['vmware'],
status: 'online',
lastSeen: 123,
vmware: {
connectionName: 'Lab vCenter',
datacenterName: 'Lab Datacenter',
managedObjectId: 'network-401',
entityType: 'network',
},
},
] as any[];
render(() => <ThresholdsTable {...(baseProps() as any)} allResources={resources} />);
await waitFor(() => {
expect(screen.getByTestId('section-Hosts')).toBeInTheDocument();
});
expect(screen.getByTestId('section-Virtual Machines')).toBeInTheDocument();
expect(screen.getByTestId('section-Datastores')).toBeInTheDocument();
expect(screen.getByTestId('section-Networks')).toBeInTheDocument();
expect(screen.getByTestId('resource-name-vmware:vc-1:host:host-101')).toHaveTextContent(
'ESXi 01',
);
expect(screen.getByTestId('resource-name-vmware:vc-1:vm:vm-201')).toHaveTextContent('App 01');
expect(
screen.getByTestId('resource-name-vmware:vc-1:datastore:datastore-301'),
).toHaveTextContent('nvme-primary');
expect(screen.getByTestId('resource-name-vmware:vc-1:network:network-401')).toHaveTextContent(
'VM Network',
);
});
});
describe('ThresholdsTable Metric Formatting', () => {
@ -863,7 +1069,7 @@ describe('ThresholdsTable V6 ID compatibility', () => {
});
it('removes PBS offline alerts using legacy compatibility IDs when disabled', async () => {
setPathname('/alerts/thresholds/infrastructure');
setPathname('/alerts/thresholds/pbs');
const removeAlerts = vi.fn();
const pbs = {
id: 'pbs-main',

View file

@ -1,4 +1,5 @@
import type { Resource as UnifiedResource, ResourcePolicy } from '@/types/resource';
import type { PlatformTableColumnKind } from '@/features/platformPage/columnAlignment';
import { getPreferredInfrastructureDisplayName } from '@/utils/resourceIdentity';
const COLUMN_TOOLTIP_LOOKUP: Record<string, string> = {
@ -218,6 +219,14 @@ export function getAlertResourceColumnHeaderTooltip(column: string): string | un
return COLUMN_TOOLTIP_LOOKUP[column] ?? COLUMN_TOOLTIP_LOOKUP[normalized];
}
export function getAlertResourceColumnKind(column: string): PlatformTableColumnKind {
const metric = normalizeAlertResourceMetricKey(column);
if (metric === 'backup' || metric === 'snapshot') {
return 'badge';
}
return 'numeric-value';
}
export function alertResourceSupportsMetric(
resourceType: string | undefined,
metric: string,
@ -235,6 +244,53 @@ export function alertResourceSupportsMetric(
if (resourceType === 'storage') {
return metric === 'usage';
}
if (resourceType === 'kubernetesNamespace') {
return false;
}
if (
resourceType === 'kubernetesCluster' ||
resourceType === 'kubernetesDeployment' ||
resourceType === 'kubernetesPod'
) {
return ['cpu', 'memory', 'disk', 'diskRead', 'diskWrite', 'networkIn', 'networkOut'].includes(
metric,
);
}
if (resourceType === 'kubernetesNode') {
return ['cpu', 'memory', 'disk'].includes(metric);
}
if (resourceType === 'truenasSystem') {
return [
'cpu',
'memory',
'disk',
'diskRead',
'diskWrite',
'networkIn',
'networkOut',
'temperature',
].includes(metric);
}
if (resourceType === 'truenasPool' || resourceType === 'truenasDataset') {
return metric === 'usage';
}
if (resourceType === 'truenasDisk') {
return metric === 'temperature';
}
if (resourceType === 'vmwareHost') {
return ['cpu', 'memory', 'diskRead', 'diskWrite', 'networkIn', 'networkOut'].includes(metric);
}
if (resourceType === 'vmwareVm') {
return ['cpu', 'memory', 'disk', 'diskRead', 'diskWrite', 'networkIn', 'networkOut'].includes(
metric,
);
}
if (resourceType === 'vmwareDatastore') {
return metric === 'usage';
}
if (resourceType === 'vmwareNetwork') {
return false;
}
if (resourceType === 'dockerContainer') {
return [
'cpu',
@ -267,7 +323,8 @@ export function getAlertResourceMetricDisplayValue(
editingThresholds?: AlertResourceThresholdMap,
isEditing = false,
): number {
const extract = (source: Record<string, unknown> | undefined) => parseAlertMetricNumber(source?.[metric]);
const extract = (source: Record<string, unknown> | undefined) =>
parseAlertMetricNumber(source?.[metric]);
const defaults = resource.defaults as Record<string, unknown> | undefined;
if (isEditing) {

View file

@ -3,6 +3,7 @@ import { Show } from 'solid-js';
import { IncidentTimelinePanel } from '@/components/Alerts/IncidentTimelinePanel';
import { InvestigateAlertButton } from '@/components/Alerts/InvestigateAlertButton';
import { TableCell, TableRow } from '@/components/shared/Table';
import { getPlatformTableCellClassForKind } from '@/features/platformPage/sharedPlatformPage';
import {
getAlertHistoryResourceTypeBadgeClass,
getAlertHistorySourcePresentation,
@ -32,61 +33,65 @@ export function AlertHistoryTableAlertRow(props: AlertHistoryTableAlertRowProps)
<TableRow
class={`border-b border-border hover:bg-surface-hover ${historyStatusPresentation().rowClassName}`}
>
<TableCell class="p-1 px-1 font-mono whitespace-nowrap text-muted sm:p-1.5 sm:px-2">
<TableCell
class={`${getPlatformTableCellClassForKind('text')} font-mono whitespace-nowrap text-muted`}
>
{new Date(props.alert.startTime).toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
})}
</TableCell>
<TableCell class="p-1 px-1 text-center sm:p-1.5 sm:px-2">
<TableCell class={getPlatformTableCellClassForKind('badge')}>
<span class={sourcePresentation().className}>{sourcePresentation().label}</span>
</TableCell>
<TableCell
class="max-w-[150px] truncate p-1 px-1 font-medium text-base-content sm:p-1.5 sm:px-2"
class={`${getPlatformTableCellClassForKind('name')} max-w-[150px] truncate font-medium text-base-content`}
title={props.alert.resourceName}
>
{props.alert.resourceName}
</TableCell>
<TableCell class="p-1 px-1 sm:p-1.5 sm:px-2">
<TableCell class={getPlatformTableCellClassForKind('badge')}>
<span class={getAlertHistoryResourceTypeBadgeClass(props.alert.resourceType)}>
{props.alert.resourceType}
</span>
</TableCell>
<TableCell class="p-1 px-1 text-center sm:p-1.5 sm:px-2">
<TableCell class={getPlatformTableCellClassForKind('badge')}>
<span class={getAlertIncidentLevelBadgeClass(props.alert.severity)}>
{props.alert.severity}
</span>
</TableCell>
<TableCell
class="max-w-[300px] truncate p-1 px-1 text-base-content sm:p-1.5 sm:px-2"
class={`${getPlatformTableCellClassForKind('text')} max-w-[300px] truncate text-base-content`}
title={props.alert.description}
>
{props.alert.description}
</TableCell>
<TableCell class="hidden p-1 px-1 text-center text-muted lg:table-cell sm:p-1.5 sm:px-2">
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} hidden text-muted lg:table-cell`}
>
{props.alert.duration}
</TableCell>
<TableCell class="hidden p-1 px-1 text-center lg:table-cell sm:p-1.5 sm:px-2">
<TableCell class={`${getPlatformTableCellClassForKind('badge')} hidden lg:table-cell`}>
<span class={historyStatusPresentation().className}>
{historyStatusPresentation().label}
</span>
</TableCell>
<TableCell
class="hidden truncate p-1 px-1 text-muted lg:table-cell sm:p-1.5 sm:px-2"
class={`${getPlatformTableCellClassForKind('text')} hidden truncate text-muted lg:table-cell`}
title={props.alert.nodeDisplayName || props.alert.node || ''}
>
{props.alert.nodeDisplayName || props.alert.node || '—'}
</TableCell>
<TableCell class="p-1 px-1 text-center sm:p-1.5 sm:px-2">
<TableCell class={getPlatformTableCellClassForKind('badge')}>
<div class="flex items-center justify-center gap-1">
<Show when={props.alert.source === 'alert'}>
<button

View file

@ -2,6 +2,7 @@ import { For, Show } from 'solid-js';
import { Table, TableBody, TableHead, TableHeader, TableRow } from '@/components/shared/Table';
import { TableCard } from '@/components/shared/TableCard';
import { getPlatformTableHeadClassForKind } from '@/features/platformPage/sharedPlatformPage';
import {
getAlertHistoryEmptyState,
getAlertHistoryLoadingState,
@ -34,34 +35,54 @@ export function AlertHistoryTableSection(props: AlertHistoryTableSectionProps) {
<Table class="w-full text-[11px] sm:text-sm">
<TableHeader>
<TableRow class="border-b border-border bg-surface-hover text-muted">
<TableHead class="p-1 px-1 text-left text-[10px] font-medium uppercase tracking-wider sm:p-1.5 sm:px-2 sm:text-xs">
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} text-[10px] font-medium uppercase tracking-wider sm:text-xs`}
>
Timestamp
</TableHead>
<TableHead class="p-1 px-1 text-center text-[10px] font-medium uppercase tracking-wider sm:p-1.5 sm:px-2 sm:text-xs">
<TableHead
class={`${getPlatformTableHeadClassForKind('badge')} text-[10px] font-medium uppercase tracking-wider sm:text-xs`}
>
Source
</TableHead>
<TableHead class="p-1 px-1 text-left text-[10px] font-medium uppercase tracking-wider sm:p-1.5 sm:px-2 sm:text-xs">
<TableHead
class={`${getPlatformTableHeadClassForKind('name')} text-[10px] font-medium uppercase tracking-wider sm:text-xs`}
>
Resource
</TableHead>
<TableHead class="p-1 px-1 text-left text-[10px] font-medium uppercase tracking-wider sm:p-1.5 sm:px-2 sm:text-xs">
<TableHead
class={`${getPlatformTableHeadClassForKind('badge')} text-[10px] font-medium uppercase tracking-wider sm:text-xs`}
>
{getTypeColumnLabel()}
</TableHead>
<TableHead class="p-1 px-1 text-center text-[10px] font-medium uppercase tracking-wider sm:p-1.5 sm:px-2 sm:text-xs">
<TableHead
class={`${getPlatformTableHeadClassForKind('badge')} text-[10px] font-medium uppercase tracking-wider sm:text-xs`}
>
Severity
</TableHead>
<TableHead class="p-1 px-1 text-left text-[10px] font-medium uppercase tracking-wider sm:p-1.5 sm:px-2 sm:text-xs">
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} text-[10px] font-medium uppercase tracking-wider sm:text-xs`}
>
Message
</TableHead>
<TableHead class="hidden p-1 px-1 text-center text-[10px] font-medium uppercase tracking-wider lg:table-cell sm:p-1.5 sm:px-2 sm:text-xs">
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} hidden text-[10px] font-medium uppercase tracking-wider lg:table-cell sm:text-xs`}
>
Duration
</TableHead>
<TableHead class="hidden p-1 px-1 text-center text-[10px] font-medium uppercase tracking-wider lg:table-cell sm:p-1.5 sm:px-2 sm:text-xs">
<TableHead
class={`${getPlatformTableHeadClassForKind('badge')} hidden text-[10px] font-medium uppercase tracking-wider lg:table-cell sm:text-xs`}
>
Status
</TableHead>
<TableHead class="hidden p-1 px-1 text-left text-[10px] font-medium uppercase tracking-wider lg:table-cell sm:p-1.5 sm:px-2 sm:text-xs">
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden text-[10px] font-medium uppercase tracking-wider lg:table-cell sm:text-xs`}
>
Node
</TableHead>
<TableHead class="p-1 px-1 text-center text-[10px] font-medium uppercase tracking-wider sm:p-1.5 sm:px-2 sm:text-xs">
<TableHead
class={`${getPlatformTableHeadClassForKind('badge')} text-[10px] font-medium uppercase tracking-wider sm:text-xs`}
>
Actions
</TableHead>
</TableRow>

View file

@ -107,6 +107,14 @@ export function AlertsConfigurationSurface(props: AlertsConfigurationSurfaceProp
setAgentDefaults={state.setAgentDefaults}
pbsDefaults={state.pbsDefaults}
setPBSDefaults={state.setPBSDefaults}
kubernetesDefaults={state.kubernetesDefaults}
setKubernetesDefaults={state.setKubernetesDefaults}
trueNASDefaults={state.trueNASDefaults}
setTrueNASDefaults={state.setTrueNASDefaults}
trueNASDiskDefaults={state.trueNASDiskDefaults}
setTrueNASDiskDefaults={state.setTrueNASDiskDefaults}
vmwareDefaults={state.vmwareDefaults}
setVMwareDefaults={state.setVMwareDefaults}
dockerDefaults={state.dockerDefaults}
dockerDisableConnectivity={state.dockerDisableConnectivity}
setDockerDisableConnectivity={state.setDockerDisableConnectivity}
@ -156,6 +164,12 @@ export function AlertsConfigurationSurface(props: AlertsConfigurationSurfaceProp
setDisableAllDockerServices={state.setDisableAllDockerServices}
disableAllDockerContainers={state.disableAllDockerContainers}
setDisableAllDockerContainers={state.setDisableAllDockerContainers}
disableAllKubernetes={state.disableAllKubernetes}
setDisableAllKubernetes={state.setDisableAllKubernetes}
disableAllTrueNAS={state.disableAllTrueNAS}
setDisableAllTrueNAS={state.setDisableAllTrueNAS}
disableAllVMware={state.disableAllVMware}
setDisableAllVMware={state.setDisableAllVMware}
disableAllNodesOffline={state.disableAllNodesOffline}
setDisableAllNodesOffline={state.setDisableAllNodesOffline}
disableAllGuestsOffline={state.disableAllGuestsOffline}
@ -169,6 +183,10 @@ export function AlertsConfigurationSurface(props: AlertsConfigurationSurfaceProp
disableAllDockerHostsOffline={state.disableAllDockerHostsOffline}
setDisableAllDockerHostsOffline={state.setDisableAllDockerHostsOffline}
resetPBSDefaults={state.resetPBSDefaults}
resetKubernetesDefaults={state.resetKubernetesDefaults}
resetTrueNASDefaults={state.resetTrueNASDefaults}
resetTrueNASDiskDefaults={state.resetTrueNASDiskDefaults}
resetVMwareDefaults={state.resetVMwareDefaults}
resetDockerDefaults={state.resetDockerDefaults}
resetDockerIgnoredPrefixes={state.resetDockerIgnoredPrefixes}
resetStorageDefault={state.resetStorageDefault}
@ -177,6 +195,10 @@ export function AlertsConfigurationSurface(props: AlertsConfigurationSurfaceProp
factoryGuestDefaults={state.factoryGuestDefaults}
factoryNodeDefaults={state.factoryNodeDefaults}
factoryPBSDefaults={state.factoryPBSDefaults}
factoryKubernetesDefaults={state.factoryKubernetesDefaults}
factoryTrueNASDefaults={state.factoryTrueNASDefaults}
factoryTrueNASDiskDefaults={state.factoryTrueNASDiskDefaults}
factoryVMwareDefaults={state.factoryVMwareDefaults}
factoryAgentDefaults={state.factoryAgentDefaults}
factoryDockerDefaults={state.factoryDockerDefaults}
factoryStorageDefault={state.factoryStorageDefault}

View file

@ -40,6 +40,41 @@ const buildProps = (): ThresholdsTabProps =>
setNodeDefaults: vi.fn(),
pbsDefaults: () => ({}),
setPBSDefaults: vi.fn(),
kubernetesDefaults: () => ({
cpu: 80,
memory: 85,
disk: 90,
diskRead: -1,
diskWrite: -1,
networkIn: -1,
networkOut: -1,
}),
setKubernetesDefaults: vi.fn(),
trueNASDefaults: () => ({
cpu: 80,
memory: 85,
disk: 85,
usage: 85,
temperature: 80,
diskRead: -1,
diskWrite: -1,
networkIn: -1,
networkOut: -1,
}),
setTrueNASDefaults: vi.fn(),
trueNASDiskDefaults: () => ({ temperature: 55 }),
setTrueNASDiskDefaults: vi.fn(),
vmwareDefaults: () => ({
cpu: 80,
memory: 85,
disk: 90,
usage: 85,
diskRead: -1,
diskWrite: -1,
networkIn: -1,
networkOut: -1,
}),
setVMwareDefaults: vi.fn(),
agentDefaults: () => ({ cpu: 80 }),
setAgentDefaults: vi.fn(),
dockerDefaults: () => ({
@ -71,6 +106,10 @@ const buildProps = (): ThresholdsTabProps =>
resetGuestDefaults: vi.fn(),
resetNodeDefaults: vi.fn(),
resetPBSDefaults: vi.fn(),
resetKubernetesDefaults: vi.fn(),
resetTrueNASDefaults: vi.fn(),
resetTrueNASDiskDefaults: vi.fn(),
resetVMwareDefaults: vi.fn(),
resetAgentDefaults: vi.fn(),
resetDockerDefaults: vi.fn(),
resetDockerIgnoredPrefixes: vi.fn(),
@ -78,6 +117,37 @@ const buildProps = (): ThresholdsTabProps =>
factoryGuestDefaults: {},
factoryNodeDefaults: {},
factoryPBSDefaults: {},
factoryKubernetesDefaults: {
cpu: 80,
memory: 85,
disk: 90,
diskRead: -1,
diskWrite: -1,
networkIn: -1,
networkOut: -1,
},
factoryTrueNASDefaults: {
cpu: 80,
memory: 85,
disk: 85,
usage: 85,
temperature: 80,
diskRead: -1,
diskWrite: -1,
networkIn: -1,
networkOut: -1,
},
factoryTrueNASDiskDefaults: { temperature: 55 },
factoryVMwareDefaults: {
cpu: 80,
memory: 85,
disk: 90,
usage: 85,
diskRead: -1,
diskWrite: -1,
networkIn: -1,
networkOut: -1,
},
factoryAgentDefaults: { cpu: 80 },
factoryDockerDefaults: {
cpu: 80,
@ -91,7 +161,26 @@ const buildProps = (): ThresholdsTabProps =>
serviceCriticalGapPercent: 50,
},
factoryStorageDefault: 85,
timeThresholds: () => ({ guest: 5, node: 5, storage: 5, pbs: 5, agent: 5 }),
timeThresholds: () => ({
guest: 5,
node: 5,
storage: 5,
pbs: 5,
agent: 5,
'k8s-cluster': 5,
'k8s-node': 5,
'k8s-deployment': 5,
'k8s-namespace': 5,
pod: 5,
'truenas-system': 5,
'truenas-pool': 5,
'truenas-dataset': 5,
'truenas-disk': 5,
'vmware-host': 5,
'vmware-vm': 5,
'vmware-datastore': 5,
'vmware-network': 5,
}),
metricTimeThresholds: () => ({}),
setMetricTimeThresholds: vi.fn(),
snapshotDefaults: () => ({
@ -151,6 +240,12 @@ const buildProps = (): ThresholdsTabProps =>
setDisableAllDockerServices: vi.fn(),
disableAllDockerContainers: () => false,
setDisableAllDockerContainers: vi.fn(),
disableAllKubernetes: () => false,
setDisableAllKubernetes: vi.fn(),
disableAllTrueNAS: () => false,
setDisableAllTrueNAS: vi.fn(),
disableAllVMware: () => false,
setDisableAllVMware: vi.fn(),
disableAllNodesOffline: () => false,
setDisableAllNodesOffline: vi.fn(),
disableAllGuestsOffline: () => false,

View file

@ -22,6 +22,8 @@ describe('alertsConfigurationModel', () => {
);
expect(snapshot.storageDefault).toBe(FACTORY_STORAGE_DEFAULT);
expect(snapshot.timeThresholds.guest).toBe(5);
expect(snapshot.timeThresholds['vmware-host']).toBe(5);
expect(snapshot.vmwareDefaults.usage).toBe(85);
expect(snapshot.scheduleCooldown.enabled).toBe(true);
expect(snapshot.backupDefaults.ignoreVMIDs).toEqual([]);
});
@ -49,6 +51,10 @@ describe('alertsConfigurationModel', () => {
criticalDays: 10,
ignoreVMIDs: [' 101 ', '101', ''],
},
vmwareDefaults: {
cpu: { trigger: 83, clear: 78 },
usage: { trigger: 91, clear: 86 },
},
snapshotDefaults: {
enabled: true,
warningDays: 25,
@ -74,6 +80,8 @@ describe('alertsConfigurationModel', () => {
expect(snapshot.dockerDefaults.serviceWarnGapPercent).toBe(40);
expect(snapshot.dockerDefaults.serviceCriticalGapPercent).toBe(40);
expect(snapshot.backupDefaults.warningDays).toBe(10);
expect(snapshot.vmwareDefaults.cpu).toBe(83);
expect(snapshot.vmwareDefaults.usage).toBe(91);
expect(snapshot.backupDefaults.criticalDays).toBe(10);
expect(snapshot.backupDefaults.ignoreVMIDs).toEqual(['101']);
expect(snapshot.snapshotDefaults.warningDays).toBe(5);
@ -133,6 +141,8 @@ describe('alertsConfigurationModel', () => {
expect(result.dockerValidationError).toBeUndefined();
expect(result.alertConfig).toBeDefined();
expect(result.alertConfig?.guestDefaults.cpu).toEqual({ trigger: 92, clear: 87 });
expect(result.alertConfig?.vmwareDefaults?.cpu).toEqual({ trigger: 80, clear: 75 });
expect(result.alertConfig?.vmwareDefaults?.usage).toEqual({ trigger: 85, clear: 80 });
expect(result.alertConfig?.dockerIgnoredContainerPrefixes).toEqual(['web']);
expect(result.alertConfig?.guestTagWhitelist).toEqual(['prod']);
expect(result.alertConfig?.metricTimeThresholds).toEqual({ guest: { cpu: 17 } });

View file

@ -213,6 +213,7 @@ interface BuildProjectedOverridesArgs {
containerRuntimeResources: Resource[];
getChildren: (resourceId: string) => Resource[];
pbsInstanceById: Map<string, PBSInstance>;
allResources?: Resource[];
}
export const buildProjectedOverrides = ({
@ -225,6 +226,7 @@ export const buildProjectedOverrides = ({
containerRuntimeResources,
getChildren,
pbsInstanceById,
allResources = [],
}: BuildProjectedOverridesArgs): Override[] => {
const overridesList: Override[] = [];
const overrideIndexByID = new Map<string, number>();
@ -236,6 +238,10 @@ export const buildProjectedOverrides = ({
const agentMap = new Map<string, Resource>();
const guestMap = new Map<string, Resource>();
const storageMap = new Map<string, Resource>();
const alertPlatformMap = new Map<
string,
{ resource: Resource; type: Override['type']; resourceType: string }
>();
const upsertProjectedOverride = (override: Override) => {
const existingIndex = overrideIndexByID.get(override.id);
@ -247,6 +253,96 @@ export const buildProjectedOverrides = ({
overridesList.push(override);
};
const alertResourceIdCandidates = (resource: Resource): string[] =>
uniqueIds(
resource.id,
resource.metricsTarget?.resourceId,
resource.discoveryTarget?.resourceId,
resource.platformId,
);
const isTrueNASResource = (resource: Resource): boolean =>
isTrueNASSystemResource(resource) ||
resource.platformType === 'truenas' ||
resource.sources?.includes('truenas') ||
resource.storage?.platform === 'truenas';
const isVMwareResource = (resource: Resource): boolean =>
resource.platformType === 'vmware-vsphere' ||
resource.sources?.includes('vmware') ||
Boolean(resource.vmware) ||
resource.storage?.platform === 'vmware-vsphere';
allResources.forEach((resource) => {
let type: Override['type'] | undefined;
let resourceType = '';
switch (resource.type) {
case 'k8s-cluster':
type = 'kubernetesCluster';
resourceType = 'Kubernetes Cluster';
break;
case 'k8s-node':
type = 'kubernetesNode';
resourceType = 'Kubernetes Node';
break;
case 'k8s-namespace':
type = 'kubernetesNamespace';
resourceType = 'Kubernetes Namespace';
break;
case 'k8s-deployment':
type = 'kubernetesDeployment';
resourceType = 'Kubernetes Deployment';
break;
case 'pod':
type = 'kubernetesPod';
resourceType = 'Kubernetes Pod';
break;
case 'agent':
if (isTrueNASResource(resource)) {
type = 'truenasSystem';
resourceType = 'TrueNAS System';
} else if (isVMwareResource(resource)) {
type = 'vmwareHost';
resourceType = 'vSphere Host';
}
break;
case 'vm':
if (isVMwareResource(resource)) {
type = 'vmwareVm';
resourceType = 'vSphere VM';
}
break;
case 'physical_disk':
if (isTrueNASResource(resource)) {
type = 'truenasDisk';
resourceType = 'TrueNAS Disk';
}
break;
case 'storage':
case 'pool':
case 'dataset':
if (isTrueNASResource(resource)) {
const topology = resource.storage?.topology || resource.type;
type = topology === 'dataset' ? 'truenasDataset' : 'truenasPool';
resourceType = topology === 'dataset' ? 'TrueNAS Dataset' : 'TrueNAS Pool';
} else if (isVMwareResource(resource)) {
type = 'vmwareDatastore';
resourceType = 'vSphere Datastore';
}
break;
case 'network':
if (isVMwareResource(resource)) {
type = 'vmwareNetwork';
resourceType = 'vSphere Network';
}
break;
}
if (!type) return;
alertResourceIdCandidates(resource).forEach((id) => {
alertPlatformMap.set(id, { resource, type, resourceType });
});
});
const storageCoords = (resource: Resource): { node: string; instance: string } => {
const data = platformData(resource);
if (resource.type === 'datastore') {
@ -310,6 +406,35 @@ export const buildProjectedOverrides = ({
});
Object.entries(rawConfig).forEach(([key, thresholds]) => {
const alertPlatformResource = alertPlatformMap.get(key);
if (alertPlatformResource) {
const { resource, type, resourceType } = alertPlatformResource;
upsertProjectedOverride({
id: key,
name: getAlertResourceDisplayLabel(resource),
type,
resourceType,
node:
resource.parentName ||
resource.kubernetes?.nodeName ||
resource.truenas?.hostname ||
resource.vmware?.runtimeHostName ||
resource.vmware?.clusterName ||
resource.vmware?.datacenterName ||
resource.vmware?.vcenterHost,
instance:
resource.kubernetes?.clusterName ||
resource.kubernetes?.namespace ||
(resource.truenas ? 'TrueNAS' : undefined) ||
resource.vmware?.connectionName ||
resource.vmware?.vcenterHost ||
(resource.vmware ? 'vSphere' : undefined),
disabled: thresholds.disabled || false,
thresholds: extractTriggerValues(thresholds),
});
return;
}
const dockerHost = dockerHostMap.get(key);
if (dockerHost) {
upsertProjectedOverride({

View file

@ -11,10 +11,14 @@ import {
FACTORY_DOCKER_STATE_DISABLE_CONNECTIVITY,
FACTORY_DOCKER_STATE_SEVERITY,
FACTORY_GUEST_DEFAULTS,
FACTORY_KUBERNETES_DEFAULTS,
FACTORY_NODE_DEFAULTS,
FACTORY_PBS_DEFAULTS,
FACTORY_SNAPSHOT_DEFAULTS,
FACTORY_STORAGE_DEFAULT,
FACTORY_TRUENAS_DEFAULTS,
FACTORY_TRUENAS_DISK_DEFAULTS,
FACTORY_VMWARE_DEFAULTS,
} from '@/utils/alertThresholdDefaults';
import {
@ -45,10 +49,14 @@ export {
FACTORY_DOCKER_STATE_DISABLE_CONNECTIVITY,
FACTORY_DOCKER_STATE_SEVERITY,
FACTORY_GUEST_DEFAULTS,
FACTORY_KUBERNETES_DEFAULTS,
FACTORY_NODE_DEFAULTS,
FACTORY_PBS_DEFAULTS,
FACTORY_SNAPSHOT_DEFAULTS,
FACTORY_STORAGE_DEFAULT,
FACTORY_TRUENAS_DEFAULTS,
FACTORY_TRUENAS_DISK_DEFAULTS,
FACTORY_VMWARE_DEFAULTS,
} from '@/utils/alertThresholdDefaults';
export interface AlertsConfigurationSnapshot {
@ -62,6 +70,10 @@ export interface AlertsConfigurationSnapshot {
guestPoweredOffSeverity: 'warning' | 'critical';
nodeDefaults: Record<string, number | undefined>;
pbsDefaults: Record<string, number | undefined>;
kubernetesDefaults: Record<string, number | undefined>;
trueNASDefaults: Record<string, number | undefined>;
trueNASDiskDefaults: Record<string, number | undefined>;
vmwareDefaults: Record<string, number | undefined>;
agentDefaults: Record<string, number | undefined>;
dockerDefaults: typeof FACTORY_DOCKER_DEFAULTS;
dockerDisableConnectivity: boolean;
@ -78,6 +90,19 @@ export interface AlertsConfigurationSnapshot {
storage: number;
pbs: number;
agent: number;
'k8s-cluster': number;
'k8s-node': number;
'k8s-deployment': number;
'k8s-namespace': number;
pod: number;
'truenas-system': number;
'truenas-pool': number;
'truenas-dataset': number;
'truenas-disk': number;
'vmware-host': number;
'vmware-vm': number;
'vmware-datastore': number;
'vmware-network': number;
};
metricTimeThresholds: Record<string, Record<string, number>>;
snapshotDefaults: SnapshotAlertConfig;
@ -108,6 +133,9 @@ export interface AlertsConfigurationSnapshot {
disableAllDockerHosts: boolean;
disableAllDockerServices: boolean;
disableAllDockerContainers: boolean;
disableAllKubernetes: boolean;
disableAllTrueNAS: boolean;
disableAllVMware: boolean;
disableAllNodesOffline: boolean;
disableAllGuestsOffline: boolean;
disableAllAgentsOffline: boolean;
@ -183,6 +211,10 @@ export function createDefaultAlertsConfigurationSnapshot(): AlertsConfigurationS
guestPoweredOffSeverity: 'warning',
nodeDefaults: { ...FACTORY_NODE_DEFAULTS },
pbsDefaults: { ...FACTORY_PBS_DEFAULTS },
kubernetesDefaults: { ...FACTORY_KUBERNETES_DEFAULTS },
trueNASDefaults: { ...FACTORY_TRUENAS_DEFAULTS },
trueNASDiskDefaults: { ...FACTORY_TRUENAS_DISK_DEFAULTS },
vmwareDefaults: { ...FACTORY_VMWARE_DEFAULTS },
agentDefaults: { ...FACTORY_AGENT_DEFAULTS },
dockerDefaults: { ...FACTORY_DOCKER_DEFAULTS },
dockerDisableConnectivity: FACTORY_DOCKER_STATE_DISABLE_CONNECTIVITY,
@ -199,6 +231,19 @@ export function createDefaultAlertsConfigurationSnapshot(): AlertsConfigurationS
storage: DEFAULT_DELAY_SECONDS,
pbs: DEFAULT_DELAY_SECONDS,
agent: DEFAULT_DELAY_SECONDS,
'k8s-cluster': DEFAULT_DELAY_SECONDS,
'k8s-node': DEFAULT_DELAY_SECONDS,
'k8s-deployment': DEFAULT_DELAY_SECONDS,
'k8s-namespace': DEFAULT_DELAY_SECONDS,
pod: DEFAULT_DELAY_SECONDS,
'truenas-system': DEFAULT_DELAY_SECONDS,
'truenas-pool': DEFAULT_DELAY_SECONDS,
'truenas-dataset': DEFAULT_DELAY_SECONDS,
'truenas-disk': DEFAULT_DELAY_SECONDS,
'vmware-host': DEFAULT_DELAY_SECONDS,
'vmware-vm': DEFAULT_DELAY_SECONDS,
'vmware-datastore': DEFAULT_DELAY_SECONDS,
'vmware-network': DEFAULT_DELAY_SECONDS,
},
metricTimeThresholds: {},
snapshotDefaults: { ...FACTORY_SNAPSHOT_DEFAULTS },
@ -229,6 +274,9 @@ export function createDefaultAlertsConfigurationSnapshot(): AlertsConfigurationS
disableAllDockerHosts: false,
disableAllDockerServices: false,
disableAllDockerContainers: false,
disableAllKubernetes: false,
disableAllTrueNAS: false,
disableAllVMware: false,
disableAllNodesOffline: false,
disableAllGuestsOffline: false,
disableAllAgentsOffline: false,
@ -276,6 +324,69 @@ export function readAlertsConfigurationSnapshot(config: AlertConfig): AlertsConf
};
}
if (config.kubernetesDefaults) {
snapshot.kubernetesDefaults = {
cpu: getTriggerValue(config.kubernetesDefaults.cpu) ?? FACTORY_KUBERNETES_DEFAULTS.cpu,
memory:
getTriggerValue(config.kubernetesDefaults.memory) ?? FACTORY_KUBERNETES_DEFAULTS.memory,
disk: getTriggerValue(config.kubernetesDefaults.disk) ?? FACTORY_KUBERNETES_DEFAULTS.disk,
diskRead:
getTriggerValue(config.kubernetesDefaults.diskRead) ?? FACTORY_KUBERNETES_DEFAULTS.diskRead,
diskWrite:
getTriggerValue(config.kubernetesDefaults.diskWrite) ??
FACTORY_KUBERNETES_DEFAULTS.diskWrite,
networkIn:
getTriggerValue(config.kubernetesDefaults.networkIn) ??
FACTORY_KUBERNETES_DEFAULTS.networkIn,
networkOut:
getTriggerValue(config.kubernetesDefaults.networkOut) ??
FACTORY_KUBERNETES_DEFAULTS.networkOut,
};
}
if (config.truenasDefaults) {
snapshot.trueNASDefaults = {
cpu: getTriggerValue(config.truenasDefaults.cpu) ?? FACTORY_TRUENAS_DEFAULTS.cpu,
memory: getTriggerValue(config.truenasDefaults.memory) ?? FACTORY_TRUENAS_DEFAULTS.memory,
disk: getTriggerValue(config.truenasDefaults.disk) ?? FACTORY_TRUENAS_DEFAULTS.disk,
usage: getTriggerValue(config.truenasDefaults.usage) ?? FACTORY_TRUENAS_DEFAULTS.usage,
temperature:
getTriggerValue(config.truenasDefaults.temperature) ?? FACTORY_TRUENAS_DEFAULTS.temperature,
diskRead:
getTriggerValue(config.truenasDefaults.diskRead) ?? FACTORY_TRUENAS_DEFAULTS.diskRead,
diskWrite:
getTriggerValue(config.truenasDefaults.diskWrite) ?? FACTORY_TRUENAS_DEFAULTS.diskWrite,
networkIn:
getTriggerValue(config.truenasDefaults.networkIn) ?? FACTORY_TRUENAS_DEFAULTS.networkIn,
networkOut:
getTriggerValue(config.truenasDefaults.networkOut) ?? FACTORY_TRUENAS_DEFAULTS.networkOut,
};
}
if (config.truenasDiskDefaults) {
snapshot.trueNASDiskDefaults = {
temperature:
getTriggerValue(config.truenasDiskDefaults.temperature) ??
FACTORY_TRUENAS_DISK_DEFAULTS.temperature,
};
}
if (config.vmwareDefaults) {
snapshot.vmwareDefaults = {
cpu: getTriggerValue(config.vmwareDefaults.cpu) ?? FACTORY_VMWARE_DEFAULTS.cpu,
memory: getTriggerValue(config.vmwareDefaults.memory) ?? FACTORY_VMWARE_DEFAULTS.memory,
disk: getTriggerValue(config.vmwareDefaults.disk) ?? FACTORY_VMWARE_DEFAULTS.disk,
usage: getTriggerValue(config.vmwareDefaults.usage) ?? FACTORY_VMWARE_DEFAULTS.usage,
diskRead: getTriggerValue(config.vmwareDefaults.diskRead) ?? FACTORY_VMWARE_DEFAULTS.diskRead,
diskWrite:
getTriggerValue(config.vmwareDefaults.diskWrite) ?? FACTORY_VMWARE_DEFAULTS.diskWrite,
networkIn:
getTriggerValue(config.vmwareDefaults.networkIn) ?? FACTORY_VMWARE_DEFAULTS.networkIn,
networkOut:
getTriggerValue(config.vmwareDefaults.networkOut) ?? FACTORY_VMWARE_DEFAULTS.networkOut,
};
}
if (config.agentDefaults) {
snapshot.agentDefaults = {
cpu: getTriggerValue(config.agentDefaults.cpu) ?? FACTORY_AGENT_DEFAULTS.cpu,
@ -332,6 +443,19 @@ export function readAlertsConfigurationSnapshot(config: AlertConfig): AlertsConf
storage: config.timeThresholds.storage ?? DEFAULT_DELAY_SECONDS,
pbs: config.timeThresholds.pbs ?? DEFAULT_DELAY_SECONDS,
agent: config.timeThresholds.agent ?? DEFAULT_DELAY_SECONDS,
'k8s-cluster': config.timeThresholds['k8s-cluster'] ?? DEFAULT_DELAY_SECONDS,
'k8s-node': config.timeThresholds['k8s-node'] ?? DEFAULT_DELAY_SECONDS,
'k8s-deployment': config.timeThresholds['k8s-deployment'] ?? DEFAULT_DELAY_SECONDS,
'k8s-namespace': config.timeThresholds['k8s-namespace'] ?? DEFAULT_DELAY_SECONDS,
pod: config.timeThresholds.pod ?? DEFAULT_DELAY_SECONDS,
'truenas-system': config.timeThresholds['truenas-system'] ?? DEFAULT_DELAY_SECONDS,
'truenas-pool': config.timeThresholds['truenas-pool'] ?? DEFAULT_DELAY_SECONDS,
'truenas-dataset': config.timeThresholds['truenas-dataset'] ?? DEFAULT_DELAY_SECONDS,
'truenas-disk': config.timeThresholds['truenas-disk'] ?? DEFAULT_DELAY_SECONDS,
'vmware-host': config.timeThresholds['vmware-host'] ?? DEFAULT_DELAY_SECONDS,
'vmware-vm': config.timeThresholds['vmware-vm'] ?? DEFAULT_DELAY_SECONDS,
'vmware-datastore': config.timeThresholds['vmware-datastore'] ?? DEFAULT_DELAY_SECONDS,
'vmware-network': config.timeThresholds['vmware-network'] ?? DEFAULT_DELAY_SECONDS,
};
}
if (config.metricTimeThresholds) {
@ -403,6 +527,9 @@ export function readAlertsConfigurationSnapshot(config: AlertConfig): AlertsConf
snapshot.disableAllDockerHosts = config.disableAllDockerHosts ?? false;
snapshot.disableAllDockerServices = config.disableAllDockerServices ?? false;
snapshot.disableAllDockerContainers = config.disableAllDockerContainers ?? false;
snapshot.disableAllKubernetes = config.disableAllKubernetes ?? false;
snapshot.disableAllTrueNAS = config.disableAllTrueNAS ?? false;
snapshot.disableAllVMware = config.disableAllVMware ?? false;
snapshot.disableAllNodesOffline = config.disableAllNodesOffline ?? false;
snapshot.disableAllGuestsOffline = config.disableAllGuestsOffline ?? false;
snapshot.disableAllAgentsOffline = config.disableAllAgentsOffline ?? false;
@ -536,6 +663,9 @@ export function buildAlertsConfigurationPayload({
disableAllDockerHosts: snapshot.disableAllDockerHosts,
disableAllDockerContainers: snapshot.disableAllDockerContainers,
disableAllDockerServices: snapshot.disableAllDockerServices,
disableAllKubernetes: snapshot.disableAllKubernetes,
disableAllTrueNAS: snapshot.disableAllTrueNAS,
disableAllVMware: snapshot.disableAllVMware,
disableAllNodesOffline: snapshot.disableAllNodesOffline,
disableAllGuestsOffline: snapshot.disableAllGuestsOffline,
disableAllPBSOffline: snapshot.disableAllPBSOffline,
@ -569,6 +699,39 @@ export function buildAlertsConfigurationPayload({
cpu: createHysteresisThreshold(snapshot.pbsDefaults.cpu),
memory: createHysteresisThreshold(snapshot.pbsDefaults.memory),
},
kubernetesDefaults: {
cpu: createHysteresisThreshold(snapshot.kubernetesDefaults.cpu),
memory: createHysteresisThreshold(snapshot.kubernetesDefaults.memory),
disk: createHysteresisThreshold(snapshot.kubernetesDefaults.disk),
diskRead: createHysteresisThreshold(snapshot.kubernetesDefaults.diskRead),
diskWrite: createHysteresisThreshold(snapshot.kubernetesDefaults.diskWrite),
networkIn: createHysteresisThreshold(snapshot.kubernetesDefaults.networkIn),
networkOut: createHysteresisThreshold(snapshot.kubernetesDefaults.networkOut),
},
truenasDefaults: {
cpu: createHysteresisThreshold(snapshot.trueNASDefaults.cpu),
memory: createHysteresisThreshold(snapshot.trueNASDefaults.memory),
disk: createHysteresisThreshold(snapshot.trueNASDefaults.disk),
usage: createHysteresisThreshold(snapshot.trueNASDefaults.usage),
temperature: createHysteresisThreshold(snapshot.trueNASDefaults.temperature),
diskRead: createHysteresisThreshold(snapshot.trueNASDefaults.diskRead),
diskWrite: createHysteresisThreshold(snapshot.trueNASDefaults.diskWrite),
networkIn: createHysteresisThreshold(snapshot.trueNASDefaults.networkIn),
networkOut: createHysteresisThreshold(snapshot.trueNASDefaults.networkOut),
},
truenasDiskDefaults: {
temperature: createHysteresisThreshold(snapshot.trueNASDiskDefaults.temperature),
},
vmwareDefaults: {
cpu: createHysteresisThreshold(snapshot.vmwareDefaults.cpu),
memory: createHysteresisThreshold(snapshot.vmwareDefaults.memory),
disk: createHysteresisThreshold(snapshot.vmwareDefaults.disk),
usage: createHysteresisThreshold(snapshot.vmwareDefaults.usage),
diskRead: createHysteresisThreshold(snapshot.vmwareDefaults.diskRead),
diskWrite: createHysteresisThreshold(snapshot.vmwareDefaults.diskWrite),
networkIn: createHysteresisThreshold(snapshot.vmwareDefaults.networkIn),
networkOut: createHysteresisThreshold(snapshot.vmwareDefaults.networkOut),
},
dockerDefaults: {
cpu: createHysteresisThreshold(snapshot.dockerDefaults.cpu),
memory: createHysteresisThreshold(snapshot.dockerDefaults.memory),

View file

@ -28,9 +28,17 @@ export function ThresholdsTab(props: ThresholdsTabProps) {
setGuestPoweredOffSeverity={props.setGuestPoweredOffSeverity}
nodeDefaults={props.nodeDefaults()}
pbsDefaults={props.pbsDefaults()}
kubernetesDefaults={props.kubernetesDefaults()}
trueNASDefaults={props.trueNASDefaults()}
trueNASDiskDefaults={props.trueNASDiskDefaults()}
vmwareDefaults={props.vmwareDefaults()}
agentDefaults={props.agentDefaults()}
setNodeDefaults={props.setNodeDefaults}
setPBSDefaults={props.setPBSDefaults}
setKubernetesDefaults={props.setKubernetesDefaults}
setTrueNASDefaults={props.setTrueNASDefaults}
setTrueNASDiskDefaults={props.setTrueNASDiskDefaults}
setVMwareDefaults={props.setVMwareDefaults}
setAgentDefaults={props.setAgentDefaults}
dockerDefaults={props.dockerDefaults()}
dockerDisableConnectivity={props.dockerDisableConnectivity}
@ -51,6 +59,10 @@ export function ThresholdsTab(props: ThresholdsTabProps) {
resetGuestDefaults={props.resetGuestDefaults}
resetNodeDefaults={props.resetNodeDefaults}
resetPBSDefaults={props.resetPBSDefaults}
resetKubernetesDefaults={props.resetKubernetesDefaults}
resetTrueNASDefaults={props.resetTrueNASDefaults}
resetTrueNASDiskDefaults={props.resetTrueNASDiskDefaults}
resetVMwareDefaults={props.resetVMwareDefaults}
resetAgentDefaults={props.resetAgentDefaults}
resetDockerDefaults={props.resetDockerDefaults}
resetDockerIgnoredPrefixes={props.resetDockerIgnoredPrefixes}
@ -58,6 +70,10 @@ export function ThresholdsTab(props: ThresholdsTabProps) {
factoryGuestDefaults={props.factoryGuestDefaults}
factoryNodeDefaults={props.factoryNodeDefaults}
factoryPBSDefaults={props.factoryPBSDefaults}
factoryKubernetesDefaults={props.factoryKubernetesDefaults}
factoryTrueNASDefaults={props.factoryTrueNASDefaults}
factoryTrueNASDiskDefaults={props.factoryTrueNASDiskDefaults}
factoryVMwareDefaults={props.factoryVMwareDefaults}
factoryAgentDefaults={props.factoryAgentDefaults}
factoryDockerDefaults={props.factoryDockerDefaults}
factoryStorageDefault={props.factoryStorageDefault}
@ -93,6 +109,12 @@ export function ThresholdsTab(props: ThresholdsTabProps) {
setDisableAllDockerServices={props.setDisableAllDockerServices}
disableAllDockerContainers={props.disableAllDockerContainers}
setDisableAllDockerContainers={props.setDisableAllDockerContainers}
disableAllKubernetes={props.disableAllKubernetes}
setDisableAllKubernetes={props.setDisableAllKubernetes}
disableAllTrueNAS={props.disableAllTrueNAS}
setDisableAllTrueNAS={props.setDisableAllTrueNAS}
disableAllVMware={props.disableAllVMware}
setDisableAllVMware={props.setDisableAllVMware}
disableAllNodesOffline={props.disableAllNodesOffline}
setDisableAllNodesOffline={props.setDisableAllNodesOffline}
disableAllGuestsOffline={props.disableAllGuestsOffline}

View file

@ -192,6 +192,9 @@ describe('useThresholdsTableState', () => {
expect(localStorage.getItem('pulse-thresholds-help-dismissed')).toBe('true');
captured!.handleTabClick('docker');
expect(navigateSpy).toHaveBeenCalledWith('/alerts/thresholds/containers');
expect(navigateSpy).toHaveBeenCalledWith('/alerts/thresholds/docker');
captured!.handleTabClick('vmware');
expect(navigateSpy).toHaveBeenCalledWith('/alerts/thresholds/vmware');
});
});

View file

@ -28,6 +28,19 @@ interface ThresholdsAvailabilityMutationResources {
dockerContainersFlat: () => TableResource[];
pbsServersWithOverrides: () => TableResource[];
storageWithOverrides: () => TableResource[];
kubernetesClustersWithOverrides?: () => TableResource[];
kubernetesNodesWithOverrides?: () => TableResource[];
kubernetesNamespacesWithOverrides?: () => TableResource[];
kubernetesDeploymentsWithOverrides?: () => TableResource[];
kubernetesPodsWithOverrides?: () => TableResource[];
trueNASSystemsWithOverrides?: () => TableResource[];
trueNASPoolsWithOverrides?: () => TableResource[];
trueNASDatasetsWithOverrides?: () => TableResource[];
trueNASDisksWithOverrides?: () => TableResource[];
vmwareHostsWithOverrides?: () => TableResource[];
vmwareVMsWithOverrides?: () => TableResource[];
vmwareDatastoresWithOverrides?: () => TableResource[];
vmwareNetworksWithOverrides?: () => TableResource[];
}
interface ThresholdsAvailabilityMutationProps {
@ -42,6 +55,8 @@ export function useThresholdsAvailabilityMutations({
removeOverride,
}: ThresholdsAvailabilityMutationProps) {
const guestLikeResources = () => [...resources.guestsFlat(), ...resources.dockerContainersFlat()];
const optionalResources = (accessor?: () => TableResource[]): TableResource[] =>
accessor?.() ?? [];
const clearDockerHostConnectivityAlerts = (resourceId: string) => {
if (!props.removeAlerts) return;
@ -60,6 +75,19 @@ export function useThresholdsAvailabilityMutations({
...resources.pbsServersWithOverrides(),
...resources.agentsWithOverrides(),
...resources.agentDisksWithOverrides(),
...optionalResources(resources.kubernetesClustersWithOverrides),
...optionalResources(resources.kubernetesNodesWithOverrides),
...optionalResources(resources.kubernetesNamespacesWithOverrides),
...optionalResources(resources.kubernetesDeploymentsWithOverrides),
...optionalResources(resources.kubernetesPodsWithOverrides),
...optionalResources(resources.trueNASSystemsWithOverrides),
...optionalResources(resources.trueNASPoolsWithOverrides),
...optionalResources(resources.trueNASDatasetsWithOverrides),
...optionalResources(resources.trueNASDisksWithOverrides),
...optionalResources(resources.vmwareHostsWithOverrides),
...optionalResources(resources.vmwareVMsWithOverrides),
...optionalResources(resources.vmwareDatastoresWithOverrides),
...optionalResources(resources.vmwareNetworksWithOverrides),
].find((entry) => entry.id === resourceId);
if (
@ -69,7 +97,20 @@ export function useThresholdsAvailabilityMutations({
resource.type !== 'pbs' &&
resource.type !== 'dockerContainer' &&
resource.type !== 'agent' &&
resource.type !== 'agentDisk')
resource.type !== 'agentDisk' &&
resource.type !== 'kubernetesCluster' &&
resource.type !== 'kubernetesNode' &&
resource.type !== 'kubernetesNamespace' &&
resource.type !== 'kubernetesDeployment' &&
resource.type !== 'kubernetesPod' &&
resource.type !== 'truenasSystem' &&
resource.type !== 'truenasPool' &&
resource.type !== 'truenasDataset' &&
resource.type !== 'truenasDisk' &&
resource.type !== 'vmwareHost' &&
resource.type !== 'vmwareVm' &&
resource.type !== 'vmwareDatastore' &&
resource.type !== 'vmwareNetwork')
) {
return;
}

View file

@ -3,6 +3,7 @@ import { useThresholdsDockerData } from './useThresholdsDockerData';
import { useThresholdsGuestData } from './useThresholdsGuestData';
import { useThresholdsHostData } from './useThresholdsHostData';
import { useThresholdsInfrastructureData } from './useThresholdsInfrastructureData';
import { useThresholdsPlatformData } from './useThresholdsPlatformData';
export function useThresholdsData(
props: ThresholdsTableProps,
@ -14,11 +15,13 @@ export function useThresholdsData(
const dockerData = useThresholdsDockerData(inputs);
const guestData = useThresholdsGuestData(inputs);
const infrastructureData = useThresholdsInfrastructureData(inputs);
const platformData = useThresholdsPlatformData(inputs);
return {
...hostData,
...dockerData,
...guestData,
...infrastructureData,
...platformData,
};
}

View file

@ -37,6 +37,19 @@ interface ThresholdsOverrideMutationResources {
pbsServersWithOverrides: Accessor<TableResource[]>;
pmgServersWithOverrides: Accessor<TableResource[]>;
storageWithOverrides: Accessor<TableResource[]>;
kubernetesClustersWithOverrides?: Accessor<TableResource[]>;
kubernetesNodesWithOverrides?: Accessor<TableResource[]>;
kubernetesNamespacesWithOverrides?: Accessor<TableResource[]>;
kubernetesDeploymentsWithOverrides?: Accessor<TableResource[]>;
kubernetesPodsWithOverrides?: Accessor<TableResource[]>;
trueNASSystemsWithOverrides?: Accessor<TableResource[]>;
trueNASPoolsWithOverrides?: Accessor<TableResource[]>;
trueNASDatasetsWithOverrides?: Accessor<TableResource[]>;
trueNASDisksWithOverrides?: Accessor<TableResource[]>;
vmwareHostsWithOverrides?: Accessor<TableResource[]>;
vmwareVMsWithOverrides?: Accessor<TableResource[]>;
vmwareDatastoresWithOverrides?: Accessor<TableResource[]>;
vmwareNetworksWithOverrides?: Accessor<TableResource[]>;
}
interface ThresholdsOverrideMutationProps {
@ -64,6 +77,9 @@ export function useThresholdsOverrideMutations({
updateBackupDefaults,
updateSnapshotDefaults,
}: ThresholdsOverrideMutationProps) {
const optionalResources = (accessor?: Accessor<TableResource[]>): TableResource[] =>
accessor?.() ?? [];
const proxmoxResources = () => [
...resources.nodesWithOverrides(),
...resources.agentsWithOverrides(),
@ -72,6 +88,19 @@ export function useThresholdsOverrideMutations({
...resources.pbsServersWithOverrides(),
...resources.pmgServersWithOverrides(),
...resources.storageWithOverrides(),
...optionalResources(resources.kubernetesClustersWithOverrides),
...optionalResources(resources.kubernetesNodesWithOverrides),
...optionalResources(resources.kubernetesNamespacesWithOverrides),
...optionalResources(resources.kubernetesDeploymentsWithOverrides),
...optionalResources(resources.kubernetesPodsWithOverrides),
...optionalResources(resources.trueNASSystemsWithOverrides),
...optionalResources(resources.trueNASPoolsWithOverrides),
...optionalResources(resources.trueNASDatasetsWithOverrides),
...optionalResources(resources.trueNASDisksWithOverrides),
...optionalResources(resources.vmwareHostsWithOverrides),
...optionalResources(resources.vmwareVMsWithOverrides),
...optionalResources(resources.vmwareDatastoresWithOverrides),
...optionalResources(resources.vmwareNetworksWithOverrides),
];
const guestLikeResources = () => [...resources.guestsFlat(), ...resources.dockerContainersFlat()];

View file

@ -0,0 +1,409 @@
import { createMemo } from 'solid-js';
import { getAlertResourceDisplayLabel } from '@/features/alerts/helpers';
import { buildKubernetesPageModel } from '@/features/kubernetes/kubernetesPageModel';
import { buildTrueNASPageModel } from '@/features/truenas/truenasPageModel';
import { buildVmwarePageModel } from '@/features/vmware/vmwarePageModel';
import type { Resource } from '@/types/resource';
import type { Resource as TableResource } from '../tableTypes';
import type { Override } from '../types';
import type { ThresholdsDataInputs } from '../thresholdsResourceModel';
import {
createOverridesMap,
findOverrideByCandidates,
hasThresholdDiff,
readString,
uniqueIds,
} from '../thresholdsResourceModel';
type AlertPlatformResourceType =
| 'kubernetesCluster'
| 'kubernetesNode'
| 'kubernetesNamespace'
| 'kubernetesDeployment'
| 'kubernetesPod'
| 'truenasSystem'
| 'truenasPool'
| 'truenasDataset'
| 'truenasDisk'
| 'vmwareHost'
| 'vmwareVm'
| 'vmwareDatastore'
| 'vmwareNetwork';
const resourceAlertIdCandidates = (resource: Resource): string[] =>
uniqueIds(
resource.id,
resource.metricsTarget?.resourceId,
resource.discoveryTarget?.resourceId,
resource.platformId,
);
const resourceAlertActionId = (resource: Resource): string =>
resourceAlertIdCandidates(resource)[0] || resource.id;
const searchResource = (resource: TableResource, search: string): boolean => {
if (!search) return true;
return [
resource.name,
resource.displayName,
resource.rawName,
resource.node,
resource.instance,
resource.resourceType,
].some((value) => typeof value === 'string' && value.toLowerCase().includes(search));
};
const metricPercent = (
resource: Resource,
metric: 'cpu' | 'memory' | 'disk',
): number | undefined => {
const value = resource[metric]?.current;
return typeof value === 'number' ? value : undefined;
};
const kubernetesClusterName = (resource: Resource): string =>
readString(resource.kubernetes?.clusterName) ||
readString(resource.kubernetes?.context) ||
readString(resource.kubernetes?.clusterId) ||
readString(resource.clusterId) ||
'';
const kubernetesNamespace = (resource: Resource): string =>
readString(resource.kubernetes?.namespace) || '';
const trueNASSystemName = (resource: Resource): string =>
readString(resource.truenas?.hostname) || readString(resource.parentName) || '';
const vmwareConnectionName = (resource: Resource): string =>
readString(resource.vmware?.connectionName) ||
readString(resource.vmware?.vcenterHost) ||
'vSphere';
const vmwareNodeName = (resource: Resource): string =>
readString(resource.vmware?.runtimeHostName) ||
readString(resource.parentName) ||
readString(resource.vmware?.clusterName) ||
readString(resource.vmware?.datacenterName) ||
readString(resource.vmware?.vcenterHost) ||
'';
const toTableResource = ({
resource,
type,
resourceType,
defaults,
candidates = resourceAlertIdCandidates(resource),
overridesMap,
node,
instance,
}: {
resource: Resource;
type: AlertPlatformResourceType;
resourceType: string;
defaults: Record<string, number | undefined>;
candidates?: string[];
overridesMap: Map<string, Override>;
node?: string;
instance?: string;
}): TableResource => {
const override = findOverrideByCandidates(overridesMap, candidates);
const hasCustomThresholds = hasThresholdDiff(override, defaults);
const note = typeof override?.note === 'string' ? override.note : undefined;
return {
id: override?.id || candidates[0] || resource.id,
name: getAlertResourceDisplayLabel(resource),
displayName: getAlertResourceDisplayLabel(resource),
rawName: resource.name,
type,
resourceType,
node,
instance,
status: resource.status,
cpu: metricPercent(resource, 'cpu'),
memory: metricPercent(resource, 'memory'),
hasOverride: hasCustomThresholds || Boolean(override?.disabled) || Boolean(note && note.trim()),
disabled: override?.disabled || false,
thresholds: override?.thresholds || {},
defaults,
clusterName: kubernetesClusterName(resource) || undefined,
note,
};
};
export function useThresholdsPlatformData(inputs: ThresholdsDataInputs) {
const { props, editingId, searchTerm } = inputs;
const allResources = () => props.allResources ?? [];
const kubernetesDefaults = () => props.kubernetesDefaults ?? {};
const trueNASDefaults = () => props.trueNASDefaults ?? {};
const trueNASDiskDefaults = () => props.trueNASDiskDefaults ?? {};
const vmwareDefaults = () => props.vmwareDefaults ?? {};
const kubernetesClustersWithOverrides = createMemo<TableResource[]>((prev = []) => {
if (editingId()) return prev;
const search = searchTerm().toLowerCase();
const overridesMap = createOverridesMap(props.overrides());
return buildKubernetesPageModel(allResources())
.clusters.map((resource) =>
toTableResource({
resource,
type: 'kubernetesCluster',
resourceType: 'Kubernetes Cluster',
defaults: kubernetesDefaults(),
overridesMap,
node: kubernetesClusterName(resource),
instance: kubernetesClusterName(resource),
}),
)
.filter((resource) => searchResource(resource, search));
}, []);
const kubernetesNodesWithOverrides = createMemo<TableResource[]>((prev = []) => {
if (editingId()) return prev;
const search = searchTerm().toLowerCase();
const overridesMap = createOverridesMap(props.overrides());
return buildKubernetesPageModel(allResources())
.nodes.map((resource) =>
toTableResource({
resource,
type: 'kubernetesNode',
resourceType: 'Kubernetes Node',
defaults: kubernetesDefaults(),
overridesMap,
node: readString(resource.kubernetes?.nodeName) || resource.name,
instance: kubernetesClusterName(resource),
}),
)
.filter((resource) => searchResource(resource, search));
}, []);
const kubernetesNamespacesWithOverrides = createMemo<TableResource[]>((prev = []) => {
if (editingId()) return prev;
const search = searchTerm().toLowerCase();
const overridesMap = createOverridesMap(props.overrides());
return buildKubernetesPageModel(allResources())
.namespaces.map((resource) =>
toTableResource({
resource,
type: 'kubernetesNamespace',
resourceType: 'Kubernetes Namespace',
defaults: {},
overridesMap,
node: kubernetesClusterName(resource),
instance: kubernetesClusterName(resource),
}),
)
.filter((resource) => searchResource(resource, search));
}, []);
const kubernetesDeploymentsWithOverrides = createMemo<TableResource[]>((prev = []) => {
if (editingId()) return prev;
const search = searchTerm().toLowerCase();
const overridesMap = createOverridesMap(props.overrides());
return buildKubernetesPageModel(allResources())
.deployments.map((resource) =>
toTableResource({
resource,
type: 'kubernetesDeployment',
resourceType: 'Kubernetes Deployment',
defaults: kubernetesDefaults(),
overridesMap,
node: kubernetesNamespace(resource),
instance: kubernetesClusterName(resource),
}),
)
.filter((resource) => searchResource(resource, search));
}, []);
const kubernetesPodsWithOverrides = createMemo<TableResource[]>((prev = []) => {
if (editingId()) return prev;
const search = searchTerm().toLowerCase();
const overridesMap = createOverridesMap(props.overrides());
return buildKubernetesPageModel(allResources())
.pods.map((resource) =>
toTableResource({
resource,
type: 'kubernetesPod',
resourceType: 'Kubernetes Pod',
defaults: kubernetesDefaults(),
overridesMap,
node: readString(resource.kubernetes?.nodeName) || kubernetesNamespace(resource),
instance: kubernetesClusterName(resource),
}),
)
.filter((resource) => searchResource(resource, search));
}, []);
const trueNASSystemsWithOverrides = createMemo<TableResource[]>((prev = []) => {
if (editingId()) return prev;
const search = searchTerm().toLowerCase();
const overridesMap = createOverridesMap(props.overrides());
return buildTrueNASPageModel(allResources())
.systems.map((resource) =>
toTableResource({
resource,
type: 'truenasSystem',
resourceType: 'TrueNAS System',
defaults: trueNASDefaults(),
overridesMap,
node: trueNASSystemName(resource),
instance: 'TrueNAS',
}),
)
.filter((resource) => searchResource(resource, search));
}, []);
const trueNASPoolsWithOverrides = createMemo<TableResource[]>((prev = []) => {
if (editingId()) return prev;
const search = searchTerm().toLowerCase();
const overridesMap = createOverridesMap(props.overrides());
return buildTrueNASPageModel(allResources())
.pools.map((resource) =>
toTableResource({
resource,
type: 'truenasPool',
resourceType: 'TrueNAS Pool',
defaults: { usage: trueNASDefaults().usage ?? 85 },
overridesMap,
node: trueNASSystemName(resource),
instance: 'TrueNAS',
}),
)
.filter((resource) => searchResource(resource, search));
}, []);
const trueNASDatasetsWithOverrides = createMemo<TableResource[]>((prev = []) => {
if (editingId()) return prev;
const search = searchTerm().toLowerCase();
const overridesMap = createOverridesMap(props.overrides());
return buildTrueNASPageModel(allResources())
.datasets.map((resource) =>
toTableResource({
resource,
type: 'truenasDataset',
resourceType: 'TrueNAS Dataset',
defaults: { usage: trueNASDefaults().usage ?? 85 },
overridesMap,
node: trueNASSystemName(resource),
instance: 'TrueNAS',
}),
)
.filter((resource) => searchResource(resource, search));
}, []);
const trueNASDisksWithOverrides = createMemo<TableResource[]>((prev = []) => {
if (editingId()) return prev;
const search = searchTerm().toLowerCase();
const overridesMap = createOverridesMap(props.overrides());
return buildTrueNASPageModel(allResources())
.disks.map((resource) =>
toTableResource({
resource,
type: 'truenasDisk',
resourceType: 'TrueNAS Disk',
defaults: trueNASDiskDefaults(),
overridesMap,
node: trueNASSystemName(resource),
instance: readString(resource.physicalDisk?.diskType) || 'TrueNAS',
}),
)
.filter((resource) => searchResource(resource, search));
}, []);
const vmwareHostsWithOverrides = createMemo<TableResource[]>((prev = []) => {
if (editingId()) return prev;
const search = searchTerm().toLowerCase();
const overridesMap = createOverridesMap(props.overrides());
return buildVmwarePageModel(allResources())
.hosts.map((resource) =>
toTableResource({
resource,
type: 'vmwareHost',
resourceType: 'vSphere Host',
defaults: vmwareDefaults(),
overridesMap,
node: vmwareNodeName(resource),
instance: vmwareConnectionName(resource),
}),
)
.filter((resource) => searchResource(resource, search));
}, []);
const vmwareVMsWithOverrides = createMemo<TableResource[]>((prev = []) => {
if (editingId()) return prev;
const search = searchTerm().toLowerCase();
const overridesMap = createOverridesMap(props.overrides());
return buildVmwarePageModel(allResources())
.vms.map((resource) =>
toTableResource({
resource,
type: 'vmwareVm',
resourceType: 'vSphere VM',
defaults: vmwareDefaults(),
overridesMap,
node: vmwareNodeName(resource),
instance: vmwareConnectionName(resource),
}),
)
.filter((resource) => searchResource(resource, search));
}, []);
const vmwareDatastoresWithOverrides = createMemo<TableResource[]>((prev = []) => {
if (editingId()) return prev;
const search = searchTerm().toLowerCase();
const overridesMap = createOverridesMap(props.overrides());
return buildVmwarePageModel(allResources())
.datastores.map((resource) =>
toTableResource({
resource,
type: 'vmwareDatastore',
resourceType: 'vSphere Datastore',
defaults: { usage: vmwareDefaults().usage ?? 85 },
overridesMap,
node: vmwareNodeName(resource),
instance: vmwareConnectionName(resource),
}),
)
.filter((resource) => searchResource(resource, search));
}, []);
const vmwareNetworksWithOverrides = createMemo<TableResource[]>((prev = []) => {
if (editingId()) return prev;
const search = searchTerm().toLowerCase();
const overridesMap = createOverridesMap(props.overrides());
return buildVmwarePageModel(allResources())
.networks.map((resource) =>
toTableResource({
resource,
type: 'vmwareNetwork',
resourceType: 'vSphere Network',
defaults: {},
overridesMap,
node: vmwareNodeName(resource),
instance: vmwareConnectionName(resource),
}),
)
.filter((resource) => searchResource(resource, search));
}, []);
return {
kubernetesClustersWithOverrides,
kubernetesNodesWithOverrides,
kubernetesNamespacesWithOverrides,
kubernetesDeploymentsWithOverrides,
kubernetesPodsWithOverrides,
trueNASSystemsWithOverrides,
trueNASPoolsWithOverrides,
trueNASDatasetsWithOverrides,
trueNASDisksWithOverrides,
vmwareHostsWithOverrides,
vmwareVMsWithOverrides,
vmwareDatastoresWithOverrides,
vmwareNetworksWithOverrides,
};
}
export { resourceAlertIdCandidates, resourceAlertActionId };

View file

@ -54,11 +54,24 @@ interface ThresholdsSummaryItem {
| 'dockerContainers'
| 'dockerHosts'
| 'guests'
| 'kubernetesClusters'
| 'kubernetesDeployments'
| 'kubernetesNamespaces'
| 'kubernetesNodes'
| 'kubernetesPods'
| 'nodes'
| 'pbs'
| 'pmg'
| 'snapshots'
| 'storage';
| 'storage'
| 'trueNASDatasets'
| 'trueNASDisks'
| 'trueNASPools'
| 'trueNASSystems'
| 'vmwareDatastores'
| 'vmwareHosts'
| 'vmwareNetworks'
| 'vmwareVMs';
label: string;
overrides: number;
tab: ThresholdsActiveTab;
@ -90,6 +103,7 @@ export function useThresholdsTableState(props: ThresholdsTableProps) {
};
const [searchTerm, setSearchTerm] = createSignal('');
const [overrideFilter, setOverrideFilter] = createSignal('all');
const [editingId, setEditingId] = createSignal<string | null>(null);
const [editingThresholds, setEditingThresholds] = createSignal<
Record<string, number | undefined>
@ -98,7 +112,7 @@ export function useThresholdsTableState(props: ThresholdsTableProps) {
const [bulkEditIds, setBulkEditIds] = createSignal<string[]>([]);
const [bulkEditColumns, setBulkEditColumns] = createSignal<string[]>([]);
const [isBulkEditDialogOpen, setIsBulkEditDialogOpen] = createSignal(false);
const [activeTab, setActiveTab] = createSignal<ThresholdsActiveTab>('infrastructure');
const [activeTab, setActiveTab] = createSignal<ThresholdsActiveTab>('proxmox');
const [dockerIgnoredInput, setDockerIgnoredInput] = createSignal(
props.dockerIgnoredPrefixes().join('\n'),
);
@ -132,10 +146,17 @@ export function useThresholdsTableState(props: ThresholdsTableProps) {
const getActiveTabFromRoute = (): ThresholdsActiveTab => {
const path = location.pathname;
if (path.includes('/thresholds/containers')) return 'docker';
if (path.includes('/thresholds/systems') || path.includes('/thresholds/agents')) return 'systems';
if (path.includes('/thresholds/mail-gateway')) return 'pmg';
return 'infrastructure';
if (path.includes('/thresholds/containers') || path.includes('/thresholds/docker'))
return 'docker';
if (path.includes('/thresholds/kubernetes')) return 'kubernetes';
if (path.includes('/thresholds/truenas')) return 'truenas';
if (path.includes('/thresholds/vmware') || path.includes('/thresholds/vsphere'))
return 'vmware';
if (path.includes('/thresholds/pbs')) return 'pbs';
if (path.includes('/thresholds/systems') || path.includes('/thresholds/agents'))
return 'systems';
if (path.includes('/thresholds/mail-gateway') || path.includes('/thresholds/pmg')) return 'pmg';
return 'proxmox';
};
createEffect(() => {
@ -147,12 +168,22 @@ export function useThresholdsTableState(props: ThresholdsTableProps) {
createEffect(() => {
if (location.pathname === '/alerts/thresholds') {
navigate('/alerts/thresholds/infrastructure', { replace: true });
navigate('/alerts/thresholds/proxmox', { replace: true });
return;
}
if (location.pathname === '/alerts/thresholds/proxmox') {
navigate('/alerts/thresholds/infrastructure', { replace: true });
if (location.pathname === '/alerts/thresholds/infrastructure') {
navigate('/alerts/thresholds/proxmox', { replace: true });
return;
}
if (location.pathname === '/alerts/thresholds/containers') {
navigate('/alerts/thresholds/docker', { replace: true });
return;
}
if (location.pathname === '/alerts/thresholds/mail-gateway') {
navigate('/alerts/thresholds/pmg', { replace: true });
return;
}
@ -163,9 +194,13 @@ export function useThresholdsTableState(props: ThresholdsTableProps) {
const handleTabClick = (tab: ThresholdsActiveTab) => {
const tabRoutes: Record<ThresholdsActiveTab, string> = {
infrastructure: '/alerts/thresholds/infrastructure',
docker: '/alerts/thresholds/containers',
pmg: '/alerts/thresholds/mail-gateway',
proxmox: '/alerts/thresholds/proxmox',
docker: '/alerts/thresholds/docker',
kubernetes: '/alerts/thresholds/kubernetes',
truenas: '/alerts/thresholds/truenas',
vmware: '/alerts/thresholds/vmware',
pbs: '/alerts/thresholds/pbs',
pmg: '/alerts/thresholds/pmg',
systems: '/alerts/thresholds/systems',
};
navigate(tabRoutes[tab]);
@ -194,26 +229,121 @@ export function useThresholdsTableState(props: ThresholdsTableProps) {
};
const {
nodesWithOverrides,
agentsWithOverrides,
agentDisksWithOverrides,
agentDisksGroupedByAgent,
nodesWithOverrides: rawNodesWithOverrides,
agentsWithOverrides: rawAgentsWithOverrides,
agentDisksWithOverrides: rawAgentDisksWithOverrides,
agentDisksGroupedByAgent: rawAgentDisksGroupedByAgent,
agentGroupHeaderMeta,
dockerHostsWithOverrides,
dockerContainersGroupedByHost,
dockerContainersFlat,
dockerHostsWithOverrides: rawDockerHostsWithOverrides,
dockerContainersGroupedByHost: rawDockerContainersGroupedByHost,
dockerContainersFlat: rawDockerContainersFlat,
totalDockerContainers,
dockerHostGroupMeta,
guestsGroupedByNode,
guestsFlat,
guestsGroupedByNode: rawGuestsGroupedByNode,
guestsFlat: rawGuestsFlat,
guestGroupHeaderMeta,
pbsServersWithOverrides,
kubernetesClustersWithOverrides: rawKubernetesClustersWithOverrides = () => [],
kubernetesDeploymentsWithOverrides: rawKubernetesDeploymentsWithOverrides = () => [],
kubernetesNamespacesWithOverrides: rawKubernetesNamespacesWithOverrides = () => [],
kubernetesNodesWithOverrides: rawKubernetesNodesWithOverrides = () => [],
kubernetesPodsWithOverrides: rawKubernetesPodsWithOverrides = () => [],
pbsServersWithOverrides: rawPbsServersWithOverrides,
pmgGlobalDefaults,
pmgServersWithOverrides,
storageWithOverrides,
storageGroupedByNode,
pmgServersWithOverrides: rawPmgServersWithOverrides,
storageWithOverrides: rawStorageWithOverrides,
storageGroupedByNode: rawStorageGroupedByNode,
trueNASDatasetsWithOverrides: rawTrueNASDatasetsWithOverrides = () => [],
trueNASDisksWithOverrides: rawTrueNASDisksWithOverrides = () => [],
trueNASPoolsWithOverrides: rawTrueNASPoolsWithOverrides = () => [],
trueNASSystemsWithOverrides: rawTrueNASSystemsWithOverrides = () => [],
vmwareDatastoresWithOverrides: rawVmwareDatastoresWithOverrides = () => [],
vmwareHostsWithOverrides: rawVmwareHostsWithOverrides = () => [],
vmwareNetworksWithOverrides: rawVmwareNetworksWithOverrides = () => [],
vmwareVMsWithOverrides: rawVmwareVMsWithOverrides = () => [],
} = useThresholdsData(props, editingId, searchTerm);
const resourceMatchesOverrideFilter = (resource: TableResource): boolean => {
const filter = overrideFilter();
if (filter === 'custom') {
return Boolean(
resource.hasOverride || resource.disableConnectivity || resource.poweredOffSeverity,
);
}
if (filter === 'disabled') {
return Boolean(resource.disabled || resource.disableConnectivity);
}
return true;
};
const filterResources = (resources: TableResource[]): TableResource[] =>
resources.filter(resourceMatchesOverrideFilter);
const filterGroupedResources = (
groups: Record<string, TableResource[]>,
): Record<string, TableResource[]> => {
const filtered: Record<string, TableResource[]> = {};
Object.entries(groups).forEach(([groupKey, resources]) => {
const visibleResources = filterResources(resources);
if (visibleResources.length > 0) {
filtered[groupKey] = visibleResources;
}
});
return filtered;
};
const nodesWithOverrides = createMemo(() => filterResources(rawNodesWithOverrides()));
const agentsWithOverrides = createMemo(() => filterResources(rawAgentsWithOverrides()));
const agentDisksWithOverrides = createMemo(() => filterResources(rawAgentDisksWithOverrides()));
const agentDisksGroupedByAgent = createMemo(() =>
filterGroupedResources(rawAgentDisksGroupedByAgent()),
);
const dockerHostsWithOverrides = createMemo(() => filterResources(rawDockerHostsWithOverrides()));
const dockerContainersGroupedByHost = createMemo(() =>
filterGroupedResources(rawDockerContainersGroupedByHost()),
);
const dockerContainersFlat = createMemo(() => filterResources(rawDockerContainersFlat()));
const guestsGroupedByNode = createMemo(() => filterGroupedResources(rawGuestsGroupedByNode()));
const guestsFlat = createMemo(() => filterResources(rawGuestsFlat()));
const kubernetesClustersWithOverrides = createMemo(() =>
filterResources(rawKubernetesClustersWithOverrides()),
);
const kubernetesDeploymentsWithOverrides = createMemo(() =>
filterResources(rawKubernetesDeploymentsWithOverrides()),
);
const kubernetesNamespacesWithOverrides = createMemo(() =>
filterResources(rawKubernetesNamespacesWithOverrides()),
);
const kubernetesNodesWithOverrides = createMemo(() =>
filterResources(rawKubernetesNodesWithOverrides()),
);
const kubernetesPodsWithOverrides = createMemo(() =>
filterResources(rawKubernetesPodsWithOverrides()),
);
const pbsServersWithOverrides = createMemo(() => filterResources(rawPbsServersWithOverrides()));
const pmgServersWithOverrides = createMemo(() => filterResources(rawPmgServersWithOverrides()));
const storageWithOverrides = createMemo(() => filterResources(rawStorageWithOverrides()));
const storageGroupedByNode = createMemo(() => filterGroupedResources(rawStorageGroupedByNode()));
const trueNASDatasetsWithOverrides = createMemo(() =>
filterResources(rawTrueNASDatasetsWithOverrides()),
);
const trueNASDisksWithOverrides = createMemo(() =>
filterResources(rawTrueNASDisksWithOverrides()),
);
const trueNASPoolsWithOverrides = createMemo(() =>
filterResources(rawTrueNASPoolsWithOverrides()),
);
const trueNASSystemsWithOverrides = createMemo(() =>
filterResources(rawTrueNASSystemsWithOverrides()),
);
const vmwareDatastoresWithOverrides = createMemo(() =>
filterResources(rawVmwareDatastoresWithOverrides()),
);
const vmwareHostsWithOverrides = createMemo(() => filterResources(rawVmwareHostsWithOverrides()));
const vmwareNetworksWithOverrides = createMemo(() =>
filterResources(rawVmwareNetworksWithOverrides()),
);
const vmwareVMsWithOverrides = createMemo(() => filterResources(rawVmwareVMsWithOverrides()));
const {
backupDefaultsRecord,
backupFactoryConfig,
@ -299,7 +429,7 @@ export function useThresholdsTableState(props: ThresholdsTableProps) {
key: 'nodes',
label: 'Virtualization Hosts',
overrides: countOverrides(nodesWithOverrides()),
tab: 'infrastructure',
tab: 'proxmox',
total: props.nodes?.length ?? 0,
},
{
@ -327,28 +457,28 @@ export function useThresholdsTableState(props: ThresholdsTableProps) {
key: 'storage',
label: 'Storage',
overrides: countOverrides(storageWithOverrides()),
tab: 'infrastructure',
tab: 'proxmox',
total: props.storage?.length ?? 0,
},
{
key: 'backups',
label: 'Recovery',
overrides: backupOverridesCount(),
tab: 'infrastructure',
tab: 'proxmox',
total: 1,
},
{
key: 'snapshots',
label: 'Snapshot Age',
overrides: snapshotOverridesCount(),
tab: 'infrastructure',
tab: 'proxmox',
total: 1,
},
{
key: 'pbs',
label: 'PBS Servers',
overrides: countOverrides(pbsServersWithOverrides()),
tab: 'infrastructure',
tab: 'pbs',
total: props.pbsInstances?.length ?? 0,
},
{
@ -369,9 +499,100 @@ export function useThresholdsTableState(props: ThresholdsTableProps) {
key: 'guests',
label: 'VMs & Containers',
overrides: countOverrides(guestsFlat()),
tab: 'infrastructure',
tab: 'proxmox',
total: props.allGuests?.()?.length ?? 0,
},
{
key: 'kubernetesClusters',
label: 'Kubernetes Clusters',
overrides: countOverrides(kubernetesClustersWithOverrides()),
tab: 'kubernetes',
total: kubernetesClustersWithOverrides().length,
},
{
key: 'kubernetesNodes',
label: 'Kubernetes Nodes',
overrides: countOverrides(kubernetesNodesWithOverrides()),
tab: 'kubernetes',
total: kubernetesNodesWithOverrides().length,
},
{
key: 'kubernetesNamespaces',
label: 'Namespaces',
overrides: countOverrides(kubernetesNamespacesWithOverrides()),
tab: 'kubernetes',
total: kubernetesNamespacesWithOverrides().length,
},
{
key: 'kubernetesDeployments',
label: 'Deployments',
overrides: countOverrides(kubernetesDeploymentsWithOverrides()),
tab: 'kubernetes',
total: kubernetesDeploymentsWithOverrides().length,
},
{
key: 'kubernetesPods',
label: 'Pods',
overrides: countOverrides(kubernetesPodsWithOverrides()),
tab: 'kubernetes',
total: kubernetesPodsWithOverrides().length,
},
{
key: 'trueNASSystems',
label: 'TrueNAS Systems',
overrides: countOverrides(trueNASSystemsWithOverrides()),
tab: 'truenas',
total: trueNASSystemsWithOverrides().length,
},
{
key: 'trueNASPools',
label: 'Pools',
overrides: countOverrides(trueNASPoolsWithOverrides()),
tab: 'truenas',
total: trueNASPoolsWithOverrides().length,
},
{
key: 'trueNASDatasets',
label: 'Datasets',
overrides: countOverrides(trueNASDatasetsWithOverrides()),
tab: 'truenas',
total: trueNASDatasetsWithOverrides().length,
},
{
key: 'trueNASDisks',
label: 'Disks',
overrides: countOverrides(trueNASDisksWithOverrides()),
tab: 'truenas',
total: trueNASDisksWithOverrides().length,
},
{
key: 'vmwareHosts',
label: 'vSphere Hosts',
overrides: countOverrides(vmwareHostsWithOverrides()),
tab: 'vmware',
total: vmwareHostsWithOverrides().length,
},
{
key: 'vmwareVMs',
label: 'vSphere VMs',
overrides: countOverrides(vmwareVMsWithOverrides()),
tab: 'vmware',
total: vmwareVMsWithOverrides().length,
},
{
key: 'vmwareDatastores',
label: 'Datastores',
overrides: countOverrides(vmwareDatastoresWithOverrides()),
tab: 'vmware',
total: vmwareDatastoresWithOverrides().length,
},
{
key: 'vmwareNetworks',
label: 'Networks',
overrides: countOverrides(vmwareNetworksWithOverrides()),
tab: 'vmware',
total: vmwareNetworksWithOverrides().length,
},
];
return items
@ -420,6 +641,19 @@ export function useThresholdsTableState(props: ThresholdsTableProps) {
pbsServersWithOverrides,
pmgServersWithOverrides,
storageWithOverrides,
kubernetesClustersWithOverrides,
kubernetesNodesWithOverrides,
kubernetesNamespacesWithOverrides,
kubernetesDeploymentsWithOverrides,
kubernetesPodsWithOverrides,
trueNASSystemsWithOverrides,
trueNASPoolsWithOverrides,
trueNASDatasetsWithOverrides,
trueNASDisksWithOverrides,
vmwareHostsWithOverrides,
vmwareVMsWithOverrides,
vmwareDatastoresWithOverrides,
vmwareNetworksWithOverrides,
},
editingThresholds,
editingNote,
@ -441,6 +675,19 @@ export function useThresholdsTableState(props: ThresholdsTableProps) {
dockerContainersFlat,
pbsServersWithOverrides,
storageWithOverrides,
kubernetesClustersWithOverrides,
kubernetesNodesWithOverrides,
kubernetesNamespacesWithOverrides,
kubernetesDeploymentsWithOverrides,
kubernetesPodsWithOverrides,
trueNASSystemsWithOverrides,
trueNASPoolsWithOverrides,
trueNASDatasetsWithOverrides,
trueNASDisksWithOverrides,
vmwareHostsWithOverrides,
vmwareVMsWithOverrides,
vmwareDatastoresWithOverrides,
vmwareNetworksWithOverrides,
},
removeOverride,
});
@ -459,7 +706,25 @@ export function useThresholdsTableState(props: ThresholdsTableProps) {
};
const updateMetricDelay = (
typeKey: 'guest' | 'node' | 'storage' | 'pbs' | 'agent',
typeKey:
| 'guest'
| 'node'
| 'storage'
| 'pbs'
| 'agent'
| 'k8s-cluster'
| 'k8s-node'
| 'k8s-deployment'
| 'k8s-namespace'
| 'pod'
| 'truenas-system'
| 'truenas-pool'
| 'truenas-dataset'
| 'truenas-disk'
| 'vmware-host'
| 'vmware-vm'
| 'vmware-datastore'
| 'vmware-network',
metricKey: string,
value: number | null,
) => {
@ -554,6 +819,11 @@ export function useThresholdsTableState(props: ThresholdsTableProps) {
hasDockerSpecificControls,
isBulkEditDialogOpen,
isCollapsed,
kubernetesClustersWithOverrides,
kubernetesDeploymentsWithOverrides,
kubernetesNamespacesWithOverrides,
kubernetesNodesWithOverrides,
kubernetesPodsWithOverrides,
nodesWithOverrides,
NODE_THRESHOLDS_FILTER_EMPTY_STATE,
pbsServersWithOverrides,
@ -565,6 +835,7 @@ export function useThresholdsTableState(props: ThresholdsTableProps) {
pmgServersWithOverrides,
registerSection,
removeOverride,
overrideFilter,
saveEdit,
searchTerm,
sectionTitles,
@ -580,6 +851,7 @@ export function useThresholdsTableState(props: ThresholdsTableProps) {
setEditingThresholds,
setHasUnsavedChanges: props.setHasUnsavedChanges,
setIsBulkEditDialogOpen,
setOverrideFilter,
setOfflineState,
setPMGGlobalDefaults,
SNAPSHOT_THRESHOLDS_EMPTY_STATE,
@ -593,6 +865,14 @@ export function useThresholdsTableState(props: ThresholdsTableProps) {
STORAGE_THRESHOLDS_EMPTY_STATE,
STORAGE_THRESHOLDS_FILTER_EMPTY_STATE,
summaryItems,
trueNASDatasetsWithOverrides,
trueNASDisksWithOverrides,
trueNASPoolsWithOverrides,
trueNASSystemsWithOverrides,
vmwareDatastoresWithOverrides,
vmwareHostsWithOverrides,
vmwareNetworksWithOverrides,
vmwareVMsWithOverrides,
toggleBackup,
toggleDisabled,
toggleNodeConnectivity,

View file

@ -2,7 +2,15 @@ import type { ResourcePolicy } from '@/types/resource';
import type { BackupAlertConfig, SnapshotAlertConfig } from '@/types/alerts';
import type { AlertResourceThresholdMap } from '@/components/Alerts/alertResourceTableModel';
export type ThresholdsActiveTab = 'infrastructure' | 'pmg' | 'systems' | 'docker';
export type ThresholdsActiveTab =
| 'proxmox'
| 'docker'
| 'kubernetes'
| 'truenas'
| 'vmware'
| 'pbs'
| 'pmg'
| 'systems';
export interface Resource {
id: string;

View file

@ -2,14 +2,25 @@ import type { Accessor } from 'solid-js';
import type { ThresholdsTableProps } from './types';
export interface ThresholdsTabProps
extends Omit<
ThresholdsTableProps,
'guestDefaults' | 'nodeDefaults' | 'pbsDefaults' | 'agentDefaults' | 'dockerDefaults'
> {
export interface ThresholdsTabProps extends Omit<
ThresholdsTableProps,
| 'guestDefaults'
| 'nodeDefaults'
| 'pbsDefaults'
| 'kubernetesDefaults'
| 'trueNASDefaults'
| 'trueNASDiskDefaults'
| 'vmwareDefaults'
| 'agentDefaults'
| 'dockerDefaults'
> {
guestDefaults: Accessor<ThresholdsTableProps['guestDefaults']>;
nodeDefaults: Accessor<ThresholdsTableProps['nodeDefaults']>;
pbsDefaults: Accessor<NonNullable<ThresholdsTableProps['pbsDefaults']>>;
kubernetesDefaults: Accessor<NonNullable<ThresholdsTableProps['kubernetesDefaults']>>;
trueNASDefaults: Accessor<NonNullable<ThresholdsTableProps['trueNASDefaults']>>;
trueNASDiskDefaults: Accessor<NonNullable<ThresholdsTableProps['trueNASDiskDefaults']>>;
vmwareDefaults: Accessor<NonNullable<ThresholdsTableProps['vmwareDefaults']>>;
agentDefaults: Accessor<ThresholdsTableProps['agentDefaults']>;
dockerDefaults: Accessor<ThresholdsTableProps['dockerDefaults']>;
}

View file

@ -14,6 +14,19 @@ export type OverrideType =
| 'storage'
| 'pbs'
| 'pmg'
| 'kubernetesCluster'
| 'kubernetesNode'
| 'kubernetesNamespace'
| 'kubernetesDeployment'
| 'kubernetesPod'
| 'truenasSystem'
| 'truenasPool'
| 'truenasDataset'
| 'truenasDisk'
| 'vmwareHost'
| 'vmwareVm'
| 'vmwareDatastore'
| 'vmwareNetwork'
| 'dockerHost'
| 'dockerContainer';
@ -89,6 +102,10 @@ export interface ThresholdsTableProps {
setGuestPoweredOffSeverity: (value: 'warning' | 'critical') => void;
nodeDefaults: SimpleThresholds;
pbsDefaults?: SimpleThresholds;
kubernetesDefaults?: SimpleThresholds;
trueNASDefaults?: SimpleThresholds;
trueNASDiskDefaults?: SimpleThresholds;
vmwareDefaults?: SimpleThresholds;
agentDefaults: SimpleThresholds;
setNodeDefaults: (
value:
@ -100,6 +117,26 @@ export interface ThresholdsTableProps {
| Record<string, number | undefined>
| ((prev: Record<string, number | undefined>) => Record<string, number | undefined>),
) => void;
setKubernetesDefaults?: (
value:
| Record<string, number | undefined>
| ((prev: Record<string, number | undefined>) => Record<string, number | undefined>),
) => void;
setTrueNASDefaults?: (
value:
| Record<string, number | undefined>
| ((prev: Record<string, number | undefined>) => Record<string, number | undefined>),
) => void;
setTrueNASDiskDefaults?: (
value:
| Record<string, number | undefined>
| ((prev: Record<string, number | undefined>) => Record<string, number | undefined>),
) => void;
setVMwareDefaults?: (
value:
| Record<string, number | undefined>
| ((prev: Record<string, number | undefined>) => Record<string, number | undefined>),
) => void;
setAgentDefaults: (
value:
| Record<string, number | undefined>
@ -168,6 +205,10 @@ export interface ThresholdsTableProps {
resetGuestDefaults?: () => void;
resetNodeDefaults?: () => void;
resetPBSDefaults?: () => void;
resetKubernetesDefaults?: () => void;
resetTrueNASDefaults?: () => void;
resetTrueNASDiskDefaults?: () => void;
resetVMwareDefaults?: () => void;
resetAgentDefaults?: () => void;
resetDockerDefaults?: () => void;
resetDockerIgnoredPrefixes?: () => void;
@ -175,6 +216,10 @@ export interface ThresholdsTableProps {
factoryGuestDefaults?: Record<string, number | undefined>;
factoryNodeDefaults?: Record<string, number | undefined>;
factoryPBSDefaults?: Record<string, number | undefined>;
factoryKubernetesDefaults?: Record<string, number | undefined>;
factoryTrueNASDefaults?: Record<string, number | undefined>;
factoryTrueNASDiskDefaults?: Record<string, number | undefined>;
factoryVMwareDefaults?: Record<string, number | undefined>;
factoryAgentDefaults?: Record<string, number | undefined>;
factoryDockerDefaults?: Record<string, number | undefined>;
factoryStorageDefault?: number;
@ -184,6 +229,19 @@ export interface ThresholdsTableProps {
storage: number;
pbs: number;
agent: number;
'k8s-cluster': number;
'k8s-node': number;
'k8s-deployment': number;
'k8s-namespace': number;
pod: number;
'truenas-system': number;
'truenas-pool': number;
'truenas-dataset': number;
'truenas-disk': number;
'vmware-host': number;
'vmware-vm': number;
'vmware-datastore': number;
'vmware-network': number;
};
metricTimeThresholds: () => Record<string, Record<string, number>>;
setMetricTimeThresholds: (
@ -225,6 +283,12 @@ export interface ThresholdsTableProps {
setDisableAllDockerServices: (value: boolean) => void;
disableAllDockerContainers: () => boolean;
setDisableAllDockerContainers: (value: boolean) => void;
disableAllKubernetes: () => boolean;
setDisableAllKubernetes: (value: boolean) => void;
disableAllTrueNAS: () => boolean;
setDisableAllTrueNAS: (value: boolean) => void;
disableAllVMware: () => boolean;
setDisableAllVMware: (value: boolean) => void;
// Global disable offline alerts flags
disableAllNodesOffline: () => boolean;
setDisableAllNodesOffline: (value: boolean) => void;

View file

@ -90,6 +90,19 @@ export type OverrideType =
| 'storage'
| 'pbs'
| 'pmg'
| 'kubernetesCluster'
| 'kubernetesNode'
| 'kubernetesNamespace'
| 'kubernetesDeployment'
| 'kubernetesPod'
| 'truenasSystem'
| 'truenasPool'
| 'truenasDataset'
| 'truenasDisk'
| 'vmwareHost'
| 'vmwareVm'
| 'vmwareDatastore'
| 'vmwareNetwork'
| 'dockerHost'
| 'dockerContainer';

View file

@ -114,6 +114,7 @@ export function useAlertOverridesState(props: AlertOverridesStateProps) {
containerRuntimeResources: containerRuntimeResources(),
getChildren: props.children,
pbsInstanceById: pbsInstanceById(),
allResources: props.allResources(),
});
const currentOverrides = overrides();
@ -128,8 +129,7 @@ export function useAlertOverridesState(props: AlertOverridesStateProps) {
Boolean(newOverride.disabled) !== Boolean(existing.disabled) ||
(newOverride.poweredOffSeverity ?? null) !== (existing.poweredOffSeverity ?? null) ||
JSON.stringify(newOverride.backup ?? null) !== JSON.stringify(existing.backup ?? null) ||
JSON.stringify(newOverride.snapshot ?? null) !==
JSON.stringify(existing.snapshot ?? null)
JSON.stringify(newOverride.snapshot ?? null) !== JSON.stringify(existing.snapshot ?? null)
);
});

View file

@ -10,18 +10,17 @@ import {
FACTORY_DOCKER_STATE_DISABLE_CONNECTIVITY,
FACTORY_DOCKER_STATE_SEVERITY,
FACTORY_GUEST_DEFAULTS,
FACTORY_KUBERNETES_DEFAULTS,
FACTORY_NODE_DEFAULTS,
FACTORY_PBS_DEFAULTS,
FACTORY_SNAPSHOT_DEFAULTS,
FACTORY_STORAGE_DEFAULT,
FACTORY_TRUENAS_DEFAULTS,
FACTORY_TRUENAS_DISK_DEFAULTS,
FACTORY_VMWARE_DEFAULTS,
type AlertsConfigurationSnapshot,
} from './alertsConfigurationModel';
import type {
CooldownConfig,
EscalationConfig,
GroupingConfig,
QuietHoursConfig,
} from './types';
import type { CooldownConfig, EscalationConfig, GroupingConfig, QuietHoursConfig } from './types';
interface UseAlertsConfigurationSnapshotStateProps {
setHasUnsavedChanges: (value: boolean) => void;
@ -61,6 +60,18 @@ export function useAlertsConfigurationSnapshotState(
const [pbsDefaults, setPBSDefaults] = createSignal<Record<string, number | undefined>>(
defaultSnapshot.pbsDefaults,
);
const [kubernetesDefaults, setKubernetesDefaults] = createSignal<
Record<string, number | undefined>
>(defaultSnapshot.kubernetesDefaults);
const [trueNASDefaults, setTrueNASDefaults] = createSignal<Record<string, number | undefined>>(
defaultSnapshot.trueNASDefaults,
);
const [trueNASDiskDefaults, setTrueNASDiskDefaults] = createSignal<
Record<string, number | undefined>
>(defaultSnapshot.trueNASDiskDefaults);
const [vmwareDefaults, setVMwareDefaults] = createSignal<Record<string, number | undefined>>(
defaultSnapshot.vmwareDefaults,
);
const [agentDefaults, setAgentDefaults] = createSignal<Record<string, number | undefined>>(
defaultSnapshot.agentDefaults,
);
@ -110,6 +121,11 @@ export function useAlertsConfigurationSnapshotState(
const [disableAllDockerContainers, setDisableAllDockerContainers] = createSignal(
defaultSnapshot.disableAllDockerContainers,
);
const [disableAllKubernetes, setDisableAllKubernetes] = createSignal(
defaultSnapshot.disableAllKubernetes,
);
const [disableAllTrueNAS, setDisableAllTrueNAS] = createSignal(defaultSnapshot.disableAllTrueNAS);
const [disableAllVMware, setDisableAllVMware] = createSignal(defaultSnapshot.disableAllVMware);
const [disableAllNodesOffline, setDisableAllNodesOffline] = createSignal(
defaultSnapshot.disableAllNodesOffline,
);
@ -147,6 +163,10 @@ export function useAlertsConfigurationSnapshotState(
setGuestPoweredOffSeverity(snapshot.guestPoweredOffSeverity);
setNodeDefaults({ ...snapshot.nodeDefaults });
setPBSDefaults({ ...snapshot.pbsDefaults });
setKubernetesDefaults({ ...snapshot.kubernetesDefaults });
setTrueNASDefaults({ ...snapshot.trueNASDefaults });
setTrueNASDiskDefaults({ ...snapshot.trueNASDiskDefaults });
setVMwareDefaults({ ...snapshot.vmwareDefaults });
setAgentDefaults({ ...snapshot.agentDefaults });
setDockerDefaults({ ...snapshot.dockerDefaults });
setDockerDisableConnectivity(snapshot.dockerDisableConnectivity);
@ -173,6 +193,9 @@ export function useAlertsConfigurationSnapshotState(
setDisableAllDockerHosts(snapshot.disableAllDockerHosts);
setDisableAllDockerServices(snapshot.disableAllDockerServices);
setDisableAllDockerContainers(snapshot.disableAllDockerContainers);
setDisableAllKubernetes(snapshot.disableAllKubernetes);
setDisableAllTrueNAS(snapshot.disableAllTrueNAS);
setDisableAllVMware(snapshot.disableAllVMware);
setDisableAllNodesOffline(snapshot.disableAllNodesOffline);
setDisableAllGuestsOffline(snapshot.disableAllGuestsOffline);
setDisableAllAgentsOffline(snapshot.disableAllAgentsOffline);
@ -199,6 +222,10 @@ export function useAlertsConfigurationSnapshotState(
guestPoweredOffSeverity: guestPoweredOffSeverity(),
nodeDefaults: { ...nodeDefaults() },
pbsDefaults: { ...pbsDefaults() },
kubernetesDefaults: { ...kubernetesDefaults() },
trueNASDefaults: { ...trueNASDefaults() },
trueNASDiskDefaults: { ...trueNASDiskDefaults() },
vmwareDefaults: { ...vmwareDefaults() },
agentDefaults: { ...agentDefaults() },
dockerDefaults: { ...dockerDefaults() },
dockerDisableConnectivity: dockerDisableConnectivity(),
@ -225,6 +252,9 @@ export function useAlertsConfigurationSnapshotState(
disableAllDockerHosts: disableAllDockerHosts(),
disableAllDockerServices: disableAllDockerServices(),
disableAllDockerContainers: disableAllDockerContainers(),
disableAllKubernetes: disableAllKubernetes(),
disableAllTrueNAS: disableAllTrueNAS(),
disableAllVMware: disableAllVMware(),
disableAllNodesOffline: disableAllNodesOffline(),
disableAllGuestsOffline: disableAllGuestsOffline(),
disableAllAgentsOffline: disableAllAgentsOffline(),
@ -249,6 +279,22 @@ export function useAlertsConfigurationSnapshotState(
setPBSDefaults({ ...FACTORY_PBS_DEFAULTS });
markUnsaved();
};
const resetKubernetesDefaults = () => {
setKubernetesDefaults({ ...FACTORY_KUBERNETES_DEFAULTS });
markUnsaved();
};
const resetTrueNASDefaults = () => {
setTrueNASDefaults({ ...FACTORY_TRUENAS_DEFAULTS });
markUnsaved();
};
const resetTrueNASDiskDefaults = () => {
setTrueNASDiskDefaults({ ...FACTORY_TRUENAS_DISK_DEFAULTS });
markUnsaved();
};
const resetVMwareDefaults = () => {
setVMwareDefaults({ ...FACTORY_VMWARE_DEFAULTS });
markUnsaved();
};
const resetAgentDefaults = () => {
setAgentDefaults({ ...FACTORY_AGENT_DEFAULTS });
markUnsaved();
@ -297,6 +343,14 @@ export function useAlertsConfigurationSnapshotState(
setNodeDefaults,
pbsDefaults,
setPBSDefaults,
kubernetesDefaults,
setKubernetesDefaults,
trueNASDefaults,
setTrueNASDefaults,
trueNASDiskDefaults,
setTrueNASDiskDefaults,
vmwareDefaults,
setVMwareDefaults,
agentDefaults,
setAgentDefaults,
dockerDefaults,
@ -343,6 +397,12 @@ export function useAlertsConfigurationSnapshotState(
setDisableAllDockerServices,
disableAllDockerContainers,
setDisableAllDockerContainers,
disableAllKubernetes,
setDisableAllKubernetes,
disableAllTrueNAS,
setDisableAllTrueNAS,
disableAllVMware,
setDisableAllVMware,
disableAllNodesOffline,
setDisableAllNodesOffline,
disableAllGuestsOffline,
@ -360,6 +420,10 @@ export function useAlertsConfigurationSnapshotState(
resetGuestDefaults,
resetNodeDefaults,
resetPBSDefaults,
resetKubernetesDefaults,
resetTrueNASDefaults,
resetTrueNASDiskDefaults,
resetVMwareDefaults,
resetAgentDefaults,
resetDockerDefaults,
resetDockerIgnoredPrefixes,
@ -369,6 +433,10 @@ export function useAlertsConfigurationSnapshotState(
factoryGuestDefaults: FACTORY_GUEST_DEFAULTS,
factoryNodeDefaults: FACTORY_NODE_DEFAULTS,
factoryPBSDefaults: FACTORY_PBS_DEFAULTS,
factoryKubernetesDefaults: FACTORY_KUBERNETES_DEFAULTS,
factoryTrueNASDefaults: FACTORY_TRUENAS_DEFAULTS,
factoryTrueNASDiskDefaults: FACTORY_TRUENAS_DISK_DEFAULTS,
factoryVMwareDefaults: FACTORY_VMWARE_DEFAULTS,
factoryAgentDefaults: FACTORY_AGENT_DEFAULTS,
factoryDockerDefaults: FACTORY_DOCKER_DEFAULTS,
factoryStorageDefault: FACTORY_STORAGE_DEFAULT,

View file

@ -15,6 +15,7 @@ export interface AlertThresholds {
networkOut?: HysteresisThreshold;
temperature?: HysteresisThreshold;
diskTemperature?: HysteresisThreshold;
usage?: HysteresisThreshold;
disableConnectivity?: boolean; // Disable connectivity/powered-off alerts
poweredOffSeverity?: 'warning' | 'critical';
// Allow indexing with string
@ -131,6 +132,10 @@ export interface AlertConfig {
guestTagBlacklist?: string[];
pmgDefaults?: PMGThresholdDefaults;
pbsDefaults?: AlertThresholds;
kubernetesDefaults?: AlertThresholds;
truenasDefaults?: AlertThresholds;
truenasDiskDefaults?: AlertThresholds;
vmwareDefaults?: AlertThresholds;
snapshotDefaults?: SnapshotAlertConfig;
backupDefaults?: BackupAlertConfig;
customRules?: CustomAlertRule[];
@ -144,6 +149,19 @@ export interface AlertConfig {
storage?: number;
pbs?: number;
agent?: number;
'k8s-cluster'?: number;
'k8s-node'?: number;
'k8s-deployment'?: number;
'k8s-namespace'?: number;
pod?: number;
'truenas-system'?: number;
'truenas-pool'?: number;
'truenas-dataset'?: number;
'truenas-disk'?: number;
'vmware-host'?: number;
'vmware-vm'?: number;
'vmware-datastore'?: number;
'vmware-network'?: number;
};
metricTimeThresholds?: Record<string, Record<string, number>>;
aggregation?: {
@ -217,6 +235,9 @@ export interface AlertConfig {
disableAllDockerHosts?: boolean;
disableAllDockerContainers?: boolean;
disableAllDockerServices?: boolean;
disableAllKubernetes?: boolean;
disableAllTrueNAS?: boolean;
disableAllVMware?: boolean;
disableAllNodesOffline?: boolean;
disableAllGuestsOffline?: boolean;
disableAllPBSOffline?: boolean;

View file

@ -10,13 +10,16 @@ describe('alertTargetTypes', () => {
it('canonicalizes supported aliases used for alert investigation targets', () => {
expect(canonicalizeAlertTargetType('host')).toBe('agent');
expect(canonicalizeAlertTargetType('docker-service')).toBe('app-container');
expect(canonicalizeAlertTargetType('kubernetes')).toBe('k8s-cluster');
expect(canonicalizeAlertTargetType('Kubernetes Namespace')).toBe('k8s-namespace');
expect(canonicalizeAlertTargetType('TrueNAS Dataset')).toBe('truenas-dataset');
expect(canonicalizeAlertTargetType('TrueNAS Disk')).toBe('physical_disk');
expect(canonicalizeAlertTargetType('vSphere Host')).toBe('vmware-host');
expect(canonicalizeAlertTargetType('VMware Virtual Machine')).toBe('vmware-vm');
expect(canonicalizeAlertTargetType('vSphere Datastore')).toBe('vmware-datastore');
expect(canonicalizeAlertTargetType('VMware Network')).toBe('vmware-network');
expect(canonicalizeAlertTargetType('lxc')).toBeUndefined();
});
it('intentionally leaves k8s generic aliases unresolved at this layer', () => {
expect(canonicalizeAlertTargetType('k8s')).toBeUndefined();
expect(canonicalizeAlertTargetType('kubernetes')).toBeUndefined();
});
});
describe('inferAlertTargetTypeFromResourceId', () => {
@ -26,6 +29,16 @@ describe('alertTargetTypes', () => {
expect(inferAlertTargetTypeFromResourceId('docker:abc')).toBe('app-container');
expect(inferAlertTargetTypeFromResourceId('node:host1')).toBe('agent');
expect(inferAlertTargetTypeFromResourceId('pod:ns/name')).toBe('pod');
expect(inferAlertTargetTypeFromResourceId('k8s:cluster-1:deployment:api')).toBe(
'k8s-deployment',
);
expect(inferAlertTargetTypeFromResourceId('truenas-dataset:tank/media')).toBe(
'truenas-dataset',
);
expect(inferAlertTargetTypeFromResourceId('vmware:vc-1:host:host-101')).toBe('vmware-host');
expect(inferAlertTargetTypeFromResourceId('vmware:vc-1:datastore:datastore-301')).toBe(
'vmware-datastore',
);
});
});
@ -62,6 +75,22 @@ describe('alertTargetTypes', () => {
}),
).toBe('pbs');
expect(
resolveAlertTargetType({
alertType: 'custom_alert',
metadataResourceType: 'TrueNAS Disk',
resourceId: 'disk:sdb',
}),
).toBe('physical_disk');
expect(
resolveAlertTargetType({
alertType: 'custom_alert',
metadataResourceType: 'vSphere VM',
resourceId: 'vmware:vc-1:vm:vm-201',
}),
).toBe('vmware-vm');
expect(resolveAlertTargetType({ alertType: 'custom_alert' })).toBe('agent');
});
});

View file

@ -10,6 +10,12 @@ import {
METRIC_THRESHOLDS,
type MetricType,
} from '@/utils/metricThresholds';
import {
FACTORY_KUBERNETES_DEFAULTS,
FACTORY_TRUENAS_DEFAULTS,
FACTORY_TRUENAS_DISK_DEFAULTS,
FACTORY_VMWARE_DEFAULTS,
} from '@/utils/alertThresholdDefaults';
import type { AlertConfig } from '@/types/alerts';
describe('metricThresholds', () => {
@ -237,5 +243,35 @@ describe('metricThresholds', () => {
critical: 92,
});
});
it('keeps Kubernetes, TrueNAS, and vSphere factory defaults explicit for alert configuration', () => {
expect(FACTORY_KUBERNETES_DEFAULTS).toEqual({
cpu: 80,
memory: 85,
disk: 90,
diskRead: -1,
diskWrite: -1,
networkIn: -1,
networkOut: -1,
});
expect(FACTORY_TRUENAS_DEFAULTS).toMatchObject({
cpu: 80,
memory: 85,
disk: 85,
usage: 85,
temperature: 80,
});
expect(FACTORY_TRUENAS_DISK_DEFAULTS).toEqual({ temperature: 55 });
expect(FACTORY_VMWARE_DEFAULTS).toEqual({
cpu: 80,
memory: 85,
disk: 90,
usage: 85,
diskRead: -1,
diskWrite: -1,
networkIn: -1,
networkOut: -1,
});
});
});
});

View file

@ -1,13 +1,47 @@
import { canonicalizeFrontendResourceType } from '@/utils/resourceTypeCompat';
const normalize = (value: string | null | undefined): string => (value || '').trim().toLowerCase();
const canonicalToken = (value: string): string => value.replace(/[\s_]+/g, '-');
const ALERT_TARGET_TYPE_ALIASES: Record<string, string> = {
k8s: 'k8s-cluster',
kubernetes: 'k8s-cluster',
'k8s-cluster': 'k8s-cluster',
'kubernetes-cluster': 'k8s-cluster',
'k8s-node': 'k8s-node',
'kubernetes-node': 'k8s-node',
'k8s-namespace': 'k8s-namespace',
'kubernetes-namespace': 'k8s-namespace',
'k8s-deployment': 'k8s-deployment',
'kubernetes-deployment': 'k8s-deployment',
'k8s-pod': 'pod',
'kubernetes-pod': 'pod',
truenas: 'truenas',
'truenas-system': 'truenas',
'truenas-pool': 'pool',
'truenas-dataset': 'truenas-dataset',
'truenas-disk': 'physical_disk',
vmware: 'vmware-vsphere',
vsphere: 'vmware-vsphere',
vmsphere: 'vmware-vsphere',
'vmware-host': 'vmware-host',
'vsphere-host': 'vmware-host',
'vmware-vm': 'vmware-vm',
'vsphere-vm': 'vmware-vm',
'vmware-virtual-machine': 'vmware-vm',
'vsphere-virtual-machine': 'vmware-vm',
'vmware-datastore': 'vmware-datastore',
'vsphere-datastore': 'vmware-datastore',
'vmware-network': 'vmware-network',
'vsphere-network': 'vmware-network',
};
export const canonicalizeAlertTargetType = (raw?: string): string | undefined => {
const normalized = normalize(raw);
if (!normalized) return undefined;
if (normalized === 'k8s' || normalized === 'kubernetes') {
return undefined;
}
const alias =
ALERT_TARGET_TYPE_ALIASES[normalized] ?? ALERT_TARGET_TYPE_ALIASES[canonicalToken(normalized)];
if (alias) return alias;
if (normalized === 'docker-container' || normalized === 'docker-service') {
return 'app-container';
}
@ -57,12 +91,50 @@ export const inferAlertTargetTypeFromResourceId = (resourceID?: string): string
return 'pmg';
}
if (
normalized.startsWith('k8s-') ||
normalized.startsWith('k8s-pod:') ||
normalized.startsWith('k8s-pod-') ||
normalized.startsWith('pod:') ||
normalized.includes(':pod:')
) {
return 'pod';
}
if (normalized.startsWith('k8s:')) {
if (normalized.includes(':deployment:')) return 'k8s-deployment';
if (normalized.includes(':namespace:')) return 'k8s-namespace';
if (normalized.includes(':node:')) return 'k8s-node';
return 'k8s-cluster';
}
if (normalized.startsWith('truenas-system:') || normalized.startsWith('system:truenas')) {
return 'truenas';
}
if (normalized.startsWith('truenas-pool:')) {
return 'pool';
}
if (normalized.startsWith('truenas-dataset:')) {
return 'truenas-dataset';
}
if (normalized.startsWith('truenas-disk:') || normalized.startsWith('physical-disk:')) {
return 'physical_disk';
}
if (normalized.startsWith('vmware-host:') || normalized.startsWith('vsphere-host:')) {
return 'vmware-host';
}
if (normalized.startsWith('vmware-vm:') || normalized.startsWith('vsphere-vm:')) {
return 'vmware-vm';
}
if (normalized.startsWith('vmware-datastore:') || normalized.startsWith('vsphere-datastore:')) {
return 'vmware-datastore';
}
if (normalized.startsWith('vmware-network:') || normalized.startsWith('vsphere-network:')) {
return 'vmware-network';
}
if (normalized.startsWith('vmware:') || normalized.startsWith('vsphere:')) {
if (normalized.includes(':host:')) return 'vmware-host';
if (normalized.includes(':vm:')) return 'vmware-vm';
if (normalized.includes(':datastore:')) return 'vmware-datastore';
if (normalized.includes(':network:')) return 'vmware-network';
return 'vmware-vsphere';
}
return undefined;
};

View file

@ -22,6 +22,43 @@ export const FACTORY_PBS_DEFAULTS = {
memory: 85,
};
export const FACTORY_KUBERNETES_DEFAULTS = {
cpu: 80,
memory: 85,
disk: 90,
diskRead: -1,
diskWrite: -1,
networkIn: -1,
networkOut: -1,
};
export const FACTORY_TRUENAS_DEFAULTS = {
cpu: 80,
memory: 85,
disk: 85,
usage: 85,
temperature: 80,
diskRead: -1,
diskWrite: -1,
networkIn: -1,
networkOut: -1,
};
export const FACTORY_TRUENAS_DISK_DEFAULTS = {
temperature: 55,
};
export const FACTORY_VMWARE_DEFAULTS = {
cpu: 80,
memory: 85,
disk: 90,
usage: 85,
diskRead: -1,
diskWrite: -1,
networkIn: -1,
networkOut: -1,
};
export const FACTORY_AGENT_DEFAULTS = {
cpu: 80,
memory: 85,

View file

@ -65,13 +65,76 @@ func CanonicalResourceTypeKeys(resourceType string) []string {
keys = addUnique(keys, "node")
case "k8s-cluster":
keys = addUnique(keys, "k8s-cluster")
keys = addUnique(keys, "guest")
keys = addUnique(keys, "node")
case "kubernetes cluster":
keys = addUnique(keys, "k8s-cluster")
keys = addUnique(keys, "node")
case "k8s-node":
keys = addUnique(keys, "k8s-node")
keys = addUnique(keys, "node")
case "kubernetes node":
keys = addUnique(keys, "k8s-node")
keys = addUnique(keys, "node")
case "k8s-deployment":
keys = addUnique(keys, "k8s-deployment")
keys = addUnique(keys, "guest")
case "kubernetes deployment":
keys = addUnique(keys, "k8s-deployment")
keys = addUnique(keys, "guest")
case "k8s-namespace":
keys = addUnique(keys, "k8s-namespace")
case "kubernetes namespace":
keys = addUnique(keys, "k8s-namespace")
case "pod":
keys = addUnique(keys, "pod")
keys = addUnique(keys, "guest")
case "kubernetes pod":
keys = addUnique(keys, "pod")
keys = addUnique(keys, "guest")
case "truenas-system":
keys = addUnique(keys, "truenas-system")
keys = addUnique(keys, "agent")
keys = addUnique(keys, "node")
case "truenas system":
keys = addUnique(keys, "truenas-system")
keys = addUnique(keys, "agent")
keys = addUnique(keys, "node")
case "truenas-pool":
keys = addUnique(keys, "truenas-pool")
keys = addUnique(keys, "storage")
case "truenas pool":
keys = addUnique(keys, "truenas-pool")
keys = addUnique(keys, "storage")
case "truenas-dataset":
keys = addUnique(keys, "truenas-dataset")
keys = addUnique(keys, "storage")
case "truenas dataset":
keys = addUnique(keys, "truenas-dataset")
keys = addUnique(keys, "storage")
case "truenas-disk":
keys = addUnique(keys, "truenas-disk")
keys = addUnique(keys, "physical_disk")
keys = addUnique(keys, "disk")
keys = addUnique(keys, "storage")
case "truenas disk":
keys = addUnique(keys, "truenas-disk")
keys = addUnique(keys, "physical_disk")
keys = addUnique(keys, "disk")
keys = addUnique(keys, "storage")
case "vmware-host":
keys = addUnique(keys, "vmware-host")
keys = addUnique(keys, "agent")
keys = addUnique(keys, "node")
case "vmware-vm":
keys = addUnique(keys, "vmware-vm")
keys = addUnique(keys, "vm")
keys = addUnique(keys, "guest")
case "vmware-datastore":
keys = addUnique(keys, "vmware-datastore")
keys = addUnique(keys, "storage")
case "vmware-network":
keys = addUnique(keys, "vmware-network")
keys = addUnique(keys, "network")
case "storage":
keys = addUnique(keys, "storage")
case "disk":
@ -112,5 +175,35 @@ func isUnsupportedLegacyAlertResourceType(typeKey string) bool {
}
func CanonicalAlertResourceType(resourceType string) string {
return strings.ToLower(strings.TrimSpace(resourceType))
typeKey := strings.ToLower(strings.TrimSpace(resourceType))
switch typeKey {
case "kubernetes cluster":
return "k8s-cluster"
case "kubernetes node":
return "k8s-node"
case "kubernetes deployment":
return "k8s-deployment"
case "kubernetes namespace":
return "k8s-namespace"
case "kubernetes pod":
return "pod"
case "truenas system":
return "truenas-system"
case "truenas pool":
return "truenas-pool"
case "truenas dataset":
return "truenas-dataset"
case "truenas disk":
return "truenas-disk"
case "vmware host", "vsphere host":
return "vmware-host"
case "vmware vm", "vsphere vm", "vmware virtual machine", "vsphere virtual machine":
return "vmware-vm"
case "vmware datastore", "vsphere datastore":
return "vmware-datastore"
case "vmware network", "vsphere network":
return "vmware-network"
default:
return typeKey
}
}

View file

@ -287,6 +287,66 @@ func NormalizeAgentDefaults(config *AlertConfig) {
NormalizeDiskTempByType(config)
}
func normalizeThresholdPointer(
current *HysteresisThreshold,
defaultTrigger float64,
defaultClear float64,
metricName string,
) *HysteresisThreshold {
if current == nil || current.Trigger < 0 {
return &HysteresisThreshold{Trigger: defaultTrigger, Clear: defaultClear}
}
normalized := *current
if normalized.Trigger == 0 {
normalized.Clear = 0
return &normalized
}
if normalized.Clear <= 0 {
normalized.Clear = normalized.Trigger - 5
if normalized.Clear < 0 {
normalized.Clear = 0
}
}
EnsureValidHysteresis(&normalized, metricName)
return &normalized
}
func NormalizeKubernetesDefaults(config *AlertConfig) {
config.KubernetesDefaults.CPU = normalizeThresholdPointer(config.KubernetesDefaults.CPU, 80, 75, "kubernetes.cpu")
config.KubernetesDefaults.Memory = normalizeThresholdPointer(config.KubernetesDefaults.Memory, 85, 80, "kubernetes.memory")
config.KubernetesDefaults.Disk = normalizeThresholdPointer(config.KubernetesDefaults.Disk, 90, 85, "kubernetes.disk")
config.KubernetesDefaults.DiskRead = normalizeThresholdPointer(config.KubernetesDefaults.DiskRead, 0, 0, "kubernetes.diskRead")
config.KubernetesDefaults.DiskWrite = normalizeThresholdPointer(config.KubernetesDefaults.DiskWrite, 0, 0, "kubernetes.diskWrite")
config.KubernetesDefaults.NetworkIn = normalizeThresholdPointer(config.KubernetesDefaults.NetworkIn, 0, 0, "kubernetes.networkIn")
config.KubernetesDefaults.NetworkOut = normalizeThresholdPointer(config.KubernetesDefaults.NetworkOut, 0, 0, "kubernetes.networkOut")
}
func NormalizeTrueNASDefaults(config *AlertConfig) {
config.TrueNASDefaults.CPU = normalizeThresholdPointer(config.TrueNASDefaults.CPU, 80, 75, "truenas.cpu")
config.TrueNASDefaults.Memory = normalizeThresholdPointer(config.TrueNASDefaults.Memory, 85, 80, "truenas.memory")
config.TrueNASDefaults.Disk = normalizeThresholdPointer(config.TrueNASDefaults.Disk, 85, 80, "truenas.disk")
config.TrueNASDefaults.Usage = normalizeThresholdPointer(config.TrueNASDefaults.Usage, 85, 80, "truenas.usage")
config.TrueNASDefaults.Temperature = normalizeThresholdPointer(config.TrueNASDefaults.Temperature, 80, 75, "truenas.temperature")
config.TrueNASDefaults.DiskRead = normalizeThresholdPointer(config.TrueNASDefaults.DiskRead, 0, 0, "truenas.diskRead")
config.TrueNASDefaults.DiskWrite = normalizeThresholdPointer(config.TrueNASDefaults.DiskWrite, 0, 0, "truenas.diskWrite")
config.TrueNASDefaults.NetworkIn = normalizeThresholdPointer(config.TrueNASDefaults.NetworkIn, 0, 0, "truenas.networkIn")
config.TrueNASDefaults.NetworkOut = normalizeThresholdPointer(config.TrueNASDefaults.NetworkOut, 0, 0, "truenas.networkOut")
config.TrueNASDiskDefaults.Temperature = normalizeThresholdPointer(config.TrueNASDiskDefaults.Temperature, 55, 50, "truenas.disk.temperature")
}
func NormalizeVMwareDefaults(config *AlertConfig) {
config.VMwareDefaults.CPU = normalizeThresholdPointer(config.VMwareDefaults.CPU, 80, 75, "vmware.cpu")
config.VMwareDefaults.Memory = normalizeThresholdPointer(config.VMwareDefaults.Memory, 85, 80, "vmware.memory")
config.VMwareDefaults.Disk = normalizeThresholdPointer(config.VMwareDefaults.Disk, 90, 85, "vmware.disk")
config.VMwareDefaults.Usage = normalizeThresholdPointer(config.VMwareDefaults.Usage, 85, 80, "vmware.usage")
config.VMwareDefaults.DiskRead = normalizeThresholdPointer(config.VMwareDefaults.DiskRead, 0, 0, "vmware.diskRead")
config.VMwareDefaults.DiskWrite = normalizeThresholdPointer(config.VMwareDefaults.DiskWrite, 0, 0, "vmware.diskWrite")
config.VMwareDefaults.NetworkIn = normalizeThresholdPointer(config.VMwareDefaults.NetworkIn, 0, 0, "vmware.networkIn")
config.VMwareDefaults.NetworkOut = normalizeThresholdPointer(config.VMwareDefaults.NetworkOut, 0, 0, "vmware.networkOut")
}
// diskFillByTypeDefaults returns the canonical per-type fill-% defaults.
// Keys are lowercase hardware type strings.
func diskFillByTypeDefaults() map[string]HysteresisThreshold {
@ -434,6 +494,19 @@ func NormalizeTimeThresholds(config *AlertConfig) {
ensureDelay("storage")
ensureDelay("pbs")
ensureDelay("agent")
ensureDelay("k8s-cluster")
ensureDelay("k8s-node")
ensureDelay("k8s-deployment")
ensureDelay("k8s-namespace")
ensureDelay("pod")
ensureDelay("truenas-system")
ensureDelay("truenas-pool")
ensureDelay("truenas-dataset")
ensureDelay("truenas-disk")
ensureDelay("vmware-host")
ensureDelay("vmware-vm")
ensureDelay("vmware-datastore")
ensureDelay("vmware-network")
if delay, ok := config.TimeThresholds["all"]; ok && delay < 0 {
config.TimeThresholds["all"] = defaultDelaySeconds
}
@ -447,6 +520,19 @@ func ValidateHysteresisThresholds(config *AlertConfig) {
EnsureValidHysteresis(config.NodeDefaults.Memory, "node.memory")
EnsureValidHysteresis(config.NodeDefaults.Temperature, "node.temperature")
EnsureValidHysteresis(&config.StorageDefault, "storage")
EnsureValidHysteresis(config.KubernetesDefaults.CPU, "kubernetes.cpu")
EnsureValidHysteresis(config.KubernetesDefaults.Memory, "kubernetes.memory")
EnsureValidHysteresis(config.KubernetesDefaults.Disk, "kubernetes.disk")
EnsureValidHysteresis(config.TrueNASDefaults.CPU, "truenas.cpu")
EnsureValidHysteresis(config.TrueNASDefaults.Memory, "truenas.memory")
EnsureValidHysteresis(config.TrueNASDefaults.Disk, "truenas.disk")
EnsureValidHysteresis(config.TrueNASDefaults.Usage, "truenas.usage")
EnsureValidHysteresis(config.TrueNASDefaults.Temperature, "truenas.temperature")
EnsureValidHysteresis(config.TrueNASDiskDefaults.Temperature, "truenas.disk.temperature")
EnsureValidHysteresis(config.VMwareDefaults.CPU, "vmware.cpu")
EnsureValidHysteresis(config.VMwareDefaults.Memory, "vmware.memory")
EnsureValidHysteresis(config.VMwareDefaults.Disk, "vmware.disk")
EnsureValidHysteresis(config.VMwareDefaults.Usage, "vmware.usage")
}
func ValidateQuietHoursTimezone(config *AlertConfig) {

View file

@ -230,6 +230,10 @@ type AlertConfig struct {
GuestTagBlacklist []string `json:"guestTagBlacklist,omitempty"`
PMGDefaults PMGThresholdConfig `json:"pmgDefaults"`
PBSDefaults ThresholdConfig `json:"pbsDefaults"`
KubernetesDefaults ThresholdConfig `json:"kubernetesDefaults"`
TrueNASDefaults ThresholdConfig `json:"truenasDefaults"`
TrueNASDiskDefaults ThresholdConfig `json:"truenasDiskDefaults"`
VMwareDefaults ThresholdConfig `json:"vmwareDefaults"`
SnapshotDefaults SnapshotAlertConfig `json:"snapshotDefaults"`
BackupDefaults BackupAlertConfig `json:"backupDefaults"`
Overrides map[string]ThresholdConfig `json:"overrides"` // keyed by resource ID
@ -244,6 +248,9 @@ type AlertConfig struct {
DisableAllDockerHosts bool `json:"disableAllDockerHosts"` // Disable all alerts for Docker hosts
DisableAllDockerContainers bool `json:"disableAllDockerContainers"` // Disable all alerts for Docker containers
DisableAllDockerServices bool `json:"disableAllDockerServices"` // Disable all alerts for Docker services
DisableAllKubernetes bool `json:"disableAllKubernetes"` // Disable all alerts for Kubernetes resources
DisableAllTrueNAS bool `json:"disableAllTrueNAS"` // Disable all alerts for TrueNAS resources
DisableAllVMware bool `json:"disableAllVMware"` // Disable all alerts for VMware vSphere resources
DisableAllNodesOffline bool `json:"disableAllNodesOffline"` // Disable node offline/connectivity alerts globally
DisableAllGuestsOffline bool `json:"disableAllGuestsOffline"` // Disable guest powered-off alerts globally
DisableAllAgentsOffline bool `json:"disableAllAgentsOffline"` // Disable agent offline alerts globally

View file

@ -78,8 +78,13 @@ func normalizeSnapshotDefaults(config *AlertConfig) { alertconfig.NormalizeSnaps
func normalizeBackupDefaults(config *AlertConfig) { alertconfig.NormalizeBackupDefaults(config) }
func normalizeNodeDefaults(config *AlertConfig) { alertconfig.NormalizeNodeDefaults(config) }
func normalizeAgentDefaults(config *AlertConfig) { alertconfig.NormalizeAgentDefaults(config) }
func normalizeGeneralSettings(config *AlertConfig) { alertconfig.NormalizeGeneralSettings(config) }
func normalizeTimeThresholds(config *AlertConfig) { alertconfig.NormalizeTimeThresholds(config) }
func normalizeKubernetesDefaults(config *AlertConfig) {
alertconfig.NormalizeKubernetesDefaults(config)
}
func normalizeTrueNASDefaults(config *AlertConfig) { alertconfig.NormalizeTrueNASDefaults(config) }
func normalizeVMwareDefaults(config *AlertConfig) { alertconfig.NormalizeVMwareDefaults(config) }
func normalizeGeneralSettings(config *AlertConfig) { alertconfig.NormalizeGeneralSettings(config) }
func normalizeTimeThresholds(config *AlertConfig) { alertconfig.NormalizeTimeThresholds(config) }
func validateHysteresisThresholds(config *AlertConfig) {
alertconfig.ValidateHysteresisThresholds(config)

View file

@ -32,6 +32,9 @@ func (m *Manager) UpdateConfig(config AlertConfig) {
alertconfig.NormalizeBackupDefaults(&config)
alertconfig.NormalizeNodeDefaults(&config)
alertconfig.NormalizeAgentDefaults(&config)
alertconfig.NormalizeKubernetesDefaults(&config)
alertconfig.NormalizeTrueNASDefaults(&config)
alertconfig.NormalizeVMwareDefaults(&config)
alertconfig.NormalizeGeneralSettings(&config)
alertconfig.NormalizeTimeThresholds(&config)
@ -229,6 +232,42 @@ func (m *Manager) applyGlobalOfflineSettingsLocked() {
m.clearAlertNoLock(alertID)
}
}
if m.config.DisableAllKubernetes || m.config.DisableAllTrueNAS || m.config.DisableAllVMware {
var platformAlerts []string
for storageKey, alert := range m.activeAlerts {
primaryType := alertPrimaryResourceType(alert)
if primaryType == "" {
continue
}
switch {
case m.config.DisableAllKubernetes && isUnifiedKubernetesAlertType(primaryType):
platformAlerts = append(platformAlerts, effectiveAlertID(alert, storageKey))
case m.config.DisableAllTrueNAS && isUnifiedTrueNASAlertType(primaryType):
platformAlerts = append(platformAlerts, effectiveAlertID(alert, storageKey))
case m.config.DisableAllVMware && isUnifiedVMwareAlertType(primaryType):
platformAlerts = append(platformAlerts, effectiveAlertID(alert, storageKey))
}
}
for _, alertID := range platformAlerts {
m.clearAlertNoLock(alertID)
}
}
}
func alertPrimaryResourceType(alert *Alert) string {
if alert == nil || alert.Metadata == nil {
return ""
}
metaType, ok := alert.Metadata["resourceType"].(string)
if !ok {
return ""
}
keys := CanonicalResourceTypeKeys(metaType)
if len(keys) == 0 {
return ""
}
return keys[0]
}
// reevaluateActiveAlertsLocked re-evaluates all active alerts against the current configuration.
@ -269,6 +308,11 @@ func (m *Manager) reevaluateActiveAlertsLocked() {
resourceTypeMeta = alertconfig.CanonicalAlertResourceType(metaType)
}
}
resourceTypeKeys := CanonicalResourceTypeKeys(resourceTypeMeta)
primaryResourceType := ""
if len(resourceTypeKeys) > 0 {
primaryResourceType = resourceTypeKeys[0]
}
if alert.Type == "queue-depth" || alert.Type == "queue-deferred" || alert.Type == "queue-hold" || alert.Type == "message-age" {
if m.config.DisableAllPMG {
@ -277,14 +321,29 @@ func (m *Manager) reevaluateActiveAlertsLocked() {
}
}
handledModernPlatformType := false
if isUnifiedModernPlatformAlertType(primaryResourceType) {
handledModernPlatformType = true
if m.unifiedPlatformAlertsDisabledNoLock(primaryResourceType) {
alertsToResolve = append(alertsToResolve, alertID)
continue
}
thresholds := m.resolveResourceThresholds(primaryResourceType, resourceID)
if thresholds.Disabled {
alertsToResolve = append(alertsToResolve, alertID)
continue
}
threshold = getThresholdForMetric(thresholds, metricType)
}
isAgentResource := false
for _, key := range CanonicalResourceTypeKeys(resourceTypeMeta) {
for _, key := range resourceTypeKeys {
if key == "agent" {
isAgentResource = true
break
}
}
if isAgentResource {
if !handledModernPlatformType && isAgentResource {
if m.config.DisableAllAgents {
alertsToResolve = append(alertsToResolve, alertID)
continue
@ -347,7 +406,7 @@ func (m *Manager) reevaluateActiveAlertsLocked() {
threshold = getThresholdForMetric(thresholds, metricType)
}
if threshold == nil && !strings.Contains(resourceID, ":") && (alert.Instance == "Node" || alert.Instance == alert.Node) {
if threshold == nil && !handledModernPlatformType && !strings.Contains(resourceID, ":") && (alert.Instance == "Node" || alert.Instance == alert.Node) {
if m.config.DisableAllNodes {
alertsToResolve = append(alertsToResolve, alertID)
continue
@ -358,7 +417,7 @@ func (m *Manager) reevaluateActiveAlertsLocked() {
continue
}
threshold = getThresholdForMetric(thresholds, metricType)
} else if threshold == nil && (alert.Instance == "Storage" || strings.Contains(alert.ResourceID, ":storage/")) {
} else if threshold == nil && !handledModernPlatformType && (alert.Instance == "Storage" || strings.Contains(alert.ResourceID, ":storage/")) {
if m.config.DisableAllStorage {
alertsToResolve = append(alertsToResolve, alertID)
continue
@ -369,7 +428,7 @@ func (m *Manager) reevaluateActiveAlertsLocked() {
continue
}
threshold = getThresholdForMetric(thresholds, metricType)
} else if threshold == nil && (resourceTypeMeta == "pbs" || alert.Instance == "PBS") {
} else if threshold == nil && !handledModernPlatformType && (resourceTypeMeta == "pbs" || alert.Instance == "PBS") {
if m.config.DisableAllPBS {
alertsToResolve = append(alertsToResolve, alertID)
continue
@ -382,7 +441,7 @@ func (m *Manager) reevaluateActiveAlertsLocked() {
threshold = getThresholdForMetric(thresholds, metricType)
}
if threshold == nil {
if threshold == nil && !handledModernPlatformType {
if m.config.DisableAllGuests {
alertsToResolve = append(alertsToResolve, alertID)
continue

View file

@ -76,6 +76,39 @@ func defaultAlertConfig() AlertConfig {
CPU: &HysteresisThreshold{Trigger: 80, Clear: 75},
Memory: &HysteresisThreshold{Trigger: 85, Clear: 80},
},
KubernetesDefaults: ThresholdConfig{
CPU: &HysteresisThreshold{Trigger: 80, Clear: 75},
Memory: &HysteresisThreshold{Trigger: 85, Clear: 80},
Disk: &HysteresisThreshold{Trigger: 90, Clear: 85},
DiskRead: &HysteresisThreshold{Trigger: 0, Clear: 0},
DiskWrite: &HysteresisThreshold{Trigger: 0, Clear: 0},
NetworkIn: &HysteresisThreshold{Trigger: 0, Clear: 0},
NetworkOut: &HysteresisThreshold{Trigger: 0, Clear: 0},
},
TrueNASDefaults: ThresholdConfig{
CPU: &HysteresisThreshold{Trigger: 80, Clear: 75},
Memory: &HysteresisThreshold{Trigger: 85, Clear: 80},
Disk: &HysteresisThreshold{Trigger: 85, Clear: 80},
Usage: &HysteresisThreshold{Trigger: 85, Clear: 80},
Temperature: &HysteresisThreshold{Trigger: 80, Clear: 75},
DiskRead: &HysteresisThreshold{Trigger: 0, Clear: 0},
DiskWrite: &HysteresisThreshold{Trigger: 0, Clear: 0},
NetworkIn: &HysteresisThreshold{Trigger: 0, Clear: 0},
NetworkOut: &HysteresisThreshold{Trigger: 0, Clear: 0},
},
TrueNASDiskDefaults: ThresholdConfig{
Temperature: &HysteresisThreshold{Trigger: 55, Clear: 50},
},
VMwareDefaults: ThresholdConfig{
CPU: &HysteresisThreshold{Trigger: 80, Clear: 75},
Memory: &HysteresisThreshold{Trigger: 85, Clear: 80},
Disk: &HysteresisThreshold{Trigger: 90, Clear: 85},
Usage: &HysteresisThreshold{Trigger: 85, Clear: 80},
DiskRead: &HysteresisThreshold{Trigger: 0, Clear: 0},
DiskWrite: &HysteresisThreshold{Trigger: 0, Clear: 0},
NetworkIn: &HysteresisThreshold{Trigger: 0, Clear: 0},
NetworkOut: &HysteresisThreshold{Trigger: 0, Clear: 0},
},
StorageDefault: HysteresisThreshold{Trigger: 85, Clear: 80},
DiskFillByType: map[string]HysteresisThreshold{
"nvme": {Trigger: 92, Clear: 87},
@ -91,11 +124,24 @@ func defaultAlertConfig() AlertConfig {
SuppressionWindow: 5, // 5 minutes
HysteresisMargin: 5.0, // 5% default margin
TimeThresholds: map[string]int{
"guest": 5,
"node": 5,
"agent": 5,
"storage": 5,
"pbs": 5,
"guest": 5,
"node": 5,
"agent": 5,
"storage": 5,
"pbs": 5,
"k8s-cluster": 5,
"k8s-node": 5,
"k8s-deployment": 5,
"k8s-namespace": 5,
"pod": 5,
"truenas-system": 5,
"truenas-pool": 5,
"truenas-dataset": 5,
"truenas-disk": 5,
"vmware-host": 5,
"vmware-vm": 5,
"vmware-datastore": 5,
"vmware-network": 5,
},
Overrides: make(map[string]ThresholdConfig),
Schedule: ScheduleConfig{

View file

@ -1108,7 +1108,9 @@ func isKnownResourceType(rt unifiedresources.ResourceType) bool {
unifiedresources.ResourceTypeK8sNode,
unifiedresources.ResourceTypePod,
unifiedresources.ResourceTypeK8sDeployment,
unifiedresources.ResourceTypeK8sNamespace,
unifiedresources.ResourceTypeStorage,
unifiedresources.ResourceTypeNetwork,
unifiedresources.ResourceTypePBS,
unifiedresources.ResourceTypePMG,
unifiedresources.ResourceTypeCeph,

View file

@ -30,6 +30,64 @@ func TestResourceAlertSpecValidateAcceptsSupportedKinds(t *testing.T) {
},
},
},
{
name: "kubernetes pod metric threshold",
spec: ResourceAlertSpec{
ID: "k8s-prod-default-api-cpu-high",
ResourceID: "k8s:prod/ns:default/pod:api-7d9f",
ResourceType: unifiedresources.ResourceTypePod,
Kind: AlertSpecKindMetricThreshold,
Severity: AlertSeverityWarning,
MetricThreshold: &MetricThresholdSpec{
Metric: "cpu",
Direction: ThresholdDirectionAbove,
Trigger: 80,
},
},
},
{
name: "kubernetes namespace provider incident",
spec: ResourceAlertSpec{
ID: "k8s-prod-default-incident",
ResourceID: "k8s:prod/ns:default",
ResourceType: unifiedresources.ResourceTypeK8sNamespace,
Kind: AlertSpecKindProviderIncident,
Severity: AlertSeverityWarning,
ProviderIncident: &ProviderIncidentSpec{
Provider: "kubernetes",
Codes: []string{"namespace_quota_pressure"},
},
},
},
{
name: "truenas physical disk metric threshold",
spec: ResourceAlertSpec{
ID: "truenas-main-ada0-temperature-high",
ResourceID: "physical-disk:truenas-main/ada0",
ResourceType: unifiedresources.ResourceTypePhysicalDisk,
Kind: AlertSpecKindMetricThreshold,
Severity: AlertSeverityWarning,
MetricThreshold: &MetricThresholdSpec{
Metric: "temperature",
Direction: ThresholdDirectionAbove,
Trigger: 55,
},
},
},
{
name: "vmware network provider incident",
spec: ResourceAlertSpec{
ID: "vmware-vc1-network-alarm",
ResourceID: "vmware:vc-1:network:network-401",
ResourceType: unifiedresources.ResourceTypeNetwork,
Kind: AlertSpecKindProviderIncident,
Severity: AlertSeverityWarning,
ProviderIncident: &ProviderIncidentSpec{
Provider: "vmware",
Codes: []string{"vmware_alarm_state"},
},
},
},
{
name: "severity threshold",
spec: ResourceAlertSpec{

View file

@ -705,3 +705,98 @@ func TestReevaluateActiveAlertsUsesPBSResourceTypeMetadata(t *testing.T) {
t.Fatalf("expected PBS metric alert to resolve when PBS memory threshold is disabled")
}
}
func TestReevaluateActiveAlertsUsesKubernetesResourceTypeMetadata(t *testing.T) {
m := newTestManager(t)
resourceID := "k8s:prod/ns:default/pod:api-7d9f"
m.mu.Lock()
m.config.Enabled = true
m.config.KubernetesDefaults = ThresholdConfig{
CPU: &HysteresisThreshold{Trigger: 0, Clear: 0},
}
state, alert := testNewCanonicalAlert(resourceID, canonicalMetricSpecID(resourceID, "cpu"), string(alertspecs.AlertSpecKindMetricThreshold), "cpu")
alert.Value = 88
alert.Threshold = 80
alert.ResourceName = "api-7d9f"
alert.Node = "worker-1"
alert.Instance = "prod"
alert.Metadata = map[string]interface{}{
"resourceType": "Kubernetes Pod",
}
m.setActiveAlertNoLock(state, alert)
m.reevaluateActiveAlertsLocked()
m.mu.Unlock()
m.mu.RLock()
_, exists := m.activeAlerts[state]
m.mu.RUnlock()
if exists {
t.Fatalf("expected Kubernetes metric alert to resolve when Kubernetes CPU threshold is disabled")
}
}
func TestReevaluateActiveAlertsUsesTrueNASResourceTypeMetadata(t *testing.T) {
m := newTestManager(t)
resourceID := "storage:truenas-main/pool:tank"
m.mu.Lock()
m.config.Enabled = true
m.config.StorageDefault = HysteresisThreshold{Trigger: 95, Clear: 90}
m.config.TrueNASDefaults = ThresholdConfig{
Usage: &HysteresisThreshold{Trigger: 0, Clear: 0},
}
state, alert := testNewCanonicalAlert(resourceID, canonicalMetricSpecID(resourceID, "usage"), string(alertspecs.AlertSpecKindMetricThreshold), "usage")
alert.Value = 90
alert.Threshold = 85
alert.ResourceName = "tank"
alert.Node = "truenas-main"
alert.Instance = "TrueNAS"
alert.Metadata = map[string]interface{}{
"resourceType": "TrueNAS Pool",
}
m.setActiveAlertNoLock(state, alert)
m.reevaluateActiveAlertsLocked()
m.mu.Unlock()
m.mu.RLock()
_, exists := m.activeAlerts[state]
m.mu.RUnlock()
if exists {
t.Fatalf("expected TrueNAS metric alert to resolve when TrueNAS usage threshold is disabled")
}
}
func TestReevaluateActiveAlertsUsesVMwareResourceTypeMetadata(t *testing.T) {
m := newTestManager(t)
resourceID := "vmware:vc-1:datastore:datastore-301"
m.mu.Lock()
m.config.Enabled = true
m.config.StorageDefault = HysteresisThreshold{Trigger: 95, Clear: 90}
m.config.VMwareDefaults = ThresholdConfig{
Usage: &HysteresisThreshold{Trigger: 0, Clear: 0},
}
state, alert := testNewCanonicalAlert(resourceID, canonicalMetricSpecID(resourceID, "usage"), string(alertspecs.AlertSpecKindMetricThreshold), "usage")
alert.Value = 90
alert.Threshold = 85
alert.ResourceName = "nvme-primary"
alert.Node = "Lab Datacenter"
alert.Instance = "Lab vCenter"
alert.Metadata = map[string]interface{}{
"resourceType": "vSphere Datastore",
}
m.setActiveAlertNoLock(state, alert)
m.reevaluateActiveAlertsLocked()
m.mu.Unlock()
m.mu.RLock()
_, exists := m.activeAlerts[state]
m.mu.RUnlock()
if exists {
t.Fatalf("expected vSphere metric alert to resolve when vSphere usage threshold is disabled")
}
}

View file

@ -1,6 +1,8 @@
package alerts
import (
"strings"
alertspecs "github.com/rcourtman/pulse-go-rewrite/internal/alerts/specs"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
"github.com/rs/zerolog/log"
@ -19,6 +21,11 @@ import (
// PBS: pbs.ID e.g. "pbs-1"
// PMG: pmg.ID e.g. "pmg-1"
// Docker Container: "docker:{hostID}/{containerID}"
// Kubernetes: canonical unified resource ID for cluster/node/namespace/deployment/pod
// TrueNAS System: canonical unified agent resource ID
// TrueNAS Storage: canonical unified storage resource ID for pool/dataset
// TrueNAS Disk: canonical unified physical_disk resource ID
// VMware vSphere: canonical unified resource ID for host/vm/datastore/network
//
// For guests, CheckUnifiedResource expects the same canonical resource ID the
// monitoring pipeline already uses. It does not translate IDs.
@ -31,18 +38,19 @@ type UnifiedResourceMetric struct {
// UnifiedResourceInput is the data needed to evaluate alerts for a unified resource.
// This avoids importing unifiedresources (which would cause an import cycle).
type UnifiedResourceInput struct {
ID string
Type string // lowercase: "vm", "system-container", "app-container", "agent", "pbs", "storage", "pmg"
Name string
Node string
Instance string
CPU *UnifiedResourceMetric
Memory *UnifiedResourceMetric
Disk *UnifiedResourceMetric
DiskRead *UnifiedResourceMetric
DiskWrite *UnifiedResourceMetric
NetworkIn *UnifiedResourceMetric
NetworkOut *UnifiedResourceMetric
ID string
Type string // lowercase: "vm", "system-container", "app-container", "agent", "pbs", "storage", "pmg"
Name string
Node string
Instance string
CPU *UnifiedResourceMetric
Memory *UnifiedResourceMetric
Disk *UnifiedResourceMetric
DiskRead *UnifiedResourceMetric
DiskWrite *UnifiedResourceMetric
NetworkIn *UnifiedResourceMetric
NetworkOut *UnifiedResourceMetric
Temperature *UnifiedResourceMetric
}
type unifiedMetricCandidate struct {
@ -70,6 +78,32 @@ func unifiedAlertType(typeKey string) string {
return "Storage"
case "pmg":
return "PMG"
case "k8s-cluster":
return "Kubernetes Cluster"
case "k8s-node":
return "Kubernetes Node"
case "k8s-deployment":
return "Kubernetes Deployment"
case "k8s-namespace":
return "Kubernetes Namespace"
case "pod":
return "Kubernetes Pod"
case "truenas-system":
return "TrueNAS System"
case "truenas-pool":
return "TrueNAS Pool"
case "truenas-dataset":
return "TrueNAS Dataset"
case "truenas-disk":
return "TrueNAS Disk"
case "vmware-host":
return "vSphere Host"
case "vmware-vm":
return "vSphere VM"
case "vmware-datastore":
return "vSphere Datastore"
case "vmware-network":
return "vSphere Network"
default:
return typeKey
}
@ -85,6 +119,59 @@ func isUnifiedGuestType(typeKey string) bool {
}
}
func supportsUnifiedIOMetrics(typeKey string) bool {
switch typeKey {
case "vm", "system-container", "app-container", "k8s-cluster", "k8s-deployment", "pod", "truenas-system", "vmware-host", "vmware-vm":
return true
default:
return false
}
}
func isUnifiedKubernetesAlertType(typeKey string) bool {
switch typeKey {
case "k8s-cluster", "k8s-node", "k8s-deployment", "k8s-namespace", "pod":
return true
default:
return false
}
}
func isUnifiedTrueNASAlertType(typeKey string) bool {
switch typeKey {
case "truenas-system", "truenas-pool", "truenas-dataset", "truenas-disk":
return true
default:
return false
}
}
func isUnifiedVMwareAlertType(typeKey string) bool {
switch typeKey {
case "vmware-host", "vmware-vm", "vmware-datastore", "vmware-network":
return true
default:
return false
}
}
func isUnifiedModernPlatformAlertType(typeKey string) bool {
return isUnifiedKubernetesAlertType(typeKey) || isUnifiedTrueNASAlertType(typeKey) || isUnifiedVMwareAlertType(typeKey)
}
func (m *Manager) unifiedPlatformAlertsDisabledNoLock(typeKey string) bool {
switch {
case isUnifiedKubernetesAlertType(typeKey):
return m.config.DisableAllKubernetes
case isUnifiedTrueNASAlertType(typeKey):
return m.config.DisableAllTrueNAS
case isUnifiedVMwareAlertType(typeKey):
return m.config.DisableAllVMware
default:
return false
}
}
// defaultThresholdsForResourceType returns the default ThresholdConfig for a resource type key.
func (m *Manager) defaultThresholdsForResourceType(typeKey string) ThresholdConfig {
switch typeKey {
@ -98,6 +185,24 @@ func (m *Manager) defaultThresholdsForResourceType(typeKey string) ThresholdConf
return cloneThresholdConfig(m.config.PBSDefaults)
case "storage":
return ThresholdConfig{Usage: cloneThreshold(&m.config.StorageDefault)}
case "k8s-cluster", "k8s-node", "k8s-deployment", "pod":
return cloneThresholdConfig(m.config.KubernetesDefaults)
case "k8s-namespace":
return ThresholdConfig{}
case "truenas-system":
return cloneThresholdConfig(m.config.TrueNASDefaults)
case "truenas-pool", "truenas-dataset":
defaults := cloneThresholdConfig(m.config.TrueNASDefaults)
return ThresholdConfig{Usage: cloneThreshold(defaults.Usage)}
case "truenas-disk":
return cloneThresholdConfig(m.config.TrueNASDiskDefaults)
case "vmware-host", "vmware-vm":
return cloneThresholdConfig(m.config.VMwareDefaults)
case "vmware-datastore":
defaults := cloneThresholdConfig(m.config.VMwareDefaults)
return ThresholdConfig{Usage: cloneThreshold(defaults.Usage)}
case "vmware-network":
return ThresholdConfig{}
default:
return ThresholdConfig{}
}
@ -181,20 +286,31 @@ func buildUnifiedMetricCandidates(input *UnifiedResourceInput, thresholds Thresh
appendCandidate("memory", input.Memory, input.MemoryValue(), thresholds.Memory)
appendCandidate("disk", input.Disk, input.DiskValue(), thresholds.Disk)
if isUnifiedGuestType(input.Type) {
if supportsUnifiedIOMetrics(input.Type) {
appendCandidate("diskRead", input.DiskRead, input.DiskReadValue(), thresholds.DiskRead)
appendCandidate("diskWrite", input.DiskWrite, input.DiskWriteValue(), thresholds.DiskWrite)
appendCandidate("networkIn", input.NetworkIn, input.NetworkInValue(), thresholds.NetworkIn)
appendCandidate("networkOut", input.NetworkOut, input.NetworkOutValue(), thresholds.NetworkOut)
}
if input.Type == "storage" && input.Disk != nil {
if unifiedStorageUsageResourceType(input.Type) && input.Disk != nil {
appendCandidate("usage", input.Disk, input.DiskValue(), thresholds.Usage)
}
appendCandidate("temperature", input.Temperature, input.TemperatureValue(), thresholds.Temperature)
return candidates
}
func unifiedStorageUsageResourceType(typeKey string) bool {
switch typeKey {
case "storage", "truenas-pool", "truenas-dataset", "vmware-datastore":
return true
default:
return false
}
}
func buildCanonicalMetricSpec(resourceID, title string, resourceType unifiedresources.ResourceType, metricType string, threshold *HysteresisThreshold) (alertspecs.ResourceAlertSpec, error) {
spec := alertspecs.ResourceAlertSpec{
ID: canonicalMetricSpecID(resourceID, metricType),
@ -249,11 +365,238 @@ func unifiedMetricResourceType(typeKey string) (unifiedresources.ResourceType, b
return unifiedresources.ResourceTypeStorage, true
case "pmg":
return unifiedresources.ResourceTypePMG, true
case "k8s-cluster":
return unifiedresources.ResourceTypeK8sCluster, true
case "k8s-node":
return unifiedresources.ResourceTypeK8sNode, true
case "k8s-deployment":
return unifiedresources.ResourceTypeK8sDeployment, true
case "k8s-namespace":
return unifiedresources.ResourceTypeK8sNamespace, true
case "pod":
return unifiedresources.ResourceTypePod, true
case "truenas-system":
return unifiedresources.ResourceTypeAgent, true
case "truenas-pool", "truenas-dataset":
return unifiedresources.ResourceTypeStorage, true
case "truenas-disk":
return unifiedresources.ResourceTypePhysicalDisk, true
case "vmware-host":
return unifiedresources.ResourceTypeAgent, true
case "vmware-vm":
return unifiedresources.ResourceTypeVM, true
case "vmware-datastore":
return unifiedresources.ResourceTypeStorage, true
case "vmware-network":
return unifiedresources.ResourceTypeNetwork, true
default:
return "", false
}
}
func (m *Manager) CheckUnifiedResourceMetrics(resources []unifiedresources.Resource) {
if m == nil {
return
}
for _, resource := range resources {
input, ok := UnifiedResourceInputFromResource(resource)
if !ok {
continue
}
m.CheckUnifiedResource(input)
}
}
func UnifiedResourceInputFromResource(resource unifiedresources.Resource) (*UnifiedResourceInput, bool) {
typeKey, ok := unifiedAlertResourceType(resource)
if !ok {
return nil, false
}
resourceID := unifiedresources.CanonicalResourceID(resource.ID)
if strings.TrimSpace(resourceID) == "" {
return nil, false
}
input := &UnifiedResourceInput{
ID: resourceID,
Type: typeKey,
Name: unifiedResourceAlertName(resource),
Node: unifiedResourceAlertNode(resource),
Instance: unifiedResourceAlertInstance(resource, typeKey),
}
if metrics := resource.Metrics; metrics != nil {
input.CPU = unifiedResourceMetric(metrics.CPU)
input.Memory = unifiedResourceMetric(metrics.Memory)
input.Disk = unifiedResourceMetric(metrics.Disk)
input.DiskRead = unifiedResourceMetric(metrics.DiskRead)
input.DiskWrite = unifiedResourceMetric(metrics.DiskWrite)
input.NetworkIn = unifiedResourceMetric(metrics.NetIn)
input.NetworkOut = unifiedResourceMetric(metrics.NetOut)
}
if resource.Temperature != nil {
input.Temperature = &UnifiedResourceMetric{Value: *resource.Temperature, Percent: *resource.Temperature}
} else if resource.PhysicalDisk != nil && resource.PhysicalDisk.Temperature > 0 {
value := float64(resource.PhysicalDisk.Temperature)
input.Temperature = &UnifiedResourceMetric{Value: value, Percent: value}
}
return input, true
}
func unifiedAlertResourceType(resource unifiedresources.Resource) (string, bool) {
resourceType := unifiedresources.CanonicalResourceType(resource.Type)
switch resourceType {
case unifiedresources.ResourceTypeK8sCluster,
unifiedresources.ResourceTypeK8sNode,
unifiedresources.ResourceTypeK8sDeployment,
unifiedresources.ResourceTypeK8sNamespace,
unifiedresources.ResourceTypePod:
return string(resourceType), true
case unifiedresources.ResourceTypeAgent:
if resource.TrueNAS != nil || resourceHasSource(resource, unifiedresources.SourceTrueNAS) {
return "truenas-system", true
}
if resource.VMware != nil || resourceHasSource(resource, unifiedresources.SourceVMware) {
return "vmware-host", true
}
case unifiedresources.ResourceTypeVM:
if resource.VMware != nil || resourceHasSource(resource, unifiedresources.SourceVMware) {
return "vmware-vm", true
}
case unifiedresources.ResourceTypeStorage:
if isTrueNASStorageResource(resource) {
switch strings.ToLower(strings.TrimSpace(resource.Storage.Topology)) {
case "dataset":
return "truenas-dataset", true
default:
return "truenas-pool", true
}
}
if isVMwareDatastoreResource(resource) {
return "vmware-datastore", true
}
case unifiedresources.ResourceTypePhysicalDisk:
if resourceHasSource(resource, unifiedresources.SourceTrueNAS) || resource.TrueNAS != nil {
return "truenas-disk", true
}
case unifiedresources.ResourceTypeNetwork:
if resource.VMware != nil || resourceHasSource(resource, unifiedresources.SourceVMware) {
return "vmware-network", true
}
}
return "", false
}
func isTrueNASStorageResource(resource unifiedresources.Resource) bool {
if resource.TrueNAS != nil || resourceHasSource(resource, unifiedresources.SourceTrueNAS) {
return true
}
if resource.Storage == nil {
return false
}
return strings.EqualFold(strings.TrimSpace(resource.Storage.Platform), string(unifiedresources.SourceTrueNAS))
}
func isVMwareDatastoreResource(resource unifiedresources.Resource) bool {
if resource.VMware != nil || resourceHasSource(resource, unifiedresources.SourceVMware) {
return true
}
if resource.Storage == nil {
return false
}
platform := strings.TrimSpace(resource.Storage.Platform)
return strings.EqualFold(platform, "vmware-vsphere") || strings.EqualFold(platform, string(unifiedresources.SourceVMware))
}
func resourceHasSource(resource unifiedresources.Resource, source unifiedresources.DataSource) bool {
for _, candidate := range resource.Sources {
if candidate == source {
return true
}
}
if _, ok := resource.SourceStatus[source]; ok {
return true
}
return false
}
func unifiedResourceMetric(metric *unifiedresources.MetricValue) *UnifiedResourceMetric {
if metric == nil {
return nil
}
return &UnifiedResourceMetric{Value: metric.Value, Percent: metric.Percent}
}
func unifiedResourceAlertName(resource unifiedresources.Resource) string {
if resource.Canonical != nil && strings.TrimSpace(resource.Canonical.DisplayName) != "" {
return strings.TrimSpace(resource.Canonical.DisplayName)
}
if strings.TrimSpace(resource.Name) != "" {
return strings.TrimSpace(resource.Name)
}
return strings.TrimSpace(resource.ID)
}
func unifiedResourceAlertNode(resource unifiedresources.Resource) string {
if strings.TrimSpace(resource.ParentName) != "" {
return strings.TrimSpace(resource.ParentName)
}
if resource.Canonical != nil && strings.TrimSpace(resource.Canonical.Hostname) != "" {
return strings.TrimSpace(resource.Canonical.Hostname)
}
if resource.Kubernetes != nil {
if strings.TrimSpace(resource.Kubernetes.NodeName) != "" {
return strings.TrimSpace(resource.Kubernetes.NodeName)
}
if strings.TrimSpace(resource.Kubernetes.ClusterName) != "" {
return strings.TrimSpace(resource.Kubernetes.ClusterName)
}
}
if resource.TrueNAS != nil && strings.TrimSpace(resource.TrueNAS.Hostname) != "" {
return strings.TrimSpace(resource.TrueNAS.Hostname)
}
if resource.VMware != nil {
if strings.TrimSpace(resource.VMware.RuntimeHostName) != "" {
return strings.TrimSpace(resource.VMware.RuntimeHostName)
}
if strings.TrimSpace(resource.VMware.ClusterName) != "" {
return strings.TrimSpace(resource.VMware.ClusterName)
}
if strings.TrimSpace(resource.VMware.DatacenterName) != "" {
return strings.TrimSpace(resource.VMware.DatacenterName)
}
if strings.TrimSpace(resource.VMware.VCenterHost) != "" {
return strings.TrimSpace(resource.VMware.VCenterHost)
}
}
return unifiedResourceAlertName(resource)
}
func unifiedResourceAlertInstance(resource unifiedresources.Resource, typeKey string) string {
if resource.Kubernetes != nil {
if strings.TrimSpace(resource.Kubernetes.ClusterName) != "" {
return strings.TrimSpace(resource.Kubernetes.ClusterName)
}
return "Kubernetes"
}
if strings.HasPrefix(typeKey, "truenas-") {
return "TrueNAS"
}
if strings.HasPrefix(typeKey, "vmware-") {
if resource.VMware != nil {
if strings.TrimSpace(resource.VMware.ConnectionName) != "" {
return strings.TrimSpace(resource.VMware.ConnectionName)
}
if strings.TrimSpace(resource.VMware.VCenterHost) != "" {
return strings.TrimSpace(resource.VMware.VCenterHost)
}
}
return "vSphere"
}
return ""
}
func mergeMetricOptions(base *metricOptions, extra map[string]interface{}) *metricOptions {
if len(extra) == 0 {
return base
@ -329,6 +672,13 @@ func (i *UnifiedResourceInput) NetworkOutValue() float64 {
return i.NetworkOut.Value
}
func (i *UnifiedResourceInput) TemperatureValue() float64 {
if i == nil || i.Temperature == nil {
return 0
}
return i.Temperature.Value
}
// CheckUnifiedResource evaluates threshold-based metric alerts for a unified resource.
// It resolves thresholds (defaults + overrides) and calls checkMetric for each
// available metric. Discrete event alerts (offline, RAID, backup age, etc.)
@ -343,6 +693,10 @@ func (m *Manager) CheckUnifiedResource(input *UnifiedResourceInput) {
m.mu.RUnlock()
return
}
if m.unifiedPlatformAlertsDisabledNoLock(input.Type) {
m.mu.RUnlock()
return
}
thresholds := m.resolveResourceThresholds(input.Type, input.ID)
m.mu.RUnlock()

View file

@ -33,6 +33,39 @@ func unifiedEvalBaseConfig() AlertConfig {
CPU: &HysteresisThreshold{Trigger: 80, Clear: 75},
Memory: &HysteresisThreshold{Trigger: 85, Clear: 80},
},
KubernetesDefaults: ThresholdConfig{
CPU: &HysteresisThreshold{Trigger: 80, Clear: 75},
Memory: &HysteresisThreshold{Trigger: 85, Clear: 80},
Disk: &HysteresisThreshold{Trigger: 90, Clear: 85},
DiskRead: &HysteresisThreshold{Trigger: 0, Clear: 0},
DiskWrite: &HysteresisThreshold{Trigger: 0, Clear: 0},
NetworkIn: &HysteresisThreshold{Trigger: 0, Clear: 0},
NetworkOut: &HysteresisThreshold{Trigger: 0, Clear: 0},
},
TrueNASDefaults: ThresholdConfig{
CPU: &HysteresisThreshold{Trigger: 80, Clear: 75},
Memory: &HysteresisThreshold{Trigger: 85, Clear: 80},
Disk: &HysteresisThreshold{Trigger: 85, Clear: 80},
Usage: &HysteresisThreshold{Trigger: 85, Clear: 80},
Temperature: &HysteresisThreshold{Trigger: 80, Clear: 75},
DiskRead: &HysteresisThreshold{Trigger: 0, Clear: 0},
DiskWrite: &HysteresisThreshold{Trigger: 0, Clear: 0},
NetworkIn: &HysteresisThreshold{Trigger: 0, Clear: 0},
NetworkOut: &HysteresisThreshold{Trigger: 0, Clear: 0},
},
TrueNASDiskDefaults: ThresholdConfig{
Temperature: &HysteresisThreshold{Trigger: 55, Clear: 50},
},
VMwareDefaults: ThresholdConfig{
CPU: &HysteresisThreshold{Trigger: 80, Clear: 75},
Memory: &HysteresisThreshold{Trigger: 85, Clear: 80},
Disk: &HysteresisThreshold{Trigger: 90, Clear: 85},
Usage: &HysteresisThreshold{Trigger: 85, Clear: 80},
DiskRead: &HysteresisThreshold{Trigger: 0, Clear: 0},
DiskWrite: &HysteresisThreshold{Trigger: 0, Clear: 0},
NetworkIn: &HysteresisThreshold{Trigger: 0, Clear: 0},
NetworkOut: &HysteresisThreshold{Trigger: 0, Clear: 0},
},
StorageDefault: HysteresisThreshold{Trigger: 85, Clear: 80},
Overrides: map[string]ThresholdConfig{},
@ -173,6 +206,411 @@ func TestCheckUnifiedResourceMajorFamilies(t *testing.T) {
}
}
func TestCheckUnifiedResourceSupportsKubernetesTrueNASAndVMwareMetricTargets(t *testing.T) {
m := newTestManager(t)
configureUnifiedEvalManager(t, m, unifiedEvalBaseConfig())
tests := []struct {
name string
alertID string
input *UnifiedResourceInput
wantResourceType string
}{
{
name: "Kubernetes cluster CPU",
alertID: canonicalMetricStateID("k8s:prod", "cpu"),
input: &UnifiedResourceInput{
ID: "k8s:prod",
Type: "k8s-cluster",
Name: "prod",
CPU: &UnifiedResourceMetric{Percent: 88},
},
wantResourceType: "Kubernetes Cluster",
},
{
name: "Kubernetes node memory",
alertID: canonicalMetricStateID("k8s:prod/node:worker-1", "memory"),
input: &UnifiedResourceInput{
ID: "k8s:prod/node:worker-1",
Type: "k8s-node",
Name: "worker-1",
Node: "worker-1",
Memory: &UnifiedResourceMetric{Percent: 90},
},
wantResourceType: "Kubernetes Node",
},
{
name: "Kubernetes deployment CPU",
alertID: canonicalMetricStateID("k8s:prod/ns:default/deployment:api", "cpu"),
input: &UnifiedResourceInput{
ID: "k8s:prod/ns:default/deployment:api",
Type: "k8s-deployment",
Name: "api",
Node: "prod",
Instance: "prod",
CPU: &UnifiedResourceMetric{Percent: 83},
},
wantResourceType: "Kubernetes Deployment",
},
{
name: "Kubernetes pod disk",
alertID: canonicalMetricStateID("k8s:prod/ns:default/pod:api-7d9f", "disk"),
input: &UnifiedResourceInput{
ID: "k8s:prod/ns:default/pod:api-7d9f",
Type: "pod",
Name: "api-7d9f",
Node: "worker-1",
Instance: "prod",
Disk: &UnifiedResourceMetric{Percent: 93},
},
wantResourceType: "Kubernetes Pod",
},
{
name: "TrueNAS system temperature",
alertID: canonicalMetricStateID("agent:truenas-main", "temperature"),
input: &UnifiedResourceInput{
ID: "agent:truenas-main",
Type: "truenas-system",
Name: "truenas-main",
Node: "truenas-main",
Temperature: &UnifiedResourceMetric{Value: 82, Percent: 82},
},
wantResourceType: "TrueNAS System",
},
{
name: "TrueNAS pool usage",
alertID: canonicalMetricStateID("storage:truenas-main/pool:tank", "usage"),
input: &UnifiedResourceInput{
ID: "storage:truenas-main/pool:tank",
Type: "truenas-pool",
Name: "tank",
Node: "truenas-main",
Instance: "TrueNAS",
Disk: &UnifiedResourceMetric{Percent: 89},
},
wantResourceType: "TrueNAS Pool",
},
{
name: "TrueNAS dataset usage",
alertID: canonicalMetricStateID("storage:truenas-main/dataset:tank/apps", "usage"),
input: &UnifiedResourceInput{
ID: "storage:truenas-main/dataset:tank/apps",
Type: "truenas-dataset",
Name: "tank/apps",
Node: "truenas-main",
Instance: "TrueNAS",
Disk: &UnifiedResourceMetric{Percent: 91},
},
wantResourceType: "TrueNAS Dataset",
},
{
name: "TrueNAS disk temperature",
alertID: canonicalMetricStateID("physical-disk:truenas-main/ada0", "temperature"),
input: &UnifiedResourceInput{
ID: "physical-disk:truenas-main/ada0",
Type: "truenas-disk",
Name: "ada0",
Node: "truenas-main",
Instance: "TrueNAS",
Temperature: &UnifiedResourceMetric{Value: 58, Percent: 58},
},
wantResourceType: "TrueNAS Disk",
},
{
name: "vSphere host CPU",
alertID: canonicalMetricStateID("vmware:vc-1:host:host-101", "cpu"),
input: &UnifiedResourceInput{
ID: "vmware:vc-1:host:host-101",
Type: "vmware-host",
Name: "esxi-01.lab.local",
Node: "Prod Compute",
Instance: "Lab vCenter",
CPU: &UnifiedResourceMetric{Percent: 88},
},
wantResourceType: "vSphere Host",
},
{
name: "vSphere VM disk",
alertID: canonicalMetricStateID("vmware:vc-1:vm:vm-201", "disk"),
input: &UnifiedResourceInput{
ID: "vmware:vc-1:vm:vm-201",
Type: "vmware-vm",
Name: "app-01",
Node: "esxi-01.lab.local",
Instance: "Lab vCenter",
Disk: &UnifiedResourceMetric{Percent: 94},
},
wantResourceType: "vSphere VM",
},
{
name: "vSphere datastore usage",
alertID: canonicalMetricStateID("vmware:vc-1:datastore:datastore-301", "usage"),
input: &UnifiedResourceInput{
ID: "vmware:vc-1:datastore:datastore-301",
Type: "vmware-datastore",
Name: "nvme-primary",
Node: "Lab Datacenter",
Instance: "Lab vCenter",
Disk: &UnifiedResourceMetric{Percent: 89},
},
wantResourceType: "vSphere Datastore",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
m.ClearActiveAlerts()
m.CheckUnifiedResource(tt.input)
alert := activeAlert(t, m, tt.alertID)
if got := alert.Metadata["resourceType"]; got != tt.wantResourceType {
t.Fatalf("resourceType metadata = %v, want %s", got, tt.wantResourceType)
}
})
}
}
func TestCheckUnifiedResourceUsesKubernetesTrueNASAndVMwareOverrides(t *testing.T) {
m := newTestManager(t)
cfg := unifiedEvalBaseConfig()
cfg.KubernetesDefaults.CPU = &HysteresisThreshold{Trigger: 95, Clear: 90}
cfg.TrueNASDefaults.Usage = &HysteresisThreshold{Trigger: 95, Clear: 90}
cfg.VMwareDefaults.CPU = &HysteresisThreshold{Trigger: 95, Clear: 90}
cfg.Overrides["k8s:prod/ns:default/pod:api-7d9f"] = ThresholdConfig{
CPU: &HysteresisThreshold{Trigger: 60, Clear: 55},
}
cfg.Overrides["storage:truenas-main/pool:tank"] = ThresholdConfig{
Usage: &HysteresisThreshold{Trigger: 60, Clear: 55},
}
cfg.Overrides["vmware:vc-1:host:host-101"] = ThresholdConfig{
CPU: &HysteresisThreshold{Trigger: 60, Clear: 55},
}
configureUnifiedEvalManager(t, m, cfg)
m.CheckUnifiedResource(&UnifiedResourceInput{
ID: "k8s:prod/ns:default/pod:api-7d9f",
Type: "pod",
Name: "api-7d9f",
CPU: &UnifiedResourceMetric{Percent: 65},
})
m.CheckUnifiedResource(&UnifiedResourceInput{
ID: "storage:truenas-main/pool:tank",
Type: "truenas-pool",
Name: "tank",
Disk: &UnifiedResourceMetric{Percent: 65},
})
m.CheckUnifiedResource(&UnifiedResourceInput{
ID: "vmware:vc-1:host:host-101",
Type: "vmware-host",
Name: "esxi-01.lab.local",
CPU: &UnifiedResourceMetric{Percent: 65},
})
k8sAlert := activeAlert(t, m, canonicalMetricStateID("k8s:prod/ns:default/pod:api-7d9f", "cpu"))
if k8sAlert.Threshold != 60 {
t.Fatalf("kubernetes override threshold = %v, want 60", k8sAlert.Threshold)
}
truenasAlert := activeAlert(t, m, canonicalMetricStateID("storage:truenas-main/pool:tank", "usage"))
if truenasAlert.Threshold != 60 {
t.Fatalf("truenas override threshold = %v, want 60", truenasAlert.Threshold)
}
vmwareAlert := activeAlert(t, m, canonicalMetricStateID("vmware:vc-1:host:host-101", "cpu"))
if vmwareAlert.Threshold != 60 {
t.Fatalf("vmware override threshold = %v, want 60", vmwareAlert.Threshold)
}
}
func TestUnifiedResourceInputFromKubernetesTrueNASAndVMwareResources(t *testing.T) {
pod := unifiedresources.Resource{
ID: " k8s:prod/ns:default/pod:api-7d9f ",
Type: unifiedresources.ResourceTypePod,
Name: "api-7d9f",
ParentName: "worker-1",
Sources: []unifiedresources.DataSource{unifiedresources.SourceK8s},
Kubernetes: &unifiedresources.K8sData{
ClusterName: "prod",
NodeName: "worker-1",
Namespace: "default",
},
Metrics: &unifiedresources.ResourceMetrics{
CPU: &unifiedresources.MetricValue{Percent: 82},
Memory: &unifiedresources.MetricValue{Percent: 64},
},
}
podInput, ok := UnifiedResourceInputFromResource(pod)
if !ok {
t.Fatalf("expected Kubernetes pod resource to become unified alert input")
}
if podInput.Type != "pod" || podInput.ID != "k8s:prod/ns:default/pod:api-7d9f" || podInput.Node != "worker-1" || podInput.Instance != "prod" {
t.Fatalf("unexpected pod input: %+v", podInput)
}
if podInput.CPU == nil || podInput.CPU.Percent != 82 {
t.Fatalf("unexpected pod CPU metric: %+v", podInput.CPU)
}
pool := unifiedresources.Resource{
ID: "storage:truenas-main/pool:tank",
Type: unifiedresources.ResourceTypeStorage,
Name: "tank",
ParentName: "truenas-main",
Sources: []unifiedresources.DataSource{unifiedresources.SourceTrueNAS},
Storage: &unifiedresources.StorageMeta{
Platform: string(unifiedresources.SourceTrueNAS),
Topology: "pool",
},
Metrics: &unifiedresources.ResourceMetrics{
Disk: &unifiedresources.MetricValue{Percent: 89},
},
}
poolInput, ok := UnifiedResourceInputFromResource(pool)
if !ok {
t.Fatalf("expected TrueNAS pool resource to become unified alert input")
}
if poolInput.Type != "truenas-pool" || poolInput.ID != "storage:truenas-main/pool:tank" || poolInput.Node != "truenas-main" || poolInput.Instance != "TrueNAS" {
t.Fatalf("unexpected pool input: %+v", poolInput)
}
if poolInput.Disk == nil || poolInput.Disk.Percent != 89 {
t.Fatalf("unexpected pool disk metric: %+v", poolInput.Disk)
}
temp := 58.0
disk := unifiedresources.Resource{
ID: "physical-disk:truenas-main/ada0",
Type: unifiedresources.ResourceTypePhysicalDisk,
Name: "ada0",
ParentName: "truenas-main",
Sources: []unifiedresources.DataSource{unifiedresources.SourceTrueNAS},
Temperature: &temp,
}
diskInput, ok := UnifiedResourceInputFromResource(disk)
if !ok {
t.Fatalf("expected TrueNAS disk resource to become unified alert input")
}
if diskInput.Type != "truenas-disk" || diskInput.Temperature == nil || diskInput.Temperature.Value != 58 {
t.Fatalf("unexpected disk input: %+v", diskInput)
}
vmwareHost := unifiedresources.Resource{
ID: " vmware:vc-1:host:host-101 ",
Type: unifiedresources.ResourceTypeAgent,
Name: "esxi-01.lab.local",
Sources: []unifiedresources.DataSource{unifiedresources.SourceVMware},
VMware: &unifiedresources.VMwareData{
ConnectionName: "Lab vCenter",
ClusterName: "Prod Compute",
VCenterHost: "vcenter.lab.local",
},
Metrics: &unifiedresources.ResourceMetrics{
CPU: &unifiedresources.MetricValue{Percent: 82},
},
}
hostInput, ok := UnifiedResourceInputFromResource(vmwareHost)
if !ok {
t.Fatalf("expected VMware host resource to become unified alert input")
}
if hostInput.Type != "vmware-host" || hostInput.ID != "vmware:vc-1:host:host-101" || hostInput.Node != "Prod Compute" || hostInput.Instance != "Lab vCenter" {
t.Fatalf("unexpected VMware host input: %+v", hostInput)
}
vmwareVM := unifiedresources.Resource{
ID: "vmware:vc-1:vm:vm-201",
Type: unifiedresources.ResourceTypeVM,
Name: "app-01",
ParentName: "esxi-01.lab.local",
Sources: []unifiedresources.DataSource{unifiedresources.SourceVMware},
VMware: &unifiedresources.VMwareData{
ConnectionName: "Lab vCenter",
RuntimeHostName: "esxi-01.lab.local",
},
Metrics: &unifiedresources.ResourceMetrics{
Disk: &unifiedresources.MetricValue{Percent: 94},
},
}
vmInput, ok := UnifiedResourceInputFromResource(vmwareVM)
if !ok {
t.Fatalf("expected VMware VM resource to become unified alert input")
}
if vmInput.Type != "vmware-vm" || vmInput.Node != "esxi-01.lab.local" || vmInput.Instance != "Lab vCenter" || vmInput.Disk == nil || vmInput.Disk.Percent != 94 {
t.Fatalf("unexpected VMware VM input: %+v", vmInput)
}
vmwareDatastore := unifiedresources.Resource{
ID: "vmware:vc-1:datastore:datastore-301",
Type: unifiedresources.ResourceTypeStorage,
Name: "nvme-primary",
Sources: []unifiedresources.DataSource{unifiedresources.SourceVMware},
Storage: &unifiedresources.StorageMeta{
Platform: "vmware-vsphere",
Topology: "datastore",
},
VMware: &unifiedresources.VMwareData{
ConnectionName: "Lab vCenter",
DatacenterName: "Lab Datacenter",
},
Metrics: &unifiedresources.ResourceMetrics{
Disk: &unifiedresources.MetricValue{Percent: 89},
},
}
datastoreInput, ok := UnifiedResourceInputFromResource(vmwareDatastore)
if !ok {
t.Fatalf("expected VMware datastore resource to become unified alert input")
}
if datastoreInput.Type != "vmware-datastore" || datastoreInput.Node != "Lab Datacenter" || datastoreInput.Instance != "Lab vCenter" {
t.Fatalf("unexpected VMware datastore input: %+v", datastoreInput)
}
vmwareNetwork := unifiedresources.Resource{
ID: "vmware:vc-1:network:network-401",
Type: unifiedresources.ResourceTypeNetwork,
Name: "VM Network",
Sources: []unifiedresources.DataSource{unifiedresources.SourceVMware},
VMware: &unifiedresources.VMwareData{
ConnectionName: "Lab vCenter",
DatacenterName: "Lab Datacenter",
EntityType: "network",
},
}
networkInput, ok := UnifiedResourceInputFromResource(vmwareNetwork)
if !ok {
t.Fatalf("expected VMware network resource to become unified alert input")
}
if networkInput.Type != "vmware-network" || networkInput.Node != "Lab Datacenter" || networkInput.Instance != "Lab vCenter" {
t.Fatalf("unexpected VMware network input: %+v", networkInput)
}
}
func TestCheckUnifiedResourceHonorsKubernetesTrueNASAndVMwareGlobalDisables(t *testing.T) {
m := newTestManager(t)
cfg := unifiedEvalBaseConfig()
cfg.DisableAllKubernetes = true
cfg.DisableAllTrueNAS = true
cfg.DisableAllVMware = true
configureUnifiedEvalManager(t, m, cfg)
m.CheckUnifiedResource(&UnifiedResourceInput{
ID: "k8s:prod",
Type: "k8s-cluster",
Name: "prod",
CPU: &UnifiedResourceMetric{Percent: 90},
})
m.CheckUnifiedResource(&UnifiedResourceInput{
ID: "storage:truenas-main/pool:tank",
Type: "truenas-pool",
Name: "tank",
Disk: &UnifiedResourceMetric{Percent: 90},
})
m.CheckUnifiedResource(&UnifiedResourceInput{
ID: "vmware:vc-1:host:host-101",
Type: "vmware-host",
Name: "esxi-01.lab.local",
CPU: &UnifiedResourceMetric{Percent: 90},
})
assertAlertMissing(t, m, canonicalMetricStateID("k8s:prod", "cpu"))
assertAlertMissing(t, m, canonicalMetricStateID("storage:truenas-main/pool:tank", "usage"))
assertAlertMissing(t, m, canonicalMetricStateID("vmware:vc-1:host:host-101", "cpu"))
}
func TestCheckUnifiedResourceRejectsLegacyGuestTypeAlias(t *testing.T) {
m := newTestManager(t)
configureUnifiedEvalManager(t, m, unifiedEvalBaseConfig())

View file

@ -48,6 +48,9 @@ func (m *Manager) SyncUnifiedResourceIncidents(resources []unifiedresources.Reso
m.mu.RLock()
enabled := m.config.Enabled
disableAllStorage := m.config.DisableAllStorage
disableAllKubernetes := m.config.DisableAllKubernetes
disableAllTrueNAS := m.config.DisableAllTrueNAS
disableAllVMware := m.config.DisableAllVMware
overrides := m.config.Overrides
m.mu.RUnlock()
@ -65,7 +68,18 @@ func (m *Manager) SyncUnifiedResourceIncidents(resources []unifiedresources.Reso
if !resourceSupportsUnifiedIncidentAlerts(resource) {
continue
}
if disableAllStorage {
if alertType, ok := unifiedAlertResourceType(resource); ok {
if disableAllKubernetes && isUnifiedKubernetesAlertType(alertType) {
continue
}
if disableAllTrueNAS && isUnifiedTrueNASAlertType(alertType) {
continue
}
if disableAllVMware && isUnifiedVMwareAlertType(alertType) {
continue
}
}
if disableAllStorage && (resource.Type == unifiedresources.ResourceTypeStorage || resource.Type == unifiedresources.ResourceTypePhysicalDisk) {
continue
}
if override, exists := overrides[spec.ResourceID]; exists && override.Disabled {
@ -232,6 +246,14 @@ func resourceSupportsUnifiedIncidentAlerts(resource unifiedresources.Resource) b
return len(resource.Incidents) > 0
case unifiedresources.ResourceTypePBS:
return len(resource.Incidents) > 0
case unifiedresources.ResourceTypeNetwork:
return len(resource.Incidents) > 0
case unifiedresources.ResourceTypeK8sCluster,
unifiedresources.ResourceTypeK8sNode,
unifiedresources.ResourceTypeK8sNamespace,
unifiedresources.ResourceTypeK8sDeployment,
unifiedresources.ResourceTypePod:
return len(resource.Incidents) > 0
case unifiedresources.ResourceTypeNetworkEndpoint:
return len(resource.Incidents) > 0
default:
@ -312,6 +334,20 @@ func unifiedIncidentNode(resource unifiedresources.Resource) string {
if resource.Canonical != nil && strings.TrimSpace(resource.Canonical.Hostname) != "" {
return strings.TrimSpace(resource.Canonical.Hostname)
}
if resource.VMware != nil {
if strings.TrimSpace(resource.VMware.RuntimeHostName) != "" {
return strings.TrimSpace(resource.VMware.RuntimeHostName)
}
if strings.TrimSpace(resource.VMware.ClusterName) != "" {
return strings.TrimSpace(resource.VMware.ClusterName)
}
if strings.TrimSpace(resource.VMware.DatacenterName) != "" {
return strings.TrimSpace(resource.VMware.DatacenterName)
}
if strings.TrimSpace(resource.VMware.VCenterHost) != "" {
return strings.TrimSpace(resource.VMware.VCenterHost)
}
}
return unifiedIncidentResourceName(resource)
}
@ -325,7 +361,13 @@ func unifiedIncidentInstance(resource unifiedresources.Resource) string {
case resource.PBS != nil:
return "PBS"
case resource.VMware != nil:
return "VMware"
if strings.TrimSpace(resource.VMware.ConnectionName) != "" {
return strings.TrimSpace(resource.VMware.ConnectionName)
}
if strings.TrimSpace(resource.VMware.VCenterHost) != "" {
return strings.TrimSpace(resource.VMware.VCenterHost)
}
return "vSphere"
case resource.Availability != nil:
return "Availability"
case resource.Storage != nil && strings.TrimSpace(resource.Storage.Platform) != "":

View file

@ -909,6 +909,48 @@ func TestSyncUnifiedResourceIncidentsRespectsDisableAllStorage(t *testing.T) {
assertAlertMissing(t, m, alertID)
}
func TestSyncUnifiedResourceIncidentsSupportsVMwareNetworkIncidentsAndGlobalDisable(t *testing.T) {
m := newTestManager(t)
cfg := unifiedEvalBaseConfig()
configureUnifiedEvalManager(t, m, cfg)
resource := unifiedresources.Resource{
ID: "vmware:vc-1:network:network-401",
Type: unifiedresources.ResourceTypeNetwork,
Name: "VM Network",
Sources: []unifiedresources.DataSource{unifiedresources.SourceVMware},
VMware: &unifiedresources.VMwareData{
ConnectionName: "Lab vCenter",
DatacenterName: "Lab Datacenter",
EntityType: "network",
},
Incidents: []unifiedresources.ResourceIncident{{
Provider: "vmware",
NativeID: "alarm-401",
Code: "vmware_alarm_state",
Severity: storagehealth.RiskWarning,
Summary: "vSphere network has an active alarm",
}},
}
m.SyncUnifiedResourceIncidents([]unifiedresources.Resource{resource})
alertID := "unified-incident-vmware-vc-1-network-network-401-vmware-alarm-401-vmware-alarm-state"
assertAlertPresent(t, m, alertID)
cfg.DisableAllVMware = true
m.UpdateConfig(cfg)
m.mu.Lock()
m.config.TimeThresholds = map[string]int{}
m.config.MetricTimeThresholds = nil
m.config.SuppressionWindow = 0
m.config.MinimumDelta = 0
m.mu.Unlock()
m.SyncUnifiedResourceIncidents([]unifiedresources.Resource{resource})
assertAlertMissing(t, m, alertID)
}
func TestDeriveCanonicalIdentityDoesNotMutateLegacyAlert(t *testing.T) {
alert := &Alert{
ID: "node-offline-pve-a",

View file

@ -1453,16 +1453,71 @@ func TestCanonicalResourceTypeKeys(t *testing.T) {
{name: "pbs canonical + node fallback", resourceType: "pbs", want: []string{"pbs", "node"}},
{name: "pmg canonical + node fallback", resourceType: "pmg", want: []string{"pmg", "node"}},
{
name: "k8s cluster canonical + guest fallback",
name: "k8s cluster canonical + node fallback",
resourceType: "k8s-cluster",
want: []string{"k8s-cluster", "guest"},
want: []string{"k8s-cluster", "node"},
},
{
name: "k8s node canonical + node fallback",
resourceType: "k8s-node",
want: []string{"k8s-node", "node"},
},
{
name: "k8s deployment canonical + guest fallback",
resourceType: "k8s-deployment",
want: []string{"k8s-deployment", "guest"},
},
{
name: "k8s namespace canonical",
resourceType: "k8s-namespace",
want: []string{"k8s-namespace"},
},
{
name: "kubernetes display alias maps to pod",
resourceType: "Kubernetes Pod",
want: []string{"pod", "guest"},
},
{name: "pod canonical + guest fallback", resourceType: "pod", want: []string{"pod", "guest"}},
{
name: "truenas system canonical + agent fallback",
resourceType: "truenas-system",
want: []string{"truenas-system", "agent", "node"},
},
{
name: "truenas pool canonical + storage fallback",
resourceType: "truenas-pool",
want: []string{"truenas-pool", "storage"},
},
{
name: "truenas dataset display alias",
resourceType: "TrueNAS Dataset",
want: []string{"truenas-dataset", "storage"},
},
{
name: "truenas disk canonical + disk/storage fallback",
resourceType: "truenas-disk",
want: []string{"truenas-disk", "physical_disk", "disk", "storage"},
},
{
name: "vSphere host canonical + agent fallback",
resourceType: "vSphere Host",
want: []string{"vmware-host", "agent", "node"},
},
{
name: "VMware VM canonical + guest fallback",
resourceType: "VMware VM",
want: []string{"vmware-vm", "vm", "guest"},
},
{
name: "vSphere datastore canonical + storage fallback",
resourceType: "vSphere Datastore",
want: []string{"vmware-datastore", "storage"},
},
{
name: "VMware network canonical + network fallback",
resourceType: "VMware Network",
want: []string{"vmware-network", "network"},
},
{name: "storage canonical", resourceType: "storage", want: []string{"storage"}},
{name: "disk canonical + storage fallback", resourceType: "disk", want: []string{"disk", "storage"}},
{

View file

@ -260,6 +260,26 @@ func TestAlertStateSyncDoesNotLogNormalResolvedStateAtInfo(t *testing.T) {
}
}
func TestUnifiedResourceAlertSyncEvaluatesMetricsBeforeIncidents(t *testing.T) {
data, err := os.ReadFile("monitor_alert_sync.go")
if err != nil {
t.Fatalf("failed to read monitor_alert_sync.go: %v", err)
}
source := string(data)
metricIndex := strings.Index(source, "CheckUnifiedResourceMetrics(resources)")
incidentIndex := strings.Index(source, "SyncUnifiedResourceIncidents(resources)")
if metricIndex < 0 {
t.Fatalf("monitor alert sync must run unified resource metric evaluation")
}
if incidentIndex < 0 {
t.Fatalf("monitor alert sync must preserve unified resource incident sync")
}
if metricIndex > incidentIndex {
t.Fatalf("unified resource metric evaluation must run before incident sync")
}
}
func TestStateBroadcastTreatsTypedNilHubAsAbsent(t *testing.T) {
data, err := os.ReadFile("monitor.go")
if err != nil {

View file

@ -93,6 +93,7 @@ func (m *Monitor) syncUnifiedResourceAlertsToState(resources []unifiedresources.
return
}
m.alertManager.CheckUnifiedResourceMetrics(resources)
m.alertManager.SyncUnifiedResourceIncidents(resources)
m.syncAlertsToState()
}

View file

@ -2040,6 +2040,7 @@ index 1111111..2222222 100644
"frontend-modern/src/features/alerts/thresholds/hooks/__tests__/useThresholdsTableState.test.tsx",
"frontend-modern/src/pages/__tests__/Alerts.helpers.test.ts",
"frontend-modern/src/utils/__tests__/alertOverviewPresentation.test.ts",
"frontend-modern/src/utils/__tests__/alertTargetTypes.test.ts",
],
}
],