From 0d516be4dd59723fdd0e3b435a366c44000bce45 Mon Sep 17 00:00:00 2001 From: Jarek Krochmalski Date: Thu, 27 Aug 2026 07:34:38 +0200 Subject: [PATCH] sort by Disk I/O and Net I/O - the header cycles read/write and down/up --- src/lib/components/data-grid/DataGrid.svelte | 50 +++++++++++-- src/lib/components/data-grid/types.ts | 2 +- src/lib/config/grid-columns.ts | 27 +++++-- src/lib/types.ts | 4 + src/lib/utils/io-sort-cycle.ts | 22 ++++++ src/routes/containers/+page.svelte | 16 +++- src/routes/stacks/+page.svelte | 79 ++++++++++++++++++-- tests/io-sort-cycle.test.ts | 35 +++++++++ 8 files changed, 217 insertions(+), 18 deletions(-) create mode 100644 src/lib/utils/io-sort-cycle.ts create mode 100644 tests/io-sort-cycle.test.ts diff --git a/src/lib/components/data-grid/DataGrid.svelte b/src/lib/components/data-grid/DataGrid.svelte index a372d7e..93468db 100644 --- a/src/lib/components/data-grid/DataGrid.svelte +++ b/src/lib/components/data-grid/DataGrid.svelte @@ -5,6 +5,7 @@ import { columnResize } from '$lib/actions/column-resize'; import { gridPreferencesStore } from '$lib/stores/grid-preferences'; import { getAllColumnConfigs } from '$lib/config/grid-columns'; + import { nextIoSortState } from '$lib/utils/io-sort-cycle'; import ColumnSettingsPopover from '$lib/components/ColumnSettingsPopover.svelte'; import { Skeleton } from '$lib/components/ui/skeleton'; import type { GridId, ColumnConfig, ColumnPreference } from '$lib/types'; @@ -132,8 +133,8 @@ const orderedColumns = $derived.by(() => { const prefs = gridPrefs[gridId]; if (!prefs?.columns?.length) { - // Default: all configurable columns visible - return columnConfigs.filter((c) => !c.fixed).map((c) => c.id); + // Default: configurable columns visible (honoring per-column defaultVisible) + return columnConfigs.filter((c) => !c.fixed && c.defaultVisible !== false).map((c) => c.id); } // Filter out fixed columns - they're rendered separately via fixedStartCols/fixedEndCols const fixedIds = new Set([...fixedStartCols, ...fixedEndCols]); @@ -369,9 +370,36 @@ }); // Sort helpers - function toggleSort(field: string) { + // One-char badge for the active I/O sub-metric next to a sortCycle column's label. + // Char + colour mirror the cell glyphs (Disk r=green/w=yellow, Net d=blue/u=orange). + const IO_METRIC_LABELS: Record = { + diskRead: 'r', diskWrite: 'w', netRx: 'd', netTx: 'u' + }; + const IO_METRIC_COLORS: Record = { + diskRead: 'text-green-400', diskWrite: 'text-yellow-400', + netRx: 'text-blue-400', netTx: 'text-orange-400' + }; + function ioMetricLabel(field: string): string { + return IO_METRIC_LABELS[field] ?? ''; + } + function ioMetricColor(field: string): string { + return IO_METRIC_COLORS[field] ?? 'text-primary'; + } + + function toggleSort(colId: string) { if (!onSortChange) return; + const cycle = columnConfigMap.get(colId)?.sortCycle; + if (cycle?.length) { + // A two-metric column (e.g. Disk I/O) cycles read/write x asc/desc (#1111). + onSortChange(nextIoSortState(cycle as any, { + field: sortState?.field ?? '', + direction: sortState?.direction ?? 'asc' + })); + return; + } + + const field = getSortField(colId); const newState: DataGridSortState = sortState?.field === field ? { field, direction: sortState.direction === 'asc' ? 'desc' : 'asc' } : { field, direction: 'asc' }; @@ -769,13 +797,25 @@ {#if headerCell} {@render headerCell(colConfig, sortState)} {:else if isSortable(colId)} + {@const cycleActive = colConfig.sortCycle?.some((s) => s.field === sortState?.field)} + + +

Copy webhook URL

+ {webhookUrl} +
+ + {/if} {:else if column.id === 'location'} {#if source.composePath} {@const dirPath = source.composePath.replace(/\/[^/]+$/, '')} @@ -2183,8 +2238,19 @@ {@const isLoading = containerActionLoading === container.id}
- {#if $appSettings.useSelfhstIcons} - + {#if $appSettings.useSelfhstIcons || iconOverrides[container.name]} + + {:else} {/if} @@ -2698,6 +2764,7 @@ bind:open={showGitModal} gitStack={editingGitStack} environmentId={envId} + icon={editingGitStack ? (stackSources[editingGitStack.stackName]?.icon ?? null) : null} repositories={gitRepositories} credentials={gitCredentials} onClose={() => { diff --git a/tests/io-sort-cycle.test.ts b/tests/io-sort-cycle.test.ts new file mode 100644 index 0000000..162e893 --- /dev/null +++ b/tests/io-sort-cycle.test.ts @@ -0,0 +1,35 @@ +/** + * nextIoSortState: a two-metric grid column (Disk I/O = read/write) cycles a header + * click through all four (metric, direction) states rather than a plain asc/desc + * toggle (#1111). + */ +import { describe, it, expect } from 'bun:test'; +import { nextIoSortState } from '../src/lib/utils/io-sort-cycle'; + +const DISK = [ + { field: 'diskRead', direction: 'desc' as const }, + { field: 'diskRead', direction: 'asc' as const }, + { field: 'diskWrite', direction: 'desc' as const }, + { field: 'diskWrite', direction: 'asc' as const } +]; + +describe('nextIoSortState', () => { + it('starts the cycle at read-desc when the column is not currently sorted', () => { + expect(nextIoSortState(DISK, { field: 'name', direction: 'asc' })).toEqual(DISK[0]); + }); + + it('walks read-desc -> read-asc -> write-desc -> write-asc', () => { + expect(nextIoSortState(DISK, DISK[0])).toEqual(DISK[1]); + expect(nextIoSortState(DISK, DISK[1])).toEqual(DISK[2]); + expect(nextIoSortState(DISK, DISK[2])).toEqual(DISK[3]); + }); + + it('wraps from the last state back to the first', () => { + expect(nextIoSortState(DISK, DISK[3])).toEqual(DISK[0]); + }); + + it('restarts the cycle when the current field is another cycle column', () => { + // e.g. the user was sorting Net I/O, now clicks Disk I/O + expect(nextIoSortState(DISK, { field: 'netRx', direction: 'asc' })).toEqual(DISK[0]); + }); +});