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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
import time
|
import time
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
@@ -16,7 +17,7 @@ from ...db import SessionLocal
|
|||||||
from ...models import ProxmoxServer, VMInstance, OSTemplate, DeployTask, User
|
from ...models import ProxmoxServer, VMInstance, OSTemplate, DeployTask, User
|
||||||
from ...proxmox import ProxmoxClient
|
from ...proxmox import ProxmoxClient
|
||||||
from ...logging_service import LoggingService
|
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
|
# 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 ====================
|
# ==================== 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,
|
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],
|
new_name: str, full: bool, target_node: Optional[str],
|
||||||
target_storage: Optional[str], description: 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."""
|
"""Clone existing VM/LXC into a new VMID."""
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
server = None
|
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,
|
_update_deploy_task(task_id, 'running', 'Ожидание завершения клонирования...', 70,
|
||||||
vmid=new_vmid, node=target_node or node)
|
vmid=new_vmid, node=target_node or node)
|
||||||
try:
|
try:
|
||||||
client.wait_for_task(node, upid, timeout=600)
|
_wait_clone_with_progress(client, task_id, node, upid, timeout=600)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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(
|
LoggingService.log_proxmox_action(
|
||||||
db=db, action='clone',
|
db=db, action='clone',
|
||||||
resource_type='lxc' if is_lxc else 'vm', resource_id=src_vmid,
|
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 import APIRouter, Depends, Request, HTTPException, Query, Form, status, WebSocket, WebSocketDisconnect
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session, joinedload
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from typing import List
|
from typing import List
|
||||||
@@ -135,7 +135,8 @@ def get_all_virtual_machines(
|
|||||||
ipam_by_name[alloc.resource_name.lower()] = alloc
|
ipam_by_name[alloc.resource_name.lower()] = alloc
|
||||||
|
|
||||||
# Build base query for cached VMs (not deleted, not templates)
|
# 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.deleted_at.is_(None),
|
||||||
VMInstance.is_template == False
|
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'}",
|
"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,
|
"type": vm.vm_type,
|
||||||
"status": live.get('status') or vm.status or "unknown",
|
"status": live.get('status') or vm.status or "unknown",
|
||||||
|
"lock": live.get('lock') or None,
|
||||||
"node": vm.node,
|
"node": vm.node,
|
||||||
"cores": vm.cores or 0,
|
"cores": vm.cores or 0,
|
||||||
"memory": vm.memory or 0,
|
"memory": vm.memory or 0,
|
||||||
@@ -262,6 +264,14 @@ def get_all_virtual_machines(
|
|||||||
"os": os_template,
|
"os": os_template,
|
||||||
"os_template": os_template,
|
"os_template": os_template,
|
||||||
"owner": owner,
|
"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": "",
|
"owner_hostname": "",
|
||||||
"storage": "Storage1 (DIR)",
|
"storage": "Storage1 (DIR)",
|
||||||
"tags": vm.tags or "",
|
"tags": vm.tags or "",
|
||||||
@@ -358,6 +368,20 @@ class CloneRequest(BaseModel):
|
|||||||
target_node: Optional[str] = None
|
target_node: Optional[str] = None
|
||||||
target_storage: Optional[str] = None
|
target_storage: Optional[str] = None
|
||||||
description: 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):
|
class ChangePasswordRequest(BaseModel):
|
||||||
@@ -397,6 +421,7 @@ def clone_vm_endpoint(
|
|||||||
|
|
||||||
require_vm_access(db, current_user, server_id, vmid)
|
require_vm_access(db, current_user, server_id, vmid)
|
||||||
_resolve_server(db, server_id)
|
_resolve_server(db, server_id)
|
||||||
|
owner_id = _resolve_clone_owner(db, current_user, body.owner_id)
|
||||||
|
|
||||||
task = DeployTask(
|
task = DeployTask(
|
||||||
kind='clone', name=body.new_name, status='pending', step='В очереди...', progress=0,
|
kind='clone', name=body.new_name, status='pending', step='В очереди...', progress=0,
|
||||||
@@ -407,7 +432,7 @@ def clone_vm_endpoint(
|
|||||||
_deploy_executor.submit(
|
_deploy_executor.submit(
|
||||||
_do_clone_sync, task.id, server_id, vmid, node, 'qemu',
|
_do_clone_sync, task.id, server_id, vmid, node, 'qemu',
|
||||||
body.new_name, body.full, body.target_node, body.target_storage, body.description,
|
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)
|
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)
|
require_vm_access(db, current_user, server_id, vmid)
|
||||||
_resolve_server(db, server_id)
|
_resolve_server(db, server_id)
|
||||||
|
owner_id = _resolve_clone_owner(db, current_user, body.owner_id)
|
||||||
|
|
||||||
task = DeployTask(
|
task = DeployTask(
|
||||||
kind='clone', name=body.new_name, status='pending', step='В очереди...', progress=0,
|
kind='clone', name=body.new_name, status='pending', step='В очереди...', progress=0,
|
||||||
@@ -438,7 +464,7 @@ def clone_container_endpoint(
|
|||||||
_deploy_executor.submit(
|
_deploy_executor.submit(
|
||||||
_do_clone_sync, task.id, server_id, vmid, node, 'lxc',
|
_do_clone_sync, task.id, server_id, vmid, node, 'lxc',
|
||||||
body.new_name, body.full, body.target_node, body.target_storage, body.description,
|
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)
|
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,
|
useDetachIso,
|
||||||
useVMConfig,
|
useVMConfig,
|
||||||
} from '@/hooks/use-instances';
|
} 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 { toast } from 'sonner';
|
||||||
import { useDeployTasksStore } from '@/stores/deploy-tasks-store';
|
import { useDeployTasksStore } from '@/stores/deploy-tasks-store';
|
||||||
|
|
||||||
@@ -74,16 +78,49 @@ export default function InstanceActionDialogs(props: Props) {
|
|||||||
|
|
||||||
// ==================== Clone ====================
|
// ==================== Clone ====================
|
||||||
|
|
||||||
|
const CLONE_AUTO = '__auto__';
|
||||||
|
|
||||||
function CloneDialog({ open, onClose, serverId, vmid, type, node, name }: Omit<Props, 'open' | 'onOpenChange'> & { open: boolean; onClose: () => void }) {
|
function CloneDialog({ open, onClose, serverId, vmid, type, node, name }: Omit<Props, 'open' | 'onOpenChange'> & { open: boolean; onClose: () => void }) {
|
||||||
const [newName, setNewName] = useState('');
|
const [newName, setNewName] = useState('');
|
||||||
const [full, setFull] = useState(true);
|
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 clone = useCloneInstance(serverId, vmid, type, node);
|
||||||
const addDeployTask = useDeployTasksStore((s) => s.addTask);
|
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 = () => {
|
const submit = () => {
|
||||||
if (!newName.trim()) return;
|
if (!newName.trim()) return;
|
||||||
clone.mutate(
|
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) => {
|
onSuccess: (data) => {
|
||||||
addDeployTask({
|
addDeployTask({
|
||||||
@@ -93,13 +130,12 @@ function CloneDialog({ open, onClose, serverId, vmid, type, node, name }: Omit<P
|
|||||||
step: 'В очереди...',
|
step: 'В очереди...',
|
||||||
progress: 0,
|
progress: 0,
|
||||||
vmid: null,
|
vmid: null,
|
||||||
node,
|
node: targetNode || node,
|
||||||
error_message: null,
|
error_message: null,
|
||||||
kind: 'clone',
|
kind: 'clone',
|
||||||
server_id: serverId,
|
server_id: serverId,
|
||||||
});
|
});
|
||||||
toast.success('Клонирование запущено в фоне');
|
toast.success('Клонирование запущено в фоне');
|
||||||
setNewName('');
|
|
||||||
onClose();
|
onClose();
|
||||||
},
|
},
|
||||||
onError: (e) => toast.error(e.message),
|
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>
|
<DialogTitle className="flex items-center gap-2"><Copy className="h-4 w-4" /> Клонировать</DialogTitle>
|
||||||
<DialogDescription>Создать копию <strong>{name ?? `#${vmid}`}</strong></DialogDescription>
|
<DialogDescription>Создать копию <strong>{name ?? `#${vmid}`}</strong></DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-3">
|
<div className="space-y-4">
|
||||||
<div>
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="clone-name">Имя новой инстанции</Label>
|
<Label htmlFor="clone-name">Имя новой инстанции</Label>
|
||||||
<Input id="clone-name" value={newName} onChange={(e) => setNewName(e.target.value)} placeholder={`${name || 'instance'}-clone`} />
|
<Input id="clone-name" value={newName} onChange={(e) => setNewName(e.target.value)} placeholder={`${name || 'instance'}-clone`} />
|
||||||
</div>
|
</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">
|
<label className="flex items-center gap-2 text-sm">
|
||||||
<input type="checkbox" checked={full} onChange={(e) => setFull(e.target.checked)} />
|
<input type="checkbox" checked={full} onChange={(e) => setFull(e.target.checked)} />
|
||||||
<span>Полный клон (full clone)</span>
|
<span>Полный клон (full clone)</span>
|
||||||
|
|||||||
@@ -7,10 +7,13 @@ import {
|
|||||||
getFilteredRowModel,
|
getFilteredRowModel,
|
||||||
getSortedRowModel,
|
getSortedRowModel,
|
||||||
getPaginationRowModel,
|
getPaginationRowModel,
|
||||||
|
getFacetedRowModel,
|
||||||
|
getFacetedUniqueValues,
|
||||||
flexRender,
|
flexRender,
|
||||||
type ColumnDef,
|
type ColumnDef,
|
||||||
type SortingState,
|
type SortingState,
|
||||||
type RowSelectionState,
|
type RowSelectionState,
|
||||||
|
type ColumnFiltersState,
|
||||||
} from '@tanstack/react-table';
|
} from '@tanstack/react-table';
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
@@ -37,6 +40,7 @@ import {
|
|||||||
ArrowDown,
|
ArrowDown,
|
||||||
ArrowUp,
|
ArrowUp,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
|
Lock,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import InstanceActionDialogs, { PowerConfirmDialog, type InstanceAction, type PowerAction } from './InstanceActionDialogs';
|
import InstanceActionDialogs, { PowerConfirmDialog, type InstanceAction, type PowerAction } from './InstanceActionDialogs';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
@@ -68,10 +72,11 @@ import {
|
|||||||
} from '@/components/ui/table';
|
} from '@/components/ui/table';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { StatusDot } from '@/components/shared/status-dot';
|
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 { useVirtualMachines, useBulkOperation, usePowerAction, useVMStatusSync, useInstancesMetricsSync } from '@/hooks/use-instances';
|
||||||
import { useServers } from '@/hooks/use-nodes';
|
import { useServers } from '@/hooks/use-nodes';
|
||||||
import { useProfile } from '@/hooks/use-settings';
|
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 { apiClient } from '@/lib/api-client';
|
||||||
import { useDeployTasksStore } from '@/stores/deploy-tasks-store';
|
import { useDeployTasksStore } from '@/stores/deploy-tasks-store';
|
||||||
import type { VMInstance } from '@/types';
|
import type { VMInstance } from '@/types';
|
||||||
@@ -273,9 +278,32 @@ export default function InstancesPage() {
|
|||||||
return localStorage.getItem('instances-node-filter') ?? 'all';
|
return localStorage.getItem('instances-node-filter') ?? 'all';
|
||||||
});
|
});
|
||||||
const [typeTab, setTypeTab] = useState<string>('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>({});
|
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
|
// Filter real VMs
|
||||||
const filteredVMs = useMemo(() => {
|
const filteredVMs = useMemo(() => {
|
||||||
if (!vms) return [];
|
if (!vms) return [];
|
||||||
@@ -439,6 +467,8 @@ export default function InstancesPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'name',
|
accessorKey: 'name',
|
||||||
|
filterFn: 'includesString',
|
||||||
|
meta: { filter: 'text' },
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -485,6 +515,8 @@ export default function InstancesPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'status',
|
accessorKey: 'status',
|
||||||
|
filterFn: multiSelectFilter,
|
||||||
|
meta: { filter: 'select' },
|
||||||
header: t('common.status', 'Status'),
|
header: t('common.status', 'Status'),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const vm = row.original;
|
const vm = row.original;
|
||||||
@@ -518,18 +550,32 @@ export default function InstancesPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
<Badge
|
<Badge
|
||||||
variant="secondary"
|
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}
|
{status}
|
||||||
</Badge>
|
</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',
|
accessorKey: 'node',
|
||||||
|
filterFn: multiSelectFilter,
|
||||||
|
meta: { filter: 'select' },
|
||||||
header: t('common.node', 'Node'),
|
header: t('common.node', 'Node'),
|
||||||
cell: ({ getValue }) => (
|
cell: ({ getValue }) => (
|
||||||
<span className="text-muted-foreground">{getValue<string>()}</span>
|
<span className="text-muted-foreground">{getValue<string>()}</span>
|
||||||
@@ -538,6 +584,8 @@ export default function InstancesPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'type',
|
accessorKey: 'type',
|
||||||
|
filterFn: multiSelectFilter,
|
||||||
|
meta: { filter: 'select', formatOption: (v: string) => vmTypeLabel(v) },
|
||||||
header: t('common.type', 'Type'),
|
header: t('common.type', 'Type'),
|
||||||
cell: ({ getValue }) => {
|
cell: ({ getValue }) => {
|
||||||
const type = getValue<string>();
|
const type = getValue<string>();
|
||||||
@@ -559,6 +607,8 @@ export default function InstancesPage() {
|
|||||||
{
|
{
|
||||||
id: 'ip',
|
id: 'ip',
|
||||||
accessorKey: 'ip_address',
|
accessorKey: 'ip_address',
|
||||||
|
filterFn: 'includesString',
|
||||||
|
meta: { filter: 'text' },
|
||||||
header: 'IP',
|
header: 'IP',
|
||||||
cell: ({ getValue }) => {
|
cell: ({ getValue }) => {
|
||||||
const ip = getValue<string>();
|
const ip = getValue<string>();
|
||||||
@@ -570,6 +620,47 @@ export default function InstancesPage() {
|
|||||||
},
|
},
|
||||||
size: 130,
|
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',
|
id: 'cpu',
|
||||||
header: 'CPU',
|
header: 'CPU',
|
||||||
@@ -688,24 +779,6 @@ export default function InstancesPage() {
|
|||||||
},
|
},
|
||||||
size: 80,
|
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',
|
id: 'actions',
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
@@ -724,13 +797,16 @@ export default function InstancesPage() {
|
|||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: liveRows,
|
data: liveRows,
|
||||||
columns,
|
columns,
|
||||||
state: { sorting, rowSelection },
|
state: { sorting, rowSelection, columnFilters },
|
||||||
onSortingChange: setSorting,
|
onSortingChange: setSorting,
|
||||||
onRowSelectionChange: setRowSelection,
|
onRowSelectionChange: setRowSelection,
|
||||||
|
onColumnFiltersChange: setColumnFilters,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
getFilteredRowModel: getFilteredRowModel(),
|
getFilteredRowModel: getFilteredRowModel(),
|
||||||
getSortedRowModel: getSortedRowModel(),
|
getSortedRowModel: getSortedRowModel(),
|
||||||
getPaginationRowModel: getPaginationRowModel(),
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
|
getFacetedRowModel: getFacetedRowModel(),
|
||||||
|
getFacetedUniqueValues: getFacetedUniqueValues(),
|
||||||
initialState: { pagination: { pageSize: 25 } },
|
initialState: { pagination: { pageSize: 25 } },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -858,11 +934,25 @@ export default function InstancesPage() {
|
|||||||
<TableHeader>
|
<TableHeader>
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
<TableRow key={headerGroup.id}>
|
<TableRow key={headerGroup.id}>
|
||||||
{headerGroup.headers.map((header) => (
|
{headerGroup.headers.map((header) => {
|
||||||
|
const filterVariant = header.column.columnDef.meta?.filter;
|
||||||
|
return (
|
||||||
<TableHead key={header.id} style={{ width: header.getSize() }}>
|
<TableHead key={header.id} style={{ width: header.getSize() }}>
|
||||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
{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>
|
</TableHead>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
|
|||||||
@@ -506,6 +506,7 @@ interface CloneRequest {
|
|||||||
target_node?: string;
|
target_node?: string;
|
||||||
target_storage?: string;
|
target_storage?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
owner_id?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AsyncTaskResponse {
|
export interface AsyncTaskResponse {
|
||||||
|
|||||||
@@ -27,3 +27,21 @@ export function formatCPU(cpuFraction: number, maxcpu: number): string {
|
|||||||
export function vmTypeLabel(type: string): string {
|
export function vmTypeLabel(type: string): string {
|
||||||
return type === 'qemu' ? 'VM' : type === 'lxc' ? 'LXC' : type.toUpperCase();
|
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",
|
"optional": "optional",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"clear": "Clear",
|
"clear": "Clear",
|
||||||
|
"filter": "Filter…",
|
||||||
|
"reset": "Reset",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"create": "Create",
|
"create": "Create",
|
||||||
@@ -182,6 +184,7 @@
|
|||||||
},
|
},
|
||||||
"instances": {
|
"instances": {
|
||||||
"create": "Create Instance",
|
"create": "Create Instance",
|
||||||
|
"os_config": "OS / Config",
|
||||||
"owner": "Owner",
|
"owner": "Owner",
|
||||||
"no_owner": "No owner",
|
"no_owner": "No owner",
|
||||||
"owner_saved": "Owner updated",
|
"owner_saved": "Owner updated",
|
||||||
@@ -225,6 +228,7 @@
|
|||||||
"destroy": "Destroy",
|
"destroy": "Destroy",
|
||||||
"task_activity": "Task Activity",
|
"task_activity": "Task Activity",
|
||||||
"status_creating": "Creating",
|
"status_creating": "Creating",
|
||||||
|
"locked_hint": "Locked by Proxmox — operation in progress",
|
||||||
"vmid_pending": "pending",
|
"vmid_pending": "pending",
|
||||||
"no_snapshots": "No snapshots",
|
"no_snapshots": "No snapshots",
|
||||||
"notes_saved": "Notes saved",
|
"notes_saved": "Notes saved",
|
||||||
|
|||||||
@@ -86,6 +86,8 @@
|
|||||||
"optional": "необязательно",
|
"optional": "необязательно",
|
||||||
"cancel": "Отмена",
|
"cancel": "Отмена",
|
||||||
"clear": "Очистить",
|
"clear": "Очистить",
|
||||||
|
"filter": "Фильтр…",
|
||||||
|
"reset": "Сбросить",
|
||||||
"delete": "Удалить",
|
"delete": "Удалить",
|
||||||
"edit": "Редактировать",
|
"edit": "Редактировать",
|
||||||
"create": "Создать",
|
"create": "Создать",
|
||||||
@@ -182,6 +184,7 @@
|
|||||||
},
|
},
|
||||||
"instances": {
|
"instances": {
|
||||||
"create": "Создать инстанс",
|
"create": "Создать инстанс",
|
||||||
|
"os_config": "ОС / Конфигурация",
|
||||||
"owner": "Владелец",
|
"owner": "Владелец",
|
||||||
"no_owner": "Без владельца",
|
"no_owner": "Без владельца",
|
||||||
"owner_saved": "Владелец обновлён",
|
"owner_saved": "Владелец обновлён",
|
||||||
@@ -225,6 +228,7 @@
|
|||||||
"destroy": "Уничтожить",
|
"destroy": "Уничтожить",
|
||||||
"task_activity": "Активность задач",
|
"task_activity": "Активность задач",
|
||||||
"status_creating": "Создание",
|
"status_creating": "Создание",
|
||||||
|
"locked_hint": "Заблокировано Proxmox — операция выполняется",
|
||||||
"vmid_pending": "ожидание",
|
"vmid_pending": "ожидание",
|
||||||
"no_snapshots": "Нет снимков",
|
"no_snapshots": "Нет снимков",
|
||||||
"notes_saved": "Заметки сохранены",
|
"notes_saved": "Заметки сохранены",
|
||||||
|
|||||||
@@ -99,6 +99,21 @@ export interface VMInstance {
|
|||||||
tags?: string;
|
tags?: string;
|
||||||
template?: boolean;
|
template?: boolean;
|
||||||
description?: string;
|
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 */
|
/** Ghost row marker — present only for in-progress deploy tasks */
|
||||||
_deployTaskId?: number;
|
_deployTaskId?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user