738 lines
34 KiB
TypeScript
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>
|
|
);
|
|
};
|