feat(mobile): optimize tables for mobile devices

- Implement horizontal scrolling for key tables (Dashboard, Storage, Backups, Docker, Node Summary).
- Ensure consistent column widths (140px fixed on desktop, 60px min on mobile) for metric columns (CPU, Memory, Disk).
- Switch Backups view from card to scrollable table for consistency.
- Reduce shell padding on mobile.
- Update ResourceTable (Alerts) with dedicated mobile view.
- Refine column visibility logic.
This commit is contained in:
rcourtman
2025-12-25 22:32:56 +00:00
parent 337c4e1fec
commit 4083df1f47
10 changed files with 1532 additions and 1452 deletions
File diff suppressed because it is too large Load Diff
@@ -1991,105 +1991,10 @@ const UnifiedBackups: Component = () => {
</Card>
}
>
{/* Mobile Card View - Compact */}
<div class="block lg:hidden space-y-3">
<For each={paginatedData()}>
{(group) => (
<div class="space-y-1">
<div class="text-xs font-medium text-gray-600 dark:text-gray-400 px-2 py-1 sticky top-0 bg-gray-50 dark:bg-gray-900 z-10">
{group.label} ({group.items.length})
</div>
<For each={group.items}>
{(item) => (
<Card padding="sm" class="hover:shadow-sm transition-shadow">
{/* Compact header row */}
<div class="flex items-center justify-between gap-2 mb-1">
<div class="flex items-center gap-2 min-w-0 flex-1">
<span
class={`inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium shrink-0 ${item.type === 'VM'
? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
}`}
>
{item.type}
</span>
<span class="text-xs text-gray-500 shrink-0">{item.vmid}</span>
<span class="font-medium text-xs truncate">
{item.name || 'Unnamed'}
</span>
</div>
<div class="flex items-center gap-2 shrink-0">
<span
class={`inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium ${item.backupType === 'snapshot'
? 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200'
: item.backupType === 'local'
? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'
: 'bg-cyan-100 text-cyan-800 dark:bg-cyan-900 dark:text-cyan-200'
}`}
>
{item.backupType === 'snapshot'
? 'SNAP'
: item.backupType === 'local'
? 'PVE'
: 'PBS'}
</span>
</div>
</div>
{/* Compact info row */}
<div class="flex items-center justify-between gap-2 text-[11px]">
<div class="flex items-center gap-3 text-gray-600 dark:text-gray-400">
<span>{item.node}</span>
<span class={getAgeColorClass(item.backupTime)}>
{formatTime(item.backupTime * 1000)}
</span>
<Show when={item.size}>
<span class={getSizeColor(item.size)}>
{formatBytes(item.size!)}
</span>
</Show>
<Show when={item.backupType === 'remote' && item.verified}>
<svg
class="w-4 h-4 text-green-600 dark:text-green-400 inline"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
</Show>
</div>
<Show
when={
(item.storage || item.datastore) &&
item.backupType !== 'snapshot'
}
>
<span class="text-gray-500 dark:text-gray-400 text-[10px] truncate max-w-[100px]">
{item.storage ||
(item.datastore &&
(item.namespace && item.namespace !== 'root'
? `${item.datastore}/${item.namespace}`
: item.datastore)) ||
'-'}
</span>
</Show>
</div>
</Card>
)}
</For>
</div>
)}
</For>
</div>
{/* Mobile Card View removed in favor of scrollable table */}
{/* Desktop Table View */}
<table class="backup-table hidden md:table">
<table class="backup-table" style={{ "min-width": "1200px" }}>
<thead>
<tr class="bg-gray-50 dark:bg-gray-700/50 text-gray-600 dark:text-gray-300 border-b border-gray-200 dark:border-gray-600">
<th
@@ -2112,14 +2017,14 @@ const UnifiedBackups: Component = () => {
Name {sortKey() === 'name' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
<th
class="hidden xl:table-cell px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('node')}
>
Node {sortKey() === 'node' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
<Show when={backupTypeFilter() === 'all' || backupTypeFilter() === 'remote'}>
<th
class="hidden 2xl:table-cell px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('owner')}
>
Owner {sortKey() === 'owner' && (sortDirection() === 'asc' ? '▲' : '▼')}
@@ -2133,14 +2038,14 @@ const UnifiedBackups: Component = () => {
</th>
<Show when={backupTypeFilter() !== 'snapshot'}>
<th
class="hidden lg:table-cell px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('size')}
>
Size {sortKey() === 'size' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
</Show>
<th
class="hidden lg:table-cell px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('backupType')}
>
Backup{' '}
@@ -2148,7 +2053,7 @@ const UnifiedBackups: Component = () => {
</th>
<Show when={backupTypeFilter() !== 'snapshot'}>
<th
class="hidden xl:table-cell px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('storage')}
>
Location{' '}
@@ -2157,7 +2062,7 @@ const UnifiedBackups: Component = () => {
</Show>
<Show when={backupTypeFilter() === 'all' || backupTypeFilter() === 'remote'}>
<th
class="hidden lg:table-cell px-2 py-1.5 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
class="px-2 py-1.5 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('verified')}
>
<span title="Verified">✓</span>
@@ -2165,7 +2070,7 @@ const UnifiedBackups: Component = () => {
</th>
</Show>
<th
class="hidden md:table-cell px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider"
class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider"
>
Details
</th>
@@ -2209,13 +2114,13 @@ const UnifiedBackups: Component = () => {
<td class="p-0.5 px-1.5 text-sm align-middle">
{item.name || '-'}
</td>
<td class="hidden xl:table-cell p-0.5 px-1.5 text-sm align-middle">{item.node}</td>
<td class="p-0.5 px-1.5 text-sm align-middle">{item.node}</td>
<Show
when={
backupTypeFilter() === 'all' || backupTypeFilter() === 'remote'
}
>
<td class="hidden 2xl:table-cell p-0.5 px-1.5 text-xs align-middle text-gray-500 dark:text-gray-400">
<td class="p-0.5 px-1.5 text-xs align-middle text-gray-500 dark:text-gray-400">
{item.owner ? item.owner.split('@')[0] : '-'}
</td>
</Show>
@@ -2226,12 +2131,12 @@ const UnifiedBackups: Component = () => {
</td>
<Show when={backupTypeFilter() !== 'snapshot'}>
<td
class={`hidden lg:table-cell p-0.5 px-1.5 align-middle ${getSizeColor(item.size)}`}
class={`p-0.5 px-1.5 align-middle ${getSizeColor(item.size)}`}
>
{item.size ? formatBytes(item.size) : '-'}
</td>
</Show>
<td class="hidden lg:table-cell p-0.5 px-1.5 align-middle">
<td class="p-0.5 px-1.5 align-middle">
<div class="flex items-center gap-1">
<span
class={`inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium ${item.backupType === 'snapshot'
@@ -2288,7 +2193,7 @@ const UnifiedBackups: Component = () => {
</div>
</td>
<Show when={backupTypeFilter() !== 'snapshot'}>
<td class="hidden xl:table-cell p-0.5 px-1.5 text-sm align-middle">
<td class="p-0.5 px-1.5 text-sm align-middle">
{item.storage ||
(item.datastore &&
(item.namespace && item.namespace !== 'root'
@@ -2302,7 +2207,7 @@ const UnifiedBackups: Component = () => {
backupTypeFilter() === 'all' || backupTypeFilter() === 'remote'
}
>
<td class="hidden lg:table-cell p-0.5 px-1.5 text-center align-middle">
<td class="p-0.5 px-1.5 text-center align-middle">
{item.backupType === 'remote' ? (
item.verified ? (
<span title="PBS backup verified">
@@ -2348,7 +2253,7 @@ const UnifiedBackups: Component = () => {
</td>
</Show>
<td
class="hidden md:table-cell p-0.5 px-1.5 align-middle"
class="p-0.5 px-1.5 align-middle"
onMouseEnter={(e) => {
const details = [];
@@ -20,6 +20,7 @@ import { getNodeDisplayName } from '@/utils/nodes';
import { logger } from '@/utils/logger';
import { usePersistentSignal } from '@/hooks/usePersistentSignal';
import { useColumnVisibility } from '@/hooks/useColumnVisibility';
import { useBreakpoint } from '@/hooks/useBreakpoint';
import { STORAGE_KEYS } from '@/utils/localStorage';
import { getBackupInfo } from '@/utils/format';
import { aiChatStore } from '@/stores/aiChat';
@@ -207,6 +208,7 @@ export function Dashboard(props: DashboardProps) {
const navigate = useNavigate();
const ws = useWebSocket();
const { connected, activeAlerts, initialDataReceived, reconnecting, reconnect } = ws;
const { isMobile } = useBreakpoint();
const alertsActivation = useAlertsActivation();
const alertsEnabled = createMemo(() => alertsActivation.activationState() === 'active');
const [search, setSearch] = createSignal('');
@@ -1117,7 +1119,7 @@ export function Dashboard(props: DashboardProps) {
<ComponentErrorBoundary name="Guest Table">
<Card padding="none" tone="glass" class="mb-4 overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full border-collapse whitespace-nowrap" style={{ "min-width": "900px" }}>
<table class="w-full border-collapse whitespace-nowrap" style={{ "min-width": isMobile() ? "100%" : "900px" }}>
<thead>
<tr class="bg-gray-50 dark:bg-gray-700/50 text-gray-600 dark:text-gray-300 border-b border-gray-200 dark:border-gray-700">
<For each={visibleColumns()}>
@@ -1130,10 +1132,14 @@ export function Dashboard(props: DashboardProps) {
return (
<th
class={`py-1 text-[11px] sm:text-xs font-medium uppercase tracking-wider whitespace-nowrap
${isFirst() ? 'pl-4 pr-2 text-left' : 'px-2 text-center'}
${isSortable ? 'cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600' : ''}`}
${isFirst() ? 'pl-4 pr-2 text-left' : 'px-2 text-center'}
${isSortable ? 'cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600' : ''}`}
style={{
...(col.width ? { "min-width": col.width } : {}),
...((['cpu', 'memory', 'disk'].includes(col.id))
? (isMobile()
? { "min-width": "60px" }
: { "width": "140px", "min-width": "140px", "max-width": "140px" })
: (col.width && (!isMobile() || col.id !== 'name') ? { "min-width": col.width } : {})),
"vertical-align": 'middle'
}}
onClick={() => isSortable && handleSort(sortKeyForCol!)}
@@ -433,6 +433,8 @@ export interface GuestColumnDef {
export const GUEST_COLUMNS: GuestColumnDef[] = [
// Essential - always visible (fixed widths ensure no overlap)
{ id: 'name', label: 'Name', priority: 'essential', width: '120px', sortKey: 'name' },
// Secondary - visible on md+ (Now essential for mobile scroll)
{ id: 'type', label: 'Type', priority: 'essential', width: '40px', sortKey: 'type' },
{ id: 'vmid', label: 'ID', priority: 'essential', width: '45px', sortKey: 'vmid' },
@@ -441,21 +443,21 @@ export const GUEST_COLUMNS: GuestColumnDef[] = [
{ id: 'memory', label: 'Mem', priority: 'essential', width: '140px', sortKey: 'memory' },
{ id: 'disk', label: 'Disk', priority: 'essential', width: '140px', sortKey: 'disk' },
// Secondary - visible on md+ (768px), user toggleable - use icons
{ id: 'ip', label: 'IP', icon: <svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" /></svg>, priority: 'secondary', width: '45px', toggleable: true },
{ id: 'uptime', label: 'Uptime', icon: <svg class="w-3.5 h-3.5 block" 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>, priority: 'secondary', width: '60px', toggleable: true, sortKey: 'uptime' },
{ id: 'node', label: 'Node', icon: <svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" /></svg>, priority: 'secondary', width: '55px', toggleable: true, sortKey: 'node' },
// Secondary - visible on md+ (Now essential), user toggleable - use icons
{ id: 'ip', label: 'IP', icon: <svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" /></svg>, priority: 'essential', width: '45px', toggleable: true },
{ id: 'uptime', label: 'Uptime', icon: <svg class="w-3.5 h-3.5 block" 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>, priority: 'essential', width: '60px', toggleable: true, sortKey: 'uptime' },
{ id: 'node', label: 'Node', icon: <svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" /></svg>, priority: 'essential', width: '55px', toggleable: true, sortKey: 'node' },
// Supplementary - visible on lg+ (1024px), user toggleable
{ id: 'backup', label: 'Backup', icon: <svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /></svg>, priority: 'supplementary', width: '50px', toggleable: true },
{ id: 'tags', label: 'Tags', icon: <svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" /></svg>, priority: 'supplementary', width: '60px', toggleable: true },
// Supplementary - visible on lg+ (Now essential), user toggleable
{ id: 'backup', label: 'Backup', icon: <svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /></svg>, priority: 'essential', width: '50px', toggleable: true },
{ id: 'tags', label: 'Tags', icon: <svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" /></svg>, priority: 'essential', width: '60px', toggleable: true },
// Detailed - visible on xl+ (1280px), user toggleable
{ id: 'os', label: 'OS', priority: 'detailed', width: '45px', toggleable: true },
{ id: 'diskRead', label: 'D Read', icon: <svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" /></svg>, priority: 'detailed', width: '55px', toggleable: true, sortKey: 'diskRead' },
{ id: 'diskWrite', label: 'D Write', icon: <svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" /></svg>, priority: 'detailed', width: '55px', toggleable: true, sortKey: 'diskWrite' },
{ id: 'netIn', label: 'Net In', icon: <svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16l-4-4m0 0l4-4m-4 4h18" /></svg>, priority: 'detailed', width: '55px', toggleable: true, sortKey: 'networkIn' },
{ id: 'netOut', label: 'Net Out', icon: <svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 8l4 4m0 0l-4 4m4-4H3" /></svg>, priority: 'detailed', width: '55px', toggleable: true, sortKey: 'networkOut' },
// Detailed - visible on xl+ (Now essential), user toggleable
{ id: 'os', label: 'OS', priority: 'essential', width: '45px', toggleable: true },
{ id: 'diskRead', label: 'D Read', icon: <svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" /></svg>, priority: 'essential', width: '55px', toggleable: true, sortKey: 'diskRead' },
{ id: 'diskWrite', label: 'D Write', icon: <svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" /></svg>, priority: 'essential', width: '55px', toggleable: true, sortKey: 'diskWrite' },
{ id: 'netIn', label: 'Net In', icon: <svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16l-4-4m0 0l4-4m-4 4h18" /></svg>, priority: 'essential', width: '55px', toggleable: true, sortKey: 'networkIn' },
{ id: 'netOut', label: 'Net Out', icon: <svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 8l4 4m0 0l-4 4m4-4H3" /></svg>, priority: 'essential', width: '55px', toggleable: true, sortKey: 'networkOut' },
];
interface GuestRowProps {
@@ -790,6 +792,8 @@ export function GuestRow(props: GuestRowProps) {
props.onRowClick?.(props.guest);
};
return (
<>
<tr
@@ -914,16 +918,11 @@ export function GuestRow(props: GuestRowProps) {
{/* CPU */}
<Show when={isColVisible('cpu')}>
<td class="px-2 py-1 align-middle" style={{ width: "140px", "min-width": "140px", "max-width": "140px" }}>
<Show when={isMobile()}>
<div class="md:hidden h-4 flex items-center justify-center">
<MetricText value={cpuPercent()} type="cpu" />
</div>
</Show>
<div class="hidden md:block h-4">
<td class="px-2 py-1 align-middle" style={isMobile() ? { "min-width": "60px" } : { width: "140px", "min-width": "140px", "max-width": "140px" }}>
<div class="h-4">
<EnhancedCPUBar
usage={cpuPercent()}
cores={props.guest.cpus}
cores={isMobile() ? undefined : props.guest.cpus}
resourceId={metricsKey()}
anomaly={cpuAnomaly()}
/>
@@ -933,52 +932,38 @@ export function GuestRow(props: GuestRowProps) {
{/* Memory */}
<Show when={isColVisible('memory')}>
<td class="px-2 py-1 align-middle" style={{ width: "140px", "min-width": "140px", "max-width": "140px" }}>
<td class="px-2 py-1 align-middle" style={isMobile() ? { "min-width": "60px" } : { width: "140px", "min-width": "140px", "max-width": "140px" }}>
<div title={memoryTooltip() ?? undefined}>
<Show when={isMobile()}>
<div class="md:hidden flex justify-center">
<ResponsiveMetricCell
value={memPercent()}
type="memory"
<Show
when={viewMode() === 'sparklines'}
fallback={
<StackedMemoryBar
used={props.guest.memory?.used || 0}
total={props.guest.memory?.total || 0}
balloon={props.guest.memory?.balloon || 0}
swapUsed={props.guest.memory?.swapUsed || 0}
swapTotal={props.guest.memory?.swapTotal || 0}
resourceId={metricsKey()}
sublabel={memoryUsageLabel()}
isRunning={isRunning()}
showMobile={true}
anomaly={memoryAnomaly()}
/>
</div>
}
>
<ResponsiveMetricCell
value={memPercent()}
type="memory"
resourceId={metricsKey()}
sublabel={memoryUsageLabel()}
isRunning={isRunning()}
showMobile={false}
/>
</Show>
<div class="hidden md:block">
<Show
when={viewMode() === 'sparklines'}
fallback={
<StackedMemoryBar
used={props.guest.memory?.used || 0}
total={props.guest.memory?.total || 0}
balloon={props.guest.memory?.balloon || 0}
swapUsed={props.guest.memory?.swapUsed || 0}
swapTotal={props.guest.memory?.swapTotal || 0}
resourceId={metricsKey()}
anomaly={memoryAnomaly()}
/>
}
>
<ResponsiveMetricCell
value={memPercent()}
type="memory"
resourceId={metricsKey()}
sublabel={memoryUsageLabel()}
isRunning={isRunning()}
showMobile={false}
/>
</Show>
</div>
</div>
</td>
</Show>
{/* Disk */}
<Show when={isColVisible('disk')}>
<td class="px-2 py-1 align-middle" style={{ width: "140px", "min-width": "140px", "max-width": "140px" }}>
<td class="px-2 py-1 align-middle" style={isMobile() ? { "min-width": "60px" } : { width: "140px", "min-width": "140px", "max-width": "140px" }}>
<Show
when={hasDiskUsage()}
fallback={
@@ -989,31 +974,24 @@ export function GuestRow(props: GuestRowProps) {
</div>
}
>
<Show when={isMobile()}>
<div class="md:hidden flex justify-center text-xs text-gray-600 dark:text-gray-400">
{formatPercent(diskPercent())}
</div>
</Show>
<div class={isMobile() ? 'hidden md:block' : ''}>
<Show
when={viewMode() === 'sparklines'}
fallback={
<StackedDiskBar
disks={props.guest.disks}
aggregateDisk={props.guest.disk}
anomaly={diskAnomaly()}
/>
}
>
<ResponsiveMetricCell
value={diskPercent()}
type="disk"
resourceId={metricsKey()}
isRunning={isRunning()}
showMobile={false}
<Show
when={viewMode() === 'sparklines'}
fallback={
<StackedDiskBar
disks={props.guest.disks}
aggregateDisk={props.guest.disk}
anomaly={diskAnomaly()}
/>
</Show>
</div>
}
>
<ResponsiveMetricCell
value={diskPercent()}
type="disk"
resourceId={metricsKey()}
isRunning={isRunning()}
showMobile={false}
/>
</Show>
</Show>
</td>
</Show>
@@ -206,7 +206,7 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
return (
<>
<Card padding="none" tone="glass" class={`mb-4 ${urlEdit.isEditing() ? 'overflow-visible' : 'overflow-hidden'}`}>
<ScrollableTable persistKey="docker-host-summary">
<ScrollableTable persistKey="docker-host-summary" minWidth={isMobile() ? '100%' : '800px'}>
<table class="w-full border-collapse whitespace-nowrap">
<thead>
<tr class="bg-gray-50 dark:bg-gray-700/50 text-gray-600 dark:text-gray-300 border-b border-gray-200 dark:border-gray-700">
@@ -225,18 +225,21 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
</th>
<th
class="px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
style={isMobile() ? { "min-width": "60px" } : { width: "140px", "min-width": "140px", "max-width": "140px" }}
onClick={() => handleSort('cpu')}
>
CPU {renderSortIndicator('cpu')}
</th>
<th
class="px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
style={isMobile() ? { "min-width": "60px" } : { width: "140px", "min-width": "140px", "max-width": "140px" }}
onClick={() => handleSort('memory')}
>
Memory {renderSortIndicator('memory')}
</th>
<th
class="px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"
style={isMobile() ? { "min-width": "60px" } : { width: "140px", "min-width": "140px", "max-width": "140px" }}
onClick={() => handleSort('disk')}
>
Disk {renderSortIndicator('disk')}
@@ -383,40 +386,39 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
{renderDockerStatusBadge(summary.host.status)}
</div>
</td>
<td class="px-2 py-1 align-middle" style={{ "min-width": "140px" }}>
<Show when={isMobile()}>
<div class="md:hidden flex justify-center">
<MetricText value={summary.cpuPercent} type="cpu" />
</div>
</Show>
<div class="hidden md:block w-full">
<td class="px-2 py-1 align-middle" style={isMobile() ? { "min-width": "60px" } : { width: "140px", "min-width": "140px", "max-width": "140px" }}>
<div class="h-4 w-full">
<EnhancedCPUBar
usage={summary.cpuPercent}
loadAverage={summary.host.loadAverage?.[0]}
cores={summary.host.cpus}
cores={isMobile() ? undefined : summary.host.cpus}
resourceId={metricsKey}
/>
</div>
</td>
<td class="px-2 py-1 align-middle" style={{ "min-width": "140px" }}>
<ResponsiveMetricCell
value={summary.memoryPercent}
type="memory"
sublabel={summary.memoryLabel}
resourceId={metricsKey}
isRunning={online}
showMobile={isMobile()}
/>
<td class="px-2 py-1 align-middle" style={isMobile() ? { "min-width": "60px" } : { width: "140px", "min-width": "140px", "max-width": "140px" }}>
<div class="h-4">
<ResponsiveMetricCell
value={summary.memoryPercent}
type="memory"
sublabel={summary.memoryLabel}
resourceId={metricsKey}
isRunning={online}
showMobile={false}
/>
</div>
</td>
<td class="px-2 py-1 align-middle" style={{ "min-width": "140px" }}>
<ResponsiveMetricCell
value={summary.diskPercent}
type="disk"
sublabel={summary.diskLabel}
resourceId={metricsKey}
isRunning={!!summary.diskLabel}
showMobile={isMobile()}
/>
<td class="px-2 py-1 align-middle" style={isMobile() ? { "min-width": "60px" } : { width: "140px", "min-width": "140px", "max-width": "140px" }}>
<div class="h-4">
<ResponsiveMetricCell
value={summary.diskPercent}
type="disk"
sublabel={summary.diskLabel}
resourceId={metricsKey}
isRunning={!!summary.diskLabel}
showMobile={false}
/>
</div>
</td>
<td class="px-2 py-1 align-middle">
<div class="flex justify-center items-center h-full whitespace-nowrap w-full">
@@ -1322,7 +1322,7 @@ const DockerContainerRow: Component<{
type="cpu"
resourceId={metricsKey}
isRunning={isRunning() && (container.cpuPercent ?? 0) > 0}
showMobile={props.isMobile()}
showMobile={false}
class="w-full"
/>
</div>
@@ -1334,20 +1334,7 @@ const DockerContainerRow: Component<{
return (
<div class="px-2 py-0.5 flex items-center overflow-hidden">
<Show when={props.isMobile()}>
<div class="md:hidden w-full">
<ResponsiveMetricCell
value={memPercent()}
type="memory"
sublabel={memUsageLabel()}
resourceId={metricsKey}
isRunning={isRunning() && (container.memoryUsageBytes ?? 0) > 0}
showMobile={true}
class="w-full"
/>
</div>
</Show>
<div class="hidden md:block w-full">
<div class="w-full">
<StackedMemoryBar
used={container.memoryUsageBytes || 0}
total={memoryTotal()}
@@ -1370,7 +1357,7 @@ const DockerContainerRow: Component<{
resourceId={metricsKey}
sublabel={diskSublabel() ?? diskUsageLabel()}
isRunning={true}
showMobile={props.isMobile()}
showMobile={false}
class="w-full"
/>
</Show>
@@ -1415,7 +1402,9 @@ const DockerContainerRow: Component<{
<td
class={`py-0.5 align-middle whitespace-nowrap ${column.id === 'resource' ? 'max-w-[300px]' : ''}`}
style={{
"min-width": column.id === 'cpu' || column.id === 'memory' ? '140px' : undefined,
"min-width": (column.id === 'cpu' || column.id === 'memory') ? (props.isMobile() ? '60px' : '140px') : undefined,
"width": (column.id === 'cpu' || column.id === 'memory') && !props.isMobile() ? '140px' : undefined,
"max-width": (column.id === 'cpu' || column.id === 'memory') && !props.isMobile() ? '140px' : undefined
}}
>
{renderCell(column)}
@@ -2245,7 +2234,9 @@ const DockerServiceRow: Component<{
<td
class={`py-0.5 align-middle whitespace-nowrap ${column.id === 'resource' ? 'max-w-[300px]' : ''}`}
style={{
"min-width": column.id === 'cpu' || column.id === 'memory' ? '140px' : undefined,
"min-width": (column.id === 'cpu' || column.id === 'memory') ? (props.isMobile() ? '60px' : '140px') : undefined,
"width": (column.id === 'cpu' || column.id === 'memory') && !props.isMobile() ? '140px' : undefined,
"max-width": (column.id === 'cpu' || column.id === 'memory') && !props.isMobile() ? '140px' : undefined
}}
>
{renderCell(column)}
@@ -2790,7 +2781,7 @@ const DockerUnifiedTable: Component<DockerUnifiedTableProps> = (props) => {
>
<Card padding="none" tone="glass" class="overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full border-collapse whitespace-nowrap">
<table class="w-full border-collapse whitespace-nowrap" style={{ "min-width": isMobile() ? "100%" : "800px" }}>
<thead>
<tr class="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">
<For each={DOCKER_COLUMNS}>
@@ -2801,7 +2792,7 @@ const DockerUnifiedTable: Component<DockerUnifiedTableProps> = (props) => {
return (
<th
class={`${isResource ? 'pl-4 sm:pl-5 lg:pl-6 pr-2' : 'px-2'} py-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 text-left font-medium whitespace-nowrap`}
style={{ "min-width": col.id === 'cpu' || col.id === 'memory' ? '140px' : undefined }}
style={{ "min-width": (col.id === 'cpu' || col.id === 'memory' || col.id === 'disk') ? (isMobile() ? '60px' : '140px') : undefined, "width": (col.id === 'cpu' || col.id === 'memory' || col.id === 'disk') && !isMobile() ? '140px' : undefined, "max-width": (col.id === 'cpu' || col.id === 'memory' || col.id === 'disk') && !isMobile() ? '140px' : undefined }}
onClick={() => colSortKey && handleSort(colSortKey)}
onKeyDown={(e) => e.key === 'Enter' && colSortKey && handleSort(colSortKey)}
tabIndex={0}
@@ -788,30 +788,30 @@ const Storage: Component = () => {
<style>{`
.overflow-x-auto::-webkit-scrollbar { display: none; }
`}</style>
<table class="w-full">
<table class="w-full" style={{ "min-width": "900px" }}>
<thead>
<tr class="bg-gray-50 dark:bg-gray-700/50 text-gray-600 dark:text-gray-300 border-b border-gray-200 dark:border-gray-600">
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-auto">
Storage
</th>
<th class="hidden md:table-cell px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[10%]">
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[10%]">
Type
</th>
<th class="hidden lg:table-cell px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[15%]">
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[15%]">
Content
</th>
<th class="hidden sm:table-cell px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[10%]">
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[10%]">
Status
</th>
<Show when={viewMode() === 'node'}>
<th class="hidden lg:table-cell px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[8%]">
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[8%]">
Shared
</th>
</Show>
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[25%] min-w-[150px]">
Usage
</th>
<th class="hidden sm:table-cell px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[10%]">
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[10%]">
Free
</th>
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[10%]">
@@ -1154,12 +1154,12 @@ const Storage: Component = () => {
</Show>
</div>
</td>
<td class="hidden md:table-cell p-0.5 px-1.5">
<td class="p-0.5 px-1.5">
<span class="inline-block px-1.5 py-0.5 text-[10px] font-medium rounded bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300">
{storage.type}
</span>
</td>
<td class="hidden lg:table-cell p-0.5 px-1.5">
<td class="p-0.5 px-1.5">
<span
class="text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap truncate max-w-[220px]"
title={storage.content || '-'}
@@ -1167,7 +1167,7 @@ const Storage: Component = () => {
{storage.content || '-'}
</span>
</td>
<td class="hidden sm:table-cell p-0.5 px-1.5 text-xs whitespace-nowrap">
<td class="p-0.5 px-1.5 text-xs whitespace-nowrap">
<span
class={`${storage.status === 'available'
? 'text-green-600 dark:text-green-400'
@@ -1178,7 +1178,7 @@ const Storage: Component = () => {
</span>
</td>
<Show when={viewMode() === 'node'}>
<td class="hidden lg:table-cell p-0.5 px-1.5">
<td class="p-0.5 px-1.5">
<span class="text-xs text-gray-600 dark:text-gray-400">
{storage.shared ? '✓' : '-'}
</span>
@@ -1193,7 +1193,7 @@ const Storage: Component = () => {
zfsPool={storage.zfsPool}
/>
</td>
<td class="hidden sm:table-cell p-0.5 px-1.5 text-xs whitespace-nowrap">
<td class="p-0.5 px-1.5 text-xs whitespace-nowrap">
{formatBytes(storage.free || 0, 0)}
</td>
<td class="p-0.5 px-1.5 text-xs whitespace-nowrap">
@@ -348,7 +348,7 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
return (
<Card padding="none" tone="glass" class="mb-4 overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full border-collapse whitespace-nowrap" style={{ "min-width": "800px" }}>
<table class="w-full border-collapse whitespace-nowrap" style={{ "min-width": isMobile() ? "100%" : "800px" }}>
<thead>
<tr class="bg-gray-50 dark:bg-gray-700/50 text-gray-600 dark:text-gray-300 border-b border-gray-200 dark:border-gray-700">
<th
@@ -360,13 +360,13 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
<th class={thClass} style={{ "min-width": '80px' }} onClick={() => handleSort('uptime')}>
Uptime {renderSortIndicator('uptime')}
</th>
<th class={thClass} style={{ width: "200px", "min-width": "200px", "max-width": "200px" }} onClick={() => handleSort('cpu')}>
<th class={thClass} style={isMobile() ? { "min-width": "60px" } : { width: "200px", "min-width": "200px", "max-width": "200px" }} onClick={() => handleSort('cpu')}>
CPU {renderSortIndicator('cpu')}
</th>
<th class={thClass} style={{ width: "200px", "min-width": "200px", "max-width": "200px" }} onClick={() => handleSort('memory')}>
<th class={thClass} style={isMobile() ? { "min-width": "60px" } : { width: "200px", "min-width": "200px", "max-width": "200px" }} onClick={() => handleSort('memory')}>
Memory {renderSortIndicator('memory')}
</th>
<th class={thClass} style={{ width: "200px", "min-width": "200px", "max-width": "200px" }} onClick={() => handleSort('disk')}>
<th class={thClass} style={isMobile() ? { "min-width": "60px" } : { width: "200px", "min-width": "200px", "max-width": "200px" }} onClick={() => handleSort('disk')}>
Disk {renderSortIndicator('disk')}
</th>
<Show when={hasAnyTemperatureData()}>
@@ -575,17 +575,12 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
</td>
{/* CPU */}
<td class={tdClass} style={metricColumnStyle}>
<Show when={isMobile()}>
<div class="md:hidden h-4 flex items-center justify-center">
<MetricText value={cpuPercentValue} type="cpu" />
</div>
</Show>
<div class="hidden md:block h-4">
<td class={tdClass} style={isMobile() ? { "min-width": "60px" } : metricColumnStyle}>
<div class="h-4">
<EnhancedCPUBar
usage={cpuPercentValue}
loadAverage={isPVEItem ? node!.loadAverage?.[0] : undefined}
cores={isPVEItem ? node!.cpuInfo?.cores : undefined}
cores={isMobile() ? undefined : (isPVEItem ? node!.cpuInfo?.cores : undefined)}
model={isPVEItem ? node!.cpuInfo?.model : undefined}
resourceId={metricsKey}
/>
@@ -593,13 +588,8 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
</td>
{/* Memory */}
<td class={tdClass} style={metricColumnStyle}>
<Show when={isMobile()}>
<div class="md:hidden h-4 flex items-center justify-center">
<MetricText value={memoryPercentValue} type="memory" />
</div>
</Show>
<div class="hidden md:block h-4">
<td class={tdClass} style={isMobile() ? { "min-width": "60px" } : metricColumnStyle}>
<div class="h-4">
<Show when={isPVEItem} fallback={
<ResponsiveMetricCell
value={memoryPercentValue}
@@ -636,13 +626,8 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
</td>
{/* Disk */}
<td class={tdClass} style={metricColumnStyle}>
<Show when={isMobile()}>
<div class="md:hidden h-4 flex items-center justify-center">
<MetricText value={diskPercentValue} type="disk" />
</div>
</Show>
<div class="hidden md:block h-4">
<td class={tdClass} style={isMobile() ? { "min-width": "60px" } : metricColumnStyle}>
<div class="h-4">
<Show when={isPVEItem} fallback={
<ResponsiveMetricCell
value={diskPercentValue}
@@ -106,7 +106,7 @@ export function useColumnVisibility(
const visibleColumns: Accessor<ColumnDef[]> = createMemo(() => {
return columns.filter(col => {
// Always show essential columns regardless of breakpoint
if (col.priority === 'essential') return true;
// However, check for toggle status later (removed early return)
// Check if screen has space for this priority level
if (!hasSpaceForColumn(col)) return false;
+1 -1
View File
@@ -134,7 +134,7 @@
@media (max-width: 640px) {
.pulse-shell {
padding-inline: clamp(0.75rem, 6vw, 1.25rem);
padding-inline: 0.5rem;
}
}