feat: add firewall, pools, access, disks, node admin and VM hardware management
Backend: new proxmox mixins and API routes for firewall, pools, access control, node admin and disks; extend storage and VM endpoints. Frontend: node managers (firewall, pool, access, disk, node admin), VM firewall and hardware tabs, PCI/USB device dialogs, related hooks and locale strings. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,11 @@ from .cluster import router as cluster_router
|
|||||||
from .networks import router as networks_router
|
from .networks import router as networks_router
|
||||||
from .lxc import router as lxc_router
|
from .lxc import router as lxc_router
|
||||||
from .images import router as images_router
|
from .images import router as images_router
|
||||||
|
from .firewall import router as firewall_router
|
||||||
|
from .pools import router as pools_router
|
||||||
|
from .disks import router as disks_router
|
||||||
|
from .access import router as access_router
|
||||||
|
from .node_admin import router as node_admin_router
|
||||||
from ._helpers import get_next_vmid, save_vm_instance, get_vm_instance
|
from ._helpers import get_next_vmid, save_vm_instance, get_vm_instance
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -22,3 +27,8 @@ router.include_router(cluster_router)
|
|||||||
router.include_router(networks_router)
|
router.include_router(networks_router)
|
||||||
router.include_router(lxc_router)
|
router.include_router(lxc_router)
|
||||||
router.include_router(images_router)
|
router.include_router(images_router)
|
||||||
|
router.include_router(firewall_router)
|
||||||
|
router.include_router(pools_router)
|
||||||
|
router.include_router(disks_router)
|
||||||
|
router.include_router(access_router)
|
||||||
|
router.include_router(node_admin_router)
|
||||||
|
|||||||
177
backend/app/api/proxmox/access.py
Normal file
177
backend/app/api/proxmox/access.py
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
from fastapi import APIRouter, Depends, Request, HTTPException
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from ...db import get_db
|
||||||
|
from ...models import ProxmoxServer, User
|
||||||
|
from ...auth import PermissionChecker
|
||||||
|
from ._helpers import _get_proxmox_client
|
||||||
|
from ...proxmox import _run_in_executor
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_client(db: Session, server_id: int):
|
||||||
|
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
|
||||||
|
if not server:
|
||||||
|
raise HTTPException(status_code=404, detail="Proxmox server not found")
|
||||||
|
client = _get_proxmox_client(server)
|
||||||
|
if not client.is_connected():
|
||||||
|
raise HTTPException(status_code=503, detail="Failed to connect to Proxmox server")
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
def _ok_or_400(result: dict):
|
||||||
|
if result.get("success"):
|
||||||
|
return JSONResponse(content=result)
|
||||||
|
raise HTTPException(status_code=400, detail=result.get("error", "Operation failed"))
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Realms ====================
|
||||||
|
|
||||||
|
@router.get("/api/servers/{server_id}/access/realms")
|
||||||
|
async def list_realms(
|
||||||
|
server_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(PermissionChecker("user:manage")),
|
||||||
|
):
|
||||||
|
client = _resolve_client(db, server_id)
|
||||||
|
try:
|
||||||
|
return JSONResponse(content={"realms": await _run_in_executor(client.get_realms)})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error listing realms: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/servers/{server_id}/access/realms")
|
||||||
|
async def create_realm(
|
||||||
|
server_id: int,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(PermissionChecker("user:manage")),
|
||||||
|
):
|
||||||
|
client = _resolve_client(db, server_id)
|
||||||
|
data = await request.json()
|
||||||
|
realm = data.pop("realm", None)
|
||||||
|
realm_type = data.pop("type", None)
|
||||||
|
if not realm or not realm_type:
|
||||||
|
raise HTTPException(status_code=400, detail="realm and type are required")
|
||||||
|
try:
|
||||||
|
return _ok_or_400(await _run_in_executor(lambda: client.create_realm(realm, realm_type, **data)))
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error creating realm: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/api/servers/{server_id}/access/realms/{realm}")
|
||||||
|
async def update_realm(
|
||||||
|
server_id: int,
|
||||||
|
realm: str,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(PermissionChecker("user:manage")),
|
||||||
|
):
|
||||||
|
client = _resolve_client(db, server_id)
|
||||||
|
data = await request.json()
|
||||||
|
try:
|
||||||
|
return _ok_or_400(await _run_in_executor(lambda: client.update_realm(realm, **data)))
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error updating realm {realm}: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/api/servers/{server_id}/access/realms/{realm}")
|
||||||
|
async def delete_realm(
|
||||||
|
server_id: int,
|
||||||
|
realm: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(PermissionChecker("user:manage")),
|
||||||
|
):
|
||||||
|
client = _resolve_client(db, server_id)
|
||||||
|
try:
|
||||||
|
return _ok_or_400(await _run_in_executor(lambda: client.delete_realm(realm)))
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error deleting realm {realm}: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Users & API tokens ====================
|
||||||
|
|
||||||
|
@router.get("/api/servers/{server_id}/access/users")
|
||||||
|
async def list_access_users(
|
||||||
|
server_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(PermissionChecker("user:manage")),
|
||||||
|
):
|
||||||
|
client = _resolve_client(db, server_id)
|
||||||
|
try:
|
||||||
|
return JSONResponse(content={"users": await _run_in_executor(client.get_access_users)})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error listing access users: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/servers/{server_id}/access/users/{userid}/tokens")
|
||||||
|
async def list_user_tokens(
|
||||||
|
server_id: int,
|
||||||
|
userid: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(PermissionChecker("user:manage")),
|
||||||
|
):
|
||||||
|
client = _resolve_client(db, server_id)
|
||||||
|
try:
|
||||||
|
return JSONResponse(content={"tokens": await _run_in_executor(lambda: client.get_user_tokens(userid))})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error listing tokens for {userid}: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/servers/{server_id}/access/users/{userid}/tokens/{tokenid}")
|
||||||
|
async def create_user_token(
|
||||||
|
server_id: int,
|
||||||
|
userid: str,
|
||||||
|
tokenid: str,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(PermissionChecker("user:manage")),
|
||||||
|
):
|
||||||
|
"""Создать API-токен. Секрет (value) возвращается один раз."""
|
||||||
|
client = _resolve_client(db, server_id)
|
||||||
|
data = await request.json()
|
||||||
|
try:
|
||||||
|
return _ok_or_400(await _run_in_executor(lambda: client.create_user_token(
|
||||||
|
userid, tokenid,
|
||||||
|
comment=data.get("comment"),
|
||||||
|
privsep=bool(data.get("privsep", True)),
|
||||||
|
expire=data.get("expire"),
|
||||||
|
)))
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error creating token for {userid}: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/api/servers/{server_id}/access/users/{userid}/tokens/{tokenid}")
|
||||||
|
async def delete_user_token(
|
||||||
|
server_id: int,
|
||||||
|
userid: str,
|
||||||
|
tokenid: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(PermissionChecker("user:manage")),
|
||||||
|
):
|
||||||
|
client = _resolve_client(db, server_id)
|
||||||
|
try:
|
||||||
|
return _ok_or_400(await _run_in_executor(lambda: client.delete_user_token(userid, tokenid)))
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error deleting token {tokenid} for {userid}: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
161
backend/app/api/proxmox/disks.py
Normal file
161
backend/app/api/proxmox/disks.py
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
from fastapi import APIRouter, Depends, Request, HTTPException, Query
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from ...db import get_db
|
||||||
|
from ...models import ProxmoxServer, User
|
||||||
|
from ...auth import PermissionChecker
|
||||||
|
from ._helpers import _get_proxmox_client
|
||||||
|
from ...proxmox import _run_in_executor
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_client(db: Session, server_id: int):
|
||||||
|
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
|
||||||
|
if not server:
|
||||||
|
raise HTTPException(status_code=404, detail="Proxmox server not found")
|
||||||
|
client = _get_proxmox_client(server)
|
||||||
|
if not client.is_connected():
|
||||||
|
raise HTTPException(status_code=503, detail="Failed to connect to Proxmox server")
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
def _ok_or_400(result: dict):
|
||||||
|
if result.get("success"):
|
||||||
|
return JSONResponse(content=result)
|
||||||
|
raise HTTPException(status_code=400, detail=result.get("error", "Operation failed"))
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Physical disks ====================
|
||||||
|
|
||||||
|
@router.get("/api/servers/{server_id}/nodes/{node}/disks")
|
||||||
|
async def list_disks(
|
||||||
|
server_id: int,
|
||||||
|
node: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(PermissionChecker("server:view")),
|
||||||
|
):
|
||||||
|
client = _resolve_client(db, server_id)
|
||||||
|
try:
|
||||||
|
return JSONResponse(content={"disks": await _run_in_executor(lambda: client.get_node_disks(node))})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error listing disks on {node}: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/servers/{server_id}/nodes/{node}/disks/smart")
|
||||||
|
async def disk_smart(
|
||||||
|
server_id: int,
|
||||||
|
node: str,
|
||||||
|
disk: str = Query(..., description="devpath, напр. /dev/sda"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(PermissionChecker("server:view")),
|
||||||
|
):
|
||||||
|
client = _resolve_client(db, server_id)
|
||||||
|
try:
|
||||||
|
return JSONResponse(content={"smart": await _run_in_executor(lambda: client.get_disk_smart(node, disk))})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting SMART for {disk} on {node}: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/api/servers/{server_id}/nodes/{node}/disks/wipe")
|
||||||
|
async def wipe_disk(
|
||||||
|
server_id: int,
|
||||||
|
node: str,
|
||||||
|
disk: str = Query(..., description="devpath, напр. /dev/sdb"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(PermissionChecker("server:manage")),
|
||||||
|
):
|
||||||
|
client = _resolve_client(db, server_id)
|
||||||
|
try:
|
||||||
|
return _ok_or_400(await _run_in_executor(lambda: client.wipe_disk(node, disk)))
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error wiping disk {disk} on {node}: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== ZFS pools ====================
|
||||||
|
|
||||||
|
@router.get("/api/servers/{server_id}/nodes/{node}/disks/zfs")
|
||||||
|
async def list_zfs_pools(
|
||||||
|
server_id: int,
|
||||||
|
node: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(PermissionChecker("server:view")),
|
||||||
|
):
|
||||||
|
client = _resolve_client(db, server_id)
|
||||||
|
try:
|
||||||
|
return JSONResponse(content={"pools": await _run_in_executor(lambda: client.get_zfs_pools(node))})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error listing ZFS pools on {node}: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/servers/{server_id}/nodes/{node}/disks/zfs/{name}")
|
||||||
|
async def get_zfs_pool(
|
||||||
|
server_id: int,
|
||||||
|
node: str,
|
||||||
|
name: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(PermissionChecker("server:view")),
|
||||||
|
):
|
||||||
|
client = _resolve_client(db, server_id)
|
||||||
|
try:
|
||||||
|
return JSONResponse(content={"pool": await _run_in_executor(lambda: client.get_zfs_pool(node, name))})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting ZFS pool {name} on {node}: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/servers/{server_id}/nodes/{node}/disks/zfs")
|
||||||
|
async def create_zfs_pool(
|
||||||
|
server_id: int,
|
||||||
|
node: str,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(PermissionChecker("server:manage")),
|
||||||
|
):
|
||||||
|
client = _resolve_client(db, server_id)
|
||||||
|
data = await request.json()
|
||||||
|
name = data.get("name")
|
||||||
|
devices = data.get("devices")
|
||||||
|
if not name or not devices:
|
||||||
|
raise HTTPException(status_code=400, detail="name and devices are required")
|
||||||
|
try:
|
||||||
|
return _ok_or_400(await _run_in_executor(lambda: client.create_zfs_pool(
|
||||||
|
node, name, devices,
|
||||||
|
raidlevel=data.get("raidlevel", "single"),
|
||||||
|
ashift=int(data.get("ashift", 12)),
|
||||||
|
compression=data.get("compression", "on"),
|
||||||
|
add_storage=bool(data.get("add_storage", True)),
|
||||||
|
)))
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error creating ZFS pool on {node}: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/api/servers/{server_id}/nodes/{node}/disks/zfs/{name}")
|
||||||
|
async def destroy_zfs_pool(
|
||||||
|
server_id: int,
|
||||||
|
node: str,
|
||||||
|
name: str,
|
||||||
|
cleanup_disks: bool = Query(False),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(PermissionChecker("server:manage")),
|
||||||
|
):
|
||||||
|
client = _resolve_client(db, server_id)
|
||||||
|
try:
|
||||||
|
return _ok_or_400(await _run_in_executor(lambda: client.destroy_zfs_pool(
|
||||||
|
node, name, cleanup_config=True, cleanup_disks=cleanup_disks)))
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error destroying ZFS pool {name} on {node}: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
753
backend/app/api/proxmox/firewall.py
Normal file
753
backend/app/api/proxmox/firewall.py
Normal file
@@ -0,0 +1,753 @@
|
|||||||
|
from fastapi import APIRouter, Depends, Request, HTTPException, Query
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from ...db import get_db
|
||||||
|
from ...models import ProxmoxServer, User
|
||||||
|
from ...auth import PermissionChecker
|
||||||
|
from ._helpers import _get_proxmox_client
|
||||||
|
from ...proxmox import _run_in_executor
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_client(db: Session, server_id: int):
|
||||||
|
"""Достать сервер и подключённого клиента (или 404/503)."""
|
||||||
|
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
|
||||||
|
if not server:
|
||||||
|
raise HTTPException(status_code=404, detail="Proxmox server not found")
|
||||||
|
client = _get_proxmox_client(server)
|
||||||
|
if not client.is_connected():
|
||||||
|
raise HTTPException(status_code=503, detail="Failed to connect to Proxmox server")
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
def _ok_or_400(result: dict):
|
||||||
|
"""Единый разбор ответа мутаций клиента ({success, error})."""
|
||||||
|
if result.get("success"):
|
||||||
|
return JSONResponse(content=result)
|
||||||
|
raise HTTPException(status_code=400, detail=result.get("error", "Operation failed"))
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Options ====================
|
||||||
|
|
||||||
|
@router.get("/api/servers/{server_id}/firewall/options")
|
||||||
|
async def get_firewall_options(
|
||||||
|
server_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(PermissionChecker("server:view")),
|
||||||
|
):
|
||||||
|
client = _resolve_client(db, server_id)
|
||||||
|
try:
|
||||||
|
return JSONResponse(content={"options": await _run_in_executor(client.get_cluster_firewall_options)})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting firewall options: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/api/servers/{server_id}/firewall/options")
|
||||||
|
async def update_firewall_options(
|
||||||
|
server_id: int,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(PermissionChecker("server:manage")),
|
||||||
|
):
|
||||||
|
client = _resolve_client(db, server_id)
|
||||||
|
data = await request.json()
|
||||||
|
try:
|
||||||
|
return _ok_or_400(await _run_in_executor(lambda: client.update_cluster_firewall_options(**data)))
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error updating firewall options: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Datacenter rules ====================
|
||||||
|
|
||||||
|
@router.get("/api/servers/{server_id}/firewall/rules")
|
||||||
|
async def get_firewall_rules(
|
||||||
|
server_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(PermissionChecker("server:view")),
|
||||||
|
):
|
||||||
|
client = _resolve_client(db, server_id)
|
||||||
|
try:
|
||||||
|
return JSONResponse(content={"rules": await _run_in_executor(client.get_cluster_firewall_rules)})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting firewall rules: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/servers/{server_id}/firewall/rules")
|
||||||
|
async def create_firewall_rule(
|
||||||
|
server_id: int,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(PermissionChecker("server:manage")),
|
||||||
|
):
|
||||||
|
client = _resolve_client(db, server_id)
|
||||||
|
data = await request.json()
|
||||||
|
try:
|
||||||
|
return _ok_or_400(await _run_in_executor(lambda: client.create_cluster_firewall_rule(**data)))
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error creating firewall rule: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/api/servers/{server_id}/firewall/rules/{pos}")
|
||||||
|
async def update_firewall_rule(
|
||||||
|
server_id: int,
|
||||||
|
pos: int,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(PermissionChecker("server:manage")),
|
||||||
|
):
|
||||||
|
client = _resolve_client(db, server_id)
|
||||||
|
data = await request.json()
|
||||||
|
try:
|
||||||
|
return _ok_or_400(await _run_in_executor(lambda: client.update_cluster_firewall_rule(pos, **data)))
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error updating firewall rule {pos}: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/api/servers/{server_id}/firewall/rules/{pos}")
|
||||||
|
async def delete_firewall_rule(
|
||||||
|
server_id: int,
|
||||||
|
pos: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(PermissionChecker("server:manage")),
|
||||||
|
):
|
||||||
|
client = _resolve_client(db, server_id)
|
||||||
|
try:
|
||||||
|
return _ok_or_400(await _run_in_executor(lambda: client.delete_cluster_firewall_rule(pos)))
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error deleting firewall rule {pos}: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Security groups ====================
|
||||||
|
|
||||||
|
@router.get("/api/servers/{server_id}/firewall/groups")
|
||||||
|
async def get_firewall_groups(
|
||||||
|
server_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(PermissionChecker("server:view")),
|
||||||
|
):
|
||||||
|
client = _resolve_client(db, server_id)
|
||||||
|
try:
|
||||||
|
return JSONResponse(content={"groups": await _run_in_executor(client.get_firewall_groups)})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting firewall groups: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/servers/{server_id}/firewall/groups")
|
||||||
|
async def create_firewall_group(
|
||||||
|
server_id: int,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(PermissionChecker("server:manage")),
|
||||||
|
):
|
||||||
|
client = _resolve_client(db, server_id)
|
||||||
|
data = await request.json()
|
||||||
|
group = data.get("group")
|
||||||
|
if not group:
|
||||||
|
raise HTTPException(status_code=400, detail="Group name is required")
|
||||||
|
try:
|
||||||
|
return _ok_or_400(await _run_in_executor(
|
||||||
|
lambda: client.create_firewall_group(group, data.get("comment"))))
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error creating firewall group: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/api/servers/{server_id}/firewall/groups/{group}")
|
||||||
|
async def delete_firewall_group(
|
||||||
|
server_id: int,
|
||||||
|
group: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(PermissionChecker("server:manage")),
|
||||||
|
):
|
||||||
|
client = _resolve_client(db, server_id)
|
||||||
|
try:
|
||||||
|
return _ok_or_400(await _run_in_executor(lambda: client.delete_firewall_group(group)))
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error deleting firewall group {group}: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/servers/{server_id}/firewall/groups/{group}/rules")
|
||||||
|
async def get_firewall_group_rules(
|
||||||
|
server_id: int,
|
||||||
|
group: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(PermissionChecker("server:view")),
|
||||||
|
):
|
||||||
|
client = _resolve_client(db, server_id)
|
||||||
|
try:
|
||||||
|
return JSONResponse(content={"rules": await _run_in_executor(lambda: client.get_firewall_group_rules(group))})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting rules for group {group}: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/servers/{server_id}/firewall/groups/{group}/rules")
|
||||||
|
async def create_firewall_group_rule(
|
||||||
|
server_id: int,
|
||||||
|
group: str,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(PermissionChecker("server:manage")),
|
||||||
|
):
|
||||||
|
client = _resolve_client(db, server_id)
|
||||||
|
data = await request.json()
|
||||||
|
try:
|
||||||
|
return _ok_or_400(await _run_in_executor(lambda: client.create_firewall_group_rule(group, **data)))
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error creating rule in group {group}: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/api/servers/{server_id}/firewall/groups/{group}/rules/{pos}")
|
||||||
|
async def update_firewall_group_rule(
|
||||||
|
server_id: int,
|
||||||
|
group: str,
|
||||||
|
pos: int,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(PermissionChecker("server:manage")),
|
||||||
|
):
|
||||||
|
client = _resolve_client(db, server_id)
|
||||||
|
data = await request.json()
|
||||||
|
try:
|
||||||
|
return _ok_or_400(await _run_in_executor(lambda: client.update_firewall_group_rule(group, pos, **data)))
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error updating rule {pos} in group {group}: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/api/servers/{server_id}/firewall/groups/{group}/rules/{pos}")
|
||||||
|
async def delete_firewall_group_rule(
|
||||||
|
server_id: int,
|
||||||
|
group: str,
|
||||||
|
pos: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(PermissionChecker("server:manage")),
|
||||||
|
):
|
||||||
|
client = _resolve_client(db, server_id)
|
||||||
|
try:
|
||||||
|
return _ok_or_400(await _run_in_executor(lambda: client.delete_firewall_group_rule(group, pos)))
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error deleting rule {pos} in group {group}: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== IP sets ====================
|
||||||
|
|
||||||
|
@router.get("/api/servers/{server_id}/firewall/ipsets")
|
||||||
|
async def get_firewall_ipsets(
|
||||||
|
server_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(PermissionChecker("server:view")),
|
||||||
|
):
|
||||||
|
client = _resolve_client(db, server_id)
|
||||||
|
try:
|
||||||
|
return JSONResponse(content={"ipsets": await _run_in_executor(client.get_firewall_ipsets)})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting firewall ipsets: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/servers/{server_id}/firewall/ipsets")
|
||||||
|
async def create_firewall_ipset(
|
||||||
|
server_id: int,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(PermissionChecker("server:manage")),
|
||||||
|
):
|
||||||
|
client = _resolve_client(db, server_id)
|
||||||
|
data = await request.json()
|
||||||
|
name = data.get("name")
|
||||||
|
if not name:
|
||||||
|
raise HTTPException(status_code=400, detail="IP set name is required")
|
||||||
|
try:
|
||||||
|
return _ok_or_400(await _run_in_executor(
|
||||||
|
lambda: client.create_firewall_ipset(name, data.get("comment"))))
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error creating firewall ipset: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/api/servers/{server_id}/firewall/ipsets/{name}")
|
||||||
|
async def delete_firewall_ipset(
|
||||||
|
server_id: int,
|
||||||
|
name: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(PermissionChecker("server:manage")),
|
||||||
|
):
|
||||||
|
client = _resolve_client(db, server_id)
|
||||||
|
try:
|
||||||
|
return _ok_or_400(await _run_in_executor(lambda: client.delete_firewall_ipset(name)))
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error deleting firewall ipset {name}: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/servers/{server_id}/firewall/ipsets/{name}/entries")
|
||||||
|
async def get_firewall_ipset_entries(
|
||||||
|
server_id: int,
|
||||||
|
name: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(PermissionChecker("server:view")),
|
||||||
|
):
|
||||||
|
client = _resolve_client(db, server_id)
|
||||||
|
try:
|
||||||
|
return JSONResponse(content={"entries": await _run_in_executor(lambda: client.get_firewall_ipset_entries(name))})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting entries for ipset {name}: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/servers/{server_id}/firewall/ipsets/{name}/entries")
|
||||||
|
async def add_firewall_ipset_entry(
|
||||||
|
server_id: int,
|
||||||
|
name: str,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(PermissionChecker("server:manage")),
|
||||||
|
):
|
||||||
|
client = _resolve_client(db, server_id)
|
||||||
|
data = await request.json()
|
||||||
|
cidr = data.get("cidr")
|
||||||
|
if not cidr:
|
||||||
|
raise HTTPException(status_code=400, detail="CIDR is required")
|
||||||
|
try:
|
||||||
|
return _ok_or_400(await _run_in_executor(
|
||||||
|
lambda: client.add_firewall_ipset_entry(
|
||||||
|
name, cidr, data.get("comment"), bool(data.get("nomatch")))))
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error adding entry to ipset {name}: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/api/servers/{server_id}/firewall/ipsets/{name}/entries")
|
||||||
|
async def delete_firewall_ipset_entry(
|
||||||
|
server_id: int,
|
||||||
|
name: str,
|
||||||
|
cidr: str = Query(..., description="CIDR записи (содержит '/', поэтому в query)"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(PermissionChecker("server:manage")),
|
||||||
|
):
|
||||||
|
client = _resolve_client(db, server_id)
|
||||||
|
try:
|
||||||
|
return _ok_or_400(await _run_in_executor(lambda: client.delete_firewall_ipset_entry(name, cidr)))
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error deleting entry from ipset {name}: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Aliases ====================
|
||||||
|
|
||||||
|
@router.get("/api/servers/{server_id}/firewall/aliases")
|
||||||
|
async def get_firewall_aliases(
|
||||||
|
server_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(PermissionChecker("server:view")),
|
||||||
|
):
|
||||||
|
client = _resolve_client(db, server_id)
|
||||||
|
try:
|
||||||
|
return JSONResponse(content={"aliases": await _run_in_executor(client.get_firewall_aliases)})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting firewall aliases: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/servers/{server_id}/firewall/aliases")
|
||||||
|
async def create_firewall_alias(
|
||||||
|
server_id: int,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(PermissionChecker("server:manage")),
|
||||||
|
):
|
||||||
|
client = _resolve_client(db, server_id)
|
||||||
|
data = await request.json()
|
||||||
|
name, cidr = data.get("name"), data.get("cidr")
|
||||||
|
if not name or not cidr:
|
||||||
|
raise HTTPException(status_code=400, detail="Name and CIDR are required")
|
||||||
|
try:
|
||||||
|
return _ok_or_400(await _run_in_executor(
|
||||||
|
lambda: client.create_firewall_alias(name, cidr, data.get("comment"))))
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error creating firewall alias: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/api/servers/{server_id}/firewall/aliases/{name}")
|
||||||
|
async def update_firewall_alias(
|
||||||
|
server_id: int,
|
||||||
|
name: str,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(PermissionChecker("server:manage")),
|
||||||
|
):
|
||||||
|
client = _resolve_client(db, server_id)
|
||||||
|
data = await request.json()
|
||||||
|
cidr = data.get("cidr")
|
||||||
|
if not cidr:
|
||||||
|
raise HTTPException(status_code=400, detail="CIDR is required")
|
||||||
|
try:
|
||||||
|
return _ok_or_400(await _run_in_executor(
|
||||||
|
lambda: client.update_firewall_alias(name, cidr, data.get("comment"), data.get("rename"))))
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error updating firewall alias {name}: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/api/servers/{server_id}/firewall/aliases/{name}")
|
||||||
|
async def delete_firewall_alias(
|
||||||
|
server_id: int,
|
||||||
|
name: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(PermissionChecker("server:manage")),
|
||||||
|
):
|
||||||
|
client = _resolve_client(db, server_id)
|
||||||
|
try:
|
||||||
|
return _ok_or_400(await _run_in_executor(lambda: client.delete_firewall_alias(name)))
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error deleting firewall alias {name}: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Node-level: options ====================
|
||||||
|
|
||||||
|
@router.get("/api/servers/{server_id}/nodes/{node}/firewall/options")
|
||||||
|
async def get_node_firewall_options(
|
||||||
|
server_id: int,
|
||||||
|
node: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(PermissionChecker("server:view")),
|
||||||
|
):
|
||||||
|
client = _resolve_client(db, server_id)
|
||||||
|
try:
|
||||||
|
return JSONResponse(content={"options": await _run_in_executor(lambda: client.get_node_firewall_options(node))})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting node {node} firewall options: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/api/servers/{server_id}/nodes/{node}/firewall/options")
|
||||||
|
async def update_node_firewall_options(
|
||||||
|
server_id: int,
|
||||||
|
node: str,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(PermissionChecker("server:manage")),
|
||||||
|
):
|
||||||
|
client = _resolve_client(db, server_id)
|
||||||
|
data = await request.json()
|
||||||
|
try:
|
||||||
|
return _ok_or_400(await _run_in_executor(lambda: client.update_node_firewall_options(node, **data)))
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error updating node {node} firewall options: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Node-level: rules ====================
|
||||||
|
|
||||||
|
@router.get("/api/servers/{server_id}/nodes/{node}/firewall/rules")
|
||||||
|
async def get_node_firewall_rules(
|
||||||
|
server_id: int,
|
||||||
|
node: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(PermissionChecker("server:view")),
|
||||||
|
):
|
||||||
|
client = _resolve_client(db, server_id)
|
||||||
|
try:
|
||||||
|
return JSONResponse(content={"rules": await _run_in_executor(lambda: client.get_node_firewall_rules(node))})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting node {node} firewall rules: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/servers/{server_id}/nodes/{node}/firewall/rules")
|
||||||
|
async def create_node_firewall_rule(
|
||||||
|
server_id: int,
|
||||||
|
node: str,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(PermissionChecker("server:manage")),
|
||||||
|
):
|
||||||
|
client = _resolve_client(db, server_id)
|
||||||
|
data = await request.json()
|
||||||
|
try:
|
||||||
|
return _ok_or_400(await _run_in_executor(lambda: client.create_node_firewall_rule(node, **data)))
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error creating node {node} firewall rule: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/api/servers/{server_id}/nodes/{node}/firewall/rules/{pos}")
|
||||||
|
async def update_node_firewall_rule(
|
||||||
|
server_id: int,
|
||||||
|
node: str,
|
||||||
|
pos: int,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(PermissionChecker("server:manage")),
|
||||||
|
):
|
||||||
|
client = _resolve_client(db, server_id)
|
||||||
|
data = await request.json()
|
||||||
|
try:
|
||||||
|
return _ok_or_400(await _run_in_executor(lambda: client.update_node_firewall_rule(node, pos, **data)))
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error updating node {node} firewall rule {pos}: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/api/servers/{server_id}/nodes/{node}/firewall/rules/{pos}")
|
||||||
|
async def delete_node_firewall_rule(
|
||||||
|
server_id: int,
|
||||||
|
node: str,
|
||||||
|
pos: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(PermissionChecker("server:manage")),
|
||||||
|
):
|
||||||
|
client = _resolve_client(db, server_id)
|
||||||
|
try:
|
||||||
|
return _ok_or_400(await _run_in_executor(lambda: client.delete_node_firewall_rule(node, pos)))
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error deleting node {node} firewall rule {pos}: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/servers/{server_id}/nodes/{node}/firewall/log")
|
||||||
|
async def get_node_firewall_log(
|
||||||
|
server_id: int,
|
||||||
|
node: str,
|
||||||
|
limit: int = Query(100, ge=1, le=500),
|
||||||
|
start: int = Query(0, ge=0),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(PermissionChecker("server:view")),
|
||||||
|
):
|
||||||
|
client = _resolve_client(db, server_id)
|
||||||
|
try:
|
||||||
|
return JSONResponse(content={"log": await _run_in_executor(lambda: client.get_node_firewall_log(node, limit, start))})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting node {node} firewall log: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Guest-level (VM/LXC) ====================
|
||||||
|
|
||||||
|
def _norm_type(vm_type: str) -> str:
|
||||||
|
if vm_type not in ("qemu", "lxc", "vm", "ct"):
|
||||||
|
raise HTTPException(status_code=400, detail="vm_type must be qemu/lxc")
|
||||||
|
return vm_type
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/servers/{server_id}/guests/{vm_type}/{vmid}/firewall/options")
|
||||||
|
async def get_guest_firewall_options(
|
||||||
|
server_id: int,
|
||||||
|
vm_type: str,
|
||||||
|
vmid: int,
|
||||||
|
node: str = Query(...),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(PermissionChecker("vm:view")),
|
||||||
|
):
|
||||||
|
client = _resolve_client(db, server_id)
|
||||||
|
_norm_type(vm_type)
|
||||||
|
try:
|
||||||
|
return JSONResponse(content={"options": await _run_in_executor(
|
||||||
|
lambda: client.get_guest_firewall_options(node, vm_type, vmid))})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting guest {vmid} firewall options: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/api/servers/{server_id}/guests/{vm_type}/{vmid}/firewall/options")
|
||||||
|
async def update_guest_firewall_options(
|
||||||
|
server_id: int,
|
||||||
|
vm_type: str,
|
||||||
|
vmid: int,
|
||||||
|
request: Request,
|
||||||
|
node: str = Query(...),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(PermissionChecker("server:manage")),
|
||||||
|
):
|
||||||
|
client = _resolve_client(db, server_id)
|
||||||
|
_norm_type(vm_type)
|
||||||
|
data = await request.json()
|
||||||
|
try:
|
||||||
|
return _ok_or_400(await _run_in_executor(
|
||||||
|
lambda: client.update_guest_firewall_options(node, vm_type, vmid, **data)))
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error updating guest {vmid} firewall options: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/servers/{server_id}/guests/{vm_type}/{vmid}/firewall/rules")
|
||||||
|
async def get_guest_firewall_rules(
|
||||||
|
server_id: int,
|
||||||
|
vm_type: str,
|
||||||
|
vmid: int,
|
||||||
|
node: str = Query(...),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(PermissionChecker("vm:view")),
|
||||||
|
):
|
||||||
|
client = _resolve_client(db, server_id)
|
||||||
|
_norm_type(vm_type)
|
||||||
|
try:
|
||||||
|
return JSONResponse(content={"rules": await _run_in_executor(
|
||||||
|
lambda: client.get_guest_firewall_rules(node, vm_type, vmid))})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting guest {vmid} firewall rules: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/servers/{server_id}/guests/{vm_type}/{vmid}/firewall/rules")
|
||||||
|
async def create_guest_firewall_rule(
|
||||||
|
server_id: int,
|
||||||
|
vm_type: str,
|
||||||
|
vmid: int,
|
||||||
|
request: Request,
|
||||||
|
node: str = Query(...),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(PermissionChecker("server:manage")),
|
||||||
|
):
|
||||||
|
client = _resolve_client(db, server_id)
|
||||||
|
_norm_type(vm_type)
|
||||||
|
data = await request.json()
|
||||||
|
try:
|
||||||
|
return _ok_or_400(await _run_in_executor(
|
||||||
|
lambda: client.create_guest_firewall_rule(node, vm_type, vmid, **data)))
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error creating guest {vmid} firewall rule: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/api/servers/{server_id}/guests/{vm_type}/{vmid}/firewall/rules/{pos}")
|
||||||
|
async def update_guest_firewall_rule(
|
||||||
|
server_id: int,
|
||||||
|
vm_type: str,
|
||||||
|
vmid: int,
|
||||||
|
pos: int,
|
||||||
|
request: Request,
|
||||||
|
node: str = Query(...),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(PermissionChecker("server:manage")),
|
||||||
|
):
|
||||||
|
client = _resolve_client(db, server_id)
|
||||||
|
_norm_type(vm_type)
|
||||||
|
data = await request.json()
|
||||||
|
try:
|
||||||
|
return _ok_or_400(await _run_in_executor(
|
||||||
|
lambda: client.update_guest_firewall_rule(node, vm_type, vmid, pos, **data)))
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error updating guest {vmid} firewall rule {pos}: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/api/servers/{server_id}/guests/{vm_type}/{vmid}/firewall/rules/{pos}")
|
||||||
|
async def delete_guest_firewall_rule(
|
||||||
|
server_id: int,
|
||||||
|
vm_type: str,
|
||||||
|
vmid: int,
|
||||||
|
pos: int,
|
||||||
|
node: str = Query(...),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(PermissionChecker("server:manage")),
|
||||||
|
):
|
||||||
|
client = _resolve_client(db, server_id)
|
||||||
|
_norm_type(vm_type)
|
||||||
|
try:
|
||||||
|
return _ok_or_400(await _run_in_executor(
|
||||||
|
lambda: client.delete_guest_firewall_rule(node, vm_type, vmid, pos)))
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error deleting guest {vmid} firewall rule {pos}: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/servers/{server_id}/guests/{vm_type}/{vmid}/firewall/log")
|
||||||
|
async def get_guest_firewall_log(
|
||||||
|
server_id: int,
|
||||||
|
vm_type: str,
|
||||||
|
vmid: int,
|
||||||
|
node: str = Query(...),
|
||||||
|
limit: int = Query(100, ge=1, le=500),
|
||||||
|
start: int = Query(0, ge=0),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(PermissionChecker("vm:view")),
|
||||||
|
):
|
||||||
|
client = _resolve_client(db, server_id)
|
||||||
|
_norm_type(vm_type)
|
||||||
|
try:
|
||||||
|
return JSONResponse(content={"log": await _run_in_executor(
|
||||||
|
lambda: client.get_guest_firewall_log(node, vm_type, vmid, limit, start))})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting guest {vmid} firewall log: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Macros (read-only) ====================
|
||||||
|
|
||||||
|
@router.get("/api/servers/{server_id}/firewall/macros")
|
||||||
|
async def get_firewall_macros(
|
||||||
|
server_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(PermissionChecker("server:view")),
|
||||||
|
):
|
||||||
|
client = _resolve_client(db, server_id)
|
||||||
|
try:
|
||||||
|
return JSONResponse(content={"macros": await _run_in_executor(client.get_firewall_macros)})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting firewall macros: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
165
backend/app/api/proxmox/node_admin.py
Normal file
165
backend/app/api/proxmox/node_admin.py
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
from fastapi import APIRouter, Depends, Request, HTTPException
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from ...db import get_db
|
||||||
|
from ...models import ProxmoxServer, User
|
||||||
|
from ...auth import PermissionChecker
|
||||||
|
from ._helpers import _get_proxmox_client
|
||||||
|
from ...proxmox import _run_in_executor
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_client(db: Session, server_id: int):
|
||||||
|
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
|
||||||
|
if not server:
|
||||||
|
raise HTTPException(status_code=404, detail="Proxmox server not found")
|
||||||
|
client = _get_proxmox_client(server)
|
||||||
|
if not client.is_connected():
|
||||||
|
raise HTTPException(status_code=503, detail="Failed to connect to Proxmox server")
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
def _ok_or_400(result: dict):
|
||||||
|
if result.get("success"):
|
||||||
|
return JSONResponse(content=result)
|
||||||
|
raise HTTPException(status_code=400, detail=result.get("error", "Operation failed"))
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Services ====================
|
||||||
|
|
||||||
|
@router.get("/api/servers/{server_id}/nodes/{node}/services")
|
||||||
|
async def list_services(
|
||||||
|
server_id: int,
|
||||||
|
node: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(PermissionChecker("server:view")),
|
||||||
|
):
|
||||||
|
client = _resolve_client(db, server_id)
|
||||||
|
try:
|
||||||
|
return JSONResponse(content={"services": await _run_in_executor(lambda: client.get_node_services(node))})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error listing services on {node}: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/servers/{server_id}/nodes/{node}/services/{service}/{action}")
|
||||||
|
async def service_action(
|
||||||
|
server_id: int,
|
||||||
|
node: str,
|
||||||
|
service: str,
|
||||||
|
action: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(PermissionChecker("server:manage")),
|
||||||
|
):
|
||||||
|
if action not in ("start", "stop", "restart", "reload"):
|
||||||
|
raise HTTPException(status_code=400, detail="action must be start/stop/restart/reload")
|
||||||
|
client = _resolve_client(db, server_id)
|
||||||
|
try:
|
||||||
|
return _ok_or_400(await _run_in_executor(lambda: client.node_service_action(node, service, action)))
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error {action} service {service} on {node}: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== APT: updates ====================
|
||||||
|
|
||||||
|
@router.get("/api/servers/{server_id}/nodes/{node}/apt/updates")
|
||||||
|
async def apt_updates(
|
||||||
|
server_id: int,
|
||||||
|
node: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(PermissionChecker("server:view")),
|
||||||
|
):
|
||||||
|
client = _resolve_client(db, server_id)
|
||||||
|
try:
|
||||||
|
return JSONResponse(content={"updates": await _run_in_executor(lambda: client.get_apt_updates(node))})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting APT updates on {node}: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/servers/{server_id}/nodes/{node}/apt/refresh")
|
||||||
|
async def apt_refresh(
|
||||||
|
server_id: int,
|
||||||
|
node: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(PermissionChecker("server:manage")),
|
||||||
|
):
|
||||||
|
client = _resolve_client(db, server_id)
|
||||||
|
try:
|
||||||
|
return _ok_or_400(await _run_in_executor(lambda: client.apt_refresh(node)))
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error refreshing APT on {node}: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== APT: repositories ====================
|
||||||
|
|
||||||
|
@router.get("/api/servers/{server_id}/nodes/{node}/apt/repositories")
|
||||||
|
async def apt_repositories(
|
||||||
|
server_id: int,
|
||||||
|
node: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(PermissionChecker("server:view")),
|
||||||
|
):
|
||||||
|
client = _resolve_client(db, server_id)
|
||||||
|
try:
|
||||||
|
return JSONResponse(content={"repositories": await _run_in_executor(lambda: client.get_apt_repositories(node))})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting APT repositories on {node}: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/servers/{server_id}/nodes/{node}/apt/repositories")
|
||||||
|
async def set_apt_repository(
|
||||||
|
server_id: int,
|
||||||
|
node: str,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(PermissionChecker("server:manage")),
|
||||||
|
):
|
||||||
|
"""Включить/выключить репозиторий (path + index + enabled)."""
|
||||||
|
client = _resolve_client(db, server_id)
|
||||||
|
data = await request.json()
|
||||||
|
path = data.get("path")
|
||||||
|
index = data.get("index")
|
||||||
|
if not path or index is None:
|
||||||
|
raise HTTPException(status_code=400, detail="path and index are required")
|
||||||
|
try:
|
||||||
|
return _ok_or_400(await _run_in_executor(
|
||||||
|
lambda: client.set_apt_repository(node, path, int(index), bool(data.get("enabled")))))
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error toggling APT repo on {node}: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/api/servers/{server_id}/nodes/{node}/apt/repositories")
|
||||||
|
async def add_standard_repository(
|
||||||
|
server_id: int,
|
||||||
|
node: str,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(PermissionChecker("server:manage")),
|
||||||
|
):
|
||||||
|
"""Добавить стандартный репозиторий PVE по handle."""
|
||||||
|
client = _resolve_client(db, server_id)
|
||||||
|
data = await request.json()
|
||||||
|
handle = data.get("handle")
|
||||||
|
if not handle:
|
||||||
|
raise HTTPException(status_code=400, detail="handle is required")
|
||||||
|
try:
|
||||||
|
return _ok_or_400(await _run_in_executor(lambda: client.add_apt_standard_repository(node, handle)))
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error adding standard repo on {node}: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
121
backend/app/api/proxmox/pools.py
Normal file
121
backend/app/api/proxmox/pools.py
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
from fastapi import APIRouter, Depends, Request, HTTPException
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from ...db import get_db
|
||||||
|
from ...models import ProxmoxServer, User
|
||||||
|
from ...auth import PermissionChecker
|
||||||
|
from ._helpers import _get_proxmox_client
|
||||||
|
from ...proxmox import _run_in_executor
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_client(db: Session, server_id: int):
|
||||||
|
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
|
||||||
|
if not server:
|
||||||
|
raise HTTPException(status_code=404, detail="Proxmox server not found")
|
||||||
|
client = _get_proxmox_client(server)
|
||||||
|
if not client.is_connected():
|
||||||
|
raise HTTPException(status_code=503, detail="Failed to connect to Proxmox server")
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
def _ok_or_400(result: dict):
|
||||||
|
if result.get("success"):
|
||||||
|
return JSONResponse(content=result)
|
||||||
|
raise HTTPException(status_code=400, detail=result.get("error", "Operation failed"))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/servers/{server_id}/pools")
|
||||||
|
async def list_pools(
|
||||||
|
server_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(PermissionChecker("vm:view")),
|
||||||
|
):
|
||||||
|
client = _resolve_client(db, server_id)
|
||||||
|
try:
|
||||||
|
return JSONResponse(content={"pools": await _run_in_executor(client.get_pools)})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error listing pools: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/servers/{server_id}/pools/{poolid}")
|
||||||
|
async def get_pool(
|
||||||
|
server_id: int,
|
||||||
|
poolid: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(PermissionChecker("vm:view")),
|
||||||
|
):
|
||||||
|
client = _resolve_client(db, server_id)
|
||||||
|
try:
|
||||||
|
return JSONResponse(content={"pool": await _run_in_executor(lambda: client.get_pool(poolid))})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting pool {poolid}: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/servers/{server_id}/pools")
|
||||||
|
async def create_pool(
|
||||||
|
server_id: int,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(PermissionChecker("server:manage")),
|
||||||
|
):
|
||||||
|
client = _resolve_client(db, server_id)
|
||||||
|
data = await request.json()
|
||||||
|
poolid = data.get("poolid")
|
||||||
|
if not poolid:
|
||||||
|
raise HTTPException(status_code=400, detail="poolid is required")
|
||||||
|
try:
|
||||||
|
return _ok_or_400(await _run_in_executor(lambda: client.create_pool(poolid, data.get("comment"))))
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error creating pool: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/api/servers/{server_id}/pools/{poolid}")
|
||||||
|
async def update_pool(
|
||||||
|
server_id: int,
|
||||||
|
poolid: str,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(PermissionChecker("server:manage")),
|
||||||
|
):
|
||||||
|
"""Добавить/убрать участников пула (vms, storage) или изменить comment."""
|
||||||
|
client = _resolve_client(db, server_id)
|
||||||
|
data = await request.json()
|
||||||
|
try:
|
||||||
|
return _ok_or_400(await _run_in_executor(lambda: client.update_pool(
|
||||||
|
poolid,
|
||||||
|
vms=data.get("vms"),
|
||||||
|
storage=data.get("storage"),
|
||||||
|
comment=data.get("comment"),
|
||||||
|
delete=bool(data.get("delete")),
|
||||||
|
)))
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error updating pool {poolid}: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/api/servers/{server_id}/pools/{poolid}")
|
||||||
|
async def delete_pool(
|
||||||
|
server_id: int,
|
||||||
|
poolid: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(PermissionChecker("server:manage")),
|
||||||
|
):
|
||||||
|
client = _resolve_client(db, server_id)
|
||||||
|
try:
|
||||||
|
return _ok_or_400(await _run_in_executor(lambda: client.delete_pool(poolid)))
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error deleting pool {poolid}: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
@@ -1383,6 +1383,40 @@ def get_vm_config(
|
|||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/{server_id}/nodes/{node}/hardware/pci")
|
||||||
|
def list_node_pci_devices(
|
||||||
|
server_id: int,
|
||||||
|
node: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(PermissionChecker("vm:view"))
|
||||||
|
):
|
||||||
|
"""Список PCI-устройств ноды для passthrough (hostpciN)."""
|
||||||
|
server = _resolve_server(db, server_id)
|
||||||
|
client = _get_client_or_503(server)
|
||||||
|
try:
|
||||||
|
return JSONResponse(content=client.get_node_pci_devices(node))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error listing PCI devices on {node}: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/{server_id}/nodes/{node}/hardware/usb")
|
||||||
|
def list_node_usb_devices(
|
||||||
|
server_id: int,
|
||||||
|
node: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(PermissionChecker("vm:view"))
|
||||||
|
):
|
||||||
|
"""Список USB-устройств ноды для passthrough (usbN)."""
|
||||||
|
server = _resolve_server(db, server_id)
|
||||||
|
client = _get_client_or_503(server)
|
||||||
|
try:
|
||||||
|
return JSONResponse(content=client.get_node_usb_devices(node))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error listing USB devices on {node}: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.put("/api/{server_id}/vm/{vmid}/config")
|
@router.put("/api/{server_id}/vm/{vmid}/config")
|
||||||
async def update_vm_config(
|
async def update_vm_config(
|
||||||
server_id: int,
|
server_id: int,
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ from .mixins.cluster import ClusterMixin
|
|||||||
from .mixins.storage import StorageMixin
|
from .mixins.storage import StorageMixin
|
||||||
from .mixins.network import NetworkMixin
|
from .mixins.network import NetworkMixin
|
||||||
from .mixins.snapshot import SnapshotMixin
|
from .mixins.snapshot import SnapshotMixin
|
||||||
|
from .mixins.firewall import FirewallMixin
|
||||||
|
from .mixins.pools import PoolsMixin
|
||||||
|
from .mixins.disks import DisksMixin
|
||||||
|
from .mixins.access import AccessMixin
|
||||||
|
from .mixins.node_admin import NodeAdminMixin
|
||||||
|
|
||||||
proxmox_executor = ThreadPoolExecutor(max_workers=20, thread_name_prefix="proxmox_")
|
proxmox_executor = ThreadPoolExecutor(max_workers=20, thread_name_prefix="proxmox_")
|
||||||
|
|
||||||
@@ -82,7 +87,7 @@ def get_cached_client(host: str, user: str, password_hash: str = None,
|
|||||||
token_value=token_value_hash
|
token_value=token_value_hash
|
||||||
)
|
)
|
||||||
|
|
||||||
class ProxmoxClient(VmMixin, LxcMixin, ClusterMixin, StorageMixin, NetworkMixin, SnapshotMixin):
|
class ProxmoxClient(VmMixin, LxcMixin, ClusterMixin, StorageMixin, NetworkMixin, SnapshotMixin, FirewallMixin, PoolsMixin, DisksMixin, AccessMixin, NodeAdminMixin):
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_server(cls, server) -> "ProxmoxClient":
|
def from_server(cls, server) -> "ProxmoxClient":
|
||||||
"""Single source of truth for building a client from a ProxmoxServer model.
|
"""Single source of truth for building a client from a ProxmoxServer model.
|
||||||
@@ -1297,11 +1302,19 @@ class ProxmoxClient(VmMixin, LxcMixin, ClusterMixin, StorageMixin, NetworkMixin,
|
|||||||
'cores', 'sockets', 'memory', 'balloon', 'name', 'description',
|
'cores', 'sockets', 'memory', 'balloon', 'name', 'description',
|
||||||
'cpu', 'cpulimit', 'cpuunits', 'onboot', 'startup', 'boot', 'bootdisk',
|
'cpu', 'cpulimit', 'cpuunits', 'onboot', 'startup', 'boot', 'bootdisk',
|
||||||
'agent', 'ostype', 'tablet', 'hotplug', 'protection',
|
'agent', 'ostype', 'tablet', 'hotplug', 'protection',
|
||||||
'ciuser', 'cipassword', 'sshkeys',
|
'ciuser', 'cipassword', 'sshkeys', 'tags',
|
||||||
|
# Hardware editor: CPU/NUMA, дисплей, контроллер, тип платформы,
|
||||||
|
# efidisk0 — EFI-диск, обязателен для загрузки в режиме OVMF (UEFI).
|
||||||
|
'numa', 'vga', 'scsihw', 'machine', 'bios', 'efidisk0',
|
||||||
}
|
}
|
||||||
indexed_re = re.compile(r'^(net|scsi|virtio|ide|sata|ipconfig)\d+$')
|
# Индексированные ключи: сеть/диски + passthrough (PCI/USB) + serial
|
||||||
# Удаляемые ключи: сетевые интерфейсы + опциональные булевы флаги
|
indexed_re = re.compile(
|
||||||
delete_token_re = re.compile(r'^(net\d+|startup|onboot|protection)$')
|
r'^(net|scsi|virtio|ide|sata|ipconfig|hostpci|usb|serial)\d+$'
|
||||||
|
)
|
||||||
|
# Удаляемые ключи: интерфейсы, passthrough-устройства + опциональные флаги
|
||||||
|
delete_token_re = re.compile(
|
||||||
|
r'^(net\d+|hostpci\d+|usb\d+|serial\d+|startup|onboot|protection|tags)$'
|
||||||
|
)
|
||||||
|
|
||||||
filtered_config = {}
|
filtered_config = {}
|
||||||
for k, v in config.items():
|
for k, v in config.items():
|
||||||
@@ -1372,7 +1385,7 @@ class ProxmoxClient(VmMixin, LxcMixin, ClusterMixin, StorageMixin, NetworkMixin,
|
|||||||
'searchdomain', 'tags',
|
'searchdomain', 'tags',
|
||||||
}
|
}
|
||||||
net_re = re.compile(r'^net\d+$')
|
net_re = re.compile(r'^net\d+$')
|
||||||
delete_token_re = re.compile(r'^(net\d+|startup|onboot|protection)$')
|
delete_token_re = re.compile(r'^(net\d+|startup|onboot|protection|tags)$')
|
||||||
|
|
||||||
filtered_config = {}
|
filtered_config = {}
|
||||||
for k, v in config.items():
|
for k, v in config.items():
|
||||||
|
|||||||
110
backend/app/proxmox/mixins/access.py
Normal file
110
backend/app/proxmox/mixins/access.py
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
from typing import List, Dict, Optional
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
|
||||||
|
class AccessMixin:
|
||||||
|
"""
|
||||||
|
PVE Access: realms (домены аутентификации, /access/domains) и
|
||||||
|
API-токены пользователей (/access/users/{userid}/token).
|
||||||
|
Геттеры возвращают данные как есть, мутации — {"success": bool, ...}.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# ---------- Realms (auth domains) ----------
|
||||||
|
|
||||||
|
def get_realms(self) -> List[Dict]:
|
||||||
|
"""Список realms: [{realm, type, comment, tfa?}]."""
|
||||||
|
if not self.proxmox:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
return list(self.proxmox.access.domains.get() or [])
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting realms: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def create_realm(self, realm: str, realm_type: str, **kwargs) -> Dict:
|
||||||
|
if not self.proxmox:
|
||||||
|
return {"success": False, "error": "Not connected"}
|
||||||
|
try:
|
||||||
|
self.proxmox.access.domains.post(realm=realm, type=realm_type, **kwargs)
|
||||||
|
return {"success": True, "realm": realm}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error creating realm {realm}: {e}")
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
def update_realm(self, realm: str, **kwargs) -> Dict:
|
||||||
|
if not self.proxmox:
|
||||||
|
return {"success": False, "error": "Not connected"}
|
||||||
|
try:
|
||||||
|
self.proxmox.access.domains(realm).put(**kwargs)
|
||||||
|
return {"success": True}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error updating realm {realm}: {e}")
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
def delete_realm(self, realm: str) -> Dict:
|
||||||
|
if not self.proxmox:
|
||||||
|
return {"success": False, "error": "Not connected"}
|
||||||
|
try:
|
||||||
|
self.proxmox.access.domains(realm).delete()
|
||||||
|
return {"success": True}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error deleting realm {realm}: {e}")
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
# ---------- Users & API tokens ----------
|
||||||
|
|
||||||
|
def get_access_users(self) -> List[Dict]:
|
||||||
|
"""Список пользователей PVE: [{userid, enable, comment, ...}]."""
|
||||||
|
if not self.proxmox:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
return list(self.proxmox.access.users.get() or [])
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting access users: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_user_tokens(self, userid: str) -> List[Dict]:
|
||||||
|
"""Токены пользователя: [{tokenid, comment, expire, privsep}]."""
|
||||||
|
if not self.proxmox:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
return list(self.proxmox.access.users(userid).token.get() or [])
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting tokens for {userid}: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def create_user_token(self, userid: str, tokenid: str,
|
||||||
|
comment: Optional[str] = None, privsep: bool = True,
|
||||||
|
expire: Optional[int] = None) -> Dict:
|
||||||
|
"""
|
||||||
|
Создать API-токен. Возвращает секрет (value) — показывается один раз.
|
||||||
|
"""
|
||||||
|
if not self.proxmox:
|
||||||
|
return {"success": False, "error": "Not connected"}
|
||||||
|
try:
|
||||||
|
params: Dict = {"privsep": 1 if privsep else 0}
|
||||||
|
if comment:
|
||||||
|
params["comment"] = comment
|
||||||
|
if expire:
|
||||||
|
params["expire"] = expire
|
||||||
|
result = self.proxmox.access.users(userid).token(tokenid).post(**params)
|
||||||
|
data = dict(result or {})
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"value": data.get("value"),
|
||||||
|
"full_tokenid": data.get("full-tokenid") or f"{userid}!{tokenid}",
|
||||||
|
"info": data.get("info"),
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error creating token {tokenid} for {userid}: {e}")
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
def delete_user_token(self, userid: str, tokenid: str) -> Dict:
|
||||||
|
if not self.proxmox:
|
||||||
|
return {"success": False, "error": "Not connected"}
|
||||||
|
try:
|
||||||
|
self.proxmox.access.users(userid).token(tokenid).delete()
|
||||||
|
return {"success": True}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error deleting token {tokenid} for {userid}: {e}")
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
119
backend/app/proxmox/mixins/disks.py
Normal file
119
backend/app/proxmox/mixins/disks.py
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
from typing import List, Dict, Optional
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
|
||||||
|
class DisksMixin:
|
||||||
|
"""
|
||||||
|
Физические диски ноды, SMART и ZFS-пулы (/nodes/{node}/disks/*).
|
||||||
|
Геттеры возвращают данные как есть, мутации — {"success": bool, "upid"?, "error"?}.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# ---------- Physical disks ----------
|
||||||
|
|
||||||
|
def get_node_disks(self, node: str) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
Список физических дисков ноды: devpath, size, model, serial, type
|
||||||
|
(ssd/hdd/nvme), health (SMART), wearout, used, vendor и т.д.
|
||||||
|
"""
|
||||||
|
if not self.proxmox:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
return list(self.proxmox.nodes(node).disks.list.get() or [])
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting disks for node {node}: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_disk_smart(self, node: str, disk: str) -> Dict:
|
||||||
|
"""SMART-данные диска (health + attributes)."""
|
||||||
|
if not self.proxmox:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return dict(self.proxmox.nodes(node).disks.smart.get(disk=disk) or {})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting SMART for {disk} on {node}: {e}")
|
||||||
|
return {"error": str(e)}
|
||||||
|
|
||||||
|
def wipe_disk(self, node: str, disk: str) -> Dict:
|
||||||
|
"""Очистить диск (wipe). ВНИМАНИЕ: уничтожает данные."""
|
||||||
|
if not self.proxmox:
|
||||||
|
return {"success": False, "error": "Not connected"}
|
||||||
|
try:
|
||||||
|
upid = self.proxmox.nodes(node).disks.wipedisk.put(disk=disk)
|
||||||
|
return {"success": True, "upid": upid}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error wiping disk {disk} on {node}: {e}")
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
# ---------- ZFS pools ----------
|
||||||
|
|
||||||
|
def get_zfs_pools(self, node: str) -> List[Dict]:
|
||||||
|
"""Список ZFS-пулов ноды: name, size, alloc, free, frag, dedup, health."""
|
||||||
|
if not self.proxmox:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
return list(self.proxmox.nodes(node).disks.zfs.get() or [])
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting ZFS pools for node {node}: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_zfs_pool(self, node: str, name: str) -> Dict:
|
||||||
|
"""Детали ZFS-пула, включая дерево устройств (children)."""
|
||||||
|
if not self.proxmox:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return dict(self.proxmox.nodes(node).disks.zfs(name).get() or {})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting ZFS pool {name} on {node}: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def create_zfs_pool(self, node: str, name: str, devices: str,
|
||||||
|
raidlevel: str = "single", ashift: int = 12,
|
||||||
|
compression: str = "on", add_storage: bool = True) -> Dict:
|
||||||
|
"""
|
||||||
|
Создать ZFS-пул из устройств.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
devices: список devpath через запятую ("/dev/sdb,/dev/sdc")
|
||||||
|
raidlevel: single | mirror | raid10 | raidz | raidz2 | raidz3
|
||||||
|
ashift: 9..16 (обычно 12 для 4K-секторов)
|
||||||
|
compression: on | off | lz4 | zstd | gzip
|
||||||
|
add_storage: сразу зарегистрировать пул как storage в PVE
|
||||||
|
"""
|
||||||
|
if not self.proxmox:
|
||||||
|
return {"success": False, "error": "Not connected"}
|
||||||
|
try:
|
||||||
|
upid = self.proxmox.nodes(node).disks.zfs.post(
|
||||||
|
name=name,
|
||||||
|
devices=devices,
|
||||||
|
raidlevel=raidlevel,
|
||||||
|
ashift=ashift,
|
||||||
|
compression=compression,
|
||||||
|
add_storage=1 if add_storage else 0,
|
||||||
|
)
|
||||||
|
return {"success": True, "upid": upid}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error creating ZFS pool {name} on {node}: {e}")
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
def destroy_zfs_pool(self, node: str, name: str,
|
||||||
|
cleanup_config: bool = True, cleanup_disks: bool = False) -> Dict:
|
||||||
|
"""
|
||||||
|
Уничтожить ZFS-пул.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cleanup_config: убрать связанную запись storage из PVE
|
||||||
|
cleanup_disks: затереть диски (wipe) после уничтожения пула
|
||||||
|
"""
|
||||||
|
if not self.proxmox:
|
||||||
|
return {"success": False, "error": "Not connected"}
|
||||||
|
try:
|
||||||
|
params = {}
|
||||||
|
if cleanup_config:
|
||||||
|
params["cleanup-config"] = 1
|
||||||
|
if cleanup_disks:
|
||||||
|
params["cleanup-disks"] = 1
|
||||||
|
upid = self.proxmox.nodes(node).disks.zfs(name).delete(**params)
|
||||||
|
return {"success": True, "upid": upid}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error destroying ZFS pool {name} on {node}: {e}")
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
436
backend/app/proxmox/mixins/firewall.py
Normal file
436
backend/app/proxmox/mixins/firewall.py
Normal file
@@ -0,0 +1,436 @@
|
|||||||
|
from typing import List, Dict, Optional
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
|
||||||
|
class FirewallMixin:
|
||||||
|
"""
|
||||||
|
Datacenter-level Proxmox firewall: options, rules, security groups,
|
||||||
|
IP sets and aliases. Все методы работают с /cluster/firewall/*.
|
||||||
|
|
||||||
|
Геттеры возвращают списки/словари как есть, мутации — dict
|
||||||
|
{"success": bool, "error"?: str}, по образцу SDN-миксина.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# ---------- Options ----------
|
||||||
|
|
||||||
|
def get_cluster_firewall_options(self) -> Dict:
|
||||||
|
"""Опции датацентр-фаервола (enable, policy_in/out, log_ratelimit, …)."""
|
||||||
|
if not self.proxmox:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return dict(self.proxmox.cluster.firewall.options.get() or {})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting cluster firewall options: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def update_cluster_firewall_options(self, **opts) -> Dict:
|
||||||
|
"""Обновить опции датацентр-фаервола."""
|
||||||
|
if not self.proxmox:
|
||||||
|
return {"success": False, "error": "Not connected"}
|
||||||
|
try:
|
||||||
|
self.proxmox.cluster.firewall.options.put(**opts)
|
||||||
|
return {"success": True}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error updating cluster firewall options: {e}")
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
# ---------- Datacenter rules ----------
|
||||||
|
|
||||||
|
def get_cluster_firewall_rules(self) -> List[Dict]:
|
||||||
|
if not self.proxmox:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
return list(self.proxmox.cluster.firewall.rules.get() or [])
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting cluster firewall rules: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def create_cluster_firewall_rule(self, **rule) -> Dict:
|
||||||
|
if not self.proxmox:
|
||||||
|
return {"success": False, "error": "Not connected"}
|
||||||
|
try:
|
||||||
|
self.proxmox.cluster.firewall.rules.post(**rule)
|
||||||
|
return {"success": True}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error creating cluster firewall rule: {e}")
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
def update_cluster_firewall_rule(self, pos: int, **rule) -> Dict:
|
||||||
|
if not self.proxmox:
|
||||||
|
return {"success": False, "error": "Not connected"}
|
||||||
|
try:
|
||||||
|
self.proxmox.cluster.firewall.rules(pos).put(**rule)
|
||||||
|
return {"success": True}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error updating cluster firewall rule {pos}: {e}")
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
def delete_cluster_firewall_rule(self, pos: int) -> Dict:
|
||||||
|
if not self.proxmox:
|
||||||
|
return {"success": False, "error": "Not connected"}
|
||||||
|
try:
|
||||||
|
self.proxmox.cluster.firewall.rules(pos).delete()
|
||||||
|
return {"success": True}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error deleting cluster firewall rule {pos}: {e}")
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
# ---------- Security groups ----------
|
||||||
|
|
||||||
|
def get_firewall_groups(self) -> List[Dict]:
|
||||||
|
"""Список security groups: [{group, comment, digest}]."""
|
||||||
|
if not self.proxmox:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
return list(self.proxmox.cluster.firewall.groups.get() or [])
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting firewall groups: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def create_firewall_group(self, group: str, comment: Optional[str] = None) -> Dict:
|
||||||
|
if not self.proxmox:
|
||||||
|
return {"success": False, "error": "Not connected"}
|
||||||
|
try:
|
||||||
|
params = {"group": group}
|
||||||
|
if comment:
|
||||||
|
params["comment"] = comment
|
||||||
|
self.proxmox.cluster.firewall.groups.post(**params)
|
||||||
|
return {"success": True, "group": group}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error creating firewall group {group}: {e}")
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
def delete_firewall_group(self, group: str) -> Dict:
|
||||||
|
"""Удалить security group целиком (должна быть пустой в PVE)."""
|
||||||
|
if not self.proxmox:
|
||||||
|
return {"success": False, "error": "Not connected"}
|
||||||
|
try:
|
||||||
|
self.proxmox.cluster.firewall.groups(group).delete()
|
||||||
|
return {"success": True}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error deleting firewall group {group}: {e}")
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
def get_firewall_group_rules(self, group: str) -> List[Dict]:
|
||||||
|
if not self.proxmox:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
return list(self.proxmox.cluster.firewall.groups(group).get() or [])
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting rules for firewall group {group}: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def create_firewall_group_rule(self, group: str, **rule) -> Dict:
|
||||||
|
if not self.proxmox:
|
||||||
|
return {"success": False, "error": "Not connected"}
|
||||||
|
try:
|
||||||
|
self.proxmox.cluster.firewall.groups(group).post(**rule)
|
||||||
|
return {"success": True}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error creating rule in firewall group {group}: {e}")
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
def update_firewall_group_rule(self, group: str, pos: int, **rule) -> Dict:
|
||||||
|
if not self.proxmox:
|
||||||
|
return {"success": False, "error": "Not connected"}
|
||||||
|
try:
|
||||||
|
self.proxmox.cluster.firewall.groups(group)(pos).put(**rule)
|
||||||
|
return {"success": True}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error updating rule {pos} in group {group}: {e}")
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
def delete_firewall_group_rule(self, group: str, pos: int) -> Dict:
|
||||||
|
if not self.proxmox:
|
||||||
|
return {"success": False, "error": "Not connected"}
|
||||||
|
try:
|
||||||
|
self.proxmox.cluster.firewall.groups(group)(pos).delete()
|
||||||
|
return {"success": True}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error deleting rule {pos} in group {group}: {e}")
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
# ---------- IP sets ----------
|
||||||
|
|
||||||
|
def get_firewall_ipsets(self) -> List[Dict]:
|
||||||
|
"""Список IP set'ов: [{name, comment, digest}]."""
|
||||||
|
if not self.proxmox:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
return list(self.proxmox.cluster.firewall.ipset.get() or [])
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting firewall ipsets: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def create_firewall_ipset(self, name: str, comment: Optional[str] = None) -> Dict:
|
||||||
|
if not self.proxmox:
|
||||||
|
return {"success": False, "error": "Not connected"}
|
||||||
|
try:
|
||||||
|
params = {"name": name}
|
||||||
|
if comment:
|
||||||
|
params["comment"] = comment
|
||||||
|
self.proxmox.cluster.firewall.ipset.post(**params)
|
||||||
|
return {"success": True, "name": name}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error creating firewall ipset {name}: {e}")
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
def delete_firewall_ipset(self, name: str) -> Dict:
|
||||||
|
"""Удалить IP set целиком (должен быть пустым в PVE)."""
|
||||||
|
if not self.proxmox:
|
||||||
|
return {"success": False, "error": "Not connected"}
|
||||||
|
try:
|
||||||
|
self.proxmox.cluster.firewall.ipset(name).delete()
|
||||||
|
return {"success": True}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error deleting firewall ipset {name}: {e}")
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
def get_firewall_ipset_entries(self, name: str) -> List[Dict]:
|
||||||
|
if not self.proxmox:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
return list(self.proxmox.cluster.firewall.ipset(name).get() or [])
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting entries for ipset {name}: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def add_firewall_ipset_entry(self, name: str, cidr: str,
|
||||||
|
comment: Optional[str] = None,
|
||||||
|
nomatch: bool = False) -> Dict:
|
||||||
|
if not self.proxmox:
|
||||||
|
return {"success": False, "error": "Not connected"}
|
||||||
|
try:
|
||||||
|
params = {"cidr": cidr}
|
||||||
|
if comment:
|
||||||
|
params["comment"] = comment
|
||||||
|
if nomatch:
|
||||||
|
params["nomatch"] = 1
|
||||||
|
self.proxmox.cluster.firewall.ipset(name).post(**params)
|
||||||
|
return {"success": True}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error adding entry {cidr} to ipset {name}: {e}")
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
def delete_firewall_ipset_entry(self, name: str, cidr: str) -> Dict:
|
||||||
|
if not self.proxmox:
|
||||||
|
return {"success": False, "error": "Not connected"}
|
||||||
|
try:
|
||||||
|
self.proxmox.cluster.firewall.ipset(name)(cidr).delete()
|
||||||
|
return {"success": True}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error deleting entry {cidr} from ipset {name}: {e}")
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
# ---------- Aliases ----------
|
||||||
|
|
||||||
|
def get_firewall_aliases(self) -> List[Dict]:
|
||||||
|
"""Список алиасов: [{name, cidr, comment, ipversion}]."""
|
||||||
|
if not self.proxmox:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
return list(self.proxmox.cluster.firewall.aliases.get() or [])
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting firewall aliases: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def create_firewall_alias(self, name: str, cidr: str,
|
||||||
|
comment: Optional[str] = None) -> Dict:
|
||||||
|
if not self.proxmox:
|
||||||
|
return {"success": False, "error": "Not connected"}
|
||||||
|
try:
|
||||||
|
params = {"name": name, "cidr": cidr}
|
||||||
|
if comment:
|
||||||
|
params["comment"] = comment
|
||||||
|
self.proxmox.cluster.firewall.aliases.post(**params)
|
||||||
|
return {"success": True, "name": name}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error creating firewall alias {name}: {e}")
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
def update_firewall_alias(self, name: str, cidr: str,
|
||||||
|
comment: Optional[str] = None,
|
||||||
|
rename: Optional[str] = None) -> Dict:
|
||||||
|
if not self.proxmox:
|
||||||
|
return {"success": False, "error": "Not connected"}
|
||||||
|
try:
|
||||||
|
params = {"cidr": cidr}
|
||||||
|
if comment is not None:
|
||||||
|
params["comment"] = comment
|
||||||
|
if rename:
|
||||||
|
params["rename"] = rename
|
||||||
|
self.proxmox.cluster.firewall.aliases(name).put(**params)
|
||||||
|
return {"success": True}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error updating firewall alias {name}: {e}")
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
def delete_firewall_alias(self, name: str) -> Dict:
|
||||||
|
if not self.proxmox:
|
||||||
|
return {"success": False, "error": "Not connected"}
|
||||||
|
try:
|
||||||
|
self.proxmox.cluster.firewall.aliases(name).delete()
|
||||||
|
return {"success": True}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error deleting firewall alias {name}: {e}")
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
# ---------- Node-level: options ----------
|
||||||
|
|
||||||
|
def get_node_firewall_options(self, node: str) -> Dict:
|
||||||
|
"""Опции фаервола ноды (enable, log_level_in/out, nosmurfs, …)."""
|
||||||
|
if not self.proxmox:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return dict(self.proxmox.nodes(node).firewall.options.get() or {})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting node {node} firewall options: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def update_node_firewall_options(self, node: str, **opts) -> Dict:
|
||||||
|
if not self.proxmox:
|
||||||
|
return {"success": False, "error": "Not connected"}
|
||||||
|
try:
|
||||||
|
self.proxmox.nodes(node).firewall.options.put(**opts)
|
||||||
|
return {"success": True}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error updating node {node} firewall options: {e}")
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
# ---------- Node-level: rules ----------
|
||||||
|
|
||||||
|
def get_node_firewall_rules(self, node: str) -> List[Dict]:
|
||||||
|
if not self.proxmox:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
return list(self.proxmox.nodes(node).firewall.rules.get() or [])
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting node {node} firewall rules: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def create_node_firewall_rule(self, node: str, **rule) -> Dict:
|
||||||
|
if not self.proxmox:
|
||||||
|
return {"success": False, "error": "Not connected"}
|
||||||
|
try:
|
||||||
|
self.proxmox.nodes(node).firewall.rules.post(**rule)
|
||||||
|
return {"success": True}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error creating node {node} firewall rule: {e}")
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
def update_node_firewall_rule(self, node: str, pos: int, **rule) -> Dict:
|
||||||
|
if not self.proxmox:
|
||||||
|
return {"success": False, "error": "Not connected"}
|
||||||
|
try:
|
||||||
|
self.proxmox.nodes(node).firewall.rules(pos).put(**rule)
|
||||||
|
return {"success": True}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error updating node {node} firewall rule {pos}: {e}")
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
def delete_node_firewall_rule(self, node: str, pos: int) -> Dict:
|
||||||
|
if not self.proxmox:
|
||||||
|
return {"success": False, "error": "Not connected"}
|
||||||
|
try:
|
||||||
|
self.proxmox.nodes(node).firewall.rules(pos).delete()
|
||||||
|
return {"success": True}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error deleting node {node} firewall rule {pos}: {e}")
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
def get_node_firewall_log(self, node: str, limit: int = 100, start: int = 0) -> List[Dict]:
|
||||||
|
"""Строки firewall-лога ноды: [{n, t}]."""
|
||||||
|
if not self.proxmox:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
return list(self.proxmox.nodes(node).firewall.log.get(limit=limit, start=start) or [])
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting node {node} firewall log: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
# ---------- Guest-level (VM/LXC) ----------
|
||||||
|
|
||||||
|
def _guest_fw(self, node: str, vm_type: str, vmid: int):
|
||||||
|
"""Ресурс /nodes/{node}/{qemu|lxc}/{vmid}/firewall для proxmoxer."""
|
||||||
|
kind = "qemu" if vm_type in ("vm", "qemu") else "lxc"
|
||||||
|
return self.proxmox.nodes(node)(kind)(vmid).firewall
|
||||||
|
|
||||||
|
def get_guest_firewall_options(self, node: str, vm_type: str, vmid: int) -> Dict:
|
||||||
|
"""Опции фаервола VM/LXC (enable, dhcp, macfilter, ipfilter, policy_in/out, …)."""
|
||||||
|
if not self.proxmox:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return dict(self._guest_fw(node, vm_type, vmid).options.get() or {})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting guest {vmid} firewall options: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def update_guest_firewall_options(self, node: str, vm_type: str, vmid: int, **opts) -> Dict:
|
||||||
|
if not self.proxmox:
|
||||||
|
return {"success": False, "error": "Not connected"}
|
||||||
|
try:
|
||||||
|
self._guest_fw(node, vm_type, vmid).options.put(**opts)
|
||||||
|
return {"success": True}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error updating guest {vmid} firewall options: {e}")
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
def get_guest_firewall_rules(self, node: str, vm_type: str, vmid: int) -> List[Dict]:
|
||||||
|
if not self.proxmox:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
return list(self._guest_fw(node, vm_type, vmid).rules.get() or [])
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting guest {vmid} firewall rules: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def create_guest_firewall_rule(self, node: str, vm_type: str, vmid: int, **rule) -> Dict:
|
||||||
|
if not self.proxmox:
|
||||||
|
return {"success": False, "error": "Not connected"}
|
||||||
|
try:
|
||||||
|
self._guest_fw(node, vm_type, vmid).rules.post(**rule)
|
||||||
|
return {"success": True}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error creating guest {vmid} firewall rule: {e}")
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
def update_guest_firewall_rule(self, node: str, vm_type: str, vmid: int, pos: int, **rule) -> Dict:
|
||||||
|
if not self.proxmox:
|
||||||
|
return {"success": False, "error": "Not connected"}
|
||||||
|
try:
|
||||||
|
self._guest_fw(node, vm_type, vmid).rules(pos).put(**rule)
|
||||||
|
return {"success": True}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error updating guest {vmid} firewall rule {pos}: {e}")
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
def delete_guest_firewall_rule(self, node: str, vm_type: str, vmid: int, pos: int) -> Dict:
|
||||||
|
if not self.proxmox:
|
||||||
|
return {"success": False, "error": "Not connected"}
|
||||||
|
try:
|
||||||
|
self._guest_fw(node, vm_type, vmid).rules(pos).delete()
|
||||||
|
return {"success": True}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error deleting guest {vmid} firewall rule {pos}: {e}")
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
def get_guest_firewall_log(self, node: str, vm_type: str, vmid: int,
|
||||||
|
limit: int = 100, start: int = 0) -> List[Dict]:
|
||||||
|
if not self.proxmox:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
return list(self._guest_fw(node, vm_type, vmid).log.get(limit=limit, start=start) or [])
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting guest {vmid} firewall log: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
# ---------- Macros (read-only, для подсказок при создании правил) ----------
|
||||||
|
|
||||||
|
def get_firewall_macros(self) -> List[Dict]:
|
||||||
|
if not self.proxmox:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
return list(self.proxmox.cluster.firewall.macros.get() or [])
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting firewall macros: {e}")
|
||||||
|
return []
|
||||||
97
backend/app/proxmox/mixins/node_admin.py
Normal file
97
backend/app/proxmox/mixins/node_admin.py
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
from typing import List, Dict
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
|
||||||
|
class NodeAdminMixin:
|
||||||
|
"""
|
||||||
|
Администрирование ноды: systemd-сервисы (/nodes/{node}/services)
|
||||||
|
и APT (обновления и репозитории, /nodes/{node}/apt).
|
||||||
|
"""
|
||||||
|
|
||||||
|
# ---------- Services ----------
|
||||||
|
|
||||||
|
def get_node_services(self, node: str) -> List[Dict]:
|
||||||
|
"""Список сервисов ноды: [{service, name, desc, state, active-state}]."""
|
||||||
|
if not self.proxmox:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
return list(self.proxmox.nodes(node).services.get() or [])
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting services for {node}: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def node_service_action(self, node: str, service: str, action: str) -> Dict:
|
||||||
|
"""
|
||||||
|
Управление сервисом: start | stop | restart | reload.
|
||||||
|
Возвращает UPID задачи.
|
||||||
|
"""
|
||||||
|
if action not in ("start", "stop", "restart", "reload"):
|
||||||
|
return {"success": False, "error": "invalid action"}
|
||||||
|
if not self.proxmox:
|
||||||
|
return {"success": False, "error": "Not connected"}
|
||||||
|
try:
|
||||||
|
endpoint = getattr(self.proxmox.nodes(node).services(service), action)
|
||||||
|
upid = endpoint.post()
|
||||||
|
return {"success": True, "upid": upid}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error {action} service {service} on {node}: {e}")
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
# ---------- APT: updates ----------
|
||||||
|
|
||||||
|
def get_apt_updates(self, node: str) -> List[Dict]:
|
||||||
|
"""Доступные обновления пакетов: [{Package, Version, OldVersion, Title}]."""
|
||||||
|
if not self.proxmox:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
return list(self.proxmox.nodes(node).apt.update.get() or [])
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting APT updates for {node}: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def apt_refresh(self, node: str) -> Dict:
|
||||||
|
"""Обновить списки пакетов (apt-get update). Возвращает UPID."""
|
||||||
|
if not self.proxmox:
|
||||||
|
return {"success": False, "error": "Not connected"}
|
||||||
|
try:
|
||||||
|
upid = self.proxmox.nodes(node).apt.update.post()
|
||||||
|
return {"success": True, "upid": upid}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error refreshing APT on {node}: {e}")
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
# ---------- APT: repositories ----------
|
||||||
|
|
||||||
|
def get_apt_repositories(self, node: str) -> Dict:
|
||||||
|
"""Информация о репозиториях APT (files, standard-repos, errors)."""
|
||||||
|
if not self.proxmox:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return dict(self.proxmox.nodes(node).apt.repositories.get() or {})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting APT repositories for {node}: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def set_apt_repository(self, node: str, path: str, index: int, enabled: bool) -> Dict:
|
||||||
|
"""Включить/выключить конкретный репозиторий (по файлу и индексу)."""
|
||||||
|
if not self.proxmox:
|
||||||
|
return {"success": False, "error": "Not connected"}
|
||||||
|
try:
|
||||||
|
self.proxmox.nodes(node).apt.repositories.post(
|
||||||
|
path=path, index=index, enabled=1 if enabled else 0,
|
||||||
|
)
|
||||||
|
return {"success": True}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error toggling APT repo {path}#{index} on {node}: {e}")
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
def add_apt_standard_repository(self, node: str, handle: str) -> Dict:
|
||||||
|
"""Добавить стандартный репозиторий PVE (handle из standard-repos)."""
|
||||||
|
if not self.proxmox:
|
||||||
|
return {"success": False, "error": "Not connected"}
|
||||||
|
try:
|
||||||
|
self.proxmox.nodes(node).apt.repositories.put(handle=handle)
|
||||||
|
return {"success": True}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error adding standard repo {handle} on {node}: {e}")
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
82
backend/app/proxmox/mixins/pools.py
Normal file
82
backend/app/proxmox/mixins/pools.py
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
from typing import List, Dict, Optional
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
|
||||||
|
class PoolsMixin:
|
||||||
|
"""
|
||||||
|
Resource pools (/pools) — группировка VM/LXC и хранилищ.
|
||||||
|
Геттеры возвращают данные как есть, мутации — {"success": bool, "error"?: str}.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get_pools(self) -> List[Dict]:
|
||||||
|
"""Список пулов: [{poolid, comment}]."""
|
||||||
|
if not self.proxmox:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
return list(self.proxmox.pools.get() or [])
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting pools: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_pool(self, poolid: str) -> Dict:
|
||||||
|
"""Детали пула, включая members (VM/LXC/storage)."""
|
||||||
|
if not self.proxmox:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return dict(self.proxmox.pools(poolid).get() or {})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting pool {poolid}: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def create_pool(self, poolid: str, comment: Optional[str] = None) -> Dict:
|
||||||
|
if not self.proxmox:
|
||||||
|
return {"success": False, "error": "Not connected"}
|
||||||
|
try:
|
||||||
|
params = {"poolid": poolid}
|
||||||
|
if comment:
|
||||||
|
params["comment"] = comment
|
||||||
|
self.proxmox.pools.post(**params)
|
||||||
|
return {"success": True, "poolid": poolid}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error creating pool {poolid}: {e}")
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
def update_pool(self, poolid: str, vms: Optional[str] = None,
|
||||||
|
storage: Optional[str] = None, comment: Optional[str] = None,
|
||||||
|
delete: bool = False) -> Dict:
|
||||||
|
"""
|
||||||
|
Добавить/убрать участников пула.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
vms: список vmid через запятую ("100,101")
|
||||||
|
storage: список storage id через запятую
|
||||||
|
delete: True — убрать указанных участников, иначе добавить
|
||||||
|
"""
|
||||||
|
if not self.proxmox:
|
||||||
|
return {"success": False, "error": "Not connected"}
|
||||||
|
try:
|
||||||
|
params: Dict = {}
|
||||||
|
if vms:
|
||||||
|
params["vms"] = vms
|
||||||
|
if storage:
|
||||||
|
params["storage"] = storage
|
||||||
|
if comment is not None:
|
||||||
|
params["comment"] = comment
|
||||||
|
if delete:
|
||||||
|
params["delete"] = 1
|
||||||
|
self.proxmox.pools(poolid).put(**params)
|
||||||
|
return {"success": True}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error updating pool {poolid}: {e}")
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
def delete_pool(self, poolid: str) -> Dict:
|
||||||
|
"""Удалить пул (в PVE должен быть пустым)."""
|
||||||
|
if not self.proxmox:
|
||||||
|
return {"success": False, "error": "Not connected"}
|
||||||
|
try:
|
||||||
|
self.proxmox.pools(poolid).delete()
|
||||||
|
return {"success": True}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error deleting pool {poolid}: {e}")
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
@@ -215,3 +215,43 @@ class StorageMixin:
|
|||||||
logger.error(f"Ошибка получения backup-хранилищ для {node}: {e}")
|
logger.error(f"Ошибка получения backup-хранилищ для {node}: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
def get_node_pci_devices(self, node: str) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
Получить список PCI-устройств ноды для passthrough (hostpciN).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Список Dict с полями: id (0000:01:00.0), vendor_name, device_name,
|
||||||
|
iommugroup, mdev, subsystem_* и т.д. Отсортирован по id.
|
||||||
|
"""
|
||||||
|
if not self.proxmox:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
devices = self.proxmox.nodes(node).hardware.pci.get()
|
||||||
|
return sorted(devices, key=lambda d: d.get('id', ''))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка получения PCI-устройств ноды {node}: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_node_usb_devices(self, node: str) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
Получить список USB-устройств ноды для passthrough (usbN).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Список Dict с полями: busnum, devnum, vendid, prodid, port,
|
||||||
|
usbpath, class, speed, prodname/manufacturer. Отсортирован по usbpath.
|
||||||
|
"""
|
||||||
|
if not self.proxmox:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
# Не все версии PVE отдают hardware/usb; scanusb — исторический fallback.
|
||||||
|
try:
|
||||||
|
devices = self.proxmox.nodes(node).hardware.usb.get()
|
||||||
|
except Exception:
|
||||||
|
devices = self.proxmox.nodes(node).scanusb.get()
|
||||||
|
# Отбрасываем корневые хабы без vendor/product id — их нельзя пробросить.
|
||||||
|
cleaned = [d for d in devices if d.get('vendid') and d.get('prodid')]
|
||||||
|
return sorted(cleaned, key=lambda d: str(d.get('usbpath') or d.get('port') or ''))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка получения USB-устройств ноды {node}: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ function SelectTrigger({
|
|||||||
data-slot="select-trigger"
|
data-slot="select-trigger"
|
||||||
data-size={size}
|
data-size={size}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"flex w-full items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -20,7 +20,20 @@ import { formatBytes } from '@/lib/format';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useConfirm } from '@/components/shared/ConfirmDialog';
|
import { useConfirm } from '@/components/shared/ConfirmDialog';
|
||||||
|
|
||||||
const STORAGE_TYPES = ['dir', 'nfs', 'cifs', 'pbs'];
|
const STORAGE_TYPES = ['dir', 'nfs', 'cifs', 'pbs', 'lvm', 'lvmthin', 'zfspool', 'rbd', 'cephfs'];
|
||||||
|
|
||||||
|
/** Разумный content по умолчанию для типа хранилища. */
|
||||||
|
const DEFAULT_CONTENT: Record<string, string> = {
|
||||||
|
dir: 'images,iso,vztmpl,backup',
|
||||||
|
nfs: 'images,iso,vztmpl,backup',
|
||||||
|
cifs: 'images,iso,vztmpl,backup',
|
||||||
|
pbs: 'backup',
|
||||||
|
lvm: 'images,rootdir',
|
||||||
|
lvmthin: 'images,rootdir',
|
||||||
|
zfspool: 'images,rootdir',
|
||||||
|
rbd: 'images,rootdir',
|
||||||
|
cephfs: 'backup,iso,vztmpl',
|
||||||
|
};
|
||||||
|
|
||||||
interface StorageRow {
|
interface StorageRow {
|
||||||
storage: string;
|
storage: string;
|
||||||
@@ -51,12 +64,19 @@ interface StorageForm {
|
|||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
fingerprint: string;
|
fingerprint: string;
|
||||||
|
vgname: string; // lvm / lvmthin — volume group
|
||||||
|
thinpool: string; // lvmthin — thin pool
|
||||||
|
pool: string; // zfspool — пул/датасет; rbd — ceph pool
|
||||||
|
monhost: string; // rbd / cephfs (внешний ceph)
|
||||||
|
fsname: string; // cephfs — имя файловой системы
|
||||||
|
sparse: boolean; // zfspool — тонкое выделение
|
||||||
}
|
}
|
||||||
|
|
||||||
const emptyForm: StorageForm = {
|
const emptyForm: StorageForm = {
|
||||||
storage: '', type: 'dir', content: 'backup', nodes: '',
|
storage: '', type: 'dir', content: 'images,iso,vztmpl,backup', nodes: '',
|
||||||
path: '', server: '', export: '', share: '', datastore: '',
|
path: '', server: '', export: '', share: '', datastore: '',
|
||||||
username: '', password: '', fingerprint: '',
|
username: '', password: '', fingerprint: '',
|
||||||
|
vgname: '', thinpool: '', pool: '', monhost: '', fsname: '', sparse: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
function StorageDialog({ serverId, open, onOpenChange, editing }: {
|
function StorageDialog({ serverId, open, onOpenChange, editing }: {
|
||||||
@@ -113,6 +133,19 @@ function StorageDialog({ serverId, open, onOpenChange, editing }: {
|
|||||||
if (form.password) payload.password = form.password;
|
if (form.password) payload.password = form.password;
|
||||||
if (form.fingerprint.trim()) payload.fingerprint = form.fingerprint.trim();
|
if (form.fingerprint.trim()) payload.fingerprint = form.fingerprint.trim();
|
||||||
}
|
}
|
||||||
|
if (form.type === 'lvm') payload.vgname = form.vgname.trim();
|
||||||
|
if (form.type === 'lvmthin') { payload.vgname = form.vgname.trim(); payload.thinpool = form.thinpool.trim(); }
|
||||||
|
if (form.type === 'zfspool') { payload.pool = form.pool.trim(); if (form.sparse) payload.sparse = 1; }
|
||||||
|
if (form.type === 'rbd') {
|
||||||
|
payload.pool = form.pool.trim();
|
||||||
|
if (form.monhost.trim()) payload.monhost = form.monhost.trim();
|
||||||
|
if (form.username.trim()) payload.username = form.username.trim();
|
||||||
|
}
|
||||||
|
if (form.type === 'cephfs') {
|
||||||
|
if (form.fsname.trim()) payload['fs-name'] = form.fsname.trim();
|
||||||
|
if (form.monhost.trim()) payload.monhost = form.monhost.trim();
|
||||||
|
if (form.username.trim()) payload.username = form.username.trim();
|
||||||
|
}
|
||||||
createStorage.mutate(payload, {
|
createStorage.mutate(payload, {
|
||||||
onSuccess: () => done(t('storages.created')),
|
onSuccess: () => done(t('storages.created')),
|
||||||
onError: (e: Error) => toast.error(e.message),
|
onError: (e: Error) => toast.error(e.message),
|
||||||
@@ -133,7 +166,11 @@ function StorageDialog({ serverId, open, onOpenChange, editing }: {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label>{t('storages.type')}</Label>
|
<Label>{t('storages.type')}</Label>
|
||||||
<Select value={form.type} onValueChange={v => { if (v) set('type', v); }} disabled={!!editing}>
|
<Select
|
||||||
|
value={form.type}
|
||||||
|
onValueChange={v => { if (v) setForm(p => ({ ...p, type: v, content: DEFAULT_CONTENT[v] ?? p.content })); }}
|
||||||
|
disabled={!!editing}
|
||||||
|
>
|
||||||
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>{STORAGE_TYPES.map(ty => <SelectItem key={ty} value={ty}>{ty.toUpperCase()}</SelectItem>)}</SelectContent>
|
<SelectContent>{STORAGE_TYPES.map(ty => <SelectItem key={ty} value={ty}>{ty.toUpperCase()}</SelectItem>)}</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -166,6 +203,38 @@ function StorageDialog({ serverId, open, onOpenChange, editing }: {
|
|||||||
<div className="col-span-2"><Label>{t('storages.fingerprint')}</Label><Input value={form.fingerprint} onChange={e => set('fingerprint', e.target.value)} className="mt-1 font-mono" /></div>
|
<div className="col-span-2"><Label>{t('storages.fingerprint')}</Label><Input value={form.fingerprint} onChange={e => set('fingerprint', e.target.value)} className="mt-1 font-mono" /></div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{!editing && form.type === 'lvm' && (
|
||||||
|
<div><Label>{t('storages.vgname')}</Label><Input value={form.vgname} onChange={e => set('vgname', e.target.value)} className="mt-1 font-mono" placeholder="pve" /></div>
|
||||||
|
)}
|
||||||
|
{!editing && form.type === 'lvmthin' && (
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div><Label>{t('storages.vgname')}</Label><Input value={form.vgname} onChange={e => set('vgname', e.target.value)} className="mt-1 font-mono" placeholder="pve" /></div>
|
||||||
|
<div><Label>{t('storages.thinpool')}</Label><Input value={form.thinpool} onChange={e => set('thinpool', e.target.value)} className="mt-1 font-mono" placeholder="data" /></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!editing && form.type === 'zfspool' && (
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div><Label>{t('storages.zfs_pool')}</Label><Input value={form.pool} onChange={e => set('pool', e.target.value)} className="mt-1 font-mono" placeholder="rpool/data" /></div>
|
||||||
|
<label className="flex items-end gap-2 pb-1.5 text-sm">
|
||||||
|
<input type="checkbox" checked={form.sparse} onChange={e => set('sparse', e.target.checked)} />
|
||||||
|
{t('storages.sparse')}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!editing && form.type === 'rbd' && (
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div><Label>{t('storages.ceph_pool')}</Label><Input value={form.pool} onChange={e => set('pool', e.target.value)} className="mt-1 font-mono" placeholder="vm-pool" /></div>
|
||||||
|
<div><Label>{t('storages.username')}</Label><Input value={form.username} onChange={e => set('username', e.target.value)} className="mt-1" placeholder="admin" /></div>
|
||||||
|
<div className="col-span-2"><Label>{t('storages.monhost')}</Label><Input value={form.monhost} onChange={e => set('monhost', e.target.value)} className="mt-1 font-mono" placeholder="10.0.0.1 10.0.0.2 (внешний Ceph)" /></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!editing && form.type === 'cephfs' && (
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div><Label>{t('storages.fsname')}</Label><Input value={form.fsname} onChange={e => set('fsname', e.target.value)} className="mt-1 font-mono" placeholder="cephfs" /></div>
|
||||||
|
<div><Label>{t('storages.username')}</Label><Input value={form.username} onChange={e => set('username', e.target.value)} className="mt-1" placeholder="admin" /></div>
|
||||||
|
<div className="col-span-2"><Label>{t('storages.monhost')}</Label><Input value={form.monhost} onChange={e => set('monhost', e.target.value)} className="mt-1 font-mono" placeholder="10.0.0.1 10.0.0.2 (внешний Ceph)" /></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div><Label>{t('storages.content')}</Label><Input value={form.content} onChange={e => set('content', e.target.value)} className="mt-1" placeholder="backup,images,iso" /></div>
|
<div><Label>{t('storages.content')}</Label><Input value={form.content} onChange={e => set('content', e.target.value)} className="mt-1" placeholder="backup,images,iso" /></div>
|
||||||
<div><Label>{t('storages.nodes')}</Label><Input value={form.nodes} onChange={e => set('nodes', e.target.value)} className="mt-1" placeholder={t('storages.nodes_placeholder')} /></div>
|
<div><Label>{t('storages.nodes')}</Label><Input value={form.nodes} onChange={e => set('nodes', e.target.value)} className="mt-1" placeholder={t('storages.nodes_placeholder')} /></div>
|
||||||
@@ -395,6 +464,7 @@ export default function StoragesTab() {
|
|||||||
const { data: storagesData, isLoading } = useBackupStorages(serverId);
|
const { data: storagesData, isLoading } = useBackupStorages(serverId);
|
||||||
const { data: jobsData } = useProxmoxBackupJobs(serverId);
|
const { data: jobsData } = useProxmoxBackupJobs(serverId);
|
||||||
const deleteStorage = useDeleteStorage(serverId);
|
const deleteStorage = useDeleteStorage(serverId);
|
||||||
|
const updateStorage = useUpdateStorage(serverId);
|
||||||
const updateJob = useUpdateProxmoxJob(serverId);
|
const updateJob = useUpdateProxmoxJob(serverId);
|
||||||
const deleteJob = useDeleteProxmoxJob(serverId);
|
const deleteJob = useDeleteProxmoxJob(serverId);
|
||||||
|
|
||||||
@@ -419,6 +489,14 @@ export default function StoragesTab() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleToggleStorage = (s: StorageRow) => {
|
||||||
|
const disabling = s.disable !== 1;
|
||||||
|
updateStorage.mutate({ storage: s.storage, disable: disabling ? 1 : 0 }, {
|
||||||
|
onSuccess: () => toast.success(disabling ? t('storages.disabled_ok', 'Хранилище отключено') : t('storages.enabled_ok', 'Хранилище включено')),
|
||||||
|
onError: (e: Error) => toast.error(e.message),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleDeleteJob = async (job: NativeJob) => {
|
const handleDeleteJob = async (job: NativeJob) => {
|
||||||
if (!await confirm(`${t('storages.delete_job_confirm')} "${String(job.id)}"?`)) return;
|
if (!await confirm(`${t('storages.delete_job_confirm')} "${String(job.id)}"?`)) return;
|
||||||
deleteJob.mutate(String(job.id), {
|
deleteJob.mutate(String(job.id), {
|
||||||
@@ -498,6 +576,11 @@ export default function StoragesTab() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<div className="flex justify-end gap-1">
|
<div className="flex justify-end gap-1">
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7"
|
||||||
|
title={s.disable === 1 ? t('backups.enable') : t('backups.disable')}
|
||||||
|
onClick={() => handleToggleStorage(s)}>
|
||||||
|
{s.disable === 1 ? <Power className="h-3.5 w-3.5" /> : <PowerOff className="h-3.5 w-3.5" />}
|
||||||
|
</Button>
|
||||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => { setEditing(s); setDialogOpen(true); }}>
|
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => { setEditing(s); setDialogOpen(true); }}>
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
175
frontend/src/features/instances/AddPciDeviceDialog.tsx
Normal file
175
frontend/src/features/instances/AddPciDeviceDialog.tsx
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Cpu } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { useNodePciDevices, useUpdateConfig } from '@/hooks/use-instances';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
serverId: number;
|
||||||
|
vmid: number;
|
||||||
|
node: string;
|
||||||
|
config?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Следующий свободный слот hostpciN из текущего конфига. */
|
||||||
|
function nextHostpciKey(config?: Record<string, unknown>): string {
|
||||||
|
const used = new Set(
|
||||||
|
Object.keys(config ?? {})
|
||||||
|
.filter((k) => /^hostpci\d+$/.test(k))
|
||||||
|
.map((k) => Number(k.slice(7))),
|
||||||
|
);
|
||||||
|
let n = 0;
|
||||||
|
while (used.has(n)) n++;
|
||||||
|
return `hostpci${n}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AddPciDeviceDialog({ open, onClose, serverId, vmid, node, config }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { data: devices, isLoading } = useNodePciDevices(serverId, node, open);
|
||||||
|
const updateConfig = useUpdateConfig(serverId, vmid, 'qemu', node);
|
||||||
|
|
||||||
|
const [deviceId, setDeviceId] = useState('');
|
||||||
|
const [pcie, setPcie] = useState(true);
|
||||||
|
const [allFunctions, setAllFunctions] = useState(false);
|
||||||
|
const [primaryGpu, setPrimaryGpu] = useState(false);
|
||||||
|
const [romBar, setRomBar] = useState(true);
|
||||||
|
|
||||||
|
const hostpciKey = useMemo(() => nextHostpciKey(config), [config]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setDeviceId('');
|
||||||
|
setPcie(true);
|
||||||
|
setAllFunctions(false);
|
||||||
|
setPrimaryGpu(false);
|
||||||
|
setRomBar(true);
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const buildValue = (): string => {
|
||||||
|
// "All functions" пробрасывает всё устройство: 0000:01:00 (без .функции)
|
||||||
|
const id = allFunctions ? deviceId.replace(/\.\d+$/, '') : deviceId;
|
||||||
|
const parts = [id];
|
||||||
|
if (pcie) parts.push('pcie=1');
|
||||||
|
if (primaryGpu) parts.push('x-vga=1');
|
||||||
|
if (!romBar) parts.push('rombar=0');
|
||||||
|
return parts.join(',');
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = () => {
|
||||||
|
if (!deviceId) {
|
||||||
|
toast.error(t('hw.err_pci_required', 'Выберите PCI-устройство'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateConfig.mutate(
|
||||||
|
{ [hostpciKey]: buildValue() },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(t('hw.pci_added', 'Устройство {{key}} добавлено', { key: hostpciKey }));
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
onError: (e) => toast.error((e as Error).message),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Cpu className="h-4 w-4" />
|
||||||
|
{t('hw.add_pci_title', 'Проброс PCI-устройства')}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t('hw.add_pci_description', 'Будет добавлено как {{key}}. VM должна быть выключена.', { key: hostpciKey })}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>{t('hw.pci_device', 'Устройство')}</Label>
|
||||||
|
<Select value={deviceId} onValueChange={setDeviceId}>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue
|
||||||
|
placeholder={
|
||||||
|
isLoading
|
||||||
|
? t('common.loading', 'Загрузка…')
|
||||||
|
: t('hw.select_pci', 'Выберите устройство')
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(devices ?? []).map((d) => {
|
||||||
|
const label = [d.device_name, d.vendor_name].filter(Boolean).join(' — ');
|
||||||
|
return (
|
||||||
|
<SelectItem key={d.id} value={d.id}>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span className="font-mono text-xs">{d.id}</span>
|
||||||
|
{label && <span className="text-xs text-muted-foreground">{label}</span>}
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{!isLoading && (devices ?? []).length === 0 && (
|
||||||
|
<div className="px-2 py-1.5 text-xs text-muted-foreground">
|
||||||
|
{t('hw.no_pci', 'Устройства не найдены')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<input type="checkbox" checked={pcie} onChange={(e) => setPcie(e.target.checked)} />
|
||||||
|
{t('hw.pci_pcie', 'PCI-Express (только для q35)')}
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<input type="checkbox" checked={allFunctions} onChange={(e) => setAllFunctions(e.target.checked)} />
|
||||||
|
{t('hw.pci_all_functions', 'Все функции устройства')}
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<input type="checkbox" checked={primaryGpu} onChange={(e) => setPrimaryGpu(e.target.checked)} />
|
||||||
|
{t('hw.pci_primary_gpu', 'Основная видеокарта (x-vga)')}
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<input type="checkbox" checked={romBar} onChange={(e) => setRomBar(e.target.checked)} />
|
||||||
|
{t('hw.pci_rombar', 'ROM-Bar')}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t('hw.pci_hint', 'Проброс PCI требует включённого IOMMU (VT-d / AMD-Vi) на хосте.')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={onClose}>{t('common.cancel', 'Отмена')}</Button>
|
||||||
|
<Button onClick={submit} disabled={updateConfig.isPending || !deviceId}>
|
||||||
|
{t('hw.add_submit', 'Добавить')}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
145
frontend/src/features/instances/AddUsbDeviceDialog.tsx
Normal file
145
frontend/src/features/instances/AddUsbDeviceDialog.tsx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Usb } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { useNodeUsbDevices, useUpdateConfig } from '@/hooks/use-instances';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
serverId: number;
|
||||||
|
vmid: number;
|
||||||
|
node: string;
|
||||||
|
config?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Следующий свободный слот usbN из текущего конфига (макс. usb0..usb4). */
|
||||||
|
function nextUsbKey(config?: Record<string, unknown>): string {
|
||||||
|
const used = new Set(
|
||||||
|
Object.keys(config ?? {})
|
||||||
|
.filter((k) => /^usb\d+$/.test(k))
|
||||||
|
.map((k) => Number(k.slice(3))),
|
||||||
|
);
|
||||||
|
let n = 0;
|
||||||
|
while (used.has(n)) n++;
|
||||||
|
return `usb${n}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AddUsbDeviceDialog({ open, onClose, serverId, vmid, node, config }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { data: devices, isLoading } = useNodeUsbDevices(serverId, node, open);
|
||||||
|
const updateConfig = useUpdateConfig(serverId, vmid, 'qemu', node);
|
||||||
|
|
||||||
|
// value = "vendid:prodid" — привязка по конкретному устройству (host=id:id)
|
||||||
|
const [selected, setSelected] = useState('');
|
||||||
|
const [usb3, setUsb3] = useState(false);
|
||||||
|
|
||||||
|
const usbKey = useMemo(() => nextUsbKey(config), [config]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setSelected('');
|
||||||
|
setUsb3(false);
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const submit = () => {
|
||||||
|
if (!selected) {
|
||||||
|
toast.error(t('hw.err_usb_required', 'Выберите USB-устройство'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parts = [`host=${selected}`];
|
||||||
|
if (usb3) parts.push('usb3=1');
|
||||||
|
updateConfig.mutate(
|
||||||
|
{ [usbKey]: parts.join(',') },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(t('hw.usb_added', 'Устройство {{key}} добавлено', { key: usbKey }));
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
onError: (e) => toast.error((e as Error).message),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Usb className="h-4 w-4" />
|
||||||
|
{t('hw.add_usb_title', 'Проброс USB-устройства')}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t('hw.add_usb_description', 'Будет добавлено как {{key}}', { key: usbKey })}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>{t('hw.usb_device', 'Устройство')}</Label>
|
||||||
|
<Select value={selected} onValueChange={setSelected}>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue
|
||||||
|
placeholder={
|
||||||
|
isLoading
|
||||||
|
? t('common.loading', 'Загрузка…')
|
||||||
|
: t('hw.select_usb', 'Выберите устройство')
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(devices ?? []).map((d, i) => {
|
||||||
|
const id = `${d.vendid}:${d.prodid}`;
|
||||||
|
const label = [d.manufacturer, d.prodname].filter(Boolean).join(' ');
|
||||||
|
return (
|
||||||
|
<SelectItem key={`${id}-${d.usbpath ?? d.port ?? i}`} value={id}>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span className="font-mono text-xs">{id}</span>
|
||||||
|
{label && <span className="text-xs text-muted-foreground">{label}</span>}
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{!isLoading && (devices ?? []).length === 0 && (
|
||||||
|
<div className="px-2 py-1.5 text-xs text-muted-foreground">
|
||||||
|
{t('hw.no_usb', 'Устройства не найдены')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<input type="checkbox" checked={usb3} onChange={(e) => setUsb3(e.target.checked)} />
|
||||||
|
{t('hw.usb3', 'USB 3.0')}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={onClose}>{t('common.cancel', 'Отмена')}</Button>
|
||||||
|
<Button onClick={submit} disabled={updateConfig.isPending || !selected}>
|
||||||
|
{t('hw.add_submit', 'Добавить')}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -40,6 +40,8 @@ import OverviewTab from './tabs/OverviewTab';
|
|||||||
import GraphsTab from './tabs/GraphsTab';
|
import GraphsTab from './tabs/GraphsTab';
|
||||||
import SnapshotsTab from './tabs/SnapshotsTab';
|
import SnapshotsTab from './tabs/SnapshotsTab';
|
||||||
import NetworkingTab from './tabs/NetworkingTab';
|
import NetworkingTab from './tabs/NetworkingTab';
|
||||||
|
import HardwareTab from './tabs/HardwareTab';
|
||||||
|
import FirewallTab from './tabs/FirewallTab';
|
||||||
import SettingsTab from './tabs/SettingsTab';
|
import SettingsTab from './tabs/SettingsTab';
|
||||||
import DestroyTab from './tabs/DestroyTab';
|
import DestroyTab from './tabs/DestroyTab';
|
||||||
|
|
||||||
@@ -241,6 +243,10 @@ export default function InstanceDetailPage() {
|
|||||||
<TabsTrigger value="graphs">{t('instances.graphs', 'Graphs')}</TabsTrigger>
|
<TabsTrigger value="graphs">{t('instances.graphs', 'Graphs')}</TabsTrigger>
|
||||||
<TabsTrigger value="snapshots">{t('common.snapshots', 'Snapshots')}</TabsTrigger>
|
<TabsTrigger value="snapshots">{t('common.snapshots', 'Snapshots')}</TabsTrigger>
|
||||||
<TabsTrigger value="networking">{t('instances.networking', 'Networking')}</TabsTrigger>
|
<TabsTrigger value="networking">{t('instances.networking', 'Networking')}</TabsTrigger>
|
||||||
|
{type !== 'lxc' && (
|
||||||
|
<TabsTrigger value="hardware">{t('instances.hardware', 'Hardware')}</TabsTrigger>
|
||||||
|
)}
|
||||||
|
<TabsTrigger value="firewall">{t('instances.firewall', 'Firewall')}</TabsTrigger>
|
||||||
<TabsTrigger value="settings">{t('nav.settings', 'Settings')}</TabsTrigger>
|
<TabsTrigger value="settings">{t('nav.settings', 'Settings')}</TabsTrigger>
|
||||||
<TabsTrigger value="destroy" className="text-destructive data-[state=active]:text-destructive">
|
<TabsTrigger value="destroy" className="text-destructive data-[state=active]:text-destructive">
|
||||||
{t('instances.destroy', 'Destroy')}
|
{t('instances.destroy', 'Destroy')}
|
||||||
@@ -259,6 +265,14 @@ export default function InstanceDetailPage() {
|
|||||||
<TabsContent value="networking" className="mt-4">
|
<TabsContent value="networking" className="mt-4">
|
||||||
<NetworkingTab serverId={sid} vmid={vid} type={type} node={node} />
|
<NetworkingTab serverId={sid} vmid={vid} type={type} node={node} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
{type !== 'lxc' && (
|
||||||
|
<TabsContent value="hardware" className="mt-4">
|
||||||
|
<HardwareTab serverId={sid} vmid={vid} type={type} node={node} />
|
||||||
|
</TabsContent>
|
||||||
|
)}
|
||||||
|
<TabsContent value="firewall" className="mt-4">
|
||||||
|
<FirewallTab serverId={sid} vmid={vid} type={type} node={node} />
|
||||||
|
</TabsContent>
|
||||||
<TabsContent value="settings" className="mt-4">
|
<TabsContent value="settings" className="mt-4">
|
||||||
<SettingsTab serverId={sid} vmid={vid} type={type} node={node} />
|
<SettingsTab serverId={sid} vmid={vid} type={type} node={node} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|||||||
190
frontend/src/features/instances/tabs/FirewallTab.tsx
Normal file
190
frontend/src/features/instances/tabs/FirewallTab.tsx
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Plus, Power, Shield } from 'lucide-react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Table, TableBody, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import {
|
||||||
|
useFirewallGroups,
|
||||||
|
useGuestFirewallOptions, useUpdateGuestFirewallOptions,
|
||||||
|
useGuestFirewallRules, useCreateGuestFirewallRule, useUpdateGuestFirewallRule, useDeleteGuestFirewallRule,
|
||||||
|
useGuestFirewallLog,
|
||||||
|
type FirewallRule,
|
||||||
|
} from '@/hooks/use-firewall';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { useConfirm } from '@/components/shared/ConfirmDialog';
|
||||||
|
import { RuleDialog, RuleRow } from '@/features/nodes/FirewallManager';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
serverId: number;
|
||||||
|
vmid: number;
|
||||||
|
type: string; // 'qemu' | 'lxc'
|
||||||
|
node: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const POLICIES = ['ACCEPT', 'REJECT', 'DROP'];
|
||||||
|
|
||||||
|
export default function FirewallTab({ serverId, vmid, type, node }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const confirm = useConfirm();
|
||||||
|
const isLxc = type === 'lxc';
|
||||||
|
|
||||||
|
const { data: groupsData } = useFirewallGroups(serverId);
|
||||||
|
const { data: optionsData, isLoading } = useGuestFirewallOptions(serverId, type, vmid, node);
|
||||||
|
const { data: rulesData } = useGuestFirewallRules(serverId, type, vmid, node);
|
||||||
|
|
||||||
|
const updateOptions = useUpdateGuestFirewallOptions(serverId, type, vmid, node);
|
||||||
|
const createRule = useCreateGuestFirewallRule(serverId, type, vmid, node);
|
||||||
|
const updateRule = useUpdateGuestFirewallRule(serverId, type, vmid, node);
|
||||||
|
const deleteRule = useDeleteGuestFirewallRule(serverId, type, vmid, node);
|
||||||
|
|
||||||
|
const groups = groupsData?.groups ?? [];
|
||||||
|
const options = optionsData?.options ?? {};
|
||||||
|
const rules = rulesData?.rules ?? [];
|
||||||
|
const enabled = Number(options.enable) === 1;
|
||||||
|
|
||||||
|
const [ruleDialog, setRuleDialog] = useState(false);
|
||||||
|
const [logOpen, setLogOpen] = useState(false);
|
||||||
|
const logQuery = useGuestFirewallLog(serverId, type, vmid, node, logOpen);
|
||||||
|
const log = logQuery.data?.log ?? [];
|
||||||
|
|
||||||
|
const saveOption = (patch: Record<string, unknown>) => {
|
||||||
|
updateOptions.mutate(patch, {
|
||||||
|
onSuccess: () => toast.success(t('fw.options_saved', 'Опции сохранены')),
|
||||||
|
onError: (e: Error) => toast.error(e.message),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const addRule = (payload: Record<string, unknown>) => {
|
||||||
|
createRule.mutate(payload, {
|
||||||
|
onSuccess: () => { toast.success(t('fw.rule_added', 'Правило добавлено')); setRuleDialog(false); },
|
||||||
|
onError: (e: Error) => toast.error(e.message),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const toggleRule = (r: FirewallRule) => {
|
||||||
|
if (r.pos == null) return;
|
||||||
|
updateRule.mutate({ pos: r.pos, data: { enable: r.enable === 0 ? 1 : 0 } }, {
|
||||||
|
onError: (e: Error) => toast.error(e.message),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const removeRule = async (pos?: number) => {
|
||||||
|
if (pos == null || !await confirm(t('fw.delete_rule_confirm', 'Удалить правило?'))) return;
|
||||||
|
deleteRule.mutate(pos, { onSuccess: () => toast.success(t('fw.rule_deleted', 'Правило удалено')), onError: (e: Error) => toast.error(e.message) });
|
||||||
|
};
|
||||||
|
|
||||||
|
const boolOption = (key: string, label: string) => (
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<input type="checkbox" checked={Number(options[key]) === 1}
|
||||||
|
onChange={(e) => saveOption({ [key]: e.target.checked ? 1 : 0 })} />
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="max-w-3xl">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between gap-2 space-y-0">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Shield className="h-4 w-4" />
|
||||||
|
{t('fw.guest_title', 'Firewall инстанса')}
|
||||||
|
<Badge variant={enabled ? 'default' : 'secondary'}>{enabled ? t('fw.on', 'вкл') : t('fw.off', 'выкл')}</Badge>
|
||||||
|
</CardTitle>
|
||||||
|
<Button size="sm" variant={enabled ? 'outline' : 'default'} onClick={() => saveOption({ enable: enabled ? 0 : 1 })} disabled={updateOptions.isPending}>
|
||||||
|
<Power className="mr-1 h-4 w-4" />{enabled ? t('fw.disable', 'Выключить') : t('fw.enable', 'Включить')}
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="py-8 text-center text-sm text-muted-foreground">{t('common.loading', 'Загрузка…')}</p>
|
||||||
|
) : (
|
||||||
|
<Tabs defaultValue="rules" onValueChange={(v) => setLogOpen(v === 'log')}>
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="rules">{t('fw.rules', 'Правила')} ({rules.length})</TabsTrigger>
|
||||||
|
<TabsTrigger value="options">{t('fw.options', 'Опции')}</TabsTrigger>
|
||||||
|
<TabsTrigger value="log">{t('fw.log_tab', 'Лог')}</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* Rules */}
|
||||||
|
<TabsContent value="rules" className="mt-4 space-y-3">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button size="sm" onClick={() => setRuleDialog(true)}>
|
||||||
|
<Plus className="mr-1 h-4 w-4" />{t('fw.add_rule', 'Добавить правило')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{rules.length === 0 ? (
|
||||||
|
<p className="py-6 text-center text-sm text-muted-foreground">{t('common.no_data', 'Нет данных')}</p>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>#</TableHead>
|
||||||
|
<TableHead>{t('fw.direction', 'Напр.')}</TableHead>
|
||||||
|
<TableHead>{t('fw.action', 'Действие')}</TableHead>
|
||||||
|
<TableHead>{t('fw.proto', 'Прото')}</TableHead>
|
||||||
|
<TableHead>{t('fw.match', 'Источник → Назначение')}</TableHead>
|
||||||
|
<TableHead>{t('common.comment', 'Комментарий')}</TableHead>
|
||||||
|
<TableHead className="text-right">{t('common.actions', 'Действия')}</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{rules.map(r => (
|
||||||
|
<RuleRow key={r.pos} rule={r} onToggle={() => toggleRule(r)} onDelete={() => removeRule(r.pos)} />
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Options */}
|
||||||
|
<TabsContent value="options" className="mt-4 space-y-4 max-w-md">
|
||||||
|
<label className="flex items-center gap-2.5 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={enabled} onChange={(e) => saveOption({ enable: e.target.checked ? 1 : 0 })} className="h-4 w-4 accent-primary" />
|
||||||
|
<span className="text-sm">{t('fw.enable_guest', 'Включить фаервол инстанса')}</span>
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>{t('fw.policy_in', 'Политика входящих')}</Label>
|
||||||
|
<Select value={String(options.policy_in || 'DROP')} onValueChange={(v) => { if (v) saveOption({ policy_in: v }); }}>
|
||||||
|
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>{POLICIES.map(p => <SelectItem key={p} value={p}>{p}</SelectItem>)}</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>{t('fw.policy_out', 'Политика исходящих')}</Label>
|
||||||
|
<Select value={String(options.policy_out || 'ACCEPT')} onValueChange={(v) => { if (v) saveOption({ policy_out: v }); }}>
|
||||||
|
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>{POLICIES.map(p => <SelectItem key={p} value={p}>{p}</SelectItem>)}</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{boolOption('dhcp', t('fw.dhcp', 'Разрешить DHCP'))}
|
||||||
|
{boolOption('macfilter', t('fw.macfilter', 'MAC-фильтр'))}
|
||||||
|
{boolOption('ipfilter', t('fw.ipfilter', 'IP-фильтр (анти-спуфинг)'))}
|
||||||
|
{isLxc && boolOption('ndp', t('fw.ndp', 'NDP (IPv6)'))}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('fw.guest_options_hint', 'Фаервол инстанса работает только когда включён фаервол датацентра и стоит firewall=1 на NIC.')}</p>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Log */}
|
||||||
|
<TabsContent value="log" className="mt-4">
|
||||||
|
{logQuery.isLoading ? (
|
||||||
|
<p className="py-6 text-center text-sm text-muted-foreground">{t('common.loading', 'Загрузка…')}</p>
|
||||||
|
) : log.length === 0 ? (
|
||||||
|
<p className="py-6 text-center text-sm text-muted-foreground">{t('common.no_data', 'Нет данных')}</p>
|
||||||
|
) : (
|
||||||
|
<pre className="max-h-80 overflow-auto rounded-md bg-muted p-3 text-xs font-mono whitespace-pre-wrap">
|
||||||
|
{log.map(l => l.t).filter(Boolean).join('\n')}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
<RuleDialog open={ruleDialog} onOpenChange={setRuleDialog} onSubmit={addRule} pending={createRule.isPending} groups={groups} allowGroupType />
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
357
frontend/src/features/instances/tabs/HardwareTab.tsx
Normal file
357
frontend/src/features/instances/tabs/HardwareTab.tsx
Normal file
@@ -0,0 +1,357 @@
|
|||||||
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Cpu, Usb, Plus, Trash2, MemoryStick, Loader2, AlertTriangle } from 'lucide-react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { useVMConfig, useUpdateConfig, useVMStatus } from '@/hooks/use-instances';
|
||||||
|
import { useImageStorages } from '@/hooks/use-image-catalog';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import AddPciDeviceDialog from '../AddPciDeviceDialog';
|
||||||
|
import AddUsbDeviceDialog from '../AddUsbDeviceDialog';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
serverId: number;
|
||||||
|
vmid: number;
|
||||||
|
type: string;
|
||||||
|
node: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BIOS_OPTIONS = [
|
||||||
|
{ value: 'seabios', label: 'SeaBIOS (default)' },
|
||||||
|
{ value: 'ovmf', label: 'OVMF (UEFI)' },
|
||||||
|
];
|
||||||
|
const MACHINE_OPTIONS = [
|
||||||
|
{ value: 'pc', label: 'Default (i440fx)' },
|
||||||
|
{ value: 'q35', label: 'q35' },
|
||||||
|
];
|
||||||
|
const VGA_OPTIONS = ['std', 'qxl', 'virtio', 'vmware', 'serial0', 'none'];
|
||||||
|
const SCSIHW_OPTIONS = [
|
||||||
|
'virtio-scsi-single',
|
||||||
|
'virtio-scsi-pci',
|
||||||
|
'lsi',
|
||||||
|
'lsi53c810',
|
||||||
|
'megasas',
|
||||||
|
'pvscsi',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Нормализуем machine из конфига (напр. "pc-q35-8.1") к значению селекта. */
|
||||||
|
function machineToOption(raw: unknown): string {
|
||||||
|
const s = typeof raw === 'string' ? raw.toLowerCase() : '';
|
||||||
|
if (s.includes('q35')) return 'q35';
|
||||||
|
return 'pc';
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlatformCard({
|
||||||
|
serverId, vmid, node, config,
|
||||||
|
}: Props & { config?: Record<string, unknown> }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const updateConfig = useUpdateConfig(serverId, vmid, 'qemu', node);
|
||||||
|
|
||||||
|
const [bios, setBios] = useState('seabios');
|
||||||
|
const [machine, setMachine] = useState('pc');
|
||||||
|
const [vga, setVga] = useState('std');
|
||||||
|
const [scsihw, setScsihw] = useState('virtio-scsi-single');
|
||||||
|
const [sockets, setSockets] = useState('');
|
||||||
|
const [efiStorage, setEfiStorage] = useState('');
|
||||||
|
|
||||||
|
const hasEfiDisk = !!config?.efidisk0;
|
||||||
|
// EFI-диск нужно создать, только если переключаемся на OVMF и его ещё нет.
|
||||||
|
const needsEfiDisk = bios === 'ovmf' && !hasEfiDisk;
|
||||||
|
|
||||||
|
// Хранилища ноды, поддерживающие образы дисков (content=images) — для efidisk0.
|
||||||
|
const { data: storages, isLoading: storagesLoading } = useImageStorages(
|
||||||
|
serverId, node, needsEfiDisk ? 'images' : undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!config) return;
|
||||||
|
setBios(typeof config.bios === 'string' ? config.bios : 'seabios');
|
||||||
|
setMachine(machineToOption(config.machine));
|
||||||
|
setVga(typeof config.vga === 'string' ? String(config.vga).split(',')[0] : 'std');
|
||||||
|
setScsihw(typeof config.scsihw === 'string' ? config.scsihw : 'virtio-scsi-single');
|
||||||
|
setSockets(config.sockets != null ? String(config.sockets) : '');
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
|
const save = () => {
|
||||||
|
if (needsEfiDisk && !efiStorage) {
|
||||||
|
toast.error(t('hw.err_efi_storage', 'Выберите хранилище для EFI-диска'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const updates: Record<string, unknown> = { bios, machine, vga, scsihw };
|
||||||
|
if (sockets.trim() !== '') updates.sockets = Number(sockets);
|
||||||
|
// При переходе на OVMF без существующего EFI-диска создаём efidisk0.
|
||||||
|
// Формат как в web-интерфейсе PVE: <storage>:1,efitype=4m,pre-enrolled-keys=1
|
||||||
|
if (needsEfiDisk) {
|
||||||
|
updates.efidisk0 = `${efiStorage}:1,efitype=4m,pre-enrolled-keys=1`;
|
||||||
|
}
|
||||||
|
updateConfig.mutate(updates, {
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(t('hw.platform_saved', 'Оборудование обновлено'));
|
||||||
|
setEfiStorage('');
|
||||||
|
},
|
||||||
|
onError: (e: Error) => toast.error(e.message),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||||
|
<MemoryStick className="h-4 w-4" />
|
||||||
|
{t('hw.platform', 'Платформа')}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>{t('hw.bios', 'BIOS')}</Label>
|
||||||
|
<Select value={bios} onValueChange={setBios}>
|
||||||
|
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{BIOS_OPTIONS.map((o) => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>{t('hw.machine', 'Тип платформы')}</Label>
|
||||||
|
<Select value={machine} onValueChange={setMachine}>
|
||||||
|
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{MACHINE_OPTIONS.map((o) => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>{t('hw.vga', 'Дисплей (VGA)')}</Label>
|
||||||
|
<Select value={vga} onValueChange={setVga}>
|
||||||
|
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{VGA_OPTIONS.map((o) => <SelectItem key={o} value={o}>{o}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>{t('hw.scsihw', 'SCSI-контроллер')}</Label>
|
||||||
|
<Select value={scsihw} onValueChange={setScsihw}>
|
||||||
|
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{SCSIHW_OPTIONS.map((o) => <SelectItem key={o} value={o}>{o}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="hw-sockets">{t('hw.sockets', 'CPU-сокеты')}</Label>
|
||||||
|
<Input id="hw-sockets" type="number" min={1} max={4} value={sockets}
|
||||||
|
onChange={(e) => setSockets(e.target.value)} placeholder="1" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* EFI-диск: обязателен для OVMF, показываем выбор хранилища */}
|
||||||
|
{needsEfiDisk && (
|
||||||
|
<div className="rounded-md border border-amber-500/40 bg-amber-500/5 p-3 space-y-2">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium text-amber-600 dark:text-amber-500">
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
{t('hw.efi_required_title', 'Для OVMF (UEFI) нужен EFI-диск')}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t('hw.efi_required_hint', 'EFI-диск будет создан автоматически. Выберите хранилище для него.')}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>{t('hw.efi_storage', 'Хранилище EFI-диска')}</Label>
|
||||||
|
<Select value={efiStorage} onValueChange={setEfiStorage}>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder={
|
||||||
|
storagesLoading
|
||||||
|
? t('common.loading', 'Загрузка…')
|
||||||
|
: t('hw.select_storage', 'Выберите хранилище')
|
||||||
|
} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(storages ?? []).map((s) => (
|
||||||
|
<SelectItem key={s.storage} value={s.storage}>{s.storage}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button size="sm" onClick={save} disabled={updateConfig.isPending}>
|
||||||
|
{updateConfig.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
{t('common.save', 'Сохранить')}
|
||||||
|
</Button>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t('hw.platform_hint', 'Изменения оборудования применяются после выключения и повторного запуска VM.')}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HardwareTab({ serverId, vmid, type, node }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { data: config } = useVMConfig(serverId, vmid, type, node);
|
||||||
|
const { data: status } = useVMStatus(serverId, vmid, type, node);
|
||||||
|
const updateConfig = useUpdateConfig(serverId, vmid, 'qemu', node);
|
||||||
|
|
||||||
|
const [showAddPci, setShowAddPci] = useState(false);
|
||||||
|
const [showAddUsb, setShowAddUsb] = useState(false);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const cfg = config as Record<string, unknown> | undefined;
|
||||||
|
// Проброс PCI/USB нельзя менять на работающей VM — устройства не поддерживают hotplug.
|
||||||
|
const isRunning = status?.status === 'running';
|
||||||
|
|
||||||
|
// Проброшенные устройства: hostpciN (PCI) и usbN (USB)
|
||||||
|
const passthrough = useMemo(() => {
|
||||||
|
if (!cfg) return [] as Array<{ key: string; value: string; kind: 'pci' | 'usb' }>;
|
||||||
|
return Object.entries(cfg)
|
||||||
|
.filter(([k]) => /^(hostpci|usb)\d+$/.test(k))
|
||||||
|
.map(([key, value]) => ({
|
||||||
|
key,
|
||||||
|
value: String(value),
|
||||||
|
kind: key.startsWith('hostpci') ? ('pci' as const) : ('usb' as const),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.key.localeCompare(b.key));
|
||||||
|
}, [cfg]);
|
||||||
|
|
||||||
|
const removeDevice = () => {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
updateConfig.mutate(
|
||||||
|
{ delete: deleteTarget },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(t('hw.device_removed', 'Устройство {{key}} удалено', { key: deleteTarget }));
|
||||||
|
setDeleteTarget(null);
|
||||||
|
},
|
||||||
|
onError: (e) => toast.error((e as Error).message),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 max-w-2xl">
|
||||||
|
<PlatformCard serverId={serverId} vmid={vmid} type={type} node={node} config={cfg} />
|
||||||
|
|
||||||
|
{/* Passthrough devices */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||||
|
<Cpu className="h-4 w-4" />
|
||||||
|
{t('hw.passthrough', 'Проброс устройств')}
|
||||||
|
</CardTitle>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Button size="sm" variant="outline" disabled={isRunning}
|
||||||
|
title={isRunning ? t('hw.stop_first', 'Выключите VM для изменения проброса') : undefined}
|
||||||
|
onClick={() => setShowAddPci(true)}>
|
||||||
|
<Plus className="mr-1.5 h-3.5 w-3.5" />PCI
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" disabled={isRunning}
|
||||||
|
title={isRunning ? t('hw.stop_first', 'Выключите VM для изменения проброса') : undefined}
|
||||||
|
onClick={() => setShowAddUsb(true)}>
|
||||||
|
<Plus className="mr-1.5 h-3.5 w-3.5" />USB
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{isRunning && (
|
||||||
|
<div className="flex items-center gap-2 rounded-md border border-amber-500/40 bg-amber-500/5 p-2.5 text-xs text-amber-600 dark:text-amber-500">
|
||||||
|
<AlertTriangle className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
{t('hw.running_lock', 'VM запущена — проброс устройств нельзя добавить или удалить. Выключите VM.')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{passthrough.length === 0 && (
|
||||||
|
<p className="py-4 text-center text-sm text-muted-foreground">
|
||||||
|
{t('hw.no_passthrough', 'Нет проброшенных устройств')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{passthrough.map(({ key, value, kind }) => (
|
||||||
|
<div key={key} className="rounded-md border p-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{kind === 'pci'
|
||||||
|
? <Cpu className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
: <Usb className="h-3.5 w-3.5 text-muted-foreground" />}
|
||||||
|
<Badge variant="outline">{key}</Badge>
|
||||||
|
<span className="font-mono text-xs">{value}</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 text-destructive hover:text-destructive"
|
||||||
|
disabled={isRunning}
|
||||||
|
onClick={() => setDeleteTarget(key)}
|
||||||
|
title={isRunning
|
||||||
|
? t('hw.stop_first', 'Выключите VM для изменения проброса')
|
||||||
|
: t('hw.remove_device', 'Удалить устройство')}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t('hw.passthrough_hint', 'Проброс PCI/USB требует выключенной VM и включённого IOMMU на хосте.')}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<AddPciDeviceDialog
|
||||||
|
open={showAddPci}
|
||||||
|
onClose={() => setShowAddPci(false)}
|
||||||
|
serverId={serverId}
|
||||||
|
vmid={vmid}
|
||||||
|
node={node}
|
||||||
|
config={cfg}
|
||||||
|
/>
|
||||||
|
<AddUsbDeviceDialog
|
||||||
|
open={showAddUsb}
|
||||||
|
onClose={() => setShowAddUsb(false)}
|
||||||
|
serverId={serverId}
|
||||||
|
vmid={vmid}
|
||||||
|
node={node}
|
||||||
|
config={cfg}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Delete confirmation */}
|
||||||
|
<Dialog open={!!deleteTarget} onOpenChange={(o) => !o && setDeleteTarget(null)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t('hw.remove_title', 'Удалить устройство?')}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t('hw.remove_description', 'Устройство {{key}} будет удалено из конфигурации VM.', { key: deleteTarget ?? '' })}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDeleteTarget(null)}>
|
||||||
|
{t('common.cancel', 'Отмена')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" onClick={removeDevice} disabled={updateConfig.isPending}>
|
||||||
|
{t('common.delete', 'Удалить')}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -260,6 +260,63 @@ function AutostartCard({ serverId, vmid, type, node }: { serverId: number; vmid:
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Разобрать строку тегов Proxmox ("web;prod") в массив. */
|
||||||
|
function parseTags(spec: unknown): string[] {
|
||||||
|
if (typeof spec !== 'string') return [];
|
||||||
|
return spec.split(/[;,\s]+/).map(s => s.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TagsCard({ serverId, vmid, type, node }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { data: config } = useVMConfig(serverId, vmid, type, node);
|
||||||
|
const updateConfig = useUpdateConfig(serverId, vmid, type, node);
|
||||||
|
|
||||||
|
const [value, setValue] = useState<string | null>(null);
|
||||||
|
const current = useMemo(() => parseTags(config?.tags).join(', '), [config]);
|
||||||
|
const text = value ?? current;
|
||||||
|
|
||||||
|
const save = () => {
|
||||||
|
// Proxmox хранит теги через ';'; пустая строка удаляет все теги.
|
||||||
|
const tags = parseTags(text.replace(/,/g, ';')).join(';');
|
||||||
|
updateConfig.mutate(
|
||||||
|
tags ? { tags } : { delete: 'tags' },
|
||||||
|
{
|
||||||
|
onSuccess: () => { toast.success(t('instances.tags_saved', 'Теги сохранены')); setValue(null); },
|
||||||
|
onError: (e: Error) => toast.error(e.message),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||||
|
<Settings className="h-4 w-4" />{t('instances.tags', 'Теги')}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="flex flex-wrap gap-1.5 min-h-[1.5rem]">
|
||||||
|
{parseTags(text).length === 0
|
||||||
|
? <span className="text-xs text-muted-foreground">{t('instances.no_tags', 'Нет тегов')}</span>
|
||||||
|
: parseTags(text).map(tag => (
|
||||||
|
<span key={tag} className="rounded-full bg-secondary px-2 py-0.5 text-xs">{tag}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<div className="flex-1 space-y-1.5">
|
||||||
|
<Label htmlFor="tags-input">{t('instances.tags_hint', 'Теги через запятую')}</Label>
|
||||||
|
<Input id="tags-input" value={text} onChange={e => setValue(e.target.value)} placeholder="web, prod, db" />
|
||||||
|
</div>
|
||||||
|
<Button size="sm" onClick={save} disabled={updateConfig.isPending || text === current}>
|
||||||
|
{updateConfig.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
{t('common.save', 'Сохранить')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function SavedConfigCard({ serverId, vmid }: { serverId: number; vmid: number }) {
|
function SavedConfigCard({ serverId, vmid }: { serverId: number; vmid: number }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { data } = useSavedConfig(serverId, vmid);
|
const { data } = useSavedConfig(serverId, vmid);
|
||||||
@@ -400,6 +457,8 @@ export default function SettingsTab({ serverId, vmid, type, node }: Props) {
|
|||||||
|
|
||||||
<AutostartCard serverId={serverId} vmid={vmid} type={type} node={node} />
|
<AutostartCard serverId={serverId} vmid={vmid} type={type} node={node} />
|
||||||
|
|
||||||
|
<TagsCard serverId={serverId} vmid={vmid} type={type} node={node} />
|
||||||
|
|
||||||
{type !== 'lxc' && <ExecuteScriptCard serverId={serverId} vmid={vmid} node={node} />}
|
{type !== 'lxc' && <ExecuteScriptCard serverId={serverId} vmid={vmid} node={node} />}
|
||||||
|
|
||||||
{/* CPU & Memory */}
|
{/* CPU & Memory */}
|
||||||
|
|||||||
362
frontend/src/features/nodes/AccessManager.tsx
Normal file
362
frontend/src/features/nodes/AccessManager.tsx
Normal file
@@ -0,0 +1,362 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Plus, Trash2, RefreshCw, KeyRound, ShieldQuestion, Copy } from 'lucide-react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import {
|
||||||
|
useRealms, useCreateRealm, useDeleteRealm,
|
||||||
|
useAccessUsers, useUserTokens, useCreateToken, useDeleteToken,
|
||||||
|
} from '@/hooks/use-access';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { useConfirm } from '@/components/shared/ConfirmDialog';
|
||||||
|
|
||||||
|
const REALM_TYPES = ['ldap', 'ad', 'openid'];
|
||||||
|
|
||||||
|
// ==================== Realm dialog ====================
|
||||||
|
|
||||||
|
function RealmDialog({ serverId, open, onClose }: { serverId: number; open: boolean; onClose: () => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const createRealm = useCreateRealm(serverId);
|
||||||
|
const [realm, setRealm] = useState('');
|
||||||
|
const [type, setType] = useState('ldap');
|
||||||
|
const [comment, setComment] = useState('');
|
||||||
|
// type-specific
|
||||||
|
const [server1, setServer1] = useState('');
|
||||||
|
const [baseDn, setBaseDn] = useState('');
|
||||||
|
const [userAttr, setUserAttr] = useState('uid');
|
||||||
|
const [domain, setDomain] = useState('');
|
||||||
|
const [issuerUrl, setIssuerUrl] = useState('');
|
||||||
|
const [clientId, setClientId] = useState('');
|
||||||
|
const [usernameClaim, setUsernameClaim] = useState('sub');
|
||||||
|
const [secure, setSecure] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setRealm(''); setType('ldap'); setComment('');
|
||||||
|
setServer1(''); setBaseDn(''); setUserAttr('uid'); setDomain('');
|
||||||
|
setIssuerUrl(''); setClientId(''); setUsernameClaim('sub'); setSecure(true);
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const submit = () => {
|
||||||
|
if (!realm.trim()) { toast.error(t('access.err_realm', 'Укажите имя realm')); return; }
|
||||||
|
const data: Record<string, unknown> = { realm: realm.trim(), type };
|
||||||
|
if (comment.trim()) data.comment = comment.trim();
|
||||||
|
if (type === 'ldap') {
|
||||||
|
data.server1 = server1.trim(); data.base_dn = baseDn.trim(); data.user_attr = userAttr.trim();
|
||||||
|
data.secure = secure ? 1 : 0;
|
||||||
|
} else if (type === 'ad') {
|
||||||
|
data.server1 = server1.trim(); data.domain = domain.trim(); data.secure = secure ? 1 : 0;
|
||||||
|
} else if (type === 'openid') {
|
||||||
|
data['issuer-url'] = issuerUrl.trim(); data['client-id'] = clientId.trim();
|
||||||
|
data['username-claim'] = usernameClaim.trim();
|
||||||
|
}
|
||||||
|
createRealm.mutate(data, {
|
||||||
|
onSuccess: () => { toast.success(t('access.realm_created', 'Realm создан')); onClose(); },
|
||||||
|
onError: (e: Error) => toast.error(e.message),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader><DialogTitle>{t('access.add_realm', 'Добавить realm')}</DialogTitle></DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>{t('access.realm_id', 'Имя (realm)')}</Label>
|
||||||
|
<Input value={realm} onChange={(e) => setRealm(e.target.value)} className="font-mono" placeholder="company-ldap" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>{t('access.type', 'Тип')}</Label>
|
||||||
|
<Select value={type} onValueChange={(v) => { if (v) setType(v); }}>
|
||||||
|
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>{REALM_TYPES.map(r => <SelectItem key={r} value={r}>{r.toUpperCase()}</SelectItem>)}</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(type === 'ldap' || type === 'ad') && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>{t('access.server', 'Сервер')}</Label>
|
||||||
|
<Input value={server1} onChange={(e) => setServer1(e.target.value)} className="font-mono" placeholder="ldap.example.com" />
|
||||||
|
</div>
|
||||||
|
{type === 'ldap' && (
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5"><Label>Base DN</Label><Input value={baseDn} onChange={(e) => setBaseDn(e.target.value)} className="font-mono" placeholder="dc=example,dc=com" /></div>
|
||||||
|
<div className="space-y-1.5"><Label>{t('access.user_attr', 'User attribute')}</Label><Input value={userAttr} onChange={(e) => setUserAttr(e.target.value)} className="font-mono" /></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{type === 'ad' && (
|
||||||
|
<div className="space-y-1.5"><Label>{t('access.domain', 'Домен')}</Label><Input value={domain} onChange={(e) => setDomain(e.target.value)} className="font-mono" placeholder="example.com" /></div>
|
||||||
|
)}
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<input type="checkbox" checked={secure} onChange={(e) => setSecure(e.target.checked)} />
|
||||||
|
{t('access.secure', 'SSL/TLS (secure)')}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{type === 'openid' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-1.5"><Label>Issuer URL</Label><Input value={issuerUrl} onChange={(e) => setIssuerUrl(e.target.value)} className="font-mono" placeholder="https://idp.example.com" /></div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5"><Label>Client ID</Label><Input value={clientId} onChange={(e) => setClientId(e.target.value)} className="font-mono" /></div>
|
||||||
|
<div className="space-y-1.5"><Label>{t('access.username_claim', 'Username claim')}</Label><Input value={usernameClaim} onChange={(e) => setUsernameClaim(e.target.value)} className="font-mono" /></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-1.5"><Label>{t('common.comment', 'Комментарий')}</Label><Input value={comment} onChange={(e) => setComment(e.target.value)} /></div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={onClose}>{t('common.cancel', 'Отмена')}</Button>
|
||||||
|
<Button onClick={submit} disabled={createRealm.isPending}>{t('common.create', 'Создать')}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Token create + secret reveal ====================
|
||||||
|
|
||||||
|
function TokenDialog({ serverId, userid, open, onClose }: { serverId: number; userid: string; open: boolean; onClose: () => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const createToken = useCreateToken(serverId);
|
||||||
|
const [tokenid, setTokenid] = useState('');
|
||||||
|
const [comment, setComment] = useState('');
|
||||||
|
const [privsep, setPrivsep] = useState(true);
|
||||||
|
const [secret, setSecret] = useState<{ full: string; value: string } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) { setTokenid(''); setComment(''); setPrivsep(true); setSecret(null); }
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const submit = () => {
|
||||||
|
if (!tokenid.trim()) { toast.error(t('access.err_tokenid', 'Укажите ID токена')); return; }
|
||||||
|
createToken.mutate(
|
||||||
|
{ userid, tokenid: tokenid.trim(), comment: comment.trim() || undefined, privsep },
|
||||||
|
{
|
||||||
|
onSuccess: (r) => {
|
||||||
|
if (r.value && r.full_tokenid) setSecret({ full: r.full_tokenid, value: r.value });
|
||||||
|
else { toast.success(t('access.token_created', 'Токен создан')); onClose(); }
|
||||||
|
},
|
||||||
|
onError: (e: Error) => toast.error(e.message),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const copy = (text: string) => { navigator.clipboard?.writeText(text); toast.success(t('common.copied', 'Скопировано')); };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t('access.add_token', 'Создать API-токен')}</DialogTitle>
|
||||||
|
<DialogDescription className="font-mono text-xs">{userid}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{secret ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="rounded-md border border-amber-500/40 bg-amber-500/5 p-3 text-xs text-amber-600 dark:text-amber-500">
|
||||||
|
{t('access.secret_warning', 'Секрет показывается только один раз — сохраните его сейчас.')}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Token ID</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input readOnly value={secret.full} className="font-mono text-xs" />
|
||||||
|
<Button size="icon" variant="outline" onClick={() => copy(secret.full)}><Copy className="h-4 w-4" /></Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>{t('access.secret', 'Секрет')}</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input readOnly value={secret.value} className="font-mono text-xs" />
|
||||||
|
<Button size="icon" variant="outline" onClick={() => copy(secret.value)}><Copy className="h-4 w-4" /></Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button onClick={onClose}>{t('common.done', 'Готово')}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-1.5"><Label>{t('access.token_id', 'ID токена')}</Label><Input value={tokenid} onChange={(e) => setTokenid(e.target.value)} className="font-mono" placeholder="automation" /></div>
|
||||||
|
<div className="space-y-1.5"><Label>{t('common.comment', 'Комментарий')}</Label><Input value={comment} onChange={(e) => setComment(e.target.value)} /></div>
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<input type="checkbox" checked={privsep} onChange={(e) => setPrivsep(e.target.checked)} />
|
||||||
|
{t('access.privsep', 'Privilege separation (отдельные права токена)')}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={onClose}>{t('common.cancel', 'Отмена')}</Button>
|
||||||
|
<Button onClick={submit} disabled={createToken.isPending}>{t('common.create', 'Создать')}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Tokens tab ====================
|
||||||
|
|
||||||
|
function TokensTab({ serverId }: { serverId: number }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const confirm = useConfirm();
|
||||||
|
const { data: usersData } = useAccessUsers(serverId);
|
||||||
|
const users = usersData?.users ?? [];
|
||||||
|
const [userid, setUserid] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (users.length > 0 && !users.some(u => u.userid === userid)) setUserid(users[0].userid);
|
||||||
|
}, [users, userid]);
|
||||||
|
|
||||||
|
const { data: tokensData } = useUserTokens(serverId, userid);
|
||||||
|
const tokens = tokensData?.tokens ?? [];
|
||||||
|
const deleteToken = useDeleteToken(serverId, userid);
|
||||||
|
const [dialog, setDialog] = useState(false);
|
||||||
|
|
||||||
|
const remove = async (tid: string) => {
|
||||||
|
if (!await confirm(`${t('access.delete_token', 'Удалить токен')} "${tid}"?`)) return;
|
||||||
|
deleteToken.mutate(tid, { onSuccess: () => toast.success(t('access.token_deleted', 'Токен удалён')), onError: (e: Error) => toast.error(e.message) });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select value={userid} onValueChange={(v) => { if (v) setUserid(v); }}>
|
||||||
|
<SelectTrigger className="w-[240px]"><SelectValue placeholder={t('access.select_user', 'Пользователь')} /></SelectTrigger>
|
||||||
|
<SelectContent>{users.map(u => <SelectItem key={u.userid} value={u.userid}>{u.userid}</SelectItem>)}</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button size="sm" className="ml-auto" onClick={() => setDialog(true)} disabled={!userid}>
|
||||||
|
<Plus className="mr-1 h-4 w-4" />{t('access.add_token', 'Создать токен')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{tokens.length === 0 ? (
|
||||||
|
<p className="py-6 text-center text-sm text-muted-foreground">{t('common.no_data', 'Нет данных')}</p>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader><TableRow>
|
||||||
|
<TableHead>Token ID</TableHead><TableHead>{t('common.comment', 'Комментарий')}</TableHead>
|
||||||
|
<TableHead>privsep</TableHead><TableHead>{t('access.expire', 'Истекает')}</TableHead>
|
||||||
|
<TableHead className="text-right">{t('common.actions', 'Действия')}</TableHead>
|
||||||
|
</TableRow></TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{tokens.map(tok => (
|
||||||
|
<TableRow key={tok.tokenid}>
|
||||||
|
<TableCell className="font-mono font-medium">{tok.tokenid}</TableCell>
|
||||||
|
<TableCell className="text-xs text-muted-foreground">{tok.comment || ''}</TableCell>
|
||||||
|
<TableCell>{tok.privsep ? <Badge variant="outline">on</Badge> : <Badge variant="secondary">off</Badge>}</TableCell>
|
||||||
|
<TableCell className="text-xs">{tok.expire ? new Date(tok.expire * 1000).toLocaleDateString() : '—'}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => remove(tok.tokenid)}>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
<TokenDialog serverId={serverId} userid={userid} open={dialog} onClose={() => setDialog(false)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Main ====================
|
||||||
|
|
||||||
|
export default function AccessManager({ serverId }: { serverId: number }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const confirm = useConfirm();
|
||||||
|
const { data: realmsData, isLoading, refetch, isFetching } = useRealms(serverId);
|
||||||
|
const deleteRealm = useDeleteRealm(serverId);
|
||||||
|
const realms = realmsData?.realms ?? [];
|
||||||
|
const [realmDialog, setRealmDialog] = useState(false);
|
||||||
|
|
||||||
|
const removeRealm = async (r: string) => {
|
||||||
|
if (!await confirm(`${t('access.delete_realm', 'Удалить realm')} "${r}"?`)) return;
|
||||||
|
deleteRealm.mutate(r, { onSuccess: () => toast.success(t('access.realm_deleted', 'Realm удалён')), onError: (e: Error) => toast.error(e.message) });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between gap-2 space-y-0">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-lg">
|
||||||
|
<KeyRound className="h-5 w-5" />{t('access.title', 'PVE Access')}
|
||||||
|
</CardTitle>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => refetch()} disabled={isFetching}>
|
||||||
|
<RefreshCw className={`h-4 w-4 ${isFetching ? 'animate-spin' : ''}`} />
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Tabs defaultValue="tokens">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="tokens">{t('access.tokens', 'API-токены')}</TabsTrigger>
|
||||||
|
<TabsTrigger value="realms">{t('access.realms', 'Realms')} ({realms.length})</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="tokens" className="mt-4">
|
||||||
|
<TokensTab serverId={serverId} />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="realms" className="mt-4 space-y-3">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button size="sm" onClick={() => setRealmDialog(true)}>
|
||||||
|
<Plus className="mr-1 h-4 w-4" />{t('access.add_realm', 'Добавить realm')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="py-6 text-center text-sm text-muted-foreground">{t('common.loading', 'Загрузка…')}</p>
|
||||||
|
) : realms.length === 0 ? (
|
||||||
|
<p className="py-6 text-center text-sm text-muted-foreground">{t('common.no_data', 'Нет данных')}</p>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader><TableRow>
|
||||||
|
<TableHead>{t('access.realm_id', 'Realm')}</TableHead><TableHead>{t('access.type', 'Тип')}</TableHead>
|
||||||
|
<TableHead>TFA</TableHead><TableHead>{t('common.comment', 'Комментарий')}</TableHead>
|
||||||
|
<TableHead className="text-right">{t('common.actions', 'Действия')}</TableHead>
|
||||||
|
</TableRow></TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{realms.map(r => {
|
||||||
|
const builtin = r.type === 'pam' || r.type === 'pve';
|
||||||
|
return (
|
||||||
|
<TableRow key={r.realm}>
|
||||||
|
<TableCell className="font-mono font-medium">{r.realm}</TableCell>
|
||||||
|
<TableCell><Badge variant="outline">{r.type}</Badge></TableCell>
|
||||||
|
<TableCell className="text-xs">{r.tfa || '—'}</TableCell>
|
||||||
|
<TableCell className="text-xs text-muted-foreground">{r.comment || ''}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{builtin ? (
|
||||||
|
<span className="text-xs text-muted-foreground flex items-center justify-end gap-1">
|
||||||
|
<ShieldQuestion className="h-3.5 w-3.5" />{t('access.builtin', 'встроенный')}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => removeRealm(r.realm)}>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
<RealmDialog serverId={serverId} open={realmDialog} onClose={() => setRealmDialog(false)} />
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
336
frontend/src/features/nodes/DiskManager.tsx
Normal file
336
frontend/src/features/nodes/DiskManager.tsx
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { HardDrive, RefreshCw, Activity, Trash2, Plus, Database, Eraser } from 'lucide-react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import {
|
||||||
|
useNodeDisks, useDiskSmart, useZfsPools, useCreateZfsPool, useDestroyZfsPool, useWipeDisk,
|
||||||
|
type NodeDisk,
|
||||||
|
} from '@/hooks/use-disks';
|
||||||
|
import { formatBytes } from '@/lib/format';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { useConfirm } from '@/components/shared/ConfirmDialog';
|
||||||
|
|
||||||
|
const RAID_LEVELS = ['single', 'mirror', 'raid10', 'raidz', 'raidz2', 'raidz3'];
|
||||||
|
const COMPRESSIONS = ['on', 'off', 'lz4', 'zstd', 'gzip'];
|
||||||
|
|
||||||
|
function healthVariant(h?: string): 'default' | 'secondary' | 'destructive' {
|
||||||
|
if (!h) return 'secondary';
|
||||||
|
const up = h.toUpperCase();
|
||||||
|
if (up === 'PASSED' || up === 'OK' || up === 'ONLINE') return 'default';
|
||||||
|
if (up.includes('FAIL') || up === 'DEGRADED') return 'destructive';
|
||||||
|
return 'secondary';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== SMART dialog ====================
|
||||||
|
|
||||||
|
function SmartDialog({ serverId, node, disk, onClose }: { serverId: number; node: string; disk: string | null; onClose: () => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { data, isLoading } = useDiskSmart(serverId, node, disk ?? '', !!disk);
|
||||||
|
const smart = data?.smart;
|
||||||
|
const attrs = smart?.attributes ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={!!disk} onOpenChange={(o) => !o && onClose()}>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Activity className="h-4 w-4" />SMART — <span className="font-mono">{disk}</span>
|
||||||
|
</DialogTitle>
|
||||||
|
{smart?.health && (
|
||||||
|
<DialogDescription>
|
||||||
|
{t('disks.health', 'Состояние')}: <Badge variant={healthVariant(smart.health)}>{smart.health}</Badge>
|
||||||
|
</DialogDescription>
|
||||||
|
)}
|
||||||
|
</DialogHeader>
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="py-6 text-center text-sm text-muted-foreground">{t('common.loading', 'Загрузка…')}</p>
|
||||||
|
) : smart?.error ? (
|
||||||
|
<p className="py-6 text-center text-sm text-destructive">{smart.error}</p>
|
||||||
|
) : attrs.length > 0 ? (
|
||||||
|
<div className="max-h-96 overflow-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader><TableRow>
|
||||||
|
<TableHead>ID</TableHead><TableHead>{t('disks.attribute', 'Атрибут')}</TableHead>
|
||||||
|
<TableHead>{t('disks.value', 'Знач.')}</TableHead><TableHead>{t('disks.worst', 'Худш.')}</TableHead>
|
||||||
|
<TableHead>{t('disks.threshold', 'Порог')}</TableHead><TableHead>RAW</TableHead>
|
||||||
|
</TableRow></TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{attrs.map((a, i) => (
|
||||||
|
<TableRow key={a.id ?? i}>
|
||||||
|
<TableCell className="text-xs text-muted-foreground">{a.id ?? '—'}</TableCell>
|
||||||
|
<TableCell className="text-xs">{a.name ?? '—'}</TableCell>
|
||||||
|
<TableCell className="text-xs tabular-nums">{a.value ?? '—'}</TableCell>
|
||||||
|
<TableCell className="text-xs tabular-nums">{a.worst ?? '—'}</TableCell>
|
||||||
|
<TableCell className="text-xs tabular-nums">{a.threshold ?? '—'}</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">{a.raw ?? '—'}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
) : smart?.text ? (
|
||||||
|
<pre className="max-h-96 overflow-auto rounded-md bg-muted p-3 text-xs font-mono whitespace-pre-wrap">{smart.text}</pre>
|
||||||
|
) : (
|
||||||
|
<p className="py-6 text-center text-sm text-muted-foreground">{t('common.no_data', 'Нет данных')}</p>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Create ZFS pool dialog ====================
|
||||||
|
|
||||||
|
function CreateZfsDialog({ serverId, node, disks, open, onClose }: {
|
||||||
|
serverId: number; node: string; disks: NodeDisk[]; open: boolean; onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const createPool = useCreateZfsPool(serverId, node);
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [raidlevel, setRaid] = useState('single');
|
||||||
|
const [ashift, setAshift] = useState('12');
|
||||||
|
const [compression, setCompression] = useState('on');
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// Свободные диски (без файловой системы / роли) — кандидаты под пул.
|
||||||
|
const available = useMemo(() => disks.filter(d => !d.used), [disks]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) { setName(''); setRaid('single'); setAshift('12'); setCompression('on'); setSelected(new Set()); }
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const toggle = (dev: string) => setSelected(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(dev)) next.delete(dev); else next.add(dev);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
const submit = () => {
|
||||||
|
if (!name.trim()) { toast.error(t('disks.err_name', 'Укажите имя пула')); return; }
|
||||||
|
if (selected.size === 0) { toast.error(t('disks.err_devices', 'Выберите диски')); return; }
|
||||||
|
createPool.mutate(
|
||||||
|
{ name: name.trim(), devices: Array.from(selected).join(','), raidlevel, ashift: Number(ashift), compression, add_storage: true },
|
||||||
|
{
|
||||||
|
onSuccess: () => { toast.success(t('disks.pool_created', 'ZFS-пул создаётся')); onClose(); },
|
||||||
|
onError: (e: Error) => toast.error(e.message),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t('disks.create_zfs', 'Создать ZFS-пул')}</DialogTitle>
|
||||||
|
<DialogDescription>{t('disks.create_zfs_hint', 'Диски будут очищены и объединены в пул.')}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>{t('disks.pool_name', 'Имя пула')}</Label>
|
||||||
|
<Input value={name} onChange={(e) => setName(e.target.value)} className="font-mono" placeholder="tank" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>{t('disks.raid_level', 'RAID-уровень')}</Label>
|
||||||
|
<Select value={raidlevel} onValueChange={(v) => { if (v) setRaid(v); }}>
|
||||||
|
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>{RAID_LEVELS.map(r => <SelectItem key={r} value={r}>{r}</SelectItem>)}</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>ashift</Label>
|
||||||
|
<Input type="number" min={9} max={16} value={ashift} onChange={(e) => setAshift(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>{t('disks.compression', 'Сжатие')}</Label>
|
||||||
|
<Select value={compression} onValueChange={(v) => { if (v) setCompression(v); }}>
|
||||||
|
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>{COMPRESSIONS.map(c => <SelectItem key={c} value={c}>{c}</SelectItem>)}</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>{t('disks.devices', 'Диски')} ({selected.size})</Label>
|
||||||
|
<div className="max-h-48 space-y-1 overflow-auto rounded-md border p-2">
|
||||||
|
{available.length === 0
|
||||||
|
? <p className="py-2 text-center text-xs text-muted-foreground">{t('disks.no_free_disks', 'Нет свободных дисков')}</p>
|
||||||
|
: available.map(d => (
|
||||||
|
<label key={d.devpath} className="flex items-center gap-2 rounded px-1 py-0.5 text-sm hover:bg-muted/50">
|
||||||
|
<input type="checkbox" checked={selected.has(d.devpath)} onChange={() => toggle(d.devpath)} />
|
||||||
|
<span className="font-mono text-xs">{d.devpath}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{formatBytes(d.size || 0)}</span>
|
||||||
|
{d.model && <span className="text-xs text-muted-foreground truncate">{d.model}</span>}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={onClose}>{t('common.cancel', 'Отмена')}</Button>
|
||||||
|
<Button onClick={submit} disabled={createPool.isPending}>{t('common.create', 'Создать')}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Main ====================
|
||||||
|
|
||||||
|
export default function DiskManager({ serverId, nodeNames }: { serverId: number; nodeNames: string[] }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const confirm = useConfirm();
|
||||||
|
|
||||||
|
const [node, setNode] = useState('');
|
||||||
|
useEffect(() => {
|
||||||
|
if (nodeNames.length > 0 && !nodeNames.includes(node)) setNode(nodeNames[0]);
|
||||||
|
}, [nodeNames, node]);
|
||||||
|
|
||||||
|
const { data: disksData, isLoading, refetch, isFetching } = useNodeDisks(serverId, node);
|
||||||
|
const { data: zfsData } = useZfsPools(serverId, node);
|
||||||
|
const destroyPool = useDestroyZfsPool(serverId, node);
|
||||||
|
const wipeDisk = useWipeDisk(serverId, node);
|
||||||
|
|
||||||
|
const disks = disksData?.disks ?? [];
|
||||||
|
const pools = zfsData?.pools ?? [];
|
||||||
|
|
||||||
|
const [smartDisk, setSmartDisk] = useState<string | null>(null);
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
|
||||||
|
const wipe = async (dev: string) => {
|
||||||
|
if (!await confirm(`${t('disks.wipe_confirm', 'Очистить диск (все данные будут уничтожены)')} ${dev}?`)) return;
|
||||||
|
wipeDisk.mutate(dev, { onSuccess: () => toast.success(t('disks.wiped', 'Диск очищается')), onError: (e: Error) => toast.error(e.message) });
|
||||||
|
};
|
||||||
|
const destroy = async (name: string) => {
|
||||||
|
if (!await confirm(`${t('disks.destroy_pool', 'Уничтожить ZFS-пул')} "${name}"?`)) return;
|
||||||
|
destroyPool.mutate({ name }, { onSuccess: () => toast.success(t('disks.pool_destroyed', 'Пул уничтожается')), onError: (e: Error) => toast.error(e.message) });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between gap-2 space-y-0">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-lg">
|
||||||
|
<HardDrive className="h-5 w-5" />{t('disks.title', 'Диски и ZFS')}
|
||||||
|
</CardTitle>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{nodeNames.length > 1 && (
|
||||||
|
<Select value={node} onValueChange={(v) => { if (v) setNode(v); }}>
|
||||||
|
<SelectTrigger className="w-[140px] h-8"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>{nodeNames.map(n => <SelectItem key={n} value={n}>{n}</SelectItem>)}</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
<Button size="sm" variant="outline" onClick={() => refetch()} disabled={isFetching}>
|
||||||
|
<RefreshCw className={`h-4 w-4 ${isFetching ? 'animate-spin' : ''}`} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{!node ? (
|
||||||
|
<p className="py-8 text-center text-sm text-muted-foreground">{t('common.no_data', 'Нет данных')}</p>
|
||||||
|
) : (
|
||||||
|
<Tabs defaultValue="disks">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="disks">{t('disks.physical', 'Диски')} ({disks.length})</TabsTrigger>
|
||||||
|
<TabsTrigger value="zfs">ZFS ({pools.length})</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* Physical disks */}
|
||||||
|
<TabsContent value="disks" className="mt-4">
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="py-6 text-center text-sm text-muted-foreground">{t('common.loading', 'Загрузка…')}</p>
|
||||||
|
) : disks.length === 0 ? (
|
||||||
|
<p className="py-6 text-center text-sm text-muted-foreground">{t('common.no_data', 'Нет данных')}</p>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader><TableRow>
|
||||||
|
<TableHead>{t('disks.device', 'Устройство')}</TableHead>
|
||||||
|
<TableHead>{t('disks.type', 'Тип')}</TableHead>
|
||||||
|
<TableHead>{t('disks.model', 'Модель')}</TableHead>
|
||||||
|
<TableHead>{t('disks.size', 'Объём')}</TableHead>
|
||||||
|
<TableHead>{t('disks.usage', 'Использование')}</TableHead>
|
||||||
|
<TableHead>{t('disks.health', 'Состояние')}</TableHead>
|
||||||
|
<TableHead className="text-right">{t('common.actions', 'Действия')}</TableHead>
|
||||||
|
</TableRow></TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{disks.map(d => (
|
||||||
|
<TableRow key={d.devpath}>
|
||||||
|
<TableCell className="font-mono text-xs font-medium">{d.devpath}</TableCell>
|
||||||
|
<TableCell><Badge variant="outline" className="uppercase text-[10px]">{d.type || '—'}</Badge></TableCell>
|
||||||
|
<TableCell className="text-xs">{d.model || '—'}{d.serial ? <span className="block text-[10px] text-muted-foreground font-mono">{d.serial}</span> : null}</TableCell>
|
||||||
|
<TableCell className="text-xs tabular-nums">{formatBytes(d.size || 0)}</TableCell>
|
||||||
|
<TableCell className="text-xs">{d.used || <span className="text-muted-foreground">{t('disks.free', 'свободен')}</span>}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{d.health ? <Badge variant={healthVariant(d.health)}>{d.health}</Badge> : <span className="text-xs text-muted-foreground">—</span>}
|
||||||
|
{d.wearout != null && d.wearout !== '' && <span className="ml-1 text-[10px] text-muted-foreground">wear {d.wearout}%</span>}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7" title="SMART" onClick={() => setSmartDisk(d.devpath)}>
|
||||||
|
<Activity className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" title={t('disks.wipe', 'Очистить')}
|
||||||
|
disabled={!!d.used} onClick={() => wipe(d.devpath)}>
|
||||||
|
<Eraser className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* ZFS pools */}
|
||||||
|
<TabsContent value="zfs" className="mt-4 space-y-3">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||||
|
<Plus className="mr-1 h-4 w-4" />{t('disks.create_zfs', 'Создать ZFS-пул')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{pools.length === 0 ? (
|
||||||
|
<p className="py-6 text-center text-sm text-muted-foreground">{t('common.no_data', 'Нет данных')}</p>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader><TableRow>
|
||||||
|
<TableHead>{t('disks.pool_name', 'Имя')}</TableHead>
|
||||||
|
<TableHead>{t('disks.size', 'Объём')}</TableHead>
|
||||||
|
<TableHead>{t('disks.alloc', 'Занято')}</TableHead>
|
||||||
|
<TableHead>{t('disks.free', 'Свободно')}</TableHead>
|
||||||
|
<TableHead>{t('disks.health', 'Состояние')}</TableHead>
|
||||||
|
<TableHead className="text-right">{t('common.actions', 'Действия')}</TableHead>
|
||||||
|
</TableRow></TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{pools.map(p => (
|
||||||
|
<TableRow key={p.name}>
|
||||||
|
<TableCell className="font-mono font-medium flex items-center gap-2"><Database className="h-3.5 w-3.5 text-muted-foreground" />{p.name}</TableCell>
|
||||||
|
<TableCell className="text-xs tabular-nums">{p.size ? formatBytes(p.size) : '—'}</TableCell>
|
||||||
|
<TableCell className="text-xs tabular-nums">{p.alloc != null ? formatBytes(p.alloc) : '—'}</TableCell>
|
||||||
|
<TableCell className="text-xs tabular-nums">{p.free != null ? formatBytes(p.free) : '—'}</TableCell>
|
||||||
|
<TableCell>{p.health ? <Badge variant={healthVariant(p.health)}>{p.health}</Badge> : '—'}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => destroy(p.name)}>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
<SmartDialog serverId={serverId} node={node} disk={smartDisk} onClose={() => setSmartDisk(null)} />
|
||||||
|
<CreateZfsDialog serverId={serverId} node={node} disks={disks} open={createOpen} onClose={() => setCreateOpen(false)} />
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
685
frontend/src/features/nodes/FirewallManager.tsx
Normal file
685
frontend/src/features/nodes/FirewallManager.tsx
Normal file
@@ -0,0 +1,685 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Plus, Trash2, RefreshCw, Shield, ChevronRight, Power } from 'lucide-react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import {
|
||||||
|
useFirewallOptions, useUpdateFirewallOptions,
|
||||||
|
useFirewallRules, useCreateFirewallRule, useDeleteFirewallRule, useUpdateFirewallRule,
|
||||||
|
useFirewallGroups, useCreateFirewallGroup, useDeleteFirewallGroup,
|
||||||
|
useFirewallGroupRules, useCreateFirewallGroupRule, useDeleteFirewallGroupRule,
|
||||||
|
useFirewallIpsets, useCreateFirewallIpset, useDeleteFirewallIpset,
|
||||||
|
useFirewallIpsetEntries, useAddIpsetEntry, useDeleteIpsetEntry,
|
||||||
|
useFirewallAliases, useCreateFirewallAlias, useDeleteFirewallAlias,
|
||||||
|
type FirewallRule, type FirewallGroup,
|
||||||
|
} from '@/hooks/use-firewall';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { useConfirm } from '@/components/shared/ConfirmDialog';
|
||||||
|
|
||||||
|
const POLICIES = ['ACCEPT', 'REJECT', 'DROP'];
|
||||||
|
const ACTIONS = ['ACCEPT', 'DROP', 'REJECT'];
|
||||||
|
const PROTOS = ['', 'tcp', 'udp', 'icmp'];
|
||||||
|
|
||||||
|
// ==================== Rule dialog (shared: datacenter + group rules) ====================
|
||||||
|
|
||||||
|
interface RuleForm {
|
||||||
|
type: string; // 'in' | 'out' | 'group'
|
||||||
|
action: string; // ACCEPT/DROP/REJECT | <group name>
|
||||||
|
enable: boolean;
|
||||||
|
proto: string;
|
||||||
|
source: string;
|
||||||
|
dest: string;
|
||||||
|
dport: string;
|
||||||
|
sport: string;
|
||||||
|
macro: string;
|
||||||
|
comment: string;
|
||||||
|
log: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyRule: RuleForm = {
|
||||||
|
type: 'in', action: 'ACCEPT', enable: true, proto: '', source: '', dest: '',
|
||||||
|
dport: '', sport: '', macro: '', comment: '', log: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function RuleDialog({
|
||||||
|
open, onOpenChange, onSubmit, pending, groups, allowGroupType,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (v: boolean) => void;
|
||||||
|
onSubmit: (data: Record<string, unknown>) => void;
|
||||||
|
pending: boolean;
|
||||||
|
groups: FirewallGroup[];
|
||||||
|
allowGroupType: boolean;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [form, setForm] = useState<RuleForm>(emptyRule);
|
||||||
|
const set = <K extends keyof RuleForm>(k: K, v: RuleForm[K]) => setForm(p => ({ ...p, [k]: v }));
|
||||||
|
|
||||||
|
const isGroup = form.type === 'group';
|
||||||
|
|
||||||
|
const submit = () => {
|
||||||
|
if (isGroup && !form.action) {
|
||||||
|
toast.error(t('fw.err_group_required', 'Выберите security group'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
type: form.type,
|
||||||
|
action: form.action,
|
||||||
|
enable: form.enable ? 1 : 0,
|
||||||
|
};
|
||||||
|
if (!isGroup) {
|
||||||
|
if (form.proto) payload.proto = form.proto;
|
||||||
|
if (form.source) payload.source = form.source;
|
||||||
|
if (form.dest) payload.dest = form.dest;
|
||||||
|
if (form.dport) payload.dport = form.dport;
|
||||||
|
if (form.sport) payload.sport = form.sport;
|
||||||
|
if (form.macro) payload.macro = form.macro;
|
||||||
|
if (form.log) payload.log = 'info';
|
||||||
|
}
|
||||||
|
if (form.comment) payload.comment = form.comment;
|
||||||
|
onSubmit(payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(v) => { onOpenChange(v); if (!v) setForm(emptyRule); }}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t('fw.add_rule', 'Добавить правило')}</DialogTitle>
|
||||||
|
<DialogDescription>{t('fw.rule_hint', 'Правила применяются сверху вниз.')}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>{t('fw.direction', 'Направление')}</Label>
|
||||||
|
<Select value={form.type} onValueChange={(v) => { if (v) { set('type', v); set('action', v === 'group' ? '' : 'ACCEPT'); } }}>
|
||||||
|
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="in">in</SelectItem>
|
||||||
|
<SelectItem value="out">out</SelectItem>
|
||||||
|
{allowGroupType && <SelectItem value="group">{t('fw.security_group', 'Security group')}</SelectItem>}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>{isGroup ? t('fw.security_group', 'Security group') : t('fw.action', 'Действие')}</Label>
|
||||||
|
<Select value={form.action} onValueChange={(v) => { if (v) set('action', v); }}>
|
||||||
|
<SelectTrigger className="w-full"><SelectValue placeholder={isGroup ? t('fw.select_group', 'Выберите группу') : ''} /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{isGroup
|
||||||
|
? groups.map(g => <SelectItem key={g.group} value={g.group}>{g.group}</SelectItem>)
|
||||||
|
: ACTIONS.map(a => <SelectItem key={a} value={a}>{a}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isGroup && (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>{t('fw.proto', 'Протокол')}</Label>
|
||||||
|
<Select value={form.proto} onValueChange={(v) => set('proto', v === 'any' ? '' : (v ?? ''))}>
|
||||||
|
<SelectTrigger className="w-full"><SelectValue placeholder="any" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{PROTOS.map(p => <SelectItem key={p || 'any'} value={p || 'any'}>{p || 'any'}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>{t('fw.macro', 'Макрос')}</Label>
|
||||||
|
<Input value={form.macro} onChange={(e) => set('macro', e.target.value)} placeholder="HTTP, SSH…" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>{t('fw.source', 'Источник')}</Label>
|
||||||
|
<Input value={form.source} onChange={(e) => set('source', e.target.value)} placeholder="10.0.0.0/24, +ipset, alias" className="font-mono" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>{t('fw.dest', 'Назначение')}</Label>
|
||||||
|
<Input value={form.dest} onChange={(e) => set('dest', e.target.value)} placeholder="10.0.0.5" className="font-mono" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>{t('fw.dport', 'Порт назн.')}</Label>
|
||||||
|
<Input value={form.dport} onChange={(e) => set('dport', e.target.value)} placeholder="80, 443, 8000:8100" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>{t('fw.sport', 'Порт источн.')}</Label>
|
||||||
|
<Input value={form.sport} onChange={(e) => set('sport', e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>{t('common.comment', 'Комментарий')}</Label>
|
||||||
|
<Input value={form.comment} onChange={(e) => set('comment', e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<input type="checkbox" checked={form.enable} onChange={(e) => set('enable', e.target.checked)} />
|
||||||
|
{t('fw.enabled', 'Включено')}
|
||||||
|
</label>
|
||||||
|
{!isGroup && (
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<input type="checkbox" checked={form.log} onChange={(e) => set('log', e.target.checked)} />
|
||||||
|
{t('fw.log', 'Логировать')}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>{t('common.cancel', 'Отмена')}</Button>
|
||||||
|
<Button onClick={submit} disabled={pending}>{t('common.add', 'Добавить')}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Rule row (shared render) ====================
|
||||||
|
|
||||||
|
export function RuleRow({ rule, onToggle, onDelete }: {
|
||||||
|
rule: FirewallRule; onToggle?: () => void; onDelete: () => void;
|
||||||
|
}) {
|
||||||
|
const disabled = rule.enable === 0;
|
||||||
|
const actionColor = rule.action === 'ACCEPT' ? 'text-green-600 dark:text-green-500'
|
||||||
|
: rule.action === 'DROP' || rule.action === 'REJECT' ? 'text-destructive' : '';
|
||||||
|
return (
|
||||||
|
<TableRow className={disabled ? 'opacity-50' : ''}>
|
||||||
|
<TableCell className="text-muted-foreground tabular-nums">{rule.pos}</TableCell>
|
||||||
|
<TableCell><Badge variant="outline">{rule.type}</Badge></TableCell>
|
||||||
|
<TableCell className={`font-medium ${actionColor}`}>{rule.macro || rule.action}</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">{rule.proto || '—'}</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">{rule.source || '*'} → {rule.dest || '*'}{rule.dport ? `:${rule.dport}` : ''}</TableCell>
|
||||||
|
<TableCell className="text-xs text-muted-foreground truncate max-w-[160px]">{rule.comment || ''}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
|
{onToggle && (
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={onToggle} title={disabled ? 'Enable' : 'Disable'}>
|
||||||
|
<Power className={`h-3.5 w-3.5 ${disabled ? '' : 'text-green-600 dark:text-green-500'}`} />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={onDelete}>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Security group section (expandable rules) ====================
|
||||||
|
|
||||||
|
function GroupRules({ serverId, group }: { serverId: number; group: string }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const confirm = useConfirm();
|
||||||
|
const { data } = useFirewallGroupRules(serverId, group);
|
||||||
|
const createRule = useCreateFirewallGroupRule(serverId, group);
|
||||||
|
const deleteRule = useDeleteFirewallGroupRule(serverId, group);
|
||||||
|
const [dialog, setDialog] = useState(false);
|
||||||
|
const rules = data?.rules ?? [];
|
||||||
|
|
||||||
|
const add = (payload: Record<string, unknown>) => {
|
||||||
|
createRule.mutate(payload, {
|
||||||
|
onSuccess: () => { toast.success(t('fw.rule_added', 'Правило добавлено')); setDialog(false); },
|
||||||
|
onError: (e: Error) => toast.error(e.message),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const remove = async (pos?: number) => {
|
||||||
|
if (pos == null || !await confirm(t('fw.delete_rule_confirm', 'Удалить правило?'))) return;
|
||||||
|
deleteRule.mutate(pos, { onSuccess: () => toast.success(t('fw.rule_deleted', 'Правило удалено')), onError: (e: Error) => toast.error(e.message) });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 pt-2">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setDialog(true)}>
|
||||||
|
<Plus className="mr-1 h-3.5 w-3.5" />{t('fw.add_rule', 'Добавить правило')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{rules.length === 0 ? (
|
||||||
|
<p className="py-3 text-center text-xs text-muted-foreground">{t('common.no_data', 'Нет данных')}</p>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableBody>
|
||||||
|
{rules.map(r => <RuleRow key={r.pos} rule={r} onDelete={() => remove(r.pos)} />)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
<RuleDialog open={dialog} onOpenChange={setDialog} onSubmit={add} pending={createRule.isPending} groups={[]} allowGroupType={false} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== IP set entries (expandable) ====================
|
||||||
|
|
||||||
|
function IpsetEntries({ serverId, name }: { serverId: number; name: string }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const confirm = useConfirm();
|
||||||
|
const { data } = useFirewallIpsetEntries(serverId, name);
|
||||||
|
const addEntry = useAddIpsetEntry(serverId, name);
|
||||||
|
const delEntry = useDeleteIpsetEntry(serverId, name);
|
||||||
|
const [cidr, setCidr] = useState('');
|
||||||
|
const [comment, setComment] = useState('');
|
||||||
|
const entries = data?.entries ?? [];
|
||||||
|
|
||||||
|
const add = () => {
|
||||||
|
if (!cidr.trim()) { toast.error(t('fw.err_cidr', 'Укажите CIDR')); return; }
|
||||||
|
addEntry.mutate({ cidr: cidr.trim(), comment: comment.trim() || undefined }, {
|
||||||
|
onSuccess: () => { toast.success(t('fw.entry_added', 'Запись добавлена')); setCidr(''); setComment(''); },
|
||||||
|
onError: (e: Error) => toast.error(e.message),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const remove = async (c: string) => {
|
||||||
|
if (!await confirm(`${t('fw.delete_entry', 'Удалить запись')} ${c}?`)) return;
|
||||||
|
delEntry.mutate(c, { onSuccess: () => toast.success(t('fw.entry_deleted', 'Запись удалена')), onError: (e: Error) => toast.error(e.message) });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 pt-2">
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<div className="flex-1 space-y-1"><Label className="text-xs">CIDR</Label><Input value={cidr} onChange={(e) => setCidr(e.target.value)} placeholder="10.0.0.0/24" className="font-mono h-8" /></div>
|
||||||
|
<div className="flex-1 space-y-1"><Label className="text-xs">{t('common.comment', 'Комментарий')}</Label><Input value={comment} onChange={(e) => setComment(e.target.value)} className="h-8" /></div>
|
||||||
|
<Button size="sm" onClick={add} disabled={addEntry.isPending}><Plus className="h-3.5 w-3.5" /></Button>
|
||||||
|
</div>
|
||||||
|
{entries.length === 0 ? (
|
||||||
|
<p className="py-2 text-center text-xs text-muted-foreground">{t('common.no_data', 'Нет данных')}</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{entries.map(e => (
|
||||||
|
<div key={e.cidr} className="flex items-center gap-2 rounded border px-2 py-1 text-xs">
|
||||||
|
<span className="font-mono">{e.cidr}</span>
|
||||||
|
{e.nomatch ? <Badge variant="outline" className="text-[10px]">nomatch</Badge> : null}
|
||||||
|
{e.comment && <span className="text-muted-foreground">— {e.comment}</span>}
|
||||||
|
<Button variant="ghost" size="icon" className="ml-auto h-6 w-6 text-destructive" onClick={() => remove(e.cidr)}>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Main ====================
|
||||||
|
|
||||||
|
export default function FirewallManager({ serverId }: { serverId: number }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const confirm = useConfirm();
|
||||||
|
|
||||||
|
const { data: optionsData, isLoading, refetch, isFetching } = useFirewallOptions(serverId);
|
||||||
|
const { data: rulesData } = useFirewallRules(serverId);
|
||||||
|
const { data: groupsData } = useFirewallGroups(serverId);
|
||||||
|
const { data: ipsetsData } = useFirewallIpsets(serverId);
|
||||||
|
const { data: aliasesData } = useFirewallAliases(serverId);
|
||||||
|
|
||||||
|
const updateOptions = useUpdateFirewallOptions(serverId);
|
||||||
|
const createRule = useCreateFirewallRule(serverId);
|
||||||
|
const updateRule = useUpdateFirewallRule(serverId);
|
||||||
|
const deleteRule = useDeleteFirewallRule(serverId);
|
||||||
|
const createGroup = useCreateFirewallGroup(serverId);
|
||||||
|
const deleteGroup = useDeleteFirewallGroup(serverId);
|
||||||
|
const createIpset = useCreateFirewallIpset(serverId);
|
||||||
|
const deleteIpset = useDeleteFirewallIpset(serverId);
|
||||||
|
const createAlias = useCreateFirewallAlias(serverId);
|
||||||
|
const deleteAlias = useDeleteFirewallAlias(serverId);
|
||||||
|
|
||||||
|
const options = optionsData?.options ?? {};
|
||||||
|
const rules = rulesData?.rules ?? [];
|
||||||
|
const groups = groupsData?.groups ?? [];
|
||||||
|
const ipsets = ipsetsData?.ipsets ?? [];
|
||||||
|
const aliases = aliasesData?.aliases ?? [];
|
||||||
|
|
||||||
|
const [ruleDialog, setRuleDialog] = useState(false);
|
||||||
|
const [groupDialog, setGroupDialog] = useState(false);
|
||||||
|
const [ipsetDialog, setIpsetDialog] = useState(false);
|
||||||
|
const [aliasDialog, setAliasDialog] = useState(false);
|
||||||
|
const [expandedGroup, setExpandedGroup] = useState<string | null>(null);
|
||||||
|
const [expandedIpset, setExpandedIpset] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// ---- Options local state ----
|
||||||
|
const enabled = Number(options.enable) === 1;
|
||||||
|
const toggleEnable = () => {
|
||||||
|
updateOptions.mutate({ enable: enabled ? 0 : 1 }, {
|
||||||
|
onSuccess: () => toast.success(t('fw.options_saved', 'Опции сохранены')),
|
||||||
|
onError: (e: Error) => toast.error(e.message),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const setPolicy = (dir: 'policy_in' | 'policy_out', v: string) => {
|
||||||
|
updateOptions.mutate({ [dir]: v }, {
|
||||||
|
onSuccess: () => toast.success(t('fw.options_saved', 'Опции сохранены')),
|
||||||
|
onError: (e: Error) => toast.error(e.message),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- Rules ----
|
||||||
|
const addRule = (payload: Record<string, unknown>) => {
|
||||||
|
createRule.mutate(payload, {
|
||||||
|
onSuccess: () => { toast.success(t('fw.rule_added', 'Правило добавлено')); setRuleDialog(false); },
|
||||||
|
onError: (e: Error) => toast.error(e.message),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const toggleRule = (r: FirewallRule) => {
|
||||||
|
if (r.pos == null) return;
|
||||||
|
updateRule.mutate({ pos: r.pos, data: { enable: r.enable === 0 ? 1 : 0 } }, {
|
||||||
|
onError: (e: Error) => toast.error(e.message),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const removeRule = async (pos?: number) => {
|
||||||
|
if (pos == null || !await confirm(t('fw.delete_rule_confirm', 'Удалить правило?'))) return;
|
||||||
|
deleteRule.mutate(pos, { onSuccess: () => toast.success(t('fw.rule_deleted', 'Правило удалено')), onError: (e: Error) => toast.error(e.message) });
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- Groups / ipsets / aliases delete ----
|
||||||
|
const removeGroup = async (g: string) => {
|
||||||
|
if (!await confirm(`${t('fw.delete_group', 'Удалить security group')} "${g}"?`)) return;
|
||||||
|
deleteGroup.mutate(g, { onSuccess: () => toast.success(t('fw.group_deleted', 'Группа удалена')), onError: (e: Error) => toast.error(e.message) });
|
||||||
|
};
|
||||||
|
const removeIpset = async (n: string) => {
|
||||||
|
if (!await confirm(`${t('fw.delete_ipset', 'Удалить IP set')} "${n}"?`)) return;
|
||||||
|
deleteIpset.mutate(n, { onSuccess: () => toast.success(t('fw.ipset_deleted', 'IP set удалён')), onError: (e: Error) => toast.error(e.message) });
|
||||||
|
};
|
||||||
|
const removeAlias = async (n: string) => {
|
||||||
|
if (!await confirm(`${t('fw.delete_alias', 'Удалить алиас')} "${n}"?`)) return;
|
||||||
|
deleteAlias.mutate(n, { onSuccess: () => toast.success(t('fw.alias_deleted', 'Алиас удалён')), onError: (e: Error) => toast.error(e.message) });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between gap-2 space-y-0">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-lg">
|
||||||
|
<Shield className="h-5 w-5" />
|
||||||
|
{t('fw.title', 'Firewall (датацентр)')}
|
||||||
|
<Badge variant={enabled ? 'default' : 'secondary'}>
|
||||||
|
{enabled ? t('fw.on', 'вкл') : t('fw.off', 'выкл')}
|
||||||
|
</Badge>
|
||||||
|
</CardTitle>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button size="sm" variant="outline" onClick={() => refetch()} disabled={isFetching}>
|
||||||
|
<RefreshCw className={`h-4 w-4 ${isFetching ? 'animate-spin' : ''}`} />
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant={enabled ? 'outline' : 'default'} onClick={toggleEnable} disabled={updateOptions.isPending}>
|
||||||
|
<Power className="mr-1 h-4 w-4" />{enabled ? t('fw.disable', 'Выключить') : t('fw.enable', 'Включить')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="py-8 text-center text-sm text-muted-foreground">{t('common.loading', 'Загрузка…')}</p>
|
||||||
|
) : (
|
||||||
|
<Tabs defaultValue="rules">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="rules">{t('fw.rules', 'Правила')} ({rules.length})</TabsTrigger>
|
||||||
|
<TabsTrigger value="groups">{t('fw.groups', 'Security Groups')} ({groups.length})</TabsTrigger>
|
||||||
|
<TabsTrigger value="ipsets">{t('fw.ipsets', 'IP Sets')} ({ipsets.length})</TabsTrigger>
|
||||||
|
<TabsTrigger value="aliases">{t('fw.aliases', 'Aliases')} ({aliases.length})</TabsTrigger>
|
||||||
|
<TabsTrigger value="options">{t('fw.options', 'Опции')}</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* Rules */}
|
||||||
|
<TabsContent value="rules" className="mt-4 space-y-3">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button size="sm" onClick={() => setRuleDialog(true)}>
|
||||||
|
<Plus className="mr-1 h-4 w-4" />{t('fw.add_rule', 'Добавить правило')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{rules.length === 0 ? (
|
||||||
|
<p className="py-6 text-center text-sm text-muted-foreground">{t('common.no_data', 'Нет данных')}</p>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>#</TableHead>
|
||||||
|
<TableHead>{t('fw.direction', 'Напр.')}</TableHead>
|
||||||
|
<TableHead>{t('fw.action', 'Действие')}</TableHead>
|
||||||
|
<TableHead>{t('fw.proto', 'Прото')}</TableHead>
|
||||||
|
<TableHead>{t('fw.match', 'Источник → Назначение')}</TableHead>
|
||||||
|
<TableHead>{t('common.comment', 'Комментарий')}</TableHead>
|
||||||
|
<TableHead className="text-right">{t('common.actions', 'Действия')}</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{rules.map(r => (
|
||||||
|
<RuleRow key={r.pos} rule={r} onToggle={() => toggleRule(r)} onDelete={() => removeRule(r.pos)} />
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Security groups */}
|
||||||
|
<TabsContent value="groups" className="mt-4 space-y-3">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button size="sm" onClick={() => setGroupDialog(true)}>
|
||||||
|
<Plus className="mr-1 h-4 w-4" />{t('fw.add_group', 'Добавить группу')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{groups.length === 0 ? (
|
||||||
|
<p className="py-6 text-center text-sm text-muted-foreground">{t('common.no_data', 'Нет данных')}</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{groups.map(g => (
|
||||||
|
<div key={g.group} className="rounded-md border">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2">
|
||||||
|
<button className="flex flex-1 items-center gap-2 text-left" onClick={() => setExpandedGroup(p => p === g.group ? null : g.group)}>
|
||||||
|
<ChevronRight className={`h-4 w-4 transition-transform ${expandedGroup === g.group ? 'rotate-90' : ''}`} />
|
||||||
|
<span className="font-mono font-medium">{g.group}</span>
|
||||||
|
{g.comment && <span className="text-xs text-muted-foreground">— {g.comment}</span>}
|
||||||
|
</button>
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => removeGroup(g.group)}>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{expandedGroup === g.group && (
|
||||||
|
<div className="px-3 pb-3"><GroupRules serverId={serverId} group={g.group} /></div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* IP sets */}
|
||||||
|
<TabsContent value="ipsets" className="mt-4 space-y-3">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button size="sm" onClick={() => setIpsetDialog(true)}>
|
||||||
|
<Plus className="mr-1 h-4 w-4" />{t('fw.add_ipset', 'Добавить IP set')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{ipsets.length === 0 ? (
|
||||||
|
<p className="py-6 text-center text-sm text-muted-foreground">{t('common.no_data', 'Нет данных')}</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{ipsets.map(s => (
|
||||||
|
<div key={s.name} className="rounded-md border">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2">
|
||||||
|
<button className="flex flex-1 items-center gap-2 text-left" onClick={() => setExpandedIpset(p => p === s.name ? null : s.name)}>
|
||||||
|
<ChevronRight className={`h-4 w-4 transition-transform ${expandedIpset === s.name ? 'rotate-90' : ''}`} />
|
||||||
|
<span className="font-mono font-medium">{s.name}</span>
|
||||||
|
{s.comment && <span className="text-xs text-muted-foreground">— {s.comment}</span>}
|
||||||
|
</button>
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => removeIpset(s.name)}>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{expandedIpset === s.name && (
|
||||||
|
<div className="px-3 pb-3"><IpsetEntries serverId={serverId} name={s.name} /></div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Aliases */}
|
||||||
|
<TabsContent value="aliases" className="mt-4 space-y-3">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button size="sm" onClick={() => setAliasDialog(true)}>
|
||||||
|
<Plus className="mr-1 h-4 w-4" />{t('fw.add_alias', 'Добавить алиас')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{aliases.length === 0 ? (
|
||||||
|
<p className="py-6 text-center text-sm text-muted-foreground">{t('common.no_data', 'Нет данных')}</p>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>{t('fw.name', 'Имя')}</TableHead>
|
||||||
|
<TableHead>CIDR</TableHead>
|
||||||
|
<TableHead>{t('common.comment', 'Комментарий')}</TableHead>
|
||||||
|
<TableHead className="text-right">{t('common.actions', 'Действия')}</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{aliases.map(a => (
|
||||||
|
<TableRow key={a.name}>
|
||||||
|
<TableCell className="font-mono font-medium">{a.name}</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">{a.cidr}</TableCell>
|
||||||
|
<TableCell className="text-xs text-muted-foreground">{a.comment || ''}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => removeAlias(a.name)}>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Options */}
|
||||||
|
<TabsContent value="options" className="mt-4 space-y-4 max-w-md">
|
||||||
|
<label className="flex items-center gap-2.5 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={enabled} onChange={toggleEnable} className="h-4 w-4 accent-primary" />
|
||||||
|
<span className="text-sm">{t('fw.enable_dc', 'Включить фаервол датацентра')}</span>
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>{t('fw.policy_in', 'Политика входящих')}</Label>
|
||||||
|
<Select value={options.policy_in || 'DROP'} onValueChange={(v) => { if (v) setPolicy('policy_in', v); }}>
|
||||||
|
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>{POLICIES.map(p => <SelectItem key={p} value={p}>{p}</SelectItem>)}</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>{t('fw.policy_out', 'Политика исходящих')}</Label>
|
||||||
|
<Select value={options.policy_out || 'ACCEPT'} onValueChange={(v) => { if (v) setPolicy('policy_out', v); }}>
|
||||||
|
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>{POLICIES.map(p => <SelectItem key={p} value={p}>{p}</SelectItem>)}</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('fw.options_hint', 'Политики применяются к трафику, не совпавшему ни с одним правилом.')}</p>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
{/* Dialogs */}
|
||||||
|
<RuleDialog open={ruleDialog} onOpenChange={setRuleDialog} onSubmit={addRule} pending={createRule.isPending} groups={groups} allowGroupType />
|
||||||
|
|
||||||
|
<SimpleCreateDialog
|
||||||
|
open={groupDialog} onOpenChange={setGroupDialog}
|
||||||
|
title={t('fw.add_group', 'Добавить security group')}
|
||||||
|
nameLabel={t('fw.group_name', 'Имя группы')}
|
||||||
|
withCidr={false}
|
||||||
|
pending={createGroup.isPending}
|
||||||
|
onSubmit={({ name, comment }) => createGroup.mutate({ group: name, comment }, {
|
||||||
|
onSuccess: () => { toast.success(t('fw.group_added', 'Группа создана')); setGroupDialog(false); },
|
||||||
|
onError: (e: Error) => toast.error(e.message),
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SimpleCreateDialog
|
||||||
|
open={ipsetDialog} onOpenChange={setIpsetDialog}
|
||||||
|
title={t('fw.add_ipset', 'Добавить IP set')}
|
||||||
|
nameLabel={t('fw.ipset_name', 'Имя IP set')}
|
||||||
|
withCidr={false}
|
||||||
|
pending={createIpset.isPending}
|
||||||
|
onSubmit={({ name, comment }) => createIpset.mutate({ name, comment }, {
|
||||||
|
onSuccess: () => { toast.success(t('fw.ipset_added', 'IP set создан')); setIpsetDialog(false); },
|
||||||
|
onError: (e: Error) => toast.error(e.message),
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SimpleCreateDialog
|
||||||
|
open={aliasDialog} onOpenChange={setAliasDialog}
|
||||||
|
title={t('fw.add_alias', 'Добавить алиас')}
|
||||||
|
nameLabel={t('fw.name', 'Имя')}
|
||||||
|
withCidr
|
||||||
|
pending={createAlias.isPending}
|
||||||
|
onSubmit={({ name, cidr, comment }) => createAlias.mutate({ name, cidr: cidr!, comment }, {
|
||||||
|
onSuccess: () => { toast.success(t('fw.alias_added', 'Алиас создан')); setAliasDialog(false); },
|
||||||
|
onError: (e: Error) => toast.error(e.message),
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Simple create dialog (group / ipset / alias) ====================
|
||||||
|
|
||||||
|
function SimpleCreateDialog({
|
||||||
|
open, onOpenChange, title, nameLabel, withCidr, pending, onSubmit,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (v: boolean) => void;
|
||||||
|
title: string;
|
||||||
|
nameLabel: string;
|
||||||
|
withCidr: boolean;
|
||||||
|
pending: boolean;
|
||||||
|
onSubmit: (data: { name: string; cidr?: string; comment?: string }) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [cidr, setCidr] = useState('');
|
||||||
|
const [comment, setComment] = useState('');
|
||||||
|
|
||||||
|
const reset = () => { setName(''); setCidr(''); setComment(''); };
|
||||||
|
|
||||||
|
const submit = () => {
|
||||||
|
if (!name.trim()) { toast.error(t('fw.err_name', 'Укажите имя')); return; }
|
||||||
|
if (withCidr && !cidr.trim()) { toast.error(t('fw.err_cidr', 'Укажите CIDR')); return; }
|
||||||
|
onSubmit({ name: name.trim(), cidr: withCidr ? cidr.trim() : undefined, comment: comment.trim() || undefined });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(v) => { onOpenChange(v); if (!v) reset(); }}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader><DialogTitle>{title}</DialogTitle></DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>{nameLabel}</Label>
|
||||||
|
<Input value={name} onChange={(e) => setName(e.target.value)} className="font-mono" />
|
||||||
|
</div>
|
||||||
|
{withCidr && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>CIDR / IP</Label>
|
||||||
|
<Input value={cidr} onChange={(e) => setCidr(e.target.value)} placeholder="10.0.0.0/24" className="font-mono" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>{t('common.comment', 'Комментарий')}</Label>
|
||||||
|
<Input value={comment} onChange={(e) => setComment(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>{t('common.cancel', 'Отмена')}</Button>
|
||||||
|
<Button onClick={submit} disabled={pending}>{t('common.add', 'Добавить')}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
186
frontend/src/features/nodes/NodeAdminManager.tsx
Normal file
186
frontend/src/features/nodes/NodeAdminManager.tsx
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { RefreshCw, Server, Play, Square, RotateCw, PackageCheck } from 'lucide-react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import {
|
||||||
|
useNodeServices, useServiceAction,
|
||||||
|
useAptUpdates, useAptRefresh, useAptRepositories, useSetAptRepository,
|
||||||
|
} from '@/hooks/use-node-admin';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
function stateVariant(s?: string): 'default' | 'secondary' | 'destructive' {
|
||||||
|
if (!s) return 'secondary';
|
||||||
|
if (s === 'running' || s === 'active') return 'default';
|
||||||
|
if (s === 'dead' || s === 'failed') return 'destructive';
|
||||||
|
return 'secondary';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NodeAdminManager({ serverId, nodeNames }: { serverId: number; nodeNames: string[] }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [node, setNode] = useState('');
|
||||||
|
useEffect(() => {
|
||||||
|
if (nodeNames.length > 0 && !nodeNames.includes(node)) setNode(nodeNames[0]);
|
||||||
|
}, [nodeNames, node]);
|
||||||
|
|
||||||
|
const { data: servicesData, refetch, isFetching } = useNodeServices(serverId, node);
|
||||||
|
const serviceAction = useServiceAction(serverId, node);
|
||||||
|
const [aptOpen, setAptOpen] = useState(false);
|
||||||
|
const [reposOpen, setReposOpen] = useState(false);
|
||||||
|
const { data: updatesData } = useAptUpdates(serverId, node, aptOpen);
|
||||||
|
const aptRefresh = useAptRefresh(serverId, node);
|
||||||
|
const { data: reposData } = useAptRepositories(serverId, node, reposOpen);
|
||||||
|
const setRepo = useSetAptRepository(serverId, node);
|
||||||
|
|
||||||
|
const services = servicesData?.services ?? [];
|
||||||
|
const updates = updatesData?.updates ?? [];
|
||||||
|
const files = reposData?.repositories?.files ?? [];
|
||||||
|
|
||||||
|
const doAction = (service: string, action: string) => {
|
||||||
|
serviceAction.mutate({ service, action }, {
|
||||||
|
onSuccess: () => toast.success(`${service}: ${action}`),
|
||||||
|
onError: (e: Error) => toast.error(e.message),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const refreshApt = () => {
|
||||||
|
aptRefresh.mutate(undefined, {
|
||||||
|
onSuccess: () => toast.success(t('nodeadm.apt_refreshed', 'Списки пакетов обновляются')),
|
||||||
|
onError: (e: Error) => toast.error(e.message),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const toggleRepo = (path: string, index: number, enabled: boolean) => {
|
||||||
|
setRepo.mutate({ path, index, enabled }, {
|
||||||
|
onSuccess: () => toast.success(t('nodeadm.repo_updated', 'Репозиторий обновлён')),
|
||||||
|
onError: (e: Error) => toast.error(e.message),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between gap-2 space-y-0">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-lg">
|
||||||
|
<Server className="h-5 w-5" />{t('nodeadm.title', 'Администрирование ноды')}
|
||||||
|
</CardTitle>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{nodeNames.length > 1 && (
|
||||||
|
<Select value={node} onValueChange={(v) => { if (v) setNode(v); }}>
|
||||||
|
<SelectTrigger className="w-[140px] h-8"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>{nodeNames.map(n => <SelectItem key={n} value={n}>{n}</SelectItem>)}</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
<Button size="sm" variant="outline" onClick={() => refetch()} disabled={isFetching}>
|
||||||
|
<RefreshCw className={`h-4 w-4 ${isFetching ? 'animate-spin' : ''}`} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{!node ? (
|
||||||
|
<p className="py-8 text-center text-sm text-muted-foreground">{t('common.no_data', 'Нет данных')}</p>
|
||||||
|
) : (
|
||||||
|
<Tabs defaultValue="services" onValueChange={(v) => { setAptOpen(v === 'updates'); setReposOpen(v === 'repos'); }}>
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="services">{t('nodeadm.services', 'Сервисы')} ({services.length})</TabsTrigger>
|
||||||
|
<TabsTrigger value="updates">{t('nodeadm.updates', 'Обновления')}</TabsTrigger>
|
||||||
|
<TabsTrigger value="repos">{t('nodeadm.repos', 'Репозитории')}</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* Services */}
|
||||||
|
<TabsContent value="services" className="mt-4">
|
||||||
|
{services.length === 0 ? (
|
||||||
|
<p className="py-6 text-center text-sm text-muted-foreground">{t('common.loading', 'Загрузка…')}</p>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader><TableRow>
|
||||||
|
<TableHead>{t('nodeadm.service', 'Сервис')}</TableHead>
|
||||||
|
<TableHead>{t('nodeadm.desc', 'Описание')}</TableHead>
|
||||||
|
<TableHead>{t('common.status', 'Статус')}</TableHead>
|
||||||
|
<TableHead className="text-right">{t('common.actions', 'Действия')}</TableHead>
|
||||||
|
</TableRow></TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{services.map(s => (
|
||||||
|
<TableRow key={s.service}>
|
||||||
|
<TableCell className="font-mono text-xs font-medium">{s.service}</TableCell>
|
||||||
|
<TableCell className="text-xs text-muted-foreground truncate max-w-[280px]">{s.desc || s.name}</TableCell>
|
||||||
|
<TableCell><Badge variant={stateVariant(s.state)}>{s.state || '—'}</Badge></TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7" title="start" onClick={() => doAction(s.service, 'start')}><Play className="h-3.5 w-3.5" /></Button>
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7" title="restart" onClick={() => doAction(s.service, 'restart')}><RotateCw className="h-3.5 w-3.5" /></Button>
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" title="stop" onClick={() => doAction(s.service, 'stop')}><Square className="h-3.5 w-3.5" /></Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Updates */}
|
||||||
|
<TabsContent value="updates" className="mt-4 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{updates.length > 0
|
||||||
|
? t('nodeadm.updates_count', 'Доступно обновлений: {{n}}', { n: updates.length })
|
||||||
|
: t('nodeadm.up_to_date', 'Обновлений нет')}
|
||||||
|
</p>
|
||||||
|
<Button size="sm" variant="outline" onClick={refreshApt} disabled={aptRefresh.isPending}>
|
||||||
|
<PackageCheck className="mr-1 h-4 w-4" />{t('nodeadm.refresh', 'Проверить (apt update)')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{updates.length > 0 && (
|
||||||
|
<Table>
|
||||||
|
<TableHeader><TableRow>
|
||||||
|
<TableHead>{t('nodeadm.package', 'Пакет')}</TableHead>
|
||||||
|
<TableHead>{t('nodeadm.current', 'Текущая')}</TableHead>
|
||||||
|
<TableHead>{t('nodeadm.new', 'Новая')}</TableHead>
|
||||||
|
</TableRow></TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{updates.map(u => (
|
||||||
|
<TableRow key={u.Package}>
|
||||||
|
<TableCell className="font-mono text-xs font-medium">{u.Package}</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs text-muted-foreground">{u.OldVersion || '—'}</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs text-green-600 dark:text-green-500">{u.Version || '—'}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-muted-foreground">{t('nodeadm.upgrade_hint', 'Установка обновлений выполняется в консоли ноды (apt dist-upgrade) — API не поддерживает неинтерактивный upgrade.')}</p>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Repositories */}
|
||||||
|
<TabsContent value="repos" className="mt-4 space-y-4">
|
||||||
|
{files.length === 0 ? (
|
||||||
|
<p className="py-6 text-center text-sm text-muted-foreground">{t('common.no_data', 'Нет данных')}</p>
|
||||||
|
) : files.map(f => (
|
||||||
|
<div key={f.path} className="space-y-2">
|
||||||
|
<p className="font-mono text-xs text-muted-foreground">{f.path}</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{(f.repositories ?? []).map((r, idx) => {
|
||||||
|
const enabled = r.Enabled === 1 || r.Enabled === true;
|
||||||
|
return (
|
||||||
|
<div key={idx} className="flex items-center gap-2 rounded border px-2 py-1.5 text-xs">
|
||||||
|
<Badge variant={enabled ? 'default' : 'secondary'} className="text-[10px]">{enabled ? 'on' : 'off'}</Badge>
|
||||||
|
<span className="font-mono truncate">{(r.URIs || []).join(' ')} {(r.Suites || []).join(' ')} {(r.Components || []).join(' ')}</span>
|
||||||
|
<Button size="sm" variant="outline" className="ml-auto h-6 text-[11px]"
|
||||||
|
onClick={() => toggleRepo(f.path, idx, !enabled)} disabled={setRepo.isPending}>
|
||||||
|
{enabled ? t('nodeadm.disable', 'Выключить') : t('nodeadm.enable', 'Включить')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,6 +11,12 @@ import { formatBytes, formatUptime, formatPercent } from '@/lib/format';
|
|||||||
import NodeNetworks from './NodeNetworks';
|
import NodeNetworks from './NodeNetworks';
|
||||||
import HAResources from './HAResources';
|
import HAResources from './HAResources';
|
||||||
import SDNManager from './SDNManager';
|
import SDNManager from './SDNManager';
|
||||||
|
import FirewallManager from './FirewallManager';
|
||||||
|
import NodeFirewallManager from './NodeFirewallManager';
|
||||||
|
import PoolManager from './PoolManager';
|
||||||
|
import DiskManager from './DiskManager';
|
||||||
|
import AccessManager from './AccessManager';
|
||||||
|
import NodeAdminManager from './NodeAdminManager';
|
||||||
|
|
||||||
export default function NodeDetailPage() {
|
export default function NodeDetailPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -88,6 +94,39 @@ export default function NodeDetailPage() {
|
|||||||
<SDNManager serverId={sid} />
|
<SDNManager serverId={sid} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Datacenter Firewall */}
|
||||||
|
{nodes.length > 0 && (
|
||||||
|
<FirewallManager serverId={sid} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Node Firewall */}
|
||||||
|
{nodes.length > 0 && (
|
||||||
|
<NodeFirewallManager serverId={sid} nodeNames={nodes.map(n => n.node)} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Resource Pools */}
|
||||||
|
{nodes.length > 0 && (
|
||||||
|
<PoolManager
|
||||||
|
serverId={sid}
|
||||||
|
vms={serverVMs.map(v => ({ vmid: v.vmid, name: v.name, type: v.type, node: v.node }))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Disks & ZFS */}
|
||||||
|
{nodes.length > 0 && (
|
||||||
|
<DiskManager serverId={sid} nodeNames={nodes.map(n => n.node)} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Node administration: services & APT */}
|
||||||
|
{nodes.length > 0 && (
|
||||||
|
<NodeAdminManager serverId={sid} nodeNames={nodes.map(n => n.node)} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* PVE Access: realms & API tokens */}
|
||||||
|
{nodes.length > 0 && (
|
||||||
|
<AccessManager serverId={sid} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* VMs on this server */}
|
{/* VMs on this server */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h2 className="text-lg font-semibold">{t('nodes.instances_on_server')} ({serverVMs.length})</h2>
|
<h2 className="text-lg font-semibold">{t('nodes.instances_on_server')} ({serverVMs.length})</h2>
|
||||||
|
|||||||
206
frontend/src/features/nodes/NodeFirewallManager.tsx
Normal file
206
frontend/src/features/nodes/NodeFirewallManager.tsx
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Plus, RefreshCw, ShieldCheck, Power } from 'lucide-react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Table, TableBody, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import {
|
||||||
|
useFirewallGroups,
|
||||||
|
useNodeFirewallOptions, useUpdateNodeFirewallOptions,
|
||||||
|
useNodeFirewallRules, useCreateNodeFirewallRule, useUpdateNodeFirewallRule, useDeleteNodeFirewallRule,
|
||||||
|
useNodeFirewallLog,
|
||||||
|
type FirewallRule,
|
||||||
|
} from '@/hooks/use-firewall';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { useConfirm } from '@/components/shared/ConfirmDialog';
|
||||||
|
import { RuleDialog, RuleRow } from './FirewallManager';
|
||||||
|
|
||||||
|
const LOG_LEVELS = ['nolog', 'emerg', 'alert', 'crit', 'err', 'warning', 'notice', 'info', 'debug'];
|
||||||
|
|
||||||
|
export default function NodeFirewallManager({ serverId, nodeNames }: { serverId: number; nodeNames: string[] }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const confirm = useConfirm();
|
||||||
|
|
||||||
|
const [node, setNode] = useState('');
|
||||||
|
useEffect(() => {
|
||||||
|
if (nodeNames.length > 0 && !nodeNames.includes(node)) setNode(nodeNames[0]);
|
||||||
|
}, [nodeNames, node]);
|
||||||
|
|
||||||
|
const { data: groupsData } = useFirewallGroups(serverId);
|
||||||
|
const { data: optionsData, isLoading, refetch, isFetching } = useNodeFirewallOptions(serverId, node);
|
||||||
|
const { data: rulesData } = useNodeFirewallRules(serverId, node);
|
||||||
|
const { data: logData } = useNodeFirewallLog(serverId, node, false); // грузим лениво по вкладке
|
||||||
|
|
||||||
|
const updateOptions = useUpdateNodeFirewallOptions(serverId, node);
|
||||||
|
const createRule = useCreateNodeFirewallRule(serverId, node);
|
||||||
|
const updateRule = useUpdateNodeFirewallRule(serverId, node);
|
||||||
|
const deleteRule = useDeleteNodeFirewallRule(serverId, node);
|
||||||
|
|
||||||
|
const groups = groupsData?.groups ?? [];
|
||||||
|
const options = optionsData?.options ?? {};
|
||||||
|
const rules = rulesData?.rules ?? [];
|
||||||
|
const enabled = Number(options.enable) === 1;
|
||||||
|
|
||||||
|
const [ruleDialog, setRuleDialog] = useState(false);
|
||||||
|
const [logOpen, setLogOpen] = useState(false);
|
||||||
|
const logQuery = useNodeFirewallLog(serverId, node, logOpen);
|
||||||
|
const log = (logOpen ? logQuery.data : logData)?.log ?? [];
|
||||||
|
|
||||||
|
const saveOption = (patch: Record<string, unknown>) => {
|
||||||
|
updateOptions.mutate(patch, {
|
||||||
|
onSuccess: () => toast.success(t('fw.options_saved', 'Опции сохранены')),
|
||||||
|
onError: (e: Error) => toast.error(e.message),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const addRule = (payload: Record<string, unknown>) => {
|
||||||
|
createRule.mutate(payload, {
|
||||||
|
onSuccess: () => { toast.success(t('fw.rule_added', 'Правило добавлено')); setRuleDialog(false); },
|
||||||
|
onError: (e: Error) => toast.error(e.message),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const toggleRule = (r: FirewallRule) => {
|
||||||
|
if (r.pos == null) return;
|
||||||
|
updateRule.mutate({ pos: r.pos, data: { enable: r.enable === 0 ? 1 : 0 } }, {
|
||||||
|
onError: (e: Error) => toast.error(e.message),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const removeRule = async (pos?: number) => {
|
||||||
|
if (pos == null || !await confirm(t('fw.delete_rule_confirm', 'Удалить правило?'))) return;
|
||||||
|
deleteRule.mutate(pos, { onSuccess: () => toast.success(t('fw.rule_deleted', 'Правило удалено')), onError: (e: Error) => toast.error(e.message) });
|
||||||
|
};
|
||||||
|
|
||||||
|
const boolOption = (key: string, label: string) => (
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={Number(options[key]) === 1}
|
||||||
|
onChange={(e) => saveOption({ [key]: e.target.checked ? 1 : 0 })}
|
||||||
|
/>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between gap-2 space-y-0">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-lg">
|
||||||
|
<ShieldCheck className="h-5 w-5" />
|
||||||
|
{t('fw.node_title', 'Firewall (нода)')}
|
||||||
|
<Badge variant={enabled ? 'default' : 'secondary'}>{enabled ? t('fw.on', 'вкл') : t('fw.off', 'выкл')}</Badge>
|
||||||
|
</CardTitle>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{nodeNames.length > 1 && (
|
||||||
|
<Select value={node} onValueChange={(v) => { if (v) setNode(v); }}>
|
||||||
|
<SelectTrigger className="w-[140px] h-8"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{nodeNames.map(n => <SelectItem key={n} value={n}>{n}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
<Button size="sm" variant="outline" onClick={() => refetch()} disabled={isFetching}>
|
||||||
|
<RefreshCw className={`h-4 w-4 ${isFetching ? 'animate-spin' : ''}`} />
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant={enabled ? 'outline' : 'default'} onClick={() => saveOption({ enable: enabled ? 0 : 1 })} disabled={updateOptions.isPending || !node}>
|
||||||
|
<Power className="mr-1 h-4 w-4" />{enabled ? t('fw.disable', 'Выключить') : t('fw.enable', 'Включить')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{!node ? (
|
||||||
|
<p className="py-8 text-center text-sm text-muted-foreground">{t('common.no_data', 'Нет данных')}</p>
|
||||||
|
) : isLoading ? (
|
||||||
|
<p className="py-8 text-center text-sm text-muted-foreground">{t('common.loading', 'Загрузка…')}</p>
|
||||||
|
) : (
|
||||||
|
<Tabs defaultValue="rules" onValueChange={(v) => setLogOpen(v === 'log')}>
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="rules">{t('fw.rules', 'Правила')} ({rules.length})</TabsTrigger>
|
||||||
|
<TabsTrigger value="options">{t('fw.options', 'Опции')}</TabsTrigger>
|
||||||
|
<TabsTrigger value="log">{t('fw.log_tab', 'Лог')}</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* Rules */}
|
||||||
|
<TabsContent value="rules" className="mt-4 space-y-3">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button size="sm" onClick={() => setRuleDialog(true)}>
|
||||||
|
<Plus className="mr-1 h-4 w-4" />{t('fw.add_rule', 'Добавить правило')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{rules.length === 0 ? (
|
||||||
|
<p className="py-6 text-center text-sm text-muted-foreground">{t('common.no_data', 'Нет данных')}</p>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>#</TableHead>
|
||||||
|
<TableHead>{t('fw.direction', 'Напр.')}</TableHead>
|
||||||
|
<TableHead>{t('fw.action', 'Действие')}</TableHead>
|
||||||
|
<TableHead>{t('fw.proto', 'Прото')}</TableHead>
|
||||||
|
<TableHead>{t('fw.match', 'Источник → Назначение')}</TableHead>
|
||||||
|
<TableHead>{t('common.comment', 'Комментарий')}</TableHead>
|
||||||
|
<TableHead className="text-right">{t('common.actions', 'Действия')}</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{rules.map(r => (
|
||||||
|
<RuleRow key={r.pos} rule={r} onToggle={() => toggleRule(r)} onDelete={() => removeRule(r.pos)} />
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Options */}
|
||||||
|
<TabsContent value="options" className="mt-4 space-y-4 max-w-md">
|
||||||
|
<label className="flex items-center gap-2.5 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={enabled} onChange={(e) => saveOption({ enable: e.target.checked ? 1 : 0 })} className="h-4 w-4 accent-primary" />
|
||||||
|
<span className="text-sm">{t('fw.enable_node', 'Включить фаервол ноды')}</span>
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>{t('fw.log_level_in', 'Лог входящих')}</Label>
|
||||||
|
<Select value={String(options.log_level_in || 'nolog')} onValueChange={(v) => { if (v) saveOption({ log_level_in: v }); }}>
|
||||||
|
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>{LOG_LEVELS.map(l => <SelectItem key={l} value={l}>{l}</SelectItem>)}</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>{t('fw.log_level_out', 'Лог исходящих')}</Label>
|
||||||
|
<Select value={String(options.log_level_out || 'nolog')} onValueChange={(v) => { if (v) saveOption({ log_level_out: v }); }}>
|
||||||
|
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>{LOG_LEVELS.map(l => <SelectItem key={l} value={l}>{l}</SelectItem>)}</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{boolOption('nosmurfs', t('fw.nosmurfs', 'Блокировать SMURF-атаки'))}
|
||||||
|
{boolOption('tcpflags', t('fw.tcpflags', 'Фильтровать недопустимые TCP-флаги'))}
|
||||||
|
{boolOption('protection_synflood', t('fw.synflood', 'Защита от SYN-flood'))}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('fw.node_options_hint', 'Опции применяются к трафику самой ноды хоста Proxmox.')}</p>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Log */}
|
||||||
|
<TabsContent value="log" className="mt-4">
|
||||||
|
{logQuery.isLoading ? (
|
||||||
|
<p className="py-6 text-center text-sm text-muted-foreground">{t('common.loading', 'Загрузка…')}</p>
|
||||||
|
) : log.length === 0 ? (
|
||||||
|
<p className="py-6 text-center text-sm text-muted-foreground">{t('common.no_data', 'Нет данных')}</p>
|
||||||
|
) : (
|
||||||
|
<pre className="max-h-80 overflow-auto rounded-md bg-muted p-3 text-xs font-mono whitespace-pre-wrap">
|
||||||
|
{log.map(l => l.t).filter(Boolean).join('\n')}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
<RuleDialog open={ruleDialog} onOpenChange={setRuleDialog} onSubmit={addRule} pending={createRule.isPending} groups={groups} allowGroupType />
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
192
frontend/src/features/nodes/PoolManager.tsx
Normal file
192
frontend/src/features/nodes/PoolManager.tsx
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Plus, Trash2, RefreshCw, FolderTree, ChevronRight } from 'lucide-react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
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, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import {
|
||||||
|
usePools, usePool, useCreatePool, useUpdatePool, useDeletePool,
|
||||||
|
} from '@/hooks/use-pools';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { useConfirm } from '@/components/shared/ConfirmDialog';
|
||||||
|
|
||||||
|
interface PoolVm {
|
||||||
|
vmid: number;
|
||||||
|
name?: string;
|
||||||
|
type: string;
|
||||||
|
node?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Pool members (expandable) ====================
|
||||||
|
|
||||||
|
function PoolMembers({ serverId, poolid, vms }: { serverId: number; poolid: string; vms: PoolVm[] }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const confirm = useConfirm();
|
||||||
|
const { data } = usePool(serverId, poolid);
|
||||||
|
const updatePool = useUpdatePool(serverId);
|
||||||
|
const [addVmid, setAddVmid] = useState('');
|
||||||
|
|
||||||
|
const members = data?.pool?.members ?? [];
|
||||||
|
const memberVmids = new Set(members.filter(m => m.vmid != null).map(m => m.vmid));
|
||||||
|
const available = useMemo(() => vms.filter(v => !memberVmids.has(v.vmid)), [vms, memberVmids]);
|
||||||
|
|
||||||
|
const add = () => {
|
||||||
|
if (!addVmid) return;
|
||||||
|
updatePool.mutate({ poolid, data: { vms: addVmid } }, {
|
||||||
|
onSuccess: () => { toast.success(t('pools.member_added', 'Добавлено в пул')); setAddVmid(''); },
|
||||||
|
onError: (e: Error) => toast.error(e.message),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const remove = async (vmid: number) => {
|
||||||
|
if (!await confirm(t('pools.remove_member_confirm', 'Убрать из пула?'))) return;
|
||||||
|
updatePool.mutate({ poolid, data: { vms: String(vmid), delete: 1 } }, {
|
||||||
|
onSuccess: () => toast.success(t('pools.member_removed', 'Убрано из пула')),
|
||||||
|
onError: (e: Error) => toast.error(e.message),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 pt-2">
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<div className="flex-1 space-y-1">
|
||||||
|
<Label className="text-xs">{t('pools.add_member', 'Добавить VM/LXC')}</Label>
|
||||||
|
<Select value={addVmid} onValueChange={(v) => { if (v) setAddVmid(v); }}>
|
||||||
|
<SelectTrigger className="w-full h-8"><SelectValue placeholder={t('pools.select_vm', 'Выберите инстанс')} /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{available.length === 0
|
||||||
|
? <div className="px-2 py-1.5 text-xs text-muted-foreground">{t('pools.no_available', 'Нет доступных')}</div>
|
||||||
|
: available.map(v => (
|
||||||
|
<SelectItem key={v.vmid} value={String(v.vmid)}>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Badge variant="outline" className="text-[10px]">{v.type === 'qemu' ? 'VM' : 'LXC'}</Badge>
|
||||||
|
{v.name || `#${v.vmid}`} <span className="text-xs text-muted-foreground">#{v.vmid}</span>
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" onClick={add} disabled={!addVmid || updatePool.isPending}><Plus className="h-3.5 w-3.5" /></Button>
|
||||||
|
</div>
|
||||||
|
{members.length === 0 ? (
|
||||||
|
<p className="py-2 text-center text-xs text-muted-foreground">{t('pools.empty', 'Пул пуст')}</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{members.map(m => (
|
||||||
|
<div key={m.id} className="flex items-center gap-2 rounded border px-2 py-1 text-xs">
|
||||||
|
<Badge variant="outline" className="text-[10px]">
|
||||||
|
{m.type === 'storage' ? 'STORAGE' : m.type === 'qemu' ? 'VM' : 'LXC'}
|
||||||
|
</Badge>
|
||||||
|
<span className="font-medium">{m.name || m.storage || m.id}</span>
|
||||||
|
{m.vmid != null && <span className="font-mono text-muted-foreground">#{m.vmid}</span>}
|
||||||
|
{m.node && <span className="text-muted-foreground">{m.node}</span>}
|
||||||
|
{m.vmid != null && (
|
||||||
|
<Button variant="ghost" size="icon" className="ml-auto h-6 w-6 text-destructive" onClick={() => remove(m.vmid!)}>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Main ====================
|
||||||
|
|
||||||
|
export default function PoolManager({ serverId, vms }: { serverId: number; vms: PoolVm[] }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const confirm = useConfirm();
|
||||||
|
const { data, isLoading, refetch, isFetching } = usePools(serverId);
|
||||||
|
const createPool = useCreatePool(serverId);
|
||||||
|
const deletePool = useDeletePool(serverId);
|
||||||
|
|
||||||
|
const pools = data?.pools ?? [];
|
||||||
|
const [expanded, setExpanded] = useState<string | null>(null);
|
||||||
|
const [dialog, setDialog] = useState(false);
|
||||||
|
const [poolid, setPoolid] = useState('');
|
||||||
|
const [comment, setComment] = useState('');
|
||||||
|
|
||||||
|
const create = () => {
|
||||||
|
if (!poolid.trim()) { toast.error(t('pools.err_id', 'Укажите имя пула')); return; }
|
||||||
|
createPool.mutate({ poolid: poolid.trim(), comment: comment.trim() || undefined }, {
|
||||||
|
onSuccess: () => { toast.success(t('pools.created', 'Пул создан')); setDialog(false); setPoolid(''); setComment(''); },
|
||||||
|
onError: (e: Error) => toast.error(e.message),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const remove = async (p: string) => {
|
||||||
|
if (!await confirm(`${t('pools.delete', 'Удалить пул')} "${p}"? ${t('pools.delete_hint', '(пул должен быть пустым)')}`)) return;
|
||||||
|
deletePool.mutate(p, { onSuccess: () => toast.success(t('pools.deleted', 'Пул удалён')), onError: (e: Error) => toast.error(e.message) });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between gap-2 space-y-0">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-lg">
|
||||||
|
<FolderTree className="h-5 w-5" />
|
||||||
|
{t('pools.title', 'Пулы ресурсов')} ({pools.length})
|
||||||
|
</CardTitle>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button size="sm" variant="outline" onClick={() => refetch()} disabled={isFetching}>
|
||||||
|
<RefreshCw className={`h-4 w-4 ${isFetching ? 'animate-spin' : ''}`} />
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={() => setDialog(true)}>
|
||||||
|
<Plus className="mr-1 h-4 w-4" />{t('pools.add', 'Создать пул')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="py-8 text-center text-sm text-muted-foreground">{t('common.loading', 'Загрузка…')}</p>
|
||||||
|
) : pools.length === 0 ? (
|
||||||
|
<p className="py-8 text-center text-sm text-muted-foreground">{t('common.no_data', 'Нет данных')}</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{pools.map(p => (
|
||||||
|
<div key={p.poolid} className="rounded-md border">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2">
|
||||||
|
<button className="flex flex-1 items-center gap-2 text-left" onClick={() => setExpanded(prev => prev === p.poolid ? null : p.poolid)}>
|
||||||
|
<ChevronRight className={`h-4 w-4 transition-transform ${expanded === p.poolid ? 'rotate-90' : ''}`} />
|
||||||
|
<span className="font-mono font-medium">{p.poolid}</span>
|
||||||
|
{p.comment && <span className="text-xs text-muted-foreground">— {p.comment}</span>}
|
||||||
|
</button>
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => remove(p.poolid)}>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{expanded === p.poolid && (
|
||||||
|
<div className="px-3 pb-3"><PoolMembers serverId={serverId} poolid={p.poolid} vms={vms} /></div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
<Dialog open={dialog} onOpenChange={(v) => { setDialog(v); if (!v) { setPoolid(''); setComment(''); } }}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader><DialogTitle>{t('pools.add', 'Создать пул')}</DialogTitle></DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>{t('pools.pool_id', 'Имя пула')}</Label>
|
||||||
|
<Input value={poolid} onChange={(e) => setPoolid(e.target.value)} className="font-mono" placeholder="production" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>{t('common.comment', 'Комментарий')}</Label>
|
||||||
|
<Input value={comment} onChange={(e) => setComment(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDialog(false)}>{t('common.cancel', 'Отмена')}</Button>
|
||||||
|
<Button onClick={create} disabled={createPool.isPending}>{t('common.add', 'Создать')}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
97
frontend/src/hooks/use-access.ts
Normal file
97
frontend/src/hooks/use-access.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { apiClient } from '@/lib/api-client';
|
||||||
|
|
||||||
|
export interface Realm {
|
||||||
|
realm: string;
|
||||||
|
type: string; // pam | pve | ldap | ad | openid
|
||||||
|
comment?: string;
|
||||||
|
tfa?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AccessUser {
|
||||||
|
userid: string;
|
||||||
|
enable?: number;
|
||||||
|
comment?: string;
|
||||||
|
email?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiToken {
|
||||||
|
tokenid: string;
|
||||||
|
comment?: string;
|
||||||
|
expire?: number;
|
||||||
|
privsep?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const accessKeys = {
|
||||||
|
realms: (s: number) => ['access-realms', s] as const,
|
||||||
|
users: (s: number) => ['access-users', s] as const,
|
||||||
|
tokens: (s: number, u: string) => ['access-tokens', s, u] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
const base = (s: number) => `/proxmox/api/servers/${s}/access`;
|
||||||
|
|
||||||
|
// ---------- Realms ----------
|
||||||
|
export function useRealms(serverId: number, enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: accessKeys.realms(serverId),
|
||||||
|
queryFn: () => apiClient.get<{ realms: Realm[] }>(`${base(serverId)}/realms`),
|
||||||
|
enabled: serverId > 0 && enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateRealm(serverId: number) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: Record<string, unknown>) => apiClient.post(`${base(serverId)}/realms`, data),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: accessKeys.realms(serverId) }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteRealm(serverId: number) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (realm: string) => apiClient.delete(`${base(serverId)}/realms/${realm}`),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: accessKeys.realms(serverId) }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Users & tokens ----------
|
||||||
|
export function useAccessUsers(serverId: number, enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: accessKeys.users(serverId),
|
||||||
|
queryFn: () => apiClient.get<{ users: AccessUser[] }>(`${base(serverId)}/users`),
|
||||||
|
enabled: serverId > 0 && enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUserTokens(serverId: number, userid: string, enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: accessKeys.tokens(serverId, userid),
|
||||||
|
queryFn: () => apiClient.get<{ tokens: ApiToken[] }>(`${base(serverId)}/users/${encodeURIComponent(userid)}/tokens`),
|
||||||
|
enabled: serverId > 0 && !!userid && enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreatedToken {
|
||||||
|
success: boolean;
|
||||||
|
value?: string;
|
||||||
|
full_tokenid?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateToken(serverId: number) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation<CreatedToken, Error, { userid: string; tokenid: string; comment?: string; privsep: boolean }>({
|
||||||
|
mutationFn: ({ userid, tokenid, ...data }) =>
|
||||||
|
apiClient.post(`${base(serverId)}/users/${encodeURIComponent(userid)}/tokens/${encodeURIComponent(tokenid)}`, data),
|
||||||
|
onSuccess: (_r, v) => qc.invalidateQueries({ queryKey: accessKeys.tokens(serverId, v.userid) }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteToken(serverId: number, userid: string) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (tokenid: string) =>
|
||||||
|
apiClient.delete(`${base(serverId)}/users/${encodeURIComponent(userid)}/tokens/${encodeURIComponent(tokenid)}`),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: accessKeys.tokens(serverId, userid) }),
|
||||||
|
});
|
||||||
|
}
|
||||||
109
frontend/src/hooks/use-disks.ts
Normal file
109
frontend/src/hooks/use-disks.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { apiClient } from '@/lib/api-client';
|
||||||
|
|
||||||
|
export interface NodeDisk {
|
||||||
|
devpath: string;
|
||||||
|
size?: number;
|
||||||
|
model?: string;
|
||||||
|
serial?: string;
|
||||||
|
type?: string; // 'ssd' | 'hdd' | 'nvme' | ...
|
||||||
|
vendor?: string;
|
||||||
|
used?: string; // 'LVM' | 'ZFS' | 'partitions' | ... | undefined
|
||||||
|
health?: string; // 'PASSED' | 'OK' | 'FAILED' | ...
|
||||||
|
wearout?: number | string;
|
||||||
|
rpm?: number;
|
||||||
|
gpt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ZfsPool {
|
||||||
|
name: string;
|
||||||
|
size?: number;
|
||||||
|
alloc?: number;
|
||||||
|
free?: number;
|
||||||
|
frag?: number;
|
||||||
|
dedup?: number;
|
||||||
|
health?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SmartAttribute {
|
||||||
|
id?: number;
|
||||||
|
name?: string;
|
||||||
|
value?: number | string;
|
||||||
|
worst?: number | string;
|
||||||
|
threshold?: number | string;
|
||||||
|
raw?: string;
|
||||||
|
flags?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SmartData {
|
||||||
|
health?: string;
|
||||||
|
type?: string;
|
||||||
|
text?: string;
|
||||||
|
attributes?: SmartAttribute[];
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const diskKeys = {
|
||||||
|
disks: (s: number, n: string) => ['node-disks', s, n] as const,
|
||||||
|
smart: (s: number, n: string, d: string) => ['disk-smart', s, n, d] as const,
|
||||||
|
zfs: (s: number, n: string) => ['zfs-pools', s, n] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
const base = (s: number, n: string) => `/proxmox/api/servers/${s}/nodes/${n}/disks`;
|
||||||
|
|
||||||
|
export function useNodeDisks(serverId: number, node: string, enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: diskKeys.disks(serverId, node),
|
||||||
|
queryFn: () => apiClient.get<{ disks: NodeDisk[] }>(base(serverId, node)),
|
||||||
|
enabled: serverId > 0 && !!node && enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDiskSmart(serverId: number, node: string, disk: string, enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: diskKeys.smart(serverId, node, disk),
|
||||||
|
queryFn: () => apiClient.get<{ smart: SmartData }>(`${base(serverId, node)}/smart?disk=${encodeURIComponent(disk)}`),
|
||||||
|
enabled: serverId > 0 && !!node && !!disk && enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useZfsPools(serverId: number, node: string, enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: diskKeys.zfs(serverId, node),
|
||||||
|
queryFn: () => apiClient.get<{ pools: ZfsPool[] }>(`${base(serverId, node)}/zfs`),
|
||||||
|
enabled: serverId > 0 && !!node && enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function useInvalidate(serverId: number, node: string) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return () => {
|
||||||
|
qc.invalidateQueries({ queryKey: diskKeys.zfs(serverId, node) });
|
||||||
|
qc.invalidateQueries({ queryKey: diskKeys.disks(serverId, node) });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateZfsPool(serverId: number, node: string) {
|
||||||
|
const invalidate = useInvalidate(serverId, node);
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: Record<string, unknown>) => apiClient.post(`${base(serverId, node)}/zfs`, data),
|
||||||
|
onSuccess: () => invalidate(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDestroyZfsPool(serverId: number, node: string) {
|
||||||
|
const invalidate = useInvalidate(serverId, node);
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ name, cleanupDisks }: { name: string; cleanupDisks?: boolean }) =>
|
||||||
|
apiClient.delete(`${base(serverId, node)}/zfs/${name}?cleanup_disks=${cleanupDisks ? 'true' : 'false'}`),
|
||||||
|
onSuccess: () => invalidate(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWipeDisk(serverId: number, node: string) {
|
||||||
|
const invalidate = useInvalidate(serverId, node);
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (disk: string) => apiClient.put(`${base(serverId, node)}/wipe?disk=${encodeURIComponent(disk)}`, {}),
|
||||||
|
onSuccess: () => invalidate(),
|
||||||
|
});
|
||||||
|
}
|
||||||
391
frontend/src/hooks/use-firewall.ts
Normal file
391
frontend/src/hooks/use-firewall.ts
Normal file
@@ -0,0 +1,391 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { apiClient } from '@/lib/api-client';
|
||||||
|
|
||||||
|
// ==================== Types ====================
|
||||||
|
export interface FirewallRule {
|
||||||
|
pos?: number;
|
||||||
|
type: string; // 'in' | 'out' | 'group'
|
||||||
|
action: string; // ACCEPT | DROP | REJECT | <security group name>
|
||||||
|
enable?: number;
|
||||||
|
source?: string;
|
||||||
|
dest?: string;
|
||||||
|
proto?: string;
|
||||||
|
dport?: string;
|
||||||
|
sport?: string;
|
||||||
|
macro?: string;
|
||||||
|
iface?: string;
|
||||||
|
log?: string;
|
||||||
|
comment?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FirewallGroup {
|
||||||
|
group: string;
|
||||||
|
comment?: string;
|
||||||
|
digest?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FirewallIpset {
|
||||||
|
name: string;
|
||||||
|
comment?: string;
|
||||||
|
digest?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IpsetEntry {
|
||||||
|
cidr: string;
|
||||||
|
comment?: string;
|
||||||
|
nomatch?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FirewallAlias {
|
||||||
|
name: string;
|
||||||
|
cidr: string;
|
||||||
|
comment?: string;
|
||||||
|
ipversion?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FirewallOptions {
|
||||||
|
enable?: number;
|
||||||
|
policy_in?: string;
|
||||||
|
policy_out?: string;
|
||||||
|
log_ratelimit?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FirewallMacro {
|
||||||
|
macro: string;
|
||||||
|
descr?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FirewallLogLine {
|
||||||
|
n?: number;
|
||||||
|
t?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Query keys ====================
|
||||||
|
export const fwKeys = {
|
||||||
|
options: (s: number) => ['fw-options', s] as const,
|
||||||
|
rules: (s: number) => ['fw-rules', s] as const,
|
||||||
|
groups: (s: number) => ['fw-groups', s] as const,
|
||||||
|
groupRules: (s: number, g: string) => ['fw-group-rules', s, g] as const,
|
||||||
|
ipsets: (s: number) => ['fw-ipsets', s] as const,
|
||||||
|
ipsetEntries: (s: number, n: string) => ['fw-ipset-entries', s, n] as const,
|
||||||
|
aliases: (s: number) => ['fw-aliases', s] as const,
|
||||||
|
macros: (s: number) => ['fw-macros', s] as const,
|
||||||
|
nodeOptions: (s: number, n: string) => ['fw-node-options', s, n] as const,
|
||||||
|
nodeRules: (s: number, n: string) => ['fw-node-rules', s, n] as const,
|
||||||
|
nodeLog: (s: number, n: string) => ['fw-node-log', s, n] as const,
|
||||||
|
guestOptions: (s: number, t: string, v: number) => ['fw-guest-options', s, t, v] as const,
|
||||||
|
guestRules: (s: number, t: string, v: number) => ['fw-guest-rules', s, t, v] as const,
|
||||||
|
guestLog: (s: number, t: string, v: number) => ['fw-guest-log', s, t, v] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
const base = (s: number) => `/proxmox/api/servers/${s}/firewall`;
|
||||||
|
|
||||||
|
// ==================== Options ====================
|
||||||
|
export function useFirewallOptions(serverId: number, enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: fwKeys.options(serverId),
|
||||||
|
queryFn: () => apiClient.get<{ options: FirewallOptions }>(`${base(serverId)}/options`),
|
||||||
|
enabled: serverId > 0 && enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateFirewallOptions(serverId: number) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: Record<string, unknown>) => apiClient.put(`${base(serverId)}/options`, data),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: fwKeys.options(serverId) }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Datacenter rules ====================
|
||||||
|
export function useFirewallRules(serverId: number, enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: fwKeys.rules(serverId),
|
||||||
|
queryFn: () => apiClient.get<{ rules: FirewallRule[] }>(`${base(serverId)}/rules`),
|
||||||
|
enabled: serverId > 0 && enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateFirewallRule(serverId: number) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: Record<string, unknown>) => apiClient.post(`${base(serverId)}/rules`, data),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: fwKeys.rules(serverId) }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateFirewallRule(serverId: number) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ pos, data }: { pos: number; data: Record<string, unknown> }) =>
|
||||||
|
apiClient.put(`${base(serverId)}/rules/${pos}`, data),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: fwKeys.rules(serverId) }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteFirewallRule(serverId: number) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (pos: number) => apiClient.delete(`${base(serverId)}/rules/${pos}`),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: fwKeys.rules(serverId) }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Security groups ====================
|
||||||
|
export function useFirewallGroups(serverId: number, enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: fwKeys.groups(serverId),
|
||||||
|
queryFn: () => apiClient.get<{ groups: FirewallGroup[] }>(`${base(serverId)}/groups`),
|
||||||
|
enabled: serverId > 0 && enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateFirewallGroup(serverId: number) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: { group: string; comment?: string }) => apiClient.post(`${base(serverId)}/groups`, data),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: fwKeys.groups(serverId) }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteFirewallGroup(serverId: number) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (group: string) => apiClient.delete(`${base(serverId)}/groups/${group}`),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: fwKeys.groups(serverId) }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFirewallGroupRules(serverId: number, group: string, enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: fwKeys.groupRules(serverId, group),
|
||||||
|
queryFn: () => apiClient.get<{ rules: FirewallRule[] }>(`${base(serverId)}/groups/${group}/rules`),
|
||||||
|
enabled: serverId > 0 && !!group && enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateFirewallGroupRule(serverId: number, group: string) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: Record<string, unknown>) => apiClient.post(`${base(serverId)}/groups/${group}/rules`, data),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: fwKeys.groupRules(serverId, group) }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteFirewallGroupRule(serverId: number, group: string) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (pos: number) => apiClient.delete(`${base(serverId)}/groups/${group}/rules/${pos}`),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: fwKeys.groupRules(serverId, group) }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== IP sets ====================
|
||||||
|
export function useFirewallIpsets(serverId: number, enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: fwKeys.ipsets(serverId),
|
||||||
|
queryFn: () => apiClient.get<{ ipsets: FirewallIpset[] }>(`${base(serverId)}/ipsets`),
|
||||||
|
enabled: serverId > 0 && enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateFirewallIpset(serverId: number) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: { name: string; comment?: string }) => apiClient.post(`${base(serverId)}/ipsets`, data),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: fwKeys.ipsets(serverId) }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteFirewallIpset(serverId: number) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (name: string) => apiClient.delete(`${base(serverId)}/ipsets/${name}`),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: fwKeys.ipsets(serverId) }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFirewallIpsetEntries(serverId: number, name: string, enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: fwKeys.ipsetEntries(serverId, name),
|
||||||
|
queryFn: () => apiClient.get<{ entries: IpsetEntry[] }>(`${base(serverId)}/ipsets/${name}/entries`),
|
||||||
|
enabled: serverId > 0 && !!name && enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAddIpsetEntry(serverId: number, name: string) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: { cidr: string; comment?: string; nomatch?: boolean }) =>
|
||||||
|
apiClient.post(`${base(serverId)}/ipsets/${name}/entries`, data),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: fwKeys.ipsetEntries(serverId, name) }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteIpsetEntry(serverId: number, name: string) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (cidr: string) =>
|
||||||
|
apiClient.delete(`${base(serverId)}/ipsets/${name}/entries?cidr=${encodeURIComponent(cidr)}`),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: fwKeys.ipsetEntries(serverId, name) }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Aliases ====================
|
||||||
|
export function useFirewallAliases(serverId: number, enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: fwKeys.aliases(serverId),
|
||||||
|
queryFn: () => apiClient.get<{ aliases: FirewallAlias[] }>(`${base(serverId)}/aliases`),
|
||||||
|
enabled: serverId > 0 && enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateFirewallAlias(serverId: number) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: { name: string; cidr: string; comment?: string }) =>
|
||||||
|
apiClient.post(`${base(serverId)}/aliases`, data),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: fwKeys.aliases(serverId) }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateFirewallAlias(serverId: number) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ name, data }: { name: string; data: Record<string, unknown> }) =>
|
||||||
|
apiClient.put(`${base(serverId)}/aliases/${name}`, data),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: fwKeys.aliases(serverId) }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteFirewallAlias(serverId: number) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (name: string) => apiClient.delete(`${base(serverId)}/aliases/${name}`),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: fwKeys.aliases(serverId) }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Node-level ====================
|
||||||
|
const nodeBase = (s: number, n: string) => `/proxmox/api/servers/${s}/nodes/${n}/firewall`;
|
||||||
|
|
||||||
|
export function useNodeFirewallOptions(serverId: number, node: string, enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: fwKeys.nodeOptions(serverId, node),
|
||||||
|
queryFn: () => apiClient.get<{ options: FirewallOptions }>(`${nodeBase(serverId, node)}/options`),
|
||||||
|
enabled: serverId > 0 && !!node && enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateNodeFirewallOptions(serverId: number, node: string) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: Record<string, unknown>) => apiClient.put(`${nodeBase(serverId, node)}/options`, data),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: fwKeys.nodeOptions(serverId, node) }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNodeFirewallRules(serverId: number, node: string, enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: fwKeys.nodeRules(serverId, node),
|
||||||
|
queryFn: () => apiClient.get<{ rules: FirewallRule[] }>(`${nodeBase(serverId, node)}/rules`),
|
||||||
|
enabled: serverId > 0 && !!node && enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateNodeFirewallRule(serverId: number, node: string) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: Record<string, unknown>) => apiClient.post(`${nodeBase(serverId, node)}/rules`, data),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: fwKeys.nodeRules(serverId, node) }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateNodeFirewallRule(serverId: number, node: string) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ pos, data }: { pos: number; data: Record<string, unknown> }) =>
|
||||||
|
apiClient.put(`${nodeBase(serverId, node)}/rules/${pos}`, data),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: fwKeys.nodeRules(serverId, node) }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteNodeFirewallRule(serverId: number, node: string) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (pos: number) => apiClient.delete(`${nodeBase(serverId, node)}/rules/${pos}`),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: fwKeys.nodeRules(serverId, node) }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNodeFirewallLog(serverId: number, node: string, enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: fwKeys.nodeLog(serverId, node),
|
||||||
|
queryFn: () => apiClient.get<{ log: FirewallLogLine[] }>(`${nodeBase(serverId, node)}/log?limit=200`),
|
||||||
|
enabled: serverId > 0 && !!node && enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Guest-level (VM/LXC) ====================
|
||||||
|
const guestBase = (s: number, t: string, v: number) => `/proxmox/api/servers/${s}/guests/${t}/${v}/firewall`;
|
||||||
|
|
||||||
|
export function useGuestFirewallOptions(serverId: number, vmType: string, vmid: number, node: string, enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: fwKeys.guestOptions(serverId, vmType, vmid),
|
||||||
|
queryFn: () => apiClient.get<{ options: FirewallOptions }>(`${guestBase(serverId, vmType, vmid)}/options?node=${node}`),
|
||||||
|
enabled: serverId > 0 && !!node && vmid > 0 && enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateGuestFirewallOptions(serverId: number, vmType: string, vmid: number, node: string) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: Record<string, unknown>) =>
|
||||||
|
apiClient.put(`${guestBase(serverId, vmType, vmid)}/options?node=${node}`, data),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: fwKeys.guestOptions(serverId, vmType, vmid) }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGuestFirewallRules(serverId: number, vmType: string, vmid: number, node: string, enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: fwKeys.guestRules(serverId, vmType, vmid),
|
||||||
|
queryFn: () => apiClient.get<{ rules: FirewallRule[] }>(`${guestBase(serverId, vmType, vmid)}/rules?node=${node}`),
|
||||||
|
enabled: serverId > 0 && !!node && vmid > 0 && enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateGuestFirewallRule(serverId: number, vmType: string, vmid: number, node: string) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: Record<string, unknown>) =>
|
||||||
|
apiClient.post(`${guestBase(serverId, vmType, vmid)}/rules?node=${node}`, data),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: fwKeys.guestRules(serverId, vmType, vmid) }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateGuestFirewallRule(serverId: number, vmType: string, vmid: number, node: string) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ pos, data }: { pos: number; data: Record<string, unknown> }) =>
|
||||||
|
apiClient.put(`${guestBase(serverId, vmType, vmid)}/rules/${pos}?node=${node}`, data),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: fwKeys.guestRules(serverId, vmType, vmid) }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteGuestFirewallRule(serverId: number, vmType: string, vmid: number, node: string) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (pos: number) =>
|
||||||
|
apiClient.delete(`${guestBase(serverId, vmType, vmid)}/rules/${pos}?node=${node}`),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: fwKeys.guestRules(serverId, vmType, vmid) }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGuestFirewallLog(serverId: number, vmType: string, vmid: number, node: string, enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: fwKeys.guestLog(serverId, vmType, vmid),
|
||||||
|
queryFn: () => apiClient.get<{ log: FirewallLogLine[] }>(`${guestBase(serverId, vmType, vmid)}/log?node=${node}&limit=200`),
|
||||||
|
enabled: serverId > 0 && !!node && vmid > 0 && enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -682,6 +682,44 @@ export function useNodeNetworks(serverId: number, node: string, enabled = true)
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== Node hardware (PCI/USB passthrough) ====================
|
||||||
|
export interface NodePciDevice {
|
||||||
|
id: string; // 0000:01:00.0
|
||||||
|
vendor_name?: string;
|
||||||
|
device_name?: string;
|
||||||
|
subsystem_device_name?: string;
|
||||||
|
iommugroup?: number;
|
||||||
|
mdev?: number | boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NodeUsbDevice {
|
||||||
|
busnum?: number | string;
|
||||||
|
devnum?: number | string;
|
||||||
|
vendid?: string;
|
||||||
|
prodid?: string;
|
||||||
|
port?: number | string;
|
||||||
|
usbpath?: string;
|
||||||
|
speed?: string;
|
||||||
|
manufacturer?: string;
|
||||||
|
prodname?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNodePciDevices(serverId: number, node: string, enabled = true) {
|
||||||
|
return useQuery<NodePciDevice[]>({
|
||||||
|
queryKey: ['node-pci', serverId, node],
|
||||||
|
queryFn: () => apiClient.get(`/proxmox/api/${serverId}/nodes/${node}/hardware/pci`),
|
||||||
|
enabled: enabled && !!node,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNodeUsbDevices(serverId: number, node: string, enabled = true) {
|
||||||
|
return useQuery<NodeUsbDevice[]>({
|
||||||
|
queryKey: ['node-usb', serverId, node],
|
||||||
|
queryFn: () => apiClient.get(`/proxmox/api/${serverId}/nodes/${node}/hardware/usb`),
|
||||||
|
enabled: enabled && !!node,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== ISO mount/unmount (KVM only) ====================
|
// ==================== ISO mount/unmount (KVM only) ====================
|
||||||
export interface IsoItem {
|
export interface IsoItem {
|
||||||
volid: string;
|
volid: string;
|
||||||
|
|||||||
101
frontend/src/hooks/use-node-admin.ts
Normal file
101
frontend/src/hooks/use-node-admin.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { apiClient } from '@/lib/api-client';
|
||||||
|
|
||||||
|
export interface NodeService {
|
||||||
|
service: string;
|
||||||
|
name?: string;
|
||||||
|
desc?: string;
|
||||||
|
state?: string; // running | stopped | dead | ...
|
||||||
|
'active-state'?: string;
|
||||||
|
'unit-state'?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AptUpdate {
|
||||||
|
Package: string;
|
||||||
|
Version?: string;
|
||||||
|
OldVersion?: string;
|
||||||
|
Title?: string;
|
||||||
|
Description?: string;
|
||||||
|
Priority?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AptRepo {
|
||||||
|
Enabled?: number | boolean;
|
||||||
|
Types?: string[];
|
||||||
|
URIs?: string[];
|
||||||
|
Suites?: string[];
|
||||||
|
Components?: string[];
|
||||||
|
Comment?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AptRepoFile {
|
||||||
|
path: string;
|
||||||
|
'file-type'?: string;
|
||||||
|
repositories?: AptRepo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AptRepositories {
|
||||||
|
files?: AptRepoFile[];
|
||||||
|
'standard-repos'?: Array<{ handle: string; name: string; status?: number }>;
|
||||||
|
errors?: unknown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const nodeAdminKeys = {
|
||||||
|
services: (s: number, n: string) => ['node-services', s, n] as const,
|
||||||
|
updates: (s: number, n: string) => ['apt-updates', s, n] as const,
|
||||||
|
repos: (s: number, n: string) => ['apt-repos', s, n] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
const base = (s: number, n: string) => `/proxmox/api/servers/${s}/nodes/${n}`;
|
||||||
|
|
||||||
|
// ---------- Services ----------
|
||||||
|
export function useNodeServices(serverId: number, node: string, enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: nodeAdminKeys.services(serverId, node),
|
||||||
|
queryFn: () => apiClient.get<{ services: NodeService[] }>(`${base(serverId, node)}/services`),
|
||||||
|
enabled: serverId > 0 && !!node && enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useServiceAction(serverId: number, node: string) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ service, action }: { service: string; action: string }) =>
|
||||||
|
apiClient.post(`${base(serverId, node)}/services/${service}/${action}`, {}),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: nodeAdminKeys.services(serverId, node) }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- APT ----------
|
||||||
|
export function useAptUpdates(serverId: number, node: string, enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: nodeAdminKeys.updates(serverId, node),
|
||||||
|
queryFn: () => apiClient.get<{ updates: AptUpdate[] }>(`${base(serverId, node)}/apt/updates`),
|
||||||
|
enabled: serverId > 0 && !!node && enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAptRefresh(serverId: number, node: string) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: () => apiClient.post(`${base(serverId, node)}/apt/refresh`, {}),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: nodeAdminKeys.updates(serverId, node) }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAptRepositories(serverId: number, node: string, enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: nodeAdminKeys.repos(serverId, node),
|
||||||
|
queryFn: () => apiClient.get<{ repositories: AptRepositories }>(`${base(serverId, node)}/apt/repositories`),
|
||||||
|
enabled: serverId > 0 && !!node && enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSetAptRepository(serverId: number, node: string) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: { path: string; index: number; enabled: boolean }) =>
|
||||||
|
apiClient.post(`${base(serverId, node)}/apt/repositories`, data),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: nodeAdminKeys.repos(serverId, node) }),
|
||||||
|
});
|
||||||
|
}
|
||||||
78
frontend/src/hooks/use-pools.ts
Normal file
78
frontend/src/hooks/use-pools.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { apiClient } from '@/lib/api-client';
|
||||||
|
|
||||||
|
export interface Pool {
|
||||||
|
poolid: string;
|
||||||
|
comment?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PoolMember {
|
||||||
|
id: string;
|
||||||
|
type: string; // 'qemu' | 'lxc' | 'storage'
|
||||||
|
vmid?: number;
|
||||||
|
name?: string;
|
||||||
|
node?: string;
|
||||||
|
storage?: string;
|
||||||
|
status?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PoolDetail {
|
||||||
|
comment?: string;
|
||||||
|
members?: PoolMember[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const poolKeys = {
|
||||||
|
list: (s: number) => ['pools', s] as const,
|
||||||
|
detail: (s: number, p: string) => ['pool', s, p] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
const base = (s: number) => `/proxmox/api/servers/${s}/pools`;
|
||||||
|
|
||||||
|
export function usePools(serverId: number, enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: poolKeys.list(serverId),
|
||||||
|
queryFn: () => apiClient.get<{ pools: Pool[] }>(base(serverId)),
|
||||||
|
enabled: serverId > 0 && enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePool(serverId: number, poolid: string, enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: poolKeys.detail(serverId, poolid),
|
||||||
|
queryFn: () => apiClient.get<{ pool: PoolDetail }>(`${base(serverId)}/${poolid}`),
|
||||||
|
enabled: serverId > 0 && !!poolid && enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function useInvalidatePools(serverId: number) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return (poolid?: string) => {
|
||||||
|
qc.invalidateQueries({ queryKey: poolKeys.list(serverId) });
|
||||||
|
if (poolid) qc.invalidateQueries({ queryKey: poolKeys.detail(serverId, poolid) });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreatePool(serverId: number) {
|
||||||
|
const invalidate = useInvalidatePools(serverId);
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: { poolid: string; comment?: string }) => apiClient.post(base(serverId), data),
|
||||||
|
onSuccess: () => invalidate(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdatePool(serverId: number) {
|
||||||
|
const invalidate = useInvalidatePools(serverId);
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ poolid, data }: { poolid: string; data: Record<string, unknown> }) =>
|
||||||
|
apiClient.put(`${base(serverId)}/${poolid}`, data),
|
||||||
|
onSuccess: (_r, v) => invalidate(v.poolid),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeletePool(serverId: number) {
|
||||||
|
const invalidate = useInvalidatePools(serverId);
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (poolid: string) => apiClient.delete(`${base(serverId)}/${poolid}`),
|
||||||
|
onSuccess: () => invalidate(),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -276,6 +276,12 @@
|
|||||||
"overview": "Overview",
|
"overview": "Overview",
|
||||||
"graphs": "Graphs",
|
"graphs": "Graphs",
|
||||||
"networking": "Networking",
|
"networking": "Networking",
|
||||||
|
"hardware": "Hardware",
|
||||||
|
"firewall": "Firewall",
|
||||||
|
"tags": "Tags",
|
||||||
|
"no_tags": "No tags",
|
||||||
|
"tags_hint": "Comma-separated tags",
|
||||||
|
"tags_saved": "Tags saved",
|
||||||
"destroy": "Destroy",
|
"destroy": "Destroy",
|
||||||
"task_activity": "Task Activity",
|
"task_activity": "Task Activity",
|
||||||
"status_creating": "Creating",
|
"status_creating": "Creating",
|
||||||
@@ -453,6 +459,219 @@
|
|||||||
"applied": "Network configuration applied",
|
"applied": "Network configuration applied",
|
||||||
"reverted": "Pending changes reverted"
|
"reverted": "Pending changes reverted"
|
||||||
},
|
},
|
||||||
|
"hw": {
|
||||||
|
"platform": "Platform",
|
||||||
|
"bios": "BIOS",
|
||||||
|
"machine": "Machine type",
|
||||||
|
"vga": "Display (VGA)",
|
||||||
|
"scsihw": "SCSI controller",
|
||||||
|
"sockets": "CPU sockets",
|
||||||
|
"platform_saved": "Hardware updated",
|
||||||
|
"platform_hint": "Hardware changes take effect after the VM is powered off and started again.",
|
||||||
|
"passthrough": "Device passthrough",
|
||||||
|
"no_passthrough": "No passthrough devices",
|
||||||
|
"remove_device": "Remove device",
|
||||||
|
"device_removed": "Device {{key}} removed",
|
||||||
|
"remove_title": "Remove device?",
|
||||||
|
"remove_description": "Device {{key}} will be removed from the VM configuration.",
|
||||||
|
"passthrough_hint": "PCI/USB passthrough requires the VM to be powered off and IOMMU enabled on the host.",
|
||||||
|
"stop_first": "Power off the VM to change passthrough",
|
||||||
|
"running_lock": "The VM is running — passthrough devices cannot be added or removed. Power off the VM.",
|
||||||
|
"efi_required_title": "OVMF (UEFI) requires an EFI disk",
|
||||||
|
"efi_required_hint": "The EFI disk will be created automatically. Choose a storage for it.",
|
||||||
|
"efi_storage": "EFI disk storage",
|
||||||
|
"select_storage": "Select a storage",
|
||||||
|
"err_efi_storage": "Select a storage for the EFI disk",
|
||||||
|
"add_submit": "Add",
|
||||||
|
"add_pci_title": "PCI passthrough",
|
||||||
|
"add_pci_description": "Will be added as {{key}}. The VM must be powered off.",
|
||||||
|
"pci_device": "Device",
|
||||||
|
"select_pci": "Select a device",
|
||||||
|
"no_pci": "No devices found",
|
||||||
|
"pci_pcie": "PCI-Express (q35 only)",
|
||||||
|
"pci_all_functions": "All device functions",
|
||||||
|
"pci_primary_gpu": "Primary GPU (x-vga)",
|
||||||
|
"pci_rombar": "ROM-Bar",
|
||||||
|
"pci_hint": "PCI passthrough requires IOMMU (VT-d / AMD-Vi) enabled on the host.",
|
||||||
|
"err_pci_required": "Select a PCI device",
|
||||||
|
"pci_added": "Device {{key}} added",
|
||||||
|
"add_usb_title": "USB passthrough",
|
||||||
|
"add_usb_description": "Will be added as {{key}}",
|
||||||
|
"usb_device": "Device",
|
||||||
|
"select_usb": "Select a device",
|
||||||
|
"no_usb": "No devices found",
|
||||||
|
"usb3": "USB 3.0",
|
||||||
|
"err_usb_required": "Select a USB device",
|
||||||
|
"usb_added": "Device {{key}} added"
|
||||||
|
},
|
||||||
|
"fw": {
|
||||||
|
"title": "Firewall (datacenter)",
|
||||||
|
"on": "on",
|
||||||
|
"off": "off",
|
||||||
|
"enable": "Enable",
|
||||||
|
"disable": "Disable",
|
||||||
|
"rules": "Rules",
|
||||||
|
"groups": "Security Groups",
|
||||||
|
"ipsets": "IP Sets",
|
||||||
|
"aliases": "Aliases",
|
||||||
|
"options": "Options",
|
||||||
|
"add_rule": "Add rule",
|
||||||
|
"rule_hint": "Rules are evaluated top to bottom.",
|
||||||
|
"direction": "Direction",
|
||||||
|
"action": "Action",
|
||||||
|
"match": "Source → Destination",
|
||||||
|
"security_group": "Security group",
|
||||||
|
"select_group": "Select a group",
|
||||||
|
"proto": "Protocol",
|
||||||
|
"macro": "Macro",
|
||||||
|
"source": "Source",
|
||||||
|
"dest": "Destination",
|
||||||
|
"dport": "Dest. port",
|
||||||
|
"sport": "Src. port",
|
||||||
|
"enabled": "Enabled",
|
||||||
|
"log": "Log",
|
||||||
|
"err_group_required": "Select a security group",
|
||||||
|
"rule_added": "Rule added",
|
||||||
|
"rule_deleted": "Rule deleted",
|
||||||
|
"delete_rule_confirm": "Delete this rule?",
|
||||||
|
"add_group": "Add security group",
|
||||||
|
"delete_group": "Delete security group",
|
||||||
|
"group_name": "Group name",
|
||||||
|
"group_added": "Security group created",
|
||||||
|
"group_deleted": "Group deleted",
|
||||||
|
"add_ipset": "Add IP set",
|
||||||
|
"delete_ipset": "Delete IP set",
|
||||||
|
"ipset_name": "IP set name",
|
||||||
|
"ipset_added": "IP set created",
|
||||||
|
"ipset_deleted": "IP set deleted",
|
||||||
|
"add_alias": "Add alias",
|
||||||
|
"delete_alias": "Delete alias",
|
||||||
|
"alias_added": "Alias created",
|
||||||
|
"alias_deleted": "Alias deleted",
|
||||||
|
"name": "Name",
|
||||||
|
"entry_added": "Entry added",
|
||||||
|
"entry_deleted": "Entry deleted",
|
||||||
|
"delete_entry": "Delete entry",
|
||||||
|
"enable_dc": "Enable datacenter firewall",
|
||||||
|
"policy_in": "Inbound policy",
|
||||||
|
"policy_out": "Outbound policy",
|
||||||
|
"options_hint": "Policies apply to traffic that matches no rule.",
|
||||||
|
"options_saved": "Options saved",
|
||||||
|
"err_name": "Enter a name",
|
||||||
|
"err_cidr": "Enter a CIDR",
|
||||||
|
"node_title": "Firewall (node)",
|
||||||
|
"log_tab": "Log",
|
||||||
|
"enable_node": "Enable node firewall",
|
||||||
|
"log_level_in": "Inbound log level",
|
||||||
|
"log_level_out": "Outbound log level",
|
||||||
|
"nosmurfs": "Block SMURF attacks",
|
||||||
|
"tcpflags": "Filter illegal TCP flags",
|
||||||
|
"synflood": "SYN-flood protection",
|
||||||
|
"node_options_hint": "These options apply to traffic of the Proxmox host node itself.",
|
||||||
|
"guest_title": "Instance firewall",
|
||||||
|
"enable_guest": "Enable instance firewall",
|
||||||
|
"dhcp": "Allow DHCP",
|
||||||
|
"macfilter": "MAC filter",
|
||||||
|
"ipfilter": "IP filter (anti-spoofing)",
|
||||||
|
"ndp": "NDP (IPv6)",
|
||||||
|
"guest_options_hint": "The instance firewall only works when the datacenter firewall is enabled and firewall=1 is set on the NIC."
|
||||||
|
},
|
||||||
|
"pools": {
|
||||||
|
"title": "Resource Pools",
|
||||||
|
"add": "Create pool",
|
||||||
|
"pool_id": "Pool name",
|
||||||
|
"created": "Pool created",
|
||||||
|
"deleted": "Pool deleted",
|
||||||
|
"delete": "Delete pool",
|
||||||
|
"delete_hint": "(the pool must be empty)",
|
||||||
|
"err_id": "Enter a pool name",
|
||||||
|
"empty": "Pool is empty",
|
||||||
|
"add_member": "Add VM/LXC",
|
||||||
|
"select_vm": "Select an instance",
|
||||||
|
"no_available": "None available",
|
||||||
|
"member_added": "Added to pool",
|
||||||
|
"member_removed": "Removed from pool",
|
||||||
|
"remove_member_confirm": "Remove from pool?"
|
||||||
|
},
|
||||||
|
"disks": {
|
||||||
|
"title": "Disks & ZFS",
|
||||||
|
"physical": "Disks",
|
||||||
|
"device": "Device",
|
||||||
|
"type": "Type",
|
||||||
|
"model": "Model",
|
||||||
|
"size": "Size",
|
||||||
|
"usage": "Usage",
|
||||||
|
"free": "free",
|
||||||
|
"health": "Health",
|
||||||
|
"attribute": "Attribute",
|
||||||
|
"value": "Value",
|
||||||
|
"worst": "Worst",
|
||||||
|
"threshold": "Threshold",
|
||||||
|
"wipe": "Wipe",
|
||||||
|
"wipe_confirm": "Wipe disk (all data will be destroyed)",
|
||||||
|
"wiped": "Disk is being wiped",
|
||||||
|
"create_zfs": "Create ZFS pool",
|
||||||
|
"create_zfs_hint": "The disks will be wiped and combined into a pool.",
|
||||||
|
"pool_name": "Pool name",
|
||||||
|
"raid_level": "RAID level",
|
||||||
|
"compression": "Compression",
|
||||||
|
"devices": "Disks",
|
||||||
|
"no_free_disks": "No free disks",
|
||||||
|
"err_name": "Enter a pool name",
|
||||||
|
"err_devices": "Select disks",
|
||||||
|
"pool_created": "ZFS pool is being created",
|
||||||
|
"destroy_pool": "Destroy ZFS pool",
|
||||||
|
"pool_destroyed": "Pool is being destroyed",
|
||||||
|
"alloc": "Allocated"
|
||||||
|
},
|
||||||
|
"access": {
|
||||||
|
"title": "PVE Access",
|
||||||
|
"tokens": "API Tokens",
|
||||||
|
"realms": "Realms",
|
||||||
|
"type": "Type",
|
||||||
|
"add_realm": "Add realm",
|
||||||
|
"realm_id": "Realm",
|
||||||
|
"realm_created": "Realm created",
|
||||||
|
"realm_deleted": "Realm deleted",
|
||||||
|
"delete_realm": "Delete realm",
|
||||||
|
"err_realm": "Enter a realm name",
|
||||||
|
"server": "Server",
|
||||||
|
"user_attr": "User attribute",
|
||||||
|
"domain": "Domain",
|
||||||
|
"username_claim": "Username claim",
|
||||||
|
"secure": "SSL/TLS (secure)",
|
||||||
|
"builtin": "built-in",
|
||||||
|
"select_user": "User",
|
||||||
|
"add_token": "Create token",
|
||||||
|
"token_id": "Token ID",
|
||||||
|
"privsep": "Privilege separation (token has its own permissions)",
|
||||||
|
"secret": "Secret",
|
||||||
|
"secret_warning": "The secret is shown only once — save it now.",
|
||||||
|
"token_created": "Token created",
|
||||||
|
"token_deleted": "Token deleted",
|
||||||
|
"delete_token": "Delete token",
|
||||||
|
"err_tokenid": "Enter a token ID",
|
||||||
|
"expire": "Expires"
|
||||||
|
},
|
||||||
|
"nodeadm": {
|
||||||
|
"title": "Node administration",
|
||||||
|
"services": "Services",
|
||||||
|
"updates": "Updates",
|
||||||
|
"repos": "Repositories",
|
||||||
|
"service": "Service",
|
||||||
|
"desc": "Description",
|
||||||
|
"package": "Package",
|
||||||
|
"current": "Current",
|
||||||
|
"new": "New",
|
||||||
|
"refresh": "Check (apt update)",
|
||||||
|
"apt_refreshed": "Package lists are refreshing",
|
||||||
|
"up_to_date": "No updates",
|
||||||
|
"updates_count": "Updates available: {{n}}",
|
||||||
|
"upgrade_hint": "Installing updates is done in the node console (apt dist-upgrade) — the API does not support unattended upgrade.",
|
||||||
|
"repo_updated": "Repository updated",
|
||||||
|
"enable": "Enable",
|
||||||
|
"disable": "Disable"
|
||||||
|
},
|
||||||
"ha": {
|
"ha": {
|
||||||
"title": "High Availability",
|
"title": "High Availability",
|
||||||
"add": "Add to HA",
|
"add": "Add to HA",
|
||||||
@@ -572,6 +791,15 @@
|
|||||||
"username": "Username",
|
"username": "Username",
|
||||||
"password": "Password",
|
"password": "Password",
|
||||||
"fingerprint": "Fingerprint",
|
"fingerprint": "Fingerprint",
|
||||||
|
"vgname": "Volume group",
|
||||||
|
"thinpool": "Thin pool",
|
||||||
|
"zfs_pool": "ZFS pool / dataset",
|
||||||
|
"ceph_pool": "Ceph pool",
|
||||||
|
"monhost": "Monitor hosts",
|
||||||
|
"fsname": "CephFS name",
|
||||||
|
"sparse": "Thin provisioning",
|
||||||
|
"disabled_ok": "Storage disabled",
|
||||||
|
"enabled_ok": "Storage enabled",
|
||||||
"disabled": "Disabled",
|
"disabled": "Disabled",
|
||||||
"enabled": "Enabled",
|
"enabled": "Enabled",
|
||||||
"id_required": "Storage ID is required",
|
"id_required": "Storage ID is required",
|
||||||
|
|||||||
@@ -276,6 +276,12 @@
|
|||||||
"overview": "Обзор",
|
"overview": "Обзор",
|
||||||
"graphs": "Графики",
|
"graphs": "Графики",
|
||||||
"networking": "Сеть",
|
"networking": "Сеть",
|
||||||
|
"hardware": "Оборудование",
|
||||||
|
"firewall": "Firewall",
|
||||||
|
"tags": "Теги",
|
||||||
|
"no_tags": "Нет тегов",
|
||||||
|
"tags_hint": "Теги через запятую",
|
||||||
|
"tags_saved": "Теги сохранены",
|
||||||
"destroy": "Уничтожить",
|
"destroy": "Уничтожить",
|
||||||
"task_activity": "Активность задач",
|
"task_activity": "Активность задач",
|
||||||
"status_creating": "Создание",
|
"status_creating": "Создание",
|
||||||
@@ -453,6 +459,219 @@
|
|||||||
"applied": "Конфигурация сети применена",
|
"applied": "Конфигурация сети применена",
|
||||||
"reverted": "Ожидающие изменения отменены"
|
"reverted": "Ожидающие изменения отменены"
|
||||||
},
|
},
|
||||||
|
"hw": {
|
||||||
|
"platform": "Платформа",
|
||||||
|
"bios": "BIOS",
|
||||||
|
"machine": "Тип платформы",
|
||||||
|
"vga": "Дисплей (VGA)",
|
||||||
|
"scsihw": "SCSI-контроллер",
|
||||||
|
"sockets": "CPU-сокеты",
|
||||||
|
"platform_saved": "Оборудование обновлено",
|
||||||
|
"platform_hint": "Изменения оборудования применяются после выключения и повторного запуска VM.",
|
||||||
|
"passthrough": "Проброс устройств",
|
||||||
|
"no_passthrough": "Нет проброшенных устройств",
|
||||||
|
"remove_device": "Удалить устройство",
|
||||||
|
"device_removed": "Устройство {{key}} удалено",
|
||||||
|
"remove_title": "Удалить устройство?",
|
||||||
|
"remove_description": "Устройство {{key}} будет удалено из конфигурации VM.",
|
||||||
|
"passthrough_hint": "Проброс PCI/USB требует выключенной VM и включённого IOMMU на хосте.",
|
||||||
|
"stop_first": "Выключите VM для изменения проброса",
|
||||||
|
"running_lock": "VM запущена — проброс устройств нельзя добавить или удалить. Выключите VM.",
|
||||||
|
"efi_required_title": "Для OVMF (UEFI) нужен EFI-диск",
|
||||||
|
"efi_required_hint": "EFI-диск будет создан автоматически. Выберите хранилище для него.",
|
||||||
|
"efi_storage": "Хранилище EFI-диска",
|
||||||
|
"select_storage": "Выберите хранилище",
|
||||||
|
"err_efi_storage": "Выберите хранилище для EFI-диска",
|
||||||
|
"add_submit": "Добавить",
|
||||||
|
"add_pci_title": "Проброс PCI-устройства",
|
||||||
|
"add_pci_description": "Будет добавлено как {{key}}. VM должна быть выключена.",
|
||||||
|
"pci_device": "Устройство",
|
||||||
|
"select_pci": "Выберите устройство",
|
||||||
|
"no_pci": "Устройства не найдены",
|
||||||
|
"pci_pcie": "PCI-Express (только для q35)",
|
||||||
|
"pci_all_functions": "Все функции устройства",
|
||||||
|
"pci_primary_gpu": "Основная видеокарта (x-vga)",
|
||||||
|
"pci_rombar": "ROM-Bar",
|
||||||
|
"pci_hint": "Проброс PCI требует включённого IOMMU (VT-d / AMD-Vi) на хосте.",
|
||||||
|
"err_pci_required": "Выберите PCI-устройство",
|
||||||
|
"pci_added": "Устройство {{key}} добавлено",
|
||||||
|
"add_usb_title": "Проброс USB-устройства",
|
||||||
|
"add_usb_description": "Будет добавлено как {{key}}",
|
||||||
|
"usb_device": "Устройство",
|
||||||
|
"select_usb": "Выберите устройство",
|
||||||
|
"no_usb": "Устройства не найдены",
|
||||||
|
"usb3": "USB 3.0",
|
||||||
|
"err_usb_required": "Выберите USB-устройство",
|
||||||
|
"usb_added": "Устройство {{key}} добавлено"
|
||||||
|
},
|
||||||
|
"fw": {
|
||||||
|
"title": "Firewall (датацентр)",
|
||||||
|
"on": "вкл",
|
||||||
|
"off": "выкл",
|
||||||
|
"enable": "Включить",
|
||||||
|
"disable": "Выключить",
|
||||||
|
"rules": "Правила",
|
||||||
|
"groups": "Security Groups",
|
||||||
|
"ipsets": "IP Sets",
|
||||||
|
"aliases": "Aliases",
|
||||||
|
"options": "Опции",
|
||||||
|
"add_rule": "Добавить правило",
|
||||||
|
"rule_hint": "Правила применяются сверху вниз.",
|
||||||
|
"direction": "Направление",
|
||||||
|
"action": "Действие",
|
||||||
|
"match": "Источник → Назначение",
|
||||||
|
"security_group": "Security group",
|
||||||
|
"select_group": "Выберите группу",
|
||||||
|
"proto": "Протокол",
|
||||||
|
"macro": "Макрос",
|
||||||
|
"source": "Источник",
|
||||||
|
"dest": "Назначение",
|
||||||
|
"dport": "Порт назн.",
|
||||||
|
"sport": "Порт источн.",
|
||||||
|
"enabled": "Включено",
|
||||||
|
"log": "Логировать",
|
||||||
|
"err_group_required": "Выберите security group",
|
||||||
|
"rule_added": "Правило добавлено",
|
||||||
|
"rule_deleted": "Правило удалено",
|
||||||
|
"delete_rule_confirm": "Удалить правило?",
|
||||||
|
"add_group": "Добавить security group",
|
||||||
|
"delete_group": "Удалить security group",
|
||||||
|
"group_name": "Имя группы",
|
||||||
|
"group_added": "Группа создана",
|
||||||
|
"group_deleted": "Группа удалена",
|
||||||
|
"add_ipset": "Добавить IP set",
|
||||||
|
"delete_ipset": "Удалить IP set",
|
||||||
|
"ipset_name": "Имя IP set",
|
||||||
|
"ipset_added": "IP set создан",
|
||||||
|
"ipset_deleted": "IP set удалён",
|
||||||
|
"add_alias": "Добавить алиас",
|
||||||
|
"delete_alias": "Удалить алиас",
|
||||||
|
"alias_added": "Алиас создан",
|
||||||
|
"alias_deleted": "Алиас удалён",
|
||||||
|
"name": "Имя",
|
||||||
|
"entry_added": "Запись добавлена",
|
||||||
|
"entry_deleted": "Запись удалена",
|
||||||
|
"delete_entry": "Удалить запись",
|
||||||
|
"enable_dc": "Включить фаервол датацентра",
|
||||||
|
"policy_in": "Политика входящих",
|
||||||
|
"policy_out": "Политика исходящих",
|
||||||
|
"options_hint": "Политики применяются к трафику, не совпавшему ни с одним правилом.",
|
||||||
|
"options_saved": "Опции сохранены",
|
||||||
|
"err_name": "Укажите имя",
|
||||||
|
"err_cidr": "Укажите CIDR",
|
||||||
|
"node_title": "Firewall (нода)",
|
||||||
|
"log_tab": "Лог",
|
||||||
|
"enable_node": "Включить фаервол ноды",
|
||||||
|
"log_level_in": "Лог входящих",
|
||||||
|
"log_level_out": "Лог исходящих",
|
||||||
|
"nosmurfs": "Блокировать SMURF-атаки",
|
||||||
|
"tcpflags": "Фильтровать недопустимые TCP-флаги",
|
||||||
|
"synflood": "Защита от SYN-flood",
|
||||||
|
"node_options_hint": "Опции применяются к трафику самой ноды хоста Proxmox.",
|
||||||
|
"guest_title": "Firewall инстанса",
|
||||||
|
"enable_guest": "Включить фаервол инстанса",
|
||||||
|
"dhcp": "Разрешить DHCP",
|
||||||
|
"macfilter": "MAC-фильтр",
|
||||||
|
"ipfilter": "IP-фильтр (анти-спуфинг)",
|
||||||
|
"ndp": "NDP (IPv6)",
|
||||||
|
"guest_options_hint": "Фаервол инстанса работает только когда включён фаервол датацентра и стоит firewall=1 на NIC."
|
||||||
|
},
|
||||||
|
"pools": {
|
||||||
|
"title": "Пулы ресурсов",
|
||||||
|
"add": "Создать пул",
|
||||||
|
"pool_id": "Имя пула",
|
||||||
|
"created": "Пул создан",
|
||||||
|
"deleted": "Пул удалён",
|
||||||
|
"delete": "Удалить пул",
|
||||||
|
"delete_hint": "(пул должен быть пустым)",
|
||||||
|
"err_id": "Укажите имя пула",
|
||||||
|
"empty": "Пул пуст",
|
||||||
|
"add_member": "Добавить VM/LXC",
|
||||||
|
"select_vm": "Выберите инстанс",
|
||||||
|
"no_available": "Нет доступных",
|
||||||
|
"member_added": "Добавлено в пул",
|
||||||
|
"member_removed": "Убрано из пула",
|
||||||
|
"remove_member_confirm": "Убрать из пула?"
|
||||||
|
},
|
||||||
|
"disks": {
|
||||||
|
"title": "Диски и ZFS",
|
||||||
|
"physical": "Диски",
|
||||||
|
"device": "Устройство",
|
||||||
|
"type": "Тип",
|
||||||
|
"model": "Модель",
|
||||||
|
"size": "Объём",
|
||||||
|
"usage": "Использование",
|
||||||
|
"free": "свободен",
|
||||||
|
"health": "Состояние",
|
||||||
|
"attribute": "Атрибут",
|
||||||
|
"value": "Знач.",
|
||||||
|
"worst": "Худш.",
|
||||||
|
"threshold": "Порог",
|
||||||
|
"wipe": "Очистить",
|
||||||
|
"wipe_confirm": "Очистить диск (все данные будут уничтожены)",
|
||||||
|
"wiped": "Диск очищается",
|
||||||
|
"create_zfs": "Создать ZFS-пул",
|
||||||
|
"create_zfs_hint": "Диски будут очищены и объединены в пул.",
|
||||||
|
"pool_name": "Имя пула",
|
||||||
|
"raid_level": "RAID-уровень",
|
||||||
|
"compression": "Сжатие",
|
||||||
|
"devices": "Диски",
|
||||||
|
"no_free_disks": "Нет свободных дисков",
|
||||||
|
"err_name": "Укажите имя пула",
|
||||||
|
"err_devices": "Выберите диски",
|
||||||
|
"pool_created": "ZFS-пул создаётся",
|
||||||
|
"destroy_pool": "Уничтожить ZFS-пул",
|
||||||
|
"pool_destroyed": "Пул уничтожается",
|
||||||
|
"alloc": "Занято"
|
||||||
|
},
|
||||||
|
"access": {
|
||||||
|
"title": "PVE Access",
|
||||||
|
"tokens": "API-токены",
|
||||||
|
"realms": "Realms",
|
||||||
|
"type": "Тип",
|
||||||
|
"add_realm": "Добавить realm",
|
||||||
|
"realm_id": "Realm",
|
||||||
|
"realm_created": "Realm создан",
|
||||||
|
"realm_deleted": "Realm удалён",
|
||||||
|
"delete_realm": "Удалить realm",
|
||||||
|
"err_realm": "Укажите имя realm",
|
||||||
|
"server": "Сервер",
|
||||||
|
"user_attr": "User attribute",
|
||||||
|
"domain": "Домен",
|
||||||
|
"username_claim": "Username claim",
|
||||||
|
"secure": "SSL/TLS (secure)",
|
||||||
|
"builtin": "встроенный",
|
||||||
|
"select_user": "Пользователь",
|
||||||
|
"add_token": "Создать токен",
|
||||||
|
"token_id": "ID токена",
|
||||||
|
"privsep": "Privilege separation (у токена свои права)",
|
||||||
|
"secret": "Секрет",
|
||||||
|
"secret_warning": "Секрет показывается только один раз — сохраните его сейчас.",
|
||||||
|
"token_created": "Токен создан",
|
||||||
|
"token_deleted": "Токен удалён",
|
||||||
|
"delete_token": "Удалить токен",
|
||||||
|
"err_tokenid": "Укажите ID токена",
|
||||||
|
"expire": "Истекает"
|
||||||
|
},
|
||||||
|
"nodeadm": {
|
||||||
|
"title": "Администрирование ноды",
|
||||||
|
"services": "Сервисы",
|
||||||
|
"updates": "Обновления",
|
||||||
|
"repos": "Репозитории",
|
||||||
|
"service": "Сервис",
|
||||||
|
"desc": "Описание",
|
||||||
|
"package": "Пакет",
|
||||||
|
"current": "Текущая",
|
||||||
|
"new": "Новая",
|
||||||
|
"refresh": "Проверить (apt update)",
|
||||||
|
"apt_refreshed": "Списки пакетов обновляются",
|
||||||
|
"up_to_date": "Обновлений нет",
|
||||||
|
"updates_count": "Доступно обновлений: {{n}}",
|
||||||
|
"upgrade_hint": "Установка обновлений выполняется в консоли ноды (apt dist-upgrade) — API не поддерживает неинтерактивный upgrade.",
|
||||||
|
"repo_updated": "Репозиторий обновлён",
|
||||||
|
"enable": "Включить",
|
||||||
|
"disable": "Выключить"
|
||||||
|
},
|
||||||
"ha": {
|
"ha": {
|
||||||
"title": "Высокая доступность (HA)",
|
"title": "Высокая доступность (HA)",
|
||||||
"add": "Добавить в HA",
|
"add": "Добавить в HA",
|
||||||
@@ -572,6 +791,15 @@
|
|||||||
"username": "Пользователь",
|
"username": "Пользователь",
|
||||||
"password": "Пароль",
|
"password": "Пароль",
|
||||||
"fingerprint": "Отпечаток",
|
"fingerprint": "Отпечаток",
|
||||||
|
"vgname": "Volume group",
|
||||||
|
"thinpool": "Thin pool",
|
||||||
|
"zfs_pool": "ZFS пул / датасет",
|
||||||
|
"ceph_pool": "Ceph pool",
|
||||||
|
"monhost": "Monitor hosts",
|
||||||
|
"fsname": "Имя CephFS",
|
||||||
|
"sparse": "Тонкое выделение",
|
||||||
|
"disabled_ok": "Хранилище отключено",
|
||||||
|
"enabled_ok": "Хранилище включено",
|
||||||
"disabled": "Отключено",
|
"disabled": "Отключено",
|
||||||
"enabled": "Включено",
|
"enabled": "Включено",
|
||||||
"id_required": "Укажите ID хранилища",
|
"id_required": "Укажите ID хранилища",
|
||||||
|
|||||||
Reference in New Issue
Block a user