Files
gh-christianlouis-docuelevate/frontend/templates/backup.html
copilot-swe-agent[bot] 64577f2d92 i18n: translate 6 HTML Jinja2 templates with _() calls
Replace all hardcoded English visible text with {{ _("key") }} calls in:
- queue_dashboard.html: headings, stat cards, table headers, empty states
- api_tokens.html: form labels, table headers, status badges, aria-labels
- similarity_dashboard.html: headings, stat labels, controls, empty states
- backup.html: buttons, config labels, table, retention section, confirm dialog
- shared_links.html: form labels/options, table headers, status badges
- duplicates.html: tabs, role tags, near-dup form, explanation bullets

Follows i18n rules: placeholder/aria-label/title attrs translated,
JS strings in <script> left as-is, Alpine.js bindings use Jinja2
server-side evaluation pattern for dynamic translated strings.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-11 12:22:46 +00:00

411 lines
20 KiB
HTML
Raw Permalink 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 %}{{ _("backup.page_title") }}{% endblock %}
{% block content %}
<div class="container mx-auto px-4 py-8" x-data="backupDashboard()">
<!-- Header -->
<div class="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 class="text-3xl font-bold text-gray-900">
<i class="fas fa-database mr-2 text-blue-600" aria-hidden="true"></i>
{{ _("backup.heading") }}
</h1>
<p class="mt-1 text-sm text-gray-500">
{{ _("backup.subtitle") }}
</p>
</div>
<div class="flex gap-2 flex-wrap">
<!-- Manual backup triggers -->
<button @click="triggerBackup('hourly')"
:disabled="triggering"
type="button"
class="inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 min-h-[44px]">
<i class="fas fa-clock mr-1 text-blue-500" aria-hidden="true"></i> {{ _("backup.trigger_hourly") }}
</button>
<button @click="triggerBackup('daily')"
:disabled="triggering"
type="button"
class="inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 min-h-[44px]">
<i class="fas fa-calendar-day mr-1 text-green-500" aria-hidden="true"></i> {{ _("backup.trigger_daily") }}
</button>
<button @click="triggerBackup('weekly')"
:disabled="triggering"
type="button"
class="inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 min-h-[44px]">
<i class="fas fa-calendar-week mr-1 text-purple-500" aria-hidden="true"></i> {{ _("backup.trigger_weekly") }}
</button>
<button @click="runCleanup()"
:disabled="triggering"
type="button"
class="inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 min-h-[44px]"
title="{{ _('backup.cleanup_title') }}">
<i class="fas fa-broom mr-1 text-orange-400" aria-hidden="true"></i> {{ _("backup.clean_up") }}
</button>
</div>
</div>
<!-- Status flash (always in DOM; content toggled via aria-live) -->
<div role="alert" aria-live="polite" aria-atomic="true"
:class="flashMsg ? '' : 'sr-only'"
class="border-l-4 p-4 mb-4 rounded transition-all"
:style="flashMsg ? '' : 'pointer-events:none'"
x-bind:class="flashMsg ? (flashError ? 'bg-red-100 border-red-500 text-red-700' : 'bg-green-100 border-green-500 text-green-700') : 'sr-only'">
<span x-text="flashMsg"></span>
</div>
<!-- Accessible confirm dialog -->
<div x-show="confirmOpen" x-cloak
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50"
role="dialog" aria-modal="true" :aria-labelledby="'confirmTitle'">
<div class="bg-white rounded-lg shadow-xl max-w-sm w-full p-6"
@keydown.escape.window="confirmOpen = false">
<h2 id="confirmTitle" class="text-lg font-semibold text-gray-900 mb-2">{{ _("backup.confirm_title") }}</h2>
<p class="text-sm text-gray-600 mb-4" x-text="confirmMsg"></p>
<div class="flex justify-end gap-3">
<button @click="confirmOpen = false; confirmResolve(false)"
type="button"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 min-h-[44px]">
{{ _("common.cancel") }}
</button>
<button @click="confirmOpen = false; confirmResolve(true)"
type="button"
class="px-4 py-2 text-sm font-medium text-white bg-red-600 border border-transparent rounded-md hover:bg-red-700 min-h-[44px]">
{{ _("backup.confirm_btn") }}
</button>
</div>
</div>
</div>
<!-- Config summary -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<!-- Backup enabled -->
<div class="bg-white shadow rounded-lg p-4">
<p class="text-xs text-gray-500 uppercase tracking-wider">{{ _("backup.config_auto_backup") }}</p>
<p class="mt-1 text-lg font-semibold {% if backup_enabled %}text-green-600{% else %}text-red-600{% endif %}">
{% if backup_enabled %}<i class="fas fa-check-circle mr-1" aria-hidden="true"></i> {{ _("common.enabled") }}
{% else %}<i class="fas fa-times-circle mr-1" aria-hidden="true"></i> {{ _("common.disabled") }}{% endif %}
</p>
</div>
<!-- Remote destination -->
<div class="bg-white shadow rounded-lg p-4">
<p class="text-xs text-gray-500 uppercase tracking-wider">{{ _("backup.config_remote_dest") }}</p>
<p class="mt-1 text-lg font-semibold text-gray-800">
{% if backup_remote_destination %}
<i class="fas fa-cloud-upload-alt mr-1 text-blue-500" aria-hidden="true"></i>
{{ backup_remote_destination }}
{% else %}
<span class="text-gray-400"><i class="fas fa-hdd mr-1" aria-hidden="true"></i> {{ _("backup.local_only") }}</span>
{% endif %}
</p>
</div>
<!-- Retention hourly -->
<div class="bg-white shadow rounded-lg p-4">
<p class="text-xs text-gray-500 uppercase tracking-wider">{{ _("backup.config_retention") }}</p>
<p class="mt-1 text-sm text-gray-700">
<span class="font-semibold">{{ backup_retain_hourly }}</span> {{ _("backup.type_hourly") }} &middot;
<span class="font-semibold">{{ backup_retain_daily }}</span> {{ _("backup.type_daily") }} &middot;
<span class="font-semibold">{{ backup_retain_weekly }}</span> {{ _("backup.type_weekly") }}
</p>
</div>
<!-- Total backup size -->
<div class="bg-white shadow rounded-lg p-4">
<p class="text-xs text-gray-500 uppercase tracking-wider">{{ _("backup.config_total_size") }}</p>
<p class="mt-1 text-lg font-semibold text-gray-800" id="totalSize">
{{ (total_size / 1048576) | round(2) }} MB
</p>
</div>
</div>
<!-- Upload restore section -->
<div class="bg-white shadow rounded-lg p-6 mb-8">
<h2 class="text-lg font-semibold text-gray-900 mb-1">
<i class="fas fa-upload mr-2 text-yellow-500" aria-hidden="true"></i>{{ _("backup.restore_heading") }}
</h2>
<p class="text-sm text-gray-500 mb-4">
{{ _("backup.restore_desc_pre") }} <code>.db.gz</code> {{ _("backup.restore_desc_mid") }}
<strong class="text-red-600">{{ _("backup.restore_desc_warning") }}</strong>
</p>
<form id="restoreForm" @submit.prevent="submitRestore" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="flex flex-col sm:flex-row gap-3 items-start sm:items-end">
<div>
<label for="restoreFile" class="block text-sm font-medium text-gray-700 mb-1">{{ _("backup.restore_file_label") }}</label>
<input type="file" id="restoreFile" name="file" accept=".gz"
required
class="block w-full text-sm text-gray-700 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100">
</div>
<button type="submit"
:disabled="restoring"
class="inline-flex items-center px-4 py-2 bg-yellow-500 hover:bg-yellow-600 text-white text-sm font-medium rounded-md disabled:opacity-50 min-h-[44px]">
<i class="fas fa-undo mr-2" aria-hidden="true"></i>
<span x-text="restoring ? '{{ _('backup.restoring') }}' : '{{ _('backup.restore_btn') }}'"></span>
</button>
</div>
</form>
</div>
<!-- Backup list -->
<div class="bg-white shadow rounded-lg overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-900">
<i class="fas fa-list mr-2 text-gray-500" aria-hidden="true"></i>{{ _("backup.archives_heading") }}
<span class="ml-2 text-sm font-normal text-gray-400">({{ records | length }} {{ _("backup.records_label") }})</span>
</h2>
<!-- Tier filter -->
<div class="flex gap-2 text-sm">
<button @click="filterType = ''" :class="filterType === '' ? 'bg-gray-200 font-semibold' : 'hover:bg-gray-100'" class="px-2 py-1 rounded">{{ _("common.all") }}</button>
<button @click="filterType = 'hourly'" :class="filterType === 'hourly' ? 'bg-blue-200 font-semibold' : 'hover:bg-gray-100'" class="px-2 py-1 rounded">{{ _("backup.type_hourly") }}</button>
<button @click="filterType = 'daily'" :class="filterType === 'daily' ? 'bg-green-200 font-semibold' : 'hover:bg-gray-100'" class="px-2 py-1 rounded">{{ _("backup.type_daily") }}</button>
<button @click="filterType = 'weekly'" :class="filterType === 'weekly' ? 'bg-purple-200 font-semibold' : 'hover:bg-gray-100'" class="px-2 py-1 rounded">{{ _("backup.type_weekly") }}</button>
</div>
</div>
{% if records %}
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200" aria-label="{{ _('backup.table_aria') }}">
<thead class="bg-gray-50">
<tr>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ _("backup.col_filename") }}</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ _("backup.col_type") }}</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ _("backup.col_created") }}</th>
<th scope="col" class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">{{ _("backup.col_size") }}</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ _("common.status") }}</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ _("backup.col_storage") }}</th>
<th scope="col" class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">{{ _("common.actions") }}</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{% for r in records %}
<tr class="hover:bg-gray-50"
x-show="filterType === '' || filterType === '{{ r.backup_type }}'"
data-backup-id="{{ r.id }}">
<td class="px-4 py-3 text-sm text-gray-900 font-mono truncate max-w-xs" title="{{ r.filename }}">
{{ r.filename }}
</td>
<td class="px-4 py-3 text-sm">
{% if r.backup_type == 'hourly' %}
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800">
<i class="fas fa-clock mr-1" aria-hidden="true"></i> {{ _("backup.type_hourly") }}
</span>
{% elif r.backup_type == 'daily' %}
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">
<i class="fas fa-calendar-day mr-1" aria-hidden="true"></i> {{ _("backup.type_daily") }}
</span>
{% else %}
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-800">
<i class="fas fa-calendar-week mr-1" aria-hidden="true"></i> {{ _("backup.type_weekly") }}
</span>
{% endif %}
</td>
<td class="px-4 py-3 text-sm text-gray-600 whitespace-nowrap">
{% if r.created_at %}{{ r.created_at.strftime('%Y-%m-%d %H:%M') }} UTC{% endif %}
</td>
<td class="px-4 py-3 text-sm text-gray-600 text-right whitespace-nowrap">
{% if r.size_bytes %}
{{ (r.size_bytes / 1024) | round(1) }} KB
{% else %}—{% endif %}
</td>
<td class="px-4 py-3 text-sm">
{% if r.status == 'ok' %}
<span class="inline-flex items-center text-green-700">
<i class="fas fa-check-circle mr-1" aria-hidden="true"></i> {{ _("backup.status_ok") }}
</span>
{% else %}
<span class="inline-flex items-center text-red-600">
<i class="fas fa-exclamation-circle mr-1" aria-hidden="true"></i> {{ r.status }}
</span>
{% endif %}
</td>
<td class="px-4 py-3 text-sm text-gray-600">
{% if r.local_path %}
<span class="inline-flex items-center" title="{{ r.local_path }}">
<i class="fas fa-hdd mr-1 text-gray-400" aria-hidden="true"></i> {{ _("backup.storage_local") }}
</span>
{% endif %}
{% if r.remote_destination %}
<span class="inline-flex items-center ml-2 text-blue-600" title="{{ r.remote_path }}">
<i class="fas fa-cloud mr-1" aria-hidden="true"></i> {{ r.remote_destination }}
</span>
{% endif %}
{% if not r.local_path and not r.remote_destination %}
<span class="text-gray-400"></span>
{% endif %}
</td>
<td class="px-4 py-3 text-sm text-right whitespace-nowrap">
{% if r.local_path %}
<a href="/api/admin/backup/{{ r.id }}/download"
class="inline-flex items-center px-2 py-1 text-xs border border-gray-300 rounded hover:bg-gray-50 text-gray-700 min-h-[32px]"
aria-label="{{ _('common.download') }} {{ r.filename }}">
<i class="fas fa-download mr-1" aria-hidden="true"></i> {{ _("common.download") }}
</a>
{% endif %}
<button @click="deleteBackup({{ r.id }}, '{{ r.filename }}')"
type="button"
class="inline-flex items-center px-2 py-1 text-xs border border-red-300 rounded hover:bg-red-50 text-red-600 ml-1 min-h-[32px]"
aria-label="{{ _('common.delete') }} {{ r.filename }}">
<i class="fas fa-trash-alt mr-1" aria-hidden="true"></i> {{ _("common.delete") }}
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="px-6 py-12 text-center text-gray-400">
<i class="fas fa-database text-4xl mb-3" aria-hidden="true"></i>
<p class="text-lg font-medium">{{ _("backup.no_backups") }}</p>
<p class="text-sm">{{ _("backup.no_backups_hint_pre") }}<em>{{ _("backup.backup_now") }}</em>{{ _("backup.no_backups_hint_post") }}</p>
</div>
{% endif %}
</div>
<!-- Retention explanation -->
<div class="mt-8 bg-gray-50 border border-gray-200 rounded-lg p-5">
<h3 class="text-sm font-semibold text-gray-700 mb-2">
<i class="fas fa-info-circle mr-1 text-blue-400" aria-hidden="true"></i> {{ _("backup.retention_heading") }}
</h3>
<ul class="text-sm text-gray-600 space-y-1 list-disc list-inside">
<li><strong>{{ _("backup.type_hourly") }}</strong> {{ _("backup.retention_hourly_detail") }} ({{ backup_retain_hourly }} {{ _("backup.snapshots") }})</li>
<li><strong>{{ _("backup.type_daily") }}</strong> {{ _("backup.retention_daily_detail") }} ({{ backup_retain_daily }} {{ _("backup.snapshots") }})</li>
<li><strong>{{ _("backup.type_weekly") }}</strong> {{ _("backup.retention_weekly_detail") }} ({{ backup_retain_weekly }} {{ _("backup.snapshots") }})</li>
</ul>
<p class="text-xs text-gray-400 mt-2">
{{ _("backup.retention_note_pre") }}<em>{{ _("backup.clean_up") }}</em>{{ _("backup.retention_note_post") }}
</p>
</div>
</div>
<script>
function backupDashboard() {
return {
triggering: false,
restoring: false,
filterType: '',
flashMsg: '',
flashError: false,
confirmOpen: false,
confirmMsg: '',
confirmResolve: null,
/** Show a timed status flash message. */
flash(msg, isError = false) {
this.flashMsg = msg;
this.flashError = isError;
setTimeout(() => { this.flashMsg = ''; }, 5000);
},
/** Return CSRF token value, or throw if missing. */
csrfToken() {
const el = document.querySelector('[name=csrf_token]');
if (!el || !el.value) throw new Error('CSRF token missing cannot proceed.');
return el.value;
},
/** Show the accessible confirm dialog and return a Promise<boolean>. */
askConfirm(msg) {
this.confirmMsg = msg;
this.confirmOpen = true;
return new Promise(resolve => { this.confirmResolve = resolve; });
},
async triggerBackup(type) {
this.triggering = true;
try {
const resp = await fetch(`/api/admin/backup/create?backup_type=${type}`, {
method: 'POST',
headers: { 'X-CSRF-Token': this.csrfToken() },
});
if (resp.ok) {
const data = await resp.json();
this.flash(`${type.charAt(0).toUpperCase() + type.slice(1)} backup queued (task ${data.task_id}).`);
setTimeout(() => location.reload(), 3000);
} else {
const err = await resp.json();
this.flash(`Error: ${err.detail || resp.statusText}`, true);
}
} catch (e) {
this.flash(`Network error: ${e}`, true);
} finally {
this.triggering = false;
}
},
async runCleanup() {
this.triggering = true;
try {
const resp = await fetch('/api/admin/backup/cleanup', {
method: 'POST',
headers: { 'X-CSRF-Token': this.csrfToken() },
});
if (resp.ok) {
this.flash('Cleanup queued.');
setTimeout(() => location.reload(), 3000);
} else {
const err = await resp.json();
this.flash(`Error: ${err.detail || resp.statusText}`, true);
}
} catch (e) {
this.flash(`Network error: ${e}`, true);
} finally {
this.triggering = false;
}
},
async deleteBackup(id, filename) {
const ok = await this.askConfirm(`Delete backup "${filename}"? This cannot be undone.`);
if (!ok) return;
try {
const resp = await fetch(`/api/admin/backup/${id}`, {
method: 'DELETE',
headers: { 'X-CSRF-Token': this.csrfToken() },
});
if (resp.ok) {
this.flash(`Backup ${filename} deleted.`);
const row = document.querySelector(`[data-backup-id="${id}"]`);
if (row) row.remove();
} else {
const err = await resp.json();
this.flash(`Error: ${err.detail || resp.statusText}`, true);
}
} catch (e) {
this.flash(`Network error: ${e}`, true);
}
},
async submitRestore() {
const form = document.getElementById('restoreForm');
const fileInput = document.getElementById('restoreFile');
if (!fileInput.files.length) return;
const ok = await this.askConfirm('Are you sure? This will OVERWRITE all current database data with the contents of the backup file.');
if (!ok) return;
this.restoring = true;
try {
const formData = new FormData(form);
const resp = await fetch('/api/admin/backup/restore', {
method: 'POST',
headers: { 'X-CSRF-Token': this.csrfToken() },
body: formData,
});
if (resp.ok) {
this.flash('Database restored successfully. The page will reload.');
setTimeout(() => location.reload(), 3000);
} else {
const err = await resp.json();
this.flash(`Restore failed: ${err.detail || resp.statusText}`, true);
}
} catch (e) {
this.flash(`Network error: ${e}`, true);
} finally {
this.restoring = false;
}
},
};
}
</script>
{% endblock %}