feat(instances): add VM/LXC migration, autostart/boot order, protection
All checks were successful
Backend Tests / pytest (Python 3.12) (push) Successful in 52s
Deploy to Production / Trigger production update (push) Successful in 7s

- 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:
markmorado
2026-07-03 02:40:58 +05:00
parent e8bce3e8ae
commit c12fbf13e5
30 changed files with 1411 additions and 111 deletions

View File

@@ -109,7 +109,10 @@ function SelectContent({
sideOffset = 4,
align = "center",
alignOffset = 0,
alignItemWithTrigger = true,
// Открывать список как обычную выпадашку под/над полем, а не выравнивать
// выбранный пункт над триггером — иначе длинные списки (напр. в диалогах)
// «уезжают» вверх и отрываются от поля.
alignItemWithTrigger = false,
...props
}: SelectPrimitive.Popup.Props &
Pick<

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -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 }) {

View File

@@ -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 образ')}

View File

@@ -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 */}

View File

@@ -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>
);
}

View File

@@ -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 */}

View File

@@ -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({

View File

@@ -75,6 +75,7 @@ export interface ImageDownloadRequest {
checksum_algorithm?: string;
// Авто-конвертация qcow2 → VM-шаблон
to_template?: boolean;
vmid?: number;
disk_storage?: string;
cores?: number;
memory?: number;

View File

@@ -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';

View File

@@ -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 }[] }>(

View File

@@ -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' }),
),
});
}

View File

@@ -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"
}
}
}

View File

@@ -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": "Ошибка"
}
}
}

View File

@@ -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;
}

View File

@@ -133,6 +133,8 @@ export interface VMConfig {
boot?: string;
agent?: number;
onboot?: number;
startup?: string;
protection?: number;
[key: string]: unknown;
}