/** * SMM Operator Web Console */ (function () { 'use strict'; // 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'); // 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'); const nodeIdInput = document.getElementById('node-id-input'); const generateCodeBtn = document.getElementById('generate-code-btn'); const modalError = document.getElementById('modal-error'); const enrollmentResult = document.getElementById('enrollment-result'); const resultDoneBtn = document.getElementById('result-done-btn'); 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'); const loginForm = document.getElementById('login-form'); const loginUsername = document.getElementById('login-username'); const loginPassword = document.getElementById('login-password'); const loginSubmitBtn = document.getElementById('login-submit-btn'); const loginError = document.getElementById('login-error'); // 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 () => { bindEvents(); await checkAuthStatus(); await loadDashboardData(); startAutoRefresh(); startEventStream(); }); function bindEvents() { refreshBtn.addEventListener('click', () => { loadDashboardData(true); }); // Add Node modal addNodeBtn.addEventListener('click', openAddNodeModal); modalCloseBtn.addEventListener('click', closeAddNodeModal); modalCancelBtn.addEventListener('click', closeAddNodeModal); resultDoneBtn.addEventListener('click', closeAddNodeModal); 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); } if (logoutBtn) { logoutBtn.addEventListener('click', handleLogout); } // Copy buttons copyCodeBtn.addEventListener('click', () => { copyToClipboard(enrollmentCodeDisplay.value, copyCodeBtn, 'Скопировать код'); }); copyFingerprintBtn.addEventListener('click', () => { copyToClipboard(caFingerprintDisplay.textContent, copyFingerprintBtn, 'Копировать отпечаток'); }); // Global keyboard handling document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && isModalOpen) { 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'); if (res.ok) { const data = await res.json(); isPasswordLoginEnabled = !!data?.enabledForTesting; if (isPasswordLoginEnabled && testingBanner) { testingBanner.classList.remove('hidden'); } } } catch { // Ignored if status endpoint unavailable } } function getStoredToken() { return sessionStorage.getItem(TOKEN_KEY) || ''; } function setStoredToken(token) { if (token) { sessionStorage.setItem(TOKEN_KEY, token); } else { sessionStorage.removeItem(TOKEN_KEY); } } async function authFetch(url, options = {}) { const headers = options.headers ? { ...options.headers } : {}; const token = getStoredToken(); if (token && !headers['Authorization']) { headers['Authorization'] = `Bearer ${token}`; } if (!headers['Accept']) { headers['Accept'] = 'application/json'; } return await fetch(url, { ...options, headers }); } function startAutoRefresh() { if (autoRefreshInterval) clearInterval(autoRefreshInterval); autoRefreshInterval = setInterval(() => { if (!isModalOpen && (!loginModal || loginModal.classList.contains('hidden'))) { loadDashboardData(false); } }, 10000); } async function loadDashboardData(showLoadingIndicator = false) { if (showLoadingIndicator) { refreshBtn.disabled = true; refreshBtn.querySelector('.btn-icon').textContent = '⏳'; } try { const [agentsRes, linksRes] = await Promise.all([ authFetch('/api/v1/control/agents'), authFetch('/api/v1/control/links') ]); if (agentsRes.status === 401 || linksRes.status === 401) { if (isPasswordLoginEnabled && !getStoredToken()) { showLoginModal(); return; } showGlobalAlert('Ошибка аутентификации: требуется клиентский сертификат роли Operator или авторизация по паролю.', 'error'); return; } if (agentsRes.status === 403 || linksRes.status === 403) { showGlobalAlert('Доступ запрещён: пользователь или сертификат не имеет роли Operator.', 'error'); return; } if (!agentsRes.ok || !linksRes.ok) { throw new Error(`Ошибка загрузки данных (${agentsRes.status} / ${linksRes.status})`); } const agents = await agentsRes.json(); const links = await linksRes.json(); updateKnownNodes(agents); renderNodes(agents); renderLinks(links); hideGlobalAlert(); hideLoginModal(); if (getStoredToken() && logoutBtn) { logoutBtn.classList.remove('hidden'); if (connectionStatus) { connectionStatus.textContent = 'Пароль (Operator)'; } } const now = new Date(); lastUpdatedText.textContent = `Обновлено в ${now.toLocaleTimeString()}`; } catch (err) { console.error('Failed to load dashboard data:', err); showGlobalAlert(`Не удалось обновить данные: ${err.message}`, 'error'); } finally { if (showLoadingIndicator) { refreshBtn.disabled = false; refreshBtn.querySelector('.btn-icon').textContent = '↻'; } } } function updateKnownNodes(agents) { const list = Array.isArray(agents) ? agents : []; knownNodes = list.map(a => a.nodeId || a.name).filter(Boolean); if (nodesDatalist) { nodesDatalist.innerHTML = knownNodes.map(nodeId => `` ).join(''); } } function renderNodes(agents) { const list = Array.isArray(agents) ? agents : []; nodesCountBadge.textContent = list.length; if (list.length === 0) { nodesTableBody.innerHTML = ''; nodesEmpty.classList.remove('hidden'); return; } nodesEmpty.classList.add('hidden'); nodesTableBody.innerHTML = list.map(agent => { const statusClass = (agent.status || 'unknown').toLowerCase(); const lastSeen = formatTimestamp(agent.lastSeenAt); const certInfo = formatCertInfo(agent); return `
${escapeHtml(src)}). Правило должно соединять два разных узла!`;
return;
}
const srcDisplay = src ? `«${escapeHtml(src)}»` : '[источник]';
const dstDisplay = dst ? `«${escapeHtml(dst)}»` : '[назначение]';
const portDisplay = port ? `порт ${escapeHtml(port)}` : '[порт]';
const ttlDisplay = ttl ? `на ${escapeHtml(ttl)} мин.` : '[срок]';
linkDirectionSummary.className = 'direction-summary-text';
linkDirectionSummary.innerHTML = `Открытие доступа: с узла ${srcDisplay} к узлу ${dstDisplay}, протокол ${escapeHtml(proto)}, ${portDisplay}, ${ttlDisplay}.`;
}
async function handleCreateLinkSubmit(e) {
e.preventDefault();
const sourceNodeId = (linkSourceNode.value || '').trim();
const targetNodeId = (linkTargetNode.value || '').trim();
const protocol = (linkProtocol.value || 'tcp').trim().toLowerCase();
const portStr = (linkPort.value || '').trim();
const ttlStr = (linkTtl.value || '').trim();
const reason = (linkReason.value || '').trim();
if (!sourceNodeId || !/^[a-z0-9-]{1,63}$/.test(sourceNodeId)) {
showLinkModalError('Укажите корректный Source Node ID (1-63 латинских букв, цифр или дефисов).');
linkSourceNode.focus();
return;
}
if (!targetNodeId || !/^[a-z0-9-]{1,63}$/.test(targetNodeId)) {
showLinkModalError('Укажите корректный Target Node ID (1-63 латинских букв, цифр или дефисов).');
linkTargetNode.focus();
return;
}
if (sourceNodeId === targetNodeId) {
showLinkModalError('Узел-источник и узел-назначение не могут совпадать.');
linkTargetNode.focus();
return;
}
const port = parseInt(portStr, 10);
if (isNaN(port) || port < 1 || port > 65535) {
showLinkModalError('Порт должен быть целым числом в диапазоне от 1 до 65535.');
linkPort.focus();
return;
}
const ttlMinutes = parseInt(ttlStr, 10);
if (isNaN(ttlMinutes) || ttlMinutes < 0 || ttlMinutes > 525600) {
showLinkModalError('Срок действия (TTL) должен быть числом минут от 0 до 525600.');
linkTtl.focus();
return;
}
if (!reason || reason.length === 0) {
showLinkModalError('Причина открытия доступа (Reason) обязательна для аудита безопасности.');
linkReason.focus();
return;
}
if (reason.length > 256) {
showLinkModalError('Причина не должна превышать 256 символов.');
linkReason.focus();
return;
}
if (!currentLinkCreationIdempotencyKey) {
currentLinkCreationIdempotencyKey = generateUuid();
}
hideLinkModalError();
linkModalSubmitBtn.disabled = true;
linkModalSubmitBtn.querySelector('span').textContent = 'Создание...';
const payload = {
sourceNodeId,
targetNodeId,
protocol,
port,
ttlMinutes,
reason,
idempotencyKey: currentLinkCreationIdempotencyKey
};
try {
const res = await authFetch('/api/v1/control/links', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
});
if (res.status === 401) {
showLinkModalError('Требуется авторизация роли Operator.');
return;
}
if (res.status === 403) {
showLinkModalError('Доступ запрещен: роль не является Operator.');
return;
}
if (!res.ok) {
let errorMsg = `Ошибка создания Link (код ${res.status})`;
try {
const errorData = await res.json();
if (errorData?.detail) {
errorMsg = errorData.detail;
} else if (errorData?.title) {
errorMsg = errorData.title;
} else if (errorData?.errors) {
errorMsg = Object.values(errorData.errors).flat().join(' ');
}
} catch {
const text = await res.text();
if (text) errorMsg = text;
}
showLinkModalError(errorMsg);
return;
}
// Success
currentLinkCreationIdempotencyKey = null;
closeCreateLinkModal();
await loadDashboardData(true);
showGlobalAlert(`Сетевая связь ${sourceNodeId} → ${targetNodeId}:${port} успешно создана.`, 'success');
} catch (err) {
console.error('Link creation error:', err);
showLinkModalError(`Сетевая ошибка: ${err.message}`);
} finally {
linkModalSubmitBtn.disabled = false;
linkModalSubmitBtn.querySelector('span').textContent = 'Подтвердить и создать Link';
}
}
function showLinkModalError(msg) {
linkModalError.textContent = msg;
linkModalError.classList.remove('hidden');
}
function hideLinkModalError() {
linkModalError.textContent = '';
linkModalError.classList.add('hidden');
}
// Emergency Link Disabling
async function handleLinksTableClick(e) {
const disableBtn = e.target.closest('.btn-disable-link');
if (!disableBtn) return;
const linkId = disableBtn.getAttribute('data-link-id');
const source = disableBtn.getAttribute('data-source');
const target = disableBtn.getAttribute('data-target');
const protocol = (disableBtn.getAttribute('data-protocol') || 'TCP').toUpperCase();
const port = disableBtn.getAttribute('data-port');
if (!linkId) return;
const confirmed = window.confirm(
`АВАРИЙНОЕ ОТКЛЮЧЕНИЕ СВЯЗИ:\n\nВы уверены, что хотите немедленно отозвать сетевой доступ с узла "${source}" к узлу "${target}" (${protocol}/${port})?`
);
if (!confirmed) return;
disableBtn.disabled = true;
disableBtn.textContent = 'Отключение...';
const disablePayload = {
idempotencyKey: generateUuid()
};
try {
const res = await authFetch(`/api/v1/control/links/${encodeURIComponent(linkId)}/disable`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(disablePayload)
});
if (res.status === 401) {
showGlobalAlert('Требуется авторизация роли Operator для отключения связи.', 'error');
return;
}
if (res.status === 403) {
showGlobalAlert('Доступ запрещен: роль не является Operator.', 'error');
return;
}
if (!res.ok) {
let errorMsg = `Ошибка при отключении Link (код ${res.status})`;
try {
const errorData = await res.json();
if (errorData?.detail) errorMsg = errorData.detail;
else if (errorData?.title) errorMsg = errorData.title;
} catch {
const text = await res.text();
if (text) errorMsg = text;
}
showGlobalAlert(errorMsg, 'error');
return;
}
await loadDashboardData(true);
showGlobalAlert(`Сетевая связь ${source} → ${target}:${port} успешно отключена.`, 'success');
} catch (err) {
console.error('Disable link error:', err);
showGlobalAlert(`Сетевая ошибка при отключении Link: ${err.message}`, 'error');
} finally {
disableBtn.disabled = false;
disableBtn.textContent = 'Отключить';
}
}
// Add Node Modal & Code Generation
async function handleEnrollmentSubmit(e) {
e.preventDefault();
const nodeId = (nodeIdInput.value || '').trim();
if (!nodeId || !/^[a-z0-9-]{1,63}$/.test(nodeId)) {
showModalError('Имя узла должно содержать от 1 до 63 строчных латинских букв, цифр или дефисов.');
return;
}
hideModalError();
generateCodeBtn.disabled = true;
generateCodeBtn.textContent = 'Генерация...';
try {
const res = await authFetch(`/api/v1/control/agents/${encodeURIComponent(nodeId)}/enrollment-code`, {
method: 'POST'
});
if (res.status === 401) {
showModalError('Требуется авторизация роли Operator.');
return;
}
if (res.status === 403) {
showModalError('Доступ запрещен: роль не является Operator.');
return;
}
if (res.status === 429) {
showModalError('Превышен лимит запросов на выпуск кодов (10/мин). Подождите перед повторной попыткой.');
return;
}
const data = await res.json();
if (!res.ok) {
const errorDetail = data?.detail || data?.title || 'Ошибка выпуска кода регистрации';
showModalError(errorDetail);
return;
}
displayEnrollmentResult(data);
} catch (err) {
console.error('Enrollment code creation error:', err);
showModalError(`Сетевая ошибка: ${err.message}`);
} finally {
generateCodeBtn.disabled = false;
generateCodeBtn.textContent = 'Сгенерировать код';
}
}
function displayEnrollmentResult(data) {
enrollmentForm.classList.add('hidden');
enrollmentResult.classList.remove('hidden');
caFingerprintDisplay.textContent = data.certificateAuthorityFingerprintSha256 || '—';
enrollmentCodeDisplay.value = data.code || '';
// Start 10-minute countdown
const expiresAt = data.expiresAt ? new Date(data.expiresAt) : new Date(Date.now() + 10 * 60 * 1000);
startCountdown(expiresAt);
}
function startCountdown(targetDate) {
if (countdownInterval) clearInterval(countdownInterval);
function update() {
const now = new Date();
const remainingMs = targetDate.getTime() - now.getTime();
if (remainingMs <= 0) {
countdownTimer.textContent = '00:00 (истёк)';
countdownTimer.style.backgroundColor = 'var(--danger-bg)';
countdownTimer.style.color = 'var(--danger-text)';
clearInterval(countdownInterval);
return;
}
const totalSec = Math.floor(remainingMs / 1000);
const min = Math.floor(totalSec / 60);
const sec = totalSec % 60;
countdownTimer.textContent = `${String(min).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
}
update();
countdownInterval = setInterval(update, 1000);
}
function openAddNodeModal() {
isModalOpen = true;
enrollmentForm.reset();
enrollmentForm.classList.remove('hidden');
enrollmentResult.classList.add('hidden');
hideModalError();
addNodeModal.classList.remove('hidden');
nodeIdInput.focus();
}
function closeAddNodeModal() {
isModalOpen = false;
addNodeModal.classList.add('hidden');
if (countdownInterval) {
clearInterval(countdownInterval);
countdownInterval = null;
}
loadDashboardData();
}
// Password Login Modal
function showLoginModal() {
if (loginModal) {
loginModal.classList.remove('hidden');
if (loginUsername) loginUsername.focus();
}
}
function hideLoginModal() {
if (loginModal) {
loginModal.classList.add('hidden');
hideLoginError();
}
}
function showLoginError(msg) {
if (loginError) {
loginError.textContent = msg;
loginError.classList.remove('hidden');
}
}
function hideLoginError() {
if (loginError) {
loginError.textContent = '';
loginError.classList.add('hidden');
}
}
async function handleLoginSubmit(e) {
e.preventDefault();
const username = (loginUsername.value || '').trim();
const password = loginPassword.value || '';
if (!username || !password) {
showLoginError('Заполните логин и пароль.');
return;
}
hideLoginError();
loginSubmitBtn.disabled = true;
loginSubmitBtn.querySelector('span').textContent = 'Вход...';
try {
const res = await fetch('/api/v1/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({ username, password })
});
if (res.status === 401) {
showLoginError('Неверный логин или пароль.');
return;
}
if (res.status === 429) {
showLoginError('Превышено количество попыток входа (лимит 5/мин). Подождите перед повторной попыткой.');
return;
}
if (!res.ok) {
showLoginError('Вход по паролю отключён на этом сервере.');
return;
}
const data = await res.json();
if (data?.token) {
setStoredToken(data.token);
hideLoginModal();
await loadDashboardData(true);
}
} catch (err) {
showLoginError(`Ошибка сети: ${err.message}`);
} finally {
loginSubmitBtn.disabled = false;
loginSubmitBtn.querySelector('span').textContent = 'Войти';
}
}
async function handleLogout() {
try {
await authFetch('/api/v1/auth/logout', { method: 'POST' });
} catch {
// Ignore network errors on logout
}
setStoredToken('');
if (logoutBtn) logoutBtn.classList.add('hidden');
if (connectionStatus) connectionStatus.textContent = 'Сессия завершена';
if (isPasswordLoginEnabled) {
showLoginModal();
} else {
window.location.reload();
}
}
// Utilities
async function copyToClipboard(text, btnElement, defaultLabel) {
if (!text) return;
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text);
} else {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
}
btnElement.textContent = '✓ Скопировано!';
btnElement.classList.add('btn-success');
setTimeout(() => {
btnElement.textContent = defaultLabel;
btnElement.classList.remove('btn-success');
}, 2000);
} catch (err) {
console.error('Copy failed:', err);
btnElement.textContent = 'Ошибка копирования';
setTimeout(() => {
btnElement.textContent = defaultLabel;
}, 2000);
}
}
function showModalError(msg) {
modalError.textContent = msg;
modalError.classList.remove('hidden');
}
function hideModalError() {
modalError.textContent = '';
modalError.classList.add('hidden');
}
function showGlobalAlert(msg, type = 'error') {
globalAlert.textContent = msg;
globalAlert.className = `alert alert-${type}`;
globalAlert.classList.remove('hidden');
}
function hideGlobalAlert() {
globalAlert.classList.add('hidden');
}
function formatTimestamp(isoString) {
if (!isoString) return '—';
const date = new Date(isoString);
if (isNaN(date.getTime())) return '—';
const diffSec = Math.floor((Date.now() - date.getTime()) / 1000);
if (diffSec < 0) {
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
if (diffSec < 60) return `${diffSec} сек. назад`;
if (diffSec < 3600) return `${Math.floor(diffSec / 60)} мин. назад`;
if (diffSec < 86400) return `${Math.floor(diffSec / 3600)} ч. назад`;
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
function formatExpiration(isoString) {
if (!isoString) return 'Бессрочно';
const date = new Date(isoString);
if (isNaN(date.getTime())) return '—';
const remainingSec = Math.floor((date.getTime() - Date.now()) / 1000);
const dateStr = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
if (remainingSec <= 0) {
return `Истёк (${dateStr})`;
}
if (remainingSec < 60) {
return `через ${remainingSec} сек.`;
}
if (remainingSec < 3600) {
return `через ${Math.floor(remainingSec / 60)} мин. (${dateStr})`;
}
if (remainingSec < 86400) {
return `через ${Math.floor(remainingSec / 3600)} ч. (${dateStr})`;
}
return `${date.toLocaleDateString()} ${dateStr}`;
}
function formatCertInfo(agent) {
if (typeof agent.certificateRemainingDays === 'number') {
const days = agent.certificateRemainingDays;
if (days < 0) return 'Истёк';
if (days <= 7) return `${days} дн.`;
return `${days} дн.`;
}
if (agent.certificateExpiresAt) {
const expDate = new Date(agent.certificateExpiresAt);
return expDate.toLocaleDateString();
}
return '—';
}
function escapeHtml(str) {
if (str === null || str === undefined) return '';
return String(str)
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
})();