diff --git a/e2e/specs/app/vps_detail_tabs_matrix.spec.ts b/e2e/specs/app/vps_detail_tabs_matrix.spec.ts index ab988a06..37c4012f 100644 --- a/e2e/specs/app/vps_detail_tabs_matrix.spec.ts +++ b/e2e/specs/app/vps_detail_tabs_matrix.spec.ts @@ -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§ion=resources', ); await expect(page.getByTestId('vps.overview.resources_usage.runtime')).toBeVisible(); await expect(page.getByTestId('vps.overview.status_access.card')).toHaveCount(0); diff --git a/e2e/specs/app/vps_resource_workspace.spec.ts b/e2e/specs/app/vps_resource_workspace.spec.ts new file mode 100644 index 00000000..020fa183 --- /dev/null +++ b/e2e/specs/app/vps_resource_workspace.spec.ts @@ -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); + }); +}); diff --git a/e2e/specs/app/vps_resources_failures.spec.ts b/e2e/specs/app/vps_resources_failures.spec.ts index 78bb0124..a4c173d1 100644 --- a/e2e/specs/app/vps_resources_failures.spec.ts +++ b/e2e/specs/app/vps_resources_failures.spec.ts @@ -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(); diff --git a/e2e/specs/app/vps_storage_tab_mounts.spec.ts b/e2e/specs/app/vps_storage_tab_mounts.spec.ts index 00674a63..ee182250 100644 --- a/e2e/specs/app/vps_storage_tab_mounts.spec.ts +++ b/e2e/specs/app/vps_storage_tab_mounts.spec.ts @@ -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; @@ -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(); @@ -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); }); diff --git a/src/i18n/locales/cs/vps/config.ts b/src/i18n/locales/cs/vps/config.ts index 6e9751c7..6b14932a 100644 --- a/src/i18n/locales/cs/vps/config.ts +++ b/src/i18n/locales/cs/vps/config.ts @@ -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.', diff --git a/src/i18n/locales/en/vps/config.ts b/src/i18n/locales/en/vps/config.ts index 0c602ddb..817b5020 100644 --- a/src/i18n/locales/en/vps/config.ts +++ b/src/i18n/locales/en/vps/config.ts @@ -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.', diff --git a/src/pages/app/vps/VpsConfigurationPage.tsx b/src/pages/app/vps/VpsConfigurationPage.tsx index 0d828709..9574a71e 100644 --- a/src/pages/app/vps/VpsConfigurationPage.tsx +++ b/src/pages/app/vps/VpsConfigurationPage.tsx @@ -1,4 +1,5 @@ import React, { useMemo, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useAuth } from '../../../app/auth'; @@ -22,9 +23,10 @@ import { explicitUserNamespaceOwnerId, fetchUserNamespaceMaps } from '../../../l import { updateVps } from '../../../lib/api/vps'; import { gateVpsMutation } from '../../../lib/gates/vps'; import { objectRef } from '../../../lib/objectRef'; -import { formatMiB } from '../../../lib/format'; import { preflightVpsNotBusy } from './vpsPreflight'; import { useVps } from './VpsContext'; +import { VpsResourceDiskCard } from './VpsResourceDiskCard'; +import { datasetId } from './VpsStorageModel'; import { freezeVpsMutationSnapshot, type VpsMutationSnapshot } from './VpsMutationSnapshot'; import { ADMIN_LOCK_TYPES, @@ -79,7 +81,10 @@ function mergeFieldErrorMessages(args: { export function VpsConfigurationPage() { const auth = useAuth(); - const { basePath, mode } = useAppMode(); + const { mode } = useAppMode(); + const [searchParams] = useSearchParams(); + const focusedResources = searchParams.get('section') === 'resources'; + const [diskPending, setDiskPending] = useState(false); const isAdminMode = mode === 'admin'; const canEditAdminConfig = isAdminMode && auth.role === 'admin'; const canMutateVps = !isAdminMode || canEditAdminConfig; @@ -149,7 +154,7 @@ export function VpsConfigurationPage() { setDraft((prev) => ({ ...(prev ?? baseline), ...patch })); }; - const busyLocal = busyLocalLock || saveM.isPending; + const busyLocal = busyLocalLock || saveM.isPending || diskPending; const gate = gateVpsMutation({ vps, busyLocal, busyTransaction }); const dirty = result.changedKeys.length > 0; const saveDisabled = !canMutateVps || !dirty || Boolean(result.validationError) || !gate.allowed || saveM.isPending; @@ -254,29 +259,8 @@ export function VpsConfigurationPage() {
{t('vps.resources.disk.confirm_help')}
+ {override ?