Compare commits
4 Commits
0d4406024f
...
5c6bb013c9
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c6bb013c9 | |||
| 6b3700f496 | |||
| 1cc4d86b18 | |||
| 8ebef3d10e |
@@ -7,6 +7,7 @@ progress through DeployTask records (with `kind` field) + WebSocket broadcast.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
@@ -16,7 +17,7 @@ from ...db import SessionLocal
|
||||
from ...models import ProxmoxServer, VMInstance, OSTemplate, DeployTask, User
|
||||
from ...proxmox import ProxmoxClient
|
||||
from ...logging_service import LoggingService
|
||||
from ._helpers import _get_proxmox_client, get_next_vmid
|
||||
from ._helpers import _get_proxmox_client, get_next_vmid, save_vm_instance, save_vm_instance
|
||||
|
||||
|
||||
# Re-use deploy executor and update helper
|
||||
@@ -386,10 +387,56 @@ def _do_reinstall_sync(task_id: int, server_id: int, vmid: int, node: str,
|
||||
|
||||
# ==================== Clone ====================
|
||||
|
||||
_PCT_RE = re.compile(r"\((\d{1,3}(?:\.\d+)?)\s*%\)")
|
||||
|
||||
|
||||
def _wait_clone_with_progress(client, task_id: int, node: str, upid: str,
|
||||
timeout: int = 600) -> bool:
|
||||
"""Wait for a PVE clone task, mapping its disk-copy % to deploy progress 50→95.
|
||||
|
||||
PVE reports copy progress only in the task log (e.g. "transferred 12.0 GiB of
|
||||
20.0 GiB (59.94%)"), so we tail the log and parse the last percentage seen.
|
||||
"""
|
||||
start = time.time()
|
||||
log_pos = 0
|
||||
last_reported = -1
|
||||
while time.time() - start < timeout:
|
||||
# Tail new log lines and extract the most recent percentage.
|
||||
try:
|
||||
lines = client.get_task_log(node, upid, start=log_pos, limit=500) or []
|
||||
except Exception:
|
||||
lines = []
|
||||
pct = None
|
||||
for line in lines:
|
||||
log_pos = max(log_pos, line.get('n', log_pos) or log_pos)
|
||||
m = _PCT_RE.search(line.get('t', '') or '')
|
||||
if m:
|
||||
try:
|
||||
pct = float(m.group(1))
|
||||
except ValueError:
|
||||
pass
|
||||
if pct is not None:
|
||||
progress = 50 + int(min(pct, 100) * 0.45) # 50..95
|
||||
if progress != last_reported:
|
||||
last_reported = progress
|
||||
_update_deploy_task(task_id, 'running',
|
||||
f'Копирование диска... {pct:.0f}%', progress)
|
||||
|
||||
status = client.get_task_status(node, upid)
|
||||
if status and status.get('status') == 'stopped':
|
||||
exit_status = status.get('exitstatus')
|
||||
return exit_status == 'OK' or (
|
||||
isinstance(exit_status, str) and exit_status.startswith('WARNINGS:'))
|
||||
time.sleep(2)
|
||||
|
||||
logger.warning(f"Таймаут ожидания задачи клонирования {upid}")
|
||||
return False
|
||||
|
||||
|
||||
def _do_clone_sync(task_id: int, server_id: int, src_vmid: int, node: str, vm_type: str,
|
||||
new_name: str, full: bool, target_node: Optional[str],
|
||||
target_storage: Optional[str], description: Optional[str],
|
||||
user_id: int, username: str):
|
||||
user_id: int, username: str, owner_id: Optional[int] = None):
|
||||
"""Clone existing VM/LXC into a new VMID."""
|
||||
db = SessionLocal()
|
||||
server = None
|
||||
@@ -438,10 +485,21 @@ def _do_clone_sync(task_id: int, server_id: int, src_vmid: int, node: str, vm_ty
|
||||
_update_deploy_task(task_id, 'running', 'Ожидание завершения клонирования...', 70,
|
||||
vmid=new_vmid, node=target_node or node)
|
||||
try:
|
||||
client.wait_for_task(node, upid, timeout=600)
|
||||
_wait_clone_with_progress(client, task_id, node, upid, timeout=600)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Register the cloned instance locally so it gets an owner and is visible
|
||||
# to limited users (live list also picks it up from PVE, but without owner).
|
||||
try:
|
||||
save_vm_instance(
|
||||
db=db, server_id=server_id, vmid=new_vmid,
|
||||
node=target_node or node, vm_type=vm_type, name=new_name,
|
||||
description=description, owner_id=owner_id or user_id,
|
||||
)
|
||||
except Exception as reg_err:
|
||||
logger.warning(f"[CLONE #{task_id}] failed to register instance: {reg_err}")
|
||||
|
||||
LoggingService.log_proxmox_action(
|
||||
db=db, action='clone',
|
||||
resource_type='lxc' if is_lxc else 'vm', resource_id=src_vmid,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from fastapi import APIRouter, Depends, Request, HTTPException, Query, Form, status, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from sqlalchemy import func
|
||||
from loguru import logger
|
||||
from typing import List
|
||||
@@ -135,7 +135,8 @@ def get_all_virtual_machines(
|
||||
ipam_by_name[alloc.resource_name.lower()] = alloc
|
||||
|
||||
# Build base query for cached VMs (not deleted, not templates)
|
||||
query = db.query(VMInstance).filter(
|
||||
# joinedload(owner) avoids an N+1 query when rendering the owner column.
|
||||
query = db.query(VMInstance).options(joinedload(VMInstance.owner)).filter(
|
||||
VMInstance.deleted_at.is_(None),
|
||||
VMInstance.is_template == False
|
||||
)
|
||||
@@ -252,6 +253,7 @@ def get_all_virtual_machines(
|
||||
"hostname": ip_hostname or f"{'vps' if vm.vm_type == 'qemu' else 'lxc'}{vm.vmid}.{server.hostname if server else 'local'}",
|
||||
"type": vm.vm_type,
|
||||
"status": live.get('status') or vm.status or "unknown",
|
||||
"lock": live.get('lock') or None,
|
||||
"node": vm.node,
|
||||
"cores": vm.cores or 0,
|
||||
"memory": vm.memory or 0,
|
||||
@@ -262,6 +264,14 @@ def get_all_virtual_machines(
|
||||
"os": os_template,
|
||||
"os_template": os_template,
|
||||
"owner": owner,
|
||||
"owner_user": (
|
||||
{
|
||||
"username": vm.owner.username,
|
||||
"email": vm.owner.email,
|
||||
"full_name": vm.owner.full_name,
|
||||
}
|
||||
if vm.owner else None
|
||||
),
|
||||
"owner_hostname": "",
|
||||
"storage": "Storage1 (DIR)",
|
||||
"tags": vm.tags or "",
|
||||
@@ -358,6 +368,20 @@ class CloneRequest(BaseModel):
|
||||
target_node: Optional[str] = None
|
||||
target_storage: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
owner_id: Optional[int] = None
|
||||
|
||||
|
||||
def _resolve_clone_owner(db: Session, current_user: User, owner_id: Optional[int]) -> int:
|
||||
"""Resolve the owner for a cloned instance (admins may assign to others)."""
|
||||
if owner_id and owner_id != current_user.id:
|
||||
is_admin = current_user.has_permission("user:manage") or current_user.is_admin
|
||||
if not is_admin:
|
||||
raise HTTPException(status_code=403, detail="Only admins can assign instance to another user")
|
||||
owner = db.query(User).filter(User.id == owner_id).first()
|
||||
if not owner:
|
||||
raise HTTPException(status_code=404, detail="Owner user not found")
|
||||
return owner.id
|
||||
return current_user.id
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
@@ -397,6 +421,7 @@ def clone_vm_endpoint(
|
||||
|
||||
require_vm_access(db, current_user, server_id, vmid)
|
||||
_resolve_server(db, server_id)
|
||||
owner_id = _resolve_clone_owner(db, current_user, body.owner_id)
|
||||
|
||||
task = DeployTask(
|
||||
kind='clone', name=body.new_name, status='pending', step='В очереди...', progress=0,
|
||||
@@ -407,7 +432,7 @@ def clone_vm_endpoint(
|
||||
_deploy_executor.submit(
|
||||
_do_clone_sync, task.id, server_id, vmid, node, 'qemu',
|
||||
body.new_name, body.full, body.target_node, body.target_storage, body.description,
|
||||
current_user.id, current_user.username,
|
||||
current_user.id, current_user.username, owner_id,
|
||||
)
|
||||
return DeployTaskStartResponse(task_id=task.id, status='pending', name=body.new_name)
|
||||
|
||||
@@ -428,6 +453,7 @@ def clone_container_endpoint(
|
||||
|
||||
require_vm_access(db, current_user, server_id, vmid)
|
||||
_resolve_server(db, server_id)
|
||||
owner_id = _resolve_clone_owner(db, current_user, body.owner_id)
|
||||
|
||||
task = DeployTask(
|
||||
kind='clone', name=body.new_name, status='pending', step='В очереди...', progress=0,
|
||||
@@ -438,7 +464,7 @@ def clone_container_endpoint(
|
||||
_deploy_executor.submit(
|
||||
_do_clone_sync, task.id, server_id, vmid, node, 'lxc',
|
||||
body.new_name, body.full, body.target_node, body.target_storage, body.description,
|
||||
current_user.id, current_user.username,
|
||||
current_user.id, current_user.username, owner_id,
|
||||
)
|
||||
return DeployTaskStartResponse(task_id=task.id, status='pending', name=body.new_name)
|
||||
|
||||
|
||||
137
frontend/src/components/shared/column-filter.tsx
Normal file
137
frontend/src/components/shared/column-filter.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { Column, FilterFn, RowData } from '@tanstack/react-table';
|
||||
import { ListFilter, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// Filter metadata declared per-column via columnDef.meta
|
||||
declare module '@tanstack/react-table' {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
interface ColumnMeta<TData extends RowData, TValue> {
|
||||
filter?: 'text' | 'select';
|
||||
formatOption?: (value: string) => string;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Multi-select filter: keeps the row when its (single string) cell value is one
|
||||
* of the selected values. Built-in arrIncludesSome expects array cells, so we
|
||||
* provide our own. Empty selection passes everything.
|
||||
*/
|
||||
export const multiSelectFilter: FilterFn<unknown> = (row, columnId, value: string[]) => {
|
||||
if (!value?.length) return true;
|
||||
return value.includes(String(row.getValue(columnId)));
|
||||
};
|
||||
|
||||
interface ColumnFilterProps<TData> {
|
||||
column: Column<TData, unknown>;
|
||||
/** "text" → contains input; "select" → checklist of unique values */
|
||||
variant?: 'text' | 'select';
|
||||
/** Optional label→value formatter for select options */
|
||||
formatOption?: (value: string) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Funnel-style per-column filter popover for TanStack tables.
|
||||
* Text columns filter by substring (filterFn "includesString").
|
||||
* Select columns filter by a set of values (filterFn "arrIncludesSome").
|
||||
*/
|
||||
export function ColumnFilter<TData>({ column, variant = 'text', formatOption }: ColumnFilterProps<TData>) {
|
||||
const { t } = useTranslation();
|
||||
const filterValue = column.getFilterValue();
|
||||
const isActive =
|
||||
variant === 'select'
|
||||
? Array.isArray(filterValue) && filterValue.length > 0
|
||||
: !!filterValue;
|
||||
|
||||
const uniqueValues = useMemo(() => {
|
||||
if (variant !== 'select') return [];
|
||||
return Array.from(column.getFacetedUniqueValues().keys())
|
||||
.filter((v): v is string => v != null && String(v).length > 0)
|
||||
.map(String)
|
||||
.sort();
|
||||
}, [variant, column]);
|
||||
|
||||
const selected = (Array.isArray(filterValue) ? filterValue : []) as string[];
|
||||
|
||||
const toggle = (value: string) => {
|
||||
const next = selected.includes(value)
|
||||
? selected.filter((v) => v !== value)
|
||||
: [...selected, value];
|
||||
column.setFilterValue(next.length ? next : undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn('h-6 w-6', isActive && 'text-amber-500')}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ListFilter className="h-3.5 w-3.5" />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-56 p-2">
|
||||
{variant === 'text' ? (
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder={t('common.filter', 'Filter…')}
|
||||
value={(filterValue as string) ?? ''}
|
||||
onChange={(e) => column.setFilterValue(e.target.value || undefined)}
|
||||
className="h-8"
|
||||
/>
|
||||
{isActive && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-full justify-start text-xs text-muted-foreground"
|
||||
onClick={() => column.setFilterValue(undefined)}
|
||||
>
|
||||
<X className="mr-1 h-3 w-3" /> {t('common.reset', 'Reset')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<div className="max-h-60 space-y-0.5 overflow-y-auto">
|
||||
{uniqueValues.length === 0 && (
|
||||
<span className="px-1 text-xs text-muted-foreground">—</span>
|
||||
)}
|
||||
{uniqueValues.map((value) => (
|
||||
<label
|
||||
key={value}
|
||||
className="flex cursor-pointer items-center gap-2 rounded px-1 py-1 text-sm hover:bg-accent"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-border"
|
||||
checked={selected.includes(value)}
|
||||
onChange={() => toggle(value)}
|
||||
/>
|
||||
<span className="truncate">{formatOption ? formatOption(value) : value}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{isActive && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-full justify-start text-xs text-muted-foreground"
|
||||
onClick={() => column.setFilterValue(undefined)}
|
||||
>
|
||||
<X className="mr-1 h-3 w-3" /> {t('common.reset', 'Reset')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
47
frontend/src/components/ui/popover.tsx
Normal file
47
frontend/src/components/ui/popover.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Popover as PopoverPrimitive } from "@base-ui/react/popover"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Popover({ ...props }: PopoverPrimitive.Root.Props) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||
}
|
||||
|
||||
function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
align = "start",
|
||||
alignOffset = 0,
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
className,
|
||||
...props
|
||||
}: PopoverPrimitive.Popup.Props &
|
||||
Pick<
|
||||
PopoverPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset"
|
||||
>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Positioner
|
||||
className="isolate z-50 outline-none"
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
>
|
||||
<PopoverPrimitive.Popup
|
||||
data-slot="popover-content"
|
||||
className={cn(
|
||||
"z-50 w-72 origin-(--transform-origin) rounded-lg bg-popover p-3 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Positioner>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent }
|
||||
@@ -30,6 +30,10 @@ import {
|
||||
useDetachIso,
|
||||
useVMConfig,
|
||||
} from '@/hooks/use-instances';
|
||||
import { useNodes } from '@/hooks/use-nodes';
|
||||
import { useLXCStorages } from '@/hooks/use-lxc-templates';
|
||||
import { useUsers } from '@/hooks/use-users';
|
||||
import { useProfile } from '@/hooks/use-settings';
|
||||
import { toast } from 'sonner';
|
||||
import { useDeployTasksStore } from '@/stores/deploy-tasks-store';
|
||||
|
||||
@@ -74,16 +78,49 @@ export default function InstanceActionDialogs(props: Props) {
|
||||
|
||||
// ==================== Clone ====================
|
||||
|
||||
const CLONE_AUTO = '__auto__';
|
||||
|
||||
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 [targetNode, setTargetNode] = useState('');
|
||||
const [targetStorage, setTargetStorage] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [ownerId, setOwnerId] = useState<number | null>(null);
|
||||
const clone = useCloneInstance(serverId, vmid, type, node);
|
||||
const addDeployTask = useDeployTasksStore((s) => s.addTask);
|
||||
|
||||
const { data: profile } = useProfile();
|
||||
const isAdmin = !!profile?.is_admin;
|
||||
const { data: allUsers = [] } = useUsers();
|
||||
const { data: nodesResp } = useNodes(serverId);
|
||||
const nodes = nodesResp?.nodes ?? [];
|
||||
const effectiveNode = targetNode || node;
|
||||
const { data: storages = [] } = useLXCStorages(serverId, effectiveNode);
|
||||
|
||||
// Reset form whenever the dialog is opened for a fresh instance.
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setNewName('');
|
||||
setFull(true);
|
||||
setTargetNode('');
|
||||
setTargetStorage('');
|
||||
setDescription('');
|
||||
setOwnerId(null);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const submit = () => {
|
||||
if (!newName.trim()) return;
|
||||
clone.mutate(
|
||||
{ new_name: newName.trim(), full },
|
||||
{
|
||||
new_name: newName.trim(),
|
||||
full,
|
||||
target_node: targetNode || undefined,
|
||||
target_storage: targetStorage || undefined,
|
||||
description: description.trim() || undefined,
|
||||
owner_id: isAdmin && ownerId ? ownerId : undefined,
|
||||
},
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
addDeployTask({
|
||||
@@ -93,13 +130,12 @@ function CloneDialog({ open, onClose, serverId, vmid, type, node, name }: Omit<P
|
||||
step: 'В очереди...',
|
||||
progress: 0,
|
||||
vmid: null,
|
||||
node,
|
||||
node: targetNode || node,
|
||||
error_message: null,
|
||||
kind: 'clone',
|
||||
server_id: serverId,
|
||||
});
|
||||
toast.success('Клонирование запущено в фоне');
|
||||
setNewName('');
|
||||
onClose();
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
@@ -114,11 +150,62 @@ function CloneDialog({ open, onClose, serverId, vmid, type, node, name }: Omit<P
|
||||
<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>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="clone-name">Имя новой инстанции</Label>
|
||||
<Input id="clone-name" value={newName} onChange={(e) => setNewName(e.target.value)} placeholder={`${name || 'instance'}-clone`} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Целевой узел</Label>
|
||||
<Select value={targetNode || CLONE_AUTO} onValueChange={(v) => { setTargetNode(v === CLONE_AUTO ? '' : v); setTargetStorage(''); }}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={CLONE_AUTO}>Тот же узел ({node})</SelectItem>
|
||||
{nodes.filter((n) => n.node !== node).map((n) => (
|
||||
<SelectItem key={n.node} value={n.node}>{n.node}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Хранилище</Label>
|
||||
<Select value={targetStorage || CLONE_AUTO} onValueChange={(v) => setTargetStorage(v === CLONE_AUTO ? '' : v)}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={CLONE_AUTO}>Как у источника</SelectItem>
|
||||
{storages.map((s) => (
|
||||
<SelectItem key={s.storage} value={s.storage}>{s.storage} ({s.type})</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isAdmin && (
|
||||
<div className="space-y-1.5">
|
||||
<Label>Владелец</Label>
|
||||
<Select value={ownerId ? String(ownerId) : CLONE_AUTO} onValueChange={(v) => setOwnerId(v === CLONE_AUTO ? null : Number(v))}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={CLONE_AUTO}>Я ({profile?.username})</SelectItem>
|
||||
{allUsers.map((u) => (
|
||||
<SelectItem key={u.id} value={String(u.id)}>
|
||||
{u.username}{u.full_name ? ` (${u.full_name})` : ''}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="clone-desc">Описание <span className="text-muted-foreground">(необязательно)</span></Label>
|
||||
<Input id="clone-desc" value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Заметка для новой инстанции" />
|
||||
</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>
|
||||
|
||||
@@ -7,10 +7,13 @@ import {
|
||||
getFilteredRowModel,
|
||||
getSortedRowModel,
|
||||
getPaginationRowModel,
|
||||
getFacetedRowModel,
|
||||
getFacetedUniqueValues,
|
||||
flexRender,
|
||||
type ColumnDef,
|
||||
type SortingState,
|
||||
type RowSelectionState,
|
||||
type ColumnFiltersState,
|
||||
} from '@tanstack/react-table';
|
||||
import {
|
||||
Plus,
|
||||
@@ -37,6 +40,7 @@ import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
HardDrive,
|
||||
Lock,
|
||||
} from 'lucide-react';
|
||||
import InstanceActionDialogs, { PowerConfirmDialog, type InstanceAction, type PowerAction } from './InstanceActionDialogs';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
@@ -68,10 +72,11 @@ import {
|
||||
} from '@/components/ui/table';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { StatusDot } from '@/components/shared/status-dot';
|
||||
import { ColumnFilter, multiSelectFilter } from '@/components/shared/column-filter';
|
||||
import { useVirtualMachines, useBulkOperation, usePowerAction, useVMStatusSync, useInstancesMetricsSync } from '@/hooks/use-instances';
|
||||
import { useServers } from '@/hooks/use-nodes';
|
||||
import { useProfile } from '@/hooks/use-settings';
|
||||
import { formatBytes, vmTypeLabel, formatUptime } from '@/lib/format';
|
||||
import { formatBytes, vmTypeLabel, formatUptime, formatVmConfig } from '@/lib/format';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { useDeployTasksStore } from '@/stores/deploy-tasks-store';
|
||||
import type { VMInstance } from '@/types';
|
||||
@@ -273,9 +278,32 @@ export default function InstancesPage() {
|
||||
return localStorage.getItem('instances-node-filter') ?? 'all';
|
||||
});
|
||||
const [typeTab, setTypeTab] = useState<string>('all');
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [sorting, setSorting] = useState<SortingState>(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem('instances-sorting');
|
||||
return raw ? (JSON.parse(raw) as SortingState) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem('instances-column-filters');
|
||||
return raw ? (JSON.parse(raw) as ColumnFiltersState) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
|
||||
// Persist sorting + column filters across reloads
|
||||
useEffect(() => {
|
||||
localStorage.setItem('instances-sorting', JSON.stringify(sorting));
|
||||
}, [sorting]);
|
||||
useEffect(() => {
|
||||
localStorage.setItem('instances-column-filters', JSON.stringify(columnFilters));
|
||||
}, [columnFilters]);
|
||||
|
||||
// Filter real VMs
|
||||
const filteredVMs = useMemo(() => {
|
||||
if (!vms) return [];
|
||||
@@ -439,6 +467,8 @@ export default function InstancesPage() {
|
||||
},
|
||||
{
|
||||
accessorKey: 'name',
|
||||
filterFn: 'includesString',
|
||||
meta: { filter: 'text' },
|
||||
header: ({ column }) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -485,6 +515,8 @@ export default function InstancesPage() {
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
filterFn: multiSelectFilter,
|
||||
meta: { filter: 'select' },
|
||||
header: t('common.status', 'Status'),
|
||||
cell: ({ row }) => {
|
||||
const vm = row.original;
|
||||
@@ -518,18 +550,32 @@ export default function InstancesPage() {
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={status === 'running' ? 'bg-green-500/10 text-green-500' : 'bg-muted text-muted-foreground'}
|
||||
>
|
||||
{status}
|
||||
</Badge>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={status === 'running' ? 'bg-green-500/10 text-green-500' : 'bg-muted text-muted-foreground'}
|
||||
>
|
||||
{status}
|
||||
</Badge>
|
||||
{vm.lock && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-amber-500/10 text-amber-600 text-[10px] gap-1"
|
||||
title={t('instances.locked_hint', 'Заблокировано Proxmox — операция выполняется')}
|
||||
>
|
||||
<Lock className="h-3 w-3" />
|
||||
{vm.lock}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
size: 140,
|
||||
size: 160,
|
||||
},
|
||||
{
|
||||
accessorKey: 'node',
|
||||
filterFn: multiSelectFilter,
|
||||
meta: { filter: 'select' },
|
||||
header: t('common.node', 'Node'),
|
||||
cell: ({ getValue }) => (
|
||||
<span className="text-muted-foreground">{getValue<string>()}</span>
|
||||
@@ -538,6 +584,8 @@ export default function InstancesPage() {
|
||||
},
|
||||
{
|
||||
accessorKey: 'type',
|
||||
filterFn: multiSelectFilter,
|
||||
meta: { filter: 'select', formatOption: (v: string) => vmTypeLabel(v) },
|
||||
header: t('common.type', 'Type'),
|
||||
cell: ({ getValue }) => {
|
||||
const type = getValue<string>();
|
||||
@@ -559,6 +607,8 @@ export default function InstancesPage() {
|
||||
{
|
||||
id: 'ip',
|
||||
accessorKey: 'ip_address',
|
||||
filterFn: 'includesString',
|
||||
meta: { filter: 'text' },
|
||||
header: 'IP',
|
||||
cell: ({ getValue }) => {
|
||||
const ip = getValue<string>();
|
||||
@@ -570,6 +620,47 @@ export default function InstancesPage() {
|
||||
},
|
||||
size: 130,
|
||||
},
|
||||
{
|
||||
id: 'owner',
|
||||
accessorFn: (vm) =>
|
||||
vm.owner_user?.email ||
|
||||
vm.owner_user?.full_name ||
|
||||
vm.owner_user?.username ||
|
||||
vm.owner ||
|
||||
'',
|
||||
filterFn: 'includesString',
|
||||
meta: { filter: 'text' },
|
||||
header: t('instances.owner', 'Owner'),
|
||||
cell: ({ getValue }) => {
|
||||
const owner = getValue<string>();
|
||||
return owner ? (
|
||||
<span className="text-xs">{owner}</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
);
|
||||
},
|
||||
size: 180,
|
||||
},
|
||||
{
|
||||
id: 'os',
|
||||
accessorFn: (vm) => vm.os || vm.os_template || '',
|
||||
filterFn: multiSelectFilter,
|
||||
meta: { filter: 'select' },
|
||||
header: t('instances.os_config', 'OS / Config'),
|
||||
cell: ({ row }) => {
|
||||
const vm = row.original;
|
||||
if (vm._deployTaskId) return <span className="text-xs text-muted-foreground">—</span>;
|
||||
const os = vm.os || vm.os_template;
|
||||
const config = formatVmConfig(vm);
|
||||
return (
|
||||
<div className="leading-tight">
|
||||
<div className="text-sm">{os || '—'}</div>
|
||||
{config && <div className="text-xs text-muted-foreground">{config}</div>}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
size: 200,
|
||||
},
|
||||
{
|
||||
id: 'cpu',
|
||||
header: 'CPU',
|
||||
@@ -688,24 +779,6 @@ export default function InstancesPage() {
|
||||
},
|
||||
size: 80,
|
||||
},
|
||||
{
|
||||
id: 'tags',
|
||||
accessorKey: 'tags',
|
||||
header: 'Tags',
|
||||
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>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
size: 120,
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
cell: ({ row }) => {
|
||||
@@ -724,13 +797,16 @@ export default function InstancesPage() {
|
||||
const table = useReactTable({
|
||||
data: liveRows,
|
||||
columns,
|
||||
state: { sorting, rowSelection },
|
||||
state: { sorting, rowSelection, columnFilters },
|
||||
onSortingChange: setSorting,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getFacetedRowModel: getFacetedRowModel(),
|
||||
getFacetedUniqueValues: getFacetedUniqueValues(),
|
||||
initialState: { pagination: { pageSize: 25 } },
|
||||
});
|
||||
|
||||
@@ -858,11 +934,25 @@ export default function InstancesPage() {
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead key={header.id} style={{ width: header.getSize() }}>
|
||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</TableHead>
|
||||
))}
|
||||
{headerGroup.headers.map((header) => {
|
||||
const filterVariant = header.column.columnDef.meta?.filter;
|
||||
return (
|
||||
<TableHead key={header.id} style={{ width: header.getSize() }}>
|
||||
{header.isPlaceholder ? null : (
|
||||
<div className="flex items-center gap-0.5">
|
||||
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||
{filterVariant && (
|
||||
<ColumnFilter
|
||||
column={header.column}
|
||||
variant={filterVariant}
|
||||
formatOption={header.column.columnDef.meta?.formatOption}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
|
||||
@@ -506,6 +506,7 @@ interface CloneRequest {
|
||||
target_node?: string;
|
||||
target_storage?: string;
|
||||
description?: string;
|
||||
owner_id?: number;
|
||||
}
|
||||
|
||||
export interface AsyncTaskResponse {
|
||||
|
||||
@@ -27,3 +27,21 @@ export function formatCPU(cpuFraction: number, maxcpu: number): string {
|
||||
export function vmTypeLabel(type: string): string {
|
||||
return type === 'qemu' ? 'VM' : type === 'lxc' ? 'LXC' : type.toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a VM/LXC hardware configuration as "N vCPU / X GB RAM / Y GB".
|
||||
* `memory` is expected in MB; disk in GB (falls back to `maxdisk` in bytes).
|
||||
*/
|
||||
export function formatVmConfig(vm: {
|
||||
cores?: number;
|
||||
memory?: number;
|
||||
disk_size?: number;
|
||||
maxdisk?: number;
|
||||
}): string {
|
||||
const parts: string[] = [];
|
||||
if (vm.cores) parts.push(`${vm.cores} vCPU`);
|
||||
if (vm.memory) parts.push(`${Math.round(vm.memory / 1024)} GB RAM`);
|
||||
const diskGb = vm.disk_size ?? (vm.maxdisk ? Math.round(vm.maxdisk / 1024 ** 3) : 0);
|
||||
if (diskGb) parts.push(`${diskGb} GB`);
|
||||
return parts.join(' / ');
|
||||
}
|
||||
|
||||
@@ -86,6 +86,8 @@
|
||||
"optional": "optional",
|
||||
"cancel": "Cancel",
|
||||
"clear": "Clear",
|
||||
"filter": "Filter…",
|
||||
"reset": "Reset",
|
||||
"delete": "Delete",
|
||||
"edit": "Edit",
|
||||
"create": "Create",
|
||||
@@ -182,6 +184,7 @@
|
||||
},
|
||||
"instances": {
|
||||
"create": "Create Instance",
|
||||
"os_config": "OS / Config",
|
||||
"owner": "Owner",
|
||||
"no_owner": "No owner",
|
||||
"owner_saved": "Owner updated",
|
||||
@@ -225,6 +228,7 @@
|
||||
"destroy": "Destroy",
|
||||
"task_activity": "Task Activity",
|
||||
"status_creating": "Creating",
|
||||
"locked_hint": "Locked by Proxmox — operation in progress",
|
||||
"vmid_pending": "pending",
|
||||
"no_snapshots": "No snapshots",
|
||||
"notes_saved": "Notes saved",
|
||||
|
||||
@@ -86,6 +86,8 @@
|
||||
"optional": "необязательно",
|
||||
"cancel": "Отмена",
|
||||
"clear": "Очистить",
|
||||
"filter": "Фильтр…",
|
||||
"reset": "Сбросить",
|
||||
"delete": "Удалить",
|
||||
"edit": "Редактировать",
|
||||
"create": "Создать",
|
||||
@@ -182,6 +184,7 @@
|
||||
},
|
||||
"instances": {
|
||||
"create": "Создать инстанс",
|
||||
"os_config": "ОС / Конфигурация",
|
||||
"owner": "Владелец",
|
||||
"no_owner": "Без владельца",
|
||||
"owner_saved": "Владелец обновлён",
|
||||
@@ -225,6 +228,7 @@
|
||||
"destroy": "Уничтожить",
|
||||
"task_activity": "Активность задач",
|
||||
"status_creating": "Создание",
|
||||
"locked_hint": "Заблокировано Proxmox — операция выполняется",
|
||||
"vmid_pending": "ожидание",
|
||||
"no_snapshots": "Нет снимков",
|
||||
"notes_saved": "Заметки сохранены",
|
||||
|
||||
@@ -99,6 +99,21 @@ export interface VMInstance {
|
||||
tags?: string;
|
||||
template?: boolean;
|
||||
description?: string;
|
||||
/** Configured vCPU count */
|
||||
cores?: number;
|
||||
/** Configured RAM in MB */
|
||||
memory?: number;
|
||||
/** Configured disk size in GB */
|
||||
disk_size?: number;
|
||||
/** OS name / template label */
|
||||
os?: string;
|
||||
os_template?: string;
|
||||
/** IPAM allocated_by string (legacy owner) */
|
||||
owner?: string;
|
||||
/** Actual owner user (VMInstance.owner_id → User) */
|
||||
owner_user?: { username: string; email: string; full_name?: string | null } | null;
|
||||
/** PVE lock state (e.g. 'clone', 'create', 'migrate') while an operation runs */
|
||||
lock?: string;
|
||||
/** Ghost row marker — present only for in-progress deploy tasks */
|
||||
_deployTaskId?: number;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user