- Add BackupJob model and migration 15 - Add RBAC permissions for backup resources - Add ProxmoxClient storage/backup methods (list, create, restore, delete) - Add /proxmox/api/backups/* API endpoints - Add APScheduler-based backup job scheduler service - Add backups.html template with 3 tabs (backups, storages, jobs) - Add sidebar navigation link for backups page - Fix backup page icons (replace SVG with Font Awesome) - Bump version to 1.0.4
913 lines
43 KiB
HTML
913 lines
43 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">
|
|
<i class="fa-solid fa-hard-drive me-2"></i>
|
|
{{ 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)">
|
|
<i class="fa-solid fa-box-archive me-1"></i>
|
|
{{ t('backup_tab_backups') }}
|
|
</a>
|
|
</li>
|
|
<li class="nav-item">
|
|
<a class="nav-link" id="tab-storages" href="#" onclick="showTab('storages',this)">
|
|
<i class="fa-solid fa-database me-1"></i>
|
|
{{ t('backup_tab_storages') }}
|
|
</a>
|
|
</li>
|
|
<li class="nav-item">
|
|
<a class="nav-link" id="tab-jobs" href="#" onclick="showTab('jobs',this)">
|
|
<i class="fa-solid fa-clock me-1"></i>
|
|
{{ 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()">
|
|
<i class="fa-solid fa-plus me-1"></i>
|
|
{{ 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()">
|
|
<i class="fa-solid fa-plus me-1"></i>
|
|
{{ 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()">
|
|
<i class="fa-solid fa-plus me-1"></i>
|
|
{{ 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 resp = await fetchWithAuth(`/proxmox/api/servers/${serverId}/cluster-info`);
|
|
const data = await resp.json();
|
|
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 (await fetchWithAuth(`/proxmox/api/backups/storages/${currentServerId}`)).json();
|
|
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 (await fetchWithAuth(url)).json();
|
|
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') }}">
|
|
<i class="fa-solid fa-rotate-left"></i>
|
|
</button>
|
|
<button class="btn btn-sm btn-outline-danger" onclick="deleteBackup(${JSON.stringify(b.volid)}, '${node}', '${storage}')" title="{{ t('delete') }}">
|
|
<i class="fa-solid fa-trash"></i>
|
|
</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 (await fetchWithAuth(`/proxmox/api/backups/${currentServerId}/backup`, {
|
|
method: 'DELETE',
|
|
body: JSON.stringify({ node, storage, volid }),
|
|
})).json();
|
|
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 (await fetchWithAuth('/proxmox/api/backups/create', { method: 'POST', body: JSON.stringify(payload) })).json();
|
|
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 (await fetchWithAuth('/proxmox/api/backups/restore', { method: 'POST', body: JSON.stringify(payload) })).json();
|
|
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 (await fetchWithAuth(`/proxmox/api/backups/task/${serverId}/${node}/${encodeURIComponent(upid)}`)).json();
|
|
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 (await fetchWithAuth(`/proxmox/api/backups/storages/${currentServerId}`)).json();
|
|
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') }}">
|
|
<i class="fa-solid fa-trash"></i>
|
|
</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 (await fetchWithAuth(`/proxmox/api/backups/storages/${currentServerId}/${storageId}`, { method: 'DELETE' })).json();
|
|
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 (await fetchWithAuth(`/proxmox/api/backups/storages/${currentServerId}`, { method: 'POST', body: JSON.stringify(payload) })).json();
|
|
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 (await fetchWithAuth('/proxmox/api/backups/jobs')).json();
|
|
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
|
|
? '<i class="fa-solid fa-check"></i>'
|
|
: '<i class="fa-solid fa-xmark"></i>';
|
|
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') }}">
|
|
<i class="fa-solid fa-play"></i>
|
|
</button>
|
|
<button class="btn btn-sm btn-outline-secondary" onclick='openEditJobModal(${JSON.stringify(j)})' title="{{ t('edit') }}">
|
|
<i class="fa-solid fa-pencil"></i>
|
|
</button>
|
|
<button class="btn btn-sm btn-outline-danger" onclick="deleteJob(${j.id})" title="{{ t('delete') }}">
|
|
<i class="fa-solid fa-trash"></i>
|
|
</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 (await fetchWithAuth(`/proxmox/api/backups/storages/${serverId}`)).json();
|
|
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 (await fetchWithAuth(url, { method, body: JSON.stringify(payload) })).json();
|
|
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 (await fetchWithAuth(`/proxmox/api/backups/jobs/${jobId}/toggle`, { method: 'PATCH' })).json();
|
|
if (r.success) loadJobs();
|
|
} catch(e) { showToast('{{ t("error") }}', e.message, 'danger'); }
|
|
}
|
|
|
|
async function runJobNow(jobId) {
|
|
try {
|
|
const r = await (await fetchWithAuth(`/proxmox/api/backups/jobs/${jobId}/run-now`, { method: 'POST' })).json();
|
|
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 (await fetchWithAuth(`/proxmox/api/backups/jobs/${jobId}`, { method: 'DELETE' })).json();
|
|
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 %}
|