refactor(ui): improve table column sizing and progress bar constraints

- Cap progress bars at 140px max-width for better readability
- Constrain shell layout to 1400px max-width, centered
- Fix metric columns (CPU, Memory, Disk) at 156px
- Give name/resource columns extra space via flex
- Remove Docker Disk column (data rarely available from API)
- Remove fixed percentage widths from backups table for auto-sizing
- Improve text contrast in progress bar labels
This commit is contained in:
rcourtman 2025-11-27 12:09:12 +00:00
parent 787f947f2d
commit 244431b6c2
6 changed files with 53 additions and 51 deletions

View file

@ -1869,7 +1869,6 @@ const UnifiedBackups: Component = () => {
<style>{` <style>{`
.overflow-x-auto::-webkit-scrollbar { display: none; } .overflow-x-auto::-webkit-scrollbar { display: none; }
.backup-table { .backup-table {
table-layout: fixed;
width: 100%; width: 100%;
} }
.backup-table th, .backup-table th,
@ -2039,54 +2038,54 @@ const UnifiedBackups: Component = () => {
<thead> <thead>
<tr class="bg-gray-50 dark:bg-gray-700/50 text-gray-600 dark:text-gray-300 border-b border-gray-200 dark:border-gray-600"> <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 <th
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 w-[8%] min-w-[60px]" 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('vmid')} onClick={() => handleSort('vmid')}
> >
{hasHostBackups() ? 'VMID/Host' : 'VMID'}{' '} {hasHostBackups() ? 'ID' : 'VMID'}{' '}
{sortKey() === 'vmid' && (sortDirection() === 'asc' ? '▲' : '▼')} {sortKey() === 'vmid' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th> </th>
<th <th
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 w-[8%] min-w-[60px]" 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('type')} onClick={() => handleSort('type')}
> >
Type {sortKey() === 'type' && (sortDirection() === 'asc' ? '▲' : '▼')} Type {sortKey() === 'type' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th> </th>
<th <th
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 w-auto" 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('name')} onClick={() => handleSort('name')}
> >
Name {sortKey() === 'name' && (sortDirection() === 'asc' ? '▲' : '▼')} Name {sortKey() === 'name' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th> </th>
<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 w-[8%]" 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"
onClick={() => handleSort('node')} onClick={() => handleSort('node')}
> >
Node {sortKey() === 'node' && (sortDirection() === 'asc' ? '▲' : '▼')} Node {sortKey() === 'node' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th> </th>
<Show when={backupTypeFilter() === 'all' || backupTypeFilter() === 'remote'}> <Show when={backupTypeFilter() === 'all' || backupTypeFilter() === 'remote'}>
<th <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 w-[8%]" 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"
onClick={() => handleSort('owner')} onClick={() => handleSort('owner')}
> >
Owner {sortKey() === 'owner' && (sortDirection() === 'asc' ? '▲' : '▼')} Owner {sortKey() === 'owner' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th> </th>
</Show> </Show>
<th <th
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 w-[14%] min-w-[120px]" 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('backupTime')} onClick={() => handleSort('backupTime')}
> >
Time {sortKey() === 'backupTime' && (sortDirection() === 'asc' ? '▲' : '▼')} Time {sortKey() === 'backupTime' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th> </th>
<Show when={backupTypeFilter() !== 'snapshot'}> <Show when={backupTypeFilter() !== 'snapshot'}>
<th <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 w-[8%]" 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"
onClick={() => handleSort('size')} onClick={() => handleSort('size')}
> >
Size {sortKey() === 'size' && (sortDirection() === 'asc' ? '▲' : '▼')} Size {sortKey() === 'size' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th> </th>
</Show> </Show>
<th <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 w-[8%]" 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"
onClick={() => handleSort('backupType')} onClick={() => handleSort('backupType')}
> >
Backup{' '} Backup{' '}
@ -2094,7 +2093,7 @@ const UnifiedBackups: Component = () => {
</th> </th>
<Show when={backupTypeFilter() !== 'snapshot'}> <Show when={backupTypeFilter() !== 'snapshot'}>
<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 w-[12%]" 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"
onClick={() => handleSort('storage')} onClick={() => handleSort('storage')}
> >
Location{' '} Location{' '}
@ -2103,15 +2102,15 @@ const UnifiedBackups: Component = () => {
</Show> </Show>
<Show when={backupTypeFilter() === 'all' || backupTypeFilter() === 'remote'}> <Show when={backupTypeFilter() === 'all' || backupTypeFilter() === 'remote'}>
<th <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 w-[6%]" 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"
onClick={() => handleSort('verified')} onClick={() => handleSort('verified')}
> >
Verified{' '} <span title="Verified"></span>
{sortKey() === 'verified' && (sortDirection() === 'asc' ? '▲' : '▼')} {sortKey() === 'verified' && (sortDirection() === 'asc' ? ' ▲' : ' ▼')}
</th> </th>
</Show> </Show>
<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-[15%]" class="hidden md:table-cell px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider"
> >
Details Details
</th> </th>

View file

@ -111,14 +111,15 @@ interface ColumnDef {
} }
export const GUEST_COLUMNS: ColumnDef[] = [ export const GUEST_COLUMNS: ColumnDef[] = [
{ id: 'name', label: 'Name', priority: 'essential', minWidth: '100px', flex: 1.5 }, { id: 'name', label: 'Name', priority: 'essential', minWidth: '100px', maxWidth: '300px' },
{ id: 'type', label: 'Type', priority: 'essential', minWidth: '24px', maxWidth: '50px' }, { id: 'type', label: 'Type', priority: 'essential', minWidth: '24px', maxWidth: '50px' },
{ id: 'vmid', label: 'VMID', priority: 'essential', minWidth: '28px', maxWidth: '55px' }, { id: 'vmid', label: 'VMID', priority: 'essential', minWidth: '28px', maxWidth: '55px' },
{ id: 'uptime', label: 'Uptime', priority: 'essential', minWidth: '28px', maxWidth: '65px' }, { id: 'uptime', label: 'Uptime', priority: 'essential', minWidth: '28px', maxWidth: '65px' },
{ id: 'cpu', label: 'CPU', priority: 'essential', minWidth: '50px', flex: 1 }, // Metric columns - fixed width to match progress bar max-width (140px + padding)
{ id: 'memory', label: 'Memory', priority: 'essential', minWidth: '50px', flex: 1 }, { id: 'cpu', label: 'CPU', priority: 'essential', minWidth: '50px', maxWidth: '156px' },
{ id: 'disk', label: 'Disk', priority: 'essential', minWidth: '50px', flex: 1 }, { id: 'memory', label: 'Memory', priority: 'essential', minWidth: '50px', maxWidth: '156px' },
// I/O columns - fixed width matching v4.32.5 (grows at breakpoints to fit full header text) { id: 'disk', label: 'Disk', priority: 'essential', minWidth: '50px', maxWidth: '156px' },
// I/O columns - fixed width
{ id: 'diskRead', label: 'Disk Read', priority: 'essential', minWidth: '56px', maxWidth: '90px' }, { id: 'diskRead', label: 'Disk Read', priority: 'essential', minWidth: '56px', maxWidth: '90px' },
{ id: 'diskWrite', label: 'Disk Write', priority: 'essential', minWidth: '56px', maxWidth: '90px' }, { id: 'diskWrite', label: 'Disk Write', priority: 'essential', minWidth: '56px', maxWidth: '90px' },
{ id: 'netIn', label: 'Net In', priority: 'essential', minWidth: '56px', maxWidth: '70px' }, { id: 'netIn', label: 'Net In', priority: 'essential', minWidth: '56px', maxWidth: '70px' },

View file

@ -101,15 +101,15 @@ export function MetricBar(props: MetricBarProps) {
<Show <Show
when={viewMode() === 'sparklines' && props.resourceId} when={viewMode() === 'sparklines' && props.resourceId}
fallback={ fallback={
// Original progress bar mode // Original progress bar mode - capped width for better appearance on wide screens
<div ref={containerRef} class="metric-text w-full h-4 flex items-center"> <div ref={containerRef} class="metric-text w-full h-4 flex items-center justify-center">
<div class="relative w-full h-full overflow-hidden bg-gray-200 dark:bg-gray-600 rounded"> <div class="relative w-full max-w-[140px] h-full overflow-hidden bg-gray-200 dark:bg-gray-600 rounded">
<div class={`absolute top-0 left-0 h-full ${progressColorClass()}`} style={{ width: `${width()}%` }} /> <div class={`absolute top-0 left-0 h-full ${progressColorClass()}`} style={{ width: `${width()}%` }} />
<span class="absolute inset-0 flex items-center justify-center text-[10px] font-medium text-gray-800 dark:text-gray-100 leading-none"> <span class="absolute inset-0 flex items-center justify-center text-[10px] font-semibold text-gray-700 dark:text-gray-100 leading-none">
<span class="flex items-center gap-1 whitespace-nowrap px-0.5"> <span class="flex items-center gap-1 whitespace-nowrap px-0.5">
<span>{props.label}</span> <span>{props.label}</span>
<Show when={showSublabel()}> <Show when={showSublabel()}>
<span class="metric-sublabel font-normal"> <span class="metric-sublabel font-normal text-gray-500 dark:text-gray-300">
({props.sublabel}) ({props.sublabel})
</span> </span>
</Show> </Show>

View file

@ -122,15 +122,16 @@ interface DockerColumnDef extends ColumnConfig {
// - supplementary: Visible on large screens and up (lg: 1024px+) // - supplementary: Visible on large screens and up (lg: 1024px+)
// - detailed: Visible on extra large screens and up (xl: 1280px+) // - detailed: Visible on extra large screens and up (xl: 1280px+)
export const DOCKER_COLUMNS: DockerColumnDef[] = [ export const DOCKER_COLUMNS: DockerColumnDef[] = [
{ id: 'resource', label: 'Resource', priority: 'essential', minWidth: '100px', flex: 1.5, sortKey: 'resource' }, { id: 'resource', label: 'Resource', priority: 'essential', minWidth: '100px', flex: 1, sortKey: 'resource' },
{ id: 'type', label: 'Type', priority: 'essential', minWidth: '24px', maxWidth: '50px', sortKey: 'type' }, { id: 'type', label: 'Type', priority: 'essential', minWidth: '50px', maxWidth: '80px', sortKey: 'type' },
{ id: 'image', label: 'Image / Stack', shortLabel: 'Image', priority: 'essential', minWidth: '80px', flex: 1, sortKey: 'image' }, { id: 'image', label: 'Image / Stack', shortLabel: 'Image', priority: 'essential', minWidth: '80px', maxWidth: '250px', sortKey: 'image' },
{ id: 'status', label: 'Status', priority: 'essential', minWidth: '60px', maxWidth: '100px', sortKey: 'status' }, { id: 'status', label: 'Status', priority: 'essential', minWidth: '80px', maxWidth: '130px', sortKey: 'status' },
{ id: 'cpu', label: 'CPU', priority: 'essential', minWidth: '50px', flex: 1, sortKey: 'cpu' }, // Metric columns - fixed width to match progress bar max-width (140px + padding)
{ id: 'memory', label: 'Memory', shortLabel: 'Mem', priority: 'essential', minWidth: '50px', flex: 1, sortKey: 'memory' }, // Note: Disk column removed - Docker API rarely provides this data
{ id: 'disk', label: 'Disk', priority: 'essential', minWidth: '50px', flex: 1, sortKey: 'disk' }, { id: 'cpu', label: 'CPU', priority: 'essential', minWidth: '50px', maxWidth: '156px', sortKey: 'cpu' },
{ id: 'tasks', label: 'Tasks / Restarts', shortLabel: 'Tasks', priority: 'essential', minWidth: '40px', maxWidth: '70px', sortKey: 'tasks' }, { id: 'memory', label: 'Memory', shortLabel: 'Mem', priority: 'essential', minWidth: '50px', maxWidth: '156px', sortKey: 'memory' },
{ id: 'updated', label: 'Updated / Uptime', shortLabel: 'Updated', priority: 'essential', minWidth: '50px', maxWidth: '80px', sortKey: 'updated' }, { id: 'tasks', label: 'Tasks', shortLabel: 'Tasks', priority: 'essential', minWidth: '60px', maxWidth: '80px', sortKey: 'tasks' },
{ id: 'updated', label: 'Uptime', shortLabel: 'Uptime', priority: 'essential', minWidth: '70px', maxWidth: '90px', sortKey: 'updated' },
]; ];
// Global state for currently expanded drawer (only one drawer open at a time) // Global state for currently expanded drawer (only one drawer open at a time)
@ -2451,9 +2452,9 @@ const DockerUnifiedTable: Component<DockerUnifiedTableProps> = (props) => {
</div> </div>
</Show> </Show>
<Show when={!isResource}> <Show when={!isResource}>
<div class="flex items-center gap-1"> <div class="flex items-center gap-1 overflow-hidden">
<span class="hidden xl:inline">{col.label}</span> <span class="hidden xl:inline truncate">{col.label}</span>
<span class="xl:hidden">{col.shortLabel || col.label}</span> <span class="xl:hidden truncate">{col.shortLabel || col.label}</span>
{colSortKey && renderSortIndicator(colSortKey)} {colSortKey && renderSortIndicator(colSortKey)}
</div> </div>
</Show> </Show>

View file

@ -76,11 +76,12 @@ interface ColumnDef {
} }
const BASE_COLUMNS: ColumnDef[] = [ const BASE_COLUMNS: ColumnDef[] = [
{ id: 'name', label: 'Node', priority: 'essential', minWidth: '70px', flex: 1.5, align: 'left' }, { id: 'name', label: 'Node', priority: 'essential', minWidth: '100px', maxWidth: '300px', align: 'left' },
{ id: 'uptime', label: 'Uptime', priority: 'essential', icon: ClockIcon, minWidth: '35px', maxWidth: '70px', align: 'center' }, { id: 'uptime', label: 'Uptime', priority: 'essential', icon: ClockIcon, minWidth: '35px', flex: 1, align: 'center' },
{ id: 'cpu', label: 'CPU', priority: 'essential', icon: CpuIcon, minWidth: '40px', flex: 1.2, align: 'center' }, // Metric columns - fixed width to match progress bar max-width (140px + padding)
{ id: 'memory', label: 'Memory', priority: 'essential', icon: MemoryIcon, minWidth: '40px', flex: 1.2, align: 'center' }, { id: 'cpu', label: 'CPU', priority: 'essential', icon: CpuIcon, minWidth: '40px', maxWidth: '156px', align: 'center' },
{ id: 'disk', label: 'Disk', priority: 'essential', icon: DiskIcon, minWidth: '40px', flex: 1.2, align: 'center' }, { id: 'memory', label: 'Memory', priority: 'essential', icon: MemoryIcon, minWidth: '40px', maxWidth: '156px', align: 'center' },
{ id: 'disk', label: 'Disk', priority: 'essential', icon: DiskIcon, minWidth: '40px', maxWidth: '156px', align: 'center' },
]; ];
const TEMP_COLUMN: ColumnDef = { const TEMP_COLUMN: ColumnDef = {
@ -89,7 +90,7 @@ const TEMP_COLUMN: ColumnDef = {
priority: 'essential', priority: 'essential',
icon: TempIcon, icon: TempIcon,
minWidth: '35px', minWidth: '35px',
maxWidth: '55px', flex: 1,
align: 'center' align: 'center'
}; };
@ -150,16 +151,16 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
switch (props.currentTab) { switch (props.currentTab) {
case 'dashboard': case 'dashboard':
return [ return [
{ header: 'VMs', key: 'vmCount', icon: VMIcon, minWidth: '40px', maxWidth: '50px' }, { header: 'VMs', key: 'vmCount', icon: VMIcon, minWidth: '40px' },
{ header: 'CTs', key: 'containerCount', icon: ContainerIcon, minWidth: '40px', maxWidth: '50px' }, { header: 'CTs', key: 'containerCount', icon: ContainerIcon, minWidth: '40px' },
]; ];
case 'storage': case 'storage':
return [ return [
{ header: 'Storage', key: 'storageCount', icon: DiskIcon, minWidth: '50px', maxWidth: '70px' }, { header: 'Storage', key: 'storageCount', icon: DiskIcon, minWidth: '50px' },
{ header: 'Disks', key: 'diskCount', icon: DiskIcon, minWidth: '45px', maxWidth: '60px' }, { header: 'Disks', key: 'diskCount', icon: DiskIcon, minWidth: '45px' },
]; ];
case 'backups': case 'backups':
return [{ header: 'Backups', key: 'backupCount', icon: BackupIcon, minWidth: '50px', maxWidth: '70px' }]; return [{ header: 'Backups', key: 'backupCount', icon: BackupIcon, minWidth: '50px' }];
default: default:
return []; return [];
} }
@ -181,7 +182,7 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
cols.push(TEMP_COLUMN); cols.push(TEMP_COLUMN);
} }
// Add count columns based on tab // Add count columns based on tab - these share extra space evenly with other flex columns
countColumns().forEach((cc) => { countColumns().forEach((cc) => {
cols.push({ cols.push({
id: cc.key, id: cc.key,
@ -189,7 +190,7 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
priority: 'essential' as ColumnPriority, priority: 'essential' as ColumnPriority,
icon: cc.icon, icon: cc.icon,
minWidth: cc.minWidth || '40px', minWidth: cc.minWidth || '40px',
maxWidth: cc.maxWidth || '50px', flex: 1,
align: 'center', align: 'center',
}); });
}); });

View file

@ -126,7 +126,7 @@
@layer components { @layer components {
.pulse-shell { .pulse-shell {
width: 100%; width: 100%;
max-width: min(95vw, 2400px); max-width: min(95vw, 1400px);
margin-inline: auto; margin-inline: auto;
padding-inline: clamp(1rem, 1.5vw, 2rem); padding-inline: clamp(1rem, 1.5vw, 2rem);
transition: padding-inline 0.3s ease; transition: padding-inline 0.3s ease;