Add snapshot size alert thresholds (#585)
This commit is contained in:
parent
30879c3b7b
commit
be26f957c0
20 changed files with 2306 additions and 874 deletions
|
|
@ -320,11 +320,14 @@ Quick start - most settings are in the web UI:
|
|||
- **Settings → Security**: Authentication and API tokens
|
||||
- **Alerts**: Thresholds and notifications
|
||||
|
||||
### Apprise CLI Notifications
|
||||
### Apprise Notifications
|
||||
|
||||
Pulse can broadcast grouped alerts through the [Apprise](https://github.com/caronc/apprise) CLI. Install Apprise on the Pulse host (for example with `pip install apprise`) and configure the targets under **Alerts → Notifications**. Each target URL should be a valid Apprise destination (Discord, Slack, email, SMS, etc.).
|
||||
Pulse can broadcast grouped alerts through [Apprise](https://github.com/caronc/apprise) using either the local CLI or a remote Apprise API gateway. Configure everything under **Alerts → Notifications → Apprise**.
|
||||
|
||||
You can also override the CLI path and execution timeout if Apprise is installed in a non-standard location. Pulse automatically skips Apprise delivery when no targets are configured.
|
||||
- **Local CLI** – Install Apprise on the Pulse host (for example `pip install apprise`) and enter one Apprise URL per line in the delivery targets field. You can override the CLI path and timeout if the executable lives outside of `$PATH`. Pulse skips CLI delivery automatically when no targets are configured.
|
||||
- **Remote API** – Point Pulse at an Apprise API server by providing the base URL (such as `https://apprise-api.local:8000`). Optionally include a configuration key (for `/notify/{key}` routes), an API key header/value pair, and allow self-signed certificates for lab deployments. Targets remain optional in API mode—leave the list empty to let the Apprise server use its stored defaults.
|
||||
|
||||
For both modes, delivery targets accept any Apprise URL (Discord, Slack, email, SMS, etc.). The timeout applies to the CLI process or HTTP request respectively.
|
||||
|
||||
### Configuration Files
|
||||
|
||||
|
|
|
|||
|
|
@ -59,9 +59,15 @@ export interface Webhook {
|
|||
|
||||
export interface AppriseConfig {
|
||||
enabled: boolean;
|
||||
targets: string[];
|
||||
mode?: 'cli' | 'http';
|
||||
targets?: string[];
|
||||
cliPath?: string;
|
||||
timeoutSeconds?: number;
|
||||
serverUrl?: string;
|
||||
configKey?: string;
|
||||
apiKey?: string;
|
||||
apiKeyHeader?: string;
|
||||
skipTlsVerify?: boolean;
|
||||
}
|
||||
|
||||
export interface NotificationTestRequest {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -19,17 +19,15 @@ import type {
|
|||
PMGBackup,
|
||||
Backups,
|
||||
} from '@/types/api';
|
||||
import type { RawOverrideConfig, PMGThresholdDefaults, SnapshotAlertConfig, BackupAlertConfig } from '@/types/alerts';
|
||||
import type {
|
||||
RawOverrideConfig,
|
||||
PMGThresholdDefaults,
|
||||
SnapshotAlertConfig,
|
||||
BackupAlertConfig,
|
||||
} from '@/types/alerts';
|
||||
import { ResourceTable, Resource, GroupHeaderMeta } from './ResourceTable';
|
||||
import { useAlertsActivation } from '@/stores/alertsActivation';
|
||||
type OverrideType =
|
||||
| 'guest'
|
||||
| 'node'
|
||||
| 'storage'
|
||||
| 'pbs'
|
||||
| 'pmg'
|
||||
| 'dockerHost'
|
||||
| 'dockerContainer';
|
||||
type OverrideType = 'guest' | 'node' | 'storage' | 'pbs' | 'pmg' | 'dockerHost' | 'dockerContainer';
|
||||
|
||||
type OfflineState = 'off' | 'warning' | 'critical';
|
||||
|
||||
|
|
@ -110,6 +108,8 @@ export const normalizeDockerIgnoredInput = (value: string): string[] =>
|
|||
|
||||
const DEFAULT_SNAPSHOT_WARNING = 30;
|
||||
const DEFAULT_SNAPSHOT_CRITICAL = 45;
|
||||
const DEFAULT_SNAPSHOT_WARNING_SIZE = 0;
|
||||
const DEFAULT_SNAPSHOT_CRITICAL_SIZE = 0;
|
||||
const DEFAULT_BACKUP_WARNING = 7;
|
||||
const DEFAULT_BACKUP_CRITICAL = 14;
|
||||
|
||||
|
|
@ -143,13 +143,13 @@ interface ThresholdsTableProps {
|
|||
pmgBackups?: PMGBackup[];
|
||||
pmgThresholds: () => PMGThresholdDefaults;
|
||||
setPMGThresholds: (
|
||||
value:
|
||||
| PMGThresholdDefaults
|
||||
| ((prev: PMGThresholdDefaults) => PMGThresholdDefaults),
|
||||
value: PMGThresholdDefaults | ((prev: PMGThresholdDefaults) => PMGThresholdDefaults),
|
||||
) => void;
|
||||
guestDefaults: SimpleThresholds;
|
||||
setGuestDefaults: (
|
||||
value: Record<string, number | undefined> | ((prev: Record<string, number | undefined>) => Record<string, number | undefined>),
|
||||
value:
|
||||
| Record<string, number | undefined>
|
||||
| ((prev: Record<string, number | undefined>) => Record<string, number | undefined>),
|
||||
) => void;
|
||||
guestDisableConnectivity: () => boolean;
|
||||
setGuestDisableConnectivity: (value: boolean) => void;
|
||||
|
|
@ -157,11 +157,43 @@ interface ThresholdsTableProps {
|
|||
setGuestPoweredOffSeverity: (value: 'warning' | 'critical') => void;
|
||||
nodeDefaults: SimpleThresholds;
|
||||
setNodeDefaults: (
|
||||
value: Record<string, number | undefined> | ((prev: Record<string, number | undefined>) => Record<string, number | undefined>),
|
||||
value:
|
||||
| Record<string, number | undefined>
|
||||
| ((prev: Record<string, number | undefined>) => Record<string, number | undefined>),
|
||||
) => void;
|
||||
dockerDefaults: { cpu: number; memory: number; restartCount: number; restartWindow: number; memoryWarnPct: number; memoryCriticalPct: number };
|
||||
dockerDefaults: {
|
||||
cpu: number;
|
||||
memory: number;
|
||||
restartCount: number;
|
||||
restartWindow: number;
|
||||
memoryWarnPct: number;
|
||||
memoryCriticalPct: number;
|
||||
};
|
||||
setDockerDefaults: (
|
||||
value: { cpu: number; memory: number; restartCount: number; restartWindow: number; memoryWarnPct: number; memoryCriticalPct: number } | ((prev: { cpu: number; memory: number; restartCount: number; restartWindow: number; memoryWarnPct: number; memoryCriticalPct: number }) => { cpu: number; memory: number; restartCount: number; restartWindow: number; memoryWarnPct: number; memoryCriticalPct: number }),
|
||||
value:
|
||||
| {
|
||||
cpu: number;
|
||||
memory: number;
|
||||
restartCount: number;
|
||||
restartWindow: number;
|
||||
memoryWarnPct: number;
|
||||
memoryCriticalPct: number;
|
||||
}
|
||||
| ((prev: {
|
||||
cpu: number;
|
||||
memory: number;
|
||||
restartCount: number;
|
||||
restartWindow: number;
|
||||
memoryWarnPct: number;
|
||||
memoryCriticalPct: number;
|
||||
}) => {
|
||||
cpu: number;
|
||||
memory: number;
|
||||
restartCount: number;
|
||||
restartWindow: number;
|
||||
memoryWarnPct: number;
|
||||
memoryCriticalPct: number;
|
||||
}),
|
||||
) => void;
|
||||
dockerIgnoredPrefixes: () => string[];
|
||||
setDockerIgnoredPrefixes: (value: string[] | ((prev: string[]) => string[])) => void;
|
||||
|
|
@ -185,17 +217,13 @@ interface ThresholdsTableProps {
|
|||
) => void;
|
||||
snapshotDefaults: () => SnapshotAlertConfig;
|
||||
setSnapshotDefaults: (
|
||||
value:
|
||||
| SnapshotAlertConfig
|
||||
| ((prev: SnapshotAlertConfig) => SnapshotAlertConfig),
|
||||
value: SnapshotAlertConfig | ((prev: SnapshotAlertConfig) => SnapshotAlertConfig),
|
||||
) => void;
|
||||
snapshotFactoryDefaults?: SnapshotAlertConfig;
|
||||
resetSnapshotDefaults?: () => void;
|
||||
backupDefaults: () => BackupAlertConfig;
|
||||
setBackupDefaults: (
|
||||
value:
|
||||
| BackupAlertConfig
|
||||
| ((prev: BackupAlertConfig) => BackupAlertConfig),
|
||||
value: BackupAlertConfig | ((prev: BackupAlertConfig) => BackupAlertConfig),
|
||||
) => void;
|
||||
backupFactoryDefaults?: BackupAlertConfig;
|
||||
resetBackupDefaults?: () => void;
|
||||
|
|
@ -306,10 +334,7 @@ export function ThresholdsTable(props: ThresholdsTableProps) {
|
|||
if (!el) return false;
|
||||
const tag = el.tagName;
|
||||
return (
|
||||
tag === 'INPUT' ||
|
||||
tag === 'TEXTAREA' ||
|
||||
tag === 'SELECT' ||
|
||||
el.contentEditable === 'true'
|
||||
tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || el.contentEditable === 'true'
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -381,6 +406,11 @@ export function ThresholdsTable(props: ThresholdsTableProps) {
|
|||
return String(value);
|
||||
}
|
||||
|
||||
if (metric === 'warningSizeGiB' || metric === 'criticalSizeGiB') {
|
||||
const rounded = Math.round(value * 10) / 10;
|
||||
return `${rounded} GiB`;
|
||||
}
|
||||
|
||||
// MB/s metrics
|
||||
if (
|
||||
metric === 'diskRead' ||
|
||||
|
|
@ -403,75 +433,80 @@ export function ThresholdsTable(props: ThresholdsTableProps) {
|
|||
};
|
||||
|
||||
// Process nodes with their overrides
|
||||
const getFriendlyNodeName = (value: string, clusterName?: string): string => {
|
||||
if (!value) return value;
|
||||
const getFriendlyNodeName = (value: string, clusterName?: string): string => {
|
||||
if (!value) return value;
|
||||
|
||||
const clusterLower = clusterName?.toLowerCase().trim();
|
||||
const clusterLower = clusterName?.toLowerCase().trim();
|
||||
|
||||
const normalizeToken = (token?: string | null): string => {
|
||||
if (!token) return '';
|
||||
let result = token.replace(/\(.*?\)/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
if (clusterLower) {
|
||||
result = result
|
||||
.split(' ')
|
||||
.filter((part) => part.toLowerCase() !== clusterLower)
|
||||
.join(' ')
|
||||
const normalizeToken = (token?: string | null): string => {
|
||||
if (!token) return '';
|
||||
let result = token
|
||||
.replace(/\(.*?\)/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
if (clusterLower) {
|
||||
result = result
|
||||
.split(' ')
|
||||
.filter((part) => part.toLowerCase() !== clusterLower)
|
||||
.join(' ')
|
||||
.trim();
|
||||
}
|
||||
if (!result) return '';
|
||||
const firstWord = result.split(/\s+/)[0] || result;
|
||||
const withoutDomain = firstWord.includes('.')
|
||||
? (firstWord.split('.')[0] ?? firstWord)
|
||||
: firstWord;
|
||||
return withoutDomain.trim();
|
||||
};
|
||||
|
||||
const parentheticalMatch = value.match(/\(([^)]+)\)/);
|
||||
const parentheticalRaw = parentheticalMatch?.[1]?.trim();
|
||||
|
||||
let base = normalizeToken(value);
|
||||
if (!base) {
|
||||
base = value.trim();
|
||||
}
|
||||
if (!result) return '';
|
||||
const firstWord = result.split(/\s+/)[0] || result;
|
||||
const withoutDomain = firstWord.includes('.') ? firstWord.split('.')[0] ?? firstWord : firstWord;
|
||||
return withoutDomain.trim();
|
||||
|
||||
const parenthetical = normalizeToken(parentheticalRaw);
|
||||
if (parenthetical && parenthetical.toLowerCase() !== base.toLowerCase()) {
|
||||
return parenthetical;
|
||||
}
|
||||
|
||||
return base;
|
||||
};
|
||||
|
||||
const parentheticalMatch = value.match(/\(([^)]+)\)/);
|
||||
const parentheticalRaw = parentheticalMatch?.[1]?.trim();
|
||||
|
||||
let base = normalizeToken(value);
|
||||
if (!base) {
|
||||
base = value.trim();
|
||||
}
|
||||
|
||||
const parenthetical = normalizeToken(parentheticalRaw);
|
||||
if (parenthetical && parenthetical.toLowerCase() !== base.toLowerCase()) {
|
||||
return parenthetical;
|
||||
}
|
||||
|
||||
return base;
|
||||
};
|
||||
|
||||
const buildNodeHeaderMeta = (node: Node) => {
|
||||
const originalDisplayName = node.displayName?.trim() || node.name;
|
||||
const friendlyName = getFriendlyNodeName(originalDisplayName, node.clusterName);
|
||||
const hostValue = node.host?.trim();
|
||||
let host: string | undefined;
|
||||
if (hostValue && hostValue !== '') {
|
||||
host = hostValue.startsWith('http')
|
||||
? hostValue
|
||||
: `https://${hostValue.includes(':') ? hostValue : `${hostValue}:8006`}`;
|
||||
} else if (node.name) {
|
||||
host = `https://${node.name.includes(':') ? node.name : `${node.name}:8006`}`;
|
||||
}
|
||||
|
||||
const headerMeta: GroupHeaderMeta = {
|
||||
type: 'node',
|
||||
displayName: friendlyName,
|
||||
rawName: originalDisplayName,
|
||||
host,
|
||||
status: node.status,
|
||||
clusterName: node.isClusterMember ? node.clusterName?.trim() || 'Cluster' : undefined,
|
||||
isClusterMember: node.isClusterMember ?? false,
|
||||
};
|
||||
|
||||
const keys = new Set<string>();
|
||||
[node.name, originalDisplayName, friendlyName].forEach((value) => {
|
||||
if (value && value.trim()) {
|
||||
keys.add(value.trim());
|
||||
const buildNodeHeaderMeta = (node: Node) => {
|
||||
const originalDisplayName = node.displayName?.trim() || node.name;
|
||||
const friendlyName = getFriendlyNodeName(originalDisplayName, node.clusterName);
|
||||
const hostValue = node.host?.trim();
|
||||
let host: string | undefined;
|
||||
if (hostValue && hostValue !== '') {
|
||||
host = hostValue.startsWith('http')
|
||||
? hostValue
|
||||
: `https://${hostValue.includes(':') ? hostValue : `${hostValue}:8006`}`;
|
||||
} else if (node.name) {
|
||||
host = `https://${node.name.includes(':') ? node.name : `${node.name}:8006`}`;
|
||||
}
|
||||
});
|
||||
|
||||
return { headerMeta, keys };
|
||||
};
|
||||
const headerMeta: GroupHeaderMeta = {
|
||||
type: 'node',
|
||||
displayName: friendlyName,
|
||||
rawName: originalDisplayName,
|
||||
host,
|
||||
status: node.status,
|
||||
clusterName: node.isClusterMember ? node.clusterName?.trim() || 'Cluster' : undefined,
|
||||
isClusterMember: node.isClusterMember ?? false,
|
||||
};
|
||||
|
||||
const keys = new Set<string>();
|
||||
[node.name, originalDisplayName, friendlyName].forEach((value) => {
|
||||
if (value && value.trim()) {
|
||||
keys.add(value.trim());
|
||||
}
|
||||
});
|
||||
|
||||
return { headerMeta, keys };
|
||||
};
|
||||
|
||||
const nodesWithOverrides = createMemo<Resource[]>((prev = []) => {
|
||||
// If we're currently editing, return the previous value to avoid re-renders
|
||||
|
|
@ -496,16 +531,16 @@ const buildNodeHeaderMeta = (node: Node) => {
|
|||
);
|
||||
});
|
||||
|
||||
|
||||
const originalDisplayName = node.displayName?.trim() || node.name;
|
||||
const friendlyName = getFriendlyNodeName(originalDisplayName, node.clusterName);
|
||||
const rawName = node.name;
|
||||
const sanitizedName = friendlyName || originalDisplayName || rawName.split('.')[0] || rawName;
|
||||
// Build a best-effort management URL for the node
|
||||
const hostValue = node.host?.trim() || rawName;
|
||||
const normalizedHost = hostValue.startsWith('http://') || hostValue.startsWith('https://')
|
||||
? hostValue
|
||||
: `https://${hostValue.includes(':') ? hostValue : `${hostValue}:8006`}`;
|
||||
const normalizedHost =
|
||||
hostValue.startsWith('http://') || hostValue.startsWith('https://')
|
||||
? hostValue
|
||||
: `https://${hostValue.includes(':') ? hostValue : `${hostValue}:8006`}`;
|
||||
|
||||
return {
|
||||
id: node.id,
|
||||
|
|
@ -602,7 +637,7 @@ const buildNodeHeaderMeta = (node: Node) => {
|
|||
}, []);
|
||||
|
||||
// Process Docker containers grouped by host
|
||||
const dockerContainersGroupedByHost = createMemo<Record<string, Resource[]>>((prev = {}) => {
|
||||
const dockerContainersGroupedByHost = createMemo<Record<string, Resource[]>>((prev = {}) => {
|
||||
if (editingId()) {
|
||||
return prev;
|
||||
}
|
||||
|
|
@ -646,9 +681,12 @@ const dockerContainersGroupedByHost = createMemo<Record<string, Resource[]>>((pr
|
|||
);
|
||||
});
|
||||
|
||||
|
||||
const hasOverride =
|
||||
hasCustomThresholds || override?.disabled || override?.disableConnectivity || overrideSeverity !== undefined || false;
|
||||
hasCustomThresholds ||
|
||||
override?.disabled ||
|
||||
override?.disableConnectivity ||
|
||||
overrideSeverity !== undefined ||
|
||||
false;
|
||||
|
||||
const containerName = normalizeContainerName(container);
|
||||
const containerNameLower = containerName.toLowerCase();
|
||||
|
|
@ -770,8 +808,9 @@ const dockerContainersGroupedByHost = createMemo<Record<string, Resource[]>>((pr
|
|||
});
|
||||
|
||||
const countOverrides = (resources: Resource[] | undefined) =>
|
||||
resources?.filter((resource) => resource.hasOverride || resource.disabled || resource.disableConnectivity)
|
||||
.length ?? 0;
|
||||
resources?.filter(
|
||||
(resource) => resource.hasOverride || resource.disabled || resource.disableConnectivity,
|
||||
).length ?? 0;
|
||||
|
||||
const registerSection = (_key: string) => (_el: HTMLDivElement | null) => {
|
||||
/* no-op placeholder for future scroll restoration */
|
||||
|
|
@ -782,6 +821,8 @@ const dockerContainersGroupedByHost = createMemo<Record<string, Resource[]>>((pr
|
|||
enabled: false,
|
||||
warningDays: DEFAULT_SNAPSHOT_WARNING,
|
||||
criticalDays: DEFAULT_SNAPSHOT_CRITICAL,
|
||||
warningSizeGiB: DEFAULT_SNAPSHOT_WARNING_SIZE,
|
||||
criticalSizeGiB: DEFAULT_SNAPSHOT_CRITICAL_SIZE,
|
||||
};
|
||||
|
||||
const sanitizeSnapshotConfig = (config: SnapshotAlertConfig): SnapshotAlertConfig => {
|
||||
|
|
@ -795,17 +836,36 @@ const dockerContainersGroupedByHost = createMemo<Record<string, Resource[]>>((pr
|
|||
critical = warning;
|
||||
}
|
||||
|
||||
const rawWarningSize = Number.isFinite(config.warningSizeGiB)
|
||||
? Number(config.warningSizeGiB)
|
||||
: DEFAULT_SNAPSHOT_WARNING_SIZE;
|
||||
const rawCriticalSize = Number.isFinite(config.criticalSizeGiB)
|
||||
? Number(config.criticalSizeGiB)
|
||||
: DEFAULT_SNAPSHOT_CRITICAL_SIZE;
|
||||
|
||||
const roundSize = (value: number) => Math.round(Math.max(0, value) * 10) / 10;
|
||||
|
||||
let warningSize = roundSize(rawWarningSize);
|
||||
let criticalSize = roundSize(rawCriticalSize);
|
||||
|
||||
if (criticalSize > 0 && warningSize > criticalSize) {
|
||||
warningSize = criticalSize;
|
||||
}
|
||||
if (criticalSize === 0 && warningSize > 0) {
|
||||
criticalSize = warningSize;
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: !!config.enabled,
|
||||
warningDays: warning,
|
||||
criticalDays: critical,
|
||||
warningSizeGiB: warningSize,
|
||||
criticalSizeGiB: criticalSize,
|
||||
};
|
||||
};
|
||||
|
||||
const updateSnapshotDefaults = (
|
||||
updater:
|
||||
| SnapshotAlertConfig
|
||||
| ((prev: SnapshotAlertConfig) => SnapshotAlertConfig),
|
||||
updater: SnapshotAlertConfig | ((prev: SnapshotAlertConfig) => SnapshotAlertConfig),
|
||||
) => {
|
||||
props.setSnapshotDefaults((prev) => {
|
||||
const next =
|
||||
|
|
@ -822,6 +882,8 @@ const dockerContainersGroupedByHost = createMemo<Record<string, Resource[]>>((pr
|
|||
return {
|
||||
'warning days': current.warningDays ?? 0,
|
||||
'critical days': current.criticalDays ?? 0,
|
||||
'warning size (gib)': current.warningSizeGiB ?? 0,
|
||||
'critical size (gib)': current.criticalSizeGiB ?? 0,
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -830,6 +892,8 @@ const dockerContainersGroupedByHost = createMemo<Record<string, Resource[]>>((pr
|
|||
return {
|
||||
'warning days': factory.warningDays ?? DEFAULT_SNAPSHOT_WARNING,
|
||||
'critical days': factory.criticalDays ?? DEFAULT_SNAPSHOT_CRITICAL,
|
||||
'warning size (gib)': factory.warningSizeGiB ?? DEFAULT_SNAPSHOT_WARNING_SIZE,
|
||||
'critical size (gib)': factory.criticalSizeGiB ?? DEFAULT_SNAPSHOT_CRITICAL_SIZE,
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -859,9 +923,7 @@ const dockerContainersGroupedByHost = createMemo<Record<string, Resource[]>>((pr
|
|||
};
|
||||
|
||||
const updateBackupDefaults = (
|
||||
updater:
|
||||
| BackupAlertConfig
|
||||
| ((prev: BackupAlertConfig) => BackupAlertConfig),
|
||||
updater: BackupAlertConfig | ((prev: BackupAlertConfig) => BackupAlertConfig),
|
||||
) => {
|
||||
props.setBackupDefaults((prev) => {
|
||||
const next =
|
||||
|
|
@ -889,18 +951,21 @@ const dockerContainersGroupedByHost = createMemo<Record<string, Resource[]>>((pr
|
|||
};
|
||||
});
|
||||
|
||||
const snapshotOverridesCount = createMemo(() => {
|
||||
const current = props.snapshotDefaults();
|
||||
const factory = snapshotFactoryConfig();
|
||||
return current.enabled !== factory.enabled ||
|
||||
(current.warningDays ?? DEFAULT_SNAPSHOT_WARNING) !==
|
||||
(factory.warningDays ?? DEFAULT_SNAPSHOT_WARNING) ||
|
||||
(current.criticalDays ?? DEFAULT_SNAPSHOT_CRITICAL) !==
|
||||
(factory.criticalDays ?? DEFAULT_SNAPSHOT_CRITICAL)
|
||||
? 1
|
||||
: 0;
|
||||
});
|
||||
|
||||
const snapshotOverridesCount = createMemo(() => {
|
||||
const current = props.snapshotDefaults();
|
||||
const factory = snapshotFactoryConfig();
|
||||
const differs =
|
||||
current.enabled !== factory.enabled ||
|
||||
(current.warningDays ?? DEFAULT_SNAPSHOT_WARNING) !==
|
||||
(factory.warningDays ?? DEFAULT_SNAPSHOT_WARNING) ||
|
||||
(current.criticalDays ?? DEFAULT_SNAPSHOT_CRITICAL) !==
|
||||
(factory.criticalDays ?? DEFAULT_SNAPSHOT_CRITICAL) ||
|
||||
(current.warningSizeGiB ?? DEFAULT_SNAPSHOT_WARNING_SIZE) !==
|
||||
(factory.warningSizeGiB ?? DEFAULT_SNAPSHOT_WARNING_SIZE) ||
|
||||
(current.criticalSizeGiB ?? DEFAULT_SNAPSHOT_CRITICAL_SIZE) !==
|
||||
(factory.criticalSizeGiB ?? DEFAULT_SNAPSHOT_CRITICAL_SIZE);
|
||||
return differs ? 1 : 0;
|
||||
});
|
||||
|
||||
const backupOverridesCount = createMemo(() => {
|
||||
const backupCurrent = props.backupDefaults();
|
||||
|
|
@ -914,7 +979,6 @@ const snapshotOverridesCount = createMemo(() => {
|
|||
: 0;
|
||||
});
|
||||
|
||||
|
||||
// Process guests with their overrides and group by node
|
||||
const guestsGroupedByNode = createMemo<Record<string, Resource[]>>((prev = {}) => {
|
||||
// If we're currently editing, return the previous value to avoid re-renders
|
||||
|
|
@ -941,10 +1005,13 @@ const snapshotOverridesCount = createMemo(() => {
|
|||
);
|
||||
});
|
||||
|
||||
|
||||
// A guest has an override if it has custom thresholds OR is disabled OR has connectivity disabled
|
||||
const hasOverride =
|
||||
hasCustomThresholds || override?.disabled || override?.disableConnectivity || overrideSeverity !== undefined || false;
|
||||
hasCustomThresholds ||
|
||||
override?.disabled ||
|
||||
override?.disableConnectivity ||
|
||||
overrideSeverity !== undefined ||
|
||||
false;
|
||||
|
||||
return {
|
||||
id: guestId,
|
||||
|
|
@ -1079,11 +1146,10 @@ const snapshotOverridesCount = createMemo(() => {
|
|||
const record: Record<string, number> = {};
|
||||
PMG_THRESHOLD_COLUMNS.forEach(({ key, normalized }) => {
|
||||
const value = defaults[key];
|
||||
record[normalized] =
|
||||
typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
||||
record[normalized] = typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
||||
});
|
||||
return record;
|
||||
});
|
||||
return record;
|
||||
});
|
||||
|
||||
const setPMGGlobalDefaults = (
|
||||
value:
|
||||
|
|
@ -1273,11 +1339,11 @@ const snapshotOverridesCount = createMemo(() => {
|
|||
overrides: snapshotOverridesCount(),
|
||||
tab: 'proxmox' as const,
|
||||
},
|
||||
{
|
||||
key: 'pbs' as const,
|
||||
label: 'PBS Servers',
|
||||
total: props.pbsInstances?.length ?? 0,
|
||||
overrides: countOverrides(pbsServersWithOverrides()),
|
||||
{
|
||||
key: 'pbs' as const,
|
||||
label: 'PBS Servers',
|
||||
total: props.pbsInstances?.length ?? 0,
|
||||
overrides: countOverrides(pbsServersWithOverrides()),
|
||||
tab: 'proxmox' as const,
|
||||
},
|
||||
{
|
||||
|
|
@ -1344,9 +1410,13 @@ const snapshotOverridesCount = createMemo(() => {
|
|||
if (resource.editScope === 'backup') {
|
||||
const currentBackupDefaults = props.backupDefaults();
|
||||
const nextWarning =
|
||||
editedThresholds['warning days'] ?? currentBackupDefaults.warningDays ?? DEFAULT_BACKUP_WARNING;
|
||||
editedThresholds['warning days'] ??
|
||||
currentBackupDefaults.warningDays ??
|
||||
DEFAULT_BACKUP_WARNING;
|
||||
const nextCritical =
|
||||
editedThresholds['critical days'] ?? currentBackupDefaults.criticalDays ?? DEFAULT_BACKUP_CRITICAL;
|
||||
editedThresholds['critical days'] ??
|
||||
currentBackupDefaults.criticalDays ??
|
||||
DEFAULT_BACKUP_CRITICAL;
|
||||
|
||||
updateBackupDefaults({
|
||||
enabled: currentBackupDefaults.enabled,
|
||||
|
|
@ -1361,14 +1431,28 @@ const snapshotOverridesCount = createMemo(() => {
|
|||
if (resource.editScope === 'snapshot') {
|
||||
const currentSnapshotDefaults = props.snapshotDefaults();
|
||||
const nextWarning =
|
||||
editedThresholds['warning days'] ?? currentSnapshotDefaults.warningDays ?? DEFAULT_SNAPSHOT_WARNING;
|
||||
editedThresholds['warning days'] ??
|
||||
currentSnapshotDefaults.warningDays ??
|
||||
DEFAULT_SNAPSHOT_WARNING;
|
||||
const nextCritical =
|
||||
editedThresholds['critical days'] ?? currentSnapshotDefaults.criticalDays ?? DEFAULT_SNAPSHOT_CRITICAL;
|
||||
editedThresholds['critical days'] ??
|
||||
currentSnapshotDefaults.criticalDays ??
|
||||
DEFAULT_SNAPSHOT_CRITICAL;
|
||||
const nextWarningSize =
|
||||
editedThresholds['warning size (gib)'] ??
|
||||
currentSnapshotDefaults.warningSizeGiB ??
|
||||
DEFAULT_SNAPSHOT_WARNING_SIZE;
|
||||
const nextCriticalSize =
|
||||
editedThresholds['critical size (gib)'] ??
|
||||
currentSnapshotDefaults.criticalSizeGiB ??
|
||||
DEFAULT_SNAPSHOT_CRITICAL_SIZE;
|
||||
|
||||
updateSnapshotDefaults({
|
||||
enabled: currentSnapshotDefaults.enabled,
|
||||
warningDays: nextWarning,
|
||||
criticalDays: nextCritical,
|
||||
warningSizeGiB: nextWarningSize,
|
||||
criticalSizeGiB: nextCriticalSize,
|
||||
});
|
||||
|
||||
cancelEdit();
|
||||
|
|
@ -1467,7 +1551,10 @@ const snapshotOverridesCount = createMemo(() => {
|
|||
hysteresisThresholds.disableConnectivity = true;
|
||||
delete hysteresisThresholds.poweredOffSeverity;
|
||||
} else {
|
||||
if ((resource.type === 'guest' || resource.type === 'dockerContainer') && props.guestDisableConnectivity()) {
|
||||
if (
|
||||
(resource.type === 'guest' || resource.type === 'dockerContainer') &&
|
||||
props.guestDisableConnectivity()
|
||||
) {
|
||||
hysteresisThresholds.disableConnectivity = false;
|
||||
} else {
|
||||
delete hysteresisThresholds.disableConnectivity;
|
||||
|
|
@ -1491,7 +1578,11 @@ const snapshotOverridesCount = createMemo(() => {
|
|||
setEditingThresholds({});
|
||||
};
|
||||
|
||||
const updateMetricDelay = (typeKey: 'guest' | 'node' | 'storage' | 'pbs', metricKey: string, value: number | null) => {
|
||||
const updateMetricDelay = (
|
||||
typeKey: 'guest' | 'node' | 'storage' | 'pbs',
|
||||
metricKey: string,
|
||||
value: number | null,
|
||||
) => {
|
||||
const normalizedMetric = metricKey.trim().toLowerCase();
|
||||
if (!normalizedMetric) return;
|
||||
|
||||
|
|
@ -1576,10 +1667,7 @@ const snapshotOverridesCount = createMemo(() => {
|
|||
delete (cleanThresholds as Record<string, unknown>).disabled;
|
||||
|
||||
// If enabling (disabled = false) and no custom thresholds exist, remove the override entirely
|
||||
if (
|
||||
!newDisabledState &&
|
||||
(!existingOverride || Object.keys(cleanThresholds).length === 0)
|
||||
) {
|
||||
if (!newDisabledState && (!existingOverride || Object.keys(cleanThresholds).length === 0)) {
|
||||
// Remove the override completely
|
||||
props.setOverrides(props.overrides().filter((o) => o.id !== resourceId));
|
||||
|
||||
|
|
@ -1646,8 +1734,7 @@ const snapshotOverridesCount = createMemo(() => {
|
|||
const offlineId = `pbs-offline-${resourceId}`;
|
||||
props.removeAlerts(
|
||||
(alert) =>
|
||||
alert.resourceId === resourceId &&
|
||||
(alert.id === offlineId || alert.type === 'offline'),
|
||||
alert.resourceId === resourceId && (alert.id === offlineId || alert.type === 'offline'),
|
||||
);
|
||||
} else if (resource.type === 'dockerContainer') {
|
||||
props.removeAlerts(
|
||||
|
|
@ -1755,9 +1842,7 @@ const snapshotOverridesCount = createMemo(() => {
|
|||
if (props.removeAlerts && resource.type === 'dockerHost') {
|
||||
const offlineId = `docker-host-offline-${resourceId}`;
|
||||
const resourceKey = `docker:${resourceId}`;
|
||||
props.removeAlerts(
|
||||
(alert) => alert.id === offlineId || alert.resourceId === resourceKey,
|
||||
);
|
||||
props.removeAlerts((alert) => alert.id === offlineId || alert.resourceId === resourceKey);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -1865,7 +1950,9 @@ const snapshotOverridesCount = createMemo(() => {
|
|||
|
||||
if (props.removeAlerts && newDisableConnectivity) {
|
||||
if (resource.type === 'guest') {
|
||||
props.removeAlerts((alert) => alert.resourceId === resourceId && alert.type === 'powered-off');
|
||||
props.removeAlerts(
|
||||
(alert) => alert.resourceId === resourceId && alert.type === 'powered-off',
|
||||
);
|
||||
} else if (resource.type === 'dockerContainer') {
|
||||
props.removeAlerts(
|
||||
(alert) =>
|
||||
|
|
@ -1922,11 +2009,30 @@ const snapshotOverridesCount = createMemo(() => {
|
|||
{/* Help Banner */}
|
||||
<div class="rounded-lg border border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-950/30 p-3">
|
||||
<div class="flex items-start gap-2">
|
||||
<svg class="w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
<svg
|
||||
class="w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<div class="text-sm text-blue-900 dark:text-blue-100">
|
||||
<span class="font-medium">Quick tips:</span> Set any threshold to <code class="px-1 py-0.5 bg-blue-100 dark:bg-blue-900/50 rounded text-xs font-mono">0</code> to disable alerts for that metric. Click on disabled thresholds showing <span class="italic">Off</span> to re-enable them. Resources with custom settings show a <span class="inline-flex items-center px-1.5 py-0.5 bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 rounded text-xs">Custom</span> badge.
|
||||
<span class="font-medium">Quick tips:</span> Set any threshold to{' '}
|
||||
<code class="px-1 py-0.5 bg-blue-100 dark:bg-blue-900/50 rounded text-xs font-mono">
|
||||
0
|
||||
</code>{' '}
|
||||
to disable alerts for that metric. Click on disabled thresholds showing{' '}
|
||||
<span class="italic">Off</span> to re-enable them. Resources with custom settings show a{' '}
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 rounded text-xs">
|
||||
Custom
|
||||
</span>{' '}
|
||||
badge.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1998,7 +2104,9 @@ const snapshotOverridesCount = createMemo(() => {
|
|||
globalDisableFlag={props.disableAllNodes}
|
||||
onToggleGlobalDisable={() => props.setDisableAllNodes(!props.disableAllNodes())}
|
||||
globalDisableOfflineFlag={props.disableAllNodesOffline}
|
||||
onToggleGlobalDisableOffline={() => props.setDisableAllNodesOffline(!props.disableAllNodesOffline())}
|
||||
onToggleGlobalDisableOffline={() =>
|
||||
props.setDisableAllNodesOffline(!props.disableAllNodesOffline())
|
||||
}
|
||||
showDelayColumn={true}
|
||||
globalDelaySeconds={props.timeThresholds().node}
|
||||
metricDelaySeconds={props.metricTimeThresholds().node ?? {}}
|
||||
|
|
@ -2032,22 +2140,42 @@ const snapshotOverridesCount = createMemo(() => {
|
|||
globalDefaults={{ cpu: props.nodeDefaults.cpu, memory: props.nodeDefaults.memory }}
|
||||
setGlobalDefaults={(value) => {
|
||||
if (typeof value === 'function') {
|
||||
const newValue = value({ cpu: props.nodeDefaults.cpu, memory: props.nodeDefaults.memory });
|
||||
props.setNodeDefaults((prev) => ({ ...prev, cpu: newValue.cpu ?? prev.cpu, memory: newValue.memory ?? prev.memory }));
|
||||
const newValue = value({
|
||||
cpu: props.nodeDefaults.cpu,
|
||||
memory: props.nodeDefaults.memory,
|
||||
});
|
||||
props.setNodeDefaults((prev) => ({
|
||||
...prev,
|
||||
cpu: newValue.cpu ?? prev.cpu,
|
||||
memory: newValue.memory ?? prev.memory,
|
||||
}));
|
||||
} else {
|
||||
props.setNodeDefaults((prev) => ({ ...prev, cpu: value.cpu ?? prev.cpu, memory: value.memory ?? prev.memory }));
|
||||
props.setNodeDefaults((prev) => ({
|
||||
...prev,
|
||||
cpu: value.cpu ?? prev.cpu,
|
||||
memory: value.memory ?? prev.memory,
|
||||
}));
|
||||
}
|
||||
}}
|
||||
setHasUnsavedChanges={props.setHasUnsavedChanges}
|
||||
globalDisableFlag={props.disableAllPBS}
|
||||
onToggleGlobalDisable={() => props.setDisableAllPBS(!props.disableAllPBS())}
|
||||
globalDisableOfflineFlag={props.disableAllPBSOffline}
|
||||
onToggleGlobalDisableOffline={() => props.setDisableAllPBSOffline(!props.disableAllPBSOffline())}
|
||||
onToggleGlobalDisableOffline={() =>
|
||||
props.setDisableAllPBSOffline(!props.disableAllPBSOffline())
|
||||
}
|
||||
showDelayColumn={true}
|
||||
globalDelaySeconds={props.timeThresholds().pbs}
|
||||
metricDelaySeconds={props.metricTimeThresholds().pbs ?? {}}
|
||||
onMetricDelayChange={(metric, value) => updateMetricDelay('pbs', metric, value)}
|
||||
factoryDefaults={props.factoryNodeDefaults ? { cpu: props.factoryNodeDefaults.cpu, memory: props.factoryNodeDefaults.memory } : undefined}
|
||||
factoryDefaults={
|
||||
props.factoryNodeDefaults
|
||||
? {
|
||||
cpu: props.factoryNodeDefaults.cpu,
|
||||
memory: props.factoryNodeDefaults.memory,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onResetDefaults={props.resetNodeDefaults}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -2059,7 +2187,15 @@ const snapshotOverridesCount = createMemo(() => {
|
|||
title="VMs & Containers"
|
||||
groupedResources={guestsGroupedByNode()}
|
||||
groupHeaderMeta={guestGroupHeaderMeta()}
|
||||
columns={['CPU %', 'Memory %', 'Disk %', 'Disk R MB/s', 'Disk W MB/s', 'Net In MB/s', 'Net Out MB/s']}
|
||||
columns={[
|
||||
'CPU %',
|
||||
'Memory %',
|
||||
'Disk %',
|
||||
'Disk R MB/s',
|
||||
'Disk W MB/s',
|
||||
'Net In MB/s',
|
||||
'Net Out MB/s',
|
||||
]}
|
||||
activeAlerts={props.activeAlerts}
|
||||
emptyMessage="No VMs or containers match the current filters."
|
||||
onEdit={startEditing}
|
||||
|
|
@ -2108,8 +2244,22 @@ const snapshotOverridesCount = createMemo(() => {
|
|||
<div ref={registerSection('backups')} class="scroll-mt-24">
|
||||
<ResourceTable
|
||||
title="Backups"
|
||||
resources={[{ id: "backups-defaults", name: "Global Defaults", thresholds: backupDefaultsRecord(), defaults: backupDefaultsRecord(), editable: true, editScope: "backup" }]}
|
||||
columns={['Warning Days', 'Critical Days']}
|
||||
resources={[
|
||||
{
|
||||
id: 'backups-defaults',
|
||||
name: 'Global Defaults',
|
||||
thresholds: backupDefaultsRecord(),
|
||||
defaults: backupDefaultsRecord(),
|
||||
editable: true,
|
||||
editScope: 'backup',
|
||||
},
|
||||
]}
|
||||
columns={[
|
||||
'Warning Days',
|
||||
'Critical Days',
|
||||
'Warning Size (GiB)',
|
||||
'Critical Size (GiB)',
|
||||
]}
|
||||
activeAlerts={props.activeAlerts}
|
||||
emptyMessage=""
|
||||
onEdit={startEditing}
|
||||
|
|
@ -2171,7 +2321,16 @@ const snapshotOverridesCount = createMemo(() => {
|
|||
<div ref={registerSection('snapshots')} class="scroll-mt-24">
|
||||
<ResourceTable
|
||||
title="Snapshot Age"
|
||||
resources={[{ id: "snapshots-defaults", name: "Global Defaults", thresholds: snapshotDefaultsRecord(), defaults: snapshotDefaultsRecord(), editable: true, editScope: "snapshot" }]}
|
||||
resources={[
|
||||
{
|
||||
id: 'snapshots-defaults',
|
||||
name: 'Global Defaults',
|
||||
thresholds: snapshotDefaultsRecord(),
|
||||
defaults: snapshotDefaultsRecord(),
|
||||
editable: true,
|
||||
editScope: 'snapshot',
|
||||
},
|
||||
]}
|
||||
columns={['Warning Days', 'Critical Days']}
|
||||
activeAlerts={props.activeAlerts}
|
||||
emptyMessage=""
|
||||
|
|
@ -2191,6 +2350,8 @@ const snapshotOverridesCount = createMemo(() => {
|
|||
const currentRecord = {
|
||||
'warning days': prev.warningDays ?? 0,
|
||||
'critical days': prev.criticalDays ?? 0,
|
||||
'warning size (gib)': prev.warningSizeGiB ?? 0,
|
||||
'critical size (gib)': prev.criticalSizeGiB ?? 0,
|
||||
};
|
||||
const nextRecord =
|
||||
typeof value === 'function'
|
||||
|
|
@ -2206,6 +2367,14 @@ const snapshotOverridesCount = createMemo(() => {
|
|||
typeof nextRecord['critical days'] === 'number'
|
||||
? nextRecord['critical days']
|
||||
: prev.criticalDays,
|
||||
warningSizeGiB:
|
||||
typeof nextRecord['warning size (gib)'] === 'number'
|
||||
? nextRecord['warning size (gib)']
|
||||
: prev.warningSizeGiB,
|
||||
criticalSizeGiB:
|
||||
typeof nextRecord['critical size (gib)'] === 'number'
|
||||
? nextRecord['critical size (gib)']
|
||||
: prev.criticalSizeGiB,
|
||||
};
|
||||
});
|
||||
}}
|
||||
|
|
@ -2266,12 +2435,15 @@ const snapshotOverridesCount = createMemo(() => {
|
|||
globalDelaySeconds={props.timeThresholds().storage}
|
||||
metricDelaySeconds={props.metricTimeThresholds().storage ?? {}}
|
||||
onMetricDelayChange={(metric, value) => updateMetricDelay('storage', metric, value)}
|
||||
factoryDefaults={props.factoryStorageDefault !== undefined ? { usage: props.factoryStorageDefault } : undefined}
|
||||
factoryDefaults={
|
||||
props.factoryStorageDefault !== undefined
|
||||
? { usage: props.factoryStorageDefault }
|
||||
: undefined
|
||||
}
|
||||
onResetDefaults={props.resetStorageDefault}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
</Show>
|
||||
|
||||
<Show when={activeTab() === 'pmg'}>
|
||||
|
|
@ -2279,7 +2451,8 @@ const snapshotOverridesCount = createMemo(() => {
|
|||
when={pmgServersWithOverrides().length > 0}
|
||||
fallback={
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-6 text-sm text-gray-600 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300">
|
||||
No mail gateways configured yet. Add a PMG instance in Settings to manage thresholds.
|
||||
No mail gateways configured yet. Add a PMG instance in Settings to manage
|
||||
thresholds.
|
||||
</div>
|
||||
}
|
||||
>
|
||||
|
|
@ -2325,7 +2498,9 @@ const snapshotOverridesCount = createMemo(() => {
|
|||
globalDisableFlag={props.disableAllPMG}
|
||||
onToggleGlobalDisable={() => props.setDisableAllPMG(!props.disableAllPMG())}
|
||||
globalDisableOfflineFlag={props.disableAllPMGOffline}
|
||||
onToggleGlobalDisableOffline={() => props.setDisableAllPMGOffline(!props.disableAllPMGOffline())}
|
||||
onToggleGlobalDisableOffline={() =>
|
||||
props.setDisableAllPMGOffline(!props.disableAllPMGOffline())
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
|
|
@ -2383,9 +2558,13 @@ const snapshotOverridesCount = createMemo(() => {
|
|||
formatMetricValue={formatMetricValue}
|
||||
hasActiveAlert={hasActiveAlert}
|
||||
globalDisableFlag={props.disableAllDockerHosts}
|
||||
onToggleGlobalDisable={() => props.setDisableAllDockerHosts(!props.disableAllDockerHosts())}
|
||||
onToggleGlobalDisable={() =>
|
||||
props.setDisableAllDockerHosts(!props.disableAllDockerHosts())
|
||||
}
|
||||
globalDisableOfflineFlag={props.disableAllDockerHostsOffline}
|
||||
onToggleGlobalDisableOffline={() => props.setDisableAllDockerHostsOffline(!props.disableAllDockerHostsOffline())}
|
||||
onToggleGlobalDisableOffline={() =>
|
||||
props.setDisableAllDockerHostsOffline(!props.disableAllDockerHostsOffline())
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
|
|
@ -2435,9 +2614,7 @@ const snapshotOverridesCount = createMemo(() => {
|
|||
memoryCriticalPct: props.dockerDefaults.memoryCriticalPct,
|
||||
};
|
||||
const next =
|
||||
typeof value === 'function'
|
||||
? value(current)
|
||||
: { ...current, ...value };
|
||||
typeof value === 'function' ? value(current) : { ...current, ...value };
|
||||
|
||||
props.setDockerDefaults((prev) => ({
|
||||
...prev,
|
||||
|
|
@ -2451,7 +2628,9 @@ const snapshotOverridesCount = createMemo(() => {
|
|||
}}
|
||||
setHasUnsavedChanges={props.setHasUnsavedChanges}
|
||||
globalDisableFlag={props.disableAllDockerContainers}
|
||||
onToggleGlobalDisable={() => props.setDisableAllDockerContainers(!props.disableAllDockerContainers())}
|
||||
onToggleGlobalDisable={() =>
|
||||
props.setDisableAllDockerContainers(!props.disableAllDockerContainers())
|
||||
}
|
||||
globalDisableOfflineFlag={() => props.guestDisableConnectivity()}
|
||||
onToggleGlobalDisableOffline={() =>
|
||||
props.setGuestDisableConnectivity(!props.guestDisableConnectivity())
|
||||
|
|
|
|||
|
|
@ -2,10 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
|
|||
import { render, fireEvent, screen, cleanup } from '@solidjs/testing-library';
|
||||
import { createSignal } from 'solid-js';
|
||||
|
||||
import {
|
||||
ThresholdsTable,
|
||||
normalizeDockerIgnoredInput,
|
||||
} from '../ThresholdsTable';
|
||||
import { ThresholdsTable, normalizeDockerIgnoredInput } from '../ThresholdsTable';
|
||||
import type { PMGThresholdDefaults, SnapshotAlertConfig, BackupAlertConfig } from '@/types/alerts';
|
||||
|
||||
vi.mock('@solidjs/router', () => ({
|
||||
|
|
@ -91,9 +88,21 @@ const baseProps = () => ({
|
|||
setBackupDefaults: vi.fn(),
|
||||
backupFactoryDefaults: { enabled: false, warningDays: 7, criticalDays: 14 } as BackupAlertConfig,
|
||||
resetBackupDefaults: vi.fn(),
|
||||
snapshotDefaults: () => ({ enabled: false, warningDays: 30, criticalDays: 45 }),
|
||||
snapshotDefaults: () => ({
|
||||
enabled: false,
|
||||
warningDays: 30,
|
||||
criticalDays: 45,
|
||||
warningSizeGiB: 0,
|
||||
criticalSizeGiB: 0,
|
||||
}),
|
||||
setSnapshotDefaults: vi.fn(),
|
||||
snapshotFactoryDefaults: { enabled: false, warningDays: 30, criticalDays: 45 } as SnapshotAlertConfig,
|
||||
snapshotFactoryDefaults: {
|
||||
enabled: false,
|
||||
warningDays: 30,
|
||||
criticalDays: 45,
|
||||
warningSizeGiB: 0,
|
||||
criticalSizeGiB: 0,
|
||||
} as SnapshotAlertConfig,
|
||||
resetSnapshotDefaults: vi.fn(),
|
||||
timeThresholds: () => ({ guest: 5, node: 5, storage: 5, pbs: 5 }),
|
||||
metricTimeThresholds: () => ({}),
|
||||
|
|
@ -126,7 +135,10 @@ const baseProps = () => ({
|
|||
setDisableAllDockerHostsOffline: vi.fn(),
|
||||
});
|
||||
|
||||
const renderThresholdsTable = (options?: { initialPrefixes?: string[]; includeReset?: boolean }) => {
|
||||
const renderThresholdsTable = (options?: {
|
||||
initialPrefixes?: string[];
|
||||
includeReset?: boolean;
|
||||
}) => {
|
||||
let setDockerIgnoredPrefixesMock!: ReturnType<typeof vi.fn>;
|
||||
let resetDockerIgnoredPrefixesMock: ReturnType<typeof vi.fn> | undefined;
|
||||
let setHasUnsavedChangesMock!: ReturnType<typeof vi.fn>;
|
||||
|
|
@ -177,9 +189,11 @@ const renderThresholdsTable = (options?: { initialPrefixes?: string[]; includeRe
|
|||
|
||||
describe('normalizeDockerIgnoredInput', () => {
|
||||
it('trims whitespace and removes empty lines', () => {
|
||||
expect(
|
||||
normalizeDockerIgnoredInput(' runner- \n\n #system \n\t \njob-'),
|
||||
).toEqual(['runner-', '#system', 'job-']);
|
||||
expect(normalizeDockerIgnoredInput(' runner- \n\n #system \n\t \njob-')).toEqual([
|
||||
'runner-',
|
||||
'#system',
|
||||
'job-',
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns empty array for blank input', () => {
|
||||
|
|
|
|||
|
|
@ -16,7 +16,16 @@ import { usePersistentSignal } from '@/hooks/usePersistentSignal';
|
|||
|
||||
type BackupSortKey = keyof Pick<
|
||||
UnifiedBackup,
|
||||
'backupTime' | 'name' | 'node' | 'vmid' | 'backupType' | 'size' | 'storage' | 'verified' | 'type' | 'owner'
|
||||
| 'backupTime'
|
||||
| 'name'
|
||||
| 'node'
|
||||
| 'vmid'
|
||||
| 'backupType'
|
||||
| 'size'
|
||||
| 'storage'
|
||||
| 'verified'
|
||||
| 'type'
|
||||
| 'owner'
|
||||
>;
|
||||
const BACKUP_SORT_KEY_VALUES: readonly BackupSortKey[] = [
|
||||
'backupTime',
|
||||
|
|
@ -70,16 +79,10 @@ const UnifiedBackups: Component = () => {
|
|||
else if (value === 'pve') setBackupTypeFilter('local');
|
||||
else if (value === 'pbs') setBackupTypeFilter('remote');
|
||||
};
|
||||
const [sortKey, setSortKey] = usePersistentSignal<BackupSortKey>(
|
||||
'backupsSortKey',
|
||||
'backupTime',
|
||||
{
|
||||
deserialize: (raw) =>
|
||||
BACKUP_SORT_KEY_VALUES.includes(raw as BackupSortKey)
|
||||
? (raw as BackupSortKey)
|
||||
: 'backupTime',
|
||||
},
|
||||
);
|
||||
const [sortKey, setSortKey] = usePersistentSignal<BackupSortKey>('backupsSortKey', 'backupTime', {
|
||||
deserialize: (raw) =>
|
||||
BACKUP_SORT_KEY_VALUES.includes(raw as BackupSortKey) ? (raw as BackupSortKey) : 'backupTime',
|
||||
});
|
||||
const [sortDirection, setSortDirection] = usePersistentSignal<'asc' | 'desc'>(
|
||||
'backupsSortDirection',
|
||||
'desc',
|
||||
|
|
@ -188,7 +191,9 @@ const UnifiedBackups: Component = () => {
|
|||
pveBackupsState()?.guestSnapshots?.forEach((snapshot) => {
|
||||
// Try to find the guest name by matching VMID and instance (not hostname)
|
||||
let guestName = '';
|
||||
const vm = state.vms?.find((v) => v.vmid === snapshot.vmid && v.instance === snapshot.instance);
|
||||
const vm = state.vms?.find(
|
||||
(v) => v.vmid === snapshot.vmid && v.instance === snapshot.instance,
|
||||
);
|
||||
const ct = state.containers?.find(
|
||||
(c) => c.vmid === snapshot.vmid && c.instance === snapshot.instance,
|
||||
);
|
||||
|
|
@ -209,7 +214,7 @@ const UnifiedBackups: Component = () => {
|
|||
backupName: snapshot.name, // This is the snapshot name like "current", "pre-upgrade"
|
||||
description: snapshot.description || '',
|
||||
status: 'ok',
|
||||
size: null,
|
||||
size: typeof snapshot.sizeBytes === 'number' ? snapshot.sizeBytes : null,
|
||||
storage: null,
|
||||
datastore: null,
|
||||
namespace: null,
|
||||
|
|
@ -291,7 +296,7 @@ const UnifiedBackups: Component = () => {
|
|||
|
||||
unified.push({
|
||||
backupType: 'remote',
|
||||
vmid: displayType === 'Host' ? backup.vmid : (parseInt(backup.vmid) || 0),
|
||||
vmid: displayType === 'Host' ? backup.vmid : parseInt(backup.vmid) || 0,
|
||||
name: backup.comment || '',
|
||||
type: displayType,
|
||||
node: backup.instance || 'PBS',
|
||||
|
|
@ -402,7 +407,7 @@ const UnifiedBackups: Component = () => {
|
|||
// For regular backups: show Proxmox node in Node column, local storage in Location
|
||||
unified.push({
|
||||
backupType: backupType,
|
||||
vmid: displayType === 'Host' ? backup.vmid : (backup.vmid || 0),
|
||||
vmid: displayType === 'Host' ? backup.vmid : backup.vmid || 0,
|
||||
name: backup.notes || backup.volid?.split('/').pop() || '',
|
||||
type: displayType,
|
||||
node: backup.node || '', // Proxmox node that has access to this backup
|
||||
|
|
@ -532,9 +537,7 @@ const UnifiedBackups: Component = () => {
|
|||
if (nodeFilter) {
|
||||
const node = state.nodes?.find((n) => n.id === nodeFilter);
|
||||
if (node) {
|
||||
data = data.filter(
|
||||
(item) => item.instance === node.instance && item.node === node.name,
|
||||
);
|
||||
data = data.filter((item) => item.instance === node.instance && item.node === node.name);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1364,17 +1367,27 @@ const UnifiedBackups: Component = () => {
|
|||
aria-label={availableBackupsTooltipText}
|
||||
onMouseEnter={(e) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
showTooltip(availableBackupsTooltipText, rect.left + rect.width / 2, rect.top, {
|
||||
align: 'center',
|
||||
direction: 'up',
|
||||
});
|
||||
showTooltip(
|
||||
availableBackupsTooltipText,
|
||||
rect.left + rect.width / 2,
|
||||
rect.top,
|
||||
{
|
||||
align: 'center',
|
||||
direction: 'up',
|
||||
},
|
||||
);
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
showTooltip(availableBackupsTooltipText, rect.left + rect.width / 2, rect.top, {
|
||||
align: 'center',
|
||||
direction: 'up',
|
||||
});
|
||||
showTooltip(
|
||||
availableBackupsTooltipText,
|
||||
rect.left + rect.width / 2,
|
||||
rect.top,
|
||||
{
|
||||
align: 'center',
|
||||
direction: 'up',
|
||||
},
|
||||
);
|
||||
}}
|
||||
onMouseLeave={() => hideTooltip()}
|
||||
onBlur={() => hideTooltip()}
|
||||
|
|
@ -1780,12 +1793,9 @@ const UnifiedBackups: Component = () => {
|
|||
}`;
|
||||
|
||||
const breakdown: string[] = [];
|
||||
if (d.snapshots > 0)
|
||||
breakdown.push(`Snapshots: ${d.snapshots}`);
|
||||
if (d.pve > 0)
|
||||
breakdown.push(`PVE: ${d.pve}`);
|
||||
if (d.pbs > 0)
|
||||
breakdown.push(`PBS: ${d.pbs}`);
|
||||
if (d.snapshots > 0) breakdown.push(`Snapshots: ${d.snapshots}`);
|
||||
if (d.pve > 0) breakdown.push(`PVE: ${d.pve}`);
|
||||
if (d.pbs > 0) breakdown.push(`PBS: ${d.pbs}`);
|
||||
|
||||
if (breakdown.length > 0) {
|
||||
tooltipText += `\n${breakdown.join(' • ')}`;
|
||||
|
|
@ -2121,7 +2131,8 @@ const UnifiedBackups: Component = () => {
|
|||
onClick={() => handleSort('vmid')}
|
||||
style="width: 60px;"
|
||||
>
|
||||
{hasHostBackups() ? 'VMID/Host' : 'VMID'} {sortKey() === 'vmid' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||
{hasHostBackups() ? 'VMID/Host' : 'VMID'}{' '}
|
||||
{sortKey() === 'vmid' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||
</th>
|
||||
<th
|
||||
class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
|
||||
|
|
|
|||
|
|
@ -157,9 +157,15 @@ interface UIEmailConfig {
|
|||
|
||||
interface UIAppriseConfig {
|
||||
enabled: boolean;
|
||||
mode: 'cli' | 'http';
|
||||
targetsText: string;
|
||||
cliPath: string;
|
||||
timeoutSeconds: number;
|
||||
serverUrl: string;
|
||||
configKey: string;
|
||||
apiKey: string;
|
||||
apiKeyHeader: string;
|
||||
skipTlsVerify: boolean;
|
||||
}
|
||||
|
||||
interface QuietHoursConfig {
|
||||
|
|
@ -239,9 +245,15 @@ export const createDefaultGrouping = (): GroupingConfig => ({
|
|||
|
||||
const createDefaultAppriseConfig = (): UIAppriseConfig => ({
|
||||
enabled: false,
|
||||
mode: 'cli',
|
||||
targetsText: '',
|
||||
cliPath: 'apprise',
|
||||
timeoutSeconds: 15,
|
||||
serverUrl: '',
|
||||
configKey: '',
|
||||
apiKey: '',
|
||||
apiKeyHeader: 'X-API-KEY',
|
||||
skipTlsVerify: false,
|
||||
});
|
||||
|
||||
const parseAppriseTargets = (value: string): string[] =>
|
||||
|
|
@ -486,9 +498,15 @@ const [appriseConfig, setAppriseConfig] = createSignal<UIAppriseConfig>(
|
|||
const config = appriseConfig();
|
||||
return {
|
||||
enabled: config.enabled,
|
||||
mode: config.mode,
|
||||
targets: parseAppriseTargets(config.targetsText),
|
||||
cliPath: config.cliPath,
|
||||
timeoutSeconds: config.timeoutSeconds,
|
||||
serverUrl: config.serverUrl,
|
||||
configKey: config.configKey,
|
||||
apiKey: config.apiKey,
|
||||
apiKeyHeader: config.apiKeyHeader,
|
||||
skipTlsVerify: config.skipTlsVerify,
|
||||
} as AppriseConfig;
|
||||
};
|
||||
|
||||
|
|
@ -1012,12 +1030,18 @@ const [appriseConfig, setAppriseConfig] = createSignal<UIAppriseConfig>(
|
|||
const appriseData = await NotificationsAPI.getAppriseConfig();
|
||||
setAppriseConfig({
|
||||
enabled: appriseData.enabled ?? false,
|
||||
mode: appriseData.mode === 'http' ? 'http' : 'cli',
|
||||
targetsText: formatAppriseTargets(appriseData.targets),
|
||||
cliPath: appriseData.cliPath || 'apprise',
|
||||
timeoutSeconds:
|
||||
typeof appriseData.timeoutSeconds === 'number' && appriseData.timeoutSeconds > 0
|
||||
? appriseData.timeoutSeconds
|
||||
: 15,
|
||||
serverUrl: appriseData.serverUrl || '',
|
||||
configKey: appriseData.configKey || '',
|
||||
apiKey: appriseData.apiKey || '',
|
||||
apiKeyHeader: appriseData.apiKeyHeader || 'X-API-KEY',
|
||||
skipTlsVerify: Boolean(appriseData.skipTlsVerify),
|
||||
});
|
||||
} catch (appriseErr) {
|
||||
console.error('Failed to load Apprise configuration:', appriseErr);
|
||||
|
|
@ -1072,12 +1096,18 @@ const [appriseConfig, setAppriseConfig] = createSignal<UIAppriseConfig>(
|
|||
.then((appriseData) => {
|
||||
setAppriseConfig({
|
||||
enabled: appriseData.enabled ?? false,
|
||||
mode: appriseData.mode === 'http' ? 'http' : 'cli',
|
||||
targetsText: formatAppriseTargets(appriseData.targets),
|
||||
cliPath: appriseData.cliPath || 'apprise',
|
||||
timeoutSeconds:
|
||||
typeof appriseData.timeoutSeconds === 'number' && appriseData.timeoutSeconds > 0
|
||||
? appriseData.timeoutSeconds
|
||||
: 15,
|
||||
serverUrl: appriseData.serverUrl || '',
|
||||
configKey: appriseData.configKey || '',
|
||||
apiKey: appriseData.apiKey || '',
|
||||
apiKeyHeader: appriseData.apiKeyHeader || 'X-API-KEY',
|
||||
skipTlsVerify: Boolean(appriseData.skipTlsVerify),
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
|
|
@ -1484,12 +1514,18 @@ const [appriseConfig, setAppriseConfig] = createSignal<UIAppriseConfig>(
|
|||
const updatedApprise = await NotificationsAPI.updateAppriseConfig(appriseData);
|
||||
setAppriseConfig({
|
||||
enabled: updatedApprise.enabled ?? false,
|
||||
mode: updatedApprise.mode === 'http' ? 'http' : 'cli',
|
||||
targetsText: formatAppriseTargets(updatedApprise.targets),
|
||||
cliPath: updatedApprise.cliPath || 'apprise',
|
||||
timeoutSeconds:
|
||||
typeof updatedApprise.timeoutSeconds === 'number' && updatedApprise.timeoutSeconds > 0
|
||||
? updatedApprise.timeoutSeconds
|
||||
: 15,
|
||||
serverUrl: updatedApprise.serverUrl || '',
|
||||
configKey: updatedApprise.configKey || '',
|
||||
apiKey: updatedApprise.apiKey || '',
|
||||
apiKeyHeader: updatedApprise.apiKeyHeader || 'X-API-KEY',
|
||||
skipTlsVerify: Boolean(updatedApprise.skipTlsVerify),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -89,6 +89,8 @@ export interface SnapshotAlertConfig {
|
|||
enabled: boolean;
|
||||
warningDays: number;
|
||||
criticalDays: number;
|
||||
warningSizeGiB?: number;
|
||||
criticalSizeGiB?: number;
|
||||
}
|
||||
|
||||
export interface BackupAlertConfig {
|
||||
|
|
|
|||
|
|
@ -607,6 +607,7 @@ export interface GuestSnapshot {
|
|||
description: string;
|
||||
parent: string;
|
||||
vmstate: boolean;
|
||||
sizeBytes?: number;
|
||||
}
|
||||
|
||||
export interface Performance {
|
||||
|
|
|
|||
|
|
@ -294,9 +294,11 @@ type PMGThresholdConfig struct {
|
|||
|
||||
// SnapshotAlertConfig represents snapshot age alert configuration
|
||||
type SnapshotAlertConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
WarningDays int `json:"warningDays"`
|
||||
CriticalDays int `json:"criticalDays"`
|
||||
Enabled bool `json:"enabled"`
|
||||
WarningDays int `json:"warningDays"`
|
||||
CriticalDays int `json:"criticalDays"`
|
||||
WarningSizeGiB float64 `json:"warningSizeGiB,omitempty"`
|
||||
CriticalSizeGiB float64 `json:"criticalSizeGiB,omitempty"`
|
||||
}
|
||||
|
||||
// BackupAlertConfig represents backup age alert configuration
|
||||
|
|
@ -513,9 +515,11 @@ func NewManager() *Manager {
|
|||
QuarantineGrowthCritMin: 500, // AND ≥500 messages
|
||||
},
|
||||
SnapshotDefaults: SnapshotAlertConfig{
|
||||
Enabled: false,
|
||||
WarningDays: 30,
|
||||
CriticalDays: 45,
|
||||
Enabled: false,
|
||||
WarningDays: 30,
|
||||
CriticalDays: 45,
|
||||
WarningSizeGiB: 0,
|
||||
CriticalSizeGiB: 0,
|
||||
},
|
||||
BackupDefaults: BackupAlertConfig{
|
||||
Enabled: false,
|
||||
|
|
@ -836,6 +840,21 @@ func (m *Manager) UpdateConfig(config AlertConfig) {
|
|||
if config.SnapshotDefaults.CriticalDays > 0 && config.SnapshotDefaults.WarningDays > config.SnapshotDefaults.CriticalDays {
|
||||
config.SnapshotDefaults.WarningDays = config.SnapshotDefaults.CriticalDays
|
||||
}
|
||||
if config.SnapshotDefaults.CriticalDays == 0 && config.SnapshotDefaults.WarningDays > 0 {
|
||||
config.SnapshotDefaults.CriticalDays = config.SnapshotDefaults.WarningDays
|
||||
}
|
||||
if config.SnapshotDefaults.WarningSizeGiB < 0 {
|
||||
config.SnapshotDefaults.WarningSizeGiB = 0
|
||||
}
|
||||
if config.SnapshotDefaults.CriticalSizeGiB < 0 {
|
||||
config.SnapshotDefaults.CriticalSizeGiB = 0
|
||||
}
|
||||
if config.SnapshotDefaults.CriticalSizeGiB > 0 && config.SnapshotDefaults.WarningSizeGiB > config.SnapshotDefaults.CriticalSizeGiB {
|
||||
config.SnapshotDefaults.WarningSizeGiB = config.SnapshotDefaults.CriticalSizeGiB
|
||||
}
|
||||
if config.SnapshotDefaults.CriticalSizeGiB == 0 && config.SnapshotDefaults.WarningSizeGiB > 0 {
|
||||
config.SnapshotDefaults.CriticalSizeGiB = config.SnapshotDefaults.WarningSizeGiB
|
||||
}
|
||||
if config.BackupDefaults.WarningDays < 0 {
|
||||
config.BackupDefaults.WarningDays = 0
|
||||
}
|
||||
|
|
@ -2991,19 +3010,63 @@ func (m *Manager) CheckSnapshotsForInstance(instanceName string, snapshots []mod
|
|||
}
|
||||
ageDays := ageHours / 24
|
||||
|
||||
var level AlertLevel
|
||||
var threshold int
|
||||
const gib = 1024.0 * 1024 * 1024
|
||||
sizeGiB := 0.0
|
||||
if snapshot.SizeBytes > 0 {
|
||||
sizeGiB = float64(snapshot.SizeBytes) / gib
|
||||
}
|
||||
|
||||
var (
|
||||
ageLevel AlertLevel
|
||||
ageThreshold int
|
||||
sizeLevel AlertLevel
|
||||
sizeThreshold float64
|
||||
triggeredStats []string
|
||||
)
|
||||
|
||||
if snapshotCfg.CriticalDays > 0 && ageDays >= float64(snapshotCfg.CriticalDays) {
|
||||
level = AlertLevelCritical
|
||||
threshold = snapshotCfg.CriticalDays
|
||||
ageLevel = AlertLevelCritical
|
||||
ageThreshold = snapshotCfg.CriticalDays
|
||||
triggeredStats = append(triggeredStats, "age")
|
||||
} else if snapshotCfg.WarningDays > 0 && ageDays >= float64(snapshotCfg.WarningDays) {
|
||||
level = AlertLevelWarning
|
||||
threshold = snapshotCfg.WarningDays
|
||||
} else {
|
||||
ageLevel = AlertLevelWarning
|
||||
ageThreshold = snapshotCfg.WarningDays
|
||||
triggeredStats = append(triggeredStats, "age")
|
||||
}
|
||||
|
||||
if snapshot.SizeBytes > 0 {
|
||||
if snapshotCfg.CriticalSizeGiB > 0 && sizeGiB >= snapshotCfg.CriticalSizeGiB {
|
||||
sizeLevel = AlertLevelCritical
|
||||
sizeThreshold = snapshotCfg.CriticalSizeGiB
|
||||
triggeredStats = append(triggeredStats, "size")
|
||||
} else if snapshotCfg.WarningSizeGiB > 0 && sizeGiB >= snapshotCfg.WarningSizeGiB {
|
||||
sizeLevel = AlertLevelWarning
|
||||
sizeThreshold = snapshotCfg.WarningSizeGiB
|
||||
triggeredStats = append(triggeredStats, "size")
|
||||
}
|
||||
}
|
||||
|
||||
if ageLevel == "" && sizeLevel == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
var level AlertLevel
|
||||
switch {
|
||||
case ageLevel == AlertLevelCritical || sizeLevel == AlertLevelCritical:
|
||||
level = AlertLevelCritical
|
||||
case ageLevel == AlertLevelWarning || sizeLevel == AlertLevelWarning:
|
||||
level = AlertLevelWarning
|
||||
default:
|
||||
continue
|
||||
}
|
||||
|
||||
useSizePrimary := false
|
||||
if sizeLevel == AlertLevelCritical && ageLevel != AlertLevelCritical {
|
||||
useSizePrimary = true
|
||||
} else if sizeLevel != "" && ageLevel == "" {
|
||||
useSizePrimary = true
|
||||
}
|
||||
|
||||
alertID := fmt.Sprintf("snapshot-age-%s", snapshot.ID)
|
||||
validAlerts[alertID] = struct{}{}
|
||||
|
||||
|
|
@ -3030,19 +3093,35 @@ func (m *Manager) CheckSnapshotsForInstance(instanceName string, snapshots []mod
|
|||
}
|
||||
|
||||
ageDaysRounded := math.Round(ageDays*10) / 10
|
||||
sizeGiBRounded := math.Round(sizeGiB*10) / 10
|
||||
reasons := make([]string, 0, 2)
|
||||
if ageLevel != "" {
|
||||
reasons = append(reasons, fmt.Sprintf("%.1f days old (threshold %d days)", ageDaysRounded, ageThreshold))
|
||||
}
|
||||
if sizeLevel != "" {
|
||||
reasons = append(reasons, fmt.Sprintf("%.1f GiB (threshold %.1f GiB)", sizeGiBRounded, sizeThreshold))
|
||||
}
|
||||
reasonText := strings.Join(reasons, " and ")
|
||||
message := fmt.Sprintf(
|
||||
"%s snapshot '%s' for %s is %.1f days old on %s (threshold: %d days)",
|
||||
"%s snapshot '%s' for %s is %s on %s",
|
||||
guestType,
|
||||
snapshotName,
|
||||
guestName,
|
||||
ageDaysRounded,
|
||||
reasonText,
|
||||
snapshot.Node,
|
||||
threshold,
|
||||
)
|
||||
|
||||
thresholdTime := snapshot.Time.Add(time.Duration(threshold) * 24 * time.Hour)
|
||||
if thresholdTime.After(now) {
|
||||
thresholdTime = now
|
||||
alertValue := ageDays
|
||||
alertThreshold := float64(ageThreshold)
|
||||
thresholdTime := now
|
||||
if useSizePrimary {
|
||||
alertValue = sizeGiB
|
||||
alertThreshold = sizeThreshold
|
||||
} else if ageThreshold > 0 {
|
||||
thresholdTime = snapshot.Time.Add(time.Duration(ageThreshold) * 24 * time.Hour)
|
||||
if thresholdTime.After(now) {
|
||||
thresholdTime = now
|
||||
}
|
||||
}
|
||||
|
||||
metadata := map[string]interface{}{
|
||||
|
|
@ -3050,12 +3129,24 @@ func (m *Manager) CheckSnapshotsForInstance(instanceName string, snapshots []mod
|
|||
"snapshotCreatedAt": snapshot.Time,
|
||||
"snapshotAgeDays": ageDays,
|
||||
"snapshotAgeHours": ageHours,
|
||||
"snapshotSizeBytes": snapshot.SizeBytes,
|
||||
"snapshotSizeGiB": sizeGiB,
|
||||
"guestName": guestName,
|
||||
"guestType": guestType,
|
||||
"guestInstance": snapshot.Instance,
|
||||
"guestNode": snapshot.Node,
|
||||
"guestVmid": snapshot.VMID,
|
||||
"thresholdDays": threshold,
|
||||
"triggeredMetrics": triggeredStats,
|
||||
"primaryMetric": "age",
|
||||
}
|
||||
if useSizePrimary {
|
||||
metadata["primaryMetric"] = "size"
|
||||
}
|
||||
if ageLevel != "" {
|
||||
metadata["thresholdDays"] = ageThreshold
|
||||
}
|
||||
if sizeLevel != "" {
|
||||
metadata["thresholdSizeGiB"] = sizeThreshold
|
||||
}
|
||||
|
||||
resourceName := fmt.Sprintf("%s snapshot '%s'", guestName, snapshotName)
|
||||
|
|
@ -3064,8 +3155,8 @@ func (m *Manager) CheckSnapshotsForInstance(instanceName string, snapshots []mod
|
|||
if existing, exists := m.activeAlerts[alertID]; exists {
|
||||
existing.LastSeen = now
|
||||
existing.Level = level
|
||||
existing.Value = ageDays
|
||||
existing.Threshold = float64(threshold)
|
||||
existing.Value = alertValue
|
||||
existing.Threshold = alertThreshold
|
||||
existing.Message = message
|
||||
existing.ResourceName = resourceName
|
||||
if existing.Metadata == nil {
|
||||
|
|
@ -3087,8 +3178,8 @@ func (m *Manager) CheckSnapshotsForInstance(instanceName string, snapshots []mod
|
|||
Node: snapshot.Node,
|
||||
Instance: snapshot.Instance,
|
||||
Message: message,
|
||||
Value: ageDays,
|
||||
Threshold: float64(threshold),
|
||||
Value: alertValue,
|
||||
Threshold: alertThreshold,
|
||||
StartTime: thresholdTime,
|
||||
LastSeen: now,
|
||||
Metadata: metadata,
|
||||
|
|
|
|||
|
|
@ -221,9 +221,11 @@ func TestCheckSnapshotsForInstanceCreatesAndClearsAlerts(t *testing.T) {
|
|||
Enabled: true,
|
||||
StorageDefault: HysteresisThreshold{Trigger: 85, Clear: 80},
|
||||
SnapshotDefaults: SnapshotAlertConfig{
|
||||
Enabled: true,
|
||||
WarningDays: 7,
|
||||
CriticalDays: 14,
|
||||
Enabled: true,
|
||||
WarningDays: 7,
|
||||
CriticalDays: 14,
|
||||
WarningSizeGiB: 0,
|
||||
CriticalSizeGiB: 0,
|
||||
},
|
||||
Overrides: make(map[string]ThresholdConfig),
|
||||
}
|
||||
|
|
@ -236,13 +238,14 @@ func TestCheckSnapshotsForInstanceCreatesAndClearsAlerts(t *testing.T) {
|
|||
now := time.Now()
|
||||
snapshots := []models.GuestSnapshot{
|
||||
{
|
||||
ID: "inst-node-100-weekly",
|
||||
Name: "weekly",
|
||||
Node: "node",
|
||||
Instance: "inst",
|
||||
Type: "qemu",
|
||||
VMID: 100,
|
||||
Time: now.Add(-15 * 24 * time.Hour),
|
||||
ID: "inst-node-100-weekly",
|
||||
Name: "weekly",
|
||||
Node: "node",
|
||||
Instance: "inst",
|
||||
Type: "qemu",
|
||||
VMID: 100,
|
||||
Time: now.Add(-15 * 24 * time.Hour),
|
||||
SizeBytes: 60 << 30,
|
||||
},
|
||||
}
|
||||
guestNames := map[string]string{
|
||||
|
|
@ -274,6 +277,155 @@ func TestCheckSnapshotsForInstanceCreatesAndClearsAlerts(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestCheckSnapshotsForInstanceTriggersOnSnapshotSize(t *testing.T) {
|
||||
m := NewManager()
|
||||
m.ClearActiveAlerts()
|
||||
|
||||
cfg := AlertConfig{
|
||||
Enabled: true,
|
||||
StorageDefault: HysteresisThreshold{Trigger: 85, Clear: 80},
|
||||
SnapshotDefaults: SnapshotAlertConfig{
|
||||
Enabled: true,
|
||||
WarningDays: 0,
|
||||
CriticalDays: 0,
|
||||
WarningSizeGiB: 50,
|
||||
CriticalSizeGiB: 100,
|
||||
},
|
||||
Overrides: make(map[string]ThresholdConfig),
|
||||
}
|
||||
m.UpdateConfig(cfg)
|
||||
m.mu.Lock()
|
||||
m.config.TimeThreshold = 0
|
||||
m.config.TimeThresholds = map[string]int{}
|
||||
m.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
snapshots := []models.GuestSnapshot{
|
||||
{
|
||||
ID: "inst-node-200-sizey",
|
||||
Name: "pre-maintenance",
|
||||
Node: "node",
|
||||
Instance: "inst",
|
||||
Type: "qemu",
|
||||
VMID: 200,
|
||||
Time: now.Add(-2 * time.Hour),
|
||||
SizeBytes: int64(120) << 30,
|
||||
},
|
||||
}
|
||||
guestNames := map[string]string{
|
||||
"inst-node-200": "db-server",
|
||||
}
|
||||
|
||||
m.CheckSnapshotsForInstance("inst", snapshots, guestNames)
|
||||
|
||||
m.mu.RLock()
|
||||
alert, exists := m.activeAlerts["snapshot-age-inst-node-200-sizey"]
|
||||
m.mu.RUnlock()
|
||||
if !exists {
|
||||
t.Fatalf("expected snapshot size alert to be created")
|
||||
}
|
||||
if alert.Level != AlertLevelCritical {
|
||||
t.Fatalf("expected critical level for large snapshot, got %s", alert.Level)
|
||||
}
|
||||
if alert.Value < 119.5 || alert.Value > 120.5 {
|
||||
t.Fatalf("expected alert value near 120 GiB, got %.2f", alert.Value)
|
||||
}
|
||||
if alert.Threshold != 100 {
|
||||
t.Fatalf("expected threshold 100 GiB, got %.2f", alert.Threshold)
|
||||
}
|
||||
if alert.Metadata == nil {
|
||||
t.Fatalf("expected metadata for snapshot alert")
|
||||
}
|
||||
if metric, ok := alert.Metadata["primaryMetric"].(string); !ok || metric != "size" {
|
||||
t.Fatalf("expected primary metric size, got %#v", alert.Metadata["primaryMetric"])
|
||||
}
|
||||
if sizeBytes, ok := alert.Metadata["snapshotSizeBytes"].(int64); !ok || sizeBytes == 0 {
|
||||
t.Fatalf("expected snapshotSizeBytes in metadata")
|
||||
}
|
||||
metrics, ok := alert.Metadata["triggeredMetrics"].([]string)
|
||||
if !ok {
|
||||
t.Fatalf("expected triggeredMetrics slice, got %#v", alert.Metadata["triggeredMetrics"])
|
||||
}
|
||||
foundSize := false
|
||||
for _, metric := range metrics {
|
||||
if metric == "size" {
|
||||
foundSize = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundSize {
|
||||
t.Fatalf("expected size metric recorded in metadata")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckSnapshotsForInstanceIncludesAgeAndSizeReasons(t *testing.T) {
|
||||
m := NewManager()
|
||||
m.ClearActiveAlerts()
|
||||
|
||||
cfg := AlertConfig{
|
||||
Enabled: true,
|
||||
StorageDefault: HysteresisThreshold{Trigger: 85, Clear: 80},
|
||||
SnapshotDefaults: SnapshotAlertConfig{
|
||||
Enabled: true,
|
||||
WarningDays: 5,
|
||||
CriticalDays: 10,
|
||||
WarningSizeGiB: 40,
|
||||
CriticalSizeGiB: 80,
|
||||
},
|
||||
Overrides: make(map[string]ThresholdConfig),
|
||||
}
|
||||
m.UpdateConfig(cfg)
|
||||
m.mu.Lock()
|
||||
m.config.TimeThreshold = 0
|
||||
m.config.TimeThresholds = map[string]int{}
|
||||
m.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
snapshots := []models.GuestSnapshot{
|
||||
{
|
||||
ID: "inst-node-300-combined",
|
||||
Name: "long-running",
|
||||
Node: "node",
|
||||
Instance: "inst",
|
||||
Type: "qemu",
|
||||
VMID: 300,
|
||||
Time: now.Add(-15 * 24 * time.Hour),
|
||||
SizeBytes: int64(90) << 30,
|
||||
},
|
||||
}
|
||||
guestNames := map[string]string{
|
||||
"inst-node-300": "app-server",
|
||||
}
|
||||
|
||||
m.CheckSnapshotsForInstance("inst", snapshots, guestNames)
|
||||
|
||||
m.mu.RLock()
|
||||
alert, exists := m.activeAlerts["snapshot-age-inst-node-300-combined"]
|
||||
m.mu.RUnlock()
|
||||
if !exists {
|
||||
t.Fatalf("expected combined snapshot alert to be created")
|
||||
}
|
||||
if alert.Level != AlertLevelCritical {
|
||||
t.Fatalf("expected critical level, got %s", alert.Level)
|
||||
}
|
||||
if !strings.Contains(alert.Message, "days old") || !strings.Contains(strings.ToLower(alert.Message), "gib") {
|
||||
t.Fatalf("expected alert message to reference age and size, got %q", alert.Message)
|
||||
}
|
||||
if alert.Metadata == nil {
|
||||
t.Fatalf("expected metadata for combined alert")
|
||||
}
|
||||
metrics, ok := alert.Metadata["triggeredMetrics"].([]string)
|
||||
if !ok {
|
||||
t.Fatalf("expected triggeredMetrics slice, got %#v", alert.Metadata["triggeredMetrics"])
|
||||
}
|
||||
if len(metrics) < 2 {
|
||||
t.Fatalf("expected both age and size metrics recorded, got %v", metrics)
|
||||
}
|
||||
if metric, ok := alert.Metadata["primaryMetric"].(string); !ok || metric != "age" {
|
||||
t.Fatalf("expected primary metric age, got %#v", alert.Metadata["primaryMetric"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckBackupsCreatesAndClearsAlerts(t *testing.T) {
|
||||
m := NewManager()
|
||||
m.ClearActiveAlerts()
|
||||
|
|
|
|||
|
|
@ -113,8 +113,14 @@ func (h *NotificationHandlers) UpdateAppriseConfig(w http.ResponseWriter, r *htt
|
|||
|
||||
log.Info().
|
||||
Bool("enabled", config.Enabled).
|
||||
Str("mode", string(config.Mode)).
|
||||
Int("targetCount", len(config.Targets)).
|
||||
Str("cliPath", config.CLIPath).
|
||||
Str("serverUrl", config.ServerURL).
|
||||
Str("configKey", config.ConfigKey).
|
||||
Bool("hasApiKey", config.APIKey != "").
|
||||
Str("apiKeyHeader", config.APIKeyHeader).
|
||||
Bool("skipTlsVerify", config.SkipTLSVerify).
|
||||
Int("timeoutSeconds", config.TimeoutSeconds).
|
||||
Msg("Parsed Apprise configuration update")
|
||||
|
||||
|
|
|
|||
|
|
@ -215,6 +215,21 @@ func (c *ConfigPersistence) SaveAlertConfig(config alerts.AlertConfig) error {
|
|||
if config.SnapshotDefaults.CriticalDays > 0 && config.SnapshotDefaults.WarningDays > config.SnapshotDefaults.CriticalDays {
|
||||
config.SnapshotDefaults.WarningDays = config.SnapshotDefaults.CriticalDays
|
||||
}
|
||||
if config.SnapshotDefaults.CriticalDays == 0 && config.SnapshotDefaults.WarningDays > 0 {
|
||||
config.SnapshotDefaults.CriticalDays = config.SnapshotDefaults.WarningDays
|
||||
}
|
||||
if config.SnapshotDefaults.WarningSizeGiB < 0 {
|
||||
config.SnapshotDefaults.WarningSizeGiB = 0
|
||||
}
|
||||
if config.SnapshotDefaults.CriticalSizeGiB < 0 {
|
||||
config.SnapshotDefaults.CriticalSizeGiB = 0
|
||||
}
|
||||
if config.SnapshotDefaults.CriticalSizeGiB > 0 && config.SnapshotDefaults.WarningSizeGiB > config.SnapshotDefaults.CriticalSizeGiB {
|
||||
config.SnapshotDefaults.WarningSizeGiB = config.SnapshotDefaults.CriticalSizeGiB
|
||||
}
|
||||
if config.SnapshotDefaults.CriticalSizeGiB == 0 && config.SnapshotDefaults.WarningSizeGiB > 0 {
|
||||
config.SnapshotDefaults.CriticalSizeGiB = config.SnapshotDefaults.WarningSizeGiB
|
||||
}
|
||||
if config.BackupDefaults.WarningDays < 0 {
|
||||
config.BackupDefaults.WarningDays = 0
|
||||
}
|
||||
|
|
@ -277,9 +292,11 @@ func (c *ConfigPersistence) LoadAlertConfig() (*alerts.AlertConfig, error) {
|
|||
SuppressionWindow: 5,
|
||||
HysteresisMargin: 5.0,
|
||||
SnapshotDefaults: alerts.SnapshotAlertConfig{
|
||||
Enabled: false,
|
||||
WarningDays: 30,
|
||||
CriticalDays: 45,
|
||||
Enabled: false,
|
||||
WarningDays: 30,
|
||||
CriticalDays: 45,
|
||||
WarningSizeGiB: 0,
|
||||
CriticalSizeGiB: 0,
|
||||
},
|
||||
BackupDefaults: alerts.BackupAlertConfig{
|
||||
Enabled: false,
|
||||
|
|
@ -345,6 +362,18 @@ func (c *ConfigPersistence) LoadAlertConfig() (*alerts.AlertConfig, error) {
|
|||
if config.SnapshotDefaults.CriticalDays > 0 && config.SnapshotDefaults.WarningDays > config.SnapshotDefaults.CriticalDays {
|
||||
config.SnapshotDefaults.WarningDays = config.SnapshotDefaults.CriticalDays
|
||||
}
|
||||
if config.SnapshotDefaults.WarningSizeGiB < 0 {
|
||||
config.SnapshotDefaults.WarningSizeGiB = 0
|
||||
}
|
||||
if config.SnapshotDefaults.CriticalSizeGiB < 0 {
|
||||
config.SnapshotDefaults.CriticalSizeGiB = 0
|
||||
}
|
||||
if config.SnapshotDefaults.CriticalSizeGiB > 0 && config.SnapshotDefaults.WarningSizeGiB > config.SnapshotDefaults.CriticalSizeGiB {
|
||||
config.SnapshotDefaults.WarningSizeGiB = config.SnapshotDefaults.CriticalSizeGiB
|
||||
}
|
||||
if config.SnapshotDefaults.CriticalSizeGiB == 0 && config.SnapshotDefaults.WarningSizeGiB > 0 {
|
||||
config.SnapshotDefaults.CriticalSizeGiB = config.SnapshotDefaults.WarningSizeGiB
|
||||
}
|
||||
if config.BackupDefaults.WarningDays < 0 {
|
||||
config.BackupDefaults.WarningDays = 0
|
||||
}
|
||||
|
|
@ -500,9 +529,11 @@ func (c *ConfigPersistence) LoadAppriseConfig() (*notifications.AppriseConfig, e
|
|||
if os.IsNotExist(err) {
|
||||
defaultCfg := notifications.AppriseConfig{
|
||||
Enabled: false,
|
||||
Mode: notifications.AppriseModeCLI,
|
||||
Targets: []string{},
|
||||
CLIPath: "apprise",
|
||||
TimeoutSeconds: 15,
|
||||
APIKeyHeader: "X-API-KEY",
|
||||
}
|
||||
return &defaultCfg, nil
|
||||
}
|
||||
|
|
@ -684,29 +715,29 @@ type NodesConfig struct {
|
|||
// SystemSettings represents system configuration settings
|
||||
type SystemSettings struct {
|
||||
// Note: PVE polling is hardcoded to 10s since Proxmox cluster/resources endpoint only updates every 10s
|
||||
PBSPollingInterval int `json:"pbsPollingInterval"` // PBS polling interval in seconds
|
||||
PMGPollingInterval int `json:"pmgPollingInterval"` // PMG polling interval in seconds
|
||||
BackupPollingInterval int `json:"backupPollingInterval,omitempty"`
|
||||
BackupPollingEnabled *bool `json:"backupPollingEnabled,omitempty"`
|
||||
AdaptivePollingEnabled *bool `json:"adaptivePollingEnabled,omitempty"`
|
||||
AdaptivePollingBaseInterval int `json:"adaptivePollingBaseInterval,omitempty"`
|
||||
AdaptivePollingMinInterval int `json:"adaptivePollingMinInterval,omitempty"`
|
||||
AdaptivePollingMaxInterval int `json:"adaptivePollingMaxInterval,omitempty"`
|
||||
BackendPort int `json:"backendPort,omitempty"`
|
||||
FrontendPort int `json:"frontendPort,omitempty"`
|
||||
AllowedOrigins string `json:"allowedOrigins,omitempty"`
|
||||
ConnectionTimeout int `json:"connectionTimeout,omitempty"`
|
||||
UpdateChannel string `json:"updateChannel,omitempty"`
|
||||
AutoUpdateEnabled bool `json:"autoUpdateEnabled"` // Removed omitempty so false is saved
|
||||
AutoUpdateCheckInterval int `json:"autoUpdateCheckInterval,omitempty"`
|
||||
AutoUpdateTime string `json:"autoUpdateTime,omitempty"`
|
||||
LogLevel string `json:"logLevel,omitempty"`
|
||||
DiscoveryEnabled bool `json:"discoveryEnabled"`
|
||||
DiscoverySubnet string `json:"discoverySubnet,omitempty"`
|
||||
DiscoveryConfig DiscoveryConfig `json:"discoveryConfig"`
|
||||
Theme string `json:"theme,omitempty"` // User theme preference: "light", "dark", or empty for system default
|
||||
AllowEmbedding bool `json:"allowEmbedding"` // Allow iframe embedding
|
||||
AllowedEmbedOrigins string `json:"allowedEmbedOrigins,omitempty"` // Comma-separated list of allowed origins for embedding
|
||||
PBSPollingInterval int `json:"pbsPollingInterval"` // PBS polling interval in seconds
|
||||
PMGPollingInterval int `json:"pmgPollingInterval"` // PMG polling interval in seconds
|
||||
BackupPollingInterval int `json:"backupPollingInterval,omitempty"`
|
||||
BackupPollingEnabled *bool `json:"backupPollingEnabled,omitempty"`
|
||||
AdaptivePollingEnabled *bool `json:"adaptivePollingEnabled,omitempty"`
|
||||
AdaptivePollingBaseInterval int `json:"adaptivePollingBaseInterval,omitempty"`
|
||||
AdaptivePollingMinInterval int `json:"adaptivePollingMinInterval,omitempty"`
|
||||
AdaptivePollingMaxInterval int `json:"adaptivePollingMaxInterval,omitempty"`
|
||||
BackendPort int `json:"backendPort,omitempty"`
|
||||
FrontendPort int `json:"frontendPort,omitempty"`
|
||||
AllowedOrigins string `json:"allowedOrigins,omitempty"`
|
||||
ConnectionTimeout int `json:"connectionTimeout,omitempty"`
|
||||
UpdateChannel string `json:"updateChannel,omitempty"`
|
||||
AutoUpdateEnabled bool `json:"autoUpdateEnabled"` // Removed omitempty so false is saved
|
||||
AutoUpdateCheckInterval int `json:"autoUpdateCheckInterval,omitempty"`
|
||||
AutoUpdateTime string `json:"autoUpdateTime,omitempty"`
|
||||
LogLevel string `json:"logLevel,omitempty"`
|
||||
DiscoveryEnabled bool `json:"discoveryEnabled"`
|
||||
DiscoverySubnet string `json:"discoverySubnet,omitempty"`
|
||||
DiscoveryConfig DiscoveryConfig `json:"discoveryConfig"`
|
||||
Theme string `json:"theme,omitempty"` // User theme preference: "light", "dark", or empty for system default
|
||||
AllowEmbedding bool `json:"allowEmbedding"` // Allow iframe embedding
|
||||
AllowedEmbedOrigins string `json:"allowedEmbedOrigins,omitempty"` // Comma-separated list of allowed origins for embedding
|
||||
// APIToken removed - now handled via .env file only
|
||||
}
|
||||
|
||||
|
|
@ -714,13 +745,13 @@ type SystemSettings struct {
|
|||
func DefaultSystemSettings() *SystemSettings {
|
||||
defaultDiscovery := DefaultDiscoveryConfig()
|
||||
return &SystemSettings{
|
||||
PBSPollingInterval: 60,
|
||||
PMGPollingInterval: 60,
|
||||
AutoUpdateEnabled: false,
|
||||
DiscoveryEnabled: false,
|
||||
DiscoverySubnet: "auto",
|
||||
DiscoveryConfig: defaultDiscovery,
|
||||
AllowEmbedding: false,
|
||||
PBSPollingInterval: 60,
|
||||
PMGPollingInterval: 60,
|
||||
AutoUpdateEnabled: false,
|
||||
DiscoveryEnabled: false,
|
||||
DiscoverySubnet: "auto",
|
||||
DiscoveryConfig: defaultDiscovery,
|
||||
AllowEmbedding: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -145,9 +145,11 @@ func TestLoadAlertConfigAppliesDefaults(t *testing.T) {
|
|||
TimeThresholds: map[string]int{"guest": 0, "node": 0},
|
||||
DockerIgnoredContainerPrefixes: []string{" Runner "},
|
||||
SnapshotDefaults: alerts.SnapshotAlertConfig{
|
||||
Enabled: true,
|
||||
WarningDays: 20,
|
||||
CriticalDays: 10,
|
||||
Enabled: true,
|
||||
WarningDays: 20,
|
||||
CriticalDays: 10,
|
||||
WarningSizeGiB: 15,
|
||||
CriticalSizeGiB: 8,
|
||||
},
|
||||
BackupDefaults: alerts.BackupAlertConfig{
|
||||
Enabled: true,
|
||||
|
|
@ -210,6 +212,12 @@ func TestLoadAlertConfigAppliesDefaults(t *testing.T) {
|
|||
if loaded.SnapshotDefaults.CriticalDays != 10 {
|
||||
t.Fatalf("expected snapshot critical days preserved at 10, got %d", loaded.SnapshotDefaults.CriticalDays)
|
||||
}
|
||||
if loaded.SnapshotDefaults.WarningSizeGiB != 8 {
|
||||
t.Fatalf("expected snapshot warning size normalized to 8, got %.1f", loaded.SnapshotDefaults.WarningSizeGiB)
|
||||
}
|
||||
if loaded.SnapshotDefaults.CriticalSizeGiB != 8 {
|
||||
t.Fatalf("expected snapshot critical size preserved at 8, got %.1f", loaded.SnapshotDefaults.CriticalSizeGiB)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppriseConfigPersistence(t *testing.T) {
|
||||
|
|
@ -348,7 +356,7 @@ func TestImportConfigTransactionalSuccess(t *testing.T) {
|
|||
}
|
||||
|
||||
newAlerts := alerts.AlertConfig{
|
||||
Enabled: true,
|
||||
Enabled: true,
|
||||
HysteresisMargin: 3.5,
|
||||
StorageDefault: alerts.HysteresisThreshold{
|
||||
Trigger: 70,
|
||||
|
|
@ -426,7 +434,7 @@ func TestImportConfigTransactionalSuccess(t *testing.T) {
|
|||
}
|
||||
|
||||
oldAlerts := alerts.AlertConfig{
|
||||
Enabled: true,
|
||||
Enabled: true,
|
||||
HysteresisMargin: 5,
|
||||
StorageDefault: alerts.HysteresisThreshold{
|
||||
Trigger: 85,
|
||||
|
|
@ -439,13 +447,13 @@ func TestImportConfigTransactionalSuccess(t *testing.T) {
|
|||
}
|
||||
|
||||
oldSystem := config.SystemSettings{
|
||||
PBSPollingInterval: 120,
|
||||
PMGPollingInterval: 120,
|
||||
AutoUpdateEnabled: false,
|
||||
DiscoveryEnabled: true,
|
||||
DiscoverySubnet: "auto",
|
||||
DiscoveryConfig: config.DefaultDiscoveryConfig(),
|
||||
Theme: "light",
|
||||
PBSPollingInterval: 120,
|
||||
PMGPollingInterval: 120,
|
||||
AutoUpdateEnabled: false,
|
||||
DiscoveryEnabled: true,
|
||||
DiscoverySubnet: "auto",
|
||||
DiscoveryConfig: config.DefaultDiscoveryConfig(),
|
||||
Theme: "light",
|
||||
}
|
||||
if err := target.SaveSystemSettings(oldSystem); err != nil {
|
||||
t.Fatalf("SaveSystemSettings baseline: %v", err)
|
||||
|
|
@ -529,7 +537,7 @@ func TestImportConfigRollbackOnFailure(t *testing.T) {
|
|||
}
|
||||
|
||||
newAlerts := alerts.AlertConfig{
|
||||
Enabled: true,
|
||||
Enabled: true,
|
||||
HysteresisMargin: 4,
|
||||
StorageDefault: alerts.HysteresisThreshold{
|
||||
Trigger: 65,
|
||||
|
|
@ -592,7 +600,7 @@ func TestImportConfigRollbackOnFailure(t *testing.T) {
|
|||
t.Fatalf("SaveNodesConfig baseline: %v", err)
|
||||
}
|
||||
baselineAlerts := alerts.AlertConfig{
|
||||
Enabled: true,
|
||||
Enabled: true,
|
||||
HysteresisMargin: 5,
|
||||
StorageDefault: alerts.HysteresisThreshold{
|
||||
Trigger: 90,
|
||||
|
|
@ -703,7 +711,7 @@ func TestImportAcceptsVersion40Bundle(t *testing.T) {
|
|||
}
|
||||
|
||||
newAlerts := alerts.AlertConfig{
|
||||
Enabled: true,
|
||||
Enabled: true,
|
||||
HysteresisMargin: 4,
|
||||
StorageDefault: alerts.HysteresisThreshold{
|
||||
Trigger: 75,
|
||||
|
|
|
|||
|
|
@ -2514,7 +2514,8 @@ func generateSnapshots(vms []models.VM, containers []models.Container) []models.
|
|||
VMID: vm.VMID,
|
||||
Time: snapshotTime,
|
||||
Description: fmt.Sprintf("Snapshot of %s taken on %s", vm.Name, snapshotTime.Format("2006-01-02")),
|
||||
VMState: rand.Float64() > 0.5, // 50% include VM state
|
||||
VMState: rand.Float64() > 0.5, // 50% include VM state
|
||||
SizeBytes: int64(10+rand.Intn(90)) << 30, // 10-99 GiB
|
||||
}
|
||||
|
||||
// Add parent relationship for some snapshots
|
||||
|
|
@ -2546,7 +2547,8 @@ func generateSnapshots(vms []models.VM, containers []models.Container) []models.
|
|||
VMID: int(ct.VMID),
|
||||
Time: snapshotTime,
|
||||
Description: fmt.Sprintf("Container snapshot for %s", ct.Name),
|
||||
VMState: false, // Containers don't have VM state
|
||||
VMState: false, // Containers don't have VM state
|
||||
SizeBytes: int64(5+rand.Intn(40)) << 30, // 5-44 GiB
|
||||
}
|
||||
|
||||
snapshots = append(snapshots, snapshot)
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ type State struct {
|
|||
VMs []VM `json:"vms"`
|
||||
Containers []Container `json:"containers"`
|
||||
DockerHosts []DockerHost `json:"dockerHosts"`
|
||||
Hosts []Host `json:"hosts"`
|
||||
Storage []Storage `json:"storage"`
|
||||
CephClusters []CephCluster `json:"cephClusters"`
|
||||
PhysicalDisks []PhysicalDisk `json:"physicalDisks"`
|
||||
|
|
@ -137,6 +138,52 @@ type Container struct {
|
|||
LastSeen time.Time `json:"lastSeen"`
|
||||
}
|
||||
|
||||
// Host represents a generic infrastructure host reporting via external agents.
|
||||
type Host struct {
|
||||
ID string `json:"id"`
|
||||
Hostname string `json:"hostname"`
|
||||
DisplayName string `json:"displayName,omitempty"`
|
||||
Platform string `json:"platform,omitempty"`
|
||||
OSName string `json:"osName,omitempty"`
|
||||
OSVersion string `json:"osVersion,omitempty"`
|
||||
KernelVersion string `json:"kernelVersion,omitempty"`
|
||||
Architecture string `json:"architecture,omitempty"`
|
||||
CPUCount int `json:"cpuCount,omitempty"`
|
||||
CPUUsage float64 `json:"cpuUsage,omitempty"`
|
||||
Memory Memory `json:"memory"`
|
||||
LoadAverage []float64 `json:"loadAverage,omitempty"`
|
||||
Disks []Disk `json:"disks,omitempty"`
|
||||
NetworkInterfaces []HostNetworkInterface `json:"networkInterfaces,omitempty"`
|
||||
Sensors HostSensorSummary `json:"sensors,omitempty"`
|
||||
Status string `json:"status"`
|
||||
UptimeSeconds int64 `json:"uptimeSeconds,omitempty"`
|
||||
IntervalSeconds int `json:"intervalSeconds,omitempty"`
|
||||
LastSeen time.Time `json:"lastSeen"`
|
||||
AgentVersion string `json:"agentVersion,omitempty"`
|
||||
TokenID string `json:"tokenId,omitempty"`
|
||||
TokenName string `json:"tokenName,omitempty"`
|
||||
TokenHint string `json:"tokenHint,omitempty"`
|
||||
TokenLastUsedAt *time.Time `json:"tokenLastUsedAt,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
}
|
||||
|
||||
// HostNetworkInterface describes a host network adapter summary.
|
||||
type HostNetworkInterface struct {
|
||||
Name string `json:"name"`
|
||||
MAC string `json:"mac,omitempty"`
|
||||
Addresses []string `json:"addresses,omitempty"`
|
||||
RXBytes uint64 `json:"rxBytes,omitempty"`
|
||||
TXBytes uint64 `json:"txBytes,omitempty"`
|
||||
SpeedMbps *int64 `json:"speedMbps,omitempty"`
|
||||
}
|
||||
|
||||
// HostSensorSummary captures optional per-host sensor readings.
|
||||
type HostSensorSummary struct {
|
||||
TemperatureCelsius map[string]float64 `json:"temperatureCelsius,omitempty"`
|
||||
FanRPM map[string]float64 `json:"fanRpm,omitempty"`
|
||||
Additional map[string]float64 `json:"additional,omitempty"`
|
||||
}
|
||||
|
||||
// DockerHost represents a Docker host reporting metrics via the external agent.
|
||||
type DockerHost struct {
|
||||
ID string `json:"id"`
|
||||
|
|
@ -674,6 +721,7 @@ type GuestSnapshot struct {
|
|||
Description string `json:"description,omitempty"`
|
||||
Parent string `json:"parent,omitempty"`
|
||||
VMState bool `json:"vmstate"`
|
||||
SizeBytes int64 `json:"sizeBytes,omitempty"`
|
||||
}
|
||||
|
||||
// Performance represents performance metrics
|
||||
|
|
@ -1046,6 +1094,91 @@ func (s *State) GetDockerHosts() []DockerHost {
|
|||
return hosts
|
||||
}
|
||||
|
||||
// UpsertHost inserts or updates a generic host in state.
|
||||
func (s *State) UpsertHost(host Host) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
updated := false
|
||||
for i, existing := range s.Hosts {
|
||||
if existing.ID == host.ID {
|
||||
s.Hosts[i] = host
|
||||
updated = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !updated {
|
||||
s.Hosts = append(s.Hosts, host)
|
||||
}
|
||||
|
||||
sort.Slice(s.Hosts, func(i, j int) bool {
|
||||
return s.Hosts[i].Hostname < s.Hosts[j].Hostname
|
||||
})
|
||||
|
||||
s.LastUpdate = time.Now()
|
||||
}
|
||||
|
||||
// GetHosts returns a copy of all generic hosts.
|
||||
func (s *State) GetHosts() []Host {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
hosts := make([]Host, len(s.Hosts))
|
||||
copy(hosts, s.Hosts)
|
||||
return hosts
|
||||
}
|
||||
|
||||
// RemoveHost removes a host by ID and returns the removed entry.
|
||||
func (s *State) RemoveHost(hostID string) (Host, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
for i, host := range s.Hosts {
|
||||
if host.ID == hostID {
|
||||
s.Hosts = append(s.Hosts[:i], s.Hosts[i+1:]...)
|
||||
s.LastUpdate = time.Now()
|
||||
return host, true
|
||||
}
|
||||
}
|
||||
|
||||
return Host{}, false
|
||||
}
|
||||
|
||||
// SetHostStatus updates the status of a host if present.
|
||||
func (s *State) SetHostStatus(hostID, status string) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
for i, host := range s.Hosts {
|
||||
if host.ID == hostID {
|
||||
if host.Status != status {
|
||||
host.Status = status
|
||||
s.Hosts[i] = host
|
||||
s.LastUpdate = time.Now()
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// TouchHost updates the last seen timestamp for a host.
|
||||
func (s *State) TouchHost(hostID string, ts time.Time) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
for i, host := range s.Hosts {
|
||||
if host.ID == hostID {
|
||||
host.LastSeen = ts
|
||||
s.Hosts[i] = host
|
||||
s.LastUpdate = time.Now()
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// UpdateStorage updates the storage in the state
|
||||
func (s *State) UpdateStorage(storage []Storage) {
|
||||
s.mu.Lock()
|
||||
|
|
|
|||
|
|
@ -6431,6 +6431,17 @@ func (m *Monitor) pollGuestSnapshots(ctx context.Context, instanceName string, c
|
|||
return
|
||||
}
|
||||
|
||||
if len(allSnapshots) > 0 {
|
||||
sizeMap := m.collectSnapshotSizes(snapshotCtx, instanceName, client, allSnapshots)
|
||||
if len(sizeMap) > 0 {
|
||||
for i := range allSnapshots {
|
||||
if size, ok := sizeMap[allSnapshots[i].ID]; ok && size > 0 {
|
||||
allSnapshots[i].SizeBytes = size
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update state with guest snapshots for this instance
|
||||
m.state.UpdateGuestSnapshotsForInstance(instanceName, allSnapshots)
|
||||
|
||||
|
|
@ -6444,6 +6455,116 @@ func (m *Monitor) pollGuestSnapshots(ctx context.Context, instanceName string, c
|
|||
Msg("Guest snapshots polled")
|
||||
}
|
||||
|
||||
func (m *Monitor) collectSnapshotSizes(ctx context.Context, instanceName string, client PVEClientInterface, snapshots []models.GuestSnapshot) map[string]int64 {
|
||||
sizes := make(map[string]int64, len(snapshots))
|
||||
if len(snapshots) == 0 {
|
||||
return sizes
|
||||
}
|
||||
|
||||
validSnapshots := make(map[string]struct{}, len(snapshots))
|
||||
nodes := make(map[string]struct{})
|
||||
|
||||
for _, snap := range snapshots {
|
||||
validSnapshots[snap.ID] = struct{}{}
|
||||
if snap.Node != "" {
|
||||
nodes[snap.Node] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
if len(nodes) == 0 {
|
||||
return sizes
|
||||
}
|
||||
|
||||
seenVolids := make(map[string]struct{})
|
||||
|
||||
for nodeName := range nodes {
|
||||
if ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
|
||||
storages, err := client.GetStorage(ctx, nodeName)
|
||||
if err != nil {
|
||||
log.Debug().
|
||||
Err(err).
|
||||
Str("node", nodeName).
|
||||
Str("instance", instanceName).
|
||||
Msg("Failed to get storage list for snapshot sizing")
|
||||
continue
|
||||
}
|
||||
|
||||
for _, storage := range storages {
|
||||
if ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
|
||||
contentTypes := strings.ToLower(storage.Content)
|
||||
if !strings.Contains(contentTypes, "images") && !strings.Contains(contentTypes, "rootdir") {
|
||||
continue
|
||||
}
|
||||
|
||||
contents, err := client.GetStorageContent(ctx, nodeName, storage.Storage)
|
||||
if err != nil {
|
||||
log.Debug().
|
||||
Err(err).
|
||||
Str("node", nodeName).
|
||||
Str("storage", storage.Storage).
|
||||
Str("instance", instanceName).
|
||||
Msg("Failed to get storage content for snapshot sizing")
|
||||
continue
|
||||
}
|
||||
|
||||
for _, item := range contents {
|
||||
if item.VMID <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, seen := seenVolids[item.Volid]; seen {
|
||||
continue
|
||||
}
|
||||
|
||||
snapName := extractSnapshotName(item.Volid)
|
||||
if snapName == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
key := fmt.Sprintf("%s-%s-%d-%s", instanceName, nodeName, item.VMID, snapName)
|
||||
if _, ok := validSnapshots[key]; !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
seenVolids[item.Volid] = struct{}{}
|
||||
|
||||
size := int64(item.Size)
|
||||
if size < 0 {
|
||||
size = 0
|
||||
}
|
||||
|
||||
sizes[key] += size
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sizes
|
||||
}
|
||||
|
||||
func extractSnapshotName(volid string) string {
|
||||
if volid == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
parts := strings.SplitN(volid, ":", 2)
|
||||
remainder := volid
|
||||
if len(parts) == 2 {
|
||||
remainder = parts[1]
|
||||
}
|
||||
|
||||
if idx := strings.Index(remainder, "@"); idx >= 0 && idx+1 < len(remainder) {
|
||||
return strings.TrimSpace(remainder[idx+1:])
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// Stop gracefully stops the monitor
|
||||
func (m *Monitor) Stop() {
|
||||
log.Info().Msg("Stopping monitor")
|
||||
|
|
|
|||
122
internal/monitoring/monitor_snapshots_test.go
Normal file
122
internal/monitoring/monitor_snapshots_test.go
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
package monitoring
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
||||
"github.com/rcourtman/pulse-go-rewrite/pkg/proxmox"
|
||||
)
|
||||
|
||||
type fakeSnapshotClient struct {
|
||||
storages map[string][]proxmox.Storage
|
||||
contents map[string]map[string][]proxmox.StorageContent
|
||||
}
|
||||
|
||||
func (f fakeSnapshotClient) GetNodes(ctx context.Context) ([]proxmox.Node, error) { return nil, nil }
|
||||
func (f fakeSnapshotClient) GetNodeStatus(ctx context.Context, node string) (*proxmox.NodeStatus, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f fakeSnapshotClient) GetNodeRRDData(ctx context.Context, node string, timeframe string, cf string, ds []string) ([]proxmox.NodeRRDPoint, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f fakeSnapshotClient) GetVMs(ctx context.Context, node string) ([]proxmox.VM, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f fakeSnapshotClient) GetContainers(ctx context.Context, node string) ([]proxmox.Container, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f fakeSnapshotClient) GetStorage(ctx context.Context, node string) ([]proxmox.Storage, error) {
|
||||
return f.storages[node], nil
|
||||
}
|
||||
func (f fakeSnapshotClient) GetAllStorage(ctx context.Context) ([]proxmox.Storage, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f fakeSnapshotClient) GetBackupTasks(ctx context.Context) ([]proxmox.Task, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f fakeSnapshotClient) GetStorageContent(ctx context.Context, node, storage string) ([]proxmox.StorageContent, error) {
|
||||
if storageContents, ok := f.contents[node]; ok {
|
||||
return storageContents[storage], nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
func (f fakeSnapshotClient) GetVMSnapshots(ctx context.Context, node string, vmid int) ([]proxmox.Snapshot, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f fakeSnapshotClient) GetContainerSnapshots(ctx context.Context, node string, vmid int) ([]proxmox.Snapshot, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f fakeSnapshotClient) GetVMStatus(ctx context.Context, node string, vmid int) (*proxmox.VMStatus, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f fakeSnapshotClient) GetContainerStatus(ctx context.Context, node string, vmid int) (*proxmox.Container, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f fakeSnapshotClient) GetClusterResources(ctx context.Context, resourceType string) ([]proxmox.ClusterResource, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f fakeSnapshotClient) IsClusterMember(ctx context.Context) (bool, error) { return false, nil }
|
||||
func (f fakeSnapshotClient) GetVMFSInfo(ctx context.Context, node string, vmid int) ([]proxmox.VMFileSystem, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f fakeSnapshotClient) GetVMNetworkInterfaces(ctx context.Context, node string, vmid int) ([]proxmox.VMNetworkInterface, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f fakeSnapshotClient) GetVMAgentInfo(ctx context.Context, node string, vmid int) (map[string]interface{}, error) {
|
||||
return map[string]interface{}{}, nil
|
||||
}
|
||||
func (f fakeSnapshotClient) GetZFSPoolStatus(ctx context.Context, node string) ([]proxmox.ZFSPoolStatus, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f fakeSnapshotClient) GetZFSPoolsWithDetails(ctx context.Context, node string) ([]proxmox.ZFSPoolInfo, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f fakeSnapshotClient) GetDisks(ctx context.Context, node string) ([]proxmox.Disk, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f fakeSnapshotClient) GetCephStatus(ctx context.Context) (*proxmox.CephStatus, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f fakeSnapshotClient) GetCephDF(ctx context.Context) (*proxmox.CephDF, error) { return nil, nil }
|
||||
|
||||
func TestCollectSnapshotSizes(t *testing.T) {
|
||||
m := &Monitor{}
|
||||
snapshots := []models.GuestSnapshot{
|
||||
{
|
||||
ID: "inst-node1-100-pre",
|
||||
Name: "pre",
|
||||
Node: "node1",
|
||||
Instance: "inst",
|
||||
Type: "qemu",
|
||||
VMID: 100,
|
||||
},
|
||||
}
|
||||
|
||||
client := fakeSnapshotClient{
|
||||
storages: map[string][]proxmox.Storage{
|
||||
"node1": {
|
||||
{Storage: "local-zfs", Content: "images"},
|
||||
},
|
||||
},
|
||||
contents: map[string]map[string][]proxmox.StorageContent{
|
||||
"node1": {
|
||||
"local-zfs": {
|
||||
{Volid: "local-zfs:vm-100-disk-0@pre", VMID: 100, Size: 20 << 30},
|
||||
// Duplicate entry should be deduped via volid tracking
|
||||
{Volid: "local-zfs:vm-100-disk-0@pre", VMID: 100, Size: 20 << 30},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
sizes := m.collectSnapshotSizes(context.Background(), "inst", client, snapshots)
|
||||
got, ok := sizes[snapshots[0].ID]
|
||||
if !ok {
|
||||
t.Fatalf("expected size entry for snapshot")
|
||||
}
|
||||
want := int64(20 << 30)
|
||||
if got != want {
|
||||
t.Fatalf("unexpected size: got %d want %d", got, want)
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ package notifications
|
|||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
|
|
@ -171,6 +172,15 @@ func copyAppriseConfig(cfg AppriseConfig) AppriseConfig {
|
|||
// NormalizeAppriseConfig cleans and normalizes Apprise configuration values.
|
||||
func NormalizeAppriseConfig(cfg AppriseConfig) AppriseConfig {
|
||||
normalized := cfg
|
||||
|
||||
mode := strings.ToLower(strings.TrimSpace(string(normalized.Mode)))
|
||||
switch mode {
|
||||
case string(AppriseModeHTTP):
|
||||
normalized.Mode = AppriseModeHTTP
|
||||
default:
|
||||
normalized.Mode = AppriseModeCLI
|
||||
}
|
||||
|
||||
normalized.CLIPath = strings.TrimSpace(normalized.CLIPath)
|
||||
if normalized.CLIPath == "" {
|
||||
normalized.CLIPath = "apprise"
|
||||
|
|
@ -198,10 +208,28 @@ func NormalizeAppriseConfig(cfg AppriseConfig) AppriseConfig {
|
|||
seen[lower] = struct{}{}
|
||||
cleanTargets = append(cleanTargets, trimmed)
|
||||
}
|
||||
|
||||
normalized.Targets = cleanTargets
|
||||
if len(cleanTargets) == 0 {
|
||||
normalized.Enabled = false
|
||||
|
||||
normalized.ServerURL = strings.TrimSpace(normalized.ServerURL)
|
||||
normalized.ServerURL = strings.TrimRight(normalized.ServerURL, "/")
|
||||
|
||||
normalized.ConfigKey = strings.TrimSpace(normalized.ConfigKey)
|
||||
|
||||
normalized.APIKey = strings.TrimSpace(normalized.APIKey)
|
||||
normalized.APIKeyHeader = strings.TrimSpace(normalized.APIKeyHeader)
|
||||
if normalized.APIKeyHeader == "" {
|
||||
normalized.APIKeyHeader = "X-API-KEY"
|
||||
}
|
||||
|
||||
switch normalized.Mode {
|
||||
case AppriseModeCLI:
|
||||
if len(normalized.Targets) == 0 {
|
||||
normalized.Enabled = false
|
||||
}
|
||||
case AppriseModeHTTP:
|
||||
if normalized.ServerURL == "" {
|
||||
normalized.Enabled = false
|
||||
}
|
||||
}
|
||||
|
||||
return normalized
|
||||
|
|
@ -258,12 +286,26 @@ type WebhookConfig struct {
|
|||
CustomFields map[string]string `json:"customFields,omitempty"`
|
||||
}
|
||||
|
||||
// AppriseConfig holds Apprise CLI notification settings.
|
||||
// AppriseMode identifies how Pulse should deliver notifications through Apprise.
|
||||
type AppriseMode string
|
||||
|
||||
const (
|
||||
AppriseModeCLI AppriseMode = "cli"
|
||||
AppriseModeHTTP AppriseMode = "http"
|
||||
)
|
||||
|
||||
// AppriseConfig holds Apprise notification settings.
|
||||
type AppriseConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Targets []string `json:"targets"`
|
||||
CLIPath string `json:"cliPath,omitempty"`
|
||||
TimeoutSeconds int `json:"timeoutSeconds,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Mode AppriseMode `json:"mode,omitempty"`
|
||||
Targets []string `json:"targets"`
|
||||
CLIPath string `json:"cliPath,omitempty"`
|
||||
TimeoutSeconds int `json:"timeoutSeconds,omitempty"`
|
||||
ServerURL string `json:"serverUrl,omitempty"`
|
||||
ConfigKey string `json:"configKey,omitempty"`
|
||||
APIKey string `json:"apiKey,omitempty"`
|
||||
APIKeyHeader string `json:"apiKeyHeader,omitempty"`
|
||||
SkipTLSVerify bool `json:"skipTlsVerify,omitempty"`
|
||||
}
|
||||
|
||||
// NewNotificationManager creates a new notification manager
|
||||
|
|
@ -281,9 +323,11 @@ func NewNotificationManager(publicURL string) *NotificationManager {
|
|||
webhooks: []WebhookConfig{},
|
||||
appriseConfig: AppriseConfig{
|
||||
Enabled: false,
|
||||
Mode: AppriseModeCLI,
|
||||
Targets: []string{},
|
||||
CLIPath: "apprise",
|
||||
TimeoutSeconds: 15,
|
||||
APIKeyHeader: "X-API-KEY",
|
||||
},
|
||||
groupWindow: 30 * time.Second,
|
||||
pendingAlerts: make([]*alerts.Alert, 0),
|
||||
|
|
@ -571,7 +615,7 @@ func (n *NotificationManager) sendGroupedAlerts() {
|
|||
}
|
||||
}
|
||||
|
||||
if appriseConfig.Enabled && len(appriseConfig.Targets) > 0 {
|
||||
if appriseConfig.Enabled {
|
||||
go n.sendGroupedApprise(appriseConfig, alertsToSend)
|
||||
}
|
||||
|
||||
|
|
@ -604,23 +648,64 @@ func (n *NotificationManager) sendGroupedApprise(config AppriseConfig, alertList
|
|||
}
|
||||
|
||||
cfg := NormalizeAppriseConfig(config)
|
||||
if !cfg.Enabled || len(cfg.Targets) == 0 {
|
||||
if !cfg.Enabled {
|
||||
return
|
||||
}
|
||||
|
||||
primary := alertList[0]
|
||||
alertCount := len(alertList)
|
||||
title, body, notifyType := buildApprisePayload(alertList, n.publicURL)
|
||||
if title == "" && body == "" {
|
||||
log.Warn().Msg("Apprise notification skipped: failed to build payload")
|
||||
return
|
||||
}
|
||||
|
||||
switch cfg.Mode {
|
||||
case AppriseModeHTTP:
|
||||
if err := n.sendAppriseViaHTTP(cfg, title, body, notifyType); err != nil {
|
||||
log.Warn().
|
||||
Err(err).
|
||||
Str("mode", string(cfg.Mode)).
|
||||
Str("serverUrl", cfg.ServerURL).
|
||||
Msg("Failed to send Apprise notification via API")
|
||||
}
|
||||
default:
|
||||
if err := n.sendAppriseViaCLI(cfg, title, body); err != nil {
|
||||
log.Warn().
|
||||
Err(err).
|
||||
Str("mode", string(cfg.Mode)).
|
||||
Str("cliPath", cfg.CLIPath).
|
||||
Strs("targets", cfg.Targets).
|
||||
Msg("Failed to send Apprise notification")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func buildApprisePayload(alertList []*alerts.Alert, publicURL string) (string, string, string) {
|
||||
validAlerts := make([]*alerts.Alert, 0, len(alertList))
|
||||
var primary *alerts.Alert
|
||||
for _, alert := range alertList {
|
||||
if alert == nil {
|
||||
continue
|
||||
}
|
||||
if primary == nil {
|
||||
primary = alert
|
||||
}
|
||||
validAlerts = append(validAlerts, alert)
|
||||
}
|
||||
|
||||
if len(validAlerts) == 0 || primary == nil {
|
||||
return "", "", "info"
|
||||
}
|
||||
|
||||
title := fmt.Sprintf("Pulse alert: %s", primary.ResourceName)
|
||||
if alertCount > 1 {
|
||||
title = fmt.Sprintf("Pulse alerts (%d)", alertCount)
|
||||
if len(validAlerts) > 1 {
|
||||
title = fmt.Sprintf("Pulse alerts (%d)", len(validAlerts))
|
||||
}
|
||||
|
||||
var bodyBuilder strings.Builder
|
||||
bodyBuilder.WriteString(primary.Message)
|
||||
bodyBuilder.WriteString("\n\n")
|
||||
|
||||
for _, alert := range alertList {
|
||||
for _, alert := range validAlerts {
|
||||
bodyBuilder.WriteString(fmt.Sprintf("[%s] %s", strings.ToUpper(string(alert.Level)), alert.ResourceName))
|
||||
bodyBuilder.WriteString(fmt.Sprintf(" — value %.2f (threshold %.2f)\n", alert.Value, alert.Threshold))
|
||||
if alert.Node != "" {
|
||||
|
|
@ -632,11 +717,33 @@ func (n *NotificationManager) sendGroupedApprise(config AppriseConfig, alertList
|
|||
bodyBuilder.WriteString("\n")
|
||||
}
|
||||
|
||||
if n.publicURL != "" {
|
||||
bodyBuilder.WriteString("Dashboard: " + n.publicURL + "\n")
|
||||
if publicURL != "" {
|
||||
bodyBuilder.WriteString("Dashboard: " + publicURL + "\n")
|
||||
}
|
||||
|
||||
body := bodyBuilder.String()
|
||||
return title, bodyBuilder.String(), resolveAppriseNotificationType(validAlerts)
|
||||
}
|
||||
|
||||
func resolveAppriseNotificationType(alertList []*alerts.Alert) string {
|
||||
notifyType := "info"
|
||||
for _, alert := range alertList {
|
||||
if alert == nil {
|
||||
continue
|
||||
}
|
||||
switch alert.Level {
|
||||
case alerts.AlertLevelCritical:
|
||||
return "failure"
|
||||
case alerts.AlertLevelWarning:
|
||||
notifyType = "warning"
|
||||
}
|
||||
}
|
||||
return notifyType
|
||||
}
|
||||
|
||||
func (n *NotificationManager) sendAppriseViaCLI(cfg AppriseConfig, title, body string) error {
|
||||
if len(cfg.Targets) == 0 {
|
||||
return fmt.Errorf("no Apprise targets configured for CLI delivery")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(cfg.TimeoutSeconds)*time.Second)
|
||||
defer cancel()
|
||||
|
|
@ -651,12 +758,14 @@ func (n *NotificationManager) sendGroupedApprise(config AppriseConfig, alertList
|
|||
|
||||
output, err := execFn(ctx, cfg.CLIPath, args)
|
||||
if err != nil {
|
||||
log.Warn().
|
||||
Err(err).
|
||||
Str("cliPath", cfg.CLIPath).
|
||||
Strs("targets", cfg.Targets).
|
||||
Msg("Failed to send Apprise notification")
|
||||
return
|
||||
if len(output) > 0 {
|
||||
log.Debug().
|
||||
Str("cliPath", cfg.CLIPath).
|
||||
Strs("targets", cfg.Targets).
|
||||
Str("output", string(output)).
|
||||
Msg("Apprise CLI output (error)")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if len(output) > 0 {
|
||||
|
|
@ -666,6 +775,97 @@ func (n *NotificationManager) sendGroupedApprise(config AppriseConfig, alertList
|
|||
Str("output", string(output)).
|
||||
Msg("Apprise CLI output")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (n *NotificationManager) sendAppriseViaHTTP(cfg AppriseConfig, title, body, notifyType string) error {
|
||||
if cfg.ServerURL == "" {
|
||||
return fmt.Errorf("apprise server URL is not configured")
|
||||
}
|
||||
|
||||
serverURL := cfg.ServerURL
|
||||
lowerURL := strings.ToLower(serverURL)
|
||||
if !strings.HasPrefix(lowerURL, "http://") && !strings.HasPrefix(lowerURL, "https://") {
|
||||
return fmt.Errorf("apprise server URL must start with http or https: %s", serverURL)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(cfg.TimeoutSeconds)*time.Second)
|
||||
defer cancel()
|
||||
|
||||
notifyEndpoint := "/notify"
|
||||
if cfg.ConfigKey != "" {
|
||||
notifyEndpoint = "/notify/" + url.PathEscape(cfg.ConfigKey)
|
||||
}
|
||||
|
||||
requestURL := strings.TrimRight(serverURL, "/") + notifyEndpoint
|
||||
|
||||
payload := map[string]any{
|
||||
"body": body,
|
||||
"title": title,
|
||||
}
|
||||
if len(cfg.Targets) > 0 {
|
||||
payload["urls"] = cfg.Targets
|
||||
}
|
||||
if notifyType != "" {
|
||||
payload["type"] = notifyType
|
||||
}
|
||||
|
||||
payloadBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal Apprise payload: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, requestURL, bytes.NewReader(payloadBytes))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create Apprise request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
if cfg.APIKey != "" {
|
||||
if cfg.APIKeyHeader == "" {
|
||||
req.Header.Set("X-API-KEY", cfg.APIKey)
|
||||
} else {
|
||||
req.Header.Set(cfg.APIKeyHeader, cfg.APIKey)
|
||||
}
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: time.Duration(cfg.TimeoutSeconds) * time.Second,
|
||||
}
|
||||
|
||||
if strings.HasPrefix(lowerURL, "https://") && cfg.SkipTLSVerify {
|
||||
client.Transport = &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to reach Apprise server: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
limited := io.LimitReader(resp.Body, WebhookMaxResponseSize)
|
||||
respBody, _ := io.ReadAll(limited)
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
if len(respBody) > 0 {
|
||||
return fmt.Errorf("apprise server returned HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody)))
|
||||
}
|
||||
return fmt.Errorf("apprise server returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
if len(respBody) > 0 {
|
||||
log.Debug().
|
||||
Str("mode", string(cfg.Mode)).
|
||||
Str("serverUrl", cfg.ServerURL).
|
||||
Str("response", string(respBody)).
|
||||
Msg("Apprise API response")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// sendEmail sends an email notification
|
||||
|
|
@ -878,6 +1078,8 @@ func (n *NotificationManager) sendGroupedWebhook(webhook WebhookConfig, alertLis
|
|||
if routingKey, ok := webhook.Headers["routing_key"]; ok {
|
||||
dataPtr.CustomFields["routing_key"] = routingKey
|
||||
}
|
||||
case "pushover":
|
||||
dataPtr.CustomFields = ensurePushoverCustomFieldAliases(dataPtr.CustomFields)
|
||||
}
|
||||
serviceDataApplied = true
|
||||
}
|
||||
|
|
@ -1339,6 +1541,39 @@ func convertWebhookCustomFields(fields map[string]string) map[string]interface{}
|
|||
return converted
|
||||
}
|
||||
|
||||
func ensurePushoverCustomFieldAliases(fields map[string]interface{}) map[string]interface{} {
|
||||
if fields == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, ok := fields["token"]; !ok || isEmptyInterface(fields["token"]) {
|
||||
if legacy, ok := fields["app_token"]; ok && !isEmptyInterface(legacy) {
|
||||
fields["token"] = legacy
|
||||
}
|
||||
}
|
||||
|
||||
if _, ok := fields["user"]; !ok || isEmptyInterface(fields["user"]) {
|
||||
if legacy, ok := fields["user_token"]; ok && !isEmptyInterface(legacy) {
|
||||
fields["user"] = legacy
|
||||
}
|
||||
}
|
||||
|
||||
return fields
|
||||
}
|
||||
|
||||
func isEmptyInterface(value interface{}) bool {
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
return strings.TrimSpace(v) == ""
|
||||
case fmt.Stringer:
|
||||
return strings.TrimSpace(v.String()) == ""
|
||||
case nil:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// prepareWebhookData prepares data for template rendering
|
||||
func (n *NotificationManager) prepareWebhookData(alert *alerts.Alert, customFields map[string]interface{}) WebhookPayloadData {
|
||||
duration := time.Since(alert.StartTime)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,11 @@ package notifications
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
|
|
@ -24,10 +29,15 @@ func TestNormalizeAppriseConfig(t *testing.T) {
|
|||
Targets: []string{" discord://token ", "", "DISCORD://TOKEN"},
|
||||
CLIPath: " ",
|
||||
TimeoutSeconds: -5,
|
||||
APIKeyHeader: "",
|
||||
}
|
||||
|
||||
normalized := NormalizeAppriseConfig(original)
|
||||
|
||||
if normalized.Mode != AppriseModeCLI {
|
||||
t.Fatalf("expected default mode cli, got %q", normalized.Mode)
|
||||
}
|
||||
|
||||
if normalized.CLIPath != "apprise" {
|
||||
t.Fatalf("expected default CLI path 'apprise', got %q", normalized.CLIPath)
|
||||
}
|
||||
|
|
@ -44,11 +54,51 @@ func TestNormalizeAppriseConfig(t *testing.T) {
|
|||
t.Fatalf("unexpected targets normalization result: %#v", normalized.Targets)
|
||||
}
|
||||
|
||||
if normalized.APIKeyHeader != "X-API-KEY" {
|
||||
t.Fatalf("expected default API key header, got %q", normalized.APIKeyHeader)
|
||||
}
|
||||
|
||||
// When all targets removed, enabled should reset to false
|
||||
empty := NormalizeAppriseConfig(AppriseConfig{Enabled: true})
|
||||
if empty.Enabled {
|
||||
t.Fatalf("expected enabled to be false when no targets configured")
|
||||
}
|
||||
|
||||
httpConfig := NormalizeAppriseConfig(AppriseConfig{
|
||||
Enabled: true,
|
||||
Mode: AppriseModeHTTP,
|
||||
ServerURL: "https://apprise.example.com/api/",
|
||||
APIKey: " secret ",
|
||||
APIKeyHeader: " X-Token ",
|
||||
TimeoutSeconds: 200,
|
||||
})
|
||||
|
||||
if httpConfig.Mode != AppriseModeHTTP {
|
||||
t.Fatalf("expected HTTP mode, got %q", httpConfig.Mode)
|
||||
}
|
||||
if httpConfig.ServerURL != "https://apprise.example.com/api" {
|
||||
t.Fatalf("expected server URL to be trimmed, got %q", httpConfig.ServerURL)
|
||||
}
|
||||
if httpConfig.APIKey != "secret" {
|
||||
t.Fatalf("expected API key to be trimmed, got %q", httpConfig.APIKey)
|
||||
}
|
||||
if httpConfig.APIKeyHeader != "X-Token" {
|
||||
t.Fatalf("expected API key header to be trimmed, got %q", httpConfig.APIKeyHeader)
|
||||
}
|
||||
if httpConfig.TimeoutSeconds != 120 {
|
||||
t.Fatalf("expected timeout to clamp to 120, got %d", httpConfig.TimeoutSeconds)
|
||||
}
|
||||
if !httpConfig.Enabled {
|
||||
t.Fatalf("expected HTTP config with server URL to remain enabled")
|
||||
}
|
||||
|
||||
disabledHTTP := NormalizeAppriseConfig(AppriseConfig{
|
||||
Enabled: true,
|
||||
Mode: AppriseModeHTTP,
|
||||
})
|
||||
if disabledHTTP.Enabled {
|
||||
t.Fatalf("expected HTTP config without server URL to disable notifications")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendGroupedAppriseInvokesExecutor(t *testing.T) {
|
||||
|
|
@ -108,6 +158,127 @@ func TestSendGroupedAppriseInvokesExecutor(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestSendGroupedAppriseHTTP(t *testing.T) {
|
||||
nm := NewNotificationManager("https://pulse.local")
|
||||
nm.SetGroupingWindow(0)
|
||||
nm.SetEmailConfig(EmailConfig{Enabled: false})
|
||||
|
||||
type apprisePayload struct {
|
||||
Body string `json:"body"`
|
||||
Title string `json:"title"`
|
||||
Type string `json:"type"`
|
||||
URLs []string `json:"urls"`
|
||||
}
|
||||
|
||||
type capturedRequest struct {
|
||||
Method string
|
||||
Path string
|
||||
ContentType string
|
||||
APIKey string
|
||||
Payload apprisePayload
|
||||
}
|
||||
|
||||
requests := make(chan capturedRequest, 1)
|
||||
errs := make(chan error, 1)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer r.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
errs <- err
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var payload apprisePayload
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
errs <- err
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
requests <- capturedRequest{
|
||||
Method: r.Method,
|
||||
Path: r.URL.Path,
|
||||
ContentType: r.Header.Get("Content-Type"),
|
||||
APIKey: r.Header.Get("X-Test-Key"),
|
||||
Payload: payload,
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
nm.SetAppriseConfig(AppriseConfig{
|
||||
Enabled: true,
|
||||
Mode: AppriseModeHTTP,
|
||||
ServerURL: server.URL,
|
||||
ConfigKey: "primary",
|
||||
APIKey: "secret",
|
||||
APIKeyHeader: "X-Test-Key",
|
||||
Targets: []string{"discord://token"},
|
||||
TimeoutSeconds: 10,
|
||||
})
|
||||
|
||||
alert := &alerts.Alert{
|
||||
ID: "test",
|
||||
Type: "cpu",
|
||||
Level: alerts.AlertLevelCritical,
|
||||
ResourceID: "vm-100",
|
||||
ResourceName: "vm-100",
|
||||
Message: "CPU usage high",
|
||||
Value: 95,
|
||||
Threshold: 90,
|
||||
StartTime: time.Now().Add(-time.Minute),
|
||||
LastSeen: time.Now(),
|
||||
}
|
||||
|
||||
nm.mu.Lock()
|
||||
nm.pendingAlerts = append(nm.pendingAlerts, alert)
|
||||
nm.mu.Unlock()
|
||||
|
||||
nm.sendGroupedAlerts()
|
||||
|
||||
var req capturedRequest
|
||||
select {
|
||||
case req = <-requests:
|
||||
case err := <-errs:
|
||||
t.Fatalf("server error: %v", err)
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatalf("timed out waiting for Apprise API request")
|
||||
}
|
||||
|
||||
if req.Method != http.MethodPost {
|
||||
t.Fatalf("expected POST request, got %s", req.Method)
|
||||
}
|
||||
if req.Path != "/notify/primary" {
|
||||
t.Fatalf("expected notify path with config key, got %s", req.Path)
|
||||
}
|
||||
if req.ContentType != "application/json" {
|
||||
t.Fatalf("expected JSON content type, got %s", req.ContentType)
|
||||
}
|
||||
if req.APIKey != "secret" {
|
||||
t.Fatalf("expected API key header to be set, got %q", req.APIKey)
|
||||
}
|
||||
if req.Payload.Title != "Pulse alert: vm-100" {
|
||||
t.Fatalf("unexpected title: %s", req.Payload.Title)
|
||||
}
|
||||
if req.Payload.Type != "failure" {
|
||||
t.Fatalf("expected failure notification type, got %s", req.Payload.Type)
|
||||
}
|
||||
if len(req.Payload.URLs) != 1 || req.Payload.URLs[0] != "discord://token" {
|
||||
t.Fatalf("unexpected URLs in payload: %#v", req.Payload.URLs)
|
||||
}
|
||||
if !strings.Contains(req.Payload.Body, "CPU usage high") {
|
||||
t.Fatalf("expected alert message in payload body, got %s", req.Payload.Body)
|
||||
}
|
||||
if !strings.Contains(req.Payload.Body, "Dashboard: https://pulse.local") {
|
||||
t.Fatalf("expected dashboard link in payload body, got %s", req.Payload.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationCooldownAllowsNewAlertInstance(t *testing.T) {
|
||||
nm := NewNotificationManager("")
|
||||
nm.SetCooldown(1) // 1 minute cooldown
|
||||
|
|
|
|||
Loading…
Reference in a new issue