2026-10-29 14:39:39 +03:00 | | | /** @file script.js @version 0.5.0 */
|
2025-10-24 02:13:35 +03:00 | | | class TerminalCard {
|
2026-10-29 14:39:39 +03:00 | | | constructor(element) {
|
| | | this.element = element;
|
| | | this.folder = element.dataset.folder;
|
| | | this.data = {};
|
| | | this.busy = false;
|
| | | this.loaded = false;
|
2025-10-24 02:13:35 +03:00 | | | }
|
2025-10-10 10:16:59 +03:00 | | |
|
2026-10-29 14:39:39 +03:00 | | | text(selector, value) { this.element.querySelector(selector).textContent = value; }
|
2025-10-30 22:41:27 +03:00 | | |
|
2026-10-29 14:39:39 +03:00 | | | 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);
|
2025-10-30 22:41:27 +03:00 | | | }
|
| | |
|
2025-10-24 02:13:35 +03:00 | | | updateData(data) {
|
2026-10-29 14:39:39 +03:00 | | | 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);
|
2025-10-24 02:13:35 +03:00 | | | }
|
2026-10-29 14:39:39 +03:00 | | | 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', 'Ожидает запуска');
|
2025-10-24 02:13:35 +03:00 | | | } else {
|
2026-10-29 14:39:39 +03:00 | | | this.text('.time', 'Ожидаем данные');
|
2025-10-24 02:13:35 +03:00 | | | }
|
2026-10-29 14:39:39 +03:00 | | | this.renderState();
|
2025-10-24 02:13:35 +03:00 | | | }
|
2025-10-21 16:08:01 +03:00 | | |
|
2026-10-29 14:39:39 +03:00 | | | 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;
|
2025-10-24 02:13:35 +03:00 | | | }
|
| | | }
|
2025-10-21 16:08:01 +03:00 | | |
|
2026-10-29 14:39:39 +03:00 | | | const cards = Object.create(null);
|
2025-10-21 16:08:01 +03:00 | | | let updateInterval;
|
2026-10-29 14:39:39 +03:00 | | | 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);
|
| | | }
|
2025-10-24 02:13:35 +03:00 | | |
|
2025-10-30 10:00:52 +03:00 | | | async function fetchTerminalInfo(folder) {
|
2026-10-29 14:39:39 +03:00 | | | const card = cards[folder];
|
| | | try {
|
| | | const data = await managerRequest('/instances/' + encodeURIComponent(folder), {method:'POST'});
|
2025-10-24 02:13:35 +03:00 | | | card.updateData(data);
|
2026-10-29 14:39:39 +03:00 | | | updateTerminalList();
|
| | | } catch (error) {
|
| | | const badge = card.element.querySelector('.status-badge');
|
| | | badge.dataset.tone = 'danger';
|
| | | badge.title = error.message;
|
| | | card.text('.description', 'Нет ответа');
|
| | | }
|
2025-10-21 16:08:01 +03:00 | | | }
|
| | |
|
2026-10-29 14:39:39 +03:00 | | | 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);
|
| | | }
|
2025-10-24 02:13:35 +03:00 | | | }
|
2025-10-21 16:08:01 +03:00 | | |
|
2026-10-29 14:39:39 +03:00 | | | 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();
|
2025-10-24 02:13:35 +03:00 | | | });
|
2026-10-29 14:39:39 +03:00 | | | });
|
| | | 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; }
|
| | | });
|
2025-10-24 02:13:35 +03:00 | | |
|
2026-10-29 14:39:39 +03:00 | | | 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));
|