Files
sencho/e2e/modal-title-wrap.spec.ts
Anso 935158b5e6 feat(resources): bring the Resources page to the design system (#2156)
* fix(resources): load the Resources page and volume prune without the slow image df

On a host that uses the containerd image store, the image section of
`docker system df` takes about 30 seconds, and the Resources page waited
on it for its footprint figures. Pruning volumes repeated that full df
once per volume, and the prune preview ran into its 12 second timeout.

- The disk-usage figures now come from calls that answer in milliseconds:
  a shared-size image listing plus the volume and container sections of
  df. The reclaimable image figure is the bytes unused images do not
  share with another image; the exact df is used only when the daemon
  does not report shared sizes.
- External and managed volume bytes were always zero because the volume
  listing carries no usage data. They now come from the volume section.
- Prune planning and execution read only the volume section for volume
  usage, ask for container sizes only when containers are planned, take
  shared image sizes from the listing, and inspect idle networks in
  parallel. When volume usage cannot be read the plan fails with an
  error instead of reading as nothing to prune, and execution skips the
  volume with a clear reason.
- The Resources page loads the tables and the disk usage separately, so
  the tables no longer wait on the figures. Refreshing after an action
  keeps the rows on screen, and a failed load shows an error with Retry
  instead of an empty table.

On a node with 200 volumes and 85 images, the disk-usage request went
from about 31 seconds to 0.2 seconds, prune previews from a 12 second
timeout to under 0.4 seconds, and pruning 200 volumes now takes about
1.5 seconds.

* feat(resources): bring the Resources page to the design system

The Resources page had a reclaim banner behind an opt-in setting, a disk
footprint card, four equal-weight Quick Clean tiles, and per-node chrome
of its own. It now follows the same shape as the other primary pages.

- A hero masthead gives the one answer: how much a cleanup would free,
  Tidy when nothing is left, Measuring disk while usage loads, and Disk
  usage unknown when the node could not report it (never Tidy). Managed
  and external bytes sit beside it.
- Reclaim and a cleanup menu (prune unused networks, prune stopped
  containers) sit in the tab strip. Images and Volumes each have their
  own Prune unused. Every prune opens an itemized preview with a scope
  switch (Sencho-managed by default, or All Docker) that rebuilds the
  plan when it changes.
- The reclaim banner and its opt-in setting are removed, along with the
  footprint card and the Quick Clean tiles.
- Filters use the shared segmented control, scans use the shared scan
  flow, and confirm buttons show progress through the shared busy label.
- The Unmanaged tab is named External. Its rows use checkboxes and its
  project headers are quiet rows. A Rollback protected badge on a
  stack-held image opens the Rollback tab.
- The docs page, the pages that mention the old tiles, and the
  screenshots are updated.
2026-10-20 09:51:36 -04:00

121 lines
4.9 KiB
TypeScript

import { test, expect } from '@playwright/test';
import { loginAs } from './helpers';
// Prune-plan fixture so the confirmation dialog renders deterministically.
// The dialog is opened but never confirmed; the plan endpoint is a read-only
// plan computation, so this spec performs no Docker mutation.
const PRUNE_PLAN_FIXTURE = {
scope: 'managed',
targets: ['images'],
items: [{
target: 'images',
id: 'sha256:title-wrap-fixture',
name: 'ghcr.io/example/app:1.5.2',
sizeBytes: 48234496,
managed: true,
reason: 'Image is not used by any container',
image: {
references: [
'ghcr.io/example/app:1.5.2',
'ghcr.io/example/app:latest',
],
},
}],
reclaimableBytes: 48234496,
fingerprint: 'fp-title-wrap',
createdAt: 1_700_000_000_000,
nodeId: 1,
};
// Geometry fixture for the shared confirm-dialog header at the sm width
// (max-w-sm = 384px; header padding px-6 pt-6 pb-4 pr-12, so the title box
// is 312px wide). No app dialog accepts an arbitrary long title, so the
// long-token and long-multi-word cases render the exact final CSS strategy
// the shared HeaderShell emits (overflow-wrap: break-word + text-wrap:
// balance) at that geometry. The component emitting these classes is
// asserted in the vitest suite, and the real-app prune dialog below asserts
// the computed styles end to end; this fixture validates the layout result.
const HEADER_GEOMETRY_FIXTURE = (title: string) => `
<div id="header" style="max-width:384px;padding:24px 48px 16px 24px;">
<h2 id="title" style="margin-top:4px;font-size:28px;line-height:1.25;overflow-wrap:break-word;text-wrap:balance;">${title}</h2>
</div>
`;
test.describe('Modal title wrapping contract', () => {
test.use({ viewport: { width: 1920, height: 1080 } });
test.beforeEach(async ({ page }) => {
await page.route('**/system/prune/plan', async (route) => {
if (route.request().method() !== 'POST') {
await route.continue();
return;
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(PRUNE_PLAN_FIXTURE),
});
});
await loginAs(page);
await page.getByRole('button', { name: /resources/i }).click();
await page.getByRole('button', { name: 'Prune unused' }).click();
});
test('prune confirmation wraps on word boundaries with no orphaned letters', async ({ page }) => {
const title = page.getByRole('heading', { name: 'Prune Sencho-managed images' });
await expect(title).toBeVisible();
const metrics = await title.evaluate((el) => ({
scrollWidth: el.scrollWidth,
clientWidth: el.clientWidth,
wordBreak: getComputedStyle(el).wordBreak,
overflowWrap: getComputedStyle(el).overflowWrap,
textWrap: getComputedStyle(el).textWrap,
}));
expect(metrics.wordBreak).not.toBe('break-all');
expect(metrics.overflowWrap).toBe('break-word');
expect(metrics.textWrap).toBe('balance');
expect(metrics.scrollWidth).toBeLessThanOrEqual(metrics.clientWidth);
});
test('dialog stays bounded with the confirm action reachable', async ({ page }) => {
const dialog = page.getByRole('alertdialog').filter({ hasText: 'Prune Sencho-managed images' });
await expect(dialog).toBeVisible();
await expect(dialog.getByRole('button', { name: /Prune/ })).toBeVisible();
const bounded = await dialog.evaluate((el) => el.scrollHeight <= el.clientHeight + 1);
expect(bounded).toBe(true);
});
});
test.describe('Header geometry: long titles', () => {
test('long unbroken token does not overflow the title box', async ({ page }) => {
await page.setContent(HEADER_GEOMETRY_FIXTURE('x'.repeat(200)));
const metrics = await page.locator('#title').evaluate((el) => ({
scrollWidth: el.scrollWidth,
clientWidth: el.clientWidth,
}));
expect(metrics.scrollWidth).toBeLessThanOrEqual(metrics.clientWidth);
});
test('long multi-word title wraps into balanced lines with no orphan', async ({ page }) => {
await page.setContent(HEADER_GEOMETRY_FIXTURE('Prune Sencho-managed containers'));
const metrics = await page.locator('#title').evaluate((el) => {
const range = document.createRange();
range.selectNodeContents(el);
const rects = Array.from(range.getClientRects()).filter((r) => r.width > 0);
return {
lineCount: rects.length,
firstLineWidth: rects[0]?.width ?? 0,
lastLineWidth: rects[rects.length - 1]?.width ?? 0,
scrollWidth: el.scrollWidth,
clientWidth: el.clientWidth,
};
});
expect(metrics.lineCount).toBeGreaterThanOrEqual(2);
// Balanced wrapping: the shorter line is a substantial fraction of the
// longer one, not a single orphaned letter hanging on its own line.
const [longer, shorter] = [metrics.firstLineWidth, metrics.lastLineWidth].sort((a, b) => b - a);
expect(shorter / longer).toBeGreaterThan(0.5);
expect(metrics.scrollWidth).toBeLessThanOrEqual(metrics.clientWidth);
});
});