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
2 changes: 1 addition & 1 deletion e2e/specs/app/vps_detail_tabs_matrix.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,7 +319,7 @@ test('@workflow-matrix @pr-smoke @pr-smoke-mobile VPS admin overview keeps each
await expect(resources).toBeVisible();
await expect(resources.getByRole('link', { name: 'Edit resources' })).toHaveAttribute(
'href',
'/admin/vps/123/config?user=10',
'/admin/vps/123/config?user=10&section=resources',
);
await expect(page.getByTestId('vps.overview.resources_usage.runtime')).toBeVisible();
await expect(page.getByTestId('vps.overview.status_access.card')).toHaveCount(0);
Expand Down
188 changes: 188 additions & 0 deletions e2e/specs/app/vps_resource_workspace.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import { expect, test, type Page } from '@playwright/test';
import { bootstrapVpsAdminWindow, installHaveApiMock, jsonFulfill, setUiSettingsLocalStorage } from '../../fixtures';

// Synthetic API fixtures: these tests never mutate a real VPS or dataset.
async function setup(page: Page, options: { level?: number; language?: 'cs' | 'en' } = {}) {
await bootstrapVpsAdminWindow(page, { sessionToken: 'TEST' });
await setUiSettingsLocalStorage(page, { language: options.language ?? 'en' });
const state = {
vps: { id: 123, hostname: 'example.test', object_state: 'active', is_running: true, manage_hostname: true,
cpu: 2, memory: 2048, swap: 0, cpu_limit: 100, diskspace: 20480, allow_admin_modifications: true,
dataset: { id: 10, name: 'root' }, user: { id: 42, login: 'member' }, node: { id: 1, domain_name: 'node.test' } },
dataset: { id: 10, name: 'root', full_name: 'tank/root', refquota: 20480, used: 5120, object_state: 'active', user: { id: 42 } },
diskWrites: [] as unknown[], vpsWrites: [] as unknown[],
failDisk: false, missingTask: false, busyDataset: false,
};
const mock = await installHaveApiMock(page, {
user: { id: 42, login: 'admin', level: options.level ?? 99 },
handlers: {
'GET vpses/123': () => ({ vps: state.vps }),
'GET datasets/10': () => ({ dataset: state.dataset }),
'GET ip_addresses': () => ({ ip_addresses: [] }),
'GET dns_resolvers': () => ({ dns_resolvers: [] }),
'GET user_namespace_maps': () => ({ user_namespace_maps: [] }),
'GET transaction_chains': ctx => ({ transaction_chains: state.busyDataset && ctx.searchParams.get('transaction_chain[class_name]') === 'Dataset' ? [{ id: 1, state: 'queued' }] : [] }),
'PUT datasets/10': ctx => {
state.diskWrites.push(ctx.json);
if (state.failDisk) return jsonFulfill({ status: false, message: 'Resource allocation failed', response: null }, 422);
if (state.missingTask) return {};
Object.assign(state.dataset, (ctx.json as any).dataset);
return { _meta: { action_state_id: 901 } };
},
'PUT vpses/123': ctx => {
state.vpsWrites.push(ctx.json);
Object.assign(state.vps, (ctx.json as any).vps);
return { vps: state.vps, _meta: { action_state_id: 902 } };
},
'GET action_states/901': () => ({ action_state: { id: 901, finished: true, status: true, current: 1, total: 1 } }),
'GET action_states/902': () => ({ action_state: { id: 902, finished: true, status: true, current: 1, total: 1 } }),
},
});
return { state, mock };
}
const disk = (page: Page) => page.getByTestId('vps.resources.disk.size');
const memory = (page: Page) => page.getByRole('spinbutton', { name: /^Memory \(MiB\)/ });
async function submitDisk(page: Page) {
await page.getByTestId('vps.resources.disk.save').click();
await page.getByTestId('vps.resources.disk.confirm.confirm').click();
}
async function submitVps(page: Page) {
await page.getByTestId('vps.config.header.save').click();
await page.getByRole('button', { name: 'Save', exact: true }).click();
}

test.describe('@pr-smoke @pr-smoke-mobile VPS resource workspace', () => {
for (const language of ['cs', 'en'] as const) {
test(`opens focused resources from overview, ${language}`, async ({ page }, testInfo) => {
await setup(page, { language });
await page.goto('/admin/vps/123');
const entry = page.getByTestId('vps.overview.resources_usage.card').getByRole('link');
await expect(entry).toHaveAttribute('href', /\/config\?section=resources/);
await entry.click();
await expect(disk(page)).toHaveValue('20');
await expect(page.getByTestId('vps.config.additional')).not.toHaveAttribute('open', '');
await expect(page.getByTestId('vps.config.review')).toHaveCount(0);
await page.getByTestId('vps.resources.workspace').scrollIntoViewIfNeeded();
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
await page.evaluate(() => window.scrollTo(0, 0));
await page.screenshot({ path: testInfo.outputPath(`resources-${language}.png`), fullPage: true });
await page.getByTestId('vps.config.additional').locator('summary').click();
await expect(page.getByTestId('vps.config.additional')).toHaveAttribute('open', '');
});
}

for (const first of ['disk', 'vps']) {
test(`preserves the other draft when saving ${first} first`, async ({ page }) => {
const { state } = await setup(page);
await page.goto('/admin/vps/123/config?section=resources');
await memory(page).fill('4096');
await disk(page).fill('32');
if (first === 'disk') {
await submitDisk(page);
await expect(page.getByTestId('vps.resources.disk.confirm')).toBeHidden();
await expect(memory(page)).toHaveValue('4096');
expect(state.vpsWrites).toEqual([]);
await submitVps(page);
} else {
await submitVps(page);
await expect(page.getByRole('button', { name: 'Cancel', exact: true })).toHaveCount(0);
await expect(disk(page)).toHaveValue('32');
expect(state.diskWrites).toEqual([]);
await submitDisk(page);
}
await expect.poll(() => state.diskWrites).toEqual([{ dataset: { refquota: 32768 } }]);
await expect.poll(() => state.vpsWrites).toEqual([{ vps: { memory: 4096 } }]);
});
}

test('keeps both drafts after allocation failure and retries with explicit disk override', async ({ page }) => {
const { state } = await setup(page);
state.failDisk = true;
await page.goto('/admin/vps/123/config?section=resources');
await memory(page).fill('4096');
await disk(page).fill('32');
await submitDisk(page);
await expect(page.getByTestId('vps.resources.disk.confirm')).toContainText('Resource allocation failed');
await page.getByTestId('vps.resources.disk.confirm.cancel').click();
await expect(disk(page)).toHaveValue('32');
await expect(memory(page)).toHaveValue('4096');
await page.getByTestId('vps.resources.disk.override').check();
state.failDisk = false;
await submitDisk(page);
await expect.poll(() => state.diskWrites.length).toBe(2);
expect(state.diskWrites[1]).toEqual({ dataset: { refquota: 32768, admin_override: true } });
expect(state.vpsWrites).toEqual([]);
await expect(page.getByTestId('vps.resources.disk.override')).not.toBeChecked();
});

test('validates used space and rechecks dataset activity before writing', async ({ page }) => {
const { state } = await setup(page);
await page.goto('/admin/vps/123/config?section=resources');
await disk(page).fill('4');
await expect(page.getByTestId('vps.resources.disk.save')).toBeDisabled();
await expect(page.getByTestId('vps.resources.disk')).toContainText('cannot be smaller');
await disk(page).fill('32');
await page.getByTestId('vps.resources.disk.save').click();
state.busyDataset = true;
await page.getByTestId('vps.resources.disk.confirm.confirm').click();
await expect(page.getByTestId('vps.resources.disk.confirm')).toContainText('Operation in progress');
expect(state.diskWrites).toEqual([]);
});

test('blocks blind retries after a success response without task identification', async ({ page }) => {
const { state } = await setup(page);
state.missingTask = true;
await page.goto('/admin/vps/123/config?section=resources');
await disk(page).fill('32');
await submitDisk(page);
await expect(page.getByTestId('vps.resources.disk.confirm')).toContainText('did not confirm the disk change');
await page.getByTestId('vps.resources.disk.confirm.cancel').click();
await expect(page.getByTestId('vps.resources.disk.save')).toBeDisabled();
await page.reload();
await disk(page).fill('32');
await expect(page.getByTestId('vps.resources.disk.save')).toBeDisabled();
expect(state.diskWrites).toHaveLength(1);
});

test('fails closed when a fresh disk read fails or used space has increased', async ({ page }) => {
const { state, mock } = await setup(page);
await page.goto('/admin/vps/123/config?section=resources');
await disk(page).fill('10');
await page.getByTestId('vps.resources.disk.save').click();
state.dataset.used = 15 * 1024;
await page.getByTestId('vps.resources.disk.confirm.confirm').click();
await expect(page.getByTestId('vps.resources.disk.confirm')).toContainText('cannot be smaller');
expect(state.diskWrites).toEqual([]);
await page.getByTestId('vps.resources.disk.confirm.cancel').click();
await disk(page).fill('32');
await page.getByTestId('vps.resources.disk.save').click();
mock.addHandler('GET datasets/10', () => jsonFulfill({ status: false, message: 'Disk unavailable', response: null }, 503));
await page.getByTestId('vps.resources.disk.confirm.confirm').click();
await expect(page.getByTestId('vps.resources.disk.confirm')).toContainText('Disk unavailable');
expect(state.diskWrites).toEqual([]);
});

test('keeps VPS editing usable when the root disk cannot be loaded', async ({ page }) => {
const { mock } = await setup(page);
mock.addHandler('GET datasets/10', () => jsonFulfill({ status: false, message: 'Not found', response: null }, 404));
await page.goto('/admin/vps/123/config?section=resources');
await expect(page.getByTestId('vps.resources.disk')).toContainText('Could not load');
await expect(disk(page)).toHaveCount(0);
await memory(page).fill('4096');
await expect(page.getByTestId('vps.config.header.save')).toBeEnabled();
});

test('keeps root resizing admin-only, including admin personal mode', async ({ page }) => {
await setup(page);
await page.goto('/app/vps/123/config?section=resources');
await expect(memory(page)).toBeVisible();
await expect(page.getByTestId('vps.resources.disk')).toHaveCount(0);
});

test('does not expose root resizing to a member', async ({ page }) => {
await setup(page, { level: 1 });
await page.goto('/app/vps/123/config?section=resources');
await expect(memory(page)).toBeVisible();
await expect(page.getByTestId('vps.resources.disk')).toHaveCount(0);
});
});
2 changes: 1 addition & 1 deletion e2e/specs/app/vps_resources_failures.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ test.describe('@workflow-matrix VPS resource mutation regressions', () => {
await resourceInput(page, 'CPU').fill('4');
await reviewAndSubmit(page, 1);

await expect(page.getByText(/server did not return a task identifier/i)).toBeVisible();
await expect(page.getByTestId('vps.config.confirm.error').getByText(/server did not return a task identifier/i)).toBeVisible();
await expect(resourceInput(page, 'CPU')).toHaveValue('4');
await page.getByRole('button', { name: 'Cancel', exact: true }).click();
await expect(page.getByTestId('vps.mutation.uncertain')).toBeVisible();
Expand Down
12 changes: 3 additions & 9 deletions e2e/specs/app/vps_storage_tab_mounts.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ function mountItemControl(page: Page, mountId: number, control: 'dataset' | 'del

test.describe('@smoke VPS storage tab mounts', () => {
for (const override of [false, true]) {
test(`lets an admin resize the VPS root SSD live from the configuration entrypoint (override: ${override})`, async ({ page }) => {
test(`lets an admin resize the VPS root SSD live from the storage entrypoint (override: ${override})`, async ({ page }) => {
await bootstrapVpsAdminWindow(page, { sessionToken: 'TEST' });
let vpsUpdateCount = 0;

Expand Down Expand Up @@ -83,13 +83,7 @@ test.describe('@smoke VPS storage tab mounts', () => {
},
});

await page.goto('/admin/vps/123/config');
const resizeEntry = page.getByTestId('vps.config.ssd.resize');
await expect(resizeEntry).toBeVisible();
await expect(resizeEntry).toHaveAttribute('href', '/admin/vps/123/storage?resize=ssd');
await expect(resizeEntry.locator('..')).toContainText('20 GiB');

await resizeEntry.click();
await page.goto('/admin/vps/123/storage?resize=ssd');
await expect(page).toHaveURL(/\/admin\/vps\/123\/storage$/);
const modal = page.getByTestId('vps.storage.resize.modal');
await expect(modal).toBeVisible();
Expand All @@ -115,7 +109,7 @@ test.describe('@smoke VPS storage tab mounts', () => {
await expect(page.getByTestId('vps.storage.root_dataset.resize')).toBeVisible();

await page.goto('/app/vps/123/config');
await expect(page.getByTestId('vps.config.ssd.resize')).toHaveCount(0);
await expect(page.getByTestId('vps.resources.disk')).toHaveCount(0);
await page.goto('/app/vps/123/storage');
await expect(page.getByTestId('vps.storage.root_dataset.resize')).toHaveCount(0);
});
Expand Down
14 changes: 14 additions & 0 deletions src/i18n/locales/cs/vps/config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
// VPS configuration copy
export const csVps_config = {
'vps.resources.performance': 'Výkon VPS',
"vps.resources.title": "Upravit prostředky VPS",
"vps.resources.subtitle_admin": "Výkon VPS a kořenový disk na jednom místě. Každou kartu ukládáš samostatně; rozpracované změny ve druhé zůstanou zachované.",
"vps.resources.subtitle_user": "CPU, paměť a swap přehledně na jednom místě.",
"vps.resources.additional": "Další konfigurace VPS",
"vps.resources.disk.subtitle": "Velikost kořenového datasetu. Změna se aplikuje za běhu.",
"vps.resources.disk.current": "Aktuální velikost",
"vps.resources.disk.used": "Využité místo",
"vps.resources.disk.save": "Uložit velikost SSD",
"vps.resources.disk.missing": "VPS nemá přiřazený kořenový dataset.",
"vps.resources.disk.load_error": "Data disku nebo jeho úlohy se nepodařilo načíst.",
"vps.resources.disk.submitted": "Změna velikosti SSD byla odeslána. Průběh najdeš v Úlohách.",
"vps.resources.disk.unknown": "Server nepotvrdil přijetí změny disku. Před dalším pokusem ověř výsledek v Úlohách.",
"vps.resources.disk.confirm_help": "Uloží se pouze velikost kořenového datasetu. Rozpracované změny výkonu VPS zůstanou zachované.",
'vps.config.title': 'Konfigurace',
'vps.config.subtitle_admin': 'Uprav identitu VPS, vlastníka, prostředky, namespace, resolver a preference bootu.',
'vps.config.subtitle_user': 'Uprav jen nastavení VPS dostupná členovi.',
Expand Down
14 changes: 14 additions & 0 deletions src/i18n/locales/en/vps/config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
// VPS configuration copy
export const enVps_config = {
'vps.resources.performance': 'VPS performance',
"vps.resources.title": "Edit VPS resources",
"vps.resources.subtitle_admin": "VPS performance and root disk in one place. Save each card separately; unsaved changes in the other card are preserved.",
"vps.resources.subtitle_user": "CPU, memory and swap together in one place.",
"vps.resources.additional": "Additional VPS configuration",
"vps.resources.disk.subtitle": "Root dataset capacity. Changes apply while the VPS is running.",
"vps.resources.disk.current": "Current capacity",
"vps.resources.disk.used": "Used space",
"vps.resources.disk.save": "Save SSD capacity",
"vps.resources.disk.missing": "This VPS has no root dataset assigned.",
"vps.resources.disk.load_error": "Could not load the disk or its tasks.",
"vps.resources.disk.submitted": "SSD capacity change submitted. Follow its progress in Tasks.",
"vps.resources.disk.unknown": "The server did not confirm the disk change. Verify its outcome in Tasks before retrying.",
"vps.resources.disk.confirm_help": "Only the root dataset capacity will be saved. Unsaved VPS performance changes are preserved.",
'vps.config.title': 'Configuration',
'vps.config.subtitle_admin': 'Edit VPS identity, owner, resources, namespace, resolver and boot preferences.',
'vps.config.subtitle_user': 'Edit only VPS settings available to a member.',
Expand Down
Loading
Loading