feat: add firewall, pools, access, disks, node admin and VM hardware management
All checks were successful
Backend Tests / pytest (Python 3.12) (push) Successful in 1m0s
Deploy to Production / Trigger production update (push) Successful in 7s

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:
markmorado
2026-07-04 00:02:15 +05:00
parent c12fbf13e5
commit 5c8baefb3f
37 changed files with 6627 additions and 10 deletions

View File

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

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

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

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

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

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

View File

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

View File

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

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

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

View 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 []

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

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

View File

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

View File

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

View File

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

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

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

View File

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

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

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

View File

@@ -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 */}

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

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

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

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

View File

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

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

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

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

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

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

View File

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

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

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

View File

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

View File

@@ -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 хранилища",