Files
pvemanager/backend/app/templates/backups.html
markmorado b3db346cfb 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
2026-03-08 18:29:29 +01:00

1183 lines
59 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% 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">
<!-- Нативные расписания 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>
<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">
<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 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">
<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 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>
<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 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>
<option value="suspend">suspend</option>
<option value="stop">stop</option>
</select>
</div>
<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="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 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-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 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>
<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 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);
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) {}
}
// ===== 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('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;
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;
applySchedToUI(parseCronToUI(job.cron_expression));
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 %}