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) => {
- - - + +