mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-12 22:28:31 +00:00
feat(ui): implement alert overrides for backups/snapshots and add unsaved changes warning (fixes #961, fixes #959)
This commit is contained in:
parent
5cd6224997
commit
1cbe0691e3
4 changed files with 211 additions and 8 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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'}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue