diff --git a/frontend-modern/src/components/Alerts/ResourceTable.tsx b/frontend-modern/src/components/Alerts/ResourceTable.tsx
index a35cd18a6..aad49778c 100644
--- a/frontend-modern/src/components/Alerts/ResourceTable.tsx
+++ b/frontend-modern/src/components/Alerts/ResourceTable.tsx
@@ -85,6 +85,8 @@ export interface Resource {
toggleTitleDisabled?: string;
editable?: boolean;
note?: string;
+ backup?: any;
+ snapshot?: any;
[key: string]: unknown;
}
@@ -120,6 +122,8 @@ interface ResourceTableProps {
globalOfflineSeverity?: 'warning' | 'critical';
onSetGlobalOfflineState?: (state: OfflineState) => void;
onSetOfflineState?: (resourceId: string, state: OfflineState) => void;
+ onToggleBackup?: (resourceId: string, forceState?: boolean) => void;
+ onToggleSnapshot?: (resourceId: string, forceState?: boolean) => void;
showDelayColumn?: boolean;
globalDelaySeconds?: number;
editingId: () => string | null;
@@ -234,6 +238,8 @@ export function ResourceTable(props: ResourceTableProps) {
['memory critical %', 'memoryCriticalPct'],
['warning size (gib)', 'warningSizeGiB'],
['critical size (gib)', 'criticalSizeGiB'],
+ ['backup', 'backup'],
+ ['snapshot', 'snapshot'],
]).get(key);
if (mapped) {
return mapped;
@@ -1211,6 +1217,29 @@ export function ResourceTable(props: ResourceTableProps) {
resourceSupportsMetric(resource.type, metric);
const bounds = metricBounds(metric);
const isDisabled = () => thresholds()?.[metric] === -1;
+ const isSpecialToggle = metric === 'backup' || metric === 'snapshot';
+
+ if (isSpecialToggle) {
+ const config = metric === 'backup' ? resource.backup : resource.snapshot;
+ const isEnabled = config?.enabled ?? true;
+ const onToggle = metric === 'backup' ? props.onToggleBackup : props.onToggleSnapshot;
+ const titlePrefix = metric === 'backup' ? 'Backup' : 'Snapshot';
+
+ return (
+
+ -}>
+
+ onToggle?.(resource.id)}
+ titleEnabled={`${titlePrefix} alerts enabled. Click to disable for this resource.`}
+ titleDisabled={`${titlePrefix} alerts disabled. Click to enable for this resource.`}
+ />
+
+
+ |
+ );
+ }
const openMetricEditor = (e: MouseEvent) => {
e.stopPropagation();
diff --git a/frontend-modern/src/components/Alerts/ThresholdsTable.tsx b/frontend-modern/src/components/Alerts/ThresholdsTable.tsx
index 89b045095..d28da6775 100644
--- a/frontend-modern/src/components/Alerts/ThresholdsTable.tsx
+++ b/frontend-modern/src/components/Alerts/ThresholdsTable.tsx
@@ -67,6 +67,8 @@ interface Override {
disableConnectivity?: boolean; // For nodes only - disable offline alerts
poweredOffSeverity?: 'warning' | 'critical';
note?: string;
+ backup?: BackupAlertConfig;
+ snapshot?: SnapshotAlertConfig;
thresholds: {
cpu?: number;
memory?: number;
@@ -1304,6 +1306,8 @@ export function ThresholdsTable(props: ThresholdsTableProps) {
disableConnectivity: override?.disableConnectivity || false,
thresholds: override?.thresholds || {},
defaults: props.guestDefaults,
+ backup: override?.backup || props.backupDefaults(),
+ snapshot: override?.snapshot || props.snapshotDefaults(),
poweredOffSeverity: overrideSeverity,
};
});
@@ -1934,6 +1938,90 @@ export function ThresholdsTable(props: ThresholdsTableProps) {
props.setHasUnsavedChanges(true);
};
+ const toggleBackup = (resourceId: string, forceState?: boolean) => {
+ const allGuests = guestsFlat();
+ const allDockerContainers = dockerContainersFlat();
+ const resource = [...allGuests, ...allDockerContainers].find((r) => r.id === resourceId);
+ if (!resource || (resource.type !== 'guest' && resource.type !== 'dockerContainer')) return;
+
+ const existingOverride = props.overrides().find((o) => o.id === resourceId);
+ const baseConfig = existingOverride?.backup || props.backupDefaults();
+ const newEnabled = forceState !== undefined ? forceState : !baseConfig.enabled;
+ const newBackup = { ...baseConfig, enabled: newEnabled };
+
+ const override: Override = {
+ ...(existingOverride || {
+ id: resourceId,
+ name: resource.name,
+ type: resource.type as any,
+ vmid: 'vmid' in resource ? (resource as any).vmid : undefined,
+ node: 'node' in resource ? (resource as any).node : undefined,
+ instance: 'instance' in resource ? (resource as any).instance : undefined,
+ thresholds: {},
+ }),
+ backup: newBackup,
+ };
+
+ const existingIndex = props.overrides().findIndex((o) => o.id === resourceId);
+ if (existingIndex >= 0) {
+ const newOverrides = [...props.overrides()];
+ newOverrides[existingIndex] = override;
+ props.setOverrides(newOverrides);
+ } 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 allGuests = guestsFlat();
+ const allDockerContainers = dockerContainersFlat();
+ const resource = [...allGuests, ...allDockerContainers].find((r) => r.id === resourceId);
+ if (!resource || (resource.type !== 'guest' && resource.type !== 'dockerContainer')) return;
+
+ const existingOverride = props.overrides().find((o) => o.id === resourceId);
+ const baseConfig = existingOverride?.snapshot || props.snapshotDefaults();
+ const newEnabled = forceState !== undefined ? forceState : !baseConfig.enabled;
+ const newSnapshot = { ...baseConfig, enabled: newEnabled };
+
+ const override: Override = {
+ ...(existingOverride || {
+ id: resourceId,
+ name: resource.name,
+ type: resource.type as any,
+ vmid: 'vmid' in resource ? (resource as any).vmid : undefined,
+ node: 'node' in resource ? (resource as any).node : undefined,
+ instance: 'instance' in resource ? (resource as any).instance : undefined,
+ thresholds: {},
+ }),
+ snapshot: newSnapshot,
+ };
+
+ const existingIndex = props.overrides().findIndex((o) => o.id === resourceId);
+ if (existingIndex >= 0) {
+ const newOverrides = [...props.overrides()];
+ newOverrides[existingIndex] = override;
+ props.setOverrides(newOverrides);
+ } 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) => {
// Flatten grouped guests to find the resource
const allGuests = guestsFlat();
@@ -1984,8 +2072,12 @@ export function ThresholdsTable(props: ThresholdsTableProps) {
resourceType: resource.resourceType,
vmid: 'vmid' in resource ? resource.vmid : undefined,
node: 'node' in resource ? resource.node : undefined,
- instance: 'instance' in resource ? resource.instance : undefined,
+ instance: 'instance' in resource ? (resource as any).instance : undefined,
disabled: newDisabledState,
+ disableConnectivity: existingOverride?.disableConnectivity,
+ poweredOffSeverity: existingOverride?.poweredOffSeverity,
+ backup: existingOverride?.backup,
+ snapshot: existingOverride?.snapshot,
thresholds: cleanThresholds, // Only keep actual threshold overrides
};
@@ -2018,6 +2110,19 @@ export function ThresholdsTable(props: ThresholdsTableProps) {
delete hysteresisThresholds.disabled;
}
+ 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 {
@@ -2099,6 +2204,10 @@ export function ThresholdsTable(props: ThresholdsTableProps) {
type: resource.type as OverrideType,
resourceType: resource.resourceType,
disableConnectivity: newDisableConnectivity,
+ disabled: existingOverride?.disabled,
+ poweredOffSeverity: existingOverride?.poweredOffSeverity,
+ backup: existingOverride?.backup,
+ snapshot: existingOverride?.snapshot,
thresholds: cleanThresholds,
};
@@ -2132,6 +2241,19 @@ export function ThresholdsTable(props: ThresholdsTableProps) {
delete hysteresisThresholds.disableConnectivity;
}
+ 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 {
@@ -2208,6 +2330,8 @@ export function ThresholdsTable(props: ThresholdsTableProps) {
disabled: overrideDisabled,
disableConnectivity: newDisableConnectivity,
poweredOffSeverity: newDisableConnectivity ? undefined : newSeverity,
+ backup: existingOverride?.backup,
+ snapshot: existingOverride?.snapshot,
thresholds: cleanThresholds,
};
@@ -2247,6 +2371,13 @@ export function ThresholdsTable(props: ThresholdsTableProps) {
}
}
+ if (override.backup) {
+ hysteresisThresholds.backup = override.backup;
+ }
+ if (override.snapshot) {
+ hysteresisThresholds.snapshot = override.snapshot;
+ }
+
if (Object.keys(hysteresisThresholds).length > 0) {
newRawConfig[resourceId] = hysteresisThresholds;
} else {
@@ -2586,6 +2717,8 @@ export function ThresholdsTable(props: ThresholdsTableProps) {
'CPU %',
'Memory %',
'Disk %',
+ 'Backup',
+ 'Snapshot',
'Disk R MB/s',
'Disk W MB/s',
'Net In MB/s',
@@ -2599,6 +2732,8 @@ export function ThresholdsTable(props: ThresholdsTableProps) {
onRemoveOverride={removeOverride}
onToggleDisabled={toggleDisabled}
onToggleNodeConnectivity={toggleNodeConnectivity}
+ onToggleBackup={toggleBackup}
+ onToggleSnapshot={toggleSnapshot}
showOfflineAlertsColumn={true}
editingId={editingId}
editingThresholds={editingThresholds}
diff --git a/frontend-modern/src/pages/Alerts.tsx b/frontend-modern/src/pages/Alerts.tsx
index 1ac752bb6..5e1a6bf57 100644
--- a/frontend-modern/src/pages/Alerts.tsx
+++ b/frontend-modern/src/pages/Alerts.tsx
@@ -1,4 +1,5 @@
import { createSignal, Show, For, createMemo, createEffect, onMount, onCleanup } from 'solid-js';
+import { useBeforeLeave } from '@solidjs/router';
import { usePersistentSignal } from '@/hooks/usePersistentSignal';
import type { JSX } from 'solid-js';
import { EmailProviderSelect } from '@/components/Alerts/EmailProviderSelect';
@@ -221,6 +222,8 @@ interface Override {
disabled?: boolean; // Completely disable alerts for this guest/storage
disableConnectivity?: boolean; // For nodes/hosts - disable offline/connectivity alerts
poweredOffSeverity?: 'warning' | 'critical';
+ backup?: BackupAlertConfig;
+ snapshot?: SnapshotAlertConfig;
thresholds: {
cpu?: number;
memory?: number;
@@ -461,9 +464,17 @@ export const extractTriggerValues = (
const result: Record = {};
Object.entries(thresholds).forEach(([key, value]) => {
// Skip non-threshold fields
- if (key === 'disabled' || key === 'disableConnectivity' || key === 'poweredOffSeverity' || key === 'note') return;
+ if (
+ key === 'disabled' ||
+ key === 'disableConnectivity' ||
+ key === 'poweredOffSeverity' ||
+ key === 'note' ||
+ key === 'backup' ||
+ key === 'snapshot'
+ )
+ return;
if (typeof value === 'string') return;
- result[key] = getTriggerValue(value);
+ result[key] = getTriggerValue(value as any);
});
return result;
};
@@ -581,6 +592,30 @@ export function Alerts() {
localStorage.setItem('hideAlertsQuickTip', 'true');
};
+ // Add beforeunload listener to warn about unsaved changes
+ createEffect(() => {
+ const handleBeforeUnload = (e: BeforeUnloadEvent) => {
+ if (hasUnsavedChanges()) {
+ e.preventDefault();
+ e.returnValue = ''; // Standard way to show confirmation dialog
+ }
+ };
+
+ window.addEventListener('beforeunload', handleBeforeUnload);
+ onCleanup(() => {
+ window.removeEventListener('beforeunload', handleBeforeUnload);
+ });
+ });
+
+ // Warn when navigating within the app
+ useBeforeLeave((e) => {
+ if (hasUnsavedChanges()) {
+ if (!confirm('You have unsaved changes that will be lost. Discard changes and leave?')) {
+ e.preventDefault();
+ }
+ }
+ });
+
// Store references to child component data
let destinationsRef: DestinationsRef = {};
@@ -852,6 +887,8 @@ export function Alerts() {
? 'warning'
: undefined,
thresholds: extractTriggerValues(thresholds),
+ backup: thresholds.backup,
+ snapshot: thresholds.snapshot,
});
}
}
@@ -3270,10 +3307,10 @@ function OverviewTab(props: {
{rule.dismissed_reason === 'expected_behavior' && '✓ Expected'}
{rule.dismissed_reason === 'will_fix_later' && '⏱ Noted'}
diff --git a/frontend-modern/src/types/alerts.ts b/frontend-modern/src/types/alerts.ts
index fd9cfb4a9..5f2ba8621 100644
--- a/frontend-modern/src/types/alerts.ts
+++ b/frontend-modern/src/types/alerts.ts
@@ -25,7 +25,7 @@ export interface AlertThresholds {
networkInLegacy?: number;
networkOutLegacy?: number;
// Allow indexing with string
- [key: string]: HysteresisThreshold | number | boolean | string | undefined;
+ [key: string]: HysteresisThreshold | BackupAlertConfig | SnapshotAlertConfig | number | boolean | string | undefined;
}
export type RawOverrideConfig = AlertThresholds & {
@@ -33,6 +33,8 @@ export type RawOverrideConfig = AlertThresholds & {
disableConnectivity?: boolean;
poweredOffSeverity?: 'warning' | 'critical';
note?: string;
+ backup?: BackupAlertConfig;
+ snapshot?: SnapshotAlertConfig;
// NOTE: To disable individual metrics, set threshold to -1
};