feat(frontend): show more mobile operational columns

Contract-Neutral: Phone-width responsive column presentation only; no API, ownership, route, or subsystem contract changed.
This commit is contained in:
rcourtman 2026-08-18 16:50:38 +01:00
parent 61bc4b25eb
commit 642d1ec5b0
9 changed files with 125 additions and 90 deletions

View file

@ -1,32 +1,24 @@
{
"version": 1,
"base_sha": "6ca27721ef6c00836bab7e169654692bbd1ed16e",
"verified_at": "2026-08-18T15:17:29Z",
"base_sha": "61bc4b25eb506848eee4303bbf888e71b57a1c17",
"verified_at": "2026-08-18T15:50:10Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/App.tsx",
"frontend-modern/src/AppLayout.tsx",
"frontend-modern/src/components/shared/MobileNavBar.tsx",
"frontend-modern/src/components/shared/Subtabs.tsx",
"frontend-modern/src/components/shared/SummaryRowActionButton.tsx",
"frontend-modern/src/components/shared/mobileNavBarModel.ts",
"frontend-modern/src/components/Workloads/GuestRow.tsx",
"frontend-modern/src/components/Workloads/WorkloadTableHeader.tsx",
"frontend-modern/src/components/Workloads/guestRowModel.tsx",
"frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx",
"frontend-modern/src/index.css"
"frontend-modern/src/features/proxmox/proxmoxHostTableModel.ts"
],
"content_sha256": {
"frontend-modern/src/App.tsx": "2f8ec39abd969c9abc995105eccb9513a83dca9744d8c003d5b5e8cf0a69eec5",
"frontend-modern/src/AppLayout.tsx": "9c4ffef00d4d09f76b744fdb43c961c09436b2a1620607e48721eed3a848f44e",
"frontend-modern/src/components/shared/MobileNavBar.tsx": "a39ff906f6be2f35f3c6894615811e6e0566d068d7ac5ec89f14c0cb01d40751",
"frontend-modern/src/components/shared/Subtabs.tsx": "277d29d71d710382bb72cbe80791133bd9093e7529d525319d42e52adf7668ad",
"frontend-modern/src/components/shared/SummaryRowActionButton.tsx": "527c6226dd12a7b2e37167787db50c62c7de414658a163f6c316077bfaa2a862",
"frontend-modern/src/components/shared/mobileNavBarModel.ts": "fd6614f3cb8d90776fba51c04b887f152e2e88952f149b9b5eacc1e848dccd14",
"frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx": "7b8a9a56e4b31833d18c713a692a75148d786233a7a16c2339167517f7ad78a0",
"frontend-modern/src/index.css": "6e775ca1cba645cac91b1fffc6c9680fe898d24d29398919e095870d8559f8fa"
"frontend-modern/src/components/Workloads/GuestRow.tsx": "a8cf5d799db11989dd055ad119a66ac2bee52f003c0c28009bc0f47b9fc5386e",
"frontend-modern/src/components/Workloads/WorkloadTableHeader.tsx": "7e64ac79b0fb3bf6c5784201c880ae5812a6203609b634e0ed02f7af513f3858",
"frontend-modern/src/components/Workloads/guestRowModel.tsx": "cb5165bc48fe29bf227a1fdcae3ecc11fcf93d3c761f3d283d2a5e67a95de620",
"frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx": "59651f69ad19690750fc6e2235c9ed57d6b86c016b2d753eb2f60f9b752be62e",
"frontend-modern/src/features/proxmox/proxmoxHostTableModel.ts": "bd00307cdbf654f4b7968fddd114877e313974879f43734cecb67e2b5f4a909e"
},
"routes": [
"/proxmox/overview",
"/alerts/overview",
"/actions"
"/proxmox/overview"
],
"viewports": [
{
@ -36,28 +28,20 @@
{
"width": 390,
"height": 844
},
{
"width": 320,
"height": 740
}
],
"states": [
"Desktop Alerts overview with the full navigation shell and loaded content",
"Phone Proxmox overview with 37-pixel node and workload rows",
"Phone Proxmox overview with the redundant Nodes card title removed",
"Phone Proxmox filter controls in closed and expanded states",
"Phone Proxmox node details in collapsed and expanded states",
"Phone Actions page with compact page header and 44-pixel navigation rail",
"Phone Alerts overview with compact page header"
"Narrow Proxmox overview with node and workload tables fully loaded",
"Node rows showing CPU, memory, aggregate disk, temperature, and age",
"Workload rows showing type, ID, CPU, memory, aggregate disk, and age"
],
"interactions": [
"Opened and closed the Proxmox filter controls at 390 by 844",
"Expanded and collapsed a Proxmox node detail row at 390 by 844",
"Confirmed twelve host and workload rows remained visible in a 320 by 740 viewport",
"Confirmed document width matched viewport width at 390 and 320 pixels",
"Confirmed shared compact headers and navigation remained overflow-free on Actions and Alerts",
"Confirmed the desktop mobile rail remained hidden and Alerts retained its desktop geometry at 1440 by 900",
"Confirmed final browser diagnostics contained no warnings or errors"
"Reloaded the active Proxmox overview after each responsive column change",
"Confirmed host headers render Node, CPU, Mem, Disk, Temp, and Age",
"Confirmed workload headers render Name, Type, ID, CPU, Mem, Disk, and Age",
"Confirmed multi-disk workloads use a readable aggregate percentage at phone width",
"Confirmed both tables fit the 390-pixel viewport without document-level horizontal overflow",
"Confirmed the full desktop node and workload column sets remain visible without horizontal overflow at 1440 by 900",
"Captured the final narrow mobile rendering as pulse-mobile-final-density.png"
]
}

View file

@ -103,6 +103,9 @@ export function GuestRow(props: GuestRowProps) {
const cpuPercent = createMemo(() => getWorkloadCPUPercent(props.guest.cpu) ?? 0);
const metricDisplayMode = createMemo(() => props.metricDisplayMode ?? 'bars');
const isSparklineMode = createMemo(() => metricDisplayMode() === 'sparklines');
const usesCompactTableLayout = createMemo(
() => props.workloadTableLayoutMode === 'phone' || props.workloadTableLayoutMode === 'mobile',
);
const telemetryAvailable = (
metric: keyof NonNullable<WorkloadGuest['telemetryAvailability']>,
): boolean => props.guest.telemetryAvailability?.[metric] ?? true;
@ -309,7 +312,7 @@ export function GuestRow(props: GuestRowProps) {
{/* Info - merged identifier (VMID / image / namespace) for mixed-type views */}
<Show when={isColVisible('info')}>
<td class="px-1.5 sm:px-2 py-0.5 align-middle">
<td class="px-0.5 py-0.5 align-middle sm:px-2">
<div class="flex justify-center text-xs text-muted whitespace-nowrap">
<Show
when={infoValue()}
@ -471,6 +474,7 @@ export function GuestRow(props: GuestRowProps) {
}
>
<StackedDiskBar
mode={usesCompactTableLayout() ? 'aggregate' : undefined}
disks={props.guest.disks}
aggregateDisk={props.guest.disk}
anomaly={diskAnomaly()}
@ -517,7 +521,10 @@ export function GuestRow(props: GuestRowProps) {
<span
class={`text-xs whitespace-nowrap ${props.guest.uptime > 0 && props.guest.uptime < 3600 ? 'text-orange-500' : 'text-muted'}`}
>
<Show when={isMobile()} fallback={formatUptime(props.guest.uptime)}>
<Show
when={isMobile() || usesCompactTableLayout()}
fallback={formatUptime(props.guest.uptime)}
>
{formatUptime(props.guest.uptime, true)}
</Show>
</span>

View file

@ -31,6 +31,8 @@ export const getWorkloadColumnHeaderLabel = (
): string => {
if (compact && columnId === 'availability') return 'Up';
if (compact && columnId === 'memory') return 'Mem';
if (compact && columnId === 'info') return 'ID';
if (compact && columnId === 'uptime') return 'Age';
return columnId === 'memory' && memoryDisplayBasis === 'host'
? `${defaultLabel} · Host`
: defaultLabel;
@ -67,12 +69,16 @@ export function WorkloadTableHeader(props: WorkloadTableHeaderProps) {
const sortKeyForCol = col.sortKey as WorkloadSortKey | undefined;
const isSortable = !!sortKeyForCol;
const isSorted = () => sortKeyForCol && props.sortKey() === sortKeyForCol;
const usesCompactHeader = () =>
props.isMobile() ||
props.workloadTableLayoutMode() === 'phone' ||
props.workloadTableLayoutMode() === 'mobile';
const label = () =>
getWorkloadColumnHeaderLabel(
col.id,
col.label,
props.workloadMemoryDisplayBasis(),
props.isMobile(),
usesCompactHeader(),
);
return (
@ -97,7 +103,7 @@ export function WorkloadTableHeader(props: WorkloadTableHeaderProps) {
}
>
<div class={`flex min-h-[14px] items-center gap-0.5 ${alignClasses().flexJustify}`}>
{col.icon ? (
{col.icon && !(usesCompactHeader() && col.id === 'uptime') ? (
<>
<span class="flex items-center" aria-hidden="true">
{col.icon}

View file

@ -1091,14 +1091,14 @@ describe('GUEST_COLUMNS', () => {
it('derives mobile overrides from the canonical guest column model', () => {
expect(getGuestColumnStyle('name', true)).toEqual({
width: '62%',
'max-width': '62%',
width: '33%',
'max-width': '33%',
});
expect(getGuestColumnStyle('cpu', true)).toEqual({
width: '15%',
'max-width': '15%',
width: '11%',
'max-width': '11%',
});
expect(getGuestColumnWidthStyle('name', true)).toEqual({ width: '62%' });
expect(getGuestColumnWidthStyle('name', true)).toEqual({ width: '33%' });
expect(getGuestColumnWidthStyle('diskIo', true)).toEqual({ width: '170px' });
});
@ -1112,15 +1112,22 @@ describe('GUEST_COLUMNS', () => {
expect(phoneColumns.map((column) => column.id)).toEqual([
'name',
'availability',
'type',
'info',
'cpu',
'memory',
'disk',
'uptime',
]);
expect(mobileColumns.map((column) => column.id)).toEqual([
'name',
'availability',
'type',
'info',
'cpu',
'memory',
'disk',
'uptime',
]);
expect(tabletColumns.map((column) => column.id)).toEqual([
'name',
@ -1130,6 +1137,7 @@ describe('GUEST_COLUMNS', () => {
'cpu',
'memory',
'disk',
'uptime',
]);
expect(compactColumns.map((column) => column.id)).toEqual([
'name',

View file

@ -18,5 +18,7 @@ describe('getWorkloadColumnHeaderLabel', () => {
it('uses compact labels for narrow operational columns', () => {
expect(getWorkloadColumnHeaderLabel('availability', 'Avail', 'guest', true)).toBe('Up');
expect(getWorkloadColumnHeaderLabel('memory', 'Mem', 'host', true)).toBe('Mem');
expect(getWorkloadColumnHeaderLabel('info', 'Info', 'guest', true)).toBe('ID');
expect(getWorkloadColumnHeaderLabel('uptime', 'Uptime', 'guest', true)).toBe('Age');
});
});

View file

@ -42,15 +42,14 @@ const WORKLOAD_COLUMN_MIN_LAYOUT: Record<string, WorkloadTableLayoutMode> = {
runtime: 'tablet',
cpu: 'phone',
memory: 'phone',
// A phone-width row needs enough identity space to distinguish similarly
// named workloads. Disk remains available in the row details and returns in
// the wider mobile layout; keeping it beside CPU and memory on phones reduced the
// name to a handful of characters once the status and backup cues rendered.
disk: 'mobile',
type: 'tablet',
info: 'tablet',
// Phone rows retain the compact operational scan: workload kind, all three
// capacity metrics, and age. The percentage model below preserves a useful
// identity track without hiding those signals behind row expansion.
disk: 'phone',
type: 'phone',
info: 'phone',
vmid: 'tablet',
uptime: 'compact',
uptime: 'phone',
backup: 'compact',
image: 'compact',
namespace: 'compact',
@ -445,20 +444,24 @@ const GUEST_COLUMN_RESPONSIVE_WEIGHTS: Record<
Record<string, number>
> = {
phone: {
// Identity is the primary mobile task. Availability stays visible because
// it can disagree with runtime health, while CPU and memory retain useful
// bar widths. Disk rejoins this set at the wider-mobile breakpoint above.
name: 62,
availability: 8,
cpu: 15,
memory: 15,
name: 33,
availability: 5,
type: 7,
info: 9,
cpu: 11,
memory: 11,
disk: 11,
uptime: 13,
},
mobile: {
name: 40,
availability: 8,
cpu: 17,
memory: 18,
disk: 17,
name: 33,
availability: 5,
type: 7,
info: 9,
cpu: 11,
memory: 11,
disk: 11,
uptime: 13,
},
tablet: {
name: 30,

View file

@ -74,12 +74,15 @@ import {
// metricDisplayMode signal so the hosts table swaps to MetricMiniSparkline
// whenever the user flips the toggle.
const formatNodeUptime = (seconds: number | undefined): { label: string; warn: boolean } => {
const formatNodeUptime = (
seconds: number | undefined,
compact = false,
): { label: string; warn: boolean } => {
if (!seconds || seconds <= 0) return { label: '—', warn: false };
// Full "26d 4h" precision matches v5 and the guest rows below; <1h keeps
// the v5 "recently restarted" highlight.
return {
label: formatPlatformTableUptimeValue(seconds, { compact: false }),
label: formatPlatformTableUptimeValue(seconds, { compact }),
warn: seconds < 3_600,
};
};
@ -260,7 +263,10 @@ export const ProxmoxNodesTable: Component<{
<PlatformSortableTableHead kind={column.kind} sort={sort} sortKey={column.id}>
{(layoutMode() === 'phone' || layoutMode() === 'mobile') && column.id === 'memory'
? 'Mem'
: column.label}
: (layoutMode() === 'phone' || layoutMode() === 'mobile') &&
column.id === 'uptime'
? 'Age'
: column.label}
</PlatformSortableTableHead>
)}
</For>
@ -312,7 +318,11 @@ export const ProxmoxNodesTable: Component<{
return 'Online';
});
const isOnline = createMemo(() => availabilityLabel() === 'Online');
const uptime = () => formatNodeUptime(isOnline() ? node.uptime : undefined);
const usesCompactMetrics = () =>
layoutMode() === 'phone' || layoutMode() === 'mobile';
const uptime = () =>
formatNodeUptime(isOnline() ? node.uptime : undefined, usesCompactMetrics());
const fullUptime = () => formatNodeUptime(isOnline() ? node.uptime : undefined).label;
const metricsKey = () => buildMetricKeyForUnifiedResource(node);
const temperature = () => node.temperature;
const alertResourceIds = () => hostOverrideIdCandidates(node);
@ -442,6 +452,7 @@ export const ProxmoxNodesTable: Component<{
? 'text-orange-600 dark:text-orange-400'
: 'text-base-content'
}`}
title={usesCompactMetrics() ? fullUptime() : undefined}
>
{uptime().label}
</TableCell>
@ -527,7 +538,11 @@ export const ProxmoxNodesTable: Component<{
fallback={
<StackedDiskBar
mode={
(node.agent?.disks?.length ?? 0) > 1 ? 'vertical-bars' : undefined
usesCompactMetrics()
? 'aggregate'
: (node.agent?.disks?.length ?? 0) > 1
? 'vertical-bars'
: undefined
}
disks={normalizeDiskArray(node.agent?.disks)}
aggregateDisk={aggregateDisk()}

View file

@ -8,25 +8,30 @@ import {
} from '../proxmoxHostTableModel';
describe('proxmoxHostTableModel', () => {
it('reserves phone space for an identifying node label and the two fastest health signals', () => {
it('shows the full operational metric track at phone width', () => {
const columns = getProxmoxHostVisibleColumnsForLayout('phone');
const ids = columns.map((column) => column.id);
expect(ids).toEqual(['node', 'cpu', 'memory']);
expect(ids).toEqual(['node', 'cpu', 'memory', 'disk', 'temp', 'uptime']);
expect(getProxmoxHostTableMinWidthClass('phone')).toBe('min-w-[0px]');
expect(getProxmoxHostColumnWidthStyle('node', 'phone', ids)).toEqual({ width: '60%' });
expect(getProxmoxHostColumnWidthStyle('node', 'phone', ids)).toEqual({ width: '32%' });
expect(getProxmoxHostColumnWidthStyle('disk', 'phone', ids)).toEqual({ width: '13%' });
expect(getProxmoxHostColumnWidthStyle('temp', 'phone', ids)).toEqual({ width: '15%' });
expect(getProxmoxHostColumnWidthStyle('uptime', 'phone', ids)).toEqual({ width: '14%' });
});
it('prioritizes live utilization columns in the mobile host table', () => {
const columns = getProxmoxHostVisibleColumnsForLayout('mobile');
const ids = columns.map((column) => column.id);
expect(ids).toEqual(['node', 'cpu', 'memory', 'disk']);
expect(ids).toEqual(['node', 'cpu', 'memory', 'disk', 'temp', 'uptime']);
expect(getProxmoxHostTableMinWidthClass('mobile')).toBe('min-w-[0px]');
expect(getProxmoxHostColumnWidthStyle('node', 'mobile', ids)).toEqual({ width: '40%' });
expect(getProxmoxHostColumnWidthStyle('cpu', 'mobile', ids)).toEqual({ width: '20%' });
expect(getProxmoxHostColumnWidthStyle('memory', 'mobile', ids)).toEqual({ width: '20%' });
expect(getProxmoxHostColumnWidthStyle('disk', 'mobile', ids)).toEqual({ width: '20%' });
expect(getProxmoxHostColumnWidthStyle('node', 'mobile', ids)).toEqual({ width: '32%' });
expect(getProxmoxHostColumnWidthStyle('cpu', 'mobile', ids)).toEqual({ width: '13%' });
expect(getProxmoxHostColumnWidthStyle('memory', 'mobile', ids)).toEqual({ width: '13%' });
expect(getProxmoxHostColumnWidthStyle('disk', 'mobile', ids)).toEqual({ width: '13%' });
expect(getProxmoxHostColumnWidthStyle('temp', 'mobile', ids)).toEqual({ width: '15%' });
expect(getProxmoxHostColumnWidthStyle('uptime', 'mobile', ids)).toEqual({ width: '14%' });
});
it('adds operational context before inventory metadata on tablet', () => {

View file

@ -25,9 +25,9 @@ const HOST_COLUMN_MIN_LAYOUT: Record<ProxmoxHostTableColumnId, WorkloadTableLayo
node: 'phone',
cpu: 'phone',
memory: 'phone',
disk: 'mobile',
temp: 'tablet',
uptime: 'tablet',
disk: 'phone',
temp: 'phone',
uptime: 'phone',
cluster: 'tablet',
vms: 'compact',
cts: 'compact',
@ -58,15 +58,20 @@ const HOST_COLUMN_RESPONSIVE_WEIGHTS: Record<
Partial<Record<ProxmoxHostTableColumnId, number>>
> = {
phone: {
node: 60,
cpu: 20,
memory: 20,
node: 32,
cpu: 13,
memory: 13,
disk: 13,
temp: 15,
uptime: 14,
},
mobile: {
node: 40,
cpu: 20,
memory: 20,
disk: 20,
node: 32,
cpu: 13,
memory: 13,
disk: 13,
temp: 15,
uptime: 14,
},
tablet: {
node: 22,