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 { createSignal, createMemo, createEffect, For, Show, onMount } from 'solid-js';
|
||||||
import { useNavigate } from '@solidjs/router';
|
import { useNavigate } from '@solidjs/router';
|
||||||
import type { VM, Container, Node } from '@/types/api';
|
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 { useWebSocket } from '@/App';
|
||||||
import { getAlertStyles } from '@/utils/alerts';
|
import { getAlertStyles } from '@/utils/alerts';
|
||||||
import { useAlertsActivation } from '@/stores/alertsActivation';
|
import { useAlertsActivation } from '@/stores/alertsActivation';
|
||||||
|
|
@ -19,8 +19,10 @@ import { isNodeOnline, OFFLINE_HEALTH_STATUSES, DEGRADED_HEALTH_STATUSES } from
|
||||||
import { getNodeDisplayName } from '@/utils/nodes';
|
import { getNodeDisplayName } from '@/utils/nodes';
|
||||||
import { logger } from '@/utils/logger';
|
import { logger } from '@/utils/logger';
|
||||||
import { usePersistentSignal } from '@/hooks/usePersistentSignal';
|
import { usePersistentSignal } from '@/hooks/usePersistentSignal';
|
||||||
|
import { useColumnVisibility } from '@/hooks/useColumnVisibility';
|
||||||
import { STORAGE_KEYS } from '@/utils/localStorage';
|
import { STORAGE_KEYS } from '@/utils/localStorage';
|
||||||
import { getBackupInfo } from '@/utils/format';
|
import { getBackupInfo } from '@/utils/format';
|
||||||
|
import { aiChatStore } from '@/stores/aiChat';
|
||||||
|
|
||||||
type GuestMetadataRecord = Record<string, GuestMetadata>;
|
type GuestMetadataRecord = Record<string, GuestMetadata>;
|
||||||
type IdleCallbackHandle = number;
|
type IdleCallbackHandle = number;
|
||||||
|
|
@ -265,8 +267,16 @@ export function Dashboard(props: DashboardProps) {
|
||||||
const [sortKey, setSortKey] = createSignal<keyof (VM | Container) | null>('vmid');
|
const [sortKey, setSortKey] = createSignal<keyof (VM | Container) | null>('vmid');
|
||||||
const [sortDirection, setSortDirection] = createSignal<'asc' | 'desc'>('asc');
|
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
|
// 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)
|
// Load all guest metadata on mount (single API call for all guests)
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
|
|
@ -457,6 +467,12 @@ export function Dashboard(props: DashboardProps) {
|
||||||
}
|
}
|
||||||
} else if (!isInputField && e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
} 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
|
// 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
|
// Expand filters section if collapsed
|
||||||
if (!showFilters()) {
|
if (!showFilters()) {
|
||||||
setShowFilters(true);
|
setShowFilters(true);
|
||||||
|
|
@ -827,6 +843,10 @@ export function Dashboard(props: DashboardProps) {
|
||||||
setSortKey={setSortKey}
|
setSortKey={setSortKey}
|
||||||
setSortDirection={setSortDirection}
|
setSortDirection={setSortDirection}
|
||||||
searchInputRef={(el) => (searchInputRef = el)}
|
searchInputRef={(el) => (searchInputRef = el)}
|
||||||
|
availableColumns={columnVisibility.availableToggles()}
|
||||||
|
isColumnHidden={columnVisibility.isHiddenByUser}
|
||||||
|
onColumnToggle={columnVisibility.toggle}
|
||||||
|
onColumnReset={columnVisibility.resetToDefaults}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Loading State */}
|
{/* Loading State */}
|
||||||
|
|
@ -954,93 +974,25 @@ export function Dashboard(props: DashboardProps) {
|
||||||
<table class="w-full border-collapse whitespace-nowrap">
|
<table class="w-full border-collapse whitespace-nowrap">
|
||||||
<thead>
|
<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">
|
<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 */}
|
<For each={visibleColumns()}>
|
||||||
<th
|
{(col) => {
|
||||||
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"
|
const isFirst = () => col.id === visibleColumns()[0]?.id;
|
||||||
onClick={() => handleSort('name')}
|
const sortKeyForCol = col.sortKey as keyof (VM | Container) | undefined;
|
||||||
>
|
const isSortable = !!sortKeyForCol;
|
||||||
Name {sortKey() === 'name' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
const isSorted = () => sortKeyForCol && sortKey() === sortKeyForCol;
|
||||||
</th>
|
|
||||||
|
|
||||||
{/* Type */}
|
return (
|
||||||
<th
|
<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"
|
class={`py-1 text-[11px] sm:text-xs font-medium uppercase tracking-wider whitespace-nowrap
|
||||||
onClick={() => handleSort('type')}
|
${isFirst() ? 'pl-4 pr-2 text-left' : 'px-2 text-center'}
|
||||||
>
|
${isSortable ? 'cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600' : ''}`}
|
||||||
Type {sortKey() === 'type' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
onClick={() => isSortable && handleSort(sortKeyForCol!)}
|
||||||
</th>
|
>
|
||||||
|
{col.label} {isSorted() && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||||
{/* VMID */}
|
</th>
|
||||||
<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')}
|
</For>
|
||||||
>
|
|
||||||
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>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
|
@ -1059,7 +1011,7 @@ export function Dashboard(props: DashboardProps) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Show when={node && groupingMode() === 'grouped'}>
|
<Show when={node && groupingMode() === 'grouped'}>
|
||||||
<NodeGroupHeader node={node!} renderAs="tr" colspan={totalColumns} />
|
<NodeGroupHeader node={node!} renderAs="tr" colspan={totalColumns()} />
|
||||||
</Show>
|
</Show>
|
||||||
<For each={guests} fallback={<></>}>
|
<For each={guests} fallback={<></>}>
|
||||||
{(guest) => {
|
{(guest) => {
|
||||||
|
|
@ -1080,6 +1032,7 @@ export function Dashboard(props: DashboardProps) {
|
||||||
parentNodeOnline={parentNodeOnline}
|
parentNodeOnline={parentNodeOnline}
|
||||||
onCustomUrlUpdate={handleCustomUrlUpdate}
|
onCustomUrlUpdate={handleCustomUrlUpdate}
|
||||||
isGroupedView={groupingMode() === 'grouped'}
|
isGroupedView={groupingMode() === 'grouped'}
|
||||||
|
visibleColumnIds={visibleColumnIds()}
|
||||||
/>
|
/>
|
||||||
</ComponentErrorBoundary>
|
</ComponentErrorBoundary>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ import { Component, Show, For, createSignal, onMount, createEffect, onCleanup }
|
||||||
import { Card } from '@/components/shared/Card';
|
import { Card } from '@/components/shared/Card';
|
||||||
import { SearchTipsPopover } from '@/components/shared/SearchTipsPopover';
|
import { SearchTipsPopover } from '@/components/shared/SearchTipsPopover';
|
||||||
import { MetricsViewToggle } from '@/components/shared/MetricsViewToggle';
|
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 { STORAGE_KEYS } from '@/utils/localStorage';
|
||||||
import { createSearchHistoryManager } from '@/utils/searchHistory';
|
import { createSearchHistoryManager } from '@/utils/searchHistory';
|
||||||
|
|
||||||
|
|
@ -20,6 +22,11 @@ interface DashboardFilterProps {
|
||||||
setSortKey: (value: string) => void;
|
setSortKey: (value: string) => void;
|
||||||
setSortDirection: (value: string) => void;
|
setSortDirection: (value: string) => void;
|
||||||
searchInputRef?: (el: HTMLInputElement) => 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) => {
|
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>
|
<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 */}
|
{/* Reset Button */}
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|
|
||||||
|
|
@ -146,13 +146,11 @@ export const GuestDrawer: Component<GuestDrawerProps> = (props) => {
|
||||||
context.network_out_rate = formatSpeed(guest.networkOut);
|
context.network_out_rate = formatSpeed(guest.networkOut);
|
||||||
}
|
}
|
||||||
|
|
||||||
// OS info (VMs only)
|
// OS info (both VMs and containers can have this)
|
||||||
if (isVM(guest)) {
|
if (guest.osName) context.os_name = guest.osName;
|
||||||
if (guest.osName) context.os_name = guest.osName;
|
if (guest.osVersion) context.os_version = guest.osVersion;
|
||||||
if (guest.osVersion) context.os_version = guest.osVersion;
|
if (guest.agentVersion) context.guest_agent = guest.agentVersion;
|
||||||
if (guest.agentVersion) context.guest_agent = guest.agentVersion;
|
if (guest.ipAddresses?.length) context.ip_addresses = guest.ipAddresses;
|
||||||
if (guest.ipAddresses?.length) context.ip_addresses = guest.ipAddresses;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tags
|
// Tags
|
||||||
if (guest.tags?.length) {
|
if (guest.tags?.length) {
|
||||||
|
|
@ -196,32 +194,27 @@ export const GuestDrawer: Component<GuestDrawerProps> = (props) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasOsInfo = () => {
|
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;
|
return (props.guest.osName?.length ?? 0) > 0 || (props.guest.osVersion?.length ?? 0) > 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
const osName = () => {
|
const osName = () => {
|
||||||
if (!isVM(props.guest)) return '';
|
|
||||||
return props.guest.osName || '';
|
return props.guest.osName || '';
|
||||||
};
|
};
|
||||||
|
|
||||||
const osVersion = () => {
|
const osVersion = () => {
|
||||||
if (!isVM(props.guest)) return '';
|
|
||||||
return props.guest.osVersion || '';
|
return props.guest.osVersion || '';
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasAgentInfo = () => {
|
const hasAgentInfo = () => {
|
||||||
if (!isVM(props.guest)) return false;
|
|
||||||
return !!props.guest.agentVersion;
|
return !!props.guest.agentVersion;
|
||||||
};
|
};
|
||||||
|
|
||||||
const agentVersion = () => {
|
const agentVersion = () => {
|
||||||
if (!isVM(props.guest)) return '';
|
|
||||||
return props.guest.agentVersion || '';
|
return props.guest.agentVersion || '';
|
||||||
};
|
};
|
||||||
|
|
||||||
const ipAddresses = () => {
|
const ipAddresses = () => {
|
||||||
if (!isVM(props.guest)) return [];
|
|
||||||
return props.guest.ipAddresses || [];
|
return props.guest.ipAddresses || [];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -249,7 +242,7 @@ export const GuestDrawer: Component<GuestDrawerProps> = (props) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const networkInterfaces = () => {
|
const networkInterfaces = () => {
|
||||||
if (!isVM(props.guest)) return [];
|
// Both VMs and containers can have network interfaces
|
||||||
return props.guest.networkInterfaces || [];
|
return props.guest.networkInterfaces || [];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -259,122 +252,183 @@ export const GuestDrawer: Component<GuestDrawerProps> = (props) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
{/* Top row: metrics columns */}
|
{/* Flex layout - items grow to fill space, max ~4 per row */}
|
||||||
<div class="flex items-start gap-4">
|
<div class="flex flex-wrap gap-3 [&>*]:flex-1 [&>*]:basis-[calc(25%-0.75rem)] [&>*]:min-w-[200px] [&>*]:max-w-full">
|
||||||
{/* Left Column: Guest Overview */}
|
{/* System Info - always show */}
|
||||||
<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 text-gray-700 dark:text-gray-200">Guest Overview</div>
|
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200 mb-2">System</div>
|
||||||
<div class="mt-1 space-y-1">
|
<div class="space-y-1.5 text-[11px]">
|
||||||
<Show when={hasOsInfo()}>
|
<Show when={props.guest.cpus}>
|
||||||
<div class="flex flex-wrap items-center gap-1 text-gray-600 dark:text-gray-300">
|
<div class="flex items-center justify-between">
|
||||||
<Show when={osName().length > 0}>
|
<span class="text-gray-500 dark:text-gray-400">CPUs</span>
|
||||||
<span class="font-medium" title={osName()}>{osName()}</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>
|
||||||
<Show when={osName().length > 0 && osVersion().length > 0}>
|
<Show when={ipAddresses().length > 0}>
|
||||||
<span class="text-gray-400 dark:text-gray-500">•</span>
|
<div class="flex flex-wrap gap-1">
|
||||||
</Show>
|
<For each={ipAddresses()}>
|
||||||
<Show when={osVersion().length > 0}>
|
{(ip) => (
|
||||||
<span title={osVersion()}>{osVersion()}</span>
|
<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>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</div>
|
||||||
<Show when={hasAgentInfo()}>
|
</Show>
|
||||||
<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">
|
{/* Memory Details */}
|
||||||
Agent
|
<Show when={memoryExtraLines() && memoryExtraLines()!.length > 0}>
|
||||||
</span>
|
<div class="rounded border border-gray-200 bg-white/70 p-3 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
|
||||||
<span title={`QEMU guest agent ${agentVersion()}`}>
|
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200 mb-2">Memory</div>
|
||||||
QEMU guest agent {agentVersion()}
|
<div class="space-y-1 text-[11px] text-gray-600 dark:text-gray-300">
|
||||||
</span>
|
<For each={memoryExtraLines()!}>{(line) => <div>{line}</div>}</For>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</div>
|
||||||
<Show when={ipAddresses().length > 0}>
|
</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">
|
<div class="flex flex-wrap gap-1">
|
||||||
<For each={ipAddresses()}>
|
<For each={Array.isArray(props.guest.tags) ? props.guest.tags : (props.guest.tags?.split(',') || [])}>
|
||||||
{(ip) => (
|
{(tag) => (
|
||||||
<span
|
<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">
|
||||||
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"
|
{tag.trim()}
|
||||||
title={ip}
|
|
||||||
>
|
|
||||||
{ip}
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</Show>
|
||||||
</Show>
|
|
||||||
|
|
||||||
{/* Right Column: Filesystems */}
|
{/* Filesystems */}
|
||||||
<Show when={hasFilesystemDetails() && props.guest.disks && props.guest.disks.length > 0}>
|
<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="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 text-gray-700 dark:text-gray-200">Filesystems</div>
|
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200 mb-2">Filesystems</div>
|
||||||
<div class="mt-1 text-gray-600 dark:text-gray-300">
|
<div class="text-[11px] text-gray-600 dark:text-gray-300">
|
||||||
<DiskList
|
<DiskList
|
||||||
disks={props.guest.disks || []}
|
disks={props.guest.disks || []}
|
||||||
diskStatusReason={isVM(props.guest) ? props.guest.diskStatusReason : undefined}
|
diskStatusReason={isVM(props.guest) ? props.guest.diskStatusReason : undefined}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Show>
|
||||||
</Show>
|
|
||||||
|
|
||||||
{/* Far Right Column: Network Interfaces */}
|
{/* Network Interfaces */}
|
||||||
<Show when={hasNetworkInterfaces()}>
|
<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="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 text-gray-700 dark:text-gray-200">Network Interfaces</div>
|
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200 mb-2">Network</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="space-y-2">
|
||||||
<div class="mt-1 space-y-1 text-gray-600 dark:text-gray-300">
|
<For each={networkInterfaces().slice(0, 4)}>
|
||||||
<For each={networkInterfaces()}>
|
{(iface) => {
|
||||||
{(iface) => {
|
const addresses = iface.addresses ?? [];
|
||||||
const addresses = iface.addresses ?? [];
|
const hasTraffic = (iface.rxBytes ?? 0) > 0 || (iface.txBytes ?? 0) > 0;
|
||||||
const hasTraffic = (iface.rxBytes ?? 0) > 0 || (iface.txBytes ?? 0) > 0;
|
return (
|
||||||
return (
|
<div class="rounded border border-dashed border-gray-200 p-2 dark:border-gray-700">
|
||||||
<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 text-[11px] font-medium text-gray-700 dark:text-gray-200">
|
||||||
<div class="flex items-center gap-2 font-medium text-gray-700 dark:text-gray-200">
|
<span class="truncate">{iface.name || 'interface'}</span>
|
||||||
<span class="truncate" title={iface.name}>{iface.name || 'interface'}</span>
|
<Show when={iface.mac}>
|
||||||
<Show when={iface.mac}>
|
<span class="text-[9px] text-gray-400 dark:text-gray-500 font-normal">{iface.mac}</span>
|
||||||
<span class="text-[10px] text-gray-400 dark:text-gray-500" title={iface.mac}>
|
</Show>
|
||||||
{iface.mac}
|
</div>
|
||||||
</span>
|
<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>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<Show when={addresses.length > 0}>
|
);
|
||||||
<div class="flex flex-wrap gap-1">
|
}}
|
||||||
<For each={addresses}>
|
</For>
|
||||||
{(ip) => (
|
</div>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Show>
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom row: AI Context & Ask AI - only show when AI is enabled */}
|
{/* 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, For } from 'solid-js';
|
||||||
import { createMemo, createSignal, createEffect, Show } from 'solid-js';
|
import { Portal } from 'solid-js/web';
|
||||||
import type { VM, Container } from '@/types/api';
|
import type { VM, Container, NetworkInterface } from '@/types/api';
|
||||||
import { formatBytes, formatUptime, formatSpeed, getBackupInfo, type BackupStatus, formatPercent } from '@/utils/format';
|
import { formatBytes, formatUptime, formatSpeed, getBackupInfo, type BackupStatus, formatPercent } from '@/utils/format';
|
||||||
import { TagBadges } from './TagBadges';
|
import { TagBadges } from './TagBadges';
|
||||||
import { StackedDiskBar } from './StackedDiskBar';
|
import { StackedDiskBar } from './StackedDiskBar';
|
||||||
|
|
@ -30,8 +30,6 @@ function getIOColorClass(bytesPerSec: number): string {
|
||||||
return 'text-red-600 dark:text-red-400';
|
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
|
// Global editing state - use a signal so all components react
|
||||||
const [currentlyEditingGuestId, setCurrentlyEditingGuestId] = createSignal<string | null>(null);
|
const [currentlyEditingGuestId, setCurrentlyEditingGuestId] = createSignal<string | null>(null);
|
||||||
// Store the editing value globally so it survives re-renders
|
// 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
|
// Column configuration using the priority system
|
||||||
interface ColumnDef {
|
export interface GuestColumnDef {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
priority: ColumnPriority;
|
priority: ColumnPriority;
|
||||||
|
toggleable?: boolean;
|
||||||
minWidth?: string;
|
minWidth?: string;
|
||||||
maxWidth?: string;
|
maxWidth?: string;
|
||||||
flex?: number;
|
flex?: number;
|
||||||
|
sortKey?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GUEST_COLUMNS: ColumnDef[] = [
|
export const GUEST_COLUMNS: GuestColumnDef[] = [
|
||||||
{ id: 'name', label: 'Name', priority: 'essential', minWidth: 'auto', maxWidth: 'auto' },
|
// Essential - always visible
|
||||||
{ id: 'type', label: 'Type', priority: 'essential', minWidth: 'auto', maxWidth: 'auto' },
|
{ id: 'name', label: 'Name', priority: 'essential', sortKey: 'name' },
|
||||||
{ id: 'vmid', label: 'VMID', priority: 'essential', minWidth: 'auto', maxWidth: 'auto' },
|
{ id: 'type', label: 'Type', priority: 'essential', sortKey: 'type' },
|
||||||
{ id: 'uptime', label: 'Uptime', priority: 'essential', minWidth: 'auto', maxWidth: 'auto' },
|
{ id: 'vmid', label: 'VMID', priority: 'essential', sortKey: 'vmid' },
|
||||||
// Metric columns - fixed min/max width to match progress bar
|
|
||||||
{ id: 'cpu', label: 'CPU', priority: 'essential', minWidth: '55px', maxWidth: '156px' },
|
// Core metrics - always visible
|
||||||
{ id: 'memory', label: 'Memory', priority: 'essential', minWidth: '75px', maxWidth: '156px' },
|
{ id: 'cpu', label: 'CPU', priority: 'essential', minWidth: '55px', maxWidth: '156px', sortKey: 'cpu' },
|
||||||
{ id: 'disk', label: 'Disk', priority: 'essential', minWidth: '75px', maxWidth: '156px' },
|
{ id: 'memory', label: 'Memory', priority: 'essential', minWidth: '75px', maxWidth: '156px', sortKey: 'memory' },
|
||||||
// I/O columns - auto sizing
|
{ id: 'disk', label: 'Disk', priority: 'essential', minWidth: '75px', maxWidth: '156px', sortKey: 'disk' },
|
||||||
{ id: 'diskRead', label: 'Disk Read', priority: 'essential', minWidth: 'auto', maxWidth: 'auto' },
|
|
||||||
{ id: 'diskWrite', label: 'Disk Write', priority: 'essential', minWidth: 'auto', maxWidth: 'auto' },
|
// Secondary - visible on md+ (768px), user toggleable
|
||||||
{ id: 'netIn', label: 'Net In', priority: 'essential', minWidth: 'auto', maxWidth: 'auto' },
|
{ id: 'ip', label: 'IP', priority: 'secondary', toggleable: true },
|
||||||
{ id: 'netOut', label: 'Net Out', priority: 'essential', minWidth: 'auto', maxWidth: 'auto' },
|
{ 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 {
|
interface GuestRowProps {
|
||||||
|
|
@ -151,6 +267,8 @@ interface GuestRowProps {
|
||||||
parentNodeOnline?: boolean;
|
parentNodeOnline?: boolean;
|
||||||
onCustomUrlUpdate?: (guestId: string, url: string) => void;
|
onCustomUrlUpdate?: (guestId: string, url: string) => void;
|
||||||
isGroupedView?: boolean;
|
isGroupedView?: boolean;
|
||||||
|
/** IDs of columns that should be visible */
|
||||||
|
visibleColumnIds?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GuestRow(props: GuestRowProps) {
|
export function GuestRow(props: GuestRowProps) {
|
||||||
|
|
@ -160,7 +278,14 @@ export function GuestRow(props: GuestRowProps) {
|
||||||
// Use breakpoint hook directly for responsive behavior
|
// Use breakpoint hook directly for responsive behavior
|
||||||
const { isMobile } = useBreakpoint();
|
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 metricsKey = createMemo(() => {
|
||||||
const kind = props.guest.type === 'qemu' ? 'vm' : 'container';
|
const kind = props.guest.type === 'qemu' ? 'vm' : 'container';
|
||||||
return buildMetricKey(kind, guestId());
|
return buildMetricKey(kind, guestId());
|
||||||
|
|
@ -168,7 +293,6 @@ export function GuestRow(props: GuestRowProps) {
|
||||||
|
|
||||||
const [customUrl, setCustomUrl] = createSignal<string | undefined>(props.customUrl);
|
const [customUrl, setCustomUrl] = createSignal<string | undefined>(props.customUrl);
|
||||||
const [shouldAnimateIcon, setShouldAnimateIcon] = createSignal(false);
|
const [shouldAnimateIcon, setShouldAnimateIcon] = createSignal(false);
|
||||||
const drawerOpen = createMemo(() => currentlyExpandedGuestId() === guestId());
|
|
||||||
const editingUrlValue = createMemo(() => {
|
const editingUrlValue = createMemo(() => {
|
||||||
editingValuesVersion(); // Subscribe to changes
|
editingValuesVersion(); // Subscribe to changes
|
||||||
return editingValues.get(guestId()) || '';
|
return editingValues.get(guestId()) || '';
|
||||||
|
|
@ -239,34 +363,6 @@ export function GuestRow(props: GuestRowProps) {
|
||||||
return lines.length > 0 ? lines : undefined;
|
return lines.length > 0 ? lines : undefined;
|
||||||
});
|
});
|
||||||
const memoryTooltip = createMemo(() => memoryExtraLines()?.join('\n') ?? 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) => {
|
const startEditingUrl = (event: MouseEvent) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
|
@ -470,11 +566,7 @@ export function GuestRow(props: GuestRowProps) {
|
||||||
? ''
|
? ''
|
||||||
: 'hover:bg-gray-50 dark:hover:bg-gray-700/30';
|
: 'hover:bg-gray-50 dark:hover:bg-gray-700/30';
|
||||||
const stoppedDimming = !isRunning() ? 'opacity-60' : '';
|
const stoppedDimming = !isRunning() ? 'opacity-60' : '';
|
||||||
const clickable = canShowDrawer() ? 'cursor-pointer' : '';
|
return `${base} ${hover} ${defaultHover} ${alertBg} ${stoppedDimming}`;
|
||||||
const expanded = drawerOpen() && !hasUnacknowledgedAlert()
|
|
||||||
? 'bg-gray-50 dark:bg-gray-800/40'
|
|
||||||
: '';
|
|
||||||
return `${base} ${hover} ${defaultHover} ${alertBg} ${stoppedDimming} ${clickable} ${expanded}`;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const rowStyle = createMemo(() => {
|
const rowStyle = createMemo(() => {
|
||||||
|
|
@ -486,18 +578,12 @@ export function GuestRow(props: GuestRowProps) {
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Total columns for colspan calculation
|
|
||||||
const totalColumns = GUEST_COLUMNS.length;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
|
||||||
<tr
|
<tr
|
||||||
class={rowClass()}
|
class={rowClass()}
|
||||||
style={rowStyle()}
|
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}`}>
|
<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-2 min-w-0">
|
||||||
<div class="flex items-center gap-1.5 min-w-0">
|
<div class="flex items-center gap-1.5 min-w-0">
|
||||||
|
|
@ -544,7 +630,10 @@ export function GuestRow(props: GuestRowProps) {
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
</Show>
|
</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>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
@ -599,7 +688,8 @@ export function GuestRow(props: GuestRowProps) {
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</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()}>
|
<div class="hidden md:flex" data-prevent-toggle onClick={(event) => event.stopPropagation()}>
|
||||||
<TagBadges
|
<TagBadges
|
||||||
tags={Array.isArray(props.guest.tags) ? props.guest.tags : []}
|
tags={Array.isArray(props.guest.tags) ? props.guest.tags : []}
|
||||||
|
|
@ -622,44 +712,51 @@ export function GuestRow(props: GuestRowProps) {
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
{/* Type */}
|
{/* Type */}
|
||||||
<td class="px-2 py-1 align-middle">
|
<Show when={isColVisible('type')}>
|
||||||
<div class="flex justify-center">
|
<td class="px-2 py-1 align-middle">
|
||||||
<span
|
<div class="flex justify-center">
|
||||||
class={`inline-block px-1 py-0.5 text-[10px] font-medium rounded whitespace-nowrap ${props.guest.type === 'qemu'
|
<span
|
||||||
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300'
|
class={`inline-block px-1 py-0.5 text-[10px] font-medium rounded whitespace-nowrap ${props.guest.type === 'qemu'
|
||||||
: 'bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300'
|
? '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'}
|
}`}
|
||||||
>
|
title={isVM(props.guest) ? 'Virtual Machine' : 'LXC Container'}
|
||||||
{isVM(props.guest) ? 'VM' : 'LXC'}
|
>
|
||||||
</span>
|
{isVM(props.guest) ? 'VM' : 'LXC'}
|
||||||
</div>
|
</span>
|
||||||
</td>
|
</div>
|
||||||
|
</td>
|
||||||
|
</Show>
|
||||||
|
|
||||||
{/* VMID */}
|
{/* VMID */}
|
||||||
<td class="px-2 py-1 align-middle">
|
<Show when={isColVisible('vmid')}>
|
||||||
<div class="flex justify-center text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap">
|
<td class="px-2 py-1 align-middle">
|
||||||
{props.guest.vmid}
|
<div class="flex justify-center text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap">
|
||||||
</div>
|
{props.guest.vmid}
|
||||||
</td>
|
</div>
|
||||||
|
</td>
|
||||||
{/* Uptime */}
|
</Show>
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* CPU */}
|
{/* CPU */}
|
||||||
<td class="px-2 py-1 align-middle" style={{ "min-width": "140px" }}>
|
<Show when={isColVisible('cpu')}>
|
||||||
<Show when={isMobile()}>
|
<td class="px-2 py-1 align-middle" style={{ "min-width": "140px" }}>
|
||||||
<div class="md:hidden flex justify-center">
|
<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
|
<ResponsiveMetricCell
|
||||||
value={cpuPercent()}
|
value={cpuPercent()}
|
||||||
type="cpu"
|
type="cpu"
|
||||||
|
|
@ -670,127 +767,210 @@ export function GuestRow(props: GuestRowProps) {
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
isRunning={isRunning()}
|
isRunning={isRunning()}
|
||||||
showMobile={true}
|
showMobile={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</td>
|
||||||
<div class="hidden md:block">
|
</Show>
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* Memory */}
|
{/* Memory */}
|
||||||
<td class="px-2 py-1 align-middle" style={{ "min-width": "140px" }}>
|
<Show when={isColVisible('memory')}>
|
||||||
<div title={memoryTooltip() ?? undefined}>
|
<td class="px-2 py-1 align-middle" style={{ "min-width": "140px" }}>
|
||||||
<Show when={isMobile()}>
|
<div title={memoryTooltip() ?? undefined}>
|
||||||
<div class="md:hidden flex justify-center">
|
<Show when={isMobile()}>
|
||||||
<ResponsiveMetricCell
|
<div class="md:hidden flex justify-center">
|
||||||
value={memPercent()}
|
<ResponsiveMetricCell
|
||||||
type="memory"
|
value={memPercent()}
|
||||||
resourceId={metricsKey()}
|
type="memory"
|
||||||
sublabel={memoryUsageLabel()}
|
resourceId={metricsKey()}
|
||||||
isRunning={isRunning()}
|
sublabel={memoryUsageLabel()}
|
||||||
showMobile={true}
|
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>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<div class="hidden md:block">
|
</td>
|
||||||
<StackedMemoryBar
|
</Show>
|
||||||
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>
|
|
||||||
|
|
||||||
{/* Disk */}
|
{/* IP Address with Network Tooltip */}
|
||||||
<td class="px-2 py-1 align-middle" style={{ "min-width": "140px" }}>
|
<Show when={isColVisible('ip')}>
|
||||||
<Show
|
<td class="px-2 py-1 align-middle">
|
||||||
when={hasDiskUsage()}
|
<div class="flex justify-center">
|
||||||
fallback={
|
<Show when={ipAddresses().length > 0 || hasNetworkInterfaces()} fallback={<span class="text-xs text-gray-400">-</span>}>
|
||||||
<div class="flex justify-center">
|
<NetworkInfoCell
|
||||||
<span class="text-xs text-gray-400 cursor-help" title={getDiskStatusTooltip()}>
|
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>
|
</span>
|
||||||
</div>
|
</Show>
|
||||||
}
|
</div>
|
||||||
>
|
</td>
|
||||||
<Show when={isMobile()}>
|
</Show>
|
||||||
<div class="md:hidden flex justify-center text-xs text-gray-600 dark:text-gray-400">
|
|
||||||
{formatPercent(diskPercent())}
|
{/* Tags */}
|
||||||
</div>
|
<Show when={isColVisible('tags')}>
|
||||||
</Show>
|
<td class="px-2 py-1 align-middle">
|
||||||
<div class={isMobile() ? 'hidden md:block' : ''}>
|
<div class="flex justify-center" onClick={(event) => event.stopPropagation()}>
|
||||||
<StackedDiskBar
|
<TagBadges
|
||||||
disks={props.guest.disks}
|
tags={Array.isArray(props.guest.tags) ? props.guest.tags : []}
|
||||||
aggregateDisk={props.guest.disk}
|
maxVisible={2}
|
||||||
|
onTagClick={props.onTagClick}
|
||||||
|
activeSearch={props.activeSearch}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</td>
|
||||||
</td>
|
</Show>
|
||||||
|
|
||||||
{/* Disk Read */}
|
{/* Disk Read */}
|
||||||
<td class="px-2 py-1 align-middle">
|
<Show when={isColVisible('diskRead')}>
|
||||||
<div class="flex justify-center whitespace-nowrap">
|
<td class="px-2 py-1 align-middle">
|
||||||
<Show when={isRunning()} fallback={<span class="text-xs text-gray-400">-</span>}>
|
<div class="flex justify-center whitespace-nowrap">
|
||||||
<span class={`text-xs ${getIOColorClass(diskRead())}`}>{formatSpeed(diskRead())}</span>
|
<Show when={isRunning()} fallback={<span class="text-xs text-gray-400">-</span>}>
|
||||||
</Show>
|
<span class={`text-xs ${getIOColorClass(diskRead())}`}>{formatSpeed(diskRead())}</span>
|
||||||
</div>
|
</Show>
|
||||||
</td>
|
</div>
|
||||||
|
</td>
|
||||||
|
</Show>
|
||||||
|
|
||||||
{/* Disk Write */}
|
{/* Disk Write */}
|
||||||
<td class="px-2 py-1 align-middle">
|
<Show when={isColVisible('diskWrite')}>
|
||||||
<div class="flex justify-center whitespace-nowrap">
|
<td class="px-2 py-1 align-middle">
|
||||||
<Show when={isRunning()} fallback={<span class="text-xs text-gray-400">-</span>}>
|
<div class="flex justify-center whitespace-nowrap">
|
||||||
<span class={`text-xs ${getIOColorClass(diskWrite())}`}>{formatSpeed(diskWrite())}</span>
|
<Show when={isRunning()} fallback={<span class="text-xs text-gray-400">-</span>}>
|
||||||
</Show>
|
<span class={`text-xs ${getIOColorClass(diskWrite())}`}>{formatSpeed(diskWrite())}</span>
|
||||||
</div>
|
</Show>
|
||||||
</td>
|
</div>
|
||||||
|
</td>
|
||||||
|
</Show>
|
||||||
|
|
||||||
{/* Net In */}
|
{/* Net In */}
|
||||||
<td class="px-2 py-1 align-middle">
|
<Show when={isColVisible('netIn')}>
|
||||||
<div class="flex justify-center whitespace-nowrap">
|
<td class="px-2 py-1 align-middle">
|
||||||
<Show when={isRunning()} fallback={<span class="text-xs text-gray-400">-</span>}>
|
<div class="flex justify-center whitespace-nowrap">
|
||||||
<span class={`text-xs ${getIOColorClass(networkIn())}`}>{formatSpeed(networkIn())}</span>
|
<Show when={isRunning()} fallback={<span class="text-xs text-gray-400">-</span>}>
|
||||||
</Show>
|
<span class={`text-xs ${getIOColorClass(networkIn())}`}>{formatSpeed(networkIn())}</span>
|
||||||
</div>
|
</Show>
|
||||||
</td>
|
</div>
|
||||||
|
</td>
|
||||||
|
</Show>
|
||||||
|
|
||||||
{/* Net Out */}
|
{/* Net Out */}
|
||||||
<td class="px-2 py-1 align-middle">
|
<Show when={isColVisible('netOut')}>
|
||||||
<div class="flex justify-center whitespace-nowrap">
|
<td class="px-2 py-1 align-middle">
|
||||||
<Show when={isRunning()} fallback={<span class="text-xs text-gray-400">-</span>}>
|
<div class="flex justify-center whitespace-nowrap">
|
||||||
<span class={`text-xs ${getIOColorClass(networkOut())}`}>{formatSpeed(networkOut())}</span>
|
<Show when={isRunning()} fallback={<span class="text-xs text-gray-400">-</span>}>
|
||||||
</Show>
|
<span class={`text-xs ${getIOColorClass(networkOut())}`}>{formatSpeed(networkOut())}</span>
|
||||||
</div>
|
</Show>
|
||||||
</td>
|
</div>
|
||||||
</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)}
|
|
||||||
/>
|
|
||||||
</td>
|
</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
|
// Generate tooltip content
|
||||||
const tooltipContent = createMemo(() => {
|
const tooltipContent = createMemo(() => {
|
||||||
if (hasMultipleDisks() && props.disks) {
|
if (hasDisks() && props.disks) {
|
||||||
return props.disks.map((disk, idx) => {
|
return props.disks.map((disk, idx) => {
|
||||||
const percent = disk.total > 0 ? (disk.used / disk.total) * 100 : 0;
|
const percent = disk.total > 0 ? (disk.used / disk.total) * 100 : 0;
|
||||||
const label = disk.mountpoint || disk.device || `Disk ${idx + 1}`;
|
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 [];
|
return [];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -162,7 +178,7 @@ export function StackedDiskBar(props: StackedDiskBarProps) {
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleMouseEnter = (e: MouseEvent) => {
|
const handleMouseEnter = (e: MouseEvent) => {
|
||||||
if (hasMultipleDisks()) {
|
if (tooltipContent().length > 0) {
|
||||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||||
setTooltipPos({ x: rect.left + rect.width / 2, y: rect.top });
|
setTooltipPos({ x: rect.left + rect.width / 2, y: rect.top });
|
||||||
setShowTooltip(true);
|
setShowTooltip(true);
|
||||||
|
|
@ -227,8 +243,8 @@ export function StackedDiskBar(props: StackedDiskBarProps) {
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tooltip for multi-disk breakdown */}
|
{/* Tooltip for disk breakdown */}
|
||||||
<Show when={showTooltip() && hasMultipleDisks()}>
|
<Show when={showTooltip() && tooltipContent().length > 0}>
|
||||||
<Portal mount={document.body}>
|
<Portal mount={document.body}>
|
||||||
<div
|
<div
|
||||||
class="fixed z-[9999] pointer-events-none"
|
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="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">
|
<div class="font-medium mb-1 text-gray-300 border-b border-gray-700 pb-1">
|
||||||
Disk Breakdown
|
{hasMultipleDisks() ? 'Disk Breakdown' : 'Disk Usage'}
|
||||||
</div>
|
</div>
|
||||||
<For each={tooltipContent()}>
|
<For each={tooltipContent()}>
|
||||||
{(item, idx) => (
|
{(item, idx) => (
|
||||||
<div class="flex justify-between gap-3 py-0.5" classList={{ 'border-t border-gray-700/50': idx() > 0 }}>
|
<div class="flex justify-between gap-3 py-0.5" classList={{ 'border-t border-gray-700/50': idx() > 0 }}>
|
||||||
<span
|
<span
|
||||||
class="truncate max-w-[80px]"
|
class="truncate max-w-[100px]"
|
||||||
style={{ color: item.color }}
|
style={{ color: item.color }}
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
|
|
|
||||||
|
|
@ -1379,8 +1379,8 @@ const DockerContainerRow: Component<{
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan={DOCKER_COLUMNS.length} class="p-0">
|
<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="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="flex flex-wrap gap-3 [&>*]:flex-1 [&>*]:basis-[calc(25%-0.75rem)] [&>*]:min-w-[200px] [&>*]:max-w-full">
|
||||||
<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">
|
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200">
|
||||||
Summary
|
Summary
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1509,7 +1509,7 @@ const DockerContainerRow: Component<{
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<Show when={container.ports && container.ports.length > 0}>
|
<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">
|
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200">
|
||||||
Ports
|
Ports
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1529,7 +1529,7 @@ const DockerContainerRow: Component<{
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={container.networks && container.networks.length > 0}>
|
<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">
|
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200">
|
||||||
Networks
|
Networks
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1556,7 +1556,7 @@ const DockerContainerRow: Component<{
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={hasPodmanMetadata()}>
|
<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">
|
<div class="text-[11px] font-medium uppercase tracking-wide text-purple-700 dark:text-purple-200">
|
||||||
Podman Metadata
|
Podman Metadata
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1590,7 +1590,7 @@ const DockerContainerRow: Component<{
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={hasBlockIo()}>
|
<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">
|
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200">
|
||||||
Block I/O
|
Block I/O
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1626,7 +1626,7 @@ const DockerContainerRow: Component<{
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={hasMounts()}>
|
<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">
|
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200">
|
||||||
Mounts
|
Mounts
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1691,7 +1691,7 @@ const DockerContainerRow: Component<{
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={container.labels && Object.keys(container.labels).length > 0}>
|
<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">
|
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200">
|
||||||
Labels
|
Labels
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -2155,8 +2155,8 @@ const DockerServiceRow: Component<{
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan={DOCKER_COLUMNS.length} class="p-0">
|
<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="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="space-y-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="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">
|
<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>Tasks</span>
|
||||||
<span class="text-[10px] font-normal text-gray-500 dark:text-gray-400">
|
<span class="text-[10px] font-normal text-gray-500 dark:text-gray-400">
|
||||||
|
|
|
||||||
|
|
@ -608,64 +608,56 @@ export const HostsOverview: Component<HostsOverviewProps> = (props) => {
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan={8} class="p-0">
|
<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="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 */}
|
{/* 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="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">System</div>
|
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200 mb-2">System</div>
|
||||||
<div class="mt-2 space-y-1 text-[11px] text-gray-600 dark:text-gray-300">
|
<div class="space-y-1.5 text-[11px]">
|
||||||
<Show when={host.cpuCount}>
|
<Show when={host.cpuCount}>
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="flex items-center justify-between">
|
||||||
<span class="font-medium text-gray-700 dark:text-gray-200">CPUs</span>
|
<span class="text-gray-500 dark:text-gray-400">CPUs</span>
|
||||||
<span class="text-right text-gray-600 dark:text-gray-300">{host.cpuCount}</span>
|
<span class="font-medium text-gray-700 dark:text-gray-200">{host.cpuCount}</span>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={host.loadAverage && host.loadAverage.length > 0}>
|
<Show when={host.loadAverage && host.loadAverage.length > 0}>
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="flex items-center justify-between">
|
||||||
<span class="font-medium text-gray-700 dark:text-gray-200">Load Avg</span>
|
<span class="text-gray-500 dark:text-gray-400">Load Avg</span>
|
||||||
<span class="text-right text-gray-600 dark:text-gray-300">{host.loadAverage!.map(l => l.toFixed(2)).join(', ')}</span>
|
<span class="font-medium text-gray-700 dark:text-gray-200">{host.loadAverage!.map(l => l.toFixed(2)).join(', ')}</span>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={host.architecture}>
|
<Show when={host.architecture}>
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="flex items-center justify-between">
|
||||||
<span class="font-medium text-gray-700 dark:text-gray-200">Arch</span>
|
<span class="text-gray-500 dark:text-gray-400">Arch</span>
|
||||||
<span class="text-right text-gray-600 dark:text-gray-300">{host.architecture}</span>
|
<span class="font-medium text-gray-700 dark:text-gray-200">{host.architecture}</span>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={host.kernelVersion}>
|
<Show when={host.kernelVersion}>
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="flex items-center justify-between">
|
||||||
<span class="font-medium text-gray-700 dark:text-gray-200">Kernel</span>
|
<span class="text-gray-500 dark:text-gray-400">Kernel</span>
|
||||||
<span class="text-right text-gray-600 dark:text-gray-300 truncate">{host.kernelVersion}</span>
|
<span class="font-medium text-gray-700 dark:text-gray-200 truncate ml-2">{host.kernelVersion}</span>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={host.agentVersion}>
|
<Show when={host.agentVersion}>
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="flex items-center justify-between">
|
||||||
<span class="font-medium text-gray-700 dark:text-gray-200">Agent</span>
|
<span class="text-gray-500 dark:text-gray-400">Agent</span>
|
||||||
<span class="text-right text-gray-600 dark:text-gray-300">{host.agentVersion}</span>
|
<span class="font-medium text-gray-700 dark:text-gray-200">{host.agentVersion}</span>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Network Interfaces */}
|
{/* Temperature Sensors */}
|
||||||
<Show when={host.networkInterfaces && host.networkInterfaces.length > 0}>
|
<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="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">Network</div>
|
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200 mb-2">Temperatures</div>
|
||||||
<div class="mt-2 space-y-2 text-[11px]">
|
<div class="space-y-1.5 text-[11px]">
|
||||||
<For each={host.networkInterfaces?.slice(0, 4)}>
|
<For each={Object.entries(host.sensors!.temperatureCelsius!).slice(0, 5)}>
|
||||||
{(iface) => (
|
{([name, temp]) => (
|
||||||
<div class="rounded border border-dashed border-gray-200 p-2 dark:border-gray-700/70">
|
<div class="flex items-center justify-between">
|
||||||
<div class="font-medium text-gray-700 dark:text-gray-200">{iface.name}</div>
|
<span class="text-gray-500 dark:text-gray-400 truncate mr-2">{name}</span>
|
||||||
<Show when={iface.addresses && iface.addresses.length > 0}>
|
<span class={`font-medium ${temp > 80 ? 'text-red-600 dark:text-red-400' : 'text-gray-700 dark:text-gray-200'}`}>
|
||||||
<div class="flex flex-wrap gap-1 mt-1 text-[10px]">
|
{temp.toFixed(1)}°C
|
||||||
<For each={iface.addresses}>
|
</span>
|
||||||
{(addr) => (
|
|
||||||
<span class="rounded bg-blue-100 px-1.5 py-0.5 text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
|
|
||||||
{addr}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
|
|
@ -673,19 +665,64 @@ export const HostsOverview: Component<HostsOverviewProps> = (props) => {
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</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 */}
|
{/* Disk Info */}
|
||||||
<Show when={host.disks && host.disks.length > 0}>
|
<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="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">Disks</div>
|
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200 mb-2">Disks</div>
|
||||||
<div class="mt-2 space-y-2 text-[11px]">
|
<div class="space-y-2 text-[11px]">
|
||||||
<For each={host.disks?.slice(0, 3)}>
|
<For each={host.disks?.slice(0, 3)}>
|
||||||
{(disk) => {
|
{(disk) => {
|
||||||
const diskPercent = () => disk.usage ?? 0;
|
const diskPercent = () => disk.usage ?? 0;
|
||||||
return (
|
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">
|
<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="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)}
|
{formatBytes(disk.used ?? 0, 0)} / {formatBytes(disk.total ?? 0, 0)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -709,14 +746,14 @@ export const HostsOverview: Component<HostsOverviewProps> = (props) => {
|
||||||
|
|
||||||
{/* Disk I/O */}
|
{/* Disk I/O */}
|
||||||
<Show when={host.diskIO && host.diskIO.length > 0}>
|
<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="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">Disk I/O</div>
|
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200 mb-2">Disk I/O</div>
|
||||||
<div class="mt-2 space-y-2 text-[11px]">
|
<div class="space-y-2 text-[11px]">
|
||||||
<For each={host.diskIO?.slice(0, 4)}>
|
<For each={host.diskIO?.slice(0, 3)}>
|
||||||
{(io) => (
|
{(io) => (
|
||||||
<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="font-medium text-gray-700 dark:text-gray-200">{io.device}</div>
|
<div class="font-medium text-gray-700 dark:text-gray-200 mb-1">{io.device}</div>
|
||||||
<div class="mt-1 grid grid-cols-2 gap-x-2 gap-y-0.5 text-[10px]">
|
<div class="grid grid-cols-2 gap-x-2 gap-y-0.5 text-[10px]">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="text-gray-500 dark:text-gray-400">Read:</span>
|
<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>
|
<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-500 dark:text-gray-400">Write:</span>
|
||||||
<span class="text-gray-600 dark:text-gray-300">{formatBytes(io.writeBytes ?? 0, 1)}</span>
|
<span class="text-gray-600 dark:text-gray-300">{formatBytes(io.writeBytes ?? 0, 1)}</span>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -741,76 +770,31 @@ export const HostsOverview: Component<HostsOverviewProps> = (props) => {
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
{/* Temperature Sensors */}
|
{/* Network Interfaces */}
|
||||||
<Show when={host.sensors?.temperatureCelsius && Object.keys(host.sensors.temperatureCelsius).length > 0}>
|
<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="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">Temperatures</div>
|
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200 mb-2">Network</div>
|
||||||
<div class="mt-2 space-y-1 text-[11px] text-gray-600 dark:text-gray-300">
|
<div class="space-y-2 text-[11px]">
|
||||||
<For each={Object.entries(host.sensors!.temperatureCelsius!).slice(0, 5)}>
|
<For each={host.networkInterfaces?.slice(0, 4)}>
|
||||||
{([name, temp]) => (
|
{(iface) => (
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="rounded border border-dashed border-gray-200 p-2 dark:border-gray-700">
|
||||||
<span class="font-medium text-gray-700 dark:text-gray-200 truncate">{name}</span>
|
<div class="font-medium text-gray-700 dark:text-gray-200">{iface.name}</div>
|
||||||
<span class={`text-right ${temp > 80 ? 'text-red-600 dark:text-red-400 font-semibold' : 'text-gray-600 dark:text-gray-300'}`}>
|
<Show when={iface.addresses && iface.addresses.length > 0}>
|
||||||
{temp.toFixed(1)}°C
|
<div class="flex flex-wrap gap-1 mt-1">
|
||||||
</span>
|
<For each={iface.addresses}>
|
||||||
</div>
|
{(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">
|
||||||
</For>
|
{addr}
|
||||||
</div>
|
</span>
|
||||||
</div>
|
)}
|
||||||
</Show>
|
</For>
|
||||||
|
|
||||||
{/* RAID Arrays */}
|
|
||||||
<Show when={host.raid && host.raid.length > 0}>
|
|
||||||
<For each={host.raid!}>
|
|
||||||
{(array) => {
|
|
||||||
const isDegraded = () => array.state.toLowerCase().includes('degraded') || array.failedDevices > 0;
|
|
||||||
const isRebuilding = () => array.state.toLowerCase().includes('recover') || array.state.toLowerCase().includes('resync') || array.rebuildPercent > 0;
|
|
||||||
const isHealthy = () => !isDegraded() && !isRebuilding() && array.state.toLowerCase().includes('clean');
|
|
||||||
|
|
||||||
const stateColor = () => {
|
|
||||||
if (isDegraded()) return 'text-red-600 dark:text-red-400 font-semibold';
|
|
||||||
if (isRebuilding()) return 'text-amber-600 dark:text-amber-400 font-semibold';
|
|
||||||
if (isHealthy()) return 'text-green-600 dark:text-green-400';
|
|
||||||
return 'text-gray-600 dark:text-gray-300';
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="min-w-[220px] flex-1 rounded border border-gray-200 bg-white/70 p-2 shadow-sm dark:border-gray-600/70 dark:bg-gray-900/30">
|
|
||||||
<div class="text-[11px] font-medium uppercase tracking-wide text-gray-700 dark:text-gray-200">
|
|
||||||
RAID {array.level.replace('raid', '')} - {array.device}
|
|
||||||
</div>
|
|
||||||
<div class="mt-2 space-y-1 text-[11px]">
|
|
||||||
<div class="flex items-center justify-between gap-2">
|
|
||||||
<span class="font-medium text-gray-700 dark:text-gray-200">State</span>
|
|
||||||
<span class={stateColor()}>{array.state}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center justify-between gap-2">
|
|
||||||
<span class="font-medium text-gray-700 dark:text-gray-200">Devices</span>
|
|
||||||
<span class="text-gray-600 dark:text-gray-300">
|
|
||||||
{array.activeDevices}/{array.totalDevices}
|
|
||||||
{array.failedDevices > 0 && <span class="text-red-600 dark:text-red-400"> ({array.failedDevices} failed)</span>}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Show when={isRebuilding() && array.rebuildPercent > 0}>
|
|
||||||
<div class="flex items-center justify-between gap-2">
|
|
||||||
<span class="font-medium text-gray-700 dark:text-gray-200">Rebuild</span>
|
|
||||||
<span class="text-amber-600 dark:text-amber-400 font-medium">
|
|
||||||
{array.rebuildPercent.toFixed(1)}%
|
|
||||||
</span>
|
|
||||||
</div>
|
</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>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
);
|
</For>
|
||||||
}}
|
</div>
|
||||||
</For>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</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 display
|
||||||
METRICS_VIEW_MODE: 'metricsViewMode', // 'bars' | 'sparklines'
|
METRICS_VIEW_MODE: 'metricsViewMode', // 'bars' | 'sparklines'
|
||||||
|
|
||||||
|
// Column visibility
|
||||||
|
DASHBOARD_HIDDEN_COLUMNS: 'dashboardHiddenColumns',
|
||||||
|
HOSTS_HIDDEN_COLUMNS: 'hostsHiddenColumns',
|
||||||
|
DOCKER_HIDDEN_COLUMNS: 'dockerHiddenColumns',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue