feat: add Workspace feature — scoped server groups with user access and sidebar selector

This commit is contained in:
markmorado
2026-03-15 01:00:24 +01:00
parent 85cc0ce76d
commit bef0bcc542
18 changed files with 1675 additions and 77 deletions

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 %}