Merge branch 'main' into dependabot/github_actions/actions/download-artifact-8.0.1
This commit is contained in:
commit
4886669508
4 changed files with 1038 additions and 188 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue