Improve infrastructure service table density

This commit is contained in:
rcourtman 2026-04-23 23:05:48 +01:00
parent 9bada35337
commit 16efcba31f
7 changed files with 135 additions and 56 deletions

View file

@ -54,20 +54,20 @@ export const UnifiedResourcePBSTableSection: Component<UnifiedResourcePBSTableSe
<TableHeader>
<TableRow class="bg-surface-alt text-muted border-b border-border">
<TableHead
class={`text-left pl-2 sm:pl-3 ${table.resourceColumn().className}`}
width={table.resourceColumn().width}
class={`text-left pl-2 sm:pl-3 ${table.serviceResourceColumn().className}`}
width={table.serviceResourceColumn().width}
>
{table.headerLabels().resource}
</TableHead>
<TableHead
classList={{ hidden: table.isMobile() || !table.isVisible('primary') }}
classList={{ hidden: !table.isServiceVisible('primary') }}
class={table.serviceCountColumn().className}
width={table.serviceCountColumn().width}
>
{table.headerLabels().datastores}
</TableHead>
<TableHead
classList={{ hidden: table.isMobile() || !table.isVisible('secondary') }}
classList={{ hidden: !table.isServiceVisible('secondary') }}
class={table.serviceCountColumn().className}
width={table.serviceCountColumn().width}
>
@ -80,14 +80,14 @@ export const UnifiedResourcePBSTableSection: Component<UnifiedResourcePBSTableSe
{table.headerLabels().health}
</TableHead>
<TableHead
classList={{ hidden: table.isMobile() || !table.isVisible('secondary') }}
classList={{ hidden: !table.isServiceVisible('secondary') }}
class={table.sourceColumn().className}
width={table.sourceColumn().width}
>
{table.headerLabels().source}
</TableHead>
<TableHead
classList={{ hidden: table.isMobile() || !table.isVisible('supplementary') }}
classList={{ hidden: !table.isServiceVisible('supplementary') }}
class={table.uptimeColumn().className}
width={table.uptimeColumn().width}
>
@ -204,9 +204,7 @@ export const UnifiedResourcePBSTableSection: Component<UnifiedResourcePBSTableSe
</div>
</TableCell>
<TableCell
classList={{ hidden: table.isMobile() || !table.isVisible('primary') }}
>
<TableCell classList={{ hidden: !table.isServiceVisible('primary') }}>
<div class="flex justify-center">
<Show
when={pbsRow()?.datastores != null}
@ -217,9 +215,7 @@ export const UnifiedResourcePBSTableSection: Component<UnifiedResourcePBSTableSe
</div>
</TableCell>
<TableCell
classList={{ hidden: table.isMobile() || !table.isVisible('secondary') }}
>
<TableCell classList={{ hidden: !table.isServiceVisible('secondary') }}>
<div class="flex justify-center">
<Show
when={pbsRow()?.activity}
@ -255,9 +251,7 @@ export const UnifiedResourcePBSTableSection: Component<UnifiedResourcePBSTableSe
</div>
</TableCell>
<TableCell
classList={{ hidden: table.isMobile() || !table.isVisible('secondary') }}
>
<TableCell classList={{ hidden: !table.isServiceVisible('secondary') }}>
<UnifiedResourceSourceBadgeCell
unifiedBadges={unifiedSourceBadges()}
platformBadge={platformBadge()}
@ -268,7 +262,7 @@ export const UnifiedResourcePBSTableSection: Component<UnifiedResourcePBSTableSe
<TableCell
classList={{
hidden: table.isMobile() || !table.isVisible('supplementary'),
hidden: !table.isServiceVisible('supplementary'),
}}
>
<div class="flex justify-center">

View file

@ -54,34 +54,34 @@ export const UnifiedResourcePMGTableSection: Component<UnifiedResourcePMGTableSe
<TableHeader>
<TableRow class="bg-surface-alt text-muted border-b border-border">
<TableHead
class={`text-left pl-2 sm:pl-3 ${table.resourceColumn().className}`}
width={table.resourceColumn().width}
class={`text-left pl-2 sm:pl-3 ${table.serviceResourceColumn().className}`}
width={table.serviceResourceColumn().width}
>
{table.headerLabels().resource}
</TableHead>
<TableHead
classList={{ hidden: table.isMobile() || !table.isVisible('primary') }}
classList={{ hidden: !table.isServiceVisible('primary') }}
class={table.serviceQueueColumn().className}
width={table.serviceQueueColumn().width}
>
{table.headerLabels().queue}
</TableHead>
<TableHead
classList={{ hidden: table.isMobile() || !table.isVisible('secondary') }}
classList={{ hidden: !table.isServiceVisible('secondary') }}
class={table.serviceQueueColumn().className}
width={table.serviceQueueColumn().width}
>
{table.headerLabels().deferred}
</TableHead>
<TableHead
classList={{ hidden: table.isMobile() || !table.isVisible('supplementary') }}
classList={{ hidden: !table.isServiceVisible('detailed') }}
class={table.serviceQueueColumn().className}
width={table.serviceQueueColumn().width}
>
{table.headerLabels().hold}
</TableHead>
<TableHead
classList={{ hidden: table.isMobile() || !table.isVisible('secondary') }}
classList={{ hidden: !table.isServiceVisible('secondary') }}
class={table.serviceCountColumn().className}
width={table.serviceCountColumn().width}
>
@ -94,14 +94,14 @@ export const UnifiedResourcePMGTableSection: Component<UnifiedResourcePMGTableSe
{table.headerLabels().health}
</TableHead>
<TableHead
classList={{ hidden: table.isMobile() || !table.isVisible('secondary') }}
classList={{ hidden: !table.isServiceVisible('secondary') }}
class={table.sourceColumn().className}
width={table.sourceColumn().width}
>
{table.headerLabels().source}
</TableHead>
<TableHead
classList={{ hidden: table.isMobile() || !table.isVisible('supplementary') }}
classList={{ hidden: !table.isServiceVisible('supplementary') }}
class={table.uptimeColumn().className}
width={table.uptimeColumn().width}
>
@ -218,9 +218,7 @@ export const UnifiedResourcePMGTableSection: Component<UnifiedResourcePMGTableSe
</div>
</TableCell>
<TableCell
classList={{ hidden: table.isMobile() || !table.isVisible('primary') }}
>
<TableCell classList={{ hidden: !table.isServiceVisible('primary') }}>
<div class="flex justify-center">
<Show
when={pmgRow()?.queue != null}
@ -233,9 +231,7 @@ export const UnifiedResourcePMGTableSection: Component<UnifiedResourcePMGTableSe
</div>
</TableCell>
<TableCell
classList={{ hidden: table.isMobile() || !table.isVisible('secondary') }}
>
<TableCell classList={{ hidden: !table.isServiceVisible('secondary') }}>
<div class="flex justify-center">
<Show
when={pmgRow()?.deferred != null}
@ -248,7 +244,7 @@ export const UnifiedResourcePMGTableSection: Component<UnifiedResourcePMGTableSe
<TableCell
classList={{
hidden: table.isMobile() || !table.isVisible('supplementary'),
hidden: !table.isServiceVisible('detailed'),
}}
>
<div class="flex justify-center">
@ -261,9 +257,7 @@ export const UnifiedResourcePMGTableSection: Component<UnifiedResourcePMGTableSe
</div>
</TableCell>
<TableCell
classList={{ hidden: table.isMobile() || !table.isVisible('secondary') }}
>
<TableCell classList={{ hidden: !table.isServiceVisible('secondary') }}>
<div class="flex justify-center">
<Show
when={pmgRow()?.nodes != null}
@ -287,9 +281,7 @@ export const UnifiedResourcePMGTableSection: Component<UnifiedResourcePMGTableSe
</div>
</TableCell>
<TableCell
classList={{ hidden: table.isMobile() || !table.isVisible('secondary') }}
>
<TableCell classList={{ hidden: !table.isServiceVisible('secondary') }}>
<UnifiedResourceSourceBadgeCell
unifiedBadges={unifiedSourceBadges()}
platformBadge={platformBadge()}
@ -300,7 +292,7 @@ export const UnifiedResourcePMGTableSection: Component<UnifiedResourcePMGTableSe
<TableCell
classList={{
hidden: table.isMobile() || !table.isVisible('supplementary'),
hidden: !table.isServiceVisible('supplementary'),
}}
>
<div class="flex justify-center">

View file

@ -11,7 +11,7 @@ interface UnifiedResourceSourceBadgeCellProps {
}
const getVisibleSourceBadgeLimit = (layoutMode: UnifiedResourceTableLayoutMode): number =>
layoutMode === 'wide' ? 3 : 1;
layoutMode === 'wide' ? 3 : 2;
export const UnifiedResourceSourceBadgeCell: Component<UnifiedResourceSourceBadgeCellProps> = (
props,
@ -25,7 +25,11 @@ export const UnifiedResourceSourceBadgeCell: Component<UnifiedResourceSourceBadg
const visibleBadges = createMemo(() =>
badges().slice(0, getVisibleSourceBadgeLimit(props.layoutMode)),
);
const hiddenBadges = createMemo(() => badges().slice(visibleBadges().length));
const hiddenBadgeCount = createMemo(() => Math.max(0, badges().length - visibleBadges().length));
const hiddenBadgeLabel = createMemo(() =>
hiddenBadgeCount() === 1 ? `+${hiddenBadges()[0]?.label ?? ''}` : `+${hiddenBadgeCount()}`,
);
const title = createMemo(() =>
badges()
.map((badge) => badge.title ?? badge.label)
@ -47,9 +51,12 @@ export const UnifiedResourceSourceBadgeCell: Component<UnifiedResourceSourceBadg
<Show when={hiddenBadgeCount() > 0}>
<span
class="inline-flex min-w-0 max-w-full items-center overflow-hidden rounded bg-surface-alt px-1 py-0.5 text-[10px] font-medium text-muted"
aria-label={`Additional sources: ${hiddenBadges()
.map((badge) => badge.title ?? badge.label)
.join(', ')}`}
title={title()}
>
+{hiddenBadgeCount()}
<span class="min-w-0 truncate">{hiddenBadgeLabel()}</span>
</span>
</Show>
</div>

View file

@ -237,6 +237,50 @@ describe('UnifiedResourceTable performance contract', () => {
});
});
it('keeps service columns denser than host columns before hiding them', async () => {
const resources = [
makeResource(2, {
type: 'pbs',
name: 'pbs-docker',
displayName: 'pbs-docker',
platformType: 'proxmox-pbs',
sourceType: 'hybrid',
platformData: { sources: ['proxmox-pbs', 'agent'] },
uptime: 86400,
}),
];
const { getByText, queryByText } = render(() => (
<UnifiedResourceTable
resources={resources}
expandedResourceId={null}
onExpandedResourceChange={vi.fn()}
groupingMode="flat"
/>
));
await waitFor(() => {
expect(getByText('Stores')).toBeInTheDocument();
});
emitResizeObserverWidth(600);
await waitFor(() => {
expect(getByText('Store').closest('th')).not.toHaveClass('hidden');
expect(getByText('Jobs').closest('th')).not.toHaveClass('hidden');
expect(getByText('Src').closest('th')).not.toHaveClass('hidden');
expect(getByText('Up').closest('th')).toHaveClass('hidden');
});
expect(getByText('PBS')).toBeInTheDocument();
expect(getByText('Agent')).toBeInTheDocument();
expect(queryByText('+1')).toBeNull();
emitResizeObserverWidth(660);
await waitFor(() => {
expect(getByText('Up').closest('th')).not.toHaveClass('hidden');
});
});
it('renders the shared facet summary component in timeline-only mode for canonical resource counts', async () => {
const { getByText, queryByText } = render(() => (
<ResourceFacetSummary
@ -297,6 +341,7 @@ describe('UnifiedResourceTable performance contract', () => {
);
expect(unifiedResourceTableStateSource).toContain('getUnifiedResourceTableHeaderLabels');
expect(unifiedResourceTableStateSource).toContain('getUnifiedResourceTableLayoutMode');
expect(unifiedResourceTableStateSource).toContain('isUnifiedResourceServiceColumnVisible');
expect(unifiedResourceTableStateSource).toContain('getUnifiedResourceTableShellClass');
expect(unifiedResourceTableStateSource).toContain('useTableWindowing');
expect(unifiedResourceTableStateSource).toContain('useUnifiedResourceTableViewportSync');
@ -332,6 +377,9 @@ describe('UnifiedResourceTable performance contract', () => {
expect(unifiedResourceTableStateModelSource).toContain(
'export const getUnifiedResourceTableLayoutMode',
);
expect(unifiedResourceTableStateModelSource).toContain(
'export const isUnifiedResourceServiceColumnVisible',
);
expect(unifiedResourceTableStateModelSource).toContain(
'export const getUnifiedResourceTableShellClass',
);

View file

@ -15,6 +15,7 @@ import {
getUnifiedResourceTableSortIndicator,
getUnifiedSources,
getVisibleHostTableItems,
isUnifiedResourceServiceColumnVisible,
isUnifiedResourceTableColumnVisible,
normalizeUnifiedResourceTableLayoutWidth,
shouldShowUnifiedResourceHostTable,
@ -128,21 +129,26 @@ describe('unifiedResourceTableStateModel', () => {
// lower-priority columns are dropped.
expect(mobileColumns.resourceColumn.width).toBe('40%');
expect(mobileColumns.metricColumn.width).toBe('20%');
expect(mobileColumns.serviceHealthColumn.width).toBe('36%');
expect(mobileColumns.serviceActionColumn.width).toBe('24%');
expect(mobileColumns.serviceResourceColumn.width).toBe('28%');
expect(mobileColumns.serviceCountColumn.width).toBe('11%');
expect(mobileColumns.serviceHealthColumn.width).toBe('13%');
expect(mobileColumns.serviceActionColumn.width).toBe('14%');
expect(tabletColumns.resourceColumn.width).toBe('26%');
expect(tabletColumns.serviceResourceColumn.width).toBe('24%');
expect(tabletColumns.ioColumn.width).toBe('18%');
expect(tabletColumns.sourceColumn.width).toBe('17%');
expect(tabletColumns.sourceColumn.width).toBe('8%');
expect(compactColumns.resourceColumn.width).toBe('18%');
expect(compactColumns.serviceResourceColumn.width).toBe('18%');
expect(compactColumns.metricColumn.width).toBe('10.5%');
expect(compactColumns.tempColumn.width).toBe('7%');
expect(wideColumns.resourceColumn.width).toBe('18%');
expect(wideColumns.serviceResourceColumn.width).toBe('18%');
expect(wideColumns.ioColumn.width).toBe('12.5%');
expect(wideColumns.serviceActionColumn.width).toBe('16%');
expect(getUnifiedResourceTableHeaderLabels('wide').memory).toBe('Memory');
expect(getUnifiedResourceTableHeaderLabels('compact').memory).toBe('Mem');
expect(getUnifiedResourceTableHeaderLabels('tablet').network).toBe('Net');
expect(getUnifiedResourceTableHeaderLabels('mobile').datastores).toBe('Stores');
expect(getUnifiedResourceTableHeaderLabels('mobile').datastores).toBe('Store');
expect(shouldShowUnifiedResourceHostTable(0, 0)).toBe(true);
expect(shouldShowUnifiedResourceHostTable(0, 2)).toBe(false);
expect(shouldShowUnifiedResourceHostTable(3, 2)).toBe(true);
@ -166,6 +172,14 @@ describe('unifiedResourceTableStateModel', () => {
expect(isUnifiedResourceTableColumnVisible('supplementary', 900)).toBe(true);
expect(isUnifiedResourceTableColumnVisible('detailed', 1159)).toBe(false);
expect(isUnifiedResourceTableColumnVisible('detailed', 1160)).toBe(true);
expect(isUnifiedResourceServiceColumnVisible('primary', 499)).toBe(false);
expect(isUnifiedResourceServiceColumnVisible('primary', 500)).toBe(true);
expect(isUnifiedResourceServiceColumnVisible('secondary', 579)).toBe(false);
expect(isUnifiedResourceServiceColumnVisible('secondary', 580)).toBe(true);
expect(isUnifiedResourceServiceColumnVisible('supplementary', 639)).toBe(false);
expect(isUnifiedResourceServiceColumnVisible('supplementary', 640)).toBe(true);
expect(isUnifiedResourceServiceColumnVisible('detailed', 899)).toBe(false);
expect(isUnifiedResourceServiceColumnVisible('detailed', 900)).toBe(true);
});
it('reads unified source tags from platform data without hook state', () => {

View file

@ -46,6 +46,13 @@ export const UNIFIED_RESOURCE_TABLE_COLUMN_BREAKPOINTS: Record<ColumnPriority, n
supplementary: UNIFIED_RESOURCE_TABLE_COMPACT_LAYOUT_WIDTH,
detailed: UNIFIED_RESOURCE_TABLE_WIDE_LAYOUT_WIDTH,
};
export const UNIFIED_RESOURCE_SERVICE_COLUMN_BREAKPOINTS: Record<ColumnPriority, number> = {
essential: 0,
primary: 500,
secondary: 580,
supplementary: 640,
detailed: 900,
};
export type UnifiedResourceTableLayoutMode = 'mobile' | 'tablet' | 'compact' | 'wide';
@ -83,6 +90,13 @@ export const isUnifiedResourceTableColumnVisible = (
normalizeUnifiedResourceTableLayoutWidth(layoutWidth) >=
UNIFIED_RESOURCE_TABLE_COLUMN_BREAKPOINTS[priority];
export const isUnifiedResourceServiceColumnVisible = (
priority: ColumnPriority,
layoutWidth: number,
): boolean =>
normalizeUnifiedResourceTableLayoutWidth(layoutWidth) >=
UNIFIED_RESOURCE_SERVICE_COLUMN_BREAKPOINTS[priority];
export const buildResourceLabelById = (resources: Resource[]): Map<string, string> => {
const map = new Map<string, string>();
for (const resource of resources) {
@ -212,6 +226,7 @@ export interface UnifiedResourceTableColumnPresentation {
export type UnifiedResourceTableColumnPresentations = {
resourceColumn: UnifiedResourceTableColumnPresentation;
serviceResourceColumn: UnifiedResourceTableColumnPresentation;
metricColumn: UnifiedResourceTableColumnPresentation;
ioColumn: UnifiedResourceTableColumnPresentation;
sourceColumn: UnifiedResourceTableColumnPresentation;
@ -312,7 +327,7 @@ export const getUnifiedResourceTableHeaderLabels = (
source: 'Src',
uptime: 'Up',
temp: 'C',
datastores: 'Stores',
datastores: 'Store',
activity: 'Jobs',
queue: 'Queue',
deferred: 'Def',
@ -332,36 +347,39 @@ export const getUnifiedResourceTableColumnPresentations = (
// never render.
return {
resourceColumn: buildUnifiedResourceTableColumnPresentation('', '40%'),
serviceResourceColumn: buildUnifiedResourceTableColumnPresentation('', '28%'),
metricColumn: buildUnifiedResourceTableColumnPresentation('', '20%'),
ioColumn: buildUnifiedResourceTableColumnPresentation('', '20%'),
sourceColumn: buildUnifiedResourceTableColumnPresentation('', '20%'),
uptimeColumn: buildUnifiedResourceTableColumnPresentation('', '15%'),
sourceColumn: buildUnifiedResourceTableColumnPresentation('', '10%'),
uptimeColumn: buildUnifiedResourceTableColumnPresentation('', '8%'),
tempColumn: buildUnifiedResourceTableColumnPresentation('', '15%'),
serviceCountColumn: buildUnifiedResourceTableColumnPresentation('', '20%'),
serviceQueueColumn: buildUnifiedResourceTableColumnPresentation('', '20%'),
serviceHealthColumn: buildUnifiedResourceTableColumnPresentation('', '36%'),
serviceActionColumn: buildUnifiedResourceTableColumnPresentation('', '24%'),
serviceCountColumn: buildUnifiedResourceTableColumnPresentation('', '11%'),
serviceQueueColumn: buildUnifiedResourceTableColumnPresentation('', '8%'),
serviceHealthColumn: buildUnifiedResourceTableColumnPresentation('', '13%'),
serviceActionColumn: buildUnifiedResourceTableColumnPresentation('', '14%'),
};
}
if (layoutMode === 'tablet') {
return {
resourceColumn: buildUnifiedResourceTableColumnPresentation('', '26%'),
serviceResourceColumn: buildUnifiedResourceTableColumnPresentation('', '24%'),
metricColumn: buildUnifiedResourceTableColumnPresentation('', '13%'),
ioColumn: buildUnifiedResourceTableColumnPresentation('', '18%'),
sourceColumn: buildUnifiedResourceTableColumnPresentation('', '17%'),
uptimeColumn: buildUnifiedResourceTableColumnPresentation('', '8%'),
sourceColumn: buildUnifiedResourceTableColumnPresentation('', '8%'),
uptimeColumn: buildUnifiedResourceTableColumnPresentation('', '7%'),
tempColumn: buildUnifiedResourceTableColumnPresentation('', '6%'),
serviceCountColumn: buildUnifiedResourceTableColumnPresentation('', '8%'),
serviceQueueColumn: buildUnifiedResourceTableColumnPresentation('', '8%'),
serviceHealthColumn: buildUnifiedResourceTableColumnPresentation('', '16%'),
serviceActionColumn: buildUnifiedResourceTableColumnPresentation('', '17%'),
serviceQueueColumn: buildUnifiedResourceTableColumnPresentation('', '7.5%'),
serviceHealthColumn: buildUnifiedResourceTableColumnPresentation('', '14%'),
serviceActionColumn: buildUnifiedResourceTableColumnPresentation('', '13%'),
};
}
if (layoutMode === 'compact') {
return {
resourceColumn: buildUnifiedResourceTableColumnPresentation('', '18%'),
serviceResourceColumn: buildUnifiedResourceTableColumnPresentation('', '18%'),
metricColumn: buildUnifiedResourceTableColumnPresentation('', '10.5%'),
ioColumn: buildUnifiedResourceTableColumnPresentation('', '13%'),
sourceColumn: buildUnifiedResourceTableColumnPresentation('', '9.5%'),
@ -376,6 +394,7 @@ export const getUnifiedResourceTableColumnPresentations = (
return {
resourceColumn: buildUnifiedResourceTableColumnPresentation('', '18%'),
serviceResourceColumn: buildUnifiedResourceTableColumnPresentation('', '18%'),
metricColumn: buildUnifiedResourceTableColumnPresentation('', '10.5%'),
ioColumn: buildUnifiedResourceTableColumnPresentation('', '12.5%'),
sourceColumn: buildUnifiedResourceTableColumnPresentation('', '10%'),

View file

@ -22,6 +22,7 @@ import {
getUnifiedResourceTableColumnPresentations,
getUnifiedResourceTableHeaderLabels,
getUnifiedResourceTableLayoutMode,
isUnifiedResourceServiceColumnVisible,
isUnifiedResourceTableColumnVisible,
getUnifiedResourceTableShellClass,
getUnifiedResourceTableSortIndicator,
@ -107,6 +108,8 @@ export function useUnifiedResourceTableState(props: UnifiedResourceTableProps) {
const isMobile = createMemo(() => shouldUseUnifiedResourceTableMobileLayout(tableLayoutWidth()));
const isVisible = (priority: ColumnPriority) =>
isUnifiedResourceTableColumnVisible(priority, tableLayoutWidth());
const isServiceVisible = (priority: ColumnPriority) =>
isUnifiedResourceServiceColumnVisible(priority, tableLayoutWidth());
const split = createMemo(() => splitPrimaryAndServiceResources(props.resources));
const primaryResources = createMemo(() => split().primaryResources);
@ -210,6 +213,7 @@ export function useUnifiedResourceTableState(props: UnifiedResourceTableProps) {
layoutMode,
isMobile,
isVisible,
isServiceVisible,
handleSort,
renderSortIndicator,
resolveResourceLabel,
@ -225,6 +229,7 @@ export function useUnifiedResourceTableState(props: UnifiedResourceTableProps) {
tableShellClass,
headerLabels,
resourceColumn: () => columnPresentations().resourceColumn,
serviceResourceColumn: () => columnPresentations().serviceResourceColumn,
metricColumn: () => columnPresentations().metricColumn,
ioColumn: () => columnPresentations().ioColumn,
sourceColumn: () => columnPresentations().sourceColumn,