Fix workload table responsive layout

This commit is contained in:
rcourtman 2026-04-24 00:17:03 +01:00
parent 9dbaaa7efe
commit 113778b54c
11 changed files with 297 additions and 66 deletions

View file

@ -214,6 +214,17 @@ regression protection.
while the window still reports a desktop breakpoint. Live resize proof must
show the host and service tables dropping lower-priority columns without
introducing horizontal overflow.
Dashboard workload table responsive behavior belongs to the same hot-path
owner. `frontend-modern/src/components/Dashboard/useDashboardControlsState.ts`
must derive workload table layout stages from live viewport width, and
`frontend-modern/src/components/Dashboard/guestRowModel.tsx` must own the
responsive workload column priority and width model. `DashboardWorkloadTable`,
`WorkloadTableHeader`, and `WorkloadPanel` must consume one shared
layout-visible column set so headers, colgroups, and rows stay aligned during
live resize. Tablet and compact workload stages must normalize active column
widths against the currently visible column IDs, show higher-priority workload
information before exposing detail-heavy Net I/O and Disk I/O columns, and
avoid horizontal overflow at mobile, tablet, compact, and full desktop widths.
28. Keep summary-card hover emphasis on one bounded rendering budget: when a summary row is active, shared sparkline and density-map primitives must promote the selected series and demote background series through the same active-series ID rather than layering a second page-local highlight pass, so zoom-range and hover scrubbing stay visually coherent without reintroducing multi-series overdraw on the hot summary cards. Density maps on that hot path must stay overview-first under focus: preserve the multi-entity heatmap rows, layer focused-entity detail inside the card, and avoid swapping transient hover into a separate single-series chart path.
29. Keep public self-hosted checkout handoff endpoints on the adjacent
commercial/router boundary, not the summary-chart hot path. When
@ -292,7 +303,10 @@ regression protection.
first render may issue the existing non-blocking AI runtime settings
readiness read and consume existing action-state signals, but it must not
issue an LLM request, mount a second resource scan, or block estate/KPI
rendering just to produce prose.
rendering just to produce prose. Its Assistant handoff may add a scoped
`autonomous_mode:false` flag only when the operator submits the prefilled
prompt; that request-level safety override must not become a dashboard
render dependency or a persistent settings write on the hot path.
32. Keep infrastructure summary consumers on the compact dashboard overview rather than reopening the all-resources hook. `frontend-modern/src/hooks/useDashboardTrends.ts`, `frontend-modern/src/components/Infrastructure/useInfrastructureSummaryState.ts`, and adjacent dashboard summary consumers may derive chart identity and storage presence from the overview payload they were already given, but they must not call `useResources()` or mount a second unfiltered unified-resource fetch path inside the dashboard hot path. That rule also applies to globally mounted helpers such as `frontend-modern/src/components/AI/Chat/index.tsx`: closed assistant surfaces must read the live websocket snapshot or existing unified-resource cache rather than forcing the dashboard to pay for `all-resources` just because the shell component is mounted. When that assistant shell changes presentation, `frontend-modern/src/utils/aiChatPresentation.ts` must remain the canonical owner for launcher, drawer, session-menu, and empty-state copy so hot-path consumers do not grow one-off inline strings or extra state branches alongside the mounted shell. Blocking shared dialogs must also suppress closed assistant affordances through the shared dialog runtime instead of leaving the mounted shell clickable behind another overlay.
Approval presentation inside that mounted assistant shell must stay
state-local to the existing drawer/session state and backend approval

View file

@ -134,8 +134,6 @@ export function Dashboard(props: DashboardProps) {
focusedSummaryWorkloadGroupId={state.focusedSummaryWorkloadGroupId}
hoveredSummaryWorkloadGroupScope={state.hoveredSummaryWorkloadGroupScope}
isMobile={state.isMobile}
mobileVisibleColumnIds={state.mobileVisibleColumnIds}
mobileVisibleColumns={state.mobileVisibleColumns}
nodeByInstance={state.nodeByInstance}
search={state.search}
selectedGuestId={state.selectedGuestId}
@ -154,6 +152,9 @@ export function Dashboard(props: DashboardProps) {
visibleGroupKeys={state.visibleGroupKeys}
windowedGroupedGuests={state.windowedGroupedGuests}
workloadIOEmphasis={state.workloadIOEmphasis}
workloadTableLayoutMode={state.workloadTableLayoutMode}
workloadTableVisibleColumnIds={state.workloadTableVisibleColumnIds}
workloadTableVisibleColumns={state.workloadTableVisibleColumns}
/>
</Show>
</div>

View file

@ -31,8 +31,6 @@ type DashboardWorkloadTableProps = Pick<
| 'focusedSummaryWorkloadGroupId'
| 'hoveredSummaryWorkloadGroupScope'
| 'isMobile'
| 'mobileVisibleColumnIds'
| 'mobileVisibleColumns'
| 'nodeByInstance'
| 'search'
| 'selectedGuestId'
@ -51,6 +49,9 @@ type DashboardWorkloadTableProps = Pick<
| 'visibleGroupKeys'
| 'windowedGroupedGuests'
| 'workloadIOEmphasis'
| 'workloadTableLayoutMode'
| 'workloadTableVisibleColumnIds'
| 'workloadTableVisibleColumns'
>;
export function DashboardWorkloadTable(props: DashboardWorkloadTableProps) {
@ -77,11 +78,16 @@ export function DashboardWorkloadTable(props: DashboardWorkloadTableProps) {
class={`workload-table min-w-full table-fixed ${props.isMobile() ? 'workload-table--mobile' : 'workload-table--desktop'}`}
>
<colgroup>
<For each={props.mobileVisibleColumns()}>
<For each={props.workloadTableVisibleColumns()}>
{(column) => (
<col
data-workload-col={column.id}
style={getGuestColumnWidthStyle(column.id, props.isMobile())}
style={getGuestColumnWidthStyle(
column.id,
props.isMobile(),
props.workloadTableLayoutMode(),
props.workloadTableVisibleColumnIds(),
)}
/>
)}
</For>
@ -89,10 +95,12 @@ export function DashboardWorkloadTable(props: DashboardWorkloadTableProps) {
<WorkloadTableHeader
handleSort={props.handleSort}
isMobile={props.isMobile}
mobileVisibleColumns={props.mobileVisibleColumns}
sortDirection={props.sortDirection}
sortKey={props.sortKey}
visibleColumns={props.visibleColumns}
workloadTableLayoutMode={props.workloadTableLayoutMode}
workloadTableVisibleColumnIds={props.workloadTableVisibleColumnIds}
workloadTableVisibleColumns={props.workloadTableVisibleColumns}
/>
<WorkloadPanel
activeAlerts={props.activeAlerts}
@ -111,7 +119,6 @@ export function DashboardWorkloadTable(props: DashboardWorkloadTableProps) {
focusedSummaryWorkloadGroupScope={props.focusedSummaryWorkloadGroupScope}
focusedSummaryWorkloadGroupId={props.focusedSummaryWorkloadGroupId}
hoveredSummaryWorkloadGroupScope={props.hoveredSummaryWorkloadGroupScope}
mobileVisibleColumnIds={props.mobileVisibleColumnIds}
nodeByInstance={props.nodeByInstance}
search={props.search}
selectedGuestId={props.selectedGuestId}
@ -125,6 +132,8 @@ export function DashboardWorkloadTable(props: DashboardWorkloadTableProps) {
visibleGroupKeys={props.visibleGroupKeys}
windowedGroupedGuests={props.windowedGroupedGuests}
workloadIOEmphasis={props.workloadIOEmphasis}
workloadTableLayoutMode={props.workloadTableLayoutMode}
workloadTableVisibleColumnIds={props.workloadTableVisibleColumnIds}
/>
</Table>
</Card>

View file

@ -125,7 +125,12 @@ export function GuestRow(props: GuestRowProps) {
<td
class={`pr-1.5 sm:pr-2 py-0.5 align-middle whitespace-nowrap ${firstCellIndent()}`}
data-workload-col="name"
style={getGuestColumnStyle('name', isMobile())}
style={getGuestColumnStyle(
'name',
isMobile(),
props.workloadTableLayoutMode,
props.visibleColumnIds,
)}
>
<div class="flex items-center gap-2 min-w-0">
<Show when={props.onClick}>
@ -338,7 +343,7 @@ export function GuestRow(props: GuestRowProps) {
</span>
<Show when={appContainerRuntimeLabel()}>
<span
class="rounded bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300 px-1 py-0.5 text-[9px] font-medium uppercase tracking-wide whitespace-nowrap"
class="rounded border border-border bg-surface-alt px-1 py-0.5 text-[9px] font-medium uppercase tracking-wide text-muted whitespace-nowrap"
title={`${appContainerRuntimeLabel()} runtime`}
>
{appContainerRuntimeLabel()}

View file

@ -38,7 +38,6 @@ type WorkloadPanelProps = Pick<
| 'focusedSummaryWorkloadGroupScope'
| 'focusedSummaryWorkloadGroupId'
| 'hoveredSummaryWorkloadGroupScope'
| 'mobileVisibleColumnIds'
| 'nodeByInstance'
| 'search'
| 'selectedGuestId'
@ -52,6 +51,8 @@ type WorkloadPanelProps = Pick<
| 'visibleGroupKeys'
| 'windowedGroupedGuests'
| 'workloadIOEmphasis'
| 'workloadTableLayoutMode'
| 'workloadTableVisibleColumnIds'
>;
export function WorkloadPanel(props: WorkloadPanelProps) {
@ -183,7 +184,8 @@ export function WorkloadPanel(props: WorkloadPanelProps) {
parentNodeOnline={parentNodeOnline()}
onCustomUrlUpdate={props.handleCustomUrlUpdate}
isGroupedView={props.groupingMode() === 'grouped'}
visibleColumnIds={props.mobileVisibleColumnIds()}
visibleColumnIds={props.workloadTableVisibleColumnIds()}
workloadTableLayoutMode={props.workloadTableLayoutMode()}
onClick={() =>
props.setSelectedGuestId(
props.selectedGuestId() === guestId() ? null : guestId(),

View file

@ -9,17 +9,19 @@ type WorkloadTableHeaderProps = Pick<
DashboardState,
| 'handleSort'
| 'isMobile'
| 'mobileVisibleColumns'
| 'sortDirection'
| 'sortKey'
| 'visibleColumns'
| 'workloadTableLayoutMode'
| 'workloadTableVisibleColumnIds'
| 'workloadTableVisibleColumns'
>;
export function WorkloadTableHeader(props: WorkloadTableHeaderProps) {
return (
<TableHeader>
<TableRow class="bg-surface-alt text-muted border-b border-border">
<For each={props.mobileVisibleColumns()}>
<For each={props.workloadTableVisibleColumns()}>
{(col) => {
const isFirst = () => col.id === props.visibleColumns()[0]?.id;
const sortKeyForCol = col.sortKey as WorkloadSortKey | undefined;
@ -32,7 +34,12 @@ export function WorkloadTableHeader(props: WorkloadTableHeaderProps) {
${isFirst() ? 'pl-2 sm:pl-3 pr-1.5 sm:pr-2 text-left' : 'px-1.5 sm:px-2 text-center'} align-middle
${isSortable ? 'cursor-pointer hover:bg-surface-hover' : ''}`}
data-workload-col={col.id}
style={getGuestColumnStyle(col.id, props.isMobile())}
style={getGuestColumnStyle(
col.id,
props.isMobile(),
props.workloadTableLayoutMode(),
props.workloadTableVisibleColumnIds(),
)}
onClick={() => isSortable && props.handleSort(sortKeyForCol!)}
title={col.icon ? col.label : undefined}
>

View file

@ -894,7 +894,9 @@ describe('Dashboard performance contract', () => {
expect(guestRowSource).toContain('useGuestRowState');
expect(guestRowSource).toContain("from './GuestRowCells'");
expect(guestRowSource).not.toContain('style={{');
expect(guestRowSource).toContain("style={getGuestColumnStyle('name', isMobile())}");
expect(guestRowSource).toContain('style={getGuestColumnStyle(');
expect(guestRowSource).toContain('props.workloadTableLayoutMode');
expect(guestRowSource).toContain('props.visibleColumnIds');
expect(guestRowSource).not.toContain('export const GUEST_COLUMNS');
expect(guestRowSource).not.toContain('const guestId = createMemo(');
expect(guestRowSource).not.toContain('function NetworkInfoCell(');
@ -952,18 +954,18 @@ describe('Dashboard performance contract', () => {
expect(dashboardWorkloadTableSource).toContain('WorkloadTableHeader');
expect(dashboardWorkloadTableSource).toContain('WorkloadPanel');
expect(dashboardWorkloadTableSource).not.toContain('style={{');
expect(dashboardWorkloadTableSource).toContain(
'style={getGuestColumnWidthStyle(column.id, props.isMobile())}',
);
expect(dashboardWorkloadTableSource).toContain('style={getGuestColumnWidthStyle(');
expect(dashboardWorkloadTableSource).toContain('props.workloadTableLayoutMode()');
expect(dashboardWorkloadTableSource).toContain('props.workloadTableVisibleColumnIds()');
expect(dashboardWorkloadTableSource).toContain('<colgroup>');
expect(dashboardWorkloadTableSource).not.toContain('<TableHead');
expect(dashboardWorkloadTableSource).not.toContain('NodeGroupHeader');
expect(dashboardWorkloadTableSource).not.toContain('GuestDrawer');
expect(workloadTableHeaderSource).toContain('TableHead');
expect(workloadTableHeaderSource).toContain("col.sortKey as WorkloadSortKey");
expect(workloadTableHeaderSource).toContain(
"style={getGuestColumnStyle(col.id, props.isMobile())}",
);
expect(workloadTableHeaderSource).toContain('style={getGuestColumnStyle(');
expect(workloadTableHeaderSource).toContain('props.workloadTableLayoutMode()');
expect(workloadTableHeaderSource).toContain('props.workloadTableVisibleColumnIds()');
expect(workloadTableHeaderSource).not.toContain('style={{');
expect(workloadTableHeaderSource).not.toContain('NodeGroupHeader');
expect(workloadPanelSource).toContain('NodeGroupHeader');

View file

@ -124,6 +124,8 @@ import {
VIEW_MODE_COLUMNS,
getGuestColumnStyle,
getGuestColumnWidthStyle,
getWorkloadTableLayoutMode,
getWorkloadVisibleColumnsForLayout,
type WorkloadIOEmphasis,
} from '../guestRowModel';
@ -816,16 +818,90 @@ describe('GUEST_COLUMNS', () => {
});
it('derives mobile overrides from the canonical guest column model', () => {
expect(getGuestColumnStyle('name', true)).toEqual({ minWidth: '120px' });
expect(getGuestColumnStyle('name', true)).toEqual({ width: '44%', 'max-width': '44%' });
expect(getGuestColumnStyle('cpu', true)).toEqual({
width: '70px',
minWidth: '60px',
maxWidth: '70px',
width: '17%',
'max-width': '17%',
});
expect(getGuestColumnWidthStyle('name', true)).toBeUndefined();
expect(getGuestColumnWidthStyle('name', true)).toEqual({ width: '44%' });
expect(getGuestColumnWidthStyle('diskIo', true)).toEqual({ width: '170px' });
});
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 tabletColumns = getWorkloadVisibleColumnsForLayout(allModeColumns, 'tablet');
const compactColumns = getWorkloadVisibleColumnsForLayout(allModeColumns, 'compact');
expect(tabletColumns.map((column) => column.id)).toEqual([
'name',
'type',
'info',
'cpu',
'memory',
'disk',
'link',
]);
expect(compactColumns.map((column) => column.id)).toEqual([
'name',
'type',
'info',
'cpu',
'memory',
'disk',
'uptime',
'backup',
'link',
]);
expect(
getGuestColumnWidthStyle(
'name',
false,
'tablet',
tabletColumns.map((column) => column.id),
),
).toEqual({ width: '30%' });
expect(
getGuestColumnWidthStyle(
'name',
false,
'compact',
compactColumns.map((column) => column.id),
),
).toEqual({ width: '26%' });
});
it('normalizes compact widths for workload view modes with different column sets', () => {
const podColumns = GUEST_COLUMNS.filter((column) => VIEW_MODE_COLUMNS.pod!.has(column.id));
const compactPodColumns = getWorkloadVisibleColumnsForLayout(podColumns, 'compact');
const compactPodColumnIds = compactPodColumns.map((column) => column.id);
expect(compactPodColumnIds).toEqual([
'name',
'cpu',
'memory',
'image',
'namespace',
'context',
'link',
]);
expect(getGuestColumnWidthStyle('name', false, 'compact', compactPodColumnIds)).toEqual({
width: '25.7426%',
});
expect(getGuestColumnWidthStyle('link', false, 'compact', compactPodColumnIds)).toEqual({
width: '5.9406%',
});
});
it('maps workload table layout modes to viewport width stages', () => {
expect(getWorkloadTableLayoutMode(767)).toBe('mobile');
expect(getWorkloadTableLayoutMode(768)).toBe('tablet');
expect(getWorkloadTableLayoutMode(899)).toBe('tablet');
expect(getWorkloadTableLayoutMode(900)).toBe('compact');
expect(getWorkloadTableLayoutMode(1439)).toBe('compact');
expect(getWorkloadTableLayoutMode(1440)).toBe('wide');
});
it('non-toggleable columns include core metrics', () => {
const nonToggleable = GUEST_COLUMNS.filter((c) => !c.toggleable);
const ids = nonToggleable.map((c) => c.id);

View file

@ -5,6 +5,42 @@ import type { SummaryGroupMemberInteractionState } from '@/components/shared/sum
import type { WorkloadGuest, ViewMode } from '@/types/workloads';
import { createVisibleCanonicalTypeColumn } from '@/utils/typeColumnDefinition';
export type WorkloadTableLayoutMode = 'mobile' | 'tablet' | 'compact' | 'wide';
export const WORKLOAD_TABLE_MOBILE_LAYOUT_WIDTH = 768;
export const WORKLOAD_TABLE_TABLET_LAYOUT_WIDTH = 900;
export const WORKLOAD_TABLE_WIDE_LAYOUT_WIDTH = 1440;
const WORKLOAD_TABLE_LAYOUT_ORDER: Record<WorkloadTableLayoutMode, number> = {
mobile: 0,
tablet: 1,
compact: 2,
wide: 3,
};
const WORKLOAD_COLUMN_MIN_LAYOUT: Record<string, WorkloadTableLayoutMode> = {
name: 'mobile',
cpu: 'mobile',
memory: 'mobile',
disk: 'mobile',
link: 'mobile',
type: 'tablet',
info: 'tablet',
vmid: 'tablet',
uptime: 'compact',
backup: 'compact',
image: 'compact',
namespace: 'compact',
context: 'compact',
update: 'compact',
ip: 'wide',
node: 'wide',
tags: 'wide',
os: 'wide',
netIo: 'wide',
diskIo: 'wide',
};
export interface IODistributionStats {
median: number;
mad: number;
@ -47,6 +83,7 @@ export interface GuestRowProps {
isSummaryHighlighted?: boolean;
summaryGroupMemberState?: SummaryGroupMemberInteractionState;
ioEmphasis?: WorkloadIOEmphasis;
workloadTableLayoutMode?: WorkloadTableLayoutMode;
onHoverChange?: (guestId: string | null) => void;
}
@ -72,10 +109,7 @@ export const EMPTY_IO_EMPHASIS: WorkloadIOEmphasis = {
export const GROUPED_FIRST_CELL_INDENT = 'pl-3 sm:pl-5 lg:pl-8';
export const DEFAULT_FIRST_CELL_INDENT = 'pl-2 sm:pl-3';
export const getOutlierEmphasis = (
value: number,
stats: IODistributionStats,
): IOEmphasis => {
export const getOutlierEmphasis = (value: number, stats: IODistributionStats): IOEmphasis => {
if (!Number.isFinite(value) || value <= 0 || stats.max <= 0) {
return { className: 'text-muted', showOutlierHint: false };
}
@ -293,25 +327,78 @@ type GuestColumnWidthOverride = {
maxWidth?: string | null;
};
// Mobile widths use percentages so the visible column set fills the viewport
// without triggering horizontal scroll. The workload table's mobile-essential
// columns (name + cpu + memory + disk + link) must sum to 100% at mobile:
// 44 + 17 + 17 + 17 + 5 = 100. See useDashboardControlsState
// `mobileEssentialColumns` for the visible set, and DashboardWorkloadTable for
// the `table-fixed` class that makes these percentages authoritative.
const GUEST_COLUMN_MOBILE_OVERRIDES: Record<string, GuestColumnWidthOverride> = {
name: { width: '44%', minWidth: null, maxWidth: '44%' },
cpu: { width: '17%', minWidth: null, maxWidth: '17%' },
memory: { width: '17%', minWidth: null, maxWidth: '17%' },
disk: { width: '17%', minWidth: null, maxWidth: '17%' },
link: { width: '5%', minWidth: null, maxWidth: '5%' },
netIo: { width: '170px', minWidth: '170px', maxWidth: '170px' },
diskIo: { width: '170px', minWidth: '170px', maxWidth: '170px' },
const percentageColumn = (width: string): GuestColumnWidthOverride => ({
width,
minWidth: null,
maxWidth: width,
});
const formatPercentage = (value: number): string => `${Number(value.toFixed(4))}%`;
// Responsive weights are normalized against the currently visible column set.
// That keeps each workload view mode full-width without assuming one fixed set.
const GUEST_COLUMN_RESPONSIVE_WEIGHTS: Record<
Exclude<WorkloadTableLayoutMode, 'wide'>,
Record<string, number>
> = {
mobile: {
name: 44,
cpu: 17,
memory: 17,
disk: 17,
link: 5,
},
tablet: {
name: 30,
type: 8,
info: 8,
vmid: 8,
cpu: 17,
memory: 17,
disk: 17,
link: 3,
},
compact: {
name: 26,
type: 7,
info: 7,
vmid: 7,
cpu: 13,
memory: 14,
disk: 14,
uptime: 8,
backup: 5,
image: 18,
namespace: 11,
context: 13,
update: 6,
link: 6,
},
};
const getResponsiveColumnOverride = (
columnId: string,
layoutMode: WorkloadTableLayoutMode,
visibleColumnIds?: readonly string[],
): GuestColumnWidthOverride | undefined => {
if (layoutMode === 'wide') return undefined;
const weights = GUEST_COLUMN_RESPONSIVE_WEIGHTS[layoutMode];
const columnWeight = weights[columnId];
if (!columnWeight) return undefined;
const activeIds = visibleColumnIds?.length ? visibleColumnIds : Object.keys(weights);
const totalWeight = activeIds.reduce((total, id) => total + (weights[id] ?? 0), 0);
if (totalWeight <= 0) return undefined;
return percentageColumn(formatPercentage((columnWeight / totalWeight) * 100));
};
const getGuestColumnSizing = (
columnId: string,
isMobile = false,
layoutMode: WorkloadTableLayoutMode = isMobile ? 'mobile' : 'wide',
visibleColumnIds?: readonly string[],
): Pick<ColumnDef, 'width' | 'minWidth' | 'maxWidth'> | undefined => {
const column = GUEST_COLUMN_BY_ID.get(columnId);
if (!column) return undefined;
@ -322,7 +409,7 @@ const getGuestColumnSizing = (
maxWidth: column.maxWidth,
};
const override = isMobile ? GUEST_COLUMN_MOBILE_OVERRIDES[columnId] : undefined;
const override = getResponsiveColumnOverride(columnId, layoutMode, visibleColumnIds);
if (override) {
if ('width' in override) sizing.width = override.width ?? undefined;
if ('minWidth' in override) sizing.minWidth = override.minWidth ?? undefined;
@ -335,8 +422,10 @@ const getGuestColumnSizing = (
export const getGuestColumnStyle = (
columnId: string,
isMobile = false,
layoutMode?: WorkloadTableLayoutMode,
visibleColumnIds?: readonly string[],
): JSX.CSSProperties | undefined => {
const sizing = getGuestColumnSizing(columnId, isMobile);
const sizing = getGuestColumnSizing(columnId, isMobile, layoutMode, visibleColumnIds);
if (!sizing) return undefined;
const style: JSX.CSSProperties = {};
@ -351,12 +440,32 @@ export const getGuestColumnStyle = (
export const getGuestColumnWidthStyle = (
columnId: string,
isMobile = false,
layoutMode?: WorkloadTableLayoutMode,
visibleColumnIds?: readonly string[],
): JSX.CSSProperties | undefined => {
const sizing = getGuestColumnSizing(columnId, isMobile);
const sizing = getGuestColumnSizing(columnId, isMobile, layoutMode, visibleColumnIds);
if (!sizing?.width) return undefined;
return { width: sizing.width };
};
export const getWorkloadTableLayoutMode = (width: number): WorkloadTableLayoutMode => {
if (!Number.isFinite(width) || width < WORKLOAD_TABLE_MOBILE_LAYOUT_WIDTH) return 'mobile';
if (width < WORKLOAD_TABLE_TABLET_LAYOUT_WIDTH) return 'tablet';
if (width < WORKLOAD_TABLE_WIDE_LAYOUT_WIDTH) return 'compact';
return 'wide';
};
export const getWorkloadVisibleColumnsForLayout = (
columns: ColumnDef[],
layoutMode: WorkloadTableLayoutMode,
): ColumnDef[] => {
const layoutRank = WORKLOAD_TABLE_LAYOUT_ORDER[layoutMode];
return columns.filter((column) => {
const minimumLayout = WORKLOAD_COLUMN_MIN_LAYOUT[column.id] ?? 'wide';
return WORKLOAD_TABLE_LAYOUT_ORDER[minimumLayout] <= layoutRank;
});
};
export const VIEW_MODE_COLUMNS: Record<ViewMode, Set<string> | null> = {
all: new Set([
'name',

View file

@ -9,7 +9,12 @@ import { aiChatStore } from '@/stores/aiChat';
import { isSummaryTimeRange } from '@/components/shared/summaryTimeRange';
import type { ViewMode } from '@/types/workloads';
import { GUEST_COLUMNS, VIEW_MODE_COLUMNS } from './guestRowModel';
import {
GUEST_COLUMNS,
VIEW_MODE_COLUMNS,
getWorkloadTableLayoutMode,
getWorkloadVisibleColumnsForLayout,
} from './guestRowModel';
import {
DEFAULT_DASHBOARD_SORT_DIRECTION,
DEFAULT_DASHBOARD_SORT_KEY,
@ -26,7 +31,9 @@ interface DashboardControlsStateOptions {
}
export function useDashboardControlsState(options: DashboardControlsStateOptions) {
const { isMobile } = useBreakpoint();
const breakpoint = useBreakpoint();
const workloadTableLayoutMode = createMemo(() => getWorkloadTableLayoutMode(breakpoint.width()));
const isMobile = createMemo(() => workloadTableLayoutMode() === 'mobile');
const [search, setSearch] = createSignal('');
const [isSearchLocked, setIsSearchLocked] = createSignal(false);
@ -89,17 +96,13 @@ export function useDashboardControlsState(options: DashboardControlsStateOptions
);
const visibleColumns = columnVisibility.visibleColumns;
const visibleColumnIds = createMemo(() => visibleColumns().map((column) => column.id));
const mobileEssentialColumns = new Set(['name', 'cpu', 'memory', 'disk', 'link']);
const mobileVisibleColumns = createMemo(() =>
isMobile()
? visibleColumns().filter((column) => mobileEssentialColumns.has(column.id))
: visibleColumns(),
const workloadTableVisibleColumns = createMemo(() =>
getWorkloadVisibleColumnsForLayout(visibleColumns(), workloadTableLayoutMode()),
);
const mobileVisibleColumnIds = createMemo(() =>
isMobile() ? mobileVisibleColumns().map((column) => column.id) : visibleColumnIds(),
const workloadTableVisibleColumnIds = createMemo(() =>
workloadTableVisibleColumns().map((column) => column.id),
);
const totalColumns = createMemo(() => mobileVisibleColumns().length);
const totalColumns = createMemo(() => workloadTableVisibleColumns().length);
const handleSort = (key: DashboardSortKey) => {
if (sortKey() === key) {
@ -191,8 +194,6 @@ export function useDashboardControlsState(options: DashboardControlsStateOptions
handleTagClick,
isMobile,
isSearchLocked,
mobileVisibleColumnIds,
mobileVisibleColumns,
resetDashboardControls,
search,
setGroupingMode,
@ -205,8 +206,11 @@ export function useDashboardControlsState(options: DashboardControlsStateOptions
statusMode,
totalColumns,
visibleColumns,
workloadTableVisibleColumnIds,
workloadTableVisibleColumns,
workloadsSummaryCollapsed,
workloadsSummaryRange,
workloadTableLayoutMode,
setWorkloadsSummaryCollapsed,
setWorkloadsSummaryRange,
} as const;

View file

@ -117,8 +117,6 @@ export function useDashboardState(props: DashboardProps) {
handleTagClick,
isMobile,
isSearchLocked,
mobileVisibleColumnIds,
mobileVisibleColumns,
resetDashboardControls,
search,
setGroupingMode,
@ -131,6 +129,9 @@ export function useDashboardState(props: DashboardProps) {
statusMode,
totalColumns,
visibleColumns,
workloadTableVisibleColumnIds,
workloadTableVisibleColumns,
workloadTableLayoutMode,
workloadsSummaryCollapsed,
workloadsSummaryRange,
setWorkloadsSummaryCollapsed,
@ -308,8 +309,6 @@ export function useDashboardState(props: DashboardProps) {
kioskMode,
kubernetesContextOptions,
kubernetesNamespaceOptions,
mobileVisibleColumnIds,
mobileVisibleColumns,
navigate,
nodeByInstance,
namespaceFilterConfig,
@ -359,6 +358,9 @@ export function useDashboardState(props: DashboardProps) {
visibleGroupKeys,
windowedGroupedGuests,
workloadIOEmphasis,
workloadTableVisibleColumnIds,
workloadTableVisibleColumns,
workloadTableLayoutMode,
workloadNodeOptions,
workloads,
workloadsSummaryCollapsed,