IPAM Manager: API, UI-админка, журнал аудита

Backend (FastAPI, SQLAlchemy 2, Alembic, PostgreSQL 16):
- организации, VRF, префиксы (дерево, использование, автоназначение),
  адреса, операторы связи, устройства и типы устройств; JWT, роли admin/viewer;
- VRF принадлежит организации (составной FK), смена VRF у префикса
  переносит поддерево, имя VRF уникально в организации;
- журнал аудита: поиск и фильтры, ротация (срок/количество), очистка
  по паролю с блокировкой, IP клиента и метаданные запроса
  (X-Forwarded-For только от TRUSTED_PROXIES).

UI (web/, без сборки): экраны и диалоги по макетам «IPAM Manager»,
кликабельные строки реестров, локальные шрифты IBM Plex,
собственные выпадающие списки.

Окружение: docker-compose (postgres + app), миграции Alembic 0001-0004,
scripts/gen_env.py, scripts/seed_demo.py, 11 автотестов (pytest).
Документация: README.md и docs/changes/001-005 (планы и итоги).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
ayurishchevandClaude Sonnet 5 committed 2026-09-20 12:27:47 +03:00
commit a846d30872
64 files changed
+4194

No files matched your search

+833
View File
@@ -0,0 +1,833 @@
// 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);
function popMenu(id, items, extra = "") {
if (menu?.id !== id) return "";
return `<div class="pop ${extra}" role="menu">${items.map((it) => `<button type="button" role="menuitem" class="${it.cls || ""}" data-action="pick" data-menu="${id}" data-value="${esc(it.value)}">${esc(it.label)}</button>`).join("")}</div>`;
}
function filterBtn(id, label, items, value, width = 180) {
const cur = items.find((i) => String(i.value) === String(value));
return `<span class="rel"><button type="button" class="btn filter-btn" style="width:${width}px" data-action="menu" data-menu="${id}">${esc(label)}: ${esc(cur?.label ?? "любой")}${I.chevD(14)}</button>${popMenu(id, items.map((i) => ({ ...i, cls: String(i.value) === String(value) ? "sel" : "" })), "")}</span>`;
}
function orgSwitcher() {
const o = currentOrg();
return `<span class="rel" style="flex:none"><button type="button" class="org-btn" data-action="menu" data-menu="org">${I.org()}<span class="lbl">Организация:</span><span class="val">${esc(o?.name ?? "—")}</span>${I.chevD(14)}</button>${popMenu("org", orgs.map((x) => ({ label: x.name, value: x.id, cls: x.id === store.orgId ? "sel" : "" })))}</span><div class="vsep"></div>`;
}
const crumbsOrg = () => `<div class="crumbs"><a href="#/orgs" style="color:var(--muted)">Организации</a>${I.chevR(14)}<span class="cur">${esc(currentOrg()?.name ?? "")}</span></div>`;
const header = (title, sub, actions = "") => `<div class="head"><div><h1>${esc(title)}</h1><div class="sub">${sub}</div></div><div class="actions">${actions}</div></div>`;
const NAV = [["overview", "Обзор"], ["prefixes", "Префиксы"], ["orgs", "Организации"], ["isps", "Операторы"], ["devices", "Устройства"], ["journal", "Журнал"]];
function shell(active, content) {
return `<div class="appbar"><div class="brand"><span class="brand-logo">${I.globe()}</span>ipam_manager</div>
<nav class="nav">${NAV.map(([k, l]) => `<a href="#/${k}" class="${k === active ? "active" : ""}">${l}</a>`).join("")}</nav>
<div class="grow"></div><span class="user">${esc(user?.username ?? "")}</span>${btn("Выйти", "logout", { cls: "ghost" })}</div>
<div class="page">${content}</div>`;
}
/* ------------------------------------------------------------------ dialogs */
function openDialog({ title, width = 560, note = "", body, foot }) {
$("#modal-root").innerHTML = `<div class="overlay" data-action="overlay"><div class="dialog" style="width:${width}px" role="dialog" aria-modal="true" aria-label="${esc(title)}">
<div class="dialog-head"><h2>${esc(title)}</h2>${iconBtn(I.close(), "close-dialog", "Закрыть")}</div>${note ? `<div class="dialog-note">${note}</div>` : ""}${body}${foot}</div></div>`;
$("#dlg-form .input, #dlg-form .select")?.focus();
}
const closeDialog = () => { $("#modal-root").innerHTML = ""; S.dialog = null; };
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", "изменён"] };
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;
const rows = items.map((o, i) => `<div class="tr hoverable clickable${i === items.length - 1 ? " last" : ""}" data-row="1" data-action="org-open" data-id="${o.id}" style="--cols:minmax(200px,1.5fr) 110px 130px minmax(200px,1.5fr) 80px 80px 44px">
<a href="#/prefixes" data-action="open-org" data-id="${o.id}" style="font-weight:500;color:var(--text)">${esc(o.name)}</a><span class="muted">${esc(o.short_name)}</span><span class="mono muted" style="font-size:13px">${esc(o.inn)}</span>
<span class="muted ell">${esc(o.address)}</span><a href="#/prefixes" data-action="open-org" data-id="${o.id}" style="font:500 13px var(--sans);text-align:center">${o.prefixes_count}</a><span style="font-size:13px;text-align:center" class="muted">${fmtNum(o.addresses_count)}</span>
<span class="cell-actions rel">${iconBtn(I.dots(), "menu", "Действия", `data-menu="org-row-${o.id}"`)}${popMenu("org-row-" + o.id, [{ label: "Открыть префиксы", value: "open:" + o.id }, { label: "Редактировать", value: "edit:" + o.id }, { label: "Удалить", value: "del:" + o.id, cls: "danger" }], "row-pop")}</span></div>`).join("");
S.rows = items;
return shell("orgs", `${header("Организации", `${all} ${plural(all, "организация", "организации", "организаций")}`, btn("Добавить организацию", "org-new", { cls: "primary", icon: I.plus() }))}
<div class="card"><div class="filters">${searchBox(q, "Поиск: название, ИНН, адрес", 300)}</div>
<div class="tr th" style="--cols:minmax(200px,1.5fr) 110px 130px minmax(200px,1.5fr) 80px 80px 44px"><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 = "minmax(130px,1fr) minmax(160px,1.2fr) minmax(180px,1.4fr) 150px 140px minmax(120px,1fr) 44px";
const rows = items.map((i, n) => `<div class="tr hoverable clickable${n === items.length - 1 ? " last" : ""}" data-row="1" data-action="isp-edit" data-id="${i.id}" style="--cols:${cols}"><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>
<div class="tr th" style="--cols:${cols}"><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 = "minmax(200px,1.6fr) 170px 110px minmax(220px,1.8fr) 44px";
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" : ""}" data-row="1" data-action="dev-edit" data-id="${d.id}" style="--cols:${cols}"><span class="m13" style="font-weight:600;${dep ? "color:var(--faint);text-decoration:line-through" : ""}">${esc(d.name)}</span>
<span class="${dep ? "" : "muted"}" style="${dep ? "color:var(--faint)" : ""}">${esc(d.device_type_name)}</span>${ips}<span class="ell" style="color:${dep ? "var(--faint)" : "var(--muted)"}">${esc(d.note)}</span>
<span class="cell-actions rel">${iconBtn(I.dots(), "menu", "Действия", `data-menu="dev-row-${d.id}"`)}${popMenu("dev-row-" + d.id, [{ label: "Редактировать", value: "edit:" + d.id }, { label: "Удалить", value: "del:" + d.id, cls: "danger" }], "row-pop")}</span></div>`;
}).join("");
return shell("devices", `${crumbsOrg()}${header("Устройства", `${list.total} ${plural(list.total, "устройство", "устройства", "устройств")} · организация: ${esc(org?.name ?? "")}`, btn("Добавить устройство", "dev-new", { cls: "primary", icon: I.plus() }))}
<div class="right-link"><a href="#" data-action="types">${I.gear()}Управление типами</a></div>
<div class="card"><div class="filters">${orgSwitcher()}${searchBox(q, "Поиск: имя, IP, заметка", 260)}${filterBtn("type", "Тип", typeItems, S.type ?? "", 170)}</div>
<div class="tr th" style="--cols:${cols}"><span>Устройство</span><span>Тип</span><span>IP-адресов</span><span>Заметки</span><span></span></div>
${rows || '<div class="empty">Устройств нет</div>'}<div class="foot">Показано ${list.items.length} из ${list.total} ${plural(list.total, "устройства", "устройств", "устройств")}</div></div>`);
};
function deviceDialog(d) {
const edit = !!d;
S.dialog = async (v) => {
const body = edit ? { name: v.name, device_type_id: Number(v.device_type_id), mac: v.mac, note: v.note }
: { ...v, device_type_id: Number(v.device_type_id), organization_id: Number(v.organization_id) };
await api(edit ? `/devices/${d.id}` : "/devices", { method: edit ? "PATCH" : "POST", body });
toast(edit ? "Устройство сохранено" : "Устройство добавлено");
await draw();
};
openDialog({
title: edit ? "Редактирование устройства" : "Новое устройство",
body: formBody(`${fInput("name", "Название (hostname)", { value: d?.name, ph: "host.internal" })}
<div class="grid2">${fSelect("device_type_id", "Тип", S.types.map((t) => ({ value: t.id, label: t.name })), d?.device_type_id)}${fInput("mac", "MAC-адрес", { value: d?.mac, ph: "00:1A:2B:3C:4D:5E", optional: true })}</div>
${edit ? "" : fSelect("organization_id", "Организация", orgs.map((o) => ({ value: o.id, label: o.name })), store.orgId)}
${fArea("note", "Заметки", { value: d?.note })}
<div class="info">IP-адрес назначается отдельно — на экране «Адреса» через «Назначить адрес» и поле «Связано с устройством».</div>`),
foot: dlgFoot(edit ? "Сохранить" : "Добавить устройство"),
});
}
async function typesDialog() {
const { items } = await api("/device-types");
S.types = items;
const cols = "1fr 110px 84px";
const rows = items.map((t) => {
const editing = S.typeEdit === t.id;
const name = editing
? `<input class="input" id="edit-name" value="${esc(t.name)}" style="height:32px">`
: `<span style="font-weight:600;${t.is_default ? "color:var(--faint)" : ""}">${esc(t.name)}</span>`;
const locked = t.devices_count > 0;
const acts = t.is_default
? `<span class="muted" style="font-size:12px">по умолчанию</span>`
: editing
? `${iconBtn(I.edit(14), "type-save", "Сохранить", `data-id="${t.id}"`, true)}${iconBtn(I.close(), "type-cancel", "Отмена", "", true)}`
: `${iconBtn(I.edit(14), "type-edit", "Переименовать", `data-id="${t.id}"`, true)}${iconBtn(I.trash(14), "type-del", locked ? "Нельзя удалить: используется устройствами" : "Удалить", `data-id="${t.id}"`, true, locked)}`;
return `<div class="tr" style="--cols:${cols};--h:48px">${name}<span class="muted">${t.devices_count}</span><span style="display:flex;justify-content:flex-end;align-items:center;gap:4px">${acts}</span></div>`;
}).join("");
openDialog({
title: "Управление типами устройств", width: 640,
note: "Типы используются в фильтре и в поле «Тип» при добавлении устройства. Общий список для всех организаций.",
body: `<div class="mini-table"><div class="tr th" style="--cols:${cols}"><span>Название</span><span>Устройств</span><span></span></div>${rows}</div>
<div class="add-row"><input class="input" id="new-type" placeholder="Название нового типа"><button type="button" class="btn outline-primary" data-action="type-add">${I.plus()}Добавить</button></div>`,
foot: `<div class="dialog-foot">${btn("Готово", "close-dialog", { cls: "primary" })}</div>`,
});
}
// ---- prefixes
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([
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 = "44px minmax(200px,1.8fr) 1.4fr 100px 100px 200px 80px 44px";
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" : ""}" data-row="1" data-action="${leaf ? "pfx-open" : "toggle"}" data-id="${p.id}" style="--cols:${cols}">${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 ? iconBtn(I.dots(), "menu", "Действия", `data-menu="pfx-row-${p.id}"`) + popMenu("pfx-row-" + p.id, [{ label: "Открыть адреса", value: "open:" + p.id }, { label: "Редактировать", value: "edit:" + p.id }, { 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" }];
return shell("prefixes", `${crumbsOrg()}${header("Префиксы", `${all.length} ${plural(all.length, "префикс", "префикса", "префиксов")} · ${vl.total} VRF · организация: ${esc(org.name)}`, btn("Добавить префикс", "pfx-new", { cls: "primary", icon: I.plus() }))}
<div class="tabs">${tab(0, "Все", all.length)}${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>
<div class="card"><div class="filters">${orgSwitcher()}${searchBox(S.q || "", "Поиск: префикс, описание", 280)}${filterBtn("status", "Статус", statusItems, S.status ?? "")}${filterBtn("family", "Семейство", famItems, S.family ?? "")}</div>
<div class="tr th" style="--cols:${cols};--h:40px"><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} из ${all.length} ${plural(all.length, "префикса", "префиксов", "префиксов")}</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 ? "Сохранить" : "Добавить префикс"),
});
}
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 } }),
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 = "minmax(140px,1fr) 1.2fr 1.6fr 120px 160px 44px";
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" : ""}" data-row="1" data-action="${free ? "adr-assign" : "adr-edit"}" data-id="${a.id ?? ""}" data-ip="${esc(a.address)}" style="--cols:${cols};--h:52px"><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>
<div class="tr th" style="--cols:${cols};--h:40px"><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: "Оператор", session: "Сессия", journal: "Журнал" };
const eventBadge = (ev) => {
const [entity, action] = ev.split(".");
const cls = { created: "blue", assigned: "green", updated: "amber", deleted: "red", failed: "red" }[action] || "";
return badge(cls, ev);
};
const fmtDateSec = (iso) => (iso ? iso.replace("T", " ").slice(0, 19) : "—");
const isAdmin = () => user?.role === "admin";
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);
}
}
// ---- 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();
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));
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 === "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(); });
},
};
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 {
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 }));
},
"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)),
"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),
"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; }
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();
+93
View File
@@ -0,0 +1,93 @@
Copyright 2019 IBM Corp. All rights reserved. IBMPlexSans-Italic[wdth,wght].ttf: Copyright 2019 IBM Corp. All rights reserved.
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+11
View File
@@ -0,0 +1,11 @@
/* IBM Plex (SIL OFL 1.1, см. OFL.txt) — локальные файлы, без обращения к внешним сервисам */
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:400;font-display:swap;src:url(ibm-plex-sans-cyrillic-400.woff2) format('woff2');unicode-range:U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116}
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:400;font-display:swap;src:url(ibm-plex-sans-latin-400.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:500;font-display:swap;src:url(ibm-plex-sans-cyrillic-500.woff2) format('woff2');unicode-range:U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116}
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:500;font-display:swap;src:url(ibm-plex-sans-latin-500.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:600;font-display:swap;src:url(ibm-plex-sans-cyrillic-600.woff2) format('woff2');unicode-range:U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116}
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:600;font-display:swap;src:url(ibm-plex-sans-latin-600.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:400;font-display:swap;src:url(ibm-plex-mono-cyrillic-400.woff2) format('woff2');unicode-range:U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116}
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:400;font-display:swap;src:url(ibm-plex-mono-latin-400.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:500;font-display:swap;src:url(ibm-plex-mono-cyrillic-500.woff2) format('woff2');unicode-range:U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116}
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:500;font-display:swap;src:url(ibm-plex-mono-latin-500.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>IPAM Manager</title>
<link rel="stylesheet" href="fonts/fonts.css">
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div id="app"></div>
<div id="modal-root"></div>
<div id="toast-root"></div>
<script type="module" src="app.js"></script>
</body>
</html>
+184
View File
@@ -0,0 +1,184 @@
:root{--bg:#f4f6f9;--card:#fff;--line:#dde3ea;--line-strong:#c5ced9;--text:#18212f;--muted:#58657a;--faint:#8a95a5;--primary:#2450c8;--head:#f8fafc;--hover:#f3f7ff;
--sans:'IBM Plex Sans',system-ui,sans-serif;--mono:'IBM Plex Mono',ui-monospace,monospace}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);font-family:var(--sans);color:var(--text)}
a{color:var(--primary);text-decoration:none}
h1,h2{margin:0}
button{font-family:var(--sans)}
/* app bar */
.appbar{height:56px;background:#fff;border-bottom:1px solid var(--line);display:flex;align-items:center;padding:0 24px;gap:32px}
.brand{display:flex;align-items:center;gap:10px;font:600 16px/1 var(--sans)}
.brand-logo{width:28px;height:28px;border-radius:8px;background:var(--primary);display:flex;align-items:center;justify-content:center}
.nav{display:flex;height:100%;gap:4px}
.nav a{display:flex;align-items:center;height:100%;padding:0 14px;font:500 14px/1 var(--sans);color:var(--muted);border-bottom:2px solid transparent}
.nav a.active{color:var(--text);border-bottom-color:var(--primary)}
.grow{flex:1}
.appbar .user{color:var(--muted);font:400 14px/1 var(--sans)}
/* buttons */
.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;height:36px;margin:0;border-radius:8px;font:500 14px/1 var(--sans);white-space:nowrap;cursor:pointer;padding:0 14px;background:#fff;color:var(--text);border:1px solid var(--line-strong)}
.btn.primary{background:var(--primary);color:#fff;border-color:var(--primary)}
.btn.outline-primary{background:#fff;color:var(--primary);border-color:var(--primary)}
.btn.ghost{background:transparent;color:var(--muted);border-color:transparent;padding:0 10px}
.btn.danger{color:#a32020}
.btn:disabled{opacity:.6;cursor:default}
.icon-btn{display:inline-flex;align-items:center;justify-content:center;width:36px;height:36px;margin:0;border-radius:8px;cursor:pointer;background:transparent;color:var(--muted);border:1px solid transparent;padding:0}
.icon-btn:hover{background:#eef1f5}
.icon-btn.sm{width:28px;height:28px;border-radius:6px}
.icon-btn:disabled{color:var(--line-strong);cursor:default;background:transparent}
svg{flex:none}
/* layout */
.page{padding:0 24px}
.crumbs{display:flex;align-items:center;gap:6px;padding:16px 0 0;font:500 13px/1 var(--sans);color:var(--muted)}
.crumbs .cur{font-weight:600;color:var(--text)}
.head{display:flex;align-items:flex-end;justify-content:space-between;margin:24px 0 16px}
.crumbs + .head{margin:8px 0 16px}
.head h1{font:600 24px/1.2 var(--sans);color:var(--text)}
.head .sub{margin-top:4px;font:400 14px/1.4 var(--sans);color:var(--muted)}
.head .actions{display:flex;gap:8px}
.card{background:#fff;border:1px solid var(--line);border-radius:12px}
.right-link{display:flex;justify-content:flex-end;margin-bottom:12px}
.right-link a,.link-muted{display:flex;align-items:center;gap:6px;font:500 13px/1 var(--sans);color:var(--muted);cursor:pointer;background:none;border:0;padding:0}
/* tabs */
.tabs{display:flex;align-items:center;gap:24px;border-bottom:1px solid var(--line);margin-bottom:16px}
.tab{padding:10px 2px 12px;margin-bottom:-1px;font:500 14px/1 var(--sans);color:var(--muted);border-bottom:2px solid transparent;cursor:pointer;background:none;border-top:0;border-left:0;border-right:0}
.tab.active{font-weight:600;color:var(--text);border-bottom-color:var(--primary)}
.tab .n{font-weight:400;color:var(--muted);margin-left:2px}
.tabs .link-muted{padding:10px 2px 12px;margin-bottom:-1px}
/* filters */
.filters{display:flex;align-items:center;gap:8px;height:64px;padding:0 16px;position:relative}
.vsep{width:1px;height:24px;background:var(--line);flex:none}
.search{position:relative;display:inline-flex;align-items:center;flex:none}
.search .ico{position:absolute;left:12px;color:var(--muted);display:flex}
.search input{width:100%;height:36px;padding:0 12px 0 36px;border:1px solid var(--line-strong);border-radius:8px;background:#fff;color:var(--text);font:400 14px/1 var(--sans);margin:0}
.filter-btn{justify-content:space-between;width:180px}
.org-btn{display:inline-flex;align-items:center;gap:8px;height:36px;border-radius:8px;font:500 14px/1 var(--sans);cursor:pointer;background:#f3f7ff;color:var(--text);border:1px solid var(--primary);padding:0 12px;flex:none;min-width:260px}
.org-btn .lbl{color:var(--muted);font-weight:400}
.org-btn .val{font-weight:600;flex:1;text-align:left;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.pop{position:absolute;top:52px;z-index:20;min-width:220px;max-height:320px;overflow:auto;background:#fff;border:1px solid var(--line);border-radius:8px;box-shadow:0 8px 24px rgba(20,33,60,.16);padding:4px}
.pop button{display:block;width:100%;text-align:left;height:36px;padding:0 12px;border:0;background:none;border-radius:6px;font:400 14px/1 var(--sans);color:var(--text);cursor:pointer;white-space:nowrap}
.pop button:hover{background:var(--hover)}
.pop button.sel{font-weight:600}
.pop button.danger{color:#a32020}
.row-pop{top:40px;right:0;min-width:180px}
.rel{position:relative}
/* table */
.tr{display:grid;grid-template-columns:var(--cols);column-gap:12px;align-items:center;height:var(--h,56px);padding:0 16px;border-bottom:1px solid var(--line);font:400 14px/1.3 var(--sans)}
.tr.th{height:40px;background:var(--head);border-top:1px solid var(--line);color:var(--muted)}
.tr.th span{font:600 12px/1 var(--sans);color:var(--muted)}
.tr.hoverable:hover{background:var(--hover)}
.tr.clickable{cursor:pointer}
.tr.child{background:#fbfcfd}
.tr.free{background:var(--head)}
.tr.free span,.tr.free .mono{color:var(--faint)}
.tr.last{border-bottom:0}
.foot{display:flex;align-items:center;height:44px;padding:0 16px;font:400 13px/1 var(--sans);color:var(--muted)}
.foot a{margin-left:4px;cursor:pointer}
.empty{padding:32px 16px;text-align:center;color:var(--muted);font-size:14px}
.mono{font-family:var(--mono)}
.m13{font:500 13px/1 var(--mono)}
.muted{color:var(--muted)}
.ell{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.cell-actions{display:flex;justify-content:flex-end}
.chev{display:flex;justify-content:center}
.chev button{background:none;border:0;cursor:pointer;color:var(--muted);display:flex;padding:0}
.chev svg{transition:transform .12s}
.chev.open svg{transform:rotate(90deg)}
.bar{display:flex;align-items:center;gap:8px}
.bar .track{flex:1;height:4px;border-radius:2px;background:var(--line)}
.bar .fill{height:100%;border-radius:2px}
.bar .pct{font:400 12px/1 var(--sans);color:var(--muted);white-space:nowrap}
/* badges */
.badge{display:inline-flex;align-items:center;gap:4px;height:22px;padding:0 8px;border-radius:6px;font:500 12px/1 var(--sans);background:#eef1f5;color:#3c4859}
.badge.green{background:#e3f4ea;color:#14683a}
.badge.blue{background:#eaf0ff;color:#1e449e}
.badge.red{background:#fbe6e6;color:#a32020}
.badge.amber{background:#fdf0d5;color:#7c4a00}
.more{margin-left:4px;display:inline-flex;align-items:center;height:18px;padding:0 5px;border-radius:4px;background:#eaf0ff;color:#1e449e;font:500 11px/1 var(--sans)}
/* overview */
.stats{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:16px;margin-bottom:24px}
.stat{padding:20px 24px}
.stat .l{font:400 13px/1.3 var(--sans);color:var(--muted);margin-bottom:8px}
.stat .v{font:600 32px/1 var(--sans)}
.stat .s{margin-top:6px;font:400 13px/1.3 var(--sans);color:var(--muted)}
.stat .track{margin-top:8px;height:6px;border-radius:3px;background:var(--line)}
.stat .track div{height:100%;border-radius:3px;background:var(--primary)}
.two{display:grid;grid-template-columns:1fr 1fr;gap:16px}
.card-title{padding:16px 16px 12px;font:600 16px/1.2 var(--sans)}
/* address usage */
.usage{padding:16px;margin-bottom:16px;display:flex;align-items:center;gap:16px}
.usage .track{flex:1;height:8px;border-radius:4px;background:var(--line);overflow:hidden;display:flex}
.legend{display:flex;gap:20px;font:400 13px/1 var(--sans)}
.legend span{display:flex;align-items:center;gap:6px}
.legend i{width:8px;height:8px;border-radius:2px;display:inline-block}
/* dialogs */
.overlay{position:fixed;inset:0;background:rgba(24,33,47,.45);display:flex;align-items:flex-start;justify-content:center;padding-top:96px;overflow:auto;z-index:50}
.dialog{box-sizing:content-box;background:#fff;border:1px solid var(--line);border-radius:12px;box-shadow:0 16px 48px rgba(20,33,60,.25);flex:none;margin-bottom:40px}
.dialog-head{display:flex;align-items:center;justify-content:space-between;padding:16px 16px 0 24px}
.dialog-head h2{font:600 18px/1.2 var(--sans)}
.dialog-note{padding:4px 24px 0;font:400 13px/1.4 var(--sans);color:var(--muted)}
.dialog-body{display:flex;flex-direction:column;gap:16px;padding:16px 24px 24px}
.dialog-foot{display:flex;align-items:center;justify-content:flex-end;gap:8px;padding:16px 24px;border-top:1px solid var(--line)}
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:12px}
.field{display:flex;flex-direction:column;gap:6px;font:500 13px/1.3 var(--sans)}
.field .opt{font-weight:400;color:var(--muted)}
.input,.select,.textarea{width:100%;height:36px;margin:0;padding:0 12px;border:1px solid var(--line-strong);border-radius:8px;background:#fff;color:var(--text);font:400 14px/1 var(--sans)}
.input.mono,.textarea.mono{font-family:var(--mono)}
.input.mono{font-size:14px}
.select{appearance:none;font-weight:500;padding:0 34px 0 14px;background:#fff url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%2318212f' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E") no-repeat right 14px center;cursor:pointer}
.textarea{height:56px;padding:10px 12px;line-height:1.4;resize:vertical}
.input.bad,.select.bad,.textarea.bad{border-color:#c62828}
.info{padding:10px 12px;border-radius:8px;background:#eaf0ff;color:#1e449e;font:400 13px/1.4 var(--sans)}
.err-banner{padding:10px 12px;border-radius:8px;background:#fbe6e6;color:#a32020;font:400 13px/1.4 var(--sans)}
.check{display:flex;align-items:flex-start;gap:10px;font:500 14px/1.3 var(--sans)}
.check input{width:16px;height:16px;margin:2px 0 0;accent-color:var(--primary)}
.check small{display:block;font:400 12px/1.3 var(--sans);color:var(--muted)}
.mini-table{border:1px solid var(--line);border-radius:8px;overflow:hidden;margin:16px 24px 8px}
.mini-table .tr{height:48px;padding:0 12px}
.mini-table .tr.th{height:36px;border-top:0}
.mini-table .tr:last-child{border-bottom:0}
.add-row{display:flex;align-items:center;gap:8px;padding:0 24px 20px}
.add-row .input{flex:1}
/* misc */
#toast-root{position:fixed;bottom:16px;right:16px;pointer-events:none;z-index:100;display:flex;flex-direction:column;gap:8px}
.toast{background:#18212f;color:#fff;padding:10px 14px;border-radius:8px;font:400 14px/1.3 var(--sans);box-shadow:0 8px 24px rgba(20,33,60,.25)}
.toast.err{background:#a32020}
.login{max-width:380px;margin:120px auto 0;padding:32px}
.login h1{font:600 22px/1.2 var(--sans);margin:20px 0 4px}
.login .field{margin-top:16px}
.login .btn{width:100%;margin-top:20px}
.input:focus,.select:focus,.textarea:focus,.search input:focus{outline:none;border-color:var(--primary);box-shadow:0 0 0 3px rgba(36,80,200,.15)}
.btn:focus-visible,.icon-btn:focus-visible,.tab:focus-visible{outline:2px solid var(--primary);outline-offset:2px}
/* выпадающие списки в диалогах */
.cselect{position:relative}
button.select{display:block;text-align:left;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
button.select.ph{color:var(--faint)}
.pop.dd{top:40px;left:0;right:0;min-width:0;max-height:240px}
.pop.dd[hidden]{display:none}
.pop button.sel{background:var(--hover)}
/* журнал */
.datebox{position:relative;display:inline-flex;align-items:center;gap:8px;height:36px;padding:0 12px;width:170px;border:1px solid var(--line-strong);border-radius:8px;background:#fff;font:400 14px/1 var(--sans);flex:none}
.datebox input{flex:1;min-width:0;border:0;padding:0;background:transparent;color:var(--text);font:400 13px/1 var(--mono);outline:none}
.datebox input::-webkit-calendar-picker-indicator{position:absolute;inset:0;width:100%;height:100%;opacity:0;cursor:pointer}
.datebox .cal{display:flex;color:var(--muted);pointer-events:none}
.datebox:focus-within{border-color:var(--primary);box-shadow:0 0 0 3px rgba(36,80,200,.15)}
.kv{display:grid;grid-template-columns:130px 1fr;gap:12px;align-items:center;min-height:32px;font:400 14px/1.4 var(--sans)}
.json{margin:0;padding:12px;border-radius:8px;background:var(--head);border:1px solid var(--line);font:400 13px/1.2 var(--mono);white-space:pre-wrap;overflow-wrap:anywhere}
.statbox{padding:12px;border-radius:8px;background:var(--head);border:1px solid var(--line);font:400 13px/1.5 var(--sans);color:var(--muted)}
.statbox b{color:var(--text);font-weight:600}
.warn{padding:10px 12px;border-radius:8px;background:#fdf0d5;color:#7c4a00;font:400 13px/1.4 var(--sans)}
.btn.danger:disabled{background:#f4f6f9;color:var(--faint);border-color:var(--line);cursor:not-allowed;opacity:1}
.tr.clickable:focus-visible{outline:2px solid var(--primary);outline-offset:-2px}