diff --git a/frontend-modern/src/components/Backups/Backups.tsx b/frontend-modern/src/components/Backups/Backups.tsx
index da0ea9ae1..770f90a83 100644
--- a/frontend-modern/src/components/Backups/Backups.tsx
+++ b/frontend-modern/src/components/Backups/Backups.tsx
@@ -1,12 +1,15 @@
-import { Component, Show } from 'solid-js';
+import { Component, Show, For } from 'solid-js';
import { Card } from '@/components/shared/Card';
import { EmptyState } from '@/components/shared/EmptyState';
import { useWebSocket } from '@/App';
import UnifiedBackups from './UnifiedBackups';
import { ProxmoxSectionNav } from '@/components/Proxmox/ProxmoxSectionNav';
+import { useInstanceWarnings } from '@/hooks/useInstanceWarnings';
+import AlertTriangle from 'lucide-solid/icons/alert-triangle';
const Backups: Component = () => {
const { state, connected, initialDataReceived, reconnecting, reconnect } = useWebSocket();
+ const { warnings } = useInstanceWarnings();
const hasBackupData = () =>
Boolean(
@@ -16,10 +19,42 @@ const Backups: Component = () => {
state.backups?.pmg?.length,
);
+ const allWarnings = () => {
+ const result: { instance: string; warning: string }[] = [];
+ warnings().forEach((instanceWarnings, instance) => {
+ for (const warning of instanceWarnings) {
+ result.push({ instance, warning });
+ }
+ });
+ return result;
+ };
+
return (
+ {/* Permission Warnings Banner */}
+
0}>
+
+
+
+
+
+ Backup permission issue detected
+
+
+ {(item) => (
+
+ {item.instance}:{' '}
+ {item.warning}
+
+ )}
+
+
+
+
+
+
{/* Loading State */}
>(new Map());
+ const [loading, setLoading] = createSignal(true);
+ const [error, setError] = createSignal(null);
+
+ const fetchWarnings = async () => {
+ try {
+ const response = await fetch('/api/monitoring/scheduler/health');
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}`);
+ }
+ const data: SchedulerHealth = await response.json();
+
+ const warningMap = new Map();
+ for (const instance of data.instances || []) {
+ if (instance.warnings && instance.warnings.length > 0) {
+ warningMap.set(instance.instance, instance.warnings);
+ }
+ }
+ setWarnings(warningMap);
+ setError(null);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to fetch warnings');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ createEffect(() => {
+ fetchWarnings();
+ // Refresh every 60 seconds
+ const interval = setInterval(fetchWarnings, 60000);
+ onCleanup(() => clearInterval(interval));
+ });
+
+ return {
+ warnings,
+ loading,
+ error,
+ refetch: fetchWarnings,
+ };
+}
diff --git a/internal/monitoring/monitor.go b/internal/monitoring/monitor.go
index c141680f9..e49e3a706 100644
--- a/internal/monitoring/monitor.go
+++ b/internal/monitoring/monitor.go
@@ -676,6 +676,7 @@ type InstanceHealth struct {
PollStatus InstancePollStatus `json:"pollStatus"`
Breaker InstanceBreaker `json:"breaker"`
DeadLetter InstanceDLQ `json:"deadLetter"`
+ Warnings []string `json:"warnings,omitempty"`
}
func schedulerKey(instanceType InstanceType, name string) string {
@@ -734,6 +735,7 @@ type Monitor struct {
lastPhysicalDiskPoll map[string]time.Time // Track last physical disk poll time per instance
lastPVEBackupPoll map[string]time.Time // Track last PVE backup poll per instance
lastPBSBackupPoll map[string]time.Time // Track last PBS backup poll per instance
+ backupPermissionWarnings map[string]string // Track backup permission issues per instance (instance -> warning message)
persistence *config.ConfigPersistence // Add persistence for saving updated configs
pbsBackupPollers map[string]bool // Track PBS backup polling goroutines per instance
pbsBackupCacheTime map[string]map[pbsBackupGroupKey]time.Time // Track when each PBS backup group was last fetched
@@ -3654,6 +3656,7 @@ func New(cfg *config.Config) (*Monitor, error) {
lastPhysicalDiskPoll: make(map[string]time.Time),
lastPVEBackupPoll: make(map[string]time.Time),
lastPBSBackupPoll: make(map[string]time.Time),
+ backupPermissionWarnings: make(map[string]string),
persistence: config.NewConfigPersistence(cfg.DataPath),
pbsBackupPollers: make(map[string]bool),
pbsBackupCacheTime: make(map[string]map[pbsBackupGroupKey]time.Time),
@@ -5552,6 +5555,14 @@ func (m *Monitor) SchedulerHealth() SchedulerHealthResponse {
dlqInfo.NextRetry = timePtr(dlq.NextRetry)
}
+ // Collect any warnings for this instance
+ var warnings []string
+ if instType == "pve" {
+ if warning, ok := m.backupPermissionWarnings[instName]; ok {
+ warnings = append(warnings, warning)
+ }
+ }
+
instances = append(instances, InstanceHealth{
Key: key,
Type: instType,
@@ -5561,6 +5572,7 @@ func (m *Monitor) SchedulerHealth() SchedulerHealthResponse {
PollStatus: instanceStatus,
Breaker: breakerInfo,
DeadLetter: dlqInfo,
+ Warnings: warnings,
})
}
@@ -9034,10 +9046,25 @@ func (m *Monitor) pollStorageBackupsWithNodes(ctx context.Context, instanceName
contents, err := client.GetStorageContent(ctx, node.Node, storage.Storage)
if err != nil {
monErr := errors.NewMonitorError(errors.ErrorTypeAPI, "get_storage_content", instanceName, err).WithNode(node.Node)
- log.Debug().Err(monErr).
- Str("node", node.Node).
- Str("storage", storage.Storage).
- Msg("Failed to get storage content")
+ errStr := strings.ToLower(err.Error())
+
+ // Check if this is a permission error
+ if strings.Contains(errStr, "403") || strings.Contains(errStr, "401") ||
+ strings.Contains(errStr, "permission") || strings.Contains(errStr, "forbidden") {
+ m.mu.Lock()
+ m.backupPermissionWarnings[instanceName] = "Missing PVEDatastoreAdmin permission on /storage. Run: pveum aclmod /storage -user pulse-monitor@pam -role PVEDatastoreAdmin"
+ m.mu.Unlock()
+ log.Warn().
+ Str("instance", instanceName).
+ Str("node", node.Node).
+ Str("storage", storage.Storage).
+ Msg("Backup permission denied - PVEDatastoreAdmin role may be missing on /storage")
+ } else {
+ log.Debug().Err(monErr).
+ Str("node", node.Node).
+ Str("storage", storage.Storage).
+ Msg("Failed to get storage content")
+ }
if _, ok := storageSuccess[storage.Storage]; !ok {
storagePreserveNeeded[storage.Storage] = struct{}{}
}
@@ -9045,6 +9072,11 @@ func (m *Monitor) pollStorageBackupsWithNodes(ctx context.Context, instanceName
continue
}
+ // Clear any previous permission warning on success
+ m.mu.Lock()
+ delete(m.backupPermissionWarnings, instanceName)
+ m.mu.Unlock()
+
contentSuccess++
storageSuccess[storage.Storage] = struct{}{}
delete(storagePreserveNeeded, storage.Storage)