From a8be1e15de897855f2f5e748acf79f69f2cd2535 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Janou=C5=A1ek?= Date: Thu, 24 Sep 2026 20:12:35 +0200 Subject: [PATCH 1/2] feat: bring VPS performance and root disk editing together Open resource editing with CPU, memory, swap and the root dataset at the front of the page, and collapse additional configuration on that entry. Save the VPS and dataset independently so one operation cannot discard the other draft or imply an atomic update across two API resources. Preserve explicit admin overrides, validate the disk against fresh used space and active tasks, and track uncertain updates with durable locks. Cover independent saves, failures, permissions and responsive cs/en views. --- e2e/specs/app/vps_resource_workspace.spec.ts | 188 +++++++ e2e/specs/app/vps_resources_failures.spec.ts | 2 +- e2e/specs/app/vps_storage_tab_mounts.spec.ts | 12 +- src/i18n/locales/cs/vps/config.ts | 14 + src/i18n/locales/en/vps/config.ts | 14 + src/pages/app/vps/VpsConfigurationPage.tsx | 516 +++++++++---------- src/pages/app/vps/VpsControlCenterCards.tsx | 4 +- src/pages/app/vps/VpsResourceDiskCard.tsx | 157 ++++++ 8 files changed, 637 insertions(+), 270 deletions(-) create mode 100644 e2e/specs/app/vps_resource_workspace.spec.ts create mode 100644 src/pages/app/vps/VpsResourceDiskCard.tsx 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() {
- - - {dirty ? t('vps.config.save_changes', { n: result.changedKeys.length }) : t('vps.config.save_changes_empty')} - -
- } + title={t(focusedResources ? 'vps.resources.title' : 'vps.config.title')} + subtitle={t(canEditAdminConfig ? 'vps.resources.subtitle_admin' : 'vps.resources.subtitle_user')} /> @@ -302,253 +286,267 @@ export function VpsConfigurationPage() { {saveM.error && fieldErrors.length === 0 ? {isMissingActionStateError(saveM.error) ? t('vps.mutation.error.missing_action_state') : String((saveM.error as Error)?.message ?? saveM.error)} : null} - +
+ + + patchDraft({ cpu: e.target.value })} disabled={saveM.isPending} /> + + + patchDraft({ memory: e.target.value })} disabled={saveM.isPending} /> + + + patchDraft({ swap: e.target.value })} disabled={saveM.isPending} /> + + {canEditAdminConfig ? ( + + patchDraft({ cpuLimit: e.target.value })} disabled={saveM.isPending} /> + + ) : null} + {canEditAdminConfig ? ( +
+ + patchDraft({ hostnameMode: e.target.value as 'managed' | 'manual' })} - disabled={saveM.isPending} - options={[ - { value: 'managed', label: t('vps.config.option.hostname_managed') }, - { value: 'manual', label: t('vps.config.option.hostname_manual') }, - ]} - /> - - - patchDraft({ hostname: e.target.value })} - disabled={saveM.isPending || effective.hostnameMode === 'manual'} - autoComplete="off" - /> - - - - - - patchDraft({ cpu: e.target.value })} disabled={saveM.isPending} /> - - - patchDraft({ memory: e.target.value })} disabled={saveM.isPending} /> - - - patchDraft({ swap: e.target.value })} disabled={saveM.isPending} /> - - {canEditAdminConfig ? ( -
- +
+ + + {dirty ? t('vps.config.save_changes', { n: result.changedKeys.length }) : t('vps.config.save_changes_empty')} + +
+ + {canEditAdminConfig ? : null} +
+ + {dirty ? ( + + ) : null} + +
+ {t('vps.resources.additional')} +
+ + patchDraft({ hostname: e.target.value })} + disabled={saveM.isPending || effective.hostnameMode === 'manual'} + autoComplete="off" + /> + + + + + + {dnsResolversQ.isLoading ? ( + + ) : dnsResolversQ.isError ? ( + {String((dnsResolversQ.error as Error)?.message ?? dnsResolversQ.error)} + ) : ( + patchDraft({ userNamespaceMap: e.target.value })} + disabled={saveM.isPending || userNamespaceMapOptions.length === 0} + options={userNamespaceMapOptions} + /> + )} + + {canEditAdminConfig ? ( + + patchDraft({ startMenuTimeout: e.target.value })} + disabled={saveM.isPending} + /> + + ) : null} + + patchDraft({ dnsResolver: e.target.value })} disabled={saveM.isPending} options={dnsOptions} /> - )} - - - - - - {userNamespaceMapsQ.isLoading ? ( - - ) : userNamespaceMapsQ.isError ? ( - {String((userNamespaceMapsQ.error as Error)?.message ?? userNamespaceMapsQ.error)} - ) : ( - patchDraft({ mapMode: e.target.value as VpsMapMode })} - disabled={saveM.isPending} - options={VPS_MAP_MODES.map((mode) => ({ - value: mode, - label: t(`vps.config.option.map_mode.${mode}`), - }))} - testId="vps.config.map_mode" - /> - - ) : null} - - - - {canEditAdminConfig ? ( - - patchDraft({ startMenuTimeout: e.target.value })} - disabled={saveM.isPending} - /> - - ) : null} - - patchDraft({ autostartPriority: e.target.value })} + disabled={saveM.isPending} + /> + + + patchDraft({ changeReason: e.target.value })} disabled={saveM.isPending} autoComplete="off" /> + + + ) : null} - {canEditAdminConfig ? ( - - - patchDraft({ user: value })} - placeholder={t('vps.create.placeholder.user')} - disabled={saveM.isPending} - allowRawId - /> - - - patchDraft({ cpuLimit: e.target.value })} disabled={saveM.isPending} /> - - - {t('vps.config.field.autostart_priority')} - - {t(vps.autostart_enable === true ? 'vps.config.autostart.enabled' : 'vps.config.autostart.disabled')} - - - )} - help={t('vps.config.help.autostart_priority')} - errors={fieldMessages('autostart_priority')} - > - patchDraft({ autostartPriority: e.target.value })} - disabled={saveM.isPending} - /> - - - patchDraft({ changeReason: e.target.value })} disabled={saveM.isPending} autoComplete="off" /> - - - ) : null} +
+
{dirty ? {t('vps.config.unsaved', { n: result.changedKeys.length })} : null} diff --git a/src/pages/app/vps/VpsControlCenterCards.tsx b/src/pages/app/vps/VpsControlCenterCards.tsx index 2228458a..c6e66b27 100644 --- a/src/pages/app/vps/VpsControlCenterCards.tsx +++ b/src/pages/app/vps/VpsControlCenterCards.tsx @@ -158,6 +158,8 @@ export function VpsResourcesCard(props: { const { t } = useI18n(); const cpu = usageValue(props.vps.cpu ?? props.vps['cpus']); const swap = usageValue(props.vps.swap); + const resourceSearch = new URLSearchParams(props.contextSearch ?? ''); + resourceSearch.set('section', 'resources'); return ( @@ -165,7 +167,7 @@ export function VpsResourcesCard(props: { title={}>{t('vps.control.resources.title')}} subtitle={t('vps.control.resources.subtitle')} actions={( - + {t('vps.control.resources.edit')} )} diff --git a/src/pages/app/vps/VpsResourceDiskCard.tsx b/src/pages/app/vps/VpsResourceDiskCard.tsx new file mode 100644 index 00000000..5f52a34a --- /dev/null +++ b/src/pages/app/vps/VpsResourceDiskCard.tsx @@ -0,0 +1,157 @@ +import React, { useEffect, useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; + +import { useAuth } from '../../../app/auth'; +import { useAppMode } from '../../../app/appMode'; +import { useI18n } from '../../../app/i18n'; +import { useChrome } from '../../../components/layout/ChromeContext'; +import { ActionButton } from '../../../components/ui/ActionButton'; +import { Alert } from '../../../components/ui/Alert'; +import { Badge } from '../../../components/ui/Badge'; +import { Button } from '../../../components/ui/Button'; +import { Card, CardBody, CardHeader } from '../../../components/ui/Card'; +import { Checkbox } from '../../../components/ui/Checkbox'; +import { ConfirmDialog } from '../../../components/ui/ConfirmDialog'; +import { Input } from '../../../components/ui/Input'; +import { Spinner } from '../../../components/ui/Spinner'; +import { fetchDataset, updateDataset } from '../../../lib/api/datasets'; +import { getMetaActionStateId, isMissingActionStateError, requireActionStateResult } from '../../../lib/api/haveapi'; +import { fetchActiveTransactionChains } from '../../../lib/api/transactions'; +import { gateDatasetAction } from '../../../lib/gates/dataset'; +import { formatMiB } from '../../../lib/format'; +import { objectRef } from '../../../lib/objectRef'; +import { hasActiveChains } from '../../../lib/taskStatus'; +import { useVps } from './VpsContext'; +import { datasetId, rootDatasetSummary, ssdSizeGiBInput, validateSsdResize } from './VpsStorageModel'; +import { preflightVpsNotBusy } from './vpsPreflight'; + +/** Root disk edits stay independent of the VPS draft and its save operation. */ +export function VpsResourceDiskCard(props: { vpsPending: boolean; onPendingChange: (pending: boolean) => void }) { + const { t } = useI18n(); + const auth = useAuth(); + const { mode, basePath } = useAppMode(); + const { vps, busyTransaction, busyLocalLock } = useVps(); + const chrome = useChrome(); + const qc = useQueryClient(); + const canEdit = mode === 'admin' && auth.role === 'admin'; + const id = datasetId(vps.dataset); + const [size, setSize] = useState(null); + const [override, setOverride] = useState(false); + const [confirm, setConfirm] = useState(false); + const [submitted, setSubmitted] = useState(false); + const datasetQ = useQuery({ + queryKey: ['datasets', 'show', id, 'vps-resource-editor'], + enabled: canEdit && id !== null, + queryFn: async () => (await fetchDataset(id!, { includes: 'vps,environment,user,parent' })).data, + refetchOnWindowFocus: false, + }); + const chainsQ = useQuery({ + queryKey: ['transaction_chain', 'active', { className: 'Dataset', rowId: id }], + enabled: canEdit && id !== null && datasetQ.isSuccess, + queryFn: () => fetchActiveTransactionChains({ className: 'Dataset', rowId: id! }), + refetchInterval: 15000, + }); + const root = rootDatasetSummary(datasetQ.data ?? null, vps.dataset ?? null); + const ref = id === null ? null : objectRef('Dataset', id); + const gate = datasetQ.data ? gateDatasetAction('dataset.update', { + dataset: datasetQ.data, + permission: canEdit, + role: auth.role, + busyTransaction: busyTransaction || hasActiveChains(chainsQ.data ?? []), + busyLocal: busyLocalLock || props.vpsPending || Boolean(ref && chrome.isLocallyLocked(ref)), + }) : null; + const input = size ?? ssdSizeGiBInput(root.referenceQuota); + const validation = validateSsdResize(input, root.referenceQuota, root.used); + const resize = useMutation({ + mutationFn: async (variables: { id: number; vpsId: number; value: number; override: boolean }) => { + if (!canEdit) throw new Error(t('gate.blocked.permission.body')); + await preflightVpsNotBusy({ vpsId: variables.vpsId, t, knownBusy: busyTransaction || busyLocalLock }); + const fresh = (await fetchDataset(variables.id, { includes: 'vps,user' })).data; + const chains = await fetchActiveTransactionChains({ className: 'Dataset', rowId: variables.id }); + const freshGate = gateDatasetAction('dataset.update', { dataset: fresh, permission: canEdit, busyTransaction: hasActiveChains(chains) }); + if (!freshGate.allowed) throw new Error(t(freshGate.reason.titleKey)); + const freshRoot = rootDatasetSummary(fresh, vps.dataset ?? null); + const checked = validateSsdResize(String(variables.value / 1024), freshRoot.referenceQuota, freshRoot.used); + if (!checked.ok) throw new Error(t(`vps.storage.resize.validation.${checked.issue}`)); + return requireActionStateResult(await updateDataset(variables.id, { + refquota: variables.value, + ...(variables.override ? { admin_override: true } : {}), + }), 'dataset.update'); + }, + onMutate: async (variables) => { + const lockRef = objectRef('Dataset', variables.id); + return { lockRef, generation: await chrome.acquireLocalLock(lockRef, { durable: true }) }; + }, + onSuccess: (response, variables, context) => { + setConfirm(false); + setSize(null); + setOverride(false); + setSubmitted(true); + void qc.invalidateQueries({ queryKey: ['datasets', 'show', variables.id] }); + void qc.invalidateQueries({ queryKey: ['vps', 'show', { id: variables.vpsId }] }); + chrome.trackActionState(getMetaActionStateId(response.meta)!, { + actionLabelKey: 'action.dataset.update.label', + objectLabel: `${vps.hostname ?? variables.vpsId} · ${root.label}`, + object: context?.lockRef, + mutationGeneration: context?.generation, + }); + }, + onSettled: (_data, error, _variables, context) => context && chrome.settleLocalLock(context.lockRef, error, context.generation), + }); + useEffect(() => { + props.onPendingChange(resize.isPending); + return () => props.onPendingChange(false); + }, [props.onPendingChange, resize.isPending]); + if (!canEdit) return null; + const disabled = !gate?.allowed || !validation.ok || resize.isPending || !chainsQ.isSuccess; + const error = resize.error ? (isMissingActionStateError(resize.error) ? t('vps.resources.disk.unknown') : String(resize.error.message)) : null; + const edit = (value: string) => { setSize(value); setSubmitted(false); resize.reset(); }; + + return ( + + {t('vps.config.risk.live')}} /> + + {id === null ? {t('vps.resources.disk.missing')} : datasetQ.isLoading ? : datasetQ.isError ? ( + + {t('vps.resources.disk.load_error')} + + + ) : ( + <> +
+ {root.label} + +
+
+
{t('vps.resources.disk.current')}
{root.referenceQuota === null ? t('common.na') : formatMiB(root.referenceQuota)}
+
{t('vps.resources.disk.used')}
{root.used === null ? t('common.na') : formatMiB(root.used)}
+
+ + {size !== null && validation.issue && validation.issue !== 'unchanged' ? {t(`vps.storage.resize.validation.${validation.issue}`)} : null} + { setOverride(value); resize.reset(); }} label={t('vps.config.field.admin_override')} description={t('vps.config.help.admin_override')} disabled={resize.isPending} testId="vps.resources.disk.override" /> + {gate && !gate.allowed ? {t(gate.reason.titleKey)} : null} + {chainsQ.isError ? {t('vps.resources.disk.load_error')} : null} + {error ? {error} : null} + {submitted ? {t('vps.resources.disk.submitted')} : null} + { resize.reset(); setConfirm(true); }} disabled={disabled} loading={resize.isPending} testId="vps.resources.disk.save">{t('vps.resources.disk.save')} + + )} + setConfirm(false)} onConfirm={() => { + if (disabled || id === null || validation.valueMiB === null) return; + resize.mutate({ id, vpsId: Number(vps.id), value: validation.valueMiB, override }); + }} testId="vps.resources.disk.confirm"> +
+
{String(vps.hostname ?? vps.id)} · {root.label}
+
{root.referenceQuota === null ? t('common.na') : formatMiB(root.referenceQuota)} → {formatMiB(validation.valueMiB ?? 0)}
+

{t('vps.resources.disk.confirm_help')}

+ {override ? {t('vps.config.field.admin_override')} : null} + {error ? {error} : null} +
+
+
+
+ ); +} From 1a42fe28e1f9839a2ccc310715b5acc1e68df990 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Janou=C5=A1ek?= Date: Thu, 24 Sep 2026 20:14:28 +0200 Subject: [PATCH 2/2] test: expect focused resource links to preserve owner context The resource editor now selects its focused view with section=resources. Keep the overview assertion checking both that entry and the owner filter. --- e2e/specs/app/vps_detail_tabs_matrix.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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);