diff --git a/src/ServerMonitorManager.Control/wwwroot/app.js b/src/ServerMonitorManager.Control/wwwroot/app.js index 36c59f4..15790eb 100644 --- a/src/ServerMonitorManager.Control/wwwroot/app.js +++ b/src/ServerMonitorManager.Control/wwwroot/app.js @@ -8,22 +8,28 @@ // DOM Elements const nodesTableBody = document.getElementById('nodes-table-body'); const linksTableBody = document.getElementById('links-table-body'); + const eventsTableBody = document.getElementById('events-table-body'); const nodesCountBadge = document.getElementById('nodes-count'); const linksCountBadge = document.getElementById('links-count'); + const eventsCountBadge = document.getElementById('events-count'); const nodesEmpty = document.getElementById('nodes-empty'); const linksEmpty = document.getElementById('links-empty'); + const eventsEmpty = document.getElementById('events-empty'); const lastUpdatedText = document.getElementById('last-updated-text'); const globalAlert = document.getElementById('global-alert'); const connectionStatus = document.getElementById('connection-status'); const testingBanner = document.getElementById('testing-auth-warning'); + const nodesDatalist = document.getElementById('nodes-datalist'); // Action Elements const refreshBtn = document.getElementById('refresh-btn'); const addNodeBtn = document.getElementById('add-node-btn'); + const createLinkBtn = document.getElementById('create-link-btn'); + const headerCreateLinkBtn = document.getElementById('header-create-link-btn'); const logoutBtn = document.getElementById('logout-btn'); - // Modal Elements - const modalBackdrop = document.getElementById('add-node-modal'); + // Add Node Modal Elements + const addNodeModal = 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'); @@ -32,6 +38,26 @@ const modalError = document.getElementById('modal-error'); const enrollmentResult = document.getElementById('enrollment-result'); const resultDoneBtn = document.getElementById('result-done-btn'); + 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'); + + // Create Link Modal Elements + const createLinkModal = document.getElementById('create-link-modal'); + const linkModalCloseBtn = document.getElementById('link-modal-close-btn'); + const linkModalCancelBtn = document.getElementById('link-modal-cancel-btn'); + const createLinkForm = document.getElementById('create-link-form'); + const linkSourceNode = document.getElementById('link-source-node'); + const linkTargetNode = document.getElementById('link-target-node'); + const linkProtocol = document.getElementById('link-protocol'); + const linkPort = document.getElementById('link-port'); + const linkTtl = document.getElementById('link-ttl'); + const linkReason = document.getElementById('link-reason'); + const linkDirectionSummary = document.getElementById('link-direction-summary'); + const linkModalError = document.getElementById('link-modal-error'); + const linkModalSubmitBtn = document.getElementById('link-modal-submit-btn'); // Login Modal Elements const loginModal = document.getElementById('login-modal'); @@ -41,20 +67,18 @@ const loginSubmitBtn = document.getElementById('login-submit-btn'); const loginError = document.getElementById('login-error'); - // 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; let isPasswordLoginEnabled = false; + let currentLinkCreationIdempotencyKey = null; + let knownNodes = []; + let eventsList = []; + let eventStreamAbortController = null; const TOKEN_KEY = 'smm_session_token'; + const MAX_EVENTS_IN_JOURNAL = 100; // Initialize document.addEventListener('DOMContentLoaded', async () => { @@ -62,6 +86,7 @@ await checkAuthStatus(); await loadDashboardData(); startAutoRefresh(); + startEventStream(); }); function bindEvents() { @@ -69,18 +94,47 @@ loadDashboardData(true); }); + // Add Node modal addNodeBtn.addEventListener('click', openAddNodeModal); modalCloseBtn.addEventListener('click', closeAddNodeModal); modalCancelBtn.addEventListener('click', closeAddNodeModal); resultDoneBtn.addEventListener('click', closeAddNodeModal); - - modalBackdrop.addEventListener('click', (e) => { - if (e.target === modalBackdrop) { + addNodeModal.addEventListener('click', (e) => { + if (e.target === addNodeModal) { closeAddNodeModal(); } }); - enrollmentForm.addEventListener('submit', handleEnrollmentSubmit); + + // Create Link modal + if (createLinkBtn) { + createLinkBtn.addEventListener('click', openCreateLinkModal); + } + if (headerCreateLinkBtn) { + headerCreateLinkBtn.addEventListener('click', openCreateLinkModal); + } + linkModalCloseBtn.addEventListener('click', closeCreateLinkModal); + linkModalCancelBtn.addEventListener('click', closeCreateLinkModal); + createLinkModal.addEventListener('click', (e) => { + if (e.target === createLinkModal) { + closeCreateLinkModal(); + } + }); + createLinkForm.addEventListener('submit', handleCreateLinkSubmit); + + // Live update of Link direction summary + const linkInputElements = [linkSourceNode, linkTargetNode, linkProtocol, linkPort, linkTtl]; + linkInputElements.forEach(el => { + if (el) { + el.addEventListener('input', updateLinkDirectionSummary); + el.addEventListener('change', updateLinkDirectionSummary); + } + }); + + // Links table action (Emergency Disable button delegation) + linksTableBody.addEventListener('click', handleLinksTableClick); + + // Login & Logout if (loginForm) { loginForm.addEventListener('submit', handleLoginSubmit); } @@ -88,6 +142,7 @@ logoutBtn.addEventListener('click', handleLogout); } + // Copy buttons copyCodeBtn.addEventListener('click', () => { copyToClipboard(enrollmentCodeDisplay.value, copyCodeBtn, 'Скопировать код'); }); @@ -96,13 +151,26 @@ copyToClipboard(caFingerprintDisplay.textContent, copyFingerprintBtn, 'Копировать отпечаток'); }); + // Global keyboard handling document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && isModalOpen) { - closeAddNodeModal(); + if (!addNodeModal.classList.contains('hidden')) closeAddNodeModal(); + if (!createLinkModal.classList.contains('hidden')) closeCreateLinkModal(); } }); } + function generateUuid() { + if (typeof crypto !== 'undefined' && crypto.randomUUID) { + return crypto.randomUUID(); + } + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { + const r = Math.random() * 16 | 0; + const v = c === 'x' ? r : (r & 0x3 | 0x8); + return v.toString(16); + }); + } + async function checkAuthStatus() { try { const res = await fetch('/api/v1/auth/status'); @@ -184,6 +252,7 @@ const agents = await agentsRes.json(); const links = await linksRes.json(); + updateKnownNodes(agents); renderNodes(agents); renderLinks(links); hideGlobalAlert(); @@ -209,6 +278,567 @@ } } + function updateKnownNodes(agents) { + const list = Array.isArray(agents) ? agents : []; + knownNodes = list.map(a => a.nodeId || a.name).filter(Boolean); + if (nodesDatalist) { + nodesDatalist.innerHTML = knownNodes.map(nodeId => + `` + ).join(''); + } + } + + 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(src)}). Правило должно соединять два разных узла!`;
+ return;
+ }
+
+ const srcDisplay = src ? `«${escapeHtml(src)}»` : '[источник]';
+ const dstDisplay = dst ? `«${escapeHtml(dst)}»` : '[назначение]';
+ const portDisplay = port ? `порт ${escapeHtml(port)}` : '[порт]';
+ const ttlDisplay = ttl ? `на ${escapeHtml(ttl)} мин.` : '[срок]';
+
+ linkDirectionSummary.className = 'direction-summary-text';
+ linkDirectionSummary.innerHTML = `Открытие доступа: с узла ${srcDisplay} к узлу ${dstDisplay}, протокол ${escapeHtml(proto)}, ${portDisplay}, ${ttlDisplay}.`;
+ }
+
+ async function handleCreateLinkSubmit(e) {
+ e.preventDefault();
+
+ const sourceNodeId = (linkSourceNode.value || '').trim();
+ const targetNodeId = (linkTargetNode.value || '').trim();
+ const protocol = (linkProtocol.value || 'tcp').trim().toLowerCase();
+ const portStr = (linkPort.value || '').trim();
+ const ttlStr = (linkTtl.value || '').trim();
+ const reason = (linkReason.value || '').trim();
+
+ if (!sourceNodeId || !/^[a-z0-9-]{1,63}$/.test(sourceNodeId)) {
+ showLinkModalError('Укажите корректный Source Node ID (1-63 латинских букв, цифр или дефисов).');
+ linkSourceNode.focus();
+ return;
+ }
+
+ if (!targetNodeId || !/^[a-z0-9-]{1,63}$/.test(targetNodeId)) {
+ showLinkModalError('Укажите корректный Target Node ID (1-63 латинских букв, цифр или дефисов).');
+ linkTargetNode.focus();
+ return;
+ }
+
+ if (sourceNodeId === targetNodeId) {
+ showLinkModalError('Узел-источник и узел-назначение не могут совпадать.');
+ linkTargetNode.focus();
+ return;
+ }
+
+ const port = parseInt(portStr, 10);
+ if (isNaN(port) || port < 1 || port > 65535) {
+ showLinkModalError('Порт должен быть целым числом в диапазоне от 1 до 65535.');
+ linkPort.focus();
+ return;
+ }
+
+ const ttlMinutes = parseInt(ttlStr, 10);
+ if (isNaN(ttlMinutes) || ttlMinutes < 0 || ttlMinutes > 525600) {
+ showLinkModalError('Срок действия (TTL) должен быть числом минут от 0 до 525600.');
+ linkTtl.focus();
+ return;
+ }
+
+ if (!reason || reason.length === 0) {
+ showLinkModalError('Причина открытия доступа (Reason) обязательна для аудита безопасности.');
+ linkReason.focus();
+ return;
+ }
+
+ if (reason.length > 256) {
+ showLinkModalError('Причина не должна превышать 256 символов.');
+ linkReason.focus();
+ return;
+ }
+
+ if (!currentLinkCreationIdempotencyKey) {
+ currentLinkCreationIdempotencyKey = generateUuid();
+ }
+
+ hideLinkModalError();
+ linkModalSubmitBtn.disabled = true;
+ linkModalSubmitBtn.querySelector('span').textContent = 'Создание...';
+
+ const payload = {
+ sourceNodeId,
+ targetNodeId,
+ protocol,
+ port,
+ ttlMinutes,
+ reason,
+ idempotencyKey: currentLinkCreationIdempotencyKey
+ };
+
+ try {
+ const res = await authFetch('/api/v1/control/links', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify(payload)
+ });
+
+ if (res.status === 401) {
+ showLinkModalError('Требуется авторизация роли Operator.');
+ return;
+ }
+ if (res.status === 403) {
+ showLinkModalError('Доступ запрещен: роль не является Operator.');
+ return;
+ }
+
+ if (!res.ok) {
+ let errorMsg = `Ошибка создания Link (код ${res.status})`;
+ try {
+ const errorData = await res.json();
+ if (errorData?.detail) {
+ errorMsg = errorData.detail;
+ } else if (errorData?.title) {
+ errorMsg = errorData.title;
+ } else if (errorData?.errors) {
+ errorMsg = Object.values(errorData.errors).flat().join(' ');
+ }
+ } catch {
+ const text = await res.text();
+ if (text) errorMsg = text;
+ }
+ showLinkModalError(errorMsg);
+ return;
+ }
+
+ // Success
+ currentLinkCreationIdempotencyKey = null;
+ closeCreateLinkModal();
+ await loadDashboardData(true);
+ showGlobalAlert(`Сетевая связь ${sourceNodeId} → ${targetNodeId}:${port} успешно создана.`, 'success');
+ } catch (err) {
+ console.error('Link creation error:', err);
+ showLinkModalError(`Сетевая ошибка: ${err.message}`);
+ } finally {
+ linkModalSubmitBtn.disabled = false;
+ linkModalSubmitBtn.querySelector('span').textContent = 'Подтвердить и создать Link';
+ }
+ }
+
+ function showLinkModalError(msg) {
+ linkModalError.textContent = msg;
+ linkModalError.classList.remove('hidden');
+ }
+
+ function hideLinkModalError() {
+ linkModalError.textContent = '';
+ linkModalError.classList.add('hidden');
+ }
+
+ // Emergency Link Disabling
+ async function handleLinksTableClick(e) {
+ const disableBtn = e.target.closest('.btn-disable-link');
+ if (!disableBtn) return;
+
+ const linkId = disableBtn.getAttribute('data-link-id');
+ const source = disableBtn.getAttribute('data-source');
+ const target = disableBtn.getAttribute('data-target');
+ const protocol = (disableBtn.getAttribute('data-protocol') || 'TCP').toUpperCase();
+ const port = disableBtn.getAttribute('data-port');
+
+ if (!linkId) return;
+
+ const confirmed = window.confirm(
+ `АВАРИЙНОЕ ОТКЛЮЧЕНИЕ СВЯЗИ:\n\nВы уверены, что хотите немедленно отозвать сетевой доступ с узла "${source}" к узлу "${target}" (${protocol}/${port})?`
+ );
+
+ if (!confirmed) return;
+
+ disableBtn.disabled = true;
+ disableBtn.textContent = 'Отключение...';
+
+ const disablePayload = {
+ idempotencyKey: generateUuid()
+ };
+
+ try {
+ const res = await authFetch(`/api/v1/control/links/${encodeURIComponent(linkId)}/disable`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify(disablePayload)
+ });
+
+ if (res.status === 401) {
+ showGlobalAlert('Требуется авторизация роли Operator для отключения связи.', 'error');
+ return;
+ }
+ if (res.status === 403) {
+ showGlobalAlert('Доступ запрещен: роль не является Operator.', 'error');
+ return;
+ }
+
+ if (!res.ok) {
+ let errorMsg = `Ошибка при отключении Link (код ${res.status})`;
+ try {
+ const errorData = await res.json();
+ if (errorData?.detail) errorMsg = errorData.detail;
+ else if (errorData?.title) errorMsg = errorData.title;
+ } catch {
+ const text = await res.text();
+ if (text) errorMsg = text;
+ }
+ showGlobalAlert(errorMsg, 'error');
+ return;
+ }
+
+ await loadDashboardData(true);
+ showGlobalAlert(`Сетевая связь ${source} → ${target}:${port} успешно отключена.`, 'success');
+ } catch (err) {
+ console.error('Disable link error:', err);
+ showGlobalAlert(`Сетевая ошибка при отключении Link: ${err.message}`, 'error');
+ } finally {
+ disableBtn.disabled = false;
+ disableBtn.textContent = 'Отключить';
+ }
+ }
+
+ // Add Node Modal & Code Generation
+ 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 authFetch(`/api/v1/control/agents/${encodeURIComponent(nodeId)}/enrollment-code`, {
+ method: 'POST'
+ });
+
+ 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();
+ addNodeModal.classList.remove('hidden');
+ nodeIdInput.focus();
+ }
+
+ function closeAddNodeModal() {
+ isModalOpen = false;
+ addNodeModal.classList.add('hidden');
+ if (countdownInterval) {
+ clearInterval(countdownInterval);
+ countdownInterval = null;
+ }
+ loadDashboardData();
+ }
+
+ // Password Login Modal
function showLoginModal() {
if (loginModal) {
loginModal.classList.remove('hidden');
@@ -306,169 +936,7 @@
}
}
- 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 `
-