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 { useTranslation } from 'react-i18next';
|
||||||
import { Settings, Loader2 } from 'lucide-react';
|
import { Settings, Loader2 } from 'lucide-react';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
@@ -19,6 +19,17 @@ interface Props {
|
|||||||
|
|
||||||
const OWNER_NONE = '__none__';
|
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 }) {
|
function OwnerCard({ serverId, vmid }: { serverId: number; vmid: number }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { data: profile } = useProfile();
|
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) {
|
export default function SettingsTab({ serverId, vmid, type, node }: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { data: config } = useVMConfig(serverId, vmid, type, node);
|
const { data: config } = useVMConfig(serverId, vmid, type, node);
|
||||||
const updateConfig = useUpdateConfig(serverId, vmid, type);
|
const updateConfig = useUpdateConfig(serverId, vmid, type, node);
|
||||||
const resizeDisk = useResizeDisk(serverId, vmid, type);
|
const resizeDisk = useResizeDisk(serverId, vmid, type, node);
|
||||||
|
|
||||||
const [cores, setCores] = useState('');
|
const [cores, setCores] = useState('');
|
||||||
const [memory, setMemory] = useState('');
|
const [memory, setMemory] = useState('');
|
||||||
const [diskSize, setDiskSize] = 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
|
// Initialize from config
|
||||||
const currentCores = config?.cores || 0;
|
const currentCores = config?.cores || 0;
|
||||||
const currentMemory = config?.memory || 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 handleConfigUpdate = () => {
|
||||||
const updates: Record<string, unknown> = {};
|
const updates: Record<string, unknown> = {};
|
||||||
if (cores && Number(cores) !== currentCores) updates.cores = Number(cores);
|
if (cores && Number(cores) !== currentCores) updates.cores = Number(cores);
|
||||||
@@ -212,13 +260,22 @@ export default function SettingsTab({ serverId, vmid, type, node }: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDiskResize = () => {
|
const handleDiskResize = () => {
|
||||||
if (!diskSize) return;
|
if (!diskSize || !diskDevice) return;
|
||||||
|
if (currentDiskGb != null && Number(diskSize) <= currentDiskGb) {
|
||||||
|
toast.error(
|
||||||
|
`Новый размер должен быть больше текущего (${currentDiskGb.toFixed(1)} ГБ). Уменьшение диска Proxmox не поддерживает.`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
resizeDisk.mutate(
|
resizeDisk.mutate(
|
||||||
{ disk: diskDevice, size: `${diskSize}G` },
|
{ disk: diskDevice, size: `${diskSize}G` },
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: (data) => {
|
||||||
toast.success(`Disk resized to ${diskSize}G`);
|
const res = data as { message?: string; filesystem_grown?: boolean; grow_output?: string };
|
||||||
setDiskSize('');
|
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),
|
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="grid gap-4 sm:grid-cols-2">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="disk-device">{t('instances.disk_device')}</Label>
|
<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
|
<Input
|
||||||
id="disk-device"
|
id="disk-device"
|
||||||
value={diskDevice}
|
value={diskDevice}
|
||||||
onChange={(e) => setDiskDevice(e.target.value)}
|
onChange={(e) => setDiskDevice(e.target.value)}
|
||||||
placeholder={t('common.placeholder_disk')}
|
placeholder={t('common.placeholder_disk')}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="disk-size">{t('instances.new_size_gb')}</Label>
|
<Label htmlFor="disk-size">{t('instances.new_size_gb')}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="disk-size"
|
id="disk-size"
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={minDiskGb}
|
||||||
value={diskSize}
|
value={diskSize}
|
||||||
onChange={(e) => setDiskSize(e.target.value)}
|
onChange={(e) => setDiskSize(e.target.value)}
|
||||||
placeholder={t('common.placeholder_disk_size')}
|
placeholder={t('common.placeholder_disk_size')}
|
||||||
/>
|
/>
|
||||||
|
{currentDiskGb != null && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Текущий размер: {currentDiskGb.toFixed(1)} ГБ
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleDiskResize}
|
onClick={handleDiskResize}
|
||||||
disabled={!diskSize || resizeDisk.isPending}
|
disabled={!diskSize || !diskDevice || resizeDisk.isPending}
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
>
|
>
|
||||||
@@ -318,6 +396,20 @@ export default function SettingsTab({ serverId, vmid, type, node }: Props) {
|
|||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{t('instances.resize_disk_hint')}
|
{t('instances.resize_disk_hint')}
|
||||||
</p>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -473,12 +473,12 @@ export function useBulkOperation() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Disk Resize ====================
|
// ==================== 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 qc = useQueryClient();
|
||||||
const prefix = type === 'lxc' ? 'container' : 'vm';
|
const prefix = type === 'lxc' ? 'container' : 'vm';
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (body: { disk: string; size: string }) =>
|
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: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: vmKeys.config(serverId, vmid) });
|
qc.invalidateQueries({ queryKey: vmKeys.config(serverId, vmid) });
|
||||||
qc.invalidateQueries({ queryKey: vmKeys.status(serverId, vmid) });
|
qc.invalidateQueries({ queryKey: vmKeys.status(serverId, vmid) });
|
||||||
@@ -487,12 +487,12 @@ export function useResizeDisk(serverId: number, vmid: number, type: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Config Update ====================
|
// ==================== 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 qc = useQueryClient();
|
||||||
const prefix = type === 'lxc' ? 'container' : 'vm';
|
const prefix = type === 'lxc' ? 'container' : 'vm';
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (body: Record<string, unknown>) =>
|
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: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: vmKeys.config(serverId, vmid) });
|
qc.invalidateQueries({ queryKey: vmKeys.config(serverId, vmid) });
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user