mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-10-25 13:11:05 +00:00
935158b5e6
* 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.
121 lines
4.9 KiB
TypeScript
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);
|
|
});
|
|
});
|