Files
ipam_control/web/app.js
T
ayurishchevandClaude Opus 5.5 a6e70ffd1a Задача 038: тёмная тема UI
Все цвета — CSS-переменные; тёмная палитра по prefers-color-scheme (режим «авто»)
и принудительно через data-theme; светлая тема не изменилась. Переключатель —
пиктограмма в шапке (авто / светлая / тёмная), выбор в localStorage; theme.js ставит
тему до отрисовки CSS (без вспышки, без ослабления CSP). QR 2FA на белой подложке.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 17:44:48 +03:00

1229 lines
102 KiB
JavaScript
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.
// IPAM Manager — UI-админка. Только визуализирует ответы /api/v1, бизнес-логики нет.
const API = "/api/v1";
const $ = (s) => document.querySelector(s);
const esc = (v) => String(v ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
const fmtNum = (n) => new Intl.NumberFormat("ru-RU").format(n);
const fmtDate = (iso) => (iso ? iso.replace("T", " ").slice(0, 16) : "—");
const plural = (n, one, few, many) => {
const m10 = n % 10, m100 = n % 100;
return m10 === 1 && m100 !== 11 ? one : m10 >= 2 && m10 <= 4 && (m100 < 10 || m100 >= 20) ? few : many;
};
/* ------------------------------------------------------------------ icons */
const ic = (inner, s = 16) => `<svg width="${s}" height="${s}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${inner}</svg>`;
const I = {
chevR: (s) => ic('<path d="m9 6 6 6-6 6"/>', s),
chevD: (s) => ic('<path d="m6 9 6 6 6-6"/>', s),
plus: () => ic('<path d="M12 5v14M5 12h14"/>'),
search: () => ic('<circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/>'),
dots: () => ic('<circle cx="5" cy="12" r="1.3"/><circle cx="12" cy="12" r="1.3"/><circle cx="19" cy="12" r="1.3"/>'),
close: () => ic('<path d="M6 6l12 12M18 6 6 18"/>'),
edit: (s) => ic('<path d="M12 20h9M16.5 3.5a2.121 2.121 0 1 1 3 3L7 19l-4 1 1-4Z"/>', s),
trash: (s) => ic('<path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"/>', s),
org: () => ic('<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M7 9h4M7 13h4"/>'),
gear: () => ic('<path d="M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z"/><path d="M19.4 13a7.6 7.6 0 0 0 .1-1 7.6 7.6 0 0 0-.1-1l2-1.6-2-3.4-2.3.9a7.4 7.4 0 0 0-1.7-1l-.3-2.5h-4l-.3 2.5a7.4 7.4 0 0 0-1.7 1l-2.3-.9-2 3.4 2 1.6a7.6 7.6 0 0 0 0 2l-2 1.6 2 3.4 2.3-.9a7.4 7.4 0 0 0 1.7 1l.3 2.5h4l.3-2.5a7.4 7.4 0 0 0 1.7-1l2.3.9 2-3.4Z"/>'),
refresh: () => ic('<path d="M20 12a8 8 0 1 1-2.3-5.7M20 4v5h-5"/>'),
cog: () => ic('<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/>'),
trashJ: () => ic('<path d="M4 7h16M10 11v6M14 11v6M6 7l1 13h10l1-13M9 7V4h6v3"/>'),
calendar: () => ic('<rect x="3" y="5" width="18" height="16" rx="2"/><path d="M3 10h18M8 3v4M16 3v4"/>'),
copy: () => ic('<rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V6a2 2 0 0 1 2-2h9"/>'),
globe: () => `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M3.5 9h17M3.5 15h17"/><path d="M12 3a15 15 0 0 1 4 9 15 15 0 0 1-4 9"/><path d="M12 3a15 15 0 0 0-4 9 15 15 0 0 0 4 9"/></svg>`, // изменение 038: stroke="#fff" → currentColor, цвет задаёт .brand-logo{color:var(--on-primary)}
// изменение 038 (доработка): пиктограмма переключателя темы — солнце/луна/монитор по текущему режиму
sun: () => ic('<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.2 4.2l1.4 1.4M18.4 18.4l1.4 1.4M2 12h2M20 12h2M4.2 19.8l1.4-1.4M18.4 5.6l1.4-1.4"/>'),
moon: () => ic('<path d="M21 12.5A9 9 0 1 1 11.5 3a7 7 0 0 0 9.5 9.5Z"/>'),
monitor: () => ic('<rect x="3" y="4" width="18" height="12" rx="2"/><path d="M8 20h8M12 16v4"/>'),
};
/* ------------------------------------------------------------------- state */
const store = {
get token() { return sessionStorage.getItem("ipam.token"); },
set token(v) { v ? sessionStorage.setItem("ipam.token", v) : sessionStorage.removeItem("ipam.token"); },
get orgId() { return Number(localStorage.getItem("ipam.org")) || null; },
set orgId(v) { localStorage.setItem("ipam.org", v); },
};
// изменение 038: тема — "auto" (по умолчанию, следует prefers-color-scheme), "light" или "dark" (выбор пользователя, хранится в localStorage).
// Начальное применение — в web/theme.js (до отрисовки CSS, чтобы не было вспышки светлой темы); здесь только переключение из меню.
const THEME_LABEL = { auto: "авто", light: "светлая", dark: "тёмная" };
function getTheme() {
try { return localStorage.getItem("ipam.theme") || "auto"; } catch { return "auto"; }
}
function setTheme(t) {
try { t === "auto" ? localStorage.removeItem("ipam.theme") : localStorage.setItem("ipam.theme", t); } catch { /* localStorage недоступен — тема не сохранится между сессиями */ }
if (t === "auto") delete document.documentElement.dataset.theme;
else document.documentElement.dataset.theme = t;
}
let user = null;
let orgs = [];
let S = {}; // локальное состояние текущего экрана
let menu = null; // открытое всплывающее меню {id, items:[{label,value,cls}], on}
let pendingMfa = null; // изменение 037: {mfa_token} между первым и вторым шагом входа — только в памяти, не в sessionStorage
/* --------------------------------------------------------------------- api */
class ApiError extends Error {
constructor(status, body) {
super(body?.message || `Ошибка ${status}`);
this.status = status;
this.fields = body?.fields || {};
this.body = body || {};
}
}
async function api(path, { method = "GET", body, params } = {}) {
const qs = params ? "?" + new URLSearchParams(Object.entries(params).filter(([, v]) => v !== "" && v != null)) : "";
const res = await fetch(API + path + qs, {
method,
headers: { ...(body ? { "Content-Type": "application/json" } : {}), ...(store.token ? { Authorization: "Bearer " + store.token } : {}) },
body: body ? JSON.stringify(body) : undefined,
});
if (res.status === 204) return null;
const data = await res.json().catch(() => null);
if (res.status === 401 && path !== "/auth/login" && path !== "/auth/login/2fa") { // изменение 037: 401 здесь — истёкший/неверный mfa_token, обрабатывается на месте
store.token = null;
location.hash = "#/login";
throw new ApiError(401, { message: "Сессия истекла" });
}
if (!res.ok) throw new ApiError(res.status, data);
return data;
}
function toast(msg, err = false) {
const el = document.createElement("div");
el.className = "toast" + (err ? " err" : "");
el.textContent = msg;
$("#toast-root").append(el);
setTimeout(() => el.remove(), 3500);
}
/* --------------------------------------------------------- shared fragments */
const badge = (cls, text) => `<span class="badge ${cls}">${esc(text)}</span>`;
const PREFIX_STATUS = { active: ["green", "Активен"], reserved: ["amber", "Резерв"], deprecated: ["red", "Устарел"] };
const ADDR_STATUS = { assigned: ["green", "Назначен"], reserved: ["amber", "Резерв"], deprecated: ["red", "Устаревший"], free: ["", "Свободен"] };
// изменение 038: цвета — CSS-токены вместо литералов; третий элемент — признак «порог не превышен» (обычный/приглушённый вид)
const utilColor = (p) => (p >= 90 ? ["var(--danger-strong)", "var(--danger)", false] : p >= 75 ? ["var(--warn-strong)", "var(--warn)", false] : ["var(--primary)", "var(--muted)", true]);
const btn = (label, action, { cls = "", data = "", icon = "" } = {}) => `<button type="button" class="btn ${cls}" data-action="${action}" ${data}>${icon}${label}</button>`;
const iconBtn = (icon, action, label, data = "", sm = false, disabled = false) =>
`<button type="button" class="icon-btn${sm ? " sm" : ""}" data-action="${action}" aria-label="${esc(label)}" title="${esc(label)}" ${data} ${disabled ? "disabled" : ""}>${icon}</button>`;
const searchBox = (value, placeholder, width) =>
`<label class="search" style="width:${width}px"><span class="ico">${I.search()}</span><input type="search" id="q" data-input="q" placeholder="${esc(placeholder)}" aria-label="${esc(placeholder)}" value="${esc(value)}"></label>`;
const currentOrg = () => orgs.find((o) => o.id === store.orgId);
const isAdmin = () => user?.role !== "viewer"; // изменение 032: admin И superadmin
const isSuperadmin = () => user?.role === "superadmin"; // изменение 032
const ROLE_RU = { superadmin: "Суперадминистратор", admin: "Администратор", viewer: "Просмотр" }; // изменение 032
/* ------------------------------------------------ выбор строк и групповые операции (только admin) */
const selSet = () => (S.sel ||= new Set());
const selCols = (cols) => (isAdmin() ? "40px " + cols : cols);
function setSelectable(ids) { // выбираемые строки экрана; выделение усекается до присутствующих (фильтр/поиск не оставляют «невидимых»)
S.selectable = ids;
const keep = new Set(ids);
S.sel = new Set([...selSet()].filter((x) => keep.has(x)));
}
const selCell = (id, disabled = false) => (isAdmin()
? `<label class="sel-cell"><input type="checkbox" id="sel-${id}" data-action="sel" data-id="${id}" aria-label="Выбрать" ${selSet().has(id) ? "checked" : ""} ${disabled ? "disabled" : ""}></label>` : "");
const selBlank = () => (isAdmin() ? "<span></span>" : "");
const selAllCell = () => (isAdmin()
? `<label class="sel-cell"><input type="checkbox" id="sel-all" data-action="sel-all" aria-label="Выбрать все" ${S.selectable?.length && selSet().size === S.selectable.length ? "checked" : ""}></label>` : "");
const selRow = (id) => (selSet().has(id) ? " selected" : "");
const bulkMenu = (id, label, items) =>
`<span class="rel"><button type="button" class="btn" data-action="menu" data-menu="${id}">${label}${I.chevD(14)}</button>${popMenu(id, items)}</span>`;
const bulkBar = (extra = "") => (isAdmin() && selSet().size
? `<div class="bulkbar"><b>Выбрано: ${selSet().size}</b>${extra}${btn("Удалить", "bulk-del", { cls: "danger", icon: I.trash(14) })}<span class="grow"></span>${btn("Снять выделение", "sel-clear", { cls: "ghost" })}</div>` : "");
function popMenu(id, items, extra = "") {
if (menu?.id !== id) return "";
return `<div class="pop ${extra}" role="menu">${items.map((it) => `<button type="button" role="menuitem" class="${it.cls || ""}" data-action="pick" data-menu="${id}" data-value="${esc(it.value)}">${esc(it.label)}</button>`).join("")}</div>`;
}
function filterBtn(id, label, items, value, width = 180) {
const cur = items.find((i) => String(i.value) === String(value));
return `<span class="rel"><button type="button" class="btn filter-btn" style="width:${width}px" data-action="menu" data-menu="${id}">${esc(label)}: ${esc(cur?.label ?? "любой")}${I.chevD(14)}</button>${popMenu(id, items.map((i) => ({ ...i, cls: String(i.value) === String(value) ? "sel" : "" })), "")}</span>`;
}
function orgSwitcher() {
const o = currentOrg();
// изменение 032: интерактивный переключатель для superadmin, статичная подпись для других
if (isSuperadmin()) {
return `<span class="rel" style="flex:none"><button type="button" class="org-btn" data-action="menu" data-menu="org">${I.org()}<span class="lbl">Организация:</span><span class="val">${esc(o?.name ?? "—")}</span>${I.chevD(14)}</button>${popMenu("org", orgs.map((x) => ({ label: x.name, value: x.id, cls: x.id === store.orgId ? "sel" : "" })))}</span><div class="vsep"></div>`;
}
return `<span style="flex:none;display:flex;align-items:center;gap:8px;color:var(--muted);font-size:13px">${I.org()}<span>Организация: <b style="font-weight:600;color:var(--text)">${esc(o?.name ?? "—")}</b></span></span><div class="vsep"></div>`;
}
const crumbsOrg = () => `<div class="crumbs"><a href="#/orgs" style="color:var(--muted)">Организации</a>${I.chevR(14)}<span class="cur">${esc(currentOrg()?.name ?? "")}</span></div>`;
const header = (title, sub, actions = "") => `<div class="head"><div><h1>${esc(title)}</h1><div class="sub">${sub}</div></div><div class="actions">${actions}</div></div>`;
const NAV = [["overview", "Обзор"], ["prefixes", "Префиксы"], ["orgs", "Организации"], ["isps", "Операторы"], ["devices", "Устройства"], ["journal", "Журнал"], ["users", "Пользователи", "superadmin"]]; // изменение 032: "superadmin"
function shell(active, content) {
const nav = NAV.filter(([, , role]) => role !== "superadmin" || isSuperadmin()); // изменение 032: раздел «Пользователи» виден только суперадминистратору
// изменение 036: логин в шапке — триггер выпадающего меню («Сменить пароль» / «Выйти») вместо отдельных кнопок
// изменение 037: пункт «Двухфакторная аутентификация» между «Сменить пароль» и «Выйти»
const userMenu = `<span class="rel user-menu"><button type="button" class="btn ghost" data-action="menu" data-menu="user" title="${esc(ROLE_RU[user?.role] ?? "")}">${esc(user?.username ?? "")}${I.chevD(14)}</button>${popMenu("user", [{ label: "Сменить пароль", value: "pw" }, { label: "Двухфакторная аутентификация", value: "2fa" }, { label: "Выйти", value: "logout" }], "row-pop")}</span>`;
// изменение 038 (доработка по проверке): переключатель темы — отдельная пиктограмма в шапке (не пункт меню пользователя)
const curTheme = getTheme();
const THEME_ICON = { auto: I.monitor(), light: I.sun(), dark: I.moon() };
const themeBtn = `<span class="rel"><button type="button" class="icon-btn" data-action="menu" data-menu="theme" aria-label="Тема: ${esc(THEME_LABEL[curTheme])}" title="Тема: ${esc(THEME_LABEL[curTheme])}">${THEME_ICON[curTheme]}</button>${popMenu("theme", [
{ label: "Авто (как в системе)", value: "auto", cls: curTheme === "auto" ? "sel" : "" },
{ label: "Светлая", value: "light", cls: curTheme === "light" ? "sel" : "" },
{ label: "Тёмная", value: "dark", cls: curTheme === "dark" ? "sel" : "" },
], "row-pop")}</span>`;
return `<div class="appbar"><div class="brand"><span class="brand-logo">${I.globe()}</span>ipam_manager</div>
<nav class="nav">${nav.map(([k, l]) => `<a href="#/${k}" class="${k === active ? "active" : ""}">${l}</a>`).join("")}</nav>
<div class="grow"></div><span style="display:flex;align-items:center;gap:8px">${themeBtn}${userMenu}</span></div>
<div class="page">${content}</div>`;
}
/* ------------------------------------------------------------------ dialogs */
function openDialog({ title, width = 560, note = "", body, foot }) {
$("#modal-root").innerHTML = `<div class="overlay" data-action="overlay"><div class="dialog" style="width:${width}px" role="dialog" aria-modal="true" aria-label="${esc(title)}">
<div class="dialog-head"><h2>${esc(title)}</h2>${iconBtn(I.close(), "close-dialog", "Закрыть")}</div>${note ? `<div class="dialog-note">${note}</div>` : ""}${body}${foot}</div></div>`;
$("#dlg-form .input, #dlg-form .select")?.focus();
}
const closeDialog = () => { $("#modal-root").innerHTML = ""; S.dialog = null; S.subnetRefresh = null; };
const opt = (label) => `<span class="opt"> ${label}</span>`;
const fInput = (name, label, { value = "", ph = "", mono = false, optional = false, type = "text" } = {}) =>
`<label class="field"><span>${label}${optional ? opt("необязательно") : ""}</span><input class="input${mono ? " mono" : ""}" type="${type}" name="${name}" value="${esc(value)}" placeholder="${esc(ph)}"></label>`;
const fArea = (name, label, { value = "", ph = "Любая дополнительная информация", rows = 2, h = 56, mono = false, optional = true, hint = "" } = {}) =>
`<label class="field"><span>${label}${optional ? opt("необязательно") : ""}${hint ? opt(hint) : ""}</span><textarea class="textarea${mono ? " mono" : ""}" name="${name}" rows="${rows}" placeholder="${esc(ph)}" style="height:${h}px${mono ? ";font-size:13px" : ""}">${esc(value)}</textarea></label>`;
// Выпадающий список в диалогах — тот же стиль, что у меню строк (не нативный <select>); значение хранится в скрытом поле
const fSelect = (name, label, options, value, { optional = false, hint = "", empty = "" } = {}) => {
const all = empty !== "" ? [{ value: "", label: empty }, ...options] : options;
const cur = all.find((o) => String(o.value) === String(value)) ?? all[0];
return `<div class="field"><span>${label}${optional ? opt("необязательно") : ""}${hint ? opt(hint) : ""}</span>
<div class="cselect"><input type="hidden" name="${name}" value="${esc(cur?.value ?? "")}">
<button type="button" class="select${cur?.value === "" ? " ph" : ""}" data-action="dd-toggle" aria-haspopup="listbox" aria-label="${esc(label)}"><span class="v">${esc(cur?.label ?? "")}</span></button>
<div class="pop dd" role="listbox" hidden>${all.map((o) => `<button type="button" role="option" class="${String(o.value) === String(cur?.value) ? "sel" : ""}" data-action="dd-pick" data-value="${esc(o.value)}" data-empty="${o.value === "" ? 1 : 0}">${esc(o.label)}</button>`).join("")}</div></div></div>`;
};
const dlgFoot = (submit) => `<div class="dialog-foot">${btn("Отмена", "close-dialog", { cls: "ghost" })}${btn(submit, "submit-form", { cls: "primary" })}</div>`;
const formBody = (inner) => `<form class="dialog-body" id="dlg-form" novalidate><div class="err-banner" id="dlg-err" hidden></div>${inner}</form>`;
function readForm() {
const out = {};
for (const el of $("#dlg-form").elements) {
if (!el.name) continue;
out[el.name] = el.type === "checkbox" ? el.checked : el.value.trim();
}
return out;
}
function showFormError(e) {
const box = $("#dlg-err");
if (!box) return toast(e.message, true);
const msgs = Object.entries(e.fields || {}).map(([f, m]) => (f ? `${f}: ${m}` : m));
box.textContent = msgs.length ? msgs.join(" · ") : e.message;
box.hidden = false;
for (const f of Object.keys(e.fields || {})) $(`#dlg-form [name="${f.split(".")[0]}"]`)?.classList.add("bad");
}
async function submitDialog() {
const h = S.dialog;
try {
await h(readForm());
// изменение 037: обработчик мог сам открыть следующее окно диалоговой цепочки (сменив S.dialog) — тогда
// закрывать нечего, иначе только что открытое окно закрылось бы сразу вслед за собой
if (S.dialog === h) closeDialog();
} catch (e) {
if (e instanceof ApiError) showFormError(e); else throw e;
}
}
/* ------------------------------------------------------------------- screens */
const screens = {};
// ---- overview
screens.overview = async () => {
const o = await api("/overview");
const [, tc] = utilColor(o.utilization);
const actionBadge = { created: ["blue", "создан"], assigned: ["green", "назначен"], deleted: ["red", "удалён"], updated: ["amber", "изменён"], delete_blocked: ["amber", "отклонено"] };
const top = o.top_prefixes.map((p, i, a) => {
const [bar, txt, isDefault] = utilColor(p.utilization); // изменение 038: сравнение с литералом цвета заменено на флаг isDefault
return `<div class="tr${i === a.length - 1 ? " last" : ""}" style="--cols:2fr 1fr 1fr;--h:48px"><span class="m13">${esc(p.prefix)}</span><span style="color:${txt};font-weight:${isDefault ? 400 : 500}">${fmtNum(p.used)}/${fmtNum(p.capacity)} (${p.utilization}%)</span><div class="bar"><div class="track" style="height:4px"><div class="fill" style="width:${p.utilization}%;background:${bar}"></div></div></div></div>`;
}).join("");
const recent = o.recent_changes.map((r, i, a) => {
const [c, t] = actionBadge[r.action] || ["", r.action];
return `<div class="tr${i === a.length - 1 ? " last" : ""}" style="--cols:140px 1.2fr 1fr 130px;--h:48px;font-size:13px"><span class="muted">${fmtDate(r.ts)}</span><span class="${["prefix", "address"].includes(r.entity_type) ? "m13" : ""}" style="${["prefix", "address"].includes(r.entity_type) ? "" : "font-size:13px"}">${esc(r.entity_label)}</span><span>${badge(c, t)}</span><span class="muted">${esc(r.username)}</span></div>`;
}).join("");
return shell("overview", `${header("Обзор", "Сводка по адресному пространству")}
<div class="stats">
<div class="card stat"><div class="l">Префиксов</div><div class="v">${fmtNum(o.prefixes)}</div><div class="s">${o.vrfs} VRF</div></div>
<div class="card stat"><div class="l">Адресов назначено</div><div class="v">${fmtNum(o.assigned)}</div><div class="s">из ${fmtNum(o.capacity)} возможных</div></div>
<div class="card stat"><div class="l">Использование</div><div class="v">${o.utilization}%</div><div class="track"><div style="width:${o.utilization}%"></div></div></div>
<div class="card stat"><div class="l">Зарезервировано</div><div class="v" style="color:var(--warn)">${fmtNum(o.reserved)}</div><div class="s">адресов</div></div></div>
<div class="two"><div class="card"><div class="card-title">Высокая загрузка</div>
<div class="tr th" style="--cols:2fr 1fr 1fr;--h:36px"><span>Префикс</span><span>Использовано</span><span></span></div>${top || '<div class="empty">Нет данных</div>'}</div>
<div class="card"><div class="card-title">Последние изменения</div>
<div class="tr th" style="--cols:140px 1.2fr 1fr 130px;--h:36px"><span>Дата (UTC)</span><span>Объект</span><span>Действие</span><span>Пользователь</span></div>${recent || '<div class="empty">Изменений пока нет</div>'}</div></div>`);
};
// ---- organizations
screens.orgs = async () => {
const q = S.q || "";
const { items, total } = await api("/organizations", { params: { q, limit: 500 } });
const all = q ? (await api("/organizations", { params: { limit: 1 } })).total : total;
setSelectable(items.map((o) => o.id));
const cols = selCols("minmax(200px,1.5fr) 110px 130px minmax(200px,1.5fr) 80px 80px 44px");
const rows = items.map((o, i) => {
const items2 = [{ label: "Открыть префиксы", value: "open:" + o.id }, { label: "Редактировать", value: "edit:" + o.id }]; // изменение 032
if (isSuperadmin()) items2.push({ label: "Удалить", value: "del:" + o.id, cls: "danger" }); // изменение 032: кнопка удаления только для superadmin
return `<div class="tr hoverable clickable${i === items.length - 1 ? " last" : ""}${selRow(o.id)}" data-row="1" data-action="org-open" data-id="${o.id}" style="--cols:${cols}">${selCell(o.id)}
<a href="#/prefixes" data-action="open-org" data-id="${o.id}" style="font-weight:500;color:var(--text)">${esc(o.name)}</a><span class="muted">${esc(o.short_name)}</span><span class="mono muted" style="font-size:13px">${esc(o.inn)}</span>
<span class="muted ell">${esc(o.address)}</span><a href="#/prefixes" data-action="open-org" data-id="${o.id}" style="font:500 13px var(--sans);text-align:center">${o.prefixes_count}</a><span style="font-size:13px;text-align:center" class="muted">${fmtNum(o.addresses_count)}</span>
<span class="cell-actions rel">${iconBtn(I.dots(), "menu", "Действия", `data-menu="org-row-${o.id}"`)}${popMenu("org-row-" + o.id, items2, "row-pop")}</span></div>`; // изменение 032
}).join("");
S.rows = items;
const addBtn = isSuperadmin() ? btn("Добавить организацию", "org-new", { cls: "primary", icon: I.plus() }) : ""; // изменение 032: кнопка только для superadmin
return shell("orgs", `${header("Организации", `${all} ${plural(all, "организация", "организации", "организаций")}`, addBtn)}
<div class="card"><div class="filters">${searchBox(q, "Поиск: название, ИНН, адрес", 300)}</div>
${bulkBar()}<div class="tr th" style="--cols:${cols}">${selAllCell()}<span>Название</span><span>Краткое имя</span><span>ИНН</span><span>Адрес</span><span>Префиксов</span><span>Адресов</span><span></span></div>
${rows || '<div class="empty">Ничего не найдено</div>'}<div class="foot">Показано ${items.length} из ${all} ${plural(all, "организации", "организаций", "организаций")}</div></div>`);
};
function orgDialog(o) {
const edit = !!o;
S.dialog = async (v) => {
await api(edit ? `/organizations/${o.id}` : "/organizations", { method: edit ? "PATCH" : "POST", body: v });
toast(edit ? "Организация сохранена" : "Организация добавлена");
await loadOrgs();
await draw();
};
openDialog({
title: edit ? "Редактирование организации" : "Новая организация",
body: formBody(`${fInput("name", "Название", { value: o?.name })}
<div class="grid2">${fInput("short_name", "Краткое имя", { value: o?.short_name })}${fInput("inn", "ИНН", { value: o?.inn })}</div>
${fInput("address", "Юридический адрес", { value: o?.address })}
<div class="grid2">${fInput("contact_person", "Контактное лицо", { value: o?.contact_person, optional: true })}${fInput("phone", "Телефон", { value: o?.phone, optional: true })}</div>
${fInput("email", "Email", { value: o?.email, optional: true, ph: "email@example.com" })}${fArea("note", "Примечание", { value: o?.note })}`),
foot: dlgFoot(edit ? "Сохранить" : "Добавить организацию"),
});
}
// ---- ISPs
screens.isps = async () => {
const q = S.q || "";
const { items } = await api("/isps", { params: { q, limit: 500 } });
const all = q ? (await api("/isps", { params: { limit: 1 } })).total : items.length;
S.rows = items;
const cols = selCols("minmax(130px,1fr) minmax(160px,1.2fr) minmax(180px,1.4fr) 150px 140px minmax(120px,1fr) 44px");
setSelectable(items.map((i) => i.id));
const rows = items.map((i, n) => `<div class="tr hoverable clickable${n === items.length - 1 ? " last" : ""}${selRow(i.id)}" data-row="1" data-action="isp-edit" data-id="${i.id}" style="--cols:${cols}">${selCell(i.id)}<span style="font-weight:500">${esc(i.name)}</span>
<a href="#/prefixes" data-action="open-org" data-id="${i.organization_id}" style="font-size:13px">${esc(i.organization_name)}</a>
<span style="display:flex;align-items:center"><span class="mono" style="font-size:13px">${esc(i.networks[0] ?? "—")}</span>${i.networks.length > 1 ? `<span class="more">+${i.networks.length - 1}</span>` : ""}</span>
<span class="mono muted" style="font-size:13px">${esc(i.hotline)}</span><span class="muted">${esc(i.contract_number)}</span><span class="muted ell">${esc(i.note)}</span>
<span class="cell-actions rel">${iconBtn(I.dots(), "menu", "Действия", `data-menu="isp-row-${i.id}"`)}${popMenu("isp-row-" + i.id, [{ label: "Редактировать", value: "edit:" + i.id }, { label: "Удалить", value: "del:" + i.id, cls: "danger" }], "row-pop")}</span></div>`).join("");
return shell("isps", `${header("Операторы связи", "Реестр провайдеров и каналов", btn("Добавить оператора", "isp-new", { cls: "primary", icon: I.plus() }))}
<div class="card"><div class="filters">${searchBox(q, "Поиск: оператор, организация, IP", 300)}</div>
${bulkBar()}<div class="tr th" style="--cols:${cols}">${selAllCell()}<span>Название</span><span>Организация</span><span>IP-адреса</span><span>Горячая линия</span><span>Договор</span><span>Заметки</span><span></span></div>
${rows || '<div class="empty">Ничего не найдено</div>'}<div class="foot">Показано ${items.length} из ${all} ${plural(all, "оператора", "операторов", "операторов")}</div></div>`);
};
function ispDialog(i) {
const edit = !!i;
S.dialog = async (v) => {
const body = { ...v, organization_id: Number(v.organization_id), networks: v.networks.split(/\s+/).filter(Boolean) };
await api(edit ? `/isps/${i.id}` : "/isps", { method: edit ? "PUT" : "POST", body });
toast(edit ? "Оператор сохранён" : "Оператор добавлен");
await draw();
};
openDialog({
title: edit ? "Редактирование оператора" : "Новый оператор связи",
body: formBody(`${fInput("name", "Название", { value: i?.name })}
${fSelect("organization_id", "Организация", orgs.map((o) => ({ value: o.id, label: o.name })), i?.organization_id ?? store.orgId)}
${fArea("networks", "IP-адреса", { value: (i?.networks || []).join("\n"), ph: "один CIDR на строку", rows: 3, h: 72, mono: true, optional: false, hint: "можно несколько" })}
<div class="grid2">${fInput("hotline", "Горячая линия", { value: i?.hotline })}${fInput("contract_number", "Номер договора", { value: i?.contract_number })}</div>
${fArea("note", "Примечание", { value: i?.note })}`),
foot: dlgFoot(edit ? "Сохранить" : "Добавить оператора"),
});
}
// ---- devices
screens.devices = async () => {
const org = currentOrg();
const [types, list] = await Promise.all([
api("/device-types"),
api("/devices", { params: { organization_id: org?.id, device_type_id: S.type, q: S.q, limit: 500 } }),
]);
S.types = types.items;
S.rows = list.items;
const cols = selCols("minmax(200px,1.6fr) 170px 110px minmax(220px,1.8fr) 44px");
setSelectable(list.items.map((d) => d.id));
const q = S.q || "";
const typeItems = [{ label: "любой", value: "" }, ...types.items.map((t) => ({ label: t.name, value: t.id }))];
const rows = list.items.map((d, n) => {
const dep = d.all_deprecated;
const ips = d.ip_addresses.length
? `<span style="display:flex;align-items:center"><a href="#/prefixes/${d.first_prefix_id}" style="font:500 13px var(--sans)">${d.ip_addresses.length}</a>${d.ip_addresses.length > 1 ? `<span class="more">+${d.ip_addresses.length - 1}</span>` : ""}</span>`
: `<span class="muted" style="font-size:13px">0</span>`;
return `<div class="tr hoverable clickable${n === list.items.length - 1 ? " last" : ""}${selRow(d.id)}" data-row="1" data-action="dev-edit" data-id="${d.id}" style="--cols:${cols}">${selCell(d.id)}<span class="m13" style="font-weight:600;${dep ? "color:var(--faint);text-decoration:line-through" : ""}">${esc(d.name)}</span>
<span class="${dep ? "" : "muted"}" style="${dep ? "color:var(--faint)" : ""}">${esc(d.device_type_name)}</span>${ips}<span class="ell" style="color:${dep ? "var(--faint)" : "var(--muted)"}">${esc(d.note)}</span>
<span class="cell-actions rel">${iconBtn(I.dots(), "menu", "Действия", `data-menu="dev-row-${d.id}"`)}${popMenu("dev-row-" + d.id, [{ label: "Редактировать", value: "edit:" + d.id }, { label: "Удалить", value: "del:" + d.id, cls: "danger" }], "row-pop")}</span></div>`;
}).join("");
const addBtn = btn("Добавить устройство", "dev-new", { cls: "primary", icon: I.plus() }); // изменение 033: устройство создаёт любой admin своей организации
return shell("devices", `${crumbsOrg()}${header("Устройства", `${list.total} ${plural(list.total, "устройство", "устройства", "устройств")} · организация: ${esc(org?.name ?? "")}`, addBtn)}
<div class="right-link"><a href="#" data-action="types">${I.gear()}Типы устройств</a></div>
<div class="card"><div class="filters">${orgSwitcher()}${searchBox(q, "Поиск: имя, IP, заметка", 260)}${filterBtn("type", "Тип", typeItems, S.type ?? "", 170)}</div>
${bulkBar(bulkMenu("bulk-type", "Сменить тип", types.items.map((t) => ({ label: t.name, value: t.id }))))}<div class="tr th" style="--cols:${cols}">${selAllCell()}<span>Устройство</span><span>Тип</span><span>IP-адресов</span><span>Заметки</span><span></span></div>
${rows || '<div class="empty">Устройств нет</div>'}<div class="foot">Показано ${list.items.length} из ${list.total} ${plural(list.total, "устройства", "устройств", "устройств")}</div></div>`);
};
function deviceDialog(d) {
const edit = !!d;
S.dialog = async (v) => {
const body = edit ? { name: v.name, device_type_id: Number(v.device_type_id), mac: v.mac, note: v.note }
: { ...v, device_type_id: Number(v.device_type_id), organization_id: Number(v.organization_id) };
await api(edit ? `/devices/${d.id}` : "/devices", { method: edit ? "PATCH" : "POST", body });
toast(edit ? "Устройство сохранено" : "Устройство добавлено");
await draw();
};
openDialog({
title: edit ? "Редактирование устройства" : "Новое устройство",
body: formBody(`${fInput("name", "Название (hostname)", { value: d?.name, ph: "host.internal" })}
<div class="grid2">${fSelect("device_type_id", "Тип", S.types.map((t) => ({ value: t.id, label: t.name })), d?.device_type_id)}${fInput("mac", "MAC-адрес", { value: d?.mac, ph: "00:1A:2B:3C:4D:5E", optional: true })}</div>
${edit ? "" : fSelect("organization_id", "Организация", orgs.map((o) => ({ value: o.id, label: o.name })), store.orgId)}
${fArea("note", "Заметки", { value: d?.note })}
<div class="info">IP-адрес назначается отдельно — на экране «Адреса» через «Назначить адрес» и поле «Связано с устройством».</div>`),
foot: dlgFoot(edit ? "Сохранить" : "Добавить устройство"),
});
}
async function typesDialog() {
const { items } = await api("/device-types");
S.types = items;
const cols = "1fr 110px 84px";
const rows = items.map((t) => {
const editing = S.typeEdit === t.id;
const name = editing
? `<input class="input" id="edit-name" value="${esc(t.name)}" style="height:32px">`
: `<span style="font-weight:600;${t.is_default ? "color:var(--faint)" : ""}">${esc(t.name)}</span>`;
const locked = t.devices_count > 0;
let acts;
if (!isSuperadmin()) { // изменение 032: управление только для superadmin
acts = t.is_default ? `<span class="muted" style="font-size:12px">по умолчанию</span>` : "";
} else {
acts = t.is_default
? `<span class="muted" style="font-size:12px">по умолчанию</span>`
: editing
? `${iconBtn(I.edit(14), "type-save", "Сохранить", `data-id="${t.id}"`, true)}${iconBtn(I.close(), "type-cancel", "Отмена", "", true)}`
: `${iconBtn(I.edit(14), "type-edit", "Переименовать", `data-id="${t.id}"`, true)}${iconBtn(I.trash(14), "type-del", locked ? "Нельзя удалить: используется устройствами" : "Удалить", `data-id="${t.id}"`, true, locked)}`;
}
return `<div class="tr" style="--cols:${cols};--h:48px">${name}<span class="muted">${t.devices_count}</span><span style="display:flex;justify-content:flex-end;align-items:center;gap:4px">${acts}</span></div>`;
}).join("");
openDialog({
title: "Управление типами устройств", width: 640,
note: "Типы используются в фильтре и в поле «Тип» при добавлении устройства. Общий список для всех организаций.", // изменение 032
body: `<div class="mini-table"><div class="tr th" style="--cols:${cols}"><span>Название</span><span>Устройств</span><span></span></div>${rows}</div>
${isSuperadmin() ? `<div class="add-row"><input class="input" id="new-type" placeholder="Название нового типа"><button type="button" class="btn outline-primary" data-action="type-add">${I.plus()}Добавить</button></div>` : ""}`, // изменение 032: кнопка только для superadmin
foot: `<div class="dialog-foot">${btn("Готово", "close-dialog", { cls: "primary" })}</div>`,
});
}
// ---- prefixes
const PREFIX_UI_CAP = 20000; // защита от неограниченной подгрузки: организация с частичным разбиением может дать сотни тысяч префиксов
async function loadPrefixes(orgId) {
// грузит все страницы /prefixes (порядок ORDER BY vrf_id, prefix стабилен) до total, но не больше PREFIX_UI_CAP —
// иначе заголовок, вкладка «Все» и родительский список в диалогах молча ограничивались первой страницей (изменение 028, находка №3)
let items = [], total = 0, offset = 0;
for (;;) {
const page = await api("/prefixes", { params: { organization_id: orgId, limit: 1000, offset } });
total = page.total;
items = items.concat(page.items);
offset += page.items.length;
if (page.items.length === 0 || offset >= total || items.length >= PREFIX_UI_CAP) break;
}
return { items, total };
}
screens.prefixes = async () => {
const org = currentOrg();
if (!org) return shell("prefixes", header("Префиксы", "Сначала добавьте организацию") + `<div class="card"><div class="empty">Нет организаций. ${btn("Добавить организацию", "org-new", { cls: "primary" })}</div></div>`);
const [pl, vl] = await Promise.all([
loadPrefixes(org.id),
api("/vrfs", { params: { organization_id: org.id } }),
]);
S.prefixes = pl.items;
S.vrfs = vl.items;
const all = pl.items;
const q = (S.q || "").toLowerCase();
const filtered = all.filter((p) => (!S.vrf || p.vrf_id === S.vrf) && (!S.status || p.status === S.status) && (!S.family || p.family === Number(S.family))
&& (!q || p.prefix.includes(q) || p.description.toLowerCase().includes(q)));
const hasKids = new Set(all.map((p) => p.parent_id).filter(Boolean));
if (!S.collapsed) S.collapsed = new Set(all.filter((p) => !p.depth && hasKids.has(p.id)).slice(1).map((p) => p.id)); // раскрыта только первая ветка
const collapsed = S.collapsed;
const byId = Object.fromEntries(all.map((p) => [p.id, p]));
const hidden = (p) => { for (let x = byId[p.parent_id]; x; x = byId[x.parent_id]) if (collapsed.has(x.id)) return true; return false; };
const visible = filtered.filter((p) => !hidden(p));
const cols = selCols("44px minmax(200px,1.8fr) 1.4fr 100px 100px 200px 80px 44px");
setSelectable(visible.filter((p) => !hasKids.has(p.id)).map((p) => p.id)); // как и меню «⋯»: только листовые
const tab = (id, label, n) => `<button type="button" class="tab${(S.vrf || 0) === id ? " active" : ""}" data-action="vrf-tab" data-id="${id}">${esc(label)} <span class="n">${n}</span></button>`;
const rows = visible.map((p, n) => {
const [bar, txt] = utilColor(p.utilization);
const [sc, st] = PREFIX_STATUS[p.status];
const leaf = !hasKids.has(p.id);
const chev = hasKids.has(p.id) ? `<span class="chev ${collapsed.has(p.id) ? "" : "open"}"><button type="button" data-action="toggle" data-id="${p.id}" aria-label="Свернуть/развернуть" aria-expanded="${!collapsed.has(p.id)}">${I.chevR(16)}</button></span>` : "<span></span>";
const pad = p.depth * 20;
const cidr = leaf ? `<a href="#/prefixes/${p.id}" class="m13" style="padding-left:${pad}px;font-weight:600">${esc(p.prefix)}</a>` : `<span class="m13" style="padding-left:${pad}px;font-weight:600">${esc(p.prefix)}</span>`;
return `<div class="tr hoverable clickable${p.depth ? " child" : ""}${n === visible.length - 1 ? " last" : ""}${leaf ? selRow(p.id) : ""}" data-row="1" data-action="${leaf ? "pfx-open" : "toggle"}" data-id="${p.id}" style="--cols:${cols}">${leaf ? selCell(p.id) : selBlank()}${chev}${cidr}<span class="muted">${esc(p.description)}</span><span class="muted" style="font-size:13px">${esc(p.vrf_name)}</span><span>${badge(sc, st)}</span>
<div class="bar"><div class="track"><div class="fill" style="width:${p.utilization}%;background:${bar}"></div></div><span class="pct" style="color:${txt}">${p.utilization}%</span></div><span class="muted" style="font-size:13px">${fmtNum(p.used)}</span>
<span class="cell-actions rel">${leaf || isAdmin() ? iconBtn(I.dots(), "menu", "Действия", `data-menu="pfx-row-${p.id}"`) + popMenu("pfx-row-" + p.id, [...(leaf ? [{ label: "Открыть адреса", value: "open:" + p.id }, { label: "Редактировать", value: "edit:" + p.id }] : []), ...(isAdmin() ? [{ label: "Добавить вложенный (авто)", value: "subnet:" + p.id }] : []), ...(leaf ? [{ label: "Удалить", value: "del:" + p.id, cls: "danger" }] : [])], "row-pop") : ""}</span></div>`;
}).join("");
const statusItems = [{ label: "любой", value: "" }, { label: "Активен", value: "active" }, { label: "Резерв", value: "reserved" }, { label: "Устарел", value: "deprecated" }];
const famItems = [{ label: "любое", value: "" }, { label: "IPv4", value: "4" }, { label: "IPv6", value: "6" }];
const truncated = pl.total > all.length ? `<div class="info">Загружено ${fmtNum(all.length)} из ${fmtNum(pl.total)} префиксов — уточните поиск или выберите VRF</div>` : "";
return shell("prefixes", `${crumbsOrg()}${header("Префиксы", `${pl.total} ${plural(pl.total, "префикс", "префикса", "префиксов")} · ${vl.total} VRF · организация: ${esc(org.name)}`, btn("Добавить префикс", "pfx-new", { cls: "primary", icon: I.plus() }))}
<div class="tabs">${tab(0, "Все", pl.total)}${vl.items.map((v) => tab(v.id, v.name, v.prefixes_count)).join("")}<div class="grow"></div><button type="button" class="link-muted" data-action="vrfs">${I.gear()}Управление VRF</button></div>
${truncated}
<div class="card"><div class="filters">${orgSwitcher()}${searchBox(S.q || "", "Поиск: префикс, описание", 280)}${filterBtn("status", "Статус", statusItems, S.status ?? "")}${filterBtn("family", "Семейство", famItems, S.family ?? "")}</div>
${bulkBar(bulkMenu("bulk-status", "Статус", Object.entries(PREFIX_STATUS).map(([value, [, label]]) => ({ label, value }))))}<div class="tr th" style="--cols:${cols};--h:40px">${selAllCell()}<span></span><span>Префикс</span><span>Описание</span><span>VRF</span><span>Статус</span><span>Использование</span><span>Адресов</span><span></span></div>
${rows || '<div class="empty">Префиксов нет</div>'}<div class="foot">Показано ${visible.length} из ${pl.total} ${plural(pl.total, "префикса", "префиксов", "префиксов")}</div></div>`);
};
function prefixDialog(p) {
const edit = !!p;
S.dialog = async (v) => {
if (edit) await api(`/prefixes/${p.id}`, { method: "PATCH", body: { vrf_id: Number(v.vrf_id), description: v.description, status: v.status, is_pool: v.is_pool, note: v.note } });
else await api("/prefixes", { method: "POST", body: { ...v, organization_id: store.orgId, vrf_id: Number(v.vrf_id), parent_id: v.parent_id ? Number(v.parent_id) : null } });
toast(edit ? "Префикс сохранён" : "Префикс добавлен");
await draw();
};
const parents = (S.prefixes || []).map((x) => ({ value: x.id, label: x.description ? `${x.prefix} — ${x.description}` : x.prefix }));
// число вложенных префиксов: они переезжают вместе с выбранным при смене VRF
let nested = 0;
if (edit) {
let level = [p.id];
while (level.length) { level = (S.prefixes || []).filter((x) => level.includes(x.parent_id)).map((x) => x.id); nested += level.length; }
S.vrfHint = { original: String(p.vrf_id) };
}
openDialog({
title: edit ? `Префикс ${p.prefix}` : "Новый префикс",
body: formBody(`${edit ? "" : fInput("prefix", "Префикс (CIDR)", { ph: "0.0.0.0/0", mono: true, hint: "например 10.30.0.0/24" }).replace("</span><input", `${opt("например 10.30.0.0/24")}</span><input`)}
${fInput("description", "Описание", { value: p?.description })}
<div class="grid2">${fSelect("vrf_id", "VRF", (S.vrfs || []).map((v) => ({ value: v.id, label: v.name })), edit ? p.vrf_id : S.vrf || S.vrfs?.[0]?.id)}${fSelect("status", "Статус", [["active", "Активен"], ["reserved", "Резерв"], ["deprecated", "Устарел"]].map(([value, label]) => ({ value, label })), p?.status ?? "active")}</div>
${edit && nested ? `<div class="info" id="vrf-hint" hidden>Вместе с префиксом будет перенесено вложенных: ${nested}</div>` : ""}
${edit ? "" : fSelect("parent_id", "Родительский префикс", parents, "", { optional: true, empty: "Определить автоматически" })}
${fArea("note", "Примечание", { value: p?.note, ph: "Любая дополнительная информация" })}
<label class="check"><input type="checkbox" name="is_pool" ${p?.is_pool ? "checked" : ""}><span>Пул для автоназначения<small>Адреса из этого префикса будут выдаваться автоматически по запросу</small></span></label>`),
foot: dlgFoot(edit ? "Сохранить" : "Добавить префикс"),
});
}
// «следующий свободный» вложенный префикс: система сама выбирает первый свободный выровненный блок родителя
function subnetDialog(parent) {
const plen = Number(parent.prefix.split("/")[1]), max = parent.family === 6 ? 128 : 32, lo = plen + 1;
if (lo > max) return toast("Префикс нельзя дробить: это одиночный адрес", true);
const kids = (S.prefixes || []).filter((x) => x.parent_id === parent.id);
const lastKid = kids.reduce((a, b) => (a && a.id > b.id ? a : b), null);
const def = lastKid ? Number(lastKid.prefix.split("/")[1]) : Math.min(Math.max(parent.family === 6 ? 64 : 30, lo), max);
const sizes = Array.from({ length: max - lo + 1 }, (_, i) => ({ value: lo + i, label: "/" + (lo + i) }));
S.dialog = async (v) => {
const r = await api(`/prefixes/${parent.id}/subnets/next`, { method: "POST", body: { length: Number(v.length), description: v.description, status: v.status, is_pool: v.is_pool, note: v.note } });
S.collapsed?.delete(parent.id); // показать созданный префикс под родителем
toast(`Создан префикс ${r.prefix}`);
await draw();
};
let seq = 0;
S.subnetRefresh = async () => { // предпросмотр: какой блок будет выделен
const len = $("#dlg-form [name=length]")?.value, box = $("#sn-preview"), go = $("#modal-root [data-action=submit-form]"), n = ++seq;
if (!len || !box) return;
let text, ok = false;
try {
const r = await api(`/prefixes/${parent.id}/subnets/next`, { params: { length: len } });
ok = !!r.prefix; text = ok ? `Будет создан: <b class="mono">${esc(r.prefix)}</b>` : `В ${esc(parent.prefix)} нет свободного блока /${esc(len)}`;
} catch (e) { text = esc(e.message); }
if (n !== seq || !$("#sn-preview")) return; // устаревший ответ
$("#sn-preview").innerHTML = text; go.disabled = !ok;
};
openDialog({
title: `Новый вложенный префикс в ${parent.prefix}`, width: 560,
note: "Система выберет первый свободный блок выбранного размера внутри родительского префикса и создаст в нём вложенный.",
body: formBody(`${fSelect("length", "Размер", sizes, def)}
<div class="info" id="sn-preview">Подбираем блок…</div>
${fInput("description", "Описание", { value: "", optional: true })}
${fSelect("status", "Статус", Object.entries(PREFIX_STATUS).map(([value, [, label]]) => ({ value, label })), "active")}
${fArea("note", "Примечание", { value: "" })}
<label class="check"><input type="checkbox" name="is_pool"><span>Пул для автоназначения<small>Адреса из этого префикса будут выдаваться автоматически</small></span></label>`),
foot: dlgFoot("Создать префикс"),
});
S.subnetRefresh();
}
async function vrfsDialog() {
const org = currentOrg();
const { items } = await api("/vrfs", { params: { organization_id: org.id } });
S.vrfs = items;
const cols = "1fr 140px 90px 84px";
const rows = items.map((v) => {
const editing = S.vrfEdit === v.id;
const acts = editing
? `${iconBtn(I.edit(14), "vrf-save", "Сохранить", `data-id="${v.id}"`, true)}${iconBtn(I.close(), "vrf-cancel", "Отмена", "", true)}`
: `${iconBtn(I.edit(14), "vrf-edit", "Переименовать", `data-id="${v.id}"`, true)}${iconBtn(I.trash(14), "vrf-del", v.prefixes_count ? "Нельзя удалить: используется префиксами" : "Удалить", `data-id="${v.id}"`, true, v.prefixes_count > 0)}`;
return `<div class="tr" style="--cols:${cols};--h:48px">${editing ? `<input class="input" id="edit-name" value="${esc(v.name)}" style="height:32px">` : `<span style="font-weight:600">${esc(v.name)}</span>`}
${editing ? `<input class="input mono" id="edit-rt" value="${esc(v.route_target)}" style="height:32px;font-size:13px">` : `<span class="mono muted" style="font-size:13px">${esc(v.route_target)}</span>`}<span class="muted">${v.prefixes_count}</span><span style="display:flex;justify-content:flex-end;gap:4px">${acts}</span></div>`;
}).join("");
openDialog({
title: "Управление VRF", width: 680,
note: `VRF (Virtual Routing and Forwarding) — изолированная таблица маршрутизации. Каждый префикс закрепляется за одним VRF; здесь — общий список для организации «${esc(org.name)}».`,
body: `<div class="mini-table"><div class="tr th" style="--cols:${cols}"><span>Название</span><span>Route target</span><span>Префиксов</span><span></span></div>${rows}</div>
<div class="add-row"><input class="input" id="new-vrf" placeholder="Название нового VRF"><input class="input mono" id="new-rt" placeholder="Route target (необязательно)" style="width:180px;flex:none;font-size:13px"><button type="button" class="btn outline-primary" style="flex:none" data-action="vrf-add">${I.plus()}Добавить</button></div>`,
foot: `<div class="dialog-foot">${btn("Готово", "vrf-done", { cls: "primary" })}</div>`,
});
}
// ---- addresses of one prefix
screens.address = async (id) => {
const p = await api(`/prefixes/${id}`);
if (store.orgId !== p.organization_id) store.orgId = p.organization_id;
const limit = S.limit || 100;
const [page, plist, devs, vrfs] = await Promise.all([
api(`/prefixes/${id}/addresses`, { params: { status: S.astatus, q: S.q, limit } }),
loadPrefixes(p.organization_id),
api("/devices", { params: { organization_id: p.organization_id, limit: 500 } }),
api("/vrfs", { params: { organization_id: p.organization_id } }), // нужен окну «Редактировать префикс»
]);
S.prefix = p; S.prefixes = plist.items; S.devices = devs.items; S.vrfs = vrfs.items; S.page = page; S.rows = page.items;
const byId = Object.fromEntries(plist.items.map((x) => [x.id, x]));
const chain = [];
for (let x = byId[p.parent_id]; x; x = byId[x.parent_id]) chain.unshift(x);
const sm = page.summary;
const cols = selCols("minmax(140px,1fr) 1.2fr 1.6fr 120px 160px 44px");
setSelectable(page.items.filter((a) => a.id != null).map((a) => a.id)); // у «Свободен» нет записи
const rows = page.items.map((a, n) => {
const [c, t] = ADDR_STATUS[a.status];
const free = a.status === "free";
const key = "adr-row-" + (a.id ?? a.address);
const items = free ? [{ label: "Назначить адрес", value: "assign:" + a.address }] : [{ label: "Редактировать", value: "edit:" + a.id }, { label: "Удалить", value: "del:" + a.id, cls: "danger" }];
return `<div class="tr hoverable clickable${free ? " free" : ""}${n === page.items.length - 1 ? " last" : ""}${free ? "" : selRow(a.id)}" data-row="1" data-action="${free ? "adr-assign" : "adr-edit"}" data-id="${a.id ?? ""}" data-ip="${esc(a.address)}" style="--cols:${cols};--h:52px">${free ? selBlank() : selCell(a.id)}<span class="m13 ${free ? "" : ""}" style="color:${free ? "var(--faint)" : "var(--text)"}">${esc(a.address)}</span>
<span class="mono ${free ? "" : "muted"}" style="font-size:13px;font-weight:400">${esc(a.dns_name || "—")}</span><span class="muted">${esc(a.description || (free ? "—" : ""))}</span><span>${badge(c, t)}</span>
<span style="font-size:13px" class="muted">${a.updated_at ? fmtDate(a.updated_at) : "—"}</span><span class="cell-actions rel">${iconBtn(I.dots(), "menu", "Действия", `data-menu="${key}"`)}${popMenu(key, items, "row-pop")}</span></div>`;
}).join("");
const pct = (n) => (sm.capacity ? Math.min((n / sm.capacity) * 100, 100) : 0);
const shown = page.items.length;
const more = page.total > shown ? ` · <a href="#" data-action="more">Показать ещё 100</a>` : "";
const stItems = [{ label: "любой", value: "" }, { label: "Назначен", value: "assigned" }, { label: "Резерв", value: "reserved" }, { label: "Устаревший", value: "deprecated" }, { label: "Свободен", value: "free" }];
return shell("prefixes", `<div class="crumbs" style="font-weight:400;margin-top:16px;padding-top:0"><a href="#/prefixes">Префиксы</a>${chain.map((x) => `${I.chevR(14)}<span>${esc(x.prefix)}</span>`).join("")}${I.chevR(14)}<span style="color:var(--text);font-weight:500">${esc(p.prefix)}</span></div>
<div class="head" style="margin:12px 0 16px"><div><h1 class="mono" style="font-family:var(--sans)">${esc(p.prefix)}</h1><div class="sub">${esc(p.description)}${p.description ? " · " : ""}${sm.assigned} назначено · ${sm.free} свободно · VRF ${esc(p.vrf_name)}</div></div>
<div class="actions">${btn("Редактировать", "pfx-edit", { icon: I.edit(16) })}${btn("Назначить адрес", "adr-new", { cls: "primary", icon: I.plus() })}</div></div>
<div class="card usage"><div style="flex:1"><div class="track"><div style="width:${pct(sm.assigned)}%;background:var(--primary)"></div><div style="width:${pct(sm.reserved)}%;background:var(--warn-strong)"></div></div></div>
<div class="legend"><span><i style="background:var(--primary)"></i>Назначено ${sm.assigned}</span><span><i style="background:var(--warn-strong)"></i>Зарезервировано ${sm.reserved}</span><span><i style="background:var(--line)"></i>Свободно ${sm.free}</span></div></div>
<div class="card"><div class="filters">${searchBox(S.q || "", "Поиск: адрес, DNS, описание", 260)}${filterBtn("astatus", "Статус", stItems, S.astatus ?? "", 190)}</div>
${bulkBar(bulkMenu("bulk-status", "Статус", ["assigned", "reserved", "deprecated"].map((value) => ({ label: ADDR_STATUS[value][1], value }))))}<div class="tr th" style="--cols:${cols};--h:40px">${selAllCell()}<span>IP-адрес</span><span>DNS-имя</span><span>Описание</span><span>Статус</span><span>Изменён (UTC)</span><span></span></div>
${rows || '<div class="empty">Адресов нет</div>'}<div class="foot">Показано ${shown} из ${page.total} ${plural(page.total, "адреса", "адресов", "адресов")}${more}</div></div>`);
};
function addressDialog(a, presetIp = "") {
const edit = !!a;
const p = S.prefix;
S.dialog = async (v) => {
const body = { status: v.status, dns_name: v.dns_name, description: v.description, device_id: v.device_id ? Number(v.device_id) : null, note: v.note };
if (edit) await api(`/addresses/${a.id}`, { method: "PATCH", body });
else await api(`/prefixes/${p.id}/addresses`, { method: "POST", body: { ...body, address: v.address } });
toast(edit ? "Адрес сохранён" : "Адрес назначен");
await draw();
};
const ipField = edit
? `<label class="field"><span>IP-адрес</span><input class="input mono" value="${esc(a.address)}" disabled></label>`
: fInput("address", "IP-адрес", { mono: true, value: presetIp || (S.page.items.find((x) => x.status === "free")?.address ?? "") });
openDialog({
title: edit ? "Редактировать адрес" : "Назначить адрес",
body: formBody(`<div class="info">Подсеть: <b>${esc(p.prefix)}</b> · ${S.page.summary.free} ${plural(S.page.summary.free, "свободный адрес", "свободных адреса", "свободных адресов")}</div>
<div class="grid2">${ipField}${fSelect("status", "Статус", [["assigned", "Назначен"], ["reserved", "Резерв"], ["deprecated", "Устаревший"]].map(([value, label]) => ({ value, label })), a?.status ?? "assigned")}</div>
${fInput("dns_name", "DNS-имя", { value: a?.dns_name, mono: true, ph: "host.example.com", optional: true }).replace("необязательно", "необязательно, FQDN")}
${fInput("description", "Описание", { value: a?.description })}
<div class="field">${fSelect("device_id", "Связано с устройством", S.devices.map((d) => ({ value: d.id, label: d.name })), a?.device_id ?? "", { optional: true, empty: "Выберите устройство…" }).replace('<label class="field">', "<label class=\"field\" style=\"gap:6px\">")}<a href="#/devices" style="align-self:flex-end;margin-top:-4px;font:500 12px var(--sans)">Управление устройствами →</a></div>
${fArea("note", "Примечание", { value: a?.note })}`),
foot: dlgFoot(edit ? "Сохранить" : "Назначить адрес"),
});
}
// ---- journal (audit)
const ENTITY_RU = { organization: "Организация", vrf: "VRF", prefix: "Префикс", address: "Адрес", device: "Устройство", device_type: "Тип устройства", isp: "Оператор", user: "Пользователь", session: "Сессия", journal: "Журнал" };
const eventBadge = (ev) => {
const [entity, action] = ev.split(".");
const cls = { created: "blue", assigned: "green", updated: "amber", password_reset: "amber", deleted: "red", failed: "red", locked: "red", clear_failed: "red", clear_locked: "red", delete_blocked: "amber" }[action] || "";
return badge(cls, ev);
};
const fmtDateSec = (iso) => (iso ? iso.replace("T", " ").slice(0, 19) : "—");
screens.journal = async () => {
const limit = S.limit || 100;
const [page, sum, facets] = await Promise.all([
api("/audit", { params: { event_type: S.jtype, actor: S.jactor, entity_type: S.jentity, date_from: S.jfrom, date_to: S.jto, q: S.q, limit } }),
api("/audit/summary"),
api("/audit/facets"),
]);
S.entries = page.items; S.summary = sum;
const any = (l) => ({ label: l, value: "" });
const typeItems = [any("любой"), ...facets.event_types.map((v) => ({ label: v, value: v }))];
const actorItems = [any("любой"), ...facets.actors.map((v) => ({ label: v, value: v }))];
const entItems = [any("любая"), ...facets.entity_types.map((v) => ({ label: ENTITY_RU[v] || v, value: v }))];
const cols = "150px 168px 150px 1fr 110px 130px 96px";
const rows = page.items.map((r) => `<div class="tr hoverable clickable" style="--cols:${cols};--h:48px" data-action="jr-open" data-uid="${r.uid}" role="button" tabindex="0">
<span class="muted" style="font-size:13px;white-space:nowrap">${fmtDateSec(r.ts)}</span><span>${eventBadge(r.event_type)}</span>
<span style="display:flex;gap:8px;align-items:baseline;min-width:0"><span>${esc(ENTITY_RU[r.entity_type] || r.entity_type)}</span>${["session", "journal"].includes(r.entity_type) ? "" : `<span class="mono muted ell" style="font-size:13px" title="${esc(r.entity_label)}">${esc(r.entity_label)}</span>`}</span>
<span class="ell" title="${esc(r.message)}">${esc(r.message)}</span><span class="muted" style="font-size:13px">${esc(r.actor)}</span><span class="mono muted" style="font-size:13px">${esc(r.client_ip || "—")}</span><span class="mono muted" style="font-size:13px">${r.short_id}</span></div>`).join("");
const n = sum.total;
const rot = `ротация: ${sum.retention_days ? sum.retention_days + " " + plural(sum.retention_days, "день", "дня", "дней") : "без ограничения по сроку"} / ${sum.max_entries ? fmtNum(sum.max_entries) + " записей" : "без ограничения по количеству"}`;
const act = btn("Обновить", "jr-refresh", { icon: I.refresh() }) + (isAdmin() ? btn("Настройки", "jr-settings", { icon: I.cog() }) + btn("Очистить журнал", "jr-clear", { cls: "danger", icon: I.trashJ() }) : "");
const dateBox = (id, label, v) => `<label class="datebox"><span class="muted">${label}</span><input type="date" id="${id}" data-input="${id}" value="${esc(v || "")}" aria-label="${label === "с" ? "Период с" : "Период по"}"><span class="cal">${I.calendar()}</span></label>`;
return shell("journal", `${header("Журнал", `${fmtNum(n)} ${plural(n, "запись", "записи", "записей")} · старейшая ${sum.oldest_ts ? fmtDate(sum.oldest_ts) : "—"} · ${rot}`, act)}
<div class="card"><div class="filters">${filterBtn("jtype", "Тип", typeItems, S.jtype ?? "", 170)}${filterBtn("jactor", "Актор", actorItems, S.jactor ?? "", 170)}${filterBtn("jentity", "Сущность", entItems, S.jentity ?? "", 200)}${dateBox("jfrom", "с", S.jfrom)}${dateBox("jto", "по", S.jto)}${searchBox(S.q || "", "Сообщение, ID или IP", 240)}</div>
<div class="tr th" style="--cols:${cols};--h:40px"><span>Время (UTC)</span><span>Тип</span><span>Сущность</span><span>Сообщение</span><span>Актор</span><span>IP-адрес</span><span>ID записи</span></div>
${rows || '<div class="empty">Записей нет</div>'}
${page.total > page.items.length ? `<div style="display:flex;justify-content:center;align-items:center;height:64px;border-bottom:1px solid var(--line)">${btn("Показать ещё 100", "more")}</div>` : ""}
<div class="foot">Показано ${page.items.length} из ${fmtNum(page.total)} ${plural(page.total, "записи", "записей", "записей")}</div></div>`);
};
async function copyText(text) {
try { await navigator.clipboard.writeText(text); } catch {
const ta = document.createElement("textarea"); // по http navigator.clipboard недоступен
ta.value = text; ta.style.position = "fixed"; ta.style.opacity = "0"; document.body.append(ta); ta.select();
try { document.execCommand("copy"); } finally { ta.remove(); }
}
toast("Скопировано");
}
const copyBtn = (text) => iconBtn(I.copy(), "copy", "Копировать", `data-text="${esc(text)}"`);
function entryDialog(r) {
const row = (label, inner) => `<div class="kv"><span class="muted" style="font-size:13px">${label}</span><div style="display:flex;align-items:center;gap:8px;min-width:0">${inner}</div></div>`;
const mono = (v) => `<span class="mono" style="font-size:13px;overflow-wrap:anywhere">${esc(v)}</span>`;
const entity = ["session", "journal"].includes(r.entity_type) ? esc(ENTITY_RU[r.entity_type]) : `${esc(ENTITY_RU[r.entity_type] || r.entity_type)} ·&nbsp;${mono(r.entity_label)}`;
openDialog({
title: "Запись журнала", width: 640,
body: `<div class="dialog-body">${row("ID записи", mono("evt_" + r.uid) + copyBtn("evt_" + r.uid))}
${row("Время", fmtDateSec(r.ts) + " UTC")}${row("Тип", eventBadge(r.event_type))}${row("Актор", esc(r.actor))}
${row("IP-адрес", r.client_ip ? mono(r.client_ip) + copyBtn(r.client_ip) : '<span class="muted">—</span>')}${row("Сущность", entity)}${row("Сообщение", esc(r.message))}
${r.meta?.method ? row("Запрос", mono(`${r.meta.method} ${r.meta.path}`)) : ""}${r.meta?.user_agent ? row("User-Agent", `<span class="muted" style="font-size:13px;overflow-wrap:anywhere">${esc(r.meta.user_agent)}</span>`) : ""}
<div style="font:500 13px/1.3 var(--sans);margin-top:4px">Данные</div><pre class="json">${r.diff ? esc(JSON.stringify(r.diff, null, 2)) : "—"}</pre></div>`,
foot: `<div class="dialog-foot">${btn("Закрыть", "close-dialog")}</div>`,
});
}
function journalSettingsDialog() {
const sm = S.summary;
const n = sm.total;
S.dialog = async (v) => {
const res = await api("/journal/settings", { method: "PUT", body: { retention_days: Number(v.retention_days), max_entries: Number(v.max_entries) } });
toast(res.deleted ? `Настройки сохранены, удалено записей: ${res.deleted}` : "Настройки сохранены");
S.limit = 100; await draw();
};
openDialog({
title: "Настройки журнала",
body: formBody(`<div class="statbox">Сейчас в журнале: <b>${fmtNum(n)} ${plural(n, "запись", "записи", "записей")}</b> · старейшая ${sm.oldest_ts ? fmtDate(sm.oldest_ts) : "—"}</div>
${fInput("retention_days", "Хранить записи, дней", { value: sm.retention_days, type: "number", hint: "0 — без ограничения по сроку" }).replace("</span><input", `${opt("0 — без ограничения по сроку")}</span><input min="0" max="3650" step="1"`)}
${fInput("max_entries", "Максимум записей", { value: sm.max_entries, type: "number" }).replace("</span><input", `${opt("0 — без ограничения по количеству")}</span><input min="0" max="10000000" step="1"`)}
<div class="info">Ротация запускается раз в час и при сохранении настроек: удаляет самые старые записи сверх срока и лимита. Каждая ротация фиксируется записью в журнале.</div>`),
foot: dlgFoot("Сохранить"),
});
}
function journalClearDialog(state = {}) {
const n = S.summary.total;
const locked = !!state.retry;
const minutes = state.retry ? Math.max(1, Math.ceil(state.retry / 60)) : 0;
openDialog({
title: "Очистить журнал", width: 540,
body: `<div class="dialog-body" id="clear-form"><div class="warn">Будет удалено <b>${fmtNum(n)} ${plural(n, "запись", "записи", "записей")}</b> без возможности восстановления. В журнале останется одна запись об очистке: кто и когда её выполнил.</div>
<label class="field"><span>Пароль пользователя <b style="font-weight:600">${esc(user.username)}</b>${opt("подтверждение нужно только для этого действия")}</span>
<input class="input${state.wrong ? " bad" : ""}" type="password" id="clear-pw" autocomplete="current-password" placeholder="${locked ? "" : "Введите пароль"}" ${locked ? "disabled" : ""} value=""></label>
${state.wrong ? `<div style="font:500 13px/1.4 var(--sans);color:var(--danger)">Неверный пароль. Осталось попыток: ${state.left}</div>` : ""}
${locked ? `<div class="err-banner">Слишком много неверных попыток. Повторите через ${minutes} мин.</div>` : ""}</div>`,
foot: `<div class="dialog-foot">${btn("Отмена", "close-dialog", { cls: "ghost" })}<button type="button" class="btn danger" id="clear-go" data-action="jr-clear-do" disabled>${I.trashJ()}Очистить журнал</button></div>`,
});
$("#clear-pw")?.focus();
}
async function doClear() {
const pw = $("#clear-pw").value;
if (!pw) return;
try {
const res = await api("/journal/clear", { method: "POST", body: { password: pw } });
closeDialog(); toast(`Журнал очищен, удалено записей: ${res.deleted}`); S.limit = 100; await draw();
} catch (e) {
if (!(e instanceof ApiError)) throw e;
if (e.status === 429) journalClearDialog({ retry: e.body.retry_after_seconds });
else if (e.status === 403) { journalClearDialog({ wrong: true, left: e.body.attempts_left }); }
else toast(e.message, true);
}
}
// ---- users
screens.users = async () => {
if (!isSuperadmin()) return shell("users", `${header("Пользователи", "Учётные записи UI")}<div class="card" style="margin-top:24px"><div class="empty">Раздел доступен только суперадминистратору</div></div>`); // изменение 032
const q = S.q || "";
const { items, total } = await api("/users", { params: { q, limit: 500 } });
S.rows = items;
const cols = selCols("minmax(200px,1.5fr) 190px minmax(180px,1fr) 190px 180px minmax(140px,1fr) 44px"); // изменение 032: столбец организации; изменение 035: столбец последнего входа
setSelectable(items.filter((u) => u.id !== user?.id).map((u) => u.id)); // свою запись выбрать нельзя
const rows = items.map((u, n) => {
const self = u.id === user?.id;
const items2 = [
{ label: "Редактировать", value: "edit:" + u.id },
{ label: u.is_active ? "Отключить доступ" : "Разрешить доступ", value: "toggle:" + u.id, cls: u.is_active ? "danger" : "" },
// изменение 037: сброс 2FA — только у чужих записей с включённой 2FA
...(!self && u.totp_enabled ? [{ label: "Сбросить 2FA", value: "totp-reset:" + u.id }] : []),
{ label: "Удалить", value: "del:" + u.id, cls: "danger" },
];
const orgName = orgs.find((o) => o.id === u.organization_id)?.name || "—"; // изменение 032
return `<div class="tr hoverable clickable${n === items.length - 1 ? " last" : ""}${selRow(u.id)}" data-row="1" data-action="user-edit" data-id="${u.id}" style="--cols:${cols}">${selCell(u.id, self)}
<span class="m13" style="font-weight:600">${esc(u.username)}${self ? ` <span class="muted" style="font:400 12px var(--sans)">это вы</span>` : ""}</span>
<span>${badge(u.role === "superadmin" ? "purple" : u.role === "admin" ? "blue" : "", ROLE_RU[u.role] || u.role)}</span>
<span class="muted" style="font-size:13px">${u.role === "superadmin" ? "—" : esc(orgName)}</span>
<span>${u.is_active ? badge("green", "Доступ разрешён") : badge("red", "Отключён")}${u.totp_enabled ? " " + badge("blue", "2FA") : ""}</span>
<span style="font-size:13px">${u.last_login_at ? `<span style="display:flex;flex-direction:column;gap:2px"><span>${fmtDate(u.last_login_at)}</span><span class="mono muted" style="font-size:12px">${esc(u.last_login_ip || "—")}</span></span>` : '<span class="muted">—</span>'}</span>
<span class="muted" style="font-size:13px">${self ? "свои роль и доступ менять нельзя" : ""}</span>
<span class="cell-actions rel">${iconBtn(I.dots(), "menu", "Действия", `data-menu="user-row-${u.id}"`)}${popMenu("user-row-" + u.id, items2, "row-pop")}</span></div>`;
}).join("");
return shell("users", `${header("Пользователи", `${total} ${plural(total, "учётная запись", "учётные записи", "учётных записей")} · роли: суперадминистратор — полные права, администратор — запись в организации, просмотр — чтение`, btn("Добавить пользователя", "users-new", { cls: "primary", icon: I.plus() }))}
<div class="card"><div class="filters">${searchBox(q, "Поиск по логину", 260)}</div>
${bulkBar(btn("Разрешить доступ", "bulk-access", { data: 'data-value="1"' }) + btn("Отключить доступ", "bulk-access", { data: 'data-value="0"' }))}<div class="tr th" style="--cols:${cols};--h:40px">${selAllCell()}<span>Логин</span><span>Роль</span><span>Организация</span><span>Статус</span><span>Последний вход</span><span></span><span></span></div>
${rows || '<div class="empty">Пользователей нет</div>'}<div class="foot">Показано ${items.length} из ${total} ${plural(total, "записи", "записей", "записей")}</div></div>`);
};
function userDialog(u) {
const edit = !!u;
const self = edit && u.id === user?.id;
const roleInit = u?.role ?? "viewer"; // изменение 033: начальная видимость поля организации — по роли в форме
S.dialog = async (v) => {
if (edit) {
const body = { is_active: v.is_active === true };
if (v.role) body.role = v.role;
if (v.role !== "superadmin" && v.organization_id) body.organization_id = Number(v.organization_id); // изменение 033: одинаково с созданием — сервер сам очистит организацию при повышении
if (v.password) body.password = v.password;
await api(`/users/${u.id}`, { method: "PATCH", body });
} else {
const body = { username: v.username, password: v.password, role: v.role, is_active: v.is_active !== false }; // изменение 032
if (v.role !== "superadmin" && v.organization_id) body.organization_id = Number(v.organization_id); // изменение 032: проверка перед присвоением
await api("/users", { method: "POST", body });
}
toast(edit ? "Пользователь сохранён" : "Пользователь добавлен");
await draw();
};
openDialog({
title: edit ? `Пользователь ${u.username}` : "Новый пользователь",
note: "«Суперадминистратор» — полные права, «Администратор» — запись в организации, «Просмотр» — только чтение. Логин после создания не меняется.", // изменение 032
body: formBody(`${edit ? `<label class="field"><span>Логин</span><input class="input" value="${esc(u.username)}" disabled></label>` : fInput("username", "Логин", { ph: "ivanov", hint: "латиница, цифры, . _ -" })}
${self ? "" : fInput("password", edit ? "Новый пароль" : "Пароль", { type: "password", optional: edit, ph: edit ? "не менять" : "", hint: "минимум 8 символов" })}
${self ? "" : fSelect("role", "Роль", [{ value: "superadmin", label: "Суперадминистратор" }, { value: "admin", label: "Администратор" }, { value: "viewer", label: "Просмотр" }], roleInit)}
${self ? "" : `<div id="org-field-wrap" ${roleInit === "superadmin" ? "hidden" : ""}>${fSelect("organization_id", "Организация", orgs.map((o) => ({ value: o.id, label: o.name })), u?.organization_id ?? "")}</div>`}
<label class="check"><input type="checkbox" name="is_active" ${edit ? (u.is_active ? "checked" : "") : "checked"} ${self ? "disabled" : ""}><span>Доступ разрешён<small>Отключённый пользователь не входит в UI; его текущий токен перестаёт действовать сразу</small></span></label>
${self ? `<div class="info">Свою учётную запись нельзя понизить, отключить или удалить — пароль меняется кнопкой «Сменить пароль» в шапке.</div>` : ""}`),
foot: dlgFoot(edit ? "Сохранить" : "Добавить пользователя"),
});
}
function passwordDialog() {
S.dialog = async (v) => {
if (v.new_password !== v.confirm_password) throw new ApiError(422, { message: "Пароли не совпадают", fields: { confirm_password: "пароли не совпадают" } });
const r = await api("/users/me/password", { method: "POST", body: { current_password: v.current_password, new_password: v.new_password } });
if (r?.access_token) store.token = r.access_token; // прежние токены отозваны сервером — продолжаем с новым
toast("Пароль изменён");
};
openDialog({
title: "Сменить пароль",
note: `Учётная запись: ${esc(user?.username ?? "")}`,
body: formBody(`${fInput("current_password", "Текущий пароль", { type: "password" })}
<div class="grid2">${fInput("new_password", "Новый пароль", { type: "password", hint: "минимум 8 символов" })}${fInput("confirm_password", "Повторите пароль", { type: "password" })}</div>
<div class="info">Смена пароля не завершает уже открытые сессии: выданный ранее токен действует до истечения своего срока.</div>`),
foot: dlgFoot("Сохранить пароль"),
});
}
// ---- 2FA (изменение 037): управление своей двухфакторной аутентификацией из меню логина в шапке
function totpDialog() {
return user.totp_enabled ? totpStatusDialog() : totpSetupPasswordDialog();
}
function totpStatusDialog() {
openDialog({
title: "Двухфакторная аутентификация",
body: `<div class="dialog-body"><div class="info">Включена с ${esc(fmtDate(user.totp_enabled_at))}</div></div>`,
foot: `<div class="dialog-foot">${btn("Закрыть", "close-dialog", { cls: "ghost" })}${btn("Отключить", "totp-disable-start", { cls: "danger" })}</div>`,
});
}
function totpSetupPasswordDialog() {
S.dialog = async (v) => {
const r = await api("/users/me/2fa/setup", { method: "POST", body: { password: v.password } });
closeDialog();
totpConfirmDialog(r);
};
openDialog({
title: "Включить двухфакторную аутентификацию",
note: "Понадобится приложение-аутентификатор (Google Authenticator, Aegis, 1Password, Bitwarden и подобные).",
body: formBody(`${fInput("password", "Текущий пароль", { type: "password" })}`),
foot: dlgFoot("Далее"),
});
}
function totpConfirmDialog(setup) {
S.dialog = async (v) => {
const r = await api("/users/me/2fa/enable", { method: "POST", body: { code: v.code } });
closeDialog();
user = await api("/auth/me");
totpRecoveryCodesDialog(r.recovery_codes);
};
openDialog({
title: "Сканируйте QR-код",
width: 460,
note: "Отсканируйте код в приложении-аутентификаторе или введите секрет вручную, затем введите код из приложения.",
body: formBody(`<div style="display:flex;justify-content:center;padding:8px 0"><span class="qr-frame"><img src="${setup.qr}" alt="QR-код" width="220" height="220"></span></div>
<label class="field"><span>Секрет для ручного ввода</span><input class="input mono" value="${esc(setup.secret)}" readonly onclick="this.select()"></label>
${fInput("code", "Код из приложения", { ph: "000000" })}`),
foot: dlgFoot("Подтвердить"),
});
}
function totpRecoveryCodesDialog(codes) {
S.dialog = null;
const text = codes.join("\n");
openDialog({
title: "Коды восстановления", width: 460,
note: "Сохраните эти коды в надёжном месте — сервер больше не покажет их. Каждый код можно использовать один раз, вместо кода из приложения.",
body: `<div class="dialog-body"><pre class="json" style="text-align:center;line-height:1.8">${esc(text)}</pre></div>`,
foot: `<div class="dialog-foot">${btn("Скопировать", "copy", { data: `data-text="${esc(text)}"` })}${btn("Готово", "close-dialog", { cls: "primary" })}</div>`,
});
}
function totpDisableStartDialog() {
closeDialog();
S.dialog = async (v) => {
await api("/users/me/2fa/disable", { method: "POST", body: { password: v.password, code: v.code } });
closeDialog();
user = await api("/auth/me");
toast("Двухфакторная аутентификация отключена");
};
openDialog({
title: "Отключить двухфакторную аутентификацию",
body: formBody(`${fInput("password", "Текущий пароль", { type: "password" })}
${fInput("code", "Код из приложения или код восстановления", { ph: "000000" })}`),
foot: dlgFoot("Отключить"),
});
}
// ---- login
function loginScreen(err = "") {
if (pendingMfa) {
return `<div class="card login"><div class="brand"><span class="brand-logo">${I.globe()}</span>ipam_manager</div><h1>Вход</h1>
<form id="mfa-form"><div class="err-banner" style="margin-top:12px" ${err ? "" : "hidden"}>${esc(err)}</div>
<label class="field"><span>Код из приложения или код восстановления</span><input class="input mono" name="code" autocomplete="one-time-code" autofocus></label>
<button class="btn primary" type="submit">Подтвердить</button>
<button class="btn ghost" type="button" data-action="mfa-back" style="margin-top:8px;width:100%">Назад</button></form></div>`;
}
return `<div class="card login"><div class="brand"><span class="brand-logo">${I.globe()}</span>ipam_manager</div><h1>Вход</h1>
<form id="login-form"><div class="err-banner" style="margin-top:12px" ${err ? "" : "hidden"}>${esc(err)}</div>
<label class="field"><span>Логин</span><input class="input" name="username" autocomplete="username" autofocus></label>
<label class="field"><span>Пароль</span><input class="input" type="password" name="password" autocomplete="current-password"></label>
<button class="btn primary" type="submit">Войти</button></form></div>`;
}
/* ------------------------------------------------------------------- routing */
async function loadOrgs() {
orgs = (await api("/organizations", { params: { limit: 500 } })).items;
if (!orgs.some((o) => o.id === store.orgId) && orgs.length) store.orgId = orgs[0].id;
}
function route() {
const [path, ...rest] = (location.hash.replace(/^#\/?/, "") || "overview").split("/");
return { path, arg: rest[0] };
}
let current = "";
async function draw() {
const { path, arg } = route();
const app = $("#app");
if (path === "login" || !store.token) {
if (path !== "login") { location.hash = "#/login"; return; }
app.innerHTML = loginScreen();
return;
}
const key = path + "/" + (arg || "");
if (key !== current) { S = {}; current = key; menu = null; closeDialog(); }
try {
if (!user) { user = await api("/auth/me"); await loadOrgs(); }
const fn = path === "prefixes" && arg ? () => screens.address(Number(arg)) : screens[path] || screens.overview;
const active = document.activeElement;
const focusId = active?.id, caret = active?.selectionStart;
app.innerHTML = await fn();
const all = $("#sel-all");
if (all) all.indeterminate = selSet().size > 0 && selSet().size < S.selectable.length;
if (focusId) { const el = document.getElementById(focusId); if (el) { el.focus(); try { el.setSelectionRange(caret, caret); } catch { /* ignore */ } } }
} catch (e) {
if (e instanceof ApiError && e.status === 401) return;
app.innerHTML = shell(path, `<div class="card" style="margin-top:24px"><div class="empty">Не удалось загрузить данные: ${esc(e.message)}</div></div>`);
}
}
window.addEventListener("hashchange", draw);
/* --------------------------------------------------------------------- events */
const confirmDel = (what) => confirm(`Удалить ${what}?`);
async function guarded(fn) {
try { await fn(); } catch (e) { if (e instanceof ApiError) toast(e.message, true); else throw e; }
}
const rowById = (id) => S.rows?.find((r) => r.id === Number(id));
// групповые операции: последовательные вызовы существующих DELETE/PATCH; отказ по одному объекту не прерывает остальные
const BULK = {
orgs: { forms: ["организация", "организации", "организаций"], url: (id) => `/organizations/${id}`, label: (id) => rowById(id)?.name, after: loadOrgs },
isps: { forms: ["оператор", "оператора", "операторов"], url: (id) => `/isps/${id}`, label: (id) => rowById(id)?.name },
devices: { forms: ["устройство", "устройства", "устройств"], url: (id) => `/devices/${id}`, label: (id) => rowById(id)?.name },
prefixes: { forms: ["префикс", "префикса", "префиксов"], url: (id) => `/prefixes/${id}`, label: (id) => S.prefixes.find((p) => p.id === id)?.prefix },
address: { forms: ["адрес", "адреса", "адресов"], url: (id) => `/addresses/${id}`, label: (id) => S.page.items.find((a) => a.id === id)?.address },
users: { forms: ["пользователь", "пользователя", "пользователей"], url: (id) => `/users/${id}`, label: (id) => rowById(id)?.username },
};
const screenKey = () => { const { path, arg } = route(); return path === "prefixes" && arg ? "address" : path; };
function failuresDialog(title, failed) {
openDialog({
title: "Выполнено не для всех объектов", width: 640,
note: `${esc(title)}: не выполнено ${failed.length}. Эти объекты остались выбранными.`,
body: `<div class="dialog-body"><div class="mini-table" style="max-height:320px;overflow:auto">${failed.map(([label, msg]) =>
`<div class="tr" style="--cols:minmax(120px,1fr) 2fr;height:auto;min-height:48px;padding:10px 12px"><span class="m13" style="font-weight:600;overflow-wrap:anywhere">${esc(label)}</span><span class="muted" style="overflow-wrap:anywhere">${esc(msg)}</span></div>`).join("")}</div></div>`,
foot: `<div class="dialog-foot">${btn("Закрыть", "close-dialog", { cls: "primary" })}</div>`,
});
}
async function bulk(title, ids, labelOf, call, after) {
const st = S;
if (st.busy || !ids.length) return;
st.busy = true;
const failed = [];
let ok = 0;
try {
for (const id of ids) {
try { await call(id); ok++; st.sel.delete(id); } catch (e) {
if (!(e instanceof ApiError)) throw e;
if (e.status === 401) return;
failed.push([labelOf(id) ?? `#${id}`, e.message]);
}
}
} finally { st.busy = false; }
toast(`${title}: выполнено ${ok} из ${ids.length}`, failed.length > 0);
await after?.();
if (S !== st) return; // пока шли запросы, пользователь ушёл на другой экран
await draw();
if (failed.length) failuresDialog(title, failed);
}
function bulkPick(id, value) {
const k = screenKey(), ids = [...selSet()];
if (id === "bulk-type") return bulk("Смена типа", ids, BULK.devices.label, (x) => api(`/devices/${x}`, { method: "PATCH", body: { device_type_id: Number(value) } }));
return bulk("Смена статуса", ids, BULK[k].label, (x) => api(BULK[k].url(x), { method: "PATCH", body: { status: value } }));
}
const rowActions = {
"org-row": async (act, id) => {
if (act === "open") { store.orgId = Number(id); location.hash = "#/prefixes"; }
else if (act === "edit") orgDialog(rowById(id));
else if (act === "del" && confirmDel("организацию")) await guarded(async () => { await api(`/organizations/${id}`, { method: "DELETE" }); await loadOrgs(); toast("Организация удалена"); await draw(); });
},
"isp-row": async (act, id) => {
if (act === "edit") ispDialog(rowById(id));
else if (act === "del" && confirmDel("оператора")) await guarded(async () => { await api(`/isps/${id}`, { method: "DELETE" }); toast("Оператор удалён"); await draw(); });
},
"dev-row": async (act, id) => {
if (act === "edit") deviceDialog(rowById(id));
else if (act === "del" && confirmDel("устройство")) await guarded(async () => { await api(`/devices/${id}`, { method: "DELETE" }); toast("Устройство удалено"); await draw(); });
},
"pfx-row": async (act, id) => {
if (act === "open") location.hash = `#/prefixes/${id}`;
else if (act === "edit") prefixDialog(S.prefixes.find((p) => p.id === Number(id)));
else if (act === "subnet") subnetDialog(S.prefixes.find((p) => p.id === Number(id)));
else if (act === "del" && confirmDel("префикс")) await guarded(async () => {
try { await api(`/prefixes/${id}`, { method: "DELETE" }); } catch (e) {
if (e.status === 409 && confirm(`${e.message}. Удалить вместе с адресами?`)) await api(`/prefixes/${id}?force=true`, { method: "DELETE" }); else throw e;
}
toast("Префикс удалён"); await draw();
});
},
"adr-row": async (act, id) => {
if (act === "assign") addressDialog(null, id);
else if (act === "edit") addressDialog(S.page.items.find((a) => a.id === Number(id)));
else if (act === "del" && confirmDel("адрес")) await guarded(async () => { await api(`/addresses/${id}`, { method: "DELETE" }); toast("Адрес удалён"); await draw(); });
},
"user-row": async (act, id) => {
const u = rowById(id);
if (act === "edit") userDialog(u);
else if (act === "toggle") await guarded(async () => {
await api(`/users/${id}`, { method: "PATCH", body: { is_active: !u.is_active } });
toast(u.is_active ? "Доступ отключён" : "Доступ разрешён"); await draw();
});
else if (act === "totp-reset" && confirm(`Сбросить двухфакторную аутентификацию пользователю ${u.username}?`)) await guarded(async () => {
await api(`/users/${id}/2fa/reset`, { method: "POST" }); toast("Двухфакторная аутентификация сброшена"); await draw();
}); // изменение 037
else if (act === "del" && confirmDel(`пользователя ${u.username}`)) await guarded(async () => { await api(`/users/${id}`, { method: "DELETE" }); toast("Пользователь удалён"); await draw(); });
},
};
const actions = {
logout: () => { store.token = null; user = null; current = ""; location.hash = "#/login"; draw(); },
menu: (d, el) => { menu = menu?.id === d.menu ? null : { id: d.menu }; draw(); },
pick: async (d) => {
const { menu: id, value } = d;
menu = null;
if (id === "org") { store.orgId = Number(value); S.vrf = 0; S.collapsed = null; }
else if (id === "status") S.status = value;
else if (id === "family") S.family = value;
else if (id === "type") S.type = value;
else if (id === "astatus") { S.astatus = value; S.limit = 100; }
else if (id === "jtype") { S.jtype = value; S.limit = 100; }
else if (id === "jactor") { S.jactor = value; S.limit = 100; }
else if (id === "jentity") { S.jentity = value; S.limit = 100; }
else if (id === "bulk-status" || id === "bulk-type") return bulkPick(id, value);
else if (id === "theme") { setTheme(value); return draw(); } // изменение 038 (доработка): пиктограмма темы в шапке, отдельная от меню пользователя
else if (id === "user") { // изменение 036: меню логина в шапке
document.querySelectorAll(".pop").forEach((el) => el.remove());
if (value === "logout") return actions.logout();
return value === "2fa" ? totpDialog() : passwordDialog(); // изменение 037
}
else {
const key = Object.keys(rowActions).find((k) => id.startsWith(k + "-"));
const [act, rid] = value.split(":");
if (key) { document.querySelectorAll(".pop").forEach((el) => el.remove()); return rowActions[key](act, rid); }
}
draw();
},
"vrf-tab": (d) => { S.vrf = Number(d.id); draw(); },
toggle: (d) => { const c = (S.collapsed ||= new Set()); c.has(Number(d.id)) ? c.delete(Number(d.id)) : c.add(Number(d.id)); draw(); },
more: () => { S.limit = (S.limit || 100) + 100; draw(); },
"dd-toggle": (d, el) => {
const pop = el.parentElement.querySelector(".dd");
const open = pop.hidden;
closeDropdowns();
pop.hidden = !open;
if (open) (pop.querySelector(".sel") || pop.querySelector("button"))?.focus();
},
"dd-pick": (d, el) => {
const box = el.closest(".cselect"), input = box.querySelector("input");
input.value = d.value;
box.querySelector(".v").textContent = el.textContent;
box.querySelector(".select").classList.toggle("ph", d.empty === "1");
box.querySelectorAll(".dd button").forEach((b) => b.classList.toggle("sel", b === el));
closeDropdowns();
box.querySelector(".select").focus();
input.dispatchEvent(new Event("change", { bubbles: true }));
// изменение 033: живое переключение поля «Организация» при смене роли в диалоге пользователя
if (S.dialog && input.name === "role") $("#org-field-wrap")?.toggleAttribute("hidden", d.value === "superadmin");
},
sel: (d, el) => { el.checked ? selSet().add(Number(d.id)) : selSet().delete(Number(d.id)); draw(); },
"sel-all": (d, el) => { S.sel = new Set(el.checked ? S.selectable : []); draw(); },
"sel-clear": () => { S.sel = new Set(); draw(); },
"bulk-del": () => {
const c = BULK[screenKey()], ids = [...selSet()];
if (ids.length && confirm(`Удалить выбранное: ${ids.length} ${plural(ids.length, ...c.forms)}?`)) return bulk("Удаление", ids, c.label, (id) => api(c.url(id), { method: "DELETE" }), c.after);
},
"bulk-access": (d) => bulk(d.value === "1" ? "Разрешение доступа" : "Отключение доступа", [...selSet()], BULK.users.label,
(id) => api(`/users/${id}`, { method: "PATCH", body: { is_active: d.value === "1" } })),
"pfx-open": (d) => { location.hash = `#/prefixes/${d.id}`; },
"org-open": (d) => { store.orgId = Number(d.id); location.hash = "#/prefixes"; },
"isp-edit": (d) => ispDialog(rowById(d.id)),
"dev-edit": (d) => deviceDialog(rowById(d.id)),
"user-edit": (d) => userDialog(rowById(d.id)),
"adr-edit": (d) => addressDialog(S.page.items.find((x) => x.id === Number(d.id))),
"adr-assign": (d) => addressDialog(null, d.ip),
"jr-open": (d) => entryDialog(S.entries.find((r) => r.uid === d.uid)),
"jr-refresh": () => draw(),
"jr-settings": () => journalSettingsDialog(),
"jr-clear": () => journalClearDialog(),
"jr-clear-do": doClear,
copy: (d) => copyText(d.text),
"close-dialog": closeDialog,
overlay: (d, el, e) => { if (e.target === el) closeDialog(); },
"submit-form": submitDialog,
"open-org": (d) => { store.orgId = Number(d.id); },
"org-new": () => orgDialog(null),
"users-new": () => userDialog(null),
"pw-change": () => passwordDialog(),
"totp-disable-start": () => totpDisableStartDialog(), // изменение 037
"mfa-back": () => { pendingMfa = null; $("#app").innerHTML = loginScreen(); }, // изменение 037
"isp-new": () => ispDialog(null),
"dev-new": () => deviceDialog(null),
"pfx-new": () => prefixDialog(null),
"pfx-edit": () => prefixDialog(S.prefix),
"adr-new": () => addressDialog(null),
types: () => typesDialog(),
vrfs: () => vrfsDialog(),
"vrf-done": async () => { closeDialog(); S.vrfEdit = null; await draw(); },
"vrf-add": () => guarded(async () => {
const name = $("#new-vrf").value.trim();
if (!name) return toast("Введите название VRF", true);
await api("/vrfs", { method: "POST", body: { organization_id: store.orgId, name, route_target: $("#new-rt").value.trim() } });
await vrfsDialog();
}),
"vrf-edit": (d) => { S.vrfEdit = Number(d.id); vrfsDialog(); },
"vrf-cancel": () => { S.vrfEdit = null; vrfsDialog(); },
"vrf-save": (d) => guarded(async () => {
await api(`/vrfs/${d.id}`, { method: "PATCH", body: { name: $("#edit-name").value.trim(), route_target: $("#edit-rt").value.trim() } });
S.vrfEdit = null; await vrfsDialog();
}),
"vrf-del": (d) => confirmDel("VRF") && guarded(async () => { await api(`/vrfs/${d.id}`, { method: "DELETE" }); await vrfsDialog(); }),
"type-add": () => guarded(async () => {
const name = $("#new-type").value.trim();
if (!name) return toast("Введите название типа", true);
await api("/device-types", { method: "POST", body: { name } });
await typesDialog();
}),
"type-edit": (d) => { S.typeEdit = Number(d.id); typesDialog(); },
"type-cancel": () => { S.typeEdit = null; typesDialog(); },
"type-save": (d) => guarded(async () => {
await api(`/device-types/${d.id}`, { method: "PATCH", body: { name: $("#edit-name").value.trim() } });
S.typeEdit = null; await typesDialog();
}),
"type-del": (d) => confirmDel("тип") && guarded(async () => { await api(`/device-types/${d.id}`, { method: "DELETE" }); await typesDialog(); }),
};
const closeDropdowns = () => document.querySelectorAll(".dd:not([hidden])").forEach((p) => { p.hidden = true; });
document.addEventListener("click", (e) => {
if (!e.target.closest(".cselect")) closeDropdowns();
const el = e.target.closest("[data-action]");
if (!el) {
if (menu) { menu = null; draw(); }
return;
}
const a = el.dataset.action;
if (el.dataset.row) {
// действие всей строки: не мешаем ссылкам, кнопкам, меню, Ctrl/Shift-клику и выделению текста
if (e.target.closest("a[href], button, input, label, select, textarea, .pop")) return;
if (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey || String(window.getSelection?.() ?? "")) return;
if (menu) { menu = null; draw(); return; } // открытое меню: клик лишь закрывает его
}
if (menu && a !== "menu" && a !== "pick") { menu = null; if (!actions[a]) draw(); }
if (el.tagName === "A" && el.getAttribute("href") === "#") e.preventDefault();
if (actions[a]) actions[a](el.dataset, el, e);
});
let timer;
document.addEventListener("input", (e) => {
if (e.target.id === "clear-pw") { $("#clear-go").disabled = !e.target.value; return; }
if (e.target.dataset.input !== "q") return;
clearTimeout(timer);
timer = setTimeout(() => { S.q = e.target.value; S.limit = 100; draw(); }, 250);
});
document.addEventListener("change", (e) => {
if (e.target.id === "jfrom" || e.target.id === "jto") { S[e.target.id] = e.target.value; S.limit = 100; draw(); return; }
if (e.target.name === "length" && S.subnetRefresh) { S.subnetRefresh(); return; }
const hint = $("#vrf-hint");
if (hint && e.target.name === "vrf_id") hint.hidden = e.target.value === S.vrfHint?.original;
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && document.querySelector(".dd:not([hidden])")) { closeDropdowns(); e.stopPropagation(); return; }
if ((e.key === "ArrowDown" || e.key === "ArrowUp") && e.target.closest(".cselect")) {
e.preventDefault();
const box = e.target.closest(".cselect"), pop = box.querySelector(".dd");
if (pop.hidden) { closeDropdowns(); pop.hidden = false; (pop.querySelector(".sel") || pop.querySelector("button")).focus(); return; }
const items = [...pop.querySelectorAll("button")], i = items.indexOf(document.activeElement);
items[Math.max(0, Math.min(items.length - 1, i + (e.key === "ArrowDown" ? 1 : -1)))].focus();
return;
}
if (e.key === "Escape") { if ($("#modal-root").innerHTML) closeDialog(); else if (menu) { menu = null; draw(); } }
if (e.key === "Enter" && e.target.id === "clear-pw") { e.preventDefault(); doClear(); return; }
if (e.key === "Enter" && e.target.dataset?.action === "jr-open") { e.preventDefault(); actions["jr-open"](e.target.dataset); return; }
if (e.key === "Enter" && e.target.closest("#dlg-form") && e.target.tagName !== "TEXTAREA") { e.preventDefault(); submitDialog(); }
});
document.addEventListener("submit", async (e) => {
if (e.target.id === "login-form") {
e.preventDefault();
const f = new FormData(e.target);
try {
const r = await api("/auth/login", { method: "POST", body: { username: f.get("username"), password: f.get("password") } });
if (r.mfa_required) { // изменение 037: второй шаг — код из приложения или код восстановления
pendingMfa = { mfa_token: r.mfa_token };
$("#app").innerHTML = loginScreen();
return;
}
store.token = r.access_token;
user = null; current = "";
location.hash = "#/overview";
draw();
} catch (err) {
$("#app").innerHTML = loginScreen(err.message);
}
return;
}
if (e.target.id === "mfa-form") { // изменение 037
e.preventDefault();
const code = new FormData(e.target).get("code");
try {
const { access_token } = await api("/auth/login/2fa", { method: "POST", body: { mfa_token: pendingMfa.mfa_token, code } });
pendingMfa = null;
store.token = access_token;
user = null; current = "";
location.hash = "#/overview";
draw();
} catch (err) {
// 401 «Неверный код» — остаёмся на втором шаге (можно повторить); другой 401 — истёкший/недействительный
// mfa_token или отключённая 2FA — возврат к паролю (изменение 037)
if (err instanceof ApiError && err.status === 401 && err.message !== "Неверный код") {
pendingMfa = null;
$("#app").innerHTML = loginScreen("Время на ввод кода истекло, войдите заново");
return;
}
$("#app").innerHTML = loginScreen(err.message);
}
}
});
draw();