fix(disk-resize): pass node, real disk picker, shrink guard, grow output
All checks were successful
Backend Tests / pytest (Python 3.12) (push) Successful in 49s
Deploy to Production / Trigger production update (push) Successful in 8s

Settings tab disk-resize UX:
- useResizeDisk/useUpdateConfig now send the required node query param,
  fixing the 422 "Field required" error.
- disk device is chosen from the VM's actual disks (defaults to the boot
  disk) instead of a hardcoded scsi0.
- show the current disk size, prefill it, and block shrinking before
  hitting Proxmox (which rejects it).
- render the growpart output and a grown/not-grown status after resize.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 02:43:12 +05:00
parent 38d485be4b
commit 9e9cac15e1
2 changed files with 112 additions and 20 deletions

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Settings, Loader2 } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@@ -19,6 +19,17 @@ interface Props {
const OWNER_NONE = '__none__';
/** Достать текущий размер диска в ГБ из строки конфига Proxmox
* (напр. "local-nvme:vm-106-disk-0.qcow2,...,size=51712M,ssd=1"). */
function parseDiskSizeGb(spec: unknown): number | null {
if (typeof spec !== 'string') return null;
const m = spec.match(/(?:^|,)size=(\d+(?:\.\d+)?)([KMGT])?/i);
if (!m) return null;
const value = parseFloat(m[1]);
const factor: Record<string, number> = { K: 1 / 1048576, M: 1 / 1024, G: 1, T: 1024 };
return value * (factor[(m[2] || 'G').toUpperCase()] ?? 1);
}
function OwnerCard({ serverId, vmid }: { serverId: number; vmid: number }) {
const { t } = useTranslation();
const { data: profile } = useProfile();
@@ -179,18 +190,55 @@ function SavedConfigCard({ serverId, vmid }: { serverId: number; vmid: number })
export default function SettingsTab({ serverId, vmid, type, node }: Props) {
const { t } = useTranslation();
const { data: config } = useVMConfig(serverId, vmid, type, node);
const updateConfig = useUpdateConfig(serverId, vmid, type);
const resizeDisk = useResizeDisk(serverId, vmid, type);
const updateConfig = useUpdateConfig(serverId, vmid, type, node);
const resizeDisk = useResizeDisk(serverId, vmid, type, node);
const [cores, setCores] = useState('');
const [memory, setMemory] = useState('');
const [diskSize, setDiskSize] = useState('');
const [diskDevice, setDiskDevice] = useState('scsi0');
const [diskDevice, setDiskDevice] = useState('');
const [growResult, setGrowResult] = useState<{ grown: boolean; output: string } | null>(null);
// Initialize from config
const currentCores = config?.cores || 0;
const currentMemory = config?.memory || 0;
// Реальные дисковые устройства из конфига Proxmox (исключая cd-rom).
// Захардкоженный scsi0 ломал ресайз на VM с другим диском (напр. sata0).
const diskDevices = useMemo(() => {
if (!config) return [] as string[];
const re = /^(rootfs|mp\d+|scsi\d+|sata\d+|virtio\d+|ide\d+)$/;
return Object.entries(config)
.filter(([k, v]) => re.test(k) && typeof v === 'string' && !v.includes('media=cdrom'))
.map(([k]) => k);
}, [config]);
// Загрузочный диск из boot=order=... — выбираем по умолчанию.
const bootDisk = useMemo(() => {
const order = typeof config?.boot === 'string' ? config.boot : '';
const first = order.match(/order=([^;]+)/)?.[1]?.split(';')[0]?.trim();
return first && diskDevices.includes(first) ? first : undefined;
}, [config?.boot, diskDevices]);
useEffect(() => {
if (diskDevices.length === 0) return;
setDiskDevice((cur) => (diskDevices.includes(cur) ? cur : bootDisk ?? diskDevices[0]));
}, [diskDevices, bootDisk]);
// Текущий размер выбранного диска (ГБ). Proxmox не умеет уменьшать диск,
// поэтому показываем его и не даём ввести значение меньше текущего.
const currentDiskGb = useMemo(
() => (diskDevice ? parseDiskSizeGb(config?.[diskDevice]) : null),
[config, diskDevice]
);
const minDiskGb = currentDiskGb != null ? Math.ceil(currentDiskGb) : 1;
// При смене диска подставляем текущий размер как стартовое значение,
// чтобы пользователь увеличивал его, а не угадывал.
useEffect(() => {
if (currentDiskGb != null) setDiskSize(String(Math.ceil(currentDiskGb)));
}, [currentDiskGb]);
const handleConfigUpdate = () => {
const updates: Record<string, unknown> = {};
if (cores && Number(cores) !== currentCores) updates.cores = Number(cores);
@@ -212,13 +260,22 @@ export default function SettingsTab({ serverId, vmid, type, node }: Props) {
};
const handleDiskResize = () => {
if (!diskSize) return;
if (!diskSize || !diskDevice) return;
if (currentDiskGb != null && Number(diskSize) <= currentDiskGb) {
toast.error(
`Новый размер должен быть больше текущего (${currentDiskGb.toFixed(1)} ГБ). Уменьшение диска Proxmox не поддерживает.`
);
return;
}
resizeDisk.mutate(
{ disk: diskDevice, size: `${diskSize}G` },
{
onSuccess: () => {
toast.success(`Disk resized to ${diskSize}G`);
setDiskSize('');
onSuccess: (data) => {
const res = data as { message?: string; filesystem_grown?: boolean; grow_output?: string };
const grown = !!res?.filesystem_grown;
setGrowResult({ grown, output: res?.grow_output ?? '' });
if (grown) toast.success(res?.message ?? `Disk resized to ${diskSize}G`);
else toast.warning(res?.message ?? `Диск увеличен до ${diskSize}G, но ФС не расширена`);
},
onError: (err) => toast.error(err.message),
}
@@ -287,28 +344,49 @@ export default function SettingsTab({ serverId, vmid, type, node }: Props) {
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="disk-device">{t('instances.disk_device')}</Label>
<Input
id="disk-device"
value={diskDevice}
onChange={(e) => setDiskDevice(e.target.value)}
placeholder={t('common.placeholder_disk')}
/>
{diskDevices.length > 0 ? (
<Select value={diskDevice} onValueChange={setDiskDevice}>
<SelectTrigger id="disk-device">
<SelectValue placeholder={t('common.placeholder_disk')} />
</SelectTrigger>
<SelectContent>
{diskDevices.map((dev) => (
<SelectItem key={dev} value={dev}>
{dev}
{dev === bootDisk ? ' (boot)' : ''}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
id="disk-device"
value={diskDevice}
onChange={(e) => setDiskDevice(e.target.value)}
placeholder={t('common.placeholder_disk')}
/>
)}
</div>
<div className="space-y-1.5">
<Label htmlFor="disk-size">{t('instances.new_size_gb')}</Label>
<Input
id="disk-size"
type="number"
min={1}
min={minDiskGb}
value={diskSize}
onChange={(e) => setDiskSize(e.target.value)}
placeholder={t('common.placeholder_disk_size')}
/>
{currentDiskGb != null && (
<p className="text-xs text-muted-foreground">
Текущий размер: {currentDiskGb.toFixed(1)} ГБ
</p>
)}
</div>
</div>
<Button
onClick={handleDiskResize}
disabled={!diskSize || resizeDisk.isPending}
disabled={!diskSize || !diskDevice || resizeDisk.isPending}
size="sm"
variant="outline"
>
@@ -318,6 +396,20 @@ export default function SettingsTab({ serverId, vmid, type, node }: Props) {
<p className="text-xs text-muted-foreground">
{t('instances.resize_disk_hint')}
</p>
{growResult && (
<div className="space-y-1.5">
<p className={`text-xs font-medium ${growResult.grown ? 'text-green-500' : 'text-amber-500'}`}>
{growResult.grown
? 'Файловая система расширена внутри ОС'
: 'Файловая система не расширена — вывод growpart ниже'}
</p>
{growResult.output && (
<pre className="max-h-48 overflow-auto rounded bg-muted p-2 text-xs font-mono whitespace-pre-wrap">
{growResult.output}
</pre>
)}
</div>
)}
</CardContent>
</Card>