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:
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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'; }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ? ` · Завершена: ${fmtTime(task.completed_at)}` : '') +
|
||||
` · Время: ${elapsed(task.created_at, task.completed_at)}`;
|
||||
`${t('created_label')}: ${fmtTime(task.created_at)}` +
|
||||
(task.completed_at ? ` · ${t('completed_label')}: ${fmtTime(task.completed_at)}` : '') +
|
||||
` · ${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() {
|
||||
|
||||
@@ -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;">×</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;">×</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');
|
||||
|
||||
@@ -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;">×</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;">
|
||||
|
||||
@@ -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()">×</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'));
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user