bifurcado de antekov/mt5-manager
/** @file settings.js @version 0.9.0 */
| |
let settingsData;
| |
let pendingDeletion = null;
| |
| |
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');
| |
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);
| |
list.replaceChildren();
| |
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';
| |
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.accept = '.exe';
| |
input.setAttribute('aria-label', 'Выбрать ' + file.name);
| |
label.append(input, document.createTextNode('Выбрать'));
| |
const button = document.createElement('button');
| |
button.type = 'button';
| |
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);
| |
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;
| |
}
| |
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');
| |
drawFiles(settingsData.files);
| |
await loadProject();
| |
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(row);
| |
}
| |
}
| |
| |
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';
| |
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,
| |
settingsInput('Логин счёта', 'login', record.login || ''),
| |
settingsInput('Пароль счёта', 'password', '', 'password'),
| |
settingsInput('Торговый сервер', 'server', record.server || ''));
| |
showCustomName(Boolean(record.name && record.name !== folder));
| |
const folderInput = fields.querySelector('[name=folder]');
| |
folderInput.required = true;
| |
folderInput.maxLength = 80;
| |
folderInput.addEventListener('input', () => {
| |
if (!nameToggle.checked) nameInput.value = folderInput.value;
| |
});
| |
const renameHelp = document.createElement('p');
| |
renameHelp.className = 'field-help field-full';
| |
const deleteButton = document.createElement('button');
| |
const setRunning = running => {
| |
folderInput.readOnly = running;
| |
deleteButton.disabled = running;
| |
deleteButton.title = running ? 'Остановите терминал перед удалением экземпляра' : 'Удалить папку экземпляра и запись из конфигурации';
| |
renameHelp.textContent = running
| |
? 'Терминал запущен, переименование пока недоступно. Закройте терминал и заново раскройте эту форму, чтобы переименовать папку.'
| |
: 'При изменении имени папка будет переименована на диске. Отображаемое имя меняется независимо от имени папки.';
| |
};
| |
setRunning(record.running);
| |
nameControl.after(renameHelp);
| |
const password = fields.querySelector('[name=password]');
| |
password.placeholder = record.password_set ? 'Оставить сохранённый' : 'Не задан';
| |
const serversControl = document.createElement('div');
| |
serversControl.className = 'account-server-upload';
| |
const selectedName = document.createElement('span');
| |
selectedName.className = 'file-selected';
| |
selectedName.setAttribute('aria-live', 'polite');
| |
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);
| |
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';
| |
serversInput.setAttribute('aria-label', 'Заменить servers.dat');
| |
serversInput.setAttribute('aria-describedby', 'servers-help-' + folder);
| |
serversLabel.title = 'Выберите servers.dat из папки config локального терминала с нужным брокером';
| |
serversLabel.append(serversInput, document.createTextNode('Заменить servers.dat'));
| |
serversControls.append(serversLabel);
| |
serversControl.append(serversControls, selectedFile);
| |
const updateServersSelection = () => {
| |
selectedName.textContent = serversInput.files[0]?.name || '';
| |
selectedName.title = selectedName.textContent;
| |
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();
| |
});
| |
const serversHelp = document.createElement('p');
| |
serversHelp.id = 'servers-help-' + folder;
| |
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);
| |
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 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);
| |
const footer = document.createElement('footer');
| |
footer.className = 'account-form-footer';
| |
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();
| |
});
| |
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 = 'Сохранить';
| |
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);
| |
details.append(summary, form);
| |
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); }
| |
});
| |
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 { /* Состояние обновится при следующем раскрытии формы. */ }
| |
}
| |
});
| |
form.addEventListener('submit', async event => {
| |
event.preventDefault();
| |
if (button.disabled) return;
| |
managerFormError(form);
| |
const values = Object.fromEntries(new FormData(form));
| |
values.name = nameToggle.checked ? nameInput.value.trim() || values.folder : values.folder;
| |
values.clear_account = checkbox.checked;
| |
deleteButton.disabled = testButton.disabled = button.disabled = true;
| |
try {
| |
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);
| |
});
| |
}
| |
const job = await managerRequest('/api/terminals/' + encodeURIComponent(folder) + '/account', managerJson('PUT', values));
| |
password.value = '';
| |
const finished = await managerJob(job);
| |
managerMessage(finished.result.folder + ': настройки сохранены. Подключение не проверялось.', 'success');
| |
history.replaceState(null, '', '#account-' + encodeURIComponent(finished.result.folder));
| |
settingsData = await managerRequest('/api/settings');
| |
drawAccounts(settingsData.config.terminals);
| |
} catch (error) {
| |
managerFormError(form, error.message);
| |
managerMessage(error.message, 'danger');
| |
// Сверяем имя после ошибки сохранения или переименования.
| |
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 { /* Сообщение исходной операции остаётся на странице. */ }
| |
}
| |
}
| |
finally {
| |
deleteButton.disabled = folderInput.readOnly;
| |
testButton.disabled = button.disabled = false;
| |
}
| |
});
| |
list.append(details);
| |
}
| |
revealAccount();
| |
}
| |
| |
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();
| |
});
| |
| |
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');
| |
for (const key of ['mt5_folder', 'terminal_template', 'startup_timeout_sec', 'disk_warning_mb']) {
| |
form.elements[key].value = settingsData.config[key];
| |
}
| |
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 ||
| |
'Все экземпляры находятся в подпапках по своим именам. При смене базовой папки менеджер предложит перенести их в новое место, предварительно остановив запущенные терминалы.';
| |
drawFiles(settingsData.files);
| |
drawAccounts(settingsData.config.terminals);
| |
}
| |
| |
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');
| |
});
| |
| |
document.getElementById('settings-form').addEventListener('submit', async event => {
| |
event.preventDefault();
| |
const form = event.currentTarget;
| |
const values = Object.fromEntries(new FormData(form));
| |
// Отключённое поле не входит в FormData, но остальные настройки сохраняются.
| |
if (form.elements.mt5_folder.disabled) values.mt5_folder = settingsData.config.mt5_folder;
| |
values.startup_timeout_sec = Number(values.startup_timeout_sec);
| |
values.disk_warning_mb = Number(values.disk_warning_mb);
| |
const buttons = Array.from(form.elements).filter(element => element.type === 'submit');
| |
if (buttons.some(button => button.disabled)) return;
| |
buttons.forEach(button => { button.disabled = true; });
| |
try {
| |
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;
| |
await loadSettings();
| |
await loadProject();
| |
const warnings = (finished?.warnings || []).join(' ');
| |
managerMessage((finished ? 'Терминалы перенесены. ' : '') + 'Настройки сохранены.' + (warnings ? ' ' + warnings : ''), warnings ? 'warning' : 'success');
| |
} catch (error) { managerMessage(error.message, 'danger'); }
| |
finally { buttons.forEach(button => { button.disabled = false; }); }
| |
});
| |
| |
window.addEventListener('hashchange', revealAccount);
| |
document.getElementById('terminal-template').addEventListener('input', () => {
| |
if (settingsData) drawFiles(settingsData.files);
| |
});
| |
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
| |
? service.error
| |
: (service.needs_compile ? 'Требуется сборка сервиса. ' : 'Сборка не старее исходника. ') +
| |
(service.pending.length ? 'Требуется обновление EX5 или автозапуска у экземпляров: ' + service.pending.join(', ') + '.' : 'EX5 и автозапуск существующих экземпляров настроены.');
| |
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 = 'Не удалось получить состояние проекта.';
| |
});
|