Files
ipam_control/web/app.js
T
ayurishchevandClaude Opus 5.5 03d727e496 Задачи 025-030: ёмкость префиксов, политика входа, дерево префиксов
Повторный анализ кодовой базы (docs/reviews/2026-09-26-codebase-review-2.md) и доработки:
025 Ёмкость префикса — размер его подсети (а не сумма листьев); «Обзор» считает ёмкость
    по корневым активным IPv4-префиксам и адреса внутри них.
026 Политика блокировки входа: 5 неудач на логин+IP, 20 на IP, 50 на логин со всех IP,
    кроме известных IP (known_logins, миграция 0009) — владельца нельзя заблокировать анонимно.
027 Сериализация попыток входа по IP (advisory-lock после блокировки логина).
028 UI «Префиксы»: загрузка всех страниц (до 20 000), счётчики по total, предупреждение об усечении.
029 Advisory-lock по VRF для операций, меняющих дерево префиксов и раскладку адресов.
030 Исправление замечаний ревью 025-029: _lock_prefix (VRF блокируется до чтения префикса,
    409 при одновременном переносе), константы политики входа перенесены в app/services.py.

Тесты: 14 passed (проверка ёмкости родителя приведена к семантике 025); сквозные сценарии
и гонки — docs/reviews/2026-09-26-changes-025-029-review.md, 2026-09-27-changes-030-review.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 08:28:50 +03:00

1058 lines
87 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// IPAM Manager — UI-админка. Только визуализирует ответы /api/v1, бизнес-логики нет.
const API = "/api/v1";
const $ = (s) => document.querySelector(s);
const esc = (v) => String(v ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
const fmtNum = (n) => new Intl.NumberFormat("ru-RU").format(n);
const fmtDate = (iso) => (iso ? iso.replace("T", " ").slice(0, 16) : "—");
const plural = (n, one, few, many) => {
const m10 = n % 10, m100 = n % 100;
return m10 === 1 && m100 !== 11 ? one : m10 >= 2 && m10 <= 4 && (m100 < 10 || m100 >= 20) ? few : many;
};
/* ------------------------------------------------------------------ icons */
const ic = (inner, s = 16) => `<svg width="${s}" height="${s}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${inner}</svg>`;
const I = {
chevR: (s) => ic('<path d="m9 6 6 6-6 6"/>', s),
chevD: (s) => ic('<path d="m6 9 6 6 6-6"/>', s),
plus: () => ic('<path d="M12 5v14M5 12h14"/>'),
search: () => ic('<circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/>'),
dots: () => ic('<circle cx="5" cy="12" r="1.3"/><circle cx="12" cy="12" r="1.3"/><circle cx="19" cy="12" r="1.3"/>'),
close: () => ic('<path d="M6 6l12 12M18 6 6 18"/>'),
edit: (s) => ic('<path d="M12 20h9M16.5 3.5a2.121 2.121 0 1 1 3 3L7 19l-4 1 1-4Z"/>', s),
trash: (s) => ic('<path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"/>', s),
org: () => ic('<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M7 9h4M7 13h4"/>'),
gear: () => ic('<path d="M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z"/><path d="M19.4 13a7.6 7.6 0 0 0 .1-1 7.6 7.6 0 0 0-.1-1l2-1.6-2-3.4-2.3.9a7.4 7.4 0 0 0-1.7-1l-.3-2.5h-4l-.3 2.5a7.4 7.4 0 0 0-1.7 1l-2.3-.9-2 3.4 2 1.6a7.6 7.6 0 0 0 0 2l-2 1.6 2 3.4 2.3-.9a7.4 7.4 0 0 0 1.7 1l.3 2.5h4l.3-2.5a7.4 7.4 0 0 0 1.7-1l2.3.9 2-3.4Z"/>'),
refresh: () => ic('<path d="M20 12a8 8 0 1 1-2.3-5.7M20 4v5h-5"/>'),
cog: () => ic('<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/>'),
trashJ: () => ic('<path d="M4 7h16M10 11v6M14 11v6M6 7l1 13h10l1-13M9 7V4h6v3"/>'),
calendar: () => ic('<rect x="3" y="5" width="18" height="16" rx="2"/><path d="M3 10h18M8 3v4M16 3v4"/>'),
copy: () => ic('<rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V6a2 2 0 0 1 2-2h9"/>'),
globe: () => `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#fff" 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>`,
};
/* ------------------------------------------------------------------- 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); },
};
let user = null;
let orgs = [];
let S = {}; // локальное состояние текущего экрана
let menu = null; // открытое всплывающее меню {id, items:[{label,value,cls}], on}
/* --------------------------------------------------------------------- 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") {
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 utilColor = (p) => (p >= 90 ? ["#c62828", "#a32020"] : p >= 75 ? ["#d97706", "#7c4a00"] : ["#2450c8", "#58657a"]);
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 === "admin";
const ROLE_RU = { admin: "Администратор", viewer: "Просмотр" };
/* ------------------------------------------------ выбор строк и групповые операции (только 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();
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>`;
}
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", "Пользователи", "admin"]];
function shell(active, content) {
const nav = NAV.filter(([, , role]) => role !== "admin" || isAdmin()); // раздел «Пользователи» виден только администратору
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 class="user" title="${esc(ROLE_RU[user?.role] ?? "")}">${esc(user?.username ?? "")}</span>${btn("Сменить пароль", "pw-change", { cls: "ghost" })}${btn("Выйти", "logout", { cls: "ghost" })}</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());
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] = utilColor(p.utilization);
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 === "#58657a" ? "var(--muted)" : txt};font-weight:${txt === "#58657a" ? 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:#7c4a00">${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) => `<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, [{ label: "Открыть префиксы", value: "open:" + o.id }, { label: "Редактировать", value: "edit:" + o.id }, { label: "Удалить", value: "del:" + o.id, cls: "danger" }], "row-pop")}</span></div>`).join("");
S.rows = items;
return shell("orgs", `${header("Организации", `${all} ${plural(all, "организация", "организации", "организаций")}`, btn("Добавить организацию", "org-new", { cls: "primary", icon: I.plus() }))}
<div class="card"><div class="filters">${searchBox(q, "Поиск: название, ИНН, адрес", 300)}</div>
${bulkBar()}<div class="tr th" style="--cols:${cols}">${selAllCell()}<span>Название</span><span>Краткое имя</span><span>ИНН</span><span>Адрес</span><span>Префиксов</span><span>Адресов</span><span></span></div>
${rows || '<div class="empty">Ничего не найдено</div>'}<div class="foot">Показано ${items.length} из ${all} ${plural(all, "организации", "организаций", "организаций")}</div></div>`);
};
function orgDialog(o) {
const edit = !!o;
S.dialog = async (v) => {
await api(edit ? `/organizations/${o.id}` : "/organizations", { method: edit ? "PATCH" : "POST", body: v });
toast(edit ? "Организация сохранена" : "Организация добавлена");
await loadOrgs();
await draw();
};
openDialog({
title: edit ? "Редактирование организации" : "Новая организация",
body: formBody(`${fInput("name", "Название", { value: o?.name })}
<div class="grid2">${fInput("short_name", "Краткое имя", { value: o?.short_name })}${fInput("inn", "ИНН", { value: o?.inn })}</div>
${fInput("address", "Юридический адрес", { value: o?.address })}
<div class="grid2">${fInput("contact_person", "Контактное лицо", { value: o?.contact_person, optional: true })}${fInput("phone", "Телефон", { value: o?.phone, optional: true })}</div>
${fInput("email", "Email", { value: o?.email, optional: true, ph: "email@example.com" })}${fArea("note", "Примечание", { value: o?.note })}`),
foot: dlgFoot(edit ? "Сохранить" : "Добавить организацию"),
});
}
// ---- ISPs
screens.isps = async () => {
const q = S.q || "";
const { items } = await api("/isps", { params: { q, limit: 500 } });
const all = q ? (await api("/isps", { params: { limit: 1 } })).total : items.length;
S.rows = items;
const cols = selCols("minmax(130px,1fr) minmax(160px,1.2fr) minmax(180px,1.4fr) 150px 140px minmax(120px,1fr) 44px");
setSelectable(items.map((i) => i.id));
const rows = items.map((i, n) => `<div class="tr hoverable clickable${n === items.length - 1 ? " last" : ""}${selRow(i.id)}" data-row="1" data-action="isp-edit" data-id="${i.id}" style="--cols:${cols}">${selCell(i.id)}<span style="font-weight:500">${esc(i.name)}</span>
<a href="#/prefixes" data-action="open-org" data-id="${i.organization_id}" style="font-size:13px">${esc(i.organization_name)}</a>
<span style="display:flex;align-items:center"><span class="mono" style="font-size:13px">${esc(i.networks[0] ?? "—")}</span>${i.networks.length > 1 ? `<span class="more">+${i.networks.length - 1}</span>` : ""}</span>
<span class="mono muted" style="font-size:13px">${esc(i.hotline)}</span><span class="muted">${esc(i.contract_number)}</span><span class="muted ell">${esc(i.note)}</span>
<span class="cell-actions rel">${iconBtn(I.dots(), "menu", "Действия", `data-menu="isp-row-${i.id}"`)}${popMenu("isp-row-" + i.id, [{ label: "Редактировать", value: "edit:" + i.id }, { label: "Удалить", value: "del:" + i.id, cls: "danger" }], "row-pop")}</span></div>`).join("");
return shell("isps", `${header("Операторы связи", "Реестр провайдеров и каналов", btn("Добавить оператора", "isp-new", { cls: "primary", icon: I.plus() }))}
<div class="card"><div class="filters">${searchBox(q, "Поиск: оператор, организация, IP", 300)}</div>
${bulkBar()}<div class="tr th" style="--cols:${cols}">${selAllCell()}<span>Название</span><span>Организация</span><span>IP-адреса</span><span>Горячая линия</span><span>Договор</span><span>Заметки</span><span></span></div>
${rows || '<div class="empty">Ничего не найдено</div>'}<div class="foot">Показано ${items.length} из ${all} ${plural(all, "оператора", "операторов", "операторов")}</div></div>`);
};
function ispDialog(i) {
const edit = !!i;
S.dialog = async (v) => {
const body = { ...v, organization_id: Number(v.organization_id), networks: v.networks.split(/\s+/).filter(Boolean) };
await api(edit ? `/isps/${i.id}` : "/isps", { method: edit ? "PUT" : "POST", body });
toast(edit ? "Оператор сохранён" : "Оператор добавлен");
await draw();
};
openDialog({
title: edit ? "Редактирование оператора" : "Новый оператор связи",
body: formBody(`${fInput("name", "Название", { value: i?.name })}
${fSelect("organization_id", "Организация", orgs.map((o) => ({ value: o.id, label: o.name })), i?.organization_id ?? store.orgId)}
${fArea("networks", "IP-адреса", { value: (i?.networks || []).join("\n"), ph: "один CIDR на строку", rows: 3, h: 72, mono: true, optional: false, hint: "можно несколько" })}
<div class="grid2">${fInput("hotline", "Горячая линия", { value: i?.hotline })}${fInput("contract_number", "Номер договора", { value: i?.contract_number })}</div>
${fArea("note", "Примечание", { value: i?.note })}`),
foot: dlgFoot(edit ? "Сохранить" : "Добавить оператора"),
});
}
// ---- devices
screens.devices = async () => {
const org = currentOrg();
const [types, list] = await Promise.all([
api("/device-types"),
api("/devices", { params: { organization_id: org?.id, device_type_id: S.type, q: S.q, limit: 500 } }),
]);
S.types = types.items;
S.rows = list.items;
const cols = selCols("minmax(200px,1.6fr) 170px 110px minmax(220px,1.8fr) 44px");
setSelectable(list.items.map((d) => d.id));
const q = S.q || "";
const typeItems = [{ label: "любой", value: "" }, ...types.items.map((t) => ({ label: t.name, value: t.id }))];
const rows = list.items.map((d, n) => {
const dep = d.all_deprecated;
const ips = d.ip_addresses.length
? `<span style="display:flex;align-items:center"><a href="#/prefixes/${d.first_prefix_id}" style="font:500 13px var(--sans)">${d.ip_addresses.length}</a>${d.ip_addresses.length > 1 ? `<span class="more">+${d.ip_addresses.length - 1}</span>` : ""}</span>`
: `<span class="muted" style="font-size:13px">0</span>`;
return `<div class="tr hoverable clickable${n === list.items.length - 1 ? " last" : ""}${selRow(d.id)}" data-row="1" data-action="dev-edit" data-id="${d.id}" style="--cols:${cols}">${selCell(d.id)}<span class="m13" style="font-weight:600;${dep ? "color:var(--faint);text-decoration:line-through" : ""}">${esc(d.name)}</span>
<span class="${dep ? "" : "muted"}" style="${dep ? "color:var(--faint)" : ""}">${esc(d.device_type_name)}</span>${ips}<span class="ell" style="color:${dep ? "var(--faint)" : "var(--muted)"}">${esc(d.note)}</span>
<span class="cell-actions rel">${iconBtn(I.dots(), "menu", "Действия", `data-menu="dev-row-${d.id}"`)}${popMenu("dev-row-" + d.id, [{ label: "Редактировать", value: "edit:" + d.id }, { label: "Удалить", value: "del:" + d.id, cls: "danger" }], "row-pop")}</span></div>`;
}).join("");
return shell("devices", `${crumbsOrg()}${header("Устройства", `${list.total} ${plural(list.total, "устройство", "устройства", "устройств")} · организация: ${esc(org?.name ?? "")}`, btn("Добавить устройство", "dev-new", { cls: "primary", icon: I.plus() }))}
<div class="right-link"><a href="#" data-action="types">${I.gear()}Управление типами</a></div>
<div class="card"><div class="filters">${orgSwitcher()}${searchBox(q, "Поиск: имя, IP, заметка", 260)}${filterBtn("type", "Тип", typeItems, S.type ?? "", 170)}</div>
${bulkBar(bulkMenu("bulk-type", "Сменить тип", types.items.map((t) => ({ label: t.name, value: t.id }))))}<div class="tr th" style="--cols:${cols}">${selAllCell()}<span>Устройство</span><span>Тип</span><span>IP-адресов</span><span>Заметки</span><span></span></div>
${rows || '<div class="empty">Устройств нет</div>'}<div class="foot">Показано ${list.items.length} из ${list.total} ${plural(list.total, "устройства", "устройств", "устройств")}</div></div>`);
};
function deviceDialog(d) {
const edit = !!d;
S.dialog = async (v) => {
const body = edit ? { name: v.name, device_type_id: Number(v.device_type_id), mac: v.mac, note: v.note }
: { ...v, device_type_id: Number(v.device_type_id), organization_id: Number(v.organization_id) };
await api(edit ? `/devices/${d.id}` : "/devices", { method: edit ? "PATCH" : "POST", body });
toast(edit ? "Устройство сохранено" : "Устройство добавлено");
await draw();
};
openDialog({
title: edit ? "Редактирование устройства" : "Новое устройство",
body: formBody(`${fInput("name", "Название (hostname)", { value: d?.name, ph: "host.internal" })}
<div class="grid2">${fSelect("device_type_id", "Тип", S.types.map((t) => ({ value: t.id, label: t.name })), d?.device_type_id)}${fInput("mac", "MAC-адрес", { value: d?.mac, ph: "00:1A:2B:3C:4D:5E", optional: true })}</div>
${edit ? "" : fSelect("organization_id", "Организация", orgs.map((o) => ({ value: o.id, label: o.name })), store.orgId)}
${fArea("note", "Заметки", { value: d?.note })}
<div class="info">IP-адрес назначается отдельно — на экране «Адреса» через «Назначить адрес» и поле «Связано с устройством».</div>`),
foot: dlgFoot(edit ? "Сохранить" : "Добавить устройство"),
});
}
async function typesDialog() {
const { items } = await api("/device-types");
S.types = items;
const cols = "1fr 110px 84px";
const rows = items.map((t) => {
const editing = S.typeEdit === t.id;
const name = editing
? `<input class="input" id="edit-name" value="${esc(t.name)}" style="height:32px">`
: `<span style="font-weight:600;${t.is_default ? "color:var(--faint)" : ""}">${esc(t.name)}</span>`;
const locked = t.devices_count > 0;
const 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: "Типы используются в фильтре и в поле «Тип» при добавлении устройства. Общий список для всех организаций.",
body: `<div class="mini-table"><div class="tr th" style="--cols:${cols}"><span>Название</span><span>Устройств</span><span></span></div>${rows}</div>
<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>`,
foot: `<div class="dialog-foot">${btn("Готово", "close-dialog", { cls: "primary" })}</div>`,
});
}
// ---- prefixes
const PREFIX_UI_CAP = 20000; // защита от неограниченной подгрузки: организация с частичным разбиением может дать сотни тысяч префиксов
async function loadPrefixes(orgId) {
// грузит все страницы /prefixes (порядок ORDER BY vrf_id, prefix стабилен) до total, но не больше PREFIX_UI_CAP —
// иначе заголовок, вкладка «Все» и родительский список в диалогах молча ограничивались первой страницей (изменение 028, находка №3)
let items = [], total = 0, offset = 0;
for (;;) {
const page = await api("/prefixes", { params: { organization_id: orgId, limit: 1000, offset } });
total = page.total;
items = items.concat(page.items);
offset += page.items.length;
if (page.items.length === 0 || offset >= total || items.length >= PREFIX_UI_CAP) break;
}
return { items, total };
}
screens.prefixes = async () => {
const org = currentOrg();
if (!org) return shell("prefixes", header("Префиксы", "Сначала добавьте организацию") + `<div class="card"><div class="empty">Нет организаций. ${btn("Добавить организацию", "org-new", { cls: "primary" })}</div></div>`);
const [pl, vl] = await Promise.all([
loadPrefixes(org.id),
api("/vrfs", { params: { organization_id: org.id } }),
]);
S.prefixes = pl.items;
S.vrfs = vl.items;
const all = pl.items;
const q = (S.q || "").toLowerCase();
const filtered = all.filter((p) => (!S.vrf || p.vrf_id === S.vrf) && (!S.status || p.status === S.status) && (!S.family || p.family === Number(S.family))
&& (!q || p.prefix.includes(q) || p.description.toLowerCase().includes(q)));
const hasKids = new Set(all.map((p) => p.parent_id).filter(Boolean));
if (!S.collapsed) S.collapsed = new Set(all.filter((p) => !p.depth && hasKids.has(p.id)).slice(1).map((p) => p.id)); // раскрыта только первая ветка
const collapsed = S.collapsed;
const byId = Object.fromEntries(all.map((p) => [p.id, p]));
const hidden = (p) => { for (let x = byId[p.parent_id]; x; x = byId[x.parent_id]) if (collapsed.has(x.id)) return true; return false; };
const visible = filtered.filter((p) => !hidden(p));
const cols = selCols("44px minmax(200px,1.8fr) 1.4fr 100px 100px 200px 80px 44px");
setSelectable(visible.filter((p) => !hasKids.has(p.id)).map((p) => p.id)); // как и меню «⋯»: только листовые
const tab = (id, label, n) => `<button type="button" class="tab${(S.vrf || 0) === id ? " active" : ""}" data-action="vrf-tab" data-id="${id}">${esc(label)} <span class="n">${n}</span></button>`;
const rows = visible.map((p, n) => {
const [bar, txt] = utilColor(p.utilization);
const [sc, st] = PREFIX_STATUS[p.status];
const leaf = !hasKids.has(p.id);
const chev = hasKids.has(p.id) ? `<span class="chev ${collapsed.has(p.id) ? "" : "open"}"><button type="button" data-action="toggle" data-id="${p.id}" aria-label="Свернуть/развернуть" aria-expanded="${!collapsed.has(p.id)}">${I.chevR(16)}</button></span>` : "<span></span>";
const pad = p.depth * 20;
const cidr = leaf ? `<a href="#/prefixes/${p.id}" class="m13" style="padding-left:${pad}px;font-weight:600">${esc(p.prefix)}</a>` : `<span class="m13" style="padding-left:${pad}px;font-weight:600">${esc(p.prefix)}</span>`;
return `<div class="tr hoverable clickable${p.depth ? " child" : ""}${n === visible.length - 1 ? " last" : ""}${leaf ? selRow(p.id) : ""}" data-row="1" data-action="${leaf ? "pfx-open" : "toggle"}" data-id="${p.id}" style="--cols:${cols}">${leaf ? selCell(p.id) : selBlank()}${chev}${cidr}<span class="muted">${esc(p.description)}</span><span class="muted" style="font-size:13px">${esc(p.vrf_name)}</span><span>${badge(sc, st)}</span>
<div class="bar"><div class="track"><div class="fill" style="width:${p.utilization}%;background:${bar}"></div></div><span class="pct" style="color:${txt}">${p.utilization}%</span></div><span class="muted" style="font-size:13px">${fmtNum(p.used)}</span>
<span class="cell-actions rel">${leaf || isAdmin() ? iconBtn(I.dots(), "menu", "Действия", `data-menu="pfx-row-${p.id}"`) + popMenu("pfx-row-" + p.id, [...(leaf ? [{ label: "Открыть адреса", value: "open:" + p.id }, { label: "Редактировать", value: "edit:" + p.id }] : []), ...(isAdmin() ? [{ label: "Добавить вложенный (авто)", value: "subnet:" + p.id }] : []), ...(leaf ? [{ label: "Удалить", value: "del:" + p.id, cls: "danger" }] : [])], "row-pop") : ""}</span></div>`;
}).join("");
const statusItems = [{ label: "любой", value: "" }, { label: "Активен", value: "active" }, { label: "Резерв", value: "reserved" }, { label: "Устарел", value: "deprecated" }];
const famItems = [{ label: "любое", value: "" }, { label: "IPv4", value: "4" }, { label: "IPv6", value: "6" }];
const truncated = pl.total > all.length ? `<div class="info">Загружено ${fmtNum(all.length)} из ${fmtNum(pl.total)} префиксов — уточните поиск или выберите VRF</div>` : "";
return shell("prefixes", `${crumbsOrg()}${header("Префиксы", `${pl.total} ${plural(pl.total, "префикс", "префикса", "префиксов")} · ${vl.total} VRF · организация: ${esc(org.name)}`, btn("Добавить префикс", "pfx-new", { cls: "primary", icon: I.plus() }))}
<div class="tabs">${tab(0, "Все", pl.total)}${vl.items.map((v) => tab(v.id, v.name, v.prefixes_count)).join("")}<div class="grow"></div><button type="button" class="link-muted" data-action="vrfs">${I.gear()}Управление VRF</button></div>
${truncated}
<div class="card"><div class="filters">${orgSwitcher()}${searchBox(S.q || "", "Поиск: префикс, описание", 280)}${filterBtn("status", "Статус", statusItems, S.status ?? "")}${filterBtn("family", "Семейство", famItems, S.family ?? "")}</div>
${bulkBar(bulkMenu("bulk-status", "Статус", Object.entries(PREFIX_STATUS).map(([value, [, label]]) => ({ label, value }))))}<div class="tr th" style="--cols:${cols};--h:40px">${selAllCell()}<span></span><span>Префикс</span><span>Описание</span><span>VRF</span><span>Статус</span><span>Использование</span><span>Адресов</span><span></span></div>
${rows || '<div class="empty">Префиксов нет</div>'}<div class="foot">Показано ${visible.length} из ${pl.total} ${plural(pl.total, "префикса", "префиксов", "префиксов")}</div></div>`);
};
function prefixDialog(p) {
const edit = !!p;
S.dialog = async (v) => {
if (edit) await api(`/prefixes/${p.id}`, { method: "PATCH", body: { vrf_id: Number(v.vrf_id), description: v.description, status: v.status, is_pool: v.is_pool, note: v.note } });
else await api("/prefixes", { method: "POST", body: { ...v, organization_id: store.orgId, vrf_id: Number(v.vrf_id), parent_id: v.parent_id ? Number(v.parent_id) : null } });
toast(edit ? "Префикс сохранён" : "Префикс добавлен");
await draw();
};
const parents = (S.prefixes || []).map((x) => ({ value: x.id, label: x.description ? `${x.prefix} — ${x.description}` : x.prefix }));
// число вложенных префиксов: они переезжают вместе с выбранным при смене VRF
let nested = 0;
if (edit) {
let level = [p.id];
while (level.length) { level = (S.prefixes || []).filter((x) => level.includes(x.parent_id)).map((x) => x.id); nested += level.length; }
S.vrfHint = { original: String(p.vrf_id) };
}
openDialog({
title: edit ? `Префикс ${p.prefix}` : "Новый префикс",
body: formBody(`${edit ? "" : fInput("prefix", "Префикс (CIDR)", { ph: "0.0.0.0/0", mono: true, hint: "например 10.30.0.0/24" }).replace("</span><input", `${opt("например 10.30.0.0/24")}</span><input`)}
${fInput("description", "Описание", { value: p?.description })}
<div class="grid2">${fSelect("vrf_id", "VRF", (S.vrfs || []).map((v) => ({ value: v.id, label: v.name })), edit ? p.vrf_id : S.vrf || S.vrfs?.[0]?.id)}${fSelect("status", "Статус", [["active", "Активен"], ["reserved", "Резерв"], ["deprecated", "Устарел"]].map(([value, label]) => ({ value, label })), p?.status ?? "active")}</div>
${edit && nested ? `<div class="info" id="vrf-hint" hidden>Вместе с префиксом будет перенесено вложенных: ${nested}</div>` : ""}
${edit ? "" : fSelect("parent_id", "Родительский префикс", parents, "", { optional: true, empty: "Определить автоматически" })}
${fArea("note", "Примечание", { value: p?.note, ph: "Любая дополнительная информация" })}
<label class="check"><input type="checkbox" name="is_pool" ${p?.is_pool ? "checked" : ""}><span>Пул для автоназначения<small>Адреса из этого префикса будут выдаваться автоматически по запросу</small></span></label>`),
foot: dlgFoot(edit ? "Сохранить" : "Добавить префикс"),
});
}
// «следующий свободный» вложенный префикс: система сама выбирает первый свободный выровненный блок родителя
function subnetDialog(parent) {
const plen = Number(parent.prefix.split("/")[1]), max = parent.family === 6 ? 128 : 32, lo = plen + 1;
if (lo > max) return toast("Префикс нельзя дробить: это одиночный адрес", true);
const kids = (S.prefixes || []).filter((x) => x.parent_id === parent.id);
const lastKid = kids.reduce((a, b) => (a && a.id > b.id ? a : b), null);
const def = lastKid ? Number(lastKid.prefix.split("/")[1]) : Math.min(Math.max(parent.family === 6 ? 64 : 30, lo), max);
const sizes = Array.from({ length: max - lo + 1 }, (_, i) => ({ value: lo + i, label: "/" + (lo + i) }));
S.dialog = async (v) => {
const r = await api(`/prefixes/${parent.id}/subnets/next`, { method: "POST", body: { length: Number(v.length), description: v.description, status: v.status, is_pool: v.is_pool, note: v.note } });
S.collapsed?.delete(parent.id); // показать созданный префикс под родителем
toast(`Создан префикс ${r.prefix}`);
await draw();
};
let seq = 0;
S.subnetRefresh = async () => { // предпросмотр: какой блок будет выделен
const len = $("#dlg-form [name=length]")?.value, box = $("#sn-preview"), go = $("#modal-root [data-action=submit-form]"), n = ++seq;
if (!len || !box) return;
let text, ok = false;
try {
const r = await api(`/prefixes/${parent.id}/subnets/next`, { params: { length: len } });
ok = !!r.prefix; text = ok ? `Будет создан: <b class="mono">${esc(r.prefix)}</b>` : `В ${esc(parent.prefix)} нет свободного блока /${esc(len)}`;
} catch (e) { text = esc(e.message); }
if (n !== seq || !$("#sn-preview")) return; // устаревший ответ
$("#sn-preview").innerHTML = text; go.disabled = !ok;
};
openDialog({
title: `Новый вложенный префикс в ${parent.prefix}`, width: 560,
note: "Система выберет первый свободный блок выбранного размера внутри родительского префикса и создаст в нём вложенный.",
body: formBody(`${fSelect("length", "Размер", sizes, def)}
<div class="info" id="sn-preview">Подбираем блок…</div>
${fInput("description", "Описание", { value: "", optional: true })}
${fSelect("status", "Статус", Object.entries(PREFIX_STATUS).map(([value, [, label]]) => ({ value, label })), "active")}
${fArea("note", "Примечание", { value: "" })}
<label class="check"><input type="checkbox" name="is_pool"><span>Пул для автоназначения<small>Адреса из этого префикса будут выдаваться автоматически</small></span></label>`),
foot: dlgFoot("Создать префикс"),
});
S.subnetRefresh();
}
async function vrfsDialog() {
const org = currentOrg();
const { items } = await api("/vrfs", { params: { organization_id: org.id } });
S.vrfs = items;
const cols = "1fr 140px 90px 84px";
const rows = items.map((v) => {
const editing = S.vrfEdit === v.id;
const acts = editing
? `${iconBtn(I.edit(14), "vrf-save", "Сохранить", `data-id="${v.id}"`, true)}${iconBtn(I.close(), "vrf-cancel", "Отмена", "", true)}`
: `${iconBtn(I.edit(14), "vrf-edit", "Переименовать", `data-id="${v.id}"`, true)}${iconBtn(I.trash(14), "vrf-del", v.prefixes_count ? "Нельзя удалить: используется префиксами" : "Удалить", `data-id="${v.id}"`, true, v.prefixes_count > 0)}`;
return `<div class="tr" style="--cols:${cols};--h:48px">${editing ? `<input class="input" id="edit-name" value="${esc(v.name)}" style="height:32px">` : `<span style="font-weight:600">${esc(v.name)}</span>`}
${editing ? `<input class="input mono" id="edit-rt" value="${esc(v.route_target)}" style="height:32px;font-size:13px">` : `<span class="mono muted" style="font-size:13px">${esc(v.route_target)}</span>`}<span class="muted">${v.prefixes_count}</span><span style="display:flex;justify-content:flex-end;gap:4px">${acts}</span></div>`;
}).join("");
openDialog({
title: "Управление VRF", width: 680,
note: `VRF (Virtual Routing and Forwarding) — изолированная таблица маршрутизации. Каждый префикс закрепляется за одним VRF; здесь — общий список для организации «${esc(org.name)}».`,
body: `<div class="mini-table"><div class="tr th" style="--cols:${cols}"><span>Название</span><span>Route target</span><span>Префиксов</span><span></span></div>${rows}</div>
<div class="add-row"><input class="input" id="new-vrf" placeholder="Название нового VRF"><input class="input mono" id="new-rt" placeholder="Route target (необязательно)" style="width:180px;flex:none;font-size:13px"><button type="button" class="btn outline-primary" style="flex:none" data-action="vrf-add">${I.plus()}Добавить</button></div>`,
foot: `<div class="dialog-foot">${btn("Готово", "vrf-done", { cls: "primary" })}</div>`,
});
}
// ---- addresses of one prefix
screens.address = async (id) => {
const p = await api(`/prefixes/${id}`);
if (store.orgId !== p.organization_id) store.orgId = p.organization_id;
const limit = S.limit || 100;
const [page, plist, devs, vrfs] = await Promise.all([
api(`/prefixes/${id}/addresses`, { params: { status: S.astatus, q: S.q, limit } }),
loadPrefixes(p.organization_id),
api("/devices", { params: { organization_id: p.organization_id, limit: 500 } }),
api("/vrfs", { params: { organization_id: p.organization_id } }), // нужен окну «Редактировать префикс»
]);
S.prefix = p; S.prefixes = plist.items; S.devices = devs.items; S.vrfs = vrfs.items; S.page = page; S.rows = page.items;
const byId = Object.fromEntries(plist.items.map((x) => [x.id, x]));
const chain = [];
for (let x = byId[p.parent_id]; x; x = byId[x.parent_id]) chain.unshift(x);
const sm = page.summary;
const cols = selCols("minmax(140px,1fr) 1.2fr 1.6fr 120px 160px 44px");
setSelectable(page.items.filter((a) => a.id != null).map((a) => a.id)); // у «Свободен» нет записи
const rows = page.items.map((a, n) => {
const [c, t] = ADDR_STATUS[a.status];
const free = a.status === "free";
const key = "adr-row-" + (a.id ?? a.address);
const items = free ? [{ label: "Назначить адрес", value: "assign:" + a.address }] : [{ label: "Редактировать", value: "edit:" + a.id }, { label: "Удалить", value: "del:" + a.id, cls: "danger" }];
return `<div class="tr hoverable clickable${free ? " free" : ""}${n === page.items.length - 1 ? " last" : ""}${free ? "" : selRow(a.id)}" data-row="1" data-action="${free ? "adr-assign" : "adr-edit"}" data-id="${a.id ?? ""}" data-ip="${esc(a.address)}" style="--cols:${cols};--h:52px">${free ? selBlank() : selCell(a.id)}<span class="m13 ${free ? "" : ""}" style="color:${free ? "var(--faint)" : "var(--text)"}">${esc(a.address)}</span>
<span class="mono ${free ? "" : "muted"}" style="font-size:13px;font-weight:400">${esc(a.dns_name || "—")}</span><span class="muted">${esc(a.description || (free ? "—" : ""))}</span><span>${badge(c, t)}</span>
<span style="font-size:13px" class="muted">${a.updated_at ? fmtDate(a.updated_at) : "—"}</span><span class="cell-actions rel">${iconBtn(I.dots(), "menu", "Действия", `data-menu="${key}"`)}${popMenu(key, items, "row-pop")}</span></div>`;
}).join("");
const pct = (n) => (sm.capacity ? Math.min((n / sm.capacity) * 100, 100) : 0);
const shown = page.items.length;
const more = page.total > shown ? ` · <a href="#" data-action="more">Показать ещё 100</a>` : "";
const stItems = [{ label: "любой", value: "" }, { label: "Назначен", value: "assigned" }, { label: "Резерв", value: "reserved" }, { label: "Устаревший", value: "deprecated" }, { label: "Свободен", value: "free" }];
return shell("prefixes", `<div class="crumbs" style="font-weight:400;margin-top:16px;padding-top:0"><a href="#/prefixes">Префиксы</a>${chain.map((x) => `${I.chevR(14)}<span>${esc(x.prefix)}</span>`).join("")}${I.chevR(14)}<span style="color:var(--text);font-weight:500">${esc(p.prefix)}</span></div>
<div class="head" style="margin:12px 0 16px"><div><h1 class="mono" style="font-family:var(--sans)">${esc(p.prefix)}</h1><div class="sub">${esc(p.description)}${p.description ? " · " : ""}${sm.assigned} назначено · ${sm.free} свободно · VRF ${esc(p.vrf_name)}</div></div>
<div class="actions">${btn("Редактировать", "pfx-edit", { icon: I.edit(16) })}${btn("Назначить адрес", "adr-new", { cls: "primary", icon: I.plus() })}</div></div>
<div class="card usage"><div style="flex:1"><div class="track"><div style="width:${pct(sm.assigned)}%;background:#2450c8"></div><div style="width:${pct(sm.reserved)}%;background:#d97706"></div></div></div>
<div class="legend"><span><i style="background:#2450c8"></i>Назначено ${sm.assigned}</span><span><i style="background:#d97706"></i>Зарезервировано ${sm.reserved}</span><span><i style="background:#dde3ea"></i>Свободно ${sm.free}</span></div></div>
<div class="card"><div class="filters">${searchBox(S.q || "", "Поиск: адрес, DNS, описание", 260)}${filterBtn("astatus", "Статус", stItems, S.astatus ?? "", 190)}</div>
${bulkBar(bulkMenu("bulk-status", "Статус", ["assigned", "reserved", "deprecated"].map((value) => ({ label: ADDR_STATUS[value][1], value }))))}<div class="tr th" style="--cols:${cols};--h:40px">${selAllCell()}<span>IP-адрес</span><span>DNS-имя</span><span>Описание</span><span>Статус</span><span>Изменён (UTC)</span><span></span></div>
${rows || '<div class="empty">Адресов нет</div>'}<div class="foot">Показано ${shown} из ${page.total} ${plural(page.total, "адреса", "адресов", "адресов")}${more}</div></div>`);
};
function addressDialog(a, presetIp = "") {
const edit = !!a;
const p = S.prefix;
S.dialog = async (v) => {
const body = { status: v.status, dns_name: v.dns_name, description: v.description, device_id: v.device_id ? Number(v.device_id) : null, note: v.note };
if (edit) await api(`/addresses/${a.id}`, { method: "PATCH", body });
else await api(`/prefixes/${p.id}/addresses`, { method: "POST", body: { ...body, address: v.address } });
toast(edit ? "Адрес сохранён" : "Адрес назначен");
await draw();
};
const ipField = edit
? `<label class="field"><span>IP-адрес</span><input class="input mono" value="${esc(a.address)}" disabled></label>`
: fInput("address", "IP-адрес", { mono: true, value: presetIp || (S.page.items.find((x) => x.status === "free")?.address ?? "") });
openDialog({
title: edit ? "Редактировать адрес" : "Назначить адрес",
body: formBody(`<div class="info">Подсеть: <b>${esc(p.prefix)}</b> · ${S.page.summary.free} ${plural(S.page.summary.free, "свободный адрес", "свободных адреса", "свободных адресов")}</div>
<div class="grid2">${ipField}${fSelect("status", "Статус", [["assigned", "Назначен"], ["reserved", "Резерв"], ["deprecated", "Устаревший"]].map(([value, label]) => ({ value, label })), a?.status ?? "assigned")}</div>
${fInput("dns_name", "DNS-имя", { value: a?.dns_name, mono: true, ph: "host.example.com", optional: true }).replace("необязательно", "необязательно, FQDN")}
${fInput("description", "Описание", { value: a?.description })}
<div class="field">${fSelect("device_id", "Связано с устройством", S.devices.map((d) => ({ value: d.id, label: d.name })), a?.device_id ?? "", { optional: true, empty: "Выберите устройство…" }).replace('<label class="field">', "<label class=\"field\" style=\"gap:6px\">")}<a href="#/devices" style="align-self:flex-end;margin-top:-4px;font:500 12px var(--sans)">Управление устройствами →</a></div>
${fArea("note", "Примечание", { value: a?.note })}`),
foot: dlgFoot(edit ? "Сохранить" : "Назначить адрес"),
});
}
// ---- journal (audit)
const ENTITY_RU = { organization: "Организация", vrf: "VRF", prefix: "Префикс", address: "Адрес", device: "Устройство", device_type: "Тип устройства", isp: "Оператор", user: "Пользователь", session: "Сессия", journal: "Журнал" };
const eventBadge = (ev) => {
const [entity, action] = ev.split(".");
const cls = { created: "blue", assigned: "green", updated: "amber", password_reset: "amber", deleted: "red", failed: "red", locked: "red", clear_failed: "red", clear_locked: "red", delete_blocked: "amber" }[action] || "";
return badge(cls, ev);
};
const fmtDateSec = (iso) => (iso ? iso.replace("T", " ").slice(0, 19) : "—");
screens.journal = async () => {
const limit = S.limit || 100;
const [page, sum, facets] = await Promise.all([
api("/audit", { params: { event_type: S.jtype, actor: S.jactor, entity_type: S.jentity, date_from: S.jfrom, date_to: S.jto, q: S.q, limit } }),
api("/audit/summary"),
api("/audit/facets"),
]);
S.entries = page.items; S.summary = sum;
const any = (l) => ({ label: l, value: "" });
const typeItems = [any("любой"), ...facets.event_types.map((v) => ({ label: v, value: v }))];
const actorItems = [any("любой"), ...facets.actors.map((v) => ({ label: v, value: v }))];
const entItems = [any("любая"), ...facets.entity_types.map((v) => ({ label: ENTITY_RU[v] || v, value: v }))];
const cols = "150px 168px 150px 1fr 110px 130px 96px";
const rows = page.items.map((r) => `<div class="tr hoverable clickable" style="--cols:${cols};--h:48px" data-action="jr-open" data-uid="${r.uid}" role="button" tabindex="0">
<span class="muted" style="font-size:13px;white-space:nowrap">${fmtDateSec(r.ts)}</span><span>${eventBadge(r.event_type)}</span>
<span style="display:flex;gap:8px;align-items:baseline;min-width:0"><span>${esc(ENTITY_RU[r.entity_type] || r.entity_type)}</span>${["session", "journal"].includes(r.entity_type) ? "" : `<span class="mono muted ell" style="font-size:13px" title="${esc(r.entity_label)}">${esc(r.entity_label)}</span>`}</span>
<span class="ell" title="${esc(r.message)}">${esc(r.message)}</span><span class="muted" style="font-size:13px">${esc(r.actor)}</span><span class="mono muted" style="font-size:13px">${esc(r.client_ip || "—")}</span><span class="mono muted" style="font-size:13px">${r.short_id}</span></div>`).join("");
const n = sum.total;
const rot = `ротация: ${sum.retention_days ? sum.retention_days + " " + plural(sum.retention_days, "день", "дня", "дней") : "без ограничения по сроку"} / ${sum.max_entries ? fmtNum(sum.max_entries) + " записей" : "без ограничения по количеству"}`;
const act = btn("Обновить", "jr-refresh", { icon: I.refresh() }) + (isAdmin() ? btn("Настройки", "jr-settings", { icon: I.cog() }) + btn("Очистить журнал", "jr-clear", { cls: "danger", icon: I.trashJ() }) : "");
const dateBox = (id, label, v) => `<label class="datebox"><span class="muted">${label}</span><input type="date" id="${id}" data-input="${id}" value="${esc(v || "")}" aria-label="${label === "с" ? "Период с" : "Период по"}"><span class="cal">${I.calendar()}</span></label>`;
return shell("journal", `${header("Журнал", `${fmtNum(n)} ${plural(n, "запись", "записи", "записей")} · старейшая ${sum.oldest_ts ? fmtDate(sum.oldest_ts) : "—"} · ${rot}`, act)}
<div class="card"><div class="filters">${filterBtn("jtype", "Тип", typeItems, S.jtype ?? "", 170)}${filterBtn("jactor", "Актор", actorItems, S.jactor ?? "", 170)}${filterBtn("jentity", "Сущность", entItems, S.jentity ?? "", 200)}${dateBox("jfrom", "с", S.jfrom)}${dateBox("jto", "по", S.jto)}${searchBox(S.q || "", "Сообщение, ID или IP", 240)}</div>
<div class="tr th" style="--cols:${cols};--h:40px"><span>Время (UTC)</span><span>Тип</span><span>Сущность</span><span>Сообщение</span><span>Актор</span><span>IP-адрес</span><span>ID записи</span></div>
${rows || '<div class="empty">Записей нет</div>'}
${page.total > page.items.length ? `<div style="display:flex;justify-content:center;align-items:center;height:64px;border-bottom:1px solid var(--line)">${btn("Показать ещё 100", "more")}</div>` : ""}
<div class="foot">Показано ${page.items.length} из ${fmtNum(page.total)} ${plural(page.total, "записи", "записей", "записей")}</div></div>`);
};
async function copyText(text) {
try { await navigator.clipboard.writeText(text); } catch {
const ta = document.createElement("textarea"); // по http navigator.clipboard недоступен
ta.value = text; ta.style.position = "fixed"; ta.style.opacity = "0"; document.body.append(ta); ta.select();
try { document.execCommand("copy"); } finally { ta.remove(); }
}
toast("Скопировано");
}
const copyBtn = (text) => iconBtn(I.copy(), "copy", "Копировать", `data-text="${esc(text)}"`);
function entryDialog(r) {
const row = (label, inner) => `<div class="kv"><span class="muted" style="font-size:13px">${label}</span><div style="display:flex;align-items:center;gap:8px;min-width:0">${inner}</div></div>`;
const mono = (v) => `<span class="mono" style="font-size:13px;overflow-wrap:anywhere">${esc(v)}</span>`;
const entity = ["session", "journal"].includes(r.entity_type) ? esc(ENTITY_RU[r.entity_type]) : `${esc(ENTITY_RU[r.entity_type] || r.entity_type)} ·&nbsp;${mono(r.entity_label)}`;
openDialog({
title: "Запись журнала", width: 640,
body: `<div class="dialog-body">${row("ID записи", mono("evt_" + r.uid) + copyBtn("evt_" + r.uid))}
${row("Время", fmtDateSec(r.ts) + " UTC")}${row("Тип", eventBadge(r.event_type))}${row("Актор", esc(r.actor))}
${row("IP-адрес", r.client_ip ? mono(r.client_ip) + copyBtn(r.client_ip) : '<span class="muted">—</span>')}${row("Сущность", entity)}${row("Сообщение", esc(r.message))}
${r.meta?.method ? row("Запрос", mono(`${r.meta.method} ${r.meta.path}`)) : ""}${r.meta?.user_agent ? row("User-Agent", `<span class="muted" style="font-size:13px;overflow-wrap:anywhere">${esc(r.meta.user_agent)}</span>`) : ""}
<div style="font:500 13px/1.3 var(--sans);margin-top:4px">Данные</div><pre class="json">${r.diff ? esc(JSON.stringify(r.diff, null, 2)) : "—"}</pre></div>`,
foot: `<div class="dialog-foot">${btn("Закрыть", "close-dialog")}</div>`,
});
}
function journalSettingsDialog() {
const sm = S.summary;
const n = sm.total;
S.dialog = async (v) => {
const res = await api("/journal/settings", { method: "PUT", body: { retention_days: Number(v.retention_days), max_entries: Number(v.max_entries) } });
toast(res.deleted ? `Настройки сохранены, удалено записей: ${res.deleted}` : "Настройки сохранены");
S.limit = 100; await draw();
};
openDialog({
title: "Настройки журнала",
body: formBody(`<div class="statbox">Сейчас в журнале: <b>${fmtNum(n)} ${plural(n, "запись", "записи", "записей")}</b> · старейшая ${sm.oldest_ts ? fmtDate(sm.oldest_ts) : "—"}</div>
${fInput("retention_days", "Хранить записи, дней", { value: sm.retention_days, type: "number", hint: "0 — без ограничения по сроку" }).replace("</span><input", `${opt("0 — без ограничения по сроку")}</span><input min="0" max="3650" step="1"`)}
${fInput("max_entries", "Максимум записей", { value: sm.max_entries, type: "number" }).replace("</span><input", `${opt("0 — без ограничения по количеству")}</span><input min="0" max="10000000" step="1"`)}
<div class="info">Ротация запускается раз в час и при сохранении настроек: удаляет самые старые записи сверх срока и лимита. Каждая ротация фиксируется записью в журнале.</div>`),
foot: dlgFoot("Сохранить"),
});
}
function journalClearDialog(state = {}) {
const n = S.summary.total;
const locked = !!state.retry;
const minutes = state.retry ? Math.max(1, Math.ceil(state.retry / 60)) : 0;
openDialog({
title: "Очистить журнал", width: 540,
body: `<div class="dialog-body" id="clear-form"><div class="warn">Будет удалено <b>${fmtNum(n)} ${plural(n, "запись", "записи", "записей")}</b> без возможности восстановления. В журнале останется одна запись об очистке: кто и когда её выполнил.</div>
<label class="field"><span>Пароль пользователя <b style="font-weight:600">${esc(user.username)}</b>${opt("подтверждение нужно только для этого действия")}</span>
<input class="input${state.wrong ? " bad" : ""}" type="password" id="clear-pw" autocomplete="current-password" placeholder="${locked ? "" : "Введите пароль"}" ${locked ? "disabled" : ""} value=""></label>
${state.wrong ? `<div style="font:500 13px/1.4 var(--sans);color:#a32020">Неверный пароль. Осталось попыток: ${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 (!isAdmin()) return shell("users", `${header("Пользователи", "Учётные записи UI")}<div class="card" style="margin-top:24px"><div class="empty">Раздел доступен только администратору</div></div>`);
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 190px minmax(140px,1fr) 44px");
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" : "" },
{ label: "Удалить", value: "del:" + u.id, cls: "danger" },
];
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 === "admin" ? "blue" : "", ROLE_RU[u.role] || u.role)}</span>
<span>${u.is_active ? badge("green", "Доступ разрешён") : badge("red", "Отключён")}</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></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;
S.dialog = async (v) => {
if (edit) {
const body = { is_active: v.is_active === true };
if (v.role) body.role = v.role;
if (v.password) body.password = v.password;
await api(`/users/${u.id}`, { method: "PATCH", body });
} else {
await api("/users", { method: "POST", body: { username: v.username, password: v.password, role: v.role, is_active: v.is_active !== false } });
}
toast(edit ? "Пользователь сохранён" : "Пользователь добавлен");
await draw();
};
openDialog({
title: edit ? `Пользователь ${u.username}` : "Новый пользователь",
note: "«Администратор» может изменять данные, «Просмотр» — только читать их. Логин после создания не меняется.",
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: "admin", label: "Администратор" }, { value: "viewer", label: "Просмотр" }], u?.role ?? "viewer")}
<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("Сохранить пароль"),
});
}
// ---- login
function loginScreen(err = "") {
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 === "del" && confirmDel(`пользователя ${u.username}`)) await guarded(async () => { await api(`/users/${id}`, { method: "DELETE" }); toast("Пользователь удалён"); await draw(); });
},
};
const actions = {
logout: () => { store.token = null; user = null; current = ""; location.hash = "#/login"; draw(); },
menu: (d, el) => { menu = menu?.id === d.menu ? null : { id: d.menu }; draw(); },
pick: async (d) => {
const { menu: id, value } = d;
menu = null;
if (id === "org") { store.orgId = Number(value); S.vrf = 0; S.collapsed = null; }
else if (id === "status") S.status = value;
else if (id === "family") S.family = value;
else if (id === "type") S.type = value;
else if (id === "astatus") { S.astatus = value; S.limit = 100; }
else if (id === "jtype") { S.jtype = value; S.limit = 100; }
else if (id === "jactor") { S.jactor = value; S.limit = 100; }
else if (id === "jentity") { S.jentity = value; S.limit = 100; }
else if (id === "bulk-status" || id === "bulk-type") return bulkPick(id, value);
else {
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 }));
},
sel: (d, el) => { el.checked ? selSet().add(Number(d.id)) : selSet().delete(Number(d.id)); draw(); },
"sel-all": (d, el) => { S.sel = new Set(el.checked ? S.selectable : []); draw(); },
"sel-clear": () => { S.sel = new Set(); draw(); },
"bulk-del": () => {
const c = BULK[screenKey()], ids = [...selSet()];
if (ids.length && confirm(`Удалить выбранное: ${ids.length} ${plural(ids.length, ...c.forms)}?`)) return bulk("Удаление", ids, c.label, (id) => api(c.url(id), { method: "DELETE" }), c.after);
},
"bulk-access": (d) => bulk(d.value === "1" ? "Разрешение доступа" : "Отключение доступа", [...selSet()], BULK.users.label,
(id) => api(`/users/${id}`, { method: "PATCH", body: { is_active: d.value === "1" } })),
"pfx-open": (d) => { location.hash = `#/prefixes/${d.id}`; },
"org-open": (d) => { store.orgId = Number(d.id); location.hash = "#/prefixes"; },
"isp-edit": (d) => ispDialog(rowById(d.id)),
"dev-edit": (d) => deviceDialog(rowById(d.id)),
"user-edit": (d) => userDialog(rowById(d.id)),
"adr-edit": (d) => addressDialog(S.page.items.find((x) => x.id === Number(d.id))),
"adr-assign": (d) => addressDialog(null, d.ip),
"jr-open": (d) => entryDialog(S.entries.find((r) => r.uid === d.uid)),
"jr-refresh": () => draw(),
"jr-settings": () => journalSettingsDialog(),
"jr-clear": () => journalClearDialog(),
"jr-clear-do": doClear,
copy: (d) => copyText(d.text),
"close-dialog": closeDialog,
overlay: (d, el, e) => { if (e.target === el) closeDialog(); },
"submit-form": submitDialog,
"open-org": (d) => { store.orgId = Number(d.id); },
"org-new": () => orgDialog(null),
"users-new": () => userDialog(null),
"pw-change": () => passwordDialog(),
"isp-new": () => ispDialog(null),
"dev-new": () => deviceDialog(null),
"pfx-new": () => prefixDialog(null),
"pfx-edit": () => prefixDialog(S.prefix),
"adr-new": () => addressDialog(null),
types: () => typesDialog(),
vrfs: () => vrfsDialog(),
"vrf-done": async () => { closeDialog(); S.vrfEdit = null; await draw(); },
"vrf-add": () => guarded(async () => {
const name = $("#new-vrf").value.trim();
if (!name) return toast("Введите название VRF", true);
await api("/vrfs", { method: "POST", body: { organization_id: store.orgId, name, route_target: $("#new-rt").value.trim() } });
await vrfsDialog();
}),
"vrf-edit": (d) => { S.vrfEdit = Number(d.id); vrfsDialog(); },
"vrf-cancel": () => { S.vrfEdit = null; vrfsDialog(); },
"vrf-save": (d) => guarded(async () => {
await api(`/vrfs/${d.id}`, { method: "PATCH", body: { name: $("#edit-name").value.trim(), route_target: $("#edit-rt").value.trim() } });
S.vrfEdit = null; await vrfsDialog();
}),
"vrf-del": (d) => confirmDel("VRF") && guarded(async () => { await api(`/vrfs/${d.id}`, { method: "DELETE" }); await vrfsDialog(); }),
"type-add": () => guarded(async () => {
const name = $("#new-type").value.trim();
if (!name) return toast("Введите название типа", true);
await api("/device-types", { method: "POST", body: { name } });
await typesDialog();
}),
"type-edit": (d) => { S.typeEdit = Number(d.id); typesDialog(); },
"type-cancel": () => { S.typeEdit = null; typesDialog(); },
"type-save": (d) => guarded(async () => {
await api(`/device-types/${d.id}`, { method: "PATCH", body: { name: $("#edit-name").value.trim() } });
S.typeEdit = null; await typesDialog();
}),
"type-del": (d) => confirmDel("тип") && guarded(async () => { await api(`/device-types/${d.id}`, { method: "DELETE" }); await typesDialog(); }),
};
const closeDropdowns = () => document.querySelectorAll(".dd:not([hidden])").forEach((p) => { p.hidden = true; });
document.addEventListener("click", (e) => {
if (!e.target.closest(".cselect")) closeDropdowns();
const el = e.target.closest("[data-action]");
if (!el) {
if (menu) { menu = null; draw(); }
return;
}
const a = el.dataset.action;
if (el.dataset.row) {
// действие всей строки: не мешаем ссылкам, кнопкам, меню, Ctrl/Shift-клику и выделению текста
if (e.target.closest("a[href], button, input, label, select, textarea, .pop")) return;
if (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey || String(window.getSelection?.() ?? "")) return;
if (menu) { menu = null; draw(); return; } // открытое меню: клик лишь закрывает его
}
if (menu && a !== "menu" && a !== "pick") { menu = null; if (!actions[a]) draw(); }
if (el.tagName === "A" && el.getAttribute("href") === "#") e.preventDefault();
if (actions[a]) actions[a](el.dataset, el, e);
});
let timer;
document.addEventListener("input", (e) => {
if (e.target.id === "clear-pw") { $("#clear-go").disabled = !e.target.value; return; }
if (e.target.dataset.input !== "q") return;
clearTimeout(timer);
timer = setTimeout(() => { S.q = e.target.value; S.limit = 100; draw(); }, 250);
});
document.addEventListener("change", (e) => {
if (e.target.id === "jfrom" || e.target.id === "jto") { S[e.target.id] = e.target.value; S.limit = 100; draw(); return; }
if (e.target.name === "length" && S.subnetRefresh) { S.subnetRefresh(); return; }
const hint = $("#vrf-hint");
if (hint && e.target.name === "vrf_id") hint.hidden = e.target.value === S.vrfHint?.original;
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && document.querySelector(".dd:not([hidden])")) { closeDropdowns(); e.stopPropagation(); return; }
if ((e.key === "ArrowDown" || e.key === "ArrowUp") && e.target.closest(".cselect")) {
e.preventDefault();
const box = e.target.closest(".cselect"), pop = box.querySelector(".dd");
if (pop.hidden) { closeDropdowns(); pop.hidden = false; (pop.querySelector(".sel") || pop.querySelector("button")).focus(); return; }
const items = [...pop.querySelectorAll("button")], i = items.indexOf(document.activeElement);
items[Math.max(0, Math.min(items.length - 1, i + (e.key === "ArrowDown" ? 1 : -1)))].focus();
return;
}
if (e.key === "Escape") { if ($("#modal-root").innerHTML) closeDialog(); else if (menu) { menu = null; draw(); } }
if (e.key === "Enter" && e.target.id === "clear-pw") { e.preventDefault(); doClear(); return; }
if (e.key === "Enter" && e.target.dataset?.action === "jr-open") { e.preventDefault(); actions["jr-open"](e.target.dataset); return; }
if (e.key === "Enter" && e.target.closest("#dlg-form") && e.target.tagName !== "TEXTAREA") { e.preventDefault(); submitDialog(); }
});
document.addEventListener("submit", async (e) => {
if (e.target.id !== "login-form") return;
e.preventDefault();
const f = new FormData(e.target);
try {
const { access_token } = await api("/auth/login", { method: "POST", body: { username: f.get("username"), password: f.get("password") } });
store.token = access_token;
user = null; current = "";
location.hash = "#/overview";
draw();
} catch (err) {
$("#app").innerHTML = loginScreen(err.message);
}
});
draw();