mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-02-18 00:17:39 +01:00
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:
@@ -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()}
|
||||
|
||||
60
frontend-modern/src/hooks/useInstanceWarnings.ts
Normal file
60
frontend-modern/src/hooks/useInstanceWarnings.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user