feat: add Workspace feature — scoped server groups with user access and sidebar selector
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user