// 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: () => ``,
};
/* ------------------------------------------------------------------- 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) => `${esc(text)}`;
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 = "" } = {}) => ``;
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 === "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()
? `` : "");
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
? `