feat: Proxmox management suite — hardware, firewall, pools, disks, storage, access, node admin
All checks were successful
Backend Tests / pytest (Python 3.12) (push) Successful in 54s
Deploy to Production / Trigger production update (push) Successful in 7s

- VM/LXC hardware editor with PCI/USB passthrough (BIOS/OVMF + efidisk,
  machine type, VGA, SCSI controller, CPU sockets); passthrough locked while running
- Firewall at datacenter/node/VM levels: rules, security groups, IP sets,
  aliases, options, log — with rule dialog referencing security groups
- Resource pools (CRUD + membership) and per-instance tags editor
- Node disks: physical disk list, SMART viewer, ZFS pool create/destroy, wipe
- Storage: add LVM/LVM-thin/ZFS/RBD/CephFS types + enable/disable toggle
- PVE Access: API tokens (create with one-time secret, delete) and realms (LDAP/AD/OpenID)
- Node admin: systemd services (start/stop/restart), APT updates & repositories
- Shared: SelectTrigger defaults to full width (fixes narrow dropdowns)

Also bundles related in-progress work: auth screen refresh, node shell console
page, server-updates badge, and node:*/access:* RBAC permissions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
markmorado
2026-07-04 05:40:52 +05:00
parent 5c8baefb3f
commit 00e36e1cc6
31 changed files with 2576 additions and 357 deletions

View File

@@ -34,7 +34,7 @@ def _ok_or_400(result: dict):
async def list_realms( async def list_realms(
server_id: int, server_id: int,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("user:manage")), current_user: User = Depends(PermissionChecker("access:view")),
): ):
client = _resolve_client(db, server_id) client = _resolve_client(db, server_id)
try: try:
@@ -49,7 +49,7 @@ async def create_realm(
server_id: int, server_id: int,
request: Request, request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("user:manage")), current_user: User = Depends(PermissionChecker("access:manage")),
): ):
client = _resolve_client(db, server_id) client = _resolve_client(db, server_id)
data = await request.json() data = await request.json()
@@ -72,7 +72,7 @@ async def update_realm(
realm: str, realm: str,
request: Request, request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("user:manage")), current_user: User = Depends(PermissionChecker("access:manage")),
): ):
client = _resolve_client(db, server_id) client = _resolve_client(db, server_id)
data = await request.json() data = await request.json()
@@ -90,7 +90,7 @@ async def delete_realm(
server_id: int, server_id: int,
realm: str, realm: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("user:manage")), current_user: User = Depends(PermissionChecker("access:manage")),
): ):
client = _resolve_client(db, server_id) client = _resolve_client(db, server_id)
try: try:
@@ -108,7 +108,7 @@ async def delete_realm(
async def list_access_users( async def list_access_users(
server_id: int, server_id: int,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("user:manage")), current_user: User = Depends(PermissionChecker("access:view")),
): ):
client = _resolve_client(db, server_id) client = _resolve_client(db, server_id)
try: try:
@@ -123,7 +123,7 @@ async def list_user_tokens(
server_id: int, server_id: int,
userid: str, userid: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("user:manage")), current_user: User = Depends(PermissionChecker("access:view")),
): ):
client = _resolve_client(db, server_id) client = _resolve_client(db, server_id)
try: try:
@@ -140,7 +140,7 @@ async def create_user_token(
tokenid: str, tokenid: str,
request: Request, request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("user:manage")), current_user: User = Depends(PermissionChecker("access:manage")),
): ):
"""Создать API-токен. Секрет (value) возвращается один раз.""" """Создать API-токен. Секрет (value) возвращается один раз."""
client = _resolve_client(db, server_id) client = _resolve_client(db, server_id)
@@ -165,7 +165,7 @@ async def delete_user_token(
userid: str, userid: str,
tokenid: str, tokenid: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("user:manage")), current_user: User = Depends(PermissionChecker("access:manage")),
): ):
client = _resolve_client(db, server_id) client = _resolve_client(db, server_id)
try: try:

View File

@@ -35,7 +35,7 @@ async def list_disks(
server_id: int, server_id: int,
node: str, node: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:view")), current_user: User = Depends(PermissionChecker("storage:view")),
): ):
client = _resolve_client(db, server_id) client = _resolve_client(db, server_id)
try: try:
@@ -51,7 +51,7 @@ async def disk_smart(
node: str, node: str,
disk: str = Query(..., description="devpath, напр. /dev/sda"), disk: str = Query(..., description="devpath, напр. /dev/sda"),
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:view")), current_user: User = Depends(PermissionChecker("storage:view")),
): ):
client = _resolve_client(db, server_id) client = _resolve_client(db, server_id)
try: try:
@@ -67,7 +67,7 @@ async def wipe_disk(
node: str, node: str,
disk: str = Query(..., description="devpath, напр. /dev/sdb"), disk: str = Query(..., description="devpath, напр. /dev/sdb"),
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage")), current_user: User = Depends(PermissionChecker("storage:manage")),
): ):
client = _resolve_client(db, server_id) client = _resolve_client(db, server_id)
try: try:
@@ -86,7 +86,7 @@ async def list_zfs_pools(
server_id: int, server_id: int,
node: str, node: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:view")), current_user: User = Depends(PermissionChecker("storage:view")),
): ):
client = _resolve_client(db, server_id) client = _resolve_client(db, server_id)
try: try:
@@ -102,7 +102,7 @@ async def get_zfs_pool(
node: str, node: str,
name: str, name: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:view")), current_user: User = Depends(PermissionChecker("storage:view")),
): ):
client = _resolve_client(db, server_id) client = _resolve_client(db, server_id)
try: try:
@@ -118,7 +118,7 @@ async def create_zfs_pool(
node: str, node: str,
request: Request, request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage")), current_user: User = Depends(PermissionChecker("storage:manage")),
): ):
client = _resolve_client(db, server_id) client = _resolve_client(db, server_id)
data = await request.json() data = await request.json()
@@ -148,7 +148,7 @@ async def destroy_zfs_pool(
name: str, name: str,
cleanup_disks: bool = Query(False), cleanup_disks: bool = Query(False),
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage")), current_user: User = Depends(PermissionChecker("storage:manage")),
): ):
client = _resolve_client(db, server_id) client = _resolve_client(db, server_id)
try: try:

View File

@@ -36,7 +36,7 @@ def _ok_or_400(result: dict):
async def get_firewall_options( async def get_firewall_options(
server_id: int, server_id: int,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:view")), current_user: User = Depends(PermissionChecker("firewall:view")),
): ):
client = _resolve_client(db, server_id) client = _resolve_client(db, server_id)
try: try:
@@ -51,7 +51,7 @@ async def update_firewall_options(
server_id: int, server_id: int,
request: Request, request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage")), current_user: User = Depends(PermissionChecker("firewall:manage")),
): ):
client = _resolve_client(db, server_id) client = _resolve_client(db, server_id)
data = await request.json() data = await request.json()
@@ -70,7 +70,7 @@ async def update_firewall_options(
async def get_firewall_rules( async def get_firewall_rules(
server_id: int, server_id: int,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:view")), current_user: User = Depends(PermissionChecker("firewall:view")),
): ):
client = _resolve_client(db, server_id) client = _resolve_client(db, server_id)
try: try:
@@ -85,7 +85,7 @@ async def create_firewall_rule(
server_id: int, server_id: int,
request: Request, request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage")), current_user: User = Depends(PermissionChecker("firewall:manage")),
): ):
client = _resolve_client(db, server_id) client = _resolve_client(db, server_id)
data = await request.json() data = await request.json()
@@ -104,7 +104,7 @@ async def update_firewall_rule(
pos: int, pos: int,
request: Request, request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage")), current_user: User = Depends(PermissionChecker("firewall:manage")),
): ):
client = _resolve_client(db, server_id) client = _resolve_client(db, server_id)
data = await request.json() data = await request.json()
@@ -122,7 +122,7 @@ async def delete_firewall_rule(
server_id: int, server_id: int,
pos: int, pos: int,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage")), current_user: User = Depends(PermissionChecker("firewall:manage")),
): ):
client = _resolve_client(db, server_id) client = _resolve_client(db, server_id)
try: try:
@@ -140,7 +140,7 @@ async def delete_firewall_rule(
async def get_firewall_groups( async def get_firewall_groups(
server_id: int, server_id: int,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:view")), current_user: User = Depends(PermissionChecker("firewall:view")),
): ):
client = _resolve_client(db, server_id) client = _resolve_client(db, server_id)
try: try:
@@ -155,7 +155,7 @@ async def create_firewall_group(
server_id: int, server_id: int,
request: Request, request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage")), current_user: User = Depends(PermissionChecker("firewall:manage")),
): ):
client = _resolve_client(db, server_id) client = _resolve_client(db, server_id)
data = await request.json() data = await request.json()
@@ -177,7 +177,7 @@ async def delete_firewall_group(
server_id: int, server_id: int,
group: str, group: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage")), current_user: User = Depends(PermissionChecker("firewall:manage")),
): ):
client = _resolve_client(db, server_id) client = _resolve_client(db, server_id)
try: try:
@@ -194,7 +194,7 @@ async def get_firewall_group_rules(
server_id: int, server_id: int,
group: str, group: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:view")), current_user: User = Depends(PermissionChecker("firewall:view")),
): ):
client = _resolve_client(db, server_id) client = _resolve_client(db, server_id)
try: try:
@@ -210,7 +210,7 @@ async def create_firewall_group_rule(
group: str, group: str,
request: Request, request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage")), current_user: User = Depends(PermissionChecker("firewall:manage")),
): ):
client = _resolve_client(db, server_id) client = _resolve_client(db, server_id)
data = await request.json() data = await request.json()
@@ -230,7 +230,7 @@ async def update_firewall_group_rule(
pos: int, pos: int,
request: Request, request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage")), current_user: User = Depends(PermissionChecker("firewall:manage")),
): ):
client = _resolve_client(db, server_id) client = _resolve_client(db, server_id)
data = await request.json() data = await request.json()
@@ -249,7 +249,7 @@ async def delete_firewall_group_rule(
group: str, group: str,
pos: int, pos: int,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage")), current_user: User = Depends(PermissionChecker("firewall:manage")),
): ):
client = _resolve_client(db, server_id) client = _resolve_client(db, server_id)
try: try:
@@ -267,7 +267,7 @@ async def delete_firewall_group_rule(
async def get_firewall_ipsets( async def get_firewall_ipsets(
server_id: int, server_id: int,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:view")), current_user: User = Depends(PermissionChecker("firewall:view")),
): ):
client = _resolve_client(db, server_id) client = _resolve_client(db, server_id)
try: try:
@@ -282,7 +282,7 @@ async def create_firewall_ipset(
server_id: int, server_id: int,
request: Request, request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage")), current_user: User = Depends(PermissionChecker("firewall:manage")),
): ):
client = _resolve_client(db, server_id) client = _resolve_client(db, server_id)
data = await request.json() data = await request.json()
@@ -304,7 +304,7 @@ async def delete_firewall_ipset(
server_id: int, server_id: int,
name: str, name: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage")), current_user: User = Depends(PermissionChecker("firewall:manage")),
): ):
client = _resolve_client(db, server_id) client = _resolve_client(db, server_id)
try: try:
@@ -321,7 +321,7 @@ async def get_firewall_ipset_entries(
server_id: int, server_id: int,
name: str, name: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:view")), current_user: User = Depends(PermissionChecker("firewall:view")),
): ):
client = _resolve_client(db, server_id) client = _resolve_client(db, server_id)
try: try:
@@ -337,7 +337,7 @@ async def add_firewall_ipset_entry(
name: str, name: str,
request: Request, request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage")), current_user: User = Depends(PermissionChecker("firewall:manage")),
): ):
client = _resolve_client(db, server_id) client = _resolve_client(db, server_id)
data = await request.json() data = await request.json()
@@ -361,7 +361,7 @@ async def delete_firewall_ipset_entry(
name: str, name: str,
cidr: str = Query(..., description="CIDR записи (содержит '/', поэтому в query)"), cidr: str = Query(..., description="CIDR записи (содержит '/', поэтому в query)"),
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage")), current_user: User = Depends(PermissionChecker("firewall:manage")),
): ):
client = _resolve_client(db, server_id) client = _resolve_client(db, server_id)
try: try:
@@ -379,7 +379,7 @@ async def delete_firewall_ipset_entry(
async def get_firewall_aliases( async def get_firewall_aliases(
server_id: int, server_id: int,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:view")), current_user: User = Depends(PermissionChecker("firewall:view")),
): ):
client = _resolve_client(db, server_id) client = _resolve_client(db, server_id)
try: try:
@@ -394,7 +394,7 @@ async def create_firewall_alias(
server_id: int, server_id: int,
request: Request, request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage")), current_user: User = Depends(PermissionChecker("firewall:manage")),
): ):
client = _resolve_client(db, server_id) client = _resolve_client(db, server_id)
data = await request.json() data = await request.json()
@@ -417,7 +417,7 @@ async def update_firewall_alias(
name: str, name: str,
request: Request, request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage")), current_user: User = Depends(PermissionChecker("firewall:manage")),
): ):
client = _resolve_client(db, server_id) client = _resolve_client(db, server_id)
data = await request.json() data = await request.json()
@@ -439,7 +439,7 @@ async def delete_firewall_alias(
server_id: int, server_id: int,
name: str, name: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage")), current_user: User = Depends(PermissionChecker("firewall:manage")),
): ):
client = _resolve_client(db, server_id) client = _resolve_client(db, server_id)
try: try:
@@ -458,7 +458,7 @@ async def get_node_firewall_options(
server_id: int, server_id: int,
node: str, node: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:view")), current_user: User = Depends(PermissionChecker("firewall:view")),
): ):
client = _resolve_client(db, server_id) client = _resolve_client(db, server_id)
try: try:
@@ -474,7 +474,7 @@ async def update_node_firewall_options(
node: str, node: str,
request: Request, request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage")), current_user: User = Depends(PermissionChecker("firewall:manage")),
): ):
client = _resolve_client(db, server_id) client = _resolve_client(db, server_id)
data = await request.json() data = await request.json()
@@ -494,7 +494,7 @@ async def get_node_firewall_rules(
server_id: int, server_id: int,
node: str, node: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:view")), current_user: User = Depends(PermissionChecker("firewall:view")),
): ):
client = _resolve_client(db, server_id) client = _resolve_client(db, server_id)
try: try:
@@ -510,7 +510,7 @@ async def create_node_firewall_rule(
node: str, node: str,
request: Request, request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage")), current_user: User = Depends(PermissionChecker("firewall:manage")),
): ):
client = _resolve_client(db, server_id) client = _resolve_client(db, server_id)
data = await request.json() data = await request.json()
@@ -530,7 +530,7 @@ async def update_node_firewall_rule(
pos: int, pos: int,
request: Request, request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage")), current_user: User = Depends(PermissionChecker("firewall:manage")),
): ):
client = _resolve_client(db, server_id) client = _resolve_client(db, server_id)
data = await request.json() data = await request.json()
@@ -549,7 +549,7 @@ async def delete_node_firewall_rule(
node: str, node: str,
pos: int, pos: int,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage")), current_user: User = Depends(PermissionChecker("firewall:manage")),
): ):
client = _resolve_client(db, server_id) client = _resolve_client(db, server_id)
try: try:
@@ -568,7 +568,7 @@ async def get_node_firewall_log(
limit: int = Query(100, ge=1, le=500), limit: int = Query(100, ge=1, le=500),
start: int = Query(0, ge=0), start: int = Query(0, ge=0),
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:view")), current_user: User = Depends(PermissionChecker("firewall:view")),
): ):
client = _resolve_client(db, server_id) client = _resolve_client(db, server_id)
try: try:
@@ -593,7 +593,7 @@ async def get_guest_firewall_options(
vmid: int, vmid: int,
node: str = Query(...), node: str = Query(...),
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("vm:view")), current_user: User = Depends(PermissionChecker("firewall:view")),
): ):
client = _resolve_client(db, server_id) client = _resolve_client(db, server_id)
_norm_type(vm_type) _norm_type(vm_type)
@@ -613,7 +613,7 @@ async def update_guest_firewall_options(
request: Request, request: Request,
node: str = Query(...), node: str = Query(...),
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage")), current_user: User = Depends(PermissionChecker("firewall:manage")),
): ):
client = _resolve_client(db, server_id) client = _resolve_client(db, server_id)
_norm_type(vm_type) _norm_type(vm_type)
@@ -635,7 +635,7 @@ async def get_guest_firewall_rules(
vmid: int, vmid: int,
node: str = Query(...), node: str = Query(...),
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("vm:view")), current_user: User = Depends(PermissionChecker("firewall:view")),
): ):
client = _resolve_client(db, server_id) client = _resolve_client(db, server_id)
_norm_type(vm_type) _norm_type(vm_type)
@@ -655,7 +655,7 @@ async def create_guest_firewall_rule(
request: Request, request: Request,
node: str = Query(...), node: str = Query(...),
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage")), current_user: User = Depends(PermissionChecker("firewall:manage")),
): ):
client = _resolve_client(db, server_id) client = _resolve_client(db, server_id)
_norm_type(vm_type) _norm_type(vm_type)
@@ -679,7 +679,7 @@ async def update_guest_firewall_rule(
request: Request, request: Request,
node: str = Query(...), node: str = Query(...),
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage")), current_user: User = Depends(PermissionChecker("firewall:manage")),
): ):
client = _resolve_client(db, server_id) client = _resolve_client(db, server_id)
_norm_type(vm_type) _norm_type(vm_type)
@@ -702,7 +702,7 @@ async def delete_guest_firewall_rule(
pos: int, pos: int,
node: str = Query(...), node: str = Query(...),
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage")), current_user: User = Depends(PermissionChecker("firewall:manage")),
): ):
client = _resolve_client(db, server_id) client = _resolve_client(db, server_id)
_norm_type(vm_type) _norm_type(vm_type)
@@ -725,7 +725,7 @@ async def get_guest_firewall_log(
limit: int = Query(100, ge=1, le=500), limit: int = Query(100, ge=1, le=500),
start: int = Query(0, ge=0), start: int = Query(0, ge=0),
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("vm:view")), current_user: User = Depends(PermissionChecker("firewall:view")),
): ):
client = _resolve_client(db, server_id) client = _resolve_client(db, server_id)
_norm_type(vm_type) _norm_type(vm_type)
@@ -743,7 +743,7 @@ async def get_guest_firewall_log(
async def get_firewall_macros( async def get_firewall_macros(
server_id: int, server_id: int,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:view")), current_user: User = Depends(PermissionChecker("firewall:view")),
): ):
client = _resolve_client(db, server_id) client = _resolve_client(db, server_id)
try: try:

View File

@@ -18,7 +18,7 @@ router = APIRouter()
def get_server_nodes( def get_server_nodes(
server_id: int, server_id: int,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:view")) current_user: User = Depends(PermissionChecker("network:view"))
): ):
"""List nodes for a Proxmox server""" """List nodes for a Proxmox server"""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first() server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
@@ -41,7 +41,7 @@ def list_node_networks(
server_id: int, server_id: int,
node: str, node: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:view")) current_user: User = Depends(PermissionChecker("network:view"))
): ):
"""List all network interfaces on a node, with IPAM linkage info.""" """List all network interfaces on a node, with IPAM linkage info."""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first() server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
@@ -110,7 +110,7 @@ async def create_node_network(
node: str, node: str,
request: Request, request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage")) current_user: User = Depends(PermissionChecker("network:manage"))
): ):
"""Create a new network interface on a node.""" """Create a new network interface on a node."""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first() server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
@@ -150,7 +150,7 @@ async def update_node_network(
iface: str, iface: str,
request: Request, request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage")) current_user: User = Depends(PermissionChecker("network:manage"))
): ):
"""Update a network interface on a node.""" """Update a network interface on a node."""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first() server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
@@ -181,7 +181,7 @@ def delete_node_network(
node: str, node: str,
iface: str, iface: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage")) current_user: User = Depends(PermissionChecker("network:manage"))
): ):
"""Delete a network interface from a node.""" """Delete a network interface from a node."""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first() server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
@@ -210,7 +210,7 @@ def apply_node_network(
server_id: int, server_id: int,
node: str, node: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage")) current_user: User = Depends(PermissionChecker("network:manage"))
): ):
"""Apply pending network configuration changes on a node.""" """Apply pending network configuration changes on a node."""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first() server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
@@ -239,7 +239,7 @@ def revert_node_network(
server_id: int, server_id: int,
node: str, node: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage")) current_user: User = Depends(PermissionChecker("network:manage"))
): ):
"""Revert pending network configuration changes on a node.""" """Revert pending network configuration changes on a node."""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first() server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()

View File

@@ -35,7 +35,7 @@ async def list_services(
server_id: int, server_id: int,
node: str, node: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:view")), current_user: User = Depends(PermissionChecker("node:view")),
): ):
client = _resolve_client(db, server_id) client = _resolve_client(db, server_id)
try: try:
@@ -52,7 +52,7 @@ async def service_action(
service: str, service: str,
action: str, action: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage")), current_user: User = Depends(PermissionChecker("node:manage")),
): ):
if action not in ("start", "stop", "restart", "reload"): if action not in ("start", "stop", "restart", "reload"):
raise HTTPException(status_code=400, detail="action must be start/stop/restart/reload") raise HTTPException(status_code=400, detail="action must be start/stop/restart/reload")
@@ -73,7 +73,7 @@ async def apt_updates(
server_id: int, server_id: int,
node: str, node: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:view")), current_user: User = Depends(PermissionChecker("node:view")),
): ):
client = _resolve_client(db, server_id) client = _resolve_client(db, server_id)
try: try:
@@ -88,7 +88,7 @@ async def apt_refresh(
server_id: int, server_id: int,
node: str, node: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage")), current_user: User = Depends(PermissionChecker("node:manage")),
): ):
client = _resolve_client(db, server_id) client = _resolve_client(db, server_id)
try: try:
@@ -107,7 +107,7 @@ async def apt_repositories(
server_id: int, server_id: int,
node: str, node: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:view")), current_user: User = Depends(PermissionChecker("node:view")),
): ):
client = _resolve_client(db, server_id) client = _resolve_client(db, server_id)
try: try:
@@ -123,7 +123,7 @@ async def set_apt_repository(
node: str, node: str,
request: Request, request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage")), current_user: User = Depends(PermissionChecker("node:manage")),
): ):
"""Включить/выключить репозиторий (path + index + enabled).""" """Включить/выключить репозиторий (path + index + enabled)."""
client = _resolve_client(db, server_id) client = _resolve_client(db, server_id)
@@ -148,7 +148,7 @@ async def add_standard_repository(
node: str, node: str,
request: Request, request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage")), current_user: User = Depends(PermissionChecker("node:manage")),
): ):
"""Добавить стандартный репозиторий PVE по handle.""" """Добавить стандартный репозиторий PVE по handle."""
client = _resolve_client(db, server_id) client = _resolve_client(db, server_id)

View File

@@ -32,7 +32,7 @@ def _ok_or_400(result: dict):
async def list_pools( async def list_pools(
server_id: int, server_id: int,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("vm:view")), current_user: User = Depends(PermissionChecker("pool:view")),
): ):
client = _resolve_client(db, server_id) client = _resolve_client(db, server_id)
try: try:
@@ -47,7 +47,7 @@ async def get_pool(
server_id: int, server_id: int,
poolid: str, poolid: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("vm:view")), current_user: User = Depends(PermissionChecker("pool:view")),
): ):
client = _resolve_client(db, server_id) client = _resolve_client(db, server_id)
try: try:
@@ -62,7 +62,7 @@ async def create_pool(
server_id: int, server_id: int,
request: Request, request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage")), current_user: User = Depends(PermissionChecker("pool:manage")),
): ):
client = _resolve_client(db, server_id) client = _resolve_client(db, server_id)
data = await request.json() data = await request.json()
@@ -84,7 +84,7 @@ async def update_pool(
poolid: str, poolid: str,
request: Request, request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage")), current_user: User = Depends(PermissionChecker("pool:manage")),
): ):
"""Добавить/убрать участников пула (vms, storage) или изменить comment.""" """Добавить/убрать участников пула (vms, storage) или изменить comment."""
client = _resolve_client(db, server_id) client = _resolve_client(db, server_id)
@@ -109,7 +109,7 @@ async def delete_pool(
server_id: int, server_id: int,
poolid: str, poolid: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage")), current_user: User = Depends(PermissionChecker("pool:manage")),
): ):
client = _resolve_client(db, server_id) client = _resolve_client(db, server_id)
try: try:

View File

@@ -20,7 +20,7 @@ router = APIRouter()
def get_sdn_status( def get_sdn_status(
server_id: int, server_id: int,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:view")) current_user: User = Depends(PermissionChecker("network:view"))
): ):
"""Check if SDN is available on the server""" """Check if SDN is available on the server"""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first() server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
@@ -52,7 +52,7 @@ def get_sdn_status(
def get_sdn_zones( def get_sdn_zones(
server_id: int, server_id: int,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:view")) current_user: User = Depends(PermissionChecker("network:view"))
): ):
"""Get all SDN zones""" """Get all SDN zones"""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first() server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
@@ -73,7 +73,7 @@ def get_sdn_zones(
def get_sdn_dns( def get_sdn_dns(
server_id: int, server_id: int,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:view")) current_user: User = Depends(PermissionChecker("network:view"))
): ):
"""Get SDN DNS server entries (zone `dns` option expects one of their IDs)""" """Get SDN DNS server entries (zone `dns` option expects one of their IDs)"""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first() server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
@@ -93,7 +93,7 @@ async def create_sdn_zone(
server_id: int, server_id: int,
request: Request, request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage")) current_user: User = Depends(PermissionChecker("network:manage"))
): ):
"""Create a new SDN zone""" """Create a new SDN zone"""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first() server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
@@ -131,7 +131,7 @@ def delete_sdn_zone(
server_id: int, server_id: int,
zone: str, zone: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage")) current_user: User = Depends(PermissionChecker("network:manage"))
): ):
"""Delete an SDN zone""" """Delete an SDN zone"""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first() server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
@@ -159,7 +159,7 @@ def delete_sdn_zone(
def get_sdn_vnets( def get_sdn_vnets(
server_id: int, server_id: int,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:view")) current_user: User = Depends(PermissionChecker("network:view"))
): ):
"""Get all SDN VNets""" """Get all SDN VNets"""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first() server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
@@ -181,7 +181,7 @@ async def create_sdn_vnet(
server_id: int, server_id: int,
request: Request, request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage")) current_user: User = Depends(PermissionChecker("network:manage"))
): ):
"""Create a new SDN VNet""" """Create a new SDN VNet"""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first() server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
@@ -220,7 +220,7 @@ def delete_sdn_vnet(
server_id: int, server_id: int,
vnet: str, vnet: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage")) current_user: User = Depends(PermissionChecker("network:manage"))
): ):
"""Delete an SDN VNet""" """Delete an SDN VNet"""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first() server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
@@ -249,7 +249,7 @@ def get_sdn_subnets(
server_id: int, server_id: int,
vnet: str, vnet: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:view")) current_user: User = Depends(PermissionChecker("network:view"))
): ):
"""Get subnets for a VNet""" """Get subnets for a VNet"""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first() server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
@@ -272,7 +272,7 @@ async def create_sdn_subnet(
vnet: str, vnet: str,
request: Request, request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage")) current_user: User = Depends(PermissionChecker("network:manage"))
): ):
"""Create a subnet in a VNet""" """Create a subnet in a VNet"""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first() server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
@@ -336,7 +336,7 @@ async def create_sdn_subnet(
def apply_sdn_changes( def apply_sdn_changes(
server_id: int, server_id: int,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage")) current_user: User = Depends(PermissionChecker("network:manage"))
): ):
"""Apply pending SDN changes""" """Apply pending SDN changes"""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first() server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
@@ -366,7 +366,7 @@ async def update_sdn_zone(
zone: str, zone: str,
request: Request, request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage")) current_user: User = Depends(PermissionChecker("network:manage"))
): ):
"""Update an existing SDN zone""" """Update an existing SDN zone"""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first() server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
@@ -395,7 +395,7 @@ async def update_sdn_vnet(
vnet: str, vnet: str,
request: Request, request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage")) current_user: User = Depends(PermissionChecker("network:manage"))
): ):
"""Update an existing SDN VNet""" """Update an existing SDN VNet"""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first() server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
@@ -425,7 +425,7 @@ def delete_sdn_subnet(
subnet_cidr: str, subnet_cidr: str,
delete_ipam_network: bool = False, delete_ipam_network: bool = False,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage")) current_user: User = Depends(PermissionChecker("network:manage"))
): ):
"""Delete a subnet from a VNet. subnet_cidr uses '-' instead of '/' (e.g. 10.0.0.0-24).""" """Delete a subnet from a VNet. subnet_cidr uses '-' instead of '/' (e.g. 10.0.0.0-24)."""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first() server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()

View File

@@ -1528,6 +1528,258 @@ async def resize_vm_disk(
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/{server_id}/vm/{vmid}/disk/move")
async def move_vm_disk(
server_id: int,
vmid: int,
node: str,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage"))
):
"""Переместить диск VM в другое хранилище (move_disk)."""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox server not found")
data = await request.json()
disk = data.get('disk')
target_storage = data.get('target_storage')
if not disk or not target_storage:
raise HTTPException(status_code=400, detail="Требуются параметры disk и target_storage")
try:
client = _get_proxmox_client(server)
if not client.is_connected():
raise HTTPException(status_code=503, detail="Failed to connect to Proxmox server")
result = client.move_vm_disk(
node, vmid, disk, target_storage,
delete=bool(data.get('delete', True)),
target_format=data.get('format') or None,
)
if result.get('success'):
logger.info(f"User {current_user.username} moved disk {disk} of VM {vmid} to {target_storage}")
return JSONResponse(content={"status": "success", "upid": result.get('upid')})
raise HTTPException(status_code=400, detail=result.get('error', 'Failed'))
except HTTPException:
raise
except Exception as e:
logger.error(f"Error moving VM {vmid} disk: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/{server_id}/vm/{vmid}/disk/add")
async def add_vm_disk(
server_id: int,
vmid: int,
node: str,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage"))
):
"""Добавить новый диск VM."""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox server not found")
data = await request.json()
disk = data.get('disk')
storage = data.get('storage')
size_gb = data.get('size_gb')
if not disk or not storage or not size_gb:
raise HTTPException(status_code=400, detail="Требуются параметры disk, storage и size_gb")
try:
client = _get_proxmox_client(server)
if not client.is_connected():
raise HTTPException(status_code=503, detail="Failed to connect to Proxmox server")
result = client.add_vm_disk(
node, vmid, disk, storage, int(size_gb),
ssd=bool(data.get('ssd', False)),
discard=bool(data.get('discard', False)),
iothread=bool(data.get('iothread', False)),
)
if result.get('success'):
logger.info(f"User {current_user.username} added disk {disk} ({size_gb}G) to VM {vmid}")
return JSONResponse(content={"status": "success"})
raise HTTPException(status_code=400, detail=result.get('error', 'Failed'))
except HTTPException:
raise
except Exception as e:
logger.error(f"Error adding disk to VM {vmid}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/{server_id}/vm/{vmid}/disk/detach")
async def detach_vm_disk(
server_id: int,
vmid: int,
node: str,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage"))
):
"""Отключить (или удалить) диск VM."""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox server not found")
data = await request.json()
disk = data.get('disk')
if not disk:
raise HTTPException(status_code=400, detail="Требуется параметр disk")
try:
client = _get_proxmox_client(server)
if not client.is_connected():
raise HTTPException(status_code=503, detail="Failed to connect to Proxmox server")
result = client.detach_vm_disk(node, vmid, disk, destroy=bool(data.get('destroy', False)))
if result.get('success'):
logger.info(f"User {current_user.username} detached disk {disk} of VM {vmid}")
return JSONResponse(content={"status": "success"})
raise HTTPException(status_code=400, detail=result.get('error', 'Failed'))
except HTTPException:
raise
except Exception as e:
logger.error(f"Error detaching VM {vmid} disk: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/{server_id}/vm/{vmid}/unlock")
def unlock_vm_endpoint(
server_id: int,
vmid: int,
node: str,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage"))
):
"""Снять блокировку (lock) с VM — аналог qm unlock."""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox server not found")
try:
client = _get_proxmox_client(server)
if not client.is_connected():
raise HTTPException(status_code=503, detail="Failed to connect to Proxmox server")
result = client.unlock_vm(node, vmid)
if result.get("success"):
logger.info(f"User {current_user.username} unlocked VM {vmid} on {server.name}")
return JSONResponse(content={"status": "success"})
raise HTTPException(status_code=400, detail=result.get("error", "Failed"))
except HTTPException:
raise
except Exception as e:
logger.error(f"Error unlocking VM {vmid}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/{server_id}/container/{vmid}/unlock")
def unlock_container_endpoint(
server_id: int,
vmid: int,
node: str,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage"))
):
"""Снять блокировку (lock) с LXC — аналог pct unlock."""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox server not found")
try:
client = _get_proxmox_client(server)
if not client.is_connected():
raise HTTPException(status_code=503, detail="Failed to connect to Proxmox server")
result = client.unlock_container(node, vmid)
if result.get("success"):
logger.info(f"User {current_user.username} unlocked container {vmid} on {server.name}")
return JSONResponse(content={"status": "success"})
raise HTTPException(status_code=400, detail=result.get("error", "Failed"))
except HTTPException:
raise
except Exception as e:
logger.error(f"Error unlocking container {vmid}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/{server_id}/vm/{vmid}/cloud-init")
async def update_vm_cloud_init(
server_id: int,
vmid: int,
node: str,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage"))
):
"""Обновить cloud-init параметры VM (user/password/SSH/IP/DNS)."""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox server not found")
data = await request.json()
try:
client = _get_proxmox_client(server)
if not client.is_connected():
raise HTTPException(status_code=503, detail="Failed to connect to Proxmox server")
result = client.update_cloud_init(
node, vmid,
ciuser=data.get('ciuser'),
cipassword=data.get('cipassword'),
sshkeys=data.get('sshkeys'),
ipconfig0=data.get('ipconfig0'),
nameserver=data.get('nameserver'),
searchdomain=data.get('searchdomain'),
)
if result.get('success'):
logger.info(f"User {current_user.username} updated cloud-init of VM {vmid} on {server.name}")
return JSONResponse(content={"status": "success"})
raise HTTPException(status_code=400, detail=result.get('error', 'Failed'))
except HTTPException:
raise
except Exception as e:
logger.error(f"Error updating cloud-init for VM {vmid}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/{server_id}/vm/{vmid}/serial/enable")
def enable_vm_serial(
server_id: int,
vmid: int,
node: str,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage"))
):
"""Добавить serial0 (socket) в конфиг VM, если его нет — для serial-консоли.
Возвращает added=True, если устройство было только что добавлено (тогда для
появления в гостевой ОС может потребоваться перезагрузка VM).
"""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox server not found")
try:
client = _get_proxmox_client(server)
if not client.is_connected():
raise HTTPException(status_code=503, detail="Failed to connect to Proxmox server")
cfg = client.get_vm_config(node, vmid) or {}
present = any(k.startswith("serial") for k in cfg)
added = False
if not present:
client.update_vm_config(node, vmid, {"serial0": "socket"})
added = True
logger.info(f"User {current_user.username} enabled serial0 on VM {vmid}")
return JSONResponse(content={"status": "success", "added": added, "present": True})
except HTTPException:
raise
except Exception as e:
logger.error(f"Error enabling serial for VM {vmid}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/{server_id}/container/{vmid}/disk/resize") @router.post("/api/{server_id}/container/{vmid}/disk/resize")
async def resize_container_disk( async def resize_container_disk(
server_id: int, server_id: int,
@@ -2885,6 +3137,351 @@ async def terminal_websocket_fixed(
logger.info(f"Terminal session closed for container {vmid}") logger.info(f"Terminal session closed for container {vmid}")
# ==================== VM Serial Console WebSocket Proxy ====================
@router.websocket("/ws/serial/{server_id}/{node}/{vmid}")
async def serial_websocket(
websocket: WebSocket,
server_id: int,
node: str,
vmid: int,
token: str = Query(None), # JWT панели (?token=)
db: Session = Depends(get_db)
):
"""WebSocket serial-консоль для VM (qemu) через Proxmox termproxy.
Идентично LXC-терминалу, но использует эндпоинты qemu. Требует, чтобы у VM
был настроен serial0 (см. /vm/{vmid}/serial/enable) и гость выводил в ttyS0.
"""
import websockets
import httpx
from urllib.parse import quote
user = authenticate_ws_token(token, db)
if not user:
await websocket.close(code=4001, reason="Authentication required")
return
if not PermissionEngine.has_permission(user, "vm:console"):
await websocket.close(code=4003, reason="Permission denied")
return
if not check_vm_access(db, user, server_id, vmid):
await websocket.close(code=4003, reason="Access denied")
return
await websocket.accept()
logger.info(f"Serial WebSocket accepted for VM {vmid} on node {node} (user={user.username})")
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
await websocket.close(code=1008, reason="Proxmox server not found")
return
proxmox_ws = None
try:
async with httpx.AsyncClient(verify=False, timeout=10) as client:
if not server.password:
await websocket.close(code=1011, reason="Serial console requires password authentication")
return
auth_username = server.api_user.split("!")[0] if "!" in server.api_user else server.api_user
auth_resp = await client.post(
f"https://{server.ip_address}:8006/api2/json/access/ticket",
data={"username": auth_username, "password": server.password}
)
if auth_resp.status_code != 200:
await websocket.close(code=1011, reason="Authentication failed")
return
auth_data = auth_resp.json()["data"]
ticket = auth_data["ticket"]
csrf_token = auth_data["CSRFPreventionToken"]
term_resp = await client.post(
f"https://{server.ip_address}:8006/api2/json/nodes/{node}/qemu/{vmid}/termproxy",
headers={"CSRFPreventionToken": csrf_token},
cookies={"PVEAuthCookie": ticket}
)
if term_resp.status_code != 200:
logger.error(f"Serial termproxy failed: {term_resp.status_code} - {term_resp.text}")
await websocket.close(code=1011, reason="Failed to create serial session (is serial0 configured?)")
return
term_data = term_resp.json()["data"]
vncticket = term_data["ticket"]
port = term_data["port"]
proxmox_ws_url = (
f"wss://{server.ip_address}:8006/api2/json/nodes/{node}/qemu/{vmid}/vncwebsocket"
f"?port={port}&vncticket={quote(vncticket, safe='')}"
)
ssl_context = ssl.create_default_context()
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
proxmox_ws = await websockets.connect(
proxmox_ws_url,
ssl=ssl_context,
extra_headers=[("Cookie", f"PVEAuthCookie={ticket}")],
subprotocols=["binary"],
max_size=None,
ping_interval=None,
close_timeout=5
)
await proxmox_ws.send(f"{auth_username}:{vncticket}\n")
try:
ok_raw = await asyncio.wait_for(proxmox_ws.recv(), timeout=10.0)
except asyncio.TimeoutError:
await websocket.close(code=1011, reason="Serial auth timeout")
return
ok_bytes = ok_raw if isinstance(ok_raw, bytes) else ok_raw.encode("utf-8")
if not ok_bytes.startswith(b"OK"):
await websocket.close(code=1011, reason="Serial authentication rejected")
return
if len(ok_bytes) > 2:
await websocket.send_bytes(ok_bytes[2:])
async def client_to_proxmox():
try:
while True:
message = await websocket.receive()
if message["type"] == "websocket.disconnect":
break
if "bytes" in message:
await proxmox_ws.send(message["bytes"])
elif "text" in message:
await proxmox_ws.send(message["text"].encode("utf-8"))
except WebSocketDisconnect:
pass
except Exception as e:
logger.debug(f"serial client_to_proxmox ended: {e}")
async def proxmox_to_client():
try:
async for message in proxmox_ws:
if isinstance(message, bytes):
await websocket.send_bytes(message)
else:
await websocket.send_bytes(message.encode("utf-8"))
except Exception as e:
logger.debug(f"serial proxmox_to_client ended: {e}")
done, pending = await asyncio.wait(
[
asyncio.create_task(client_to_proxmox()),
asyncio.create_task(proxmox_to_client()),
],
return_when=asyncio.FIRST_COMPLETED
)
for task in pending:
task.cancel()
except Exception as e:
logger.error(f"Serial WebSocket error: {e}")
try:
await websocket.close(code=1011, reason=str(e))
except Exception:
pass
finally:
if proxmox_ws:
try:
await proxmox_ws.close()
except Exception:
pass
try:
await websocket.close()
except Exception:
pass
logger.info(f"Serial session closed for VM {vmid}")
# ==================== Node Shell (APT upgrade) ====================
# Разрешённые команды node-termproxy (Proxmox: upgrade | login | ceph_install)
_ALLOWED_NODE_SHELL_CMDS = {"upgrade", "login"}
@router.websocket("/ws/node-shell/{server_id}/{node}")
async def node_shell_websocket(
websocket: WebSocket,
server_id: int,
node: str,
cmd: str = Query("upgrade"), # upgrade | login
token: str = Query(None), # JWT панели (?token=) — браузеры не шлют заголовки на WS
db: Session = Depends(get_db)
):
"""
WebSocket root-терминал на уровне ноды через Proxmox termproxy API.
Используется для установки обновлений (cmd=upgrade → apt dist-upgrade),
как это делает штатная кнопка «Upgrade» в интерфейсе Proxmox.
"""
import websockets
import httpx
from urllib.parse import quote
# --- Authenticate the panel user before opening a root shell -----------
user = authenticate_ws_token(token, db)
if not user:
await websocket.close(code=4001, reason="Authentication required")
return
if not PermissionEngine.has_permission(user, "node:upgrade"):
await websocket.close(code=4003, reason="Permission denied")
return
if cmd not in _ALLOWED_NODE_SHELL_CMDS:
await websocket.close(code=4003, reason="Command not allowed")
return
await websocket.accept()
logger.info(f"Node shell WebSocket accepted for node {node} (cmd={cmd}, user={user.username})")
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
await websocket.close(code=1008, reason="Proxmox server not found")
return
proxmox_ws = None
bytes_to_proxmox = 0
bytes_from_proxmox = 0
try:
# Шаг 1: Auth ticket (termproxy требует пароль, не API-токен)
async with httpx.AsyncClient(verify=False, timeout=10) as client:
if not server.password:
await websocket.close(code=1011, reason="Node shell requires password authentication")
return
auth_username = server.api_user.split("!")[0] if "!" in server.api_user else server.api_user
auth_resp = await client.post(
f"https://{server.ip_address}:8006/api2/json/access/ticket",
data={"username": auth_username, "password": server.password}
)
if auth_resp.status_code != 200:
logger.error(f"Auth failed: {auth_resp.status_code} {auth_resp.text}")
await websocket.close(code=1011, reason="Authentication failed")
return
auth_data = auth_resp.json()["data"]
ticket = auth_data["ticket"]
csrf_token = auth_data["CSRFPreventionToken"]
# Шаг 2: Termproxy на уровне ноды с нужной командой
term_resp = await client.post(
f"https://{server.ip_address}:8006/api2/json/nodes/{node}/termproxy",
headers={"CSRFPreventionToken": csrf_token},
cookies={"PVEAuthCookie": ticket},
data={"cmd": cmd},
)
if term_resp.status_code != 200:
logger.error(f"Node termproxy failed: {term_resp.status_code} - {term_resp.text}")
await websocket.close(code=1011, reason="Failed to create node shell session")
return
term_data = term_resp.json()["data"]
vncticket = term_data["ticket"]
port = term_data["port"]
logger.info(f"✅ Node termproxy: node={node}, cmd={cmd}, port={port}")
# Шаг 3: WebSocket к Proxmox (node-level vncwebsocket)
proxmox_ws_url = (
f"wss://{server.ip_address}:8006/api2/json/nodes/{node}/vncwebsocket"
f"?port={port}&vncticket={quote(vncticket, safe='')}"
)
ssl_context = ssl.create_default_context()
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
proxmox_ws = await websockets.connect(
proxmox_ws_url,
ssl=ssl_context,
extra_headers=[("Cookie", f"PVEAuthCookie={ticket}")],
subprotocols=["binary"],
max_size=None,
ping_interval=None,
close_timeout=5
)
# Auth handshake: "USERNAME:VNCTICKET\n" → "OK"
await proxmox_ws.send(f"{auth_username}:{vncticket}\n")
try:
ok_raw = await asyncio.wait_for(proxmox_ws.recv(), timeout=10.0)
except asyncio.TimeoutError:
logger.error("Node shell auth timeout — no OK received")
await websocket.close(code=1011, reason="Node shell auth timeout")
return
ok_bytes = ok_raw if isinstance(ok_raw, bytes) else ok_raw.encode("utf-8")
if not ok_bytes.startswith(b"OK"):
logger.error(f"Node shell auth rejected: {ok_bytes[:50]!r}")
await websocket.close(code=1011, reason="Node shell authentication rejected")
return
if len(ok_bytes) > 2:
await websocket.send_bytes(ok_bytes[2:])
async def client_to_proxmox():
nonlocal bytes_to_proxmox
try:
while True:
message = await websocket.receive()
if message["type"] == "websocket.disconnect":
break
if "bytes" in message:
bytes_to_proxmox += len(message["bytes"])
await proxmox_ws.send(message["bytes"])
elif "text" in message:
bytes_to_proxmox += len(message["text"])
await proxmox_ws.send(message["text"].encode("utf-8"))
except WebSocketDisconnect:
pass
except Exception as e:
logger.debug(f"client_to_proxmox ended: {e}")
async def proxmox_to_client():
nonlocal bytes_from_proxmox
try:
async for message in proxmox_ws:
if isinstance(message, bytes):
bytes_from_proxmox += len(message)
await websocket.send_bytes(message)
else:
bytes_from_proxmox += len(message)
await websocket.send_bytes(message.encode("utf-8"))
except Exception as e:
logger.debug(f"proxmox_to_client ended: {e}")
done, pending = await asyncio.wait(
[
asyncio.create_task(client_to_proxmox()),
asyncio.create_task(proxmox_to_client()),
],
return_when=asyncio.FIRST_COMPLETED
)
for task in pending:
task.cancel()
except Exception as e:
logger.error(f"Node shell WebSocket error: {e}")
import traceback
logger.error(traceback.format_exc())
try:
await websocket.close(code=1011, reason=str(e))
except Exception:
pass
finally:
logger.info(f"Node shell stats — to Proxmox: {bytes_to_proxmox}B, from Proxmox: {bytes_from_proxmox}B")
if proxmox_ws:
try:
await proxmox_ws.close()
except Exception:
pass
try:
await websocket.close()
except Exception:
pass
logger.info(f"Node shell session closed for node {node}")
# ==================== Bulk Operations API ==================== # ==================== Bulk Operations API ====================

View File

@@ -929,6 +929,57 @@ class ProxmoxClient(VmMixin, LxcMixin, ClusterMixin, StorageMixin, NetworkMixin,
logger.warning(f"Таймаут ожидания разблокировки VM {vmid}") logger.warning(f"Таймаут ожидания разблокировки VM {vmid}")
return False return False
def update_cloud_init(
self, node: str, vmid: int,
ciuser=None, cipassword=None, sshkeys=None,
ipconfig0=None, nameserver=None, searchdomain=None,
) -> Dict:
"""Точечно обновить cloud-init параметры существующей VM.
Значение None — поле не трогаем; пустая строка "" — удалить параметр.
Пустой cipassword трактуется как «оставить текущий» (blank = keep).
sshkeys перед отправкой URL-кодируется (требование PVE).
"""
if not self.proxmox:
return {"success": False, "error": "Not connected"}
import urllib.parse
params: Dict = {}
deletes = []
def _set(key, value, allow_clear=True):
if value is None:
return
if value == '' and allow_clear:
deletes.append(key)
elif value != '':
params[key] = value
_set('ciuser', ciuser)
if cipassword: # пустой пароль не меняем
params['cipassword'] = cipassword
if sshkeys is not None:
if sshkeys.strip() == '':
deletes.append('sshkeys')
else:
params['sshkeys'] = urllib.parse.quote(sshkeys, safe='')
_set('ipconfig0', ipconfig0)
_set('nameserver', nameserver)
_set('searchdomain', searchdomain)
if deletes:
params['delete'] = ','.join(deletes)
if not params:
return {"success": True}
try:
self.proxmox.nodes(node).qemu(vmid).config.put(**params)
logger.info(f"Cloud-init VM {vmid} обновлён: {list(params.keys())}")
return {"success": True}
except Exception as e:
logger.error(f"Ошибка обновления cloud-init VM {vmid}: {e}")
return {"success": False, "error": str(e)}
def configure_vm( def configure_vm(
self, self,
node: str, node: str,
@@ -1299,9 +1350,9 @@ class ProxmoxClient(VmMixin, LxcMixin, ClusterMixin, StorageMixin, NetworkMixin,
# Фильтруем разрешенные параметры # Фильтруем разрешенные параметры
allowed_params = { allowed_params = {
'cores', 'sockets', 'memory', 'balloon', 'name', 'description', 'cores', 'sockets', 'vcpus', 'numa', 'memory', 'balloon', 'name', 'description',
'cpu', 'cpulimit', 'cpuunits', 'onboot', 'startup', 'boot', 'bootdisk', 'cpu', 'cpulimit', 'cpuunits', 'affinity', 'onboot', 'startup', 'boot', 'bootdisk',
'agent', 'ostype', 'tablet', 'hotplug', 'protection', 'agent', 'ostype', 'tablet', 'hotplug', 'protection', 'kvm',
'ciuser', 'cipassword', 'sshkeys', 'tags', 'ciuser', 'cipassword', 'sshkeys', 'tags',
# Hardware editor: CPU/NUMA, дисплей, контроллер, тип платформы, # Hardware editor: CPU/NUMA, дисплей, контроллер, тип платформы,
# efidisk0 — EFI-диск, обязателен для загрузки в режиме OVMF (UEFI). # efidisk0 — EFI-диск, обязателен для загрузки в режиме OVMF (UEFI).

View File

@@ -5,6 +5,22 @@ import shlex
from loguru import logger from loguru import logger
class LxcMixin: class LxcMixin:
def unlock_container(self, node: str, vmid: int) -> Dict:
"""Снять блокировку (lock) с LXC — аналог `pct unlock`.
config.put(delete='lock', skiplock=1); skiplock принимается только
для root@pam.
"""
if not self.proxmox:
return {"success": False, "error": "Not connected"}
try:
self.proxmox.nodes(node).lxc(vmid).config.put(delete="lock", skiplock=1)
logger.info(f"С LXC {vmid} снята блокировка")
return {"success": True}
except Exception as e:
logger.error(f"Ошибка снятия блокировки LXC {vmid}: {e}")
return {"success": False, "error": str(e)}
def start_container(self, node: str, vmid: int) -> Optional[str]: def start_container(self, node: str, vmid: int) -> Optional[str]:
"""Запустить LXC контейнер. Возвращает UPID задачи или None при ошибке.""" """Запустить LXC контейнер. Возвращает UPID задачи или None при ошибке."""
if not self.proxmox: if not self.proxmox:

View File

@@ -273,6 +273,117 @@ class VmMixin:
logger.info(f"Размер диска {disk} VM {vmid} изменен на {size}") logger.info(f"Размер диска {disk} VM {vmid} изменен на {size}")
return True return True
def move_vm_disk(self, node: str, vmid: int, disk: str, target_storage: str,
delete: bool = True, target_format: str = None) -> Dict:
"""Переместить диск VM в другое хранилище (POST qemu/{vmid}/move_disk).
Args:
disk: имя диска (scsi0, virtio0, ...)
target_storage: целевое хранилище
delete: удалить исходный диск после копирования
target_format: raw|qcow2|vmdk (опционально)
Returns:
{"success": bool, "upid"?: str, "error"?: str}
"""
if not self.proxmox:
return {"success": False, "error": "Not connected"}
try:
params = {"disk": disk, "storage": target_storage, "delete": 1 if delete else 0}
if target_format:
params["format"] = target_format
upid = self.proxmox.nodes(node).qemu(vmid).move_disk.post(**params)
if isinstance(upid, str):
if not self.wait_for_task(node, upid, timeout=3600):
status = self.get_task_status(node, upid) or {}
reason = status.get("exitstatus") or "move_disk task failed"
logger.error(f"Move диска {disk} VM {vmid} провалился: {reason}")
return {"success": False, "error": f"Proxmox: {reason}"}
logger.info(f"Диск {disk} VM {vmid} перемещён в {target_storage}")
return {"success": True, "upid": upid if isinstance(upid, str) else None}
except Exception as e:
logger.error(f"Ошибка перемещения диска {disk} VM {vmid}: {e}")
return {"success": False, "error": str(e)}
def add_vm_disk(self, node: str, vmid: int, disk: str, storage: str, size_gb: int,
ssd: bool = False, discard: bool = False, iothread: bool = False) -> Dict:
"""Добавить новый диск VM (config.put c аллокацией storage:size).
Args:
disk: имя устройства (scsi1, virtio1, ...) — должно быть свободно
storage: хранилище для нового диска
size_gb: размер в ГБ
ssd/discard/iothread: опциональные флаги
"""
if not self.proxmox:
return {"success": False, "error": "Not connected"}
try:
spec = f"{storage}:{int(size_gb)}"
if ssd:
spec += ",ssd=1"
if discard:
spec += ",discard=on"
if iothread:
spec += ",iothread=1"
self.proxmox.nodes(node).qemu(vmid).config.post(**{disk: spec})
logger.info(f"Добавлен диск {disk} ({spec}) к VM {vmid}")
return {"success": True}
except Exception as e:
logger.error(f"Ошибка добавления диска {disk} к VM {vmid}: {e}")
return {"success": False, "error": str(e)}
def detach_vm_disk(self, node: str, vmid: int, disk: str, destroy: bool = False) -> Dict:
"""Отключить диск VM. destroy=True — также физически удалить том.
PVE переводит отключённый диск в unusedN (том сохраняется). Чтобы удалить
данные, вторым шагом удаляем соответствующую unused-запись (её значение —
volid исходного диска). Без destroy диск просто становится unused.
"""
if not self.proxmox:
return {"success": False, "error": "Not connected"}
try:
# volid исходного диска — по нему найдём unused-запись после отключения
old_volid = None
if destroy:
cfg = self.get_vm_config(node, vmid) or {}
spec = cfg.get(disk)
if isinstance(spec, str):
old_volid = spec.split(",", 1)[0]
self.proxmox.nodes(node).qemu(vmid).config.put(delete=disk)
if destroy and old_volid:
cfg2 = self.get_vm_config(node, vmid) or {}
unused_key = next(
(k for k, v in cfg2.items()
if k.startswith("unused") and isinstance(v, str) and old_volid in v),
None,
)
if unused_key:
self.proxmox.nodes(node).qemu(vmid).config.put(delete=unused_key)
logger.info(f"Том {old_volid} диска {disk} VM {vmid} удалён ({unused_key})")
logger.info(f"Диск {disk} VM {vmid} отключён (destroy={destroy})")
return {"success": True}
except Exception as e:
logger.error(f"Ошибка отключения диска {disk} VM {vmid}: {e}")
return {"success": False, "error": str(e)}
def unlock_vm(self, node: str, vmid: int) -> Dict:
"""Снять блокировку (lock) с VM — аналог `qm unlock`.
Реализуется через config.put(delete='lock', skiplock=1). skiplock
принимается Proxmox только для root@pam (в т.ч. API-токенов root@pam).
"""
if not self.proxmox:
return {"success": False, "error": "Not connected"}
try:
self.proxmox.nodes(node).qemu(vmid).config.put(delete="lock", skiplock=1)
logger.info(f"С VM {vmid} снята блокировка")
return {"success": True}
except Exception as e:
logger.error(f"Ошибка снятия блокировки VM {vmid}: {e}")
return {"success": False, "error": str(e)}
def grow_vm_filesystem(self, node: str, vmid: int, mountpoint: str = "/") -> Dict: def grow_vm_filesystem(self, node: str, vmid: int, mountpoint: str = "/") -> Dict:
""" """
Расширить раздел и файловую систему внутри гостя после ресайза диска. Расширить раздел и файловую систему внутри гостя после ресайза диска.

View File

@@ -26,6 +26,7 @@ NEW_DEFAULT_ROLES = [
"server:update": True, "server:update": True,
"server:delete": True, "server:delete": True,
"server:manage": True, "server:manage": True,
"cluster:manage": True,
# VMs # VMs
"vm:view": True, "vm:view": True,
"vm:create": True, "vm:create": True,
@@ -57,9 +58,26 @@ NEW_DEFAULT_ROLES = [
# Storage # Storage
"storage:view": True, "storage:view": True,
"storage:manage": True, "storage:manage": True,
# Firewall
"firewall:view": True,
"firewall:manage": True,
# Networking (interfaces + SDN)
"network:view": True,
"network:manage": True,
# Node administration (services, APT, upgrade)
"node:view": True,
"node:manage": True,
"node:upgrade": True,
# Resource pools
"pool:view": True,
"pool:manage": True,
# PVE access (realms & API tokens)
"access:view": True,
"access:manage": True,
# Backups # Backups
"backup:view": True, "backup:view": True,
"backup:create": True, "backup:create": True,
"backup:restore": True,
"backup:delete": True, "backup:delete": True,
"backup:manage": True, "backup:manage": True,
# IPAM # IPAM
@@ -115,6 +133,10 @@ NEW_DEFAULT_ROLES = [
"lxc:console": True, "lxc:console": True,
"template:view": True, "template:view": True,
"storage:view": True, "storage:view": True,
"firewall:view": True,
"network:view": True,
"node:view": True,
"pool:view": True,
"backup:view": True, "backup:view": True,
"backup:create": True, "backup:create": True,
"ipam:view": True, "ipam:view": True,
@@ -146,6 +168,10 @@ NEW_DEFAULT_ROLES = [
"lxc:console": True, "lxc:console": True,
"template:view": True, "template:view": True,
"storage:view": True, "storage:view": True,
"firewall:view": True,
"network:view": True,
"node:view": True,
"pool:view": True,
"ipam:view": True, "ipam:view": True,
"setting:view": True, "setting:view": True,
"notification:view": True, "notification:view": True,
@@ -164,6 +190,10 @@ NEW_DEFAULT_ROLES = [
"lxc:view": True, "lxc:view": True,
"template:view": True, "template:view": True,
"storage:view": True, "storage:view": True,
"firewall:view": True,
"network:view": True,
"node:view": True,
"pool:view": True,
"ipam:view": True, "ipam:view": True,
} }
}, },
@@ -446,3 +476,67 @@ def ensure_default_roles_new_format(conn) -> None:
} }
) )
logger.info(f"Created role '{role_data['name']}' with new permission format") logger.info(f"Created role '{role_data['name']}' with new permission format")
# Granular permissions introduced after RBAC v2. For existing deployments the
# corresponding features used to be gated behind umbrella permissions
# (server:view / server:manage / vm:view / user:manage). Backfill the new
# granular keys for any role that already holds the umbrella permission so that
# custom roles do not silently lose access after the split.
GRANULAR_BACKFILL = [
# (umbrella permission, [granular permissions implied by it])
("server:view", ["firewall:view", "network:view", "node:view", "storage:view"]),
("vm:view", ["pool:view", "firewall:view"]),
("server:manage", [
"firewall:view", "firewall:manage",
"network:view", "network:manage",
"node:view", "node:manage", "node:upgrade",
"storage:view", "storage:manage",
"pool:view", "pool:manage",
]),
("user:manage", ["access:view", "access:manage"]),
]
def backfill_granular_permissions(conn) -> int:
"""
Add granular permission keys to roles that already have the matching
umbrella permission. Only adds missing keys — never overrides an explicit
grant/deny — so it is idempotent and safe to run on every startup.
Returns the number of roles updated.
"""
import json
result = conn.execute(text("SELECT id, name, permissions FROM roles"))
roles = result.fetchall()
updated = 0
for role_id, role_name, permissions in roles:
if isinstance(permissions, str):
try:
permissions = json.loads(permissions)
except Exception:
continue
if not isinstance(permissions, dict) or not permissions:
continue
additions = {}
for umbrella, implied in GRANULAR_BACKFILL:
if permissions.get(umbrella) is True:
for perm in implied:
if perm not in permissions:
additions[perm] = True
if additions:
merged = {**permissions, **additions}
conn.execute(
text("UPDATE roles SET permissions = :perms WHERE id = :id"),
{"perms": json.dumps(merged), "id": role_id},
)
logger.info(
f"Backfilled {len(additions)} granular permission(s) for role '{role_name}'"
)
updated += 1
return updated

View File

@@ -639,6 +639,88 @@ def _create_permissions() -> PermissionRegistry:
requires=["notification:view"] requires=["notification:view"]
)) ))
# Firewall permissions (datacenter / node / VM firewall)
registry.register(Permission(
resource="firewall", action="view",
display_name="View Firewall",
description="View firewall rules, groups and options",
category="Firewall"
))
registry.register(Permission(
resource="firewall", action="manage",
display_name="Manage Firewall",
description="Manage firewall rules, security groups, IP sets and options",
category="Firewall",
requires=["firewall:view"]
))
# Networking permissions (node interfaces + SDN)
registry.register(Permission(
resource="network", action="view",
display_name="View Networking",
description="View node network interfaces and SDN configuration",
category="Networking"
))
registry.register(Permission(
resource="network", action="manage",
display_name="Manage Networking",
description="Manage node network interfaces, bridges and SDN (zones, vnets, subnets)",
category="Networking",
requires=["network:view"]
))
# Node administration permissions (services, APT, upgrade)
registry.register(Permission(
resource="node", action="view",
display_name="View Node Administration",
description="View node services, APT updates and repositories",
category="Node Administration"
))
registry.register(Permission(
resource="node", action="manage",
display_name="Manage Node Administration",
description="Control systemd services and manage APT repositories/refresh",
category="Node Administration",
requires=["node:view"]
))
registry.register(Permission(
resource="node", action="upgrade",
display_name="Upgrade Node Packages",
description="Run apt dist-upgrade via the node console (root shell)",
category="Node Administration",
requires=["node:view"]
))
# Resource pool permissions
registry.register(Permission(
resource="pool", action="view",
display_name="View Pools",
description="View resource pools and their members",
category="Resource Pools"
))
registry.register(Permission(
resource="pool", action="manage",
display_name="Manage Pools",
description="Create/delete pools and manage pool membership",
category="Resource Pools",
requires=["pool:view"]
))
# PVE access permissions (authentication realms & API tokens)
registry.register(Permission(
resource="access", action="view",
display_name="View PVE Access",
description="View Proxmox authentication realms and API tokens",
category="PVE Access"
))
registry.register(Permission(
resource="access", action="manage",
display_name="Manage PVE Access",
description="Manage Proxmox authentication realms and API tokens",
category="PVE Access",
requires=["access:view"]
))
return registry return registry

View File

@@ -1430,6 +1430,44 @@ def migrate_user_quotas(conn):
logger.info("✓ user_quotas table created") logger.info("✓ user_quotas table created")
def migrate_granular_permissions(conn):
"""
Migration 29: Backfill granular RBAC permissions.
Splits umbrella permissions (server:view/manage, vm:view, user:manage) into
granular ones (firewall:*, network:*, node:*, storage:*, pool:*, access:*)
for existing roles, and ensures the default roles include the new keys.
"""
logger.info("Migration 29: Backfilling granular RBAC permissions...")
if not table_exists(conn, 'roles'):
logger.info("Table roles does not exist, skipping granular permissions migration")
return
try:
from app.rbac.migration import (
backfill_granular_permissions,
ensure_default_roles_new_format,
)
except ImportError:
try:
from backend.app.rbac.migration import (
backfill_granular_permissions,
ensure_default_roles_new_format,
)
except ImportError:
logger.warning("Could not import RBAC utilities, skipping granular permissions migration")
return
updated = backfill_granular_permissions(conn)
logger.info(f"Backfilled granular permissions for {updated} role(s)")
# Ensure default roles pick up the new granular keys as well
ensure_default_roles_new_format(conn)
logger.info("✓ Granular permissions migration completed")
def run_all_migrations(engine, db_session=None): def run_all_migrations(engine, db_session=None):
""" """
Run all migrations in order. Run all migrations in order.
@@ -1668,6 +1706,14 @@ def run_all_migrations(engine, db_session=None):
logger.warning(f"User quotas migration: {e}") logger.warning(f"User quotas migration: {e}")
conn.rollback() conn.rollback()
# Migration 29: Backfill granular RBAC permissions
try:
migrate_granular_permissions(conn)
conn.commit()
except Exception as e:
logger.warning(f"Granular permissions migration: {e}")
conn.rollback()
logger.info("=" * 50) logger.info("=" * 50)
logger.info("✓ All migrations completed successfully") logger.info("✓ All migrations completed successfully")
logger.info("=" * 50) logger.info("=" * 50)

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

@@ -1,47 +0,0 @@
/**
* Декоративный геометрический паттерн для левой панели страницы входа.
* Тонкие полупрозрачные линии (круги, квадраты, треугольники, ломаные),
* бесшовно повторяющиеся через SVG <pattern>. Цвет линий — currentColor.
*/
export default function AuthPattern({ className }: { className?: string }) {
return (
<svg
className={className}
width="100%"
height="100%"
preserveAspectRatio="xMidYMid slice"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
>
<defs>
<pattern
id="auth-geo"
width="200"
height="200"
patternUnits="userSpaceOnUse"
>
<g
fill="none"
stroke="currentColor"
strokeWidth="1"
strokeOpacity="0.5"
>
{/* сетка-рамки */}
<rect x="0.5" y="0.5" width="199" height="199" />
{/* круг */}
<circle cx="100" cy="60" r="46" />
{/* квадрат со срезом-диагональю */}
<rect x="20" y="120" width="56" height="56" />
<line x1="20" y1="176" x2="76" y2="120" />
{/* треугольники из диагоналей */}
<path d="M120 120 L176 120 L176 176 Z" />
<path d="M120 176 L120 120 L176 176 Z" strokeOpacity="0.25" />
{/* ломаная линия */}
<path d="M0 40 L40 40 L40 80 L0 80" strokeOpacity="0.35" />
</g>
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#auth-geo)" />
</svg>
);
}

View File

@@ -29,6 +29,7 @@ export default function ConsolePage() {
const { t } = useTranslation(); const { t } = useTranslation();
const node = searchParams.get('node') || ''; const node = searchParams.get('node') || '';
const type = searchParams.get('type') || 'qemu'; const type = searchParams.get('type') || 'qemu';
const isSerial = type === 'qemu' && searchParams.get('mode') === 'serial';
const [status, setStatus] = useState<'connecting' | 'connected' | 'error'>('connecting'); const [status, setStatus] = useState<'connecting' | 'connected' | 'error'>('connecting');
const [errorMsg, setErrorMsg] = useState(''); const [errorMsg, setErrorMsg] = useState('');
@@ -114,118 +115,107 @@ export default function ConsolePage() {
} }
}, [sid, vid, node]); }, [sid, vid, node]);
// ==================== xterm.js session (LXC terminal / VM serial) ====================
// Общая обвязка xterm + WebSocket. wsPath — путь до нужного ws-эндпоинта Proxmox-прокси.
const setupXtermSession = useCallback(async (wsPath: string) => {
// Dynamic import xterm.js
const [{ Terminal }, { FitAddon }, { WebLinksAddon }] = await Promise.all([
import('@xterm/xterm'),
import('@xterm/addon-fit'),
import('@xterm/addon-web-links'),
]);
if (!containerRef.current) return;
const fitAddon = new FitAddon();
const webLinksAddon = new WebLinksAddon();
const terminal = new Terminal({
cursorBlink: true,
fontSize: 14,
fontFamily: 'JetBrains Mono, monospace',
theme: {
background: '#09090B',
foreground: '#F0F0F3',
cursor: '#F0F0F3',
selectionBackground: 'rgba(255,255,255,0.2)',
},
});
terminal.loadAddon(fitAddon);
terminal.loadAddon(webLinksAddon);
terminal.open(containerRef.current);
requestAnimationFrame(() => fitAddon.fit());
const wsProto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const authToken = apiClient.getToken();
const tokenParam = authToken ? `?token=${encodeURIComponent(authToken)}` : '';
const wsUrl = `${wsProto}//${window.location.host}${wsPath}${tokenParam}`;
const ws = new WebSocket(wsUrl);
ws.binaryType = 'arraybuffer';
wsRef.current = ws;
ws.onopen = () => {
setStatus('connected');
const { cols, rows } = terminal;
ws.send(`1:${cols}:${rows}:`);
keepaliveRef.current = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) ws.send('2');
}, 120000);
};
ws.onmessage = (event) => {
if (event.data instanceof ArrayBuffer) terminal.write(new Uint8Array(event.data));
else terminal.write(event.data);
};
ws.onclose = () => { setStatus('error'); setErrorMsg('Terminal connection closed'); };
ws.onerror = () => { setStatus('error'); setErrorMsg('Terminal connection error'); };
terminal.onData((data: string) => {
if (ws.readyState === WebSocket.OPEN) {
const byteLength = new TextEncoder().encode(data).byteLength;
ws.send(`0:${byteLength}:${data}`);
}
});
terminal.onResize(({ cols, rows }) => {
if (ws.readyState === WebSocket.OPEN) ws.send(`1:${cols}:${rows}:`);
});
const handleResize = () => fitAddon.fit();
window.addEventListener('resize', handleResize);
termRef.current = { terminal, fitAddon, cleanup: () => window.removeEventListener('resize', handleResize) };
}, []);
// ==================== Terminal Console (LXC) ==================== // ==================== Terminal Console (LXC) ====================
const connectTerminal = useCallback(async () => { const connectTerminal = useCallback(async () => {
try { try {
const data = await apiClient.get<VNCData>( const data = await apiClient.get<VNCData>(
`/proxmox/api/${sid}/container/${vid}/terminal?node=${node}` `/proxmox/api/${sid}/container/${vid}/terminal?node=${node}`
); );
await setupXtermSession(`/proxmox/ws/terminal/${sid}/${data.node}/${vid}`);
// Dynamic import xterm.js
const [{ Terminal }, { FitAddon }, { WebLinksAddon }] = await Promise.all([
import('@xterm/xterm'),
import('@xterm/addon-fit'),
import('@xterm/addon-web-links'),
]);
if (!containerRef.current) return;
const fitAddon = new FitAddon();
const webLinksAddon = new WebLinksAddon();
const terminal = new Terminal({
cursorBlink: true,
fontSize: 14,
fontFamily: 'JetBrains Mono, monospace',
theme: {
background: '#09090B',
foreground: '#F0F0F3',
cursor: '#F0F0F3',
selectionBackground: 'rgba(255,255,255,0.2)',
},
});
terminal.loadAddon(fitAddon);
terminal.loadAddon(webLinksAddon);
terminal.open(containerRef.current);
// Wait a frame, then fit
requestAnimationFrame(() => {
fitAddon.fit();
});
// WebSocket connection
const wsProto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const authToken = apiClient.getToken();
const tokenParam = authToken ? `?token=${encodeURIComponent(authToken)}` : '';
const wsUrl = `${wsProto}//${window.location.host}/proxmox/ws/terminal/${sid}/${data.node}/${vid}${tokenParam}`;
const ws = new WebSocket(wsUrl);
ws.binaryType = 'arraybuffer';
wsRef.current = ws;
ws.onopen = () => {
setStatus('connected');
// Send resize
const { cols, rows } = terminal;
ws.send(`1:${cols}:${rows}:`);
// Keepalive every 120s
keepaliveRef.current = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send('2');
}
}, 120000);
};
ws.onmessage = (event) => {
if (event.data instanceof ArrayBuffer) {
terminal.write(new Uint8Array(event.data));
} else {
terminal.write(event.data);
}
};
ws.onclose = () => {
setStatus('error');
setErrorMsg('Terminal connection closed');
};
ws.onerror = () => {
setStatus('error');
setErrorMsg('Terminal connection error');
};
// User input → WebSocket
terminal.onData((data: string) => {
if (ws.readyState === WebSocket.OPEN) {
const encoder = new TextEncoder();
const byteLength = encoder.encode(data).byteLength;
ws.send(`0:${byteLength}:${data}`);
}
});
// Resize handler
terminal.onResize(({ cols, rows }) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(`1:${cols}:${rows}:`);
}
});
// Window resize
const handleResize = () => fitAddon.fit();
window.addEventListener('resize', handleResize);
termRef.current = { terminal, fitAddon, cleanup: () => window.removeEventListener('resize', handleResize) };
} catch (err) { } catch (err) {
setStatus('error'); setStatus('error');
setErrorMsg(err instanceof Error ? err.message : 'Failed to connect'); setErrorMsg(err instanceof Error ? err.message : 'Failed to connect');
} }
}, [sid, vid, node]); }, [sid, vid, node, setupXtermSession]);
// ==================== Serial Console (QEMU) ====================
const connectSerial = useCallback(async () => {
try {
// Убедиться, что у VM есть serial0 (добавит при отсутствии)
await apiClient.post(`/proxmox/api/${sid}/vm/${vid}/serial/enable?node=${node}`);
await setupXtermSession(`/proxmox/ws/serial/${sid}/${node}/${vid}`);
} catch (err) {
setStatus('error');
setErrorMsg(err instanceof Error ? err.message : 'Failed to connect');
}
}, [sid, vid, node, setupXtermSession]);
// ==================== Connect on mount ==================== // ==================== Connect on mount ====================
useEffect(() => { useEffect(() => {
if (type === 'qemu') { if (isSerial) {
connectSerial();
} else if (type === 'qemu') {
connectVNC(); connectVNC();
} else { } else {
connectTerminal(); connectTerminal();
@@ -248,7 +238,7 @@ export default function ConsolePage() {
t.terminal.dispose(); t.terminal.dispose();
} }
}; };
}, [type, connectVNC, connectTerminal]); }, [type, isSerial, connectVNC, connectTerminal, connectSerial]);
// ==================== Fullscreen ==================== // ==================== Fullscreen ====================
const toggleFullscreen = () => { const toggleFullscreen = () => {
@@ -291,7 +281,7 @@ export default function ConsolePage() {
</Badge> </Badge>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{type === 'qemu' && ( {type === 'qemu' && !isSerial && (
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@@ -302,6 +292,9 @@ export default function ConsolePage() {
Ctrl+Alt+Del Ctrl+Alt+Del
</Button> </Button>
)} )}
{isSerial && (
<Badge variant="secondary" className="text-[10px] bg-blue-500/10 text-blue-500">serial</Badge>
)}
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -334,7 +327,8 @@ export default function ConsolePage() {
onClick={() => { onClick={() => {
setStatus('connecting'); setStatus('connecting');
setErrorMsg(''); setErrorMsg('');
if (type === 'qemu') connectVNC(); if (isSerial) connectSerial();
else if (type === 'qemu') connectVNC();
else connectTerminal(); else connectTerminal();
}} }}
> >

View File

@@ -0,0 +1,201 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams, useSearchParams } from 'react-router';
import { Maximize, Minimize, Loader2, PackageCheck, TerminalSquare } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { apiClient } from '@/lib/api-client';
import '@xterm/xterm/css/xterm.css';
/**
* Root-терминал на уровне ноды: установка обновлений (cmd=upgrade → apt dist-upgrade)
* или обычный shell (cmd=login). Работает через node-termproxy Proxmox,
* аналогично штатной кнопке «Upgrade» в интерфейсе Proxmox.
*/
export default function NodeShellPage() {
const { serverId, node } = useParams<{ serverId: string; node: string }>();
const [searchParams] = useSearchParams();
const { t } = useTranslation();
const cmd = searchParams.get('cmd') === 'login' ? 'login' : 'upgrade';
const [status, setStatus] = useState<'connecting' | 'connected' | 'error'>('connecting');
const [errorMsg, setErrorMsg] = useState('');
const [isFullscreen, setIsFullscreen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const wsRef = useRef<WebSocket | null>(null);
const keepaliveRef = useRef<ReturnType<typeof setInterval> | null>(null);
const termRef = useRef<{ terminal: { dispose: () => void }; cleanup: () => void } | null>(null);
const sid = Number(serverId);
const connect = useCallback(async () => {
try {
const [{ Terminal }, { FitAddon }, { WebLinksAddon }] = await Promise.all([
import('@xterm/xterm'),
import('@xterm/addon-fit'),
import('@xterm/addon-web-links'),
]);
if (!containerRef.current) return;
const fitAddon = new FitAddon();
const webLinksAddon = new WebLinksAddon();
const terminal = new Terminal({
cursorBlink: true,
fontSize: 14,
fontFamily: 'JetBrains Mono, monospace',
theme: {
background: '#09090B',
foreground: '#F0F0F3',
cursor: '#F0F0F3',
selectionBackground: 'rgba(255,255,255,0.2)',
},
});
terminal.loadAddon(fitAddon);
terminal.loadAddon(webLinksAddon);
terminal.open(containerRef.current);
requestAnimationFrame(() => fitAddon.fit());
const wsProto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const authToken = apiClient.getToken();
const params = new URLSearchParams({ cmd });
if (authToken) params.set('token', authToken);
const wsUrl = `${wsProto}//${window.location.host}/proxmox/ws/node-shell/${sid}/${node}?${params}`;
const ws = new WebSocket(wsUrl);
ws.binaryType = 'arraybuffer';
wsRef.current = ws;
ws.onopen = () => {
setStatus('connected');
const { cols, rows } = terminal;
ws.send(`1:${cols}:${rows}:`);
keepaliveRef.current = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) ws.send('2');
}, 120000);
};
ws.onmessage = (event) => {
if (event.data instanceof ArrayBuffer) {
terminal.write(new Uint8Array(event.data));
} else {
terminal.write(event.data);
}
};
ws.onclose = () => {
setStatus('error');
setErrorMsg(t('console.terminal_closed', 'Соединение с терминалом закрыто'));
};
ws.onerror = () => {
setStatus('error');
setErrorMsg(t('console.terminal_error', 'Ошибка соединения с терминалом'));
};
terminal.onData((data: string) => {
if (ws.readyState === WebSocket.OPEN) {
const byteLength = new TextEncoder().encode(data).byteLength;
ws.send(`0:${byteLength}:${data}`);
}
});
terminal.onResize(({ cols, rows }) => {
if (ws.readyState === WebSocket.OPEN) ws.send(`1:${cols}:${rows}:`);
});
const handleResize = () => fitAddon.fit();
window.addEventListener('resize', handleResize);
termRef.current = { terminal, cleanup: () => window.removeEventListener('resize', handleResize) };
} catch (err) {
setStatus('error');
setErrorMsg(err instanceof Error ? err.message : 'Failed to connect');
}
}, [sid, node, cmd, t]);
useEffect(() => {
connect();
return () => {
if (wsRef.current) wsRef.current.close();
if (keepaliveRef.current) clearInterval(keepaliveRef.current);
if (termRef.current) {
termRef.current.cleanup();
termRef.current.terminal.dispose();
}
};
}, [connect]);
const toggleFullscreen = () => {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen();
setIsFullscreen(true);
} else {
document.exitFullscreen();
setIsFullscreen(false);
}
};
return (
<div className="flex h-screen flex-col bg-[#09090B]">
<div className="flex items-center justify-between border-b border-border/30 bg-[#111113] px-3 py-1.5">
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-xs border-border/50">
{cmd === 'upgrade' ? (
<><PackageCheck className="mr-1 h-3 w-3" />{t('nodeadm.upgrade', 'Upgrade')}</>
) : (
<><TerminalSquare className="mr-1 h-3 w-3" />Shell</>
)}
</Badge>
<span className="text-xs text-muted-foreground">Node: {node}</span>
<Badge
variant="secondary"
className={`text-[10px] ${
status === 'connected'
? 'bg-green-500/10 text-green-500'
: status === 'connecting'
? 'bg-amber-500/10 text-amber-500'
: 'bg-red-500/10 text-red-500'
}`}
>
{status}
</Badge>
</div>
<Button variant="ghost" size="icon" className="h-7 w-7 text-muted-foreground" onClick={toggleFullscreen}>
{isFullscreen ? <Minimize className="h-3.5 w-3.5" /> : <Maximize className="h-3.5 w-3.5" />}
</Button>
</div>
<div className="relative flex-1">
{status === 'connecting' && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-[#09090B]">
<div className="flex flex-col items-center gap-3 text-muted-foreground">
<Loader2 className="h-8 w-8 animate-spin" />
<p className="text-sm">{t('console.connecting', 'Подключение…')}</p>
</div>
</div>
)}
{status === 'error' && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-[#09090B]">
<div className="flex flex-col items-center gap-3 text-center">
<p className="text-sm text-red-500">{errorMsg}</p>
<Button
variant="outline"
size="sm"
onClick={() => {
setStatus('connecting');
setErrorMsg('');
connect();
}}
>
{t('console.reconnect', 'Переподключиться')}
</Button>
</div>
</div>
)}
<div ref={containerRef} className="h-full w-full" style={{ minHeight: 'calc(100vh - 40px)' }} />
</div>
</div>
);
}

View File

@@ -210,6 +210,11 @@ export default function InstanceDetailPage() {
<DropdownMenuItem onClick={() => setDialog('migrate')}> <DropdownMenuItem onClick={() => setDialog('migrate')}>
<ArrowRightLeft className="mr-2 h-4 w-4" /> {t('instances.migrate', 'Миграция')} <ArrowRightLeft className="mr-2 h-4 w-4" /> {t('instances.migrate', 'Миграция')}
</DropdownMenuItem> </DropdownMenuItem>
{isQemu && (
<DropdownMenuItem render={<Link to={`/console/${serverId}/${vmid}?node=${node}&type=${type}&mode=serial`} target="_blank" />}>
<Terminal className="mr-2 h-4 w-4" /> {t('instances.serial_console', 'Serial-консоль')}
</DropdownMenuItem>
)}
{isQemu && ( {isQemu && (
<DropdownMenuItem onClick={() => setDialog('iso')}> <DropdownMenuItem onClick={() => setDialog('iso')}>
<Disc className="mr-2 h-4 w-4" /> {t('common.iso', 'ISO образ')} <Disc className="mr-2 h-4 w-4" /> {t('common.iso', 'ISO образ')}

View File

@@ -1,12 +1,14 @@
import { useState, useEffect, useMemo } from 'react'; import { useState, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Settings, Loader2, Power } from 'lucide-react'; import { Settings, Loader2, Power, Cpu, HardDrive, Trash2, SlidersHorizontal, LockOpen, ListOrdered, ArrowUp, ArrowDown, Cloud, Eye, EyeOff } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { useVMConfig, useUpdateConfig, useResizeDisk, useVMOwner, useSetVMOwner, useExecuteScript, useSavedConfig } from '@/hooks/use-instances'; import { useVMConfig, useUpdateConfig, useResizeDisk, useVMOwner, useSetVMOwner, useExecuteScript, useSavedConfig, useMoveDisk, useAddDisk, useDetachDisk, useUnlockInstance, useUpdateCloudInit } from '@/hooks/use-instances';
import { useLXCStorages } from '@/hooks/use-lxc-templates';
import { useProfile } from '@/hooks/use-settings'; import { useProfile } from '@/hooks/use-settings';
import { useConfirm } from '@/components/shared/ConfirmDialog';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -356,6 +358,635 @@ function SavedConfigCard({ serverId, vmid }: { serverId: number; vmid: number })
); );
} }
// ==================== Cloud-Init ====================
function CloudInitCard({ serverId, vmid, type, node }: { serverId: number; vmid: number; type: string; node: string }) {
const { t } = useTranslation();
const { data: config } = useVMConfig(serverId, vmid, type, node);
const update = useUpdateCloudInit(serverId, vmid, node);
const [user, setUser] = useState('');
const [password, setPassword] = useState('');
const [showPw, setShowPw] = useState(false);
const [ipMode, setIpMode] = useState<'dhcp' | 'static' | 'none'>('dhcp');
const [ipCidr, setIpCidr] = useState('');
const [gateway, setGateway] = useState('');
const [nameserver, setNameserver] = useState('');
const [searchdomain, setSearchdomain] = useState('');
const [sshkeys, setSshkeys] = useState('');
// Наличие cloud-init диска (ide2/scsiN со значением *-cloudinit)
const hasCloudInit = useMemo(() => {
if (!config) return false;
return Object.values(config).some(v => typeof v === 'string' && v.includes('cloudinit'));
}, [config]);
useEffect(() => {
if (!config) return;
setUser(typeof config.ciuser === 'string' ? config.ciuser : '');
setNameserver(typeof config.nameserver === 'string' ? config.nameserver : '');
setSearchdomain(typeof config.searchdomain === 'string' ? config.searchdomain : '');
// sshkeys приходит URL-кодированным
let keys = typeof config.sshkeys === 'string' ? config.sshkeys : '';
try { keys = decodeURIComponent(keys); } catch { /* оставляем как есть */ }
setSshkeys(keys);
// ipconfig0
const ip = typeof config.ipconfig0 === 'string' ? config.ipconfig0 : '';
if (!ip) { setIpMode('none'); setIpCidr(''); setGateway(''); }
else if (ip.includes('ip=dhcp')) { setIpMode('dhcp'); setIpCidr(''); setGateway(''); }
else {
setIpMode('static');
setIpCidr(ip.match(/ip=([^,]+)/)?.[1] ?? '');
setGateway(ip.match(/gw=([^,]+)/)?.[1] ?? '');
}
}, [config]);
const save = () => {
let ipconfig0: string | undefined;
if (ipMode === 'dhcp') ipconfig0 = 'ip=dhcp';
else if (ipMode === 'none') ipconfig0 = '';
else {
if (!ipCidr.trim()) { toast.error(t('instances.ci_ip_required')); return; }
ipconfig0 = `ip=${ipCidr.trim()}${gateway.trim() ? `,gw=${gateway.trim()}` : ''}`;
}
const payload: Record<string, string> = {
ciuser: user,
ipconfig0,
nameserver,
searchdomain,
sshkeys,
};
if (password) payload.cipassword = password; // пусто = не менять
update.mutate(payload, {
onSuccess: () => { toast.success(t('instances.ci_saved')); setPassword(''); },
onError: (e: Error) => toast.error(e.message),
});
};
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Cloud className="h-4 w-4" />{t('instances.cloud_init')}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{!hasCloudInit && (
<p className="rounded-md bg-amber-500/10 px-3 py-2 text-xs text-amber-600 dark:text-amber-500">
{t('instances.ci_no_drive')}
</p>
)}
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label>{t('instances.cloud_user')}</Label>
<Input value={user} onChange={e => setUser(e.target.value)} placeholder={t('common.placeholder_ubuntu')} />
</div>
<div className="space-y-1.5">
<Label>{t('wizard.ci_password')}</Label>
<div className="relative">
<Input type={showPw ? 'text' : 'password'} value={password} onChange={e => setPassword(e.target.value)} placeholder={t('instances.ci_password_keep')} />
<Button type="button" variant="ghost" size="icon" className="absolute right-1 top-1 h-7 w-7" onClick={() => setShowPw(s => !s)}>
{showPw ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
</Button>
</div>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-3">
<div className="space-y-1.5">
<Label>{t('instances.ci_ip_mode')}</Label>
<Select value={ipMode} onValueChange={(v) => v && setIpMode(v as 'dhcp' | 'static' | 'none')}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="dhcp">DHCP</SelectItem>
<SelectItem value="static">{t('instances.ci_static')}</SelectItem>
<SelectItem value="none">{t('instances.ci_ip_none')}</SelectItem>
</SelectContent>
</Select>
</div>
{ipMode === 'static' && (
<>
<div className="space-y-1.5">
<Label>{t('instances.ci_ip_cidr')}</Label>
<Input value={ipCidr} onChange={e => setIpCidr(e.target.value)} placeholder="10.0.0.5/24" />
</div>
<div className="space-y-1.5">
<Label>{t('netif.gateway')}</Label>
<Input value={gateway} onChange={e => setGateway(e.target.value)} placeholder="10.0.0.1" />
</div>
</>
)}
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label>DNS ({t('instances.ci_nameserver')})</Label>
<Input value={nameserver} onChange={e => setNameserver(e.target.value)} placeholder="1.1.1.1" />
</div>
<div className="space-y-1.5">
<Label>{t('instances.ci_searchdomain')}</Label>
<Input value={searchdomain} onChange={e => setSearchdomain(e.target.value)} placeholder="example.com" />
</div>
</div>
<div className="space-y-1.5">
<Label>{t('wizard.ssh_keys')}</Label>
<textarea
value={sshkeys}
onChange={e => setSshkeys(e.target.value)}
rows={3}
spellCheck={false}
placeholder={t('common.placeholder_ssh_key')}
className="w-full rounded-md border bg-background p-2 font-mono text-xs"
/>
</div>
<Button size="sm" onClick={save} disabled={update.isPending}>
{update.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('common.save')}
</Button>
<p className="text-xs text-muted-foreground">{t('instances.ci_reboot_hint')}</p>
</CardContent>
</Card>
);
}
// ==================== Boot order ====================
/** Человекочитаемая подпись загрузочного устройства по его конфигу. */
function bootDeviceLabel(dev: string, spec: unknown, t: (k: string) => string): string {
if (dev.startsWith('net')) return `${dev} · ${t('instances.boot_network')}`;
if (typeof spec === 'string' && spec.includes('media=cdrom')) return `${dev} · CD-ROM`;
return `${dev} · ${t('nodes.disk')}`;
}
function BootOrderCard({ serverId, vmid, type, node }: { serverId: number; vmid: number; type: string; node: string }) {
const { t } = useTranslation();
const { data: config } = useVMConfig(serverId, vmid, type, node);
const updateConfig = useUpdateConfig(serverId, vmid, type, node);
// Все загрузочные устройства из конфига: диски (вкл. cdrom) + сетевые.
const allDevices = useMemo(() => {
if (!config) return [] as string[];
const re = /^(scsi\d+|sata\d+|virtio\d+|ide\d+|net\d+)$/;
return Object.entries(config)
.filter(([k, v]) => re.test(k) && typeof v === 'string' && !v.includes('cloudinit'))
.map(([k]) => k);
}, [config]);
// Текущий порядок из boot=order=dev1;dev2
const currentOrder = useMemo(() => {
const raw = typeof config?.boot === 'string' ? config.boot : '';
const list = raw.match(/order=([^,]+)/)?.[1]?.split(';').map(s => s.trim()).filter(Boolean) ?? [];
return list.filter(d => allDevices.includes(d));
}, [config?.boot, allDevices]);
const [order, setOrder] = useState<string[]>([]);
useEffect(() => { setOrder(currentOrder); }, [currentOrder]);
const disabled = allDevices.filter(d => !order.includes(d));
const move = (i: number, dir: -1 | 1) => {
const j = i + dir;
if (j < 0 || j >= order.length) return;
const next = [...order];
[next[i], next[j]] = [next[j], next[i]];
setOrder(next);
};
const enable = (dev: string) => setOrder([...order, dev]);
const disable = (dev: string) => setOrder(order.filter(d => d !== dev));
const dirty = order.join(';') !== currentOrder.join(';');
const save = () => {
updateConfig.mutate(
{ boot: `order=${order.join(';')}` },
{
onSuccess: () => toast.success(t('instances.boot_order_saved')),
onError: (e: Error) => toast.error(e.message),
}
);
};
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<ListOrdered className="h-4 w-4" />{t('instances.boot_order')}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-xs text-muted-foreground">{t('instances.boot_order_hint')}</p>
{/* Активный порядок */}
<div className="space-y-1.5">
{order.length === 0 ? (
<p className="text-xs text-muted-foreground">{t('instances.boot_none_enabled')}</p>
) : order.map((dev, i) => (
<div key={dev} className="flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm">
<span className="w-5 text-center text-xs text-muted-foreground">{i + 1}</span>
<span className="flex-1 font-mono text-xs">{bootDeviceLabel(dev, config?.[dev], t)}</span>
<Button variant="ghost" size="icon" className="h-6 w-6" disabled={i === 0} onClick={() => move(i, -1)}>
<ArrowUp className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-6 w-6" disabled={i === order.length - 1} onClick={() => move(i, 1)}>
<ArrowDown className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="sm" className="h-6 px-2 text-xs text-muted-foreground" onClick={() => disable(dev)}>
{t('instances.boot_disable')}
</Button>
</div>
))}
</div>
{/* Отключённые устройства */}
{disabled.length > 0 && (
<div className="space-y-1.5 border-t pt-3">
<Label className="text-xs text-muted-foreground">{t('instances.boot_disabled')}</Label>
{disabled.map((dev) => (
<div key={dev} className="flex items-center gap-2 rounded-md border border-dashed px-3 py-1.5 text-sm">
<span className="flex-1 font-mono text-xs text-muted-foreground">{bootDeviceLabel(dev, config?.[dev], t)}</span>
<Button variant="ghost" size="sm" className="h-6 px-2 text-xs" onClick={() => enable(dev)}>
{t('instances.boot_enable')}
</Button>
</div>
))}
</div>
)}
<Button size="sm" onClick={save} disabled={!dirty || updateConfig.isPending}>
{updateConfig.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('common.save')}
</Button>
</CardContent>
</Card>
);
}
// ==================== VM options & unlock ====================
const HOTPLUG_DEFAULT = 'network,disk,usb';
/** Простой тумблер-строка. */
function OptionToggle({ label, hint, checked, onChange }: {
label: string; hint?: string; checked: boolean; onChange: (v: boolean) => void;
}) {
return (
<label className="flex items-start justify-between gap-3 cursor-pointer py-1">
<div>
<span className="text-sm">{label}</span>
{hint && <p className="text-xs text-muted-foreground">{hint}</p>}
</div>
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)}
className="mt-0.5 h-4 w-4 shrink-0 rounded border-input accent-primary" />
</label>
);
}
function VmOptionsCard({ serverId, vmid, type, node }: { serverId: number; vmid: number; type: string; node: string }) {
const { t } = useTranslation();
const { data: config } = useVMConfig(serverId, vmid, type, node);
const updateConfig = useUpdateConfig(serverId, vmid, type, node);
const unlock = useUnlockInstance(serverId, vmid, type, node);
const [agent, setAgent] = useState(false);
const [tablet, setTablet] = useState(true);
const [kvm, setKvm] = useState(true);
const [hotplug, setHotplug] = useState(true);
useEffect(() => {
if (!config) return;
const agentRaw = typeof config.agent === 'string' ? config.agent : String(config.agent ?? '');
setAgent(agentRaw.startsWith('1'));
setTablet(config.tablet == null ? true : Number(config.tablet) === 1);
setKvm(config.kvm == null ? true : Number(config.kvm) === 1);
const hp = typeof config.hotplug === 'string' ? config.hotplug : '';
setHotplug(config.hotplug == null ? true : hp !== '0' && hp !== '');
}, [config]);
const lock = typeof config?.lock === 'string' ? config.lock : '';
const save = () => {
const updates: Record<string, unknown> = {
agent: agent ? 1 : 0,
tablet: tablet ? 1 : 0,
kvm: kvm ? 1 : 0,
hotplug: hotplug ? HOTPLUG_DEFAULT : '0',
};
updateConfig.mutate(updates, {
onSuccess: () => toast.success(t('instances.vm_options_saved')),
onError: (e: Error) => toast.error(e.message),
});
};
const doUnlock = () => {
unlock.mutate(undefined, {
onSuccess: () => toast.success(t('instances.unlocked')),
onError: (e: Error) => toast.error(e.message),
});
};
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<SlidersHorizontal className="h-4 w-4" />{t('instances.vm_options')}
</CardTitle>
</CardHeader>
<CardContent className="space-y-1">
<OptionToggle label={t('instances.opt_agent')} hint={t('instances.opt_agent_hint')} checked={agent} onChange={setAgent} />
<OptionToggle label={t('instances.opt_tablet')} hint={t('instances.opt_tablet_hint')} checked={tablet} onChange={setTablet} />
<OptionToggle label={t('instances.opt_kvm')} hint={t('instances.opt_kvm_hint')} checked={kvm} onChange={setKvm} />
<OptionToggle label={t('instances.opt_hotplug')} hint={t('instances.opt_hotplug_hint')} checked={hotplug} onChange={setHotplug} />
<div className="flex items-center gap-2 pt-2">
<Button size="sm" onClick={save} disabled={updateConfig.isPending}>
{updateConfig.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('common.save')}
</Button>
</div>
{/* Unlock */}
<div className="mt-3 flex items-center justify-between gap-3 border-t pt-3">
<div>
<span className="text-sm">{t('instances.unlock')}</span>
<p className="text-xs text-muted-foreground">
{lock ? t('instances.lock_current', { lock }) : t('instances.no_lock')}
</p>
</div>
<Button size="sm" variant="outline" onClick={doUnlock} disabled={unlock.isPending}>
{unlock.isPending ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <LockOpen className="mr-2 h-4 w-4" />}
{t('instances.unlock')}
</Button>
</div>
</CardContent>
</Card>
);
}
// ==================== CPU options ====================
const CPU_TYPES = ['default', 'host', 'x86-64-v2', 'x86-64-v2-AES', 'x86-64-v3', 'x86-64-v4', 'kvm64', 'qemu64'];
function CpuOptionsCard({ serverId, vmid, type, node }: { serverId: number; vmid: number; type: string; node: string }) {
const { t } = useTranslation();
const { data: config } = useVMConfig(serverId, vmid, type, node);
const updateConfig = useUpdateConfig(serverId, vmid, type, node);
const [cpuType, setCpuType] = useState('default');
const [sockets, setSockets] = useState('');
const [cpulimit, setCpulimit] = useState('');
const [cpuunits, setCpuunits] = useState('');
const [numa, setNuma] = useState(false);
useEffect(() => {
if (!config) return;
// cpu из конфига может быть "host" или "cputype=host,flags=..."
const raw = typeof config.cpu === 'string' ? config.cpu : '';
const parsed = raw.includes('cputype=') ? (raw.match(/cputype=([^,]+)/)?.[1] ?? '') : raw;
setCpuType(parsed && CPU_TYPES.includes(parsed) ? parsed : (parsed ? parsed : 'default'));
setSockets(config.sockets != null ? String(config.sockets) : '');
setCpulimit(config.cpulimit != null ? String(config.cpulimit) : '');
setCpuunits(config.cpuunits != null ? String(config.cpuunits) : '');
setNuma(Number(config.numa) === 1);
}, [config]);
const save = () => {
const updates: Record<string, unknown> = {
sockets: sockets ? Number(sockets) : 1,
numa: numa ? 1 : 0,
};
if (cpuType && cpuType !== 'default') updates.cpu = cpuType;
else updates.delete = 'cpu';
// cpulimit: 0 = без лимита; cpuunits: вес планировщика
updates.cpulimit = cpulimit ? Number(cpulimit) : 0;
if (cpuunits) updates.cpuunits = Number(cpuunits);
updateConfig.mutate(updates, {
onSuccess: () => toast.success(t('instances.cpu_saved')),
onError: (e: Error) => toast.error(e.message),
});
};
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Cpu className="h-4 w-4" />{t('instances.cpu_options')}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label>{t('instances.cpu_type')}</Label>
<Select value={cpuType} onValueChange={(v) => v && setCpuType(v)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{CPU_TYPES.map((c) => (
<SelectItem key={c} value={c}>{c === 'default' ? `default (kvm64)` : c}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">{t('instances.cpu_type_hint')}</p>
</div>
<div className="space-y-1.5">
<Label>{t('instances.sockets')}</Label>
<Input type="number" min={1} max={8} value={sockets} onChange={(e) => setSockets(e.target.value)} placeholder="1" />
</div>
<div className="space-y-1.5">
<Label>{t('instances.cpulimit')}</Label>
<Input type="number" min={0} max={128} step="0.1" value={cpulimit} onChange={(e) => setCpulimit(e.target.value)} placeholder="0" />
<p className="text-xs text-muted-foreground">{t('instances.cpulimit_hint')}</p>
</div>
<div className="space-y-1.5">
<Label>{t('instances.cpuunits')}</Label>
<Input type="number" min={1} max={262144} value={cpuunits} onChange={(e) => setCpuunits(e.target.value)} placeholder="100" />
<p className="text-xs text-muted-foreground">{t('instances.cpuunits_hint')}</p>
</div>
</div>
<label className="flex items-center gap-2.5 cursor-pointer">
<input type="checkbox" checked={numa} onChange={(e) => setNuma(e.target.checked)} className="h-4 w-4 rounded border-input accent-primary" />
<span className="text-sm">{t('instances.numa')}</span>
</label>
<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('instances.apply_changes_note')}</p>
</CardContent>
</Card>
);
}
// ==================== Disk management (move / add / detach) ====================
/** Достать имя хранилища из спецификации диска ("local-lvm:vm-100-disk-0,size=32G"). */
function parseDiskStorage(spec: unknown): string | null {
if (typeof spec !== 'string') return null;
return spec.split(':', 1)[0] || null;
}
/** Следующее свободное имя устройства для заданной шины (scsi/virtio/sata). */
function nextFreeDevice(bus: string, existing: string[]): string {
for (let i = 0; i < 31; i++) {
const name = `${bus}${i}`;
if (!existing.includes(name)) return name;
}
return `${bus}0`;
}
function DiskManagementCard({ serverId, vmid, type, node }: { serverId: number; vmid: number; type: string; node: string }) {
const { t } = useTranslation();
const confirm = useConfirm();
const { data: config } = useVMConfig(serverId, vmid, type, node);
const { data: storages = [] } = useLXCStorages(serverId, node);
const moveDisk = useMoveDisk(serverId, vmid, node);
const addDisk = useAddDisk(serverId, vmid, node);
const detachDisk = useDetachDisk(serverId, vmid, node);
// Реальные дисковые устройства (без cd-rom и cloudinit)
const disks = useMemo(() => {
if (!config) return [] as string[];
const re = /^(scsi\d+|sata\d+|virtio\d+|ide\d+)$/;
return Object.entries(config)
.filter(([k, v]) => re.test(k) && typeof v === 'string'
&& !v.includes('media=cdrom') && !v.includes('cloudinit'))
.map(([k]) => k);
}, [config]);
const [moveDiskDev, setMoveDiskDev] = useState('');
const [moveStorage, setMoveStorage] = useState('');
const [moveDelete, setMoveDelete] = useState(true);
const [addBus, setAddBus] = useState('scsi');
const [addStorage, setAddStorage] = useState('');
const [addSize, setAddSize] = useState('16');
const [addSsd, setAddSsd] = useState(false);
const [addDiscard, setAddDiscard] = useState(false);
useEffect(() => {
if (disks.length && !disks.includes(moveDiskDev)) setMoveDiskDev(disks[0]);
}, [disks, moveDiskDev]);
const doMove = () => {
if (!moveDiskDev || !moveStorage) return;
moveDisk.mutate(
{ disk: moveDiskDev, target_storage: moveStorage, delete: moveDelete },
{
onSuccess: () => { toast.success(t('instances.disk_moved')); setMoveStorage(''); },
onError: (e: Error) => toast.error(e.message),
}
);
};
const doAdd = () => {
if (!addStorage || !addSize) return;
const dev = nextFreeDevice(addBus, disks);
addDisk.mutate(
{ disk: dev, storage: addStorage, size_gb: Number(addSize), ssd: addSsd, discard: addDiscard },
{
onSuccess: () => { toast.success(t('instances.disk_added', { dev })); },
onError: (e: Error) => toast.error(e.message),
}
);
};
const doDetach = async (dev: string) => {
const destroy = await confirm(t('instances.disk_detach_confirm', { dev }));
if (!destroy) return;
detachDisk.mutate(
{ disk: dev, destroy: true },
{
onSuccess: () => toast.success(t('instances.disk_detached')),
onError: (e: Error) => toast.error(e.message),
}
);
};
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<HardDrive className="h-4 w-4" />{t('instances.disk_management')}
</CardTitle>
</CardHeader>
<CardContent className="space-y-5">
{/* Список дисков */}
<div className="space-y-1.5">
{disks.length === 0 ? (
<p className="text-xs text-muted-foreground">{t('instances.no_disks')}</p>
) : disks.map((d) => (
<div key={d} className="flex items-center justify-between rounded-md border px-3 py-1.5 text-sm">
<span className="font-mono">{d}</span>
<span className="text-xs text-muted-foreground">{parseDiskStorage(config?.[d]) ?? '—'}</span>
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" title={t('common.delete')} onClick={() => doDetach(d)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
))}
</div>
{/* Move disk */}
<div className="space-y-2 border-t pt-3">
<Label className="text-xs font-medium">{t('instances.disk_move')}</Label>
<div className="grid gap-3 sm:grid-cols-2">
<Select value={moveDiskDev} onValueChange={(v) => v && setMoveDiskDev(v)}>
<SelectTrigger><SelectValue placeholder={t('instances.disk_device')} /></SelectTrigger>
<SelectContent>{disks.map((d) => <SelectItem key={d} value={d}>{d}</SelectItem>)}</SelectContent>
</Select>
<Select value={moveStorage} onValueChange={(v) => v && setMoveStorage(v)}>
<SelectTrigger><SelectValue placeholder={t('instances.migrate_target_storage')} /></SelectTrigger>
<SelectContent>{storages.map((s) => <SelectItem key={s.storage} value={s.storage}>{s.storage} ({s.type})</SelectItem>)}</SelectContent>
</Select>
</div>
<label className="flex items-center gap-2 text-xs">
<input type="checkbox" checked={moveDelete} onChange={(e) => setMoveDelete(e.target.checked)} />
<span>{t('instances.disk_move_delete')}</span>
</label>
<Button size="sm" variant="outline" onClick={doMove} disabled={!moveStorage || moveDisk.isPending}>
{moveDisk.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('instances.disk_move')}
</Button>
</div>
{/* Add disk */}
<div className="space-y-2 border-t pt-3">
<Label className="text-xs font-medium">{t('instances.disk_add')}</Label>
<div className="grid gap-3 sm:grid-cols-3">
<div className="space-y-1"><Label className="text-[11px]">{t('instances.disk_bus')}</Label>
<Select value={addBus} onValueChange={(v) => v && setAddBus(v)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="scsi">scsi</SelectItem>
<SelectItem value="virtio">virtio</SelectItem>
<SelectItem value="sata">sata</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1"><Label className="text-[11px]">{t('instances.migrate_target_storage')}</Label>
<Select value={addStorage} onValueChange={(v) => v && setAddStorage(v)}>
<SelectTrigger><SelectValue placeholder="—" /></SelectTrigger>
<SelectContent>{storages.map((s) => <SelectItem key={s.storage} value={s.storage}>{s.storage}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="space-y-1"><Label className="text-[11px]">{t('instances.new_size_gb')}</Label>
<Input type="number" min={1} value={addSize} onChange={(e) => setAddSize(e.target.value)} />
</div>
</div>
<div className="flex gap-4">
<label className="flex items-center gap-2 text-xs"><input type="checkbox" checked={addSsd} onChange={(e) => setAddSsd(e.target.checked)} /><span>SSD</span></label>
<label className="flex items-center gap-2 text-xs"><input type="checkbox" checked={addDiscard} onChange={(e) => setAddDiscard(e.target.checked)} /><span>Discard</span></label>
</div>
<Button size="sm" variant="outline" onClick={doAdd} disabled={!addStorage || !addSize || addDisk.isPending}>
{addDisk.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('instances.disk_add')}
</Button>
</div>
</CardContent>
</Card>
);
}
export default function SettingsTab({ serverId, vmid, type, node }: Props) { export default function SettingsTab({ serverId, vmid, type, node }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const { data: config } = useVMConfig(serverId, vmid, type, node); const { data: config } = useVMConfig(serverId, vmid, type, node);
@@ -508,6 +1139,16 @@ export default function SettingsTab({ serverId, vmid, type, node }: Props) {
</CardContent> </CardContent>
</Card> </Card>
{type !== 'lxc' && <CloudInitCard serverId={serverId} vmid={vmid} type={type} node={node} />}
{type !== 'lxc' && <BootOrderCard serverId={serverId} vmid={vmid} type={type} node={node} />}
{type !== 'lxc' && <VmOptionsCard serverId={serverId} vmid={vmid} type={type} node={node} />}
{type !== 'lxc' && <CpuOptionsCard serverId={serverId} vmid={vmid} type={type} node={node} />}
{type !== 'lxc' && <DiskManagementCard serverId={serverId} vmid={vmid} type={type} node={node} />}
{/* Disk Resize */} {/* Disk Resize */}
<Card> <Card>
<CardHeader className="pb-2"> <CardHeader className="pb-2">

View File

@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { RefreshCw, Server, Play, Square, RotateCw, PackageCheck } from 'lucide-react'; import { RefreshCw, Server, Play, Square, RotateCw, PackageCheck, Download } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@@ -128,9 +128,18 @@ export default function NodeAdminManager({ serverId, nodeNames }: { serverId: nu
? t('nodeadm.updates_count', 'Доступно обновлений: {{n}}', { n: updates.length }) ? t('nodeadm.updates_count', 'Доступно обновлений: {{n}}', { n: updates.length })
: t('nodeadm.up_to_date', 'Обновлений нет')} : t('nodeadm.up_to_date', 'Обновлений нет')}
</p> </p>
<Button size="sm" variant="outline" onClick={refreshApt} disabled={aptRefresh.isPending}> <div className="flex items-center gap-2">
<PackageCheck className="mr-1 h-4 w-4" />{t('nodeadm.refresh', 'Проверить (apt update)')} <Button size="sm" variant="outline" onClick={refreshApt} disabled={aptRefresh.isPending}>
</Button> <PackageCheck className="mr-1 h-4 w-4" />{t('nodeadm.refresh', 'Проверить (apt update)')}
</Button>
<Button
size="sm"
onClick={() => window.open(`/console/node/${serverId}/${node}?cmd=upgrade`, '_blank', 'noopener,noreferrer')}
disabled={!node}
>
<Download className="mr-1 h-4 w-4" />{t('nodeadm.do_upgrade', 'Установить обновления')}
</Button>
</div>
</div> </div>
{updates.length > 0 && ( {updates.length > 0 && (
<Table> <Table>
@@ -150,7 +159,7 @@ export default function NodeAdminManager({ serverId, nodeNames }: { serverId: nu
</TableBody> </TableBody>
</Table> </Table>
)} )}
<p className="text-xs text-muted-foreground">{t('nodeadm.upgrade_hint', 'Установка обновлений выполняется в консоли ноды (apt dist-upgrade) — API не поддерживает неинтерактивный upgrade.')}</p> <p className="text-xs text-muted-foreground">{t('nodeadm.upgrade_hint', '«Установить обновления» откроет консоль ноды с apt dist-upgrade (потребуется подтверждение). Не закрывайте вкладку до завершения.')}</p>
</TabsContent> </TabsContent>
{/* Repositories */} {/* Repositories */}

View File

@@ -1,9 +1,10 @@
import { useParams, Link } from 'react-router'; import { useParams, useSearchParams, Link } from 'react-router';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { ArrowLeft, Cpu, MemoryStick, HardDrive, Clock } from 'lucide-react'; import { ArrowLeft, Cpu, MemoryStick, HardDrive, Clock } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { useServer, useNodes, useClusterInfo } from '@/hooks/use-nodes'; import { useServer, useNodes, useClusterInfo } from '@/hooks/use-nodes';
import { useVirtualMachines } from '@/hooks/use-instances'; import { useVirtualMachines } from '@/hooks/use-instances';
import { StatusDot } from '@/components/shared/status-dot'; import { StatusDot } from '@/components/shared/status-dot';
@@ -26,10 +27,18 @@ export default function NodeDetailPage() {
const { data: nodesData } = useNodes(sid); const { data: nodesData } = useNodes(sid);
const { data: clusterInfo } = useClusterInfo(sid); const { data: clusterInfo } = useClusterInfo(sid);
const { data: allVMs = [] } = useVirtualMachines(); const { data: allVMs = [] } = useVirtualMachines();
const [searchParams, setSearchParams] = useSearchParams();
const nodes = nodesData?.nodes || []; const nodes = nodesData?.nodes || [];
const serverVMs = allVMs.filter(v => v.server_id === sid); const serverVMs = allVMs.filter(v => v.server_id === sid);
const activeTab = searchParams.get('tab') || 'overview';
const setTab = (tab: string) => {
const params = new URLSearchParams(searchParams);
params.set('tab', tab);
setSearchParams(params, { replace: true });
};
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -54,101 +63,115 @@ export default function NodeDetailPage() {
</div> </div>
</div> </div>
{/* Nodes in cluster */} <Tabs value={activeTab} onValueChange={setTab}>
{nodes.length > 0 && ( <TabsList className="flex-wrap h-auto">
<div className="space-y-3"> <TabsTrigger value="overview">{t('nodes.tab_overview', 'Обзор')}</TabsTrigger>
<h2 className="text-lg font-semibold">{t('nodes.cluster_nodes')}</h2> {nodes.length > 0 && (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> <>
{nodes.map(node => ( <TabsTrigger value="network">{t('nodes.tab_network', 'Сеть')}</TabsTrigger>
<Card key={node.node}> <TabsTrigger value="ha">{t('nodes.tab_ha', 'HA')}</TabsTrigger>
<CardContent className="p-4 space-y-3"> <TabsTrigger value="firewall">{t('nodes.tab_firewall', 'Firewall')}</TabsTrigger>
<div className="flex items-center justify-between"> <TabsTrigger value="pools">{t('nodes.tab_pools', 'Пулы')}</TabsTrigger>
<span className="font-medium">{node.node}</span> <TabsTrigger value="disks">{t('nodes.tab_disks', 'Диски')}</TabsTrigger>
<StatusDot status={node.status} /> <TabsTrigger value="admin">{t('nodes.tab_admin', 'Администрирование')}</TabsTrigger>
</div> <TabsTrigger value="access">{t('nodes.tab_access', 'Доступ')}</TabsTrigger>
<div className="grid grid-cols-2 gap-2 text-xs text-muted-foreground"> </>
<div className="flex items-center gap-1"><Cpu className="h-3 w-3" />{formatPercent(node.cpu || 0)} CPU</div>
<div className="flex items-center gap-1"><MemoryStick className="h-3 w-3" />{formatBytes(node.mem || 0)} / {formatBytes(node.maxmem || 0)}</div>
<div className="flex items-center gap-1"><HardDrive className="h-3 w-3" />{formatBytes(node.disk || 0)} / {formatBytes(node.maxdisk || 0)}</div>
<div className="flex items-center gap-1"><Clock className="h-3 w-3" />{formatUptime(node.uptime || 0)}</div>
</div>
</CardContent>
</Card>
))}
</div>
</div>
)}
{/* Node network interfaces */}
{nodes.length > 0 && (
<NodeNetworks serverId={sid} nodeNames={nodes.map(n => n.node)} />
)}
{/* High Availability */}
{nodes.length > 0 && (
<HAResources serverId={sid} vms={serverVMs} />
)}
{/* Software Defined Networking */}
{nodes.length > 0 && (
<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 */}
<div className="space-y-3">
<h2 className="text-lg font-semibold">{t('nodes.instances_on_server')} ({serverVMs.length})</h2>
<div className="rounded-lg border divide-y">
{serverVMs.map(vm => (
<Link
key={`${vm.server_id}-${vm.vmid}`}
to={`/instances/${vm.server_id}/${vm.vmid}?node=${vm.node}&type=${vm.type}`}
className="flex items-center gap-3 px-4 py-3 hover:bg-muted/50 transition-colors"
>
<StatusDot status={vm.status} />
<span className="font-medium min-w-0 truncate flex-1">{vm.name || `VM ${vm.vmid}`}</span>
<Badge variant="outline" className="text-xs">{vm.type === 'qemu' ? 'VM' : 'LXC'}</Badge>
<span className="text-xs text-muted-foreground">#{vm.vmid}</span>
<span className="text-xs text-muted-foreground">{vm.node}</span>
</Link>
))}
{serverVMs.length === 0 && (
<p className="px-4 py-6 text-center text-sm text-muted-foreground">{t('common.no_data')}</p>
)} )}
</div> </TabsList>
</div>
{/* Overview: cluster nodes + instances on server */}
<TabsContent value="overview" className="mt-4 space-y-6">
{nodes.length > 0 && (
<div className="space-y-3">
<h2 className="text-lg font-semibold">{t('nodes.cluster_nodes')}</h2>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{nodes.map(node => (
<Card key={node.node}>
<CardContent className="p-4 space-y-3">
<div className="flex items-center justify-between">
<span className="font-medium">{node.node}</span>
<StatusDot status={node.status} />
</div>
<div className="grid grid-cols-2 gap-2 text-xs text-muted-foreground">
<div className="flex items-center gap-1"><Cpu className="h-3 w-3" />{formatPercent(node.cpu || 0)} CPU</div>
<div className="flex items-center gap-1"><MemoryStick className="h-3 w-3" />{formatBytes(node.mem || 0)} / {formatBytes(node.maxmem || 0)}</div>
<div className="flex items-center gap-1"><HardDrive className="h-3 w-3" />{formatBytes(node.disk || 0)} / {formatBytes(node.maxdisk || 0)}</div>
<div className="flex items-center gap-1"><Clock className="h-3 w-3" />{formatUptime(node.uptime || 0)}</div>
</div>
</CardContent>
</Card>
))}
</div>
</div>
)}
<div className="space-y-3">
<h2 className="text-lg font-semibold">{t('nodes.instances_on_server')} ({serverVMs.length})</h2>
<div className="rounded-lg border divide-y">
{serverVMs.map(vm => (
<Link
key={`${vm.server_id}-${vm.vmid}`}
to={`/instances/${vm.server_id}/${vm.vmid}?node=${vm.node}&type=${vm.type}`}
className="flex items-center gap-3 px-4 py-3 hover:bg-muted/50 transition-colors"
>
<StatusDot status={vm.status} />
<span className="font-medium min-w-0 truncate flex-1">{vm.name || `VM ${vm.vmid}`}</span>
<Badge variant="outline" className="text-xs">{vm.type === 'qemu' ? 'VM' : 'LXC'}</Badge>
<span className="text-xs text-muted-foreground">#{vm.vmid}</span>
<span className="text-xs text-muted-foreground">{vm.node}</span>
</Link>
))}
{serverVMs.length === 0 && (
<p className="px-4 py-6 text-center text-sm text-muted-foreground">{t('common.no_data')}</p>
)}
</div>
</div>
</TabsContent>
{nodes.length > 0 && (
<>
{/* Network: interfaces + SDN */}
<TabsContent value="network" className="mt-4 space-y-6">
<NodeNetworks serverId={sid} nodeNames={nodes.map(n => n.node)} />
<SDNManager serverId={sid} />
</TabsContent>
{/* High Availability */}
<TabsContent value="ha" className="mt-4">
<HAResources serverId={sid} vms={serverVMs} />
</TabsContent>
{/* Firewall: datacenter + node */}
<TabsContent value="firewall" className="mt-4 space-y-6">
<FirewallManager serverId={sid} />
<NodeFirewallManager serverId={sid} nodeNames={nodes.map(n => n.node)} />
</TabsContent>
{/* Resource Pools */}
<TabsContent value="pools" className="mt-4">
<PoolManager
serverId={sid}
vms={serverVMs.map(v => ({ vmid: v.vmid, name: v.name, type: v.type, node: v.node }))}
/>
</TabsContent>
{/* Disks & ZFS */}
<TabsContent value="disks" className="mt-4">
<DiskManager serverId={sid} nodeNames={nodes.map(n => n.node)} />
</TabsContent>
{/* Node administration: services & APT */}
<TabsContent value="admin" className="mt-4">
<NodeAdminManager serverId={sid} nodeNames={nodes.map(n => n.node)} />
</TabsContent>
{/* PVE Access: realms & API tokens */}
<TabsContent value="access" className="mt-4">
<AccessManager serverId={sid} />
</TabsContent>
</>
)}
</Tabs>
</div> </div>
); );
} }

View File

@@ -13,6 +13,7 @@ import { useVirtualMachines } from '@/hooks/use-instances';
import type { ProxmoxServerCreate } from '@/types'; import type { ProxmoxServerCreate } from '@/types';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useConfirm } from '@/components/shared/ConfirmDialog'; import { useConfirm } from '@/components/shared/ConfirmDialog';
import ServerUpdatesBadge from './ServerUpdatesBadge';
interface ServerFormData { interface ServerFormData {
name: string; name: string;
@@ -318,6 +319,8 @@ export default function NodesPage() {
{srv.description && ( {srv.description && (
<p className="text-xs text-muted-foreground truncate">{srv.description}</p> <p className="text-xs text-muted-foreground truncate">{srv.description}</p>
)} )}
<ServerUpdatesBadge serverId={srv.id} online={srv.is_online} />
</CardContent> </CardContent>
</Card> </Card>
); );

View File

@@ -0,0 +1,75 @@
import { useCallback, useEffect, useState } from 'react';
import { Link } from 'react-router';
import { useTranslation } from 'react-i18next';
import { ArrowUpCircle } from 'lucide-react';
import { useNodes } from '@/hooks/use-nodes';
import { useAptUpdates } from '@/hooks/use-node-admin';
/**
* Невидимый пробник: тянет список APT-обновлений для одного узла
* и сообщает их количество наверх. Рендерит null.
*/
function NodeUpdatesProbe({
serverId,
node,
onCount,
}: {
serverId: number;
node: string;
onCount: (node: string, count: number) => void;
}) {
const { data } = useAptUpdates(serverId, node);
const count = data?.updates?.length ?? 0;
useEffect(() => {
onCount(node, count);
}, [node, count, onCount]);
return null;
}
/**
* Аккуратная плашка «Доступны обновления» для карточки сервера.
* Показывается только если хотя бы на одном узле есть APT-обновления.
*/
export default function ServerUpdatesBadge({
serverId,
online,
}: {
serverId: number;
online?: boolean;
}) {
const { t } = useTranslation();
const { data: nodesData } = useNodes(online ? serverId : 0);
const nodes = nodesData?.nodes ?? [];
const [counts, setCounts] = useState<Record<string, number>>({});
const report = useCallback((node: string, count: number) => {
setCounts(prev => (prev[node] === count ? prev : { ...prev, [node]: count }));
}, []);
const total = Object.values(counts).reduce((sum, c) => sum + c, 0);
if (!online) return null;
return (
<>
{nodes.map(n => (
<NodeUpdatesProbe key={n.node} serverId={serverId} node={n.node} onCount={report} />
))}
{total > 0 && (
<Link
to={`/nodes/${serverId}?tab=admin`}
className="flex items-center gap-2 rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs font-medium text-amber-600 transition-colors hover:bg-amber-500/20 dark:text-amber-400"
title={t('nodes.updates_available_count', { count: total })}
>
<ArrowUpCircle className="h-4 w-4 shrink-0" />
<span className="flex-1 min-w-0 truncate">{t('nodes.updates_available')}</span>
<span className="rounded-full bg-amber-500/20 px-2 py-0.5 font-semibold tabular-nums">
{total}
</span>
</Link>
)}
</>
);
}

View File

@@ -800,7 +800,7 @@ function RolesTab() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Shield className="h-4 w-4" /> <Shield className="h-4 w-4" />
<div> <div>
<div>{r.display_name}</div> <div>{t(`perms.role.${r.name}.name`, r.display_name)}</div>
<div className="text-xs text-muted-foreground">{r.name}</div> <div className="text-xs text-muted-foreground">{r.name}</div>
</div> </div>
{r.is_system && ( {r.is_system && (
@@ -808,7 +808,7 @@ function RolesTab() {
)} )}
</div> </div>
</TableCell> </TableCell>
<TableCell className="text-xs text-muted-foreground">{r.description || '—'}</TableCell> <TableCell className="text-xs text-muted-foreground">{t(`perms.role.${r.name}.desc`, r.description || '—')}</TableCell>
<TableCell>{enabledCount}</TableCell> <TableCell>{enabledCount}</TableCell>
<TableCell>{r.user_count}</TableCell> <TableCell>{r.user_count}</TableCell>
<TableCell> <TableCell>
@@ -934,7 +934,7 @@ function RoleDialog({
<DialogContent className="max-w-2xl"> <DialogContent className="max-w-2xl">
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
{mode === 'create' ? t('users.create_role') : `${t('users.edit_role')}: ${role?.display_name}`} {mode === 'create' ? t('users.create_role') : `${t('users.edit_role')}: ${role ? t(`perms.role.${role.name}.name`, role.display_name) : ''}`}
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
<div className="space-y-3"> <div className="space-y-3">
@@ -974,7 +974,9 @@ function RoleDialog({
<div className="border rounded-md max-h-80 overflow-y-auto p-3 space-y-3"> <div className="border rounded-md max-h-80 overflow-y-auto p-3 space-y-3">
{Object.entries(permCategories).map(([cat, items]) => ( {Object.entries(permCategories).map(([cat, items]) => (
<div key={cat}> <div key={cat}>
<div className="text-xs font-semibold text-muted-foreground mb-1.5 uppercase">{cat}</div> <div className="text-xs font-semibold text-muted-foreground mb-1.5 uppercase">
{t(`perms.cat.${cat}`, cat)}
</div>
<div className="grid grid-cols-2 gap-1"> <div className="grid grid-cols-2 gap-1">
{Object.entries(items).map(([code, label]) => ( {Object.entries(items).map(([code, label]) => (
<label key={code} className="flex items-center gap-2 text-sm cursor-pointer hover:bg-accent rounded px-2 py-1"> <label key={code} className="flex items-center gap-2 text-sm cursor-pointer hover:bg-accent rounded px-2 py-1">
@@ -984,7 +986,9 @@ function RoleDialog({
onChange={() => togglePerm(code)} onChange={() => togglePerm(code)}
className="h-4 w-4" className="h-4 w-4"
/> />
<span className="flex-1 truncate" title={code}>{label}</span> <span className="flex-1 truncate" title={code}>
{t(`perms.label.${code.replace(/:/g, '_')}`, label as string)}
</span>
</label> </label>
))} ))}
</div> </div>

View File

@@ -560,6 +560,63 @@ export function useResizeDisk(serverId: number, vmid: number, type: string, node
}); });
} }
export function useUnlockInstance(serverId: number, vmid: number, type: string, node: string) {
const qc = useQueryClient();
const prefix = type === 'lxc' ? 'container' : 'vm';
return useMutation({
mutationFn: () => apiClient.post(`/proxmox/api/${serverId}/${prefix}/${vmid}/unlock?node=${node}`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: vmKeys.config(serverId, vmid) });
qc.invalidateQueries({ queryKey: vmKeys.status(serverId, vmid) });
},
});
}
export interface CloudInitInput {
ciuser?: string;
cipassword?: string;
sshkeys?: string;
ipconfig0?: string;
nameserver?: string;
searchdomain?: string;
}
export function useUpdateCloudInit(serverId: number, vmid: number, node: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: CloudInitInput) =>
apiClient.post(`/proxmox/api/${serverId}/vm/${vmid}/cloud-init?node=${node}`, body),
onSuccess: () => qc.invalidateQueries({ queryKey: vmKeys.config(serverId, vmid) }),
});
}
export function useMoveDisk(serverId: number, vmid: number, node: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: { disk: string; target_storage: string; delete?: boolean; format?: string }) =>
apiClient.post(`/proxmox/api/${serverId}/vm/${vmid}/disk/move?node=${node}`, body),
onSuccess: () => qc.invalidateQueries({ queryKey: vmKeys.config(serverId, vmid) }),
});
}
export function useAddDisk(serverId: number, vmid: number, node: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: { disk: string; storage: string; size_gb: number; ssd?: boolean; discard?: boolean; iothread?: boolean }) =>
apiClient.post(`/proxmox/api/${serverId}/vm/${vmid}/disk/add?node=${node}`, body),
onSuccess: () => qc.invalidateQueries({ queryKey: vmKeys.config(serverId, vmid) }),
});
}
export function useDetachDisk(serverId: number, vmid: number, node: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: { disk: string; destroy?: boolean }) =>
apiClient.post(`/proxmox/api/${serverId}/vm/${vmid}/disk/detach?node=${node}`, body),
onSuccess: () => qc.invalidateQueries({ queryKey: vmKeys.config(serverId, vmid) }),
});
}
// ==================== Config Update ==================== // ==================== Config Update ====================
export function useUpdateConfig(serverId: number, vmid: number, type: string, node: string) { export function useUpdateConfig(serverId: number, vmid: number, type: string, node: string) {
const qc = useQueryClient(); const qc = useQueryClient();

View File

@@ -1,7 +1,9 @@
import { Outlet } from 'react-router'; import { Outlet } from 'react-router';
import { Monitor } from 'lucide-react'; import { Monitor } from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import AuthPattern from '@/features/auth/AuthPattern'; import { useThemeStore } from '@/stores/theme-store';
import authBgDark from '@/assets/auth/auth-bg-dark.png';
import authBgLight from '@/assets/auth/auth-bg-light.png';
function LanguageSwitcher() { function LanguageSwitcher() {
const { i18n } = useTranslation(); const { i18n } = useTranslation();
@@ -12,7 +14,7 @@ function LanguageSwitcher() {
value={lang} value={lang}
onChange={(e) => i18n.changeLanguage(e.target.value)} onChange={(e) => i18n.changeLanguage(e.target.value)}
aria-label="Language" aria-label="Language"
className="cursor-pointer rounded-md bg-transparent py-1 pr-1 text-sm text-text-secondary outline-none transition-colors hover:text-text-primary focus-visible:text-text-primary" className="cursor-pointer rounded-md bg-transparent py-1 pr-1 text-sm text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:text-foreground"
> >
<option value="ru">Русский</option> <option value="ru">Русский</option>
<option value="en">English</option> <option value="en">English</option>
@@ -22,12 +24,21 @@ function LanguageSwitcher() {
export default function AuthLayout() { export default function AuthLayout() {
const year = new Date().getFullYear(); const year = new Date().getFullYear();
const theme = useThemeStore((s) => s.theme);
const authBg = theme === 'light' ? authBgLight : authBgDark;
return ( return (
<div className="grid min-h-screen bg-surface-0 lg:grid-cols-[1.4fr_1fr]"> <div className="grid min-h-screen bg-background lg:grid-cols-[1.4fr_1fr]">
{/* Левая декоративная панель */} {/* Левая декоративная панель со сгенерированным фоном */}
<div className="relative hidden overflow-hidden bg-surface-1 text-primary/60 lg:block"> <div className="relative hidden overflow-hidden bg-muted lg:block">
<AuthPattern className="absolute inset-0 h-full w-full" /> <img
src={authBg}
alt=""
aria-hidden="true"
className="absolute inset-0 h-full w-full object-cover"
/>
{/* Плавный бесшовный переход к панели формы (в обеих темах) */}
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-transparent to-background/70" />
</div> </div>
{/* Правая панель с формой */} {/* Правая панель с формой */}
@@ -48,7 +59,7 @@ export default function AuthLayout() {
</div> </div>
{/* Футер */} {/* Футер */}
<div className="flex items-center gap-4 text-sm text-text-secondary"> <div className="flex items-center gap-4 text-sm text-muted-foreground">
<LanguageSwitcher /> <LanguageSwitcher />
<span>PVEmanager © {year}</span> <span>PVEmanager © {year}</span>
</div> </div>

View File

@@ -231,6 +231,61 @@
"migrate_online": "Online (live) migration", "migrate_online": "Online (live) migration",
"migrate_online_hint_running": "Instance is running — live migration keeps it online during the move.", "migrate_online_hint_running": "Instance is running — live migration keeps it online during the move.",
"migrate_online_hint_stopped": "Instance is stopped — leave off for a regular offline migration.", "migrate_online_hint_stopped": "Instance is stopped — leave off for a regular offline migration.",
"serial_console": "Serial console",
"cloud_init": "Cloud-Init",
"ci_no_drive": "This VM has no Cloud-Init drive — settings will only apply once one is added.",
"ci_saved": "Cloud-Init settings saved",
"ci_password_keep": "Leave blank to keep current",
"ci_ip_mode": "IP mode",
"ci_static": "Static",
"ci_ip_none": "Not set",
"ci_ip_cidr": "IP/CIDR",
"ci_ip_required": "Enter an IP address for static mode",
"ci_nameserver": "nameserver",
"ci_searchdomain": "Search domain",
"ci_reboot_hint": "Cloud-Init applies changes on the next VM reboot.",
"boot_order": "Boot order",
"boot_order_hint": "Devices are tried top to bottom. Disable a device to exclude it from booting.",
"boot_order_saved": "Boot order saved",
"boot_none_enabled": "No boot devices enabled — the VM will not boot.",
"boot_disabled": "Disabled devices",
"boot_enable": "Enable",
"boot_disable": "Disable",
"boot_network": "Network (PXE)",
"vm_options": "VM options",
"vm_options_saved": "VM options saved",
"opt_agent": "QEMU Guest Agent",
"opt_agent_hint": "Enables agent integration (needs qemu-guest-agent inside the VM).",
"opt_tablet": "Tablet pointer",
"opt_tablet_hint": "USB tablet for absolute mouse; disable to save resources on headless VMs.",
"opt_kvm": "KVM hardware virtualization",
"opt_kvm_hint": "Disable only for nested virtualization without KVM support.",
"opt_hotplug": "Hotplug (network, disk, USB)",
"opt_hotplug_hint": "Allow hot-adding devices while the VM is running.",
"unlock": "Unlock",
"unlocked": "Lock removed",
"lock_current": "Current lock: {{lock}}",
"no_lock": "No active lock.",
"cpu_options": "CPU options",
"cpu_saved": "CPU settings saved",
"cpu_type": "CPU type",
"cpu_type_hint": "\"host\" gives best performance but blocks live migration between different CPUs.",
"sockets": "Sockets",
"cpulimit": "CPU limit",
"cpulimit_hint": "Max host CPU cores the VM may use (0 = unlimited).",
"cpuunits": "CPU units (weight)",
"cpuunits_hint": "Scheduler weight relative to other guests (default 100).",
"numa": "Enable NUMA",
"disk_management": "Disk management",
"no_disks": "No disks attached",
"disk_move": "Move disk",
"disk_move_delete": "Delete source after move",
"disk_moved": "Disk move started",
"disk_add": "Add disk",
"disk_added": "Disk {{dev}} added",
"disk_bus": "Bus",
"disk_detach_confirm": "Detach and delete disk {{dev}}? This destroys its data.",
"disk_detached": "Disk detached",
"autostart": "Autostart", "autostart": "Autostart",
"autostart_saved": "Autostart settings saved", "autostart_saved": "Autostart settings saved",
"start_on_boot": "Start on boot", "start_on_boot": "Start on boot",
@@ -382,9 +437,19 @@
"disk": "Disk", "disk": "Disk",
"uptime": "Uptime", "uptime": "Uptime",
"cluster_nodes": "Cluster Nodes", "cluster_nodes": "Cluster Nodes",
"tab_overview": "Overview",
"tab_network": "Network",
"tab_ha": "HA",
"tab_firewall": "Firewall",
"tab_pools": "Pools",
"tab_disks": "Disks",
"tab_admin": "Administration",
"tab_access": "Access",
"vm_list": "Virtual Machines on Server", "vm_list": "Virtual Machines on Server",
"no_servers": "No servers configured", "no_servers": "No servers configured",
"add_server": "Add Server", "add_server": "Add Server",
"updates_available": "Updates available",
"updates_available_count": "Updates available: {{count}}",
"edit_server": "Edit Server", "edit_server": "Edit Server",
"test_connection": "Test Connection", "test_connection": "Test Connection",
"hostname": "Hostname / IP", "hostname": "Hostname / IP",
@@ -667,7 +732,9 @@
"apt_refreshed": "Package lists are refreshing", "apt_refreshed": "Package lists are refreshing",
"up_to_date": "No updates", "up_to_date": "No updates",
"updates_count": "Updates available: {{n}}", "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.", "upgrade_hint": "\"Install updates\" opens the node console running apt dist-upgrade (confirmation required). Keep the tab open until it finishes.",
"do_upgrade": "Install updates",
"upgrade": "Upgrade",
"repo_updated": "Repository updated", "repo_updated": "Repository updated",
"enable": "Enable", "enable": "Enable",
"disable": "Disable" "disable": "Disable"
@@ -1218,6 +1285,8 @@
"connecting": "Connecting to console...", "connecting": "Connecting to console...",
"reconnect": "Reconnect", "reconnect": "Reconnect",
"connected": "Connected", "connected": "Connected",
"error": "Error" "error": "Error",
"terminal_closed": "Terminal connection closed",
"terminal_error": "Terminal connection error"
} }
} }

View File

@@ -231,6 +231,61 @@
"migrate_online": "Онлайн (live) миграция", "migrate_online": "Онлайн (live) миграция",
"migrate_online_hint_running": "Инстанс запущен — live-миграция переносит его без остановки.", "migrate_online_hint_running": "Инстанс запущен — live-миграция переносит его без остановки.",
"migrate_online_hint_stopped": "Инстанс остановлен — оставьте выключенным для обычной офлайн-миграции.", "migrate_online_hint_stopped": "Инстанс остановлен — оставьте выключенным для обычной офлайн-миграции.",
"serial_console": "Serial-консоль",
"cloud_init": "Cloud-Init",
"ci_no_drive": "У этой VM нет диска Cloud-Init — настройки применятся только после его добавления.",
"ci_saved": "Настройки Cloud-Init сохранены",
"ci_password_keep": "Пусто — не менять",
"ci_ip_mode": "Режим IP",
"ci_static": "Статический",
"ci_ip_none": "Не задан",
"ci_ip_cidr": "IP/CIDR",
"ci_ip_required": "Укажите IP-адрес для статического режима",
"ci_nameserver": "nameserver",
"ci_searchdomain": "Домен поиска",
"ci_reboot_hint": "Cloud-Init применяет изменения при следующей перезагрузке VM.",
"boot_order": "Порядок загрузки",
"boot_order_hint": "Устройства пробуются сверху вниз. Отключите устройство, чтобы исключить из загрузки.",
"boot_order_saved": "Порядок загрузки сохранён",
"boot_none_enabled": "Нет активных устройств загрузки — VM не загрузится.",
"boot_disabled": "Отключённые устройства",
"boot_enable": "Включить",
"boot_disable": "Отключить",
"boot_network": "Сеть (PXE)",
"vm_options": "Опции VM",
"vm_options_saved": "Опции VM сохранены",
"opt_agent": "QEMU Guest Agent",
"opt_agent_hint": "Включает интеграцию с агентом (нужен qemu-guest-agent внутри VM).",
"opt_tablet": "Tablet-указатель",
"opt_tablet_hint": "USB-планшет для абсолютной мыши; отключите для экономии ресурсов на headless VM.",
"opt_kvm": "Аппаратная виртуализация KVM",
"opt_kvm_hint": "Отключайте только для вложенной виртуализации без поддержки KVM.",
"opt_hotplug": "Hotplug (сеть, диск, USB)",
"opt_hotplug_hint": "Разрешить горячее добавление устройств на работающей VM.",
"unlock": "Снять блокировку",
"unlocked": "Блокировка снята",
"lock_current": "Текущая блокировка: {{lock}}",
"no_lock": "Активной блокировки нет.",
"cpu_options": "Опции CPU",
"cpu_saved": "Настройки CPU сохранены",
"cpu_type": "Тип CPU",
"cpu_type_hint": "«host» даёт максимум производительности, но мешает live-миграции между разными CPU.",
"sockets": "Сокеты",
"cpulimit": "Лимит CPU",
"cpulimit_hint": "Макс. число ядер хоста для VM (0 — без лимита).",
"cpuunits": "Вес CPU (units)",
"cpuunits_hint": "Вес планировщика относительно других гостей (по умолчанию 100).",
"numa": "Включить NUMA",
"disk_management": "Управление дисками",
"no_disks": "Диски не подключены",
"disk_move": "Переместить диск",
"disk_move_delete": "Удалить исходный после перемещения",
"disk_moved": "Перемещение диска запущено",
"disk_add": "Добавить диск",
"disk_added": "Диск {{dev}} добавлен",
"disk_bus": "Шина",
"disk_detach_confirm": "Отключить и удалить диск {{dev}}? Данные будут уничтожены.",
"disk_detached": "Диск отключён",
"autostart": "Автозапуск", "autostart": "Автозапуск",
"autostart_saved": "Настройки автозапуска сохранены", "autostart_saved": "Настройки автозапуска сохранены",
"start_on_boot": "Запускать при загрузке", "start_on_boot": "Запускать при загрузке",
@@ -382,9 +437,19 @@
"disk": "Диск", "disk": "Диск",
"uptime": "Аптайм", "uptime": "Аптайм",
"cluster_nodes": "Узлы кластера", "cluster_nodes": "Узлы кластера",
"tab_overview": "Обзор",
"tab_network": "Сеть",
"tab_ha": "HA",
"tab_firewall": "Firewall",
"tab_pools": "Пулы",
"tab_disks": "Диски",
"tab_admin": "Администрирование",
"tab_access": "Доступ",
"vm_list": "Виртуальные машины на сервере", "vm_list": "Виртуальные машины на сервере",
"no_servers": "Серверы не настроены", "no_servers": "Серверы не настроены",
"add_server": "Добавить сервер", "add_server": "Добавить сервер",
"updates_available": "Доступны обновления",
"updates_available_count": "Доступно обновлений: {{count}}",
"edit_server": "Редактировать сервер", "edit_server": "Редактировать сервер",
"test_connection": "Проверить соединение", "test_connection": "Проверить соединение",
"hostname": "Хост / IP", "hostname": "Хост / IP",
@@ -667,7 +732,9 @@
"apt_refreshed": "Списки пакетов обновляются", "apt_refreshed": "Списки пакетов обновляются",
"up_to_date": "Обновлений нет", "up_to_date": "Обновлений нет",
"updates_count": "Доступно обновлений: {{n}}", "updates_count": "Доступно обновлений: {{n}}",
"upgrade_hint": "Установка обновлений выполняется в консоли ноды (apt dist-upgrade) — API не поддерживает неинтерактивный upgrade.", "upgrade_hint": "«Установить обновления» откроет консоль ноды с apt dist-upgrade (потребуется подтверждение). Не закрывайте вкладку до завершения.",
"do_upgrade": "Установить обновления",
"upgrade": "Upgrade",
"repo_updated": "Репозиторий обновлён", "repo_updated": "Репозиторий обновлён",
"enable": "Включить", "enable": "Включить",
"disable": "Выключить" "disable": "Выключить"
@@ -1218,6 +1285,110 @@
"connecting": "Подключение к консоли...", "connecting": "Подключение к консоли...",
"reconnect": "Переподключить", "reconnect": "Переподключить",
"connected": "Подключено", "connected": "Подключено",
"error": "Ошибка" "error": "Ошибка",
"terminal_closed": "Соединение с терминалом закрыто",
"terminal_error": "Ошибка соединения с терминалом"
},
"perms": {
"role": {
"admin": { "name": "Администратор", "desc": "Полный доступ ко всем функциям" },
"moderator": { "name": "Модератор", "desc": "Управление ВМ и просмотр логов" },
"user": { "name": "Пользователь", "desc": "Стандартный пользователь с ограниченным доступом" },
"demo": { "name": "Демо-пользователь", "desc": "Доступ только для чтения (демонстрация)" }
},
"cat": {
"Dashboard": "Дашборд",
"Proxmox Servers": "Серверы Proxmox",
"Virtual Machines": "Виртуальные машины",
"Containers": "Контейнеры",
"Templates": "Шаблоны",
"Storage": "Хранилище",
"Backups": "Резервные копии",
"IPAM": "IPAM",
"User Management": "Управление пользователями",
"Role Management": "Управление ролями",
"Logs": "Логи",
"Settings": "Настройки",
"Notifications": "Уведомления",
"Firewall": "Firewall",
"Networking": "Сеть",
"Node Administration": "Администрирование ноды",
"Resource Pools": "Пулы ресурсов",
"PVE Access": "Доступ PVE"
},
"label": {
"dashboard_view": "Просмотр дашборда",
"server_view": "Просмотр серверов",
"server_create": "Добавление сервера",
"server_update": "Редактирование сервера",
"server_delete": "Удаление сервера",
"server_manage": "Управление серверами",
"cluster_manage": "Управление топологией кластера",
"vm_view": "Просмотр ВМ",
"vm_create": "Создание ВМ",
"vm_update": "Изменение ВМ",
"vm_delete": "Удаление ВМ",
"vm_start": "Запуск ВМ",
"vm_stop": "Остановка ВМ",
"vm_restart": "Перезапуск ВМ",
"vm_console": "Консоль ВМ",
"vm_migrate": "Миграция ВМ",
"vm_execute": "Выполнение команд",
"vm_manage": "Управление ВМ",
"lxc_view": "Просмотр контейнеров",
"lxc_create": "Создание контейнеров",
"lxc_update": "Изменение контейнеров",
"lxc_delete": "Удаление контейнеров",
"lxc_start": "Запуск контейнеров",
"lxc_stop": "Остановка контейнеров",
"lxc_restart": "Перезапуск контейнеров",
"lxc_console": "Консоль контейнера",
"lxc_migrate": "Миграция контейнеров",
"template_view": "Просмотр шаблонов",
"template_create": "Создание шаблонов",
"template_update": "Изменение шаблонов",
"template_delete": "Удаление шаблонов",
"template_manage": "Управление шаблонами",
"storage_view": "Просмотр хранилища",
"storage_manage": "Управление хранилищем",
"backup_view": "Просмотр бэкапов",
"backup_create": "Создание бэкапов",
"backup_restore": "Восстановление бэкапов",
"backup_delete": "Удаление бэкапов",
"backup_manage": "Управление бэкапами",
"ipam_view": "Просмотр IPAM",
"ipam_manage": "Управление IPAM",
"user_view": "Просмотр пользователей",
"user_create": "Создание пользователей",
"user_update": "Изменение пользователей",
"user_delete": "Удаление пользователей",
"user_manage": "Управление пользователями",
"quota_view": "Просмотр квот",
"quota_manage": "Управление квотами",
"role_view": "Просмотр ролей",
"role_create": "Создание ролей",
"role_update": "Изменение ролей",
"role_delete": "Удаление ролей",
"role_manage": "Управление ролями",
"log_view": "Просмотр логов",
"log_export": "Экспорт логов",
"log_delete": "Удаление логов",
"setting_view": "Просмотр настроек",
"setting_update": "Изменение настроек",
"setting_manage": "Управление безопасностью",
"notification_view": "Просмотр уведомлений",
"notification_manage": "Управление уведомлениями",
"firewall_view": "Просмотр Firewall",
"firewall_manage": "Управление Firewall",
"network_view": "Просмотр сети",
"network_manage": "Управление сетью",
"node_view": "Просмотр администрирования ноды",
"node_manage": "Управление администрированием ноды",
"node_upgrade": "Обновление пакетов ноды",
"pool_view": "Просмотр пулов",
"pool_manage": "Управление пулами",
"access_view": "Просмотр доступа PVE",
"access_manage": "Управление доступом PVE"
}
} }
} }

View File

@@ -12,6 +12,7 @@ import InstancesPage from '@/features/instances/InstancesPage';
import InstanceDetailPage from '@/features/instances/InstanceDetailPage'; import InstanceDetailPage from '@/features/instances/InstanceDetailPage';
const SnapshotArchivesPage = lazy(() => import('./features/instances/SnapshotArchivesPage.tsx')); const SnapshotArchivesPage = lazy(() => import('./features/instances/SnapshotArchivesPage.tsx'));
import ConsolePage from '@/features/console/ConsolePage'; import ConsolePage from '@/features/console/ConsolePage';
const NodeShellPage = lazy(() => import('./features/console/NodeShellPage.tsx'));
// Lazy-loaded pages // Lazy-loaded pages
// NOTE: явные относительные пути с расширением .tsx — обход бага rolldown-vite // NOTE: явные относительные пути с расширением .tsx — обход бага rolldown-vite
@@ -61,6 +62,11 @@ export const router = createBrowserRouter([
element: <ProtectedRoute />, element: <ProtectedRoute />,
children: [ children: [
// Console — separate layout (no sidebar) // Console — separate layout (no sidebar)
{
path: '/console/node/:serverId/:node',
element: <ConsoleLayout />,
children: [{ index: true, element: <SuspenseWrap><NodeShellPage /></SuspenseWrap> }],
},
{ {
path: '/console/:serverId/:vmid', path: '/console/:serverId/:vmid',
element: <ConsoleLayout />, element: <ConsoleLayout />,