Merge branch 'main' into dependabot/github_actions/actions/download-artifact-8.0.1

This commit is contained in:
ochenstarik-ui 2026-08-19 16:45:55 +07:00 committed by GitHub
commit 4886669508
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 1038 additions and 188 deletions

View file

@ -8,22 +8,28 @@
// DOM Elements
const nodesTableBody = document.getElementById('nodes-table-body');
const linksTableBody = document.getElementById('links-table-body');
const eventsTableBody = document.getElementById('events-table-body');
const nodesCountBadge = document.getElementById('nodes-count');
const linksCountBadge = document.getElementById('links-count');
const eventsCountBadge = document.getElementById('events-count');
const nodesEmpty = document.getElementById('nodes-empty');
const linksEmpty = document.getElementById('links-empty');
const eventsEmpty = document.getElementById('events-empty');
const lastUpdatedText = document.getElementById('last-updated-text');
const globalAlert = document.getElementById('global-alert');
const connectionStatus = document.getElementById('connection-status');
const testingBanner = document.getElementById('testing-auth-warning');
const nodesDatalist = document.getElementById('nodes-datalist');
// Action Elements
const refreshBtn = document.getElementById('refresh-btn');
const addNodeBtn = document.getElementById('add-node-btn');
const createLinkBtn = document.getElementById('create-link-btn');
const headerCreateLinkBtn = document.getElementById('header-create-link-btn');
const logoutBtn = document.getElementById('logout-btn');
// Modal Elements
const modalBackdrop = document.getElementById('add-node-modal');
// Add Node Modal Elements
const addNodeModal = document.getElementById('add-node-modal');
const modalCloseBtn = document.getElementById('modal-close-btn');
const modalCancelBtn = document.getElementById('modal-cancel-btn');
const enrollmentForm = document.getElementById('enrollment-form');
@ -32,6 +38,26 @@
const modalError = document.getElementById('modal-error');
const enrollmentResult = document.getElementById('enrollment-result');
const resultDoneBtn = document.getElementById('result-done-btn');
const caFingerprintDisplay = document.getElementById('ca-fingerprint-display');
const enrollmentCodeDisplay = document.getElementById('enrollment-code-display');
const copyFingerprintBtn = document.getElementById('copy-fingerprint-btn');
const copyCodeBtn = document.getElementById('copy-code-btn');
const countdownTimer = document.getElementById('countdown-timer');
// Create Link Modal Elements
const createLinkModal = document.getElementById('create-link-modal');
const linkModalCloseBtn = document.getElementById('link-modal-close-btn');
const linkModalCancelBtn = document.getElementById('link-modal-cancel-btn');
const createLinkForm = document.getElementById('create-link-form');
const linkSourceNode = document.getElementById('link-source-node');
const linkTargetNode = document.getElementById('link-target-node');
const linkProtocol = document.getElementById('link-protocol');
const linkPort = document.getElementById('link-port');
const linkTtl = document.getElementById('link-ttl');
const linkReason = document.getElementById('link-reason');
const linkDirectionSummary = document.getElementById('link-direction-summary');
const linkModalError = document.getElementById('link-modal-error');
const linkModalSubmitBtn = document.getElementById('link-modal-submit-btn');
// Login Modal Elements
const loginModal = document.getElementById('login-modal');
@ -41,20 +67,18 @@
const loginSubmitBtn = document.getElementById('login-submit-btn');
const loginError = document.getElementById('login-error');
// Result Displays & Copy Buttons
const caFingerprintDisplay = document.getElementById('ca-fingerprint-display');
const enrollmentCodeDisplay = document.getElementById('enrollment-code-display');
const copyFingerprintBtn = document.getElementById('copy-fingerprint-btn');
const copyCodeBtn = document.getElementById('copy-code-btn');
const countdownTimer = document.getElementById('countdown-timer');
// State
let countdownInterval = null;
let autoRefreshInterval = null;
let isModalOpen = false;
let isPasswordLoginEnabled = false;
let currentLinkCreationIdempotencyKey = null;
let knownNodes = [];
let eventsList = [];
let eventStreamAbortController = null;
const TOKEN_KEY = 'smm_session_token';
const MAX_EVENTS_IN_JOURNAL = 100;
// Initialize
document.addEventListener('DOMContentLoaded', async () => {
@ -62,6 +86,7 @@
await checkAuthStatus();
await loadDashboardData();
startAutoRefresh();
startEventStream();
});
function bindEvents() {
@ -69,18 +94,47 @@
loadDashboardData(true);
});
// Add Node modal
addNodeBtn.addEventListener('click', openAddNodeModal);
modalCloseBtn.addEventListener('click', closeAddNodeModal);
modalCancelBtn.addEventListener('click', closeAddNodeModal);
resultDoneBtn.addEventListener('click', closeAddNodeModal);
modalBackdrop.addEventListener('click', (e) => {
if (e.target === modalBackdrop) {
addNodeModal.addEventListener('click', (e) => {
if (e.target === addNodeModal) {
closeAddNodeModal();
}
});
enrollmentForm.addEventListener('submit', handleEnrollmentSubmit);
// Create Link modal
if (createLinkBtn) {
createLinkBtn.addEventListener('click', openCreateLinkModal);
}
if (headerCreateLinkBtn) {
headerCreateLinkBtn.addEventListener('click', openCreateLinkModal);
}
linkModalCloseBtn.addEventListener('click', closeCreateLinkModal);
linkModalCancelBtn.addEventListener('click', closeCreateLinkModal);
createLinkModal.addEventListener('click', (e) => {
if (e.target === createLinkModal) {
closeCreateLinkModal();
}
});
createLinkForm.addEventListener('submit', handleCreateLinkSubmit);
// Live update of Link direction summary
const linkInputElements = [linkSourceNode, linkTargetNode, linkProtocol, linkPort, linkTtl];
linkInputElements.forEach(el => {
if (el) {
el.addEventListener('input', updateLinkDirectionSummary);
el.addEventListener('change', updateLinkDirectionSummary);
}
});
// Links table action (Emergency Disable button delegation)
linksTableBody.addEventListener('click', handleLinksTableClick);
// Login & Logout
if (loginForm) {
loginForm.addEventListener('submit', handleLoginSubmit);
}
@ -88,6 +142,7 @@
logoutBtn.addEventListener('click', handleLogout);
}
// Copy buttons
copyCodeBtn.addEventListener('click', () => {
copyToClipboard(enrollmentCodeDisplay.value, copyCodeBtn, 'Скопировать код');
});
@ -96,13 +151,26 @@
copyToClipboard(caFingerprintDisplay.textContent, copyFingerprintBtn, 'Копировать отпечаток');
});
// Global keyboard handling
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && isModalOpen) {
closeAddNodeModal();
if (!addNodeModal.classList.contains('hidden')) closeAddNodeModal();
if (!createLinkModal.classList.contains('hidden')) closeCreateLinkModal();
}
});
}
function generateUuid() {
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
return crypto.randomUUID();
}
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
async function checkAuthStatus() {
try {
const res = await fetch('/api/v1/auth/status');
@ -184,6 +252,7 @@
const agents = await agentsRes.json();
const links = await linksRes.json();
updateKnownNodes(agents);
renderNodes(agents);
renderLinks(links);
hideGlobalAlert();
@ -209,6 +278,567 @@
}
}
function updateKnownNodes(agents) {
const list = Array.isArray(agents) ? agents : [];
knownNodes = list.map(a => a.nodeId || a.name).filter(Boolean);
if (nodesDatalist) {
nodesDatalist.innerHTML = knownNodes.map(nodeId =>
`<option value="${escapeHtml(nodeId)}"></option>`
).join('');
}
}
function renderNodes(agents) {
const list = Array.isArray(agents) ? agents : [];
nodesCountBadge.textContent = list.length;
if (list.length === 0) {
nodesTableBody.innerHTML = '';
nodesEmpty.classList.remove('hidden');
return;
}
nodesEmpty.classList.add('hidden');
nodesTableBody.innerHTML = list.map(agent => {
const statusClass = (agent.status || 'unknown').toLowerCase();
const lastSeen = formatTimestamp(agent.lastSeenAt);
const certInfo = formatCertInfo(agent);
return `
<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 isMismatch = (link.desiredState || '').toLowerCase() !== (link.actualState || '').toLowerCase();
const expiresFormatted = formatExpiration(link.expiresAt);
const isDisabled = (link.desiredState || '').toLowerCase() === 'disabled';
const errorHtml = link.lastError
? `<div class="link-error-msg" title="${escapeHtml(link.lastError)}">⚠️ ${escapeHtml(link.lastError)}</div>`
: '';
const mismatchHtml = isMismatch
? `<span class="state-mismatch-badge" title="Желаемое и фактическое состояния не согласованы">⚠️ Не синхронизировано</span>`
: '';
const actionHtml = !isDisabled
? `<button type="button" class="btn btn-sm btn-danger btn-disable-link"
data-link-id="${escapeHtml(link.id)}"
data-source="${escapeHtml(link.sourceNodeId)}"
data-target="${escapeHtml(link.targetNodeId)}"
data-protocol="${escapeHtml(link.protocol)}"
data-port="${escapeHtml(String(link.port))}"
title="Мгновенное аварийное отключение доступа">
Отключить
</button>`
: `<span class="text-muted">Отключено</span>`;
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').toUpperCase())}/${escapeHtml(String(link.port || 0))}</td>
<td>
<div class="state-cell">
<span class="status-badge ${escapeHtml(desiredClass)}">${escapeHtml(link.desiredState || '—')}</span>
</div>
</td>
<td>
<div class="state-cell">
<span class="status-badge ${escapeHtml(actualClass)}">${escapeHtml(link.actualState || '—')}</span>
${mismatchHtml}
${errorHtml}
</div>
</td>
<td>${escapeHtml(link.reason || '—')}</td>
<td>${expiresFormatted}</td>
<td>${actionHtml}</td>
</tr>
`;
}).join('');
}
// Real-time Events Stream
async function startEventStream() {
if (eventStreamAbortController) {
eventStreamAbortController.abort();
}
eventStreamAbortController = new AbortController();
const signal = eventStreamAbortController.signal;
try {
const res = await authFetch('/api/v1/control/events', { signal });
if (!res.ok || !res.body) {
return;
}
const reader = res.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
// Clear loading state
if (eventsList.length === 0) {
eventsTableBody.innerHTML = '';
eventsEmpty.classList.remove('hidden');
}
while (!signal.aborted) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop(); // Keep partial line in buffer
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const ev = JSON.parse(trimmed);
addControlEvent(ev);
} catch (e) {
console.debug('Failed to parse NDJSON event line:', e);
}
}
}
} catch (err) {
if (signal.aborted) return;
console.debug('Event stream disconnected:', err);
} finally {
if (!signal.aborted) {
setTimeout(startEventStream, 5000);
}
}
}
function addControlEvent(ev) {
eventsList.unshift(ev);
if (eventsList.length > MAX_EVENTS_IN_JOURNAL) {
eventsList.pop();
}
renderEvents();
// Trigger dashboard refresh if topology changed
const eventType = (ev.type || '').toLowerCase();
if (eventType.startsWith('link.') || eventType.startsWith('agent.')) {
loadDashboardData(false);
}
}
function renderEvents() {
eventsCountBadge.textContent = eventsList.length;
if (eventsList.length === 0) {
eventsTableBody.innerHTML = '';
eventsEmpty.classList.remove('hidden');
return;
}
eventsEmpty.classList.add('hidden');
eventsTableBody.innerHTML = eventsList.map(ev => {
const timeStr = formatTimestamp(ev.recordedAt);
let payloadSummary = '—';
if (ev.payloadJson) {
try {
const parsed = JSON.parse(ev.payloadJson);
payloadSummary = typeof parsed === 'object'
? JSON.stringify(parsed)
: String(parsed);
} catch {
payloadSummary = ev.payloadJson;
}
}
return `
<tr>
<td>${timeStr}</td>
<td><span class="event-type-badge">${escapeHtml(ev.type || 'unknown')}</span></td>
<td class="node-name-cell">${escapeHtml(ev.subject || '—')}</td>
<td><div class="event-payload-box" title="${escapeHtml(payloadSummary)}">${escapeHtml(payloadSummary)}</div></td>
</tr>
`;
}).join('');
}
// Link Creation Modal & Form Handling
function openCreateLinkModal() {
isModalOpen = true;
currentLinkCreationIdempotencyKey = generateUuid();
createLinkForm.reset();
linkProtocol.value = 'tcp';
linkPort.value = '8080';
linkTtl.value = '60';
hideLinkModalError();
updateLinkDirectionSummary();
createLinkModal.classList.remove('hidden');
linkSourceNode.focus();
}
function closeCreateLinkModal() {
isModalOpen = false;
createLinkModal.classList.add('hidden');
hideLinkModalError();
}
function updateLinkDirectionSummary() {
const src = (linkSourceNode.value || '').trim();
const dst = (linkTargetNode.value || '').trim();
const proto = (linkProtocol.value || 'TCP').toUpperCase();
const port = (linkPort.value || '').trim();
const ttl = (linkTtl.value || '').trim();
if (!src && !dst) {
linkDirectionSummary.className = 'direction-summary-text';
linkDirectionSummary.innerHTML = 'Укажите узел-источник и узел-назначение для проверки направления связи.';
return;
}
if (src && dst && src === dst) {
linkDirectionSummary.className = 'direction-summary-text warning';
linkDirectionSummary.innerHTML = `⚠️ <strong>Внимание:</strong> узел-источник и назначение совпадают (<code>${escapeHtml(src)}</code>). Правило должно соединять два разных узла!`;
return;
}
const srcDisplay = src ? `«<strong>${escapeHtml(src)}</strong>»` : '<span class="text-muted">[источник]</span>';
const dstDisplay = dst ? `«<strong>${escapeHtml(dst)}</strong>»` : '<span class="text-muted">[назначение]</span>';
const portDisplay = port ? `порт <strong>${escapeHtml(port)}</strong>` : '<span class="text-muted">[порт]</span>';
const ttlDisplay = ttl ? `на <strong>${escapeHtml(ttl)} мин.</strong>` : '<span class="text-muted">[срок]</span>';
linkDirectionSummary.className = 'direction-summary-text';
linkDirectionSummary.innerHTML = `Открытие доступа: с узла ${srcDisplay} к узлу ${dstDisplay}, протокол <strong>${escapeHtml(proto)}</strong>, ${portDisplay}, ${ttlDisplay}.`;
}
async function handleCreateLinkSubmit(e) {
e.preventDefault();
const sourceNodeId = (linkSourceNode.value || '').trim();
const targetNodeId = (linkTargetNode.value || '').trim();
const protocol = (linkProtocol.value || 'tcp').trim().toLowerCase();
const portStr = (linkPort.value || '').trim();
const ttlStr = (linkTtl.value || '').trim();
const reason = (linkReason.value || '').trim();
if (!sourceNodeId || !/^[a-z0-9-]{1,63}$/.test(sourceNodeId)) {
showLinkModalError('Укажите корректный Source Node ID (1-63 латинских букв, цифр или дефисов).');
linkSourceNode.focus();
return;
}
if (!targetNodeId || !/^[a-z0-9-]{1,63}$/.test(targetNodeId)) {
showLinkModalError('Укажите корректный Target Node ID (1-63 латинских букв, цифр или дефисов).');
linkTargetNode.focus();
return;
}
if (sourceNodeId === targetNodeId) {
showLinkModalError('Узел-источник и узел-назначение не могут совпадать.');
linkTargetNode.focus();
return;
}
const port = parseInt(portStr, 10);
if (isNaN(port) || port < 1 || port > 65535) {
showLinkModalError('Порт должен быть целым числом в диапазоне от 1 до 65535.');
linkPort.focus();
return;
}
const ttlMinutes = parseInt(ttlStr, 10);
if (isNaN(ttlMinutes) || ttlMinutes < 0 || ttlMinutes > 525600) {
showLinkModalError('Срок действия (TTL) должен быть числом минут от 0 до 525600.');
linkTtl.focus();
return;
}
if (!reason || reason.length === 0) {
showLinkModalError('Причина открытия доступа (Reason) обязательна для аудита безопасности.');
linkReason.focus();
return;
}
if (reason.length > 256) {
showLinkModalError('Причина не должна превышать 256 символов.');
linkReason.focus();
return;
}
if (!currentLinkCreationIdempotencyKey) {
currentLinkCreationIdempotencyKey = generateUuid();
}
hideLinkModalError();
linkModalSubmitBtn.disabled = true;
linkModalSubmitBtn.querySelector('span').textContent = 'Создание...';
const payload = {
sourceNodeId,
targetNodeId,
protocol,
port,
ttlMinutes,
reason,
idempotencyKey: currentLinkCreationIdempotencyKey
};
try {
const res = await authFetch('/api/v1/control/links', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
});
if (res.status === 401) {
showLinkModalError('Требуется авторизация роли Operator.');
return;
}
if (res.status === 403) {
showLinkModalError('Доступ запрещен: роль не является Operator.');
return;
}
if (!res.ok) {
let errorMsg = `Ошибка создания Link (код ${res.status})`;
try {
const errorData = await res.json();
if (errorData?.detail) {
errorMsg = errorData.detail;
} else if (errorData?.title) {
errorMsg = errorData.title;
} else if (errorData?.errors) {
errorMsg = Object.values(errorData.errors).flat().join(' ');
}
} catch {
const text = await res.text();
if (text) errorMsg = text;
}
showLinkModalError(errorMsg);
return;
}
// Success
currentLinkCreationIdempotencyKey = null;
closeCreateLinkModal();
await loadDashboardData(true);
showGlobalAlert(`Сетевая связь ${sourceNodeId}${targetNodeId}:${port} успешно создана.`, 'success');
} catch (err) {
console.error('Link creation error:', err);
showLinkModalError(`Сетевая ошибка: ${err.message}`);
} finally {
linkModalSubmitBtn.disabled = false;
linkModalSubmitBtn.querySelector('span').textContent = 'Подтвердить и создать Link';
}
}
function showLinkModalError(msg) {
linkModalError.textContent = msg;
linkModalError.classList.remove('hidden');
}
function hideLinkModalError() {
linkModalError.textContent = '';
linkModalError.classList.add('hidden');
}
// Emergency Link Disabling
async function handleLinksTableClick(e) {
const disableBtn = e.target.closest('.btn-disable-link');
if (!disableBtn) return;
const linkId = disableBtn.getAttribute('data-link-id');
const source = disableBtn.getAttribute('data-source');
const target = disableBtn.getAttribute('data-target');
const protocol = (disableBtn.getAttribute('data-protocol') || 'TCP').toUpperCase();
const port = disableBtn.getAttribute('data-port');
if (!linkId) return;
const confirmed = window.confirm(
`АВАРИЙНОЕ ОТКЛЮЧЕНИЕ СВЯЗИ:\n\nВы уверены, что хотите немедленно отозвать сетевой доступ с узла "${source}" к узлу "${target}" (${protocol}/${port})?`
);
if (!confirmed) return;
disableBtn.disabled = true;
disableBtn.textContent = 'Отключение...';
const disablePayload = {
idempotencyKey: generateUuid()
};
try {
const res = await authFetch(`/api/v1/control/links/${encodeURIComponent(linkId)}/disable`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(disablePayload)
});
if (res.status === 401) {
showGlobalAlert('Требуется авторизация роли Operator для отключения связи.', 'error');
return;
}
if (res.status === 403) {
showGlobalAlert('Доступ запрещен: роль не является Operator.', 'error');
return;
}
if (!res.ok) {
let errorMsg = `Ошибка при отключении Link (код ${res.status})`;
try {
const errorData = await res.json();
if (errorData?.detail) errorMsg = errorData.detail;
else if (errorData?.title) errorMsg = errorData.title;
} catch {
const text = await res.text();
if (text) errorMsg = text;
}
showGlobalAlert(errorMsg, 'error');
return;
}
await loadDashboardData(true);
showGlobalAlert(`Сетевая связь ${source}${target}:${port} успешно отключена.`, 'success');
} catch (err) {
console.error('Disable link error:', err);
showGlobalAlert(`Сетевая ошибка при отключении Link: ${err.message}`, 'error');
} finally {
disableBtn.disabled = false;
disableBtn.textContent = 'Отключить';
}
}
// Add Node Modal & Code Generation
async function handleEnrollmentSubmit(e) {
e.preventDefault();
const nodeId = (nodeIdInput.value || '').trim();
if (!nodeId || !/^[a-z0-9-]{1,63}$/.test(nodeId)) {
showModalError('Имя узла должно содержать от 1 до 63 строчных латинских букв, цифр или дефисов.');
return;
}
hideModalError();
generateCodeBtn.disabled = true;
generateCodeBtn.textContent = 'Генерация...';
try {
const res = await authFetch(`/api/v1/control/agents/${encodeURIComponent(nodeId)}/enrollment-code`, {
method: 'POST'
});
if (res.status === 401) {
showModalError('Требуется авторизация роли Operator.');
return;
}
if (res.status === 403) {
showModalError('Доступ запрещен: роль не является Operator.');
return;
}
if (res.status === 429) {
showModalError('Превышен лимит запросов на выпуск кодов (10/мин). Подождите перед повторной попыткой.');
return;
}
const data = await res.json();
if (!res.ok) {
const errorDetail = data?.detail || data?.title || 'Ошибка выпуска кода регистрации';
showModalError(errorDetail);
return;
}
displayEnrollmentResult(data);
} catch (err) {
console.error('Enrollment code creation error:', err);
showModalError(`Сетевая ошибка: ${err.message}`);
} finally {
generateCodeBtn.disabled = false;
generateCodeBtn.textContent = 'Сгенерировать код';
}
}
function displayEnrollmentResult(data) {
enrollmentForm.classList.add('hidden');
enrollmentResult.classList.remove('hidden');
caFingerprintDisplay.textContent = data.certificateAuthorityFingerprintSha256 || '—';
enrollmentCodeDisplay.value = data.code || '';
// Start 10-minute countdown
const expiresAt = data.expiresAt ? new Date(data.expiresAt) : new Date(Date.now() + 10 * 60 * 1000);
startCountdown(expiresAt);
}
function startCountdown(targetDate) {
if (countdownInterval) clearInterval(countdownInterval);
function update() {
const now = new Date();
const remainingMs = targetDate.getTime() - now.getTime();
if (remainingMs <= 0) {
countdownTimer.textContent = '00:00 (истёк)';
countdownTimer.style.backgroundColor = 'var(--danger-bg)';
countdownTimer.style.color = 'var(--danger-text)';
clearInterval(countdownInterval);
return;
}
const totalSec = Math.floor(remainingMs / 1000);
const min = Math.floor(totalSec / 60);
const sec = totalSec % 60;
countdownTimer.textContent = `${String(min).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
}
update();
countdownInterval = setInterval(update, 1000);
}
function openAddNodeModal() {
isModalOpen = true;
enrollmentForm.reset();
enrollmentForm.classList.remove('hidden');
enrollmentResult.classList.add('hidden');
hideModalError();
addNodeModal.classList.remove('hidden');
nodeIdInput.focus();
}
function closeAddNodeModal() {
isModalOpen = false;
addNodeModal.classList.add('hidden');
if (countdownInterval) {
clearInterval(countdownInterval);
countdownInterval = null;
}
loadDashboardData();
}
// Password Login Modal
function showLoginModal() {
if (loginModal) {
loginModal.classList.remove('hidden');
@ -306,169 +936,7 @@
}
}
function renderNodes(agents) {
const list = Array.isArray(agents) ? agents : [];
nodesCountBadge.textContent = list.length;
if (list.length === 0) {
nodesTableBody.innerHTML = '';
nodesEmpty.classList.remove('hidden');
return;
}
nodesEmpty.classList.add('hidden');
nodesTableBody.innerHTML = list.map(agent => {
const statusClass = (agent.status || 'unknown').toLowerCase();
const lastSeen = formatTimestamp(agent.lastSeenAt);
const certInfo = formatCertInfo(agent);
return `
<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 authFetch(`/api/v1/control/agents/${encodeURIComponent(nodeId)}/enrollment-code`, {
method: 'POST'
});
if (res.status === 401) {
showModalError('Требуется авторизация роли Operator.');
return;
}
if (res.status === 403) {
showModalError('Доступ запрещен: роль не является Operator.');
return;
}
if (res.status === 429) {
showModalError('Превышен лимит запросов на выпуск кодов (10/мин). Подождите перед повторной попыткой.');
return;
}
const data = await res.json();
if (!res.ok) {
const errorDetail = data?.detail || data?.title || 'Ошибка выпуска кода регистрации';
showModalError(errorDetail);
return;
}
displayEnrollmentResult(data);
} catch (err) {
console.error('Enrollment code creation error:', err);
showModalError(`Сетевая ошибка: ${err.message}`);
} finally {
generateCodeBtn.disabled = false;
generateCodeBtn.textContent = 'Сгенерировать код';
}
}
function displayEnrollmentResult(data) {
enrollmentForm.classList.add('hidden');
enrollmentResult.classList.remove('hidden');
caFingerprintDisplay.textContent = data.certificateAuthorityFingerprintSha256 || '—';
enrollmentCodeDisplay.value = data.code || '';
// Start 10-minute countdown
const expiresAt = data.expiresAt ? new Date(data.expiresAt) : new Date(Date.now() + 10 * 60 * 1000);
startCountdown(expiresAt);
}
function startCountdown(targetDate) {
if (countdownInterval) clearInterval(countdownInterval);
function update() {
const now = new Date();
const remainingMs = targetDate.getTime() - now.getTime();
if (remainingMs <= 0) {
countdownTimer.textContent = '00:00 (истёк)';
countdownTimer.style.backgroundColor = 'var(--danger-bg)';
countdownTimer.style.color = 'var(--danger-text)';
clearInterval(countdownInterval);
return;
}
const totalSec = Math.floor(remainingMs / 1000);
const min = Math.floor(totalSec / 60);
const sec = totalSec % 60;
countdownTimer.textContent = `${String(min).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
}
update();
countdownInterval = setInterval(update, 1000);
}
function openAddNodeModal() {
isModalOpen = true;
enrollmentForm.reset();
enrollmentForm.classList.remove('hidden');
enrollmentResult.classList.add('hidden');
hideModalError();
modalBackdrop.classList.remove('hidden');
nodeIdInput.focus();
}
function closeAddNodeModal() {
isModalOpen = false;
modalBackdrop.classList.add('hidden');
if (countdownInterval) {
clearInterval(countdownInterval);
countdownInterval = null;
}
loadDashboardData();
}
// Utilities
async function copyToClipboard(text, btnElement, defaultLabel) {
if (!text) return;
try {
@ -536,6 +1004,30 @@
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
function formatExpiration(isoString) {
if (!isoString) return '<span class="text-muted">Бессрочно</span>';
const date = new Date(isoString);
if (isNaN(date.getTime())) return '<span class="text-muted">—</span>';
const remainingSec = Math.floor((date.getTime() - Date.now()) / 1000);
const dateStr = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
if (remainingSec <= 0) {
return `<span class="text-danger" title="${date.toLocaleString()}">Истёк (${dateStr})</span>`;
}
if (remainingSec < 60) {
return `<span class="text-warning">через ${remainingSec} сек.</span>`;
}
if (remainingSec < 3600) {
return `через ${Math.floor(remainingSec / 60)} мин. (${dateStr})`;
}
if (remainingSec < 86400) {
return `через ${Math.floor(remainingSec / 3600)} ч. (${dateStr})`;
}
return `${date.toLocaleDateString()} ${dateStr}`;
}
function formatCertInfo(agent) {
if (typeof agent.certificateRemainingDays === 'number') {
const days = agent.certificateRemainingDays;

View file

@ -25,10 +25,14 @@
<span class="btn-icon"></span>
<span>Обновить</span>
</button>
<button id="add-node-btn" class="btn btn-primary">
<button id="add-node-btn" class="btn btn-secondary">
<span class="btn-icon">+</span>
<span>Добавить узел</span>
</button>
<button id="header-create-link-btn" class="btn btn-primary">
<span class="btn-icon">🔗</span>
<span>Создать Link</span>
</button>
<button id="logout-btn" class="btn btn-secondary hidden" title="Выйти из сессии">
<span class="btn-icon">🚪</span>
<span>Выйти</span>
@ -86,7 +90,13 @@
<h2>Сетевые связи (Links)</h2>
<span id="links-count" class="counter-badge">0</span>
</div>
<span class="section-hint">Политики взаимодействия между узлами mesh-сети</span>
<div class="section-actions">
<span class="section-hint">Политики взаимодействия между узлами mesh-сети</span>
<button id="create-link-btn" class="btn btn-primary btn-sm">
<span class="btn-icon">+</span>
<span>Создать Link</span>
</button>
</div>
</div>
<div class="table-container">
@ -97,21 +107,57 @@
<th>Источник</th>
<th>Назначение</th>
<th>Протокол / Порт</th>
<th>Требуемое состояние</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>
<td colspan="9">Загрузка сетевых связей...</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>
<p class="empty-state-subtext">Сетевые политики взаимодействия между узлами ещё не созданы. Нажмите «Создать Link» для добавления нового правила.</p>
</div>
</div>
</section>
<!-- Раздел: Журнал событий (Events Journal) -->
<section class="dashboard-section" id="events-section">
<div class="section-header">
<div class="section-title-group">
<h2>Журнал событий</h2>
<span id="events-count" class="counter-badge">0</span>
</div>
<span class="section-hint">Поток системных событий в реальном времени (агенты, сетевые связи, аудит)</span>
</div>
<div class="table-container">
<table class="data-table" id="events-table">
<thead>
<tr>
<th style="width: 170px;">Время</th>
<th style="width: 180px;">Тип события</th>
<th style="width: 180px;">Субъект</th>
<th>Подробности</th>
</tr>
</thead>
<tbody id="events-table-body">
<tr class="loading-row">
<td colspan="4">Ожидание потока событий...</td>
</tr>
</tbody>
</table>
<div id="events-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>
@ -127,6 +173,8 @@
<span id="last-updated-text">Синхронизировано</span>
</div>
</footer>
</div>
<!-- Модальное окно: Вход по логину и паролю (для тестирования) -->
<div id="login-modal" class="modal-backdrop hidden" role="dialog" aria-modal="true" aria-labelledby="login-modal-title">
<div class="modal-window login-modal-window">
@ -258,6 +306,125 @@
</div>
</div>
<!-- Модальное окно: Создание сетевой связи (Link) -->
<div id="create-link-modal" class="modal-backdrop hidden" role="dialog" aria-modal="true" aria-labelledby="create-link-title">
<div class="modal-window create-link-modal-window">
<div class="modal-header">
<h3 id="create-link-title">Создание сетевой связи (Link)</h3>
<button id="link-modal-close-btn" class="modal-close" aria-label="Закрыть">×</button>
</div>
<div class="modal-body">
<datalist id="nodes-datalist"></datalist>
<form id="create-link-form">
<div class="form-row-2">
<div class="form-group">
<label for="link-source-node">Узел-источник (Source Node ID):</label>
<input
type="text"
id="link-source-node"
class="form-control"
placeholder="например: web-prod-01"
list="nodes-datalist"
maxlength="63"
pattern="^[a-z0-9-]{1,63}$"
required
autocomplete="off"
spellcheck="false">
<span class="form-hint">Откуда разрешается исходящий трафик</span>
</div>
<div class="form-group">
<label for="link-target-node">Узел-назначение (Target Node ID):</label>
<input
type="text"
id="link-target-node"
class="form-control"
placeholder="например: db-prod-01"
list="nodes-datalist"
maxlength="63"
pattern="^[a-z0-9-]{1,63}$"
required
autocomplete="off"
spellcheck="false">
<span class="form-hint">Куда разрешается входящий трафик</span>
</div>
</div>
<div class="form-row-3">
<div class="form-group">
<label for="link-protocol">Протокол:</label>
<select id="link-protocol" class="form-control form-select">
<option value="tcp" selected>TCP</option>
<option value="udp">UDP</option>
</select>
</div>
<div class="form-group">
<label for="link-port">Порт назначения:</label>
<input
type="number"
id="link-port"
class="form-control"
placeholder="8080"
min="1"
max="65535"
required>
</div>
<div class="form-group">
<label for="link-ttl">Срок действия (мин):</label>
<input
type="number"
id="link-ttl"
class="form-control"
value="60"
min="1"
max="525600"
required>
</div>
</div>
<div class="form-group">
<label for="link-reason">Причина открытия доступа (Reason):</label>
<input
type="text"
id="link-reason"
class="form-control"
placeholder="например: Доступ веб-сервера к реплике PostgreSQL для отчётов"
maxlength="256"
required
autocomplete="off">
<span class="form-hint">Обязательное поле: фиксируется в журнале аудита безопасности</span>
</div>
<!-- Явное подтверждение направления сетевого доступа -->
<div class="link-direction-card" id="link-direction-card" role="region" aria-label="Подтверждение направления связи">
<div class="direction-card-header">
<span class="direction-icon">🛡️</span>
<strong>Подтверждение направления правила доступа:</strong>
</div>
<div class="direction-card-body">
<div id="link-direction-summary" class="direction-summary-text">
Укажите узел-источник и узел-назначение для проверки направления связи.
</div>
</div>
</div>
<div id="link-modal-error" class="alert alert-error hidden" role="alert"></div>
<div class="form-actions">
<button type="button" id="link-modal-cancel-btn" class="btn btn-secondary">Отмена</button>
<button type="submit" id="link-modal-submit-btn" class="btn btn-primary">
<span>Подтвердить и создать Link</span>
</button>
</div>
</form>
</div>
</div>
</div>
<script src="/app.js"></script>
</body>
</html>

View file

@ -97,6 +97,7 @@ body {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.status-indicator {
@ -165,6 +166,17 @@ body {
background-color: #475569;
}
.btn-danger {
background-color: var(--danger-bg);
color: var(--danger-text);
border-color: var(--danger-border);
}
.btn-danger:hover {
background-color: #ef4444;
color: #ffffff;
}
.btn-outline {
background-color: transparent;
color: var(--text-primary);
@ -209,8 +221,10 @@ body {
padding: 1.25rem 1.5rem;
border-bottom: 1px solid var(--border-color);
display: flex;
flex-direction: column;
gap: 0.25rem;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 0.75rem;
}
.section-title-group {
@ -225,6 +239,12 @@ body {
color: var(--text-primary);
}
.section-actions {
display: flex;
align-items: center;
gap: 1rem;
}
.counter-badge {
background-color: var(--bg-tertiary);
color: var(--text-secondary);
@ -319,7 +339,8 @@ body {
}
.status-badge.connecting,
.status-badge.pending {
.status-badge.pending,
.status-badge.applying {
background-color: var(--warning-bg);
color: var(--warning-text);
border: 1px solid var(--warning-border);
@ -334,6 +355,59 @@ body {
border: 1px solid var(--danger-border);
}
.state-cell {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.state-mismatch-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.725rem;
font-weight: 600;
color: #fbbf24;
background-color: rgba(245, 158, 11, 0.15);
border: 1px dashed #d97706;
padding: 0.15rem 0.4rem;
border-radius: 4px;
}
.link-error-msg {
font-size: 0.775rem;
color: #f87171;
background-color: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.25);
padding: 0.25rem 0.5rem;
border-radius: 4px;
margin-top: 0.35rem;
max-width: 280px;
word-break: break-word;
}
/* Event Journal */
.event-type-badge {
font-family: var(--font-mono);
font-size: 0.775rem;
font-weight: 600;
padding: 0.2rem 0.5rem;
border-radius: 4px;
background-color: var(--bg-tertiary);
color: #93c5fd;
display: inline-block;
}
.event-payload-box {
font-family: var(--font-mono);
font-size: 0.8rem;
color: var(--text-secondary);
max-width: 500px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Empty State */
.empty-state {
display: flex;
@ -411,6 +485,10 @@ body {
animation: modalEnter 0.15s ease-out;
}
.create-link-modal-window {
max-width: 680px;
}
@keyframes modalEnter {
from {
opacity: 0;
@ -460,6 +538,18 @@ body {
}
/* Forms */
.form-row-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.form-row-3 {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 1rem;
}
.form-group {
display: flex;
flex-direction: column;
@ -482,6 +572,12 @@ body {
color: var(--text-primary);
outline: none;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
width: 100%;
}
.form-select {
font-family: var(--font-sans);
cursor: pointer;
}
.form-control:focus {
@ -498,7 +594,47 @@ body {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
margin-top: 1rem;
margin-top: 0.5rem;
}
/* Direction Confirmation Card */
.link-direction-card {
background-color: #0b2246;
border: 2px solid #2563eb;
border-radius: 8px;
padding: 1rem 1.25rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.direction-card-header {
display: flex;
align-items: center;
gap: 0.5rem;
color: #93c5fd;
font-size: 0.85rem;
font-weight: 700;
}
.direction-summary-text {
font-size: 0.9rem;
color: #ffffff;
line-height: 1.45;
background-color: rgba(15, 23, 42, 0.6);
padding: 0.65rem 0.85rem;
border-radius: 6px;
border: 1px solid rgba(59, 130, 246, 0.3);
}
.direction-summary-text strong {
color: #60a5fa;
}
.direction-summary-text.warning {
color: #fbbf24;
border-color: #f59e0b;
background-color: rgba(69, 26, 3, 0.4);
}
/* Enrollment Code Result */
@ -670,6 +806,14 @@ body {
display: none !important;
}
.text-muted {
color: var(--text-muted);
}
.text-danger {
color: var(--danger-text);
}
/* Responsive */
@media (max-width: 768px) {
.app-header {
@ -688,4 +832,8 @@ body {
padding: 0.5rem 1rem;
font-size: 0.8rem;
}
.form-row-2,
.form-row-3 {
grid-template-columns: 1fr;
}
}

View file

@ -103,19 +103,34 @@ public sealed class WebConsoleTests : IAsyncDisposable
// Header & Actions
Assert.Contains("id=\"add-node-btn\"", html, StringComparison.Ordinal);
Assert.Contains("id=\"create-link-btn\"", html, StringComparison.Ordinal);
Assert.Contains("id=\"refresh-btn\"", html, StringComparison.Ordinal);
// Tables & Counts
Assert.Contains("id=\"nodes-table\"", html, StringComparison.Ordinal);
Assert.Contains("id=\"links-table\"", html, StringComparison.Ordinal);
Assert.Contains("id=\"events-table\"", html, StringComparison.Ordinal);
Assert.Contains("id=\"nodes-count\"", html, StringComparison.Ordinal);
Assert.Contains("id=\"links-count\"", html, StringComparison.Ordinal);
Assert.Contains("id=\"events-count\"", html, StringComparison.Ordinal);
// Modal & Form
// Add Node Modal & Form
Assert.Contains("id=\"add-node-modal\"", html, StringComparison.Ordinal);
Assert.Contains("id=\"node-id-input\"", html, StringComparison.Ordinal);
Assert.Contains("id=\"generate-code-btn\"", html, StringComparison.Ordinal);
// Create Link Modal & Form
Assert.Contains("id=\"create-link-modal\"", html, StringComparison.Ordinal);
Assert.Contains("id=\"create-link-form\"", html, StringComparison.Ordinal);
Assert.Contains("id=\"link-source-node\"", html, StringComparison.Ordinal);
Assert.Contains("id=\"link-target-node\"", html, StringComparison.Ordinal);
Assert.Contains("id=\"link-protocol\"", html, StringComparison.Ordinal);
Assert.Contains("id=\"link-port\"", html, StringComparison.Ordinal);
Assert.Contains("id=\"link-ttl\"", html, StringComparison.Ordinal);
Assert.Contains("id=\"link-reason\"", html, StringComparison.Ordinal);
Assert.Contains("id=\"link-direction-summary\"", html, StringComparison.Ordinal);
Assert.Contains("id=\"link-modal-submit-btn\"", html, StringComparison.Ordinal);
// Result displays
Assert.Contains("id=\"ca-fingerprint-display\"", html, StringComparison.Ordinal);
Assert.Contains("id=\"enrollment-code-display\"", html, StringComparison.Ordinal);
@ -128,6 +143,34 @@ public sealed class WebConsoleTests : IAsyncDisposable
Assert.Contains("отпечаток", html, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task WebConsoleProvidesLinkManagementAndEventsJournal()
{
using var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-Test-Identity", "operator-admin");
client.DefaultRequestHeaders.Add("X-Test-Role", "Operator");
var response = await client.GetAsync("/", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var html = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
// Desired & Actual state separation in table headers
Assert.Contains("Желаемое", html, StringComparison.Ordinal);
Assert.Contains("Фактическое", html, StringComparison.Ordinal);
Assert.Contains("Причина", html, StringComparison.Ordinal);
Assert.Contains("Срок действия", html, StringComparison.Ordinal);
Assert.Contains("Действия", html, StringComparison.Ordinal);
// Directional confirmation text in modal
Assert.Contains("Подтверждение направления правила доступа", html, StringComparison.Ordinal);
Assert.Contains("id=\"link-direction-summary\"", html, StringComparison.Ordinal);
// Event journal section
Assert.Contains("id=\"events-section\"", html, StringComparison.Ordinal);
Assert.Contains("Журнал событий", html, StringComparison.Ordinal);
}
[Fact]
public async Task EmbeddedAssetsAreServedWhenDiskWebRootIsMissing()
{