feat(ui): implement alert overrides for backups/snapshots and add unsaved changes warning (fixes #961, fixes #959)

This commit is contained in:
rcourtman 2025-12-30 08:43:59 +00:00
parent 5cd6224997
commit 1cbe0691e3
4 changed files with 211 additions and 8 deletions

View file

@ -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 (
<td class="p-1 px-2 text-center align-middle">
<Show when={onToggle} fallback={<span class="text-sm text-gray-400">-</span>}>
<div class="flex items-center justify-center">
<StatusBadge
isEnabled={isEnabled}
onToggle={() => onToggle?.(resource.id)}
titleEnabled={`${titlePrefix} alerts enabled. Click to disable for this resource.`}
titleDisabled={`${titlePrefix} alerts disabled. Click to enable for this resource.`}
/>
</div>
</Show>
</td>
);
}
const openMetricEditor = (e: MouseEvent) => {
e.stopPropagation();

View file

@ -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}

View file

@ -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<string, number> = {};
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: {
</Show>
<Show when={rule.created_from === 'dismissed'}>
<span class={`px-1.5 py-0.5 text-xs rounded ${rule.dismissed_reason === 'expected_behavior'
? 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300'
: rule.dismissed_reason === 'will_fix_later'
? 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300'
: 'bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400'
? 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300'
: rule.dismissed_reason === 'will_fix_later'
? 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300'
: 'bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400'
}`}>
{rule.dismissed_reason === 'expected_behavior' && '✓ Expected'}
{rule.dismissed_reason === 'will_fix_later' && '⏱ Noted'}

View file

@ -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
};