mt5-manager/static/settings.js
312 lines
16 KiB
JavaScript
/** @file settings.js @version 0.3.3 */
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]);
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(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');
for (const key of ['mt5_folder', 'terminal_template', 'mt5_installation', 'service_ex5', 'startup_timeout_sec', 'disk_warning_mb']) {
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();
await loadProject();
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
? 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 = 'Не удалось получить состояние проекта.';
});