- 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
515 lines
25 KiB
HTML
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,'&').replace(/</g,'<').replace(/>/g,'>');
|
|
}
|
|
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 ? ` · ${t('completed_label')}: ${fmtTime(task.completed_at)}` : '') +
|
|
` · ${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 %}
|