/** * SMM Operator Web Console */ (function () { 'use strict'; // DOM Elements const nodesTableBody = document.getElementById('nodes-table-body'); const linksTableBody = document.getElementById('links-table-body'); const nodesCountBadge = document.getElementById('nodes-count'); const linksCountBadge = document.getElementById('links-count'); const nodesEmpty = document.getElementById('nodes-empty'); const linksEmpty = document.getElementById('links-empty'); const lastUpdatedText = document.getElementById('last-updated-text'); const globalAlert = document.getElementById('global-alert'); // Action Elements const refreshBtn = document.getElementById('refresh-btn'); const addNodeBtn = document.getElementById('add-node-btn'); // Modal Elements const modalBackdrop = document.getElementById('add-node-modal'); const modalCloseBtn = document.getElementById('modal-close-btn'); const modalCancelBtn = document.getElementById('modal-cancel-btn'); const enrollmentForm = document.getElementById('enrollment-form'); const nodeIdInput = document.getElementById('node-id-input'); const generateCodeBtn = document.getElementById('generate-code-btn'); const modalError = document.getElementById('modal-error'); const enrollmentResult = document.getElementById('enrollment-result'); const resultDoneBtn = document.getElementById('result-done-btn'); // Result Displays & Copy Buttons const caFingerprintDisplay = document.getElementById('ca-fingerprint-display'); const enrollmentCodeDisplay = document.getElementById('enrollment-code-display'); const copyFingerprintBtn = document.getElementById('copy-fingerprint-btn'); const copyCodeBtn = document.getElementById('copy-code-btn'); const countdownTimer = document.getElementById('countdown-timer'); // State let countdownInterval = null; let autoRefreshInterval = null; let isModalOpen = false; // Initialize document.addEventListener('DOMContentLoaded', () => { bindEvents(); loadDashboardData(); startAutoRefresh(); }); function bindEvents() { refreshBtn.addEventListener('click', () => { loadDashboardData(true); }); addNodeBtn.addEventListener('click', openAddNodeModal); modalCloseBtn.addEventListener('click', closeAddNodeModal); modalCancelBtn.addEventListener('click', closeAddNodeModal); resultDoneBtn.addEventListener('click', closeAddNodeModal); modalBackdrop.addEventListener('click', (e) => { if (e.target === modalBackdrop) { closeAddNodeModal(); } }); enrollmentForm.addEventListener('submit', handleEnrollmentSubmit); copyCodeBtn.addEventListener('click', () => { copyToClipboard(enrollmentCodeDisplay.value, copyCodeBtn, 'Скопировать код'); }); copyFingerprintBtn.addEventListener('click', () => { copyToClipboard(caFingerprintDisplay.textContent, copyFingerprintBtn, 'Копировать отпечаток'); }); document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && isModalOpen) { closeAddNodeModal(); } }); } function startAutoRefresh() { if (autoRefreshInterval) clearInterval(autoRefreshInterval); autoRefreshInterval = setInterval(() => { if (!isModalOpen) { loadDashboardData(false); } }, 10000); } async function loadDashboardData(showLoadingIndicator = false) { if (showLoadingIndicator) { refreshBtn.disabled = true; refreshBtn.querySelector('.btn-icon').textContent = '⏳'; } try { const [agentsRes, linksRes] = await Promise.all([ fetch('/api/v1/control/agents', { headers: { Accept: 'application/json' } }), fetch('/api/v1/control/links', { headers: { Accept: 'application/json' } }) ]); if (agentsRes.status === 401 || linksRes.status === 401) { showGlobalAlert('Ошибка аутентификации: требуется клиентский сертификат роли Operator.', 'error'); return; } if (agentsRes.status === 403 || linksRes.status === 403) { showGlobalAlert('Доступ запрещён: сертификат не имеет роли Operator.', 'error'); return; } if (!agentsRes.ok || !linksRes.ok) { throw new Error(`Ошибка загрузки данных (${agentsRes.status} / ${linksRes.status})`); } const agents = await agentsRes.json(); const links = await linksRes.json(); renderNodes(agents); renderLinks(links); hideGlobalAlert(); const now = new Date(); lastUpdatedText.textContent = `Обновлено в ${now.toLocaleTimeString()}`; } catch (err) { console.error('Failed to load dashboard data:', err); showGlobalAlert(`Не удалось обновить данные: ${err.message}`, 'error'); } finally { if (showLoadingIndicator) { refreshBtn.disabled = false; refreshBtn.querySelector('.btn-icon').textContent = '↻'; } } } function renderNodes(agents) { const list = Array.isArray(agents) ? agents : []; nodesCountBadge.textContent = list.length; if (list.length === 0) { nodesTableBody.innerHTML = ''; nodesEmpty.classList.remove('hidden'); return; } nodesEmpty.classList.add('hidden'); nodesTableBody.innerHTML = list.map(agent => { const statusClass = (agent.status || 'unknown').toLowerCase(); const lastSeen = formatTimestamp(agent.lastSeenAt); const certInfo = formatCertInfo(agent); return ` ${escapeHtml(agent.nodeId || agent.name || '—')} ${escapeHtml(agent.status || 'Unknown')} ${escapeHtml(agent.agentVersion || '—')} ${lastSeen} ${certInfo} `; }).join(''); } function renderLinks(links) { const list = Array.isArray(links) ? links : []; linksCountBadge.textContent = list.length; if (list.length === 0) { linksTableBody.innerHTML = ''; linksEmpty.classList.remove('hidden'); return; } linksEmpty.classList.add('hidden'); linksTableBody.innerHTML = list.map(link => { const desiredClass = (link.desiredState || 'unknown').toLowerCase(); const actualClass = (link.actualState || 'unknown').toLowerCase(); const expires = formatTimestamp(link.expiresAt); return ` ${escapeHtml((link.id || '').substring(0, 8))}... ${escapeHtml(link.sourceNodeId || '—')} ${escapeHtml(link.targetNodeId || '—')} ${escapeHtml(link.protocol || 'TCP')}/${escapeHtml(String(link.port || 0))} ${escapeHtml(link.desiredState || '—')} ${escapeHtml(link.actualState || '—')} ${expires} `; }).join(''); } async function handleEnrollmentSubmit(e) { e.preventDefault(); const nodeId = (nodeIdInput.value || '').trim(); if (!nodeId || !/^[a-z0-9-]{1,63}$/.test(nodeId)) { showModalError('Имя узла должно содержать от 1 до 63 строчных латинских букв, цифр или дефисов.'); return; } hideModalError(); generateCodeBtn.disabled = true; generateCodeBtn.textContent = 'Генерация...'; try { const res = await fetch(`/api/v1/control/agents/${encodeURIComponent(nodeId)}/enrollment-code`, { method: 'POST', headers: { 'Accept': 'application/json' } }); if (res.status === 401) { showModalError('Требуется клиентский сертификат роли Operator.'); return; } if (res.status === 403) { showModalError('Доступ запрещен: роль не является Operator.'); return; } if (res.status === 429) { showModalError('Превышен лимит запросов на выпуск кодов (10/мин). Подождите перед повторной попыткой.'); return; } const data = await res.json(); if (!res.ok) { const errorDetail = data?.detail || data?.title || 'Ошибка выпуска кода регистрации'; showModalError(errorDetail); return; } displayEnrollmentResult(data); } catch (err) { console.error('Enrollment code creation error:', err); showModalError(`Сетевая ошибка: ${err.message}`); } finally { generateCodeBtn.disabled = false; generateCodeBtn.textContent = 'Сгенерировать код'; } } function displayEnrollmentResult(data) { enrollmentForm.classList.add('hidden'); enrollmentResult.classList.remove('hidden'); caFingerprintDisplay.textContent = data.certificateAuthorityFingerprintSha256 || '—'; enrollmentCodeDisplay.value = data.code || ''; // Start 10-minute countdown const expiresAt = data.expiresAt ? new Date(data.expiresAt) : new Date(Date.now() + 10 * 60 * 1000); startCountdown(expiresAt); } function startCountdown(targetDate) { if (countdownInterval) clearInterval(countdownInterval); function update() { const now = new Date(); const remainingMs = targetDate.getTime() - now.getTime(); if (remainingMs <= 0) { countdownTimer.textContent = '00:00 (истёк)'; countdownTimer.style.backgroundColor = 'var(--danger-bg)'; countdownTimer.style.color = 'var(--danger-text)'; clearInterval(countdownInterval); return; } const totalSec = Math.floor(remainingMs / 1000); const min = Math.floor(totalSec / 60); const sec = totalSec % 60; countdownTimer.textContent = `${String(min).padStart(2, '0')}:${String(sec).padStart(2, '0')}`; } update(); countdownInterval = setInterval(update, 1000); } function openAddNodeModal() { isModalOpen = true; enrollmentForm.reset(); enrollmentForm.classList.remove('hidden'); enrollmentResult.classList.add('hidden'); hideModalError(); modalBackdrop.classList.remove('hidden'); nodeIdInput.focus(); } function closeAddNodeModal() { isModalOpen = false; modalBackdrop.classList.add('hidden'); if (countdownInterval) { clearInterval(countdownInterval); countdownInterval = null; } loadDashboardData(); } async function copyToClipboard(text, btnElement, defaultLabel) { if (!text) return; try { if (navigator.clipboard && navigator.clipboard.writeText) { await navigator.clipboard.writeText(text); } else { const textarea = document.createElement('textarea'); textarea.value = text; textarea.style.position = 'fixed'; textarea.style.opacity = '0'; document.body.appendChild(textarea); textarea.select(); document.execCommand('copy'); document.body.removeChild(textarea); } btnElement.textContent = '✓ Скопировано!'; btnElement.classList.add('btn-success'); setTimeout(() => { btnElement.textContent = defaultLabel; btnElement.classList.remove('btn-success'); }, 2000); } catch (err) { console.error('Copy failed:', err); btnElement.textContent = 'Ошибка копирования'; setTimeout(() => { btnElement.textContent = defaultLabel; }, 2000); } } function showModalError(msg) { modalError.textContent = msg; modalError.classList.remove('hidden'); } function hideModalError() { modalError.textContent = ''; modalError.classList.add('hidden'); } function showGlobalAlert(msg, type = 'error') { globalAlert.textContent = msg; globalAlert.className = `alert alert-${type}`; globalAlert.classList.remove('hidden'); } function hideGlobalAlert() { globalAlert.classList.add('hidden'); } function formatTimestamp(isoString) { if (!isoString) return ''; const date = new Date(isoString); if (isNaN(date.getTime())) return ''; const diffSec = Math.floor((Date.now() - date.getTime()) / 1000); if (diffSec < 0) { return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } if (diffSec < 60) return `${diffSec} сек. назад`; if (diffSec < 3600) return `${Math.floor(diffSec / 60)} мин. назад`; if (diffSec < 86400) return `${Math.floor(diffSec / 3600)} ч. назад`; return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } function formatCertInfo(agent) { if (typeof agent.certificateRemainingDays === 'number') { const days = agent.certificateRemainingDays; if (days < 0) return 'Истёк'; if (days <= 7) return `${days} дн.`; return `${days} дн.`; } if (agent.certificateExpiresAt) { const expDate = new Date(agent.certificateExpiresAt); return expDate.toLocaleDateString(); } return ''; } function escapeHtml(str) { if (str === null || str === undefined) return ''; return String(str) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } })();