fix(disk-resize): pass node, real disk picker, shrink guard, grow output
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:
@@ -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>
|
||||
{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>
|
||||
|
||||
|
||||
@@ -473,12 +473,12 @@ export function useBulkOperation() {
|
||||
}
|
||||
|
||||
// ==================== Disk Resize ====================
|
||||
export function useResizeDisk(serverId: number, vmid: number, type: string) {
|
||||
export function useResizeDisk(serverId: number, vmid: number, type: string, node: string) {
|
||||
const qc = useQueryClient();
|
||||
const prefix = type === 'lxc' ? 'container' : 'vm';
|
||||
return useMutation({
|
||||
mutationFn: (body: { disk: string; size: string }) =>
|
||||
apiClient.post(`/proxmox/api/${serverId}/${prefix}/${vmid}/disk/resize`, body),
|
||||
apiClient.post(`/proxmox/api/${serverId}/${prefix}/${vmid}/disk/resize?node=${node}`, body),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: vmKeys.config(serverId, vmid) });
|
||||
qc.invalidateQueries({ queryKey: vmKeys.status(serverId, vmid) });
|
||||
@@ -487,12 +487,12 @@ export function useResizeDisk(serverId: number, vmid: number, type: string) {
|
||||
}
|
||||
|
||||
// ==================== Config Update ====================
|
||||
export function useUpdateConfig(serverId: number, vmid: number, type: string) {
|
||||
export function useUpdateConfig(serverId: number, vmid: number, type: string, node: string) {
|
||||
const qc = useQueryClient();
|
||||
const prefix = type === 'lxc' ? 'container' : 'vm';
|
||||
return useMutation({
|
||||
mutationFn: (body: Record<string, unknown>) =>
|
||||
apiClient.put(`/proxmox/api/${serverId}/${prefix}/${vmid}/config`, body),
|
||||
apiClient.put(`/proxmox/api/${serverId}/${prefix}/${vmid}/config?node=${node}`, body),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: vmKeys.config(serverId, vmid) });
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user