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
Some checks failed
Backend Tests / pytest (Python 3.12) (push) Has been cancelled
This commit is contained in:
@@ -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"}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 ====================
|
||||
|
||||
@@ -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
453
backend/app/api/ssh_keys.py
Normal 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()
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
26
compose.dev.yml
Normal 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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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')} />
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
286
frontend/src/features/settings/SSHKeysManager.tsx
Normal file
286
frontend/src/features/settings/SSHKeysManager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
132
frontend/src/hooks/use-ssh-keys.ts
Normal file
132
frontend/src/hooks/use-ssh-keys.ts
Normal 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`,
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -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}` : ''}`),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Создать рабочую область",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user