Files
pvemanager/backend/app/templates/tasks.html
markmorado 57575d58ce 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
2026-03-16 08:27:36 +01:00

515 lines
25 KiB
HTML

{% extends "base.html" %}
{% 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> {{ 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">{{ t('connecting') }}</span>
</div>
<button class="btn btn-secondary" onclick="TaskManager.refresh()" title="{{ t('refresh') }}">
<i class="fa-solid fa-rotate-right"></i>
</button>
</div>
</div>
<!-- Tab bar -->
<div class="tasks-tab-bar">
<button class="tasks-tab active" data-filter="all" onclick="TaskManager.setFilter('all', this)">
{{ 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>
{{ 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>
{{ 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>
{{ 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>
{{ 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>
{{ t('status_cancelled') }} <span class="tasks-tab-count" id="cnt-cancelled">0</span>
</button>
</div>
<!-- Task list -->
<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;">{{ t('tasks_not_found') }}</p>
</div>
<div id="taskCards"></div>
</div>
<!-- Trace Modal -->
<div class="modal fade" id="traceModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content" style="background:var(--glass-bg); border:1px solid var(--glass-border); border-radius:16px;">
<div class="modal-header" style="border-bottom:1px solid var(--border-default); padding:1.25rem 1.5rem;">
<div>
<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="{{ t('close') }}"></button>
</div>
<div class="modal-body" style="padding:0;">
<!-- Status bar -->
<div id="traceStatusBar" style="padding:.75rem 1.5rem; border-bottom:1px solid var(--border-muted); display:flex; align-items:center; gap:.75rem;">
<span id="traceStatusBadge"></span>
<div class="task-progress-track" style="flex:1;">
<div class="task-progress-fill" id="traceProgressFill" style="width:0%;"></div>
</div>
<span id="traceProgressPct" style="font-size:.78rem; color:var(--text-muted); min-width:35px; text-align:right;">0%</span>
</div>
<!-- Tabs -->
<ul class="nav nav-tabs" style="padding:0 1.5rem; border-bottom:1px solid var(--border-muted); margin:0;">
<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> {{ 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> {{ t('log') }}
</button>
</li>
</ul>
<!-- 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);">{{ 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">{{ 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> {{ 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>
</div>
</div>
<script>
const TaskManager = (() => {
let ws = null;
let tasks = {}; // id -> task object
let currentFilter = 'all';
let openTaskId = null;
let openTaskKind = null; // 'bulk' | 'proxmox'
let traceTab = 'results';
let reconnectTimer = null;
let heartbeatTimer = null;
let reconnectDelay = 2000;
// ── Status helpers ─────────────────────────────────────────────
const STATUS_LABEL = {
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>',
pending: '<i class="fa-solid fa-clock"></i>',
queued: '<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>',
error: '<i class="fa-solid fa-circle-xmark"></i>',
cancelled: '<i class="fa-solid fa-ban"></i>',
canceled: '<i class="fa-solid fa-ban"></i>',
};
function normalizeStatus(s) {
const map = { queued: 'pending', error: 'failed', canceled: 'cancelled' };
return map[s] || s || 'pending';
}
function progressPct(task) {
const s = normalizeStatus(task.status);
if (s === 'completed') return 100;
if (s === 'failed' || s === 'cancelled') return 100;
if (task.kind === 'bulk' && task.total_items > 0) {
return Math.round((task.processed_items / task.total_items) * 100);
}
if (s === 'running') return null; // indeterminate
return 0;
}
function escH(s) {
if (s == null) return '';
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function fmtTime(iso) {
if (!iso) return '—';
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}${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 ────────────────────────────────────────────
function buildCard(task) {
const st = normalizeStatus(task.status);
const pct = progressPct(task);
const indeterminate = (pct === null);
const fillPct = indeterminate ? 60 : pct;
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} ${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>`);
}
meta.push(`<span><i class="fa-regular fa-clock"></i> ${fmtTime(task.created_at)}</span>`);
if (task.completed_at || task.updated_at) meta.push(`<span><i class="fa-solid fa-hourglass-end"></i> ${elapsed(task.created_at, task.completed_at || task.updated_at)}</span>`);
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> ${t('cancel')}
</button>` : '';
return `<div class="task-card ${st}" data-id="${task.id}" data-kind="${task.kind}" onclick="TaskManager.openTrace('${task.kind}','${task.id}')">
<div class="task-card-header">
<span class="task-dot ${st}"></span>
<span class="task-card-title">${escH(desc)}</span>
<span class="task-badge task-badge-${st}">
${STATUS_ICON[st] || ''} ${STATUS_LABEL[st] || st}
</span>
${cancelBtn}
</div>
<div class="task-progress-track">
<div class="task-progress-fill ${st}" style="width:${fillPct}%;"></div>
</div>
<div class="task-card-meta">
${meta.join('')}
</div>
</div>`;
}
// ── Render all cards ───────────────────────────────────────────
function render() {
const counts = { all:0, running:0, pending:0, completed:0, failed:0, cancelled:0 };
const filtered = [];
for (const [, task] of Object.entries(tasks)) {
const st = normalizeStatus(task.status);
counts.all++;
counts[st] = (counts[st] || 0) + 1;
if (currentFilter === 'all' || st === currentFilter) filtered.push(task);
}
// Sort: running first, then pending, then by date desc
const order = ['running','pending','failed','cancelled','completed'];
filtered.sort((a,b) => {
const oa = order.indexOf(normalizeStatus(a.status));
const ob = order.indexOf(normalizeStatus(b.status));
if (oa !== ob) return oa - ob;
return new Date(b.created_at) - new Date(a.created_at);
});
document.getElementById('cnt-all').textContent = counts.all;
document.getElementById('cnt-running').textContent = counts.running || 0;
document.getElementById('cnt-pending').textContent = counts.pending || 0;
document.getElementById('cnt-completed').textContent = counts.completed || 0;
document.getElementById('cnt-failed').textContent = counts.failed || 0;
document.getElementById('cnt-cancelled').textContent = counts.cancelled || 0;
const cardsEl = document.getElementById('taskCards');
const emptyEl = document.getElementById('taskEmpty');
if (filtered.length === 0) {
cardsEl.innerHTML = '';
emptyEl.style.display = 'flex';
emptyEl.style.flexDirection = 'column';
emptyEl.style.alignItems = 'center';
} else {
emptyEl.style.display = 'none';
cardsEl.innerHTML = filtered.map(buildCard).join('');
}
// Update open trace if modal is visible
const modal = document.getElementById('traceModal');
if (modal.classList.contains('show') && openTaskId) {
const t = findTask(openTaskKind, openTaskId);
if (t) refreshTraceModal(t);
}
}
function findTask(kind, id) {
const key = `${kind}_${id}`;
return tasks[key] || null;
}
// ── WebSocket ──────────────────────────────────────────────────
function connect() {
const token = localStorage.getItem('access_token');
if (!token) return;
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
const url = `${proto}://${location.host}/ws/tasks?token=${encodeURIComponent(token)}`;
ws = new WebSocket(url);
setWsStatus('connecting');
ws.onopen = () => {
setWsStatus('connected');
reconnectDelay = 2000;
// heartbeat
heartbeatTimer = setInterval(() => {
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({type:'ping'}));
}, 20000);
};
ws.onmessage = ({data}) => {
try {
const msg = JSON.parse(data);
handleMessage(msg);
} catch {}
};
ws.onerror = () => {};
ws.onclose = () => {
clearInterval(heartbeatTimer);
setWsStatus('disconnected');
reconnectTimer = setTimeout(connect, reconnectDelay);
reconnectDelay = Math.min(reconnectDelay * 2, 30000);
};
}
function handleMessage(msg) {
if (msg.type === 'task_list') {
tasks = {};
(msg.tasks || []).forEach(t => { tasks[`${t.kind}_${t.id}`] = t; });
render();
} else if (msg.type === 'task_update' || msg.type === 'task_created') {
const t = msg.task;
if (t) { tasks[`${t.kind}_${t.id}`] = t; render(); }
} else if (msg.type === 'task_removed') {
const t = msg.task;
if (t) { delete tasks[`${t.kind}_${t.id}`]; render(); }
} else if (msg.type === 'pong') { /* ok */ }
}
function setWsStatus(status) {
const dot = document.getElementById('wsDot');
const txt = document.getElementById('wsStatusText');
const colors = { connected:'#10b981', connecting:'#f97316', disconnected:'#ef4444' };
const labels = { connected: t('connected'), connecting: t('connecting'), disconnected: t('disconnected') };
if (dot) dot.style.background = colors[status];
if (txt) txt.textContent = labels[status];
}
// ── Trace Modal ────────────────────────────────────────────────
function openTrace(kind, id) {
openTaskId = id;
openTaskKind = kind;
const task = findTask(kind, id);
if (!task) return;
refreshTraceModal(task);
const modal = new bootstrap.Modal(document.getElementById('traceModal'));
modal.show();
// If proxmox task and running, fetch fresh log
if (kind === 'proxmox') loadProxmoxLog(id);
}
function refreshTraceModal(task) {
const st = normalizeStatus(task.status);
const desc = task.description || task.action || task.task_type || t('task_default_name');
document.getElementById('traceModalTitle').textContent = desc;
document.getElementById('traceModalMeta').innerHTML =
`${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 =
`<span class="task-badge task-badge-${st}">${STATUS_ICON[st]||''} ${STATUS_LABEL[st]||st}</span>`;
// Progress
const pct = progressPct(task);
const fill = document.getElementById('traceProgressFill');
fill.className = `task-progress-fill ${st}`;
fill.style.width = `${pct === null ? 60 : pct}%`;
document.getElementById('traceProgressPct').textContent = pct === null ? '…' : `${pct}%`;
// Cancel button
const cancelBtn = document.getElementById('traceCancelBtn');
cancelBtn.style.display = (st === 'pending' && task.kind === 'bulk') ? 'inline-flex' : 'none';
// Results tab
renderTraceResults(task);
// Log tab
if (task.kind === 'proxmox') {
document.getElementById('traceLogContent').textContent = task.log_text || t('log_empty');
} else {
renderBulkLog(task);
}
}
function renderTraceResults(task) {
const el = document.getElementById('traceResultsContent');
const st = normalizeStatus(task.status);
if (task.kind === 'proxmox') {
el.innerHTML = `<table class="table task-results-table" style="margin:0;">
<thead><tr>
<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>${t('node_label')}</td><td>${escH(task.node)||'—'}</td></tr>
<tr><td>VM ID</td><td>${task.vmid||'—'}</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') {
const items = task.processed_items || 0;
const total = task.total_items || 0;
const results = task.results || [];
if (results.length === 0) {
el.innerHTML = `<div style="padding:1.5rem; text-align:center; color:var(--text-muted);">
${total > 0 ? t('processed_of').replace('{processed}', items).replace('{total}', total) : t('no_results')}
</div>`;
return;
}
const rows = results.map(r => {
const ok = r.success || r.status === 'success' || r.status === 'completed';
const icon = ok ? '<i class="fa-solid fa-circle-check task-result-ok"></i>' : '<i class="fa-solid fa-circle-xmark task-result-fail"></i>';
return `<tr>
<td>${icon}</td>
<td>${escH(r.vm_name || r.name || r.id || '—')}</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>${t('object_column')}</th><th>${t('message_column')}</th></tr></thead>
<tbody>${rows}</tbody>
</table>`;
}
}
function renderBulkLog(task) {
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(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 || t('log_empty_simple');
}
function switchTraceTab(tab) {
traceTab = tab;
document.getElementById('tabResults').style.display = tab === 'results' ? '' : 'none';
document.getElementById('tabLog').style.display = tab === 'log' ? '' : 'none';
document.getElementById('tabResultsBtn').classList.toggle('active', tab === 'results');
document.getElementById('tabLogBtn').classList.toggle('active', tab === 'log');
// Style active tab
['tabResultsBtn','tabLogBtn'].forEach(id => {
const btn = document.getElementById(id);
const isActive = btn.classList.contains('active');
btn.style.color = isActive ? 'var(--primary)' : 'var(--text-muted)';
btn.style.borderBottom = isActive ? '2px solid var(--primary)' : '2px solid transparent';
});
}
async function loadProxmoxLog(id) {
try {
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 || t('log_empty');
}
} catch {}
}
async function cancelTaskById(kind, id) {
if (kind !== 'bulk') return;
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', 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() {
if (openTaskKind && openTaskId) {
await cancelTaskById(openTaskKind, openTaskId);
bootstrap.Modal.getInstance(document.getElementById('traceModal'))?.hide();
}
}
async function refresh() {
try {
const resp = await fetchWithAuth('/proxmox/api/all-tasks');
if (resp && resp.ok) {
const data = await resp.json();
const list = Array.isArray(data) ? data : (data.tasks || []);
tasks = {};
list.forEach(t => { tasks[`${t.kind}_${t.id}`] = t; });
render();
}
} catch {}
}
function setFilter(f, btn) {
currentFilter = f;
document.querySelectorAll('.tasks-tab').forEach(b => b.classList.remove('active'));
if (btn) btn.classList.add('active');
render();
}
// ── Init ───────────────────────────────────────────────────────
function init() {
connect();
refresh();
}
return { init, refresh, setFilter, openTrace, switchTraceTab, cancelTask, cancelTaskById };
})();
document.addEventListener('DOMContentLoaded', () => {
// Wait for token to be available
const check = () => {
if (localStorage.getItem('access_token')) TaskManager.init();
else setTimeout(check, 300);
};
check();
});
// Style active tab on load
document.addEventListener('DOMContentLoaded', () => {
TaskManager.switchTraceTab('results');
});
</script>
{% endblock %}