fix(frontend): keep narrow mobile data readable

This commit is contained in:
rcourtman 2026-08-18 21:01:07 +01:00
parent 9ba62be303
commit c68be00412
22 changed files with 550 additions and 225 deletions

View file

@ -394,7 +394,10 @@ alignment classes, or ad hoc report-category coercion. Platform tabs are
responsively scrollable within that shared shell. When the rail overflows, the
primitive must expose focusable, labeled previous/next controls and keep their
availability synchronized with the rail position so hidden workflows remain
discoverable without widening the document or requiring precision swipes.
discoverable without widening the document or requiring precision swipes. At
the leading edge, the first tab must begin flush with the rail instead of
reserving an empty previous-control slot; trailing space may be reserved only
while the next-control affordance is actually present.
Platform tabs are
feature-owned consumers of canonical resource payloads: Docker page model
helpers may prefer backend-authored `DockerData` stack and Podman metadata for
@ -1094,12 +1097,22 @@ not a replacement status card, CTA band, or page-local nested card.
high-value phone columns when that many meaningful fields exist, keep the
identity column at the canonical 30 percent of the available width, use the
shared compact label and column-width classes, and preserve the fuller tablet
and desktop presentation. `PlatformTableShell` marks every owned table with
the shared `platform-table` class; direct Workloads and Storage consumers and
nested provider detail tables must apply that same marker so the phone
contract cannot vary by rendering path. They must not rely on a global rule
that reveals every hidden
column: phone priority remains source-specific because state, capacity,
and desktop presentation. Below a 360-pixel content width, the shared narrow
layout must retain five high-value columns, promote identity to 40 percent,
and remove the lowest-priority context columns rather than shrinking names
and metric values into illegible fragments. `PlatformTableShell` marks every
owned table with the shared `platform-table` class; direct Workloads and
Storage consumers and nested provider detail tables must apply that same
marker so the phone contract cannot vary by rendering path. Text-only
operational rows must use `getPlatformTableRowClass` so the absence of
buttons, badges, or progress bars does not collapse them below the canonical
compact scanning rhythm. Mobile truncation may rely on a full-value row
detail only when that row actually has a keyboard- and touch-operable
disclosure; a non-expandable identity cell must otherwise expose its
complete value without requiring hover. Default data rows stay single-line
so scan density and row rhythm are not traded for automatic text wrapping.
They must not rely on a global rule that reveals
every hidden column: phone priority remains source-specific because state, capacity,
freshness, and recovery posture do not carry equal value for every resource
type.
Narrow viewports keep document-level overflow contained by `Table` and

View file

@ -571,6 +571,10 @@ change may globally weaken the Task 03 lifecycle-state idempotency invariant.
8. Render workload row identity directly from the shared canonical workload helper so row selection, hover, and fallback metadata lookup stay aligned with the same workload contract
9. Format infrastructure sensor labels through the shared `frontend-modern/src/utils/textPresentation.ts` presentation helper instead of maintaining a local title-casing implementation in `frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts`
10. Extend workload row contract and per-row hot-path derivations through `frontend-modern/src/components/Workloads/guestRowModel.tsx` and `frontend-modern/src/components/Workloads/useGuestRowState.ts`, and extend tooltip-backed row cell presentation through `frontend-modern/src/components/Workloads/GuestRowCells.tsx`, rather than rebuilding column metadata, row identity, cell tooltips, or anomaly correlation inside `frontend-modern/src/components/Workloads/GuestRow.tsx`
Ultra-narrow workload presentation belongs in the existing layout and
column model: below 360 pixels it keeps five identity/health columns with a
40-percent identity track. It must not add per-row measurement, viewport
listeners, or render-time column discovery to the Workloads hot path.
Workload runtime badges must consume the shared resource badge
presentation helper rather than carrying local neutral chip classes, so
Docker, Podman, and future container runtimes keep the same identity tones

View file

@ -453,6 +453,19 @@ and the relative weights for those columns, while dense platform table
rendering must use `getPlatformTableWeightedColumnWidthStyle` for
visible-column percentage normalization, zero-width fallback, and stable
percentage formatting instead of declaring local width-percent helpers.
Workload, Proxmox host, Docker host, and Docker container consumers must enter the shared
`narrow` layout below a 360-pixel content width: identity receives 40 percent,
five high-value identity/health columns remain, and lower-priority type, ID,
temperature, restart, or availability context returns at the ordinary phone
stage instead of making the retained values unreadable.
`ProxmoxReplicationTable` must also compose the frontend-primitives-owned
`getPlatformTableRowClass` for its text-only job rows so its five-column phone
projection retains the shared platform scanning rhythm instead of collapsing
to the intrinsic text height. That phone projection keeps the
guest identity on one line and makes every row keyboard- and touch-operable so
the complete guest identity and job context are available through an explicit
detail disclosure rather than hover-only title text. Compact status, route,
last-sync, and next-sync values must remain fully visible in the scanning row.
Optional numeric table cells follow the same split: unified-resource consumers
own which count or replica field is meaningful, whether the domain should
zero-default an absent scheduler/service/inventory count, whether a

View file

@ -1,41 +1,65 @@
{
"version": 1,
"result": "passed",
"verified_at": "2026-08-18T19:20:22Z",
"base_sha": "7879862c24a9657707f8a963f2bf70d1c50ed32c",
"verified_at": "2026-08-18T19:58:12Z",
"base_sha": "9ba62be3033d5ed326c55bac7304e47683ea3e11",
"changed_paths": [
"frontend-modern/src/features/alerts/OverviewTab.tsx",
"frontend-modern/src/features/alerts/tabs/DestinationsTab.tsx",
"frontend-modern/src/features/alerts/useAlertDestinationsTabState.ts",
"frontend-modern/src/features/alerts/useNotificationDeliveryHealth.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/components/Workloads/useWorkloadsControlsState.ts",
"frontend-modern/src/features/docker/DockerHostsTable.tsx",
"frontend-modern/src/features/docker/DockerNativeTableShared.tsx",
"frontend-modern/src/features/docker/dockerContainerTableModel.ts",
"frontend-modern/src/features/platformPage/sharedPlatformPage.tsx",
"frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx",
"frontend-modern/src/features/proxmox/ProxmoxReplicationTable.tsx",
"frontend-modern/src/features/proxmox/proxmoxHostTableModel.ts"
],
"content_sha256": {
"frontend-modern/src/features/alerts/OverviewTab.tsx": "a96d21aed9960769a7fbdf8fcdd3ebbd64890749c4afab2259cd11df97a92653",
"frontend-modern/src/features/alerts/tabs/DestinationsTab.tsx": "eb85b628ea48e3e399d8613fa0511638d62f66b4b1f0e2a12d7127901dede711",
"frontend-modern/src/features/alerts/useAlertDestinationsTabState.ts": "76046151c145a681b0bf098d50be3c7a8d5fa89d80304fc014bd8f6fb79025b3",
"frontend-modern/src/features/alerts/useNotificationDeliveryHealth.ts": "092df36398945a9436017c070adf5f5a9e0a439017bed4069ee48abcfa7117fb"
"frontend-modern/src/components/Workloads/GuestRow.tsx": "012be1639b4cd68b0a1e08f912bac125024795433e9ddc17d1c0f6e0adbdf732",
"frontend-modern/src/components/Workloads/WorkloadTableHeader.tsx": "5a6489738cf5e5422ba015a4f97450b734270ed0d8bf68a8b713760fd0d98153",
"frontend-modern/src/components/Workloads/guestRowModel.tsx": "718a01cc0567e90f08ea4b4c3d98d70fd350fe45c3e587ef233f425db2011279",
"frontend-modern/src/components/Workloads/useWorkloadsControlsState.ts": "f94bfc204f366f7fb644d4b162c0ff18192493aebbf28f843e978d7fc73ffbe9",
"frontend-modern/src/features/docker/DockerHostsTable.tsx": "b9ba1e7cce85d15c63de1191fd979fd2fb8097c506ad884fd9ca2c0fbca51348",
"frontend-modern/src/features/docker/DockerNativeTableShared.tsx": "f9f6f064c4089fc86277561431cda5ca96daff0dc0e3aef08a40d7fae7bf8903",
"frontend-modern/src/features/docker/dockerContainerTableModel.ts": "e05dda8e13765526322bde8d9c09f02e369b132e14fece0162d063c1968df80f",
"frontend-modern/src/features/platformPage/sharedPlatformPage.tsx": "0d6d76222bf096713285926373a362bda62222c550f8c5d4060d80c54e9964a8",
"frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx": "e62a369d083a581dacb1906477c4351c0bf3f92221138f8e23baa2780e31a54e",
"frontend-modern/src/features/proxmox/ProxmoxReplicationTable.tsx": "04249616c146d3f768661b09adc323b703a310af10a96e8223103838c54d1680",
"frontend-modern/src/features/proxmox/proxmoxHostTableModel.ts": "7e6e79f3bab5faba79b18fd9a8dcee8c7bb20054f8d27ed3c51754ce9c028f9c"
},
"routes": [
"/alerts/overview",
"/alerts/notifications"
],
"states": [
"healthy queue: no delivery warning on the alerts overview",
"degraded queue: warning renders above the overview stat cards, naming the dominant failure class",
"before the first health load resolves: nothing rendered, so no warning flash"
],
"interactions": [
"loaded /alerts/overview against a healthy queue and confirmed the overview stays clean",
"served a degraded notification health payload and re-entered /alerts/overview; the delivery warning appeared with its refresh control"
"/proxmox/overview",
"/proxmox/replication",
"/docker/overview"
],
"viewports": [
{
"width": 1280,
"height": 800
"width": 1440,
"height": 1000
},
{
"width": 375,
"height": 812
"width": 390,
"height": 844
},
{
"width": 320,
"height": 844
}
],
"states": [
"Platform tab rails start flush at the leading edge and reserve trailing space only while the next control is present",
"Proxmox overview shows five readable host and workload fields at 320 pixels and restores Type, ID, and Temperature at 390 pixels",
"Replication shows five single-line priority fields throughout phone widths with complete route and time values",
"Docker hosts show Host, CPU, Memory, Disk, and Uptime at 320 pixels without wrapped OS metadata",
"Desktop Proxmox and Docker tables retain their full column sets and primary navigation"
],
"interactions": [
"used the Proxmox tab rail next and previous controls at 320 pixels",
"expanded and collapsed a replication row at 320 and 390 pixels to verify touch access to the complete guest, job, and route values",
"opened Proxmox host and workload disclosures to confirm truncated identities remain touch-accessible",
"switched from Proxmox to Docker through the dedicated mobile platform switcher",
"verified the Docker host and container rows retain keyboard- and touch-operable detail disclosures"
]
}

View file

@ -104,7 +104,13 @@ export function GuestRow(props: GuestRowProps) {
const metricDisplayMode = createMemo(() => props.metricDisplayMode ?? 'bars');
const isSparklineMode = createMemo(() => metricDisplayMode() === 'sparklines');
const usesCompactTableLayout = createMemo(
() => props.workloadTableLayoutMode === 'phone' || props.workloadTableLayoutMode === 'mobile',
() =>
props.workloadTableLayoutMode === 'narrow' ||
props.workloadTableLayoutMode === 'phone' ||
props.workloadTableLayoutMode === 'mobile',
);
const usesCondensedIdentity = createMemo(
() => props.workloadTableLayoutMode === 'narrow' || props.workloadTableLayoutMode === 'phone',
);
const telemetryAvailable = (
metric: keyof NonNullable<WorkloadGuest['telemetryAvailability']>,
@ -228,7 +234,7 @@ export function GuestRow(props: GuestRowProps) {
<ResourceNameWithWebInterfaceLink
name={props.guest.name}
url={customUrl()}
class="min-w-0"
class={`min-w-0 ${usesCondensedIdentity() ? '[&>a]:hidden' : ''}`}
nameClass="truncate text-[11px] font-medium text-base-content select-none"
/>
{/* Show backup indicator in name cell only if backup column is hidden */}

View file

@ -71,6 +71,7 @@ export function WorkloadTableHeader(props: WorkloadTableHeaderProps) {
const isSorted = () => sortKeyForCol && props.sortKey() === sortKeyForCol;
const usesCompactHeader = () =>
props.isMobile() ||
props.workloadTableLayoutMode() === 'narrow' ||
props.workloadTableLayoutMode() === 'phone' ||
props.workloadTableLayoutMode() === 'mobile';
const label = () =>

View file

@ -926,6 +926,22 @@ describe('GuestRow', () => {
expect(container.querySelector('td[data-workload-col="link"]')).toBeNull();
});
it.each(['narrow', 'phone'] as const)(
'prioritizes the workload name over the adjacent web link in %s rows',
(workloadTableLayoutMode) => {
const { container } = renderGuestRow({
guest: makeGuest(),
customUrl: 'https://example.com',
visibleColumnIds: ['name'],
workloadTableLayoutMode,
});
expect(container.querySelector('a[href="https://example.com"]')?.parentElement).toHaveClass(
'[&>a]:hidden',
);
},
);
it('does not render a trailing infrastructure fallback link when no customUrl is set', () => {
const { container } = renderGuestRow({
guest: makeGuest(),
@ -1104,11 +1120,35 @@ describe('GUEST_COLUMNS', () => {
it('derives normalized tablet and compact widths from the visible workload columns', () => {
const allModeColumns = GUEST_COLUMNS.filter((column) => VIEW_MODE_COLUMNS.all!.has(column.id));
const narrowColumns = getWorkloadVisibleColumnsForLayout(allModeColumns, 'narrow');
const phoneColumns = getWorkloadVisibleColumnsForLayout(allModeColumns, 'phone');
const mobileColumns = getWorkloadVisibleColumnsForLayout(allModeColumns, 'mobile');
const tabletColumns = getWorkloadVisibleColumnsForLayout(allModeColumns, 'tablet');
const compactColumns = getWorkloadVisibleColumnsForLayout(allModeColumns, 'compact');
expect(narrowColumns.map((column) => column.id)).toEqual([
'name',
'cpu',
'memory',
'disk',
'uptime',
]);
expect(
getGuestColumnWidthStyle(
'name',
true,
'narrow',
narrowColumns.map((column) => column.id),
),
).toEqual({ width: '40%' });
expect(
getGuestColumnWidthStyle(
'cpu',
true,
'narrow',
narrowColumns.map((column) => column.id),
),
).toEqual({ width: '16%' });
expect(phoneColumns.map((column) => column.id)).toEqual([
'name',
'availability',
@ -1209,6 +1249,8 @@ describe('GUEST_COLUMNS', () => {
});
it('maps workload table layout modes to viewport width stages', () => {
expect(getWorkloadTableLayoutMode(359)).toBe('narrow');
expect(getWorkloadTableLayoutMode(360)).toBe('phone');
expect(getWorkloadTableLayoutMode(479)).toBe('phone');
expect(getWorkloadTableLayoutMode(480)).toBe('mobile');
expect(getWorkloadTableLayoutMode(767)).toBe('mobile');
@ -1223,6 +1265,8 @@ describe('GUEST_COLUMNS', () => {
});
it('maps workload table layout modes to the actual table container', () => {
expect(getWorkloadTableLayoutModeForContainer(359)).toBe('narrow');
expect(getWorkloadTableLayoutModeForContainer(360)).toBe('phone');
expect(getWorkloadTableLayoutModeForContainer(439)).toBe('phone');
expect(getWorkloadTableLayoutModeForContainer(440)).toBe('mobile');
expect(getWorkloadTableLayoutModeForContainer(719)).toBe('mobile');

View file

@ -3,6 +3,7 @@ import type { JSX } from 'solid-js';
import type { ColumnDef } from '@/hooks/useColumnVisibility';
import type { SummaryGroupMemberInteractionState } from '@/components/shared/summaryCardInteraction';
import {
PLATFORM_TABLE_NARROW_IDENTITY_WIDTH_PERCENT,
getPlatformTableWeightedColumnWidthStyle,
PLATFORM_TABLE_PHONE_IDENTITY_WIDTH_PERCENT,
} from '@/features/platformPage/sharedPlatformPage';
@ -15,8 +16,9 @@ import type {
import type { NestedWorkloadContext } from './nestedWorkloadContext';
import type { WorkloadsMemoryDisplayBasis } from './workloadsFilterModel';
export type WorkloadTableLayoutMode = 'phone' | 'mobile' | 'tablet' | 'compact' | 'wide';
export type WorkloadTableLayoutMode = 'narrow' | 'phone' | 'mobile' | 'tablet' | 'compact' | 'wide';
export const WORKLOAD_TABLE_NARROW_LAYOUT_WIDTH = 360;
export const WORKLOAD_TABLE_PHONE_LAYOUT_WIDTH = 480;
export const WORKLOAD_TABLE_MOBILE_LAYOUT_WIDTH = 768;
export const WORKLOAD_TABLE_TABLET_LAYOUT_WIDTH = 900;
@ -27,33 +29,35 @@ export const WORKLOAD_TABLE_TABLET_LAYOUT_WIDTH = 900;
// relying on horizontal scroll, so wide waits until the shell can actually
// hold the full set.
export const WORKLOAD_TABLE_WIDE_LAYOUT_WIDTH = 1536;
export const WORKLOAD_TABLE_CONTAINER_NARROW_WIDTH = 360;
export const WORKLOAD_TABLE_CONTAINER_PHONE_WIDTH = 440;
export const WORKLOAD_TABLE_CONTAINER_MOBILE_WIDTH = 720;
export const WORKLOAD_TABLE_CONTAINER_TABLET_WIDTH = 900;
export const WORKLOAD_TABLE_CONTAINER_WIDE_WIDTH = 1440;
const WORKLOAD_TABLE_LAYOUT_ORDER: Record<WorkloadTableLayoutMode, number> = {
phone: 0,
mobile: 1,
tablet: 2,
compact: 3,
wide: 4,
narrow: 0,
phone: 1,
mobile: 2,
tablet: 3,
compact: 4,
wide: 5,
};
const WORKLOAD_COLUMN_MIN_LAYOUT: Record<string, WorkloadTableLayoutMode> = {
name: 'phone',
name: 'narrow',
availability: 'phone',
runtime: 'tablet',
cpu: 'phone',
memory: 'phone',
cpu: 'narrow',
memory: 'narrow',
// 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',
disk: 'narrow',
type: 'phone',
info: 'phone',
vmid: 'tablet',
uptime: 'phone',
uptime: 'narrow',
backup: 'compact',
image: 'compact',
namespace: 'compact',
@ -445,6 +449,13 @@ const GUEST_COLUMN_RESPONSIVE_WEIGHTS: Record<
Exclude<WorkloadTableLayoutMode, 'wide'>,
Record<string, number>
> = {
narrow: {
name: 40,
cpu: 16,
memory: 16,
disk: 16,
uptime: 12,
},
phone: {
name: 33,
availability: 5,
@ -509,9 +520,11 @@ const getResponsiveColumnOverride = (
columnId,
weights,
activeIds,
layoutMode === 'phone' || layoutMode === 'mobile'
? { columnId: 'name', widthPercent: PLATFORM_TABLE_PHONE_IDENTITY_WIDTH_PERCENT }
: undefined,
layoutMode === 'narrow'
? { columnId: 'name', widthPercent: PLATFORM_TABLE_NARROW_IDENTITY_WIDTH_PERCENT }
: layoutMode === 'phone' || layoutMode === 'mobile'
? { columnId: 'name', widthPercent: PLATFORM_TABLE_PHONE_IDENTITY_WIDTH_PERCENT }
: undefined,
);
return typeof style.width === 'string' ? percentageColumn(style.width) : undefined;
};
@ -571,6 +584,7 @@ export const getGuestColumnWidthStyle = (
};
export const getWorkloadTableLayoutMode = (width: number): WorkloadTableLayoutMode => {
if (!Number.isFinite(width) || width < WORKLOAD_TABLE_NARROW_LAYOUT_WIDTH) return 'narrow';
if (!Number.isFinite(width) || width < WORKLOAD_TABLE_PHONE_LAYOUT_WIDTH) return 'phone';
if (width < WORKLOAD_TABLE_MOBILE_LAYOUT_WIDTH) return 'mobile';
if (width < WORKLOAD_TABLE_TABLET_LAYOUT_WIDTH) return 'tablet';
@ -579,6 +593,7 @@ export const getWorkloadTableLayoutMode = (width: number): WorkloadTableLayoutMo
};
export const getWorkloadTableLayoutModeForContainer = (width: number): WorkloadTableLayoutMode => {
if (!Number.isFinite(width) || width < WORKLOAD_TABLE_CONTAINER_NARROW_WIDTH) return 'narrow';
if (!Number.isFinite(width) || width < WORKLOAD_TABLE_CONTAINER_PHONE_WIDTH) return 'phone';
if (width < WORKLOAD_TABLE_CONTAINER_MOBILE_WIDTH) return 'mobile';
if (width < WORKLOAD_TABLE_CONTAINER_TABLET_WIDTH) return 'tablet';

View file

@ -96,7 +96,9 @@ export function useWorkloadsControlsState(options: WorkloadsControlsStateOptions
? getWorkloadTableLayoutModeForContainer(measuredWidth)
: getWorkloadTableLayoutMode(breakpoint.width());
});
const isMobile = createMemo(() => workloadTableLayoutMode() === 'mobile');
const isMobile = createMemo(() =>
['narrow', 'phone', 'mobile'].includes(workloadTableLayoutMode()),
);
const [isSearchLocked, setIsSearchLocked] = createSignal(false);
const updateSearchParam = (mutate: (params: URLSearchParams) => void): void => {

View file

@ -7276,6 +7276,18 @@ describe('shared primitive guardrails', () => {
expect(sharedPlatformPageSource).toContain('props.tabs.length > 1');
expect(sharedPlatformPageSource).toContain('href={tab.path}');
expect(sharedPlatformPageSource).toContain('border-b-2');
expect(sharedPlatformPageSource).toContain('overflow-x-auto pl-0');
expect(sharedPlatformPageSource).toContain("canScrollRight() ? 'pr-10' : 'pr-0'");
expect(sharedPlatformPageSource).not.toContain('overflow-x-auto px-10');
expect(sharedPlatformPageSource).toContain('export const getPlatformTableRowClass');
expect(sharedPlatformPageSource).toContain(
'export const PLATFORM_TABLE_NARROW_IDENTITY_WIDTH_PERCENT = 40',
);
expect(proxmoxNodesTableSource).toContain("usesCondensedIdentity() ? '[&>a]:hidden' : ''");
expect(proxmoxNodesTableSource).toContain(
"layoutMode() === 'narrow' && nativeNodeName().length",
);
expect(proxmoxReplicationTableSource).toContain('getPlatformTableRowClass()');
expect(horizontalRailVisibilityModelSource).toContain(
'export function getHorizontalRailScrollLeft',
);

View file

@ -246,7 +246,7 @@ export const DockerHostsTable: Component<{
kind="name"
sort={sort}
sortKey="host"
class="platform-table-mobile-w-30 w-[34%] md:w-[13%]"
class="w-[40%] min-[360px]:w-[34%] md:w-[13%]"
>
Host
</PlatformSortableTableHead>
@ -278,7 +278,7 @@ export const DockerHostsTable: Component<{
kind="metric-bar"
sort={sort}
sortKey="cpu"
class="w-[14%] md:w-[14%]"
class="w-[15%] min-[360px]:w-[13%] md:w-[14%]"
>
CPU
</PlatformSortableTableHead>
@ -286,7 +286,7 @@ export const DockerHostsTable: Component<{
kind="metric-bar"
sort={sort}
sortKey="memory"
class="w-[14%] md:w-[14%]"
class="w-[15%] min-[360px]:w-[13%] md:w-[14%]"
>
<span class="md:hidden">Mem</span>
<span class="hidden md:inline">Memory</span>
@ -295,7 +295,7 @@ export const DockerHostsTable: Component<{
kind="metric-bar"
sort={sort}
sortKey="disk"
class="w-[14%] md:w-[14%]"
class="w-[15%] min-[360px]:w-[13%] md:w-[14%]"
>
Disk
</PlatformSortableTableHead>
@ -303,7 +303,7 @@ export const DockerHostsTable: Component<{
kind="numeric-value"
sort={sort}
sortKey="uptime"
class="w-[12%] sm:hidden md:table-cell md:w-[6%]"
class="w-[15%] min-[360px]:w-[11%] sm:hidden md:table-cell md:w-[6%]"
>
<PlatformResponsiveTableLabel compact="Up" full="Uptime" />
</PlatformSortableTableHead>
@ -311,7 +311,7 @@ export const DockerHostsTable: Component<{
kind="numeric-value"
sort={sort}
sortKey="temp"
class="w-[16%] sm:hidden md:table-cell md:w-[6%]"
class="hidden min-[360px]:table-cell min-[360px]:w-[16%] md:table-cell md:w-[6%]"
>
<PlatformResponsiveTableLabel compact="Temp" full="Temp" />
</PlatformSortableTableHead>
@ -397,14 +397,13 @@ export const DockerHostsTable: Component<{
onKeyDown={handleActivationKey}
tabIndex={0}
>
<TableCell
class={`${getPlatformTableCellClassForKind('name')} w-[40%] md:w-auto`}
>
<TableCell class={getPlatformTableCellClassForKind('name')}>
<div class="flex min-w-0 items-center gap-2">
<PlatformResourceDetailToggleButton
expanded={isSelected()}
resourceLabel={name()}
controlsId={detailRowId()}
class="max-[359px]:hidden"
onToggle={toggleDrawer}
/>
<StatusDot
@ -416,19 +415,10 @@ export const DockerHostsTable: Component<{
<ResourceNameWithWebInterfaceLink
name={name()}
url={customUrl()}
class="min-w-0 max-[359px]:[&>a]:hidden"
nameClass="truncate font-semibold text-base-content"
/>
</div>
<Show when={systemBadge()}>
{(badge) => (
<span
class="mt-0.5 block truncate pl-5 text-[9px] text-muted sm:text-[10px] md:hidden"
title={badge().title ?? badge().label}
>
{badge().label}
</span>
)}
</Show>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content md:table-cell`}
@ -454,9 +444,7 @@ export const DockerHostsTable: Component<{
>
{containerCount()}
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('metric-bar')} w-[20%] md:w-auto`}
>
<TableCell class={getPlatformTableCellClassForKind('metric-bar')}>
<ResponsiveMetricCell
class="w-full"
value={cpuPercent() ?? 0}
@ -467,9 +455,7 @@ export const DockerHostsTable: Component<{
thresholds={cpuThresholds()}
/>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('metric-bar')} w-[20%] md:w-auto`}
>
<TableCell class={getPlatformTableCellClassForKind('metric-bar')}>
<Show
when={canRenderMetrics() && hasMemoryMetric()}
fallback={<PlatformTableMetricFallback />}
@ -483,9 +469,7 @@ export const DockerHostsTable: Component<{
/>
</Show>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('metric-bar')} w-[20%] md:w-auto`}
>
<TableCell class={getPlatformTableCellClassForKind('metric-bar')}>
<Show
when={canRenderMetrics() && hasDiskMetric()}
fallback={<PlatformTableMetricFallback />}
@ -499,12 +483,12 @@ export const DockerHostsTable: Component<{
</Show>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} sm:hidden text-base-content md:table-cell`}
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content sm:hidden md:table-cell`}
>
{formatPlatformTableUptimeValue(host.uptime ?? docker()?.uptimeSeconds)}
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} sm:hidden text-base-content md:table-cell`}
class={`${getPlatformTableCellClassForKind('numeric-value')} hidden text-base-content min-[360px]:table-cell md:table-cell`}
>
<PlatformTableTemperatureValue
value={host.temperature ?? docker()?.temperature}

View file

@ -87,7 +87,7 @@ export const DockerResourceNameCell: Component<{
<ResourceNameWithWebInterfaceLink
name={name()}
url={props.resource.customUrl}
class="min-w-0"
class="min-w-0 max-[359px]:[&>a]:hidden"
nameClass="truncate font-semibold text-base-content"
/>
</div>

View file

@ -5,6 +5,7 @@ import type { Disk } from '@/types/api';
import type { Resource } from '@/types/resource';
import type { MetricDisplayThresholds } from '@/utils/metricThresholds';
import { DockerHostsTable } from '../DockerHostsTable';
import dockerHostsTableSource from '../DockerHostsTable.tsx?raw';
const formatThresholds = (thresholds?: MetricDisplayThresholds | null): string =>
thresholds ? `${thresholds.warning}/${thresholds.critical}` : '';
@ -127,6 +128,13 @@ afterEach(() => {
});
describe('DockerHostsTable', () => {
it('keeps five readable host fields below 360 pixels without wrapped metadata rows', () => {
expect(dockerHostsTableSource).toContain('w-[40%] min-[360px]:w-[34%]');
expect(dockerHostsTableSource).toContain('hidden min-[360px]:table-cell min-[360px]:w-[16%]');
expect(dockerHostsTableSource).toContain('class="max-[359px]:hidden"');
expect(dockerHostsTableSource).not.toContain('md:hidden" title={badge().title');
});
it('renders Docker hosts with a single-line Version column and shared metric bars', () => {
render(() => (
<DockerHostsTable

View file

@ -7,6 +7,17 @@ import {
} from '../dockerContainerTableModel';
describe('dockerContainerTableModel', () => {
it('keeps five readable container signals in ultra-narrow containers', () => {
const columns = getDockerContainerVisibleColumnsForLayout('narrow', true, true, true);
const ids = columns.map((column) => column.id);
expect(ids).toEqual(['container', 'state', 'cpu', 'memory', 'updates']);
expect(getDockerContainerColumnWidthStyle('container', 'narrow', ids)).toEqual({
width: '40%',
});
expect(getDockerContainerColumnWidthStyle('cpu', 'narrow', ids)).toEqual({ width: '15%' });
});
it('keeps phone rows focused on container identity and live health signals', () => {
const columns = getDockerContainerVisibleColumnsForLayout('phone', true, true, true);
const ids = columns.map((column) => column.id);

View file

@ -4,6 +4,7 @@ import type { WorkloadTableLayoutMode } from '@/components/Workloads/guestRowMod
import type { PlatformTableColumnKind } from '@/features/platformPage/columnAlignment';
import {
getPlatformTableWeightedColumnWidthStyle,
PLATFORM_TABLE_NARROW_IDENTITY_WIDTH_PERCENT,
PLATFORM_TABLE_PHONE_IDENTITY_WIDTH_PERCENT,
} from '@/features/platformPage/sharedPlatformPage';
@ -53,23 +54,24 @@ export const getDockerContainerSortKey = (
: undefined;
const DOCKER_CONTAINER_TABLE_LAYOUT_ORDER: Record<WorkloadTableLayoutMode, number> = {
phone: 0,
mobile: 1,
tablet: 2,
compact: 3,
wide: 4,
narrow: 0,
phone: 1,
mobile: 2,
tablet: 3,
compact: 4,
wide: 5,
};
const DOCKER_CONTAINER_COLUMN_MIN_LAYOUT: Record<
DockerContainerTableColumnId,
WorkloadTableLayoutMode
> = {
container: 'phone',
state: 'phone',
cpu: 'phone',
memory: 'phone',
container: 'narrow',
state: 'narrow',
cpu: 'narrow',
memory: 'narrow',
restarts: 'phone',
updates: 'phone',
updates: 'narrow',
actions: 'mobile',
host: 'tablet',
image: 'compact',
@ -115,6 +117,13 @@ const DOCKER_CONTAINER_RESPONSIVE_WIDTHS: Record<
Exclude<WorkloadTableLayoutMode, 'wide'>,
Partial<Record<DockerContainerTableColumnId, number>>
> = {
narrow: {
container: 40,
state: 15,
cpu: 15,
memory: 17,
updates: 13,
},
phone: {
container: 32,
state: 13,
@ -188,9 +197,11 @@ export const getDockerContainerColumnWidthStyle = (
columnId,
weights,
visibleColumnIds,
layoutMode === 'phone' || layoutMode === 'mobile'
? { columnId: 'container', widthPercent: PLATFORM_TABLE_PHONE_IDENTITY_WIDTH_PERCENT }
: undefined,
layoutMode === 'narrow'
? { columnId: 'container', widthPercent: PLATFORM_TABLE_NARROW_IDENTITY_WIDTH_PERCENT }
: layoutMode === 'phone' || layoutMode === 'mobile'
? { columnId: 'container', widthPercent: PLATFORM_TABLE_PHONE_IDENTITY_WIDTH_PERCENT }
: undefined,
);
};

View file

@ -108,6 +108,9 @@ describe('PlatformSectionTabs', () => {
const navigation = screen.getByRole('navigation', { name: 'TrueNAS sections' });
expect(navigation).toHaveClass('overflow-x-auto');
expect(navigation).toHaveClass('scrollbar-hide');
expect(navigation).toHaveClass('pl-0');
expect(navigation).toHaveClass('pr-0');
expect(navigation).not.toHaveClass('px-10');
expect(navigation).not.toHaveClass('flex-wrap');
expect(within(navigation).getByRole('link', { name: 'Overview' })).toHaveAttribute(
'href',
@ -155,6 +158,8 @@ describe('PlatformSectionTabs', () => {
const scrollRight = await screen.findByRole('button', {
name: 'Example sections: scroll right',
});
expect(navigation).toHaveClass('pl-0');
expect(navigation).toHaveClass('pr-10');
expect(
screen.queryByRole('button', { name: 'Example sections: scroll left' }),
).not.toBeInTheDocument();
@ -167,6 +172,7 @@ describe('PlatformSectionTabs', () => {
expect(
await screen.findByRole('button', { name: 'Example sections: scroll left' }),
).toBeInTheDocument();
expect(navigation).toHaveClass('pl-0');
});
it('keeps the active destination visible when the tab rail narrows', async () => {

View file

@ -139,7 +139,9 @@ export function PlatformSectionTabs<TabId extends string>(props: {
ref={(element) => {
tabListRef = element;
}}
class="flex min-w-0 scroll-px-10 items-center gap-1 overflow-x-auto px-10 scrollbar-hide sm:scroll-px-0 sm:px-0"
class={`flex min-w-0 items-center gap-1 overflow-x-auto pl-0 scrollbar-hide sm:pr-0 ${
canScrollRight() ? 'pr-10' : 'pr-0'
}`}
aria-label={props.ariaLabel}
>
<For each={props.tabs}>
@ -239,6 +241,7 @@ export const PLATFORM_TABLE_HEADER_ROW_CLASS = 'bg-surface-alt text-muted border
export const PLATFORM_TABLE_BODY_CLASS = 'divide-y divide-border';
export const PLATFORM_TABLE_CLASS = 'platform-table';
export const PLATFORM_TABLE_PHONE_IDENTITY_WIDTH_PERCENT = 30;
export const PLATFORM_TABLE_NARROW_IDENTITY_WIDTH_PERCENT = 40;
// Responsive platform tables already decide which columns remain useful at
// each breakpoint. A shared 48rem floor defeated that work by forcing a
// horizontal rail even when the visible columns could fit the real container.
@ -323,6 +326,10 @@ export const getPlatformTableHeadClassForKind = (kind: PlatformTableColumnKind):
export const getPlatformTableCellClassForKind = (kind: PlatformTableColumnKind): string =>
getPlatformTableCellClass(getPlatformColumnAlign(kind));
// Text-only rows otherwise collapse below the scanning rhythm established by
// platform rows with buttons, badges, or progress bars.
export const getPlatformTableRowClass = (): string => 'h-8';
export function PlatformResponsiveTableLabel(props: { compact: string; full: string }) {
return (
<>

View file

@ -261,9 +261,14 @@ export const ProxmoxNodesTable: Component<{
<For each={visibleColumns()}>
{(column) => (
<PlatformSortableTableHead kind={column.kind} sort={sort} sortKey={column.id}>
{(layoutMode() === 'phone' || layoutMode() === 'mobile') && column.id === 'memory'
{(layoutMode() === 'narrow' ||
layoutMode() === 'phone' ||
layoutMode() === 'mobile') &&
column.id === 'memory'
? 'Mem'
: (layoutMode() === 'phone' || layoutMode() === 'mobile') &&
: (layoutMode() === 'narrow' ||
layoutMode() === 'phone' ||
layoutMode() === 'mobile') &&
column.id === 'uptime'
? 'Age'
: column.label}
@ -276,6 +281,10 @@ export const ProxmoxNodesTable: Component<{
{(node) => {
const name = () => asTrimmedString(node.name) || node.id;
const nativeNodeName = () => asTrimmedString(node.proxmox?.nodeName) ?? '';
const visibleNodeLabel = () =>
layoutMode() === 'narrow' && nativeNodeName().length > 0
? nativeNodeName()
: name();
const showNativeNodeName = () =>
layoutMode() === 'phone' &&
nativeNodeName().length > 0 &&
@ -319,7 +328,9 @@ export const ProxmoxNodesTable: Component<{
});
const isOnline = createMemo(() => availabilityLabel() === 'Online');
const usesCompactMetrics = () =>
layoutMode() === 'phone' || layoutMode() === 'mobile';
layoutMode() === 'narrow' || layoutMode() === 'phone' || layoutMode() === 'mobile';
const usesCondensedIdentity = () =>
layoutMode() === 'narrow' || layoutMode() === 'phone';
const uptime = () =>
formatNodeUptime(isOnline() ? node.uptime : undefined, usesCompactMetrics());
const fullUptime = () => formatNodeUptime(isOnline() ? node.uptime : undefined).label;
@ -387,7 +398,9 @@ export const ProxmoxNodesTable: Component<{
case 'node':
return (
<TableCell class={getPlatformTableCellClassForKind(column.kind)}>
<div class="flex min-w-0 items-center gap-2">
<div
class={`flex min-w-0 items-center ${usesCondensedIdentity() ? 'gap-1' : 'gap-2'}`}
>
<PlatformResourceDetailToggleButton
expanded={isSelected()}
resourceLabel={name()}
@ -404,10 +417,12 @@ export const ProxmoxNodesTable: Component<{
<ResourceNameWithWebInterfaceLink
name={name()}
url={externalUrl()}
class="min-w-0"
class={`min-w-0 ${usesCondensedIdentity() ? '[&>a]:hidden' : ''}`}
nameClass="truncate font-semibold text-base-content"
title={`Open ${name()} web interface`}
/>
>
{visibleNodeLabel()}
</ResourceNameWithWebInterfaceLink>
<Show when={showNativeNodeName()}>
<span class="-mt-0.5 truncate text-[10px] text-muted">
{nativeNodeName()}

View file

@ -9,10 +9,12 @@ import {
PlatformTableToolbar,
PlatformErrorState,
PlatformTableDurationValue,
formatPlatformTableRelativeTimeValue,
PlatformTableRelativeTimeValue,
getPlatformTableCellClassForKind,
getPlatformTableContainerLayout,
getPlatformTableHeadClassForKind,
getPlatformTableRowClass,
type PlatformTableContainerLayout,
type PlatformTableFilterOption,
PlatformTableEmptyState,
@ -43,24 +45,23 @@ type ReplicationColumn =
| 'fails'
| 'error';
export const REPLICATION_PHONE_COLUMNS: readonly ReplicationColumn[] = [
export const REPLICATION_MOBILE_COLUMNS: readonly ReplicationColumn[] = [
'guest',
'status',
'job',
'route',
'lastSync',
'nextSync',
];
export const REPLICATION_PHONE_COLUMN_WIDTHS: Readonly<Partial<Record<ReplicationColumn, number>>> =
{
status: 15,
job: 12,
guest: 30,
route: 18,
lastSync: 13,
nextSync: 12,
};
export const REPLICATION_MOBILE_COLUMN_WIDTHS: Readonly<
Partial<Record<ReplicationColumn, number>>
> = {
guest: 40,
status: 16,
route: 22,
lastSync: 9,
nextSync: 13,
};
const REPLICATION_COLUMN_WIDTH_CLASS: Record<
PlatformTableContainerLayout,
@ -195,6 +196,13 @@ function formatGuestLabel(job: ReplicationJob): string {
return '—';
}
export function formatMobileReplicationGuestLabel(job: ReplicationJob): string {
const guestId = job.guestId ?? 0;
const name = (job.guestName ?? '').trim();
if (guestId && name) return `${guestId} ${name}`;
return formatGuestLabel(job);
}
function syncTimeValue(job: ReplicationJob): number | string | undefined {
if (job.lastSyncUnix && job.lastSyncUnix > 0) {
return job.lastSyncUnix * 1000;
@ -236,6 +244,11 @@ function nextSyncFor(job: ReplicationJob): { text: string; tone: NextSyncTone }
return { text: `in ${Math.floor(minutes / 60)}h ${minutes % 60}m`, tone: 'normal' };
}
export const compactReplicationNextSyncText = (text: string, tone: NextSyncTone): string => {
if (tone === 'overdue') return `-${text.replace(/ overdue$/, '')}`;
return text.replace(/^in /, '');
};
export async function fetchReplicationJobs(): Promise<ReplicationJob[]> {
const response = await apiFetch('/api/replication/jobs?platform=proxmox-pve');
if (!response.ok) {
@ -257,6 +270,7 @@ export const ProxmoxReplicationTable: Component<{
}> = (props) => {
const [search, setSearch] = createSignal('');
const [status, setStatus] = createSignal<ReplicationStatusFilter>('all');
const [expandedJobKey, setExpandedJobKey] = createSignal<string | null>(null);
const filtered = createMemo(() => {
const term = search().trim().toLowerCase();
@ -287,12 +301,20 @@ export const ProxmoxReplicationTable: Component<{
const layout = createMemo(() =>
getPlatformTableContainerLayout(observedWidth.width() ?? 1920, [520, 720, 960, 1200]),
);
const showJob = createMemo(() => true);
const canRevealDetails = createMemo(() => layout() === 'compact');
const mobilePaddingClass = () => (canRevealDetails() ? '!px-1' : '');
const mobileHeadClass = () => (canRevealDetails() ? '!px-1 !text-[9px]' : '');
const showJob = createMemo(() => !canRevealDetails());
const showNext = createMemo(() => true);
const showOperational = createMemo(() => ['operational', 'expanded', 'full'].includes(layout()));
const showError = createMemo(() => ['expanded', 'full'].includes(layout()));
const columnWidthClass = (column: ReplicationColumn) =>
REPLICATION_COLUMN_WIDTH_CLASS[layout()][column];
canRevealDetails() ? '' : REPLICATION_COLUMN_WIDTH_CLASS[layout()][column];
const visibleColumnCount = createMemo(() => {
if (layout() === 'compact') return REPLICATION_MOBILE_COLUMNS.length;
if (layout() === 'basic') return 6;
return showError() ? 10 : 9;
});
return (
<Show
@ -356,10 +378,10 @@ export const ProxmoxReplicationTable: Component<{
colgroup={
<Show when={layout() === 'compact'}>
<colgroup>
<For each={REPLICATION_PHONE_COLUMNS}>
<For each={REPLICATION_MOBILE_COLUMNS}>
{(column) => (
<col
style={{ width: `${REPLICATION_PHONE_COLUMN_WIDTHS[column]}%` }}
style={{ width: `${REPLICATION_MOBILE_COLUMN_WIDTHS[column]}%` }}
data-proxmox-replication-column={column}
/>
)}
@ -370,24 +392,24 @@ export const ProxmoxReplicationTable: Component<{
header={
<>
<TableHead
class={`${getPlatformTableHeadClassForKind('name')} ${columnWidthClass('guest')}`}
class={`${getPlatformTableHeadClassForKind('name')} ${columnWidthClass('guest')} ${mobileHeadClass()}`}
>
Guest
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} ${columnWidthClass('status')}`}
class={`${getPlatformTableHeadClassForKind('text')} ${columnWidthClass('status')} ${mobileHeadClass()}`}
>
Status
{canRevealDetails() ? 'State' : 'Status'}
</TableHead>
<Show when={showJob()}>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} ${columnWidthClass('job')}`}
class={`${getPlatformTableHeadClassForKind('text')} ${columnWidthClass('job')} ${mobileHeadClass()}`}
>
Job
</TableHead>
</Show>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} ${columnWidthClass('route')}`}
class={`${getPlatformTableHeadClassForKind('text')} ${columnWidthClass('route')} ${mobileHeadClass()}`}
title="Source → target"
>
Route
@ -400,13 +422,13 @@ export const ProxmoxReplicationTable: Component<{
</TableHead>
</Show>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} ${columnWidthClass('lastSync')}`}
class={`${getPlatformTableHeadClassForKind('numeric-value')} ${columnWidthClass('lastSync')} ${mobileHeadClass()}`}
>
{layout() === 'compact' ? 'Last' : 'Last sync'}
</TableHead>
<Show when={showNext()}>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} ${columnWidthClass('nextSync')}`}
class={`${getPlatformTableHeadClassForKind('numeric-value')} ${columnWidthClass('nextSync')} ${mobileHeadClass()}`}
>
{layout() === 'compact' ? 'Next' : 'Next sync'}
</TableHead>
@ -435,107 +457,203 @@ export const ProxmoxReplicationTable: Component<{
body={
<>
<For each={filtered()}>
{(job) => {
{(job, index) => {
const classification = classifyJob(job);
const ind = indicatorFor(classification);
const next = nextSyncFor(job);
const sourceNode = (job.sourceNode ?? '').trim() || '—';
const targetNode = (job.targetNode ?? '').trim() || '—';
const guestLabel = formatGuestLabel(job);
const jobKey = `${job.id}:${job.jobId}:${job.guestId ?? index()}`;
const detailRowId = `replication-job-detail-${index()}`;
const isExpanded = () => canRevealDetails() && expandedJobKey() === jobKey;
const toggleDetails = () => {
if (!canRevealDetails()) return;
setExpandedJobKey(isExpanded() ? null : jobKey);
};
return (
<TableRow class="hover:bg-surface-hover">
<TableCell
class={`${getPlatformTableCellClassForKind('name')} text-base-content`}
<>
<TableRow
class={`${getPlatformTableRowClass()} ${canRevealDetails() ? 'cursor-pointer' : ''}`}
aria-controls={isExpanded() ? detailRowId : undefined}
aria-expanded={
canRevealDetails() ? (isExpanded() ? 'true' : 'false') : undefined
}
onClick={toggleDetails}
onKeyDown={(event) => {
if (
!canRevealDetails() ||
(event.key !== 'Enter' && event.key !== ' ')
) {
return;
}
event.preventDefault();
toggleDetails();
}}
tabIndex={canRevealDetails() ? 0 : undefined}
>
{formatGuestLabel(job)}
</TableCell>
<TableCell class={getPlatformTableCellClassForKind('text')}>
<div class="flex items-center gap-2">
<StatusDot
size="sm"
variant={ind.variant}
title={ind.label}
ariaHidden
/>
<span class={`text-[11px] font-medium ${ind.tone}`}>
{ind.label}
<TableCell
class={`${getPlatformTableCellClassForKind('name')} text-base-content ${mobilePaddingClass()}`}
title={guestLabel}
>
<span
class={`block truncate ${canRevealDetails() ? 'text-[10px]' : ''}`}
>
{canRevealDetails()
? formatMobileReplicationGuestLabel(job)
: guestLabel}
</span>
</div>
</TableCell>
<Show when={showJob()}>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content font-mono text-[11px]`}
>
<span title={job.id}>{job.jobId || job.id}</span>
</TableCell>
</Show>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
>
<span class="inline-flex items-center gap-1 font-mono text-[11px]">
<span>{sourceNode}</span>
<ArrowRightIcon class="h-3 w-3 text-muted" aria-hidden="true" />
<span>{targetNode}</span>
</span>
</TableCell>
<Show when={showOperational()}>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content font-mono text-[11px]`}
>
{job.schedule || '—'}
</TableCell>
</Show>
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}
>
<PlatformTableRelativeTimeValue value={syncTimeValue(job)} />
</TableCell>
<Show when={showNext()}>
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}
>
<span class={NEXT_SYNC_TONE_CLASS[next.tone]}>{next.text}</span>
</TableCell>
</Show>
<Show when={showOperational()}>
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}
>
<PlatformTableDurationValue
seconds={job.lastSyncDurationSeconds}
fallbackText={job.lastSyncDurationHuman}
/>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content tabular-nums`}
class={`${getPlatformTableCellClassForKind('text')} ${mobilePaddingClass()}`}
>
<div
class={`flex items-center ${canRevealDetails() ? 'gap-0' : 'gap-2'}`}
>
<Show when={!canRevealDetails()}>
<StatusDot
size="sm"
variant={ind.variant}
title={ind.label}
ariaHidden
/>
</Show>
<span
class={`${canRevealDetails() ? 'text-[10px]' : 'text-[11px]'} font-medium ${ind.tone}`}
>
{ind.label}
</span>
</div>
</TableCell>
<Show when={showJob()}>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content font-mono text-[11px] ${mobilePaddingClass()}`}
>
<span title={job.id}>{job.jobId || job.id}</span>
</TableCell>
</Show>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content ${mobilePaddingClass()}`}
>
<Show
when={(job.failCount ?? 0) > 0}
fallback={<span class="text-muted">0</span>}
when={canRevealDetails()}
fallback={
<span class="inline-flex items-center gap-1 font-mono text-[11px]">
<span>{sourceNode}</span>
<ArrowRightIcon
class="h-3 w-3 text-muted"
aria-hidden="true"
/>
<span>{targetNode}</span>
</span>
}
>
<span class="text-red-600 dark:text-red-300 font-semibold">
{job.failCount}
<span class="font-mono text-[10px]">
{sourceNode}→{targetNode}
</span>
</Show>
</TableCell>
</Show>
<Show when={showError()}>
<Show when={showOperational()}>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content font-mono text-[11px]`}
>
{job.schedule || '—'}
</TableCell>
</Show>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content ${mobilePaddingClass()}`}
>
<Show
when={!!job.error?.trim()}
fallback={<span class="text-muted">—</span>}
when={canRevealDetails()}
fallback={
<PlatformTableRelativeTimeValue value={syncTimeValue(job)} />
}
>
<span class="tabular-nums text-[10px]">
{formatPlatformTableRelativeTimeValue(
syncTimeValue(job),
).replace(/ ago$/, '')}
</span>
</Show>
</TableCell>
<Show when={showNext()}>
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content ${mobilePaddingClass()}`}
>
<span
class="inline-block max-w-[18rem] truncate text-red-600 dark:text-red-300"
title={job.error ?? ''}
class={`${NEXT_SYNC_TONE_CLASS[next.tone]} ${canRevealDetails() ? 'text-[10px]' : ''}`}
>
{job.error}
{canRevealDetails()
? compactReplicationNextSyncText(next.text, next.tone)
: next.text}
</span>
</Show>
</TableCell>
</TableCell>
</Show>
<Show when={showOperational()}>
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}
>
<PlatformTableDurationValue
seconds={job.lastSyncDurationSeconds}
fallbackText={job.lastSyncDurationHuman}
/>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content tabular-nums`}
>
<Show
when={(job.failCount ?? 0) > 0}
fallback={<span class="text-muted">0</span>}
>
<span class="text-red-600 dark:text-red-300 font-semibold">
{job.failCount}
</span>
</Show>
</TableCell>
</Show>
<Show when={showError()}>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
>
<Show
when={!!job.error?.trim()}
fallback={<span class="text-muted">—</span>}
>
<span
class="inline-block max-w-[18rem] truncate text-red-600 dark:text-red-300"
title={job.error ?? ''}
>
{job.error}
</span>
</Show>
</TableCell>
</Show>
</TableRow>
<Show when={isExpanded()}>
<TableRow
id={detailRowId}
class="bg-surface-alt/60 hover:bg-surface-alt/60"
>
<TableCell
colSpan={visibleColumnCount()}
class="!whitespace-normal !px-2 !py-2"
>
<dl class="grid grid-cols-[auto_minmax(0,1fr)] gap-x-2 gap-y-1 text-[10px] leading-4">
<dt class="font-semibold text-muted">Guest</dt>
<dd class="break-all text-base-content">{guestLabel}</dd>
<dt class="font-semibold text-muted">Job</dt>
<dd class="break-all font-mono text-base-content">
{job.jobId || job.id}
</dd>
<dt class="font-semibold text-muted">Route</dt>
<dd class="font-mono text-base-content">
{sourceNode} → {targetNode}
</dd>
</dl>
</TableCell>
</TableRow>
</Show>
</TableRow>
</>
);
}}
</For>

View file

@ -3,8 +3,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import type { ReplicationJob } from '@/types/api';
import {
ProxmoxReplicationTable,
REPLICATION_PHONE_COLUMNS,
REPLICATION_PHONE_COLUMN_WIDTHS,
REPLICATION_MOBILE_COLUMNS,
REPLICATION_MOBILE_COLUMN_WIDTHS,
compactReplicationNextSyncText,
formatMobileReplicationGuestLabel,
} from '../ProxmoxReplicationTable';
const replicationJob = (overrides: Partial<ReplicationJob> = {}): ReplicationJob => ({
@ -30,23 +32,24 @@ afterEach(() => {
});
describe('ProxmoxReplicationTable', () => {
it('keeps identity, route, and both sync horizons visible on phones', () => {
expect(REPLICATION_PHONE_COLUMNS).toEqual([
it('uses five readable columns and compact time direction across phone widths', () => {
expect(REPLICATION_MOBILE_COLUMNS).toEqual([
'guest',
'status',
'job',
'route',
'lastSync',
'nextSync',
]);
expect(REPLICATION_PHONE_COLUMN_WIDTHS).toEqual({
status: 15,
job: 12,
guest: 30,
route: 18,
lastSync: 13,
nextSync: 12,
expect(REPLICATION_MOBILE_COLUMN_WIDTHS).toEqual({
guest: 40,
status: 16,
route: 22,
lastSync: 9,
nextSync: 13,
});
expect(compactReplicationNextSyncText('34m overdue', 'overdue')).toBe('-34m');
expect(compactReplicationNextSyncText('in 8m', 'normal')).toBe('8m');
expect(formatMobileReplicationGuestLabel(replicationJob())).toBe('100 web');
});
it('renders replication duration cells through the shared duration format', () => {
@ -64,6 +67,7 @@ describe('ProxmoxReplicationTable', () => {
));
expect(screen.getByText('100 (web)')).toBeInTheDocument();
expect(screen.getByText('100 (web)').closest('tr')).toHaveClass('h-8');
expect(screen.getByText('2m 5s')).toBeInTheDocument();
});

View file

@ -8,6 +8,17 @@ import {
} from '../proxmoxHostTableModel';
describe('proxmoxHostTableModel', () => {
it('keeps five legible identity and health columns in ultra-narrow containers', () => {
const columns = getProxmoxHostVisibleColumnsForLayout('narrow');
const ids = columns.map((column) => column.id);
expect(ids).toEqual(['node', 'cpu', 'memory', 'disk', 'uptime']);
expect(getProxmoxHostTableMinWidthClass('narrow')).toBe('min-w-[0px]');
expect(getProxmoxHostColumnWidthStyle('node', 'narrow', ids)).toEqual({ width: '40%' });
expect(getProxmoxHostColumnWidthStyle('cpu', 'narrow', ids)).toEqual({ width: '15%' });
expect(getProxmoxHostColumnWidthStyle('uptime', 'narrow', ids)).toEqual({ width: '15%' });
});
it('shows the full operational metric track at phone width', () => {
const columns = getProxmoxHostVisibleColumnsForLayout('phone');
const ids = columns.map((column) => column.id);
@ -83,6 +94,8 @@ describe('proxmoxHostTableModel', () => {
});
it('chooses host columns from available container width', () => {
expect(getProxmoxHostTableLayoutModeForContainer(359)).toBe('narrow');
expect(getProxmoxHostTableLayoutModeForContainer(360)).toBe('phone');
expect(getProxmoxHostTableLayoutModeForContainer(439)).toBe('phone');
expect(getProxmoxHostTableLayoutModeForContainer(440)).toBe('mobile');
expect(getProxmoxHostTableLayoutModeForContainer(799)).toBe('mobile');

View file

@ -4,6 +4,7 @@ import type { WorkloadTableLayoutMode } from '@/components/Workloads/guestRowMod
import type { PlatformTableColumnKind } from '@/features/platformPage/columnAlignment';
import {
getPlatformTableWeightedColumnWidthStyle,
PLATFORM_TABLE_NARROW_IDENTITY_WIDTH_PERCENT,
PLATFORM_TABLE_PHONE_IDENTITY_WIDTH_PERCENT,
} from '@/features/platformPage/sharedPlatformPage';
@ -17,20 +18,21 @@ export type ProxmoxHostTableColumn = {
};
const HOST_TABLE_LAYOUT_ORDER: Record<WorkloadTableLayoutMode, number> = {
phone: 0,
mobile: 1,
tablet: 2,
compact: 3,
wide: 4,
narrow: 0,
phone: 1,
mobile: 2,
tablet: 3,
compact: 4,
wide: 5,
};
const HOST_COLUMN_MIN_LAYOUT: Record<ProxmoxHostTableColumnId, WorkloadTableLayoutMode> = {
node: 'phone',
cpu: 'phone',
memory: 'phone',
disk: 'phone',
node: 'narrow',
cpu: 'narrow',
memory: 'narrow',
disk: 'narrow',
temp: 'phone',
uptime: 'phone',
uptime: 'narrow',
cluster: 'tablet',
vms: 'compact',
cts: 'compact',
@ -60,6 +62,13 @@ const HOST_COLUMN_RESPONSIVE_WEIGHTS: Record<
Exclude<WorkloadTableLayoutMode, 'wide'>,
Partial<Record<ProxmoxHostTableColumnId, number>>
> = {
narrow: {
node: 40,
cpu: 15,
memory: 15,
disk: 15,
uptime: 15,
},
phone: {
node: 32,
cpu: 13,
@ -101,6 +110,7 @@ const HOST_COLUMN_RESPONSIVE_WEIGHTS: Record<
export const getProxmoxHostTableLayoutModeForContainer = (
width: number,
): WorkloadTableLayoutMode => {
if (Number.isFinite(width) && width < 360) return 'narrow';
if (Number.isFinite(width) && width < 440) return 'phone';
if (!Number.isFinite(width) || width < 800) return 'mobile';
if (width < 1040) return 'tablet';
@ -147,9 +157,11 @@ export const getProxmoxHostColumnWidthStyle = (
columnId,
weights,
visibleColumnIds,
layoutMode === 'phone' || layoutMode === 'mobile'
? { columnId: 'node', widthPercent: PLATFORM_TABLE_PHONE_IDENTITY_WIDTH_PERCENT }
: undefined,
layoutMode === 'narrow'
? { columnId: 'node', widthPercent: PLATFORM_TABLE_NARROW_IDENTITY_WIDTH_PERCENT }
: layoutMode === 'phone' || layoutMode === 'mobile'
? { columnId: 'node', widthPercent: PLATFORM_TABLE_PHONE_IDENTITY_WIDTH_PERCENT }
: undefined,
);
};
@ -160,7 +172,9 @@ export const getProxmoxHostColumnWidthStyle = (
export const getProxmoxHostTableMinWidthClass = (
layoutMode: WorkloadTableLayoutMode,
): 'min-w-[0px]' | 'min-w-[50rem]' | 'min-w-[64rem]' | 'min-w-[1240px]' => {
if (layoutMode === 'phone' || layoutMode === 'mobile') return 'min-w-[0px]';
if (layoutMode === 'narrow' || layoutMode === 'phone' || layoutMode === 'mobile') {
return 'min-w-[0px]';
}
if (layoutMode === 'tablet') return 'min-w-[50rem]';
if (layoutMode === 'compact') return 'min-w-[64rem]';
return 'min-w-[1240px]';