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