feat: v1.1.8 — user-server assignment, VM/LXC ownership, smart login redirect, RBAC migrations

- 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
This commit is contained in:
markmorado
2026-03-16 08:27:36 +01:00
parent c09c0c5b63
commit 57575d58ce
22 changed files with 1436 additions and 314 deletions

View File

@@ -226,6 +226,14 @@
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;
@@ -305,6 +313,10 @@
--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 {
@@ -488,6 +500,100 @@
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">
@@ -495,7 +601,7 @@
<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>
<span id="update-banner-text">{{ t('updating_system') }}</span>
</div>
</div>
@@ -598,6 +704,8 @@ body.update-in-progress .layout {
<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>
@@ -613,48 +721,48 @@ body.update-in-progress .layout {
</div>
<nav>
<ul class="sidebar-nav">
<li><a href="/" class="{{ 'active' if request.path == '/' or request.path == '/dashboard' else '' }}" title="{{ t('nav_dashboard') }}">
<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><a href="/virtual-machines" class="{{ 'active' if request.path.startswith('/virtual-machines') or request.path.startswith('/proxmox/server') else '' }}" title="{{ t('nav_vms') }}">
<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><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') }}">
<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><a href="/templates" class="{{ 'active' if request.path.startswith('/templates') else '' }}" title="{{ t('nav_templates') }}">
<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><a href="/backups" class="{{ 'active' if request.path.startswith('/backups') or request.path.startswith('/proxmox/api/backups/page') else '' }}" title="{{ t('nav_backups') }}">
<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><a href="/ipam" class="{{ 'active' if request.path.startswith('/ipam') else '' }}" title="{{ t('nav_ipam') }}">
<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><a href="/containers" class="{{ 'active' if request.path.startswith('/containers') else '' }}" title="{{ t('nav_docker') }}">
<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><a href="/logs" class="{{ 'active' if request.path.startswith('/logs') else '' }}" title="{{ t('nav_logs') }}">
<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="Менеджер задач">
<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>Задачи</span>
<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><a href="/settings" class="{{ 'active' if request.path.startswith('/settings') else '' }}" title="{{ t('nav_settings') }}">
<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><a href="/admin/users" class="{{ 'active' if request.path.startswith('/admin/users') else '' }}" title="{{ t('nav_users') }}">
<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>
@@ -696,19 +804,6 @@ body.update-in-progress .layout {
{% block page_subtitle %}<div class="page-subtitle" id="pageGlobalSubtitle"></div>{% endblock %}
</div>
<div class="header-actions">
{% block header_status %}{% endblock %}
<!-- Version Badge -->
<span class="version-badge" title="PVEmanager version">v{{ version }}</span>
<!-- GitHub Stars Button -->
<a href="https://github.com/markmorado/pvemanager" target="_blank" rel="noopener noreferrer" class="github-btn" 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="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') }}">
@@ -729,7 +824,7 @@ body.update-in-progress .layout {
</button>
<!-- Task Manager Button -->
<button id="tasksBtn" class="tasks-btn" onclick="toggleTaskDrawer()" title="Менеджер задач">
<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>
@@ -744,11 +839,46 @@ body.update-in-progress .layout {
</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 -->
@@ -773,9 +903,9 @@ body.update-in-progress .layout {
<!-- 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> Задачи</h3>
<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="Очистить завершённые" style="background:none; border:none; color:var(--text-muted); cursor:pointer; padding:.3rem .5rem; border-radius:6px; font-size:.75rem; display:none;">
<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()">
@@ -786,18 +916,47 @@ body.update-in-progress .layout {
<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">
@@ -844,15 +1003,153 @@ body.update-in-progress .layout {
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;
// Permission checking helper function
// 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;
return window.userPermissions[permission] === 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
@@ -879,14 +1176,20 @@ body.update-in-progress .layout {
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) {
// Save current URL as query parameter
const currentUrl = encodeURIComponent(window.location.pathname + window.location.search);
window.location.href = `/login?returnUrl=${currentUrl}`;
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;
}
@@ -912,6 +1215,16 @@ body.update-in-progress .layout {
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();
@@ -920,44 +1233,79 @@ body.update-in-progress .layout {
window.dispatchEvent(new CustomEvent('permissionsLoaded', { detail: { permissions: window.userPermissions, isAdmin: window.isAdmin } }));
} catch (error) {
// On network error, allow staying (offline mode)
// 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() {
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';
}
// --- 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
@@ -1044,7 +1392,7 @@ body.update-in-progress .layout {
const dotEl = document.getElementById('sidebarWorkspaceColorDot');
if (!nameEl) return;
if (!ws) {
nameEl.textContent = 'Все';
nameEl.textContent = t('all_workspaces');
if (dotEl) { dotEl.style.background = '#0ea5e9'; dotEl.style.opacity = '0.4'; }
} else {
nameEl.textContent = ws.name;
@@ -1061,7 +1409,7 @@ body.update-in-progress .layout {
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>Все</span>`;
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);
}
@@ -1069,7 +1417,7 @@ body.update-in-progress .layout {
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} серв.</small>`;
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);
});
@@ -1682,12 +2030,12 @@ body.update-in-progress .layout {
}
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:'Отменено' };
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 || 'Задача';
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">
@@ -1921,7 +2269,7 @@ body.update-in-progress .layout {
}
renderTaskDrawer();
updateTaskBadge();
if (data.deleted > 0) showToast('success', 'Очищено', `Удалено ${data.deleted} задач`);
if (data.deleted > 0) showToast('success', t('cleared'), t('deleted_tasks').replace('{count}', data.deleted));
}
} catch(e) {
console.error('clearCompletedTasks error', e);

View File

@@ -11,7 +11,8 @@
</div>
{% endblock %}
{% block header_status %}
{% block sidebar_status %}
<div class="sidebar-status-section">
<div class="status-chip {% if stats.total_alerts == 0 %}status-ok{% elif stats.critical_alerts > 0 %}status-error{% else %}status-warning{% endif %}" id="dashStatusChip"{% if stats.total_alerts > 0 %} onclick="openAlertsModal()" style="cursor:pointer;" title="{{ t('view_alerts') if t('view_alerts') else 'Посмотреть предупреждения' }}"{% endif %}>
<span class="status-chip-dot"></span>
<span id="dashStatusText">
@@ -24,7 +25,7 @@
{%- endif -%}
</span>
</div>
<div class="header-datetime" id="dashDatetime"></div>
</div>
{% endblock %}
{% block content %}
@@ -201,24 +202,6 @@
<script>
// ======== Dashboard JS ========
// Live clock
(function startClock() {
const el = document.getElementById('dashDatetime');
if (!el) return;
function tick() {
const now = new Date();
el.textContent = now.toLocaleDateString(
'{{ "ru-RU" if lang == "ru" else "en-US" }}',
{ month: 'short', day: 'numeric' }
) + ', ' + now.toLocaleTimeString(
'{{ "ru-RU" if lang == "ru" else "en-US" }}',
{ hour: '2-digit', minute: '2-digit' }
);
}
tick();
setInterval(tick, 30000);
})();
// Colour the % badge based on value
function setPctBadgeColor(el, pct) {
el.classList.remove('pct-ok', 'pct-warn', 'pct-high');

View File

@@ -742,7 +742,7 @@ async function executeCommand(command) {
const outputContent = document.getElementById('command-output-content');
outputDiv.style.display = 'block';
outputContent.innerHTML = `$ ${command}\n<i class="fa-solid fa-spinner fa-spin"></i> Выполняется...\n`;
outputContent.innerHTML = `$ ${command}\n<i class="fa-solid fa-spinner fa-spin"></i> ${t('executing_command')}\n`;
try {
const timeout = 30;
@@ -755,8 +755,8 @@ async function executeCommand(command) {
if (!response.ok) {
const error = await response.text();
outputContent.innerHTML = `$ ${command}\n<i class="fa-solid fa-circle-xmark"></i> Ошибка: ${error}`;
showNotification('Ошибка выполнения команды', 'error');
outputContent.innerHTML = `$ ${command}\n<i class="fa-solid fa-circle-xmark"></i> ${t('error')}: ${error}`;
showNotification(t('command_execution_error'), 'error');
return;
}
@@ -764,8 +764,8 @@ async function executeCommand(command) {
displayCommandOutput(result, command);
} catch (error) {
outputContent.innerHTML = `$ ${command}\n<i class="fa-solid fa-circle-xmark"></i> Ошибка: ${error.message}`;
showNotification('Ошибка выполнения команды', 'error');
outputContent.innerHTML = `$ ${command}\n<i class="fa-solid fa-circle-xmark"></i> ${t('error')}: ${error.message}`;
showNotification(t('command_execution_error'), 'error');
}
}
@@ -799,7 +799,7 @@ function displayCommandOutput(result, command = null) {
output += `\n\nExit code: ${result.exit_code}`;
}
showNotification('Ошибка выполнения', 'error');
showNotification(t('execution_error'), 'error');
}
outputContent.textContent = output;
@@ -952,7 +952,7 @@ function drawCharts(data, timeframe = 'hour') {
labels: labels,
datasets: [
{
label: 'Использовано',
label: t('used'),
data: data.map(d => {
const used = d.mem || d.memused || 0;
return (used / (1024**3)).toFixed(2);
@@ -965,7 +965,7 @@ function drawCharts(data, timeframe = 'hour') {
pointRadius: 0
},
{
label: 'Выделено',
label: t('allocated'),
data: data.map(d => {
const max = d.maxmem || d.memtotal || 1;
return (max / (1024**3)).toFixed(2);
@@ -1171,7 +1171,7 @@ async function deleteInstance() {
// Second confirmation - enter ID
const confirmId = await showPrompt(
t('enter_id_to_confirm').replace('{id}', vmid),
t('confirm_deletion') || 'Подтверждение удаления',
t('confirm_deletion'),
{
type: 'danger',
icon: '<i class="fa-solid fa-triangle-exclamation"></i>',
@@ -1314,25 +1314,25 @@ async function openVNC() {
vncConnection.addEventListener('disconnect', (e) => {
if (e.detail.clean) {
vncContent.innerHTML = '<div style="display: flex; align-items: center; justify-content: center; height: 100%; color: var(--text-secondary);">Соединение закрыто</div>';
vncContent.innerHTML = `<div style="display: flex; align-items: center; justify-content: center; height: 100%; color: var(--text-secondary);">${t('connection_closed')}</div>`;
} else {
vncContent.innerHTML = `
<div style="display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; color: var(--text-secondary); padding: 2rem;">
<div style="font-size: 2rem; margin-bottom: 1rem;"><i class="fa-solid fa-triangle-exclamation"></i></div>
<div style="margin-bottom: 1rem; font-size: 1.125rem;">Соединение прервано</div>
<div style="margin-bottom: 1rem; font-size: 1.125rem;">${t('connection_interrupted')}</div>
<div style="background: var(--bg-tertiary); padding: 1.5rem; border-radius: 8px; max-width: 500px; text-align: left; margin-bottom: 1rem;">
<p style="color: var(--text-muted); font-size: 0.875rem; margin-bottom: 0.5rem;">
<strong>Возможные причины:</strong>
<strong>${t('possible_reasons')}:</strong>
</p>
<ul style="color: var(--text-muted); font-size: 0.8rem; margin-left: 1rem;">
<li>Proxmox VNC требует авторизацию по паролю (не API token)</li>
<li>Добавьте пароль в настройках Proxmox сервера</li>
<li>VM/контейнер может быть выключен</li>
<li>${t('vnc_requires_password')}</li>
<li>${t('vnc_add_password')}</li>
<li>${t('vnc_vm_may_be_off')}</li>
</ul>
</div>
<div style="display: flex; gap: 1rem;">
<button onclick="openVNC()" class="btn btn-primary"><i class="fa-solid fa-rotate"></i> Переподключиться</button>
<a href="https://${vncData.host}:8006" target="_blank" class="btn btn-secondary"><i class="fa-solid fa-globe"></i> Открыть Proxmox</a>
<button onclick="openVNC()" class="btn btn-primary"><i class="fa-solid fa-rotate"></i> ${t('reconnect')}</button>
<a href="https://${vncData.host}:8006" target="_blank" class="btn btn-secondary"><i class="fa-solid fa-globe"></i> ${t('open_proxmox')}</a>
</div>
</div>
`;
@@ -1343,8 +1343,8 @@ async function openVNC() {
vncContent.innerHTML = `
<div style="padding: 2rem; text-align: center;">
<div style="font-size: 2rem; margin-bottom: 1rem;"><i class="fa-solid fa-lock"></i></div>
<div class="text-danger">Ошибка безопасности VNC</div>
<p style="color: var(--text-muted); font-size: 0.875rem; margin-top: 0.5rem;">${e.detail.reason || 'Неизвестная ошибка'}</p>
<div class="text-danger">${t('vnc_security_error')}</div>
<p style="color: var(--text-muted); font-size: 0.875rem; margin-top: 0.5rem;">${e.detail.reason || t('unknown_error')}</p>
</div>
`;
});
@@ -1739,7 +1739,7 @@ function closeEditModal() {
async function showReinstallModal() {
document.getElementById('reinstallModal').style.display = 'flex';
const content = document.getElementById('reinstallContent');
content.innerHTML = '<div style="text-align: center; padding: 2rem; color: var(--text-muted);"><i class="fa-solid fa-spinner fa-spin"></i> Загрузка шаблонов...</div>';
content.innerHTML = `<div style="text-align: center; padding: 2rem; color: var(--text-muted);"><i class="fa-solid fa-spinner fa-spin"></i> ${t('loading')}</div>`;
try {
const [groupsRes, templatesRes] = await Promise.all([
@@ -1754,8 +1754,8 @@ async function showReinstallModal() {
content.innerHTML = `
<div style="text-align: center; padding: 2rem;">
<div style="font-size: 2rem; margin-bottom: 1rem;"><i class="fa-solid fa-cube"></i></div>
<p style="color: var(--text-secondary);">Нет доступных шаблонов для этого сервера</p>
<a href="/templates" class="btn" style="margin-top: 1rem;">Добавить шаблоны</a>
<p style="color: var(--text-secondary);">${t('no_templates_for_server')}</p>
<a href="/templates" class="btn" style="margin-top: 1rem;">${t('add_templates')}</a>
</div>
`;
return;
@@ -1920,22 +1920,22 @@ async function doReinstallInstance(templateId, cloudInitUser, cloudInitPassword,
const stopRes = await fetchWithAuth(`/proxmox/api/${serverId}/${apiType}/${vmid}/stop?node=${instanceNode}`, { method: 'POST' });
if (!stopRes.ok) {
const error = await stopRes.json();
throw new Error(`Ошибка остановки: ${error.detail || stopRes.statusText}`);
throw new Error(`${t('stop_error')}: ${error.detail || stopRes.statusText}`);
}
await new Promise(r => setTimeout(r, 5000));
}
showNotification('<i class="fa-solid fa-spinner fa-spin"></i> Удаление старого инстанса...', 'warning');
showNotification(`<i class="fa-solid fa-spinner fa-spin"></i> ${t('deleting_old_instance')}`, 'warning');
// Удалить (не force, чтобы сохранилось в базу)
const deleteRes = await fetchWithAuth(`/proxmox/api/${serverId}/${apiType}/${vmid}?node=${instanceNode}`, { method: 'DELETE' });
if (!deleteRes.ok) {
const error = await deleteRes.json();
throw new Error(`Ошибка удаления: ${error.detail || deleteRes.statusText}`);
throw new Error(`${t('delete_error')}: ${error.detail || deleteRes.statusText}`);
}
await new Promise(r => setTimeout(r, 5000));
showNotification('<i class="fa-solid fa-spinner fa-spin"></i> Развертывание из шаблона...', 'warning');
showNotification(`<i class="fa-solid fa-spinner fa-spin"></i> ${t('deploying_from_template')}`, 'warning');
// Формируем тело запроса с сохраненной конфигурацией и cloud-init параметрами
const deployData = {
@@ -1976,7 +1976,7 @@ async function doReinstallInstance(templateId, cloudInitUser, cloudInitPassword,
if (response.ok) {
const result = await response.json();
showNotification(`<i class="fa-solid fa-circle-check"></i> Переустановка завершена! VMID: ${result.vmid}`, 'success');
showNotification(`<i class="fa-solid fa-circle-check"></i> ${t('reinstall_completed_vmid').replace('{vmid}', result.vmid)}`, 'success');
closeReinstallModal();
// Перезагружаем страницу через 2 секунды (VMID остался тот же)
setTimeout(() => {
@@ -1991,7 +1991,7 @@ async function doReinstallInstance(templateId, cloudInitUser, cloudInitPassword,
}
} catch (error) {
console.error('Reinstall error:', error);
showNotification(`<i class="fa-solid fa-circle-xmark"></i> Ошибка: ${error.message}`, 'error');
showNotification(`<i class="fa-solid fa-circle-xmark"></i> ${t('error')}: ${error.message}`, 'error');
}
}

View File

@@ -226,10 +226,33 @@
// Save token to localStorage
localStorage.setItem('access_token', data.access_token);
// Redirect to original page or dashboard
const urlParams = new URLSearchParams(window.location.search);
const returnUrl = urlParams.get('returnUrl') ? decodeURIComponent(urlParams.get('returnUrl')) : '/dashboard';
window.location.href = returnUrl;
if (urlParams.get('returnUrl')) {
window.location.href = decodeURIComponent(urlParams.get('returnUrl'));
} else {
// Fetch profile to decide landing page based on actual permissions
try {
const meResp = await fetch('/api/auth/me', { headers: { 'Authorization': `Bearer ${data.access_token}` } });
const me = meResp.ok ? await meResp.json() : {};
const perms = me.permissions || {};
const isAdmin = me.is_admin === true || me.role?.name === 'admin';
if (isAdmin || perms['dashboard:view'] || perms['dashboard.view']) {
window.location.href = '/dashboard';
} else if (perms['vm:view'] || perms['vms.view']) {
window.location.href = '/virtual-machines';
} else if (perms['server:view'] || perms['proxmox.view']) {
window.location.href = '/vms';
} else if (perms['backup:view'] || perms['backups.view']) {
window.location.href = '/backups';
} else if (perms['ipam:view'] || perms['ipam.view']) {
window.location.href = '/ipam';
} else if (perms['log:view'] || perms['logs.view']) {
window.location.href = '/logs';
} else {
window.location.href = '/settings';
}
} catch { window.location.href = '/settings'; }
}
} else {
// Show error
errorMessage.textContent = data.detail || '{{ t('login_error_check_credentials') }}';
@@ -255,11 +278,34 @@
headers: {
'Authorization': `Bearer ${token}`
}
}).then(response => {
}).then(async response => {
if (response.ok) {
const urlParams = new URLSearchParams(window.location.search);
const returnUrl = urlParams.get('returnUrl') ? decodeURIComponent(urlParams.get('returnUrl')) : '/dashboard';
window.location.href = returnUrl;
if (urlParams.get('returnUrl')) {
window.location.href = decodeURIComponent(urlParams.get('returnUrl'));
} else {
try {
const meResp = await fetch('/api/auth/me', { headers: { 'Authorization': `Bearer ${token}` } });
const me = meResp.ok ? await meResp.json() : {};
const perms = me.permissions || {};
const isAdmin = me.is_admin === true || me.role?.name === 'admin';
if (isAdmin || perms['dashboard:view'] || perms['dashboard.view']) {
window.location.href = '/dashboard';
} else if (perms['vm:view'] || perms['vms.view']) {
window.location.href = '/virtual-machines';
} else if (perms['server:view'] || perms['proxmox.view']) {
window.location.href = '/vms';
} else if (perms['backup:view'] || perms['backups.view']) {
window.location.href = '/backups';
} else if (perms['ipam:view'] || perms['ipam.view']) {
window.location.href = '/ipam';
} else if (perms['log:view'] || perms['logs.view']) {
window.location.href = '/logs';
} else {
window.location.href = '/settings';
}
} catch { window.location.href = '/settings'; }
}
}
});
}

View File

@@ -44,21 +44,24 @@
<th class="sortable" data-sort="node" onclick="sortTable('node')">
{{ t('node') or 'Node' }} <i class="fa-solid fa-sort sort-icon"></i>
</th>
<th class="sortable" data-sort="vms" onclick="sortTable('vms')">
<th class="sortable" data-sort="vms" onclick="sortTable('vms')" style="text-align:center;">
VM <i class="fa-solid fa-sort sort-icon"></i>
</th>
<th class="sortable" data-sort="lxc" onclick="sortTable('lxc')">
<th class="sortable" data-sort="lxc" onclick="sortTable('lxc')" style="text-align:center;">
LXC <i class="fa-solid fa-sort sort-icon"></i>
</th>
<th class="sortable" data-sort="status" onclick="sortTable('status')">
<th class="sortable" data-sort="status" onclick="sortTable('status')" style="text-align:center;">
{{ t('status') }} <i class="fa-solid fa-sort sort-icon"></i>
</th>
<th data-sort="uptime" style="text-align:center; white-space:nowrap;">
Uptime
</th>
<th style="text-align: right;">{{ t('actions') }}</th>
</tr>
</thead>
<tbody id="serversTableBody">
<tr id="servers-loading-row">
<td colspan="7" style="text-align:center; color:var(--text-muted); padding:2rem;">
<td colspan="8" style="text-align:center; color:var(--text-muted); padding:2rem;">
<i class="fa-solid fa-spinner fa-spin"></i> {{ t('loading') }}
</td>
</tr>
@@ -317,31 +320,39 @@
<div style="margin-bottom: 1rem; padding: 1rem; background: var(--bg-secondary); border-radius: 0.5rem;">
<div class="checkbox-row">
<label style="display: flex; align-items: center; gap: 0.5rem;">
<input type="checkbox" id="editUsePassword" onchange="toggleEditAuthMethod()">
<span>{{ t('use_password_less_secure') }}</span>
<input type="checkbox" id="editChangeCredentials" onchange="toggleEditCredentialsSection()">
<span>{{ t('change_credentials') or 'Изменить учётные данные' }}</span>
</label>
</div>
</div>
<div id="editTokenFields">
<div class="form-row">
<div>
<label title="TokenID из Proxmox">TokenID * <span style="color: var(--text-secondary); cursor: help;"><i class="fa-solid fa-circle-info"></i></span></label>
<input type="text" id="editTokenName" name="api_token_name" placeholder="mytoken">
</div>
<div>
<label title="Secret из Proxmox">Secret * <span style="color: var(--text-secondary); cursor: help;"><i class="fa-solid fa-circle-info"></i></span></label>
<input type="text" id="editTokenValue" name="api_token_value" placeholder="xxxxxxxx-xxxx-xxxx">
<div id="editCredentialsSection" style="display: none;">
<div style="margin-bottom: 1rem; padding: 1rem; background: var(--bg-secondary); border-radius: 0.5rem;">
<div class="checkbox-row">
<label style="display: flex; align-items: center; gap: 0.5rem;">
<input type="checkbox" id="editUsePassword" onchange="toggleEditAuthMethod()">
<span>{{ t('use_password_less_secure') }}</span>
</label>
</div>
</div>
</div>
<div id="editPasswordField" style="display: none;">
<label>{{ t('password') }} *</label>
<input type="password" id="editPassword" name="password" placeholder="{{ t('password') }}">
</div>
<div style="margin: 1rem 0;">
<div id="editTokenFields">
<div class="form-row">
<div>
<label title="TokenID из Proxmox">TokenID * <span style="color: var(--text-secondary); cursor: help;"><i class="fa-solid fa-circle-info"></i></span></label>
<input type="text" id="editTokenName" name="api_token_name" placeholder="mytoken">
</div>
<div>
<label title="Secret из Proxmox">Secret * <span style="color: var(--text-secondary); cursor: help;"><i class="fa-solid fa-circle-info"></i></span></label>
<input type="text" id="editTokenValue" name="api_token_value" placeholder="xxxxxxxx-xxxx-xxxx">
</div>
</div>
</div>
<div id="editPasswordField" style="display: none;">
<label>{{ t('password') }} *</label>
<input type="password" id="editPassword" name="password" placeholder="{{ t('password') }}">
</div>
<div class="checkbox-row">
<label style="display: flex; align-items: center; gap: 0.5rem;">
<input type="checkbox" id="editVerifySsl" name="verify_ssl">
@@ -717,9 +728,27 @@ function showEditServerModal() {
function closeEditServerModal() {
document.getElementById('editServerModal').style.display = 'none';
document.getElementById('editServerForm').reset();
document.getElementById('editCredentialsSection').style.display = 'none';
document.getElementById('editChangeCredentials').checked = false;
document.getElementById('editTokenFields').style.display = 'block';
document.getElementById('editPasswordField').style.display = 'none';
document.getElementById('editUsePassword').checked = false;
document.getElementById('editTokenName').removeAttribute('required');
document.getElementById('editTokenValue').removeAttribute('required');
document.getElementById('editPassword').removeAttribute('required');
}
function toggleEditCredentialsSection() {
const show = document.getElementById('editChangeCredentials').checked;
document.getElementById('editCredentialsSection').style.display = show ? 'block' : 'none';
if (show) {
document.getElementById('editUsePassword').checked = false;
toggleEditAuthMethod();
} else {
document.getElementById('editTokenName').removeAttribute('required');
document.getElementById('editTokenValue').removeAttribute('required');
document.getElementById('editPassword').removeAttribute('required');
}
}
function toggleEditAuthMethod() {
@@ -761,8 +790,12 @@ async function editServer(serverId) {
document.getElementById('editDescription').value = server.description || '';
// Note: We don't populate token/password fields for security
document.getElementById('editChangeCredentials').checked = false;
document.getElementById('editCredentialsSection').style.display = 'none';
document.getElementById('editUsePassword').checked = false;
toggleEditAuthMethod();
document.getElementById('editTokenName').removeAttribute('required');
document.getElementById('editTokenValue').removeAttribute('required');
document.getElementById('editPassword').removeAttribute('required');
showEditServerModal();
} else {
@@ -790,20 +823,23 @@ async function submitEditServer(event) {
verify_ssl: formData.get('verify_ssl') === 'on'
};
// Only update auth credentials if provided
if (usePassword) {
const password = formData.get('password');
if (password) {
data.use_password = true;
data.password = password;
}
} else {
const tokenName = formData.get('api_token_name');
const tokenValue = formData.get('api_token_value');
if (tokenName && tokenValue) {
data.use_password = false;
data.api_token_name = tokenName;
data.api_token_value = tokenValue;
// Only update auth credentials if user explicitly chose to change them
const changeCredentials = document.getElementById('editChangeCredentials').checked;
if (changeCredentials) {
if (usePassword) {
const password = formData.get('password');
if (password) {
data.use_password = true;
data.password = password;
}
} else {
const tokenName = formData.get('api_token_name');
const tokenValue = formData.get('api_token_value');
if (tokenName && tokenValue) {
data.use_password = false;
data.api_token_name = tokenName;
data.api_token_value = tokenValue;
}
}
}
@@ -888,6 +924,7 @@ async function loadServerStatus(serverId) {
const statusCell = document.getElementById(`server-status-${serverId}`);
const vmsCell = document.getElementById(`server-vms-${serverId}`);
const lxcCell = document.getElementById(`server-lxc-${serverId}`);
const uptimeCell = document.getElementById(`server-uptime-${serverId}`);
const row = document.getElementById(`server-row-${serverId}`);
try {
@@ -915,6 +952,22 @@ async function loadServerStatus(serverId) {
// Update status
statusCell.innerHTML = `<span class="status-badge status-online"><i class="fa-solid fa-circle"></i> Online</span>`;
// Update uptime
if (uptimeCell) {
const totalSeconds = data.node_uptime || 0;
if (totalSeconds > 0) {
const days = Math.floor(totalSeconds / 86400);
const hours = Math.floor((totalSeconds % 86400) / 3600);
const mins = Math.floor((totalSeconds % 3600) / 60);
let uptimeStr = days > 0
? `${days}d ${String(hours).padStart(2,'0')}:${String(mins).padStart(2,'0')}`
: `${String(hours).padStart(2,'0')}:${String(mins).padStart(2,'0')}`;
uptimeCell.innerHTML = `<span style="color:var(--text-primary);">${uptimeStr}</span>`;
} else {
uptimeCell.innerHTML = `<span style="color:var(--text-muted);">—</span>`;
}
}
// Update data attributes for sorting
if (row) {
row.dataset.vms = vmsCount;
@@ -929,6 +982,7 @@ async function loadServerStatus(serverId) {
vmsCell.innerHTML = `<span style="color: var(--text-muted);">—</span>`;
lxcCell.innerHTML = `<span style="color: var(--text-muted);">—</span>`;
statusCell.innerHTML = `<span class="status-badge status-offline"><i class="fa-solid fa-circle-xmark"></i> Offline</span>`;
if (uptimeCell) uptimeCell.innerHTML = `<span style="color:var(--text-muted);">—</span>`;
if (row) row.dataset.status = 'offline';
}
} catch (error) {
@@ -1922,7 +1976,7 @@ async function loadServersTable() {
const tbody = document.getElementById('serversTableBody');
if (!tbody) return;
tbody.innerHTML = `<tr><td colspan="7" style="text-align:center;color:var(--text-muted);padding:2rem;"><i class="fa-solid fa-spinner fa-spin"></i> ${t('loading') || 'Загрузка...'}</td></tr>`;
tbody.innerHTML = `<tr><td colspan="8" style="text-align:center;color:var(--text-muted);padding:2rem;"><i class="fa-solid fa-spinner fa-spin"></i> ${t('loading') || 'Загрузка...'}</td></tr>`;
try {
const resp = await fetchWithAuth('/proxmox/api/servers');
@@ -1930,7 +1984,7 @@ async function loadServersTable() {
const servers = await resp.json();
if (!servers || servers.length === 0) {
tbody.innerHTML = `<tr><td colspan="7" style="text-align:center;color:var(--text-muted);padding:2rem;">
tbody.innerHTML = `<tr><td colspan="8" style="text-align:center;color:var(--text-muted);padding:2rem;">
${t('no_proxmox_servers') || 'Нет серверов.'} <a href="#" onclick="showAddServerModal();return false;" style="color:var(--success);">${t('add_first_server') || 'Добавить первый'}</a>
</td></tr>`;
return;
@@ -1963,6 +2017,9 @@ async function loadServersTable() {
<td id="server-status-${server.id}" style="text-align:center;">
<span class="status-badge status-loading"><i class="fa-solid fa-spinner fa-spin"></i> ${t('loading') || 'Загрузка'}</span>
</td>
<td id="server-uptime-${server.id}" style="text-align:center;color:var(--text-muted);font-size:0.8rem;">
<i class="fa-solid fa-spinner fa-spin"></i>
</td>
<td style="text-align:right;">
<div style="display:flex;gap:0.5rem;justify-content:flex-end;">
<button onclick="testConnection(${server.id})" class="btn btn-sm" style="background:var(--success);color:var(--text-on-success);" title="${t('test') || 'Тест'}">
@@ -1971,10 +2028,10 @@ async function loadServersTable() {
<button onclick="window.location.href='/proxmox/server/${server.id}'" class="btn btn-sm" style="background:var(--info);color:var(--text-on-info);" title="${t('details') || 'Детали'}">
<i class="fa-solid fa-chart-simple"></i> ${t('details') || 'Детали'}
</button>
<button onclick="editServer(${server.id}, ${JSON.stringify(server.name)})" class="btn btn-sm" style="background:var(--warning);color:var(--text-on-warning);" title="${t('edit') || 'Изменить'}" data-permission="proxmox.servers.edit">
<button onclick="editServer(${server.id})" class="btn btn-sm" style="background:var(--warning);color:var(--text-on-warning);" title="${t('edit') || 'Изменить'}" data-permission="proxmox.servers.edit">
<i class="fa-solid fa-pen"></i>
</button>
<button onclick="deleteServer(${server.id}, ${JSON.stringify(server.name)})" class="btn btn-sm btn-delete" title="${t('delete') || 'Удалить'}" data-permission="proxmox.servers.delete">
<button onclick="deleteServer(${server.id}, '${server.name.replace(/'/g, "\\'")}')" class="btn btn-sm btn-delete" title="${t('delete') || 'Удалить'}" data-permission="proxmox.servers.delete">
<i class="fa-solid fa-trash"></i>
</button>
</div>

View File

@@ -3,13 +3,13 @@
{% block content %}
<div class="page-header-block">
<div class="page-header-left">
<h1 class="page-header-title"><i class="fa-solid fa-list-check"></i> Менеджер задач</h1>
<p class="page-header-sub">Все активные и завершённые задачи в реальном времени</p>
<h1 class="page-header-title"><i class="fa-solid fa-list-check"></i> {{ t('task_manager') }}</h1>
<p class="page-header-sub">{{ t('task_manager_subtitle') }}</p>
</div>
<div class="page-header-right">
<div id="wsStatus" style="display:flex; align-items:center; gap:.4rem; font-size:.78rem; color:var(--text-muted);">
<span class="task-dot" id="wsDot" style="background:#6b7280;"></span>
<span id="wsStatusText">Подключение...</span>
<span id="wsStatusText">{{ t('connecting') }}</span>
</div>
<button class="btn btn-secondary" onclick="TaskManager.refresh()" title="{{ t('refresh') }}">
<i class="fa-solid fa-rotate-right"></i>
@@ -20,27 +20,27 @@
<!-- Tab bar -->
<div class="tasks-tab-bar">
<button class="tasks-tab active" data-filter="all" onclick="TaskManager.setFilter('all', this)">
Все <span class="tasks-tab-count" id="cnt-all">0</span>
{{ t('all') }} <span class="tasks-tab-count" id="cnt-all">0</span>
</button>
<button class="tasks-tab" data-filter="running" onclick="TaskManager.setFilter('running', this)">
<span class="task-dot running" style="margin-right:.3rem;"></span>
Выполняется <span class="tasks-tab-count" id="cnt-running">0</span>
{{ t('status_running') }} <span class="tasks-tab-count" id="cnt-running">0</span>
</button>
<button class="tasks-tab" data-filter="pending" onclick="TaskManager.setFilter('pending', this)">
<span class="task-dot pending" style="margin-right:.3rem;"></span>
В очереди <span class="tasks-tab-count" id="cnt-pending">0</span>
{{ t('status_pending') }} <span class="tasks-tab-count" id="cnt-pending">0</span>
</button>
<button class="tasks-tab" data-filter="completed" onclick="TaskManager.setFilter('completed', this)">
<span class="task-dot completed" style="margin-right:.3rem;"></span>
Завершено <span class="tasks-tab-count" id="cnt-completed">0</span>
{{ t('status_completed') }} <span class="tasks-tab-count" id="cnt-completed">0</span>
</button>
<button class="tasks-tab" data-filter="failed" onclick="TaskManager.setFilter('failed', this)">
<span class="task-dot failed" style="margin-right:.3rem;"></span>
Ошибка <span class="tasks-tab-count" id="cnt-failed">0</span>
{{ t('status_failed') }} <span class="tasks-tab-count" id="cnt-failed">0</span>
</button>
<button class="tasks-tab" data-filter="cancelled" onclick="TaskManager.setFilter('cancelled', this)">
<span class="task-dot cancelled" style="margin-right:.3rem;"></span>
Отменено <span class="tasks-tab-count" id="cnt-cancelled">0</span>
{{ t('status_cancelled') }} <span class="tasks-tab-count" id="cnt-cancelled">0</span>
</button>
</div>
@@ -48,7 +48,7 @@
<div id="taskList">
<div class="task-empty-state" id="taskEmpty" style="text-align:center; padding:4rem 2rem; color:var(--text-muted); display:none;">
<i class="fa-solid fa-inbox" style="font-size:2.5rem; margin-bottom:1rem; display:block; opacity:.35;"></i>
<p style="font-size:.95rem;">Задачи не найдены</p>
<p style="font-size:.95rem;">{{ t('tasks_not_found') }}</p>
</div>
<div id="taskCards"></div>
</div>
@@ -62,7 +62,7 @@
<h5 class="modal-title" id="traceModalTitle" style="font-weight:700; color:var(--text-primary); margin:0;"></h5>
<div id="traceModalMeta" style="font-size:.78rem; color:var(--text-muted); margin-top:.25rem;"></div>
</div>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Закрыть"></button>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="{{ t('close') }}"></button>
</div>
<div class="modal-body" style="padding:0;">
<!-- Status bar -->
@@ -79,13 +79,13 @@
<li class="nav-item">
<button class="nav-link active" id="tabResultsBtn" onclick="TaskManager.switchTraceTab('results')"
style="color:var(--text-muted); background:none; border:none; border-bottom:2px solid transparent; padding:.75rem 1rem; font-size:.85rem;">
<i class="fa-solid fa-table-list" style="margin-right:.4rem;"></i> Результаты
<i class="fa-solid fa-table-list" style="margin-right:.4rem;"></i> {{ t('results') }}
</button>
</li>
<li class="nav-item">
<button class="nav-link" id="tabLogBtn" onclick="TaskManager.switchTraceTab('log')"
style="color:var(--text-muted); background:none; border:none; border-bottom:2px solid transparent; padding:.75rem 1rem; font-size:.85rem;">
<i class="fa-solid fa-terminal" style="margin-right:.4rem;"></i> Лог
<i class="fa-solid fa-terminal" style="margin-right:.4rem;"></i> {{ t('log') }}
</button>
</li>
</ul>
@@ -93,22 +93,22 @@
<!-- Results tab -->
<div id="tabResults" style="padding:1.25rem 1.5rem;">
<div id="traceResultsContent">
<div style="text-align:center; padding:2rem; color:var(--text-muted);">Нет данных</div>
<div style="text-align:center; padding:2rem; color:var(--text-muted);">{{ t('no_data') }}</div>
</div>
</div>
<!-- Log tab -->
<div id="tabLog" style="padding:1.25rem 1.5rem; display:none;">
<div class="task-trace-log" id="traceLogContent">Загрузка...</div>
<div class="task-trace-log" id="traceLogContent">{{ t('loading') }}</div>
</div>
</div>
<div class="modal-footer" style="border-top:1px solid var(--border-default); padding:1rem 1.5rem;">
<button id="traceCancelBtn" class="btn btn-sm btn-danger" onclick="TaskManager.cancelTask()" style="display:none;">
<i class="fa-solid fa-ban"></i> Отменить задачу
<i class="fa-solid fa-ban"></i> {{ t('cancel_task') }}
</button>
<button type="button" class="btn btn-sm" data-bs-dismiss="modal"
style="background:var(--bg-surface); border:1px solid var(--border-default); color:var(--text-secondary);">
Закрыть
{{ t('close') }}
</button>
</div>
</div>
@@ -129,9 +129,9 @@ const TaskManager = (() => {
// ── Status helpers ─────────────────────────────────────────────
const STATUS_LABEL = {
running: 'Выполняется', pending: 'В очереди', queued: 'В очереди',
completed: 'Завершено', failed: 'Ошибка', error: 'Ошибка',
cancelled: 'Отменено', canceled: 'Отменено'
running: t('status_running'), pending: t('status_pending'), queued: t('status_pending'),
completed: t('status_completed'), failed: t('status_failed'), error: t('status_failed'),
cancelled: t('status_cancelled'), canceled: t('status_cancelled')
};
const STATUS_ICON = {
running: '<i class="fa-solid fa-circle-notch task-icon-spin"></i>',
@@ -163,16 +163,16 @@ const TaskManager = (() => {
}
function fmtTime(iso) {
if (!iso) return '—';
try { return new Date(iso).toLocaleString('ru-RU', { day:'2-digit', month:'2-digit', year:'2-digit', hour:'2-digit', minute:'2-digit' }); }
try { return new Date(iso).toLocaleString(window.currentLang === 'en' ? 'en-US' : 'ru-RU', { day:'2-digit', month:'2-digit', year:'2-digit', hour:'2-digit', minute:'2-digit' }); }
catch { return iso; }
}
function elapsed(start, end) {
if (!start) return '';
const a = new Date(start), b = end ? new Date(end) : new Date();
const s = Math.floor((b - a) / 1000);
if (s < 60) return `${s}с`;
if (s < 3600) return `${Math.floor(s/60)}м ${s%60}с`;
return `${Math.floor(s/3600)}ч ${Math.floor((s%3600)/60)}м`;
if (s < 60) return `${s}${t('seconds_s')}`;
if (s < 3600) return `${Math.floor(s/60)}${t('minutes_m')} ${s%60}${t('seconds_s')}`;
return `${Math.floor(s/3600)}${t('hours_h')} ${Math.floor((s%3600)/60)}${t('minutes_m')}`;
}
// ── Build card HTML ────────────────────────────────────────────
@@ -181,10 +181,10 @@ const TaskManager = (() => {
const pct = progressPct(task);
const indeterminate = (pct === null);
const fillPct = indeterminate ? 60 : pct;
const desc = task.description || task.action || task.task_type || 'Задача';
const desc = task.description || task.action || task.task_type || t('task_default_name');
const meta = [];
if (task.kind === 'bulk') {
if (task.total_items > 0) meta.push(`<span><i class="fa-solid fa-layer-group"></i> ${task.processed_items||0} / ${task.total_items} объектов</span>`);
if (task.total_items > 0) meta.push(`<span><i class="fa-solid fa-layer-group"></i> ${task.processed_items||0} / ${task.total_items} ${t('objects')}</span>`);
} else {
if (task.vmid) meta.push(`<span><i class="fa-solid fa-server"></i> VM ${task.vmid}</span>`);
if (task.node) meta.push(`<span><i class="fa-solid fa-network-wired"></i> ${escH(task.node)}</span>`);
@@ -194,7 +194,7 @@ const TaskManager = (() => {
const cancelBtn = (st === 'pending' && task.kind === 'bulk') ?
`<button class="btn btn-sm" onclick="event.stopPropagation(); TaskManager.cancelTaskById('${task.kind}','${task.id}')"
style="background:rgba(239,68,68,.12); color:#ef4444; border:1px solid rgba(239,68,68,.3); border-radius:8px; padding:3px 10px; font-size:.75rem;">
<i class="fa-solid fa-ban"></i> Отменить
<i class="fa-solid fa-ban"></i> ${t('cancel')}
</button>` : '';
return `<div class="task-card ${st}" data-id="${task.id}" data-kind="${task.kind}" onclick="TaskManager.openTrace('${task.kind}','${task.id}')">
@@ -318,7 +318,7 @@ const TaskManager = (() => {
const dot = document.getElementById('wsDot');
const txt = document.getElementById('wsStatusText');
const colors = { connected:'#10b981', connecting:'#f97316', disconnected:'#ef4444' };
const labels = { connected:'Подключено', connecting:'Подключение...', disconnected:'Отключено' };
const labels = { connected: t('connected'), connecting: t('connecting'), disconnected: t('disconnected') };
if (dot) dot.style.background = colors[status];
if (txt) txt.textContent = labels[status];
}
@@ -338,12 +338,12 @@ const TaskManager = (() => {
function refreshTraceModal(task) {
const st = normalizeStatus(task.status);
const desc = task.description || task.action || task.task_type || 'Задача';
const desc = task.description || task.action || task.task_type || t('task_default_name');
document.getElementById('traceModalTitle').textContent = desc;
document.getElementById('traceModalMeta').innerHTML =
`Создана: ${fmtTime(task.created_at)}` +
(task.completed_at ? ` &nbsp;·&nbsp; Завершена: ${fmtTime(task.completed_at)}` : '') +
` &nbsp;·&nbsp; Время: ${elapsed(task.created_at, task.completed_at)}`;
`${t('created_label')}: ${fmtTime(task.created_at)}` +
(task.completed_at ? ` &nbsp;·&nbsp; ${t('completed_label')}: ${fmtTime(task.completed_at)}` : '') +
` &nbsp;·&nbsp; ${t('elapsed_label')}: ${elapsed(task.created_at, task.completed_at)}`;
// Status badge
document.getElementById('traceStatusBadge').innerHTML =
@@ -365,7 +365,7 @@ const TaskManager = (() => {
// Log tab
if (task.kind === 'proxmox') {
document.getElementById('traceLogContent').textContent = task.log_text || '(лог пуст)';
document.getElementById('traceLogContent').textContent = task.log_text || t('log_empty');
} else {
renderBulkLog(task);
}
@@ -378,15 +378,15 @@ const TaskManager = (() => {
if (task.kind === 'proxmox') {
el.innerHTML = `<table class="table task-results-table" style="margin:0;">
<thead><tr>
<th>Параметр</th><th>Значение</th>
<th>${t('parameter')}</th><th>${t('value_column')}</th>
</tr></thead>
<tbody>
<tr><td>UPID</td><td style="font-family:monospace; font-size:.78rem;">${escH(task.upid)}</td></tr>
<tr><td>Узел</td><td>${escH(task.node)||'—'}</td></tr>
<tr><td>${t('node_label')}</td><td>${escH(task.node)||'—'}</td></tr>
<tr><td>VM ID</td><td>${task.vmid||'—'}</td></tr>
<tr><td>Тип</td><td>${escH(task.vm_type)||'—'}</td></tr>
<tr><td>Действие</td><td>${escH(task.action)}</td></tr>
<tr><td>Статус выхода</td><td>${task.exit_status ? `<span class="${task.exit_status==='OK'?'task-result-ok':'task-result-fail'}">${escH(task.exit_status)}</span>` : '—'}</td></tr>
<tr><td>${t('type_label')}</td><td>${escH(task.vm_type)||'—'}</td></tr>
<tr><td>${t('action_label')}</td><td>${escH(task.action)}</td></tr>
<tr><td>${t('exit_status')}</td><td>${task.exit_status ? `<span class="${task.exit_status==='OK'?'task-result-ok':'task-result-fail'}">${escH(task.exit_status)}</span>` : '—'}</td></tr>
</tbody>
</table>`;
} else if (task.kind === 'bulk') {
@@ -395,7 +395,7 @@ const TaskManager = (() => {
const results = task.results || [];
if (results.length === 0) {
el.innerHTML = `<div style="padding:1.5rem; text-align:center; color:var(--text-muted);">
${total > 0 ? `Обработано ${items} из ${total} объектов` : 'Нет результатов'}
${total > 0 ? t('processed_of').replace('{processed}', items).replace('{total}', total) : t('no_results')}
</div>`;
return;
}
@@ -405,11 +405,11 @@ const TaskManager = (() => {
return `<tr>
<td>${icon}</td>
<td>${escH(r.vm_name || r.name || r.id || '—')}</td>
<td>${escH(r.message || r.error || (ok ? 'OK' : 'Ошибка'))}</td>
<td>${escH(r.message || r.error || (ok ? 'OK' : t('status_failed')))}</td>
</tr>`;
}).join('');
el.innerHTML = `<table class="table task-results-table" style="margin:0;">
<thead><tr><th width="30"></th><th>Объект</th><th>Сообщение</th></tr></thead>
<thead><tr><th width="30"></th><th>${t('object_column')}</th><th>${t('message_column')}</th></tr></thead>
<tbody>${rows}</tbody>
</table>`;
}
@@ -419,10 +419,10 @@ const TaskManager = (() => {
const items = task.results || [];
const lines = items.map(r => {
const ok = r.success || r.status === 'success' || r.status === 'completed';
const ts = r.timestamp ? new Date(r.timestamp).toLocaleTimeString('ru-RU') : '';
const ts = r.timestamp ? new Date(r.timestamp).toLocaleTimeString(window.currentLang === 'en' ? 'en-US' : 'ru-RU') : '';
return `${ts ? '['+ts+'] ' : ''}${ok?'✓':'✗'} ${r.vm_name||r.name||''}: ${r.message||r.error||''}`;
}).join('\n');
document.getElementById('traceLogContent').textContent = lines || '(пусто)';
document.getElementById('traceLogContent').textContent = lines || t('log_empty_simple');
}
function switchTraceTab(tab) {
@@ -445,20 +445,20 @@ const TaskManager = (() => {
const resp = await fetchWithAuth(`/proxmox/api/proxmox-tasks/${id}/log`);
if (resp && resp.ok) {
const data = await resp.json();
document.getElementById('traceLogContent').textContent = data.log_text || '(лог пуст)';
document.getElementById('traceLogContent').textContent = data.log_text || t('log_empty');
}
} catch {}
}
async function cancelTaskById(kind, id) {
if (kind !== 'bulk') return;
const ok = await showConfirm('Отменить эту задачу?', 'Подтверждение');
const ok = await showConfirm(t('cancel_task_confirm'), t('confirmation'));
if (!ok) return;
try {
const resp = await fetchWithAuth(`/proxmox/api/tasks/${id}/cancel`, { method:'POST' });
if (resp && resp.ok) showToast('success', 'Готово', 'Задача отменена');
else showToast('error', 'Ошибка', 'Не удалось отменить');
} catch { showToast('error', 'Ошибка', 'Ошибка сети'); }
if (resp && resp.ok) showToast('success', t('done'), t('task_cancelled'));
else showToast('error', t('error'), t('failed_to_cancel'));
} catch { showToast('error', t('error'), t('network_error')); }
}
async function cancelTask() {

View File

@@ -295,6 +295,20 @@
</div>
</div>
<!-- Generic Alert Modal -->
<div id="alertModal" style="display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.8); z-index: 3000; align-items: center; justify-content: center;">
<div class="card" style="max-width: 520px; width: 95%; max-height: 85vh; overflow-y: auto;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h3 id="alertModalTitle" style="margin: 0; color: var(--text-primary);"></h3>
<button onclick="closeModal('alertModal')" style="background: none; border: none; color: var(--text-muted); font-size: 1.5rem; cursor: pointer;">&times;</button>
</div>
<div id="alertModalBody" style="color: var(--text-secondary); line-height: 1.5;"></div>
<div style="display: flex; justify-content: flex-end; margin-top: 1.5rem;">
<button class="btn btn-secondary" onclick="closeModal('alertModal')">OK</button>
</div>
</div>
</div>
<!-- Add User Modal -->
<div id="addUserModal" style="display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.8); z-index: 2000; align-items: center; justify-content: center;">
<div class="card" style="max-width: 500px; width: 95%; max-height: 90vh; overflow-y: auto;">
@@ -339,7 +353,7 @@
<!-- Edit User Modal -->
<div id="editUserModal" style="display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.8); z-index: 2000; align-items: center; justify-content: center;">
<div class="card" style="max-width: 500px; width: 95%; max-height: 90vh; overflow-y: auto;">
<div class="card" style="max-width: 560px; width: 95%; max-height: 90vh; overflow-y: auto;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h3 style="margin: 0; color: var(--text-primary);"><i class="fa-solid fa-pen"></i> {{ t('edit_user') }}</h3>
<button onclick="closeModal('editUserModal')" style="background: none; border: none; color: var(--text-muted); font-size: 1.5rem; cursor: pointer;">&times;</button>
@@ -372,10 +386,12 @@
<input type="checkbox" class="form-check-input" name="is_admin" id="editIsAdmin">
<label class="form-check-label" for="editIsAdmin">{{ t('administrator') }}</label>
</div>
<div class="form-check">
<div class="form-check" style="margin-bottom: 1rem;">
<input type="checkbox" class="form-check-input" name="is_active" id="editIsActive">
<label class="form-check-label" for="editIsActive">{{ t('active') }}</label>
</div>
<div style="display: flex; gap: 0.5rem; justify-content: flex-end; margin-top: 1.5rem;">
<button type="button" class="btn btn-secondary" onclick="closeModal('editUserModal')">{{ t('cancel') }}</button>
<button type="submit" class="btn btn-success">{{ t('save') }}</button>
@@ -520,7 +536,7 @@ function switchTab(tabName) {
// Show modals
function showAddUserModal() {
document.getElementById('addUserModal').style.display = 'flex';
loadRolesForSelect('userRoleSelect');
loadRoles();
}
function showAddRoleModal() {
@@ -536,6 +552,12 @@ function closeModal(modalId) {
document.getElementById(modalId).style.display = 'none';
}
function showAlertModal(title, bodyHtml) {
document.getElementById('alertModalTitle').textContent = title;
document.getElementById('alertModalBody').innerHTML = bodyHtml;
document.getElementById('alertModal').style.display = 'flex';
}
// Initialize on page load
document.addEventListener('DOMContentLoaded', function() {
// Wait for permissions to be loaded before initializing
@@ -589,7 +611,7 @@ async function loadUsers() {
<td style="color: var(--text-primary);">${user.full_name || '-'}</td>
<td style="color: var(--text-primary);">${user.email}</td>
<td>
${user.role ? `<span class="badge bg-primary">${user.role.display_name}</span>` : '-'}
${user.role_name ? `<span class="badge bg-primary">${user.role_name}</span>` : '-'}
</td>
<td>
${user.is_active
@@ -1018,7 +1040,7 @@ async function editUser(userId) {
document.getElementById('editRoleSelect').value = user.role_id || '';
document.getElementById('editIsAdmin').checked = user.is_admin;
document.getElementById('editIsActive').checked = user.is_active;
document.getElementById('editUserModal').style.display = 'flex';
} catch (error) {
console.error('Error:', error);
@@ -1044,6 +1066,7 @@ async function deleteUser(userId, username) {
if (response.ok) {
showToast('success', '{{ _("success") }}', '{{ _("user_deleted") }}');
loadUsers();
loadRoles();
} else {
const error = await response.json();
showToast('error', '{{ _("error") }}', error.detail || '{{ _("error") }}');
@@ -1080,6 +1103,7 @@ document.getElementById('addUserForm').addEventListener('submit', async function
closeModal('addUserModal');
showToast('{{ _("user_created") }}', 'success');
loadUsers();
loadRoles();
this.reset();
} else {
const error = await response.json();
@@ -1117,14 +1141,16 @@ document.getElementById('editUserForm').addEventListener('submit', async functio
body: JSON.stringify(data)
});
if (response.ok) {
closeModal('editUserModal');
showToast('{{ _("user_updated") }}', 'success');
loadUsers();
} else {
if (!response.ok) {
const error = await response.json();
showToast(error.detail || '{{ _("error") }}', 'danger');
return;
}
closeModal('editUserModal');
showToast('{{ _("user_updated") }}', 'success');
loadUsers();
loadRoles();
} catch (error) {
console.error('Error:', error);
showToast('{{ _("error") }}', 'danger');

View File

@@ -1731,6 +1731,12 @@ function showVMActions(event, serverId, vmid, type, node, vmName, status) {
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"></polyline><polyline points="23 20 23 14 17 14"></polyline><path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"></path></svg>
<span>{{ t('reinstall') }}</span>
</div>
${window.isAdmin ? `
<div class="vm-context-menu-item" onclick="showAssignOwnerModal(${serverId}, ${vmid}, '${vmName.replace(/'/g, "\\'")}')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path><circle cx="12" cy="7" r="4"></circle></svg>
<span>{{ t('assign_user') or 'Assign User' }}</span>
</div>
` : ''}
<div class="vm-context-menu-item danger" onclick="confirmDeleteVM(${serverId}, ${vmid}, '${type}', '${node}', '${vmName}')" data-permission="vms.delete">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path><line x1="10" y1="11" x2="10" y2="17"></line><line x1="14" y1="11" x2="14" y2="17"></line></svg>
<span>{{ t('delete') }}</span>
@@ -2069,6 +2075,73 @@ function reinstallVM(serverId, vmid, type, node, vmName) {
showReinstallModal(serverId, vmid, type, node, vmName);
}
// ===================== Assign Owner =====================
async function showAssignOwnerModal(serverId, vmid, vmName) {
closeVMContextMenu();
document.getElementById('assignOwnerServerId').value = serverId;
document.getElementById('assignOwnerVmid').value = vmid;
document.getElementById('assignOwnerVmTitle').textContent = vmName;
document.getElementById('assignOwnerCurrentInfo').textContent = '';
document.getElementById('assignOwnerUserSelect').innerHTML = '<option value="">{{ t("loading") or "Loading..." }}</option>';
document.getElementById('assignOwnerModal').style.display = 'flex';
try {
const resp = await fetch(`/api/${serverId}/vm/${vmid}/owner`, {
headers: { 'Authorization': `Bearer ${API_TOKEN}` }
});
if (!resp.ok) throw new Error('Failed to load');
const data = await resp.json();
const select = document.getElementById('assignOwnerUserSelect');
select.innerHTML = '<option value="">— {{ t("no_owner") or "No owner" }} —</option>';
(data.users || []).forEach(u => {
const opt = document.createElement('option');
opt.value = u.id;
opt.textContent = u.username + (u.full_name ? ` (${u.full_name})` : '');
if (u.id === data.owner_id) opt.selected = true;
select.appendChild(opt);
});
if (data.owner) {
document.getElementById('assignOwnerCurrentInfo').textContent =
`{{ t("current_owner") or "Current owner" }}: ${data.owner.username}${data.owner.full_name ? ' (' + data.owner.full_name + ')' : ''}`;
}
} catch (e) {
document.getElementById('assignOwnerUserSelect').innerHTML = '<option value="">Error loading users</option>';
}
}
async function saveAssignOwner() {
const serverId = document.getElementById('assignOwnerServerId').value;
const vmid = document.getElementById('assignOwnerVmid').value;
const rawVal = document.getElementById('assignOwnerUserSelect').value;
const userId = rawVal ? parseInt(rawVal) : null;
const btn = document.getElementById('assignOwnerSaveBtn');
btn.disabled = true;
try {
const resp = await fetch(`/api/${serverId}/vm/${vmid}/owner`, {
method: 'PUT',
headers: { 'Authorization': `Bearer ${API_TOKEN}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: userId })
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
showToast('error', '{{ t("error") or "Error" }}', err.detail || 'Failed to assign');
return;
}
closeAssignOwnerModal();
showToast('success', '{{ t("success") or "Success" }}', '{{ t("owner_updated") or "Owner updated" }}');
} finally {
btn.disabled = false;
}
}
function closeAssignOwnerModal() {
document.getElementById('assignOwnerModal').style.display = 'none';
}
// ========================================================
function confirmDeleteVM(serverId, vmid, type, node, vmName) {
closeVMContextMenu();
@@ -3188,6 +3261,33 @@ window.addEventListener('beforeunload', () => {
});
</script>
<!-- Assign Owner Modal -->
<div id="assignOwnerModal" style="display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.85); z-index: 2100; align-items: center; justify-content: center; padding: 1rem;">
<div class="card" style="max-width: 440px; width: 95%; max-height: 85vh; overflow-y: auto;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.25rem;">
<h3 style="margin: 0; font-size: 1.05rem;">
<svg style="width:1em;height:1em;vertical-align:-0.15em;margin-right:0.4rem;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path><circle cx="12" cy="7" r="4"></circle></svg>
{{ t('assign_user') or 'Assign User' }}
</h3>
<button onclick="closeAssignOwnerModal()" style="background:none;border:none;color:var(--text-muted);font-size:1.4rem;cursor:pointer;line-height:1;">&times;</button>
</div>
<p id="assignOwnerVmTitle" style="color:var(--text-muted);font-size:0.875rem;margin:0 0 1.1rem;"></p>
<div style="margin-bottom:1rem;">
<label style="display:block;margin-bottom:0.4rem;font-size:0.875rem;color:var(--text-secondary);">{{ t('user') or 'User' }}</label>
<select id="assignOwnerUserSelect" class="form-control" style="width:100%;">
<option value="">— {{ t('no_owner') or 'No owner' }} —</option>
</select>
</div>
<div id="assignOwnerCurrentInfo" style="font-size:0.8rem;color:var(--text-muted);margin-bottom:1rem;"></div>
<div style="display:flex;gap:0.5rem;justify-content:flex-end;margin-top:1.25rem;">
<button class="btn btn-secondary" onclick="closeAssignOwnerModal()">{{ t('cancel') }}</button>
<button class="btn btn-primary" onclick="saveAssignOwner()" id="assignOwnerSaveBtn">{{ t('save') }}</button>
</div>
<input type="hidden" id="assignOwnerServerId">
<input type="hidden" id="assignOwnerVmid">
</div>
</div>
<!-- Create VM Modal -->
<div id="createVMModal" style="display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.85); z-index: 2000; align-items: center; justify-content: center; padding: 1rem;">
<div class="card" style="max-width: 700px; width: 95%; max-height: 95vh; overflow-y: auto;">

View File

@@ -13,7 +13,7 @@
</div>
<div class="page-header-right">
<button class="btn btn-success" onclick="showCreateModal()">
<i class="fa-solid fa-plus"></i> Создать Workspace
<i class="fa-solid fa-plus"></i> {{ t('create_workspace') }}
</button>
</div>
</div>
@@ -21,7 +21,7 @@
<!-- Workspace Cards -->
<div id="workspacesGrid" class="ws-grid">
<div class="loading-placeholder">
<i class="fa-solid fa-spinner fa-spin"></i> Загрузка...
<i class="fa-solid fa-spinner fa-spin"></i> {{ t('loading') }}
</div>
</div>
@@ -31,20 +31,20 @@
<div id="wsModal" class="modal-overlay" style="display:none;" onclick="if(event.target===this)closeWsModal()">
<div class="modal-container" style="max-width:600px;">
<div class="modal-header">
<h3 id="wsModalTitle">Создать Workspace</h3>
<h3 id="wsModalTitle">{{ t('create_workspace') }}</h3>
<button class="modal-close" onclick="closeWsModal()">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label>Название <span style="color:var(--danger)">*</span></label>
<label>{{ t('name') }} <span style="color:var(--danger)">*</span></label>
<input type="text" id="wsName" class="form-input" placeholder="Production" maxlength="100">
</div>
<div class="form-group">
<label>Описание</label>
<textarea id="wsDescription" class="form-input" rows="2" placeholder="Описание workspace" maxlength="500"></textarea>
<label>{{ t('description') }}</label>
<textarea id="wsDescription" class="form-input" rows="2" placeholder="{{ t('workspace_desc_placeholder') }}" maxlength="500"></textarea>
</div>
<div class="form-group">
<label>Цвет</label>
<label>{{ t('color') }}</label>
<div style="display:flex;align-items:center;gap:10px;">
<input type="color" id="wsColor" value="#0ea5e9" style="width:48px;height:36px;border:none;background:none;cursor:pointer;padding:0;">
<div class="ws-color-presets">
@@ -59,22 +59,22 @@
</div>
<div class="form-group" id="wsServersGroup">
<label>Серверы</label>
<label>{{ t('servers_label') }}</label>
<div id="wsServersList" class="ws-assign-list">
<div class="loading-placeholder"><i class="fa-solid fa-spinner fa-spin"></i></div>
</div>
</div>
<div class="form-group" id="wsUsersGroup">
<label>Пользователи с доступом</label>
<label>{{ t('users_with_access') }}</label>
<div id="wsUsersList" class="ws-assign-list">
<div class="loading-placeholder"><i class="fa-solid fa-spinner fa-spin"></i></div>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeWsModal()">Отмена</button>
<button class="btn btn-primary" id="wsModalSaveBtn" onclick="saveWorkspace()">Сохранить</button>
<button class="btn btn-secondary" onclick="closeWsModal()">{{ t('cancel') }}</button>
<button class="btn btn-primary" id="wsModalSaveBtn" onclick="saveWorkspace()">{{ t('save') }}</button>
</div>
</div>
</div>
@@ -327,7 +327,7 @@ async function loadWorkspaces() {
const workspaces = await resp.json();
if (workspaces.length === 0) {
grid.innerHTML = '<div class="ws-empty"><i class="fa-solid fa-layer-group" style="font-size:2rem;opacity:0.2;display:block;margin-bottom:0.5rem;"></i>Нет workspaces</div>';
grid.innerHTML = `<div class="ws-empty"><i class="fa-solid fa-layer-group" style="font-size:2rem;opacity:0.2;display:block;margin-bottom:0.5rem;"></i>${t('no_workspaces')}</div>`;
return;
}
@@ -341,8 +341,8 @@ async function loadWorkspaces() {
</div>
<div class="ws-card-desc">${escapeHtml(ws.description || '')}</div>
<div class="ws-card-stats">
<span class="ws-stat"><i class="fa-solid fa-server"></i>${ws.server_count} серверов</span>
<span class="ws-stat"><i class="fa-solid fa-users"></i>${ws.user_count} пользователей</span>
<span class="ws-stat"><i class="fa-solid fa-server"></i>${ws.server_count} ${t('servers_count_sfx')}</span>
<span class="ws-stat"><i class="fa-solid fa-users"></i>${ws.user_count} ${t('users_count_sfx')}</span>
</div>
<div class="ws-card-actions">
<button class="btn btn-sm btn-secondary" onclick="showEditModal(${ws.id})">
@@ -355,7 +355,7 @@ async function loadWorkspaces() {
</div>
`).join('');
} catch (e) {
grid.innerHTML = '<div class="ws-empty">Ошибка загрузки</div>';
grid.innerHTML = `<div class="ws-empty">${t('loading_error')}</div>`;
}
}
@@ -379,7 +379,7 @@ async function loadAssignLists() {
function renderServerCheckboxes(selectedIds = []) {
const container = document.getElementById('wsServersList');
if (_allServers.length === 0) {
container.innerHTML = '<div class="ws-assign-item" style="color:var(--text-muted)">Нет серверов</div>';
container.innerHTML = `<div class="ws-assign-item" style="color:var(--text-muted)">${t('no_servers_available')}</div>`;
return;
}
const selectedSet = new Set(selectedIds);
@@ -396,7 +396,7 @@ function renderServerCheckboxes(selectedIds = []) {
function renderUserCheckboxes(selectedIds = []) {
const container = document.getElementById('wsUsersList');
if (_allUsers.length === 0) {
container.innerHTML = '<div class="ws-assign-item" style="color:var(--text-muted)">Нет пользователей</div>';
container.innerHTML = `<div class="ws-assign-item" style="color:var(--text-muted)">${t('no_users_available')}</div>`;
return;
}
const selectedSet = new Set(selectedIds);
@@ -415,7 +415,7 @@ function renderUserCheckboxes(selectedIds = []) {
// -----------------------------------------------------------------------
async function showCreateModal() {
_editingId = null;
document.getElementById('wsModalTitle').textContent = 'Создать Workspace';
document.getElementById('wsModalTitle').textContent = t('create_workspace');
document.getElementById('wsName').value = '';
document.getElementById('wsDescription').value = '';
document.getElementById('wsColor').value = '#0ea5e9';
@@ -432,7 +432,7 @@ async function showCreateModal() {
// -----------------------------------------------------------------------
async function showEditModal(id) {
_editingId = id;
document.getElementById('wsModalTitle').textContent = 'Редактировать Workspace';
document.getElementById('wsModalTitle').textContent = t('edit_workspace');
try {
const resp = await fetchWithAuth(`/api/workspaces/${id}`);
@@ -451,7 +451,7 @@ async function showEditModal(id) {
document.getElementById('wsModal').style.display = 'flex';
} catch (e) {
showAlertModal('Ошибка', 'Не удалось загрузить данные workspace');
showAlertModal(t('error'), t('failed_to_load_workspace'));
}
}
@@ -466,7 +466,7 @@ function closeWsModal() {
async function saveWorkspace() {
const name = document.getElementById('wsName').value.trim();
if (!name) {
showAlertModal('Ошибка', 'Введите название workspace');
showAlertModal(t('error'), t('workspace_name_required'));
return;
}
@@ -519,13 +519,13 @@ async function saveWorkspace() {
if (typeof loadSidebarWorkspace === 'function') loadSidebarWorkspace();
} else {
const err = await resp.json().catch(() => ({}));
showAlertModal('Ошибка', err.detail || 'Не удалось сохранить');
showAlertModal(t('error'), err.detail || t('failed_to_save'));
}
} catch (e) {
showAlertModal('Ошибка', e.message || 'Произошла ошибка');
showAlertModal(t('error'), e.message || t('error_occurred'));
} finally {
btn.disabled = false;
btn.innerHTML = 'Сохранить';
btn.innerHTML = t('save');
}
}
@@ -534,8 +534,8 @@ async function saveWorkspace() {
// -----------------------------------------------------------------------
async function deleteWorkspace(id, name) {
showConfirmModal(
'Удалить Workspace',
`Удалить workspace «${name}»? Серверы и пользователи не будут удалены.`,
t('delete_workspace'),
t('delete_workspace_confirm').replace('{name}', name),
async () => {
const resp = await fetchWithAuth(`/api/workspaces/${id}`, { method: 'DELETE' });
if (resp && (resp.ok || resp.status === 204)) {
@@ -543,7 +543,7 @@ async function deleteWorkspace(id, name) {
if (typeof loadSidebarWorkspace === 'function') loadSidebarWorkspace();
} else {
const err = await resp.json().catch(() => ({}));
showAlertModal('Ошибка', err.detail || 'Не удалось удалить');
showAlertModal(t('error'), err.detail || t('failed_to_delete'));
}
}
);