feat(instances): add VM/LXC migration, autostart/boot order, protection
- Migrate VMs/LXCs between cluster nodes with live progress tracking - Configure autostart, boot order, startup/shutdown delays, and delete protection from the instance settings tab - Group OS templates by detected OS (Ubuntu, AlmaLinux, etc.) instead of a single "Cloud Images" bucket - Expand native Proxmox backup job management (schedule, compression, retention, guest selection, notifications) - Add ru/en translations for the above
This commit is contained in:
@@ -109,7 +109,10 @@ function SelectContent({
|
||||
sideOffset = 4,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
alignItemWithTrigger = true,
|
||||
// Открывать список как обычную выпадашку под/над полем, а не выравнивать
|
||||
// выбранный пункт над триггером — иначе длинные списки (напр. в диалогах)
|
||||
// «уезжают» вверх и отрываются от поля.
|
||||
alignItemWithTrigger = false,
|
||||
...props
|
||||
}: SelectPrimitive.Popup.Props &
|
||||
Pick<
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Plus, Pencil, Trash2, Database, CalendarClock } from 'lucide-react';
|
||||
import { Plus, Pencil, Trash2, Database, CalendarClock, Power, PowerOff } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -13,6 +13,8 @@ import { useServers } from '@/hooks/use-nodes';
|
||||
import {
|
||||
useBackupStorages, useProxmoxBackupJobs,
|
||||
useCreateStorage, useUpdateStorage, useDeleteStorage,
|
||||
useCreateProxmoxJob, useUpdateProxmoxJob, useDeleteProxmoxJob,
|
||||
type ProxmoxJobInput,
|
||||
} from '@/hooks/use-backups';
|
||||
import { formatBytes } from '@/lib/format';
|
||||
import { toast } from 'sonner';
|
||||
@@ -179,6 +181,207 @@ function StorageDialog({ serverId, open, onOpenChange, editing }: {
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Native PVE backup job ====================
|
||||
|
||||
type NativeJob = Record<string, unknown>;
|
||||
|
||||
const BACKUP_MODES = ['snapshot', 'suspend', 'stop'];
|
||||
const COMPRESS_OPTS = ['zstd', 'gzip', 'lzo', '0'];
|
||||
|
||||
/** Разобрать prune-backups="keep-last=3,keep-daily=7" в отдельные поля. */
|
||||
function parsePrune(spec: unknown): Record<string, number> {
|
||||
const out: Record<string, number> = {};
|
||||
if (typeof spec !== 'string') return out;
|
||||
for (const part of spec.split(',')) {
|
||||
const [k, v] = part.split('=');
|
||||
const n = Number(v);
|
||||
if (k && !Number.isNaN(n)) out[k.trim()] = n;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
interface NativeJobForm {
|
||||
schedule: string;
|
||||
storage: string;
|
||||
mode: string;
|
||||
compress: string;
|
||||
enabled: boolean;
|
||||
selection_mode: 'all' | 'vmid';
|
||||
vmid: string;
|
||||
keep_last: string;
|
||||
keep_daily: string;
|
||||
keep_weekly: string;
|
||||
keep_monthly: string;
|
||||
comment: string;
|
||||
mailto: string;
|
||||
}
|
||||
|
||||
function jobToForm(job: NativeJob | null): NativeJobForm {
|
||||
const prune = parsePrune(job?.['prune-backups']);
|
||||
const hasVmid = !!(job && (job.vmid || job.pool) && job.all !== 1 && job.all !== '1');
|
||||
return {
|
||||
schedule: String(job?.schedule ?? ''),
|
||||
storage: String(job?.storage ?? ''),
|
||||
mode: String(job?.mode ?? 'snapshot'),
|
||||
compress: String(job?.compress ?? 'zstd'),
|
||||
enabled: !(job && (job.enabled === 0 || job.enabled === '0')),
|
||||
selection_mode: hasVmid ? 'vmid' : 'all',
|
||||
vmid: String(job?.vmid ?? ''),
|
||||
keep_last: prune['keep-last'] != null ? String(prune['keep-last']) : '',
|
||||
keep_daily: prune['keep-daily'] != null ? String(prune['keep-daily']) : '',
|
||||
keep_weekly: prune['keep-weekly'] != null ? String(prune['keep-weekly']) : '',
|
||||
keep_monthly: prune['keep-monthly'] != null ? String(prune['keep-monthly']) : '',
|
||||
comment: String(job?.comment ?? ''),
|
||||
mailto: String(job?.mailto ?? ''),
|
||||
};
|
||||
}
|
||||
|
||||
function NativeJobDialog({ serverId, open, onOpenChange, editing, backupStorages }: {
|
||||
serverId: number;
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
editing: NativeJob | null;
|
||||
backupStorages: string[];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [form, setForm] = useState<NativeJobForm>(() => jobToForm(editing));
|
||||
const create = useCreateProxmoxJob(serverId);
|
||||
const update = useUpdateProxmoxJob(serverId);
|
||||
const pending = create.isPending || update.isPending;
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setForm(jobToForm(editing));
|
||||
}, [open, editing]);
|
||||
|
||||
const set = <K extends keyof NativeJobForm>(k: K, v: NativeJobForm[K]) =>
|
||||
setForm(prev => ({ ...prev, [k]: v }));
|
||||
|
||||
const submit = () => {
|
||||
if (!form.schedule.trim() || !form.storage) {
|
||||
toast.error(t('storages.job_schedule_storage_required'));
|
||||
return;
|
||||
}
|
||||
const payload: ProxmoxJobInput = {
|
||||
schedule: form.schedule.trim(),
|
||||
storage: form.storage,
|
||||
mode: form.mode,
|
||||
compress: form.compress,
|
||||
enabled: form.enabled,
|
||||
selection_mode: form.selection_mode,
|
||||
vmid: form.selection_mode === 'vmid' ? form.vmid.trim() : undefined,
|
||||
keep_last: form.keep_last ? Number(form.keep_last) : undefined,
|
||||
keep_daily: form.keep_daily ? Number(form.keep_daily) : undefined,
|
||||
keep_weekly: form.keep_weekly ? Number(form.keep_weekly) : undefined,
|
||||
keep_monthly: form.keep_monthly ? Number(form.keep_monthly) : undefined,
|
||||
comment: form.comment.trim() || undefined,
|
||||
mailto: form.mailto.trim() || undefined,
|
||||
};
|
||||
const opts = {
|
||||
onSuccess: () => { toast.success(editing ? t('storages.job_updated') : t('storages.job_created')); onOpenChange(false); },
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
};
|
||||
if (editing) update.mutate({ id: String(editing.id), data: payload }, opts);
|
||||
else create.mutate(payload, opts);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<CalendarClock className="h-5 w-5" />
|
||||
{editing ? t('storages.edit_native_job') : t('storages.add_native_job')}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('storages.schedule')}</Label>
|
||||
<Input value={form.schedule} onChange={e => set('schedule', e.target.value)} placeholder="sat 03:00" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('storages.id')}</Label>
|
||||
<Select value={form.storage} onValueChange={v => v && set('storage', v)}>
|
||||
<SelectTrigger className="w-full"><SelectValue placeholder={t('backups.select_storage')} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{backupStorages.map(s => <SelectItem key={s} value={s}>{s}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<p className="-mt-1 text-xs text-muted-foreground">{t('storages.schedule_hint')}</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('backups.mode')}</Label>
|
||||
<Select value={form.mode} onValueChange={v => v && set('mode', v)}>
|
||||
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{BACKUP_MODES.map(m => <SelectItem key={m} value={m}>{m}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('storages.compress')}</Label>
|
||||
<Select value={form.compress} onValueChange={v => v && set('compress', v)}>
|
||||
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{COMPRESS_OPTS.map(c => <SelectItem key={c} value={c}>{c === '0' ? t('storages.no_compress') : c}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('storages.selection')}</Label>
|
||||
<Select value={form.selection_mode} onValueChange={v => v && set('selection_mode', v as 'all' | 'vmid')}>
|
||||
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t('storages.select_all_guests')}</SelectItem>
|
||||
<SelectItem value="vmid">{t('storages.select_vmids')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{form.selection_mode === 'vmid' && (
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('backups.vmids')}</Label>
|
||||
<Input value={form.vmid} onChange={e => set('vmid', e.target.value)} placeholder="100,101,102" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">{t('storages.retention')}</Label>
|
||||
<div className="mt-1.5 grid grid-cols-4 gap-2">
|
||||
<div className="space-y-1"><Label className="text-[11px]">{t('backups.keep_last')}</Label><Input type="number" min={0} value={form.keep_last} onChange={e => set('keep_last', e.target.value)} /></div>
|
||||
<div className="space-y-1"><Label className="text-[11px]">{t('backups.keep_daily')}</Label><Input type="number" min={0} value={form.keep_daily} onChange={e => set('keep_daily', e.target.value)} /></div>
|
||||
<div className="space-y-1"><Label className="text-[11px]">{t('backups.keep_weekly')}</Label><Input type="number" min={0} value={form.keep_weekly} onChange={e => set('keep_weekly', e.target.value)} /></div>
|
||||
<div className="space-y-1"><Label className="text-[11px]">{t('backups.keep_monthly')}</Label><Input type="number" min={0} value={form.keep_monthly} onChange={e => set('keep_monthly', e.target.value)} /></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('storages.comment')}</Label>
|
||||
<Input value={form.comment} onChange={e => set('comment', e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('storages.mailto')}</Label>
|
||||
<Input value={form.mailto} onChange={e => set('mailto', e.target.value)} placeholder="admin@example.com" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={form.enabled} onChange={e => set('enabled', e.target.checked)} />
|
||||
<span>{t('storages.enabled')}</span>
|
||||
</label>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>{t('common.cancel')}</Button>
|
||||
<Button onClick={submit} disabled={pending}>
|
||||
{editing ? t('common.save') : t('common.create')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default function StoragesTab() {
|
||||
const { t } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
@@ -192,12 +395,21 @@ export default function StoragesTab() {
|
||||
const { data: storagesData, isLoading } = useBackupStorages(serverId);
|
||||
const { data: jobsData } = useProxmoxBackupJobs(serverId);
|
||||
const deleteStorage = useDeleteStorage(serverId);
|
||||
const updateJob = useUpdateProxmoxJob(serverId);
|
||||
const deleteJob = useDeleteProxmoxJob(serverId);
|
||||
|
||||
const storages = (storagesData?.storages ?? []) as StorageRow[];
|
||||
const nativeJobs = (jobsData?.jobs ?? []) as Array<Record<string, unknown>>;
|
||||
|
||||
// Хранилища, поддерживающие бэкапы — для селекта в диалоге задания
|
||||
const backupStorages = storages
|
||||
.filter(s => (s.content ?? '').split(',').map(c => c.trim()).includes('backup'))
|
||||
.map(s => s.storage);
|
||||
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<StorageRow | null>(null);
|
||||
const [jobDialogOpen, setJobDialogOpen] = useState(false);
|
||||
const [editingJob, setEditingJob] = useState<NativeJob | null>(null);
|
||||
|
||||
const handleDelete = async (s: string) => {
|
||||
if (!await confirm(`${t('storages.delete_confirm')} "${s}"?`)) return;
|
||||
@@ -207,6 +419,43 @@ export default function StoragesTab() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteJob = async (job: NativeJob) => {
|
||||
if (!await confirm(`${t('storages.delete_job_confirm')} "${String(job.id)}"?`)) return;
|
||||
deleteJob.mutate(String(job.id), {
|
||||
onSuccess: () => toast.success(t('storages.job_deleted')),
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
};
|
||||
|
||||
const handleToggleJob = (job: NativeJob) => {
|
||||
const currentlyEnabled = !(job.enabled === 0 || job.enabled === '0');
|
||||
const form = jobToForm(job);
|
||||
updateJob.mutate(
|
||||
{
|
||||
id: String(job.id),
|
||||
data: {
|
||||
schedule: form.schedule,
|
||||
storage: form.storage,
|
||||
mode: form.mode,
|
||||
compress: form.compress,
|
||||
enabled: !currentlyEnabled,
|
||||
selection_mode: form.selection_mode,
|
||||
vmid: form.selection_mode === 'vmid' ? form.vmid : undefined,
|
||||
keep_last: form.keep_last ? Number(form.keep_last) : undefined,
|
||||
keep_daily: form.keep_daily ? Number(form.keep_daily) : undefined,
|
||||
keep_weekly: form.keep_weekly ? Number(form.keep_weekly) : undefined,
|
||||
keep_monthly: form.keep_monthly ? Number(form.keep_monthly) : undefined,
|
||||
comment: form.comment || undefined,
|
||||
mailto: form.mailto || undefined,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => toast.success(currentlyEnabled ? t('storages.job_disabled') : t('storages.job_enabled')),
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -265,9 +514,14 @@ export default function StoragesTab() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Native PVE backup schedules (read-only) */}
|
||||
{/* Native PVE backup schedules */}
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="flex items-center gap-2 text-lg"><CalendarClock className="h-5 w-5" />{t('storages.native_jobs')}</CardTitle></CardHeader>
|
||||
<CardHeader className="flex-row items-center justify-between space-y-0">
|
||||
<CardTitle className="flex items-center gap-2 text-lg"><CalendarClock className="h-5 w-5" />{t('storages.native_jobs')}</CardTitle>
|
||||
<Button size="sm" variant="outline" onClick={() => { setEditingJob(null); setJobDialogOpen(true); }} disabled={!serverId}>
|
||||
<Plus className="mr-1 h-4 w-4" />{t('storages.add_native_job')}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{nativeJobs.length === 0 ? (
|
||||
<p className="py-4 text-center text-sm text-muted-foreground">{t('storages.no_native_jobs')}</p>
|
||||
@@ -277,21 +531,38 @@ export default function StoragesTab() {
|
||||
<TableHead>ID</TableHead><TableHead>{t('storages.schedule')}</TableHead>
|
||||
<TableHead>{t('storages.id')}</TableHead><TableHead>{t('backups.mode')}</TableHead>
|
||||
<TableHead>{t('common.status')}</TableHead>
|
||||
<TableHead className="text-right">{t('common.actions')}</TableHead>
|
||||
</TableRow></TableHeader>
|
||||
<TableBody>
|
||||
{nativeJobs.map((j, i) => (
|
||||
<TableRow key={String(j.id ?? i)}>
|
||||
<TableCell className="font-mono text-xs">{String(j.id ?? '—')}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{String(j.schedule ?? j.starttime ?? '—')}</TableCell>
|
||||
<TableCell className="text-xs">{String(j.storage ?? '—')}</TableCell>
|
||||
<TableCell className="text-xs">{String(j.mode ?? '—')}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={j.enabled === 0 || j.enabled === '0' ? 'secondary' : 'default'}>
|
||||
{j.enabled === 0 || j.enabled === '0' ? t('storages.disabled') : t('storages.enabled')}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{nativeJobs.map((j, i) => {
|
||||
const enabled = !(j.enabled === 0 || j.enabled === '0');
|
||||
return (
|
||||
<TableRow key={String(j.id ?? i)}>
|
||||
<TableCell className="font-mono text-xs">{String(j.id ?? '—')}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{String(j.schedule ?? j.starttime ?? '—')}</TableCell>
|
||||
<TableCell className="text-xs">{String(j.storage ?? '—')}</TableCell>
|
||||
<TableCell className="text-xs">{String(j.mode ?? '—')}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={enabled ? 'default' : 'secondary'}>
|
||||
{enabled ? t('storages.enabled') : t('storages.disabled')}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" title={enabled ? t('backups.disable') : t('backups.enable')} onClick={() => handleToggleJob(j)}>
|
||||
{enabled ? <PowerOff className="h-3.5 w-3.5" /> : <Power className="h-3.5 w-3.5" />}
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" title={t('common.edit')} onClick={() => { setEditingJob(j); setJobDialogOpen(true); }}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" title={t('common.delete')} onClick={() => handleDeleteJob(j)}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
@@ -306,6 +577,15 @@ export default function StoragesTab() {
|
||||
editing={editing}
|
||||
/>
|
||||
)}
|
||||
{jobDialogOpen && (
|
||||
<NativeJobDialog
|
||||
serverId={serverId}
|
||||
open={jobDialogOpen}
|
||||
onOpenChange={(v) => { setJobDialogOpen(v); if (!v) setEditingJob(null); }}
|
||||
editing={editingJob}
|
||||
backupStorages={backupStorages}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ export default function DownloadImageDialog({ open, onClose, serverId, node, ima
|
||||
const [storage, setStorage] = useState(''); // download storage (import / vztmpl)
|
||||
const [toTemplate, setToTemplate] = useState(true);
|
||||
const [diskStorage, setDiskStorage] = useState(''); // VM disk storage (images)
|
||||
const [vmid, setVmid] = useState<string>(''); // желаемый VMID шаблона (пусто = авто)
|
||||
const [cores, setCores] = useState(2);
|
||||
const [memory, setMemory] = useState(2048);
|
||||
const [bridge, setBridge] = useState('vmbr0');
|
||||
@@ -77,6 +78,7 @@ export default function DownloadImageDialog({ open, onClose, serverId, node, ima
|
||||
if (open) {
|
||||
setStorage(storages.length === 1 ? storages[0].storage : '');
|
||||
setToTemplate(true);
|
||||
setVmid('');
|
||||
}
|
||||
}, [open, storages]);
|
||||
|
||||
@@ -100,6 +102,7 @@ export default function DownloadImageDialog({ open, onClose, serverId, node, ima
|
||||
kind: image.kind,
|
||||
template: image.source_id ? undefined : image.template || undefined,
|
||||
to_template: makeTemplate,
|
||||
vmid: makeTemplate && vmid.trim() ? Number(vmid) : undefined,
|
||||
disk_storage: makeTemplate ? diskStorage : undefined,
|
||||
cores: makeTemplate ? cores : undefined,
|
||||
memory: makeTemplate ? memory : undefined,
|
||||
@@ -193,6 +196,11 @@ export default function DownloadImageDialog({ open, onClose, serverId, node, ima
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="img-vmid">VMID шаблона (пусто = авто)</Label>
|
||||
<Input id="img-vmid" type="number" min={100} placeholder="напр. 9001"
|
||||
value={vmid} onChange={(e) => setVmid(e.target.value)} />
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<Label htmlFor="img-cores">Ядра</Label>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Loader2, Disc, Eye, EyeOff, Sparkles, Copy, KeyRound, FileText, Terminal as TerminalIcon, Play, Square, RotateCcw, Power, AlertTriangle } from 'lucide-react';
|
||||
import { Loader2, Disc, Eye, EyeOff, Sparkles, Copy, KeyRound, FileText, Terminal as TerminalIcon, Play, Square, RotateCcw, Power, AlertTriangle, ArrowRightLeft } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -21,6 +21,8 @@ import {
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
useCloneInstance,
|
||||
useMigrateInstance,
|
||||
useVMStatus,
|
||||
useReinstallInstance,
|
||||
useChangePassword,
|
||||
useUpdateNotes,
|
||||
@@ -41,6 +43,7 @@ export type PowerAction = 'start' | 'stop' | 'restart' | 'shutdown';
|
||||
|
||||
export type InstanceAction =
|
||||
| 'clone'
|
||||
| 'migrate'
|
||||
| 'reinstall'
|
||||
| 'change-password'
|
||||
| 'notes'
|
||||
@@ -67,6 +70,7 @@ export default function InstanceActionDialogs(props: Props) {
|
||||
return (
|
||||
<>
|
||||
<CloneDialog open={open === 'clone'} onClose={close} {...shared} />
|
||||
<MigrateDialog open={open === 'migrate'} onClose={close} {...shared} />
|
||||
<ReinstallDialog open={open === 'reinstall'} onClose={close} {...shared} />
|
||||
<ChangePasswordDialog open={open === 'change-password'} onClose={close} {...shared} />
|
||||
<NotesDialog open={open === 'notes'} onClose={close} {...shared} />
|
||||
@@ -223,6 +227,119 @@ function CloneDialog({ open, onClose, serverId, vmid, type, node, name }: Omit<P
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Migrate ====================
|
||||
|
||||
const MIGRATE_SAME_STORAGE = '__same__';
|
||||
|
||||
function MigrateDialog({ open, onClose, serverId, vmid, type, node, name }: Omit<Props, 'open' | 'onOpenChange'> & { open: boolean; onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [targetNode, setTargetNode] = useState('');
|
||||
const [targetStorage, setTargetStorage] = useState('');
|
||||
const [online, setOnline] = useState(false);
|
||||
const migrate = useMigrateInstance(serverId, vmid, type, node);
|
||||
const addDeployTask = useDeployTasksStore((s) => s.addTask);
|
||||
|
||||
const { data: nodesResp } = useNodes(serverId);
|
||||
const nodes = (nodesResp?.nodes ?? []).filter((n) => n.node !== node);
|
||||
const { data: status } = useVMStatus(serverId, vmid, type, node, open);
|
||||
const isRunning = status?.status === 'running';
|
||||
const { data: storages = [] } = useLXCStorages(serverId, targetNode || node);
|
||||
|
||||
// Сброс формы при открытии; online по умолчанию = инстанс запущен.
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setTargetNode('');
|
||||
setTargetStorage('');
|
||||
setOnline(isRunning);
|
||||
}
|
||||
}, [open, isRunning]);
|
||||
|
||||
const submit = () => {
|
||||
if (!targetNode) return;
|
||||
migrate.mutate(
|
||||
{
|
||||
target_node: targetNode,
|
||||
target_storage: targetStorage || undefined,
|
||||
online,
|
||||
},
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
addDeployTask({
|
||||
id: data.task_id,
|
||||
name: data.name,
|
||||
status: 'pending',
|
||||
step: 'В очереди...',
|
||||
progress: 0,
|
||||
vmid,
|
||||
node: targetNode,
|
||||
error_message: null,
|
||||
kind: 'migrate',
|
||||
server_id: serverId,
|
||||
});
|
||||
toast.success(t('instances.migrate_started'));
|
||||
onClose();
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2"><ArrowRightLeft className="h-4 w-4" /> {t('instances.migrate')}</DialogTitle>
|
||||
<DialogDescription>{t('instances.migrate_desc')} <strong>{name ?? `#${vmid}`}</strong> ({node})</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('instances.migrate_target_node')}</Label>
|
||||
<Select value={targetNode} onValueChange={(v) => { if (v) { setTargetNode(v); setTargetStorage(''); } }}>
|
||||
<SelectTrigger><SelectValue placeholder={t('instances.migrate_select_node')} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{nodes.map((n) => (
|
||||
<SelectItem key={n.node} value={n.node}>{n.node}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{nodes.length === 0 && (
|
||||
<p className="text-xs text-amber-500">{t('instances.migrate_no_targets')}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('instances.migrate_target_storage')}</Label>
|
||||
<Select value={targetStorage || MIGRATE_SAME_STORAGE} onValueChange={(v) => setTargetStorage(!v || v === MIGRATE_SAME_STORAGE ? '' : v)}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={MIGRATE_SAME_STORAGE}>{t('instances.migrate_same_storage')}</SelectItem>
|
||||
{storages.map((s) => (
|
||||
<SelectItem key={s.storage} value={s.storage}>{s.storage} ({s.type})</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2.5 cursor-pointer">
|
||||
<input type="checkbox" checked={online} onChange={(e) => setOnline(e.target.checked)} className="h-4 w-4 rounded border-input accent-primary" />
|
||||
<span className="text-sm">{t('instances.migrate_online')}</span>
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground -mt-2">
|
||||
{isRunning ? t('instances.migrate_online_hint_running') : t('instances.migrate_online_hint_stopped')}
|
||||
</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>{t('common.cancel')}</Button>
|
||||
<Button onClick={submit} disabled={!targetNode || migrate.isPending}>
|
||||
{migrate.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t('instances.migrate')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Reinstall ====================
|
||||
|
||||
function ReinstallDialog({ open, onClose, serverId, vmid, node, name, type }: Omit<Props, 'open' | 'onOpenChange'> & { open: boolean; onClose: () => void }) {
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
KeyRound,
|
||||
FileText,
|
||||
Disc,
|
||||
ArrowRightLeft,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -204,6 +205,9 @@ export default function InstanceDetailPage() {
|
||||
<DropdownMenuItem onClick={() => setDialog('clone')}>
|
||||
<Copy className="mr-2 h-4 w-4" /> {t('common.clone', 'Клонировать')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setDialog('migrate')}>
|
||||
<ArrowRightLeft className="mr-2 h-4 w-4" /> {t('instances.migrate', 'Миграция')}
|
||||
</DropdownMenuItem>
|
||||
{isQemu && (
|
||||
<DropdownMenuItem onClick={() => setDialog('iso')}>
|
||||
<Disc className="mr-2 h-4 w-4" /> {t('common.iso', 'ISO образ')}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Settings, Loader2 } from 'lucide-react';
|
||||
import { Settings, Loader2, Power } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -148,6 +148,118 @@ function ExecuteScriptCard({ serverId, vmid, node }: { serverId: number; vmid: n
|
||||
);
|
||||
}
|
||||
|
||||
/** Разобрать строку startup Proxmox ("order=1,up=30,down=60") на поля. */
|
||||
function parseStartup(spec: unknown): { order: string; up: string; down: string } {
|
||||
const out = { order: '', up: '', down: '' };
|
||||
if (typeof spec !== 'string') return out;
|
||||
for (const part of spec.split(',')) {
|
||||
const [k, v] = part.split('=');
|
||||
if (k === 'order') out.order = v ?? '';
|
||||
else if (k === 'up') out.up = v ?? '';
|
||||
else if (k === 'down') out.down = v ?? '';
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function AutostartCard({ serverId, vmid, type, node }: { serverId: number; vmid: number; type: string; node: string }) {
|
||||
const { t } = useTranslation();
|
||||
const { data: config } = useVMConfig(serverId, vmid, type, node);
|
||||
const updateConfig = useUpdateConfig(serverId, vmid, type, node);
|
||||
|
||||
const [onboot, setOnboot] = useState(false);
|
||||
const [protection, setProtection] = useState(false);
|
||||
const [order, setOrder] = useState('');
|
||||
const [up, setUp] = useState('');
|
||||
const [down, setDown] = useState('');
|
||||
|
||||
// Инициализация из конфига Proxmox
|
||||
useEffect(() => {
|
||||
if (!config) return;
|
||||
setOnboot(Number(config.onboot) === 1);
|
||||
setProtection(Number(config.protection) === 1);
|
||||
const s = parseStartup(config.startup);
|
||||
setOrder(s.order);
|
||||
setUp(s.up);
|
||||
setDown(s.down);
|
||||
}, [config]);
|
||||
|
||||
const save = () => {
|
||||
const updates: Record<string, unknown> = {
|
||||
onboot: onboot ? 1 : 0,
|
||||
protection: protection ? 1 : 0,
|
||||
};
|
||||
// Собираем startup=order=..,up=..,down.. — либо удаляем, если всё пусто
|
||||
const parts: string[] = [];
|
||||
if (order.trim() !== '') parts.push(`order=${Number(order)}`);
|
||||
if (up.trim() !== '') parts.push(`up=${Number(up)}`);
|
||||
if (down.trim() !== '') parts.push(`down=${Number(down)}`);
|
||||
if (parts.length > 0) updates.startup = parts.join(',');
|
||||
else updates.delete = 'startup';
|
||||
|
||||
updateConfig.mutate(updates, {
|
||||
onSuccess: () => toast.success(t('instances.autostart_saved')),
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<Power className="h-4 w-4" />{t('instances.autostart')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<label className="flex items-center gap-2.5 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={onboot}
|
||||
onChange={e => setOnboot(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-input accent-primary"
|
||||
/>
|
||||
<span className="text-sm">{t('instances.start_on_boot')}</span>
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground -mt-2">{t('instances.start_on_boot_hint')}</p>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="startup-order">{t('instances.startup_order')}</Label>
|
||||
<Input id="startup-order" type="number" min={0} value={order}
|
||||
onChange={e => setOrder(e.target.value)} placeholder="any" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="startup-up">{t('instances.startup_up_delay')}</Label>
|
||||
<Input id="startup-up" type="number" min={0} value={up}
|
||||
onChange={e => setUp(e.target.value)} placeholder="0" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="startup-down">{t('instances.startup_down_timeout')}</Label>
|
||||
<Input id="startup-down" type="number" min={0} value={down}
|
||||
onChange={e => setDown(e.target.value)} placeholder="0" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('instances.startup_order_hint')}</p>
|
||||
|
||||
<label className="flex items-center gap-2.5 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={protection}
|
||||
onChange={e => setProtection(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-input accent-primary"
|
||||
/>
|
||||
<span className="text-sm">{t('instances.protection')}</span>
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground -mt-2">{t('instances.protection_hint')}</p>
|
||||
|
||||
<Button size="sm" onClick={save} disabled={updateConfig.isPending}>
|
||||
{updateConfig.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function SavedConfigCard({ serverId, vmid }: { serverId: number; vmid: number }) {
|
||||
const { t } = useTranslation();
|
||||
const { data } = useSavedConfig(serverId, vmid);
|
||||
@@ -286,6 +398,8 @@ export default function SettingsTab({ serverId, vmid, type, node }: Props) {
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
<OwnerCard serverId={serverId} vmid={vmid} />
|
||||
|
||||
<AutostartCard serverId={serverId} vmid={vmid} type={type} node={node} />
|
||||
|
||||
{type !== 'lxc' && <ExecuteScriptCard serverId={serverId} vmid={vmid} node={node} />}
|
||||
|
||||
{/* CPU & Memory */}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Label } from '@/components/ui/label';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { useServers, useNodes, useStorages } from '@/hooks/use-nodes';
|
||||
import { useQuickDeployInfo, useDeployVM } from '@/hooks/use-templates';
|
||||
import { useQuickDeployInfo, useDeployVM, useTemplateGroups, useUpdateTemplate } from '@/hooks/use-templates';
|
||||
import { useAvailableLXCTemplates, useDownloadLXCTemplate } from '@/hooks/use-lxc-templates';
|
||||
import type { OSTemplate } from '@/types';
|
||||
import { toast } from 'sonner';
|
||||
@@ -177,3 +177,54 @@ export function DownloadCTDialog({ open, onClose }: { open: boolean; onClose: ()
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Edit template group (category) ====================
|
||||
|
||||
export function EditTemplateDialog({ template, onClose }: { template: OSTemplate | null; onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const open = !!template;
|
||||
const { data: groups = [] } = useTemplateGroups();
|
||||
const update = useUpdateTemplate();
|
||||
const [groupId, setGroupId] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (template) setGroupId(String(template.group_id));
|
||||
}, [template]);
|
||||
|
||||
const submit = () => {
|
||||
if (!template || !groupId) return;
|
||||
update.mutate(
|
||||
{ id: template.id, group_id: Number(groupId) },
|
||||
{
|
||||
onSuccess: () => { toast.success(t('templates.group_updated')); onClose(); },
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose(); }}>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>{t('templates.edit_group')}: {template?.name}</DialogTitle></DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label>{t('templates.group')}</Label>
|
||||
<Select value={groupId} onValueChange={v => { if (v) setGroupId(v); }}>
|
||||
<SelectTrigger className="mt-1 w-full"><SelectValue placeholder={t('templates.select_group')} /></SelectTrigger>
|
||||
<SelectContent className="max-h-72">
|
||||
{groups.map(g => <SelectItem key={g.id} value={String(g.id)}>{g.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>{t('common.cancel')}</Button>
|
||||
<Button onClick={submit} disabled={update.isPending || !groupId || groupId === String(template?.group_id)}>
|
||||
{update.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Monitor, Search, Download, Trash2, Loader2, Rocket, PackagePlus } from 'lucide-react';
|
||||
import { Monitor, Search, Download, Trash2, Loader2, Rocket, PackagePlus, Pencil } from 'lucide-react';
|
||||
import { OsLogo } from './OsLogo';
|
||||
import { DeployDialog, DownloadCTDialog } from './TemplateDialogs';
|
||||
import { DeployDialog, DownloadCTDialog, EditTemplateDialog } from './TemplateDialogs';
|
||||
import type { OSTemplate } from '@/types';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -25,6 +25,7 @@ export default function TemplatesPage() {
|
||||
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
||||
const [importServerId, setImportServerId] = useState<string>('');
|
||||
const [deployTpl, setDeployTpl] = useState<OSTemplate | null>(null);
|
||||
const [editTpl, setEditTpl] = useState<OSTemplate | null>(null);
|
||||
const [downloadOpen, setDownloadOpen] = useState(false);
|
||||
|
||||
const { data: groups = [] } = useTemplateGroups();
|
||||
@@ -123,14 +124,25 @@ export default function TemplatesPage() {
|
||||
<p className="text-xs text-muted-foreground">{tpl.group_name}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity h-7 w-7 p-0 text-destructive"
|
||||
onClick={async () => { if (await confirm(t('common.confirm_delete'))) deleteTpl.mutate(tpl.id); }}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity h-7 w-7 p-0"
|
||||
title={t('templates.edit_group')}
|
||||
onClick={() => setEditTpl(tpl)}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity h-7 w-7 p-0 text-destructive"
|
||||
onClick={async () => { if (await confirm(t('common.confirm_delete'))) deleteTpl.mutate(tpl.id); }}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Badge variant={tpl.vm_type === 'lxc' ? 'secondary' : 'default'} className="text-xs">
|
||||
@@ -156,6 +168,7 @@ export default function TemplatesPage() {
|
||||
)}
|
||||
|
||||
<DeployDialog template={deployTpl} onClose={() => setDeployTpl(null)} />
|
||||
<EditTemplateDialog template={editTpl} onClose={() => setEditTpl(null)} />
|
||||
<DownloadCTDialog open={downloadOpen} onClose={() => setDownloadOpen(false)} />
|
||||
|
||||
{/* Import Dialog */}
|
||||
|
||||
@@ -24,6 +24,52 @@ export function useProxmoxBackupJobs(serverId: number) {
|
||||
});
|
||||
}
|
||||
|
||||
export type ProxmoxJobInput = {
|
||||
id?: string;
|
||||
schedule: string;
|
||||
storage: string;
|
||||
mode?: string;
|
||||
compress?: string;
|
||||
enabled?: boolean;
|
||||
selection_mode?: 'all' | 'vmid' | 'pool';
|
||||
vmid?: string;
|
||||
pool?: string;
|
||||
node?: string;
|
||||
keep_last?: number;
|
||||
keep_daily?: number;
|
||||
keep_weekly?: number;
|
||||
keep_monthly?: number;
|
||||
comment?: string;
|
||||
mailto?: string;
|
||||
};
|
||||
|
||||
export function useCreateProxmoxJob(serverId: number) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: ProxmoxJobInput) =>
|
||||
apiClient.post<{ success: boolean }>(`/proxmox/api/backups/proxmox-jobs/${serverId}`, data),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: backupKeys.proxmoxJobs(serverId) }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateProxmoxJob(serverId: number) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: ProxmoxJobInput }) =>
|
||||
apiClient.put<{ success: boolean }>(`/proxmox/api/backups/proxmox-jobs/${serverId}/${id}`, data),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: backupKeys.proxmoxJobs(serverId) }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteProxmoxJob(serverId: number) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (jobId: string) =>
|
||||
apiClient.delete<{ success: boolean }>(`/proxmox/api/backups/proxmox-jobs/${serverId}/${jobId}`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: backupKeys.proxmoxJobs(serverId) }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateStorage(serverId: number) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
|
||||
@@ -75,6 +75,7 @@ export interface ImageDownloadRequest {
|
||||
checksum_algorithm?: string;
|
||||
// Авто-конвертация qcow2 → VM-шаблон
|
||||
to_template?: boolean;
|
||||
vmid?: number;
|
||||
disk_storage?: string;
|
||||
cores?: number;
|
||||
memory?: number;
|
||||
|
||||
@@ -604,6 +604,21 @@ export function useReinstallInstance(serverId: number, vmid: number, node: strin
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Migrate ====================
|
||||
export interface MigrateRequest {
|
||||
target_node: string;
|
||||
target_storage?: string;
|
||||
online?: boolean;
|
||||
}
|
||||
|
||||
export function useMigrateInstance(serverId: number, vmid: number, type: string, node: string) {
|
||||
const prefix = type === 'lxc' ? 'container' : 'vm';
|
||||
return useMutation<AsyncTaskResponse, Error, MigrateRequest>({
|
||||
mutationFn: (body) =>
|
||||
apiClient.post(`/proxmox/api/${serverId}/${prefix}/${vmid}/migrate?node=${node}`, body),
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Change Password ====================
|
||||
export function useChangePassword(serverId: number, vmid: number, type: string, node: string) {
|
||||
const prefix = type === 'lxc' ? 'container' : 'vm';
|
||||
|
||||
@@ -4,7 +4,8 @@ import type { LXCTemplate, LXCDeployRequest } from '@/types';
|
||||
|
||||
export const lxcKeys = {
|
||||
templates: (serverId: number) => ['lxc-templates', serverId] as const,
|
||||
storages: (serverId: number, node?: string) => ['lxc-storages', serverId, node] as const,
|
||||
storages: (serverId: number, node?: string, content?: string) =>
|
||||
['lxc-storages', serverId, node, content] as const,
|
||||
};
|
||||
|
||||
// CT templates change rarely — cache for 5 minutes
|
||||
@@ -20,11 +21,15 @@ export function useLXCTemplates(serverId?: number) {
|
||||
});
|
||||
}
|
||||
|
||||
// Storage list changes rarely — cache for 5 minutes
|
||||
export function useLXCStorages(serverId?: number, node?: string) {
|
||||
const params = node ? `?node=${encodeURIComponent(node)}` : '';
|
||||
// Storage list changes rarely — cache for 5 minutes.
|
||||
// content: 'rootdir' (LXC rootfs) | 'images' (VM disk) | undefined (both)
|
||||
export function useLXCStorages(serverId?: number, node?: string, content?: string) {
|
||||
const qs = new URLSearchParams();
|
||||
if (node) qs.set('node', node);
|
||||
if (content) qs.set('content', content);
|
||||
const params = qs.toString() ? `?${qs.toString()}` : '';
|
||||
return useQuery({
|
||||
queryKey: lxcKeys.storages(serverId!, node),
|
||||
queryKey: lxcKeys.storages(serverId!, node, content),
|
||||
queryFn: () =>
|
||||
apiClient
|
||||
.get<{ storages: { storage: string; type: string; avail: number; total: number }[] }>(
|
||||
|
||||
@@ -13,6 +13,14 @@ export function useTemplateGroups() {
|
||||
return useQuery({
|
||||
queryKey: templateKeys.groups,
|
||||
queryFn: () => apiClient.get<OSTemplateGroup[]>('/templates/api/groups'),
|
||||
// Список групп ОС — по алфавиту; «Other» (корзина «прочее») всегда в конце.
|
||||
select: (groups) =>
|
||||
[...groups].sort((a, b) => {
|
||||
const aOther = a.name.toLowerCase() === 'other';
|
||||
const bOther = b.name.toLowerCase() === 'other';
|
||||
if (aOther !== bOther) return aOther ? 1 : -1;
|
||||
return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -25,6 +33,11 @@ export function useTemplates(groupId?: number, serverId?: number, vmType?: strin
|
||||
return useQuery({
|
||||
queryKey: templateKeys.templates(groupId, serverId, vmType),
|
||||
queryFn: () => apiClient.get<OSTemplate[]>(`/templates/api/templates${qs ? `?${qs}` : ''}`),
|
||||
// Шаблоны — по имени, по алфавиту (с числовой сортировкой: Debian-9 < Debian-11).
|
||||
select: (templates) =>
|
||||
[...templates].sort((a, b) =>
|
||||
a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -220,6 +220,27 @@
|
||||
"owner_hint": "Assign this instance to a user (admin only).",
|
||||
"saved_config": "Saved configuration",
|
||||
"saved_config_hint": "Values stored by the panel at creation/reinstall time.",
|
||||
"migrate": "Migrate",
|
||||
"migrate_desc": "Move to another cluster node:",
|
||||
"migrate_started": "Migration started in background",
|
||||
"migrate_target_node": "Target node",
|
||||
"migrate_select_node": "Select node",
|
||||
"migrate_no_targets": "No other nodes available in the cluster.",
|
||||
"migrate_target_storage": "Target storage",
|
||||
"migrate_same_storage": "Keep source storage",
|
||||
"migrate_online": "Online (live) migration",
|
||||
"migrate_online_hint_running": "Instance is running — live migration keeps it online during the move.",
|
||||
"migrate_online_hint_stopped": "Instance is stopped — leave off for a regular offline migration.",
|
||||
"autostart": "Autostart",
|
||||
"autostart_saved": "Autostart settings saved",
|
||||
"start_on_boot": "Start on boot",
|
||||
"start_on_boot_hint": "Automatically start this instance when the host node boots.",
|
||||
"startup_order": "Startup order",
|
||||
"startup_up_delay": "Start delay (sec)",
|
||||
"startup_down_timeout": "Shutdown timeout (sec)",
|
||||
"startup_order_hint": "Lower order starts first. Start delay waits before the next instance; shutdown timeout waits before force-off. Leave empty for defaults.",
|
||||
"protection": "Protection",
|
||||
"protection_hint": "Prevents the instance and its disks from being removed.",
|
||||
"compute_resources": "Compute Resources",
|
||||
"vcpu_cores": "vCPU Cores",
|
||||
"current_cores": "Current: {{count}} cores",
|
||||
@@ -526,7 +547,11 @@
|
||||
"version": "Version",
|
||||
"imported": "Imported",
|
||||
"select_server": "Select server",
|
||||
"import_btn": "Import Templates"
|
||||
"import_btn": "Import Templates",
|
||||
"group": "Group",
|
||||
"edit_group": "Edit group",
|
||||
"select_group": "Select group",
|
||||
"group_updated": "Group updated"
|
||||
},
|
||||
"storages": {
|
||||
"title": "Storages",
|
||||
@@ -556,7 +581,25 @@
|
||||
"delete_confirm": "Delete storage",
|
||||
"native_jobs": "Native PVE backup schedules",
|
||||
"no_native_jobs": "No native backup schedules",
|
||||
"schedule": "Schedule"
|
||||
"schedule": "Schedule",
|
||||
"schedule_hint": "systemd calendar event, e.g. \"sat 03:00\" or \"mon..fri 02:30\".",
|
||||
"compress": "Compression",
|
||||
"no_compress": "None",
|
||||
"selection": "Guest selection",
|
||||
"select_all_guests": "All guests",
|
||||
"select_vmids": "Selected VMIDs",
|
||||
"retention": "Retention (prune-backups)",
|
||||
"comment": "Comment",
|
||||
"mailto": "Email notifications",
|
||||
"add_native_job": "Add job",
|
||||
"edit_native_job": "Edit backup job",
|
||||
"delete_job_confirm": "Delete backup job",
|
||||
"job_created": "Backup job created",
|
||||
"job_updated": "Backup job updated",
|
||||
"job_deleted": "Backup job deleted",
|
||||
"job_enabled": "Job enabled",
|
||||
"job_disabled": "Job disabled",
|
||||
"job_schedule_storage_required": "Schedule and storage are required"
|
||||
},
|
||||
"backups": {
|
||||
"title": "Backups",
|
||||
@@ -582,6 +625,10 @@
|
||||
"notes": "Notes",
|
||||
"vmids": "VMIDs",
|
||||
"storage_col": "Storage",
|
||||
"keep_last": "Last",
|
||||
"keep_daily": "Daily",
|
||||
"keep_weekly": "Weekly",
|
||||
"keep_monthly": "Monthly",
|
||||
"select_server": "Select server",
|
||||
"select_storage": "Select storage",
|
||||
"node": "Node",
|
||||
@@ -945,4 +992,4 @@
|
||||
"connected": "Connected",
|
||||
"error": "Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -220,6 +220,27 @@
|
||||
"owner_hint": "Назначить инстанс пользователю (только админ).",
|
||||
"saved_config": "Сохранённая конфигурация",
|
||||
"saved_config_hint": "Значения, сохранённые панелью при создании/переустановке.",
|
||||
"migrate": "Миграция",
|
||||
"migrate_desc": "Перенести на другую ноду кластера:",
|
||||
"migrate_started": "Миграция запущена в фоне",
|
||||
"migrate_target_node": "Целевая нода",
|
||||
"migrate_select_node": "Выберите ноду",
|
||||
"migrate_no_targets": "В кластере нет других нод.",
|
||||
"migrate_target_storage": "Целевое хранилище",
|
||||
"migrate_same_storage": "Оставить как у источника",
|
||||
"migrate_online": "Онлайн (live) миграция",
|
||||
"migrate_online_hint_running": "Инстанс запущен — live-миграция переносит его без остановки.",
|
||||
"migrate_online_hint_stopped": "Инстанс остановлен — оставьте выключенным для обычной офлайн-миграции.",
|
||||
"autostart": "Автозапуск",
|
||||
"autostart_saved": "Настройки автозапуска сохранены",
|
||||
"start_on_boot": "Запускать при загрузке",
|
||||
"start_on_boot_hint": "Автоматически запускать инстанс при загрузке ноды.",
|
||||
"startup_order": "Порядок запуска",
|
||||
"startup_up_delay": "Задержка старта (сек)",
|
||||
"startup_down_timeout": "Таймаут остановки (сек)",
|
||||
"startup_order_hint": "Меньший порядок запускается раньше. Задержка старта — пауза перед следующим инстансом; таймаут остановки — пауза перед принудительным выключением. Пусто — значения по умолчанию.",
|
||||
"protection": "Защита от удаления",
|
||||
"protection_hint": "Запрещает удаление инстанса и его дисков.",
|
||||
"compute_resources": "Вычислительные ресурсы",
|
||||
"vcpu_cores": "Ядра vCPU",
|
||||
"current_cores": "Сейчас: {{count}} ядер",
|
||||
@@ -526,7 +547,11 @@
|
||||
"version": "Версия",
|
||||
"imported": "Импортировано",
|
||||
"select_server": "Выберите сервер",
|
||||
"import_btn": "Импортировать шаблоны"
|
||||
"import_btn": "Импортировать шаблоны",
|
||||
"group": "Группа",
|
||||
"edit_group": "Изменить группу",
|
||||
"select_group": "Выберите группу",
|
||||
"group_updated": "Группа обновлена"
|
||||
},
|
||||
"storages": {
|
||||
"title": "Хранилища",
|
||||
@@ -556,7 +581,25 @@
|
||||
"delete_confirm": "Удалить хранилище",
|
||||
"native_jobs": "Нативные задания бэкапов PVE",
|
||||
"no_native_jobs": "Нативных заданий нет",
|
||||
"schedule": "Расписание"
|
||||
"schedule": "Расписание",
|
||||
"schedule_hint": "systemd calendar event, напр. «sat 03:00» или «mon..fri 02:30».",
|
||||
"compress": "Сжатие",
|
||||
"no_compress": "Без сжатия",
|
||||
"selection": "Выбор гостей",
|
||||
"select_all_guests": "Все гости",
|
||||
"select_vmids": "Выбранные VMID",
|
||||
"retention": "Хранение (prune-backups)",
|
||||
"comment": "Комментарий",
|
||||
"mailto": "Email-уведомления",
|
||||
"add_native_job": "Добавить задание",
|
||||
"edit_native_job": "Редактировать задание",
|
||||
"delete_job_confirm": "Удалить задание бэкапа",
|
||||
"job_created": "Задание бэкапа создано",
|
||||
"job_updated": "Задание бэкапа обновлено",
|
||||
"job_deleted": "Задание бэкапа удалено",
|
||||
"job_enabled": "Задание включено",
|
||||
"job_disabled": "Задание выключено",
|
||||
"job_schedule_storage_required": "Нужны расписание и хранилище"
|
||||
},
|
||||
"backups": {
|
||||
"title": "Бэкапы",
|
||||
@@ -582,6 +625,10 @@
|
||||
"notes": "Заметки",
|
||||
"vmids": "VMID",
|
||||
"storage_col": "Хранилище",
|
||||
"keep_last": "Последних",
|
||||
"keep_daily": "Дневных",
|
||||
"keep_weekly": "Недельных",
|
||||
"keep_monthly": "Месячных",
|
||||
"select_server": "Выберите сервер",
|
||||
"select_storage": "Выберите хранилище",
|
||||
"node": "Узел",
|
||||
@@ -945,4 +992,4 @@
|
||||
"connected": "Подключено",
|
||||
"error": "Ошибка"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ export interface DeployTask {
|
||||
node: string | null;
|
||||
error_message: string | null;
|
||||
created_at: number;
|
||||
kind?: 'deploy' | 'reinstall' | 'clone' | 'change_password' | 'image_download' | 'image_template';
|
||||
kind?: 'deploy' | 'reinstall' | 'clone' | 'change_password' | 'image_download' | 'image_template' | 'migrate';
|
||||
server_id?: number | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -133,6 +133,8 @@ export interface VMConfig {
|
||||
boot?: string;
|
||||
agent?: number;
|
||||
onboot?: number;
|
||||
startup?: string;
|
||||
protection?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user