feat(ui): show backup permission warnings on Backups page

When PVE backup polling detects permission errors (403/401/permission
denied), track them per instance and surface them via the scheduler
health endpoint.

The Backups page now fetches instance warnings and displays a banner
when backup permission issues are detected, telling users exactly how
to fix the problem.

Related to #1139
This commit is contained in:
rcourtman
2026-02-03 19:27:10 +00:00
parent b6d0713552
commit 1733bea15c
3 changed files with 132 additions and 5 deletions

View File

@@ -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 (
<div class="space-y-3">
<ProxmoxSectionNav current="backups" />
{/* Permission Warnings Banner */}
<Show when={allWarnings().length > 0}>
<Card padding="md" tone="warning">
<div class="flex items-start gap-3">
<AlertTriangle class="h-5 w-5 text-amber-500 flex-shrink-0 mt-0.5" />
<div class="space-y-2">
<p class="font-medium text-amber-800 dark:text-amber-200">
Backup permission issue detected
</p>
<For each={allWarnings()}>
{(item) => (
<div class="text-sm text-amber-700 dark:text-amber-300">
<span class="font-medium">{item.instance}:</span>{' '}
<span>{item.warning}</span>
</div>
)}
</For>
</div>
</div>
</Card>
</Show>
{/* Loading State */}
<Show
when={connected() && !initialDataReceived() && !hasBackupData()}

View File

@@ -0,0 +1,60 @@
import { createSignal, createEffect, onCleanup } from 'solid-js';
interface InstanceHealth {
key: string;
type: string;
displayName: string;
instance: string;
warnings?: string[];
}
interface SchedulerHealth {
instances: InstanceHealth[];
}
/**
* Hook to fetch instance warnings from the scheduler health endpoint.
* Returns warnings for PVE instances (e.g., backup permission issues).
*/
export function useInstanceWarnings() {
const [warnings, setWarnings] = createSignal<Map<string, string[]>>(new Map());
const [loading, setLoading] = createSignal(true);
const [error, setError] = createSignal<string | null>(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<string, string[]>();
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,
};
}

View File

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