Групповое удаление бэкапов (docs/changes/013): - страница «Резервные копии»: чекбоксы, «выбрать все», панель «Выбрано: N / Удалить / Снять выбор» вместо фильтров, подтверждение, итог удаления; - backups.delete_many: все ключи проверяются до удаления, удаление параллельное; UI POST /backups/delete-many, API POST /api/v1/backups/delete; - выбор строк в app.js обобщён (data-select) для устройств и бэкапов. Поддержка CHR (docs/changes/014): - у CHR нет /system/routerboard (HTTP 400): устройство больше не считается недоступным, версия FW не показывается, обновление FW пропускается; - «есть обновление ROS» определяется строгим сравнением версий (на канале long-term последняя версия может быть старше установленной); - в таблице указана причина недоступности: авторизация / ошибка ответа / нет соединения. Тесты: 11 из 11. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
106 lines
6.4 KiB
JavaScript
106 lines
6.4 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])")?.focus();
|
|
const sel = $("select[name=group_id][data-newgroup]"); if (sel) $("#new-group-field").hidden = sel.value !== "__new__";
|
|
}
|
|
syncSelection(); syncFilters();
|
|
});
|
|
|
|
// --- автообновление статусов: сервер опрашивает устройства сам, страница подтягивает результат ---
|
|
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);
|
|
|
|
const init = () => { syncSelection(); syncFilters(); };
|
|
document.readyState === "loading" ? document.addEventListener("DOMContentLoaded", init) : init();
|
|
})();
|