diff --git a/frontend-modern/src/components/Settings/NodeModal.tsx b/frontend-modern/src/components/Settings/NodeModal.tsx index b4dfc54a6..5238b27ce 100644 --- a/frontend-modern/src/components/Settings/NodeModal.tsx +++ b/frontend-modern/src/components/Settings/NodeModal.tsx @@ -35,19 +35,7 @@ export const NodeModal: Component = (props) => { tokenName: '', tokenValue: '', fingerprint: '', - verifySSL: true, - // PVE specific - monitorVMs: true, - monitorContainers: true, - monitorStorage: true, - monitorBackups: true, - enableBackupManagement: true, // New field for backup write permissions - // PBS specific - monitorDatastores: true, - monitorSyncJobs: true, - monitorVerifyJobs: true, - monitorPruneJobs: true, - monitorGarbageJobs: false + verifySSL: true }); const [formData, setFormData] = createSignal(getCleanFormData()); @@ -121,17 +109,7 @@ export const NodeModal: Component = (props) => { tokenName: tokenName, tokenValue: '', // Don't show existing token fingerprint: ('fingerprint' in node ? node.fingerprint : '') || '', - verifySSL: node.verifySSL ?? true, - monitorVMs: (node.type === 'pve' && 'monitorVMs' in node ? node.monitorVMs : true) ?? true, - monitorContainers: (node.type === 'pve' && 'monitorContainers' in node ? node.monitorContainers : true) ?? true, - monitorStorage: (node.type === 'pve' && 'monitorStorage' in node ? node.monitorStorage : true) ?? true, - monitorBackups: (node.type === 'pve' && 'monitorBackups' in node ? node.monitorBackups : true) ?? true, - enableBackupManagement: true, // Default to true for existing nodes - monitorDatastores: (node.type === 'pbs' && 'monitorDatastores' in node ? node.monitorDatastores : true) ?? true, - monitorSyncJobs: (node.type === 'pbs' && 'monitorSyncJobs' in node ? node.monitorSyncJobs : true) ?? true, - monitorVerifyJobs: (node.type === 'pbs' && 'monitorVerifyJobs' in node ? node.monitorVerifyJobs : true) ?? true, - monitorPruneJobs: (node.type === 'pbs' && 'monitorPruneJobs' in node ? node.monitorPruneJobs : true) ?? true, - monitorGarbageJobs: (node.type === 'pbs' && 'monitorGarbageJobs' in node ? node.monitorGarbageJobs : false) ?? false + verifySSL: node.verifySSL ?? true }); } }); @@ -165,18 +143,18 @@ export const NodeModal: Component = (props) => { // Add monitor settings based on type if (props.nodeType === 'pve') { Object.assign(nodeData, { - monitorVMs: data.monitorVMs, - monitorContainers: data.monitorContainers, - monitorStorage: data.monitorStorage, - monitorBackups: data.monitorBackups + monitorVMs: true, + monitorContainers: true, + monitorStorage: true, + monitorBackups: true }); } else { Object.assign(nodeData, { - monitorDatastores: data.monitorDatastores, - monitorSyncJobs: data.monitorSyncJobs, - monitorVerifyJobs: data.monitorVerifyJobs, - monitorPruneJobs: data.monitorPruneJobs, - monitorGarbageJobs: data.monitorGarbageJobs + monitorDatastores: true, + monitorSyncJobs: true, + monitorVerifyJobs: true, + monitorPruneJobs: true, + monitorGarbageJobs: true }); } @@ -476,26 +454,9 @@ export const NodeModal: Component = (props) => { {/* Quick Setup Tab */} - {/* Backup Management Checkbox */} -
- -

- {formData().enableBackupManagement - ? 'Required to read PVE backup files and display them in the Backups tab (Proxmox API limitation)' - : 'Backups tab will not show PVE backups without these permissions'} -

-
- +

+ The command below creates the monitoring user, applies read-only access, and adds the storage permissions Pulse needs to display backups. +

Just copy and run this one command on your Proxmox VE server:

{/* One-line command */} @@ -518,7 +479,7 @@ export const NodeModal: Component = (props) => { body: JSON.stringify({ type: 'pve', host: formData().host, - backupPerms: formData().enableBackupManagement + backupPerms: true }) }); @@ -611,8 +572,7 @@ export const NodeModal: Component = (props) => { const hostValue = formData().host || ''; const encodedHost = encodeURIComponent(hostValue); const pulseUrl = encodeURIComponent(window.location.origin); - const backupPerms = formData().enableBackupManagement ? '&backup_perms=true' : ''; - const scriptUrl = `/api/setup-script?type=pve&host=${encodedHost}&pulse_url=${pulseUrl}${backupPerms}`; + const scriptUrl = `/api/setup-script?type=pve&host=${encodedHost}&pulse_url=${pulseUrl}&backup_perms=true`; // Fetch the script using the current session const response = await fetch(scriptUrl); @@ -664,7 +624,7 @@ export const NodeModal: Component = (props) => {
  • ✓ - Sets up monitoring permissions (PVEAuditor + guest agent access{formData().enableBackupManagement ? ' + backup access' : ''}) + Sets up monitoring permissions (PVEAuditor + guest agent access + backup visibility)
  • ✓ @@ -1193,104 +1153,17 @@ export const NodeModal: Component = (props) => { - {/* Monitoring Options */} + {/* Monitoring Overview */}
    -
    - {props.nodeType === 'pve' ? ( - <> - - - - - - ) : ( - <> - - - - - - - )} -
    +

    + Pulse automatically tracks all supported resources for this node — virtual machines, containers, storage usage, backups, and PBS job activity — so you always get full visibility without extra configuration. +

    diff --git a/frontend-modern/src/components/Settings/Settings.tsx b/frontend-modern/src/components/Settings/Settings.tsx index bc8c3675e..56feb49f0 100644 --- a/frontend-modern/src/components/Settings/Settings.tsx +++ b/frontend-modern/src/components/Settings/Settings.tsx @@ -93,13 +93,21 @@ interface DiagnosticsData { errors: string[]; } +interface DiscoveryScanStatus { + scanning: boolean; + subnet?: string; + lastScanStartedAt?: number; + lastResultAt?: number; + errors?: string[]; +} + type SettingsTab = 'pve' | 'pbs' | 'system' | 'urls' | 'security' | 'diagnostics'; // Node with UI-specific fields type NodeConfigWithStatus = NodeConfig & { hasPassword?: boolean; hasToken?: boolean; - status: 'connected' | 'disconnected' | 'error'; + status: 'connected' | 'disconnected' | 'error' | 'pending'; }; const Settings: Component = () => { @@ -114,6 +122,7 @@ const Settings: Component = () => { const [modalResetKey, setModalResetKey] = createSignal(0); const [showPasswordModal, setShowPasswordModal] = createSignal(false); const [initialLoadComplete, setInitialLoadComplete] = createSignal(false); + const [discoveryScanStatus, setDiscoveryScanStatus] = createSignal({ scanning: false }); // System settings // PBS polling interval removed - fixed at 10 seconds @@ -167,6 +176,34 @@ const Settings: Component = () => { return date.toLocaleString(); }; + const formatRelativeTime = (timestamp?: number) => { + if (!timestamp) { + return ''; + } + + const delta = Date.now() - timestamp; + if (delta < 0) { + return 'just now'; + } + + const seconds = Math.round(delta / 1000); + if (seconds < 60) { + return `${seconds}s ago`; + } + + const minutes = Math.round(seconds / 60); + if (minutes < 60) { + return `${minutes}m ago`; + } + + const hours = Math.round(minutes / 60); + if (hours < 24) { + return `${hours}h ago`; + } + + return new Date(timestamp).toLocaleString(); + }; + const tabs: { id: SettingsTab; label: string; icon: string }[] = [ { id: 'pve', @@ -210,7 +247,7 @@ const Settings: Component = () => { // Use the hasPassword/hasToken from the API if available, otherwise check local fields hasPassword: node.hasPassword ?? !!node.password, hasToken: node.hasToken ?? !!node.tokenValue, - status: node.status || 'disconnected' as const + status: node.status || 'pending' as const })); setNodes(nodesWithStatus); } catch (error) { @@ -324,6 +361,11 @@ const Settings: Component = () => { } else { setDiscoveredNodes(filtered); } + + setDiscoveryScanStatus(prev => ({ + ...prev, + lastResultAt: Date.now() + })); }; const loadDiscoveredNodes = async () => { @@ -334,8 +376,18 @@ const Settings: Component = () => { const data = await response.json(); if (Array.isArray(data.servers)) { updateDiscoveredNodesFromServers(data.servers as RawDiscoveredServer[]); + setDiscoveryScanStatus(prev => ({ + ...prev, + lastResultAt: typeof data.timestamp === 'number' ? data.timestamp : Date.now(), + errors: Array.isArray(data.errors) && data.errors.length > 0 ? data.errors : undefined + })); } else { updateDiscoveredNodesFromServers([]); + setDiscoveryScanStatus(prev => ({ + ...prev, + lastResultAt: typeof data?.timestamp === 'number' ? data.timestamp : prev.lastResultAt, + errors: Array.isArray(data?.errors) && data.errors.length > 0 ? data.errors : undefined + })); } } } catch (error) { @@ -343,6 +395,45 @@ const Settings: Component = () => { } }; + const triggerDiscoveryScan = async (options: { quiet?: boolean } = {}) => { + const { quiet = false } = options; + + setDiscoveryScanStatus(prev => ({ + ...prev, + scanning: true, + subnet: discoverySubnet() || prev.subnet, + lastScanStartedAt: Date.now(), + errors: undefined + })); + + try { + const { apiFetch } = await import('@/utils/apiClient'); + const response = await apiFetch('/api/discover', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ subnet: discoverySubnet() || 'auto' }) + }); + + if (!response.ok) { + const message = await response.text(); + throw new Error(message || 'Discovery request failed'); + } + + if (!quiet) { + notificationStore.info('Discovery scan started', 2000); + } + } catch (error) { + console.error('Failed to start discovery scan:', error); + notificationStore.error('Failed to start discovery scan'); + setDiscoveryScanStatus(prev => ({ + ...prev, + scanning: false + })); + } + }; + // Load nodes and system settings on mount onMount(async () => { // Subscribe to events @@ -362,16 +453,60 @@ const Settings: Component = () => { const unsubscribeDiscovery = eventBus.on('discovery_updated', (data) => { if (!data) { updateDiscoveredNodesFromServers([]); + setDiscoveryScanStatus(prev => ({ + ...prev, + scanning: false + })); return; } if (Array.isArray(data.servers)) { updateDiscoveredNodesFromServers(data.servers as RawDiscoveredServer[], { merge: !!data.immediate }); + setDiscoveryScanStatus(prev => ({ + ...prev, + scanning: data.scanning ?? prev.scanning, + lastResultAt: data.timestamp ?? Date.now(), + errors: Array.isArray(data.errors) && data.errors.length > 0 ? data.errors : undefined + })); } else if (!data.immediate) { // Ensure we clear stale results when the update explicitly reports no servers updateDiscoveredNodesFromServers([]); + setDiscoveryScanStatus(prev => ({ + ...prev, + scanning: data.scanning ?? prev.scanning, + lastResultAt: data.timestamp ?? prev.lastResultAt, + errors: Array.isArray(data.errors) && data.errors.length > 0 ? data.errors : undefined + })); + } else { + setDiscoveryScanStatus(prev => ({ + ...prev, + scanning: data.scanning ?? prev.scanning, + errors: Array.isArray(data.errors) && data.errors.length > 0 ? data.errors : undefined + })); } }); + + const unsubscribeDiscoveryStatus = eventBus.on('discovery_status', (data) => { + if (!data) { + setDiscoveryScanStatus(prev => ({ + ...prev, + scanning: false + })); + return; + } + + setDiscoveryScanStatus(prev => ({ + ...prev, + scanning: !!data.scanning, + subnet: data.subnet || prev.subnet, + lastScanStartedAt: data.scanning + ? (data.timestamp ?? Date.now()) + : prev.lastScanStartedAt, + lastResultAt: !data.scanning && data.timestamp + ? data.timestamp + : prev.lastResultAt + })); + }); // Poll for node updates when modal is open let pollInterval: ReturnType | undefined; @@ -401,6 +536,7 @@ const Settings: Component = () => { unsubscribeAutoRegister(); unsubscribeRefresh(); unsubscribeDiscovery(); + unsubscribeDiscoveryStatus(); if (pollInterval) { clearInterval(pollInterval); } @@ -866,15 +1002,21 @@ const Settings: Component = () => { discoverySubnet: discoverySubnet() }); if (newValue) { - loadDiscoveredNodes(); - notificationStore.success('Discovery enabled', 2000); + await triggerDiscoveryScan({ quiet: true }); + notificationStore.success('Discovery enabled — scanning network...', 2000); } else { notificationStore.info('Discovery disabled', 2000); + setDiscoveryScanStatus(prev => ({ + ...prev, + scanning: false + })); } } catch (error) { console.error('Failed to update discovery setting:', error); notificationStore.error('Failed to update discovery setting'); setDiscoveryEnabled(!newValue); + } finally { + await loadDiscoveredNodes(); } }} disabled={envOverrides().discoveryEnabled} @@ -885,9 +1027,13 @@ const Settings: Component = () => {