4 Commits

Author SHA1 Message Date
5c6bb013c9 feat(instances): surface PVE lock state in the instances list
All checks were successful
Backend Tests / pytest (Python 3.12) (push) Successful in 50s
Deploy to Production / Trigger production update (push) Successful in 8s
Backend returns the live `lock` field (clone/create/migrate/...) and the
list shows an amber badge next to the status, so an instance that is
still being cloned no longer looks like a ready stopped VM.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 00:38:41 +05:00
6b3700f496 feat(clone): richer clone dialog with node, storage and owner pickers
Expose target node, target storage, owner (admin-only) and description
in the clone dialog, wired to the existing backend params. Reset the
form on open and fix the cramped label/field spacing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 00:38:41 +05:00
1cc4d86b18 feat(clone): real disk-copy progress, owner assignment and DB registration
- _do_clone_sync now tails the PVE clone task log and maps the disk
  transfer percentage to deploy progress 50→95% instead of a static 70%.
- Add owner_id to CloneRequest; admins may assign the clone to another
  user (mirrors LXC deploy). Register the cloned instance via
  save_vm_instance so it has an owner and is visible to limited users.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 00:38:26 +05:00
8ebef3d10e feat(instances): add column filters and VM config formatter
Add ColumnFilter component (Popover-based faceted filter) and a
formatVmConfig helper rendering "N vCPU / X GB RAM / Y GB".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 00:38:14 +05:00
11 changed files with 532 additions and 45 deletions

View File

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

View File

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

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

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

View File

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

View File

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

View File

@@ -506,6 +506,7 @@ interface CloneRequest {
target_node?: string;
target_storage?: string;
description?: string;
owner_id?: number;
}
export interface AsyncTaskResponse {

View File

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

View File

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

View File

@@ -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": "Заметки сохранены",

View File

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