fix: restore I/O column styling to match v4.32.5 release

- Restore green/yellow/red color thresholds for disk and network I/O
- Use consistent text-xs font size across all columns
- Expand column widths to fit full header text at larger breakpoints
- Show full header names (Disk Read, VMID, Uptime, Memory) at xl+ screens
- Use shared GUEST_COLUMNS config for header/row grid alignment
- Add whitespace-nowrap to prevent header text wrapping
This commit is contained in:
rcourtman 2025-11-26 09:32:58 +00:00
parent 34137aeca5
commit 17c99377f6
3 changed files with 82 additions and 52 deletions

View file

@ -1,7 +1,8 @@
import { createSignal, createMemo, createEffect, For, Show, onMount } from 'solid-js';
import { useNavigate } from '@solidjs/router';
import type { VM, Container, Node } from '@/types/api';
import { GuestRow } from './GuestRow';
import { GuestRow, GUEST_COLUMNS } from './GuestRow';
import { useGridTemplate } from '@/components/shared/responsive';
import { useWebSocket } from '@/App';
import { getAlertStyles } from '@/utils/alerts';
import { useAlertsActivation } from '@/stores/alertsActivation';
@ -258,6 +259,9 @@ export function Dashboard(props: DashboardProps) {
const [sortKey, setSortKey] = createSignal<keyof (VM | Container) | null>('vmid');
const [sortDirection, setSortDirection] = createSignal<'asc' | 'desc'>('asc');
// Use the same grid template as GuestRow for header alignment
const { gridTemplate: headerGridTemplate } = useGridTemplate({ columns: GUEST_COLUMNS });
// Load all guest metadata on mount (single API call for all guests)
onMount(async () => {
@ -928,85 +932,111 @@ export function Dashboard(props: DashboardProps) {
<Card padding="none" class="mb-4 bg-white dark:bg-gray-800">
<div class="overflow-x-auto">
{/* Desktop Header */}
<div class="grid grid-cols-[minmax(100px,1.5fr)_minmax(24px,32px)_minmax(28px,36px)_minmax(28px,50px)_minmax(50px,1fr)_minmax(50px,1fr)_minmax(50px,1fr)_minmax(44px,54px)_minmax(44px,54px)_minmax(44px,54px)_minmax(44px,54px)] 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">
<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': headerGridTemplate() }}
>
{/* Name Header */}
<div
class="pl-4 pr-2 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center"
class="pl-4 pr-2 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center whitespace-nowrap"
onClick={() => handleSort('name')}
>
Name
{sortKey() === 'name' && (sortDirection() === 'asc' ? '▲' : '▼')}
Name {sortKey() === 'name' && (sortDirection() === 'asc' ? '▲' : '▼')}
</div>
{/* Type */}
<div
class="px-0.5 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center text-[10px]"
class="px-1 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center whitespace-nowrap"
onClick={() => handleSort('type')}
title="Type"
>
Type {sortKey() === 'type' && (sortDirection() === 'asc' ? '▲' : '▼')}
<span class="hidden xl:inline">Type</span>
<span class="xl:hidden">T</span>
{sortKey() === 'type' && (sortDirection() === 'asc' ? ' ▲' : ' ▼')}
</div>
{/* VMID */}
<div
class="px-0.5 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center text-[10px]"
class="px-1 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center whitespace-nowrap"
onClick={() => handleSort('vmid')}
title="VMID"
>
ID {sortKey() === 'vmid' && (sortDirection() === 'asc' ? '▲' : '▼')}
<span class="hidden xl:inline">VMID</span>
<span class="xl:hidden">ID</span>
{sortKey() === 'vmid' && (sortDirection() === 'asc' ? ' ▲' : ' ▼')}
</div>
{/* Uptime */}
<div
class="px-0.5 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center text-[10px]"
class="px-1 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center whitespace-nowrap"
onClick={() => handleSort('uptime')}
title="Uptime"
>
Up {sortKey() === 'uptime' && (sortDirection() === 'asc' ? '▲' : '▼')}
<span class="hidden xl:inline">Uptime</span>
<span class="xl:hidden">Up</span>
{sortKey() === 'uptime' && (sortDirection() === 'asc' ? ' ▲' : ' ▼')}
</div>
{/* CPU */}
<div
class="px-0.5 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center text-[10px]"
class="px-1 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center whitespace-nowrap"
onClick={() => handleSort('cpu')}
>
CPU {sortKey() === 'cpu' && (sortDirection() === 'asc' ? '▲' : '▼')}
</div>
{/* Memory */}
<div
class="px-0.5 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center text-[10px]"
class="px-1 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center whitespace-nowrap"
onClick={() => handleSort('memory')}
title="Memory"
>
Mem {sortKey() === 'memory' && (sortDirection() === 'asc' ? '▲' : '▼')}
<span class="hidden xl:inline">Memory</span>
<span class="xl:hidden">Mem</span>
{sortKey() === 'memory' && (sortDirection() === 'asc' ? ' ▲' : ' ▼')}
</div>
{/* Disk */}
<div
class="px-0.5 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center text-[10px]"
class="px-1 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center whitespace-nowrap"
onClick={() => handleSort('disk')}
>
Disk {sortKey() === 'disk' && (sortDirection() === 'asc' ? '▲' : '▼')}
</div>
{/* Disk Read */}
<div
class="px-0.5 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center text-[10px]"
class="px-1 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center whitespace-nowrap"
onClick={() => handleSort('diskRead')}
title="Disk Read"
>
D Rd {sortKey() === 'diskRead' && (sortDirection() === 'asc' ? '▲' : '▼')}
<span class="hidden xl:inline">Disk Read</span>
<span class="xl:hidden">D Rd</span>
{sortKey() === 'diskRead' && (sortDirection() === 'asc' ? ' ▲' : ' ▼')}
</div>
{/* Disk Write */}
<div
class="px-0.5 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center text-[10px]"
class="px-1 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center whitespace-nowrap"
onClick={() => handleSort('diskWrite')}
title="Disk Write"
>
D Wr {sortKey() === 'diskWrite' && (sortDirection() === 'asc' ? '▲' : '▼')}
<span class="hidden xl:inline">Disk Write</span>
<span class="xl:hidden">D Wr</span>
{sortKey() === 'diskWrite' && (sortDirection() === 'asc' ? ' ▲' : ' ▼')}
</div>
{/* Net In */}
<div
class="px-0.5 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center text-[10px]"
class="px-1 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center whitespace-nowrap"
onClick={() => handleSort('networkIn')}
title="Net In"
>
N In {sortKey() === 'networkIn' && (sortDirection() === 'asc' ? '▲' : '▼')}
<span class="hidden xl:inline">Net In</span>
<span class="xl:hidden">N In</span>
{sortKey() === 'networkIn' && (sortDirection() === 'asc' ? ' ▲' : ' ▼')}
</div>
{/* Net Out */}
<div
class="px-0.5 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center text-[10px]"
class="px-1 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center whitespace-nowrap"
onClick={() => handleSort('networkOut')}
title="Net Out"
>
N Out {sortKey() === 'networkOut' && (sortDirection() === 'asc' ? '▲' : '▼')}
<span class="hidden xl:inline">Net Out</span>
<span class="xl:hidden">N Out</span>
{sortKey() === 'networkOut' && (sortDirection() === 'asc' ? ' ▲' : ' ▼')}
</div>
</div>

View file

@ -17,14 +17,14 @@ type Guest = VM | Container;
/**
* Get color class for I/O values based on throughput (bytes/sec)
* Higher throughput = more prominent color to draw attention
* Uses color intensity to indicate activity level (green/yellow/red)
*/
function getIOColorClass(bytesPerSec: number): string {
const mbps = bytesPerSec / (1024 * 1024);
if (mbps < 1) return 'text-gray-400 dark:text-gray-500';
if (mbps < 10) return 'text-gray-600 dark:text-gray-300';
if (mbps < 50) return 'text-gray-700 dark:text-gray-200 font-medium';
return 'text-gray-900 dark:text-white font-semibold';
if (mbps < 1) return 'text-gray-300 dark:text-gray-400';
if (mbps < 10) return 'text-green-600 dark:text-green-400';
if (mbps < 50) return 'text-yellow-600 dark:text-yellow-400';
return 'text-red-600 dark:text-red-400';
}
// Global state for currently expanded drawer (only one drawer open at a time)
@ -59,19 +59,19 @@ interface ColumnDef {
flex?: number;
}
const GUEST_COLUMNS: ColumnDef[] = [
export const GUEST_COLUMNS: ColumnDef[] = [
{ id: 'name', label: 'Name', priority: 'essential', minWidth: '100px', flex: 1.5 },
{ id: 'type', label: 'Type', priority: 'essential', minWidth: '24px', maxWidth: '32px' },
{ id: 'vmid', label: 'VMID', priority: 'essential', minWidth: '28px', maxWidth: '36px' },
{ id: 'uptime', label: 'Uptime', priority: 'essential', minWidth: '28px', maxWidth: '50px' },
{ id: 'type', label: 'Type', priority: 'essential', minWidth: '24px', maxWidth: '50px' },
{ id: 'vmid', label: 'VMID', priority: 'essential', minWidth: '28px', maxWidth: '55px' },
{ id: 'uptime', label: 'Uptime', priority: 'essential', minWidth: '28px', maxWidth: '65px' },
{ id: 'cpu', label: 'CPU', priority: 'essential', minWidth: '50px', flex: 1 },
{ id: 'memory', label: 'Memory', priority: 'essential', minWidth: '50px', flex: 1 },
{ id: 'disk', label: 'Disk', priority: 'essential', minWidth: '50px', flex: 1 },
// I/O columns - fixed width, no flex
{ id: 'diskRead', label: 'D Read', priority: 'essential', minWidth: '44px', maxWidth: '54px' },
{ id: 'diskWrite', label: 'D Write', priority: 'essential', minWidth: '44px', maxWidth: '54px' },
{ id: 'netIn', label: 'Net In', priority: 'essential', minWidth: '44px', maxWidth: '54px' },
{ id: 'netOut', label: 'Net Out', priority: 'essential', minWidth: '44px', maxWidth: '54px' },
// I/O columns - fixed width matching v4.32.5 (grows at breakpoints to fit full header text)
{ id: 'diskRead', label: 'Disk Read', priority: 'essential', minWidth: '56px', maxWidth: '90px' },
{ id: 'diskWrite', label: 'Disk Write', priority: 'essential', minWidth: '56px', maxWidth: '90px' },
{ id: 'netIn', label: 'Net In', priority: 'essential', minWidth: '56px', maxWidth: '70px' },
{ id: 'netOut', label: 'Net Out', priority: 'essential', minWidth: '56px', maxWidth: '70px' },
];
interface GuestRowProps {
@ -662,36 +662,36 @@ export function GuestRow(props: GuestRowProps) {
case 'diskRead':
return (
<div class="py-1 flex justify-center items-center text-[9px] font-mono whitespace-nowrap">
<Show when={isRunning()} fallback={<span class="text-gray-400">-</span>}>
<span class={getIOColorClass(diskRead())}>{formatSpeed(diskRead())}</span>
<div class="py-1 flex justify-center items-center min-h-[24px]">
<Show when={isRunning()} fallback={<span class="text-xs text-gray-400">-</span>}>
<span class={`text-xs ${getIOColorClass(diskRead())}`}>{formatSpeed(diskRead())}</span>
</Show>
</div>
);
case 'diskWrite':
return (
<div class="py-1 flex justify-center items-center text-[9px] font-mono whitespace-nowrap">
<Show when={isRunning()} fallback={<span class="text-gray-400">-</span>}>
<span class={getIOColorClass(diskWrite())}>{formatSpeed(diskWrite())}</span>
<div class="py-1 flex justify-center items-center min-h-[24px]">
<Show when={isRunning()} fallback={<span class="text-xs text-gray-400">-</span>}>
<span class={`text-xs ${getIOColorClass(diskWrite())}`}>{formatSpeed(diskWrite())}</span>
</Show>
</div>
);
case 'netIn':
return (
<div class="py-1 flex justify-center items-center text-[9px] font-mono whitespace-nowrap">
<Show when={isRunning()} fallback={<span class="text-gray-400">-</span>}>
<span class={getIOColorClass(networkIn())}>{formatSpeed(networkIn())}</span>
<div class="py-1 flex justify-center items-center min-h-[24px]">
<Show when={isRunning()} fallback={<span class="text-xs text-gray-400">-</span>}>
<span class={`text-xs ${getIOColorClass(networkIn())}`}>{formatSpeed(networkIn())}</span>
</Show>
</div>
);
case 'netOut':
return (
<div class="py-1 flex justify-center items-center text-[9px] font-mono whitespace-nowrap">
<Show when={isRunning()} fallback={<span class="text-gray-400">-</span>}>
<span class={getIOColorClass(networkOut())}>{formatSpeed(networkOut())}</span>
<div class="py-1 flex justify-center items-center min-h-[24px]">
<Show when={isRunning()} fallback={<span class="text-xs text-gray-400">-</span>}>
<span class={`text-xs ${getIOColorClass(networkOut())}`}>{formatSpeed(networkOut())}</span>
</Show>
</div>
);

View file

@ -24,7 +24,7 @@ export function IOMetric(props: IOMetricProps) {
}
});
// Color based on speed (MB/s) - matching current dashboard
// Color based on speed (MB/s) - uses color intensity to indicate activity level
const colorClass = createMemo(() => {
if (props.disabled) return 'text-gray-400 dark:text-gray-500';
@ -38,9 +38,9 @@ export function IOMetric(props: IOMetricProps) {
return (
<Show
when={!props.disabled}
fallback={<div class="min-h-[14px] flex items-center text-xs text-gray-400">-</div>}
fallback={<div class="min-h-[24px] flex items-center text-xs text-gray-400">-</div>}
>
<div class={`min-h-[14px] text-xs font-mono ${colorClass()} flex items-center`}>
<div class={`min-h-[24px] text-xs ${colorClass()} flex items-center`}>
{formatSpeed(currentValue(), 0)}
</div>
</Show>