2026-11-02 18:42:23 +03:00
/** @file settings.js @version 0.9.0 */
2026-10-29 14:39:39 +03:00
let settingsData ;
2026-11-01 19:40:01 +03:00
let pendingDeletion = null ;
2026-10-29 14:39:39 +03:00
function settingsInput ( label , name , value , type = 'text' ) {
const wrapper = document . createElement ( 'label' ) ;
wrapper . className = 'field' ;
const caption = document . createElement ( 'span' ) ;
caption . textContent = label ;
const input = document . createElement ( 'input' ) ;
input . name = name ;
input . type = type ;
input . value = value ;
input . autocomplete = type === 'password' ? 'new-password' : 'off' ;
if ( name === 'login' ) input . inputMode = 'numeric' ;
wrapper . append ( caption , input ) ;
return wrapper ;
}
function fileMetadata ( element , file ) {
element . className = 'file-meta' + ( file . exists ? '' : ' missing' ) ;
const text = document . createElement ( 'span' ) ;
text . textContent = file . exists
? ( file . size / 1024 ) . toLocaleString ( 'ru-RU' , { maximumFractionDigits : 1 } ) + ' КиБ · ' +
new Date ( file . modified * 1000 ) . toLocaleDateString ( 'ru-RU' , { day : '2-digit' , month : '2-digit' , year : 'numeric' } )
2026-11-01 11:14:41 +03:00
: 'Не найден в выбранной папке' ;
2026-10-29 14:39:39 +03:00
element . replaceChildren ( ... ( file . exists ? [ managerIcon ( 'check' ) , text ] : [ text ] ) ) ;
}
function drawFiles ( files ) {
const list = document . getElementById ( 'template-files' ) ;
2026-11-01 11:14:41 +03:00
const status = document . getElementById ( 'source-files-status' ) ;
const upload = document . getElementById ( 'source-files-upload' ) ;
const savedFolder = document . getElementById ( 'terminal-template' ) . value . trim ( ) === settingsData . config . terminal _template ;
const missing = Object . entries ( files ) . filter ( ( [ , file ] ) => ! file . exists ) ;
2026-10-29 14:39:39 +03:00
list . replaceChildren ( ) ;
2026-11-01 11:14:41 +03:00
upload . classList . toggle ( 'is-hidden' , ! savedFolder || ! missing . length ) ;
status . dataset . tone = savedFolder && ! missing . length ? 'success' : 'neutral' ;
if ( ! savedFolder ) {
status . textContent = 'Сохраните настройки, чтобы проверить файлы в этой папке.' ;
return ;
}
status . textContent = missing . length
? 'Не найдены: ' + missing . map ( ( [ , file ] ) => file . name ) . join ( ', ' ) + '. Их можно загрузить ниже.'
: 'Всё готово: terminal64.exe и MetaEditor64.exe найдены в этой папке.' ;
if ( ! missing . length ) upload . open = false ;
for ( const [ kind , file ] of missing ) {
const row = document . createElement ( 'div' ) ;
row . className = 'file-row' ;
2026-10-29 14:39:39 +03:00
const icon = document . createElement ( 'span' ) ;
icon . className = 'file-icon' ;
icon . append ( managerIcon ( 'file' ) ) ;
const info = document . createElement ( 'div' ) ;
const name = document . createElement ( 'span' ) ;
name . className = 'file-name' ;
name . textContent = file . name ;
const status = document . createElement ( 'div' ) ;
fileMetadata ( status , file ) ;
const selection = document . createElement ( 'span' ) ;
selection . className = 'file-selected is-hidden' ;
info . append ( name , status , selection ) ;
const controls = document . createElement ( 'div' ) ;
controls . className = 'file-choice' ;
const label = document . createElement ( 'label' ) ;
label . className = 'button button-secondary button-small' ;
const input = document . createElement ( 'input' ) ;
input . className = 'file-input' ;
input . type = 'file' ;
2026-11-01 11:14:41 +03:00
input . accept = '.exe' ;
2026-10-29 14:39:39 +03:00
input . setAttribute ( 'aria-label' , 'Выбрать ' + file . name ) ;
label . append ( input , document . createTextNode ( 'Выбрать' ) ) ;
const button = document . createElement ( 'button' ) ;
2026-11-01 11:14:41 +03:00
button . type = 'button' ;
2026-10-29 14:39:39 +03:00
button . className = 'button button-soft button-small is-hidden' ;
button . textContent = 'Загрузить' ;
input . addEventListener ( 'change' , ( ) => {
const chosen = input . files [ 0 ] ;
button . classList . toggle ( 'is-hidden' , ! chosen ) ;
selection . classList . toggle ( 'is-hidden' , ! chosen ) ;
selection . textContent = chosen ? chosen . name : '' ;
selection . title = selection . textContent ;
} ) ;
controls . append ( label , button ) ;
2026-11-01 11:14:41 +03:00
row . append ( icon , info , controls ) ;
button . addEventListener ( 'click' , async ( ) => {
if ( button . disabled || ! input . files [ 0 ] ) return ;
if ( document . getElementById ( 'terminal-template' ) . value . trim ( ) !== settingsData . config . terminal _template ) {
drawFiles ( settingsData . files ) ;
managerMessage ( 'Перед загрузкой сохраните папку исходной установки.' , 'warning' ) ;
return ;
}
2026-10-29 14:39:39 +03:00
button . disabled = true ;
button . textContent = 'Загрузка…' ;
try {
await managerRequest ( '/api/files/' + kind , { method : 'PUT' , body : input . files [ 0 ] } ) ;
managerMessage ( file . name + ': файл загружен.' , 'success' ) ;
settingsData = await managerRequest ( '/api/settings' ) ;
2026-11-01 11:14:41 +03:00
drawFiles ( settingsData . files ) ;
2026-10-31 06:48:02 +03:00
await loadProject ( ) ;
2026-10-29 14:39:39 +03:00
input . value = '' ;
selection . classList . add ( 'is-hidden' ) ;
button . classList . add ( 'is-hidden' ) ;
} catch ( error ) { managerMessage ( error . message , 'danger' ) ; }
finally { button . disabled = false ; button . textContent = 'Загрузить' ; }
} ) ;
2026-11-01 11:14:41 +03:00
list . append ( row ) ;
2026-10-29 14:39:39 +03:00
}
}
function drawAccounts ( terminals ) {
const list = document . getElementById ( 'account-list' ) ;
list . replaceChildren ( ) ;
const total = Object . keys ( terminals ) . length ;
document . getElementById ( 'account-count' ) . textContent = total ? 'Всего: ' + total : '' ;
if ( ! total ) {
const empty = document . createElement ( 'p' ) ;
empty . className = 'panel panel-note' ;
empty . textContent = 'Пока нет терминалов. Создайте первый экземпляр на главной странице.' ;
list . append ( empty ) ;
}
for ( const [ folder , record ] of Object . entries ( terminals ) ) {
const details = document . createElement ( 'details' ) ;
details . className = 'account-panel' ;
details . id = 'account-' + folder ;
const summary = document . createElement ( 'summary' ) ;
summary . className = 'account-summary' ;
const avatar = document . createElement ( 'span' ) ;
avatar . className = 'account-avatar' ;
avatar . append ( managerIcon ( 'terminal' ) ) ;
const copy = document . createElement ( 'span' ) ;
copy . className = 'account-summary-copy' ;
const title = document . createElement ( 'span' ) ;
title . className = 'account-title' ;
title . textContent = record . name || folder ;
const subtitle = document . createElement ( 'span' ) ;
subtitle . className = 'account-subtitle' ;
subtitle . textContent = record . login ? String ( record . login ) + ' · ' + record . server : 'Использовать настройки терминала' ;
copy . append ( title , subtitle ) ;
const passwordState = document . createElement ( 'span' ) ;
passwordState . className = 'account-password-state' ;
passwordState . append ( managerIcon ( 'key' ) , document . createTextNode ( record . password _set ? 'Пароль сохранён' : 'Пароль не задан' ) ) ;
const chevron = managerIcon ( 'chevron' ) ;
chevron . classList . add ( 'disclosure-icon' ) ;
summary . append ( avatar , copy , passwordState , chevron ) ;
const form = document . createElement ( 'form' ) ;
form . className = 'account-form' ;
const path = document . createElement ( 'p' ) ;
path . className = 'account-path' ;
path . textContent = record . path ;
const feedback = document . createElement ( 'div' ) ;
feedback . className = 'form-feedback notice notice-danger is-hidden' ;
feedback . setAttribute ( 'role' , 'alert' ) ;
const fields = document . createElement ( 'div' ) ;
fields . className = 'form-grid' ;
2026-11-01 19:40:01 +03:00
const nameControl = document . createElement ( 'div' ) ;
nameControl . className = 'account-name-control' ;
const nameToggleLabel = document . createElement ( 'label' ) ;
nameToggleLabel . className = 'checkbox-label' ;
const nameToggle = document . createElement ( 'input' ) ;
nameToggle . type = 'checkbox' ;
nameToggleLabel . append ( nameToggle , document . createTextNode ( 'Отображать в списке под другим именем' ) ) ;
const nameField = settingsInput ( 'Отображаемое имя' , 'name' , record . name || folder ) ;
const nameInput = nameField . querySelector ( 'input' ) ;
const resetName = document . createElement ( 'button' ) ;
resetName . type = 'button' ;
resetName . className = 'button button-text button-small' ;
resetName . textContent = 'Сбросить' ;
resetName . title = 'Использовать имя папки' ;
resetName . setAttribute ( 'aria-label' , 'Использовать имя папки' ) ;
nameControl . append ( nameToggleLabel , nameField , resetName ) ;
const showCustomName = ( enabled , focus = false ) => {
nameToggle . checked = enabled ;
nameToggleLabel . classList . toggle ( 'is-hidden' , enabled ) ;
nameField . classList . toggle ( 'is-hidden' , ! enabled ) ;
resetName . classList . toggle ( 'is-hidden' , ! enabled ) ;
if ( ! enabled ) nameInput . value = fields . querySelector ( '[name=folder]' ) . value ;
if ( focus ) ( enabled ? nameInput : nameToggle ) . focus ( ) ;
} ;
nameToggle . addEventListener ( 'change' , ( ) => showCustomName ( nameToggle . checked , true ) ) ;
resetName . addEventListener ( 'click' , ( ) => showCustomName ( false , true ) ) ;
fields . append ( settingsInput ( 'Имя папки экземпляра' , 'folder' , folder ) , nameControl ,
2026-10-29 14:39:39 +03:00
settingsInput ( 'Логин счёта' , 'login' , record . login || '' ) ,
2026-11-01 11:14:41 +03:00
settingsInput ( 'Пароль счёта' , 'password' , '' , 'password' ) ,
settingsInput ( 'Торговый сервер' , 'server' , record . server || '' ) ) ;
2026-11-01 19:40:01 +03:00
showCustomName ( Boolean ( record . name && record . name !== folder ) ) ;
2026-11-01 11:14:41 +03:00
const folderInput = fields . querySelector ( '[name=folder]' ) ;
folderInput . required = true ;
folderInput . maxLength = 80 ;
2026-11-01 19:40:01 +03:00
folderInput . addEventListener ( 'input' , ( ) => {
if ( ! nameToggle . checked ) nameInput . value = folderInput . value ;
} ) ;
2026-11-01 11:14:41 +03:00
const renameHelp = document . createElement ( 'p' ) ;
renameHelp . className = 'field-help field-full' ;
2026-11-01 19:40:01 +03:00
const deleteButton = document . createElement ( 'button' ) ;
2026-11-01 11:14:41 +03:00
const setRunning = running => {
folderInput . readOnly = running ;
2026-11-01 19:40:01 +03:00
deleteButton . disabled = running ;
deleteButton . title = running ? 'Остановите терминал перед удалением экземпляра' : 'Удалить папку экземпляра и запись из конфигурации' ;
2026-11-01 11:14:41 +03:00
renameHelp . textContent = running
? 'Терминал запущен, переименование пока недоступно. Закройте терминал и заново раскройте эту форму, чтобы переименовать папку.'
: 'При изменении имени папка будет переименована на диске. Отображаемое имя меняется независимо от имени папки.' ;
} ;
setRunning ( record . running ) ;
2026-11-01 19:40:01 +03:00
nameControl . after ( renameHelp ) ;
2026-10-29 14:39:39 +03:00
const password = fields . querySelector ( '[name=password]' ) ;
password . placeholder = record . password _set ? 'Оставить сохранённый' : 'Не задан' ;
2026-11-01 19:40:01 +03:00
const serversControl = document . createElement ( 'div' ) ;
serversControl . className = 'account-server-upload' ;
2026-11-01 11:14:41 +03:00
const selectedName = document . createElement ( 'span' ) ;
2026-11-01 19:40:01 +03:00
selectedName . className = 'file-selected' ;
2026-11-01 11:14:41 +03:00
selectedName . setAttribute ( 'aria-live' , 'polite' ) ;
2026-11-01 19:40:01 +03:00
const selectedFile = document . createElement ( 'div' ) ;
selectedFile . className = 'account-selected-file is-hidden' ;
const cancelServers = document . createElement ( 'button' ) ;
cancelServers . type = 'button' ;
cancelServers . className = 'button button-text button-small' ;
cancelServers . textContent = 'Отменить' ;
cancelServers . setAttribute ( 'aria-label' , 'Отменить замену servers.dat' ) ;
selectedFile . append ( selectedName , cancelServers ) ;
2026-11-01 11:14:41 +03:00
const serversControls = document . createElement ( 'div' ) ;
serversControls . className = 'file-choice' ;
const serversLabel = document . createElement ( 'label' ) ;
serversLabel . className = 'button button-secondary button-small' ;
const serversInput = document . createElement ( 'input' ) ;
serversInput . type = 'file' ;
serversInput . className = 'file-input' ;
serversInput . accept = '.dat' ;
2026-11-01 19:40:01 +03:00
serversInput . setAttribute ( 'aria-label' , 'Заменить servers.dat' ) ;
2026-11-01 11:14:41 +03:00
serversInput . setAttribute ( 'aria-describedby' , 'servers-help-' + folder ) ;
2026-11-01 19:40:01 +03:00
serversLabel . title = 'Выберите servers.dat из папки config локального терминала с нужным брокером' ;
serversLabel . append ( serversInput , document . createTextNode ( 'Заменить servers.dat' ) ) ;
2026-11-01 11:14:41 +03:00
serversControls . append ( serversLabel ) ;
2026-11-01 19:40:01 +03:00
serversControl . append ( serversControls , selectedFile ) ;
const updateServersSelection = ( ) => {
2026-11-01 11:14:41 +03:00
selectedName . textContent = serversInput . files [ 0 ] ? . name || '' ;
selectedName . title = selectedName . textContent ;
2026-11-01 19:40:01 +03:00
selectedFile . classList . toggle ( 'is-hidden' , ! serversInput . files . length ) ;
serversHelp . classList . toggle ( 'is-hidden' , ! serversInput . files . length ) ;
} ;
serversInput . addEventListener ( 'change' , updateServersSelection ) ;
cancelServers . addEventListener ( 'click' , ( ) => {
serversInput . value = '' ;
updateServersSelection ( ) ;
serversInput . focus ( ) ;
2026-11-01 11:14:41 +03:00
} ) ;
const serversHelp = document . createElement ( 'p' ) ;
serversHelp . id = 'servers-help-' + folder ;
2026-11-01 19:40:01 +03:00
serversHelp . className = 'field-help field-full is-hidden' ;
serversHelp . textContent = 'Выберите servers.dat из папки config вашего локального терминала с нужным брокером. Файл будет заменён только у этого экземпляра после сохранения. Сначала закройте терминал. Если файл не выбран, сохраняется прежний.' ;
const connectionFields = document . createElement ( 'div' ) ;
connectionFields . className = 'form-grid' ;
for ( const name of [ 'login' , 'password' , 'server' ] ) {
connectionFields . append ( fields . querySelector ( '[name=' + name + ']' ) . closest ( 'label' ) ) ;
}
connectionFields . append ( serversControl , serversHelp ) ;
2026-10-29 14:39:39 +03:00
const clear = document . createElement ( 'label' ) ;
clear . className = 'checkbox-label' ;
const checkbox = document . createElement ( 'input' ) ;
checkbox . type = 'checkbox' ;
checkbox . name = 'clear_account' ;
const clearCopy = document . createElement ( 'span' ) ;
clearCopy . textContent = 'Убрать реквизиты из менеджера' ;
const help = document . createElement ( 'small' ) ;
help . textContent = 'Терминал будет использовать собственный сохранённый счёт. Пустое поле пароля выше сохраняет прежнее значение.' ;
clearCopy . append ( help ) ;
clear . append ( checkbox , clearCopy ) ;
2026-11-01 19:40:01 +03:00
const connectionGroup = document . createElement ( 'details' ) ;
connectionGroup . className = 'form-section' ;
connectionGroup . open = Boolean ( record . login || record . server || record . password _set ) ;
const connectionSummary = document . createElement ( 'summary' ) ;
const connectionChevron = managerIcon ( 'chevron' ) ;
connectionChevron . classList . add ( 'disclosure-icon' ) ;
connectionSummary . append ( connectionChevron , document . createTextNode (
connectionGroup . open ? 'Реквизиты счёта' : 'Указать реквизиты счёта' ) ) ;
const connectionBody = document . createElement ( 'div' ) ;
connectionBody . className = 'form-section-body' ;
connectionBody . append ( connectionFields , clear ) ;
connectionGroup . append ( connectionSummary , connectionBody ) ;
2026-10-29 14:39:39 +03:00
const footer = document . createElement ( 'footer' ) ;
footer . className = 'account-form-footer' ;
2026-11-01 19:40:01 +03:00
deleteButton . type = 'button' ;
deleteButton . className = 'button button-text account-delete' ;
deleteButton . textContent = 'Удалить экземпляр' ;
deleteButton . addEventListener ( 'click' , ( ) => {
pendingDeletion = folder ;
document . getElementById ( 'delete-instance-name' ) . textContent = record . name || folder ;
document . getElementById ( 'delete-instance-path' ) . textContent = record . path ;
const feedback = document . getElementById ( 'delete-instance-feedback' ) ;
feedback . textContent = '' ;
feedback . classList . add ( 'is-hidden' ) ;
document . getElementById ( 'delete-instance-dialog' ) . showModal ( ) ;
} ) ;
2026-10-29 14:39:39 +03:00
const collapse = document . createElement ( 'button' ) ;
collapse . type = 'button' ;
collapse . className = 'button button-text' ;
collapse . textContent = 'Свернуть' ;
collapse . addEventListener ( 'click' , ( ) => { details . open = false ; summary . focus ( ) ; } ) ;
const button = document . createElement ( 'button' ) ;
button . type = 'submit' ;
button . className = 'button button-primary' ;
2026-11-01 19:40:01 +03:00
button . textContent = 'Сохранить' ;
const testButton = document . createElement ( 'button' ) ;
testButton . type = 'button' ;
testButton . className = 'button button-secondary' ;
testButton . textContent = 'Проверить подключение' ;
const connectionHelp = document . createElement ( 'p' ) ;
connectionHelp . className = 'field-help' ;
connectionHelp . textContent = 'Сохранение не запускает и не переподключает терминал. Проверка использует реквизиты из формы без сохранения. После проверки терминал остаётся запущенным, если работал до неё, либо закрывается, если был остановлен.' ;
footer . append ( deleteButton , collapse , testButton , button ) ;
form . append ( path , feedback , fields , connectionGroup , connectionHelp , footer ) ;
2026-10-29 14:39:39 +03:00
details . append ( summary , form ) ;
2026-11-01 11:14:41 +03:00
details . addEventListener ( 'toggle' , async ( ) => {
if ( ! details . open ) { password . value = '' ; return ; }
try {
const fresh = await managerRequest ( '/api/settings' ) ;
const current = fresh . config . terminals [ folder ] ;
if ( current ) setRunning ( current . running ) ;
} catch ( error ) { managerFormError ( form , error . message ) ; }
} ) ;
2026-11-01 19:40:01 +03:00
testButton . addEventListener ( 'click' , async ( ) => {
if ( testButton . disabled || ! form . reportValidity ( ) ) return ;
managerFormError ( form ) ;
if ( ! form . elements . login . value . trim ( ) || ! form . elements . server . value . trim ( ) ) {
connectionGroup . open = true ;
managerFormError ( form , 'Для проверки подключения укажите логин и торговый сервер.' ) ;
form . elements . login . focus ( ) ;
return ;
}
if ( serversInput . files . length ) {
managerFormError ( form , 'Сначала сохраните выбранный servers.dat, затем проверьте подключение.' ) ;
return ;
}
const values = Object . fromEntries ( new FormData ( form ) ) ;
values . clear _account = checkbox . checked ;
deleteButton . disabled = testButton . disabled = button . disabled = true ;
try {
const job = await managerRequest ( '/api/terminals/' + encodeURIComponent ( folder ) + '/test-connection' , managerJson ( 'POST' , values ) ) ;
await managerJob ( job ) ;
managerMessage ( 'Подключение подтверждено. Настройки менеджера не сохранены.' , 'success' ) ;
} catch ( error ) {
managerFormError ( form , error . message ) ;
managerMessage ( error . message , 'danger' ) ;
} finally {
deleteButton . disabled = folderInput . readOnly ;
testButton . disabled = button . disabled = false ;
// Состояние восстановлено; перечитываем запрет переименования.
try {
const fresh = await managerRequest ( '/api/settings' ) ;
if ( fresh . config . terminals [ folder ] ) setRunning ( fresh . config . terminals [ folder ] . running ) ;
} catch { /* Состояние обновится при следующем раскрытии формы. */ }
}
} ) ;
2026-10-29 14:39:39 +03:00
form . addEventListener ( 'submit' , async event => {
event . preventDefault ( ) ;
if ( button . disabled ) return ;
managerFormError ( form ) ;
const values = Object . fromEntries ( new FormData ( form ) ) ;
2026-11-01 19:40:01 +03:00
values . name = nameToggle . checked ? nameInput . value . trim ( ) || values . folder : values . folder ;
2026-10-29 14:39:39 +03:00
values . clear _account = checkbox . checked ;
2026-11-01 19:40:01 +03:00
deleteButton . disabled = testButton . disabled = button . disabled = true ;
2026-10-29 14:39:39 +03:00
try {
2026-11-01 11:14:41 +03:00
const serversFile = serversInput . files [ 0 ] ;
if ( serversFile ) {
if ( ! serversFile . size || serversFile . size > 16 * 1024 * 1024 ) {
throw new Error ( 'Выберите непустой servers.dat размером не более 16 МиБ.' ) ;
}
values . servers _dat = await new Promise ( ( resolve , reject ) => {
const reader = new FileReader ( ) ;
reader . onload = ( ) => resolve ( reader . result . slice ( reader . result . indexOf ( ',' ) + 1 ) ) ;
reader . onerror = ( ) => reject ( new Error ( 'Не удалось прочитать servers.dat.' ) ) ;
reader . readAsDataURL ( serversFile ) ;
} ) ;
}
2026-10-29 14:39:39 +03:00
const job = await managerRequest ( '/api/terminals/' + encodeURIComponent ( folder ) + '/account' , managerJson ( 'PUT' , values ) ) ;
password . value = '' ;
2026-11-01 11:14:41 +03:00
const finished = await managerJob ( job ) ;
2026-11-01 19:40:01 +03:00
managerMessage ( finished . result . folder + ': настройки сохранены. Подключение не проверялось.' , 'success' ) ;
2026-11-01 11:14:41 +03:00
history . replaceState ( null , '' , '#account-' + encodeURIComponent ( finished . result . folder ) ) ;
2026-10-29 14:39:39 +03:00
settingsData = await managerRequest ( '/api/settings' ) ;
drawAccounts ( settingsData . config . terminals ) ;
2026-11-01 11:14:41 +03:00
} catch ( error ) {
managerFormError ( form , error . message ) ;
managerMessage ( error . message , 'danger' ) ;
2026-11-01 19:40:01 +03:00
// Сверяем имя после ошибки сохранения или переименования.
2026-11-01 11:14:41 +03:00
if ( values . folder !== folder ) {
try {
const fresh = await managerRequest ( '/api/settings' ) ;
if ( ! fresh . config . terminals [ folder ] && fresh . config . terminals [ values . folder ] ) {
settingsData = fresh ;
history . replaceState ( null , '' , '#account-' + encodeURIComponent ( values . folder ) ) ;
drawAccounts ( fresh . config . terminals ) ;
}
} catch { /* Сообщение исходной операции остаётся на странице. */ }
}
}
2026-11-01 19:40:01 +03:00
finally {
deleteButton . disabled = folderInput . readOnly ;
testButton . disabled = button . disabled = false ;
}
2026-10-29 14:39:39 +03:00
} ) ;
list . append ( details ) ;
}
revealAccount ( ) ;
}
2026-11-01 19:40:01 +03:00
document . getElementById ( 'delete-instance-confirm' ) . addEventListener ( 'click' , async event => {
const button = event . currentTarget ;
if ( button . disabled || ! pendingDeletion ) return ;
const folder = pendingDeletion ;
const dialog = document . getElementById ( 'delete-instance-dialog' ) ;
const feedback = document . getElementById ( 'delete-instance-feedback' ) ;
const cancel = dialog . querySelector ( '[data-dialog-close]' ) ;
button . disabled = cancel . disabled = true ;
feedback . textContent = 'Удаляем экземпляр. Дождитесь завершения операции.' ;
feedback . classList . remove ( 'is-hidden' ) ;
try {
const job = await managerRequest ( '/api/terminals/' + encodeURIComponent ( folder ) , { method : 'DELETE' } ) ;
const finished = await managerJob ( job ) ;
dialog . close ( ) ;
history . replaceState ( null , '' , location . pathname + location . search ) ;
settingsData = await managerRequest ( '/api/settings' ) ;
drawAccounts ( settingsData . config . terminals ) ;
const warnings = ( finished . warnings || [ ] ) . join ( ' ' ) ;
managerMessage ( folder + ': экземпляр удалён.' + ( warnings ? ' ' + warnings : '' ) , warnings ? 'warning' : 'success' ) ;
await loadProject ( ) ;
} catch ( error ) {
feedback . textContent = error . message ;
managerMessage ( error . message , 'danger' ) ;
} finally {
button . disabled = cancel . disabled = false ;
}
} ) ;
document . getElementById ( 'delete-instance-dialog' ) . addEventListener ( 'cancel' , event => {
if ( document . getElementById ( 'delete-instance-confirm' ) . disabled ) event . preventDefault ( ) ;
} ) ;
2026-10-29 14:39:39 +03:00
function revealAccount ( ) {
let id ;
try { id = decodeURIComponent ( location . hash . slice ( 1 ) ) ; } catch { return ; }
if ( ! id . startsWith ( 'account-' ) ) return ;
const element = document . getElementById ( id ) ;
if ( element ) {
element . open = true ;
element . scrollIntoView ( { block : 'start' } ) ;
}
}
async function loadSettings ( ) {
settingsData = await managerRequest ( '/api/settings' ) ;
const form = document . getElementById ( 'settings-form' ) ;
2026-11-01 11:14:41 +03:00
for ( const key of [ 'mt5_folder' , 'terminal_template' , 'startup_timeout_sec' , 'disk_warning_mb' ] ) {
2026-10-29 14:39:39 +03:00
form . elements [ key ] . value = settingsData . config [ key ] ;
}
2026-11-02 18:42:23 +03:00
const base = form . elements . mt5 _folder ;
base . disabled = Boolean ( settingsData . base _folder _lock ) ;
base . closest ( 'label' ) . classList . toggle ( 'field-locked' , base . disabled ) ;
document . getElementById ( 'base-folder-lock' ) . classList . toggle ( 'is-hidden' , ! base . disabled ) ;
document . getElementById ( 'base-folder-help' ) . textContent = settingsData . base _folder _lock ||
'Все экземпляры находятся в подпапках по своим именам. При смене базовой папки менеджер предложит перенести их в новое место, предварительно остановив запущенные терминалы.' ;
2026-10-29 14:39:39 +03:00
drawFiles ( settingsData . files ) ;
drawAccounts ( settingsData . config . terminals ) ;
}
2026-11-02 18:42:23 +03:00
function confirmSettingsMove ( plan ) {
const dialog = document . getElementById ( 'move-terminals-dialog' ) ;
document . getElementById ( 'move-source' ) . textContent = plan . source ;
document . getElementById ( 'move-target' ) . textContent = plan . destination ;
document . getElementById ( 'move-description' ) . textContent = 'Будут перенесены папки экземпляров: ' + plan . folders . join ( ', ' ) + '.' ;
document . getElementById ( 'move-running' ) . textContent = plan . running . length
? 'Сначала менеджер штатно остановит: ' + plan . running . join ( ', ' ) + '. После переноса терминалы останутся остановленными.'
: 'Все терминалы остановлены. После переноса они останутся остановленными.' ;
document . getElementById ( 'move-missing' ) . textContent = plan . missing . length
? 'В старой папке не найдены: ' + plan . missing . join ( ', ' ) + '. После сохранения они тоже будут искаться в новой папке.' : '' ;
document . getElementById ( 'move-terminals-confirm' ) . textContent = plan . running . length ? 'Остановить и перенести' : 'Перенести' ;
return new Promise ( resolve => {
dialog . returnValue = '' ;
dialog . addEventListener ( 'close' , ( ) => resolve ( dialog . returnValue === 'move' ) , { once : true } ) ;
dialog . showModal ( ) ;
} ) ;
}
document . getElementById ( 'move-terminals-confirm' ) . addEventListener ( 'click' , ( ) => {
document . getElementById ( 'move-terminals-dialog' ) . close ( 'move' ) ;
} ) ;
2026-10-29 14:39:39 +03:00
document . getElementById ( 'settings-form' ) . addEventListener ( 'submit' , async event => {
event . preventDefault ( ) ;
const form = event . currentTarget ;
const values = Object . fromEntries ( new FormData ( form ) ) ;
2026-11-02 18:42:23 +03:00
// Отключённое поле не входит в FormData, но остальные настройки сохраняются.
if ( form . elements . mt5 _folder . disabled ) values . mt5 _folder = settingsData . config . mt5 _folder ;
2026-10-29 14:39:39 +03:00
values . startup _timeout _sec = Number ( values . startup _timeout _sec ) ;
values . disk _warning _mb = Number ( values . disk _warning _mb ) ;
2026-11-01 11:14:41 +03:00
const buttons = Array . from ( form . elements ) . filter ( element => element . type === 'submit' ) ;
if ( buttons . some ( button => button . disabled ) ) return ;
buttons . forEach ( button => { button . disabled = true ; } ) ;
2026-10-29 14:39:39 +03:00
try {
2026-11-02 18:42:23 +03:00
const plan = await managerRequest ( '/api/settings/plan' , managerJson ( 'POST' , values ) ) ;
if ( plan . required ) {
if ( ! await confirmSettingsMove ( plan ) ) {
form . elements . mt5 _folder . value = settingsData . config . mt5 _folder ;
return ;
}
values . move _token = plan . token ;
values . stop _running = plan . running . length > 0 ;
}
const result = await managerRequest ( '/api/settings' , managerJson ( 'PUT' , values ) ) ;
const finished = result . id ? await managerJob ( result , job => {
if ( job . status === 'queued' || job . status === 'running' ) {
managerMessage ( 'Перенос терминалов: ' + job . stage , 'info' , 0 ) ;
}
} ) : null ;
2026-10-29 14:39:39 +03:00
await loadSettings ( ) ;
2026-10-31 06:48:02 +03:00
await loadProject ( ) ;
2026-11-02 18:42:23 +03:00
const warnings = ( finished ? . warnings || [ ] ) . join ( ' ' ) ;
managerMessage ( ( finished ? 'Терминалы перенесены. ' : '' ) + 'Настройки сохранены.' + ( warnings ? ' ' + warnings : '' ) , warnings ? 'warning' : 'success' ) ;
2026-10-29 14:39:39 +03:00
} catch ( error ) { managerMessage ( error . message , 'danger' ) ; }
2026-11-01 11:14:41 +03:00
finally { buttons . forEach ( button => { button . disabled = false ; } ) ; }
2026-10-29 14:39:39 +03:00
} ) ;
window . addEventListener ( 'hashchange' , revealAccount ) ;
2026-11-01 11:14:41 +03:00
document . getElementById ( 'terminal-template' ) . addEventListener ( 'input' , ( ) => {
if ( settingsData ) drawFiles ( settingsData . files ) ;
} ) ;
2026-10-29 14:39:39 +03:00
loadSettings ( ) . catch ( error => managerMessage ( error . message , 'danger' ) ) ;
let projectBusy = false ;
let projectState ;
function renderProject ( ) {
const git = projectState . git ;
const service = projectState . service ;
const status = document . getElementById ( 'project-git-status' ) ;
const help = document . getElementById ( 'project-git-help' ) ;
if ( git . error ) {
status . textContent = git . error ;
help . textContent = 'Для обновления нужен Git и настроенная удалённая ветка (upstream).' ;
} else {
const checked = git . checked _at ? ' Проверено: ' + new Date ( git . checked _at * 1000 ) . toLocaleString ( 'ru-RU' ) + '.' : ' Нажмите «Проверить обновления», чтобы обратиться к серверу Git.' ;
status . textContent = git . branch + ' · ' + git . revision + ' → ' + git . upstream + '.' + checked ;
const messages = [ ] ;
if ( git . behind ) messages . push ( 'Доступных коммитов: ' + git . behind + '.' ) ;
else messages . push ( git . checked _at ? 'Новых коммитов не найдено.' : 'Показано локальное состояние удалённой ветки.' ) ;
if ( git . dirty ) messages . push ( 'Есть локальные изменения: перед загрузкой сохраните их в Git.' ) ;
if ( git . ahead ) messages . push ( 'Есть собственные коммиты: объединение выполняется вручную.' ) ;
if ( projectState . reload _enabled ) messages . push ( 'Для загрузки обновления перезапустите менеджер без --reload.' ) ;
help . textContent = messages . join ( ' ' ) ;
}
document . getElementById ( 'project-service-status' ) . textContent = ! service . available
2026-10-31 06:48:02 +03:00
? service . error
2026-10-29 14:39:39 +03:00
: ( service . needs _compile ? 'Требуется сборка сервиса. ' : 'Сборка не старее исходника. ' ) +
2026-10-31 14:22:28 +03:00
( service . pending . length ? 'Требуется обновление EX5 или автозапуска у экземпляров: ' + service . pending . join ( ', ' ) + '.' : 'EX5 и автозапуск существующих экземпляров настроены.' ) ;
2026-10-29 14:39:39 +03:00
const update = document . getElementById ( 'project-update' ) ;
update . classList . toggle ( 'is-hidden' , ! git . behind ) ;
update . disabled = projectBusy || ! git . behind || git . dirty || Boolean ( git . ahead ) || projectState . reload _enabled ;
document . getElementById ( 'project-check' ) . disabled = projectBusy || Boolean ( git . error ) ;
document . getElementById ( 'project-deploy' ) . disabled = projectBusy || ! service . available ;
}
async function loadProject ( ) {
projectState = await managerRequest ( '/api/project' ) ;
renderProject ( ) ;
}
async function projectOperation ( action ) {
if ( projectBusy ) return ;
projectBusy = true ;
renderProject ( ) ;
const feedback = document . getElementById ( 'project-feedback' ) ;
const labels = { check : 'Проверка обновлений' , update : 'Обновление проекта' , deploy : 'Сборка и распространение сервиса' } ;
feedback . className = 'notice notice-info' ;
feedback . textContent = labels [ action ] + '…' ;
try {
let job = await managerRequest ( '/api/project/' + action , { method : 'POST' } ) ;
while ( job . status === 'queued' || job . status === 'running' ) {
feedback . textContent = job . stage + '…' ;
await new Promise ( resolve => setTimeout ( resolve , 1000 ) ) ;
job = await managerRequest ( '/api/jobs/' + encodeURIComponent ( job . id ) ) ;
}
if ( job . status === 'error' ) throw new Error ( [ job . error , ... job . warnings ] . join ( ' ' ) ) ;
const result = job . result || { } ;
let text = action === 'check' ? 'Проверка завершена.' : action === 'update'
? ( result . updated ? 'Проект обновлён до ' + result . revision + '.' : 'Новых коммитов нет.' )
: 'Сервис собран. Обработано экземпляров: ' + result . copied . length + '.' ;
if ( job . warnings . length ) text += ' ' + job . warnings . join ( ' ' ) ;
feedback . textContent = text ;
feedback . className = 'notice notice-' + ( job . warnings . length ? 'warning' : 'success' ) ;
settingsData = await managerRequest ( '/api/settings' ) ;
drawFiles ( settingsData . files ) ;
} catch ( error ) {
feedback . textContent = error . message ;
feedback . className = 'notice notice-danger' ;
} finally {
projectBusy = false ;
try { await loadProject ( ) ; } catch { renderProject ( ) ; }
}
}
for ( const action of [ 'check' , 'update' , 'deploy' ] ) {
document . getElementById ( 'project-' + action ) . addEventListener ( 'click' , ( ) => projectOperation ( action ) ) ;
}
loadProject ( ) . catch ( error => {
document . getElementById ( 'project-git-status' ) . textContent = error . message ;
document . getElementById ( 'project-service-status' ) . textContent = 'Не удалось получить состояние проекта.' ;
} ) ;