- Migration 20: user_servers association table (User ↔ ProxmoxServer)
- API: GET/PUT /api/users/{id}/servers — assign servers to users with workspace-conflict validation
- API: GET /api/users/{id}/server-assignments — enriched server list (assigned + compatible flags)
- Non-admin server filter: intersection of assigned_servers and workspace servers
- Migration 21: dashboard:view = false for user role (new key format)
- Migration 22: proxmox.cluster.manage → cluster:manage for admin role
- API: GET/PUT /api/{server_id}/vm/{vmid}/owner — VM/LXC owner assignment (admin-only)
- Login: smart redirect based on user permissions after authentication
- Login: returnUrl param support for post-login redirect
- Roles API: user_count field per role in GET /api/roles
- node_uptime added to server detail API response
- Remove redundant PermissionChecker from GET /users HTML route
2287 lines
100 KiB
HTML
2287 lines
100 KiB
HTML
<!doctype html>
|
|
<html lang="{{ lang }}" data-theme="light">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>{{ title or t('app_name') }}</title>
|
|
<link rel="icon" type="image/x-icon" href="/static/img/favicon.ico">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=5, viewport-fit=cover">
|
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
|
<meta name="mobile-web-app-capable" content="yes">
|
|
<meta name="theme-color" content="#0f172a" media="(prefers-color-scheme: dark)">
|
|
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
|
|
|
|
<!-- Google Fonts - Inter -->
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
|
|
|
<!-- Font Awesome Icons -->
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA==" crossorigin="anonymous" referrerpolicy="no-referrer" />
|
|
|
|
<!-- Bootstrap 5 CSS -->
|
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous">
|
|
|
|
<!-- Custom Theme CSS -->
|
|
<link rel="stylesheet" href="/static/css/theme.css">
|
|
|
|
<!-- Theme Switcher (loads early to prevent flash) -->
|
|
<script src="/static/js/theme.js"></script>
|
|
|
|
<!-- Chart.js -->
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
|
|
|
<!-- noVNC for VNC console - ES6 module from esm.sh -->
|
|
<script type="module">
|
|
try {
|
|
// Import from esm.sh which handles ES6 modules properly
|
|
const { default: RFB } = await import('https://esm.sh/@novnc/novnc@1.4.0/lib/rfb.js');
|
|
window.RFB = RFB;
|
|
window.RFBReady = true;
|
|
window.dispatchEvent(new CustomEvent('RFBReady', { detail: RFB }));
|
|
} catch (err) {
|
|
console.error('Failed to load noVNC:', err);
|
|
}
|
|
</script>
|
|
|
|
<!-- xterm.js for Terminal console -->
|
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css">
|
|
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.min.js"></script>
|
|
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.min.js"></script>
|
|
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-web-links@0.9.0/lib/xterm-addon-web-links.min.js"></script>
|
|
<script>
|
|
window.xtermReady = true;
|
|
</script>
|
|
<script>
|
|
// Helper function to wait for RFB to be ready
|
|
window.waitForRFB = function() {
|
|
return new Promise(function(resolve) {
|
|
if (window.RFBReady && window.RFB) {
|
|
resolve(window.RFB);
|
|
} else {
|
|
window.addEventListener('RFBReady', function(e) {
|
|
resolve(e.detail);
|
|
}, { once: true });
|
|
}
|
|
});
|
|
};
|
|
|
|
// Debug mode flag - set to true to enable console.log messages
|
|
window.DEBUG_MODE = false;
|
|
|
|
// Debug logger - only logs if DEBUG_MODE is true
|
|
window.debugLog = function() {
|
|
if (window.DEBUG_MODE) {
|
|
console.log.apply(console, arguments);
|
|
}
|
|
};
|
|
|
|
// Global translations object - available immediately for all scripts
|
|
window.translations = {{ translations | tojson | safe }};
|
|
|
|
// Global timezone from server
|
|
window.serverTimezone = '{{ timezone | default("UTC") }}';
|
|
|
|
// Translation helper function - available globally
|
|
window.t = function(key) {
|
|
return window.translations[key] || key;
|
|
};
|
|
|
|
// Date formatting helper with server timezone
|
|
window.formatDateTime = function(dateStr) {
|
|
if (!dateStr) return '-';
|
|
try {
|
|
const date = new Date(dateStr);
|
|
return date.toLocaleString(window.currentLang === 'en' ? 'en-US' : 'ru-RU', {
|
|
timeZone: window.serverTimezone,
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
second: '2-digit'
|
|
});
|
|
} catch (e) {
|
|
return dateStr;
|
|
}
|
|
};
|
|
|
|
window.formatDate = function(dateStr) {
|
|
if (!dateStr) return '-';
|
|
try {
|
|
const date = new Date(dateStr);
|
|
return date.toLocaleDateString(window.currentLang === 'en' ? 'en-US' : 'ru-RU', {
|
|
timeZone: window.serverTimezone
|
|
});
|
|
} catch (e) {
|
|
return dateStr;
|
|
}
|
|
};
|
|
|
|
// Global fetchWithAuth function - available to all scripts
|
|
// Options:
|
|
// hideOnForbidden: if true, returns null instead of throwing on 403 (for graceful UI hiding)
|
|
window.fetchWithAuth = async function(url, options = {}) {
|
|
const token = localStorage.getItem('access_token');
|
|
|
|
if (!token) {
|
|
const currentUrl = encodeURIComponent(window.location.pathname + window.location.search);
|
|
window.location.href = `/login?returnUrl=${currentUrl}`;
|
|
throw new Error('No authentication token');
|
|
}
|
|
|
|
const headers = {
|
|
'Authorization': `Bearer ${token}`,
|
|
...options.headers
|
|
};
|
|
|
|
// Set default Content-Type only if not specified
|
|
if (!headers['Content-Type'] && !headers['content-type']) {
|
|
headers['Content-Type'] = 'application/json';
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(url, { ...options, headers });
|
|
|
|
if (response.status === 401) {
|
|
localStorage.removeItem('access_token');
|
|
const currentUrl = encodeURIComponent(window.location.pathname + window.location.search);
|
|
window.location.href = `/login?returnUrl=${currentUrl}`;
|
|
throw new Error('Unauthorized');
|
|
}
|
|
|
|
// Handle 403 Forbidden - permission denied
|
|
if (response.status === 403) {
|
|
if (options.hideOnForbidden) {
|
|
// Return special response to allow caller to hide UI
|
|
return { ok: false, status: 403, forbidden: true };
|
|
}
|
|
// Don't throw, just return response for caller to handle
|
|
return response;
|
|
}
|
|
|
|
return response;
|
|
} catch (error) {
|
|
console.error('Fetch error:', error.message);
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
// Also define as local function for scripts that don't use window prefix
|
|
var fetchWithAuth = window.fetchWithAuth;
|
|
|
|
// ==================== Auto token refresh ====================
|
|
// Decodes JWT payload (base64url) without verifying signature
|
|
function _jwtPayload(token) {
|
|
try {
|
|
const base64 = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
|
|
return JSON.parse(atob(base64));
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Refresh the JWT token silently; returns true on success
|
|
async function _refreshToken() {
|
|
const token = localStorage.getItem('access_token');
|
|
if (!token) return false;
|
|
try {
|
|
const resp = await fetch('/api/auth/refresh', {
|
|
method: 'POST',
|
|
headers: { 'Authorization': `Bearer ${token}` }
|
|
});
|
|
if (resp.ok) {
|
|
const data = await resp.json();
|
|
localStorage.setItem('access_token', data.access_token);
|
|
_scheduleTokenRefresh(); // reschedule with new expiry
|
|
return true;
|
|
}
|
|
} catch (e) { /* network error */ }
|
|
return false;
|
|
}
|
|
|
|
// Schedule a proactive refresh 2 minutes before JWT expiry
|
|
function _scheduleTokenRefresh() {
|
|
if (window._tokenRefreshTimer) clearTimeout(window._tokenRefreshTimer);
|
|
const token = localStorage.getItem('access_token');
|
|
if (!token) return;
|
|
const payload = _jwtPayload(token);
|
|
if (!payload || !payload.exp) return;
|
|
const nowSec = Math.floor(Date.now() / 1000);
|
|
const msUntilRefresh = (payload.exp - nowSec - 120) * 1000; // 2 min before expiry
|
|
if (msUntilRefresh <= 0) {
|
|
// Token already expired or about to expire - try immediately
|
|
_refreshToken();
|
|
return;
|
|
}
|
|
window._tokenRefreshTimer = setTimeout(_refreshToken, msUntilRefresh);
|
|
}
|
|
|
|
// Kick off the scheduler on every page load (except login)
|
|
if (window.location.pathname !== '/login') {
|
|
_scheduleTokenRefresh();
|
|
}
|
|
// ==================== End auto token refresh ====================
|
|
|
|
|
|
document.addEventListener('keydown', function(event) {
|
|
if (event.key === 'Escape') {
|
|
// Close change password modal if open
|
|
const cpOverlay = document.getElementById('changePasswordOverlay');
|
|
if (cpOverlay && cpOverlay.classList.contains('show')) {
|
|
closeChangePasswordModal();
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
|
|
// Find all visible modals and close the topmost one
|
|
const modals = document.querySelectorAll('[id$="Modal"], [id$="modal"]');
|
|
let closedAny = false;
|
|
let highestZIndex = 0;
|
|
let topModal = null;
|
|
|
|
modals.forEach(function(modal) {
|
|
const style = window.getComputedStyle(modal);
|
|
if (style.display !== 'none' && style.visibility !== 'hidden') {
|
|
const zIndex = parseInt(style.zIndex) || 0;
|
|
if (zIndex >= highestZIndex) {
|
|
highestZIndex = zIndex;
|
|
topModal = modal;
|
|
}
|
|
}
|
|
});
|
|
|
|
if (topModal) {
|
|
// Try to find and click the close button
|
|
const closeBtn = topModal.querySelector('button[onclick*="close"], button[onclick*="Close"]');
|
|
if (closeBtn) {
|
|
closeBtn.click();
|
|
closedAny = true;
|
|
} else {
|
|
// If no close button, just hide the modal
|
|
topModal.style.display = 'none';
|
|
closedAny = true;
|
|
}
|
|
}
|
|
|
|
if (closedAny) {
|
|
event.preventDefault();
|
|
}
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<style>
|
|
/* Minimal overrides - main styles in /static/css/theme.css */
|
|
|
|
/* Tab pane fix for Bootstrap */
|
|
.tab-pane {
|
|
display: none;
|
|
}
|
|
.tab-pane.show.active {
|
|
display: block;
|
|
}
|
|
|
|
/* Material icons sizing */
|
|
.material-icon {
|
|
width: 20px;
|
|
height: 20px;
|
|
fill: currentColor;
|
|
stroke: none;
|
|
display: inline-block;
|
|
vertical-align: middle;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.material-icon-lg {
|
|
width: 24px;
|
|
height: 24px;
|
|
}
|
|
|
|
/* Legacy variable mappings for compatibility */
|
|
:root {
|
|
--bg-primary: var(--bg-body);
|
|
--bg-secondary: var(--bg-surface);
|
|
--bg-tertiary: var(--bg-surface-elevated);
|
|
--bg-card: var(--bg-surface);
|
|
--bg-hover: var(--bg-surface-hover);
|
|
--border-color: var(--border-default);
|
|
--accent: var(--primary);
|
|
--accent-gradient: linear-gradient(135deg, #0066FF 0%, #0052CC 100%);
|
|
--success-gradient: linear-gradient(135deg, #00C853 0%, #00A844 100%);
|
|
--warning-gradient: linear-gradient(135deg, #FF9800 0%, #F57C00 100%);
|
|
--transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
|
--transition-slow: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
}
|
|
|
|
/* Hide sidebar nav items until permissions are resolved (prevents flash of admin-only items) */
|
|
.sidebar-nav { visibility: hidden; }
|
|
.sidebar-nav.nav-ready { visibility: visible; }
|
|
|
|
/* Custom Dialog Styles */
|
|
.custom-dialog-overlay {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background: rgba(0, 0, 0, 0.7);
|
|
backdrop-filter: blur(4px);
|
|
z-index: 10000;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
opacity: 0;
|
|
visibility: hidden;
|
|
transition: all 0.2s ease;
|
|
}
|
|
|
|
.custom-dialog-overlay.show {
|
|
opacity: 1;
|
|
visibility: visible;
|
|
}
|
|
|
|
.custom-dialog {
|
|
background: var(--bg-surface);
|
|
border: 1px solid var(--border-default);
|
|
border-radius: 20px;
|
|
padding: 32px 28px;
|
|
max-width: 440px;
|
|
width: 90%;
|
|
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
|
|
transform: scale(0.9) translateY(-20px);
|
|
transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
|
}
|
|
|
|
.custom-dialog-overlay.show .custom-dialog {
|
|
transform: scale(1) translateY(0);
|
|
}
|
|
|
|
.custom-dialog-icon {
|
|
width: 64px;
|
|
height: 64px;
|
|
border-radius: 50%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
margin: 0 auto 20px;
|
|
font-size: 28px;
|
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
|
}
|
|
|
|
.custom-dialog-icon.info {
|
|
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
|
|
}
|
|
|
|
.custom-dialog-icon.success {
|
|
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
|
}
|
|
|
|
.custom-dialog-icon.warning {
|
|
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
|
|
}
|
|
|
|
.custom-dialog-icon.danger {
|
|
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
|
|
}
|
|
|
|
.custom-dialog-icon.question {
|
|
background: linear-gradient(135deg, #0ea5e9 0%, #0284c7 100%);
|
|
}
|
|
|
|
.custom-dialog-title {
|
|
font-size: 1.35rem;
|
|
font-weight: 600;
|
|
color: var(--text-primary);
|
|
text-align: center;
|
|
margin-bottom: 12px;
|
|
line-height: 1.4;
|
|
}
|
|
|
|
.custom-dialog-message {
|
|
font-size: 1rem;
|
|
color: var(--text-secondary);
|
|
text-align: center;
|
|
margin-bottom: 28px;
|
|
line-height: 1.6;
|
|
}
|
|
|
|
.custom-dialog-input-container {
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.custom-dialog-input {
|
|
width: 100%;
|
|
padding: 12px 16px;
|
|
border-radius: 8px;
|
|
border: 2px solid var(--border-color);
|
|
background: var(--bg-input);
|
|
color: var(--text-primary);
|
|
font-size: 1rem;
|
|
text-align: center;
|
|
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
|
}
|
|
|
|
.custom-dialog-input:focus {
|
|
outline: none;
|
|
border-color: var(--primary);
|
|
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2);
|
|
}
|
|
|
|
.custom-dialog-input.error {
|
|
border-color: var(--danger);
|
|
animation: shake 0.3s ease-in-out;
|
|
}
|
|
|
|
@keyframes shake {
|
|
0%, 100% { transform: translateX(0); }
|
|
25% { transform: translateX(-5px); }
|
|
75% { transform: translateX(5px); }
|
|
}
|
|
|
|
.custom-dialog-buttons {
|
|
display: flex;
|
|
gap: 12px;
|
|
justify-content: center;
|
|
}
|
|
|
|
.custom-dialog-btn {
|
|
padding: 12px 28px;
|
|
border-radius: 10px;
|
|
font-weight: 600;
|
|
font-size: 0.95rem;
|
|
cursor: pointer;
|
|
transition: all 0.2s ease;
|
|
border: none;
|
|
min-width: 110px;
|
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
|
}
|
|
|
|
.custom-dialog-btn.primary {
|
|
background: #0ea5e9;
|
|
color: #ffffff !important;
|
|
box-shadow: 0 2px 8px rgba(14, 165, 233, 0.3);
|
|
}
|
|
|
|
.custom-dialog-btn.primary:hover {
|
|
transform: translateY(-1px);
|
|
background: #0284c7;
|
|
box-shadow: 0 4px 12px rgba(14, 165, 233, 0.4);
|
|
}
|
|
|
|
.custom-dialog-btn.secondary {
|
|
background: var(--bg-tertiary);
|
|
color: var(--text-primary);
|
|
border: 1.5px solid var(--border-default);
|
|
text-shadow: none;
|
|
}
|
|
|
|
.custom-dialog-btn.secondary:hover {
|
|
background: var(--bg-hover);
|
|
border-color: var(--border-color);
|
|
}
|
|
|
|
.custom-dialog-btn.danger {
|
|
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
|
|
color: white;
|
|
}
|
|
|
|
.custom-dialog-btn.danger:hover {
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.4);
|
|
}
|
|
|
|
.custom-dialog-btn.success {
|
|
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
|
color: white;
|
|
}
|
|
|
|
.custom-dialog-btn.success:hover {
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.4);
|
|
}
|
|
|
|
/* ---- User Menu ---- */
|
|
.user-menu-wrapper {
|
|
position: relative;
|
|
}
|
|
|
|
.user-menu-btn {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
background: none;
|
|
border: 1px solid var(--border-default);
|
|
border-radius: 8px;
|
|
color: var(--text-primary);
|
|
cursor: pointer;
|
|
padding: 5px 10px;
|
|
font-size: 0.85rem;
|
|
font-weight: 500;
|
|
transition: background 0.2s;
|
|
white-space: nowrap;
|
|
max-width: 160px;
|
|
}
|
|
|
|
.user-menu-btn:hover {
|
|
background: var(--bg-surface-hover);
|
|
}
|
|
|
|
.user-menu-btn .user-menu-name {
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
max-width: 100px;
|
|
}
|
|
|
|
.user-menu-dropdown {
|
|
position: absolute;
|
|
right: 0;
|
|
top: calc(100% + 6px);
|
|
min-width: 200px;
|
|
background: var(--bg-surface);
|
|
border: 1px solid var(--border-default);
|
|
border-radius: 12px;
|
|
box-shadow: 0 8px 32px rgba(0,0,0,0.35);
|
|
z-index: 5000;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.user-menu-header {
|
|
padding: 12px 16px 10px;
|
|
border-bottom: 1px solid var(--border-default);
|
|
}
|
|
|
|
.user-menu-header-username {
|
|
font-weight: 600;
|
|
font-size: 0.9rem;
|
|
color: var(--text-primary);
|
|
display: block;
|
|
}
|
|
|
|
.user-menu-header-role {
|
|
font-size: 0.75rem;
|
|
color: var(--text-muted);
|
|
display: block;
|
|
margin-top: 2px;
|
|
}
|
|
|
|
.user-menu-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
width: 100%;
|
|
background: none;
|
|
border: none;
|
|
padding: 10px 16px;
|
|
font-size: 0.875rem;
|
|
color: var(--text-secondary);
|
|
cursor: pointer;
|
|
text-align: left;
|
|
transition: background 0.15s, color 0.15s;
|
|
}
|
|
|
|
.user-menu-item:hover {
|
|
background: var(--bg-surface-hover);
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.user-menu-item.danger:hover {
|
|
color: var(--danger, #ef4444);
|
|
}
|
|
|
|
.user-menu-divider {
|
|
height: 1px;
|
|
background: var(--border-default);
|
|
margin: 3px 0;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body data-is-admin="false">
|
|
<!-- Update Banner (shown during system update) -->
|
|
<div id="update-banner" class="update-banner" style="display: none;">
|
|
<div class="update-banner-content">
|
|
<i class="fas fa-sync fa-spin"></i>
|
|
<span id="update-banner-text">{{ t('updating_system') }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<style>
|
|
.update-banner {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
z-index: 10000;
|
|
background: linear-gradient(135deg, #f97316 0%, #ea580c 100%);
|
|
color: white;
|
|
padding: 12px 20px;
|
|
text-align: center;
|
|
font-weight: 600;
|
|
box-shadow: 0 4px 20px rgba(249, 115, 22, 0.4);
|
|
animation: pulse-banner 2s infinite;
|
|
}
|
|
|
|
@keyframes pulse-banner {
|
|
0%, 100% { opacity: 1; }
|
|
50% { opacity: 0.85; }
|
|
}
|
|
|
|
.update-banner-content {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 10px;
|
|
}
|
|
|
|
.update-banner i {
|
|
font-size: 1.1rem;
|
|
}
|
|
|
|
/* Offset content when banner is shown */
|
|
body.update-in-progress .layout {
|
|
margin-top: 50px;
|
|
}
|
|
</style>
|
|
|
|
<script>
|
|
// Check for update in progress on page load
|
|
(function() {
|
|
const updateInProgress = localStorage.getItem('pve_update_in_progress');
|
|
const updateStarted = localStorage.getItem('pve_update_started');
|
|
|
|
if (updateInProgress === 'true') {
|
|
// Check if update has been running for more than 5 minutes (timeout)
|
|
const startTime = parseInt(updateStarted || '0');
|
|
const now = Date.now();
|
|
const fiveMinutes = 5 * 60 * 1000;
|
|
|
|
if (now - startTime > fiveMinutes) {
|
|
// Update timeout, clear the flag
|
|
localStorage.removeItem('pve_update_in_progress');
|
|
localStorage.removeItem('pve_update_started');
|
|
} else {
|
|
// Show update banner
|
|
document.body.classList.add('update-in-progress');
|
|
const banner = document.getElementById('update-banner');
|
|
if (banner) {
|
|
banner.style.display = 'block';
|
|
|
|
// Poll for server to come back
|
|
pollServerRestart();
|
|
}
|
|
}
|
|
}
|
|
|
|
function pollServerRestart() {
|
|
fetch('/settings/api/version', { method: 'GET', cache: 'no-cache' })
|
|
.then(response => {
|
|
if (response.ok) {
|
|
// Server is back, reload!
|
|
localStorage.removeItem('pve_update_in_progress');
|
|
localStorage.removeItem('pve_update_started');
|
|
window.location.reload();
|
|
} else {
|
|
setTimeout(pollServerRestart, 2000);
|
|
}
|
|
})
|
|
.catch(() => {
|
|
// Still updating, check again
|
|
setTimeout(pollServerRestart, 2000);
|
|
});
|
|
}
|
|
})();
|
|
</script>
|
|
|
|
<div class="layout">
|
|
<!-- Sidebar Overlay for Mobile -->
|
|
<div class="sidebar-overlay" id="sidebarOverlay" onclick="toggleSidebar()"></div>
|
|
|
|
<aside class="sidebar" id="sidebar">
|
|
<div class="sidebar-logo">
|
|
{% if custom_logo_exists %}
|
|
<img src="/settings/api/logo.png" alt="{{ panel_name }}" class="sidebar-logo-img" style="height: 105px; width: auto; object-fit: contain;">
|
|
{% else %}
|
|
<span style="font-size: 2rem;"><i class="fa-solid fa-cloud"></i></span>
|
|
{% endif %}
|
|
</div>
|
|
<!-- System Status -->
|
|
{% block sidebar_status %}{% endblock %}
|
|
<!-- Workspace Selector -->
|
|
<div class="sidebar-workspace-section" id="sidebarWorkspaceSection">
|
|
<div class="sidebar-workspace-label">Workspace</div>
|
|
<button class="workspace-selector-btn" id="sidebarWorkspaceBtn" title="Active workspace" onclick="toggleWorkspaceDropdown(event)">
|
|
<span class="workspace-color-dot" id="sidebarWorkspaceColorDot" style="background:#0ea5e9;"></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">
|
|
<li data-nav-permission="dashboard.view"><a href="/" class="{{ 'active' if request.path == '/' or request.path == '/dashboard' else '' }}" title="{{ t('nav_dashboard') }}">
|
|
<svg class="material-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"></rect><rect x="14" y="3" width="7" height="7"></rect><rect x="14" y="14" width="7" height="7"></rect><rect x="3" y="14" width="7" height="7"></rect></svg>
|
|
<span>{{ t('nav_dashboard') }}</span>
|
|
</a></li>
|
|
<li data-nav-permission="vms.view"><a href="/virtual-machines" class="{{ 'active' if request.path.startswith('/virtual-machines') or request.path.startswith('/proxmox/server') else '' }}" title="{{ t('nav_vms') }}">
|
|
<svg class="material-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect><line x1="8" y1="21" x2="16" y2="21"></line><line x1="12" y1="17" x2="12" y2="21"></line></svg>
|
|
<span>{{ t('nav_vms') }}</span>
|
|
</a></li>
|
|
<li data-nav-permission="proxmox.view"><a href="/vms" class="{{ 'active' if request.path == '/vms' or (request.path.startswith('/vms/') or (request.path.startswith('/proxmox/') and not request.path.startswith('/proxmox/server') and not request.path.startswith('/proxmox/api/backups'))) else '' }}" title="{{ t('nav_proxmox') }}">
|
|
<svg class="material-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2" ry="2"></rect><rect x="9" y="9" width="6" height="6"></rect><line x1="9" y1="1" x2="9" y2="4"></line><line x1="15" y1="1" x2="15" y2="4"></line><line x1="9" y1="20" x2="9" y2="23"></line><line x1="15" y1="20" x2="15" y2="23"></line><line x1="20" y1="9" x2="23" y2="9"></line><line x1="20" y1="14" x2="23" y2="14"></line><line x1="1" y1="9" x2="4" y2="9"></line><line x1="1" y1="14" x2="4" y2="14"></line></svg>
|
|
<span>{{ t('nav_proxmox') }}</span>
|
|
</a></li>
|
|
<li data-nav-permission="templates.view"><a href="/templates" class="{{ 'active' if request.path.startswith('/templates') else '' }}" title="{{ t('nav_templates') }}">
|
|
<svg class="material-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line><polyline points="10 9 9 9 8 9"></polyline></svg>
|
|
<span>{{ t('nav_templates') }}</span>
|
|
</a></li>
|
|
<li data-nav-permission="backups.view"><a href="/backups" class="{{ 'active' if request.path.startswith('/backups') or request.path.startswith('/proxmox/api/backups/page') else '' }}" title="{{ t('nav_backups') }}">
|
|
<svg class="material-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
|
|
<span>{{ t('nav_backups') }}</span>
|
|
</a></li>
|
|
<li data-nav-permission="ipam.view"><a href="/ipam" class="{{ 'active' if request.path.startswith('/ipam') else '' }}" title="{{ t('nav_ipam') }}">
|
|
<svg class="material-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="2" y1="12" x2="22" y2="12"></line><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path></svg>
|
|
<span>{{ t('nav_ipam') }}</span>
|
|
</a></li>
|
|
<li data-nav-permission="docker.view"><a href="/containers" class="{{ 'active' if request.path.startswith('/containers') else '' }}" title="{{ t('nav_docker') }}">
|
|
<i class="fa-brands fa-docker" style="font-size: 1.25rem; width: 24px; text-align: center;"></i>
|
|
<span>{{ t('nav_docker') }}</span>
|
|
</a></li>
|
|
<li data-nav-permission="logs.view"><a href="/logs" class="{{ 'active' if request.path.startswith('/logs') else '' }}" title="{{ t('nav_logs') }}">
|
|
<svg class="material-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line><line x1="10" y1="9" x2="8" y2="9"></line></svg>
|
|
<span>{{ t('nav_logs') }}</span>
|
|
</a></li>
|
|
<li><a href="/tasks" class="{{ 'active' if request.path.startswith('/tasks') else '' }}" title="{{ t('task_manager') }}">
|
|
<svg class="material-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"></path><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"></path></svg>
|
|
<span>{{ t('nav_tasks') }}</span>
|
|
<span id="sidebarTasksBadge" style="display:none; background:#0ea5e9; color:#fff; font-size:.62rem; font-weight:700; border-radius:9999px; padding:1px 6px; margin-left:auto; line-height:1.5;"></span>
|
|
</a></li>
|
|
<li data-nav-permission="settings.view"><a href="/settings" class="{{ 'active' if request.path.startswith('/settings') else '' }}" title="{{ t('nav_settings') }}">
|
|
<svg class="material-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
|
|
<span>{{ t('nav_settings') }}</span>
|
|
</a></li>
|
|
<li data-nav-permission="users.view"><a href="/admin/users" class="{{ 'active' if request.path.startswith('/admin/users') else '' }}" title="{{ t('nav_users') }}">
|
|
<svg class="material-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>
|
|
<span>{{ t('nav_users') }}</span>
|
|
</a></li>
|
|
<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>
|
|
<span>{{ t('nav_logout') }}</span>
|
|
</a>
|
|
</li>
|
|
</ul>
|
|
</nav>
|
|
<div class="sidebar-footer">
|
|
<button class="sidebar-toggle" id="sidebarToggle" onclick="toggleSidebarCollapse()" title="{{ t('collapse_sidebar') }}">
|
|
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2">
|
|
<polyline points="15 18 9 12 15 6"></polyline>
|
|
</svg>
|
|
<span>{{ t('collapse_sidebar') }}</span>
|
|
</button>
|
|
</div>
|
|
</aside>
|
|
<div class="content-wrapper">
|
|
<header>
|
|
<!-- Mobile Menu Toggle -->
|
|
<button class="menu-toggle" id="menuToggle" onclick="toggleSidebar()">
|
|
<svg viewBox="0 0 24 24">
|
|
<line x1="3" y1="6" x2="21" y2="6"></line>
|
|
<line x1="3" y1="12" x2="21" y2="12"></line>
|
|
<line x1="3" y1="18" x2="21" y2="18"></line>
|
|
</svg>
|
|
</button>
|
|
<div class="header-title-block">
|
|
<h2>{{ page_title or title or t('nav_dashboard') }}</h2>
|
|
{% block page_subtitle %}<div class="page-subtitle" id="pageGlobalSubtitle"></div>{% endblock %}
|
|
</div>
|
|
<div class="header-actions">
|
|
|
|
<!-- Theme Toggle Button -->
|
|
<button id="themeToggle" class="theme-toggle" title="{{ t('theme') }}">
|
|
<svg class="icon-sun" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<circle cx="12" cy="12" r="5"></circle>
|
|
<line x1="12" y1="1" x2="12" y2="3"></line>
|
|
<line x1="12" y1="21" x2="12" y2="23"></line>
|
|
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
|
|
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
|
|
<line x1="1" y1="12" x2="3" y2="12"></line>
|
|
<line x1="21" y1="12" x2="23" y2="12"></line>
|
|
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
|
|
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
|
|
</svg>
|
|
<svg class="icon-moon" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display: none;">
|
|
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
|
|
</svg>
|
|
</button>
|
|
|
|
<!-- Task Manager Button -->
|
|
<button id="tasksBtn" class="tasks-btn" onclick="toggleTaskDrawer()" title="{{ t('task_manager') }}">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M9 11l3 3L22 4"></path><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"></path>
|
|
</svg>
|
|
<span id="tasksCountBadge" class="tasks-count-badge" style="display:none;">0</span>
|
|
</button>
|
|
|
|
<!-- Notification Bell -->
|
|
<button id="notificationBell" class="notification-bell" onclick="toggleNotificationCenter()">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"></path>
|
|
<path d="M13.73 21a2 2 0 0 1-3.46 0"></path>
|
|
</svg>
|
|
<span id="notificationBadge" class="notification-badge" style="display: none;">0</span>
|
|
</button>
|
|
|
|
<!-- User Menu -->
|
|
<div class="user-menu-wrapper" id="userMenuWrapper">
|
|
<button class="user-menu-btn" id="userMenuBtn" onclick="toggleUserMenu(event)">
|
|
<i class="fas fa-user-circle" style="font-size:1rem; opacity:0.8;"></i>
|
|
<span class="user-menu-name" id="userMenuName">...</span>
|
|
<i class="fas fa-chevron-down" style="font-size:0.6rem; opacity:0.6;"></i>
|
|
</button>
|
|
<div class="user-menu-dropdown" id="userMenuDropdown" style="display:none;">
|
|
<div class="user-menu-header">
|
|
<span class="user-menu-header-username" id="userMenuFullName">...</span>
|
|
<span class="user-menu-header-role" id="userMenuRoleName">...</span>
|
|
</div>
|
|
<button class="user-menu-item" onclick="openChangePasswordModal(); closeUserMenu();">
|
|
<i class="fas fa-key"></i>
|
|
<span>{{ t('change_password') }}</span>
|
|
</button>
|
|
<div class="user-menu-divider"></div>
|
|
<button class="user-menu-item danger" onclick="logout(event)">
|
|
<i class="fas fa-sign-out-alt"></i>
|
|
<span>{{ t('nav_logout') }}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
<main>
|
|
{% block content %}{% endblock %}
|
|
</main>
|
|
<footer class="page-footer">
|
|
<span class="version-badge" title="PVEmanager version">v{{ version }}</span>
|
|
<a href="https://github.com/markmorado/pvemanager" target="_blank" rel="noopener noreferrer" class="github-btn github-btn-sm" title="Star us on GitHub">
|
|
<i class="fa-brands fa-github"></i>
|
|
<span class="github-text">pvemanager</span>
|
|
<svg class="github-star-icon" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
|
<path d="M12 .587l3.668 7.568 8.332 1.151-6.064 5.828 1.48 8.279-7.416-3.967-7.417 3.967 1.481-8.279-6.064-5.828 8.332-1.151z"/>
|
|
</svg>
|
|
<span class="github-stars" id="githubStars">...</span>
|
|
</a>
|
|
</footer>
|
|
</div>
|
|
|
|
<!-- Notification Center Dropdown -->
|
|
<div id="notificationCenter" class="notification-center" style="display: none;">
|
|
<div class="notification-center-header">
|
|
<h3>{{ t('notifications') }}</h3>
|
|
<div class="notification-filters">
|
|
<button class="filter-btn active" data-filter="all">{{ t('all') }}</button>
|
|
<button class="filter-btn" data-filter="unread">{{ t('unread') }}</button>
|
|
<button class="filter-btn" data-filter="critical">{{ t('critical') }}</button>
|
|
</div>
|
|
</div>
|
|
<div id="notificationList" class="notification-list">
|
|
<div class="notification-empty">{{ t('no_notifications') }}</div>
|
|
</div>
|
|
<div class="notification-center-footer">
|
|
<button onclick="markAllAsRead()" class="notification-action-btn">{{ t('mark_all_read') }}</button>
|
|
<button onclick="clearAllRead()" class="notification-action-btn">{{ t('clear_all') }}</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Task Drawer -->
|
|
<div id="taskDrawer" class="task-drawer" style="display:none;">
|
|
<div class="task-drawer-header">
|
|
<h3><i class="fa-solid fa-list-check" style="color:var(--primary); margin-right:.5rem;"></i> {{ t('nav_tasks') }}</h3>
|
|
<div style="display:flex; gap:.4rem; align-items:center; margin-left:auto;">
|
|
<button id="taskDrawerClearBtn" onclick="clearCompletedTasks()" title="{{ t('clear_completed_tasks') }}" style="background:none; border:none; color:var(--text-muted); cursor:pointer; padding:.3rem .5rem; border-radius:6px; font-size:.75rem; display:none;">
|
|
<i class="fa-solid fa-trash-can"></i>
|
|
</button>
|
|
<button class="task-drawer-close" onclick="toggleTaskDrawer()">
|
|
<i class="fa-solid fa-xmark"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="task-drawer-body" id="taskDrawerBody">
|
|
<div class="task-drawer-empty" id="taskDrawerEmpty">
|
|
<i class="fa-solid fa-inbox" style="font-size:1.8rem; display:block; margin-bottom:.5rem; opacity:.4;"></i>
|
|
{{ t('no_active_tasks') }}
|
|
</div>
|
|
<div id="taskDrawerCards"></div>
|
|
</div>
|
|
<div class="task-drawer-footer">
|
|
<a href="/tasks">
|
|
<i class="fa-solid fa-arrow-right" style="margin-right:.4rem;"></i>
|
|
{{ t('open_task_manager') }}
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Change Password Modal -->
|
|
<div id="changePasswordOverlay" class="custom-dialog-overlay" onclick="if(event.target===this)closeChangePasswordModal()">
|
|
<div class="custom-dialog" style="max-width:420px;">
|
|
<div class="custom-dialog-icon info">
|
|
<i class="fas fa-key"></i>
|
|
</div>
|
|
<div class="custom-dialog-title">{{ t('change_password') }}</div>
|
|
<div style="display:flex; flex-direction:column; gap:12px; margin-bottom:20px;">
|
|
<div>
|
|
<label style="font-size:.82rem; color:var(--text-secondary); margin-bottom:5px; display:block;">{{ t('current_password') }}</label>
|
|
<input type="password" id="cpOldPassword" class="custom-dialog-input" style="text-align:left;" placeholder="{{ t('current_password') }}">
|
|
</div>
|
|
<div>
|
|
<label style="font-size:.82rem; color:var(--text-secondary); margin-bottom:5px; display:block;">{{ t('new_password') }}</label>
|
|
<input type="password" id="cpNewPassword" class="custom-dialog-input" style="text-align:left;" placeholder="{{ t('new_password') }}">
|
|
</div>
|
|
<div>
|
|
<label style="font-size:.82rem; color:var(--text-secondary); margin-bottom:5px; display:block;">{{ t('confirm_password') }}</label>
|
|
<input type="password" id="cpConfirmPassword" class="custom-dialog-input" style="text-align:left;" placeholder="{{ t('confirm_password') }}">
|
|
</div>
|
|
</div>
|
|
<div id="cpError" style="display:none; color:var(--danger,#ef4444); font-size:.85rem; margin-bottom:14px; text-align:center;"></div>
|
|
<div class="custom-dialog-buttons">
|
|
<button class="custom-dialog-btn secondary" onclick="closeChangePasswordModal()">{{ t('cancel') }}</button>
|
|
<button class="custom-dialog-btn primary" id="cpSubmitBtn" onclick="submitChangePassword()">{{ t('change_password') }}</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Custom Dialog Container -->
|
|
<div id="customDialogOverlay" class="custom-dialog-overlay">
|
|
<div class="custom-dialog">
|
|
<div id="customDialogIcon" class="custom-dialog-icon info">
|
|
<span id="customDialogIconEmoji"><i class="fa-solid fa-circle-info"></i></span>
|
|
</div>
|
|
<div id="customDialogTitle" class="custom-dialog-title">Title</div>
|
|
<div id="customDialogMessage" class="custom-dialog-message">Message</div>
|
|
<div id="customDialogInputContainer" class="custom-dialog-input-container" style="display: none;">
|
|
<input type="text" id="customDialogInput" class="custom-dialog-input" placeholder="">
|
|
</div>
|
|
<div id="customDialogButtons" class="custom-dialog-buttons">
|
|
<button class="custom-dialog-btn primary" onclick="closeCustomDialog(true)">OK</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
// Language setting
|
|
const currentLang = "{{ lang }}";
|
|
|
|
// Sidebar collapse toggle function
|
|
function toggleSidebarCollapse() {
|
|
const sidebar = document.getElementById('sidebar');
|
|
const isCollapsed = sidebar.classList.toggle('collapsed');
|
|
document.body.classList.toggle('sidebar-collapsed', isCollapsed);
|
|
localStorage.setItem('sidebarCollapsed', isCollapsed);
|
|
}
|
|
|
|
// Restore sidebar state from localStorage
|
|
(function() {
|
|
const isCollapsed = localStorage.getItem('sidebarCollapsed') === 'true';
|
|
if (isCollapsed) {
|
|
document.getElementById('sidebar')?.classList.add('collapsed');
|
|
document.body.classList.add('sidebar-collapsed');
|
|
}
|
|
})();
|
|
|
|
// Logout function
|
|
function logout(event) {
|
|
event.preventDefault();
|
|
localStorage.removeItem('access_token');
|
|
window.location.href = '/login';
|
|
}
|
|
|
|
// ==================== User Menu ====================
|
|
function toggleUserMenu(e) {
|
|
e.stopPropagation();
|
|
const dd = document.getElementById('userMenuDropdown');
|
|
const isOpen = dd.style.display !== 'none';
|
|
dd.style.display = isOpen ? 'none' : 'block';
|
|
}
|
|
|
|
function closeUserMenu() {
|
|
const dd = document.getElementById('userMenuDropdown');
|
|
if (dd) dd.style.display = 'none';
|
|
}
|
|
|
|
document.addEventListener('click', function(e) {
|
|
const wrapper = document.getElementById('userMenuWrapper');
|
|
if (wrapper && !wrapper.contains(e.target)) {
|
|
closeUserMenu();
|
|
}
|
|
});
|
|
|
|
// ==================== Change Password Modal ====================
|
|
function openChangePasswordModal() {
|
|
document.getElementById('cpOldPassword').value = '';
|
|
document.getElementById('cpNewPassword').value = '';
|
|
document.getElementById('cpConfirmPassword').value = '';
|
|
document.getElementById('cpError').style.display = 'none';
|
|
document.getElementById('changePasswordOverlay').classList.add('show');
|
|
setTimeout(() => document.getElementById('cpOldPassword').focus(), 100);
|
|
}
|
|
|
|
function closeChangePasswordModal() {
|
|
document.getElementById('changePasswordOverlay').classList.remove('show');
|
|
}
|
|
|
|
async function submitChangePassword() {
|
|
const oldPwd = document.getElementById('cpOldPassword').value.trim();
|
|
const newPwd = document.getElementById('cpNewPassword').value;
|
|
const confirmPwd = document.getElementById('cpConfirmPassword').value;
|
|
const errEl = document.getElementById('cpError');
|
|
const btn = document.getElementById('cpSubmitBtn');
|
|
|
|
errEl.style.display = 'none';
|
|
|
|
if (!oldPwd || !newPwd || !confirmPwd) {
|
|
errEl.textContent = t('fill_all_fields') || 'Please fill all fields';
|
|
errEl.style.display = 'block';
|
|
return;
|
|
}
|
|
if (newPwd !== confirmPwd) {
|
|
errEl.textContent = t('passwords_dont_match');
|
|
errEl.style.display = 'block';
|
|
return;
|
|
}
|
|
|
|
btn.disabled = true;
|
|
const origText = btn.textContent;
|
|
btn.textContent = '...';
|
|
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append('old_password', oldPwd);
|
|
formData.append('new_password', newPwd);
|
|
|
|
// Use fetch directly — FormData sets its own Content-Type with boundary.
|
|
// fetchWithAuth would override it with application/json.
|
|
const token = localStorage.getItem('access_token');
|
|
const resp = await fetch('/api/auth/change-password', {
|
|
method: 'POST',
|
|
headers: { 'Authorization': `Bearer ${token}` },
|
|
body: formData
|
|
});
|
|
|
|
if (resp.ok) {
|
|
closeChangePasswordModal();
|
|
showToast('success', t('success'), t('password_changed'));
|
|
} else {
|
|
const data = await resp.json().catch(() => ({}));
|
|
errEl.textContent = data.detail || t('error');
|
|
errEl.style.display = 'block';
|
|
}
|
|
} catch(err) {
|
|
errEl.textContent = t('error');
|
|
errEl.style.display = 'block';
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.textContent = origText;
|
|
}
|
|
}
|
|
|
|
// Allow Enter key in change password fields
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
['cpOldPassword','cpNewPassword','cpConfirmPassword'].forEach(id => {
|
|
const el = document.getElementById(id);
|
|
if (el) el.addEventListener('keydown', e => { if (e.key === 'Enter') submitChangePassword(); });
|
|
});
|
|
});
|
|
|
|
// Global user permissions object
|
|
window.userPermissions = {};
|
|
window.isAdmin = false;
|
|
|
|
// Bidirectional permission format translation table (legacy dot <-> new colon)
|
|
const _PERM_LEGACY_TO_NEW = {
|
|
'dashboard.view': 'dashboard:view',
|
|
'vms.view': 'vm:view',
|
|
'vms.create': 'vm:create',
|
|
'vms.start': 'vm:start',
|
|
'vms.stop': 'vm:stop',
|
|
'vms.restart': 'vm:restart',
|
|
'vms.delete': 'vm:delete',
|
|
'vms.console': 'vm:console',
|
|
'vms.migrate': 'vm:migrate',
|
|
'proxmox.view': 'server:view',
|
|
'proxmox.manage': 'server:manage',
|
|
'templates.view': 'template:view',
|
|
'templates.manage': 'template:manage',
|
|
'backups.view': 'backup:view',
|
|
'backups.manage': 'backup:manage',
|
|
'ipam.view': 'ipam:view',
|
|
'ipam.manage': 'ipam:manage',
|
|
'docker.view': 'docker:view',
|
|
'logs.view': 'log:view',
|
|
'logs.export': 'log:export',
|
|
'logs.delete': 'log:delete',
|
|
'settings.view': 'setting:view',
|
|
'settings.panel': 'setting:update',
|
|
'settings.security':'setting:manage',
|
|
'users.view': 'user:view',
|
|
'users.create': 'user:create',
|
|
'users.edit': 'user:update',
|
|
'users.delete': 'user:delete',
|
|
'roles.view': 'role:view',
|
|
'roles.manage': 'role:manage',
|
|
'notification.view':'notification:view',
|
|
'notification.manage':'notification:manage',
|
|
};
|
|
const _PERM_NEW_TO_LEGACY = Object.fromEntries(Object.entries(_PERM_LEGACY_TO_NEW).map(([k,v])=>[v,k]));
|
|
|
|
// Permission checking helper function — supports both legacy (dot) and new (colon) formats
|
|
window.hasPermission = function(permission) {
|
|
if (window.isAdmin) return true;
|
|
if (!permission) return true;
|
|
if (window.userPermissions[permission] === true) return true;
|
|
// Try translated format
|
|
const alt = _PERM_LEGACY_TO_NEW[permission] || _PERM_NEW_TO_LEGACY[permission];
|
|
if (alt && window.userPermissions[alt] === true) return true;
|
|
return false;
|
|
};
|
|
|
|
// Hide elements based on permission
|
|
window.hideIfNoPermission = function(elementOrSelector, permission) {
|
|
const el = typeof elementOrSelector === 'string'
|
|
? document.querySelector(elementOrSelector)
|
|
: elementOrSelector;
|
|
if (el && !window.hasPermission(permission)) {
|
|
el.style.display = 'none';
|
|
}
|
|
};
|
|
|
|
// Apply permissions to all elements with data-permission attribute
|
|
window.applyPermissions = function() {
|
|
document.querySelectorAll('[data-permission]').forEach(el => {
|
|
const permission = el.getAttribute('data-permission');
|
|
if (!window.hasPermission(permission)) {
|
|
el.style.display = 'none';
|
|
}
|
|
});
|
|
};
|
|
|
|
// Check authentication on page load
|
|
window.addEventListener('DOMContentLoaded', async () => {
|
|
// Skip check on login page
|
|
if (window.location.pathname === '/login') {
|
|
// Show nav immediately on login page (no permission filtering needed)
|
|
const sidebarNav = document.querySelector('.sidebar-nav');
|
|
if (sidebarNav) sidebarNav.classList.add('nav-ready');
|
|
return;
|
|
}
|
|
|
|
const token = localStorage.getItem('access_token');
|
|
if (!token) {
|
|
const currentPath = window.location.pathname;
|
|
// Avoid redirect loop: don't add returnUrl if already on login
|
|
if (currentPath !== '/login') {
|
|
const currentUrl = encodeURIComponent(currentPath + window.location.search);
|
|
window.location.href = `/login?returnUrl=${currentUrl}`;
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Verify token with backend and load permissions
|
|
try {
|
|
const response = await fetch('/api/auth/me', {
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`
|
|
}
|
|
});
|
|
|
|
if (!response.ok) {
|
|
// Token invalid or expired
|
|
localStorage.removeItem('access_token');
|
|
// Save current URL as query parameter
|
|
const currentUrl = encodeURIComponent(window.location.pathname + window.location.search);
|
|
window.location.href = `/login?returnUrl=${currentUrl}`;
|
|
return;
|
|
}
|
|
|
|
// Load user permissions
|
|
const userData = await response.json();
|
|
window.userPermissions = userData.permissions || {};
|
|
window.isAdmin = userData.is_admin === true;
|
|
window.currentUserId = userData.id;
|
|
window.currentUser = userData;
|
|
|
|
// Update user menu button
|
|
const displayName = userData.full_name || userData.username || '...';
|
|
const nameEl = document.getElementById('userMenuName');
|
|
const fullNameEl = document.getElementById('userMenuFullName');
|
|
const roleEl = document.getElementById('userMenuRoleName');
|
|
if (nameEl) nameEl.textContent = userData.username || '...';
|
|
if (fullNameEl) fullNameEl.textContent = displayName;
|
|
if (roleEl) roleEl.textContent = (userData.role && userData.role.display_name) || (userData.is_admin ? 'Administrator' : 'User');
|
|
|
|
// Apply permissions to UI elements
|
|
applyPermissionsToUI();
|
|
|
|
// Dispatch event for pages that need to react to permissions loaded
|
|
window.dispatchEvent(new CustomEvent('permissionsLoaded', { detail: { permissions: window.userPermissions, isAdmin: window.isAdmin } }));
|
|
|
|
} catch (error) {
|
|
// On network error, allow staying (offline mode) but still reveal nav
|
|
const sidebarNav = document.querySelector('.sidebar-nav');
|
|
if (sidebarNav) sidebarNav.classList.add('nav-ready');
|
|
}
|
|
});
|
|
|
|
// Apply permissions to sidebar menu and other UI elements
|
|
function applyPermissionsToUI() {
|
|
// --- Sidebar: hide items by data-nav-permission ---
|
|
const PAGE_PERMISSION_MAP = [
|
|
// [exact_prefix, permission] — only checks pages that actually need a permission
|
|
['/dashboard', 'dashboard.view'],
|
|
['/virtual-machines', 'vms.view'],
|
|
['/vms', 'proxmox.view'],
|
|
['/proxmox/vms', 'proxmox.view'],
|
|
['/proxmox/server', 'proxmox.view'],
|
|
['/templates', 'templates.view'],
|
|
['/backups', 'backups.view'],
|
|
['/proxmox/api/backups', 'backups.view'],
|
|
['/ipam', 'ipam.view'],
|
|
['/containers', 'docker.view'],
|
|
['/logs', 'logs.view'],
|
|
['/admin/users', 'users.view'],
|
|
];
|
|
|
|
document.querySelectorAll('.sidebar-nav li[data-nav-permission]').forEach(li => {
|
|
const perm = li.getAttribute('data-nav-permission');
|
|
if (!window.hasPermission(perm)) {
|
|
li.style.display = 'none';
|
|
} else {
|
|
li.style.display = '';
|
|
}
|
|
});
|
|
|
|
// Apply permissions to elements with data-permission attribute
|
|
window.applyPermissions();
|
|
|
|
// --- Redirect if current page is forbidden ---
|
|
const path = window.location.pathname;
|
|
for (const [prefix, perm] of PAGE_PERMISSION_MAP) {
|
|
// Match if path equals prefix or starts with prefix + '/'
|
|
const isMatch = path === prefix || path.startsWith(prefix + '/');
|
|
if (isMatch && !window.hasPermission(perm)) {
|
|
const firstAllowed = _findFirstAllowedPage();
|
|
// Avoid looping: only redirect if target differs from current page
|
|
if (firstAllowed && firstAllowed !== path && !path.startsWith(firstAllowed + '/')) {
|
|
window.location.replace(firstAllowed);
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Reveal the nav now that permissions are applied (prevents flash of admin items)
|
|
const sidebarNav = document.querySelector('.sidebar-nav');
|
|
if (sidebarNav) sidebarNav.classList.add('nav-ready');
|
|
}
|
|
|
|
function _findFirstAllowedPage() {
|
|
const candidates = [
|
|
['vms.view', '/virtual-machines'],
|
|
['proxmox.view', '/vms'],
|
|
['backups.view', '/backups'],
|
|
['ipam.view', '/ipam'],
|
|
['logs.view', '/logs'],
|
|
['users.view', '/admin/users'],
|
|
['templates.view', '/templates'],
|
|
['docker.view', '/containers'],
|
|
];
|
|
for (const [perm, page] of candidates) {
|
|
if (window.hasPermission(perm)) return page;
|
|
}
|
|
// /settings and /tasks are open to all authenticated users — safe fallback
|
|
return '/settings';
|
|
}
|
|
|
|
// Notification System
|
|
let notificationCenterOpen = false;
|
|
let currentFilter = 'all';
|
|
|
|
// Toggle notification center
|
|
function toggleNotificationCenter() {
|
|
const center = document.getElementById('notificationCenter');
|
|
notificationCenterOpen = !notificationCenterOpen;
|
|
|
|
if (notificationCenterOpen) {
|
|
center.style.display = 'flex';
|
|
loadNotifications();
|
|
} else {
|
|
center.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
// Close notification center when clicking outside
|
|
document.addEventListener('click', (e) => {
|
|
const bell = document.getElementById('notificationBell');
|
|
const center = document.getElementById('notificationCenter');
|
|
|
|
if (notificationCenterOpen && !bell.contains(e.target) && !center.contains(e.target)) {
|
|
toggleNotificationCenter();
|
|
}
|
|
});
|
|
|
|
// Load GitHub stars count
|
|
async function loadGitHubStars() {
|
|
try {
|
|
const response = await fetch('https://api.github.com/repos/markmorado/pvemanager', {
|
|
headers: {
|
|
'Accept': 'application/vnd.github+json'
|
|
}
|
|
});
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
const starsElement = document.getElementById('githubStars');
|
|
if (starsElement) {
|
|
// Format number with K for thousands
|
|
const stars = data.stargazers_count;
|
|
const formatted = stars >= 1000
|
|
? (stars / 1000).toFixed(1) + 'K'
|
|
: stars.toString();
|
|
starsElement.textContent = formatted;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load GitHub stars:', error);
|
|
const starsElement = document.getElementById('githubStars');
|
|
if (starsElement) {
|
|
starsElement.textContent = '★';
|
|
}
|
|
}
|
|
}
|
|
|
|
// ======================================================================
|
|
// 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 = t('all_workspaces');
|
|
if (dotEl) { dotEl.style.background = '#0ea5e9'; dotEl.style.opacity = '0.4'; }
|
|
} else {
|
|
nameEl.textContent = ws.name;
|
|
if (dotEl) { dotEl.style.background = ws.color || '#0ea5e9'; 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:#0ea5e9;opacity:0.4;"></span><span>${t('all_workspaces')}</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 || '#0ea5e9'};"></span><span>${ws.name}</span><small>${ws.server_count} ${t('servers_short')}</small>`;
|
|
item.onclick = (e) => { e.stopPropagation(); _wsSelect(ws.id, ws, isAdmin); };
|
|
container.appendChild(item);
|
|
});
|
|
}
|
|
|
|
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 {
|
|
// 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 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 {
|
|
// 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 {
|
|
const response = await fetchWithAuth('/api/notifications/unread-count');
|
|
if (response && response.ok) {
|
|
const data = await response.json();
|
|
const badge = document.getElementById('notificationBadge');
|
|
|
|
if (data.count > 0) {
|
|
badge.textContent = data.count > 99 ? '99+' : data.count;
|
|
badge.style.display = 'block';
|
|
} else {
|
|
badge.style.display = 'none';
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load notification count:', error);
|
|
}
|
|
}
|
|
|
|
// Load notifications with filter
|
|
async function loadNotifications() {
|
|
try {
|
|
let url = '/api/notifications/?limit=20';
|
|
|
|
if (currentFilter === 'unread') {
|
|
url += '&unread_only=true';
|
|
} else if (currentFilter === 'critical') {
|
|
url += '&level=critical';
|
|
}
|
|
|
|
const response = await fetchWithAuth(url);
|
|
if (response && response.ok) {
|
|
const data = await response.json();
|
|
displayNotifications(data.notifications || []);
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load notifications:', error);
|
|
}
|
|
}
|
|
|
|
// Display notifications in the list
|
|
function displayNotifications(notifications) {
|
|
const list = document.getElementById('notificationList');
|
|
|
|
if (notifications.length === 0) {
|
|
list.innerHTML = `<div class="notification-empty">${t('no_notifications')}</div>`;
|
|
return;
|
|
}
|
|
|
|
list.innerHTML = notifications.map(n => {
|
|
const timeAgo = formatTimeAgo(new Date(n.created_at));
|
|
const isRead = n.read || n.is_read;
|
|
return `
|
|
<div class="notification-item ${isRead ? '' : 'unread'}" onclick="markAsRead(${n.id})">
|
|
<div class="notification-header">
|
|
<span class="notification-title">${escapeHtml(n.title)}</span>
|
|
<span class="notification-level ${n.level}">${n.level}</span>
|
|
</div>
|
|
<div class="notification-message">${escapeHtml(n.message)}</div>
|
|
<div class="notification-time">${timeAgo}</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
// Format time ago
|
|
function formatTimeAgo(date) {
|
|
const seconds = Math.floor((new Date() - date) / 1000);
|
|
|
|
// Get translations for time units
|
|
const timeUnits = {
|
|
year: { single: t('year'), few: t('years_few'), many: t('years_many'), seconds: 31536000 },
|
|
month: { single: t('month'), few: t('months_few'), many: t('months_many'), seconds: 2592000 },
|
|
day: { single: t('day'), few: t('days_few'), many: t('days_many'), seconds: 86400 },
|
|
hour: { single: t('hour'), few: t('hours_few'), many: t('hours_many'), seconds: 3600 },
|
|
minute: { single: t('minute'), few: t('minutes_few'), many: t('minutes_many'), seconds: 60 }
|
|
};
|
|
|
|
for (const [key, unit] of Object.entries(timeUnits)) {
|
|
const interval = Math.floor(seconds / unit.seconds);
|
|
|
|
if (interval >= 1) {
|
|
// Russian plural forms: 1 час, 2-4 часа, 5-20 часов
|
|
let form;
|
|
const lastDigit = interval % 10;
|
|
const lastTwoDigits = interval % 100;
|
|
|
|
if (lastTwoDigits >= 11 && lastTwoDigits <= 14) {
|
|
form = unit.many;
|
|
} else if (lastDigit === 1) {
|
|
form = unit.single;
|
|
} else if (lastDigit >= 2 && lastDigit <= 4) {
|
|
form = unit.few;
|
|
} else {
|
|
form = unit.many;
|
|
}
|
|
|
|
return `${interval} ${form} ${t('ago')}`;
|
|
}
|
|
}
|
|
|
|
return t('just_now');
|
|
}
|
|
|
|
// Escape HTML to prevent XSS
|
|
function escapeHtml(text) {
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
// Mark notification as read
|
|
async function markAsRead(id) {
|
|
try {
|
|
const response = await fetchWithAuth(`/api/notifications/${id}/read`, {
|
|
method: 'PATCH'
|
|
});
|
|
|
|
if (response && response.ok) {
|
|
loadNotifications();
|
|
loadNotificationCount();
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to mark notification as read:', error);
|
|
}
|
|
}
|
|
|
|
// Mark all notifications as read
|
|
async function markAllAsRead() {
|
|
try {
|
|
const response = await fetchWithAuth('/api/notifications/mark-all-read', {
|
|
method: 'POST'
|
|
});
|
|
|
|
if (response && response.ok) {
|
|
loadNotifications();
|
|
loadNotificationCount();
|
|
showToast('success', t('success'), t('all_notifications_marked_read'));
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to mark all as read:', error);
|
|
}
|
|
}
|
|
|
|
// Clear all read notifications
|
|
async function clearAllRead() {
|
|
try {
|
|
const response = await fetchWithAuth('/api/notifications/read/all', {
|
|
method: 'DELETE'
|
|
});
|
|
|
|
if (response && response.ok) {
|
|
const data = await response.json();
|
|
loadNotifications();
|
|
loadNotificationCount();
|
|
showToast('success', t('success'), `${t('notifications_deleted')}: ${data.deleted_count}`);
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to clear notifications:', error);
|
|
}
|
|
}
|
|
|
|
// Show toast notification
|
|
// Supports both old format: showToast(message, type) and new format: showToast(level, title, message)
|
|
window.showToast = function(levelOrMessage, titleOrType, message, duration = 5000) {
|
|
const oldTypes = ['success', 'danger', 'info', 'warning', 'error'];
|
|
let level, title, msg;
|
|
|
|
if (oldTypes.includes(titleOrType) && !message) {
|
|
msg = levelOrMessage;
|
|
level = titleOrType === 'danger' ? 'error' : titleOrType;
|
|
title = level === 'success' ? '{{ _("success") }}' :
|
|
level === 'error' ? '{{ _("error") }}' :
|
|
level === 'warning' ? '{{ _("warning") }}' : '{{ _("info") }}';
|
|
} else {
|
|
level = levelOrMessage;
|
|
title = titleOrType;
|
|
msg = message;
|
|
}
|
|
|
|
const colors = { success: '#10b981', error: '#ef4444', warning: '#f59e0b', info: '#3b82f6' };
|
|
const borderColor = colors[level] || colors.info;
|
|
|
|
const toast = document.createElement('div');
|
|
toast.style.cssText = `
|
|
position: fixed; top: 20px; right: 20px; min-width: 300px; max-width: 400px;
|
|
padding: 16px 20px; background: var(--bg-surface); border: 1px solid var(--border-default);
|
|
border-left: 4px solid ${borderColor}; border-radius: 8px; box-shadow: 0 10px 40px rgba(0,0,0,0.5);
|
|
z-index: 99999; animation: toastSlide 0.3s ease-out;
|
|
`;
|
|
toast.innerHTML = `
|
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px;">
|
|
<span style="font-weight: 600; color: var(--text-primary);">${title}</span>
|
|
<button onclick="this.parentElement.parentElement.remove()" style="background: none; border: none; color: var(--text-muted); cursor: pointer; font-size: 18px;">×</button>
|
|
</div>
|
|
<div style="color: var(--text-secondary); font-size: 14px;">${msg || ''}</div>
|
|
`;
|
|
document.body.appendChild(toast);
|
|
|
|
setTimeout(() => {
|
|
toast.style.opacity = '0';
|
|
toast.style.transform = 'translateX(100%)';
|
|
toast.style.transition = 'all 0.3s ease';
|
|
setTimeout(() => toast.remove(), 300);
|
|
}, duration);
|
|
};
|
|
|
|
// Local alias for backward compatibility
|
|
var showToast = window.showToast;
|
|
|
|
// ==================== Custom Dialog System ====================
|
|
let dialogResolve = null;
|
|
|
|
// Show custom dialog (replacement for native alert/confirm)
|
|
window.showDialog = function(options) {
|
|
return new Promise((resolve) => {
|
|
dialogResolve = resolve;
|
|
|
|
const overlay = document.getElementById('customDialogOverlay');
|
|
const icon = document.getElementById('customDialogIcon');
|
|
const iconEmoji = document.getElementById('customDialogIconEmoji');
|
|
const title = document.getElementById('customDialogTitle');
|
|
const message = document.getElementById('customDialogMessage');
|
|
const buttons = document.getElementById('customDialogButtons');
|
|
|
|
// Set icon type
|
|
const type = options.type || 'info';
|
|
icon.className = 'custom-dialog-icon ' + type;
|
|
|
|
const icons = {
|
|
info: '<i class="fa-solid fa-circle-info"></i>',
|
|
success: '<i class="fa-solid fa-check"></i>',
|
|
warning: '<i class="fa-solid fa-triangle-exclamation"></i>',
|
|
danger: '<i class="fa-solid fa-trash"></i>',
|
|
question: '<i class="fa-solid fa-question"></i>',
|
|
error: '<i class="fa-solid fa-circle-xmark"></i>'
|
|
};
|
|
iconEmoji.innerHTML = options.icon || icons[type] || '<i class="fa-solid fa-circle-info"></i>';
|
|
|
|
// Set content
|
|
title.textContent = options.title || t('notification');
|
|
message.innerHTML = options.message || '';
|
|
|
|
// Handle input field for prompt dialogs
|
|
const inputContainer = document.getElementById('customDialogInputContainer');
|
|
const inputField = document.getElementById('customDialogInput');
|
|
|
|
if (options.prompt) {
|
|
inputContainer.style.display = 'block';
|
|
inputField.value = options.defaultValue || '';
|
|
inputField.placeholder = options.placeholder || '';
|
|
inputField.type = options.inputType || 'text';
|
|
inputField.classList.remove('error');
|
|
|
|
// Focus input after dialog is shown
|
|
setTimeout(() => inputField.focus(), 100);
|
|
|
|
// Handle Enter key in input
|
|
inputField.onkeydown = function(e) {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
closeCustomDialog(inputField.value);
|
|
}
|
|
};
|
|
} else {
|
|
inputContainer.style.display = 'none';
|
|
inputField.value = '';
|
|
}
|
|
|
|
// Set buttons
|
|
if (options.buttons) {
|
|
buttons.innerHTML = options.buttons.map(btn =>
|
|
`<button class="custom-dialog-btn ${btn.class || 'primary'}" onclick="closeCustomDialog(${btn.value !== undefined ? JSON.stringify(btn.value) : 'true'})">${btn.text}</button>`
|
|
).join('');
|
|
} else if (options.prompt) {
|
|
buttons.innerHTML = `
|
|
<button class="custom-dialog-btn secondary" onclick="closeCustomDialog(null)">${options.cancelText || t('cancel')}</button>
|
|
<button class="custom-dialog-btn ${options.confirmClass || 'primary'}" onclick="closeCustomDialog(document.getElementById('customDialogInput').value)">${options.confirmText || 'OK'}</button>
|
|
`;
|
|
} else if (options.confirm) {
|
|
buttons.innerHTML = `
|
|
<button class="custom-dialog-btn secondary" onclick="closeCustomDialog(false)">${options.cancelText || t('cancel')}</button>
|
|
<button class="custom-dialog-btn ${options.confirmClass || 'primary'}" onclick="closeCustomDialog(true)">${options.confirmText || 'OK'}</button>
|
|
`;
|
|
} else {
|
|
buttons.innerHTML = `<button class="custom-dialog-btn primary" onclick="closeCustomDialog(true)">OK</button>`;
|
|
}
|
|
|
|
// Show dialog
|
|
overlay.classList.add('show');
|
|
|
|
// Close on overlay click (optional)
|
|
if (options.closeOnOverlay !== false) {
|
|
overlay.onclick = function(e) {
|
|
if (e.target === overlay) {
|
|
closeCustomDialog(false);
|
|
}
|
|
};
|
|
}
|
|
|
|
// Close on ESC key
|
|
const escHandler = function(e) {
|
|
if (e.key === 'Escape') {
|
|
closeCustomDialog(false);
|
|
document.removeEventListener('keydown', escHandler);
|
|
}
|
|
};
|
|
document.addEventListener('keydown', escHandler);
|
|
});
|
|
};
|
|
|
|
// Close custom dialog
|
|
window.closeCustomDialog = function(result) {
|
|
const overlay = document.getElementById('customDialogOverlay');
|
|
overlay.classList.remove('show');
|
|
|
|
if (dialogResolve) {
|
|
dialogResolve(result);
|
|
dialogResolve = null;
|
|
}
|
|
};
|
|
|
|
// Shorthand functions
|
|
window.showAlert = function(message, title, type = 'info') {
|
|
return showDialog({
|
|
type: type,
|
|
title: title || t('notification'),
|
|
message: message
|
|
});
|
|
};
|
|
|
|
window.showSuccess = function(message, title) {
|
|
return showDialog({
|
|
type: 'success',
|
|
title: title || t('success'),
|
|
message: message
|
|
});
|
|
};
|
|
|
|
window.showError = function(message, title) {
|
|
return showDialog({
|
|
type: 'error',
|
|
title: title || t('error'),
|
|
message: message
|
|
});
|
|
};
|
|
|
|
window.showWarning = function(message, title) {
|
|
return showDialog({
|
|
type: 'warning',
|
|
title: title || t('warning'),
|
|
message: message
|
|
});
|
|
};
|
|
|
|
window.showConfirm = function(message, title, options = {}) {
|
|
return showDialog({
|
|
type: options.type || 'question',
|
|
title: title || t('confirm'),
|
|
message: message,
|
|
confirm: true,
|
|
confirmText: options.confirmText || t('yes') || 'Yes',
|
|
cancelText: options.cancelText || t('cancel') || 'Cancel',
|
|
confirmClass: options.confirmClass || 'primary',
|
|
icon: options.icon
|
|
});
|
|
};
|
|
|
|
window.showDeleteConfirm = function(message, title) {
|
|
return showDialog({
|
|
type: 'danger',
|
|
icon: '<i class="fa-solid fa-trash"></i>',
|
|
title: title || t('confirm_delete') || 'Confirm Delete',
|
|
message: message,
|
|
confirm: true,
|
|
confirmText: t('delete') || 'Delete',
|
|
cancelText: t('cancel') || 'Cancel',
|
|
confirmClass: 'danger'
|
|
});
|
|
};
|
|
|
|
// Show prompt dialog (replacement for native prompt())
|
|
window.showPrompt = function(message, title, options = {}) {
|
|
return showDialog({
|
|
type: options.type || 'question',
|
|
icon: options.icon || '<i class="fa-solid fa-pen"></i>',
|
|
title: title || t('input_required') || 'Input Required',
|
|
message: message,
|
|
prompt: true,
|
|
placeholder: options.placeholder || '',
|
|
defaultValue: options.defaultValue || '',
|
|
inputType: options.inputType || 'text',
|
|
confirmText: options.confirmText || 'OK',
|
|
cancelText: options.cancelText || t('cancel') || 'Cancel',
|
|
confirmClass: options.confirmClass || 'primary'
|
|
});
|
|
};
|
|
|
|
// Filter button handlers
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
const filterButtons = document.querySelectorAll('.filter-btn');
|
|
filterButtons.forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
filterButtons.forEach(b => b.classList.remove('active'));
|
|
btn.classList.add('active');
|
|
currentFilter = btn.dataset.filter;
|
|
loadNotifications();
|
|
});
|
|
});
|
|
|
|
// Load initial notification count
|
|
if (window.location.pathname !== '/login') {
|
|
loadNotificationCount();
|
|
|
|
// Refresh count every 30 seconds
|
|
setInterval(loadNotificationCount, 30000);
|
|
|
|
// Load GitHub stars count
|
|
loadGitHubStars();
|
|
|
|
// Init task drawer WS
|
|
initTaskDrawerWS();
|
|
|
|
// Load workspace selector for sidebar
|
|
loadSidebarWorkspace();
|
|
}
|
|
|
|
// Add click handlers for sidebar menu items to update page title
|
|
const sidebarLinks = document.querySelectorAll('.sidebar-nav a');
|
|
sidebarLinks.forEach(link => {
|
|
link.addEventListener('click', function() {
|
|
const menuText = this.querySelector('span')?.textContent || '';
|
|
if (menuText) {
|
|
// Update browser tab title
|
|
document.title = menuText;
|
|
// Update header title
|
|
const headerTitle = document.querySelector('header h2');
|
|
if (headerTitle) {
|
|
headerTitle.textContent = menuText;
|
|
}
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
// ================================================================
|
|
// Task Drawer — global WebSocket-based live task indicator
|
|
// ================================================================
|
|
let taskDrawerOpen = false;
|
|
var taskDrawerWS = null; // var → exposes as window.taskDrawerWS for child templates
|
|
let taskDrawerTasks = {};
|
|
let taskDrawerReconnectTimer = null;
|
|
let taskDrawerReconnectDelay = 2000;
|
|
|
|
// Task Drawer: задачи старше 24 ч скрываются из панели (в /tasks они остаются)
|
|
const DRAWER_TASK_TTL = 24 * 60 * 60 * 1000;
|
|
function isDrawerTaskExpired(t) {
|
|
if (!t || !t.created_at) return false;
|
|
return (Date.now() - new Date(t.created_at).getTime()) > DRAWER_TASK_TTL;
|
|
}
|
|
|
|
function toggleTaskDrawer() {
|
|
const drawer = document.getElementById('taskDrawer');
|
|
taskDrawerOpen = !taskDrawerOpen;
|
|
drawer.style.display = taskDrawerOpen ? 'flex' : 'none';
|
|
if (taskDrawerOpen) renderTaskDrawer();
|
|
}
|
|
|
|
// Close drawer when clicking outside
|
|
document.addEventListener('click', function(e) {
|
|
if (!taskDrawerOpen) return;
|
|
const drawer = document.getElementById('taskDrawer');
|
|
const btn = document.getElementById('tasksBtn');
|
|
if (drawer && btn && !drawer.contains(e.target) && !btn.contains(e.target)) {
|
|
taskDrawerOpen = false;
|
|
drawer.style.display = 'none';
|
|
}
|
|
});
|
|
|
|
function normalizeTaskStatus(s) {
|
|
const m = { queued:'pending', error:'failed', canceled:'cancelled' };
|
|
return m[s] || s || 'pending';
|
|
}
|
|
|
|
function renderTaskDrawer() {
|
|
const cards = document.getElementById('taskDrawerCards');
|
|
const empty = document.getElementById('taskDrawerEmpty');
|
|
const list = Object.values(taskDrawerTasks)
|
|
.sort((a,b) => new Date(b.created_at) - new Date(a.created_at))
|
|
.slice(0, 10);
|
|
if (list.length === 0) {
|
|
cards.innerHTML = '';
|
|
empty.style.display = 'block';
|
|
return;
|
|
}
|
|
empty.style.display = 'none';
|
|
const icons = { running:'<i class="fa-solid fa-circle-notch" style="animation:task-spin 1.2s linear infinite;"></i>', pending:'<i class="fa-solid fa-clock"></i>', completed:'<i class="fa-solid fa-circle-check"></i>', failed:'<i class="fa-solid fa-circle-xmark"></i>', cancelled:'<i class="fa-solid fa-ban"></i>' };
|
|
const labels = { running:t('status_running'), pending:t('status_pending'), completed:t('status_completed'), failed:t('status_failed'), cancelled:t('status_cancelled') };
|
|
cards.innerHTML = list.map(t => {
|
|
const st = normalizeTaskStatus(t.status);
|
|
const pct = st === 'completed' ? 100 : st === 'failed' || st === 'cancelled' ? 100 :
|
|
(t.kind === 'bulk' && t.total_items > 0 ? Math.round(t.processed_items/t.total_items*100) : (st==='running'?60:0));
|
|
const desc = t.description || t.action || t.task_type || window.t('task_default_name');
|
|
const ts = t.created_at ? new Date(t.created_at).toLocaleTimeString('ru-RU',{hour:'2-digit',minute:'2-digit'}) : '';
|
|
return `<div class="task-mini-card ${st}">
|
|
<div class="task-mini-top">
|
|
<span class="task-dot ${st}"></span>
|
|
<span class="task-mini-name" title="${desc}">${desc.length>35?desc.slice(0,33)+'…':desc}</span>
|
|
<span class="task-mini-time">${ts}</span>
|
|
</div>
|
|
<div class="task-mini-progress">
|
|
<div class="task-progress-track" style="height:4px;">
|
|
<div class="task-progress-fill ${st}" style="width:${pct}%;"></div>
|
|
</div>
|
|
</div>
|
|
<div style="display:flex; align-items:center; justify-content:space-between; margin-top:.35rem;">
|
|
<span class="task-badge task-badge-${st}" style="font-size:.65rem; padding:2px 7px;">${icons[st]||''} ${labels[st]||st}</span>
|
|
${t.kind==='bulk'&&t.total_items>0?`<span style="font-size:.7rem;color:var(--text-muted);">${t.processed_items||0}/${t.total_items}</span>`:''}
|
|
</div>
|
|
</div>`;
|
|
}).join('');
|
|
}
|
|
|
|
function updateTaskBadge() {
|
|
const active = Object.values(taskDrawerTasks).filter(t => {
|
|
const st = normalizeTaskStatus(t.status);
|
|
return st === 'running' || st === 'pending';
|
|
}).length;
|
|
const badge = document.getElementById('tasksCountBadge');
|
|
const sidebar = document.getElementById('sidebarTasksBadge');
|
|
if (!badge) return;
|
|
if (active > 0) {
|
|
badge.textContent = active;
|
|
badge.style.display = 'flex';
|
|
badge.className = 'tasks-count-badge has-active';
|
|
if (sidebar) { sidebar.textContent = active; sidebar.style.display = 'inline-block'; }
|
|
} else {
|
|
badge.style.display = 'none';
|
|
if (sidebar) sidebar.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
function initTaskDrawerWS() {
|
|
const token = localStorage.getItem('access_token');
|
|
if (!token) return;
|
|
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
|
taskDrawerWS = new WebSocket(`${proto}://${location.host}/ws/tasks?token=${encodeURIComponent(token)}`);
|
|
taskDrawerWS.onmessage = ({data}) => {
|
|
try {
|
|
const msg = JSON.parse(data);
|
|
if (msg.type === 'task_list') {
|
|
taskDrawerTasks = {};
|
|
(msg.tasks||[]).forEach(t => {
|
|
if (!isDrawerTaskExpired(t))
|
|
taskDrawerTasks[`${t.kind}_${t.id}`] = t;
|
|
});
|
|
} else if (msg.type === 'task_update' || msg.type === 'task_created') {
|
|
if (msg.task && !isDrawerTaskExpired(msg.task))
|
|
taskDrawerTasks[`${msg.task.kind}_${msg.task.id}`] = msg.task;
|
|
} else if (msg.type === 'task_removed') {
|
|
if (msg.task) delete taskDrawerTasks[`${msg.task.kind}_${msg.task.id}`];
|
|
}
|
|
updateTaskBadge();
|
|
if (taskDrawerOpen) renderTaskDrawer();
|
|
// Route to registered per-type handlers
|
|
if (_wsHandlers[msg.type]) { try { _wsHandlers[msg.type](msg); } catch(e) {} }
|
|
// Resolve pending request-response promises
|
|
if (msg.type === 'rrd_response' && msg.request_id && _wsPendingRequests[msg.request_id]) {
|
|
const { resolve, timer } = _wsPendingRequests[msg.request_id];
|
|
clearTimeout(timer);
|
|
delete _wsPendingRequests[msg.request_id];
|
|
if (msg.error) { /* still resolve — caller decides what to do */ }
|
|
resolve(msg);
|
|
}
|
|
} catch {}
|
|
};
|
|
taskDrawerWS.onclose = () => {
|
|
taskDrawerReconnectTimer = setTimeout(initTaskDrawerWS, taskDrawerReconnectDelay);
|
|
taskDrawerReconnectDelay = Math.min(taskDrawerReconnectDelay * 2, 30000);
|
|
};
|
|
taskDrawerWS.onopen = () => {
|
|
taskDrawerReconnectDelay = 2000;
|
|
setInterval(() => {
|
|
if (taskDrawerWS && taskDrawerWS.readyState === WebSocket.OPEN) taskDrawerWS.send(JSON.stringify({type:'ping'}));
|
|
}, 20000);
|
|
// Re-subscribe to all active channels after reconnect
|
|
_wsActiveSubscriptions.forEach(channel => {
|
|
taskDrawerWS.send(JSON.stringify({type: 'subscribe', channel}));
|
|
});
|
|
};
|
|
}
|
|
|
|
// Периодически удаляем задачи старше 24 ч из Drawer (каждые 5 минут)
|
|
setInterval(() => {
|
|
let changed = false;
|
|
for (const key of Object.keys(taskDrawerTasks)) {
|
|
if (isDrawerTaskExpired(taskDrawerTasks[key])) {
|
|
delete taskDrawerTasks[key];
|
|
changed = true;
|
|
}
|
|
}
|
|
if (changed) {
|
|
updateTaskBadge();
|
|
if (taskDrawerOpen) renderTaskDrawer();
|
|
}
|
|
}, 5 * 60 * 1000);
|
|
|
|
// ── WebSocket utility API (usable from any page script) ──────────────────
|
|
|
|
/** Registry of per-message-type handlers. */
|
|
const _wsHandlers = {};
|
|
|
|
/** Pending request-response promises keyed by request_id. */
|
|
const _wsPendingRequests = {};
|
|
|
|
/** Channels this page is currently subscribed to (for reconnect restore). */
|
|
const _wsActiveSubscriptions = new Set();
|
|
|
|
let _wsReqCounter = 0;
|
|
|
|
/**
|
|
* Register a handler function for a specific WebSocket message type.
|
|
* Multiple calls with the same type replace the previous handler.
|
|
*/
|
|
function registerWsHandler(type, fn) {
|
|
_wsHandlers[type] = fn;
|
|
}
|
|
|
|
/**
|
|
* Call fn immediately if the WebSocket is open, otherwise wait for onopen.
|
|
*/
|
|
function onWsReady(fn) {
|
|
if (taskDrawerWS && taskDrawerWS.readyState === WebSocket.OPEN) {
|
|
fn();
|
|
} else if (taskDrawerWS) {
|
|
const prev = taskDrawerWS.onopen;
|
|
taskDrawerWS.onopen = function(e) {
|
|
if (prev) prev.call(this, e);
|
|
fn();
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Subscribe this tab to a named metrics channel.
|
|
* The subscription is remembered so it can be re-sent after reconnect.
|
|
*/
|
|
function subscribeWsChannel(channel) {
|
|
_wsActiveSubscriptions.add(channel);
|
|
if (taskDrawerWS && taskDrawerWS.readyState === WebSocket.OPEN) {
|
|
taskDrawerWS.send(JSON.stringify({type: 'subscribe', channel}));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Unsubscribe from a channel and remove it from the reconnect set.
|
|
*/
|
|
function unsubscribeWsChannel(channel) {
|
|
_wsActiveSubscriptions.delete(channel);
|
|
if (taskDrawerWS && taskDrawerWS.readyState === WebSocket.OPEN) {
|
|
taskDrawerWS.send(JSON.stringify({type: 'unsubscribe', channel}));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send an on-demand request over WebSocket and return a Promise that
|
|
* resolves with the server's rrd_response message.
|
|
* Automatically falls back gracefully if WS is unavailable.
|
|
* @param {string} action — "get_rrd" or "get_node_rrd"
|
|
* @param {object} params — { server_id, node, timeframe, vmid?, inst_type? }
|
|
* @returns {Promise<object>} resolves with { data, error? }
|
|
*/
|
|
function sendWsRequest(action, params) {
|
|
return new Promise((resolve, reject) => {
|
|
if (!taskDrawerWS || taskDrawerWS.readyState !== WebSocket.OPEN) {
|
|
reject(new Error('WebSocket not open'));
|
|
return;
|
|
}
|
|
const request_id = `req_${++_wsReqCounter}_${Date.now()}`;
|
|
const timer = setTimeout(() => {
|
|
if (_wsPendingRequests[request_id]) {
|
|
delete _wsPendingRequests[request_id];
|
|
reject(new Error('WS request timed out'));
|
|
}
|
|
}, 12000);
|
|
_wsPendingRequests[request_id] = { resolve, timer };
|
|
taskDrawerWS.send(JSON.stringify({type: 'request', action, request_id, params}));
|
|
});
|
|
}
|
|
|
|
// ── Task Drawer utilities ─────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Открыть Task Drawer и подсветить задачу (используется внешними страницами).
|
|
* Вызывается при запуске bulk-операций вместо нижнего тоста.
|
|
*/
|
|
function openTaskDrawer() {
|
|
if (!taskDrawerOpen) {
|
|
taskDrawerOpen = true;
|
|
document.getElementById('taskDrawer').style.display = 'flex';
|
|
renderTaskDrawer();
|
|
}
|
|
}
|
|
|
|
/** Обновить видимость кнопки очистки в зависимости от состава задач. */
|
|
function _updateClearButton() {
|
|
const hasDone = Object.values(taskDrawerTasks).some(t => {
|
|
const s = normalizeTaskStatus(t.status);
|
|
return s === 'completed' || s === 'failed' || s === 'cancelled';
|
|
});
|
|
const btn = document.getElementById('taskDrawerClearBtn');
|
|
if (btn) btn.style.display = hasDone ? 'flex' : 'none';
|
|
}
|
|
|
|
/** Оригинальная renderTaskDrawer расширяется — вызываем _updateClearButton после. */
|
|
const _origRenderTaskDrawer = renderTaskDrawer;
|
|
renderTaskDrawer = function() {
|
|
_origRenderTaskDrawer();
|
|
_updateClearButton();
|
|
};
|
|
|
|
/** Удалить все завершённые/ошибочные/отменённые задачи. */
|
|
async function clearCompletedTasks() {
|
|
try {
|
|
const resp = await fetchWithAuth('/proxmox/api/all-tasks/completed', { method: 'DELETE' });
|
|
if (resp && resp.ok) {
|
|
const data = await resp.json();
|
|
// Remove done tasks from local state
|
|
for (const key of Object.keys(taskDrawerTasks)) {
|
|
const s = normalizeTaskStatus(taskDrawerTasks[key].status);
|
|
if (s === 'completed' || s === 'failed' || s === 'cancelled') {
|
|
delete taskDrawerTasks[key];
|
|
}
|
|
}
|
|
renderTaskDrawer();
|
|
updateTaskBadge();
|
|
if (data.deleted > 0) showToast('success', t('cleared'), t('deleted_tasks').replace('{count}', data.deleted));
|
|
}
|
|
} catch(e) {
|
|
console.error('clearCompletedTasks error', e);
|
|
}
|
|
}
|
|
</script>
|
|
<!-- Bootstrap 5 JS Bundle -->
|
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL" crossorigin="anonymous"></script>
|
|
|
|
<!-- Toast Container - Outside layout for proper positioning -->
|
|
<div id="toastContainer" class="toast-container"></div>
|
|
|
|
</body>
|
|
</html>
|