// IPAM Manager — UI-админка. Только визуализирует ответы /api/v1, бизнес-логики нет.
const API = "/api/v1";
const $ = (s) => document.querySelector(s);
const esc = (v) => String(v ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
const fmtNum = (n) => new Intl.NumberFormat("ru-RU").format(n);
const fmtDate = (iso) => (iso ? iso.replace("T", " ").slice(0, 16) : "—");
const plural = (n, one, few, many) => {
const m10 = n % 10, m100 = n % 100;
return m10 === 1 && m100 !== 11 ? one : m10 >= 2 && m10 <= 4 && (m100 < 10 || m100 >= 20) ? few : many;
};
/* ------------------------------------------------------------------ icons */
const ic = (inner, s = 16) => ``;
const I = {
chevR: (s) => ic('', s),
chevD: (s) => ic('', s),
plus: () => ic(''),
search: () => ic(''),
dots: () => ic(''),
close: () => ic(''),
edit: (s) => ic('', s),
trash: (s) => ic('', s),
org: () => ic(''),
gear: () => ic(''),
refresh: () => ic(''),
cog: () => ic(''),
trashJ: () => ic(''),
calendar: () => ic(''),
copy: () => ic(''),
globe: () => ``, // изменение 038: stroke="#fff" → currentColor, цвет задаёт .brand-logo{color:var(--on-primary)}
// изменение 038 (доработка): пиктограмма переключателя темы — солнце/луна/монитор по текущему режиму
sun: () => ic(''),
moon: () => ic(''),
monitor: () => ic(''),
};
/* ------------------------------------------------------------------- state */
const store = {
get token() { return sessionStorage.getItem("ipam.token"); },
set token(v) { v ? sessionStorage.setItem("ipam.token", v) : sessionStorage.removeItem("ipam.token"); },
get orgId() { return Number(localStorage.getItem("ipam.org")) || null; },
set orgId(v) { localStorage.setItem("ipam.org", v); },
};
// изменение 038: тема — "auto" (по умолчанию, следует prefers-color-scheme), "light" или "dark" (выбор пользователя, хранится в localStorage).
// Начальное применение — в web/theme.js (до отрисовки CSS, чтобы не было вспышки светлой темы); здесь только переключение из меню.
const THEME_LABEL = { auto: "авто", light: "светлая", dark: "тёмная" };
function getTheme() {
try { return localStorage.getItem("ipam.theme") || "auto"; } catch { return "auto"; }
}
function setTheme(t) {
try { t === "auto" ? localStorage.removeItem("ipam.theme") : localStorage.setItem("ipam.theme", t); } catch { /* localStorage недоступен — тема не сохранится между сессиями */ }
if (t === "auto") delete document.documentElement.dataset.theme;
else document.documentElement.dataset.theme = t;
}
let user = null;
let orgs = [];
let S = {}; // локальное состояние текущего экрана
let menu = null; // открытое всплывающее меню {id, items:[{label,value,cls}], on}
let pendingMfa = null; // изменение 037: {mfa_token} между первым и вторым шагом входа — только в памяти, не в sessionStorage
/* --------------------------------------------------------------------- api */
class ApiError extends Error {
constructor(status, body) {
super(body?.message || `Ошибка ${status}`);
this.status = status;
this.fields = body?.fields || {};
this.body = body || {};
}
}
async function api(path, { method = "GET", body, params } = {}) {
const qs = params ? "?" + new URLSearchParams(Object.entries(params).filter(([, v]) => v !== "" && v != null)) : "";
const res = await fetch(API + path + qs, {
method,
headers: { ...(body ? { "Content-Type": "application/json" } : {}), ...(store.token ? { Authorization: "Bearer " + store.token } : {}) },
body: body ? JSON.stringify(body) : undefined,
});
if (res.status === 204) return null;
const data = await res.json().catch(() => null);
if (res.status === 401 && path !== "/auth/login" && path !== "/auth/login/2fa") { // изменение 037: 401 здесь — истёкший/неверный mfa_token, обрабатывается на месте
store.token = null;
location.hash = "#/login";
throw new ApiError(401, { message: "Сессия истекла" });
}
if (!res.ok) throw new ApiError(res.status, data);
return data;
}
function toast(msg, err = false) {
const el = document.createElement("div");
el.className = "toast" + (err ? " err" : "");
el.textContent = msg;
$("#toast-root").append(el);
setTimeout(() => el.remove(), 3500);
}
/* --------------------------------------------------------- shared fragments */
const badge = (cls, text) => `${esc(text)}`;
const PREFIX_STATUS = { active: ["green", "Активен"], reserved: ["amber", "Резерв"], deprecated: ["red", "Устарел"] };
const ADDR_STATUS = { assigned: ["green", "Назначен"], reserved: ["amber", "Резерв"], deprecated: ["red", "Устаревший"], free: ["", "Свободен"] };
// изменение 038: цвета — CSS-токены вместо литералов; третий элемент — признак «порог не превышен» (обычный/приглушённый вид)
const utilColor = (p) => (p >= 90 ? ["var(--danger-strong)", "var(--danger)", false] : p >= 75 ? ["var(--warn-strong)", "var(--warn)", false] : ["var(--primary)", "var(--muted)", true]);
const btn = (label, action, { cls = "", data = "", icon = "" } = {}) => ``;
const iconBtn = (icon, action, label, data = "", sm = false, disabled = false) =>
``;
const searchBox = (value, placeholder, width) =>
``;
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()
? `` : "");
const selBlank = () => (isAdmin() ? "" : "");
const selAllCell = () => (isAdmin()
? `` : "");
const selRow = (id) => (selSet().has(id) ? " selected" : "");
const bulkMenu = (id, label, items) =>
`${popMenu(id, items)}`;
const bulkBar = (extra = "") => (isAdmin() && selSet().size
? `