From 9d372ba9a829c6dc8dc9ba040418ae6cfbfb07a9 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Fri, 24 Oct 2025 08:20:12 +0000 Subject: [PATCH] refactor: improve frontend performance with lazy loading and component extraction Add lazy loading for major route components (Dashboard, Settings, Docker, etc.) to reduce initial bundle size and improve load times. Extract node configuration tables (PVE, PBS, PMG) into dedicated reusable components with consistent table-based layouts. Implement Suspense fallbacks for smoother transitions between routes. --- frontend-modern/src/App.tsx | 55 +- .../Settings/ConfiguredNodeTables.tsx | 611 +++++ .../src/components/Settings/Settings.tsx | 2039 ++++++----------- 3 files changed, 1409 insertions(+), 1296 deletions(-) create mode 100644 frontend-modern/src/components/Settings/ConfiguredNodeTables.tsx diff --git a/frontend-modern/src/App.tsx b/frontend-modern/src/App.tsx index 7616a78..e44d17d 100644 --- a/frontend-modern/src/App.tsx +++ b/frontend-modern/src/App.tsx @@ -1,6 +1,8 @@ import { Show, For, + Suspense, + lazy, createSignal, createContext, useContext, @@ -14,14 +16,6 @@ import { import type { JSX } from 'solid-js'; import { Router, Route, Navigate, useNavigate, useLocation } from '@solidjs/router'; import { getGlobalWebSocketStore } from './stores/websocket-global'; -import { Dashboard } from './components/Dashboard/Dashboard'; -import StorageComponent from './components/Storage/Storage'; -import Backups from './components/Backups/Backups'; -import Replication from './components/Replication/Replication'; -import Settings from './components/Settings/Settings'; -import { Alerts } from './pages/Alerts'; -import { DockerHosts } from './components/Docker/DockerHosts'; -import { HostsOverview } from './components/Hosts/HostsOverview'; import { ToastContainer } from './components/Toast/Toast'; import NotificationContainer from './components/NotificationContainer'; import { ErrorBoundary } from './components/ErrorBoundary'; @@ -40,7 +34,6 @@ import { LegacySSHBanner } from './components/LegacySSHBanner'; import { DemoBanner } from './components/DemoBanner'; import { createTooltipSystem } from './components/shared/Tooltip'; import type { State } from '@/types/api'; -import MailGateway from './components/PMG/MailGateway'; import { ProxmoxIcon } from '@/components/icons/ProxmoxIcon'; import { DockerIcon } from '@/components/icons/DockerIcon'; import { HostsIcon } from '@/components/icons/HostsIcon'; @@ -49,6 +42,26 @@ import { SettingsGearIcon } from '@/components/icons/SettingsGearIcon'; import { TokenRevealDialog } from './components/TokenRevealDialog'; import { useAlertsActivation } from './stores/alertsActivation'; +const Dashboard = lazy(() => + import('./components/Dashboard/Dashboard').then((module) => ({ default: module.Dashboard })), +); +const StorageComponent = lazy(() => import('./components/Storage/Storage')); +const Backups = lazy(() => import('./components/Backups/Backups')); +const Replication = lazy(() => import('./components/Replication/Replication')); +const MailGateway = lazy(() => import('./components/PMG/MailGateway')); +const AlertsPage = lazy(() => + import('./pages/Alerts').then((module) => ({ default: module.Alerts })), +); +const SettingsPage = lazy(() => import('./components/Settings/Settings')); +const DockerHosts = lazy(() => + import('./components/Docker/DockerHosts').then((module) => ({ default: module.DockerHosts })), +); +const HostsOverview = lazy(() => + import('./components/Hosts/HostsOverview').then((module) => ({ + default: module.HostsOverview, + })), +); + // Enhanced store type with proper typing type EnhancedStore = ReturnType; @@ -564,6 +577,14 @@ function App() { // Pass through the store directly (only when initialized) const enhancedStore = () => wsStore(); + const DashboardView = () => ( + + ); + + const SettingsRoute = () => ( + + ); + // Root layout component for Router const RootLayout = (props: { children?: JSX.Element }) => { return ( @@ -618,10 +639,7 @@ function App() { } /> } /> - } - /> + @@ -631,11 +649,8 @@ function App() { } /> - - } - /> + + ); } @@ -1068,7 +1083,9 @@ function AppLayout(props: { class="tab-content block bg-white dark:bg-gray-800 rounded-b rounded-tr shadow mb-2" >
- {props.children} + Loading view...
}> + {props.children} + diff --git a/frontend-modern/src/components/Settings/ConfiguredNodeTables.tsx b/frontend-modern/src/components/Settings/ConfiguredNodeTables.tsx new file mode 100644 index 0000000..ec9cbd7 --- /dev/null +++ b/frontend-modern/src/components/Settings/ConfiguredNodeTables.tsx @@ -0,0 +1,611 @@ +import { Component, For, Show, createMemo } from 'solid-js'; +import type { NodeConfig } from '@/types/nodes'; + +type NodeConfigWithStatus = NodeConfig & { + hasPassword?: boolean; + hasToken?: boolean; + status: 'connected' | 'disconnected' | 'offline' | 'error' | 'pending'; +}; + +interface PveNodesTableProps { + nodes: NodeConfigWithStatus[]; + stateNodes: { instance: string; status?: string; connectionHealth?: string }[]; + onTestConnection: (nodeId: string) => void; + onEdit: (node: NodeConfigWithStatus) => void; + onDelete: (node: NodeConfigWithStatus) => void; +} + +type StatusMeta = { dotClass: string; label: string; labelClass: string }; + +const STATUS_META: Record = { + online: { + dotClass: 'bg-green-500', + label: 'Online', + labelClass: 'text-green-600 dark:text-green-400', + }, + offline: { + dotClass: 'bg-red-500', + label: 'Offline', + labelClass: 'text-red-600 dark:text-red-400', + }, + degraded: { + dotClass: 'bg-yellow-500', + label: 'Degraded', + labelClass: 'text-amber-600 dark:text-amber-400', + }, + pending: { + dotClass: 'bg-amber-500 animate-pulse', + label: 'Pending', + labelClass: 'text-amber-600 dark:text-amber-400', + }, + unknown: { + dotClass: 'bg-gray-400', + label: 'Unknown', + labelClass: 'text-gray-500 dark:text-gray-400', + }, +}; + +const resolvePveStatusMeta = ( + node: NodeConfigWithStatus, + stateNodes: PveNodesTableProps['stateNodes'], +): StatusMeta => { + const stateNode = stateNodes.find((n) => n.instance === node.name); + if ( + stateNode?.connectionHealth === 'unhealthy' || + stateNode?.connectionHealth === 'error' || + stateNode?.status === 'offline' || + stateNode?.status === 'disconnected' + ) { + return STATUS_META.offline; + } + if (stateNode?.connectionHealth === 'degraded') { + return STATUS_META.degraded; + } + if (stateNode && (stateNode.status === 'online' || stateNode.connectionHealth === 'healthy')) { + return STATUS_META.online; + } + + switch (node.status) { + case 'connected': + return STATUS_META.online; + case 'pending': + return STATUS_META.pending; + case 'disconnected': + case 'offline': + case 'error': + return STATUS_META.offline; + default: + return STATUS_META.unknown; + } +}; + +export const PveNodesTable: Component = (props) => { + return ( +
+ + + + + + + + + + + + + {(node) => { + const statusMeta = createMemo(() => resolvePveStatusMeta(node, props.stateNodes)); + const clusterEndpoints = createMemo(() => + 'clusterEndpoints' in node && node.clusterEndpoints ? node.clusterEndpoints : [], + ); + const clusterName = createMemo(() => + 'clusterName' in node && node.clusterName ? node.clusterName : 'Unknown', + ); + return ( + + + + + + + + ); + }} + + +
+ Node + + Credentials + + Capabilities + + Status + + Actions +
+
+
+
+
+

+ {node.name} +

+

+ {node.host} +

+
+
+ +
+
+ {clusterName()} Cluster + + {clusterEndpoints().length} nodes + +
+ 0}> +
+ + {(endpoint) => ( + + {endpoint.NodeName} + + {endpoint.IP} + + + )} + +
+
+

+ + + + Automatic failover enabled between cluster nodes +

+
+
+
+
+ + {node.user ? `User: ${node.user}` : `Token: ${node.tokenName}`} + + +
+ {node.type === 'pve' && 'monitorVMs' in node && node.monitorVMs && ( + + VMs + + )} + {node.type === 'pve' && 'monitorContainers' in node && node.monitorContainers && ( + + Containers + + )} + {node.type === 'pve' && 'monitorStorage' in node && node.monitorStorage && ( + + Storage + + )} + {node.type === 'pve' && 'monitorBackups' in node && node.monitorBackups && ( + + Backups + + )} + {node.type === 'pve' && + 'monitorPhysicalDisks' in node && + node.monitorPhysicalDisks && ( + + Physical Disks + + )} + {node.type === 'pve' && node.temperature?.available && ( + + Temperature + + )} +
+
+ + + {statusMeta().label} + + +
+ + + +
+
+
+ ); +}; + +interface PbsNodesTableProps { + nodes: NodeConfigWithStatus[]; + statePbs: { name: string; status?: string; connectionHealth?: string }[]; + onTestConnection: (nodeId: string) => void; + onEdit: (node: NodeConfigWithStatus) => void; + onDelete: (node: NodeConfigWithStatus) => void; +} + +const resolvePbsStatusMeta = ( + node: NodeConfigWithStatus, + statePbs: PbsNodesTableProps['statePbs'], +): StatusMeta => { + const statePBS = statePbs.find((p) => p.name === node.name); + if ( + statePBS?.connectionHealth === 'unhealthy' || + statePBS?.connectionHealth === 'error' || + statePBS?.status === 'offline' || + statePBS?.status === 'disconnected' + ) { + return STATUS_META.offline; + } + if (statePBS?.connectionHealth === 'degraded') { + return STATUS_META.degraded; + } + if (statePBS && (statePBS.status === 'online' || statePBS.connectionHealth === 'healthy')) { + return STATUS_META.online; + } + + switch (node.status) { + case 'connected': + return STATUS_META.online; + case 'pending': + return STATUS_META.pending; + case 'disconnected': + case 'offline': + case 'error': + return STATUS_META.offline; + default: + return STATUS_META.unknown; + } +}; + +export const PbsNodesTable: Component = (props) => { + return ( +
+ + + + + + + + + + + + + {(node) => { + const statusMeta = createMemo(() => resolvePbsStatusMeta(node, props.statePbs)); + return ( + + + + + + + + ); + }} + + +
+ Node + + Credentials + + Capabilities + + Status + + Actions +
+
+
+
+
+

+ {node.name} +

+

+ {node.host} +

+
+
+
+
+ + {node.user ? `User: ${node.user}` : `Token: ${node.tokenName}`} + + +
+ {node.type === 'pbs' && 'monitorDatastores' in node && node.monitorDatastores && ( + + Datastores + + )} + {node.type === 'pbs' && 'monitorSyncJobs' in node && node.monitorSyncJobs && ( + + Sync Jobs + + )} + {node.type === 'pbs' && + 'monitorVerifyJobs' in node && + node.monitorVerifyJobs && ( + + Verify Jobs + + )} + {node.type === 'pbs' && 'monitorPruneJobs' in node && node.monitorPruneJobs && ( + + Prune Jobs + + )} + {node.type === 'pbs' && + 'monitorGarbageJobs' in node && + (node as NodeConfig & { monitorGarbageJobs?: boolean }).monitorGarbageJobs && ( + + Garbage Collection + + )} + {node.type === 'pbs' && node.temperature?.available && ( + + Temperature + + )} +
+
+ + + {statusMeta().label} + + +
+ + + +
+
+
+ ); +}; + +interface PmgNodesTableProps { + nodes: NodeConfigWithStatus[]; + statePmg: { name: string; status?: string; connectionHealth?: string }[]; + onTestConnection: (nodeId: string) => void; + onEdit: (node: NodeConfigWithStatus) => void; + onDelete: (node: NodeConfigWithStatus) => void; +} + +const resolvePmgStatusMeta = ( + node: NodeConfigWithStatus, + statePmg: PmgNodesTableProps['statePmg'], +): StatusMeta => { + const statePMG = statePmg.find((p) => p.name === node.name); + if ( + statePMG?.connectionHealth === 'unhealthy' || + statePMG?.connectionHealth === 'error' || + statePMG?.status === 'offline' || + statePMG?.status === 'disconnected' + ) { + return STATUS_META.offline; + } + if (statePMG?.connectionHealth === 'degraded') { + return STATUS_META.degraded; + } + if (statePMG && (statePMG.status === 'online' || statePMG.connectionHealth === 'healthy')) { + return STATUS_META.online; + } + + switch (node.status) { + case 'connected': + return STATUS_META.online; + case 'pending': + return STATUS_META.pending; + case 'disconnected': + case 'offline': + case 'error': + return STATUS_META.offline; + default: + return STATUS_META.unknown; + } +}; + +export const PmgNodesTable: Component = (props) => { + return ( +
+ + + + + + + + + + + + + {(node) => { + const statusMeta = createMemo(() => resolvePmgStatusMeta(node, props.statePmg)); + return ( + + + + + + + + ); + }} + + +
+ Node + + Credentials + + Capabilities + + Status + + Actions +
+
+
+
+
+

+ {node.name} +

+

+ {node.host} +

+
+
+
+
+ + {node.user ? `User: ${node.user}` : `Token: ${node.tokenName}`} + + +
+ {node.type === 'pmg' && + (node as NodeConfig & { monitorMailStats?: boolean }).monitorMailStats && ( + + Mail stats + + )} + {node.type === 'pmg' && + (node as NodeConfig & { monitorQueues?: boolean }).monitorQueues && ( + + Queues + + )} + {node.type === 'pmg' && + (node as NodeConfig & { monitorQuarantine?: boolean }).monitorQuarantine && ( + + Quarantine + + )} + {node.type === 'pmg' && + (node as NodeConfig & { monitorDomainStats?: boolean }).monitorDomainStats && ( + + Domain stats + + )} +
+
+ + + {statusMeta().label} + + +
+ + + +
+
+
+ ); +}; diff --git a/frontend-modern/src/components/Settings/Settings.tsx b/frontend-modern/src/components/Settings/Settings.tsx index de4f1ca..e20f85c 100644 --- a/frontend-modern/src/components/Settings/Settings.tsx +++ b/frontend-modern/src/components/Settings/Settings.tsx @@ -1,4 +1,4 @@ -import { Component, createSignal, onMount, For, Show, createEffect, onCleanup, on } from 'solid-js'; +import { Component, createSignal, onMount, For, Show, createEffect, createMemo, onCleanup, on } from 'solid-js'; import type { JSX } from 'solid-js'; import { useNavigate, useLocation } from '@solidjs/router'; import { useWebSocket } from '@/App'; @@ -12,6 +12,7 @@ import APITokenManager from './APITokenManager'; import { OIDCPanel } from './OIDCPanel'; import { QuickSecuritySetup } from './QuickSecuritySetup'; import { SecurityPostureSummary } from './SecurityPostureSummary'; +import { PveNodesTable, PbsNodesTable, PmgNodesTable } from './ConfiguredNodeTables'; import { SettingsAPI } from '@/api/settings'; import { NodesAPI } from '@/api/nodes'; import { UpdatesAPI } from '@/api/updates'; @@ -532,6 +533,10 @@ const Settings: Component = (props) => { scanning: false, }); + const pveNodes = createMemo(() => nodes().filter((n) => n.type === 'pve')); + const pbsNodes = createMemo(() => nodes().filter((n) => n.type === 'pbs')); + const pmgNodes = createMemo(() => nodes().filter((n) => n.type === 'pmg')); + // System settings // PBS polling interval removed - fixed at 10 seconds const [allowedOrigins, setAllowedOrigins] = createSignal('*'); @@ -2181,346 +2186,99 @@ const Settings: Component = (props) => { {/* PVE Nodes Tab */} - +
+
+

+ Connect Proxmox VE +

+

+ Link Pulse to your Proxmox VE cluster with a dedicated API token. Quick setup can scaffold users, roles, and permissions for you. +

+
- -
- Loading configuration... -
-
- -
- -
- {/* Discovery toggle */} -
- - Discovery - - { - if (envOverrides().discoveryEnabled || savingDiscoverySettings()) { - e.preventDefault(); - return; - } - const success = await handleDiscoveryEnabledChange( - e.currentTarget.checked, - ); - if (!success) { - e.currentTarget.checked = discoveryEnabled(); - } - }} - disabled={envOverrides().discoveryEnabled || savingDiscoverySettings()} - containerClass="gap-2" - label={ - - {discoveryEnabled() ? 'On' : 'Off'} - - } - /> -
- - - + +
+ Loading configuration... +
- - -
-
- -
- n.type === 'pve')}> - {(node) => ( -
-
-
-
-
{ - // Find the corresponding node in the WebSocket state - const stateNode = state.nodes.find( - (n) => n.instance === node.name, - ); - // Check if the node has an unhealthy connection or is offline - if ( - stateNode?.connectionHealth === 'unhealthy' || - stateNode?.connectionHealth === 'error' || - stateNode?.status === 'offline' || - stateNode?.status === 'disconnected' - ) { - return 'bg-red-500'; - } - // Check if connection is degraded (partial cluster connectivity) - if (stateNode?.connectionHealth === 'degraded') { - return 'bg-yellow-500'; - } - // Check if we have a healthy connection - if ( - stateNode && - (stateNode.status === 'online' || - stateNode.connectionHealth === 'healthy') - ) { - return 'bg-green-500'; - } - // Fall back to the last known config status if live data hasn't arrived yet - if (node.status === 'connected') { - return 'bg-green-500'; - } - if ( - node.status === 'error' || - node.status === 'offline' || - node.status === 'disconnected' - ) { - return 'bg-red-500'; - } - if (node.status === 'pending') { - return 'bg-amber-500 animate-pulse'; - } - return 'bg-gray-400'; - })()}`} - >
-
-

- {node.name} -

-

- {node.host} -

-
- - {node.user - ? `User: ${node.user}` - : `Token: ${node.tokenName}`} - - {node.type === 'pve' && - 'monitorVMs' in node && - node.monitorVMs && ( - - VMs - - )} - {node.type === 'pve' && - 'monitorContainers' in node && - node.monitorContainers && ( - - Containers - - )} - {node.type === 'pve' && - 'monitorStorage' in node && - node.monitorStorage && ( - - Storage - - )} - {node.type === 'pve' && - 'monitorBackups' in node && - node.monitorBackups && ( - - Backups - - )} - {node.type === 'pve' && - 'monitorPhysicalDisks' in node && - node.monitorPhysicalDisks && ( - - Physical Disks - - )} - {node.type === 'pve' && node.temperature?.available && ( - - Temperature - - )} -
- -
-
- - - - - - - - - {'clusterName' in node ? node.clusterName : 'Unknown'}{' '} - Cluster - - - {'clusterEndpoints' in node && node.clusterEndpoints - ? node.clusterEndpoints.length - : 0}{' '} - nodes - -
-
- - {(endpoint) => ( -
-
- - {endpoint.NodeName} - - - {endpoint.IP} - -
- )} -
-
-

- - - - Automatic failover enabled between cluster nodes -

-
-
-
-
-
-
- + + + + + + + + + -
-
- )} -
- {nodes().filter((n) => n.type === 'pve').length === 0 && - discoveredNodes().filter((n) => n.type === 'pve').length === 0 && ( -
-

No PVE nodes configured

-

Add a node to start monitoring

-
- )} - - {/* Discovered PVE nodes - only show when discovery is enabled */} - -
-
- - - - Scanning your network for Proxmox VE servers… - - - - - Last scan{' '} - {formatRelativeTime( - discoveryScanStatus().lastResultAt ?? - discoveryScanStatus().lastScanStartedAt, - )} - - -
- -
- Discovery issues: -
    - - {(err) =>
  • {err}
  • } -
    -
- - /timed out|timeout/i.test(err), - ) - } - > -

- Large networks can time out in auto mode. Switch to a custom subnet - for faster, targeted scans. -

-
-
-
- n.type === 'pve').length === 0 - } - > -
- - - Waiting for responses… this can take up to a minute depending on your - network size. - -
-
- n.type === 'pve')}> - {(server) => ( -
{ - // Pre-fill the modal with discovered server info - setEditingNode({ - id: '', - type: 'pve', - name: server.hostname || `pve-${server.ip}`, - host: `https://${server.ip}:${server.port}`, - user: '', - tokenName: '', - tokenValue: '', - verifySSL: false, - monitorVMs: true, - monitorContainers: true, - monitorStorage: true, - monitorBackups: true, - monitorPhysicalDisks: false, - status: 'pending', - } as NodeConfigWithStatus); + 0}> + { + setEditingNode(node); setCurrentNodeType('pve'); setShowNodeModal(true); }} + onDelete={requestDeleteNode} + /> + + + n.type === 'pve').length === 0 + } + > +
+

No PVE nodes configured

+

Add a node to start monitoring

+
+
+ + + + {/* Discovered PVE nodes - only show when discovery is enabled */} + +
+
+ + + + Scanning your network for Proxmox VE servers… + + + -
-
-
-
-
-

- {server.hostname || `Proxmox VE at ${server.ip}`} -

-

- {server.ip}:{server.port} -

-
- - Discovered - - - Click to configure - + + Last scan{' '} + {formatRelativeTime( + discoveryScanStatus().lastResultAt ?? + discoveryScanStatus().lastScanStartedAt, + )} + + +
+ +
+ Discovery issues: +
    + + {(err) =>
  • {err}
  • } +
    +
+ + /timed out|timeout/i.test(err), + ) + } + > +

+ Large networks can time out in auto mode. Switch to a custom subnet + for faster, targeted scans. +

+
+
+
+ n.type === 'pve').length === 0 + } + > +
+ + + Waiting for responses… this can take up to a minute depending on your + network size. + +
+
+ n.type === 'pve')}> + {(server) => ( +
{ + // Pre-fill the modal with discovered server info + setEditingNode({ + id: '', + type: 'pve', + name: server.hostname || `pve-${server.ip}`, + host: `https://${server.ip}:${server.port}`, + user: '', + tokenName: '', + tokenValue: '', + verifySSL: false, + monitorVMs: true, + monitorContainers: true, + monitorStorage: true, + monitorBackups: true, + monitorPhysicalDisks: false, + status: 'pending', + } as NodeConfigWithStatus); + setCurrentNodeType('pve'); + setShowNodeModal(true); + }} + > +
+
+
+
+
+

+ {server.hostname || `Proxmox VE at ${server.ip}`} +

+

+ {server.ip}:{server.port} +

+
+ + Discovered + + + Click to configure + +
+ + +
- + )} + +
+ +
+
+
+ + {/* PBS Nodes Tab */} + +
+
+

+ Connect Proxmox Backup Server +

+

+ Provide a PBS API token with backup read access. Use quick setup to generate the token and grant the minimum privileges. +

+
+
+ +
+ Loading configuration... +
+
+ + <> +
+ +
+ {/* Discovery toggle */} +
+ + Discovery + + { + if (envOverrides().discoveryEnabled || savingDiscoverySettings()) { + e.preventDefault(); + return; + } + const success = await handleDiscoveryEnabledChange( + e.currentTarget.checked, + ); + if (!success) { + e.currentTarget.checked = discoveryEnabled(); + } + }} + disabled={ + envOverrides().discoveryEnabled || savingDiscoverySettings() + } + containerClass="gap-2" + label={ + + {discoveryEnabled() ? 'On' : 'Off'} + + } + /> +
+ + +
-
- )} - -
- - - - - - + > + + + + + + + - {/* PBS Nodes Tab */} - - -
- -
- Loading configuration... -
-
- -
- -
- {/* Discovery toggle */} -
- - Discovery - - { - if (envOverrides().discoveryEnabled || savingDiscoverySettings()) { - e.preventDefault(); - return; - } - const success = await handleDiscoveryEnabledChange( - e.currentTarget.checked, - ); - if (!success) { - e.currentTarget.checked = discoveryEnabled(); - } - }} - disabled={envOverrides().discoveryEnabled || savingDiscoverySettings()} - containerClass="gap-2" - label={ - - {discoveryEnabled() ? 'On' : 'Off'} - - } - /> -
- - - - - - -
-
- -
- n.type === 'pbs')}> - {(node) => ( -
-
-
-
-
{ - // Find the corresponding PBS instance in the WebSocket state - const statePBS = state.pbs.find((p) => p.name === node.name); - // Check if the PBS has an unhealthy connection or is offline - if ( - statePBS?.connectionHealth === 'unhealthy' || - statePBS?.connectionHealth === 'error' || - statePBS?.status === 'offline' || - statePBS?.status === 'disconnected' - ) { - return 'bg-red-500'; - } - // Check if connection is degraded (not commonly used for PBS but keeping consistent) - if (statePBS?.connectionHealth === 'degraded') { - return 'bg-yellow-500'; - } - // Check if we have a healthy connection - if ( - statePBS && - (statePBS.status === 'online' || - statePBS.connectionHealth === 'healthy') - ) { - return 'bg-green-500'; - } - // Fall back to the last known config status if live data hasn't arrived yet - if (node.status === 'connected') { - return 'bg-green-500'; - } - if ( - node.status === 'error' || - node.status === 'offline' || - node.status === 'disconnected' - ) { - return 'bg-red-500'; - } - if (node.status === 'pending') { - return 'bg-amber-500 animate-pulse'; - } - return 'bg-gray-400'; - })()}`} - >
-
-

- {node.name} -

-

- {node.host} -

-
- - {node.user - ? `User: ${node.user}` - : `Token: ${node.tokenName}`} - - {node.type === 'pbs' && - 'monitorDatastores' in node && - node.monitorDatastores && ( - - Datastores - - )} - {node.type === 'pbs' && - 'monitorSyncJobs' in node && - node.monitorSyncJobs && ( - - Sync Jobs - - )} - {node.type === 'pbs' && - 'monitorVerifyJobs' in node && - node.monitorVerifyJobs && ( - - Verify Jobs - - )} - {node.type === 'pbs' && - 'monitorPruneJobs' in node && - node.monitorPruneJobs && ( - - Prune Jobs - - )} - {node.type === 'pbs' && node.temperature?.available && ( - - Temperature - - )} -
-
-
-
-
- -
-
- )} -
- {nodes().filter((n) => n.type === 'pbs').length === 0 && - discoveredNodes().filter((n) => n.type === 'pbs').length === 0 && ( -
-

No PBS nodes configured

-

Add a node to start monitoring

-
- )} - - {/* Discovered PBS nodes - only show when discovery is enabled */} - -
-
- - - - Scanning your network for Proxmox Backup Servers… - - - - - Last scan{' '} - {formatRelativeTime( - discoveryScanStatus().lastResultAt ?? - discoveryScanStatus().lastScanStartedAt, - )} - - -
- -
- Discovery issues: -
    - - {(err) =>
  • {err}
  • } -
    -
- - /timed out|timeout/i.test(err), - ) - } - > -

- Large networks can time out in auto mode. Switch to a custom subnet - for faster, targeted scans. -

-
-
-
- n.type === 'pbs').length === 0 - } - > -
- - - Waiting for responses… this can take up to a minute depending on your - network size. - -
-
- n.type === 'pbs')}> - {(server) => ( -
{ - // Pre-fill the modal with discovered server info - setEditingNode({ - id: '', - type: 'pbs', - name: server.hostname || `pbs-${server.ip}`, - host: `https://${server.ip}:${server.port}`, - user: '', - tokenName: '', - tokenValue: '', - verifySSL: false, - monitorDatastores: true, - monitorSyncJobs: true, - monitorVerifyJobs: true, - monitorPruneJobs: true, - monitorGarbageJobs: true, - status: 'pending', - } as NodeConfigWithStatus); + 0}> + { + setEditingNode(node); setCurrentNodeType('pbs'); setShowNodeModal(true); }} + onDelete={requestDeleteNode} + /> + + + n.type === 'pbs').length === 0 + } + > +
+

No PBS nodes configured

+

Add a node to start monitoring

+
+
+ + + + {/* Discovered PBS nodes - only show when discovery is enabled */} + +
+
+ + + + Scanning your network for Proxmox Backup Servers… + + + -
-
-
-
+ + Last scan{' '} + {formatRelativeTime( + discoveryScanStatus().lastResultAt ?? + discoveryScanStatus().lastScanStartedAt, + )} + + +
+ +
+ Discovery issues: +
    + + {(err) =>
  • {err}
  • } +
    +
+ + /timed out|timeout/i.test(err), + ) + } + > +

+ Large networks can time out in auto mode. Switch to a custom subnet + for faster, targeted scans. +

+
+
+
+ n.type === 'pbs').length === 0 + } + > +
+ + + Waiting for responses… this can take up to a minute depending on your + network size. + +
+
+ n.type === 'pbs')}> + {(server) => ( +
{ + // Pre-fill the modal with discovered server info + setEditingNode({ + id: '', + type: 'pbs', + name: server.hostname || `pbs-${server.ip}`, + host: `https://${server.ip}:${server.port}`, + user: '', + tokenName: '', + tokenValue: '', + verifySSL: false, + monitorDatastores: true, + monitorSyncJobs: true, + monitorVerifyJobs: true, + monitorPruneJobs: true, + monitorGarbageJobs: true, + status: 'pending', + } as NodeConfigWithStatus); + setCurrentNodeType('pbs'); + setShowNodeModal(true); + }} + > +
+
+
+
+
+

+ {server.hostname || `Backup Server at ${server.ip}`} +

+

+ {server.ip}:{server.port} +

+
+ + Discovered + + + Click to configure + +
+
+
+
+ + + +
+
+ )} +
+
+ +
+
+
+{/* PMG Nodes Tab */} + +
+
+

+ Connect Proxmox Mail Gateway +

+

+ Onboard each Mail Gateway with a limited API token so Pulse can read queue depth and quarantine metrics. +

+
+
+ +
+ Loading configuration... +
+
+ + + <> +
+ +
+ {/* Discovery toggle */} +
+ + Discovery + + { + if (envOverrides().discoveryEnabled || savingDiscoverySettings()) { + e.preventDefault(); + return; + } + const success = await handleDiscoveryEnabledChange( + e.currentTarget.checked, + ); + if (!success) { + e.currentTarget.checked = discoveryEnabled(); + } + }} + disabled={ + envOverrides().discoveryEnabled || savingDiscoverySettings() + } + containerClass="gap-2" + label={ + + {discoveryEnabled() ? 'On' : 'Off'} + + } + /> +
+ + + + + + +
+
+ + 0}> + { + setEditingNode(nodes().find((n) => n.id === node.id) ?? null); + setCurrentNodeType('pmg'); + setModalResetKey((prev) => prev + 1); + setShowNodeModal(true); + }} + onDelete={requestDeleteNode} + /> + + + +
+

No PMG nodes configured

+

Add a node to start monitoring mail flow

+
+
+ +
+ + {/* Discovered PMG nodes - only show when discovery is enabled */} + +
+
+ + + + Scanning network... + + + + + Last scan{' '} + {formatRelativeTime( + discoveryScanStatus().lastResultAt ?? + discoveryScanStatus().lastScanStartedAt, + )} + + +
+ +
+ Discovery issues: +
    + + {(err) =>
  • {err}
  • } +
    +
+ + /timed out|timeout/i.test(err), + ) + } + > +

+ Large networks can time out in auto mode. Switch to a custom subnet + for faster, targeted scans. +

+
+
+
+ n.type === 'pmg').length === 0 + } + > +
+ + + + +

Scanning for PMG servers...

+
+
+ n.type === 'pmg')}> + {(server) => ( +
{ + setEditingNode(null); + setCurrentNodeType('pmg'); + setModalResetKey((prev) => prev + 1); + setShowNodeModal(true); + setTimeout(() => { + const hostInput = document.querySelector( + 'input[placeholder*="192.168"]', + ) as HTMLInputElement; + if (hostInput) { + hostInput.value = server.ip; + hostInput.dispatchEvent(new Event('input', { bubbles: true })); + } + }, 50); + }} + > +
+
+ + + +
-

- {server.hostname || `Backup Server at ${server.ip}`} +

+ {server.hostname || `PMG at ${server.ip}`}

{server.ip}:{server.port} @@ -3095,440 +2991,30 @@ const Settings: Component = (props) => {

-
- - - -
-
- )} - - - - - - - - - - {/* PMG Nodes Tab */} - - -
- -
- Loading configuration... -
-
- - -
- -
- {/* Discovery toggle */} -
- - Discovery - - { - if (envOverrides().discoveryEnabled || savingDiscoverySettings()) { - e.preventDefault(); - return; - } - const success = await handleDiscoveryEnabledChange( - e.currentTarget.checked, - ); - if (!success) { - e.currentTarget.checked = discoveryEnabled(); - } - }} - disabled={envOverrides().discoveryEnabled || savingDiscoverySettings()} - containerClass="gap-2" - label={ - - {discoveryEnabled() ? 'On' : 'Off'} - - } - /> -
- - - - - - -
-
- -
- n.type === 'pmg')}> - {(node) => { - const pmgNode = node as NodeConfigWithStatus & { - monitorMailStats?: boolean; - monitorQueues?: boolean; - monitorQuarantine?: boolean; - monitorDomainStats?: boolean; - }; - const statePMG = state.pmg.find((p) => p.name === pmgNode.name); - - const statusClass = (() => { - if ( - statePMG?.connectionHealth === 'unhealthy' || - statePMG?.connectionHealth === 'error' || - statePMG?.status === 'offline' || - statePMG?.status === 'disconnected' - ) { - return 'bg-red-500'; - } - if (statePMG?.connectionHealth === 'degraded') { - return 'bg-yellow-500'; - } - if (statePMG && (statePMG.status === 'online' || statePMG.connectionHealth === 'healthy')) { - return 'bg-green-500'; - } - if (pmgNode.status === 'connected') { - return 'bg-green-500'; - } - if ( - pmgNode.status === 'error' || - pmgNode.status === 'offline' || - pmgNode.status === 'disconnected' - ) { - return 'bg-red-500'; - } - if (pmgNode.status === 'pending') { - return 'bg-amber-500 animate-pulse'; - } - return 'bg-gray-400'; - })(); - - return ( -
-
-
-
-
-
-

- {pmgNode.name} -

-

- {pmgNode.host} -

-
- - {pmgNode.user ? `User: ${pmgNode.user}` : `Token: ${pmgNode.tokenName}`} - - {pmgNode.monitorMailStats && ( - - Mail stats - - )} - {pmgNode.monitorQueues && ( - - Queues - - )} - {pmgNode.monitorQuarantine && ( - - Quarantine - - )} - {pmgNode.monitorDomainStats && ( - - Domain stats - - )} -
-
+ + +
-
- - - -
-
-
- ); - }} -
- - {nodes().filter((n) => n.type === 'pmg').length === 0 && ( -
-

No PMG nodes configured

-

Add a node to start monitoring mail flow

-
- )} - - {/* Discovered PMG nodes - only show when discovery is enabled */} - -
-
- - - - Scanning network... - - - - - Last scan{' '} - {formatRelativeTime( - discoveryScanStatus().lastResultAt ?? - discoveryScanStatus().lastScanStartedAt, - )} - - + )} +
- -
- Discovery issues: -
    - - {(err) =>
  • {err}
  • } -
    -
- - /timed out|timeout/i.test(err), - ) - } - > -

- Large networks can time out in auto mode. Switch to a custom subnet - for faster, targeted scans. -

-
-
-
- n.type === 'pmg').length === 0 - } - > -
- - - - -

Scanning for PMG servers...

-
-
- n.type === 'pmg')}> - {(server) => ( -
{ - setEditingNode(null); - setCurrentNodeType('pmg'); - setModalResetKey((prev) => prev + 1); - setShowNodeModal(true); - setTimeout(() => { - const hostInput = document.querySelector( - 'input[placeholder*="192.168"]', - ) as HTMLInputElement; - if (hostInput) { - hostInput.value = server.ip; - hostInput.dispatchEvent(new Event('input', { bubbles: true })); - } - }, 50); - }} - > -
-
- - - - -
-

- {server.hostname || `PMG at ${server.ip}`} -

-

- {server.ip}:{server.port} -

-
- - Discovered - - - Click to configure - -
-
-
- - - -
-
- )} -
-
-
-
+
+
+
- - - - - {/* Docker Tab */} +{/* Docker Tab */} = (props) => {
PVE Nodes: - {nodes().filter((n) => n.type === 'pve').length} + {pveNodes().length}
PBS Nodes: - {nodes().filter((n) => n.type === 'pbs').length} + {pbsNodes().length}
@@ -6614,10 +6100,9 @@ const Settings: Component = (props) => {
- - - + +