From 051d917bc2a43b0002ad44e6733fd5ca721a206b Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Tue, 18 Aug 2026 12:13:37 +0700 Subject: [PATCH] feat(control): add Link management and real-time Event Journal to Web Console --- .../wwwroot/app.js | 846 ++++++++++++++---- .../wwwroot/index.html | 179 +++- .../wwwroot/style.css | 156 +++- .../WebConsoleTests.cs | 45 +- 4 files changed, 1038 insertions(+), 188 deletions(-) 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(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 isMismatch = (link.desiredState || '').toLowerCase() !== (link.actualState || '').toLowerCase(); + const expiresFormatted = formatExpiration(link.expiresAt); + const isDisabled = (link.desiredState || '').toLowerCase() === 'disabled'; + + const errorHtml = link.lastError + ? `` + : ''; + + const mismatchHtml = isMismatch + ? `⚠️ Не синхронизировано` + : ''; + + const actionHtml = !isDisabled + ? `` + : `Отключено`; + + return ` + + ${escapeHtml((link.id || '').substring(0, 8))}... + ${escapeHtml(link.sourceNodeId || '—')} + ${escapeHtml(link.targetNodeId || '—')} + ${escapeHtml((link.protocol || 'tcp').toUpperCase())}/${escapeHtml(String(link.port || 0))} + +
+ ${escapeHtml(link.desiredState || '—')} +
+ + +
+ ${escapeHtml(link.actualState || '—')} + ${mismatchHtml} + ${errorHtml} +
+ + ${escapeHtml(link.reason || '—')} + ${expiresFormatted} + ${actionHtml} + + `; + }).join(''); + } + + // Real-time Events Stream + async function startEventStream() { + if (eventStreamAbortController) { + eventStreamAbortController.abort(); + } + eventStreamAbortController = new AbortController(); + const signal = eventStreamAbortController.signal; + + try { + const res = await authFetch('/api/v1/control/events', { signal }); + if (!res.ok || !res.body) { + return; + } + + const reader = res.body.getReader(); + const decoder = new TextDecoder('utf-8'); + let buffer = ''; + + // Clear loading state + if (eventsList.length === 0) { + eventsTableBody.innerHTML = ''; + eventsEmpty.classList.remove('hidden'); + } + + while (!signal.aborted) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop(); // Keep partial line in buffer + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + const ev = JSON.parse(trimmed); + addControlEvent(ev); + } catch (e) { + console.debug('Failed to parse NDJSON event line:', e); + } + } + } + } catch (err) { + if (signal.aborted) return; + console.debug('Event stream disconnected:', err); + } finally { + if (!signal.aborted) { + setTimeout(startEventStream, 5000); + } + } + } + + function addControlEvent(ev) { + eventsList.unshift(ev); + if (eventsList.length > MAX_EVENTS_IN_JOURNAL) { + eventsList.pop(); + } + renderEvents(); + + // Trigger dashboard refresh if topology changed + const eventType = (ev.type || '').toLowerCase(); + if (eventType.startsWith('link.') || eventType.startsWith('agent.')) { + loadDashboardData(false); + } + } + + function renderEvents() { + eventsCountBadge.textContent = eventsList.length; + + if (eventsList.length === 0) { + eventsTableBody.innerHTML = ''; + eventsEmpty.classList.remove('hidden'); + return; + } + + eventsEmpty.classList.add('hidden'); + eventsTableBody.innerHTML = eventsList.map(ev => { + const timeStr = formatTimestamp(ev.recordedAt); + let payloadSummary = '—'; + if (ev.payloadJson) { + try { + const parsed = JSON.parse(ev.payloadJson); + payloadSummary = typeof parsed === 'object' + ? JSON.stringify(parsed) + : String(parsed); + } catch { + payloadSummary = ev.payloadJson; + } + } + + return ` + + ${timeStr} + ${escapeHtml(ev.type || 'unknown')} + ${escapeHtml(ev.subject || '—')} +
${escapeHtml(payloadSummary)}
+ + `; + }).join(''); + } + + // Link Creation Modal & Form Handling + function openCreateLinkModal() { + isModalOpen = true; + currentLinkCreationIdempotencyKey = generateUuid(); + createLinkForm.reset(); + linkProtocol.value = 'tcp'; + linkPort.value = '8080'; + linkTtl.value = '60'; + hideLinkModalError(); + updateLinkDirectionSummary(); + createLinkModal.classList.remove('hidden'); + linkSourceNode.focus(); + } + + function closeCreateLinkModal() { + isModalOpen = false; + createLinkModal.classList.add('hidden'); + hideLinkModalError(); + } + + function updateLinkDirectionSummary() { + const src = (linkSourceNode.value || '').trim(); + const dst = (linkTargetNode.value || '').trim(); + const proto = (linkProtocol.value || 'TCP').toUpperCase(); + const port = (linkPort.value || '').trim(); + const ttl = (linkTtl.value || '').trim(); + + if (!src && !dst) { + linkDirectionSummary.className = 'direction-summary-text'; + linkDirectionSummary.innerHTML = 'Укажите узел-источник и узел-назначение для проверки направления связи.'; + return; + } + + if (src && dst && src === dst) { + linkDirectionSummary.className = 'direction-summary-text warning'; + linkDirectionSummary.innerHTML = `⚠️ Внимание: узел-источник и назначение совпадают (${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 ` - - ${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 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(); - modalBackdrop.classList.remove('hidden'); - nodeIdInput.focus(); - } - - function closeAddNodeModal() { - isModalOpen = false; - modalBackdrop.classList.add('hidden'); - if (countdownInterval) { - clearInterval(countdownInterval); - countdownInterval = null; - } - loadDashboardData(); - } - + // Utilities async function copyToClipboard(text, btnElement, defaultLabel) { if (!text) return; try { @@ -536,6 +1004,30 @@ return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } + function formatExpiration(isoString) { + if (!isoString) return 'Бессрочно'; + const date = new Date(isoString); + if (isNaN(date.getTime())) return ''; + + const remainingSec = Math.floor((date.getTime() - Date.now()) / 1000); + const dateStr = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + + if (remainingSec <= 0) { + return `Истёк (${dateStr})`; + } + if (remainingSec < 60) { + return `через ${remainingSec} сек.`; + } + if (remainingSec < 3600) { + return `через ${Math.floor(remainingSec / 60)} мин. (${dateStr})`; + } + if (remainingSec < 86400) { + return `через ${Math.floor(remainingSec / 3600)} ч. (${dateStr})`; + } + + return `${date.toLocaleDateString()} ${dateStr}`; + } + function formatCertInfo(agent) { if (typeof agent.certificateRemainingDays === 'number') { const days = agent.certificateRemainingDays; diff --git a/src/ServerMonitorManager.Control/wwwroot/index.html b/src/ServerMonitorManager.Control/wwwroot/index.html index 0800394..4077952 100644 --- a/src/ServerMonitorManager.Control/wwwroot/index.html +++ b/src/ServerMonitorManager.Control/wwwroot/index.html @@ -25,10 +25,14 @@ Обновить - + +
@@ -97,21 +107,57 @@ Источник Назначение Протокол / Порт - Требуемое состояние - Фактическое состояние + Желаемое + Фактическое + Причина Срок действия + Действия - Загрузка сетевых связей... + Загрузка сетевых связей... +
+ + + +
+
+
+

Журнал событий

+ 0 +
+ Поток системных событий в реальном времени (агенты, сетевые связи, аудит) +
+ +
+ + + + + + + + + + + + + + +
ВремяТип событияСубъектПодробности
Ожидание потока событий...
+
@@ -127,6 +173,8 @@ Синхронизировано + + + + + diff --git a/src/ServerMonitorManager.Control/wwwroot/style.css b/src/ServerMonitorManager.Control/wwwroot/style.css index 83fd81c..1cb44e2 100644 --- a/src/ServerMonitorManager.Control/wwwroot/style.css +++ b/src/ServerMonitorManager.Control/wwwroot/style.css @@ -97,6 +97,7 @@ body { display: flex; align-items: center; gap: 0.75rem; + flex-wrap: wrap; } .status-indicator { @@ -165,6 +166,17 @@ body { background-color: #475569; } +.btn-danger { + background-color: var(--danger-bg); + color: var(--danger-text); + border-color: var(--danger-border); +} + +.btn-danger:hover { + background-color: #ef4444; + color: #ffffff; +} + .btn-outline { background-color: transparent; color: var(--text-primary); @@ -209,8 +221,10 @@ body { padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color); display: flex; - flex-direction: column; - gap: 0.25rem; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 0.75rem; } .section-title-group { @@ -225,6 +239,12 @@ body { color: var(--text-primary); } +.section-actions { + display: flex; + align-items: center; + gap: 1rem; +} + .counter-badge { background-color: var(--bg-tertiary); color: var(--text-secondary); @@ -319,7 +339,8 @@ body { } .status-badge.connecting, -.status-badge.pending { +.status-badge.pending, +.status-badge.applying { background-color: var(--warning-bg); color: var(--warning-text); border: 1px solid var(--warning-border); @@ -334,6 +355,59 @@ body { border: 1px solid var(--danger-border); } +.state-cell { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.state-mismatch-badge { + display: inline-flex; + align-items: center; + gap: 0.25rem; + font-size: 0.725rem; + font-weight: 600; + color: #fbbf24; + background-color: rgba(245, 158, 11, 0.15); + border: 1px dashed #d97706; + padding: 0.15rem 0.4rem; + border-radius: 4px; +} + +.link-error-msg { + font-size: 0.775rem; + color: #f87171; + background-color: rgba(239, 68, 68, 0.1); + border: 1px solid rgba(239, 68, 68, 0.25); + padding: 0.25rem 0.5rem; + border-radius: 4px; + margin-top: 0.35rem; + max-width: 280px; + word-break: break-word; +} + +/* Event Journal */ +.event-type-badge { + font-family: var(--font-mono); + font-size: 0.775rem; + font-weight: 600; + padding: 0.2rem 0.5rem; + border-radius: 4px; + background-color: var(--bg-tertiary); + color: #93c5fd; + display: inline-block; +} + +.event-payload-box { + font-family: var(--font-mono); + font-size: 0.8rem; + color: var(--text-secondary); + max-width: 500px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + /* Empty State */ .empty-state { display: flex; @@ -411,6 +485,10 @@ body { animation: modalEnter 0.15s ease-out; } +.create-link-modal-window { + max-width: 680px; +} + @keyframes modalEnter { from { opacity: 0; @@ -460,6 +538,18 @@ body { } /* Forms */ +.form-row-2 { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; +} + +.form-row-3 { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 1rem; +} + .form-group { display: flex; flex-direction: column; @@ -482,6 +572,12 @@ body { color: var(--text-primary); outline: none; transition: border-color 0.15s ease, box-shadow 0.15s ease; + width: 100%; +} + +.form-select { + font-family: var(--font-sans); + cursor: pointer; } .form-control:focus { @@ -498,7 +594,47 @@ body { display: flex; justify-content: flex-end; gap: 0.75rem; - margin-top: 1rem; + margin-top: 0.5rem; +} + +/* Direction Confirmation Card */ +.link-direction-card { + background-color: #0b2246; + border: 2px solid #2563eb; + border-radius: 8px; + padding: 1rem 1.25rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.direction-card-header { + display: flex; + align-items: center; + gap: 0.5rem; + color: #93c5fd; + font-size: 0.85rem; + font-weight: 700; +} + +.direction-summary-text { + font-size: 0.9rem; + color: #ffffff; + line-height: 1.45; + background-color: rgba(15, 23, 42, 0.6); + padding: 0.65rem 0.85rem; + border-radius: 6px; + border: 1px solid rgba(59, 130, 246, 0.3); +} + +.direction-summary-text strong { + color: #60a5fa; +} + +.direction-summary-text.warning { + color: #fbbf24; + border-color: #f59e0b; + background-color: rgba(69, 26, 3, 0.4); } /* Enrollment Code Result */ @@ -670,6 +806,14 @@ body { display: none !important; } +.text-muted { + color: var(--text-muted); +} + +.text-danger { + color: var(--danger-text); +} + /* Responsive */ @media (max-width: 768px) { .app-header { @@ -688,4 +832,8 @@ body { padding: 0.5rem 1rem; font-size: 0.8rem; } + .form-row-2, + .form-row-3 { + grid-template-columns: 1fr; + } } diff --git a/tests/ServerMonitorManager.Control.Tests/WebConsoleTests.cs b/tests/ServerMonitorManager.Control.Tests/WebConsoleTests.cs index 6501872..ace9218 100644 --- a/tests/ServerMonitorManager.Control.Tests/WebConsoleTests.cs +++ b/tests/ServerMonitorManager.Control.Tests/WebConsoleTests.cs @@ -103,19 +103,34 @@ public sealed class WebConsoleTests : IAsyncDisposable // Header & Actions Assert.Contains("id=\"add-node-btn\"", html, StringComparison.Ordinal); + Assert.Contains("id=\"create-link-btn\"", html, StringComparison.Ordinal); Assert.Contains("id=\"refresh-btn\"", html, StringComparison.Ordinal); // Tables & Counts Assert.Contains("id=\"nodes-table\"", html, StringComparison.Ordinal); Assert.Contains("id=\"links-table\"", html, StringComparison.Ordinal); + Assert.Contains("id=\"events-table\"", html, StringComparison.Ordinal); Assert.Contains("id=\"nodes-count\"", html, StringComparison.Ordinal); Assert.Contains("id=\"links-count\"", html, StringComparison.Ordinal); + Assert.Contains("id=\"events-count\"", html, StringComparison.Ordinal); - // Modal & Form + // Add Node Modal & Form Assert.Contains("id=\"add-node-modal\"", html, StringComparison.Ordinal); Assert.Contains("id=\"node-id-input\"", html, StringComparison.Ordinal); Assert.Contains("id=\"generate-code-btn\"", html, StringComparison.Ordinal); + // Create Link Modal & Form + Assert.Contains("id=\"create-link-modal\"", html, StringComparison.Ordinal); + Assert.Contains("id=\"create-link-form\"", html, StringComparison.Ordinal); + Assert.Contains("id=\"link-source-node\"", html, StringComparison.Ordinal); + Assert.Contains("id=\"link-target-node\"", html, StringComparison.Ordinal); + Assert.Contains("id=\"link-protocol\"", html, StringComparison.Ordinal); + Assert.Contains("id=\"link-port\"", html, StringComparison.Ordinal); + Assert.Contains("id=\"link-ttl\"", html, StringComparison.Ordinal); + Assert.Contains("id=\"link-reason\"", html, StringComparison.Ordinal); + Assert.Contains("id=\"link-direction-summary\"", html, StringComparison.Ordinal); + Assert.Contains("id=\"link-modal-submit-btn\"", html, StringComparison.Ordinal); + // Result displays Assert.Contains("id=\"ca-fingerprint-display\"", html, StringComparison.Ordinal); Assert.Contains("id=\"enrollment-code-display\"", html, StringComparison.Ordinal); @@ -128,6 +143,34 @@ public sealed class WebConsoleTests : IAsyncDisposable Assert.Contains("отпечаток", html, StringComparison.OrdinalIgnoreCase); } + [Fact] + public async Task WebConsoleProvidesLinkManagementAndEventsJournal() + { + using var client = _factory.CreateClient(); + client.DefaultRequestHeaders.Add("X-Test-Identity", "operator-admin"); + client.DefaultRequestHeaders.Add("X-Test-Role", "Operator"); + + var response = await client.GetAsync("/", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var html = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + + // Desired & Actual state separation in table headers + Assert.Contains("Желаемое", html, StringComparison.Ordinal); + Assert.Contains("Фактическое", html, StringComparison.Ordinal); + Assert.Contains("Причина", html, StringComparison.Ordinal); + Assert.Contains("Срок действия", html, StringComparison.Ordinal); + Assert.Contains("Действия", html, StringComparison.Ordinal); + + // Directional confirmation text in modal + Assert.Contains("Подтверждение направления правила доступа", html, StringComparison.Ordinal); + Assert.Contains("id=\"link-direction-summary\"", html, StringComparison.Ordinal); + + // Event journal section + Assert.Contains("id=\"events-section\"", html, StringComparison.Ordinal); + Assert.Contains("Журнал событий", html, StringComparison.Ordinal); + } + [Fact] public async Task EmbeddedAssetsAreServedWhenDiskWebRootIsMissing() {