mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-19 10:33:29 +00:00
fix(frontend): keep narrow mobile data readable
This commit is contained in:
parent
9ba62be303
commit
c68be00412
22 changed files with 550 additions and 225 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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 = () =>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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]';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue