Files
ros_control/app/ui/static/app.js
T
ayurishchevandClaude Sonnet 5 1e5fb17755 Имя устройства задаётся только при создании
Имя входит в ключи бэкапов в S3 (backups/<имя>/…), поэтому после создания
не меняется (docs/changes/015):
- API: PATCH с другим именем → 400 «Имя устройства нельзя изменить»,
  то же имя допустимо;
- UI: в окне изменения поле имени только для чтения, форма изменения
  присланное имя игнорирует;
- вёрстка: минимальная ширина таблиц устройств и файлов — в узком окне
  колонка с именем не схлопывается, включается горизонтальная прокрутка.

Тесты: 12 из 12.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 15:53:08 +03:00

106 lines
6.5 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();
});
// --- автообновление статусов: сервер опрашивает устройства сам, страница подтягивает результат ---
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();
})();