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.
408 lines
16 KiB
TypeScript
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'),
|
|
});
|
|
});
|
|
});
|