fix(reinstall): use OSTemplate.vmid (not template_vmid) and convert memory bytes->MB
- AttributeError: OSTemplate has no 'template_vmid' field; the actual column is 'vmid'. - Use OSTemplate.get_vmid_for_node() to support cross-node replicated templates, falling back to source_node/vmid otherwise. - VMInstance.memory is stored in bytes, but Proxmox config expects MB.
This commit is contained in:
@@ -49,12 +49,27 @@ export default function ConsolePage() {
|
||||
`/proxmox/api/${sid}/vm/${vid}/vnc?node=${node}`
|
||||
);
|
||||
|
||||
// Dynamic import noVNC
|
||||
// Dynamic import noVNC. The package ships CJS, so depending on bundler
|
||||
// interop the result can be: { default: RFB }, { default: { default: RFB, __esModule: true } },
|
||||
// or the raw CJS namespace. Normalise to the actual constructor.
|
||||
// @ts-expect-error noVNC doesn't have TS declarations
|
||||
const rfbModule = await import('@novnc/novnc/lib/rfb');
|
||||
// Handle both ESM default export and CJS module.exports
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const RFB = (rfbModule.default ?? rfbModule) as any;
|
||||
const mod: any = rfbModule;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const RFB: any =
|
||||
typeof mod === 'function'
|
||||
? mod
|
||||
: typeof mod.default === 'function'
|
||||
? mod.default
|
||||
: typeof mod.default?.default === 'function'
|
||||
? mod.default.default
|
||||
: typeof mod.RFB === 'function'
|
||||
? mod.RFB
|
||||
: null;
|
||||
if (typeof RFB !== 'function') {
|
||||
throw new Error('noVNC RFB constructor not found in module exports');
|
||||
}
|
||||
|
||||
const wsProto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const params = new URLSearchParams({
|
||||
|
||||
@@ -274,7 +274,7 @@ export default function DashboardPage() {
|
||||
{/* Resource Cards */}
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<ResourceCard
|
||||
label="CPU"
|
||||
label={t('common.cpu')}
|
||||
percent={cpuPercent}
|
||||
used={cpuUsedLabel}
|
||||
total={cpuTotalLabel}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useServers } from '@/hooks/use-nodes';
|
||||
import { useTemplates, useTemplateGroups } from '@/hooks/use-templates';
|
||||
import { useIPAMNetworks } from '@/hooks/use-ipam';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { useDeployTasksStore } from '@/stores/deploy-tasks-store';
|
||||
import type { VMDeployRequest, OSTemplate, ProxmoxServer } from '@/types';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
@@ -26,6 +27,8 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
|
||||
const [step, setStep] = useState<Step>('server');
|
||||
const stepIdx = STEPS.indexOf(step);
|
||||
|
||||
const addDeployTask = useDeployTasksStore((s) => s.addTask);
|
||||
|
||||
// State
|
||||
const [selectedServer, setSelectedServer] = useState<ProxmoxServer | null>(null);
|
||||
const [selectedNode, setSelectedNode] = useState('');
|
||||
@@ -67,10 +70,20 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
|
||||
const { data: networks = [] } = useIPAMNetworks();
|
||||
|
||||
const deploy = useMutation({
|
||||
mutationFn: (data: VMDeployRequest) => apiClient.post<{ success: boolean; vmid: number; name: string }>('/proxmox/api/deploy', data),
|
||||
onSuccess: () => {
|
||||
toast.success(t('wizard.deploy_success', { name: config.name }));
|
||||
handleClose();
|
||||
mutationFn: (data: VMDeployRequest) =>
|
||||
apiClient.post<{ task_id: number; status: string; name: string }>('/templates/api/deploy', data),
|
||||
onSuccess: (res) => {
|
||||
addDeployTask({
|
||||
id: res.task_id,
|
||||
name: res.name,
|
||||
status: 'pending',
|
||||
step: t('wizard.deploy_starting'),
|
||||
progress: 0,
|
||||
vmid: null,
|
||||
node: null,
|
||||
error_message: null,
|
||||
});
|
||||
toast.success(t('wizard.deploy_queued', { name: res.name }));
|
||||
navigate('/instances');
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
@@ -129,18 +142,18 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
|
||||
{/* Step indicator */}
|
||||
<div className="flex items-center gap-2">
|
||||
{STEPS.map((s, i) => (
|
||||
<div key={s} className="flex items-center gap-2">
|
||||
<div className={`flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium ${
|
||||
i < stepIdx ? 'bg-green-500/20 text-green-400' :
|
||||
i === stepIdx ? 'bg-blue-500/20 text-blue-400 ring-2 ring-blue-500/50' :
|
||||
'bg-muted text-muted-foreground'
|
||||
}`}>
|
||||
{i < stepIdx ? <CheckCircle className="h-4 w-4" /> : i + 1}
|
||||
<div key={s} className="flex items-center gap-2">
|
||||
<div className={`flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium ${
|
||||
i < stepIdx ? 'bg-green-500/20 text-green-400' :
|
||||
i === stepIdx ? 'bg-blue-500/20 text-blue-400 ring-2 ring-blue-500/50' :
|
||||
'bg-muted text-muted-foreground'
|
||||
}`}>
|
||||
{i < stepIdx ? <CheckCircle className="h-4 w-4" /> : i + 1}
|
||||
</div>
|
||||
{i < STEPS.length - 1 && <div className={`h-0.5 w-8 ${i < stepIdx ? 'bg-green-500/50' : 'bg-border'}`} />}
|
||||
</div>
|
||||
{i < STEPS.length - 1 && <div className={`h-0.5 w-8 ${i < stepIdx ? 'bg-green-500/50' : 'bg-border'}`} />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Step 1: Server */}
|
||||
{step === 'server' && (
|
||||
@@ -222,7 +235,7 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
|
||||
<CardContent className="space-y-3">
|
||||
<div>
|
||||
<Label>{t('common.name')}</Label>
|
||||
<Input value={config.name} onChange={e => setConfig(p => ({ ...p, name: e.target.value }))} placeholder="my-vm-01" />
|
||||
<Input value={config.name} onChange={e => setConfig(p => ({ ...p, name: e.target.value }))} placeholder={t('common.placeholder_vm_name')} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t('wizard.target_node')}</Label>
|
||||
@@ -316,7 +329,7 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
|
||||
<CardContent className="space-y-3">
|
||||
<div>
|
||||
<Label>{t('wizard.ci_user')}</Label>
|
||||
<Input value={config.cloud_init_user} onChange={e => setConfig(p => ({ ...p, cloud_init_user: e.target.value }))} placeholder="ubuntu" />
|
||||
<Input value={config.cloud_init_user} onChange={e => setConfig(p => ({ ...p, cloud_init_user: e.target.value }))} placeholder={t('common.placeholder_ubuntu')} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t('wizard.ci_password')}</Label>
|
||||
@@ -328,7 +341,7 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
|
||||
className="w-full rounded-md border bg-transparent px-3 py-2 text-sm font-mono min-h-[60px]"
|
||||
value={config.ssh_keys}
|
||||
onChange={e => setConfig(p => ({ ...p, ssh_keys: e.target.value }))}
|
||||
placeholder="ssh-rsa AAAA..."
|
||||
placeholder={t('common.placeholder_ssh_key')}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -346,12 +359,12 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
|
||||
<Row label={t('wizard.server_label')} value={selectedServer?.name || ''} />
|
||||
<Row label={t('wizard.template_label')} value={selectedTemplate?.name || ''} />
|
||||
<Row label={t('common.name')} value={config.name} />
|
||||
<Row label="vCPU" value={String(config.cores)} />
|
||||
<Row label={t('common.vcpu')} value={String(config.cores)} />
|
||||
<Row label={t('wizard.memory_mb')} value={`${config.memory} MB`} />
|
||||
<Row label={t('wizard.disk_gb')} value={`${config.disk} GB`} />
|
||||
<Row label={t('wizard.bridge')} value={config.bridge} />
|
||||
{config.ipam_network_id && <Row label="IPAM" value={networks.find(n => n.id === config.ipam_network_id)?.name || 'Auto'} />}
|
||||
{config.ip_address && <Row label="IP" value={config.ip_address} />}
|
||||
{config.ipam_network_id && <Row label={t('common.ipam')} value={networks.find(n => n.id === config.ipam_network_id)?.name || 'Auto'} />}
|
||||
{config.ip_address && <Row label={t('common.primary_ip')} value={config.ip_address} />}
|
||||
{config.cloud_init_user && <Row label={t('wizard.ci_user')} value={config.cloud_init_user} />}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
498
frontend/src/features/instances/InstanceActionDialogs.tsx
Normal file
498
frontend/src/features/instances/InstanceActionDialogs.tsx
Normal file
@@ -0,0 +1,498 @@
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Loader2, Disc, Eye, EyeOff, Sparkles, Copy, KeyRound, FileText, Terminal as TerminalIcon } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
useCloneInstance,
|
||||
useReinstallInstance,
|
||||
useChangePassword,
|
||||
useUpdateNotes,
|
||||
useExecuteCommand,
|
||||
useNodeIsos,
|
||||
useAttachIso,
|
||||
useDetachIso,
|
||||
useVMConfig,
|
||||
} from '@/hooks/use-instances';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export type InstanceAction =
|
||||
| 'clone'
|
||||
| 'reinstall'
|
||||
| 'change-password'
|
||||
| 'notes'
|
||||
| 'execute'
|
||||
| 'iso'
|
||||
| null;
|
||||
|
||||
interface Props {
|
||||
open: InstanceAction;
|
||||
onOpenChange: (open: InstanceAction) => void;
|
||||
serverId: number;
|
||||
vmid: number;
|
||||
type: string; // 'qemu' | 'lxc'
|
||||
node: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export default function InstanceActionDialogs(props: Props) {
|
||||
const { open, onOpenChange, serverId, vmid, type, node, name, description } = props;
|
||||
const close = () => onOpenChange(null);
|
||||
const shared = { serverId, vmid, type, node, name, description };
|
||||
|
||||
return (
|
||||
<>
|
||||
<CloneDialog open={open === 'clone'} 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} />
|
||||
<ExecuteCommandDialog open={open === 'execute'} onClose={close} {...shared} />
|
||||
<IsoDialog open={open === 'iso'} onClose={close} {...shared} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Clone ====================
|
||||
|
||||
function CloneDialog({ open, onClose, serverId, vmid, type, node, name }: Omit<Props, 'open' | 'onOpenChange'> & { open: boolean; onClose: () => void }) {
|
||||
const [newName, setNewName] = useState('');
|
||||
const [full, setFull] = useState(true);
|
||||
const clone = useCloneInstance(serverId, vmid, type, node);
|
||||
|
||||
const submit = () => {
|
||||
if (!newName.trim()) return;
|
||||
clone.mutate(
|
||||
{ new_name: newName.trim(), full },
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
toast.success(`Cloned to VM #${data.new_vmid}`);
|
||||
setNewName('');
|
||||
onClose();
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2"><Copy className="h-4 w-4" /> Клонировать</DialogTitle>
|
||||
<DialogDescription>Создать копию <strong>{name ?? `#${vmid}`}</strong></DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="clone-name">Имя новой инстанции</Label>
|
||||
<Input id="clone-name" value={newName} onChange={(e) => setNewName(e.target.value)} placeholder={`${name || 'instance'}-clone`} />
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={full} onChange={(e) => setFull(e.target.checked)} />
|
||||
<span>Полный клон (full clone)</span>
|
||||
</label>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>Отмена</Button>
|
||||
<Button onClick={submit} disabled={!newName.trim() || clone.isPending}>
|
||||
{clone.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Клонировать
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Reinstall ====================
|
||||
|
||||
function ReinstallDialog({ open, onClose, serverId, vmid, node, name, type }: Omit<Props, 'open' | 'onOpenChange'> & { open: boolean; onClose: () => void }) {
|
||||
const [confirm, setConfirm] = useState('');
|
||||
const reinstall = useReinstallInstance(serverId, vmid, node);
|
||||
const expected = name || String(vmid);
|
||||
|
||||
// Reinstall via this endpoint currently supports only saved-template instances.
|
||||
const submit = () => {
|
||||
if (confirm !== expected) return;
|
||||
reinstall.mutate(undefined, {
|
||||
onSuccess: () => {
|
||||
toast.success(`Reinstall queued for ${expected}`);
|
||||
setConfirm('');
|
||||
onClose();
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2"><Sparkles className="h-4 w-4" /> Переустановить</DialogTitle>
|
||||
<DialogDescription>
|
||||
Это пересоздаст инстанс <strong>{expected}</strong> из исходного шаблона. Все данные будут утеряны.
|
||||
{type === 'lxc' && (
|
||||
<span className="mt-2 block text-amber-500">Поддержка LXC — экспериментальная.</span>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label>Введите <strong className="text-foreground">{expected}</strong> для подтверждения</Label>
|
||||
<Input value={confirm} onChange={(e) => setConfirm(e.target.value)} placeholder={expected} />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>Отмена</Button>
|
||||
<Button variant="destructive" onClick={submit} disabled={confirm !== expected || reinstall.isPending}>
|
||||
{reinstall.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Переустановить
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Change Password ====================
|
||||
|
||||
function ChangePasswordDialog({ open, onClose, serverId, vmid, type, node }: Omit<Props, 'open' | 'onOpenChange'> & { open: boolean; onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [username, setUsername] = useState('root');
|
||||
const [password, setPassword] = useState('');
|
||||
const [show, setShow] = useState(false);
|
||||
const mut = useChangePassword(serverId, vmid, type, node);
|
||||
|
||||
const submit = () => {
|
||||
if (!password || password.length < 4) {
|
||||
toast.error('Password must be at least 4 chars');
|
||||
return;
|
||||
}
|
||||
mut.mutate(
|
||||
{ username, password },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success('Password changed');
|
||||
setPassword('');
|
||||
onClose();
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2"><KeyRound className="h-4 w-4" /> Изменить пароль</DialogTitle>
|
||||
<DialogDescription>
|
||||
{type === 'qemu'
|
||||
? 'Требуется установленный qemu-guest-agent внутри VM.'
|
||||
: 'Контейнер должен быть запущен.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label>Пользователь</Label>
|
||||
<Input value={username} onChange={(e) => setUsername(e.target.value)} placeholder={t('common.placeholder_root')} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Новый пароль</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={show ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-1 top-1 h-7 w-7"
|
||||
onClick={() => setShow((s) => !s)}
|
||||
>
|
||||
{show ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>Отмена</Button>
|
||||
<Button onClick={submit} disabled={!password || mut.isPending}>
|
||||
{mut.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Изменить
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Notes ====================
|
||||
|
||||
function NotesDialog({ open, onClose, serverId, vmid, type, node, description }: Omit<Props, 'open' | 'onOpenChange'> & { open: boolean; onClose: () => void }) {
|
||||
const config = useVMConfig(serverId, vmid, type, node, open);
|
||||
const remote = (config.data?.description as string | undefined) ?? description ?? '';
|
||||
const [value, setValue] = useState('');
|
||||
const [touched, setTouched] = useState(false);
|
||||
|
||||
// Sync remote → local when dialog opens or config loads, unless user typed
|
||||
useEffect(() => {
|
||||
if (open && !touched) setValue(remote);
|
||||
if (!open) setTouched(false);
|
||||
}, [open, remote, touched]);
|
||||
|
||||
const mut = useUpdateNotes(serverId, vmid, type, node);
|
||||
|
||||
const submit = () => {
|
||||
mut.mutate(
|
||||
{ description: value },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success('Notes saved');
|
||||
setTouched(false);
|
||||
onClose();
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2"><FileText className="h-4 w-4" /> Примечание</DialogTitle>
|
||||
<DialogDescription>Свободный текст / markdown, видимый в Proxmox UI.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(e) => { setTouched(true); setValue(e.target.value); }}
|
||||
rows={8}
|
||||
className="min-h-32 w-full rounded-md border bg-background p-2 text-sm font-mono outline-none focus:ring-1 focus:ring-ring"
|
||||
placeholder="Описание / заметки об инстансе..."
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>Отмена</Button>
|
||||
<Button onClick={submit} disabled={mut.isPending}>
|
||||
{mut.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Сохранить
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Execute Command ====================
|
||||
|
||||
function ExecuteCommandDialog({ open, onClose, serverId, vmid, node, type }: Omit<Props, 'open' | 'onOpenChange'> & { open: boolean; onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [command, setCommand] = useState('');
|
||||
const [timeout, setTimeoutVal] = useState(30);
|
||||
const exec = useExecuteCommand(serverId, vmid);
|
||||
const result = exec.data;
|
||||
|
||||
const submit = () => {
|
||||
if (!command.trim()) return;
|
||||
exec.mutate({ node, command, timeout });
|
||||
};
|
||||
|
||||
if (type !== 'qemu') {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Запуск команд недоступен</DialogTitle>
|
||||
<DialogDescription>Поддержка через qemu-guest-agent — только для KVM (qemu) инстансов.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button onClick={onClose}>OK</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2"><TerminalIcon className="h-4 w-4" /> Запуск команды (qemu-agent)</DialogTitle>
|
||||
<DialogDescription>Команда выполняется внутри VM через guest-agent.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="grid gap-3 sm:grid-cols-[1fr_120px]">
|
||||
<div>
|
||||
<Label>Команда</Label>
|
||||
<Input
|
||||
value={command}
|
||||
onChange={(e) => setCommand(e.target.value)}
|
||||
placeholder={t('common.placeholder_command')}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') submit(); }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Таймаут (с)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={300}
|
||||
value={timeout}
|
||||
onChange={(e) => setTimeoutVal(Number(e.target.value) || 30)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{result && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs">
|
||||
exit_code: <span className={result.exit_code === 0 ? 'text-green-500' : 'text-red-500'}>{result.exit_code}</span>
|
||||
</div>
|
||||
{result.stdout && (
|
||||
<div>
|
||||
<p className="mb-1 text-xs text-muted-foreground">stdout</p>
|
||||
<pre className="max-h-48 overflow-auto rounded-md bg-muted p-2 text-xs font-mono">{result.stdout}</pre>
|
||||
</div>
|
||||
)}
|
||||
{result.stderr && (
|
||||
<div>
|
||||
<p className="mb-1 text-xs text-muted-foreground">stderr</p>
|
||||
<pre className="max-h-32 overflow-auto rounded-md bg-red-500/10 p-2 text-xs font-mono text-red-500">{result.stderr}</pre>
|
||||
</div>
|
||||
)}
|
||||
{result.error && <p className="text-xs text-red-500">{result.error}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>Закрыть</Button>
|
||||
<Button onClick={submit} disabled={!command.trim() || exec.isPending}>
|
||||
{exec.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Выполнить
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== ISO mount/unmount ====================
|
||||
|
||||
function IsoDialog({ open, onClose, serverId, vmid, node, type }: Omit<Props, 'open' | 'onOpenChange'> & { open: boolean; onClose: () => void }) {
|
||||
const [device, setDevice] = useState('ide2');
|
||||
const [volid, setVolid] = useState<string>('');
|
||||
const isQemu = type === 'qemu';
|
||||
const isos = useNodeIsos(serverId, node, open && isQemu);
|
||||
const config = useVMConfig(serverId, vmid, type, node, open && isQemu);
|
||||
const attach = useAttachIso(serverId, vmid, node);
|
||||
const detach = useDetachIso(serverId, vmid, node);
|
||||
|
||||
const currentIso = useMemo(() => {
|
||||
const v = config.data?.[device];
|
||||
if (typeof v !== 'string') return null;
|
||||
if (v.startsWith('none')) return null;
|
||||
return v;
|
||||
}, [config.data, device]);
|
||||
|
||||
if (!isQemu) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>ISO недоступно для LXC</DialogTitle>
|
||||
<DialogDescription>Подключение ISO-образов поддерживается только для KVM (qemu) виртуальных машин.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button onClick={onClose}>OK</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2"><Disc className="h-4 w-4" /> ISO образ</DialogTitle>
|
||||
<DialogDescription>Подключите ISO к виртуальному CD-ROM устройству.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label>Устройство</Label>
|
||||
<Select value={device} onValueChange={(v) => v && setDevice(v)}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ide0">ide0</SelectItem>
|
||||
<SelectItem value="ide1">ide1</SelectItem>
|
||||
<SelectItem value="ide2">ide2</SelectItem>
|
||||
<SelectItem value="ide3">ide3</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground self-end">
|
||||
Сейчас: <span className="font-mono">{currentIso || 'пусто'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Образ</Label>
|
||||
<Select value={volid} onValueChange={(v) => v && setVolid(v)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={isos.isLoading ? 'Загрузка...' : 'Выберите ISO'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(isos.data?.isos || []).map((iso) => (
|
||||
<SelectItem key={iso.volid} value={iso.volid}>{iso.name || iso.volid}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{isos.data?.isos?.length === 0 && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">На ноде нет загруженных ISO образов.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => detach.mutate({ device }, {
|
||||
onSuccess: () => { toast.success('ISO отключён'); onClose(); },
|
||||
onError: (e) => toast.error(e.message),
|
||||
})}
|
||||
disabled={detach.isPending || !currentIso}
|
||||
>
|
||||
{detach.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Отключить
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => attach.mutate({ volid, device }, {
|
||||
onSuccess: () => { toast.success('ISO подключён'); onClose(); },
|
||||
onError: (e) => toast.error(e.message),
|
||||
})}
|
||||
disabled={!volid || attach.isPending}
|
||||
>
|
||||
{attach.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Подключить
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useParams, useSearchParams, Link } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
@@ -9,12 +10,27 @@ import {
|
||||
Monitor,
|
||||
Container,
|
||||
Loader2,
|
||||
Power,
|
||||
MoreHorizontal,
|
||||
Copy,
|
||||
Sparkles,
|
||||
KeyRound,
|
||||
FileText,
|
||||
Disc,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { StatusDot } from '@/components/shared/status-dot';
|
||||
import { useVirtualMachines, usePowerAction } from '@/hooks/use-instances';
|
||||
import InstanceActionDialogs, { type InstanceAction } from './InstanceActionDialogs';
|
||||
import { vmTypeLabel } from '@/lib/format';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
@@ -43,7 +59,9 @@ export default function InstanceDetailPage() {
|
||||
|
||||
const power = usePowerAction(sid, vid, type, node);
|
||||
const isRunning = vm?.status === 'running';
|
||||
const isQemu = type === 'qemu';
|
||||
const TypeIcon = type === 'qemu' ? Monitor : Container;
|
||||
const [dialog, setDialog] = useState<InstanceAction>(null);
|
||||
|
||||
const handlePower = (action: string) => {
|
||||
power.mutate(
|
||||
@@ -131,6 +149,15 @@ export default function InstanceDetailPage() {
|
||||
<RotateCcw className="mr-1.5 h-4 w-4" />
|
||||
{t('common.restart', 'Restart')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handlePower('shutdown')}
|
||||
disabled={power.isPending}
|
||||
>
|
||||
<Power className="mr-1.5 h-4 w-4" />
|
||||
{t('common.shutdown', 'Shutdown')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
@@ -142,6 +169,36 @@ export default function InstanceDetailPage() {
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={<Button variant="outline" size="sm" />}>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setDialog('clone')}>
|
||||
<Copy className="mr-2 h-4 w-4" /> {t('common.clone', 'Клонировать')}
|
||||
</DropdownMenuItem>
|
||||
{isQemu && (
|
||||
<DropdownMenuItem onClick={() => setDialog('iso')}>
|
||||
<Disc className="mr-2 h-4 w-4" /> {t('common.iso', 'ISO образ')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={() => setDialog('change-password')}>
|
||||
<KeyRound className="mr-2 h-4 w-4" /> {t('common.changePassword', 'Изменить пароль')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setDialog('notes')}>
|
||||
<FileText className="mr-2 h-4 w-4" /> {t('common.notes', 'Примечание')}
|
||||
</DropdownMenuItem>
|
||||
{isQemu && (
|
||||
<DropdownMenuItem onClick={() => setDialog('execute')}>
|
||||
<Terminal className="mr-2 h-4 w-4" /> {t('common.runCommand', 'Запуск команд')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => setDialog('reinstall')}>
|
||||
<Sparkles className="mr-2 h-4 w-4" /> {t('common.reinstall', 'Переустановить')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -178,6 +235,17 @@ export default function InstanceDetailPage() {
|
||||
<DestroyTab serverId={sid} vmid={vid} type={type} node={node} name={vm?.name} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<InstanceActionDialogs
|
||||
open={dialog}
|
||||
onOpenChange={setDialog}
|
||||
serverId={sid}
|
||||
vmid={vid}
|
||||
type={type}
|
||||
node={node}
|
||||
name={vm?.name}
|
||||
description={vm?.description}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
@@ -28,7 +28,14 @@ import {
|
||||
Monitor,
|
||||
Container,
|
||||
Loader2,
|
||||
Power,
|
||||
Copy,
|
||||
Sparkles,
|
||||
KeyRound,
|
||||
FileText,
|
||||
Disc,
|
||||
} from 'lucide-react';
|
||||
import InstanceActionDialogs, { type InstanceAction } from './InstanceActionDialogs';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -58,8 +65,10 @@ import {
|
||||
} from '@/components/ui/table';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { StatusDot } from '@/components/shared/status-dot';
|
||||
import { useVirtualMachines, useBulkOperation, usePowerAction } from '@/hooks/use-instances';
|
||||
import { useVirtualMachines, useBulkOperation, usePowerAction, useVMStatusSync } from '@/hooks/use-instances';
|
||||
import { formatBytes, vmTypeLabel, formatUptime } from '@/lib/format';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { useDeployTasksStore } from '@/stores/deploy-tasks-store';
|
||||
import type { VMInstance } from '@/types';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
@@ -77,11 +86,33 @@ function InlineProgress({ value, max, className }: { value: number; max: number;
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Circular Progress ====================
|
||||
function CircularProgress({ value }: { value: number }) {
|
||||
const r = 10;
|
||||
const circ = 2 * Math.PI * r;
|
||||
const offset = circ - (value / 100) * circ;
|
||||
return (
|
||||
<svg width="28" height="28" viewBox="0 0 28 28" className="shrink-0">
|
||||
<circle cx="14" cy="14" r={r} fill="none" stroke="currentColor" strokeWidth="3" className="text-muted" />
|
||||
<circle
|
||||
cx="14" cy="14" r={r} fill="none"
|
||||
stroke="currentColor" strokeWidth="3"
|
||||
strokeDasharray={circ} strokeDashoffset={offset}
|
||||
strokeLinecap="round"
|
||||
className="text-blue-500 transition-all duration-500"
|
||||
transform="rotate(-90 14 14)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Row Action Menu ====================
|
||||
function RowActionMenu({ vm }: { vm: VMInstance }) {
|
||||
const { t } = useTranslation();
|
||||
const power = usePowerAction(vm.server_id!, vm.vmid, vm.type, vm.node);
|
||||
const isRunning = vm.status === 'running';
|
||||
const isQemu = vm.type === 'qemu';
|
||||
const [dialog, setDialog] = useState<InstanceAction>(null);
|
||||
|
||||
const handleAction = (action: string) => {
|
||||
power.mutate(
|
||||
@@ -94,11 +125,12 @@ function RowActionMenu({ vm }: { vm: VMInstance }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={<Button variant="ghost" size="icon" className="h-8 w-8" />}>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuItem render={<Link to={`/console/${vm.server_id}/${vm.vmid}?node=${vm.node}&type=${vm.type}`} target="_blank" rel="noopener noreferrer" />}>
|
||||
<Terminal className="mr-2 h-4 w-4" /> {t('common.console', 'Console')}
|
||||
</DropdownMenuItem>
|
||||
@@ -112,17 +144,54 @@ function RowActionMenu({ vm }: { vm: VMInstance }) {
|
||||
<DropdownMenuItem onClick={() => handleAction('restart')}>
|
||||
<RotateCcw className="mr-2 h-4 w-4" /> {t('common.restart', 'Restart')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleAction('shutdown')}>
|
||||
<Power className="mr-2 h-4 w-4" /> {t('common.shutdown', 'Shutdown')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleAction('stop')}>
|
||||
<Square className="mr-2 h-4 w-4" /> {t('common.stop', 'Stop')}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => setDialog('clone')}>
|
||||
<Copy className="mr-2 h-4 w-4" /> {t('common.clone', 'Клонировать')}
|
||||
</DropdownMenuItem>
|
||||
{isQemu && (
|
||||
<DropdownMenuItem onClick={() => setDialog('iso')}>
|
||||
<Disc className="mr-2 h-4 w-4" /> {t('common.iso', 'ISO образ')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={() => setDialog('change-password')}>
|
||||
<KeyRound className="mr-2 h-4 w-4" /> {t('common.changePassword', 'Изменить пароль')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setDialog('notes')}>
|
||||
<FileText className="mr-2 h-4 w-4" /> {t('common.notes', 'Примечание')}
|
||||
</DropdownMenuItem>
|
||||
{isQemu && (
|
||||
<DropdownMenuItem onClick={() => setDialog('execute')}>
|
||||
<Terminal className="mr-2 h-4 w-4" /> {t('common.runCommand', 'Запуск команд')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem render={<Link to={`/instances/${vm.server_id}/${vm.vmid}?node=${vm.node}&type=${vm.type}&tab=snapshots`} />}>
|
||||
<Camera className="mr-2 h-4 w-4" /> {t('common.snapshots', 'Snapshots')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setDialog('reinstall')}>
|
||||
<Sparkles className="mr-2 h-4 w-4" /> {t('common.reinstall', 'Переустановить')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<InstanceActionDialogs
|
||||
open={dialog}
|
||||
onOpenChange={setDialog}
|
||||
serverId={vm.server_id!}
|
||||
vmid={vm.vmid}
|
||||
type={vm.type}
|
||||
node={vm.node}
|
||||
name={vm.name}
|
||||
description={vm.description}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -130,8 +199,42 @@ function RowActionMenu({ vm }: { vm: VMInstance }) {
|
||||
export default function InstancesPage() {
|
||||
const { t } = useTranslation();
|
||||
const { data: vms, isLoading } = useVirtualMachines();
|
||||
useVMStatusSync();
|
||||
const bulkOp = useBulkOperation();
|
||||
|
||||
// ── Deploy task polling ──────────────────────────────────────────
|
||||
const { tasks: deployTasks, updateTask, removeTask } = useDeployTasksStore();
|
||||
const activeTasks = deployTasks.filter((t) => t.status === 'pending' || t.status === 'running');
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTasks.length === 0) return;
|
||||
const poll = async () => {
|
||||
for (const task of activeTasks) {
|
||||
try {
|
||||
const data = await apiClient.get<{
|
||||
id: number; status: string; step: string | null; progress: number;
|
||||
vmid: number | null; node: string | null; error_message: string | null;
|
||||
}>(`/templates/api/deploy/${task.id}`);
|
||||
updateTask(task.id, {
|
||||
status: data.status as 'pending' | 'running' | 'completed' | 'failed',
|
||||
step: data.step,
|
||||
progress: data.progress,
|
||||
vmid: data.vmid ?? undefined,
|
||||
node: data.node ?? undefined,
|
||||
error_message: data.error_message ?? undefined,
|
||||
});
|
||||
if (data.status === 'completed') {
|
||||
setTimeout(() => removeTask(task.id), 5000);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
};
|
||||
poll();
|
||||
const timer = setInterval(poll, 2000);
|
||||
return () => clearInterval(timer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeTasks.length, activeTasks.map((t) => t.id).join(',')]);
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [nodeFilter, setNodeFilter] = useState<string>('all');
|
||||
@@ -139,21 +242,14 @@ export default function InstancesPage() {
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
|
||||
// Filter data
|
||||
// Filter real VMs
|
||||
const filteredVMs = useMemo(() => {
|
||||
if (!vms) return [];
|
||||
return vms.filter((vm) => {
|
||||
// Type tab filter
|
||||
if (typeTab === 'vm' && vm.type !== 'qemu') return false;
|
||||
if (typeTab === 'lxc' && vm.type !== 'lxc') return false;
|
||||
|
||||
// Status filter
|
||||
if (statusFilter !== 'all' && vm.status !== statusFilter) return false;
|
||||
|
||||
// Node filter
|
||||
if (nodeFilter !== 'all' && vm.node !== nodeFilter) return false;
|
||||
|
||||
// Search filter
|
||||
if (search) {
|
||||
const q = search.toLowerCase();
|
||||
const name = (vm.name || '').toLowerCase();
|
||||
@@ -161,18 +257,34 @@ export default function InstancesPage() {
|
||||
const vmid = String(vm.vmid);
|
||||
if (!name.includes(q) && !ip.includes(q) && !vmid.includes(q)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}, [vms, typeTab, statusFilter, nodeFilter, search]);
|
||||
|
||||
// Ghost rows from active deploy tasks (prepended before real VMs)
|
||||
const ghostRows = useMemo<VMInstance[]>(() => {
|
||||
return deployTasks
|
||||
.filter((t) => t.status === 'pending' || t.status === 'running')
|
||||
.map((t) => ({
|
||||
vmid: t.vmid ?? 0,
|
||||
name: t.name,
|
||||
status: 'creating' as const,
|
||||
type: 'qemu' as const,
|
||||
node: t.node ?? '...',
|
||||
server_id: undefined,
|
||||
_deployTaskId: t.id,
|
||||
}));
|
||||
}, [deployTasks]);
|
||||
|
||||
const allRows = useMemo<VMInstance[]>(() => [...ghostRows, ...filteredVMs], [ghostRows, filteredVMs]);
|
||||
|
||||
// Unique nodes for filter
|
||||
const uniqueNodes = useMemo(() => {
|
||||
if (!vms) return [];
|
||||
return [...new Set(vms.map((v) => v.node))].sort();
|
||||
}, [vms]);
|
||||
|
||||
// Counts for tabs
|
||||
// Counts (ghosts not counted in tabs)
|
||||
const counts = useMemo(() => {
|
||||
if (!vms) return { all: 0, vm: 0, lxc: 0 };
|
||||
return {
|
||||
@@ -182,15 +294,14 @@ export default function InstancesPage() {
|
||||
};
|
||||
}, [vms]);
|
||||
|
||||
// Selected VMs
|
||||
// Selected real VMs (skip ghost rows by checking _deployTaskId)
|
||||
const selectedVMs = useMemo(() => {
|
||||
return Object.keys(rowSelection)
|
||||
.filter((k) => rowSelection[k])
|
||||
.map((idx) => filteredVMs[Number(idx)])
|
||||
.filter(Boolean);
|
||||
}, [rowSelection, filteredVMs]);
|
||||
.map((idx) => allRows[Number(idx)])
|
||||
.filter((vm) => vm && !vm._deployTaskId);
|
||||
}, [rowSelection, allRows]);
|
||||
|
||||
// Bulk action handler
|
||||
const handleBulk = (action: string) => {
|
||||
if (!selectedVMs.length) return;
|
||||
bulkOp.mutate(
|
||||
@@ -205,10 +316,7 @@ export default function InstancesPage() {
|
||||
})),
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(`Bulk ${action}: ${selectedVMs.length} items queued`);
|
||||
setRowSelection({});
|
||||
},
|
||||
onSuccess: () => { toast.success(`Bulk ${action}: ${selectedVMs.length} items queued`); setRowSelection({}); },
|
||||
onError: (err) => toast.error(err.message),
|
||||
}
|
||||
);
|
||||
@@ -227,14 +335,18 @@ export default function InstancesPage() {
|
||||
onChange={table.getToggleAllPageRowsSelectedHandler()}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-border"
|
||||
checked={row.getIsSelected()}
|
||||
onChange={row.getToggleSelectedHandler()}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const isGhost = !!row.original._deployTaskId;
|
||||
if (isGhost) return null;
|
||||
return (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-border"
|
||||
checked={row.getIsSelected()}
|
||||
onChange={row.getToggleSelectedHandler()}
|
||||
/>
|
||||
);
|
||||
},
|
||||
size: 32,
|
||||
enableSorting: false,
|
||||
},
|
||||
@@ -253,6 +365,21 @@ export default function InstancesPage() {
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const vm = row.original;
|
||||
const isGhost = !!vm._deployTaskId;
|
||||
if (isGhost) {
|
||||
const task = deployTasks.find((t) => t.id === vm._deployTaskId);
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin text-blue-500 shrink-0" />
|
||||
<div>
|
||||
<span className="font-medium text-muted-foreground">{vm.name || 'New VM'}</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
{task?.node ? `· ${task.node}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Link
|
||||
to={`/instances/${vm.server_id}/${vm.vmid}?node=${vm.node}&type=${vm.type}`}
|
||||
@@ -272,22 +399,33 @@ export default function InstancesPage() {
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: t('common.status', 'Status'),
|
||||
cell: ({ getValue }) => {
|
||||
const status = getValue<string>();
|
||||
cell: ({ row }) => {
|
||||
const vm = row.original;
|
||||
const isGhost = !!vm._deployTaskId;
|
||||
if (isGhost) {
|
||||
const task = deployTasks.find((t) => t.id === vm._deployTaskId);
|
||||
const progress = task?.progress ?? 0;
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<CircularProgress value={progress} />
|
||||
<Badge variant="secondary" className="bg-blue-500/10 text-blue-600 text-[10px]">
|
||||
{t('instances.status_creating', 'Creating')}
|
||||
{progress > 0 ? ` ${progress}%` : ''}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const status = vm.status;
|
||||
return (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={
|
||||
status === 'running'
|
||||
? 'bg-green-500/10 text-green-500'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}
|
||||
className={status === 'running' ? 'bg-green-500/10 text-green-500' : 'bg-muted text-muted-foreground'}
|
||||
>
|
||||
{status}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
size: 100,
|
||||
size: 140,
|
||||
},
|
||||
{
|
||||
accessorKey: 'node',
|
||||
@@ -336,6 +474,7 @@ export default function InstancesPage() {
|
||||
header: 'CPU',
|
||||
cell: ({ row }) => {
|
||||
const vm = row.original;
|
||||
if (vm._deployTaskId) return <span className="text-xs text-muted-foreground">—</span>;
|
||||
if (!vm.cpu && vm.cpu !== 0) return <span className="text-xs text-muted-foreground">—</span>;
|
||||
return <InlineProgress value={vm.cpu * 100} max={100} />;
|
||||
},
|
||||
@@ -346,15 +485,13 @@ export default function InstancesPage() {
|
||||
header: t('dashboard.memory', 'Memory'),
|
||||
cell: ({ row }) => {
|
||||
const vm = row.original;
|
||||
if (!vm.mem || !vm.maxmem) return <span className="text-xs text-muted-foreground">—</span>;
|
||||
if (vm._deployTaskId || !vm.mem || !vm.maxmem) return <span className="text-xs text-muted-foreground">—</span>;
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<InlineProgress value={vm.mem} max={vm.maxmem} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{formatBytes(vm.mem)} / {formatBytes(vm.maxmem)}
|
||||
</TooltipContent>
|
||||
<TooltipContent>{formatBytes(vm.mem)} / {formatBytes(vm.maxmem)}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
@@ -365,15 +502,13 @@ export default function InstancesPage() {
|
||||
header: 'Disk',
|
||||
cell: ({ row }) => {
|
||||
const vm = row.original;
|
||||
if (!vm.disk && !vm.maxdisk) return <span className="text-xs text-muted-foreground">—</span>;
|
||||
if (vm._deployTaskId || (!vm.disk && !vm.maxdisk)) return <span className="text-xs text-muted-foreground">—</span>;
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<InlineProgress value={vm.disk || 0} max={vm.maxdisk || 0} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{formatBytes(vm.disk || 0)} / {formatBytes(vm.maxdisk || 0)}
|
||||
</TooltipContent>
|
||||
<TooltipContent>{formatBytes(vm.disk || 0)} / {formatBytes(vm.maxdisk || 0)}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
@@ -383,6 +518,7 @@ export default function InstancesPage() {
|
||||
id: 'uptime',
|
||||
header: 'Uptime',
|
||||
cell: ({ row }) => {
|
||||
if (row.original._deployTaskId) return <span className="text-xs text-muted-foreground">—</span>;
|
||||
const up = row.original.uptime;
|
||||
return <span className="text-xs text-muted-foreground">{up ? formatUptime(up) : '—'}</span>;
|
||||
},
|
||||
@@ -392,15 +528,14 @@ export default function InstancesPage() {
|
||||
id: 'tags',
|
||||
accessorKey: 'tags',
|
||||
header: 'Tags',
|
||||
cell: ({ getValue }) => {
|
||||
cell: ({ getValue, row }) => {
|
||||
if (row.original._deployTaskId) return null;
|
||||
const tags = getValue<string>();
|
||||
if (!tags) return null;
|
||||
return (
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{tags.split(/[;,]/).filter(Boolean).map((tag) => (
|
||||
<Badge key={tag} variant="secondary" className="text-[10px] leading-tight">
|
||||
{tag.trim()}
|
||||
</Badge>
|
||||
<Badge key={tag} variant="secondary" className="text-[10px] leading-tight">{tag.trim()}</Badge>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
@@ -409,17 +544,21 @@ export default function InstancesPage() {
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
cell: ({ row }) => <RowActionMenu vm={row.original} />,
|
||||
cell: ({ row }) => {
|
||||
if (row.original._deployTaskId) return null;
|
||||
return <RowActionMenu vm={row.original} />;
|
||||
},
|
||||
size: 48,
|
||||
enableSorting: false,
|
||||
},
|
||||
],
|
||||
[t]
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[t, deployTasks]
|
||||
);
|
||||
|
||||
// ==================== TanStack Table ====================
|
||||
const table = useReactTable({
|
||||
data: filteredVMs,
|
||||
data: allRows,
|
||||
columns,
|
||||
state: { sorting, rowSelection },
|
||||
onSortingChange: setSorting,
|
||||
@@ -522,7 +661,7 @@ export default function InstancesPage() {
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : filteredVMs.length === 0 ? (
|
||||
) : allRows.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||
<Monitor className="mb-3 h-10 w-10 opacity-40" />
|
||||
<p className="text-sm font-medium">{t('instances.no_instances', 'No instances found')}</p>
|
||||
@@ -542,15 +681,22 @@ export default function InstancesPage() {
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id} data-state={row.getIsSelected() && 'selected'}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
{table.getRowModel().rows.map((row) => {
|
||||
const isGhost = !!row.original._deployTaskId;
|
||||
return (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && 'selected'}
|
||||
className={isGhost ? 'opacity-70 bg-blue-500/5 border-l-2 border-l-blue-500' : undefined}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
@@ -558,15 +704,15 @@ export default function InstancesPage() {
|
||||
</Card>
|
||||
|
||||
{/* Pagination */}
|
||||
{filteredVMs.length > 0 && (
|
||||
{allRows.length > 0 && (
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<span>
|
||||
{t('instances.showing', 'Showing')} {table.getState().pagination.pageIndex * table.getState().pagination.pageSize + 1}–
|
||||
{Math.min(
|
||||
(table.getState().pagination.pageIndex + 1) * table.getState().pagination.pageSize,
|
||||
filteredVMs.length
|
||||
allRows.length
|
||||
)}{' '}
|
||||
{t('instances.of', 'of')} {filteredVMs.length}
|
||||
{t('instances.of', 'of')} {allRows.length}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
@@ -593,6 +739,7 @@ export default function InstancesPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Cpu, MemoryStick, HardDrive, Clock, Wifi, Globe, Server } from 'lucide-
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { useVMStatus, useVMConfig, useVMInterfaces } from '@/hooks/use-instances';
|
||||
import { formatBytes, formatUptime, formatPercent } from '@/lib/format';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface Props {
|
||||
serverId: number;
|
||||
@@ -64,6 +65,7 @@ function InfoRow({ label, value }: { label: string; value?: string | number | nu
|
||||
}
|
||||
|
||||
export default function OverviewTab({ serverId, vmid, type, node }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { data: status } = useVMStatus(serverId, vmid, type, node);
|
||||
const { data: config } = useVMConfig(serverId, vmid, type, node);
|
||||
const { data: ifaces } = useVMInterfaces(serverId, vmid, type, node);
|
||||
@@ -81,28 +83,28 @@ export default function OverviewTab({ serverId, vmid, type, node }: Props) {
|
||||
{/* Metric cards */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<MetricCard
|
||||
label="CPU"
|
||||
label={t('common.cpu')}
|
||||
value={formatPercent(cpuPercent)}
|
||||
subtitle={config?.cores ? `${config.cores} cores` : undefined}
|
||||
subtitle={config?.cores ? `${config.cores} ${t('common.cores')}` : undefined}
|
||||
percent={cpuPercent}
|
||||
icon={Cpu}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Memory"
|
||||
label={t('common.memory')}
|
||||
value={status ? formatBytes(status.mem) : '—'}
|
||||
subtitle={status ? `of ${formatBytes(status.maxmem)}` : undefined}
|
||||
subtitle={status ? `${t('common.of')} ${formatBytes(status.maxmem)}` : undefined}
|
||||
percent={memPercent}
|
||||
icon={MemoryStick}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Disk"
|
||||
label={t('common.disk')}
|
||||
value={status ? formatBytes(status.disk) : '—'}
|
||||
subtitle={status ? `of ${formatBytes(status.maxdisk)}` : undefined}
|
||||
subtitle={status ? `${t('common.of')} ${formatBytes(status.maxdisk)}` : undefined}
|
||||
percent={diskPercent}
|
||||
icon={HardDrive}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Uptime"
|
||||
label={t('common.uptime')}
|
||||
value={status?.uptime ? formatUptime(status.uptime) : '—'}
|
||||
icon={Clock}
|
||||
/>
|
||||
@@ -114,19 +116,19 @@ export default function OverviewTab({ serverId, vmid, type, node }: Props) {
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<Server className="h-4 w-4" />
|
||||
Instance Info
|
||||
{t('common.instance_info')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<InfoRow label="VMID" value={vmid} />
|
||||
<InfoRow label="Node" value={node} />
|
||||
<InfoRow label="Type" value={type === 'qemu' ? 'QEMU Virtual Machine' : 'LXC Container'} />
|
||||
<InfoRow label="vCPU" value={config?.cores} />
|
||||
<InfoRow label="Memory" value={config?.memory ? `${config.memory} MB` : undefined} />
|
||||
<InfoRow label="OS Type" value={config?.ostype as string} />
|
||||
<InfoRow label="Boot Order" value={config?.boot as string} />
|
||||
<InfoRow label="QEMU Agent" value={config?.agent ? 'Enabled' : 'Disabled'} />
|
||||
<InfoRow label="Start on Boot" value={config?.onboot ? 'Yes' : 'No'} />
|
||||
<InfoRow label={t('common.vmid')} value={vmid} />
|
||||
<InfoRow label={t('common.node')} value={node} />
|
||||
<InfoRow label={t('common.type')} value={type === 'qemu' ? t('common.qemu_virtual_machine') : t('common.lxc_container')} />
|
||||
<InfoRow label={t('common.vcpu')} value={config?.cores} />
|
||||
<InfoRow label={t('common.memory')} value={config?.memory ? `${config.memory} MB` : undefined} />
|
||||
<InfoRow label={t('common.os_type')} value={config?.ostype as string} />
|
||||
<InfoRow label={t('common.boot_order')} value={config?.boot as string} />
|
||||
<InfoRow label={t('common.qemu_agent')} value={config?.agent ? t('common.enabled') : t('common.disabled')} />
|
||||
<InfoRow label={t('common.start_on_boot')} value={config?.onboot ? t('common.yes') : t('common.no')} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -135,22 +137,22 @@ export default function OverviewTab({ serverId, vmid, type, node }: Props) {
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<Globe className="h-4 w-4" />
|
||||
Network
|
||||
{t('common.network')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{primaryIP && <InfoRow label="Primary IP" value={primaryIP.address} />}
|
||||
{primaryIP && <InfoRow label={t('common.primary_ip')} value={primaryIP.address} />}
|
||||
{status?.netin !== undefined && (
|
||||
<InfoRow label="Network In" value={formatBytes(status.netin)} />
|
||||
<InfoRow label={t('common.network_in')} value={formatBytes(status.netin)} />
|
||||
)}
|
||||
{status?.netout !== undefined && (
|
||||
<InfoRow label="Network Out" value={formatBytes(status.netout)} />
|
||||
<InfoRow label={t('common.network_out')} value={formatBytes(status.netout)} />
|
||||
)}
|
||||
{config?.net0 && <InfoRow label="net0" value={String(config.net0)} />}
|
||||
|
||||
{ifaces?.interfaces && ifaces.interfaces.length > 0 && (
|
||||
<div className="mt-3 space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Interfaces</p>
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">{t('common.interfaces')}</p>
|
||||
{ifaces.interfaces.map((iface) => (
|
||||
<div key={iface.name} className="rounded-md border p-2 text-xs">
|
||||
<div className="flex items-center gap-2 font-medium">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Settings, Loader2 } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -15,6 +16,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export default function SettingsTab({ serverId, vmid, type, node }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { data: config } = useVMConfig(serverId, vmid, type, node);
|
||||
const updateConfig = useUpdateConfig(serverId, vmid, type);
|
||||
const resizeDisk = useResizeDisk(serverId, vmid, type);
|
||||
@@ -124,7 +126,7 @@ export default function SettingsTab({ serverId, vmid, type, node }: Props) {
|
||||
id="disk-device"
|
||||
value={diskDevice}
|
||||
onChange={(e) => setDiskDevice(e.target.value)}
|
||||
placeholder="scsi0"
|
||||
placeholder={t('common.placeholder_disk')}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -135,7 +137,7 @@ export default function SettingsTab({ serverId, vmid, type, node }: Props) {
|
||||
min={1}
|
||||
value={diskSize}
|
||||
onChange={(e) => setDiskSize(e.target.value)}
|
||||
placeholder="e.g. 100"
|
||||
placeholder={t('common.placeholder_disk_size')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Plus, Trash2, RotateCcw, Loader2, Camera } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -36,6 +37,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export default function SnapshotsTab({ serverId, vmid, type, node }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading } = useSnapshots(serverId, vmid, type, node);
|
||||
const createSnap = useCreateSnapshot(serverId, vmid, type);
|
||||
const deleteSnap = useDeleteSnapshot(serverId, vmid, type, node);
|
||||
@@ -110,17 +112,17 @@ export default function SnapshotsTab({ serverId, vmid, type, node }: Props) {
|
||||
) : snapshots.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||
<Camera className="mb-2 h-8 w-8 opacity-40" />
|
||||
<p className="text-sm">No snapshots</p>
|
||||
<p className="text-sm">{t('instances.no_snapshots')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Description</TableHead>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead>{t('common.name')}</TableHead>
|
||||
<TableHead>{t('common.description')}</TableHead>
|
||||
<TableHead>{t('common.date')}</TableHead>
|
||||
<TableHead>RAM</TableHead>
|
||||
<TableHead className="w-[100px]">Actions</TableHead>
|
||||
<TableHead className="w-[100px]">{t('common.actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -149,7 +151,7 @@ export default function SnapshotsTab({ serverId, vmid, type, node }: Props) {
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => setRollbackTarget(snap.name)}
|
||||
title="Rollback"
|
||||
title={t('instances.rollback')}
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
@@ -158,7 +160,7 @@ export default function SnapshotsTab({ serverId, vmid, type, node }: Props) {
|
||||
size="icon"
|
||||
className="h-7 w-7 text-destructive hover:text-destructive"
|
||||
onClick={() => setDeleteTarget(snap.name)}
|
||||
title="Delete"
|
||||
title={t('instances.delete_snapshot')}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
@@ -185,16 +187,16 @@ export default function SnapshotsTab({ serverId, vmid, type, node }: Props) {
|
||||
id="snap-name"
|
||||
value={snapName}
|
||||
onChange={(e) => setSnapName(e.target.value)}
|
||||
placeholder="e.g. before-update"
|
||||
placeholder={t('common.placeholder_snapshot_name')}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="snap-desc">Description (optional)</Label>
|
||||
<Label htmlFor="snap-desc">{t('common.description')} (optional)</Label>
|
||||
<Input
|
||||
id="snap-desc"
|
||||
value={snapDesc}
|
||||
onChange={(e) => setSnapDesc(e.target.value)}
|
||||
placeholder="Description"
|
||||
placeholder={t('common.description')}
|
||||
/>
|
||||
</div>
|
||||
{type === 'qemu' && (
|
||||
|
||||
@@ -57,9 +57,9 @@ export default function IPAMNetworkDetailPage() {
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-sm">{t('ipam.network_info')}</CardTitle></CardHeader>
|
||||
<CardContent className="grid gap-2 text-sm sm:grid-cols-2">
|
||||
<InfoRow label="Gateway" value={network.gateway || '—'} />
|
||||
<InfoRow label="VLAN" value={network.vlan_id ? String(network.vlan_id) : '—'} />
|
||||
<InfoRow label="DNS" value={network.dns_primary || '—'} />
|
||||
<InfoRow label={t('common.gateway')} value={network.gateway || '—'} />
|
||||
<InfoRow label={t('common.vlan')} value={network.vlan_id ? String(network.vlan_id) : '—'} />
|
||||
<InfoRow label={t('common.dns')} value={network.dns_primary || '—'} />
|
||||
<InfoRow label={t('ipam.server')} value={network.server_name || '—'} />
|
||||
<InfoRow label={t('ipam.bridge')} value={network.proxmox_bridge || '—'} />
|
||||
{network.description && <InfoRow label={t('ipam.description')} value={network.description} />}
|
||||
|
||||
@@ -110,7 +110,7 @@ function ServerFormDialog({
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="col-span-2">
|
||||
<Label>{t('common.name')}</Label>
|
||||
<Input value={form.name} onChange={e => set('name', e.target.value)} className="mt-1" placeholder="My Proxmox Server" />
|
||||
<Input value={form.name} onChange={e => set('name', e.target.value)} className="mt-1" placeholder={t('common.my_proxmox_server')} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t('nodes.hostname')}</Label>
|
||||
@@ -122,7 +122,7 @@ function ServerFormDialog({
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t('nodes.api_user')}</Label>
|
||||
<Input value={form.api_user} onChange={e => set('api_user', e.target.value)} className="mt-1" placeholder="root@pam" />
|
||||
<Input value={form.api_user} onChange={e => set('api_user', e.target.value)} className="mt-1" placeholder={t('common.placeholder_root_pam')} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Trash2, X, ClipboardList, RefreshCw } from 'lucide-react';
|
||||
import { Trash2, X, ClipboardList, RefreshCw, Loader2 } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { useAllTasks, useCancelTask, useClearCompletedTasks } from '@/hooks/use-tasks';
|
||||
|
||||
export default function TasksPage() {
|
||||
const { t } = useTranslation();
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
const { data: tasksData, refetch } = useAllTasks();
|
||||
const { data: tasksData, refetch, isLoading } = useAllTasks();
|
||||
const tasks = tasksData?.tasks ?? [];
|
||||
const cancelTask = useCancelTask();
|
||||
const clearCompleted = useClearCompletedTasks();
|
||||
@@ -31,6 +32,9 @@ export default function TasksPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const isActive = (status: string) =>
|
||||
status === 'running' || status === 'queued' || status === 'in_progress' || status === 'pending';
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -39,8 +43,8 @@ export default function TasksPage() {
|
||||
<h1 className="text-2xl font-bold">{t('nav.tasks')}</h1>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => refetch()}>
|
||||
<RefreshCw className="h-4 w-4 mr-1" />{t('common.refresh')}
|
||||
<Button variant="outline" size="sm" onClick={() => refetch()} disabled={isLoading}>
|
||||
<RefreshCw className={`h-4 w-4 mr-1 ${isLoading ? 'animate-spin' : ''}`} />{t('common.refresh')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => clearCompleted.mutate()}>
|
||||
<Trash2 className="h-4 w-4 mr-1" />{t('tasks.clear_completed')}
|
||||
@@ -68,48 +72,88 @@ export default function TasksPage() {
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('common.status')}</TableHead>
|
||||
<TableHead>{t('common.type')}</TableHead>
|
||||
<TableHead className="w-[110px]">{t('common.status')}</TableHead>
|
||||
<TableHead className="w-[110px]">{t('common.type')}</TableHead>
|
||||
<TableHead>{t('tasks.description')}</TableHead>
|
||||
<TableHead>{t('tasks.server')}</TableHead>
|
||||
<TableHead>{t('tasks.node')}</TableHead>
|
||||
<TableHead>{t('common.user')}</TableHead>
|
||||
<TableHead>{t('tasks.started')}</TableHead>
|
||||
<TableHead>{t('tasks.progress')}</TableHead>
|
||||
<TableHead />
|
||||
<TableHead className="w-[200px]">{t('tasks.step_message')}</TableHead>
|
||||
<TableHead className="w-[100px]">{t('tasks.server')}</TableHead>
|
||||
<TableHead className="w-[90px]">{t('tasks.node')}</TableHead>
|
||||
<TableHead className="w-[80px]">{t('common.user')}</TableHead>
|
||||
<TableHead className="w-[140px]">{t('tasks.started')}</TableHead>
|
||||
<TableHead className="w-[120px]">{t('tasks.progress')}</TableHead>
|
||||
<TableHead className="w-[40px]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filtered.map((task, i) => (
|
||||
<TableRow key={task.id ?? task.upid ?? i}>
|
||||
<TableCell><Badge variant={statusColor(task.status) as any}>{task.status}</Badge></TableCell>
|
||||
<TableCell className="text-xs font-mono">{task.type || String(task.task_type ?? '') || '—'}</TableCell>
|
||||
<TableCell className="text-xs max-w-[250px] truncate">{String(task.description ?? '') || task.upid || '—'}</TableCell>
|
||||
<TableCell className="text-xs">{task.server_name || '—'}</TableCell>
|
||||
<TableCell className="text-xs">{task.node || '—'}</TableCell>
|
||||
<TableCell className="text-xs">{task.user || '—'}</TableCell>
|
||||
<TableCell className="text-xs whitespace-nowrap">{task.starttime ? new Date(task.starttime * 1000).toLocaleString() : task.created_at ? new Date(task.created_at).toLocaleString() : '—'}</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{task.progress != null ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-1.5 w-16 rounded-full bg-muted overflow-hidden">
|
||||
<div className="h-full bg-blue-500 rounded-full" style={{ width: `${task.progress}%` }} />
|
||||
</div>
|
||||
<span>{Number(task.progress ?? 0)}%</span>
|
||||
{filtered.map((task, i) => {
|
||||
const active = isActive(task.status);
|
||||
const stepMsg = String(task.step ?? task.current_step ?? task.message ?? '');
|
||||
const errMsg = String(task.error_message ?? task.exitstatus ?? '');
|
||||
const displayMsg = task.status === 'failed' ? (errMsg || stepMsg) : (active ? stepMsg : '');
|
||||
|
||||
return (
|
||||
<TableRow key={task.id ?? task.upid ?? i} className={active ? 'bg-blue-500/5' : ''}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{active && <Loader2 className="h-3 w-3 animate-spin text-blue-500 shrink-0" />}
|
||||
<Badge variant={statusColor(task.status) as any}>{task.status}</Badge>
|
||||
</div>
|
||||
) : '—'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{(task.status === 'running' || task.status === 'queued' || task.status === 'in_progress') && (
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => cancelTask.mutate(Number(task.id ?? 0))}>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs font-mono">{task.type || String(task.task_type ?? '') || '—'}</TableCell>
|
||||
<TableCell className="text-xs max-w-[200px]">
|
||||
<span className="line-clamp-2">{String(task.description ?? '') || task.upid || '—'}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{displayMsg ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<span className={`line-clamp-2 ${task.status === 'failed' ? 'text-destructive' : 'text-muted-foreground'}`}>
|
||||
{displayMsg}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-sm break-all">{displayMsg}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : '—'}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">{task.server_name || '—'}</TableCell>
|
||||
<TableCell className="text-xs">{task.node || '—'}</TableCell>
|
||||
<TableCell className="text-xs">{task.user || '—'}</TableCell>
|
||||
<TableCell className="text-xs whitespace-nowrap">{task.starttime ? new Date(task.starttime * 1000).toLocaleString() : task.created_at ? new Date(String(task.created_at)).toLocaleString() : '—'}</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{task.progress != null ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-1.5 w-16 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${task.status === 'failed' ? 'bg-destructive' : 'bg-blue-500'}`}
|
||||
style={{ width: `${task.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span>{Number(task.progress ?? 0)}%</span>
|
||||
</div>
|
||||
) : '—'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{active && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-destructive"
|
||||
onClick={() => cancelTask.mutate(Number(task.id ?? 0))}
|
||||
disabled={cancelTask.isPending}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
{filtered.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={10} className="text-center text-muted-foreground py-8">
|
||||
{t('common.no_data')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<TableRow><TableCell colSpan={9} className="text-center text-muted-foreground py-8">{t('common.no_data')}</TableCell></TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect } from 'react';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { getTasksWebSocket } from '@/lib/websocket';
|
||||
import type { VMInstance, Snapshot, VMConfig } from '@/types';
|
||||
|
||||
// ==================== Query Keys ====================
|
||||
@@ -24,6 +26,26 @@ export function useVirtualMachines() {
|
||||
});
|
||||
}
|
||||
|
||||
/** Subscribes to WebSocket vm_status_update events and patches the VM list cache in real time. */
|
||||
export function useVMStatusSync() {
|
||||
const qc = useQueryClient();
|
||||
useEffect(() => {
|
||||
const ws = getTasksWebSocket();
|
||||
ws.connect();
|
||||
const unsub = ws.onType('vm_status_update', (data: unknown) => {
|
||||
const msg = data as { vmid: number; server_id: number; status: string };
|
||||
qc.setQueryData<VMInstance[]>(vmKeys.all, (old) =>
|
||||
old?.map((vm) =>
|
||||
vm.server_id === msg.server_id && vm.vmid === msg.vmid
|
||||
? { ...vm, status: msg.status as VMInstance['status'] }
|
||||
: vm
|
||||
)
|
||||
);
|
||||
});
|
||||
return () => unsub();
|
||||
}, [qc]);
|
||||
}
|
||||
|
||||
export function useAllResources() {
|
||||
return useQuery({
|
||||
queryKey: vmKeys.resourcesAll,
|
||||
@@ -153,16 +175,55 @@ interface PowerActionResult {
|
||||
export function usePowerAction(serverId: number, vmid: number, type: string, node: string) {
|
||||
const qc = useQueryClient();
|
||||
const prefix = type === 'lxc' ? 'container' : 'vm';
|
||||
return useMutation<PowerActionResult, Error, { action: string; force?: boolean }>({
|
||||
return useMutation<PowerActionResult, Error, { action: string; force?: boolean }, { previousVMs: VMInstance[] | undefined }>({
|
||||
mutationFn: ({ action, force }) => {
|
||||
const params = new URLSearchParams({ node });
|
||||
if (force) params.set('force', '1');
|
||||
return apiClient.post(`/proxmox/api/${serverId}/${prefix}/${vmid}/${action}?${params}`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: vmKeys.status(serverId, vmid) });
|
||||
qc.invalidateQueries({ queryKey: vmKeys.all });
|
||||
qc.invalidateQueries({ queryKey: vmKeys.resourcesAll });
|
||||
onMutate: async ({ action }) => {
|
||||
await qc.cancelQueries({ queryKey: vmKeys.all });
|
||||
const previousVMs = qc.getQueryData<VMInstance[]>(vmKeys.all);
|
||||
const expectedStatus = action === 'start' || action === 'restart' ? 'running' : 'stopped';
|
||||
qc.setQueryData<VMInstance[]>(vmKeys.all, (old) =>
|
||||
old?.map((vm) =>
|
||||
vm.server_id === serverId && vm.vmid === vmid
|
||||
? { ...vm, status: expectedStatus as VMInstance['status'] }
|
||||
: vm
|
||||
)
|
||||
);
|
||||
return { previousVMs };
|
||||
},
|
||||
onError: (_err, _vars, context) => {
|
||||
if (context?.previousVMs) {
|
||||
qc.setQueryData(vmKeys.all, context.previousVMs);
|
||||
}
|
||||
},
|
||||
onSuccess: (_data, { action }) => {
|
||||
const expectedStatus = action === 'start' || action === 'restart' ? 'running' : 'stopped';
|
||||
let attempts = 0;
|
||||
const maxAttempts = 12; // 12 * 3s = 36s max
|
||||
const poll = () => {
|
||||
attempts++;
|
||||
apiClient
|
||||
.get<VMStatusResponse>(`/proxmox/api/${serverId}/${prefix}/${vmid}/status?node=${node}`)
|
||||
.then((liveStatus) => {
|
||||
qc.setQueryData<VMInstance[]>(vmKeys.all, (old) =>
|
||||
old?.map((vm) =>
|
||||
vm.server_id === serverId && vm.vmid === vmid
|
||||
? { ...vm, status: liveStatus.status as VMInstance['status'] }
|
||||
: vm
|
||||
)
|
||||
);
|
||||
if (liveStatus.status !== expectedStatus && attempts < maxAttempts) {
|
||||
setTimeout(poll, 3000);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (attempts < maxAttempts) setTimeout(poll, 3000);
|
||||
});
|
||||
};
|
||||
setTimeout(poll, 3000);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -227,3 +288,118 @@ export function useUpdateConfig(serverId: number, vmid: number, type: string) {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Clone / Reinstall ====================
|
||||
interface CloneRequest {
|
||||
new_name: string;
|
||||
full?: boolean;
|
||||
target_node?: string;
|
||||
target_storage?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export function useCloneInstance(serverId: number, vmid: number, type: string, node: string) {
|
||||
const qc = useQueryClient();
|
||||
const prefix = type === 'lxc' ? 'container' : 'vm';
|
||||
return useMutation<{ status: string; new_vmid: number; upid: string }, Error, CloneRequest>({
|
||||
mutationFn: (body) =>
|
||||
apiClient.post(`/proxmox/api/${serverId}/${prefix}/${vmid}/clone?node=${node}`, body),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: vmKeys.all });
|
||||
qc.invalidateQueries({ queryKey: vmKeys.resourcesAll });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useReinstallInstance(serverId: number, vmid: number, node: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<{ status: string; vmid: number; upid: string }, Error, void>({
|
||||
mutationFn: () =>
|
||||
apiClient.post(`/proxmox/api/${serverId}/vm/${vmid}/reinstall?node=${node}`),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: vmKeys.all });
|
||||
qc.invalidateQueries({ queryKey: vmKeys.config(serverId, vmid) });
|
||||
qc.invalidateQueries({ queryKey: vmKeys.status(serverId, vmid) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Change Password ====================
|
||||
export function useChangePassword(serverId: number, vmid: number, type: string, node: string) {
|
||||
const prefix = type === 'lxc' ? 'container' : 'vm';
|
||||
return useMutation<{ status: string }, Error, { username: string; password: string }>({
|
||||
mutationFn: (body) =>
|
||||
apiClient.post(`/proxmox/api/${serverId}/${prefix}/${vmid}/change-password?node=${node}`, body),
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Notes ====================
|
||||
export function useUpdateNotes(serverId: number, vmid: number, type: string, node: string) {
|
||||
const qc = useQueryClient();
|
||||
const prefix = type === 'lxc' ? 'container' : 'vm';
|
||||
return useMutation<{ status: string }, Error, { description: string }>({
|
||||
mutationFn: (body) =>
|
||||
apiClient.put(`/proxmox/api/${serverId}/${prefix}/${vmid}/notes?node=${node}`, body),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: vmKeys.config(serverId, vmid) });
|
||||
qc.invalidateQueries({ queryKey: vmKeys.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Execute Command (qemu-agent) ====================
|
||||
interface ExecResult {
|
||||
success?: boolean;
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
exit_code?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function useExecuteCommand(serverId: number, vmid: number) {
|
||||
return useMutation<ExecResult, Error, { node: string; command: string; timeout?: number }>({
|
||||
mutationFn: ({ node, command, timeout = 30 }) => {
|
||||
const params = new URLSearchParams({ node, command, timeout: String(timeout) });
|
||||
return apiClient.post(`/proxmox/api/${serverId}/vm/${vmid}/execute?${params}`);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== ISO mount/unmount (KVM only) ====================
|
||||
export interface IsoItem {
|
||||
volid: string;
|
||||
storage: string;
|
||||
size?: number;
|
||||
format?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export function useNodeIsos(serverId: number, node: string, enabled = true) {
|
||||
return useQuery<{ isos: IsoItem[] }>({
|
||||
queryKey: ['isos', serverId, node],
|
||||
queryFn: () => apiClient.get(`/proxmox/api/${serverId}/node/${node}/isos`),
|
||||
enabled: enabled && !!node,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAttachIso(serverId: number, vmid: number, node: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<{ status: string }, Error, { volid: string; device?: string }>({
|
||||
mutationFn: (body) =>
|
||||
apiClient.post(`/proxmox/api/${serverId}/vm/${vmid}/iso/attach?node=${node}`, body),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: vmKeys.config(serverId, vmid) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDetachIso(serverId: number, vmid: number, node: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<{ status: string }, Error, { device?: string }>({
|
||||
mutationFn: (body) =>
|
||||
apiClient.post(`/proxmox/api/${serverId}/vm/${vmid}/iso/detach?node=${node}`, body || {}),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: vmKeys.config(serverId, vmid) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,12 +7,17 @@ export const taskKeys = {
|
||||
bulk: (limit?: number) => ['bulk-tasks', limit] as const,
|
||||
};
|
||||
|
||||
interface TaskItem {
|
||||
export interface TaskItem {
|
||||
id?: number;
|
||||
upid?: string;
|
||||
type: string;
|
||||
kind?: 'bulk' | 'proxmox' | 'deploy';
|
||||
status: string;
|
||||
action?: string;
|
||||
description?: string;
|
||||
progress_percent?: number;
|
||||
current_step?: string;
|
||||
name?: string;
|
||||
items_count?: number;
|
||||
completed_count?: number;
|
||||
failed_count?: number;
|
||||
@@ -22,7 +27,10 @@ interface TaskItem {
|
||||
node?: string;
|
||||
server_id?: number;
|
||||
server_name?: string;
|
||||
vmid?: number;
|
||||
vm_type?: string;
|
||||
exitstatus?: string;
|
||||
exit_status?: string;
|
||||
starttime?: number;
|
||||
endtime?: number;
|
||||
user?: string;
|
||||
|
||||
@@ -88,8 +88,7 @@ function SidebarContent() {
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Logo */}
|
||||
<div className="flex h-14 items-center gap-2 px-4">
|
||||
<img src="/logo.png" alt="PVEmanager" className="h-8 w-auto" />
|
||||
<span className="text-base font-semibold tracking-tight">PVEmanager</span>
|
||||
<img src="/logo.png" alt="PVEmanager" className="h-12 w-auto" />
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
@@ -18,6 +18,7 @@ export class WebSocketManager {
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private pingTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private subscribers = new Map<string, Set<MessageHandler>>();
|
||||
private typeListeners = new Map<string, Set<MessageHandler>>();
|
||||
private _isConnected = false;
|
||||
|
||||
constructor(options: WSOptions) {
|
||||
@@ -56,6 +57,12 @@ export class WebSocketManager {
|
||||
const data = JSON.parse(event.data);
|
||||
this.options.onMessage?.(data);
|
||||
|
||||
// Route to type listeners (for direct user messages without channel)
|
||||
if (data.type) {
|
||||
const typeHandlers = this.typeListeners.get(data.type);
|
||||
typeHandlers?.forEach((handler) => handler(data));
|
||||
}
|
||||
|
||||
// Route to channel subscribers
|
||||
if (data.channel) {
|
||||
const handlers = this.subscribers.get(data.channel);
|
||||
@@ -112,6 +119,17 @@ export class WebSocketManager {
|
||||
};
|
||||
}
|
||||
|
||||
/** Listen to messages by their `type` field (for direct user messages without a channel). */
|
||||
onType(type: string, handler: MessageHandler) {
|
||||
if (!this.typeListeners.has(type)) {
|
||||
this.typeListeners.set(type, new Set());
|
||||
}
|
||||
this.typeListeners.get(type)!.add(handler);
|
||||
return () => {
|
||||
this.typeListeners.get(type)?.delete(handler);
|
||||
};
|
||||
}
|
||||
|
||||
private tryReconnect() {
|
||||
if (
|
||||
!this.options.reconnect ||
|
||||
|
||||
@@ -70,7 +70,50 @@
|
||||
"stop": "Stop",
|
||||
"restart": "Restart",
|
||||
"snapshots": "Snapshots",
|
||||
"suspend": "Suspend"
|
||||
"suspend": "Suspend",
|
||||
"cpu": "CPU",
|
||||
"memory": "Memory",
|
||||
"disk": "Disk",
|
||||
"uptime": "Uptime",
|
||||
"vmid": "VMID",
|
||||
"boot_order": "Boot Order",
|
||||
"qemu_agent": "QEMU Agent",
|
||||
"start_on_boot": "Start on Boot",
|
||||
"primary_ip": "Primary IP",
|
||||
"network_in": "Network In",
|
||||
"network_out": "Network Out",
|
||||
"rollback": "Rollback",
|
||||
"description": "Description",
|
||||
"os_type": "OS Type",
|
||||
"vcpu": "vCPU",
|
||||
"instance_info": "Instance Info",
|
||||
"network": "Network",
|
||||
"interfaces": "Interfaces",
|
||||
"dns": "DNS",
|
||||
"gateway": "Gateway",
|
||||
"vlan": "VLAN",
|
||||
"my_proxmox_server": "My Proxmox Server",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"qemu_virtual_machine": "QEMU Virtual Machine",
|
||||
"lxc_container": "LXC Container",
|
||||
"root": "root",
|
||||
"ubuntu": "ubuntu",
|
||||
"my_vm": "my-vm-01",
|
||||
"cores": "cores",
|
||||
"of": "of",
|
||||
"placeholder_root": "root",
|
||||
"placeholder_ubuntu": "ubuntu",
|
||||
"placeholder_vm_name": "my-vm-01",
|
||||
"placeholder_ssh_key": "ssh-rsa AAAA...",
|
||||
"placeholder_command": "ls -la /tmp",
|
||||
"placeholder_disk_size": "e.g. 100",
|
||||
"placeholder_snapshot_name": "e.g. before-update",
|
||||
"placeholder_root_pam": "root@pam",
|
||||
"placeholder_disk": "scsi0",
|
||||
"placeholder_notes": "Описание / заметки об инстансе..."
|
||||
},
|
||||
"instances": {
|
||||
"create": "Create Instance",
|
||||
@@ -85,7 +128,15 @@
|
||||
"overview": "Overview",
|
||||
"graphs": "Graphs",
|
||||
"networking": "Networking",
|
||||
"destroy": "Destroy"
|
||||
"destroy": "Destroy",
|
||||
"task_activity": "Task Activity",
|
||||
"status_creating": "Creating",
|
||||
"vmid_pending": "pending",
|
||||
"no_snapshots": "No snapshots",
|
||||
"notes_saved": "Notes saved",
|
||||
"before_update": "before-update",
|
||||
"rollback": "Rollback",
|
||||
"delete_snapshot": "Delete"
|
||||
},
|
||||
"wizard": {
|
||||
"title": "Create Instance",
|
||||
@@ -95,6 +146,25 @@
|
||||
"step_confirm": "Confirm",
|
||||
"select_server": "Select server",
|
||||
"select_template": "Select template",
|
||||
"no_templates": "No templates available",
|
||||
"configure": "Configure",
|
||||
"basic": "Basic",
|
||||
"target_node": "Target Node",
|
||||
"auto_select": "Auto select",
|
||||
"start_after_create": "Start after creation",
|
||||
"resources": "Resources",
|
||||
"memory_mb": "Memory (MB)",
|
||||
"disk_gb": "Disk (GB)",
|
||||
"bridge": "Network Bridge",
|
||||
"manual_ip": "Manual IP",
|
||||
"ci_user": "Cloud-Init User",
|
||||
"ci_password": "Cloud-Init Password",
|
||||
"confirm": "Confirm",
|
||||
"server_label": "Server",
|
||||
"template_label": "Template",
|
||||
"back": "Back",
|
||||
"next": "Next",
|
||||
"storage": "Storage",
|
||||
"hostname": "Hostname",
|
||||
"cpu": "CPU Cores",
|
||||
"memory": "Memory (MB)",
|
||||
@@ -103,7 +173,20 @@
|
||||
"password": "Password",
|
||||
"ssh_keys": "SSH Keys",
|
||||
"deploy": "Deploy",
|
||||
"deploy_queued": "Deploy task for \"{{name}}\" has been queued",
|
||||
"deploy_success": "Instance \"{{name}}\" deployed successfully",
|
||||
"deploy_failed": "Deployment failed",
|
||||
"deploy_done": "Instance created successfully",
|
||||
"deploy_wait": "Please wait, deployment in progress...",
|
||||
"deploy_starting": "Starting...",
|
||||
"deploying_title": "Deploying Instance",
|
||||
"deploying": "Deploying...",
|
||||
"status_pending": "Queued",
|
||||
"status_running": "Running",
|
||||
"status_completed": "Completed",
|
||||
"status_failed": "Failed",
|
||||
"go_to_instances": "Go to Instances",
|
||||
"retry": "Retry",
|
||||
"os_group": "OS Group",
|
||||
"ipam_network": "IPAM Network",
|
||||
"auto_allocate_ip": "Auto-allocate IP"
|
||||
@@ -276,6 +359,7 @@
|
||||
"failed": "Failed",
|
||||
"tasks_count": "tasks",
|
||||
"description": "Description",
|
||||
"step_message": "Step / Message",
|
||||
"server": "Server",
|
||||
"node": "Node",
|
||||
"started": "Started",
|
||||
@@ -300,4 +384,4 @@
|
||||
"connected": "Connected",
|
||||
"error": "Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -70,7 +70,50 @@
|
||||
"stop": "Остановить",
|
||||
"restart": "Перезагрузить",
|
||||
"snapshots": "Снимки",
|
||||
"suspend": "Приостановить"
|
||||
"suspend": "Приостановить",
|
||||
"cpu": "CPU",
|
||||
"memory": "Память",
|
||||
"disk": "Диск",
|
||||
"uptime": "Аптайм",
|
||||
"vmid": "VMID",
|
||||
"boot_order": "Порядок загрузки",
|
||||
"qemu_agent": "QEMU агент",
|
||||
"start_on_boot": "Запуск при загрузке",
|
||||
"primary_ip": "Основной IP",
|
||||
"network_in": "Входящий трафик",
|
||||
"network_out": "Исходящий трафик",
|
||||
"rollback": "Откатить",
|
||||
"description": "Описание",
|
||||
"os_type": "Тип ОС",
|
||||
"vcpu": "vCPU",
|
||||
"instance_info": "Информация об инстансе",
|
||||
"network": "Сеть",
|
||||
"interfaces": "Интерфейсы",
|
||||
"dns": "DNS",
|
||||
"gateway": "Шлюз",
|
||||
"vlan": "VLAN",
|
||||
"my_proxmox_server": "Мой Proxmox сервер",
|
||||
"enabled": "Включено",
|
||||
"disabled": "Выключено",
|
||||
"yes": "Да",
|
||||
"no": "Нет",
|
||||
"qemu_virtual_machine": "Виртуальная машина QEMU",
|
||||
"lxc_container": "LXC контейнер",
|
||||
"root": "root",
|
||||
"ubuntu": "ubuntu",
|
||||
"my_vm": "my-vm-01",
|
||||
"cores": "ядер",
|
||||
"of": "из",
|
||||
"placeholder_root": "root",
|
||||
"placeholder_ubuntu": "ubuntu",
|
||||
"placeholder_vm_name": "my-vm-01",
|
||||
"placeholder_ssh_key": "ssh-rsa AAAA...",
|
||||
"placeholder_command": "ls -la /tmp",
|
||||
"placeholder_disk_size": "например 100",
|
||||
"placeholder_snapshot_name": "например перед-обновлением",
|
||||
"placeholder_root_pam": "root@pam",
|
||||
"placeholder_disk": "scsi0",
|
||||
"placeholder_notes": "Описание / заметки об инстансе..."
|
||||
},
|
||||
"instances": {
|
||||
"create": "Создать инстанс",
|
||||
@@ -85,7 +128,15 @@
|
||||
"overview": "Обзор",
|
||||
"graphs": "Графики",
|
||||
"networking": "Сеть",
|
||||
"destroy": "Уничтожить"
|
||||
"destroy": "Уничтожить",
|
||||
"task_activity": "Активность задач",
|
||||
"status_creating": "Создание",
|
||||
"vmid_pending": "ожидание",
|
||||
"no_snapshots": "Нет снимков",
|
||||
"notes_saved": "Заметки сохранены",
|
||||
"before_update": "перед-обновлением",
|
||||
"rollback": "Откатить",
|
||||
"delete_snapshot": "Удалить"
|
||||
},
|
||||
"wizard": {
|
||||
"title": "Создание инстанса",
|
||||
@@ -95,6 +146,25 @@
|
||||
"step_confirm": "Подтверждение",
|
||||
"select_server": "Выберите сервер",
|
||||
"select_template": "Выберите шаблон",
|
||||
"no_templates": "Шаблоны не найдены",
|
||||
"configure": "Настройка",
|
||||
"basic": "Основное",
|
||||
"target_node": "Целевой узел",
|
||||
"auto_select": "Авто выбор",
|
||||
"start_after_create": "Запустить после создания",
|
||||
"resources": "Ресурсы",
|
||||
"memory_mb": "Память (МБ)",
|
||||
"disk_gb": "Диск (ГБ)",
|
||||
"bridge": "Сетевой мост",
|
||||
"manual_ip": "Ручной ввод IP",
|
||||
"ci_user": "Cloud-Init пользователь",
|
||||
"ci_password": "Cloud-Init пароль",
|
||||
"confirm": "Подтверждение",
|
||||
"server_label": "Сервер",
|
||||
"template_label": "Шаблон",
|
||||
"back": "Назад",
|
||||
"next": "Далее",
|
||||
"storage": "Хранилище",
|
||||
"hostname": "Имя хоста",
|
||||
"cpu": "Ядра CPU",
|
||||
"memory": "Память (МБ)",
|
||||
@@ -103,7 +173,20 @@
|
||||
"password": "Пароль",
|
||||
"ssh_keys": "SSH ключи",
|
||||
"deploy": "Развернуть",
|
||||
"deploy_queued": "Задача создания \"{{name}}\" добавлена в очередь",
|
||||
"deploy_success": "Инстанс \"{{name}}\" успешно развёрнут",
|
||||
"deploy_failed": "Ошибка развёртывания",
|
||||
"deploy_done": "Инстанс успешно создан",
|
||||
"deploy_wait": "Пожалуйста, подождите — идёт развёртывание...",
|
||||
"deploy_starting": "Запуск...",
|
||||
"deploying_title": "Развёртывание инстанса",
|
||||
"deploying": "Развертывание...",
|
||||
"status_pending": "В очереди",
|
||||
"status_running": "Выполняется",
|
||||
"status_completed": "Завершено",
|
||||
"status_failed": "Ошибка",
|
||||
"go_to_instances": "Перейти к инстансам",
|
||||
"retry": "Повторить",
|
||||
"os_group": "Группа ОС",
|
||||
"ipam_network": "Сеть IPAM",
|
||||
"auto_allocate_ip": "Авто-выделение IP"
|
||||
@@ -276,6 +359,7 @@
|
||||
"failed": "Ошибка",
|
||||
"tasks_count": "задач",
|
||||
"description": "Описание",
|
||||
"step_message": "Шаг / Сообщение",
|
||||
"server": "Сервер",
|
||||
"node": "Узел",
|
||||
"started": "Запущено",
|
||||
@@ -300,4 +384,4 @@
|
||||
"connected": "Подключено",
|
||||
"error": "Ошибка"
|
||||
}
|
||||
}
|
||||
}
|
||||
58
frontend/src/stores/deploy-tasks-store.ts
Normal file
58
frontend/src/stores/deploy-tasks-store.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
export interface DeployTask {
|
||||
id: number;
|
||||
name: string;
|
||||
status: 'pending' | 'running' | 'completed' | 'failed';
|
||||
step: string | null;
|
||||
progress: number;
|
||||
vmid: number | null;
|
||||
node: string | null;
|
||||
error_message: string | null;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
interface DeployTasksState {
|
||||
tasks: DeployTask[];
|
||||
addTask: (task: Omit<DeployTask, 'created_at'>) => void;
|
||||
updateTask: (id: number, update: Partial<DeployTask>) => void;
|
||||
removeTask: (id: number) => void;
|
||||
clearCompleted: () => void;
|
||||
}
|
||||
|
||||
export const useDeployTasksStore = create<DeployTasksState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
tasks: [],
|
||||
addTask: (task) =>
|
||||
set((state) => ({
|
||||
tasks: [
|
||||
...state.tasks.filter((t) => t.id !== task.id),
|
||||
{ ...task, created_at: Date.now() },
|
||||
],
|
||||
})),
|
||||
updateTask: (id, update) =>
|
||||
set((state) => ({
|
||||
tasks: state.tasks.map((t) => (t.id === id ? { ...t, ...update } : t)),
|
||||
})),
|
||||
removeTask: (id) =>
|
||||
set((state) => ({
|
||||
tasks: state.tasks.filter((t) => t.id !== id),
|
||||
})),
|
||||
clearCompleted: () =>
|
||||
set((state) => ({
|
||||
tasks: state.tasks.filter(
|
||||
(t) => t.status !== 'completed' && t.status !== 'failed',
|
||||
),
|
||||
})),
|
||||
}),
|
||||
{
|
||||
name: 'pve-deploy-tasks',
|
||||
partialize: (state) => ({
|
||||
// Don't persist completed tasks
|
||||
tasks: state.tasks.filter((t) => t.status !== 'completed'),
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -61,7 +61,7 @@ export interface ProxmoxServerCreate {
|
||||
// ==================== VM / Container Types ====================
|
||||
|
||||
export type VMType = 'qemu' | 'lxc';
|
||||
export type VMStatus = 'running' | 'stopped' | 'paused' | 'suspended' | 'unknown';
|
||||
export type VMStatus = 'running' | 'stopped' | 'paused' | 'suspended' | 'unknown' | 'creating';
|
||||
|
||||
export interface VMInstance {
|
||||
vmid: number;
|
||||
@@ -82,6 +82,9 @@ export interface VMInstance {
|
||||
ip_address?: string;
|
||||
tags?: string;
|
||||
template?: boolean;
|
||||
description?: string;
|
||||
/** Ghost row marker — present only for in-progress deploy tasks */
|
||||
_deployTaskId?: number;
|
||||
}
|
||||
|
||||
export interface VMConfig {
|
||||
|
||||
Reference in New Issue
Block a user