Pulse/frontend-modern/src/components/Settings/ConfiguredNodeTables.tsx

738 lines
34 KiB
TypeScript

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';
};
export interface TemperatureTransportInfo {
httpMap: Record<string, { reachable: boolean; error?: string; url?: string }>;
socketStatus: 'healthy' | 'error' | 'missing';
}
interface PveNodesTableProps {
nodes: NodeConfigWithStatus[];
stateNodes: { instance: string; status?: string; connectionHealth?: string }[];
globalTemperatureMonitoringEnabled?: boolean;
temperatureTransports?: TemperatureTransportInfo | null;
onTestConnection: (nodeId: string) => void;
onEdit: (node: NodeConfigWithStatus) => void;
onDelete: (node: NodeConfigWithStatus) => void;
}
type StatusMeta = { dotClass: string; label: string; labelClass: string };
type TemperatureTransportBadge = {
label: string;
badgeClass: string;
description?: 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 resolveTemperatureTransport = (
node: NodeConfigWithStatus,
info: TemperatureTransportInfo | null | undefined,
globalEnabled: boolean,
): TemperatureTransportBadge => {
const monitoringEnabled = isTemperatureMonitoringEnabled(node, globalEnabled);
if (!monitoringEnabled) {
return {
label: 'Temp disabled',
badgeClass: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300',
};
}
const key = (node.name || '').toLowerCase();
const httpEntry = info?.httpMap?.[key];
if (httpEntry) {
if (httpEntry.reachable) {
return {
label: 'HTTPS proxy',
badgeClass: 'bg-emerald-100 dark:bg-emerald-900 text-emerald-700 dark:text-emerald-300',
description: httpEntry.url,
};
}
return {
label: 'HTTPS error',
badgeClass: 'bg-red-100 dark:bg-red-900 text-red-700 dark:text-red-300',
description: httpEntry.error || 'Proxy unreachable',
};
}
if (info) {
if (info.socketStatus === 'healthy') {
return {
label: 'Socket proxy',
badgeClass: 'bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300',
};
}
if (info.socketStatus === 'error') {
return {
label: 'Socket error',
badgeClass: 'bg-amber-100 dark:bg-amber-900 text-amber-700 dark:text-amber-300',
description: 'Proxy socket not responding',
};
}
}
return {
label: 'SSH fallback',
badgeClass: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300',
};
};
const isTemperatureMonitoringEnabled = (
node: NodeConfigWithStatus,
globalEnabled: boolean,
): boolean => {
// Check per-node setting first, fall back to global
if (node.temperatureMonitoringEnabled !== undefined && node.temperatureMonitoringEnabled !== null) {
return node.temperatureMonitoringEnabled;
}
return globalEnabled;
};
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',
);
const transportMeta = createMemo(() =>
resolveTemperatureTransport(
node,
props.temperatureTransports,
props.globalTemperatureMonitoringEnabled ?? true,
),
);
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 text-[0.65rem] font-normal text-gray-500 dark:text-gray-500">
{clusterEndpoints().length} nodes
</span>
</div>
<Show when={clusterEndpoints().length > 0}>
<div class="flex flex-col gap-2">
<For each={clusterEndpoints()}>
{(endpoint) => {
const pulseStatus = endpoint.PulseReachable === null || endpoint.PulseReachable === undefined
? 'unknown'
: endpoint.PulseReachable
? 'reachable'
: 'unreachable';
const statusColor = endpoint.Online && pulseStatus === 'reachable'
? 'border-green-200 bg-green-50 text-green-700 dark:border-green-700 dark:bg-green-900/20 dark:text-green-300'
: pulseStatus === 'unreachable'
? 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-700 dark:bg-amber-900/20 dark:text-amber-300'
: endpoint.Online
? 'border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-700 dark:bg-blue-900/20 dark:text-blue-300'
: 'border-gray-200 bg-gray-100 text-gray-600 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400';
return (
<div class={`rounded border px-3 py-2 text-[0.7rem] ${statusColor}`}>
<div class="flex items-center gap-2 mb-1">
<span class="font-semibold">{endpoint.NodeName}</span>
<span class="text-[0.65rem] opacity-75">{endpoint.IP}</span>
</div>
<div class="flex flex-col gap-0.5 text-[0.65rem] opacity-90">
<div class="flex items-center gap-1.5">
<span class="w-16 font-medium">Proxmox:</span>
<span>{endpoint.Online ? 'Online' : 'Offline'}</span>
</div>
<div class="flex items-center gap-1.5">
<span class="w-16 font-medium">Pulse:</span>
<span>
{pulseStatus === 'reachable' ? 'Reachable' : pulseStatus === 'unreachable' ? 'Unreachable' : 'Checking...'}
</span>
</div>
<Show when={pulseStatus === 'unreachable' && endpoint.PulseError}>
<div class="mt-1 pt-1 border-t border-current/20">
<span class="font-medium">Error: </span>
<span class="opacity-80">{endpoint.PulseError}</span>
</div>
</Show>
</div>
</div>
);
}}
</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="text-xs text-gray-600 dark:text-gray-400">
{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' &&
isTemperatureMonitoringEnabled(node, props.globalTemperatureMonitoringEnabled ?? true) && (
<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>
)}
<Show when={transportMeta()}>
<span
class={`text-xs px-2 py-1 rounded ${transportMeta().badgeClass}`}
>
{transportMeta().label}
</span>
</Show>
</div>
<Show when={transportMeta()?.description}>
<div class="mt-1 text-[0.65rem] text-gray-500 dark:text-gray-400">
{transportMeta()?.description}
</div>
</Show>
</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 }[];
globalTemperatureMonitoringEnabled?: boolean;
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="text-xs text-gray-600 dark:text-gray-400">
{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' &&
isTemperatureMonitoringEnabled(node, props.globalTemperatureMonitoringEnabled ?? true) && (
<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 }[];
globalTemperatureMonitoringEnabled?: boolean;
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="text-xs text-gray-600 dark:text-gray-400">
{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>
);
};