feat: SSH keys management, backups fixes, instances/nodes/templates improvements, dev mode support
Some checks failed
Backend Tests / pytest (Python 3.12) (push) Has been cancelled

This commit is contained in:
markmorado
2026-05-07 00:30:21 +02:00
parent a92cb0df33
commit 323d1e57fa
40 changed files with 4112 additions and 288 deletions

0
.dev-mode Normal file
View File

View File

@@ -197,11 +197,11 @@ async def get_log_categories():
"""Get available log categories"""
return {
"categories": [
{"value": "auth", "label": "🔐 Авторизация"},
{"value": "proxmox", "label": "🖥️ Proxmox"},
{"value": "ipam", "label": "🌐 IPAM"},
{"value": "system", "label": "⚙️ Система"},
{"value": "api", "label": "🔌 API"},
{"value": "docker", "label": "🐳 Docker"}
{"value": "auth", "label": "Авторизация"},
{"value": "proxmox", "label": "Proxmox"},
{"value": "ipam", "label": "IPAM"},
{"value": "system", "label": "Система"},
{"value": "api", "label": "API"},
{"value": "docker", "label": "Docker"}
]
}

View File

@@ -137,20 +137,59 @@ def delete_storage(
@router.get("/api/backups/list/{server_id}")
def list_backups(
server_id: int,
node: str,
storage: str,
node: Optional[str] = None,
vmid: Optional[int] = None,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("backup:view")),
):
"""List backup files stored in a given storage on a node"""
"""List backup files stored in a given storage.
If ``node`` is omitted (or empty), backup files are aggregated from all
cluster nodes that have the storage available. Duplicate ``volid`` entries
(which appear on shared storages) are de-duplicated.
"""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Server not found")
try:
client = _get_proxmox_client(server)
backups = client.list_backups(node, storage, vmid)
nodes_to_query: list[str] = []
if node:
nodes_to_query = [node]
else:
try:
cluster_nodes = client.get_nodes() or []
nodes_to_query = [
n.get("node") for n in cluster_nodes
if n.get("node") and (n.get("status") in (None, "online"))
]
except Exception as e:
logger.warning(f"Failed to enumerate cluster nodes for backup listing: {e}")
seen: set[str] = set()
backups: list[dict] = []
for n in nodes_to_query:
try:
items = client.list_backups(n, storage, vmid) or []
except Exception as e:
logger.warning(f"Skipping node {n} when listing backups: {e}")
continue
for item in items:
volid = item.get("volid")
if volid and volid in seen:
continue
if volid:
seen.add(volid)
# annotate with the node where it was discovered (helpful for
# restore / delete actions on non-shared storages)
item.setdefault("node", n)
backups.append(item)
return JSONResponse(content={"backups": backups})
except HTTPException:
raise
except Exception as e:
logger.error(f"Error listing backups: {e}")
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -53,6 +53,8 @@ class LXCDeployRequest(BaseModel):
ipam_pool_id: Optional[int] = None
password: Optional[str] = None
ssh_keys: Optional[str] = None
ssh_key_ids: Optional[List[int]] = None
owner_id: Optional[int] = None
nameserver: Optional[str] = None
searchdomain: Optional[str] = None
unprivileged: bool = True
@@ -105,7 +107,8 @@ def _update_task(task_id: int, status: str, step: str, progress: int,
db.close()
def _do_lxc_deploy(task_id: int, req: LXCDeployRequest, user_id: int, username: str):
def _do_lxc_deploy(task_id: int, req: LXCDeployRequest, user_id: int, username: str,
owner_id: Optional[int] = None):
"""Blocking LXC deploy — runs in thread pool."""
db = SessionLocal()
server = None
@@ -286,7 +289,7 @@ def _do_lxc_deploy(task_id: int, req: LXCDeployRequest, user_id: int, username:
template_id=None,
template_name=req.ostemplate,
description=f"Created from CT template {req.ostemplate}",
owner_id=user_id,
owner_id=owner_id or user_id,
)
LoggingService.log_proxmox_action(
@@ -434,6 +437,23 @@ async def deploy_lxc_from_template(
if not req.name or len(req.name) < 1:
raise HTTPException(status_code=400, detail="Укажите имя контейнера")
# Resolve owner / SSH keys
is_admin = current_user.has_permission("users.manage") or current_user.is_admin
instance_owner_id = current_user.id
if req.owner_id and req.owner_id != current_user.id:
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 == req.owner_id).first()
if not owner:
raise HTTPException(status_code=404, detail="Owner user not found")
instance_owner_id = owner.id
if req.ssh_key_ids:
from ..ssh_keys import resolve_ssh_keys_for_deploy
resolved = resolve_ssh_keys_for_deploy(db, current_user, req.ssh_key_ids, instance_owner_id)
existing = (req.ssh_keys or "").strip()
req.ssh_keys = (existing + "\n" + resolved).strip() if existing else resolved
# Create task record
task = DeployTask(
status="pending",
@@ -456,6 +476,7 @@ async def deploy_lxc_from_template(
req,
current_user.id,
current_user.username,
instance_owner_id,
)
return LXCDeployResponse(task_id=task_id, status="pending", name=req.name)

View File

@@ -523,6 +523,37 @@ def delete_proxmox_server(
return None
@router.post("/api/servers/test", status_code=status.HTTP_200_OK)
def test_proxmox_credentials(
payload: ProxmoxServerCreate,
current_user: User = Depends(PermissionChecker("proxmox.view"))
):
"""Проверить произвольные учётные данные Proxmox без сохранения в БД."""
try:
if payload.use_password:
client = ProxmoxClient(
host=payload.ip_address,
user=payload.api_user,
password=payload.password,
verify_ssl=payload.verify_ssl,
)
else:
client = ProxmoxClient(
host=payload.ip_address,
user=payload.api_user,
token_name=payload.api_token_name,
token_value=payload.api_token_value,
verify_ssl=payload.verify_ssl,
)
if client.is_connected():
return {"success": True, "status": "success", "message": "Connection successful"}
return {"success": False, "status": "error", "message": "Failed to connect to Proxmox server"}
except Exception as e:
logger.error(f"Error testing Proxmox credentials for {payload.ip_address}: {e}")
return {"success": False, "status": "error", "message": str(e)}
@router.post("/api/servers/{server_id}/test", status_code=status.HTTP_200_OK)
def test_proxmox_connection(
server_id: int,
@@ -533,25 +564,25 @@ def test_proxmox_connection(
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox server not found")
try:
# Determine auth method
client = _get_proxmox_client(server)
if client.is_connected():
server.update_status(True)
db.commit()
return {"status": "success", "message": "Connection successful"}
return {"success": True, "status": "success", "message": "Connection successful"}
else:
server.update_status(False, "Failed to connect")
db.commit()
raise HTTPException(status_code=503, detail="Failed to connect to Proxmox server")
return {"success": False, "status": "error", "message": "Failed to connect to Proxmox server"}
except Exception as e:
server.update_status(False, str(e))
db.commit()
logger.error(f"Error testing Proxmox connection to {server.name}: {e}")
raise HTTPException(status_code=500, detail=str(e))
return {"success": False, "status": "error", "message": str(e)}
# ==================== Proxmox Resources ====================

View File

@@ -152,7 +152,24 @@ def get_all_virtual_machines(
query = query.filter(VMInstance.owner_id == current_user.id)
cached_vms = query.order_by(VMInstance.name).all()
# ── Live metrics from Proxmox cluster/resources (CPU, RAM, disk, uptime) ──
# One HTTP call per server returns metrics for all VMs/LXC in that cluster.
# Map: (server_id_in_db, vmid) -> live data dict
live_metrics: dict[tuple[int, int], dict] = {}
for server in servers:
try:
client = _get_proxmox_client(server)
if not client.is_connected():
continue
for res in client.get_cluster_resources(type_='vm'):
vmid = res.get('vmid')
if vmid is None:
continue
live_metrics[(server.id, vmid)] = res
except Exception as e:
logger.debug(f"Could not fetch live metrics from {server.name}: {e}")
result = []
# Detect cluster servers (servers with hostnames pve1, pve2, pve3 pattern)
@@ -195,7 +212,10 @@ def get_all_virtual_machines(
os_template = vm.template_name or vm.os_type or ("QEMU/KVM" if vm.vm_type == "qemu" else "Linux")
if not vm.template_name and vm.vm_type == "lxc" and os_template:
os_template = os_template.capitalize()
# Live metrics (cpu/mem/disk/uptime/net) from cluster/resources
live = live_metrics.get((vm.server_id, vm.vmid)) or {}
result.append({
"server_id": vm.server_id,
"server_name": server_name,
@@ -204,18 +224,28 @@ def get_all_virtual_machines(
"name": vm.name,
"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": vm.status or "unknown",
"status": live.get('status') or vm.status or "unknown",
"node": vm.node,
"cores": vm.cores or 0,
"memory": vm.memory or 0,
"disk": vm.disk_size or 0,
"disk": live.get('disk') or 0,
"ip": ip_address,
"ip_address": ip_address,
"ip_hostname": ip_network_name,
"os": os_template,
"os_template": os_template,
"owner": owner,
"owner_hostname": "",
"storage": "Storage1 (DIR)"
"storage": "Storage1 (DIR)",
"tags": vm.tags or "",
# Live runtime metrics
"cpu": live.get('cpu'),
"mem": live.get('mem'),
"maxmem": live.get('maxmem') or vm.memory or 0,
"maxdisk": live.get('maxdisk') or vm.disk_size or 0,
"uptime": live.get('uptime'),
"netin": live.get('netin'),
"netout": live.get('netout'),
})
return JSONResponse(content=result)

453
backend/app/api/ssh_keys.py Normal file
View File

@@ -0,0 +1,453 @@
"""
SSH Keys management API.
Each user owns a personal library of SSH keys (public + optionally encrypted
private key). Keys can be referenced by id when deploying a VM/LXC instance.
* Regular users can manage only their own keys and may pick only their own
keys when deploying.
* Admins (`users.manage` permission) can manage SSH keys of any user and may
pick any user's key (typically their own + the new instance owner's) when
deploying through the admin flow.
"""
from __future__ import annotations
import base64
import hashlib
from datetime import datetime
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Request
from loguru import logger
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from ..auth import get_client_ip, get_current_user, PermissionChecker
from ..db import get_db
from ..logging_service import LoggingService
from ..models import User, UserSSHKey
router = APIRouter()
# ==================== Schemas ====================
class SSHKeyBase(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
public_key: str = Field(..., min_length=20, max_length=10000)
private_key: Optional[str] = Field(None, max_length=20000)
comment: Optional[str] = Field(None, max_length=255)
class SSHKeyCreate(SSHKeyBase):
pass
class SSHKeyUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=100)
public_key: Optional[str] = Field(None, min_length=20, max_length=10000)
private_key: Optional[str] = Field(None, max_length=20000)
comment: Optional[str] = Field(None, max_length=255)
class SSHKeyResponse(BaseModel):
id: int
user_id: int
name: str
public_key: str
fingerprint: Optional[str] = None
comment: Optional[str] = None
has_private_key: bool = False
created_at: datetime
class Config:
from_attributes = True
# ==================== Helpers ====================
def _normalize_public_key(value: str) -> str:
return value.strip()
def _compute_fingerprint(public_key: str) -> Optional[str]:
"""SHA256 fingerprint in OpenSSH format (SHA256:base64...)."""
try:
parts = public_key.strip().split()
if len(parts) < 2:
return None
key_b64 = parts[1]
raw = base64.b64decode(key_b64)
digest = hashlib.sha256(raw).digest()
b64 = base64.b64encode(digest).decode().rstrip("=")
return f"SHA256:{b64}"
except Exception:
return None
def _to_response(key: UserSSHKey) -> dict:
return {
"id": key.id,
"user_id": key.user_id,
"name": key.name,
"public_key": key.public_key,
"fingerprint": key.fingerprint,
"comment": key.comment,
"has_private_key": bool(key.private_key),
"created_at": key.created_at,
}
def _ensure_target_user(db: Session, user_id: int) -> User:
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
return user
# ==================== Self-service endpoints ====================
@router.get("/api/ssh-keys/my", response_model=List[SSHKeyResponse])
async def list_my_ssh_keys(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""List current user's SSH keys."""
keys = (
db.query(UserSSHKey)
.filter(UserSSHKey.user_id == current_user.id)
.order_by(UserSSHKey.created_at.desc())
.all()
)
return [_to_response(k) for k in keys]
@router.post("/api/ssh-keys/my", response_model=SSHKeyResponse, status_code=201)
async def create_my_ssh_key(
data: SSHKeyCreate,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Add an SSH key to the current user's library."""
return _create_key_for_user(db, request, current_user, current_user.id, data)
@router.put("/api/ssh-keys/my/{key_id}", response_model=SSHKeyResponse)
async def update_my_ssh_key(
key_id: int,
data: SSHKeyUpdate,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
return _update_key(db, request, current_user, current_user.id, key_id, data)
@router.delete("/api/ssh-keys/my/{key_id}")
async def delete_my_ssh_key(
key_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
return _delete_key(db, request, current_user, current_user.id, key_id)
@router.get("/api/ssh-keys/my/{key_id}/private")
async def download_my_private_key(
key_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
return _read_private_key(db, current_user.id, key_id)
# ==================== Admin endpoints (per user) ====================
@router.get("/api/ssh-keys/users/{user_id}", response_model=List[SSHKeyResponse])
async def list_user_ssh_keys(
user_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("users.manage")),
):
"""Admin: list a user's SSH keys."""
_ensure_target_user(db, user_id)
keys = (
db.query(UserSSHKey)
.filter(UserSSHKey.user_id == user_id)
.order_by(UserSSHKey.created_at.desc())
.all()
)
return [_to_response(k) for k in keys]
@router.post(
"/api/ssh-keys/users/{user_id}",
response_model=SSHKeyResponse,
status_code=201,
)
async def admin_create_user_ssh_key(
user_id: int,
data: SSHKeyCreate,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("users.manage")),
):
target = _ensure_target_user(db, user_id)
return _create_key_for_user(db, request, current_user, target.id, data)
@router.put(
"/api/ssh-keys/users/{user_id}/{key_id}",
response_model=SSHKeyResponse,
)
async def admin_update_user_ssh_key(
user_id: int,
key_id: int,
data: SSHKeyUpdate,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("users.manage")),
):
_ensure_target_user(db, user_id)
return _update_key(db, request, current_user, user_id, key_id, data)
@router.delete("/api/ssh-keys/users/{user_id}/{key_id}")
async def admin_delete_user_ssh_key(
user_id: int,
key_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("users.manage")),
):
_ensure_target_user(db, user_id)
return _delete_key(db, request, current_user, user_id, key_id)
@router.get("/api/ssh-keys/users/{user_id}/{key_id}/private")
async def admin_download_user_private_key(
user_id: int,
key_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("users.manage")),
):
_ensure_target_user(db, user_id)
return _read_private_key(db, user_id, key_id)
# ==================== Internal helpers ====================
def _create_key_for_user(
db: Session,
request: Request,
current_user: User,
target_user_id: int,
data: SSHKeyCreate,
) -> dict:
public_key = _normalize_public_key(data.public_key)
if not public_key.startswith(("ssh-", "ecdsa-", "sk-")):
raise HTTPException(
status_code=400,
detail="Invalid SSH public key format",
)
fingerprint = _compute_fingerprint(public_key)
private_key = data.private_key.strip() if data.private_key else None
if private_key and "PRIVATE KEY" not in private_key:
raise HTTPException(
status_code=400,
detail="Invalid SSH private key format (PEM expected)",
)
key = UserSSHKey(
user_id=target_user_id,
name=data.name.strip(),
public_key=public_key,
private_key=private_key,
fingerprint=fingerprint,
comment=(data.comment or None),
)
db.add(key)
db.commit()
db.refresh(key)
LoggingService.log(
db=db,
level=LoggingService.INFO,
category=LoggingService.AUTH,
action="ssh_key_added",
message=f"SSH key '{key.name}' added to user_id={target_user_id} by {current_user.username}",
username=current_user.username,
user_id=current_user.id,
ip_address=get_client_ip(request),
resource_type="ssh_key",
resource_id=str(key.id),
resource_name=key.name,
)
return _to_response(key)
def _update_key(
db: Session,
request: Request,
current_user: User,
target_user_id: int,
key_id: int,
data: SSHKeyUpdate,
) -> dict:
key = (
db.query(UserSSHKey)
.filter(UserSSHKey.id == key_id, UserSSHKey.user_id == target_user_id)
.first()
)
if not key:
raise HTTPException(status_code=404, detail="SSH key not found")
if data.name is not None:
key.name = data.name.strip()
if data.public_key is not None:
public_key = _normalize_public_key(data.public_key)
if not public_key.startswith(("ssh-", "ecdsa-", "sk-")):
raise HTTPException(status_code=400, detail="Invalid SSH public key format")
key.public_key = public_key
key.fingerprint = _compute_fingerprint(public_key)
if data.private_key is not None:
pk = data.private_key.strip()
if pk:
if "PRIVATE KEY" not in pk:
raise HTTPException(
status_code=400,
detail="Invalid SSH private key format (PEM expected)",
)
key.private_key = pk
else:
key.private_key = None
if data.comment is not None:
key.comment = data.comment or None
db.commit()
db.refresh(key)
LoggingService.log(
db=db,
level=LoggingService.INFO,
category=LoggingService.AUTH,
action="ssh_key_updated",
message=f"SSH key #{key.id} ('{key.name}') updated by {current_user.username}",
username=current_user.username,
user_id=current_user.id,
ip_address=get_client_ip(request),
resource_type="ssh_key",
resource_id=str(key.id),
resource_name=key.name,
)
return _to_response(key)
def _delete_key(
db: Session,
request: Request,
current_user: User,
target_user_id: int,
key_id: int,
) -> dict:
key = (
db.query(UserSSHKey)
.filter(UserSSHKey.id == key_id, UserSSHKey.user_id == target_user_id)
.first()
)
if not key:
raise HTTPException(status_code=404, detail="SSH key not found")
name = key.name
db.delete(key)
db.commit()
LoggingService.log(
db=db,
level=LoggingService.INFO,
category=LoggingService.AUTH,
action="ssh_key_deleted",
message=f"SSH key '{name}' (id={key_id}) deleted by {current_user.username}",
username=current_user.username,
user_id=current_user.id,
ip_address=get_client_ip(request),
resource_type="ssh_key",
resource_id=str(key_id),
resource_name=name,
)
return {"success": True, "message": "SSH key deleted"}
def _read_private_key(db: Session, target_user_id: int, key_id: int) -> dict:
key = (
db.query(UserSSHKey)
.filter(UserSSHKey.id == key_id, UserSSHKey.user_id == target_user_id)
.first()
)
if not key:
raise HTTPException(status_code=404, detail="SSH key not found")
if not key.private_key:
raise HTTPException(status_code=404, detail="No private key stored for this entry")
return {
"id": key.id,
"name": key.name,
"private_key": key.private_key,
}
# ==================== Internal API (used by deploy flow) ====================
def resolve_ssh_keys_for_deploy(
db: Session,
requester: User,
key_ids: List[int],
instance_owner_id: Optional[int] = None,
) -> str:
"""Resolve a list of SSH key ids into a newline-joined block of public keys.
Authorization rules:
* Regular users may use only their own keys.
* Admins (`users.manage` permission) may use their own keys and the keys
of the designated `instance_owner_id` user.
"""
if not key_ids:
return ""
allowed_owners = {requester.id}
is_admin = requester.has_permission("users.manage") or requester.is_admin
if is_admin and instance_owner_id:
allowed_owners.add(instance_owner_id)
keys = (
db.query(UserSSHKey)
.filter(UserSSHKey.id.in_(key_ids))
.all()
)
if len(keys) != len(set(key_ids)):
raise HTTPException(status_code=404, detail="One or more SSH keys not found")
for k in keys:
if k.user_id not in allowed_owners:
logger.warning(
f"User {requester.username} tried to use ssh key #{k.id} "
f"belonging to user_id={k.user_id}"
)
raise HTTPException(
status_code=403,
detail="You are not allowed to use one of the selected SSH keys",
)
return "\n".join(k.public_key.strip() for k in keys if k.public_key).strip()

View File

@@ -166,6 +166,7 @@ def list_os_templates(
current_user: User = Depends(PermissionChecker("templates.view")),
group_id: int = None,
server_id: int = None,
vm_type: Optional[str] = None,
active_only: bool = True
):
"""Получить список всех OS шаблонов с информацией о группе"""
@@ -175,6 +176,8 @@ def list_os_templates(
query = query.filter(OSTemplate.group_id == group_id)
if server_id:
query = query.filter(OSTemplate.server_id == server_id)
if vm_type in ('qemu', 'lxc'):
query = query.filter(OSTemplate.vm_type == vm_type)
if active_only:
query = query.filter(OSTemplate.is_active == True)
@@ -358,20 +361,26 @@ def discover_proxmox_templates(
proxmox_templates = client.get_templates(node=node_filter)
# Get already added templates для ЭТОГО сервера
existing_vmids = set(
t.vmid for t in db.query(OSTemplate).filter(OSTemplate.server_id == server_id).all()
)
existing = db.query(OSTemplate).filter(OSTemplate.server_id == server_id).all()
existing_vmids = set(t.vmid for t in existing if t.vmid is not None)
existing_volids = set(t.volid for t in existing if t.volid)
result = []
for tpl in proxmox_templates:
volid = tpl.get('volid')
vmid = tpl.get('vmid')
already = (volid is not None and volid in existing_volids) or (vmid is not None and vmid in existing_vmids)
result.append({
'vmid': tpl.get('vmid'),
'name': tpl.get('name', f"Template-{tpl.get('vmid')}"),
'vmid': vmid,
'volid': volid,
'name': tpl.get('name', f"Template-{vmid or volid}"),
'node': tpl.get('node'),
'status': tpl.get('status'),
'vm_type': tpl.get('type', 'qemu'),
'is_file_template': tpl.get('is_file_template', False),
'maxmem': tpl.get('maxmem', 0),
'maxdisk': tpl.get('maxdisk', 0),
'already_added': tpl.get('vmid') in existing_vmids
'already_added': already,
})
return JSONResponse(content={
@@ -461,26 +470,31 @@ def _do_auto_import(server_id: int, username: str):
proxmox_templates = client.get_templates(node=node_filter)
existing_vmids = set(
t.vmid for t in db.query(OSTemplate).filter(OSTemplate.server_id == server_id).all()
)
existing = db.query(OSTemplate).filter(OSTemplate.server_id == server_id).all()
existing_vmids = set(t.vmid for t in existing if t.vmid is not None)
existing_volids = set(t.volid for t in existing if t.volid)
groups_cache = {g.name: g for g in db.query(OSTemplateGroup).all()}
templates_by_group = {}
for tpl in proxmox_templates:
volid = tpl.get('volid')
vmid = tpl.get('vmid')
if vmid in existing_vmids:
if volid and volid in existing_volids:
continue
template_name = tpl.get('name', f"Template-{vmid}")
if vmid is not None and vmid in existing_vmids:
continue
template_name = tpl.get('name', f"Template-{vmid or volid}")
group_info = detect_os_group(template_name)
group_name = group_info['name']
if group_name not in templates_by_group:
templates_by_group[group_name] = {'info': group_info, 'templates': []}
templates_by_group[group_name]['templates'].append({
'vmid': vmid,
'volid': volid,
'name': template_name,
'node': tpl.get('node'),
'vm_type': tpl.get('type', 'qemu'),
'maxmem': tpl.get('maxmem', 0),
'maxdisk': tpl.get('maxdisk', 0),
})
@@ -516,7 +530,9 @@ def _do_auto_import(server_id: int, username: str):
group_id=group.id,
server_id=server_id,
name=tpl['name'],
vmid=tpl['vmid'],
vmid=tpl.get('vmid'),
volid=tpl.get('volid'),
vm_type=tpl.get('vm_type', 'qemu'),
node=tpl.get('node'),
source_node=tpl.get('node'),
default_cores=2,
@@ -529,7 +545,7 @@ def _do_auto_import(server_id: int, username: str):
sort_order=global_sort_order
)
db.add(template)
imported.append({'vmid': tpl['vmid'], 'name': tpl['name'], 'group': group_name})
imported.append({'vmid': tpl.get('vmid'), 'volid': tpl.get('volid'), 'name': tpl['name'], 'group': group_name})
global_sort_order += 1
db.commit()
@@ -581,25 +597,30 @@ def auto_import_templates(
proxmox_templates = client.get_templates(node=node_filter)
existing_vmids = set(
t.vmid for t in db.query(OSTemplate).filter(OSTemplate.server_id == server_id).all()
)
existing = db.query(OSTemplate).filter(OSTemplate.server_id == server_id).all()
existing_vmids = set(t.vmid for t in existing if t.vmid is not None)
existing_volids = set(t.volid for t in existing if t.volid)
groups_cache = {g.name: g for g in db.query(OSTemplateGroup).all()}
templates_by_group = {}
for tpl in proxmox_templates:
volid = tpl.get('volid')
vmid = tpl.get('vmid')
if vmid in existing_vmids:
if volid and volid in existing_volids:
continue
template_name = tpl.get('name', f"Template-{vmid}")
if vmid is not None and vmid in existing_vmids:
continue
template_name = tpl.get('name', f"Template-{vmid or volid}")
group_info = detect_os_group(template_name)
group_name = group_info['name']
if group_name not in templates_by_group:
templates_by_group[group_name] = {'info': group_info, 'templates': []}
templates_by_group[group_name]['templates'].append({
'vmid': vmid,
'volid': volid,
'name': template_name,
'node': tpl.get('node'),
'vm_type': tpl.get('type', 'qemu'),
'maxmem': tpl.get('maxmem', 0),
'maxdisk': tpl.get('maxdisk', 0),
})
@@ -635,7 +656,9 @@ def auto_import_templates(
group_id=group.id,
server_id=server_id,
name=tpl['name'],
vmid=tpl['vmid'],
vmid=tpl.get('vmid'),
volid=tpl.get('volid'),
vm_type=tpl.get('vm_type', 'qemu'),
node=tpl.get('node'),
source_node=tpl.get('node'),
default_cores=2,
@@ -649,7 +672,8 @@ def auto_import_templates(
)
db.add(template)
imported.append({
'vmid': tpl['vmid'],
'vmid': tpl.get('vmid'),
'volid': tpl.get('volid'),
'name': tpl['name'],
'group': group_name,
'sort_order': global_sort_order
@@ -706,7 +730,8 @@ def _update_deploy_task(task_id: int, status: str, step: str, progress: int,
def _do_deploy_sync(task_id: int, deploy_data: VMDeployRequest,
user_id: int, username: str, user_ssh_key: str):
user_id: int, username: str, user_ssh_key: str,
owner_id: int = None):
"""Blocking deploy logic — runs in a thread pool."""
db = SessionLocal()
server = None
@@ -920,7 +945,7 @@ def _do_deploy_sync(task_id: int, deploy_data: VMDeployRequest,
template_id=template.id,
template_name=template.name,
description=f"Deployed from template {template.name}",
owner_id=user_id
owner_id=owner_id or user_id
)
LoggingService.log_proxmox_action(
@@ -999,6 +1024,25 @@ async def deploy_vm_from_template(
if disk < template.min_disk:
raise HTTPException(status_code=400, detail=f"Минимум {template.min_disk} GB диска")
# Resolve owner / SSH keys (admin may target another user)
is_admin = current_user.has_permission("users.manage") or current_user.is_admin
instance_owner_id = current_user.id
if deploy_data.owner_id and deploy_data.owner_id != current_user.id:
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 == deploy_data.owner_id).first()
if not owner:
raise HTTPException(status_code=404, detail="Owner user not found")
instance_owner_id = owner.id
if deploy_data.ssh_key_ids:
from .ssh_keys import resolve_ssh_keys_for_deploy
resolved = resolve_ssh_keys_for_deploy(
db, current_user, deploy_data.ssh_key_ids, instance_owner_id
)
existing = (deploy_data.ssh_keys or "").strip()
deploy_data.ssh_keys = (existing + "\n" + resolved).strip() if existing else resolved
# Create deploy task record
task = DeployTask(
status='pending',
@@ -1023,7 +1067,8 @@ async def deploy_vm_from_template(
deploy_data,
current_user.id,
current_user.username,
current_user.ssh_public_key or ''
current_user.ssh_public_key or '',
instance_owner_id,
)
logger.info(f"[DEPLOY] Task #{task_id} queued: {deploy_data.name} by {current_user.username}")

View File

@@ -23,6 +23,7 @@ from .api import settings as settings_router
from .api import notifications as notifications_router
from .api import users as users_router
from .api import workspaces as workspaces_router
from .api import ssh_keys as ssh_keys_router
from .logging_middleware import RequestLoggingMiddleware
from .language_middleware import LanguageMiddleware
from .i18n import I18nService
@@ -408,6 +409,7 @@ def create_app() -> FastAPI:
app.include_router(notifications_router.router, tags=["notifications"])
app.include_router(users_router.router, prefix="/admin", tags=["users", "admin"])
app.include_router(workspaces_router.router, tags=["workspaces"])
app.include_router(ssh_keys_router.router, tags=["ssh-keys"])
return app

View File

@@ -205,6 +205,33 @@ class BlockedIP(Base):
return f"<BlockedIP(id={self.id}, ip={self.ip_address})>"
class UserSSHKey(Base):
"""SSH key (public + optional encrypted private) attached to a user account.
Used as a library that user can pick from when deploying VM/LXC instances.
Admins can manage keys for any user; regular users only their own.
"""
__tablename__ = "user_ssh_keys"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
name = Column(String(100), nullable=False)
public_key = Column(Text, nullable=False)
private_key = Column(EncryptedString(8000), nullable=True)
fingerprint = Column(String(100), nullable=True, index=True)
comment = Column(String(255), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
user = relationship("User", backref="ssh_keys_pool")
__table_args__ = (
Index('idx_user_ssh_keys_user', 'user_id'),
)
def __repr__(self):
return f"<UserSSHKey(id={self.id}, user_id={self.user_id}, name='{self.name}')>"
class SecuritySetting(Base):
"""Security settings"""
__tablename__ = "security_settings"
@@ -322,7 +349,9 @@ class OSTemplate(Base):
server_id = Column(Integer, nullable=False, index=True) # FK to ProxmoxServer
name = Column(String(100), nullable=False) # Display name, e.g., "Ubuntu 22.04 LTS"
vmid = Column(Integer, nullable=False) # Proxmox template VMID on source node
vmid = Column(Integer, nullable=True) # Proxmox template VMID (qemu/lxc-container template). NULL for vztmpl files.
vm_type = Column(String(10), nullable=False, default="qemu", server_default="qemu") # 'qemu' or 'lxc'
volid = Column(String(500), nullable=True) # vztmpl volume id, e.g. 'local:vztmpl/ubuntu-24.04...tar.zst'
node = Column(String(100), nullable=True) # Primary/source node (optional for cross-node templates)
# Cross-node replication support

View File

@@ -1,4 +1,5 @@
import logging
import shlex
from typing import Dict, List, Optional, Union
from contextlib import asynccontextmanager
from proxmoxer import ProxmoxAPI
@@ -238,7 +239,25 @@ class ProxmoxClient:
'vms': self.get_vms(node),
'containers': self.get_containers(node)
}
def get_cluster_resources(self, type_: Optional[str] = None) -> List[Dict]:
"""Получить живые метрики со всех VM/LXC одним запросом через /cluster/resources.
Args:
type_: 'vm' для VM/LXC, 'node' для нод, None для всех
Returns:
Список ресурсов с полями cpu, mem, maxmem, disk, maxdisk, uptime, netin, netout, status и т.д.
"""
if not self.proxmox:
return []
try:
if type_:
return self.proxmox.cluster.resources.get(type=type_) or []
return self.proxmox.cluster.resources.get() or []
except Exception as e:
logger.error(f"Ошибка получения cluster/resources с {self.host}: {e}")
return []
def get_vm_status(self, node: str, vmid: int) -> Optional[Dict]:
"""Получить статус конкретной VM"""
if not self.proxmox:
@@ -433,15 +452,15 @@ class ProxmoxClient:
if not self.proxmox:
return {'success': False, 'error': 'Proxmox connection not initialized'}
try:
# Используем `chpasswd` в формате "user:password"
# Запускаем bash -c "echo 'user:pass' | chpasswd"
cmd_str = f"echo '{username}:{password}' | chpasswd"
result = self.proxmox.nodes(node).lxc(vmid).status.current.get()
if result.get('status') != 'running':
return {'success': False, 'error': 'Container is not running'}
res = self.proxmox.nodes(node).lxc(vmid).exec.post(
command=['bash', '-c', cmd_str]
)
# Используем printf + chpasswd; shlex.quote защищает от shell-injection
# Proxmox exec API ожидает отдельные поля: command + args[N]
entry = shlex.quote(f'{username}:{password}')
cmd_str = f"printf '%s\\n' {entry} | chpasswd"
data = {'command': 'bash', 'args[0]': '-c', 'args[1]': cmd_str}
res = self.proxmox.nodes(node).lxc(vmid).exec.post(**data)
return {'success': True, 'result': res}
except Exception as e:
logger.error(f"Не удалось сменить пароль в LXC {vmid}: {e}")
@@ -1156,6 +1175,56 @@ class ProxmoxClient:
templates.append(vm)
except Exception as e:
logger.error(f"Ошибка получения шаблонов с ноды {node_name}: {e}")
try:
lxc_cts = self.proxmox.nodes(node_name).lxc.get()
for ct in lxc_cts:
# LXC контейнер-шаблон (template=1)
if ct.get('template') in (1, '1', True):
ct['node'] = node_name
ct['type'] = 'lxc'
templates.append(ct)
except Exception as e:
logger.error(f"Ошибка получения LXC-шаблонов с ноды {node_name}: {e}")
# vztmpl файлы из storage (стандартные LXC шаблоны)
try:
storages = self.proxmox.nodes(node_name).storage.get()
seen_volids = set()
for stor in storages:
stor_name = stor.get('storage')
stor_content = stor.get('content', '') or ''
if 'vztmpl' not in stor_content:
continue
try:
items = self.proxmox.nodes(node_name).storage(stor_name).content.get(content='vztmpl')
except Exception as e:
logger.warning(f"Не удалось прочитать vztmpl из {stor_name} на {node_name}: {e}")
continue
for item in items:
volid = item.get('volid')
if not volid or volid in seen_volids:
continue
seen_volids.add(volid)
# Имя файла без 'storage:vztmpl/'
fname = volid.split('/', 1)[-1] if '/' in volid else volid
# Убираем расширения .tar.zst / .tar.gz / .tar.xz
display_name = fname
for ext in ('.tar.zst', '.tar.gz', '.tar.xz', '.tar'):
if display_name.endswith(ext):
display_name = display_name[: -len(ext)]
break
templates.append({
'vmid': None,
'volid': volid,
'name': display_name,
'node': node_name,
'type': 'lxc',
'is_file_template': True,
'maxmem': 0,
'maxdisk': int(item.get('size') or 0),
'storage': stor_name,
})
except Exception as e:
logger.error(f"Ошибка получения vztmpl с ноды {node_name}: {e}")
except Exception as e:
logger.error(f"Ошибка получения списка шаблонов {self.host}: {e}")

View File

@@ -254,8 +254,10 @@ class OSTemplateBase(BaseModel):
group_id: int = Field(..., description="Template group ID")
server_id: int = Field(..., description="Proxmox server ID")
name: str = Field(..., min_length=1, max_length=100, description="Display name")
vmid: int = Field(..., ge=100, description="Proxmox template VMID")
node: str = Field(..., min_length=1, max_length=100, description="Proxmox node name")
vmid: Optional[int] = Field(None, ge=100, description="Proxmox template VMID (None for vztmpl file templates)")
vm_type: str = Field(default="qemu", description="Template type: qemu (KVM) or lxc")
volid: Optional[str] = Field(None, max_length=500, description="vztmpl volume id (for LXC file templates)")
node: Optional[str] = Field(None, max_length=100, description="Proxmox node name")
default_cores: int = Field(default=1, ge=1, le=128, description="Default CPU cores")
default_memory: int = Field(default=1024, ge=128, description="Default memory in MB")
default_disk: int = Field(default=10, ge=1, description="Default disk size in GB")
@@ -278,6 +280,8 @@ class OSTemplateUpdate(BaseModel):
server_id: Optional[int] = None
name: Optional[str] = Field(None, min_length=1, max_length=100)
vmid: Optional[int] = Field(None, ge=100)
vm_type: Optional[str] = None
volid: Optional[str] = Field(None, max_length=500)
node: Optional[str] = Field(None, min_length=1, max_length=100)
default_cores: Optional[int] = Field(None, ge=1, le=128)
default_memory: Optional[int] = Field(None, ge=128)
@@ -333,6 +337,8 @@ class VMDeployRequest(BaseModel):
cloud_init_user: Optional[str] = Field(None, max_length=50, description="Cloud-init username")
cloud_init_password: Optional[str] = Field(None, max_length=100, description="Cloud-init password")
ssh_keys: Optional[str] = Field(None, description="SSH public keys")
ssh_key_ids: Optional[List[int]] = Field(default=None, description="IDs of saved SSH keys to inject")
owner_id: Optional[int] = Field(default=None, description="Admin-only: assign instance to this user (also enables using their saved SSH keys)")
class VMDeployResponse(BaseModel):

View File

@@ -355,6 +355,76 @@ async def instance_metrics_loop(db_factory) -> None:
await asyncio.gather(*[_push_instance(ch) for ch in channels], return_exceptions=True)
# ──────────────────────────────────────────────────────────────────────────────
# Instances list metrics loop (push every 5 s)
# Pushes a slim list of live VM/LXC metrics from cluster/resources for the
# instances table page. One channel for all clients: 'instances_list_metrics'.
# ──────────────────────────────────────────────────────────────────────────────
def _fetch_all_instances_metrics(db_factory) -> list:
"""Pull live metrics for all VMs/LXC across all online servers via cluster/resources."""
from ..models import ProxmoxServer
from ..api.proxmox._helpers import _get_proxmox_client
db = db_factory()
try:
servers = db.query(ProxmoxServer).filter(ProxmoxServer.is_online == True).all()
finally:
db.close()
out: list = []
for server in servers:
try:
client = _get_proxmox_client(server)
# Skip is_connected() check (makes extra HTTP call); just try and catch
for res in client.get_cluster_resources(type_='vm'):
vmid = res.get('vmid')
if vmid is None:
continue
out.append({
"server_id": server.id,
"vmid": vmid,
"node": res.get('node'),
"status": res.get('status'),
"cpu": res.get('cpu'),
"mem": res.get('mem'),
"maxmem": res.get('maxmem'),
"disk": res.get('disk'),
"maxdisk": res.get('maxdisk'),
"uptime": res.get('uptime'),
"netin": res.get('netin'),
"netout": res.get('netout'),
})
except Exception as exc:
logger.debug(f"[metrics_broadcaster] instances list metrics error for server {server.id}: {exc}")
return out
async def instances_list_metrics_loop(db_factory) -> None:
"""Push live VM/LXC metrics list to 'instances_list_metrics' subscribers (~1 s)."""
wsman = _get_ws_manager()
channel = "instances_list_metrics"
while True:
await asyncio.sleep(1)
if not wsman.has_channel_subscribers(channel):
continue
try:
items = await asyncio.to_thread(_fetch_all_instances_metrics, db_factory)
if not items:
continue
payload = {
"type": "metrics_update",
"channel": channel,
"data": {"items": items},
}
_metrics_cache[channel] = payload
await wsman.broadcast_to_channel(channel, payload)
except asyncio.CancelledError:
break
except Exception as exc:
logger.warning(f"[metrics_broadcaster] instances list loop error: {exc}")
# ──────────────────────────────────────────────────────────────────────────────
# On-demand RRD request handler
# ──────────────────────────────────────────────────────────────────────────────
@@ -407,4 +477,5 @@ def start_metrics_broadcaster(db_factory) -> None:
asyncio.create_task(dashboard_metrics_loop(db_factory), name="metrics:dashboard")
asyncio.create_task(server_metrics_loop(db_factory), name="metrics:servers")
asyncio.create_task(instance_metrics_loop(db_factory), name="metrics:instances")
asyncio.create_task(instances_list_metrics_loop(db_factory), name="metrics:instances_list")
logger.info("[metrics_broadcaster] Metrics broadcasting tasks started")

View File

@@ -627,6 +627,7 @@ class MonitoringWorker:
seen_standalone = {} # server_id -> set of vmid
all_vms_data = [] # List of (server_id, vm_data, vm_type)
server_clients = {} # server_id (or cluster_id) -> ProxmoxClient
sync_time = utcnow()
# Collect all VMs from all servers with proper dedup
@@ -650,11 +651,15 @@ class MonitoringWorker:
seen_in_cluster[cluster_id] = set()
seen_set = seen_in_cluster[cluster_id]
use_server_id = cluster_id # Use cluster_id for all VMs in cluster
# Map cluster_id -> client for IP detection later
if cluster_id not in server_clients:
server_clients[cluster_id] = client
else:
if server.id not in seen_standalone:
seen_standalone[server.id] = set()
seen_set = seen_standalone[server.id]
use_server_id = server.id
server_clients[server.id] = client
server_vm_count = 0
server_ct_count = 0
@@ -690,6 +695,8 @@ class MonitoringWorker:
all_seen = set()
# Track status changes for WebSocket broadcast
status_changes = [] # list of (server_id, vmid, node, old_status, new_status)
# Track newly-created VMs for WebSocket broadcast
created_vms = [] # list of (server_id, vmid, node, name, vm_type)
# Upsert all VMs to database
for server_id, vm_data, vm_type in all_vms_data:
@@ -706,6 +713,37 @@ class MonitoringWorker:
ipam_by_name.get(vm_name.lower())
)
ip_address = ipam_alloc.ip_address if ipam_alloc else None
# If no IPAM IP, try to detect from Proxmox (LXC config or QEMU guest agent)
if not ip_address:
try:
px_client = server_clients.get(server_id)
if px_client and node_name:
if vm_type == 'lxc':
config = px_client.proxmox.nodes(node_name).lxc(vmid).config.get()
for i in range(4):
ipconfig = config.get(f'ipconfig{i}', '')
if 'ip=' in str(ipconfig):
ip_part = str(ipconfig).split('ip=')[1].split(',')[0]
if ip_part and ip_part != 'dhcp':
ip_address = ip_part.split('/')[0] if '/' in ip_part else ip_part
break
elif vm_type == 'qemu' and vm_data.get('status') == 'running':
result = px_client.proxmox.nodes(node_name).qemu(vmid).agent('network-get-interfaces').get()
if result and 'result' in result:
for iface in result['result']:
if iface.get('name') == 'lo':
continue
for ip_info in (iface.get('ip-addresses') or []):
if ip_info.get('ip-address-type') == 'ipv4':
addr = ip_info.get('ip-address', '')
if addr and not addr.startswith('127.'):
ip_address = addr
break
if ip_address:
break
except Exception:
pass
# Get OS type
if vm_type == 'qemu':
@@ -727,6 +765,10 @@ class MonitoringWorker:
# Detect status change for WS broadcast
if existing.status != new_status:
status_changes.append((server_id, vmid, node_name, existing.status, new_status))
# If this VM was previously soft-deleted but reappeared,
# treat it as a new VM for broadcast purposes
if existing.deleted_at is not None:
created_vms.append((server_id, vmid, node_name, vm_name, vm_type))
# Update existing entry
existing.name = vm_name
existing.node = node_name
@@ -737,8 +779,8 @@ class MonitoringWorker:
existing.os_type = os_type
existing.vm_type = vm_type
existing.is_template = bool(vm_data.get('template'))
# Only overwrite ip_address if IPAM has a value;
# otherwise preserve user-assigned IP from deploy/wizard
# Only overwrite ip_address if we have a value;
# IPAM and detected IPs take priority; otherwise preserve existing
if ip_address:
existing.ip_address = ip_address
existing.last_sync_at = sync_time
@@ -761,6 +803,7 @@ class MonitoringWorker:
last_sync_at=sync_time
)
db.add(new_vm)
created_vms.append((server_id, vmid, node_name, vm_name, vm_type))
except Exception as e:
logger.error(f"[VM SYNC] Error processing VM {vmid}: {e}")
@@ -788,10 +831,12 @@ class MonitoringWorker:
deleted_count = 0
released_ips = 0
deleted_broadcasts = [] # list of (server_id, vmid, node, name, vm_type)
for vm in active_vms:
if (vm.server_id, vm.vmid) not in all_seen:
vm.deleted_at = sync_time
deleted_count += 1
deleted_broadcasts.append((vm.server_id, vm.vmid, vm.node, vm.name, vm.vm_type))
logger.debug(f"[VM SYNC] Marked VM {vm.name} (server={vm.server_id}, vmid={vm.vmid}) as deleted")
# Release IPAM allocation for deleted VM
@@ -828,6 +873,40 @@ class MonitoringWorker:
}))
except Exception as _we:
logger.warning(f"[VM SYNC] Failed to broadcast status changes: {_we}")
# Broadcast deletions so connected clients can update their lists in real time
if deleted_broadcasts:
try:
from app.websocket_manager import ws_manager, run_async_safe
for srv_id, vmid, node_name, vm_name, vm_type in deleted_broadcasts:
logger.info(f"[VM SYNC] VM deleted: server={srv_id} vmid={vmid} name={vm_name}")
run_async_safe(ws_manager.broadcast({
"type": "vm_deleted",
"server_id": srv_id,
"vmid": vmid,
"node": node_name,
"name": vm_name,
"vm_type": vm_type,
}))
except Exception as _we:
logger.warning(f"[VM SYNC] Failed to broadcast deletions: {_we}")
# Broadcast newly-created VMs so clients can refetch and show them
if created_vms:
try:
from app.websocket_manager import ws_manager, run_async_safe
for srv_id, vmid, node_name, vm_name, vm_type in created_vms:
logger.info(f"[VM SYNC] VM created: server={srv_id} vmid={vmid} name={vm_name}")
run_async_safe(ws_manager.broadcast({
"type": "vm_created",
"server_id": srv_id,
"vmid": vmid,
"node": node_name,
"name": vm_name,
"vm_type": vm_type,
}))
except Exception as _we:
logger.warning(f"[VM SYNC] Failed to broadcast creations: {_we}")
except Exception as e:
logger.error(f"[VM SYNC] Critical error: {e}", exc_info=True)

View File

@@ -501,6 +501,20 @@ def migrate_cross_node_templates(conn):
# Add replicated_nodes column
if add_column_if_not_exists(conn, 'os_templates', 'replicated_nodes', "JSONB DEFAULT '{}'::jsonb"):
logger.info("✓ Added replicated_nodes column to os_templates")
# Add vm_type column (qemu/lxc) — existing rows default to qemu
if add_column_if_not_exists(conn, 'os_templates', 'vm_type', "VARCHAR(10) NOT NULL DEFAULT 'qemu'"):
logger.info("✓ Added vm_type column to os_templates")
# Add volid column for LXC vztmpl file-based templates
if add_column_if_not_exists(conn, 'os_templates', 'volid', "VARCHAR(500)"):
logger.info("✓ Added volid column to os_templates")
# vmid must be nullable for vztmpl-based templates
try:
conn.execute(text("ALTER TABLE os_templates ALTER COLUMN vmid DROP NOT NULL"))
except Exception:
pass
# Make node column nullable
try:
@@ -1312,6 +1326,34 @@ def migrate_deploy_tasks_kind(conn):
logger.info("✓ deploy_tasks.kind column added")
def migrate_user_ssh_keys_table(conn):
"""Migration 24: Create user_ssh_keys table for SSH key library per user."""
if table_exists(conn, 'user_ssh_keys'):
logger.info("✓ user_ssh_keys table already exists")
return
logger.info("Creating user_ssh_keys table...")
conn.execute(text("""
CREATE TABLE user_ssh_keys (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
public_key TEXT NOT NULL,
private_key VARCHAR(8000),
fingerprint VARCHAR(100),
comment VARCHAR(255),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
"""))
conn.execute(text("""
CREATE INDEX idx_user_ssh_keys_user ON user_ssh_keys(user_id)
"""))
conn.execute(text("""
CREATE INDEX idx_user_ssh_keys_fp ON user_ssh_keys(fingerprint)
"""))
logger.info("✓ user_ssh_keys table created")
def run_all_migrations(engine, db_session=None):
"""
Run all migrations in order.
@@ -1510,6 +1552,14 @@ def run_all_migrations(engine, db_session=None):
logger.warning(f"Deploy tasks kind migration: {e}")
conn.rollback()
# Migration 24: user_ssh_keys table
try:
migrate_user_ssh_keys_table(conn)
conn.commit()
except Exception as e:
logger.warning(f"User SSH keys table migration: {e}")
conn.rollback()
logger.info("=" * 50)
logger.info("✓ All migrations completed successfully")
logger.info("=" * 50)

26
compose.dev.yml Normal file
View File

@@ -0,0 +1,26 @@
# Режим разработки фронтенда с Vite HMR (горячая перезагрузка)
# Использование: pve dev
# или вручную: docker compose -f compose.yml -f compose.dev.yml up -d frontend
#
# Фронтенд запускается через Vite dev server (порт 80 внутри контейнера),
# маппинг 3000:80 из compose.yml остаётся в силе — адрес не меняется.
# Изменения в src/ применяются в браузере мгновенно без pereборки.
services:
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
target: dev
image: pvemanager-frontend-dev:latest
volumes:
- ./frontend/src:/app/src
- ./frontend/public:/app/public
- ./frontend/index.html:/app/index.html
environment:
- BACKEND_URL=http://app:8000
deploy:
resources:
limits:
cpus: '1'
memory: 512M

View File

@@ -1,13 +1,19 @@
# syntax=docker/dockerfile:1.7
FROM node:22-alpine AS build
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
# BuildKit-кеш npm — повторные сборки переиспользуют скачанные пакеты
RUN --mount=type=cache,target=/root/.npm \
npm ci --prefer-offline --no-audit --no-fund
# ── Dev stage: Vite HMR (используется compose.dev.yml) ───────────────────────
FROM deps AS dev
COPY . .
EXPOSE 80
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "80"]
# ── Production build ──────────────────────────────────────────────────────────
FROM deps AS build
COPY . .
RUN npm run build

View File

@@ -53,7 +53,7 @@ function DialogContent({
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm 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",
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none 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}

View File

@@ -6,7 +6,53 @@ import { Select as SelectPrimitive } from "@base-ui/react/select"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
const Select = SelectPrimitive.Root
// Recursively walks through children and collects { value, label } pairs from
// every <SelectItem> found, so that <Select.Value> can render the item's label
// (e.g. node name) instead of the raw value (e.g. numeric id).
function collectSelectItems(
node: React.ReactNode,
acc: Array<{ value: unknown; label: React.ReactNode }>,
seen: Set<unknown>,
): void {
React.Children.forEach(node, (child) => {
if (!React.isValidElement(child)) return
const props = child.props as { value?: unknown; children?: React.ReactNode }
// Identify SelectItem either by component reference or by data-slot marker.
const isItem =
child.type === SelectItem ||
(typeof child.type !== "string" &&
(child.type as { displayName?: string })?.displayName === "SelectItem")
if (isItem && props.value !== undefined && !seen.has(props.value)) {
seen.add(props.value)
acc.push({ value: props.value, label: props.children })
return
}
if (props.children) {
collectSelectItems(props.children, acc, seen)
}
})
}
function Select<Value, Multiple extends boolean | undefined = false>(
props: SelectPrimitive.Root.Props<Value, Multiple>,
) {
const { children, items, ...rest } = props
const autoItems = React.useMemo(() => {
if (items) return items
const acc: Array<{ value: unknown; label: React.ReactNode }> = []
collectSelectItems(children, acc, new Set<unknown>())
return acc.length ? acc : undefined
}, [children, items])
return (
<SelectPrimitive.Root
items={autoItems}
{...(rest as SelectPrimitive.Root.Props<Value, Multiple>)}
>
{children}
</SelectPrimitive.Root>
)
}
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
return (

View File

@@ -1,36 +1,326 @@
import { useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Archive, Play, Calendar } from 'lucide-react';
import { Archive, Play, Calendar, RotateCcw, Trash2, Loader2, Plus, Pencil } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { useServers } from '@/hooks/use-nodes';
import { useBackupJobs, useBackupList, useBackupStorages, useToggleBackupJob, useRunBackupJob } from '@/hooks/use-backups';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogClose } from '@/components/ui/dialog';
import { useNodes, useServers } from '@/hooks/use-nodes';
import { useVirtualMachines } from '@/hooks/use-instances';
import {
useBackupJobs,
useBackupList,
useBackupStorages,
useToggleBackupJob,
useRunBackupJob,
useDeleteBackup,
useRestoreBackup,
useCreateBackupJob,
useUpdateBackupJob,
useDeleteBackupJob,
type BackupJobInput,
} from '@/hooks/use-backups';
import { formatBytes } from '@/lib/format';
import { toast } from 'sonner';
const ALL_NODES = '__all__';
type BackupItem = {
volid: string;
vmid: number;
size: number;
ctime: number;
format: string;
notes?: string;
node?: string;
};
type BackupJob = {
id: number;
server_id: number;
node: string;
vmids: (number | string)[];
storage: string;
mode: string;
compress: string;
notes?: string | null;
keep_last: number;
keep_daily: number;
keep_weekly: number;
keep_monthly: number;
cron_expression: string;
enabled: boolean;
};
const CRON_PRESETS: { label: string; value: string }[] = [
{ label: 'daily_2am', value: '0 2 * * *' },
{ label: 'daily_midnight', value: '0 0 * * *' },
{ label: 'every_6h', value: '0 */6 * * *' },
{ label: 'weekly_sunday', value: '0 3 * * 0' },
{ label: 'monthly_1st', value: '0 4 1 * *' },
];
const emptyJobForm = (serverId: number, node: string, storage: string): BackupJobInput => ({
server_id: serverId,
node,
vmids: [],
storage,
mode: 'snapshot',
compress: 'zstd',
notes: '',
keep_last: 3,
keep_daily: 7,
keep_weekly: 4,
keep_monthly: 6,
cron_expression: '0 2 * * *',
enabled: true,
});
// Detect VM type from a backup volid like
// local:backup/vzdump-qemu-100-2024_01_01-00_00_00.vma.zst
// local:backup/vzdump-lxc-101-...tar.zst
function detectVmType(volid: string): 'qemu' | 'lxc' {
const v = (volid || '').toLowerCase();
if (v.includes('vzdump-lxc-') || v.includes('-lxc-')) return 'lxc';
return 'qemu';
}
export default function BackupsPage() {
const { t } = useTranslation();
const [tab, setTab] = useState('files');
const [selectedServer, setSelectedServer] = useState<string>('');
const [selectedNode, setSelectedNode] = useState('');
const [selectedNode, setSelectedNode] = useState<string>(ALL_NODES);
const [selectedStorage, setSelectedStorage] = useState('');
const { data: servers = [] } = useServers();
const sid = selectedServer ? Number(selectedServer) : 0;
const { data: nodesData } = useNodes(sid);
const { data: storagesData } = useBackupStorages(sid);
const { data: backupsData } = useBackupList(sid, selectedNode, selectedStorage);
const nodeParam = selectedNode === ALL_NODES ? '' : selectedNode;
const { data: backupsData } = useBackupList(sid, nodeParam, selectedStorage);
const { data: jobsData } = useBackupJobs();
const toggleJob = useToggleBackupJob();
const runJob = useRunBackupJob();
const deleteBackup = useDeleteBackup();
const restoreBackup = useRestoreBackup();
const createJob = useCreateBackupJob();
const updateJob = useUpdateBackupJob();
const deleteJob = useDeleteBackupJob();
const storages = (storagesData?.storages || []) as { storage: string; type: string; content: string; used: number; total: number }[];
const backups = (backupsData?.backups || []) as { volid: string; vmid: number; size: number; ctime: number; format: string; notes?: string }[];
const jobs = (jobsData?.jobs || []) as { id: number; server_id: number; vmids: number[]; storage: string; mode: string; cron_expression: string; enabled: boolean; notes?: string }[];
// --- Job dialog state ---
const [jobDialogOpen, setJobDialogOpen] = useState(false);
const [editingJobId, setEditingJobId] = useState<number | null>(null);
const [jobForm, setJobForm] = useState<BackupJobInput>(() => emptyJobForm(0, '', ''));
// Selection state for the VMID picker. `selectAll` overrides the explicit Set.
const [selectedVmids, setSelectedVmids] = useState<Set<number>>(new Set());
const [selectAllVmids, setSelectAllVmids] = useState(false);
const [vmFilter, setVmFilter] = useState('');
const [deleteJobTarget, setDeleteJobTarget] = useState<BackupJob | null>(null);
// --- Restore dialog state ---
const [restoreTarget, setRestoreTarget] = useState<BackupItem | null>(null);
const [restoreNewVmid, setRestoreNewVmid] = useState('');
const [restoreStart, setRestoreStart] = useState(false);
const [restoreUnique, setRestoreUnique] = useState(true);
// --- Delete confirmation state ---
const [deleteTarget, setDeleteTarget] = useState<BackupItem | null>(null);
const nodes = useMemo(() => (nodesData?.nodes || []), [nodesData]);
const storages = useMemo(() => {
const list = (storagesData?.storages || []) as { storage: string; type: string; content: string; used: number; total: number }[];
return list.filter(s => (s.content || '').split(',').includes('backup'));
}, [storagesData]);
const backups = (backupsData?.backups || []) as BackupItem[];
const jobs = (jobsData?.jobs || []) as BackupJob[];
// Servers/storages for the job dialog (across all servers, not only the file-tab selection)
const jobServerId = jobForm.server_id;
const { data: jobNodesData } = useNodes(jobServerId || 0);
const { data: jobStoragesData } = useBackupStorages(jobServerId || 0);
const jobNodes = (jobNodesData?.nodes || []) as { node: string }[];
const jobStorages = useMemo(() => {
const list = (jobStoragesData?.storages || []) as { storage: string; type: string; content: string }[];
return list.filter(s => (s.content || '').split(',').includes('backup'));
}, [jobStoragesData]);
// VM/CT list for the picker (across all servers; filtered by server+node below)
const { data: allVms = [] } = useVirtualMachines();
const pickerVms = useMemo(() => {
if (!jobForm.server_id) return [];
let list = allVms.filter(v => v.server_id === jobForm.server_id && !v.template);
if (jobForm.node) list = list.filter(v => v.node === jobForm.node);
if (vmFilter.trim()) {
const q = vmFilter.trim().toLowerCase();
list = list.filter(v => String(v.vmid).includes(q) || (v.name || '').toLowerCase().includes(q));
}
return list.sort((a, b) => a.vmid - b.vmid);
}, [allVms, jobForm.server_id, jobForm.node, vmFilter]);
const serverNameById = useMemo(() => {
const map = new Map<number, string>();
for (const s of servers) map.set(s.id, s.name);
return map;
}, [servers]);
// Auto-select first server when list arrives
useEffect(() => {
if (!selectedServer && servers.length > 0) {
setSelectedServer(String(servers[0].id));
}
}, [servers, selectedServer]);
// Auto-select first backup-capable storage when none selected (or current is unavailable)
useEffect(() => {
if (!sid) return;
if (storages.length === 0) {
if (selectedStorage) setSelectedStorage('');
return;
}
if (!selectedStorage || !storages.some(s => s.storage === selectedStorage)) {
setSelectedStorage(storages[0].storage);
}
}, [storages, sid, selectedStorage]);
const openRestore = (b: BackupItem) => {
setRestoreTarget(b);
setRestoreNewVmid('');
setRestoreStart(false);
setRestoreUnique(true);
};
const submitRestore = () => {
if (!restoreTarget || !sid) return;
const node = restoreTarget.node || (selectedNode === ALL_NODES ? '' : selectedNode) || nodes[0]?.node;
if (!node) {
toast.error(t('backups.node_required'));
return;
}
const vmType = detectVmType(restoreTarget.volid);
const newVmidNum = restoreNewVmid ? Number(restoreNewVmid) : undefined;
if (restoreNewVmid && (!Number.isFinite(newVmidNum) || (newVmidNum as number) <= 0)) {
toast.error(t('backups.invalid_vmid'));
return;
}
restoreBackup.mutate(
{
server_id: sid,
node,
vmid: newVmidNum ?? restoreTarget.vmid,
archive: restoreTarget.volid,
storage: selectedStorage,
vm_type: vmType,
new_vmid: newVmidNum,
start: restoreStart,
...(vmType === 'qemu' ? { unique: restoreUnique } : {}),
},
{
onSuccess: () => {
toast.success(t('backups.restore_started'));
setRestoreTarget(null);
},
onError: (e: any) => toast.error(e?.message || t('backups.restore_failed')),
},
);
};
const openCreateJob = () => {
const initialServer = sid || (servers[0]?.id ?? 0);
setEditingJobId(null);
setJobForm(emptyJobForm(initialServer, '', ''));
setSelectedVmids(new Set());
setSelectAllVmids(false);
setVmFilter('');
setJobDialogOpen(true);
};
const openEditJob = (job: BackupJob) => {
setEditingJobId(job.id);
setJobForm({
server_id: job.server_id,
node: job.node,
vmids: job.vmids,
storage: job.storage,
mode: job.mode,
compress: job.compress,
notes: job.notes || '',
keep_last: job.keep_last,
keep_daily: job.keep_daily,
keep_weekly: job.keep_weekly,
keep_monthly: job.keep_monthly,
cron_expression: job.cron_expression,
enabled: job.enabled,
});
const isAll = (job.vmids || []).some(v => String(v).toLowerCase() === 'all');
setSelectAllVmids(isAll);
setSelectedVmids(
isAll
? new Set()
: new Set((job.vmids || []).map(v => Number(v)).filter(n => Number.isFinite(n))),
);
setVmFilter('');
setJobDialogOpen(true);
};
const submitJob = () => {
const vmids: (number | string)[] = selectAllVmids
? ['all']
: Array.from(selectedVmids).sort((a, b) => a - b);
if (!jobForm.server_id) { toast.error(t('backups.select_server')); return; }
if (!jobForm.node) { toast.error(t('backups.node_required')); return; }
if (!jobForm.storage) { toast.error(t('backups.select_storage')); return; }
if (vmids.length === 0) { toast.error(t('backups.vmids_required')); return; }
if (!jobForm.cron_expression?.trim()) { toast.error(t('backups.cron_required')); return; }
const payload: BackupJobInput = { ...jobForm, vmids };
if (editingJobId != null) {
updateJob.mutate(
{ id: editingJobId, data: payload },
{
onSuccess: () => { toast.success(t('backups.job_updated')); setJobDialogOpen(false); },
onError: (e: any) => toast.error(e?.message || t('backups.job_save_failed')),
},
);
} else {
createJob.mutate(payload, {
onSuccess: () => { toast.success(t('backups.job_created')); setJobDialogOpen(false); },
onError: (e: any) => toast.error(e?.message || t('backups.job_save_failed')),
});
}
};
const submitDeleteJob = () => {
if (!deleteJobTarget) return;
deleteJob.mutate(deleteJobTarget.id, {
onSuccess: () => { toast.success(t('backups.job_deleted')); setDeleteJobTarget(null); },
onError: (e: any) => toast.error(e?.message || t('backups.job_delete_failed')),
});
};
const submitDelete = () => {
if (!deleteTarget || !sid) return;
const node = deleteTarget.node || (selectedNode === ALL_NODES ? '' : selectedNode) || nodes[0]?.node;
if (!node) {
toast.error(t('backups.node_required'));
return;
}
deleteBackup.mutate(
{ server_id: sid, node, storage: selectedStorage, volid: deleteTarget.volid },
{
onSuccess: () => {
toast.success(t('backups.delete_success'));
setDeleteTarget(null);
},
onError: (e: any) => toast.error(e?.message || t('backups.delete_failed')),
},
);
};
return (
<div className="space-y-6">
@@ -45,15 +335,21 @@ export default function BackupsPage() {
<TabsContent value="files" className="space-y-4 mt-4">
{/* Filters */}
<div className="flex flex-wrap gap-3">
<Select value={selectedServer} onValueChange={v => { if (v !== null) { setSelectedServer(v); setSelectedNode(''); setSelectedStorage(''); } }}>
<Select value={selectedServer} onValueChange={v => { if (v !== null) { setSelectedServer(v); setSelectedNode(ALL_NODES); setSelectedStorage(''); } }}>
<SelectTrigger className="w-[200px]"><SelectValue placeholder={t('backups.select_server')} /></SelectTrigger>
<SelectContent>
{servers.map(s => <SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>)}
</SelectContent>
</Select>
<Input className="w-[160px]" placeholder={t('backups.node')} value={selectedNode} onChange={e => setSelectedNode(e.target.value)} />
<Select value={selectedStorage} onValueChange={v => { if (v !== null) setSelectedStorage(v); }}>
<SelectTrigger className="w-[200px]"><SelectValue placeholder={t('backups.select_storage')} /></SelectTrigger>
<Select value={selectedNode} onValueChange={v => { if (v !== null) setSelectedNode(v); }} disabled={!sid}>
<SelectTrigger className="w-[200px]"><SelectValue placeholder={t('backups.node')} /></SelectTrigger>
<SelectContent>
<SelectItem value={ALL_NODES}>{t('backups.all_nodes')}</SelectItem>
{nodes.map(n => <SelectItem key={n.node} value={n.node}>{n.node}</SelectItem>)}
</SelectContent>
</Select>
<Select value={selectedStorage} onValueChange={v => { if (v !== null) setSelectedStorage(v); }} disabled={!sid || storages.length === 0}>
<SelectTrigger className="w-[220px]"><SelectValue placeholder={t('backups.select_storage')} /></SelectTrigger>
<SelectContent>
{storages.map(s => <SelectItem key={s.storage} value={s.storage}>{s.storage} ({s.type})</SelectItem>)}
</SelectContent>
@@ -67,24 +363,55 @@ export default function BackupsPage() {
<TableHeader>
<TableRow>
<TableHead>VMID</TableHead>
<TableHead>{t('backups.type')}</TableHead>
<TableHead>{t('backups.node')}</TableHead>
<TableHead>{t('backups.volume')}</TableHead>
<TableHead>{t('backups.size')}</TableHead>
<TableHead>{t('backups.date')}</TableHead>
<TableHead>{t('backups.format')}</TableHead>
<TableHead>{t('backups.notes')}</TableHead>
<TableHead className="text-right">{t('common.actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{backups.map((b, i) => (
<TableRow key={i}>
<TableCell className="font-mono">{b.vmid}</TableCell>
<TableCell className="font-mono text-xs max-w-[300px] truncate">{b.volid}</TableCell>
<TableCell>{formatBytes(b.size)}</TableCell>
<TableCell>{new Date(b.ctime * 1000).toLocaleString()}</TableCell>
<TableCell><Badge variant="outline">{b.format}</Badge></TableCell>
<TableCell className="text-xs text-muted-foreground">{b.notes || '—'}</TableCell>
</TableRow>
))}
{backups.map((b, i) => {
const vmType = detectVmType(b.volid);
return (
<TableRow key={i}>
<TableCell className="font-mono">{b.vmid}</TableCell>
<TableCell>
<Badge variant="outline" className="uppercase text-xs">{vmType}</Badge>
</TableCell>
<TableCell className="font-mono text-xs">{b.node || '—'}</TableCell>
<TableCell className="font-mono text-xs max-w-[300px] truncate">{b.volid}</TableCell>
<TableCell>{formatBytes(b.size)}</TableCell>
<TableCell>{new Date(b.ctime * 1000).toLocaleString()}</TableCell>
<TableCell><Badge variant="outline">{b.format}</Badge></TableCell>
<TableCell className="text-xs text-muted-foreground">{b.notes || '—'}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button
variant="ghost"
size="sm"
title={t('backups.restore')}
onClick={() => openRestore(b)}
>
<RotateCcw className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
title={t('common.delete')}
onClick={() => setDeleteTarget(b)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
@@ -97,23 +424,32 @@ export default function BackupsPage() {
</TabsContent>
<TabsContent value="jobs" className="space-y-4 mt-4">
<div className="flex justify-end">
<Button size="sm" onClick={openCreateJob}>
<Plus className="h-4 w-4 mr-1" /> {t('backups.create_job')}
</Button>
</div>
{jobs.length > 0 ? (
<div className="rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>{t('backups.server')}</TableHead>
<TableHead>{t('backups.node')}</TableHead>
<TableHead>{t('backups.vmids')}</TableHead>
<TableHead>{t('backups.storage_col')}</TableHead>
<TableHead>{t('backups.schedule')}</TableHead>
<TableHead>{t('common.status')}</TableHead>
<TableHead>{t('common.actions')}</TableHead>
<TableHead className="text-right">{t('common.actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{jobs.map(job => (
<TableRow key={job.id}>
<TableCell>{job.id}</TableCell>
<TableCell>{serverNameById.get(job.server_id) || job.server_id}</TableCell>
<TableCell className="font-mono text-xs">{job.node}</TableCell>
<TableCell className="font-mono text-xs">{job.vmids?.join(', ') || '—'}</TableCell>
<TableCell>{job.storage}</TableCell>
<TableCell className="font-mono text-xs">{job.cron_expression}</TableCell>
@@ -122,14 +458,20 @@ export default function BackupsPage() {
{job.enabled ? t('backups.enabled') : t('backups.disabled')}
</Badge>
</TableCell>
<TableCell>
<div className="flex gap-1">
<Button variant="ghost" size="sm" onClick={() => toggleJob.mutate(job.id)}>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button variant="ghost" size="sm" title={job.enabled ? t('backups.disable') : t('backups.enable')} onClick={() => toggleJob.mutate(job.id)}>
{job.enabled ? t('backups.disable') : t('backups.enable')}
</Button>
<Button variant="ghost" size="sm" onClick={() => { runJob.mutate(job.id); toast.success(t('backups.job_started')); }}>
<Button variant="ghost" size="sm" title={t('backups.run_now')} onClick={() => { runJob.mutate(job.id); toast.success(t('backups.job_started')); }}>
<Play className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="sm" title={t('common.edit')} onClick={() => openEditJob(job)}>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="sm" className="text-destructive hover:text-destructive" title={t('common.delete')} onClick={() => setDeleteJobTarget(job)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
@@ -145,6 +487,336 @@ export default function BackupsPage() {
)}
</TabsContent>
</Tabs>
{/* Restore dialog */}
<Dialog open={!!restoreTarget} onOpenChange={(o) => { if (!o) setRestoreTarget(null); }}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t('backups.restore_title')}</DialogTitle>
</DialogHeader>
{restoreTarget && (
<div className="space-y-3 text-sm">
<p className="text-muted-foreground">{t('backups.restore_warning')}</p>
<div className="rounded-md border p-3 space-y-1 bg-muted/40">
<div><span className="text-muted-foreground">{t('backups.volume')}:</span> <span className="font-mono text-xs break-all">{restoreTarget.volid}</span></div>
<div><span className="text-muted-foreground">VMID:</span> <span className="font-mono">{restoreTarget.vmid}</span></div>
<div><span className="text-muted-foreground">{t('backups.type')}:</span> <Badge variant="outline" className="uppercase ml-1">{detectVmType(restoreTarget.volid)}</Badge></div>
<div><span className="text-muted-foreground">{t('backups.size')}:</span> {formatBytes(restoreTarget.size)}</div>
</div>
<div>
<Label>{t('backups.new_vmid_optional')}</Label>
<Input
className="mt-1"
type="number"
min={100}
placeholder={String(restoreTarget.vmid)}
value={restoreNewVmid}
onChange={(e) => setRestoreNewVmid(e.target.value)}
/>
<p className="mt-1 text-xs text-muted-foreground">{t('backups.new_vmid_hint')}</p>
</div>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={restoreStart}
onChange={(e) => setRestoreStart(e.target.checked)}
/>
<span>{t('backups.start_after_restore')}</span>
</label>
{detectVmType(restoreTarget.volid) === 'qemu' && (
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={restoreUnique}
onChange={(e) => setRestoreUnique(e.target.checked)}
/>
<span>{t('backups.unique_mac')}</span>
</label>
)}
</div>
)}
<DialogFooter>
<DialogClose render={<Button variant="outline" />}>{t('common.cancel')}</DialogClose>
<Button onClick={submitRestore} disabled={restoreBackup.isPending}>
{restoreBackup.isPending && <Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />}
{t('backups.restore')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Job create/edit dialog */}
<Dialog open={jobDialogOpen} onOpenChange={(o) => { if (!o) setJobDialogOpen(false); }}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{editingJobId != null ? t('backups.edit_job') : t('backups.create_job')}</DialogTitle>
</DialogHeader>
<div className="space-y-3 text-sm">
<div className="grid grid-cols-2 gap-3">
<div>
<Label>{t('backups.server')}</Label>
<Select
value={jobForm.server_id ? String(jobForm.server_id) : ''}
onValueChange={(v) => { if (v !== null) setJobForm({ ...jobForm, server_id: Number(v), node: '', storage: '' }); }}
>
<SelectTrigger className="mt-1"><SelectValue placeholder={t('backups.select_server')} /></SelectTrigger>
<SelectContent>
{servers.map(s => <SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div>
<Label>{t('backups.node')}</Label>
<Select
value={jobForm.node}
onValueChange={(v) => { if (v !== null) setJobForm({ ...jobForm, node: v }); }}
>
<SelectTrigger className="mt-1"><SelectValue placeholder={t('backups.node')} /></SelectTrigger>
<SelectContent>
{jobNodes.map(n => <SelectItem key={n.node} value={n.node}>{n.node}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="col-span-2">
<Label>{t('backups.storage')}</Label>
<Select
value={jobForm.storage}
onValueChange={(v) => { if (v !== null) setJobForm({ ...jobForm, storage: v }); }}
>
<SelectTrigger className="mt-1"><SelectValue placeholder={t('backups.select_storage')} /></SelectTrigger>
<SelectContent>
{jobStorages.map(s => <SelectItem key={s.storage} value={s.storage}>{s.storage}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div>
<Label>{t('backups.mode')}</Label>
<Select value={jobForm.mode} onValueChange={(v) => { if (v !== null) setJobForm({ ...jobForm, mode: v }); }}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="snapshot">snapshot</SelectItem>
<SelectItem value="suspend">suspend</SelectItem>
<SelectItem value="stop">stop</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>{t('backups.compress')}</Label>
<Select value={jobForm.compress} onValueChange={(v) => { if (v !== null) setJobForm({ ...jobForm, compress: v }); }}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="zstd">zstd</SelectItem>
<SelectItem value="gzip">gzip</SelectItem>
<SelectItem value="lzo">lzo</SelectItem>
<SelectItem value="0">{t('backups.none')}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div>
<div className="flex items-center justify-between mb-1">
<Label>{t('backups.vmids')}</Label>
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<input
type="checkbox"
checked={selectAllVmids}
onChange={(e) => { setSelectAllVmids(e.target.checked); if (e.target.checked) setSelectedVmids(new Set()); }}
/>
<span>{t('backups.all_vms')}</span>
</label>
</div>
<div className="rounded-md border">
<div className="p-2 border-b">
<Input
placeholder={t('common.search')}
value={vmFilter}
onChange={(e) => setVmFilter(e.target.value)}
disabled={selectAllVmids}
className="h-8"
/>
</div>
<div className="max-h-56 overflow-y-auto p-2 space-y-1">
{!jobForm.server_id || !jobForm.node ? (
<p className="text-xs text-muted-foreground py-3 text-center">{t('backups.pick_server_node_first')}</p>
) : pickerVms.length === 0 ? (
<p className="text-xs text-muted-foreground py-3 text-center">{t('backups.no_vms_found')}</p>
) : (
<>
<div className="flex justify-between text-xs text-muted-foreground px-1 pb-1">
<button
type="button"
className="hover:text-foreground disabled:opacity-50"
disabled={selectAllVmids}
onClick={() => setSelectedVmids(new Set(pickerVms.map(v => v.vmid)))}
>
{t('backups.select_all_visible')}
</button>
<button
type="button"
className="hover:text-foreground disabled:opacity-50"
disabled={selectAllVmids}
onClick={() => setSelectedVmids(new Set())}
>
{t('backups.clear_selection')}
</button>
</div>
{pickerVms.map(vm => {
const checked = selectAllVmids || selectedVmids.has(vm.vmid);
return (
<label
key={`${vm.server_id}-${vm.vmid}`}
className="flex items-center gap-2 px-2 py-1 rounded hover:bg-muted cursor-pointer text-sm"
>
<input
type="checkbox"
checked={checked}
disabled={selectAllVmids}
onChange={(e) => {
setSelectedVmids(prev => {
const next = new Set(prev);
if (e.target.checked) next.add(vm.vmid);
else next.delete(vm.vmid);
return next;
});
}}
/>
<Badge variant="outline" className="uppercase text-[10px] px-1">{vm.type}</Badge>
<span className="font-mono text-xs w-12">{vm.vmid}</span>
<span className="truncate">{vm.name || '—'}</span>
<span className="ml-auto text-xs text-muted-foreground font-mono">{vm.node}</span>
</label>
);
})}
</>
)}
</div>
<div className="px-2 py-1 border-t text-xs text-muted-foreground">
{selectAllVmids
? t('backups.all_vms_selected')
: t('backups.selected_count', { count: selectedVmids.size })}
</div>
</div>
</div>
<div>
<Label>{t('backups.schedule')} (cron)</Label>
<div className="mt-1 flex gap-2">
<Input
className="font-mono"
value={jobForm.cron_expression}
onChange={(e) => setJobForm({ ...jobForm, cron_expression: e.target.value })}
placeholder="0 2 * * *"
/>
<Select value="" onValueChange={(v) => { if (v) setJobForm({ ...jobForm, cron_expression: v }); }}>
<SelectTrigger className="w-[180px]"><SelectValue placeholder={t('backups.preset')} /></SelectTrigger>
<SelectContent>
{CRON_PRESETS.map(p => (
<SelectItem key={p.value} value={p.value}>{t(`backups.cron.${p.label}`)}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<p className="mt-1 text-xs text-muted-foreground">{t('backups.cron_hint')}</p>
</div>
<div className="grid grid-cols-4 gap-3 items-end">
<div>
<Label className="whitespace-nowrap">keep-last</Label>
<Input className="mt-1" type="number" min={0} value={jobForm.keep_last ?? 0}
onChange={(e) => setJobForm({ ...jobForm, keep_last: Number(e.target.value) })} />
</div>
<div>
<Label className="whitespace-nowrap">keep-daily</Label>
<Input className="mt-1" type="number" min={0} value={jobForm.keep_daily ?? 0}
onChange={(e) => setJobForm({ ...jobForm, keep_daily: Number(e.target.value) })} />
</div>
<div>
<Label className="whitespace-nowrap">keep-weekly</Label>
<Input className="mt-1" type="number" min={0} value={jobForm.keep_weekly ?? 0}
onChange={(e) => setJobForm({ ...jobForm, keep_weekly: Number(e.target.value) })} />
</div>
<div>
<Label className="whitespace-nowrap">keep-monthly</Label>
<Input className="mt-1" type="number" min={0} value={jobForm.keep_monthly ?? 0}
onChange={(e) => setJobForm({ ...jobForm, keep_monthly: Number(e.target.value) })} />
</div>
</div>
<div>
<Label>{t('backups.notes')}</Label>
<Input className="mt-1" value={jobForm.notes ?? ''} onChange={(e) => setJobForm({ ...jobForm, notes: e.target.value })} />
</div>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={!!jobForm.enabled}
onChange={(e) => setJobForm({ ...jobForm, enabled: e.target.checked })}
/>
<span>{t('backups.enabled')}</span>
</label>
</div>
<DialogFooter>
<DialogClose render={<Button variant="outline" />}>{t('common.cancel')}</DialogClose>
<Button onClick={submitJob} disabled={createJob.isPending || updateJob.isPending}>
{(createJob.isPending || updateJob.isPending) && <Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />}
{editingJobId != null ? t('common.save') : t('common.create')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Job delete confirmation */}
<Dialog open={!!deleteJobTarget} onOpenChange={(o) => { if (!o) setDeleteJobTarget(null); }}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t('backups.delete_job_title')}</DialogTitle>
</DialogHeader>
{deleteJobTarget && (
<div className="space-y-3 text-sm">
<p className="text-muted-foreground">{t('backups.delete_job_confirm')}</p>
<div className="rounded-md border p-3 space-y-1 bg-muted/40">
<div><span className="text-muted-foreground">ID:</span> {deleteJobTarget.id}</div>
<div><span className="text-muted-foreground">{t('backups.schedule')}:</span> <span className="font-mono text-xs">{deleteJobTarget.cron_expression}</span></div>
<div><span className="text-muted-foreground">{t('backups.vmids')}:</span> <span className="font-mono text-xs">{deleteJobTarget.vmids?.join(', ')}</span></div>
</div>
</div>
)}
<DialogFooter>
<DialogClose render={<Button variant="outline" />}>{t('common.cancel')}</DialogClose>
<Button variant="destructive" onClick={submitDeleteJob} disabled={deleteJob.isPending}>
{deleteJob.isPending && <Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />}
{t('common.delete')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete backup confirmation */}
<Dialog open={!!deleteTarget} onOpenChange={(o) => { if (!o) setDeleteTarget(null); }}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t('backups.delete_title')}</DialogTitle>
</DialogHeader>
{deleteTarget && (
<div className="space-y-3 text-sm">
<p className="text-muted-foreground">{t('backups.delete_confirm')}</p>
<div className="rounded-md border p-3 space-y-1 bg-muted/40">
<div><span className="text-muted-foreground">{t('backups.volume')}:</span> <span className="font-mono text-xs break-all">{deleteTarget.volid}</span></div>
<div><span className="text-muted-foreground">{t('backups.size')}:</span> {formatBytes(deleteTarget.size)}</div>
</div>
</div>
)}
<DialogFooter>
<DialogClose render={<Button variant="outline" />}>{t('common.cancel')}</DialogClose>
<Button variant="destructive" onClick={submitDelete} disabled={deleteBackup.isPending}>
{deleteBackup.isPending && <Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />}
{t('common.delete')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -2,7 +2,7 @@ import { useState } from 'react';
import { useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import { useMutation } from '@tanstack/react-query';
import { Server, Cpu, CheckCircle, ChevronLeft, ChevronRight, Loader2, Monitor, Container, HardDrive } from 'lucide-react';
import { Server, Cpu, CheckCircle, ChevronLeft, ChevronRight, Loader2, Monitor, Container, HardDrive, KeyRound } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -13,6 +13,9 @@ import { useServers } from '@/hooks/use-nodes';
import { useTemplates, useTemplateGroups } from '@/hooks/use-templates';
import { useIPAMNetworks } from '@/hooks/use-ipam';
import { useLXCTemplates, useLXCStorages, useDeployLXC } from '@/hooks/use-lxc-templates';
import { useMySSHKeys, useUserSSHKeys } from '@/hooks/use-ssh-keys';
import { useProfile } from '@/hooks/use-settings';
import { useUsers } from '@/hooks/use-users';
import { apiClient } from '@/lib/api-client';
import { useDeployTasksStore } from '@/stores/deploy-tasks-store';
import type { VMDeployRequest, OSTemplate, ProxmoxServer, LXCTemplate } from '@/types';
@@ -97,6 +100,21 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
const { data: lxcTemplates = [], isLoading: lxcLoading } = useLXCTemplates(selectedServer?.id);
const { data: lxcStorages = [] } = useLXCStorages(selectedServer?.id);
// ── SSH Keys & ownership ──────────────────────────────────────────────────
const { data: profile } = useProfile();
const isAdmin = !!profile?.is_admin;
const { data: allUsers = [] } = useUsers();
const [ownerId, setOwnerId] = useState<number | null>(null);
const effectiveOwnerId = isAdmin && ownerId ? ownerId : null;
const { data: myKeys = [] } = useMySSHKeys();
const { data: targetUserKeys = [] } = useUserSSHKeys(effectiveOwnerId);
const [selectedKeyIds, setSelectedKeyIds] = useState<number[]>([]);
const availableKeys = isAdmin && effectiveOwnerId
? [...myKeys, ...targetUserKeys.filter(k => !myKeys.find(m => m.id === k.id))]
: myKeys;
const toggleKey = (id: number) =>
setSelectedKeyIds(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]);
const deploy = useMutation({
mutationFn: (data: VMDeployRequest) =>
apiClient.post<{ task_id: number; status: string; name: string }>('/templates/api/deploy', data),
@@ -156,6 +174,8 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
ipam_network_id: lxcConfig.ipam_network_id || undefined,
password: lxcConfig.password || undefined,
ssh_keys: lxcConfig.ssh_keys || undefined,
ssh_key_ids: selectedKeyIds.length ? selectedKeyIds : undefined,
owner_id: effectiveOwnerId ?? undefined,
nameserver: lxcConfig.nameserver || undefined,
unprivileged: lxcConfig.unprivileged,
start_after_create: lxcConfig.start_after_create,
@@ -197,6 +217,8 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
cloud_init_user: config.cloud_init_user || undefined,
cloud_init_password: config.cloud_init_password || undefined,
ssh_keys: config.ssh_keys || undefined,
ssh_key_ids: selectedKeyIds.length ? selectedKeyIds : undefined,
owner_id: effectiveOwnerId ?? undefined,
});
}
};
@@ -487,8 +509,16 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
<Label>{t('wizard.ci_password')}</Label>
<Input type="password" value={config.cloud_init_password} onChange={e => setConfig(p => ({ ...p, cloud_init_password: e.target.value }))} />
</div>
{isAdmin && (
<SSHOwnerSelector
users={allUsers}
value={ownerId}
onChange={(v) => { setOwnerId(v); setSelectedKeyIds([]); }}
/>
)}
<SSHKeyPicker keys={availableKeys} selected={selectedKeyIds} onToggle={toggleKey} />
<div>
<Label>SSH Keys</Label>
<Label>SSH Keys ({t('common.optional')})</Label>
<textarea
className="w-full rounded-md border bg-transparent px-3 py-2 text-sm font-mono min-h-[60px]"
value={config.ssh_keys}
@@ -628,8 +658,16 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
placeholder={t('wizard.lxc_password_placeholder')}
/>
</div>
{isAdmin && (
<SSHOwnerSelector
users={allUsers}
value={ownerId}
onChange={(v) => { setOwnerId(v); setSelectedKeyIds([]); }}
/>
)}
<SSHKeyPicker keys={availableKeys} selected={selectedKeyIds} onToggle={toggleKey} />
<div>
<Label>SSH Keys</Label>
<Label>SSH Keys ({t('common.optional')})</Label>
<textarea
className="w-full rounded-md border bg-transparent px-3 py-2 text-sm font-mono min-h-[60px]"
value={lxcConfig.ssh_keys}
@@ -716,3 +754,71 @@ function Row({ label, value }: { label: string; value: string }) {
</div>
);
}
function SSHOwnerSelector({
users, value, onChange,
}: {
users: { id: number; username: string; full_name?: string | null }[];
value: number | null;
onChange: (v: number | null) => void;
}) {
const { t } = useTranslation();
return (
<div>
<Label>{t('wizard.ssh_keys_owner')}</Label>
<Select value={value ? String(value) : '__none__'} onValueChange={v => onChange(v === '__none__' ? null : Number(v))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="__none__">{t('wizard.ssh_keys_owner_self')}</SelectItem>
{users.map(u => (
<SelectItem key={u.id} value={String(u.id)}>
{u.username}{u.full_name ? ` (${u.full_name})` : ''}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
function SSHKeyPicker({
keys, selected, onToggle,
}: {
keys: { id: number; name: string; fingerprint?: string | null; comment?: string | null }[];
selected: number[];
onToggle: (id: number) => void;
}) {
const { t } = useTranslation();
return (
<div>
<Label className="flex items-center gap-2">
<KeyRound className="h-3.5 w-3.5" />
{t('wizard.ssh_keys_select')}
</Label>
{keys.length === 0 ? (
<p className="text-xs text-muted-foreground mt-1">{t('wizard.no_saved_keys')}</p>
) : (
<div className="mt-1 space-y-1 max-h-40 overflow-y-auto rounded-md border p-2">
{keys.map(k => (
<label
key={k.id}
className="flex items-start gap-2 text-xs cursor-pointer hover:bg-accent rounded px-2 py-1"
>
<input
type="checkbox"
className="h-4 w-4 mt-0.5"
checked={selected.includes(k.id)}
onChange={() => onToggle(k.id)}
/>
<div className="min-w-0 flex-1">
<div className="font-medium truncate">{k.name}</div>
{k.fingerprint && <div className="font-mono text-muted-foreground truncate">{k.fingerprint}</div>}
{k.comment && <div className="text-muted-foreground truncate">{k.comment}</div>}
</div>
</label>
))}
</div>
)}
</div>
);
}

View File

@@ -65,7 +65,8 @@ import {
} from '@/components/ui/table';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { StatusDot } from '@/components/shared/status-dot';
import { useVirtualMachines, useBulkOperation, usePowerAction, useVMStatusSync } from '@/hooks/use-instances';
import { useVirtualMachines, useBulkOperation, usePowerAction, useVMStatusSync, useInstancesMetricsSync } from '@/hooks/use-instances';
import { useServers } from '@/hooks/use-nodes';
import { formatBytes, vmTypeLabel, formatUptime } from '@/lib/format';
import { apiClient } from '@/lib/api-client';
import { useDeployTasksStore } from '@/stores/deploy-tasks-store';
@@ -220,6 +221,7 @@ export default function InstancesPage() {
const { t } = useTranslation();
const { data: vms, isLoading } = useVirtualMachines();
useVMStatusSync();
const metricsMap = useInstancesMetricsSync();
const bulkOp = useBulkOperation();
// ── Deploy task polling ──────────────────────────────────────────
@@ -255,8 +257,11 @@ export default function InstancesPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTasks.length, activeTasks.map((t) => t.id).join(',')]);
const { data: servers = [] } = useServers();
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [serverFilter, setServerFilter] = useState<string>('all');
const [nodeFilter, setNodeFilter] = useState<string>(() => {
return localStorage.getItem('instances-node-filter') ?? 'all';
});
@@ -271,6 +276,7 @@ export default function InstancesPage() {
if (typeTab === 'vm' && vm.type !== 'qemu') return false;
if (typeTab === 'lxc' && vm.type !== 'lxc') return false;
if (statusFilter !== 'all' && vm.status !== statusFilter) return false;
if (serverFilter !== 'all' && vm.server_id !== Number(serverFilter)) return false;
if (nodeFilter !== 'all' && vm.node !== nodeFilter) return false;
if (search) {
const q = search.toLowerCase();
@@ -281,7 +287,7 @@ export default function InstancesPage() {
}
return true;
});
}, [vms, typeTab, statusFilter, nodeFilter, search]);
}, [vms, typeTab, statusFilter, serverFilter, nodeFilter, search]);
// Ghost rows from active deploy/clone tasks (prepended before real VMs).
// - reinstall / change_password tasks are NOT shown as ghosts (they live as overlays on real rows)
@@ -322,11 +328,35 @@ export default function InstancesPage() {
const allRows = useMemo<VMInstance[]>(() => [...ghostRows, ...filteredVMs], [ghostRows, filteredVMs]);
// Unique nodes for filter
// Merge live WS metrics into rows for real-time rendering
const liveRows = useMemo<VMInstance[]>(() => {
if (!metricsMap.size) return allRows;
return allRows.map((vm) => {
const live = metricsMap.get(`${vm.server_id}:${vm.vmid}`);
if (!live) return vm;
return {
...vm,
status: (live.status as VMInstance['status']) ?? vm.status,
cpu: live.cpu ?? vm.cpu,
mem: live.mem ?? vm.mem,
maxmem: live.maxmem ?? vm.maxmem,
disk: live.disk ?? vm.disk,
maxdisk: live.maxdisk ?? vm.maxdisk,
uptime: live.uptime ?? vm.uptime,
netin: live.netin ?? vm.netin,
netout: live.netout ?? vm.netout,
};
});
}, [allRows, metricsMap]);
// Unique nodes for filter — scoped to selected server
const uniqueNodes = useMemo(() => {
if (!vms) return [];
return [...new Set(vms.map((v) => v.node))].sort();
}, [vms]);
const source = serverFilter !== 'all'
? vms.filter((v) => v.server_id === Number(serverFilter))
: vms;
return [...new Set(source.map((v) => v.node))].sort();
}, [vms, serverFilter]);
// Counts (ghosts not counted in tabs)
const counts = useMemo(() => {
@@ -616,7 +646,7 @@ export default function InstancesPage() {
// ==================== TanStack Table ====================
const table = useReactTable({
data: allRows,
data: liveRows,
columns,
state: { sorting, rowSelection },
onSortingChange: setSorting,
@@ -680,6 +710,20 @@ export default function InstancesPage() {
</SelectContent>
</Select>
{servers.length > 1 && (
<Select value={serverFilter} onValueChange={v => { if (v !== null) { setServerFilter(v); setNodeFilter('all'); } }}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder={t('backups.select_server', 'Server')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('instances.all_nodes', 'All servers')}</SelectItem>
{servers.map((s) => (
<SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>
))}
</SelectContent>
</Select>
)}
<Select value={nodeFilter} onValueChange={v => { if (v !== null) { setNodeFilter(v); localStorage.setItem('instances-node-filter', v); } }}>
<SelectTrigger className="w-[130px]">
<SelectValue placeholder={t('common.node', 'Node')} />

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { Link } from 'react-router';
import { useTranslation } from 'react-i18next';
import { Server, Monitor, Container, Plus, Pencil, Trash2, Wifi, Loader2 } from 'lucide-react';
@@ -8,7 +8,7 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogClose } from '@/components/ui/dialog';
import { useServers, useCreateServer, useUpdateServer, useDeleteServer, useTestServer } from '@/hooks/use-nodes';
import { useServers, useCreateServer, useUpdateServer, useDeleteServer, useTestServerCredentials } from '@/hooks/use-nodes';
import { useVirtualMachines } from '@/hooks/use-instances';
import type { ProxmoxServerCreate } from '@/types';
import { toast } from 'sonner';
@@ -48,7 +48,6 @@ function ServerFormDialog({
initialData,
onSubmit,
isPending,
serverId,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
@@ -60,18 +59,17 @@ function ServerFormDialog({
}) {
const { t } = useTranslation();
const [form, setForm] = useState<ServerFormData>(initialData);
const testServer = useTestServer();
const testServer = useTestServerCredentials();
// Sync initialData when dialog opens
const handleOpen = (v: boolean) => {
if (v) setForm(initialData);
onOpenChange(v);
};
// Sync initialData whenever dialog opens (also covers programmatic open changes)
useEffect(() => {
if (open) setForm(initialData);
}, [open, initialData]);
const set = (field: keyof ServerFormData, value: string | boolean) =>
setForm(prev => ({ ...prev, [field]: value }));
const handleSubmit = () => {
const buildPayload = (): ProxmoxServerCreate => {
const payload: ProxmoxServerCreate = {
name: form.name,
hostname: form.hostname || form.ip_address,
@@ -88,12 +86,19 @@ function ServerFormDialog({
payload.api_token_name = form.api_token_name;
payload.api_token_value = form.api_token_value;
}
onSubmit(payload);
return payload;
};
const handleSubmit = () => {
onSubmit(buildPayload());
};
const handleTest = () => {
if (!serverId) return;
testServer.mutate(serverId, {
if (form.use_password ? !form.password : (!form.api_token_name || !form.api_token_value)) {
toast.error(t('nodes.connection_failed'));
return;
}
testServer.mutate(buildPayload(), {
onSuccess: (res) => {
if (res.success) toast.success(t('nodes.connection_ok'));
else toast.error(t('nodes.connection_failed') + (res.message ? `: ${res.message}` : ''));
@@ -103,7 +108,7 @@ function ServerFormDialog({
};
return (
<Dialog open={open} onOpenChange={handleOpen}>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader><DialogTitle>{title}</DialogTitle></DialogHeader>
<div className="space-y-3">
@@ -172,12 +177,15 @@ function ServerFormDialog({
</div>
</div>
<DialogFooter className="gap-2">
{serverId && (
<Button variant="outline" size="sm" onClick={handleTest} disabled={testServer.isPending}>
{testServer.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Wifi className="h-4 w-4 mr-1" />}
{t('nodes.test_connection')}
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={handleTest}
disabled={testServer.isPending || !form.ip_address}
>
{testServer.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Wifi className="h-4 w-4 mr-1" />}
{t('nodes.test_connection')}
</Button>
<DialogClose render={<Button variant="outline" />}>{t('common.cancel')}</DialogClose>
<Button onClick={handleSubmit} disabled={isPending || !form.name || !form.ip_address}>
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}

View File

@@ -0,0 +1,286 @@
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { Plus, Trash2, Download, Pencil, KeyRound, Copy } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import {
Dialog, DialogClose, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger,
} from '@/components/ui/dialog';
import {
type SSHKey,
useMySSHKeys, useCreateMySSHKey, useUpdateMySSHKey, useDeleteMySSHKey, useDownloadMyPrivateKey,
useUserSSHKeys, useAdminCreateUserSSHKey, useAdminUpdateUserSSHKey,
useAdminDeleteUserSSHKey, useAdminDownloadUserPrivateKey,
} from '@/hooks/use-ssh-keys';
interface Props {
/** When provided + admin=true, manage another user's keys via admin endpoints. */
userId?: number | null;
admin?: boolean;
}
function getErrMsg(err: unknown): string {
if (err instanceof Error) return err.message;
return 'Error';
}
export function SSHKeysManager({ userId = null, admin = false }: Props) {
const { t } = useTranslation();
const myList = useMySSHKeys();
const adminList = useUserSSHKeys(admin && userId ? userId : null);
const myCreate = useCreateMySSHKey();
const myUpdate = useUpdateMySSHKey();
const myDelete = useDeleteMySSHKey();
const myDownload = useDownloadMyPrivateKey();
const adminCreate = useAdminCreateUserSSHKey();
const adminUpdate = useAdminUpdateUserSSHKey();
const adminDelete = useAdminDeleteUserSSHKey();
const adminDownload = useAdminDownloadUserPrivateKey();
const isAdminMode = admin && !!userId;
const list = isAdminMode ? adminList : myList;
const keys: SSHKey[] = list.data ?? [];
const [editing, setEditing] = useState<SSHKey | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const onCreate = (data: { name: string; public_key: string; private_key?: string; comment?: string }) => {
if (isAdminMode && userId) {
adminCreate.mutate(
{ userId, data },
{
onSuccess: () => { setCreateOpen(false); toast.success(t('ssh_keys.created')); },
onError: e => toast.error(getErrMsg(e)),
},
);
} else {
myCreate.mutate(data, {
onSuccess: () => { setCreateOpen(false); toast.success(t('ssh_keys.created')); },
onError: e => toast.error(getErrMsg(e)),
});
}
};
const onUpdate = (id: number, data: { name?: string; public_key?: string; private_key?: string; comment?: string }) => {
if (isAdminMode && userId) {
adminUpdate.mutate(
{ userId, keyId: id, data },
{
onSuccess: () => { setEditing(null); toast.success(t('common.saved')); },
onError: e => toast.error(getErrMsg(e)),
},
);
} else {
myUpdate.mutate(
{ id, data },
{
onSuccess: () => { setEditing(null); toast.success(t('common.saved')); },
onError: e => toast.error(getErrMsg(e)),
},
);
}
};
const onDelete = (id: number) => {
if (!confirm(t('ssh_keys.confirm_delete'))) return;
if (isAdminMode && userId) {
adminDelete.mutate({ userId, keyId: id }, {
onSuccess: () => toast.success(t('ssh_keys.deleted')),
onError: e => toast.error(getErrMsg(e)),
});
} else {
myDelete.mutate(id, {
onSuccess: () => toast.success(t('ssh_keys.deleted')),
onError: e => toast.error(getErrMsg(e)),
});
}
};
const onDownload = (key: SSHKey) => {
const onSuccess = (res: { name: string; private_key: string }) => {
const blob = new Blob([res.private_key], { type: 'application/octet-stream' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${res.name.replace(/[^a-z0-9._-]+/gi, '_')}.key`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
};
if (isAdminMode && userId) {
adminDownload.mutate({ userId, keyId: key.id }, {
onSuccess,
onError: e => toast.error(getErrMsg(e)),
});
} else {
myDownload.mutate(key.id, {
onSuccess,
onError: e => toast.error(getErrMsg(e)),
});
}
};
const copyPublic = (k: SSHKey) => {
navigator.clipboard.writeText(k.public_key).then(
() => toast.success(t('ssh_keys.copied')),
() => toast.error(t('ssh_keys.copy_failed')),
);
};
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<KeyRound className="h-4 w-4" />
<span className="text-sm font-medium">{t('ssh_keys.title')}</span>
<Badge variant="outline" className="text-xs">{keys.length}</Badge>
</div>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogTrigger render={<Button size="sm"><Plus className="mr-1 h-4 w-4" />{t('ssh_keys.add')}</Button>} />
<SSHKeyDialog
mode="create"
onSubmit={onCreate}
submitting={isAdminMode ? adminCreate.isPending : myCreate.isPending}
/>
</Dialog>
</div>
{list.isLoading ? (
<p className="text-sm text-muted-foreground">{t('common.loading')}</p>
) : keys.length === 0 ? (
<p className="text-sm text-muted-foreground py-4 text-center">{t('ssh_keys.empty')}</p>
) : (
<div className="space-y-2">
{keys.map(k => (
<div key={k.id} className="rounded-md border p-3 space-y-2">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium truncate">{k.name}</span>
{k.has_private_key && (
<Badge variant="outline" className="text-xs">{t('ssh_keys.has_private')}</Badge>
)}
</div>
{k.comment && (
<p className="text-xs text-muted-foreground mt-0.5">{k.comment}</p>
)}
{k.fingerprint && (
<p className="text-xs font-mono text-muted-foreground mt-1 truncate">{k.fingerprint}</p>
)}
</div>
<div className="flex shrink-0 gap-1">
<Button size="sm" variant="ghost" onClick={() => copyPublic(k)} title={t('ssh_keys.copy_public')}>
<Copy className="h-3.5 w-3.5" />
</Button>
{k.has_private_key && (
<Button size="sm" variant="ghost" onClick={() => onDownload(k)} title={t('ssh_keys.download_private')}>
<Download className="h-3.5 w-3.5" />
</Button>
)}
<Button size="sm" variant="ghost" onClick={() => setEditing(k)} title={t('common.edit')}>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button size="sm" variant="ghost" onClick={() => onDelete(k.id)} title={t('common.delete')}>
<Trash2 className="h-3.5 w-3.5 text-destructive" />
</Button>
</div>
</div>
<p className="text-xs font-mono break-all bg-muted/40 rounded px-2 py-1">
{k.public_key.length > 120 ? `${k.public_key.slice(0, 120)}…` : k.public_key}
</p>
</div>
))}
</div>
)}
{editing && (
<Dialog open={!!editing} onOpenChange={o => !o && setEditing(null)}>
<SSHKeyDialog
mode="edit"
initial={editing}
onSubmit={data => onUpdate(editing.id, data)}
submitting={isAdminMode ? adminUpdate.isPending : myUpdate.isPending}
/>
</Dialog>
)}
</div>
);
}
function SSHKeyDialog({
mode, initial, onSubmit, submitting,
}: {
mode: 'create' | 'edit';
initial?: SSHKey;
onSubmit: (data: { name: string; public_key: string; private_key?: string; comment?: string }) => void;
submitting: boolean;
}) {
const { t } = useTranslation();
const [name, setName] = useState(initial?.name ?? '');
const [publicKey, setPublicKey] = useState(initial?.public_key ?? '');
const [privateKey, setPrivateKey] = useState('');
const [comment, setComment] = useState(initial?.comment ?? '');
const valid = useMemo(() => name.trim().length > 0 && publicKey.trim().length >= 20, [name, publicKey]);
const submit = () => {
onSubmit({
name: name.trim(),
public_key: publicKey.trim(),
private_key: privateKey.trim() || undefined,
comment: comment.trim() || undefined,
});
};
return (
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{mode === 'create' ? t('ssh_keys.add') : t('ssh_keys.edit')}</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div>
<Label className="text-xs">{t('common.name')}</Label>
<Input value={name} onChange={e => setName(e.target.value)} className="mt-1" placeholder="laptop / work / ci" />
</div>
<div>
<Label className="text-xs">{t('ssh_keys.public_key')} <span className="text-destructive">*</span></Label>
<textarea
className="mt-1 w-full rounded-md border bg-transparent px-3 py-2 text-xs font-mono min-h-[100px]"
value={publicKey}
onChange={e => setPublicKey(e.target.value)}
placeholder="ssh-ed25519 AAAA... user@host"
/>
</div>
<div>
<Label className="text-xs">
{t('ssh_keys.private_key')} <span className="text-muted-foreground">({t('common.optional')})</span>
</Label>
<textarea
className="mt-1 w-full rounded-md border bg-transparent px-3 py-2 text-xs font-mono min-h-[120px]"
value={privateKey}
onChange={e => setPrivateKey(e.target.value)}
placeholder={mode === 'edit' ? t('ssh_keys.private_key_replace_hint') : '-----BEGIN OPENSSH PRIVATE KEY-----\n...'}
/>
<p className="text-xs text-muted-foreground mt-1">{t('ssh_keys.private_key_hint')}</p>
</div>
<div>
<Label className="text-xs">{t('ssh_keys.comment')}</Label>
<Input value={comment} onChange={e => setComment(e.target.value)} className="mt-1" />
</div>
</div>
<DialogFooter>
<DialogClose render={<Button variant="outline" />}>{t('common.cancel')}</DialogClose>
<Button onClick={submit} disabled={!valid || submitting}>
{mode === 'create' ? t('common.create') : t('common.save')}
</Button>
</DialogFooter>
</DialogContent>
);
}

View File

@@ -14,6 +14,7 @@ import {
useNotificationChannels, useUpdateSMTP, useUpdateTelegram,
useAppVersion, useCheckUpdates,
} from '@/hooks/use-settings';
import { SSHKeysManager } from './SSHKeysManager';
export default function SettingsPage() {
const { t } = useTranslation();
@@ -23,12 +24,19 @@ export default function SettingsPage() {
<Tabs defaultValue="profile">
<TabsList>
<TabsTrigger value="profile">{t('settings.profile')}</TabsTrigger>
<TabsTrigger value="ssh-keys">{t('ssh_keys.title')}</TabsTrigger>
<TabsTrigger value="panel">{t('settings.panel')}</TabsTrigger>
<TabsTrigger value="security">{t('settings.security')}</TabsTrigger>
<TabsTrigger value="notifications">{t('settings.notifications')}</TabsTrigger>
<TabsTrigger value="about">{t('settings.about')}</TabsTrigger>
</TabsList>
<TabsContent value="profile"><ProfileTab /></TabsContent>
<TabsContent value="ssh-keys">
<Card>
<CardHeader><CardTitle className="text-sm">{t('ssh_keys.title')}</CardTitle></CardHeader>
<CardContent><SSHKeysManager /></CardContent>
</Card>
</TabsContent>
<TabsContent value="panel"><PanelTab /></TabsContent>
<TabsContent value="security"><SecurityTab /></TabsContent>
<TabsContent value="notifications"><NotificationsTab /></TabsContent>

View File

@@ -16,6 +16,7 @@ export default function TemplatesPage() {
const [search, setSearch] = useState('');
const [groupFilter, setGroupFilter] = useState<string>('');
const [serverFilter, setServerFilter] = useState<string>('');
const [typeFilter, setTypeFilter] = useState<string>('');
const [importDialogOpen, setImportDialogOpen] = useState(false);
const [importServerId, setImportServerId] = useState<string>('');
@@ -23,6 +24,7 @@ export default function TemplatesPage() {
const { data: templates = [], isLoading } = useTemplates(
groupFilter ? Number(groupFilter) : undefined,
serverFilter ? Number(serverFilter) : undefined,
typeFilter || undefined,
);
const { data: servers = [] } = useServers();
const autoImport = useAutoImportTemplates();
@@ -72,6 +74,14 @@ export default function TemplatesPage() {
{servers.map(s => <SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>)}
</SelectContent>
</Select>
<Select value={typeFilter} onValueChange={v => { if (v !== null) setTypeFilter(v); }}>
<SelectTrigger className="w-[160px]"><SelectValue placeholder={t('templates.all_types')} /></SelectTrigger>
<SelectContent>
<SelectItem value="">{t('templates.all_types')}</SelectItem>
<SelectItem value="qemu">{t('templates.type_kvm')}</SelectItem>
<SelectItem value="lxc">{t('templates.type_lxc')}</SelectItem>
</SelectContent>
</Select>
</div>
{/* Templates grid */}
@@ -109,13 +119,16 @@ export default function TemplatesPage() {
</Button>
</div>
<div className="flex items-center gap-2 flex-wrap">
<Badge variant={tpl.vm_type === 'lxc' ? 'secondary' : 'default'} className="text-xs">
{tpl.vm_type === 'lxc' ? t('templates.type_lxc') : t('templates.type_kvm')}
</Badge>
<Badge variant="outline" className="text-xs">{tpl.default_cores} vCPU</Badge>
<Badge variant="outline" className="text-xs">{tpl.default_memory} MB</Badge>
<Badge variant="outline" className="text-xs">{tpl.default_disk} GB</Badge>
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>{tpl.server_name}</span>
<span>VMID: {tpl.vmid}</span>
<span>{tpl.vmid != null ? `VMID: ${tpl.vmid}` : t('templates.file_template')}</span>
</div>
</CardContent>
</Card>

File diff suppressed because it is too large Load Diff

View File

@@ -19,8 +19,13 @@ export function useBackupStorages(serverId: number) {
export function useBackupList(serverId: number, node: string, storage: string) {
return useQuery({
queryKey: backupKeys.list(serverId, node, storage),
queryFn: () => apiClient.get<{ backups: unknown[] }>(`/proxmox/api/backups/list/${serverId}?node=${node}&storage=${storage}`),
enabled: serverId > 0 && !!node && !!storage,
queryFn: () => {
const params = new URLSearchParams();
params.set('storage', storage);
if (node) params.set('node', node);
return apiClient.get<{ backups: unknown[] }>(`/proxmox/api/backups/list/${serverId}?${params.toString()}`);
},
enabled: serverId > 0 && !!storage,
});
}
@@ -44,7 +49,11 @@ export function useDeleteBackup() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { server_id: number; node: string; storage: string; volid: string }) =>
apiClient.delete(`/proxmox/api/backups/${data.server_id}/backup`),
apiClient.delete(`/proxmox/api/backups/${data.server_id}/backup`, {
node: data.node,
storage: data.storage,
volid: data.volid,
}),
onSuccess: () => qc.invalidateQueries({ queryKey: ['backups'] }),
});
}
@@ -56,6 +65,48 @@ export function useRestoreBackup() {
});
}
export type BackupJobInput = {
server_id: number;
node: string;
vmids: (number | string)[];
storage: string;
mode?: string;
compress?: string;
notes?: string;
keep_last?: number;
keep_daily?: number;
keep_weekly?: number;
keep_monthly?: number;
cron_expression: string;
enabled?: boolean;
};
export function useCreateBackupJob() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: BackupJobInput) =>
apiClient.post<{ success: boolean; job: unknown }>('/proxmox/api/backups/jobs', data),
onSuccess: () => qc.invalidateQueries({ queryKey: backupKeys.jobs }),
});
}
export function useUpdateBackupJob() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: number; data: Partial<BackupJobInput> }) =>
apiClient.put<{ success: boolean; job: unknown }>(`/proxmox/api/backups/jobs/${id}`, data),
onSuccess: () => qc.invalidateQueries({ queryKey: backupKeys.jobs }),
});
}
export function useDeleteBackupJob() {
const qc = useQueryClient();
return useMutation({
mutationFn: (jobId: number) => apiClient.delete<{ success: boolean }>(`/proxmox/api/backups/jobs/${jobId}`),
onSuccess: () => qc.invalidateQueries({ queryKey: backupKeys.jobs }),
});
}
export function useToggleBackupJob() {
const qc = useQueryClient();
return useMutation({

View File

@@ -1,5 +1,5 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import { apiClient } from '@/lib/api-client';
import { getTasksWebSocket } from '@/lib/websocket';
import type { VMInstance, Snapshot, VMConfig } from '@/types';
@@ -18,21 +18,25 @@ export const vmKeys = {
};
// ==================== VM List ====================
// No HTTP polling: live metrics arrive via WebSocket (`useInstancesMetricsSync`).
// We still refetch on reconnect/focus to recover from missed events.
export function useVirtualMachines() {
return useQuery({
queryKey: vmKeys.all,
queryFn: () => apiClient.get<VMInstance[]>('/proxmox/api/virtual-machines'),
refetchInterval: 30000,
refetchInterval: false,
refetchOnWindowFocus: false,
staleTime: Infinity,
});
}
/** Subscribes to WebSocket vm_status_update events and patches the VM list cache in real time. */
/** Subscribes to WebSocket vm_status_update / vm_deleted / vm_created events and patches the VM list cache in real time. */
export function useVMStatusSync() {
const qc = useQueryClient();
useEffect(() => {
const ws = getTasksWebSocket();
ws.connect();
const unsub = ws.onType('vm_status_update', (data: unknown) => {
const unsubStatus = ws.onType('vm_status_update', (data: unknown) => {
const msg = data as { vmid: number; server_id: number; status: string };
qc.setQueryData<VMInstance[]>(vmKeys.all, (old) =>
old?.map((vm) =>
@@ -42,10 +46,56 @@ export function useVMStatusSync() {
)
);
});
return () => unsub();
const unsubDeleted = ws.onType('vm_deleted', (data: unknown) => {
const msg = data as { vmid: number; server_id: number };
qc.setQueryData<VMInstance[]>(vmKeys.all, (old) =>
old?.filter((vm) => !(vm.server_id === msg.server_id && vm.vmid === msg.vmid))
);
// Also invalidate any per-VM detail queries so stale pages refresh
qc.invalidateQueries({ queryKey: vmKeys.resourcesAll });
});
const unsubCreated = ws.onType('vm_created', () => {
// A new VM appeared on Proxmox; refetch the full list
qc.invalidateQueries({ queryKey: vmKeys.all });
qc.invalidateQueries({ queryKey: vmKeys.resourcesAll });
});
return () => {
unsubStatus();
unsubDeleted();
unsubCreated();
};
}, [qc]);
}
export type LiveInstanceMetrics = {
server_id: number; vmid: number; node?: string; status?: string;
cpu?: number; mem?: number; maxmem?: number; disk?: number; maxdisk?: number;
uptime?: number; netin?: number; netout?: number;
};
/**
* Subscribes to live VM/LXC metrics via WebSocket channel `instances_list_metrics`.
* Returns a Map keyed by `"server_id:vmid"` that updates every ~1 s.
* Uses its own useState — bypasses TanStack Query structural sharing entirely.
*/
export function useInstancesMetricsSync(): Map<string, LiveInstanceMetrics> {
const [metricsMap, setMetricsMap] = useState(() => new Map<string, LiveInstanceMetrics>());
useEffect(() => {
const ws = getTasksWebSocket();
ws.connect();
const apply = (data: unknown) => {
const items = (data as { data?: { items?: LiveInstanceMetrics[] } })?.data?.items;
if (!items?.length) return;
const next = new Map<string, LiveInstanceMetrics>();
for (const it of items) next.set(`${it.server_id}:${it.vmid}`, it);
setMetricsMap(next);
};
const unsub = ws.subscribe('instances_list_metrics', apply);
return () => unsub();
}, []);
return metricsMap;
}
export function useAllResources() {
return useQuery({
queryKey: vmKeys.resourcesAll,

View File

@@ -99,6 +99,13 @@ export function useDeleteServer() {
export function useTestServer() {
return useMutation({
mutationFn: (id: number) => apiClient.post<{ success: boolean; message?: string }>(`/proxmox/api/servers/${id}/test`, {}),
mutationFn: (id: number) => apiClient.post<{ success: boolean; status?: string; message?: string }>(`/proxmox/api/servers/${id}/test`, {}),
});
}
export function useTestServerCredentials() {
return useMutation({
mutationFn: (data: ProxmoxServerCreate) =>
apiClient.post<{ success: boolean; status?: string; message?: string }>(`/proxmox/api/servers/test`, data),
});
}

View File

@@ -0,0 +1,132 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
export interface SSHKey {
id: number;
user_id: number;
name: string;
public_key: string;
fingerprint: string | null;
comment: string | null;
has_private_key: boolean;
created_at: string;
}
export interface CreateSSHKeyPayload {
name: string;
public_key: string;
private_key?: string;
comment?: string;
}
export interface UpdateSSHKeyPayload {
name?: string;
public_key?: string;
private_key?: string;
comment?: string;
}
export const sshKeyKeys = {
my: ['ssh-keys', 'my'] as const,
byUser: (userId: number) => ['ssh-keys', 'user', userId] as const,
};
// ── Self-service ────────────────────────────────────────────────────────────
export function useMySSHKeys() {
return useQuery({
queryKey: sshKeyKeys.my,
queryFn: () => apiClient.get<SSHKey[]>('/api/ssh-keys/my'),
});
}
export function useCreateMySSHKey() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: CreateSSHKeyPayload) =>
apiClient.post<SSHKey>('/api/ssh-keys/my', data),
onSuccess: () => qc.invalidateQueries({ queryKey: sshKeyKeys.my }),
});
}
export function useUpdateMySSHKey() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: number; data: UpdateSSHKeyPayload }) =>
apiClient.put<SSHKey>(`/api/ssh-keys/my/${id}`, data),
onSuccess: () => qc.invalidateQueries({ queryKey: sshKeyKeys.my }),
});
}
export function useDeleteMySSHKey() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) =>
apiClient.delete<{ success: boolean; message: string }>(`/api/ssh-keys/my/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: sshKeyKeys.my }),
});
}
export function useDownloadMyPrivateKey() {
return useMutation({
mutationFn: (id: number) =>
apiClient.get<{ id: number; name: string; private_key: string }>(
`/api/ssh-keys/my/${id}/private`,
),
});
}
// ── Admin: per-user ─────────────────────────────────────────────────────────
export function useUserSSHKeys(userId: number | null | undefined) {
return useQuery({
queryKey: userId ? sshKeyKeys.byUser(userId) : ['ssh-keys', 'user', 'none'],
queryFn: () =>
apiClient.get<SSHKey[]>(`/api/ssh-keys/users/${userId}`),
enabled: !!userId,
});
}
export function useAdminCreateUserSSHKey() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ userId, data }: { userId: number; data: CreateSSHKeyPayload }) =>
apiClient.post<SSHKey>(`/api/ssh-keys/users/${userId}`, data),
onSuccess: (_data, vars) => {
qc.invalidateQueries({ queryKey: sshKeyKeys.byUser(vars.userId) });
},
});
}
export function useAdminUpdateUserSSHKey() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ userId, keyId, data }: { userId: number; keyId: number; data: UpdateSSHKeyPayload }) =>
apiClient.put<SSHKey>(`/api/ssh-keys/users/${userId}/${keyId}`, data),
onSuccess: (_data, vars) => {
qc.invalidateQueries({ queryKey: sshKeyKeys.byUser(vars.userId) });
},
});
}
export function useAdminDeleteUserSSHKey() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ userId, keyId }: { userId: number; keyId: number }) =>
apiClient.delete<{ success: boolean; message: string }>(
`/api/ssh-keys/users/${userId}/${keyId}`,
),
onSuccess: (_data, vars) => {
qc.invalidateQueries({ queryKey: sshKeyKeys.byUser(vars.userId) });
},
});
}
export function useAdminDownloadUserPrivateKey() {
return useMutation({
mutationFn: ({ userId, keyId }: { userId: number; keyId: number }) =>
apiClient.get<{ id: number; name: string; private_key: string }>(
`/api/ssh-keys/users/${userId}/${keyId}/private`,
),
});
}

View File

@@ -4,7 +4,7 @@ import type { OSTemplate, OSTemplateGroup } from '@/types';
export const templateKeys = {
groups: ['template-groups'] as const,
templates: (groupId?: number, serverId?: number) => ['templates', groupId, serverId] as const,
templates: (groupId?: number, serverId?: number, vmType?: string) => ['templates', groupId, serverId, vmType] as const,
template: (id: number) => ['template', id] as const,
discover: (serverId: number) => ['template-discover', serverId] as const,
};
@@ -16,13 +16,14 @@ export function useTemplateGroups() {
});
}
export function useTemplates(groupId?: number, serverId?: number) {
export function useTemplates(groupId?: number, serverId?: number, vmType?: string) {
const params = new URLSearchParams();
if (groupId) params.set('group_id', String(groupId));
if (serverId) params.set('server_id', String(serverId));
if (vmType) params.set('vm_type', vmType);
const qs = params.toString();
return useQuery({
queryKey: templateKeys.templates(groupId, serverId),
queryKey: templateKeys.templates(groupId, serverId, vmType),
queryFn: () => apiClient.get<OSTemplate[]>(`/templates/api/templates${qs ? `?${qs}` : ''}`),
});
}

View File

@@ -5,6 +5,7 @@ import type { User } from '@/types';
export const userKeys = {
users: ['users'] as const,
user: (id: number) => ['user', id] as const,
userServers: (id: number) => ['user-server-assignments', id] as const,
roles: ['roles'] as const,
permissions: ['permissions-v2'] as const,
sessions: ['sessions'] as const,
@@ -13,17 +14,80 @@ export const userKeys = {
securityEvents: ['security-events'] as const,
};
interface Role {
export interface Role {
id: number;
name: string;
display_name: string;
description?: string;
permissions: string[];
permissions: Record<string, boolean>;
is_system: boolean;
is_active: boolean;
user_count: number;
created_at?: string;
}
export interface SessionInfo {
id: number;
user_id: number;
username: string;
ip_address?: string;
device_info?: string;
created_at?: string;
last_activity?: string;
expires_at?: string;
}
export interface BlockedIp {
id: number;
ip_address: string;
reason?: string;
blocked_by?: string;
blocked_at?: string;
expires_at?: string;
is_permanent: boolean;
attempts_count?: number;
is_active: boolean;
}
export interface SecuritySettings {
max_login_attempts: number;
lockout_duration_minutes: number;
session_timeout_minutes: number;
ip_block_threshold: number;
ip_block_duration_minutes: number;
password_min_length: number;
password_require_uppercase: boolean;
password_require_lowercase: boolean;
password_require_numbers: boolean;
password_require_special: boolean;
}
export interface SecurityEvent {
id: number;
event_type: string;
ip_address?: string;
username?: string;
message?: string;
created_at?: string;
}
export interface ServerAssignment {
id: number;
name: string;
ip_address: string;
is_online: boolean;
workspaces: { id: number; name: string }[];
compatible: boolean;
assigned: boolean;
}
export interface UserServerAssignmentsResponse {
servers: ServerAssignment[];
user_workspaces: { id: number; name: string }[];
}
// ==================== Users ====================
export function useUsers() {
return useQuery({
queryKey: userKeys.users,
@@ -39,21 +103,43 @@ export function useUser(userId: number) {
});
}
export interface CreateUserPayload {
username: string;
email: string;
password: string;
full_name?: string;
role_id?: number | null;
is_active?: boolean;
is_admin?: boolean;
}
export function useCreateUser() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { username: string; email: string; password: string; full_name?: string; role_id?: number; is_active?: boolean; is_admin?: boolean }) =>
apiClient.post<User>('/admin/api/users', data),
mutationFn: (data: CreateUserPayload) => apiClient.post<User>('/admin/api/users', data),
onSuccess: () => qc.invalidateQueries({ queryKey: userKeys.users }),
});
}
export interface UpdateUserPayload {
email?: string;
full_name?: string;
password?: string;
role_id?: number | null;
is_active?: boolean;
is_admin?: boolean;
require_password_change?: boolean;
}
export function useUpdateUser() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, ...data }: { id: number; email?: string; full_name?: string; role_id?: number; is_active?: boolean; is_admin?: boolean }) =>
mutationFn: ({ id, ...data }: { id: number } & UpdateUserPayload) =>
apiClient.put<User>(`/admin/api/users/${id}`, data),
onSuccess: () => qc.invalidateQueries({ queryKey: userKeys.users }),
onSuccess: (_, vars) => {
qc.invalidateQueries({ queryKey: userKeys.users });
qc.invalidateQueries({ queryKey: userKeys.user(vars.id) });
},
});
}
@@ -68,8 +154,10 @@ export function useDeleteUser() {
export function useResetPassword() {
const qc = useQueryClient();
return useMutation({
mutationFn: (userId: number) =>
apiClient.post<{ message: string }>(`/admin/api/users/${userId}/reset-password`, {}),
mutationFn: ({ userId, newPassword }: { userId: number; newPassword: string }) =>
apiClient.post<{ message: string }>(`/admin/api/users/${userId}/reset-password`, {
new_password: newPassword,
}),
onSuccess: () => qc.invalidateQueries({ queryKey: userKeys.users }),
});
}
@@ -77,11 +165,51 @@ export function useResetPassword() {
export function useUnlockUser() {
const qc = useQueryClient();
return useMutation({
mutationFn: (userId: number) => apiClient.post<{ message: string }>(`/admin/api/users/${userId}/unlock`),
mutationFn: (userId: number) =>
apiClient.post<{ message: string }>(`/admin/api/users/${userId}/unlock`),
onSuccess: () => qc.invalidateQueries({ queryKey: userKeys.users }),
});
}
export function useTerminateUserSessions() {
const qc = useQueryClient();
return useMutation({
mutationFn: (userId: number) =>
apiClient.post<{ message: string }>(`/admin/api/users/${userId}/terminate-sessions`),
onSuccess: () => qc.invalidateQueries({ queryKey: userKeys.sessions }),
});
}
// ==================== User → Servers ====================
export function useUserServerAssignments(userId: number) {
return useQuery({
queryKey: userKeys.userServers(userId),
queryFn: () =>
apiClient.get<UserServerAssignmentsResponse>(
`/admin/api/users/${userId}/server-assignments`
),
enabled: userId > 0,
});
}
export function useSetUserServers() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ userId, serverIds }: { userId: number; serverIds: number[] }) =>
apiClient.put<{ message: string; server_ids: number[] }>(
`/admin/api/users/${userId}/servers`,
{ server_ids: serverIds }
),
onSuccess: (_, vars) => {
qc.invalidateQueries({ queryKey: userKeys.userServers(vars.userId) });
qc.invalidateQueries({ queryKey: userKeys.users });
},
});
}
// ==================== Roles ====================
export function useRoles() {
return useQuery({
queryKey: userKeys.roles,
@@ -92,8 +220,12 @@ export function useRoles() {
export function useCreateRole() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { name: string; display_name: string; description?: string; permissions: string[] }) =>
apiClient.post<Role>('/admin/api/roles', data),
mutationFn: (data: {
name: string;
display_name: string;
description?: string;
permissions: Record<string, boolean>;
}) => apiClient.post<Role>('/admin/api/roles', data),
onSuccess: () => qc.invalidateQueries({ queryKey: userKeys.roles }),
});
}
@@ -101,8 +233,16 @@ export function useCreateRole() {
export function useUpdateRole() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, ...data }: { id: number; display_name?: string; description?: string; permissions?: string[]; is_active?: boolean }) =>
apiClient.put<Role>(`/admin/api/roles/${id}`, data),
mutationFn: ({
id,
...data
}: {
id: number;
display_name?: string;
description?: string;
permissions?: Record<string, boolean>;
is_active?: boolean;
}) => apiClient.put<Role>(`/admin/api/roles/${id}`, data),
onSuccess: () => qc.invalidateQueries({ queryKey: userKeys.roles }),
});
}
@@ -118,15 +258,18 @@ export function useDeleteRole() {
export function usePermissions() {
return useQuery({
queryKey: userKeys.permissions,
queryFn: () => apiClient.get<Record<string, unknown>>('/admin/api/permissions/v2'),
queryFn: () =>
apiClient.get<Record<string, Record<string, string>>>('/admin/api/permissions/v2'),
staleTime: Infinity,
});
}
// ==================== Sessions ====================
export function useSessions() {
return useQuery({
queryKey: userKeys.sessions,
queryFn: () => apiClient.get<any[]>('/admin/api/sessions'),
queryFn: () => apiClient.get<SessionInfo[]>('/admin/api/sessions'),
refetchInterval: 15000,
});
}
@@ -134,7 +277,70 @@ export function useSessions() {
export function useTerminateSession() {
const qc = useQueryClient();
return useMutation({
mutationFn: (sessionId: string) => apiClient.delete(`/admin/api/sessions/${sessionId}`),
mutationFn: (sessionId: number) => apiClient.delete(`/admin/api/sessions/${sessionId}`),
onSuccess: () => qc.invalidateQueries({ queryKey: userKeys.sessions }),
});
}
export function useTerminateAllSessions() {
const qc = useQueryClient();
return useMutation({
mutationFn: () => apiClient.post<{ message: string }>('/admin/api/sessions/terminate-all'),
onSuccess: () => qc.invalidateQueries({ queryKey: userKeys.sessions }),
});
}
// ==================== Security ====================
export function useSecuritySettings() {
return useQuery({
queryKey: userKeys.securitySettings,
queryFn: () => apiClient.get<SecuritySettings>('/admin/api/security/settings'),
});
}
export function useUpdateSecuritySettings() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: Partial<SecuritySettings>) =>
apiClient.put<{ message: string; updated: string[] }>(
'/admin/api/security/settings',
data
),
onSuccess: () => qc.invalidateQueries({ queryKey: userKeys.securitySettings }),
});
}
export function useBlockedIps() {
return useQuery({
queryKey: userKeys.blockedIps,
queryFn: () => apiClient.get<BlockedIp[]>('/admin/api/security/blocked-ips'),
refetchInterval: 30000,
});
}
export function useBlockIp() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { ip_address: string; reason?: string; duration_minutes?: number }) =>
apiClient.post<{ message: string; id: number }>('/admin/api/security/blocked-ips', data),
onSuccess: () => qc.invalidateQueries({ queryKey: userKeys.blockedIps }),
});
}
export function useUnblockIp() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => apiClient.delete(`/admin/api/security/blocked-ips/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: userKeys.blockedIps }),
});
}
export function useSecurityEvents(limit = 50) {
return useQuery({
queryKey: [...userKeys.securityEvents, limit],
queryFn: () =>
apiClient.get<SecurityEvent[]>(`/admin/api/security/events?limit=${limit}`),
refetchInterval: 30000,
});
}

View File

@@ -19,6 +19,7 @@ import {
Languages,
ChevronDown,
Building2,
Check,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
@@ -208,7 +209,7 @@ function WorkspaceSwitcher() {
<FolderKanban className="mr-2 h-3.5 w-3.5" />
<span className="truncate">{w.name}</span>
{w.id === activeWorkspaceId && (
<span className="ml-auto text-primary">✓</span>
<Check className="ml-auto h-3.5 w-3.5 text-primary" />
)}
</DropdownMenuItem>
))}

View File

@@ -146,8 +146,11 @@ class ApiClient {
});
}
delete<T>(path: string) {
return this.request<T>(path, { method: 'DELETE' });
delete<T>(path: string, body?: unknown) {
return this.request<T>(path, {
method: 'DELETE',
body: body ? JSON.stringify(body) : undefined,
});
}
}

View File

@@ -35,7 +35,8 @@ export class WebSocketManager {
}
connect() {
if (this.ws?.readyState === WebSocket.OPEN) return;
// Don't create a new socket if one is already open or connecting
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) return;
const token = localStorage.getItem('access_token');
const separator = this.options.url.includes('?') ? '&' : '?';
@@ -49,6 +50,13 @@ export class WebSocketManager {
this._isConnected = true;
this.reconnectAttempts = 0;
this.startPing();
// Re-send subscribe messages for all active channels (handles initial
// subscribe-before-open and reconnect cases).
for (const channel of this.subscribers.keys()) {
try {
this.ws?.send(JSON.stringify({ type: 'subscribe', channel }));
} catch { /* ignore */ }
}
this.options.onOpen?.();
};
@@ -107,15 +115,20 @@ export class WebSocketManager {
subscribe(channel: string, handler: MessageHandler) {
if (!this.subscribers.has(channel)) {
this.subscribers.set(channel, new Set());
// Only send a subscribe message the first time someone subscribes to
// this channel. If WS is not OPEN yet, onopen() will replay it.
this.send({ type: 'subscribe', channel });
}
this.subscribers.get(channel)!.add(handler);
// Send subscribe message
this.send({ type: 'subscribe', channel });
// Return unsubscribe function
return () => {
this.subscribers.get(channel)?.delete(handler);
const handlers = this.subscribers.get(channel);
handlers?.delete(handler);
if (handlers && handlers.size === 0) {
this.subscribers.delete(channel);
this.send({ type: 'unsubscribe', channel });
}
};
}

View File

@@ -40,6 +40,8 @@
"common": {
"loading": "Loading...",
"save": "Save",
"saved": "Saved",
"optional": "optional",
"cancel": "Cancel",
"delete": "Delete",
"edit": "Edit",
@@ -172,6 +174,10 @@
"network": "Network",
"password": "Password",
"ssh_keys": "SSH Keys",
"ssh_keys_select": "Select saved SSH keys",
"ssh_keys_owner": "Instance owner",
"ssh_keys_owner_self": "Me (current user)",
"no_saved_keys": "No saved SSH keys",
"deploy": "Deploy",
"deploy_queued": "Deploy task for \"{{name}}\" has been queued",
"deploy_success": "Instance \"{{name}}\" deployed successfully",
@@ -234,6 +240,10 @@
"search": "Search templates...",
"all_groups": "All groups",
"all_servers": "All servers",
"all_types": "All types",
"type_kvm": "KVM",
"type_lxc": "LXC",
"file_template": "File template",
"auto_import": "Auto Import",
"importing": "Importing...",
"no_templates": "No templates found",
@@ -268,10 +278,57 @@
"select_server": "Select server",
"select_storage": "Select storage",
"node": "Node",
"all_nodes": "All nodes",
"select_server_first": "Select a server to view backups",
"disable": "Disable",
"enable": "Enable",
"job_started": "Backup job started"
"job_started": "Backup job started",
"type": "Type",
"restore_title": "Restore from backup",
"restore_warning": "Restoring will overwrite the existing VM/container with this VMID. Provide a new VMID to restore as a new guest.",
"new_vmid_optional": "New VMID (optional)",
"new_vmid_hint": "Leave empty to restore with the original VMID (existing guest will be overwritten).",
"start_after_restore": "Start after restore",
"unique_mac": "Generate unique MAC addresses",
"restore_started": "Restore started",
"restore_failed": "Failed to start restore",
"node_required": "Could not determine node for the operation",
"invalid_vmid": "Invalid VMID",
"delete_title": "Delete backup",
"delete_confirm": "The backup file will be permanently deleted. Continue?",
"delete_success": "Backup deleted",
"delete_failed": "Failed to delete backup",
"create_job": "Create schedule",
"edit_job": "Edit schedule",
"job_created": "Schedule created",
"job_updated": "Schedule updated",
"job_deleted": "Schedule deleted",
"job_save_failed": "Failed to save schedule",
"job_delete_failed": "Failed to delete schedule",
"delete_job_title": "Delete schedule?",
"delete_job_confirm": "The schedule will be removed from the planner. Existing backup files are kept.",
"vmids_required": "Specify at least one VMID",
"vmids_hint": "Comma-separated VMIDs (e.g. 100, 101) or 'all' for every VM/CT.",
"all_vms": "All VMs/CTs",
"all_vms_selected": "All VMs/CTs on the node selected",
"selected_count": "Selected: {{count}}",
"select_all_visible": "Select all visible",
"clear_selection": "Clear",
"pick_server_node_first": "Select a server and node first",
"no_vms_found": "No VMs/CTs found",
"cron_required": "Specify a cron expression",
"cron_hint": "Format: minute hour day month weekday. Example: '0 2 * * *' — daily at 02:00.",
"mode": "Mode",
"compress": "Compression",
"none": "none",
"preset": "Preset",
"cron": {
"daily_2am": "Daily at 02:00",
"daily_midnight": "Daily at midnight",
"every_6h": "Every 6 hours",
"weekly_sunday": "Weekly (Sunday 03:00)",
"monthly_1st": "Monthly (1st at 04:00)"
}
},
"ipam": {
"dashboard": "IPAM Dashboard",
@@ -333,24 +390,95 @@
"update_available": "Update available",
"up_to_date": "You are up to date"
},
"ssh_keys": {
"title": "SSH Keys",
"add": "Add SSH key",
"edit": "Edit SSH key",
"name": "Name",
"public_key": "Public key",
"private_key": "Private key",
"private_key_hint": "The private key is stored encrypted. Used only on explicit download.",
"private_key_replace_hint": "Leave empty to keep the current value",
"comment": "Comment",
"fingerprint": "Fingerprint",
"has_private": "Has private",
"no_private": "Public only",
"download_private": "Download private key",
"copy_public": "Copy public key",
"copied": "Copied",
"copy_failed": "Copy failed",
"created": "SSH key added",
"deleted": "SSH key deleted",
"confirm_delete": "Delete this SSH key?",
"empty": "No saved SSH keys",
"manage_for_user": "User SSH keys"
},
"users": {
"users": "Users",
"roles": "Roles",
"sessions": "Sessions",
"security": "Security",
"create": "Create User",
"edit": "Edit User",
"create_role": "Create Role",
"edit_role": "Edit Role",
"role": "Role",
"no_role": "No role",
"last_login": "Last Login",
"active": "Active",
"inactive": "Inactive",
"locked": "Locked",
"is_active": "Active",
"is_admin": "Administrator",
"require_password_change": "Require password change",
"reset_password": "Reset Password",
"new_password": "New password",
"unlock": "Unlock",
"manage_servers": "Assigned Servers",
"manage_ssh_keys": "Manage SSH Keys",
"assigned_servers": "Assigned Servers",
"compatible_servers": "Compatible Servers",
"incompatible": "Incompatible",
"failed_attempts": "Failed Attempts",
"terminate_sessions": "Terminate Sessions",
"terminate_all_sessions": "Terminate All Sessions",
"permissions_count": "Permissions",
"permissions": "Permissions",
"select_all": "Select all",
"clear_all": "Clear all",
"system_role": "System",
"users_count": "Users",
"active_sessions": "Active Sessions",
"user_agent": "User Agent",
"device": "Device",
"last_activity": "Last Activity",
"created": "Created",
"expires": "Expires"
"expires": "Expires",
"security_settings": "Security Settings",
"max_login_attempts": "Max Login Attempts",
"lockout_duration": "Lockout Duration (min)",
"session_timeout": "Session Timeout (min)",
"ip_block_threshold": "IP Block Threshold",
"ip_block_duration": "IP Block Duration (min)",
"password_min_length": "Min Password Length",
"password_require_uppercase": "Uppercase letter",
"password_require_lowercase": "Lowercase letter",
"password_require_numbers": "Number",
"password_require_special": "Special character",
"blocked_ips": "Blocked IPs",
"block_ip": "Block IP",
"ip_address": "IP address",
"reason": "Reason",
"duration_minutes": "Duration (min, empty = permanent)",
"permanent": "Permanent",
"blocked_at": "Blocked at",
"expires_at": "Expires",
"unblock": "Unblock",
"security_events": "Security Events",
"event_type": "Event Type",
"workspaces": "Workspaces",
"no_workspaces": "No workspaces",
"workspace_required": "User must belong to the server's workspace"
},
"workspaces": {
"create": "Create Workspace",

View File

@@ -40,6 +40,8 @@
"common": {
"loading": "Загрузка...",
"save": "Сохранить",
"saved": "Сохранено",
"optional": "необязательно",
"cancel": "Отмена",
"delete": "Удалить",
"edit": "Редактировать",
@@ -172,6 +174,10 @@
"network": "Сеть",
"password": "Пароль",
"ssh_keys": "SSH ключи",
"ssh_keys_select": "Выбрать сохранённые SSH ключи",
"ssh_keys_owner": "Владелец инстанса",
"ssh_keys_owner_self": "Я (текущий пользователь)",
"no_saved_keys": "Нет сохранённых SSH ключей",
"deploy": "Развернуть",
"deploy_queued": "Задача создания \"{{name}}\" добавлена в очередь",
"deploy_success": "Инстанс \"{{name}}\" успешно развёрнут",
@@ -234,6 +240,10 @@
"search": "Поиск шаблонов...",
"all_groups": "Все группы",
"all_servers": "Все серверы",
"all_types": "Все типы",
"type_kvm": "KVM",
"type_lxc": "LXC",
"file_template": "Файл-шаблон",
"auto_import": "Авто-импорт",
"importing": "Импорт...",
"no_templates": "Шаблоны не найдены",
@@ -268,10 +278,57 @@
"select_server": "Выберите сервер",
"select_storage": "Выберите хранилище",
"node": "Узел",
"all_nodes": "Все узлы",
"select_server_first": "Выберите сервер для просмотра бэкапов",
"disable": "Отключить",
"enable": "Включить",
"job_started": "Задание бэкапа запущено"
"job_started": "Задание бэкапа запущено",
"type": "Тип",
"restore_title": "Восстановить из бэкапа",
"restore_warning": "Восстановление перезапишет существующую VM/контейнер с этим VMID. Укажите новый VMID, чтобы восстановить как новый.",
"new_vmid_optional": "Новый VMID (необязательно)",
"new_vmid_hint": "Оставьте пустым, чтобы восстановить с исходным VMID (текущая VM будет перезаписана).",
"start_after_restore": "Запустить после восстановления",
"unique_mac": "Сгенерировать уникальные MAC-адреса",
"restore_started": "Восстановление запущено",
"restore_failed": "Не удалось запустить восстановление",
"node_required": "Не удалось определить узел для операции",
"invalid_vmid": "Некорректный VMID",
"delete_title": "Удалить бэкап",
"delete_confirm": "Файл бэкапа будет удалён без возможности восстановления. Продолжить?",
"delete_success": "Бэкап удалён",
"delete_failed": "Не удалось удалить бэкап",
"create_job": "Создать расписание",
"edit_job": "Редактировать расписание",
"job_created": "Расписание создано",
"job_updated": "Расписание обновлено",
"job_deleted": "Расписание удалено",
"job_save_failed": "Не удалось сохранить расписание",
"job_delete_failed": "Не удалось удалить расписание",
"delete_job_title": "Удалить расписание?",
"delete_job_confirm": "Расписание будет удалено и снято с планировщика. Файлы бэкапов останутся.",
"vmids_required": "Укажите хотя бы один VMID",
"vmids_hint": "Список VMID через запятую (напр. 100, 101) или слово all для всех VM/CT.",
"all_vms": "Все VM/CT",
"all_vms_selected": "Выбраны все VM/CT на узле",
"selected_count": "Выбрано: {{count}}",
"select_all_visible": "Выбрать все видимые",
"clear_selection": "Очистить",
"pick_server_node_first": "Сначала выберите сервер и узел",
"no_vms_found": "VM/CT не найдены",
"cron_required": "Укажите cron-выражение",
"cron_hint": "Формат: минута час день месяц день-недели. Пример: '0 2 * * *' ежедневно в 02:00.",
"mode": "Режим",
"compress": "Сжатие",
"none": "без сжатия",
"preset": "Пресет",
"cron": {
"daily_2am": "Ежедневно в 02:00",
"daily_midnight": "Ежедневно в полночь",
"every_6h": "Каждые 6 часов",
"weekly_sunday": "Еженедельно (воскресенье 03:00)",
"monthly_1st": "Ежемесячно (1-е число 04:00)"
}
},
"ipam": {
"dashboard": "IPAM Дашборд",
@@ -333,24 +390,95 @@
"update_available": "Доступно обновление",
"up_to_date": "Установлена последняя версия"
},
"ssh_keys": {
"title": "SSH ключи",
"add": "Добавить SSH ключ",
"edit": "Редактировать SSH ключ",
"name": "Название",
"public_key": "Публичный ключ",
"private_key": "Приватный ключ",
"private_key_hint": "Приватный ключ хранится зашифрованно. Используется только при явной загрузке.",
"private_key_replace_hint": "Оставьте пустым, чтобы не менять",
"comment": "Комментарий",
"fingerprint": "Отпечаток",
"has_private": "С приватным",
"no_private": "Только публичный",
"download_private": "Скачать приватный ключ",
"copy_public": "Копировать публичный ключ",
"copied": "Скопировано",
"copy_failed": "Не удалось скопировать",
"created": "SSH ключ добавлен",
"deleted": "SSH ключ удалён",
"confirm_delete": "Удалить этот SSH ключ?",
"empty": "Нет сохранённых SSH ключей",
"manage_for_user": "SSH ключи пользователя"
},
"users": {
"users": "Пользователи",
"roles": "Роли",
"sessions": "Сессии",
"security": "Безопасность",
"create": "Создать пользователя",
"edit": "Редактировать пользователя",
"create_role": "Создать роль",
"edit_role": "Редактировать роль",
"role": "Роль",
"no_role": "Без роли",
"last_login": "Последний вход",
"active": "Активен",
"inactive": "Неактивен",
"locked": "Заблокирован",
"is_active": "Активен",
"is_admin": "Администратор",
"require_password_change": "Требовать смену пароля",
"reset_password": "Сбросить пароль",
"new_password": "Новый пароль",
"unlock": "Разблокировать",
"manage_servers": "Назначенные серверы",
"manage_ssh_keys": "Управление SSH ключами",
"assigned_servers": "Назначенные серверы",
"compatible_servers": "Совместимые серверы",
"incompatible": "Несовместим",
"failed_attempts": "Неуд. попыток",
"terminate_sessions": "Завершить сессии",
"terminate_all_sessions": "Завершить все сессии",
"permissions_count": "Разрешения",
"permissions": "Разрешения",
"select_all": "Выбрать все",
"clear_all": "Снять все",
"system_role": "Системная",
"users_count": "Пользователей",
"active_sessions": "Активные сессии",
"user_agent": "User Agent",
"device": "Устройство",
"last_activity": "Активность",
"created": "Создано",
"expires": "Истекает"
"expires": "Истекает",
"security_settings": "Настройки безопасности",
"max_login_attempts": "Макс. попыток входа",
"lockout_duration": "Блокировка (мин)",
"session_timeout": "Таймаут сессии (мин)",
"ip_block_threshold": "Порог блокировки IP",
"ip_block_duration": "Блокировка IP (мин)",
"password_min_length": "Мин. длина пароля",
"password_require_uppercase": "Заглавная буква",
"password_require_lowercase": "Строчная буква",
"password_require_numbers": "Цифра",
"password_require_special": "Спецсимвол",
"blocked_ips": "Заблокированные IP",
"block_ip": "Заблокировать IP",
"ip_address": "IP-адрес",
"reason": "Причина",
"duration_minutes": "Длительность (мин, пусто = навсегда)",
"permanent": "Навсегда",
"blocked_at": "Заблокирован",
"expires_at": "Истекает",
"unblock": "Разблокировать",
"security_events": "События безопасности",
"event_type": "Тип события",
"workspaces": "Рабочие области",
"no_workspaces": "Нет рабочих областей",
"workspace_required": "Пользователь должен принадлежать рабочей области сервера"
},
"workspaces": {
"create": "Создать рабочую область",

View File

@@ -9,9 +9,12 @@ export interface User {
is_admin: boolean;
created_at: string;
last_login?: string;
role_id?: number;
role_id?: number | null;
role_name?: string;
is_locked?: boolean;
failed_login_attempts?: number;
locked_until?: string | null;
require_password_change?: boolean;
permissions?: string[];
}
@@ -120,6 +123,8 @@ export interface VMDeployRequest {
cloud_init_user?: string;
cloud_init_password?: string;
ssh_keys?: string;
ssh_key_ids?: number[];
owner_id?: number;
}
// ==================== LXC CT Template Types ====================
@@ -150,6 +155,8 @@ export interface LXCDeployRequest {
ipam_pool_id?: number;
password?: string;
ssh_keys?: string;
ssh_key_ids?: number[];
owner_id?: number;
nameserver?: string;
searchdomain?: string;
unprivileged: boolean;
@@ -254,7 +261,9 @@ export interface OSTemplate {
group_id: number;
server_id: number;
name: string;
vmid: number;
vmid: number | null;
vm_type: 'qemu' | 'lxc';
volid?: string | null;
node: string;
default_cores: number;
default_memory: number;

View File

@@ -3,6 +3,8 @@ import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import path from 'path'
const backendUrl = process.env.BACKEND_URL ?? 'http://localhost:8000'
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
@@ -13,16 +15,16 @@ export default defineConfig({
server: {
proxy: {
'/api': {
target: 'http://localhost:8000',
target: backendUrl,
changeOrigin: true,
},
'/proxmox': {
target: 'http://localhost:8000',
target: backendUrl,
changeOrigin: true,
ws: true,
},
'/ws': {
target: 'http://localhost:8000',
target: backendUrl,
changeOrigin: true,
ws: true,
},