Compare commits
2 Commits
85cc0ce76d
...
c4778d2972
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4778d2972 | ||
|
|
bef0bcc542 |
20
CHANGELOG.md
20
CHANGELOG.md
@@ -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
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
**Modern web panel for managing Proxmox servers, virtual machines and LXC containers**
|
||||
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
[](LICENSE)
|
||||
[](compose.yml)
|
||||
[](backend/)
|
||||
|
||||
58
WIKI.md
58
WIKI.md
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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,
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
431
backend/app/api/workspaces.py
Normal file
431
backend/app/api/workspaces.py
Normal 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)
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
@@ -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
|
||||
============================================ */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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];
|
||||
|
||||
485
backend/app/templates/workspaces.html
Normal file
485
backend/app/templates/workspaces.html
Normal 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()">×</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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Init
|
||||
// -----------------------------------------------------------------------
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadWorkspaces();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user