Files
ros_control/app/ui/templates/backups.html
T
ayurishchevandClaude Sonnet 5 26dd1f8be4 Уникальные ID сущностей, журнал событий и его UI с ротацией и очисткой
Глобально уникальные 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>
2026-09-19 17:01:52 +03:00

84 lines
5.9 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" %}
{% 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 %}