Files
ros_control/app/ui/static/app.js
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

128 lines
8.0 KiB
JavaScript

// ros_control UI: выбор строк, меню, окна, состояние фильтров. Без внешних библиотек (кроме htmx).
(() => {
const $ = (s, r = document) => r.querySelector(s);
const $$ = (s, r = document) => [...r.querySelectorAll(s)];
// --- выбор строк: панель фильтров ⇄ панель действий над выбранными ---
function syncSelection() {
const boxes = $$("input[data-select]");
const n = boxes.filter((b) => b.checked).length;
boxes.forEach((b) => b.closest("tr")?.classList.toggle("sel", b.checked));
const all = $("#check-all");
if (all) { all.checked = boxes.length > 0 && n === boxes.length; all.indeterminate = n > 0 && n < boxes.length; }
const bulk = $("#tools-bulk"), filters = $("#tools-filters");
if (bulk && filters) { bulk.hidden = n === 0; filters.hidden = n > 0; }
const cnt = $("#sel-count"); if (cnt) cnt.textContent = n;
}
// --- фильтры устройств: подсветка включённых полей и кнопка «Сбросить (N)» ---
function syncFilters() {
const form = $("#dev-filters"); if (!form) return;
let active = 0;
$$("select.field", form).forEach((s) => { const on = s.value !== ""; s.classList.toggle("on", on); active += on; });
if ($("input[name=f_q]", form)?.value.trim()) active += 1;
const reset = $("#filters-reset", form);
if (reset) { reset.hidden = active === 0; reset.textContent = `Сбросить (${active})`; }
}
// Выпадающие списки позиционируются от кнопки в координатах окна: их не обрезает прокручиваемая
// таблица, а внизу экрана список раскрывается вверх.
function placeMenu(menu) {
const panel = $(".menu-panel", menu), btn = $("summary", menu);
if (!panel || !btn) return;
panel.style.position = "fixed"; panel.style.top = "0"; panel.style.left = "0"; panel.style.right = "auto";
const b = btn.getBoundingClientRect(), w = panel.offsetWidth, h = panel.offsetHeight, gap = 4, edge = 8;
let left = menu.classList.contains("right") ? b.right - w : b.left;
left = Math.max(edge, Math.min(left, window.innerWidth - w - edge));
const below = b.bottom + gap, above = b.top - gap - h;
panel.style.left = `${left}px`;
panel.style.top = `${below + h > window.innerHeight - edge && above >= edge ? above : below}px`;
}
document.addEventListener("toggle", (e) => { if (e.target.matches?.("details.menu[open]")) placeMenu(e.target); }, true);
// при прокрутке/изменении размера список следует за кнопкой; если кнопка ушла за пределы окна — закрывается
function reflowMenu(e) {
if (e?.target?.closest?.(".menu-panel")) return;
const menu = $("details.menu[open]"); if (!menu) return;
const r = $("summary", menu).getBoundingClientRect();
if (r.bottom < 0 || r.top > window.innerHeight || r.right < 0 || r.left > window.innerWidth) menu.removeAttribute("open");
else placeMenu(menu);
}
window.addEventListener("scroll", reflowMenu, true);
window.addEventListener("resize", reflowMenu);
const closeMenus = (keep) => $$("details.menu[open]").forEach((m) => { if (m !== keep) m.removeAttribute("open"); });
document.addEventListener("change", (e) => {
const t = e.target;
if (t.matches("input[data-select]")) syncSelection();
if (t.id === "check-all") { $$("input[data-select]").forEach((b) => (b.checked = t.checked)); syncSelection(); }
if (t.closest("#dev-filters")) syncFilters();
// «Новая группа…» в списке групп окна устройства
if (t.matches("select[name=group_id][data-newgroup]")) $("#new-group-field").hidden = t.value !== "__new__";
// предупреждение о HTTP
if (t.matches("input[name=use_tls]")) $("#http-note").hidden = t.checked;
});
document.addEventListener("input", (e) => { if (e.target.closest("#dev-filters")) syncFilters(); });
document.addEventListener("click", (e) => {
const t = e.target;
if (t.closest("#clear-sel")) { $$("input[data-select]").forEach((b) => (b.checked = false)); syncSelection(); }
// меню: закрывается по клику вне и после выбора пункта; открыто не более одного
if (t.closest("details.sub > summary")) return; // раскрытие подменю («Сменить канал…») меню не закрывает
const menu = t.closest("details.menu");
closeMenus(t.closest(".menu-panel") ? null : menu);
// окно: закрытие по кнопке и по клику на подложку
const modal = $("#modal");
if (modal && (t.closest("[data-close]") || t === modal)) modal.close();
});
document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeMenus(null); });
// окно открывается, когда HTMX положил в него содержимое
document.addEventListener("htmx:afterSwap", (e) => {
const modal = $("#modal");
if (e.detail.target.id === "modal-body") {
if (modal && !modal.open) modal.showModal();
$("#modal-body [autofocus], #modal-body input:not([type=hidden]):not([readonly])")?.focus();
const sel = $("select[name=group_id][data-newgroup]"); if (sel) $("#new-group-field").hidden = sel.value !== "__new__";
}
syncSelection(); syncFilters(); syncEnables();
});
// --- автообновление статусов: сервер опрашивает устройства сам, страница подтягивает результат ---
const REFRESH_MS = 15000;
function autoRefresh() {
const form = $("#dev-filters"), target = $("#devices");
if (!form || !target || document.hidden) return;
const busy = $$("input[name=device_ids]:checked").length || $("details[open]") || $("#modal")?.open || $("#dev-card .htmx-request");
if (busy || form.contains(document.activeElement)) return; // не мешаем работе пользователя
const params = new URLSearchParams(new FormData(form)); params.set("poll", "1");
htmx.ajax("GET", `/ui/devices?${params}`, { target: "#devices", swap: "innerHTML" });
}
setInterval(autoRefresh, REFRESH_MS);
// --- журнал: копирование ID, подтверждение очистки, строка таблицы открывает запись ---
function legacyCopy(text) { // запасной путь: http вне localhost или нет разрешения на буфер обмена
const ta = document.createElement("textarea"); ta.value = text; ta.style.position = "fixed"; ta.style.opacity = "0";
document.body.appendChild(ta); ta.select();
try { document.execCommand("copy"); } finally { ta.remove(); }
}
function copyText(text) {
if (navigator.clipboard && window.isSecureContext) {
return navigator.clipboard.writeText(text).catch(() => legacyCopy(text));
}
legacyCopy(text);
return Promise.resolve();
}
document.addEventListener("click", (e) => {
const c = e.target.closest("[data-copy]");
if (c) { copyText(c.dataset.copy).then(() => { const t = c.title; c.title = "Скопировано"; c.setAttribute("aria-label", "Скопировано"); setTimeout(() => { c.title = t; c.setAttribute("aria-label", "Копировать"); }, 1200); }); return; }
const tr = e.target.closest("tr.clickable");
if (tr && !e.target.closest("a, button")) tr.querySelector("a.row-link")?.click();
});
const syncEnables = () => $$("input[data-enables]").forEach((i) => { const t = $(i.dataset.enables); if (t) t.disabled = i.disabled || !i.value; });
document.addEventListener("input", (e) => { if (e.target.matches("input[data-enables]")) syncEnables(); });
const init = () => { syncSelection(); syncFilters(); syncEnables(); };
document.readyState === "loading" ? document.addEventListener("DOMContentLoaded", init) : init();
})();