feat: v1.1.0 — backup module, IPAM node binding, HA, task queue, theme CSS

- Backup module: full CRUD for storages, backup files, scheduled jobs (APScheduler)
- Proxmox client: storage/backup/restore/HA methods
- IPAM: proxmox_node field, server+node binding for networks and allocations
- Task queue service: improvements and new task types
- Monitoring worker: extended metrics and alerts
- HA support: add/remove VMs from Proxmox HA groups
- tasks.html: new task tracking page
- proxmox_vms.html: major UI update (bulk ops, HA, snapshots)
- backups.html: full backup management UI (3 tabs)
- base.html: sidebar backup link, websocket manager
- theme.css: full CSS variables theme, 395 lines added
- migrations: migration #15 (BackupJob), #16 (proxmox_node)
- RBAC: backup resource permissions
- websocket_manager.py: new module for WS connections
This commit is contained in:
markmorado
2026-03-08 18:29:29 +01:00
parent 2c8becc6d4
commit b3db346cfb
21 changed files with 3676 additions and 93 deletions

View File

@@ -113,13 +113,45 @@
<!-- =================== TAB: SCHEDULE =================== -->
<div id="pane-jobs" class="d-none">
<!-- Нативные расписания Proxmox -->
<div class="card mb-3" id="proxmoxJobsCard" style="display:none!important">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0 text-muted"><i class="fa-solid fa-server me-1"></i>Расписания из Proxmox <span class="badge bg-secondary ms-1">нативные</span></h6>
<button class="btn btn-sm btn-outline-secondary" onclick="loadProxmoxNativeJobs()">
<i class="fa-solid fa-rotate me-1"></i>Обновить
</button>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm table-hover mb-0">
<thead class="table-secondary"><tr>
<th>ID</th>
<th>VMIDs</th>
<th>{{ t('backup_storage_id') }}</th>
<th>Cron</th>
<th>{{ t('backup_mode') }}</th>
<th>{{ t('status') }}</th>
</tr></thead>
<tbody id="proxmoxJobsTbody">
<tr><td colspan="6" class="text-center py-3 text-muted">{{ t('select_server') }}</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">{{ t('backup_jobs_title') }}</h5>
<button class="btn btn-primary btn-sm" onclick="openAddJobModal()">
<i class="fa-solid fa-plus me-1"></i>
{{ t('backup_add_job') }}
</button>
<div class="d-flex gap-2">
<button class="btn btn-outline-secondary btn-sm" onclick="loadProxmoxNativeJobs()" id="btnLoadProxmoxJobs" title="Показать расписания, созданные напрямую в Proxmox">
<i class="fa-solid fa-server me-1"></i>Расписания Proxmox
</button>
<button class="btn btn-primary btn-sm" onclick="openAddJobModal()">
<i class="fa-solid fa-plus me-1"></i>
{{ t('backup_add_job') }}
</button>
</div>
</div>
<div class="card-body p-0">
<div class="table-responsive">
@@ -370,31 +402,120 @@
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="row">
<div class="col-6 mb-3">
<label class="form-label">{{ t('server') }}</label>
<!-- Сервер и нода -->
<div class="row mb-3">
<div class="col-6">
<label class="form-label fw-semibold">{{ t('server') }}</label>
<select id="ajServer" class="form-select" onchange="onJobServerChange(this.value)">
{% for s in servers %}
<option value="{{ s.id }}">{{ s.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-6 mb-3">
<label class="form-label">{{ t('node') }}</label>
<div class="col-6">
<label class="form-label fw-semibold">{{ t('node') }}</label>
<select id="ajNode" class="form-select"></select>
</div>
</div>
<hr class="my-3">
<!-- Построитель расписания -->
<div class="mb-3">
<label class="form-label">VMIDs</label>
<input type="text" id="ajVmids" class="form-control" placeholder='100,101,102 или "all"'>
<div class="form-text">{{ t('backup_vmids_hint') }}</div>
<label class="form-label fw-semibold"><i class="fa-solid fa-clock me-1"></i>Расписание</label>
<div class="btn-group w-100 mb-3" role="group">
<input type="radio" class="btn-check" name="ajSchedType" id="ajSchedHourly" value="hourly" onchange="onSchedTypeChange('hourly')">
<label class="btn btn-outline-secondary" for="ajSchedHourly"><i class="fa-solid fa-stopwatch me-1"></i>Ежечасно</label>
<input type="radio" class="btn-check" name="ajSchedType" id="ajSchedDaily" value="daily" onchange="onSchedTypeChange('daily')" checked>
<label class="btn btn-outline-secondary" for="ajSchedDaily"><i class="fa-solid fa-sun me-1"></i>Ежедневно</label>
<input type="radio" class="btn-check" name="ajSchedType" id="ajSchedWeekly" value="weekly" onchange="onSchedTypeChange('weekly')">
<label class="btn btn-outline-secondary" for="ajSchedWeekly"><i class="fa-solid fa-calendar-week me-1"></i>Еженедельно</label>
<input type="radio" class="btn-check" name="ajSchedType" id="ajSchedMonthly" value="monthly" onchange="onSchedTypeChange('monthly')">
<label class="btn btn-outline-secondary" for="ajSchedMonthly"><i class="fa-solid fa-calendar-days me-1"></i>Ежемесячно</label>
<input type="radio" class="btn-check" name="ajSchedType" id="ajSchedCustom" value="custom" onchange="onSchedTypeChange('custom')">
<label class="btn btn-outline-secondary" for="ajSchedCustom"><i class="fa-solid fa-code me-1"></i>Cron</label>
</div>
<!-- Интервал (ежечасно) -->
<div id="schedHourlyOpts" class="d-none mb-3">
<div class="d-flex align-items-center gap-2">
<span class="text-muted">Каждые</span>
<select id="ajHourlyInterval" class="form-select" style="width:180px" onchange="buildCron()">
<option value="15">15 минут</option>
<option value="30">30 минут</option>
<option value="60">1 час</option>
<option value="120">2 часа</option>
<option value="360">6 часов</option>
<option value="720">12 часов</option>
</select>
</div>
</div>
<!-- Время (ежедневно / еженедельно / ежемесячно) -->
<div id="schedTimeRow" class="d-flex align-items-center gap-2 mb-3">
<span class="text-muted">в</span>
<input type="time" id="ajSchedTime" class="form-control" style="width:140px" value="02:00" oninput="buildCron()">
<span class="text-muted">по UTC</span>
</div>
<!-- Дни недели (еженедельно) -->
<div id="schedWeeklyOpts" class="d-none mb-3">
<label class="form-label small text-muted">Дни недели:</label>
<div class="d-flex gap-2 flex-wrap">
<div class="border rounded px-2 py-1"><input class="form-check-input" type="checkbox" id="wd1" value="1" onchange="buildCron()" checked><label class="form-check-label small fw-semibold ms-1" for="wd1">Пн</label></div>
<div class="border rounded px-2 py-1"><input class="form-check-input" type="checkbox" id="wd2" value="2" onchange="buildCron()"><label class="form-check-label small fw-semibold ms-1" for="wd2">Вт</label></div>
<div class="border rounded px-2 py-1"><input class="form-check-input" type="checkbox" id="wd3" value="3" onchange="buildCron()"><label class="form-check-label small fw-semibold ms-1" for="wd3">Ср</label></div>
<div class="border rounded px-2 py-1"><input class="form-check-input" type="checkbox" id="wd4" value="4" onchange="buildCron()"><label class="form-check-label small fw-semibold ms-1" for="wd4">Чт</label></div>
<div class="border rounded px-2 py-1"><input class="form-check-input" type="checkbox" id="wd5" value="5" onchange="buildCron()"><label class="form-check-label small fw-semibold ms-1" for="wd5">Пт</label></div>
<div class="border rounded px-2 py-1"><input class="form-check-input" type="checkbox" id="wd6" value="6" onchange="buildCron()"><label class="form-check-label small fw-semibold ms-1" for="wd6">Сб</label></div>
<div class="border rounded px-2 py-1"><input class="form-check-input" type="checkbox" id="wd0" value="0" onchange="buildCron()"><label class="form-check-label small fw-semibold ms-1" for="wd0">Вс</label></div>
</div>
</div>
<!-- Число месяца (ежемесячно) -->
<div id="schedMonthlyOpts" class="d-none mb-3">
<div class="d-flex align-items-center gap-2">
<span class="text-muted">Число:</span>
<input type="number" id="ajMonthDay" class="form-control" style="width:90px" value="1" min="1" max="28" oninput="buildCron()">
<span class="text-muted">каждого месяца</span>
</div>
</div>
<!-- Произвольный cron -->
<div id="schedCustomOpts" class="d-none mb-3">
<input type="text" id="ajCronCustom" class="form-control font-monospace" placeholder="0 2 * * *" oninput="buildCron()">
<div class="form-text">{{ t('backup_cron_hint') }}</div>
</div>
<!-- Превью cron -->
<div class="d-flex align-items-center gap-2 p-2 rounded" style="background:var(--bg-surface,rgba(0,0,0,.05))">
<i class="fa-regular fa-calendar-check text-primary"></i>
<span id="ajCronPreview" class="small">Ежедневно в 02:00</span>
<code id="ajCronDisplay" class="ms-auto small text-muted font-monospace">0 2 * * *</code>
</div>
<input type="hidden" id="ajCron" value="0 2 * * *">
</div>
<div class="mb-3">
<label class="form-label">{{ t('backup_storage_id') }}</label>
<select id="ajStorage" class="form-select"></select>
<hr class="my-3">
<!-- Хранилище и VMIDs -->
<div class="row mb-3">
<div class="col-6">
<label class="form-label fw-semibold"><i class="fa-solid fa-database me-1"></i>{{ t('backup_storage_id') }}</label>
<select id="ajStorage" class="form-select"></select>
</div>
<div class="col-6">
<label class="form-label fw-semibold">VMIDs</label>
<input type="text" id="ajVmids" class="form-control" placeholder='100,101 или "all"'>
<div class="form-text">{{ t('backup_vmids_hint') }}</div>
</div>
</div>
<div class="row">
<div class="col-6 mb-3">
<!-- Режим и сжатие -->
<div class="row mb-3">
<div class="col-6">
<label class="form-label">{{ t('backup_mode') }}</label>
<select id="ajMode" class="form-select">
<option value="snapshot">snapshot</option>
@@ -402,47 +523,57 @@
<option value="stop">stop</option>
</select>
</div>
<div class="col-6 mb-3">
<div class="col-6">
<label class="form-label">{{ t('backup_compress') }}</label>
<select id="ajCompress" class="form-select">
<option value="zstd">zstd</option>
<option value="zstd">zstd (быстро + хорошо)</option>
<option value="gzip">gzip</option>
<option value="lzo">lzo</option>
<option value="0">{{ t('none') }}</option>
</select>
</div>
</div>
<hr class="my-3">
<!-- Хранение копий -->
<div class="mb-3">
<label class="form-label">Cron</label>
<input type="text" id="ajCron" class="form-control font-monospace" placeholder="0 2 * * *">
<div class="form-text">{{ t('backup_cron_hint') }}</div>
<label class="form-label fw-semibold"><i class="fa-solid fa-rotate me-1"></i>Хранение копий</label>
<div class="row g-2">
<div class="col-6 col-md-3">
<label class="form-label small text-muted mb-1">keep-last</label>
<input type="number" id="ajKeepLast" class="form-control" value="3" min="0">
</div>
<div class="col-6 col-md-3">
<label class="form-label small text-muted mb-1">keep-daily</label>
<input type="number" id="ajKeepDaily" class="form-control" value="7" min="0">
</div>
<div class="col-6 col-md-3">
<label class="form-label small text-muted mb-1">keep-weekly</label>
<input type="number" id="ajKeepWeekly" class="form-control" value="4" min="0">
</div>
<div class="col-6 col-md-3">
<label class="form-label small text-muted mb-1">keep-monthly</label>
<input type="number" id="ajKeepMonthly" class="form-control" value="6" min="0">
</div>
</div>
<div class="form-text mt-1">{{ t('backup_keep_hint') }}</div>
</div>
<!-- Заметки + Включено -->
<div class="row">
<div class="col-3 mb-2">
<label class="form-label small">keep-last</label>
<input type="number" id="ajKeepLast" class="form-control form-control-sm" value="3" min="0">
<div class="col-9">
<label class="form-label">{{ t('backup_notes') }}</label>
<input type="text" id="ajNotes" class="form-control" placeholder="{{ t('backup_notes_placeholder') }}">
</div>
<div class="col-3 mb-2">
<label class="form-label small">keep-daily</label>
<input type="number" id="ajKeepDaily" class="form-control form-control-sm" value="7" min="0">
</div>
<div class="col-3 mb-2">
<label class="form-label small">keep-weekly</label>
<input type="number" id="ajKeepWeekly" class="form-control form-control-sm" value="4" min="0">
</div>
<div class="col-3 mb-2">
<label class="form-label small">keep-monthly</label>
<input type="number" id="ajKeepMonthly" class="form-control form-control-sm" value="6" min="0">
<div class="col-3 d-flex align-items-end pb-1">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="ajEnabled" checked>
<label class="form-check-label">{{ t('backup_job_enabled') }}</label>
</div>
</div>
</div>
<div class="mb-3">
<label class="form-label">{{ t('backup_notes') }}</label>
<input type="text" id="ajNotes" class="form-control" placeholder="{{ t('backup_notes_placeholder') }}">
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="ajEnabled" checked>
<label class="form-check-label">{{ t('backup_job_enabled') }}</label>
</div>
<input type="hidden" id="ajJobId">
</div>
<div class="modal-footer">
@@ -777,6 +908,45 @@ async function loadJobs() {
} catch(e) { console.error(e); }
}
async function loadProxmoxNativeJobs() {
const card = document.getElementById('proxmoxJobsCard');
card.style.removeProperty('display');
const tbody = document.getElementById('proxmoxJobsTbody');
tbody.innerHTML = '<tr><td colspan="6" class="text-center py-2"><span class="spinner-border spinner-border-sm me-2"></span>Загрузка...</td></tr>';
const servers = serversData || [];
if (!servers.length) {
tbody.innerHTML = '<tr><td colspan="6" class="text-center py-3 text-muted">Нет подключённых серверов</td></tr>';
return;
}
const allRows = [];
await Promise.all(servers.map(async srv => {
try {
const r = await (await fetchWithAuth(`/proxmox/api/backups/proxmox-jobs/${srv.id}`)).json();
(r.jobs || []).forEach(j => allRows.push({ srv, job: j }));
} catch (e) { console.warn(`Failed to load Proxmox jobs for server ${srv.name}:`, e); }
}));
if (!allRows.length) {
tbody.innerHTML = '<tr><td colspan="6" class="text-center py-3 text-muted">Нативных расписаний Proxmox не найдено</td></tr>';
return;
}
tbody.innerHTML = allRows.map(({ srv, job }) => {
const vmids = job.vmid ? String(job.vmid).replace(/,/g, ', ') : '<em class="text-muted">all</em>';
const enabled = job.enabled !== 0 && job.enabled !== '0' && job.enabled !== false;
const statusBadge = enabled
? '<span class="badge bg-success">активно</span>'
: '<span class="badge bg-secondary">отключено</span>';
const mode = job.mode || '-';
return `<tr class="table-light">
<td><span class="badge bg-dark me-1">${srv.name}</span>${job.id || ''}</td>
<td class="font-monospace small">${vmids}</td>
<td>${job.storage || '-'}</td>
<td class="font-monospace small">${job.schedule || '-'}</td>
<td>${mode}</td>
<td>${statusBadge}</td>
</tr>`;
}).join('');
}
async function onJobServerChange(serverId) {
const nodes = await fetchNodes(serverId);
fillNodeSelect('ajNode', nodes);
@@ -793,13 +963,112 @@ async function populateJobStorages(serverId) {
} catch(e) {}
}
// ===== Schedule Builder =====
function onSchedTypeChange(type) {
document.getElementById('schedHourlyOpts').classList.add('d-none');
document.getElementById('schedTimeRow').classList.add('d-none');
document.getElementById('schedWeeklyOpts').classList.add('d-none');
document.getElementById('schedMonthlyOpts').classList.add('d-none');
document.getElementById('schedCustomOpts').classList.add('d-none');
if (type === 'hourly') document.getElementById('schedHourlyOpts').classList.remove('d-none');
else if (type === 'daily') document.getElementById('schedTimeRow').classList.remove('d-none');
else if (type === 'weekly') {
document.getElementById('schedTimeRow').classList.remove('d-none');
document.getElementById('schedWeeklyOpts').classList.remove('d-none');
} else if (type === 'monthly') {
document.getElementById('schedTimeRow').classList.remove('d-none');
document.getElementById('schedMonthlyOpts').classList.remove('d-none');
} else if (type === 'custom') document.getElementById('schedCustomOpts').classList.remove('d-none');
buildCron();
}
function buildCron() {
const type = document.querySelector('input[name="ajSchedType"]:checked')?.value || 'daily';
let cron = '0 2 * * *', preview = '';
if (type === 'hourly') {
const mins = parseInt(document.getElementById('ajHourlyInterval').value);
if (mins < 60) { cron = `*/${mins} * * * *`; preview = `Каждые ${mins} минут`; }
else { const hrs = mins / 60; cron = `0 */${hrs} * * *`; preview = `Каждые ${hrs} ${hrs === 1 ? 'час' : hrs < 5 ? 'часа' : 'часов'}`; }
} else if (type === 'daily') {
const time = document.getElementById('ajSchedTime').value || '02:00';
const [h, m] = time.split(':');
cron = `${parseInt(m)} ${parseInt(h)} * * *`;
preview = `Ежедневно в ${time}`;
} else if (type === 'weekly') {
const time = document.getElementById('ajSchedTime').value || '02:00';
const [h, m] = time.split(':');
const dayNames = { '0': 'Вс', '1': 'Пн', '2': 'Вт', '3': 'Ср', '4': 'Чт', '5': 'Пт', '6': 'Сб' };
const days = ['1','2','3','4','5','6','0'].filter(d => document.getElementById('wd' + d)?.checked);
if (!days.length) { document.getElementById('wd1').checked = true; days.push('1'); }
cron = `${parseInt(m)} ${parseInt(h)} * * ${days.join(',')}`;
preview = `Еженедельно (${days.map(d => dayNames[d]).join(', ')}) в ${time}`;
} else if (type === 'monthly') {
const time = document.getElementById('ajSchedTime').value || '02:00';
const [h, m] = time.split(':');
const day = document.getElementById('ajMonthDay').value || '1';
cron = `${parseInt(m)} ${parseInt(h)} ${day} * *`;
preview = `Ежемесячно ${day}-го в ${time}`;
} else if (type === 'custom') {
cron = (document.getElementById('ajCronCustom').value || '').trim() || '0 2 * * *';
preview = cron;
}
document.getElementById('ajCron').value = cron;
document.getElementById('ajCronDisplay').textContent = cron;
document.getElementById('ajCronPreview').textContent = preview;
}
function parseCronToUI(cron) {
const p = (cron || '').trim().split(/\s+/);
if (p.length !== 5) return { type: 'custom' };
const [min, hour, dom, month, dow] = p;
if (month === '*' && dom === '*' && min.startsWith('*/') && hour === '*' && dow === '*')
return { type: 'hourly', interval: parseInt(min.slice(2)) };
if (month === '*' && dom === '*' && min === '0' && hour.startsWith('*/') && dow === '*')
return { type: 'hourly', interval: parseInt(hour.slice(2)) * 60 };
if (month === '*' && dow === '*' && !/[*\/,]/.test(dom) && !/[*\/]/.test(hour) && !/[*\/]/.test(min))
return { type: 'monthly', time: `${hour.padStart(2,'0')}:${min.padStart(2,'0')}`, day: dom };
if (dom === '*' && month === '*' && dow !== '*' && !/[*\/]/.test(hour) && !/[*\/]/.test(min))
return { type: 'weekly', time: `${hour.padStart(2,'0')}:${min.padStart(2,'0')}`, days: dow.split(',') };
if (dom === '*' && month === '*' && dow === '*' && !/[*\/]/.test(hour) && !/[*\/]/.test(min))
return { type: 'daily', time: `${hour.padStart(2,'0')}:${min.padStart(2,'0')}` };
return { type: 'custom' };
}
function applySchedToUI(info) {
const map = { hourly: 'ajSchedHourly', daily: 'ajSchedDaily', weekly: 'ajSchedWeekly', monthly: 'ajSchedMonthly', custom: 'ajSchedCustom' };
const r = document.getElementById(map[info.type] || 'ajSchedDaily');
if (r) r.checked = true;
if (info.time) document.getElementById('ajSchedTime').value = info.time;
if (info.type === 'hourly' && info.interval) {
const opts = [15, 30, 60, 120, 360, 720];
document.getElementById('ajHourlyInterval').value = opts.reduce((a, b) => Math.abs(b - info.interval) < Math.abs(a - info.interval) ? b : a);
}
if (info.type === 'weekly') {
['0','1','2','3','4','5','6'].forEach(d => {
const cb = document.getElementById('wd' + d);
if (cb) cb.checked = (info.days || []).includes(d);
});
}
if (info.type === 'monthly' && info.day) document.getElementById('ajMonthDay').value = info.day;
if (info.type === 'custom') document.getElementById('ajCronCustom').value = document.getElementById('ajCron').value;
onSchedTypeChange(info.type);
}
async function openAddJobModal() {
document.getElementById('addJobModalTitle').textContent = '{{ t("backup_add_job") }}';
document.getElementById('ajJobId').value = '';
document.getElementById('ajCron').value = '0 2 * * *';
document.getElementById('ajVmids').value = '';
document.getElementById('ajNotes').value = '';
document.getElementById('ajEnabled').checked = true;
document.getElementById('ajKeepLast').value = 3;
document.getElementById('ajKeepDaily').value = 7;
document.getElementById('ajKeepWeekly').value = 4;
document.getElementById('ajKeepMonthly').value = 6;
document.getElementById('ajMode').value = 'snapshot';
document.getElementById('ajCompress').value = 'zstd';
document.getElementById('ajSchedTime').value = '02:00';
document.getElementById('ajSchedDaily').checked = true;
onSchedTypeChange('daily');
const firstServer = serversData[0];
if (firstServer) {
document.getElementById('ajServer').value = firstServer.id;
@@ -825,6 +1094,7 @@ async function openEditJobModal(job) {
document.getElementById('ajKeepMonthly').value = job.keep_monthly;
document.getElementById('ajNotes').value = job.notes || '';
document.getElementById('ajEnabled').checked = job.enabled;
applySchedToUI(parseCronToUI(job.cron_expression));
new bootstrap.Modal(document.getElementById('addJobModal')).show();
}

View File

@@ -578,6 +578,11 @@ body.update-in-progress .layout {
<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="Менеджер задач">
<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 id="sidebarTasksBadge" style="display:none; background:#667eea; 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') }}">
<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>
@@ -643,6 +648,14 @@ body.update-in-progress .layout {
</svg>
</button>
<!-- Task Manager Button -->
<button id="tasksBtn" class="tasks-btn" onclick="toggleTaskDrawer()" title="Менеджер задач">
<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>
<span id="tasksCountBadge" class="tasks-count-badge" style="display:none;">0</span>
</button>
<!-- Notification Bell -->
<button id="notificationBell" class="notification-bell" onclick="toggleNotificationCenter()">
<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">
@@ -676,6 +689,29 @@ body.update-in-progress .layout {
<button onclick="clearAllRead()" class="notification-action-btn">{{ t('clear_all') }}</button>
</div>
</div>
<!-- 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:#667eea; margin-right:.5rem;"></i> Задачи</h3>
<button class="task-drawer-close" onclick="toggleTaskDrawer()">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
<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>
Нет активных задач
</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>
Открыть менеджер задач
</a>
</div>
</div>
<!-- Custom Dialog Container -->
<div id="customDialogOverlay" class="custom-dialog-overlay">
@@ -1316,6 +1352,9 @@ body.update-in-progress .layout {
// Load GitHub stars count
loadGitHubStars();
// Init task drawer WS
initTaskDrawerWS();
}
// Add click handlers for sidebar menu items to update page title
@@ -1335,6 +1374,128 @@ body.update-in-progress .layout {
});
});
});
// ================================================================
// Task Drawer — global WebSocket-based live task indicator
// ================================================================
let taskDrawerOpen = false;
let taskDrawerWS = null;
let taskDrawerTasks = {};
let taskDrawerReconnectTimer = null;
let taskDrawerReconnectDelay = 2000;
function toggleTaskDrawer() {
const drawer = document.getElementById('taskDrawer');
taskDrawerOpen = !taskDrawerOpen;
drawer.style.display = taskDrawerOpen ? 'flex' : 'none';
if (taskDrawerOpen) renderTaskDrawer();
}
// Close drawer when clicking outside
document.addEventListener('click', function(e) {
if (!taskDrawerOpen) return;
const drawer = document.getElementById('taskDrawer');
const btn = document.getElementById('tasksBtn');
if (drawer && btn && !drawer.contains(e.target) && !btn.contains(e.target)) {
taskDrawerOpen = false;
drawer.style.display = 'none';
}
});
function normalizeTaskStatus(s) {
const m = { queued:'pending', error:'failed', canceled:'cancelled' };
return m[s] || s || 'pending';
}
function renderTaskDrawer() {
const cards = document.getElementById('taskDrawerCards');
const empty = document.getElementById('taskDrawerEmpty');
const list = Object.values(taskDrawerTasks)
.sort((a,b) => new Date(b.created_at) - new Date(a.created_at))
.slice(0, 10);
if (list.length === 0) {
cards.innerHTML = '';
empty.style.display = 'block';
return;
}
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:'Отменено' };
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 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">
<span class="task-dot ${st}"></span>
<span class="task-mini-name" title="${desc}">${desc.length>35?desc.slice(0,33)+'…':desc}</span>
<span class="task-mini-time">${ts}</span>
</div>
<div class="task-mini-progress">
<div class="task-progress-track" style="height:4px;">
<div class="task-progress-fill ${st}" style="width:${pct}%;"></div>
</div>
</div>
<div style="display:flex; align-items:center; justify-content:space-between; margin-top:.35rem;">
<span class="task-badge task-badge-${st}" style="font-size:.65rem; padding:2px 7px;">${icons[st]||''} ${labels[st]||st}</span>
${t.kind==='bulk'&&t.total_items>0?`<span style="font-size:.7rem;color:var(--text-muted);">${t.processed_items||0}/${t.total_items}</span>`:''}
</div>
</div>`;
}).join('');
}
function updateTaskBadge() {
const active = Object.values(taskDrawerTasks).filter(t => {
const st = normalizeTaskStatus(t.status);
return st === 'running' || st === 'pending';
}).length;
const badge = document.getElementById('tasksCountBadge');
const sidebar = document.getElementById('sidebarTasksBadge');
if (!badge) return;
if (active > 0) {
badge.textContent = active;
badge.style.display = 'flex';
badge.className = 'tasks-count-badge has-active';
if (sidebar) { sidebar.textContent = active; sidebar.style.display = 'inline-block'; }
} else {
badge.style.display = 'none';
if (sidebar) sidebar.style.display = 'none';
}
}
function initTaskDrawerWS() {
const token = localStorage.getItem('access_token');
if (!token) return;
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
taskDrawerWS = new WebSocket(`${proto}://${location.host}/ws/tasks?token=${encodeURIComponent(token)}`);
taskDrawerWS.onmessage = ({data}) => {
try {
const msg = JSON.parse(data);
if (msg.type === 'task_list') {
taskDrawerTasks = {};
(msg.tasks||[]).forEach(t => { taskDrawerTasks[`${t.kind}_${t.id}`] = t; });
} else if (msg.type === 'task_update' || msg.type === 'task_created') {
if (msg.task) taskDrawerTasks[`${msg.task.kind}_${msg.task.id}`] = msg.task;
} else if (msg.type === 'task_removed') {
if (msg.task) delete taskDrawerTasks[`${msg.task.kind}_${msg.task.id}`];
}
updateTaskBadge();
if (taskDrawerOpen) renderTaskDrawer();
} catch {}
};
taskDrawerWS.onclose = () => {
taskDrawerReconnectTimer = setTimeout(initTaskDrawerWS, taskDrawerReconnectDelay);
taskDrawerReconnectDelay = Math.min(taskDrawerReconnectDelay * 2, 30000);
};
taskDrawerWS.onopen = () => {
taskDrawerReconnectDelay = 2000;
setInterval(() => {
if (taskDrawerWS && taskDrawerWS.readyState === WebSocket.OPEN) taskDrawerWS.send(JSON.stringify({type:'ping'}));
}, 20000);
};
}
</script>
<!-- Bootstrap 5 JS Bundle -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL" crossorigin="anonymous"></script>

View File

@@ -5,8 +5,26 @@
<div class="card">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h3><i class="fa-solid fa-computer"></i> {{ t('proxmox_servers') }}</h3>
<div style="display: flex; gap: 0.5rem;" data-permission="proxmox.servers.add">
<button onclick="showAddServerModal()" class="btn btn-primary"><i class="fa-solid fa-plus"></i> {{ t('add_server') }}</button>
<div style="display: flex; gap: 0.5rem; align-items: center;">
<div data-permission="proxmox.cluster.manage" style="position: relative;">
<button onclick="toggleClusterDropdown(event)" id="clusterMenuBtn" class="btn" style="background: var(--bg-tertiary); color: var(--text-primary);">
<i class="fa-solid fa-network-wired"></i> Кластер <i class="fa-solid fa-caret-down" style="font-size: 0.75rem; margin-left: 2px;"></i>
</button>
<div id="clusterDropdown" class="cluster-dropdown" style="display: none;">
<button onclick="showCreateClusterModal(); closeClusterDropdown();" class="cluster-dropdown-item">
<i class="fa-solid fa-circle-plus" style="color: var(--success);"></i> Создать кластер
</button>
<button onclick="showJoinClusterModal(); closeClusterDropdown();" class="cluster-dropdown-item">
<i class="fa-solid fa-link" style="color: var(--info);"></i> Присоединить к кластеру
</button>
<button onclick="showEjectNodeModal(); closeClusterDropdown();" class="cluster-dropdown-item">
<i class="fa-solid fa-right-from-bracket" style="color: var(--danger);"></i> Исключить ноду
</button>
</div>
</div>
<div data-permission="proxmox.servers.add">
<button onclick="showAddServerModal()" class="btn btn-primary"><i class="fa-solid fa-plus"></i> {{ t('add_server') }}</button>
</div>
</div>
</div>
@@ -41,6 +59,9 @@
<tr id="server-row-{{ server.id }}" data-server-id="{{ server.id }}" data-name="{{ server.name|lower }}" data-ip="{{ server.ip_address }}" data-node="{{ server.hostname or '' }}" data-vms="0" data-lxc="0" data-status="loading">
<td>
<div style="font-weight: 600; color: var(--text-primary);">{{ server.name }}</div>
{% if server.cluster_name %}
<span class="cluster-badge"><i class="fa-solid fa-network-wired"></i> {{ server.cluster_name }}</span>
{% endif %}
{% if server.description %}
<div style="font-size: 0.75rem; color: var(--text-muted);">{{ server.description }}</div>
{% endif %}
@@ -173,6 +194,52 @@
background: var(--bg-surface-hover);
color: var(--text-muted);
}
.cluster-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
background: rgba(56, 189, 248, 0.1);
color: var(--info);
border: 1px solid rgba(56, 189, 248, 0.4);
border-radius: 0.375rem;
font-size: 0.7rem;
font-weight: 600;
padding: 0.125rem 0.5rem;
margin-top: 0.25rem;
}
.cluster-dropdown {
position: absolute;
top: calc(100% + 4px);
right: 0;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 0.5rem;
min-width: 215px;
box-shadow: 0 4px 16px rgba(0,0,0,0.35);
z-index: 200;
overflow: hidden;
}
.cluster-dropdown-item {
display: flex;
align-items: center;
gap: 0.6rem;
width: 100%;
padding: 0.7rem 1rem;
background: none;
border: none;
color: var(--text-primary);
cursor: pointer;
text-align: left;
font-size: 0.875rem;
transition: background 0.15s;
}
.cluster-dropdown-item:hover {
background: var(--bg-hover);
}
</style>
<!-- Modal для добавления сервера -->
@@ -343,6 +410,245 @@
</div>
</div>
<!-- ==================== Cluster: Create Cluster Modal ==================== -->
<div id="createClusterModal" style="display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.7); z-index: 1000; align-items: center; justify-content: center;">
<div class="card" style="max-width: 520px; width: 90%; max-height: 90vh; overflow-y: auto; margin: 2rem;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
<h3><i class="fa-solid fa-network-wired" style="color: var(--success);"></i> Создать кластер Proxmox</h3>
<button onclick="closeCreateClusterModal()" style="background: none; border: none; color: var(--text-secondary); font-size: 1.5rem; cursor: pointer;">&times;</button>
</div>
<form id="createClusterForm" onsubmit="submitCreateCluster(event)">
<div style="margin-bottom: 1rem;">
<label>Сервер (нода для создания кластера) *</label>
<select id="createClusterServerId" name="server_id" required style="width: 100%; padding: 0.6rem; background: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border-color); border-radius: 0.5rem;">
<option value="">-- Выберите сервер --</option>
{% for s in proxmox_servers %}{% if not s.cluster_name %}
<option value="{{ s.id }}">{{ s.name }} ({{ s.ip_address }})</option>
{% endif %}{% endfor %}
</select>
</div>
<div style="margin-bottom: 1rem;">
<label>Имя кластера *</label>
<input type="text" id="createClusterName" name="cluster_name" required
placeholder="mycluster" pattern="[a-zA-Z][a-zA-Z0-9\-]{0,14}"
title="Начинается с буквы, буквы/цифры/дефис, макс 15 символов"
style="width: 100%; padding: 0.6rem; background: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border-color); border-radius: 0.5rem; box-sizing: border-box;">
<small style="color: var(--text-muted);">Начинается с буквы, только буквы/цифры/дефис, макс. 15 символов</small>
</div>
<div style="margin-bottom: 1rem;">
<label>Corosync link0 IP <span style="color: var(--text-muted);">(опционально)</span></label>
<input type="text" id="createClusterLink0" name="link0_ip"
placeholder="192.168.1.101 (по умолчанию — IP сервера)"
style="width: 100%; padding: 0.6rem; background: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border-color); border-radius: 0.5rem; box-sizing: border-box;">
</div>
<div style="margin-bottom: 1.5rem;">
<label>Пароль root@pam <span style="color: var(--text-muted);">(если не сохранён в панели)</span></label>
<input type="password" id="createClusterRootpw" name="rootpw"
placeholder="Пароль для кластерных операций"
style="width: 100%; padding: 0.6rem; background: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border-color); border-radius: 0.5rem; box-sizing: border-box;">
</div>
{% if proxmox_servers | length <= 2 %}
<div style="background: rgba(234, 179, 8, 0.1); border: 1px solid var(--warning); border-radius: 0.5rem; padding: 1rem; margin-bottom: 1.5rem;">
<div style="display: flex; align-items: start; gap: 0.75rem;">
<i class="fa-solid fa-triangle-exclamation" style="color: var(--warning); margin-top: 2px;"></i>
<div style="font-size: 0.875rem; color: var(--text-primary);">
<strong>Кластер из 2 нод</strong><br>
В 2-нодовых кластерах нет чёткого кворума — при отказе одной ноды оставшаяся не сможет запускать VM.
Рекомендуется добавить 3-ю ноду или настроить <strong>QDevice</strong> (<code>pvecm qdevice setup</code>).
Временное восстановление: <code>pvecm expected 1</code>.
</div>
</div>
</div>
{% endif %}
<div id="createClusterStatus" style="display: none; margin-bottom: 1rem; padding: 1rem; border-radius: 0.5rem;"></div>
<div style="display: flex; gap: 0.5rem; justify-content: flex-end;">
<button type="button" onclick="closeCreateClusterModal()" class="btn" style="background: var(--bg-tertiary); color: var(--text-primary);">Отмена</button>
<button type="submit" id="createClusterBtn" class="btn" style="background: var(--success); color: var(--text-on-success);">
<i class="fa-solid fa-circle-plus"></i> Создать кластер
</button>
</div>
</form>
</div>
</div>
<!-- ==================== Cluster: Join Cluster Modal (4-step) ==================== -->
<div id="joinClusterModal" style="display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.7); z-index: 1000; align-items: center; justify-content: center;">
<div class="card" style="max-width: 580px; width: 90%; max-height: 90vh; overflow-y: auto; margin: 2rem;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
<div>
<h3 style="margin: 0;"><i class="fa-solid fa-link" style="color: var(--info);"></i> Присоединить к кластеру</h3>
<small id="joinStepTitle" style="color: var(--text-muted);">Шаг 1 из 4: Выбор нод</small>
</div>
<button onclick="closeJoinClusterModal()" style="background: none; border: none; color: var(--text-secondary); font-size: 1.5rem; cursor: pointer;">&times;</button>
</div>
<!-- Warning: no cluster servers -->
<div id="joinNoClusterWarning" style="display: none; background: rgba(234, 179, 8, 0.1); border: 1px solid var(--warning); border-radius: 0.5rem; padding: 0.75rem; margin-bottom: 1rem; font-size: 0.875rem;">
<i class="fa-solid fa-triangle-exclamation" style="color: var(--warning);"></i>
Нет серверов с активным кластером. Сначала <strong>создайте кластер</strong> на одном из серверов.
</div>
<!-- Step 1: Select nodes -->
<div id="joinStep1">
<div style="background: rgba(56, 189, 248, 0.1); border: 1px solid var(--info); border-radius: 0.5rem; padding: 0.75rem; margin-bottom: 1.2rem; font-size: 0.875rem;">
<i class="fa-solid fa-circle-info" style="color: var(--info);"></i>
Выберите ноду для добавления в кластер и любую существующую ноду кластера.
</div>
<form id="joinStep1Form" onsubmit="submitJoinStep1(event)">
<div style="margin-bottom: 1rem;">
<label>Добавляемая нода (standalone) *</label>
<select id="joinNodeServerId" required style="width: 100%; padding: 0.6rem; background: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border-color); border-radius: 0.5rem;">
<option value="">-- Выберите ноду для добавления --</option>
{% for s in proxmox_servers %}{% if not s.cluster_name %}
<option value="{{ s.id }}">{{ s.name }} ({{ s.ip_address }})</option>
{% endif %}{% endfor %}
</select>
</div>
<div style="margin-bottom: 1.5rem;">
<label>Кластерная нода (источник join-информации) *</label>
<select id="joinClusterServerId" required style="width: 100%; padding: 0.6rem; background: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border-color); border-radius: 0.5rem;">
<option value="">-- Выберите ноду кластера --</option>
{% for s in proxmox_servers %}{% if s.cluster_name %}
<option value="{{ s.id }}" data-cluster="{{ s.cluster_name }}">{{ s.name }} (кластер: {{ s.cluster_name }})</option>
{% endif %}{% endfor %}
</select>
</div>
<div style="display: flex; gap: 0.5rem; justify-content: flex-end;">
<button type="button" onclick="closeJoinClusterModal()" class="btn" style="background: var(--bg-tertiary); color: var(--text-primary);">Отмена</button>
<button type="submit" id="joinStep1Btn" class="btn btn-primary">Далее <i class="fa-solid fa-arrow-right"></i></button>
</div>
</form>
</div>
<!-- Step 2: Pre-check results -->
<div id="joinStep2" style="display: none;">
<div id="joinPreCheckResult"></div>
<div id="joinPrepareSection" style="display: none; margin-top: 0.5rem;">
<div style="margin-bottom: 1rem;">
<label>Хранилище для резервных копий *</label>
<select id="joinBackupStorage" style="width: 100%; padding: 0.6rem; background: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border-color); border-radius: 0.5rem;">
<option value="">-- Загрузка хранилищ... --</option>
</select>
<small style="color: var(--text-muted);">Рекомендуется общее хранилище (NFS, Ceph) — доступное в будущем из кластера.</small>
</div>
</div>
<div style="display: flex; gap: 0.5rem; justify-content: flex-end; margin-top: 1rem;">
<button onclick="showJoinStep(1)" class="btn" style="background: var(--bg-tertiary); color: var(--text-primary);">
<i class="fa-solid fa-arrow-left"></i> Назад
</button>
<button id="joinSkipToStep4Btn" onclick="joinSkipToFinalStep()" class="btn btn-primary" style="display: none;">
Продолжить <i class="fa-solid fa-arrow-right"></i>
</button>
<button id="joinStep2PrepBtn" onclick="submitJoinPrepare()" class="btn" style="display: none; background: var(--warning); color: var(--bg-primary);">
<i class="fa-solid fa-hard-drive"></i> Бэкап и очистка ноды
</button>
</div>
</div>
<!-- Step 3: Backup progress -->
<div id="joinStep3" style="display: none;">
<div style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1rem; color: var(--text-secondary); font-size: 0.875rem;">
<i class="fa-solid fa-hard-drive"></i>
Резервное копирование и очистка гестов перед присоединением к кластеру
</div>
<div id="joinPrepResult"></div>
<div style="display: flex; gap: 0.5rem; justify-content: flex-end; margin-top: 1rem;">
<button id="joinPrepContinueBtn" onclick="showJoinStep(4)" class="btn btn-primary" style="display: none;">
Продолжить <i class="fa-solid fa-arrow-right"></i>
</button>
</div>
</div>
<!-- Step 4: Final join -->
<div id="joinStep4" style="display: none;">
<div style="background: rgba(234, 179, 8, 0.1); border: 1px solid var(--warning); border-radius: 0.5rem; padding: 0.75rem; margin-bottom: 1.2rem; font-size: 0.875rem;">
<i class="fa-solid fa-triangle-exclamation" style="color: var(--warning);"></i>
После присоединения нода синхронизирует конфигурацию с кластером. Операция необратима.
</div>
<form onsubmit="submitJoinFinal(event)">
<div style="margin-bottom: 1rem;">
<label>Пароль root@pam присоединяемой ноды *</label>
<input type="password" id="joinRootpw" required placeholder="root-пароль"
style="width: 100%; padding: 0.6rem; background: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border-color); border-radius: 0.5rem; box-sizing: border-box;">
</div>
<div style="margin-bottom: 1.5rem;">
<label>Corosync link0 IP <span style="color: var(--text-muted);">(опционально)</span></label>
<input type="text" id="joinLink0" placeholder="IP присоединяемой ноды для corosync"
style="width: 100%; padding: 0.6rem; background: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border-color); border-radius: 0.5rem; box-sizing: border-box;">
</div>
<div id="joinFinalStatus" style="display: none; margin-bottom: 1rem; padding: 1rem; border-radius: 0.5rem;"></div>
<div style="display: flex; gap: 0.5rem; justify-content: flex-end;">
<button type="button"
onclick="showJoinStep(joinState.preCheck && joinState.preCheck.guests_count > 0 ? 3 : 2)"
class="btn" style="background: var(--bg-tertiary); color: var(--text-primary);">
<i class="fa-solid fa-arrow-left"></i> Назад
</button>
<button type="submit" id="joinFinalBtn" class="btn" style="background: var(--success); color: var(--text-on-success);">
<i class="fa-solid fa-link"></i> Присоединить к кластеру
</button>
</div>
</form>
</div>
</div>
</div>
<!-- ==================== Cluster: Eject Node Modal ==================== -->
<div id="ejectNodeModal" style="display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.7); z-index: 1000; align-items: center; justify-content: center;">
<div class="card" style="max-width: 500px; width: 90%; max-height: 90vh; overflow-y: auto; margin: 2rem;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
<h3><i class="fa-solid fa-right-from-bracket" style="color: var(--danger);"></i> Исключить ноду из кластера</h3>
<button onclick="closeEjectNodeModal()" style="background: none; border: none; color: var(--text-secondary); font-size: 1.5rem; cursor: pointer;">&times;</button>
</div>
<div style="background: rgba(239, 68, 68, 0.1); border: 1px solid var(--danger); border-radius: 0.5rem; padding: 1rem; margin-bottom: 1.5rem; font-size: 0.875rem;">
<i class="fa-solid fa-triangle-exclamation" style="color: var(--danger);"></i>
<strong> Внимание!</strong> Исключаемая нода должна быть <strong>полностью выключена</strong> до запуска операции.
После исключения на ней необходимо вручную сбросить конфигурацию кластера.
</div>
<form id="ejectNodeForm" onsubmit="submitEjectNode(event)">
<div style="margin-bottom: 1rem;">
<label>Управляющий сервер (остаётся в кластере) *</label>
<select id="ejectFromServerId" required style="width: 100%; padding: 0.6rem; background: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border-color); border-radius: 0.5rem;">
<option value="">-- Выберите кластерный сервер --</option>
{% for s in proxmox_servers %}{% if s.cluster_name %}
<option value="{{ s.id }}" data-cluster="{{ s.cluster_name }}">{{ s.name }} (кластер: {{ s.cluster_name }})</option>
{% endif %}{% endfor %}
</select>
</div>
<div style="margin-bottom: 1rem;">
<label>Hostname ноды для исключения *</label>
<input type="text" id="ejectNodeName" required placeholder="pve2 (hostname как в pvecm status)"
style="width: 100%; padding: 0.6rem; background: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border-color); border-radius: 0.5rem; box-sizing: border-box;">
<small style="color: var(--text-muted);">Hostname как в <code>pvecm status</code>, не IP-адрес</small>
</div>
<div style="margin-bottom: 1.5rem;">
<label>Пароль root@pam <span style="color: var(--text-muted);">(для SSH fallback)</span></label>
<input type="password" id="ejectRootpw" placeholder="Используется при ошибке REST API"
style="width: 100%; padding: 0.6rem; background: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border-color); border-radius: 0.5rem; box-sizing: border-box;">
</div>
<div id="ejectNodeStatus" style="display: none; margin-bottom: 1rem; padding: 1rem; border-radius: 0.5rem;"></div>
<div style="display: flex; gap: 0.5rem; justify-content: flex-end;">
<button type="button" onclick="closeEjectNodeModal()" class="btn" style="background: var(--bg-tertiary); color: var(--text-primary);">Отмена</button>
<button type="submit" id="ejectNodeBtn" class="btn" style="background: var(--danger); color: white;">
<i class="fa-solid fa-right-from-bracket"></i> Исключить ноду
</button>
</div>
</form>
</div>
</div>
<div id="stats-section" style="display: none;">
<div class="stats-grid">
<div class="stat-card">
@@ -2136,6 +2442,464 @@ function closeCreateVMModal() {
const status = document.getElementById('createVMStatus');
if (status) status.style.display = 'none';
}
// ==================== Cluster Dropdown ====================
function toggleClusterDropdown(event) {
event.stopPropagation();
const dropdown = document.getElementById('clusterDropdown');
dropdown.style.display = dropdown.style.display === 'none' ? 'block' : 'none';
}
function closeClusterDropdown() {
const dropdown = document.getElementById('clusterDropdown');
if (dropdown) dropdown.style.display = 'none';
}
document.addEventListener('click', function(e) {
const btn = document.getElementById('clusterMenuBtn');
const dropdown = document.getElementById('clusterDropdown');
if (dropdown && btn && !btn.contains(e.target) && !dropdown.contains(e.target)) {
dropdown.style.display = 'none';
}
});
// ==================== Create Cluster ====================
function showCreateClusterModal() {
document.getElementById('createClusterModal').style.display = 'flex';
document.getElementById('createClusterStatus').style.display = 'none';
}
function closeCreateClusterModal() {
document.getElementById('createClusterModal').style.display = 'none';
document.getElementById('createClusterForm').reset();
document.getElementById('createClusterStatus').style.display = 'none';
}
async function submitCreateCluster(event) {
event.preventDefault();
const btn = document.getElementById('createClusterBtn');
const originalText = btn.innerHTML;
btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Создание...';
btn.disabled = true;
const data = {
server_id: parseInt(document.getElementById('createClusterServerId').value),
cluster_name: document.getElementById('createClusterName').value.trim(),
link0_ip: document.getElementById('createClusterLink0').value.trim() || null,
rootpw: document.getElementById('createClusterRootpw').value || null,
};
const statusEl = document.getElementById('createClusterStatus');
statusEl.style.display = 'block';
statusEl.style.background = 'var(--bg-tertiary)';
statusEl.style.border = '1px solid var(--border-color)';
statusEl.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Создание кластера...';
try {
const response = await fetchWithAuth('/proxmox/api/cluster/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
const result = await response.json();
if (response.ok) {
statusEl.style.background = 'var(--success-dark)';
statusEl.style.border = '1px solid var(--success)';
statusEl.innerHTML = `<i class="fa-solid fa-circle-check" style="color: var(--success);"></i>
<strong>Кластер "${result.cluster_name}" создаётся!</strong><br>
<span style="font-size: 0.875rem; color: var(--text-secondary);">Сервер: ${result.server_name}</span>
${result.upid ? `<br><small style="color: var(--text-muted);">UPID: <code>${result.upid}</code></small>` : ''}`;
setTimeout(() => { closeCreateClusterModal(); window.location.reload(); }, 2500);
} else {
statusEl.style.background = 'var(--danger-dark)';
statusEl.style.border = '1px solid var(--danger)';
statusEl.innerHTML = `<i class="fa-solid fa-circle-xmark" style="color: var(--danger);"></i>
${typeof result.detail === 'string' ? result.detail : JSON.stringify(result.detail)}`;
}
} catch (error) {
statusEl.style.background = 'var(--danger-dark)';
statusEl.style.border = '1px solid var(--danger)';
statusEl.innerHTML = `<i class="fa-solid fa-circle-xmark" style="color: var(--danger);"></i> Ошибка: ${error.message}`;
} finally {
btn.innerHTML = originalText;
btn.disabled = false;
}
}
// ==================== Join Cluster (4-step wizard) ====================
let joinState = {};
function showJoinClusterModal() {
joinState = {};
document.getElementById('joinClusterModal').style.display = 'flex';
document.getElementById('joinPreCheckResult').innerHTML = '';
document.getElementById('joinPrepResult').innerHTML = '';
document.getElementById('joinFinalStatus').style.display = 'none';
document.getElementById('joinStep1Form').reset();
showJoinStep(1);
// Warn if no cluster servers available
const clusterSelect = document.getElementById('joinClusterServerId');
const hasCluster = clusterSelect.querySelectorAll('option[value]:not([value=""])').length > 0;
document.getElementById('joinNoClusterWarning').style.display = hasCluster ? 'none' : 'block';
}
function closeJoinClusterModal() {
document.getElementById('joinClusterModal').style.display = 'none';
joinState = {};
}
function showJoinStep(step) {
[1, 2, 3, 4].forEach(s => {
const el = document.getElementById(`joinStep${s}`);
if (el) el.style.display = s === step ? 'block' : 'none';
});
const titles = ['', 'Шаг 1 из 4: Выбор нод', 'Шаг 2 из 4: Проверка ноды', 'Шаг 3 из 4: Подготовка', 'Шаг 4 из 4: Присоединение'];
document.getElementById('joinStepTitle').textContent = titles[step] || `Шаг ${step} из 4`;
}
async function submitJoinStep1(event) {
event.preventDefault();
const nodeServerId = parseInt(document.getElementById('joinNodeServerId').value);
const clusterServerId = parseInt(document.getElementById('joinClusterServerId').value);
if (!nodeServerId || !clusterServerId) {
showToast('error', 'Ошибка', 'Выберите оба сервера'); return;
}
if (nodeServerId === clusterServerId) {
showToast('error', 'Ошибка', 'Нда для добавления и кластерная нода должны быть разными'); return;
}
joinState.nodeServerId = nodeServerId;
joinState.clusterServerId = clusterServerId;
const btn = document.getElementById('joinStep1Btn');
btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Проверка...';
btn.disabled = true;
try {
const response = await fetchWithAuth(`/proxmox/api/cluster/${nodeServerId}/pre-join-check`);
const data = await response.json();
if (!response.ok) {
showToast('error', 'Ошибка', typeof data.detail === 'string' ? data.detail : 'Ошибка проверки ноды');
return;
}
joinState.preCheck = data;
renderPreCheckResult(data);
showJoinStep(2);
} catch (error) {
showToast('error', 'Ошибка', error.message);
} finally {
btn.innerHTML = 'Далее <i class="fa-solid fa-arrow-right"></i>';
btn.disabled = false;
}
}
function renderPreCheckResult(data) {
const container = document.getElementById('joinPreCheckResult');
const storageOptions = (data.backup_storages || []).map(s =>
`<option value="${s.storage}">${s.storage} (${s.type}${s.avail ? ', свободно: ' + formatBytes(s.avail) : ''})</option>`
).join('');
if (data.guests_count === 0) {
container.innerHTML = `
<div style="background: rgba(34, 197, 94, 0.1); border: 1px solid var(--success); border-radius: 0.5rem; padding: 1rem; margin-bottom: 1rem;">
<i class="fa-solid fa-circle-check" style="color: var(--success);"></i>
<strong> Нода чистая!</strong> VM и LXC не обнаружены. Можно сразу присоединяться к кластеру.
</div>
`;
document.getElementById('joinPrepareSection').style.display = 'none';
document.getElementById('joinSkipToStep4Btn').style.display = 'inline-flex';
document.getElementById('joinStep2PrepBtn').style.display = 'none';
} else {
const guestRows = data.guests.map(g => `
<tr>
<td style="padding: 0.4rem 0.75rem;">${g.vmid}</td>
<td style="padding: 0.4rem 0.75rem;">${g.name}</td>
<td style="padding: 0.4rem 0.75rem;">
<span style="background: ${g.type === 'qemu' ? 'var(--purple)' : 'var(--info)'}; color: white;
padding: 0.1rem 0.4rem; border-radius: 3px; font-size: 0.7rem;">
${g.type === 'qemu' ? 'VM' : 'LXC'}
</span>
</td>
<td style="padding: 0.4rem 0.75rem;">
<span class="status-badge ${g.status === 'running' ? 'status-online' : 'status-offline'}"
style="font-size: 0.7rem; padding: 0.1rem 0.5rem;">${g.status}</span>
</td>
</tr>
`).join('');
container.innerHTML = `
<div style="background: rgba(239, 68, 68, 0.1); border: 1px solid var(--danger); border-radius: 0.5rem; padding: 1rem; margin-bottom: 1rem;">
<i class="fa-solid fa-triangle-exclamation" style="color: var(--danger);"></i>
<strong> Обнаружено гестов: ${data.guests_count}</strong><br>
<span style="font-size: 0.875rem; color: var(--text-secondary);">
При присоединении к кластеру /etc/pve перезапишется и конфиги гестов потеряются.
Необходимо создать бэкапы и удалить всех гестов перед join.
</span>
</div>
<div style="overflow-x: auto; margin-bottom: 1rem;">
<table style="width: 100%; border-collapse: collapse; font-size: 0.875rem;">
<thead>
<tr style="background: var(--bg-secondary);">
<th style="padding: 0.4rem 0.75rem; text-align: left;">VMID</th>
<th style="padding: 0.4rem 0.75rem; text-align: left;">Имя</th>
<th style="padding: 0.4rem 0.75rem; text-align: left;">Тип</th>
<th style="padding: 0.4rem 0.75rem; text-align: left;">Статус</th>
</tr>
</thead>
<tbody>${guestRows}</tbody>
</table>
</div>
`;
document.getElementById('joinPrepareSection').style.display = 'block';
if (storageOptions) {
document.getElementById('joinBackupStorage').innerHTML = storageOptions;
} else {
document.getElementById('joinBackupStorage').innerHTML = '<option value="">Нет доступных хранилищ backup</option>';
container.innerHTML += `
<div style="background: rgba(239, 68, 68, 0.1); border: 1px solid var(--danger); border-radius: 0.5rem; padding: 0.75rem; margin-bottom: 1rem; font-size: 0.875rem;">
<i class="fa-solid fa-exclamation-circle" style="color: var(--danger);"></i>
<strong> Нет хранилищ для бэкапов.</strong> Добавьте NFS/CIFS/Ceph хранилище с типом backup.
</div>
`;
}
document.getElementById('joinSkipToStep4Btn').style.display = 'none';
document.getElementById('joinStep2PrepBtn').style.display = 'inline-flex';
}
}
function joinSkipToFinalStep() {
showJoinStep(4);
}
async function submitJoinPrepare() {
const storage = document.getElementById('joinBackupStorage').value;
if (!storage) {
showToast('error', 'Ошибка', 'Выберите хранилище для бэкапов');
return;
}
joinState.backupStorage = storage;
const btn = document.getElementById('joinStep2PrepBtn');
btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Создание бэкапов...';
btn.disabled = true;
showJoinStep(3);
document.getElementById('joinPrepResult').innerHTML = `
<div style="text-align: center; padding: 2rem; color: var(--text-muted);">
<i class="fa-solid fa-spinner fa-spin" style="font-size: 2rem; margin-bottom: 1rem; display: block;"></i>
<div>Создание резервных копий и очистка ноды...</div>
<div style="font-size: 0.875rem; margin-top: 0.5rem;">Процесс может занять несколько минут</div>
</div>
`;
document.getElementById('joinPrepContinueBtn').style.display = 'none';
try {
const response = await fetchWithAuth('/proxmox/api/cluster/prepare-join', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
node_server_id: joinState.nodeServerId,
backup_storage: storage,
compress: 'zstd',
}),
});
const result = await response.json();
const container = document.getElementById('joinPrepResult');
if (!response.ok) {
container.innerHTML = `
<div style="background: var(--danger-dark); border: 1px solid var(--danger); border-radius: 0.5rem; padding: 1rem;">
<i class="fa-solid fa-circle-xmark" style="color: var(--danger);"></i>
<strong> Ошибка:</strong> ${typeof result.detail === 'string' ? result.detail : JSON.stringify(result.detail)}
</div>
`;
return;
}
const statusColor = result.ready_for_join ? 'var(--success)' : 'var(--warning)';
let errorsHtml = '';
if (result.errors && result.errors.length > 0) {
const errList = result.errors.map(e => `<li>VMID ${e.vmid} (${e.phase}): ${e.error}</li>`).join('');
errorsHtml = `<ul style="margin-top: 0.5rem; color: var(--danger); font-size: 0.875rem;">${errList}</ul>`;
}
container.innerHTML = `
<div style="background: ${result.ready_for_join ? 'rgba(34, 197, 94, 0.1)' : 'rgba(234, 179, 8, 0.1)'};
border: 1px solid ${statusColor}; border-radius: 0.5rem; padding: 1rem; margin-bottom: 1rem;">
<i class="fa-solid fa-${result.ready_for_join ? 'circle-check' : 'triangle-exclamation'}" style="color: ${statusColor};"></i>
<strong> ${result.message}</strong><br>
<span style="font-size: 0.875rem; color: var(--text-secondary);">
Забэкаплено: ${result.backed_up.length} | Удалено: ${result.deleted.length} | Ошибки: ${result.errors.length}
</span>
${errorsHtml}
</div>
`;
joinState.prepareResult = result;
if (result.ready_for_join) {
document.getElementById('joinPrepContinueBtn').style.display = 'inline-flex';
}
} catch (error) {
document.getElementById('joinPrepResult').innerHTML = `
<div style="background: var(--danger-dark); border: 1px solid var(--danger); border-radius: 0.5rem; padding: 1rem;">
<i class="fa-solid fa-circle-xmark" style="color: var(--danger);"></i> Ошибка: ${error.message}
</div>
`;
} finally {
btn.innerHTML = '<i class="fa-solid fa-hard-drive"></i> Бэкап и очистка';
btn.disabled = false;
}
}
async function submitJoinFinal(event) {
event.preventDefault();
const rootpw = document.getElementById('joinRootpw').value.trim();
if (!rootpw) {
showToast('error', 'Ошибка', 'Введите пароль root@pam для присоединяемой ноды');
return;
}
const link0 = document.getElementById('joinLink0').value.trim() || null;
const btn = document.getElementById('joinFinalBtn');
const originalText = btn.innerHTML;
btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Присоединение...';
btn.disabled = true;
const statusEl = document.getElementById('joinFinalStatus');
statusEl.style.display = 'block';
statusEl.style.background = 'var(--bg-tertiary)';
statusEl.style.border = '1px solid var(--border-color)';
statusEl.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Выполняется join...';
try {
const response = await fetchWithAuth('/proxmox/api/cluster/join', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
node_server_id: joinState.nodeServerId,
cluster_server_id: joinState.clusterServerId,
rootpw: rootpw,
link0: link0,
}),
});
const result = await response.json();
if (response.ok) {
statusEl.style.background = 'var(--success-dark)';
statusEl.style.border = '1px solid var(--success)';
statusEl.innerHTML = `<i class="fa-solid fa-circle-check" style="color: var(--success);"></i>
<strong> ${result.message}</strong>${result.upid ? `<br><small style="color: var(--text-muted);">UPID: <code>${result.upid}</code></small>` : ''}`;
setTimeout(() => { closeJoinClusterModal(); window.location.reload(); }, 3000);
} else if (response.status === 409 && result.detail && result.detail.code === 'GUESTS_FOUND') {
statusEl.style.background = 'var(--danger-dark)';
statusEl.style.border = '1px solid var(--danger)';
statusEl.innerHTML = `<i class="fa-solid fa-circle-xmark" style="color: var(--danger);"></i> ${result.detail.message}`;
} else {
statusEl.style.background = 'var(--danger-dark)';
statusEl.style.border = '1px solid var(--danger)';
statusEl.innerHTML = `<i class="fa-solid fa-circle-xmark" style="color: var(--danger);"></i>
${typeof result.detail === 'string' ? result.detail : JSON.stringify(result.detail)}`;
}
} catch (error) {
statusEl.style.background = 'var(--danger-dark)';
statusEl.style.border = '1px solid var(--danger)';
statusEl.innerHTML = `<i class="fa-solid fa-circle-xmark" style="color: var(--danger);"></i> Ошибка: ${error.message}`;
} finally {
btn.innerHTML = originalText;
btn.disabled = false;
}
}
// ==================== Eject Node ====================
function showEjectNodeModal() {
document.getElementById('ejectNodeModal').style.display = 'flex';
document.getElementById('ejectNodeStatus').style.display = 'none';
document.getElementById('ejectNodeForm').reset();
}
function closeEjectNodeModal() {
document.getElementById('ejectNodeModal').style.display = 'none';
}
async function submitEjectNode(event) {
event.preventDefault();
const serverId = document.getElementById('ejectFromServerId').value;
const nodeName = document.getElementById('ejectNodeName').value.trim();
const rootpw = document.getElementById('ejectRootpw').value.trim() || null;
if (!serverId || !nodeName) {
showToast('error', 'Ошибка', 'Выберите сервер и введите имя ноды');
return;
}
const confirmed = await showDeleteConfirm(
`Вы уверены, что хотите исключить ноду "${nodeName}" из кластера?\n\nНода должна быть ВЫКЛЮЧЕНА перед выполнением этой операции.`,
'Исключить ноду'
);
if (!confirmed) return;
const btn = document.getElementById('ejectNodeBtn');
const originalText = btn.innerHTML;
btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Исключение...';
btn.disabled = true;
const statusEl = document.getElementById('ejectNodeStatus');
statusEl.style.display = 'block';
statusEl.style.background = 'var(--bg-tertiary)';
statusEl.style.border = '1px solid var(--border-color)';
statusEl.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Удаление ноды из кластера...';
try {
const response = await fetchWithAuth(`/proxmox/api/cluster/${serverId}/eject-node`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ node_name: nodeName, rootpw: rootpw, clear_panel_entry: true }),
});
const result = await response.json();
if (response.ok) {
statusEl.style.background = 'var(--success-dark)';
statusEl.style.border = '1px solid var(--success)';
statusEl.innerHTML = `<i class="fa-solid fa-circle-check" style="color: var(--success);"></i>
<strong> ${result.message}</strong>
<br><span style="font-size: 0.875rem; color: var(--text-muted);">Метод: ${result.method}</span>`;
setTimeout(() => { closeEjectNodeModal(); window.location.reload(); }, 2500);
} else {
statusEl.style.background = 'var(--danger-dark)';
statusEl.style.border = '1px solid var(--danger)';
statusEl.innerHTML = `<i class="fa-solid fa-circle-xmark" style="color: var(--danger);"></i>
${typeof result.detail === 'string' ? result.detail : JSON.stringify(result.detail)}`;
}
} catch (error) {
statusEl.style.background = 'var(--danger-dark)';
statusEl.style.border = '1px solid var(--danger)';
statusEl.innerHTML = `<i class="fa-solid fa-circle-xmark" style="color: var(--danger);"></i> Ошибка: ${error.message}`;
} finally {
btn.innerHTML = originalText;
btn.disabled = false;
}
}
</script>
{% endblock %}

View File

@@ -0,0 +1,522 @@
{% extends "base.html" %}
{% block content %}
<div class="page-header" style="margin-bottom: 1.5rem;">
<div style="display:flex; align-items:center; gap:1rem; flex-wrap:wrap;">
<div>
<h1 style="font-size:1.5rem; font-weight:700; margin:0; color:var(--text-primary);">
<i class="fa-solid fa-list-check" style="color:#667eea; margin-right:.5rem;"></i>
Менеджер задач
</h1>
<p style="margin:.25rem 0 0; color:var(--text-muted); font-size:.87rem;">
Все активные и завершённые задачи в реальном времени
</p>
</div>
<div style="margin-left:auto; display:flex; gap:.75rem; align-items:center;">
<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>
</div>
<button class="btn btn-sm" style="background:var(--bg-surface); border:1px solid var(--border-default); color:var(--text-secondary); border-radius:8px; padding:.35rem .85rem;"
onclick="TaskManager.refresh()" title="Обновить">
<i class="fa-solid fa-rotate-right"></i>
</button>
</div>
</div>
</div>
<!-- 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>
</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>
</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>
</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>
</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>
</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>
</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;">Задачи не найдены</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="Закрыть"></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> Результаты
</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> Лог
</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);">Нет данных</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>
</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> Отменить задачу
</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);">
Закрыть
</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: 'Выполняется', pending: 'В очереди', queued: 'В очереди',
completed: 'Завершено', failed: 'Ошибка', error: 'Ошибка',
cancelled: 'Отменено', canceled: 'Отменено'
};
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('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)}м`;
}
// ── 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 || 'Задача';
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>`);
} 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> Отменить
</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:'Подключено', connecting:'Подключение...', 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 || 'Задача';
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)}`;
// 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 || '(лог пуст)';
} 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>Параметр</th><th>Значение</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>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>
</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 ? `Обработано ${items} из ${total} объектов` : 'Нет результатов'}
</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' : 'Ошибка'))}</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>
<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('ru-RU') : '';
return `${ts ? '['+ts+'] ' : ''}${ok?'✓':'✗'} ${r.vm_name||r.name||''}: ${r.message||r.error||''}`;
}).join('\n');
document.getElementById('traceLogContent').textContent = lines || '(пусто)';
}
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 ? '#667eea' : 'var(--text-muted)';
btn.style.borderBottom = isActive ? '2px solid #667eea' : '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 || '(лог пуст)';
}
} catch {}
}
async function cancelTaskById(kind, id) {
if (kind !== 'bulk') return;
const ok = await showConfirm('Отменить эту задачу?', 'Подтверждение');
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', 'Ошибка', 'Ошибка сети'); }
}
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 %}

View File

@@ -966,6 +966,7 @@ function restoreFiltersFromUrl() {
// Bulk selection state
let selectedVMs = new Set(); // Set of "server_id-vmid" keys
let currentPageVMsCache = []; // sorted page items cache used by getCurrentPageVMs
let currentTaskId = null;
let taskPollInterval = null;
@@ -1002,7 +1003,7 @@ function toggleSelectAll(event) {
// checked уже изменён браузером к моменту вызова onclick
const isChecked = checkbox.checked;
console.log('toggleSelectAll:', { isChecked, selectedVMsSize: selectedVMs.size });
debugLog('toggleSelectAll:', { isChecked, selectedVMsSize: selectedVMs.size });
const checkboxes = document.querySelectorAll('.vm-row-checkbox');
@@ -1025,7 +1026,7 @@ function toggleSelectAll(event) {
});
}
console.log('After toggleSelectAll:', { selectedVMsSize: selectedVMs.size, isChecked });
debugLog('After toggleSelectAll:', { selectedVMsSize: selectedVMs.size, isChecked });
updateBulkActionsBar();
}
@@ -1091,9 +1092,7 @@ function updateSelectAllCheckbox() {
}
function getCurrentPageVMs() {
const start = (currentPageNum - 1) * pageSizeNum;
const end = start + pageSizeNum;
return filteredVMs.slice(start, end);
return currentPageVMsCache;
}
function getSelectedVMsData() {
@@ -1162,9 +1161,9 @@ async function bulkAction(action) {
bulkOperationAction = action;
// Show pending status with spinner for all selected items
console.log('[bulkAction] Setting pending status for items:', items);
debugLog('[bulkAction] Setting pending status for items:', items);
items.forEach(item => {
console.log(`[bulkAction] Calling updateVMStatusUI for server_id=${item.server_id}, vmid=${item.vmid}, action=${action}`);
debugLog(`[bulkAction] Calling updateVMStatusUI for server_id=${item.server_id}, vmid=${item.vmid}, action=${action}`);
updateVMStatusUI(item.server_id, item.vmid, 'pending', action);
});
@@ -1439,6 +1438,7 @@ function renderTable() {
const start = (currentPageNum - 1) * pageSizeNum;
const end = start + pageSizeNum;
const pageItems = sorted.slice(start, end);
currentPageVMsCache = pageItems;
if (pageItems.length === 0) {
tbody.innerHTML = `
@@ -1816,7 +1816,7 @@ async function vmAction(action, serverId, vmid, type, node, vmName) {
function updateVMStatusUI(serverId, vmid, status, action = null) {
const elementId = `vm-status-${serverId}-${vmid}`;
const statusEl = document.getElementById(elementId);
console.log(`[updateVMStatusUI] Looking for element: ${elementId}, found: ${!!statusEl}, status: ${status}, action: ${action}`);
debugLog(`[updateVMStatusUI] Looking for element: ${elementId}, found: ${!!statusEl}, status: ${status}, action: ${action}`);
if (!statusEl) {
console.warn(`[updateVMStatusUI] Element not found: ${elementId}`);
return;
@@ -1837,8 +1837,7 @@ function updateVMStatusUI(serverId, vmid, status, action = null) {
action === 'restart' ? '{{ t("restarting") }}' :
action === 'shutdown' ? '{{ t("shutting_down") }}' : '...';
statusEl.innerHTML = `<span class="status-spinner"></span> ${actionLabel}`;
console.log(`[updateVMStatusUI] Set pending with spinner: ${statusEl.innerHTML}`);
} else if (status === 'running') {
debugLog(`[updateVMStatusUI] Set pending with spinner: ${statusEl.innerHTML}`); } else if (status === 'running') {
statusEl.textContent = t('active');
} else {
statusEl.textContent = t('inactive');