В списке адресов префикса подряд идущие свободные адреса сворачиваются в строку «начало – конец · Свободно: N» (параметр group_free, группировка на сервере — пагинация по строкам после свёртки); раскрытие — существующим status=free&offset=free_offset порциями по 500. Без group_free ответ API прежний. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1281 lines
108 KiB
JavaScript
1281 lines
108 KiB
JavaScript
// IPAM Manager — UI-админка. Только визуализирует ответы /api/v1, бизнес-логики нет.
|
||
const API = "/api/v1";
|
||
const $ = (s) => document.querySelector(s);
|
||
const esc = (v) => String(v ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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: ["", "Свободен"] };
|
||
const DEVICE_STATUS = { active: ["green", "Активен"], off: ["", "Выключен"], maintenance: ["amber", "На обслуживании"] }; // изменение 039
|
||
// изменение 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 140px 110px minmax(200px,1.6fr) 44px"); // изменение 039: колонка «Статус»
|
||
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 [dc, dt] = DEVICE_STATUS[d.status]; // изменение 039
|
||
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><span>${badge(dc, dt)}</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>Статус</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, status: v.status }
|
||
: { ...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();
|
||
};
|
||
// изменение 039: статус устройства, по умолчанию «Активен»
|
||
const statusOptions = Object.entries(DEVICE_STATUS).map(([value, [, label]]) => ({ value, label }));
|
||
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)}${fSelect("status", "Статус", statusOptions, d?.status ?? "active")}</div>
|
||
${fInput("mac", "MAC-адрес", { value: d?.mac, ph: "00:1A:2B:3C:4D:5E", optional: true })}
|
||
${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, group_free: true } }), // изменение 042: свёртка подряд идущих свободных адресов
|
||
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;
|
||
// изменение 042: подгрузка адресов для раскрытых диапазонов (S.freeOpen — множество начальных адресов диапазонов);
|
||
// S.freeLoaded — сколько адресов диапазона подгружено (порциями по 500, если диапазон больше)
|
||
const openRanges = page.items.filter((a) => a.status === "free_range" && S.freeOpen?.has(a.address));
|
||
const expanded = Object.fromEntries(await Promise.all(openRanges.map(async (a) => {
|
||
const want = Math.min((S.freeLoaded && S.freeLoaded[a.address]) || Math.min(a.range_count, 500), a.range_count);
|
||
const res = await api(`/prefixes/${id}/addresses`, { params: { status: "free", offset: a.free_offset, limit: want } });
|
||
return [a.address, res.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;
|
||
// изменение 039: после «Описание» — «Тип устройства», «Устройство», «Статус устройства»; «Описание» и адресные колонки сужены, «Изменён» без изменений
|
||
const cols = selCols("minmax(120px,0.9fr) minmax(110px,0.8fr) minmax(110px,1fr) 120px minmax(130px,1fr) 140px 110px 160px 44px");
|
||
setSelectable(page.items.filter((a) => a.id != null).map((a) => a.id)); // у «Свободен» и «диапазона» нет записи
|
||
// изменение 042: обычная строка адреса (в т.ч. раскрытые адреса диапазона, indent — с отступом, как вложенные)
|
||
const addrRow = (a, indent = false) => {
|
||
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" }];
|
||
const devStatus = a.device_status ? badge(...DEVICE_STATUS[a.device_status]) : `<span class="muted">—</span>`; // изменение 039
|
||
return `<div class="tr hoverable clickable${free ? " free" : ""}${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" style="color:${free ? "var(--faint)" : "var(--text)"}${indent ? ";padding-left:20px" : ""}">${esc(a.address)}</span>
|
||
<span class="mono ${free ? "" : "muted"}" style="font-size:13px;font-weight:400">${esc(a.dns_name || "—")}</span><span class="ell muted">${esc(a.description || (free ? "—" : ""))}</span>
|
||
<span class="muted">${esc(a.device_type_name || "—")}</span><span class="ell muted">${esc(a.device_name || "—")}</span><span>${devStatus}</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>`;
|
||
};
|
||
// изменение 042: свёрнутая строка диапазона свободных адресов — шеврон, «<начало> – <конец>», бейдж «Свободно: N», без действий и без чекбокса
|
||
const rangeRow = (a, open) => `<div class="tr hoverable clickable free" data-row="1" data-action="range-toggle" data-id="${esc(a.address)}" style="--cols:${cols};--h:52px">${selBlank()}<span class="m13" style="display:flex;align-items:center;gap:6px;color:var(--faint)"><span class="chev${open ? " open" : ""}">${I.chevR(14)}</span>${esc(a.address)} – ${esc(a.range_end)}</span>
|
||
<span class="muted">—</span><span class="muted">—</span><span class="muted">—</span><span class="muted">—</span><span class="muted">—</span><span>${badge("", `Свободно: ${a.range_count}`)}</span>
|
||
<span class="muted" style="font-size:13px">—</span><span></span></div>`;
|
||
// изменение 042: диапазон больше 500 — после подгруженной порции строка «ещё N — показать»
|
||
const rangeMoreRow = (a, loaded) => `<div class="tr free" style="--cols:${cols};--h:52px">${selBlank()}<span class="m13" style="padding-left:20px"><a href="#" data-action="range-more" data-id="${esc(a.address)}" data-loaded="${loaded}">ещё ${a.range_count - loaded} — показать</a></span>
|
||
<span class="muted">—</span><span class="muted">—</span><span class="muted">—</span><span class="muted">—</span><span class="muted">—</span><span class="muted">—</span><span></span></div>`;
|
||
const rowsArr = [];
|
||
page.items.forEach((a) => {
|
||
if (a.status === "free_range") {
|
||
const open = !!S.freeOpen?.has(a.address);
|
||
rowsArr.push(rangeRow(a, open));
|
||
if (open) {
|
||
const items = expanded[a.address] || [];
|
||
items.forEach((f) => rowsArr.push(addrRow(f, true)));
|
||
if (items.length < a.range_count) rowsArr.push(rangeMoreRow(a, items.length));
|
||
}
|
||
} else rowsArr.push(addrRow(a));
|
||
});
|
||
if (rowsArr.length) rowsArr[rowsArr.length - 1] = rowsArr[rowsArr.length - 1].replace('class="tr ', 'class="tr last ');
|
||
const rows = rowsArr.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>Устройство</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>`
|
||
// изменение 042: при group_free одиночных строк «free» может не быть — первый адрес свёрнутого диапазона тоже свободен
|
||
: fInput("address", "IP-адрес", { mono: true, value: presetIp || (S.page.items.find((x) => x.status === "free" || x.status === "free_range")?.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)} · ${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; S.freeOpen = new Set(); S.freeLoaded = {}; } // изменение 042: свёрнутые диапазоны сбрасываются со сменой фильтра
|
||
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),
|
||
// изменение 042: свёрнутый диапазон свободных адресов — раскрыть/свернуть по клику на строку
|
||
"range-toggle": (d) => {
|
||
const open = (S.freeOpen ||= new Set());
|
||
open.has(d.id) ? open.delete(d.id) : open.add(d.id);
|
||
draw();
|
||
},
|
||
// изменение 042: диапазон больше 500 адресов — подгрузить следующую порцию (до 500 за раз)
|
||
"range-more": (d) => {
|
||
const a = S.page.items.find((x) => x.status === "free_range" && x.address === d.id);
|
||
if (!a) return;
|
||
(S.freeLoaded ||= {})[d.id] = Math.min(Number(d.loaded) + 500, a.range_count);
|
||
draw();
|
||
},
|
||
"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; S.freeOpen = new Set(); S.freeLoaded = {}; draw(); }, 250); // изменение 042: свёрнутые диапазоны сбрасываются с поиском
|
||
});
|
||
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();
|