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:
markmorado
2026-03-16 08:27:36 +01:00
parent c09c0c5b63
commit 57575d58ce
22 changed files with 1436 additions and 314 deletions

View File

@@ -8,7 +8,7 @@ from datetime import datetime, timedelta
from ..db import get_db
from ..models import ProxmoxServer, AuditLog, User
from ..template_helpers import add_i18n_context
from ..auth import get_current_user, PermissionChecker
from ..auth import PermissionChecker
router = APIRouter()
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))
named_clusters = cluster_q.scalar() or 0
standalone_count = standalone_q.count()
total_clusters = named_clusters + standalone_count
total_clusters = named_clusters
# Alerts in last 24 hours
since_24h = datetime.utcnow() - timedelta(hours=24)

View File

@@ -92,12 +92,50 @@ def list_proxmox_servers(
):
"""Получить список всех Proxmox серверов (фильтруется по активному workspace)"""
from ...api.workspaces import get_workspace_server_ids
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))
servers = query.all()
return servers
from ...models import WorkspaceUser, WorkspaceServer
is_privileged = current_user.is_admin or (
current_user.role and current_user.role.name in ('admin', 'moderator')
)
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)
@@ -601,6 +639,22 @@ def get_server_resources(
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)]
# Получаем 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)
db.commit()
@@ -608,7 +662,8 @@ def get_server_resources(
"server_id": server_id,
"server_name": server.name,
"vms": vms,
"containers": containers
"containers": containers,
"node_uptime": node_uptime
})
except Exception as e:
server.update_status(False, str(e))

View File

@@ -45,6 +45,10 @@ class BulkOperationRequest(BaseModel):
items: TypingList[BulkOperationItem]
class VMOwnerUpdate(BaseModel):
user_id: Optional[int] = None
@router.post("/api/sync-vms")
def sync_vms_now(
db: Session = Depends(get_db),
@@ -2793,3 +2797,73 @@ def create_bulk_operation(
except ValueError as 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}

View File

@@ -12,7 +12,7 @@ from pydantic import BaseModel, Field, EmailStr
from loguru import logger
from ..db import get_db
from ..models import User, Role
from ..models import User, Role, ProxmoxServer, Workspace, WorkspaceServer, WorkspaceUser
from ..auth import (
get_password_hash,
get_current_user,
@@ -75,6 +75,7 @@ class RoleResponse(BaseModel):
is_system: bool
is_active: bool
created_at: datetime
user_count: int = 0
class Config:
from_attributes = True
@@ -140,7 +141,6 @@ class SecuritySettingsUpdate(BaseModel):
@router.get("/", response_class=HTMLResponse)
async def users_page(
request: Request,
current_user: User = Depends(PermissionChecker("users.view"))
):
"""Render users management page"""
from ..i18n import t
@@ -162,8 +162,31 @@ async def list_roles(
current_user: User = Depends(PermissionChecker("roles.view"))
):
"""Get all roles"""
from sqlalchemy import func
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)
@@ -585,6 +608,162 @@ async def delete_user(
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")
async def reset_user_password(
user_id: int,

View File

@@ -3,7 +3,7 @@ import os
from contextlib import asynccontextmanager
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.staticfiles import StaticFiles
from fastapi.exception_handlers import http_exception_handler
@@ -13,6 +13,8 @@ from loguru import logger
from .config import settings
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 proxmox as proxmox_router
from .api import auth as auth_router
@@ -259,6 +261,13 @@ def create_app() -> FastAPI:
@app.exception_handler(HTTPException)
async def custom_http_exception_handler(request: Request, exc: HTTPException):
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)
# Health check endpoint

View File

@@ -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.orm import relationship
from datetime import datetime, timezone
@@ -43,6 +43,15 @@ class Role(Base):
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):
__tablename__ = "users"
@@ -76,6 +85,7 @@ class User(Base):
# Relationships
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:
"""
@@ -429,6 +439,9 @@ class ProxmoxServer(Base):
elif is_online:
self.last_error = None
# Relationships
assigned_users = relationship("User", secondary=user_servers, back_populates="assigned_servers", lazy="select")
class VMInstance(Base):
"""VM/Container instance cache and configuration storage"""

View File

@@ -127,7 +127,7 @@ NEW_DEFAULT_ROLES = [
"description": "Standard user with limited access",
"is_system": True,
"permissions": {
"dashboard:view": True,
"dashboard:view": False,
"server:view": True,
"vm:view": True,
"vm:start": True,
@@ -377,22 +377,25 @@ def ensure_default_roles_new_format(conn) -> None:
if existing:
role_id, current_perms = existing
# Parse current permissions
if isinstance(current_perms, str):
try:
current_perms = json.loads(current_perms)
except Exception:
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())
if has_old_format or not current_perms:
# Update to new format
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 not current_perms:
# Role has no permissions at all — seed with defaults
conn.execute(
text("""
UPDATE roles
UPDATE roles
SET permissions = :perms,
display_name = :display_name,
description = :description
@@ -402,10 +405,26 @@ def ensure_default_roles_new_format(conn) -> None:
"perms": json.dumps(role_data["permissions"]),
"display_name": role_data["display_name"],
"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:
# Create new role
conn.execute(

View File

@@ -2068,13 +2068,15 @@ a:hover {
}
[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);
border-color: #dde1e9;
}
[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);
}
@@ -2423,20 +2425,22 @@ body {
/* ── Navbar tasks button & badge ──────────────────────────────── */
.tasks-btn {
position: relative;
background: none;
border: none;
cursor: pointer;
padding: 8px;
border-radius: 50%;
width: 40px;
height: 40px;
border-radius: var(--radius-md);
border: 1px solid var(--border-default);
background: var(--bg-surface-hover);
color: var(--text-secondary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: color var(--transition-fast), background var(--transition-fast);
transition: all var(--transition-fast);
}
.tasks-btn:hover {
background: var(--bg-surface-active);
color: var(--text-primary);
background: var(--bg-hover);
transform: scale(1.05);
}
.tasks-count-badge {
position: absolute;
@@ -2882,6 +2886,39 @@ body {
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 {
display: flex;

View File

@@ -226,6 +226,14 @@
document.addEventListener('keydown', function(event) {
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
const modals = document.querySelectorAll('[id$="Modal"], [id$="modal"]');
let closedAny = false;
@@ -305,6 +313,10 @@
--transition: all 0.2s 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-overlay {
@@ -488,6 +500,100 @@
transform: translateY(-2px);
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>
</head>
<body data-is-admin="false">
@@ -495,7 +601,7 @@
<div id="update-banner" class="update-banner" style="display: none;">
<div class="update-banner-content">
<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>
@@ -598,6 +704,8 @@ body.update-in-progress .layout {
<span style="font-size: 2rem;"><i class="fa-solid fa-cloud"></i></span>
{% endif %}
</div>
<!-- System Status -->
{% block sidebar_status %}{% endblock %}
<!-- Workspace Selector -->
<div class="sidebar-workspace-section" id="sidebarWorkspaceSection">
<div class="sidebar-workspace-label">Workspace</div>
@@ -613,48 +721,48 @@ body.update-in-progress .layout {
</div>
<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>
<span>{{ t('nav_dashboard') }}</span>
</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>
<span>{{ t('nav_vms') }}</span>
</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>
<span>{{ t('nav_proxmox') }}</span>
</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>
<span>{{ t('nav_templates') }}</span>
</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>
<span>{{ t('nav_backups') }}</span>
</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>
<span>{{ t('nav_ipam') }}</span>
</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>
<span>{{ t('nav_docker') }}</span>
</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>
<span>{{ t('nav_logs') }}</span>
</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>
<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>
</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>
<span>{{ t('nav_settings') }}</span>
</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>
<span>{{ t('nav_users') }}</span>
</a></li>
@@ -696,19 +804,6 @@ body.update-in-progress .layout {
{% block page_subtitle %}<div class="page-subtitle" id="pageGlobalSubtitle"></div>{% endblock %}
</div>
<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 -->
<button id="themeToggle" class="theme-toggle" title="{{ t('theme') }}">
@@ -729,7 +824,7 @@ body.update-in-progress .layout {
</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">
<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>
@@ -744,11 +839,46 @@ body.update-in-progress .layout {
</svg>
<span id="notificationBadge" class="notification-badge" style="display: none;">0</span>
</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>
</header>
<main>
{% block content %}{% endblock %}
</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>
<!-- Notification Center Dropdown -->
@@ -773,9 +903,9 @@ body.update-in-progress .layout {
<!-- Task Drawer -->
<div id="taskDrawer" class="task-drawer" style="display:none;">
<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;">
<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>
</button>
<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-empty" id="taskDrawerEmpty">
<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 id="taskDrawerCards"></div>
</div>
<div class="task-drawer-footer">
<a href="/tasks">
<i class="fa-solid fa-arrow-right" style="margin-right:.4rem;"></i>
Открыть менеджер задач
{{ t('open_task_manager') }}
</a>
</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 -->
<div id="customDialogOverlay" class="custom-dialog-overlay">
<div class="custom-dialog">
@@ -844,15 +1003,153 @@ body.update-in-progress .layout {
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
window.userPermissions = {};
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) {
if (window.isAdmin) 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
@@ -879,14 +1176,20 @@ body.update-in-progress .layout {
window.addEventListener('DOMContentLoaded', async () => {
// Skip check on login page
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;
}
const token = localStorage.getItem('access_token');
if (!token) {
// Save current URL as query parameter
const currentUrl = encodeURIComponent(window.location.pathname + window.location.search);
window.location.href = `/login?returnUrl=${currentUrl}`;
const currentPath = window.location.pathname;
// Avoid redirect loop: don't add returnUrl if already on login
if (currentPath !== '/login') {
const currentUrl = encodeURIComponent(currentPath + window.location.search);
window.location.href = `/login?returnUrl=${currentUrl}`;
}
return;
}
@@ -912,6 +1215,16 @@ body.update-in-progress .layout {
window.userPermissions = userData.permissions || {};
window.isAdmin = userData.is_admin === true;
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
applyPermissionsToUI();
@@ -920,44 +1233,79 @@ body.update-in-progress .layout {
window.dispatchEvent(new CustomEvent('permissionsLoaded', { detail: { permissions: window.userPermissions, isAdmin: window.isAdmin } }));
} 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
function applyPermissionsToUI() {
const navLinks = document.querySelectorAll('.sidebar-nav a[href]');
navLinks.forEach(link => {
const href = link.getAttribute('href');
let requiredPermission = null;
// Map routes to required permissions
if (href === '/vms' || href.startsWith('/proxmox')) {
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';
}
// --- 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'],
];
document.querySelectorAll('.sidebar-nav li[data-nav-permission]').forEach(li => {
const perm = li.getAttribute('data-nav-permission');
if (!window.hasPermission(perm)) {
li.style.display = 'none';
} else {
li.style.display = '';
}
});
// Apply permissions to elements with data-permission attribute
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
@@ -1044,7 +1392,7 @@ body.update-in-progress .layout {
const dotEl = document.getElementById('sidebarWorkspaceColorDot');
if (!nameEl) return;
if (!ws) {
nameEl.textContent = 'Все';
nameEl.textContent = t('all_workspaces');
if (dotEl) { dotEl.style.background = '#0ea5e9'; dotEl.style.opacity = '0.4'; }
} else {
nameEl.textContent = ws.name;
@@ -1061,7 +1409,7 @@ body.update-in-progress .layout {
if (isAdmin) {
const allItem = document.createElement('div');
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); };
container.appendChild(allItem);
}
@@ -1069,7 +1417,7 @@ body.update-in-progress .layout {
workspaces.forEach(ws => {
const item = document.createElement('div');
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); };
container.appendChild(item);
});
@@ -1682,12 +2030,12 @@ body.update-in-progress .layout {
}
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 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 => {
const st = normalizeTaskStatus(t.status);
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));
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'}) : '';
return `<div class="task-mini-card ${st}">
<div class="task-mini-top">
@@ -1921,7 +2269,7 @@ body.update-in-progress .layout {
}
renderTaskDrawer();
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) {
console.error('clearCompletedTasks error', e);

View File

@@ -11,7 +11,8 @@
</div>
{% 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 %}>
<span class="status-chip-dot"></span>
<span id="dashStatusText">
@@ -24,7 +25,7 @@
{%- endif -%}
</span>
</div>
<div class="header-datetime" id="dashDatetime"></div>
</div>
{% endblock %}
{% block content %}
@@ -201,24 +202,6 @@
<script>
// ======== 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
function setPctBadgeColor(el, pct) {
el.classList.remove('pct-ok', 'pct-warn', 'pct-high');

View File

@@ -742,7 +742,7 @@ async function executeCommand(command) {
const outputContent = document.getElementById('command-output-content');
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 {
const timeout = 30;
@@ -755,8 +755,8 @@ async function executeCommand(command) {
if (!response.ok) {
const error = await response.text();
outputContent.innerHTML = `$ ${command}\n<i class="fa-solid fa-circle-xmark"></i> Ошибка: ${error}`;
showNotification('Ошибка выполнения команды', 'error');
outputContent.innerHTML = `$ ${command}\n<i class="fa-solid fa-circle-xmark"></i> ${t('error')}: ${error}`;
showNotification(t('command_execution_error'), 'error');
return;
}
@@ -764,8 +764,8 @@ async function executeCommand(command) {
displayCommandOutput(result, command);
} catch (error) {
outputContent.innerHTML = `$ ${command}\n<i class="fa-solid fa-circle-xmark"></i> Ошибка: ${error.message}`;
showNotification('Ошибка выполнения команды', 'error');
outputContent.innerHTML = `$ ${command}\n<i class="fa-solid fa-circle-xmark"></i> ${t('error')}: ${error.message}`;
showNotification(t('command_execution_error'), 'error');
}
}
@@ -799,7 +799,7 @@ function displayCommandOutput(result, command = null) {
output += `\n\nExit code: ${result.exit_code}`;
}
showNotification('Ошибка выполнения', 'error');
showNotification(t('execution_error'), 'error');
}
outputContent.textContent = output;
@@ -952,7 +952,7 @@ function drawCharts(data, timeframe = 'hour') {
labels: labels,
datasets: [
{
label: 'Использовано',
label: t('used'),
data: data.map(d => {
const used = d.mem || d.memused || 0;
return (used / (1024**3)).toFixed(2);
@@ -965,7 +965,7 @@ function drawCharts(data, timeframe = 'hour') {
pointRadius: 0
},
{
label: 'Выделено',
label: t('allocated'),
data: data.map(d => {
const max = d.maxmem || d.memtotal || 1;
return (max / (1024**3)).toFixed(2);
@@ -1171,7 +1171,7 @@ async function deleteInstance() {
// Second confirmation - enter ID
const confirmId = await showPrompt(
t('enter_id_to_confirm').replace('{id}', vmid),
t('confirm_deletion') || 'Подтверждение удаления',
t('confirm_deletion'),
{
type: 'danger',
icon: '<i class="fa-solid fa-triangle-exclamation"></i>',
@@ -1314,25 +1314,25 @@ async function openVNC() {
vncConnection.addEventListener('disconnect', (e) => {
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 {
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="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;">
<p style="color: var(--text-muted); font-size: 0.875rem; margin-bottom: 0.5rem;">
<strong>Возможные причины:</strong>
<strong>${t('possible_reasons')}:</strong>
</p>
<ul style="color: var(--text-muted); font-size: 0.8rem; margin-left: 1rem;">
<li>Proxmox VNC требует авторизацию по паролю (не API token)</li>
<li>Добавьте пароль в настройках Proxmox сервера</li>
<li>VM/контейнер может быть выключен</li>
<li>${t('vnc_requires_password')}</li>
<li>${t('vnc_add_password')}</li>
<li>${t('vnc_vm_may_be_off')}</li>
</ul>
</div>
<div style="display: flex; gap: 1rem;">
<button onclick="openVNC()" class="btn btn-primary"><i class="fa-solid fa-rotate"></i> Переподключиться</button>
<a href="https://${vncData.host}:8006" target="_blank" class="btn btn-secondary"><i class="fa-solid fa-globe"></i> Открыть Proxmox</a>
<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> ${t('open_proxmox')}</a>
</div>
</div>
`;
@@ -1343,8 +1343,8 @@ async function openVNC() {
vncContent.innerHTML = `
<div style="padding: 2rem; text-align: center;">
<div style="font-size: 2rem; margin-bottom: 1rem;"><i class="fa-solid fa-lock"></i></div>
<div class="text-danger">Ошибка безопасности VNC</div>
<p style="color: var(--text-muted); font-size: 0.875rem; margin-top: 0.5rem;">${e.detail.reason || 'Неизвестная ошибка'}</p>
<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 || t('unknown_error')}</p>
</div>
`;
});
@@ -1739,7 +1739,7 @@ function closeEditModal() {
async function showReinstallModal() {
document.getElementById('reinstallModal').style.display = 'flex';
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 {
const [groupsRes, templatesRes] = await Promise.all([
@@ -1754,8 +1754,8 @@ async function showReinstallModal() {
content.innerHTML = `
<div style="text-align: center; padding: 2rem;">
<div style="font-size: 2rem; margin-bottom: 1rem;"><i class="fa-solid fa-cube"></i></div>
<p style="color: var(--text-secondary);">Нет доступных шаблонов для этого сервера</p>
<a href="/templates" class="btn" style="margin-top: 1rem;">Добавить шаблоны</a>
<p style="color: var(--text-secondary);">${t('no_templates_for_server')}</p>
<a href="/templates" class="btn" style="margin-top: 1rem;">${t('add_templates')}</a>
</div>
`;
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' });
if (!stopRes.ok) {
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));
}
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, чтобы сохранилось в базу)
const deleteRes = await fetchWithAuth(`/proxmox/api/${serverId}/${apiType}/${vmid}?node=${instanceNode}`, { method: 'DELETE' });
if (!deleteRes.ok) {
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));
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 параметрами
const deployData = {
@@ -1976,7 +1976,7 @@ async function doReinstallInstance(templateId, cloudInitUser, cloudInitPassword,
if (response.ok) {
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();
// Перезагружаем страницу через 2 секунды (VMID остался тот же)
setTimeout(() => {
@@ -1991,7 +1991,7 @@ async function doReinstallInstance(templateId, cloudInitUser, cloudInitPassword,
}
} catch (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');
}
}

View File

@@ -226,10 +226,33 @@
// Save token to localStorage
localStorage.setItem('access_token', data.access_token);
// Redirect to original page or dashboard
const urlParams = new URLSearchParams(window.location.search);
const returnUrl = urlParams.get('returnUrl') ? decodeURIComponent(urlParams.get('returnUrl')) : '/dashboard';
window.location.href = returnUrl;
if (urlParams.get('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 {
// Show error
errorMessage.textContent = data.detail || '{{ t('login_error_check_credentials') }}';
@@ -255,11 +278,34 @@
headers: {
'Authorization': `Bearer ${token}`
}
}).then(response => {
}).then(async response => {
if (response.ok) {
const urlParams = new URLSearchParams(window.location.search);
const returnUrl = urlParams.get('returnUrl') ? decodeURIComponent(urlParams.get('returnUrl')) : '/dashboard';
window.location.href = returnUrl;
if (urlParams.get('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'; }
}
}
});
}

View File

@@ -44,21 +44,24 @@
<th class="sortable" data-sort="node" onclick="sortTable('node')">
{{ t('node') or 'Node' }} <i class="fa-solid fa-sort sort-icon"></i>
</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>
</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>
</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>
</th>
<th data-sort="uptime" style="text-align:center; white-space:nowrap;">
Uptime
</th>
<th style="text-align: right;">{{ t('actions') }}</th>
</tr>
</thead>
<tbody id="serversTableBody">
<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') }}
</td>
</tr>
@@ -317,31 +320,39 @@
<div style="margin-bottom: 1rem; padding: 1rem; background: var(--bg-secondary); border-radius: 0.5rem;">
<div class="checkbox-row">
<label style="display: flex; align-items: center; gap: 0.5rem;">
<input type="checkbox" id="editUsePassword" onchange="toggleEditAuthMethod()">
<span>{{ t('use_password_less_secure') }}</span>
<input type="checkbox" id="editChangeCredentials" onchange="toggleEditCredentialsSection()">
<span>{{ t('change_credentials') or 'Изменить учётные данные' }}</span>
</label>
</div>
</div>
<div id="editTokenFields">
<div class="form-row">
<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 id="editCredentialsSection" style="display: none;">
<div style="margin-bottom: 1rem; padding: 1rem; background: var(--bg-secondary); border-radius: 0.5rem;">
<div class="checkbox-row">
<label style="display: flex; align-items: center; gap: 0.5rem;">
<input type="checkbox" id="editUsePassword" onchange="toggleEditAuthMethod()">
<span>{{ t('use_password_less_secure') }}</span>
</label>
</div>
</div>
</div>
<div id="editPasswordField" style="display: none;">
<label>{{ t('password') }} *</label>
<input type="password" id="editPassword" name="password" placeholder="{{ t('password') }}">
</div>
<div style="margin: 1rem 0;">
<div id="editTokenFields">
<div class="form-row">
<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 id="editPasswordField" style="display: none;">
<label>{{ t('password') }} *</label>
<input type="password" id="editPassword" name="password" placeholder="{{ t('password') }}">
</div>
<div class="checkbox-row">
<label style="display: flex; align-items: center; gap: 0.5rem;">
<input type="checkbox" id="editVerifySsl" name="verify_ssl">
@@ -717,9 +728,27 @@ function showEditServerModal() {
function closeEditServerModal() {
document.getElementById('editServerModal').style.display = 'none';
document.getElementById('editServerForm').reset();
document.getElementById('editCredentialsSection').style.display = 'none';
document.getElementById('editChangeCredentials').checked = false;
document.getElementById('editTokenFields').style.display = 'block';
document.getElementById('editPasswordField').style.display = 'none';
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() {
@@ -761,8 +790,12 @@ async function editServer(serverId) {
document.getElementById('editDescription').value = server.description || '';
// 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;
toggleEditAuthMethod();
document.getElementById('editTokenName').removeAttribute('required');
document.getElementById('editTokenValue').removeAttribute('required');
document.getElementById('editPassword').removeAttribute('required');
showEditServerModal();
} else {
@@ -790,20 +823,23 @@ async function submitEditServer(event) {
verify_ssl: formData.get('verify_ssl') === 'on'
};
// Only update auth credentials if provided
if (usePassword) {
const password = formData.get('password');
if (password) {
data.use_password = true;
data.password = password;
}
} else {
const tokenName = formData.get('api_token_name');
const tokenValue = formData.get('api_token_value');
if (tokenName && tokenValue) {
data.use_password = false;
data.api_token_name = tokenName;
data.api_token_value = tokenValue;
// Only update auth credentials if user explicitly chose to change them
const changeCredentials = document.getElementById('editChangeCredentials').checked;
if (changeCredentials) {
if (usePassword) {
const password = formData.get('password');
if (password) {
data.use_password = true;
data.password = password;
}
} else {
const tokenName = formData.get('api_token_name');
const tokenValue = formData.get('api_token_value');
if (tokenName && 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 vmsCell = document.getElementById(`server-vms-${serverId}`);
const lxcCell = document.getElementById(`server-lxc-${serverId}`);
const uptimeCell = document.getElementById(`server-uptime-${serverId}`);
const row = document.getElementById(`server-row-${serverId}`);
try {
@@ -915,6 +952,22 @@ async function loadServerStatus(serverId) {
// Update status
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
if (row) {
row.dataset.vms = vmsCount;
@@ -929,6 +982,7 @@ async function loadServerStatus(serverId) {
vmsCell.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>`;
if (uptimeCell) uptimeCell.innerHTML = `<span style="color:var(--text-muted);">—</span>`;
if (row) row.dataset.status = 'offline';
}
} catch (error) {
@@ -1922,7 +1976,7 @@ async function loadServersTable() {
const tbody = document.getElementById('serversTableBody');
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 {
const resp = await fetchWithAuth('/proxmox/api/servers');
@@ -1930,7 +1984,7 @@ async function loadServersTable() {
const servers = await resp.json();
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>
</td></tr>`;
return;
@@ -1963,6 +2017,9 @@ async function loadServersTable() {
<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>
</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;">
<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') || 'Тест'}">
@@ -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') || 'Детали'}">
<i class="fa-solid fa-chart-simple"></i> ${t('details') || 'Детали'}
</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>
</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>
</button>
</div>

View File

@@ -3,13 +3,13 @@
{% block content %}
<div class="page-header-block">
<div class="page-header-left">
<h1 class="page-header-title"><i class="fa-solid fa-list-check"></i> Менеджер задач</h1>
<p class="page-header-sub">Все активные и завершённые задачи в реальном времени</p>
<h1 class="page-header-title"><i class="fa-solid fa-list-check"></i> {{ t('task_manager') }}</h1>
<p class="page-header-sub">{{ t('task_manager_subtitle') }}</p>
</div>
<div class="page-header-right">
<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 id="wsStatusText">Подключение...</span>
<span id="wsStatusText">{{ t('connecting') }}</span>
</div>
<button class="btn btn-secondary" onclick="TaskManager.refresh()" title="{{ t('refresh') }}">
<i class="fa-solid fa-rotate-right"></i>
@@ -20,27 +20,27 @@
<!-- Tab bar -->
<div class="tasks-tab-bar">
<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 class="tasks-tab" data-filter="running" onclick="TaskManager.setFilter('running', this)">
<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 class="tasks-tab" data-filter="pending" onclick="TaskManager.setFilter('pending', this)">
<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 class="tasks-tab" data-filter="completed" onclick="TaskManager.setFilter('completed', this)">
<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 class="tasks-tab" data-filter="failed" onclick="TaskManager.setFilter('failed', this)">
<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 class="tasks-tab" data-filter="cancelled" onclick="TaskManager.setFilter('cancelled', this)">
<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>
</div>
@@ -48,7 +48,7 @@
<div id="taskList">
<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>
<p style="font-size:.95rem;">Задачи не найдены</p>
<p style="font-size:.95rem;">{{ t('tasks_not_found') }}</p>
</div>
<div id="taskCards"></div>
</div>
@@ -62,7 +62,7 @@
<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>
<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 class="modal-body" style="padding:0;">
<!-- Status bar -->
@@ -79,13 +79,13 @@
<li class="nav-item">
<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;">
<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>
</li>
<li class="nav-item">
<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;">
<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>
</li>
</ul>
@@ -93,22 +93,22 @@
<!-- Results tab -->
<div id="tabResults" style="padding:1.25rem 1.5rem;">
<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>
<!-- Log tab -->
<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 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;">
<i class="fa-solid fa-ban"></i> Отменить задачу
<i class="fa-solid fa-ban"></i> {{ t('cancel_task') }}
</button>
<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);">
Закрыть
{{ t('close') }}
</button>
</div>
</div>
@@ -129,9 +129,9 @@ const TaskManager = (() => {
// ── Status helpers ─────────────────────────────────────────────
const STATUS_LABEL = {
running: 'Выполняется', pending: 'В очереди', queued: 'В очереди',
completed: 'Завершено', failed: 'Ошибка', error: 'Ошибка',
cancelled: 'Отменено', canceled: 'Отменено'
running: t('status_running'), pending: t('status_pending'), queued: t('status_pending'),
completed: t('status_completed'), failed: t('status_failed'), error: t('status_failed'),
cancelled: t('status_cancelled'), canceled: t('status_cancelled')
};
const STATUS_ICON = {
running: '<i class="fa-solid fa-circle-notch task-icon-spin"></i>',
@@ -163,16 +163,16 @@ const TaskManager = (() => {
}
function fmtTime(iso) {
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; }
}
function elapsed(start, end) {
if (!start) return '';
const a = new Date(start), b = end ? new Date(end) : new Date();
const s = Math.floor((b - a) / 1000);
if (s < 60) return `${s}с`;
if (s < 3600) return `${Math.floor(s/60)}м ${s%60}с`;
return `${Math.floor(s/3600)}ч ${Math.floor((s%3600)/60)}м`;
if (s < 60) return `${s}${t('seconds_s')}`;
if (s < 3600) return `${Math.floor(s/60)}${t('minutes_m')} ${s%60}${t('seconds_s')}`;
return `${Math.floor(s/3600)}${t('hours_h')} ${Math.floor((s%3600)/60)}${t('minutes_m')}`;
}
// ── Build card HTML ────────────────────────────────────────────
@@ -181,10 +181,10 @@ const TaskManager = (() => {
const pct = progressPct(task);
const indeterminate = (pct === null);
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 = [];
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 {
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>`);
@@ -194,7 +194,7 @@ const TaskManager = (() => {
const cancelBtn = (st === 'pending' && task.kind === 'bulk') ?
`<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;">
<i class="fa-solid fa-ban"></i> Отменить
<i class="fa-solid fa-ban"></i> ${t('cancel')}
</button>` : '';
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 txt = document.getElementById('wsStatusText');
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 (txt) txt.textContent = labels[status];
}
@@ -338,12 +338,12 @@ const TaskManager = (() => {
function refreshTraceModal(task) {
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('traceModalMeta').innerHTML =
`Создана: ${fmtTime(task.created_at)}` +
(task.completed_at ? ` &nbsp;·&nbsp; Завершена: ${fmtTime(task.completed_at)}` : '') +
` &nbsp;·&nbsp; Время: ${elapsed(task.created_at, task.completed_at)}`;
`${t('created_label')}: ${fmtTime(task.created_at)}` +
(task.completed_at ? ` &nbsp;·&nbsp; ${t('completed_label')}: ${fmtTime(task.completed_at)}` : '') +
` &nbsp;·&nbsp; ${t('elapsed_label')}: ${elapsed(task.created_at, task.completed_at)}`;
// Status badge
document.getElementById('traceStatusBadge').innerHTML =
@@ -365,7 +365,7 @@ const TaskManager = (() => {
// Log tab
if (task.kind === 'proxmox') {
document.getElementById('traceLogContent').textContent = task.log_text || '(лог пуст)';
document.getElementById('traceLogContent').textContent = task.log_text || t('log_empty');
} else {
renderBulkLog(task);
}
@@ -378,15 +378,15 @@ const TaskManager = (() => {
if (task.kind === 'proxmox') {
el.innerHTML = `<table class="table task-results-table" style="margin:0;">
<thead><tr>
<th>Параметр</th><th>Значение</th>
<th>${t('parameter')}</th><th>${t('value_column')}</th>
</tr></thead>
<tbody>
<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>Тип</td><td>${escH(task.vm_type)||'—'}</td></tr>
<tr><td>Действие</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('type_label')}</td><td>${escH(task.vm_type)||'—'}</td></tr>
<tr><td>${t('action_label')}</td><td>${escH(task.action)}</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>
</table>`;
} else if (task.kind === 'bulk') {
@@ -395,7 +395,7 @@ const TaskManager = (() => {
const results = task.results || [];
if (results.length === 0) {
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>`;
return;
}
@@ -405,11 +405,11 @@ const TaskManager = (() => {
return `<tr>
<td>${icon}</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>`;
}).join('');
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>
</table>`;
}
@@ -419,10 +419,10 @@ const TaskManager = (() => {
const items = task.results || [];
const lines = items.map(r => {
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||''}`;
}).join('\n');
document.getElementById('traceLogContent').textContent = lines || '(пусто)';
document.getElementById('traceLogContent').textContent = lines || t('log_empty_simple');
}
function switchTraceTab(tab) {
@@ -445,20 +445,20 @@ const TaskManager = (() => {
const resp = await fetchWithAuth(`/proxmox/api/proxmox-tasks/${id}/log`);
if (resp && resp.ok) {
const data = await resp.json();
document.getElementById('traceLogContent').textContent = data.log_text || '(лог пуст)';
document.getElementById('traceLogContent').textContent = data.log_text || t('log_empty');
}
} catch {}
}
async function cancelTaskById(kind, id) {
if (kind !== 'bulk') return;
const ok = await showConfirm('Отменить эту задачу?', 'Подтверждение');
const ok = await showConfirm(t('cancel_task_confirm'), t('confirmation'));
if (!ok) return;
try {
const resp = await fetchWithAuth(`/proxmox/api/tasks/${id}/cancel`, { method:'POST' });
if (resp && resp.ok) showToast('success', 'Готово', 'Задача отменена');
else showToast('error', 'Ошибка', 'Не удалось отменить');
} catch { showToast('error', 'Ошибка', 'Ошибка сети'); }
if (resp && resp.ok) showToast('success', t('done'), t('task_cancelled'));
else showToast('error', t('error'), t('failed_to_cancel'));
} catch { showToast('error', t('error'), t('network_error')); }
}
async function cancelTask() {

View File

@@ -295,6 +295,20 @@
</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;">&times;</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 -->
<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;">
@@ -339,7 +353,7 @@
<!-- 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 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;">
<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;">&times;</button>
@@ -372,10 +386,12 @@
<input type="checkbox" class="form-check-input" name="is_admin" id="editIsAdmin">
<label class="form-check-label" for="editIsAdmin">{{ t('administrator') }}</label>
</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">
<label class="form-check-label" for="editIsActive">{{ t('active') }}</label>
</div>
<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="submit" class="btn btn-success">{{ t('save') }}</button>
@@ -520,7 +536,7 @@ function switchTab(tabName) {
// Show modals
function showAddUserModal() {
document.getElementById('addUserModal').style.display = 'flex';
loadRolesForSelect('userRoleSelect');
loadRoles();
}
function showAddRoleModal() {
@@ -536,6 +552,12 @@ function closeModal(modalId) {
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
document.addEventListener('DOMContentLoaded', function() {
// 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.email}</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>
${user.is_active
@@ -1018,7 +1040,7 @@ async function editUser(userId) {
document.getElementById('editRoleSelect').value = user.role_id || '';
document.getElementById('editIsAdmin').checked = user.is_admin;
document.getElementById('editIsActive').checked = user.is_active;
document.getElementById('editUserModal').style.display = 'flex';
} catch (error) {
console.error('Error:', error);
@@ -1044,6 +1066,7 @@ async function deleteUser(userId, username) {
if (response.ok) {
showToast('success', '{{ _("success") }}', '{{ _("user_deleted") }}');
loadUsers();
loadRoles();
} else {
const error = await response.json();
showToast('error', '{{ _("error") }}', error.detail || '{{ _("error") }}');
@@ -1080,6 +1103,7 @@ document.getElementById('addUserForm').addEventListener('submit', async function
closeModal('addUserModal');
showToast('{{ _("user_created") }}', 'success');
loadUsers();
loadRoles();
this.reset();
} else {
const error = await response.json();
@@ -1117,14 +1141,16 @@ document.getElementById('editUserForm').addEventListener('submit', async functio
body: JSON.stringify(data)
});
if (response.ok) {
closeModal('editUserModal');
showToast('{{ _("user_updated") }}', 'success');
loadUsers();
} else {
if (!response.ok) {
const error = await response.json();
showToast(error.detail || '{{ _("error") }}', 'danger');
return;
}
closeModal('editUserModal');
showToast('{{ _("user_updated") }}', 'success');
loadUsers();
loadRoles();
} catch (error) {
console.error('Error:', error);
showToast('{{ _("error") }}', 'danger');

View File

@@ -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>
<span>{{ t('reinstall') }}</span>
</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">
<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>
@@ -2069,6 +2075,73 @@ function reinstallVM(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) {
closeVMContextMenu();
@@ -3188,6 +3261,33 @@ window.addEventListener('beforeunload', () => {
});
</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;">&times;</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 -->
<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;">

View File

@@ -13,7 +13,7 @@
</div>
<div class="page-header-right">
<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>
</div>
</div>
@@ -21,7 +21,7 @@
<!-- Workspace Cards -->
<div id="workspacesGrid" class="ws-grid">
<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>
@@ -31,20 +31,20 @@
<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-header">
<h3 id="wsModalTitle">Создать Workspace</h3>
<h3 id="wsModalTitle">{{ t('create_workspace') }}</h3>
<button class="modal-close" onclick="closeWsModal()">&times;</button>
</div>
<div class="modal-body">
<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">
</div>
<div class="form-group">
<label>Описание</label>
<textarea id="wsDescription" class="form-input" rows="2" placeholder="Описание workspace" maxlength="500"></textarea>
<label>{{ t('description') }}</label>
<textarea id="wsDescription" class="form-input" rows="2" placeholder="{{ t('workspace_desc_placeholder') }}" maxlength="500"></textarea>
</div>
<div class="form-group">
<label>Цвет</label>
<label>{{ t('color') }}</label>
<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;">
<div class="ws-color-presets">
@@ -59,22 +59,22 @@
</div>
<div class="form-group" id="wsServersGroup">
<label>Серверы</label>
<label>{{ t('servers_label') }}</label>
<div id="wsServersList" class="ws-assign-list">
<div class="loading-placeholder"><i class="fa-solid fa-spinner fa-spin"></i></div>
</div>
</div>
<div class="form-group" id="wsUsersGroup">
<label>Пользователи с доступом</label>
<label>{{ t('users_with_access') }}</label>
<div id="wsUsersList" class="ws-assign-list">
<div class="loading-placeholder"><i class="fa-solid fa-spinner fa-spin"></i></div>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeWsModal()">Отмена</button>
<button class="btn btn-primary" id="wsModalSaveBtn" onclick="saveWorkspace()">Сохранить</button>
<button class="btn btn-secondary" onclick="closeWsModal()">{{ t('cancel') }}</button>
<button class="btn btn-primary" id="wsModalSaveBtn" onclick="saveWorkspace()">{{ t('save') }}</button>
</div>
</div>
</div>
@@ -327,7 +327,7 @@ async function loadWorkspaces() {
const workspaces = await resp.json();
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;
}
@@ -341,8 +341,8 @@ async function loadWorkspaces() {
</div>
<div class="ws-card-desc">${escapeHtml(ws.description || '')}</div>
<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-users"></i>${ws.user_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} ${t('users_count_sfx')}</span>
</div>
<div class="ws-card-actions">
<button class="btn btn-sm btn-secondary" onclick="showEditModal(${ws.id})">
@@ -355,7 +355,7 @@ async function loadWorkspaces() {
</div>
`).join('');
} 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 = []) {
const container = document.getElementById('wsServersList');
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;
}
const selectedSet = new Set(selectedIds);
@@ -396,7 +396,7 @@ function renderServerCheckboxes(selectedIds = []) {
function renderUserCheckboxes(selectedIds = []) {
const container = document.getElementById('wsUsersList');
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;
}
const selectedSet = new Set(selectedIds);
@@ -415,7 +415,7 @@ function renderUserCheckboxes(selectedIds = []) {
// -----------------------------------------------------------------------
async function showCreateModal() {
_editingId = null;
document.getElementById('wsModalTitle').textContent = 'Создать Workspace';
document.getElementById('wsModalTitle').textContent = t('create_workspace');
document.getElementById('wsName').value = '';
document.getElementById('wsDescription').value = '';
document.getElementById('wsColor').value = '#0ea5e9';
@@ -432,7 +432,7 @@ async function showCreateModal() {
// -----------------------------------------------------------------------
async function showEditModal(id) {
_editingId = id;
document.getElementById('wsModalTitle').textContent = 'Редактировать Workspace';
document.getElementById('wsModalTitle').textContent = t('edit_workspace');
try {
const resp = await fetchWithAuth(`/api/workspaces/${id}`);
@@ -451,7 +451,7 @@ async function showEditModal(id) {
document.getElementById('wsModal').style.display = 'flex';
} catch (e) {
showAlertModal('Ошибка', 'Не удалось загрузить данные workspace');
showAlertModal(t('error'), t('failed_to_load_workspace'));
}
}
@@ -466,7 +466,7 @@ function closeWsModal() {
async function saveWorkspace() {
const name = document.getElementById('wsName').value.trim();
if (!name) {
showAlertModal('Ошибка', 'Введите название workspace');
showAlertModal(t('error'), t('workspace_name_required'));
return;
}
@@ -519,13 +519,13 @@ async function saveWorkspace() {
if (typeof loadSidebarWorkspace === 'function') loadSidebarWorkspace();
} else {
const err = await resp.json().catch(() => ({}));
showAlertModal('Ошибка', err.detail || 'Не удалось сохранить');
showAlertModal(t('error'), err.detail || t('failed_to_save'));
}
} catch (e) {
showAlertModal('Ошибка', e.message || 'Произошла ошибка');
showAlertModal(t('error'), e.message || t('error_occurred'));
} finally {
btn.disabled = false;
btn.innerHTML = 'Сохранить';
btn.innerHTML = t('save');
}
}
@@ -534,8 +534,8 @@ async function saveWorkspace() {
// -----------------------------------------------------------------------
async function deleteWorkspace(id, name) {
showConfirmModal(
'Удалить Workspace',
`Удалить workspace «${name}»? Серверы и пользователи не будут удалены.`,
t('delete_workspace'),
t('delete_workspace_confirm').replace('{name}', name),
async () => {
const resp = await fetchWithAuth(`/api/workspaces/${id}`, { method: 'DELETE' });
if (resp && (resp.ok || resp.status === 204)) {
@@ -543,7 +543,7 @@ async function deleteWorkspace(id, name) {
if (typeof loadSidebarWorkspace === 'function') loadSidebarWorkspace();
} else {
const err = await resp.json().catch(() => ({}));
showAlertModal('Ошибка', err.detail || 'Не удалось удалить');
showAlertModal(t('error'), err.detail || t('failed_to_delete'));
}
}
);

View File

@@ -121,7 +121,7 @@ DEFAULT_ROLES = [
"description": "VPS-style user: can only access their own instances",
"is_system": True,
"permissions": {
"dashboard.view": True,
"dashboard.view": False,
"proxmox.view": True,
"proxmox.manage": False,
"proxmox.servers.add": False,
@@ -808,29 +808,34 @@ def migrate_vm_instance_owner(conn):
except Exception as 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:
result = conn.execute(text("SELECT permissions FROM roles WHERE name = 'user'"))
row = result.fetchone()
if row:
import json
permissions = row[0] if isinstance(row[0], dict) else json.loads(row[0] or '{}')
# Add VPS-style permissions for user role
permissions.update({
vps_permissions = {
"vms:view:own": True, # View only own VMs
"vms:start:own": True, # Start own VMs
"vms:stop:own": True, # Stop own VMs
"vms:restart:own": True, # Restart own VMs
"vms:console:own": True, # Console to own VMs
"vms:snapshots:own": True, # Manage snapshots of own VMs
})
conn.execute(
text("UPDATE roles SET permissions = :perms, updated_at = NOW() WHERE name = 'user'"),
{"perms": json.dumps(permissions)}
)
logger.info("✓ Updated user role with instance ownership permissions")
}
# Only add keys that are not already present — never overwrite existing values
missing = {k: v for k, v in vps_permissions.items() if k not in permissions}
if missing:
permissions.update(missing)
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:
logger.warning(f"Could not update user role permissions: {e}")
@@ -1062,19 +1067,29 @@ def migrate_cluster_support(conn):
else:
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:
result = conn.execute(text("SELECT permissions FROM roles WHERE name = 'admin'")).fetchone()
if result:
import json as _json
perms = _json.loads(result.permissions) if isinstance(result.permissions, str) else result.permissions
if not perms.get('proxmox.cluster.manage'):
perms['proxmox.cluster.manage'] = True
changed = False
# 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(
text("UPDATE roles SET permissions = :p WHERE name = 'admin'"),
{"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:
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")
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):
"""
Run all migrations in order.
@@ -1347,6 +1422,22 @@ def run_all_migrations(engine, db_session=None):
logger.warning(f"Workspaces migration: {e}")
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("✓ All migrations completed successfully")
logger.info("=" * 50)