feat: Toggleable table columns with rich tooltips for Proxmox dashboard

Replace drawer-based info display with inline columns that can be toggled:
- Add IP, Uptime, Node, Backup, OS, Tags columns (user-toggleable)
- Add ColumnPicker dropdown to show/hide columns with localStorage persistence
- Columns auto-show based on screen width using priority system
- Remove GuestDrawer - all info now visible inline or via tooltips

Rich hover tooltips:
- Disk bar: Shows all mount points with usage %, color-coded by severity
- Memory bar: Shows used/free/balloon/swap breakdown
- IP column: Shows network icon + count, hover for interfaces, MACs, IPs, traffic

Also:
- Create useColumnVisibility hook for responsive column management
- Create ColumnPicker component for column toggle UI
- Update drawer layouts in Hosts/Docker tabs for consistency
This commit is contained in:
rcourtman 2025-12-05 12:12:56 +00:00
parent 309302a793
commit a15dc6b899
10 changed files with 1023 additions and 545 deletions

View file

@ -1,7 +1,7 @@
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, GUEST_COLUMNS } from './GuestRow';
import { GuestRow, GUEST_COLUMNS, type GuestColumnDef } from './GuestRow';
import { useWebSocket } from '@/App';
import { getAlertStyles } from '@/utils/alerts';
import { useAlertsActivation } from '@/stores/alertsActivation';
@ -19,8 +19,10 @@ import { isNodeOnline, OFFLINE_HEALTH_STATUSES, DEGRADED_HEALTH_STATUSES } from
import { getNodeDisplayName } from '@/utils/nodes';
import { logger } from '@/utils/logger';
import { usePersistentSignal } from '@/hooks/usePersistentSignal';
import { useColumnVisibility } from '@/hooks/useColumnVisibility';
import { STORAGE_KEYS } from '@/utils/localStorage';
import { getBackupInfo } from '@/utils/format';
import { aiChatStore } from '@/stores/aiChat';
type GuestMetadataRecord = Record<string, GuestMetadata>;
type IdleCallbackHandle = number;
@ -265,8 +267,16 @@ export function Dashboard(props: DashboardProps) {
const [sortKey, setSortKey] = createSignal<keyof (VM | Container) | null>('vmid');
const [sortDirection, setSortDirection] = createSignal<'asc' | 'desc'>('asc');
// Column visibility management
const columnVisibility = useColumnVisibility(
STORAGE_KEYS.DASHBOARD_HIDDEN_COLUMNS,
GUEST_COLUMNS as GuestColumnDef[]
);
const visibleColumns = columnVisibility.visibleColumns;
const visibleColumnIds = createMemo(() => visibleColumns().map(c => c.id));
// Total columns for colspan calculations
const totalColumns = GUEST_COLUMNS.length;
const totalColumns = createMemo(() => visibleColumns().length);
// Load all guest metadata on mount (single API call for all guests)
onMount(async () => {
@ -457,6 +467,12 @@ export function Dashboard(props: DashboardProps) {
}
} else if (!isInputField && e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
// If it's a printable character and user is not in an input field
// Check if AI chat is open - if so, focus that instead
if (aiChatStore.focusInput()) {
// AI chat input was focused, let the character be typed there
return;
}
// Otherwise, focus the search input
// Expand filters section if collapsed
if (!showFilters()) {
setShowFilters(true);
@ -827,6 +843,10 @@ export function Dashboard(props: DashboardProps) {
setSortKey={setSortKey}
setSortDirection={setSortDirection}
searchInputRef={(el) => (searchInputRef = el)}
availableColumns={columnVisibility.availableToggles()}
isColumnHidden={columnVisibility.isHiddenByUser}
onColumnToggle={columnVisibility.toggle}
onColumnReset={columnVisibility.resetToDefaults}
/>
{/* Loading State */}
@ -954,93 +974,25 @@ export function Dashboard(props: DashboardProps) {
<table class="w-full border-collapse whitespace-nowrap">
<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-700">
{/* Name Header */}
<th
class="pl-4 pr-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
onClick={() => handleSort('name')}
>
Name {sortKey() === 'name' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
<For each={visibleColumns()}>
{(col) => {
const isFirst = () => col.id === visibleColumns()[0]?.id;
const sortKeyForCol = col.sortKey as keyof (VM | Container) | undefined;
const isSortable = !!sortKeyForCol;
const isSorted = () => sortKeyForCol && sortKey() === sortKeyForCol;
{/* Type */}
<th
class="px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
onClick={() => handleSort('type')}
>
Type {sortKey() === 'type' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
{/* VMID */}
<th
class="px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
onClick={() => handleSort('vmid')}
>
VMID {sortKey() === 'vmid' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
{/* Uptime */}
<th
class="px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
onClick={() => handleSort('uptime')}
>
Uptime {sortKey() === 'uptime' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
{/* CPU */}
<th
class="px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
onClick={() => handleSort('cpu')}
>
CPU {sortKey() === 'cpu' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
{/* Memory */}
<th
class="px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
onClick={() => handleSort('memory')}
>
Memory {sortKey() === 'memory' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
{/* Disk */}
<th
class="px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
onClick={() => handleSort('disk')}
>
Disk {sortKey() === 'disk' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
{/* Disk Read */}
<th
class="px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
onClick={() => handleSort('diskRead')}
>
Disk Read {sortKey() === 'diskRead' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
{/* Disk Write */}
<th
class="px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
onClick={() => handleSort('diskWrite')}
>
Disk Write {sortKey() === 'diskWrite' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
{/* Net In */}
<th
class="px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
onClick={() => handleSort('networkIn')}
>
Net In {sortKey() === 'networkIn' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
{/* Net Out */}
<th
class="px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
onClick={() => handleSort('networkOut')}
>
Net Out {sortKey() === 'networkOut' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
return (
<th
class={`py-1 text-[11px] sm:text-xs font-medium uppercase tracking-wider whitespace-nowrap
${isFirst() ? 'pl-4 pr-2 text-left' : 'px-2 text-center'}
${isSortable ? 'cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600' : ''}`}
onClick={() => isSortable && handleSort(sortKeyForCol!)}
>
{col.label} {isSorted() && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
);
}}
</For>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
@ -1059,7 +1011,7 @@ export function Dashboard(props: DashboardProps) {
return (
<>
<Show when={node && groupingMode() === 'grouped'}>
<NodeGroupHeader node={node!} renderAs="tr" colspan={totalColumns} />
<NodeGroupHeader node={node!} renderAs="tr" colspan={totalColumns()} />
</Show>
<For each={guests} fallback={<></>}>
{(guest) => {
@ -1080,6 +1032,7 @@ export function Dashboard(props: DashboardProps) {
parentNodeOnline={parentNodeOnline}
onCustomUrlUpdate={handleCustomUrlUpdate}
isGroupedView={groupingMode() === 'grouped'}
visibleColumnIds={visibleColumnIds()}
/>
</ComponentErrorBoundary>
);

View file

@ -2,6 +2,8 @@ import { Component, Show, For, createSignal, onMount, createEffect, onCleanup }
import { Card } from '@/components/shared/Card';
import { SearchTipsPopover } from '@/components/shared/SearchTipsPopover';
import { MetricsViewToggle } from '@/components/shared/MetricsViewToggle';
import { ColumnPicker } from '@/components/shared/ColumnPicker';
import type { ColumnDef } from '@/hooks/useColumnVisibility';
import { STORAGE_KEYS } from '@/utils/localStorage';
import { createSearchHistoryManager } from '@/utils/searchHistory';
@ -20,6 +22,11 @@ interface DashboardFilterProps {
setSortKey: (value: string) => void;
setSortDirection: (value: string) => void;
searchInputRef?: (el: HTMLInputElement) => void;
// Column visibility
availableColumns?: ColumnDef[];
isColumnHidden?: (id: string) => boolean;
onColumnToggle?: (id: string) => void;
onColumnReset?: () => void;
}
export const DashboardFilter: Component<DashboardFilterProps> = (props) => {
@ -423,6 +430,20 @@ export const DashboardFilter: Component<DashboardFilterProps> = (props) => {
<div class="h-5 w-px bg-gray-200 dark:bg-gray-600 hidden sm:block"></div>
{/* Column Picker */}
<Show when={props.availableColumns && props.isColumnHidden && props.onColumnToggle}>
<ColumnPicker
columns={props.availableColumns!}
isHidden={props.isColumnHidden!}
onToggle={props.onColumnToggle!}
onReset={props.onColumnReset}
/>
</Show>
<Show when={props.availableColumns}>
<div class="h-5 w-px bg-gray-200 dark:bg-gray-600 hidden sm:block"></div>
</Show>
{/* Reset Button */}
<button
onClick={() => {

View file

@ -146,13 +146,11 @@ export const GuestDrawer: Component<GuestDrawerProps> = (props) => {
context.network_out_rate = formatSpeed(guest.networkOut);
}
// OS info (VMs only)
if (isVM(guest)) {
if (guest.osName) context.os_name = guest.osName;
if (guest.osVersion) context.os_version = guest.osVersion;
if (guest.agentVersion) context.guest_agent = guest.agentVersion;
if (guest.ipAddresses?.length) context.ip_addresses = guest.ipAddresses;
}
// OS info (both VMs and containers can have this)
if (guest.osName) context.os_name = guest.osName;
if (guest.osVersion) context.os_version = guest.osVersion;
if (guest.agentVersion) context.guest_agent = guest.agentVersion;
if (guest.ipAddresses?.length) context.ip_addresses = guest.ipAddresses;
// Tags
if (guest.tags?.length) {
@ -196,32 +194,27 @@ export const GuestDrawer: Component<GuestDrawerProps> = (props) => {
};
const hasOsInfo = () => {
if (!isVM(props.guest)) return false;
// Both VMs and containers can have OS info
return (props.guest.osName?.length ?? 0) > 0 || (props.guest.osVersion?.length ?? 0) > 0;
};
const osName = () => {
if (!isVM(props.guest)) return '';
return props.guest.osName || '';
};
const osVersion = () => {
if (!isVM(props.guest)) return '';
return props.guest.osVersion || '';
};
const hasAgentInfo = () => {
if (!isVM(props.guest)) return false;
return !!props.guest.agentVersion;
};
const agentVersion = () => {
if (!isVM(props.guest)) return '';
return props.guest.agentVersion || '';
};
const ipAddresses = () => {
if (!isVM(props.guest)) return [];
return props.guest.ipAddresses || [];
};
@ -249,7 +242,7 @@ export const GuestDrawer: Component<GuestDrawerProps> = (props) => {
};
const networkInterfaces = () => {
if (!isVM(props.guest)) return [];
// Both VMs and containers can have network interfaces
return props.guest.networkInterfaces || [];
};
@ -259,122 +252,183 @@ export const GuestDrawer: Component<GuestDrawerProps> = (props) => {
return (
<div class="space-y-3">
{/* Top row: metrics columns */}
<div class="flex items-start gap-4">
{/* Left Column: Guest Overview */}
<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 text-gray-700 dark:text-gray-200">Guest Overview</div>
<div class="mt-1 space-y-1">
<Show when={hasOsInfo()}>
<div class="flex flex-wrap items-center gap-1 text-gray-600 dark:text-gray-300">
<Show when={osName().length > 0}>
<span class="font-medium" title={osName()}>{osName()}</span>
{/* Flex layout - items grow to fill space, max ~4 per row */}
<div class="flex flex-wrap gap-3 [&>*]:flex-1 [&>*]:basis-[calc(25%-0.75rem)] [&>*]:min-w-[200px] [&>*]:max-w-full">
{/* System Info - always show */}
<div class="rounded border border-gray-200 bg-white/70 p-3 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 mb-2">System</div>
<div class="space-y-1.5 text-[11px]">
<Show when={props.guest.cpus}>
<div class="flex items-center justify-between">
<span class="text-gray-500 dark:text-gray-400">CPUs</span>
<span class="font-medium text-gray-700 dark:text-gray-200">{props.guest.cpus}</span>
</div>
</Show>
<Show when={props.guest.uptime > 0}>
<div class="flex items-center justify-between">
<span class="text-gray-500 dark:text-gray-400">Uptime</span>
<span class="font-medium text-gray-700 dark:text-gray-200">{formatUptime(props.guest.uptime)}</span>
</div>
</Show>
<Show when={props.guest.node}>
<div class="flex items-center justify-between">
<span class="text-gray-500 dark:text-gray-400">Node</span>
<span class="font-medium text-gray-700 dark:text-gray-200">{props.guest.node}</span>
</div>
</Show>
<Show when={hasAgentInfo()}>
<div class="flex items-center justify-between">
<span class="text-gray-500 dark:text-gray-400">Agent</span>
<span class="font-medium text-gray-700 dark:text-gray-200 truncate ml-2" title={isVM(props.guest) ? `QEMU guest agent ${agentVersion()}` : agentVersion()}>
{isVM(props.guest) ? `QEMU ${agentVersion()}` : agentVersion()}
</span>
</div>
</Show>
</div>
</div>
{/* Guest Info - OS and IPs */}
<Show when={hasOsInfo() || ipAddresses().length > 0}>
<div class="rounded border border-gray-200 bg-white/70 p-3 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 mb-2">Guest Info</div>
<div class="space-y-2">
<Show when={hasOsInfo()}>
<div class="text-[11px] text-gray-600 dark:text-gray-300">
<Show when={osName().length > 0}>
<span class="font-medium">{osName()}</span>
</Show>
<Show when={osName().length > 0 && osVersion().length > 0}>
<span class="text-gray-400 dark:text-gray-500 mx-1"></span>
</Show>
<Show when={osVersion().length > 0}>
<span>{osVersion()}</span>
</Show>
</div>
</Show>
<Show when={osName().length > 0 && osVersion().length > 0}>
<span class="text-gray-400 dark:text-gray-500"></span>
</Show>
<Show when={osVersion().length > 0}>
<span title={osVersion()}>{osVersion()}</span>
<Show when={ipAddresses().length > 0}>
<div class="flex flex-wrap gap-1">
<For each={ipAddresses()}>
{(ip) => (
<span class="inline-block rounded bg-blue-100 px-1.5 py-0.5 text-[10px] text-blue-700 dark:bg-blue-900/40 dark:text-blue-200" title={ip}>
{ip}
</span>
)}
</For>
</div>
</Show>
</div>
</Show>
<Show when={hasAgentInfo()}>
<div class="flex flex-wrap items-center gap-1 text-[11px] text-gray-500 dark:text-gray-400">
<span class="uppercase tracking-wide text-[10px] text-gray-400 dark:text-gray-500">
Agent
</span>
<span title={`QEMU guest agent ${agentVersion()}`}>
QEMU guest agent {agentVersion()}
</span>
</div>
</Show>
{/* Memory Details */}
<Show when={memoryExtraLines() && memoryExtraLines()!.length > 0}>
<div class="rounded border border-gray-200 bg-white/70 p-3 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 mb-2">Memory</div>
<div class="space-y-1 text-[11px] text-gray-600 dark:text-gray-300">
<For each={memoryExtraLines()!}>{(line) => <div>{line}</div>}</For>
</div>
</Show>
<Show when={ipAddresses().length > 0}>
</div>
</Show>
{/* Backup Info */}
<Show when={props.guest.lastBackup}>
<div class="rounded border border-gray-200 bg-white/70 p-3 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 mb-2">Backup</div>
<div class="space-y-1 text-[11px]">
{(() => {
const backupDate = new Date(props.guest.lastBackup);
const now = new Date();
const daysSince = Math.floor((now.getTime() - backupDate.getTime()) / (1000 * 60 * 60 * 24));
const isOld = daysSince > 7;
const isCritical = daysSince > 30;
return (
<>
<div class="flex items-center justify-between">
<span class="text-gray-500 dark:text-gray-400">Last Backup</span>
<span class={`font-medium ${isCritical ? 'text-red-600 dark:text-red-400' : isOld ? 'text-amber-600 dark:text-amber-400' : 'text-green-600 dark:text-green-400'}`}>
{daysSince === 0 ? 'Today' : daysSince === 1 ? 'Yesterday' : `${daysSince}d ago`}
</span>
</div>
<div class="text-[10px] text-gray-400 dark:text-gray-500">
{backupDate.toLocaleDateString()}
</div>
</>
);
})()}
</div>
</div>
</Show>
{/* Tags */}
<Show when={props.guest.tags && (Array.isArray(props.guest.tags) ? props.guest.tags.length > 0 : props.guest.tags.length > 0)}>
<div class="rounded border border-gray-200 bg-white/70 p-3 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 mb-2">Tags</div>
<div class="flex flex-wrap gap-1">
<For each={ipAddresses()}>
{(ip) => (
<span
class="max-w-full truncate rounded bg-blue-100 px-1.5 py-0.5 text-blue-700 dark:bg-blue-900/40 dark:text-blue-200"
title={ip}
>
{ip}
<For each={Array.isArray(props.guest.tags) ? props.guest.tags : (props.guest.tags?.split(',') || [])}>
{(tag) => (
<span class="inline-block rounded bg-gray-100 px-1.5 py-0.5 text-[10px] text-gray-700 dark:bg-gray-700 dark:text-gray-200">
{tag.trim()}
</span>
)}
</For>
</div>
</Show>
</div>
</div>
{/* Middle Column: Memory Details */}
<Show when={memoryExtraLines() && memoryExtraLines()!.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 text-gray-700 dark:text-gray-200">Memory</div>
<div class="mt-1 space-y-1 text-gray-600 dark:text-gray-300">
<For each={memoryExtraLines()!}>{(line) => <div>{line}</div>}</For>
</div>
</div>
</Show>
</Show>
{/* Right Column: Filesystems */}
<Show when={hasFilesystemDetails() && props.guest.disks && props.guest.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 text-gray-700 dark:text-gray-200">Filesystems</div>
<div class="mt-1 text-gray-600 dark:text-gray-300">
<DiskList
disks={props.guest.disks || []}
diskStatusReason={isVM(props.guest) ? props.guest.diskStatusReason : undefined}
/>
{/* Filesystems */}
<Show when={hasFilesystemDetails() && props.guest.disks && props.guest.disks.length > 0}>
<div class="rounded border border-gray-200 bg-white/70 p-3 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 mb-2">Filesystems</div>
<div class="text-[11px] text-gray-600 dark:text-gray-300">
<DiskList
disks={props.guest.disks || []}
diskStatusReason={isVM(props.guest) ? props.guest.diskStatusReason : undefined}
/>
</div>
</div>
</div>
</Show>
</Show>
{/* Far Right Column: Network Interfaces */}
<Show when={hasNetworkInterfaces()}>
<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 text-gray-700 dark:text-gray-200">Network Interfaces</div>
<div class="mt-1 text-[10px] text-gray-400 dark:text-gray-500">Row charts show current rate; totals below are cumulative since boot.</div>
<div class="mt-1 space-y-1 text-gray-600 dark:text-gray-300">
<For each={networkInterfaces()}>
{(iface) => {
const addresses = iface.addresses ?? [];
const hasTraffic = (iface.rxBytes ?? 0) > 0 || (iface.txBytes ?? 0) > 0;
return (
<div class="space-y-1 rounded border border-dashed border-gray-200 p-2 last:mb-0 dark:border-gray-700">
<div class="flex items-center gap-2 font-medium text-gray-700 dark:text-gray-200">
<span class="truncate" title={iface.name}>{iface.name || 'interface'}</span>
<Show when={iface.mac}>
<span class="text-[10px] text-gray-400 dark:text-gray-500" title={iface.mac}>
{iface.mac}
</span>
{/* Network Interfaces */}
<Show when={hasNetworkInterfaces()}>
<div class="rounded border border-gray-200 bg-white/70 p-3 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 mb-2">Network</div>
<div class="space-y-2">
<For each={networkInterfaces().slice(0, 4)}>
{(iface) => {
const addresses = iface.addresses ?? [];
const hasTraffic = (iface.rxBytes ?? 0) > 0 || (iface.txBytes ?? 0) > 0;
return (
<div class="rounded border border-dashed border-gray-200 p-2 dark:border-gray-700">
<div class="flex items-center gap-2 text-[11px] font-medium text-gray-700 dark:text-gray-200">
<span class="truncate">{iface.name || 'interface'}</span>
<Show when={iface.mac}>
<span class="text-[9px] text-gray-400 dark:text-gray-500 font-normal">{iface.mac}</span>
</Show>
</div>
<Show when={addresses.length > 0}>
<div class="flex flex-wrap gap-1 mt-1">
<For each={addresses}>
{(ip) => (
<span class="inline-block rounded bg-blue-100 px-1.5 py-0.5 text-[10px] text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
{ip}
</span>
)}
</For>
</div>
</Show>
<Show when={hasTraffic}>
<div class="flex gap-3 mt-1 text-[10px] text-gray-500 dark:text-gray-400">
<span>RX {formatBytes(iface.rxBytes ?? 0)}</span>
<span>TX {formatBytes(iface.txBytes ?? 0)}</span>
</div>
</Show>
</div>
<Show when={addresses.length > 0}>
<div class="flex flex-wrap gap-1">
<For each={addresses}>
{(ip) => (
<span
class="max-w-full truncate rounded bg-blue-100 px-1.5 py-0.5 text-blue-700 dark:bg-blue-900/40 dark:text-blue-200"
title={ip}
>
{ip}
</span>
)}
</For>
</div>
</Show>
<Show when={hasTraffic}>
<div class="flex items-center gap-3 text-[10px] text-gray-500 dark:text-gray-400">
<span>Total RX {formatBytes(iface.rxBytes ?? 0)}</span>
<span>Total TX {formatBytes(iface.txBytes ?? 0)}</span>
</div>
</Show>
</div>
);
}}
</For>
);
}}
</For>
</div>
</div>
</div>
</Show>
</Show>
</div>
{/* Bottom row: AI Context & Ask AI - only show when AI is enabled */}

View file

@ -1,6 +1,6 @@
import { GuestDrawer } from './GuestDrawer';
import { createMemo, createSignal, createEffect, Show } from 'solid-js';
import type { VM, Container } from '@/types/api';
import { createMemo, createSignal, createEffect, Show, For } from 'solid-js';
import { Portal } from 'solid-js/web';
import type { VM, Container, NetworkInterface } from '@/types/api';
import { formatBytes, formatUptime, formatSpeed, getBackupInfo, type BackupStatus, formatPercent } from '@/utils/format';
import { TagBadges } from './TagBadges';
import { StackedDiskBar } from './StackedDiskBar';
@ -30,8 +30,6 @@ function getIOColorClass(bytesPerSec: number): string {
return 'text-red-600 dark:text-red-400';
}
// Global state for currently expanded drawer (only one drawer open at a time)
const [currentlyExpandedGuestId, setCurrentlyExpandedGuestId] = createSignal<string | null>(null);
// Global editing state - use a signal so all components react
const [currentlyEditingGuestId, setCurrentlyEditingGuestId] = createSignal<string | null>(null);
// Store the editing value globally so it survives re-renders
@ -103,30 +101,148 @@ function BackupIndicator(props: { lastBackup: string | number | null | undefined
);
}
// Network info cell with rich tooltip showing interfaces, IPs, and MACs
function NetworkInfoCell(props: { ipAddresses: string[]; networkInterfaces: NetworkInterface[] }) {
const [showTooltip, setShowTooltip] = createSignal(false);
const [tooltipPos, setTooltipPos] = createSignal({ x: 0, y: 0 });
const hasInterfaces = () => props.networkInterfaces.length > 0;
const primaryIp = () => props.ipAddresses[0] || props.networkInterfaces[0]?.addresses?.[0] || null;
const totalIps = () => {
if (props.ipAddresses.length > 0) return props.ipAddresses.length;
return props.networkInterfaces.reduce((sum, iface) => sum + (iface.addresses?.length || 0), 0);
};
const handleMouseEnter = (e: MouseEvent) => {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
setTooltipPos({ x: rect.left + rect.width / 2, y: rect.top });
setShowTooltip(true);
};
const handleMouseLeave = () => {
setShowTooltip(false);
};
return (
<>
<span
class="inline-flex items-center gap-1 text-xs text-gray-600 dark:text-gray-400 cursor-help"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<Show when={primaryIp()} fallback="-">
{/* Network icon */}
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418" />
</svg>
<span class="text-[10px] font-medium">{totalIps()}</span>
</Show>
</span>
<Show when={showTooltip() && (hasInterfaces() || props.ipAddresses.length > 0)}>
<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-[180px] max-w-[280px] border border-gray-700">
<div class="font-medium mb-1 text-gray-300 border-b border-gray-700 pb-1">
Network Interfaces
</div>
{/* Show detailed interface info if available */}
<Show when={hasInterfaces()}>
<For each={props.networkInterfaces}>
{(iface, idx) => (
<div class="py-1" classList={{ 'border-t border-gray-700/50': idx() > 0 }}>
<div class="flex items-center gap-2 text-blue-400 font-medium">
<span>{iface.name || 'eth' + idx()}</span>
<Show when={iface.mac}>
<span class="text-[9px] text-gray-500 font-normal">{iface.mac}</span>
</Show>
</div>
<Show when={iface.addresses && iface.addresses.length > 0}>
<div class="mt-0.5 flex flex-wrap gap-1">
<For each={iface.addresses}>
{(ip) => (
<span class="text-gray-300 font-mono">{ip}</span>
)}
</For>
</div>
</Show>
<Show when={!iface.addresses || iface.addresses.length === 0}>
<span class="text-gray-500 text-[9px]">No IP assigned</span>
</Show>
<Show when={(iface.rxBytes || 0) > 0 || (iface.txBytes || 0) > 0}>
<div class="mt-0.5 text-[9px] text-gray-500">
RX: {formatBytes(iface.rxBytes || 0)} / TX: {formatBytes(iface.txBytes || 0)}
</div>
</Show>
</div>
)}
</For>
</Show>
{/* Fallback: just show IP list if no interface details */}
<Show when={!hasInterfaces() && props.ipAddresses.length > 0}>
<div class="flex flex-wrap gap-1 py-0.5">
<For each={props.ipAddresses}>
{(ip) => (
<span class="text-gray-300 font-mono">{ip}</span>
)}
</For>
</div>
</Show>
</div>
</div>
</Portal>
</Show>
</>
);
}
// Column configuration using the priority system
interface ColumnDef {
export interface GuestColumnDef {
id: string;
label: string;
priority: ColumnPriority;
toggleable?: boolean;
minWidth?: string;
maxWidth?: string;
flex?: number;
sortKey?: string;
}
export const GUEST_COLUMNS: ColumnDef[] = [
{ id: 'name', label: 'Name', priority: 'essential', minWidth: 'auto', maxWidth: 'auto' },
{ id: 'type', label: 'Type', priority: 'essential', minWidth: 'auto', maxWidth: 'auto' },
{ id: 'vmid', label: 'VMID', priority: 'essential', minWidth: 'auto', maxWidth: 'auto' },
{ id: 'uptime', label: 'Uptime', priority: 'essential', minWidth: 'auto', maxWidth: 'auto' },
// Metric columns - fixed min/max width to match progress bar
{ id: 'cpu', label: 'CPU', priority: 'essential', minWidth: '55px', maxWidth: '156px' },
{ id: 'memory', label: 'Memory', priority: 'essential', minWidth: '75px', maxWidth: '156px' },
{ id: 'disk', label: 'Disk', priority: 'essential', minWidth: '75px', maxWidth: '156px' },
// I/O columns - auto sizing
{ id: 'diskRead', label: 'Disk Read', priority: 'essential', minWidth: 'auto', maxWidth: 'auto' },
{ id: 'diskWrite', label: 'Disk Write', priority: 'essential', minWidth: 'auto', maxWidth: 'auto' },
{ id: 'netIn', label: 'Net In', priority: 'essential', minWidth: 'auto', maxWidth: 'auto' },
{ id: 'netOut', label: 'Net Out', priority: 'essential', minWidth: 'auto', maxWidth: 'auto' },
export const GUEST_COLUMNS: GuestColumnDef[] = [
// Essential - always visible
{ id: 'name', label: 'Name', priority: 'essential', sortKey: 'name' },
{ id: 'type', label: 'Type', priority: 'essential', sortKey: 'type' },
{ id: 'vmid', label: 'VMID', priority: 'essential', sortKey: 'vmid' },
// Core metrics - always visible
{ id: 'cpu', label: 'CPU', priority: 'essential', minWidth: '55px', maxWidth: '156px', sortKey: 'cpu' },
{ id: 'memory', label: 'Memory', priority: 'essential', minWidth: '75px', maxWidth: '156px', sortKey: 'memory' },
{ id: 'disk', label: 'Disk', priority: 'essential', minWidth: '75px', maxWidth: '156px', sortKey: 'disk' },
// Secondary - visible on md+ (768px), user toggleable
{ id: 'ip', label: 'IP', priority: 'secondary', toggleable: true },
{ id: 'uptime', label: 'Uptime', priority: 'secondary', toggleable: true, sortKey: 'uptime' },
{ id: 'node', label: 'Node', priority: 'secondary', toggleable: true, sortKey: 'node' },
// Supplementary - visible on lg+ (1024px), user toggleable
{ id: 'backup', label: 'Backup', priority: 'supplementary', toggleable: true },
{ id: 'os', label: 'OS', priority: 'supplementary', toggleable: true },
{ id: 'tags', label: 'Tags', priority: 'supplementary', toggleable: true },
// Detailed - visible on xl+ (1280px), user toggleable
{ id: 'diskRead', label: 'D Read', priority: 'detailed', toggleable: true, sortKey: 'diskRead' },
{ id: 'diskWrite', label: 'D Write', priority: 'detailed', toggleable: true, sortKey: 'diskWrite' },
{ id: 'netIn', label: 'Net In', priority: 'detailed', toggleable: true, sortKey: 'networkIn' },
{ id: 'netOut', label: 'Net Out', priority: 'detailed', toggleable: true, sortKey: 'networkOut' },
];
interface GuestRowProps {
@ -151,6 +267,8 @@ interface GuestRowProps {
parentNodeOnline?: boolean;
onCustomUrlUpdate?: (guestId: string, url: string) => void;
isGroupedView?: boolean;
/** IDs of columns that should be visible */
visibleColumnIds?: string[];
}
export function GuestRow(props: GuestRowProps) {
@ -160,7 +278,14 @@ export function GuestRow(props: GuestRowProps) {
// Use breakpoint hook directly for responsive behavior
const { isMobile } = useBreakpoint();
// Create namespaced metrics key
// Helper to check if a column is visible
// If visibleColumnIds is not provided, show all columns for backwards compatibility
const isColVisible = (colId: string) => {
if (!props.visibleColumnIds) return true;
return props.visibleColumnIds.includes(colId);
};
// Create namespaced metrics key for sparklines
const metricsKey = createMemo(() => {
const kind = props.guest.type === 'qemu' ? 'vm' : 'container';
return buildMetricKey(kind, guestId());
@ -168,7 +293,6 @@ export function GuestRow(props: GuestRowProps) {
const [customUrl, setCustomUrl] = createSignal<string | undefined>(props.customUrl);
const [shouldAnimateIcon, setShouldAnimateIcon] = createSignal(false);
const drawerOpen = createMemo(() => currentlyExpandedGuestId() === guestId());
const editingUrlValue = createMemo(() => {
editingValuesVersion(); // Subscribe to changes
return editingValues.get(guestId()) || '';
@ -239,34 +363,6 @@ export function GuestRow(props: GuestRowProps) {
return lines.length > 0 ? lines : undefined;
});
const memoryTooltip = createMemo(() => memoryExtraLines()?.join('\n') ?? undefined);
const hasDrawerContent = createMemo(
() =>
hasOsInfo() ||
hasAgentInfo() ||
ipAddresses().length > 0 ||
(memoryExtraLines()?.length ?? 0) > 0 ||
hasFilesystemDetails() ||
hasNetworkInterfaces(),
);
const hasFallbackContent = createMemo(
() => !hasDrawerContent() && (props.guest.type === 'qemu' || props.guest.type === 'lxc'),
);
const canShowDrawer = createMemo(() => hasDrawerContent() || hasFallbackContent());
createEffect(() => {
if (!canShowDrawer() && drawerOpen()) {
setCurrentlyExpandedGuestId(null);
}
});
const toggleDrawer = (event: MouseEvent) => {
if (!canShowDrawer()) return;
const target = event.target as HTMLElement;
if (target.closest('a, button, input, [data-prevent-toggle]')) {
return;
}
setCurrentlyExpandedGuestId(prev => prev === guestId() ? null : guestId());
};
const startEditingUrl = (event: MouseEvent) => {
event.stopPropagation();
@ -470,11 +566,7 @@ export function GuestRow(props: GuestRowProps) {
? ''
: 'hover:bg-gray-50 dark:hover:bg-gray-700/30';
const stoppedDimming = !isRunning() ? 'opacity-60' : '';
const clickable = canShowDrawer() ? 'cursor-pointer' : '';
const expanded = drawerOpen() && !hasUnacknowledgedAlert()
? 'bg-gray-50 dark:bg-gray-800/40'
: '';
return `${base} ${hover} ${defaultHover} ${alertBg} ${stoppedDimming} ${clickable} ${expanded}`;
return `${base} ${hover} ${defaultHover} ${alertBg} ${stoppedDimming}`;
});
const rowStyle = createMemo(() => {
@ -486,18 +578,12 @@ export function GuestRow(props: GuestRowProps) {
};
});
// Total columns for colspan calculation
const totalColumns = GUEST_COLUMNS.length;
return (
<>
<tr
class={rowClass()}
style={rowStyle()}
onClick={toggleDrawer}
aria-expanded={drawerOpen()}
>
{/* Name */}
{/* Name - always visible */}
<td class={`pr-2 py-1 align-middle whitespace-nowrap ${props.isGroupedView ? GROUPED_FIRST_CELL_INDENT : DEFAULT_FIRST_CELL_INDENT}`}>
<div class="flex items-center gap-2 min-w-0">
<div class="flex items-center gap-1.5 min-w-0">
@ -544,7 +630,10 @@ export function GuestRow(props: GuestRowProps) {
</svg>
</a>
</Show>
<BackupIndicator lastBackup={props.guest.lastBackup} isTemplate={props.guest.template} />
{/* Show backup indicator in name cell only if backup column is hidden */}
<Show when={!isColVisible('backup')}>
<BackupIndicator lastBackup={props.guest.lastBackup} isTemplate={props.guest.template} />
</Show>
</div>
}
>
@ -599,7 +688,8 @@ export function GuestRow(props: GuestRowProps) {
</Show>
</div>
<Show when={!isEditingUrl()}>
{/* Show tags inline only if tags column is hidden */}
<Show when={!isEditingUrl() && !isColVisible('tags')}>
<div class="hidden md:flex" data-prevent-toggle onClick={(event) => event.stopPropagation()}>
<TagBadges
tags={Array.isArray(props.guest.tags) ? props.guest.tags : []}
@ -622,44 +712,51 @@ export function GuestRow(props: GuestRowProps) {
</td>
{/* Type */}
<td class="px-2 py-1 align-middle">
<div class="flex justify-center">
<span
class={`inline-block px-1 py-0.5 text-[10px] font-medium rounded whitespace-nowrap ${props.guest.type === 'qemu'
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300'
: 'bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300'
}`}
title={isVM(props.guest) ? 'Virtual Machine' : 'LXC Container'}
>
{isVM(props.guest) ? 'VM' : 'LXC'}
</span>
</div>
</td>
<Show when={isColVisible('type')}>
<td class="px-2 py-1 align-middle">
<div class="flex justify-center">
<span
class={`inline-block px-1 py-0.5 text-[10px] font-medium rounded whitespace-nowrap ${props.guest.type === 'qemu'
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300'
: 'bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300'
}`}
title={isVM(props.guest) ? 'Virtual Machine' : 'LXC Container'}
>
{isVM(props.guest) ? 'VM' : 'LXC'}
</span>
</div>
</td>
</Show>
{/* VMID */}
<td class="px-2 py-1 align-middle">
<div class="flex justify-center text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap">
{props.guest.vmid}
</div>
</td>
{/* Uptime */}
<td class="px-2 py-1 align-middle">
<div class="flex justify-center">
<span class={`text-xs whitespace-nowrap ${props.guest.uptime < 3600 ? 'text-orange-500' : 'text-gray-600 dark:text-gray-400'}`}>
<Show when={isRunning()} fallback="-">
<Show when={isMobile()} fallback={formatUptime(props.guest.uptime)}>
{formatUptime(props.guest.uptime, true)}
</Show>
</Show>
</span>
</div>
</td>
<Show when={isColVisible('vmid')}>
<td class="px-2 py-1 align-middle">
<div class="flex justify-center text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap">
{props.guest.vmid}
</div>
</td>
</Show>
{/* CPU */}
<td class="px-2 py-1 align-middle" style={{ "min-width": "140px" }}>
<Show when={isMobile()}>
<div class="md:hidden flex justify-center">
<Show when={isColVisible('cpu')}>
<td class="px-2 py-1 align-middle" style={{ "min-width": "140px" }}>
<Show when={isMobile()}>
<div class="md:hidden flex justify-center">
<ResponsiveMetricCell
value={cpuPercent()}
type="cpu"
resourceId={metricsKey()}
sublabel={
props.guest.cpus
? `${props.guest.cpus} ${props.guest.cpus === 1 ? 'core' : 'cores'}`
: undefined
}
isRunning={isRunning()}
showMobile={true}
/>
</div>
</Show>
<div class="hidden md:block">
<ResponsiveMetricCell
value={cpuPercent()}
type="cpu"
@ -670,127 +767,210 @@ export function GuestRow(props: GuestRowProps) {
: undefined
}
isRunning={isRunning()}
showMobile={true}
showMobile={false}
/>
</div>
</Show>
<div class="hidden md:block">
<ResponsiveMetricCell
value={cpuPercent()}
type="cpu"
resourceId={metricsKey()}
sublabel={
props.guest.cpus
? `${props.guest.cpus} ${props.guest.cpus === 1 ? 'core' : 'cores'}`
: undefined
}
isRunning={isRunning()}
showMobile={false}
/>
</div>
</td>
</td>
</Show>
{/* Memory */}
<td class="px-2 py-1 align-middle" style={{ "min-width": "140px" }}>
<div title={memoryTooltip() ?? undefined}>
<Show when={isMobile()}>
<div class="md:hidden flex justify-center">
<ResponsiveMetricCell
value={memPercent()}
type="memory"
resourceId={metricsKey()}
sublabel={memoryUsageLabel()}
isRunning={isRunning()}
showMobile={true}
<Show when={isColVisible('memory')}>
<td class="px-2 py-1 align-middle" style={{ "min-width": "140px" }}>
<div title={memoryTooltip() ?? undefined}>
<Show when={isMobile()}>
<div class="md:hidden flex justify-center">
<ResponsiveMetricCell
value={memPercent()}
type="memory"
resourceId={metricsKey()}
sublabel={memoryUsageLabel()}
isRunning={isRunning()}
showMobile={true}
/>
</div>
</Show>
<div class="hidden md:block">
<StackedMemoryBar
used={props.guest.memory?.used || 0}
total={props.guest.memory?.total || 0}
balloon={props.guest.memory?.balloon || 0}
swapUsed={props.guest.memory?.swapUsed || 0}
swapTotal={props.guest.memory?.swapTotal || 0}
/>
</div>
</div>
</td>
</Show>
{/* Disk */}
<Show when={isColVisible('disk')}>
<td class="px-2 py-1 align-middle" style={{ "min-width": "140px" }}>
<Show
when={hasDiskUsage()}
fallback={
<div class="flex justify-center">
<span class="text-xs text-gray-400 cursor-help" title={getDiskStatusTooltip()}>
-
</span>
</div>
}
>
<Show when={isMobile()}>
<div class="md:hidden flex justify-center text-xs text-gray-600 dark:text-gray-400">
{formatPercent(diskPercent())}
</div>
</Show>
<div class={isMobile() ? 'hidden md:block' : ''}>
<StackedDiskBar
disks={props.guest.disks}
aggregateDisk={props.guest.disk}
/>
</div>
</Show>
<div class="hidden md:block">
<StackedMemoryBar
used={props.guest.memory?.used || 0}
total={props.guest.memory?.total || 0}
balloon={props.guest.memory?.balloon || 0}
swapUsed={props.guest.memory?.swapUsed || 0}
swapTotal={props.guest.memory?.swapTotal || 0}
/>
</div>
</div>
</td>
</td>
</Show>
{/* Disk */}
<td class="px-2 py-1 align-middle" style={{ "min-width": "140px" }}>
<Show
when={hasDiskUsage()}
fallback={
<div class="flex justify-center">
<span class="text-xs text-gray-400 cursor-help" title={getDiskStatusTooltip()}>
-
{/* IP Address with Network Tooltip */}
<Show when={isColVisible('ip')}>
<td class="px-2 py-1 align-middle">
<div class="flex justify-center">
<Show when={ipAddresses().length > 0 || hasNetworkInterfaces()} fallback={<span class="text-xs text-gray-400">-</span>}>
<NetworkInfoCell
ipAddresses={ipAddresses()}
networkInterfaces={networkInterfaces()}
/>
</Show>
</div>
</td>
</Show>
{/* Uptime */}
<Show when={isColVisible('uptime')}>
<td class="px-2 py-1 align-middle">
<div class="flex justify-center">
<span class={`text-xs whitespace-nowrap ${props.guest.uptime < 3600 ? 'text-orange-500' : 'text-gray-600 dark:text-gray-400'}`}>
<Show when={isRunning()} fallback="-">
<Show when={isMobile()} fallback={formatUptime(props.guest.uptime)}>
{formatUptime(props.guest.uptime, true)}
</Show>
</Show>
</span>
</div>
</td>
</Show>
{/* Node - NEW */}
<Show when={isColVisible('node')}>
<td class="px-2 py-1 align-middle">
<div class="flex justify-center">
<span class="text-xs text-gray-600 dark:text-gray-400 truncate max-w-[80px]" title={props.guest.node}>
{props.guest.node}
</span>
</div>
</td>
</Show>
{/* Backup Status - NEW */}
<Show when={isColVisible('backup')}>
<td class="px-2 py-1 align-middle">
<div class="flex justify-center">
<Show when={!props.guest.template}>
{(() => {
const info = getBackupInfo(props.guest.lastBackup);
const config = BACKUP_STATUS_CONFIG[info.status];
return (
<span
class={`inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded ${config.bgColor} ${config.color}`}
title={info.status === 'never' ? 'No backup found' : `Last backup: ${info.ageFormatted}`}
>
{info.status === 'fresh' && 'OK'}
{info.status === 'stale' && info.ageFormatted}
{info.status === 'critical' && info.ageFormatted}
{info.status === 'never' && 'Never'}
</span>
);
})()}
</Show>
<Show when={props.guest.template}>
<span class="text-xs text-gray-400">-</span>
</Show>
</div>
</td>
</Show>
{/* OS - NEW */}
<Show when={isColVisible('os')}>
<td class="px-2 py-1 align-middle">
<div class="flex justify-center">
<Show when={hasOsInfo()} fallback={<span class="text-xs text-gray-400">-</span>}>
<span
class="text-xs text-gray-600 dark:text-gray-400 truncate max-w-[100px]"
title={`${osName()} ${osVersion()}`}
>
{osName() || osVersion()}
</span>
</div>
}
>
<Show when={isMobile()}>
<div class="md:hidden flex justify-center text-xs text-gray-600 dark:text-gray-400">
{formatPercent(diskPercent())}
</div>
</Show>
<div class={isMobile() ? 'hidden md:block' : ''}>
<StackedDiskBar
disks={props.guest.disks}
aggregateDisk={props.guest.disk}
</Show>
</div>
</td>
</Show>
{/* Tags */}
<Show when={isColVisible('tags')}>
<td class="px-2 py-1 align-middle">
<div class="flex justify-center" onClick={(event) => event.stopPropagation()}>
<TagBadges
tags={Array.isArray(props.guest.tags) ? props.guest.tags : []}
maxVisible={2}
onTagClick={props.onTagClick}
activeSearch={props.activeSearch}
/>
</div>
</Show>
</td>
</td>
</Show>
{/* Disk Read */}
<td class="px-2 py-1 align-middle">
<div class="flex justify-center whitespace-nowrap">
<Show when={isRunning()} fallback={<span class="text-xs text-gray-400">-</span>}>
<span class={`text-xs ${getIOColorClass(diskRead())}`}>{formatSpeed(diskRead())}</span>
</Show>
</div>
</td>
<Show when={isColVisible('diskRead')}>
<td class="px-2 py-1 align-middle">
<div class="flex justify-center whitespace-nowrap">
<Show when={isRunning()} fallback={<span class="text-xs text-gray-400">-</span>}>
<span class={`text-xs ${getIOColorClass(diskRead())}`}>{formatSpeed(diskRead())}</span>
</Show>
</div>
</td>
</Show>
{/* Disk Write */}
<td class="px-2 py-1 align-middle">
<div class="flex justify-center whitespace-nowrap">
<Show when={isRunning()} fallback={<span class="text-xs text-gray-400">-</span>}>
<span class={`text-xs ${getIOColorClass(diskWrite())}`}>{formatSpeed(diskWrite())}</span>
</Show>
</div>
</td>
<Show when={isColVisible('diskWrite')}>
<td class="px-2 py-1 align-middle">
<div class="flex justify-center whitespace-nowrap">
<Show when={isRunning()} fallback={<span class="text-xs text-gray-400">-</span>}>
<span class={`text-xs ${getIOColorClass(diskWrite())}`}>{formatSpeed(diskWrite())}</span>
</Show>
</div>
</td>
</Show>
{/* Net In */}
<td class="px-2 py-1 align-middle">
<div class="flex justify-center whitespace-nowrap">
<Show when={isRunning()} fallback={<span class="text-xs text-gray-400">-</span>}>
<span class={`text-xs ${getIOColorClass(networkIn())}`}>{formatSpeed(networkIn())}</span>
</Show>
</div>
</td>
<Show when={isColVisible('netIn')}>
<td class="px-2 py-1 align-middle">
<div class="flex justify-center whitespace-nowrap">
<Show when={isRunning()} fallback={<span class="text-xs text-gray-400">-</span>}>
<span class={`text-xs ${getIOColorClass(networkIn())}`}>{formatSpeed(networkIn())}</span>
</Show>
</div>
</td>
</Show>
{/* Net Out */}
<td class="px-2 py-1 align-middle">
<div class="flex justify-center whitespace-nowrap">
<Show when={isRunning()} fallback={<span class="text-xs text-gray-400">-</span>}>
<span class={`text-xs ${getIOColorClass(networkOut())}`}>{formatSpeed(networkOut())}</span>
</Show>
</div>
</td>
</tr>
<Show when={drawerOpen() && canShowDrawer()}>
<tr class="bg-gray-50 dark:bg-gray-900/50">
<td colspan={totalColumns} class="p-4 border-b border-gray-200 dark:border-gray-700">
<GuestDrawer
guest={props.guest}
metricsKey={metricsKey()}
onClose={() => setCurrentlyExpandedGuestId(null)}
/>
<Show when={isColVisible('netOut')}>
<td class="px-2 py-1 align-middle">
<div class="flex justify-center whitespace-nowrap">
<Show when={isRunning()} fallback={<span class="text-xs text-gray-400">-</span>}>
<span class={`text-xs ${getIOColorClass(networkOut())}`}>{formatSpeed(networkOut())}</span>
</Show>
</div>
</td>
</tr>
</Show>
</>
</Show>
</tr>
);
}

View file

@ -122,9 +122,14 @@ export function StackedDiskBar(props: StackedDiskBarProps) {
});
});
// Check if we have any disk details to show
const hasDisks = createMemo(() => {
return props.disks && props.disks.length > 0;
});
// Generate tooltip content
const tooltipContent = createMemo(() => {
if (hasMultipleDisks() && props.disks) {
if (hasDisks() && props.disks) {
return props.disks.map((disk, idx) => {
const percent = disk.total > 0 ? (disk.used / disk.total) * 100 : 0;
const label = disk.mountpoint || disk.device || `Disk ${idx + 1}`;
@ -139,6 +144,17 @@ export function StackedDiskBar(props: StackedDiskBarProps) {
};
});
}
// Fallback for aggregate disk
if (props.aggregateDisk && props.aggregateDisk.total > 0) {
const percent = (props.aggregateDisk.used / props.aggregateDisk.total) * 100;
return [{
label: 'Total',
used: formatBytes(props.aggregateDisk.used, 0),
total: formatBytes(props.aggregateDisk.total, 0),
percent: formatPercent(percent),
color: getUsageColor(percent),
}];
}
return [];
});
@ -162,7 +178,7 @@ export function StackedDiskBar(props: StackedDiskBarProps) {
});
const handleMouseEnter = (e: MouseEvent) => {
if (hasMultipleDisks()) {
if (tooltipContent().length > 0) {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
setTooltipPos({ x: rect.left + rect.width / 2, y: rect.top });
setShowTooltip(true);
@ -227,8 +243,8 @@ export function StackedDiskBar(props: StackedDiskBarProps) {
</span>
</div>
{/* Tooltip for multi-disk breakdown */}
<Show when={showTooltip() && hasMultipleDisks()}>
{/* Tooltip for disk breakdown */}
<Show when={showTooltip() && tooltipContent().length > 0}>
<Portal mount={document.body}>
<div
class="fixed z-[9999] pointer-events-none"
@ -240,13 +256,13 @@ export function StackedDiskBar(props: StackedDiskBarProps) {
>
<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
{hasMultipleDisks() ? 'Disk Breakdown' : 'Disk Usage'}
</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]"
class="truncate max-w-[100px]"
style={{ color: item.color }}
>
{item.label}

View file

@ -1379,8 +1379,8 @@ const DockerContainerRow: Component<{
<tr>
<td colspan={DOCKER_COLUMNS.length} class="p-0">
<div class="w-0 min-w-full bg-gray-50 dark:bg-gray-900/50 px-4 py-3 overflow-hidden">
<div class="flex flex-wrap justify-start gap-3">
<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="flex flex-wrap gap-3 [&>*]:flex-1 [&>*]:basis-[calc(25%-0.75rem)] [&>*]:min-w-[200px] [&>*]:max-w-full">
<div class="rounded border border-gray-200 bg-white/70 p-3 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">
Summary
</div>
@ -1509,7 +1509,7 @@ const DockerContainerRow: Component<{
</Show>
</div>
<Show when={container.ports && container.ports.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="rounded border border-gray-200 bg-white/70 p-3 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">
Ports
</div>
@ -1529,7 +1529,7 @@ const DockerContainerRow: Component<{
</Show>
<Show when={container.networks && container.networks.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="rounded border border-gray-200 bg-white/70 p-3 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">
Networks
</div>
@ -1556,7 +1556,7 @@ const DockerContainerRow: Component<{
</Show>
<Show when={hasPodmanMetadata()}>
<div class="min-w-[220px] flex-1 rounded border border-purple-200 bg-white/70 p-2 shadow-sm dark:border-purple-700/60 dark:bg-purple-950/20">
<div class="rounded border border-purple-200 bg-white/70 p-3 shadow-sm dark:border-purple-700/60 dark:bg-purple-950/20">
<div class="text-[11px] font-medium uppercase tracking-wide text-purple-700 dark:text-purple-200">
Podman Metadata
</div>
@ -1590,7 +1590,7 @@ const DockerContainerRow: Component<{
</Show>
<Show when={hasBlockIo()}>
<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="rounded border border-gray-200 bg-white/70 p-3 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">
Block I/O
</div>
@ -1626,7 +1626,7 @@ const DockerContainerRow: Component<{
</Show>
<Show when={hasMounts()}>
<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="rounded border border-gray-200 bg-white/70 p-3 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">
Mounts
</div>
@ -1691,7 +1691,7 @@ const DockerContainerRow: Component<{
</Show>
<Show when={container.labels && Object.keys(container.labels).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="rounded border border-gray-200 bg-white/70 p-3 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">
Labels
</div>
@ -2155,8 +2155,8 @@ const DockerServiceRow: Component<{
<tr>
<td colspan={DOCKER_COLUMNS.length} class="p-0">
<div class="w-0 min-w-full bg-gray-50 dark:bg-gray-900/60 px-4 py-3 overflow-hidden">
<div class="flex flex-wrap justify-start gap-3">
<div class="min-w-[320px] flex-1 rounded border border-gray-200 bg-white/70 p-3 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="space-y-3">
<div class="rounded border border-gray-200 bg-white/70 p-3 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
<div class="flex items-center justify-between text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200">
<span>Tasks</span>
<span class="text-[10px] font-normal text-gray-500 dark:text-gray-400">

View file

@ -608,64 +608,56 @@ export const HostsOverview: Component<HostsOverviewProps> = (props) => {
<tr>
<td colspan={8} class="p-0">
<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">
<div class="flex flex-wrap gap-3 [&>*]:flex-1 [&>*]:basis-[calc(25%-0.75rem)] [&>*]:min-w-[200px] [&>*]:max-w-full">
{/* 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">
<div class="rounded border border-gray-200 bg-white/70 p-3 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 mb-2">System</div>
<div class="space-y-1.5 text-[11px]">
<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 class="flex items-center justify-between">
<span class="text-gray-500 dark:text-gray-400">CPUs</span>
<span class="font-medium text-gray-700 dark:text-gray-200">{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 class="flex items-center justify-between">
<span class="text-gray-500 dark:text-gray-400">Load Avg</span>
<span class="font-medium text-gray-700 dark:text-gray-200">{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 class="flex items-center justify-between">
<span class="text-gray-500 dark:text-gray-400">Arch</span>
<span class="font-medium text-gray-700 dark:text-gray-200">{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 class="flex items-center justify-between">
<span class="text-gray-500 dark:text-gray-400">Kernel</span>
<span class="font-medium text-gray-700 dark:text-gray-200 truncate ml-2">{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 class="flex items-center justify-between">
<span class="text-gray-500 dark:text-gray-400">Agent</span>
<span class="font-medium text-gray-700 dark:text-gray-200">{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>
{/* Temperature Sensors */}
<Show when={host.sensors?.temperatureCelsius && Object.keys(host.sensors.temperatureCelsius).length > 0}>
<div class="rounded border border-gray-200 bg-white/70 p-3 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 mb-2">Temperatures</div>
<div class="space-y-1.5 text-[11px]">
<For each={Object.entries(host.sensors!.temperatureCelsius!).slice(0, 5)}>
{([name, temp]) => (
<div class="flex items-center justify-between">
<span class="text-gray-500 dark:text-gray-400 truncate mr-2">{name}</span>
<span class={`font-medium ${temp > 80 ? 'text-red-600 dark:text-red-400' : 'text-gray-700 dark:text-gray-200'}`}>
{temp.toFixed(1)}°C
</span>
</div>
)}
</For>
@ -673,19 +665,64 @@ export const HostsOverview: Component<HostsOverviewProps> = (props) => {
</div>
</Show>
{/* RAID Arrays */}
<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';
if (isHealthy()) return 'text-green-600 dark:text-green-400';
return 'text-gray-700 dark:text-gray-200';
};
return (
<div class="rounded border border-gray-200 bg-white/70 p-3 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 mb-2">
RAID {array.level.replace('raid', '')} - {array.device}
</div>
<div class="space-y-1.5 text-[11px]">
<div class="flex items-center justify-between">
<span class="text-gray-500 dark:text-gray-400">State</span>
<span class={`font-medium ${stateColor()}`}>{array.state}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-gray-500 dark:text-gray-400">Devices</span>
<span class="font-medium text-gray-700 dark:text-gray-200">
{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">
<span class="text-gray-500 dark:text-gray-400">Rebuild</span>
<span class="font-medium text-amber-600 dark:text-amber-400">
{array.rebuildPercent.toFixed(1)}%
</span>
</div>
</Show>
</div>
</div>
);
}}
</For>
{/* 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]">
<div class="rounded border border-gray-200 bg-white/70 p-3 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 mb-2">Disks</div>
<div class="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="rounded border border-dashed border-gray-200 p-2 dark:border-gray-700">
<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">
<span class="text-[10px] text-gray-500 dark:text-gray-400 ml-2">
{formatBytes(disk.used ?? 0, 0)} / {formatBytes(disk.total ?? 0, 0)}
</span>
</div>
@ -709,14 +746,14 @@ export const HostsOverview: Component<HostsOverviewProps> = (props) => {
{/* Disk I/O */}
<Show when={host.diskIO && host.diskIO.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">Disk I/O</div>
<div class="mt-2 space-y-2 text-[11px]">
<For each={host.diskIO?.slice(0, 4)}>
<div class="rounded border border-gray-200 bg-white/70 p-3 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 mb-2">Disk I/O</div>
<div class="space-y-2 text-[11px]">
<For each={host.diskIO?.slice(0, 3)}>
{(io) => (
<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">{io.device}</div>
<div class="mt-1 grid grid-cols-2 gap-x-2 gap-y-0.5 text-[10px]">
<div class="rounded border border-dashed border-gray-200 p-2 dark:border-gray-700">
<div class="font-medium text-gray-700 dark:text-gray-200 mb-1">{io.device}</div>
<div class="grid grid-cols-2 gap-x-2 gap-y-0.5 text-[10px]">
<div class="flex items-center justify-between">
<span class="text-gray-500 dark:text-gray-400">Read:</span>
<span class="text-gray-600 dark:text-gray-300">{formatBytes(io.readBytes ?? 0, 1)}</span>
@ -725,14 +762,6 @@ export const HostsOverview: Component<HostsOverviewProps> = (props) => {
<span class="text-gray-500 dark:text-gray-400">Write:</span>
<span class="text-gray-600 dark:text-gray-300">{formatBytes(io.writeBytes ?? 0, 1)}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-gray-500 dark:text-gray-400">Read Ops:</span>
<span class="text-gray-600 dark:text-gray-300">{formatNumber(io.readOps ?? 0)}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-gray-500 dark:text-gray-400">Write Ops:</span>
<span class="text-gray-600 dark:text-gray-300">{formatNumber(io.writeOps ?? 0)}</span>
</div>
</div>
</div>
)}
@ -741,76 +770,31 @@ export const HostsOverview: Component<HostsOverviewProps> = (props) => {
</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
</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>
{/* Network Interfaces */}
<Show when={host.networkInterfaces && host.networkInterfaces.length > 0}>
<div class="rounded border border-gray-200 bg-white/70 p-3 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 mb-2">Network</div>
<div class="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">
<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">
<For each={iface.addresses}>
{(addr) => (
<span class="inline-block rounded bg-blue-100 px-1.5 py-0.5 text-[10px] text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
{addr}
</span>
)}
</For>
</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>
)}
</For>
</div>
</div>
</Show>
</div>

View file

@ -0,0 +1,127 @@
import { Component, Show, For, createSignal, onCleanup, createEffect } from 'solid-js';
import type { ColumnDef } from '@/hooks/useColumnVisibility';
interface ColumnPickerProps {
/** Columns that can be toggled */
columns: ColumnDef[];
/** Check if a column is currently hidden */
isHidden: (id: string) => boolean;
/** Toggle a column's visibility */
onToggle: (id: string) => void;
/** Reset all columns to visible */
onReset?: () => void;
}
export const ColumnPicker: Component<ColumnPickerProps> = (props) => {
const [isOpen, setIsOpen] = createSignal(false);
let containerRef: HTMLDivElement | undefined;
// Close on click outside
const handleClickOutside = (e: MouseEvent) => {
if (containerRef && !containerRef.contains(e.target as Node)) {
setIsOpen(false);
}
};
createEffect(() => {
if (isOpen()) {
document.addEventListener('mousedown', handleClickOutside);
} else {
document.removeEventListener('mousedown', handleClickOutside);
}
});
onCleanup(() => {
document.removeEventListener('mousedown', handleClickOutside);
});
// Count how many are hidden
const hiddenCount = () => props.columns.filter(c => props.isHidden(c.id)).length;
return (
<div ref={containerRef} class="relative">
<button
type="button"
onClick={() => setIsOpen(!isOpen())}
class={`inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium rounded-lg transition-all
${isOpen()
? 'bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300'
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
}`}
title="Choose which columns to display"
>
{/* Columns icon */}
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 4v16M15 4v16M4 9h16M4 15h16" />
</svg>
<span>Columns</span>
<Show when={hiddenCount() > 0}>
<span class="ml-0.5 px-1.5 py-0.5 text-[10px] font-semibold rounded-full bg-gray-200 dark:bg-gray-600 text-gray-700 dark:text-gray-200">
{hiddenCount()} hidden
</span>
</Show>
</button>
<Show when={isOpen()}>
<div
class="absolute right-0 mt-1 w-56 rounded-lg border border-gray-200 bg-white shadow-xl z-50
dark:border-gray-700 dark:bg-gray-800"
>
<div class="px-3 py-2 border-b border-gray-100 dark:border-gray-700">
<div class="flex items-center justify-between">
<span class="text-xs font-medium text-gray-700 dark:text-gray-200">Show Columns</span>
<Show when={props.onReset && hiddenCount() > 0}>
<button
type="button"
onClick={() => props.onReset?.()}
class="text-[10px] text-blue-600 dark:text-blue-400 hover:underline"
>
Show all
</button>
</Show>
</div>
</div>
<div class="max-h-64 overflow-y-auto py-1">
<For each={props.columns}>
{(col) => {
const isChecked = () => !props.isHidden(col.id);
return (
<label
class="flex items-center gap-2.5 px-3 py-2 cursor-pointer
hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
>
<input
type="checkbox"
checked={isChecked()}
onChange={() => props.onToggle(col.id)}
class="w-3.5 h-3.5 rounded border-gray-300 text-blue-600
focus:ring-blue-500 focus:ring-offset-0
dark:border-gray-600 dark:bg-gray-700 dark:checked:bg-blue-600"
/>
<span class={`text-sm ${isChecked() ? 'text-gray-700 dark:text-gray-200' : 'text-gray-400 dark:text-gray-500'}`}>
{col.label}
</span>
<Show when={col.priority !== 'essential'}>
<span class="ml-auto text-[10px] text-gray-400 dark:text-gray-500">
{col.priority === 'secondary' && 'md+'}
{col.priority === 'supplementary' && 'lg+'}
{col.priority === 'detailed' && 'xl+'}
</span>
</Show>
</label>
);
}}
</For>
</div>
<Show when={props.columns.length === 0}>
<div class="px-3 py-4 text-xs text-gray-500 dark:text-gray-400 text-center">
No columns available to toggle at this screen size
</div>
</Show>
</div>
</Show>
</div>
);
};

View file

@ -0,0 +1,138 @@
import { createMemo, Accessor } from 'solid-js';
import { usePersistentSignal } from './usePersistentSignal';
import { useBreakpoint, type ColumnPriority, PRIORITY_BREAKPOINTS, type Breakpoint } from './useBreakpoint';
export interface ColumnDef {
id: string;
label: string;
priority: ColumnPriority;
toggleable?: boolean;
minWidth?: string;
maxWidth?: string;
flex?: number;
sortKey?: string;
}
const BREAKPOINT_ORDER: Breakpoint[] = ['xs', 'sm', 'md', 'lg', 'xl', '2xl'];
function breakpointIndex(bp: Breakpoint): number {
return BREAKPOINT_ORDER.indexOf(bp);
}
/**
* Hook for managing column visibility with persistence and responsive behavior.
*
* Columns are shown if:
* 1. The current breakpoint supports their priority level, AND
* 2. The user hasn't explicitly hidden them (for toggleable columns)
*
* @param storageKey - localStorage key for persisting user preferences
* @param columns - Array of column definitions
*/
export function useColumnVisibility(
storageKey: string,
columns: ColumnDef[]
) {
const { breakpoint } = useBreakpoint();
// Get list of toggleable column IDs
const toggleableIds = columns.filter(c => c.toggleable).map(c => c.id);
// Persist hidden columns to localStorage
const [hiddenColumns, setHiddenColumns] = usePersistentSignal<string[]>(
storageKey,
[],
{
serialize: (arr) => JSON.stringify(arr),
deserialize: (str) => {
try {
const parsed = JSON.parse(str);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
},
}
);
// Check if a column is hidden by user preference
const isHiddenByUser = (id: string): boolean => {
return hiddenColumns().includes(id);
};
// Check if breakpoint supports showing this column
const hasSpaceForColumn = (col: ColumnDef): boolean => {
const minBreakpoint = PRIORITY_BREAKPOINTS[col.priority];
return breakpointIndex(breakpoint()) >= breakpointIndex(minBreakpoint);
};
// Toggle a column's visibility
const toggle = (id: string) => {
const current = hiddenColumns();
if (current.includes(id)) {
setHiddenColumns(current.filter(c => c !== id));
} else {
setHiddenColumns([...current, id]);
}
};
// Show a column (remove from hidden)
const show = (id: string) => {
setHiddenColumns(hiddenColumns().filter(c => c !== id));
};
// Hide a column (add to hidden)
const hide = (id: string) => {
if (!hiddenColumns().includes(id)) {
setHiddenColumns([...hiddenColumns(), id]);
}
};
// Reset to defaults (show all)
const resetToDefaults = () => {
setHiddenColumns([]);
};
// Compute visible columns based on breakpoint and user preferences
const visibleColumns: Accessor<ColumnDef[]> = createMemo(() => {
return columns.filter(col => {
// Always show essential columns regardless of breakpoint
if (col.priority === 'essential') return true;
// Check if screen has space for this priority level
if (!hasSpaceForColumn(col)) return false;
// If toggleable, check user preference
if (col.toggleable && isHiddenByUser(col.id)) return false;
return true;
});
});
// Get columns that could be toggled at the current breakpoint
// (i.e., screen is wide enough to show them)
const availableToggles: Accessor<ColumnDef[]> = createMemo(() => {
return columns.filter(col => {
if (!col.toggleable) return false;
return hasSpaceForColumn(col);
});
});
// Check if a specific column is currently visible
const isColumnVisible = (id: string): boolean => {
return visibleColumns().some(col => col.id === id);
};
return {
visibleColumns,
availableToggles,
toggleableIds,
hiddenColumns,
isColumnVisible,
isHiddenByUser,
toggle,
show,
hide,
resetToDefaults,
};
}

View file

@ -108,4 +108,9 @@ export const STORAGE_KEYS = {
// Metrics display
METRICS_VIEW_MODE: 'metricsViewMode', // 'bars' | 'sparklines'
// Column visibility
DASHBOARD_HIDDEN_COLUMNS: 'dashboardHiddenColumns',
HOSTS_HIDDEN_COLUMNS: 'hostsHiddenColumns',
DOCKER_HIDDEN_COLUMNS: 'dockerHiddenColumns',
} as const;