1189 lines
60 KiB
HTML
1189 lines
60 KiB
HTML
{% extends "base.html" %}
|
||
|
||
{% block title %}{{ t('nav_backups') }}{% endblock %}
|
||
|
||
{% block content %}
|
||
<div class="page-container">
|
||
<div class="page-header-block">
|
||
<div class="page-header-left">
|
||
<nav class="breadcrumb-nav">{{ t('dashboard') }} / {{ t('nav_backups') }}</nav>
|
||
<h1 class="page-header-title"><i class="fa-solid fa-hard-drive"></i> {{ t('nav_backups') }}</h1>
|
||
</div>
|
||
<div class="page-header-right">
|
||
<div class="d-flex align-items-center gap-2">
|
||
<label for="serverSelect" class="form-label mb-0 text-nowrap fw-semibold">
|
||
<i class="fa-solid fa-server me-1"></i>{{ t('server') }}:
|
||
</label>
|
||
<select id="serverSelect" class="form-select form-select-sm" style="min-width:260px" 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>
|
||
</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) {
|
||
const confirmed = await showDeleteConfirm('{{ t("backup_delete_confirm") }}');
|
||
if (!confirmed) 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) {
|
||
const confirmed = await showDeleteConfirm('{{ t("backup_storage_delete_confirm") }}');
|
||
if (!confirmed) 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) {
|
||
const confirmed = await showDeleteConfirm('{{ t("backup_job_delete_confirm") }}');
|
||
if (!confirmed) 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 %}
|