-
- Boolean(state.editingId())}
- focusOnShortcut
- clearOnEscape
- shortcutHint="Ctrl+F"
- />
-
+
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');
+ }}
+ />
@@ -83,45 +140,7 @@ export function ThresholdsTable(props: ThresholdsTableProps) {
-
-
- 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'}`}
- >
-
- Infrastructure
-
- 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 Gateway
- Mail
-
- 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'}`}
- >
-
- Systems
-
- 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'}`}
- >
-
- Containers
-
-
-
-
-
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -153,10 +192,6 @@ export function ThresholdsTable(props: ThresholdsTableProps) {
-
-
-
-
Resource[];
+ columns: string[];
+ icon: JSX.Element;
+ typeKey: 'k8s-cluster' | 'k8s-node' | 'k8s-namespace' | 'k8s-deployment' | 'pod';
+ defaults?: Record;
+ },
+) {
+ const { state, tableProps } = props;
+
+ return (
+
+ state.toggleSection(props.id)}
+ icon={props.icon}
+ isGloballyDisabled={tableProps.disableAllKubernetes()}
+ emptyMessage="No Kubernetes alert targets match the current filters."
+ >
+
+ 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}
+ />
+
+
+
+ );
+}
+
+export function ThresholdsTableKubernetesTab(props: ThresholdsTableSectionProps) {
+ const defaults = () => props.tableProps.kubernetesDefaults ?? {};
+
+ return (
+ <>
+ }
+ typeKey="k8s-cluster"
+ defaults={defaults()}
+ />
+ }
+ typeKey="k8s-node"
+ defaults={defaults()}
+ />
+ }
+ typeKey="k8s-namespace"
+ />
+ }
+ typeKey="k8s-deployment"
+ defaults={defaults()}
+ />
+ }
+ typeKey="pod"
+ defaults={defaults()}
+ />
+ >
+ );
+}
diff --git a/frontend-modern/src/components/Alerts/ThresholdsTablePBSTab.tsx b/frontend-modern/src/components/Alerts/ThresholdsTablePBSTab.tsx
new file mode 100644
index 000000000..7a4011f45
--- /dev/null
+++ b/frontend-modern/src/components/Alerts/ThresholdsTablePBSTab.tsx
@@ -0,0 +1,6 @@
+import { ThresholdsTableProxmoxPBSSection } from './ThresholdsTableProxmoxPBSSection';
+import type { ThresholdsTableSectionProps } from '@/features/alerts/thresholds/thresholdsTableSectionProps';
+
+export function ThresholdsTablePBSTab(props: ThresholdsTableSectionProps) {
+ return ;
+}
diff --git a/frontend-modern/src/components/Alerts/ThresholdsTableProxmoxTab.tsx b/frontend-modern/src/components/Alerts/ThresholdsTableProxmoxTab.tsx
index 9bdf92d60..e005f477c 100644
--- a/frontend-modern/src/components/Alerts/ThresholdsTableProxmoxTab.tsx
+++ b/frontend-modern/src/components/Alerts/ThresholdsTableProxmoxTab.tsx
@@ -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 (
<>
-
diff --git a/frontend-modern/src/components/Alerts/ThresholdsTableTrueNASTab.tsx b/frontend-modern/src/components/Alerts/ThresholdsTableTrueNASTab.tsx
new file mode 100644
index 000000000..3a17135d7
--- /dev/null
+++ b/frontend-modern/src/components/Alerts/ThresholdsTableTrueNASTab.tsx
@@ -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;
+ factoryDefaults?: Record;
+ setGlobalDefaults?: (
+ value:
+ | Record
+ | ((prev: Record) => Record),
+ ) => void;
+ onResetDefaults?: () => void;
+ },
+) {
+ const { state, tableProps } = props;
+
+ return (
+
+ state.toggleSection(props.id)}
+ icon={props.icon}
+ isGloballyDisabled={tableProps.disableAllTrueNAS()}
+ emptyMessage="No TrueNAS alert targets match the current filters."
+ >
+
+ 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}
+ />
+
+
+
+ );
+}
+
+export function ThresholdsTableTrueNASTab(props: ThresholdsTableSectionProps) {
+ const trueNASDefaults = () => props.tableProps.trueNASDefaults ?? {};
+ const trueNASStorageDefaults = () => ({ usage: trueNASDefaults().usage ?? 85 });
+ const trueNASDiskDefaults = () => props.tableProps.trueNASDiskDefaults ?? {};
+
+ return (
+ <>
+ }
+ typeKey="truenas-system"
+ defaults={trueNASDefaults()}
+ factoryDefaults={props.tableProps.factoryTrueNASDefaults}
+ setGlobalDefaults={props.tableProps.setTrueNASDefaults}
+ onResetDefaults={props.tableProps.resetTrueNASDefaults}
+ />
+ }
+ 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}
+ />
+ }
+ 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}
+ />
+ }
+ typeKey="truenas-disk"
+ defaults={trueNASDiskDefaults()}
+ factoryDefaults={props.tableProps.factoryTrueNASDiskDefaults}
+ setGlobalDefaults={props.tableProps.setTrueNASDiskDefaults}
+ onResetDefaults={props.tableProps.resetTrueNASDiskDefaults}
+ />
+ >
+ );
+}
diff --git a/frontend-modern/src/components/Alerts/ThresholdsTableVMwareTab.tsx b/frontend-modern/src/components/Alerts/ThresholdsTableVMwareTab.tsx
new file mode 100644
index 000000000..8ff273cc1
--- /dev/null
+++ b/frontend-modern/src/components/Alerts/ThresholdsTableVMwareTab.tsx
@@ -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;
+ factoryDefaults?: Record;
+ setGlobalDefaults?: (
+ value:
+ | Record
+ | ((prev: Record) => Record),
+ ) => void;
+ onResetDefaults?: () => void;
+ },
+) {
+ const { state, tableProps } = props;
+
+ return (
+
+ state.toggleSection(props.id)}
+ icon={props.icon}
+ isGloballyDisabled={tableProps.disableAllVMware()}
+ emptyMessage="No vSphere alert targets match the current filters."
+ >
+
+ 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}
+ />
+
+
+
+ );
+}
+
+export function ThresholdsTableVMwareTab(props: ThresholdsTableSectionProps) {
+ const vmwareDefaults = () => props.tableProps.vmwareDefaults ?? {};
+ const datastoreDefaults = () => ({ usage: vmwareDefaults().usage ?? 85 });
+
+ const setDatastoreDefaults = (
+ value:
+ | Record
+ | ((prev: Record) => Record),
+ ) => {
+ 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 (
+ <>
+ }
+ typeKey="vmware-host"
+ defaults={vmwareDefaults()}
+ factoryDefaults={props.tableProps.factoryVMwareDefaults}
+ setGlobalDefaults={props.tableProps.setVMwareDefaults}
+ onResetDefaults={props.tableProps.resetVMwareDefaults}
+ />
+ }
+ typeKey="vmware-vm"
+ defaults={vmwareDefaults()}
+ factoryDefaults={props.tableProps.factoryVMwareDefaults}
+ setGlobalDefaults={props.tableProps.setVMwareDefaults}
+ onResetDefaults={props.tableProps.resetVMwareDefaults}
+ />
+ }
+ typeKey="vmware-datastore"
+ defaults={datastoreDefaults()}
+ factoryDefaults={
+ props.tableProps.factoryVMwareDefaults?.usage !== undefined
+ ? { usage: props.tableProps.factoryVMwareDefaults.usage }
+ : undefined
+ }
+ setGlobalDefaults={setDatastoreDefaults}
+ onResetDefaults={props.tableProps.resetVMwareDefaults}
+ />
+ }
+ typeKey="vmware-network"
+ />
+ >
+ );
+}
diff --git a/frontend-modern/src/components/Alerts/__tests__/ThresholdsTable.test.tsx b/frontend-modern/src/components/Alerts/__tests__/ThresholdsTable.test.tsx
index c22e9d889..60245d8c6 100644
--- a/frontend-modern/src/components/Alerts/__tests__/ThresholdsTable.test.tsx
+++ b/frontend-modern/src/components/Alerts/__tests__/ThresholdsTable.test.tsx
@@ -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(() => );
- 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(() => );
- 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(() => );
- 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(() => );
+
+ 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',
diff --git a/frontend-modern/src/components/Alerts/alertResourceTableModel.ts b/frontend-modern/src/components/Alerts/alertResourceTableModel.ts
index 46b58b2a3..e5e9f2779 100644
--- a/frontend-modern/src/components/Alerts/alertResourceTableModel.ts
+++ b/frontend-modern/src/components/Alerts/alertResourceTableModel.ts
@@ -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 = {
@@ -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 | undefined) => parseAlertMetricNumber(source?.[metric]);
+ const extract = (source: Record | undefined) =>
+ parseAlertMetricNumber(source?.[metric]);
const defaults = resource.defaults as Record | undefined;
if (isEditing) {
diff --git a/frontend-modern/src/features/alerts/AlertHistoryTableAlertRow.tsx b/frontend-modern/src/features/alerts/AlertHistoryTableAlertRow.tsx
index 6ae51ad6d..3cce19732 100644
--- a/frontend-modern/src/features/alerts/AlertHistoryTableAlertRow.tsx
+++ b/frontend-modern/src/features/alerts/AlertHistoryTableAlertRow.tsx
@@ -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)
-
+
{new Date(props.alert.startTime).toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
})}
-
+
{sourcePresentation().label}
{props.alert.resourceName}
-
+
{props.alert.resourceType}
-
+
{props.alert.severity}
{props.alert.description}
-
+
{props.alert.duration}
-
+
{historyStatusPresentation().label}
{props.alert.nodeDisplayName || props.alert.node || '—'}
-
+
-
+
Timestamp
-
+
Source
-
+
Resource
-
+
{getTypeColumnLabel()}
-
+
Severity
-
+
Message
-
+
Duration
-
+
Status
-
+
Node
-
+
Actions
diff --git a/frontend-modern/src/features/alerts/AlertsConfigurationSurface.tsx b/frontend-modern/src/features/alerts/AlertsConfigurationSurface.tsx
index b835119d2..b02b69bd2 100644
--- a/frontend-modern/src/features/alerts/AlertsConfigurationSurface.tsx
+++ b/frontend-modern/src/features/alerts/AlertsConfigurationSurface.tsx
@@ -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}
diff --git a/frontend-modern/src/features/alerts/__tests__/ThresholdsTab.test.tsx b/frontend-modern/src/features/alerts/__tests__/ThresholdsTab.test.tsx
index 099ceeb0b..0761f011d 100644
--- a/frontend-modern/src/features/alerts/__tests__/ThresholdsTab.test.tsx
+++ b/frontend-modern/src/features/alerts/__tests__/ThresholdsTab.test.tsx
@@ -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,
diff --git a/frontend-modern/src/features/alerts/__tests__/alertsConfigurationModel.test.ts b/frontend-modern/src/features/alerts/__tests__/alertsConfigurationModel.test.ts
index 432acba4e..5251ad61d 100644
--- a/frontend-modern/src/features/alerts/__tests__/alertsConfigurationModel.test.ts
+++ b/frontend-modern/src/features/alerts/__tests__/alertsConfigurationModel.test.ts
@@ -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 } });
diff --git a/frontend-modern/src/features/alerts/alertOverridesModel.ts b/frontend-modern/src/features/alerts/alertOverridesModel.ts
index be3532227..2ddd535fa 100644
--- a/frontend-modern/src/features/alerts/alertOverridesModel.ts
+++ b/frontend-modern/src/features/alerts/alertOverridesModel.ts
@@ -213,6 +213,7 @@ interface BuildProjectedOverridesArgs {
containerRuntimeResources: Resource[];
getChildren: (resourceId: string) => Resource[];
pbsInstanceById: Map;
+ 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();
@@ -236,6 +238,10 @@ export const buildProjectedOverrides = ({
const agentMap = new Map();
const guestMap = new Map();
const storageMap = new Map();
+ 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({
diff --git a/frontend-modern/src/features/alerts/alertsConfigurationModel.ts b/frontend-modern/src/features/alerts/alertsConfigurationModel.ts
index 70fb03ec3..3a7f540f2 100644
--- a/frontend-modern/src/features/alerts/alertsConfigurationModel.ts
+++ b/frontend-modern/src/features/alerts/alertsConfigurationModel.ts
@@ -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;
pbsDefaults: Record;
+ kubernetesDefaults: Record;
+ trueNASDefaults: Record;
+ trueNASDiskDefaults: Record;
+ vmwareDefaults: Record;
agentDefaults: Record;
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>;
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),
diff --git a/frontend-modern/src/features/alerts/tabs/ThresholdsTab.tsx b/frontend-modern/src/features/alerts/tabs/ThresholdsTab.tsx
index cb983ed01..f440e023b 100644
--- a/frontend-modern/src/features/alerts/tabs/ThresholdsTab.tsx
+++ b/frontend-modern/src/features/alerts/tabs/ThresholdsTab.tsx
@@ -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}
diff --git a/frontend-modern/src/features/alerts/thresholds/hooks/__tests__/useThresholdsTableState.test.tsx b/frontend-modern/src/features/alerts/thresholds/hooks/__tests__/useThresholdsTableState.test.tsx
index 9b5ae410c..8968dea0c 100644
--- a/frontend-modern/src/features/alerts/thresholds/hooks/__tests__/useThresholdsTableState.test.tsx
+++ b/frontend-modern/src/features/alerts/thresholds/hooks/__tests__/useThresholdsTableState.test.tsx
@@ -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');
});
});
diff --git a/frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsAvailabilityMutations.ts b/frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsAvailabilityMutations.ts
index 4fb1443f9..29452e049 100644
--- a/frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsAvailabilityMutations.ts
+++ b/frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsAvailabilityMutations.ts
@@ -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;
}
diff --git a/frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsData.ts b/frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsData.ts
index be06305d4..4ffabc6dc 100644
--- a/frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsData.ts
+++ b/frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsData.ts
@@ -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,
};
}
diff --git a/frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsOverrideMutations.ts b/frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsOverrideMutations.ts
index 865faf8bc..71cede6c5 100644
--- a/frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsOverrideMutations.ts
+++ b/frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsOverrideMutations.ts
@@ -37,6 +37,19 @@ interface ThresholdsOverrideMutationResources {
pbsServersWithOverrides: Accessor;
pmgServersWithOverrides: Accessor;
storageWithOverrides: Accessor;
+ kubernetesClustersWithOverrides?: Accessor;
+ kubernetesNodesWithOverrides?: Accessor;
+ kubernetesNamespacesWithOverrides?: Accessor;
+ kubernetesDeploymentsWithOverrides?: Accessor;
+ kubernetesPodsWithOverrides?: Accessor;
+ trueNASSystemsWithOverrides?: Accessor;
+ trueNASPoolsWithOverrides?: Accessor;
+ trueNASDatasetsWithOverrides?: Accessor;
+ trueNASDisksWithOverrides?: Accessor;
+ vmwareHostsWithOverrides?: Accessor;
+ vmwareVMsWithOverrides?: Accessor;
+ vmwareDatastoresWithOverrides?: Accessor;
+ vmwareNetworksWithOverrides?: Accessor;
}
interface ThresholdsOverrideMutationProps {
@@ -64,6 +77,9 @@ export function useThresholdsOverrideMutations({
updateBackupDefaults,
updateSnapshotDefaults,
}: ThresholdsOverrideMutationProps) {
+ const optionalResources = (accessor?: Accessor): 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()];
diff --git a/frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsPlatformData.ts b/frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsPlatformData.ts
new file mode 100644
index 000000000..367f376f9
--- /dev/null
+++ b/frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsPlatformData.ts
@@ -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;
+ candidates?: string[];
+ overridesMap: Map;
+ 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((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((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((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((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((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((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((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((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((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((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((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((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((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 };
diff --git a/frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsTableState.ts b/frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsTableState.ts
index efda2a03d..3bef8c5af 100644
--- a/frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsTableState.ts
+++ b/frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsTableState.ts
@@ -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(null);
const [editingThresholds, setEditingThresholds] = createSignal<
Record
@@ -98,7 +112,7 @@ export function useThresholdsTableState(props: ThresholdsTableProps) {
const [bulkEditIds, setBulkEditIds] = createSignal([]);
const [bulkEditColumns, setBulkEditColumns] = createSignal([]);
const [isBulkEditDialogOpen, setIsBulkEditDialogOpen] = createSignal(false);
- const [activeTab, setActiveTab] = createSignal('infrastructure');
+ const [activeTab, setActiveTab] = createSignal('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 = {
- 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,
+ ): Record => {
+ const filtered: Record = {};
+ 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,
diff --git a/frontend-modern/src/features/alerts/thresholds/tableTypes.ts b/frontend-modern/src/features/alerts/thresholds/tableTypes.ts
index b4788bdc8..699950788 100644
--- a/frontend-modern/src/features/alerts/thresholds/tableTypes.ts
+++ b/frontend-modern/src/features/alerts/thresholds/tableTypes.ts
@@ -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;
diff --git a/frontend-modern/src/features/alerts/thresholds/thresholdsTabModel.ts b/frontend-modern/src/features/alerts/thresholds/thresholdsTabModel.ts
index f29c917fa..9440fd7d6 100644
--- a/frontend-modern/src/features/alerts/thresholds/thresholdsTabModel.ts
+++ b/frontend-modern/src/features/alerts/thresholds/thresholdsTabModel.ts
@@ -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;
nodeDefaults: Accessor;
pbsDefaults: Accessor>;
+ kubernetesDefaults: Accessor>;
+ trueNASDefaults: Accessor>;
+ trueNASDiskDefaults: Accessor>;
+ vmwareDefaults: Accessor>;
agentDefaults: Accessor;
dockerDefaults: Accessor;
}
diff --git a/frontend-modern/src/features/alerts/thresholds/types.ts b/frontend-modern/src/features/alerts/thresholds/types.ts
index 3ecdff4c2..358f0d703 100644
--- a/frontend-modern/src/features/alerts/thresholds/types.ts
+++ b/frontend-modern/src/features/alerts/thresholds/types.ts
@@ -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
| ((prev: Record) => Record),
) => void;
+ setKubernetesDefaults?: (
+ value:
+ | Record
+ | ((prev: Record) => Record),
+ ) => void;
+ setTrueNASDefaults?: (
+ value:
+ | Record
+ | ((prev: Record) => Record),
+ ) => void;
+ setTrueNASDiskDefaults?: (
+ value:
+ | Record
+ | ((prev: Record) => Record),
+ ) => void;
+ setVMwareDefaults?: (
+ value:
+ | Record
+ | ((prev: Record) => Record),
+ ) => void;
setAgentDefaults: (
value:
| Record
@@ -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;
factoryNodeDefaults?: Record;
factoryPBSDefaults?: Record;
+ factoryKubernetesDefaults?: Record;
+ factoryTrueNASDefaults?: Record;
+ factoryTrueNASDiskDefaults?: Record;
+ factoryVMwareDefaults?: Record;
factoryAgentDefaults?: Record;
factoryDockerDefaults?: Record;
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>;
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;
diff --git a/frontend-modern/src/features/alerts/types.ts b/frontend-modern/src/features/alerts/types.ts
index 06bf19741..0a47a288d 100644
--- a/frontend-modern/src/features/alerts/types.ts
+++ b/frontend-modern/src/features/alerts/types.ts
@@ -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';
diff --git a/frontend-modern/src/features/alerts/useAlertOverridesState.ts b/frontend-modern/src/features/alerts/useAlertOverridesState.ts
index 444171349..c727c7a25 100644
--- a/frontend-modern/src/features/alerts/useAlertOverridesState.ts
+++ b/frontend-modern/src/features/alerts/useAlertOverridesState.ts
@@ -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)
);
});
diff --git a/frontend-modern/src/features/alerts/useAlertsConfigurationSnapshotState.ts b/frontend-modern/src/features/alerts/useAlertsConfigurationSnapshotState.ts
index 0f41ab95c..2d78ba81f 100644
--- a/frontend-modern/src/features/alerts/useAlertsConfigurationSnapshotState.ts
+++ b/frontend-modern/src/features/alerts/useAlertsConfigurationSnapshotState.ts
@@ -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>(
defaultSnapshot.pbsDefaults,
);
+ const [kubernetesDefaults, setKubernetesDefaults] = createSignal<
+ Record
+ >(defaultSnapshot.kubernetesDefaults);
+ const [trueNASDefaults, setTrueNASDefaults] = createSignal>(
+ defaultSnapshot.trueNASDefaults,
+ );
+ const [trueNASDiskDefaults, setTrueNASDiskDefaults] = createSignal<
+ Record
+ >(defaultSnapshot.trueNASDiskDefaults);
+ const [vmwareDefaults, setVMwareDefaults] = createSignal>(
+ defaultSnapshot.vmwareDefaults,
+ );
const [agentDefaults, setAgentDefaults] = createSignal>(
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,
diff --git a/frontend-modern/src/types/alerts.ts b/frontend-modern/src/types/alerts.ts
index 1196ed294..3627f41c2 100644
--- a/frontend-modern/src/types/alerts.ts
+++ b/frontend-modern/src/types/alerts.ts
@@ -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>;
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;
diff --git a/frontend-modern/src/utils/__tests__/alertTargetTypes.test.ts b/frontend-modern/src/utils/__tests__/alertTargetTypes.test.ts
index 77f25bad6..3f6e6d1a2 100644
--- a/frontend-modern/src/utils/__tests__/alertTargetTypes.test.ts
+++ b/frontend-modern/src/utils/__tests__/alertTargetTypes.test.ts
@@ -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');
});
});
diff --git a/frontend-modern/src/utils/__tests__/metricThresholds.test.ts b/frontend-modern/src/utils/__tests__/metricThresholds.test.ts
index c1e4a5a47..52cf287c1 100644
--- a/frontend-modern/src/utils/__tests__/metricThresholds.test.ts
+++ b/frontend-modern/src/utils/__tests__/metricThresholds.test.ts
@@ -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,
+ });
+ });
});
});
diff --git a/frontend-modern/src/utils/alertTargetTypes.ts b/frontend-modern/src/utils/alertTargetTypes.ts
index 665d5f9ee..463396d3d 100644
--- a/frontend-modern/src/utils/alertTargetTypes.ts
+++ b/frontend-modern/src/utils/alertTargetTypes.ts
@@ -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 = {
+ 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;
};
diff --git a/frontend-modern/src/utils/alertThresholdDefaults.ts b/frontend-modern/src/utils/alertThresholdDefaults.ts
index ac1b1d09f..2c25815ee 100644
--- a/frontend-modern/src/utils/alertThresholdDefaults.ts
+++ b/frontend-modern/src/utils/alertThresholdDefaults.ts
@@ -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,
diff --git a/internal/alerts/config/identity.go b/internal/alerts/config/identity.go
index 6c76ddeb0..fdaebaaa1 100644
--- a/internal/alerts/config/identity.go
+++ b/internal/alerts/config/identity.go
@@ -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
+ }
}
diff --git a/internal/alerts/config/normalize.go b/internal/alerts/config/normalize.go
index 3427bb004..b558e9163 100644
--- a/internal/alerts/config/normalize.go
+++ b/internal/alerts/config/normalize.go
@@ -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) {
diff --git a/internal/alerts/config/types.go b/internal/alerts/config/types.go
index 950f0c215..f75085c72 100644
--- a/internal/alerts/config/types.go
+++ b/internal/alerts/config/types.go
@@ -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
diff --git a/internal/alerts/config_facade.go b/internal/alerts/config_facade.go
index af9aa0264..46230a9e4 100644
--- a/internal/alerts/config_facade.go
+++ b/internal/alerts/config_facade.go
@@ -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)
diff --git a/internal/alerts/config_runtime.go b/internal/alerts/config_runtime.go
index 37e9f0e67..5cf681c20 100644
--- a/internal/alerts/config_runtime.go
+++ b/internal/alerts/config_runtime.go
@@ -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
diff --git a/internal/alerts/default_config.go b/internal/alerts/default_config.go
index 28f1bcf7b..5fb49a16d 100644
--- a/internal/alerts/default_config.go
+++ b/internal/alerts/default_config.go
@@ -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{
diff --git a/internal/alerts/specs/types.go b/internal/alerts/specs/types.go
index 14ce8fc9b..89c6ba2d9 100644
--- a/internal/alerts/specs/types.go
+++ b/internal/alerts/specs/types.go
@@ -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,
diff --git a/internal/alerts/specs/types_test.go b/internal/alerts/specs/types_test.go
index be440738a..1e28802aa 100644
--- a/internal/alerts/specs/types_test.go
+++ b/internal/alerts/specs/types_test.go
@@ -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{
diff --git a/internal/alerts/threshold_resolution_shared_test.go b/internal/alerts/threshold_resolution_shared_test.go
index bde15235d..a8998a4ca 100644
--- a/internal/alerts/threshold_resolution_shared_test.go
+++ b/internal/alerts/threshold_resolution_shared_test.go
@@ -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")
+ }
+}
diff --git a/internal/alerts/unified_eval.go b/internal/alerts/unified_eval.go
index e1151dc48..10782921d 100644
--- a/internal/alerts/unified_eval.go
+++ b/internal/alerts/unified_eval.go
@@ -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()
diff --git a/internal/alerts/unified_eval_test.go b/internal/alerts/unified_eval_test.go
index 19bc7ecdf..15b6f57a3 100644
--- a/internal/alerts/unified_eval_test.go
+++ b/internal/alerts/unified_eval_test.go
@@ -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())
diff --git a/internal/alerts/unified_incidents.go b/internal/alerts/unified_incidents.go
index 688396db5..5fa9c6bfb 100644
--- a/internal/alerts/unified_incidents.go
+++ b/internal/alerts/unified_incidents.go
@@ -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) != "":
diff --git a/internal/alerts/unified_incidents_test.go b/internal/alerts/unified_incidents_test.go
index 21d645b8b..fcea3dd9f 100644
--- a/internal/alerts/unified_incidents_test.go
+++ b/internal/alerts/unified_incidents_test.go
@@ -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",
diff --git a/internal/alerts/utility_test.go b/internal/alerts/utility_test.go
index fa053aa54..20282f725 100644
--- a/internal/alerts/utility_test.go
+++ b/internal/alerts/utility_test.go
@@ -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"}},
{
diff --git a/internal/monitoring/canonical_guardrails_test.go b/internal/monitoring/canonical_guardrails_test.go
index b2e2d47f3..5e47de82f 100644
--- a/internal/monitoring/canonical_guardrails_test.go
+++ b/internal/monitoring/canonical_guardrails_test.go
@@ -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 {
diff --git a/internal/monitoring/monitor_alert_sync.go b/internal/monitoring/monitor_alert_sync.go
index 74b03234b..a2c773c00 100644
--- a/internal/monitoring/monitor_alert_sync.go
+++ b/internal/monitoring/monitor_alert_sync.go
@@ -93,6 +93,7 @@ func (m *Monitor) syncUnifiedResourceAlertsToState(resources []unifiedresources.
return
}
+ m.alertManager.CheckUnifiedResourceMetrics(resources)
m.alertManager.SyncUnifiedResourceIncidents(resources)
m.syncAlertsToState()
}
diff --git a/scripts/release_control/canonical_completion_guard_test.py b/scripts/release_control/canonical_completion_guard_test.py
index 0ff9ddf30..df4f5089a 100644
--- a/scripts/release_control/canonical_completion_guard_test.py
+++ b/scripts/release_control/canonical_completion_guard_test.py
@@ -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",
],
}
],