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