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.
This commit is contained in:
rcourtman 2025-10-24 08:20:12 +00:00
parent 9925a0d78f
commit 9d372ba9a8
3 changed files with 1409 additions and 1296 deletions

View file

@ -1,6 +1,8 @@
import { import {
Show, Show,
For, For,
Suspense,
lazy,
createSignal, createSignal,
createContext, createContext,
useContext, useContext,
@ -14,14 +16,6 @@ import {
import type { JSX } from 'solid-js'; import type { JSX } from 'solid-js';
import { Router, Route, Navigate, useNavigate, useLocation } from '@solidjs/router'; import { Router, Route, Navigate, useNavigate, useLocation } from '@solidjs/router';
import { getGlobalWebSocketStore } from './stores/websocket-global'; 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 { ToastContainer } from './components/Toast/Toast';
import NotificationContainer from './components/NotificationContainer'; import NotificationContainer from './components/NotificationContainer';
import { ErrorBoundary } from './components/ErrorBoundary'; import { ErrorBoundary } from './components/ErrorBoundary';
@ -40,7 +34,6 @@ import { LegacySSHBanner } from './components/LegacySSHBanner';
import { DemoBanner } from './components/DemoBanner'; import { DemoBanner } from './components/DemoBanner';
import { createTooltipSystem } from './components/shared/Tooltip'; import { createTooltipSystem } from './components/shared/Tooltip';
import type { State } from '@/types/api'; import type { State } from '@/types/api';
import MailGateway from './components/PMG/MailGateway';
import { ProxmoxIcon } from '@/components/icons/ProxmoxIcon'; import { ProxmoxIcon } from '@/components/icons/ProxmoxIcon';
import { DockerIcon } from '@/components/icons/DockerIcon'; import { DockerIcon } from '@/components/icons/DockerIcon';
import { HostsIcon } from '@/components/icons/HostsIcon'; import { HostsIcon } from '@/components/icons/HostsIcon';
@ -49,6 +42,26 @@ import { SettingsGearIcon } from '@/components/icons/SettingsGearIcon';
import { TokenRevealDialog } from './components/TokenRevealDialog'; import { TokenRevealDialog } from './components/TokenRevealDialog';
import { useAlertsActivation } from './stores/alertsActivation'; 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 // Enhanced store type with proper typing
type EnhancedStore = ReturnType<typeof getGlobalWebSocketStore>; type EnhancedStore = ReturnType<typeof getGlobalWebSocketStore>;
@ -564,6 +577,14 @@ function App() {
// Pass through the store directly (only when initialized) // Pass through the store directly (only when initialized)
const enhancedStore = () => wsStore(); const enhancedStore = () => wsStore();
const DashboardView = () => (
<Dashboard vms={state().vms} containers={state().containers} nodes={state().nodes} />
);
const SettingsRoute = () => (
<SettingsPage darkMode={darkMode} toggleDarkMode={toggleDarkMode} />
);
// Root layout component for Router // Root layout component for Router
const RootLayout = (props: { children?: JSX.Element }) => { const RootLayout = (props: { children?: JSX.Element }) => {
return ( return (
@ -618,10 +639,7 @@ function App() {
<Router root={RootLayout}> <Router root={RootLayout}>
<Route path="/" component={() => <Navigate href="/proxmox/overview" />} /> <Route path="/" component={() => <Navigate href="/proxmox/overview" />} />
<Route path="/proxmox" component={() => <Navigate href="/proxmox/overview" />} /> <Route path="/proxmox" component={() => <Navigate href="/proxmox/overview" />} />
<Route <Route path="/proxmox/overview" component={DashboardView} />
path="/proxmox/overview"
component={() => <Dashboard vms={state().vms} containers={state().containers} nodes={state().nodes} />}
/>
<Route path="/proxmox/storage" component={StorageComponent} /> <Route path="/proxmox/storage" component={StorageComponent} />
<Route path="/proxmox/replication" component={Replication} /> <Route path="/proxmox/replication" component={Replication} />
<Route path="/proxmox/mail" component={MailGateway} /> <Route path="/proxmox/mail" component={MailGateway} />
@ -631,11 +649,8 @@ function App() {
<Route path="/docker" component={DockerRoute} /> <Route path="/docker" component={DockerRoute} />
<Route path="/hosts" component={HostsRoute} /> <Route path="/hosts" component={HostsRoute} />
<Route path="/servers" component={() => <Navigate href="/hosts" />} /> <Route path="/servers" component={() => <Navigate href="/hosts" />} />
<Route path="/alerts/*" component={Alerts} /> <Route path="/alerts/*" component={AlertsPage} />
<Route <Route path="/settings/*" component={SettingsRoute} />
path="/settings/*"
component={() => <Settings darkMode={darkMode} toggleDarkMode={toggleDarkMode} />}
/>
</Router> </Router>
); );
} }
@ -1068,7 +1083,9 @@ function AppLayout(props: {
class="tab-content block bg-white dark:bg-gray-800 rounded-b rounded-tr shadow mb-2" class="tab-content block bg-white dark:bg-gray-800 rounded-b rounded-tr shadow mb-2"
> >
<div class="pulse-panel"> <div class="pulse-panel">
<Suspense fallback={<div class="p-6 text-sm text-gray-500 dark:text-gray-400">Loading view...</div>}>
{props.children} {props.children}
</Suspense>
</div> </div>
</main> </main>

View file

@ -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<string, StatusMeta> = {
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<PveNodesTableProps> = (props) => {
return (
<div class="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm">
<thead class="bg-gray-50 dark:bg-gray-800/70">
<tr>
<th scope="col" class="py-2 pl-4 pr-3 text-left text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Node
</th>
<th scope="col" class="px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Credentials
</th>
<th scope="col" class="px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Capabilities
</th>
<th scope="col" class="px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Status
</th>
<th scope="col" class="px-3 py-2 text-right text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Actions
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700 bg-white dark:bg-gray-900/40">
<For each={props.nodes}>
{(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 (
<tr class="even:bg-gray-50/60 dark:even:bg-gray-800/30 hover:bg-blue-50/40 dark:hover:bg-blue-900/20 transition-colors">
<td class="align-top py-3 pl-4 pr-3">
<div class="min-w-0 space-y-1">
<div class="flex items-start gap-3">
<div class={`mt-1.5 h-3 w-3 rounded-full ${statusMeta().dotClass}`}></div>
<div class="min-w-0 flex-1">
<p class="font-medium text-gray-900 dark:text-gray-100 truncate">
{node.name}
</p>
<p class="text-xs text-gray-600 dark:text-gray-400 break-all">
{node.host}
</p>
</div>
</div>
<Show when={node.type === 'pve' && 'isCluster' in node && node.isCluster}>
<div class="rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-100 dark:bg-gray-800 px-3 py-2 space-y-2">
<div class="flex items-center gap-2 text-xs font-semibold text-gray-700 dark:text-gray-300">
<span>{clusterName()} Cluster</span>
<span class="ml-auto rounded-full bg-gray-200 dark:bg-gray-700 px-2 py-0.5 text-[0.65rem] font-medium text-gray-600 dark:text-gray-400">
{clusterEndpoints().length} nodes
</span>
</div>
<Show when={clusterEndpoints().length > 0}>
<div class="flex flex-wrap gap-1">
<For each={clusterEndpoints()}>
{(endpoint) => (
<span
class={`inline-flex items-center gap-2 rounded border px-2 py-1 text-[0.7rem] ${
endpoint.Online
? 'border-green-200 bg-green-50 text-green-700 dark:border-green-700 dark:bg-green-900/20 dark:text-green-300'
: 'border-gray-200 bg-gray-100 text-gray-600 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400'
}`}
>
<span class="font-medium">{endpoint.NodeName}</span>
<span class="text-[0.65rem] text-gray-500 dark:text-gray-400">
{endpoint.IP}
</span>
</span>
)}
</For>
</div>
</Show>
<p class="flex items-center gap-1 text-[0.7rem] text-gray-600 dark:text-gray-400">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M5 12h14M12 5l7 7-7 7" />
</svg>
Automatic failover enabled between cluster nodes
</p>
</div>
</Show>
</div>
</td>
<td class="align-top px-3 py-3">
<span class="inline-flex rounded-full bg-gray-200 dark:bg-gray-600 px-3 py-1 text-xs font-medium text-gray-700 dark:text-gray-300">
{node.user ? `User: ${node.user}` : `Token: ${node.tokenName}`}
</span>
</td>
<td class="align-top px-3 py-3">
<div class="flex flex-wrap gap-1">
{node.type === 'pve' && 'monitorVMs' in node && node.monitorVMs && (
<span class="text-xs px-2 py-1 bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded">
VMs
</span>
)}
{node.type === 'pve' && 'monitorContainers' in node && node.monitorContainers && (
<span class="text-xs px-2 py-1 bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded">
Containers
</span>
)}
{node.type === 'pve' && 'monitorStorage' in node && node.monitorStorage && (
<span class="text-xs px-2 py-1 bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded">
Storage
</span>
)}
{node.type === 'pve' && 'monitorBackups' in node && node.monitorBackups && (
<span class="text-xs px-2 py-1 bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded">
Backups
</span>
)}
{node.type === 'pve' &&
'monitorPhysicalDisks' in node &&
node.monitorPhysicalDisks && (
<span class="text-xs px-2 py-1 bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded">
Physical Disks
</span>
)}
{node.type === 'pve' && node.temperature?.available && (
<span class="text-xs px-2 py-1 bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300 rounded">
Temperature
</span>
)}
</div>
</td>
<td class="align-top px-3 py-3 whitespace-nowrap">
<span class={`inline-flex items-center gap-2 text-xs font-medium ${statusMeta().labelClass}`}>
<span class={`h-2.5 w-2.5 rounded-full ${statusMeta().dotClass}`}></span>
{statusMeta().label}
</span>
</td>
<td class="align-top px-3 py-3">
<div class="flex items-center justify-end gap-1 sm:gap-2">
<button
type="button"
onClick={() => props.onTestConnection(node.id)}
class="p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100"
title="Test connection"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"></polyline>
</svg>
</button>
<button
type="button"
onClick={() => props.onEdit(node)}
class="p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100"
title="Edit node"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"></path>
<path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"></path>
</svg>
</button>
<button
type="button"
onClick={() => props.onDelete(node)}
class="p-2 text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300"
title="Delete node"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"></path>
</svg>
</button>
</div>
</td>
</tr>
);
}}
</For>
</tbody>
</table>
</div>
);
};
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<PbsNodesTableProps> = (props) => {
return (
<div class="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm">
<thead class="bg-gray-50 dark:bg-gray-800/70">
<tr>
<th scope="col" class="py-2 pl-4 pr-3 text-left text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Node
</th>
<th scope="col" class="px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Credentials
</th>
<th scope="col" class="px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Capabilities
</th>
<th scope="col" class="px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Status
</th>
<th scope="col" class="px-3 py-2 text-right text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Actions
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700 bg-white dark:bg-gray-900/40">
<For each={props.nodes}>
{(node) => {
const statusMeta = createMemo(() => resolvePbsStatusMeta(node, props.statePbs));
return (
<tr class="even:bg-gray-50/60 dark:even:bg-gray-800/30 hover:bg-blue-50/40 dark:hover:bg-blue-900/20 transition-colors">
<td class="align-top py-3 pl-4 pr-3">
<div class="min-w-0 space-y-1">
<div class="flex items-start gap-3">
<div class={`mt-1.5 h-3 w-3 rounded-full ${statusMeta().dotClass}`}></div>
<div class="min-w-0 flex-1">
<p class="font-medium text-gray-900 dark:text-gray-100 truncate">
{node.name}
</p>
<p class="text-xs text-gray-600 dark:text-gray-400 break-all">
{node.host}
</p>
</div>
</div>
</div>
</td>
<td class="align-top px-3 py-3">
<span class="inline-flex rounded-full bg-gray-200 dark:bg-gray-600 px-3 py-1 text-xs font-medium text-gray-700 dark:text-gray-300">
{node.user ? `User: ${node.user}` : `Token: ${node.tokenName}`}
</span>
</td>
<td class="align-top px-3 py-3">
<div class="flex flex-wrap gap-1">
{node.type === 'pbs' && 'monitorDatastores' in node && node.monitorDatastores && (
<span class="text-xs px-2 py-1 bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded">
Datastores
</span>
)}
{node.type === 'pbs' && 'monitorSyncJobs' in node && node.monitorSyncJobs && (
<span class="text-xs px-2 py-1 bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded">
Sync Jobs
</span>
)}
{node.type === 'pbs' &&
'monitorVerifyJobs' in node &&
node.monitorVerifyJobs && (
<span class="text-xs px-2 py-1 bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded">
Verify Jobs
</span>
)}
{node.type === 'pbs' && 'monitorPruneJobs' in node && node.monitorPruneJobs && (
<span class="text-xs px-2 py-1 bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded">
Prune Jobs
</span>
)}
{node.type === 'pbs' &&
'monitorGarbageJobs' in node &&
(node as NodeConfig & { monitorGarbageJobs?: boolean }).monitorGarbageJobs && (
<span class="text-xs px-2 py-1 bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded">
Garbage Collection
</span>
)}
{node.type === 'pbs' && node.temperature?.available && (
<span class="text-xs px-2 py-1 bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300 rounded">
Temperature
</span>
)}
</div>
</td>
<td class="align-top px-3 py-3 whitespace-nowrap">
<span class={`inline-flex items-center gap-2 text-xs font-medium ${statusMeta().labelClass}`}>
<span class={`h-2.5 w-2.5 rounded-full ${statusMeta().dotClass}`}></span>
{statusMeta().label}
</span>
</td>
<td class="align-top px-3 py-3">
<div class="flex items-center justify-end gap-1 sm:gap-2">
<button
type="button"
onClick={() => props.onTestConnection(node.id)}
class="p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100"
title="Test connection"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"></polyline>
</svg>
</button>
<button
type="button"
onClick={() => props.onEdit(node)}
class="p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100"
title="Edit node"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"></path>
<path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"></path>
</svg>
</button>
<button
type="button"
onClick={() => props.onDelete(node)}
class="p-2 text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300"
title="Delete node"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"></path>
</svg>
</button>
</div>
</td>
</tr>
);
}}
</For>
</tbody>
</table>
</div>
);
};
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<PmgNodesTableProps> = (props) => {
return (
<div class="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm">
<thead class="bg-gray-50 dark:bg-gray-800/70">
<tr>
<th scope="col" class="py-2 pl-4 pr-3 text-left text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Node
</th>
<th scope="col" class="px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Credentials
</th>
<th scope="col" class="px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Capabilities
</th>
<th scope="col" class="px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Status
</th>
<th scope="col" class="px-3 py-2 text-right text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Actions
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700 bg-white dark:bg-gray-900/40">
<For each={props.nodes}>
{(node) => {
const statusMeta = createMemo(() => resolvePmgStatusMeta(node, props.statePmg));
return (
<tr class="even:bg-gray-50/60 dark:even:bg-gray-800/30 hover:bg-blue-50/40 dark:hover:bg-blue-900/20 transition-colors">
<td class="align-top py-3 pl-4 pr-3">
<div class="min-w-0 space-y-1">
<div class="flex items-start gap-3">
<div class={`mt-1.5 h-3 w-3 rounded-full ${statusMeta().dotClass}`}></div>
<div class="min-w-0 flex-1">
<p class="font-medium text-gray-900 dark:text-gray-100 truncate">
{node.name}
</p>
<p class="text-xs text-gray-600 dark:text-gray-400 break-all">
{node.host}
</p>
</div>
</div>
</div>
</td>
<td class="align-top px-3 py-3">
<span class="inline-flex rounded-full bg-gray-200 dark:bg-gray-600 px-3 py-1 text-xs font-medium text-gray-700 dark:text-gray-300">
{node.user ? `User: ${node.user}` : `Token: ${node.tokenName}`}
</span>
</td>
<td class="align-top px-3 py-3">
<div class="flex flex-wrap gap-1">
{node.type === 'pmg' &&
(node as NodeConfig & { monitorMailStats?: boolean }).monitorMailStats && (
<span class="text-xs px-2 py-1 bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded">
Mail stats
</span>
)}
{node.type === 'pmg' &&
(node as NodeConfig & { monitorQueues?: boolean }).monitorQueues && (
<span class="text-xs px-2 py-1 bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded">
Queues
</span>
)}
{node.type === 'pmg' &&
(node as NodeConfig & { monitorQuarantine?: boolean }).monitorQuarantine && (
<span class="text-xs px-2 py-1 bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded">
Quarantine
</span>
)}
{node.type === 'pmg' &&
(node as NodeConfig & { monitorDomainStats?: boolean }).monitorDomainStats && (
<span class="text-xs px-2 py-1 bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded">
Domain stats
</span>
)}
</div>
</td>
<td class="align-top px-3 py-3 whitespace-nowrap">
<span class={`inline-flex items-center gap-2 text-xs font-medium ${statusMeta().labelClass}`}>
<span class={`h-2.5 w-2.5 rounded-full ${statusMeta().dotClass}`}></span>
{statusMeta().label}
</span>
</td>
<td class="align-top px-3 py-3">
<div class="flex items-center justify-end gap-1 sm:gap-2">
<button
type="button"
onClick={() => props.onTestConnection(node.id)}
class="p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100"
title="Test connection"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"></polyline>
</svg>
</button>
<button
type="button"
onClick={() => props.onEdit(node)}
class="p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100"
title="Edit node"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"></path>
<path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"></path>
</svg>
</button>
<button
type="button"
onClick={() => props.onDelete(node)}
class="p-2 text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300"
title="Delete node"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"></path>
</svg>
</button>
</div>
</td>
</tr>
);
}}
</For>
</tbody>
</table>
</div>
);
};

View file

@ -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 type { JSX } from 'solid-js';
import { useNavigate, useLocation } from '@solidjs/router'; import { useNavigate, useLocation } from '@solidjs/router';
import { useWebSocket } from '@/App'; import { useWebSocket } from '@/App';
@ -12,6 +12,7 @@ import APITokenManager from './APITokenManager';
import { OIDCPanel } from './OIDCPanel'; import { OIDCPanel } from './OIDCPanel';
import { QuickSecuritySetup } from './QuickSecuritySetup'; import { QuickSecuritySetup } from './QuickSecuritySetup';
import { SecurityPostureSummary } from './SecurityPostureSummary'; import { SecurityPostureSummary } from './SecurityPostureSummary';
import { PveNodesTable, PbsNodesTable, PmgNodesTable } from './ConfiguredNodeTables';
import { SettingsAPI } from '@/api/settings'; import { SettingsAPI } from '@/api/settings';
import { NodesAPI } from '@/api/nodes'; import { NodesAPI } from '@/api/nodes';
import { UpdatesAPI } from '@/api/updates'; import { UpdatesAPI } from '@/api/updates';
@ -532,6 +533,10 @@ const Settings: Component<SettingsProps> = (props) => {
scanning: false, 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 // System settings
// PBS polling interval removed - fixed at 10 seconds // PBS polling interval removed - fixed at 10 seconds
const [allowedOrigins, setAllowedOrigins] = createSignal('*'); const [allowedOrigins, setAllowedOrigins] = createSignal('*');
@ -2181,18 +2186,23 @@ const Settings: Component<SettingsProps> = (props) => {
</Show> </Show>
{/* PVE Nodes Tab */} {/* PVE Nodes Tab */}
<Show when={activeTab() === 'agent-hub' && selectedAgent() === 'pve'}> <Show when={activeTab() === 'agent-hub' && selectedAgent() === 'pve'}>
<AgentStepSection <section class="space-y-6">
step="Step 2" <header class="space-y-1">
title="Connect Proxmox VE" <h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
description="Link Pulse to your Proxmox VE cluster with a dedicated API token. Quick setup can scaffold users, roles, and permissions for you." Connect Proxmox VE
> </h3>
<p class="text-sm text-gray-600 dark:text-gray-400 leading-relaxed">
Link Pulse to your Proxmox VE cluster with a dedicated API token. Quick setup can scaffold users, roles, and permissions for you.
</p>
</header>
<div class="space-y-4"> <div class="space-y-4">
<Show when={!initialLoadComplete()}> <Show when={!initialLoadComplete()}>
<div class="flex items-center justify-center py-8"> <div class="flex items-center justify-center rounded-lg border border-dashed border-gray-300 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/40 py-12 text-sm text-gray-500 dark:text-gray-400">
<span class="text-gray-500">Loading configuration...</span> Loading configuration...
</div> </div>
</Show> </Show>
<Show when={initialLoadComplete()}> <Show when={initialLoadComplete()}>
<>
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-4 gap-3"> <div class="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-4 gap-3">
<SectionHeader title="Proxmox VE nodes" size="md" class="flex-1" /> <SectionHeader title="Proxmox VE nodes" size="md" class="flex-1" />
<div class="flex flex-wrap items-center justify-start gap-2 sm:justify-end"> <div class="flex flex-wrap items-center justify-start gap-2 sm:justify-end">
@ -2218,7 +2228,9 @@ const Settings: Component<SettingsProps> = (props) => {
e.currentTarget.checked = discoveryEnabled(); e.currentTarget.checked = discoveryEnabled();
} }
}} }}
disabled={envOverrides().discoveryEnabled || savingDiscoverySettings()} disabled={
envOverrides().discoveryEnabled || savingDiscoverySettings()
}
containerClass="gap-2" containerClass="gap-2"
label={ label={
<span class="text-xs font-medium text-gray-600 dark:text-gray-400"> <span class="text-xs font-medium text-gray-600 dark:text-gray-400">
@ -2285,285 +2297,33 @@ const Settings: Component<SettingsProps> = (props) => {
</div> </div>
</div> </div>
<div class="grid gap-4"> <Show when={pveNodes().length > 0}>
<For each={nodes().filter((n) => n.type === 'pve')}> <PveNodesTable
{(node) => ( nodes={pveNodes()}
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 border border-gray-200 dark:border-gray-600"> stateNodes={state.nodes ?? []}
<div class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-2"> onTestConnection={testNodeConnection}
<div class="flex-1 min-w-0"> onEdit={(node) => {
<div class="flex items-start gap-3">
<div
class={`flex-shrink-0 w-3 h-3 mt-1.5 rounded-full ${(() => {
// 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';
})()}`}
></div>
<div class="flex-1 min-w-0">
<h4 class="font-medium text-gray-900 dark:text-gray-100 truncate">
{node.name}
</h4>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1 break-all">
{node.host}
</p>
<div class="flex flex-wrap gap-1 sm:gap-2 mt-2">
<span class="text-xs px-2 py-1 bg-gray-200 dark:bg-gray-600 rounded">
{node.user
? `User: ${node.user}`
: `Token: ${node.tokenName}`}
</span>
{node.type === 'pve' &&
'monitorVMs' in node &&
node.monitorVMs && (
<span class="text-xs px-2 py-1 bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded">
VMs
</span>
)}
{node.type === 'pve' &&
'monitorContainers' in node &&
node.monitorContainers && (
<span class="text-xs px-2 py-1 bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded">
Containers
</span>
)}
{node.type === 'pve' &&
'monitorStorage' in node &&
node.monitorStorage && (
<span class="text-xs px-2 py-1 bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded">
Storage
</span>
)}
{node.type === 'pve' &&
'monitorBackups' in node &&
node.monitorBackups && (
<span class="text-xs px-2 py-1 bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded">
Backups
</span>
)}
{node.type === 'pve' &&
'monitorPhysicalDisks' in node &&
node.monitorPhysicalDisks && (
<span class="text-xs px-2 py-1 bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded">
Physical Disks
</span>
)}
{node.type === 'pve' && node.temperature?.available && (
<span class="text-xs px-2 py-1 bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300 rounded">
Temperature
</span>
)}
</div>
<Show
when={
node.type === 'pve' && 'isCluster' in node && node.isCluster
}
>
<div class="mt-3 p-3 bg-gray-100 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
<div class="flex items-center gap-2 mb-2">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
class="text-gray-600 dark:text-gray-400"
>
<circle
cx="12"
cy="12"
r="3"
stroke="currentColor"
stroke-width="2"
fill="none"
/>
<circle
cx="4"
cy="12"
r="2"
stroke="currentColor"
stroke-width="2"
fill="none"
/>
<circle
cx="20"
cy="12"
r="2"
stroke="currentColor"
stroke-width="2"
fill="none"
/>
<line
x1="7"
y1="12"
x2="9"
y2="12"
stroke="currentColor"
stroke-width="2"
/>
<line
x1="15"
y1="12"
x2="17"
y2="12"
stroke="currentColor"
stroke-width="2"
/>
</svg>
<span class="font-semibold text-gray-700 dark:text-gray-300">
{'clusterName' in node ? node.clusterName : 'Unknown'}{' '}
Cluster
</span>
<span class="text-xs bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400 px-2 py-0.5 rounded-full ml-auto">
{'clusterEndpoints' in node && node.clusterEndpoints
? node.clusterEndpoints.length
: 0}{' '}
nodes
</span>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<For
each={
'clusterEndpoints' in node ? node.clusterEndpoints : []
}
>
{(endpoint) => (
<div class="flex items-center gap-2 text-xs bg-white dark:bg-gray-900 px-2 py-1.5 rounded border border-gray-200 dark:border-gray-700">
<div
class={`w-2 h-2 rounded-full flex-shrink-0 ${endpoint.Online ? 'bg-green-500' : 'bg-gray-400'}`}
></div>
<span class="font-medium text-gray-700 dark:text-gray-300">
{endpoint.NodeName}
</span>
<span class="text-gray-500 dark:text-gray-500 ml-auto">
{endpoint.IP}
</span>
</div>
)}
</For>
</div>
<p class="mt-2 text-xs text-gray-600 dark:text-gray-400 flex items-center gap-1">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M5 12h14M12 5l7 7-7 7" />
</svg>
Automatic failover enabled between cluster nodes
</p>
</div>
</Show>
</div>
</div>
</div>
<div class="flex items-center gap-1 sm:gap-2 flex-shrink-0">
<button
type="button"
onClick={() => testNodeConnection(node.id)}
class="p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100"
title="Test connection"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"></polyline>
</svg>
</button>
<button
type="button"
onClick={() => {
setEditingNode(node); setEditingNode(node);
setCurrentNodeType(node.type as 'pve' | 'pbs' | 'pmg'); setCurrentNodeType('pve');
setShowNodeModal(true); setShowNodeModal(true);
}} }}
class="p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100" onDelete={requestDeleteNode}
> />
<svg </Show>
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"></path>
<path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"></path>
</svg>
</button>
<button
type="button"
onClick={() => requestDeleteNode(node)}
class="p-2 text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"></path>
</svg>
</button>
</div>
</div>
</div>
)}
</For>
{nodes().filter((n) => n.type === 'pve').length === 0 && <Show
discoveredNodes().filter((n) => n.type === 'pve').length === 0 && ( when={
pveNodes().length === 0 &&
discoveredNodes().filter((n) => n.type === 'pve').length === 0
}
>
<div class="text-center py-8 text-gray-500 dark:text-gray-400"> <div class="text-center py-8 text-gray-500 dark:text-gray-400">
<p>No PVE nodes configured</p> <p>No PVE nodes configured</p>
<p class="text-sm mt-1">Add a node to start monitoring</p> <p class="text-sm mt-1">Add a node to start monitoring</p>
</div> </div>
)} </Show>
</>
</Show>
{/* Discovered PVE nodes - only show when discovery is enabled */} {/* Discovered PVE nodes - only show when discovery is enabled */}
<Show when={discoveryEnabled()}> <Show when={discoveryEnabled()}>
@ -2701,25 +2461,28 @@ const Settings: Component<SettingsProps> = (props) => {
</div> </div>
</Show> </Show>
</div> </div>
</Show> </section>
</div>
</AgentStepSection>
</Show> </Show>
{/* PBS Nodes Tab */} {/* PBS Nodes Tab */}
<Show when={activeTab() === 'agent-hub' && selectedAgent() === 'pbs'}> <Show when={activeTab() === 'agent-hub' && selectedAgent() === 'pbs'}>
<AgentStepSection <section class="space-y-6">
step="Step 2" <header class="space-y-1">
title="Connect Proxmox Backup Server" <h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
description="Provide a PBS API token with backup read access. Use quick setup to generate the token and grant the minimum privileges." Connect Proxmox Backup Server
> </h3>
<p class="text-sm text-gray-600 dark:text-gray-400 leading-relaxed">
Provide a PBS API token with backup read access. Use quick setup to generate the token and grant the minimum privileges.
</p>
</header>
<div class="space-y-4"> <div class="space-y-4">
<Show when={!initialLoadComplete()}> <Show when={!initialLoadComplete()}>
<div class="flex items-center justify-center py-8"> <div class="flex items-center justify-center rounded-lg border border-dashed border-gray-300 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/40 py-12 text-sm text-gray-500 dark:text-gray-400">
<span class="text-gray-500">Loading configuration...</span> Loading configuration...
</div> </div>
</Show> </Show>
<Show when={initialLoadComplete()}> <Show when={initialLoadComplete()}>
<>
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-4 gap-3"> <div class="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-4 gap-3">
<SectionHeader title="Proxmox Backup Server nodes" size="md" class="flex-1" /> <SectionHeader title="Proxmox Backup Server nodes" size="md" class="flex-1" />
<div class="flex flex-wrap items-center justify-start gap-2 sm:justify-end"> <div class="flex flex-wrap items-center justify-start gap-2 sm:justify-end">
@ -2745,7 +2508,9 @@ const Settings: Component<SettingsProps> = (props) => {
e.currentTarget.checked = discoveryEnabled(); e.currentTarget.checked = discoveryEnabled();
} }
}} }}
disabled={envOverrides().discoveryEnabled || savingDiscoverySettings()} disabled={
envOverrides().discoveryEnabled || savingDiscoverySettings()
}
containerClass="gap-2" containerClass="gap-2"
label={ label={
<span class="text-xs font-medium text-gray-600 dark:text-gray-400"> <span class="text-xs font-medium text-gray-600 dark:text-gray-400">
@ -2812,174 +2577,33 @@ const Settings: Component<SettingsProps> = (props) => {
</div> </div>
</div> </div>
<div class="grid gap-4"> <Show when={pbsNodes().length > 0}>
<For each={nodes().filter((n) => n.type === 'pbs')}> <PbsNodesTable
{(node) => ( nodes={pbsNodes()}
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 border border-gray-200 dark:border-gray-600"> statePbs={state.pbs ?? []}
<div class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-2"> onTestConnection={testNodeConnection}
<div class="flex-1 min-w-0"> onEdit={(node) => {
<div class="flex items-start gap-3">
<div
class={`flex-shrink-0 w-3 h-3 mt-1.5 rounded-full ${(() => {
// 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';
})()}`}
></div>
<div class="flex-1 min-w-0">
<h4 class="font-medium text-gray-900 dark:text-gray-100 truncate">
{node.name}
</h4>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1 break-all">
{node.host}
</p>
<div class="flex flex-wrap gap-1 sm:gap-2 mt-2">
<span class="text-xs px-2 py-1 bg-gray-200 dark:bg-gray-600 rounded">
{node.user
? `User: ${node.user}`
: `Token: ${node.tokenName}`}
</span>
{node.type === 'pbs' &&
'monitorDatastores' in node &&
node.monitorDatastores && (
<span class="text-xs px-2 py-1 bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded">
Datastores
</span>
)}
{node.type === 'pbs' &&
'monitorSyncJobs' in node &&
node.monitorSyncJobs && (
<span class="text-xs px-2 py-1 bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded">
Sync Jobs
</span>
)}
{node.type === 'pbs' &&
'monitorVerifyJobs' in node &&
node.monitorVerifyJobs && (
<span class="text-xs px-2 py-1 bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded">
Verify Jobs
</span>
)}
{node.type === 'pbs' &&
'monitorPruneJobs' in node &&
node.monitorPruneJobs && (
<span class="text-xs px-2 py-1 bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded">
Prune Jobs
</span>
)}
{node.type === 'pbs' && node.temperature?.available && (
<span class="text-xs px-2 py-1 bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300 rounded">
Temperature
</span>
)}
</div>
</div>
</div>
</div>
<div class="flex items-center gap-1 sm:gap-2 flex-shrink-0">
<button
type="button"
onClick={() => testNodeConnection(node.id)}
class="p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100"
title="Test connection"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"></polyline>
</svg>
</button>
<button
type="button"
onClick={() => {
setEditingNode(node); setEditingNode(node);
setCurrentNodeType(node.type as 'pve' | 'pbs' | 'pmg'); setCurrentNodeType('pbs');
setShowNodeModal(true); setShowNodeModal(true);
}} }}
class="p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100" onDelete={requestDeleteNode}
> />
<svg </Show>
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"></path>
<path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"></path>
</svg>
</button>
<button
type="button"
onClick={() => requestDeleteNode(node)}
class="p-2 text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"></path>
</svg>
</button>
</div>
</div>
</div>
)}
</For>
{nodes().filter((n) => n.type === 'pbs').length === 0 && <Show
discoveredNodes().filter((n) => n.type === 'pbs').length === 0 && ( when={
pbsNodes().length === 0 &&
discoveredNodes().filter((n) => n.type === 'pbs').length === 0
}
>
<div class="text-center py-8 text-gray-500 dark:text-gray-400"> <div class="text-center py-8 text-gray-500 dark:text-gray-400">
<p>No PBS nodes configured</p> <p>No PBS nodes configured</p>
<p class="text-sm mt-1">Add a node to start monitoring</p> <p class="text-sm mt-1">Add a node to start monitoring</p>
</div> </div>
)} </Show>
</>
</Show>
{/* Discovered PBS nodes - only show when discovery is enabled */} {/* Discovered PBS nodes - only show when discovery is enabled */}
<Show when={discoveryEnabled()}> <Show when={discoveryEnabled()}>
@ -3117,26 +2741,28 @@ const Settings: Component<SettingsProps> = (props) => {
</div> </div>
</Show> </Show>
</div> </div>
</section>
</Show> </Show>
</div>
</AgentStepSection>
</Show>
{/* PMG Nodes Tab */} {/* PMG Nodes Tab */}
<Show when={activeTab() === 'agent-hub' && selectedAgent() === 'pmg'}> <Show when={activeTab() === 'agent-hub' && selectedAgent() === 'pmg'}>
<AgentStepSection <section class="space-y-6">
step="Step 2" <header class="space-y-1">
title="Connect Proxmox Mail Gateway" <h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
description="Onboard each Mail Gateway with a limited API token so Pulse can read queue depth and quarantine metrics." Connect Proxmox Mail Gateway
> </h3>
<p class="text-sm text-gray-600 dark:text-gray-400 leading-relaxed">
Onboard each Mail Gateway with a limited API token so Pulse can read queue depth and quarantine metrics.
</p>
</header>
<div class="space-y-4"> <div class="space-y-4">
<Show when={!initialLoadComplete()}> <Show when={!initialLoadComplete()}>
<div class="flex items-center justify-center py-8"> <div class="flex items-center justify-center rounded-lg border border-dashed border-gray-300 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/40 py-12 text-sm text-gray-500 dark:text-gray-400">
<span class="text-gray-500">Loading configuration...</span> Loading configuration...
</div> </div>
</Show> </Show>
<Show when={initialLoadComplete()}> <Show when={initialLoadComplete()}>
<>
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-4 gap-3"> <div class="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-4 gap-3">
<SectionHeader title="Proxmox Mail Gateway nodes" size="md" class="flex-1" /> <SectionHeader title="Proxmox Mail Gateway nodes" size="md" class="flex-1" />
<div class="flex flex-wrap items-center justify-start gap-2 sm:justify-end"> <div class="flex flex-wrap items-center justify-start gap-2 sm:justify-end">
@ -3162,7 +2788,9 @@ const Settings: Component<SettingsProps> = (props) => {
e.currentTarget.checked = discoveryEnabled(); e.currentTarget.checked = discoveryEnabled();
} }
}} }}
disabled={envOverrides().discoveryEnabled || savingDiscoverySettings()} disabled={
envOverrides().discoveryEnabled || savingDiscoverySettings()
}
containerClass="gap-2" containerClass="gap-2"
label={ label={
<span class="text-xs font-medium text-gray-600 dark:text-gray-400"> <span class="text-xs font-medium text-gray-600 dark:text-gray-400">
@ -3186,14 +2814,7 @@ const Settings: Component<SettingsProps> = (props) => {
class="px-2 sm:px-4 py-1.5 sm:py-2 text-xs sm:text-sm bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors flex items-center gap-1" class="px-2 sm:px-4 py-1.5 sm:py-2 text-xs sm:text-sm bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors flex items-center gap-1"
title="Refresh discovered servers" title="Refresh discovered servers"
> >
<svg <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="23 4 23 10 17 10"></polyline> <polyline points="23 4 23 10 17 10"></polyline>
<polyline points="1 20 1 14 7 14"></polyline> <polyline points="1 20 1 14 7 14"></polyline>
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path> <path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path>
@ -3222,161 +2843,29 @@ const Settings: Component<SettingsProps> = (props) => {
</div> </div>
</div> </div>
<div class="grid gap-4"> <Show when={pmgNodes().length > 0}>
<For each={nodes().filter((n) => n.type === 'pmg')}> <PmgNodesTable
{(node) => { nodes={pmgNodes()}
const pmgNode = node as NodeConfigWithStatus & { statePmg={state.pmg ?? []}
monitorMailStats?: boolean; onTestConnection={testNodeConnection}
monitorQueues?: boolean; onEdit={(node) => {
monitorQuarantine?: boolean; setEditingNode(nodes().find((n) => n.id === node.id) ?? null);
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 (
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 border border-gray-200 dark:border-gray-600">
<div class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-2">
<div class="flex-1 min-w-0">
<div class="flex items-start gap-3">
<div class={`flex-shrink-0 w-3 h-3 mt-1.5 rounded-full ${statusClass}`}></div>
<div class="flex-1 min-w-0">
<h4 class="font-medium text-gray-900 dark:text-gray-100 truncate">
{pmgNode.name}
</h4>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1 break-all">
{pmgNode.host}
</p>
<div class="flex flex-wrap gap-1 sm:gap-2 mt-2">
<span class="text-xs px-2 py-1 bg-gray-200 dark:bg-gray-600 rounded">
{pmgNode.user ? `User: ${pmgNode.user}` : `Token: ${pmgNode.tokenName}`}
</span>
{pmgNode.monitorMailStats && (
<span class="text-xs px-2 py-1 bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded">
Mail stats
</span>
)}
{pmgNode.monitorQueues && (
<span class="text-xs px-2 py-1 bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded">
Queues
</span>
)}
{pmgNode.monitorQuarantine && (
<span class="text-xs px-2 py-1 bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded">
Quarantine
</span>
)}
{pmgNode.monitorDomainStats && (
<span class="text-xs px-2 py-1 bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded">
Domain stats
</span>
)}
</div>
</div>
</div>
</div>
<div class="flex items-center gap-1 sm:gap-2 flex-shrink-0">
<button
type="button"
onClick={() => testNodeConnection(pmgNode.id)}
class="p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100"
title="Test connection"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"></polyline>
</svg>
</button>
<button
type="button"
onClick={() => {
setEditingNode(nodes().find((n) => n.id === pmgNode.id) ?? null);
setCurrentNodeType('pmg'); setCurrentNodeType('pmg');
setModalResetKey((prev) => prev + 1); setModalResetKey((prev) => prev + 1);
setShowNodeModal(true); setShowNodeModal(true);
}} }}
class="p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100" onDelete={requestDeleteNode}
title="Edit node" />
> </Show>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"></path>
<path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"></path>
</svg>
</button>
<button
type="button"
onClick={() => requestDeleteNode(pmgNode)}
class="p-2 text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300"
title="Delete node"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"></path>
</svg>
</button>
</div>
</div>
</div>
);
}}
</For>
{nodes().filter((n) => n.type === 'pmg').length === 0 && ( <Show when={pmgNodes().length === 0}>
<div class="text-center py-8 text-gray-500 dark:text-gray-400"> <div class="text-center py-8 text-gray-500 dark:text-gray-400">
<p>No PMG nodes configured</p> <p>No PMG nodes configured</p>
<p class="text-sm mt-1">Add a node to start monitoring mail flow</p> <p class="text-sm mt-1">Add a node to start monitoring mail flow</p>
</div> </div>
)} </Show>
</>
</Show>
{/* Discovered PMG nodes - only show when discovery is enabled */} {/* Discovered PMG nodes - only show when discovery is enabled */}
<Show when={discoveryEnabled()}> <Show when={discoveryEnabled()}>
@ -3523,11 +3012,8 @@ const Settings: Component<SettingsProps> = (props) => {
</div> </div>
</Show> </Show>
</div> </div>
</section>
</Show> </Show>
</div>
</AgentStepSection>
</Show>
{/* Docker Tab */} {/* Docker Tab */}
<Show when={activeTab() === 'agent-hub' && selectedAgent() === 'docker'}> <Show when={activeTab() === 'agent-hub' && selectedAgent() === 'docker'}>
<AgentStepSection <AgentStepSection
@ -5657,13 +5143,13 @@ const Settings: Component<SettingsProps> = (props) => {
<div class="flex justify-between"> <div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">PVE Nodes:</span> <span class="text-gray-600 dark:text-gray-400">PVE Nodes:</span>
<span class="font-medium"> <span class="font-medium">
{nodes().filter((n) => n.type === 'pve').length} {pveNodes().length}
</span> </span>
</div> </div>
<div class="flex justify-between"> <div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">PBS Nodes:</span> <span class="text-gray-600 dark:text-gray-400">PBS Nodes:</span>
<span class="font-medium"> <span class="font-medium">
{nodes().filter((n) => n.type === 'pbs').length} {pbsNodes().length}
</span> </span>
</div> </div>
<div class="flex justify-between"> <div class="flex justify-between">
@ -6614,7 +6100,6 @@ const Settings: Component<SettingsProps> = (props) => {
</div> </div>
</div> </div>
</Show> </Show>
</div> </div>
</div> </div>
</Card> </Card>