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:
parent
309302a793
commit
a15dc6b899
10 changed files with 1023 additions and 545 deletions
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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={() => {
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
127
frontend-modern/src/components/shared/ColumnPicker.tsx
Normal file
127
frontend-modern/src/components/shared/ColumnPicker.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
138
frontend-modern/src/hooks/useColumnVisibility.ts
Normal file
138
frontend-modern/src/hooks/useColumnVisibility.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue