Add snapshot size alert thresholds (#585)
This commit is contained in:
parent
30879c3b7b
commit
be26f957c0
20 changed files with 2306 additions and 874 deletions
|
|
@ -320,11 +320,14 @@ Quick start - most settings are in the web UI:
|
||||||
- **Settings → Security**: Authentication and API tokens
|
- **Settings → Security**: Authentication and API tokens
|
||||||
- **Alerts**: Thresholds and notifications
|
- **Alerts**: Thresholds and notifications
|
||||||
|
|
||||||
### Apprise CLI Notifications
|
### Apprise Notifications
|
||||||
|
|
||||||
Pulse can broadcast grouped alerts through the [Apprise](https://github.com/caronc/apprise) CLI. Install Apprise on the Pulse host (for example with `pip install apprise`) and configure the targets under **Alerts → Notifications**. Each target URL should be a valid Apprise destination (Discord, Slack, email, SMS, etc.).
|
Pulse can broadcast grouped alerts through [Apprise](https://github.com/caronc/apprise) using either the local CLI or a remote Apprise API gateway. Configure everything under **Alerts → Notifications → Apprise**.
|
||||||
|
|
||||||
You can also override the CLI path and execution timeout if Apprise is installed in a non-standard location. Pulse automatically skips Apprise delivery when no targets are configured.
|
- **Local CLI** – Install Apprise on the Pulse host (for example `pip install apprise`) and enter one Apprise URL per line in the delivery targets field. You can override the CLI path and timeout if the executable lives outside of `$PATH`. Pulse skips CLI delivery automatically when no targets are configured.
|
||||||
|
- **Remote API** – Point Pulse at an Apprise API server by providing the base URL (such as `https://apprise-api.local:8000`). Optionally include a configuration key (for `/notify/{key}` routes), an API key header/value pair, and allow self-signed certificates for lab deployments. Targets remain optional in API mode—leave the list empty to let the Apprise server use its stored defaults.
|
||||||
|
|
||||||
|
For both modes, delivery targets accept any Apprise URL (Discord, Slack, email, SMS, etc.). The timeout applies to the CLI process or HTTP request respectively.
|
||||||
|
|
||||||
### Configuration Files
|
### Configuration Files
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -59,9 +59,15 @@ export interface Webhook {
|
||||||
|
|
||||||
export interface AppriseConfig {
|
export interface AppriseConfig {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
targets: string[];
|
mode?: 'cli' | 'http';
|
||||||
|
targets?: string[];
|
||||||
cliPath?: string;
|
cliPath?: string;
|
||||||
timeoutSeconds?: number;
|
timeoutSeconds?: number;
|
||||||
|
serverUrl?: string;
|
||||||
|
configKey?: string;
|
||||||
|
apiKey?: string;
|
||||||
|
apiKeyHeader?: string;
|
||||||
|
skipTlsVerify?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NotificationTestRequest {
|
export interface NotificationTestRequest {
|
||||||
|
|
|
||||||
|
|
@ -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()}
|
||||||
|
|
|
||||||
|
|
@ -19,17 +19,15 @@ import type {
|
||||||
PMGBackup,
|
PMGBackup,
|
||||||
Backups,
|
Backups,
|
||||||
} from '@/types/api';
|
} from '@/types/api';
|
||||||
import type { RawOverrideConfig, PMGThresholdDefaults, SnapshotAlertConfig, BackupAlertConfig } from '@/types/alerts';
|
import type {
|
||||||
|
RawOverrideConfig,
|
||||||
|
PMGThresholdDefaults,
|
||||||
|
SnapshotAlertConfig,
|
||||||
|
BackupAlertConfig,
|
||||||
|
} from '@/types/alerts';
|
||||||
import { ResourceTable, Resource, GroupHeaderMeta } from './ResourceTable';
|
import { ResourceTable, Resource, GroupHeaderMeta } from './ResourceTable';
|
||||||
import { useAlertsActivation } from '@/stores/alertsActivation';
|
import { useAlertsActivation } from '@/stores/alertsActivation';
|
||||||
type OverrideType =
|
type OverrideType = 'guest' | 'node' | 'storage' | 'pbs' | 'pmg' | 'dockerHost' | 'dockerContainer';
|
||||||
| 'guest'
|
|
||||||
| 'node'
|
|
||||||
| 'storage'
|
|
||||||
| 'pbs'
|
|
||||||
| 'pmg'
|
|
||||||
| 'dockerHost'
|
|
||||||
| 'dockerContainer';
|
|
||||||
|
|
||||||
type OfflineState = 'off' | 'warning' | 'critical';
|
type OfflineState = 'off' | 'warning' | 'critical';
|
||||||
|
|
||||||
|
|
@ -110,6 +108,8 @@ export const normalizeDockerIgnoredInput = (value: string): string[] =>
|
||||||
|
|
||||||
const DEFAULT_SNAPSHOT_WARNING = 30;
|
const DEFAULT_SNAPSHOT_WARNING = 30;
|
||||||
const DEFAULT_SNAPSHOT_CRITICAL = 45;
|
const DEFAULT_SNAPSHOT_CRITICAL = 45;
|
||||||
|
const DEFAULT_SNAPSHOT_WARNING_SIZE = 0;
|
||||||
|
const DEFAULT_SNAPSHOT_CRITICAL_SIZE = 0;
|
||||||
const DEFAULT_BACKUP_WARNING = 7;
|
const DEFAULT_BACKUP_WARNING = 7;
|
||||||
const DEFAULT_BACKUP_CRITICAL = 14;
|
const DEFAULT_BACKUP_CRITICAL = 14;
|
||||||
|
|
||||||
|
|
@ -143,13 +143,13 @@ interface ThresholdsTableProps {
|
||||||
pmgBackups?: PMGBackup[];
|
pmgBackups?: PMGBackup[];
|
||||||
pmgThresholds: () => PMGThresholdDefaults;
|
pmgThresholds: () => PMGThresholdDefaults;
|
||||||
setPMGThresholds: (
|
setPMGThresholds: (
|
||||||
value:
|
value: PMGThresholdDefaults | ((prev: PMGThresholdDefaults) => PMGThresholdDefaults),
|
||||||
| PMGThresholdDefaults
|
|
||||||
| ((prev: PMGThresholdDefaults) => PMGThresholdDefaults),
|
|
||||||
) => void;
|
) => void;
|
||||||
guestDefaults: SimpleThresholds;
|
guestDefaults: SimpleThresholds;
|
||||||
setGuestDefaults: (
|
setGuestDefaults: (
|
||||||
value: Record<string, number | undefined> | ((prev: Record<string, number | undefined>) => Record<string, number | undefined>),
|
value:
|
||||||
|
| Record<string, number | undefined>
|
||||||
|
| ((prev: Record<string, number | undefined>) => Record<string, number | undefined>),
|
||||||
) => void;
|
) => void;
|
||||||
guestDisableConnectivity: () => boolean;
|
guestDisableConnectivity: () => boolean;
|
||||||
setGuestDisableConnectivity: (value: boolean) => void;
|
setGuestDisableConnectivity: (value: boolean) => void;
|
||||||
|
|
@ -157,11 +157,43 @@ interface ThresholdsTableProps {
|
||||||
setGuestPoweredOffSeverity: (value: 'warning' | 'critical') => void;
|
setGuestPoweredOffSeverity: (value: 'warning' | 'critical') => void;
|
||||||
nodeDefaults: SimpleThresholds;
|
nodeDefaults: SimpleThresholds;
|
||||||
setNodeDefaults: (
|
setNodeDefaults: (
|
||||||
value: Record<string, number | undefined> | ((prev: Record<string, number | undefined>) => Record<string, number | undefined>),
|
value:
|
||||||
|
| Record<string, number | undefined>
|
||||||
|
| ((prev: Record<string, number | undefined>) => Record<string, number | undefined>),
|
||||||
) => void;
|
) => void;
|
||||||
dockerDefaults: { cpu: number; memory: number; restartCount: number; restartWindow: number; memoryWarnPct: number; memoryCriticalPct: number };
|
dockerDefaults: {
|
||||||
|
cpu: number;
|
||||||
|
memory: number;
|
||||||
|
restartCount: number;
|
||||||
|
restartWindow: number;
|
||||||
|
memoryWarnPct: number;
|
||||||
|
memoryCriticalPct: number;
|
||||||
|
};
|
||||||
setDockerDefaults: (
|
setDockerDefaults: (
|
||||||
value: { cpu: number; memory: number; restartCount: number; restartWindow: number; memoryWarnPct: number; memoryCriticalPct: number } | ((prev: { cpu: number; memory: number; restartCount: number; restartWindow: number; memoryWarnPct: number; memoryCriticalPct: number }) => { cpu: number; memory: number; restartCount: number; restartWindow: number; memoryWarnPct: number; memoryCriticalPct: number }),
|
value:
|
||||||
|
| {
|
||||||
|
cpu: number;
|
||||||
|
memory: number;
|
||||||
|
restartCount: number;
|
||||||
|
restartWindow: number;
|
||||||
|
memoryWarnPct: number;
|
||||||
|
memoryCriticalPct: number;
|
||||||
|
}
|
||||||
|
| ((prev: {
|
||||||
|
cpu: number;
|
||||||
|
memory: number;
|
||||||
|
restartCount: number;
|
||||||
|
restartWindow: number;
|
||||||
|
memoryWarnPct: number;
|
||||||
|
memoryCriticalPct: number;
|
||||||
|
}) => {
|
||||||
|
cpu: number;
|
||||||
|
memory: number;
|
||||||
|
restartCount: number;
|
||||||
|
restartWindow: number;
|
||||||
|
memoryWarnPct: number;
|
||||||
|
memoryCriticalPct: number;
|
||||||
|
}),
|
||||||
) => void;
|
) => void;
|
||||||
dockerIgnoredPrefixes: () => string[];
|
dockerIgnoredPrefixes: () => string[];
|
||||||
setDockerIgnoredPrefixes: (value: string[] | ((prev: string[]) => string[])) => void;
|
setDockerIgnoredPrefixes: (value: string[] | ((prev: string[]) => string[])) => void;
|
||||||
|
|
@ -185,17 +217,13 @@ interface ThresholdsTableProps {
|
||||||
) => void;
|
) => void;
|
||||||
snapshotDefaults: () => SnapshotAlertConfig;
|
snapshotDefaults: () => SnapshotAlertConfig;
|
||||||
setSnapshotDefaults: (
|
setSnapshotDefaults: (
|
||||||
value:
|
value: SnapshotAlertConfig | ((prev: SnapshotAlertConfig) => SnapshotAlertConfig),
|
||||||
| SnapshotAlertConfig
|
|
||||||
| ((prev: SnapshotAlertConfig) => SnapshotAlertConfig),
|
|
||||||
) => void;
|
) => void;
|
||||||
snapshotFactoryDefaults?: SnapshotAlertConfig;
|
snapshotFactoryDefaults?: SnapshotAlertConfig;
|
||||||
resetSnapshotDefaults?: () => void;
|
resetSnapshotDefaults?: () => void;
|
||||||
backupDefaults: () => BackupAlertConfig;
|
backupDefaults: () => BackupAlertConfig;
|
||||||
setBackupDefaults: (
|
setBackupDefaults: (
|
||||||
value:
|
value: BackupAlertConfig | ((prev: BackupAlertConfig) => BackupAlertConfig),
|
||||||
| BackupAlertConfig
|
|
||||||
| ((prev: BackupAlertConfig) => BackupAlertConfig),
|
|
||||||
) => void;
|
) => void;
|
||||||
backupFactoryDefaults?: BackupAlertConfig;
|
backupFactoryDefaults?: BackupAlertConfig;
|
||||||
resetBackupDefaults?: () => void;
|
resetBackupDefaults?: () => void;
|
||||||
|
|
@ -306,10 +334,7 @@ export function ThresholdsTable(props: ThresholdsTableProps) {
|
||||||
if (!el) return false;
|
if (!el) return false;
|
||||||
const tag = el.tagName;
|
const tag = el.tagName;
|
||||||
return (
|
return (
|
||||||
tag === 'INPUT' ||
|
tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || el.contentEditable === 'true'
|
||||||
tag === 'TEXTAREA' ||
|
|
||||||
tag === 'SELECT' ||
|
|
||||||
el.contentEditable === 'true'
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -381,6 +406,11 @@ export function ThresholdsTable(props: ThresholdsTableProps) {
|
||||||
return String(value);
|
return String(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (metric === 'warningSizeGiB' || metric === 'criticalSizeGiB') {
|
||||||
|
const rounded = Math.round(value * 10) / 10;
|
||||||
|
return `${rounded} GiB`;
|
||||||
|
}
|
||||||
|
|
||||||
// MB/s metrics
|
// MB/s metrics
|
||||||
if (
|
if (
|
||||||
metric === 'diskRead' ||
|
metric === 'diskRead' ||
|
||||||
|
|
@ -403,14 +433,17 @@ export function ThresholdsTable(props: ThresholdsTableProps) {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Process nodes with their overrides
|
// Process nodes with their overrides
|
||||||
const getFriendlyNodeName = (value: string, clusterName?: string): string => {
|
const getFriendlyNodeName = (value: string, clusterName?: string): string => {
|
||||||
if (!value) return value;
|
if (!value) return value;
|
||||||
|
|
||||||
const clusterLower = clusterName?.toLowerCase().trim();
|
const clusterLower = clusterName?.toLowerCase().trim();
|
||||||
|
|
||||||
const normalizeToken = (token?: string | null): string => {
|
const normalizeToken = (token?: string | null): string => {
|
||||||
if (!token) return '';
|
if (!token) return '';
|
||||||
let result = token.replace(/\(.*?\)/g, ' ').replace(/\s+/g, ' ').trim();
|
let result = token
|
||||||
|
.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();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -438,9 +473,9 @@ const getFriendlyNodeName = (value: string, clusterName?: string): string => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return base;
|
return base;
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildNodeHeaderMeta = (node: Node) => {
|
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 hostValue = node.host?.trim();
|
const hostValue = node.host?.trim();
|
||||||
|
|
@ -471,7 +506,7 @@ const buildNodeHeaderMeta = (node: Node) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
return { headerMeta, keys };
|
return { headerMeta, keys };
|
||||||
};
|
};
|
||||||
|
|
||||||
const nodesWithOverrides = createMemo<Resource[]>((prev = []) => {
|
const nodesWithOverrides = createMemo<Resource[]>((prev = []) => {
|
||||||
// If we're currently editing, return the previous value to avoid re-renders
|
// If we're currently editing, return the previous value to avoid re-renders
|
||||||
|
|
@ -496,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`}`;
|
||||||
|
|
||||||
|
|
@ -602,7 +637,7 @@ const buildNodeHeaderMeta = (node: Node) => {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Process Docker containers grouped by host
|
// Process Docker containers grouped by host
|
||||||
const dockerContainersGroupedByHost = createMemo<Record<string, Resource[]>>((prev = {}) => {
|
const dockerContainersGroupedByHost = createMemo<Record<string, Resource[]>>((prev = {}) => {
|
||||||
if (editingId()) {
|
if (editingId()) {
|
||||||
return prev;
|
return prev;
|
||||||
}
|
}
|
||||||
|
|
@ -646,9 +681,12 @@ const dockerContainersGroupedByHost = createMemo<Record<string, Resource[]>>((pr
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
const hasOverride =
|
const hasOverride =
|
||||||
hasCustomThresholds || override?.disabled || override?.disableConnectivity || overrideSeverity !== undefined || false;
|
hasCustomThresholds ||
|
||||||
|
override?.disabled ||
|
||||||
|
override?.disableConnectivity ||
|
||||||
|
overrideSeverity !== undefined ||
|
||||||
|
false;
|
||||||
|
|
||||||
const containerName = normalizeContainerName(container);
|
const containerName = normalizeContainerName(container);
|
||||||
const containerNameLower = containerName.toLowerCase();
|
const containerNameLower = containerName.toLowerCase();
|
||||||
|
|
@ -770,8 +808,9 @@ const dockerContainersGroupedByHost = createMemo<Record<string, Resource[]>>((pr
|
||||||
});
|
});
|
||||||
|
|
||||||
const countOverrides = (resources: Resource[] | undefined) =>
|
const countOverrides = (resources: Resource[] | undefined) =>
|
||||||
resources?.filter((resource) => resource.hasOverride || resource.disabled || resource.disableConnectivity)
|
resources?.filter(
|
||||||
.length ?? 0;
|
(resource) => resource.hasOverride || resource.disabled || resource.disableConnectivity,
|
||||||
|
).length ?? 0;
|
||||||
|
|
||||||
const registerSection = (_key: string) => (_el: HTMLDivElement | null) => {
|
const registerSection = (_key: string) => (_el: HTMLDivElement | null) => {
|
||||||
/* no-op placeholder for future scroll restoration */
|
/* no-op placeholder for future scroll restoration */
|
||||||
|
|
@ -782,6 +821,8 @@ const dockerContainersGroupedByHost = createMemo<Record<string, Resource[]>>((pr
|
||||||
enabled: false,
|
enabled: false,
|
||||||
warningDays: DEFAULT_SNAPSHOT_WARNING,
|
warningDays: DEFAULT_SNAPSHOT_WARNING,
|
||||||
criticalDays: DEFAULT_SNAPSHOT_CRITICAL,
|
criticalDays: DEFAULT_SNAPSHOT_CRITICAL,
|
||||||
|
warningSizeGiB: DEFAULT_SNAPSHOT_WARNING_SIZE,
|
||||||
|
criticalSizeGiB: DEFAULT_SNAPSHOT_CRITICAL_SIZE,
|
||||||
};
|
};
|
||||||
|
|
||||||
const sanitizeSnapshotConfig = (config: SnapshotAlertConfig): SnapshotAlertConfig => {
|
const sanitizeSnapshotConfig = (config: SnapshotAlertConfig): SnapshotAlertConfig => {
|
||||||
|
|
@ -795,17 +836,36 @@ const dockerContainersGroupedByHost = createMemo<Record<string, Resource[]>>((pr
|
||||||
critical = warning;
|
critical = warning;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rawWarningSize = Number.isFinite(config.warningSizeGiB)
|
||||||
|
? Number(config.warningSizeGiB)
|
||||||
|
: DEFAULT_SNAPSHOT_WARNING_SIZE;
|
||||||
|
const rawCriticalSize = Number.isFinite(config.criticalSizeGiB)
|
||||||
|
? Number(config.criticalSizeGiB)
|
||||||
|
: DEFAULT_SNAPSHOT_CRITICAL_SIZE;
|
||||||
|
|
||||||
|
const roundSize = (value: number) => Math.round(Math.max(0, value) * 10) / 10;
|
||||||
|
|
||||||
|
let warningSize = roundSize(rawWarningSize);
|
||||||
|
let criticalSize = roundSize(rawCriticalSize);
|
||||||
|
|
||||||
|
if (criticalSize > 0 && warningSize > criticalSize) {
|
||||||
|
warningSize = criticalSize;
|
||||||
|
}
|
||||||
|
if (criticalSize === 0 && warningSize > 0) {
|
||||||
|
criticalSize = warningSize;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
enabled: !!config.enabled,
|
enabled: !!config.enabled,
|
||||||
warningDays: warning,
|
warningDays: warning,
|
||||||
criticalDays: critical,
|
criticalDays: critical,
|
||||||
|
warningSizeGiB: warningSize,
|
||||||
|
criticalSizeGiB: criticalSize,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateSnapshotDefaults = (
|
const updateSnapshotDefaults = (
|
||||||
updater:
|
updater: SnapshotAlertConfig | ((prev: SnapshotAlertConfig) => SnapshotAlertConfig),
|
||||||
| SnapshotAlertConfig
|
|
||||||
| ((prev: SnapshotAlertConfig) => SnapshotAlertConfig),
|
|
||||||
) => {
|
) => {
|
||||||
props.setSnapshotDefaults((prev) => {
|
props.setSnapshotDefaults((prev) => {
|
||||||
const next =
|
const next =
|
||||||
|
|
@ -822,6 +882,8 @@ const dockerContainersGroupedByHost = createMemo<Record<string, Resource[]>>((pr
|
||||||
return {
|
return {
|
||||||
'warning days': current.warningDays ?? 0,
|
'warning days': current.warningDays ?? 0,
|
||||||
'critical days': current.criticalDays ?? 0,
|
'critical days': current.criticalDays ?? 0,
|
||||||
|
'warning size (gib)': current.warningSizeGiB ?? 0,
|
||||||
|
'critical size (gib)': current.criticalSizeGiB ?? 0,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -830,6 +892,8 @@ const dockerContainersGroupedByHost = createMemo<Record<string, Resource[]>>((pr
|
||||||
return {
|
return {
|
||||||
'warning days': factory.warningDays ?? DEFAULT_SNAPSHOT_WARNING,
|
'warning days': factory.warningDays ?? DEFAULT_SNAPSHOT_WARNING,
|
||||||
'critical days': factory.criticalDays ?? DEFAULT_SNAPSHOT_CRITICAL,
|
'critical days': factory.criticalDays ?? DEFAULT_SNAPSHOT_CRITICAL,
|
||||||
|
'warning size (gib)': factory.warningSizeGiB ?? DEFAULT_SNAPSHOT_WARNING_SIZE,
|
||||||
|
'critical size (gib)': factory.criticalSizeGiB ?? DEFAULT_SNAPSHOT_CRITICAL_SIZE,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -859,9 +923,7 @@ const dockerContainersGroupedByHost = createMemo<Record<string, Resource[]>>((pr
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateBackupDefaults = (
|
const updateBackupDefaults = (
|
||||||
updater:
|
updater: BackupAlertConfig | ((prev: BackupAlertConfig) => BackupAlertConfig),
|
||||||
| BackupAlertConfig
|
|
||||||
| ((prev: BackupAlertConfig) => BackupAlertConfig),
|
|
||||||
) => {
|
) => {
|
||||||
props.setBackupDefaults((prev) => {
|
props.setBackupDefaults((prev) => {
|
||||||
const next =
|
const next =
|
||||||
|
|
@ -889,18 +951,21 @@ const dockerContainersGroupedByHost = createMemo<Record<string, Resource[]>>((pr
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const snapshotOverridesCount = createMemo(() => {
|
const snapshotOverridesCount = createMemo(() => {
|
||||||
const current = props.snapshotDefaults();
|
const current = props.snapshotDefaults();
|
||||||
const factory = snapshotFactoryConfig();
|
const factory = snapshotFactoryConfig();
|
||||||
return current.enabled !== factory.enabled ||
|
const differs =
|
||||||
|
current.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();
|
||||||
|
|
@ -914,7 +979,6 @@ const snapshotOverridesCount = createMemo(() => {
|
||||||
: 0;
|
: 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
// Process guests with their overrides and group by node
|
// Process guests with their overrides and group by node
|
||||||
const guestsGroupedByNode = createMemo<Record<string, Resource[]>>((prev = {}) => {
|
const guestsGroupedByNode = createMemo<Record<string, Resource[]>>((prev = {}) => {
|
||||||
// If we're currently editing, return the previous value to avoid re-renders
|
// If we're currently editing, return the previous value to avoid re-renders
|
||||||
|
|
@ -941,10 +1005,13 @@ const snapshotOverridesCount = createMemo(() => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
// A guest has an override if it has custom thresholds OR is disabled OR has connectivity disabled
|
// A guest has an override if it has custom thresholds OR is disabled OR has connectivity disabled
|
||||||
const hasOverride =
|
const hasOverride =
|
||||||
hasCustomThresholds || override?.disabled || override?.disableConnectivity || overrideSeverity !== undefined || false;
|
hasCustomThresholds ||
|
||||||
|
override?.disabled ||
|
||||||
|
override?.disableConnectivity ||
|
||||||
|
overrideSeverity !== undefined ||
|
||||||
|
false;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: guestId,
|
id: guestId,
|
||||||
|
|
@ -1079,11 +1146,10 @@ const snapshotOverridesCount = createMemo(() => {
|
||||||
const record: Record<string, number> = {};
|
const record: Record<string, number> = {};
|
||||||
PMG_THRESHOLD_COLUMNS.forEach(({ key, normalized }) => {
|
PMG_THRESHOLD_COLUMNS.forEach(({ key, normalized }) => {
|
||||||
const value = defaults[key];
|
const value = defaults[key];
|
||||||
record[normalized] =
|
record[normalized] = typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
||||||
typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
|
||||||
});
|
});
|
||||||
return record;
|
return record;
|
||||||
});
|
});
|
||||||
|
|
||||||
const setPMGGlobalDefaults = (
|
const setPMGGlobalDefaults = (
|
||||||
value:
|
value:
|
||||||
|
|
@ -1344,9 +1410,13 @@ const snapshotOverridesCount = createMemo(() => {
|
||||||
if (resource.editScope === 'backup') {
|
if (resource.editScope === 'backup') {
|
||||||
const currentBackupDefaults = props.backupDefaults();
|
const currentBackupDefaults = props.backupDefaults();
|
||||||
const nextWarning =
|
const nextWarning =
|
||||||
editedThresholds['warning days'] ?? currentBackupDefaults.warningDays ?? DEFAULT_BACKUP_WARNING;
|
editedThresholds['warning days'] ??
|
||||||
|
currentBackupDefaults.warningDays ??
|
||||||
|
DEFAULT_BACKUP_WARNING;
|
||||||
const nextCritical =
|
const nextCritical =
|
||||||
editedThresholds['critical days'] ?? currentBackupDefaults.criticalDays ?? DEFAULT_BACKUP_CRITICAL;
|
editedThresholds['critical days'] ??
|
||||||
|
currentBackupDefaults.criticalDays ??
|
||||||
|
DEFAULT_BACKUP_CRITICAL;
|
||||||
|
|
||||||
updateBackupDefaults({
|
updateBackupDefaults({
|
||||||
enabled: currentBackupDefaults.enabled,
|
enabled: currentBackupDefaults.enabled,
|
||||||
|
|
@ -1361,14 +1431,28 @@ const snapshotOverridesCount = createMemo(() => {
|
||||||
if (resource.editScope === 'snapshot') {
|
if (resource.editScope === 'snapshot') {
|
||||||
const currentSnapshotDefaults = props.snapshotDefaults();
|
const currentSnapshotDefaults = props.snapshotDefaults();
|
||||||
const nextWarning =
|
const nextWarning =
|
||||||
editedThresholds['warning days'] ?? currentSnapshotDefaults.warningDays ?? DEFAULT_SNAPSHOT_WARNING;
|
editedThresholds['warning days'] ??
|
||||||
|
currentSnapshotDefaults.warningDays ??
|
||||||
|
DEFAULT_SNAPSHOT_WARNING;
|
||||||
const nextCritical =
|
const nextCritical =
|
||||||
editedThresholds['critical days'] ?? currentSnapshotDefaults.criticalDays ?? DEFAULT_SNAPSHOT_CRITICAL;
|
editedThresholds['critical days'] ??
|
||||||
|
currentSnapshotDefaults.criticalDays ??
|
||||||
|
DEFAULT_SNAPSHOT_CRITICAL;
|
||||||
|
const nextWarningSize =
|
||||||
|
editedThresholds['warning size (gib)'] ??
|
||||||
|
currentSnapshotDefaults.warningSizeGiB ??
|
||||||
|
DEFAULT_SNAPSHOT_WARNING_SIZE;
|
||||||
|
const nextCriticalSize =
|
||||||
|
editedThresholds['critical size (gib)'] ??
|
||||||
|
currentSnapshotDefaults.criticalSizeGiB ??
|
||||||
|
DEFAULT_SNAPSHOT_CRITICAL_SIZE;
|
||||||
|
|
||||||
updateSnapshotDefaults({
|
updateSnapshotDefaults({
|
||||||
enabled: currentSnapshotDefaults.enabled,
|
enabled: currentSnapshotDefaults.enabled,
|
||||||
warningDays: nextWarning,
|
warningDays: nextWarning,
|
||||||
criticalDays: nextCritical,
|
criticalDays: nextCritical,
|
||||||
|
warningSizeGiB: nextWarningSize,
|
||||||
|
criticalSizeGiB: nextCriticalSize,
|
||||||
});
|
});
|
||||||
|
|
||||||
cancelEdit();
|
cancelEdit();
|
||||||
|
|
@ -1467,7 +1551,10 @@ const snapshotOverridesCount = createMemo(() => {
|
||||||
hysteresisThresholds.disableConnectivity = true;
|
hysteresisThresholds.disableConnectivity = true;
|
||||||
delete hysteresisThresholds.poweredOffSeverity;
|
delete hysteresisThresholds.poweredOffSeverity;
|
||||||
} else {
|
} else {
|
||||||
if ((resource.type === 'guest' || resource.type === 'dockerContainer') && props.guestDisableConnectivity()) {
|
if (
|
||||||
|
(resource.type === 'guest' || resource.type === 'dockerContainer') &&
|
||||||
|
props.guestDisableConnectivity()
|
||||||
|
) {
|
||||||
hysteresisThresholds.disableConnectivity = false;
|
hysteresisThresholds.disableConnectivity = false;
|
||||||
} else {
|
} else {
|
||||||
delete hysteresisThresholds.disableConnectivity;
|
delete hysteresisThresholds.disableConnectivity;
|
||||||
|
|
@ -1491,7 +1578,11 @@ const snapshotOverridesCount = createMemo(() => {
|
||||||
setEditingThresholds({});
|
setEditingThresholds({});
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateMetricDelay = (typeKey: 'guest' | 'node' | 'storage' | 'pbs', metricKey: string, value: number | null) => {
|
const updateMetricDelay = (
|
||||||
|
typeKey: 'guest' | 'node' | 'storage' | 'pbs',
|
||||||
|
metricKey: string,
|
||||||
|
value: number | null,
|
||||||
|
) => {
|
||||||
const normalizedMetric = metricKey.trim().toLowerCase();
|
const normalizedMetric = metricKey.trim().toLowerCase();
|
||||||
if (!normalizedMetric) return;
|
if (!normalizedMetric) return;
|
||||||
|
|
||||||
|
|
@ -1576,10 +1667,7 @@ const snapshotOverridesCount = createMemo(() => {
|
||||||
delete (cleanThresholds as Record<string, unknown>).disabled;
|
delete (cleanThresholds as Record<string, unknown>).disabled;
|
||||||
|
|
||||||
// If enabling (disabled = false) and no custom thresholds exist, remove the override entirely
|
// If enabling (disabled = false) and no custom thresholds exist, remove the override entirely
|
||||||
if (
|
if (!newDisabledState && (!existingOverride || Object.keys(cleanThresholds).length === 0)) {
|
||||||
!newDisabledState &&
|
|
||||||
(!existingOverride || Object.keys(cleanThresholds).length === 0)
|
|
||||||
) {
|
|
||||||
// Remove the override completely
|
// Remove the override completely
|
||||||
props.setOverrides(props.overrides().filter((o) => o.id !== resourceId));
|
props.setOverrides(props.overrides().filter((o) => o.id !== resourceId));
|
||||||
|
|
||||||
|
|
@ -1646,8 +1734,7 @@ const snapshotOverridesCount = createMemo(() => {
|
||||||
const offlineId = `pbs-offline-${resourceId}`;
|
const offlineId = `pbs-offline-${resourceId}`;
|
||||||
props.removeAlerts(
|
props.removeAlerts(
|
||||||
(alert) =>
|
(alert) =>
|
||||||
alert.resourceId === resourceId &&
|
alert.resourceId === resourceId && (alert.id === offlineId || alert.type === 'offline'),
|
||||||
(alert.id === offlineId || alert.type === 'offline'),
|
|
||||||
);
|
);
|
||||||
} else if (resource.type === 'dockerContainer') {
|
} else if (resource.type === 'dockerContainer') {
|
||||||
props.removeAlerts(
|
props.removeAlerts(
|
||||||
|
|
@ -1755,9 +1842,7 @@ const snapshotOverridesCount = createMemo(() => {
|
||||||
if (props.removeAlerts && resource.type === 'dockerHost') {
|
if (props.removeAlerts && resource.type === 'dockerHost') {
|
||||||
const offlineId = `docker-host-offline-${resourceId}`;
|
const offlineId = `docker-host-offline-${resourceId}`;
|
||||||
const resourceKey = `docker:${resourceId}`;
|
const resourceKey = `docker:${resourceId}`;
|
||||||
props.removeAlerts(
|
props.removeAlerts((alert) => alert.id === offlineId || alert.resourceId === resourceKey);
|
||||||
(alert) => alert.id === offlineId || alert.resourceId === resourceKey,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -1865,7 +1950,9 @@ const snapshotOverridesCount = createMemo(() => {
|
||||||
|
|
||||||
if (props.removeAlerts && newDisableConnectivity) {
|
if (props.removeAlerts && newDisableConnectivity) {
|
||||||
if (resource.type === 'guest') {
|
if (resource.type === 'guest') {
|
||||||
props.removeAlerts((alert) => alert.resourceId === resourceId && alert.type === 'powered-off');
|
props.removeAlerts(
|
||||||
|
(alert) => alert.resourceId === resourceId && alert.type === 'powered-off',
|
||||||
|
);
|
||||||
} else if (resource.type === 'dockerContainer') {
|
} else if (resource.type === 'dockerContainer') {
|
||||||
props.removeAlerts(
|
props.removeAlerts(
|
||||||
(alert) =>
|
(alert) =>
|
||||||
|
|
@ -1922,11 +2009,30 @@ const snapshotOverridesCount = createMemo(() => {
|
||||||
{/* Help Banner */}
|
{/* Help Banner */}
|
||||||
<div class="rounded-lg border border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-950/30 p-3">
|
<div class="rounded-lg border border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-950/30 p-3">
|
||||||
<div class="flex items-start gap-2">
|
<div class="flex items-start gap-2">
|
||||||
<svg class="w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
class="w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<div class="text-sm text-blue-900 dark:text-blue-100">
|
<div class="text-sm text-blue-900 dark:text-blue-100">
|
||||||
<span class="font-medium">Quick tips:</span> Set any threshold to <code class="px-1 py-0.5 bg-blue-100 dark:bg-blue-900/50 rounded text-xs font-mono">0</code> to disable alerts for that metric. Click on disabled thresholds showing <span class="italic">Off</span> to re-enable them. Resources with custom settings show a <span class="inline-flex items-center px-1.5 py-0.5 bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 rounded text-xs">Custom</span> badge.
|
<span class="font-medium">Quick tips:</span> Set any threshold to{' '}
|
||||||
|
<code class="px-1 py-0.5 bg-blue-100 dark:bg-blue-900/50 rounded text-xs font-mono">
|
||||||
|
0
|
||||||
|
</code>{' '}
|
||||||
|
to disable alerts for that metric. Click on disabled thresholds showing{' '}
|
||||||
|
<span class="italic">Off</span> to re-enable them. Resources with custom settings show a{' '}
|
||||||
|
<span class="inline-flex items-center px-1.5 py-0.5 bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 rounded text-xs">
|
||||||
|
Custom
|
||||||
|
</span>{' '}
|
||||||
|
badge.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1998,7 +2104,9 @@ const snapshotOverridesCount = createMemo(() => {
|
||||||
globalDisableFlag={props.disableAllNodes}
|
globalDisableFlag={props.disableAllNodes}
|
||||||
onToggleGlobalDisable={() => props.setDisableAllNodes(!props.disableAllNodes())}
|
onToggleGlobalDisable={() => props.setDisableAllNodes(!props.disableAllNodes())}
|
||||||
globalDisableOfflineFlag={props.disableAllNodesOffline}
|
globalDisableOfflineFlag={props.disableAllNodesOffline}
|
||||||
onToggleGlobalDisableOffline={() => props.setDisableAllNodesOffline(!props.disableAllNodesOffline())}
|
onToggleGlobalDisableOffline={() =>
|
||||||
|
props.setDisableAllNodesOffline(!props.disableAllNodesOffline())
|
||||||
|
}
|
||||||
showDelayColumn={true}
|
showDelayColumn={true}
|
||||||
globalDelaySeconds={props.timeThresholds().node}
|
globalDelaySeconds={props.timeThresholds().node}
|
||||||
metricDelaySeconds={props.metricTimeThresholds().node ?? {}}
|
metricDelaySeconds={props.metricTimeThresholds().node ?? {}}
|
||||||
|
|
@ -2032,22 +2140,42 @@ const snapshotOverridesCount = createMemo(() => {
|
||||||
globalDefaults={{ cpu: props.nodeDefaults.cpu, memory: props.nodeDefaults.memory }}
|
globalDefaults={{ cpu: props.nodeDefaults.cpu, memory: props.nodeDefaults.memory }}
|
||||||
setGlobalDefaults={(value) => {
|
setGlobalDefaults={(value) => {
|
||||||
if (typeof value === 'function') {
|
if (typeof value === 'function') {
|
||||||
const newValue = value({ cpu: props.nodeDefaults.cpu, memory: props.nodeDefaults.memory });
|
const newValue = value({
|
||||||
props.setNodeDefaults((prev) => ({ ...prev, cpu: newValue.cpu ?? prev.cpu, memory: newValue.memory ?? prev.memory }));
|
cpu: props.nodeDefaults.cpu,
|
||||||
|
memory: props.nodeDefaults.memory,
|
||||||
|
});
|
||||||
|
props.setNodeDefaults((prev) => ({
|
||||||
|
...prev,
|
||||||
|
cpu: newValue.cpu ?? prev.cpu,
|
||||||
|
memory: newValue.memory ?? prev.memory,
|
||||||
|
}));
|
||||||
} else {
|
} else {
|
||||||
props.setNodeDefaults((prev) => ({ ...prev, cpu: value.cpu ?? prev.cpu, memory: value.memory ?? prev.memory }));
|
props.setNodeDefaults((prev) => ({
|
||||||
|
...prev,
|
||||||
|
cpu: value.cpu ?? prev.cpu,
|
||||||
|
memory: value.memory ?? prev.memory,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
setHasUnsavedChanges={props.setHasUnsavedChanges}
|
setHasUnsavedChanges={props.setHasUnsavedChanges}
|
||||||
globalDisableFlag={props.disableAllPBS}
|
globalDisableFlag={props.disableAllPBS}
|
||||||
onToggleGlobalDisable={() => props.setDisableAllPBS(!props.disableAllPBS())}
|
onToggleGlobalDisable={() => props.setDisableAllPBS(!props.disableAllPBS())}
|
||||||
globalDisableOfflineFlag={props.disableAllPBSOffline}
|
globalDisableOfflineFlag={props.disableAllPBSOffline}
|
||||||
onToggleGlobalDisableOffline={() => props.setDisableAllPBSOffline(!props.disableAllPBSOffline())}
|
onToggleGlobalDisableOffline={() =>
|
||||||
|
props.setDisableAllPBSOffline(!props.disableAllPBSOffline())
|
||||||
|
}
|
||||||
showDelayColumn={true}
|
showDelayColumn={true}
|
||||||
globalDelaySeconds={props.timeThresholds().pbs}
|
globalDelaySeconds={props.timeThresholds().pbs}
|
||||||
metricDelaySeconds={props.metricTimeThresholds().pbs ?? {}}
|
metricDelaySeconds={props.metricTimeThresholds().pbs ?? {}}
|
||||||
onMetricDelayChange={(metric, value) => updateMetricDelay('pbs', metric, value)}
|
onMetricDelayChange={(metric, value) => updateMetricDelay('pbs', metric, value)}
|
||||||
factoryDefaults={props.factoryNodeDefaults ? { cpu: props.factoryNodeDefaults.cpu, memory: props.factoryNodeDefaults.memory } : undefined}
|
factoryDefaults={
|
||||||
|
props.factoryNodeDefaults
|
||||||
|
? {
|
||||||
|
cpu: props.factoryNodeDefaults.cpu,
|
||||||
|
memory: props.factoryNodeDefaults.memory,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
onResetDefaults={props.resetNodeDefaults}
|
onResetDefaults={props.resetNodeDefaults}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -2059,7 +2187,15 @@ const snapshotOverridesCount = createMemo(() => {
|
||||||
title="VMs & Containers"
|
title="VMs & Containers"
|
||||||
groupedResources={guestsGroupedByNode()}
|
groupedResources={guestsGroupedByNode()}
|
||||||
groupHeaderMeta={guestGroupHeaderMeta()}
|
groupHeaderMeta={guestGroupHeaderMeta()}
|
||||||
columns={['CPU %', 'Memory %', 'Disk %', 'Disk R MB/s', 'Disk W MB/s', 'Net In MB/s', 'Net Out MB/s']}
|
columns={[
|
||||||
|
'CPU %',
|
||||||
|
'Memory %',
|
||||||
|
'Disk %',
|
||||||
|
'Disk R MB/s',
|
||||||
|
'Disk W MB/s',
|
||||||
|
'Net In MB/s',
|
||||||
|
'Net Out MB/s',
|
||||||
|
]}
|
||||||
activeAlerts={props.activeAlerts}
|
activeAlerts={props.activeAlerts}
|
||||||
emptyMessage="No VMs or containers match the current filters."
|
emptyMessage="No VMs or containers match the current filters."
|
||||||
onEdit={startEditing}
|
onEdit={startEditing}
|
||||||
|
|
@ -2108,8 +2244,22 @@ const snapshotOverridesCount = createMemo(() => {
|
||||||
<div ref={registerSection('backups')} class="scroll-mt-24">
|
<div ref={registerSection('backups')} class="scroll-mt-24">
|
||||||
<ResourceTable
|
<ResourceTable
|
||||||
title="Backups"
|
title="Backups"
|
||||||
resources={[{ id: "backups-defaults", name: "Global Defaults", thresholds: backupDefaultsRecord(), defaults: backupDefaultsRecord(), editable: true, editScope: "backup" }]}
|
resources={[
|
||||||
columns={['Warning Days', 'Critical Days']}
|
{
|
||||||
|
id: 'backups-defaults',
|
||||||
|
name: 'Global Defaults',
|
||||||
|
thresholds: backupDefaultsRecord(),
|
||||||
|
defaults: backupDefaultsRecord(),
|
||||||
|
editable: true,
|
||||||
|
editScope: 'backup',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
columns={[
|
||||||
|
'Warning Days',
|
||||||
|
'Critical Days',
|
||||||
|
'Warning Size (GiB)',
|
||||||
|
'Critical Size (GiB)',
|
||||||
|
]}
|
||||||
activeAlerts={props.activeAlerts}
|
activeAlerts={props.activeAlerts}
|
||||||
emptyMessage=""
|
emptyMessage=""
|
||||||
onEdit={startEditing}
|
onEdit={startEditing}
|
||||||
|
|
@ -2171,7 +2321,16 @@ const snapshotOverridesCount = createMemo(() => {
|
||||||
<div ref={registerSection('snapshots')} class="scroll-mt-24">
|
<div ref={registerSection('snapshots')} class="scroll-mt-24">
|
||||||
<ResourceTable
|
<ResourceTable
|
||||||
title="Snapshot Age"
|
title="Snapshot Age"
|
||||||
resources={[{ id: "snapshots-defaults", name: "Global Defaults", thresholds: snapshotDefaultsRecord(), defaults: snapshotDefaultsRecord(), editable: true, editScope: "snapshot" }]}
|
resources={[
|
||||||
|
{
|
||||||
|
id: 'snapshots-defaults',
|
||||||
|
name: 'Global Defaults',
|
||||||
|
thresholds: snapshotDefaultsRecord(),
|
||||||
|
defaults: snapshotDefaultsRecord(),
|
||||||
|
editable: true,
|
||||||
|
editScope: 'snapshot',
|
||||||
|
},
|
||||||
|
]}
|
||||||
columns={['Warning Days', 'Critical Days']}
|
columns={['Warning Days', 'Critical Days']}
|
||||||
activeAlerts={props.activeAlerts}
|
activeAlerts={props.activeAlerts}
|
||||||
emptyMessage=""
|
emptyMessage=""
|
||||||
|
|
@ -2191,6 +2350,8 @@ const snapshotOverridesCount = createMemo(() => {
|
||||||
const currentRecord = {
|
const currentRecord = {
|
||||||
'warning days': prev.warningDays ?? 0,
|
'warning days': prev.warningDays ?? 0,
|
||||||
'critical days': prev.criticalDays ?? 0,
|
'critical days': prev.criticalDays ?? 0,
|
||||||
|
'warning size (gib)': prev.warningSizeGiB ?? 0,
|
||||||
|
'critical size (gib)': prev.criticalSizeGiB ?? 0,
|
||||||
};
|
};
|
||||||
const nextRecord =
|
const nextRecord =
|
||||||
typeof value === 'function'
|
typeof value === 'function'
|
||||||
|
|
@ -2206,6 +2367,14 @@ const snapshotOverridesCount = createMemo(() => {
|
||||||
typeof nextRecord['critical days'] === 'number'
|
typeof nextRecord['critical days'] === 'number'
|
||||||
? nextRecord['critical days']
|
? nextRecord['critical days']
|
||||||
: prev.criticalDays,
|
: prev.criticalDays,
|
||||||
|
warningSizeGiB:
|
||||||
|
typeof nextRecord['warning size (gib)'] === 'number'
|
||||||
|
? nextRecord['warning size (gib)']
|
||||||
|
: prev.warningSizeGiB,
|
||||||
|
criticalSizeGiB:
|
||||||
|
typeof nextRecord['critical size (gib)'] === 'number'
|
||||||
|
? nextRecord['critical size (gib)']
|
||||||
|
: prev.criticalSizeGiB,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
|
@ -2266,12 +2435,15 @@ const snapshotOverridesCount = createMemo(() => {
|
||||||
globalDelaySeconds={props.timeThresholds().storage}
|
globalDelaySeconds={props.timeThresholds().storage}
|
||||||
metricDelaySeconds={props.metricTimeThresholds().storage ?? {}}
|
metricDelaySeconds={props.metricTimeThresholds().storage ?? {}}
|
||||||
onMetricDelayChange={(metric, value) => updateMetricDelay('storage', metric, value)}
|
onMetricDelayChange={(metric, value) => updateMetricDelay('storage', metric, value)}
|
||||||
factoryDefaults={props.factoryStorageDefault !== undefined ? { usage: props.factoryStorageDefault } : undefined}
|
factoryDefaults={
|
||||||
|
props.factoryStorageDefault !== undefined
|
||||||
|
? { usage: props.factoryStorageDefault }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
onResetDefaults={props.resetStorageDefault}
|
onResetDefaults={props.resetStorageDefault}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={activeTab() === 'pmg'}>
|
<Show when={activeTab() === 'pmg'}>
|
||||||
|
|
@ -2279,7 +2451,8 @@ const snapshotOverridesCount = createMemo(() => {
|
||||||
when={pmgServersWithOverrides().length > 0}
|
when={pmgServersWithOverrides().length > 0}
|
||||||
fallback={
|
fallback={
|
||||||
<div class="rounded-lg border border-gray-200 bg-white p-6 text-sm text-gray-600 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300">
|
<div class="rounded-lg border border-gray-200 bg-white p-6 text-sm text-gray-600 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300">
|
||||||
No mail gateways configured yet. Add a PMG instance in Settings to manage thresholds.
|
No mail gateways configured yet. Add a PMG instance in Settings to manage
|
||||||
|
thresholds.
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
@ -2325,7 +2498,9 @@ const snapshotOverridesCount = createMemo(() => {
|
||||||
globalDisableFlag={props.disableAllPMG}
|
globalDisableFlag={props.disableAllPMG}
|
||||||
onToggleGlobalDisable={() => props.setDisableAllPMG(!props.disableAllPMG())}
|
onToggleGlobalDisable={() => props.setDisableAllPMG(!props.disableAllPMG())}
|
||||||
globalDisableOfflineFlag={props.disableAllPMGOffline}
|
globalDisableOfflineFlag={props.disableAllPMGOffline}
|
||||||
onToggleGlobalDisableOffline={() => props.setDisableAllPMGOffline(!props.disableAllPMGOffline())}
|
onToggleGlobalDisableOffline={() =>
|
||||||
|
props.setDisableAllPMGOffline(!props.disableAllPMGOffline())
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
@ -2383,9 +2558,13 @@ const snapshotOverridesCount = createMemo(() => {
|
||||||
formatMetricValue={formatMetricValue}
|
formatMetricValue={formatMetricValue}
|
||||||
hasActiveAlert={hasActiveAlert}
|
hasActiveAlert={hasActiveAlert}
|
||||||
globalDisableFlag={props.disableAllDockerHosts}
|
globalDisableFlag={props.disableAllDockerHosts}
|
||||||
onToggleGlobalDisable={() => props.setDisableAllDockerHosts(!props.disableAllDockerHosts())}
|
onToggleGlobalDisable={() =>
|
||||||
|
props.setDisableAllDockerHosts(!props.disableAllDockerHosts())
|
||||||
|
}
|
||||||
globalDisableOfflineFlag={props.disableAllDockerHostsOffline}
|
globalDisableOfflineFlag={props.disableAllDockerHostsOffline}
|
||||||
onToggleGlobalDisableOffline={() => props.setDisableAllDockerHostsOffline(!props.disableAllDockerHostsOffline())}
|
onToggleGlobalDisableOffline={() =>
|
||||||
|
props.setDisableAllDockerHostsOffline(!props.disableAllDockerHostsOffline())
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
@ -2435,9 +2614,7 @@ const snapshotOverridesCount = createMemo(() => {
|
||||||
memoryCriticalPct: props.dockerDefaults.memoryCriticalPct,
|
memoryCriticalPct: props.dockerDefaults.memoryCriticalPct,
|
||||||
};
|
};
|
||||||
const next =
|
const next =
|
||||||
typeof value === 'function'
|
typeof value === 'function' ? value(current) : { ...current, ...value };
|
||||||
? value(current)
|
|
||||||
: { ...current, ...value };
|
|
||||||
|
|
||||||
props.setDockerDefaults((prev) => ({
|
props.setDockerDefaults((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
|
|
@ -2451,7 +2628,9 @@ const snapshotOverridesCount = createMemo(() => {
|
||||||
}}
|
}}
|
||||||
setHasUnsavedChanges={props.setHasUnsavedChanges}
|
setHasUnsavedChanges={props.setHasUnsavedChanges}
|
||||||
globalDisableFlag={props.disableAllDockerContainers}
|
globalDisableFlag={props.disableAllDockerContainers}
|
||||||
onToggleGlobalDisable={() => props.setDisableAllDockerContainers(!props.disableAllDockerContainers())}
|
onToggleGlobalDisable={() =>
|
||||||
|
props.setDisableAllDockerContainers(!props.disableAllDockerContainers())
|
||||||
|
}
|
||||||
globalDisableOfflineFlag={() => props.guestDisableConnectivity()}
|
globalDisableOfflineFlag={() => props.guestDisableConnectivity()}
|
||||||
onToggleGlobalDisableOffline={() =>
|
onToggleGlobalDisableOffline={() =>
|
||||||
props.setGuestDisableConnectivity(!props.guestDisableConnectivity())
|
props.setGuestDisableConnectivity(!props.guestDisableConnectivity())
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { render, fireEvent, screen, cleanup } from '@solidjs/testing-library';
|
import { render, fireEvent, screen, cleanup } from '@solidjs/testing-library';
|
||||||
import { createSignal } from 'solid-js';
|
import { createSignal } from 'solid-js';
|
||||||
|
|
||||||
import {
|
import { ThresholdsTable, normalizeDockerIgnoredInput } from '../ThresholdsTable';
|
||||||
ThresholdsTable,
|
|
||||||
normalizeDockerIgnoredInput,
|
|
||||||
} from '../ThresholdsTable';
|
|
||||||
import type { PMGThresholdDefaults, SnapshotAlertConfig, BackupAlertConfig } from '@/types/alerts';
|
import type { PMGThresholdDefaults, SnapshotAlertConfig, BackupAlertConfig } from '@/types/alerts';
|
||||||
|
|
||||||
vi.mock('@solidjs/router', () => ({
|
vi.mock('@solidjs/router', () => ({
|
||||||
|
|
@ -91,9 +88,21 @@ const baseProps = () => ({
|
||||||
setBackupDefaults: vi.fn(),
|
setBackupDefaults: vi.fn(),
|
||||||
backupFactoryDefaults: { enabled: false, warningDays: 7, criticalDays: 14 } as BackupAlertConfig,
|
backupFactoryDefaults: { enabled: false, warningDays: 7, criticalDays: 14 } as BackupAlertConfig,
|
||||||
resetBackupDefaults: vi.fn(),
|
resetBackupDefaults: vi.fn(),
|
||||||
snapshotDefaults: () => ({ enabled: false, warningDays: 30, criticalDays: 45 }),
|
snapshotDefaults: () => ({
|
||||||
|
enabled: false,
|
||||||
|
warningDays: 30,
|
||||||
|
criticalDays: 45,
|
||||||
|
warningSizeGiB: 0,
|
||||||
|
criticalSizeGiB: 0,
|
||||||
|
}),
|
||||||
setSnapshotDefaults: vi.fn(),
|
setSnapshotDefaults: vi.fn(),
|
||||||
snapshotFactoryDefaults: { enabled: false, warningDays: 30, criticalDays: 45 } as SnapshotAlertConfig,
|
snapshotFactoryDefaults: {
|
||||||
|
enabled: false,
|
||||||
|
warningDays: 30,
|
||||||
|
criticalDays: 45,
|
||||||
|
warningSizeGiB: 0,
|
||||||
|
criticalSizeGiB: 0,
|
||||||
|
} as SnapshotAlertConfig,
|
||||||
resetSnapshotDefaults: vi.fn(),
|
resetSnapshotDefaults: vi.fn(),
|
||||||
timeThresholds: () => ({ guest: 5, node: 5, storage: 5, pbs: 5 }),
|
timeThresholds: () => ({ guest: 5, node: 5, storage: 5, pbs: 5 }),
|
||||||
metricTimeThresholds: () => ({}),
|
metricTimeThresholds: () => ({}),
|
||||||
|
|
@ -126,7 +135,10 @@ const baseProps = () => ({
|
||||||
setDisableAllDockerHostsOffline: vi.fn(),
|
setDisableAllDockerHostsOffline: vi.fn(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const renderThresholdsTable = (options?: { initialPrefixes?: string[]; includeReset?: boolean }) => {
|
const renderThresholdsTable = (options?: {
|
||||||
|
initialPrefixes?: string[];
|
||||||
|
includeReset?: boolean;
|
||||||
|
}) => {
|
||||||
let setDockerIgnoredPrefixesMock!: ReturnType<typeof vi.fn>;
|
let setDockerIgnoredPrefixesMock!: ReturnType<typeof vi.fn>;
|
||||||
let resetDockerIgnoredPrefixesMock: ReturnType<typeof vi.fn> | undefined;
|
let resetDockerIgnoredPrefixesMock: ReturnType<typeof vi.fn> | undefined;
|
||||||
let setHasUnsavedChangesMock!: ReturnType<typeof vi.fn>;
|
let setHasUnsavedChangesMock!: ReturnType<typeof vi.fn>;
|
||||||
|
|
@ -177,9 +189,11 @@ const renderThresholdsTable = (options?: { initialPrefixes?: string[]; includeRe
|
||||||
|
|
||||||
describe('normalizeDockerIgnoredInput', () => {
|
describe('normalizeDockerIgnoredInput', () => {
|
||||||
it('trims whitespace and removes empty lines', () => {
|
it('trims whitespace and removes empty lines', () => {
|
||||||
expect(
|
expect(normalizeDockerIgnoredInput(' runner- \n\n #system \n\t \njob-')).toEqual([
|
||||||
normalizeDockerIgnoredInput(' runner- \n\n #system \n\t \njob-'),
|
'runner-',
|
||||||
).toEqual(['runner-', '#system', 'job-']);
|
'#system',
|
||||||
|
'job-',
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns empty array for blank input', () => {
|
it('returns empty array for blank input', () => {
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,16 @@ import { usePersistentSignal } from '@/hooks/usePersistentSignal';
|
||||||
|
|
||||||
type BackupSortKey = keyof Pick<
|
type BackupSortKey = keyof Pick<
|
||||||
UnifiedBackup,
|
UnifiedBackup,
|
||||||
'backupTime' | 'name' | 'node' | 'vmid' | 'backupType' | 'size' | 'storage' | 'verified' | 'type' | 'owner'
|
| 'backupTime'
|
||||||
|
| 'name'
|
||||||
|
| 'node'
|
||||||
|
| 'vmid'
|
||||||
|
| 'backupType'
|
||||||
|
| 'size'
|
||||||
|
| 'storage'
|
||||||
|
| 'verified'
|
||||||
|
| 'type'
|
||||||
|
| 'owner'
|
||||||
>;
|
>;
|
||||||
const BACKUP_SORT_KEY_VALUES: readonly BackupSortKey[] = [
|
const BACKUP_SORT_KEY_VALUES: readonly BackupSortKey[] = [
|
||||||
'backupTime',
|
'backupTime',
|
||||||
|
|
@ -70,16 +79,10 @@ const UnifiedBackups: Component = () => {
|
||||||
else if (value === 'pve') setBackupTypeFilter('local');
|
else if (value === 'pve') setBackupTypeFilter('local');
|
||||||
else if (value === 'pbs') setBackupTypeFilter('remote');
|
else if (value === 'pbs') setBackupTypeFilter('remote');
|
||||||
};
|
};
|
||||||
const [sortKey, setSortKey] = usePersistentSignal<BackupSortKey>(
|
const [sortKey, setSortKey] = usePersistentSignal<BackupSortKey>('backupsSortKey', 'backupTime', {
|
||||||
'backupsSortKey',
|
|
||||||
'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"
|
||||||
|
|
|
||||||
|
|
@ -157,9 +157,15 @@ interface UIEmailConfig {
|
||||||
|
|
||||||
interface UIAppriseConfig {
|
interface UIAppriseConfig {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
mode: 'cli' | 'http';
|
||||||
targetsText: string;
|
targetsText: string;
|
||||||
cliPath: string;
|
cliPath: string;
|
||||||
timeoutSeconds: number;
|
timeoutSeconds: number;
|
||||||
|
serverUrl: string;
|
||||||
|
configKey: string;
|
||||||
|
apiKey: string;
|
||||||
|
apiKeyHeader: string;
|
||||||
|
skipTlsVerify: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface QuietHoursConfig {
|
interface QuietHoursConfig {
|
||||||
|
|
@ -239,9 +245,15 @@ export const createDefaultGrouping = (): GroupingConfig => ({
|
||||||
|
|
||||||
const createDefaultAppriseConfig = (): UIAppriseConfig => ({
|
const createDefaultAppriseConfig = (): UIAppriseConfig => ({
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
mode: 'cli',
|
||||||
targetsText: '',
|
targetsText: '',
|
||||||
cliPath: 'apprise',
|
cliPath: 'apprise',
|
||||||
timeoutSeconds: 15,
|
timeoutSeconds: 15,
|
||||||
|
serverUrl: '',
|
||||||
|
configKey: '',
|
||||||
|
apiKey: '',
|
||||||
|
apiKeyHeader: 'X-API-KEY',
|
||||||
|
skipTlsVerify: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const parseAppriseTargets = (value: string): string[] =>
|
const parseAppriseTargets = (value: string): string[] =>
|
||||||
|
|
@ -486,9 +498,15 @@ const [appriseConfig, setAppriseConfig] = createSignal<UIAppriseConfig>(
|
||||||
const config = appriseConfig();
|
const config = appriseConfig();
|
||||||
return {
|
return {
|
||||||
enabled: config.enabled,
|
enabled: config.enabled,
|
||||||
|
mode: config.mode,
|
||||||
targets: parseAppriseTargets(config.targetsText),
|
targets: parseAppriseTargets(config.targetsText),
|
||||||
cliPath: config.cliPath,
|
cliPath: config.cliPath,
|
||||||
timeoutSeconds: config.timeoutSeconds,
|
timeoutSeconds: config.timeoutSeconds,
|
||||||
|
serverUrl: config.serverUrl,
|
||||||
|
configKey: config.configKey,
|
||||||
|
apiKey: config.apiKey,
|
||||||
|
apiKeyHeader: config.apiKeyHeader,
|
||||||
|
skipTlsVerify: config.skipTlsVerify,
|
||||||
} as AppriseConfig;
|
} as AppriseConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -1012,12 +1030,18 @@ const [appriseConfig, setAppriseConfig] = createSignal<UIAppriseConfig>(
|
||||||
const appriseData = await NotificationsAPI.getAppriseConfig();
|
const appriseData = await NotificationsAPI.getAppriseConfig();
|
||||||
setAppriseConfig({
|
setAppriseConfig({
|
||||||
enabled: appriseData.enabled ?? false,
|
enabled: appriseData.enabled ?? false,
|
||||||
|
mode: appriseData.mode === 'http' ? 'http' : 'cli',
|
||||||
targetsText: formatAppriseTargets(appriseData.targets),
|
targetsText: formatAppriseTargets(appriseData.targets),
|
||||||
cliPath: appriseData.cliPath || 'apprise',
|
cliPath: appriseData.cliPath || 'apprise',
|
||||||
timeoutSeconds:
|
timeoutSeconds:
|
||||||
typeof appriseData.timeoutSeconds === 'number' && appriseData.timeoutSeconds > 0
|
typeof appriseData.timeoutSeconds === 'number' && appriseData.timeoutSeconds > 0
|
||||||
? appriseData.timeoutSeconds
|
? appriseData.timeoutSeconds
|
||||||
: 15,
|
: 15,
|
||||||
|
serverUrl: appriseData.serverUrl || '',
|
||||||
|
configKey: appriseData.configKey || '',
|
||||||
|
apiKey: appriseData.apiKey || '',
|
||||||
|
apiKeyHeader: appriseData.apiKeyHeader || 'X-API-KEY',
|
||||||
|
skipTlsVerify: Boolean(appriseData.skipTlsVerify),
|
||||||
});
|
});
|
||||||
} catch (appriseErr) {
|
} catch (appriseErr) {
|
||||||
console.error('Failed to load Apprise configuration:', appriseErr);
|
console.error('Failed to load Apprise configuration:', appriseErr);
|
||||||
|
|
@ -1072,12 +1096,18 @@ const [appriseConfig, setAppriseConfig] = createSignal<UIAppriseConfig>(
|
||||||
.then((appriseData) => {
|
.then((appriseData) => {
|
||||||
setAppriseConfig({
|
setAppriseConfig({
|
||||||
enabled: appriseData.enabled ?? false,
|
enabled: appriseData.enabled ?? false,
|
||||||
|
mode: appriseData.mode === 'http' ? 'http' : 'cli',
|
||||||
targetsText: formatAppriseTargets(appriseData.targets),
|
targetsText: formatAppriseTargets(appriseData.targets),
|
||||||
cliPath: appriseData.cliPath || 'apprise',
|
cliPath: appriseData.cliPath || 'apprise',
|
||||||
timeoutSeconds:
|
timeoutSeconds:
|
||||||
typeof appriseData.timeoutSeconds === 'number' && appriseData.timeoutSeconds > 0
|
typeof appriseData.timeoutSeconds === 'number' && appriseData.timeoutSeconds > 0
|
||||||
? appriseData.timeoutSeconds
|
? appriseData.timeoutSeconds
|
||||||
: 15,
|
: 15,
|
||||||
|
serverUrl: appriseData.serverUrl || '',
|
||||||
|
configKey: appriseData.configKey || '',
|
||||||
|
apiKey: appriseData.apiKey || '',
|
||||||
|
apiKeyHeader: appriseData.apiKeyHeader || 'X-API-KEY',
|
||||||
|
skipTlsVerify: Boolean(appriseData.skipTlsVerify),
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
|
|
@ -1484,12 +1514,18 @@ const [appriseConfig, setAppriseConfig] = createSignal<UIAppriseConfig>(
|
||||||
const updatedApprise = await NotificationsAPI.updateAppriseConfig(appriseData);
|
const updatedApprise = await NotificationsAPI.updateAppriseConfig(appriseData);
|
||||||
setAppriseConfig({
|
setAppriseConfig({
|
||||||
enabled: updatedApprise.enabled ?? false,
|
enabled: updatedApprise.enabled ?? false,
|
||||||
|
mode: updatedApprise.mode === 'http' ? 'http' : 'cli',
|
||||||
targetsText: formatAppriseTargets(updatedApprise.targets),
|
targetsText: formatAppriseTargets(updatedApprise.targets),
|
||||||
cliPath: updatedApprise.cliPath || 'apprise',
|
cliPath: updatedApprise.cliPath || 'apprise',
|
||||||
timeoutSeconds:
|
timeoutSeconds:
|
||||||
typeof updatedApprise.timeoutSeconds === 'number' && updatedApprise.timeoutSeconds > 0
|
typeof updatedApprise.timeoutSeconds === 'number' && updatedApprise.timeoutSeconds > 0
|
||||||
? updatedApprise.timeoutSeconds
|
? updatedApprise.timeoutSeconds
|
||||||
: 15,
|
: 15,
|
||||||
|
serverUrl: updatedApprise.serverUrl || '',
|
||||||
|
configKey: updatedApprise.configKey || '',
|
||||||
|
apiKey: updatedApprise.apiKey || '',
|
||||||
|
apiKeyHeader: updatedApprise.apiKeyHeader || 'X-API-KEY',
|
||||||
|
skipTlsVerify: Boolean(updatedApprise.skipTlsVerify),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,8 @@ export interface SnapshotAlertConfig {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
warningDays: number;
|
warningDays: number;
|
||||||
criticalDays: number;
|
criticalDays: number;
|
||||||
|
warningSizeGiB?: number;
|
||||||
|
criticalSizeGiB?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BackupAlertConfig {
|
export interface BackupAlertConfig {
|
||||||
|
|
|
||||||
|
|
@ -607,6 +607,7 @@ export interface GuestSnapshot {
|
||||||
description: string;
|
description: string;
|
||||||
parent: string;
|
parent: string;
|
||||||
vmstate: boolean;
|
vmstate: boolean;
|
||||||
|
sizeBytes?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Performance {
|
export interface Performance {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -113,8 +113,14 @@ func (h *NotificationHandlers) UpdateAppriseConfig(w http.ResponseWriter, r *htt
|
||||||
|
|
||||||
log.Info().
|
log.Info().
|
||||||
Bool("enabled", config.Enabled).
|
Bool("enabled", config.Enabled).
|
||||||
|
Str("mode", string(config.Mode)).
|
||||||
Int("targetCount", len(config.Targets)).
|
Int("targetCount", len(config.Targets)).
|
||||||
Str("cliPath", config.CLIPath).
|
Str("cliPath", config.CLIPath).
|
||||||
|
Str("serverUrl", config.ServerURL).
|
||||||
|
Str("configKey", config.ConfigKey).
|
||||||
|
Bool("hasApiKey", config.APIKey != "").
|
||||||
|
Str("apiKeyHeader", config.APIKeyHeader).
|
||||||
|
Bool("skipTlsVerify", config.SkipTLSVerify).
|
||||||
Int("timeoutSeconds", config.TimeoutSeconds).
|
Int("timeoutSeconds", config.TimeoutSeconds).
|
||||||
Msg("Parsed Apprise configuration update")
|
Msg("Parsed Apprise configuration update")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -215,6 +215,21 @@ func (c *ConfigPersistence) SaveAlertConfig(config alerts.AlertConfig) error {
|
||||||
if config.SnapshotDefaults.CriticalDays > 0 && config.SnapshotDefaults.WarningDays > config.SnapshotDefaults.CriticalDays {
|
if config.SnapshotDefaults.CriticalDays > 0 && config.SnapshotDefaults.WarningDays > config.SnapshotDefaults.CriticalDays {
|
||||||
config.SnapshotDefaults.WarningDays = config.SnapshotDefaults.CriticalDays
|
config.SnapshotDefaults.WarningDays = config.SnapshotDefaults.CriticalDays
|
||||||
}
|
}
|
||||||
|
if config.SnapshotDefaults.CriticalDays == 0 && config.SnapshotDefaults.WarningDays > 0 {
|
||||||
|
config.SnapshotDefaults.CriticalDays = config.SnapshotDefaults.WarningDays
|
||||||
|
}
|
||||||
|
if config.SnapshotDefaults.WarningSizeGiB < 0 {
|
||||||
|
config.SnapshotDefaults.WarningSizeGiB = 0
|
||||||
|
}
|
||||||
|
if config.SnapshotDefaults.CriticalSizeGiB < 0 {
|
||||||
|
config.SnapshotDefaults.CriticalSizeGiB = 0
|
||||||
|
}
|
||||||
|
if config.SnapshotDefaults.CriticalSizeGiB > 0 && config.SnapshotDefaults.WarningSizeGiB > config.SnapshotDefaults.CriticalSizeGiB {
|
||||||
|
config.SnapshotDefaults.WarningSizeGiB = config.SnapshotDefaults.CriticalSizeGiB
|
||||||
|
}
|
||||||
|
if config.SnapshotDefaults.CriticalSizeGiB == 0 && config.SnapshotDefaults.WarningSizeGiB > 0 {
|
||||||
|
config.SnapshotDefaults.CriticalSizeGiB = config.SnapshotDefaults.WarningSizeGiB
|
||||||
|
}
|
||||||
if config.BackupDefaults.WarningDays < 0 {
|
if config.BackupDefaults.WarningDays < 0 {
|
||||||
config.BackupDefaults.WarningDays = 0
|
config.BackupDefaults.WarningDays = 0
|
||||||
}
|
}
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ type State struct {
|
||||||
VMs []VM `json:"vms"`
|
VMs []VM `json:"vms"`
|
||||||
Containers []Container `json:"containers"`
|
Containers []Container `json:"containers"`
|
||||||
DockerHosts []DockerHost `json:"dockerHosts"`
|
DockerHosts []DockerHost `json:"dockerHosts"`
|
||||||
|
Hosts []Host `json:"hosts"`
|
||||||
Storage []Storage `json:"storage"`
|
Storage []Storage `json:"storage"`
|
||||||
CephClusters []CephCluster `json:"cephClusters"`
|
CephClusters []CephCluster `json:"cephClusters"`
|
||||||
PhysicalDisks []PhysicalDisk `json:"physicalDisks"`
|
PhysicalDisks []PhysicalDisk `json:"physicalDisks"`
|
||||||
|
|
@ -137,6 +138,52 @@ type Container struct {
|
||||||
LastSeen time.Time `json:"lastSeen"`
|
LastSeen time.Time `json:"lastSeen"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Host represents a generic infrastructure host reporting via external agents.
|
||||||
|
type Host struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Hostname string `json:"hostname"`
|
||||||
|
DisplayName string `json:"displayName,omitempty"`
|
||||||
|
Platform string `json:"platform,omitempty"`
|
||||||
|
OSName string `json:"osName,omitempty"`
|
||||||
|
OSVersion string `json:"osVersion,omitempty"`
|
||||||
|
KernelVersion string `json:"kernelVersion,omitempty"`
|
||||||
|
Architecture string `json:"architecture,omitempty"`
|
||||||
|
CPUCount int `json:"cpuCount,omitempty"`
|
||||||
|
CPUUsage float64 `json:"cpuUsage,omitempty"`
|
||||||
|
Memory Memory `json:"memory"`
|
||||||
|
LoadAverage []float64 `json:"loadAverage,omitempty"`
|
||||||
|
Disks []Disk `json:"disks,omitempty"`
|
||||||
|
NetworkInterfaces []HostNetworkInterface `json:"networkInterfaces,omitempty"`
|
||||||
|
Sensors HostSensorSummary `json:"sensors,omitempty"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
UptimeSeconds int64 `json:"uptimeSeconds,omitempty"`
|
||||||
|
IntervalSeconds int `json:"intervalSeconds,omitempty"`
|
||||||
|
LastSeen time.Time `json:"lastSeen"`
|
||||||
|
AgentVersion string `json:"agentVersion,omitempty"`
|
||||||
|
TokenID string `json:"tokenId,omitempty"`
|
||||||
|
TokenName string `json:"tokenName,omitempty"`
|
||||||
|
TokenHint string `json:"tokenHint,omitempty"`
|
||||||
|
TokenLastUsedAt *time.Time `json:"tokenLastUsedAt,omitempty"`
|
||||||
|
Tags []string `json:"tags,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HostNetworkInterface describes a host network adapter summary.
|
||||||
|
type HostNetworkInterface struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
MAC string `json:"mac,omitempty"`
|
||||||
|
Addresses []string `json:"addresses,omitempty"`
|
||||||
|
RXBytes uint64 `json:"rxBytes,omitempty"`
|
||||||
|
TXBytes uint64 `json:"txBytes,omitempty"`
|
||||||
|
SpeedMbps *int64 `json:"speedMbps,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HostSensorSummary captures optional per-host sensor readings.
|
||||||
|
type HostSensorSummary struct {
|
||||||
|
TemperatureCelsius map[string]float64 `json:"temperatureCelsius,omitempty"`
|
||||||
|
FanRPM map[string]float64 `json:"fanRpm,omitempty"`
|
||||||
|
Additional map[string]float64 `json:"additional,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// DockerHost represents a Docker host reporting metrics via the external agent.
|
// DockerHost represents a Docker host reporting metrics via the external agent.
|
||||||
type DockerHost struct {
|
type DockerHost struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
|
|
@ -674,6 +721,7 @@ type GuestSnapshot struct {
|
||||||
Description string `json:"description,omitempty"`
|
Description string `json:"description,omitempty"`
|
||||||
Parent string `json:"parent,omitempty"`
|
Parent string `json:"parent,omitempty"`
|
||||||
VMState bool `json:"vmstate"`
|
VMState bool `json:"vmstate"`
|
||||||
|
SizeBytes int64 `json:"sizeBytes,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Performance represents performance metrics
|
// Performance represents performance metrics
|
||||||
|
|
@ -1046,6 +1094,91 @@ func (s *State) GetDockerHosts() []DockerHost {
|
||||||
return hosts
|
return hosts
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpsertHost inserts or updates a generic host in state.
|
||||||
|
func (s *State) UpsertHost(host Host) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
updated := false
|
||||||
|
for i, existing := range s.Hosts {
|
||||||
|
if existing.ID == host.ID {
|
||||||
|
s.Hosts[i] = host
|
||||||
|
updated = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !updated {
|
||||||
|
s.Hosts = append(s.Hosts, host)
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(s.Hosts, func(i, j int) bool {
|
||||||
|
return s.Hosts[i].Hostname < s.Hosts[j].Hostname
|
||||||
|
})
|
||||||
|
|
||||||
|
s.LastUpdate = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetHosts returns a copy of all generic hosts.
|
||||||
|
func (s *State) GetHosts() []Host {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
hosts := make([]Host, len(s.Hosts))
|
||||||
|
copy(hosts, s.Hosts)
|
||||||
|
return hosts
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveHost removes a host by ID and returns the removed entry.
|
||||||
|
func (s *State) RemoveHost(hostID string) (Host, bool) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
for i, host := range s.Hosts {
|
||||||
|
if host.ID == hostID {
|
||||||
|
s.Hosts = append(s.Hosts[:i], s.Hosts[i+1:]...)
|
||||||
|
s.LastUpdate = time.Now()
|
||||||
|
return host, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Host{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetHostStatus updates the status of a host if present.
|
||||||
|
func (s *State) SetHostStatus(hostID, status string) bool {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
for i, host := range s.Hosts {
|
||||||
|
if host.ID == hostID {
|
||||||
|
if host.Status != status {
|
||||||
|
host.Status = status
|
||||||
|
s.Hosts[i] = host
|
||||||
|
s.LastUpdate = time.Now()
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// TouchHost updates the last seen timestamp for a host.
|
||||||
|
func (s *State) TouchHost(hostID string, ts time.Time) bool {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
for i, host := range s.Hosts {
|
||||||
|
if host.ID == hostID {
|
||||||
|
host.LastSeen = ts
|
||||||
|
s.Hosts[i] = host
|
||||||
|
s.LastUpdate = time.Now()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// UpdateStorage updates the storage in the state
|
// UpdateStorage updates the storage in the state
|
||||||
func (s *State) UpdateStorage(storage []Storage) {
|
func (s *State) UpdateStorage(storage []Storage) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
|
|
|
||||||
|
|
@ -6431,6 +6431,17 @@ func (m *Monitor) pollGuestSnapshots(ctx context.Context, instanceName string, c
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(allSnapshots) > 0 {
|
||||||
|
sizeMap := m.collectSnapshotSizes(snapshotCtx, instanceName, client, allSnapshots)
|
||||||
|
if len(sizeMap) > 0 {
|
||||||
|
for i := range allSnapshots {
|
||||||
|
if size, ok := sizeMap[allSnapshots[i].ID]; ok && size > 0 {
|
||||||
|
allSnapshots[i].SizeBytes = size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Update state with guest snapshots for this instance
|
// Update state with guest snapshots for this instance
|
||||||
m.state.UpdateGuestSnapshotsForInstance(instanceName, allSnapshots)
|
m.state.UpdateGuestSnapshotsForInstance(instanceName, allSnapshots)
|
||||||
|
|
||||||
|
|
@ -6444,6 +6455,116 @@ func (m *Monitor) pollGuestSnapshots(ctx context.Context, instanceName string, c
|
||||||
Msg("Guest snapshots polled")
|
Msg("Guest snapshots polled")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Monitor) collectSnapshotSizes(ctx context.Context, instanceName string, client PVEClientInterface, snapshots []models.GuestSnapshot) map[string]int64 {
|
||||||
|
sizes := make(map[string]int64, len(snapshots))
|
||||||
|
if len(snapshots) == 0 {
|
||||||
|
return sizes
|
||||||
|
}
|
||||||
|
|
||||||
|
validSnapshots := make(map[string]struct{}, len(snapshots))
|
||||||
|
nodes := make(map[string]struct{})
|
||||||
|
|
||||||
|
for _, snap := range snapshots {
|
||||||
|
validSnapshots[snap.ID] = struct{}{}
|
||||||
|
if snap.Node != "" {
|
||||||
|
nodes[snap.Node] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(nodes) == 0 {
|
||||||
|
return sizes
|
||||||
|
}
|
||||||
|
|
||||||
|
seenVolids := make(map[string]struct{})
|
||||||
|
|
||||||
|
for nodeName := range nodes {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
storages, err := client.GetStorage(ctx, nodeName)
|
||||||
|
if err != nil {
|
||||||
|
log.Debug().
|
||||||
|
Err(err).
|
||||||
|
Str("node", nodeName).
|
||||||
|
Str("instance", instanceName).
|
||||||
|
Msg("Failed to get storage list for snapshot sizing")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, storage := range storages {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
contentTypes := strings.ToLower(storage.Content)
|
||||||
|
if !strings.Contains(contentTypes, "images") && !strings.Contains(contentTypes, "rootdir") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
contents, err := client.GetStorageContent(ctx, nodeName, storage.Storage)
|
||||||
|
if err != nil {
|
||||||
|
log.Debug().
|
||||||
|
Err(err).
|
||||||
|
Str("node", nodeName).
|
||||||
|
Str("storage", storage.Storage).
|
||||||
|
Str("instance", instanceName).
|
||||||
|
Msg("Failed to get storage content for snapshot sizing")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, item := range contents {
|
||||||
|
if item.VMID <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, seen := seenVolids[item.Volid]; seen {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
snapName := extractSnapshotName(item.Volid)
|
||||||
|
if snapName == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
key := fmt.Sprintf("%s-%s-%d-%s", instanceName, nodeName, item.VMID, snapName)
|
||||||
|
if _, ok := validSnapshots[key]; !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
seenVolids[item.Volid] = struct{}{}
|
||||||
|
|
||||||
|
size := int64(item.Size)
|
||||||
|
if size < 0 {
|
||||||
|
size = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
sizes[key] += size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sizes
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractSnapshotName(volid string) string {
|
||||||
|
if volid == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.SplitN(volid, ":", 2)
|
||||||
|
remainder := volid
|
||||||
|
if len(parts) == 2 {
|
||||||
|
remainder = parts[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
if idx := strings.Index(remainder, "@"); idx >= 0 && idx+1 < len(remainder) {
|
||||||
|
return strings.TrimSpace(remainder[idx+1:])
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// Stop gracefully stops the monitor
|
// Stop gracefully stops the monitor
|
||||||
func (m *Monitor) Stop() {
|
func (m *Monitor) Stop() {
|
||||||
log.Info().Msg("Stopping monitor")
|
log.Info().Msg("Stopping monitor")
|
||||||
|
|
|
||||||
122
internal/monitoring/monitor_snapshots_test.go
Normal file
122
internal/monitoring/monitor_snapshots_test.go
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
package monitoring
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
||||||
|
"github.com/rcourtman/pulse-go-rewrite/pkg/proxmox"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeSnapshotClient struct {
|
||||||
|
storages map[string][]proxmox.Storage
|
||||||
|
contents map[string]map[string][]proxmox.StorageContent
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f fakeSnapshotClient) GetNodes(ctx context.Context) ([]proxmox.Node, error) { return nil, nil }
|
||||||
|
func (f fakeSnapshotClient) GetNodeStatus(ctx context.Context, node string) (*proxmox.NodeStatus, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (f fakeSnapshotClient) GetNodeRRDData(ctx context.Context, node string, timeframe string, cf string, ds []string) ([]proxmox.NodeRRDPoint, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (f fakeSnapshotClient) GetVMs(ctx context.Context, node string) ([]proxmox.VM, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (f fakeSnapshotClient) GetContainers(ctx context.Context, node string) ([]proxmox.Container, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (f fakeSnapshotClient) GetStorage(ctx context.Context, node string) ([]proxmox.Storage, error) {
|
||||||
|
return f.storages[node], nil
|
||||||
|
}
|
||||||
|
func (f fakeSnapshotClient) GetAllStorage(ctx context.Context) ([]proxmox.Storage, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (f fakeSnapshotClient) GetBackupTasks(ctx context.Context) ([]proxmox.Task, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (f fakeSnapshotClient) GetStorageContent(ctx context.Context, node, storage string) ([]proxmox.StorageContent, error) {
|
||||||
|
if storageContents, ok := f.contents[node]; ok {
|
||||||
|
return storageContents[storage], nil
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (f fakeSnapshotClient) GetVMSnapshots(ctx context.Context, node string, vmid int) ([]proxmox.Snapshot, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (f fakeSnapshotClient) GetContainerSnapshots(ctx context.Context, node string, vmid int) ([]proxmox.Snapshot, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (f fakeSnapshotClient) GetVMStatus(ctx context.Context, node string, vmid int) (*proxmox.VMStatus, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (f fakeSnapshotClient) GetContainerStatus(ctx context.Context, node string, vmid int) (*proxmox.Container, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (f fakeSnapshotClient) GetClusterResources(ctx context.Context, resourceType string) ([]proxmox.ClusterResource, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (f fakeSnapshotClient) IsClusterMember(ctx context.Context) (bool, error) { return false, nil }
|
||||||
|
func (f fakeSnapshotClient) GetVMFSInfo(ctx context.Context, node string, vmid int) ([]proxmox.VMFileSystem, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (f fakeSnapshotClient) GetVMNetworkInterfaces(ctx context.Context, node string, vmid int) ([]proxmox.VMNetworkInterface, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (f fakeSnapshotClient) GetVMAgentInfo(ctx context.Context, node string, vmid int) (map[string]interface{}, error) {
|
||||||
|
return map[string]interface{}{}, nil
|
||||||
|
}
|
||||||
|
func (f fakeSnapshotClient) GetZFSPoolStatus(ctx context.Context, node string) ([]proxmox.ZFSPoolStatus, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (f fakeSnapshotClient) GetZFSPoolsWithDetails(ctx context.Context, node string) ([]proxmox.ZFSPoolInfo, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (f fakeSnapshotClient) GetDisks(ctx context.Context, node string) ([]proxmox.Disk, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (f fakeSnapshotClient) GetCephStatus(ctx context.Context) (*proxmox.CephStatus, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (f fakeSnapshotClient) GetCephDF(ctx context.Context) (*proxmox.CephDF, error) { return nil, nil }
|
||||||
|
|
||||||
|
func TestCollectSnapshotSizes(t *testing.T) {
|
||||||
|
m := &Monitor{}
|
||||||
|
snapshots := []models.GuestSnapshot{
|
||||||
|
{
|
||||||
|
ID: "inst-node1-100-pre",
|
||||||
|
Name: "pre",
|
||||||
|
Node: "node1",
|
||||||
|
Instance: "inst",
|
||||||
|
Type: "qemu",
|
||||||
|
VMID: 100,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
client := fakeSnapshotClient{
|
||||||
|
storages: map[string][]proxmox.Storage{
|
||||||
|
"node1": {
|
||||||
|
{Storage: "local-zfs", Content: "images"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
contents: map[string]map[string][]proxmox.StorageContent{
|
||||||
|
"node1": {
|
||||||
|
"local-zfs": {
|
||||||
|
{Volid: "local-zfs:vm-100-disk-0@pre", VMID: 100, Size: 20 << 30},
|
||||||
|
// Duplicate entry should be deduped via volid tracking
|
||||||
|
{Volid: "local-zfs:vm-100-disk-0@pre", VMID: 100, Size: 20 << 30},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
sizes := m.collectSnapshotSizes(context.Background(), "inst", client, snapshots)
|
||||||
|
got, ok := sizes[snapshots[0].ID]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected size entry for snapshot")
|
||||||
|
}
|
||||||
|
want := int64(20 << 30)
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("unexpected size: got %d want %d", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ package notifications
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
|
@ -171,6 +172,15 @@ func copyAppriseConfig(cfg AppriseConfig) AppriseConfig {
|
||||||
// NormalizeAppriseConfig cleans and normalizes Apprise configuration values.
|
// NormalizeAppriseConfig cleans and normalizes Apprise configuration values.
|
||||||
func NormalizeAppriseConfig(cfg AppriseConfig) AppriseConfig {
|
func NormalizeAppriseConfig(cfg AppriseConfig) AppriseConfig {
|
||||||
normalized := cfg
|
normalized := cfg
|
||||||
|
|
||||||
|
mode := strings.ToLower(strings.TrimSpace(string(normalized.Mode)))
|
||||||
|
switch mode {
|
||||||
|
case string(AppriseModeHTTP):
|
||||||
|
normalized.Mode = AppriseModeHTTP
|
||||||
|
default:
|
||||||
|
normalized.Mode = AppriseModeCLI
|
||||||
|
}
|
||||||
|
|
||||||
normalized.CLIPath = strings.TrimSpace(normalized.CLIPath)
|
normalized.CLIPath = strings.TrimSpace(normalized.CLIPath)
|
||||||
if normalized.CLIPath == "" {
|
if normalized.CLIPath == "" {
|
||||||
normalized.CLIPath = "apprise"
|
normalized.CLIPath = "apprise"
|
||||||
|
|
@ -198,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)
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,11 @@ package notifications
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -24,10 +29,15 @@ func TestNormalizeAppriseConfig(t *testing.T) {
|
||||||
Targets: []string{" discord://token ", "", "DISCORD://TOKEN"},
|
Targets: []string{" discord://token ", "", "DISCORD://TOKEN"},
|
||||||
CLIPath: " ",
|
CLIPath: " ",
|
||||||
TimeoutSeconds: -5,
|
TimeoutSeconds: -5,
|
||||||
|
APIKeyHeader: "",
|
||||||
}
|
}
|
||||||
|
|
||||||
normalized := NormalizeAppriseConfig(original)
|
normalized := NormalizeAppriseConfig(original)
|
||||||
|
|
||||||
|
if normalized.Mode != AppriseModeCLI {
|
||||||
|
t.Fatalf("expected default mode cli, got %q", normalized.Mode)
|
||||||
|
}
|
||||||
|
|
||||||
if normalized.CLIPath != "apprise" {
|
if normalized.CLIPath != "apprise" {
|
||||||
t.Fatalf("expected default CLI path 'apprise', got %q", normalized.CLIPath)
|
t.Fatalf("expected default CLI path 'apprise', got %q", normalized.CLIPath)
|
||||||
}
|
}
|
||||||
|
|
@ -44,11 +54,51 @@ func TestNormalizeAppriseConfig(t *testing.T) {
|
||||||
t.Fatalf("unexpected targets normalization result: %#v", normalized.Targets)
|
t.Fatalf("unexpected targets normalization result: %#v", normalized.Targets)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if normalized.APIKeyHeader != "X-API-KEY" {
|
||||||
|
t.Fatalf("expected default API key header, got %q", normalized.APIKeyHeader)
|
||||||
|
}
|
||||||
|
|
||||||
// When all targets removed, enabled should reset to false
|
// When all targets removed, enabled should reset to false
|
||||||
empty := NormalizeAppriseConfig(AppriseConfig{Enabled: true})
|
empty := NormalizeAppriseConfig(AppriseConfig{Enabled: true})
|
||||||
if empty.Enabled {
|
if empty.Enabled {
|
||||||
t.Fatalf("expected enabled to be false when no targets configured")
|
t.Fatalf("expected enabled to be false when no targets configured")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
httpConfig := NormalizeAppriseConfig(AppriseConfig{
|
||||||
|
Enabled: true,
|
||||||
|
Mode: AppriseModeHTTP,
|
||||||
|
ServerURL: "https://apprise.example.com/api/",
|
||||||
|
APIKey: " secret ",
|
||||||
|
APIKeyHeader: " X-Token ",
|
||||||
|
TimeoutSeconds: 200,
|
||||||
|
})
|
||||||
|
|
||||||
|
if httpConfig.Mode != AppriseModeHTTP {
|
||||||
|
t.Fatalf("expected HTTP mode, got %q", httpConfig.Mode)
|
||||||
|
}
|
||||||
|
if httpConfig.ServerURL != "https://apprise.example.com/api" {
|
||||||
|
t.Fatalf("expected server URL to be trimmed, got %q", httpConfig.ServerURL)
|
||||||
|
}
|
||||||
|
if httpConfig.APIKey != "secret" {
|
||||||
|
t.Fatalf("expected API key to be trimmed, got %q", httpConfig.APIKey)
|
||||||
|
}
|
||||||
|
if httpConfig.APIKeyHeader != "X-Token" {
|
||||||
|
t.Fatalf("expected API key header to be trimmed, got %q", httpConfig.APIKeyHeader)
|
||||||
|
}
|
||||||
|
if httpConfig.TimeoutSeconds != 120 {
|
||||||
|
t.Fatalf("expected timeout to clamp to 120, got %d", httpConfig.TimeoutSeconds)
|
||||||
|
}
|
||||||
|
if !httpConfig.Enabled {
|
||||||
|
t.Fatalf("expected HTTP config with server URL to remain enabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
disabledHTTP := NormalizeAppriseConfig(AppriseConfig{
|
||||||
|
Enabled: true,
|
||||||
|
Mode: AppriseModeHTTP,
|
||||||
|
})
|
||||||
|
if disabledHTTP.Enabled {
|
||||||
|
t.Fatalf("expected HTTP config without server URL to disable notifications")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSendGroupedAppriseInvokesExecutor(t *testing.T) {
|
func TestSendGroupedAppriseInvokesExecutor(t *testing.T) {
|
||||||
|
|
@ -108,6 +158,127 @@ func TestSendGroupedAppriseInvokesExecutor(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSendGroupedAppriseHTTP(t *testing.T) {
|
||||||
|
nm := NewNotificationManager("https://pulse.local")
|
||||||
|
nm.SetGroupingWindow(0)
|
||||||
|
nm.SetEmailConfig(EmailConfig{Enabled: false})
|
||||||
|
|
||||||
|
type apprisePayload struct {
|
||||||
|
Body string `json:"body"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
URLs []string `json:"urls"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type capturedRequest struct {
|
||||||
|
Method string
|
||||||
|
Path string
|
||||||
|
ContentType string
|
||||||
|
APIKey string
|
||||||
|
Payload apprisePayload
|
||||||
|
}
|
||||||
|
|
||||||
|
requests := make(chan capturedRequest, 1)
|
||||||
|
errs := make(chan error, 1)
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
defer r.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
errs <- err
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload apprisePayload
|
||||||
|
if err := json.Unmarshal(body, &payload); err != nil {
|
||||||
|
errs <- err
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
requests <- capturedRequest{
|
||||||
|
Method: r.Method,
|
||||||
|
Path: r.URL.Path,
|
||||||
|
ContentType: r.Header.Get("Content-Type"),
|
||||||
|
APIKey: r.Header.Get("X-Test-Key"),
|
||||||
|
Payload: payload,
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
nm.SetAppriseConfig(AppriseConfig{
|
||||||
|
Enabled: true,
|
||||||
|
Mode: AppriseModeHTTP,
|
||||||
|
ServerURL: server.URL,
|
||||||
|
ConfigKey: "primary",
|
||||||
|
APIKey: "secret",
|
||||||
|
APIKeyHeader: "X-Test-Key",
|
||||||
|
Targets: []string{"discord://token"},
|
||||||
|
TimeoutSeconds: 10,
|
||||||
|
})
|
||||||
|
|
||||||
|
alert := &alerts.Alert{
|
||||||
|
ID: "test",
|
||||||
|
Type: "cpu",
|
||||||
|
Level: alerts.AlertLevelCritical,
|
||||||
|
ResourceID: "vm-100",
|
||||||
|
ResourceName: "vm-100",
|
||||||
|
Message: "CPU usage high",
|
||||||
|
Value: 95,
|
||||||
|
Threshold: 90,
|
||||||
|
StartTime: time.Now().Add(-time.Minute),
|
||||||
|
LastSeen: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
nm.mu.Lock()
|
||||||
|
nm.pendingAlerts = append(nm.pendingAlerts, alert)
|
||||||
|
nm.mu.Unlock()
|
||||||
|
|
||||||
|
nm.sendGroupedAlerts()
|
||||||
|
|
||||||
|
var req capturedRequest
|
||||||
|
select {
|
||||||
|
case req = <-requests:
|
||||||
|
case err := <-errs:
|
||||||
|
t.Fatalf("server error: %v", err)
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatalf("timed out waiting for Apprise API request")
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Method != http.MethodPost {
|
||||||
|
t.Fatalf("expected POST request, got %s", req.Method)
|
||||||
|
}
|
||||||
|
if req.Path != "/notify/primary" {
|
||||||
|
t.Fatalf("expected notify path with config key, got %s", req.Path)
|
||||||
|
}
|
||||||
|
if req.ContentType != "application/json" {
|
||||||
|
t.Fatalf("expected JSON content type, got %s", req.ContentType)
|
||||||
|
}
|
||||||
|
if req.APIKey != "secret" {
|
||||||
|
t.Fatalf("expected API key header to be set, got %q", req.APIKey)
|
||||||
|
}
|
||||||
|
if req.Payload.Title != "Pulse alert: vm-100" {
|
||||||
|
t.Fatalf("unexpected title: %s", req.Payload.Title)
|
||||||
|
}
|
||||||
|
if req.Payload.Type != "failure" {
|
||||||
|
t.Fatalf("expected failure notification type, got %s", req.Payload.Type)
|
||||||
|
}
|
||||||
|
if len(req.Payload.URLs) != 1 || req.Payload.URLs[0] != "discord://token" {
|
||||||
|
t.Fatalf("unexpected URLs in payload: %#v", req.Payload.URLs)
|
||||||
|
}
|
||||||
|
if !strings.Contains(req.Payload.Body, "CPU usage high") {
|
||||||
|
t.Fatalf("expected alert message in payload body, got %s", req.Payload.Body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(req.Payload.Body, "Dashboard: https://pulse.local") {
|
||||||
|
t.Fatalf("expected dashboard link in payload body, got %s", req.Payload.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNotificationCooldownAllowsNewAlertInstance(t *testing.T) {
|
func TestNotificationCooldownAllowsNewAlertInstance(t *testing.T) {
|
||||||
nm := NewNotificationManager("")
|
nm := NewNotificationManager("")
|
||||||
nm.SetCooldown(1) // 1 minute cooldown
|
nm.SetCooldown(1) // 1 minute cooldown
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue