diff --git a/src/ServerMonitorManager.Control/Program.cs b/src/ServerMonitorManager.Control/Program.cs index 2780018..379bd37 100644 --- a/src/ServerMonitorManager.Control/Program.cs +++ b/src/ServerMonitorManager.Control/Program.cs @@ -573,6 +573,13 @@ agents.MapPost("/provisioning/jobs/{id}/execution-grant", async ( } }); +var console = app.MapGroup("/").RequireAuthorization("Operator"); +console.MapGet("/", (IWebHostEnvironment env) => GetWebConsoleAsset(env, "index.html", "text/html; charset=utf-8")); +console.MapGet("/index.html", (IWebHostEnvironment env) => GetWebConsoleAsset(env, "index.html", "text/html; charset=utf-8")); +console.MapGet("/style.css", (IWebHostEnvironment env) => GetWebConsoleAsset(env, "style.css", "text/css; charset=utf-8")); +console.MapGet("/app.js", (IWebHostEnvironment env) => GetWebConsoleAsset(env, "app.js", "application/javascript; charset=utf-8")); +console.MapGet("/console", (IWebHostEnvironment env) => GetWebConsoleAsset(env, "index.html", "text/html; charset=utf-8")); + var control = app.MapGroup("/api/v1/control").RequireAuthorization("Operator"); control.MapPost("/certificates/renew", async ( CertificateRenewalRequest request, @@ -1054,6 +1061,36 @@ automation.MapGet("/links", async ( await app.RunAsync(); return 0; +static IResult GetWebConsoleAsset(IWebHostEnvironment env, string fileName, string contentType) +{ + var webRoot = env.WebRootPath; + if (string.IsNullOrWhiteSpace(webRoot)) + { + webRoot = Path.Combine(AppContext.BaseDirectory, "wwwroot"); + } + + var filePath = Path.Combine(webRoot, fileName); + if (File.Exists(filePath)) + { + return Results.File(filePath, contentType); + } + + var assembly = typeof(Program).Assembly; + var resourceName = assembly.GetManifestResourceNames() + .FirstOrDefault(name => name.EndsWith(fileName, StringComparison.OrdinalIgnoreCase)); + + if (resourceName is not null) + { + var stream = assembly.GetManifestResourceStream(resourceName); + if (stream is not null) + { + return Results.Stream(stream, contentType); + } + } + + return Results.NotFound(); +} + static async Task ChangeProvisioningJobAsync( string id, ProvisioningJobCommandRequest request, diff --git a/src/ServerMonitorManager.Control/ServerMonitorManager.Control.csproj b/src/ServerMonitorManager.Control/ServerMonitorManager.Control.csproj index 2cb7a5f..0b8a84f 100644 --- a/src/ServerMonitorManager.Control/ServerMonitorManager.Control.csproj +++ b/src/ServerMonitorManager.Control/ServerMonitorManager.Control.csproj @@ -16,5 +16,6 @@ + diff --git a/src/ServerMonitorManager.Control/wwwroot/app.js b/src/ServerMonitorManager.Control/wwwroot/app.js new file mode 100644 index 0000000..a5ea1d9 --- /dev/null +++ b/src/ServerMonitorManager.Control/wwwroot/app.js @@ -0,0 +1,395 @@ +/** + * 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, '''); + } +})(); diff --git a/src/ServerMonitorManager.Control/wwwroot/index.html b/src/ServerMonitorManager.Control/wwwroot/index.html new file mode 100644 index 0000000..7df7725 --- /dev/null +++ b/src/ServerMonitorManager.Control/wwwroot/index.html @@ -0,0 +1,212 @@ + + + + + + SMM — Консоль оператора + + + +
+
+
+
SMM
+
+

Консоль оператора

+ Server Monitor Manager +
+
+
+
+ + mTLS подключен +
+ + +
+
+ +
+ + + +
+
+
+

Узлы мониторинга

+ 0 +
+ Зарегистрированные агенты и их текущий статус +
+ +
+ + + + + + + + + + + + + + + +
Имя узлаСостояниеВерсия агентаПоследний HeartbeatСертификат
Загрузка списка узлов...
+ +
+
+ + + +
+ +
+ + +
+
+ + + + + + + diff --git a/src/ServerMonitorManager.Control/wwwroot/style.css b/src/ServerMonitorManager.Control/wwwroot/style.css new file mode 100644 index 0000000..679e26b --- /dev/null +++ b/src/ServerMonitorManager.Control/wwwroot/style.css @@ -0,0 +1,652 @@ +:root { + --bg-primary: #0f172a; + --bg-secondary: #1e293b; + --bg-tertiary: #334155; + --bg-card: #1e293b; + --bg-input: #0f172a; + + --text-primary: #f8fafc; + --text-secondary: #94a3b8; + --text-muted: #64748b; + + --accent-primary: #2563eb; + --accent-primary-hover: #1d4ed8; + --accent-primary-active: #1e40af; + + --border-color: #334155; + --border-subtle: #1e293b; + + --success-bg: rgba(16, 185, 129, 0.15); + --success-text: #34d399; + --success-border: #059669; + + --warning-bg: rgba(245, 158, 11, 0.15); + --warning-text: #fbbf24; + --warning-border: #d97706; + + --danger-bg: rgba(239, 68, 68, 0.15); + --danger-text: #f87171; + --danger-border: #dc2626; + + --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: var(--font-sans); + background-color: var(--bg-primary); + color: var(--text-primary); + line-height: 1.5; + min-height: 100vh; +} + +.app-container { + display: flex; + flex-direction: column; + min-height: 100vh; +} + +/* Header */ +.app-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem 2rem; + background-color: var(--bg-secondary); + border-bottom: 1px solid var(--border-color); + flex-wrap: wrap; + gap: 1rem; +} + +.header-brand { + display: flex; + align-items: center; + gap: 0.875rem; +} + +.brand-badge { + background-color: var(--accent-primary); + color: #fff; + font-weight: 700; + font-size: 1rem; + padding: 0.35rem 0.65rem; + border-radius: 6px; + letter-spacing: 0.05em; +} + +.brand-titles h1 { + font-size: 1.25rem; + font-weight: 700; + color: var(--text-primary); + letter-spacing: -0.01em; +} + +.brand-subtitle { + font-size: 0.8rem; + color: var(--text-secondary); + display: block; +} + +.header-actions { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.status-indicator { + display: flex; + align-items: center; + gap: 0.5rem; + background-color: var(--bg-primary); + padding: 0.4rem 0.85rem; + border-radius: 6px; + border: 1px solid var(--border-color); + font-size: 0.825rem; + color: var(--text-secondary); +} + +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background-color: #10b981; + display: inline-block; + box-shadow: 0 0 6px #10b981; +} + +/* Buttons */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.4rem; + font-family: inherit; + font-size: 0.875rem; + font-weight: 600; + padding: 0.5rem 1rem; + border-radius: 6px; + border: 1px solid transparent; + cursor: pointer; + transition: background-color 0.15s ease, border-color 0.15s ease, transform 0.05s ease; + user-select: none; +} + +.btn:active { + transform: scale(0.98); +} + +.btn:focus-visible { + outline: 2px solid var(--accent-primary); + outline-offset: 2px; +} + +.btn-primary { + background-color: var(--accent-primary); + color: #ffffff; +} + +.btn-primary:hover { + background-color: var(--accent-primary-hover); +} + +.btn-secondary { + background-color: var(--bg-tertiary); + color: var(--text-primary); + border-color: var(--border-color); +} + +.btn-secondary:hover { + background-color: #475569; +} + +.btn-outline { + background-color: transparent; + color: var(--text-primary); + border-color: var(--border-color); +} + +.btn-outline:hover { + background-color: var(--bg-tertiary); +} + +.btn-sm { + font-size: 0.775rem; + padding: 0.3rem 0.65rem; +} + +.btn-icon { + font-size: 1rem; + line-height: 1; +} + +/* Main content */ +.app-main { + flex: 1; + padding: 2rem; + max-width: 1400px; + margin: 0 auto; + width: 100%; + display: flex; + flex-direction: column; + gap: 2rem; +} + +.dashboard-section { + background-color: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 8px; + overflow: hidden; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.2); +} + +.section-header { + padding: 1.25rem 1.5rem; + border-bottom: 1px solid var(--border-color); + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.section-title-group { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.section-title-group h2 { + font-size: 1.15rem; + font-weight: 700; + color: var(--text-primary); +} + +.counter-badge { + background-color: var(--bg-tertiary); + color: var(--text-secondary); + font-size: 0.8rem; + font-weight: 700; + padding: 0.15rem 0.5rem; + border-radius: 9999px; +} + +.section-hint { + font-size: 0.825rem; + color: var(--text-muted); +} + +/* Tables */ +.table-container { + overflow-x: auto; + min-height: 140px; +} + +.data-table { + width: 100%; + border-collapse: collapse; + text-align: left; + font-size: 0.875rem; +} + +.data-table th { + background-color: rgba(15, 23, 42, 0.6); + color: var(--text-secondary); + font-weight: 600; + padding: 0.75rem 1.5rem; + border-bottom: 1px solid var(--border-color); + text-transform: uppercase; + font-size: 0.75rem; + letter-spacing: 0.05em; +} + +.data-table td { + padding: 1rem 1.5rem; + border-bottom: 1px solid var(--border-color); + color: var(--text-primary); + vertical-align: middle; +} + +.data-table tbody tr:last-child td { + border-bottom: none; +} + +.data-table tbody tr:hover { + background-color: rgba(255, 255, 255, 0.02); +} + +.node-name-cell { + font-family: var(--font-mono); + font-weight: 600; + color: #60a5fa; +} + +.link-id-cell { + font-family: var(--font-mono); + font-size: 0.8rem; + color: var(--text-secondary); +} + +.loading-row td { + text-align: center; + color: var(--text-muted); + font-style: italic; + padding: 2.5rem; +} + +/* Status Badges */ +.status-badge { + display: inline-flex; + align-items: center; + gap: 0.35rem; + font-size: 0.775rem; + font-weight: 600; + padding: 0.25rem 0.65rem; + border-radius: 9999px; + text-transform: uppercase; + letter-spacing: 0.025em; +} + +.status-badge.active, +.status-badge.online, +.status-badge.connected { + background-color: var(--success-bg); + color: var(--success-text); + border: 1px solid var(--success-border); +} + +.status-badge.connecting, +.status-badge.pending { + background-color: var(--warning-bg); + color: var(--warning-text); + border: 1px solid var(--warning-border); +} + +.status-badge.inactive, +.status-badge.offline, +.status-badge.disabled, +.status-badge.failed { + background-color: var(--danger-bg); + color: var(--danger-text); + border: 1px solid var(--danger-border); +} + +/* Empty State */ +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 3.5rem 2rem; + text-align: center; +} + +.empty-state-icon { + font-size: 2.5rem; + margin-bottom: 0.75rem; +} + +.empty-state-text { + font-size: 1.05rem; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 0.25rem; +} + +.empty-state-subtext { + font-size: 0.85rem; + color: var(--text-muted); + max-width: 450px; +} + +/* Alerts */ +.alert { + padding: 1rem 1.25rem; + border-radius: 6px; + font-size: 0.875rem; + display: flex; + align-items: center; + gap: 0.75rem; +} + +.alert-error { + background-color: var(--danger-bg); + color: var(--danger-text); + border: 1px solid var(--danger-border); +} + +.alert-success { + background-color: var(--success-bg); + color: var(--success-text); + border: 1px solid var(--success-border); +} + +/* Modals */ +.modal-backdrop { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.75); + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; + z-index: 1000; + backdrop-filter: blur(4px); +} + +.modal-window { + background-color: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 8px; + width: 100%; + max-width: 640px; + box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.5), 0 10px 10px -5px rgba(0, 0, 0, 0.4); + overflow: hidden; + animation: modalEnter 0.15s ease-out; +} + +@keyframes modalEnter { + from { + opacity: 0; + transform: scale(0.96) translateY(8px); + } + to { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1.25rem 1.5rem; + border-bottom: 1px solid var(--border-color); +} + +.modal-header h3 { + font-size: 1.15rem; + font-weight: 700; + color: var(--text-primary); +} + +.modal-close { + background: transparent; + border: none; + color: var(--text-muted); + font-size: 1.5rem; + cursor: pointer; + line-height: 1; + padding: 0.25rem; + border-radius: 4px; +} + +.modal-close:hover { + color: var(--text-primary); + background-color: var(--bg-tertiary); +} + +.modal-body { + padding: 1.5rem; + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +/* Forms */ +.form-group { + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.form-group label { + font-size: 0.875rem; + font-weight: 600; + color: var(--text-primary); +} + +.form-control { + font-family: var(--font-mono); + font-size: 0.925rem; + padding: 0.65rem 0.85rem; + background-color: var(--bg-input); + border: 1px solid var(--border-color); + border-radius: 6px; + color: var(--text-primary); + outline: none; + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} + +.form-control:focus { + border-color: var(--accent-primary); + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.25); +} + +.form-hint { + font-size: 0.775rem; + color: var(--text-muted); +} + +.form-actions { + display: flex; + justify-content: flex-end; + gap: 0.75rem; + margin-top: 1rem; +} + +/* Enrollment Code Result */ +.enrollment-result-container { + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +.security-warning-card { + background-color: #451a03; + border: 2px solid #f59e0b; + border-radius: 8px; + padding: 1rem 1.25rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.warning-badge-label { + display: flex; + align-items: center; + gap: 0.5rem; + color: #fbbf24; + font-size: 0.85rem; + font-weight: 700; + letter-spacing: 0.025em; +} + +.warning-text { + font-size: 0.825rem; + color: #fef3c7; + line-height: 1.45; +} + +.result-field-group { + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.result-field-header { + display: flex; + justify-content: space-between; + align-items: center; +} + +.result-field-header label { + font-size: 0.825rem; + font-weight: 600; + color: var(--text-secondary); +} + +.code-box { + background-color: var(--bg-input); + border: 1px solid var(--border-color); + border-radius: 6px; + padding: 0.75rem; + font-family: var(--font-mono); + font-size: 0.825rem; + color: #e2e8f0; + word-break: break-all; + width: 100%; +} + +.fingerprint-box { + color: #38bdf8; + font-weight: 600; +} + +.enrollment-code-box { + resize: vertical; + min-height: 90px; + line-height: 1.4; + outline: none; +} + +.enrollment-code-box:focus { + border-color: var(--accent-primary); +} + +.result-meta-row { + display: flex; + justify-content: space-between; + align-items: center; + padding-top: 0.5rem; + border-top: 1px solid var(--border-color); +} + +.expiration-info { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.825rem; + color: var(--text-secondary); +} + +.countdown-badge { + background-color: var(--warning-bg); + color: var(--warning-text); + border: 1px solid var(--warning-border); + padding: 0.15rem 0.5rem; + border-radius: 4px; + font-family: var(--font-mono); + font-weight: 700; +} + +/* Footer */ +.app-footer { + padding: 1.25rem 2rem; + background-color: var(--bg-secondary); + border-top: 1px solid var(--border-color); + display: flex; + justify-content: space-between; + align-items: center; + font-size: 0.8rem; + color: var(--text-muted); + flex-wrap: wrap; + gap: 0.5rem; +} + +.footer-info { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.footer-separator { + color: var(--border-color); +} + +/* Utilities */ +.hidden { + display: none !important; +} + +/* Responsive */ +@media (max-width: 768px) { + .app-header { + padding: 1rem; + } + .app-main { + padding: 1rem; + } + .app-footer { + padding: 1rem; + } + .modal-window { + margin: 0.5rem; + } +} diff --git a/tests/ServerMonitorManager.Control.Tests/WebConsoleTests.cs b/tests/ServerMonitorManager.Control.Tests/WebConsoleTests.cs new file mode 100644 index 0000000..6501872 --- /dev/null +++ b/tests/ServerMonitorManager.Control.Tests/WebConsoleTests.cs @@ -0,0 +1,355 @@ +extern alias controlapp; + +using System.Net; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Xunit; + +namespace ServerMonitorManager.Control.Tests; + +public sealed class WebConsoleTests : IAsyncDisposable +{ + private readonly WebConsoleTestFactory _factory = new(); + + [Theory] + [InlineData("/")] + [InlineData("/index.html")] + [InlineData("/style.css")] + [InlineData("/app.js")] + [InlineData("/console")] + public async Task AnonymousAccessToWebConsoleRoutesIsRejectedWithUnauthorized(string path) + { + using var client = _factory.CreateClient(); + var response = await client.GetAsync(path, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Theory] + [InlineData("/")] + [InlineData("/index.html")] + [InlineData("/style.css")] + [InlineData("/app.js")] + public async Task AgentRoleIsForbiddenFromWebConsole(string path) + { + using var client = _factory.CreateClient(); + client.DefaultRequestHeaders.Add("X-Test-Identity", "agent-node-01"); + client.DefaultRequestHeaders.Add("X-Test-Role", "Agent"); + + var response = await client.GetAsync(path, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Theory] + [InlineData("/")] + [InlineData("/index.html")] + [InlineData("/style.css")] + [InlineData("/app.js")] + public async Task AutomationRoleIsForbiddenFromWebConsole(string path) + { + using var client = _factory.CreateClient(); + client.DefaultRequestHeaders.Add("X-Test-Identity", "automation-worker-01"); + client.DefaultRequestHeaders.Add("X-Test-Role", "Automation"); + + var response = await client.GetAsync(path, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task OperatorCanAccessWebConsoleHtmlAndAssets() + { + using var client = _factory.CreateClient(); + client.DefaultRequestHeaders.Add("X-Test-Identity", "operator-admin"); + client.DefaultRequestHeaders.Add("X-Test-Role", "Operator"); + + var indexResponse = await client.GetAsync("/", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, indexResponse.StatusCode); + Assert.Contains("text/html", indexResponse.Content.Headers.ContentType?.MediaType, StringComparison.OrdinalIgnoreCase); + + var cssResponse = await client.GetAsync("/style.css", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, cssResponse.StatusCode); + Assert.Contains("text/css", cssResponse.Content.Headers.ContentType?.MediaType, StringComparison.OrdinalIgnoreCase); + + var jsResponse = await client.GetAsync("/app.js", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, jsResponse.StatusCode); + Assert.Contains("javascript", jsResponse.Content.Headers.ContentType?.MediaType, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task WebConsoleHtmlContainsRequiredUiElementsAndWarning() + { + 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("/index.html", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var html = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + + // Header & Actions + Assert.Contains("id=\"add-node-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=\"nodes-count\"", html, StringComparison.Ordinal); + Assert.Contains("id=\"links-count\"", html, StringComparison.Ordinal); + + // 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); + + // Result displays + Assert.Contains("id=\"ca-fingerprint-display\"", html, StringComparison.Ordinal); + Assert.Contains("id=\"enrollment-code-display\"", html, StringComparison.Ordinal); + Assert.Contains("id=\"copy-code-btn\"", html, StringComparison.Ordinal); + Assert.Contains("id=\"copy-fingerprint-btn\"", html, StringComparison.Ordinal); + Assert.Contains("id=\"countdown-timer\"", html, StringComparison.Ordinal); + + // Security verification requirement for CA fingerprint + Assert.Contains("ОБЯЗАТЕЛЬНО К ПРОВЕРКЕ", html, StringComparison.Ordinal); + Assert.Contains("отпечаток", html, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task EmbeddedAssetsAreServedWhenDiskWebRootIsMissing() + { + await using var embeddedFactory = new WebConsoleEmbeddedOnlyTestFactory(); + using var client = embeddedFactory.CreateClient(); + client.DefaultRequestHeaders.Add("X-Test-Identity", "operator-admin"); + client.DefaultRequestHeaders.Add("X-Test-Role", "Operator"); + + // 1. Root / + var rootResponse = await client.GetAsync("/", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, rootResponse.StatusCode); + Assert.Contains("text/html", rootResponse.Content.Headers.ContentType?.MediaType, StringComparison.OrdinalIgnoreCase); + var rootHtml = await rootResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + Assert.NotEmpty(rootHtml); + Assert.Contains("id=\"nodes-table\"", rootHtml, StringComparison.Ordinal); + Assert.Contains("id=\"add-node-btn\"", rootHtml, StringComparison.Ordinal); + + // 2. /index.html + var indexResponse = await client.GetAsync("/index.html", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, indexResponse.StatusCode); + Assert.Contains("text/html", indexResponse.Content.Headers.ContentType?.MediaType, StringComparison.OrdinalIgnoreCase); + var indexHtml = await indexResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + Assert.NotEmpty(indexHtml); + Assert.Contains("id=\"links-table\"", indexHtml, StringComparison.Ordinal); + + // 3. /style.css + var cssResponse = await client.GetAsync("/style.css", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, cssResponse.StatusCode); + Assert.Contains("text/css", cssResponse.Content.Headers.ContentType?.MediaType, StringComparison.OrdinalIgnoreCase); + var cssContent = await cssResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + Assert.NotEmpty(cssContent); + Assert.Contains(".app-container", cssContent, StringComparison.Ordinal); + + // 4. /app.js + var jsResponse = await client.GetAsync("/app.js", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, jsResponse.StatusCode); + Assert.Contains("javascript", jsResponse.Content.Headers.ContentType?.MediaType, StringComparison.OrdinalIgnoreCase); + var jsContent = await jsResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + Assert.NotEmpty(jsContent); + Assert.Contains("loadDashboardData", jsContent, StringComparison.Ordinal); + + // 5. /console alias + var consoleResponse = await client.GetAsync("/console", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, consoleResponse.StatusCode); + Assert.Contains("text/html", consoleResponse.Content.Headers.ContentType?.MediaType, StringComparison.OrdinalIgnoreCase); + } + + public async ValueTask DisposeAsync() => await _factory.DisposeAsync(); + + private sealed class WebConsoleEmbeddedOnlyTestFactory : WebApplicationFactory + { + private readonly string _directory = Path.Combine( + Path.GetTempPath(), $"smm-web-console-embedded-{Guid.NewGuid():N}"); + + public string DatabasePath => Path.Combine(_directory, "control.db"); + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + Directory.CreateDirectory(_directory); + var emptyWebRoot = Path.Combine(_directory, "empty-webroot-no-files"); + Directory.CreateDirectory(emptyWebRoot); + builder.UseWebRoot(emptyWebRoot); + + var authorityPath = Path.Combine(_directory, "control-ca.pfx"); + if (!File.Exists(authorityPath)) + { + using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256); + var request = new CertificateRequest("CN=SMM Web Console Embedded CA", key, HashAlgorithmName.SHA256); + request.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true)); + request.CertificateExtensions.Add(new X509KeyUsageExtension( + X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.CrlSign, true)); + using var certificate = request.CreateSelfSigned( + DateTimeOffset.UtcNow.AddMinutes(-1), DateTimeOffset.UtcNow.AddDays(1)); + File.WriteAllBytes(authorityPath, certificate.Export(X509ContentType.Pfx)); + } + + var publicUrlPath = Path.Combine(_directory, "control-public-url"); + File.WriteAllText(publicUrlPath, "https://hub.example.com:7443\n"); + + var meshEnvPath = Path.Combine(_directory, "mesh.env"); + File.WriteAllText(meshEnvPath, "HUB_ENDPOINT=hub.example.com:51820\nHUB_PUBLIC_KEY=mQZ/Y4yQpQhX6j0rL8vU2w==\nMESH_NETWORK=10.77.0.0/24\n"); + + var meshNodesPath = Path.Combine(_directory, "mesh", "nodes.tsv"); + + builder.ConfigureAppConfiguration((_, configuration) => + configuration.AddInMemoryCollection(new Dictionary + { + ["Control:DatabasePath"] = DatabasePath, + ["Control:CertificateAuthorityPath"] = authorityPath, + ["Control:BackupDirectory"] = Path.Combine(_directory, "backups"), + ["Control:PublicUrlPath"] = publicUrlPath, + ["Control:MeshEnvironmentPath"] = meshEnvPath, + ["Control:MeshNodesPath"] = meshNodesPath + })); + + builder.ConfigureTestServices(services => + { + services.RemoveAll(); + services.AddAuthentication(options => + { + options.DefaultAuthenticateScheme = "Test"; + options.DefaultChallengeScheme = "Test"; + }) + .AddScheme("Test", _ => { }); + }); + } + + public override async ValueTask DisposeAsync() + { + await base.DisposeAsync(); + try + { + if (Directory.Exists(_directory)) + { + Directory.Delete(_directory, recursive: true); + } + } + catch + { + // Ignored in test cleanup + } + } + } + + private sealed class WebConsoleTestFactory : WebApplicationFactory + { + private readonly string _directory = Path.Combine( + Path.GetTempPath(), $"smm-web-console-tests-{Guid.NewGuid():N}"); + + public string DatabasePath => Path.Combine(_directory, "control.db"); + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + Directory.CreateDirectory(_directory); + var authorityPath = Path.Combine(_directory, "control-ca.pfx"); + if (!File.Exists(authorityPath)) + { + using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256); + var request = new CertificateRequest("CN=SMM Web Console Test CA", key, HashAlgorithmName.SHA256); + request.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true)); + request.CertificateExtensions.Add(new X509KeyUsageExtension( + X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.CrlSign, true)); + using var certificate = request.CreateSelfSigned( + DateTimeOffset.UtcNow.AddMinutes(-1), DateTimeOffset.UtcNow.AddDays(1)); + File.WriteAllBytes(authorityPath, certificate.Export(X509ContentType.Pfx)); + } + + var publicUrlPath = Path.Combine(_directory, "control-public-url"); + File.WriteAllText(publicUrlPath, "https://hub.example.com:7443\n"); + + var meshEnvPath = Path.Combine(_directory, "mesh.env"); + File.WriteAllText(meshEnvPath, "HUB_ENDPOINT=hub.example.com:51820\nHUB_PUBLIC_KEY=mQZ/Y4yQpQhX6j0rL8vU2w==\nMESH_NETWORK=10.77.0.0/24\n"); + + var meshNodesPath = Path.Combine(_directory, "mesh", "nodes.tsv"); + + builder.ConfigureAppConfiguration((_, configuration) => + configuration.AddInMemoryCollection(new Dictionary + { + ["Control:DatabasePath"] = DatabasePath, + ["Control:CertificateAuthorityPath"] = authorityPath, + ["Control:BackupDirectory"] = Path.Combine(_directory, "backups"), + ["Control:PublicUrlPath"] = publicUrlPath, + ["Control:MeshEnvironmentPath"] = meshEnvPath, + ["Control:MeshNodesPath"] = meshNodesPath + })); + + builder.ConfigureTestServices(services => + { + services.RemoveAll(); + services.AddAuthentication(options => + { + options.DefaultAuthenticateScheme = "Test"; + options.DefaultChallengeScheme = "Test"; + }) + .AddScheme("Test", _ => { }); + }); + } + + public override async ValueTask DisposeAsync() + { + await base.DisposeAsync(); + try + { + if (Directory.Exists(_directory)) + { + Directory.Delete(_directory, recursive: true); + } + } + catch + { + // Ignored in test cleanup + } + } + } + + private sealed class TestAuthenticationHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder) : AuthenticationHandler(options, logger, encoder) + { + protected override Task HandleAuthenticateAsync() + { + if (!Request.Headers.TryGetValue("X-Test-Role", out var roleValue) + || string.IsNullOrWhiteSpace(roleValue)) + { + return Task.FromResult(AuthenticateResult.NoResult()); + } + + var identityName = Request.Headers.TryGetValue("X-Test-Identity", out var identityValue) + && !string.IsNullOrWhiteSpace(identityValue) + ? identityValue.ToString() + : "test-user"; + + var claims = new List + { + new(ClaimTypes.NameIdentifier, identityName), + new(ClaimTypes.Role, roleValue.ToString()) + }; + + var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, Scheme.Name)); + var ticket = new AuthenticationTicket(principal, Scheme.Name); + return Task.FromResult(AuthenticateResult.Success(ticket)); + } + } +}