Suivre
1
1
Forker
Vous avez déjà forké mt5-manager
1
forké depuis antekov/mt5-manager
mt5-manager/static/settings.js
607 lignes
36 Kio
JavaScript
BrutLien permanentVue normaleHistorique
/** @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 = 'Не удалось получить состояние проекта.';
});