From 1cbe0691e3195019bbf29be99acaf0cd8a4d67da Mon Sep 17 00:00:00 2001 From: rcourtman Date: Tue, 30 Dec 2025 08:43:59 +0000 Subject: [PATCH] feat(ui): implement alert overrides for backups/snapshots and add unsaved changes warning (fixes #961, fixes #959) --- .../src/components/Alerts/ResourceTable.tsx | 29 ++++ .../src/components/Alerts/ThresholdsTable.tsx | 137 +++++++++++++++++- frontend-modern/src/pages/Alerts.tsx | 49 ++++++- frontend-modern/src/types/alerts.ts | 4 +- 4 files changed, 211 insertions(+), 8 deletions(-) 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 };