Add snapshot size alert thresholds (#585)
This commit is contained in:
parent
30879c3b7b
commit
be26f957c0
20 changed files with 2306 additions and 874 deletions
|
|
@ -320,11 +320,14 @@ Quick start - most settings are in the web UI:
|
||||||
- **Settings → Security**: Authentication and API tokens
|
- **Settings → Security**: Authentication and API tokens
|
||||||
- **Alerts**: Thresholds and notifications
|
- **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
|
### Configuration Files
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -59,9 +59,15 @@ export interface Webhook {
|
||||||
|
|
||||||
export interface AppriseConfig {
|
export interface AppriseConfig {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
targets: string[];
|
mode?: 'cli' | 'http';
|
||||||
|
targets?: string[];
|
||||||
cliPath?: string;
|
cliPath?: string;
|
||||||
timeoutSeconds?: number;
|
timeoutSeconds?: number;
|
||||||
|
serverUrl?: string;
|
||||||
|
configKey?: string;
|
||||||
|
apiKey?: string;
|
||||||
|
apiKeyHeader?: string;
|
||||||
|
skipTlsVerify?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NotificationTestRequest {
|
export interface NotificationTestRequest {
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -19,17 +19,15 @@ import type {
|
||||||
PMGBackup,
|
PMGBackup,
|
||||||
Backups,
|
Backups,
|
||||||
} from '@/types/api';
|
} 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 { ResourceTable, Resource, GroupHeaderMeta } from './ResourceTable';
|
||||||
import { useAlertsActivation } from '@/stores/alertsActivation';
|
import { useAlertsActivation } from '@/stores/alertsActivation';
|
||||||
type OverrideType =
|
type OverrideType = 'guest' | 'node' | 'storage' | 'pbs' | 'pmg' | 'dockerHost' | 'dockerContainer';
|
||||||
| 'guest'
|
|
||||||
| 'node'
|
|
||||||
| 'storage'
|
|
||||||
| 'pbs'
|
|
||||||
| 'pmg'
|
|
||||||
| 'dockerHost'
|
|
||||||
| 'dockerContainer';
|
|
||||||
|
|
||||||
type OfflineState = 'off' | 'warning' | 'critical';
|
type OfflineState = 'off' | 'warning' | 'critical';
|
||||||
|
|
||||||
|
|
@ -110,6 +108,8 @@ export const normalizeDockerIgnoredInput = (value: string): string[] =>
|
||||||
|
|
||||||
const DEFAULT_SNAPSHOT_WARNING = 30;
|
const DEFAULT_SNAPSHOT_WARNING = 30;
|
||||||
const DEFAULT_SNAPSHOT_CRITICAL = 45;
|
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_WARNING = 7;
|
||||||
const DEFAULT_BACKUP_CRITICAL = 14;
|
const DEFAULT_BACKUP_CRITICAL = 14;
|
||||||
|
|
||||||
|
|
@ -143,13 +143,13 @@ interface ThresholdsTableProps {
|
||||||
pmgBackups?: PMGBackup[];
|
pmgBackups?: PMGBackup[];
|
||||||
pmgThresholds: () => PMGThresholdDefaults;
|
pmgThresholds: () => PMGThresholdDefaults;
|
||||||
setPMGThresholds: (
|
setPMGThresholds: (
|
||||||
value:
|
value: PMGThresholdDefaults | ((prev: PMGThresholdDefaults) => PMGThresholdDefaults),
|
||||||
| PMGThresholdDefaults
|
|
||||||
| ((prev: PMGThresholdDefaults) => PMGThresholdDefaults),
|
|
||||||
) => void;
|
) => void;
|
||||||
guestDefaults: SimpleThresholds;
|
guestDefaults: SimpleThresholds;
|
||||||
setGuestDefaults: (
|
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;
|
) => void;
|
||||||
guestDisableConnectivity: () => boolean;
|
guestDisableConnectivity: () => boolean;
|
||||||
setGuestDisableConnectivity: (value: boolean) => void;
|
setGuestDisableConnectivity: (value: boolean) => void;
|
||||||
|
|
@ -157,11 +157,43 @@ interface ThresholdsTableProps {
|
||||||
setGuestPoweredOffSeverity: (value: 'warning' | 'critical') => void;
|
setGuestPoweredOffSeverity: (value: 'warning' | 'critical') => void;
|
||||||
nodeDefaults: SimpleThresholds;
|
nodeDefaults: SimpleThresholds;
|
||||||
setNodeDefaults: (
|
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;
|
) => 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: (
|
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;
|
) => void;
|
||||||
dockerIgnoredPrefixes: () => string[];
|
dockerIgnoredPrefixes: () => string[];
|
||||||
setDockerIgnoredPrefixes: (value: string[] | ((prev: string[]) => string[])) => void;
|
setDockerIgnoredPrefixes: (value: string[] | ((prev: string[]) => string[])) => void;
|
||||||
|
|
@ -185,17 +217,13 @@ interface ThresholdsTableProps {
|
||||||
) => void;
|
) => void;
|
||||||
snapshotDefaults: () => SnapshotAlertConfig;
|
snapshotDefaults: () => SnapshotAlertConfig;
|
||||||
setSnapshotDefaults: (
|
setSnapshotDefaults: (
|
||||||
value:
|
value: SnapshotAlertConfig | ((prev: SnapshotAlertConfig) => SnapshotAlertConfig),
|
||||||
| SnapshotAlertConfig
|
|
||||||
| ((prev: SnapshotAlertConfig) => SnapshotAlertConfig),
|
|
||||||
) => void;
|
) => void;
|
||||||
snapshotFactoryDefaults?: SnapshotAlertConfig;
|
snapshotFactoryDefaults?: SnapshotAlertConfig;
|
||||||
resetSnapshotDefaults?: () => void;
|
resetSnapshotDefaults?: () => void;
|
||||||
backupDefaults: () => BackupAlertConfig;
|
backupDefaults: () => BackupAlertConfig;
|
||||||
setBackupDefaults: (
|
setBackupDefaults: (
|
||||||
value:
|
value: BackupAlertConfig | ((prev: BackupAlertConfig) => BackupAlertConfig),
|
||||||
| BackupAlertConfig
|
|
||||||
| ((prev: BackupAlertConfig) => BackupAlertConfig),
|
|
||||||
) => void;
|
) => void;
|
||||||
backupFactoryDefaults?: BackupAlertConfig;
|
backupFactoryDefaults?: BackupAlertConfig;
|
||||||
resetBackupDefaults?: () => void;
|
resetBackupDefaults?: () => void;
|
||||||
|
|
@ -306,10 +334,7 @@ export function ThresholdsTable(props: ThresholdsTableProps) {
|
||||||
if (!el) return false;
|
if (!el) return false;
|
||||||
const tag = el.tagName;
|
const tag = el.tagName;
|
||||||
return (
|
return (
|
||||||
tag === 'INPUT' ||
|
tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || el.contentEditable === 'true'
|
||||||
tag === 'TEXTAREA' ||
|
|
||||||
tag === 'SELECT' ||
|
|
||||||
el.contentEditable === 'true'
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -381,6 +406,11 @@ export function ThresholdsTable(props: ThresholdsTableProps) {
|
||||||
return String(value);
|
return String(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (metric === 'warningSizeGiB' || metric === 'criticalSizeGiB') {
|
||||||
|
const rounded = Math.round(value * 10) / 10;
|
||||||
|
return `${rounded} GiB`;
|
||||||
|
}
|
||||||
|
|
||||||
// MB/s metrics
|
// MB/s metrics
|
||||||
if (
|
if (
|
||||||
metric === 'diskRead' ||
|
metric === 'diskRead' ||
|
||||||
|
|
@ -403,75 +433,80 @@ export function ThresholdsTable(props: ThresholdsTableProps) {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Process nodes with their overrides
|
// Process nodes with their overrides
|
||||||
const getFriendlyNodeName = (value: string, clusterName?: string): string => {
|
const getFriendlyNodeName = (value: string, clusterName?: string): string => {
|
||||||
if (!value) return value;
|
if (!value) return value;
|
||||||
|
|
||||||
const clusterLower = clusterName?.toLowerCase().trim();
|
const clusterLower = clusterName?.toLowerCase().trim();
|
||||||
|
|
||||||
const normalizeToken = (token?: string | null): string => {
|
const normalizeToken = (token?: string | null): string => {
|
||||||
if (!token) return '';
|
if (!token) return '';
|
||||||
let result = token.replace(/\(.*?\)/g, ' ').replace(/\s+/g, ' ').trim();
|
let result = token
|
||||||
if (clusterLower) {
|
.replace(/\(.*?\)/g, ' ')
|
||||||
result = result
|
.replace(/\s+/g, ' ')
|
||||||
.split(' ')
|
|
||||||
.filter((part) => part.toLowerCase() !== clusterLower)
|
|
||||||
.join(' ')
|
|
||||||
.trim();
|
.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 parenthetical = normalizeToken(parentheticalRaw);
|
||||||
const withoutDomain = firstWord.includes('.') ? firstWord.split('.')[0] ?? firstWord : firstWord;
|
if (parenthetical && parenthetical.toLowerCase() !== base.toLowerCase()) {
|
||||||
return withoutDomain.trim();
|
return parenthetical;
|
||||||
|
}
|
||||||
|
|
||||||
|
return base;
|
||||||
};
|
};
|
||||||
|
|
||||||
const parentheticalMatch = value.match(/\(([^)]+)\)/);
|
const buildNodeHeaderMeta = (node: Node) => {
|
||||||
const parentheticalRaw = parentheticalMatch?.[1]?.trim();
|
const originalDisplayName = node.displayName?.trim() || node.name;
|
||||||
|
const friendlyName = getFriendlyNodeName(originalDisplayName, node.clusterName);
|
||||||
let base = normalizeToken(value);
|
const hostValue = node.host?.trim();
|
||||||
if (!base) {
|
let host: string | undefined;
|
||||||
base = value.trim();
|
if (hostValue && hostValue !== '') {
|
||||||
}
|
host = hostValue.startsWith('http')
|
||||||
|
? hostValue
|
||||||
const parenthetical = normalizeToken(parentheticalRaw);
|
: `https://${hostValue.includes(':') ? hostValue : `${hostValue}:8006`}`;
|
||||||
if (parenthetical && parenthetical.toLowerCase() !== base.toLowerCase()) {
|
} else if (node.name) {
|
||||||
return parenthetical;
|
host = `https://${node.name.includes(':') ? node.name : `${node.name}:8006`}`;
|
||||||
}
|
|
||||||
|
|
||||||
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());
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
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 = []) => {
|
const nodesWithOverrides = createMemo<Resource[]>((prev = []) => {
|
||||||
// If we're currently editing, return the previous value to avoid re-renders
|
// 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 originalDisplayName = node.displayName?.trim() || node.name;
|
||||||
const friendlyName = getFriendlyNodeName(originalDisplayName, node.clusterName);
|
const friendlyName = getFriendlyNodeName(originalDisplayName, node.clusterName);
|
||||||
const rawName = node.name;
|
const rawName = node.name;
|
||||||
const sanitizedName = friendlyName || originalDisplayName || rawName.split('.')[0] || rawName;
|
const sanitizedName = friendlyName || originalDisplayName || rawName.split('.')[0] || rawName;
|
||||||
// Build a best-effort management URL for the node
|
// Build a best-effort management URL for the node
|
||||||
const hostValue = node.host?.trim() || rawName;
|
const hostValue = node.host?.trim() || rawName;
|
||||||
const normalizedHost = hostValue.startsWith('http://') || hostValue.startsWith('https://')
|
const normalizedHost =
|
||||||
? hostValue
|
hostValue.startsWith('http://') || hostValue.startsWith('https://')
|
||||||
: `https://${hostValue.includes(':') ? hostValue : `${hostValue}:8006`}`;
|
? hostValue
|
||||||
|
: `https://${hostValue.includes(':') ? hostValue : `${hostValue}:8006`}`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: node.id,
|
id: node.id,
|
||||||
|
|
@ -602,7 +637,7 @@ const buildNodeHeaderMeta = (node: Node) => {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Process Docker containers grouped by host
|
// Process Docker containers grouped by host
|
||||||
const dockerContainersGroupedByHost = createMemo<Record<string, Resource[]>>((prev = {}) => {
|
const dockerContainersGroupedByHost = createMemo<Record<string, Resource[]>>((prev = {}) => {
|
||||||
if (editingId()) {
|
if (editingId()) {
|
||||||
return prev;
|
return prev;
|
||||||
}
|
}
|
||||||
|
|
@ -646,9 +681,12 @@ const dockerContainersGroupedByHost = createMemo<Record<string, Resource[]>>((pr
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
const hasOverride =
|
const hasOverride =
|
||||||
hasCustomThresholds || override?.disabled || override?.disableConnectivity || overrideSeverity !== undefined || false;
|
hasCustomThresholds ||
|
||||||
|
override?.disabled ||
|
||||||
|
override?.disableConnectivity ||
|
||||||
|
overrideSeverity !== undefined ||
|
||||||
|
false;
|
||||||
|
|
||||||
const containerName = normalizeContainerName(container);
|
const containerName = normalizeContainerName(container);
|
||||||
const containerNameLower = containerName.toLowerCase();
|
const containerNameLower = containerName.toLowerCase();
|
||||||
|
|
@ -770,8 +808,9 @@ const dockerContainersGroupedByHost = createMemo<Record<string, Resource[]>>((pr
|
||||||
});
|
});
|
||||||
|
|
||||||
const countOverrides = (resources: Resource[] | undefined) =>
|
const countOverrides = (resources: Resource[] | undefined) =>
|
||||||
resources?.filter((resource) => resource.hasOverride || resource.disabled || resource.disableConnectivity)
|
resources?.filter(
|
||||||
.length ?? 0;
|
(resource) => resource.hasOverride || resource.disabled || resource.disableConnectivity,
|
||||||
|
).length ?? 0;
|
||||||
|
|
||||||
const registerSection = (_key: string) => (_el: HTMLDivElement | null) => {
|
const registerSection = (_key: string) => (_el: HTMLDivElement | null) => {
|
||||||
/* no-op placeholder for future scroll restoration */
|
/* no-op placeholder for future scroll restoration */
|
||||||
|
|
@ -782,6 +821,8 @@ const dockerContainersGroupedByHost = createMemo<Record<string, Resource[]>>((pr
|
||||||
enabled: false,
|
enabled: false,
|
||||||
warningDays: DEFAULT_SNAPSHOT_WARNING,
|
warningDays: DEFAULT_SNAPSHOT_WARNING,
|
||||||
criticalDays: DEFAULT_SNAPSHOT_CRITICAL,
|
criticalDays: DEFAULT_SNAPSHOT_CRITICAL,
|
||||||
|
warningSizeGiB: DEFAULT_SNAPSHOT_WARNING_SIZE,
|
||||||
|
criticalSizeGiB: DEFAULT_SNAPSHOT_CRITICAL_SIZE,
|
||||||
};
|
};
|
||||||
|
|
||||||
const sanitizeSnapshotConfig = (config: SnapshotAlertConfig): SnapshotAlertConfig => {
|
const sanitizeSnapshotConfig = (config: SnapshotAlertConfig): SnapshotAlertConfig => {
|
||||||
|
|
@ -795,17 +836,36 @@ const dockerContainersGroupedByHost = createMemo<Record<string, Resource[]>>((pr
|
||||||
critical = warning;
|
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 {
|
return {
|
||||||
enabled: !!config.enabled,
|
enabled: !!config.enabled,
|
||||||
warningDays: warning,
|
warningDays: warning,
|
||||||
criticalDays: critical,
|
criticalDays: critical,
|
||||||
|
warningSizeGiB: warningSize,
|
||||||
|
criticalSizeGiB: criticalSize,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateSnapshotDefaults = (
|
const updateSnapshotDefaults = (
|
||||||
updater:
|
updater: SnapshotAlertConfig | ((prev: SnapshotAlertConfig) => SnapshotAlertConfig),
|
||||||
| SnapshotAlertConfig
|
|
||||||
| ((prev: SnapshotAlertConfig) => SnapshotAlertConfig),
|
|
||||||
) => {
|
) => {
|
||||||
props.setSnapshotDefaults((prev) => {
|
props.setSnapshotDefaults((prev) => {
|
||||||
const next =
|
const next =
|
||||||
|
|
@ -822,6 +882,8 @@ const dockerContainersGroupedByHost = createMemo<Record<string, Resource[]>>((pr
|
||||||
return {
|
return {
|
||||||
'warning days': current.warningDays ?? 0,
|
'warning days': current.warningDays ?? 0,
|
||||||
'critical days': current.criticalDays ?? 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 {
|
return {
|
||||||
'warning days': factory.warningDays ?? DEFAULT_SNAPSHOT_WARNING,
|
'warning days': factory.warningDays ?? DEFAULT_SNAPSHOT_WARNING,
|
||||||
'critical days': factory.criticalDays ?? DEFAULT_SNAPSHOT_CRITICAL,
|
'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 = (
|
const updateBackupDefaults = (
|
||||||
updater:
|
updater: BackupAlertConfig | ((prev: BackupAlertConfig) => BackupAlertConfig),
|
||||||
| BackupAlertConfig
|
|
||||||
| ((prev: BackupAlertConfig) => BackupAlertConfig),
|
|
||||||
) => {
|
) => {
|
||||||
props.setBackupDefaults((prev) => {
|
props.setBackupDefaults((prev) => {
|
||||||
const next =
|
const next =
|
||||||
|
|
@ -889,18 +951,21 @@ const dockerContainersGroupedByHost = createMemo<Record<string, Resource[]>>((pr
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const snapshotOverridesCount = createMemo(() => {
|
const snapshotOverridesCount = createMemo(() => {
|
||||||
const current = props.snapshotDefaults();
|
const current = props.snapshotDefaults();
|
||||||
const factory = snapshotFactoryConfig();
|
const factory = snapshotFactoryConfig();
|
||||||
return current.enabled !== factory.enabled ||
|
const differs =
|
||||||
(current.warningDays ?? DEFAULT_SNAPSHOT_WARNING) !==
|
current.enabled !== factory.enabled ||
|
||||||
(factory.warningDays ?? DEFAULT_SNAPSHOT_WARNING) ||
|
(current.warningDays ?? DEFAULT_SNAPSHOT_WARNING) !==
|
||||||
(current.criticalDays ?? DEFAULT_SNAPSHOT_CRITICAL) !==
|
(factory.warningDays ?? DEFAULT_SNAPSHOT_WARNING) ||
|
||||||
(factory.criticalDays ?? DEFAULT_SNAPSHOT_CRITICAL)
|
(current.criticalDays ?? DEFAULT_SNAPSHOT_CRITICAL) !==
|
||||||
? 1
|
(factory.criticalDays ?? DEFAULT_SNAPSHOT_CRITICAL) ||
|
||||||
: 0;
|
(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 backupOverridesCount = createMemo(() => {
|
||||||
const backupCurrent = props.backupDefaults();
|
const backupCurrent = props.backupDefaults();
|
||||||
|
|
@ -914,7 +979,6 @@ const snapshotOverridesCount = createMemo(() => {
|
||||||
: 0;
|
: 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
// Process guests with their overrides and group by node
|
// Process guests with their overrides and group by node
|
||||||
const guestsGroupedByNode = createMemo<Record<string, Resource[]>>((prev = {}) => {
|
const guestsGroupedByNode = createMemo<Record<string, Resource[]>>((prev = {}) => {
|
||||||
// If we're currently editing, return the previous value to avoid re-renders
|
// 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
|
// A guest has an override if it has custom thresholds OR is disabled OR has connectivity disabled
|
||||||
const hasOverride =
|
const hasOverride =
|
||||||
hasCustomThresholds || override?.disabled || override?.disableConnectivity || overrideSeverity !== undefined || false;
|
hasCustomThresholds ||
|
||||||
|
override?.disabled ||
|
||||||
|
override?.disableConnectivity ||
|
||||||
|
overrideSeverity !== undefined ||
|
||||||
|
false;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: guestId,
|
id: guestId,
|
||||||
|
|
@ -1079,11 +1146,10 @@ const snapshotOverridesCount = createMemo(() => {
|
||||||
const record: Record<string, number> = {};
|
const record: Record<string, number> = {};
|
||||||
PMG_THRESHOLD_COLUMNS.forEach(({ key, normalized }) => {
|
PMG_THRESHOLD_COLUMNS.forEach(({ key, normalized }) => {
|
||||||
const value = defaults[key];
|
const value = defaults[key];
|
||||||
record[normalized] =
|
record[normalized] = typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
||||||
typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
|
||||||
});
|
});
|
||||||
return record;
|
return record;
|
||||||
});
|
});
|
||||||
|
|
||||||
const setPMGGlobalDefaults = (
|
const setPMGGlobalDefaults = (
|
||||||
value:
|
value:
|
||||||
|
|
@ -1273,11 +1339,11 @@ const snapshotOverridesCount = createMemo(() => {
|
||||||
overrides: snapshotOverridesCount(),
|
overrides: snapshotOverridesCount(),
|
||||||
tab: 'proxmox' as const,
|
tab: 'proxmox' as const,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'pbs' as const,
|
key: 'pbs' as const,
|
||||||
label: 'PBS Servers',
|
label: 'PBS Servers',
|
||||||
total: props.pbsInstances?.length ?? 0,
|
total: props.pbsInstances?.length ?? 0,
|
||||||
overrides: countOverrides(pbsServersWithOverrides()),
|
overrides: countOverrides(pbsServersWithOverrides()),
|
||||||
tab: 'proxmox' as const,
|
tab: 'proxmox' as const,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -1344,9 +1410,13 @@ const snapshotOverridesCount = createMemo(() => {
|
||||||
if (resource.editScope === 'backup') {
|
if (resource.editScope === 'backup') {
|
||||||
const currentBackupDefaults = props.backupDefaults();
|
const currentBackupDefaults = props.backupDefaults();
|
||||||
const nextWarning =
|
const nextWarning =
|
||||||
editedThresholds['warning days'] ?? currentBackupDefaults.warningDays ?? DEFAULT_BACKUP_WARNING;
|
editedThresholds['warning days'] ??
|
||||||
|
currentBackupDefaults.warningDays ??
|
||||||
|
DEFAULT_BACKUP_WARNING;
|
||||||
const nextCritical =
|
const nextCritical =
|
||||||
editedThresholds['critical days'] ?? currentBackupDefaults.criticalDays ?? DEFAULT_BACKUP_CRITICAL;
|
editedThresholds['critical days'] ??
|
||||||
|
currentBackupDefaults.criticalDays ??
|
||||||
|
DEFAULT_BACKUP_CRITICAL;
|
||||||
|
|
||||||
updateBackupDefaults({
|
updateBackupDefaults({
|
||||||
enabled: currentBackupDefaults.enabled,
|
enabled: currentBackupDefaults.enabled,
|
||||||
|
|
@ -1361,14 +1431,28 @@ const snapshotOverridesCount = createMemo(() => {
|
||||||
if (resource.editScope === 'snapshot') {
|
if (resource.editScope === 'snapshot') {
|
||||||
const currentSnapshotDefaults = props.snapshotDefaults();
|
const currentSnapshotDefaults = props.snapshotDefaults();
|
||||||
const nextWarning =
|
const nextWarning =
|
||||||
editedThresholds['warning days'] ?? currentSnapshotDefaults.warningDays ?? DEFAULT_SNAPSHOT_WARNING;
|
editedThresholds['warning days'] ??
|
||||||
|
currentSnapshotDefaults.warningDays ??
|
||||||
|
DEFAULT_SNAPSHOT_WARNING;
|
||||||
const nextCritical =
|
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({
|
updateSnapshotDefaults({
|
||||||
enabled: currentSnapshotDefaults.enabled,
|
enabled: currentSnapshotDefaults.enabled,
|
||||||
warningDays: nextWarning,
|
warningDays: nextWarning,
|
||||||
criticalDays: nextCritical,
|
criticalDays: nextCritical,
|
||||||
|
warningSizeGiB: nextWarningSize,
|
||||||
|
criticalSizeGiB: nextCriticalSize,
|
||||||
});
|
});
|
||||||
|
|
||||||
cancelEdit();
|
cancelEdit();
|
||||||
|
|
@ -1467,7 +1551,10 @@ const snapshotOverridesCount = createMemo(() => {
|
||||||
hysteresisThresholds.disableConnectivity = true;
|
hysteresisThresholds.disableConnectivity = true;
|
||||||
delete hysteresisThresholds.poweredOffSeverity;
|
delete hysteresisThresholds.poweredOffSeverity;
|
||||||
} else {
|
} else {
|
||||||
if ((resource.type === 'guest' || resource.type === 'dockerContainer') && props.guestDisableConnectivity()) {
|
if (
|
||||||
|
(resource.type === 'guest' || resource.type === 'dockerContainer') &&
|
||||||
|
props.guestDisableConnectivity()
|
||||||
|
) {
|
||||||
hysteresisThresholds.disableConnectivity = false;
|
hysteresisThresholds.disableConnectivity = false;
|
||||||
} else {
|
} else {
|
||||||
delete hysteresisThresholds.disableConnectivity;
|
delete hysteresisThresholds.disableConnectivity;
|
||||||
|
|
@ -1491,7 +1578,11 @@ const snapshotOverridesCount = createMemo(() => {
|
||||||
setEditingThresholds({});
|
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();
|
const normalizedMetric = metricKey.trim().toLowerCase();
|
||||||
if (!normalizedMetric) return;
|
if (!normalizedMetric) return;
|
||||||
|
|
||||||
|
|
@ -1576,10 +1667,7 @@ const snapshotOverridesCount = createMemo(() => {
|
||||||
delete (cleanThresholds as Record<string, unknown>).disabled;
|
delete (cleanThresholds as Record<string, unknown>).disabled;
|
||||||
|
|
||||||
// If enabling (disabled = false) and no custom thresholds exist, remove the override entirely
|
// If enabling (disabled = false) and no custom thresholds exist, remove the override entirely
|
||||||
if (
|
if (!newDisabledState && (!existingOverride || Object.keys(cleanThresholds).length === 0)) {
|
||||||
!newDisabledState &&
|
|
||||||
(!existingOverride || Object.keys(cleanThresholds).length === 0)
|
|
||||||
) {
|
|
||||||
// Remove the override completely
|
// Remove the override completely
|
||||||
props.setOverrides(props.overrides().filter((o) => o.id !== resourceId));
|
props.setOverrides(props.overrides().filter((o) => o.id !== resourceId));
|
||||||
|
|
||||||
|
|
@ -1646,8 +1734,7 @@ const snapshotOverridesCount = createMemo(() => {
|
||||||
const offlineId = `pbs-offline-${resourceId}`;
|
const offlineId = `pbs-offline-${resourceId}`;
|
||||||
props.removeAlerts(
|
props.removeAlerts(
|
||||||
(alert) =>
|
(alert) =>
|
||||||
alert.resourceId === resourceId &&
|
alert.resourceId === resourceId && (alert.id === offlineId || alert.type === 'offline'),
|
||||||
(alert.id === offlineId || alert.type === 'offline'),
|
|
||||||
);
|
);
|
||||||
} else if (resource.type === 'dockerContainer') {
|
} else if (resource.type === 'dockerContainer') {
|
||||||
props.removeAlerts(
|
props.removeAlerts(
|
||||||
|
|
@ -1755,9 +1842,7 @@ const snapshotOverridesCount = createMemo(() => {
|
||||||
if (props.removeAlerts && resource.type === 'dockerHost') {
|
if (props.removeAlerts && resource.type === 'dockerHost') {
|
||||||
const offlineId = `docker-host-offline-${resourceId}`;
|
const offlineId = `docker-host-offline-${resourceId}`;
|
||||||
const resourceKey = `docker:${resourceId}`;
|
const resourceKey = `docker:${resourceId}`;
|
||||||
props.removeAlerts(
|
props.removeAlerts((alert) => alert.id === offlineId || alert.resourceId === resourceKey);
|
||||||
(alert) => alert.id === offlineId || alert.resourceId === resourceKey,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -1865,7 +1950,9 @@ const snapshotOverridesCount = createMemo(() => {
|
||||||
|
|
||||||
if (props.removeAlerts && newDisableConnectivity) {
|
if (props.removeAlerts && newDisableConnectivity) {
|
||||||
if (resource.type === 'guest') {
|
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') {
|
} else if (resource.type === 'dockerContainer') {
|
||||||
props.removeAlerts(
|
props.removeAlerts(
|
||||||
(alert) =>
|
(alert) =>
|
||||||
|
|
@ -1922,11 +2009,30 @@ const snapshotOverridesCount = createMemo(() => {
|
||||||
{/* Help Banner */}
|
{/* 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="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">
|
<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">
|
<svg
|
||||||
<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" />
|
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>
|
</svg>
|
||||||
<div class="text-sm text-blue-900 dark:text-blue-100">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1998,7 +2104,9 @@ const snapshotOverridesCount = createMemo(() => {
|
||||||
globalDisableFlag={props.disableAllNodes}
|
globalDisableFlag={props.disableAllNodes}
|
||||||
onToggleGlobalDisable={() => props.setDisableAllNodes(!props.disableAllNodes())}
|
onToggleGlobalDisable={() => props.setDisableAllNodes(!props.disableAllNodes())}
|
||||||
globalDisableOfflineFlag={props.disableAllNodesOffline}
|
globalDisableOfflineFlag={props.disableAllNodesOffline}
|
||||||
onToggleGlobalDisableOffline={() => props.setDisableAllNodesOffline(!props.disableAllNodesOffline())}
|
onToggleGlobalDisableOffline={() =>
|
||||||
|
props.setDisableAllNodesOffline(!props.disableAllNodesOffline())
|
||||||
|
}
|
||||||
showDelayColumn={true}
|
showDelayColumn={true}
|
||||||
globalDelaySeconds={props.timeThresholds().node}
|
globalDelaySeconds={props.timeThresholds().node}
|
||||||
metricDelaySeconds={props.metricTimeThresholds().node ?? {}}
|
metricDelaySeconds={props.metricTimeThresholds().node ?? {}}
|
||||||
|
|
@ -2032,22 +2140,42 @@ const snapshotOverridesCount = createMemo(() => {
|
||||||
globalDefaults={{ cpu: props.nodeDefaults.cpu, memory: props.nodeDefaults.memory }}
|
globalDefaults={{ cpu: props.nodeDefaults.cpu, memory: props.nodeDefaults.memory }}
|
||||||
setGlobalDefaults={(value) => {
|
setGlobalDefaults={(value) => {
|
||||||
if (typeof value === 'function') {
|
if (typeof value === 'function') {
|
||||||
const newValue = value({ cpu: props.nodeDefaults.cpu, memory: props.nodeDefaults.memory });
|
const newValue = value({
|
||||||
props.setNodeDefaults((prev) => ({ ...prev, cpu: newValue.cpu ?? prev.cpu, memory: newValue.memory ?? prev.memory }));
|
cpu: props.nodeDefaults.cpu,
|
||||||
|
memory: props.nodeDefaults.memory,
|
||||||
|
});
|
||||||
|
props.setNodeDefaults((prev) => ({
|
||||||
|
...prev,
|
||||||
|
cpu: newValue.cpu ?? prev.cpu,
|
||||||
|
memory: newValue.memory ?? prev.memory,
|
||||||
|
}));
|
||||||
} else {
|
} 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}
|
setHasUnsavedChanges={props.setHasUnsavedChanges}
|
||||||
globalDisableFlag={props.disableAllPBS}
|
globalDisableFlag={props.disableAllPBS}
|
||||||
onToggleGlobalDisable={() => props.setDisableAllPBS(!props.disableAllPBS())}
|
onToggleGlobalDisable={() => props.setDisableAllPBS(!props.disableAllPBS())}
|
||||||
globalDisableOfflineFlag={props.disableAllPBSOffline}
|
globalDisableOfflineFlag={props.disableAllPBSOffline}
|
||||||
onToggleGlobalDisableOffline={() => props.setDisableAllPBSOffline(!props.disableAllPBSOffline())}
|
onToggleGlobalDisableOffline={() =>
|
||||||
|
props.setDisableAllPBSOffline(!props.disableAllPBSOffline())
|
||||||
|
}
|
||||||
showDelayColumn={true}
|
showDelayColumn={true}
|
||||||
globalDelaySeconds={props.timeThresholds().pbs}
|
globalDelaySeconds={props.timeThresholds().pbs}
|
||||||
metricDelaySeconds={props.metricTimeThresholds().pbs ?? {}}
|
metricDelaySeconds={props.metricTimeThresholds().pbs ?? {}}
|
||||||
onMetricDelayChange={(metric, value) => updateMetricDelay('pbs', metric, value)}
|
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}
|
onResetDefaults={props.resetNodeDefaults}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -2059,7 +2187,15 @@ const snapshotOverridesCount = createMemo(() => {
|
||||||
title="VMs & Containers"
|
title="VMs & Containers"
|
||||||
groupedResources={guestsGroupedByNode()}
|
groupedResources={guestsGroupedByNode()}
|
||||||
groupHeaderMeta={guestGroupHeaderMeta()}
|
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}
|
activeAlerts={props.activeAlerts}
|
||||||
emptyMessage="No VMs or containers match the current filters."
|
emptyMessage="No VMs or containers match the current filters."
|
||||||
onEdit={startEditing}
|
onEdit={startEditing}
|
||||||
|
|
@ -2108,8 +2244,22 @@ const snapshotOverridesCount = createMemo(() => {
|
||||||
<div ref={registerSection('backups')} class="scroll-mt-24">
|
<div ref={registerSection('backups')} class="scroll-mt-24">
|
||||||
<ResourceTable
|
<ResourceTable
|
||||||
title="Backups"
|
title="Backups"
|
||||||
resources={[{ id: "backups-defaults", name: "Global Defaults", thresholds: backupDefaultsRecord(), defaults: backupDefaultsRecord(), editable: true, editScope: "backup" }]}
|
resources={[
|
||||||
columns={['Warning Days', 'Critical Days']}
|
{
|
||||||
|
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}
|
activeAlerts={props.activeAlerts}
|
||||||
emptyMessage=""
|
emptyMessage=""
|
||||||
onEdit={startEditing}
|
onEdit={startEditing}
|
||||||
|
|
@ -2171,7 +2321,16 @@ const snapshotOverridesCount = createMemo(() => {
|
||||||
<div ref={registerSection('snapshots')} class="scroll-mt-24">
|
<div ref={registerSection('snapshots')} class="scroll-mt-24">
|
||||||
<ResourceTable
|
<ResourceTable
|
||||||
title="Snapshot Age"
|
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']}
|
columns={['Warning Days', 'Critical Days']}
|
||||||
activeAlerts={props.activeAlerts}
|
activeAlerts={props.activeAlerts}
|
||||||
emptyMessage=""
|
emptyMessage=""
|
||||||
|
|
@ -2191,6 +2350,8 @@ const snapshotOverridesCount = createMemo(() => {
|
||||||
const currentRecord = {
|
const currentRecord = {
|
||||||
'warning days': prev.warningDays ?? 0,
|
'warning days': prev.warningDays ?? 0,
|
||||||
'critical days': prev.criticalDays ?? 0,
|
'critical days': prev.criticalDays ?? 0,
|
||||||
|
'warning size (gib)': prev.warningSizeGiB ?? 0,
|
||||||
|
'critical size (gib)': prev.criticalSizeGiB ?? 0,
|
||||||
};
|
};
|
||||||
const nextRecord =
|
const nextRecord =
|
||||||
typeof value === 'function'
|
typeof value === 'function'
|
||||||
|
|
@ -2206,6 +2367,14 @@ const snapshotOverridesCount = createMemo(() => {
|
||||||
typeof nextRecord['critical days'] === 'number'
|
typeof nextRecord['critical days'] === 'number'
|
||||||
? nextRecord['critical days']
|
? nextRecord['critical days']
|
||||||
: prev.criticalDays,
|
: 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}
|
globalDelaySeconds={props.timeThresholds().storage}
|
||||||
metricDelaySeconds={props.metricTimeThresholds().storage ?? {}}
|
metricDelaySeconds={props.metricTimeThresholds().storage ?? {}}
|
||||||
onMetricDelayChange={(metric, value) => updateMetricDelay('storage', metric, value)}
|
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}
|
onResetDefaults={props.resetStorageDefault}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={activeTab() === 'pmg'}>
|
<Show when={activeTab() === 'pmg'}>
|
||||||
|
|
@ -2279,7 +2451,8 @@ const snapshotOverridesCount = createMemo(() => {
|
||||||
when={pmgServersWithOverrides().length > 0}
|
when={pmgServersWithOverrides().length > 0}
|
||||||
fallback={
|
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">
|
<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>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
@ -2325,7 +2498,9 @@ const snapshotOverridesCount = createMemo(() => {
|
||||||
globalDisableFlag={props.disableAllPMG}
|
globalDisableFlag={props.disableAllPMG}
|
||||||
onToggleGlobalDisable={() => props.setDisableAllPMG(!props.disableAllPMG())}
|
onToggleGlobalDisable={() => props.setDisableAllPMG(!props.disableAllPMG())}
|
||||||
globalDisableOfflineFlag={props.disableAllPMGOffline}
|
globalDisableOfflineFlag={props.disableAllPMGOffline}
|
||||||
onToggleGlobalDisableOffline={() => props.setDisableAllPMGOffline(!props.disableAllPMGOffline())}
|
onToggleGlobalDisableOffline={() =>
|
||||||
|
props.setDisableAllPMGOffline(!props.disableAllPMGOffline())
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
@ -2383,9 +2558,13 @@ const snapshotOverridesCount = createMemo(() => {
|
||||||
formatMetricValue={formatMetricValue}
|
formatMetricValue={formatMetricValue}
|
||||||
hasActiveAlert={hasActiveAlert}
|
hasActiveAlert={hasActiveAlert}
|
||||||
globalDisableFlag={props.disableAllDockerHosts}
|
globalDisableFlag={props.disableAllDockerHosts}
|
||||||
onToggleGlobalDisable={() => props.setDisableAllDockerHosts(!props.disableAllDockerHosts())}
|
onToggleGlobalDisable={() =>
|
||||||
|
props.setDisableAllDockerHosts(!props.disableAllDockerHosts())
|
||||||
|
}
|
||||||
globalDisableOfflineFlag={props.disableAllDockerHostsOffline}
|
globalDisableOfflineFlag={props.disableAllDockerHostsOffline}
|
||||||
onToggleGlobalDisableOffline={() => props.setDisableAllDockerHostsOffline(!props.disableAllDockerHostsOffline())}
|
onToggleGlobalDisableOffline={() =>
|
||||||
|
props.setDisableAllDockerHostsOffline(!props.disableAllDockerHostsOffline())
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
@ -2435,9 +2614,7 @@ const snapshotOverridesCount = createMemo(() => {
|
||||||
memoryCriticalPct: props.dockerDefaults.memoryCriticalPct,
|
memoryCriticalPct: props.dockerDefaults.memoryCriticalPct,
|
||||||
};
|
};
|
||||||
const next =
|
const next =
|
||||||
typeof value === 'function'
|
typeof value === 'function' ? value(current) : { ...current, ...value };
|
||||||
? value(current)
|
|
||||||
: { ...current, ...value };
|
|
||||||
|
|
||||||
props.setDockerDefaults((prev) => ({
|
props.setDockerDefaults((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
|
|
@ -2451,7 +2628,9 @@ const snapshotOverridesCount = createMemo(() => {
|
||||||
}}
|
}}
|
||||||
setHasUnsavedChanges={props.setHasUnsavedChanges}
|
setHasUnsavedChanges={props.setHasUnsavedChanges}
|
||||||
globalDisableFlag={props.disableAllDockerContainers}
|
globalDisableFlag={props.disableAllDockerContainers}
|
||||||
onToggleGlobalDisable={() => props.setDisableAllDockerContainers(!props.disableAllDockerContainers())}
|
onToggleGlobalDisable={() =>
|
||||||
|
props.setDisableAllDockerContainers(!props.disableAllDockerContainers())
|
||||||
|
}
|
||||||
globalDisableOfflineFlag={() => props.guestDisableConnectivity()}
|
globalDisableOfflineFlag={() => props.guestDisableConnectivity()}
|
||||||
onToggleGlobalDisableOffline={() =>
|
onToggleGlobalDisableOffline={() =>
|
||||||
props.setGuestDisableConnectivity(!props.guestDisableConnectivity())
|
props.setGuestDisableConnectivity(!props.guestDisableConnectivity())
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { render, fireEvent, screen, cleanup } from '@solidjs/testing-library';
|
import { render, fireEvent, screen, cleanup } from '@solidjs/testing-library';
|
||||||
import { createSignal } from 'solid-js';
|
import { createSignal } from 'solid-js';
|
||||||
|
|
||||||
import {
|
import { ThresholdsTable, normalizeDockerIgnoredInput } from '../ThresholdsTable';
|
||||||
ThresholdsTable,
|
|
||||||
normalizeDockerIgnoredInput,
|
|
||||||
} from '../ThresholdsTable';
|
|
||||||
import type { PMGThresholdDefaults, SnapshotAlertConfig, BackupAlertConfig } from '@/types/alerts';
|
import type { PMGThresholdDefaults, SnapshotAlertConfig, BackupAlertConfig } from '@/types/alerts';
|
||||||
|
|
||||||
vi.mock('@solidjs/router', () => ({
|
vi.mock('@solidjs/router', () => ({
|
||||||
|
|
@ -91,9 +88,21 @@ const baseProps = () => ({
|
||||||
setBackupDefaults: vi.fn(),
|
setBackupDefaults: vi.fn(),
|
||||||
backupFactoryDefaults: { enabled: false, warningDays: 7, criticalDays: 14 } as BackupAlertConfig,
|
backupFactoryDefaults: { enabled: false, warningDays: 7, criticalDays: 14 } as BackupAlertConfig,
|
||||||
resetBackupDefaults: vi.fn(),
|
resetBackupDefaults: vi.fn(),
|
||||||
snapshotDefaults: () => ({ enabled: false, warningDays: 30, criticalDays: 45 }),
|
snapshotDefaults: () => ({
|
||||||
|
enabled: false,
|
||||||
|
warningDays: 30,
|
||||||
|
criticalDays: 45,
|
||||||
|
warningSizeGiB: 0,
|
||||||
|
criticalSizeGiB: 0,
|
||||||
|
}),
|
||||||
setSnapshotDefaults: vi.fn(),
|
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(),
|
resetSnapshotDefaults: vi.fn(),
|
||||||
timeThresholds: () => ({ guest: 5, node: 5, storage: 5, pbs: 5 }),
|
timeThresholds: () => ({ guest: 5, node: 5, storage: 5, pbs: 5 }),
|
||||||
metricTimeThresholds: () => ({}),
|
metricTimeThresholds: () => ({}),
|
||||||
|
|
@ -126,7 +135,10 @@ const baseProps = () => ({
|
||||||
setDisableAllDockerHostsOffline: vi.fn(),
|
setDisableAllDockerHostsOffline: vi.fn(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const renderThresholdsTable = (options?: { initialPrefixes?: string[]; includeReset?: boolean }) => {
|
const renderThresholdsTable = (options?: {
|
||||||
|
initialPrefixes?: string[];
|
||||||
|
includeReset?: boolean;
|
||||||
|
}) => {
|
||||||
let setDockerIgnoredPrefixesMock!: ReturnType<typeof vi.fn>;
|
let setDockerIgnoredPrefixesMock!: ReturnType<typeof vi.fn>;
|
||||||
let resetDockerIgnoredPrefixesMock: ReturnType<typeof vi.fn> | undefined;
|
let resetDockerIgnoredPrefixesMock: ReturnType<typeof vi.fn> | undefined;
|
||||||
let setHasUnsavedChangesMock!: ReturnType<typeof vi.fn>;
|
let setHasUnsavedChangesMock!: ReturnType<typeof vi.fn>;
|
||||||
|
|
@ -177,9 +189,11 @@ const renderThresholdsTable = (options?: { initialPrefixes?: string[]; includeRe
|
||||||
|
|
||||||
describe('normalizeDockerIgnoredInput', () => {
|
describe('normalizeDockerIgnoredInput', () => {
|
||||||
it('trims whitespace and removes empty lines', () => {
|
it('trims whitespace and removes empty lines', () => {
|
||||||
expect(
|
expect(normalizeDockerIgnoredInput(' runner- \n\n #system \n\t \njob-')).toEqual([
|
||||||
normalizeDockerIgnoredInput(' runner- \n\n #system \n\t \njob-'),
|
'runner-',
|
||||||
).toEqual(['runner-', '#system', 'job-']);
|
'#system',
|
||||||
|
'job-',
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns empty array for blank input', () => {
|
it('returns empty array for blank input', () => {
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,16 @@ import { usePersistentSignal } from '@/hooks/usePersistentSignal';
|
||||||
|
|
||||||
type BackupSortKey = keyof Pick<
|
type BackupSortKey = keyof Pick<
|
||||||
UnifiedBackup,
|
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[] = [
|
const BACKUP_SORT_KEY_VALUES: readonly BackupSortKey[] = [
|
||||||
'backupTime',
|
'backupTime',
|
||||||
|
|
@ -70,16 +79,10 @@ const UnifiedBackups: Component = () => {
|
||||||
else if (value === 'pve') setBackupTypeFilter('local');
|
else if (value === 'pve') setBackupTypeFilter('local');
|
||||||
else if (value === 'pbs') setBackupTypeFilter('remote');
|
else if (value === 'pbs') setBackupTypeFilter('remote');
|
||||||
};
|
};
|
||||||
const [sortKey, setSortKey] = usePersistentSignal<BackupSortKey>(
|
const [sortKey, setSortKey] = usePersistentSignal<BackupSortKey>('backupsSortKey', 'backupTime', {
|
||||||
'backupsSortKey',
|
deserialize: (raw) =>
|
||||||
'backupTime',
|
BACKUP_SORT_KEY_VALUES.includes(raw as BackupSortKey) ? (raw as BackupSortKey) : 'backupTime',
|
||||||
{
|
});
|
||||||
deserialize: (raw) =>
|
|
||||||
BACKUP_SORT_KEY_VALUES.includes(raw as BackupSortKey)
|
|
||||||
? (raw as BackupSortKey)
|
|
||||||
: 'backupTime',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const [sortDirection, setSortDirection] = usePersistentSignal<'asc' | 'desc'>(
|
const [sortDirection, setSortDirection] = usePersistentSignal<'asc' | 'desc'>(
|
||||||
'backupsSortDirection',
|
'backupsSortDirection',
|
||||||
'desc',
|
'desc',
|
||||||
|
|
@ -188,7 +191,9 @@ const UnifiedBackups: Component = () => {
|
||||||
pveBackupsState()?.guestSnapshots?.forEach((snapshot) => {
|
pveBackupsState()?.guestSnapshots?.forEach((snapshot) => {
|
||||||
// Try to find the guest name by matching VMID and instance (not hostname)
|
// Try to find the guest name by matching VMID and instance (not hostname)
|
||||||
let guestName = '';
|
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(
|
const ct = state.containers?.find(
|
||||||
(c) => c.vmid === snapshot.vmid && c.instance === snapshot.instance,
|
(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"
|
backupName: snapshot.name, // This is the snapshot name like "current", "pre-upgrade"
|
||||||
description: snapshot.description || '',
|
description: snapshot.description || '',
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
size: null,
|
size: typeof snapshot.sizeBytes === 'number' ? snapshot.sizeBytes : null,
|
||||||
storage: null,
|
storage: null,
|
||||||
datastore: null,
|
datastore: null,
|
||||||
namespace: null,
|
namespace: null,
|
||||||
|
|
@ -291,7 +296,7 @@ const UnifiedBackups: Component = () => {
|
||||||
|
|
||||||
unified.push({
|
unified.push({
|
||||||
backupType: 'remote',
|
backupType: 'remote',
|
||||||
vmid: displayType === 'Host' ? backup.vmid : (parseInt(backup.vmid) || 0),
|
vmid: displayType === 'Host' ? backup.vmid : parseInt(backup.vmid) || 0,
|
||||||
name: backup.comment || '',
|
name: backup.comment || '',
|
||||||
type: displayType,
|
type: displayType,
|
||||||
node: backup.instance || 'PBS',
|
node: backup.instance || 'PBS',
|
||||||
|
|
@ -402,7 +407,7 @@ const UnifiedBackups: Component = () => {
|
||||||
// For regular backups: show Proxmox node in Node column, local storage in Location
|
// For regular backups: show Proxmox node in Node column, local storage in Location
|
||||||
unified.push({
|
unified.push({
|
||||||
backupType: backupType,
|
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() || '',
|
name: backup.notes || backup.volid?.split('/').pop() || '',
|
||||||
type: displayType,
|
type: displayType,
|
||||||
node: backup.node || '', // Proxmox node that has access to this backup
|
node: backup.node || '', // Proxmox node that has access to this backup
|
||||||
|
|
@ -532,9 +537,7 @@ const UnifiedBackups: Component = () => {
|
||||||
if (nodeFilter) {
|
if (nodeFilter) {
|
||||||
const node = state.nodes?.find((n) => n.id === nodeFilter);
|
const node = state.nodes?.find((n) => n.id === nodeFilter);
|
||||||
if (node) {
|
if (node) {
|
||||||
data = data.filter(
|
data = data.filter((item) => item.instance === node.instance && item.node === node.name);
|
||||||
(item) => item.instance === node.instance && item.node === node.name,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1364,17 +1367,27 @@ const UnifiedBackups: Component = () => {
|
||||||
aria-label={availableBackupsTooltipText}
|
aria-label={availableBackupsTooltipText}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
const rect = e.currentTarget.getBoundingClientRect();
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
showTooltip(availableBackupsTooltipText, rect.left + rect.width / 2, rect.top, {
|
showTooltip(
|
||||||
align: 'center',
|
availableBackupsTooltipText,
|
||||||
direction: 'up',
|
rect.left + rect.width / 2,
|
||||||
});
|
rect.top,
|
||||||
|
{
|
||||||
|
align: 'center',
|
||||||
|
direction: 'up',
|
||||||
|
},
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
onFocus={(e) => {
|
onFocus={(e) => {
|
||||||
const rect = e.currentTarget.getBoundingClientRect();
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
showTooltip(availableBackupsTooltipText, rect.left + rect.width / 2, rect.top, {
|
showTooltip(
|
||||||
align: 'center',
|
availableBackupsTooltipText,
|
||||||
direction: 'up',
|
rect.left + rect.width / 2,
|
||||||
});
|
rect.top,
|
||||||
|
{
|
||||||
|
align: 'center',
|
||||||
|
direction: 'up',
|
||||||
|
},
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
onMouseLeave={() => hideTooltip()}
|
onMouseLeave={() => hideTooltip()}
|
||||||
onBlur={() => hideTooltip()}
|
onBlur={() => hideTooltip()}
|
||||||
|
|
@ -1780,12 +1793,9 @@ const UnifiedBackups: Component = () => {
|
||||||
}`;
|
}`;
|
||||||
|
|
||||||
const breakdown: string[] = [];
|
const breakdown: string[] = [];
|
||||||
if (d.snapshots > 0)
|
if (d.snapshots > 0) breakdown.push(`Snapshots: ${d.snapshots}`);
|
||||||
breakdown.push(`Snapshots: ${d.snapshots}`);
|
if (d.pve > 0) breakdown.push(`PVE: ${d.pve}`);
|
||||||
if (d.pve > 0)
|
if (d.pbs > 0) breakdown.push(`PBS: ${d.pbs}`);
|
||||||
breakdown.push(`PVE: ${d.pve}`);
|
|
||||||
if (d.pbs > 0)
|
|
||||||
breakdown.push(`PBS: ${d.pbs}`);
|
|
||||||
|
|
||||||
if (breakdown.length > 0) {
|
if (breakdown.length > 0) {
|
||||||
tooltipText += `\n${breakdown.join(' • ')}`;
|
tooltipText += `\n${breakdown.join(' • ')}`;
|
||||||
|
|
@ -2121,7 +2131,8 @@ const UnifiedBackups: Component = () => {
|
||||||
onClick={() => handleSort('vmid')}
|
onClick={() => handleSort('vmid')}
|
||||||
style="width: 60px;"
|
style="width: 60px;"
|
||||||
>
|
>
|
||||||
{hasHostBackups() ? 'VMID/Host' : 'VMID'} {sortKey() === 'vmid' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
{hasHostBackups() ? 'VMID/Host' : 'VMID'}{' '}
|
||||||
|
{sortKey() === 'vmid' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||||
</th>
|
</th>
|
||||||
<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"
|
class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
|
||||||
|
|
|
||||||
|
|
@ -157,9 +157,15 @@ interface UIEmailConfig {
|
||||||
|
|
||||||
interface UIAppriseConfig {
|
interface UIAppriseConfig {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
mode: 'cli' | 'http';
|
||||||
targetsText: string;
|
targetsText: string;
|
||||||
cliPath: string;
|
cliPath: string;
|
||||||
timeoutSeconds: number;
|
timeoutSeconds: number;
|
||||||
|
serverUrl: string;
|
||||||
|
configKey: string;
|
||||||
|
apiKey: string;
|
||||||
|
apiKeyHeader: string;
|
||||||
|
skipTlsVerify: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface QuietHoursConfig {
|
interface QuietHoursConfig {
|
||||||
|
|
@ -239,9 +245,15 @@ export const createDefaultGrouping = (): GroupingConfig => ({
|
||||||
|
|
||||||
const createDefaultAppriseConfig = (): UIAppriseConfig => ({
|
const createDefaultAppriseConfig = (): UIAppriseConfig => ({
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
mode: 'cli',
|
||||||
targetsText: '',
|
targetsText: '',
|
||||||
cliPath: 'apprise',
|
cliPath: 'apprise',
|
||||||
timeoutSeconds: 15,
|
timeoutSeconds: 15,
|
||||||
|
serverUrl: '',
|
||||||
|
configKey: '',
|
||||||
|
apiKey: '',
|
||||||
|
apiKeyHeader: 'X-API-KEY',
|
||||||
|
skipTlsVerify: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const parseAppriseTargets = (value: string): string[] =>
|
const parseAppriseTargets = (value: string): string[] =>
|
||||||
|
|
@ -486,9 +498,15 @@ const [appriseConfig, setAppriseConfig] = createSignal<UIAppriseConfig>(
|
||||||
const config = appriseConfig();
|
const config = appriseConfig();
|
||||||
return {
|
return {
|
||||||
enabled: config.enabled,
|
enabled: config.enabled,
|
||||||
|
mode: config.mode,
|
||||||
targets: parseAppriseTargets(config.targetsText),
|
targets: parseAppriseTargets(config.targetsText),
|
||||||
cliPath: config.cliPath,
|
cliPath: config.cliPath,
|
||||||
timeoutSeconds: config.timeoutSeconds,
|
timeoutSeconds: config.timeoutSeconds,
|
||||||
|
serverUrl: config.serverUrl,
|
||||||
|
configKey: config.configKey,
|
||||||
|
apiKey: config.apiKey,
|
||||||
|
apiKeyHeader: config.apiKeyHeader,
|
||||||
|
skipTlsVerify: config.skipTlsVerify,
|
||||||
} as AppriseConfig;
|
} as AppriseConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -1012,12 +1030,18 @@ const [appriseConfig, setAppriseConfig] = createSignal<UIAppriseConfig>(
|
||||||
const appriseData = await NotificationsAPI.getAppriseConfig();
|
const appriseData = await NotificationsAPI.getAppriseConfig();
|
||||||
setAppriseConfig({
|
setAppriseConfig({
|
||||||
enabled: appriseData.enabled ?? false,
|
enabled: appriseData.enabled ?? false,
|
||||||
|
mode: appriseData.mode === 'http' ? 'http' : 'cli',
|
||||||
targetsText: formatAppriseTargets(appriseData.targets),
|
targetsText: formatAppriseTargets(appriseData.targets),
|
||||||
cliPath: appriseData.cliPath || 'apprise',
|
cliPath: appriseData.cliPath || 'apprise',
|
||||||
timeoutSeconds:
|
timeoutSeconds:
|
||||||
typeof appriseData.timeoutSeconds === 'number' && appriseData.timeoutSeconds > 0
|
typeof appriseData.timeoutSeconds === 'number' && appriseData.timeoutSeconds > 0
|
||||||
? appriseData.timeoutSeconds
|
? appriseData.timeoutSeconds
|
||||||
: 15,
|
: 15,
|
||||||
|
serverUrl: appriseData.serverUrl || '',
|
||||||
|
configKey: appriseData.configKey || '',
|
||||||
|
apiKey: appriseData.apiKey || '',
|
||||||
|
apiKeyHeader: appriseData.apiKeyHeader || 'X-API-KEY',
|
||||||
|
skipTlsVerify: Boolean(appriseData.skipTlsVerify),
|
||||||
});
|
});
|
||||||
} catch (appriseErr) {
|
} catch (appriseErr) {
|
||||||
console.error('Failed to load Apprise configuration:', appriseErr);
|
console.error('Failed to load Apprise configuration:', appriseErr);
|
||||||
|
|
@ -1072,12 +1096,18 @@ const [appriseConfig, setAppriseConfig] = createSignal<UIAppriseConfig>(
|
||||||
.then((appriseData) => {
|
.then((appriseData) => {
|
||||||
setAppriseConfig({
|
setAppriseConfig({
|
||||||
enabled: appriseData.enabled ?? false,
|
enabled: appriseData.enabled ?? false,
|
||||||
|
mode: appriseData.mode === 'http' ? 'http' : 'cli',
|
||||||
targetsText: formatAppriseTargets(appriseData.targets),
|
targetsText: formatAppriseTargets(appriseData.targets),
|
||||||
cliPath: appriseData.cliPath || 'apprise',
|
cliPath: appriseData.cliPath || 'apprise',
|
||||||
timeoutSeconds:
|
timeoutSeconds:
|
||||||
typeof appriseData.timeoutSeconds === 'number' && appriseData.timeoutSeconds > 0
|
typeof appriseData.timeoutSeconds === 'number' && appriseData.timeoutSeconds > 0
|
||||||
? appriseData.timeoutSeconds
|
? appriseData.timeoutSeconds
|
||||||
: 15,
|
: 15,
|
||||||
|
serverUrl: appriseData.serverUrl || '',
|
||||||
|
configKey: appriseData.configKey || '',
|
||||||
|
apiKey: appriseData.apiKey || '',
|
||||||
|
apiKeyHeader: appriseData.apiKeyHeader || 'X-API-KEY',
|
||||||
|
skipTlsVerify: Boolean(appriseData.skipTlsVerify),
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
|
|
@ -1484,12 +1514,18 @@ const [appriseConfig, setAppriseConfig] = createSignal<UIAppriseConfig>(
|
||||||
const updatedApprise = await NotificationsAPI.updateAppriseConfig(appriseData);
|
const updatedApprise = await NotificationsAPI.updateAppriseConfig(appriseData);
|
||||||
setAppriseConfig({
|
setAppriseConfig({
|
||||||
enabled: updatedApprise.enabled ?? false,
|
enabled: updatedApprise.enabled ?? false,
|
||||||
|
mode: updatedApprise.mode === 'http' ? 'http' : 'cli',
|
||||||
targetsText: formatAppriseTargets(updatedApprise.targets),
|
targetsText: formatAppriseTargets(updatedApprise.targets),
|
||||||
cliPath: updatedApprise.cliPath || 'apprise',
|
cliPath: updatedApprise.cliPath || 'apprise',
|
||||||
timeoutSeconds:
|
timeoutSeconds:
|
||||||
typeof updatedApprise.timeoutSeconds === 'number' && updatedApprise.timeoutSeconds > 0
|
typeof updatedApprise.timeoutSeconds === 'number' && updatedApprise.timeoutSeconds > 0
|
||||||
? updatedApprise.timeoutSeconds
|
? updatedApprise.timeoutSeconds
|
||||||
: 15,
|
: 15,
|
||||||
|
serverUrl: updatedApprise.serverUrl || '',
|
||||||
|
configKey: updatedApprise.configKey || '',
|
||||||
|
apiKey: updatedApprise.apiKey || '',
|
||||||
|
apiKeyHeader: updatedApprise.apiKeyHeader || 'X-API-KEY',
|
||||||
|
skipTlsVerify: Boolean(updatedApprise.skipTlsVerify),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,8 @@ export interface SnapshotAlertConfig {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
warningDays: number;
|
warningDays: number;
|
||||||
criticalDays: number;
|
criticalDays: number;
|
||||||
|
warningSizeGiB?: number;
|
||||||
|
criticalSizeGiB?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BackupAlertConfig {
|
export interface BackupAlertConfig {
|
||||||
|
|
|
||||||
|
|
@ -607,6 +607,7 @@ export interface GuestSnapshot {
|
||||||
description: string;
|
description: string;
|
||||||
parent: string;
|
parent: string;
|
||||||
vmstate: boolean;
|
vmstate: boolean;
|
||||||
|
sizeBytes?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Performance {
|
export interface Performance {
|
||||||
|
|
|
||||||
|
|
@ -294,9 +294,11 @@ type PMGThresholdConfig struct {
|
||||||
|
|
||||||
// SnapshotAlertConfig represents snapshot age alert configuration
|
// SnapshotAlertConfig represents snapshot age alert configuration
|
||||||
type SnapshotAlertConfig struct {
|
type SnapshotAlertConfig struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
WarningDays int `json:"warningDays"`
|
WarningDays int `json:"warningDays"`
|
||||||
CriticalDays int `json:"criticalDays"`
|
CriticalDays int `json:"criticalDays"`
|
||||||
|
WarningSizeGiB float64 `json:"warningSizeGiB,omitempty"`
|
||||||
|
CriticalSizeGiB float64 `json:"criticalSizeGiB,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// BackupAlertConfig represents backup age alert configuration
|
// BackupAlertConfig represents backup age alert configuration
|
||||||
|
|
@ -513,9 +515,11 @@ func NewManager() *Manager {
|
||||||
QuarantineGrowthCritMin: 500, // AND ≥500 messages
|
QuarantineGrowthCritMin: 500, // AND ≥500 messages
|
||||||
},
|
},
|
||||||
SnapshotDefaults: SnapshotAlertConfig{
|
SnapshotDefaults: SnapshotAlertConfig{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
WarningDays: 30,
|
WarningDays: 30,
|
||||||
CriticalDays: 45,
|
CriticalDays: 45,
|
||||||
|
WarningSizeGiB: 0,
|
||||||
|
CriticalSizeGiB: 0,
|
||||||
},
|
},
|
||||||
BackupDefaults: BackupAlertConfig{
|
BackupDefaults: BackupAlertConfig{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
|
|
@ -836,6 +840,21 @@ func (m *Manager) UpdateConfig(config AlertConfig) {
|
||||||
if config.SnapshotDefaults.CriticalDays > 0 && config.SnapshotDefaults.WarningDays > config.SnapshotDefaults.CriticalDays {
|
if config.SnapshotDefaults.CriticalDays > 0 && config.SnapshotDefaults.WarningDays > config.SnapshotDefaults.CriticalDays {
|
||||||
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 {
|
if config.BackupDefaults.WarningDays < 0 {
|
||||||
config.BackupDefaults.WarningDays = 0
|
config.BackupDefaults.WarningDays = 0
|
||||||
}
|
}
|
||||||
|
|
@ -2991,19 +3010,63 @@ func (m *Manager) CheckSnapshotsForInstance(instanceName string, snapshots []mod
|
||||||
}
|
}
|
||||||
ageDays := ageHours / 24
|
ageDays := ageHours / 24
|
||||||
|
|
||||||
var level AlertLevel
|
const gib = 1024.0 * 1024 * 1024
|
||||||
var threshold int
|
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) {
|
if snapshotCfg.CriticalDays > 0 && ageDays >= float64(snapshotCfg.CriticalDays) {
|
||||||
level = AlertLevelCritical
|
ageLevel = AlertLevelCritical
|
||||||
threshold = snapshotCfg.CriticalDays
|
ageThreshold = snapshotCfg.CriticalDays
|
||||||
|
triggeredStats = append(triggeredStats, "age")
|
||||||
} else if snapshotCfg.WarningDays > 0 && ageDays >= float64(snapshotCfg.WarningDays) {
|
} else if snapshotCfg.WarningDays > 0 && ageDays >= float64(snapshotCfg.WarningDays) {
|
||||||
level = AlertLevelWarning
|
ageLevel = AlertLevelWarning
|
||||||
threshold = snapshotCfg.WarningDays
|
ageThreshold = snapshotCfg.WarningDays
|
||||||
} else {
|
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
|
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)
|
alertID := fmt.Sprintf("snapshot-age-%s", snapshot.ID)
|
||||||
validAlerts[alertID] = struct{}{}
|
validAlerts[alertID] = struct{}{}
|
||||||
|
|
||||||
|
|
@ -3030,19 +3093,35 @@ func (m *Manager) CheckSnapshotsForInstance(instanceName string, snapshots []mod
|
||||||
}
|
}
|
||||||
|
|
||||||
ageDaysRounded := math.Round(ageDays*10) / 10
|
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(
|
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,
|
guestType,
|
||||||
snapshotName,
|
snapshotName,
|
||||||
guestName,
|
guestName,
|
||||||
ageDaysRounded,
|
reasonText,
|
||||||
snapshot.Node,
|
snapshot.Node,
|
||||||
threshold,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
thresholdTime := snapshot.Time.Add(time.Duration(threshold) * 24 * time.Hour)
|
alertValue := ageDays
|
||||||
if thresholdTime.After(now) {
|
alertThreshold := float64(ageThreshold)
|
||||||
thresholdTime = now
|
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{}{
|
metadata := map[string]interface{}{
|
||||||
|
|
@ -3050,12 +3129,24 @@ func (m *Manager) CheckSnapshotsForInstance(instanceName string, snapshots []mod
|
||||||
"snapshotCreatedAt": snapshot.Time,
|
"snapshotCreatedAt": snapshot.Time,
|
||||||
"snapshotAgeDays": ageDays,
|
"snapshotAgeDays": ageDays,
|
||||||
"snapshotAgeHours": ageHours,
|
"snapshotAgeHours": ageHours,
|
||||||
|
"snapshotSizeBytes": snapshot.SizeBytes,
|
||||||
|
"snapshotSizeGiB": sizeGiB,
|
||||||
"guestName": guestName,
|
"guestName": guestName,
|
||||||
"guestType": guestType,
|
"guestType": guestType,
|
||||||
"guestInstance": snapshot.Instance,
|
"guestInstance": snapshot.Instance,
|
||||||
"guestNode": snapshot.Node,
|
"guestNode": snapshot.Node,
|
||||||
"guestVmid": snapshot.VMID,
|
"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)
|
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 {
|
if existing, exists := m.activeAlerts[alertID]; exists {
|
||||||
existing.LastSeen = now
|
existing.LastSeen = now
|
||||||
existing.Level = level
|
existing.Level = level
|
||||||
existing.Value = ageDays
|
existing.Value = alertValue
|
||||||
existing.Threshold = float64(threshold)
|
existing.Threshold = alertThreshold
|
||||||
existing.Message = message
|
existing.Message = message
|
||||||
existing.ResourceName = resourceName
|
existing.ResourceName = resourceName
|
||||||
if existing.Metadata == nil {
|
if existing.Metadata == nil {
|
||||||
|
|
@ -3087,8 +3178,8 @@ func (m *Manager) CheckSnapshotsForInstance(instanceName string, snapshots []mod
|
||||||
Node: snapshot.Node,
|
Node: snapshot.Node,
|
||||||
Instance: snapshot.Instance,
|
Instance: snapshot.Instance,
|
||||||
Message: message,
|
Message: message,
|
||||||
Value: ageDays,
|
Value: alertValue,
|
||||||
Threshold: float64(threshold),
|
Threshold: alertThreshold,
|
||||||
StartTime: thresholdTime,
|
StartTime: thresholdTime,
|
||||||
LastSeen: now,
|
LastSeen: now,
|
||||||
Metadata: metadata,
|
Metadata: metadata,
|
||||||
|
|
|
||||||
|
|
@ -221,9 +221,11 @@ func TestCheckSnapshotsForInstanceCreatesAndClearsAlerts(t *testing.T) {
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
StorageDefault: HysteresisThreshold{Trigger: 85, Clear: 80},
|
StorageDefault: HysteresisThreshold{Trigger: 85, Clear: 80},
|
||||||
SnapshotDefaults: SnapshotAlertConfig{
|
SnapshotDefaults: SnapshotAlertConfig{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
WarningDays: 7,
|
WarningDays: 7,
|
||||||
CriticalDays: 14,
|
CriticalDays: 14,
|
||||||
|
WarningSizeGiB: 0,
|
||||||
|
CriticalSizeGiB: 0,
|
||||||
},
|
},
|
||||||
Overrides: make(map[string]ThresholdConfig),
|
Overrides: make(map[string]ThresholdConfig),
|
||||||
}
|
}
|
||||||
|
|
@ -236,13 +238,14 @@ func TestCheckSnapshotsForInstanceCreatesAndClearsAlerts(t *testing.T) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
snapshots := []models.GuestSnapshot{
|
snapshots := []models.GuestSnapshot{
|
||||||
{
|
{
|
||||||
ID: "inst-node-100-weekly",
|
ID: "inst-node-100-weekly",
|
||||||
Name: "weekly",
|
Name: "weekly",
|
||||||
Node: "node",
|
Node: "node",
|
||||||
Instance: "inst",
|
Instance: "inst",
|
||||||
Type: "qemu",
|
Type: "qemu",
|
||||||
VMID: 100,
|
VMID: 100,
|
||||||
Time: now.Add(-15 * 24 * time.Hour),
|
Time: now.Add(-15 * 24 * time.Hour),
|
||||||
|
SizeBytes: 60 << 30,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
guestNames := map[string]string{
|
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) {
|
func TestCheckBackupsCreatesAndClearsAlerts(t *testing.T) {
|
||||||
m := NewManager()
|
m := NewManager()
|
||||||
m.ClearActiveAlerts()
|
m.ClearActiveAlerts()
|
||||||
|
|
|
||||||
|
|
@ -113,8 +113,14 @@ func (h *NotificationHandlers) UpdateAppriseConfig(w http.ResponseWriter, r *htt
|
||||||
|
|
||||||
log.Info().
|
log.Info().
|
||||||
Bool("enabled", config.Enabled).
|
Bool("enabled", config.Enabled).
|
||||||
|
Str("mode", string(config.Mode)).
|
||||||
Int("targetCount", len(config.Targets)).
|
Int("targetCount", len(config.Targets)).
|
||||||
Str("cliPath", config.CLIPath).
|
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).
|
Int("timeoutSeconds", config.TimeoutSeconds).
|
||||||
Msg("Parsed Apprise configuration update")
|
Msg("Parsed Apprise configuration update")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -215,6 +215,21 @@ func (c *ConfigPersistence) SaveAlertConfig(config alerts.AlertConfig) error {
|
||||||
if config.SnapshotDefaults.CriticalDays > 0 && config.SnapshotDefaults.WarningDays > config.SnapshotDefaults.CriticalDays {
|
if config.SnapshotDefaults.CriticalDays > 0 && config.SnapshotDefaults.WarningDays > config.SnapshotDefaults.CriticalDays {
|
||||||
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 {
|
if config.BackupDefaults.WarningDays < 0 {
|
||||||
config.BackupDefaults.WarningDays = 0
|
config.BackupDefaults.WarningDays = 0
|
||||||
}
|
}
|
||||||
|
|
@ -277,9 +292,11 @@ func (c *ConfigPersistence) LoadAlertConfig() (*alerts.AlertConfig, error) {
|
||||||
SuppressionWindow: 5,
|
SuppressionWindow: 5,
|
||||||
HysteresisMargin: 5.0,
|
HysteresisMargin: 5.0,
|
||||||
SnapshotDefaults: alerts.SnapshotAlertConfig{
|
SnapshotDefaults: alerts.SnapshotAlertConfig{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
WarningDays: 30,
|
WarningDays: 30,
|
||||||
CriticalDays: 45,
|
CriticalDays: 45,
|
||||||
|
WarningSizeGiB: 0,
|
||||||
|
CriticalSizeGiB: 0,
|
||||||
},
|
},
|
||||||
BackupDefaults: alerts.BackupAlertConfig{
|
BackupDefaults: alerts.BackupAlertConfig{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
|
|
@ -345,6 +362,18 @@ func (c *ConfigPersistence) LoadAlertConfig() (*alerts.AlertConfig, error) {
|
||||||
if config.SnapshotDefaults.CriticalDays > 0 && config.SnapshotDefaults.WarningDays > config.SnapshotDefaults.CriticalDays {
|
if config.SnapshotDefaults.CriticalDays > 0 && config.SnapshotDefaults.WarningDays > config.SnapshotDefaults.CriticalDays {
|
||||||
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 {
|
if config.BackupDefaults.WarningDays < 0 {
|
||||||
config.BackupDefaults.WarningDays = 0
|
config.BackupDefaults.WarningDays = 0
|
||||||
}
|
}
|
||||||
|
|
@ -500,9 +529,11 @@ func (c *ConfigPersistence) LoadAppriseConfig() (*notifications.AppriseConfig, e
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
defaultCfg := notifications.AppriseConfig{
|
defaultCfg := notifications.AppriseConfig{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
|
Mode: notifications.AppriseModeCLI,
|
||||||
Targets: []string{},
|
Targets: []string{},
|
||||||
CLIPath: "apprise",
|
CLIPath: "apprise",
|
||||||
TimeoutSeconds: 15,
|
TimeoutSeconds: 15,
|
||||||
|
APIKeyHeader: "X-API-KEY",
|
||||||
}
|
}
|
||||||
return &defaultCfg, nil
|
return &defaultCfg, nil
|
||||||
}
|
}
|
||||||
|
|
@ -684,29 +715,29 @@ type NodesConfig struct {
|
||||||
// SystemSettings represents system configuration settings
|
// SystemSettings represents system configuration settings
|
||||||
type SystemSettings struct {
|
type SystemSettings struct {
|
||||||
// Note: PVE polling is hardcoded to 10s since Proxmox cluster/resources endpoint only updates every 10s
|
// 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
|
PBSPollingInterval int `json:"pbsPollingInterval"` // PBS polling interval in seconds
|
||||||
PMGPollingInterval int `json:"pmgPollingInterval"` // PMG polling interval in seconds
|
PMGPollingInterval int `json:"pmgPollingInterval"` // PMG polling interval in seconds
|
||||||
BackupPollingInterval int `json:"backupPollingInterval,omitempty"`
|
BackupPollingInterval int `json:"backupPollingInterval,omitempty"`
|
||||||
BackupPollingEnabled *bool `json:"backupPollingEnabled,omitempty"`
|
BackupPollingEnabled *bool `json:"backupPollingEnabled,omitempty"`
|
||||||
AdaptivePollingEnabled *bool `json:"adaptivePollingEnabled,omitempty"`
|
AdaptivePollingEnabled *bool `json:"adaptivePollingEnabled,omitempty"`
|
||||||
AdaptivePollingBaseInterval int `json:"adaptivePollingBaseInterval,omitempty"`
|
AdaptivePollingBaseInterval int `json:"adaptivePollingBaseInterval,omitempty"`
|
||||||
AdaptivePollingMinInterval int `json:"adaptivePollingMinInterval,omitempty"`
|
AdaptivePollingMinInterval int `json:"adaptivePollingMinInterval,omitempty"`
|
||||||
AdaptivePollingMaxInterval int `json:"adaptivePollingMaxInterval,omitempty"`
|
AdaptivePollingMaxInterval int `json:"adaptivePollingMaxInterval,omitempty"`
|
||||||
BackendPort int `json:"backendPort,omitempty"`
|
BackendPort int `json:"backendPort,omitempty"`
|
||||||
FrontendPort int `json:"frontendPort,omitempty"`
|
FrontendPort int `json:"frontendPort,omitempty"`
|
||||||
AllowedOrigins string `json:"allowedOrigins,omitempty"`
|
AllowedOrigins string `json:"allowedOrigins,omitempty"`
|
||||||
ConnectionTimeout int `json:"connectionTimeout,omitempty"`
|
ConnectionTimeout int `json:"connectionTimeout,omitempty"`
|
||||||
UpdateChannel string `json:"updateChannel,omitempty"`
|
UpdateChannel string `json:"updateChannel,omitempty"`
|
||||||
AutoUpdateEnabled bool `json:"autoUpdateEnabled"` // Removed omitempty so false is saved
|
AutoUpdateEnabled bool `json:"autoUpdateEnabled"` // Removed omitempty so false is saved
|
||||||
AutoUpdateCheckInterval int `json:"autoUpdateCheckInterval,omitempty"`
|
AutoUpdateCheckInterval int `json:"autoUpdateCheckInterval,omitempty"`
|
||||||
AutoUpdateTime string `json:"autoUpdateTime,omitempty"`
|
AutoUpdateTime string `json:"autoUpdateTime,omitempty"`
|
||||||
LogLevel string `json:"logLevel,omitempty"`
|
LogLevel string `json:"logLevel,omitempty"`
|
||||||
DiscoveryEnabled bool `json:"discoveryEnabled"`
|
DiscoveryEnabled bool `json:"discoveryEnabled"`
|
||||||
DiscoverySubnet string `json:"discoverySubnet,omitempty"`
|
DiscoverySubnet string `json:"discoverySubnet,omitempty"`
|
||||||
DiscoveryConfig DiscoveryConfig `json:"discoveryConfig"`
|
DiscoveryConfig DiscoveryConfig `json:"discoveryConfig"`
|
||||||
Theme string `json:"theme,omitempty"` // User theme preference: "light", "dark", or empty for system default
|
Theme string `json:"theme,omitempty"` // User theme preference: "light", "dark", or empty for system default
|
||||||
AllowEmbedding bool `json:"allowEmbedding"` // Allow iframe embedding
|
AllowEmbedding bool `json:"allowEmbedding"` // Allow iframe embedding
|
||||||
AllowedEmbedOrigins string `json:"allowedEmbedOrigins,omitempty"` // Comma-separated list of allowed origins for embedding
|
AllowedEmbedOrigins string `json:"allowedEmbedOrigins,omitempty"` // Comma-separated list of allowed origins for embedding
|
||||||
// APIToken removed - now handled via .env file only
|
// APIToken removed - now handled via .env file only
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -714,13 +745,13 @@ type SystemSettings struct {
|
||||||
func DefaultSystemSettings() *SystemSettings {
|
func DefaultSystemSettings() *SystemSettings {
|
||||||
defaultDiscovery := DefaultDiscoveryConfig()
|
defaultDiscovery := DefaultDiscoveryConfig()
|
||||||
return &SystemSettings{
|
return &SystemSettings{
|
||||||
PBSPollingInterval: 60,
|
PBSPollingInterval: 60,
|
||||||
PMGPollingInterval: 60,
|
PMGPollingInterval: 60,
|
||||||
AutoUpdateEnabled: false,
|
AutoUpdateEnabled: false,
|
||||||
DiscoveryEnabled: false,
|
DiscoveryEnabled: false,
|
||||||
DiscoverySubnet: "auto",
|
DiscoverySubnet: "auto",
|
||||||
DiscoveryConfig: defaultDiscovery,
|
DiscoveryConfig: defaultDiscovery,
|
||||||
AllowEmbedding: false,
|
AllowEmbedding: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -145,9 +145,11 @@ func TestLoadAlertConfigAppliesDefaults(t *testing.T) {
|
||||||
TimeThresholds: map[string]int{"guest": 0, "node": 0},
|
TimeThresholds: map[string]int{"guest": 0, "node": 0},
|
||||||
DockerIgnoredContainerPrefixes: []string{" Runner "},
|
DockerIgnoredContainerPrefixes: []string{" Runner "},
|
||||||
SnapshotDefaults: alerts.SnapshotAlertConfig{
|
SnapshotDefaults: alerts.SnapshotAlertConfig{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
WarningDays: 20,
|
WarningDays: 20,
|
||||||
CriticalDays: 10,
|
CriticalDays: 10,
|
||||||
|
WarningSizeGiB: 15,
|
||||||
|
CriticalSizeGiB: 8,
|
||||||
},
|
},
|
||||||
BackupDefaults: alerts.BackupAlertConfig{
|
BackupDefaults: alerts.BackupAlertConfig{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
|
|
@ -210,6 +212,12 @@ func TestLoadAlertConfigAppliesDefaults(t *testing.T) {
|
||||||
if loaded.SnapshotDefaults.CriticalDays != 10 {
|
if loaded.SnapshotDefaults.CriticalDays != 10 {
|
||||||
t.Fatalf("expected snapshot critical days preserved at 10, got %d", loaded.SnapshotDefaults.CriticalDays)
|
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) {
|
func TestAppriseConfigPersistence(t *testing.T) {
|
||||||
|
|
@ -348,7 +356,7 @@ func TestImportConfigTransactionalSuccess(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
newAlerts := alerts.AlertConfig{
|
newAlerts := alerts.AlertConfig{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
HysteresisMargin: 3.5,
|
HysteresisMargin: 3.5,
|
||||||
StorageDefault: alerts.HysteresisThreshold{
|
StorageDefault: alerts.HysteresisThreshold{
|
||||||
Trigger: 70,
|
Trigger: 70,
|
||||||
|
|
@ -426,7 +434,7 @@ func TestImportConfigTransactionalSuccess(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
oldAlerts := alerts.AlertConfig{
|
oldAlerts := alerts.AlertConfig{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
HysteresisMargin: 5,
|
HysteresisMargin: 5,
|
||||||
StorageDefault: alerts.HysteresisThreshold{
|
StorageDefault: alerts.HysteresisThreshold{
|
||||||
Trigger: 85,
|
Trigger: 85,
|
||||||
|
|
@ -439,13 +447,13 @@ func TestImportConfigTransactionalSuccess(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
oldSystem := config.SystemSettings{
|
oldSystem := config.SystemSettings{
|
||||||
PBSPollingInterval: 120,
|
PBSPollingInterval: 120,
|
||||||
PMGPollingInterval: 120,
|
PMGPollingInterval: 120,
|
||||||
AutoUpdateEnabled: false,
|
AutoUpdateEnabled: false,
|
||||||
DiscoveryEnabled: true,
|
DiscoveryEnabled: true,
|
||||||
DiscoverySubnet: "auto",
|
DiscoverySubnet: "auto",
|
||||||
DiscoveryConfig: config.DefaultDiscoveryConfig(),
|
DiscoveryConfig: config.DefaultDiscoveryConfig(),
|
||||||
Theme: "light",
|
Theme: "light",
|
||||||
}
|
}
|
||||||
if err := target.SaveSystemSettings(oldSystem); err != nil {
|
if err := target.SaveSystemSettings(oldSystem); err != nil {
|
||||||
t.Fatalf("SaveSystemSettings baseline: %v", err)
|
t.Fatalf("SaveSystemSettings baseline: %v", err)
|
||||||
|
|
@ -529,7 +537,7 @@ func TestImportConfigRollbackOnFailure(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
newAlerts := alerts.AlertConfig{
|
newAlerts := alerts.AlertConfig{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
HysteresisMargin: 4,
|
HysteresisMargin: 4,
|
||||||
StorageDefault: alerts.HysteresisThreshold{
|
StorageDefault: alerts.HysteresisThreshold{
|
||||||
Trigger: 65,
|
Trigger: 65,
|
||||||
|
|
@ -592,7 +600,7 @@ func TestImportConfigRollbackOnFailure(t *testing.T) {
|
||||||
t.Fatalf("SaveNodesConfig baseline: %v", err)
|
t.Fatalf("SaveNodesConfig baseline: %v", err)
|
||||||
}
|
}
|
||||||
baselineAlerts := alerts.AlertConfig{
|
baselineAlerts := alerts.AlertConfig{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
HysteresisMargin: 5,
|
HysteresisMargin: 5,
|
||||||
StorageDefault: alerts.HysteresisThreshold{
|
StorageDefault: alerts.HysteresisThreshold{
|
||||||
Trigger: 90,
|
Trigger: 90,
|
||||||
|
|
@ -703,7 +711,7 @@ func TestImportAcceptsVersion40Bundle(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
newAlerts := alerts.AlertConfig{
|
newAlerts := alerts.AlertConfig{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
HysteresisMargin: 4,
|
HysteresisMargin: 4,
|
||||||
StorageDefault: alerts.HysteresisThreshold{
|
StorageDefault: alerts.HysteresisThreshold{
|
||||||
Trigger: 75,
|
Trigger: 75,
|
||||||
|
|
|
||||||
|
|
@ -2514,7 +2514,8 @@ func generateSnapshots(vms []models.VM, containers []models.Container) []models.
|
||||||
VMID: vm.VMID,
|
VMID: vm.VMID,
|
||||||
Time: snapshotTime,
|
Time: snapshotTime,
|
||||||
Description: fmt.Sprintf("Snapshot of %s taken on %s", vm.Name, snapshotTime.Format("2006-01-02")),
|
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
|
// Add parent relationship for some snapshots
|
||||||
|
|
@ -2546,7 +2547,8 @@ func generateSnapshots(vms []models.VM, containers []models.Container) []models.
|
||||||
VMID: int(ct.VMID),
|
VMID: int(ct.VMID),
|
||||||
Time: snapshotTime,
|
Time: snapshotTime,
|
||||||
Description: fmt.Sprintf("Container snapshot for %s", ct.Name),
|
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)
|
snapshots = append(snapshots, snapshot)
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ type State struct {
|
||||||
VMs []VM `json:"vms"`
|
VMs []VM `json:"vms"`
|
||||||
Containers []Container `json:"containers"`
|
Containers []Container `json:"containers"`
|
||||||
DockerHosts []DockerHost `json:"dockerHosts"`
|
DockerHosts []DockerHost `json:"dockerHosts"`
|
||||||
|
Hosts []Host `json:"hosts"`
|
||||||
Storage []Storage `json:"storage"`
|
Storage []Storage `json:"storage"`
|
||||||
CephClusters []CephCluster `json:"cephClusters"`
|
CephClusters []CephCluster `json:"cephClusters"`
|
||||||
PhysicalDisks []PhysicalDisk `json:"physicalDisks"`
|
PhysicalDisks []PhysicalDisk `json:"physicalDisks"`
|
||||||
|
|
@ -137,6 +138,52 @@ type Container struct {
|
||||||
LastSeen time.Time `json:"lastSeen"`
|
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.
|
// DockerHost represents a Docker host reporting metrics via the external agent.
|
||||||
type DockerHost struct {
|
type DockerHost struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
|
|
@ -674,6 +721,7 @@ type GuestSnapshot struct {
|
||||||
Description string `json:"description,omitempty"`
|
Description string `json:"description,omitempty"`
|
||||||
Parent string `json:"parent,omitempty"`
|
Parent string `json:"parent,omitempty"`
|
||||||
VMState bool `json:"vmstate"`
|
VMState bool `json:"vmstate"`
|
||||||
|
SizeBytes int64 `json:"sizeBytes,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Performance represents performance metrics
|
// Performance represents performance metrics
|
||||||
|
|
@ -1046,6 +1094,91 @@ func (s *State) GetDockerHosts() []DockerHost {
|
||||||
return hosts
|
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
|
// UpdateStorage updates the storage in the state
|
||||||
func (s *State) UpdateStorage(storage []Storage) {
|
func (s *State) UpdateStorage(storage []Storage) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
|
|
|
||||||
|
|
@ -6431,6 +6431,17 @@ func (m *Monitor) pollGuestSnapshots(ctx context.Context, instanceName string, c
|
||||||
return
|
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
|
// Update state with guest snapshots for this instance
|
||||||
m.state.UpdateGuestSnapshotsForInstance(instanceName, allSnapshots)
|
m.state.UpdateGuestSnapshotsForInstance(instanceName, allSnapshots)
|
||||||
|
|
||||||
|
|
@ -6444,6 +6455,116 @@ func (m *Monitor) pollGuestSnapshots(ctx context.Context, instanceName string, c
|
||||||
Msg("Guest snapshots polled")
|
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
|
// Stop gracefully stops the monitor
|
||||||
func (m *Monitor) Stop() {
|
func (m *Monitor) Stop() {
|
||||||
log.Info().Msg("Stopping monitor")
|
log.Info().Msg("Stopping monitor")
|
||||||
|
|
|
||||||
122
internal/monitoring/monitor_snapshots_test.go
Normal file
122
internal/monitoring/monitor_snapshots_test.go
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
package monitoring
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
||||||
|
"github.com/rcourtman/pulse-go-rewrite/pkg/proxmox"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeSnapshotClient struct {
|
||||||
|
storages map[string][]proxmox.Storage
|
||||||
|
contents map[string]map[string][]proxmox.StorageContent
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f fakeSnapshotClient) GetNodes(ctx context.Context) ([]proxmox.Node, error) { return nil, nil }
|
||||||
|
func (f fakeSnapshotClient) GetNodeStatus(ctx context.Context, node string) (*proxmox.NodeStatus, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (f fakeSnapshotClient) GetNodeRRDData(ctx context.Context, node string, timeframe string, cf string, ds []string) ([]proxmox.NodeRRDPoint, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (f fakeSnapshotClient) GetVMs(ctx context.Context, node string) ([]proxmox.VM, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (f fakeSnapshotClient) GetContainers(ctx context.Context, node string) ([]proxmox.Container, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (f fakeSnapshotClient) GetStorage(ctx context.Context, node string) ([]proxmox.Storage, error) {
|
||||||
|
return f.storages[node], nil
|
||||||
|
}
|
||||||
|
func (f fakeSnapshotClient) GetAllStorage(ctx context.Context) ([]proxmox.Storage, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (f fakeSnapshotClient) GetBackupTasks(ctx context.Context) ([]proxmox.Task, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (f fakeSnapshotClient) GetStorageContent(ctx context.Context, node, storage string) ([]proxmox.StorageContent, error) {
|
||||||
|
if storageContents, ok := f.contents[node]; ok {
|
||||||
|
return storageContents[storage], nil
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (f fakeSnapshotClient) GetVMSnapshots(ctx context.Context, node string, vmid int) ([]proxmox.Snapshot, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (f fakeSnapshotClient) GetContainerSnapshots(ctx context.Context, node string, vmid int) ([]proxmox.Snapshot, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (f fakeSnapshotClient) GetVMStatus(ctx context.Context, node string, vmid int) (*proxmox.VMStatus, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (f fakeSnapshotClient) GetContainerStatus(ctx context.Context, node string, vmid int) (*proxmox.Container, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (f fakeSnapshotClient) GetClusterResources(ctx context.Context, resourceType string) ([]proxmox.ClusterResource, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (f fakeSnapshotClient) IsClusterMember(ctx context.Context) (bool, error) { return false, nil }
|
||||||
|
func (f fakeSnapshotClient) GetVMFSInfo(ctx context.Context, node string, vmid int) ([]proxmox.VMFileSystem, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (f fakeSnapshotClient) GetVMNetworkInterfaces(ctx context.Context, node string, vmid int) ([]proxmox.VMNetworkInterface, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (f fakeSnapshotClient) GetVMAgentInfo(ctx context.Context, node string, vmid int) (map[string]interface{}, error) {
|
||||||
|
return map[string]interface{}{}, nil
|
||||||
|
}
|
||||||
|
func (f fakeSnapshotClient) GetZFSPoolStatus(ctx context.Context, node string) ([]proxmox.ZFSPoolStatus, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (f fakeSnapshotClient) GetZFSPoolsWithDetails(ctx context.Context, node string) ([]proxmox.ZFSPoolInfo, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (f fakeSnapshotClient) GetDisks(ctx context.Context, node string) ([]proxmox.Disk, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (f fakeSnapshotClient) GetCephStatus(ctx context.Context) (*proxmox.CephStatus, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (f fakeSnapshotClient) GetCephDF(ctx context.Context) (*proxmox.CephDF, error) { return nil, nil }
|
||||||
|
|
||||||
|
func TestCollectSnapshotSizes(t *testing.T) {
|
||||||
|
m := &Monitor{}
|
||||||
|
snapshots := []models.GuestSnapshot{
|
||||||
|
{
|
||||||
|
ID: "inst-node1-100-pre",
|
||||||
|
Name: "pre",
|
||||||
|
Node: "node1",
|
||||||
|
Instance: "inst",
|
||||||
|
Type: "qemu",
|
||||||
|
VMID: 100,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
client := fakeSnapshotClient{
|
||||||
|
storages: map[string][]proxmox.Storage{
|
||||||
|
"node1": {
|
||||||
|
{Storage: "local-zfs", Content: "images"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
contents: map[string]map[string][]proxmox.StorageContent{
|
||||||
|
"node1": {
|
||||||
|
"local-zfs": {
|
||||||
|
{Volid: "local-zfs:vm-100-disk-0@pre", VMID: 100, Size: 20 << 30},
|
||||||
|
// Duplicate entry should be deduped via volid tracking
|
||||||
|
{Volid: "local-zfs:vm-100-disk-0@pre", VMID: 100, Size: 20 << 30},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
sizes := m.collectSnapshotSizes(context.Background(), "inst", client, snapshots)
|
||||||
|
got, ok := sizes[snapshots[0].ID]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected size entry for snapshot")
|
||||||
|
}
|
||||||
|
want := int64(20 << 30)
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("unexpected size: got %d want %d", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ package notifications
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
|
@ -171,6 +172,15 @@ func copyAppriseConfig(cfg AppriseConfig) AppriseConfig {
|
||||||
// NormalizeAppriseConfig cleans and normalizes Apprise configuration values.
|
// NormalizeAppriseConfig cleans and normalizes Apprise configuration values.
|
||||||
func NormalizeAppriseConfig(cfg AppriseConfig) AppriseConfig {
|
func NormalizeAppriseConfig(cfg AppriseConfig) AppriseConfig {
|
||||||
normalized := cfg
|
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)
|
normalized.CLIPath = strings.TrimSpace(normalized.CLIPath)
|
||||||
if normalized.CLIPath == "" {
|
if normalized.CLIPath == "" {
|
||||||
normalized.CLIPath = "apprise"
|
normalized.CLIPath = "apprise"
|
||||||
|
|
@ -198,10 +208,28 @@ func NormalizeAppriseConfig(cfg AppriseConfig) AppriseConfig {
|
||||||
seen[lower] = struct{}{}
|
seen[lower] = struct{}{}
|
||||||
cleanTargets = append(cleanTargets, trimmed)
|
cleanTargets = append(cleanTargets, trimmed)
|
||||||
}
|
}
|
||||||
|
|
||||||
normalized.Targets = cleanTargets
|
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
|
return normalized
|
||||||
|
|
@ -258,12 +286,26 @@ type WebhookConfig struct {
|
||||||
CustomFields map[string]string `json:"customFields,omitempty"`
|
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 {
|
type AppriseConfig struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
Targets []string `json:"targets"`
|
Mode AppriseMode `json:"mode,omitempty"`
|
||||||
CLIPath string `json:"cliPath,omitempty"`
|
Targets []string `json:"targets"`
|
||||||
TimeoutSeconds int `json:"timeoutSeconds,omitempty"`
|
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
|
// NewNotificationManager creates a new notification manager
|
||||||
|
|
@ -281,9 +323,11 @@ func NewNotificationManager(publicURL string) *NotificationManager {
|
||||||
webhooks: []WebhookConfig{},
|
webhooks: []WebhookConfig{},
|
||||||
appriseConfig: AppriseConfig{
|
appriseConfig: AppriseConfig{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
|
Mode: AppriseModeCLI,
|
||||||
Targets: []string{},
|
Targets: []string{},
|
||||||
CLIPath: "apprise",
|
CLIPath: "apprise",
|
||||||
TimeoutSeconds: 15,
|
TimeoutSeconds: 15,
|
||||||
|
APIKeyHeader: "X-API-KEY",
|
||||||
},
|
},
|
||||||
groupWindow: 30 * time.Second,
|
groupWindow: 30 * time.Second,
|
||||||
pendingAlerts: make([]*alerts.Alert, 0),
|
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)
|
go n.sendGroupedApprise(appriseConfig, alertsToSend)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -604,23 +648,64 @@ func (n *NotificationManager) sendGroupedApprise(config AppriseConfig, alertList
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg := NormalizeAppriseConfig(config)
|
cfg := NormalizeAppriseConfig(config)
|
||||||
if !cfg.Enabled || len(cfg.Targets) == 0 {
|
if !cfg.Enabled {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
primary := alertList[0]
|
title, body, notifyType := buildApprisePayload(alertList, n.publicURL)
|
||||||
alertCount := len(alertList)
|
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)
|
title := fmt.Sprintf("Pulse alert: %s", primary.ResourceName)
|
||||||
if alertCount > 1 {
|
if len(validAlerts) > 1 {
|
||||||
title = fmt.Sprintf("Pulse alerts (%d)", alertCount)
|
title = fmt.Sprintf("Pulse alerts (%d)", len(validAlerts))
|
||||||
}
|
}
|
||||||
|
|
||||||
var bodyBuilder strings.Builder
|
var bodyBuilder strings.Builder
|
||||||
bodyBuilder.WriteString(primary.Message)
|
bodyBuilder.WriteString(primary.Message)
|
||||||
bodyBuilder.WriteString("\n\n")
|
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("[%s] %s", strings.ToUpper(string(alert.Level)), alert.ResourceName))
|
||||||
bodyBuilder.WriteString(fmt.Sprintf(" — value %.2f (threshold %.2f)\n", alert.Value, alert.Threshold))
|
bodyBuilder.WriteString(fmt.Sprintf(" — value %.2f (threshold %.2f)\n", alert.Value, alert.Threshold))
|
||||||
if alert.Node != "" {
|
if alert.Node != "" {
|
||||||
|
|
@ -632,11 +717,33 @@ func (n *NotificationManager) sendGroupedApprise(config AppriseConfig, alertList
|
||||||
bodyBuilder.WriteString("\n")
|
bodyBuilder.WriteString("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
if n.publicURL != "" {
|
if publicURL != "" {
|
||||||
bodyBuilder.WriteString("Dashboard: " + n.publicURL + "\n")
|
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)
|
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(cfg.TimeoutSeconds)*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
@ -651,12 +758,14 @@ func (n *NotificationManager) sendGroupedApprise(config AppriseConfig, alertList
|
||||||
|
|
||||||
output, err := execFn(ctx, cfg.CLIPath, args)
|
output, err := execFn(ctx, cfg.CLIPath, args)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warn().
|
if len(output) > 0 {
|
||||||
Err(err).
|
log.Debug().
|
||||||
Str("cliPath", cfg.CLIPath).
|
Str("cliPath", cfg.CLIPath).
|
||||||
Strs("targets", cfg.Targets).
|
Strs("targets", cfg.Targets).
|
||||||
Msg("Failed to send Apprise notification")
|
Str("output", string(output)).
|
||||||
return
|
Msg("Apprise CLI output (error)")
|
||||||
|
}
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(output) > 0 {
|
if len(output) > 0 {
|
||||||
|
|
@ -666,6 +775,97 @@ func (n *NotificationManager) sendGroupedApprise(config AppriseConfig, alertList
|
||||||
Str("output", string(output)).
|
Str("output", string(output)).
|
||||||
Msg("Apprise CLI 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
|
// sendEmail sends an email notification
|
||||||
|
|
@ -878,6 +1078,8 @@ func (n *NotificationManager) sendGroupedWebhook(webhook WebhookConfig, alertLis
|
||||||
if routingKey, ok := webhook.Headers["routing_key"]; ok {
|
if routingKey, ok := webhook.Headers["routing_key"]; ok {
|
||||||
dataPtr.CustomFields["routing_key"] = routingKey
|
dataPtr.CustomFields["routing_key"] = routingKey
|
||||||
}
|
}
|
||||||
|
case "pushover":
|
||||||
|
dataPtr.CustomFields = ensurePushoverCustomFieldAliases(dataPtr.CustomFields)
|
||||||
}
|
}
|
||||||
serviceDataApplied = true
|
serviceDataApplied = true
|
||||||
}
|
}
|
||||||
|
|
@ -1339,6 +1541,39 @@ func convertWebhookCustomFields(fields map[string]string) map[string]interface{}
|
||||||
return converted
|
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
|
// prepareWebhookData prepares data for template rendering
|
||||||
func (n *NotificationManager) prepareWebhookData(alert *alerts.Alert, customFields map[string]interface{}) WebhookPayloadData {
|
func (n *NotificationManager) prepareWebhookData(alert *alerts.Alert, customFields map[string]interface{}) WebhookPayloadData {
|
||||||
duration := time.Since(alert.StartTime)
|
duration := time.Since(alert.StartTime)
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,11 @@ package notifications
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -24,10 +29,15 @@ func TestNormalizeAppriseConfig(t *testing.T) {
|
||||||
Targets: []string{" discord://token ", "", "DISCORD://TOKEN"},
|
Targets: []string{" discord://token ", "", "DISCORD://TOKEN"},
|
||||||
CLIPath: " ",
|
CLIPath: " ",
|
||||||
TimeoutSeconds: -5,
|
TimeoutSeconds: -5,
|
||||||
|
APIKeyHeader: "",
|
||||||
}
|
}
|
||||||
|
|
||||||
normalized := NormalizeAppriseConfig(original)
|
normalized := NormalizeAppriseConfig(original)
|
||||||
|
|
||||||
|
if normalized.Mode != AppriseModeCLI {
|
||||||
|
t.Fatalf("expected default mode cli, got %q", normalized.Mode)
|
||||||
|
}
|
||||||
|
|
||||||
if normalized.CLIPath != "apprise" {
|
if normalized.CLIPath != "apprise" {
|
||||||
t.Fatalf("expected default CLI path 'apprise', got %q", normalized.CLIPath)
|
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)
|
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
|
// When all targets removed, enabled should reset to false
|
||||||
empty := NormalizeAppriseConfig(AppriseConfig{Enabled: true})
|
empty := NormalizeAppriseConfig(AppriseConfig{Enabled: true})
|
||||||
if empty.Enabled {
|
if empty.Enabled {
|
||||||
t.Fatalf("expected enabled to be false when no targets configured")
|
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) {
|
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) {
|
func TestNotificationCooldownAllowsNewAlertInstance(t *testing.T) {
|
||||||
nm := NewNotificationManager("")
|
nm := NewNotificationManager("")
|
||||||
nm.SetCooldown(1) // 1 minute cooldown
|
nm.SetCooldown(1) // 1 minute cooldown
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue