| /** @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));
|