Pulse/frontend-modern/src/components/shared/NodeSummaryTable.tsx
rcourtman b1831d7b3e Add guest URL support for PVE hosts
Related to discussion #615

Add optional GuestURL field to PVE instances and cluster endpoints,
allowing users to specify a separate guest-accessible URL for web UI
navigation that differs from the internal management URL.

Backend changes:
- Add GuestURL field to PVEInstance and ClusterEndpoint structs
- Add GuestURL field to Node model
- Update cluster auto-discovery to preserve existing GuestURL values
- Update node creation logic to populate GuestURL from config
- Update API handlers to accept and persist GuestURL field

Frontend changes:
- Add GuestURL input field to NodeModal for configuration
- Update NodeGroupHeader and NodeSummaryTable to use GuestURL for navigation
- Add GuestURL to Node and PVENodeConfig TypeScript interfaces

When GuestURL is configured, it will be used for navigation links
instead of the Host URL, allowing users to access PVE hosts through
a reverse proxy or different domain while maintaining internal API
connections.
2025-11-05 19:06:08 +00:00

707 lines
28 KiB
TypeScript

import { Component, For, Show, createMemo, createSignal } from 'solid-js';
import type { Node, VM, Container, Storage, PBSInstance } from '@/types/api';
import { formatBytes, formatPercent, formatUptime } from '@/utils/format';
import { MetricBar } from '@/components/Dashboard/MetricBar';
import { useWebSocket } from '@/App';
import { getAlertStyles } from '@/utils/alerts';
import { Card } from '@/components/shared/Card';
import { getNodeDisplayName, hasAlternateDisplayName } from '@/utils/nodes';
import { getCpuTemperature } from '@/utils/temperature';
import { useAlertsActivation } from '@/stores/alertsActivation';
interface NodeSummaryTableProps {
nodes: Node[];
pbsInstances?: PBSInstance[];
vms?: VM[];
containers?: Container[];
storage?: Storage[];
backupCounts?: Record<string, number>;
currentTab: 'dashboard' | 'storage' | 'backups';
selectedNode: string | null;
globalTemperatureMonitoringEnabled?: boolean;
onNodeClick: (nodeId: string, nodeType: 'pve' | 'pbs') => void;
}
export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
const { activeAlerts, state } = useWebSocket();
const alertsActivation = useAlertsActivation();
const alertsEnabled = createMemo(() => alertsActivation.activationState() === 'active');
const isTemperatureMonitoringEnabled = (node: Node): boolean => {
const globalEnabled = props.globalTemperatureMonitoringEnabled ?? true;
// Check per-node setting first, fall back to global
if (node.temperatureMonitoringEnabled !== undefined && node.temperatureMonitoringEnabled !== null) {
return node.temperatureMonitoringEnabled;
}
return globalEnabled;
};
type CountSortKey = 'vmCount' | 'containerCount' | 'storageCount' | 'diskCount' | 'backupCount';
type SortKey =
| 'default'
| 'name'
| 'uptime'
| 'cpu'
| 'memory'
| 'disk'
| 'temperature'
| CountSortKey;
interface SortableItem {
type: 'pve' | 'pbs';
data: Node | PBSInstance;
}
interface CountColumn {
header: string;
key: CountSortKey;
}
const [sortKey, setSortKey] = createSignal<SortKey>('default');
const [sortDirection, setSortDirection] = createSignal<'asc' | 'desc'>('asc');
const countColumns = createMemo<CountColumn[]>(() => {
switch (props.currentTab) {
case 'dashboard':
return [
{ header: 'VMs', key: 'vmCount' },
{ header: 'Containers', key: 'containerCount' },
];
case 'storage':
return [
{ header: 'Storage', key: 'storageCount' },
{ header: 'Disks', key: 'diskCount' },
];
case 'backups':
return [{ header: 'Backups', key: 'backupCount' }];
default:
return [];
}
});
const hasAnyTemperatureData = createMemo(() => {
// Show temperature column if ANY node has monitoring enabled OR has temperature data
return (
props.nodes?.some(
(node) => node.temperature?.available || isTemperatureMonitoringEnabled(node),
) || false
);
});
const nodeKey = (instance?: string, nodeName?: string) => `${instance ?? ''}::${nodeName ?? ''}`;
const vmCountsByNode = createMemo<Record<string, number>>(() => {
const counts: Record<string, number> = {};
(props.vms ?? []).forEach((vm) => {
const key = nodeKey(vm.instance, vm.node);
counts[key] = (counts[key] || 0) + 1;
});
return counts;
});
const containerCountsByNode = createMemo<Record<string, number>>(() => {
const counts: Record<string, number> = {};
(props.containers ?? []).forEach((ct) => {
const key = nodeKey(ct.instance, ct.node);
counts[key] = (counts[key] || 0) + 1;
});
return counts;
});
const storageCountsByNode = createMemo<Record<string, number>>(() => {
const counts: Record<string, number> = {};
(props.storage ?? []).forEach((storage) => {
const key = nodeKey(storage.instance, storage.node);
counts[key] = (counts[key] || 0) + 1;
});
return counts;
});
const diskCountsByNode = createMemo<Record<string, number>>(() => {
const counts: Record<string, number> = {};
(state.physicalDisks ?? []).forEach((disk) => {
const key = nodeKey(disk.instance, disk.node);
counts[key] = (counts[key] || 0) + 1;
});
return counts;
});
const getCpuTemperatureValue = (item: SortableItem) => {
if (item.type !== 'pve') return null;
const node = item.data as Node;
const value = getCpuTemperature(node.temperature);
return value !== null ? Math.round(value) : null;
};
const getDefaultSortDirection = (key: Exclude<SortKey, 'default'>) => {
switch (key) {
case 'name':
return 'asc';
default:
return 'desc';
}
};
const handleSort = (key: Exclude<SortKey, 'default'>) => {
if (sortKey() === key) {
if (sortDirection() === 'asc') {
setSortDirection('desc');
} else {
setSortKey('default');
setSortDirection('asc');
}
} else {
setSortKey(key);
setSortDirection(getDefaultSortDirection(key));
}
};
const isItemOnline = (item: SortableItem) => {
if (item.type === 'pve') {
const node = item.data as Node;
return node.status === 'online' && (node.uptime || 0) > 0;
}
const pbs = item.data as PBSInstance;
return pbs.status === 'healthy' || pbs.status === 'online';
};
const getPbsTotals = (pbs: PBSInstance) => {
return (pbs.datastores ?? []).reduce(
(acc, ds) => {
acc.used += ds.used || 0;
acc.total += ds.total || 0;
return acc;
},
{ used: 0, total: 0 },
);
};
const getCpuPercent = (item: SortableItem) => {
if (item.type === 'pve') {
const node = item.data as Node;
return Math.round((node.cpu || 0) * 100);
}
const pbs = item.data as PBSInstance;
return Math.round(pbs.cpu || 0);
};
const getMemoryPercent = (item: SortableItem) => {
if (item.type === 'pve') {
const node = item.data as Node;
return Math.round(node.memory?.usage || 0);
}
const pbs = item.data as PBSInstance;
if (!pbs.memoryTotal) return 0;
return Math.round((pbs.memoryUsed / pbs.memoryTotal) * 100);
};
const getDiskPercent = (item: SortableItem) => {
if (item.type === 'pve') {
const node = item.data as Node;
if (!node.disk || node.disk.total === 0) return 0;
return Math.round((node.disk.used / node.disk.total) * 100);
}
const pbs = item.data as PBSInstance;
const totals = getPbsTotals(pbs);
if (totals.total === 0) return 0;
return Math.round((totals.used / totals.total) * 100);
};
const getDiskSublabel = (item: SortableItem) => {
if (item.type === 'pve') {
const node = item.data as Node;
if (!node.disk) return undefined;
return `${formatBytes(node.disk.used, 0)}/${formatBytes(node.disk.total, 0)}`;
}
const pbs = item.data as PBSInstance;
if (!pbs.datastores || pbs.datastores.length === 0) return undefined;
const totals = getPbsTotals(pbs);
return `${formatBytes(totals.used, 0)}/${formatBytes(totals.total, 0)}`;
};
const getTemperatureValue = (item: SortableItem) => {
return getCpuTemperatureValue(item);
};
const getCountValue = (item: SortableItem, key: CountSortKey): number | null => {
if (item.type === 'pbs') {
const pbs = item.data as PBSInstance;
if (key === 'backupCount') {
return props.backupCounts?.[pbs.name] ?? 0;
}
return null;
}
const node = item.data as Node;
const keyId = nodeKey(node.instance, node.name);
switch (key) {
case 'vmCount':
return vmCountsByNode()[keyId] ?? 0;
case 'containerCount':
return containerCountsByNode()[keyId] ?? 0;
case 'storageCount':
return storageCountsByNode()[keyId] ?? 0;
case 'diskCount':
return diskCountsByNode()[keyId] ?? 0;
case 'backupCount':
return props.backupCounts?.[node.id] ?? 0;
default:
return null;
}
};
const getSortValue = (item: SortableItem, key: SortKey): number | string | null => {
switch (key) {
case 'name':
return item.type === 'pve'
? getNodeDisplayName(item.data as Node)
: (item.data as PBSInstance).name;
case 'uptime':
return item.type === 'pve'
? (item.data as Node).uptime ?? 0
: (item.data as PBSInstance).uptime ?? 0;
case 'cpu':
return getCpuPercent(item);
case 'memory':
return getMemoryPercent(item);
case 'disk':
return getDiskPercent(item);
case 'temperature':
return getTemperatureValue(item);
case 'vmCount':
case 'containerCount':
case 'storageCount':
case 'diskCount':
case 'backupCount':
return getCountValue(item, key);
default:
return null;
}
};
const defaultComparison = (a: SortableItem, b: SortableItem) => {
if (a.type !== b.type) return a.type === 'pve' ? -1 : 1;
const aOnline = isItemOnline(a);
const bOnline = isItemOnline(b);
if (aOnline !== bOnline) return aOnline ? -1 : 1;
const aName = a.type === 'pve'
? getNodeDisplayName(a.data as Node)
: (a.data as PBSInstance).name;
const bName = b.type === 'pve'
? getNodeDisplayName(b.data as Node)
: (b.data as PBSInstance).name;
return aName.localeCompare(bName);
};
const compareValues = (valueA: number | string | null, valueB: number | string | null) => {
const aEmpty =
valueA === null || valueA === undefined || (typeof valueA === 'number' && Number.isNaN(valueA));
const bEmpty =
valueB === null || valueB === undefined || (typeof valueB === 'number' && Number.isNaN(valueB));
if (aEmpty && bEmpty) return 0;
if (aEmpty) return 1;
if (bEmpty) return -1;
if (typeof valueA === 'number' && typeof valueB === 'number') {
if (valueA === valueB) return 0;
return valueA < valueB ? -1 : 1;
}
const aStr = String(valueA).toLowerCase();
const bStr = String(valueB).toLowerCase();
if (aStr === bStr) return 0;
return aStr < bStr ? -1 : 1;
};
// Combine and sort nodes based on current sort selection
const sortedItems = createMemo(() => {
const items: SortableItem[] = [];
props.nodes?.forEach((node) => items.push({ type: 'pve', data: node }));
props.pbsInstances?.forEach((pbs) => items.push({ type: 'pbs', data: pbs }));
const key = sortKey();
const direction = sortDirection();
return items.sort((a, b) => {
if (key === 'default') {
return defaultComparison(a, b);
}
const valueA = getSortValue(a, key);
const valueB = getSortValue(b, key);
const comparison = compareValues(valueA, valueB);
if (comparison !== 0) {
return direction === 'asc' ? comparison : -comparison;
}
return defaultComparison(a, b);
});
});
// Don't return null - let the table render even if empty
// This prevents the table from disappearing on refresh while data loads
return (
<>
<Card padding="none" class="mb-4 overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full min-w-[600px] border-collapse">
<thead>
<tr class="bg-gray-50 dark:bg-gray-700/50 text-gray-600 dark:text-gray-300 border-b border-gray-200 dark:border-gray-600">
<th
class="pl-3 pr-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-1/4 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500"
onClick={() => handleSort('name')}
onKeyDown={(e) => e.key === 'Enter' && handleSort('name')}
tabindex="0"
role="button"
aria-label={`Sort by name ${
sortKey() === 'name' ? (sortDirection() === 'asc' ? 'ascending' : 'descending') : ''
}`}
>
{props.currentTab === 'backups' ? 'Node / PBS' : 'Node'}{' '}
{sortKey() === 'name' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
<th
class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider min-w-24 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('uptime')}
>
Uptime {sortKey() === 'uptime' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
<th
class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider min-w-32 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('cpu')}
>
CPU {sortKey() === 'cpu' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
<th
class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider min-w-32 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('memory')}
>
Memory {sortKey() === 'memory' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
<th
class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider min-w-32 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('disk')}
>
{props.currentTab === 'backups' && props.pbsInstances ? 'Storage / Disk' : 'Disk'}{' '}
{sortKey() === 'disk' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
<Show when={hasAnyTemperatureData()}>
<th
class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider min-w-20 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('temperature')}
>
Temp{' '}
{sortKey() === 'temperature' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
</Show>
<For each={countColumns()}>
{(column) => (
<th
class="px-2 py-1.5 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider min-w-16 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort(column.key)}
>
{column.header}{' '}
{sortKey() === column.key && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
)}
</For>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<For each={sortedItems()}>
{(item) => {
const isPVE = item.type === 'pve';
const isPBS = item.type === 'pbs';
const node = isPVE ? (item.data as Node) : null;
const pbs = isPBS ? (item.data as PBSInstance) : null;
const online = isItemOnline(item);
const cpuPercentValue = getCpuPercent(item);
const memoryPercentValue = getMemoryPercent(item);
const diskPercentValue = getDiskPercent(item);
const diskSublabel = getDiskSublabel(item);
const cpuTemperatureValue = getCpuTemperatureValue(item);
const uptimeValue = isPVE ? node?.uptime ?? 0 : isPBS ? pbs?.uptime ?? 0 : 0;
const displayName = () => {
if (isPVE) return getNodeDisplayName(node as Node);
return (pbs as PBSInstance).name;
};
const showActualName = () => isPVE && hasAlternateDisplayName(node as Node);
// Use unique node ID (not hostname) to handle duplicate node names
const nodeId = isPVE ? node!.id : pbs!.name;
const isSelected = () => props.selectedNode === nodeId;
// Use the full resource ID for alert matching
const resourceId = isPVE ? node!.id || node!.name : pbs!.id || pbs!.name;
const alertStyles = createMemo(() =>
getAlertStyles(resourceId, activeAlerts, alertsEnabled()),
);
const showAlertHighlight = createMemo(
() => alertStyles().hasUnacknowledgedAlert && online,
);
const rowStyle = createMemo(() => {
const styles: Record<string, string> = {};
const shadows: string[] = [];
if (showAlertHighlight()) {
const color = alertStyles().severity === 'critical' ? '#ef4444' : '#eab308';
shadows.push(`inset 4px 0 0 0 ${color}`);
}
if (isSelected()) {
shadows.push('0 0 0 1px rgba(59, 130, 246, 0.5)');
shadows.push('0 2px 4px -1px rgba(0, 0, 0, 0.1)');
}
if (shadows.length > 0) {
styles['box-shadow'] = shadows.join(', ');
}
return styles;
});
const rowClass = createMemo(() => {
const baseHover = 'cursor-pointer transition-all duration-200 relative hover:bg-gray-50 dark:hover:bg-gray-700/50 hover:shadow-sm';
if (isSelected()) {
return `cursor-pointer transition-all duration-200 relative bg-blue-50 dark:bg-blue-900/20 hover:bg-blue-100 dark:hover:bg-blue-900/30 hover:shadow-sm z-10`;
}
if (showAlertHighlight()) {
return alertStyles().severity === 'critical'
? 'cursor-pointer transition-all duration-200 relative bg-red-50 dark:bg-red-950/30 hover:bg-red-100 dark:hover:bg-red-950/40 hover:shadow-sm'
: 'cursor-pointer transition-all duration-200 relative bg-yellow-50 dark:bg-yellow-950/20 hover:bg-yellow-100 dark:hover:bg-yellow-950/30 hover:shadow-sm';
}
let className = baseHover;
if (props.selectedNode && props.selectedNode !== nodeId) {
className += ' opacity-50 hover:opacity-80';
}
if (!online) {
className += ' opacity-60';
}
return className;
});
return (
<tr
class={rowClass()}
style={rowStyle()}
onClick={() => props.onNodeClick(nodeId, item.type)}
>
<td
class={`pr-2 py-0.5 whitespace-nowrap ${showAlertHighlight() ? 'pl-4' : 'pl-3'}`}
>
<div class="flex items-center gap-1">
<a
href={
isPVE
? node!.guestURL || node!.host || `https://${node!.name}:8006`
: pbs!.host || `https://${pbs!.name}:8007`
}
target="_blank"
onClick={(e) => e.stopPropagation()}
class="font-medium text-[11px] text-gray-900 dark:text-gray-100 hover:text-blue-600 dark:hover:text-blue-400"
>
{displayName()}
</a>
<Show when={showActualName()}>
<span class="text-[9px] text-gray-500 dark:text-gray-400">
({(node as Node).name})
</span>
</Show>
<Show when={isPVE}>
<span class="text-[9px] px-1 py-0 rounded text-[8px] font-medium bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400">
PVE
</span>
</Show>
<Show when={isPVE && node!.pveVersion}>
<span class="text-[9px] text-gray-500 dark:text-gray-400">
v{node!.pveVersion.split('/')[1] || node!.pveVersion}
</span>
</Show>
<Show when={isPVE && node!.isClusterMember !== undefined}>
<span
class={`text-[9px] px-1 py-0 rounded text-[8px] font-medium whitespace-nowrap ${
node!.isClusterMember
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
: 'bg-gray-100 text-gray-600 dark:bg-gray-700/50 dark:text-gray-400'
}`}
>
{node!.isClusterMember ? node!.clusterName : 'Standalone'}
</span>
</Show>
<Show when={isPBS}>
<span class="text-[9px] px-1 py-0 rounded text-[8px] font-medium bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400">
PBS
</span>
</Show>
<Show when={isPBS && pbs!.version}>
<span class="text-[9px] text-gray-500 dark:text-gray-400">
v{pbs!.version}
</span>
</Show>
</div>
</td>
<td class="px-2 py-0.5 whitespace-nowrap">
<span
class={`text-xs ${
isPVE && (node?.uptime ?? 0) < 3600
? 'text-orange-500'
: 'text-gray-600 dark:text-gray-400'
}`}
>
<Show
when={online && uptimeValue}
fallback="-"
>
{formatUptime(uptimeValue)}
</Show>
</span>
</td>
<td class="px-2 py-0.5">
<Show
when={online && cpuPercentValue !== null}
fallback={<span class="text-xs text-gray-500 dark:text-gray-400">-</span>}
>
<MetricBar
value={cpuPercentValue ?? 0}
label={formatPercent(cpuPercentValue ?? 0)}
sublabel={
isPVE && node!.cpuInfo?.cores
? `${node!.cpuInfo.cores} cores`
: undefined
}
type="cpu"
/>
</Show>
</td>
<td class="px-2 py-0.5">
<Show
when={online && memoryPercentValue !== null}
fallback={<span class="text-xs text-gray-500 dark:text-gray-400">-</span>}
>
<MetricBar
value={memoryPercentValue ?? 0}
label={formatPercent(memoryPercentValue ?? 0)}
sublabel={
isPVE && node!.memory
? `${formatBytes(node!.memory.used, 0)}/${formatBytes(node!.memory.total, 0)}`
: isPBS && pbs!.memoryTotal
? `${formatBytes(pbs!.memoryUsed, 0)}/${formatBytes(pbs!.memoryTotal, 0)}`
: undefined
}
type="memory"
/>
</Show>
</td>
<td class="px-2 py-0.5">
<Show
when={online && diskPercentValue !== null}
fallback={<span class="text-xs text-gray-500 dark:text-gray-400">-</span>}
>
<MetricBar
value={diskPercentValue ?? 0}
label={formatPercent(diskPercentValue ?? 0)}
sublabel={diskSublabel}
type="disk"
/>
</Show>
</td>
<Show when={hasAnyTemperatureData()}>
<td class="px-2 py-0.5 whitespace-nowrap text-center">
<Show
when={
online &&
isPVE &&
cpuTemperatureValue !== null &&
(node!.temperature?.hasCPU ?? node!.temperature?.available) &&
isTemperatureMonitoringEnabled(node!)
}
fallback={
<span class="text-xs text-gray-400 dark:text-gray-500">-</span>
}
>
{(() => {
const value = cpuTemperatureValue as number;
const severityClass =
value >= 80
? 'text-red-600 dark:text-red-400'
: value >= 70
? 'text-yellow-600 dark:text-yellow-400'
: 'text-green-600 dark:text-green-400';
const temp = node!.temperature;
const cpuMin = temp?.cpuMin;
const cpuMax = temp?.cpuMaxRecord;
const hasMinMax =
typeof cpuMin === 'number' &&
cpuMin > 0 &&
typeof cpuMax === 'number' &&
cpuMax > 0;
if (hasMinMax) {
const min = Math.round(cpuMin);
const max = Math.round(cpuMax);
const getTooltipColor = (temp: number) => {
if (temp >= 80) return 'text-red-400';
if (temp >= 70) return 'text-yellow-400';
return 'text-green-400';
};
return (
<span class="relative inline-block group">
<span class={`text-xs font-medium ${severityClass} cursor-help`}>
{value}°C
</span>
<span class="invisible group-hover:visible absolute bottom-full left-1/2 -translate-x-1/2 mb-1 px-2 py-1 text-xs whitespace-nowrap bg-gray-900 dark:bg-gray-700 text-white rounded shadow-lg z-50 pointer-events-none">
<span class="text-gray-300">Range:</span> <span class={getTooltipColor(min)}>{min}</span>-<span class={getTooltipColor(max)}>{max}</span>°C
</span>
</span>
);
}
return <span class={`text-xs font-medium ${severityClass}`}>{value}°C</span>;
})()}
</Show>
</td>
</Show>
<For each={countColumns()}>
{(column) => {
const value = getCountValue(item, column.key);
const display = online ? value ?? '-' : '-';
const textClass = online
? 'text-xs text-gray-700 dark:text-gray-300'
: 'text-xs text-gray-400 dark:text-gray-500';
return (
<td class="px-2 py-0.5 whitespace-nowrap text-center">
<span class={textClass}>{display}</span>
</td>
);
}}
</For>
</tr>
);
}}
</For>
</tbody>
</table>
</div>
</Card>
</>
);
};