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

@@ -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() {