Add snapshot size alert thresholds (#585)

This commit is contained in:
rcourtman 2025-10-22 13:30:40 +00:00
parent 30879c3b7b
commit be26f957c0
20 changed files with 2306 additions and 874 deletions

View file

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

View file

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

View file

@ -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())

View file

@ -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', () => {

View file

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

View file

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

View file

@ -89,6 +89,8 @@ export interface SnapshotAlertConfig {
enabled: boolean;
warningDays: number;
criticalDays: number;
warningSizeGiB?: number;
criticalSizeGiB?: number;
}
export interface BackupAlertConfig {

View file

@ -607,6 +607,7 @@ export interface GuestSnapshot {
description: string;
parent: string;
vmstate: boolean;
sizeBytes?: number;
}
export interface Performance {

View file

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

View file

@ -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()

View file

@ -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")

View file

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

View file

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

View file

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

View file

@ -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()

View file

@ -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")

View 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)
}
}

View file

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

View file

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