Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [1.5.1-rc.5] — 2026-07-02

### Fixed

- **Grouped "Update All" buttons could scroll out of view after upgrading to rc.4.** The new Version column added in rc.4 widened the containers table for existing users, and at moderate desktop widths the table overflowed horizontally in a way its responsive column-hiding never accounted for: columns were only ever laid out at their preferred widths, so the per-stack **Update All** button — positioned at the far end of the group header row — ended up past the visible edge while everything else looked normal. Tables now shrink columns proportionally toward their minimum widths when space is tight (matching the widths the responsive logic already budgets for), and the group header's Update All button is additionally pinned to the visible edge, so it stays reachable even when a table legitimately overflows. (#467)

## [1.5.1-rc.4] — 2026-06-29

### Added
Expand Down
70 changes: 66 additions & 4 deletions ui/src/components/DataTable.vue
Original file line number Diff line number Diff line change
Expand Up @@ -193,8 +193,64 @@ const normalizedColumns = computed(() =>
})),
);

interface ColumnWidthEntry {
key: string;
width: number;
sizing: NormalizedTableColumnSizing;
}

// Shrinks columns proportionally toward (never below) their minSize using water-filling: on
// each pass, the deficit is distributed across still-shrinkable columns weighted by remaining
// headroom (width - minSize). Columns whose proportional share would take them past their floor
// are clamped to minSize and dropped from the pool; whatever they couldn't absorb is
// redistributed among the columns that still have headroom left. This guarantees resolved
// widths never fall below minSize while eliminating the overflow whenever total headroom covers
// the deficit (see #467 — rendered widths must agree with the auto-hide budget's minSize-based
// "fits" decision). When total headroom is smaller than the deficit, every shrinkable column
// bottoms out at minSize and the remaining overflow is left for auto-hide to resolve.
function shrinkColumnWidthsToFit(
base: ColumnWidthEntry[],
widthMap: Record<string, number>,
deficit: number,
): void {
const widths = new Map(base.map((entry) => [entry.key, entry.width]));
let shrinkable = base.filter((entry) => entry.width > entry.sizing.minSize);
let remainingDeficit = deficit;

while (remainingDeficit > 0.5 && shrinkable.length > 0) {
const totalHeadroom = shrinkable.reduce(
(acc, entry) => acc + ((widths.get(entry.key) ?? 0) - entry.sizing.minSize),
0,
);
if (totalHeadroom <= 0) {
break;
}

let absorbed = 0;
const stillShrinkable: ColumnWidthEntry[] = [];
for (const entry of shrinkable) {
const width = widths.get(entry.key) ?? 0;
const headroom = width - entry.sizing.minSize;
const shrinkBy = Math.min(remainingDeficit * (headroom / totalHeadroom), headroom);
const nextWidth = width - shrinkBy;
widths.set(entry.key, nextWidth);
absorbed += shrinkBy;
if (nextWidth - entry.sizing.minSize > 0.5) {
stillShrinkable.push(entry);
}
}
remainingDeficit -= absorbed;
shrinkable = stillShrinkable;
}

for (const entry of base) {
const width = widths.get(entry.key) ?? entry.width;
widthMap[entry.key] = Math.max(entry.sizing.minSize, Math.floor(width));
}
}

function resolveColumnWidths(): Record<string, number> {
const base = normalizedColumns.value.map(({ column, sizing }) => {
const base: ColumnWidthEntry[] = normalizedColumns.value.map(({ column, sizing }) => {
const manual = liveColumnWidths[column.key];
const persisted = getPersistedColumnWidth(column.key);
const width = manual ?? persisted ?? sizing.size;
Expand All @@ -207,15 +263,21 @@ function resolveColumnWidths(): Record<string, number> {

const widthMap = Object.fromEntries(base.map((entry) => [entry.key, entry.width]));
const available = viewportWidth.value;
const flexColumns = base.filter((entry) => entry.sizing.flex > 0);
if (available <= 0 || flexColumns.length === 0) {
if (available <= 0) {
return widthMap;
}

const actionsWidth = props.showActions ? actionsColumn.value.resolvedWidth : 0;
const totalBaseWidth = base.reduce((acc, entry) => acc + entry.width, 0) + actionsWidth;
const extra = available - totalBaseWidth;
if (extra <= 0) {

if (extra < 0) {
shrinkColumnWidthsToFit(base, widthMap, -extra);
return widthMap;
}

const flexColumns = base.filter((entry) => entry.sizing.flex > 0);
if (extra <= 0 || flexColumns.length === 0) {
return widthMap;
}

Expand Down
93 changes: 49 additions & 44 deletions ui/src/components/containers/ContainersGroupHeader.vue
Original file line number Diff line number Diff line change
Expand Up @@ -51,52 +51,57 @@ const emit = defineEmits<{
<AppBadge v-if="group.updatesAvailable > 0" tone="success" size="xs">
{{ group.updatesAvailable }} {{ group.updatesAvailable === 1 ? t('containerComponents.groupHeader.updateSingular') : t('containerComponents.groupHeader.updatePlural') }}
</AppBadge>
<AppButton
<div
v-if="group.updatesAvailable > 0 || !containerActionsEnabled"
size="compact"
:variant="
!containerActionsEnabled || group.updatableCount === 0 || inProgress
? 'muted-subtle'
: 'success'
"
weight="semibold"
class="ml-auto inline-flex items-center justify-center"
:class="
!containerActionsEnabled || inProgress
? 'cursor-not-allowed'
: ''
"
:disabled="!containerActionsEnabled || group.updatableCount === 0 || inProgress"
v-tooltip.top="
tt(
!containerActionsEnabled
? containerActionsDisabledReason
: group.updatableCount === 0
? t('containerComponents.groupHeader.allBlockedTooltip')
: t('containerComponents.groupHeader.updateAllInGroupTooltip'),
)
"
@click.stop="emit('updateAll', group)"
data-test="group-header-update-all-sticky"
class="ms-auto sticky end-0 z-10 flex items-center"
>
<AppIcon
:name="
!containerActionsEnabled || group.updatableCount === 0
? 'lock'
: inProgress
? 'spinner'
: 'cloud-download'
<AppButton
size="compact"
:variant="
!containerActionsEnabled || group.updatableCount === 0 || inProgress
? 'muted-subtle'
: 'success'
"
weight="semibold"
class="inline-flex items-center justify-center"
:class="
!containerActionsEnabled || inProgress
? 'cursor-not-allowed'
: ''
"
:size="14"
class="mr-1"
:class="!containerActionsEnabled ? '' : inProgress ? 'dd-spin' : ''"
/>
{{
!containerActionsEnabled
? t('containerComponents.groupHeader.actionsDisabled')
: inProgress && frozenTotal !== undefined && doneCount !== undefined && frozenTotal >= 2
? t('containerComponents.groupHeader.updatingStack', { done: doneCount, total: frozenTotal })
: t('containerComponents.groupHeader.updateAll')
}}
</AppButton>
:disabled="!containerActionsEnabled || group.updatableCount === 0 || inProgress"
v-tooltip.top="
tt(
!containerActionsEnabled
? containerActionsDisabledReason
: group.updatableCount === 0
? t('containerComponents.groupHeader.allBlockedTooltip')
: t('containerComponents.groupHeader.updateAllInGroupTooltip'),
)
"
@click.stop="emit('updateAll', group)"
>
<AppIcon
:name="
!containerActionsEnabled || group.updatableCount === 0
? 'lock'
: inProgress
? 'spinner'
: 'cloud-download'
"
:size="14"
class="mr-1"
:class="!containerActionsEnabled ? '' : inProgress ? 'dd-spin' : ''"
/>
{{
!containerActionsEnabled
? t('containerComponents.groupHeader.actionsDisabled')
: inProgress && frozenTotal !== undefined && doneCount !== undefined && frozenTotal >= 2
? t('containerComponents.groupHeader.updatingStack', { done: doneCount, total: frozenTotal })
: t('containerComponents.groupHeader.updateAll')
}}
</AppButton>
</div>
</div>
</template>
103 changes: 103 additions & 0 deletions ui/tests/components/DataTable.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -565,6 +565,109 @@ describe('DataTable', () => {
});
});

describe('column width shrink-to-fit (#467)', () => {
function setViewportWidth(w: ReturnType<typeof mount>, width: number) {
const scrollEl = w.find('.overflow-x-auto').element as HTMLElement;
Object.defineProperty(scrollEl, 'clientWidth', { configurable: true, value: width });
window.dispatchEvent(new Event('resize'));
}

function columnWidths(w: ReturnType<typeof mount>): Record<string, number> {
const widths: Record<string, number> = {};
for (const col of w.findAll('colgroup col')) {
const key = col.attributes('data-col-key');
const match = (col.attributes('style') ?? '').match(/width:\s*([0-9.]+)px/);
if (key && match) {
widths[key] = Number.parseFloat(match[1]);
}
}
return widths;
}

// Mirrors the rc.4 default containers column set after the v6->v7 preferences migration
// force-adds `softwareVersion` (220 size / 150 minSize) to every existing user's column
// set: default-size sum grows from 1264px to 1484px while the minSize sum stays 1136px.
const regressionColumns = [
{ key: 'icon', label: '', icon: true, size: 40, minSize: 40, maxSize: 40 },
{ key: 'name', label: 'Container', size: 360, minSize: 220, maxSize: 640, flex: 1 },
{ key: 'version', label: 'Tag', size: 220, minSize: 150, maxSize: 320 },
{ key: 'softwareVersion', label: 'Version', size: 220, minSize: 150, maxSize: 320 },
{ key: 'kind', label: 'Update', size: 128, minSize: 116, maxSize: 180 },
{ key: 'status', label: 'Status', size: 118, minSize: 112, maxSize: 160 },
{ key: 'server', label: 'Host', size: 152, minSize: 132, maxSize: 240 },
{ key: 'registry', label: 'Registry', size: 126, minSize: 116, maxSize: 180 },
{ key: 'uptime', label: 'Uptime', size: 120, minSize: 100, maxSize: 180 },
];

it('shrinks columns to fit when size-sum overflows the available width but minSize-sum still fits (#467)', async () => {
// available (1300) sits inside the 1136 (minSize-sum) .. 1484 (size-sum) overflow band —
// the exact divergence window where rc.4 rendered wider than the auto-hide budget allowed.
const available = 1300;
const w = mount(DataTable, {
props: { columns: regressionColumns, rows: [], rowKey: 'id' },
global: { stubs: { AppIcon: { template: '<span />' } } },
});
setViewportWidth(w, available);
await nextTick();

const widths = columnWidths(w);
const total = Object.values(widths).reduce((sum, width) => sum + width, 0);

expect(total).toBeLessThanOrEqual(available);
for (const col of regressionColumns) {
expect(widths[col.key]).toBeGreaterThanOrEqual(col.minSize);
}
// Confirms shrink actually engaged rather than silently no-op'ing.
expect(widths.softwareVersion).toBeLessThan(220);
expect(widths.name).toBeLessThan(360);
});

it('floors shrunk columns at minSize and leaves the remainder for auto-hide when available is below the minSize-sum (#467)', async () => {
// available (1000) is below the 1136px minSize-sum entirely, so every shrinkable column
// must bottom out at exactly minSize — no negative or sub-minSize widths — and the table
// legitimately keeps overflowing; resolving that residual overflow is auto-hide's job, not
// DataTable's.
const available = 1000;
const w = mount(DataTable, {
props: { columns: regressionColumns, rows: [], rowKey: 'id' },
global: { stubs: { AppIcon: { template: '<span />' } } },
});
setViewportWidth(w, available);
await nextTick();

const widths = columnWidths(w);
const total = Object.values(widths).reduce((sum, width) => sum + width, 0);

for (const col of regressionColumns) {
expect(widths[col.key]).toBe(col.minSize);
}
expect(total).toBeGreaterThan(available);
});

it('distributes extra space across flex columns exactly as before when the layout has room to expand', async () => {
const w = mount(DataTable, {
props: {
columns: [
{ key: 'name', label: 'Name', size: 300, minSize: 200, maxSize: 640, flex: 1 },
{ key: 'status', label: 'Status', size: 120, minSize: 100, maxSize: 200 },
],
rows: [],
rowKey: 'id',
},
global: { stubs: { AppIcon: { template: '<span />' } } },
});
// totalBaseWidth = 300 + 120 = 420; extra = 500 - 420 = 80, all absorbed by the sole flex
// column exactly as resolveColumnWidths() distributed it before the #467 shrink path
// existed.
setViewportWidth(w, 500);
await nextTick();

const widths = columnWidths(w);
expect(widths.name).toBe(380);
expect(widths.status).toBe(120);
});
});

describe('column resize performance', () => {
it('renders resize movement through colgroup instead of header width attributes', async () => {
const resizeColumns = [
Expand Down
14 changes: 14 additions & 0 deletions ui/tests/components/containers/ContainersGroupHeader.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,4 +87,18 @@ describe('ContainersGroupHeader', () => {
expect(wrapper.text()).toContain('Update all');
expect(wrapper.text()).not.toContain('Updating stack');
});

// #467: on rc.4, existing users' default column set overflows the table at moderate desktop
// widths (see DataTable.spec.ts "column width shrink-to-fit (#467)"), and the group header's
// full-row <td> renders as wide as the overflowing row. Without sticky positioning, the
// "Update all" button scrolls out of the visible area. Pin it to the logical end edge (RTL
// safe) so it stays reachable regardless of overflow.
it('pins the update-all action to the end edge so it stays visible when the row overflows (#467)', () => {
const wrapper = mountHeader();
const sticky = wrapper.find('[data-test="group-header-update-all-sticky"]');

expect(sticky.exists()).toBe(true);
expect(sticky.classes()).toEqual(expect.arrayContaining(['sticky', 'end-0']));
expect(sticky.find('button').exists()).toBe(true);
});
});
Loading