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:
@@ -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 ? ` · Завершена: ${fmtTime(task.completed_at)}` : '') +
|
||||
` · Время: ${elapsed(task.created_at, task.completed_at)}`;
|
||||
`${t('created_label')}: ${fmtTime(task.created_at)}` +
|
||||
(task.completed_at ? ` · ${t('completed_label')}: ${fmtTime(task.completed_at)}` : '') +
|
||||
` · ${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() {
|
||||
|
||||
Reference in New Issue
Block a user