Задачи 006-010: пользователи, исправление удаления, журнал отказов, групповые операции, автовыделение префиксов
006 Пользователи: API /users (CRUD, смена своего пароля), раздел UI «Пользователи»,
события журнала user.*, защита от отключения/удаления себя и последнего админа.
007 Исправление удаления организации: VRF удаляются явным DELETE до организации
(без relationship() порядок DELETE не гарантирован → ложный 409).
008 Журнал фиксирует отказы в удалении (<entity>.delete_blocked) со списком
мешающих объектов в «Данных»: организация, VRF, тип устройства, префикс, пользователь.
009 Выбор строк чекбоксами и групповые операции в UI (удаление, смена типа устройств,
статус префиксов и адресов, доступ пользователей); цикл запросов из UI, итог и список отказов.
010 Автовыделение следующего вложенного префикса: POST/GET /prefixes/{id}/subnets/next,
первый свободный выровненный блок; пункт «Добавить вложенный (авто)» в меню префикса.
Тесты: 14 (добавлены сценарии для 006, 007/008, 010); исправлена нестабильность
тестов журнала (IPv6-группы с ведущими нулями нормализуются PostgreSQL).
Документация: README.md, docs/changes/006-010 (планы и итоги).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a846d30872
commit
cd09ef0805
23 files changed
+988
-49
No files matched your search
+230
-23
@@ -87,6 +87,27 @@ const iconBtn = (icon, action, label, data = "", sm = false, disabled = false) =
|
||||
const searchBox = (value, placeholder, width) =>
|
||||
`<label class="search" style="width:${width}px"><span class="ico">${I.search()}</span><input type="search" id="q" data-input="q" placeholder="${esc(placeholder)}" aria-label="${esc(placeholder)}" value="${esc(value)}"></label>`;
|
||||
const currentOrg = () => orgs.find((o) => o.id === store.orgId);
|
||||
const isAdmin = () => user?.role === "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()
|
||||
? `<label class="sel-cell"><input type="checkbox" id="sel-${id}" data-action="sel" data-id="${id}" aria-label="Выбрать" ${selSet().has(id) ? "checked" : ""} ${disabled ? "disabled" : ""}></label>` : "");
|
||||
const selBlank = () => (isAdmin() ? "<span></span>" : "");
|
||||
const selAllCell = () => (isAdmin()
|
||||
? `<label class="sel-cell"><input type="checkbox" id="sel-all" data-action="sel-all" aria-label="Выбрать все" ${S.selectable?.length && selSet().size === S.selectable.length ? "checked" : ""}></label>` : "");
|
||||
const selRow = (id) => (selSet().has(id) ? " selected" : "");
|
||||
const bulkMenu = (id, label, items) =>
|
||||
`<span class="rel"><button type="button" class="btn" data-action="menu" data-menu="${id}">${label}${I.chevD(14)}</button>${popMenu(id, items)}</span>`;
|
||||
const bulkBar = (extra = "") => (isAdmin() && selSet().size
|
||||
? `<div class="bulkbar"><b>Выбрано: ${selSet().size}</b>${extra}${btn("Удалить", "bulk-del", { cls: "danger", icon: I.trash(14) })}<span class="grow"></span>${btn("Снять выделение", "sel-clear", { cls: "ghost" })}</div>` : "");
|
||||
|
||||
function popMenu(id, items, extra = "") {
|
||||
if (menu?.id !== id) return "";
|
||||
@@ -103,11 +124,12 @@ function orgSwitcher() {
|
||||
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", "Журнал"]];
|
||||
const NAV = [["overview", "Обзор"], ["prefixes", "Префиксы"], ["orgs", "Организации"], ["isps", "Операторы"], ["devices", "Устройства"], ["journal", "Журнал"], ["users", "Пользователи", "admin"]];
|
||||
function shell(active, content) {
|
||||
const nav = NAV.filter(([, , role]) => role !== "admin" || isAdmin()); // раздел «Пользователи» виден только администратору
|
||||
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>
|
||||
<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" title="${esc(ROLE_RU[user?.role] ?? "")}">${esc(user?.username ?? "")}</span>${btn("Сменить пароль", "pw-change", { cls: "ghost" })}${btn("Выйти", "logout", { cls: "ghost" })}</div>
|
||||
<div class="page">${content}</div>`;
|
||||
}
|
||||
|
||||
@@ -117,7 +139,7 @@ function openDialog({ title, width = 560, note = "", body, foot }) {
|
||||
<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 closeDialog = () => { $("#modal-root").innerHTML = ""; S.dialog = null; S.subnetRefresh = null; };
|
||||
const opt = (label) => `<span class="opt"> ${label}</span>`;
|
||||
const fInput = (name, label, { value = "", ph = "", mono = false, optional = false, type = "text" } = {}) =>
|
||||
`<label class="field"><span>${label}${optional ? opt("необязательно") : ""}</span><input class="input${mono ? " mono" : ""}" type="${type}" name="${name}" value="${esc(value)}" placeholder="${esc(ph)}"></label>`;
|
||||
@@ -168,7 +190,7 @@ const screens = {};
|
||||
screens.overview = async () => {
|
||||
const o = await api("/overview");
|
||||
const [, tc] = utilColor(o.utilization);
|
||||
const actionBadge = { created: ["blue", "создан"], assigned: ["green", "назначен"], deleted: ["red", "удалён"], updated: ["amber", "изменён"] };
|
||||
const actionBadge = { created: ["blue", "создан"], assigned: ["green", "назначен"], deleted: ["red", "удалён"], updated: ["amber", "изменён"], delete_blocked: ["amber", "отклонено"] };
|
||||
const top = o.top_prefixes.map((p, i, a) => {
|
||||
const [bar, txt] = utilColor(p.utilization);
|
||||
return `<div class="tr${i === a.length - 1 ? " last" : ""}" style="--cols:2fr 1fr 1fr;--h:48px"><span class="m13">${esc(p.prefix)}</span><span style="color:${txt === "#58657a" ? "var(--muted)" : txt};font-weight:${txt === "#58657a" ? 400 : 500}">${fmtNum(p.used)}/${fmtNum(p.capacity)} (${p.utilization}%)</span><div class="bar"><div class="track" style="height:4px"><div class="fill" style="width:${p.utilization}%;background:${bar}"></div></div></div></div>`;
|
||||
@@ -194,14 +216,16 @@ 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">
|
||||
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) => `<div class="tr hoverable clickable${i === items.length - 1 ? " last" : ""}${selRow(o.id)}" data-row="1" data-action="org-open" data-id="${o.id}" style="--cols:${cols}">${selCell(o.id)}
|
||||
<a href="#/prefixes" data-action="open-org" data-id="${o.id}" style="font-weight:500;color:var(--text)">${esc(o.name)}</a><span class="muted">${esc(o.short_name)}</span><span class="mono muted" style="font-size:13px">${esc(o.inn)}</span>
|
||||
<span class="muted ell">${esc(o.address)}</span><a href="#/prefixes" data-action="open-org" data-id="${o.id}" style="font:500 13px var(--sans);text-align:center">${o.prefixes_count}</a><span style="font-size:13px;text-align:center" class="muted">${fmtNum(o.addresses_count)}</span>
|
||||
<span class="cell-actions rel">${iconBtn(I.dots(), "menu", "Действия", `data-menu="org-row-${o.id}"`)}${popMenu("org-row-" + o.id, [{ 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>
|
||||
${bulkBar()}<div class="tr th" style="--cols:${cols}">${selAllCell()}<span>Название</span><span>Краткое имя</span><span>ИНН</span><span>Адрес</span><span>Префиксов</span><span>Адресов</span><span></span></div>
|
||||
${rows || '<div class="empty">Ничего не найдено</div>'}<div class="foot">Показано ${items.length} из ${all} ${plural(all, "организации", "организаций", "организаций")}</div></div>`);
|
||||
};
|
||||
function orgDialog(o) {
|
||||
@@ -229,15 +253,16 @@ screens.isps = async () => {
|
||||
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>
|
||||
const cols = selCols("minmax(130px,1fr) minmax(160px,1.2fr) minmax(180px,1.4fr) 150px 140px minmax(120px,1fr) 44px");
|
||||
setSelectable(items.map((i) => i.id));
|
||||
const rows = items.map((i, n) => `<div class="tr hoverable clickable${n === items.length - 1 ? " last" : ""}${selRow(i.id)}" data-row="1" data-action="isp-edit" data-id="${i.id}" style="--cols:${cols}">${selCell(i.id)}<span style="font-weight:500">${esc(i.name)}</span>
|
||||
<a href="#/prefixes" data-action="open-org" data-id="${i.organization_id}" style="font-size:13px">${esc(i.organization_name)}</a>
|
||||
<span style="display:flex;align-items:center"><span class="mono" style="font-size:13px">${esc(i.networks[0] ?? "—")}</span>${i.networks.length > 1 ? `<span class="more">+${i.networks.length - 1}</span>` : ""}</span>
|
||||
<span class="mono muted" style="font-size:13px">${esc(i.hotline)}</span><span class="muted">${esc(i.contract_number)}</span><span class="muted ell">${esc(i.note)}</span>
|
||||
<span class="cell-actions rel">${iconBtn(I.dots(), "menu", "Действия", `data-menu="isp-row-${i.id}"`)}${popMenu("isp-row-" + i.id, [{ label: "Редактировать", value: "edit:" + i.id }, { label: "Удалить", value: "del:" + i.id, cls: "danger" }], "row-pop")}</span></div>`).join("");
|
||||
return shell("isps", `${header("Операторы связи", "Реестр провайдеров и каналов", btn("Добавить оператора", "isp-new", { cls: "primary", icon: I.plus() }))}
|
||||
<div class="card"><div class="filters">${searchBox(q, "Поиск: оператор, организация, IP", 300)}</div>
|
||||
<div class="tr th" style="--cols:${cols}"><span>Название</span><span>Организация</span><span>IP-адреса</span><span>Горячая линия</span><span>Договор</span><span>Заметки</span><span></span></div>
|
||||
${bulkBar()}<div class="tr th" style="--cols:${cols}">${selAllCell()}<span>Название</span><span>Организация</span><span>IP-адреса</span><span>Горячая линия</span><span>Договор</span><span>Заметки</span><span></span></div>
|
||||
${rows || '<div class="empty">Ничего не найдено</div>'}<div class="foot">Показано ${items.length} из ${all} ${plural(all, "оператора", "операторов", "операторов")}</div></div>`);
|
||||
};
|
||||
function ispDialog(i) {
|
||||
@@ -268,7 +293,8 @@ screens.devices = async () => {
|
||||
]);
|
||||
S.types = types.items;
|
||||
S.rows = list.items;
|
||||
const cols = "minmax(200px,1.6fr) 170px 110px minmax(220px,1.8fr) 44px";
|
||||
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) => {
|
||||
@@ -276,14 +302,14 @@ screens.devices = async () => {
|
||||
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>
|
||||
return `<div class="tr hoverable clickable${n === list.items.length - 1 ? " last" : ""}${selRow(d.id)}" data-row="1" data-action="dev-edit" data-id="${d.id}" style="--cols:${cols}">${selCell(d.id)}<span class="m13" style="font-weight:600;${dep ? "color:var(--faint);text-decoration:line-through" : ""}">${esc(d.name)}</span>
|
||||
<span class="${dep ? "" : "muted"}" style="${dep ? "color:var(--faint)" : ""}">${esc(d.device_type_name)}</span>${ips}<span class="ell" style="color:${dep ? "var(--faint)" : "var(--muted)"}">${esc(d.note)}</span>
|
||||
<span class="cell-actions rel">${iconBtn(I.dots(), "menu", "Действия", `data-menu="dev-row-${d.id}"`)}${popMenu("dev-row-" + d.id, [{ label: "Редактировать", value: "edit:" + d.id }, { label: "Удалить", value: "del:" + d.id, cls: "danger" }], "row-pop")}</span></div>`;
|
||||
}).join("");
|
||||
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>
|
||||
${bulkBar(bulkMenu("bulk-type", "Сменить тип", types.items.map((t) => ({ label: t.name, value: t.id }))))}<div class="tr th" style="--cols:${cols}">${selAllCell()}<span>Устройство</span><span>Тип</span><span>IP-адресов</span><span>Заметки</span><span></span></div>
|
||||
${rows || '<div class="empty">Устройств нет</div>'}<div class="foot">Показано ${list.items.length} из ${list.total} ${plural(list.total, "устройства", "устройств", "устройств")}</div></div>`);
|
||||
};
|
||||
function deviceDialog(d) {
|
||||
@@ -351,7 +377,8 @@ screens.prefixes = async () => {
|
||||
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 cols = selCols("44px minmax(200px,1.8fr) 1.4fr 100px 100px 200px 80px 44px");
|
||||
setSelectable(visible.filter((p) => !hasKids.has(p.id)).map((p) => p.id)); // как и меню «⋯»: только листовые
|
||||
const tab = (id, label, n) => `<button type="button" class="tab${(S.vrf || 0) === id ? " active" : ""}" data-action="vrf-tab" data-id="${id}">${esc(label)} <span class="n">${n}</span></button>`;
|
||||
const rows = visible.map((p, n) => {
|
||||
const [bar, txt] = utilColor(p.utilization);
|
||||
@@ -360,16 +387,16 @@ screens.prefixes = async () => {
|
||||
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>
|
||||
return `<div class="tr hoverable clickable${p.depth ? " child" : ""}${n === visible.length - 1 ? " last" : ""}${leaf ? selRow(p.id) : ""}" data-row="1" data-action="${leaf ? "pfx-open" : "toggle"}" data-id="${p.id}" style="--cols:${cols}">${leaf ? selCell(p.id) : selBlank()}${chev}${cidr}<span class="muted">${esc(p.description)}</span><span class="muted" style="font-size:13px">${esc(p.vrf_name)}</span><span>${badge(sc, st)}</span>
|
||||
<div class="bar"><div class="track"><div class="fill" style="width:${p.utilization}%;background:${bar}"></div></div><span class="pct" style="color:${txt}">${p.utilization}%</span></div><span class="muted" style="font-size:13px">${fmtNum(p.used)}</span>
|
||||
<span class="cell-actions rel">${leaf ? 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>`;
|
||||
<span class="cell-actions rel">${leaf || isAdmin() ? iconBtn(I.dots(), "menu", "Действия", `data-menu="pfx-row-${p.id}"`) + popMenu("pfx-row-" + p.id, [...(leaf ? [{ label: "Открыть адреса", value: "open:" + p.id }, { label: "Редактировать", value: "edit:" + p.id }] : []), ...(isAdmin() ? [{ label: "Добавить вложенный (авто)", value: "subnet:" + p.id }] : []), ...(leaf ? [{ label: "Удалить", value: "del:" + p.id, cls: "danger" }] : [])], "row-pop") : ""}</span></div>`;
|
||||
}).join("");
|
||||
const statusItems = [{ label: "любой", value: "" }, { label: "Активен", value: "active" }, { label: "Резерв", value: "reserved" }, { label: "Устарел", value: "deprecated" }];
|
||||
const famItems = [{ label: "любое", value: "" }, { label: "IPv4", value: "4" }, { label: "IPv6", value: "6" }];
|
||||
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>
|
||||
${bulkBar(bulkMenu("bulk-status", "Статус", Object.entries(PREFIX_STATUS).map(([value, [, label]]) => ({ label, value }))))}<div class="tr th" style="--cols:${cols};--h:40px">${selAllCell()}<span></span><span>Префикс</span><span>Описание</span><span>VRF</span><span>Статус</span><span>Использование</span><span>Адресов</span><span></span></div>
|
||||
${rows || '<div class="empty">Префиксов нет</div>'}<div class="foot">Показано ${visible.length} из ${all.length} ${plural(all.length, "префикса", "префиксов", "префиксов")}</div></div>`);
|
||||
};
|
||||
function prefixDialog(p) {
|
||||
@@ -400,6 +427,45 @@ ${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 ? `Будет создан: <b class="mono">${esc(r.prefix)}</b>` : `В ${esc(parent.prefix)} нет свободного блока /${esc(len)}`;
|
||||
} catch (e) { text = esc(e.message); }
|
||||
if (n !== seq || !$("#sn-preview")) return; // устаревший ответ
|
||||
$("#sn-preview").innerHTML = text; go.disabled = !ok;
|
||||
};
|
||||
openDialog({
|
||||
title: `Новый вложенный префикс в ${parent.prefix}`, width: 560,
|
||||
note: "Система выберет первый свободный блок выбранного размера внутри родительского префикса и создаст в нём вложенный.",
|
||||
body: formBody(`${fSelect("length", "Размер", sizes, def)}
|
||||
<div class="info" id="sn-preview">Подбираем блок…</div>
|
||||
${fInput("description", "Описание", { value: "", optional: true })}
|
||||
${fSelect("status", "Статус", Object.entries(PREFIX_STATUS).map(([value, [, label]]) => ({ value, label })), "active")}
|
||||
${fArea("note", "Примечание", { value: "" })}
|
||||
<label class="check"><input type="checkbox" name="is_pool"><span>Пул для автоназначения<small>Адреса из этого префикса будут выдаваться автоматически</small></span></label>`),
|
||||
foot: dlgFoot("Создать префикс"),
|
||||
});
|
||||
S.subnetRefresh();
|
||||
}
|
||||
async function vrfsDialog() {
|
||||
const org = currentOrg();
|
||||
const { items } = await api("/vrfs", { params: { organization_id: org.id } });
|
||||
@@ -438,13 +504,14 @@ screens.address = async (id) => {
|
||||
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 cols = selCols("minmax(140px,1fr) 1.2fr 1.6fr 120px 160px 44px");
|
||||
setSelectable(page.items.filter((a) => a.id != null).map((a) => a.id)); // у «Свободен» нет записи
|
||||
const rows = page.items.map((a, n) => {
|
||||
const [c, t] = ADDR_STATUS[a.status];
|
||||
const free = a.status === "free";
|
||||
const key = "adr-row-" + (a.id ?? a.address);
|
||||
const items = free ? [{ label: "Назначить адрес", value: "assign:" + a.address }] : [{ label: "Редактировать", value: "edit:" + a.id }, { label: "Удалить", value: "del:" + a.id, cls: "danger" }];
|
||||
return `<div class="tr hoverable clickable${free ? " free" : ""}${n === page.items.length - 1 ? " last" : ""}" 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>
|
||||
return `<div class="tr hoverable clickable${free ? " free" : ""}${n === page.items.length - 1 ? " last" : ""}${free ? "" : selRow(a.id)}" data-row="1" data-action="${free ? "adr-assign" : "adr-edit"}" data-id="${a.id ?? ""}" data-ip="${esc(a.address)}" style="--cols:${cols};--h:52px">${free ? selBlank() : selCell(a.id)}<span class="m13 ${free ? "" : ""}" style="color:${free ? "var(--faint)" : "var(--text)"}">${esc(a.address)}</span>
|
||||
<span class="mono ${free ? "" : "muted"}" style="font-size:13px;font-weight:400">${esc(a.dns_name || "—")}</span><span class="muted">${esc(a.description || (free ? "—" : ""))}</span><span>${badge(c, t)}</span>
|
||||
<span style="font-size:13px" class="muted">${a.updated_at ? fmtDate(a.updated_at) : "—"}</span><span class="cell-actions rel">${iconBtn(I.dots(), "menu", "Действия", `data-menu="${key}"`)}${popMenu(key, items, "row-pop")}</span></div>`;
|
||||
}).join("");
|
||||
@@ -458,7 +525,7 @@ screens.address = async (id) => {
|
||||
<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>
|
||||
${bulkBar(bulkMenu("bulk-status", "Статус", ["assigned", "reserved", "deprecated"].map((value) => ({ label: ADDR_STATUS[value][1], value }))))}<div class="tr th" style="--cols:${cols};--h:40px">${selAllCell()}<span>IP-адрес</span><span>DNS-имя</span><span>Описание</span><span>Статус</span><span>Изменён (UTC)</span><span></span></div>
|
||||
${rows || '<div class="empty">Адресов нет</div>'}<div class="foot">Показано ${shown} из ${page.total} ${plural(page.total, "адреса", "адресов", "адресов")}${more}</div></div>`);
|
||||
};
|
||||
function addressDialog(a, presetIp = "") {
|
||||
@@ -487,14 +554,13 @@ ${fArea("note", "Примечание", { value: a?.note })}`),
|
||||
}
|
||||
|
||||
// ---- journal (audit)
|
||||
const ENTITY_RU = { organization: "Организация", vrf: "VRF", prefix: "Префикс", address: "Адрес", device: "Устройство", device_type: "Тип устройства", isp: "Оператор", session: "Сессия", journal: "Журнал" };
|
||||
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", deleted: "red", failed: "red" }[action] || "";
|
||||
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) : "—");
|
||||
const isAdmin = () => user?.role === "admin";
|
||||
|
||||
screens.journal = async () => {
|
||||
const limit = S.limit || 100;
|
||||
@@ -594,6 +660,75 @@ async function doClear() {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- users
|
||||
screens.users = async () => {
|
||||
if (!isAdmin()) return shell("users", `${header("Пользователи", "Учётные записи UI")}<div class="card" style="margin-top:24px"><div class="empty">Раздел доступен только администратору</div></div>`);
|
||||
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 `<div class="tr hoverable clickable${n === items.length - 1 ? " last" : ""}${selRow(u.id)}" data-row="1" data-action="user-edit" data-id="${u.id}" style="--cols:${cols}">${selCell(u.id, self)}
|
||||
<span class="m13" style="font-weight:600">${esc(u.username)}${self ? ` <span class="muted" style="font:400 12px var(--sans)">это вы</span>` : ""}</span>
|
||||
<span>${badge(u.role === "admin" ? "blue" : "", ROLE_RU[u.role] || u.role)}</span>
|
||||
<span>${u.is_active ? badge("green", "Доступ разрешён") : badge("red", "Отключён")}</span>
|
||||
<span class="muted" style="font-size:13px">${self ? "свои роль и доступ менять нельзя" : ""}</span>
|
||||
<span class="cell-actions rel">${iconBtn(I.dots(), "menu", "Действия", `data-menu="user-row-${u.id}"`)}${popMenu("user-row-" + u.id, items2, "row-pop")}</span></div>`;
|
||||
}).join("");
|
||||
return shell("users", `${header("Пользователи", `${total} ${plural(total, "учётная запись", "учётные записи", "учётных записей")} · роли: администратор — запись, просмотр — чтение`, btn("Добавить пользователя", "users-new", { cls: "primary", icon: I.plus() }))}
|
||||
<div class="card"><div class="filters">${searchBox(q, "Поиск по логину", 260)}</div>
|
||||
${bulkBar(btn("Разрешить доступ", "bulk-access", { data: 'data-value="1"' }) + btn("Отключить доступ", "bulk-access", { data: 'data-value="0"' }))}<div class="tr th" style="--cols:${cols};--h:40px">${selAllCell()}<span>Логин</span><span>Роль</span><span>Статус</span><span></span><span></span></div>
|
||||
${rows || '<div class="empty">Пользователей нет</div>'}<div class="foot">Показано ${items.length} из ${total} ${plural(total, "записи", "записей", "записей")}</div></div>`);
|
||||
};
|
||||
function userDialog(u) {
|
||||
const edit = !!u;
|
||||
const self = edit && u.id === user?.id;
|
||||
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 ? `<label class="field"><span>Логин</span><input class="input" value="${esc(u.username)}" disabled></label>` : fInput("username", "Логин", { ph: "ivanov", hint: "латиница, цифры, . _ -" })}
|
||||
${self ? "" : fInput("password", edit ? "Новый пароль" : "Пароль", { type: "password", optional: edit, ph: edit ? "не менять" : "", hint: "минимум 8 символов" })}
|
||||
${self ? "" : fSelect("role", "Роль", [{ value: "admin", label: "Администратор" }, { value: "viewer", label: "Просмотр" }], u?.role ?? "viewer")}
|
||||
<label class="check"><input type="checkbox" name="is_active" ${edit ? (u.is_active ? "checked" : "") : "checked"} ${self ? "disabled" : ""}><span>Доступ разрешён<small>Отключённый пользователь не входит в UI; его текущий токен перестаёт действовать сразу</small></span></label>
|
||||
${self ? `<div class="info">Свою учётную запись нельзя понизить, отключить или удалить — пароль меняется кнопкой «Сменить пароль» в шапке.</div>` : ""}`),
|
||||
foot: dlgFoot(edit ? "Сохранить" : "Добавить пользователя"),
|
||||
});
|
||||
}
|
||||
function passwordDialog() {
|
||||
S.dialog = async (v) => {
|
||||
if (v.new_password !== v.confirm_password) throw new ApiError(422, { message: "Пароли не совпадают", fields: { confirm_password: "пароли не совпадают" } });
|
||||
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" })}
|
||||
<div class="grid2">${fInput("new_password", "Новый пароль", { type: "password", hint: "минимум 8 символов" })}${fInput("confirm_password", "Повторите пароль", { type: "password" })}</div>
|
||||
<div class="info">Смена пароля не завершает уже открытые сессии: выданный ранее токен действует до истечения своего срока.</div>`),
|
||||
foot: dlgFoot("Сохранить пароль"),
|
||||
});
|
||||
}
|
||||
|
||||
// ---- login
|
||||
function loginScreen(err = "") {
|
||||
return `<div class="card login"><div class="brand"><span class="brand-logo">${I.globe()}</span>ipam_manager</div><h1>Вход</h1>
|
||||
@@ -629,6 +764,8 @@ async function draw() {
|
||||
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;
|
||||
@@ -644,6 +781,52 @@ async function guarded(fn) {
|
||||
}
|
||||
const rowById = (id) => S.rows?.find((r) => r.id === Number(id));
|
||||
|
||||
// групповые операции: последовательные вызовы существующих DELETE/PATCH; отказ по одному объекту не прерывает остальные
|
||||
const BULK = {
|
||||
orgs: { forms: ["организация", "организации", "организаций"], url: (id) => `/organizations/${id}`, label: (id) => rowById(id)?.name, after: loadOrgs },
|
||||
isps: { forms: ["оператор", "оператора", "операторов"], url: (id) => `/isps/${id}`, label: (id) => rowById(id)?.name },
|
||||
devices: { forms: ["устройство", "устройства", "устройств"], url: (id) => `/devices/${id}`, label: (id) => rowById(id)?.name },
|
||||
prefixes: { forms: ["префикс", "префикса", "префиксов"], url: (id) => `/prefixes/${id}`, label: (id) => S.prefixes.find((p) => p.id === id)?.prefix },
|
||||
address: { forms: ["адрес", "адреса", "адресов"], url: (id) => `/addresses/${id}`, label: (id) => S.page.items.find((a) => a.id === id)?.address },
|
||||
users: { forms: ["пользователь", "пользователя", "пользователей"], url: (id) => `/users/${id}`, label: (id) => rowById(id)?.username },
|
||||
};
|
||||
const screenKey = () => { const { path, arg } = route(); return path === "prefixes" && arg ? "address" : path; };
|
||||
function failuresDialog(title, failed) {
|
||||
openDialog({
|
||||
title: "Выполнено не для всех объектов", width: 640,
|
||||
note: `${esc(title)}: не выполнено ${failed.length}. Эти объекты остались выбранными.`,
|
||||
body: `<div class="dialog-body"><div class="mini-table" style="max-height:320px;overflow:auto">${failed.map(([label, msg]) =>
|
||||
`<div class="tr" style="--cols:minmax(120px,1fr) 2fr;height:auto;min-height:48px;padding:10px 12px"><span class="m13" style="font-weight:600;overflow-wrap:anywhere">${esc(label)}</span><span class="muted" style="overflow-wrap:anywhere">${esc(msg)}</span></div>`).join("")}</div></div>`,
|
||||
foot: `<div class="dialog-foot">${btn("Закрыть", "close-dialog", { cls: "primary" })}</div>`,
|
||||
});
|
||||
}
|
||||
async function bulk(title, ids, labelOf, call, after) {
|
||||
const st = S;
|
||||
if (st.busy || !ids.length) return;
|
||||
st.busy = true;
|
||||
const failed = [];
|
||||
let ok = 0;
|
||||
try {
|
||||
for (const id of ids) {
|
||||
try { await call(id); ok++; st.sel.delete(id); } catch (e) {
|
||||
if (!(e instanceof ApiError)) throw e;
|
||||
if (e.status === 401) return;
|
||||
failed.push([labelOf(id) ?? `#${id}`, e.message]);
|
||||
}
|
||||
}
|
||||
} finally { st.busy = false; }
|
||||
toast(`${title}: выполнено ${ok} из ${ids.length}`, failed.length > 0);
|
||||
await after?.();
|
||||
if (S !== st) return; // пока шли запросы, пользователь ушёл на другой экран
|
||||
await draw();
|
||||
if (failed.length) failuresDialog(title, failed);
|
||||
}
|
||||
function bulkPick(id, value) {
|
||||
const k = screenKey(), ids = [...selSet()];
|
||||
if (id === "bulk-type") return bulk("Смена типа", ids, BULK.devices.label, (x) => api(`/devices/${x}`, { method: "PATCH", body: { device_type_id: Number(value) } }));
|
||||
return bulk("Смена статуса", ids, BULK[k].label, (x) => api(BULK[k].url(x), { method: "PATCH", body: { status: value } }));
|
||||
}
|
||||
|
||||
const rowActions = {
|
||||
"org-row": async (act, id) => {
|
||||
if (act === "open") { store.orgId = Number(id); location.hash = "#/prefixes"; }
|
||||
@@ -661,6 +844,7 @@ const rowActions = {
|
||||
"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;
|
||||
@@ -673,6 +857,15 @@ const rowActions = {
|
||||
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 = {
|
||||
@@ -689,6 +882,7 @@ const actions = {
|
||||
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(":");
|
||||
@@ -716,10 +910,20 @@ const actions = {
|
||||
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)),
|
||||
@@ -733,6 +937,8 @@ const actions = {
|
||||
"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),
|
||||
@@ -797,6 +1003,7 @@ document.addEventListener("input", (e) => {
|
||||
});
|
||||
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;
|
||||
});
|
||||
|
||||
@@ -182,3 +182,10 @@ button.select.ph{color:var(--faint)}
|
||||
.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}
|
||||
|
||||
.sel-cell{display:flex;align-items:center;justify-content:center;height:100%;cursor:pointer}
|
||||
.sel-cell input{width:16px;height:16px;margin:0;accent-color:var(--primary);cursor:pointer}
|
||||
.sel-cell input:disabled{cursor:not-allowed}
|
||||
.tr.selected,.tr.selected:hover{background:#eef3ff}
|
||||
.bulkbar{display:flex;align-items:center;gap:8px;height:48px;padding:0 16px;background:#f3f7ff;border-top:1px solid var(--line);font:400 14px/1 var(--sans);position:relative}
|
||||
.bulkbar .pop{top:40px}
|
||||
Reference in new issue
Block a user