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:
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;">×</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;">×</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;">×</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 %}
|
||||
|
||||
522
backend/app/templates/tasks.html
Normal file
522
backend/app/templates/tasks.html
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
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 ? ` · Завершена: ${fmtTime(task.completed_at)}` : '') +
|
||||
` · Время: ${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 %}
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user