Adjust backup and snapshot alert handling

This commit is contained in:
Pulse Automation Bot 2025-10-18 20:11:01 +00:00
parent 80b9d0602a
commit cfdfe896be
27 changed files with 1565 additions and 207 deletions

View file

@ -98,15 +98,25 @@ function App() {
cephClusters: [],
physicalDisks: [],
pbs: [],
pmg: [],
metrics: [],
pveBackups: {
pmg: [],
metrics: [],
pveBackups: {
backupTasks: [],
storageBackups: [],
guestSnapshots: [],
},
pbsBackups: [],
pmgBackups: [],
backups: {
pve: {
backupTasks: [],
storageBackups: [],
guestSnapshots: [],
},
pbsBackups: [],
performance: {
pbs: [],
pmg: [],
},
performance: {
apiCallDuration: {},
lastPollDuration: 0,
pollingStartTime: '',

View file

@ -54,6 +54,7 @@ export interface Resource {
host?: string;
type?: string;
resourceType?: string;
subtitle?: string;
thresholds?: Record<string, number | undefined>;
defaults?: Record<string, number | undefined>;
disabled?: boolean;
@ -68,6 +69,12 @@ export interface Resource {
clusterName?: string;
isClusterMember?: boolean;
delaySeconds?: number;
editScope?: 'snapshot';
isEnabled?: boolean;
toggleEnabled?: () => void;
toggleTitleEnabled?: string;
toggleTitleDisabled?: string;
editable?: boolean;
[key: string]: unknown;
}
@ -386,9 +393,7 @@ export function ResourceTable(props: ResourceTableProps) {
titleEnabled?: string;
titleDisabled?: string;
titleWhenDisabled?: string;
}) => {
return <StatusBadge {...config} />;
};
}) => <StatusBadge {...config} />;
const offlineStateOrder: OfflineState[] = ['off', 'warning', 'critical'];
@ -826,61 +831,67 @@ export function ResourceTable(props: ResourceTableProps) {
</Show>
</td>
<td class="p-1 px-2">
<Show when={resource.type === 'node'} fallback={
<div class="flex items-center gap-2">
<span
class={`text-sm font-medium ${resource.disabled ? 'text-gray-500 dark:text-gray-500' : 'text-gray-900 dark:text-gray-100'}`}
>
{resource.name}
</span>
<Show when={resource.hasOverride || resource.disableConnectivity}>
<span class="text-xs px-1.5 py-0.5 bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 rounded">
Custom
</span>
</Show>
</div>
}>
<div class="flex flex-wrap items-center gap-3" title={resource.status || undefined}>
<Show when={resource.host} fallback={
<div class="flex items-center gap-2">
<Show
when={resource.type === 'node'}
fallback={
<span
class={`text-sm font-medium ${resource.disabled ? 'text-gray-500 dark:text-gray-500' : 'text-gray-900 dark:text-gray-100'}`}
>
{resource.type === 'node'
? resource.name
: resource.displayName || resource.name}
{resource.name}
</span>
}>
{(host) => (
<a
href={host() as string}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
class={`text-sm font-medium transition-colors duration-150 ${
resource.disabled
? 'text-gray-500 dark:text-gray-500'
: 'text-gray-900 dark:text-gray-100 hover:text-sky-600 dark:hover:text-sky-400'
}`}
title={`Open ${resource.displayName || resource.name} web interface`}
>
{resource.type === 'node'
? resource.name
: resource.displayName || resource.name}
</a>
)}
</Show>
<Show when={resource.clusterName}>
<span class="rounded px-2 py-0.5 text-[10px] font-medium whitespace-nowrap bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300">
{resource.clusterName}
</span>
</Show>
<Show when={resource.hasOverride || resource.disableConnectivity}>
<span class="text-xs px-1.5 py-0.5 bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 rounded">
Custom
</span>
</Show>
</div>
</Show>
}
>
<div class="flex flex-wrap items-center gap-3" title={resource.status || undefined}>
<Show
when={resource.host}
fallback={
<span
class={`text-sm font-medium ${resource.disabled ? 'text-gray-500 dark:text-gray-500' : 'text-gray-900 dark:text-gray-100'}`}
>
{resource.type === 'node'
? resource.name
: resource.displayName || resource.name}
</span>
}
>
{(host) => (
<a
href={host() as string}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
class={`text-sm font-medium transition-colors duration-150 ${
resource.disabled
? 'text-gray-500 dark:text-gray-500'
: 'text-gray-900 dark:text-gray-100 hover:text-sky-600 dark:hover:text-sky-400'
}`}
title={`Open ${resource.displayName || resource.name} web interface`}
>
{resource.type === 'node'
? resource.name
: resource.displayName || resource.name}
</a>
)}
</Show>
<Show when={resource.clusterName}>
<span class="rounded px-2 py-0.5 text-[10px] font-medium whitespace-nowrap bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300">
{resource.clusterName}
</span>
</Show>
</div>
</Show>
<Show when={resource.subtitle}>
<span class="text-xs text-gray-500 dark:text-gray-400">
{resource.subtitle as string}
</span>
</Show>
<Show when={resource.hasOverride || resource.disableConnectivity}>
<span class="text-xs px-1.5 py-0.5 bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 rounded">
Custom
</span>
</Show>
</div>
</td>
{/* Metric columns - dynamically rendered based on resource type */}
<For each={props.columns}>
@ -1279,14 +1290,7 @@ export function ResourceTable(props: ResourceTableProps) {
{/* Metric columns - dynamically rendered based on resource type */}
<For each={props.columns}>
{(column) => {
const metric = column
.toLowerCase()
.replace(' %', '')
.replace(' mb/s', '')
.replace('disk r', 'diskRead')
.replace('disk w', 'diskWrite')
.replace('net in', 'networkIn')
.replace('net out', 'networkOut');
const metric = normalizeMetricKey(column);
// Check if this metric applies to this resource type
const showMetric = () => {
@ -1312,6 +1316,9 @@ export function ResourceTable(props: ResourceTableProps) {
const openMetricEditor = (e: MouseEvent) => {
e.stopPropagation();
if (resource.editable === false) {
return;
}
setActiveMetricInput({ resourceId: resource.id, metric });
props.onEdit(
resource.id,
@ -1438,7 +1445,20 @@ export function ResourceTable(props: ResourceTableProps) {
{/* Actions column */}
<td class="p-1 px-2">
<div class="flex items-center justify-center gap-1">
<Show when={typeof resource.toggleEnabled === 'function'}>
<StatusBadge
size="sm"
isEnabled={resource.isEnabled ?? true}
onToggle={resource.toggleEnabled}
titleEnabled={resource.toggleTitleEnabled}
titleDisabled={resource.toggleTitleDisabled}
/>
</Show>
<Show
when={resource.editable !== false && typeof props.onEdit === 'function'}
fallback={<span class="text-xs text-gray-400 dark:text-gray-600"></span>}
>
<Show
when={!isEditing()}
fallback={
<button
@ -1466,43 +1486,18 @@ export function ResourceTable(props: ResourceTableProps) {
</button>
}
>
<button
type="button"
onClick={() =>
props.onEdit(
resource.id,
resource.thresholds ? { ...resource.thresholds } : {},
resource.defaults ? { ...resource.defaults } : {},
)
}
class="p-1 text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
title="Edit thresholds"
>
<svg
class="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
</button>
<Show
when={
resource.hasOverride ||
(resource.type === 'node' && resource.disableConnectivity)
}
>
<div class="flex items-center gap-1">
<button
type="button"
onClick={() => props.onRemoveOverride(resource.id)}
class="p-1 text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
title="Remove override"
onClick={() =>
props.onEdit(
resource.id,
resource.thresholds ? { ...resource.thresholds } : {},
resource.defaults ? { ...resource.defaults } : {},
)
}
class="p-1 text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
title="Edit thresholds"
>
<svg
class="w-4 h-4"
@ -1514,14 +1509,42 @@ export function ResourceTable(props: ResourceTableProps) {
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
</button>
</Show>
<Show
when={
resource.hasOverride ||
(resource.type === 'node' && resource.disableConnectivity)
}
>
<button
type="button"
onClick={() => props.onRemoveOverride(resource.id)}
class="p-1 text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
title="Remove override"
>
<svg
class="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</Show>
</div>
</Show>
</div>
</td>
</Show>
</div>
</td>
</tr>
);
}}

View file

@ -14,8 +14,12 @@ import type {
PMGInstance,
DockerHost,
DockerContainer,
PVEBackups,
PBSBackup,
PMGBackup,
Backups,
} from '@/types/api';
import type { RawOverrideConfig, PMGThresholdDefaults, SnapshotAlertConfig } from '@/types/alerts';
import type { RawOverrideConfig, PMGThresholdDefaults, SnapshotAlertConfig, BackupAlertConfig } from '@/types/alerts';
import { ResourceTable, Resource, GroupHeaderMeta } from './ResourceTable';
type OverrideType =
| 'guest'
@ -103,6 +107,11 @@ export const normalizeDockerIgnoredInput = (value: string): string[] =>
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
const DEFAULT_SNAPSHOT_WARNING = 30;
const DEFAULT_SNAPSHOT_CRITICAL = 45;
const DEFAULT_BACKUP_WARNING = 7;
const DEFAULT_BACKUP_CRITICAL = 14;
// Simple threshold object for the UI
interface SimpleThresholds {
cpu?: number;
@ -127,6 +136,10 @@ interface ThresholdsTableProps {
dockerHosts: DockerHost[];
pbsInstances?: PBSInstance[]; // PBS instances from state
pmgInstances?: PMGInstance[]; // PMG instances from state
backups?: Backups;
pveBackups?: PVEBackups;
pbsBackups?: PBSBackup[];
pmgBackups?: PMGBackup[];
pmgThresholds: () => PMGThresholdDefaults;
setPMGThresholds: (
value:
@ -177,6 +190,14 @@ interface ThresholdsTableProps {
) => void;
snapshotFactoryDefaults?: SnapshotAlertConfig;
resetSnapshotDefaults?: () => void;
backupDefaults: () => BackupAlertConfig;
setBackupDefaults: (
value:
| BackupAlertConfig
| ((prev: BackupAlertConfig) => BackupAlertConfig),
) => void;
backupFactoryDefaults?: BackupAlertConfig;
resetBackupDefaults?: () => void;
setHasUnsavedChanges: (value: boolean) => void;
activeAlerts?: Record<string, Alert>;
removeAlerts?: (predicate: (alert: Alert) => boolean) => void;
@ -208,9 +229,6 @@ interface ThresholdsTableProps {
setDisableAllDockerHostsOffline: (value: boolean) => void;
}
const DEFAULT_SNAPSHOT_WARNING = 30;
const DEFAULT_SNAPSHOT_CRITICAL = 45;
export function ThresholdsTable(props: ThresholdsTableProps) {
const navigate = useNavigate();
const location = useLocation();
@ -803,26 +821,88 @@ const dockerContainersGroupedByHost = createMemo<Record<string, Resource[]>>((pr
};
});
const snapshotFactoryDefaultsRecord = createMemo(() => {
const factory = snapshotFactoryConfig();
const backupFactoryConfig = () =>
props.backupFactoryDefaults ?? {
enabled: false,
warningDays: DEFAULT_BACKUP_WARNING,
criticalDays: DEFAULT_BACKUP_CRITICAL,
};
const sanitizeBackupConfig = (config: BackupAlertConfig): BackupAlertConfig => {
let warning = Math.max(0, Math.round(config.warningDays ?? 0));
let critical = Math.max(0, Math.round(config.criticalDays ?? 0));
if (critical > 0 && warning > critical) {
warning = critical;
}
if (critical === 0 && warning > 0) {
critical = warning;
}
return {
'warning days': factory.warningDays ?? DEFAULT_SNAPSHOT_WARNING,
'critical days': factory.criticalDays ?? DEFAULT_SNAPSHOT_CRITICAL,
enabled: !!config.enabled,
warningDays: warning,
criticalDays: critical,
};
};
const updateBackupDefaults = (
updater:
| BackupAlertConfig
| ((prev: BackupAlertConfig) => BackupAlertConfig),
) => {
props.setBackupDefaults((prev) => {
const next =
typeof updater === 'function'
? (updater as (prev: BackupAlertConfig) => BackupAlertConfig)(prev)
: { ...prev, ...updater };
return sanitizeBackupConfig(next);
});
props.setHasUnsavedChanges(true);
};
const backupDefaultsRecord = createMemo(() => {
const current = props.backupDefaults();
return {
'warning days': current.warningDays ?? 0,
'critical days': current.criticalDays ?? 0,
};
});
const snapshotOverridesCount = createMemo(() => {
const current = props.snapshotDefaults();
const factory = snapshotFactoryConfig();
return current.enabled !== factory.enabled ||
(current.warningDays ?? DEFAULT_SNAPSHOT_WARNING) !==
(factory.warningDays ?? DEFAULT_SNAPSHOT_WARNING) ||
(current.criticalDays ?? DEFAULT_SNAPSHOT_CRITICAL) !==
(factory.criticalDays ?? DEFAULT_SNAPSHOT_CRITICAL)
const backupFactoryDefaultsRecord = createMemo(() => {
const factory = backupFactoryConfig();
return {
'warning days': factory.warningDays ?? DEFAULT_BACKUP_WARNING,
'critical days': factory.criticalDays ?? DEFAULT_BACKUP_CRITICAL,
};
});
const snapshotOverridesCount = createMemo(() => {
const current = props.snapshotDefaults();
const factory = snapshotFactoryConfig();
return current.enabled !== factory.enabled ||
(current.warningDays ?? DEFAULT_SNAPSHOT_WARNING) !==
(factory.warningDays ?? DEFAULT_SNAPSHOT_WARNING) ||
(current.criticalDays ?? DEFAULT_SNAPSHOT_CRITICAL) !==
(factory.criticalDays ?? DEFAULT_SNAPSHOT_CRITICAL)
? 1
: 0;
});
const backupOverridesCount = createMemo(() => {
const backupCurrent = props.backupDefaults();
const backupFactory = backupFactoryConfig();
return backupCurrent.enabled !== backupFactory.enabled ||
(backupCurrent.warningDays ?? DEFAULT_BACKUP_WARNING) !==
(backupFactory.warningDays ?? DEFAULT_BACKUP_WARNING) ||
(backupCurrent.criticalDays ?? DEFAULT_BACKUP_CRITICAL) !==
(backupFactory.criticalDays ?? DEFAULT_BACKUP_CRITICAL)
? 1
: 0;
});
// Process guests with their overrides and group by node
const guestsGroupedByNode = createMemo<Record<string, Resource[]>>((prev = {}) => {
// If we're currently editing, return the previous value to avoid re-renders
@ -1161,18 +1241,25 @@ const dockerContainersGroupedByHost = createMemo<Record<string, Resource[]>>((pr
},
{
key: 'storage' as const,
label: 'Storage',
total: props.storage?.length ?? 0,
overrides: countOverrides(storageWithOverrides()),
tab: 'proxmox' as const,
},
{
key: 'snapshots' as const,
label: 'Snapshots',
total: 1,
overrides: snapshotOverridesCount(),
tab: 'proxmox' as const,
},
label: 'Storage',
total: props.storage?.length ?? 0,
overrides: countOverrides(storageWithOverrides()),
tab: 'proxmox' as const,
},
{
key: 'backups' as const,
label: 'Backups',
total: 1,
overrides: backupOverridesCount(),
tab: 'proxmox' as const,
},
{
key: 'snapshots' as const,
label: 'Snapshot Age',
total: 1,
overrides: snapshotOverridesCount(),
tab: 'proxmox' as const,
},
{
key: 'pbs' as const,
label: 'PBS Servers',
@ -1240,6 +1327,41 @@ const dockerContainersGroupedByHost = createMemo<Record<string, Resource[]>>((pr
if (!resource) return;
const editedThresholds = editingThresholds();
if (resource.editScope === 'backup') {
const currentBackupDefaults = props.backupDefaults();
const nextWarning =
editedThresholds['warning days'] ?? currentBackupDefaults.warningDays ?? DEFAULT_BACKUP_WARNING;
const nextCritical =
editedThresholds['critical days'] ?? currentBackupDefaults.criticalDays ?? DEFAULT_BACKUP_CRITICAL;
updateBackupDefaults({
enabled: currentBackupDefaults.enabled,
warningDays: nextWarning,
criticalDays: nextCritical,
});
cancelEdit();
return;
}
if (resource.editScope === 'snapshot') {
const currentSnapshotDefaults = props.snapshotDefaults();
const nextWarning =
editedThresholds['warning days'] ?? currentSnapshotDefaults.warningDays ?? DEFAULT_SNAPSHOT_WARNING;
const nextCritical =
editedThresholds['critical days'] ?? currentSnapshotDefaults.criticalDays ?? DEFAULT_SNAPSHOT_CRITICAL;
updateSnapshotDefaults({
enabled: currentSnapshotDefaults.enabled,
warningDays: nextWarning,
criticalDays: nextCritical,
});
cancelEdit();
return;
}
const defaultThresholds = (resource.defaults ?? {}) as Record<string, number | undefined>;
// Only include values that differ from defaults
@ -1969,11 +2091,74 @@ const dockerContainersGroupedByHost = createMemo<Record<string, Resource[]>>((pr
</div>
</Show>
<Show when={hasSection('backups')}>
<div ref={registerSection('backups')} class="scroll-mt-24">
<ResourceTable
title="Backups"
resources={[{ id: "backups-defaults", name: "Global Defaults", thresholds: backupDefaultsRecord(), defaults: backupDefaultsRecord(), editable: true, editScope: "backup" }]}
columns={['Warning Days', 'Critical Days']}
activeAlerts={props.activeAlerts}
emptyMessage=""
onEdit={startEditing}
onSaveEdit={saveEdit}
onCancelEdit={cancelEdit}
onRemoveOverride={removeOverride}
showOfflineAlertsColumn={false}
editingId={editingId}
editingThresholds={editingThresholds}
setEditingThresholds={setEditingThresholds}
formatMetricValue={formatMetricValue}
hasActiveAlert={hasActiveAlert}
globalDefaults={backupDefaultsRecord()}
setGlobalDefaults={(value) => {
updateBackupDefaults((prev) => {
const currentRecord = {
'warning days': prev.warningDays ?? 0,
'critical days': prev.criticalDays ?? 0,
};
const nextRecord =
typeof value === 'function'
? value(currentRecord)
: { ...currentRecord, ...value };
return {
...prev,
warningDays:
typeof nextRecord['warning days'] === 'number'
? nextRecord['warning days']
: prev.warningDays,
criticalDays:
typeof nextRecord['critical days'] === 'number'
? nextRecord['critical days']
: prev.criticalDays,
};
});
}}
setHasUnsavedChanges={props.setHasUnsavedChanges}
globalDisableFlag={() => !props.backupDefaults().enabled}
onToggleGlobalDisable={() =>
updateBackupDefaults((prev) => ({
...prev,
enabled: !prev.enabled,
}))
}
factoryDefaults={backupFactoryDefaultsRecord()}
onResetDefaults={() => {
if (props.resetBackupDefaults) {
props.resetBackupDefaults();
props.setHasUnsavedChanges(true);
} else {
updateBackupDefaults(backupFactoryConfig());
}
}}
/>
</div>
</Show>
<Show when={hasSection('snapshots')}>
<div ref={registerSection('snapshots')} class="scroll-mt-24">
<ResourceTable
title="Snapshot Age"
resources={[]}
resources={[{ id: "snapshots-defaults", name: "Global Defaults", thresholds: snapshotDefaultsRecord(), defaults: snapshotDefaultsRecord(), editable: true, editScope: "snapshot" }]}
columns={['Warning Days', 'Critical Days']}
activeAlerts={props.activeAlerts}
emptyMessage=""
@ -2019,18 +2204,15 @@ const dockerContainersGroupedByHost = createMemo<Record<string, Resource[]>>((pr
enabled: !prev.enabled,
}))
}
factoryDefaults={snapshotFactoryDefaultsRecord()}
onResetDefaults={
props.resetSnapshotDefaults
? () => {
props.resetSnapshotDefaults?.();
props.setHasUnsavedChanges(true);
}
: () =>
updateSnapshotDefaults({
...snapshotFactoryConfig(),
})
}
factoryDefaults={snapshotFactoryConfig()}
onResetDefaults={() => {
if (props.resetSnapshotDefaults) {
props.resetSnapshotDefaults();
props.setHasUnsavedChanges(true);
} else {
updateSnapshotDefaults(snapshotFactoryConfig());
}
}}
/>
</div>
</Show>

View file

@ -6,7 +6,7 @@ import {
ThresholdsTable,
normalizeDockerIgnoredInput,
} from '../ThresholdsTable';
import type { PMGThresholdDefaults, SnapshotAlertConfig } from '@/types/alerts';
import type { PMGThresholdDefaults, SnapshotAlertConfig, BackupAlertConfig } from '@/types/alerts';
vi.mock('@solidjs/router', () => ({
useNavigate: () => vi.fn(),
@ -87,6 +87,10 @@ const baseProps = () => ({
factoryNodeDefaults: {},
factoryDockerDefaults: {},
factoryStorageDefault: 85,
backupDefaults: () => ({ enabled: false, warningDays: 7, criticalDays: 14 }),
setBackupDefaults: vi.fn(),
backupFactoryDefaults: { enabled: false, warningDays: 7, criticalDays: 14 } as BackupAlertConfig,
resetBackupDefaults: vi.fn(),
snapshotDefaults: () => ({ enabled: false, warningDays: 30, criticalDays: 45 }),
setSnapshotDefaults: vi.fn(),
snapshotFactoryDefaults: { enabled: false, warningDays: 30, criticalDays: 45 } as SnapshotAlertConfig,

View file

@ -13,7 +13,15 @@ const Backups: Component = () => {
<ProxmoxSectionNav current="backups" />
{/* Loading State */}
<Show when={connected() && !state.pveBackups && !state.pbs}>
<Show
when={
connected() &&
!(state.backups?.pve?.guestSnapshots?.length ||
state.backups?.pve?.storageBackups?.length ||
state.backups?.pbs?.length ||
state.backups?.pmg?.length)
}
>
<Card padding="lg">
<EmptyState
icon={
@ -72,7 +80,7 @@ const Backups: Component = () => {
</Show>
{/* Main Content - Unified Backups View */}
<Show when={connected() && (state.pveBackups || state.pbs)}>
<Show when={connected() && (state.backups?.pve || state.backups?.pbs || state.pbs)}>
<UnifiedBackups />
</Show>
</div>

View file

@ -25,6 +25,9 @@ interface DateGroup {
const UnifiedBackups: Component = () => {
const { state } = useWebSocket();
const pveBackupsState = createMemo(() => state.backups?.pve ?? state.pveBackups);
const pbsBackupsState = createMemo(() => state.backups?.pbs ?? state.pbsBackups);
const pmgBackupsState = createMemo(() => state.backups?.pmg ?? state.pmgBackups);
const [searchTerm, setSearchTerm] = createSignal('');
const [selectedNode, setSelectedNode] = createSignal<string | null>(null);
const [typeFilter, setTypeFilter] = createSignal<'all' | FilterableGuestType>('all');
@ -159,10 +162,14 @@ const UnifiedBackups: Component = () => {
// Check if we have any backup data yet
const isLoading = createMemo(() => {
const pve = pveBackupsState();
const pbs = pbsBackupsState();
const pmg = pmgBackupsState();
return (
!state.pveBackups?.guestSnapshots &&
!state.pveBackups?.storageBackups &&
!state.pbsBackups?.length &&
!(pve?.guestSnapshots?.length ?? 0) &&
!(pve?.storageBackups?.length ?? 0) &&
!(pbs?.length ?? 0) &&
!(pmg?.length ?? 0) &&
!state.pbs?.length
);
});
@ -176,7 +183,7 @@ const UnifiedBackups: Component = () => {
const debugMode = typeof window !== 'undefined' && localStorage.getItem('debug-pmg') === 'true';
// Normalize snapshots
state.pveBackups?.guestSnapshots?.forEach((snapshot) => {
pveBackupsState()?.guestSnapshots?.forEach((snapshot) => {
// Try to find the guest name by matching VMID and instance (not hostname)
let guestName = '';
const vm = state.vms?.find((v) => v.vmid === snapshot.vmid && v.instance === snapshot.instance);
@ -213,8 +220,8 @@ const UnifiedBackups: Component = () => {
// This ensures we have the complete PBS data with namespaces
// Filter by selected PBS instance if one is selected
const pbsBackupsToProcess = selectedPBSInstance()
? state.pbsBackups?.filter((b) => b.instance === selectedPBSInstance())
: state.pbsBackups;
? pbsBackupsState()?.filter((b) => b.instance === selectedPBSInstance())
: pbsBackupsState();
pbsBackupsToProcess?.forEach((backup) => {
const backupDate = new Date(backup.backupTime);
@ -302,8 +309,38 @@ const UnifiedBackups: Component = () => {
});
});
pmgBackupsState()?.forEach((backup) => {
const backupTimeMs = backup.backupTime ? Date.parse(backup.backupTime) : Number.NaN;
const backupTimeSeconds = Number.isFinite(backupTimeMs) ? Math.floor(backupTimeMs / 1000) : 0;
const backupKey = backup.id || `${backup.instance}:${backup.node}:${backup.filename}`;
if (seenBackups.has(backupKey)) {
return;
}
seenBackups.add(backupKey);
const displayName = backup.node || backup.filename || 'PMG Host Backup';
unified.push({
backupType: 'local',
vmid: backup.node || backup.filename,
name: displayName,
type: 'Host',
node: backup.node || 'PMG',
instance: backup.instance || 'PMG',
backupTime: backupTimeSeconds,
backupName: backup.filename || displayName,
description: backup.filename || '',
status: 'ok',
size: backup.size || null,
storage: 'PMG',
datastore: null,
namespace: null,
verified: null,
protected: false,
});
});
// Normalize local backups (including PBS through PVE storage)
state.pveBackups?.storageBackups?.forEach((backup) => {
pveBackupsState()?.storageBackups?.forEach((backup) => {
// Skip templates and ISOs - they're not backups
if (backup.type === 'vztmpl' || backup.type === 'iso') {
return;
@ -1199,7 +1236,7 @@ const UnifiedBackups: Component = () => {
// Count backups for this PBS instance
const pbsBackups = () =>
state.pbsBackups?.filter((b) => b.instance === pbs.name).length || 0;
pbsBackupsState()?.filter((b) => b.instance === pbs.name).length || 0;
const isSelected = () => selectedPBSInstance() === pbs.name;

View file

@ -317,6 +317,10 @@ const Settings: Component<SettingsProps> = (props) => {
description: 'Manage Pulse configuration.',
};
const pveBackupsState = () => state.backups?.pve ?? state.pveBackups;
const pbsBackupsState = () => state.backups?.pbs ?? state.pbsBackups;
const pmgBackupsState = () => state.backups?.pmg ?? state.pmgBackups;
// Keep tab state in sync with URL and handle /settings redirect without flicker
createEffect(
on(
@ -5413,11 +5417,11 @@ const Settings: Component<SettingsProps> = (props) => {
storageCount: state.storage?.length || 0,
physicalDisksCount: state.physicalDisks?.length || 0,
pbsCount: state.pbs?.length || 0,
pbsBackupsCount: state.pbsBackups?.length || 0,
pbsBackupsCount: pbsBackupsState()?.length || 0,
pveBackups: {
backupTasksCount: state.pveBackups?.backupTasks?.length || 0,
storageBackupsCount: state.pveBackups?.storageBackups?.length || 0,
guestSnapshotsCount: state.pveBackups?.guestSnapshots?.length || 0,
backupTasksCount: pveBackupsState()?.backupTasks?.length || 0,
storageBackupsCount: pveBackupsState()?.storageBackups?.length || 0,
guestSnapshotsCount: pveBackupsState()?.guestSnapshots?.length || 0,
},
},
// Node status details
@ -5454,9 +5458,9 @@ const Settings: Component<SettingsProps> = (props) => {
}
: undefined,
hasBackups:
(state.pveBackups?.storageBackups?.filter(
(pveBackupsState()?.storageBackups ?? []).filter(
(b) => b.storage === s.name,
).length || 0) > 0,
).length > 0,
})) || [],
// Physical disks - critical for troubleshooting
physicalDisks:
@ -5472,10 +5476,10 @@ const Settings: Component<SettingsProps> = (props) => {
smart: d.smart ?? null,
})) || [],
backups: {
pveBackupTasks: state.pveBackups?.backupTasks?.slice(0, 10) || [],
pveBackupTasks: pveBackupsState()?.backupTasks?.slice(0, 10) || [],
pveStorageBackups:
state.pveBackups?.storageBackups?.slice(0, 10) || [],
pbsBackups: state.pbsBackups?.slice(0, 10) || [],
pveBackupsState()?.storageBackups?.slice(0, 10) || [],
pbsBackups: pbsBackupsState()?.slice(0, 10) || [],
},
connectionHealth: state.connectionHealth || {},
performance: {

View file

@ -44,6 +44,8 @@ export const UnifiedNodeSelector: Component<UnifiedNodeSelectorProps> = (props)
// This allows users to select a node AND search within it
// Calculate backup counts for nodes and PBS instances
const pveBackupsState = () => state.backups?.pve ?? state.pveBackups;
const pbsBackupsState = () => state.backups?.pbs ?? state.pbsBackups;
const backupCounts = createMemo(() => {
const counts: Record<string, number> = {};
@ -54,15 +56,16 @@ export const UnifiedNodeSelector: Component<UnifiedNodeSelectorProps> = (props)
let count = 0;
// Count storage backups (excluding PBS backups which are counted separately)
if (state.pveBackups?.storageBackups) {
count += state.pveBackups.storageBackups.filter(
const pveBackups = pveBackupsState();
if (pveBackups?.storageBackups) {
count += pveBackups.storageBackups.filter(
(b) => b.instance === node.instance && b.node === node.name && !b.isPBS,
).length;
}
// Count snapshots
if (state.pveBackups?.guestSnapshots) {
count += state.pveBackups.guestSnapshots.filter((s) => s.instance === node.instance && s.node === node.name).length;
if (pveBackups?.guestSnapshots) {
count += pveBackups.guestSnapshots.filter((s) => s.instance === node.instance && s.node === node.name).length;
}
counts[node.id] = count;
@ -70,9 +73,10 @@ export const UnifiedNodeSelector: Component<UnifiedNodeSelectorProps> = (props)
}
// Count PBS backups by instance
if (state.pbs && state.pbsBackups) {
const pbsBackups = pbsBackupsState();
if (state.pbs && pbsBackups) {
state.pbs.forEach((pbs) => {
counts[pbs.name] = state.pbsBackups?.filter((b) => b.instance === pbs.name).length || 0;
counts[pbs.name] = pbsBackups.filter((b) => b.instance === pbs.name).length || 0;
});
}

View file

@ -3,7 +3,7 @@ import type { JSX } from 'solid-js';
import { EmailProviderSelect } from '@/components/Alerts/EmailProviderSelect';
import { WebhookConfig } from '@/components/Alerts/WebhookConfig';
import { ThresholdsTable } from '@/components/Alerts/ThresholdsTable';
import type { RawOverrideConfig, PMGThresholdDefaults, SnapshotAlertConfig } from '@/types/alerts';
import type { RawOverrideConfig, PMGThresholdDefaults, SnapshotAlertConfig, BackupAlertConfig } from '@/types/alerts';
import { Card } from '@/components/shared/Card';
import { SectionHeader } from '@/components/shared/SectionHeader';
import { SettingsPanel } from '@/components/shared/SettingsPanel';
@ -769,6 +769,25 @@ const [appriseConfig, setAppriseConfig] = createSignal<UIAppriseConfig>(
setMetricTimeThresholds({});
}
// Load backup thresholds
if (config.backupDefaults) {
const enabled = Boolean(config.backupDefaults.enabled);
const rawWarning = config.backupDefaults.warningDays ?? FACTORY_BACKUP_DEFAULTS.warningDays;
const rawCritical = config.backupDefaults.criticalDays ?? FACTORY_BACKUP_DEFAULTS.criticalDays;
const safeCritical = Math.max(0, rawCritical);
const normalizedWarning = Math.max(0, rawWarning);
const warningDays =
safeCritical > 0 && normalizedWarning > safeCritical ? safeCritical : normalizedWarning;
const criticalDays = Math.max(safeCritical, warningDays);
setBackupDefaults({
enabled,
warningDays,
criticalDays,
});
} else {
setBackupDefaults({ ...FACTORY_BACKUP_DEFAULTS });
}
// Load snapshot thresholds
if (config.snapshotDefaults) {
const enabled = Boolean(config.snapshotDefaults.enabled);
@ -1058,6 +1077,11 @@ const [appriseConfig, setAppriseConfig] = createSignal<UIAppriseConfig>(
warningDays: 30,
criticalDays: 45,
};
const FACTORY_BACKUP_DEFAULTS: BackupAlertConfig = {
enabled: false,
warningDays: 7,
criticalDays: 14,
};
// Threshold states - using trigger values for display
const [guestDefaults, setGuestDefaults] = createSignal<Record<string, number | undefined>>({ ...FACTORY_GUEST_DEFAULTS });
@ -1070,6 +1094,9 @@ const [appriseConfig, setAppriseConfig] = createSignal<UIAppriseConfig>(
const [dockerIgnoredPrefixes, setDockerIgnoredPrefixes] = createSignal<string[]>([]);
const [storageDefault, setStorageDefault] = createSignal(FACTORY_STORAGE_DEFAULT);
const [backupDefaults, setBackupDefaults] = createSignal<BackupAlertConfig>({
...FACTORY_BACKUP_DEFAULTS,
});
// Reset functions
const resetGuestDefaults = () => {
@ -1096,6 +1123,10 @@ const [appriseConfig, setAppriseConfig] = createSignal<UIAppriseConfig>(
setStorageDefault(FACTORY_STORAGE_DEFAULT);
setHasUnsavedChanges(true);
};
const resetBackupDefaults = () => {
setBackupDefaults({ ...FACTORY_BACKUP_DEFAULTS });
setHasUnsavedChanges(true);
};
const resetSnapshotDefaults = () => {
setSnapshotDefaults({ ...FACTORY_SNAPSHOT_DEFAULTS });
setHasUnsavedChanges(true);
@ -1224,12 +1255,20 @@ const [appriseConfig, setAppriseConfig] = createSignal<UIAppriseConfig>(
};
const snapshotConfig = snapshotDefaults();
const normalizedWarningDays = Math.max(0, snapshotConfig.warningDays ?? 0);
const normalizedCriticalDays = Math.max(0, snapshotConfig.criticalDays ?? 0);
const finalCriticalDays =
normalizedCriticalDays > 0
? Math.max(normalizedCriticalDays, normalizedWarningDays)
: normalizedWarningDays;
const normalizedSnapshotWarning = Math.max(0, snapshotConfig.warningDays ?? 0);
const normalizedSnapshotCritical = Math.max(0, snapshotConfig.criticalDays ?? 0);
const finalSnapshotCritical =
normalizedSnapshotCritical > 0
? Math.max(normalizedSnapshotCritical, normalizedSnapshotWarning)
: normalizedSnapshotWarning;
const backupConfig = backupDefaults();
const normalizedBackupWarning = Math.max(0, backupConfig.warningDays ?? 0);
const normalizedBackupCritical = Math.max(0, backupConfig.criticalDays ?? 0);
const finalBackupCritical =
normalizedBackupCritical > 0
? Math.max(normalizedBackupCritical, normalizedBackupWarning)
: normalizedBackupWarning;
const alertConfig = {
enabled: true,
@ -1284,8 +1323,13 @@ const [appriseConfig, setAppriseConfig] = createSignal<UIAppriseConfig>(
metricTimeThresholds: normalizeMetricDelayMap(metricTimeThresholds()),
snapshotDefaults: {
enabled: snapshotConfig.enabled,
warningDays: normalizedWarningDays,
criticalDays: finalCriticalDays,
warningDays: normalizedSnapshotWarning,
criticalDays: finalSnapshotCritical,
},
backupDefaults: {
enabled: backupConfig.enabled,
warningDays: normalizedBackupWarning,
criticalDays: finalBackupCritical,
},
pmgDefaults: pmgThresholds(),
// Use rawOverridesConfig which is already properly formatted with disabled flags
@ -1491,14 +1535,18 @@ const [appriseConfig, setAppriseConfig] = createSignal<UIAppriseConfig>(
resetDockerIgnoredPrefixes={resetDockerIgnoredPrefixes}
resetStorageDefault={resetStorageDefault}
resetSnapshotDefaults={resetSnapshotDefaults}
resetBackupDefaults={resetBackupDefaults}
factoryGuestDefaults={FACTORY_GUEST_DEFAULTS}
factoryNodeDefaults={FACTORY_NODE_DEFAULTS}
factoryDockerDefaults={FACTORY_DOCKER_DEFAULTS}
factoryStorageDefault={FACTORY_STORAGE_DEFAULT}
snapshotFactoryDefaults={FACTORY_SNAPSHOT_DEFAULTS}
backupFactoryDefaults={FACTORY_BACKUP_DEFAULTS}
timeThresholds={timeThresholds}
metricTimeThresholds={metricTimeThresholds}
setMetricTimeThresholds={setMetricTimeThresholds}
backupDefaults={backupDefaults}
setBackupDefaults={setBackupDefaults}
snapshotDefaults={snapshotDefaults}
setSnapshotDefaults={setSnapshotDefaults}
pmgThresholds={pmgThresholds}
@ -2028,6 +2076,14 @@ interface ThresholdsTabProps {
) => void;
snapshotFactoryDefaults: SnapshotAlertConfig;
resetSnapshotDefaults: () => void;
backupDefaults: () => BackupAlertConfig;
setBackupDefaults: (
value:
| BackupAlertConfig
| ((prev: BackupAlertConfig) => BackupAlertConfig),
) => void;
backupFactoryDefaults: BackupAlertConfig;
resetBackupDefaults: () => void;
setOverrides: (value: Override[]) => void;
setRawOverridesConfig: (value: Record<string, RawOverrideConfig>) => void;
activeAlerts: Record<string, Alert>;
@ -2085,6 +2141,10 @@ function ThresholdsTab(props: ThresholdsTabProps) {
dockerHosts={props.state.dockerHosts || []}
pbsInstances={props.state.pbs || []}
pmgInstances={props.state.pmg || []}
backups={props.state.backups}
pveBackups={props.state.pveBackups}
pbsBackups={props.state.pbsBackups}
pmgBackups={props.state.pmgBackups}
pmgThresholds={props.pmgThresholds}
setPMGThresholds={props.setPMGThresholds}
guestDefaults={props.guestDefaults()}
@ -2104,6 +2164,10 @@ function ThresholdsTab(props: ThresholdsTabProps) {
timeThresholds={props.timeThresholds}
metricTimeThresholds={props.metricTimeThresholds}
setMetricTimeThresholds={props.setMetricTimeThresholds}
backupDefaults={props.backupDefaults}
setBackupDefaults={props.setBackupDefaults}
backupFactoryDefaults={props.backupFactoryDefaults}
resetBackupDefaults={props.resetBackupDefaults}
snapshotDefaults={props.snapshotDefaults}
setSnapshotDefaults={props.setSnapshotDefaults}
snapshotFactoryDefaults={props.snapshotFactoryDefaults}

View file

@ -36,6 +36,16 @@ export function createWebSocketStore(url: string) {
guestSnapshots: [],
} as PVEBackups,
pbsBackups: [],
pmgBackups: [],
backups: {
pve: {
backupTasks: [],
storageBackups: [],
guestSnapshots: [],
},
pbs: [],
pmg: [],
},
performance: {
apiCallDuration: {},
lastPollDuration: 0,
@ -282,8 +292,19 @@ export function createWebSocketStore(url: string) {
setState('cephClusters', message.data.cephClusters);
if (message.data.pbs !== undefined) setState('pbs', message.data.pbs);
if (message.data.pmg !== undefined) setState('pmg', message.data.pmg);
if (message.data.backups !== undefined) {
setState('backups', message.data.backups);
if (message.data.backups.pve !== undefined)
setState('pveBackups', message.data.backups.pve);
if (message.data.backups.pbs !== undefined)
setState('pbsBackups', message.data.backups.pbs);
if (message.data.backups.pmg !== undefined)
setState('pmgBackups', message.data.backups.pmg);
}
if (message.data.pbsBackups !== undefined)
setState('pbsBackups', message.data.pbsBackups);
if (message.data.pmgBackups !== undefined)
setState('pmgBackups', message.data.pmgBackups);
if (message.data.metrics !== undefined) setState('metrics', message.data.metrics);
if (message.data.pveBackups !== undefined)
setState('pveBackups', message.data.pveBackups);

View file

@ -91,6 +91,12 @@ export interface SnapshotAlertConfig {
criticalDays: number;
}
export interface BackupAlertConfig {
enabled: boolean;
warningDays: number;
criticalDays: number;
}
export interface AlertConfig {
enabled: boolean;
guestDefaults: AlertThresholds;
@ -100,6 +106,7 @@ export interface AlertConfig {
dockerIgnoredContainerPrefixes?: string[];
pmgDefaults?: PMGThresholdDefaults;
snapshotDefaults?: SnapshotAlertConfig;
backupDefaults?: BackupAlertConfig;
customRules?: CustomAlertRule[];
overrides: Record<string, RawOverrideConfig>; // key: resource ID
minimumDelta?: number;

View file

@ -11,6 +11,8 @@ export interface State {
pbs: PBSInstance[];
pmg: PMGInstance[];
pbsBackups: PBSBackup[];
pmgBackups: PMGBackup[];
backups: Backups;
metrics: Metric[];
pveBackups: PVEBackups;
performance: Performance;
@ -414,6 +416,21 @@ export interface PBSBackup {
owner?: string;
}
export interface PMGBackup {
id: string;
instance: string;
node: string;
filename: string;
backupTime: string;
size: number;
}
export interface Backups {
pve: PVEBackups;
pbs: PBSBackup[];
pmg: PMGBackup[];
}
export interface PBSBackupJob {
id: string;
store: string;

View file

@ -290,6 +290,22 @@ type SnapshotAlertConfig struct {
CriticalDays int `json:"criticalDays"`
}
// BackupAlertConfig represents backup age alert configuration
type BackupAlertConfig struct {
Enabled bool `json:"enabled"`
WarningDays int `json:"warningDays"`
CriticalDays int `json:"criticalDays"`
}
// GuestLookup describes a guest identity used for snapshot/backup evaluations.
type GuestLookup struct {
Name string
Instance string
Node string
Type string
VMID int
}
// AlertConfig represents the complete alert configuration
type AlertConfig struct {
Enabled bool `json:"enabled"`
@ -300,6 +316,7 @@ type AlertConfig struct {
DockerIgnoredContainerPrefixes []string `json:"dockerIgnoredContainerPrefixes,omitempty"`
PMGDefaults PMGThresholdConfig `json:"pmgDefaults"`
SnapshotDefaults SnapshotAlertConfig `json:"snapshotDefaults"`
BackupDefaults BackupAlertConfig `json:"backupDefaults"`
Overrides map[string]ThresholdConfig `json:"overrides"` // keyed by resource ID
CustomRules []CustomAlertRule `json:"customRules,omitempty"`
Schedule ScheduleConfig `json:"schedule"`
@ -486,6 +503,11 @@ func NewManager() *Manager {
WarningDays: 30,
CriticalDays: 45,
},
BackupDefaults: BackupAlertConfig{
Enabled: false,
WarningDays: 7,
CriticalDays: 14,
},
StorageDefault: HysteresisThreshold{Trigger: 85, Clear: 80},
MinimumDelta: 2.0, // 2% minimum change
SuppressionWindow: 5, // 5 minutes
@ -702,6 +724,15 @@ func (m *Manager) UpdateConfig(config AlertConfig) {
if config.SnapshotDefaults.CriticalDays > 0 && config.SnapshotDefaults.WarningDays > config.SnapshotDefaults.CriticalDays {
config.SnapshotDefaults.WarningDays = config.SnapshotDefaults.CriticalDays
}
if config.BackupDefaults.WarningDays < 0 {
config.BackupDefaults.WarningDays = 0
}
if config.BackupDefaults.CriticalDays < 0 {
config.BackupDefaults.CriticalDays = 0
}
if config.BackupDefaults.CriticalDays > 0 && config.BackupDefaults.WarningDays > config.BackupDefaults.CriticalDays {
config.BackupDefaults.WarningDays = config.BackupDefaults.CriticalDays
}
// Ensure minimums for other important fields
if config.MinimumDelta <= 0 {
@ -764,6 +795,9 @@ func (m *Manager) UpdateConfig(config AlertConfig) {
if !m.config.SnapshotDefaults.Enabled {
m.clearSnapshotAlertsForInstanceLocked("")
}
if !m.config.BackupDefaults.Enabled {
m.clearBackupAlertsLocked()
}
m.applyGlobalOfflineSettingsLocked()
@ -2756,7 +2790,7 @@ func (m *Manager) CheckStorage(storage models.Storage) {
}
}
func buildSnapshotGuestKey(instance, node string, vmid int) string {
func BuildGuestKey(instance, node string, vmid int) string {
instance = strings.TrimSpace(instance)
node = strings.TrimSpace(node)
if instance == "" {
@ -2817,7 +2851,7 @@ func (m *Manager) CheckSnapshotsForInstance(instanceName string, snapshots []mod
alertID := fmt.Sprintf("snapshot-age-%s", snapshot.ID)
validAlerts[alertID] = struct{}{}
guestKey := buildSnapshotGuestKey(snapshot.Instance, snapshot.Node, snapshot.VMID)
guestKey := BuildGuestKey(snapshot.Instance, snapshot.Node, snapshot.VMID)
guestName := strings.TrimSpace(guestNames[guestKey])
guestType := "VM"
@ -2966,6 +3000,356 @@ func (m *Manager) CheckSnapshotsForInstance(instanceName string, snapshots []mod
m.mu.Unlock()
}
// CheckBackups evaluates storage, PBS, and PMG backups for age-based alerts.
func (m *Manager) CheckBackups(
storageBackups []models.StorageBackup,
pbsBackups []models.PBSBackup,
pmgBackups []models.PMGBackup,
guestsByKey map[string]GuestLookup,
guestsByVMID map[string]GuestLookup,
) {
m.mu.RLock()
enabled := m.config.Enabled
backupCfg := m.config.BackupDefaults
m.mu.RUnlock()
if !enabled || !backupCfg.Enabled {
m.clearBackupAlerts()
return
}
if backupCfg.WarningDays <= 0 && backupCfg.CriticalDays <= 0 {
m.clearBackupAlerts()
return
}
type backupRecord struct {
key string
lookup GuestLookup
fallbackName string
instance string
node string
source string
storage string
datastore string
backupType string
filename string
lastTime time.Time
}
records := make(map[string]*backupRecord)
updateRecord := func(key string, candidate backupRecord) {
if key == "" {
return
}
if existing, ok := records[key]; ok {
if candidate.lastTime.After(existing.lastTime) {
*existing = candidate
}
return
}
record := candidate
records[key] = &record
}
now := time.Now()
for _, backup := range storageBackups {
if backup.Time.IsZero() {
continue
}
key := BuildGuestKey(backup.Instance, backup.Node, backup.VMID)
info := guestsByKey[key]
displayName := info.Name
if displayName == "" {
displayName = fmt.Sprintf("%s-%d", sanitizeAlertKey(backup.Node), backup.VMID)
}
updateRecord(key, backupRecord{
key: key,
lookup: info,
fallbackName: displayName,
instance: backup.Instance,
node: backup.Node,
source: "PVE storage",
storage: backup.Storage,
backupType: backup.Type,
lastTime: backup.Time,
})
}
for _, backup := range pbsBackups {
if backup.BackupTime.IsZero() {
continue
}
if backup.VMID == "0" {
// Host configuration backups - skip from age alerts
continue
}
info, exists := guestsByVMID[backup.VMID]
var key string
var displayName string
var instance string
var node string
if exists && info.Instance != "" && info.Node != "" {
key = BuildGuestKey(info.Instance, info.Node, info.VMID)
displayName = info.Name
instance = info.Instance
node = info.Node
} else {
key = fmt.Sprintf("pbs:%s:%s:%s", backup.Instance, backup.BackupType, backup.VMID)
displayName = fmt.Sprintf("VMID %s", backup.VMID)
instance = fmt.Sprintf("PBS:%s", backup.Instance)
node = backup.Datastore
}
updateRecord(key, backupRecord{
key: key,
lookup: info,
fallbackName: displayName,
instance: instance,
node: node,
source: "PBS",
datastore: backup.Datastore,
backupType: backup.BackupType,
lastTime: backup.BackupTime,
})
}
for _, backup := range pmgBackups {
if backup.BackupTime.IsZero() {
continue
}
instanceLabel := strings.TrimSpace(backup.Instance)
if instanceLabel == "" {
instanceLabel = "PMG"
}
nodeName := strings.TrimSpace(backup.Node)
keyComponent := nodeName
if keyComponent == "" {
keyComponent = strings.TrimSpace(backup.Filename)
}
if keyComponent == "" {
keyComponent = "unknown"
}
displayName := nodeName
if displayName == "" {
displayName = instanceLabel
}
if displayName == "" {
displayName = "PMG gateway"
} else {
displayName = fmt.Sprintf("PMG %s", displayName)
}
instanceField := fmt.Sprintf("PMG:%s", instanceLabel)
key := fmt.Sprintf("pmg:%s:%s", instanceLabel, keyComponent)
updateRecord(key, backupRecord{
key: key,
fallbackName: displayName,
instance: instanceField,
node: nodeName,
source: "PMG",
backupType: "pmg",
filename: backup.Filename,
lastTime: backup.BackupTime,
})
}
if len(records) == 0 {
m.clearBackupAlerts()
return
}
validAlerts := make(map[string]struct{})
for key, record := range records {
age := now.Sub(record.lastTime)
if age < 0 {
continue
}
ageDays := age.Hours() / 24
if ageDays < 0 {
continue
}
ageDaysRounded := math.Round(ageDays*10) / 10
var level AlertLevel
var threshold int
switch {
case backupCfg.CriticalDays > 0 && ageDays >= float64(backupCfg.CriticalDays):
level = AlertLevelCritical
threshold = backupCfg.CriticalDays
case backupCfg.WarningDays > 0 && ageDays >= float64(backupCfg.WarningDays):
level = AlertLevelWarning
threshold = backupCfg.WarningDays
default:
continue
}
alertKey := sanitizeAlertKey(key)
alertID := fmt.Sprintf("backup-age-%s", alertKey)
validAlerts[alertID] = struct{}{}
displayName := record.lookup.Name
if displayName == "" {
displayName = record.fallbackName
}
if displayName == "" {
displayName = "Unknown guest"
}
node := record.node
if node == "" {
node = record.lookup.Node
}
instance := record.instance
if instance == "" {
instance = record.lookup.Instance
}
thresholdTime := record.lastTime.Add(time.Duration(threshold) * 24 * time.Hour)
if thresholdTime.After(now) {
thresholdTime = now
}
var sourceLabel string
switch record.source {
case "PBS":
sourceLabel = fmt.Sprintf("PBS datastore %s on %s", record.datastore, strings.TrimPrefix(instance, "PBS:"))
case "PMG":
if node != "" {
sourceLabel = fmt.Sprintf("PMG node %s", node)
} else {
sourceLabel = "PMG"
}
default:
sourceLabel = fmt.Sprintf("storage %s on %s", record.storage, node)
}
message := fmt.Sprintf(
"%s backup via %s is %.1f days old (threshold: %d days)",
displayName,
sourceLabel,
ageDaysRounded,
threshold,
)
metadata := map[string]interface{}{
"source": record.source,
"lastBackupTime": record.lastTime,
"ageDays": ageDays,
"thresholdDays": threshold,
}
if record.storage != "" {
metadata["storage"] = record.storage
}
if record.datastore != "" {
metadata["datastore"] = record.datastore
}
if record.backupType != "" {
metadata["backupType"] = record.backupType
}
if record.filename != "" {
metadata["filename"] = record.filename
}
m.mu.Lock()
if existing, exists := m.activeAlerts[alertID]; exists {
existing.LastSeen = now
existing.Level = level
existing.Value = ageDays
existing.Threshold = float64(threshold)
existing.Message = message
if existing.Metadata == nil {
existing.Metadata = make(map[string]interface{})
}
for k, v := range metadata {
existing.Metadata[k] = v
}
m.mu.Unlock()
continue
}
alert := &Alert{
ID: alertID,
Type: "backup-age",
Level: level,
ResourceID: alertKey,
ResourceName: fmt.Sprintf("%s backup", displayName),
Node: node,
Instance: instance,
Message: message,
Value: ageDays,
Threshold: float64(threshold),
StartTime: thresholdTime,
LastSeen: now,
Metadata: metadata,
}
m.preserveAlertState(alertID, alert)
m.activeAlerts[alertID] = alert
m.recentAlerts[alertID] = alert
m.historyManager.AddAlert(*alert)
go func() {
defer func() {
if r := recover(); r != nil {
log.Error().Interface("panic", r).Msg("Panic in SaveActiveAlerts goroutine (backup)")
}
}()
if err := m.SaveActiveAlerts(); err != nil {
log.Error().Err(err).Msg("Failed to save active alerts after backup alert creation")
}
}()
if !m.checkRateLimit(alertID) {
m.mu.Unlock()
log.Debug().
Str("alertID", alertID).
Str("resource", displayName).
Msg("Backup alert suppressed due to rate limit")
continue
}
if m.onAlert != nil {
notified := now
alert.LastNotified = &notified
if m.dispatchAlert(alert, true) {
log.Info().
Str("alertID", alertID).
Str("resource", displayName).
Msg("Backup age alert dispatched")
} else {
alert.LastNotified = nil
}
}
m.mu.Unlock()
}
m.mu.Lock()
for alertID, alert := range m.activeAlerts {
if alert == nil || alert.Type != "backup-age" {
continue
}
if _, ok := validAlerts[alertID]; ok {
continue
}
m.clearAlertNoLock(alertID)
}
m.mu.Unlock()
}
// checkZFSPoolHealth checks ZFS pool for errors and degraded state
func (m *Manager) checkZFSPoolHealth(storage models.Storage) {
pool := storage.ZFSPool
@ -6463,3 +6847,18 @@ func (m *Manager) clearSnapshotAlertsForInstanceLocked(instance string) {
m.clearAlertNoLock(alertID)
}
}
func (m *Manager) clearBackupAlerts() {
m.mu.Lock()
m.clearBackupAlertsLocked()
m.mu.Unlock()
}
func (m *Manager) clearBackupAlertsLocked() {
for alertID, alert := range m.activeAlerts {
if alert == nil || alert.Type != "backup-age" {
continue
}
m.clearAlertNoLock(alertID)
}
}

View file

@ -3,6 +3,7 @@ package alerts
import (
"fmt"
"reflect"
"strings"
"testing"
"time"
@ -273,6 +274,160 @@ func TestCheckSnapshotsForInstanceCreatesAndClearsAlerts(t *testing.T) {
}
}
func TestCheckBackupsCreatesAndClearsAlerts(t *testing.T) {
m := NewManager()
m.ClearActiveAlerts()
m.mu.Lock()
m.config.Enabled = true
m.config.BackupDefaults = BackupAlertConfig{
Enabled: true,
WarningDays: 7,
CriticalDays: 14,
}
m.config.TimeThreshold = 0
m.config.TimeThresholds = map[string]int{}
m.mu.Unlock()
now := time.Now()
storageBackups := []models.StorageBackup{
{
ID: "inst-node-100-backup",
Storage: "local",
Node: "node",
Instance: "inst",
Type: "qemu",
VMID: 100,
Time: now.Add(-15 * 24 * time.Hour),
},
}
key := BuildGuestKey("inst", "node", 100)
guestsByKey := map[string]GuestLookup{
key: {
Name: "app-server",
Instance: "inst",
Node: "node",
Type: "qemu",
VMID: 100,
},
}
guestsByVMID := map[string]GuestLookup{
"100": guestsByKey[key],
}
m.CheckBackups(storageBackups, nil, nil, guestsByKey, guestsByVMID)
m.mu.RLock()
alert, exists := m.activeAlerts["backup-age-"+sanitizeAlertKey(key)]
m.mu.RUnlock()
if !exists {
t.Fatalf("expected backup age alert to be created")
}
if alert.Level != AlertLevelCritical {
t.Fatalf("expected critical backup alert, got %s", alert.Level)
}
// Recent backup clears alert
storageBackups[0].Time = now
m.CheckBackups(storageBackups, nil, nil, guestsByKey, guestsByVMID)
m.mu.RLock()
_, exists = m.activeAlerts["backup-age-"+sanitizeAlertKey(key)]
m.mu.RUnlock()
if exists {
t.Fatalf("expected backup-age alert to clear after fresh backup")
}
}
func TestCheckBackupsHandlesPbsOnlyGuests(t *testing.T) {
m := NewManager()
m.ClearActiveAlerts()
m.mu.Lock()
m.config.Enabled = true
m.config.BackupDefaults = BackupAlertConfig{
Enabled: true,
WarningDays: 3,
CriticalDays: 5,
}
m.mu.Unlock()
now := time.Now()
pbsBackups := []models.PBSBackup{
{
ID: "pbs-backup-999-0",
Instance: "pbs-main",
Datastore: "backup-store",
BackupType: "qemu",
VMID: "999",
BackupTime: now.Add(-6 * 24 * time.Hour),
},
}
m.CheckBackups(nil, pbsBackups, nil, map[string]GuestLookup{}, map[string]GuestLookup{})
m.mu.RLock()
found := false
for id, alert := range m.activeAlerts {
if strings.HasPrefix(id, "backup-age-") {
found = true
if alert.Level != AlertLevelCritical {
t.Fatalf("expected PBS backup alert to be critical")
}
break
}
}
m.mu.RUnlock()
if !found {
t.Fatalf("expected PBS backup alert to be created")
}
}
func TestCheckBackupsHandlesPmgBackups(t *testing.T) {
m := NewManager()
m.ClearActiveAlerts()
m.mu.Lock()
m.config.Enabled = true
m.config.BackupDefaults = BackupAlertConfig{
Enabled: true,
WarningDays: 5,
CriticalDays: 7,
}
m.mu.Unlock()
now := time.Now()
pmgBackups := []models.PMGBackup{
{
ID: "pmg-backup-mail-01",
Instance: "mail",
Node: "mail-gateway",
Filename: "pmg-backup_2024-01-01.tgz",
BackupTime: now.Add(-8 * 24 * time.Hour),
Size: 123456,
},
}
m.CheckBackups(nil, nil, pmgBackups, map[string]GuestLookup{}, map[string]GuestLookup{})
m.mu.RLock()
found := false
for id, alert := range m.activeAlerts {
if strings.HasPrefix(id, "backup-age-") {
found = true
if alert.Level != AlertLevelCritical {
t.Fatalf("expected PMG backup alert to be critical")
}
break
}
}
m.mu.RUnlock()
if !found {
t.Fatalf("expected PMG backup alert to be created")
}
}
func TestCheckDockerHostIgnoresContainersByPrefix(t *testing.T) {
m := NewManager()

View file

@ -2452,16 +2452,24 @@ func (r *Router) handleBackups(w http.ResponseWriter, req *http.Request) {
// Get current state
state := r.monitor.GetState()
// Return backup data structure
backups := map[string]interface{}{
"backupTasks": state.PVEBackups.BackupTasks,
"storageBackups": state.PVEBackups.StorageBackups,
"guestSnapshots": state.PVEBackups.GuestSnapshots,
"pbsBackups": state.PBSBackups,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(backups)
json.NewEncoder(w).Encode(struct {
Backups models.Backups `json:"backups"`
PVEBackups models.PVEBackups `json:"pveBackups"`
PBSBackups []models.PBSBackup `json:"pbsBackups"`
PMGBackups []models.PMGBackup `json:"pmgBackups"`
BackupTasks []models.BackupTask `json:"backupTasks"`
Storage []models.StorageBackup `json:"storageBackups"`
GuestSnaps []models.GuestSnapshot `json:"guestSnapshots"`
}{
Backups: state.Backups,
PVEBackups: state.PVEBackups,
PBSBackups: state.PBSBackups,
PMGBackups: state.PMGBackups,
BackupTasks: state.PVEBackups.BackupTasks,
Storage: state.PVEBackups.StorageBackups,
GuestSnaps: state.PVEBackups.GuestSnapshots,
})
}
// handleBackupsPVE handles PVE backup requests

View file

@ -58,6 +58,8 @@ type StateResponse struct {
PBSInstances []models.PBSInstance `json:"pbs"`
PMGInstances []models.PMGInstance `json:"pmg"`
PBSBackups []models.PBSBackup `json:"pbsBackups"`
PMGBackups []models.PMGBackup `json:"pmgBackups"`
Backups models.Backups `json:"backups"`
Metrics []models.Metric `json:"metrics"`
PVEBackups models.PVEBackups `json:"pveBackups"`
Performance models.Performance `json:"performance"`

View file

@ -194,6 +194,15 @@ func (c *ConfigPersistence) SaveAlertConfig(config alerts.AlertConfig) error {
if config.SnapshotDefaults.CriticalDays > 0 && config.SnapshotDefaults.WarningDays > config.SnapshotDefaults.CriticalDays {
config.SnapshotDefaults.WarningDays = config.SnapshotDefaults.CriticalDays
}
if config.BackupDefaults.WarningDays < 0 {
config.BackupDefaults.WarningDays = 0
}
if config.BackupDefaults.CriticalDays < 0 {
config.BackupDefaults.CriticalDays = 0
}
if config.BackupDefaults.CriticalDays > 0 && config.BackupDefaults.WarningDays > config.BackupDefaults.CriticalDays {
config.BackupDefaults.WarningDays = config.BackupDefaults.CriticalDays
}
config.DockerIgnoredContainerPrefixes = alerts.NormalizeDockerIgnoredPrefixes(config.DockerIgnoredContainerPrefixes)
data, err := json.MarshalIndent(config, "", " ")
@ -251,6 +260,11 @@ func (c *ConfigPersistence) LoadAlertConfig() (*alerts.AlertConfig, error) {
WarningDays: 30,
CriticalDays: 45,
},
BackupDefaults: alerts.BackupAlertConfig{
Enabled: false,
WarningDays: 7,
CriticalDays: 14,
},
Overrides: make(map[string]alerts.ThresholdConfig),
}, nil
}
@ -310,6 +324,15 @@ func (c *ConfigPersistence) LoadAlertConfig() (*alerts.AlertConfig, error) {
if config.SnapshotDefaults.CriticalDays > 0 && config.SnapshotDefaults.WarningDays > config.SnapshotDefaults.CriticalDays {
config.SnapshotDefaults.WarningDays = config.SnapshotDefaults.CriticalDays
}
if config.BackupDefaults.WarningDays < 0 {
config.BackupDefaults.WarningDays = 0
}
if config.BackupDefaults.CriticalDays < 0 {
config.BackupDefaults.CriticalDays = 0
}
if config.BackupDefaults.CriticalDays > 0 && config.BackupDefaults.WarningDays > config.BackupDefaults.CriticalDays {
config.BackupDefaults.WarningDays = config.BackupDefaults.CriticalDays
}
config.MetricTimeThresholds = alerts.NormalizeMetricTimeThresholds(config.MetricTimeThresholds)
config.DockerIgnoredContainerPrefixes = alerts.NormalizeDockerIgnoredPrefixes(config.DockerIgnoredContainerPrefixes)

View file

@ -140,6 +140,11 @@ func TestLoadAlertConfigAppliesDefaults(t *testing.T) {
WarningDays: 20,
CriticalDays: 10,
},
BackupDefaults: alerts.BackupAlertConfig{
Enabled: true,
WarningDays: 12,
CriticalDays: 8,
},
NodeDefaults: alerts.ThresholdConfig{
Temperature: &alerts.HysteresisThreshold{Trigger: 0, Clear: 0},
},
@ -174,6 +179,15 @@ func TestLoadAlertConfigAppliesDefaults(t *testing.T) {
if loaded.NodeDefaults.Temperature.Trigger != 80 || loaded.NodeDefaults.Temperature.Clear != 75 {
t.Fatalf("expected temperature defaults 80/75, got %+v", loaded.NodeDefaults.Temperature)
}
if !loaded.BackupDefaults.Enabled {
t.Fatalf("expected backup defaults to remain enabled")
}
if loaded.BackupDefaults.WarningDays != 8 {
t.Fatalf("expected backup warning normalized to 8, got %d", loaded.BackupDefaults.WarningDays)
}
if loaded.BackupDefaults.CriticalDays != 8 {
t.Fatalf("expected backup critical normalized to 8, got %d", loaded.BackupDefaults.CriticalDays)
}
expectedPrefixes := []string{"Runner"}
if !reflect.DeepEqual(loaded.DockerIgnoredContainerPrefixes, expectedPrefixes) {
t.Fatalf("expected normalized prefixes %v, got %v", expectedPrefixes, loaded.DockerIgnoredContainerPrefixes)

View file

@ -329,6 +329,12 @@ func GenerateMockData(config MockConfig) models.StateSnapshot {
StorageBackups: generateBackups(data.VMs, data.Containers),
GuestSnapshots: generateSnapshots(data.VMs, data.Containers),
}
data.PMGBackups = extractPMGBackups(data.PVEBackups.StorageBackups)
data.Backups = models.Backups{
PVE: data.PVEBackups,
PBS: append([]models.PBSBackup(nil), data.PBSBackups...),
PMG: append([]models.PMGBackup(nil), data.PMGBackups...),
}
// Calculate stats
data.Stats.StartTime = time.Now()
@ -2100,6 +2106,47 @@ func generateBackups(vms []models.VM, containers []models.Container) []models.St
return backups
}
func extractPMGBackups(storageBackups []models.StorageBackup) []models.PMGBackup {
pmgBackups := make([]models.PMGBackup, 0)
for _, backup := range storageBackups {
if !strings.EqualFold(backup.Type, "host") {
continue
}
format := strings.ToLower(backup.Format)
notes := strings.ToLower(backup.Notes)
if !strings.Contains(format, "pmg") && !strings.Contains(notes, "pmg") {
continue
}
filename := backup.Volid
if filename == "" {
filename = backup.Notes
}
instance := backup.Instance
if instance == "" && backup.Node != "" {
instance = fmt.Sprintf("PMG:%s", backup.Node)
}
if instance == "" {
instance = "PMG:mock"
}
pmgBackups = append(pmgBackups, models.PMGBackup{
ID: backup.ID,
Instance: instance,
Node: backup.Node,
Filename: filename,
BackupTime: backup.Time,
Size: backup.Size,
})
}
sort.Slice(pmgBackups, func(i, j int) bool {
return pmgBackups[i].BackupTime.After(pmgBackups[j].BackupTime)
})
return pmgBackups
}
// generatePBSInstances generates mock PBS instances
func generatePBSInstances() []models.PBSInstance {
pbsInstances := []models.PBSInstance{

View file

@ -310,6 +310,7 @@ func cloneState(state models.StateSnapshot) models.StateSnapshot {
PhysicalDisks: append([]models.PhysicalDisk(nil), state.PhysicalDisks...),
PBSInstances: append([]models.PBSInstance(nil), state.PBSInstances...),
PBSBackups: append([]models.PBSBackup(nil), state.PBSBackups...),
PMGBackups: append([]models.PMGBackup(nil), state.PMGBackups...),
Metrics: append([]models.Metric(nil), state.Metrics...),
Performance: state.Performance,
Stats: state.Stats,
@ -324,6 +325,15 @@ func cloneState(state models.StateSnapshot) models.StateSnapshot {
StorageBackups: append([]models.StorageBackup(nil), state.PVEBackups.StorageBackups...),
GuestSnapshots: append([]models.GuestSnapshot(nil), state.PVEBackups.GuestSnapshots...),
}
copyState.Backups = models.Backups{
PVE: models.PVEBackups{
BackupTasks: append([]models.BackupTask(nil), state.Backups.PVE.BackupTasks...),
StorageBackups: append([]models.StorageBackup(nil), state.Backups.PVE.StorageBackups...),
GuestSnapshots: append([]models.GuestSnapshot(nil), state.Backups.PVE.GuestSnapshots...),
},
PBS: append([]models.PBSBackup(nil), state.Backups.PBS...),
PMG: append([]models.PMGBackup(nil), state.Backups.PMG...),
}
for k, v := range state.ConnectionHealth {
copyState.ConnectionHealth[k] = v

View file

@ -20,6 +20,8 @@ type State struct {
PBSInstances []PBSInstance `json:"pbs"`
PMGInstances []PMGInstance `json:"pmg"`
PBSBackups []PBSBackup `json:"pbsBackups"`
PMGBackups []PMGBackup `json:"pmgBackups"`
Backups Backups `json:"backups"`
Metrics []Metric `json:"metrics"`
PVEBackups PVEBackups `json:"pveBackups"`
Performance Performance `json:"performance"`
@ -461,6 +463,23 @@ type PMGNodeStatus struct {
QueueStatus *PMGQueueStatus `json:"queueStatus,omitempty"` // Postfix queue status for this node
}
// PMGBackup represents a configuration backup generated by a PMG node.
type PMGBackup struct {
ID string `json:"id"`
Instance string `json:"instance"`
Node string `json:"node"`
Filename string `json:"filename"`
BackupTime time.Time `json:"backupTime"`
Size int64 `json:"size"`
}
// Backups aggregates backup collections by source type.
type Backups struct {
PVE PVEBackups `json:"pve"`
PBS []PBSBackup `json:"pbs"`
PMG []PMGBackup `json:"pmg"`
}
// PMGMailStats summarizes aggregated mail statistics for a timeframe
type PMGMailStats struct {
Timeframe string `json:"timeframe"`
@ -677,7 +696,13 @@ type Stats struct {
// NewState creates a new State instance
func NewState() *State {
return &State{
pveBackups := PVEBackups{
BackupTasks: make([]BackupTask, 0),
StorageBackups: make([]StorageBackup, 0),
GuestSnapshots: make([]GuestSnapshot, 0),
}
state := &State{
Nodes: make([]Node, 0),
VMs: make([]VM, 0),
Containers: make([]Container, 0),
@ -687,17 +712,31 @@ func NewState() *State {
PBSInstances: make([]PBSInstance, 0),
PMGInstances: make([]PMGInstance, 0),
PBSBackups: make([]PBSBackup, 0),
Metrics: make([]Metric, 0),
PVEBackups: PVEBackups{
BackupTasks: make([]BackupTask, 0),
StorageBackups: make([]StorageBackup, 0),
GuestSnapshots: make([]GuestSnapshot, 0),
PMGBackups: make([]PMGBackup, 0),
Backups: Backups{
PVE: pveBackups,
PBS: make([]PBSBackup, 0),
PMG: make([]PMGBackup, 0),
},
Metrics: make([]Metric, 0),
PVEBackups: pveBackups,
ConnectionHealth: make(map[string]bool),
ActiveAlerts: make([]Alert, 0),
RecentlyResolved: make([]ResolvedAlert, 0),
LastUpdate: time.Now(),
}
state.syncBackupsLocked()
return state
}
// syncBackupsLocked updates the aggregated backups structure.
func (s *State) syncBackupsLocked() {
s.Backups = Backups{
PVE: s.PVEBackups,
PBS: append([]PBSBackup(nil), s.PBSBackups...),
PMG: append([]PMGBackup(nil), s.PMGBackups...),
}
}
// UpdateActiveAlerts updates the active alerts in the state
@ -1211,6 +1250,7 @@ func (s *State) UpdateBackupTasksForInstance(instanceName string, tasks []Backup
})
s.PVEBackups.BackupTasks = newTasks
s.syncBackupsLocked()
s.LastUpdate = time.Now()
}
@ -1245,6 +1285,7 @@ func (s *State) UpdateStorageBackupsForInstance(instanceName string, backups []S
})
s.PVEBackups.StorageBackups = newBackups
s.syncBackupsLocked()
s.LastUpdate = time.Now()
}
@ -1279,6 +1320,7 @@ func (s *State) UpdateGuestSnapshotsForInstance(instanceName string, snapshots [
})
s.PVEBackups.GuestSnapshots = newSnapshots
s.syncBackupsLocked()
s.LastUpdate = time.Now()
}
@ -1326,5 +1368,32 @@ func (s *State) UpdatePBSBackups(instanceName string, backups []PBSBackup) {
})
s.PBSBackups = newBackups
s.syncBackupsLocked()
s.LastUpdate = time.Now()
}
// UpdatePMGBackups updates PMG backups for a specific instance.
func (s *State) UpdatePMGBackups(instanceName string, backups []PMGBackup) {
s.mu.Lock()
defer s.mu.Unlock()
combined := make([]PMGBackup, 0, len(s.PMGBackups)+len(backups))
for _, backup := range s.PMGBackups {
if backup.Instance != instanceName {
combined = append(combined, backup)
}
}
if len(backups) > 0 {
combined = append(combined, backups...)
}
if len(combined) > 1 {
sort.Slice(combined, func(i, j int) bool {
return combined[i].BackupTime.After(combined[j].BackupTime)
})
}
s.PMGBackups = combined
s.syncBackupsLocked()
s.LastUpdate = time.Now()
}

View file

@ -230,6 +230,9 @@ type StateFrontend struct {
PhysicalDisks []PhysicalDisk `json:"physicalDisks"`
PBS []PBSInstance `json:"pbs"` // Keep as is
PMG []PMGInstance `json:"pmg"`
PBSBackups []PBSBackup `json:"pbsBackups"`
PMGBackups []PMGBackup `json:"pmgBackups"`
Backups Backups `json:"backups"`
ActiveAlerts []Alert `json:"activeAlerts"` // Active alerts
Metrics map[string]any `json:"metrics"` // Empty object for now
PVEBackups PVEBackups `json:"pveBackups"` // Keep as is

View file

@ -14,6 +14,8 @@ type StateSnapshot struct {
PBSInstances []PBSInstance `json:"pbs"`
PMGInstances []PMGInstance `json:"pmg"`
PBSBackups []PBSBackup `json:"pbsBackups"`
PMGBackups []PMGBackup `json:"pmgBackups"`
Backups Backups `json:"backups"`
Metrics []Metric `json:"metrics"`
PVEBackups PVEBackups `json:"pveBackups"`
Performance Performance `json:"performance"`
@ -29,6 +31,14 @@ func (s *State) GetSnapshot() StateSnapshot {
s.mu.RLock()
defer s.mu.RUnlock()
pbsBackups := append([]PBSBackup{}, s.PBSBackups...)
pmgBackups := append([]PMGBackup{}, s.PMGBackups...)
pveBackups := PVEBackups{
BackupTasks: append([]BackupTask{}, s.PVEBackups.BackupTasks...),
StorageBackups: append([]StorageBackup{}, s.PVEBackups.StorageBackups...),
GuestSnapshots: append([]GuestSnapshot{}, s.PVEBackups.GuestSnapshots...),
}
// Create a snapshot without mutex
snapshot := StateSnapshot{
Nodes: append([]Node{}, s.Nodes...),
@ -40,13 +50,15 @@ func (s *State) GetSnapshot() StateSnapshot {
PhysicalDisks: append([]PhysicalDisk{}, s.PhysicalDisks...),
PBSInstances: append([]PBSInstance{}, s.PBSInstances...),
PMGInstances: append([]PMGInstance{}, s.PMGInstances...),
PBSBackups: append([]PBSBackup{}, s.PBSBackups...),
Metrics: append([]Metric{}, s.Metrics...),
PVEBackups: PVEBackups{
BackupTasks: append([]BackupTask{}, s.PVEBackups.BackupTasks...),
StorageBackups: append([]StorageBackup{}, s.PVEBackups.StorageBackups...),
GuestSnapshots: append([]GuestSnapshot{}, s.PVEBackups.GuestSnapshots...),
PBSBackups: pbsBackups,
PMGBackups: pmgBackups,
Backups: Backups{
PVE: pveBackups,
PBS: pbsBackups,
PMG: pmgBackups,
},
Metrics: append([]Metric{}, s.Metrics...),
PVEBackups: pveBackups,
Performance: s.Performance,
ConnectionHealth: make(map[string]bool),
Stats: s.Stats,
@ -110,6 +122,9 @@ func (s StateSnapshot) ToFrontend() StateFrontend {
PhysicalDisks: s.PhysicalDisks,
PBS: s.PBSInstances,
PMG: s.PMGInstances,
PBSBackups: s.PBSBackups,
PMGBackups: s.PMGBackups,
Backups: s.Backups,
ActiveAlerts: s.ActiveAlerts,
Metrics: make(map[string]any),
PVEBackups: s.PVEBackups,

View file

@ -1707,7 +1707,7 @@ func (m *Monitor) Start(ctx context.Context, wsHub *websocket.Hub) {
Int("vms", len(state.VMs)).
Int("containers", len(state.Containers)).
Int("pbs", len(state.PBSInstances)).
Int("pbsBackups", len(state.PBSBackups)).
Int("pbsBackups", len(state.Backups.PBS)).
Int("physicalDisks", len(state.PhysicalDisks)).
Msg("Broadcasting state update (ticker)")
// Convert to frontend format before broadcasting (converts time.Time to int64, etc.)
@ -5167,7 +5167,11 @@ func (m *Monitor) pollPMGInstance(ctx context.Context, instanceName string, clie
cluster, err := client.GetClusterStatus(ctx, true)
if err != nil {
log.Debug().Err(err).Str("instance", instanceName).Msg("Failed to retrieve PMG cluster status")
} else if len(cluster) > 0 {
}
backupNodes := make(map[string]struct{})
if len(cluster) > 0 {
nodes := make([]models.PMGNodeStatus, 0, len(cluster))
for _, entry := range cluster {
status := strings.ToLower(strings.TrimSpace(entry.Type))
@ -5180,6 +5184,8 @@ func (m *Monitor) pollPMGInstance(ctx context.Context, instanceName string, clie
Role: entry.Type,
}
backupNodes[entry.Name] = struct{}{}
// Fetch queue status for this node
if queueData, qErr := client.GetQueueStatus(ctx, entry.Name); qErr != nil {
log.Debug().Err(qErr).
@ -5204,6 +5210,53 @@ func (m *Monitor) pollPMGInstance(ctx context.Context, instanceName string, clie
pmgInst.Nodes = nodes
}
if len(backupNodes) == 0 {
trimmed := strings.TrimSpace(instanceName)
if trimmed != "" {
backupNodes[trimmed] = struct{}{}
}
}
pmgBackups := make([]models.PMGBackup, 0)
seenBackupIDs := make(map[string]struct{})
for nodeName := range backupNodes {
if ctx.Err() != nil {
break
}
backups, backupErr := client.ListBackups(ctx, nodeName)
if backupErr != nil {
log.Debug().Err(backupErr).
Str("instance", instanceName).
Str("node", nodeName).
Msg("Failed to list PMG configuration backups")
continue
}
for _, b := range backups {
backupTime := time.Unix(b.Timestamp, 0)
id := fmt.Sprintf("pmg-%s-%s-%d", instanceName, nodeName, b.Timestamp)
if _, exists := seenBackupIDs[id]; exists {
continue
}
seenBackupIDs[id] = struct{}{}
pmgBackups = append(pmgBackups, models.PMGBackup{
ID: id,
Instance: instanceName,
Node: nodeName,
Filename: b.Filename,
BackupTime: backupTime,
Size: b.Size,
})
}
}
log.Debug().
Str("instance", instanceName).
Int("backupCount", len(pmgBackups)).
Msg("PMG backups polled")
if stats, err := client.GetMailStatistics(ctx, "day"); err != nil {
log.Warn().Err(err).Str("instance", instanceName).Msg("Failed to fetch PMG mail statistics")
} else if stats != nil {
@ -5279,6 +5332,7 @@ func (m *Monitor) pollPMGInstance(ctx context.Context, instanceName string, clie
}
pmgInst.Quarantine = &quarantine
m.state.UpdatePMGBackups(instanceName, pmgBackups)
m.state.UpdatePMGInstance(pmgInst)
log.Info().
Str("instance", instanceName).
@ -5620,6 +5674,24 @@ func (m *Monitor) pollStorageBackupsWithNodes(ctx context.Context, instanceName
// Update state with storage backups for this instance
m.state.UpdateStorageBackupsForInstance(instanceName, allBackups)
if m.alertManager != nil {
snapshot := m.state.GetSnapshot()
guestsByKey, guestsByVMID := buildGuestLookups(snapshot)
pveStorage := snapshot.Backups.PVE.StorageBackups
if len(pveStorage) == 0 && len(snapshot.PVEBackups.StorageBackups) > 0 {
pveStorage = snapshot.PVEBackups.StorageBackups
}
pbsBackups := snapshot.Backups.PBS
if len(pbsBackups) == 0 && len(snapshot.PBSBackups) > 0 {
pbsBackups = snapshot.PBSBackups
}
pmgBackups := snapshot.Backups.PMG
if len(pmgBackups) == 0 && len(snapshot.PMGBackups) > 0 {
pmgBackups = snapshot.PMGBackups
}
m.alertManager.CheckBackups(pveStorage, pbsBackups, pmgBackups, guestsByKey, guestsByVMID)
}
log.Debug().
Str("instance", instanceName).
Int("count", len(allBackups)).
@ -5636,6 +5708,49 @@ func shouldPreserveBackups(nodeCount int, hadSuccessfulNode bool, storagesWithBa
return false
}
func buildGuestLookups(snapshot models.StateSnapshot) (map[string]alerts.GuestLookup, map[string]alerts.GuestLookup) {
byKey := make(map[string]alerts.GuestLookup)
byVMID := make(map[string]alerts.GuestLookup)
for _, vm := range snapshot.VMs {
info := alerts.GuestLookup{
Name: vm.Name,
Instance: vm.Instance,
Node: vm.Node,
Type: vm.Type,
VMID: vm.VMID,
}
key := alerts.BuildGuestKey(vm.Instance, vm.Node, vm.VMID)
byKey[key] = info
vmidKey := fmt.Sprintf("%d", vm.VMID)
if _, exists := byVMID[vmidKey]; !exists {
byVMID[vmidKey] = info
}
}
for _, ct := range snapshot.Containers {
info := alerts.GuestLookup{
Name: ct.Name,
Instance: ct.Instance,
Node: ct.Node,
Type: ct.Type,
VMID: int(ct.VMID),
}
key := alerts.BuildGuestKey(ct.Instance, ct.Node, int(ct.VMID))
if _, exists := byKey[key]; !exists {
byKey[key] = info
}
vmidKey := fmt.Sprintf("%d", ct.VMID)
if _, exists := byVMID[vmidKey]; !exists {
byVMID[vmidKey] = info
}
}
return byKey, byVMID
}
func (m *Monitor) calculateBackupOperationTimeout(instanceName string) time.Duration {
const (
minTimeout = 2 * time.Minute
@ -6042,6 +6157,7 @@ func (m *Monitor) removeFailedPMGInstance(instanceName string) {
}
m.state.UpdatePMGInstances(updated)
m.state.UpdatePMGBackups(instanceName, nil)
m.state.SetConnectionHealth("pmg-"+instanceName, false)
}
@ -6149,6 +6265,24 @@ func (m *Monitor) pollPBSBackups(ctx context.Context, instanceName string, clien
// Update state
m.state.UpdatePBSBackups(instanceName, allBackups)
if m.alertManager != nil {
snapshot := m.state.GetSnapshot()
guestsByKey, guestsByVMID := buildGuestLookups(snapshot)
pveStorage := snapshot.Backups.PVE.StorageBackups
if len(pveStorage) == 0 && len(snapshot.PVEBackups.StorageBackups) > 0 {
pveStorage = snapshot.PVEBackups.StorageBackups
}
pbsBackups := snapshot.Backups.PBS
if len(pbsBackups) == 0 && len(snapshot.PBSBackups) > 0 {
pbsBackups = snapshot.PBSBackups
}
pmgBackups := snapshot.Backups.PMG
if len(pmgBackups) == 0 && len(snapshot.PMGBackups) > 0 {
pmgBackups = snapshot.PMGBackups
}
m.alertManager.CheckBackups(pveStorage, pbsBackups, pmgBackups, guestsByKey, guestsByVMID)
}
}
// checkMockAlerts checks alerts for mock data
@ -6188,6 +6322,21 @@ func (m *Monitor) checkMockAlerts() {
Msg("Collecting resources for alert cleanup in mock mode")
m.alertManager.CleanupAlertsForNodes(existingNodes)
guestsByKey, guestsByVMID := buildGuestLookups(state)
pveStorage := state.Backups.PVE.StorageBackups
if len(pveStorage) == 0 && len(state.PVEBackups.StorageBackups) > 0 {
pveStorage = state.PVEBackups.StorageBackups
}
pbsBackups := state.Backups.PBS
if len(pbsBackups) == 0 && len(state.PBSBackups) > 0 {
pbsBackups = state.PBSBackups
}
pmgBackups := state.Backups.PMG
if len(pmgBackups) == 0 && len(state.PMGBackups) > 0 {
pmgBackups = state.PMGBackups
}
m.alertManager.CheckBackups(pveStorage, pbsBackups, pmgBackups, guestsByKey, guestsByVMID)
// Limit how many guests we check per cycle to prevent blocking with large datasets
const maxGuestsPerCycle = 50
guestsChecked := 0

View file

@ -66,6 +66,11 @@ func TestPollPMGInstancePopulatesState(t *testing.T) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"data":{"active":5,"deferred":2,"hold":0,"maildrop":0}}`)
case "/api2/json/nodes/mail-gateway/backup":
w.Header().Set("Content-Type", "application/json")
timestamp := time.Now().Add(-8 * 24 * time.Hour).Unix()
fmt.Fprintf(w, `{"data":[{"filename":"pmg-backup_2024-01-01.tgz","size":123456,"timestamp":%d}]}`, timestamp)
default:
t.Fatalf("unexpected path: %s", r.URL.Path)
}
@ -144,6 +149,15 @@ func TestPollPMGInstancePopulatesState(t *testing.T) {
if failures := mon.authFailures["pmg-primary"]; failures != 0 {
t.Fatalf("expected no auth failures tracked, got %d", failures)
}
if len(snapshot.PMGBackups) != 1 {
t.Fatalf("expected 1 PMG backup in state, got %d", len(snapshot.PMGBackups))
}
pmgBackup := snapshot.PMGBackups[0]
if pmgBackup.Node != "mail-gateway" {
t.Fatalf("expected PMG backup node mail-gateway, got %s", pmgBackup.Node)
}
}
func TestPollPMGInstanceRecordsAuthFailures(t *testing.T) {

View file

@ -119,6 +119,13 @@ type QueueStatusEntry struct {
OldestAge int64 `json:"oldest_age,omitempty"` // Age of oldest message in seconds
}
// BackupEntry represents a PMG configuration backup stored on a node.
type BackupEntry struct {
Filename string `json:"filename"`
Size int64 `json:"size"`
Timestamp int64 `json:"timestamp"`
}
func NewClient(cfg ClientConfig) (*Client, error) {
if cfg.Timeout <= 0 {
cfg.Timeout = 60 * time.Second
@ -459,3 +466,13 @@ func (c *Client) GetQueueStatus(ctx context.Context, node string) (*QueueStatusE
}
return &resp.Data, nil
}
// ListBackups returns configuration backup archives available on a PMG node.
func (c *Client) ListBackups(ctx context.Context, node string) ([]BackupEntry, error) {
path := fmt.Sprintf("/nodes/%s/backup", url.PathEscape(node))
var resp apiResponse[[]BackupEntry]
if err := c.getJSON(ctx, path, nil, &resp); err != nil {
return nil, err
}
return resp.Data, nil
}

View file

@ -231,3 +231,55 @@ func TestClientUsesTokenAuthorizationHeader(t *testing.T) {
t.Fatalf("expected authorization header %q, got %q", expected, authHeader)
}
}
func TestListBackups(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api2/json/access/ticket":
if r.Method != http.MethodPost {
t.Fatalf("expected POST for auth, got %s", r.Method)
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"data":{"ticket":"ticket789","CSRFPreventionToken":"csrf789"}}`)
case "/api2/json/nodes/node1/backup":
if r.Method != http.MethodGet {
t.Fatalf("expected GET for backup listing, got %s", r.Method)
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"data":[{"filename":"pmg-backup_2024-01-01.tgz","size":123456,"timestamp":1704096000}]}`)
default:
t.Fatalf("unexpected request path: %s", r.URL.Path)
}
}))
defer server.Close()
client, err := NewClient(ClientConfig{
Host: server.URL,
User: "api@pmg",
Password: "secret",
VerifySSL: false,
})
if err != nil {
t.Fatalf("unexpected error creating client: %v", err)
}
backups, err := client.ListBackups(context.Background(), "node1")
if err != nil {
t.Fatalf("ListBackups failed: %v", err)
}
if len(backups) != 1 {
t.Fatalf("expected 1 backup, got %d", len(backups))
}
backup := backups[0]
if backup.Filename != "pmg-backup_2024-01-01.tgz" {
t.Fatalf("unexpected filename: %s", backup.Filename)
}
if backup.Size != 123456 {
t.Fatalf("unexpected size: %d", backup.Size)
}
if backup.Timestamp != 1704096000 {
t.Fatalf("unexpected timestamp: %d", backup.Timestamp)
}
}