Files
ipam_control/web/app.js
T

1089 lines
92 KiB
JavaScript
Raw Normal View History

// 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 !== "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: логин в шапке — триггер выпадающего меню («Сменить пароль» / «Выйти») вместо отдельных кнопок
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: "logout" }], "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>${userMenu}</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) => {
const items2 = [{ label: "Открыть префиксы", value: "open:" + o.id }, { label: "Редактировать", value: "edit:" + o.id }]; // изменение 032
if (isSuperadmin()) items2.push({ label: "Удалить", value: "del:" + o.id, cls: "danger" }); // изменение 032: кнопка удаления только для superadmin
return `<div class="tr hoverable clickable${i === items.length - 1 ? " last" : ""}${selRow(o.id)}" data-row="1" data-action="org-open" data-id="${o.id}" style="--cols:${cols}">${selCell(o.id)}
<a href="#/prefixes" data-action="open-org" data-id="${o.id}" style="font-weight:500;color:var(--text)">${esc(o.name)}</a><span class="muted">${esc(o.short_name)}</span><span class="mono muted" style="font-size:13px">${esc(o.inn)}</span>
<span class="muted ell">${esc(o.address)}</span><a href="#/prefixes" data-action="open-org" data-id="${o.id}" style="font:500 13px var(--sans);text-align:center">${o.prefixes_count}</a><span style="font-size:13px;text-align:center" class="muted">${fmtNum(o.addresses_count)}</span>
<span class="cell-actions rel">${iconBtn(I.dots(), "menu", "Действия", `data-menu="org-row-${o.id}"`)}${popMenu("org-row-" + o.id, items2, "row-pop")}</span></div>`; // изменение 032
}).join("");
S.rows = items;
const addBtn = isSuperadmin() ? btn("Добавить организацию", "org-new", { cls: "primary", icon: I.plus() }) : ""; // изменение 032: кнопка только для superadmin
return shell("orgs", `${header("Организации", `${all} ${plural(all, "организация", "организации", "организаций")}`, addBtn)}
<div class="card"><div class="filters">${searchBox(q, "Поиск: название, ИНН, адрес", 300)}</div>
${bulkBar()}<div class="tr th" style="--cols:${cols}">${selAllCell()}<span>Название</span><span>Краткое имя</span><span>ИНН</span><span>Адрес</span><span>Префиксов</span><span>Адресов</span><span></span></div>
${rows || '<div class="empty">Ничего не найдено</div>'}<div class="foot">Показано ${items.length} из ${all} ${plural(all, "организации", "организаций", "организаций")}</div></div>`);
};
function orgDialog(o) {
const edit = !!o;
S.dialog = async (v) => {
await api(edit ? `/organizations/${o.id}` : "/organizations", { method: edit ? "PATCH" : "POST", body: v });
toast(edit ? "Организация сохранена" : "Организация добавлена");
await loadOrgs();
await draw();
};
openDialog({
title: edit ? "Редактирование организации" : "Новая организация",
body: formBody(`${fInput("name", "Название", { value: o?.name })}
<div class="grid2">${fInput("short_name", "Краткое имя", { value: o?.short_name })}${fInput("inn", "ИНН", { value: o?.inn })}</div>
${fInput("address", "Юридический адрес", { value: o?.address })}
<div class="grid2">${fInput("contact_person", "Контактное лицо", { value: o?.contact_person, optional: true })}${fInput("phone", "Телефон", { value: o?.phone, optional: true })}</div>
${fInput("email", "Email", { value: o?.email, optional: true, ph: "email@example.com" })}${fArea("note", "Примечание", { value: o?.note })}`),
foot: dlgFoot(edit ? "Сохранить" : "Добавить организацию"),
});
}
// ---- ISPs
screens.isps = async () => {
const q = S.q || "";
const { items } = await api("/isps", { params: { q, limit: 500 } });
const all = q ? (await api("/isps", { params: { limit: 1 } })).total : items.length;
S.rows = items;
const cols = selCols("minmax(130px,1fr) minmax(160px,1.2fr) minmax(180px,1.4fr) 150px 140px minmax(120px,1fr) 44px");
setSelectable(items.map((i) => i.id));
const rows = items.map((i, n) => `<div class="tr hoverable clickable${n === items.length - 1 ? " last" : ""}${selRow(i.id)}" data-row="1" data-action="isp-edit" data-id="${i.id}" style="--cols:${cols}">${selCell(i.id)}<span style="font-weight:500">${esc(i.name)}</span>
<a href="#/prefixes" data-action="open-org" data-id="${i.organization_id}" style="font-size:13px">${esc(i.organization_name)}</a>
<span style="display:flex;align-items:center"><span class="mono" style="font-size:13px">${esc(i.networks[0] ?? "—")}</span>${i.networks.length > 1 ? `<span class="more">+${i.networks.length - 1}</span>` : ""}</span>
<span class="mono muted" style="font-size:13px">${esc(i.hotline)}</span><span class="muted">${esc(i.contract_number)}</span><span class="muted ell">${esc(i.note)}</span>
<span class="cell-actions rel">${iconBtn(I.dots(), "menu", "Действия", `data-menu="isp-row-${i.id}"`)}${popMenu("isp-row-" + i.id, [{ label: "Редактировать", value: "edit:" + i.id }, { label: "Удалить", value: "del:" + i.id, cls: "danger" }], "row-pop")}</span></div>`).join("");
return shell("isps", `${header("Операторы связи", "Реестр провайдеров и каналов", btn("Добавить оператора", "isp-new", { cls: "primary", icon: I.plus() }))}
<div class="card"><div class="filters">${searchBox(q, "Поиск: оператор, организация, IP", 300)}</div>
${bulkBar()}<div class="tr th" style="--cols:${cols}">${selAllCell()}<span>Название</span><span>Организация</span><span>IP-адреса</span><span>Горячая линия</span><span>Договор</span><span>Заметки</span><span></span></div>
${rows || '<div class="empty">Ничего не найдено</div>'}<div class="foot">Показано ${items.length} из ${all} ${plural(all, "оператора", "операторов", "операторов")}</div></div>`);
};
function ispDialog(i) {
const edit = !!i;
S.dialog = async (v) => {
const body = { ...v, organization_id: Number(v.organization_id), networks: v.networks.split(/\s+/).filter(Boolean) };
await api(edit ? `/isps/${i.id}` : "/isps", { method: edit ? "PUT" : "POST", body });
toast(edit ? "Оператор сохранён" : "Оператор добавлен");
await draw();
};
openDialog({
title: edit ? "Редактирование оператора" : "Новый оператор связи",
body: formBody(`${fInput("name", "Название", { value: i?.name })}
${fSelect("organization_id", "Организация", orgs.map((o) => ({ value: o.id, label: o.name })), i?.organization_id ?? store.orgId)}
${fArea("networks", "IP-адреса", { value: (i?.networks || []).join("\n"), ph: "один CIDR на строку", rows: 3, h: 72, mono: true, optional: false, hint: "можно несколько" })}
<div class="grid2">${fInput("hotline", "Горячая линия", { value: i?.hotline })}${fInput("contract_number", "Номер договора", { value: i?.contract_number })}</div>
${fArea("note", "Примечание", { value: i?.note })}`),
foot: dlgFoot(edit ? "Сохранить" : "Добавить оператора"),
});
}
// ---- devices
screens.devices = async () => {
const org = currentOrg();
const [types, list] = await Promise.all([
api("/device-types"),
api("/devices", { params: { organization_id: org?.id, device_type_id: S.type, q: S.q, limit: 500 } }),
]);
S.types = types.items;
S.rows = list.items;
const cols = selCols("minmax(200px,1.6fr) 170px 110px minmax(220px,1.8fr) 44px");
setSelectable(list.items.map((d) => d.id));
const q = S.q || "";
const typeItems = [{ label: "любой", value: "" }, ...types.items.map((t) => ({ label: t.name, value: t.id }))];
const rows = list.items.map((d, n) => {
const dep = d.all_deprecated;
const ips = d.ip_addresses.length
? `<span style="display:flex;align-items:center"><a href="#/prefixes/${d.first_prefix_id}" style="font:500 13px var(--sans)">${d.ip_addresses.length}</a>${d.ip_addresses.length > 1 ? `<span class="more">+${d.ip_addresses.length - 1}</span>` : ""}</span>`
: `<span class="muted" style="font-size:13px">0</span>`;
return `<div class="tr hoverable clickable${n === list.items.length - 1 ? " last" : ""}${selRow(d.id)}" data-row="1" data-action="dev-edit" data-id="${d.id}" style="--cols:${cols}">${selCell(d.id)}<span class="m13" style="font-weight:600;${dep ? "color:var(--faint);text-decoration:line-through" : ""}">${esc(d.name)}</span>
<span class="${dep ? "" : "muted"}" style="${dep ? "color:var(--faint)" : ""}">${esc(d.device_type_name)}</span>${ips}<span class="ell" style="color:${dep ? "var(--faint)" : "var(--muted)"}">${esc(d.note)}</span>
<span class="cell-actions rel">${iconBtn(I.dots(), "menu", "Действия", `data-menu="dev-row-${d.id}"`)}${popMenu("dev-row-" + d.id, [{ label: "Редактировать", value: "edit:" + d.id }, { label: "Удалить", value: "del:" + d.id, cls: "danger" }], "row-pop")}</span></div>`;
}).join("");
const addBtn = btn("Добавить устройство", "dev-new", { cls: "primary", icon: I.plus() }); // изменение 033: устройство создаёт любой admin своей организации
return shell("devices", `${crumbsOrg()}${header("Устройства", `${list.total} ${plural(list.total, "устройство", "устройства", "устройств")} · организация: ${esc(org?.name ?? "")}`, addBtn)}
<div class="right-link"><a href="#" data-action="types">${I.gear()}Типы устройств</a></div>
<div class="card"><div class="filters">${orgSwitcher()}${searchBox(q, "Поиск: имя, IP, заметка", 260)}${filterBtn("type", "Тип", typeItems, S.type ?? "", 170)}</div>
${bulkBar(bulkMenu("bulk-type", "Сменить тип", types.items.map((t) => ({ label: t.name, value: t.id }))))}<div class="tr th" style="--cols:${cols}">${selAllCell()}<span>Устройство</span><span>Тип</span><span>IP-адресов</span><span>Заметки</span><span></span></div>
${rows || '<div class="empty">Устройств нет</div>'}<div class="foot">Показано ${list.items.length} из ${list.total} ${plural(list.total, "устройства", "устройств", "устройств")}</div></div>`);
};
function deviceDialog(d) {
const edit = !!d;
S.dialog = async (v) => {
const body = edit ? { name: v.name, device_type_id: Number(v.device_type_id), mac: v.mac, note: v.note }
: { ...v, device_type_id: Number(v.device_type_id), organization_id: Number(v.organization_id) };
await api(edit ? `/devices/${d.id}` : "/devices", { method: edit ? "PATCH" : "POST", body });
toast(edit ? "Устройство сохранено" : "Устройство добавлено");
await draw();
};
openDialog({
title: edit ? "Редактирование устройства" : "Новое устройство",
body: formBody(`${fInput("name", "Название (hostname)", { value: d?.name, ph: "host.internal" })}
<div class="grid2">${fSelect("device_type_id", "Тип", S.types.map((t) => ({ value: t.id, label: t.name })), d?.device_type_id)}${fInput("mac", "MAC-адрес", { value: d?.mac, ph: "00:1A:2B:3C:4D:5E", optional: true })}</div>
${edit ? "" : fSelect("organization_id", "Организация", orgs.map((o) => ({ value: o.id, label: o.name })), store.orgId)}
${fArea("note", "Заметки", { value: d?.note })}
<div class="info">IP-адрес назначается отдельно — на экране «Адреса» через «Назначить адрес» и поле «Связано с устройством».</div>`),
foot: dlgFoot(edit ? "Сохранить" : "Добавить устройство"),
});
}
async function typesDialog() {
const { items } = await api("/device-types");
S.types = items;
const cols = "1fr 110px 84px";
const rows = items.map((t) => {
const editing = S.typeEdit === t.id;
const name = editing
? `<input class="input" id="edit-name" value="${esc(t.name)}" style="height:32px">`
: `<span style="font-weight:600;${t.is_default ? "color:var(--faint)" : ""}">${esc(t.name)}</span>`;
const locked = t.devices_count > 0;
let acts;
if (!isSuperadmin()) { // изменение 032: управление только для superadmin
acts = t.is_default ? `<span class="muted" style="font-size:12px">по умолчанию</span>` : "";
} else {
acts = t.is_default
? `<span class="muted" style="font-size:12px">по умолчанию</span>`
: editing
? `${iconBtn(I.edit(14), "type-save", "Сохранить", `data-id="${t.id}"`, true)}${iconBtn(I.close(), "type-cancel", "Отмена", "", true)}`
: `${iconBtn(I.edit(14), "type-edit", "Переименовать", `data-id="${t.id}"`, true)}${iconBtn(I.trash(14), "type-del", locked ? "Нельзя удалить: используется устройствами" : "Удалить", `data-id="${t.id}"`, true, locked)}`;
}
return `<div class="tr" style="--cols:${cols};--h:48px">${name}<span class="muted">${t.devices_count}</span><span style="display:flex;justify-content:flex-end;align-items:center;gap:4px">${acts}</span></div>`;
}).join("");
openDialog({
title: "Управление типами устройств", width: 640,
note: "Типы используются в фильтре и в поле «Тип» при добавлении устройства. Общий список для всех организаций.", // изменение 032
body: `<div class="mini-table"><div class="tr th" style="--cols:${cols}"><span>Название</span><span>Устройств</span><span></span></div>${rows}</div>
${isSuperadmin() ? `<div class="add-row"><input class="input" id="new-type" placeholder="Название нового типа"><button type="button" class="btn outline-primary" data-action="type-add">${I.plus()}Добавить</button></div>` : ""}`, // изменение 032: кнопка только для superadmin
foot: `<div class="dialog-foot">${btn("Готово", "close-dialog", { cls: "primary" })}</div>`,
});
}
// ---- prefixes
const PREFIX_UI_CAP = 20000; // защита от неограниченной подгрузки: организация с частичным разбиением может дать сотни тысяч префиксов
async function loadPrefixes(orgId) {
// грузит все страницы /prefixes (порядок ORDER BY vrf_id, prefix стабилен) до total, но не больше PREFIX_UI_CAP —
// иначе заголовок, вкладка «Все» и родительский список в диалогах молча ограничивались первой страницей (изменение 028, находка №3)
let items = [], total = 0, offset = 0;
for (;;) {
const page = await api("/prefixes", { params: { organization_id: orgId, limit: 1000, offset } });
total = page.total;
items = items.concat(page.items);
offset += page.items.length;
if (page.items.length === 0 || offset >= total || items.length >= PREFIX_UI_CAP) break;
}
return { items, total };
}
screens.prefixes = async () => {
const org = currentOrg();
if (!org) return shell("prefixes", header("Префиксы", "Сначала добавьте организацию") + `<div class="card"><div class="empty">Нет организаций. ${btn("Добавить организацию", "org-new", { cls: "primary" })}</div></div>`);
const [pl, vl] = await Promise.all([
loadPrefixes(org.id),
api("/vrfs", { params: { organization_id: org.id } }),
]);
S.prefixes = pl.items;
S.vrfs = vl.items;
const all = pl.items;
const q = (S.q || "").toLowerCase();
const filtered = all.filter((p) => (!S.vrf || p.vrf_id === S.vrf) && (!S.status || p.status === S.status) && (!S.family || p.family === Number(S.family))
&& (!q || p.prefix.includes(q) || p.description.toLowerCase().includes(q)));
const hasKids = new Set(all.map((p) => p.parent_id).filter(Boolean));
if (!S.collapsed) S.collapsed = new Set(all.filter((p) => !p.depth && hasKids.has(p.id)).slice(1).map((p) => p.id)); // раскрыта только первая ветка
const collapsed = S.collapsed;
const byId = Object.fromEntries(all.map((p) => [p.id, p]));
const hidden = (p) => { for (let x = byId[p.parent_id]; x; x = byId[x.parent_id]) if (collapsed.has(x.id)) return true; return false; };
const visible = filtered.filter((p) => !hidden(p));
const cols = selCols("44px minmax(200px,1.8fr) 1.4fr 100px 100px 200px 80px 44px");
setSelectable(visible.filter((p) => !hasKids.has(p.id)).map((p) => p.id)); // как и меню «⋯»: только листовые
const tab = (id, label, n) => `<button type="button" class="tab${(S.vrf || 0) === id ? " active" : ""}" data-action="vrf-tab" data-id="${id}">${esc(label)} <span class="n">${n}</span></button>`;
const rows = visible.map((p, n) => {
const [bar, txt] = utilColor(p.utilization);
const [sc, st] = PREFIX_STATUS[p.status];
const leaf = !hasKids.has(p.id);
const chev = hasKids.has(p.id) ? `<span class="chev ${collapsed.has(p.id) ? "" : "open"}"><button type="button" data-action="toggle" data-id="${p.id}" aria-label="Свернуть/развернуть" aria-expanded="${!collapsed.has(p.id)}">${I.chevR(16)}</button></span>` : "<span></span>";
const pad = p.depth * 20;
const cidr = leaf ? `<a href="#/prefixes/${p.id}" class="m13" style="padding-left:${pad}px;font-weight:600">${esc(p.prefix)}</a>` : `<span class="m13" style="padding-left:${pad}px;font-weight:600">${esc(p.prefix)}</span>`;
return `<div class="tr hoverable clickable${p.depth ? " child" : ""}${n === visible.length - 1 ? " last" : ""}${leaf ? selRow(p.id) : ""}" data-row="1" data-action="${leaf ? "pfx-open" : "toggle"}" data-id="${p.id}" style="--cols:${cols}">${leaf ? selCell(p.id) : selBlank()}${chev}${cidr}<span class="muted">${esc(p.description)}</span><span class="muted" style="font-size:13px">${esc(p.vrf_name)}</span><span>${badge(sc, st)}</span>
<div class="bar"><div class="track"><div class="fill" style="width:${p.utilization}%;background:${bar}"></div></div><span class="pct" style="color:${txt}">${p.utilization}%</span></div><span class="muted" style="font-size:13px">${fmtNum(p.used)}</span>
<span class="cell-actions rel">${leaf || isAdmin() ? iconBtn(I.dots(), "menu", "Действия", `data-menu="pfx-row-${p.id}"`) + popMenu("pfx-row-" + p.id, [...(leaf ? [{ label: "Открыть адреса", value: "open:" + p.id }, { label: "Редактировать", value: "edit:" + p.id }] : []), ...(isAdmin() ? [{ label: "Добавить вложенный (авто)", value: "subnet:" + p.id }] : []), ...(leaf ? [{ label: "Удалить", value: "del:" + p.id, cls: "danger" }] : [])], "row-pop") : ""}</span></div>`;
}).join("");
const statusItems = [{ label: "любой", value: "" }, { label: "Активен", value: "active" }, { label: "Резерв", value: "reserved" }, { label: "Устарел", value: "deprecated" }];
const famItems = [{ label: "любое", value: "" }, { label: "IPv4", value: "4" }, { label: "IPv6", value: "6" }];
const truncated = pl.total > all.length ? `<div class="info">Загружено ${fmtNum(all.length)} из ${fmtNum(pl.total)} префиксов — уточните поиск или выберите VRF</div>` : "";
return shell("prefixes", `${crumbsOrg()}${header("Префиксы", `${pl.total} ${plural(pl.total, "префикс", "префикса", "префиксов")} · ${vl.total} VRF · организация: ${esc(org.name)}`, btn("Добавить префикс", "pfx-new", { cls: "primary", icon: I.plus() }))}
<div class="tabs">${tab(0, "Все", pl.total)}${vl.items.map((v) => tab(v.id, v.name, v.prefixes_count)).join("")}<div class="grow"></div><button type="button" class="link-muted" data-action="vrfs">${I.gear()}Управление VRF</button></div>
${truncated}
<div class="card"><div class="filters">${orgSwitcher()}${searchBox(S.q || "", "Поиск: префикс, описание", 280)}${filterBtn("status", "Статус", statusItems, S.status ?? "")}${filterBtn("family", "Семейство", famItems, S.family ?? "")}</div>
${bulkBar(bulkMenu("bulk-status", "Статус", Object.entries(PREFIX_STATUS).map(([value, [, label]]) => ({ label, value }))))}<div class="tr th" style="--cols:${cols};--h:40px">${selAllCell()}<span></span><span>Префикс</span><span>Описание</span><span>VRF</span><span>Статус</span><span>Использование</span><span>Адресов</span><span></span></div>
${rows || '<div class="empty">Префиксов нет</div>'}<div class="foot">Показано ${visible.length} из ${pl.total} ${plural(pl.total, "префикса", "префиксов", "префиксов")}</div></div>`);
};
function prefixDialog(p) {
const edit = !!p;
S.dialog = async (v) => {
if (edit) await api(`/prefixes/${p.id}`, { method: "PATCH", body: { vrf_id: Number(v.vrf_id), description: v.description, status: v.status, is_pool: v.is_pool, note: v.note } });
else await api("/prefixes", { method: "POST", body: { ...v, organization_id: store.orgId, vrf_id: Number(v.vrf_id), parent_id: v.parent_id ? Number(v.parent_id) : null } });
toast(edit ? "Префикс сохранён" : "Префикс добавлен");
await draw();
};
const parents = (S.prefixes || []).map((x) => ({ value: x.id, label: x.description ? `${x.prefix} — ${x.description}` : x.prefix }));
// число вложенных префиксов: они переезжают вместе с выбранным при смене VRF
let nested = 0;
if (edit) {
let level = [p.id];
while (level.length) { level = (S.prefixes || []).filter((x) => level.includes(x.parent_id)).map((x) => x.id); nested += level.length; }
S.vrfHint = { original: String(p.vrf_id) };
}
openDialog({
title: edit ? `Префикс ${p.prefix}` : "Новый префикс",
body: formBody(`${edit ? "" : fInput("prefix", "Префикс (CIDR)", { ph: "0.0.0.0/0", mono: true, hint: "например 10.30.0.0/24" }).replace("</span><input", `${opt("например 10.30.0.0/24")}</span><input`)}
${fInput("description", "Описание", { value: p?.description })}
<div class="grid2">${fSelect("vrf_id", "VRF", (S.vrfs || []).map((v) => ({ value: v.id, label: v.name })), edit ? p.vrf_id : S.vrf || S.vrfs?.[0]?.id)}${fSelect("status", "Статус", [["active", "Активен"], ["reserved", "Резерв"], ["deprecated", "Устарел"]].map(([value, label]) => ({ value, label })), p?.status ?? "active")}</div>
${edit && nested ? `<div class="info" id="vrf-hint" hidden>Вместе с префиксом будет перенесено вложенных: ${nested}</div>` : ""}
${edit ? "" : fSelect("parent_id", "Родительский префикс", parents, "", { optional: true, empty: "Определить автоматически" })}
${fArea("note", "Примечание", { value: p?.note, ph: "Любая дополнительная информация" })}
<label class="check"><input type="checkbox" name="is_pool" ${p?.is_pool ? "checked" : ""}><span>Пул для автоназначения<small>Адреса из этого префикса будут выдаваться автоматически по запросу</small></span></label>`),
foot: dlgFoot(edit ? "Сохранить" : "Добавить префикс"),
});
}
// «следующий свободный» вложенный префикс: система сама выбирает первый свободный выровненный блок родителя
function subnetDialog(parent) {
const plen = Number(parent.prefix.split("/")[1]), max = parent.family === 6 ? 128 : 32, lo = plen + 1;
if (lo > max) return toast("Префикс нельзя дробить: это одиночный адрес", true);
const kids = (S.prefixes || []).filter((x) => x.parent_id === parent.id);
const lastKid = kids.reduce((a, b) => (a && a.id > b.id ? a : b), null);
const def = lastKid ? Number(lastKid.prefix.split("/")[1]) : Math.min(Math.max(parent.family === 6 ? 64 : 30, lo), max);
const sizes = Array.from({ length: max - lo + 1 }, (_, i) => ({ value: lo + i, label: "/" + (lo + i) }));
S.dialog = async (v) => {
const r = await api(`/prefixes/${parent.id}/subnets/next`, { method: "POST", body: { length: Number(v.length), description: v.description, status: v.status, is_pool: v.is_pool, note: v.note } });
S.collapsed?.delete(parent.id); // показать созданный префикс под родителем
toast(`Создан префикс ${r.prefix}`);
await draw();
};
let seq = 0;
S.subnetRefresh = async () => { // предпросмотр: какой блок будет выделен
const len = $("#dlg-form [name=length]")?.value, box = $("#sn-preview"), go = $("#modal-root [data-action=submit-form]"), n = ++seq;
if (!len || !box) return;
let text, ok = false;
try {
const r = await api(`/prefixes/${parent.id}/subnets/next`, { params: { length: len } });
ok = !!r.prefix; text = ok ? `Будет создан: <b class="mono">${esc(r.prefix)}</b>` : `В ${esc(parent.prefix)} нет свободного блока /${esc(len)}`;
} catch (e) { text = esc(e.message); }
if (n !== seq || !$("#sn-preview")) return; // устаревший ответ
$("#sn-preview").innerHTML = text; go.disabled = !ok;
};
openDialog({
title: `Новый вложенный префикс в ${parent.prefix}`, width: 560,
note: "Система выберет первый свободный блок выбранного размера внутри родительского префикса и создаст в нём вложенный.",
body: formBody(`${fSelect("length", "Размер", sizes, def)}
<div class="info" id="sn-preview">Подбираем блок…</div>
${fInput("description", "Описание", { value: "", optional: true })}
${fSelect("status", "Статус", Object.entries(PREFIX_STATUS).map(([value, [, label]]) => ({ value, label })), "active")}
${fArea("note", "Примечание", { value: "" })}
<label class="check"><input type="checkbox" name="is_pool"><span>Пул для автоназначения<small>Адреса из этого префикса будут выдаваться автоматически</small></span></label>`),
foot: dlgFoot("Создать префикс"),
});
S.subnetRefresh();
}
async function vrfsDialog() {
const org = currentOrg();
const { items } = await api("/vrfs", { params: { organization_id: org.id } });
S.vrfs = items;
const cols = "1fr 140px 90px 84px";
const rows = items.map((v) => {
const editing = S.vrfEdit === v.id;
const acts = editing
? `${iconBtn(I.edit(14), "vrf-save", "Сохранить", `data-id="${v.id}"`, true)}${iconBtn(I.close(), "vrf-cancel", "Отмена", "", true)}`
: `${iconBtn(I.edit(14), "vrf-edit", "Переименовать", `data-id="${v.id}"`, true)}${iconBtn(I.trash(14), "vrf-del", v.prefixes_count ? "Нельзя удалить: используется префиксами" : "Удалить", `data-id="${v.id}"`, true, v.prefixes_count > 0)}`;
return `<div class="tr" style="--cols:${cols};--h:48px">${editing ? `<input class="input" id="edit-name" value="${esc(v.name)}" style="height:32px">` : `<span style="font-weight:600">${esc(v.name)}</span>`}
${editing ? `<input class="input mono" id="edit-rt" value="${esc(v.route_target)}" style="height:32px;font-size:13px">` : `<span class="mono muted" style="font-size:13px">${esc(v.route_target)}</span>`}<span class="muted">${v.prefixes_count}</span><span style="display:flex;justify-content:flex-end;gap:4px">${acts}</span></div>`;
}).join("");
openDialog({
title: "Управление VRF", width: 680,
note: `VRF (Virtual Routing and Forwarding) — изолированная таблица маршрутизации. Каждый префикс закрепляется за одним VRF; здесь — общий список для организации «${esc(org.name)}».`,
body: `<div class="mini-table"><div class="tr th" style="--cols:${cols}"><span>Название</span><span>Route target</span><span>Префиксов</span><span></span></div>${rows}</div>
<div class="add-row"><input class="input" id="new-vrf" placeholder="Название нового VRF"><input class="input mono" id="new-rt" placeholder="Route target (необязательно)" style="width:180px;flex:none;font-size:13px"><button type="button" class="btn outline-primary" style="flex:none" data-action="vrf-add">${I.plus()}Добавить</button></div>`,
foot: `<div class="dialog-foot">${btn("Готово", "vrf-done", { cls: "primary" })}</div>`,
});
}
// ---- addresses of one prefix
screens.address = async (id) => {
const p = await api(`/prefixes/${id}`);
if (store.orgId !== p.organization_id) store.orgId = p.organization_id;
const limit = S.limit || 100;
const [page, plist, devs, vrfs] = await Promise.all([
api(`/prefixes/${id}/addresses`, { params: { status: S.astatus, q: S.q, limit } }),
loadPrefixes(p.organization_id),
api("/devices", { params: { organization_id: p.organization_id, limit: 500 } }),
api("/vrfs", { params: { organization_id: p.organization_id } }), // нужен окну «Редактировать префикс»
]);
S.prefix = p; S.prefixes = plist.items; S.devices = devs.items; S.vrfs = vrfs.items; S.page = page; S.rows = page.items;
const byId = Object.fromEntries(plist.items.map((x) => [x.id, x]));
const chain = [];
for (let x = byId[p.parent_id]; x; x = byId[x.parent_id]) chain.unshift(x);
const sm = page.summary;
const cols = selCols("minmax(140px,1fr) 1.2fr 1.6fr 120px 160px 44px");
setSelectable(page.items.filter((a) => a.id != null).map((a) => a.id)); // у «Свободен» нет записи
const rows = page.items.map((a, n) => {
const [c, t] = ADDR_STATUS[a.status];
const free = a.status === "free";
const key = "adr-row-" + (a.id ?? a.address);
const items = free ? [{ label: "Назначить адрес", value: "assign:" + a.address }] : [{ label: "Редактировать", value: "edit:" + a.id }, { label: "Удалить", value: "del:" + a.id, cls: "danger" }];
return `<div class="tr hoverable clickable${free ? " free" : ""}${n === page.items.length - 1 ? " last" : ""}${free ? "" : selRow(a.id)}" data-row="1" data-action="${free ? "adr-assign" : "adr-edit"}" data-id="${a.id ?? ""}" data-ip="${esc(a.address)}" style="--cols:${cols};--h:52px">${free ? selBlank() : selCell(a.id)}<span class="m13 ${free ? "" : ""}" style="color:${free ? "var(--faint)" : "var(--text)"}">${esc(a.address)}</span>
<span class="mono ${free ? "" : "muted"}" style="font-size:13px;font-weight:400">${esc(a.dns_name || "—")}</span><span class="muted">${esc(a.description || (free ? "—" : ""))}</span><span>${badge(c, t)}</span>
<span style="font-size:13px" class="muted">${a.updated_at ? fmtDate(a.updated_at) : "—"}</span><span class="cell-actions rel">${iconBtn(I.dots(), "menu", "Действия", `data-menu="${key}"`)}${popMenu(key, items, "row-pop")}</span></div>`;
}).join("");
const pct = (n) => (sm.capacity ? Math.min((n / sm.capacity) * 100, 100) : 0);
const shown = page.items.length;
const more = page.total > shown ? ` · <a href="#" data-action="more">Показать ещё 100</a>` : "";
const stItems = [{ label: "любой", value: "" }, { label: "Назначен", value: "assigned" }, { label: "Резерв", value: "reserved" }, { label: "Устаревший", value: "deprecated" }, { label: "Свободен", value: "free" }];
return shell("prefixes", `<div class="crumbs" style="font-weight:400;margin-top:16px;padding-top:0"><a href="#/prefixes">Префиксы</a>${chain.map((x) => `${I.chevR(14)}<span>${esc(x.prefix)}</span>`).join("")}${I.chevR(14)}<span style="color:var(--text);font-weight:500">${esc(p.prefix)}</span></div>
<div class="head" style="margin:12px 0 16px"><div><h1 class="mono" style="font-family:var(--sans)">${esc(p.prefix)}</h1><div class="sub">${esc(p.description)}${p.description ? " · " : ""}${sm.assigned} назначено · ${sm.free} свободно · VRF ${esc(p.vrf_name)}</div></div>
<div class="actions">${btn("Редактировать", "pfx-edit", { icon: I.edit(16) })}${btn("Назначить адрес", "adr-new", { cls: "primary", icon: I.plus() })}</div></div>
<div class="card usage"><div style="flex:1"><div class="track"><div style="width:${pct(sm.assigned)}%;background:#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 (!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" : "" },
{ 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", "Отключён")}</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("Сохранить пароль"),
});
}
// ---- 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 if (id === "user") { // изменение 036: меню логина в шапке
document.querySelectorAll(".pop").forEach((el) => el.remove());
return value === "logout" ? actions.logout() : passwordDialog();
}
else {
const key = Object.keys(rowActions).find((k) => id.startsWith(k + "-"));
const [act, rid] = value.split(":");
if (key) { document.querySelectorAll(".pop").forEach((el) => el.remove()); return rowActions[key](act, rid); }
}
draw();
},
"vrf-tab": (d) => { S.vrf = Number(d.id); draw(); },
toggle: (d) => { const c = (S.collapsed ||= new Set()); c.has(Number(d.id)) ? c.delete(Number(d.id)) : c.add(Number(d.id)); draw(); },
more: () => { S.limit = (S.limit || 100) + 100; draw(); },
"dd-toggle": (d, el) => {
const pop = el.parentElement.querySelector(".dd");
const open = pop.hidden;
closeDropdowns();
pop.hidden = !open;
if (open) (pop.querySelector(".sel") || pop.querySelector("button"))?.focus();
},
"dd-pick": (d, el) => {
const box = el.closest(".cselect"), input = box.querySelector("input");
input.value = d.value;
box.querySelector(".v").textContent = el.textContent;
box.querySelector(".select").classList.toggle("ph", d.empty === "1");
box.querySelectorAll(".dd button").forEach((b) => b.classList.toggle("sel", b === el));
closeDropdowns();
box.querySelector(".select").focus();
input.dispatchEvent(new Event("change", { bubbles: true }));
// изменение 033: живое переключение поля «Организация» при смене роли в диалоге пользователя
if (S.dialog && input.name === "role") $("#org-field-wrap")?.toggleAttribute("hidden", d.value === "superadmin");
},
sel: (d, el) => { el.checked ? selSet().add(Number(d.id)) : selSet().delete(Number(d.id)); draw(); },
"sel-all": (d, el) => { S.sel = new Set(el.checked ? S.selectable : []); draw(); },
"sel-clear": () => { S.sel = new Set(); draw(); },
"bulk-del": () => {
const c = BULK[screenKey()], ids = [...selSet()];
if (ids.length && confirm(`Удалить выбранное: ${ids.length} ${plural(ids.length, ...c.forms)}?`)) return bulk("Удаление", ids, c.label, (id) => api(c.url(id), { method: "DELETE" }), c.after);
},
"bulk-access": (d) => bulk(d.value === "1" ? "Разрешение доступа" : "Отключение доступа", [...selSet()], BULK.users.label,
(id) => api(`/users/${id}`, { method: "PATCH", body: { is_active: d.value === "1" } })),
"pfx-open": (d) => { location.hash = `#/prefixes/${d.id}`; },
"org-open": (d) => { store.orgId = Number(d.id); location.hash = "#/prefixes"; },
"isp-edit": (d) => ispDialog(rowById(d.id)),
"dev-edit": (d) => deviceDialog(rowById(d.id)),
"user-edit": (d) => userDialog(rowById(d.id)),
"adr-edit": (d) => addressDialog(S.page.items.find((x) => x.id === Number(d.id))),
"adr-assign": (d) => addressDialog(null, d.ip),
"jr-open": (d) => entryDialog(S.entries.find((r) => r.uid === d.uid)),
"jr-refresh": () => draw(),
"jr-settings": () => journalSettingsDialog(),
"jr-clear": () => journalClearDialog(),
"jr-clear-do": doClear,
copy: (d) => copyText(d.text),
"close-dialog": closeDialog,
overlay: (d, el, e) => { if (e.target === el) closeDialog(); },
"submit-form": submitDialog,
"open-org": (d) => { store.orgId = Number(d.id); },
"org-new": () => orgDialog(null),
"users-new": () => userDialog(null),
"pw-change": () => passwordDialog(),
"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();