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() {
- - - {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} +
+
+
+
+ ); +}