Add snapshot size alert thresholds (#585)

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

View file

@ -320,11 +320,14 @@ Quick start - most settings are in the web UI:
- **Settings → Security**: Authentication and API tokens - **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

View file

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

View file

@ -25,7 +25,8 @@ const COLUMN_TOOLTIP_LOOKUP: Record<string, string> = {
// PMG (Proxmox Mail Gateway) thresholds // PMG (Proxmox Mail Gateway) thresholds
'queue warn': 'Early warning when total mail queue exceeds this message count.', 'queue warn': 'Early warning when total mail queue exceeds this message count.',
'queue crit': 'Critical alert requiring urgent action when queue reaches this size.', 'queue crit': 'Critical alert requiring urgent action when queue reaches this size.',
'deferred warn': 'Early warning for messages stuck in deferred queue (waiting to retry delivery).', 'deferred warn':
'Early warning for messages stuck in deferred queue (waiting to retry delivery).',
'deferred crit': 'Critical threshold for deferred messages indicating serious delivery problems.', 'deferred crit': 'Critical threshold for deferred messages indicating serious delivery problems.',
'hold warn': 'Early warning when administratively held messages exceed this count.', 'hold warn': 'Early warning when administratively held messages exceed this count.',
'hold crit': 'Critical alert for held messages requiring immediate moderation attention.', 'hold crit': 'Critical alert for held messages requiring immediate moderation attention.',
@ -38,7 +39,10 @@ const COLUMN_TOOLTIP_LOOKUP: Record<string, string> = {
'growth warn %': 'Early warning when quarantine growth rate exceeds this percentage.', 'growth warn %': 'Early warning when quarantine growth rate exceeds this percentage.',
'growth warn min': 'Minimum new messages required before growth percentage triggers warning.', 'growth warn min': 'Minimum new messages required before growth percentage triggers warning.',
'growth crit %': 'Critical quarantine growth rate requiring immediate investigation.', 'growth crit %': 'Critical quarantine growth rate requiring immediate investigation.',
'growth crit min': 'Minimum new messages required before growth percentage triggers critical alert.', 'growth crit min':
'Minimum new messages required before growth percentage triggers critical alert.',
'warning size (gib)': 'Total snapshot size in GiB that raises a warning.',
'critical size (gib)': 'Total snapshot size in GiB that raises a critical alert.',
}; };
const OFFLINE_ALERTS_TOOLTIP = const OFFLINE_ALERTS_TOOLTIP =
@ -117,7 +121,11 @@ interface ResourceTableProps {
formatMetricValue: (metric: string, value: number | undefined) => string; formatMetricValue: (metric: string, value: number | undefined) => string;
hasActiveAlert: (resourceId: string, metric: string) => boolean; hasActiveAlert: (resourceId: string, metric: string) => boolean;
globalDefaults?: Record<string, number | undefined>; globalDefaults?: Record<string, number | undefined>;
setGlobalDefaults?: (value: Record<string, number | undefined> | ((prev: Record<string, number | undefined>) => Record<string, number | undefined>)) => void; setGlobalDefaults?: (
value:
| Record<string, number | undefined>
| ((prev: Record<string, number | undefined>) => Record<string, number | undefined>),
) => void;
setHasUnsavedChanges?: (value: boolean) => void; setHasUnsavedChanges?: (value: boolean) => void;
globalDisableFlag?: () => boolean; globalDisableFlag?: () => boolean;
onToggleGlobalDisable?: () => void; onToggleGlobalDisable?: () => void;
@ -150,7 +158,10 @@ export function ResourceTable(props: ResourceTableProps) {
return Boolean(props.globalDefaults); return Boolean(props.globalDefaults);
}; };
const [activeMetricInput, setActiveMetricInput] = createSignal<{ resourceId: string; metric: string } | null>(null); const [activeMetricInput, setActiveMetricInput] = createSignal<{
resourceId: string;
metric: string;
} | null>(null);
const [showDelayRow, setShowDelayRow] = createSignal(false); const [showDelayRow, setShowDelayRow] = createSignal(false);
// Track changes to global defaults and factory defaults for debugging // Track changes to global defaults and factory defaults for debugging
@ -174,12 +185,14 @@ export function ResourceTable(props: ResourceTableProps) {
console.log('[ResourceTable] Missing props, returning false'); console.log('[ResourceTable] Missing props, returning false');
return false; return false;
} }
const result = Object.keys(props.factoryDefaults).some(key => { const result = Object.keys(props.factoryDefaults).some((key) => {
const current = props.globalDefaults?.[key]; const current = props.globalDefaults?.[key];
const factory = props.factoryDefaults?.[key]; const factory = props.factoryDefaults?.[key];
const differs = current !== undefined && current !== factory; const differs = current !== undefined && current !== factory;
if (differs) { if (differs) {
console.log(`[ResourceTable] Difference found: ${key} current=${current} factory=${factory}`); console.log(
`[ResourceTable] Difference found: ${key} current=${current} factory=${factory}`,
);
} }
return differs; return differs;
}); });
@ -189,8 +202,7 @@ export function ResourceTable(props: ResourceTableProps) {
const normalizeMetricKey = (column: string): string => { const normalizeMetricKey = (column: string): string => {
const key = column.trim().toLowerCase(); const key = column.trim().toLowerCase();
const mapped = ( const mapped = new Map<string, string>([
new Map<string, string>([
['cpu %', 'cpu'], ['cpu %', 'cpu'],
['memory %', 'memory'], ['memory %', 'memory'],
['disk %', 'disk'], ['disk %', 'disk'],
@ -207,8 +219,9 @@ export function ResourceTable(props: ResourceTableProps) {
['restart window (s)', 'restartWindow'], ['restart window (s)', 'restartWindow'],
['memory warn %', 'memoryWarnPct'], ['memory warn %', 'memoryWarnPct'],
['memory critical %', 'memoryCriticalPct'], ['memory critical %', 'memoryCriticalPct'],
]) ['warning size (gib)', 'warningSizeGiB'],
).get(key); ['critical size (gib)', 'criticalSizeGiB'],
]).get(key);
if (mapped) { if (mapped) {
return mapped; return mapped;
} }
@ -233,6 +246,9 @@ export function ResourceTable(props: ResourceTableProps) {
if (['cpu', 'memory', 'disk', 'usage', 'memoryWarnPct', 'memoryCriticalPct'].includes(metric)) { if (['cpu', 'memory', 'disk', 'usage', 'memoryWarnPct', 'memoryCriticalPct'].includes(metric)) {
return { min: -1, max: 100 }; return { min: -1, max: 100 };
} }
if (['warningSizeGiB', 'criticalSizeGiB'].includes(metric)) {
return { min: -1, max: 100000 };
}
if (metric === 'restartCount') { if (metric === 'restartCount') {
return { min: -1, max: 50 }; return { min: -1, max: 50 };
} }
@ -246,6 +262,9 @@ export function ResourceTable(props: ResourceTableProps) {
if (['diskRead', 'diskWrite', 'networkIn', 'networkOut'].includes(metric)) { if (['diskRead', 'diskWrite', 'networkIn', 'networkOut'].includes(metric)) {
return 'any'; return 'any';
} }
if (['warningSizeGiB', 'criticalSizeGiB'].includes(metric)) {
return 'any';
}
return 1; return 1;
}; };
@ -280,10 +299,7 @@ export function ResourceTable(props: ResourceTableProps) {
return value; return value;
}; };
const totalColumnCount = () => const totalColumnCount = () => props.columns.length + 3 + (props.showOfflineAlertsColumn ? 1 : 0);
props.columns.length +
3 +
(props.showOfflineAlertsColumn ? 1 : 0);
const getColumnHeaderTooltip = (column: string): string | undefined => { const getColumnHeaderTooltip = (column: string): string | undefined => {
const normalized = column.trim().toLowerCase(); const normalized = column.trim().toLowerCase();
@ -324,9 +340,14 @@ export function ResourceTable(props: ResourceTableProps) {
return ( return (
<div class="flex flex-wrap items-center gap-3"> <div class="flex flex-wrap items-center gap-3">
<Show when={meta.host} fallback={ <Show
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">{meta.displayName || groupKey}</span> when={meta.host}
}> fallback={
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">
{meta.displayName || groupKey}
</span>
}
>
{(host) => ( {(host) => (
<a <a
href={host() as string} href={host() as string}
@ -397,7 +418,10 @@ export function ResourceTable(props: ResourceTableProps) {
const offlineStateOrder: OfflineState[] = ['off', 'warning', 'critical']; const offlineStateOrder: OfflineState[] = ['off', 'warning', 'critical'];
const offlineStateConfig: Record<OfflineState, { label: string; className: string; title: string }> = { const offlineStateConfig: Record<
OfflineState,
{ label: string; className: string; title: string }
> = {
off: { off: {
label: 'Off', label: 'Off',
className: className:
@ -423,7 +447,11 @@ export function ResourceTable(props: ResourceTableProps) {
return offlineStateOrder[(idx + 1) % offlineStateOrder.length]; return offlineStateOrder[(idx + 1) % offlineStateOrder.length];
}; };
const renderOfflineStateButton = (state: OfflineState, disabled: boolean, onToggle: () => void) => { const renderOfflineStateButton = (
state: OfflineState,
disabled: boolean,
onToggle: () => void,
) => {
const config = offlineStateConfig[state]; const config = offlineStateConfig[state];
return ( return (
<button <button
@ -485,10 +513,17 @@ export function ResourceTable(props: ResourceTableProps) {
</thead> </thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700"> <tbody class="divide-y divide-gray-200 dark:divide-gray-700">
{/* Global Defaults Row */} {/* Global Defaults Row */}
<Show when={props.globalDefaults && props.setGlobalDefaults && props.setHasUnsavedChanges}> <Show
<tr class={`bg-gray-50 dark:bg-gray-800/50 border-b border-gray-300 dark:border-gray-600 ${props.globalDisableFlag?.() ? 'opacity-40' : ''}`}> when={props.globalDefaults && props.setGlobalDefaults && props.setHasUnsavedChanges}
>
<tr
class={`bg-gray-50 dark:bg-gray-800/50 border-b border-gray-300 dark:border-gray-600 ${props.globalDisableFlag?.() ? 'opacity-40' : ''}`}
>
<td class="p-1 px-2 text-center align-middle"> <td class="p-1 px-2 text-center align-middle">
<Show when={props.onToggleGlobalDisable} fallback={<span class="text-sm text-gray-400">-</span>}> <Show
when={props.onToggleGlobalDisable}
fallback={<span class="text-sm text-gray-400">-</span>}
>
<div class="flex items-center justify-center"> <div class="flex items-center justify-center">
<TogglePrimitive <TogglePrimitive
size="sm" size="sm"
@ -551,7 +586,11 @@ export function ResourceTable(props: ResourceTableProps) {
? 'border-gray-300 dark:border-gray-600 bg-gray-100 dark:bg-gray-800 text-gray-400 dark:text-gray-500 italic placeholder:text-gray-400 dark:placeholder:text-gray-500 placeholder:opacity-60 pointer-events-none' ? 'border-gray-300 dark:border-gray-600 bg-gray-100 dark:bg-gray-800 text-gray-400 dark:text-gray-500 italic placeholder:text-gray-400 dark:placeholder:text-gray-500 placeholder:opacity-60 pointer-events-none'
: 'border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:border-blue-500 focus:ring-1 focus:ring-blue-500' : 'border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:border-blue-500 focus:ring-1 focus:ring-blue-500'
}`} }`}
title={isOff() ? 'Click to enable this metric' : 'Set to -1 to disable alerts for this metric'} title={
isOff()
? 'Click to enable this metric'
: 'Set to -1 to disable alerts for this metric'
}
/> />
<Show when={isOff()}> <Show when={isOff()}>
<button <button
@ -577,8 +616,13 @@ export function ResourceTable(props: ResourceTableProps) {
</For> </For>
<Show when={props.showOfflineAlertsColumn}> <Show when={props.showOfflineAlertsColumn}>
<td class="p-1 px-2 text-center align-middle"> <td class="p-1 px-2 text-center align-middle">
<Show when={props.onSetGlobalOfflineState} fallback={ <Show
<Show when={props.onToggleGlobalDisableOffline} fallback={<span class="text-sm text-gray-400">-</span>}> when={props.onSetGlobalOfflineState}
fallback={
<Show
when={props.onToggleGlobalDisableOffline}
fallback={<span class="text-sm text-gray-400">-</span>}
>
{(() => { {(() => {
const defaultDisabled = props.globalDisableOfflineFlag?.() ?? false; const defaultDisabled = props.globalDisableOfflineFlag?.() ?? false;
return renderToggleBadge({ return renderToggleBadge({
@ -590,12 +634,15 @@ export function ResourceTable(props: ResourceTableProps) {
}, },
labelEnabled: 'On', labelEnabled: 'On',
labelDisabled: 'Off', labelDisabled: 'Off',
titleEnabled: 'Offline alerts currently enabled by default. Click to disable.', titleEnabled:
titleDisabled: 'Offline alerts currently disabled by default. Click to enable.', 'Offline alerts currently enabled by default. Click to disable.',
titleDisabled:
'Offline alerts currently disabled by default. Click to enable.',
}); });
})()} })()}
</Show> </Show>
}> }
>
{(() => { {(() => {
const disabledGlobally = props.globalDisableFlag?.() ?? false; const disabledGlobally = props.globalDisableFlag?.() ?? false;
const defaultDisabled = props.globalDisableOfflineFlag?.() ?? false; const defaultDisabled = props.globalDisableOfflineFlag?.() ?? false;
@ -617,12 +664,18 @@ export function ResourceTable(props: ResourceTableProps) {
</Show> </Show>
<td class="p-1 px-2 text-center align-middle"> <td class="p-1 px-2 text-center align-middle">
<div class="flex items-center justify-center gap-1"> <div class="flex items-center justify-center gap-1">
<Show when={props.showDelayColumn && typeof props.onMetricDelayChange === 'function'}> <Show
when={
props.showDelayColumn && typeof props.onMetricDelayChange === 'function'
}
>
<button <button
type="button" type="button"
onClick={() => setShowDelayRow(!showDelayRow())} onClick={() => setShowDelayRow(!showDelayRow())}
class="p-1 text-gray-600 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 transition-colors" class="p-1 text-gray-600 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 transition-colors"
title={showDelayRow() ? 'Hide alert delay settings' : 'Show alert delay settings'} title={
showDelayRow() ? 'Hide alert delay settings' : 'Show alert delay settings'
}
> >
<svg <svg
class={`w-4 h-4 transition-transform ${showDelayRow() ? 'rotate-180' : ''}`} class={`w-4 h-4 transition-transform ${showDelayRow() ? 'rotate-180' : ''}`}
@ -646,12 +699,7 @@ export function ResourceTable(props: ResourceTableProps) {
class="p-1 text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 transition-colors" class="p-1 text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 transition-colors"
title="Reset to factory defaults" title="Reset to factory defaults"
> >
<svg <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
class="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path <path
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
@ -668,8 +716,16 @@ export function ResourceTable(props: ResourceTableProps) {
</td> </td>
</tr> </tr>
</Show> </Show>
<Show when={showDelayRow() && props.showDelayColumn && typeof props.onMetricDelayChange === 'function'}> <Show
<tr class={`bg-gray-50 dark:bg-gray-800/50 border-b border-gray-300 dark:border-gray-600 ${props.globalDisableFlag?.() ? 'opacity-40' : ''}`}> when={
showDelayRow() &&
props.showDelayColumn &&
typeof props.onMetricDelayChange === 'function'
}
>
<tr
class={`bg-gray-50 dark:bg-gray-800/50 border-b border-gray-300 dark:border-gray-600 ${props.globalDisableFlag?.() ? 'opacity-40' : ''}`}
>
<td class="p-1 px-2 text-center align-middle"> <td class="p-1 px-2 text-center align-middle">
<span class="text-sm text-gray-400">-</span> <span class="text-sm text-gray-400">-</span>
</td> </td>
@ -772,7 +828,9 @@ export function ResourceTable(props: ResourceTableProps) {
const extract = (source: Record<string, unknown> | undefined) => const extract = (source: Record<string, unknown> | undefined) =>
parseNumeric(source?.[metric]); parseNumeric(source?.[metric]);
const defaults = resource.defaults as Record<string, unknown> | undefined; const defaults = resource.defaults as
| Record<string, unknown>
| undefined;
if (isEditing()) { if (isEditing()) {
const edited = extract(thresholds() as Record<string, unknown>); const edited = extract(thresholds() as Record<string, unknown>);
@ -783,7 +841,9 @@ export function ResourceTable(props: ResourceTableProps) {
return fallback !== undefined ? fallback : 0; return fallback !== undefined ? fallback : 0;
} }
const liveValue = extract(resource.thresholds as Record<string, unknown> | undefined); const liveValue = extract(
resource.thresholds as Record<string, unknown> | undefined,
);
if (liveValue !== undefined) { if (liveValue !== undefined) {
return liveValue; return liveValue;
} }
@ -814,7 +874,10 @@ export function ResourceTable(props: ResourceTableProps) {
size="sm" size="sm"
checked={isChecked} checked={isChecked}
disabled={globallyDisabled} disabled={globallyDisabled}
onToggle={() => !globallyDisabled && props.onToggleDisabled?.(resource.id)} onToggle={() =>
!globallyDisabled &&
props.onToggleDisabled?.(resource.id)
}
class="my-[1px]" class="my-[1px]"
title={ title={
globallyDisabled globallyDisabled
@ -823,7 +886,11 @@ export function ResourceTable(props: ResourceTableProps) {
? 'Click to enable alerts' ? 'Click to enable alerts'
: 'Click to disable alerts' : 'Click to disable alerts'
} }
ariaLabel={isChecked ? 'Alerts enabled for this resource' : 'Alerts disabled for this resource'} ariaLabel={
isChecked
? 'Alerts enabled for this resource'
: 'Alerts disabled for this resource'
}
/> />
</div> </div>
); );
@ -842,7 +909,10 @@ export function ResourceTable(props: ResourceTableProps) {
</span> </span>
} }
> >
<div class="flex flex-wrap items-center gap-3" title={resource.status || undefined}> <div
class="flex flex-wrap items-center gap-3"
title={resource.status || undefined}
>
<Show <Show
when={resource.host} when={resource.host}
fallback={ fallback={
@ -897,7 +967,8 @@ export function ResourceTable(props: ResourceTableProps) {
<For each={props.columns}> <For each={props.columns}>
{(column) => { {(column) => {
const metric = normalizeMetricKey(column); const metric = normalizeMetricKey(column);
const showMetric = () => resourceSupportsMetric(resource.type, metric); const showMetric = () =>
resourceSupportsMetric(resource.type, metric);
const bounds = metricBounds(metric); const bounds = metricBounds(metric);
const isDisabled = () => thresholds()?.[metric] === -1; const isDisabled = () => thresholds()?.[metric] === -1;
@ -1005,11 +1076,14 @@ export function ResourceTable(props: ResourceTableProps) {
const disabledGlobally = props.globalDisableFlag?.() ?? false; const disabledGlobally = props.globalDisableFlag?.() ?? false;
const supportsTriState = const supportsTriState =
typeof props.onSetOfflineState === 'function' && typeof props.onSetOfflineState === 'function' &&
(resource.type === 'guest' || resource.type === 'dockerContainer'); (resource.type === 'guest' ||
resource.type === 'dockerContainer');
if (supportsTriState) { if (supportsTriState) {
const defaultDisabled = props.globalDisableOfflineFlag?.() ?? false; const defaultDisabled =
const defaultSeverity = props.globalOfflineSeverity ?? 'warning'; props.globalDisableOfflineFlag?.() ?? false;
const defaultSeverity =
props.globalOfflineSeverity ?? 'warning';
let state: OfflineState; let state: OfflineState;
if (resource.disableConnectivity) { if (resource.disableConnectivity) {
@ -1019,30 +1093,39 @@ export function ResourceTable(props: ResourceTableProps) {
} else if (defaultDisabled) { } else if (defaultDisabled) {
state = 'off'; state = 'off';
} else { } else {
state = defaultSeverity === 'critical' ? 'critical' : 'warning'; state =
defaultSeverity === 'critical' ? 'critical' : 'warning';
} }
return renderOfflineStateButton(state, disabledGlobally, () => { return renderOfflineStateButton(
state,
disabledGlobally,
() => {
if (disabledGlobally) return; if (disabledGlobally) return;
const next = nextOfflineState(state); const next = nextOfflineState(state);
props.onSetOfflineState?.(resource.id, next); props.onSetOfflineState?.(resource.id, next);
}); },
);
} }
if (!props.onToggleNodeConnectivity) { if (!props.onToggleNodeConnectivity) {
return <span class="text-sm text-gray-400">-</span>; return <span class="text-sm text-gray-400">-</span>;
} }
const globalOfflineDisabled = props.globalDisableOfflineFlag?.() ?? false; const globalOfflineDisabled =
props.globalDisableOfflineFlag?.() ?? false;
return renderToggleBadge({ return renderToggleBadge({
isEnabled: !globalOfflineDisabled && !resource.disableConnectivity, isEnabled:
!globalOfflineDisabled && !resource.disableConnectivity,
disabled: disabledGlobally, disabled: disabledGlobally,
onToggle: () => { onToggle: () => {
if (disabledGlobally) return; if (disabledGlobally) return;
props.onToggleNodeConnectivity?.(resource.id); props.onToggleNodeConnectivity?.(resource.id);
}, },
titleEnabled: 'Offline alerts enabled. Click to disable for this resource.', titleEnabled:
titleDisabled: 'Offline alerts disabled. Click to enable for this resource.', 'Offline alerts enabled. Click to disable for this resource.',
titleDisabled:
'Offline alerts disabled. Click to enable for this resource.',
titleWhenDisabled: 'Offline alerts controlled globally', titleWhenDisabled: 'Offline alerts controlled globally',
}); });
})()} })()}
@ -1111,7 +1194,8 @@ export function ResourceTable(props: ResourceTableProps) {
<Show <Show
when={ when={
resource.hasOverride || resource.hasOverride ||
((resource.type === 'node' || resource.type === 'dockerHost') && ((resource.type === 'node' ||
resource.type === 'dockerHost') &&
resource.disableConnectivity) resource.disableConnectivity)
} }
> >
@ -1183,7 +1267,9 @@ export function ResourceTable(props: ResourceTableProps) {
return fallback !== undefined ? fallback : 0; return fallback !== undefined ? fallback : 0;
} }
const liveValue = extract(resource.thresholds as Record<string, unknown> | undefined); const liveValue = extract(
resource.thresholds as Record<string, unknown> | undefined,
);
if (liveValue !== undefined) { if (liveValue !== undefined) {
return liveValue; return liveValue;
} }
@ -1214,7 +1300,9 @@ export function ResourceTable(props: ResourceTableProps) {
size="sm" size="sm"
checked={isChecked} checked={isChecked}
disabled={globallyDisabled} disabled={globallyDisabled}
onToggle={() => !globallyDisabled && props.onToggleDisabled?.(resource.id)} onToggle={() =>
!globallyDisabled && props.onToggleDisabled?.(resource.id)
}
class="my-[1px]" class="my-[1px]"
title={ title={
globallyDisabled globallyDisabled
@ -1223,7 +1311,11 @@ export function ResourceTable(props: ResourceTableProps) {
? 'Click to enable alerts' ? 'Click to enable alerts'
: 'Click to disable alerts' : 'Click to disable alerts'
} }
ariaLabel={isChecked ? 'Alerts enabled for this resource' : 'Alerts disabled for this resource'} ariaLabel={
isChecked
? 'Alerts enabled for this resource'
: 'Alerts disabled for this resource'
}
/> />
</div> </div>
); );
@ -1231,7 +1323,9 @@ export function ResourceTable(props: ResourceTableProps) {
</Show> </Show>
</td> </td>
<td class="p-1 px-2"> <td class="p-1 px-2">
<Show when={resource.type === 'node'} fallback={ <Show
when={resource.type === 'node'}
fallback={
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span <span
class={`text-sm font-medium ${resource.disabled ? 'text-gray-500 dark:text-gray-500' : 'text-gray-900 dark:text-gray-100'}`} class={`text-sm font-medium ${resource.disabled ? 'text-gray-500 dark:text-gray-500' : 'text-gray-900 dark:text-gray-100'}`}
@ -1244,9 +1338,15 @@ export function ResourceTable(props: ResourceTableProps) {
</span> </span>
</Show> </Show>
</div> </div>
}> }
<div class="flex flex-wrap items-center gap-3" title={resource.status || undefined}> >
<Show when={resource.host} fallback={ <div
class="flex flex-wrap items-center gap-3"
title={resource.status || undefined}
>
<Show
when={resource.host}
fallback={
<span <span
class={`text-sm font-medium ${resource.disabled ? 'text-gray-500 dark:text-gray-500' : 'text-gray-900 dark:text-gray-100'}`} class={`text-sm font-medium ${resource.disabled ? 'text-gray-500 dark:text-gray-500' : 'text-gray-900 dark:text-gray-100'}`}
> >
@ -1254,7 +1354,8 @@ export function ResourceTable(props: ResourceTableProps) {
? resource.name ? resource.name
: resource.displayName || resource.name} : resource.displayName || resource.name}
</span> </span>
}> }
>
{(host) => ( {(host) => (
<a <a
href={host() as string} href={host() as string}
@ -1424,8 +1525,11 @@ export function ResourceTable(props: ResourceTableProps) {
<td class="p-1 px-2 text-center align-middle"> <td class="p-1 px-2 text-center align-middle">
<Show when={props.onToggleNodeConnectivity}> <Show when={props.onToggleNodeConnectivity}>
{(() => { {(() => {
const defaultOfflineDisabled = props.globalDisableOfflineFlag?.() ?? false; const defaultOfflineDisabled =
const isEnabled = !(resource.disableConnectivity || defaultOfflineDisabled); props.globalDisableOfflineFlag?.() ?? false;
const isEnabled = !(
resource.disableConnectivity || defaultOfflineDisabled
);
const disabledGlobally = props.globalDisableFlag?.() ?? false; const disabledGlobally = props.globalDisableFlag?.() ?? false;
return ( return (
<StatusBadge <StatusBadge
@ -1455,8 +1559,12 @@ export function ResourceTable(props: ResourceTableProps) {
/> />
</Show> </Show>
<Show <Show
when={resource.editable !== false && typeof props.onEdit === 'function'} when={
fallback={<span class="text-xs text-gray-400 dark:text-gray-600"></span>} resource.editable !== false && typeof props.onEdit === 'function'
}
fallback={
<span class="text-xs text-gray-400 dark:text-gray-600"></span>
}
> >
<Show <Show
when={!isEditing()} when={!isEditing()}

View file

@ -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' ||
@ -410,7 +440,10 @@ const getFriendlyNodeName = (value: string, clusterName?: string): string => {
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
.replace(/\(.*?\)/g, ' ')
.replace(/\s+/g, ' ')
.trim();
if (clusterLower) { if (clusterLower) {
result = result result = result
.split(' ') .split(' ')
@ -420,7 +453,9 @@ const getFriendlyNodeName = (value: string, clusterName?: string): string => {
} }
if (!result) return ''; if (!result) return '';
const firstWord = result.split(/\s+/)[0] || result; const firstWord = result.split(/\s+/)[0] || result;
const withoutDomain = firstWord.includes('.') ? firstWord.split('.')[0] ?? firstWord : firstWord; const withoutDomain = firstWord.includes('.')
? (firstWord.split('.')[0] ?? firstWord)
: firstWord;
return withoutDomain.trim(); return withoutDomain.trim();
}; };
@ -496,14 +531,14 @@ 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.startsWith('http://') || hostValue.startsWith('https://')
? hostValue ? hostValue
: `https://${hostValue.includes(':') ? hostValue : `${hostValue}:8006`}`; : `https://${hostValue.includes(':') ? hostValue : `${hostValue}:8006`}`;
@ -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 =
@ -892,16 +954,19 @@ 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.enabled !== factory.enabled ||
(current.warningDays ?? DEFAULT_SNAPSHOT_WARNING) !== (current.warningDays ?? DEFAULT_SNAPSHOT_WARNING) !==
(factory.warningDays ?? DEFAULT_SNAPSHOT_WARNING) || (factory.warningDays ?? DEFAULT_SNAPSHOT_WARNING) ||
(current.criticalDays ?? DEFAULT_SNAPSHOT_CRITICAL) !== (current.criticalDays ?? DEFAULT_SNAPSHOT_CRITICAL) !==
(factory.criticalDays ?? DEFAULT_SNAPSHOT_CRITICAL) (factory.criticalDays ?? DEFAULT_SNAPSHOT_CRITICAL) ||
? 1 (current.warningSizeGiB ?? DEFAULT_SNAPSHOT_WARNING_SIZE) !==
: 0; (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();
const backupFactory = backupFactoryConfig(); const backupFactory = backupFactoryConfig();
@ -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,8 +1146,7 @@ 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;
}); });
@ -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())

View file

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

View file

@ -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',
'backupTime',
{
deserialize: (raw) => deserialize: (raw) =>
BACKUP_SORT_KEY_VALUES.includes(raw as BackupSortKey) BACKUP_SORT_KEY_VALUES.includes(raw as BackupSortKey) ? (raw as BackupSortKey) : 'backupTime',
? (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(
availableBackupsTooltipText,
rect.left + rect.width / 2,
rect.top,
{
align: 'center', align: 'center',
direction: 'up', 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(
availableBackupsTooltipText,
rect.left + rect.width / 2,
rect.top,
{
align: 'center', align: 'center',
direction: 'up', 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"

View file

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

View file

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

View file

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

View file

@ -297,6 +297,8 @@ 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
@ -516,6 +518,8 @@ func NewManager() *Manager {
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,32 +3093,60 @@ 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
alertThreshold := float64(ageThreshold)
thresholdTime := now
if useSizePrimary {
alertValue = sizeGiB
alertThreshold = sizeThreshold
} else if ageThreshold > 0 {
thresholdTime = snapshot.Time.Add(time.Duration(ageThreshold) * 24 * time.Hour)
if thresholdTime.After(now) { if thresholdTime.After(now) {
thresholdTime = now thresholdTime = now
} }
}
metadata := map[string]interface{}{ metadata := map[string]interface{}{
"snapshotName": snapshot.Name, "snapshotName": snapshot.Name,
"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,

View file

@ -224,6 +224,8 @@ func TestCheckSnapshotsForInstanceCreatesAndClearsAlerts(t *testing.T) {
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),
} }
@ -243,6 +245,7 @@ func TestCheckSnapshotsForInstanceCreatesAndClearsAlerts(t *testing.T) {
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()

View file

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

View file

@ -215,6 +215,21 @@ func (c *ConfigPersistence) SaveAlertConfig(config alerts.AlertConfig) error {
if config.SnapshotDefaults.CriticalDays > 0 && config.SnapshotDefaults.WarningDays > config.SnapshotDefaults.CriticalDays { 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
} }
@ -280,6 +295,8 @@ func (c *ConfigPersistence) LoadAlertConfig() (*alerts.AlertConfig, error) {
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
} }

View file

@ -148,6 +148,8 @@ func TestLoadAlertConfigAppliesDefaults(t *testing.T) {
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) {

View file

@ -2515,6 +2515,7 @@ func generateSnapshots(vms []models.VM, containers []models.Container) []models.
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
@ -2547,6 +2548,7 @@ func generateSnapshots(vms []models.VM, containers []models.Container) []models.
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)

View file

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

View file

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

View file

@ -0,0 +1,122 @@
package monitoring
import (
"context"
"testing"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/rcourtman/pulse-go-rewrite/pkg/proxmox"
)
type fakeSnapshotClient struct {
storages map[string][]proxmox.Storage
contents map[string]map[string][]proxmox.StorageContent
}
func (f fakeSnapshotClient) GetNodes(ctx context.Context) ([]proxmox.Node, error) { return nil, nil }
func (f fakeSnapshotClient) GetNodeStatus(ctx context.Context, node string) (*proxmox.NodeStatus, error) {
return nil, nil
}
func (f fakeSnapshotClient) GetNodeRRDData(ctx context.Context, node string, timeframe string, cf string, ds []string) ([]proxmox.NodeRRDPoint, error) {
return nil, nil
}
func (f fakeSnapshotClient) GetVMs(ctx context.Context, node string) ([]proxmox.VM, error) {
return nil, nil
}
func (f fakeSnapshotClient) GetContainers(ctx context.Context, node string) ([]proxmox.Container, error) {
return nil, nil
}
func (f fakeSnapshotClient) GetStorage(ctx context.Context, node string) ([]proxmox.Storage, error) {
return f.storages[node], nil
}
func (f fakeSnapshotClient) GetAllStorage(ctx context.Context) ([]proxmox.Storage, error) {
return nil, nil
}
func (f fakeSnapshotClient) GetBackupTasks(ctx context.Context) ([]proxmox.Task, error) {
return nil, nil
}
func (f fakeSnapshotClient) GetStorageContent(ctx context.Context, node, storage string) ([]proxmox.StorageContent, error) {
if storageContents, ok := f.contents[node]; ok {
return storageContents[storage], nil
}
return nil, nil
}
func (f fakeSnapshotClient) GetVMSnapshots(ctx context.Context, node string, vmid int) ([]proxmox.Snapshot, error) {
return nil, nil
}
func (f fakeSnapshotClient) GetContainerSnapshots(ctx context.Context, node string, vmid int) ([]proxmox.Snapshot, error) {
return nil, nil
}
func (f fakeSnapshotClient) GetVMStatus(ctx context.Context, node string, vmid int) (*proxmox.VMStatus, error) {
return nil, nil
}
func (f fakeSnapshotClient) GetContainerStatus(ctx context.Context, node string, vmid int) (*proxmox.Container, error) {
return nil, nil
}
func (f fakeSnapshotClient) GetClusterResources(ctx context.Context, resourceType string) ([]proxmox.ClusterResource, error) {
return nil, nil
}
func (f fakeSnapshotClient) IsClusterMember(ctx context.Context) (bool, error) { return false, nil }
func (f fakeSnapshotClient) GetVMFSInfo(ctx context.Context, node string, vmid int) ([]proxmox.VMFileSystem, error) {
return nil, nil
}
func (f fakeSnapshotClient) GetVMNetworkInterfaces(ctx context.Context, node string, vmid int) ([]proxmox.VMNetworkInterface, error) {
return nil, nil
}
func (f fakeSnapshotClient) GetVMAgentInfo(ctx context.Context, node string, vmid int) (map[string]interface{}, error) {
return map[string]interface{}{}, nil
}
func (f fakeSnapshotClient) GetZFSPoolStatus(ctx context.Context, node string) ([]proxmox.ZFSPoolStatus, error) {
return nil, nil
}
func (f fakeSnapshotClient) GetZFSPoolsWithDetails(ctx context.Context, node string) ([]proxmox.ZFSPoolInfo, error) {
return nil, nil
}
func (f fakeSnapshotClient) GetDisks(ctx context.Context, node string) ([]proxmox.Disk, error) {
return nil, nil
}
func (f fakeSnapshotClient) GetCephStatus(ctx context.Context) (*proxmox.CephStatus, error) {
return nil, nil
}
func (f fakeSnapshotClient) GetCephDF(ctx context.Context) (*proxmox.CephDF, error) { return nil, nil }
func TestCollectSnapshotSizes(t *testing.T) {
m := &Monitor{}
snapshots := []models.GuestSnapshot{
{
ID: "inst-node1-100-pre",
Name: "pre",
Node: "node1",
Instance: "inst",
Type: "qemu",
VMID: 100,
},
}
client := fakeSnapshotClient{
storages: map[string][]proxmox.Storage{
"node1": {
{Storage: "local-zfs", Content: "images"},
},
},
contents: map[string]map[string][]proxmox.StorageContent{
"node1": {
"local-zfs": {
{Volid: "local-zfs:vm-100-disk-0@pre", VMID: 100, Size: 20 << 30},
// Duplicate entry should be deduped via volid tracking
{Volid: "local-zfs:vm-100-disk-0@pre", VMID: 100, Size: 20 << 30},
},
},
},
}
sizes := m.collectSnapshotSizes(context.Background(), "inst", client, snapshots)
got, ok := sizes[snapshots[0].ID]
if !ok {
t.Fatalf("expected size entry for snapshot")
}
want := int64(20 << 30)
if got != want {
t.Fatalf("unexpected size: got %d want %d", got, want)
}
}

View file

@ -3,6 +3,7 @@ package notifications
import ( 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,11 +208,29 @@ 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.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 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"`
Mode AppriseMode `json:"mode,omitempty"`
Targets []string `json:"targets"` Targets []string `json:"targets"`
CLIPath string `json:"cliPath,omitempty"` CLIPath string `json:"cliPath,omitempty"`
TimeoutSeconds int `json:"timeoutSeconds,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)

View file

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