feat: v1.1.8 — user-server assignment, VM/LXC ownership, smart login redirect, RBAC migrations
- Migration 20: user_servers association table (User ↔ ProxmoxServer)
- API: GET/PUT /api/users/{id}/servers — assign servers to users with workspace-conflict validation
- API: GET /api/users/{id}/server-assignments — enriched server list (assigned + compatible flags)
- Non-admin server filter: intersection of assigned_servers and workspace servers
- Migration 21: dashboard:view = false for user role (new key format)
- Migration 22: proxmox.cluster.manage → cluster:manage for admin role
- API: GET/PUT /api/{server_id}/vm/{vmid}/owner — VM/LXC owner assignment (admin-only)
- Login: smart redirect based on user permissions after authentication
- Login: returnUrl param support for post-login redirect
- Roles API: user_count field per role in GET /api/roles
- node_uptime added to server detail API response
- Remove redundant PermissionChecker from GET /users HTML route
This commit is contained in:
34
CHANGELOG.md
34
CHANGELOG.md
@@ -4,6 +4,40 @@ All notable changes to PVEmanager will be documented in this file.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [v1.1.8] - 2026-03-16
|
||||||
|
|
||||||
|
### 👤 User → Server Assignment
|
||||||
|
|
||||||
|
- **`user_servers` table** (Migration 20) — новая таблица-связка `User ↔ ProxmoxServer`; позволяет напрямую закреплять конкретные серверы за пользователями
|
||||||
|
- **API `GET /api/users/{id}/servers`** — список серверов, назначенных пользователю
|
||||||
|
- **API `GET /api/users/{id}/server-assignments`** — расширенный список всех серверов с флагами `assigned` и `compatible` (совместимость по воркспейсам)
|
||||||
|
- **API `PUT /api/users/{id}/servers`** — установка набора серверов для пользователя; проверяет пересечение воркспейсов — если сервер и пользователь не состоят ни в одном общем воркспейсе, возвращает `409 workspace_conflict`
|
||||||
|
- **Фильтрация серверов для non-admin** — обычный пользователь видит только серверы, которые ему назначены **и** входят в его воркспейс
|
||||||
|
|
||||||
|
### 🖥️ VM / LXC Owner
|
||||||
|
|
||||||
|
- **API `GET /api/{server_id}/vm/{vmid}/owner`** — получить текущего владельца VM/LXC и список доступных пользователей
|
||||||
|
- **API `PUT /api/{server_id}/vm/{vmid}/owner`** — назначить или снять владельца (admin-only); действие логируется в audit
|
||||||
|
|
||||||
|
### 🔐 RBAC & Роли
|
||||||
|
|
||||||
|
- **Migration 21** — `dashboard:view` принудительно выставлен в `false` для роли `user` (новый формат ключей)
|
||||||
|
- **Migration 22** — ключ `proxmox.cluster.manage` → `cluster:manage` в роли `admin`; старый dot-формат удаляется, чтобы не сбрасывать весь permission-map
|
||||||
|
- **`user_count`** в ответе `GET /api/roles` — для каждой роли возвращается количество активных пользователей
|
||||||
|
|
||||||
|
### 🚀 Умный редирект после логина
|
||||||
|
|
||||||
|
- После успешного входа производится запрос `GET /api/auth/me` — пользователь автоматически перенаправляется на первую страницу, к которой у него есть доступ (`/dashboard` → `/virtual-machines` → `/vms` → `/backups` → `/ipam` → `/logs` → `/settings`)
|
||||||
|
- Поддержка параметра `?returnUrl=` — возврат на запрошенную страницу после логина
|
||||||
|
|
||||||
|
### 🔧 Прочие улучшения
|
||||||
|
|
||||||
|
- `GET /api/servers` для не-привилегированного пользователя объединяет фильтры `assigned_servers` и `WorkspaceServer` — пользователь видит только серверы из своего воркспейса, которые ему назначены
|
||||||
|
- `node_uptime` добавлен в ответ детального API сервера
|
||||||
|
- Удалена лишняя проверка `PermissionChecker` на GET-роуте страницы `/users` (HTML)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [v1.1.7] - 2026-03-16
|
## [v1.1.7] - 2026-03-16
|
||||||
|
|
||||||
### 🌍 i18n — Translation Files Refactored
|
### 🌍 i18n — Translation Files Refactored
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
**Modern web panel for managing Proxmox servers, virtual machines and LXC containers**
|
**Modern web panel for managing Proxmox servers, virtual machines and LXC containers**
|
||||||
|
|
||||||
[](CHANGELOG.md)
|
[](CHANGELOG.md)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](compose.yml)
|
[](compose.yml)
|
||||||
[](backend/)
|
[](backend/)
|
||||||
@@ -34,7 +34,10 @@ PVEmanager is a self-hosted web panel that provides a unified interface for mana
|
|||||||
- **xterm.js terminal** — full interactive shell directly in the browser for LXC containers
|
- **xterm.js terminal** — full interactive shell directly in the browser for LXC containers
|
||||||
- **Remote commands** — execute bash commands via QEMU Guest Agent
|
- **Remote commands** — execute bash commands via QEMU Guest Agent
|
||||||
- **RBAC v2** — granular role-based access control with per-resource permissions
|
- **RBAC v2** — granular role-based access control with per-resource permissions
|
||||||
|
- **Server assignment** — directly assign specific Proxmox servers to users with workspace-conflict validation
|
||||||
|
- **VM/LXC ownership** — assign an owner user to any VM or LXC container
|
||||||
- **Session management** — active session list, remote session revocation, login protection
|
- **Session management** — active session list, remote session revocation, login protection
|
||||||
|
- **Smart login redirect** — users are automatically routed to the first page they have access to
|
||||||
|
|
||||||
### Monitoring & Alerts
|
### Monitoring & Alerts
|
||||||
- **Real-time metrics** — CPU, RAM, Disk, Network charts per node and per VM
|
- **Real-time metrics** — CPU, RAM, Disk, Network charts per node and per VM
|
||||||
|
|||||||
64
WIKI.md
64
WIKI.md
@@ -1,6 +1,6 @@
|
|||||||
# 📖 PVEmanager - Documentation
|
# 📖 PVEmanager - Documentation
|
||||||
|
|
||||||
> Complete guide for installation, configuration and usage of PVEmanager v1.1.7
|
> Complete guide for installation, configuration and usage of PVEmanager v1.1.8
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -25,10 +25,12 @@
|
|||||||
17. [pve CLI Tool](#-pve-cli-tool)
|
17. [pve CLI Tool](#-pve-cli-tool)
|
||||||
18. [API Reference](#-api-reference)
|
18. [API Reference](#-api-reference)
|
||||||
19. [Workspaces](#-workspaces)
|
19. [Workspaces](#-workspaces)
|
||||||
20. [Deployment Guide](#-installation-and-deployment)
|
20. [User → Server Assignment](#-user--server-assignment)
|
||||||
21. [Private Repo Updates](#-settings)
|
21. [VM / LXC Ownership](#-vm--lxc-ownership)
|
||||||
22. [Troubleshooting](#-troubleshooting)
|
22. [Deployment Guide](#-installation-and-deployment)
|
||||||
23. [FAQ](#-faq)
|
23. [Private Repo Updates](#-settings)
|
||||||
|
24. [Troubleshooting](#-troubleshooting)
|
||||||
|
25. [FAQ](#-faq)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1454,7 +1456,53 @@ Pass the `X-Active-Workspace: {id}` header in API requests to filter responses b
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## <EFBFBD>🔧 Troubleshooting
|
## 👤 User → Server Assignment
|
||||||
|
|
||||||
|
Server assignment allows admins to directly link specific Proxmox servers to individual users. In combination with Workspaces it gives fine-grained control: a user only sees servers that are **both** in their workspace **and** explicitly assigned to them.
|
||||||
|
|
||||||
|
### How it works
|
||||||
|
|
||||||
|
1. User belongs to one or more **Workspaces**
|
||||||
|
2. Admin assigns specific **servers** to the user (from servers that share at least one workspace with the user)
|
||||||
|
3. The user sees only the intersection: workspace-servers ∩ assigned-servers
|
||||||
|
|
||||||
|
### Managing assignments (UI)
|
||||||
|
|
||||||
|
1. Open **Users** page
|
||||||
|
2. Click **Edit** for a user
|
||||||
|
3. In the **Servers** tab — check/uncheck servers; incompatible servers (different workspace) are highlighted
|
||||||
|
4. Save — assignments are applied immediately
|
||||||
|
|
||||||
|
### REST API
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `GET` | `/api/users/{id}/servers` | Assigned servers for a user |
|
||||||
|
| `GET` | `/api/users/{id}/server-assignments` | All servers with `assigned` and `compatible` flags |
|
||||||
|
| `PUT` | `/api/users/{id}/servers` | Set servers (replaces current list); body: `{"server_ids": [1,2]}` |
|
||||||
|
|
||||||
|
Returns `409 workspace_conflict` if any server shares no workspace with the user.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🖥️ VM / LXC Ownership
|
||||||
|
|
||||||
|
Any VM or LXC container can have an **owner** — a regular user responsible for that instance. The owner is displayed in the VM list.
|
||||||
|
|
||||||
|
### Assigning an owner (Admin)
|
||||||
|
|
||||||
|
Open the VM detail page → **Owner** button → select user → Save.
|
||||||
|
|
||||||
|
### REST API
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `GET` | `/api/{server_id}/vm/{vmid}/owner` | Current owner + list of users |
|
||||||
|
| `PUT` | `/api/{server_id}/vm/{vmid}/owner` | Set or clear owner (`{"user_id": null}` to clear) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Troubleshooting
|
||||||
|
|
||||||
### Startup Issues
|
### Startup Issues
|
||||||
|
|
||||||
@@ -1684,5 +1732,5 @@ A: Not yet, but planned for future versions.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Last updated: March 10, 2026*
|
*Last updated: March 16, 2026*
|
||||||
*Version: 1.1.3*
|
*Version: 1.1.8*
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from datetime import datetime, timedelta
|
|||||||
from ..db import get_db
|
from ..db import get_db
|
||||||
from ..models import ProxmoxServer, AuditLog, User
|
from ..models import ProxmoxServer, AuditLog, User
|
||||||
from ..template_helpers import add_i18n_context
|
from ..template_helpers import add_i18n_context
|
||||||
from ..auth import get_current_user, PermissionChecker
|
from ..auth import PermissionChecker
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
templates = Jinja2Templates(directory="app/templates")
|
templates = Jinja2Templates(directory="app/templates")
|
||||||
@@ -63,7 +63,7 @@ def dashboard(request: Request, db: Session = Depends(get_db)):
|
|||||||
standalone_q = standalone_q.filter(ProxmoxServer.id.in_(server_ids))
|
standalone_q = standalone_q.filter(ProxmoxServer.id.in_(server_ids))
|
||||||
named_clusters = cluster_q.scalar() or 0
|
named_clusters = cluster_q.scalar() or 0
|
||||||
standalone_count = standalone_q.count()
|
standalone_count = standalone_q.count()
|
||||||
total_clusters = named_clusters + standalone_count
|
total_clusters = named_clusters
|
||||||
|
|
||||||
# Alerts in last 24 hours
|
# Alerts in last 24 hours
|
||||||
since_24h = datetime.utcnow() - timedelta(hours=24)
|
since_24h = datetime.utcnow() - timedelta(hours=24)
|
||||||
|
|||||||
@@ -92,12 +92,50 @@ def list_proxmox_servers(
|
|||||||
):
|
):
|
||||||
"""Получить список всех Proxmox серверов (фильтруется по активному workspace)"""
|
"""Получить список всех Proxmox серверов (фильтруется по активному workspace)"""
|
||||||
from ...api.workspaces import get_workspace_server_ids
|
from ...api.workspaces import get_workspace_server_ids
|
||||||
server_ids = get_workspace_server_ids(request, db, current_user)
|
from ...models import WorkspaceUser, WorkspaceServer
|
||||||
query = db.query(ProxmoxServer)
|
|
||||||
if server_ids is not None:
|
is_privileged = current_user.is_admin or (
|
||||||
query = query.filter(ProxmoxServer.id.in_(server_ids))
|
current_user.role and current_user.role.name in ('admin', 'moderator')
|
||||||
servers = query.all()
|
)
|
||||||
return servers
|
|
||||||
|
if is_privileged:
|
||||||
|
# Privileged: use workspace filter only
|
||||||
|
server_ids = get_workspace_server_ids(request, db, current_user)
|
||||||
|
query = db.query(ProxmoxServer)
|
||||||
|
if server_ids is not None:
|
||||||
|
query = query.filter(ProxmoxServer.id.in_(server_ids))
|
||||||
|
return query.all()
|
||||||
|
|
||||||
|
# Non-privileged user: must be directly assigned AND server must be in one of their workspaces
|
||||||
|
assigned_ids = {s.id for s in current_user.assigned_servers}
|
||||||
|
|
||||||
|
# Collect server IDs accessible through user's workspaces
|
||||||
|
user_ws_ids = {
|
||||||
|
r.workspace_id for r in
|
||||||
|
db.query(WorkspaceUser.workspace_id).filter(WorkspaceUser.user_id == current_user.id).all()
|
||||||
|
}
|
||||||
|
if user_ws_ids:
|
||||||
|
ws_server_ids = {
|
||||||
|
r.server_id for r in
|
||||||
|
db.query(WorkspaceServer.server_id).filter(
|
||||||
|
WorkspaceServer.workspace_id.in_(user_ws_ids)
|
||||||
|
).all()
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
ws_server_ids = set()
|
||||||
|
|
||||||
|
# Intersect: directly assigned AND within user's workspaces
|
||||||
|
effective_ids = assigned_ids & ws_server_ids
|
||||||
|
|
||||||
|
# Also apply active-workspace header filter on top
|
||||||
|
ws_filter = get_workspace_server_ids(request, db, current_user)
|
||||||
|
if ws_filter is not None:
|
||||||
|
effective_ids = effective_ids & set(ws_filter)
|
||||||
|
|
||||||
|
if not effective_ids:
|
||||||
|
return []
|
||||||
|
|
||||||
|
return db.query(ProxmoxServer).filter(ProxmoxServer.id.in_(effective_ids)).all()
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/servers", response_model=ProxmoxServerResponse, status_code=status.HTTP_201_CREATED)
|
@router.post("/api/servers", response_model=ProxmoxServerResponse, status_code=status.HTTP_201_CREATED)
|
||||||
@@ -601,6 +639,22 @@ def get_server_resources(
|
|||||||
vms = [vm for vm in resources.get('vms', []) if not vm.get('template', 0)]
|
vms = [vm for vm in resources.get('vms', []) if not vm.get('template', 0)]
|
||||||
containers = [ct for ct in resources.get('containers', []) if not ct.get('template', 0)]
|
containers = [ct for ct in resources.get('containers', []) if not ct.get('template', 0)]
|
||||||
|
|
||||||
|
# Получаем uptime ноды
|
||||||
|
node_uptime = None
|
||||||
|
try:
|
||||||
|
client = _get_proxmox_client(server)
|
||||||
|
nodes = client.get_nodes()
|
||||||
|
if nodes:
|
||||||
|
target_node = node_filter or (nodes[0].get('node') if nodes else None)
|
||||||
|
for n in nodes:
|
||||||
|
if target_node and n.get('node') == target_node:
|
||||||
|
node_uptime = n.get('uptime')
|
||||||
|
break
|
||||||
|
if node_uptime is None and nodes:
|
||||||
|
node_uptime = nodes[0].get('uptime')
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
server.update_status(True)
|
server.update_status(True)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
@@ -608,7 +662,8 @@ def get_server_resources(
|
|||||||
"server_id": server_id,
|
"server_id": server_id,
|
||||||
"server_name": server.name,
|
"server_name": server.name,
|
||||||
"vms": vms,
|
"vms": vms,
|
||||||
"containers": containers
|
"containers": containers,
|
||||||
|
"node_uptime": node_uptime
|
||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
server.update_status(False, str(e))
|
server.update_status(False, str(e))
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ class BulkOperationRequest(BaseModel):
|
|||||||
items: TypingList[BulkOperationItem]
|
items: TypingList[BulkOperationItem]
|
||||||
|
|
||||||
|
|
||||||
|
class VMOwnerUpdate(BaseModel):
|
||||||
|
user_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/sync-vms")
|
@router.post("/api/sync-vms")
|
||||||
def sync_vms_now(
|
def sync_vms_now(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
@@ -2793,3 +2797,73 @@ def create_bulk_operation(
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# ===================== VM Owner Management =====================
|
||||||
|
|
||||||
|
@router.get("/api/{server_id}/vm/{vmid}/owner")
|
||||||
|
def get_vm_owner(
|
||||||
|
server_id: int,
|
||||||
|
vmid: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Get current owner of a VM/LXC. Admin only."""
|
||||||
|
if not current_user.is_admin:
|
||||||
|
raise HTTPException(status_code=403, detail="Admin required")
|
||||||
|
|
||||||
|
instance = db.query(VMInstance).filter(
|
||||||
|
VMInstance.server_id == server_id,
|
||||||
|
VMInstance.vmid == vmid,
|
||||||
|
VMInstance.deleted_at.is_(None)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
owner = None
|
||||||
|
if instance and instance.owner_id:
|
||||||
|
owner_user = db.query(User).filter(User.id == instance.owner_id).first()
|
||||||
|
if owner_user:
|
||||||
|
owner = {"id": owner_user.id, "username": owner_user.username, "full_name": owner_user.full_name}
|
||||||
|
|
||||||
|
users = (
|
||||||
|
db.query(User)
|
||||||
|
.filter(User.is_active == True, User.is_admin == False)
|
||||||
|
.order_by(User.username)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"owner_id": instance.owner_id if instance else None,
|
||||||
|
"owner": owner,
|
||||||
|
"users": [{"id": u.id, "username": u.username, "full_name": u.full_name} for u in users]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/api/{server_id}/vm/{vmid}/owner")
|
||||||
|
def set_vm_owner(
|
||||||
|
server_id: int,
|
||||||
|
vmid: int,
|
||||||
|
body: VMOwnerUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""Set (or clear) the owner of a VM/LXC. Admin only."""
|
||||||
|
if not current_user.is_admin:
|
||||||
|
raise HTTPException(status_code=403, detail="Admin required")
|
||||||
|
|
||||||
|
instance = db.query(VMInstance).filter(
|
||||||
|
VMInstance.server_id == server_id,
|
||||||
|
VMInstance.vmid == vmid,
|
||||||
|
VMInstance.deleted_at.is_(None)
|
||||||
|
).first()
|
||||||
|
if not instance:
|
||||||
|
raise HTTPException(status_code=404, detail="VM not found in cache")
|
||||||
|
|
||||||
|
if body.user_id is not None:
|
||||||
|
target_user = db.query(User).filter(User.id == body.user_id, User.is_active == True).first()
|
||||||
|
if not target_user:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
instance.owner_id = body.user_id
|
||||||
|
db.commit()
|
||||||
|
logger.info(f"Admin {current_user.username} set owner of VM {vmid} (server {server_id}) to user_id={body.user_id}")
|
||||||
|
return {"ok": True, "owner_id": body.user_id}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from pydantic import BaseModel, Field, EmailStr
|
|||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from ..db import get_db
|
from ..db import get_db
|
||||||
from ..models import User, Role
|
from ..models import User, Role, ProxmoxServer, Workspace, WorkspaceServer, WorkspaceUser
|
||||||
from ..auth import (
|
from ..auth import (
|
||||||
get_password_hash,
|
get_password_hash,
|
||||||
get_current_user,
|
get_current_user,
|
||||||
@@ -75,6 +75,7 @@ class RoleResponse(BaseModel):
|
|||||||
is_system: bool
|
is_system: bool
|
||||||
is_active: bool
|
is_active: bool
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
user_count: int = 0
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
@@ -140,7 +141,6 @@ class SecuritySettingsUpdate(BaseModel):
|
|||||||
@router.get("/", response_class=HTMLResponse)
|
@router.get("/", response_class=HTMLResponse)
|
||||||
async def users_page(
|
async def users_page(
|
||||||
request: Request,
|
request: Request,
|
||||||
current_user: User = Depends(PermissionChecker("users.view"))
|
|
||||||
):
|
):
|
||||||
"""Render users management page"""
|
"""Render users management page"""
|
||||||
from ..i18n import t
|
from ..i18n import t
|
||||||
@@ -162,8 +162,31 @@ async def list_roles(
|
|||||||
current_user: User = Depends(PermissionChecker("roles.view"))
|
current_user: User = Depends(PermissionChecker("roles.view"))
|
||||||
):
|
):
|
||||||
"""Get all roles"""
|
"""Get all roles"""
|
||||||
|
from sqlalchemy import func
|
||||||
roles = SecurityService.get_all_roles(db, include_inactive=True)
|
roles = SecurityService.get_all_roles(db, include_inactive=True)
|
||||||
return roles
|
|
||||||
|
# Count users per role
|
||||||
|
counts = dict(
|
||||||
|
db.query(User.role_id, func.count(User.id))
|
||||||
|
.filter(User.role_id.isnot(None))
|
||||||
|
.group_by(User.role_id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for role in roles:
|
||||||
|
result.append({
|
||||||
|
"id": role.id,
|
||||||
|
"name": role.name,
|
||||||
|
"display_name": role.display_name,
|
||||||
|
"description": role.description,
|
||||||
|
"permissions": role.permissions or {},
|
||||||
|
"is_system": role.is_system,
|
||||||
|
"is_active": role.is_active,
|
||||||
|
"created_at": role.created_at,
|
||||||
|
"user_count": counts.get(role.id, 0),
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/roles/{role_id}", response_model=RoleResponse)
|
@router.get("/api/roles/{role_id}", response_model=RoleResponse)
|
||||||
@@ -585,6 +608,162 @@ async def delete_user(
|
|||||||
return {"message": "User deleted"}
|
return {"message": "User deleted"}
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Server Assignment API ====================
|
||||||
|
|
||||||
|
def _get_user_workspace_ids(db: Session, user_id: int) -> set:
|
||||||
|
"""Return set of workspace IDs the user belongs to."""
|
||||||
|
rows = db.query(WorkspaceUser.workspace_id).filter(WorkspaceUser.user_id == user_id).all()
|
||||||
|
return {r.workspace_id for r in rows}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_server_workspace_info(db: Session, server_id: int):
|
||||||
|
"""Return list of {id, name} workspaces that own this server."""
|
||||||
|
rows = (
|
||||||
|
db.query(WorkspaceServer.workspace_id, Workspace.name)
|
||||||
|
.join(Workspace, Workspace.id == WorkspaceServer.workspace_id)
|
||||||
|
.filter(WorkspaceServer.server_id == server_id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [{"id": r.workspace_id, "name": r.name} for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/users/{user_id}/servers")
|
||||||
|
async def get_user_servers(
|
||||||
|
user_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(PermissionChecker("users.view"))
|
||||||
|
):
|
||||||
|
"""Get list of Proxmox servers assigned to a specific user"""
|
||||||
|
user = db.query(User).filter(User.id == user_id).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
return [
|
||||||
|
{"id": s.id, "name": s.name, "hostname": s.hostname, "ip_address": s.ip_address, "is_online": s.is_online}
|
||||||
|
for s in user.assigned_servers
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/users/{user_id}/server-assignments")
|
||||||
|
async def get_user_server_assignments(
|
||||||
|
user_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(PermissionChecker("users.view"))
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Return all Proxmox servers enriched with:
|
||||||
|
- workspaces each server belongs to
|
||||||
|
- whether the server is compatible with the user (user is in at least one of its workspaces)
|
||||||
|
- whether the server is currently assigned to the user
|
||||||
|
"""
|
||||||
|
user = db.query(User).filter(User.id == user_id).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
user_ws_ids = _get_user_workspace_ids(db, user_id)
|
||||||
|
assigned_ids = {s.id for s in user.assigned_servers}
|
||||||
|
|
||||||
|
# User's own workspaces info
|
||||||
|
user_workspaces = [
|
||||||
|
{"id": ws.id, "name": ws.name}
|
||||||
|
for ws in db.query(Workspace).filter(Workspace.id.in_(user_ws_ids)).all()
|
||||||
|
] if user_ws_ids else []
|
||||||
|
|
||||||
|
all_servers = db.query(ProxmoxServer).all()
|
||||||
|
result = []
|
||||||
|
for server in all_servers:
|
||||||
|
server_workspaces = _get_server_workspace_info(db, server.id)
|
||||||
|
server_ws_ids = {w["id"] for w in server_workspaces}
|
||||||
|
# Compatible = user's workspaces and server's workspaces intersect
|
||||||
|
compatible = bool(user_ws_ids & server_ws_ids) if (user_ws_ids and server_ws_ids) else False
|
||||||
|
result.append({
|
||||||
|
"id": server.id,
|
||||||
|
"name": server.name,
|
||||||
|
"ip_address": server.ip_address,
|
||||||
|
"is_online": server.is_online,
|
||||||
|
"workspaces": server_workspaces,
|
||||||
|
"compatible": compatible,
|
||||||
|
"assigned": server.id in assigned_ids,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"servers": result,
|
||||||
|
"user_workspaces": user_workspaces,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/api/users/{user_id}/servers")
|
||||||
|
async def set_user_servers(
|
||||||
|
user_id: int,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(PermissionChecker("users.edit"))
|
||||||
|
):
|
||||||
|
"""Assign a list of Proxmox servers to a user (replaces current assignment).
|
||||||
|
Rejects the operation if any server's workspaces don't intersect with the user's workspaces.
|
||||||
|
"""
|
||||||
|
user = db.query(User).filter(User.id == user_id).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
data = await request.json()
|
||||||
|
server_ids = data.get("server_ids", [])
|
||||||
|
|
||||||
|
if not server_ids:
|
||||||
|
user.assigned_servers = []
|
||||||
|
db.commit()
|
||||||
|
return {"message": "Servers cleared", "server_ids": []}
|
||||||
|
|
||||||
|
servers = db.query(ProxmoxServer).filter(ProxmoxServer.id.in_(server_ids)).all()
|
||||||
|
|
||||||
|
# Workspace compatibility validation
|
||||||
|
user_ws_ids = _get_user_workspace_ids(db, user_id)
|
||||||
|
conflicts = []
|
||||||
|
for server in servers:
|
||||||
|
server_workspaces = _get_server_workspace_info(db, server.id)
|
||||||
|
server_ws_ids = {w["id"] for w in server_workspaces}
|
||||||
|
# No overlap between user workspaces and server workspaces → conflict
|
||||||
|
if user_ws_ids and server_ws_ids and not (user_ws_ids & server_ws_ids):
|
||||||
|
user_workspaces_info = [
|
||||||
|
{"id": ws.id, "name": ws.name}
|
||||||
|
for ws in db.query(Workspace).filter(Workspace.id.in_(user_ws_ids)).all()
|
||||||
|
]
|
||||||
|
conflicts.append({
|
||||||
|
"server_id": server.id,
|
||||||
|
"server_name": server.name,
|
||||||
|
"server_workspaces": server_workspaces,
|
||||||
|
"user_workspaces": user_workspaces_info,
|
||||||
|
})
|
||||||
|
|
||||||
|
if conflicts:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail={
|
||||||
|
"code": "workspace_conflict",
|
||||||
|
"message": "Cannot assign: server(s) are not in any workspace the user belongs to",
|
||||||
|
"conflicts": conflicts,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
user.assigned_servers = servers
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
LoggingService.log(
|
||||||
|
db=db,
|
||||||
|
level=LoggingService.INFO,
|
||||||
|
category=LoggingService.AUTH,
|
||||||
|
action="user_servers_updated",
|
||||||
|
message=f"Servers assigned to '{user.username}' by {current_user.username}: {[s.id for s in servers]}",
|
||||||
|
username=current_user.username,
|
||||||
|
user_id=current_user.id,
|
||||||
|
ip_address=get_client_ip(request),
|
||||||
|
resource_type="user",
|
||||||
|
resource_id=str(user.id),
|
||||||
|
resource_name=user.username
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"message": "Servers assigned successfully", "server_ids": [s.id for s in servers]}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/users/{user_id}/reset-password")
|
@router.post("/api/users/{user_id}/reset-password")
|
||||||
async def reset_user_password(
|
async def reset_user_password(
|
||||||
user_id: int,
|
user_id: int,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import os
|
|||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import FastAPI, Request, HTTPException, WebSocket, WebSocketDisconnect, Query
|
from fastapi import FastAPI, Request, HTTPException, WebSocket, WebSocketDisconnect, Query, Depends
|
||||||
from fastapi.responses import RedirectResponse, JSONResponse
|
from fastapi.responses import RedirectResponse, JSONResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.exception_handlers import http_exception_handler
|
from fastapi.exception_handlers import http_exception_handler
|
||||||
@@ -13,6 +13,8 @@ from loguru import logger
|
|||||||
|
|
||||||
from .config import settings
|
from .config import settings
|
||||||
from .db import Base, engine, init_db, check_db_connection
|
from .db import Base, engine, init_db, check_db_connection
|
||||||
|
from .auth import PermissionChecker, get_current_user
|
||||||
|
from .models import User
|
||||||
from .api import dashboard as dashboard_router
|
from .api import dashboard as dashboard_router
|
||||||
from .api import proxmox as proxmox_router
|
from .api import proxmox as proxmox_router
|
||||||
from .api import auth as auth_router
|
from .api import auth as auth_router
|
||||||
@@ -259,6 +261,13 @@ def create_app() -> FastAPI:
|
|||||||
@app.exception_handler(HTTPException)
|
@app.exception_handler(HTTPException)
|
||||||
async def custom_http_exception_handler(request: Request, exc: HTTPException):
|
async def custom_http_exception_handler(request: Request, exc: HTTPException):
|
||||||
logger.warning(f"HTTP exception: {exc.status_code} - {exc.detail}")
|
logger.warning(f"HTTP exception: {exc.status_code} - {exc.detail}")
|
||||||
|
# Redirect browser HTML page requests to /login on auth errors
|
||||||
|
if exc.status_code in (401, 403):
|
||||||
|
accept = request.headers.get("accept", "")
|
||||||
|
path = request.url.path
|
||||||
|
if "text/html" in accept and not path.startswith(("/api/", "/static/", "/settings/api/")):
|
||||||
|
from urllib.parse import quote
|
||||||
|
return RedirectResponse(url=f"/login?returnUrl={quote(path)}", status_code=302)
|
||||||
return await http_exception_handler(request, exc)
|
return await http_exception_handler(request, exc)
|
||||||
|
|
||||||
# Health check endpoint
|
# Health check endpoint
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from sqlalchemy import Boolean, Column, Integer, BigInteger, String, DateTime, Text, Index, JSON, ForeignKey
|
from sqlalchemy import Boolean, Column, Integer, BigInteger, String, DateTime, Text, Index, JSON, ForeignKey, Table
|
||||||
from sqlalchemy.sql import func
|
from sqlalchemy.sql import func
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
@@ -43,6 +43,15 @@ class Role(Base):
|
|||||||
return f"<Role(id={self.id}, name='{self.name}')>"
|
return f"<Role(id={self.id}, name='{self.name}')>"
|
||||||
|
|
||||||
|
|
||||||
|
# Association table: User <-> ProxmoxServer (assigned servers)
|
||||||
|
user_servers = Table(
|
||||||
|
'user_servers',
|
||||||
|
Base.metadata,
|
||||||
|
Column('user_id', Integer, ForeignKey('users.id', ondelete='CASCADE'), primary_key=True),
|
||||||
|
Column('server_id', Integer, ForeignKey('proxmox_servers.id', ondelete='CASCADE'), primary_key=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class User(Base):
|
class User(Base):
|
||||||
__tablename__ = "users"
|
__tablename__ = "users"
|
||||||
|
|
||||||
@@ -76,6 +85,7 @@ class User(Base):
|
|||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
sessions = relationship("ActiveSession", back_populates="user", cascade="all, delete-orphan")
|
sessions = relationship("ActiveSession", back_populates="user", cascade="all, delete-orphan")
|
||||||
|
assigned_servers = relationship("ProxmoxServer", secondary=user_servers, back_populates="assigned_users", lazy="select")
|
||||||
|
|
||||||
def has_permission(self, permission: str) -> bool:
|
def has_permission(self, permission: str) -> bool:
|
||||||
"""
|
"""
|
||||||
@@ -429,6 +439,9 @@ class ProxmoxServer(Base):
|
|||||||
elif is_online:
|
elif is_online:
|
||||||
self.last_error = None
|
self.last_error = None
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
assigned_users = relationship("User", secondary=user_servers, back_populates="assigned_servers", lazy="select")
|
||||||
|
|
||||||
|
|
||||||
class VMInstance(Base):
|
class VMInstance(Base):
|
||||||
"""VM/Container instance cache and configuration storage"""
|
"""VM/Container instance cache and configuration storage"""
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ NEW_DEFAULT_ROLES = [
|
|||||||
"description": "Standard user with limited access",
|
"description": "Standard user with limited access",
|
||||||
"is_system": True,
|
"is_system": True,
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"dashboard:view": True,
|
"dashboard:view": False,
|
||||||
"server:view": True,
|
"server:view": True,
|
||||||
"vm:view": True,
|
"vm:view": True,
|
||||||
"vm:start": True,
|
"vm:start": True,
|
||||||
@@ -385,11 +385,14 @@ def ensure_default_roles_new_format(conn) -> None:
|
|||||||
except Exception:
|
except Exception:
|
||||||
current_perms = {}
|
current_perms = {}
|
||||||
|
|
||||||
# Check if needs update
|
# Migrate any old dot-format keys to new colon-format in-place
|
||||||
has_old_format = any("." in k and ":" not in k for k in (current_perms or {}).keys())
|
has_old_format = any("." in k and ":" not in k for k in (current_perms or {}).keys())
|
||||||
|
if has_old_format:
|
||||||
|
current_perms = convert_permissions_to_new_format(current_perms)
|
||||||
|
logger.info(f"Converted old-format permissions for role '{role_data['name']}'")
|
||||||
|
|
||||||
if has_old_format or not current_perms:
|
if not current_perms:
|
||||||
# Update to new format
|
# Role has no permissions at all — seed with defaults
|
||||||
conn.execute(
|
conn.execute(
|
||||||
text("""
|
text("""
|
||||||
UPDATE roles
|
UPDATE roles
|
||||||
@@ -402,10 +405,26 @@ def ensure_default_roles_new_format(conn) -> None:
|
|||||||
"perms": json.dumps(role_data["permissions"]),
|
"perms": json.dumps(role_data["permissions"]),
|
||||||
"display_name": role_data["display_name"],
|
"display_name": role_data["display_name"],
|
||||||
"description": role_data["description"],
|
"description": role_data["description"],
|
||||||
"id": role_id
|
"id": role_id,
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
logger.info(f"Updated role '{role_data['name']}' with new permission format")
|
logger.info(f"Seeded empty role '{role_data['name']}' with default permissions")
|
||||||
|
else:
|
||||||
|
# Role already has permissions — only add keys that are completely missing.
|
||||||
|
# NEVER overwrite keys that already exist (preserves admin UI changes).
|
||||||
|
missing = {k: v for k, v in role_data["permissions"].items() if k not in current_perms}
|
||||||
|
if missing or has_old_format:
|
||||||
|
merged = {**current_perms, **missing}
|
||||||
|
conn.execute(
|
||||||
|
text("UPDATE roles SET permissions = :perms WHERE id = :id"),
|
||||||
|
{"perms": json.dumps(merged), "id": role_id},
|
||||||
|
)
|
||||||
|
if missing:
|
||||||
|
logger.info(f"Added {len(missing)} missing permission(s) to role '{role_data['name']}'")
|
||||||
|
if has_old_format:
|
||||||
|
logger.info(f"Saved converted permissions for role '{role_data['name']}'")
|
||||||
|
else:
|
||||||
|
logger.debug(f"Role '{role_data['name']}' permissions already up to date")
|
||||||
else:
|
else:
|
||||||
# Create new role
|
# Create new role
|
||||||
conn.execute(
|
conn.execute(
|
||||||
|
|||||||
@@ -2068,13 +2068,15 @@ a:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="light"] .theme-toggle,
|
[data-theme="light"] .theme-toggle,
|
||||||
[data-theme="light"] .notification-bell {
|
[data-theme="light"] .notification-bell,
|
||||||
|
[data-theme="light"] .tasks-btn {
|
||||||
background: rgba(0, 0, 0, 0.04);
|
background: rgba(0, 0, 0, 0.04);
|
||||||
border-color: #dde1e9;
|
border-color: #dde1e9;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="light"] .theme-toggle:hover,
|
[data-theme="light"] .theme-toggle:hover,
|
||||||
[data-theme="light"] .notification-bell:hover {
|
[data-theme="light"] .notification-bell:hover,
|
||||||
|
[data-theme="light"] .tasks-btn:hover {
|
||||||
background: rgba(0, 0, 0, 0.07);
|
background: rgba(0, 0, 0, 0.07);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2423,20 +2425,22 @@ body {
|
|||||||
/* ── Navbar tasks button & badge ──────────────────────────────── */
|
/* ── Navbar tasks button & badge ──────────────────────────────── */
|
||||||
.tasks-btn {
|
.tasks-btn {
|
||||||
position: relative;
|
position: relative;
|
||||||
background: none;
|
width: 40px;
|
||||||
border: none;
|
height: 40px;
|
||||||
cursor: pointer;
|
border-radius: var(--radius-md);
|
||||||
padding: 8px;
|
border: 1px solid var(--border-default);
|
||||||
border-radius: 50%;
|
background: var(--bg-surface-hover);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
transition: color var(--transition-fast), background var(--transition-fast);
|
transition: all var(--transition-fast);
|
||||||
}
|
}
|
||||||
.tasks-btn:hover {
|
.tasks-btn:hover {
|
||||||
|
background: var(--bg-surface-active);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
background: var(--bg-hover);
|
transform: scale(1.05);
|
||||||
}
|
}
|
||||||
.tasks-count-badge {
|
.tasks-count-badge {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -2882,6 +2886,39 @@ body {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Page footer (version + github) */
|
||||||
|
.page-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 24px;
|
||||||
|
border-top: 1px solid var(--border-muted);
|
||||||
|
margin-top: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.github-btn-sm {
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar system status section */
|
||||||
|
.sidebar-status-section {
|
||||||
|
padding: 0 var(--spacing-md) var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-status-section .status-chip {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-start;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 5px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.collapsed .sidebar-status-section {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* Header title block (title + subtitle stacked) */
|
/* Header title block (title + subtitle stacked) */
|
||||||
.header-title-block {
|
.header-title-block {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -226,6 +226,14 @@
|
|||||||
|
|
||||||
document.addEventListener('keydown', function(event) {
|
document.addEventListener('keydown', function(event) {
|
||||||
if (event.key === 'Escape') {
|
if (event.key === 'Escape') {
|
||||||
|
// Close change password modal if open
|
||||||
|
const cpOverlay = document.getElementById('changePasswordOverlay');
|
||||||
|
if (cpOverlay && cpOverlay.classList.contains('show')) {
|
||||||
|
closeChangePasswordModal();
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Find all visible modals and close the topmost one
|
// Find all visible modals and close the topmost one
|
||||||
const modals = document.querySelectorAll('[id$="Modal"], [id$="modal"]');
|
const modals = document.querySelectorAll('[id$="Modal"], [id$="modal"]');
|
||||||
let closedAny = false;
|
let closedAny = false;
|
||||||
@@ -306,6 +314,10 @@
|
|||||||
--transition-slow: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
--transition-slow: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Hide sidebar nav items until permissions are resolved (prevents flash of admin-only items) */
|
||||||
|
.sidebar-nav { visibility: hidden; }
|
||||||
|
.sidebar-nav.nav-ready { visibility: visible; }
|
||||||
|
|
||||||
/* Custom Dialog Styles */
|
/* Custom Dialog Styles */
|
||||||
.custom-dialog-overlay {
|
.custom-dialog-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -488,6 +500,100 @@
|
|||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.4);
|
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- User Menu ---- */
|
||||||
|
.user-menu-wrapper {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-menu-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--border-default);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 5px 10px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: background 0.2s;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-menu-btn:hover {
|
||||||
|
background: var(--bg-surface-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-menu-btn .user-menu-name {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-menu-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
min-width: 200px;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-default);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0,0,0,0.35);
|
||||||
|
z-index: 5000;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-menu-header {
|
||||||
|
padding: 12px 16px 10px;
|
||||||
|
border-bottom: 1px solid var(--border-default);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-menu-header-username {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-menu-header-role {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
display: block;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-menu-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 10px 16px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-menu-item:hover {
|
||||||
|
background: var(--bg-surface-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-menu-item.danger:hover {
|
||||||
|
color: var(--danger, #ef4444);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-menu-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border-default);
|
||||||
|
margin: 3px 0;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body data-is-admin="false">
|
<body data-is-admin="false">
|
||||||
@@ -495,7 +601,7 @@
|
|||||||
<div id="update-banner" class="update-banner" style="display: none;">
|
<div id="update-banner" class="update-banner" style="display: none;">
|
||||||
<div class="update-banner-content">
|
<div class="update-banner-content">
|
||||||
<i class="fas fa-sync fa-spin"></i>
|
<i class="fas fa-sync fa-spin"></i>
|
||||||
<span id="update-banner-text">Обновление системы...</span>
|
<span id="update-banner-text">{{ t('updating_system') }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -598,6 +704,8 @@ body.update-in-progress .layout {
|
|||||||
<span style="font-size: 2rem;"><i class="fa-solid fa-cloud"></i></span>
|
<span style="font-size: 2rem;"><i class="fa-solid fa-cloud"></i></span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
<!-- System Status -->
|
||||||
|
{% block sidebar_status %}{% endblock %}
|
||||||
<!-- Workspace Selector -->
|
<!-- Workspace Selector -->
|
||||||
<div class="sidebar-workspace-section" id="sidebarWorkspaceSection">
|
<div class="sidebar-workspace-section" id="sidebarWorkspaceSection">
|
||||||
<div class="sidebar-workspace-label">Workspace</div>
|
<div class="sidebar-workspace-label">Workspace</div>
|
||||||
@@ -613,48 +721,48 @@ body.update-in-progress .layout {
|
|||||||
</div>
|
</div>
|
||||||
<nav>
|
<nav>
|
||||||
<ul class="sidebar-nav">
|
<ul class="sidebar-nav">
|
||||||
<li><a href="/" class="{{ 'active' if request.path == '/' or request.path == '/dashboard' else '' }}" title="{{ t('nav_dashboard') }}">
|
<li data-nav-permission="dashboard.view"><a href="/" class="{{ 'active' if request.path == '/' or request.path == '/dashboard' else '' }}" title="{{ t('nav_dashboard') }}">
|
||||||
<svg class="material-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"></rect><rect x="14" y="3" width="7" height="7"></rect><rect x="14" y="14" width="7" height="7"></rect><rect x="3" y="14" width="7" height="7"></rect></svg>
|
<svg class="material-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"></rect><rect x="14" y="3" width="7" height="7"></rect><rect x="14" y="14" width="7" height="7"></rect><rect x="3" y="14" width="7" height="7"></rect></svg>
|
||||||
<span>{{ t('nav_dashboard') }}</span>
|
<span>{{ t('nav_dashboard') }}</span>
|
||||||
</a></li>
|
</a></li>
|
||||||
<li><a href="/virtual-machines" class="{{ 'active' if request.path.startswith('/virtual-machines') or request.path.startswith('/proxmox/server') else '' }}" title="{{ t('nav_vms') }}">
|
<li data-nav-permission="vms.view"><a href="/virtual-machines" class="{{ 'active' if request.path.startswith('/virtual-machines') or request.path.startswith('/proxmox/server') else '' }}" title="{{ t('nav_vms') }}">
|
||||||
<svg class="material-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect><line x1="8" y1="21" x2="16" y2="21"></line><line x1="12" y1="17" x2="12" y2="21"></line></svg>
|
<svg class="material-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect><line x1="8" y1="21" x2="16" y2="21"></line><line x1="12" y1="17" x2="12" y2="21"></line></svg>
|
||||||
<span>{{ t('nav_vms') }}</span>
|
<span>{{ t('nav_vms') }}</span>
|
||||||
</a></li>
|
</a></li>
|
||||||
<li><a href="/vms" class="{{ 'active' if request.path == '/vms' or (request.path.startswith('/vms/') or (request.path.startswith('/proxmox/') and not request.path.startswith('/proxmox/server') and not request.path.startswith('/proxmox/api/backups'))) else '' }}" title="{{ t('nav_proxmox') }}">
|
<li data-nav-permission="proxmox.view"><a href="/vms" class="{{ 'active' if request.path == '/vms' or (request.path.startswith('/vms/') or (request.path.startswith('/proxmox/') and not request.path.startswith('/proxmox/server') and not request.path.startswith('/proxmox/api/backups'))) else '' }}" title="{{ t('nav_proxmox') }}">
|
||||||
<svg class="material-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2" ry="2"></rect><rect x="9" y="9" width="6" height="6"></rect><line x1="9" y1="1" x2="9" y2="4"></line><line x1="15" y1="1" x2="15" y2="4"></line><line x1="9" y1="20" x2="9" y2="23"></line><line x1="15" y1="20" x2="15" y2="23"></line><line x1="20" y1="9" x2="23" y2="9"></line><line x1="20" y1="14" x2="23" y2="14"></line><line x1="1" y1="9" x2="4" y2="9"></line><line x1="1" y1="14" x2="4" y2="14"></line></svg>
|
<svg class="material-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2" ry="2"></rect><rect x="9" y="9" width="6" height="6"></rect><line x1="9" y1="1" x2="9" y2="4"></line><line x1="15" y1="1" x2="15" y2="4"></line><line x1="9" y1="20" x2="9" y2="23"></line><line x1="15" y1="20" x2="15" y2="23"></line><line x1="20" y1="9" x2="23" y2="9"></line><line x1="20" y1="14" x2="23" y2="14"></line><line x1="1" y1="9" x2="4" y2="9"></line><line x1="1" y1="14" x2="4" y2="14"></line></svg>
|
||||||
<span>{{ t('nav_proxmox') }}</span>
|
<span>{{ t('nav_proxmox') }}</span>
|
||||||
</a></li>
|
</a></li>
|
||||||
<li><a href="/templates" class="{{ 'active' if request.path.startswith('/templates') else '' }}" title="{{ t('nav_templates') }}">
|
<li data-nav-permission="templates.view"><a href="/templates" class="{{ 'active' if request.path.startswith('/templates') else '' }}" title="{{ t('nav_templates') }}">
|
||||||
<svg class="material-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line><polyline points="10 9 9 9 8 9"></polyline></svg>
|
<svg class="material-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line><polyline points="10 9 9 9 8 9"></polyline></svg>
|
||||||
<span>{{ t('nav_templates') }}</span>
|
<span>{{ t('nav_templates') }}</span>
|
||||||
</a></li>
|
</a></li>
|
||||||
<li><a href="/backups" class="{{ 'active' if request.path.startswith('/backups') or request.path.startswith('/proxmox/api/backups/page') else '' }}" title="{{ t('nav_backups') }}">
|
<li data-nav-permission="backups.view"><a href="/backups" class="{{ 'active' if request.path.startswith('/backups') or request.path.startswith('/proxmox/api/backups/page') else '' }}" title="{{ t('nav_backups') }}">
|
||||||
<svg class="material-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
|
<svg class="material-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
|
||||||
<span>{{ t('nav_backups') }}</span>
|
<span>{{ t('nav_backups') }}</span>
|
||||||
</a></li>
|
</a></li>
|
||||||
<li><a href="/ipam" class="{{ 'active' if request.path.startswith('/ipam') else '' }}" title="{{ t('nav_ipam') }}">
|
<li data-nav-permission="ipam.view"><a href="/ipam" class="{{ 'active' if request.path.startswith('/ipam') else '' }}" title="{{ t('nav_ipam') }}">
|
||||||
<svg class="material-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="2" y1="12" x2="22" y2="12"></line><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path></svg>
|
<svg class="material-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="2" y1="12" x2="22" y2="12"></line><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path></svg>
|
||||||
<span>{{ t('nav_ipam') }}</span>
|
<span>{{ t('nav_ipam') }}</span>
|
||||||
</a></li>
|
</a></li>
|
||||||
<li><a href="/containers" class="{{ 'active' if request.path.startswith('/containers') else '' }}" title="{{ t('nav_docker') }}">
|
<li data-nav-permission="docker.view"><a href="/containers" class="{{ 'active' if request.path.startswith('/containers') else '' }}" title="{{ t('nav_docker') }}">
|
||||||
<i class="fa-brands fa-docker" style="font-size: 1.25rem; width: 24px; text-align: center;"></i>
|
<i class="fa-brands fa-docker" style="font-size: 1.25rem; width: 24px; text-align: center;"></i>
|
||||||
<span>{{ t('nav_docker') }}</span>
|
<span>{{ t('nav_docker') }}</span>
|
||||||
</a></li>
|
</a></li>
|
||||||
<li><a href="/logs" class="{{ 'active' if request.path.startswith('/logs') else '' }}" title="{{ t('nav_logs') }}">
|
<li data-nav-permission="logs.view"><a href="/logs" class="{{ 'active' if request.path.startswith('/logs') else '' }}" title="{{ t('nav_logs') }}">
|
||||||
<svg class="material-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line><line x1="10" y1="9" x2="8" y2="9"></line></svg>
|
<svg class="material-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line><line x1="10" y1="9" x2="8" y2="9"></line></svg>
|
||||||
<span>{{ t('nav_logs') }}</span>
|
<span>{{ t('nav_logs') }}</span>
|
||||||
</a></li>
|
</a></li>
|
||||||
<li><a href="/tasks" class="{{ 'active' if request.path.startswith('/tasks') else '' }}" title="Менеджер задач">
|
<li><a href="/tasks" class="{{ 'active' if request.path.startswith('/tasks') else '' }}" title="{{ t('task_manager') }}">
|
||||||
<svg class="material-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"></path><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"></path></svg>
|
<svg class="material-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"></path><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"></path></svg>
|
||||||
<span>Задачи</span>
|
<span>{{ t('nav_tasks') }}</span>
|
||||||
<span id="sidebarTasksBadge" style="display:none; background:#0ea5e9; color:#fff; font-size:.62rem; font-weight:700; border-radius:9999px; padding:1px 6px; margin-left:auto; line-height:1.5;"></span>
|
<span id="sidebarTasksBadge" style="display:none; background:#0ea5e9; color:#fff; font-size:.62rem; font-weight:700; border-radius:9999px; padding:1px 6px; margin-left:auto; line-height:1.5;"></span>
|
||||||
</a></li>
|
</a></li>
|
||||||
<li><a href="/settings" class="{{ 'active' if request.path.startswith('/settings') else '' }}" title="{{ t('nav_settings') }}">
|
<li data-nav-permission="settings.view"><a href="/settings" class="{{ 'active' if request.path.startswith('/settings') else '' }}" title="{{ t('nav_settings') }}">
|
||||||
<svg class="material-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
|
<svg class="material-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
|
||||||
<span>{{ t('nav_settings') }}</span>
|
<span>{{ t('nav_settings') }}</span>
|
||||||
</a></li>
|
</a></li>
|
||||||
<li><a href="/admin/users" class="{{ 'active' if request.path.startswith('/admin/users') else '' }}" title="{{ t('nav_users') }}">
|
<li data-nav-permission="users.view"><a href="/admin/users" class="{{ 'active' if request.path.startswith('/admin/users') else '' }}" title="{{ t('nav_users') }}">
|
||||||
<svg class="material-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>
|
<svg class="material-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>
|
||||||
<span>{{ t('nav_users') }}</span>
|
<span>{{ t('nav_users') }}</span>
|
||||||
</a></li>
|
</a></li>
|
||||||
@@ -696,19 +804,6 @@ body.update-in-progress .layout {
|
|||||||
{% block page_subtitle %}<div class="page-subtitle" id="pageGlobalSubtitle"></div>{% endblock %}
|
{% block page_subtitle %}<div class="page-subtitle" id="pageGlobalSubtitle"></div>{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
{% block header_status %}{% endblock %}
|
|
||||||
<!-- Version Badge -->
|
|
||||||
<span class="version-badge" title="PVEmanager version">v{{ version }}</span>
|
|
||||||
|
|
||||||
<!-- GitHub Stars Button -->
|
|
||||||
<a href="https://github.com/markmorado/pvemanager" target="_blank" rel="noopener noreferrer" class="github-btn" title="Star us on GitHub">
|
|
||||||
<i class="fa-brands fa-github"></i>
|
|
||||||
<span class="github-text">pvemanager</span>
|
|
||||||
<svg class="github-star-icon" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
|
||||||
<path d="M12 .587l3.668 7.568 8.332 1.151-6.064 5.828 1.48 8.279-7.416-3.967-7.417 3.967 1.481-8.279-6.064-5.828 8.332-1.151z"/>
|
|
||||||
</svg>
|
|
||||||
<span class="github-stars" id="githubStars">...</span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<!-- Theme Toggle Button -->
|
<!-- Theme Toggle Button -->
|
||||||
<button id="themeToggle" class="theme-toggle" title="{{ t('theme') }}">
|
<button id="themeToggle" class="theme-toggle" title="{{ t('theme') }}">
|
||||||
@@ -729,7 +824,7 @@ body.update-in-progress .layout {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Task Manager Button -->
|
<!-- Task Manager Button -->
|
||||||
<button id="tasksBtn" class="tasks-btn" onclick="toggleTaskDrawer()" title="Менеджер задач">
|
<button id="tasksBtn" class="tasks-btn" onclick="toggleTaskDrawer()" title="{{ t('task_manager') }}">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path d="M9 11l3 3L22 4"></path><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"></path>
|
<path d="M9 11l3 3L22 4"></path><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"></path>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -744,11 +839,46 @@ body.update-in-progress .layout {
|
|||||||
</svg>
|
</svg>
|
||||||
<span id="notificationBadge" class="notification-badge" style="display: none;">0</span>
|
<span id="notificationBadge" class="notification-badge" style="display: none;">0</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<!-- User Menu -->
|
||||||
|
<div class="user-menu-wrapper" id="userMenuWrapper">
|
||||||
|
<button class="user-menu-btn" id="userMenuBtn" onclick="toggleUserMenu(event)">
|
||||||
|
<i class="fas fa-user-circle" style="font-size:1rem; opacity:0.8;"></i>
|
||||||
|
<span class="user-menu-name" id="userMenuName">...</span>
|
||||||
|
<i class="fas fa-chevron-down" style="font-size:0.6rem; opacity:0.6;"></i>
|
||||||
|
</button>
|
||||||
|
<div class="user-menu-dropdown" id="userMenuDropdown" style="display:none;">
|
||||||
|
<div class="user-menu-header">
|
||||||
|
<span class="user-menu-header-username" id="userMenuFullName">...</span>
|
||||||
|
<span class="user-menu-header-role" id="userMenuRoleName">...</span>
|
||||||
|
</div>
|
||||||
|
<button class="user-menu-item" onclick="openChangePasswordModal(); closeUserMenu();">
|
||||||
|
<i class="fas fa-key"></i>
|
||||||
|
<span>{{ t('change_password') }}</span>
|
||||||
|
</button>
|
||||||
|
<div class="user-menu-divider"></div>
|
||||||
|
<button class="user-menu-item danger" onclick="logout(event)">
|
||||||
|
<i class="fas fa-sign-out-alt"></i>
|
||||||
|
<span>{{ t('nav_logout') }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</main>
|
</main>
|
||||||
|
<footer class="page-footer">
|
||||||
|
<span class="version-badge" title="PVEmanager version">v{{ version }}</span>
|
||||||
|
<a href="https://github.com/markmorado/pvemanager" target="_blank" rel="noopener noreferrer" class="github-btn github-btn-sm" title="Star us on GitHub">
|
||||||
|
<i class="fa-brands fa-github"></i>
|
||||||
|
<span class="github-text">pvemanager</span>
|
||||||
|
<svg class="github-star-icon" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M12 .587l3.668 7.568 8.332 1.151-6.064 5.828 1.48 8.279-7.416-3.967-7.417 3.967 1.481-8.279-6.064-5.828 8.332-1.151z"/>
|
||||||
|
</svg>
|
||||||
|
<span class="github-stars" id="githubStars">...</span>
|
||||||
|
</a>
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Notification Center Dropdown -->
|
<!-- Notification Center Dropdown -->
|
||||||
@@ -773,9 +903,9 @@ body.update-in-progress .layout {
|
|||||||
<!-- Task Drawer -->
|
<!-- Task Drawer -->
|
||||||
<div id="taskDrawer" class="task-drawer" style="display:none;">
|
<div id="taskDrawer" class="task-drawer" style="display:none;">
|
||||||
<div class="task-drawer-header">
|
<div class="task-drawer-header">
|
||||||
<h3><i class="fa-solid fa-list-check" style="color:var(--primary); margin-right:.5rem;"></i> Задачи</h3>
|
<h3><i class="fa-solid fa-list-check" style="color:var(--primary); margin-right:.5rem;"></i> {{ t('nav_tasks') }}</h3>
|
||||||
<div style="display:flex; gap:.4rem; align-items:center; margin-left:auto;">
|
<div style="display:flex; gap:.4rem; align-items:center; margin-left:auto;">
|
||||||
<button id="taskDrawerClearBtn" onclick="clearCompletedTasks()" title="Очистить завершённые" style="background:none; border:none; color:var(--text-muted); cursor:pointer; padding:.3rem .5rem; border-radius:6px; font-size:.75rem; display:none;">
|
<button id="taskDrawerClearBtn" onclick="clearCompletedTasks()" title="{{ t('clear_completed_tasks') }}" style="background:none; border:none; color:var(--text-muted); cursor:pointer; padding:.3rem .5rem; border-radius:6px; font-size:.75rem; display:none;">
|
||||||
<i class="fa-solid fa-trash-can"></i>
|
<i class="fa-solid fa-trash-can"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="task-drawer-close" onclick="toggleTaskDrawer()">
|
<button class="task-drawer-close" onclick="toggleTaskDrawer()">
|
||||||
@@ -786,18 +916,47 @@ body.update-in-progress .layout {
|
|||||||
<div class="task-drawer-body" id="taskDrawerBody">
|
<div class="task-drawer-body" id="taskDrawerBody">
|
||||||
<div class="task-drawer-empty" id="taskDrawerEmpty">
|
<div class="task-drawer-empty" id="taskDrawerEmpty">
|
||||||
<i class="fa-solid fa-inbox" style="font-size:1.8rem; display:block; margin-bottom:.5rem; opacity:.4;"></i>
|
<i class="fa-solid fa-inbox" style="font-size:1.8rem; display:block; margin-bottom:.5rem; opacity:.4;"></i>
|
||||||
Нет активных задач
|
{{ t('no_active_tasks') }}
|
||||||
</div>
|
</div>
|
||||||
<div id="taskDrawerCards"></div>
|
<div id="taskDrawerCards"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="task-drawer-footer">
|
<div class="task-drawer-footer">
|
||||||
<a href="/tasks">
|
<a href="/tasks">
|
||||||
<i class="fa-solid fa-arrow-right" style="margin-right:.4rem;"></i>
|
<i class="fa-solid fa-arrow-right" style="margin-right:.4rem;"></i>
|
||||||
Открыть менеджер задач
|
{{ t('open_task_manager') }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Change Password Modal -->
|
||||||
|
<div id="changePasswordOverlay" class="custom-dialog-overlay" onclick="if(event.target===this)closeChangePasswordModal()">
|
||||||
|
<div class="custom-dialog" style="max-width:420px;">
|
||||||
|
<div class="custom-dialog-icon info">
|
||||||
|
<i class="fas fa-key"></i>
|
||||||
|
</div>
|
||||||
|
<div class="custom-dialog-title">{{ t('change_password') }}</div>
|
||||||
|
<div style="display:flex; flex-direction:column; gap:12px; margin-bottom:20px;">
|
||||||
|
<div>
|
||||||
|
<label style="font-size:.82rem; color:var(--text-secondary); margin-bottom:5px; display:block;">{{ t('current_password') }}</label>
|
||||||
|
<input type="password" id="cpOldPassword" class="custom-dialog-input" style="text-align:left;" placeholder="{{ t('current_password') }}">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style="font-size:.82rem; color:var(--text-secondary); margin-bottom:5px; display:block;">{{ t('new_password') }}</label>
|
||||||
|
<input type="password" id="cpNewPassword" class="custom-dialog-input" style="text-align:left;" placeholder="{{ t('new_password') }}">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style="font-size:.82rem; color:var(--text-secondary); margin-bottom:5px; display:block;">{{ t('confirm_password') }}</label>
|
||||||
|
<input type="password" id="cpConfirmPassword" class="custom-dialog-input" style="text-align:left;" placeholder="{{ t('confirm_password') }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="cpError" style="display:none; color:var(--danger,#ef4444); font-size:.85rem; margin-bottom:14px; text-align:center;"></div>
|
||||||
|
<div class="custom-dialog-buttons">
|
||||||
|
<button class="custom-dialog-btn secondary" onclick="closeChangePasswordModal()">{{ t('cancel') }}</button>
|
||||||
|
<button class="custom-dialog-btn primary" id="cpSubmitBtn" onclick="submitChangePassword()">{{ t('change_password') }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Custom Dialog Container -->
|
<!-- Custom Dialog Container -->
|
||||||
<div id="customDialogOverlay" class="custom-dialog-overlay">
|
<div id="customDialogOverlay" class="custom-dialog-overlay">
|
||||||
<div class="custom-dialog">
|
<div class="custom-dialog">
|
||||||
@@ -844,15 +1003,153 @@ body.update-in-progress .layout {
|
|||||||
window.location.href = '/login';
|
window.location.href = '/login';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== User Menu ====================
|
||||||
|
function toggleUserMenu(e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
const dd = document.getElementById('userMenuDropdown');
|
||||||
|
const isOpen = dd.style.display !== 'none';
|
||||||
|
dd.style.display = isOpen ? 'none' : 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeUserMenu() {
|
||||||
|
const dd = document.getElementById('userMenuDropdown');
|
||||||
|
if (dd) dd.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
const wrapper = document.getElementById('userMenuWrapper');
|
||||||
|
if (wrapper && !wrapper.contains(e.target)) {
|
||||||
|
closeUserMenu();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== Change Password Modal ====================
|
||||||
|
function openChangePasswordModal() {
|
||||||
|
document.getElementById('cpOldPassword').value = '';
|
||||||
|
document.getElementById('cpNewPassword').value = '';
|
||||||
|
document.getElementById('cpConfirmPassword').value = '';
|
||||||
|
document.getElementById('cpError').style.display = 'none';
|
||||||
|
document.getElementById('changePasswordOverlay').classList.add('show');
|
||||||
|
setTimeout(() => document.getElementById('cpOldPassword').focus(), 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeChangePasswordModal() {
|
||||||
|
document.getElementById('changePasswordOverlay').classList.remove('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitChangePassword() {
|
||||||
|
const oldPwd = document.getElementById('cpOldPassword').value.trim();
|
||||||
|
const newPwd = document.getElementById('cpNewPassword').value;
|
||||||
|
const confirmPwd = document.getElementById('cpConfirmPassword').value;
|
||||||
|
const errEl = document.getElementById('cpError');
|
||||||
|
const btn = document.getElementById('cpSubmitBtn');
|
||||||
|
|
||||||
|
errEl.style.display = 'none';
|
||||||
|
|
||||||
|
if (!oldPwd || !newPwd || !confirmPwd) {
|
||||||
|
errEl.textContent = t('fill_all_fields') || 'Please fill all fields';
|
||||||
|
errEl.style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newPwd !== confirmPwd) {
|
||||||
|
errEl.textContent = t('passwords_dont_match');
|
||||||
|
errEl.style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
const origText = btn.textContent;
|
||||||
|
btn.textContent = '...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('old_password', oldPwd);
|
||||||
|
formData.append('new_password', newPwd);
|
||||||
|
|
||||||
|
// Use fetch directly — FormData sets its own Content-Type with boundary.
|
||||||
|
// fetchWithAuth would override it with application/json.
|
||||||
|
const token = localStorage.getItem('access_token');
|
||||||
|
const resp = await fetch('/api/auth/change-password', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` },
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (resp.ok) {
|
||||||
|
closeChangePasswordModal();
|
||||||
|
showToast('success', t('success'), t('password_changed'));
|
||||||
|
} else {
|
||||||
|
const data = await resp.json().catch(() => ({}));
|
||||||
|
errEl.textContent = data.detail || t('error');
|
||||||
|
errEl.style.display = 'block';
|
||||||
|
}
|
||||||
|
} catch(err) {
|
||||||
|
errEl.textContent = t('error');
|
||||||
|
errEl.style.display = 'block';
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = origText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow Enter key in change password fields
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
['cpOldPassword','cpNewPassword','cpConfirmPassword'].forEach(id => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) el.addEventListener('keydown', e => { if (e.key === 'Enter') submitChangePassword(); });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Global user permissions object
|
// Global user permissions object
|
||||||
window.userPermissions = {};
|
window.userPermissions = {};
|
||||||
window.isAdmin = false;
|
window.isAdmin = false;
|
||||||
|
|
||||||
// Permission checking helper function
|
// Bidirectional permission format translation table (legacy dot <-> new colon)
|
||||||
|
const _PERM_LEGACY_TO_NEW = {
|
||||||
|
'dashboard.view': 'dashboard:view',
|
||||||
|
'vms.view': 'vm:view',
|
||||||
|
'vms.create': 'vm:create',
|
||||||
|
'vms.start': 'vm:start',
|
||||||
|
'vms.stop': 'vm:stop',
|
||||||
|
'vms.restart': 'vm:restart',
|
||||||
|
'vms.delete': 'vm:delete',
|
||||||
|
'vms.console': 'vm:console',
|
||||||
|
'vms.migrate': 'vm:migrate',
|
||||||
|
'proxmox.view': 'server:view',
|
||||||
|
'proxmox.manage': 'server:manage',
|
||||||
|
'templates.view': 'template:view',
|
||||||
|
'templates.manage': 'template:manage',
|
||||||
|
'backups.view': 'backup:view',
|
||||||
|
'backups.manage': 'backup:manage',
|
||||||
|
'ipam.view': 'ipam:view',
|
||||||
|
'ipam.manage': 'ipam:manage',
|
||||||
|
'docker.view': 'docker:view',
|
||||||
|
'logs.view': 'log:view',
|
||||||
|
'logs.export': 'log:export',
|
||||||
|
'logs.delete': 'log:delete',
|
||||||
|
'settings.view': 'setting:view',
|
||||||
|
'settings.panel': 'setting:update',
|
||||||
|
'settings.security':'setting:manage',
|
||||||
|
'users.view': 'user:view',
|
||||||
|
'users.create': 'user:create',
|
||||||
|
'users.edit': 'user:update',
|
||||||
|
'users.delete': 'user:delete',
|
||||||
|
'roles.view': 'role:view',
|
||||||
|
'roles.manage': 'role:manage',
|
||||||
|
'notification.view':'notification:view',
|
||||||
|
'notification.manage':'notification:manage',
|
||||||
|
};
|
||||||
|
const _PERM_NEW_TO_LEGACY = Object.fromEntries(Object.entries(_PERM_LEGACY_TO_NEW).map(([k,v])=>[v,k]));
|
||||||
|
|
||||||
|
// Permission checking helper function — supports both legacy (dot) and new (colon) formats
|
||||||
window.hasPermission = function(permission) {
|
window.hasPermission = function(permission) {
|
||||||
if (window.isAdmin) return true;
|
if (window.isAdmin) return true;
|
||||||
if (!permission) return true;
|
if (!permission) return true;
|
||||||
return window.userPermissions[permission] === true;
|
if (window.userPermissions[permission] === true) return true;
|
||||||
|
// Try translated format
|
||||||
|
const alt = _PERM_LEGACY_TO_NEW[permission] || _PERM_NEW_TO_LEGACY[permission];
|
||||||
|
if (alt && window.userPermissions[alt] === true) return true;
|
||||||
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Hide elements based on permission
|
// Hide elements based on permission
|
||||||
@@ -879,14 +1176,20 @@ body.update-in-progress .layout {
|
|||||||
window.addEventListener('DOMContentLoaded', async () => {
|
window.addEventListener('DOMContentLoaded', async () => {
|
||||||
// Skip check on login page
|
// Skip check on login page
|
||||||
if (window.location.pathname === '/login') {
|
if (window.location.pathname === '/login') {
|
||||||
|
// Show nav immediately on login page (no permission filtering needed)
|
||||||
|
const sidebarNav = document.querySelector('.sidebar-nav');
|
||||||
|
if (sidebarNav) sidebarNav.classList.add('nav-ready');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = localStorage.getItem('access_token');
|
const token = localStorage.getItem('access_token');
|
||||||
if (!token) {
|
if (!token) {
|
||||||
// Save current URL as query parameter
|
const currentPath = window.location.pathname;
|
||||||
const currentUrl = encodeURIComponent(window.location.pathname + window.location.search);
|
// Avoid redirect loop: don't add returnUrl if already on login
|
||||||
window.location.href = `/login?returnUrl=${currentUrl}`;
|
if (currentPath !== '/login') {
|
||||||
|
const currentUrl = encodeURIComponent(currentPath + window.location.search);
|
||||||
|
window.location.href = `/login?returnUrl=${currentUrl}`;
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -912,6 +1215,16 @@ body.update-in-progress .layout {
|
|||||||
window.userPermissions = userData.permissions || {};
|
window.userPermissions = userData.permissions || {};
|
||||||
window.isAdmin = userData.is_admin === true;
|
window.isAdmin = userData.is_admin === true;
|
||||||
window.currentUserId = userData.id;
|
window.currentUserId = userData.id;
|
||||||
|
window.currentUser = userData;
|
||||||
|
|
||||||
|
// Update user menu button
|
||||||
|
const displayName = userData.full_name || userData.username || '...';
|
||||||
|
const nameEl = document.getElementById('userMenuName');
|
||||||
|
const fullNameEl = document.getElementById('userMenuFullName');
|
||||||
|
const roleEl = document.getElementById('userMenuRoleName');
|
||||||
|
if (nameEl) nameEl.textContent = userData.username || '...';
|
||||||
|
if (fullNameEl) fullNameEl.textContent = displayName;
|
||||||
|
if (roleEl) roleEl.textContent = (userData.role && userData.role.display_name) || (userData.is_admin ? 'Administrator' : 'User');
|
||||||
|
|
||||||
// Apply permissions to UI elements
|
// Apply permissions to UI elements
|
||||||
applyPermissionsToUI();
|
applyPermissionsToUI();
|
||||||
@@ -920,44 +1233,79 @@ body.update-in-progress .layout {
|
|||||||
window.dispatchEvent(new CustomEvent('permissionsLoaded', { detail: { permissions: window.userPermissions, isAdmin: window.isAdmin } }));
|
window.dispatchEvent(new CustomEvent('permissionsLoaded', { detail: { permissions: window.userPermissions, isAdmin: window.isAdmin } }));
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// On network error, allow staying (offline mode)
|
// On network error, allow staying (offline mode) but still reveal nav
|
||||||
|
const sidebarNav = document.querySelector('.sidebar-nav');
|
||||||
|
if (sidebarNav) sidebarNav.classList.add('nav-ready');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Apply permissions to sidebar menu and other UI elements
|
// Apply permissions to sidebar menu and other UI elements
|
||||||
function applyPermissionsToUI() {
|
function applyPermissionsToUI() {
|
||||||
const navLinks = document.querySelectorAll('.sidebar-nav a[href]');
|
// --- Sidebar: hide items by data-nav-permission ---
|
||||||
|
const PAGE_PERMISSION_MAP = [
|
||||||
|
// [exact_prefix, permission] — only checks pages that actually need a permission
|
||||||
|
['/dashboard', 'dashboard.view'],
|
||||||
|
['/virtual-machines', 'vms.view'],
|
||||||
|
['/vms', 'proxmox.view'],
|
||||||
|
['/proxmox/vms', 'proxmox.view'],
|
||||||
|
['/proxmox/server', 'proxmox.view'],
|
||||||
|
['/templates', 'templates.view'],
|
||||||
|
['/backups', 'backups.view'],
|
||||||
|
['/proxmox/api/backups', 'backups.view'],
|
||||||
|
['/ipam', 'ipam.view'],
|
||||||
|
['/containers', 'docker.view'],
|
||||||
|
['/logs', 'logs.view'],
|
||||||
|
['/admin/users', 'users.view'],
|
||||||
|
];
|
||||||
|
|
||||||
navLinks.forEach(link => {
|
document.querySelectorAll('.sidebar-nav li[data-nav-permission]').forEach(li => {
|
||||||
const href = link.getAttribute('href');
|
const perm = li.getAttribute('data-nav-permission');
|
||||||
let requiredPermission = null;
|
if (!window.hasPermission(perm)) {
|
||||||
|
li.style.display = 'none';
|
||||||
// Map routes to required permissions
|
} else {
|
||||||
if (href === '/vms' || href.startsWith('/proxmox')) {
|
li.style.display = '';
|
||||||
requiredPermission = 'proxmox.view';
|
|
||||||
} else if (href === '/templates' || href.startsWith('/templates')) {
|
|
||||||
requiredPermission = 'templates.view';
|
|
||||||
} else if (href === '/ipam' || href.startsWith('/ipam')) {
|
|
||||||
requiredPermission = 'ipam.view';
|
|
||||||
} else if (href === '/logs' || href.startsWith('/logs')) {
|
|
||||||
requiredPermission = 'logs.view';
|
|
||||||
} else if (href === '/settings' || href.startsWith('/settings')) {
|
|
||||||
requiredPermission = 'settings.view';
|
|
||||||
} else if (href === '/admin/users' || href.startsWith('/admin/users')) {
|
|
||||||
requiredPermission = 'users.view';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hide menu item if no permission
|
|
||||||
if (requiredPermission && !window.hasPermission(requiredPermission)) {
|
|
||||||
const li = link.closest('li');
|
|
||||||
if (li) {
|
|
||||||
li.style.display = 'none';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Apply permissions to elements with data-permission attribute
|
// Apply permissions to elements with data-permission attribute
|
||||||
window.applyPermissions();
|
window.applyPermissions();
|
||||||
|
|
||||||
|
// --- Redirect if current page is forbidden ---
|
||||||
|
const path = window.location.pathname;
|
||||||
|
for (const [prefix, perm] of PAGE_PERMISSION_MAP) {
|
||||||
|
// Match if path equals prefix or starts with prefix + '/'
|
||||||
|
const isMatch = path === prefix || path.startsWith(prefix + '/');
|
||||||
|
if (isMatch && !window.hasPermission(perm)) {
|
||||||
|
const firstAllowed = _findFirstAllowedPage();
|
||||||
|
// Avoid looping: only redirect if target differs from current page
|
||||||
|
if (firstAllowed && firstAllowed !== path && !path.startsWith(firstAllowed + '/')) {
|
||||||
|
window.location.replace(firstAllowed);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reveal the nav now that permissions are applied (prevents flash of admin items)
|
||||||
|
const sidebarNav = document.querySelector('.sidebar-nav');
|
||||||
|
if (sidebarNav) sidebarNav.classList.add('nav-ready');
|
||||||
|
}
|
||||||
|
|
||||||
|
function _findFirstAllowedPage() {
|
||||||
|
const candidates = [
|
||||||
|
['vms.view', '/virtual-machines'],
|
||||||
|
['proxmox.view', '/vms'],
|
||||||
|
['backups.view', '/backups'],
|
||||||
|
['ipam.view', '/ipam'],
|
||||||
|
['logs.view', '/logs'],
|
||||||
|
['users.view', '/admin/users'],
|
||||||
|
['templates.view', '/templates'],
|
||||||
|
['docker.view', '/containers'],
|
||||||
|
];
|
||||||
|
for (const [perm, page] of candidates) {
|
||||||
|
if (window.hasPermission(perm)) return page;
|
||||||
|
}
|
||||||
|
// /settings and /tasks are open to all authenticated users — safe fallback
|
||||||
|
return '/settings';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notification System
|
// Notification System
|
||||||
@@ -1044,7 +1392,7 @@ body.update-in-progress .layout {
|
|||||||
const dotEl = document.getElementById('sidebarWorkspaceColorDot');
|
const dotEl = document.getElementById('sidebarWorkspaceColorDot');
|
||||||
if (!nameEl) return;
|
if (!nameEl) return;
|
||||||
if (!ws) {
|
if (!ws) {
|
||||||
nameEl.textContent = 'Все';
|
nameEl.textContent = t('all_workspaces');
|
||||||
if (dotEl) { dotEl.style.background = '#0ea5e9'; dotEl.style.opacity = '0.4'; }
|
if (dotEl) { dotEl.style.background = '#0ea5e9'; dotEl.style.opacity = '0.4'; }
|
||||||
} else {
|
} else {
|
||||||
nameEl.textContent = ws.name;
|
nameEl.textContent = ws.name;
|
||||||
@@ -1061,7 +1409,7 @@ body.update-in-progress .layout {
|
|||||||
if (isAdmin) {
|
if (isAdmin) {
|
||||||
const allItem = document.createElement('div');
|
const allItem = document.createElement('div');
|
||||||
allItem.className = 'workspace-dropdown-item' + (_activeWorkspaceId === null ? ' active' : '');
|
allItem.className = 'workspace-dropdown-item' + (_activeWorkspaceId === null ? ' active' : '');
|
||||||
allItem.innerHTML = `<span class="workspace-dropdown-dot" style="background:#0ea5e9;opacity:0.4;"></span><span>Все</span>`;
|
allItem.innerHTML = `<span class="workspace-dropdown-dot" style="background:#0ea5e9;opacity:0.4;"></span><span>${t('all_workspaces')}</span>`;
|
||||||
allItem.onclick = (e) => { e.stopPropagation(); _wsSelect(null, null, isAdmin); };
|
allItem.onclick = (e) => { e.stopPropagation(); _wsSelect(null, null, isAdmin); };
|
||||||
container.appendChild(allItem);
|
container.appendChild(allItem);
|
||||||
}
|
}
|
||||||
@@ -1069,7 +1417,7 @@ body.update-in-progress .layout {
|
|||||||
workspaces.forEach(ws => {
|
workspaces.forEach(ws => {
|
||||||
const item = document.createElement('div');
|
const item = document.createElement('div');
|
||||||
item.className = 'workspace-dropdown-item' + (_activeWorkspaceId === ws.id ? ' active' : '');
|
item.className = 'workspace-dropdown-item' + (_activeWorkspaceId === ws.id ? ' active' : '');
|
||||||
item.innerHTML = `<span class="workspace-dropdown-dot" style="background:${ws.color || '#0ea5e9'};"></span><span>${ws.name}</span><small>${ws.server_count} серв.</small>`;
|
item.innerHTML = `<span class="workspace-dropdown-dot" style="background:${ws.color || '#0ea5e9'};"></span><span>${ws.name}</span><small>${ws.server_count} ${t('servers_short')}</small>`;
|
||||||
item.onclick = (e) => { e.stopPropagation(); _wsSelect(ws.id, ws, isAdmin); };
|
item.onclick = (e) => { e.stopPropagation(); _wsSelect(ws.id, ws, isAdmin); };
|
||||||
container.appendChild(item);
|
container.appendChild(item);
|
||||||
});
|
});
|
||||||
@@ -1682,12 +2030,12 @@ body.update-in-progress .layout {
|
|||||||
}
|
}
|
||||||
empty.style.display = 'none';
|
empty.style.display = 'none';
|
||||||
const icons = { running:'<i class="fa-solid fa-circle-notch" style="animation:task-spin 1.2s linear infinite;"></i>', pending:'<i class="fa-solid fa-clock"></i>', completed:'<i class="fa-solid fa-circle-check"></i>', failed:'<i class="fa-solid fa-circle-xmark"></i>', cancelled:'<i class="fa-solid fa-ban"></i>' };
|
const icons = { running:'<i class="fa-solid fa-circle-notch" style="animation:task-spin 1.2s linear infinite;"></i>', pending:'<i class="fa-solid fa-clock"></i>', completed:'<i class="fa-solid fa-circle-check"></i>', failed:'<i class="fa-solid fa-circle-xmark"></i>', cancelled:'<i class="fa-solid fa-ban"></i>' };
|
||||||
const labels = { running:'Выполняется', pending:'В очереди', completed:'Завершено', failed:'Ошибка', cancelled:'Отменено' };
|
const labels = { running:t('status_running'), pending:t('status_pending'), completed:t('status_completed'), failed:t('status_failed'), cancelled:t('status_cancelled') };
|
||||||
cards.innerHTML = list.map(t => {
|
cards.innerHTML = list.map(t => {
|
||||||
const st = normalizeTaskStatus(t.status);
|
const st = normalizeTaskStatus(t.status);
|
||||||
const pct = st === 'completed' ? 100 : st === 'failed' || st === 'cancelled' ? 100 :
|
const pct = st === 'completed' ? 100 : st === 'failed' || st === 'cancelled' ? 100 :
|
||||||
(t.kind === 'bulk' && t.total_items > 0 ? Math.round(t.processed_items/t.total_items*100) : (st==='running'?60:0));
|
(t.kind === 'bulk' && t.total_items > 0 ? Math.round(t.processed_items/t.total_items*100) : (st==='running'?60:0));
|
||||||
const desc = t.description || t.action || t.task_type || 'Задача';
|
const desc = t.description || t.action || t.task_type || window.t('task_default_name');
|
||||||
const ts = t.created_at ? new Date(t.created_at).toLocaleTimeString('ru-RU',{hour:'2-digit',minute:'2-digit'}) : '';
|
const ts = t.created_at ? new Date(t.created_at).toLocaleTimeString('ru-RU',{hour:'2-digit',minute:'2-digit'}) : '';
|
||||||
return `<div class="task-mini-card ${st}">
|
return `<div class="task-mini-card ${st}">
|
||||||
<div class="task-mini-top">
|
<div class="task-mini-top">
|
||||||
@@ -1921,7 +2269,7 @@ body.update-in-progress .layout {
|
|||||||
}
|
}
|
||||||
renderTaskDrawer();
|
renderTaskDrawer();
|
||||||
updateTaskBadge();
|
updateTaskBadge();
|
||||||
if (data.deleted > 0) showToast('success', 'Очищено', `Удалено ${data.deleted} задач`);
|
if (data.deleted > 0) showToast('success', t('cleared'), t('deleted_tasks').replace('{count}', data.deleted));
|
||||||
}
|
}
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
console.error('clearCompletedTasks error', e);
|
console.error('clearCompletedTasks error', e);
|
||||||
|
|||||||
@@ -11,7 +11,8 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block header_status %}
|
{% block sidebar_status %}
|
||||||
|
<div class="sidebar-status-section">
|
||||||
<div class="status-chip {% if stats.total_alerts == 0 %}status-ok{% elif stats.critical_alerts > 0 %}status-error{% else %}status-warning{% endif %}" id="dashStatusChip"{% if stats.total_alerts > 0 %} onclick="openAlertsModal()" style="cursor:pointer;" title="{{ t('view_alerts') if t('view_alerts') else 'Посмотреть предупреждения' }}"{% endif %}>
|
<div class="status-chip {% if stats.total_alerts == 0 %}status-ok{% elif stats.critical_alerts > 0 %}status-error{% else %}status-warning{% endif %}" id="dashStatusChip"{% if stats.total_alerts > 0 %} onclick="openAlertsModal()" style="cursor:pointer;" title="{{ t('view_alerts') if t('view_alerts') else 'Посмотреть предупреждения' }}"{% endif %}>
|
||||||
<span class="status-chip-dot"></span>
|
<span class="status-chip-dot"></span>
|
||||||
<span id="dashStatusText">
|
<span id="dashStatusText">
|
||||||
@@ -24,7 +25,7 @@
|
|||||||
{%- endif -%}
|
{%- endif -%}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-datetime" id="dashDatetime"></div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
@@ -201,24 +202,6 @@
|
|||||||
<script>
|
<script>
|
||||||
// ======== Dashboard JS ========
|
// ======== Dashboard JS ========
|
||||||
|
|
||||||
// Live clock
|
|
||||||
(function startClock() {
|
|
||||||
const el = document.getElementById('dashDatetime');
|
|
||||||
if (!el) return;
|
|
||||||
function tick() {
|
|
||||||
const now = new Date();
|
|
||||||
el.textContent = now.toLocaleDateString(
|
|
||||||
'{{ "ru-RU" if lang == "ru" else "en-US" }}',
|
|
||||||
{ month: 'short', day: 'numeric' }
|
|
||||||
) + ', ' + now.toLocaleTimeString(
|
|
||||||
'{{ "ru-RU" if lang == "ru" else "en-US" }}',
|
|
||||||
{ hour: '2-digit', minute: '2-digit' }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
tick();
|
|
||||||
setInterval(tick, 30000);
|
|
||||||
})();
|
|
||||||
|
|
||||||
// Colour the % badge based on value
|
// Colour the % badge based on value
|
||||||
function setPctBadgeColor(el, pct) {
|
function setPctBadgeColor(el, pct) {
|
||||||
el.classList.remove('pct-ok', 'pct-warn', 'pct-high');
|
el.classList.remove('pct-ok', 'pct-warn', 'pct-high');
|
||||||
|
|||||||
@@ -742,7 +742,7 @@ async function executeCommand(command) {
|
|||||||
const outputContent = document.getElementById('command-output-content');
|
const outputContent = document.getElementById('command-output-content');
|
||||||
|
|
||||||
outputDiv.style.display = 'block';
|
outputDiv.style.display = 'block';
|
||||||
outputContent.innerHTML = `$ ${command}\n<i class="fa-solid fa-spinner fa-spin"></i> Выполняется...\n`;
|
outputContent.innerHTML = `$ ${command}\n<i class="fa-solid fa-spinner fa-spin"></i> ${t('executing_command')}\n`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const timeout = 30;
|
const timeout = 30;
|
||||||
@@ -755,8 +755,8 @@ async function executeCommand(command) {
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.text();
|
const error = await response.text();
|
||||||
outputContent.innerHTML = `$ ${command}\n<i class="fa-solid fa-circle-xmark"></i> Ошибка: ${error}`;
|
outputContent.innerHTML = `$ ${command}\n<i class="fa-solid fa-circle-xmark"></i> ${t('error')}: ${error}`;
|
||||||
showNotification('Ошибка выполнения команды', 'error');
|
showNotification(t('command_execution_error'), 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -764,8 +764,8 @@ async function executeCommand(command) {
|
|||||||
displayCommandOutput(result, command);
|
displayCommandOutput(result, command);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
outputContent.innerHTML = `$ ${command}\n<i class="fa-solid fa-circle-xmark"></i> Ошибка: ${error.message}`;
|
outputContent.innerHTML = `$ ${command}\n<i class="fa-solid fa-circle-xmark"></i> ${t('error')}: ${error.message}`;
|
||||||
showNotification('Ошибка выполнения команды', 'error');
|
showNotification(t('command_execution_error'), 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -799,7 +799,7 @@ function displayCommandOutput(result, command = null) {
|
|||||||
output += `\n\nExit code: ${result.exit_code}`;
|
output += `\n\nExit code: ${result.exit_code}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
showNotification('Ошибка выполнения', 'error');
|
showNotification(t('execution_error'), 'error');
|
||||||
}
|
}
|
||||||
|
|
||||||
outputContent.textContent = output;
|
outputContent.textContent = output;
|
||||||
@@ -952,7 +952,7 @@ function drawCharts(data, timeframe = 'hour') {
|
|||||||
labels: labels,
|
labels: labels,
|
||||||
datasets: [
|
datasets: [
|
||||||
{
|
{
|
||||||
label: 'Использовано',
|
label: t('used'),
|
||||||
data: data.map(d => {
|
data: data.map(d => {
|
||||||
const used = d.mem || d.memused || 0;
|
const used = d.mem || d.memused || 0;
|
||||||
return (used / (1024**3)).toFixed(2);
|
return (used / (1024**3)).toFixed(2);
|
||||||
@@ -965,7 +965,7 @@ function drawCharts(data, timeframe = 'hour') {
|
|||||||
pointRadius: 0
|
pointRadius: 0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Выделено',
|
label: t('allocated'),
|
||||||
data: data.map(d => {
|
data: data.map(d => {
|
||||||
const max = d.maxmem || d.memtotal || 1;
|
const max = d.maxmem || d.memtotal || 1;
|
||||||
return (max / (1024**3)).toFixed(2);
|
return (max / (1024**3)).toFixed(2);
|
||||||
@@ -1171,7 +1171,7 @@ async function deleteInstance() {
|
|||||||
// Second confirmation - enter ID
|
// Second confirmation - enter ID
|
||||||
const confirmId = await showPrompt(
|
const confirmId = await showPrompt(
|
||||||
t('enter_id_to_confirm').replace('{id}', vmid),
|
t('enter_id_to_confirm').replace('{id}', vmid),
|
||||||
t('confirm_deletion') || 'Подтверждение удаления',
|
t('confirm_deletion'),
|
||||||
{
|
{
|
||||||
type: 'danger',
|
type: 'danger',
|
||||||
icon: '<i class="fa-solid fa-triangle-exclamation"></i>',
|
icon: '<i class="fa-solid fa-triangle-exclamation"></i>',
|
||||||
@@ -1314,25 +1314,25 @@ async function openVNC() {
|
|||||||
|
|
||||||
vncConnection.addEventListener('disconnect', (e) => {
|
vncConnection.addEventListener('disconnect', (e) => {
|
||||||
if (e.detail.clean) {
|
if (e.detail.clean) {
|
||||||
vncContent.innerHTML = '<div style="display: flex; align-items: center; justify-content: center; height: 100%; color: var(--text-secondary);">Соединение закрыто</div>';
|
vncContent.innerHTML = `<div style="display: flex; align-items: center; justify-content: center; height: 100%; color: var(--text-secondary);">${t('connection_closed')}</div>`;
|
||||||
} else {
|
} else {
|
||||||
vncContent.innerHTML = `
|
vncContent.innerHTML = `
|
||||||
<div style="display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; color: var(--text-secondary); padding: 2rem;">
|
<div style="display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; color: var(--text-secondary); padding: 2rem;">
|
||||||
<div style="font-size: 2rem; margin-bottom: 1rem;"><i class="fa-solid fa-triangle-exclamation"></i></div>
|
<div style="font-size: 2rem; margin-bottom: 1rem;"><i class="fa-solid fa-triangle-exclamation"></i></div>
|
||||||
<div style="margin-bottom: 1rem; font-size: 1.125rem;">Соединение прервано</div>
|
<div style="margin-bottom: 1rem; font-size: 1.125rem;">${t('connection_interrupted')}</div>
|
||||||
<div style="background: var(--bg-tertiary); padding: 1.5rem; border-radius: 8px; max-width: 500px; text-align: left; margin-bottom: 1rem;">
|
<div style="background: var(--bg-tertiary); padding: 1.5rem; border-radius: 8px; max-width: 500px; text-align: left; margin-bottom: 1rem;">
|
||||||
<p style="color: var(--text-muted); font-size: 0.875rem; margin-bottom: 0.5rem;">
|
<p style="color: var(--text-muted); font-size: 0.875rem; margin-bottom: 0.5rem;">
|
||||||
<strong>Возможные причины:</strong>
|
<strong>${t('possible_reasons')}:</strong>
|
||||||
</p>
|
</p>
|
||||||
<ul style="color: var(--text-muted); font-size: 0.8rem; margin-left: 1rem;">
|
<ul style="color: var(--text-muted); font-size: 0.8rem; margin-left: 1rem;">
|
||||||
<li>Proxmox VNC требует авторизацию по паролю (не API token)</li>
|
<li>${t('vnc_requires_password')}</li>
|
||||||
<li>Добавьте пароль в настройках Proxmox сервера</li>
|
<li>${t('vnc_add_password')}</li>
|
||||||
<li>VM/контейнер может быть выключен</li>
|
<li>${t('vnc_vm_may_be_off')}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div style="display: flex; gap: 1rem;">
|
<div style="display: flex; gap: 1rem;">
|
||||||
<button onclick="openVNC()" class="btn btn-primary"><i class="fa-solid fa-rotate"></i> Переподключиться</button>
|
<button onclick="openVNC()" class="btn btn-primary"><i class="fa-solid fa-rotate"></i> ${t('reconnect')}</button>
|
||||||
<a href="https://${vncData.host}:8006" target="_blank" class="btn btn-secondary"><i class="fa-solid fa-globe"></i> Открыть Proxmox</a>
|
<a href="https://${vncData.host}:8006" target="_blank" class="btn btn-secondary"><i class="fa-solid fa-globe"></i> ${t('open_proxmox')}</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -1343,8 +1343,8 @@ async function openVNC() {
|
|||||||
vncContent.innerHTML = `
|
vncContent.innerHTML = `
|
||||||
<div style="padding: 2rem; text-align: center;">
|
<div style="padding: 2rem; text-align: center;">
|
||||||
<div style="font-size: 2rem; margin-bottom: 1rem;"><i class="fa-solid fa-lock"></i></div>
|
<div style="font-size: 2rem; margin-bottom: 1rem;"><i class="fa-solid fa-lock"></i></div>
|
||||||
<div class="text-danger">Ошибка безопасности VNC</div>
|
<div class="text-danger">${t('vnc_security_error')}</div>
|
||||||
<p style="color: var(--text-muted); font-size: 0.875rem; margin-top: 0.5rem;">${e.detail.reason || 'Неизвестная ошибка'}</p>
|
<p style="color: var(--text-muted); font-size: 0.875rem; margin-top: 0.5rem;">${e.detail.reason || t('unknown_error')}</p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
});
|
});
|
||||||
@@ -1739,7 +1739,7 @@ function closeEditModal() {
|
|||||||
async function showReinstallModal() {
|
async function showReinstallModal() {
|
||||||
document.getElementById('reinstallModal').style.display = 'flex';
|
document.getElementById('reinstallModal').style.display = 'flex';
|
||||||
const content = document.getElementById('reinstallContent');
|
const content = document.getElementById('reinstallContent');
|
||||||
content.innerHTML = '<div style="text-align: center; padding: 2rem; color: var(--text-muted);"><i class="fa-solid fa-spinner fa-spin"></i> Загрузка шаблонов...</div>';
|
content.innerHTML = `<div style="text-align: center; padding: 2rem; color: var(--text-muted);"><i class="fa-solid fa-spinner fa-spin"></i> ${t('loading')}</div>`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [groupsRes, templatesRes] = await Promise.all([
|
const [groupsRes, templatesRes] = await Promise.all([
|
||||||
@@ -1754,8 +1754,8 @@ async function showReinstallModal() {
|
|||||||
content.innerHTML = `
|
content.innerHTML = `
|
||||||
<div style="text-align: center; padding: 2rem;">
|
<div style="text-align: center; padding: 2rem;">
|
||||||
<div style="font-size: 2rem; margin-bottom: 1rem;"><i class="fa-solid fa-cube"></i></div>
|
<div style="font-size: 2rem; margin-bottom: 1rem;"><i class="fa-solid fa-cube"></i></div>
|
||||||
<p style="color: var(--text-secondary);">Нет доступных шаблонов для этого сервера</p>
|
<p style="color: var(--text-secondary);">${t('no_templates_for_server')}</p>
|
||||||
<a href="/templates" class="btn" style="margin-top: 1rem;">Добавить шаблоны</a>
|
<a href="/templates" class="btn" style="margin-top: 1rem;">${t('add_templates')}</a>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
return;
|
return;
|
||||||
@@ -1920,22 +1920,22 @@ async function doReinstallInstance(templateId, cloudInitUser, cloudInitPassword,
|
|||||||
const stopRes = await fetchWithAuth(`/proxmox/api/${serverId}/${apiType}/${vmid}/stop?node=${instanceNode}`, { method: 'POST' });
|
const stopRes = await fetchWithAuth(`/proxmox/api/${serverId}/${apiType}/${vmid}/stop?node=${instanceNode}`, { method: 'POST' });
|
||||||
if (!stopRes.ok) {
|
if (!stopRes.ok) {
|
||||||
const error = await stopRes.json();
|
const error = await stopRes.json();
|
||||||
throw new Error(`Ошибка остановки: ${error.detail || stopRes.statusText}`);
|
throw new Error(`${t('stop_error')}: ${error.detail || stopRes.statusText}`);
|
||||||
}
|
}
|
||||||
await new Promise(r => setTimeout(r, 5000));
|
await new Promise(r => setTimeout(r, 5000));
|
||||||
}
|
}
|
||||||
|
|
||||||
showNotification('<i class="fa-solid fa-spinner fa-spin"></i> Удаление старого инстанса...', 'warning');
|
showNotification(`<i class="fa-solid fa-spinner fa-spin"></i> ${t('deleting_old_instance')}`, 'warning');
|
||||||
|
|
||||||
// Удалить (не force, чтобы сохранилось в базу)
|
// Удалить (не force, чтобы сохранилось в базу)
|
||||||
const deleteRes = await fetchWithAuth(`/proxmox/api/${serverId}/${apiType}/${vmid}?node=${instanceNode}`, { method: 'DELETE' });
|
const deleteRes = await fetchWithAuth(`/proxmox/api/${serverId}/${apiType}/${vmid}?node=${instanceNode}`, { method: 'DELETE' });
|
||||||
if (!deleteRes.ok) {
|
if (!deleteRes.ok) {
|
||||||
const error = await deleteRes.json();
|
const error = await deleteRes.json();
|
||||||
throw new Error(`Ошибка удаления: ${error.detail || deleteRes.statusText}`);
|
throw new Error(`${t('delete_error')}: ${error.detail || deleteRes.statusText}`);
|
||||||
}
|
}
|
||||||
await new Promise(r => setTimeout(r, 5000));
|
await new Promise(r => setTimeout(r, 5000));
|
||||||
|
|
||||||
showNotification('<i class="fa-solid fa-spinner fa-spin"></i> Развертывание из шаблона...', 'warning');
|
showNotification(`<i class="fa-solid fa-spinner fa-spin"></i> ${t('deploying_from_template')}`, 'warning');
|
||||||
|
|
||||||
// Формируем тело запроса с сохраненной конфигурацией и cloud-init параметрами
|
// Формируем тело запроса с сохраненной конфигурацией и cloud-init параметрами
|
||||||
const deployData = {
|
const deployData = {
|
||||||
@@ -1976,7 +1976,7 @@ async function doReinstallInstance(templateId, cloudInitUser, cloudInitPassword,
|
|||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
showNotification(`<i class="fa-solid fa-circle-check"></i> Переустановка завершена! VMID: ${result.vmid}`, 'success');
|
showNotification(`<i class="fa-solid fa-circle-check"></i> ${t('reinstall_completed_vmid').replace('{vmid}', result.vmid)}`, 'success');
|
||||||
closeReinstallModal();
|
closeReinstallModal();
|
||||||
// Перезагружаем страницу через 2 секунды (VMID остался тот же)
|
// Перезагружаем страницу через 2 секунды (VMID остался тот же)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -1991,7 +1991,7 @@ async function doReinstallInstance(templateId, cloudInitUser, cloudInitPassword,
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Reinstall error:', error);
|
console.error('Reinstall error:', error);
|
||||||
showNotification(`<i class="fa-solid fa-circle-xmark"></i> Ошибка: ${error.message}`, 'error');
|
showNotification(`<i class="fa-solid fa-circle-xmark"></i> ${t('error')}: ${error.message}`, 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -226,10 +226,33 @@
|
|||||||
// Save token to localStorage
|
// Save token to localStorage
|
||||||
localStorage.setItem('access_token', data.access_token);
|
localStorage.setItem('access_token', data.access_token);
|
||||||
|
|
||||||
// Redirect to original page or dashboard
|
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
const returnUrl = urlParams.get('returnUrl') ? decodeURIComponent(urlParams.get('returnUrl')) : '/dashboard';
|
if (urlParams.get('returnUrl')) {
|
||||||
window.location.href = returnUrl;
|
window.location.href = decodeURIComponent(urlParams.get('returnUrl'));
|
||||||
|
} else {
|
||||||
|
// Fetch profile to decide landing page based on actual permissions
|
||||||
|
try {
|
||||||
|
const meResp = await fetch('/api/auth/me', { headers: { 'Authorization': `Bearer ${data.access_token}` } });
|
||||||
|
const me = meResp.ok ? await meResp.json() : {};
|
||||||
|
const perms = me.permissions || {};
|
||||||
|
const isAdmin = me.is_admin === true || me.role?.name === 'admin';
|
||||||
|
if (isAdmin || perms['dashboard:view'] || perms['dashboard.view']) {
|
||||||
|
window.location.href = '/dashboard';
|
||||||
|
} else if (perms['vm:view'] || perms['vms.view']) {
|
||||||
|
window.location.href = '/virtual-machines';
|
||||||
|
} else if (perms['server:view'] || perms['proxmox.view']) {
|
||||||
|
window.location.href = '/vms';
|
||||||
|
} else if (perms['backup:view'] || perms['backups.view']) {
|
||||||
|
window.location.href = '/backups';
|
||||||
|
} else if (perms['ipam:view'] || perms['ipam.view']) {
|
||||||
|
window.location.href = '/ipam';
|
||||||
|
} else if (perms['log:view'] || perms['logs.view']) {
|
||||||
|
window.location.href = '/logs';
|
||||||
|
} else {
|
||||||
|
window.location.href = '/settings';
|
||||||
|
}
|
||||||
|
} catch { window.location.href = '/settings'; }
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Show error
|
// Show error
|
||||||
errorMessage.textContent = data.detail || '{{ t('login_error_check_credentials') }}';
|
errorMessage.textContent = data.detail || '{{ t('login_error_check_credentials') }}';
|
||||||
@@ -255,11 +278,34 @@
|
|||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${token}`
|
'Authorization': `Bearer ${token}`
|
||||||
}
|
}
|
||||||
}).then(response => {
|
}).then(async response => {
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
const returnUrl = urlParams.get('returnUrl') ? decodeURIComponent(urlParams.get('returnUrl')) : '/dashboard';
|
if (urlParams.get('returnUrl')) {
|
||||||
window.location.href = returnUrl;
|
window.location.href = decodeURIComponent(urlParams.get('returnUrl'));
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const meResp = await fetch('/api/auth/me', { headers: { 'Authorization': `Bearer ${token}` } });
|
||||||
|
const me = meResp.ok ? await meResp.json() : {};
|
||||||
|
const perms = me.permissions || {};
|
||||||
|
const isAdmin = me.is_admin === true || me.role?.name === 'admin';
|
||||||
|
if (isAdmin || perms['dashboard:view'] || perms['dashboard.view']) {
|
||||||
|
window.location.href = '/dashboard';
|
||||||
|
} else if (perms['vm:view'] || perms['vms.view']) {
|
||||||
|
window.location.href = '/virtual-machines';
|
||||||
|
} else if (perms['server:view'] || perms['proxmox.view']) {
|
||||||
|
window.location.href = '/vms';
|
||||||
|
} else if (perms['backup:view'] || perms['backups.view']) {
|
||||||
|
window.location.href = '/backups';
|
||||||
|
} else if (perms['ipam:view'] || perms['ipam.view']) {
|
||||||
|
window.location.href = '/ipam';
|
||||||
|
} else if (perms['log:view'] || perms['logs.view']) {
|
||||||
|
window.location.href = '/logs';
|
||||||
|
} else {
|
||||||
|
window.location.href = '/settings';
|
||||||
|
}
|
||||||
|
} catch { window.location.href = '/settings'; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,21 +44,24 @@
|
|||||||
<th class="sortable" data-sort="node" onclick="sortTable('node')">
|
<th class="sortable" data-sort="node" onclick="sortTable('node')">
|
||||||
{{ t('node') or 'Node' }} <i class="fa-solid fa-sort sort-icon"></i>
|
{{ t('node') or 'Node' }} <i class="fa-solid fa-sort sort-icon"></i>
|
||||||
</th>
|
</th>
|
||||||
<th class="sortable" data-sort="vms" onclick="sortTable('vms')">
|
<th class="sortable" data-sort="vms" onclick="sortTable('vms')" style="text-align:center;">
|
||||||
VM <i class="fa-solid fa-sort sort-icon"></i>
|
VM <i class="fa-solid fa-sort sort-icon"></i>
|
||||||
</th>
|
</th>
|
||||||
<th class="sortable" data-sort="lxc" onclick="sortTable('lxc')">
|
<th class="sortable" data-sort="lxc" onclick="sortTable('lxc')" style="text-align:center;">
|
||||||
LXC <i class="fa-solid fa-sort sort-icon"></i>
|
LXC <i class="fa-solid fa-sort sort-icon"></i>
|
||||||
</th>
|
</th>
|
||||||
<th class="sortable" data-sort="status" onclick="sortTable('status')">
|
<th class="sortable" data-sort="status" onclick="sortTable('status')" style="text-align:center;">
|
||||||
{{ t('status') }} <i class="fa-solid fa-sort sort-icon"></i>
|
{{ t('status') }} <i class="fa-solid fa-sort sort-icon"></i>
|
||||||
</th>
|
</th>
|
||||||
|
<th data-sort="uptime" style="text-align:center; white-space:nowrap;">
|
||||||
|
Uptime
|
||||||
|
</th>
|
||||||
<th style="text-align: right;">{{ t('actions') }}</th>
|
<th style="text-align: right;">{{ t('actions') }}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="serversTableBody">
|
<tbody id="serversTableBody">
|
||||||
<tr id="servers-loading-row">
|
<tr id="servers-loading-row">
|
||||||
<td colspan="7" style="text-align:center; color:var(--text-muted); padding:2rem;">
|
<td colspan="8" style="text-align:center; color:var(--text-muted); padding:2rem;">
|
||||||
<i class="fa-solid fa-spinner fa-spin"></i> {{ t('loading') }}
|
<i class="fa-solid fa-spinner fa-spin"></i> {{ t('loading') }}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -317,31 +320,39 @@
|
|||||||
<div style="margin-bottom: 1rem; padding: 1rem; background: var(--bg-secondary); border-radius: 0.5rem;">
|
<div style="margin-bottom: 1rem; padding: 1rem; background: var(--bg-secondary); border-radius: 0.5rem;">
|
||||||
<div class="checkbox-row">
|
<div class="checkbox-row">
|
||||||
<label style="display: flex; align-items: center; gap: 0.5rem;">
|
<label style="display: flex; align-items: center; gap: 0.5rem;">
|
||||||
<input type="checkbox" id="editUsePassword" onchange="toggleEditAuthMethod()">
|
<input type="checkbox" id="editChangeCredentials" onchange="toggleEditCredentialsSection()">
|
||||||
<span>{{ t('use_password_less_secure') }}</span>
|
<span>{{ t('change_credentials') or 'Изменить учётные данные' }}</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="editTokenFields">
|
<div id="editCredentialsSection" style="display: none;">
|
||||||
<div class="form-row">
|
<div style="margin-bottom: 1rem; padding: 1rem; background: var(--bg-secondary); border-radius: 0.5rem;">
|
||||||
<div>
|
<div class="checkbox-row">
|
||||||
<label title="TokenID из Proxmox">TokenID * <span style="color: var(--text-secondary); cursor: help;"><i class="fa-solid fa-circle-info"></i></span></label>
|
<label style="display: flex; align-items: center; gap: 0.5rem;">
|
||||||
<input type="text" id="editTokenName" name="api_token_name" placeholder="mytoken">
|
<input type="checkbox" id="editUsePassword" onchange="toggleEditAuthMethod()">
|
||||||
</div>
|
<span>{{ t('use_password_less_secure') }}</span>
|
||||||
<div>
|
</label>
|
||||||
<label title="Secret из Proxmox">Secret * <span style="color: var(--text-secondary); cursor: help;"><i class="fa-solid fa-circle-info"></i></span></label>
|
|
||||||
<input type="text" id="editTokenValue" name="api_token_value" placeholder="xxxxxxxx-xxxx-xxxx">
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="editPasswordField" style="display: none;">
|
<div id="editTokenFields">
|
||||||
<label>{{ t('password') }} *</label>
|
<div class="form-row">
|
||||||
<input type="password" id="editPassword" name="password" placeholder="{{ t('password') }}">
|
<div>
|
||||||
</div>
|
<label title="TokenID из Proxmox">TokenID * <span style="color: var(--text-secondary); cursor: help;"><i class="fa-solid fa-circle-info"></i></span></label>
|
||||||
|
<input type="text" id="editTokenName" name="api_token_name" placeholder="mytoken">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label title="Secret из Proxmox">Secret * <span style="color: var(--text-secondary); cursor: help;"><i class="fa-solid fa-circle-info"></i></span></label>
|
||||||
|
<input type="text" id="editTokenValue" name="api_token_value" placeholder="xxxxxxxx-xxxx-xxxx">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style="margin: 1rem 0;">
|
<div id="editPasswordField" style="display: none;">
|
||||||
|
<label>{{ t('password') }} *</label>
|
||||||
|
<input type="password" id="editPassword" name="password" placeholder="{{ t('password') }}">
|
||||||
|
</div>
|
||||||
<div class="checkbox-row">
|
<div class="checkbox-row">
|
||||||
<label style="display: flex; align-items: center; gap: 0.5rem;">
|
<label style="display: flex; align-items: center; gap: 0.5rem;">
|
||||||
<input type="checkbox" id="editVerifySsl" name="verify_ssl">
|
<input type="checkbox" id="editVerifySsl" name="verify_ssl">
|
||||||
@@ -717,9 +728,27 @@ function showEditServerModal() {
|
|||||||
function closeEditServerModal() {
|
function closeEditServerModal() {
|
||||||
document.getElementById('editServerModal').style.display = 'none';
|
document.getElementById('editServerModal').style.display = 'none';
|
||||||
document.getElementById('editServerForm').reset();
|
document.getElementById('editServerForm').reset();
|
||||||
|
document.getElementById('editCredentialsSection').style.display = 'none';
|
||||||
|
document.getElementById('editChangeCredentials').checked = false;
|
||||||
document.getElementById('editTokenFields').style.display = 'block';
|
document.getElementById('editTokenFields').style.display = 'block';
|
||||||
document.getElementById('editPasswordField').style.display = 'none';
|
document.getElementById('editPasswordField').style.display = 'none';
|
||||||
document.getElementById('editUsePassword').checked = false;
|
document.getElementById('editUsePassword').checked = false;
|
||||||
|
document.getElementById('editTokenName').removeAttribute('required');
|
||||||
|
document.getElementById('editTokenValue').removeAttribute('required');
|
||||||
|
document.getElementById('editPassword').removeAttribute('required');
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleEditCredentialsSection() {
|
||||||
|
const show = document.getElementById('editChangeCredentials').checked;
|
||||||
|
document.getElementById('editCredentialsSection').style.display = show ? 'block' : 'none';
|
||||||
|
if (show) {
|
||||||
|
document.getElementById('editUsePassword').checked = false;
|
||||||
|
toggleEditAuthMethod();
|
||||||
|
} else {
|
||||||
|
document.getElementById('editTokenName').removeAttribute('required');
|
||||||
|
document.getElementById('editTokenValue').removeAttribute('required');
|
||||||
|
document.getElementById('editPassword').removeAttribute('required');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleEditAuthMethod() {
|
function toggleEditAuthMethod() {
|
||||||
@@ -761,8 +790,12 @@ async function editServer(serverId) {
|
|||||||
document.getElementById('editDescription').value = server.description || '';
|
document.getElementById('editDescription').value = server.description || '';
|
||||||
|
|
||||||
// Note: We don't populate token/password fields for security
|
// Note: We don't populate token/password fields for security
|
||||||
|
document.getElementById('editChangeCredentials').checked = false;
|
||||||
|
document.getElementById('editCredentialsSection').style.display = 'none';
|
||||||
document.getElementById('editUsePassword').checked = false;
|
document.getElementById('editUsePassword').checked = false;
|
||||||
toggleEditAuthMethod();
|
document.getElementById('editTokenName').removeAttribute('required');
|
||||||
|
document.getElementById('editTokenValue').removeAttribute('required');
|
||||||
|
document.getElementById('editPassword').removeAttribute('required');
|
||||||
|
|
||||||
showEditServerModal();
|
showEditServerModal();
|
||||||
} else {
|
} else {
|
||||||
@@ -790,20 +823,23 @@ async function submitEditServer(event) {
|
|||||||
verify_ssl: formData.get('verify_ssl') === 'on'
|
verify_ssl: formData.get('verify_ssl') === 'on'
|
||||||
};
|
};
|
||||||
|
|
||||||
// Only update auth credentials if provided
|
// Only update auth credentials if user explicitly chose to change them
|
||||||
if (usePassword) {
|
const changeCredentials = document.getElementById('editChangeCredentials').checked;
|
||||||
const password = formData.get('password');
|
if (changeCredentials) {
|
||||||
if (password) {
|
if (usePassword) {
|
||||||
data.use_password = true;
|
const password = formData.get('password');
|
||||||
data.password = password;
|
if (password) {
|
||||||
}
|
data.use_password = true;
|
||||||
} else {
|
data.password = password;
|
||||||
const tokenName = formData.get('api_token_name');
|
}
|
||||||
const tokenValue = formData.get('api_token_value');
|
} else {
|
||||||
if (tokenName && tokenValue) {
|
const tokenName = formData.get('api_token_name');
|
||||||
data.use_password = false;
|
const tokenValue = formData.get('api_token_value');
|
||||||
data.api_token_name = tokenName;
|
if (tokenName && tokenValue) {
|
||||||
data.api_token_value = tokenValue;
|
data.use_password = false;
|
||||||
|
data.api_token_name = tokenName;
|
||||||
|
data.api_token_value = tokenValue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -888,6 +924,7 @@ async function loadServerStatus(serverId) {
|
|||||||
const statusCell = document.getElementById(`server-status-${serverId}`);
|
const statusCell = document.getElementById(`server-status-${serverId}`);
|
||||||
const vmsCell = document.getElementById(`server-vms-${serverId}`);
|
const vmsCell = document.getElementById(`server-vms-${serverId}`);
|
||||||
const lxcCell = document.getElementById(`server-lxc-${serverId}`);
|
const lxcCell = document.getElementById(`server-lxc-${serverId}`);
|
||||||
|
const uptimeCell = document.getElementById(`server-uptime-${serverId}`);
|
||||||
const row = document.getElementById(`server-row-${serverId}`);
|
const row = document.getElementById(`server-row-${serverId}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -915,6 +952,22 @@ async function loadServerStatus(serverId) {
|
|||||||
// Update status
|
// Update status
|
||||||
statusCell.innerHTML = `<span class="status-badge status-online"><i class="fa-solid fa-circle"></i> Online</span>`;
|
statusCell.innerHTML = `<span class="status-badge status-online"><i class="fa-solid fa-circle"></i> Online</span>`;
|
||||||
|
|
||||||
|
// Update uptime
|
||||||
|
if (uptimeCell) {
|
||||||
|
const totalSeconds = data.node_uptime || 0;
|
||||||
|
if (totalSeconds > 0) {
|
||||||
|
const days = Math.floor(totalSeconds / 86400);
|
||||||
|
const hours = Math.floor((totalSeconds % 86400) / 3600);
|
||||||
|
const mins = Math.floor((totalSeconds % 3600) / 60);
|
||||||
|
let uptimeStr = days > 0
|
||||||
|
? `${days}d ${String(hours).padStart(2,'0')}:${String(mins).padStart(2,'0')}`
|
||||||
|
: `${String(hours).padStart(2,'0')}:${String(mins).padStart(2,'0')}`;
|
||||||
|
uptimeCell.innerHTML = `<span style="color:var(--text-primary);">${uptimeStr}</span>`;
|
||||||
|
} else {
|
||||||
|
uptimeCell.innerHTML = `<span style="color:var(--text-muted);">—</span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Update data attributes for sorting
|
// Update data attributes for sorting
|
||||||
if (row) {
|
if (row) {
|
||||||
row.dataset.vms = vmsCount;
|
row.dataset.vms = vmsCount;
|
||||||
@@ -929,6 +982,7 @@ async function loadServerStatus(serverId) {
|
|||||||
vmsCell.innerHTML = `<span style="color: var(--text-muted);">—</span>`;
|
vmsCell.innerHTML = `<span style="color: var(--text-muted);">—</span>`;
|
||||||
lxcCell.innerHTML = `<span style="color: var(--text-muted);">—</span>`;
|
lxcCell.innerHTML = `<span style="color: var(--text-muted);">—</span>`;
|
||||||
statusCell.innerHTML = `<span class="status-badge status-offline"><i class="fa-solid fa-circle-xmark"></i> Offline</span>`;
|
statusCell.innerHTML = `<span class="status-badge status-offline"><i class="fa-solid fa-circle-xmark"></i> Offline</span>`;
|
||||||
|
if (uptimeCell) uptimeCell.innerHTML = `<span style="color:var(--text-muted);">—</span>`;
|
||||||
if (row) row.dataset.status = 'offline';
|
if (row) row.dataset.status = 'offline';
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1922,7 +1976,7 @@ async function loadServersTable() {
|
|||||||
const tbody = document.getElementById('serversTableBody');
|
const tbody = document.getElementById('serversTableBody');
|
||||||
if (!tbody) return;
|
if (!tbody) return;
|
||||||
|
|
||||||
tbody.innerHTML = `<tr><td colspan="7" style="text-align:center;color:var(--text-muted);padding:2rem;"><i class="fa-solid fa-spinner fa-spin"></i> ${t('loading') || 'Загрузка...'}</td></tr>`;
|
tbody.innerHTML = `<tr><td colspan="8" style="text-align:center;color:var(--text-muted);padding:2rem;"><i class="fa-solid fa-spinner fa-spin"></i> ${t('loading') || 'Загрузка...'}</td></tr>`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetchWithAuth('/proxmox/api/servers');
|
const resp = await fetchWithAuth('/proxmox/api/servers');
|
||||||
@@ -1930,7 +1984,7 @@ async function loadServersTable() {
|
|||||||
const servers = await resp.json();
|
const servers = await resp.json();
|
||||||
|
|
||||||
if (!servers || servers.length === 0) {
|
if (!servers || servers.length === 0) {
|
||||||
tbody.innerHTML = `<tr><td colspan="7" style="text-align:center;color:var(--text-muted);padding:2rem;">
|
tbody.innerHTML = `<tr><td colspan="8" style="text-align:center;color:var(--text-muted);padding:2rem;">
|
||||||
${t('no_proxmox_servers') || 'Нет серверов.'} <a href="#" onclick="showAddServerModal();return false;" style="color:var(--success);">${t('add_first_server') || 'Добавить первый'}</a>
|
${t('no_proxmox_servers') || 'Нет серверов.'} <a href="#" onclick="showAddServerModal();return false;" style="color:var(--success);">${t('add_first_server') || 'Добавить первый'}</a>
|
||||||
</td></tr>`;
|
</td></tr>`;
|
||||||
return;
|
return;
|
||||||
@@ -1963,6 +2017,9 @@ async function loadServersTable() {
|
|||||||
<td id="server-status-${server.id}" style="text-align:center;">
|
<td id="server-status-${server.id}" style="text-align:center;">
|
||||||
<span class="status-badge status-loading"><i class="fa-solid fa-spinner fa-spin"></i> ${t('loading') || 'Загрузка'}</span>
|
<span class="status-badge status-loading"><i class="fa-solid fa-spinner fa-spin"></i> ${t('loading') || 'Загрузка'}</span>
|
||||||
</td>
|
</td>
|
||||||
|
<td id="server-uptime-${server.id}" style="text-align:center;color:var(--text-muted);font-size:0.8rem;">
|
||||||
|
<i class="fa-solid fa-spinner fa-spin"></i>
|
||||||
|
</td>
|
||||||
<td style="text-align:right;">
|
<td style="text-align:right;">
|
||||||
<div style="display:flex;gap:0.5rem;justify-content:flex-end;">
|
<div style="display:flex;gap:0.5rem;justify-content:flex-end;">
|
||||||
<button onclick="testConnection(${server.id})" class="btn btn-sm" style="background:var(--success);color:var(--text-on-success);" title="${t('test') || 'Тест'}">
|
<button onclick="testConnection(${server.id})" class="btn btn-sm" style="background:var(--success);color:var(--text-on-success);" title="${t('test') || 'Тест'}">
|
||||||
@@ -1971,10 +2028,10 @@ async function loadServersTable() {
|
|||||||
<button onclick="window.location.href='/proxmox/server/${server.id}'" class="btn btn-sm" style="background:var(--info);color:var(--text-on-info);" title="${t('details') || 'Детали'}">
|
<button onclick="window.location.href='/proxmox/server/${server.id}'" class="btn btn-sm" style="background:var(--info);color:var(--text-on-info);" title="${t('details') || 'Детали'}">
|
||||||
<i class="fa-solid fa-chart-simple"></i> ${t('details') || 'Детали'}
|
<i class="fa-solid fa-chart-simple"></i> ${t('details') || 'Детали'}
|
||||||
</button>
|
</button>
|
||||||
<button onclick="editServer(${server.id}, ${JSON.stringify(server.name)})" class="btn btn-sm" style="background:var(--warning);color:var(--text-on-warning);" title="${t('edit') || 'Изменить'}" data-permission="proxmox.servers.edit">
|
<button onclick="editServer(${server.id})" class="btn btn-sm" style="background:var(--warning);color:var(--text-on-warning);" title="${t('edit') || 'Изменить'}" data-permission="proxmox.servers.edit">
|
||||||
<i class="fa-solid fa-pen"></i>
|
<i class="fa-solid fa-pen"></i>
|
||||||
</button>
|
</button>
|
||||||
<button onclick="deleteServer(${server.id}, ${JSON.stringify(server.name)})" class="btn btn-sm btn-delete" title="${t('delete') || 'Удалить'}" data-permission="proxmox.servers.delete">
|
<button onclick="deleteServer(${server.id}, '${server.name.replace(/'/g, "\\'")}')" class="btn btn-sm btn-delete" title="${t('delete') || 'Удалить'}" data-permission="proxmox.servers.delete">
|
||||||
<i class="fa-solid fa-trash"></i>
|
<i class="fa-solid fa-trash"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,13 +3,13 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="page-header-block">
|
<div class="page-header-block">
|
||||||
<div class="page-header-left">
|
<div class="page-header-left">
|
||||||
<h1 class="page-header-title"><i class="fa-solid fa-list-check"></i> Менеджер задач</h1>
|
<h1 class="page-header-title"><i class="fa-solid fa-list-check"></i> {{ t('task_manager') }}</h1>
|
||||||
<p class="page-header-sub">Все активные и завершённые задачи в реальном времени</p>
|
<p class="page-header-sub">{{ t('task_manager_subtitle') }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="page-header-right">
|
<div class="page-header-right">
|
||||||
<div id="wsStatus" style="display:flex; align-items:center; gap:.4rem; font-size:.78rem; color:var(--text-muted);">
|
<div id="wsStatus" style="display:flex; align-items:center; gap:.4rem; font-size:.78rem; color:var(--text-muted);">
|
||||||
<span class="task-dot" id="wsDot" style="background:#6b7280;"></span>
|
<span class="task-dot" id="wsDot" style="background:#6b7280;"></span>
|
||||||
<span id="wsStatusText">Подключение...</span>
|
<span id="wsStatusText">{{ t('connecting') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-secondary" onclick="TaskManager.refresh()" title="{{ t('refresh') }}">
|
<button class="btn btn-secondary" onclick="TaskManager.refresh()" title="{{ t('refresh') }}">
|
||||||
<i class="fa-solid fa-rotate-right"></i>
|
<i class="fa-solid fa-rotate-right"></i>
|
||||||
@@ -20,27 +20,27 @@
|
|||||||
<!-- Tab bar -->
|
<!-- Tab bar -->
|
||||||
<div class="tasks-tab-bar">
|
<div class="tasks-tab-bar">
|
||||||
<button class="tasks-tab active" data-filter="all" onclick="TaskManager.setFilter('all', this)">
|
<button class="tasks-tab active" data-filter="all" onclick="TaskManager.setFilter('all', this)">
|
||||||
Все <span class="tasks-tab-count" id="cnt-all">0</span>
|
{{ t('all') }} <span class="tasks-tab-count" id="cnt-all">0</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="tasks-tab" data-filter="running" onclick="TaskManager.setFilter('running', this)">
|
<button class="tasks-tab" data-filter="running" onclick="TaskManager.setFilter('running', this)">
|
||||||
<span class="task-dot running" style="margin-right:.3rem;"></span>
|
<span class="task-dot running" style="margin-right:.3rem;"></span>
|
||||||
Выполняется <span class="tasks-tab-count" id="cnt-running">0</span>
|
{{ t('status_running') }} <span class="tasks-tab-count" id="cnt-running">0</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="tasks-tab" data-filter="pending" onclick="TaskManager.setFilter('pending', this)">
|
<button class="tasks-tab" data-filter="pending" onclick="TaskManager.setFilter('pending', this)">
|
||||||
<span class="task-dot pending" style="margin-right:.3rem;"></span>
|
<span class="task-dot pending" style="margin-right:.3rem;"></span>
|
||||||
В очереди <span class="tasks-tab-count" id="cnt-pending">0</span>
|
{{ t('status_pending') }} <span class="tasks-tab-count" id="cnt-pending">0</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="tasks-tab" data-filter="completed" onclick="TaskManager.setFilter('completed', this)">
|
<button class="tasks-tab" data-filter="completed" onclick="TaskManager.setFilter('completed', this)">
|
||||||
<span class="task-dot completed" style="margin-right:.3rem;"></span>
|
<span class="task-dot completed" style="margin-right:.3rem;"></span>
|
||||||
Завершено <span class="tasks-tab-count" id="cnt-completed">0</span>
|
{{ t('status_completed') }} <span class="tasks-tab-count" id="cnt-completed">0</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="tasks-tab" data-filter="failed" onclick="TaskManager.setFilter('failed', this)">
|
<button class="tasks-tab" data-filter="failed" onclick="TaskManager.setFilter('failed', this)">
|
||||||
<span class="task-dot failed" style="margin-right:.3rem;"></span>
|
<span class="task-dot failed" style="margin-right:.3rem;"></span>
|
||||||
Ошибка <span class="tasks-tab-count" id="cnt-failed">0</span>
|
{{ t('status_failed') }} <span class="tasks-tab-count" id="cnt-failed">0</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="tasks-tab" data-filter="cancelled" onclick="TaskManager.setFilter('cancelled', this)">
|
<button class="tasks-tab" data-filter="cancelled" onclick="TaskManager.setFilter('cancelled', this)">
|
||||||
<span class="task-dot cancelled" style="margin-right:.3rem;"></span>
|
<span class="task-dot cancelled" style="margin-right:.3rem;"></span>
|
||||||
Отменено <span class="tasks-tab-count" id="cnt-cancelled">0</span>
|
{{ t('status_cancelled') }} <span class="tasks-tab-count" id="cnt-cancelled">0</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
<div id="taskList">
|
<div id="taskList">
|
||||||
<div class="task-empty-state" id="taskEmpty" style="text-align:center; padding:4rem 2rem; color:var(--text-muted); display:none;">
|
<div class="task-empty-state" id="taskEmpty" style="text-align:center; padding:4rem 2rem; color:var(--text-muted); display:none;">
|
||||||
<i class="fa-solid fa-inbox" style="font-size:2.5rem; margin-bottom:1rem; display:block; opacity:.35;"></i>
|
<i class="fa-solid fa-inbox" style="font-size:2.5rem; margin-bottom:1rem; display:block; opacity:.35;"></i>
|
||||||
<p style="font-size:.95rem;">Задачи не найдены</p>
|
<p style="font-size:.95rem;">{{ t('tasks_not_found') }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div id="taskCards"></div>
|
<div id="taskCards"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
<h5 class="modal-title" id="traceModalTitle" style="font-weight:700; color:var(--text-primary); margin:0;"></h5>
|
<h5 class="modal-title" id="traceModalTitle" style="font-weight:700; color:var(--text-primary); margin:0;"></h5>
|
||||||
<div id="traceModalMeta" style="font-size:.78rem; color:var(--text-muted); margin-top:.25rem;"></div>
|
<div id="traceModalMeta" style="font-size:.78rem; color:var(--text-muted); margin-top:.25rem;"></div>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Закрыть"></button>
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="{{ t('close') }}"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body" style="padding:0;">
|
<div class="modal-body" style="padding:0;">
|
||||||
<!-- Status bar -->
|
<!-- Status bar -->
|
||||||
@@ -79,13 +79,13 @@
|
|||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<button class="nav-link active" id="tabResultsBtn" onclick="TaskManager.switchTraceTab('results')"
|
<button class="nav-link active" id="tabResultsBtn" onclick="TaskManager.switchTraceTab('results')"
|
||||||
style="color:var(--text-muted); background:none; border:none; border-bottom:2px solid transparent; padding:.75rem 1rem; font-size:.85rem;">
|
style="color:var(--text-muted); background:none; border:none; border-bottom:2px solid transparent; padding:.75rem 1rem; font-size:.85rem;">
|
||||||
<i class="fa-solid fa-table-list" style="margin-right:.4rem;"></i> Результаты
|
<i class="fa-solid fa-table-list" style="margin-right:.4rem;"></i> {{ t('results') }}
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<button class="nav-link" id="tabLogBtn" onclick="TaskManager.switchTraceTab('log')"
|
<button class="nav-link" id="tabLogBtn" onclick="TaskManager.switchTraceTab('log')"
|
||||||
style="color:var(--text-muted); background:none; border:none; border-bottom:2px solid transparent; padding:.75rem 1rem; font-size:.85rem;">
|
style="color:var(--text-muted); background:none; border:none; border-bottom:2px solid transparent; padding:.75rem 1rem; font-size:.85rem;">
|
||||||
<i class="fa-solid fa-terminal" style="margin-right:.4rem;"></i> Лог
|
<i class="fa-solid fa-terminal" style="margin-right:.4rem;"></i> {{ t('log') }}
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -93,22 +93,22 @@
|
|||||||
<!-- Results tab -->
|
<!-- Results tab -->
|
||||||
<div id="tabResults" style="padding:1.25rem 1.5rem;">
|
<div id="tabResults" style="padding:1.25rem 1.5rem;">
|
||||||
<div id="traceResultsContent">
|
<div id="traceResultsContent">
|
||||||
<div style="text-align:center; padding:2rem; color:var(--text-muted);">Нет данных</div>
|
<div style="text-align:center; padding:2rem; color:var(--text-muted);">{{ t('no_data') }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Log tab -->
|
<!-- Log tab -->
|
||||||
<div id="tabLog" style="padding:1.25rem 1.5rem; display:none;">
|
<div id="tabLog" style="padding:1.25rem 1.5rem; display:none;">
|
||||||
<div class="task-trace-log" id="traceLogContent">Загрузка...</div>
|
<div class="task-trace-log" id="traceLogContent">{{ t('loading') }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer" style="border-top:1px solid var(--border-default); padding:1rem 1.5rem;">
|
<div class="modal-footer" style="border-top:1px solid var(--border-default); padding:1rem 1.5rem;">
|
||||||
<button id="traceCancelBtn" class="btn btn-sm btn-danger" onclick="TaskManager.cancelTask()" style="display:none;">
|
<button id="traceCancelBtn" class="btn btn-sm btn-danger" onclick="TaskManager.cancelTask()" style="display:none;">
|
||||||
<i class="fa-solid fa-ban"></i> Отменить задачу
|
<i class="fa-solid fa-ban"></i> {{ t('cancel_task') }}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn btn-sm" data-bs-dismiss="modal"
|
<button type="button" class="btn btn-sm" data-bs-dismiss="modal"
|
||||||
style="background:var(--bg-surface); border:1px solid var(--border-default); color:var(--text-secondary);">
|
style="background:var(--bg-surface); border:1px solid var(--border-default); color:var(--text-secondary);">
|
||||||
Закрыть
|
{{ t('close') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -129,9 +129,9 @@ const TaskManager = (() => {
|
|||||||
|
|
||||||
// ── Status helpers ─────────────────────────────────────────────
|
// ── Status helpers ─────────────────────────────────────────────
|
||||||
const STATUS_LABEL = {
|
const STATUS_LABEL = {
|
||||||
running: 'Выполняется', pending: 'В очереди', queued: 'В очереди',
|
running: t('status_running'), pending: t('status_pending'), queued: t('status_pending'),
|
||||||
completed: 'Завершено', failed: 'Ошибка', error: 'Ошибка',
|
completed: t('status_completed'), failed: t('status_failed'), error: t('status_failed'),
|
||||||
cancelled: 'Отменено', canceled: 'Отменено'
|
cancelled: t('status_cancelled'), canceled: t('status_cancelled')
|
||||||
};
|
};
|
||||||
const STATUS_ICON = {
|
const STATUS_ICON = {
|
||||||
running: '<i class="fa-solid fa-circle-notch task-icon-spin"></i>',
|
running: '<i class="fa-solid fa-circle-notch task-icon-spin"></i>',
|
||||||
@@ -163,16 +163,16 @@ const TaskManager = (() => {
|
|||||||
}
|
}
|
||||||
function fmtTime(iso) {
|
function fmtTime(iso) {
|
||||||
if (!iso) return '—';
|
if (!iso) return '—';
|
||||||
try { return new Date(iso).toLocaleString('ru-RU', { day:'2-digit', month:'2-digit', year:'2-digit', hour:'2-digit', minute:'2-digit' }); }
|
try { return new Date(iso).toLocaleString(window.currentLang === 'en' ? 'en-US' : 'ru-RU', { day:'2-digit', month:'2-digit', year:'2-digit', hour:'2-digit', minute:'2-digit' }); }
|
||||||
catch { return iso; }
|
catch { return iso; }
|
||||||
}
|
}
|
||||||
function elapsed(start, end) {
|
function elapsed(start, end) {
|
||||||
if (!start) return '';
|
if (!start) return '';
|
||||||
const a = new Date(start), b = end ? new Date(end) : new Date();
|
const a = new Date(start), b = end ? new Date(end) : new Date();
|
||||||
const s = Math.floor((b - a) / 1000);
|
const s = Math.floor((b - a) / 1000);
|
||||||
if (s < 60) return `${s}с`;
|
if (s < 60) return `${s}${t('seconds_s')}`;
|
||||||
if (s < 3600) return `${Math.floor(s/60)}м ${s%60}с`;
|
if (s < 3600) return `${Math.floor(s/60)}${t('minutes_m')} ${s%60}${t('seconds_s')}`;
|
||||||
return `${Math.floor(s/3600)}ч ${Math.floor((s%3600)/60)}м`;
|
return `${Math.floor(s/3600)}${t('hours_h')} ${Math.floor((s%3600)/60)}${t('minutes_m')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Build card HTML ────────────────────────────────────────────
|
// ── Build card HTML ────────────────────────────────────────────
|
||||||
@@ -181,10 +181,10 @@ const TaskManager = (() => {
|
|||||||
const pct = progressPct(task);
|
const pct = progressPct(task);
|
||||||
const indeterminate = (pct === null);
|
const indeterminate = (pct === null);
|
||||||
const fillPct = indeterminate ? 60 : pct;
|
const fillPct = indeterminate ? 60 : pct;
|
||||||
const desc = task.description || task.action || task.task_type || 'Задача';
|
const desc = task.description || task.action || task.task_type || t('task_default_name');
|
||||||
const meta = [];
|
const meta = [];
|
||||||
if (task.kind === 'bulk') {
|
if (task.kind === 'bulk') {
|
||||||
if (task.total_items > 0) meta.push(`<span><i class="fa-solid fa-layer-group"></i> ${task.processed_items||0} / ${task.total_items} объектов</span>`);
|
if (task.total_items > 0) meta.push(`<span><i class="fa-solid fa-layer-group"></i> ${task.processed_items||0} / ${task.total_items} ${t('objects')}</span>`);
|
||||||
} else {
|
} else {
|
||||||
if (task.vmid) meta.push(`<span><i class="fa-solid fa-server"></i> VM ${task.vmid}</span>`);
|
if (task.vmid) meta.push(`<span><i class="fa-solid fa-server"></i> VM ${task.vmid}</span>`);
|
||||||
if (task.node) meta.push(`<span><i class="fa-solid fa-network-wired"></i> ${escH(task.node)}</span>`);
|
if (task.node) meta.push(`<span><i class="fa-solid fa-network-wired"></i> ${escH(task.node)}</span>`);
|
||||||
@@ -194,7 +194,7 @@ const TaskManager = (() => {
|
|||||||
const cancelBtn = (st === 'pending' && task.kind === 'bulk') ?
|
const cancelBtn = (st === 'pending' && task.kind === 'bulk') ?
|
||||||
`<button class="btn btn-sm" onclick="event.stopPropagation(); TaskManager.cancelTaskById('${task.kind}','${task.id}')"
|
`<button class="btn btn-sm" onclick="event.stopPropagation(); TaskManager.cancelTaskById('${task.kind}','${task.id}')"
|
||||||
style="background:rgba(239,68,68,.12); color:#ef4444; border:1px solid rgba(239,68,68,.3); border-radius:8px; padding:3px 10px; font-size:.75rem;">
|
style="background:rgba(239,68,68,.12); color:#ef4444; border:1px solid rgba(239,68,68,.3); border-radius:8px; padding:3px 10px; font-size:.75rem;">
|
||||||
<i class="fa-solid fa-ban"></i> Отменить
|
<i class="fa-solid fa-ban"></i> ${t('cancel')}
|
||||||
</button>` : '';
|
</button>` : '';
|
||||||
|
|
||||||
return `<div class="task-card ${st}" data-id="${task.id}" data-kind="${task.kind}" onclick="TaskManager.openTrace('${task.kind}','${task.id}')">
|
return `<div class="task-card ${st}" data-id="${task.id}" data-kind="${task.kind}" onclick="TaskManager.openTrace('${task.kind}','${task.id}')">
|
||||||
@@ -318,7 +318,7 @@ const TaskManager = (() => {
|
|||||||
const dot = document.getElementById('wsDot');
|
const dot = document.getElementById('wsDot');
|
||||||
const txt = document.getElementById('wsStatusText');
|
const txt = document.getElementById('wsStatusText');
|
||||||
const colors = { connected:'#10b981', connecting:'#f97316', disconnected:'#ef4444' };
|
const colors = { connected:'#10b981', connecting:'#f97316', disconnected:'#ef4444' };
|
||||||
const labels = { connected:'Подключено', connecting:'Подключение...', disconnected:'Отключено' };
|
const labels = { connected: t('connected'), connecting: t('connecting'), disconnected: t('disconnected') };
|
||||||
if (dot) dot.style.background = colors[status];
|
if (dot) dot.style.background = colors[status];
|
||||||
if (txt) txt.textContent = labels[status];
|
if (txt) txt.textContent = labels[status];
|
||||||
}
|
}
|
||||||
@@ -338,12 +338,12 @@ const TaskManager = (() => {
|
|||||||
|
|
||||||
function refreshTraceModal(task) {
|
function refreshTraceModal(task) {
|
||||||
const st = normalizeStatus(task.status);
|
const st = normalizeStatus(task.status);
|
||||||
const desc = task.description || task.action || task.task_type || 'Задача';
|
const desc = task.description || task.action || task.task_type || t('task_default_name');
|
||||||
document.getElementById('traceModalTitle').textContent = desc;
|
document.getElementById('traceModalTitle').textContent = desc;
|
||||||
document.getElementById('traceModalMeta').innerHTML =
|
document.getElementById('traceModalMeta').innerHTML =
|
||||||
`Создана: ${fmtTime(task.created_at)}` +
|
`${t('created_label')}: ${fmtTime(task.created_at)}` +
|
||||||
(task.completed_at ? ` · Завершена: ${fmtTime(task.completed_at)}` : '') +
|
(task.completed_at ? ` · ${t('completed_label')}: ${fmtTime(task.completed_at)}` : '') +
|
||||||
` · Время: ${elapsed(task.created_at, task.completed_at)}`;
|
` · ${t('elapsed_label')}: ${elapsed(task.created_at, task.completed_at)}`;
|
||||||
|
|
||||||
// Status badge
|
// Status badge
|
||||||
document.getElementById('traceStatusBadge').innerHTML =
|
document.getElementById('traceStatusBadge').innerHTML =
|
||||||
@@ -365,7 +365,7 @@ const TaskManager = (() => {
|
|||||||
|
|
||||||
// Log tab
|
// Log tab
|
||||||
if (task.kind === 'proxmox') {
|
if (task.kind === 'proxmox') {
|
||||||
document.getElementById('traceLogContent').textContent = task.log_text || '(лог пуст)';
|
document.getElementById('traceLogContent').textContent = task.log_text || t('log_empty');
|
||||||
} else {
|
} else {
|
||||||
renderBulkLog(task);
|
renderBulkLog(task);
|
||||||
}
|
}
|
||||||
@@ -378,15 +378,15 @@ const TaskManager = (() => {
|
|||||||
if (task.kind === 'proxmox') {
|
if (task.kind === 'proxmox') {
|
||||||
el.innerHTML = `<table class="table task-results-table" style="margin:0;">
|
el.innerHTML = `<table class="table task-results-table" style="margin:0;">
|
||||||
<thead><tr>
|
<thead><tr>
|
||||||
<th>Параметр</th><th>Значение</th>
|
<th>${t('parameter')}</th><th>${t('value_column')}</th>
|
||||||
</tr></thead>
|
</tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr><td>UPID</td><td style="font-family:monospace; font-size:.78rem;">${escH(task.upid)}</td></tr>
|
<tr><td>UPID</td><td style="font-family:monospace; font-size:.78rem;">${escH(task.upid)}</td></tr>
|
||||||
<tr><td>Узел</td><td>${escH(task.node)||'—'}</td></tr>
|
<tr><td>${t('node_label')}</td><td>${escH(task.node)||'—'}</td></tr>
|
||||||
<tr><td>VM ID</td><td>${task.vmid||'—'}</td></tr>
|
<tr><td>VM ID</td><td>${task.vmid||'—'}</td></tr>
|
||||||
<tr><td>Тип</td><td>${escH(task.vm_type)||'—'}</td></tr>
|
<tr><td>${t('type_label')}</td><td>${escH(task.vm_type)||'—'}</td></tr>
|
||||||
<tr><td>Действие</td><td>${escH(task.action)}</td></tr>
|
<tr><td>${t('action_label')}</td><td>${escH(task.action)}</td></tr>
|
||||||
<tr><td>Статус выхода</td><td>${task.exit_status ? `<span class="${task.exit_status==='OK'?'task-result-ok':'task-result-fail'}">${escH(task.exit_status)}</span>` : '—'}</td></tr>
|
<tr><td>${t('exit_status')}</td><td>${task.exit_status ? `<span class="${task.exit_status==='OK'?'task-result-ok':'task-result-fail'}">${escH(task.exit_status)}</span>` : '—'}</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>`;
|
</table>`;
|
||||||
} else if (task.kind === 'bulk') {
|
} else if (task.kind === 'bulk') {
|
||||||
@@ -395,7 +395,7 @@ const TaskManager = (() => {
|
|||||||
const results = task.results || [];
|
const results = task.results || [];
|
||||||
if (results.length === 0) {
|
if (results.length === 0) {
|
||||||
el.innerHTML = `<div style="padding:1.5rem; text-align:center; color:var(--text-muted);">
|
el.innerHTML = `<div style="padding:1.5rem; text-align:center; color:var(--text-muted);">
|
||||||
${total > 0 ? `Обработано ${items} из ${total} объектов` : 'Нет результатов'}
|
${total > 0 ? t('processed_of').replace('{processed}', items).replace('{total}', total) : t('no_results')}
|
||||||
</div>`;
|
</div>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -405,11 +405,11 @@ const TaskManager = (() => {
|
|||||||
return `<tr>
|
return `<tr>
|
||||||
<td>${icon}</td>
|
<td>${icon}</td>
|
||||||
<td>${escH(r.vm_name || r.name || r.id || '—')}</td>
|
<td>${escH(r.vm_name || r.name || r.id || '—')}</td>
|
||||||
<td>${escH(r.message || r.error || (ok ? 'OK' : 'Ошибка'))}</td>
|
<td>${escH(r.message || r.error || (ok ? 'OK' : t('status_failed')))}</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
el.innerHTML = `<table class="table task-results-table" style="margin:0;">
|
el.innerHTML = `<table class="table task-results-table" style="margin:0;">
|
||||||
<thead><tr><th width="30"></th><th>Объект</th><th>Сообщение</th></tr></thead>
|
<thead><tr><th width="30"></th><th>${t('object_column')}</th><th>${t('message_column')}</th></tr></thead>
|
||||||
<tbody>${rows}</tbody>
|
<tbody>${rows}</tbody>
|
||||||
</table>`;
|
</table>`;
|
||||||
}
|
}
|
||||||
@@ -419,10 +419,10 @@ const TaskManager = (() => {
|
|||||||
const items = task.results || [];
|
const items = task.results || [];
|
||||||
const lines = items.map(r => {
|
const lines = items.map(r => {
|
||||||
const ok = r.success || r.status === 'success' || r.status === 'completed';
|
const ok = r.success || r.status === 'success' || r.status === 'completed';
|
||||||
const ts = r.timestamp ? new Date(r.timestamp).toLocaleTimeString('ru-RU') : '';
|
const ts = r.timestamp ? new Date(r.timestamp).toLocaleTimeString(window.currentLang === 'en' ? 'en-US' : 'ru-RU') : '';
|
||||||
return `${ts ? '['+ts+'] ' : ''}${ok?'✓':'✗'} ${r.vm_name||r.name||''}: ${r.message||r.error||''}`;
|
return `${ts ? '['+ts+'] ' : ''}${ok?'✓':'✗'} ${r.vm_name||r.name||''}: ${r.message||r.error||''}`;
|
||||||
}).join('\n');
|
}).join('\n');
|
||||||
document.getElementById('traceLogContent').textContent = lines || '(пусто)';
|
document.getElementById('traceLogContent').textContent = lines || t('log_empty_simple');
|
||||||
}
|
}
|
||||||
|
|
||||||
function switchTraceTab(tab) {
|
function switchTraceTab(tab) {
|
||||||
@@ -445,20 +445,20 @@ const TaskManager = (() => {
|
|||||||
const resp = await fetchWithAuth(`/proxmox/api/proxmox-tasks/${id}/log`);
|
const resp = await fetchWithAuth(`/proxmox/api/proxmox-tasks/${id}/log`);
|
||||||
if (resp && resp.ok) {
|
if (resp && resp.ok) {
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
document.getElementById('traceLogContent').textContent = data.log_text || '(лог пуст)';
|
document.getElementById('traceLogContent').textContent = data.log_text || t('log_empty');
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function cancelTaskById(kind, id) {
|
async function cancelTaskById(kind, id) {
|
||||||
if (kind !== 'bulk') return;
|
if (kind !== 'bulk') return;
|
||||||
const ok = await showConfirm('Отменить эту задачу?', 'Подтверждение');
|
const ok = await showConfirm(t('cancel_task_confirm'), t('confirmation'));
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
try {
|
try {
|
||||||
const resp = await fetchWithAuth(`/proxmox/api/tasks/${id}/cancel`, { method:'POST' });
|
const resp = await fetchWithAuth(`/proxmox/api/tasks/${id}/cancel`, { method:'POST' });
|
||||||
if (resp && resp.ok) showToast('success', 'Готово', 'Задача отменена');
|
if (resp && resp.ok) showToast('success', t('done'), t('task_cancelled'));
|
||||||
else showToast('error', 'Ошибка', 'Не удалось отменить');
|
else showToast('error', t('error'), t('failed_to_cancel'));
|
||||||
} catch { showToast('error', 'Ошибка', 'Ошибка сети'); }
|
} catch { showToast('error', t('error'), t('network_error')); }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function cancelTask() {
|
async function cancelTask() {
|
||||||
|
|||||||
@@ -295,6 +295,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Generic Alert Modal -->
|
||||||
|
<div id="alertModal" style="display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.8); z-index: 3000; align-items: center; justify-content: center;">
|
||||||
|
<div class="card" style="max-width: 520px; width: 95%; max-height: 85vh; overflow-y: auto;">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
||||||
|
<h3 id="alertModalTitle" style="margin: 0; color: var(--text-primary);"></h3>
|
||||||
|
<button onclick="closeModal('alertModal')" style="background: none; border: none; color: var(--text-muted); font-size: 1.5rem; cursor: pointer;">×</button>
|
||||||
|
</div>
|
||||||
|
<div id="alertModalBody" style="color: var(--text-secondary); line-height: 1.5;"></div>
|
||||||
|
<div style="display: flex; justify-content: flex-end; margin-top: 1.5rem;">
|
||||||
|
<button class="btn btn-secondary" onclick="closeModal('alertModal')">OK</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Add User Modal -->
|
<!-- Add User Modal -->
|
||||||
<div id="addUserModal" style="display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.8); z-index: 2000; align-items: center; justify-content: center;">
|
<div id="addUserModal" style="display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.8); z-index: 2000; align-items: center; justify-content: center;">
|
||||||
<div class="card" style="max-width: 500px; width: 95%; max-height: 90vh; overflow-y: auto;">
|
<div class="card" style="max-width: 500px; width: 95%; max-height: 90vh; overflow-y: auto;">
|
||||||
@@ -339,7 +353,7 @@
|
|||||||
|
|
||||||
<!-- Edit User Modal -->
|
<!-- Edit User Modal -->
|
||||||
<div id="editUserModal" style="display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.8); z-index: 2000; align-items: center; justify-content: center;">
|
<div id="editUserModal" style="display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.8); z-index: 2000; align-items: center; justify-content: center;">
|
||||||
<div class="card" style="max-width: 500px; width: 95%; max-height: 90vh; overflow-y: auto;">
|
<div class="card" style="max-width: 560px; width: 95%; max-height: 90vh; overflow-y: auto;">
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
||||||
<h3 style="margin: 0; color: var(--text-primary);"><i class="fa-solid fa-pen"></i> {{ t('edit_user') }}</h3>
|
<h3 style="margin: 0; color: var(--text-primary);"><i class="fa-solid fa-pen"></i> {{ t('edit_user') }}</h3>
|
||||||
<button onclick="closeModal('editUserModal')" style="background: none; border: none; color: var(--text-muted); font-size: 1.5rem; cursor: pointer;">×</button>
|
<button onclick="closeModal('editUserModal')" style="background: none; border: none; color: var(--text-muted); font-size: 1.5rem; cursor: pointer;">×</button>
|
||||||
@@ -372,10 +386,12 @@
|
|||||||
<input type="checkbox" class="form-check-input" name="is_admin" id="editIsAdmin">
|
<input type="checkbox" class="form-check-input" name="is_admin" id="editIsAdmin">
|
||||||
<label class="form-check-label" for="editIsAdmin">{{ t('administrator') }}</label>
|
<label class="form-check-label" for="editIsAdmin">{{ t('administrator') }}</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-check">
|
<div class="form-check" style="margin-bottom: 1rem;">
|
||||||
<input type="checkbox" class="form-check-input" name="is_active" id="editIsActive">
|
<input type="checkbox" class="form-check-input" name="is_active" id="editIsActive">
|
||||||
<label class="form-check-label" for="editIsActive">{{ t('active') }}</label>
|
<label class="form-check-label" for="editIsActive">{{ t('active') }}</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div style="display: flex; gap: 0.5rem; justify-content: flex-end; margin-top: 1.5rem;">
|
<div style="display: flex; gap: 0.5rem; justify-content: flex-end; margin-top: 1.5rem;">
|
||||||
<button type="button" class="btn btn-secondary" onclick="closeModal('editUserModal')">{{ t('cancel') }}</button>
|
<button type="button" class="btn btn-secondary" onclick="closeModal('editUserModal')">{{ t('cancel') }}</button>
|
||||||
<button type="submit" class="btn btn-success">{{ t('save') }}</button>
|
<button type="submit" class="btn btn-success">{{ t('save') }}</button>
|
||||||
@@ -520,7 +536,7 @@ function switchTab(tabName) {
|
|||||||
// Show modals
|
// Show modals
|
||||||
function showAddUserModal() {
|
function showAddUserModal() {
|
||||||
document.getElementById('addUserModal').style.display = 'flex';
|
document.getElementById('addUserModal').style.display = 'flex';
|
||||||
loadRolesForSelect('userRoleSelect');
|
loadRoles();
|
||||||
}
|
}
|
||||||
|
|
||||||
function showAddRoleModal() {
|
function showAddRoleModal() {
|
||||||
@@ -536,6 +552,12 @@ function closeModal(modalId) {
|
|||||||
document.getElementById(modalId).style.display = 'none';
|
document.getElementById(modalId).style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showAlertModal(title, bodyHtml) {
|
||||||
|
document.getElementById('alertModalTitle').textContent = title;
|
||||||
|
document.getElementById('alertModalBody').innerHTML = bodyHtml;
|
||||||
|
document.getElementById('alertModal').style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize on page load
|
// Initialize on page load
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
// Wait for permissions to be loaded before initializing
|
// Wait for permissions to be loaded before initializing
|
||||||
@@ -589,7 +611,7 @@ async function loadUsers() {
|
|||||||
<td style="color: var(--text-primary);">${user.full_name || '-'}</td>
|
<td style="color: var(--text-primary);">${user.full_name || '-'}</td>
|
||||||
<td style="color: var(--text-primary);">${user.email}</td>
|
<td style="color: var(--text-primary);">${user.email}</td>
|
||||||
<td>
|
<td>
|
||||||
${user.role ? `<span class="badge bg-primary">${user.role.display_name}</span>` : '-'}
|
${user.role_name ? `<span class="badge bg-primary">${user.role_name}</span>` : '-'}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
${user.is_active
|
${user.is_active
|
||||||
@@ -1044,6 +1066,7 @@ async function deleteUser(userId, username) {
|
|||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
showToast('success', '{{ _("success") }}', '{{ _("user_deleted") }}');
|
showToast('success', '{{ _("success") }}', '{{ _("user_deleted") }}');
|
||||||
loadUsers();
|
loadUsers();
|
||||||
|
loadRoles();
|
||||||
} else {
|
} else {
|
||||||
const error = await response.json();
|
const error = await response.json();
|
||||||
showToast('error', '{{ _("error") }}', error.detail || '{{ _("error") }}');
|
showToast('error', '{{ _("error") }}', error.detail || '{{ _("error") }}');
|
||||||
@@ -1080,6 +1103,7 @@ document.getElementById('addUserForm').addEventListener('submit', async function
|
|||||||
closeModal('addUserModal');
|
closeModal('addUserModal');
|
||||||
showToast('{{ _("user_created") }}', 'success');
|
showToast('{{ _("user_created") }}', 'success');
|
||||||
loadUsers();
|
loadUsers();
|
||||||
|
loadRoles();
|
||||||
this.reset();
|
this.reset();
|
||||||
} else {
|
} else {
|
||||||
const error = await response.json();
|
const error = await response.json();
|
||||||
@@ -1117,14 +1141,16 @@ document.getElementById('editUserForm').addEventListener('submit', async functio
|
|||||||
body: JSON.stringify(data)
|
body: JSON.stringify(data)
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (!response.ok) {
|
||||||
closeModal('editUserModal');
|
|
||||||
showToast('{{ _("user_updated") }}', 'success');
|
|
||||||
loadUsers();
|
|
||||||
} else {
|
|
||||||
const error = await response.json();
|
const error = await response.json();
|
||||||
showToast(error.detail || '{{ _("error") }}', 'danger');
|
showToast(error.detail || '{{ _("error") }}', 'danger');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
closeModal('editUserModal');
|
||||||
|
showToast('{{ _("user_updated") }}', 'success');
|
||||||
|
loadUsers();
|
||||||
|
loadRoles();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error:', error);
|
console.error('Error:', error);
|
||||||
showToast('{{ _("error") }}', 'danger');
|
showToast('{{ _("error") }}', 'danger');
|
||||||
|
|||||||
@@ -1731,6 +1731,12 @@ function showVMActions(event, serverId, vmid, type, node, vmName, status) {
|
|||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"></polyline><polyline points="23 20 23 14 17 14"></polyline><path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"></path></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"></polyline><polyline points="23 20 23 14 17 14"></polyline><path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"></path></svg>
|
||||||
<span>{{ t('reinstall') }}</span>
|
<span>{{ t('reinstall') }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
${window.isAdmin ? `
|
||||||
|
<div class="vm-context-menu-item" onclick="showAssignOwnerModal(${serverId}, ${vmid}, '${vmName.replace(/'/g, "\\'")}')">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path><circle cx="12" cy="7" r="4"></circle></svg>
|
||||||
|
<span>{{ t('assign_user') or 'Assign User' }}</span>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
<div class="vm-context-menu-item danger" onclick="confirmDeleteVM(${serverId}, ${vmid}, '${type}', '${node}', '${vmName}')" data-permission="vms.delete">
|
<div class="vm-context-menu-item danger" onclick="confirmDeleteVM(${serverId}, ${vmid}, '${type}', '${node}', '${vmName}')" data-permission="vms.delete">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path><line x1="10" y1="11" x2="10" y2="17"></line><line x1="14" y1="11" x2="14" y2="17"></line></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path><line x1="10" y1="11" x2="10" y2="17"></line><line x1="14" y1="11" x2="14" y2="17"></line></svg>
|
||||||
<span>{{ t('delete') }}</span>
|
<span>{{ t('delete') }}</span>
|
||||||
@@ -2069,6 +2075,73 @@ function reinstallVM(serverId, vmid, type, node, vmName) {
|
|||||||
showReinstallModal(serverId, vmid, type, node, vmName);
|
showReinstallModal(serverId, vmid, type, node, vmName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===================== Assign Owner =====================
|
||||||
|
async function showAssignOwnerModal(serverId, vmid, vmName) {
|
||||||
|
closeVMContextMenu();
|
||||||
|
document.getElementById('assignOwnerServerId').value = serverId;
|
||||||
|
document.getElementById('assignOwnerVmid').value = vmid;
|
||||||
|
document.getElementById('assignOwnerVmTitle').textContent = vmName;
|
||||||
|
document.getElementById('assignOwnerCurrentInfo').textContent = '';
|
||||||
|
document.getElementById('assignOwnerUserSelect').innerHTML = '<option value="">{{ t("loading") or "Loading..." }}</option>';
|
||||||
|
document.getElementById('assignOwnerModal').style.display = 'flex';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/${serverId}/vm/${vmid}/owner`, {
|
||||||
|
headers: { 'Authorization': `Bearer ${API_TOKEN}` }
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error('Failed to load');
|
||||||
|
const data = await resp.json();
|
||||||
|
|
||||||
|
const select = document.getElementById('assignOwnerUserSelect');
|
||||||
|
select.innerHTML = '<option value="">— {{ t("no_owner") or "No owner" }} —</option>';
|
||||||
|
(data.users || []).forEach(u => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = u.id;
|
||||||
|
opt.textContent = u.username + (u.full_name ? ` (${u.full_name})` : '');
|
||||||
|
if (u.id === data.owner_id) opt.selected = true;
|
||||||
|
select.appendChild(opt);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (data.owner) {
|
||||||
|
document.getElementById('assignOwnerCurrentInfo').textContent =
|
||||||
|
`{{ t("current_owner") or "Current owner" }}: ${data.owner.username}${data.owner.full_name ? ' (' + data.owner.full_name + ')' : ''}`;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('assignOwnerUserSelect').innerHTML = '<option value="">Error loading users</option>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveAssignOwner() {
|
||||||
|
const serverId = document.getElementById('assignOwnerServerId').value;
|
||||||
|
const vmid = document.getElementById('assignOwnerVmid').value;
|
||||||
|
const rawVal = document.getElementById('assignOwnerUserSelect').value;
|
||||||
|
const userId = rawVal ? parseInt(rawVal) : null;
|
||||||
|
|
||||||
|
const btn = document.getElementById('assignOwnerSaveBtn');
|
||||||
|
btn.disabled = true;
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/${serverId}/vm/${vmid}/owner`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Authorization': `Bearer ${API_TOKEN}`, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ user_id: userId })
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const err = await resp.json().catch(() => ({}));
|
||||||
|
showToast('error', '{{ t("error") or "Error" }}', err.detail || 'Failed to assign');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
closeAssignOwnerModal();
|
||||||
|
showToast('success', '{{ t("success") or "Success" }}', '{{ t("owner_updated") or "Owner updated" }}');
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAssignOwnerModal() {
|
||||||
|
document.getElementById('assignOwnerModal').style.display = 'none';
|
||||||
|
}
|
||||||
|
// ========================================================
|
||||||
|
|
||||||
function confirmDeleteVM(serverId, vmid, type, node, vmName) {
|
function confirmDeleteVM(serverId, vmid, type, node, vmName) {
|
||||||
closeVMContextMenu();
|
closeVMContextMenu();
|
||||||
|
|
||||||
@@ -3188,6 +3261,33 @@ window.addEventListener('beforeunload', () => {
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- Assign Owner Modal -->
|
||||||
|
<div id="assignOwnerModal" style="display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.85); z-index: 2100; align-items: center; justify-content: center; padding: 1rem;">
|
||||||
|
<div class="card" style="max-width: 440px; width: 95%; max-height: 85vh; overflow-y: auto;">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.25rem;">
|
||||||
|
<h3 style="margin: 0; font-size: 1.05rem;">
|
||||||
|
<svg style="width:1em;height:1em;vertical-align:-0.15em;margin-right:0.4rem;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path><circle cx="12" cy="7" r="4"></circle></svg>
|
||||||
|
{{ t('assign_user') or 'Assign User' }}
|
||||||
|
</h3>
|
||||||
|
<button onclick="closeAssignOwnerModal()" style="background:none;border:none;color:var(--text-muted);font-size:1.4rem;cursor:pointer;line-height:1;">×</button>
|
||||||
|
</div>
|
||||||
|
<p id="assignOwnerVmTitle" style="color:var(--text-muted);font-size:0.875rem;margin:0 0 1.1rem;"></p>
|
||||||
|
<div style="margin-bottom:1rem;">
|
||||||
|
<label style="display:block;margin-bottom:0.4rem;font-size:0.875rem;color:var(--text-secondary);">{{ t('user') or 'User' }}</label>
|
||||||
|
<select id="assignOwnerUserSelect" class="form-control" style="width:100%;">
|
||||||
|
<option value="">— {{ t('no_owner') or 'No owner' }} —</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div id="assignOwnerCurrentInfo" style="font-size:0.8rem;color:var(--text-muted);margin-bottom:1rem;"></div>
|
||||||
|
<div style="display:flex;gap:0.5rem;justify-content:flex-end;margin-top:1.25rem;">
|
||||||
|
<button class="btn btn-secondary" onclick="closeAssignOwnerModal()">{{ t('cancel') }}</button>
|
||||||
|
<button class="btn btn-primary" onclick="saveAssignOwner()" id="assignOwnerSaveBtn">{{ t('save') }}</button>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" id="assignOwnerServerId">
|
||||||
|
<input type="hidden" id="assignOwnerVmid">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Create VM Modal -->
|
<!-- Create VM Modal -->
|
||||||
<div id="createVMModal" style="display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.85); z-index: 2000; align-items: center; justify-content: center; padding: 1rem;">
|
<div id="createVMModal" style="display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.85); z-index: 2000; align-items: center; justify-content: center; padding: 1rem;">
|
||||||
<div class="card" style="max-width: 700px; width: 95%; max-height: 95vh; overflow-y: auto;">
|
<div class="card" style="max-width: 700px; width: 95%; max-height: 95vh; overflow-y: auto;">
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="page-header-right">
|
<div class="page-header-right">
|
||||||
<button class="btn btn-success" onclick="showCreateModal()">
|
<button class="btn btn-success" onclick="showCreateModal()">
|
||||||
<i class="fa-solid fa-plus"></i> Создать Workspace
|
<i class="fa-solid fa-plus"></i> {{ t('create_workspace') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
<!-- Workspace Cards -->
|
<!-- Workspace Cards -->
|
||||||
<div id="workspacesGrid" class="ws-grid">
|
<div id="workspacesGrid" class="ws-grid">
|
||||||
<div class="loading-placeholder">
|
<div class="loading-placeholder">
|
||||||
<i class="fa-solid fa-spinner fa-spin"></i> Загрузка...
|
<i class="fa-solid fa-spinner fa-spin"></i> {{ t('loading') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -31,20 +31,20 @@
|
|||||||
<div id="wsModal" class="modal-overlay" style="display:none;" onclick="if(event.target===this)closeWsModal()">
|
<div id="wsModal" class="modal-overlay" style="display:none;" onclick="if(event.target===this)closeWsModal()">
|
||||||
<div class="modal-container" style="max-width:600px;">
|
<div class="modal-container" style="max-width:600px;">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h3 id="wsModalTitle">Создать Workspace</h3>
|
<h3 id="wsModalTitle">{{ t('create_workspace') }}</h3>
|
||||||
<button class="modal-close" onclick="closeWsModal()">×</button>
|
<button class="modal-close" onclick="closeWsModal()">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Название <span style="color:var(--danger)">*</span></label>
|
<label>{{ t('name') }} <span style="color:var(--danger)">*</span></label>
|
||||||
<input type="text" id="wsName" class="form-input" placeholder="Production" maxlength="100">
|
<input type="text" id="wsName" class="form-input" placeholder="Production" maxlength="100">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Описание</label>
|
<label>{{ t('description') }}</label>
|
||||||
<textarea id="wsDescription" class="form-input" rows="2" placeholder="Описание workspace" maxlength="500"></textarea>
|
<textarea id="wsDescription" class="form-input" rows="2" placeholder="{{ t('workspace_desc_placeholder') }}" maxlength="500"></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Цвет</label>
|
<label>{{ t('color') }}</label>
|
||||||
<div style="display:flex;align-items:center;gap:10px;">
|
<div style="display:flex;align-items:center;gap:10px;">
|
||||||
<input type="color" id="wsColor" value="#0ea5e9" style="width:48px;height:36px;border:none;background:none;cursor:pointer;padding:0;">
|
<input type="color" id="wsColor" value="#0ea5e9" style="width:48px;height:36px;border:none;background:none;cursor:pointer;padding:0;">
|
||||||
<div class="ws-color-presets">
|
<div class="ws-color-presets">
|
||||||
@@ -59,22 +59,22 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group" id="wsServersGroup">
|
<div class="form-group" id="wsServersGroup">
|
||||||
<label>Серверы</label>
|
<label>{{ t('servers_label') }}</label>
|
||||||
<div id="wsServersList" class="ws-assign-list">
|
<div id="wsServersList" class="ws-assign-list">
|
||||||
<div class="loading-placeholder"><i class="fa-solid fa-spinner fa-spin"></i></div>
|
<div class="loading-placeholder"><i class="fa-solid fa-spinner fa-spin"></i></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group" id="wsUsersGroup">
|
<div class="form-group" id="wsUsersGroup">
|
||||||
<label>Пользователи с доступом</label>
|
<label>{{ t('users_with_access') }}</label>
|
||||||
<div id="wsUsersList" class="ws-assign-list">
|
<div id="wsUsersList" class="ws-assign-list">
|
||||||
<div class="loading-placeholder"><i class="fa-solid fa-spinner fa-spin"></i></div>
|
<div class="loading-placeholder"><i class="fa-solid fa-spinner fa-spin"></i></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button class="btn btn-secondary" onclick="closeWsModal()">Отмена</button>
|
<button class="btn btn-secondary" onclick="closeWsModal()">{{ t('cancel') }}</button>
|
||||||
<button class="btn btn-primary" id="wsModalSaveBtn" onclick="saveWorkspace()">Сохранить</button>
|
<button class="btn btn-primary" id="wsModalSaveBtn" onclick="saveWorkspace()">{{ t('save') }}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -327,7 +327,7 @@ async function loadWorkspaces() {
|
|||||||
const workspaces = await resp.json();
|
const workspaces = await resp.json();
|
||||||
|
|
||||||
if (workspaces.length === 0) {
|
if (workspaces.length === 0) {
|
||||||
grid.innerHTML = '<div class="ws-empty"><i class="fa-solid fa-layer-group" style="font-size:2rem;opacity:0.2;display:block;margin-bottom:0.5rem;"></i>Нет workspaces</div>';
|
grid.innerHTML = `<div class="ws-empty"><i class="fa-solid fa-layer-group" style="font-size:2rem;opacity:0.2;display:block;margin-bottom:0.5rem;"></i>${t('no_workspaces')}</div>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,8 +341,8 @@ async function loadWorkspaces() {
|
|||||||
</div>
|
</div>
|
||||||
<div class="ws-card-desc">${escapeHtml(ws.description || '')}</div>
|
<div class="ws-card-desc">${escapeHtml(ws.description || '')}</div>
|
||||||
<div class="ws-card-stats">
|
<div class="ws-card-stats">
|
||||||
<span class="ws-stat"><i class="fa-solid fa-server"></i>${ws.server_count} серверов</span>
|
<span class="ws-stat"><i class="fa-solid fa-server"></i>${ws.server_count} ${t('servers_count_sfx')}</span>
|
||||||
<span class="ws-stat"><i class="fa-solid fa-users"></i>${ws.user_count} пользователей</span>
|
<span class="ws-stat"><i class="fa-solid fa-users"></i>${ws.user_count} ${t('users_count_sfx')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="ws-card-actions">
|
<div class="ws-card-actions">
|
||||||
<button class="btn btn-sm btn-secondary" onclick="showEditModal(${ws.id})">
|
<button class="btn btn-sm btn-secondary" onclick="showEditModal(${ws.id})">
|
||||||
@@ -355,7 +355,7 @@ async function loadWorkspaces() {
|
|||||||
</div>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
grid.innerHTML = '<div class="ws-empty">Ошибка загрузки</div>';
|
grid.innerHTML = `<div class="ws-empty">${t('loading_error')}</div>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -379,7 +379,7 @@ async function loadAssignLists() {
|
|||||||
function renderServerCheckboxes(selectedIds = []) {
|
function renderServerCheckboxes(selectedIds = []) {
|
||||||
const container = document.getElementById('wsServersList');
|
const container = document.getElementById('wsServersList');
|
||||||
if (_allServers.length === 0) {
|
if (_allServers.length === 0) {
|
||||||
container.innerHTML = '<div class="ws-assign-item" style="color:var(--text-muted)">Нет серверов</div>';
|
container.innerHTML = `<div class="ws-assign-item" style="color:var(--text-muted)">${t('no_servers_available')}</div>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const selectedSet = new Set(selectedIds);
|
const selectedSet = new Set(selectedIds);
|
||||||
@@ -396,7 +396,7 @@ function renderServerCheckboxes(selectedIds = []) {
|
|||||||
function renderUserCheckboxes(selectedIds = []) {
|
function renderUserCheckboxes(selectedIds = []) {
|
||||||
const container = document.getElementById('wsUsersList');
|
const container = document.getElementById('wsUsersList');
|
||||||
if (_allUsers.length === 0) {
|
if (_allUsers.length === 0) {
|
||||||
container.innerHTML = '<div class="ws-assign-item" style="color:var(--text-muted)">Нет пользователей</div>';
|
container.innerHTML = `<div class="ws-assign-item" style="color:var(--text-muted)">${t('no_users_available')}</div>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const selectedSet = new Set(selectedIds);
|
const selectedSet = new Set(selectedIds);
|
||||||
@@ -415,7 +415,7 @@ function renderUserCheckboxes(selectedIds = []) {
|
|||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
async function showCreateModal() {
|
async function showCreateModal() {
|
||||||
_editingId = null;
|
_editingId = null;
|
||||||
document.getElementById('wsModalTitle').textContent = 'Создать Workspace';
|
document.getElementById('wsModalTitle').textContent = t('create_workspace');
|
||||||
document.getElementById('wsName').value = '';
|
document.getElementById('wsName').value = '';
|
||||||
document.getElementById('wsDescription').value = '';
|
document.getElementById('wsDescription').value = '';
|
||||||
document.getElementById('wsColor').value = '#0ea5e9';
|
document.getElementById('wsColor').value = '#0ea5e9';
|
||||||
@@ -432,7 +432,7 @@ async function showCreateModal() {
|
|||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
async function showEditModal(id) {
|
async function showEditModal(id) {
|
||||||
_editingId = id;
|
_editingId = id;
|
||||||
document.getElementById('wsModalTitle').textContent = 'Редактировать Workspace';
|
document.getElementById('wsModalTitle').textContent = t('edit_workspace');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetchWithAuth(`/api/workspaces/${id}`);
|
const resp = await fetchWithAuth(`/api/workspaces/${id}`);
|
||||||
@@ -451,7 +451,7 @@ async function showEditModal(id) {
|
|||||||
|
|
||||||
document.getElementById('wsModal').style.display = 'flex';
|
document.getElementById('wsModal').style.display = 'flex';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showAlertModal('Ошибка', 'Не удалось загрузить данные workspace');
|
showAlertModal(t('error'), t('failed_to_load_workspace'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -466,7 +466,7 @@ function closeWsModal() {
|
|||||||
async function saveWorkspace() {
|
async function saveWorkspace() {
|
||||||
const name = document.getElementById('wsName').value.trim();
|
const name = document.getElementById('wsName').value.trim();
|
||||||
if (!name) {
|
if (!name) {
|
||||||
showAlertModal('Ошибка', 'Введите название workspace');
|
showAlertModal(t('error'), t('workspace_name_required'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -519,13 +519,13 @@ async function saveWorkspace() {
|
|||||||
if (typeof loadSidebarWorkspace === 'function') loadSidebarWorkspace();
|
if (typeof loadSidebarWorkspace === 'function') loadSidebarWorkspace();
|
||||||
} else {
|
} else {
|
||||||
const err = await resp.json().catch(() => ({}));
|
const err = await resp.json().catch(() => ({}));
|
||||||
showAlertModal('Ошибка', err.detail || 'Не удалось сохранить');
|
showAlertModal(t('error'), err.detail || t('failed_to_save'));
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showAlertModal('Ошибка', e.message || 'Произошла ошибка');
|
showAlertModal(t('error'), e.message || t('error_occurred'));
|
||||||
} finally {
|
} finally {
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.innerHTML = 'Сохранить';
|
btn.innerHTML = t('save');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -534,8 +534,8 @@ async function saveWorkspace() {
|
|||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
async function deleteWorkspace(id, name) {
|
async function deleteWorkspace(id, name) {
|
||||||
showConfirmModal(
|
showConfirmModal(
|
||||||
'Удалить Workspace',
|
t('delete_workspace'),
|
||||||
`Удалить workspace «${name}»? Серверы и пользователи не будут удалены.`,
|
t('delete_workspace_confirm').replace('{name}', name),
|
||||||
async () => {
|
async () => {
|
||||||
const resp = await fetchWithAuth(`/api/workspaces/${id}`, { method: 'DELETE' });
|
const resp = await fetchWithAuth(`/api/workspaces/${id}`, { method: 'DELETE' });
|
||||||
if (resp && (resp.ok || resp.status === 204)) {
|
if (resp && (resp.ok || resp.status === 204)) {
|
||||||
@@ -543,7 +543,7 @@ async function deleteWorkspace(id, name) {
|
|||||||
if (typeof loadSidebarWorkspace === 'function') loadSidebarWorkspace();
|
if (typeof loadSidebarWorkspace === 'function') loadSidebarWorkspace();
|
||||||
} else {
|
} else {
|
||||||
const err = await resp.json().catch(() => ({}));
|
const err = await resp.json().catch(() => ({}));
|
||||||
showAlertModal('Ошибка', err.detail || 'Не удалось удалить');
|
showAlertModal(t('error'), err.detail || t('failed_to_delete'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ DEFAULT_ROLES = [
|
|||||||
"description": "VPS-style user: can only access their own instances",
|
"description": "VPS-style user: can only access their own instances",
|
||||||
"is_system": True,
|
"is_system": True,
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"dashboard.view": True,
|
"dashboard.view": False,
|
||||||
"proxmox.view": True,
|
"proxmox.view": True,
|
||||||
"proxmox.manage": False,
|
"proxmox.manage": False,
|
||||||
"proxmox.servers.add": False,
|
"proxmox.servers.add": False,
|
||||||
@@ -808,7 +808,7 @@ def migrate_vm_instance_owner(conn):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not create owner_id index: {e}")
|
logger.warning(f"Could not create owner_id index: {e}")
|
||||||
|
|
||||||
# Update user role with instance ownership permissions
|
# Add VPS-style permissions to user role (idempotent: only add missing keys)
|
||||||
try:
|
try:
|
||||||
result = conn.execute(text("SELECT permissions FROM roles WHERE name = 'user'"))
|
result = conn.execute(text("SELECT permissions FROM roles WHERE name = 'user'"))
|
||||||
row = result.fetchone()
|
row = result.fetchone()
|
||||||
@@ -816,21 +816,26 @@ def migrate_vm_instance_owner(conn):
|
|||||||
import json
|
import json
|
||||||
permissions = row[0] if isinstance(row[0], dict) else json.loads(row[0] or '{}')
|
permissions = row[0] if isinstance(row[0], dict) else json.loads(row[0] or '{}')
|
||||||
|
|
||||||
# Add VPS-style permissions for user role
|
vps_permissions = {
|
||||||
permissions.update({
|
|
||||||
"vms:view:own": True, # View only own VMs
|
"vms:view:own": True, # View only own VMs
|
||||||
"vms:start:own": True, # Start own VMs
|
"vms:start:own": True, # Start own VMs
|
||||||
"vms:stop:own": True, # Stop own VMs
|
"vms:stop:own": True, # Stop own VMs
|
||||||
"vms:restart:own": True, # Restart own VMs
|
"vms:restart:own": True, # Restart own VMs
|
||||||
"vms:console:own": True, # Console to own VMs
|
"vms:console:own": True, # Console to own VMs
|
||||||
"vms:snapshots:own": True, # Manage snapshots of own VMs
|
"vms:snapshots:own": True, # Manage snapshots of own VMs
|
||||||
})
|
}
|
||||||
|
|
||||||
conn.execute(
|
# Only add keys that are not already present — never overwrite existing values
|
||||||
text("UPDATE roles SET permissions = :perms, updated_at = NOW() WHERE name = 'user'"),
|
missing = {k: v for k, v in vps_permissions.items() if k not in permissions}
|
||||||
{"perms": json.dumps(permissions)}
|
if missing:
|
||||||
)
|
permissions.update(missing)
|
||||||
logger.info("✓ Updated user role with instance ownership permissions")
|
conn.execute(
|
||||||
|
text("UPDATE roles SET permissions = :perms, updated_at = NOW() WHERE name = 'user'"),
|
||||||
|
{"perms": json.dumps(permissions)}
|
||||||
|
)
|
||||||
|
logger.info(f"✓ Added missing VPS permissions to user role: {list(missing.keys())}")
|
||||||
|
else:
|
||||||
|
logger.info("✓ User role already has VPS instance ownership permissions, skipping")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not update user role permissions: {e}")
|
logger.warning(f"Could not update user role permissions: {e}")
|
||||||
|
|
||||||
@@ -1062,19 +1067,29 @@ def migrate_cluster_support(conn):
|
|||||||
else:
|
else:
|
||||||
logger.info("✓ proxmox_servers.cluster_name already exists")
|
logger.info("✓ proxmox_servers.cluster_name already exists")
|
||||||
|
|
||||||
# Add cluster:manage permission to admin role if not already present
|
# Ensure admin role has cluster:manage (new format) and remove old proxmox.cluster.manage key
|
||||||
try:
|
try:
|
||||||
result = conn.execute(text("SELECT permissions FROM roles WHERE name = 'admin'")).fetchone()
|
result = conn.execute(text("SELECT permissions FROM roles WHERE name = 'admin'")).fetchone()
|
||||||
if result:
|
if result:
|
||||||
import json as _json
|
import json as _json
|
||||||
perms = _json.loads(result.permissions) if isinstance(result.permissions, str) else result.permissions
|
perms = _json.loads(result.permissions) if isinstance(result.permissions, str) else result.permissions
|
||||||
if not perms.get('proxmox.cluster.manage'):
|
changed = False
|
||||||
perms['proxmox.cluster.manage'] = True
|
# Remove old dot-format key that triggers full permission reset in Migration 9
|
||||||
|
if 'proxmox.cluster.manage' in perms:
|
||||||
|
del perms['proxmox.cluster.manage']
|
||||||
|
changed = True
|
||||||
|
# Add new-format key if missing
|
||||||
|
if not perms.get('cluster:manage'):
|
||||||
|
perms['cluster:manage'] = True
|
||||||
|
changed = True
|
||||||
|
if changed:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
text("UPDATE roles SET permissions = :p WHERE name = 'admin'"),
|
text("UPDATE roles SET permissions = :p WHERE name = 'admin'"),
|
||||||
{"p": _json.dumps(perms)}
|
{"p": _json.dumps(perms)}
|
||||||
)
|
)
|
||||||
logger.info("✓ Added proxmox.cluster.manage to admin role")
|
logger.info("✓ Migrated admin role: proxmox.cluster.manage → cluster:manage")
|
||||||
|
else:
|
||||||
|
logger.info("✓ Admin role cluster:manage already up to date")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not update admin role permissions: {e}")
|
logger.warning(f"Could not update admin role permissions: {e}")
|
||||||
|
|
||||||
@@ -1181,6 +1196,66 @@ def migrate_workspaces(conn):
|
|||||||
logger.info("✓ workspaces tables created, Default workspace populated")
|
logger.info("✓ workspaces tables created, Default workspace populated")
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_user_servers(conn):
|
||||||
|
"""Migration 20: Create user_servers association table for server-to-user assignments."""
|
||||||
|
logger.info("Migration 20: Creating user_servers table...")
|
||||||
|
|
||||||
|
if table_exists(conn, 'user_servers'):
|
||||||
|
logger.info("Table user_servers already exists, skipping")
|
||||||
|
return
|
||||||
|
|
||||||
|
conn.execute(text("""
|
||||||
|
CREATE TABLE user_servers (
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
server_id INTEGER NOT NULL REFERENCES proxmox_servers(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (user_id, server_id)
|
||||||
|
)
|
||||||
|
"""))
|
||||||
|
conn.execute(text("CREATE INDEX idx_user_servers_user ON user_servers(user_id)"))
|
||||||
|
conn.execute(text("CREATE INDEX idx_user_servers_server ON user_servers(server_id)"))
|
||||||
|
|
||||||
|
logger.info("✓ user_servers table created")
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_user_role_remove_dashboard(conn):
|
||||||
|
"""Migration 21: Ensure dashboard:view is False for user role (new format only)."""
|
||||||
|
logger.info("Migration 21: Updating user role permissions (dashboard:view = false)...")
|
||||||
|
|
||||||
|
if not table_exists(conn, 'roles'):
|
||||||
|
logger.info("Table roles does not exist, skipping")
|
||||||
|
return
|
||||||
|
|
||||||
|
result = conn.execute(text("SELECT id, permissions FROM roles WHERE name = 'user'"))
|
||||||
|
row = result.fetchone()
|
||||||
|
if not row:
|
||||||
|
logger.info("Role 'user' not found, skipping")
|
||||||
|
return
|
||||||
|
|
||||||
|
import json as _json
|
||||||
|
perms = row[1] if isinstance(row[1], dict) else _json.loads(row[1] or '{}')
|
||||||
|
|
||||||
|
changed = False
|
||||||
|
|
||||||
|
# Remove old dot-format key if present (it triggers Migration 9 to "migrate" the role every start)
|
||||||
|
if 'dashboard.view' in perms:
|
||||||
|
del perms['dashboard.view']
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
# Ensure new-format key is False
|
||||||
|
if perms.get('dashboard:view') is not False:
|
||||||
|
perms['dashboard:view'] = False
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
if changed:
|
||||||
|
conn.execute(
|
||||||
|
text("UPDATE roles SET permissions = :p WHERE name = 'user'"),
|
||||||
|
{"p": _json.dumps(perms)}
|
||||||
|
)
|
||||||
|
logger.info("✓ user role: dashboard:view set to False (old dot-format key removed)")
|
||||||
|
else:
|
||||||
|
logger.info("✓ user role: dashboard:view already False, skipping")
|
||||||
|
|
||||||
|
|
||||||
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.
|
||||||
@@ -1347,6 +1422,22 @@ def run_all_migrations(engine, db_session=None):
|
|||||||
logger.warning(f"Workspaces migration: {e}")
|
logger.warning(f"Workspaces migration: {e}")
|
||||||
conn.rollback()
|
conn.rollback()
|
||||||
|
|
||||||
|
# Migration 20: User-Server assignments
|
||||||
|
try:
|
||||||
|
migrate_user_servers(conn)
|
||||||
|
conn.commit()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"User-Server assignments migration: {e}")
|
||||||
|
conn.rollback()
|
||||||
|
|
||||||
|
# Migration 21: Update user role - disable dashboard.view
|
||||||
|
try:
|
||||||
|
migrate_user_role_remove_dashboard(conn)
|
||||||
|
conn.commit()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"User role dashboard 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)
|
||||||
|
|||||||
Reference in New Issue
Block a user