feat: add stacked disk bar to hosts overview and fix tooltip positioning
- Replace standard disk metric bar with StackedDiskBar in HostsOverview
- Show detailed per-disk usage breakdown on hover
- Use Portal for tooltips to ensure correct positioning and z-index
- Add class prop support to MetricBar
This commit is contained in:
parent
1d0c8b3ce1
commit
6852004ffb
3 changed files with 480 additions and 391 deletions
|
|
@ -9,6 +9,7 @@ interface MetricBarProps {
|
|||
sublabel?: string;
|
||||
type?: 'cpu' | 'memory' | 'disk' | 'generic';
|
||||
resourceId?: string; // Required for sparkline mode to fetch history
|
||||
class?: string;
|
||||
}
|
||||
|
||||
// Estimate text width based on character count (rough approximation for 10px font)
|
||||
|
|
@ -103,7 +104,7 @@ export function MetricBar(props: MetricBarProps) {
|
|||
fallback={
|
||||
// Original progress bar mode - capped width for better appearance on wide screens
|
||||
<div ref={containerRef} class="metric-text w-full h-4 flex items-center justify-center">
|
||||
<div class="relative w-full max-w-[140px] h-full overflow-hidden bg-gray-200 dark:bg-gray-600 rounded">
|
||||
<div class={`relative w-full max-w-[140px] h-full overflow-hidden bg-gray-200 dark:bg-gray-600 rounded ${props.class || ''}`}>
|
||||
<div class={`absolute top-0 left-0 h-full ${progressColorClass()}`} style={{ width: `${width()}%` }} />
|
||||
<span class="absolute inset-0 flex items-center justify-center text-[10px] font-semibold text-gray-700 dark:text-gray-100 leading-none">
|
||||
<span class="flex items-center gap-1 whitespace-nowrap px-0.5">
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { Show, For, createMemo, createSignal, onMount, onCleanup } from 'solid-js';
|
||||
import { Portal } from 'solid-js/web';
|
||||
import type { Disk } from '@/types/api';
|
||||
import { formatBytes, formatPercent } from '@/utils/format';
|
||||
|
||||
|
|
@ -109,8 +110,8 @@ export function StackedDiskBar(props: StackedDiskBarProps) {
|
|||
const diskPercent = disk.total > 0 ? (disk.used / disk.total) * 100 : 0;
|
||||
// Use warning/critical colors for high usage, otherwise use the color palette
|
||||
const color = diskPercent >= 90 ? getUsageColor(90) :
|
||||
diskPercent >= 80 ? getUsageColor(80) :
|
||||
SEGMENT_COLORS[idx % SEGMENT_COLORS.length];
|
||||
diskPercent >= 80 ? getUsageColor(80) :
|
||||
SEGMENT_COLORS[idx % SEGMENT_COLORS.length];
|
||||
return {
|
||||
disk,
|
||||
widthPercent: Math.min(usedPercent, 100),
|
||||
|
|
@ -133,8 +134,8 @@ export function StackedDiskBar(props: StackedDiskBarProps) {
|
|||
total: formatBytes(disk.total, 0),
|
||||
percent: formatPercent(percent),
|
||||
color: percent >= 90 ? getUsageColor(90) :
|
||||
percent >= 80 ? getUsageColor(80) :
|
||||
SEGMENT_COLORS[idx % SEGMENT_COLORS.length],
|
||||
percent >= 80 ? getUsageColor(80) :
|
||||
SEGMENT_COLORS[idx % SEGMENT_COLORS.length],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
@ -228,35 +229,37 @@ export function StackedDiskBar(props: StackedDiskBarProps) {
|
|||
|
||||
{/* Tooltip for multi-disk breakdown */}
|
||||
<Show when={showTooltip() && hasMultipleDisks()}>
|
||||
<div
|
||||
class="fixed z-50 pointer-events-none"
|
||||
style={{
|
||||
left: `${tooltipPos().x}px`,
|
||||
top: `${tooltipPos().y - 8}px`,
|
||||
transform: 'translate(-50%, -100%)',
|
||||
}}
|
||||
>
|
||||
<div class="bg-gray-900 dark:bg-gray-800 text-white text-[10px] rounded-md shadow-lg px-2 py-1.5 min-w-[140px]">
|
||||
<div class="font-medium mb-1 text-gray-300 border-b border-gray-700 pb-1">
|
||||
Disk Breakdown
|
||||
<Portal mount={document.body}>
|
||||
<div
|
||||
class="fixed z-[9999] pointer-events-none"
|
||||
style={{
|
||||
left: `${tooltipPos().x}px`,
|
||||
top: `${tooltipPos().y - 8}px`,
|
||||
transform: 'translate(-50%, -100%)',
|
||||
}}
|
||||
>
|
||||
<div class="bg-gray-900 dark:bg-gray-800 text-white text-[10px] rounded-md shadow-lg px-2 py-1.5 min-w-[140px] border border-gray-700">
|
||||
<div class="font-medium mb-1 text-gray-300 border-b border-gray-700 pb-1">
|
||||
Disk Breakdown
|
||||
</div>
|
||||
<For each={tooltipContent()}>
|
||||
{(item, idx) => (
|
||||
<div class="flex justify-between gap-3 py-0.5" classList={{ 'border-t border-gray-700/50': idx() > 0 }}>
|
||||
<span
|
||||
class="truncate max-w-[80px]"
|
||||
style={{ color: item.color }}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
<span class="whitespace-nowrap text-gray-300">
|
||||
{item.percent} ({item.used}/{item.total})
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<For each={tooltipContent()}>
|
||||
{(item, idx) => (
|
||||
<div class="flex justify-between gap-3 py-0.5" classList={{ 'border-t border-gray-700/50': idx() > 0 }}>
|
||||
<span
|
||||
class="truncate max-w-[80px]"
|
||||
style={{ color: item.color }}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
<span class="whitespace-nowrap text-gray-300">
|
||||
{item.percent} ({item.used}/{item.total})
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -4,17 +4,31 @@ import { useNavigate } from '@solidjs/router';
|
|||
import type { Host } from '@/types/api';
|
||||
import { formatBytes, formatPercent, formatRelativeTime, formatUptime } from '@/utils/format';
|
||||
import { Card } from '@/components/shared/Card';
|
||||
import { ScrollableTable } from '@/components/shared/ScrollableTable';
|
||||
import { EmptyState } from '@/components/shared/EmptyState';
|
||||
import { MetricBar } from '@/components/Dashboard/MetricBar';
|
||||
import { StackedDiskBar } from '@/components/Dashboard/StackedDiskBar';
|
||||
import { HostsFilter } from './HostsFilter';
|
||||
import { useWebSocket } from '@/App';
|
||||
import { StatusDot } from '@/components/shared/StatusDot';
|
||||
import { getHostStatusIndicator } from '@/utils/status';
|
||||
import { ResponsiveMetricCell, MetricText, useGridTemplate } from '@/components/shared/responsive';
|
||||
import type { ColumnConfig } from '@/types/responsive';
|
||||
import { STANDARD_COLUMNS } from '@/types/responsive';
|
||||
|
||||
// Global drawer state to persist across re-renders
|
||||
const drawerState = new Map<string, boolean>();
|
||||
|
||||
type SortKey = 'name' | 'platform' | 'cpu' | 'memory' | 'uptime';
|
||||
|
||||
const HOST_COLUMNS: ColumnConfig[] = [
|
||||
{ ...STANDARD_COLUMNS.name, label: 'Host', minWidth: '200px', sortKey: 'name' },
|
||||
{ id: 'platform', label: 'Platform', priority: 'primary', minWidth: '120px', flex: 1, sortable: true, sortKey: 'platform' },
|
||||
{ ...STANDARD_COLUMNS.cpu, maxWidth: '156px', sortKey: 'cpu' },
|
||||
{ ...STANDARD_COLUMNS.memory, maxWidth: '156px', sortKey: 'memory' },
|
||||
{ id: 'disk', label: 'Disk', minWidth: '140px', maxWidth: '156px', priority: 'secondary' },
|
||||
{ ...STANDARD_COLUMNS.uptime, maxWidth: '100px', align: 'right', sortKey: 'uptime' },
|
||||
];
|
||||
|
||||
interface HostsOverviewProps {
|
||||
hosts: Host[];
|
||||
connectionHealth: Record<string, boolean>;
|
||||
|
|
@ -25,6 +39,20 @@ export const HostsOverview: Component<HostsOverviewProps> = (props) => {
|
|||
const wsContext = useWebSocket();
|
||||
const [search, setSearch] = createSignal('');
|
||||
const [statusFilter, setStatusFilter] = createSignal<'all' | 'online' | 'degraded' | 'offline'>('all');
|
||||
const [sortKey, setSortKey] = createSignal<SortKey>('name');
|
||||
const [sortDirection, setSortDirection] = createSignal<'asc' | 'desc'>('asc');
|
||||
|
||||
const handleSort = (key: string) => {
|
||||
if (sortKey() === key) {
|
||||
setSortDirection(sortDirection() === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
setSortKey(key as SortKey);
|
||||
setSortDirection('asc');
|
||||
}
|
||||
};
|
||||
|
||||
// Use responsive grid template
|
||||
const { gridTemplate, visibleColumns, isMobile } = useGridTemplate({ columns: HOST_COLUMNS });
|
||||
|
||||
// Keyboard listener to auto-focus search
|
||||
let searchInputRef: HTMLInputElement | undefined;
|
||||
|
|
@ -64,13 +92,35 @@ export const HostsOverview: Component<HostsOverviewProps> = (props) => {
|
|||
return !connected() && !reconnecting();
|
||||
});
|
||||
|
||||
const sortedHosts = createMemo(() =>
|
||||
[...props.hosts].sort((a, b) => {
|
||||
const aName = a.displayName || a.hostname || a.id;
|
||||
const bName = b.displayName || b.hostname || b.id;
|
||||
return aName.localeCompare(bName);
|
||||
}),
|
||||
);
|
||||
const sortedHosts = createMemo(() => {
|
||||
const hosts = [...props.hosts];
|
||||
const key = sortKey();
|
||||
const direction = sortDirection();
|
||||
|
||||
return hosts.sort((a, b) => {
|
||||
let comparison = 0;
|
||||
switch (key) {
|
||||
case 'name':
|
||||
comparison = (a.displayName || a.hostname || a.id).localeCompare(b.displayName || b.hostname || b.id);
|
||||
break;
|
||||
case 'platform':
|
||||
comparison = (a.platform || '').localeCompare(b.platform || '');
|
||||
break;
|
||||
case 'cpu':
|
||||
comparison = (a.cpuUsage ?? 0) - (b.cpuUsage ?? 0);
|
||||
break;
|
||||
case 'memory':
|
||||
comparison = (a.memory?.usage ?? 0) - (b.memory?.usage ?? 0);
|
||||
break;
|
||||
case 'uptime':
|
||||
comparison = (a.uptimeSeconds ?? 0) - (b.uptimeSeconds ?? 0);
|
||||
break;
|
||||
default:
|
||||
comparison = 0;
|
||||
}
|
||||
return direction === 'asc' ? comparison : -comparison;
|
||||
});
|
||||
});
|
||||
|
||||
const matchesSearch = (host: Host) => {
|
||||
const term = search().toLowerCase();
|
||||
|
|
@ -100,8 +150,136 @@ export const HostsOverview: Component<HostsOverviewProps> = (props) => {
|
|||
return sortedHosts().filter((host) => matchesSearch(host) && matchesStatus(host));
|
||||
});
|
||||
|
||||
const renderCell = (column: ColumnConfig, host: Host) => {
|
||||
const cpuPercent = () => host.cpuUsage ?? 0;
|
||||
const memPercent = () => host.memory?.usage ?? 0;
|
||||
const memUsed = () => formatBytes(host.memory?.used ?? 0, 0);
|
||||
const memTotal = () => formatBytes(host.memory?.total ?? 0, 0);
|
||||
const hostStatus = createMemo(() => getHostStatusIndicator(host));
|
||||
|
||||
switch (column.id) {
|
||||
case 'name':
|
||||
return (
|
||||
<div class="pl-4 pr-2 py-1 overflow-hidden">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<StatusDot
|
||||
variant={hostStatus().variant}
|
||||
title={hostStatus().label}
|
||||
ariaLabel={hostStatus().label}
|
||||
size="xs"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate">
|
||||
{host.displayName || host.hostname || host.id}
|
||||
</p>
|
||||
<Show when={host.displayName && host.displayName !== host.hostname}>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5 truncate">
|
||||
{host.hostname}
|
||||
</p>
|
||||
</Show>
|
||||
<Show when={host.lastSeen}>
|
||||
<p class="text-[10px] text-gray-500 dark:text-gray-400 mt-0.5 truncate">
|
||||
Updated {formatRelativeTime(host.lastSeen!)}
|
||||
</p>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case 'platform':
|
||||
return (
|
||||
<div class="px-2 py-1 overflow-hidden">
|
||||
<div class="text-xs text-gray-700 dark:text-gray-300">
|
||||
<p class="font-medium capitalize">{host.platform || '—'}</p>
|
||||
<Show when={host.osName}>
|
||||
<p class="text-[10px] text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{host.osName} {host.osVersion}
|
||||
</p>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case 'cpu':
|
||||
return (
|
||||
<div class="px-2 py-1 overflow-hidden">
|
||||
<ResponsiveMetricCell
|
||||
value={cpuPercent()}
|
||||
type="cpu"
|
||||
label={formatPercent(cpuPercent())}
|
||||
isRunning={true}
|
||||
showMobile={isMobile()}
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
case 'memory':
|
||||
return (
|
||||
<div class="px-2 py-1 overflow-hidden">
|
||||
<ResponsiveMetricCell
|
||||
value={memPercent()}
|
||||
type="memory"
|
||||
label={`${memUsed()} / ${memTotal()}`}
|
||||
sublabel={formatPercent(memPercent())}
|
||||
isRunning={true}
|
||||
showMobile={isMobile()}
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
case 'disk':
|
||||
const diskStats = (() => {
|
||||
if (!host.disks || host.disks.length === 0) return { percent: 0, used: 0, total: 0 };
|
||||
const totalUsed = host.disks.reduce((sum, d) => sum + (d.used ?? 0), 0);
|
||||
const totalSize = host.disks.reduce((sum, d) => sum + (d.total ?? 0), 0);
|
||||
return {
|
||||
percent: totalSize > 0 ? (totalUsed / totalSize) * 100 : 0,
|
||||
used: totalUsed,
|
||||
total: totalSize
|
||||
};
|
||||
})();
|
||||
const diskPercent = diskStats.percent;
|
||||
|
||||
|
||||
return (
|
||||
<div class="px-2 py-1 overflow-hidden">
|
||||
<Show when={isMobile()}>
|
||||
<div class="md:hidden">
|
||||
<MetricText value={diskPercent} type="disk" />
|
||||
</div>
|
||||
</Show>
|
||||
<div class="hidden md:block w-full">
|
||||
<StackedDiskBar
|
||||
disks={host.disks}
|
||||
aggregateDisk={{
|
||||
total: diskStats.total,
|
||||
used: diskStats.used,
|
||||
free: diskStats.total - diskStats.used,
|
||||
usage: diskStats.percent / 100
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case 'uptime':
|
||||
return (
|
||||
<div class="px-2 py-1 text-right overflow-hidden">
|
||||
<Show
|
||||
when={host.uptimeSeconds}
|
||||
fallback={<span class="text-xs text-gray-500 dark:text-gray-400">—</span>}
|
||||
>
|
||||
<span class="text-xs text-gray-700 dark:text-gray-300">
|
||||
{formatUptime(host.uptimeSeconds!)}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="space-y-0">
|
||||
<div class="space-y-4">
|
||||
<Show when={isLoading()}>
|
||||
<Card padding="lg">
|
||||
<EmptyState
|
||||
|
|
@ -171,372 +349,279 @@ export const HostsOverview: Component<HostsOverviewProps> = (props) => {
|
|||
}
|
||||
>
|
||||
<Card padding="none" tone="glass" class="overflow-hidden">
|
||||
<ScrollableTable>
|
||||
<table class="w-full 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-4 pr-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[22%]">
|
||||
Host
|
||||
</th>
|
||||
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[14%]">
|
||||
Platform
|
||||
</th>
|
||||
<th class="px-2 py-1.5 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[18%]">
|
||||
CPU
|
||||
</th>
|
||||
<th class="px-2 py-1.5 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[18%]">
|
||||
Memory
|
||||
</th>
|
||||
<th class="px-2 py-1.5 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[18%]">
|
||||
Disk
|
||||
</th>
|
||||
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[10%]">
|
||||
Uptime
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={filteredHosts()}>
|
||||
{(host) => {
|
||||
const cpuPercent = () => host.cpuUsage ?? 0;
|
||||
const memPercent = () => host.memory?.usage ?? 0;
|
||||
const memUsed = () => formatBytes(host.memory?.used ?? 0, 0);
|
||||
const memTotal = () => formatBytes(host.memory?.total ?? 0, 0);
|
||||
// Calculate aggregate disk usage from all disks
|
||||
const diskStats = createMemo(() => {
|
||||
if (!host.disks || host.disks.length === 0) return { percent: 0, used: 0, total: 0 };
|
||||
const totalUsed = host.disks.reduce((sum, d) => sum + (d.used ?? 0), 0);
|
||||
const totalSize = host.disks.reduce((sum, d) => sum + (d.total ?? 0), 0);
|
||||
return {
|
||||
percent: totalSize > 0 ? (totalUsed / totalSize) * 100 : 0,
|
||||
used: totalUsed,
|
||||
total: totalSize
|
||||
};
|
||||
});
|
||||
const diskPercent = () => diskStats().percent;
|
||||
const diskUsed = () => formatBytes(diskStats().used, 0);
|
||||
const diskTotal = () => formatBytes(diskStats().total, 0);
|
||||
const hostStatus = createMemo(() => getHostStatusIndicator(host));
|
||||
<div class="overflow-x-auto">
|
||||
{/* Header */}
|
||||
<div
|
||||
class="grid border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/50 text-gray-600 dark:text-gray-300 text-[11px] sm:text-xs font-medium uppercase tracking-wider sticky top-0 z-20 min-w-[520px] md:min-w-0"
|
||||
style={{ 'grid-template-columns': gridTemplate() }}
|
||||
>
|
||||
<For each={visibleColumns()}>
|
||||
{(column) => (
|
||||
<div
|
||||
class={`${column.id === 'name' ? 'pl-4 pr-2' : 'px-2'} py-1 flex items-center ${column.align === 'right' ? 'justify-end' : 'justify-start'} ${column.sortable ? 'cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600' : ''}`}
|
||||
onClick={() => column.sortable && column.sortKey && handleSort(column.sortKey)}
|
||||
>
|
||||
<div class="flex items-center gap-1">
|
||||
{column.label}
|
||||
{column.sortKey === sortKey() && (
|
||||
<span class="text-gray-400">
|
||||
{sortDirection() === 'asc' ? '▲' : '▼'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
// Drawer state
|
||||
const [drawerOpen, setDrawerOpen] = createSignal(drawerState.get(host.id) ?? false);
|
||||
|
||||
// Check if we have additional info to show in drawer
|
||||
const hasDrawerContent = createMemo(() => {
|
||||
return (
|
||||
(host.disks && host.disks.length > 0) ||
|
||||
(host.networkInterfaces && host.networkInterfaces.length > 0) ||
|
||||
(host.raid && host.raid.length > 0) ||
|
||||
host.loadAverage ||
|
||||
host.cpuCount ||
|
||||
host.kernelVersion ||
|
||||
host.architecture ||
|
||||
host.agentVersion ||
|
||||
(host.sensors?.temperatureCelsius && Object.keys(host.sensors.temperatureCelsius).length > 0)
|
||||
);
|
||||
});
|
||||
|
||||
const toggleDrawer = (event: MouseEvent) => {
|
||||
if (!hasDrawerContent()) return;
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.closest('a, button, [data-prevent-toggle]')) {
|
||||
return;
|
||||
}
|
||||
setDrawerOpen((prev) => !prev);
|
||||
};
|
||||
|
||||
// Sync drawer state
|
||||
createEffect(on(() => host.id, (id) => {
|
||||
const stored = drawerState.get(id);
|
||||
if (stored !== undefined) {
|
||||
setDrawerOpen(stored);
|
||||
} else {
|
||||
setDrawerOpen(false);
|
||||
}
|
||||
}));
|
||||
|
||||
createEffect(() => {
|
||||
drawerState.set(host.id, drawerOpen());
|
||||
});
|
||||
|
||||
const rowClass = () => {
|
||||
const base = 'border-b border-gray-200 dark:border-gray-700 transition-all duration-200';
|
||||
const hover = 'hover:bg-gray-50 dark:hover:bg-gray-800/50';
|
||||
const clickable = hasDrawerContent() ? 'cursor-pointer' : '';
|
||||
const expanded = drawerOpen() ? 'bg-gray-50 dark:bg-gray-800/40' : '';
|
||||
return `${base} ${hover} ${clickable} ${expanded}`;
|
||||
};
|
||||
{/* Rows */}
|
||||
<div class="divide-y divide-gray-200 dark:divide-gray-700 min-w-[520px] md:min-w-0">
|
||||
<For each={filteredHosts()}>
|
||||
{(host) => {
|
||||
// Drawer state
|
||||
const [drawerOpen, setDrawerOpen] = createSignal(drawerState.get(host.id) ?? false);
|
||||
|
||||
// Check if we have additional info to show in drawer
|
||||
const hasDrawerContent = createMemo(() => {
|
||||
return (
|
||||
<>
|
||||
<tr class={rowClass()} onClick={toggleDrawer} aria-expanded={drawerOpen()}>
|
||||
<td class="pl-4 pr-2 py-2">
|
||||
<div>
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<StatusDot
|
||||
variant={hostStatus().variant}
|
||||
title={hostStatus().label}
|
||||
ariaLabel={hostStatus().label}
|
||||
size="xs"
|
||||
/>
|
||||
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate">
|
||||
{host.displayName || host.hostname || host.id}
|
||||
</p>
|
||||
(host.disks && host.disks.length > 0) ||
|
||||
(host.networkInterfaces && host.networkInterfaces.length > 0) ||
|
||||
(host.raid && host.raid.length > 0) ||
|
||||
host.loadAverage ||
|
||||
host.cpuCount ||
|
||||
host.kernelVersion ||
|
||||
host.architecture ||
|
||||
host.agentVersion ||
|
||||
(host.sensors?.temperatureCelsius && Object.keys(host.sensors.temperatureCelsius).length > 0)
|
||||
);
|
||||
});
|
||||
|
||||
const toggleDrawer = (event: MouseEvent) => {
|
||||
if (!hasDrawerContent()) return;
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.closest('a, button, [data-prevent-toggle]')) {
|
||||
return;
|
||||
}
|
||||
setDrawerOpen((prev) => !prev);
|
||||
};
|
||||
|
||||
// Sync drawer state
|
||||
createEffect(on(() => host.id, (id) => {
|
||||
const stored = drawerState.get(id);
|
||||
if (stored !== undefined) {
|
||||
setDrawerOpen(stored);
|
||||
} else {
|
||||
setDrawerOpen(false);
|
||||
}
|
||||
}));
|
||||
|
||||
createEffect(() => {
|
||||
drawerState.set(host.id, drawerOpen());
|
||||
});
|
||||
|
||||
const rowClass = () => {
|
||||
const base = 'grid items-center transition-all duration-200';
|
||||
const hover = 'hover:bg-gray-50 dark:hover:bg-gray-800/50';
|
||||
const clickable = hasDrawerContent() ? 'cursor-pointer' : '';
|
||||
const expanded = drawerOpen() ? 'bg-gray-50 dark:bg-gray-800/40' : '';
|
||||
return `${base} ${hover} ${clickable} ${expanded}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
class={rowClass()}
|
||||
style={{ 'grid-template-columns': gridTemplate() }}
|
||||
onClick={toggleDrawer}
|
||||
aria-expanded={drawerOpen()}
|
||||
>
|
||||
<For each={visibleColumns()}>
|
||||
{(column) => renderCell(column, host)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
{/* Drawer - Additional Info */}
|
||||
<Show when={drawerOpen() && hasDrawerContent()}>
|
||||
<div class="bg-gray-50 dark:bg-gray-900/50 px-4 py-3 border-t border-gray-100 dark:border-gray-800/50">
|
||||
<div class="flex flex-wrap justify-start gap-3">
|
||||
{/* System Info */}
|
||||
<div class="min-w-[220px] flex-1 rounded border border-gray-200 bg-white/70 p-2 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
|
||||
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200">System</div>
|
||||
<div class="mt-2 space-y-1 text-[11px] text-gray-600 dark:text-gray-300">
|
||||
<Show when={host.cpuCount}>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-200">CPUs</span>
|
||||
<span class="text-right text-gray-600 dark:text-gray-300">{host.cpuCount}</span>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={host.loadAverage && host.loadAverage.length > 0}>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-200">Load Avg</span>
|
||||
<span class="text-right text-gray-600 dark:text-gray-300">{host.loadAverage!.map(l => l.toFixed(2)).join(', ')}</span>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={host.architecture}>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-200">Arch</span>
|
||||
<span class="text-right text-gray-600 dark:text-gray-300">{host.architecture}</span>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={host.kernelVersion}>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-200">Kernel</span>
|
||||
<span class="text-right text-gray-600 dark:text-gray-300 truncate">{host.kernelVersion}</span>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={host.agentVersion}>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-200">Agent</span>
|
||||
<span class="text-right text-gray-600 dark:text-gray-300">{host.agentVersion}</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={host.displayName && host.displayName !== host.hostname}>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5 truncate">
|
||||
{host.hostname}
|
||||
</p>
|
||||
</Show>
|
||||
<Show when={host.lastSeen}>
|
||||
<p class="text-[10px] text-gray-500 dark:text-gray-400 mt-0.5 truncate">
|
||||
Updated {formatRelativeTime(host.lastSeen!)}
|
||||
</p>
|
||||
</Show>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-2 py-2">
|
||||
<div class="text-xs text-gray-700 dark:text-gray-300">
|
||||
<p class="font-medium capitalize">{host.platform || '—'}</p>
|
||||
<Show when={host.osName}>
|
||||
<p class="text-[10px] text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{host.osName} {host.osVersion}
|
||||
</p>
|
||||
</Show>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-2 py-2">
|
||||
<Show
|
||||
when={cpuPercent() > 0}
|
||||
fallback={<span class="text-xs text-gray-500 dark:text-gray-400">—</span>}
|
||||
>
|
||||
<MetricBar
|
||||
label={formatPercent(cpuPercent())}
|
||||
value={cpuPercent()}
|
||||
type="cpu"
|
||||
/>
|
||||
</Show>
|
||||
</td>
|
||||
<td class="px-2 py-2">
|
||||
<Show
|
||||
when={memPercent() > 0}
|
||||
fallback={<span class="text-xs text-gray-500 dark:text-gray-400">—</span>}
|
||||
>
|
||||
<MetricBar
|
||||
label={`${memUsed()} / ${memTotal()}`}
|
||||
value={memPercent()}
|
||||
type="memory"
|
||||
/>
|
||||
</Show>
|
||||
</td>
|
||||
<td class="px-2 py-2">
|
||||
<Show
|
||||
when={diskPercent() > 0}
|
||||
fallback={<span class="text-xs text-gray-500 dark:text-gray-400">—</span>}
|
||||
>
|
||||
<MetricBar
|
||||
label={`${diskUsed()} / ${diskTotal()}`}
|
||||
value={diskPercent()}
|
||||
type="disk"
|
||||
/>
|
||||
</Show>
|
||||
</td>
|
||||
<td class="px-2 py-2">
|
||||
<Show
|
||||
when={host.uptimeSeconds}
|
||||
fallback={<span class="text-xs text-gray-500 dark:text-gray-400">—</span>}
|
||||
>
|
||||
<span class="text-xs text-gray-700 dark:text-gray-300">
|
||||
{formatUptime(host.uptimeSeconds!)}
|
||||
</span>
|
||||
</Show>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* Drawer - Additional Info */}
|
||||
<Show when={drawerOpen() && hasDrawerContent()}>
|
||||
<tr class="bg-gray-50 dark:bg-gray-900/50">
|
||||
<td class="px-4 py-3" colSpan={6}>
|
||||
<div class="flex flex-wrap justify-start gap-3">
|
||||
{/* System Info */}
|
||||
<div class="min-w-[220px] flex-1 rounded border border-gray-200 bg-white/70 p-2 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
|
||||
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200">System</div>
|
||||
<div class="mt-2 space-y-1 text-[11px] text-gray-600 dark:text-gray-300">
|
||||
<Show when={host.cpuCount}>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-200">CPUs</span>
|
||||
<span class="text-right text-gray-600 dark:text-gray-300">{host.cpuCount}</span>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={host.loadAverage && host.loadAverage.length > 0}>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-200">Load Avg</span>
|
||||
<span class="text-right text-gray-600 dark:text-gray-300">{host.loadAverage!.map(l => l.toFixed(2)).join(', ')}</span>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={host.architecture}>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-200">Arch</span>
|
||||
<span class="text-right text-gray-600 dark:text-gray-300">{host.architecture}</span>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={host.kernelVersion}>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-200">Kernel</span>
|
||||
<span class="text-right text-gray-600 dark:text-gray-300 truncate">{host.kernelVersion}</span>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={host.agentVersion}>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-200">Agent</span>
|
||||
<span class="text-right text-gray-600 dark:text-gray-300">{host.agentVersion}</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Network Interfaces */}
|
||||
<Show when={host.networkInterfaces && host.networkInterfaces.length > 0}>
|
||||
<div class="min-w-[220px] flex-1 rounded border border-gray-200 bg-white/70 p-2 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
|
||||
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200">Network</div>
|
||||
<div class="mt-2 space-y-2 text-[11px]">
|
||||
<For each={host.networkInterfaces?.slice(0, 4)}>
|
||||
{(iface) => (
|
||||
<div class="rounded border border-dashed border-gray-200 p-2 dark:border-gray-700/70">
|
||||
<div class="font-medium text-gray-700 dark:text-gray-200">{iface.name}</div>
|
||||
<Show when={iface.addresses && iface.addresses.length > 0}>
|
||||
<div class="flex flex-wrap gap-1 mt-1 text-[10px]">
|
||||
<For each={iface.addresses}>
|
||||
{(addr) => (
|
||||
<span class="rounded bg-blue-100 px-1.5 py-0.5 text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
|
||||
{addr}
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Disk Info */}
|
||||
<Show when={host.disks && host.disks.length > 0}>
|
||||
<div class="min-w-[220px] flex-1 rounded border border-gray-200 bg-white/70 p-2 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
|
||||
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200">Disks</div>
|
||||
<div class="mt-2 space-y-2 text-[11px]">
|
||||
<For each={host.disks?.slice(0, 3)}>
|
||||
{(disk) => {
|
||||
const diskPercent = () => disk.usage ?? 0;
|
||||
return (
|
||||
<div class="rounded border border-dashed border-gray-200 p-2 dark:border-gray-700/70">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-200 truncate">{disk.mountpoint || disk.device}</span>
|
||||
<span class="text-[10px] text-gray-500 dark:text-gray-400">
|
||||
{formatBytes(disk.used ?? 0, 0)} / {formatBytes(disk.total ?? 0, 0)}
|
||||
{/* Network Interfaces */}
|
||||
<Show when={host.networkInterfaces && host.networkInterfaces.length > 0}>
|
||||
<div class="min-w-[220px] flex-1 rounded border border-gray-200 bg-white/70 p-2 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
|
||||
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200">Network</div>
|
||||
<div class="mt-2 space-y-2 text-[11px]">
|
||||
<For each={host.networkInterfaces?.slice(0, 4)}>
|
||||
{(iface) => (
|
||||
<div class="rounded border border-dashed border-gray-200 p-2 dark:border-gray-700/70">
|
||||
<div class="font-medium text-gray-700 dark:text-gray-200">{iface.name}</div>
|
||||
<Show when={iface.addresses && iface.addresses.length > 0}>
|
||||
<div class="flex flex-wrap gap-1 mt-1 text-[10px]">
|
||||
<For each={iface.addresses}>
|
||||
{(addr) => (
|
||||
<span class="rounded bg-blue-100 px-1.5 py-0.5 text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
|
||||
{addr}
|
||||
</span>
|
||||
</div>
|
||||
<Show when={diskPercent() > 0}>
|
||||
<div class="mt-1">
|
||||
<MetricBar
|
||||
value={diskPercent()}
|
||||
label={formatPercent(diskPercent())}
|
||||
type="disk"
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Temperature Sensors */}
|
||||
<Show when={host.sensors?.temperatureCelsius && Object.keys(host.sensors.temperatureCelsius).length > 0}>
|
||||
<div class="min-w-[220px] flex-1 rounded border border-gray-200 bg-white/70 p-2 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
|
||||
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200">Temperatures</div>
|
||||
<div class="mt-2 space-y-1 text-[11px] text-gray-600 dark:text-gray-300">
|
||||
<For each={Object.entries(host.sensors!.temperatureCelsius!).slice(0, 5)}>
|
||||
{([name, temp]) => (
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-200 truncate">{name}</span>
|
||||
<span class={`text-right ${temp > 80 ? 'text-red-600 dark:text-red-400 font-semibold' : 'text-gray-600 dark:text-gray-300'}`}>
|
||||
{temp.toFixed(1)}°C
|
||||
{/* Disk Info */}
|
||||
<Show when={host.disks && host.disks.length > 0}>
|
||||
<div class="min-w-[220px] flex-1 rounded border border-gray-200 bg-white/70 p-2 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
|
||||
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200">Disks</div>
|
||||
<div class="mt-2 space-y-2 text-[11px]">
|
||||
<For each={host.disks?.slice(0, 3)}>
|
||||
{(disk) => {
|
||||
const diskPercent = () => disk.usage ?? 0;
|
||||
return (
|
||||
<div class="rounded border border-dashed border-gray-200 p-2 dark:border-gray-700/70">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-200 truncate">{disk.mountpoint || disk.device}</span>
|
||||
<span class="text-[10px] text-gray-500 dark:text-gray-400">
|
||||
{formatBytes(disk.used ?? 0, 0)} / {formatBytes(disk.total ?? 0, 0)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* RAID Arrays */}
|
||||
<Show when={host.raid && host.raid.length > 0}>
|
||||
<For each={host.raid!}>
|
||||
{(array) => {
|
||||
const isDegraded = () => array.state.toLowerCase().includes('degraded') || array.failedDevices > 0;
|
||||
const isRebuilding = () => array.state.toLowerCase().includes('recover') || array.state.toLowerCase().includes('resync') || array.rebuildPercent > 0;
|
||||
const isHealthy = () => !isDegraded() && !isRebuilding() && array.state.toLowerCase().includes('clean');
|
||||
|
||||
const stateColor = () => {
|
||||
if (isDegraded()) return 'text-red-600 dark:text-red-400 font-semibold';
|
||||
if (isRebuilding()) return 'text-amber-600 dark:text-amber-400 font-semibold';
|
||||
if (isHealthy()) return 'text-green-600 dark:text-green-400';
|
||||
return 'text-gray-600 dark:text-gray-300';
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="min-w-[220px] flex-1 rounded border border-gray-200 bg-white/70 p-2 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
|
||||
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200">
|
||||
RAID {array.level.replace('raid', '')} - {array.device}
|
||||
</div>
|
||||
<div class="mt-2 space-y-1 text-[11px]">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-200">State</span>
|
||||
<span class={stateColor()}>{array.state}</span>
|
||||
<Show when={diskPercent() > 0}>
|
||||
<div class="mt-1">
|
||||
<MetricBar
|
||||
value={diskPercent()}
|
||||
label={formatPercent(diskPercent())}
|
||||
type="disk"
|
||||
class="max-w-none"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-200">Devices</span>
|
||||
<span class="text-gray-600 dark:text-gray-300">
|
||||
{array.activeDevices}/{array.totalDevices}
|
||||
{array.failedDevices > 0 && <span class="text-red-600 dark:text-red-400"> ({array.failedDevices} failed)</span>}
|
||||
</span>
|
||||
</div>
|
||||
<Show when={isRebuilding() && array.rebuildPercent > 0}>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-200">Rebuild</span>
|
||||
<span class="text-amber-600 dark:text-amber-400 font-medium">
|
||||
{array.rebuildPercent.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<Show when={array.rebuildSpeed}>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-200">Speed</span>
|
||||
<span class="text-gray-600 dark:text-gray-300">{array.rebuildSpeed}</span>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</Show>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</ScrollableTable>
|
||||
</Show>
|
||||
|
||||
{/* Temperature Sensors */}
|
||||
<Show when={host.sensors?.temperatureCelsius && Object.keys(host.sensors.temperatureCelsius).length > 0}>
|
||||
<div class="min-w-[220px] flex-1 rounded border border-gray-200 bg-white/70 p-2 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
|
||||
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200">Temperatures</div>
|
||||
<div class="mt-2 space-y-1 text-[11px] text-gray-600 dark:text-gray-300">
|
||||
<For each={Object.entries(host.sensors!.temperatureCelsius!).slice(0, 5)}>
|
||||
{([name, temp]) => (
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-200 truncate">{name}</span>
|
||||
<span class={`text-right ${temp > 80 ? 'text-red-600 dark:text-red-400 font-semibold' : 'text-gray-600 dark:text-gray-300'}`}>
|
||||
{temp.toFixed(1)}°C
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* RAID Arrays */}
|
||||
<Show when={host.raid && host.raid.length > 0}>
|
||||
<For each={host.raid!}>
|
||||
{(array) => {
|
||||
const isDegraded = () => array.state.toLowerCase().includes('degraded') || array.failedDevices > 0;
|
||||
const isRebuilding = () => array.state.toLowerCase().includes('recover') || array.state.toLowerCase().includes('resync') || array.rebuildPercent > 0;
|
||||
const isHealthy = () => !isDegraded() && !isRebuilding() && array.state.toLowerCase().includes('clean');
|
||||
|
||||
const stateColor = () => {
|
||||
if (isDegraded()) return 'text-red-600 dark:text-red-400 font-semibold';
|
||||
if (isRebuilding()) return 'text-amber-600 dark:text-amber-400 font-semibold';
|
||||
if (isHealthy()) return 'text-green-600 dark:text-green-400';
|
||||
return 'text-gray-600 dark:text-gray-300';
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="min-w-[220px] flex-1 rounded border border-gray-200 bg-white/70 p-2 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
|
||||
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200">
|
||||
RAID {array.level.replace('raid', '')} - {array.device}
|
||||
</div>
|
||||
<div class="mt-2 space-y-1 text-[11px]">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-200">State</span>
|
||||
<span class={stateColor()}>{array.state}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-200">Devices</span>
|
||||
<span class="text-gray-600 dark:text-gray-300">
|
||||
{array.activeDevices}/{array.totalDevices}
|
||||
{array.failedDevices > 0 && <span class="text-red-600 dark:text-red-400"> ({array.failedDevices} failed)</span>}
|
||||
</span>
|
||||
</div>
|
||||
<Show when={isRebuilding() && array.rebuildPercent > 0}>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-200">Rebuild</span>
|
||||
<span class="text-amber-600 dark:text-amber-400 font-medium">
|
||||
{array.rebuildPercent.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<Show when={array.rebuildSpeed}>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-200">Speed</span>
|
||||
<span class="text-gray-600 dark:text-gray-300">{array.rebuildSpeed}</span>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Show>
|
||||
</>
|
||||
|
|
|
|||
Loading…
Reference in a new issue