Merge remote-tracking branch 'origin/main' into codex/uninstall-mode
This commit is contained in:
commit
53fba8617e
6 changed files with 1652 additions and 0 deletions
|
|
@ -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");
|
var control = app.MapGroup("/api/v1/control").RequireAuthorization("Operator");
|
||||||
control.MapPost("/certificates/renew", async (
|
control.MapPost("/certificates/renew", async (
|
||||||
CertificateRenewalRequest request,
|
CertificateRenewalRequest request,
|
||||||
|
|
@ -1054,6 +1061,36 @@ automation.MapGet("/links", async (
|
||||||
await app.RunAsync();
|
await app.RunAsync();
|
||||||
return 0;
|
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<IResult> ChangeProvisioningJobAsync(
|
static async Task<IResult> ChangeProvisioningJobAsync(
|
||||||
string id,
|
string id,
|
||||||
ProvisioningJobCommandRequest request,
|
ProvisioningJobCommandRequest request,
|
||||||
|
|
|
||||||
|
|
@ -16,5 +16,6 @@
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<None Update="appsettings.json" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
|
<None Update="appsettings.json" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
|
||||||
|
<EmbeddedResource Include="wwwroot\**" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
||||||
395
src/ServerMonitorManager.Control/wwwroot/app.js
Normal file
395
src/ServerMonitorManager.Control/wwwroot/app.js
Normal file
|
|
@ -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 `
|
||||||
|
<tr>
|
||||||
|
<td class="node-name-cell">${escapeHtml(agent.nodeId || agent.name || '—')}</td>
|
||||||
|
<td><span class="status-badge ${escapeHtml(statusClass)}">${escapeHtml(agent.status || 'Unknown')}</span></td>
|
||||||
|
<td>${escapeHtml(agent.agentVersion || '—')}</td>
|
||||||
|
<td>${lastSeen}</td>
|
||||||
|
<td>${certInfo}</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).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 `
|
||||||
|
<tr>
|
||||||
|
<td class="link-id-cell" title="${escapeHtml(link.id)}">${escapeHtml((link.id || '').substring(0, 8))}...</td>
|
||||||
|
<td class="node-name-cell">${escapeHtml(link.sourceNodeId || '—')}</td>
|
||||||
|
<td class="node-name-cell">${escapeHtml(link.targetNodeId || '—')}</td>
|
||||||
|
<td>${escapeHtml(link.protocol || 'TCP')}/${escapeHtml(String(link.port || 0))}</td>
|
||||||
|
<td><span class="status-badge ${escapeHtml(desiredClass)}">${escapeHtml(link.desiredState || '—')}</span></td>
|
||||||
|
<td><span class="status-badge ${escapeHtml(actualClass)}">${escapeHtml(link.actualState || '—')}</span></td>
|
||||||
|
<td>${expires}</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).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 '<span class="text-muted">—</span>';
|
||||||
|
const date = new Date(isoString);
|
||||||
|
if (isNaN(date.getTime())) return '<span class="text-muted">—</span>';
|
||||||
|
|
||||||
|
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 '<span class="status-badge failed">Истёк</span>';
|
||||||
|
if (days <= 7) return `<span class="status-badge pending">${days} дн.</span>`;
|
||||||
|
return `<span class="status-badge active">${days} дн.</span>`;
|
||||||
|
}
|
||||||
|
if (agent.certificateExpiresAt) {
|
||||||
|
const expDate = new Date(agent.certificateExpiresAt);
|
||||||
|
return expDate.toLocaleDateString();
|
||||||
|
}
|
||||||
|
return '<span class="text-muted">—</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
if (str === null || str === undefined) return '';
|
||||||
|
return String(str)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
})();
|
||||||
212
src/ServerMonitorManager.Control/wwwroot/index.html
Normal file
212
src/ServerMonitorManager.Control/wwwroot/index.html
Normal file
|
|
@ -0,0 +1,212 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>SMM — Консоль оператора</title>
|
||||||
|
<link rel="stylesheet" href="/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app-container">
|
||||||
|
<header class="app-header">
|
||||||
|
<div class="header-brand">
|
||||||
|
<div class="brand-badge">SMM</div>
|
||||||
|
<div class="brand-titles">
|
||||||
|
<h1>Консоль оператора</h1>
|
||||||
|
<span class="brand-subtitle">Server Monitor Manager</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<div class="status-indicator">
|
||||||
|
<span class="status-dot"></span>
|
||||||
|
<span id="connection-status">mTLS подключен</span>
|
||||||
|
</div>
|
||||||
|
<button id="refresh-btn" class="btn btn-secondary" title="Обновить данные">
|
||||||
|
<span class="btn-icon">↻</span>
|
||||||
|
<span>Обновить</span>
|
||||||
|
</button>
|
||||||
|
<button id="add-node-btn" class="btn btn-primary">
|
||||||
|
<span class="btn-icon">+</span>
|
||||||
|
<span>Добавить узел</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="app-main">
|
||||||
|
<div id="global-alert" class="alert hidden" role="alert"></div>
|
||||||
|
|
||||||
|
<!-- Раздел: Узлы -->
|
||||||
|
<section class="dashboard-section" id="nodes-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<div class="section-title-group">
|
||||||
|
<h2>Узлы мониторинга</h2>
|
||||||
|
<span id="nodes-count" class="counter-badge">0</span>
|
||||||
|
</div>
|
||||||
|
<span class="section-hint">Зарегистрированные агенты и их текущий статус</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-container">
|
||||||
|
<table class="data-table" id="nodes-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Имя узла</th>
|
||||||
|
<th>Состояние</th>
|
||||||
|
<th>Версия агента</th>
|
||||||
|
<th>Последний Heartbeat</th>
|
||||||
|
<th>Сертификат</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="nodes-table-body">
|
||||||
|
<tr class="loading-row">
|
||||||
|
<td colspan="5">Загрузка списка узлов...</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div id="nodes-empty" class="empty-state hidden">
|
||||||
|
<div class="empty-state-icon">🖥️</div>
|
||||||
|
<div class="empty-state-text">Нет зарегистрированных узлов</div>
|
||||||
|
<p class="empty-state-subtext">Нажмите «Добавить узел», чтобы выпустить код регистрации и подключить первый сервер.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Раздел: Сетевые связи (Links) -->
|
||||||
|
<section class="dashboard-section" id="links-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<div class="section-title-group">
|
||||||
|
<h2>Сетевые связи (Links)</h2>
|
||||||
|
<span id="links-count" class="counter-badge">0</span>
|
||||||
|
</div>
|
||||||
|
<span class="section-hint">Политики взаимодействия между узлами mesh-сети</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-container">
|
||||||
|
<table class="data-table" id="links-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID связи</th>
|
||||||
|
<th>Источник</th>
|
||||||
|
<th>Назначение</th>
|
||||||
|
<th>Протокол / Порт</th>
|
||||||
|
<th>Требуемое состояние</th>
|
||||||
|
<th>Фактическое состояние</th>
|
||||||
|
<th>Срок действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="links-table-body">
|
||||||
|
<tr class="loading-row">
|
||||||
|
<td colspan="7">Загрузка сетевых связей...</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div id="links-empty" class="empty-state hidden">
|
||||||
|
<div class="empty-state-icon">🔗</div>
|
||||||
|
<div class="empty-state-text">Нет активных связей</div>
|
||||||
|
<p class="empty-state-subtext">Сетевые политики взаимодействия между узлами ещё не созданы.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="app-footer">
|
||||||
|
<div class="footer-info">
|
||||||
|
<span>Server Monitor Manager v0.1.0</span>
|
||||||
|
<span class="footer-separator">•</span>
|
||||||
|
<span>Доступ авторизован по сертификату роли Operator</span>
|
||||||
|
</div>
|
||||||
|
<div class="footer-timestamp">
|
||||||
|
<span id="last-updated-text">Синхронизировано</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Модальное окно: Добавление узла -->
|
||||||
|
<div id="add-node-modal" class="modal-backdrop hidden" role="dialog" aria-modal="true" aria-labelledby="modal-title">
|
||||||
|
<div class="modal-window">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 id="modal-title">Выпуск кода регистрации узла</h3>
|
||||||
|
<button id="modal-close-btn" class="modal-close" aria-label="Закрыть">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-body">
|
||||||
|
<!-- Шаг 1: Форма ввода имени -->
|
||||||
|
<form id="enrollment-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="node-id-input">Имя нового узла (Node ID):</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="node-id-input"
|
||||||
|
class="form-control"
|
||||||
|
placeholder="например: web-prod-01"
|
||||||
|
maxlength="63"
|
||||||
|
pattern="^[a-z0-9-]{1,63}$"
|
||||||
|
required
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false">
|
||||||
|
<span class="form-hint">От 1 до 63 символов: строчные латинские буквы, цифры и дефисы.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="modal-error" class="alert alert-error hidden" role="alert"></div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="button" id="modal-cancel-btn" class="btn btn-secondary">Отмена</button>
|
||||||
|
<button type="submit" id="generate-code-btn" class="btn btn-primary">
|
||||||
|
<span>Сгенерировать код</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Шаг 2: Результат генерации кода -->
|
||||||
|
<div id="enrollment-result" class="enrollment-result-container hidden">
|
||||||
|
<!-- Обязательное заметное предупреждение для оператора -->
|
||||||
|
<div class="security-warning-card" role="alert">
|
||||||
|
<div class="warning-badge-label">
|
||||||
|
<span class="warning-icon">⚠️</span>
|
||||||
|
<strong>ОБЯЗАТЕЛЬНО К ПРОВЕРКЕ ПЕРЕД ПОДТВЕРЖДЕНИЕМ УСТАНОВКИ</strong>
|
||||||
|
</div>
|
||||||
|
<p class="warning-text">
|
||||||
|
При выполнении установщика на сервере обязательно сверьте отпечаток CA, выведенный скриптом, с отпечатком ниже. Подтверждение без сверки обесценивает криптографическую проверку подписи!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="result-field-group">
|
||||||
|
<div class="result-field-header">
|
||||||
|
<label for="ca-fingerprint-display">Отпечаток CA (SHA-256):</label>
|
||||||
|
<button id="copy-fingerprint-btn" class="btn btn-sm btn-outline" type="button" title="Скопировать отпечаток CA">
|
||||||
|
Копировать отпечаток
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="code-box fingerprint-box" id="ca-fingerprint-display" tabindex="0"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="result-field-group">
|
||||||
|
<div class="result-field-header">
|
||||||
|
<label for="enrollment-code-display">Код регистрации (SMMNODE2):</label>
|
||||||
|
<button id="copy-code-btn" class="btn btn-sm btn-primary" type="button" title="Скопировать код регистрации">
|
||||||
|
Скопировать код
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
id="enrollment-code-display"
|
||||||
|
class="code-box enrollment-code-box"
|
||||||
|
readonly
|
||||||
|
rows="4"
|
||||||
|
spellcheck="false"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="result-meta-row">
|
||||||
|
<div class="expiration-info">
|
||||||
|
<span class="timer-icon">⏱️</span>
|
||||||
|
<span>Срок действия: <strong>10 минут</strong></span>
|
||||||
|
<span id="countdown-timer" class="countdown-badge">10:00</span>
|
||||||
|
</div>
|
||||||
|
<button type="button" id="result-done-btn" class="btn btn-secondary">Готово</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
652
src/ServerMonitorManager.Control/wwwroot/style.css
Normal file
652
src/ServerMonitorManager.Control/wwwroot/style.css
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
355
tests/ServerMonitorManager.Control.Tests/WebConsoleTests.cs
Normal file
355
tests/ServerMonitorManager.Control.Tests/WebConsoleTests.cs
Normal file
|
|
@ -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<controlapp::Program>
|
||||||
|
{
|
||||||
|
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<string, string?>
|
||||||
|
{
|
||||||
|
["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<IHostedService>();
|
||||||
|
services.AddAuthentication(options =>
|
||||||
|
{
|
||||||
|
options.DefaultAuthenticateScheme = "Test";
|
||||||
|
options.DefaultChallengeScheme = "Test";
|
||||||
|
})
|
||||||
|
.AddScheme<AuthenticationSchemeOptions, TestAuthenticationHandler>("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<controlapp::Program>
|
||||||
|
{
|
||||||
|
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<string, string?>
|
||||||
|
{
|
||||||
|
["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<IHostedService>();
|
||||||
|
services.AddAuthentication(options =>
|
||||||
|
{
|
||||||
|
options.DefaultAuthenticateScheme = "Test";
|
||||||
|
options.DefaultChallengeScheme = "Test";
|
||||||
|
})
|
||||||
|
.AddScheme<AuthenticationSchemeOptions, TestAuthenticationHandler>("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<AuthenticationSchemeOptions> options,
|
||||||
|
ILoggerFactory logger,
|
||||||
|
UrlEncoder encoder) : AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
|
||||||
|
{
|
||||||
|
protected override Task<AuthenticateResult> 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<Claim>
|
||||||
|
{
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue