feat: improve responsive table layout with tighter columns
- Add 4 separate I/O columns (D Read, D Write, N In, N Out) to guest table - Tighten column widths: fixed-width I/O columns, flexible progress bar columns - Remove sticky columns from NodeSummaryTable (not needed) - Shorten "Containers" to "CTs" in node summary for consistency - Always show full VM/LXC labels (no mobile abbreviation) - Increase name column minWidth to 100px for mobile readability - Add formatSpeed utility function for I/O display - Add responsive infrastructure: useBreakpoint hook, useGridTemplate hook
This commit is contained in:
parent
3dc2bc3f56
commit
98b0721461
12 changed files with 1867 additions and 807 deletions
|
|
@ -925,111 +925,93 @@ export function Dashboard(props: DashboardProps) {
|
||||||
{/* Table View */}
|
{/* Table View */}
|
||||||
<Show when={connected() && initialDataReceived() && filteredGuests().length > 0}>
|
<Show when={connected() && initialDataReceived() && filteredGuests().length > 0}>
|
||||||
<ComponentErrorBoundary name="Guest Table">
|
<ComponentErrorBoundary name="Guest Table">
|
||||||
<Card padding="none" class="mb-4 overflow-hidden bg-white dark:bg-gray-800">
|
<Card padding="none" class="mb-4 bg-white dark:bg-gray-800">
|
||||||
{/* Desktop Header - Hidden on mobile/tablet, visible on xl+ */}
|
<div class="overflow-x-auto">
|
||||||
<div class="flex md:grid md:grid-cols-[minmax(150px,1fr)_60px_60px_80px_minmax(100px,1fr)_minmax(100px,1fr)_minmax(100px,1fr)] xl:grid-cols-[minmax(200px,1fr)_80px_80px_100px_minmax(150px,1.5fr)_minmax(150px,1.5fr)_minmax(150px,1.5fr)_minmax(100px,1fr)_minmax(100px,1fr)_minmax(100px,1fr)_minmax(100px,1fr)] border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/50 text-gray-600 dark:text-gray-300 text-[11px] sm:text-xs font-medium uppercase tracking-wider sticky top-0 z-20">
|
{/* Desktop Header */}
|
||||||
{/* Name Header - Sticky on mobile */}
|
<div class="grid grid-cols-[minmax(100px,1.5fr)_minmax(24px,32px)_minmax(28px,36px)_minmax(28px,50px)_minmax(50px,1fr)_minmax(50px,1fr)_minmax(50px,1fr)_minmax(44px,54px)_minmax(44px,54px)_minmax(44px,54px)_minmax(44px,54px)] border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/50 text-gray-600 dark:text-gray-300 text-[11px] sm:text-xs font-medium uppercase tracking-wider sticky top-0 z-20 min-w-[520px] md:min-w-0">
|
||||||
|
{/* Name Header */}
|
||||||
<div
|
<div
|
||||||
class="pl-4 pr-2 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center sticky left-0 z-30 bg-gray-50 dark:bg-gray-700/50 w-[160px] sm:w-[200px] md:w-full flex-shrink-0 border-r md:border-r-0 border-gray-200 dark:border-gray-700"
|
class="pl-4 pr-2 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center"
|
||||||
onClick={() => handleSort('name')}
|
onClick={() => handleSort('name')}
|
||||||
>
|
>
|
||||||
Name
|
Name
|
||||||
{sortKey() === 'name' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
{sortKey() === 'name' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Metrics Headers - Scrollable on mobile */}
|
{/* Type */}
|
||||||
<div class="flex-1 overflow-hidden md:contents">
|
<div
|
||||||
<div class="flex md:contents min-w-full md:min-w-0">
|
class="px-0.5 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center text-[10px]"
|
||||||
<div
|
onClick={() => handleSort('type')}
|
||||||
class="flex-1 px-0.5 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center md:justify-start w-auto min-w-[30px] md:w-full"
|
>
|
||||||
onClick={() => handleSort('type')}
|
Type {sortKey() === 'type' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||||
>
|
</div>
|
||||||
<span class="md:hidden" title="Type">
|
{/* VMID */}
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" /></svg>
|
<div
|
||||||
</span>
|
class="px-0.5 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center text-[10px]"
|
||||||
<span class="hidden md:inline">Type</span>
|
onClick={() => handleSort('vmid')}
|
||||||
{sortKey() === 'type' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
>
|
||||||
</div>
|
ID {sortKey() === 'vmid' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||||
<div
|
</div>
|
||||||
class="flex-1 px-0.5 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center md:justify-start w-auto min-w-[30px] md:w-full"
|
{/* Uptime */}
|
||||||
onClick={() => handleSort('vmid')}
|
<div
|
||||||
>
|
class="px-0.5 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center text-[10px]"
|
||||||
<span class="md:hidden" title="ID">
|
onClick={() => handleSort('uptime')}
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14" /></svg>
|
>
|
||||||
</span>
|
Up {sortKey() === 'uptime' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||||
<span class="hidden md:inline">VMID</span>
|
</div>
|
||||||
{sortKey() === 'vmid' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
{/* CPU */}
|
||||||
</div>
|
<div
|
||||||
<div
|
class="px-0.5 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center text-[10px]"
|
||||||
class="flex-1 px-0.5 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center md:justify-start w-auto min-w-[40px] md:w-full"
|
onClick={() => handleSort('cpu')}
|
||||||
onClick={() => handleSort('uptime')}
|
>
|
||||||
>
|
CPU {sortKey() === 'cpu' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||||
<span class="md:hidden" title="Uptime">
|
</div>
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
{/* Memory */}
|
||||||
</span>
|
<div
|
||||||
<span class="hidden md:inline">Uptime</span>
|
class="px-0.5 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center text-[10px]"
|
||||||
{sortKey() === 'uptime' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
onClick={() => handleSort('memory')}
|
||||||
</div>
|
>
|
||||||
<div
|
Mem {sortKey() === 'memory' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||||
class="flex-1 px-0.5 py-2 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center md:justify-start w-auto min-w-[35px] md:w-full"
|
</div>
|
||||||
onClick={() => handleSort('cpu')}
|
{/* Disk */}
|
||||||
>
|
<div
|
||||||
<span class="md:hidden" title="CPU">
|
class="px-0.5 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center text-[10px]"
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" /></svg>
|
onClick={() => handleSort('disk')}
|
||||||
</span>
|
>
|
||||||
<span class="hidden md:inline">CPU</span>
|
Disk {sortKey() === 'disk' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||||
{sortKey() === 'cpu' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
</div>
|
||||||
</div>
|
{/* Disk Read */}
|
||||||
<div
|
<div
|
||||||
class="flex-1 px-0.5 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center md:justify-start w-auto min-w-[35px] md:w-full"
|
class="px-0.5 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center text-[10px]"
|
||||||
onClick={() => handleSort('memory')}
|
onClick={() => handleSort('diskRead')}
|
||||||
>
|
>
|
||||||
<span class="md:hidden" title="Memory">
|
D Rd {sortKey() === 'diskRead' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>
|
</div>
|
||||||
</span>
|
{/* Disk Write */}
|
||||||
<span class="hidden md:inline">Memory</span>
|
<div
|
||||||
{sortKey() === 'memory' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
class="px-0.5 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center text-[10px]"
|
||||||
</div>
|
onClick={() => handleSort('diskWrite')}
|
||||||
<div
|
>
|
||||||
class="flex-1 px-0.5 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center md:justify-start w-auto min-w-[35px] md:w-full"
|
D Wr {sortKey() === 'diskWrite' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||||
onClick={() => handleSort('disk')}
|
</div>
|
||||||
>
|
{/* Net In */}
|
||||||
<span class="md:hidden" title="Disk">
|
<div
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" /></svg>
|
class="px-0.5 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center text-[10px]"
|
||||||
</span>
|
onClick={() => handleSort('networkIn')}
|
||||||
<span class="hidden md:inline">Disk</span>
|
>
|
||||||
{sortKey() === 'disk' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
N In {sortKey() === 'networkIn' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||||
</div>
|
</div>
|
||||||
<div
|
{/* Net Out */}
|
||||||
class="hidden xl:flex px-2 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 items-center w-full"
|
<div
|
||||||
onClick={() => handleSort('diskRead')}
|
class="px-0.5 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center text-[10px]"
|
||||||
>
|
onClick={() => handleSort('networkOut')}
|
||||||
D Read {sortKey() === 'diskRead' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
>
|
||||||
</div>
|
N Out {sortKey() === 'networkOut' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
||||||
<div
|
|
||||||
class="hidden xl:flex px-2 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 items-center w-full"
|
|
||||||
onClick={() => handleSort('diskWrite')}
|
|
||||||
>
|
|
||||||
D Write {sortKey() === 'diskWrite' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="hidden xl:flex px-2 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 items-center w-full"
|
|
||||||
onClick={() => handleSort('networkIn')}
|
|
||||||
>
|
|
||||||
Net In {sortKey() === 'networkIn' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="hidden xl:flex px-2 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 items-center w-full"
|
|
||||||
onClick={() => handleSort('networkOut')}
|
|
||||||
>
|
|
||||||
Net Out {sortKey() === 'networkOut' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Guest List */}
|
{/* Guest List */}
|
||||||
<div class="divide-y divide-gray-200 dark:divide-gray-700">
|
<div class="divide-y divide-gray-200 dark:divide-gray-700 min-w-[520px] md:min-w-0">
|
||||||
<For
|
<For
|
||||||
each={Object.entries(groupedGuests()).sort(([instanceIdA], [instanceIdB]) => {
|
each={Object.entries(groupedGuests()).sort(([instanceIdA], [instanceIdB]) => {
|
||||||
const nodeA = nodeByInstance()[instanceIdA];
|
const nodeA = nodeByInstance()[instanceIdA];
|
||||||
|
|
@ -1076,6 +1058,7 @@ export function Dashboard(props: DashboardProps) {
|
||||||
}}
|
}}
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</ComponentErrorBoundary>
|
</ComponentErrorBoundary>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
import { GuestDrawer } from './GuestDrawer';
|
import { GuestDrawer } from './GuestDrawer';
|
||||||
import { createMemo, createSignal, createEffect, Show } from 'solid-js';
|
import { createMemo, createSignal, createEffect, Show, For } from 'solid-js';
|
||||||
import type { VM, Container } from '@/types/api';
|
import type { VM, Container } from '@/types/api';
|
||||||
import { formatBytes, formatPercent, formatUptime } from '@/utils/format';
|
import { formatBytes, formatUptime, formatSpeed } from '@/utils/format';
|
||||||
import { MetricBar } from './MetricBar';
|
|
||||||
import { IOMetric } from './IOMetric';
|
|
||||||
import { TagBadges } from './TagBadges';
|
import { TagBadges } from './TagBadges';
|
||||||
|
|
||||||
import { StatusDot } from '@/components/shared/StatusDot';
|
import { StatusDot } from '@/components/shared/StatusDot';
|
||||||
|
|
@ -12,6 +10,8 @@ import { GuestMetadataAPI } from '@/api/guestMetadata';
|
||||||
import { showSuccess, showError } from '@/utils/toast';
|
import { showSuccess, showError } from '@/utils/toast';
|
||||||
import { logger } from '@/utils/logger';
|
import { logger } from '@/utils/logger';
|
||||||
import { buildMetricKey } from '@/utils/metricsKeys';
|
import { buildMetricKey } from '@/utils/metricsKeys';
|
||||||
|
import { type ColumnPriority } from '@/hooks/useBreakpoint';
|
||||||
|
import { ResponsiveMetricCell, useGridTemplate } from '@/components/shared/responsive';
|
||||||
|
|
||||||
type Guest = VM | Container;
|
type Guest = VM | Container;
|
||||||
|
|
||||||
|
|
@ -37,6 +37,31 @@ const isVM = (guest: Guest): guest is VM => {
|
||||||
return guest.type === 'qemu';
|
return guest.type === 'qemu';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Column configuration using the priority system
|
||||||
|
interface ColumnDef {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
priority: ColumnPriority;
|
||||||
|
minWidth?: string;
|
||||||
|
maxWidth?: string;
|
||||||
|
flex?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GUEST_COLUMNS: ColumnDef[] = [
|
||||||
|
{ id: 'name', label: 'Name', priority: 'essential', minWidth: '100px', flex: 1.5 },
|
||||||
|
{ id: 'type', label: 'Type', priority: 'essential', minWidth: '24px', maxWidth: '32px' },
|
||||||
|
{ id: 'vmid', label: 'VMID', priority: 'essential', minWidth: '28px', maxWidth: '36px' },
|
||||||
|
{ id: 'uptime', label: 'Uptime', priority: 'essential', minWidth: '28px', maxWidth: '50px' },
|
||||||
|
{ id: 'cpu', label: 'CPU', priority: 'essential', minWidth: '50px', flex: 1 },
|
||||||
|
{ id: 'memory', label: 'Memory', priority: 'essential', minWidth: '50px', flex: 1 },
|
||||||
|
{ id: 'disk', label: 'Disk', priority: 'essential', minWidth: '50px', flex: 1 },
|
||||||
|
// I/O columns - fixed width, no flex
|
||||||
|
{ id: 'diskRead', label: 'D Read', priority: 'essential', minWidth: '44px', maxWidth: '54px' },
|
||||||
|
{ id: 'diskWrite', label: 'D Write', priority: 'essential', minWidth: '44px', maxWidth: '54px' },
|
||||||
|
{ id: 'netIn', label: 'Net In', priority: 'essential', minWidth: '44px', maxWidth: '54px' },
|
||||||
|
{ id: 'netOut', label: 'Net Out', priority: 'essential', minWidth: '44px', maxWidth: '54px' },
|
||||||
|
];
|
||||||
|
|
||||||
interface GuestRowProps {
|
interface GuestRowProps {
|
||||||
guest: Guest;
|
guest: Guest;
|
||||||
alertStyles?: {
|
alertStyles?: {
|
||||||
|
|
@ -65,6 +90,9 @@ export function GuestRow(props: GuestRowProps) {
|
||||||
const guestId = createMemo(() => buildGuestId(props.guest));
|
const guestId = createMemo(() => buildGuestId(props.guest));
|
||||||
const isEditingUrl = createMemo(() => currentlyEditingGuestId() === guestId());
|
const isEditingUrl = createMemo(() => currentlyEditingGuestId() === guestId());
|
||||||
|
|
||||||
|
// Use the responsive grid template hook for dynamic column visibility
|
||||||
|
const { gridTemplate, visibleColumns, isMobile } = useGridTemplate({ columns: GUEST_COLUMNS });
|
||||||
|
|
||||||
// Create namespaced metrics key
|
// Create namespaced metrics key
|
||||||
const metricsKey = createMemo(() => {
|
const metricsKey = createMemo(() => {
|
||||||
const kind = props.guest.type === 'qemu' ? 'vm' : 'container';
|
const kind = props.guest.type === 'qemu' ? 'vm' : 'container';
|
||||||
|
|
@ -108,12 +136,9 @@ export function GuestRow(props: GuestRowProps) {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const cpuPercent = createMemo(() => (props.guest.cpu || 0) * 100);
|
const cpuPercent = createMemo(() => (props.guest.cpu || 0) * 100);
|
||||||
const memPercent = createMemo(() => {
|
const memPercent = createMemo(() => {
|
||||||
if (!props.guest.memory) return 0;
|
if (!props.guest.memory) return 0;
|
||||||
// Use the pre-calculated usage percentage from the backend
|
|
||||||
return props.guest.memory.usage || 0;
|
return props.guest.memory.usage || 0;
|
||||||
});
|
});
|
||||||
const memoryUsageLabel = createMemo(() => {
|
const memoryUsageLabel = createMemo(() => {
|
||||||
|
|
@ -166,17 +191,14 @@ export function GuestRow(props: GuestRowProps) {
|
||||||
if (target.closest('a, button, input, [data-prevent-toggle]')) {
|
if (target.closest('a, button, input, [data-prevent-toggle]')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Toggle: if this guest is currently expanded, close it; otherwise open it (closing any other)
|
|
||||||
setCurrentlyExpandedGuestId(prev => prev === guestId() ? null : guestId());
|
setCurrentlyExpandedGuestId(prev => prev === guestId() ? null : guestId());
|
||||||
};
|
};
|
||||||
|
|
||||||
const startEditingUrl = (event: MouseEvent) => {
|
const startEditingUrl = (event: MouseEvent) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
|
||||||
// If another guest is being edited, save it first
|
|
||||||
const currentEditing = currentlyEditingGuestId();
|
const currentEditing = currentlyEditingGuestId();
|
||||||
if (currentEditing !== null && currentEditing !== guestId()) {
|
if (currentEditing !== null && currentEditing !== guestId()) {
|
||||||
// Find the input for the currently editing guest and blur it
|
|
||||||
const currentInput = document.querySelector(`input[data-guest-id="${currentEditing}"]`) as HTMLInputElement;
|
const currentInput = document.querySelector(`input[data-guest-id="${currentEditing}"]`) as HTMLInputElement;
|
||||||
if (currentInput) {
|
if (currentInput) {
|
||||||
currentInput.blur();
|
currentInput.blur();
|
||||||
|
|
@ -188,7 +210,6 @@ export function GuestRow(props: GuestRowProps) {
|
||||||
setCurrentlyEditingGuestId(guestId());
|
setCurrentlyEditingGuestId(guestId());
|
||||||
};
|
};
|
||||||
|
|
||||||
// Auto-focus the input when editing starts
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (isEditingUrl() && urlInputRef) {
|
if (isEditingUrl() && urlInputRef) {
|
||||||
urlInputRef.focus();
|
urlInputRef.focus();
|
||||||
|
|
@ -196,21 +217,16 @@ export function GuestRow(props: GuestRowProps) {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Track if we're currently editing to prevent cleanup during re-renders
|
|
||||||
let isCurrentlyMounted = true;
|
let isCurrentlyMounted = true;
|
||||||
|
|
||||||
// Add global click handler to close editor and prevent clicks while editing
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (isEditingUrl() && isCurrentlyMounted) {
|
if (isEditingUrl() && isCurrentlyMounted) {
|
||||||
const handleGlobalClick = (e: MouseEvent) => {
|
const handleGlobalClick = (e: MouseEvent) => {
|
||||||
// Double-check we're still the editing guest
|
|
||||||
if (currentlyEditingGuestId() !== guestId()) return;
|
if (currentlyEditingGuestId() !== guestId()) return;
|
||||||
|
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
// Allow clicking another guest name to switch editing
|
|
||||||
const isClickingGuestName = target.closest('[data-guest-name-editable]');
|
const isClickingGuestName = target.closest('[data-guest-name-editable]');
|
||||||
|
|
||||||
// If clicking outside the editor (and not another guest name), close it and prevent the click
|
|
||||||
if (!target.closest('[data-url-editor]') && !isClickingGuestName) {
|
if (!target.closest('[data-url-editor]') && !isClickingGuestName) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
@ -220,7 +236,6 @@ export function GuestRow(props: GuestRowProps) {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleGlobalMouseDown = (e: MouseEvent) => {
|
const handleGlobalMouseDown = (e: MouseEvent) => {
|
||||||
// Double-check we're still the editing guest
|
|
||||||
if (currentlyEditingGuestId() !== guestId()) return;
|
if (currentlyEditingGuestId() !== guestId()) return;
|
||||||
|
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
|
|
@ -233,7 +248,6 @@ export function GuestRow(props: GuestRowProps) {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Use capture phase to intercept clicks before they bubble
|
|
||||||
document.addEventListener('mousedown', handleGlobalMouseDown, true);
|
document.addEventListener('mousedown', handleGlobalMouseDown, true);
|
||||||
document.addEventListener('click', handleGlobalClick, true);
|
document.addEventListener('click', handleGlobalClick, true);
|
||||||
return () => {
|
return () => {
|
||||||
|
|
@ -244,23 +258,19 @@ export function GuestRow(props: GuestRowProps) {
|
||||||
});
|
});
|
||||||
|
|
||||||
const saveUrl = async () => {
|
const saveUrl = async () => {
|
||||||
// Only save if this guest is the one being edited
|
|
||||||
if (currentlyEditingGuestId() !== guestId()) return;
|
if (currentlyEditingGuestId() !== guestId()) return;
|
||||||
|
|
||||||
const newUrl = (editingValues.get(guestId()) || '').trim();
|
const newUrl = (editingValues.get(guestId()) || '').trim();
|
||||||
|
|
||||||
// Clear global editing state
|
|
||||||
editingValues.delete(guestId());
|
editingValues.delete(guestId());
|
||||||
setEditingValuesVersion(v => v + 1);
|
setEditingValuesVersion(v => v + 1);
|
||||||
setCurrentlyEditingGuestId(null);
|
setCurrentlyEditingGuestId(null);
|
||||||
|
|
||||||
// If URL hasn't changed, don't save
|
|
||||||
if (newUrl === (customUrl() || '')) return;
|
if (newUrl === (customUrl() || '')) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await GuestMetadataAPI.updateMetadata(guestId(), { customUrl: newUrl });
|
await GuestMetadataAPI.updateMetadata(guestId(), { customUrl: newUrl });
|
||||||
|
|
||||||
// Animate if transitioning from no URL to having a URL
|
|
||||||
const hadUrl = !!customUrl();
|
const hadUrl = !!customUrl();
|
||||||
if (!hadUrl && newUrl) {
|
if (!hadUrl && newUrl) {
|
||||||
setShouldAnimateIcon(true);
|
setShouldAnimateIcon(true);
|
||||||
|
|
@ -269,7 +279,6 @@ export function GuestRow(props: GuestRowProps) {
|
||||||
|
|
||||||
setCustomUrl(newUrl || undefined);
|
setCustomUrl(newUrl || undefined);
|
||||||
|
|
||||||
// Notify parent to update metadata
|
|
||||||
if (props.onCustomUrlUpdate) {
|
if (props.onCustomUrlUpdate) {
|
||||||
props.onCustomUrlUpdate(guestId(), newUrl);
|
props.onCustomUrlUpdate(guestId(), newUrl);
|
||||||
}
|
}
|
||||||
|
|
@ -286,21 +295,17 @@ export function GuestRow(props: GuestRowProps) {
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteUrl = async () => {
|
const deleteUrl = async () => {
|
||||||
// Only process if this guest is the one being edited
|
|
||||||
if (currentlyEditingGuestId() !== guestId()) return;
|
if (currentlyEditingGuestId() !== guestId()) return;
|
||||||
|
|
||||||
// Clear global editing state
|
|
||||||
editingValues.delete(guestId());
|
editingValues.delete(guestId());
|
||||||
setEditingValuesVersion(v => v + 1);
|
setEditingValuesVersion(v => v + 1);
|
||||||
setCurrentlyEditingGuestId(null);
|
setCurrentlyEditingGuestId(null);
|
||||||
|
|
||||||
// If there was a URL set, delete it
|
|
||||||
if (customUrl()) {
|
if (customUrl()) {
|
||||||
try {
|
try {
|
||||||
await GuestMetadataAPI.updateMetadata(guestId(), { customUrl: '' });
|
await GuestMetadataAPI.updateMetadata(guestId(), { customUrl: '' });
|
||||||
setCustomUrl(undefined);
|
setCustomUrl(undefined);
|
||||||
|
|
||||||
// Notify parent to update metadata
|
|
||||||
if (props.onCustomUrlUpdate) {
|
if (props.onCustomUrlUpdate) {
|
||||||
props.onCustomUrlUpdate(guestId(), '');
|
props.onCustomUrlUpdate(guestId(), '');
|
||||||
}
|
}
|
||||||
|
|
@ -314,17 +319,15 @@ export function GuestRow(props: GuestRowProps) {
|
||||||
};
|
};
|
||||||
|
|
||||||
const cancelEditingUrl = () => {
|
const cancelEditingUrl = () => {
|
||||||
// Only cancel if this guest is the one being edited
|
|
||||||
if (currentlyEditingGuestId() !== guestId()) return;
|
if (currentlyEditingGuestId() !== guestId()) return;
|
||||||
|
|
||||||
// Just close without saving
|
|
||||||
editingValues.delete(guestId());
|
editingValues.delete(guestId());
|
||||||
setEditingValuesVersion(v => v + 1);
|
setEditingValuesVersion(v => v + 1);
|
||||||
setCurrentlyEditingGuestId(null);
|
setCurrentlyEditingGuestId(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const diskPercent = createMemo(() => {
|
const diskPercent = createMemo(() => {
|
||||||
if (!props.guest.disk || props.guest.disk.total === 0) return 0;
|
if (!props.guest.disk || props.guest.disk.total === 0) return 0;
|
||||||
// Check if usage is -1 (unknown/no guest agent)
|
|
||||||
if (props.guest.disk.usage === -1) return -1;
|
if (props.guest.disk.usage === -1) return -1;
|
||||||
return (props.guest.disk.used / props.guest.disk.total) * 100;
|
return (props.guest.disk.used / props.guest.disk.total) * 100;
|
||||||
});
|
});
|
||||||
|
|
@ -339,7 +342,6 @@ export function GuestRow(props: GuestRowProps) {
|
||||||
const guestStatus = createMemo(() => getGuestPowerIndicator(props.guest, parentOnline()));
|
const guestStatus = createMemo(() => getGuestPowerIndicator(props.guest, parentOnline()));
|
||||||
const lockLabel = createMemo(() => (props.guest.lock || '').trim());
|
const lockLabel = createMemo(() => (props.guest.lock || '').trim());
|
||||||
|
|
||||||
// Get helpful tooltip for disk status
|
|
||||||
const getDiskStatusTooltip = () => {
|
const getDiskStatusTooltip = () => {
|
||||||
if (!isVM(props.guest)) return 'Disk stats unavailable';
|
if (!isVM(props.guest)) return 'Disk stats unavailable';
|
||||||
|
|
||||||
|
|
@ -382,9 +384,6 @@ export function GuestRow(props: GuestRowProps) {
|
||||||
return '#9ca3af';
|
return '#9ca3af';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Get row styling - include alert styles if present
|
|
||||||
const rowClass = createMemo(() => {
|
const rowClass = createMemo(() => {
|
||||||
const base = 'transition-all duration-200 relative';
|
const base = 'transition-all duration-200 relative';
|
||||||
const hover = 'hover:shadow-sm';
|
const hover = 'hover:shadow-sm';
|
||||||
|
|
@ -404,9 +403,6 @@ export function GuestRow(props: GuestRowProps) {
|
||||||
return `${base} ${hover} ${defaultHover} ${alertBg} ${stoppedDimming} ${clickable} ${expanded}`;
|
return `${base} ${hover} ${defaultHover} ${alertBg} ${stoppedDimming} ${clickable} ${expanded}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Get row styles including box-shadow for alert border
|
|
||||||
const rowStyle = createMemo(() => {
|
const rowStyle = createMemo(() => {
|
||||||
if (!showAlertHighlight()) return {};
|
if (!showAlertHighlight()) return {};
|
||||||
const color = alertAccentColor();
|
const color = alertAccentColor();
|
||||||
|
|
@ -416,265 +412,287 @@ export function GuestRow(props: GuestRowProps) {
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Render cell content based on column type
|
||||||
|
const renderCell = (column: ColumnDef) => {
|
||||||
|
switch (column.id) {
|
||||||
|
case 'name':
|
||||||
|
return (
|
||||||
|
<div class={`px-1 py-1 flex items-center min-w-0 ${props.isGroupedView ? GROUPED_FIRST_CELL_INDENT : DEFAULT_FIRST_CELL_INDENT}`}>
|
||||||
|
<div class="flex items-center gap-2 min-w-0 w-full">
|
||||||
|
<div class="flex items-center gap-1.5 flex-1 min-w-0">
|
||||||
|
<StatusDot
|
||||||
|
variant={guestStatus().variant}
|
||||||
|
title={guestStatus().label}
|
||||||
|
ariaLabel={guestStatus().label}
|
||||||
|
size="xs"
|
||||||
|
/>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<Show
|
||||||
|
when={isEditingUrl()}
|
||||||
|
fallback={
|
||||||
|
<div class="flex items-center gap-1.5 min-w-0">
|
||||||
|
<span
|
||||||
|
class="text-xs font-medium text-gray-900 dark:text-gray-100 cursor-text select-none overflow-hidden text-ellipsis whitespace-nowrap"
|
||||||
|
style="cursor: text;"
|
||||||
|
title={`${props.guest.name}${customUrl() ? ' - Click to edit URL' : ' - Click to add URL'}`}
|
||||||
|
onClick={startEditingUrl}
|
||||||
|
data-guest-name-editable
|
||||||
|
>
|
||||||
|
{props.guest.name}
|
||||||
|
</span>
|
||||||
|
<Show when={customUrl()}>
|
||||||
|
<a
|
||||||
|
href={customUrl()}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class={`flex-shrink-0 text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors ${shouldAnimateIcon() ? 'animate-fadeIn' : ''}`}
|
||||||
|
title="Open in new tab"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="w-3.5 h-3.5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div class="flex-1 flex items-center gap-1 min-w-0" data-url-editor>
|
||||||
|
<input
|
||||||
|
ref={urlInputRef}
|
||||||
|
type="text"
|
||||||
|
value={editingUrlValue()}
|
||||||
|
data-guest-id={guestId()}
|
||||||
|
onInput={(e) => {
|
||||||
|
editingValues.set(guestId(), e.currentTarget.value);
|
||||||
|
setEditingValuesVersion(v => v + 1);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
saveUrl();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
cancelEditingUrl();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
placeholder="https://192.168.1.100:8006"
|
||||||
|
class="flex-1 min-w-0 px-2 py-0.5 text-sm border border-blue-500 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-url-editor-button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
saveUrl();
|
||||||
|
}}
|
||||||
|
class="flex-shrink-0 w-6 h-6 flex items-center justify-center text-xs bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
|
||||||
|
title="Save (or press Enter)"
|
||||||
|
>
|
||||||
|
✓
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-url-editor-button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
deleteUrl();
|
||||||
|
}}
|
||||||
|
class="flex-shrink-0 w-6 h-6 flex items-center justify-center text-xs bg-red-600 text-white rounded hover:bg-red-700 transition-colors"
|
||||||
|
title="Delete URL"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={!isEditingUrl()}>
|
||||||
|
<div class="hidden md:flex" data-prevent-toggle onClick={(event) => event.stopPropagation()}>
|
||||||
|
<TagBadges
|
||||||
|
tags={Array.isArray(props.guest.tags) ? props.guest.tags : []}
|
||||||
|
maxVisible={3}
|
||||||
|
onTagClick={props.onTagClick}
|
||||||
|
activeSearch={props.activeSearch}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={lockLabel()}>
|
||||||
|
<span
|
||||||
|
class="text-[10px] font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide"
|
||||||
|
title={`Guest is locked (${lockLabel()})`}
|
||||||
|
>
|
||||||
|
Lock: {lockLabel()}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'type':
|
||||||
|
return (
|
||||||
|
<div class="px-0.5 py-1 flex justify-center items-center">
|
||||||
|
<span
|
||||||
|
class={`inline-block px-1 py-0.5 text-[10px] font-medium rounded ${props.guest.type === 'qemu'
|
||||||
|
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300'
|
||||||
|
: 'bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300'
|
||||||
|
}`}
|
||||||
|
title={isVM(props.guest) ? 'Virtual Machine' : 'LXC Container'}
|
||||||
|
>
|
||||||
|
{isVM(props.guest) ? 'VM' : 'LXC'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'vmid':
|
||||||
|
return (
|
||||||
|
<div class="flex-1 px-0.5 py-1 md:px-1.5 md:py-0 w-auto min-w-[30px] md:w-full flex justify-center md:justify-start items-center text-xs text-gray-600 dark:text-gray-400">
|
||||||
|
{props.guest.vmid}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'uptime':
|
||||||
|
return (
|
||||||
|
<div class="flex-1 px-0.5 py-1 md:px-1.5 md:py-0 w-auto min-w-[40px] md:w-full flex justify-center md:justify-start items-center">
|
||||||
|
<div 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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'cpu':
|
||||||
|
return (
|
||||||
|
<div class="flex-1 px-0.5 py-1 md:px-2 md:py-0 w-auto min-w-[35px] md:w-full flex justify-center items-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={isMobile()}
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'memory':
|
||||||
|
return (
|
||||||
|
<div class="flex-1 px-0.5 py-1 md:px-2 md:py-0 w-auto min-w-[35px] md:w-full flex justify-center items-center">
|
||||||
|
<div title={memoryTooltip() ?? undefined} class="w-full text-center xl:text-left">
|
||||||
|
<ResponsiveMetricCell
|
||||||
|
value={memPercent()}
|
||||||
|
type="memory"
|
||||||
|
resourceId={metricsKey()}
|
||||||
|
sublabel={memoryUsageLabel()}
|
||||||
|
isRunning={isRunning()}
|
||||||
|
showMobile={isMobile()}
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'disk':
|
||||||
|
return (
|
||||||
|
<div class="flex-1 px-0.5 py-1 md:px-2 md:py-0 w-auto min-w-[35px] md:w-full flex justify-center items-center">
|
||||||
|
<Show
|
||||||
|
when={hasDiskUsage()}
|
||||||
|
fallback={
|
||||||
|
<span class="text-xs text-gray-400 cursor-help" title={getDiskStatusTooltip()}>
|
||||||
|
-
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ResponsiveMetricCell
|
||||||
|
value={diskPercent()}
|
||||||
|
type="disk"
|
||||||
|
resourceId={metricsKey()}
|
||||||
|
sublabel={
|
||||||
|
props.guest.disk
|
||||||
|
? `${formatBytes(props.guest.disk.used, 0)}/${formatBytes(props.guest.disk.total, 0)}`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
isRunning={true}
|
||||||
|
showMobile={isMobile()}
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'diskRead':
|
||||||
|
return (
|
||||||
|
<div class="py-1 flex justify-center items-center text-[9px] font-mono whitespace-nowrap">
|
||||||
|
<Show when={isRunning()} fallback={<span class="text-gray-400">-</span>}>
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">{formatSpeed(props.guest.diskRead)}</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'diskWrite':
|
||||||
|
return (
|
||||||
|
<div class="py-1 flex justify-center items-center text-[9px] font-mono whitespace-nowrap">
|
||||||
|
<Show when={isRunning()} fallback={<span class="text-gray-400">-</span>}>
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">{formatSpeed(props.guest.diskWrite)}</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'netIn':
|
||||||
|
return (
|
||||||
|
<div class="py-1 flex justify-center items-center text-[9px] font-mono whitespace-nowrap">
|
||||||
|
<Show when={isRunning()} fallback={<span class="text-gray-400">-</span>}>
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">{formatSpeed(props.guest.networkIn)}</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'netOut':
|
||||||
|
return (
|
||||||
|
<div class="py-1 flex justify-center items-center text-[9px] font-mono whitespace-nowrap">
|
||||||
|
<Show when={isRunning()} fallback={<span class="text-gray-400">-</span>}>
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">{formatSpeed(props.guest.networkOut)}</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
class={`${rowClass()} flex md:grid md:grid-cols-[minmax(150px,1fr)_60px_60px_80px_minmax(100px,1fr)_minmax(100px,1fr)_minmax(100px,1fr)] xl:grid-cols-[minmax(200px,1fr)_80px_80px_100px_minmax(150px,1.5fr)_minmax(150px,1.5fr)_minmax(150px,1.5fr)_minmax(100px,1fr)_minmax(100px,1fr)_minmax(100px,1fr)_minmax(100px,1fr)] items-center`}
|
class={`${rowClass()} grid items-center`}
|
||||||
style={rowStyle()}
|
style={{ ...rowStyle(), 'grid-template-columns': gridTemplate() }}
|
||||||
onClick={toggleDrawer}
|
onClick={toggleDrawer}
|
||||||
aria-expanded={drawerOpen()}
|
aria-expanded={drawerOpen()}
|
||||||
>
|
>
|
||||||
{/* Name Section - Sticky on mobile */}
|
<For each={visibleColumns()}>
|
||||||
<div class={`p-2 md:py-0 md:pl-4 md:pr-2 flex items-center sticky left-0 z-10 bg-inherit w-[160px] sm:w-[200px] md:w-full flex-shrink-0 border-r md:border-r-0 border-gray-100 dark:border-gray-700 ${props.isGroupedView ? GROUPED_FIRST_CELL_INDENT : DEFAULT_FIRST_CELL_INDENT}`}>
|
{(column) => renderCell(column)}
|
||||||
<div class="flex items-center gap-2 min-w-0 w-full">
|
</For>
|
||||||
<div class="flex items-center gap-1.5 flex-1 min-w-0">
|
|
||||||
<StatusDot
|
|
||||||
variant={guestStatus().variant}
|
|
||||||
title={guestStatus().label}
|
|
||||||
ariaLabel={guestStatus().label}
|
|
||||||
size="xs"
|
|
||||||
/>
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<Show
|
|
||||||
when={isEditingUrl()}
|
|
||||||
fallback={
|
|
||||||
<div class="flex items-center gap-1.5 min-w-0">
|
|
||||||
<span
|
|
||||||
class="text-xs font-medium text-gray-900 dark:text-gray-100 cursor-text select-none overflow-hidden text-ellipsis whitespace-nowrap"
|
|
||||||
style="cursor: text;"
|
|
||||||
title={`${props.guest.name}${customUrl() ? ' - Click to edit URL' : ' - Click to add URL'}`}
|
|
||||||
onClick={startEditingUrl}
|
|
||||||
data-guest-name-editable
|
|
||||||
>
|
|
||||||
{props.guest.name}
|
|
||||||
</span>
|
|
||||||
<Show when={customUrl()}>
|
|
||||||
<a
|
|
||||||
href={customUrl()}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class={`flex-shrink-0 text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors ${shouldAnimateIcon() ? 'animate-fadeIn' : ''}`}
|
|
||||||
title="Open in new tab"
|
|
||||||
onClick={(event) => event.stopPropagation()}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="w-3.5 h-3.5"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div class="flex-1 flex items-center gap-1 min-w-0" data-url-editor>
|
|
||||||
<input
|
|
||||||
ref={urlInputRef}
|
|
||||||
type="text"
|
|
||||||
value={editingUrlValue()}
|
|
||||||
data-guest-id={guestId()}
|
|
||||||
onInput={(e) => {
|
|
||||||
editingValues.set(guestId(), e.currentTarget.value);
|
|
||||||
setEditingValuesVersion(v => v + 1);
|
|
||||||
}}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.preventDefault();
|
|
||||||
saveUrl();
|
|
||||||
} else if (e.key === 'Escape') {
|
|
||||||
e.preventDefault();
|
|
||||||
cancelEditingUrl();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
placeholder="https://192.168.1.100:8006"
|
|
||||||
class="flex-1 min-w-0 px-2 py-0.5 text-sm border border-blue-500 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-url-editor-button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
saveUrl();
|
|
||||||
}}
|
|
||||||
class="flex-shrink-0 w-6 h-6 flex items-center justify-center text-xs bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
|
|
||||||
title="Save (or press Enter)"
|
|
||||||
>
|
|
||||||
✓
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-url-editor-button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
deleteUrl();
|
|
||||||
}}
|
|
||||||
class="flex-shrink-0 w-6 h-6 flex items-center justify-center text-xs bg-red-600 text-white rounded hover:bg-red-700 transition-colors"
|
|
||||||
title="Delete URL"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Show when={!isEditingUrl()}>
|
|
||||||
<div class="hidden md:flex" data-prevent-toggle onClick={(event) => event.stopPropagation()}>
|
|
||||||
<TagBadges
|
|
||||||
tags={Array.isArray(props.guest.tags) ? props.guest.tags : []}
|
|
||||||
maxVisible={3}
|
|
||||||
onTagClick={props.onTagClick}
|
|
||||||
activeSearch={props.activeSearch}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={lockLabel()}>
|
|
||||||
<span
|
|
||||||
class="text-[10px] font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide"
|
|
||||||
title={`Guest is locked (${lockLabel()})`}
|
|
||||||
>
|
|
||||||
Lock: {lockLabel()}
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Metrics Section - Scrollable on mobile */}
|
|
||||||
<div class="flex-1 overflow-x-auto md:contents no-scrollbar">
|
|
||||||
<div class="flex md:contents min-w-full md:min-w-0 divide-x md:divide-none divide-gray-100 dark:divide-gray-800">
|
|
||||||
{/* Type */}
|
|
||||||
<div class="flex-1 px-0.5 py-1 md:px-2 md:py-0 w-auto min-w-[30px] md:w-full flex justify-center md:justify-start items-center">
|
|
||||||
<span
|
|
||||||
class={`inline-block px-1.5 py-0.5 text-xs font-medium rounded ${props.guest.type === 'qemu'
|
|
||||||
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300'
|
|
||||||
: 'bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{isVM(props.guest) ? 'VM' : 'LXC'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* VMID */}
|
|
||||||
<div class="flex-1 px-0.5 py-1 md:px-1.5 md:py-0 w-auto min-w-[30px] md:w-full flex justify-center md:justify-start items-center text-xs text-gray-600 dark:text-gray-400">
|
|
||||||
{props.guest.vmid}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Uptime */}
|
|
||||||
<div class="flex-1 px-0.5 py-1 md:px-1.5 md:py-0 w-auto min-w-[40px] md:w-full flex justify-center md:justify-start items-center">
|
|
||||||
<div class={`text-xs whitespace-nowrap ${props.guest.uptime < 3600 ? 'text-orange-500' : 'text-gray-600 dark:text-gray-400'}`}>
|
|
||||||
<Show when={isRunning()} fallback="-">
|
|
||||||
<span class="md:hidden">{formatUptime(props.guest.uptime, true)}</span>
|
|
||||||
<span class="hidden md:inline">{formatUptime(props.guest.uptime)}</span>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* CPU */}
|
|
||||||
<div class="flex-1 px-0.5 py-1 md:px-2 md:py-0 w-auto min-w-[35px] md:w-full flex justify-center items-center">
|
|
||||||
<Show when={isRunning()} fallback={<span class="text-xs text-gray-400">-</span>}>
|
|
||||||
<div class={`md:hidden text-xs ${cpuPercent() >= 90 ? 'text-red-600 dark:text-red-400 font-bold' : cpuPercent() >= 80 ? 'text-orange-600 dark:text-orange-400 font-medium' : 'text-gray-600 dark:text-gray-400'}`}>
|
|
||||||
{formatPercent(cpuPercent())}
|
|
||||||
</div>
|
|
||||||
<div class="hidden md:block w-full">
|
|
||||||
<MetricBar
|
|
||||||
value={cpuPercent()}
|
|
||||||
label={formatPercent(cpuPercent())}
|
|
||||||
sublabel={
|
|
||||||
props.guest.cpus
|
|
||||||
? `${props.guest.cpus} ${props.guest.cpus === 1 ? 'core' : 'cores'}`
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
type="cpu"
|
|
||||||
resourceId={metricsKey()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Memory */}
|
|
||||||
<div class="flex-1 px-0.5 py-1 md:px-2 md:py-0 w-auto min-w-[35px] md:w-full flex justify-center items-center">
|
|
||||||
<div title={memoryTooltip() ?? undefined} class="w-full text-center xl:text-left">
|
|
||||||
<Show when={isRunning()} fallback={<span class="text-xs text-gray-400">-</span>}>
|
|
||||||
<div class={`md:hidden text-xs ${memPercent() >= 85 ? 'text-red-600 dark:text-red-400 font-bold' : memPercent() >= 75 ? 'text-orange-600 dark:text-orange-400 font-medium' : 'text-gray-600 dark:text-gray-400'}`}>
|
|
||||||
{formatPercent(memPercent())}
|
|
||||||
</div>
|
|
||||||
<div class="hidden md:block w-full">
|
|
||||||
<MetricBar
|
|
||||||
value={memPercent()}
|
|
||||||
label={formatPercent(memPercent())}
|
|
||||||
sublabel={memoryUsageLabel()}
|
|
||||||
type="memory"
|
|
||||||
resourceId={metricsKey()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Disk */}
|
|
||||||
<div class="flex-1 px-0.5 py-1 md:px-2 md:py-0 w-auto min-w-[35px] md:w-full flex justify-center items-center">
|
|
||||||
<Show
|
|
||||||
when={hasDiskUsage()}
|
|
||||||
fallback={
|
|
||||||
<span class="text-xs text-gray-400 cursor-help" title={getDiskStatusTooltip()}>
|
|
||||||
-
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div class={`md:hidden text-xs ${diskPercent() >= 90 ? 'text-red-600 dark:text-red-400 font-bold' : diskPercent() >= 80 ? 'text-orange-600 dark:text-orange-400 font-medium' : 'text-gray-600 dark:text-gray-400'}`}>
|
|
||||||
{formatPercent(diskPercent())}
|
|
||||||
</div>
|
|
||||||
<div class="hidden md:block w-full">
|
|
||||||
<MetricBar
|
|
||||||
value={diskPercent()}
|
|
||||||
label={formatPercent(diskPercent())}
|
|
||||||
sublabel={
|
|
||||||
props.guest.disk
|
|
||||||
? `${formatBytes(props.guest.disk.used, 0)}/${formatBytes(props.guest.disk.total, 0)}`
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
type="disk"
|
|
||||||
resourceId={metricsKey()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Disk I/O */}
|
|
||||||
<div class="hidden xl:flex px-2 py-1 xl:px-2 xl:py-0 w-auto min-w-[60px] xl:w-full justify-start items-center">
|
|
||||||
<div class="flex h-[24px] items-center w-full justify-start">
|
|
||||||
<IOMetric value={props.guest.diskRead} disabled={!isRunning()} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="hidden xl:flex px-2 py-1 xl:px-2 xl:py-0 w-auto min-w-[60px] xl:w-full justify-start items-center">
|
|
||||||
<div class="flex h-[24px] items-center w-full justify-start">
|
|
||||||
<IOMetric value={props.guest.diskWrite} disabled={!isRunning()} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Network I/O */}
|
|
||||||
<div class="hidden xl:flex px-2 py-1 xl:px-2 xl:py-0 w-auto min-w-[60px] xl:w-full justify-start items-center">
|
|
||||||
<div class="flex h-[24px] items-center w-full justify-start">
|
|
||||||
<IOMetric value={props.guest.networkIn} disabled={!isRunning()} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="hidden xl:flex px-2 py-1 xl:px-2 xl:py-0 w-auto min-w-[60px] xl:w-full justify-start items-center">
|
|
||||||
<div class="flex h-[24px] items-center w-full justify-start">
|
|
||||||
<IOMetric value={props.guest.networkOut} disabled={!isRunning()} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={drawerOpen() && canShowDrawer()}>
|
<Show when={drawerOpen() && canShowDrawer()}>
|
||||||
|
|
@ -690,4 +708,4 @@ export function GuestRow(props: GuestRowProps) {
|
||||||
</Show>
|
</Show>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { Component, For, Show, createMemo, createSignal } from 'solid-js';
|
import { Component, For, Show, createMemo, createSignal } from 'solid-js';
|
||||||
import type { DockerHost } from '@/types/api';
|
import type { DockerHost } from '@/types/api';
|
||||||
import { Card } from '@/components/shared/Card';
|
import { Card } from '@/components/shared/Card';
|
||||||
import { MetricBar } from '@/components/Dashboard/MetricBar';
|
|
||||||
import { renderDockerStatusBadge } from './DockerStatusBadge';
|
import { renderDockerStatusBadge } from './DockerStatusBadge';
|
||||||
import { resolveHostRuntime } from './runtimeDisplay';
|
import { resolveHostRuntime } from './runtimeDisplay';
|
||||||
import { formatPercent, formatUptime } from '@/utils/format';
|
import { formatPercent, formatUptime } from '@/utils/format';
|
||||||
|
|
@ -9,6 +8,8 @@ import { ScrollableTable } from '@/components/shared/ScrollableTable';
|
||||||
import { buildMetricKey } from '@/utils/metricsKeys';
|
import { buildMetricKey } from '@/utils/metricsKeys';
|
||||||
import { StatusDot } from '@/components/shared/StatusDot';
|
import { StatusDot } from '@/components/shared/StatusDot';
|
||||||
import { getDockerHostStatusIndicator } from '@/utils/status';
|
import { getDockerHostStatusIndicator } from '@/utils/status';
|
||||||
|
import { useBreakpoint } from '@/hooks/useBreakpoint';
|
||||||
|
import { ResponsiveMetricCell } from '@/components/shared/responsive';
|
||||||
|
|
||||||
export interface DockerHostSummary {
|
export interface DockerHostSummary {
|
||||||
host: DockerHost;
|
host: DockerHost;
|
||||||
|
|
@ -47,6 +48,7 @@ const getDisplayName = (host: DockerHost) => {
|
||||||
export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (props) => {
|
export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (props) => {
|
||||||
const [sortKey, setSortKey] = createSignal<SortKey>('name');
|
const [sortKey, setSortKey] = createSignal<SortKey>('name');
|
||||||
const [sortDirection, setSortDirection] = createSignal<SortDirection>('asc');
|
const [sortDirection, setSortDirection] = createSignal<SortDirection>('asc');
|
||||||
|
const { isMobile } = useBreakpoint();
|
||||||
|
|
||||||
const handleSort = (key: SortKey) => {
|
const handleSort = (key: SortKey) => {
|
||||||
if (sortKey() === key) {
|
if (sortKey() === key) {
|
||||||
|
|
@ -328,51 +330,33 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-0.5 md:px-2 py-1 align-middle">
|
<td class="px-0.5 md:px-2 py-1 align-middle">
|
||||||
<Show when={online} fallback={<span class="text-xs text-gray-400 dark:text-gray-500">—</span>}>
|
<ResponsiveMetricCell
|
||||||
<div class={`md:hidden text-xs text-center ${summary.cpuPercent >= 90 ? 'text-red-600 dark:text-red-400 font-bold' : summary.cpuPercent >= 80 ? 'text-orange-600 dark:text-orange-400 font-medium' : 'text-gray-600 dark:text-gray-400'}`}>
|
value={summary.cpuPercent}
|
||||||
{formatPercent(summary.cpuPercent)}
|
type="cpu"
|
||||||
</div>
|
resourceId={metricsKey}
|
||||||
<div class="hidden md:block">
|
isRunning={online}
|
||||||
<MetricBar
|
showMobile={isMobile()}
|
||||||
value={summary.cpuPercent}
|
/>
|
||||||
label={formatPercent(summary.cpuPercent)}
|
|
||||||
type="cpu"
|
|
||||||
resourceId={metricsKey}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</td>
|
</td>
|
||||||
<td class="px-0.5 md:px-2 py-1 align-middle">
|
<td class="px-0.5 md:px-2 py-1 align-middle">
|
||||||
<Show when={online} fallback={<span class="text-xs text-gray-400 dark:text-gray-500">—</span>}>
|
<ResponsiveMetricCell
|
||||||
<div class={`md:hidden text-xs text-center ${summary.memoryPercent >= 85 ? 'text-red-600 dark:text-red-400 font-bold' : summary.memoryPercent >= 75 ? 'text-orange-600 dark:text-orange-400 font-medium' : 'text-gray-600 dark:text-gray-400'}`}>
|
value={summary.memoryPercent}
|
||||||
{formatPercent(summary.memoryPercent)}
|
type="memory"
|
||||||
</div>
|
sublabel={summary.memoryLabel}
|
||||||
<div class="hidden md:block">
|
resourceId={metricsKey}
|
||||||
<MetricBar
|
isRunning={online}
|
||||||
value={summary.memoryPercent}
|
showMobile={isMobile()}
|
||||||
label={formatPercent(summary.memoryPercent)}
|
/>
|
||||||
sublabel={summary.memoryLabel}
|
|
||||||
type="memory"
|
|
||||||
resourceId={metricsKey}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</td>
|
</td>
|
||||||
<td class="px-0.5 md:px-2 py-1 align-middle">
|
<td class="px-0.5 md:px-2 py-1 align-middle">
|
||||||
<Show when={summary.diskLabel} fallback={<span class="text-xs text-gray-400 dark:text-gray-500">—</span>}>
|
<ResponsiveMetricCell
|
||||||
<div class={`md:hidden text-xs text-center ${summary.diskPercent >= 90 ? 'text-red-600 dark:text-red-400 font-bold' : summary.diskPercent >= 80 ? 'text-orange-600 dark:text-orange-400 font-medium' : 'text-gray-600 dark:text-gray-400'}`}>
|
value={summary.diskPercent}
|
||||||
{formatPercent(summary.diskPercent)}
|
type="disk"
|
||||||
</div>
|
sublabel={summary.diskLabel}
|
||||||
<div class="hidden md:block">
|
resourceId={metricsKey}
|
||||||
<MetricBar
|
isRunning={!!summary.diskLabel}
|
||||||
value={summary.diskPercent}
|
showMobile={isMobile()}
|
||||||
label={formatPercent(summary.diskPercent)}
|
/>
|
||||||
sublabel={summary.diskLabel}
|
|
||||||
type="disk"
|
|
||||||
resourceId={metricsKey}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</td>
|
</td>
|
||||||
<td class="px-0.5 md:px-2 py-1 align-middle">
|
<td class="px-0.5 md:px-2 py-1 align-middle">
|
||||||
<div class="flex justify-center items-center h-full whitespace-nowrap">
|
<div class="flex justify-center items-center h-full whitespace-nowrap">
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { Component, For, Show, createMemo, createSignal } from 'solid-js';
|
import { Component, For, Show, createMemo, createSignal } from 'solid-js';
|
||||||
import type { Node, VM, Container, Storage, PBSInstance } from '@/types/api';
|
import type { Node, VM, Container, Storage, PBSInstance } from '@/types/api';
|
||||||
import { formatBytes, formatPercent, formatUptime } from '@/utils/format';
|
import { formatBytes, formatUptime } from '@/utils/format';
|
||||||
import { MetricBar } from '@/components/Dashboard/MetricBar';
|
|
||||||
import { useWebSocket } from '@/App';
|
import { useWebSocket } from '@/App';
|
||||||
import { getAlertStyles } from '@/utils/alerts';
|
import { getAlertStyles } from '@/utils/alerts';
|
||||||
import { Card } from '@/components/shared/Card';
|
import { Card } from '@/components/shared/Card';
|
||||||
|
|
@ -11,6 +10,88 @@ import { useAlertsActivation } from '@/stores/alertsActivation';
|
||||||
import { buildMetricKey } from '@/utils/metricsKeys';
|
import { buildMetricKey } from '@/utils/metricsKeys';
|
||||||
import { StatusDot } from '@/components/shared/StatusDot';
|
import { StatusDot } from '@/components/shared/StatusDot';
|
||||||
import { getNodeStatusIndicator, getPBSStatusIndicator } from '@/utils/status';
|
import { getNodeStatusIndicator, getPBSStatusIndicator } from '@/utils/status';
|
||||||
|
import { type ColumnPriority } from '@/hooks/useBreakpoint';
|
||||||
|
import { ResponsiveMetricCell, useGridTemplate } from '@/components/shared/responsive';
|
||||||
|
|
||||||
|
// Icons for mobile headers
|
||||||
|
const ClockIcon = (props: { class?: string }) => (
|
||||||
|
<svg class={props.class} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const CpuIcon = (props: { class?: string }) => (
|
||||||
|
<svg class={props.class} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const MemoryIcon = (props: { class?: string }) => (
|
||||||
|
<svg class={props.class} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const DiskIcon = (props: { class?: string }) => (
|
||||||
|
<svg class={props.class} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const TempIcon = (props: { class?: string }) => (
|
||||||
|
<svg class={props.class} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 2a2 2 0 00-2 2v9.5a4 4 0 104 0V4a2 2 0 00-2-2z" />
|
||||||
|
<circle cx="12" cy="17" r="2" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const VMIcon = (props: { class?: string }) => (
|
||||||
|
<svg class={props.class} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ContainerIcon = (props: { class?: string }) => (
|
||||||
|
<svg class={props.class} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const BackupIcon = (props: { class?: string }) => (
|
||||||
|
<svg class={props.class} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Column configuration using the new priority system
|
||||||
|
interface ColumnDef {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
priority: ColumnPriority;
|
||||||
|
icon?: Component<{ class?: string }>;
|
||||||
|
minWidth?: string;
|
||||||
|
maxWidth?: string;
|
||||||
|
flex?: number;
|
||||||
|
align?: 'left' | 'center' | 'right';
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE_COLUMNS: ColumnDef[] = [
|
||||||
|
{ id: 'name', label: 'Node', priority: 'essential', minWidth: '70px', flex: 1.5, align: 'left' },
|
||||||
|
{ id: 'uptime', label: 'Uptime', priority: 'essential', icon: ClockIcon, minWidth: '35px', maxWidth: '70px', align: 'center' },
|
||||||
|
{ id: 'cpu', label: 'CPU', priority: 'essential', icon: CpuIcon, minWidth: '40px', flex: 1.2, align: 'center' },
|
||||||
|
{ id: 'memory', label: 'Memory', priority: 'essential', icon: MemoryIcon, minWidth: '40px', flex: 1.2, align: 'center' },
|
||||||
|
{ id: 'disk', label: 'Disk', priority: 'essential', icon: DiskIcon, minWidth: '40px', flex: 1.2, align: 'center' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const TEMP_COLUMN: ColumnDef = {
|
||||||
|
id: 'temperature',
|
||||||
|
label: 'Temp',
|
||||||
|
priority: 'essential',
|
||||||
|
icon: TempIcon,
|
||||||
|
minWidth: '35px',
|
||||||
|
maxWidth: '55px',
|
||||||
|
align: 'center'
|
||||||
|
};
|
||||||
|
|
||||||
interface NodeSummaryTableProps {
|
interface NodeSummaryTableProps {
|
||||||
nodes: Node[];
|
nodes: Node[];
|
||||||
|
|
@ -32,7 +113,6 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
|
||||||
|
|
||||||
const isTemperatureMonitoringEnabled = (node: Node): boolean => {
|
const isTemperatureMonitoringEnabled = (node: Node): boolean => {
|
||||||
const globalEnabled = props.globalTemperatureMonitoringEnabled ?? true;
|
const globalEnabled = props.globalTemperatureMonitoringEnabled ?? true;
|
||||||
// Check per-node setting first, fall back to global
|
|
||||||
if (node.temperatureMonitoringEnabled !== undefined && node.temperatureMonitoringEnabled !== null) {
|
if (node.temperatureMonitoringEnabled !== undefined && node.temperatureMonitoringEnabled !== null) {
|
||||||
return node.temperatureMonitoringEnabled;
|
return node.temperatureMonitoringEnabled;
|
||||||
}
|
}
|
||||||
|
|
@ -58,6 +138,7 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
|
||||||
interface CountColumn {
|
interface CountColumn {
|
||||||
header: string;
|
header: string;
|
||||||
key: CountSortKey;
|
key: CountSortKey;
|
||||||
|
icon: Component<{ class?: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [sortKey, setSortKey] = createSignal<SortKey>('default');
|
const [sortKey, setSortKey] = createSignal<SortKey>('default');
|
||||||
|
|
@ -67,23 +148,22 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
|
||||||
switch (props.currentTab) {
|
switch (props.currentTab) {
|
||||||
case 'dashboard':
|
case 'dashboard':
|
||||||
return [
|
return [
|
||||||
{ header: 'VMs', key: 'vmCount' },
|
{ header: 'VMs', key: 'vmCount', icon: VMIcon },
|
||||||
{ header: 'Containers', key: 'containerCount' },
|
{ header: 'CTs', key: 'containerCount', icon: ContainerIcon },
|
||||||
];
|
];
|
||||||
case 'storage':
|
case 'storage':
|
||||||
return [
|
return [
|
||||||
{ header: 'Storage', key: 'storageCount' },
|
{ header: 'Storage', key: 'storageCount', icon: DiskIcon },
|
||||||
{ header: 'Disks', key: 'diskCount' },
|
{ header: 'Disks', key: 'diskCount', icon: DiskIcon },
|
||||||
];
|
];
|
||||||
case 'backups':
|
case 'backups':
|
||||||
return [{ header: 'Backups', key: 'backupCount' }];
|
return [{ header: 'Backups', key: 'backupCount', icon: BackupIcon }];
|
||||||
default:
|
default:
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const hasAnyTemperatureData = createMemo(() => {
|
const hasAnyTemperatureData = createMemo(() => {
|
||||||
// Show temperature column if ANY node has monitoring enabled OR has temperature data
|
|
||||||
return (
|
return (
|
||||||
props.nodes?.some(
|
props.nodes?.some(
|
||||||
(node) => node.temperature?.available || isTemperatureMonitoringEnabled(node),
|
(node) => node.temperature?.available || isTemperatureMonitoringEnabled(node),
|
||||||
|
|
@ -91,6 +171,34 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Build dynamic columns list
|
||||||
|
const columns = createMemo<ColumnDef[]>(() => {
|
||||||
|
const cols = [...BASE_COLUMNS];
|
||||||
|
|
||||||
|
if (hasAnyTemperatureData()) {
|
||||||
|
cols.push(TEMP_COLUMN);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add count columns based on tab
|
||||||
|
countColumns().forEach((cc) => {
|
||||||
|
cols.push({
|
||||||
|
id: cc.key,
|
||||||
|
label: cc.header,
|
||||||
|
priority: 'essential' as ColumnPriority,
|
||||||
|
icon: cc.icon,
|
||||||
|
minWidth: '40px',
|
||||||
|
maxWidth: '50px',
|
||||||
|
align: 'center',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return cols;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Use the responsive grid template hook for dynamic column visibility
|
||||||
|
// Pass the columns accessor to support reactive column changes
|
||||||
|
const { gridTemplate, visibleColumns, isMobile } = useGridTemplate({ columns });
|
||||||
|
|
||||||
const nodeKey = (instance?: string, nodeName?: string) => `${instance ?? ''}::${nodeName ?? ''}`;
|
const nodeKey = (instance?: string, nodeName?: string) => `${instance ?? ''}::${nodeName ?? ''}`;
|
||||||
|
|
||||||
const vmCountsByNode = createMemo<Record<string, number>>(() => {
|
const vmCountsByNode = createMemo<Record<string, number>>(() => {
|
||||||
|
|
@ -322,7 +430,6 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
|
||||||
return aStr < bStr ? -1 : 1;
|
return aStr < bStr ? -1 : 1;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Combine and sort nodes based on current sort selection
|
|
||||||
const sortedItems = createMemo(() => {
|
const sortedItems = createMemo(() => {
|
||||||
const items: SortableItem[] = [];
|
const items: SortableItem[] = [];
|
||||||
|
|
||||||
|
|
@ -349,377 +456,292 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const gridTemplate = createMemo(() => {
|
// Header cell component
|
||||||
const parts = ['minmax(90px, 1.5fr)']; // Name
|
const HeaderCell: Component<{ column: ColumnDef }> = (cellProps) => {
|
||||||
parts.push('minmax(45px, 80px)'); // Uptime
|
const Icon = cellProps.column.icon;
|
||||||
parts.push('minmax(55px, 1.5fr)'); // CPU
|
const isSorted = () => sortKey() === cellProps.column.id;
|
||||||
parts.push('minmax(55px, 1.5fr)'); // Memory
|
const sortIndicator = () => isSorted() ? (sortDirection() === 'asc' ? '▲' : '▼') : '';
|
||||||
parts.push('minmax(55px, 1.5fr)'); // Disk
|
|
||||||
|
|
||||||
if (hasAnyTemperatureData()) {
|
const alignClass = () => {
|
||||||
parts.push('minmax(45px, 65px)'); // Temp
|
if (cellProps.column.align === 'center') return 'justify-center text-center';
|
||||||
}
|
if (cellProps.column.align === 'right') return 'justify-end text-right';
|
||||||
|
return 'justify-start text-left';
|
||||||
|
};
|
||||||
|
|
||||||
countColumns().forEach(() => {
|
const baseClass = `px-0.5 md:px-2 py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center h-full ${alignClass()}`;
|
||||||
parts.push('minmax(40px, 85px)'); // Counts
|
const nameClass = cellProps.column.id === 'name' ? 'pl-3' : '';
|
||||||
});
|
|
||||||
|
|
||||||
return parts.join(' ');
|
return (
|
||||||
});
|
<div
|
||||||
|
class={`${baseClass} ${nameClass}`}
|
||||||
// Don't return null - let the table render even if empty
|
onClick={() => handleSort(cellProps.column.id as Exclude<SortKey, 'default'>)}
|
||||||
// This prevents the table from disappearing on refresh while data loads
|
onKeyDown={(e) => e.key === 'Enter' && handleSort(cellProps.column.id as Exclude<SortKey, 'default'>)}
|
||||||
|
tabindex="0"
|
||||||
|
role="button"
|
||||||
|
aria-label={`Sort by ${cellProps.column.label} ${isSorted() ? (sortDirection() === 'asc' ? 'ascending' : 'descending') : ''}`}
|
||||||
|
>
|
||||||
|
<Show when={Icon !== undefined && isMobile()}>
|
||||||
|
<span class="md:hidden" title={cellProps.column.label}>
|
||||||
|
{Icon && <Icon class="w-4 h-4" />}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
<span class={Icon !== undefined && isMobile() ? 'hidden md:inline' : ''}>
|
||||||
|
{cellProps.column.id === 'name' && props.currentTab === 'backups' ? 'Node / PBS' : cellProps.column.label}
|
||||||
|
</span>
|
||||||
|
<Show when={sortIndicator()}>
|
||||||
|
<span class="ml-0.5">{sortIndicator()}</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Card padding="none" class="mb-4 overflow-hidden">
|
||||||
<Card padding="none" class="mb-4 overflow-hidden">
|
<div>
|
||||||
<div class="overflow-x-auto">
|
{/* Header */}
|
||||||
{/* Header */}
|
<div
|
||||||
<div
|
class="grid items-center bg-gray-50 dark:bg-gray-700/50 text-gray-600 dark:text-gray-300 border-b border-gray-200 dark:border-gray-600 text-[11px] sm:text-xs font-medium uppercase tracking-wider sticky top-0 z-20"
|
||||||
class="grid items-center bg-gray-50 dark:bg-gray-700/50 text-gray-600 dark:text-gray-300 border-b border-gray-200 dark:border-gray-600 text-[11px] sm:text-xs font-medium uppercase tracking-wider sticky top-0 z-20 min-w-full"
|
style={{ 'grid-template-columns': gridTemplate() }}
|
||||||
style={{ 'grid-template-columns': gridTemplate() }}
|
>
|
||||||
>
|
<For each={visibleColumns()}>
|
||||||
<div
|
{(column, index) => (
|
||||||
class="sticky left-0 z-30 bg-gray-50 dark:bg-gray-700/50 pl-3 pr-2 py-1 text-left border-r md:border-r-0 border-gray-200 dark:border-gray-600 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center h-full"
|
<HeaderCell column={column} />
|
||||||
onClick={() => handleSort('name')}
|
)}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleSort('name')}
|
</For>
|
||||||
tabindex="0"
|
</div>
|
||||||
role="button"
|
|
||||||
aria-label={`Sort by name ${sortKey() === 'name' ? (sortDirection() === 'asc' ? 'ascending' : 'descending') : ''
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{props.currentTab === 'backups' ? 'Node / PBS' : 'Node'}{' '}
|
|
||||||
{sortKey() === 'name' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="px-0.5 md:px-2 py-1 text-center md:text-left cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center md:justify-start h-full"
|
|
||||||
onClick={() => handleSort('uptime')}
|
|
||||||
>
|
|
||||||
<span class="md:hidden" title="Uptime">
|
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
|
||||||
</span>
|
|
||||||
<span class="hidden md:inline">Uptime</span>
|
|
||||||
{sortKey() === 'uptime' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="px-0.5 md:px-2 py-1 text-center md:text-left cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center md:justify-start h-full"
|
|
||||||
onClick={() => handleSort('cpu')}
|
|
||||||
>
|
|
||||||
<span class="md:hidden" title="CPU">
|
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" /></svg>
|
|
||||||
</span>
|
|
||||||
<span class="hidden md:inline">CPU</span>
|
|
||||||
{sortKey() === 'cpu' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="px-0.5 md:px-2 py-1 text-center md:text-left cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center md:justify-start h-full"
|
|
||||||
onClick={() => handleSort('memory')}
|
|
||||||
>
|
|
||||||
<span class="md:hidden" title="Memory">
|
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>
|
|
||||||
</span>
|
|
||||||
<span class="hidden md:inline">Memory</span>
|
|
||||||
{sortKey() === 'memory' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="px-0.5 md:px-2 py-1 text-center md:text-left cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center md:justify-start h-full"
|
|
||||||
onClick={() => handleSort('disk')}
|
|
||||||
>
|
|
||||||
<span class="md:hidden" title={props.currentTab === 'backups' && props.pbsInstances ? 'Storage / Disk' : 'Disk'}>
|
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" /></svg>
|
|
||||||
</span>
|
|
||||||
<span class="hidden md:inline">
|
|
||||||
{props.currentTab === 'backups' && props.pbsInstances ? 'Storage / Disk' : 'Disk'}
|
|
||||||
</span>
|
|
||||||
{sortKey() === 'disk' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
|
||||||
</div>
|
|
||||||
<Show when={hasAnyTemperatureData()}>
|
|
||||||
<div
|
|
||||||
class="px-0.5 md:px-2 py-1 text-center md:text-left cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center md:justify-start h-full"
|
|
||||||
onClick={() => handleSort('temperature')}
|
|
||||||
>
|
|
||||||
<span class="md:hidden" title="Temperature">
|
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" /></svg>
|
|
||||||
</span>
|
|
||||||
<span class="hidden md:inline">Temp</span>
|
|
||||||
{sortKey() === 'temperature' && (sortDirection() === 'asc' ? '▲' : '▼')}
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
<For each={countColumns()}>
|
|
||||||
{(column) => (
|
|
||||||
<div
|
|
||||||
class="px-0.5 md:px-2 py-1 text-center cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 flex items-center justify-center h-full"
|
|
||||||
onClick={() => handleSort(column.key)}
|
|
||||||
>
|
|
||||||
<span class="md:hidden" title={column.header}>
|
|
||||||
<Show when={column.key === 'vmCount'}>
|
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg>
|
|
||||||
</Show>
|
|
||||||
<Show when={column.key === 'containerCount'}>
|
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" /></svg>
|
|
||||||
</Show>
|
|
||||||
<Show when={column.key === 'storageCount' || column.key === 'diskCount'}>
|
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" /></svg>
|
|
||||||
</Show>
|
|
||||||
<Show when={column.key === 'backupCount'}>
|
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4" /></svg>
|
|
||||||
</Show>
|
|
||||||
</span>
|
|
||||||
<span class="hidden md:inline">{column.header}</span>
|
|
||||||
{sortKey() === column.key && (sortDirection() === 'asc' ? '▲' : '▼')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="divide-y divide-gray-200 dark:divide-gray-700 min-w-full">
|
<div class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
<For each={sortedItems()}>
|
<For each={sortedItems()}>
|
||||||
{(item) => {
|
{(item) => {
|
||||||
const isPVE = item.type === 'pve';
|
const isPVE = item.type === 'pve';
|
||||||
const isPBS = item.type === 'pbs';
|
const isPBS = item.type === 'pbs';
|
||||||
const node = isPVE ? (item.data as Node) : null;
|
const node = isPVE ? (item.data as Node) : null;
|
||||||
const pbs = isPBS ? (item.data as PBSInstance) : null;
|
const pbs = isPBS ? (item.data as PBSInstance) : null;
|
||||||
|
|
||||||
const online = isItemOnline(item);
|
const online = isItemOnline(item);
|
||||||
const statusIndicator = createMemo(() =>
|
const statusIndicator = createMemo(() =>
|
||||||
isPVE ? getNodeStatusIndicator(node as Node) : getPBSStatusIndicator(pbs as PBSInstance),
|
isPVE ? getNodeStatusIndicator(node as Node) : getPBSStatusIndicator(pbs as PBSInstance),
|
||||||
);
|
);
|
||||||
const cpuPercentValue = getCpuPercent(item);
|
const cpuPercentValue = getCpuPercent(item);
|
||||||
const memoryPercentValue = getMemoryPercent(item);
|
const memoryPercentValue = getMemoryPercent(item);
|
||||||
const diskPercentValue = getDiskPercent(item);
|
const diskPercentValue = getDiskPercent(item);
|
||||||
const diskSublabel = getDiskSublabel(item);
|
const diskSublabel = getDiskSublabel(item);
|
||||||
const cpuTemperatureValue = getCpuTemperatureValue(item);
|
const cpuTemperatureValue = getCpuTemperatureValue(item);
|
||||||
const uptimeValue = isPVE ? node?.uptime ?? 0 : isPBS ? pbs?.uptime ?? 0 : 0;
|
const uptimeValue = isPVE ? node?.uptime ?? 0 : isPBS ? pbs?.uptime ?? 0 : 0;
|
||||||
const displayName = () => {
|
const displayName = () => {
|
||||||
if (isPVE) return getNodeDisplayName(node as Node);
|
if (isPVE) return getNodeDisplayName(node as Node);
|
||||||
return (pbs as PBSInstance).name;
|
return (pbs as PBSInstance).name;
|
||||||
};
|
};
|
||||||
const showActualName = () => isPVE && hasAlternateDisplayName(node as Node);
|
const showActualName = () => isPVE && hasAlternateDisplayName(node as Node);
|
||||||
|
|
||||||
// Use unique node ID (not hostname) to handle duplicate node names
|
const nodeId = isPVE ? node!.id : pbs!.name;
|
||||||
const nodeId = isPVE ? node!.id : pbs!.name;
|
const isSelected = () => props.selectedNode === nodeId;
|
||||||
const isSelected = () => props.selectedNode === nodeId;
|
const resourceId = isPVE ? node!.id || node!.name : pbs!.id || pbs!.name;
|
||||||
// Use the full resource ID for alert matching
|
const metricsKey = buildMetricKey('node', resourceId);
|
||||||
const resourceId = isPVE ? node!.id || node!.name : pbs!.id || pbs!.name;
|
const alertStyles = createMemo(() =>
|
||||||
// Use namespaced metric key for sparklines
|
getAlertStyles(resourceId, activeAlerts, alertsEnabled()),
|
||||||
const metricsKey = buildMetricKey('node', resourceId);
|
);
|
||||||
const alertStyles = createMemo(() =>
|
const showAlertHighlight = createMemo(
|
||||||
getAlertStyles(resourceId, activeAlerts, alertsEnabled()),
|
() => alertStyles().hasUnacknowledgedAlert && online,
|
||||||
);
|
);
|
||||||
const showAlertHighlight = createMemo(
|
|
||||||
() => alertStyles().hasUnacknowledgedAlert && online,
|
|
||||||
);
|
|
||||||
|
|
||||||
const rowStyle = createMemo(() => {
|
const rowStyle = createMemo(() => {
|
||||||
const styles: Record<string, string> = {};
|
const styles: Record<string, string> = {};
|
||||||
const shadows: string[] = [];
|
const shadows: string[] = [];
|
||||||
|
|
||||||
if (showAlertHighlight()) {
|
if (showAlertHighlight()) {
|
||||||
const color = alertStyles().severity === 'critical' ? '#ef4444' : '#eab308';
|
const color = alertStyles().severity === 'critical' ? '#ef4444' : '#eab308';
|
||||||
shadows.push(`inset 4px 0 0 0 ${color}`);
|
shadows.push(`inset 4px 0 0 0 ${color}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isSelected()) {
|
if (isSelected()) {
|
||||||
shadows.push('0 0 0 1px rgba(59, 130, 246, 0.5)');
|
shadows.push('0 0 0 1px rgba(59, 130, 246, 0.5)');
|
||||||
shadows.push('0 2px 4px -1px rgba(0, 0, 0, 0.1)');
|
shadows.push('0 2px 4px -1px rgba(0, 0, 0, 0.1)');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shadows.length > 0) {
|
if (shadows.length > 0) {
|
||||||
styles['box-shadow'] = shadows.join(', ');
|
styles['box-shadow'] = shadows.join(', ');
|
||||||
}
|
}
|
||||||
|
|
||||||
return styles;
|
return styles;
|
||||||
});
|
});
|
||||||
|
|
||||||
const rowClass = createMemo(() => {
|
const rowClass = createMemo(() => {
|
||||||
const baseHover = 'cursor-pointer transition-all duration-200 relative hover:shadow-sm group';
|
const baseHover = 'cursor-pointer transition-all duration-200 relative hover:shadow-sm group';
|
||||||
|
|
||||||
if (isSelected()) {
|
if (isSelected()) {
|
||||||
return `cursor-pointer transition-all duration-200 relative hover:shadow-sm z-10 group`;
|
return `cursor-pointer transition-all duration-200 relative hover:shadow-sm z-10 group`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showAlertHighlight()) {
|
if (showAlertHighlight()) {
|
||||||
return alertStyles().severity === 'critical'
|
return 'cursor-pointer transition-all duration-200 relative hover:shadow-sm group';
|
||||||
? 'cursor-pointer transition-all duration-200 relative hover:shadow-sm group'
|
}
|
||||||
: 'cursor-pointer transition-all duration-200 relative hover:shadow-sm group';
|
|
||||||
}
|
|
||||||
|
|
||||||
let className = baseHover;
|
let className = baseHover;
|
||||||
|
|
||||||
if (props.selectedNode && props.selectedNode !== nodeId) {
|
if (props.selectedNode && props.selectedNode !== nodeId) {
|
||||||
className += ' opacity-50 hover:opacity-80';
|
className += ' opacity-50 hover:opacity-80';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!online) {
|
if (!online) {
|
||||||
className += ' opacity-60';
|
className += ' opacity-60';
|
||||||
}
|
}
|
||||||
|
|
||||||
return className;
|
return className;
|
||||||
});
|
});
|
||||||
|
|
||||||
const cellBgClass = createMemo(() => {
|
const cellBgClass = createMemo(() => {
|
||||||
if (isSelected()) return 'bg-blue-50 dark:bg-blue-900/20 group-hover:bg-blue-100 dark:group-hover:bg-blue-900/30';
|
if (isSelected()) return 'bg-blue-50 dark:bg-blue-900/20 group-hover:bg-blue-100 dark:group-hover:bg-blue-900/30';
|
||||||
if (showAlertHighlight()) {
|
if (showAlertHighlight()) {
|
||||||
return alertStyles().severity === 'critical'
|
return alertStyles().severity === 'critical'
|
||||||
? 'bg-red-50 dark:bg-red-950/30 group-hover:bg-red-100 dark:group-hover:bg-red-950/40'
|
? 'bg-red-50 dark:bg-red-950/30 group-hover:bg-red-100 dark:group-hover:bg-red-950/40'
|
||||||
: 'bg-yellow-50 dark:bg-yellow-950/20 group-hover:bg-yellow-100 dark:group-hover:bg-yellow-950/30';
|
: 'bg-yellow-50 dark:bg-yellow-950/20 group-hover:bg-yellow-100 dark:group-hover:bg-yellow-950/30';
|
||||||
}
|
}
|
||||||
return 'bg-white dark:bg-gray-800 group-hover:bg-gray-50 dark:group-hover:bg-gray-700/50';
|
return 'bg-white dark:bg-gray-800 group-hover:bg-gray-50 dark:group-hover:bg-gray-700/50';
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
// Render cell content based on column type
|
||||||
<div
|
const renderCell = (column: ColumnDef) => {
|
||||||
class={`${rowClass()} grid items-center`}
|
const baseCellClass = `${cellBgClass()} px-0.5 md:px-2 py-1 flex items-center h-full`;
|
||||||
style={{ ...rowStyle(), 'grid-template-columns': gridTemplate() }}
|
const alignClass = column.align === 'center' ? 'justify-center' : column.align === 'right' ? 'justify-end' : 'justify-start';
|
||||||
onClick={() => props.onNodeClick(nodeId, item.type)}
|
|
||||||
>
|
switch (column.id) {
|
||||||
<div
|
case 'name':
|
||||||
class={`sticky left-0 z-10 ${cellBgClass()} pr-2 py-1 whitespace-nowrap border-r md:border-r-0 border-gray-100 dark:border-gray-700 flex items-center h-full ${showAlertHighlight() ? 'pl-4' : 'pl-3'}`}
|
return (
|
||||||
>
|
<div class={`${baseCellClass} ${showAlertHighlight() ? 'pl-4' : 'pl-3'}`}>
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<StatusDot
|
<StatusDot
|
||||||
variant={statusIndicator().variant}
|
variant={statusIndicator().variant}
|
||||||
title={statusIndicator().label}
|
title={statusIndicator().label}
|
||||||
ariaLabel={statusIndicator().label}
|
ariaLabel={statusIndicator().label}
|
||||||
size="xs"
|
size="xs"
|
||||||
/>
|
/>
|
||||||
<a
|
<a
|
||||||
href={
|
href={
|
||||||
isPVE
|
isPVE
|
||||||
? node!.guestURL || node!.host || `https://${node!.name}:8006`
|
? node!.guestURL || node!.host || `https://${node!.name}:8006`
|
||||||
: pbs!.host || `https://${pbs!.name}:8007`
|
: pbs!.host || `https://${pbs!.name}:8007`
|
||||||
}
|
}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
class="font-medium text-[11px] text-gray-900 dark:text-gray-100 hover:text-blue-600 dark:hover:text-blue-400 truncate"
|
class="font-medium text-[11px] text-gray-900 dark:text-gray-100 hover:text-blue-600 dark:hover:text-blue-400 truncate"
|
||||||
title={displayName()}
|
title={displayName()}
|
||||||
>
|
>
|
||||||
{displayName()}
|
{displayName()}
|
||||||
</a>
|
</a>
|
||||||
<Show when={showActualName()}>
|
<Show when={showActualName()}>
|
||||||
<span class="text-[9px] text-gray-500 dark:text-gray-400 truncate">
|
<span class="text-[9px] text-gray-500 dark:text-gray-400 truncate">
|
||||||
({(node as Node).name})
|
({(node as Node).name})
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
<div class="hidden xl:flex items-center gap-1.5 ml-1">
|
|
||||||
<Show when={isPVE}>
|
|
||||||
<span class="text-[9px] px-1 py-0 rounded text-[8px] font-medium bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400">
|
|
||||||
PVE
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
<Show when={isPVE && node!.pveVersion}>
|
|
||||||
<span class="text-[9px] text-gray-500 dark:text-gray-400">
|
|
||||||
v{node!.pveVersion.split('/')[1] || node!.pveVersion}
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
<Show when={isPVE && node!.isClusterMember !== undefined}>
|
|
||||||
<span
|
|
||||||
class={`text-[9px] px-1 py-0 rounded text-[8px] font-medium whitespace-nowrap ${node!.isClusterMember
|
|
||||||
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
|
|
||||||
: 'bg-gray-100 text-gray-600 dark:bg-gray-700/50 dark:text-gray-400'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{node!.isClusterMember ? node!.clusterName : 'Standalone'}
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
<Show when={isPBS}>
|
|
||||||
<span class="text-[9px] px-1 py-0 rounded text-[8px] font-medium bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400">
|
|
||||||
PBS
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
<Show when={isPBS && pbs!.version}>
|
|
||||||
<span class="text-[9px] text-gray-500 dark:text-gray-400">
|
|
||||||
v{pbs!.version}
|
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
|
<div class="hidden xl:flex items-center gap-1.5 ml-1">
|
||||||
|
<Show when={isPVE}>
|
||||||
|
<span class="text-[9px] px-1 py-0 rounded text-[8px] font-medium bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400">
|
||||||
|
PVE
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
<Show when={isPVE && node!.pveVersion}>
|
||||||
|
<span class="text-[9px] text-gray-500 dark:text-gray-400">
|
||||||
|
v{node!.pveVersion.split('/')[1] || node!.pveVersion}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
<Show when={isPVE && node!.isClusterMember !== undefined}>
|
||||||
|
<span
|
||||||
|
class={`text-[9px] px-1 py-0 rounded text-[8px] font-medium whitespace-nowrap ${node!.isClusterMember
|
||||||
|
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
|
||||||
|
: 'bg-gray-100 text-gray-600 dark:bg-gray-700/50 dark:text-gray-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{node!.isClusterMember ? node!.clusterName : 'Standalone'}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
<Show when={isPBS}>
|
||||||
|
<span class="text-[9px] px-1 py-0 rounded text-[8px] font-medium bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400">
|
||||||
|
PBS
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
<Show when={isPBS && pbs!.version}>
|
||||||
|
<span class="text-[9px] text-gray-500 dark:text-gray-400">
|
||||||
|
v{pbs!.version}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
<div class={`${cellBgClass()} px-0.5 md:px-2 py-1 whitespace-nowrap text-center md:text-left flex items-center justify-center md:justify-start h-full`}>
|
|
||||||
<span
|
case 'uptime':
|
||||||
class={`text-xs ${isPVE && (node?.uptime ?? 0) < 3600
|
return (
|
||||||
? 'text-orange-500'
|
<div class={`${baseCellClass} ${alignClass} whitespace-nowrap`}>
|
||||||
: 'text-gray-600 dark:text-gray-400'
|
<span
|
||||||
}`}
|
class={`text-xs ${isPVE && (node?.uptime ?? 0) < 3600
|
||||||
>
|
? 'text-orange-500'
|
||||||
<Show
|
: 'text-gray-600 dark:text-gray-400'
|
||||||
when={online && uptimeValue}
|
}`}
|
||||||
fallback="-"
|
|
||||||
>
|
>
|
||||||
<span class="md:hidden">{formatUptime(uptimeValue, true)}</span>
|
<Show when={online && uptimeValue} fallback="-">
|
||||||
<span class="hidden md:inline">{formatUptime(uptimeValue)}</span>
|
<Show when={isMobile()} fallback={formatUptime(uptimeValue)}>
|
||||||
</Show>
|
{formatUptime(uptimeValue, true)}
|
||||||
</span>
|
</Show>
|
||||||
</div>
|
</Show>
|
||||||
<div class={`${cellBgClass()} px-0.5 md:px-2 py-1 flex items-center justify-center h-full`}>
|
</span>
|
||||||
<Show
|
</div>
|
||||||
when={online && cpuPercentValue !== null}
|
);
|
||||||
fallback={<span class="text-xs text-gray-500 dark:text-gray-400">-</span>}
|
|
||||||
>
|
case 'cpu':
|
||||||
<div class={`md:hidden text-xs text-center ${cpuPercentValue! >= 90 ? 'text-red-600 dark:text-red-400 font-bold' : cpuPercentValue! >= 80 ? 'text-orange-600 dark:text-orange-400 font-medium' : 'text-gray-600 dark:text-gray-400'}`}>
|
return (
|
||||||
{formatPercent(cpuPercentValue ?? 0)}
|
<div class={`${baseCellClass} ${alignClass}`}>
|
||||||
</div>
|
<ResponsiveMetricCell
|
||||||
<div class="hidden md:block w-full">
|
value={cpuPercentValue}
|
||||||
<MetricBar
|
type="cpu"
|
||||||
value={cpuPercentValue ?? 0}
|
resourceId={metricsKey}
|
||||||
label={formatPercent(cpuPercentValue ?? 0)}
|
sublabel={isPVE && node!.cpuInfo?.cores ? `${node!.cpuInfo.cores} cores` : undefined}
|
||||||
sublabel={
|
isRunning={online}
|
||||||
isPVE && node!.cpuInfo?.cores
|
showMobile={isMobile()}
|
||||||
? `${node!.cpuInfo.cores} cores`
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'memory':
|
||||||
|
return (
|
||||||
|
<div class={`${baseCellClass} ${alignClass}`}>
|
||||||
|
<ResponsiveMetricCell
|
||||||
|
value={memoryPercentValue}
|
||||||
|
type="memory"
|
||||||
|
resourceId={metricsKey}
|
||||||
|
sublabel={
|
||||||
|
isPVE && node!.memory
|
||||||
|
? `${formatBytes(node!.memory.used, 0)}/${formatBytes(node!.memory.total, 0)}`
|
||||||
|
: isPBS && pbs!.memoryTotal
|
||||||
|
? `${formatBytes(pbs!.memoryUsed, 0)}/${formatBytes(pbs!.memoryTotal, 0)}`
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
type="cpu"
|
isRunning={online}
|
||||||
resourceId={metricsKey}
|
showMobile={isMobile()}
|
||||||
/>
|
class="w-full"
|
||||||
</div>
|
/>
|
||||||
</Show>
|
</div>
|
||||||
</div>
|
);
|
||||||
<div class={`${cellBgClass()} px-0.5 md:px-2 py-1 flex items-center justify-center h-full`}>
|
|
||||||
<Show
|
case 'disk':
|
||||||
when={online && memoryPercentValue !== null}
|
return (
|
||||||
fallback={<span class="text-xs text-gray-500 dark:text-gray-400">-</span>}
|
<div class={`${baseCellClass} ${alignClass}`}>
|
||||||
>
|
<ResponsiveMetricCell
|
||||||
<div class={`md:hidden text-xs text-center ${memoryPercentValue! >= 85 ? 'text-red-600 dark:text-red-400 font-bold' : memoryPercentValue! >= 75 ? 'text-orange-600 dark:text-orange-400 font-medium' : 'text-gray-600 dark:text-gray-400'}`}>
|
value={diskPercentValue}
|
||||||
{formatPercent(memoryPercentValue ?? 0)}
|
type="disk"
|
||||||
</div>
|
resourceId={metricsKey}
|
||||||
<div class="hidden md:block w-full">
|
sublabel={diskSublabel}
|
||||||
<MetricBar
|
isRunning={online}
|
||||||
value={memoryPercentValue ?? 0}
|
showMobile={isMobile()}
|
||||||
label={formatPercent(memoryPercentValue ?? 0)}
|
class="w-full"
|
||||||
sublabel={
|
/>
|
||||||
isPVE && node!.memory
|
</div>
|
||||||
? `${formatBytes(node!.memory.used, 0)}/${formatBytes(node!.memory.total, 0)}`
|
);
|
||||||
: isPBS && pbs!.memoryTotal
|
|
||||||
? `${formatBytes(pbs!.memoryUsed, 0)}/${formatBytes(pbs!.memoryTotal, 0)}`
|
case 'temperature':
|
||||||
: undefined
|
return (
|
||||||
}
|
<div class={`${baseCellClass} ${alignClass} whitespace-nowrap`}>
|
||||||
type="memory"
|
|
||||||
resourceId={metricsKey}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
<div class={`${cellBgClass()} px-0.5 md:px-2 py-1 flex items-center justify-center h-full`}>
|
|
||||||
<Show
|
|
||||||
when={online && diskPercentValue !== null}
|
|
||||||
fallback={<span class="text-xs text-gray-500 dark:text-gray-400">-</span>}
|
|
||||||
>
|
|
||||||
<div class={`md:hidden text-xs text-center ${diskPercentValue! >= 90 ? 'text-red-600 dark:text-red-400 font-bold' : diskPercentValue! >= 80 ? 'text-orange-600 dark:text-orange-400 font-medium' : 'text-gray-600 dark:text-gray-400'}`}>
|
|
||||||
{formatPercent(diskPercentValue ?? 0)}
|
|
||||||
</div>
|
|
||||||
<div class="hidden md:block w-full">
|
|
||||||
<MetricBar
|
|
||||||
value={diskPercentValue ?? 0}
|
|
||||||
label={formatPercent(diskPercentValue ?? 0)}
|
|
||||||
sublabel={diskSublabel}
|
|
||||||
type="disk"
|
|
||||||
resourceId={metricsKey}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
<Show when={hasAnyTemperatureData()}>
|
|
||||||
<div class={`${cellBgClass()} px-0.5 md:px-2 py-1 whitespace-nowrap text-center md:text-left flex items-center justify-center md:justify-start h-full`}>
|
|
||||||
<Show
|
<Show
|
||||||
when={
|
when={
|
||||||
online &&
|
online &&
|
||||||
|
|
@ -728,9 +750,7 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
|
||||||
(node!.temperature?.hasCPU ?? node!.temperature?.hasGPU ?? node!.temperature?.available) &&
|
(node!.temperature?.hasCPU ?? node!.temperature?.hasGPU ?? node!.temperature?.available) &&
|
||||||
isTemperatureMonitoringEnabled(node!)
|
isTemperatureMonitoringEnabled(node!)
|
||||||
}
|
}
|
||||||
fallback={
|
fallback={<span class="text-xs text-gray-400 dark:text-gray-500">-</span>}
|
||||||
<span class="text-xs text-gray-400 dark:text-gray-500">-</span>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{(() => {
|
{(() => {
|
||||||
const value = cpuTemperatureValue as number;
|
const value = cpuTemperatureValue as number;
|
||||||
|
|
@ -764,11 +784,11 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span class="relative inline-block group">
|
<span class="relative inline-block group/temp">
|
||||||
<span class={`text-xs font-medium ${severityClass} cursor-help`}>
|
<span class={`text-xs font-medium ${severityClass} cursor-help`}>
|
||||||
{value}°C
|
{value}°C
|
||||||
</span>
|
</span>
|
||||||
<span class="invisible group-hover:visible absolute bottom-full left-1/2 -translate-x-1/2 mb-1 px-2 py-1 text-xs whitespace-nowrap bg-gray-900 dark:bg-gray-700 text-white rounded shadow-lg z-50 pointer-events-none">
|
<span class="invisible group-hover/temp:visible absolute bottom-full left-1/2 -translate-x-1/2 mb-1 px-2 py-1 text-xs whitespace-nowrap bg-gray-900 dark:bg-gray-700 text-white rounded shadow-lg z-50 pointer-events-none">
|
||||||
{hasMinMax && (
|
{hasMinMax && (
|
||||||
<div>
|
<div>
|
||||||
<span class="text-gray-300">CPU:</span> <span class={getTooltipColor(min)}>{min}</span>-<span class={getTooltipColor(max)}>{max}</span>°C
|
<span class="text-gray-300">CPU:</span> <span class={getTooltipColor(min)}>{min}</span>-<span class={getTooltipColor(max)}>{max}</span>°C
|
||||||
|
|
@ -794,28 +814,41 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
|
||||||
})()}
|
})()}
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
);
|
||||||
<For each={countColumns()}>
|
|
||||||
{(column) => {
|
// Count columns (vmCount, containerCount, storageCount, diskCount, backupCount)
|
||||||
const value = getCountValue(item, column.key);
|
default:
|
||||||
const display = online ? value ?? '-' : '-';
|
if (column.id.endsWith('Count')) {
|
||||||
const textClass = online
|
const value = getCountValue(item, column.id as CountSortKey);
|
||||||
? 'text-xs text-gray-700 dark:text-gray-300'
|
const display = online ? value ?? '-' : '-';
|
||||||
: 'text-xs text-gray-400 dark:text-gray-500';
|
const textClass = online
|
||||||
return (
|
? 'text-xs text-gray-700 dark:text-gray-300'
|
||||||
<div class={`${cellBgClass()} px-0.5 md:px-2 py-1 whitespace-nowrap text-center flex items-center justify-center h-full`}>
|
: 'text-xs text-gray-400 dark:text-gray-500';
|
||||||
<span class={textClass}>{display}</span>
|
return (
|
||||||
</div>
|
<div class={`${baseCellClass} ${alignClass} whitespace-nowrap`}>
|
||||||
);
|
<span class={textClass}>{display}</span>
|
||||||
}}
|
</div>
|
||||||
</For>
|
);
|
||||||
</div>
|
}
|
||||||
);
|
return null;
|
||||||
}}
|
}
|
||||||
</For>
|
};
|
||||||
</div>
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class={`${rowClass()} grid items-center`}
|
||||||
|
style={{ ...rowStyle(), 'grid-template-columns': gridTemplate() }}
|
||||||
|
onClick={() => props.onNodeClick(nodeId, item.type)}
|
||||||
|
>
|
||||||
|
<For each={visibleColumns()}>
|
||||||
|
{(column) => renderCell(column)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
</>
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
29
frontend-modern/src/components/shared/OnlineStatusBadge.tsx
Normal file
29
frontend-modern/src/components/shared/OnlineStatusBadge.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { Component } from 'solid-js';
|
||||||
|
import type { StatusIndicatorVariant } from '@/utils/status';
|
||||||
|
|
||||||
|
interface OnlineStatusBadgeProps {
|
||||||
|
variant: StatusIndicatorVariant;
|
||||||
|
label: string;
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const variantClasses: Record<StatusIndicatorVariant, string> = {
|
||||||
|
success: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
|
||||||
|
warning: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
|
||||||
|
danger: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300',
|
||||||
|
muted: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OnlineStatusBadge - A pill-shaped badge showing online/offline/degraded status
|
||||||
|
* Used in tables to show resource status (nodes, guests, containers, etc.)
|
||||||
|
*/
|
||||||
|
export const OnlineStatusBadge: Component<OnlineStatusBadgeProps> = (props) => {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
class={`inline-flex items-center justify-center rounded-full px-2 py-0.5 text-[10px] font-semibold whitespace-nowrap ${variantClasses[props.variant]} ${props.class || ''}`}
|
||||||
|
>
|
||||||
|
{props.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,127 @@
|
||||||
|
import { Component, Show } from 'solid-js';
|
||||||
|
import type { ColumnConfig, SortState } from '@/types/responsive';
|
||||||
|
|
||||||
|
export interface ResponsiveHeaderProps {
|
||||||
|
/** Column configuration */
|
||||||
|
column: ColumnConfig;
|
||||||
|
|
||||||
|
/** Current sort state */
|
||||||
|
sortState?: SortState;
|
||||||
|
|
||||||
|
/** Sort handler - called with the column's sortKey or id */
|
||||||
|
onSort?: (key: string) => void;
|
||||||
|
|
||||||
|
/** Whether to show mobile (icon) view */
|
||||||
|
showMobile?: boolean;
|
||||||
|
|
||||||
|
/** Additional CSS classes */
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A responsive table header cell that shows an icon on mobile
|
||||||
|
* and the full label on larger screens.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* <ResponsiveHeader
|
||||||
|
* column={STANDARD_COLUMNS.cpu}
|
||||||
|
* sortState={sortState()}
|
||||||
|
* onSort={handleSort}
|
||||||
|
* showMobile={isMobile()}
|
||||||
|
* />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export const ResponsiveHeader: Component<ResponsiveHeaderProps> = (props) => {
|
||||||
|
const sortKey = () => props.column.sortKey || props.column.id;
|
||||||
|
const isSorted = () => props.sortState?.key === sortKey();
|
||||||
|
const sortIndicator = () => {
|
||||||
|
if (!isSorted()) return null;
|
||||||
|
return props.sortState?.direction === 'asc' ? '▲' : '▼';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (props.column.sortable && props.onSort) {
|
||||||
|
props.onSort(sortKey());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter' && props.column.sortable && props.onSort) {
|
||||||
|
props.onSort(sortKey());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const alignmentClass = () => {
|
||||||
|
switch (props.column.align) {
|
||||||
|
case 'center': return 'justify-center text-center';
|
||||||
|
case 'right': return 'justify-end text-right';
|
||||||
|
default: return 'justify-start text-left';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseClass = () => {
|
||||||
|
const classes = [
|
||||||
|
'px-0.5 md:px-2 py-1',
|
||||||
|
'text-[11px] sm:text-xs font-medium uppercase tracking-wider',
|
||||||
|
'flex items-center h-full',
|
||||||
|
alignmentClass(),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (props.column.sortable) {
|
||||||
|
classes.push('cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.class) {
|
||||||
|
classes.push(props.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
return classes.join(' ');
|
||||||
|
};
|
||||||
|
|
||||||
|
const Icon = props.column.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class={baseClass()}
|
||||||
|
onClick={handleClick}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
tabIndex={props.column.sortable ? 0 : undefined}
|
||||||
|
role={props.column.sortable ? 'button' : undefined}
|
||||||
|
aria-label={props.column.sortable
|
||||||
|
? `Sort by ${props.column.label} ${isSorted() ? (props.sortState?.direction === 'asc' ? 'ascending' : 'descending') : ''}`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* Mobile: Show icon if available */}
|
||||||
|
<Show when={props.showMobile && Icon}>
|
||||||
|
<span class="md:hidden" title={props.column.label}>
|
||||||
|
{Icon && <Icon class="w-4 h-4" />}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
{/* Desktop: Show label (or always if no icon) */}
|
||||||
|
<span class={props.showMobile && Icon ? 'hidden md:inline' : ''}>
|
||||||
|
{props.column.label}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Sort indicator */}
|
||||||
|
<Show when={sortIndicator()}>
|
||||||
|
<span class="ml-0.5">{sortIndicator()}</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sticky header variant for name columns
|
||||||
|
*/
|
||||||
|
export const StickyHeader: Component<ResponsiveHeaderProps & { width?: string }> = (props) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class={`sticky left-0 z-30 bg-gray-50 dark:bg-gray-700/50 border-r md:border-r-0 border-gray-200 dark:border-gray-600 ${props.width || 'w-[160px] sm:w-[200px] md:w-full'}`}
|
||||||
|
>
|
||||||
|
<ResponsiveHeader {...props} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,179 @@
|
||||||
|
import { Component, Show, createMemo, JSX } from 'solid-js';
|
||||||
|
import { MetricBar } from '@/components/Dashboard/MetricBar';
|
||||||
|
import { formatPercent } from '@/utils/format';
|
||||||
|
|
||||||
|
export interface ResponsiveMetricCellProps {
|
||||||
|
/** Metric value (0-100 percentage) */
|
||||||
|
value: number;
|
||||||
|
|
||||||
|
/** Metric type for theming/thresholds */
|
||||||
|
type: 'cpu' | 'memory' | 'disk';
|
||||||
|
|
||||||
|
/** Primary label (defaults to formatted percentage) */
|
||||||
|
label?: string;
|
||||||
|
|
||||||
|
/** Secondary label (e.g., "4.2GB / 8GB") */
|
||||||
|
sublabel?: string;
|
||||||
|
|
||||||
|
/** Resource ID for sparkline tracking */
|
||||||
|
resourceId?: string;
|
||||||
|
|
||||||
|
/** Whether the resource is running/online - if false, shows fallback */
|
||||||
|
isRunning?: boolean;
|
||||||
|
|
||||||
|
/** Show mobile (compact text) view */
|
||||||
|
showMobile?: boolean;
|
||||||
|
|
||||||
|
/** Custom fallback when not running (defaults to "—") */
|
||||||
|
fallback?: JSX.Element;
|
||||||
|
|
||||||
|
/** Additional CSS classes for container */
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the appropriate text color class based on metric value and type
|
||||||
|
*/
|
||||||
|
function getMetricColorClass(value: number, type: 'cpu' | 'memory' | 'disk'): string {
|
||||||
|
// Thresholds match MetricBar component
|
||||||
|
if (type === 'cpu') {
|
||||||
|
if (value >= 90) return 'text-red-600 dark:text-red-400 font-bold';
|
||||||
|
if (value >= 80) return 'text-orange-600 dark:text-orange-400 font-medium';
|
||||||
|
return 'text-gray-600 dark:text-gray-400';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'memory') {
|
||||||
|
if (value >= 85) return 'text-red-600 dark:text-red-400 font-bold';
|
||||||
|
if (value >= 75) return 'text-orange-600 dark:text-orange-400 font-medium';
|
||||||
|
return 'text-gray-600 dark:text-gray-400';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'disk') {
|
||||||
|
if (value >= 90) return 'text-red-600 dark:text-red-400 font-bold';
|
||||||
|
if (value >= 80) return 'text-orange-600 dark:text-orange-400 font-medium';
|
||||||
|
return 'text-gray-600 dark:text-gray-400';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'text-gray-600 dark:text-gray-400';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A responsive metric cell that shows a simple colored percentage on mobile
|
||||||
|
* and a full MetricBar (with progress bar or sparkline) on desktop.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* <ResponsiveMetricCell
|
||||||
|
* value={cpuPercent}
|
||||||
|
* type="cpu"
|
||||||
|
* resourceId={metricsKey}
|
||||||
|
* isRunning={isOnline}
|
||||||
|
* showMobile={isMobile()}
|
||||||
|
* />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export const ResponsiveMetricCell: Component<ResponsiveMetricCellProps> = (props) => {
|
||||||
|
const displayLabel = createMemo(() => props.label ?? formatPercent(props.value));
|
||||||
|
const colorClass = createMemo(() => getMetricColorClass(props.value, props.type));
|
||||||
|
const isRunning = () => props.isRunning !== false; // Default to true if not specified
|
||||||
|
|
||||||
|
const defaultFallback = (
|
||||||
|
<span class="text-xs text-gray-400 dark:text-gray-500">—</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show when={isRunning()} fallback={props.fallback ?? defaultFallback}>
|
||||||
|
<div class={props.class}>
|
||||||
|
{/* Mobile: Colored percentage text */}
|
||||||
|
<Show when={props.showMobile}>
|
||||||
|
<div class={`md:hidden text-xs text-center ${colorClass()}`}>
|
||||||
|
{displayLabel()}
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
{/* Desktop: Full MetricBar with sparkline support */}
|
||||||
|
<div class={props.showMobile ? 'hidden md:block' : ''}>
|
||||||
|
<MetricBar
|
||||||
|
value={props.value}
|
||||||
|
label={displayLabel()}
|
||||||
|
sublabel={props.sublabel}
|
||||||
|
type={props.type}
|
||||||
|
resourceId={props.resourceId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simpler metric text component for when you just want colored percentage
|
||||||
|
* without the MetricBar complexity
|
||||||
|
*/
|
||||||
|
export const MetricText: Component<{
|
||||||
|
value: number;
|
||||||
|
type: 'cpu' | 'memory' | 'disk';
|
||||||
|
label?: string;
|
||||||
|
class?: string;
|
||||||
|
}> = (props) => {
|
||||||
|
const displayLabel = createMemo(() => props.label ?? formatPercent(props.value));
|
||||||
|
const colorClass = createMemo(() => getMetricColorClass(props.value, props.type));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span class={`text-xs text-center ${colorClass()} ${props.class || ''}`}>
|
||||||
|
{displayLabel()}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metric cell with explicit mobile/desktop rendering
|
||||||
|
* Use this when you need full control over what renders in each mode
|
||||||
|
*/
|
||||||
|
export const DualMetricCell: Component<{
|
||||||
|
value: number;
|
||||||
|
type: 'cpu' | 'memory' | 'disk';
|
||||||
|
label?: string;
|
||||||
|
sublabel?: string;
|
||||||
|
resourceId?: string;
|
||||||
|
isRunning?: boolean;
|
||||||
|
showMobile: boolean;
|
||||||
|
mobileContent?: JSX.Element;
|
||||||
|
desktopContent?: JSX.Element;
|
||||||
|
fallback?: JSX.Element;
|
||||||
|
class?: string;
|
||||||
|
}> = (props) => {
|
||||||
|
const displayLabel = createMemo(() => props.label ?? formatPercent(props.value));
|
||||||
|
const colorClass = createMemo(() => getMetricColorClass(props.value, props.type));
|
||||||
|
const isRunning = () => props.isRunning !== false;
|
||||||
|
|
||||||
|
const defaultFallback = (
|
||||||
|
<span class="text-xs text-gray-400 dark:text-gray-500">—</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
const defaultMobileContent = (
|
||||||
|
<div class={`text-xs text-center ${colorClass()}`}>
|
||||||
|
{displayLabel()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const defaultDesktopContent = (
|
||||||
|
<MetricBar
|
||||||
|
value={props.value}
|
||||||
|
label={displayLabel()}
|
||||||
|
sublabel={props.sublabel}
|
||||||
|
type={props.type}
|
||||||
|
resourceId={props.resourceId}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show when={isRunning()} fallback={props.fallback ?? defaultFallback}>
|
||||||
|
<div class={props.class}>
|
||||||
|
<Show when={props.showMobile} fallback={props.desktopContent ?? defaultDesktopContent}>
|
||||||
|
{props.mobileContent ?? defaultMobileContent}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
);
|
||||||
|
};
|
||||||
11
frontend-modern/src/components/shared/responsive/index.ts
Normal file
11
frontend-modern/src/components/shared/responsive/index.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
// Responsive table components and utilities
|
||||||
|
// These provide a unified system for responsive column visibility across all tables
|
||||||
|
|
||||||
|
export { ResponsiveHeader, StickyHeader } from './ResponsiveHeader';
|
||||||
|
export { ResponsiveMetricCell, MetricText, DualMetricCell } from './ResponsiveMetricCell';
|
||||||
|
export { useGridTemplate, generateStaticGridTemplate, generateResponsiveTemplates, getColumnVisibilityClass } from './useGridTemplate';
|
||||||
|
|
||||||
|
// Re-export types for convenience
|
||||||
|
export type { ResponsiveHeaderProps } from './ResponsiveHeader';
|
||||||
|
export type { ResponsiveMetricCellProps } from './ResponsiveMetricCell';
|
||||||
|
export type { GridTemplateOptions, GridTemplateResult } from './useGridTemplate';
|
||||||
|
|
@ -0,0 +1,170 @@
|
||||||
|
import { createMemo, Accessor } from 'solid-js';
|
||||||
|
import type { ColumnConfig } from '@/types/responsive';
|
||||||
|
import { useBreakpoint, type Breakpoint, PRIORITY_BREAKPOINTS } from '@/hooks/useBreakpoint';
|
||||||
|
|
||||||
|
export interface GridTemplateOptions {
|
||||||
|
/** Column configurations - can be a static array or reactive accessor */
|
||||||
|
columns: ColumnConfig[] | Accessor<ColumnConfig[]>;
|
||||||
|
|
||||||
|
/** Whether to use a sticky first column on mobile */
|
||||||
|
hasStickyColumn?: boolean;
|
||||||
|
|
||||||
|
/** Custom gap between columns (CSS value) */
|
||||||
|
gap?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GridTemplateResult {
|
||||||
|
/** CSS grid-template-columns value */
|
||||||
|
gridTemplate: Accessor<string>;
|
||||||
|
|
||||||
|
/** Columns that are currently visible */
|
||||||
|
visibleColumns: Accessor<ColumnConfig[]>;
|
||||||
|
|
||||||
|
/** Check if a specific column is visible */
|
||||||
|
isColumnVisible: (columnId: string) => boolean;
|
||||||
|
|
||||||
|
/** Current breakpoint */
|
||||||
|
breakpoint: Accessor<Breakpoint>;
|
||||||
|
|
||||||
|
/** Whether currently in mobile view */
|
||||||
|
isMobile: Accessor<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a CSS grid-template-columns value from column configs
|
||||||
|
*/
|
||||||
|
function generateGridTemplate(columns: ColumnConfig[]): string {
|
||||||
|
return columns.map(col => {
|
||||||
|
const min = col.minWidth || '50px';
|
||||||
|
const max = col.maxWidth || `${col.flex || 1}fr`;
|
||||||
|
|
||||||
|
// If we have both min and max as fixed values, use the appropriate one
|
||||||
|
if (col.maxWidth && !col.maxWidth.includes('fr')) {
|
||||||
|
return `minmax(${min}, ${col.maxWidth})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use minmax for flexible columns
|
||||||
|
return `minmax(${min}, ${max})`;
|
||||||
|
}).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter columns based on current breakpoint
|
||||||
|
*/
|
||||||
|
function getVisibleColumns(columns: ColumnConfig[], breakpoint: Breakpoint): ColumnConfig[] {
|
||||||
|
const breakpointOrder: Breakpoint[] = ['xs', 'sm', 'md', 'lg', 'xl', '2xl'];
|
||||||
|
const currentIndex = breakpointOrder.indexOf(breakpoint);
|
||||||
|
|
||||||
|
return columns.filter(col => {
|
||||||
|
const colBreakpoint = PRIORITY_BREAKPOINTS[col.priority];
|
||||||
|
const colIndex = breakpointOrder.indexOf(colBreakpoint);
|
||||||
|
return colIndex <= currentIndex;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to generate responsive CSS grid templates based on column configuration.
|
||||||
|
*
|
||||||
|
* This hook automatically adjusts the grid template based on the current
|
||||||
|
* viewport width, showing/hiding columns according to their priority.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* const columns = [
|
||||||
|
* { id: 'name', label: 'Name', priority: 'essential', minWidth: '150px', flex: 1.5 },
|
||||||
|
* { id: 'cpu', label: 'CPU', priority: 'essential', minWidth: '100px', flex: 1 },
|
||||||
|
* { id: 'diskRead', label: 'D Read', priority: 'detailed', minWidth: '100px', flex: 1 },
|
||||||
|
* ];
|
||||||
|
*
|
||||||
|
* const { gridTemplate, visibleColumns, isMobile } = useGridTemplate({ columns });
|
||||||
|
*
|
||||||
|
* return (
|
||||||
|
* <div
|
||||||
|
* class="grid"
|
||||||
|
* style={{ 'grid-template-columns': gridTemplate() }}
|
||||||
|
* >
|
||||||
|
* <For each={visibleColumns()}>
|
||||||
|
* {col => <div>{col.label}</div>}
|
||||||
|
* </For>
|
||||||
|
* </div>
|
||||||
|
* );
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useGridTemplate(options: GridTemplateOptions): GridTemplateResult {
|
||||||
|
const { breakpoint, isMobile } = useBreakpoint();
|
||||||
|
|
||||||
|
// Support both static arrays and reactive accessors
|
||||||
|
const getColumns = (): ColumnConfig[] => {
|
||||||
|
const cols = options.columns;
|
||||||
|
return typeof cols === 'function' ? cols() : cols;
|
||||||
|
};
|
||||||
|
|
||||||
|
const visibleColumns = createMemo(() => {
|
||||||
|
return getVisibleColumns(getColumns(), breakpoint());
|
||||||
|
});
|
||||||
|
|
||||||
|
const gridTemplate = createMemo(() => {
|
||||||
|
const visible = visibleColumns();
|
||||||
|
|
||||||
|
// On mobile with sticky column, we handle layout differently
|
||||||
|
// The first column is sticky and outside the grid
|
||||||
|
if (isMobile() && options.hasStickyColumn && visible.length > 1) {
|
||||||
|
// Skip the first column (it's sticky) and generate template for the rest
|
||||||
|
return generateGridTemplate(visible.slice(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
return generateGridTemplate(visible);
|
||||||
|
});
|
||||||
|
|
||||||
|
const isColumnVisible = (columnId: string): boolean => {
|
||||||
|
return visibleColumns().some(col => col.id === columnId);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
gridTemplate,
|
||||||
|
visibleColumns,
|
||||||
|
isColumnVisible,
|
||||||
|
breakpoint,
|
||||||
|
isMobile,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple utility to generate a static grid template (non-reactive)
|
||||||
|
*/
|
||||||
|
export function generateStaticGridTemplate(
|
||||||
|
columns: ColumnConfig[],
|
||||||
|
breakpoint: Breakpoint = 'xl'
|
||||||
|
): string {
|
||||||
|
const visible = getVisibleColumns(columns, breakpoint);
|
||||||
|
return generateGridTemplate(visible);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate responsive grid template classes for Tailwind
|
||||||
|
* Returns an object with breakpoint-specific templates
|
||||||
|
*/
|
||||||
|
export function generateResponsiveTemplates(columns: ColumnConfig[]): Record<Breakpoint, string> {
|
||||||
|
const breakpoints: Breakpoint[] = ['xs', 'sm', 'md', 'lg', 'xl', '2xl'];
|
||||||
|
|
||||||
|
return Object.fromEntries(
|
||||||
|
breakpoints.map(bp => [bp, generateStaticGridTemplate(columns, bp)])
|
||||||
|
) as Record<Breakpoint, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Tailwind CSS classes for column visibility based on priority
|
||||||
|
*/
|
||||||
|
export function getColumnVisibilityClass(
|
||||||
|
priority: ColumnConfig['priority'],
|
||||||
|
displayType: 'flex' | 'block' | 'table-cell' | 'grid' = 'flex'
|
||||||
|
): string {
|
||||||
|
const bp = PRIORITY_BREAKPOINTS[priority];
|
||||||
|
|
||||||
|
if (bp === 'xs') {
|
||||||
|
// Always visible
|
||||||
|
return displayType;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `hidden ${bp}:${displayType}`;
|
||||||
|
}
|
||||||
161
frontend-modern/src/hooks/useBreakpoint.ts
Normal file
161
frontend-modern/src/hooks/useBreakpoint.ts
Normal file
|
|
@ -0,0 +1,161 @@
|
||||||
|
import { createSignal, onMount, onCleanup, createMemo, Accessor } from 'solid-js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tailwind CSS breakpoint values (in pixels)
|
||||||
|
* These match Tailwind's default breakpoints
|
||||||
|
*/
|
||||||
|
export const BREAKPOINTS = {
|
||||||
|
xs: 0,
|
||||||
|
sm: 640,
|
||||||
|
md: 768,
|
||||||
|
lg: 1024,
|
||||||
|
xl: 1280,
|
||||||
|
'2xl': 1536,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type Breakpoint = keyof typeof BREAKPOINTS;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Column priority tiers mapped to breakpoints
|
||||||
|
* - essential: Always visible (xs and up)
|
||||||
|
* - primary: Visible on small screens and up (sm: 640px+)
|
||||||
|
* - secondary: Visible on medium screens and up (md: 768px+)
|
||||||
|
* - supplementary: Visible on large screens and up (lg: 1024px+)
|
||||||
|
* - detailed: Visible on extra large screens and up (xl: 1280px+)
|
||||||
|
*/
|
||||||
|
export type ColumnPriority = 'essential' | 'primary' | 'secondary' | 'supplementary' | 'detailed';
|
||||||
|
|
||||||
|
export const PRIORITY_BREAKPOINTS: Record<ColumnPriority, Breakpoint> = {
|
||||||
|
essential: 'xs',
|
||||||
|
primary: 'sm',
|
||||||
|
secondary: 'md',
|
||||||
|
supplementary: 'lg',
|
||||||
|
detailed: 'xl',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current breakpoint name based on window width
|
||||||
|
*/
|
||||||
|
function getBreakpointName(width: number): Breakpoint {
|
||||||
|
if (width >= BREAKPOINTS['2xl']) return '2xl';
|
||||||
|
if (width >= BREAKPOINTS.xl) return 'xl';
|
||||||
|
if (width >= BREAKPOINTS.lg) return 'lg';
|
||||||
|
if (width >= BREAKPOINTS.md) return 'md';
|
||||||
|
if (width >= BREAKPOINTS.sm) return 'sm';
|
||||||
|
return 'xs';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseBreakpointReturn {
|
||||||
|
/** Current window width in pixels */
|
||||||
|
width: Accessor<number>;
|
||||||
|
/** Current breakpoint name (xs, sm, md, lg, xl, 2xl) */
|
||||||
|
breakpoint: Accessor<Breakpoint>;
|
||||||
|
/** Check if current width is at least the given breakpoint */
|
||||||
|
isAtLeast: (bp: Breakpoint) => boolean;
|
||||||
|
/** Check if current width is below the given breakpoint */
|
||||||
|
isBelow: (bp: Breakpoint) => boolean;
|
||||||
|
/** Check if a column with the given priority should be visible */
|
||||||
|
isVisible: (priority: ColumnPriority) => boolean;
|
||||||
|
/** Convenience booleans for common checks */
|
||||||
|
isMobile: Accessor<boolean>;
|
||||||
|
isTablet: Accessor<boolean>;
|
||||||
|
isDesktop: Accessor<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reactive hook for tracking viewport width and breakpoints.
|
||||||
|
*
|
||||||
|
* Use this for conditional rendering based on screen size,
|
||||||
|
* which is more performant than rendering and hiding with CSS.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* const { isAtLeast, isMobile, isVisible } = useBreakpoint();
|
||||||
|
*
|
||||||
|
* return (
|
||||||
|
* <Show when={isAtLeast('md')} fallback={<MobileView />}>
|
||||||
|
* <DesktopView />
|
||||||
|
* </Show>
|
||||||
|
* );
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useBreakpoint(): UseBreakpointReturn {
|
||||||
|
// Initialize with current window width (or 0 for SSR)
|
||||||
|
const initialWidth = typeof window !== 'undefined' ? window.innerWidth : 0;
|
||||||
|
const [width, setWidth] = createSignal(initialWidth);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
// Debounce resize events to avoid excessive re-renders
|
||||||
|
let resizeTimeout: number | undefined;
|
||||||
|
|
||||||
|
const handleResize = () => {
|
||||||
|
if (resizeTimeout) {
|
||||||
|
window.cancelAnimationFrame(resizeTimeout);
|
||||||
|
}
|
||||||
|
resizeTimeout = window.requestAnimationFrame(() => {
|
||||||
|
setWidth(window.innerWidth);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set initial width in case it changed between SSR and mount
|
||||||
|
setWidth(window.innerWidth);
|
||||||
|
|
||||||
|
window.addEventListener('resize', handleResize, { passive: true });
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
window.removeEventListener('resize', handleResize);
|
||||||
|
if (resizeTimeout) {
|
||||||
|
window.cancelAnimationFrame(resizeTimeout);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const breakpoint = createMemo(() => getBreakpointName(width()));
|
||||||
|
|
||||||
|
const isAtLeast = (bp: Breakpoint): boolean => {
|
||||||
|
return width() >= BREAKPOINTS[bp];
|
||||||
|
};
|
||||||
|
|
||||||
|
const isBelow = (bp: Breakpoint): boolean => {
|
||||||
|
return width() < BREAKPOINTS[bp];
|
||||||
|
};
|
||||||
|
|
||||||
|
const isVisible = (priority: ColumnPriority): boolean => {
|
||||||
|
const minBreakpoint = PRIORITY_BREAKPOINTS[priority];
|
||||||
|
return width() >= BREAKPOINTS[minBreakpoint];
|
||||||
|
};
|
||||||
|
|
||||||
|
const isMobile = createMemo(() => width() < BREAKPOINTS.md);
|
||||||
|
const isTablet = createMemo(() => width() >= BREAKPOINTS.md && width() < BREAKPOINTS.xl);
|
||||||
|
const isDesktop = createMemo(() => width() >= BREAKPOINTS.xl);
|
||||||
|
|
||||||
|
return {
|
||||||
|
width,
|
||||||
|
breakpoint,
|
||||||
|
isAtLeast,
|
||||||
|
isBelow,
|
||||||
|
isVisible,
|
||||||
|
isMobile,
|
||||||
|
isTablet,
|
||||||
|
isDesktop,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the Tailwind CSS class for hiding an element below a given breakpoint
|
||||||
|
*/
|
||||||
|
export function getVisibilityClass(priority: ColumnPriority, display: 'flex' | 'block' | 'table-cell' | 'grid' | 'inline' = 'flex'): string {
|
||||||
|
const bp = PRIORITY_BREAKPOINTS[priority];
|
||||||
|
if (bp === 'xs') {
|
||||||
|
// Always visible, no hiding class needed
|
||||||
|
return display;
|
||||||
|
}
|
||||||
|
return `hidden ${bp}:${display}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the minimum width for a priority level
|
||||||
|
*/
|
||||||
|
export function getPriorityMinWidth(priority: ColumnPriority): number {
|
||||||
|
return BREAKPOINTS[PRIORITY_BREAKPOINTS[priority]];
|
||||||
|
}
|
||||||
356
frontend-modern/src/types/responsive.ts
Normal file
356
frontend-modern/src/types/responsive.ts
Normal file
|
|
@ -0,0 +1,356 @@
|
||||||
|
import type { Component, JSX } from 'solid-js';
|
||||||
|
import type { ColumnPriority, Breakpoint } from '@/hooks/useBreakpoint';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for a responsive table column
|
||||||
|
*/
|
||||||
|
export interface ColumnConfig {
|
||||||
|
/** Unique identifier for the column, typically matches a property key */
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
/** Display label for the column header */
|
||||||
|
label: string;
|
||||||
|
|
||||||
|
/** Priority determines at which breakpoint the column becomes visible */
|
||||||
|
priority: ColumnPriority;
|
||||||
|
|
||||||
|
/** Icon component to show in header on mobile (optional) */
|
||||||
|
icon?: Component<{ class?: string }>;
|
||||||
|
|
||||||
|
/** Whether the column is sortable */
|
||||||
|
sortable?: boolean;
|
||||||
|
|
||||||
|
/** Sort key - defaults to id if sortable is true */
|
||||||
|
sortKey?: string;
|
||||||
|
|
||||||
|
/** Minimum width for the column (CSS value, e.g., '100px', '10%') */
|
||||||
|
minWidth?: string;
|
||||||
|
|
||||||
|
/** Maximum width for the column (CSS value) */
|
||||||
|
maxWidth?: string;
|
||||||
|
|
||||||
|
/** Flex grow factor for grid layout (default: 1) */
|
||||||
|
flex?: number;
|
||||||
|
|
||||||
|
/** Text alignment */
|
||||||
|
align?: 'left' | 'center' | 'right';
|
||||||
|
|
||||||
|
/** Whether this column should be sticky (typically for name columns) */
|
||||||
|
sticky?: boolean;
|
||||||
|
|
||||||
|
/** Custom CSS class for the column */
|
||||||
|
className?: string;
|
||||||
|
|
||||||
|
/** Custom CSS class for the header */
|
||||||
|
headerClassName?: string;
|
||||||
|
|
||||||
|
/** Custom CSS class for cells */
|
||||||
|
cellClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extended column config with render functions
|
||||||
|
*/
|
||||||
|
export interface ColumnConfigWithRender<T> extends ColumnConfig {
|
||||||
|
/**
|
||||||
|
* Render function for mobile view (compact)
|
||||||
|
* If not provided, falls back to renderDesktop
|
||||||
|
*/
|
||||||
|
renderMobile?: (item: T, index: number) => JSX.Element;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render function for desktop view (full)
|
||||||
|
* Required if renderMobile is provided
|
||||||
|
*/
|
||||||
|
renderDesktop?: (item: T, index: number) => JSX.Element;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple render function for both mobile and desktop
|
||||||
|
* Use this for columns that don't need different mobile/desktop rendering
|
||||||
|
*/
|
||||||
|
render?: (item: T, index: number) => JSX.Element;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get sort value from item (for custom sorting)
|
||||||
|
*/
|
||||||
|
getSortValue?: (item: T) => string | number | null | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Common metric column configuration for CPU, Memory, Disk metrics
|
||||||
|
*/
|
||||||
|
export interface MetricColumnConfig {
|
||||||
|
type: 'cpu' | 'memory' | 'disk';
|
||||||
|
getValue: (item: unknown) => number;
|
||||||
|
getLabel?: (item: unknown) => string;
|
||||||
|
getSublabel?: (item: unknown) => string | undefined;
|
||||||
|
getResourceId?: (item: unknown) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sort state for a table
|
||||||
|
*/
|
||||||
|
export interface SortState {
|
||||||
|
key: string | null;
|
||||||
|
direction: 'asc' | 'desc';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grid template configuration for responsive tables
|
||||||
|
*/
|
||||||
|
export interface GridTemplateConfig {
|
||||||
|
/** Column configurations */
|
||||||
|
columns: ColumnConfig[];
|
||||||
|
|
||||||
|
/** Current breakpoint for visibility calculations */
|
||||||
|
breakpoint: Breakpoint;
|
||||||
|
|
||||||
|
/** Whether to include sticky column handling */
|
||||||
|
hasStickyColumn?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for responsive table header
|
||||||
|
*/
|
||||||
|
export interface ResponsiveHeaderProps {
|
||||||
|
/** Column configuration */
|
||||||
|
column: ColumnConfig;
|
||||||
|
|
||||||
|
/** Current sort state */
|
||||||
|
sortState?: SortState;
|
||||||
|
|
||||||
|
/** Sort handler */
|
||||||
|
onSort?: (key: string) => void;
|
||||||
|
|
||||||
|
/** Whether currently on mobile */
|
||||||
|
isMobile?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for responsive metric cell
|
||||||
|
*/
|
||||||
|
export interface ResponsiveMetricCellProps {
|
||||||
|
/** Metric value (0-100 percentage) */
|
||||||
|
value: number;
|
||||||
|
|
||||||
|
/** Metric type for theming */
|
||||||
|
type: 'cpu' | 'memory' | 'disk';
|
||||||
|
|
||||||
|
/** Primary label (usually formatted percentage) */
|
||||||
|
label?: string;
|
||||||
|
|
||||||
|
/** Secondary label (e.g., "4.2GB / 8GB") */
|
||||||
|
sublabel?: string;
|
||||||
|
|
||||||
|
/** Resource ID for sparkline tracking */
|
||||||
|
resourceId?: string;
|
||||||
|
|
||||||
|
/** Whether the resource is running/online */
|
||||||
|
isRunning?: boolean;
|
||||||
|
|
||||||
|
/** Whether currently on mobile */
|
||||||
|
isMobile?: boolean;
|
||||||
|
|
||||||
|
/** Fallback content when not running */
|
||||||
|
fallback?: JSX.Element;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standard column definitions that can be reused across tables
|
||||||
|
*/
|
||||||
|
export const STANDARD_COLUMNS = {
|
||||||
|
/** Name/identifier column - always visible, typically sticky on mobile */
|
||||||
|
name: {
|
||||||
|
id: 'name',
|
||||||
|
label: 'Name',
|
||||||
|
priority: 'essential' as ColumnPriority,
|
||||||
|
sortable: true,
|
||||||
|
sticky: true,
|
||||||
|
minWidth: '150px',
|
||||||
|
flex: 1.5,
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Type badge column (VM/LXC, Container/Service, etc.) */
|
||||||
|
type: {
|
||||||
|
id: 'type',
|
||||||
|
label: 'Type',
|
||||||
|
priority: 'essential' as ColumnPriority,
|
||||||
|
sortable: true,
|
||||||
|
minWidth: '60px',
|
||||||
|
maxWidth: '80px',
|
||||||
|
align: 'center' as const,
|
||||||
|
},
|
||||||
|
|
||||||
|
/** VMID column */
|
||||||
|
vmid: {
|
||||||
|
id: 'vmid',
|
||||||
|
label: 'VMID',
|
||||||
|
priority: 'essential' as ColumnPriority,
|
||||||
|
sortable: true,
|
||||||
|
minWidth: '60px',
|
||||||
|
maxWidth: '80px',
|
||||||
|
align: 'center' as const,
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Status column */
|
||||||
|
status: {
|
||||||
|
id: 'status',
|
||||||
|
label: 'Status',
|
||||||
|
priority: 'primary' as ColumnPriority,
|
||||||
|
sortable: true,
|
||||||
|
minWidth: '70px',
|
||||||
|
maxWidth: '100px',
|
||||||
|
align: 'center' as const,
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Uptime column */
|
||||||
|
uptime: {
|
||||||
|
id: 'uptime',
|
||||||
|
label: 'Uptime',
|
||||||
|
priority: 'secondary' as ColumnPriority,
|
||||||
|
sortable: true,
|
||||||
|
minWidth: '80px',
|
||||||
|
maxWidth: '100px',
|
||||||
|
},
|
||||||
|
|
||||||
|
/** CPU metric column */
|
||||||
|
cpu: {
|
||||||
|
id: 'cpu',
|
||||||
|
label: 'CPU',
|
||||||
|
priority: 'essential' as ColumnPriority,
|
||||||
|
sortable: true,
|
||||||
|
minWidth: '100px',
|
||||||
|
flex: 1.5,
|
||||||
|
align: 'center' as const,
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Memory metric column */
|
||||||
|
memory: {
|
||||||
|
id: 'memory',
|
||||||
|
label: 'Memory',
|
||||||
|
priority: 'essential' as ColumnPriority,
|
||||||
|
sortable: true,
|
||||||
|
minWidth: '100px',
|
||||||
|
flex: 1.5,
|
||||||
|
align: 'center' as const,
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Disk metric column */
|
||||||
|
disk: {
|
||||||
|
id: 'disk',
|
||||||
|
label: 'Disk',
|
||||||
|
priority: 'essential' as ColumnPriority,
|
||||||
|
sortable: true,
|
||||||
|
minWidth: '100px',
|
||||||
|
flex: 1.5,
|
||||||
|
align: 'center' as const,
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Disk read I/O column */
|
||||||
|
diskRead: {
|
||||||
|
id: 'diskRead',
|
||||||
|
label: 'D Read',
|
||||||
|
priority: 'detailed' as ColumnPriority,
|
||||||
|
sortable: true,
|
||||||
|
minWidth: '100px',
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Disk write I/O column */
|
||||||
|
diskWrite: {
|
||||||
|
id: 'diskWrite',
|
||||||
|
label: 'D Write',
|
||||||
|
priority: 'detailed' as ColumnPriority,
|
||||||
|
sortable: true,
|
||||||
|
minWidth: '100px',
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Network in column */
|
||||||
|
networkIn: {
|
||||||
|
id: 'networkIn',
|
||||||
|
label: 'Net In',
|
||||||
|
priority: 'detailed' as ColumnPriority,
|
||||||
|
sortable: true,
|
||||||
|
minWidth: '100px',
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Network out column */
|
||||||
|
networkOut: {
|
||||||
|
id: 'networkOut',
|
||||||
|
label: 'Net Out',
|
||||||
|
priority: 'detailed' as ColumnPriority,
|
||||||
|
sortable: true,
|
||||||
|
minWidth: '100px',
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Temperature column */
|
||||||
|
temperature: {
|
||||||
|
id: 'temperature',
|
||||||
|
label: 'Temp',
|
||||||
|
priority: 'supplementary' as ColumnPriority,
|
||||||
|
sortable: true,
|
||||||
|
minWidth: '45px',
|
||||||
|
maxWidth: '65px',
|
||||||
|
align: 'center' as const,
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Node column (for grouped views) */
|
||||||
|
node: {
|
||||||
|
id: 'node',
|
||||||
|
label: 'Node',
|
||||||
|
priority: 'supplementary' as ColumnPriority,
|
||||||
|
sortable: true,
|
||||||
|
minWidth: '100px',
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Storage column */
|
||||||
|
storage: {
|
||||||
|
id: 'storage',
|
||||||
|
label: 'Storage',
|
||||||
|
priority: 'supplementary' as ColumnPriority,
|
||||||
|
sortable: true,
|
||||||
|
minWidth: '100px',
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Size column */
|
||||||
|
size: {
|
||||||
|
id: 'size',
|
||||||
|
label: 'Size',
|
||||||
|
priority: 'secondary' as ColumnPriority,
|
||||||
|
sortable: true,
|
||||||
|
minWidth: '80px',
|
||||||
|
align: 'right' as const,
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Time/date column */
|
||||||
|
time: {
|
||||||
|
id: 'time',
|
||||||
|
label: 'Time',
|
||||||
|
priority: 'primary' as ColumnPriority,
|
||||||
|
sortable: true,
|
||||||
|
minWidth: '120px',
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Actions column (expand buttons, etc.) */
|
||||||
|
actions: {
|
||||||
|
id: 'actions',
|
||||||
|
label: '',
|
||||||
|
priority: 'essential' as ColumnPriority,
|
||||||
|
sortable: false,
|
||||||
|
minWidth: '32px',
|
||||||
|
maxWidth: '48px',
|
||||||
|
},
|
||||||
|
} satisfies Record<string, ColumnConfig>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to create a column config from a standard column with overrides
|
||||||
|
*/
|
||||||
|
export function createColumn<T>(
|
||||||
|
base: ColumnConfig,
|
||||||
|
overrides?: Partial<ColumnConfigWithRender<T>>
|
||||||
|
): ColumnConfigWithRender<T> {
|
||||||
|
return { ...base, ...overrides };
|
||||||
|
}
|
||||||
|
|
@ -15,6 +15,15 @@ export function formatSpeed(bytesPerSecond: number, decimals = 0): string {
|
||||||
return `${formatBytes(bytesPerSecond, decimals)}/s`;
|
return `${formatBytes(bytesPerSecond, decimals)}/s`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatSpeedCompact(bytesPerSecond: number): string {
|
||||||
|
if (!bytesPerSecond || bytesPerSecond < 0) return '0';
|
||||||
|
const k = 1024;
|
||||||
|
const mbps = bytesPerSecond / (k * k);
|
||||||
|
if (mbps < 1) return '<1';
|
||||||
|
if (mbps < 10) return mbps.toFixed(1);
|
||||||
|
return Math.round(mbps).toString();
|
||||||
|
}
|
||||||
|
|
||||||
export function formatPercent(value: number): string {
|
export function formatPercent(value: number): string {
|
||||||
if (!Number.isFinite(value)) return '0%';
|
if (!Number.isFinite(value)) return '0%';
|
||||||
const abs = Math.abs(value);
|
const abs = Math.abs(value);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue