feat: Proxmox management suite — hardware, firewall, pools, disks, storage, access, node admin
- 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:
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 ====================
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
"""
|
"""
|
||||||
Расширить раздел и файловую систему внутри гостя после ресайза диска.
|
Расширить раздел и файловую систему внутри гостя после ресайза диска.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
BIN
frontend/src/assets/auth/auth-bg-dark.png
Normal file
BIN
frontend/src/assets/auth/auth-bg-dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
BIN
frontend/src/assets/auth/auth-bg-light.png
Normal file
BIN
frontend/src/assets/auth/auth-bg-light.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
201
frontend/src/features/console/NodeShellPage.tsx
Normal file
201
frontend/src/features/console/NodeShellPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 образ')}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
75
frontend/src/features/nodes/ServerUpdatesBadge.tsx
Normal file
75
frontend/src/features/nodes/ServerUpdatesBadge.tsx
Normal 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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 />,
|
||||||
|
|||||||
Reference in New Issue
Block a user