Files
sencho/e2e/screenshots.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

408 lines
16 KiB
TypeScript

/**
* Docs screenshot capture.
*
* Takes canonical screenshots of key UI views and writes them to docs/images/.
* Run manually after a UI change that affects a documented view:
* npx playwright test --project=screenshots
* Then review the diff under docs/images/ and commit on a chore branch.
* The default `playwright test` invocation skips this spec via the
* project-level testIgnore in playwright.config.ts.
*/
import * as fs from 'fs';
import * as path from 'path';
import { expect, test, type Page } from '@playwright/test';
import { loginAs } from './helpers';
import { currentUserId, getPreferences, prefHeaders, putDomain } from './preferences-helpers';
const DOCS_IMAGES = path.resolve(__dirname, '../docs/images');
test.use({
viewport: { width: 1280, height: 800 },
// Always capture - this spec exists solely to produce screenshots
screenshot: 'on',
});
test.beforeAll(() => {
fs.mkdirSync(DOCS_IMAGES, { recursive: true });
});
test('login page', async ({ page }) => {
await page.context().clearCookies();
await page.goto('/');
await page.waitForTimeout(600);
await page.screenshot({ path: path.join(DOCS_IMAGES, 'login.png'), fullPage: true });
});
test('dashboard', async ({ page }) => {
await loginAs(page);
// Wait for stats widgets to settle
await page.waitForTimeout(1_000);
await page.screenshot({ path: path.join(DOCS_IMAGES, 'dashboard.png'), fullPage: true });
});
test('stacks', async ({ page }) => {
await loginAs(page);
await page.getByRole('button', { name: 'Create Stack' }).waitFor({ timeout: 10_000 });
await page.screenshot({ path: path.join(DOCS_IMAGES, 'stacks.png'), fullPage: true });
});
test('resources', async ({ page }) => {
await loginAs(page);
await page.getByRole('button', { name: /resources/i }).click();
await page.waitForTimeout(800);
await page.screenshot({ path: path.join(DOCS_IMAGES, 'resources.png'), fullPage: true });
});
test('stack detail Anatomy resize', async ({ page }) => {
await page.setViewportSize({ width: 1920, height: 1080 });
await loginAs(page);
const stackName = 'docs-pane-layout';
const userId = await currentUserId(page.request);
await expect.poll(async () => {
const preferences = await getPreferences(page.request, userId);
return preferences.preferences.appearance !== null;
}).toBe(true);
const originalAppearance = (await getPreferences(page.request, userId)).preferences.appearance;
if (!originalAppearance) throw new Error('appearance preferences did not initialize');
if (!originalAppearance.data && originalAppearance.schemaVersion !== 0) {
throw new Error('appearance preferences are corrupt');
}
const originalMode = originalAppearance.data?.anatomyMode === 'resizable' ? 'resizable' : 'fixed';
let preferenceMutationStarted = false;
let primaryError: unknown;
try {
await createStack(page, stackName);
await page.getByRole('button', { name: /profile/i }).click();
await page.getByRole('button', { name: 'Settings', exact: true }).click();
await page.getByRole('button', { name: 'Appearance', exact: true }).click();
const anatomyMode = page.getByRole('radiogroup', { name: 'Anatomy panel mode' });
preferenceMutationStarted = true;
if (originalMode !== 'resizable') {
await anatomyMode.getByRole('radio', { name: 'Resizable' }).click();
}
await expect.poll(async () => {
const stored = await getPreferences(page.request, userId);
return stored.preferences.appearance?.data?.anatomyMode;
}).toBe('resizable');
const capturePreferences = (await getPreferences(page.request, userId)).preferences.appearance?.data;
if (!capturePreferences) throw new Error('appearance preferences did not persist');
await putDomain(page.request, userId, 'appearance', { ...capturePreferences, anatomyWidth: 640 });
await page.reload();
await page.locator('[data-testid="stack-row"]').filter({ hasText: stackName }).click();
const separator = page.getByTestId('anatomy-resize-separator');
await expect(separator).toBeVisible();
await expect(page.getByTestId('anatomy-resize-pane')).toHaveAttribute('style', /width: 640px/);
await separator.focus();
await page.screenshot({ path: path.join(DOCS_IMAGES, 'settings', 'appearance-anatomy-layout.png') });
} catch (error) {
primaryError = error;
}
const cleanupErrors: unknown[] = [];
try {
const cleanup = await page.request.delete(`/api/stacks/${stackName}`);
if (!cleanup.ok() && cleanup.status() !== 404) {
throw new Error(`delete ${stackName} failed with ${cleanup.status()}`);
}
} catch (error) {
cleanupErrors.push(error);
}
if (preferenceMutationStarted) {
try {
if (originalAppearance.data) {
await putDomain(page.request, userId, 'appearance', originalAppearance.data);
} else {
const current = (await getPreferences(page.request, userId)).preferences.appearance;
if (!current) throw new Error('appearance preferences disappeared during capture');
const reset = await page.request.delete('/api/user-preferences/appearance', {
headers: prefHeaders(userId),
data: { expectedRevision: current.revision },
});
if (!reset.ok()) throw new Error(`reset appearance preferences failed with ${reset.status()}`);
}
} catch (error) {
cleanupErrors.push(error);
}
}
const failures = primaryError === undefined ? cleanupErrors : [primaryError, ...cleanupErrors];
if (failures.length === 1) throw failures[0];
if (failures.length > 1) throw new AggregateError(failures, 'Screenshot capture and cleanup failed');
});
test('sso settings', async ({ page }) => {
await page.setViewportSize({ width: 1920, height: 1080 });
await loginAs(page);
await page.getByRole('button', { name: /profile/i }).click();
await page.getByRole('button', { name: 'Settings', exact: true }).click();
await page.getByText('SSO', { exact: true }).first().click();
// The role-sync switch confirms the SSO panel (admin-only) has loaded; it
// only renders once GET /sso/config/role-sync resolves, so wait for the
// switch itself, not just the adjacent label text.
const roleSyncSwitch = page.getByRole('switch', { name: 'IdP role synchronization' });
await expect(roleSyncSwitch).toBeVisible();
await roleSyncSwitch.scrollIntoViewIfNeeded();
// The SSO panel lives in a fixed-height Radix scroll area, so a plain
// fullPage capture clips content below the fold. Expand the viewport (and
// its overflow-hidden root) so the whole panel, including the role-sync
// control, is captured.
await roleSyncSwitch.evaluate((el) => {
const viewport = el.closest<HTMLElement>('[data-radix-scroll-area-viewport]');
if (!viewport) return;
viewport.style.height = 'auto';
viewport.style.overflow = 'visible';
const root = viewport.parentElement;
if (root) {
root.style.height = 'auto';
root.style.overflow = 'visible';
}
});
await page.waitForTimeout(300);
await page.screenshot({ path: path.join(DOCS_IMAGES, 'sso', 'sso-settings.png'), fullPage: true });
});
function emptyCounts() {
return {
add: 0, modify: 0, delete: 0, rename: 0, unchanged: 0,
localModified: 0, localMissing: 0, typeChanged: 0, unmanagedCollision: 0, invocation: 0,
};
}
function linkedSource(stackName: string) {
return {
id: 1,
stack_name: stackName,
repo_url: 'https://github.com/example/compose.git',
branch: 'main',
compose_path: 'compose.yaml',
compose_paths: ['compose.yaml'],
sync_env: false,
env_path: null,
auth_type: 'none',
has_token: false,
auto_apply_on_webhook: false,
auto_deploy_on_apply: false,
last_applied_commit_sha: '1111111111111111111111111111111111111111',
pending_commit_sha: null,
pending_fetched_at: null,
created_at: 0,
updated_at: 0,
manifest_state: 'active',
manifest: null,
};
}
async function createStack(page: Page, stackName: string) {
const priorDelete = await page.request.delete(`/api/stacks/${stackName}`);
if (!priorDelete.ok() && priorDelete.status() !== 404) {
throw new Error(`delete existing ${stackName} failed with ${priorDelete.status()}`);
}
const create = await page.request.post('/api/stacks', { data: { stackName } });
if (!create.ok()) throw new Error(`create ${stackName} failed with ${create.status()}`);
}
async function stubGitSourceAndPull(page: Page, stackName: string, pullBody: unknown) {
await page.route('**/git-source/pull', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(pullBody),
});
});
await page.route(new RegExp(`/api/stacks/${stackName}/git-source$`), async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(linkedSource(stackName)),
});
return;
}
await route.continue();
});
}
async function openStubbedChangePlan(page: Page, stackName: string) {
await page.getByRole('button', { name: 'Create Stack' }).waitFor({ timeout: 15_000 });
await page.getByText(stackName).first().click();
await page.getByRole('button', { name: /Git Source/i }).click();
await expect(page.getByRole('dialog').getByRole('heading', { name: /git source/i })).toBeVisible();
await page.getByRole('button', { name: /Pull now/i }).click();
await expect(page.getByTestId('git-plan-op').first()).toBeVisible({ timeout: 10_000 });
}
test.describe('git source authentication docs screenshots', () => {
test.use({ viewport: { width: 1920, height: 1080 } });
test('git source panel with deploy key controls', async ({ page }) => {
await loginAs(page);
const stackName = 'docs-git-panel';
await createStack(page, stackName);
await page.goto('/');
await page.getByText(stackName).first().click();
await page.getByRole('button', { name: /Git Source/i }).click();
await expect(page.getByRole('dialog').getByRole('heading', { name: /git source/i })).toBeVisible();
await page.locator('#git-source-repo').fill('git@github.com:example/demo.git');
await page.getByRole('button', { name: 'Deploy key (SSH)' }).click();
await expect(page.getByRole('button', { name: 'Fetch host key fingerprint' })).toBeVisible();
const dialog = page.getByRole('dialog');
await dialog.screenshot({
path: path.join(DOCS_IMAGES, 'git-sources', 'panel.png'),
});
await page.evaluate(async (name) => {
await fetch(`/api/stacks/${name}`, { method: 'DELETE', credentials: 'include' }).catch(() => {});
}, stackName);
});
test('tutorial empty git source sheet', async ({ page }) => {
await loginAs(page);
const stackName = 'marketing-site';
await createStack(page, stackName);
await page.goto('/');
await page.getByText(stackName).first().click();
await page.getByRole('button', { name: /Git Source/i }).click();
await expect(page.getByRole('dialog').getByRole('heading', { name: /git source/i })).toBeVisible();
await expect(page.getByRole('navigation', { name: /sheet location/i })).toContainText('Git source');
const dialog = page.getByRole('dialog').filter({ has: page.getByRole('heading', { name: /git source/i }) });
await dialog.screenshot({
path: path.join(DOCS_IMAGES, 'tutorials', 'connect-a-git-source', 'git-source-panel-empty.png'),
});
await page.evaluate(async (name) => {
await fetch(`/api/stacks/${name}`, { method: 'DELETE', credentials: 'include' }).catch(() => {});
}, stackName);
});
test('create stack from git tab with deploy key options', async ({ page }) => {
await loginAs(page);
await page.getByRole('button', { name: 'Create Stack' }).click();
const dialog = page.getByRole('dialog');
await dialog.getByRole('tab', { name: 'From Git' }).click();
await expect(dialog.getByText('HTTPS OR SSH REPOS')).toBeVisible();
await dialog.getByRole('button', { name: 'Deploy key (SSH)' }).click();
await expect(dialog.getByRole('button', { name: 'Fetch host key fingerprint' })).toBeVisible();
await dialog.screenshot({
path: path.join(DOCS_IMAGES, 'git-sources', 'create-from-git-tab.png'),
});
await dialog.screenshot({
path: path.join(DOCS_IMAGES, 'stack-management', 'create-stack-git.png'),
});
});
});
test.describe('classified change-plan docs screenshots', () => {
test.use({ viewport: { width: 1920, height: 1080 } });
test('git-sources change plan dialog', async ({ page }) => {
await loginAs(page);
const stackName = 'demo-app';
await createStack(page, stackName);
await stubGitSourceAndPull(page, stackName, {
commitSha: 'c0ffee12c0ffee12c0ffee12c0ffee12c0ffee12',
validation: { ok: true },
refusals: [],
warnings: [],
plan: {
blocked: false,
counts: { ...emptyCounts(), add: 1, modify: 1, delete: 1, unchanged: 2 },
operations: [
{ path: 'added.conf', op: 'add', role: 'config' },
{ path: 'compose.yaml', op: 'modify', role: 'compose-primary' },
{ path: 'extra.conf', op: 'delete', role: 'config' },
],
invocation: { candidateChanged: false, liveDiverged: false },
},
planFingerprint: 'fp-demo-docs',
});
await page.goto('/');
await openStubbedChangePlan(page, stackName);
const planDialog = page.getByRole('dialog').filter({ hasText: 'GIT · CHANGE PLAN' });
await expect(planDialog).toBeVisible();
await planDialog.screenshot({
path: path.join(DOCS_IMAGES, 'git-sources', 'diff-dialog.png'),
});
await page.evaluate(async (name) => {
await fetch(`/api/stacks/${name}`, { method: 'DELETE', credentials: 'include' }).catch(() => {});
}, stackName);
});
test('tutorial pull change plan dialog', async ({ page }) => {
await loginAs(page);
const stackName = 'marketing-site';
await createStack(page, stackName);
await stubGitSourceAndPull(page, stackName, {
commitSha: 'a1b2c3da1b2c3da1b2c3da1b2c3da1b2c3da1b2c',
validation: { ok: true },
refusals: [],
warnings: [],
plan: {
blocked: false,
counts: { ...emptyCounts(), modify: 1, unchanged: 0 },
operations: [
{ path: 'compose.yaml', op: 'modify', role: 'compose-primary' },
],
invocation: { candidateChanged: false, liveDiverged: false },
},
planFingerprint: 'fp-marketing-docs',
});
await page.goto('/');
await openStubbedChangePlan(page, stackName);
const planDialog = page.getByRole('dialog').filter({ hasText: 'GIT · CHANGE PLAN' });
await expect(planDialog).toBeVisible();
await planDialog.screenshot({
path: path.join(DOCS_IMAGES, 'tutorials', 'connect-a-git-source', 'pull-preview-diff.png'),
});
await page.evaluate(async (name) => {
await fetch(`/api/stacks/${name}`, { method: 'DELETE', credentials: 'include' }).catch(() => {});
}, stackName);
});
});
test.describe('resources prune confirm docs screenshot', () => {
test.use({ viewport: { width: 1920, height: 1080 } });
test('prune confirm lists extra repository tags', 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({
scope: 'managed',
targets: ['images'],
items: [{
target: 'images',
id: 'sha256:docs-prune-multi-tag',
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-docs-prune-confirm',
createdAt: 1_700_000_000_000,
nodeId: 1,
}),
});
});
await loginAs(page);
await page.getByRole('button', { name: /resources/i }).click();
await page.getByRole('button', { name: 'Prune unused' }).click();
const pruneDialog = page.getByRole('alertdialog').filter({ hasText: 'Prune Sencho-managed images' });
await expect(pruneDialog).toBeVisible();
await expect(pruneDialog.getByText('ghcr.io/example/app:latest')).toBeVisible();
await pruneDialog.screenshot({
path: path.join(DOCS_IMAGES, 'resources', 'resources-prune-confirm.png'),
});
});
});