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:
markmorado
2026-04-29 23:48:48 +02:00
parent 5169bf80a3
commit 89d70654fc
45 changed files with 2946 additions and 741 deletions

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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' && (

View File

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

View File

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

View File

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