2026-09-20 12:27:47 +03:00
// IPAM Manager — UI-админка. Только визуализирует ответы /api/v1, бизнес-логики нет.
const API = "/api/v1" ;
const $ = ( s ) => document . querySelector ( s );
const esc = ( v ) => String ( v ?? "" ). replace ( /[&<>"']/g , ( c ) => ({ "&" : "&" , "<" : "<" , ">" : ">" , '"' : """ , "'" : "'" }[ c ]));
const fmtNum = ( n ) => new Intl . NumberFormat ( "ru-RU" ). format ( n );
const fmtDate = ( iso ) => ( iso ? iso . replace ( "T" , " " ). slice ( 0 , 16 ) : "—" );
const plural = ( n , one , few , many ) => {
const m10 = n % 10 , m100 = n % 100 ;
return m10 === 1 && m100 !== 11 ? one : m10 >= 2 && m10 <= 4 && ( m100 < 10 || m100 >= 20 ) ? few : many ;
};
/* ------------------------------------------------------------------ icons */
const ic = ( inner , s = 16 ) => `<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}
2026-09-27 16:57:14 +03:00
let pendingMfa = null ; // изменение 037: {mfa_token} между первым и вторым шагом входа — только в памяти, не в sessionStorage
2026-09-20 12:27:47 +03:00
/* --------------------------------------------------------------------- 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 );
2026-09-27 16:57:14 +03:00
if ( res . status === 401 && path !== "/auth/login" && path !== "/auth/login/2fa" ) { // изменение 037: 401 здесь — истёкший/неверный mfa_token, обрабатывается на месте
2026-09-20 12:27:47 +03:00
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 );
2026-09-27 11:26:57 +03:00
const isAdmin = () => user ? . role !== "viewer" ; // изменение 032: admin И superadmin
const isSuperadmin = () => user ? . role === "superadmin" ; // изменение 032
const ROLE_RU = { superadmin : "Суперадминистратор" , admin : "Администратор" , viewer : "Просмотр" }; // изменение 032
2026-09-26 13:25:33 +03:00
/* ------------------------------------------------ выбор строк и групповые операции (только 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>` : "" );
2026-09-20 12:27:47 +03:00
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 ();
2026-09-27 11:26:57 +03:00
// изменение 032: интерактивный переключатель для superadmin, статичная подпись для других
if ( isSuperadmin ()) {
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>` ;
}
return `<span style="flex:none;display:flex;align-items:center;gap:8px;color:var(--muted);font-size:13px"> ${ I . org () } <span>Организация: <b style="font-weight:600;color:var(--text)"> ${ esc ( o ? . name ?? "—" ) } </b></span></span><div class="vsep"></div>` ;
2026-09-20 12:27:47 +03:00
}
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>` ;
2026-09-27 11:26:57 +03:00
const NAV = [[ "overview" , "Обзор" ], [ "prefixes" , "Префиксы" ], [ "orgs" , "Организации" ], [ "isps" , "Операторы" ], [ "devices" , "Устройства" ], [ "journal" , "Журнал" ], [ "users" , "Пользователи" , "superadmin" ]]; // изменение 032: "superadmin"
2026-09-20 12:27:47 +03:00
function shell ( active , content ) {
2026-09-27 11:26:57 +03:00
const nav = NAV . filter (([, , role ]) => role !== "superadmin" || isSuperadmin ()); // изменение 032: раздел «Пользователи» виден только суперадминистратору
2026-09-27 16:14:58 +03:00
// изменение 036: логин в шапке — триггер выпадающего меню («Сменить пароль» / «Выйти») вместо отдельных кнопок
2026-09-27 16:57:14 +03:00
// изменение 037: пункт «Двухфакторная аутентификация» между «Сменить пароль» и «Выйти»
const userMenu = `<span class="rel user-menu"><button type="button" class="btn ghost" data-action="menu" data-menu="user" title=" ${ esc ( ROLE_RU [ user ? . role ] ?? "" ) } "> ${ esc ( user ? . username ?? "" ) }${ I . chevD ( 14 ) } </button> ${ popMenu ( "user" , [{ label : "Сменить пароль" , value : "pw" } , { label: "Двухфакторная аутентификация", value: "2fa" }, { label: "Выйти", value: "logout" }], "row-pop")}</span>` ;
2026-09-20 12:27:47 +03:00
return `<div class="appbar"><div class="brand"><span class="brand-logo"> ${ I . globe () } </span>ipam_manager</div>
2026-09-26 13:25:33 +03:00
<nav class="nav"> ${ nav . map (([ k , l ]) => `<a href="#/ ${ k } " class=" ${ k === active ? "active" : "" } "> ${ l } </a>` ). join ( "" ) } </nav>
2026-09-27 16:14:58 +03:00
<div class="grow"></div> ${ userMenu } </div>
2026-09-20 12:27:47 +03:00
<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 ();
}
2026-09-26 13:25:33 +03:00
const closeDialog = () => { $ ( "#modal-root" ). innerHTML = "" ; S . dialog = null ; S . subnetRefresh = null ; };
2026-09-20 12:27:47 +03:00
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 ());
2026-09-27 16:57:14 +03:00
// изменение 037: обработчик мог сам открыть следующее окно диалоговой цепочки (сменив S.dialog) — тогда
// закрывать нечего, иначе только что открытое окно закрылось бы сразу вслед за собой
if ( S . dialog === h ) closeDialog ();
2026-09-20 12:27:47 +03:00
} 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 );
2026-09-26 13:25:33 +03:00
const actionBadge = { created : [ "blue" , "создан" ], assigned : [ "green" , "назначен" ], deleted : [ "red" , "удалён" ], updated : [ "amber" , "изменён" ], delete_blocked : [ "amber" , "отклонено" ] };
2026-09-20 12:27:47 +03:00
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 ;
2026-09-26 13:25:33 +03:00
setSelectable ( items . map (( o ) => o . id ));
const cols = selCols ( "minmax(200px,1.5fr) 110px 130px minmax(200px,1.5fr) 80px 80px 44px" );
2026-09-27 11:26:57 +03:00
const rows = items . map (( o , i ) => {
const items2 = [{ label : "Открыть префиксы" , value : "open:" + o . id }, { label : "Редактировать" , value : "edit:" + o . id }]; // изменение 032
if ( isSuperadmin ()) items2 . push ({ label : "Удалить" , value : "del:" + o . id , cls : "danger" }); // изменение 032: кнопка удаления только для superadmin
return `<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 ) }
2026-09-20 12:27:47 +03:00
<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>
2026-09-27 11:26:57 +03:00
<span class="cell-actions rel"> ${ iconBtn ( I . dots (), "menu" , "Действия" , `data-menu="org-row- ${ o . id } "` ) }${ popMenu ( "org-row-" + o . id , items2 , "row-pop" ) } </span></div>` ; // изменение 032
}). join ( "" );
2026-09-20 12:27:47 +03:00
S . rows = items ;
2026-09-27 11:26:57 +03:00
const addBtn = isSuperadmin () ? btn ( "Добавить организацию" , "org-new" , { cls : "primary" , icon : I . plus () }) : "" ; // изменение 032: кнопка только для superadmin
return shell ( "orgs" , ` ${ header ( "Организации" , ` ${ all } ${ plural ( all , "организация" , "организации" , "организаций" ) } ` , addBtn ) }
2026-09-20 12:27:47 +03:00
<div class="card"><div class="filters"> ${ searchBox ( q , "Поиск: название, ИНН, адрес" , 300 ) } </div>
2026-09-26 13:25:33 +03:00
${ bulkBar () } <div class="tr th" style="--cols: ${ cols } "> ${ selAllCell () } <span>Название</span><span>Краткое имя</span><span>ИНН</span><span>Адрес</span><span>Префиксов</span><span>Адресов</span><span></span></div>
2026-09-20 12:27:47 +03:00
${ 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 ;
2026-09-26 13:25:33 +03:00
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>
2026-09-20 12:27:47 +03:00
<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>
2026-09-26 13:25:33 +03:00
${ 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>
2026-09-20 12:27:47 +03:00
${ 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 ;
2026-09-26 13:25:33 +03:00
const cols = selCols ( "minmax(200px,1.6fr) 170px 110px minmax(220px,1.8fr) 44px" );
setSelectable ( list . items . map (( d ) => d . id ));
2026-09-20 12:27:47 +03:00
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>` ;
2026-09-26 13:25:33 +03:00
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>
2026-09-20 12:27:47 +03:00
<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 ( "" );
2026-09-27 11:26:57 +03:00
const addBtn = btn ( "Добавить устройство" , "dev-new" , { cls : "primary" , icon : I . plus () }); // изменение 033: устройство создаёт любой admin своей организации
return shell ( "devices" , ` ${ crumbsOrg () }${ header ( "Устройства" , ` ${ list . total } ${ plural ( list . total , "устройство" , "устройства" , "устройств" ) } · организация: ${ esc ( org ? . name ?? "" ) } ` , addBtn ) }
<div class="right-link"><a href="#" data-action="types"> ${ I . gear () } Типы устройств</a></div>
2026-09-20 12:27:47 +03:00
<div class="card"><div class="filters"> ${ orgSwitcher () }${ searchBox ( q , "Поиск: имя, IP, заметка" , 260 ) }${ filterBtn ( "type" , "Тип" , typeItems , S . type ?? "" , 170 ) } </div>
2026-09-26 13:25:33 +03:00
${ 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>
2026-09-20 12:27:47 +03:00
${ 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 ;
2026-09-27 11:26:57 +03:00
let acts ;
if ( ! isSuperadmin ()) { // изменение 032: управление только для superadmin
acts = t . is_default ? `<span class="muted" style="font-size:12px">по умолчанию</span>` : "" ;
} else {
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 ) } ` ;
}
2026-09-20 12:27:47 +03:00
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 ,
2026-09-27 11:26:57 +03:00
note : "Типы используются в фильтре и в поле «Тип» при добавлении устройства. Общий список для всех организаций." , // изменение 032
2026-09-20 12:27:47 +03:00
body : `<div class="mini-table"><div class="tr th" style="--cols: ${ cols } "><span>Название</span><span>Устройств</span><span></span></div> ${ rows } </div>
2026-09-27 11:26:57 +03:00
${ isSuperadmin () ? `<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>` : "" } ` , // изменение 032: кнопка только для superadmin
2026-09-20 12:27:47 +03:00
foot : `<div class="dialog-foot"> ${ btn ( "Готово" , "close-dialog" , { cls : "primary" } )}</div>` ,
});
}
// ---- prefixes
2026-09-27 08:28:50 +03:00
const PREFIX_UI_CAP = 20000 ; // защита от неограниченной подгрузки: организация с частичным разбиением может дать сотни тысяч префиксов
async function loadPrefixes ( orgId ) {
// грузит все страницы /prefixes (порядок ORDER BY vrf_id, prefix стабилен) до total, но не больше PREFIX_UI_CAP —
// иначе заголовок, вкладка «Все» и родительский список в диалогах молча ограничивались первой страницей (изменение 028, находка №3)
let items = [], total = 0 , offset = 0 ;
for (;;) {
const page = await api ( "/prefixes" , { params : { organization_id : orgId , limit : 1000 , offset } });
total = page . total ;
items = items . concat ( page . items );
offset += page . items . length ;
if ( page . items . length === 0 || offset >= total || items . length >= PREFIX_UI_CAP ) break ;
}
return { items , total };
}
2026-09-20 12:27:47 +03:00
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 ([
2026-09-27 08:28:50 +03:00
loadPrefixes ( org . id ),
2026-09-20 12:27:47 +03:00
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 ));
2026-09-26 13:25:33 +03:00
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 )); // как и меню «⋯»: только листовые
2026-09-20 12:27:47 +03:00
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>` ;
2026-09-26 13:25:33 +03:00
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>
2026-09-20 12:27:47 +03:00
<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>
2026-09-26 13:25:33 +03:00
<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>` ;
2026-09-20 12:27:47 +03:00
}). 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" }];
2026-09-27 08:28:50 +03:00
const truncated = pl . total > all . length ? `<div class="info">Загружено ${ fmtNum ( all . length ) } из ${ fmtNum ( pl . total ) } префиксов — уточните поиск или выберите VRF</div>` : "" ;
return shell ( "prefixes" , ` ${ crumbsOrg () }${ header ( "Префиксы" , ` ${ pl . total } ${ plural ( pl . total , "префикс" , "префикса" , "префиксов" ) } · ${ vl . total } VRF · организация: ${ esc ( org . name ) } ` , btn ( "Добавить префикс" , "pfx-new" , { cls : "primary" , icon : I . plus () } ))}
<div class="tabs"> ${ tab ( 0 , "Все" , pl . total ) }${ 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>
${ truncated }
2026-09-20 12:27:47 +03:00
<div class="card"><div class="filters"> ${ orgSwitcher () }${ searchBox ( S . q || "" , "Поиск: префикс, описание" , 280 ) }${ filterBtn ( "status" , "Статус" , statusItems , S . status ?? "" ) }${ filterBtn ( "family" , "Семейство" , famItems , S . family ?? "" ) } </div>
2026-09-26 13:25:33 +03:00
${ 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>
2026-09-27 08:28:50 +03:00
${ rows || '<div class="empty">Префиксов нет</div>' } <div class="foot">Показано ${ visible . length } из ${ pl . total } ${ plural ( pl . total , "префикса" , "префиксов" , "префиксов" ) } </div></div>` );
2026-09-20 12:27:47 +03:00
};
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 ? "Сохранить" : "Добавить префикс" ),
});
}
2026-09-26 13:25:33 +03:00
// «следующий свободный» вложенный префикс: система сама выбирает первый свободный выровненный блок родителя
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 ();
}
2026-09-20 12:27:47 +03:00
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 } }),
2026-09-27 08:28:50 +03:00
loadPrefixes ( p . organization_id ),
2026-09-20 12:27:47 +03:00
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 ;
2026-09-26 13:25:33 +03:00
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 )); // у «Свободен» нет записи
2026-09-20 12:27:47 +03:00
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" }];
2026-09-26 13:25:33 +03:00
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>
2026-09-20 12:27:47 +03:00
<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>
2026-09-26 13:25:33 +03:00
${ 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>
2026-09-20 12:27:47 +03:00
${ 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)
2026-09-26 13:25:33 +03:00
const ENTITY_RU = { organization : "Организация" , vrf : "VRF" , prefix : "Префикс" , address : "Адрес" , device : "Устройство" , device_type : "Тип устройства" , isp : "Оператор" , user : "Пользователь" , session : "Сессия" , journal : "Журнал" };
2026-09-20 12:27:47 +03:00
const eventBadge = ( ev ) => {
const [ entity , action ] = ev . split ( "." );
2026-09-26 21:33:50 +03:00
const cls = { created : "blue" , assigned : "green" , updated : "amber" , password_reset : "amber" , deleted : "red" , failed : "red" , locked : "red" , clear_failed : "red" , clear_locked : "red" , delete_blocked : "amber" }[ action ] || "" ;
2026-09-20 12:27:47 +03:00
return badge ( cls , ev );
};
const fmtDateSec = ( iso ) => ( iso ? iso . replace ( "T" , " " ). slice ( 0 , 19 ) : "—" );
screens . journal = async () => {
const limit = S . limit || 100 ;
const [ page , sum , facets ] = await Promise . all ([
api ( "/audit" , { params : { event_type : S . jtype , actor : S . jactor , entity_type : S . jentity , date_from : S . jfrom , date_to : S . jto , q : S . q , limit } }),
api ( "/audit/summary" ),
api ( "/audit/facets" ),
]);
S . entries = page . items ; S . summary = sum ;
const any = ( l ) => ({ label : l , value : "" });
const typeItems = [ any ( "любой" ), ... facets . event_types . map (( v ) => ({ label : v , value : v }))];
const actorItems = [ any ( "любой" ), ... facets . actors . map (( v ) => ({ label : v , value : v }))];
const entItems = [ any ( "любая" ), ... facets . entity_types . map (( v ) => ({ label : ENTITY_RU [ v ] || v , value : v }))];
const cols = "150px 168px 150px 1fr 110px 130px 96px" ;
const rows = page . items . map (( r ) => `<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 ) } · ${ 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 );
}
}
2026-09-26 13:25:33 +03:00
// ---- users
screens . users = async () => {
2026-09-27 11:26:57 +03:00
if ( ! isSuperadmin ()) return shell ( "users" , ` ${ header ( "Пользователи" , "Учётные записи UI" ) } <div class="card" style="margin-top:24px"><div class="empty">Раздел доступен только суперадминистратору</div></div>` ); // изменение 032
2026-09-26 13:25:33 +03:00
const q = S . q || "" ;
const { items , total } = await api ( "/users" , { params : { q , limit : 500 } });
S . rows = items ;
2026-09-27 15:45:30 +03:00
const cols = selCols ( "minmax(200px,1.5fr) 190px minmax(180px,1fr) 190px 180px minmax(140px,1fr) 44px" ); // изменение 032: столбец организации; изменение 035: столбец последнего входа
2026-09-26 13:25:33 +03:00
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" : "" },
2026-09-27 16:57:14 +03:00
// изменение 037: сброс 2FA — только у чужих записей с включённой 2FA
...( ! self && u . totp_enabled ? [{ label : "Сбросить 2FA" , value : "totp-reset:" + u . id }] : []),
2026-09-26 13:25:33 +03:00
{ label : "Удалить" , value : "del:" + u . id , cls : "danger" },
];
2026-09-27 11:26:57 +03:00
const orgName = orgs . find (( o ) => o . id === u . organization_id ) ? . name || "—" ; // изменение 032
2026-09-26 13:25:33 +03:00
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>
2026-09-27 11:26:57 +03:00
<span> ${ badge ( u . role === "superadmin" ? "purple" : u . role === "admin" ? "blue" : "" , ROLE_RU [ u . role ] || u . role ) } </span>
<span class="muted" style="font-size:13px"> ${ u . role === "superadmin" ? "—" : esc ( orgName ) } </span>
2026-09-27 16:57:14 +03:00
<span> ${ u . is_active ? badge ( "green" , "Доступ разрешён" ) : badge ( "red" , "Отключён" ) }${ u . totp_enabled ? " " + badge ( "blue" , "2FA" ) : "" } </span>
2026-09-27 15:45:30 +03:00
<span style="font-size:13px"> ${ u . last_login_at ? `<span style="display:flex;flex-direction:column;gap:2px"><span> ${ fmtDate ( u . last_login_at ) } </span><span class="mono muted" style="font-size:12px"> ${ esc ( u . last_login_ip || "—" ) } </span></span>` : '<span class="muted">—</span>' } </span>
2026-09-26 13:25:33 +03:00
<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 ( "" );
2026-09-27 11:26:57 +03:00
return shell ( "users" , ` ${ header ( "Пользователи" , ` ${ total } ${ plural ( total , "учётная запись" , "учётные записи" , "учётных записей" ) } · роли: суперадминистратор — полные права, администратор — запись в организации, просмотр — чтение` , btn ( "Добавить пользователя" , "users-new" , { cls : "primary" , icon : I . plus () } ))}
2026-09-26 13:25:33 +03:00
<div class="card"><div class="filters"> ${ searchBox ( q , "Поиск по логину" , 260 ) } </div>
2026-09-27 15:45:30 +03:00
${ 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><span></span><span></span></div>
2026-09-26 13:25:33 +03:00
${ 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 ;
2026-09-27 11:26:57 +03:00
const roleInit = u ? . role ?? "viewer" ; // изменение 033: начальная видимость поля организации — по роли в форме
2026-09-26 13:25:33 +03:00
S . dialog = async ( v ) => {
if ( edit ) {
const body = { is_active : v . is_active === true };
if ( v . role ) body . role = v . role ;
2026-09-27 11:26:57 +03:00
if ( v . role !== "superadmin" && v . organization_id ) body . organization_id = Number ( v . organization_id ); // изменение 033: одинаково с созданием — сервер сам очистит организацию при повышении
2026-09-26 13:25:33 +03:00
if ( v . password ) body . password = v . password ;
await api ( `/users/ ${ u . id } ` , { method : "PATCH" , body });
} else {
2026-09-27 11:26:57 +03:00
const body = { username : v . username , password : v . password , role : v . role , is_active : v . is_active !== false }; // изменение 032
if ( v . role !== "superadmin" && v . organization_id ) body . organization_id = Number ( v . organization_id ); // изменение 032: проверка перед присвоением
await api ( "/users" , { method : "POST" , body });
2026-09-26 13:25:33 +03:00
}
toast ( edit ? "Пользователь сохранён" : "Пользователь добавлен" );
await draw ();
};
openDialog ({
title : edit ? `Пользователь ${ u . username } ` : "Новый пользователь" ,
2026-09-27 11:26:57 +03:00
note : "«Суперадминистратор» — полные права, «Администратор» — запись в организации, «Просмотр» — только чтение. Логин после создания не меняется." , // изменение 032
2026-09-26 13:25:33 +03:00
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 символов" } )}
2026-09-27 11:26:57 +03:00
${ self ? "" : fSelect ( "role" , "Роль" , [{ value : "superadmin" , label : "Суперадминистратор" } , { value: "admin", label: "Администратор" }, { value: "viewer", label: "Просмотр" }], roleInit)}
${ self ? "" : `<div id="org-field-wrap" ${ roleInit === "superadmin" ? "hidden" : "" } > ${ fSelect ( "organization_id" , "Организация" , orgs . map (( o ) => ({ value : o . id , label : o . name } )), u?.organization_id ?? "")}</div>` }
2026-09-26 13:25:33 +03:00
<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 : "пароли не совпадают" } });
2026-09-26 21:33:50 +03:00
const r = await api ( "/users/me/password" , { method : "POST" , body : { current_password : v . current_password , new_password : v . new_password } });
if ( r ? . access_token ) store . token = r . access_token ; // прежние токены отозваны сервером — продолжаем с новым
2026-09-26 13:25:33 +03:00
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 ( "Сохранить пароль" ),
});
}
2026-09-27 16:57:14 +03:00
// ---- 2FA (изменение 037): управление своей двухфакторной аутентификацией из меню логина в шапке
function totpDialog () {
return user . totp_enabled ? totpStatusDialog () : totpSetupPasswordDialog ();
}
function totpStatusDialog () {
openDialog ({
title : "Двухфакторная аутентификация" ,
body : `<div class="dialog-body"><div class="info">Включена с ${ esc ( fmtDate ( user . totp_enabled_at )) } </div></div>` ,
foot : `<div class="dialog-foot"> ${ btn ( "Закрыть" , "close-dialog" , { cls : "ghost" } )} ${ btn ( "Отключить" , "totp-disable-start" , { cls : "danger" } )}</div>` ,
});
}
function totpSetupPasswordDialog () {
S . dialog = async ( v ) => {
const r = await api ( "/users/me/2fa/setup" , { method : "POST" , body : { password : v . password } });
closeDialog ();
totpConfirmDialog ( r );
};
openDialog ({
title : "Включить двухфакторную аутентификацию" ,
note : "Понадобится приложение-аутентификатор (Google Authenticator, Aegis, 1Password, Bitwarden и подобные)." ,
body : formBody ( ` ${ fInput ( "password" , "Текущий пароль" , { type : "password" } )}` ),
foot : dlgFoot ( "Далее" ),
});
}
function totpConfirmDialog ( setup ) {
S . dialog = async ( v ) => {
const r = await api ( "/users/me/2fa/enable" , { method : "POST" , body : { code : v . code } });
closeDialog ();
user = await api ( "/auth/me" );
totpRecoveryCodesDialog ( r . recovery_codes );
};
openDialog ({
title : "Сканируйте QR-код" ,
width : 460 ,
note : "Отсканируйте код в приложении-аутентификаторе или введите секрет вручную, затем введите код из приложения." ,
body : formBody ( `<div style="display:flex;justify-content:center;padding:8px 0"><img src=" ${ setup . qr } " alt="QR-код" width="220" height="220"></div>
<label class="field"><span>Секрет для ручного ввода</span><input class="input mono" value=" ${ esc ( setup . secret ) } " readonly onclick="this.select()"></label>
${ fInput ( "code" , "Код из приложения" , { ph : "000000" } )}` ),
foot : dlgFoot ( "Подтвердить" ),
});
}
function totpRecoveryCodesDialog ( codes ) {
S . dialog = null ;
const text = codes . join ( "\n" );
openDialog ({
title : "Коды восстановления" , width : 460 ,
note : "Сохраните эти коды в надёжном месте — сервер больше не покажет их. Каждый код можно использовать один раз, вместо кода из приложения." ,
body : `<div class="dialog-body"><pre class="json" style="text-align:center;line-height:1.8"> ${ esc ( text ) } </pre></div>` ,
foot : `<div class="dialog-foot"> ${ btn ( "Скопировать" , "copy" , { data : `data-text=" ${ esc ( text ) } "` } )} ${ btn ( "Готово" , "close-dialog" , { cls : "primary" } )}</div>` ,
});
}
function totpDisableStartDialog () {
closeDialog ();
S . dialog = async ( v ) => {
await api ( "/users/me/2fa/disable" , { method : "POST" , body : { password : v . password , code : v . code } });
closeDialog ();
user = await api ( "/auth/me" );
toast ( "Двухфакторная аутентификация отключена" );
};
openDialog ({
title : "Отключить двухфакторную аутентификацию" ,
body : formBody ( ` ${ fInput ( "password" , "Текущий пароль" , { type : "password" } )}
${ fInput ( "code" , "Код из приложения или код восстановления" , { ph : "000000" } )}` ),
foot : dlgFoot ( "Отключить" ),
});
}
2026-09-20 12:27:47 +03:00
// ---- login
function loginScreen ( err = "" ) {
2026-09-27 16:57:14 +03:00
if ( pendingMfa ) {
return `<div class="card login"><div class="brand"><span class="brand-logo"> ${ I . globe () } </span>ipam_manager</div><h1>Вход</h1>
<form id="mfa-form"><div class="err-banner" style="margin-top:12px" ${ err ? "" : "hidden" } > ${ esc ( err ) } </div>
<label class="field"><span>Код из приложения или код восстановления</span><input class="input mono" name="code" autocomplete="one-time-code" autofocus></label>
<button class="btn primary" type="submit">Подтвердить</button>
<button class="btn ghost" type="button" data-action="mfa-back" style="margin-top:8px;width:100%">Назад</button></form></div>` ;
}
2026-09-20 12:27:47 +03:00
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 ();
2026-09-26 13:25:33 +03:00
const all = $ ( "#sel-all" );
if ( all ) all . indeterminate = selSet (). size > 0 && selSet (). size < S . selectable . length ;
2026-09-20 12:27:47 +03:00
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 ));
2026-09-26 13:25:33 +03:00
// групповые операции: последовательные вызовы существующих 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 } }));
}
2026-09-20 12:27:47 +03:00
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 )));
2026-09-26 13:25:33 +03:00
else if ( act === "subnet" ) subnetDialog ( S . prefixes . find (( p ) => p . id === Number ( id )));
2026-09-20 12:27:47 +03:00
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 (); });
},
2026-09-26 13:25:33 +03:00
"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 ();
});
2026-09-27 16:57:14 +03:00
else if ( act === "totp-reset" && confirm ( `Сбросить двухфакторную аутентификацию пользователю ${ u . username } ?` )) await guarded ( async () => {
await api ( `/users/ ${ id } /2fa/reset` , { method : "POST" }); toast ( "Двухфакторная аутентификация сброшена" ); await draw ();
}); // изменение 037
2026-09-26 13:25:33 +03:00
else if ( act === "del" && confirmDel ( `пользователя ${ u . username } ` )) await guarded ( async () => { await api ( `/users/ ${ id } ` , { method : "DELETE" }); toast ( "Пользователь удалён" ); await draw (); });
},
2026-09-20 12:27:47 +03:00
};
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 ; }
2026-09-26 13:25:33 +03:00
else if ( id === "bulk-status" || id === "bulk-type" ) return bulkPick ( id , value );
2026-09-27 16:14:58 +03:00
else if ( id === "user" ) { // изменение 036: меню логина в шапке
document . querySelectorAll ( ".pop" ). forEach (( el ) => el . remove ());
2026-09-27 16:57:14 +03:00
if ( value === "logout" ) return actions . logout ();
return value === "2fa" ? totpDialog () : passwordDialog (); // изменение 037
2026-09-27 16:14:58 +03:00
}
2026-09-20 12:27:47 +03:00
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 }));
2026-09-27 11:26:57 +03:00
// изменение 033: живое переключение поля «Организация» при смене роли в диалоге пользователя
if ( S . dialog && input . name === "role" ) $ ( "#org-field-wrap" ) ? . toggleAttribute ( "hidden" , d . value === "superadmin" );
2026-09-20 12:27:47 +03:00
},
2026-09-26 13:25:33 +03:00
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" } })),
2026-09-20 12:27:47 +03:00
"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 )),
2026-09-26 13:25:33 +03:00
"user-edit" : ( d ) => userDialog ( rowById ( d . id )),
2026-09-20 12:27:47 +03:00
"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 ),
2026-09-26 13:25:33 +03:00
"users-new" : () => userDialog ( null ),
"pw-change" : () => passwordDialog (),
2026-09-27 16:57:14 +03:00
"totp-disable-start" : () => totpDisableStartDialog (), // изменение 037
"mfa-back" : () => { pendingMfa = null ; $ ( "#app" ). innerHTML = loginScreen (); }, // изменение 037
2026-09-20 12:27:47 +03:00
"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 ; }
2026-09-26 13:25:33 +03:00
if ( e . target . name === "length" && S . subnetRefresh ) { S . subnetRefresh (); return ; }
2026-09-20 12:27:47 +03:00
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 ) => {
2026-09-27 16:57:14 +03:00
if ( e . target . id === "login-form" ) {
e . preventDefault ();
const f = new FormData ( e . target );
try {
const r = await api ( "/auth/login" , { method : "POST" , body : { username : f . get ( "username" ), password : f . get ( "password" ) } });
if ( r . mfa_required ) { // изменение 037: второй шаг — код из приложения или код восстановления
pendingMfa = { mfa_token : r . mfa_token };
$ ( "#app" ). innerHTML = loginScreen ();
return ;
}
store . token = r . access_token ;
user = null ; current = "" ;
location . hash = "#/overview" ;
draw ();
} catch ( err ) {
$ ( "#app" ). innerHTML = loginScreen ( err . message );
}
return ;
}
if ( e . target . id === "mfa-form" ) { // изменение 037
e . preventDefault ();
const code = new FormData ( e . target ). get ( "code" );
try {
const { access_token } = await api ( "/auth/login/2fa" , { method : "POST" , body : { mfa_token : pendingMfa . mfa_token , code } });
pendingMfa = null ;
store . token = access_token ;
user = null ; current = "" ;
location . hash = "#/overview" ;
draw ();
} catch ( err ) {
// 401 «Неверный код» — остаёмся на втором шаге (можно повторить); другой 401 — истёкший/недействительный
// mfa_token или отключённая 2FA — возврат к паролю (изменение 037)
if ( err instanceof ApiError && err . status === 401 && err . message !== "Неверный код" ) {
pendingMfa = null ;
$ ( "#app" ). innerHTML = loginScreen ( "Время на ввод кода истекло, войдите заново" );
return ;
}
$ ( "#app" ). innerHTML = loginScreen ( err . message );
}
2026-09-20 12:27:47 +03:00
}
});
draw ();