2 Commits

22 changed files with 1749 additions and 85 deletions

View File

@@ -4,6 +4,26 @@ All notable changes to PVEmanager will be documented in this file.
---
## [v1.1.4] - 2026-03-15
### 🗂️ Workspaces — Scoped Server Groups
- New **Workspace** entity: named group of Proxmox servers with scoped user access
- DB models: `Workspace`, `WorkspaceServer`, `WorkspaceUser` with automatic DB migration
- Full CRUD REST API (`/api/workspaces`, `/api/workspaces/{id}`) — admin-only create/update/delete, filtered list for regular users
- `X-Active-Workspace` request header propagated from frontend — filters server/VM/backup API responses to the active workspace
- Default workspace created on first run; all existing servers assigned to it
- Admin workspace management page (`/workspaces`) — create/edit/delete workspaces, assign servers and users
- **Sidebar workspace selector** — active workspace label shown in sidebar, switch without page reload
- `proxmox_server_detail.html` — dedicated server detail page (new template)
- i18n keys added for all workspace UI strings (EN + RU)
### 🔧 Fixes
- Fixed `PermissionChecker` import error in `dashboard.py`
---
## [v1.1.3] - 2026-03-10
### 🚨 Dashboard — Alerts Panel & Real-time Node Stats

View File

@@ -4,7 +4,7 @@
**Modern web panel for managing Proxmox servers, virtual machines and LXC containers**
[![Version](https://img.shields.io/badge/version-1.1.3-blue?style=flat-square)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-1.1.4-blue?style=flat-square)](CHANGELOG.md)
[![License](https://img.shields.io/badge/license-MIT-green?style=flat-square)](LICENSE)
[![Docker](https://img.shields.io/badge/docker-ready-2496ED?style=flat-square&logo=docker&logoColor=white)](compose.yml)
[![Python](https://img.shields.io/badge/python-3.12-3776AB?style=flat-square&logo=python&logoColor=white)](backend/)

View File

@@ -1 +1 @@
1.1.3
1.1.4

58
WIKI.md
View File

@@ -1,6 +1,6 @@
# 📖 PVEmanager - Documentation
> Complete guide for installation, configuration and usage of PVEmanager v1.1.3
> Complete guide for installation, configuration and usage of PVEmanager v1.1.4
---
@@ -24,10 +24,11 @@
16. [Settings](#-settings)
17. [pve CLI Tool](#-pve-cli-tool)
18. [API Reference](#-api-reference)
19. [Deployment Guide](#-installation-and-deployment)
20. [Private Repo Updates](#-settings)
21. [Troubleshooting](#-troubleshooting)
22. [FAQ](#-faq)
19. [Workspaces](#-workspaces)
20. [Deployment Guide](#-installation-and-deployment)
21. [Private Repo Updates](#-settings)
22. [Troubleshooting](#-troubleshooting)
23. [FAQ](#-faq)
---
@@ -1409,7 +1410,52 @@ Available at: `http://localhost:8000/docs`
---
## 🔧 Troubleshooting
## <EFBFBD>️ Workspaces
Workspaces are named groups of Proxmox servers with scoped user access. They allow administrators to partition the infrastructure and grant users access to only their servers.
### Key Concepts
| Concept | Description |
|---|---|
| **Workspace** | Named group with optional color and description |
| **Default Workspace** | Created automatically; all servers belong to it by default |
| **Server assignment** | One server can belong to multiple workspaces |
| **User assignment** | Users see only workspaces they are assigned to |
### Managing Workspaces (Admin)
1. Open **Settings → Workspaces** (sidebar menu item) or navigate to `/workspaces`
2. Click **New Workspace** to create a workspace
3. Assign servers from the **Servers** tab inside the workspace detail
4. Assign users from the **Users** tab
### Switching Workspaces (Sidebar)
- The active workspace is shown in the sidebar below the logo
- Click the workspace name to open the switcher
- Select a workspace — all VM/server lists are immediately filtered
- Select **All Workspaces** (admin only) to remove the filter
### API
| Method | Endpoint | Description |
|---|---|---|
| `GET` | `/api/workspaces` | List workspaces (filtered for non-admins) |
| `POST` | `/api/workspaces` | Create workspace (admin) |
| `GET` | `/api/workspaces/{id}` | Get workspace detail with servers/users |
| `PUT` | `/api/workspaces/{id}` | Update workspace (admin) |
| `DELETE` | `/api/workspaces/{id}` | Delete workspace (admin) |
| `POST` | `/api/workspaces/{id}/servers` | Add server to workspace |
| `DELETE` | `/api/workspaces/{id}/servers/{server_id}` | Remove server from workspace |
| `POST` | `/api/workspaces/{id}/users` | Add user to workspace |
| `DELETE` | `/api/workspaces/{id}/users/{user_id}` | Remove user from workspace |
Pass the `X-Active-Workspace: {id}` header in API requests to filter responses by workspace.
---
## <20>🔧 Troubleshooting
### Startup Issues

View File

@@ -8,6 +8,7 @@ from . import logs
from . import settings
from . import notifications
from . import users
from . import workspaces
__all__ = [
"auth",
@@ -18,5 +19,6 @@ __all__ = [
"logs",
"settings",
"notifications",
"users"
"users",
"workspaces",
]

View File

@@ -17,18 +17,52 @@ templates = Jinja2Templates(directory="app/templates")
@router.get("/", response_class=HTMLResponse, include_in_schema=False)
@router.get("/dashboard", response_class=HTMLResponse, include_in_schema=False)
def dashboard(request: Request, db: Session = Depends(get_db)):
total_servers = db.query(ProxmoxServer).count()
online_servers = db.query(ProxmoxServer).filter(ProxmoxServer.is_online == True).count()
offline_servers = db.query(ProxmoxServer).filter(ProxmoxServer.is_online == False).count()
from .workspaces import get_workspace_server_ids
# Count distinct clusters (non-null cluster_name counts as one cluster each)
named_clusters = db.query(func.count(distinct(ProxmoxServer.cluster_name))).filter(
# Try to get current user for workspace filtering (best-effort)
try:
token = request.cookies.get("access_token") or (
request.headers.get("Authorization", "").removeprefix("Bearer ").strip() or None
)
current_user_obj = None
if token:
from ..auth import decode_access_token
from ..models import User as UserModel
try:
payload = decode_access_token(token)
username = payload.get("sub")
if username:
current_user_obj = db.query(UserModel).filter(
UserModel.username == username, UserModel.is_active == True
).first()
except Exception:
pass
server_ids = get_workspace_server_ids(request, db, current_user_obj) if current_user_obj else None
except Exception:
server_ids = None
base_q = db.query(ProxmoxServer)
if server_ids is not None:
base_q = base_q.filter(ProxmoxServer.id.in_(server_ids))
total_servers = base_q.count()
online_servers = base_q.filter(ProxmoxServer.is_online == True).count()
offline_servers = base_q.filter(ProxmoxServer.is_online == False).count()
# Count distinct clusters within the filtered servers
cluster_q = db.query(func.count(distinct(ProxmoxServer.cluster_name))).filter(
ProxmoxServer.cluster_name.isnot(None),
ProxmoxServer.cluster_name != ''
).scalar() or 0
standalone_count = db.query(ProxmoxServer).filter(
)
standalone_q = db.query(ProxmoxServer).filter(
(ProxmoxServer.cluster_name == None) | (ProxmoxServer.cluster_name == '')
).count()
)
if server_ids is not None:
cluster_q = cluster_q.filter(ProxmoxServer.id.in_(server_ids))
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
# Alerts in last 24 hours
@@ -47,7 +81,10 @@ def dashboard(request: Request, db: Session = Depends(get_db)):
AuditLog.created_at.desc()
).limit(5).all()
recent_servers = db.query(ProxmoxServer).order_by(ProxmoxServer.id.desc()).limit(6).all()
recent_servers_q = db.query(ProxmoxServer)
if server_ids is not None:
recent_servers_q = recent_servers_q.filter(ProxmoxServer.id.in_(server_ids))
recent_servers = recent_servers_q.order_by(ProxmoxServer.id.desc()).limit(6).all()
stats = {
"total_servers": total_servers,

View File

@@ -32,8 +32,36 @@ async def backups_page(
db: Session = Depends(get_db),
):
from ...i18n import t
from ...api.workspaces import get_workspace_server_ids
from ...auth import get_current_user_optional
lang = request.cookies.get("language", "ru")
servers = db.query(ProxmoxServer).filter(ProxmoxServer.is_online.is_(True)).all()
# Workspace filtering (best-effort, page may be loaded unauthenticated)
server_ids = None
try:
token = request.cookies.get("access_token") or (
request.headers.get("Authorization", "").removeprefix("Bearer ").strip() or None
)
if token:
from ...auth import decode_access_token
from ...models import User as UserModel
payload = decode_access_token(token)
username = payload.get("sub")
if username:
user_obj = db.query(UserModel).filter(
UserModel.username == username, UserModel.is_active == True
).first()
if user_obj:
server_ids = get_workspace_server_ids(request, db, user_obj)
except Exception:
pass
servers_q = db.query(ProxmoxServer).filter(ProxmoxServer.is_online.is_(True))
if server_ids is not None:
servers_q = servers_q.filter(ProxmoxServer.id.in_(server_ids))
servers = servers_q.all()
context = {
"request": request,
"page_title": t("nav_backups", lang),
@@ -57,7 +85,7 @@ def get_storages(
raise HTTPException(status_code=404, detail="Server not found")
try:
client = _get_proxmox_client(server)
storages = client.get_storages()
storages = client.get_cluster_storages()
return JSONResponse(content={"storages": storages})
except Exception as e:
logger.error(f"Error listing storages for server {server_id}: {e}")

View File

@@ -84,11 +84,17 @@ def instance_detail_page(request: Request, server_id: int, vmid: int, type: str
@router.get("/api/servers", response_model=List[ProxmoxServerResponse])
def list_proxmox_servers(
db: Session = Depends(get_db),
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("proxmox.view"))
):
"""Получить список всех Proxmox серверов"""
servers = db.query(ProxmoxServer).all()
"""Получить список всех 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
@@ -705,7 +711,7 @@ def get_storages(
if not client.is_connected():
raise HTTPException(status_code=503, detail="Failed to connect to Proxmox server")
storages = client.get_storages(node)
storages = client.get_node_storages(node)
# Фильтрация по типу контента если указано
if content_type and storages:

View File

@@ -77,6 +77,7 @@ def sync_vms_now(
@router.get("/api/virtual-machines")
def get_all_virtual_machines(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("vms.view"))
):
@@ -88,9 +89,16 @@ def get_all_virtual_machines(
Для пользователей с ролью 'user' возвращаются только их собственные инстансы.
"""
from ...models import VMInstance
# Get servers for name lookup
servers = db.query(ProxmoxServer).all()
from ...api.workspaces import get_workspace_server_ids
# Workspace filtering
workspace_server_ids = get_workspace_server_ids(request, db, current_user)
# Get servers for name lookup (filtered by workspace)
servers_q = db.query(ProxmoxServer)
if workspace_server_ids is not None:
servers_q = servers_q.filter(ProxmoxServer.id.in_(workspace_server_ids))
servers = servers_q.all()
server_map = {s.id: s for s in servers}
# Pre-load IPAM allocations for IP lookup
@@ -121,6 +129,10 @@ def get_all_virtual_machines(
VMInstance.deleted_at.is_(None),
VMInstance.is_template == False
)
# Workspace filter: only show VMs from servers in the active workspace
if workspace_server_ids is not None:
query = query.filter(VMInstance.server_id.in_(workspace_server_ids))
# VPS-style user isolation: users with 'user' role see only their own instances
# Check if user has 'vms.view' but not 'vms.view:all' (or is user role)

View File

@@ -0,0 +1,431 @@
"""
Workspace API
=============
Endpoints for managing workspaces — named groups of Proxmox servers
with scoped user access.
Admin-only: create, update, delete, assign servers/users.
All authenticated: list (filtered to their workspaces), get detail.
"""
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
from loguru import logger
from ..db import get_db
from ..models import Workspace, WorkspaceServer, WorkspaceUser, ProxmoxServer, User
from ..schemas import WorkspaceCreate, WorkspaceUpdate, WorkspaceResponse, WorkspaceDetail
from ..auth import get_current_user, PermissionChecker
from ..template_helpers import add_i18n_context
from ..logging_service import LoggingService
router = APIRouter()
templates = Jinja2Templates(directory="app/templates")
# ---------------------------------------------------------------------------
# Helper: resolve workspace server IDs from X-Active-Workspace header
# ---------------------------------------------------------------------------
def get_workspace_server_ids(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> Optional[List[int]]:
"""
Read X-Active-Workspace header and return list[int] of server_ids
that belong to that workspace AND that the current user can access.
Returns None when:
- Header is absent → no workspace filter (show all, admin behaviour)
- workspace_id == 0 → explicit "show all" (admin only)
- workspace not found or user has no access → empty list (403-ish)
"""
raw = request.headers.get("X-Active-Workspace")
if raw is None:
return None # no filter
try:
workspace_id = int(raw)
except ValueError:
return None
if workspace_id == 0:
# "All workspaces" — only admins may use this
if current_user.is_admin:
return None
# non-admin trying to bypass → return their union of server_ids
workspace_id = None
if workspace_id is None:
# Collect all workspaces the user belongs to
wus = db.query(WorkspaceUser).filter(WorkspaceUser.user_id == current_user.id).all()
ws_ids = [wu.workspace_id for wu in wus]
if not ws_ids:
# Fall back to Default workspace
default_ws = db.query(Workspace).filter(Workspace.is_default == True).first()
if default_ws:
ws_ids = [default_ws.id]
else:
return []
rows = db.query(WorkspaceServer.server_id).filter(
WorkspaceServer.workspace_id.in_(ws_ids)
).all()
return [r.server_id for r in rows]
workspace = db.query(Workspace).filter(Workspace.id == workspace_id).first()
if not workspace:
return []
# Check access: admins always have access; regular users need WorkspaceUser entry
if not current_user.is_admin:
access = db.query(WorkspaceUser).filter(
WorkspaceUser.workspace_id == workspace_id,
WorkspaceUser.user_id == current_user.id,
).first()
if not access:
return []
rows = db.query(WorkspaceServer.server_id).filter(
WorkspaceServer.workspace_id == workspace_id
).all()
return [r.server_id for r in rows]
# ---------------------------------------------------------------------------
# Helper: build WorkspaceResponse with counters
# ---------------------------------------------------------------------------
def _build_response(ws: Workspace) -> WorkspaceResponse:
return WorkspaceResponse(
id=ws.id,
name=ws.name,
description=ws.description,
color=ws.color,
is_default=ws.is_default,
server_count=len(ws.workspace_servers),
user_count=len(ws.workspace_users),
created_at=ws.created_at,
)
def _build_detail(ws: Workspace) -> WorkspaceDetail:
from ..schemas import WorkspaceServerItem, WorkspaceUserItem
servers = [
WorkspaceServerItem(
id=wss.server.id,
name=wss.server.name,
ip_address=wss.server.ip_address,
is_online=wss.server.is_online,
cluster_name=wss.server.cluster_name,
)
for wss in ws.workspace_servers
if wss.server is not None
]
users = [
WorkspaceUserItem(
id=wsu.user.id,
username=wsu.user.username,
email=wsu.user.email,
full_name=wsu.user.full_name,
)
for wsu in ws.workspace_users
if wsu.user is not None
]
return WorkspaceDetail(
id=ws.id,
name=ws.name,
description=ws.description,
color=ws.color,
is_default=ws.is_default,
server_count=len(servers),
user_count=len(users),
created_at=ws.created_at,
servers=servers,
users=users,
)
# ===========================================================================
# HTML Page
# ===========================================================================
@router.get("/workspaces", response_class=HTMLResponse, include_in_schema=False)
def workspaces_page(
request: Request,
):
"""Admin workspace management page"""
context = {
"request": request,
"page_title": "Workspaces",
}
from ..template_helpers import add_i18n_context
context = add_i18n_context(request, context)
return templates.TemplateResponse("workspaces.html", context)
# ===========================================================================
# REST API
# ===========================================================================
@router.get("/api/workspaces", response_model=List[WorkspaceResponse])
def list_workspaces(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
List workspaces.
Admins see all; regular users see only workspaces they belong to
(plus the Default workspace).
"""
if current_user.is_admin:
workspaces = db.query(Workspace).order_by(Workspace.is_default.desc(), Workspace.name).all()
else:
wus = db.query(WorkspaceUser).filter(WorkspaceUser.user_id == current_user.id).all()
ws_ids = [wu.workspace_id for wu in wus]
# Always include default workspace
default_ws = db.query(Workspace).filter(Workspace.is_default == True).first()
if default_ws and default_ws.id not in ws_ids:
ws_ids.append(default_ws.id)
workspaces = db.query(Workspace).filter(Workspace.id.in_(ws_ids)).order_by(
Workspace.is_default.desc(), Workspace.name
).all()
return [_build_response(ws) for ws in workspaces]
@router.post("/api/workspaces", response_model=WorkspaceDetail, status_code=status.HTTP_201_CREATED)
def create_workspace(
data: WorkspaceCreate,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("settings.panel")),
):
"""Create a new workspace (admin only)."""
existing = db.query(Workspace).filter(Workspace.name == data.name).first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Workspace with name '{data.name}' already exists",
)
ws = Workspace(
name=data.name,
description=data.description,
color=data.color or "#667eea",
is_default=False,
created_by=current_user.id,
)
db.add(ws)
db.flush() # get ws.id
# Assign servers
for server_id in (data.server_ids or []):
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if server:
db.add(WorkspaceServer(workspace_id=ws.id, server_id=server_id))
# Assign users
for user_id in (data.user_ids or []):
user = db.query(User).filter(User.id == user_id).first()
if user:
db.add(WorkspaceUser(workspace_id=ws.id, user_id=user_id))
db.commit()
db.refresh(ws)
LoggingService.log(
db=db,
level=LoggingService.INFO,
category=LoggingService.SYSTEM,
action="workspace_create",
message=f"Workspace '{ws.name}' created",
username=current_user.username,
user_id=current_user.id,
resource_type="workspace",
resource_id=str(ws.id),
resource_name=ws.name,
success=True,
)
logger.info(f"User {current_user.username} created workspace: {ws.name}")
return _build_detail(ws)
@router.get("/api/workspaces/{workspace_id}", response_model=WorkspaceDetail)
def get_workspace(
workspace_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Get workspace detail with servers and users list."""
ws = db.query(Workspace).filter(Workspace.id == workspace_id).first()
if not ws:
raise HTTPException(status_code=404, detail="Workspace not found")
if not current_user.is_admin:
access = db.query(WorkspaceUser).filter(
WorkspaceUser.workspace_id == workspace_id,
WorkspaceUser.user_id == current_user.id,
).first()
if not access and not ws.is_default:
raise HTTPException(status_code=403, detail="Access denied")
return _build_detail(ws)
@router.put("/api/workspaces/{workspace_id}", response_model=WorkspaceDetail)
def update_workspace(
workspace_id: int,
data: WorkspaceUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("settings.panel")),
):
"""Update workspace name/description/color (admin only)."""
ws = db.query(Workspace).filter(Workspace.id == workspace_id).first()
if not ws:
raise HTTPException(status_code=404, detail="Workspace not found")
if data.name is not None:
dup = db.query(Workspace).filter(
Workspace.name == data.name, Workspace.id != workspace_id
).first()
if dup:
raise HTTPException(status_code=400, detail=f"Name '{data.name}' already taken")
ws.name = data.name
if data.description is not None:
ws.description = data.description
if data.color is not None:
ws.color = data.color
db.commit()
db.refresh(ws)
logger.info(f"User {current_user.username} updated workspace {ws.id}: {ws.name}")
return _build_detail(ws)
@router.delete("/api/workspaces/{workspace_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_workspace(
workspace_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("settings.panel")),
):
"""Delete workspace (admin only). Default workspace cannot be deleted."""
ws = db.query(Workspace).filter(Workspace.id == workspace_id).first()
if not ws:
raise HTTPException(status_code=404, detail="Workspace not found")
if ws.is_default:
raise HTTPException(status_code=400, detail="Cannot delete the Default workspace")
db.delete(ws)
db.commit()
logger.info(f"User {current_user.username} deleted workspace {workspace_id}: {ws.name}")
# ---------------------------------------------------------------------------
# Server assignment
# ---------------------------------------------------------------------------
from pydantic import BaseModel as _BaseModel
class _IdsRequest(_BaseModel):
ids: List[int]
@router.post("/api/workspaces/{workspace_id}/servers", response_model=WorkspaceDetail)
def assign_servers(
workspace_id: int,
body: _IdsRequest,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("settings.panel")),
):
"""Replace the server list of a workspace (admin only)."""
ws = db.query(Workspace).filter(Workspace.id == workspace_id).first()
if not ws:
raise HTTPException(status_code=404, detail="Workspace not found")
# Remove existing
db.query(WorkspaceServer).filter(WorkspaceServer.workspace_id == workspace_id).delete()
# Add new
for server_id in body.ids:
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if server:
db.add(WorkspaceServer(workspace_id=workspace_id, server_id=server_id))
db.commit()
db.refresh(ws)
return _build_detail(ws)
@router.delete("/api/workspaces/{workspace_id}/servers/{server_id}", response_model=WorkspaceDetail)
def remove_server(
workspace_id: int,
server_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("settings.panel")),
):
"""Remove a single server from a workspace (admin only)."""
ws = db.query(Workspace).filter(Workspace.id == workspace_id).first()
if not ws:
raise HTTPException(status_code=404, detail="Workspace not found")
db.query(WorkspaceServer).filter(
WorkspaceServer.workspace_id == workspace_id,
WorkspaceServer.server_id == server_id,
).delete()
db.commit()
db.refresh(ws)
return _build_detail(ws)
# ---------------------------------------------------------------------------
# User assignment
# ---------------------------------------------------------------------------
@router.post("/api/workspaces/{workspace_id}/users", response_model=WorkspaceDetail)
def assign_users(
workspace_id: int,
body: _IdsRequest,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("settings.panel")),
):
"""Replace the user list of a workspace (admin only)."""
ws = db.query(Workspace).filter(Workspace.id == workspace_id).first()
if not ws:
raise HTTPException(status_code=404, detail="Workspace not found")
db.query(WorkspaceUser).filter(WorkspaceUser.workspace_id == workspace_id).delete()
for user_id in body.ids:
user = db.query(User).filter(User.id == user_id).first()
if user:
db.add(WorkspaceUser(workspace_id=workspace_id, user_id=user_id))
db.commit()
db.refresh(ws)
return _build_detail(ws)
@router.delete("/api/workspaces/{workspace_id}/users/{user_id}", response_model=WorkspaceDetail)
def remove_user(
workspace_id: int,
user_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("settings.panel")),
):
"""Remove a single user from a workspace (admin only)."""
ws = db.query(Workspace).filter(Workspace.id == workspace_id).first()
if not ws:
raise HTTPException(status_code=404, detail="Workspace not found")
db.query(WorkspaceUser).filter(
WorkspaceUser.workspace_id == workspace_id,
WorkspaceUser.user_id == user_id,
).delete()
db.commit()
db.refresh(ws)
return _build_detail(ws)

View File

@@ -4769,6 +4769,14 @@ class I18nService:
"ru": "Можно указать несколько ключей (каждый с новой строки)",
"en": "You can specify multiple keys (one per line)"
},
"use_profile_ssh_key": {
"ru": "Вставить ключ из профиля",
"en": "Use key from profile"
},
"no_profile_ssh_key": {
"ru": "В вашем профиле не добавлен SSH ключ",
"en": "No SSH key found in your profile"
},
"confirm_data_loss": {
"ru": "Я понимаю, что все данные будут потеряны",
"en": "I understand that all data will be lost"

View File

@@ -22,6 +22,7 @@ from .api import logs as logs_router
from .api import settings as settings_router
from .api import notifications as notifications_router
from .api import users as users_router
from .api import workspaces as workspaces_router
from .logging_middleware import RequestLoggingMiddleware
from .language_middleware import LanguageMiddleware
from .i18n import I18nService
@@ -401,6 +402,7 @@ def create_app() -> FastAPI:
app.include_router(settings_router.router, prefix="/settings", tags=["settings"])
app.include_router(notifications_router.router, tags=["notifications"])
app.include_router(users_router.router, prefix="/admin", tags=["users", "admin"])
app.include_router(workspaces_router.router, tags=["workspaces"])
# Mount static files for noVNC
static_dir = Path(__file__).parent / "static"

View File

@@ -974,3 +974,66 @@ class BackupJob(Base):
'last_error': self.last_error,
'last_upid': self.last_upid,
}
# ==================== Workspace Models ====================
class Workspace(Base):
"""Workspace — named group of Proxmox servers with scoped user access"""
__tablename__ = "workspaces"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(100), unique=True, nullable=False, index=True)
description = Column(Text, nullable=True)
color = Column(String(20), nullable=True, default="#667eea") # accent color for UI
is_default = Column(Boolean, default=False, nullable=False) # only one can be default
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
# Relationships
creator = relationship("User", foreign_keys=[created_by])
workspace_servers = relationship("WorkspaceServer", back_populates="workspace", cascade="all, delete-orphan")
workspace_users = relationship("WorkspaceUser", back_populates="workspace", cascade="all, delete-orphan")
def __repr__(self):
return f"<Workspace(id={self.id}, name='{self.name}')>"
class WorkspaceServer(Base):
"""Junction table: servers belonging to a workspace"""
__tablename__ = "workspace_servers"
id = Column(Integer, primary_key=True, index=True)
workspace_id = Column(Integer, ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False)
server_id = Column(Integer, ForeignKey("proxmox_servers.id", ondelete="CASCADE"), nullable=False)
workspace = relationship("Workspace", back_populates="workspace_servers")
server = relationship("ProxmoxServer")
__table_args__ = (
Index('idx_ws_servers_workspace', 'workspace_id'),
Index('idx_ws_servers_server', 'server_id'),
Index('idx_ws_servers_unique', 'workspace_id', 'server_id', unique=True),
)
def __repr__(self):
return f"<WorkspaceServer(workspace_id={self.workspace_id}, server_id={self.server_id})>"
class WorkspaceUser(Base):
"""Junction table: users with access to a workspace"""
__tablename__ = "workspace_users"
id = Column(Integer, primary_key=True, index=True)
workspace_id = Column(Integer, ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
workspace = relationship("Workspace", back_populates="workspace_users")
user = relationship("User")
__table_args__ = (
Index('idx_ws_users_workspace', 'workspace_id'),
Index('idx_ws_users_user', 'user_id'),
Index('idx_ws_users_unique', 'workspace_id', 'user_id', unique=True),
)

View File

@@ -3078,7 +3078,7 @@ class ProxmoxClient:
# ==================== Storage Methods ====================
def get_storages(self) -> List[Dict]:
def get_cluster_storages(self) -> List[Dict]:
"""Get all storages configured on this Proxmox cluster"""
if not self.proxmox:
return []

View File

@@ -1,5 +1,5 @@
from datetime import datetime
from typing import Optional
from typing import Optional, List
import ipaddress
from pydantic import BaseModel, Field, field_validator, model_validator, EmailStr
@@ -734,3 +734,56 @@ class NotificationPreferenceResponse(BaseModel):
# Notification schemas added above
# ==================== Workspace Schemas ====================
class WorkspaceCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=100, description="Workspace name")
description: Optional[str] = Field(None, max_length=500)
color: Optional[str] = Field("#667eea", max_length=20, description="Accent color (CSS hex)")
server_ids: Optional[List[int]] = Field(default_factory=list, description="Initial server IDs")
user_ids: Optional[List[int]] = Field(default_factory=list, description="Initial user IDs with access")
class WorkspaceUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=100)
description: Optional[str] = Field(None, max_length=500)
color: Optional[str] = Field(None, max_length=20)
class WorkspaceServerItem(BaseModel):
id: int
name: str
ip_address: str
is_online: bool
cluster_name: Optional[str] = None
model_config = {"from_attributes": True}
class WorkspaceUserItem(BaseModel):
id: int
username: str
email: str
full_name: Optional[str] = None
model_config = {"from_attributes": True}
class WorkspaceResponse(BaseModel):
id: int
name: str
description: Optional[str] = None
color: Optional[str] = None
is_default: bool
server_count: int = 0
user_count: int = 0
created_at: datetime
model_config = {"from_attributes": True}
class WorkspaceDetail(WorkspaceResponse):
servers: List[WorkspaceServerItem] = []
users: List[WorkspaceUserItem] = []

View File

@@ -2700,16 +2700,17 @@ body {
}
/* ============================================
Sidebar - Cluster Selector Section
Sidebar - Workspace Selector Section
============================================ */
.sidebar-cluster-section {
.sidebar-workspace-section {
padding: 0 var(--spacing-md) var(--spacing-md);
border-bottom: 1px solid rgba(100,100,150,0.15);
margin-bottom: var(--spacing-sm);
position: relative;
}
.sidebar-cluster-label {
.sidebar-workspace-label {
font-size: 0.625rem;
font-weight: 700;
letter-spacing: 0.1em;
@@ -2719,7 +2720,7 @@ body {
padding-left: 2px;
}
.cluster-selector-btn {
.workspace-selector-btn {
display: flex;
align-items: center;
justify-content: space-between;
@@ -2737,23 +2738,87 @@ body {
gap: 8px;
}
.cluster-selector-btn:hover {
.workspace-selector-btn:hover {
background: rgba(102,126,234,0.18);
border-color: rgba(102,126,234,0.4);
color: #fff;
}
.cluster-name-text {
.workspace-color-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.workspace-name-text {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sidebar.collapsed .sidebar-cluster-section {
.workspace-dropdown {
position: absolute;
top: calc(100% - 4px);
left: var(--spacing-md);
right: var(--spacing-md);
background: var(--bg-card, #1a1f35);
border: 1px solid rgba(102,126,234,0.3);
border-radius: var(--radius-md);
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
z-index: 200;
max-height: 260px;
overflow-y: auto;
}
.workspace-dropdown-item {
display: flex;
align-items: center;
gap: 8px;
padding: 9px 12px;
cursor: pointer;
font-size: 0.84rem;
color: var(--text-secondary);
transition: background var(--transition-fast);
border-bottom: 1px solid rgba(100,100,150,0.08);
}
.workspace-dropdown-item:last-child {
border-bottom: none;
}
.workspace-dropdown-item:hover,
.workspace-dropdown-item.active {
background: rgba(102,126,234,0.12);
color: #e0e3ff;
}
.workspace-dropdown-item.active {
font-weight: 600;
}
.workspace-dropdown-item small {
margin-left: auto;
color: var(--text-muted);
font-size: 0.72rem;
white-space: nowrap;
}
.workspace-dropdown-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.sidebar.collapsed .sidebar-workspace-section {
display: none;
}
/* Removed old cluster selector styles — replaced by workspace */
.sidebar-cluster-section { display: none; }
/* ============================================
Page Subtitle & Header Status
============================================ */

View File

@@ -436,7 +436,7 @@
}
</style>
</head>
<body>
<body data-is-admin="false">
<!-- Update Banner (shown during system update) -->
<div id="update-banner" class="update-banner" style="display: none;">
<div class="update-banner-content">
@@ -544,13 +544,18 @@ body.update-in-progress .layout {
<span style="font-size: 2rem;"><i class="fa-solid fa-cloud"></i></span>
{% endif %}
</div>
<!-- Cluster Selector -->
<div class="sidebar-cluster-section" id="sidebarClusterSection">
<div class="sidebar-cluster-label">Cluster</div>
<button class="cluster-selector-btn" id="sidebarClusterBtn" title="Active cluster">
<span class="cluster-name-text" id="sidebarClusterName">—</span>
<i class="fa-solid fa-chevron-down" style="font-size:0.65rem; opacity:0.6;"></i>
<!-- Workspace Selector -->
<div class="sidebar-workspace-section" id="sidebarWorkspaceSection">
<div class="sidebar-workspace-label">Workspace</div>
<button class="workspace-selector-btn" id="sidebarWorkspaceBtn" title="Active workspace" onclick="toggleWorkspaceDropdown(event)">
<span class="workspace-color-dot" id="sidebarWorkspaceColorDot" style="background:#667eea;"></span>
<span class="workspace-name-text" id="sidebarWorkspaceName">—</span>
<i class="fa-solid fa-chevron-down" id="sidebarWorkspaceChevron" style="font-size:0.65rem; opacity:0.6; transition:transform 0.2s;"></i>
</button>
<!-- Dropdown -->
<div class="workspace-dropdown" id="workspaceDropdown" style="display:none;">
<div id="workspaceDropdownItems"></div>
</div>
</div>
<nav>
<ul class="sidebar-nav">
@@ -599,6 +604,12 @@ body.update-in-progress .layout {
<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>
<li id="navWorkspacesItem" class="{{ 'active' if request.path.startswith('/workspaces') else '' }}" style="display:none;">
<a href="/workspaces" class="{{ 'active' if request.path.startswith('/workspaces') else '' }}" title="Workspaces">
<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="7" width="20" height="14" rx="2"/><path d="M16 7V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2"/></svg>
<span>Workspaces</span>
</a>
</li>
<li style="margin-top: auto; border-top: 1px solid rgba(100, 100, 150, 0.2); padding-top: 1rem;">
<a href="#" onclick="logout(event)" title="{{ t('nav_logout') }}">
<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 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path><polyline points="16 17 21 12 16 7"></polyline><line x1="21" y1="12" x2="9" y2="12"></line></svg>
@@ -947,30 +958,173 @@ body.update-in-progress .layout {
}
}
// Load cluster info for sidebar cluster selector
async function loadSidebarCluster() {
// ======================================================================
// Workspace Selector
// ======================================================================
let _workspaceList = []; // cached list from API
let _activeWorkspaceId = null; // null = no filter (All)
function _wsGetActiveId() {
const stored = localStorage.getItem('activeWorkspaceId');
return stored !== null ? parseInt(stored, 10) : null;
}
function _wsSetActiveId(id) {
if (id === null || id === 0) {
localStorage.removeItem('activeWorkspaceId');
_activeWorkspaceId = null;
} else {
localStorage.setItem('activeWorkspaceId', String(id));
_activeWorkspaceId = id;
}
}
function _wsRenderButton(ws) {
const nameEl = document.getElementById('sidebarWorkspaceName');
const dotEl = document.getElementById('sidebarWorkspaceColorDot');
if (!nameEl) return;
if (!ws) {
nameEl.textContent = 'Все';
if (dotEl) { dotEl.style.background = '#667eea'; dotEl.style.opacity = '0.4'; }
} else {
nameEl.textContent = ws.name;
if (dotEl) { dotEl.style.background = ws.color || '#667eea'; dotEl.style.opacity = '1'; }
}
}
function _wsRenderDropdown(workspaces, isAdmin) {
const container = document.getElementById('workspaceDropdownItems');
if (!container) return;
container.innerHTML = '';
// "All" option — admin only
if (isAdmin) {
const allItem = document.createElement('div');
allItem.className = 'workspace-dropdown-item' + (_activeWorkspaceId === null ? ' active' : '');
allItem.innerHTML = `<span class="workspace-dropdown-dot" style="background:#667eea;opacity:0.4;"></span><span>Все</span>`;
allItem.onclick = (e) => { e.stopPropagation(); _wsSelect(null, null, isAdmin); };
container.appendChild(allItem);
}
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 || '#667eea'};"></span><span>${ws.name}</span><small>${ws.server_count} серв.</small>`;
item.onclick = (e) => { e.stopPropagation(); _wsSelect(ws.id, ws, isAdmin); };
container.appendChild(item);
});
}
function _wsSelect(id, ws, isAdmin) {
_wsSetActiveId(id);
_wsRenderButton(ws);
_wsRenderDropdown(_workspaceList, isAdmin);
document.getElementById('workspaceDropdown').style.display = 'none';
document.getElementById('sidebarWorkspaceChevron').style.transform = '';
// Reload current page data
if (typeof window.reloadPageData === 'function') {
window.reloadPageData();
} else {
location.reload();
}
}
function toggleWorkspaceDropdown(e) {
e.stopPropagation();
const dd = document.getElementById('workspaceDropdown');
const chevron = document.getElementById('sidebarWorkspaceChevron');
const isOpen = dd.style.display !== 'none';
dd.style.display = isOpen ? 'none' : 'block';
chevron.style.transform = isOpen ? '' : 'rotate(180deg)';
}
// Close dropdown on outside click
document.addEventListener('click', function(e) {
const section = document.getElementById('sidebarWorkspaceSection');
if (section && !section.contains(e.target)) {
const dd = document.getElementById('workspaceDropdown');
if (dd) dd.style.display = 'none';
const chevron = document.getElementById('sidebarWorkspaceChevron');
if (chevron) chevron.style.transform = '';
}
});
async function loadSidebarWorkspace() {
try {
const response = await fetchWithAuth('/proxmox/api/servers', { hideOnForbidden: true });
// Determine if current user is admin
let isAdminUser = false;
try {
const profileResp = await fetchWithAuth('/settings/api/profile', { hideOnForbidden: true });
if (profileResp && profileResp.ok) {
const profile = await profileResp.json();
isAdminUser = !!profile.is_admin;
document.body.dataset.isAdmin = isAdminUser ? 'true' : 'false';
}
} catch (e) { /* ignore */ }
const response = await fetchWithAuth('/api/workspaces', { hideOnForbidden: true });
if (!response || !response.ok) return;
const servers = await response.json();
const nameEl = document.getElementById('sidebarClusterName');
if (!nameEl) return;
if (!servers || servers.length === 0) {
nameEl.textContent = 'No cluster';
return;
}
// Find a named cluster
const named = servers.find(s => s.cluster_name && s.cluster_name.trim() !== '');
if (named) {
nameEl.textContent = named.cluster_name;
const workspaces = await response.json();
_workspaceList = workspaces;
// Restore active selection from localStorage
const storedId = _wsGetActiveId();
if (storedId !== null) {
const found = workspaces.find(w => w.id === storedId);
if (found) {
_activeWorkspaceId = storedId;
_wsRenderButton(found);
} else {
// Stored workspace no longer accessible — reset
_wsSetActiveId(null);
if (workspaces.length === 1) {
_wsSetActiveId(workspaces[0].id);
_wsRenderButton(workspaces[0]);
} else {
_wsRenderButton(null);
}
}
} else {
nameEl.textContent = servers[0].name || 'Standalone';
// Auto-select single workspace for non-admins
if (!isAdminUser && workspaces.length === 1) {
_wsSetActiveId(workspaces[0].id);
_wsRenderButton(workspaces[0]);
} else if (!isAdminUser && workspaces.length > 0) {
_wsSetActiveId(workspaces[0].id);
_wsRenderButton(workspaces[0]);
} else {
_wsRenderButton(null);
}
}
_wsRenderDropdown(workspaces, isAdminUser);
// Show Workspaces nav item for admins
if (isAdminUser) {
const navWs = document.getElementById('navWorkspacesItem');
if (navWs) navWs.style.display = '';
}
} catch (e) {
// silently ignore
}
}
// Patch fetchWithAuth to automatically attach X-Active-Workspace header
const _origFetchWithAuth = fetchWithAuth;
fetchWithAuth = async function(url, options = {}) {
const wsId = _wsGetActiveId();
if (wsId !== null) {
options.headers = options.headers || {};
if (options.headers instanceof Headers) {
options.headers.set('X-Active-Workspace', String(wsId));
} else {
options.headers['X-Active-Workspace'] = String(wsId);
}
}
return _origFetchWithAuth(url, options);
};
// Load unread notification count
async function loadNotificationCount() {
try {
@@ -1395,8 +1549,8 @@ body.update-in-progress .layout {
// Init task drawer WS
initTaskDrawerWS();
// Load cluster info for sidebar
loadSidebarCluster();
// Load workspace selector for sidebar
loadSidebarWorkspace();
}
// Add click handlers for sidebar menu items to update page title

View File

@@ -1798,7 +1798,10 @@ async function showReinstallModal() {
</div>
<div style="margin-bottom: 0;">
<label style="display: block; color: var(--text-secondary); margin-bottom: 0.25rem; font-size: 0.875rem;">${t('ssh_public_key')}:</label>
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.25rem;">
<label style="color: var(--text-secondary); font-size: 0.875rem;">${t('ssh_public_key')}:</label>
<button type="button" onclick="loadProfileSshKey()" style="background: none; border: 1px solid var(--border-color); border-radius: 4px; padding: 0.2rem 0.6rem; color: var(--text-secondary); font-size: 0.75rem; cursor: pointer; white-space: nowrap;"><i class="fa-solid fa-key"></i> ${t('use_profile_ssh_key')}</button>
</div>
<textarea id="reinstall-sshkey" placeholder="ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQ..." rows="3" style="width: 100%; padding: 0.5rem; border-radius: 4px; border: 1px solid var(--border-color); background: var(--bg-secondary); color: var(--text-primary); font-family: monospace; font-size: 0.75rem; resize: vertical;"></textarea>
<small style="color: var(--text-muted); font-size: 0.75rem;">${t('multiple_keys_hint')}</small>
</div>
@@ -1822,6 +1825,26 @@ async function showReinstallModal() {
}
}
async function loadProfileSshKey() {
try {
const res = await fetchWithAuth('/settings/api/profile');
if (!res.ok) throw new Error('profile_load_error');
const profile = await res.json();
const key = profile.ssh_public_key;
if (!key) {
showNotification('<i class="fa-solid fa-circle-xmark"></i> ' + (t('no_profile_ssh_key') || 'No SSH key in profile'), 'warning');
return;
}
const textarea = document.getElementById('reinstall-sshkey');
if (textarea) {
textarea.value = key;
showNotification('<i class="fa-solid fa-key"></i> ' + (t('use_profile_ssh_key') || 'Key loaded from profile'), 'success');
}
} catch (e) {
showNotification('<i class="fa-solid fa-circle-xmark"></i> ' + e.message, 'error');
}
}
async function executeReinstall(event) {
event.preventDefault();
@@ -1838,10 +1861,16 @@ async function executeReinstall(event) {
const cloudInitPassword = document.getElementById('reinstall-password').value;
const sshKeys = document.getElementById('reinstall-sshkey').value.trim();
const confirmed = await showDeleteConfirm(
t('reinstall') || 'Reinstall',
t('reinstall_confirm').replace('{name}', instanceName)
);
const confirmed = await showDialog({
type: 'warning',
icon: '<i class="fa-solid fa-triangle-exclamation"></i>',
title: t('reinstall') || 'Reinstall',
message: t('reinstall_confirm').replace('{name}', instanceName),
confirm: true,
confirmText: t('do_reinstall') || 'Reinstall',
cancelText: t('cancel') || 'Cancel',
confirmClass: 'danger'
});
if (!confirmed) return;

View File

@@ -2241,6 +2241,148 @@ async function doReinstall(modal, serverId, node, vmid, type, name, templateId,
function closeReinstallModal() {
document.getElementById('reinstallModal').style.display = 'none';
}
/**
* Разбирает сырую конфигурацию Proxmox и возвращает объект с параметрами
* для передачи в /templates/api/deploy при переустановке.
*/
function parseVmConfig(raw, type) {
const saved = {};
// CPU и память
if (raw.cores) saved.cores = parseInt(raw.cores);
if (raw.memory) saved.memory = parseInt(raw.memory);
if (raw.onboot !== undefined) saved.onboot = raw.onboot === 1 || raw.onboot === '1';
if (type === 'qemu') {
// Cloud-init user (пароль не сохраняем из соображений безопасности)
if (raw.ciuser) saved.cloud_init_user = raw.ciuser;
// Network bridge из net0 (формат: "virtio=XX:XX...,bridge=vmbr0,...")
if (raw.net0) {
const bridgeMatch = raw.net0.match(/bridge=([^,]+)/);
if (bridgeMatch) saved.network_bridge = bridgeMatch[1];
}
// IP и gateway из ipconfig0 (формат: "ip=1.2.3.4/24,gw=1.2.3.1")
if (raw.ipconfig0 && raw.ipconfig0 !== 'ip=dhcp') {
const ipMatch = raw.ipconfig0.match(/ip=([^/,]+)/);
const gwMatch = raw.ipconfig0.match(/gw=([^,]+)/);
if (ipMatch) saved.ip_address = ipMatch[1];
if (gwMatch) saved.gateway = gwMatch[1];
}
// Диск и хранилище из первого подходящего диска
for (const key of ['scsi0', 'virtio0', 'sata0', 'scsi1', 'virtio1']) {
if (raw[key] && !raw[key].includes('cdrom') && !raw[key].includes('cloudinit')) {
const storageMatch = raw[key].match(/^([^:]+):/);
const sizeMatch = raw[key].match(/size=(\d+)([GMK])/i);
if (storageMatch) saved.target_storage = storageMatch[1];
if (sizeMatch) {
let sizeGb = parseInt(sizeMatch[1]);
if (sizeMatch[2].toUpperCase() === 'M') sizeGb = Math.ceil(sizeGb / 1024);
if (sizeMatch[2].toUpperCase() === 'K') sizeGb = Math.ceil(sizeGb / (1024 * 1024));
saved.disk = sizeGb;
}
break;
}
}
} else {
// LXC
// Network bridge и IP из net0 (формат: "name=eth0,bridge=vmbr0,ip=1.2.3.4/24,gw=1.2.3.1,...")
if (raw.net0) {
const bridgeMatch = raw.net0.match(/bridge=([^,]+)/);
const ipMatch = raw.net0.match(/ip=([^/,]+)/);
const gwMatch = raw.net0.match(/gw=([^,]+)/);
if (bridgeMatch) saved.network_bridge = bridgeMatch[1];
if (ipMatch && ipMatch[1] !== 'dhcp') saved.ip_address = ipMatch[1];
if (gwMatch) saved.gateway = gwMatch[1];
}
// LXC rootfs: "local-lvm:vm-100-disk-0,size=8G"
if (raw.rootfs) {
const storageMatch = raw.rootfs.match(/^([^:]+):/);
const sizeMatch = raw.rootfs.match(/size=(\d+)([GMK])/i);
if (storageMatch) saved.target_storage = storageMatch[1];
if (sizeMatch) {
let sizeGb = parseInt(sizeMatch[1]);
if (sizeMatch[2].toUpperCase() === 'M') sizeGb = Math.ceil(sizeGb / 1024);
if (sizeMatch[2].toUpperCase() === 'K') sizeGb = Math.ceil(sizeGb / (1024 * 1024));
saved.disk = sizeGb;
}
}
}
return saved;
}
async function doReinstall(modal, serverId, node, vmid, type, name, templateId, templateVmid, templateNode) {
const apiType = type === 'qemu' ? 'vm' : 'container';
try {
// Шаг 0: Сохранить текущую конфигурацию VM перед любыми изменениями
let savedConfig = {};
try {
const configResponse = await fetchWithAuth(`/proxmox/api/${serverId}/${apiType}/${vmid}/config?node=${node}`);
if (configResponse.ok) {
const rawConfig = await configResponse.json();
savedConfig = parseVmConfig(rawConfig, type);
}
} catch (configErr) {
console.warn('Не удалось сохранить конфигурацию VM перед переустановкой:', configErr);
}
// Шаг 1: Остановить VM если запущена
const statusResponse = await fetchWithAuth(`/proxmox/api/${serverId}/${apiType}/${vmid}/status?node=${node}`);
if (statusResponse.ok) {
const status = await statusResponse.json();
if (status.status === 'running') {
await fetchWithAuth(`/proxmox/api/${serverId}/${apiType}/${vmid}/stop?node=${node}`, { method: 'POST' });
// Ждем остановки
await new Promise(resolve => setTimeout(resolve, 5000));
}
}
// Шаг 2: Удалить старую VM
const deleteResponse = await fetchWithAuth(`/proxmox/api/${serverId}/${apiType}/${vmid}?node=${node}&force=true`, {
method: 'DELETE'
});
if (!deleteResponse.ok) {
throw new Error('Не удалось удалить старую VM');
}
// Ждем удаления
await new Promise(resolve => setTimeout(resolve, 5000));
// Шаг 3: Развернуть новую VM из шаблона с тем же VMID и восстановленными параметрами
const deployResponse = await fetchWithAuth('/templates/api/deploy', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
template_id: parseInt(templateId),
vmid: parseInt(vmid),
name: name,
target_node: node,
...savedConfig
})
});
if (deployResponse.ok) {
showToast('success', t('success'), t('reinstall_started') || 'Переустановка запущена. VM будет доступна через несколько минут.');
closeReinstallModal();
setTimeout(() => loadResources(), 5000);
} else {
const error = await deployResponse.json();
throw new Error(error.detail || 'Ошибка развертывания');
}
} catch (error) {
console.error('Reinstall error:', error);
showToast('error', t('error'), `${t('reinstall_error')}: ${error.message}`);
}
}
</script>
{% endblock %}

View File

@@ -2251,7 +2251,11 @@ async function loadCreateVMForm() {
const content = document.getElementById('createVMContent');
try {
// Load templates for all servers FIRST
// Load all servers from API
const serversResponse = await fetchWithAuth('/proxmox/api/servers');
const allServersData = serversResponse.ok ? await serversResponse.json() : [];
// Load templates for all servers
const templatesResponse = await fetchWithAuth('/templates/api/templates');
const allTemplates = templatesResponse.ok ? await templatesResponse.json() : [];
@@ -2259,32 +2263,23 @@ async function loadCreateVMForm() {
const groupsResponse = await fetchWithAuth('/templates/api/groups');
const groups = groupsResponse.ok ? await groupsResponse.json() : [];
if (allTemplates.length === 0) {
if (allServersData.length === 0) {
content.innerHTML = `
<div style="text-align: center; padding: 2rem;">
<div style="font-size: 3rem; margin-bottom: 1rem; color: var(--text-muted);"><i class="fa-solid fa-box-open"></i></div>
<p style="color: var(--text-secondary);">{{ t('no_templates') }}</p>
<p style="color: var(--text-muted); font-size: 0.875rem; margin-top: 0.5rem;">
{{ t('go_to') }} <a href="/templates" style="color: var(--accent);">{{ t('nav_templates') }}</a> {{ t('and_add_templates') }}
</p>
<p style="color: var(--text-secondary);">{{ t('no_servers') }}</p>
<button onclick="closeCreateVMModal()" class="btn" style="margin-top: 1rem;">{{ t('close') }}</button>
</div>`;
return;
}
// Build servers map from templates (only servers with templates)
const serversMap = new Map();
allTemplates.forEach(t => {
if (!serversMap.has(t.server_id)) {
serversMap.set(t.server_id, {
id: t.server_id,
name: t.server_name || `Server ${t.server_id}`
});
}
});
const allServers = Array.from(serversMap.values());
// Build servers list from API (all servers, not just those with templates)
const allServers = allServersData.map(s => ({
id: s.id,
name: s.name || `Server ${s.id}`
}));
// Use first server with templates
// Use first server
createVMServerId = allServers[0].id;
// Filter templates for selected server
@@ -2340,7 +2335,7 @@ async function loadCreateVMForm() {
</div>
<div class="form-group">
<label>{{ t('target_node') }}</label>
<select id="vmNodeSelect" class="form-control">
<select id="vmNodeSelect" onchange="onVMNodeChange(this.value)" class="form-control">
<option value="">{{ t('auto') }}</option>
${nodeOptions}
</select>
@@ -2524,7 +2519,10 @@ async function loadStoragesForServer(serverId, preferredNode = null) {
const response = await fetchWithAuth(`/proxmox/api/${serverId}/storages?node=${node}`);
if (response.ok) {
const data = await response.json();
createVMStorages = (data.storages || []).filter(s => s.content && s.content.includes('images'));
const allStorages = data.storages || [];
// Show all active/available storages — Proxmox will reject incompatible ones on create
createVMStorages = allStorages.filter(s => s.active !== 0 && s.enabled !== 0);
if (createVMStorages.length === 0) createVMStorages = allStorages;
}
}
} catch (e) {
@@ -2532,6 +2530,17 @@ async function loadStoragesForServer(serverId, preferredNode = null) {
}
}
async function onVMNodeChange(nodeValue) {
const node = nodeValue || (createVMNodes.length > 0 ? createVMNodes[0].node : null);
await loadStoragesForServer(createVMServerId, node);
const storageSelect = document.getElementById('vmStorageSelect');
if (storageSelect) {
storageSelect.innerHTML = createVMStorages.length > 0
? createVMStorages.map((s, idx) => `<option value="${s.storage}" ${idx === 0 ? 'selected' : ''}>${s.storage}</option>`).join('')
: '<option value="local-lvm">local-lvm</option>';
}
}
function onVMTemplateChange() {
const select = document.getElementById('vmTemplateSelect');
const opt = select.options[select.selectedIndex];

View File

@@ -0,0 +1,485 @@
{% extends "base.html" %}
{% block title %}Workspaces{% endblock %}
{% block content %}
<div class="page-header-block">
<div class="page-header-left">
<nav class="breadcrumb-nav">Dashboard / Workspaces</nav>
<h1 class="page-header-title">
<svg style="width:1.25rem;height:1.25rem;vertical-align:-3px;margin-right:6px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="7" width="20" height="14" rx="2"/><path d="M16 7V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2"/></svg>
Workspaces
</h1>
</div>
<div class="page-header-right">
<button class="btn btn-success" onclick="showCreateModal()">
<i class="fa-solid fa-plus"></i> Создать Workspace
</button>
</div>
</div>
<!-- Workspace Cards -->
<div id="workspacesGrid" class="ws-grid">
<div class="loading-placeholder">
<i class="fa-solid fa-spinner fa-spin"></i> Загрузка...
</div>
</div>
<!-- ============================================================
Create / Edit Modal
============================================================ -->
<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>
<button class="modal-close" onclick="closeWsModal()">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label>Название <span style="color:var(--danger)">*</span></label>
<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>
</div>
<div class="form-group">
<label>Цвет</label>
<div style="display:flex;align-items:center;gap:10px;">
<input type="color" id="wsColor" value="#667eea" style="width:48px;height:36px;border:none;background:none;cursor:pointer;padding:0;">
<div class="ws-color-presets">
<button class="ws-color-preset" style="background:#667eea;" onclick="document.getElementById('wsColor').value='#667eea'"></button>
<button class="ws-color-preset" style="background:#10b981;" onclick="document.getElementById('wsColor').value='#10b981'"></button>
<button class="ws-color-preset" style="background:#f59e0b;" onclick="document.getElementById('wsColor').value='#f59e0b'"></button>
<button class="ws-color-preset" style="background:#ef4444;" onclick="document.getElementById('wsColor').value='#ef4444'"></button>
<button class="ws-color-preset" style="background:#8b5cf6;" onclick="document.getElementById('wsColor').value='#8b5cf6'"></button>
<button class="ws-color-preset" style="background:#06b6d4;" onclick="document.getElementById('wsColor').value='#06b6d4'"></button>
</div>
</div>
</div>
<div class="form-group" id="wsServersGroup">
<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>
<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>
</div>
</div>
</div>
<style>
.ws-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1.25rem;
margin-top: 0.5rem;
}
.ws-card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 1.25rem;
position: relative;
overflow: hidden;
transition: box-shadow 0.2s;
}
.ws-card:hover {
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
}
.ws-card-accent {
position: absolute;
top: 0; left: 0; right: 0;
height: 3px;
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
}
.ws-card-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 0.75rem;
}
.ws-card-dot {
width: 12px;
height: 12px;
border-radius: 50%;
flex-shrink: 0;
}
.ws-card-name {
font-size: 1rem;
font-weight: 700;
color: var(--text-primary);
flex: 1;
}
.ws-card-badge {
font-size: 0.68rem;
font-weight: 700;
padding: 2px 8px;
border-radius: var(--radius-full);
background: rgba(102,126,234,0.18);
color: #99aaff;
border: 1px solid rgba(102,126,234,0.3);
letter-spacing: 0.03em;
text-transform: uppercase;
}
.ws-card-desc {
font-size: 0.82rem;
color: var(--text-muted);
margin-bottom: 1rem;
min-height: 1.2em;
}
.ws-card-stats {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
}
.ws-stat {
display: flex;
align-items: center;
gap: 5px;
font-size: 0.8rem;
color: var(--text-secondary);
}
.ws-stat i { opacity: 0.6; }
.ws-card-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
}
.ws-assign-list {
max-height: 200px;
overflow-y: auto;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
background: var(--bg-body);
padding: 4px;
}
.ws-assign-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: var(--radius-sm);
cursor: pointer;
transition: background 0.15s;
font-size: 0.84rem;
color: var(--text-secondary);
}
.ws-assign-item:hover { background: var(--bg-hover); }
.ws-assign-item input[type=checkbox] { cursor: pointer; accent-color: var(--accent); }
.ws-color-presets {
display: flex;
gap: 6px;
}
.ws-color-preset {
width: 22px;
height: 22px;
border-radius: 50%;
border: 2px solid transparent;
cursor: pointer;
transition: transform 0.15s;
}
.ws-color-preset:hover {
transform: scale(1.25);
border-color: rgba(255,255,255,0.5);
}
.loading-placeholder {
color: var(--text-muted);
font-size: 0.875rem;
padding: 1rem;
text-align: center;
}
.ws-empty {
grid-column: 1/-1;
text-align: center;
color: var(--text-muted);
padding: 3rem;
font-size: 0.9rem;
}
</style>
<script>
let _allServers = [];
let _allUsers = [];
let _editingId = null; // null = create mode
// -----------------------------------------------------------------------
// Load & Render workspaces
// -----------------------------------------------------------------------
async function loadWorkspaces() {
const grid = document.getElementById('workspacesGrid');
try {
const resp = await fetchWithAuth('/api/workspaces');
if (!resp || !resp.ok) throw new Error('Failed');
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>';
return;
}
grid.innerHTML = workspaces.map(ws => `
<div class="ws-card" id="wsCard_${ws.id}">
<div class="ws-card-accent" style="background:${ws.color || '#667eea'};"></div>
<div class="ws-card-header">
<span class="ws-card-dot" style="background:${ws.color || '#667eea'};"></span>
<span class="ws-card-name">${escapeHtml(ws.name)}</span>
${ws.is_default ? '<span class="ws-card-badge">Default</span>' : ''}
</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>
</div>
<div class="ws-card-actions">
<button class="btn btn-sm btn-secondary" onclick="showEditModal(${ws.id})">
<i class="fa-solid fa-pen"></i>
</button>
${!ws.is_default ? `<button class="btn btn-sm btn-danger" onclick="deleteWorkspace(${ws.id}, '${escapeHtml(ws.name)}')">
<i class="fa-solid fa-trash"></i>
</button>` : ''}
</div>
</div>
`).join('');
} catch (e) {
grid.innerHTML = '<div class="ws-empty">Ошибка загрузки</div>';
}
}
// -----------------------------------------------------------------------
// Load servers + users for checkboxes
// -----------------------------------------------------------------------
async function loadAssignLists() {
try {
const [sResp, uResp] = await Promise.all([
fetchWithAuth('/proxmox/api/servers'),
fetchWithAuth('/admin/api/users')
]);
if (sResp && sResp.ok) _allServers = await sResp.json();
if (uResp && uResp.ok) {
const data = await uResp.json();
_allUsers = Array.isArray(data) ? data : (data.users || []);
}
} catch (e) { /* ignore */ }
}
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>';
return;
}
const selectedSet = new Set(selectedIds);
container.innerHTML = _allServers.map(s => `
<label class="ws-assign-item">
<input type="checkbox" value="${s.id}" ${selectedSet.has(s.id) ? 'checked' : ''}>
<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${s.is_online ? '#10b981' : '#ef4444'};margin-right:2px;"></span>
${escapeHtml(s.name)}
<small style="margin-left:auto;color:var(--text-muted)">${escapeHtml(s.ip_address || '')}</small>
</label>
`).join('');
}
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>';
return;
}
const selectedSet = new Set(selectedIds);
container.innerHTML = _allUsers.map(u => `
<label class="ws-assign-item">
<input type="checkbox" value="${u.id}" ${selectedSet.has(u.id) ? 'checked' : ''}>
<i class="fa-solid fa-user" style="opacity:0.5;font-size:.75rem;"></i>
${escapeHtml(u.username)}
<small style="margin-left:auto;color:var(--text-muted)">${escapeHtml(u.email || '')}</small>
</label>
`).join('');
}
// -----------------------------------------------------------------------
// Modal: Create
// -----------------------------------------------------------------------
async function showCreateModal() {
_editingId = null;
document.getElementById('wsModalTitle').textContent = 'Создать Workspace';
document.getElementById('wsName').value = '';
document.getElementById('wsDescription').value = '';
document.getElementById('wsColor').value = '#667eea';
await loadAssignLists();
renderServerCheckboxes([]);
renderUserCheckboxes([]);
document.getElementById('wsModal').style.display = 'flex';
}
// -----------------------------------------------------------------------
// Modal: Edit
// -----------------------------------------------------------------------
async function showEditModal(id) {
_editingId = id;
document.getElementById('wsModalTitle').textContent = 'Редактировать Workspace';
try {
const resp = await fetchWithAuth(`/api/workspaces/${id}`);
if (!resp || !resp.ok) throw new Error();
const ws = await resp.json();
document.getElementById('wsName').value = ws.name;
document.getElementById('wsDescription').value = ws.description || '';
document.getElementById('wsColor').value = ws.color || '#667eea';
await loadAssignLists();
const selServerIds = (ws.servers || []).map(s => s.id);
const selUserIds = (ws.users || []).map(u => u.id);
renderServerCheckboxes(selServerIds);
renderUserCheckboxes(selUserIds);
document.getElementById('wsModal').style.display = 'flex';
} catch (e) {
showAlertModal('Ошибка', 'Не удалось загрузить данные workspace');
}
}
function closeWsModal() {
document.getElementById('wsModal').style.display = 'none';
_editingId = null;
}
// -----------------------------------------------------------------------
// Save (create or update)
// -----------------------------------------------------------------------
async function saveWorkspace() {
const name = document.getElementById('wsName').value.trim();
if (!name) {
showAlertModal('Ошибка', 'Введите название workspace');
return;
}
const color = document.getElementById('wsColor').value || '#667eea';
const description = document.getElementById('wsDescription').value.trim() || null;
const serverIds = [...document.querySelectorAll('#wsServersList input[type=checkbox]:checked')].map(el => parseInt(el.value));
const userIds = [...document.querySelectorAll('#wsUsersList input[type=checkbox]:checked')].map(el => parseInt(el.value));
const btn = document.getElementById('wsModalSaveBtn');
btn.disabled = true;
btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i>';
try {
let resp;
if (_editingId === null) {
// Create
resp = await fetchWithAuth('/api/workspaces', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, description, color, server_ids: serverIds, user_ids: userIds })
});
} else {
// Update name/color/desc
resp = await fetchWithAuth(`/api/workspaces/${_editingId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, description, color })
});
if (resp && resp.ok) {
// Update servers
await fetchWithAuth(`/api/workspaces/${_editingId}/servers`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: serverIds })
});
// Update users
await fetchWithAuth(`/api/workspaces/${_editingId}/users`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: userIds })
});
}
}
if (resp && resp.ok) {
closeWsModal();
await loadWorkspaces();
// Refresh sidebar workspace list
if (typeof loadSidebarWorkspace === 'function') loadSidebarWorkspace();
} else {
const err = await resp.json().catch(() => ({}));
showAlertModal('Ошибка', err.detail || 'Не удалось сохранить');
}
} catch (e) {
showAlertModal('Ошибка', e.message || 'Произошла ошибка');
} finally {
btn.disabled = false;
btn.innerHTML = 'Сохранить';
}
}
// -----------------------------------------------------------------------
// Delete
// -----------------------------------------------------------------------
async function deleteWorkspace(id, name) {
showConfirmModal(
'Удалить Workspace',
`Удалить workspace «${name}»? Серверы и пользователи не будут удалены.`,
async () => {
const resp = await fetchWithAuth(`/api/workspaces/${id}`, { method: 'DELETE' });
if (resp && (resp.ok || resp.status === 204)) {
await loadWorkspaces();
if (typeof loadSidebarWorkspace === 'function') loadSidebarWorkspace();
} else {
const err = await resp.json().catch(() => ({}));
showAlertModal('Ошибка', err.detail || 'Не удалось удалить');
}
}
);
}
// -----------------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------------
function escapeHtml(str) {
if (!str) return '';
return str.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// -----------------------------------------------------------------------
// Init
// -----------------------------------------------------------------------
document.addEventListener('DOMContentLoaded', () => {
loadWorkspaces();
});
</script>
{% endblock %}

View File

@@ -1117,6 +1117,70 @@ def migrate_proxmox_tasks(conn):
logger.info("✓ proxmox_tasks table created")
def migrate_workspaces(conn):
"""Migration 19: Create workspaces, workspace_servers, workspace_users tables."""
logger.info("Migration 19: Creating workspaces tables...")
if table_exists(conn, 'workspaces'):
logger.info("Table workspaces already exists, skipping")
return
# Create workspaces table
conn.execute(text("""
CREATE TABLE workspaces (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE,
description TEXT,
color VARCHAR(20) DEFAULT '#667eea',
is_default BOOLEAN NOT NULL DEFAULT FALSE,
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL
)
"""))
conn.execute(text("CREATE INDEX idx_workspaces_name ON workspaces(name)"))
# Create workspace_servers junction table
conn.execute(text("""
CREATE TABLE workspace_servers (
id SERIAL PRIMARY KEY,
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
server_id INTEGER NOT NULL REFERENCES proxmox_servers(id) ON DELETE CASCADE,
UNIQUE(workspace_id, server_id)
)
"""))
conn.execute(text("CREATE INDEX idx_ws_servers_workspace ON workspace_servers(workspace_id)"))
conn.execute(text("CREATE INDEX idx_ws_servers_server ON workspace_servers(server_id)"))
# Create workspace_users junction table
conn.execute(text("""
CREATE TABLE workspace_users (
id SERIAL PRIMARY KEY,
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
UNIQUE(workspace_id, user_id)
)
"""))
conn.execute(text("CREATE INDEX idx_ws_users_workspace ON workspace_users(workspace_id)"))
conn.execute(text("CREATE INDEX idx_ws_users_user ON workspace_users(user_id)"))
# Insert default workspace containing all existing servers
conn.execute(text("""
INSERT INTO workspaces (name, description, color, is_default)
VALUES ('Default', 'Default workspace containing all servers', '#667eea', TRUE)
"""))
# Add all existing servers to the default workspace
conn.execute(text("""
INSERT INTO workspace_servers (workspace_id, server_id)
SELECT w.id, s.id
FROM workspaces w, proxmox_servers s
WHERE w.is_default = TRUE
"""))
logger.info("✓ workspaces tables created, Default workspace populated")
def run_all_migrations(engine, db_session=None):
"""
Run all migrations in order.
@@ -1275,6 +1339,14 @@ def run_all_migrations(engine, db_session=None):
logger.warning(f"Proxmox tasks migration: {e}")
conn.rollback()
# Migration 19: Workspaces
try:
migrate_workspaces(conn)
conn.commit()
except Exception as e:
logger.warning(f"Workspaces migration: {e}")
conn.rollback()
logger.info("=" * 50)
logger.info("✓ All migrations completed successfully")
logger.info("=" * 50)