Extract thresholds override mutation owner

This commit is contained in:
rcourtman
2026-03-22 15:06:19 +00:00
parent 4375b1d221
commit d3b9bc5eda
7 changed files with 945 additions and 729 deletions
@@ -133,10 +133,14 @@ threshold editor saves or re-renders them.
That threshold editor data shaping now lives under
`frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsData.ts`,
while `frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsTableState.ts`
owns threshold-table route sync, edit state, bulk-edit flow, and override
mutation control. `frontend-modern/src/components/Alerts/ThresholdsTable.tsx`
is now limited to table interaction and presentation. New threshold row
grouping, override-ID compatibility, resource normalization, or thresholds-table
owns threshold-table route sync, section collapse state, search/edit shell
state, and bulk-edit dialog control. Override persistence, bulk threshold
application, powered-off/connectivity state transitions, and alert-removal side
effects now route through
`frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsOverrideMutations.ts`.
`frontend-modern/src/components/Alerts/ThresholdsTable.tsx` is now limited to
table interaction and presentation. New threshold row grouping,
override-ID compatibility, resource normalization, or thresholds-table
controller logic should land in those hooks rather than being rebuilt inside
the table component.
The alert resource thresholds editor now follows the same shape: shared metric
@@ -233,6 +233,12 @@ schedule render shell, while
reset/update policy and canonical default application. The tab should not
re-accumulate quiet-hours, cooldown, grouping, or escalation mutation logic
inline.
The thresholds editor now follows that same split more tightly:
`frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsTableState.ts`
must stay the table-shell owner for route sync and local UI state, while
`frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsOverrideMutations.ts`
owns override save/bulk/toggle persistence and alert-removal side effects. The
table-shell hook should not re-accumulate raw override mutation logic inline.
The updates settings surface now follows the same presentation-owner rule.
`frontend-modern/src/components/Settings/UpdatesSettingsPanel.tsx` stays the
@@ -0,0 +1,175 @@
import { renderHook } from '@solidjs/testing-library';
import { createSignal } from 'solid-js';
import { describe, expect, it, vi } from 'vitest';
import type { ThresholdsTableProps } from '@/features/alerts/thresholds/types';
import type { Resource as TableResource } from '@/features/alerts/thresholds/tableTypes';
import { useThresholdsOverrideMutations } from '../useThresholdsOverrideMutations';
const buildTableProps = (overridesSignal: ReturnType<typeof createSignal<any[]>>) => {
const [overrides, setOverrides] = overridesSignal;
const [rawOverridesConfig, setRawOverridesConfig] = createSignal<Record<string, any>>({});
const [backupDefaults] = createSignal({
enabled: false,
warningDays: 7,
criticalDays: 14,
});
const [snapshotDefaults] = createSignal({
enabled: false,
warningDays: 30,
criticalDays: 45,
warningSizeGiB: 0,
criticalSizeGiB: 0,
});
const setHasUnsavedChanges = vi.fn();
const removeAlerts = vi.fn();
const props = {
overrides,
setOverrides,
rawOverridesConfig,
setRawOverridesConfig,
backupDefaults,
snapshotDefaults,
guestDisableConnectivity: () => false,
guestPoweredOffSeverity: () => 'warning' as const,
dockerDisableConnectivity: () => false,
dockerPoweredOffSeverity: () => 'warning' as const,
setHasUnsavedChanges,
removeAlerts,
} as unknown as ThresholdsTableProps;
return {
props,
rawOverridesConfig,
setHasUnsavedChanges,
removeAlerts,
};
};
describe('useThresholdsOverrideMutations', () => {
it('owns threshold override save persistence outside the table-state shell', () => {
const overrideSignal = createSignal<any[]>([]);
const { props, rawOverridesConfig, setHasUnsavedChanges } = buildTableProps(overrideSignal);
const [editingThresholds] = createSignal<Record<string, number | undefined>>({ cpu: 95 });
const [editingNote] = createSignal('Investigate host pressure');
const [bulkEditIds] = createSignal<string[]>([]);
const cancelEdit = vi.fn();
const guestResource: TableResource = {
id: 'vm-100',
name: 'db-01',
type: 'guest',
resourceType: 'VM',
vmid: 100,
node: 'pve-1',
instance: 'qemu/100',
defaults: { cpu: 80 },
thresholds: { cpu: 80 },
};
const { result } = renderHook(() =>
useThresholdsOverrideMutations({
props,
resources: {
nodesWithOverrides: () => [],
agentsWithOverrides: () => [],
agentDisksWithOverrides: () => [],
dockerHostsWithOverrides: () => [],
guestsFlat: () => [guestResource],
dockerContainersFlat: () => [],
pbsServersWithOverrides: () => [],
pmgServersWithOverrides: () => [],
storageWithOverrides: () => [],
},
editingThresholds,
editingNote,
bulkEditIds,
cancelEdit,
updateBackupDefaults: vi.fn(),
updateSnapshotDefaults: vi.fn(),
}),
);
result.saveEdit('vm-100');
expect(overrideSignal[0]()).toEqual([
expect.objectContaining({
id: 'vm-100',
type: 'guest',
note: 'Investigate host pressure',
thresholds: { cpu: 95 },
}),
]);
expect(rawOverridesConfig()).toEqual({
'vm-100': {
cpu: {
clear: 90,
trigger: 95,
},
note: 'Investigate host pressure',
},
});
expect(setHasUnsavedChanges).toHaveBeenCalledWith(true);
expect(cancelEdit).toHaveBeenCalledTimes(1);
});
it('owns powered-off severity and disable-connectivity persistence for guest resources', () => {
const overrideSignal = createSignal<any[]>([]);
const { props, rawOverridesConfig, setHasUnsavedChanges, removeAlerts } =
buildTableProps(overrideSignal);
const [editingThresholds] = createSignal<Record<string, number | undefined>>({});
const [editingNote] = createSignal('');
const [bulkEditIds] = createSignal<string[]>([]);
const guestResource: TableResource = {
id: 'vm-100',
name: 'db-01',
type: 'guest',
resourceType: 'VM',
vmid: 100,
node: 'pve-1',
instance: 'qemu/100',
thresholds: {},
};
const { result } = renderHook(() =>
useThresholdsOverrideMutations({
props,
resources: {
nodesWithOverrides: () => [],
agentsWithOverrides: () => [],
agentDisksWithOverrides: () => [],
dockerHostsWithOverrides: () => [],
guestsFlat: () => [guestResource],
dockerContainersFlat: () => [],
pbsServersWithOverrides: () => [],
pmgServersWithOverrides: () => [],
storageWithOverrides: () => [],
},
editingThresholds,
editingNote,
bulkEditIds,
cancelEdit: vi.fn(),
updateBackupDefaults: vi.fn(),
updateSnapshotDefaults: vi.fn(),
}),
);
result.setOfflineState('vm-100', 'critical');
expect(overrideSignal[0]()).toEqual([
expect.objectContaining({
id: 'vm-100',
disableConnectivity: false,
poweredOffSeverity: 'critical',
}),
]);
expect(rawOverridesConfig()).toEqual({
'vm-100': {
poweredOffSeverity: 'critical',
},
});
expect(setHasUnsavedChanges).toHaveBeenCalledWith(true);
expect(removeAlerts).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,702 @@
import type { Accessor } from 'solid-js';
import type {
RawOverrideConfig,
BackupAlertConfig,
SnapshotAlertConfig,
} from '@/types/alerts';
import { matchesAlertIdentifier } from '@/features/alerts/identity';
import {
DEFAULT_SNAPSHOT_WARNING,
DEFAULT_SNAPSHOT_CRITICAL,
DEFAULT_SNAPSHOT_WARNING_SIZE,
DEFAULT_SNAPSHOT_CRITICAL_SIZE,
DEFAULT_BACKUP_WARNING,
DEFAULT_BACKUP_CRITICAL,
} from '@/features/alerts/thresholds/constants';
import type {
Override,
OverrideType,
OfflineState,
ThresholdsTableProps,
} from '@/features/alerts/thresholds/types';
import type { Resource as TableResource } from '@/features/alerts/thresholds/tableTypes';
interface ThresholdsOverrideMutationResources {
nodesWithOverrides: Accessor<TableResource[]>;
agentsWithOverrides: Accessor<TableResource[]>;
agentDisksWithOverrides: Accessor<TableResource[]>;
dockerHostsWithOverrides: Accessor<TableResource[]>;
guestsFlat: Accessor<TableResource[]>;
dockerContainersFlat: Accessor<TableResource[]>;
pbsServersWithOverrides: Accessor<TableResource[]>;
pmgServersWithOverrides: Accessor<TableResource[]>;
storageWithOverrides: Accessor<TableResource[]>;
}
interface ThresholdsOverrideMutationProps {
props: ThresholdsTableProps;
resources: ThresholdsOverrideMutationResources;
editingThresholds: Accessor<Record<string, number | undefined>>;
editingNote: Accessor<string>;
bulkEditIds: Accessor<string[]>;
cancelEdit: () => void;
updateBackupDefaults: (
updater: BackupAlertConfig | ((prev: BackupAlertConfig) => BackupAlertConfig),
) => void;
updateSnapshotDefaults: (
updater: SnapshotAlertConfig | ((prev: SnapshotAlertConfig) => SnapshotAlertConfig),
) => void;
}
const upsertOverride = (overrides: Override[], override: Override): Override[] => {
const existingIndex = overrides.findIndex((entry) => entry.id === override.id);
if (existingIndex >= 0) {
const nextOverrides = [...overrides];
nextOverrides[existingIndex] = override;
return nextOverrides;
}
return [...overrides, override];
};
const withThresholdEntries = (
rawConfig: RawOverrideConfig,
thresholds: Record<string, number | undefined>,
): RawOverrideConfig => {
const next = { ...rawConfig };
Object.entries(thresholds).forEach(([metric, value]) => {
if (value !== undefined && value !== null) {
next[metric] = {
clear: Math.max(0, value - 5),
trigger: value,
};
}
});
return next;
};
const stripStateKeys = (thresholds: Record<string, number>): Record<string, number> => {
const next = { ...thresholds };
delete (next as Record<string, unknown>).disabled;
delete (next as Record<string, unknown>).disableConnectivity;
delete (next as Record<string, unknown>).poweredOffSeverity;
return next;
};
export function useThresholdsOverrideMutations({
props,
resources,
editingThresholds,
editingNote,
bulkEditIds,
cancelEdit,
updateBackupDefaults,
updateSnapshotDefaults,
}: ThresholdsOverrideMutationProps) {
const proxmoxResources = () => [
...resources.nodesWithOverrides(),
...resources.agentsWithOverrides(),
...resources.agentDisksWithOverrides(),
...resources.dockerHostsWithOverrides(),
...resources.pbsServersWithOverrides(),
...resources.pmgServersWithOverrides(),
...resources.storageWithOverrides(),
];
const guestLikeResources = () => [
...resources.guestsFlat(),
...resources.dockerContainersFlat(),
];
const allThresholdResources = () => [...proxmoxResources(), ...guestLikeResources()];
const removeOverride = (resourceId: string) => {
props.setOverrides(props.overrides().filter((override) => override.id !== resourceId));
const newRawConfig = { ...props.rawOverridesConfig() };
delete newRawConfig[resourceId];
props.setRawOverridesConfig(newRawConfig);
props.setHasUnsavedChanges(true);
};
const saveEdit = (resourceId: string) => {
const resource = allThresholdResources().find((entry) => entry.id === resourceId);
if (!resource) return;
const editedThresholdMap = editingThresholds();
const trimmedNote = editingNote().trim();
const noteForOverride = trimmedNote.length > 0 ? trimmedNote : undefined;
if (resource.editScope === 'backup') {
const currentBackupDefaults = props.backupDefaults();
updateBackupDefaults({
criticalDays:
editedThresholdMap['critical days'] ??
currentBackupDefaults.criticalDays ??
DEFAULT_BACKUP_CRITICAL,
enabled: currentBackupDefaults.enabled,
warningDays:
editedThresholdMap['warning days'] ??
currentBackupDefaults.warningDays ??
DEFAULT_BACKUP_WARNING,
});
cancelEdit();
return;
}
if (resource.editScope === 'snapshot') {
const currentSnapshotDefaults = props.snapshotDefaults();
updateSnapshotDefaults({
criticalDays:
editedThresholdMap['critical days'] ??
currentSnapshotDefaults.criticalDays ??
DEFAULT_SNAPSHOT_CRITICAL,
criticalSizeGiB:
editedThresholdMap['critical size (gib)'] ??
currentSnapshotDefaults.criticalSizeGiB ??
DEFAULT_SNAPSHOT_CRITICAL_SIZE,
enabled: currentSnapshotDefaults.enabled,
warningDays:
editedThresholdMap['warning days'] ??
currentSnapshotDefaults.warningDays ??
DEFAULT_SNAPSHOT_WARNING,
warningSizeGiB:
editedThresholdMap['warning size (gib)'] ??
currentSnapshotDefaults.warningSizeGiB ??
DEFAULT_SNAPSHOT_WARNING_SIZE,
});
cancelEdit();
return;
}
const defaultThresholds = (resource.defaults ?? {}) as Record<string, number | undefined>;
const overrideThresholds: Record<string, number> = {};
Object.keys(editedThresholdMap).forEach((key) => {
const editedValue = editedThresholdMap[key];
const defaultValue = defaultThresholds[key];
if (editedValue !== undefined && editedValue !== defaultValue) {
overrideThresholds[key] = editedValue;
}
});
const existingOverride = props.overrides().find((override) => override.id === resourceId);
const hasStateOnlyOverride = Boolean(
resource.disabled ||
resource.disableConnectivity ||
resource.poweredOffSeverity !== undefined ||
noteForOverride !== undefined ||
existingOverride?.backup ||
existingOverride?.snapshot,
);
if (Object.keys(overrideThresholds).length === 0 && !hasStateOnlyOverride) {
if (resource.hasOverride) {
removeOverride(resourceId);
}
cancelEdit();
return;
}
const override: Override = {
id: resourceId,
name: resource.name,
type: resource.type as OverrideType,
resourceType: resource.resourceType,
vmid: 'vmid' in resource ? resource.vmid : undefined,
node: 'node' in resource ? resource.node : undefined,
instance: 'instance' in resource ? resource.instance : undefined,
disabled: resource.disabled,
disableConnectivity: resource.disableConnectivity,
poweredOffSeverity: resource.poweredOffSeverity,
note: noteForOverride,
backup: existingOverride?.backup,
snapshot: existingOverride?.snapshot,
thresholds: overrideThresholds,
};
props.setOverrides(upsertOverride(props.overrides(), override));
const previousRaw = props.rawOverridesConfig()[resourceId];
let hysteresisThresholds: RawOverrideConfig = {};
if (previousRaw) {
if (previousRaw.disabled !== undefined) hysteresisThresholds.disabled = previousRaw.disabled;
if (previousRaw.disableConnectivity !== undefined) {
hysteresisThresholds.disableConnectivity = previousRaw.disableConnectivity;
}
if (previousRaw.poweredOffSeverity) {
hysteresisThresholds.poweredOffSeverity = previousRaw.poweredOffSeverity;
}
if (previousRaw.backup) hysteresisThresholds.backup = previousRaw.backup;
if (previousRaw.snapshot) hysteresisThresholds.snapshot = previousRaw.snapshot;
}
hysteresisThresholds = withThresholdEntries(hysteresisThresholds, overrideThresholds);
if (resource.disabled) {
hysteresisThresholds.disabled = true;
} else {
delete hysteresisThresholds.disabled;
}
if (resource.disableConnectivity) {
hysteresisThresholds.disableConnectivity = true;
delete hysteresisThresholds.poweredOffSeverity;
} else {
if (
(resource.type === 'guest' || resource.type === 'dockerContainer') &&
props.guestDisableConnectivity()
) {
hysteresisThresholds.disableConnectivity = false;
} else {
delete hysteresisThresholds.disableConnectivity;
}
if (resource.poweredOffSeverity) {
hysteresisThresholds.poweredOffSeverity = resource.poweredOffSeverity;
} else {
delete hysteresisThresholds.poweredOffSeverity;
}
}
if (noteForOverride) {
hysteresisThresholds.note = noteForOverride;
} else {
delete hysteresisThresholds.note;
}
props.setRawOverridesConfig({
...props.rawOverridesConfig(),
[resourceId]: hysteresisThresholds,
});
props.setHasUnsavedChanges(true);
cancelEdit();
};
const handleSaveBulkEdit = (thresholds: Record<string, number | undefined>) => {
const nextOverrides = [...props.overrides()];
const nextRawConfig = { ...props.rawOverridesConfig() };
for (const id of bulkEditIds()) {
const resource = proxmoxResources().find((entry) => entry.id === id);
if (!resource) continue;
const defaultThresholds = (resource.defaults ?? {}) as Record<string, number | undefined>;
const existingOverride = nextOverrides.find((override) => override.id === id);
const previousRaw = nextRawConfig[id];
const newThresholds: Record<string, number | undefined> = {
...(existingOverride?.thresholds ?? {}),
};
Object.keys(thresholds).forEach((key) => {
if (thresholds[key] !== undefined) {
const value = thresholds[key];
if (value === defaultThresholds[key]) {
delete newThresholds[key];
} else {
newThresholds[key] = value as number;
}
}
});
const hasStateOnlyOverride = Boolean(
resource.disabled ||
resource.disableConnectivity ||
resource.poweredOffSeverity !== undefined ||
existingOverride?.note !== undefined ||
existingOverride?.backup ||
existingOverride?.snapshot,
);
if (Object.keys(newThresholds).length === 0 && !hasStateOnlyOverride) {
const existingIndex = nextOverrides.findIndex((override) => override.id === id);
if (existingIndex !== -1) nextOverrides.splice(existingIndex, 1);
delete nextRawConfig[id];
continue;
}
const override: Override = {
id,
name: resource.name,
type: resource.type as OverrideType,
resourceType: resource.resourceType,
vmid: 'vmid' in resource ? resource.vmid : undefined,
node: 'node' in resource ? resource.node : undefined,
instance: 'instance' in resource ? resource.instance : undefined,
disabled: resource.disabled,
disableConnectivity: resource.disableConnectivity,
poweredOffSeverity: resource.poweredOffSeverity,
note: existingOverride?.note,
backup: existingOverride?.backup,
snapshot: existingOverride?.snapshot,
thresholds: newThresholds,
};
const existingIndex = nextOverrides.findIndex((entry) => entry.id === id);
if (existingIndex >= 0) {
nextOverrides[existingIndex] = override;
} else {
nextOverrides.push(override);
}
let hysteresisThresholds: RawOverrideConfig = {};
if (previousRaw) {
if (previousRaw.disabled !== undefined) hysteresisThresholds.disabled = previousRaw.disabled;
if (previousRaw.disableConnectivity !== undefined) {
hysteresisThresholds.disableConnectivity = previousRaw.disableConnectivity;
}
if (previousRaw.poweredOffSeverity !== undefined) {
hysteresisThresholds.poweredOffSeverity = previousRaw.poweredOffSeverity;
}
if (previousRaw.note !== undefined) hysteresisThresholds.note = previousRaw.note;
if (previousRaw.backup !== undefined) hysteresisThresholds.backup = previousRaw.backup;
if (previousRaw.snapshot !== undefined) {
hysteresisThresholds.snapshot = previousRaw.snapshot;
}
}
nextRawConfig[id] = withThresholdEntries(hysteresisThresholds, newThresholds);
}
props.setOverrides(nextOverrides);
props.setRawOverridesConfig(nextRawConfig);
props.setHasUnsavedChanges(true);
};
const toggleBackup = (resourceId: string, forceState?: boolean) => {
const resource = guestLikeResources().find((entry) => entry.id === resourceId);
if (!resource || (resource.type !== 'guest' && resource.type !== 'dockerContainer')) return;
const existingOverride = props.overrides().find((override) => override.id === resourceId);
const baseConfig = existingOverride?.backup || props.backupDefaults();
const newBackup = {
...baseConfig,
enabled: forceState !== undefined ? forceState : !baseConfig.enabled,
};
const override: Override = {
...(existingOverride || {
id: resourceId,
instance: 'instance' in resource ? resource.instance : undefined,
name: resource.name,
node: 'node' in resource ? resource.node : undefined,
thresholds: {},
type: resource.type as OverrideType,
vmid: 'vmid' in resource ? resource.vmid : undefined,
}),
backup: newBackup,
};
props.setOverrides(upsertOverride(props.overrides(), override));
props.setRawOverridesConfig({
...props.rawOverridesConfig(),
[resourceId]: {
...(props.rawOverridesConfig()[resourceId] || {}),
backup: newBackup,
},
});
props.setHasUnsavedChanges(true);
};
const toggleSnapshot = (resourceId: string, forceState?: boolean) => {
const resource = guestLikeResources().find((entry) => entry.id === resourceId);
if (!resource || (resource.type !== 'guest' && resource.type !== 'dockerContainer')) return;
const existingOverride = props.overrides().find((override) => override.id === resourceId);
const baseConfig = existingOverride?.snapshot || props.snapshotDefaults();
const newSnapshot = {
...baseConfig,
enabled: forceState !== undefined ? forceState : !baseConfig.enabled,
};
const override: Override = {
...(existingOverride || {
id: resourceId,
instance: 'instance' in resource ? resource.instance : undefined,
name: resource.name,
node: 'node' in resource ? resource.node : undefined,
thresholds: {},
type: resource.type as OverrideType,
vmid: 'vmid' in resource ? resource.vmid : undefined,
}),
snapshot: newSnapshot,
};
props.setOverrides(upsertOverride(props.overrides(), override));
props.setRawOverridesConfig({
...props.rawOverridesConfig(),
[resourceId]: {
...(props.rawOverridesConfig()[resourceId] || {}),
snapshot: newSnapshot,
},
});
props.setHasUnsavedChanges(true);
};
const toggleDisabled = (resourceId: string, forceState?: boolean) => {
const resource = [
...guestLikeResources(),
...resources.storageWithOverrides(),
...resources.pbsServersWithOverrides(),
...resources.agentsWithOverrides(),
...resources.agentDisksWithOverrides(),
].find((entry) => entry.id === resourceId);
if (
!resource ||
(resource.type !== 'guest' &&
resource.type !== 'storage' &&
resource.type !== 'pbs' &&
resource.type !== 'dockerContainer' &&
resource.type !== 'agent' &&
resource.type !== 'agentDisk')
) {
return;
}
const existingOverride = props.overrides().find((override) => override.id === resourceId);
const currentDisabledState = resource.disabled;
const newDisabledState = forceState !== undefined ? forceState : !currentDisabledState;
const cleanThresholds = stripStateKeys({ ...(existingOverride?.thresholds || {}) });
if (!newDisabledState && (!existingOverride || Object.keys(cleanThresholds).length === 0)) {
removeOverride(resourceId);
} else {
const override: Override = {
id: resourceId,
name: resource.name,
type: resource.type as OverrideType,
resourceType: resource.resourceType,
vmid: 'vmid' in resource ? resource.vmid : undefined,
node: 'node' in resource ? resource.node : undefined,
instance: 'instance' in resource ? resource.instance : undefined,
disabled: newDisabledState,
disableConnectivity: existingOverride?.disableConnectivity,
poweredOffSeverity: existingOverride?.poweredOffSeverity,
backup: existingOverride?.backup,
snapshot: existingOverride?.snapshot,
thresholds: cleanThresholds,
};
props.setOverrides(upsertOverride(props.overrides(), override));
let hysteresisThresholds: RawOverrideConfig = withThresholdEntries({}, override.thresholds);
if (newDisabledState) hysteresisThresholds.disabled = true;
if (override.backup) hysteresisThresholds.backup = override.backup;
if (override.snapshot) hysteresisThresholds.snapshot = override.snapshot;
if (override.disableConnectivity) hysteresisThresholds.disableConnectivity = true;
if (override.poweredOffSeverity) {
hysteresisThresholds.poweredOffSeverity = override.poweredOffSeverity;
}
const nextRawConfig = { ...props.rawOverridesConfig() };
if (Object.keys(hysteresisThresholds).length === 0) {
delete nextRawConfig[resourceId];
} else {
nextRawConfig[resourceId] = hysteresisThresholds;
}
props.setRawOverridesConfig(nextRawConfig);
}
if (newDisabledState && props.removeAlerts) {
if (resource.type === 'guest') {
props.removeAlerts(
(alert) => alert.resourceId === resourceId && alert.type === 'powered-off',
);
} else if (resource.type === 'pbs') {
const offlineId = `pbs-offline-${resourceId}`;
props.removeAlerts(
(alert) =>
alert.resourceId === resourceId &&
(matchesAlertIdentifier(alert, offlineId) || alert.type === 'offline'),
);
} else if (resource.type === 'dockerContainer') {
props.removeAlerts(
(alert) =>
alert.resourceId === resourceId &&
(alert.type === 'docker-container-state' || alert.type === 'docker-container-health'),
);
}
}
props.setHasUnsavedChanges(true);
};
const toggleNodeConnectivity = (resourceId: string, forceState?: boolean) => {
const resource = [
...resources.nodesWithOverrides(),
...resources.pbsServersWithOverrides(),
...resources.guestsFlat(),
...resources.agentsWithOverrides(),
...resources.dockerHostsWithOverrides(),
].find((entry) => entry.id === resourceId);
if (
!resource ||
(resource.type !== 'agent' &&
resource.type !== 'pbs' &&
resource.type !== 'guest' &&
resource.type !== 'dockerHost')
) {
return;
}
const existingOverride = props.overrides().find((override) => override.id === resourceId);
const currentDisableConnectivity = resource.disableConnectivity;
const newDisableConnectivity =
forceState !== undefined ? forceState : !currentDisableConnectivity;
const cleanThresholds = stripStateKeys({ ...(existingOverride?.thresholds || {}) });
if (!newDisableConnectivity && Object.keys(cleanThresholds).length === 0) {
removeOverride(resourceId);
} else {
const override: Override = {
id: resourceId,
name: resource.name,
type: resource.type as OverrideType,
resourceType: resource.resourceType,
disableConnectivity: newDisableConnectivity,
disabled: existingOverride?.disabled,
poweredOffSeverity: existingOverride?.poweredOffSeverity,
backup: existingOverride?.backup,
snapshot: existingOverride?.snapshot,
thresholds: cleanThresholds,
};
props.setOverrides(upsertOverride(props.overrides(), override));
let hysteresisThresholds: RawOverrideConfig = withThresholdEntries({}, cleanThresholds);
if (newDisableConnectivity) {
hysteresisThresholds.disableConnectivity = true;
}
if (override.backup) hysteresisThresholds.backup = override.backup;
if (override.snapshot) hysteresisThresholds.snapshot = override.snapshot;
if (override.disabled) hysteresisThresholds.disabled = true;
if (override.poweredOffSeverity) {
hysteresisThresholds.poweredOffSeverity = override.poweredOffSeverity;
}
const nextRawConfig = { ...props.rawOverridesConfig() };
if (Object.keys(hysteresisThresholds).length === 0) {
delete nextRawConfig[resourceId];
} else {
nextRawConfig[resourceId] = hysteresisThresholds;
}
props.setRawOverridesConfig(nextRawConfig);
}
props.setHasUnsavedChanges(true);
if (props.removeAlerts && resource.type === 'dockerHost') {
const offlineId = `docker-host-offline-${resourceId}`;
const resourceKey = `docker:${resourceId}`;
props.removeAlerts(
(alert) => matchesAlertIdentifier(alert, offlineId) || alert.resourceId === resourceKey,
);
}
};
const setOfflineState = (resourceId: string, state: OfflineState) => {
const resource = guestLikeResources().find((entry) => entry.id === resourceId);
if (!resource) return;
const isDockerContainer = resource.type === 'dockerContainer';
const defaultDisabled = isDockerContainer
? props.dockerDisableConnectivity()
: props.guestDisableConnectivity();
const defaultSeverity = isDockerContainer
? props.dockerPoweredOffSeverity()
: props.guestPoweredOffSeverity();
const existingOverride = props.overrides().find((override) => override.id === resourceId);
const cleanThresholds = stripStateKeys({ ...(existingOverride?.thresholds || {}) });
const newDisableConnectivity = state === 'off';
const newSeverity: 'warning' | 'critical' | undefined =
state === 'off' ? undefined : state === 'critical' ? 'critical' : 'warning';
const overrideDisabled = existingOverride?.disabled || false;
const hasThresholds = Object.keys(cleanThresholds).length > 0;
const differsFromDefaults =
newDisableConnectivity !== defaultDisabled ||
(!newDisableConnectivity && newSeverity !== defaultSeverity);
if (
!differsFromDefaults &&
!hasThresholds &&
!overrideDisabled &&
!existingOverride?.disableConnectivity
) {
if (existingOverride) {
removeOverride(resourceId);
}
return;
}
const override: Override = {
id: resourceId,
name: resource.name,
type: resource.type as OverrideType,
resourceType: resource.resourceType,
vmid: 'vmid' in resource ? resource.vmid : undefined,
node: 'node' in resource ? resource.node : undefined,
instance: 'instance' in resource ? resource.instance : undefined,
disabled: overrideDisabled,
disableConnectivity: newDisableConnectivity,
poweredOffSeverity: newDisableConnectivity ? undefined : newSeverity,
backup: existingOverride?.backup,
snapshot: existingOverride?.snapshot,
thresholds: cleanThresholds,
};
props.setOverrides(upsertOverride(props.overrides(), override));
let hysteresisThresholds: RawOverrideConfig = withThresholdEntries({}, cleanThresholds);
if (overrideDisabled) hysteresisThresholds.disabled = true;
if (newDisableConnectivity) {
hysteresisThresholds.disableConnectivity = true;
} else {
if (defaultDisabled) hysteresisThresholds.disableConnectivity = false;
if (newSeverity) hysteresisThresholds.poweredOffSeverity = newSeverity;
}
if (override.backup) hysteresisThresholds.backup = override.backup;
if (override.snapshot) hysteresisThresholds.snapshot = override.snapshot;
const nextRawConfig = { ...props.rawOverridesConfig() };
if (Object.keys(hysteresisThresholds).length > 0) {
nextRawConfig[resourceId] = hysteresisThresholds;
} else {
delete nextRawConfig[resourceId];
}
props.setRawOverridesConfig(nextRawConfig);
props.setHasUnsavedChanges(true);
if (props.removeAlerts && newDisableConnectivity) {
if (resource.type === 'guest') {
props.removeAlerts(
(alert) => alert.resourceId === resourceId && alert.type === 'powered-off',
);
} else if (resource.type === 'dockerContainer') {
props.removeAlerts(
(alert) =>
alert.resourceId === resourceId &&
(alert.type === 'docker-container-state' || alert.type === 'docker-container-health'),
);
}
}
};
return {
handleSaveBulkEdit,
removeOverride,
saveEdit,
setOfflineState,
toggleBackup,
toggleDisabled,
toggleNodeConnectivity,
toggleSnapshot,
} as const;
}
@@ -1,12 +1,7 @@
import { createEffect, createMemo, createSignal } from 'solid-js';
import { useLocation, useNavigate } from '@solidjs/router';
import type {
RawOverrideConfig,
PMGThresholdDefaults,
SnapshotAlertConfig,
BackupAlertConfig,
} from '@/types/alerts';
import type { PMGThresholdDefaults, SnapshotAlertConfig, BackupAlertConfig } from '@/types/alerts';
import { useAlertsActivation } from '@/stores/alertsActivation';
import { logger } from '@/utils/logger';
import {
@@ -36,29 +31,16 @@ import {
STORAGE_THRESHOLDS_EMPTY_STATE,
STORAGE_THRESHOLDS_FILTER_EMPTY_STATE,
} from '@/utils/alertThresholdsPresentation';
import { matchesAlertIdentifier } from '@/features/alerts/identity';
import {
PMG_THRESHOLD_COLUMNS,
DEFAULT_SNAPSHOT_WARNING,
DEFAULT_SNAPSHOT_CRITICAL,
DEFAULT_SNAPSHOT_WARNING_SIZE,
DEFAULT_SNAPSHOT_CRITICAL_SIZE,
DEFAULT_BACKUP_WARNING,
DEFAULT_BACKUP_CRITICAL,
} from '@/features/alerts/thresholds/constants';
import { PMG_THRESHOLD_COLUMNS } from '@/features/alerts/thresholds/constants';
import { normalizeDockerIgnoredInput } from '@/features/alerts/thresholds/helpers';
import type {
Override,
OverrideType,
OfflineState,
ThresholdsTableProps,
} from '@/features/alerts/thresholds/types';
import type { ThresholdsTableProps } from '@/features/alerts/thresholds/types';
import type {
Resource as TableResource,
ThresholdsActiveTab,
} from '@/features/alerts/thresholds/tableTypes';
import { useCollapsedSections } from '@/components/Alerts/Thresholds/hooks/useCollapsedSections';
import { useThresholdsData } from './useThresholdsData';
import { useThresholdsOverrideMutations } from './useThresholdsOverrideMutations';
const HELP_BANNER_KEY = 'pulse-thresholds-help-dismissed';
@@ -402,187 +384,35 @@ export function useThresholdsTableState(props: ThresholdsTableProps) {
setEditingNote('');
};
const saveEdit = (resourceId: string) => {
const allResources = [
...nodesWithOverrides(),
...agentsWithOverrides(),
...agentDisksWithOverrides(),
...dockerHostsWithOverrides(),
...guestsFlat(),
...dockerContainersFlat(),
...storageWithOverrides(),
...pbsServersWithOverrides(),
];
const resource = allResources.find((entry) => entry.id === resourceId);
if (!resource) return;
const editedThresholdMap = editingThresholds();
const trimmedNote = editingNote().trim();
const noteForOverride = trimmedNote.length > 0 ? trimmedNote : undefined;
if (resource.editScope === 'backup') {
const currentBackupDefaults = props.backupDefaults();
updateBackupDefaults({
criticalDays:
editedThresholdMap['critical days'] ??
currentBackupDefaults.criticalDays ??
DEFAULT_BACKUP_CRITICAL,
enabled: currentBackupDefaults.enabled,
warningDays:
editedThresholdMap['warning days'] ??
currentBackupDefaults.warningDays ??
DEFAULT_BACKUP_WARNING,
});
cancelEdit();
return;
}
if (resource.editScope === 'snapshot') {
const currentSnapshotDefaults = props.snapshotDefaults();
updateSnapshotDefaults({
criticalDays:
editedThresholdMap['critical days'] ??
currentSnapshotDefaults.criticalDays ??
DEFAULT_SNAPSHOT_CRITICAL,
criticalSizeGiB:
editedThresholdMap['critical size (gib)'] ??
currentSnapshotDefaults.criticalSizeGiB ??
DEFAULT_SNAPSHOT_CRITICAL_SIZE,
enabled: currentSnapshotDefaults.enabled,
warningDays:
editedThresholdMap['warning days'] ??
currentSnapshotDefaults.warningDays ??
DEFAULT_SNAPSHOT_WARNING,
warningSizeGiB:
editedThresholdMap['warning size (gib)'] ??
currentSnapshotDefaults.warningSizeGiB ??
DEFAULT_SNAPSHOT_WARNING_SIZE,
});
cancelEdit();
return;
}
const defaultThresholds = (resource.defaults ?? {}) as Record<string, number | undefined>;
const overrideThresholds: Record<string, number> = {};
Object.keys(editedThresholdMap).forEach((key) => {
const editedValue = editedThresholdMap[key];
const defaultValue = defaultThresholds[key];
if (editedValue !== undefined && editedValue !== defaultValue) {
overrideThresholds[key] = editedValue;
}
});
const existingOverride = props.overrides().find((override) => override.id === resourceId);
const hasStateOnlyOverride = Boolean(
resource.disabled ||
resource.disableConnectivity ||
resource.poweredOffSeverity !== undefined ||
noteForOverride !== undefined ||
existingOverride?.backup ||
existingOverride?.snapshot,
);
if (Object.keys(overrideThresholds).length === 0 && !hasStateOnlyOverride) {
if (resource.hasOverride) {
props.setOverrides(props.overrides().filter((override) => override.id !== resourceId));
const newRawConfig = { ...props.rawOverridesConfig() };
delete newRawConfig[resourceId];
props.setRawOverridesConfig(newRawConfig);
props.setHasUnsavedChanges(true);
}
cancelEdit();
return;
}
const override: Override = {
id: resourceId,
name: resource.name,
type: resource.type as OverrideType,
resourceType: resource.resourceType,
vmid: 'vmid' in resource ? resource.vmid : undefined,
node: 'node' in resource ? resource.node : undefined,
instance: 'instance' in resource ? resource.instance : undefined,
disabled: resource.disabled,
disableConnectivity: resource.disableConnectivity,
poweredOffSeverity: resource.poweredOffSeverity,
note: noteForOverride,
backup: existingOverride?.backup,
snapshot: existingOverride?.snapshot,
thresholds: overrideThresholds,
};
const existingIndex = props.overrides().findIndex((entry) => entry.id === resourceId);
if (existingIndex >= 0) {
const nextOverrides = [...props.overrides()];
nextOverrides[existingIndex] = override;
props.setOverrides(nextOverrides);
} else {
props.setOverrides([...props.overrides(), override]);
}
const newRawConfig: Record<string, RawOverrideConfig> = { ...props.rawOverridesConfig() };
const previousRaw = props.rawOverridesConfig()[resourceId];
const hysteresisThresholds: RawOverrideConfig = {};
if (previousRaw) {
if (previousRaw.disabled !== undefined) hysteresisThresholds.disabled = previousRaw.disabled;
if (previousRaw.disableConnectivity !== undefined) {
hysteresisThresholds.disableConnectivity = previousRaw.disableConnectivity;
}
if (previousRaw.poweredOffSeverity) {
hysteresisThresholds.poweredOffSeverity = previousRaw.poweredOffSeverity;
}
}
Object.entries(overrideThresholds).forEach(([metric, value]) => {
if (value !== undefined && value !== null) {
hysteresisThresholds[metric] = {
clear: Math.max(0, value - 5),
trigger: value,
};
}
});
if (resource.disabled) {
hysteresisThresholds.disabled = true;
} else {
delete hysteresisThresholds.disabled;
}
if (resource.disableConnectivity) {
hysteresisThresholds.disableConnectivity = true;
delete hysteresisThresholds.poweredOffSeverity;
} else {
if (
(resource.type === 'guest' || resource.type === 'dockerContainer') &&
props.guestDisableConnectivity()
) {
hysteresisThresholds.disableConnectivity = false;
} else {
delete hysteresisThresholds.disableConnectivity;
}
if (resource.poweredOffSeverity) {
hysteresisThresholds.poweredOffSeverity = resource.poweredOffSeverity;
} else {
delete hysteresisThresholds.poweredOffSeverity;
}
}
if (noteForOverride) {
hysteresisThresholds.note = noteForOverride;
} else {
delete hysteresisThresholds.note;
}
if (previousRaw?.backup) hysteresisThresholds.backup = previousRaw.backup;
if (previousRaw?.snapshot) hysteresisThresholds.snapshot = previousRaw.snapshot;
newRawConfig[resourceId] = hysteresisThresholds;
props.setRawOverridesConfig(newRawConfig);
props.setHasUnsavedChanges(true);
cancelEdit();
};
const {
handleSaveBulkEdit: persistBulkEdit,
removeOverride,
saveEdit,
setOfflineState,
toggleBackup,
toggleDisabled,
toggleNodeConnectivity,
toggleSnapshot,
} = useThresholdsOverrideMutations({
props,
resources: {
nodesWithOverrides,
agentsWithOverrides,
agentDisksWithOverrides,
dockerHostsWithOverrides,
guestsFlat,
dockerContainersFlat,
pbsServersWithOverrides,
pmgServersWithOverrides,
storageWithOverrides,
},
editingThresholds,
editingNote,
bulkEditIds,
cancelEdit,
updateBackupDefaults,
updateSnapshotDefaults,
});
const handleBulkEdit = (ids: string[], columns: string[]) => {
setBulkEditIds(ids);
@@ -592,114 +422,7 @@ export function useThresholdsTableState(props: ThresholdsTableProps) {
const handleSaveBulkEdit = (thresholds: Record<string, number | undefined>) => {
setIsBulkEditDialogOpen(false);
const nextOverrides = [...props.overrides()];
const nextRawConfig = { ...props.rawOverridesConfig() };
const allResources = [
...nodesWithOverrides(),
...agentsWithOverrides(),
...agentDisksWithOverrides(),
...dockerHostsWithOverrides(),
...pbsServersWithOverrides(),
...pmgServersWithOverrides(),
...storageWithOverrides(),
];
for (const id of bulkEditIds()) {
const resource = allResources.find((entry) => entry.id === id);
if (!resource) continue;
const defaultThresholds = (resource.defaults ?? {}) as Record<string, number | undefined>;
const existingOverride = nextOverrides.find((override) => override.id === id);
const previousRaw = nextRawConfig[id];
const newThresholds: Record<string, number | undefined> = {
...(existingOverride?.thresholds ?? {}),
};
Object.keys(thresholds).forEach((key) => {
if (thresholds[key] !== undefined) {
const value = thresholds[key];
if (value === defaultThresholds[key]) {
delete newThresholds[key];
} else {
newThresholds[key] = value as number;
}
}
});
const hasStateOnlyOverride = Boolean(
resource.disabled ||
resource.disableConnectivity ||
resource.poweredOffSeverity !== undefined ||
existingOverride?.note !== undefined ||
existingOverride?.backup ||
existingOverride?.snapshot,
);
if (Object.keys(newThresholds).length === 0 && !hasStateOnlyOverride) {
if (resource.hasOverride) {
const existingIndex = nextOverrides.findIndex((override) => override.id === id);
if (existingIndex !== -1) nextOverrides.splice(existingIndex, 1);
delete nextRawConfig[id];
}
continue;
}
const override: Override = {
id,
name: resource.name,
type: resource.type as OverrideType,
resourceType: resource.resourceType,
vmid: 'vmid' in resource ? resource.vmid : undefined,
node: 'node' in resource ? resource.node : undefined,
instance: 'instance' in resource ? resource.instance : undefined,
disabled: resource.disabled,
disableConnectivity: resource.disableConnectivity,
poweredOffSeverity: resource.poweredOffSeverity,
note: existingOverride?.note,
backup: existingOverride?.backup,
snapshot: existingOverride?.snapshot,
thresholds: newThresholds,
};
const existingIndex = nextOverrides.findIndex((entry) => entry.id === id);
if (existingIndex >= 0) {
nextOverrides[existingIndex] = override;
} else {
nextOverrides.push(override);
}
const hysteresisThresholds: RawOverrideConfig = {};
if (previousRaw) {
if (previousRaw.disabled !== undefined) hysteresisThresholds.disabled = previousRaw.disabled;
if (previousRaw.disableConnectivity !== undefined) {
hysteresisThresholds.disableConnectivity = previousRaw.disableConnectivity;
}
if (previousRaw.poweredOffSeverity !== undefined) {
hysteresisThresholds.poweredOffSeverity = previousRaw.poweredOffSeverity;
}
if (previousRaw.note !== undefined) hysteresisThresholds.note = previousRaw.note;
if (previousRaw.backup !== undefined) hysteresisThresholds.backup = previousRaw.backup;
if (previousRaw.snapshot !== undefined) {
hysteresisThresholds.snapshot = previousRaw.snapshot;
}
}
Object.entries(newThresholds).forEach(([metric, value]) => {
if (value !== undefined && value !== null) {
hysteresisThresholds[metric] = {
clear: Math.max(0, value - 5),
trigger: value,
};
}
});
nextRawConfig[id] = hysteresisThresholds;
}
props.setOverrides(nextOverrides);
props.setRawOverridesConfig(nextRawConfig);
props.setHasUnsavedChanges(true);
persistBulkEdit(thresholds);
setBulkEditIds([]);
setBulkEditColumns([]);
};
@@ -751,420 +474,6 @@ export function useThresholdsTableState(props: ThresholdsTableProps) {
}
};
const removeOverride = (resourceId: string) => {
props.setOverrides(props.overrides().filter((override) => override.id !== resourceId));
const newRawConfig = { ...props.rawOverridesConfig() };
delete newRawConfig[resourceId];
props.setRawOverridesConfig(newRawConfig);
props.setHasUnsavedChanges(true);
};
const toggleBackup = (resourceId: string, forceState?: boolean) => {
const resource = [...guestsFlat(), ...dockerContainersFlat()].find((entry) => entry.id === resourceId);
if (!resource || (resource.type !== 'guest' && resource.type !== 'dockerContainer')) return;
const existingOverride = props.overrides().find((override) => override.id === resourceId);
const baseConfig = existingOverride?.backup || props.backupDefaults();
const newBackup = {
...baseConfig,
enabled: forceState !== undefined ? forceState : !baseConfig.enabled,
};
const override: Override = {
...(existingOverride || {
id: resourceId,
instance: 'instance' in resource ? resource.instance : undefined,
name: resource.name,
node: 'node' in resource ? resource.node : undefined,
thresholds: {},
type: resource.type as OverrideType,
vmid: 'vmid' in resource ? resource.vmid : undefined,
}),
backup: newBackup,
};
const existingIndex = props.overrides().findIndex((entry) => entry.id === resourceId);
if (existingIndex >= 0) {
const nextOverrides = [...props.overrides()];
nextOverrides[existingIndex] = override;
props.setOverrides(nextOverrides);
} else {
props.setOverrides([...props.overrides(), override]);
}
const newRawConfig = { ...props.rawOverridesConfig() };
newRawConfig[resourceId] = {
...(newRawConfig[resourceId] || {}),
backup: newBackup,
};
props.setRawOverridesConfig(newRawConfig);
props.setHasUnsavedChanges(true);
};
const toggleSnapshot = (resourceId: string, forceState?: boolean) => {
const resource = [...guestsFlat(), ...dockerContainersFlat()].find((entry) => entry.id === resourceId);
if (!resource || (resource.type !== 'guest' && resource.type !== 'dockerContainer')) return;
const existingOverride = props.overrides().find((override) => override.id === resourceId);
const baseConfig = existingOverride?.snapshot || props.snapshotDefaults();
const newSnapshot = {
...baseConfig,
enabled: forceState !== undefined ? forceState : !baseConfig.enabled,
};
const override: Override = {
...(existingOverride || {
id: resourceId,
instance: 'instance' in resource ? resource.instance : undefined,
name: resource.name,
node: 'node' in resource ? resource.node : undefined,
thresholds: {},
type: resource.type as OverrideType,
vmid: 'vmid' in resource ? resource.vmid : undefined,
}),
snapshot: newSnapshot,
};
const existingIndex = props.overrides().findIndex((entry) => entry.id === resourceId);
if (existingIndex >= 0) {
const nextOverrides = [...props.overrides()];
nextOverrides[existingIndex] = override;
props.setOverrides(nextOverrides);
} else {
props.setOverrides([...props.overrides(), override]);
}
const newRawConfig = { ...props.rawOverridesConfig() };
newRawConfig[resourceId] = {
...(newRawConfig[resourceId] || {}),
snapshot: newSnapshot,
};
props.setRawOverridesConfig(newRawConfig);
props.setHasUnsavedChanges(true);
};
const toggleDisabled = (resourceId: string, forceState?: boolean) => {
const resource = [
...guestsFlat(),
...dockerContainersFlat(),
...storageWithOverrides(),
...pbsServersWithOverrides(),
...agentsWithOverrides(),
...agentDisksWithOverrides(),
].find((entry) => entry.id === resourceId);
if (
!resource ||
(resource.type !== 'guest' &&
resource.type !== 'storage' &&
resource.type !== 'pbs' &&
resource.type !== 'dockerContainer' &&
resource.type !== 'agent' &&
resource.type !== 'agentDisk')
) {
return;
}
const existingOverride = props.overrides().find((override) => override.id === resourceId);
const currentDisabledState = resource.disabled;
const newDisabledState = forceState !== undefined ? forceState : !currentDisabledState;
const cleanThresholds: Record<string, number> = { ...(existingOverride?.thresholds || {}) };
delete (cleanThresholds as Record<string, unknown>).disabled;
if (!newDisabledState && (!existingOverride || Object.keys(cleanThresholds).length === 0)) {
props.setOverrides(props.overrides().filter((override) => override.id !== resourceId));
const newRawConfig = { ...props.rawOverridesConfig() };
delete newRawConfig[resourceId];
props.setRawOverridesConfig(newRawConfig);
} else {
const override: Override = {
id: resourceId,
name: resource.name,
type: resource.type as OverrideType,
resourceType: resource.resourceType,
vmid: 'vmid' in resource ? resource.vmid : undefined,
node: 'node' in resource ? resource.node : undefined,
instance: 'instance' in resource ? resource.instance : undefined,
disabled: newDisabledState,
disableConnectivity: existingOverride?.disableConnectivity,
poweredOffSeverity: existingOverride?.poweredOffSeverity,
backup: existingOverride?.backup,
snapshot: existingOverride?.snapshot,
thresholds: cleanThresholds,
};
const existingIndex = props.overrides().findIndex((entry) => entry.id === resourceId);
if (existingIndex >= 0) {
const nextOverrides = [...props.overrides()];
nextOverrides[existingIndex] = override;
props.setOverrides(nextOverrides);
} else {
props.setOverrides([...props.overrides(), override]);
}
const newRawConfig: Record<string, RawOverrideConfig> = { ...props.rawOverridesConfig() };
const hysteresisThresholds: RawOverrideConfig = {};
Object.entries(override.thresholds).forEach(([metric, value]) => {
if (typeof value === 'number') {
hysteresisThresholds[metric] = {
clear: Math.max(0, value - 5),
trigger: value,
};
}
});
if (newDisabledState) hysteresisThresholds.disabled = true;
if (override.backup) hysteresisThresholds.backup = override.backup;
if (override.snapshot) hysteresisThresholds.snapshot = override.snapshot;
if (override.disableConnectivity) hysteresisThresholds.disableConnectivity = true;
if (override.poweredOffSeverity) {
hysteresisThresholds.poweredOffSeverity = override.poweredOffSeverity;
}
if (Object.keys(hysteresisThresholds).length === 0) {
delete newRawConfig[resourceId];
} else {
newRawConfig[resourceId] = hysteresisThresholds;
}
props.setRawOverridesConfig(newRawConfig);
}
if (newDisabledState && props.removeAlerts) {
if (resource.type === 'guest') {
props.removeAlerts(
(alert) => alert.resourceId === resourceId && alert.type === 'powered-off',
);
} else if (resource.type === 'pbs') {
const offlineId = `pbs-offline-${resourceId}`;
props.removeAlerts(
(alert) =>
alert.resourceId === resourceId &&
(matchesAlertIdentifier(alert, offlineId) || alert.type === 'offline'),
);
} else if (resource.type === 'dockerContainer') {
props.removeAlerts(
(alert) =>
alert.resourceId === resourceId &&
(alert.type === 'docker-container-state' || alert.type === 'docker-container-health'),
);
}
}
props.setHasUnsavedChanges(true);
};
const toggleNodeConnectivity = (resourceId: string, forceState?: boolean) => {
const resource = [
...nodesWithOverrides(),
...pbsServersWithOverrides(),
...guestsFlat(),
...agentsWithOverrides(),
...dockerHostsWithOverrides(),
].find((entry) => entry.id === resourceId);
if (
!resource ||
(resource.type !== 'agent' &&
resource.type !== 'pbs' &&
resource.type !== 'guest' &&
resource.type !== 'dockerHost')
) {
return;
}
const existingOverride = props.overrides().find((override) => override.id === resourceId);
const currentDisableConnectivity = resource.disableConnectivity;
const newDisableConnectivity =
forceState !== undefined ? forceState : !currentDisableConnectivity;
const cleanThresholds: Record<string, number> = { ...(existingOverride?.thresholds || {}) };
delete (cleanThresholds as Record<string, unknown>).disabled;
delete (cleanThresholds as Record<string, unknown>).disableConnectivity;
if (!newDisableConnectivity && Object.keys(cleanThresholds).length === 0) {
props.setOverrides(props.overrides().filter((override) => override.id !== resourceId));
const newRawConfig = { ...props.rawOverridesConfig() };
delete newRawConfig[resourceId];
props.setRawOverridesConfig(newRawConfig);
} else {
const override: Override = {
id: resourceId,
name: resource.name,
type: resource.type as OverrideType,
resourceType: resource.resourceType,
disableConnectivity: newDisableConnectivity,
disabled: existingOverride?.disabled,
poweredOffSeverity: existingOverride?.poweredOffSeverity,
backup: existingOverride?.backup,
snapshot: existingOverride?.snapshot,
thresholds: cleanThresholds,
};
const existingIndex = props.overrides().findIndex((entry) => entry.id === resourceId);
if (existingIndex >= 0) {
const nextOverrides = [...props.overrides()];
nextOverrides[existingIndex] = override;
props.setOverrides(nextOverrides);
} else {
props.setOverrides([...props.overrides(), override]);
}
const newRawConfig = { ...props.rawOverridesConfig() };
const hysteresisThresholds: Record<string, unknown> = {};
Object.entries(cleanThresholds).forEach(([metric, value]) => {
if (value !== undefined && value !== null) {
hysteresisThresholds[metric] = {
clear: Math.max(0, value - 5),
trigger: value,
};
}
});
if (newDisableConnectivity) {
hysteresisThresholds.disableConnectivity = true;
}
if (override.backup) hysteresisThresholds.backup = override.backup;
if (override.snapshot) hysteresisThresholds.snapshot = override.snapshot;
if (override.disabled) hysteresisThresholds.disabled = true;
if (override.poweredOffSeverity) {
hysteresisThresholds.poweredOffSeverity = override.poweredOffSeverity;
}
if (Object.keys(hysteresisThresholds).length === 0) {
delete newRawConfig[resourceId];
} else {
newRawConfig[resourceId] = hysteresisThresholds as RawOverrideConfig;
}
props.setRawOverridesConfig(newRawConfig);
}
props.setHasUnsavedChanges(true);
if (props.removeAlerts && resource.type === 'dockerHost') {
const offlineId = `docker-host-offline-${resourceId}`;
const resourceKey = `docker:${resourceId}`;
props.removeAlerts(
(alert) => matchesAlertIdentifier(alert, offlineId) || alert.resourceId === resourceKey,
);
}
};
const setOfflineState = (resourceId: string, state: OfflineState) => {
const resource = [...guestsFlat(), ...dockerContainersFlat()].find((entry) => entry.id === resourceId);
if (!resource) return;
const isDockerContainer = resource.type === 'dockerContainer';
const defaultDisabled = isDockerContainer
? props.dockerDisableConnectivity()
: props.guestDisableConnectivity();
const defaultSeverity = isDockerContainer
? props.dockerPoweredOffSeverity()
: props.guestPoweredOffSeverity();
const existingOverride = props.overrides().find((override) => override.id === resourceId);
const cleanThresholds: Record<string, number> = { ...(existingOverride?.thresholds || {}) };
delete (cleanThresholds as Record<string, unknown>).disabled;
delete (cleanThresholds as Record<string, unknown>).disableConnectivity;
delete (cleanThresholds as Record<string, unknown>).poweredOffSeverity;
const newDisableConnectivity = state === 'off';
const newSeverity: 'warning' | 'critical' | undefined =
state === 'off' ? undefined : state === 'critical' ? 'critical' : 'warning';
const overrideDisabled = existingOverride?.disabled || false;
const hasThresholds = Object.keys(cleanThresholds).length > 0;
const differsFromDefaults =
newDisableConnectivity !== defaultDisabled ||
(!newDisableConnectivity && newSeverity !== defaultSeverity);
if (
!differsFromDefaults &&
!hasThresholds &&
!overrideDisabled &&
!existingOverride?.disableConnectivity
) {
if (existingOverride) {
props.setOverrides(props.overrides().filter((override) => override.id !== resourceId));
const newRawConfig = { ...props.rawOverridesConfig() };
delete newRawConfig[resourceId];
props.setRawOverridesConfig(newRawConfig);
props.setHasUnsavedChanges(true);
}
return;
}
const override: Override = {
id: resourceId,
name: resource.name,
type: resource.type as OverrideType,
resourceType: resource.resourceType,
vmid: 'vmid' in resource ? resource.vmid : undefined,
node: 'node' in resource ? resource.node : undefined,
instance: 'instance' in resource ? resource.instance : undefined,
disabled: overrideDisabled,
disableConnectivity: newDisableConnectivity,
poweredOffSeverity: newDisableConnectivity ? undefined : newSeverity,
backup: existingOverride?.backup,
snapshot: existingOverride?.snapshot,
thresholds: cleanThresholds,
};
const existingIndex = props.overrides().findIndex((entry) => entry.id === resourceId);
if (existingIndex >= 0) {
const nextOverrides = [...props.overrides()];
nextOverrides[existingIndex] = override;
props.setOverrides(nextOverrides);
} else {
props.setOverrides([...props.overrides(), override]);
}
const newRawConfig: Record<string, RawOverrideConfig> = { ...props.rawOverridesConfig() };
const hysteresisThresholds: RawOverrideConfig = {};
Object.entries(cleanThresholds).forEach(([metric, value]) => {
if (value !== undefined && value !== null) {
hysteresisThresholds[metric] = {
clear: Math.max(0, value - 5),
trigger: value,
};
}
});
if (overrideDisabled) hysteresisThresholds.disabled = true;
if (newDisableConnectivity) {
hysteresisThresholds.disableConnectivity = true;
} else {
if (defaultDisabled) hysteresisThresholds.disableConnectivity = false;
if (newSeverity) hysteresisThresholds.poweredOffSeverity = newSeverity;
}
if (override.backup) hysteresisThresholds.backup = override.backup;
if (override.snapshot) hysteresisThresholds.snapshot = override.snapshot;
if (Object.keys(hysteresisThresholds).length > 0) {
newRawConfig[resourceId] = hysteresisThresholds;
} else {
delete newRawConfig[resourceId];
}
props.setRawOverridesConfig(newRawConfig);
props.setHasUnsavedChanges(true);
if (props.removeAlerts && newDisableConnectivity) {
if (resource.type === 'guest') {
props.removeAlerts(
(alert) => alert.resourceId === resourceId && alert.type === 'powered-off',
);
} else if (resource.type === 'dockerContainer') {
props.removeAlerts(
(alert) =>
alert.resourceId === resourceId &&
(alert.type === 'docker-container-state' || alert.type === 'docker-container-health'),
);
}
}
};
return {
activeTab,
agentDisksGroupedByAgent,
@@ -18,6 +18,7 @@ import recentAlertsPanelSource from '@/components/Alerts/RecentAlertsPanel.tsx?r
import thresholdsTableSource from '@/components/Alerts/ThresholdsTable.tsx?raw';
import thresholdsDataHookSource from '@/features/alerts/thresholds/hooks/useThresholdsData.ts?raw';
import thresholdsTableStateHookSource from '@/features/alerts/thresholds/hooks/useThresholdsTableState.ts?raw';
import thresholdsOverrideMutationsHookSource from '@/features/alerts/thresholds/hooks/useThresholdsOverrideMutations.ts?raw';
import {
ALERT_TAB_SEGMENTS,
@@ -275,6 +276,15 @@ describe('tab path helpers', () => {
expect(thresholdsDataHookSource).toContain('export function useThresholdsData');
expect(thresholdsTableStateHookSource).toContain('export function useThresholdsTableState');
expect(thresholdsTableStateHookSource).toContain('useThresholdsData(props, editingId, searchTerm)');
expect(thresholdsTableStateHookSource).toContain('useThresholdsOverrideMutations');
expect(thresholdsTableStateHookSource).not.toContain('const saveEdit = (resourceId: string) => {');
expect(thresholdsTableStateHookSource).not.toContain(
'const toggleNodeConnectivity = (resourceId: string, forceState?: boolean) => {',
);
expect(thresholdsOverrideMutationsHookSource).toContain(
'export function useThresholdsOverrideMutations',
);
expect(thresholdsOverrideMutationsHookSource).toContain('matchesAlertIdentifier');
});
});
@@ -340,6 +340,7 @@ import alertThresholdsTabSource from '@/features/alerts/tabs/ThresholdsTab.tsx?r
import thresholdsTableSource from '@/components/Alerts/ThresholdsTable.tsx?raw';
import thresholdsDataHookSource from '@/features/alerts/thresholds/hooks/useThresholdsData.ts?raw';
import thresholdsTableStateHookSource from '@/features/alerts/thresholds/hooks/useThresholdsTableState.ts?raw';
import thresholdsOverrideMutationsHookSource from '@/features/alerts/thresholds/hooks/useThresholdsOverrideMutations.ts?raw';
import alertIncidentPresentationSource from '@/utils/alertIncidentPresentation.ts?raw';
import alertHistoryPresentationSource from '@/utils/alertHistoryPresentation.ts?raw';
import bulkEditDialogSource from '@/components/Alerts/BulkEditDialog.tsx?raw';
@@ -2887,6 +2888,15 @@ describe('frontend resource type boundaries', () => {
expect(thresholdsTableStateHookSource).toContain('export function useThresholdsTableState');
expect(thresholdsTableStateHookSource).toContain('useCollapsedSections()');
expect(thresholdsTableStateHookSource).toContain('useThresholdsData(props, editingId, searchTerm)');
expect(thresholdsTableStateHookSource).toContain('useThresholdsOverrideMutations');
expect(thresholdsTableStateHookSource).not.toContain('const saveEdit = (resourceId: string) => {');
expect(thresholdsTableStateHookSource).not.toContain(
'const toggleNodeConnectivity = (resourceId: string, forceState?: boolean) => {',
);
expect(thresholdsOverrideMutationsHookSource).toContain(
'export function useThresholdsOverrideMutations',
);
expect(thresholdsOverrideMutationsHookSource).toContain('matchesAlertIdentifier');
expect(alertScheduleTabSource).toContain('getAlertGroupingCardClass');
expect(alertScheduleTabSource).toContain('getAlertGroupingCheckboxClass');
expect(alertScheduleTabSource).toContain('getAlertQuietDayButtonClass');