/** @file script.js @version 0.5.0 */ class TerminalCard { constructor(element) { this.element = element; this.folder = element.dataset.folder; this.data = {}; this.busy = false; this.loaded = false; } text(selector, value) { this.element.querySelector(selector).textContent = value; } amount(value, signed = false) { if (value === null || value === undefined || !Number.isFinite(Number(value))) return '—'; return new Intl.NumberFormat('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2, signDisplay: signed ? 'always' : 'auto' }).format(value); } updateData(data) { this.data = data; this.loaded = true; this.element.classList.remove('loading-card'); const account = data.account; const login = account?.login || data.login; const server = account?.server || data.server; this.text('.account', login ? 'Счёт ···' + String(login).slice(-4) : 'Счёт не задан'); this.text('.account-server', server || 'Без подключения'); this.element.querySelector('.account').title = login ? String(login) : ''; const broker = this.element.querySelector('.server'); broker.className = 'server' + (server ? ' server-' + server.replace(/[^a-zA-Z0-9_-]/g, '_') : ''); if (account) { this.text('.balance', this.amount(account.balance)); this.text('.equity', this.amount(account.equity)); this.text('.profit', this.amount(account.profit, true)); this.text('.margin', this.amount(account.margin)); this.text('.currency', account.currency ? '· ' + account.currency : ''); this.element.querySelector('.profit').classList.toggle('neg', account.profit < 0); const percent = this.element.querySelector('.profit-pct'); percent.classList.toggle('is-hidden', !account.balance); percent.classList.toggle('neg', account.profit < 0); percent.textContent = new Intl.NumberFormat('ru-RU', { style: 'percent', maximumFractionDigits: 2, minimumFractionDigits: 2, signDisplay: 'always' }).format(account.balance ? account.profit / account.balance : 0); } if (data.last_update) { const date = new Date(data.last_update * 1000); const today = date.toDateString() === new Date().toDateString(); this.text('.time', (today ? '' : date.toLocaleDateString('ru-RU', {day:'2-digit', month:'2-digit'}) + ' · ') + date.toLocaleTimeString('ru-RU', {hour:'2-digit', minute:'2-digit', second:'2-digit'})); this.element.querySelector('.update-time').title = 'Последнее обновление: ' + date.toLocaleString('ru-RU'); } else if (!data.pid) { this.text('.time', 'Ожидает запуска'); } else { this.text('.time', 'Ожидаем данные'); } this.renderState(); } renderState() { const data = this.data; let state = 'stopped', text = 'Остановлен', tone = 'neutral'; if (this.busy) { state = 'starting'; text = 'Выполняется операция'; tone = 'warning'; } else if (data.pid && data.status === 'running' && data.last_error?.code === 1) { state = 'running'; text = 'Работает'; tone = 'success'; } else if (data.pid && data.status === 'running') { state = 'disconnected'; text = 'Нет подключения'; tone = 'danger'; } else if (data.pid) { state = 'starting'; text = 'Запускается'; tone = 'warning'; } this.element.dataset.state = state; const badge = this.element.querySelector('.status-badge'); badge.dataset.tone = tone; badge.title = data.last_error?.description || ''; this.text('.description', text); this.element.querySelector('.btn-start').disabled = this.busy || Boolean(data.pid); this.element.querySelector('.btn-stop').disabled = this.busy || !data.pid; } } const cards = Object.create(null); let updateInterval; let selectedFilter = 'all'; function updateTerminalList() { const values = Object.values(cards); const active = values.filter(card => card.data.pid).length; const ready = values.every(card => card.loaded); document.getElementById('count-active').textContent = ready ? active : '…'; document.getElementById('count-stopped').textContent = ready ? values.length - active : '…'; const query = document.getElementById('terminal-search').value.trim().toLocaleLowerCase(); let visible = 0; for (const card of values) { const searchText = [card.folder, card.element.querySelector('h2').textContent, card.data.server, card.data.login, card.data.account?.server, card.data.account?.login].join(' ').toLocaleLowerCase(); const stateMatch = selectedFilter === 'all' || (selectedFilter === 'active' ? Boolean(card.data.pid) : !card.data.pid); const show = stateMatch && searchText.includes(query); card.element.hidden = !show; if (show) visible++; } document.getElementById('terminal-empty').classList.toggle('is-hidden', visible > 0); } async function fetchTerminalInfo(folder) { const card = cards[folder]; try { const data = await managerRequest('/instances/' + encodeURIComponent(folder), {method:'POST'}); card.updateData(data); updateTerminalList(); } catch (error) { const badge = card.element.querySelector('.status-badge'); badge.dataset.tone = 'danger'; badge.title = error.message; card.text('.description', 'Нет ответа'); } } async function terminalOperation(folder, action) { const card = cards[folder]; if (card.busy) return; card.busy = true; card.renderState(); try { await managerJob(await managerRequest('/' + action + '/' + encodeURIComponent(folder), {method:'POST'})); } catch (error) { managerMessage(error.message, 'danger'); } finally { card.busy = false; card.renderState(); await fetchTerminalInfo(folder); } } document.addEventListener('DOMContentLoaded', () => { document.querySelectorAll('.terminal-card').forEach(element => { cards[element.dataset.folder] = new TerminalCard(element); }); document.querySelectorAll('[data-action]').forEach(button => { button.addEventListener('click', () => terminalOperation(button.dataset.folder, button.dataset.action)); }); document.querySelectorAll('[data-filter]').forEach(button => { button.addEventListener('click', () => { selectedFilter = button.dataset.filter; document.querySelectorAll('[data-filter]').forEach(item => { item.classList.toggle('active', item === button); item.setAttribute('aria-pressed', String(item === button)); }); updateTerminalList(); }); }); document.getElementById('terminal-search').addEventListener('input', updateTerminalList); managerRequest('/api/settings').then(data => { document.getElementById('new-folder').value = data.suggested_name; }).catch(error => managerMessage(error.message, 'danger')); document.getElementById('create-terminal').addEventListener('submit', async event => { event.preventDefault(); const form = event.currentTarget; const button = form.querySelector('[type=submit]'); if (button.disabled) return; managerFormError(form); const values = Object.fromEntries(new FormData(form)); button.disabled = true; try { const job = await managerRequest('/api/terminals', managerJson('POST', values)); document.getElementById('create-dialog').close(); const finished = await managerJob(job); sessionStorage.setItem('manager-result', JSON.stringify(finished)); location.reload(); } catch (error) { managerFormError(form, error.message); managerMessage(error.message, 'danger'); } finally { button.disabled = false; } }); const previous = sessionStorage.getItem('manager-result'); if (previous) { sessionStorage.removeItem('manager-result'); const job = JSON.parse(previous); managerMessage(job.folder + ': создан. ' + job.warnings.join(' '), job.warnings.length ? 'warning' : 'success'); } terminals.forEach(fetchTerminalInfo); updateInterval = setInterval(() => terminals.forEach(fetchTerminalInfo), 10000); }); window.addEventListener('beforeunload', () => clearInterval(updateInterval));