Files
pvemanager/backend/app/templates/backups.html

912 lines
46 KiB
HTML

{% extends "base.html" %}
{% block title %}{{ t('nav_backups') }}{% endblock %}
{% block content %}
<div class="page-container">
<div class="page-header d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="page-title mb-0">
<svg class="material-icon me-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:28px;height:28px"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
{{ t('nav_backups') }}
</h1>
</div>
<div class="d-flex gap-2">
<select id="serverSelect" class="form-select form-select-sm" style="min-width:180px" onchange="onServerChange()">
<option value="">{{ t('select_server') }}</option>
{% for s in servers %}
<option value="{{ s.id }}" data-name="{{ s.name }}">{{ s.name }}</option>
{% endfor %}
</select>
</div>
</div>
<!-- Tabs -->
<ul class="nav nav-tabs mb-4" id="backupTabs">
<li class="nav-item">
<a class="nav-link active" id="tab-backups" href="#" onclick="showTab('backups',this)">
<svg class="material-icon me-1" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><rect x="2" y="7" width="20" height="14" rx="2"/><path d="M16 3H8L2 7h20z"/></svg>
{{ t('backup_tab_backups') }}
</a>
</li>
<li class="nav-item">
<a class="nav-link" id="tab-storages" href="#" onclick="showTab('storages',this)">
<svg class="material-icon me-1" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/><path d="M3 12c0 1.66 4 3 9 3s9-1.34 9-3"/></svg>
{{ t('backup_tab_storages') }}
</a>
</li>
<li class="nav-item">
<a class="nav-link" id="tab-jobs" href="#" onclick="showTab('jobs',this)">
<svg class="material-icon me-1" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
{{ t('backup_tab_schedule') }}
</a>
</li>
</ul>
<!-- =================== TAB: BACKUPS =================== -->
<div id="pane-backups">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">{{ t('backup_list_title') }}</h5>
<div class="d-flex gap-2 align-items-center">
<select id="storageSelectBackups" class="form-select form-select-sm" style="min-width:150px" onchange="loadBackups()">
<option value="">{{ t('select_storage') }}</option>
</select>
<input type="number" id="filterVmid" class="form-control form-control-sm" placeholder="VMID" style="width:90px" oninput="loadBackups()">
<button class="btn btn-primary btn-sm" onclick="openCreateBackupModal()">
<svg class="material-icon me-1" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
{{ t('backup_create') }}
</button>
</div>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0" id="backupsTable">
<thead><tr>
<th>VMID</th>
<th>{{ t('backup_col_type') }}</th>
<th>{{ t('backup_col_filename') }}</th>
<th>{{ t('backup_col_size') }}</th>
<th>{{ t('backup_col_date') }}</th>
<th>{{ t('backup_col_format') }}</th>
<th class="text-end">{{ t('actions') }}</th>
</tr></thead>
<tbody id="backupsTbody">
<tr><td colspan="7" class="text-center py-4 text-muted">{{ t('select_server_and_storage') }}</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- =================== TAB: STORAGES =================== -->
<div id="pane-storages" class="d-none">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">{{ t('backup_storages_title') }}</h5>
<button class="btn btn-primary btn-sm" onclick="openAddStorageModal()">
<svg class="material-icon me-1" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
{{ t('backup_add_storage') }}
</button>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead><tr>
<th>{{ t('backup_storage_id') }}</th>
<th>{{ t('backup_storage_type') }}</th>
<th>{{ t('backup_storage_content') }}</th>
<th>{{ t('backup_storage_shared') }}</th>
<th>{{ t('backup_storage_usage') }}</th>
<th>{{ t('status') }}</th>
<th class="text-end">{{ t('actions') }}</th>
</tr></thead>
<tbody id="storagesTbody">
<tr><td colspan="7" class="text-center py-4 text-muted">{{ t('select_server') }}</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- =================== TAB: SCHEDULE =================== -->
<div id="pane-jobs" class="d-none">
<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()">
<svg class="material-icon me-1" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
{{ t('backup_add_job') }}
</button>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead><tr>
<th>{{ t('server') }}</th>
<th>{{ t('node') }}</th>
<th>VMIDs</th>
<th>{{ t('backup_storage_id') }}</th>
<th>Cron</th>
<th>{{ t('backup_job_last_run') }}</th>
<th>{{ t('status') }}</th>
<th class="text-end">{{ t('actions') }}</th>
</tr></thead>
<tbody id="jobsTbody">
<tr><td colspan="8" class="text-center py-4 text-muted" id="jobsEmpty">{{ t('loading') }}</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- =================== MODAL: CREATE BACKUP =================== -->
<div class="modal fade" id="createBackupModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ t('backup_create_title') }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">{{ t('node') }}</label>
<select id="cbNode" class="form-select" required></select>
</div>
<div class="mb-3">
<label class="form-label">VMID</label>
<input type="number" id="cbVmid" class="form-control" placeholder="100" required>
</div>
<div class="mb-3">
<label class="form-label">{{ t('backup_storage_id') }}</label>
<select id="cbStorage" class="form-select" required></select>
</div>
<div class="row">
<div class="col-6 mb-3">
<label class="form-label">{{ t('backup_mode') }}</label>
<select id="cbMode" class="form-select">
<option value="snapshot">snapshot</option>
<option value="suspend">suspend</option>
<option value="stop">stop</option>
</select>
</div>
<div class="col-6 mb-3">
<label class="form-label">{{ t('backup_compress') }}</label>
<select id="cbCompress" class="form-select">
<option value="zstd">zstd</option>
<option value="gzip">gzip</option>
<option value="lzo">lzo</option>
<option value="0">{{ t('none') }}</option>
</select>
</div>
</div>
<div class="mb-3">
<label class="form-label">{{ t('backup_keep') }}</label>
<input type="number" id="cbRemove" class="form-control" value="1" min="0">
<div class="form-text">{{ t('backup_keep_hint') }}</div>
</div>
<div class="mb-3">
<label class="form-label">{{ t('backup_notes') }}</label>
<input type="text" id="cbNotes" class="form-control" placeholder="{{ t('backup_notes_placeholder') }}">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ t('cancel') }}</button>
<button type="button" class="btn btn-primary" onclick="submitCreateBackup()">{{ t('backup_create') }}</button>
</div>
</div>
</div>
</div>
<!-- =================== MODAL: RESTORE =================== -->
<div class="modal fade" id="restoreModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ t('backup_restore_title') }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="alert alert-info small" id="restoreInfo"></div>
<div class="mb-3">
<label class="form-label">{{ t('backup_restore_storage') }}</label>
<select id="restoreStorage" class="form-select"></select>
</div>
<div class="mb-3">
<label class="form-label">{{ t('backup_restore_new_vmid') }}</label>
<input type="number" id="restoreNewVmid" class="form-control" placeholder="{{ t('backup_restore_vmid_hint') }}">
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="restoreStart">
<label class="form-check-label">{{ t('backup_restore_start') }}</label>
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="restoreUnique" checked>
<label class="form-check-label">{{ t('backup_restore_unique') }}</label>
</div>
<input type="hidden" id="restoreArchive">
<input type="hidden" id="restoreNode">
<input type="hidden" id="restoreVmid">
<input type="hidden" id="restoreVmType">
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ t('cancel') }}</button>
<button type="button" class="btn btn-warning" onclick="submitRestore()">{{ t('backup_restore_btn') }}</button>
</div>
</div>
</div>
</div>
<!-- =================== MODAL: TASK LOG =================== -->
<div class="modal fade" id="taskLogModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ t('backup_task_log') }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body p-0">
<div id="taskLogStatus" class="px-3 pt-3"></div>
<pre id="taskLogContent" style="max-height:400px;overflow-y:auto;background:var(--bs-dark,#1e1e2e);color:#cdd6f4;padding:1rem;margin:0;font-size:.82rem;"></pre>
</div>
</div>
</div>
</div>
<!-- =================== MODAL: ADD STORAGE =================== -->
<div class="modal fade" id="addStorageModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ t('backup_add_storage') }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">ID</label>
<input type="text" id="asId" class="form-control" placeholder="my-backup-storage" required>
</div>
<div class="mb-3">
<label class="form-label">{{ t('backup_storage_type') }}</label>
<select id="asType" class="form-select" onchange="onStorageTypeChange(this.value)">
<option value="dir">dir</option>
<option value="nfs">NFS</option>
<option value="cifs">CIFS/SMB</option>
<option value="pbs">PBS (Proxmox Backup Server)</option>
<option value="lvm">LVM</option>
<option value="zfspool">ZFS Pool</option>
</select>
</div>
<div id="asTypeFields">
<!-- dir -->
<div class="mb-3 as-field-dir">
<label class="form-label">{{ t('backup_storage_path') }}</label>
<input type="text" id="asPath" class="form-control" placeholder="/mnt/backup">
</div>
<!-- nfs -->
<div class="mb-3 as-field-nfs d-none">
<label class="form-label">NFS Server</label>
<input type="text" id="asNfsServer" class="form-control" placeholder="192.168.1.10">
</div>
<div class="mb-3 as-field-nfs d-none">
<label class="form-label">Export path</label>
<input type="text" id="asNfsExport" class="form-control" placeholder="/export/backup">
</div>
<!-- cifs -->
<div class="mb-3 as-field-cifs d-none">
<label class="form-label">CIFS Server</label>
<input type="text" id="asCifsServer" class="form-control" placeholder="192.168.1.10">
</div>
<div class="mb-3 as-field-cifs d-none">
<label class="form-label">Share</label>
<input type="text" id="asCifsShare" class="form-control" placeholder="backup">
</div>
<div class="mb-3 as-field-cifs d-none">
<label class="form-label">Username</label>
<input type="text" id="asCifsUser" class="form-control">
</div>
<div class="mb-3 as-field-cifs d-none">
<label class="form-label">Password</label>
<input type="password" id="asCifsPass" class="form-control">
</div>
<!-- pbs -->
<div class="mb-3 as-field-pbs d-none">
<label class="form-label">PBS Server</label>
<input type="text" id="asPbsServer" class="form-control" placeholder="pbs.example.com">
</div>
<div class="mb-3 as-field-pbs d-none">
<label class="form-label">Datastore</label>
<input type="text" id="asPbsDatastore" class="form-control" placeholder="main">
</div>
<div class="mb-3 as-field-pbs d-none">
<label class="form-label">Username</label>
<input type="text" id="asPbsUser" class="form-control" placeholder="backup@pbs">
</div>
<div class="mb-3 as-field-pbs d-none">
<label class="form-label">Password / Token</label>
<input type="password" id="asPbsPass" class="form-control">
</div>
<div class="mb-3 as-field-pbs d-none">
<label class="form-label">Fingerprint</label>
<input type="text" id="asPbsFingerprint" class="form-control" placeholder="A0:...">
</div>
</div>
<div class="mb-3">
<label class="form-label">{{ t('backup_storage_content') }}</label>
<div class="d-flex gap-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="asContentBackup" value="backup" checked>
<label class="form-check-label">backup</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="asContentImages" value="images">
<label class="form-check-label">images</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="asContentIso" value="iso">
<label class="form-check-label">iso</label>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ t('cancel') }}</button>
<button type="button" class="btn btn-primary" onclick="submitAddStorage()">{{ t('save') }}</button>
</div>
</div>
</div>
</div>
<!-- =================== MODAL: ADD JOB =================== -->
<div class="modal fade" id="addJobModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="addJobModalTitle">{{ t('backup_add_job') }}</h5>
<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>
<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>
<select id="ajNode" class="form-select"></select>
</div>
</div>
<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>
</div>
<div class="mb-3">
<label class="form-label">{{ t('backup_storage_id') }}</label>
<select id="ajStorage" class="form-select"></select>
</div>
<div class="row">
<div class="col-6 mb-3">
<label class="form-label">{{ t('backup_mode') }}</label>
<select id="ajMode" class="form-select">
<option value="snapshot">snapshot</option>
<option value="suspend">suspend</option>
<option value="stop">stop</option>
</select>
</div>
<div class="col-6 mb-3">
<label class="form-label">{{ t('backup_compress') }}</label>
<select id="ajCompress" class="form-select">
<option value="zstd">zstd</option>
<option value="gzip">gzip</option>
<option value="lzo">lzo</option>
<option value="0">{{ t('none') }}</option>
</select>
</div>
</div>
<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>
</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>
<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>
</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">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ t('cancel') }}</button>
<button type="button" class="btn btn-primary" onclick="submitSaveJob()">{{ t('save') }}</button>
</div>
</div>
</div>
</div>
<script>
let currentServerId = null;
let currentServerNodes = [];
let serversData = [{% for s in servers %}{"id":{{ s.id }},"name":"{{ s.name }}"},{% endfor %}];
// ===== Tab switching =====
function showTab(name, el) {
document.querySelectorAll('[id^="pane-"]').forEach(p => p.classList.add('d-none'));
document.querySelectorAll('#backupTabs .nav-link').forEach(l => l.classList.remove('active'));
document.getElementById('pane-' + name).classList.remove('d-none');
el.classList.add('active');
if (name === 'storages') loadStorages();
if (name === 'jobs') loadJobs();
}
function onServerChange() {
currentServerId = document.getElementById('serverSelect').value || null;
// Refresh current tab
const activeTab = document.querySelector('#backupTabs .nav-link.active')?.id?.replace('tab-','');
if (activeTab === 'storages') loadStorages();
if (activeTab === 'backups') { populateStorageDropdown(); }
if (currentServerId) fetchNodes(currentServerId).then(nodes => { currentServerNodes = nodes; });
}
// ===== Nodes =====
async function fetchNodes(serverId) {
try {
const data = await fetchWithAuth(`/proxmox/api/servers/${serverId}/cluster-info`);
return (data.nodes || []).filter(Boolean);
} catch { return []; }
}
function fillNodeSelect(selectId, nodes) {
const sel = document.getElementById(selectId);
sel.innerHTML = '';
nodes.forEach(n => { const o = document.createElement('option'); o.value = n; o.textContent = n; sel.appendChild(o); });
}
// ===== Storage dropdown =====
async function populateStorageDropdown(targetId = 'storageSelectBackups') {
if (!currentServerId) return;
try {
const r = await fetchWithAuth(`/proxmox/api/backups/storages/${currentServerId}`);
const backupStorages = (r.storages || []).filter(s => (s.content || '').includes('backup'));
const sel = document.getElementById(targetId);
sel.innerHTML = '<option value="">{{ t("select_storage") }}</option>';
backupStorages.forEach(s => {
const o = document.createElement('option');
o.value = s.storage;
o.textContent = `${s.storage} (${s.type})`;
sel.appendChild(o);
});
} catch(e) { console.error(e); }
}
// ===== BACKUPS Tab =====
async function loadBackups() {
if (!currentServerId) return;
const storage = document.getElementById('storageSelectBackups').value;
if (!storage) { document.getElementById('backupsTbody').innerHTML = '<tr><td colspan="7" class="text-center py-3 text-muted">{{ t("select_storage") }}</td></tr>'; return; }
// need node - pick first node
if (!currentServerNodes.length) currentServerNodes = await fetchNodes(currentServerId);
if (!currentServerNodes.length) return;
const node = currentServerNodes[0];
const vmid = document.getElementById('filterVmid').value || null;
let url = `/proxmox/api/backups/list/${currentServerId}?node=${node}&storage=${storage}`;
if (vmid) url += `&vmid=${vmid}`;
try {
const r = await fetchWithAuth(url);
const backups = r.backups || [];
const tbody = document.getElementById('backupsTbody');
if (!backups.length) { tbody.innerHTML = '<tr><td colspan="7" class="text-center py-3 text-muted">{{ t("backup_empty") }}</td></tr>'; return; }
tbody.innerHTML = backups.map(b => {
const vmid = b.vmid || '-';
const vtype = b.content ? b.content.split(':')[0] : '-';
const fname = (b.volid || '').split('/').pop();
const sz = formatSize(b.size);
const dt = b.ctime ? new Date(b.ctime * 1000).toLocaleString() : '-';
const fmt = b.format || '-';
return `<tr>
<td>${vmid}</td>
<td><span class="badge bg-secondary">${vtype}</span></td>
<td class="font-monospace small text-truncate" style="max-width:260px" title="${b.volid}">${fname}</td>
<td>${sz}</td>
<td>${dt}</td>
<td>${fmt}</td>
<td class="text-end">
<button class="btn btn-sm btn-outline-warning me-1" onclick='openRestoreModal(${JSON.stringify(b)}, "${node}", "${storage}")' title="{{ t('backup_restore_btn') }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 .49-4.9"/></svg>
</button>
<button class="btn btn-sm btn-outline-danger" onclick="deleteBackup(${JSON.stringify(b.volid)}, '${node}', '${storage}')" title="{{ t('delete') }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>
</button>
</td>
</tr>`;
}).join('');
} catch(e) { console.error(e); }
}
function formatSize(bytes) {
if (!bytes) return '-';
const gb = bytes / (1024**3);
if (gb >= 1) return gb.toFixed(2) + ' GB';
return (bytes / (1024**2)).toFixed(0) + ' MB';
}
async function deleteBackup(volid, node, storage) {
if (!confirm('{{ t("backup_delete_confirm") }}')) return;
try {
const r = await fetchWithAuth(`/proxmox/api/backups/${currentServerId}/backup`, {
method: 'DELETE',
body: JSON.stringify({ node, storage, volid }),
});
if (r.success) { showToast('{{ t("success") }}', '{{ t("backup_deleted") }}', 'success'); loadBackups(); }
} catch(e) { showToast('{{ t("error") }}', e.message, 'danger'); }
}
// ===== CREATE BACKUP =====
async function openCreateBackupModal() {
if (!currentServerId) { showToast('', '{{ t("select_server") }}', 'warning'); return; }
if (!currentServerNodes.length) currentServerNodes = await fetchNodes(currentServerId);
fillNodeSelect('cbNode', currentServerNodes);
await populateStorageDropdown('cbStorage');
new bootstrap.Modal(document.getElementById('createBackupModal')).show();
}
async function submitCreateBackup() {
const payload = {
server_id: parseInt(currentServerId),
node: document.getElementById('cbNode').value,
vmid: document.getElementById('cbVmid').value,
storage: document.getElementById('cbStorage').value,
mode: document.getElementById('cbMode').value,
compress: document.getElementById('cbCompress').value,
remove: parseInt(document.getElementById('cbRemove').value),
notes: document.getElementById('cbNotes').value || null,
};
try {
const r = await fetchWithAuth('/proxmox/api/backups/create', { method: 'POST', body: JSON.stringify(payload) });
if (r.success) {
bootstrap.Modal.getInstance(document.getElementById('createBackupModal'))?.hide();
showToast('{{ t("success") }}', '{{ t("backup_started") }}', 'success');
if (r.upid) openTaskLog(currentServerId, payload.node, r.upid);
}
} catch(e) { showToast('{{ t("error") }}', e.message, 'danger'); }
}
// ===== RESTORE =====
function openRestoreModal(backup, node, storage) {
document.getElementById('restoreArchive').value = backup.volid;
document.getElementById('restoreNode').value = node;
document.getElementById('restoreVmid').value = backup.vmid || '';
document.getElementById('restoreVmType').value = backup.content?.includes('lxc') ? 'lxc' : 'qemu';
document.getElementById('restoreNewVmid').value = '';
document.getElementById('restoreStart').checked = false;
document.getElementById('restoreUnique').checked = true;
document.getElementById('restoreInfo').textContent = (backup.volid || '').split('/').pop();
populateStorageDropdown('restoreStorage');
document.getElementById('restoreStorage').value = storage;
new bootstrap.Modal(document.getElementById('restoreModal')).show();
}
async function submitRestore() {
const payload = {
server_id: parseInt(currentServerId),
node: document.getElementById('restoreNode').value,
vmid: document.getElementById('restoreVmid').value,
archive: document.getElementById('restoreArchive').value,
storage: document.getElementById('restoreStorage').value,
vm_type: document.getElementById('restoreVmType').value,
new_vmid: document.getElementById('restoreNewVmid').value || null,
start: document.getElementById('restoreStart').checked,
unique: document.getElementById('restoreUnique').checked,
};
try {
const r = await fetchWithAuth('/proxmox/api/backups/restore', { method: 'POST', body: JSON.stringify(payload) });
if (r.success) {
bootstrap.Modal.getInstance(document.getElementById('restoreModal'))?.hide();
showToast('{{ t("success") }}', '{{ t("backup_restore_started") }}', 'success');
if (r.upid) openTaskLog(currentServerId, payload.node, r.upid);
}
} catch(e) { showToast('{{ t("error") }}', e.message, 'danger'); }
}
// ===== TASK LOG =====
async function openTaskLog(serverId, node, upid) {
document.getElementById('taskLogContent').textContent = '...';
document.getElementById('taskLogStatus').textContent = '';
const modal = new bootstrap.Modal(document.getElementById('taskLogModal'));
modal.show();
let done = false;
while (!done) {
try {
const r = await fetchWithAuth(`/proxmox/api/backups/task/${serverId}/${node}/${encodeURIComponent(upid)}`);
const status = r.status || {};
const lines = (r.log || []).map(l => l.t || '').join('\n');
document.getElementById('taskLogContent').textContent = lines;
document.getElementById('taskLogContent').scrollTop = document.getElementById('taskLogContent').scrollHeight;
const st = status.status || '';
if (st === 'stopped') {
const exitOk = status.exitstatus === 'OK';
document.getElementById('taskLogStatus').innerHTML = exitOk
? '<span class="badge bg-success">OK</span>'
: `<span class="badge bg-danger">${status.exitstatus || 'FAILED'}</span>`;
done = true;
} else {
document.getElementById('taskLogStatus').innerHTML = '<span class="badge bg-info">{{ t("running") }}</span>';
}
} catch(e) { done = true; }
if (!done) await new Promise(r => setTimeout(r, 2000));
}
}
// ===== STORAGES Tab =====
async function loadStorages() {
if (!currentServerId) { document.getElementById('storagesTbody').innerHTML = '<tr><td colspan="7" class="text-center py-3 text-muted">{{ t("select_server") }}</td></tr>'; return; }
try {
const r = await fetchWithAuth(`/proxmox/api/backups/storages/${currentServerId}`);
const storages = r.storages || [];
const tbody = document.getElementById('storagesTbody');
if (!storages.length) { tbody.innerHTML = '<tr><td colspan="7" class="text-center py-3 text-muted">{{ t("backup_no_storages") }}</td></tr>'; return; }
tbody.innerHTML = storages.map(s => {
const usedPct = s.total ? Math.round(s.used / s.total * 100) : null;
const usageBar = usedPct !== null ? `<div class="progress" style="height:6px;min-width:80px"><div class="progress-bar ${usedPct > 80 ? 'bg-danger' : 'bg-success'}" style="width:${usedPct}%"></div></div><small>${usedPct}%</small>` : '-';
return `<tr>
<td class="font-monospace">${s.storage}</td>
<td><span class="badge bg-secondary">${s.type}</span></td>
<td class="small text-muted">${s.content || '-'}</td>
<td>${s.shared ? '<span class="badge bg-info">shared</span>' : '<span class="badge bg-secondary">local</span>'}</td>
<td>${usageBar}</td>
<td>${s.active ? '<span class="badge bg-success">active</span>' : '<span class="badge bg-danger">inactive</span>'}</td>
<td class="text-end">
<button class="btn btn-sm btn-outline-danger" onclick="deleteStorage('${s.storage}')" title="{{ t('delete') }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/></svg>
</button>
</td>
</tr>`;
}).join('');
} catch(e) { console.error(e); }
}
async function deleteStorage(storageId) {
if (!confirm('{{ t("backup_storage_delete_confirm") }}')) return;
try {
const r = await fetchWithAuth(`/proxmox/api/backups/storages/${currentServerId}/${storageId}`, { method: 'DELETE' });
if (r.success) { showToast('{{ t("success") }}', '{{ t("backup_storage_deleted") }}', 'success'); loadStorages(); }
} catch(e) { showToast('{{ t("error") }}', e.message, 'danger'); }
}
function openAddStorageModal() {
if (!currentServerId) { showToast('', '{{ t("select_server") }}', 'warning'); return; }
document.getElementById('asId').value = '';
document.getElementById('asType').value = 'dir';
onStorageTypeChange('dir');
new bootstrap.Modal(document.getElementById('addStorageModal')).show();
}
function onStorageTypeChange(type) {
document.querySelectorAll('[class*="as-field-"]').forEach(el => el.classList.add('d-none'));
document.querySelectorAll('.as-field-' + type).forEach(el => el.classList.remove('d-none'));
}
async function submitAddStorage() {
const type = document.getElementById('asType').value;
const content = [];
if (document.getElementById('asContentBackup').checked) content.push('backup');
if (document.getElementById('asContentImages').checked) content.push('images');
if (document.getElementById('asContentIso').checked) content.push('iso');
const payload = {
storage: document.getElementById('asId').value.trim(),
type,
content: content.join(','),
};
if (type === 'dir') payload.path = document.getElementById('asPath').value;
if (type === 'nfs') { payload.server = document.getElementById('asNfsServer').value; payload.export = document.getElementById('asNfsExport').value; }
if (type === 'cifs') { payload.server = document.getElementById('asCifsServer').value; payload.share = document.getElementById('asCifsShare').value; payload.username = document.getElementById('asCifsUser').value; payload.password = document.getElementById('asCifsPass').value; }
if (type === 'pbs') { payload.server = document.getElementById('asPbsServer').value; payload.datastore = document.getElementById('asPbsDatastore').value; payload.username = document.getElementById('asPbsUser').value; payload.password = document.getElementById('asPbsPass').value; if (document.getElementById('asPbsFingerprint').value) payload.fingerprint = document.getElementById('asPbsFingerprint').value; }
try {
const r = await fetchWithAuth(`/proxmox/api/backups/storages/${currentServerId}`, { method: 'POST', body: JSON.stringify(payload) });
if (r.success) { bootstrap.Modal.getInstance(document.getElementById('addStorageModal'))?.hide(); showToast('{{ t("success") }}', '{{ t("backup_storage_added") }}', 'success'); loadStorages(); }
} catch(e) { showToast('{{ t("error") }}', e.message, 'danger'); }
}
// ===== JOBS Tab =====
async function loadJobs() {
try {
const r = await fetchWithAuth('/proxmox/api/backups/jobs');
const jobs = r.jobs || [];
const tbody = document.getElementById('jobsTbody');
if (!jobs.length) { tbody.innerHTML = '<tr><td colspan="8" class="text-center py-3 text-muted">{{ t("backup_no_jobs") }}</td></tr>'; return; }
tbody.innerHTML = jobs.map(j => {
const srv = serversData.find(s => s.id === j.server_id);
const srvName = srv ? srv.name : j.server_id;
const lastRun = j.last_run_at ? new Date(j.last_run_at).toLocaleString() : '-';
const statusBadge = j.last_status === 'success' ? 'bg-success' : j.last_status === 'failed' ? 'bg-danger' : j.last_status === 'running' ? 'bg-info' : 'bg-secondary';
const enabledIcon = j.enabled
? '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M20 6L9 17l-5-5"/></svg>'
: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
return `<tr>
<td>${srvName}</td>
<td>${j.node}</td>
<td class="font-monospace small">${Array.isArray(j.vmids) ? j.vmids.join(', ') : j.vmids}</td>
<td>${j.storage}</td>
<td class="font-monospace small">${j.cron_expression}</td>
<td class="small">${lastRun}</td>
<td>${j.last_status ? `<span class="badge ${statusBadge}">${j.last_status}</span>` : '-'}</td>
<td class="text-end d-flex gap-1 justify-content-end">
<button class="btn btn-sm ${j.enabled ? 'btn-outline-warning' : 'btn-outline-success'}" onclick="toggleJob(${j.id})" title="${j.enabled ? '{{ t("backup_job_disable") }}' : '{{ t("backup_job_enable") }}'}">${enabledIcon}</button>
<button class="btn btn-sm btn-outline-primary" onclick="runJobNow(${j.id})" title="{{ t('backup_run_now') }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polygon points="5 3 19 12 5 21 5 3"/></svg>
</button>
<button class="btn btn-sm btn-outline-secondary" onclick='openEditJobModal(${JSON.stringify(j)})' title="{{ t('edit') }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
</button>
<button class="btn btn-sm btn-outline-danger" onclick="deleteJob(${j.id})" title="{{ t('delete') }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/></svg>
</button>
</td>
</tr>`;
}).join('');
} catch(e) { console.error(e); }
}
async function onJobServerChange(serverId) {
const nodes = await fetchNodes(serverId);
fillNodeSelect('ajNode', nodes);
await populateJobStorages(serverId);
}
async function populateJobStorages(serverId) {
try {
const r = await fetchWithAuth(`/proxmox/api/backups/storages/${serverId}`);
const backupStorages = (r.storages || []).filter(s => (s.content || '').includes('backup'));
const sel = document.getElementById('ajStorage');
sel.innerHTML = '';
backupStorages.forEach(s => { const o = document.createElement('option'); o.value = s.storage; o.textContent = `${s.storage} (${s.type})`; sel.appendChild(o); });
} catch(e) {}
}
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;
const firstServer = serversData[0];
if (firstServer) {
document.getElementById('ajServer').value = firstServer.id;
await onJobServerChange(firstServer.id);
}
new bootstrap.Modal(document.getElementById('addJobModal')).show();
}
async function openEditJobModal(job) {
document.getElementById('addJobModalTitle').textContent = '{{ t("backup_edit_job") }}';
document.getElementById('ajJobId').value = job.id;
document.getElementById('ajServer').value = job.server_id;
await onJobServerChange(job.server_id);
document.getElementById('ajNode').value = job.node;
document.getElementById('ajVmids').value = Array.isArray(job.vmids) ? job.vmids.join(', ') : job.vmids;
document.getElementById('ajStorage').value = job.storage;
document.getElementById('ajMode').value = job.mode;
document.getElementById('ajCompress').value = job.compress;
document.getElementById('ajCron').value = job.cron_expression;
document.getElementById('ajKeepLast').value = job.keep_last;
document.getElementById('ajKeepDaily').value = job.keep_daily;
document.getElementById('ajKeepWeekly').value = job.keep_weekly;
document.getElementById('ajKeepMonthly').value = job.keep_monthly;
document.getElementById('ajNotes').value = job.notes || '';
document.getElementById('ajEnabled').checked = job.enabled;
new bootstrap.Modal(document.getElementById('addJobModal')).show();
}
async function submitSaveJob() {
const jobId = document.getElementById('ajJobId').value;
const vmidsRaw = document.getElementById('ajVmids').value.trim();
let vmids;
if (vmidsRaw.toLowerCase() === 'all') {
vmids = ['all'];
} else {
vmids = vmidsRaw.split(/[\s,]+/).map(v => parseInt(v)).filter(v => !isNaN(v));
}
const payload = {
server_id: parseInt(document.getElementById('ajServer').value),
node: document.getElementById('ajNode').value,
vmids,
storage: document.getElementById('ajStorage').value,
mode: document.getElementById('ajMode').value,
compress: document.getElementById('ajCompress').value,
cron_expression: document.getElementById('ajCron').value.trim(),
keep_last: parseInt(document.getElementById('ajKeepLast').value),
keep_daily: parseInt(document.getElementById('ajKeepDaily').value),
keep_weekly: parseInt(document.getElementById('ajKeepWeekly').value),
keep_monthly: parseInt(document.getElementById('ajKeepMonthly').value),
notes: document.getElementById('ajNotes').value || null,
enabled: document.getElementById('ajEnabled').checked,
};
try {
const url = jobId ? `/proxmox/api/backups/jobs/${jobId}` : '/proxmox/api/backups/jobs';
const method = jobId ? 'PUT' : 'POST';
const r = await fetchWithAuth(url, { method, body: JSON.stringify(payload) });
if (r.success) {
bootstrap.Modal.getInstance(document.getElementById('addJobModal'))?.hide();
showToast('{{ t("success") }}', '{{ t("saved") }}', 'success');
loadJobs();
}
} catch(e) { showToast('{{ t("error") }}', e.message, 'danger'); }
}
async function toggleJob(jobId) {
try {
const r = await fetchWithAuth(`/proxmox/api/backups/jobs/${jobId}/toggle`, { method: 'PATCH' });
if (r.success) loadJobs();
} catch(e) { showToast('{{ t("error") }}', e.message, 'danger'); }
}
async function runJobNow(jobId) {
try {
const r = await fetchWithAuth(`/proxmox/api/backups/jobs/${jobId}/run-now`, { method: 'POST' });
if (r.success) { showToast('{{ t("success") }}', '{{ t("backup_job_triggered") }}', 'success'); setTimeout(loadJobs, 2000); }
} catch(e) { showToast('{{ t("error") }}', e.message, 'danger'); }
}
async function deleteJob(jobId) {
if (!confirm('{{ t("backup_job_delete_confirm") }}')) return;
try {
const r = await fetchWithAuth(`/proxmox/api/backups/jobs/${jobId}`, { method: 'DELETE' });
if (r.success) { showToast('{{ t("success") }}', '{{ t("deleted") }}', 'success'); loadJobs(); }
} catch(e) { showToast('{{ t("error") }}', e.message, 'danger'); }
}
// ===== Toast helper =====
function showToast(title, msg, type = 'info') {
const id = 'toast_' + Date.now();
const colors = { success: 'bg-success', danger: 'bg-danger', warning: 'bg-warning text-dark', info: 'bg-info' };
const el = document.createElement('div');
el.className = `toast align-items-center text-white ${colors[type] || 'bg-secondary'} border-0`;
el.id = id;
el.setAttribute('role', 'alert');
el.innerHTML = `<div class="d-flex"><div class="toast-body"><strong>${title ? title + ': ' : ''}${msg}</strong></div><button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button></div>`;
let container = document.getElementById('toastContainer');
if (!container) { container = document.createElement('div'); container.id = 'toastContainer'; container.className = 'toast-container position-fixed bottom-0 end-0 p-3'; container.style.zIndex = 9999; document.body.appendChild(container); }
container.appendChild(el);
const t = new bootstrap.Toast(el, { delay: 4000 });
t.show();
el.addEventListener('hidden.bs.toast', () => el.remove());
}
// Init
document.addEventListener('DOMContentLoaded', () => {
loadJobs();
onStorageTypeChange('dir');
});
</script>
{% endblock %}