Глобально уникальные ID (docs/changes/016): - у устройств, групп, задач, резервных копий и записей журнала ID вида <префикс>_<uuid7> (dev_, grp_, job_, bkp_, evt_): типы не пересекаются, внутри типа ID не повторяются и сортируются по времени; - миграция БД v1 заменяет числовые ID с пересчётом ссылок (копия файла БД перед миграцией, сверка числа строк, одна транзакция); - резервная копия = пара файлов с одним bkp_ ID, файлы в S3 получают метаданные backup-id/device-id; синхронизация метаданных с бакетом; - журнал событий в БД (events): создание/изменение/удаление, задачи, бэкапы, смена online/offline, вход в UI; API чтения GET /api/v1/events. Журнал в UI, ротация и очистка (docs/changes/017): - страница «Журнал»: фильтры, подгрузка «Показать ещё», окно записи; - настройки ротации (срок и максимум записей) хранятся в БД (схема v2), ротация при старте, раз в час и после сохранения настроек; - очистка журнала только через окно с паролем пользователя, блокировка после 5 неверных попыток, остаётся запись о факте очистки; через API очистки нет. Тесты: 20 из 20. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
84 lines
5.9 KiB
HTML
84 lines
5.9 KiB
HTML
{% extends "base.html" %}
|
||
{% import "_ui.html" as ui %}
|
||
{% block content %}
|
||
<div class="page-head">
|
||
<div>
|
||
<h1>Резервные копии</h1>
|
||
<div class="sub">Бакет {{ bucket }} · {{ stats.total }} {{ stats.total|plural("файл", "файла", "файлов") }} · {{ stats.size|size }}</div>
|
||
</div>
|
||
<div class="actions"><a class="btn secondary" href="/backups?{{ request.url.query }}">{{ ui.icon("refresh") }}Обновить список</a></div>
|
||
</div>
|
||
|
||
{% if deleted >= 0 %}<div class="{{ 'form-err' if failed else 'form-ok' }}" role="status" style="margin-bottom:16px">
|
||
Удалено файлов: {{ deleted }}{% if failed %}; не удалось удалить: {{ failed }}{% endif %}</div>{% endif %}
|
||
|
||
<form id="bulk-form" method="post" action="/backups/delete-many" hidden><input type="hidden" name="next" value="{{ page_url }}"></form>
|
||
|
||
<div class="card">
|
||
<div class="tools" id="tools-filters">
|
||
<form method="get" action="/backups">
|
||
<select class="field {{ 'on' if flt.group }}" name="group" onchange="this.form.submit()" aria-label="Группа" style="width:170px">
|
||
<option value="">Группа: любая</option>
|
||
<option value="none" {{ "selected" if flt.group == "none" }}>Группа: без группы</option>
|
||
{% for g in groups %}<option value="{{ g.id }}" {{ "selected" if flt.group == g.id }}>Группа: {{ g.name }}</option>{% endfor %}
|
||
</select>
|
||
<select class="field {{ 'on' if flt.device }}" name="device" onchange="this.form.submit()" aria-label="Устройство" style="width:190px">
|
||
<option value="">Устройство: любое</option>
|
||
{% for d in devices %}<option value="{{ d.name }}" {{ "selected" if d.name == flt.device }}>Устройство: {{ d.name }}</option>{% endfor %}
|
||
</select>
|
||
<select class="field {{ 'on' if flt.kind }}" name="kind" onchange="this.form.submit()" aria-label="Тип файла" style="width:150px">
|
||
<option value="">Тип: любой</option>
|
||
<option value="backup" {{ "selected" if flt.kind == "backup" }}>Тип: .backup</option>
|
||
<option value="rsc" {{ "selected" if flt.kind == "rsc" }}>Тип: .rsc</option>
|
||
</select>
|
||
<label class="datefield">с <input type="date" name="date_from" value="{{ flt.date_from }}" onchange="this.form.submit()" aria-label="Дата с"></label>
|
||
<label class="datefield">по <input type="date" name="date_to" value="{{ flt.date_to }}" onchange="this.form.submit()" aria-label="Дата по"></label>
|
||
<label class="search">{{ ui.icon("search") }}<input type="search" name="q" value="{{ flt.q }}" placeholder="Имя файла" aria-label="Поиск по имени файла" style="width:220px"></label>
|
||
<span class="grow"></span>
|
||
{% if flt_active %}<a class="btn ghost" href="/backups">Сбросить ({{ flt_active }})</a>{% endif %}
|
||
</form>
|
||
</div>
|
||
<div class="tools bulk" id="tools-bulk" hidden>
|
||
<span class="sel-count">Выбрано: <span id="sel-count">0</span></span>
|
||
<button type="submit" form="bulk-form" class="btn danger"
|
||
onclick="return confirm('Удалить выбранные файлы (' + document.querySelectorAll('input[name=key]:checked').length + ') из бакета? Действие необратимо.')">{{ ui.icon("trash") }}Удалить</button>
|
||
<span class="grow"></span>
|
||
<button type="button" id="clear-sel" class="btn ghost-n">{{ ui.icon("x") }}Снять выбор</button>
|
||
</div>
|
||
{% if error %}<div class="form-err" role="alert" style="margin:0 16px 16px">Ошибка S3: {{ error }}</div>{% endif %}
|
||
<div class="table-wrap">
|
||
<table class="grid files">
|
||
<colgroup><col style="width:44px"><col style="width:20%"><col style="width:140px"><col style="width:100px"><col><col style="width:100px"><col style="width:170px"><col style="width:104px"></colgroup>
|
||
<thead><tr><th><input type="checkbox" id="check-all" aria-label="Выбрать все"></th><th>Устройство</th><th>Группа</th><th>Тип</th><th>Файл</th><th>Размер</th><th>Изменён (UTC)</th><th><span class="sr-only">Действия</span></th></tr></thead>
|
||
<tbody>
|
||
{% for i in items %}
|
||
<tr>
|
||
<td><input type="checkbox" name="key" value="{{ i.key }}" form="bulk-form" data-select aria-label="Выбрать {{ i.key.split('/')[-1] }}"></td>
|
||
<td style="font-weight:600">{{ i.device or "—" }}</td>
|
||
<td class="muted">{{ i.group or "—" }}</td>
|
||
<td><span class="badge">{{ {"backup": ".backup", "rsc": ".rsc"}.get(i.kind, "—") }}</span></td>
|
||
<td class="c-mono" title="{{ i.key }}{% if i.backup_id %} · {{ i.backup_id }}{% endif %}" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ i.key.split("/")[-1] }}</td>
|
||
<td class="c-small">{{ i.size|size }}</td>
|
||
<td class="c-small">{{ i.last_modified|dt }}</td>
|
||
<td class="c-menu">
|
||
<a class="btn ghost-n icon" href="/backups/download?key={{ i.key|urlencode }}" aria-label="Скачать {{ i.key.split('/')[-1] }}" title="Скачать">{{ ui.icon("download") }}</a>
|
||
<details class="menu right">
|
||
<summary class="btn ghost-n icon" aria-label="Действия" title="Действия">{{ ui.icon("more") }}</summary>
|
||
<div class="menu-panel w200">
|
||
<form method="post" action="/backups/delete" onsubmit="return confirm('Удалить файл из бакета?')">
|
||
<input type="hidden" name="key" value="{{ i.key }}"><button class="mi bad">Удалить файл</button>
|
||
</form>
|
||
</div>
|
||
</details>
|
||
</td>
|
||
</tr>
|
||
{% else %}
|
||
<tr><td colspan="8" class="empty">Файлов не найдено.</td></tr>
|
||
{% endfor %}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<div class="tfoot">Найдено {{ items|length }} из {{ stats.total }} {{ stats.total|plural("файла", "файлов", "файлов") }}</div>
|
||
</div>
|
||
{% endblock %}
|