mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-04-28 03:20:11 +00:00
Fix workload table responsive layout
This commit is contained in:
parent
9dbaaa7efe
commit
113778b54c
11 changed files with 297 additions and 66 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue