// 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 ? `
Выбрано: ${selSet().size}${extra}${btn("Удалить", "bulk-del", { cls: "danger", icon: I.trash(14) })}${btn("Снять выделение", "sel-clear", { cls: "ghost" })}
` : ""); function popMenu(id, items, extra = "") { if (menu?.id !== id) return ""; return ``; } function filterBtn(id, label, items, value, width = 180) { const cur = items.find((i) => String(i.value) === String(value)); return `${popMenu(id, items.map((i) => ({ ...i, cls: String(i.value) === String(value) ? "sel" : "" })), "")}`; } function orgSwitcher() { const o = currentOrg(); return `${popMenu("org", orgs.map((x) => ({ label: x.name, value: x.id, cls: x.id === store.orgId ? "sel" : "" })))}
`; } const crumbsOrg = () => `
Организации${I.chevR(14)}${esc(currentOrg()?.name ?? "")}
`; const header = (title, sub, actions = "") => `

${esc(title)}

${sub}
${actions}
`; const NAV = [["overview", "Обзор"], ["prefixes", "Префиксы"], ["orgs", "Организации"], ["isps", "Операторы"], ["devices", "Устройства"], ["journal", "Журнал"], ["users", "Пользователи", "admin"]]; function shell(active, content) { const nav = NAV.filter(([, , role]) => role !== "admin" || isAdmin()); // раздел «Пользователи» виден только администратору return `
ipam_manager
${esc(user?.username ?? "")}${btn("Сменить пароль", "pw-change", { cls: "ghost" })}${btn("Выйти", "logout", { cls: "ghost" })}
${content}
`; } /* ------------------------------------------------------------------ dialogs */ function openDialog({ title, width = 560, note = "", body, foot }) { $("#modal-root").innerHTML = `
`; $("#dlg-form .input, #dlg-form .select")?.focus(); } const closeDialog = () => { $("#modal-root").innerHTML = ""; S.dialog = null; S.subnetRefresh = null; }; const opt = (label) => ` ${label}`; const fInput = (name, label, { value = "", ph = "", mono = false, optional = false, type = "text" } = {}) => ``; const fArea = (name, label, { value = "", ph = "Любая дополнительная информация", rows = 2, h = 56, mono = false, optional = true, hint = "" } = {}) => ``; // Выпадающий список в диалогах — тот же стиль, что у меню строк (не нативный `; }; const dlgFoot = (submit) => `
${btn("Отмена", "close-dialog", { cls: "ghost" })}${btn(submit, "submit-form", { cls: "primary" })}
`; const formBody = (inner) => `
${inner}
`; 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 `
${esc(p.prefix)}${fmtNum(p.used)}/${fmtNum(p.capacity)} (${p.utilization}%)
`; }).join(""); const recent = o.recent_changes.map((r, i, a) => { const [c, t] = actionBadge[r.action] || ["", r.action]; return `
${fmtDate(r.ts)}${esc(r.entity_label)}${badge(c, t)}${esc(r.username)}
`; }).join(""); return shell("overview", `${header("Обзор", "Сводка по адресному пространству")}
Префиксов
${fmtNum(o.prefixes)}
${o.vrfs} VRF
Адресов назначено
${fmtNum(o.assigned)}
из ${fmtNum(o.capacity)} возможных
Использование
${o.utilization}%
Зарезервировано
${fmtNum(o.reserved)}
адресов
Высокая загрузка
ПрефиксИспользовано
${top || '
Нет данных
'}
Последние изменения
Дата (UTC)ОбъектДействиеПользователь
${recent || '
Изменений пока нет
'}
`); }; // ---- 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) => `
${selCell(o.id)} ${esc(o.name)}${esc(o.short_name)}${esc(o.inn)} ${esc(o.address)}${o.prefixes_count}${fmtNum(o.addresses_count)} ${iconBtn(I.dots(), "menu", "Действия", `data-menu="org-row-${o.id}"`)}${popMenu("org-row-" + o.id, [{ label: "Открыть префиксы", value: "open:" + o.id }, { label: "Редактировать", value: "edit:" + o.id }, { label: "Удалить", value: "del:" + o.id, cls: "danger" }], "row-pop")}
`).join(""); S.rows = items; return shell("orgs", `${header("Организации", `${all} ${plural(all, "организация", "организации", "организаций")}`, btn("Добавить организацию", "org-new", { cls: "primary", icon: I.plus() }))}
${searchBox(q, "Поиск: название, ИНН, адрес", 300)}
${bulkBar()}
${selAllCell()}НазваниеКраткое имяИННАдресПрефиксовАдресов
${rows || '
Ничего не найдено
'}
Показано ${items.length} из ${all} ${plural(all, "организации", "организаций", "организаций")}
`); }; 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 })}
${fInput("short_name", "Краткое имя", { value: o?.short_name })}${fInput("inn", "ИНН", { value: o?.inn })}
${fInput("address", "Юридический адрес", { value: o?.address })}
${fInput("contact_person", "Контактное лицо", { value: o?.contact_person, optional: true })}${fInput("phone", "Телефон", { value: o?.phone, optional: true })}
${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) => `
${selCell(i.id)}${esc(i.name)} ${esc(i.organization_name)} ${esc(i.networks[0] ?? "—")}${i.networks.length > 1 ? `+${i.networks.length - 1}` : ""} ${esc(i.hotline)}${esc(i.contract_number)}${esc(i.note)} ${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")}
`).join(""); return shell("isps", `${header("Операторы связи", "Реестр провайдеров и каналов", btn("Добавить оператора", "isp-new", { cls: "primary", icon: I.plus() }))}
${searchBox(q, "Поиск: оператор, организация, IP", 300)}
${bulkBar()}
${selAllCell()}НазваниеОрганизацияIP-адресаГорячая линияДоговорЗаметки
${rows || '
Ничего не найдено
'}
Показано ${items.length} из ${all} ${plural(all, "оператора", "операторов", "операторов")}
`); }; 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: "можно несколько" })}
${fInput("hotline", "Горячая линия", { value: i?.hotline })}${fInput("contract_number", "Номер договора", { value: i?.contract_number })}
${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 ? `${d.ip_addresses.length}${d.ip_addresses.length > 1 ? `+${d.ip_addresses.length - 1}` : ""}` : `0`; return `
${selCell(d.id)}${esc(d.name)} ${esc(d.device_type_name)}${ips}${esc(d.note)} ${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")}
`; }).join(""); return shell("devices", `${crumbsOrg()}${header("Устройства", `${list.total} ${plural(list.total, "устройство", "устройства", "устройств")} · организация: ${esc(org?.name ?? "")}`, btn("Добавить устройство", "dev-new", { cls: "primary", icon: I.plus() }))}
${orgSwitcher()}${searchBox(q, "Поиск: имя, IP, заметка", 260)}${filterBtn("type", "Тип", typeItems, S.type ?? "", 170)}
${bulkBar(bulkMenu("bulk-type", "Сменить тип", types.items.map((t) => ({ label: t.name, value: t.id }))))}
${selAllCell()}УстройствоТипIP-адресовЗаметки
${rows || '
Устройств нет
'}
Показано ${list.items.length} из ${list.total} ${plural(list.total, "устройства", "устройств", "устройств")}
`); }; 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" })}
${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 })}
${edit ? "" : fSelect("organization_id", "Организация", orgs.map((o) => ({ value: o.id, label: o.name })), store.orgId)} ${fArea("note", "Заметки", { value: d?.note })}
IP-адрес назначается отдельно — на экране «Адреса» через «Назначить адрес» и поле «Связано с устройством».
`), 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 ? `` : `${esc(t.name)}`; const locked = t.devices_count > 0; const acts = t.is_default ? `по умолчанию` : 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 `
${name}${t.devices_count}${acts}
`; }).join(""); openDialog({ title: "Управление типами устройств", width: 640, note: "Типы используются в фильтре и в поле «Тип» при добавлении устройства. Общий список для всех организаций.", body: `
НазваниеУстройств
${rows}
`, foot: `
${btn("Готово", "close-dialog", { cls: "primary" })}
`, }); } // ---- prefixes screens.prefixes = async () => { const org = currentOrg(); if (!org) return shell("prefixes", header("Префиксы", "Сначала добавьте организацию") + `
Нет организаций. ${btn("Добавить организацию", "org-new", { cls: "primary" })}
`); const [pl, vl] = await Promise.all([ api("/prefixes", { params: { organization_id: org.id, limit: 1000 } }), 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) => ``; 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) ? `` : ""; const pad = p.depth * 20; const cidr = leaf ? `${esc(p.prefix)}` : `${esc(p.prefix)}`; return `
${leaf ? selCell(p.id) : selBlank()}${chev}${cidr}${esc(p.description)}${esc(p.vrf_name)}${badge(sc, st)}
${p.utilization}%
${fmtNum(p.used)} ${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") : ""}
`; }).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" }]; return shell("prefixes", `${crumbsOrg()}${header("Префиксы", `${all.length} ${plural(all.length, "префикс", "префикса", "префиксов")} · ${vl.total} VRF · организация: ${esc(org.name)}`, btn("Добавить префикс", "pfx-new", { cls: "primary", icon: I.plus() }))}
${tab(0, "Все", all.length)}${vl.items.map((v) => tab(v.id, v.name, v.prefixes_count)).join("")}
${orgSwitcher()}${searchBox(S.q || "", "Поиск: префикс, описание", 280)}${filterBtn("status", "Статус", statusItems, S.status ?? "")}${filterBtn("family", "Семейство", famItems, S.family ?? "")}
${bulkBar(bulkMenu("bulk-status", "Статус", Object.entries(PREFIX_STATUS).map(([value, [, label]]) => ({ label, value }))))}
${selAllCell()}ПрефиксОписаниеVRFСтатусИспользованиеАдресов
${rows || '
Префиксов нет
'}
Показано ${visible.length} из ${all.length} ${plural(all.length, "префикса", "префиксов", "префиксов")}
`); }; 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("${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")} ${edit && nested ? `` : ""} ${edit ? "" : fSelect("parent_id", "Родительский префикс", parents, "", { optional: true, empty: "Определить автоматически" })} ${fArea("note", "Примечание", { value: p?.note, ph: "Любая дополнительная информация" })} `), 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 ? `Будет создан: ${esc(r.prefix)}` : `В ${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)}
Подбираем блок…
${fInput("description", "Описание", { value: "", optional: true })} ${fSelect("status", "Статус", Object.entries(PREFIX_STATUS).map(([value, [, label]]) => ({ value, label })), "active")} ${fArea("note", "Примечание", { value: "" })} `), 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 `
${editing ? `` : `${esc(v.name)}`} ${editing ? `` : `${esc(v.route_target)}`}${v.prefixes_count}${acts}
`; }).join(""); openDialog({ title: "Управление VRF", width: 680, note: `VRF (Virtual Routing and Forwarding) — изолированная таблица маршрутизации. Каждый префикс закрепляется за одним VRF; здесь — общий список для организации «${esc(org.name)}».`, body: `
НазваниеRoute targetПрефиксов
${rows}
`, foot: `
${btn("Готово", "vrf-done", { cls: "primary" })}
`, }); } // ---- 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 } }), api("/prefixes", { params: { organization_id: p.organization_id, limit: 1000 } }), 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 `
${free ? selBlank() : selCell(a.id)}${esc(a.address)} ${esc(a.dns_name || "—")}${esc(a.description || (free ? "—" : ""))}${badge(c, t)} ${a.updated_at ? fmtDate(a.updated_at) : "—"}${iconBtn(I.dots(), "menu", "Действия", `data-menu="${key}"`)}${popMenu(key, items, "row-pop")}
`; }).join(""); const pct = (n) => (sm.capacity ? Math.min((n / sm.capacity) * 100, 100) : 0); const shown = page.items.length; const more = page.total > shown ? ` · Показать ещё 100` : ""; const stItems = [{ label: "любой", value: "" }, { label: "Назначен", value: "assigned" }, { label: "Резерв", value: "reserved" }, { label: "Устаревший", value: "deprecated" }, { label: "Свободен", value: "free" }]; return shell("prefixes", `
Префиксы${chain.map((x) => `${I.chevR(14)}${esc(x.prefix)}`).join("")}${I.chevR(14)}${esc(p.prefix)}

${esc(p.prefix)}

${esc(p.description)}${p.description ? " · " : ""}${sm.assigned} назначено · ${sm.free} свободно · VRF ${esc(p.vrf_name)}
${btn("Редактировать", "pfx-edit", { icon: I.edit(16) })}${btn("Назначить адрес", "adr-new", { cls: "primary", icon: I.plus() })}
Назначено ${sm.assigned}Зарезервировано ${sm.reserved}Свободно ${sm.free}
${searchBox(S.q || "", "Поиск: адрес, DNS, описание", 260)}${filterBtn("astatus", "Статус", stItems, S.astatus ?? "", 190)}
${bulkBar(bulkMenu("bulk-status", "Статус", ["assigned", "reserved", "deprecated"].map((value) => ({ label: ADDR_STATUS[value][1], value }))))}
${selAllCell()}IP-адресDNS-имяОписаниеСтатусИзменён (UTC)
${rows || '
Адресов нет
'}
Показано ${shown} из ${page.total} ${plural(page.total, "адреса", "адресов", "адресов")}${more}
`); }; 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 ? `` : fInput("address", "IP-адрес", { mono: true, value: presetIp || (S.page.items.find((x) => x.status === "free")?.address ?? "") }); openDialog({ title: edit ? "Редактировать адрес" : "Назначить адрес", body: formBody(`
Подсеть: ${esc(p.prefix)} · ${S.page.summary.free} ${plural(S.page.summary.free, "свободный адрес", "свободных адреса", "свободных адресов")}
${ipField}${fSelect("status", "Статус", [["assigned", "Назначен"], ["reserved", "Резерв"], ["deprecated", "Устаревший"]].map(([value, label]) => ({ value, label })), a?.status ?? "assigned")}
${fInput("dns_name", "DNS-имя", { value: a?.dns_name, mono: true, ph: "host.example.com", optional: true }).replace("необязательно", "необязательно, FQDN")} ${fInput("description", "Описание", { value: a?.description })}
${fSelect("device_id", "Связано с устройством", S.devices.map((d) => ({ value: d.id, label: d.name })), a?.device_id ?? "", { optional: true, empty: "Выберите устройство…" }).replace('
${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", 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) => `
${fmtDateSec(r.ts)}${eventBadge(r.event_type)} ${esc(ENTITY_RU[r.entity_type] || r.entity_type)}${["session", "journal"].includes(r.entity_type) ? "" : `${esc(r.entity_label)}`} ${esc(r.message)}${esc(r.actor)}${esc(r.client_ip || "—")}${r.short_id}
`).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) => ``; return shell("journal", `${header("Журнал", `${fmtNum(n)} ${plural(n, "запись", "записи", "записей")} · старейшая ${sum.oldest_ts ? fmtDate(sum.oldest_ts) : "—"} · ${rot}`, act)}
${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)}
Время (UTC)ТипСущностьСообщениеАкторIP-адресID записи
${rows || '
Записей нет
'} ${page.total > page.items.length ? `
${btn("Показать ещё 100", "more")}
` : ""}
Показано ${page.items.length} из ${fmtNum(page.total)} ${plural(page.total, "записи", "записей", "записей")}
`); }; 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) => `
${label}
${inner}
`; const mono = (v) => `${esc(v)}`; const entity = ["session", "journal"].includes(r.entity_type) ? esc(ENTITY_RU[r.entity_type]) : `${esc(ENTITY_RU[r.entity_type] || r.entity_type)} · ${mono(r.entity_label)}`; openDialog({ title: "Запись журнала", width: 640, body: `
${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) : '—')}${row("Сущность", entity)}${row("Сообщение", esc(r.message))} ${r.meta?.method ? row("Запрос", mono(`${r.meta.method} ${r.meta.path}`)) : ""}${r.meta?.user_agent ? row("User-Agent", `${esc(r.meta.user_agent)}`) : ""}
Данные
${r.diff ? esc(JSON.stringify(r.diff, null, 2)) : "—"}
`, foot: `
${btn("Закрыть", "close-dialog")}
`, }); } 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(`
Сейчас в журнале: ${fmtNum(n)} ${plural(n, "запись", "записи", "записей")} · старейшая ${sm.oldest_ts ? fmtDate(sm.oldest_ts) : "—"}
${fInput("retention_days", "Хранить записи, дней", { value: sm.retention_days, type: "number", hint: "0 — без ограничения по сроку" }).replace("Ротация запускается раз в час и при сохранении настроек: удаляет самые старые записи сверх срока и лимита. Каждая ротация фиксируется записью в журнале.`), 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: `
Будет удалено ${fmtNum(n)} ${plural(n, "запись", "записи", "записей")} без возможности восстановления. В журнале останется одна запись об очистке: кто и когда её выполнил.
${state.wrong ? `
Неверный пароль. Осталось попыток: ${state.left}
` : ""} ${locked ? `
Слишком много неверных попыток. Повторите через ${minutes} мин.
` : ""}
`, foot: `
${btn("Отмена", "close-dialog", { cls: "ghost" })}
`, }); $("#clear-pw")?.focus(); } async function doClear() { const pw = $("#clear-pw").value; if (!pw) return; try { const res = await api("/journal/clear", { method: "POST", body: { password: pw } }); closeDialog(); toast(`Журнал очищен, удалено записей: ${res.deleted}`); S.limit = 100; await draw(); } catch (e) { if (!(e instanceof ApiError)) throw e; if (e.status === 429) journalClearDialog({ retry: e.body.retry_after_seconds }); else if (e.status === 403) { journalClearDialog({ wrong: true, left: e.body.attempts_left }); } else toast(e.message, true); } } // ---- users screens.users = async () => { if (!isAdmin()) return shell("users", `${header("Пользователи", "Учётные записи UI")}
Раздел доступен только администратору
`); const q = S.q || ""; const { items, total } = await api("/users", { params: { q, limit: 500 } }); S.rows = items; const cols = selCols("minmax(200px,1.5fr) 190px 190px minmax(140px,1fr) 44px"); setSelectable(items.filter((u) => u.id !== user?.id).map((u) => u.id)); // свою запись выбрать нельзя const rows = items.map((u, n) => { const self = u.id === user?.id; const items2 = [ { label: "Редактировать", value: "edit:" + u.id }, { label: u.is_active ? "Отключить доступ" : "Разрешить доступ", value: "toggle:" + u.id, cls: u.is_active ? "danger" : "" }, { label: "Удалить", value: "del:" + u.id, cls: "danger" }, ]; return `
${selCell(u.id, self)} ${esc(u.username)}${self ? ` это вы` : ""} ${badge(u.role === "admin" ? "blue" : "", ROLE_RU[u.role] || u.role)} ${u.is_active ? badge("green", "Доступ разрешён") : badge("red", "Отключён")} ${self ? "свои роль и доступ менять нельзя" : ""} ${iconBtn(I.dots(), "menu", "Действия", `data-menu="user-row-${u.id}"`)}${popMenu("user-row-" + u.id, items2, "row-pop")}
`; }).join(""); return shell("users", `${header("Пользователи", `${total} ${plural(total, "учётная запись", "учётные записи", "учётных записей")} · роли: администратор — запись, просмотр — чтение`, btn("Добавить пользователя", "users-new", { cls: "primary", icon: I.plus() }))}
${searchBox(q, "Поиск по логину", 260)}
${bulkBar(btn("Разрешить доступ", "bulk-access", { data: 'data-value="1"' }) + btn("Отключить доступ", "bulk-access", { data: 'data-value="0"' }))}
${selAllCell()}ЛогинРольСтатус
${rows || '
Пользователей нет
'}
Показано ${items.length} из ${total} ${plural(total, "записи", "записей", "записей")}
`); }; function userDialog(u) { const edit = !!u; const self = edit && u.id === user?.id; S.dialog = async (v) => { if (edit) { const body = { is_active: v.is_active === true }; if (v.role) body.role = v.role; if (v.password) body.password = v.password; await api(`/users/${u.id}`, { method: "PATCH", body }); } else { await api("/users", { method: "POST", body: { username: v.username, password: v.password, role: v.role, is_active: v.is_active !== false } }); } toast(edit ? "Пользователь сохранён" : "Пользователь добавлен"); await draw(); }; openDialog({ title: edit ? `Пользователь ${u.username}` : "Новый пользователь", note: "«Администратор» может изменять данные, «Просмотр» — только читать их. Логин после создания не меняется.", body: formBody(`${edit ? `` : fInput("username", "Логин", { ph: "ivanov", hint: "латиница, цифры, . _ -" })} ${self ? "" : fInput("password", edit ? "Новый пароль" : "Пароль", { type: "password", optional: edit, ph: edit ? "не менять" : "", hint: "минимум 8 символов" })} ${self ? "" : fSelect("role", "Роль", [{ value: "admin", label: "Администратор" }, { value: "viewer", label: "Просмотр" }], u?.role ?? "viewer")} ${self ? `
Свою учётную запись нельзя понизить, отключить или удалить — пароль меняется кнопкой «Сменить пароль» в шапке.
` : ""}`), foot: dlgFoot(edit ? "Сохранить" : "Добавить пользователя"), }); } function passwordDialog() { S.dialog = async (v) => { if (v.new_password !== v.confirm_password) throw new ApiError(422, { message: "Пароли не совпадают", fields: { confirm_password: "пароли не совпадают" } }); await api("/users/me/password", { method: "POST", body: { current_password: v.current_password, new_password: v.new_password } }); toast("Пароль изменён"); }; openDialog({ title: "Сменить пароль", note: `Учётная запись: ${esc(user?.username ?? "")}`, body: formBody(`${fInput("current_password", "Текущий пароль", { type: "password" })}
${fInput("new_password", "Новый пароль", { type: "password", hint: "минимум 8 символов" })}${fInput("confirm_password", "Повторите пароль", { type: "password" })}
Смена пароля не завершает уже открытые сессии: выданный ранее токен действует до истечения своего срока.
`), foot: dlgFoot("Сохранить пароль"), }); } // ---- login function loginScreen(err = "") { return ``; } /* ------------------------------------------------------------------- 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, `
Не удалось загрузить данные: ${esc(e.message)}
`); } } 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: `
${failed.map(([label, msg]) => `
${esc(label)}${esc(msg)}
`).join("")}
`, foot: `
${btn("Закрыть", "close-dialog", { cls: "primary" })}
`, }); } async function bulk(title, ids, labelOf, call, after) { const st = S; if (st.busy || !ids.length) return; st.busy = true; const failed = []; let ok = 0; try { for (const id of ids) { try { await call(id); ok++; st.sel.delete(id); } catch (e) { if (!(e instanceof ApiError)) throw e; if (e.status === 401) return; failed.push([labelOf(id) ?? `#${id}`, e.message]); } } } finally { st.busy = false; } toast(`${title}: выполнено ${ok} из ${ids.length}`, failed.length > 0); await after?.(); if (S !== st) return; // пока шли запросы, пользователь ушёл на другой экран await draw(); if (failed.length) failuresDialog(title, failed); } function bulkPick(id, value) { const k = screenKey(), ids = [...selSet()]; if (id === "bulk-type") return bulk("Смена типа", ids, BULK.devices.label, (x) => api(`/devices/${x}`, { method: "PATCH", body: { device_type_id: Number(value) } })); return bulk("Смена статуса", ids, BULK[k].label, (x) => api(BULK[k].url(x), { method: "PATCH", body: { status: value } })); } const rowActions = { "org-row": async (act, id) => { if (act === "open") { store.orgId = Number(id); location.hash = "#/prefixes"; } else if (act === "edit") orgDialog(rowById(id)); else if (act === "del" && confirmDel("организацию")) await guarded(async () => { await api(`/organizations/${id}`, { method: "DELETE" }); await loadOrgs(); toast("Организация удалена"); await draw(); }); }, "isp-row": async (act, id) => { if (act === "edit") ispDialog(rowById(id)); else if (act === "del" && confirmDel("оператора")) await guarded(async () => { await api(`/isps/${id}`, { method: "DELETE" }); toast("Оператор удалён"); await draw(); }); }, "dev-row": async (act, id) => { if (act === "edit") deviceDialog(rowById(id)); else if (act === "del" && confirmDel("устройство")) await guarded(async () => { await api(`/devices/${id}`, { method: "DELETE" }); toast("Устройство удалено"); await draw(); }); }, "pfx-row": async (act, id) => { if (act === "open") location.hash = `#/prefixes/${id}`; else if (act === "edit") prefixDialog(S.prefixes.find((p) => p.id === Number(id))); else if (act === "subnet") subnetDialog(S.prefixes.find((p) => p.id === Number(id))); else if (act === "del" && confirmDel("префикс")) await guarded(async () => { try { await api(`/prefixes/${id}`, { method: "DELETE" }); } catch (e) { if (e.status === 409 && confirm(`${e.message}. Удалить вместе с адресами?`)) await api(`/prefixes/${id}?force=true`, { method: "DELETE" }); else throw e; } toast("Префикс удалён"); await draw(); }); }, "adr-row": async (act, id) => { if (act === "assign") addressDialog(null, id); else if (act === "edit") addressDialog(S.page.items.find((a) => a.id === Number(id))); else if (act === "del" && confirmDel("адрес")) await guarded(async () => { await api(`/addresses/${id}`, { method: "DELETE" }); toast("Адрес удалён"); await draw(); }); }, "user-row": async (act, id) => { const u = rowById(id); if (act === "edit") userDialog(u); else if (act === "toggle") await guarded(async () => { await api(`/users/${id}`, { method: "PATCH", body: { is_active: !u.is_active } }); toast(u.is_active ? "Доступ отключён" : "Доступ разрешён"); await draw(); }); else if (act === "del" && confirmDel(`пользователя ${u.username}`)) await guarded(async () => { await api(`/users/${id}`, { method: "DELETE" }); toast("Пользователь удалён"); await draw(); }); }, }; const actions = { logout: () => { store.token = null; user = null; current = ""; location.hash = "#/login"; draw(); }, menu: (d, el) => { menu = menu?.id === d.menu ? null : { id: d.menu }; draw(); }, pick: async (d) => { const { menu: id, value } = d; menu = null; if (id === "org") { store.orgId = Number(value); S.vrf = 0; S.collapsed = null; } else if (id === "status") S.status = value; else if (id === "family") S.family = value; else if (id === "type") S.type = value; else if (id === "astatus") { S.astatus = value; S.limit = 100; } else if (id === "jtype") { S.jtype = value; S.limit = 100; } else if (id === "jactor") { S.jactor = value; S.limit = 100; } else if (id === "jentity") { S.jentity = value; S.limit = 100; } else if (id === "bulk-status" || id === "bulk-type") return bulkPick(id, value); else { const key = Object.keys(rowActions).find((k) => id.startsWith(k + "-")); const [act, rid] = value.split(":"); if (key) { document.querySelectorAll(".pop").forEach((el) => el.remove()); return rowActions[key](act, rid); } } draw(); }, "vrf-tab": (d) => { S.vrf = Number(d.id); draw(); }, toggle: (d) => { const c = (S.collapsed ||= new Set()); c.has(Number(d.id)) ? c.delete(Number(d.id)) : c.add(Number(d.id)); draw(); }, more: () => { S.limit = (S.limit || 100) + 100; draw(); }, "dd-toggle": (d, el) => { const pop = el.parentElement.querySelector(".dd"); const open = pop.hidden; closeDropdowns(); pop.hidden = !open; if (open) (pop.querySelector(".sel") || pop.querySelector("button"))?.focus(); }, "dd-pick": (d, el) => { const box = el.closest(".cselect"), input = box.querySelector("input"); input.value = d.value; box.querySelector(".v").textContent = el.textContent; box.querySelector(".select").classList.toggle("ph", d.empty === "1"); box.querySelectorAll(".dd button").forEach((b) => b.classList.toggle("sel", b === el)); closeDropdowns(); box.querySelector(".select").focus(); input.dispatchEvent(new Event("change", { bubbles: true })); }, sel: (d, el) => { el.checked ? selSet().add(Number(d.id)) : selSet().delete(Number(d.id)); draw(); }, "sel-all": (d, el) => { S.sel = new Set(el.checked ? S.selectable : []); draw(); }, "sel-clear": () => { S.sel = new Set(); draw(); }, "bulk-del": () => { const c = BULK[screenKey()], ids = [...selSet()]; if (ids.length && confirm(`Удалить выбранное: ${ids.length} ${plural(ids.length, ...c.forms)}?`)) return bulk("Удаление", ids, c.label, (id) => api(c.url(id), { method: "DELETE" }), c.after); }, "bulk-access": (d) => bulk(d.value === "1" ? "Разрешение доступа" : "Отключение доступа", [...selSet()], BULK.users.label, (id) => api(`/users/${id}`, { method: "PATCH", body: { is_active: d.value === "1" } })), "pfx-open": (d) => { location.hash = `#/prefixes/${d.id}`; }, "org-open": (d) => { store.orgId = Number(d.id); location.hash = "#/prefixes"; }, "isp-edit": (d) => ispDialog(rowById(d.id)), "dev-edit": (d) => deviceDialog(rowById(d.id)), "user-edit": (d) => userDialog(rowById(d.id)), "adr-edit": (d) => addressDialog(S.page.items.find((x) => x.id === Number(d.id))), "adr-assign": (d) => addressDialog(null, d.ip), "jr-open": (d) => entryDialog(S.entries.find((r) => r.uid === d.uid)), "jr-refresh": () => draw(), "jr-settings": () => journalSettingsDialog(), "jr-clear": () => journalClearDialog(), "jr-clear-do": doClear, copy: (d) => copyText(d.text), "close-dialog": closeDialog, overlay: (d, el, e) => { if (e.target === el) closeDialog(); }, "submit-form": submitDialog, "open-org": (d) => { store.orgId = Number(d.id); }, "org-new": () => orgDialog(null), "users-new": () => userDialog(null), "pw-change": () => passwordDialog(), "isp-new": () => ispDialog(null), "dev-new": () => deviceDialog(null), "pfx-new": () => prefixDialog(null), "pfx-edit": () => prefixDialog(S.prefix), "adr-new": () => addressDialog(null), types: () => typesDialog(), vrfs: () => vrfsDialog(), "vrf-done": async () => { closeDialog(); S.vrfEdit = null; await draw(); }, "vrf-add": () => guarded(async () => { const name = $("#new-vrf").value.trim(); if (!name) return toast("Введите название VRF", true); await api("/vrfs", { method: "POST", body: { organization_id: store.orgId, name, route_target: $("#new-rt").value.trim() } }); await vrfsDialog(); }), "vrf-edit": (d) => { S.vrfEdit = Number(d.id); vrfsDialog(); }, "vrf-cancel": () => { S.vrfEdit = null; vrfsDialog(); }, "vrf-save": (d) => guarded(async () => { await api(`/vrfs/${d.id}`, { method: "PATCH", body: { name: $("#edit-name").value.trim(), route_target: $("#edit-rt").value.trim() } }); S.vrfEdit = null; await vrfsDialog(); }), "vrf-del": (d) => confirmDel("VRF") && guarded(async () => { await api(`/vrfs/${d.id}`, { method: "DELETE" }); await vrfsDialog(); }), "type-add": () => guarded(async () => { const name = $("#new-type").value.trim(); if (!name) return toast("Введите название типа", true); await api("/device-types", { method: "POST", body: { name } }); await typesDialog(); }), "type-edit": (d) => { S.typeEdit = Number(d.id); typesDialog(); }, "type-cancel": () => { S.typeEdit = null; typesDialog(); }, "type-save": (d) => guarded(async () => { await api(`/device-types/${d.id}`, { method: "PATCH", body: { name: $("#edit-name").value.trim() } }); S.typeEdit = null; await typesDialog(); }), "type-del": (d) => confirmDel("тип") && guarded(async () => { await api(`/device-types/${d.id}`, { method: "DELETE" }); await typesDialog(); }), }; const closeDropdowns = () => document.querySelectorAll(".dd:not([hidden])").forEach((p) => { p.hidden = true; }); document.addEventListener("click", (e) => { if (!e.target.closest(".cselect")) closeDropdowns(); const el = e.target.closest("[data-action]"); if (!el) { if (menu) { menu = null; draw(); } return; } const a = el.dataset.action; if (el.dataset.row) { // действие всей строки: не мешаем ссылкам, кнопкам, меню, Ctrl/Shift-клику и выделению текста if (e.target.closest("a[href], button, input, label, select, textarea, .pop")) return; if (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey || String(window.getSelection?.() ?? "")) return; if (menu) { menu = null; draw(); return; } // открытое меню: клик лишь закрывает его } if (menu && a !== "menu" && a !== "pick") { menu = null; if (!actions[a]) draw(); } if (el.tagName === "A" && el.getAttribute("href") === "#") e.preventDefault(); if (actions[a]) actions[a](el.dataset, el, e); }); let timer; document.addEventListener("input", (e) => { if (e.target.id === "clear-pw") { $("#clear-go").disabled = !e.target.value; return; } if (e.target.dataset.input !== "q") return; clearTimeout(timer); timer = setTimeout(() => { S.q = e.target.value; S.limit = 100; draw(); }, 250); }); document.addEventListener("change", (e) => { if (e.target.id === "jfrom" || e.target.id === "jto") { S[e.target.id] = e.target.value; S.limit = 100; draw(); return; } if (e.target.name === "length" && S.subnetRefresh) { S.subnetRefresh(); return; } const hint = $("#vrf-hint"); if (hint && e.target.name === "vrf_id") hint.hidden = e.target.value === S.vrfHint?.original; }); document.addEventListener("keydown", (e) => { if (e.key === "Escape" && document.querySelector(".dd:not([hidden])")) { closeDropdowns(); e.stopPropagation(); return; } if ((e.key === "ArrowDown" || e.key === "ArrowUp") && e.target.closest(".cselect")) { e.preventDefault(); const box = e.target.closest(".cselect"), pop = box.querySelector(".dd"); if (pop.hidden) { closeDropdowns(); pop.hidden = false; (pop.querySelector(".sel") || pop.querySelector("button")).focus(); return; } const items = [...pop.querySelectorAll("button")], i = items.indexOf(document.activeElement); items[Math.max(0, Math.min(items.length - 1, i + (e.key === "ArrowDown" ? 1 : -1)))].focus(); return; } if (e.key === "Escape") { if ($("#modal-root").innerHTML) closeDialog(); else if (menu) { menu = null; draw(); } } if (e.key === "Enter" && e.target.id === "clear-pw") { e.preventDefault(); doClear(); return; } if (e.key === "Enter" && e.target.dataset?.action === "jr-open") { e.preventDefault(); actions["jr-open"](e.target.dataset); return; } if (e.key === "Enter" && e.target.closest("#dlg-form") && e.target.tagName !== "TEXTAREA") { e.preventDefault(); submitDialog(); } }); document.addEventListener("submit", async (e) => { if (e.target.id !== "login-form") return; e.preventDefault(); const f = new FormData(e.target); try { const { access_token } = await api("/auth/login", { method: "POST", body: { username: f.get("username"), password: f.get("password") } }); store.token = access_token; user = null; current = ""; location.hash = "#/overview"; draw(); } catch (err) { $("#app").innerHTML = loginScreen(err.message); } }); draw();