- Backup module: full CRUD for storages, backup files, scheduled jobs (APScheduler) - Proxmox client: storage/backup/restore/HA methods - IPAM: proxmox_node field, server+node binding for networks and allocations - Task queue service: improvements and new task types - Monitoring worker: extended metrics and alerts - HA support: add/remove VMs from Proxmox HA groups - tasks.html: new task tracking page - proxmox_vms.html: major UI update (bulk ops, HA, snapshots) - backups.html: full backup management UI (3 tabs) - base.html: sidebar backup link, websocket manager - theme.css: full CSS variables theme, 395 lines added - migrations: migration #15 (BackupJob), #16 (proxmox_node) - RBAC: backup resource permissions - websocket_manager.py: new module for WS connections
1508 lines
66 KiB
HTML
1508 lines
66 KiB
HTML
<!doctype html>
|
||
<html lang="{{ lang }}" data-theme="dark">
|
||
<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;
|
||
|
||
// Global ESC handler for closing modals
|
||
document.addEventListener('keydown', function(event) {
|
||
if (event.key === 'Escape') {
|
||
// 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);
|
||
}
|
||
|
||
/* 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, #667eea 0%, #4f46e5 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: linear-gradient(135deg, #667eea 0%, #4f46e5 100%);
|
||
color: #ffffff !important;
|
||
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.3);
|
||
}
|
||
|
||
.custom-dialog-btn.primary:hover {
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 6px 16px rgba(99, 102, 241, 0.5);
|
||
}
|
||
|
||
.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);
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<!-- 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">Обновление системы...</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>
|
||
<nav>
|
||
<ul class="sidebar-nav">
|
||
<li><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><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><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><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><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><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><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><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="Менеджер задач">
|
||
<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>Задачи</span>
|
||
<span id="sidebarTasksBadge" style="display:none; background:#667eea; 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><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><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 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>
|
||
<h2>{{ page_title or title or t('nav_dashboard') }}</h2>
|
||
<div class="header-actions">
|
||
<!-- GitHub Stars Button -->
|
||
<a href="https://git.tzim.uz/markmorado/pvemanager" target="_blank" rel="noopener noreferrer" class="github-btn" title="Star us on Gitea">
|
||
<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="16" height="16" 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>
|
||
|
||
<!-- 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="Менеджер задач">
|
||
<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>
|
||
</div>
|
||
</header>
|
||
<main>
|
||
{% block content %}{% endblock %}
|
||
</main>
|
||
</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:#667eea; margin-right:.5rem;"></i> Задачи</h3>
|
||
<button class="task-drawer-close" onclick="toggleTaskDrawer()">
|
||
<i class="fa-solid fa-xmark"></i>
|
||
</button>
|
||
</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>
|
||
Нет активных задач
|
||
</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>
|
||
Открыть менеджер задач
|
||
</a>
|
||
</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';
|
||
}
|
||
|
||
// Global user permissions object
|
||
window.userPermissions = {};
|
||
window.isAdmin = false;
|
||
|
||
// Permission checking helper function
|
||
window.hasPermission = function(permission) {
|
||
if (window.isAdmin) return true;
|
||
if (!permission) return true;
|
||
return window.userPermissions[permission] === true;
|
||
};
|
||
|
||
// 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') {
|
||
return;
|
||
}
|
||
|
||
const token = localStorage.getItem('access_token');
|
||
if (!token) {
|
||
// Save current URL as query parameter
|
||
const currentUrl = encodeURIComponent(window.location.pathname + 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;
|
||
|
||
// 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)
|
||
}
|
||
});
|
||
|
||
// Apply permissions to sidebar menu and other UI elements
|
||
function applyPermissionsToUI() {
|
||
const navLinks = document.querySelectorAll('.sidebar-nav a[href]');
|
||
|
||
navLinks.forEach(link => {
|
||
const href = link.getAttribute('href');
|
||
let requiredPermission = null;
|
||
|
||
// Map routes to required permissions
|
||
if (href === '/vms' || href.startsWith('/proxmox')) {
|
||
requiredPermission = 'proxmox.view';
|
||
} else if (href === '/templates' || href.startsWith('/templates')) {
|
||
requiredPermission = 'templates.view';
|
||
} else if (href === '/ipam' || href.startsWith('/ipam')) {
|
||
requiredPermission = 'ipam.view';
|
||
} else if (href === '/logs' || href.startsWith('/logs')) {
|
||
requiredPermission = 'logs.view';
|
||
} else if (href === '/settings' || href.startsWith('/settings')) {
|
||
requiredPermission = 'settings.view';
|
||
} else if (href === '/admin/users' || href.startsWith('/admin/users')) {
|
||
requiredPermission = 'users.view';
|
||
}
|
||
|
||
// Hide menu item if no permission
|
||
if (requiredPermission && !window.hasPermission(requiredPermission)) {
|
||
const li = link.closest('li');
|
||
if (li) {
|
||
li.style.display = 'none';
|
||
}
|
||
}
|
||
});
|
||
|
||
// Apply permissions to elements with data-permission attribute
|
||
window.applyPermissions();
|
||
}
|
||
|
||
// 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://git.tzim.uz/api/v1/repos/markmorado/pvemanager', {
|
||
headers: {
|
||
'Accept': 'application/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.stars_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 = '★';
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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();
|
||
}
|
||
|
||
// 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;
|
||
let taskDrawerWS = null;
|
||
let taskDrawerTasks = {};
|
||
let taskDrawerReconnectTimer = null;
|
||
let taskDrawerReconnectDelay = 2000;
|
||
|
||
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:'Выполняется', pending:'В очереди', completed:'Завершено', failed:'Ошибка', 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 || 'Задача';
|
||
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 => { taskDrawerTasks[`${t.kind}_${t.id}`] = t; });
|
||
} else if (msg.type === 'task_update' || msg.type === 'task_created') {
|
||
if (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();
|
||
} 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);
|
||
};
|
||
}
|
||
</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>
|