2026-10-31 14:22:28 +03:00
/** @file settings.js @version 0.3.3 */
2026-10-29 14:39:39 +03:00
let settingsData ;
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' } )
: 'Пока не загружен' ;
element . replaceChildren ( ... ( file . exists ? [ managerIcon ( 'check' ) , text ] : [ text ] ) ) ;
}
function drawFiles ( files ) {
const list = document . getElementById ( 'template-files' ) ;
list . replaceChildren ( ) ;
for ( const [ kind , file ] of Object . entries ( files ) ) {
const form = document . createElement ( 'form' ) ;
form . className = 'file-row' ;
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' ;
input . required = true ;
input . accept = kind === 'servers' ? '.dat' : kind === 'service' ? '.ex5' : '.exe' ;
input . setAttribute ( 'aria-label' , 'Выбрать ' + file . name ) ;
label . append ( input , document . createTextNode ( 'Выбрать' ) ) ;
const button = document . createElement ( 'button' ) ;
button . type = 'submit' ;
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 ) ;
form . append ( icon , info , controls ) ;
form . addEventListener ( 'submit' , async event => {
event . preventDefault ( ) ;
if ( button . disabled ) return ;
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' ) ;
fileMetadata ( status , settingsData . files [ kind ] ) ;
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 = 'Загрузить' ; }
} ) ;
list . append ( form ) ;
}
}
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' ;
fields . append ( settingsInput ( 'Отображаемое имя' , 'name' , record . name || folder ) ,
settingsInput ( 'Логин счёта' , 'login' , record . login || '' ) ,
settingsInput ( 'Торговый сервер' , 'server' , record . server || '' ) ,
settingsInput ( 'Пароль счёта' , 'password' , '' , 'password' ) ) ;
const password = fields . querySelector ( '[name=password]' ) ;
password . placeholder = record . password _set ? 'Оставить сохранённый' : 'Не задан' ;
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 ) ;
const footer = document . createElement ( 'footer' ) ;
footer . className = 'account-form-footer' ;
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' ;
button . textContent = 'Сохранить и применить' ;
footer . append ( collapse , button ) ;
form . append ( path , feedback , fields , clear , footer ) ;
details . append ( summary , form ) ;
details . addEventListener ( 'toggle' , ( ) => { if ( ! details . open ) password . value = '' ; } ) ;
form . addEventListener ( 'submit' , async event => {
event . preventDefault ( ) ;
if ( button . disabled ) return ;
managerFormError ( form ) ;
const values = Object . fromEntries ( new FormData ( form ) ) ;
values . clear _account = checkbox . checked ;
button . disabled = true ;
try {
const job = await managerRequest ( '/api/terminals/' + encodeURIComponent ( folder ) + '/account' , managerJson ( 'PUT' , values ) ) ;
password . value = '' ;
await managerJob ( job ) ;
settingsData = await managerRequest ( '/api/settings' ) ;
drawAccounts ( settingsData . config . terminals ) ;
} catch ( error ) { managerFormError ( form , error . message ) ; managerMessage ( error . message , 'danger' ) ; }
finally { button . disabled = false ; }
} ) ;
list . append ( details ) ;
}
revealAccount ( ) ;
}
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-10-31 06:48:02 +03:00
for ( const key of [ 'mt5_folder' , 'terminal_template' , 'mt5_installation' , 'service_ex5' , 'startup_timeout_sec' , 'disk_warning_mb' ] ) {
2026-10-29 14:39:39 +03:00
form . elements [ key ] . value = settingsData . config [ key ] ;
}
drawFiles ( settingsData . files ) ;
drawAccounts ( settingsData . config . terminals ) ;
}
document . getElementById ( 'settings-form' ) . addEventListener ( 'submit' , async event => {
event . preventDefault ( ) ;
const form = event . currentTarget ;
const values = Object . fromEntries ( new FormData ( form ) ) ;
values . startup _timeout _sec = Number ( values . startup _timeout _sec ) ;
values . disk _warning _mb = Number ( values . disk _warning _mb ) ;
const button = form . querySelector ( '[type=submit]' ) ;
if ( button . disabled ) return ;
button . disabled = true ;
try {
await managerRequest ( '/api/settings' , managerJson ( 'PUT' , values ) ) ;
await loadSettings ( ) ;
2026-10-31 06:48:02 +03:00
await loadProject ( ) ;
2026-10-29 14:39:39 +03:00
managerMessage ( 'Настройки сохранены.' , 'success' ) ;
} catch ( error ) { managerMessage ( error . message , 'danger' ) ; }
finally { button . disabled = false ; }
} ) ;
window . addEventListener ( 'hashchange' , revealAccount ) ;
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 = 'Не удалось получить состояние проекта.' ;
} ) ;