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 {
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<typeof getGlobalWebSocketStore>;
@ -564,6 +577,14 @@ function App() {
// Pass through the store directly (only when initialized)
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
const RootLayout = (props: { children?: JSX.Element }) => {
return (
@ -618,10 +639,7 @@ function App() {
<Router root={RootLayout}>
<Route path="/" component={() => <Navigate href="/proxmox/overview" />} />
<Route path="/proxmox" component={() => <Navigate href="/proxmox/overview" />} />
<Route
path="/proxmox/overview"
component={() => <Dashboard vms={state().vms} containers={state().containers} nodes={state().nodes} />}
/>
<Route path="/proxmox/overview" component={DashboardView} />
<Route path="/proxmox/storage" component={StorageComponent} />
<Route path="/proxmox/replication" component={Replication} />
<Route path="/proxmox/mail" component={MailGateway} />
@ -631,11 +649,8 @@ function App() {
<Route path="/docker" component={DockerRoute} />
<Route path="/hosts" component={HostsRoute} />
<Route path="/servers" component={() => <Navigate href="/hosts" />} />
<Route path="/alerts/*" component={Alerts} />
<Route
path="/settings/*"
component={() => <Settings darkMode={darkMode} toggleDarkMode={toggleDarkMode} />}
/>
<Route path="/alerts/*" component={AlertsPage} />
<Route path="/settings/*" component={SettingsRoute} />
</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"
>
<div class="pulse-panel">
{props.children}
<Suspense fallback={<div class="p-6 text-sm text-gray-500 dark:text-gray-400">Loading view...</div>}>
{props.children}
</Suspense>
</div>
</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>
);
};

File diff suppressed because it is too large Load diff