| /** @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 = 'Не удалось получить состояние проекта.';
|
| });
|