Добавил новый функционал в вэбку
This commit is contained in:
parent
d9aec2e986
commit
c072256b33
|
|
@ -43,3 +43,5 @@ build/
|
|||
/.cache/
|
||||
ovpn-connector.json
|
||||
/webui/src/json-rpc.js
|
||||
app/src/main/resources/htdocs/
|
||||
*.pfapp
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@
|
|||
<html lang="en" class="dark-theme">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<link rel="icon" type="image/png" href="/favicon.png">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SDN Control</title>
|
||||
<title>pfSDN</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
Before Width: | Height: | Size: 4.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.3 KiB |
|
|
@ -8,7 +8,7 @@ window.$ = window.jQuery = $;
|
|||
import { JSONRPC } from '@/json-rpc.js';
|
||||
|
||||
// 🔥 Указываем URL сервера, как ты и сделал
|
||||
JSONRPC.url = "http://localhost:8080" + JSONRPC.url;
|
||||
//JSONRPC.url = "http://localhost:8080" + JSONRPC.url;
|
||||
|
||||
// 2. Импорт главного модуля приложения
|
||||
import { initApp } from './modules/app.js';
|
||||
|
|
|
|||
|
|
@ -7,6 +7,10 @@ import { Components } from '../pages/Components.js';
|
|||
import { APITokens } from '../pages/APITokens.js';
|
||||
import { getEnabledComponents } from './app.js';
|
||||
import { OVPNConfig } from '../pages/OVPN.js';
|
||||
import { TDNSConfig } from '../pages/TDNS.js';
|
||||
import { FRRConfig } from '../pages/FRR.js';
|
||||
import { SettingsPage } from '../pages/Settings.js';
|
||||
import { LogsPage } from '../pages/Logs.js';
|
||||
|
||||
|
||||
// Переменная для отслеживания текущего активного хеша (для корректного unmount)
|
||||
|
|
@ -21,6 +25,9 @@ const allMenuItems = [
|
|||
{ label: 'Компоненты', path: 'components', component: null },
|
||||
{ label: 'API', path: 'api', component: null },
|
||||
{ label: 'Настройка OVPN', path: 'ovpn', component: 'ru.kirillius.pf.sdn.External.API.Components.OVPN' },
|
||||
{ label: 'Настройка TDNS', path: 'tdns', component: 'ru.kirillius.pf.sdn.External.API.Components.TDNS' },
|
||||
{ label: 'Настройка FRR', path: 'frr', component: 'ru.kirillius.pf.sdn.External.API.Components.FRR' },
|
||||
{ label: 'Журнал', path: 'logs', component: null },
|
||||
];
|
||||
|
||||
// 2. Определение страниц
|
||||
|
|
@ -36,9 +43,9 @@ const routes = {
|
|||
unmount: Subscriptions.unmount
|
||||
},
|
||||
'#settings': {
|
||||
render: () => '<h1 class="page-title">Системные Настройки</h1><p>Конфигурация сети, пользователя и безопасности.</p>',
|
||||
mount: () => {},
|
||||
unmount: () => {}
|
||||
render: SettingsPage.render,
|
||||
mount: SettingsPage.mount,
|
||||
unmount: SettingsPage.unmount
|
||||
},
|
||||
'#components': {
|
||||
render: Components.render,
|
||||
|
|
@ -54,6 +61,21 @@ const routes = {
|
|||
render: OVPNConfig.render,
|
||||
mount: OVPNConfig.mount,
|
||||
unmount: OVPNConfig.unmount
|
||||
},
|
||||
'#tdns': {
|
||||
render: TDNSConfig.render,
|
||||
mount: TDNSConfig.mount,
|
||||
unmount: TDNSConfig.unmount
|
||||
},
|
||||
'#frr': {
|
||||
render: FRRConfig.render,
|
||||
mount: FRRConfig.mount,
|
||||
unmount: FRRConfig.unmount
|
||||
},
|
||||
'#logs': {
|
||||
render: LogsPage.render,
|
||||
mount: LogsPage.mount,
|
||||
unmount: LogsPage.unmount
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,9 @@ import $ from 'jquery';
|
|||
import { handleLogin, handleLogout } from './auth.js';
|
||||
import { setAuthenticated } from './app.js';
|
||||
import { renderPage, getFilteredMenuItems } from './router.js';
|
||||
import { JSONRPC } from '@/json-rpc.js';
|
||||
|
||||
let configIndicatorInterval = null;
|
||||
|
||||
|
||||
// Функция рендеринга формы авторизации
|
||||
|
|
@ -10,14 +13,11 @@ export function renderLoginForm() {
|
|||
<div id="login-container" class="full-screen flex-center">
|
||||
<div class="card" style="width: 380px;">
|
||||
<h2 style="margin-top: 0; color: var(--color-text);">Авторизация</h2>
|
||||
<nav id="main-nav">
|
||||
${sidebarHtml}
|
||||
<a href="#" id="logout-btn" class="menu-item logout-btn">Выход</a>
|
||||
</nav>
|
||||
<form id="login-form">
|
||||
<div class="form-group">
|
||||
<label for="password-input">Пароль</label>
|
||||
<input type="password" id="password-input" class="form-control" placeholder="Введите пароль">
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="display: flex; align-items: center;">
|
||||
<input type="checkbox" id="remember-me" style="margin-right: 10px;">
|
||||
<label for="remember-me" style="margin-bottom: 0; font-weight: normal; cursor: pointer;">Запомнить меня</label>
|
||||
|
|
@ -63,18 +63,52 @@ export function renderLoginForm() {
|
|||
|
||||
|
||||
// Функция рендеринга рабочего стола (Dashboard)
|
||||
export function renderDashboard() {
|
||||
async function fetchConfigChanged() {
|
||||
try {
|
||||
return await JSONRPC.System.isConfigChanged();
|
||||
} catch (error) {
|
||||
console.error('Ошибка при проверке изменения конфигурации:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateStatisticsIndicator() {
|
||||
const hasChanges = await fetchConfigChanged();
|
||||
const $statsLink = $('#main-nav .menu-item[data-path="stats"]');
|
||||
if (!$statsLink.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const $indicator = $statsLink.find('.menu-indicator');
|
||||
if (hasChanges) {
|
||||
if (!$indicator.length) {
|
||||
$statsLink.append('<span class="menu-indicator"></span>');
|
||||
}
|
||||
} else {
|
||||
$indicator.remove();
|
||||
}
|
||||
}
|
||||
|
||||
export async function renderDashboard() {
|
||||
// Используем функцию из роутера для получения актуального списка меню
|
||||
const menuItems = getFilteredMenuItems();
|
||||
const configChanged = await fetchConfigChanged();
|
||||
|
||||
const sidebarHtml = menuItems.map(item => {
|
||||
return `<a href="#${item.path}" class="menu-item" data-path="${item.path}">${item.label}</a>`;
|
||||
const isStatistics = item.path === 'stats';
|
||||
const indicator = isStatistics && configChanged
|
||||
? '<span class="menu-indicator"></span>'
|
||||
: '';
|
||||
return `<a href="#${item.path}" class="menu-item" data-path="${item.path}">${item.label}${indicator}</a>`;
|
||||
}).join('');
|
||||
|
||||
const html = `
|
||||
<div id="dashboard-container" style="display: flex; min-height: 100vh;">
|
||||
<div id="sidebar" class="sidebar">
|
||||
<h2 class="sidebar-title">SDN Control</h2>
|
||||
<h2 class="sidebar-title" style="display: flex; align-items: center; gap: 10px; padding-left: 12px;">
|
||||
<img src="/favicon.png" alt="pfSDN" style="width: 16px; height: 16px;">
|
||||
pfSDN
|
||||
</h2>
|
||||
<nav id="main-nav">
|
||||
${sidebarHtml}
|
||||
<a href="#" id="logout-btn" class="menu-item logout-btn">Выход</a>
|
||||
|
|
@ -88,6 +122,11 @@ export function renderDashboard() {
|
|||
|
||||
$('#app').html(html);
|
||||
|
||||
if (configIndicatorInterval) {
|
||||
clearInterval(configIndicatorInterval);
|
||||
}
|
||||
configIndicatorInterval = setInterval(updateStatisticsIndicator, 3000);
|
||||
|
||||
// Прикрепляем события для выхода
|
||||
$('#logout-btn').on('click', async function(e) {
|
||||
e.preventDefault();
|
||||
|
|
|
|||
|
|
@ -74,13 +74,21 @@ function attachEventHandlers() {
|
|||
try {
|
||||
await JSONRPC.System.setEnabledComponents(newEnabledComponents);
|
||||
enabledComponents = newEnabledComponents;
|
||||
$message.text('Настройки компонентов успешно сохранены!').addClass('success-message').show();
|
||||
$message.text('Настройки компонентов успешно сохранены! Перезагрузка...').addClass('success-message').show();
|
||||
window.setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 800);
|
||||
} catch (e) {
|
||||
console.error('Ошибка сохранения компонентов:', e);
|
||||
$message.text('Ошибка при сохранении компонентов.').show();
|
||||
} finally {
|
||||
$btn.prop('disabled', false).text('Сохранить');
|
||||
setTimeout(() => $message.fadeOut(), 5000);
|
||||
setTimeout(() => {
|
||||
if ($message.hasClass('success-message')) {
|
||||
return;
|
||||
}
|
||||
$message.fadeOut();
|
||||
}, 5000);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,448 @@
|
|||
import $ from 'jquery';
|
||||
import { JSONRPC } from '@/json-rpc.js';
|
||||
|
||||
const FRR_COMPONENT_NAME = 'ru.kirillius.pf.sdn.External.API.Components.FRR';
|
||||
const DEFAULT_PORT = 22;
|
||||
const DEFAULT_SUBNET_PATTERN = 'ip route {%subnet} {%gateway} 100';
|
||||
|
||||
const FIELD_IDS = {
|
||||
container: 'frr-config-container',
|
||||
instancesList: 'frr-instances-list',
|
||||
addInstanceButton: 'frr-add-instance-btn',
|
||||
saveButton: 'save-frr-btn',
|
||||
status: 'frr-status-message'
|
||||
};
|
||||
|
||||
const CLASS_NAMES = {
|
||||
instance: 'frr-instance-entry',
|
||||
removeInstanceButton: 'frr-remove-instance-btn',
|
||||
shellUseSSH: 'frr-shell-use-ssh',
|
||||
shellSection: 'frr-shell-section',
|
||||
shellHost: 'frr-shell-host',
|
||||
shellPort: 'frr-shell-port',
|
||||
shellUsername: 'frr-shell-username',
|
||||
shellPassword: 'frr-shell-password',
|
||||
subnetPattern: 'frr-subnet-pattern',
|
||||
gateway: 'frr-gateway',
|
||||
essentialList: 'frr-essential-list',
|
||||
essentialItem: 'frr-essential-item',
|
||||
essentialInput: 'frr-essential-input',
|
||||
addEssentialButton: 'frr-add-essential-btn',
|
||||
removeEssentialButton: 'frr-remove-essential-btn'
|
||||
};
|
||||
|
||||
const SELECTORS = {
|
||||
container: `#${FIELD_IDS.container}`,
|
||||
instancesList: `#${FIELD_IDS.instancesList}`,
|
||||
addInstanceButton: `#${FIELD_IDS.addInstanceButton}`,
|
||||
saveButton: `#${FIELD_IDS.saveButton}`,
|
||||
status: `#${FIELD_IDS.status}`
|
||||
};
|
||||
|
||||
let currentConfig = { instances: [] };
|
||||
let statusTimeoutId = null;
|
||||
let instanceCounter = 0;
|
||||
let essentialCounters = {};
|
||||
|
||||
const getStatusElement = () => $(SELECTORS.status);
|
||||
|
||||
function normalizeConfig(config) {
|
||||
if (!config || !Array.isArray(config.instances)) {
|
||||
return { instances: [] };
|
||||
}
|
||||
|
||||
return {
|
||||
instances: config.instances.map(instance => {
|
||||
const shellConfig = instance?.shellConfig || {};
|
||||
return {
|
||||
shellConfig: {
|
||||
useSSH: !!shellConfig.useSSH,
|
||||
host: shellConfig.host || '',
|
||||
port: Number.isInteger(shellConfig.port) ? shellConfig.port : DEFAULT_PORT,
|
||||
username: shellConfig.username || '',
|
||||
password: shellConfig.password || ''
|
||||
},
|
||||
subnetPattern: instance?.subnetPattern || DEFAULT_SUBNET_PATTERN,
|
||||
gateway: instance?.gateway || '',
|
||||
essentialSubnets: Array.isArray(instance?.essentialSubnets)
|
||||
? instance.essentialSubnets.filter(item => typeof item === 'string')
|
||||
: []
|
||||
};
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
function clearStatus() {
|
||||
const $status = getStatusElement();
|
||||
if (!$status.length) {
|
||||
return;
|
||||
}
|
||||
if (statusTimeoutId) {
|
||||
clearTimeout(statusTimeoutId);
|
||||
statusTimeoutId = null;
|
||||
}
|
||||
$status.stop(true, true).hide().text('').removeClass('success-message error-message');
|
||||
}
|
||||
|
||||
function updateStatus(message, type) {
|
||||
const $status = getStatusElement();
|
||||
if (!$status.length) {
|
||||
return;
|
||||
}
|
||||
if (statusTimeoutId) {
|
||||
clearTimeout(statusTimeoutId);
|
||||
}
|
||||
$status
|
||||
.removeClass('success-message error-message')
|
||||
.addClass(type === 'success' ? 'success-message' : 'error-message')
|
||||
.text(message)
|
||||
.show();
|
||||
|
||||
statusTimeoutId = window.setTimeout(() => {
|
||||
$status.fadeOut();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
async function runAction($button, pendingText, action, messages) {
|
||||
if (!$button.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const originalText = $button.text();
|
||||
$button.prop('disabled', true).text(pendingText);
|
||||
clearStatus();
|
||||
|
||||
try {
|
||||
const result = await action();
|
||||
if (messages?.success) {
|
||||
const successMessage = typeof messages.success === 'function'
|
||||
? messages.success(result)
|
||||
: messages.success;
|
||||
if (successMessage) {
|
||||
updateStatus(successMessage, 'success');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(messages?.log || 'Ошибка выполнения действия FRR:', error);
|
||||
if (messages?.error) {
|
||||
updateStatus(messages.error, 'error');
|
||||
}
|
||||
} finally {
|
||||
$button.prop('disabled', false).text(originalText);
|
||||
}
|
||||
}
|
||||
|
||||
function resetCounters() {
|
||||
instanceCounter = 0;
|
||||
essentialCounters = {};
|
||||
}
|
||||
|
||||
function getNextInstanceId() {
|
||||
instanceCounter += 1;
|
||||
return instanceCounter;
|
||||
}
|
||||
|
||||
function getNextEssentialId(instanceId) {
|
||||
if (!essentialCounters[instanceId]) {
|
||||
essentialCounters[instanceId] = 0;
|
||||
}
|
||||
essentialCounters[instanceId] += 1;
|
||||
return essentialCounters[instanceId];
|
||||
}
|
||||
|
||||
function isValidIPv4Cidr(value) {
|
||||
const cidrRegex = /^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}\/(3[0-2]|[12]?\d)$/;
|
||||
return cidrRegex.test(value);
|
||||
}
|
||||
|
||||
function createEssentialSubnetRow(instanceId, value = '') {
|
||||
const essentialId = getNextEssentialId(instanceId);
|
||||
const inputId = `frr-essential-${instanceId}-${essentialId}`;
|
||||
|
||||
return `
|
||||
<div class="${CLASS_NAMES.essentialItem}" style="display: flex; align-items: center; gap: 10px; margin-bottom: 10px;">
|
||||
<input type="text" id="${inputId}" class="form-control ${CLASS_NAMES.essentialInput}" placeholder="1.2.3.4/24" value="${value}">
|
||||
<button type="button" class="btn-link ${CLASS_NAMES.removeEssentialButton}">Удалить</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function createInstanceRow(instance = {}) {
|
||||
const instanceId = getNextInstanceId();
|
||||
essentialCounters[instanceId] = 0;
|
||||
|
||||
const shellConfig = instance.shellConfig || {};
|
||||
const useSSHChecked = shellConfig.useSSH ? 'checked' : '';
|
||||
const portValue = shellConfig.port ?? DEFAULT_PORT;
|
||||
const subnetPattern = instance.subnetPattern || DEFAULT_SUBNET_PATTERN;
|
||||
const essentialSubnets = (instance.essentialSubnets && instance.essentialSubnets.length)
|
||||
? instance.essentialSubnets
|
||||
: [''];
|
||||
const shellSectionStyle = shellConfig.useSSH ? '' : 'display: none;';
|
||||
|
||||
const essentialRows = essentialSubnets.map(value => createEssentialSubnetRow(instanceId, value)).join('');
|
||||
|
||||
return `
|
||||
<div class="${CLASS_NAMES.instance}" data-instance-id="${instanceId}" style="border: 1px solid var(--color-border); padding: 20px; border-radius: 12px; margin-bottom: 20px; background: var(--color-surface, #1f2333);">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
|
||||
<h4 style="margin: 0;">Инстанс</h4>
|
||||
<button type="button" class="btn-link ${CLASS_NAMES.removeInstanceButton}">Удалить</button>
|
||||
</div>
|
||||
<div class="form-group checkbox-group">
|
||||
<input type="checkbox" id="frr-use-ssh-${instanceId}" class="${CLASS_NAMES.shellUseSSH}" ${useSSHChecked}>
|
||||
<label for="frr-use-ssh-${instanceId}" style="margin-bottom: 0;">Использовать SSH</label>
|
||||
</div>
|
||||
<div class="${CLASS_NAMES.shellSection}" style="${shellSectionStyle}">
|
||||
<div class="form-group">
|
||||
<label for="frr-host-${instanceId}">Хост</label>
|
||||
<input type="text" id="frr-host-${instanceId}" class="form-control ${CLASS_NAMES.shellHost}" value="${shellConfig.host || ''}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="frr-port-${instanceId}">Порт</label>
|
||||
<input type="number" id="frr-port-${instanceId}" class="form-control ${CLASS_NAMES.shellPort}" value="${portValue}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="frr-username-${instanceId}">Имя пользователя</label>
|
||||
<input type="text" id="frr-username-${instanceId}" class="form-control ${CLASS_NAMES.shellUsername}" value="${shellConfig.username || ''}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="frr-password-${instanceId}">Пароль</label>
|
||||
<input type="password" id="frr-password-${instanceId}" class="form-control ${CLASS_NAMES.shellPassword}" placeholder="Оставьте пустым для сохранения текущего пароля" data-original-password="${shellConfig.password || ''}">
|
||||
</div>
|
||||
</div>
|
||||
<hr style="border: 0; border-top: 1px solid var(--color-border); margin: 20px 0;">
|
||||
<div class="form-group">
|
||||
<label for="frr-subnet-pattern-${instanceId}">Шаблон маршрута</label>
|
||||
<input type="text" id="frr-subnet-pattern-${instanceId}" class="form-control ${CLASS_NAMES.subnetPattern}" value="${subnetPattern}">
|
||||
<small class="hint-text">Используйте плейсхолдеры <code>{%subnet}</code> и <code>{%gateway}</code> для автоматической подстановки значений.</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="frr-gateway-${instanceId}">Шлюз</label>
|
||||
<input type="text" id="frr-gateway-${instanceId}" class="form-control ${CLASS_NAMES.gateway}" placeholder="192.168.1.1" value="${instance.gateway || ''}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Обязательные подсети</label>
|
||||
<div class="${CLASS_NAMES.essentialList}" data-instance-id="${instanceId}">
|
||||
${essentialRows}
|
||||
</div>
|
||||
<button type="button" class="btn-secondary ${CLASS_NAMES.addEssentialButton}" data-instance-id="${instanceId}" style="margin-top: 10px;">Добавить подсеть</button>
|
||||
<small class="hint-text">Подсети должны быть указаны в формате IPv4 CIDR, например <code>10.0.0.0/24</code>.</small>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function populateInstances() {
|
||||
const instances = currentConfig.instances.length ? currentConfig.instances : [];
|
||||
const $list = $(SELECTORS.instancesList);
|
||||
resetCounters();
|
||||
$list.empty();
|
||||
instances.forEach(instance => {
|
||||
$list.append(createInstanceRow(instance));
|
||||
});
|
||||
updateAllShellFieldStates();
|
||||
}
|
||||
|
||||
function renderFRRForm() {
|
||||
const $container = $(SELECTORS.container);
|
||||
$container.html(`
|
||||
<div class="component-config-form">
|
||||
<h3 class="config-section-title">Инстансы FRR</h3>
|
||||
<p class="hint-text" style="margin-bottom: 20px;">Настройте параметры подключения и маршрутизации для FRR. Можно добавить несколько инстансов.</p>
|
||||
<div id="${FIELD_IDS.instancesList}"></div>
|
||||
<div class="form-group" style="margin-top: 10px;">
|
||||
<button type="button" id="${FIELD_IDS.addInstanceButton}" class="btn-secondary" style="width: 220px;">Добавить инстанс</button>
|
||||
</div>
|
||||
<div class="action-buttons" style="margin-top: 40px;">
|
||||
<button id="${FIELD_IDS.saveButton}" class="btn-primary" style="width: 220px;">Применить Конфигурацию</button>
|
||||
</div>
|
||||
<div id="${FIELD_IDS.status}" class="error-message" style="display: none; margin-top: 20px;"></div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
populateInstances();
|
||||
attachEventHandlers();
|
||||
}
|
||||
|
||||
function toggleShellFields($instance) {
|
||||
const enabled = $instance.find(`.${CLASS_NAMES.shellUseSSH}`).prop('checked');
|
||||
const $shellSection = $instance.find(`.${CLASS_NAMES.shellSection}`);
|
||||
$instance.find(`.${CLASS_NAMES.shellHost}`).prop('disabled', !enabled);
|
||||
$instance.find(`.${CLASS_NAMES.shellPort}`).prop('disabled', !enabled);
|
||||
$instance.find(`.${CLASS_NAMES.shellUsername}`).prop('disabled', !enabled);
|
||||
$instance.find(`.${CLASS_NAMES.shellPassword}`).prop('disabled', !enabled);
|
||||
$shellSection.toggle(enabled);
|
||||
}
|
||||
|
||||
function updateAllShellFieldStates() {
|
||||
$(SELECTORS.instancesList).find(`.${CLASS_NAMES.instance}`).each((_, element) => {
|
||||
toggleShellFields($(element));
|
||||
});
|
||||
}
|
||||
|
||||
function collectConfigFromForm() {
|
||||
const instances = [];
|
||||
|
||||
$(SELECTORS.instancesList).find(`.${CLASS_NAMES.instance}`).each((_, element) => {
|
||||
const $instance = $(element);
|
||||
|
||||
const useSSH = $instance.find(`.${CLASS_NAMES.shellUseSSH}`).prop('checked');
|
||||
const host = $instance.find(`.${CLASS_NAMES.shellHost}`).val().trim();
|
||||
const port = parseInt($instance.find(`.${CLASS_NAMES.shellPort}`).val(), 10) || DEFAULT_PORT;
|
||||
const username = $instance.find(`.${CLASS_NAMES.shellUsername}`).val().trim();
|
||||
const $passwordField = $instance.find(`.${CLASS_NAMES.shellPassword}`);
|
||||
const newPassword = $passwordField.val();
|
||||
const originalPassword = $passwordField.data('original-password') || '';
|
||||
const password = newPassword ? newPassword : originalPassword;
|
||||
|
||||
const subnetPatternRaw = $instance.find(`.${CLASS_NAMES.subnetPattern}`).val().trim();
|
||||
const subnetPattern = subnetPatternRaw || DEFAULT_SUBNET_PATTERN;
|
||||
const gateway = $instance.find(`.${CLASS_NAMES.gateway}`).val().trim();
|
||||
|
||||
const essentialSubnets = [];
|
||||
$instance.find(`.${CLASS_NAMES.essentialItem}`).each((_, itemElement) => {
|
||||
const value = $(itemElement).find(`.${CLASS_NAMES.essentialInput}`).val().trim();
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
if (!isValidIPv4Cidr(value)) {
|
||||
throw new Error(`Недопустимая подсеть: ${value}. Используйте формат IPv4 CIDR, например 10.0.0.0/24.`);
|
||||
}
|
||||
essentialSubnets.push(value);
|
||||
});
|
||||
|
||||
instances.push({
|
||||
shellConfig: {
|
||||
useSSH,
|
||||
host,
|
||||
port,
|
||||
username,
|
||||
password
|
||||
},
|
||||
subnetPattern,
|
||||
gateway,
|
||||
essentialSubnets
|
||||
});
|
||||
});
|
||||
|
||||
return { instances };
|
||||
}
|
||||
|
||||
async function loadConfig() {
|
||||
try {
|
||||
const fullConfig = await JSONRPC.System.getComponentConfig(FRR_COMPONENT_NAME);
|
||||
currentConfig = normalizeConfig(fullConfig);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Ошибка при загрузке конфига FRR:', error);
|
||||
currentConfig = { instances: [] };
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleAddInstance() {
|
||||
const $list = $(SELECTORS.instancesList);
|
||||
$list.append(createInstanceRow({
|
||||
shellConfig: {
|
||||
useSSH: false,
|
||||
host: '',
|
||||
port: DEFAULT_PORT,
|
||||
username: '',
|
||||
password: ''
|
||||
},
|
||||
subnetPattern: DEFAULT_SUBNET_PATTERN,
|
||||
gateway: '',
|
||||
essentialSubnets: ['']
|
||||
}));
|
||||
updateAllShellFieldStates();
|
||||
}
|
||||
|
||||
function handleRemoveInstance(event) {
|
||||
event.preventDefault();
|
||||
$(event.currentTarget).closest(`.${CLASS_NAMES.instance}`).remove();
|
||||
}
|
||||
|
||||
function handleToggleShellUseSSH(event) {
|
||||
const $instance = $(event.currentTarget).closest(`.${CLASS_NAMES.instance}`);
|
||||
toggleShellFields($instance);
|
||||
}
|
||||
|
||||
function handleAddEssentialSubnet(event) {
|
||||
event.preventDefault();
|
||||
const $button = $(event.currentTarget);
|
||||
const $instance = $button.closest(`.${CLASS_NAMES.instance}`);
|
||||
const instanceId = $instance.data('instance-id');
|
||||
const $list = $instance.find(`.${CLASS_NAMES.essentialList}`);
|
||||
$list.append(createEssentialSubnetRow(instanceId, ''));
|
||||
}
|
||||
|
||||
function handleRemoveEssentialSubnet(event) {
|
||||
event.preventDefault();
|
||||
$(event.currentTarget).closest(`.${CLASS_NAMES.essentialItem}`).remove();
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
const $button = $(SELECTORS.saveButton);
|
||||
clearStatus();
|
||||
|
||||
let newConfig;
|
||||
try {
|
||||
newConfig = collectConfigFromForm();
|
||||
} catch (error) {
|
||||
console.error('Ошибка валидации конфига FRR:', error);
|
||||
updateStatus(error.message || 'Ошибка при подготовке конфигурации FRR.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
await runAction($button, 'Применение...', async () => {
|
||||
await JSONRPC.System.setComponentConfig(FRR_COMPONENT_NAME, newConfig);
|
||||
currentConfig = normalizeConfig(newConfig);
|
||||
populateInstances();
|
||||
}, {
|
||||
success: 'Конфигурация FRR успешно сохранена.',
|
||||
error: 'Ошибка при сохранении конфигурации FRR.',
|
||||
log: 'Ошибка сохранения конфига FRR'
|
||||
});
|
||||
}
|
||||
|
||||
function attachEventHandlers() {
|
||||
$(SELECTORS.saveButton).off('click').on('click', handleSave);
|
||||
$(SELECTORS.addInstanceButton).off('click').on('click', handleAddInstance);
|
||||
$(SELECTORS.instancesList)
|
||||
.off('click', `.${CLASS_NAMES.removeInstanceButton}`).on('click', `.${CLASS_NAMES.removeInstanceButton}`, handleRemoveInstance)
|
||||
.off('change', `.${CLASS_NAMES.shellUseSSH}`).on('change', `.${CLASS_NAMES.shellUseSSH}`, handleToggleShellUseSSH)
|
||||
.off('click', `.${CLASS_NAMES.addEssentialButton}`).on('click', `.${CLASS_NAMES.addEssentialButton}`, handleAddEssentialSubnet)
|
||||
.off('click', `.${CLASS_NAMES.removeEssentialButton}`).on('click', `.${CLASS_NAMES.removeEssentialButton}`, handleRemoveEssentialSubnet);
|
||||
}
|
||||
|
||||
function detachEventHandlers() {
|
||||
$(SELECTORS.saveButton).off('click');
|
||||
$(SELECTORS.addInstanceButton).off('click');
|
||||
$(SELECTORS.instancesList)
|
||||
.off('click', `.${CLASS_NAMES.removeInstanceButton}`)
|
||||
.off('change', `.${CLASS_NAMES.shellUseSSH}`)
|
||||
.off('click', `.${CLASS_NAMES.addEssentialButton}`)
|
||||
.off('click', `.${CLASS_NAMES.removeEssentialButton}`);
|
||||
}
|
||||
|
||||
export const FRRConfig = {
|
||||
render: () => `
|
||||
<h1 class="page-title">Настройка FRR</h1>
|
||||
<div id="${FIELD_IDS.container}">
|
||||
<p>Загрузка конфигурации...</p>
|
||||
</div>
|
||||
`,
|
||||
mount: async () => {
|
||||
const success = await loadConfig();
|
||||
if (success) {
|
||||
renderFRRForm();
|
||||
} else {
|
||||
$(SELECTORS.container).html('<p class="error-message">Не удалось загрузить конфигурацию FRR.</p>');
|
||||
}
|
||||
},
|
||||
unmount: () => {
|
||||
detachEventHandlers();
|
||||
clearStatus();
|
||||
currentConfig = { instances: [] };
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,215 @@
|
|||
import $ from 'jquery';
|
||||
import { JSONRPC } from '@/json-rpc.js';
|
||||
|
||||
const MAX_PREVIEW_LENGTH = "[06.10.2025 01:14:00][SEVERE] ShellExecutor> Failed to execute local shell command \"rc-service openvpn restart\"\r\nIOException:Cannot run program \"rc-service openvpn restart\": Exec failed, error: 2 (Нет такого файла или каталога) \nStack trace:\njava.base/".length;
|
||||
|
||||
let logs = [];
|
||||
let isLoading = false;
|
||||
let refreshIntervalId = null;
|
||||
|
||||
const FIELD_IDS = {
|
||||
container: 'logs-container',
|
||||
list: 'logs-list',
|
||||
refreshButton: 'logs-refresh-btn',
|
||||
clearButton: 'logs-clear-btn',
|
||||
status: 'logs-status-message'
|
||||
};
|
||||
|
||||
function escapeHtml(value = '') {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function normalizeLevel(level) {
|
||||
if (typeof level !== 'string') {
|
||||
return '';
|
||||
}
|
||||
return level.replace(/[\[\]\s]/g, '').toUpperCase();
|
||||
}
|
||||
|
||||
function getLevelClass(level) {
|
||||
const normalized = normalizeLevel(level);
|
||||
if (normalized === 'SEVERE') {
|
||||
return 'log-level-severe';
|
||||
}
|
||||
if (normalized === 'WARNING') {
|
||||
return 'log-level-warning';
|
||||
}
|
||||
if (normalized === 'INFO') {
|
||||
return 'log-level-info';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function trimQuotes(value) {
|
||||
if (!value) {
|
||||
return value;
|
||||
}
|
||||
if (value.startsWith('"') && value.endsWith('"')) {
|
||||
return value.slice(1, -1);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function getMessage(entry) {
|
||||
if (entry?.message) {
|
||||
return trimQuotes(String(entry.message));
|
||||
}
|
||||
if (entry?.msg) {
|
||||
return trimQuotes(String(entry.msg));
|
||||
}
|
||||
if (typeof entry === 'string') {
|
||||
return trimQuotes(entry);
|
||||
}
|
||||
return JSON.stringify(entry);
|
||||
}
|
||||
|
||||
function getPreviewHtml(fullMessage, index) {
|
||||
if (fullMessage.length <= MAX_PREVIEW_LENGTH) {
|
||||
return escapeHtml(fullMessage);
|
||||
}
|
||||
|
||||
const preview = escapeHtml(fullMessage.slice(0, MAX_PREVIEW_LENGTH));
|
||||
return `${preview}... <a href="#" class="log-entry-expand" data-index="${index}" style="color: var(--color-primary);">показать полностью</a>`;
|
||||
}
|
||||
|
||||
function createLogEntryHtml(entry, index) {
|
||||
const timestamp = entry.timestamp || entry.time || '';
|
||||
let level = entry.level || entry.severity || '';
|
||||
const message = getMessage(entry);
|
||||
|
||||
let normalizedLevel = normalizeLevel(level);
|
||||
if (!normalizedLevel) {
|
||||
const match = message.match(/\[(INFO|WARNING|SEVERE)\]/i);
|
||||
if (match) {
|
||||
normalizedLevel = match[1].toUpperCase();
|
||||
level = normalizedLevel;
|
||||
}
|
||||
}
|
||||
|
||||
const levelLabel = normalizedLevel ? `[${normalizedLevel}]` : '';
|
||||
const levelClass = getLevelClass(normalizedLevel);
|
||||
const previewHtml = getPreviewHtml(message, index);
|
||||
|
||||
return `
|
||||
<div class="log-entry ${levelClass}" data-index="${index}" style="border-bottom: 1px solid var(--color-border); padding: 12px 16px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px;">
|
||||
<span class="log-entry-timestamp" style="color: var(--color-text-secondary); font-size: 13px;">${escapeHtml(timestamp)}</span>
|
||||
<span class="log-entry-level">${escapeHtml(levelLabel)}</span>
|
||||
</div>
|
||||
<div class="log-entry-message" data-full="${encodeURIComponent(message)}">${previewHtml}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderLogs() {
|
||||
const $list = $(`#${FIELD_IDS.list}`);
|
||||
if (!logs.length) {
|
||||
$list.html('<p style="color: var(--color-text-secondary);">Записей в журнале нет.</p>');
|
||||
return;
|
||||
}
|
||||
|
||||
const entriesHtml = logs.map(createLogEntryHtml).join('');
|
||||
$list.html(entriesHtml);
|
||||
$list.scrollTop($list.prop('scrollHeight'));
|
||||
}
|
||||
|
||||
async function loadLogs() {
|
||||
if (isLoading) {
|
||||
return;
|
||||
}
|
||||
isLoading = true;
|
||||
const $status = $(`#${FIELD_IDS.status}`);
|
||||
$status.hide().removeClass('error-message success-message');
|
||||
|
||||
try {
|
||||
const entries = await JSONRPC.System.getLogs();
|
||||
logs = Array.isArray(entries) ? entries : [];
|
||||
renderLogs();
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки логов:', error);
|
||||
$status.text('Не удалось загрузить журнал.').addClass('error-message').show();
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function attachEventHandlers() {
|
||||
$(`#${FIELD_IDS.refreshButton}`).off('click').on('click', () => loadLogs());
|
||||
$(`#${FIELD_IDS.clearButton}`).off('click').on('click', async () => {
|
||||
const $btn = $(`#${FIELD_IDS.clearButton}`);
|
||||
const $status = $(`#${FIELD_IDS.status}`);
|
||||
$btn.prop('disabled', true).text('Очистка...');
|
||||
$status.hide().removeClass('error-message success-message');
|
||||
|
||||
try {
|
||||
await JSONRPC.System.clearLogs();
|
||||
await loadLogs();
|
||||
$status.text('Журнал очищен.').removeClass('error-message').addClass('success-message').show();
|
||||
} catch (error) {
|
||||
console.error('Ошибка очистки журналов:', error);
|
||||
$status.text('Не удалось очистить журнал.').removeClass('success-message').addClass('error-message').show();
|
||||
} finally {
|
||||
$btn.prop('disabled', false).text('Очистить');
|
||||
}
|
||||
});
|
||||
$(document)
|
||||
.off('click', '.log-entry-expand')
|
||||
.on('click', '.log-entry-expand', function (event) {
|
||||
event.preventDefault();
|
||||
const $message = $(this).closest('.log-entry-message');
|
||||
const full = decodeURIComponent($message.data('full'));
|
||||
$message.html(`${escapeHtml(full)} <a href="#" class="log-entry-collapse" style="color: var(--color-primary);">скрыть</a>`);
|
||||
})
|
||||
.off('click', '.log-entry-collapse')
|
||||
.on('click', '.log-entry-collapse', function (event) {
|
||||
event.preventDefault();
|
||||
const $message = $(this).closest('.log-entry-message');
|
||||
const full = decodeURIComponent($message.data('full'));
|
||||
const index = $(this).closest('.log-entry').data('index') || 0;
|
||||
const previewHtml = getPreviewHtml(full, index);
|
||||
$message.html(previewHtml);
|
||||
});
|
||||
}
|
||||
|
||||
function detachEventHandlers() {
|
||||
$(`#${FIELD_IDS.refreshButton}`).off('click');
|
||||
$(`#${FIELD_IDS.clearButton}`).off('click');
|
||||
$(document).off('click', '.log-entry-expand');
|
||||
$(document).off('click', '.log-entry-collapse');
|
||||
}
|
||||
|
||||
export const LogsPage = {
|
||||
render: () => `
|
||||
<h1 class="page-title">Журнал</h1>
|
||||
<div class="component-config-form" style="max-width: none; width: 100%;">
|
||||
<div class="action-buttons" style="justify-content: flex-start; margin-bottom: 20px; gap: 12px;">
|
||||
<button id="${FIELD_IDS.refreshButton}" class="btn-secondary" style="width: 200px;">Обновить</button>
|
||||
<button id="${FIELD_IDS.clearButton}" class="btn-secondary" style="width: 200px;">Очистить</button>
|
||||
</div>
|
||||
<div id="${FIELD_IDS.status}" class="error-message" style="display: none; margin-bottom: 10px;"></div>
|
||||
<div id="${FIELD_IDS.list}" style="max-height: 480px; overflow-y: auto; border: 1px solid var(--color-border); border-radius: 8px; background: var(--color-bg-card);"></div>
|
||||
</div>
|
||||
`,
|
||||
mount: async () => {
|
||||
attachEventHandlers();
|
||||
await loadLogs();
|
||||
if (refreshIntervalId) {
|
||||
clearInterval(refreshIntervalId);
|
||||
}
|
||||
refreshIntervalId = setInterval(loadLogs, 10000);
|
||||
},
|
||||
unmount: () => {
|
||||
detachEventHandlers();
|
||||
logs = [];
|
||||
isLoading = false;
|
||||
if (refreshIntervalId) {
|
||||
clearInterval(refreshIntervalId);
|
||||
refreshIntervalId = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -11,6 +11,7 @@ const FIELD_IDS = {
|
|||
password: 'ovpn-password',
|
||||
useSSH: 'ovpn-use-ssh',
|
||||
restartCommand: 'ovpn-restart-command',
|
||||
restartOnUpdate: 'ovpn-restart-on-update',
|
||||
saveButton: 'save-ovpn-btn',
|
||||
restartButton: 'restart-ovpn-btn',
|
||||
status: 'ovpn-status-message',
|
||||
|
|
@ -25,11 +26,16 @@ const SELECTORS = {
|
|||
password: `#${FIELD_IDS.password}`,
|
||||
useSSH: `#${FIELD_IDS.useSSH}`,
|
||||
restartCommand: `#${FIELD_IDS.restartCommand}`,
|
||||
restartOnUpdate: `#${FIELD_IDS.restartOnUpdate}`,
|
||||
saveButton: `#${FIELD_IDS.saveButton}`,
|
||||
restartButton: `#${FIELD_IDS.restartButton}`,
|
||||
status: `#${FIELD_IDS.status}`
|
||||
};
|
||||
|
||||
const CLASS_NAMES = {
|
||||
shellFields: 'ovpn-shell-fields'
|
||||
};
|
||||
|
||||
let currentConfig = {};
|
||||
let statusTimeoutId = null;
|
||||
|
||||
|
|
@ -109,6 +115,7 @@ async function loadConfig() {
|
|||
function renderOVPNForm() {
|
||||
const $container = $(SELECTORS.container);
|
||||
const shellConfig = currentConfig.shellConfig || {};
|
||||
const shellFieldsStyle = shellConfig.useSSH ? '' : 'display: none;';
|
||||
|
||||
$container.html(`
|
||||
<div class="component-config-form">
|
||||
|
|
@ -117,6 +124,7 @@ function renderOVPNForm() {
|
|||
<input type="checkbox" id="${FIELD_IDS.useSSH}" ${shellConfig.useSSH ? 'checked' : ''}>
|
||||
<label for="${FIELD_IDS.useSSH}" style="margin-bottom: 0;">Использовать SSH</label>
|
||||
</div>
|
||||
<div class="${CLASS_NAMES.shellFields}" style="${shellFieldsStyle}">
|
||||
<div class="form-group">
|
||||
<label for="${FIELD_IDS.host}">Хост</label>
|
||||
<input type="text" id="${FIELD_IDS.host}" class="form-control" value="${shellConfig.host || ''}">
|
||||
|
|
@ -133,6 +141,7 @@ function renderOVPNForm() {
|
|||
<label for="${FIELD_IDS.password}">Пароль</label>
|
||||
<input type="password" id="${FIELD_IDS.password}" class="form-control" placeholder="Оставьте пустым для сохранения текущего пароля">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="config-section-title" style="margin-top: 40px;">Перезапуск сервиса</h3>
|
||||
<div class="form-group">
|
||||
|
|
@ -140,12 +149,28 @@ function renderOVPNForm() {
|
|||
<input type="text" id="${FIELD_IDS.restartCommand}" class="form-control" value="${currentConfig.restartCommand || 'systemctl restart openvpn@server'}">
|
||||
<small class="hint-text">Команда, которая будет выполнена через Shell для перезапуска сервиса OVPN.</small>
|
||||
</div>
|
||||
<div class="form-group checkbox-group">
|
||||
<input type="checkbox" id="${FIELD_IDS.restartOnUpdate}" ${currentConfig.restartOnUpdate ? 'checked' : ''}>
|
||||
<label for="${FIELD_IDS.restartOnUpdate}" style="margin-bottom: 0;">Перезапускать сервис после обновления</label>
|
||||
</div>
|
||||
|
||||
<div class="action-buttons">
|
||||
<button id="${FIELD_IDS.saveButton}" class="btn-primary" style="width: 200px;">Сохранить Конфигурацию</button>
|
||||
<button id="${FIELD_IDS.saveButton}" class="btn-primary" style="width: 200px;">Применить Конфигурацию</button>
|
||||
<button id="${FIELD_IDS.restartButton}" class="btn-secondary" style="width: 200px; margin-left: 20px;">Перезапустить Сервис</button>
|
||||
</div>
|
||||
<div id="${FIELD_IDS.status}" class="error-message" style="display: none;"></div>
|
||||
<div class="config-hint" style="margin-top: 30px;">
|
||||
<h3 class="config-section-title">Дополнительная настройка</h3>
|
||||
<p>В файле <code>openvpn.conf</code> необходимо добавить следующие директивы:</p>
|
||||
<pre style="white-space: pre-wrap;">script-security 2
|
||||
client-connect ovpn-pfsdn-bind</pre>
|
||||
<p>Также создайте файл <code>ovpn-connector.json</code> со структурой:</p>
|
||||
<pre style="white-space: pre-wrap;">{
|
||||
"token": "string",
|
||||
"host": "string"
|
||||
}</pre>
|
||||
<p>Токен создаётся на странице API, а значение <code>host</code> должно указывать на адрес вида <code>http://127.0.0.1:8080</code>.</p>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
|
|
@ -165,13 +190,14 @@ function collectConfigFromForm() {
|
|||
username: $(SELECTORS.username).val(),
|
||||
password: newPassword ? newPassword : (shellConfig.password || ''),
|
||||
},
|
||||
restartCommand: $(SELECTORS.restartCommand).val()
|
||||
restartCommand: $(SELECTORS.restartCommand).val(),
|
||||
restartOnUpdate: $(SELECTORS.restartOnUpdate).prop('checked')
|
||||
};
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
const $button = $(SELECTORS.saveButton);
|
||||
await runAction($button, 'Сохранение...', async () => {
|
||||
await runAction($button, 'Применение...', async () => {
|
||||
const newConfig = collectConfigFromForm();
|
||||
await JSONRPC.System.setComponentConfig(OVPN_COMPONENT_NAME, newConfig);
|
||||
currentConfig = newConfig;
|
||||
|
|
@ -208,10 +234,12 @@ function detachEventHandlers() {
|
|||
|
||||
function toggleSSHFields() {
|
||||
const enabled = $(SELECTORS.useSSH).prop('checked');
|
||||
const $shellFields = $(SELECTORS.container).find(`.${CLASS_NAMES.shellFields}`);
|
||||
$(SELECTORS.host).prop('disabled', !enabled);
|
||||
$(SELECTORS.port).prop('disabled', !enabled);
|
||||
$(SELECTORS.username).prop('disabled', !enabled);
|
||||
$(SELECTORS.password).prop('disabled', !enabled);
|
||||
$shellFields.toggle(enabled);
|
||||
}
|
||||
|
||||
export const OVPNConfig = {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,494 @@
|
|||
import $ from 'jquery';
|
||||
import { JSONRPC } from '@/json-rpc.js';
|
||||
|
||||
const FIELD_IDS = {
|
||||
container: 'settings-config-container',
|
||||
status: 'settings-status-message',
|
||||
saveButton: 'save-settings-btn',
|
||||
changePasswordButton: 'settings-change-password-btn',
|
||||
updateSubscriptionsInterval: 'settings-update-subscriptions-interval',
|
||||
cachingAS: 'settings-caching-as',
|
||||
updateASInterval: 'settings-update-as-interval',
|
||||
host: 'settings-host',
|
||||
cacheDirectory: 'settings-cache-directory',
|
||||
httpPort: 'settings-http-port',
|
||||
mergeSubnets: 'settings-merge-subnets',
|
||||
displayDebuggingInfo: 'settings-display-debugging-info',
|
||||
mergeSubnetsWithUsage: 'settings-merge-subnets-with-usage',
|
||||
subscriptionsList: 'settings-subscriptions-list',
|
||||
addSubscriptionButton: 'settings-add-subscription',
|
||||
customASN: 'settings-custom-asn',
|
||||
customSubnets: 'settings-custom-subnets',
|
||||
customDomains: 'settings-custom-domains',
|
||||
filteredASN: 'settings-filtered-asn',
|
||||
filteredSubnets: 'settings-filtered-subnets',
|
||||
filteredDomains: 'settings-filtered-domains'
|
||||
};
|
||||
|
||||
const CLASS_NAMES = {
|
||||
subscriptionEntry: 'settings-subscription-entry',
|
||||
subscriptionRemove: 'settings-subscription-remove',
|
||||
subscriptionName: 'settings-subscription-name',
|
||||
subscriptionType: 'settings-subscription-type',
|
||||
subscriptionSource: 'settings-subscription-source'
|
||||
};
|
||||
|
||||
let currentConfig = {};
|
||||
let repositoryTypes = [];
|
||||
let statusTimeoutId = null;
|
||||
|
||||
const getStatusElement = () => $(`#${FIELD_IDS.status}`);
|
||||
|
||||
function clearStatus() {
|
||||
const $status = getStatusElement();
|
||||
if (!$status.length) {
|
||||
return;
|
||||
}
|
||||
if (statusTimeoutId) {
|
||||
clearTimeout(statusTimeoutId);
|
||||
statusTimeoutId = null;
|
||||
}
|
||||
$status.stop(true, true).hide().text('').removeClass('success-message error-message');
|
||||
}
|
||||
|
||||
function updateStatus(message, type) {
|
||||
const $status = getStatusElement();
|
||||
if (!$status.length) {
|
||||
return;
|
||||
}
|
||||
if (statusTimeoutId) {
|
||||
clearTimeout(statusTimeoutId);
|
||||
}
|
||||
$status
|
||||
.removeClass('success-message error-message')
|
||||
.addClass(type === 'success' ? 'success-message' : 'error-message')
|
||||
.text(message)
|
||||
.show();
|
||||
|
||||
statusTimeoutId = window.setTimeout(() => {
|
||||
$status.fadeOut();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
async function runAction($button, pendingText, action, messages) {
|
||||
if (!$button.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const originalText = $button.text();
|
||||
$button.prop('disabled', true).text(pendingText);
|
||||
clearStatus();
|
||||
|
||||
try {
|
||||
const result = await action();
|
||||
if (messages?.success) {
|
||||
const successMessage = typeof messages.success === 'function' ? messages.success(result) : messages.success;
|
||||
if (successMessage) {
|
||||
updateStatus(successMessage, 'success');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(messages?.log || 'Ошибка при выполнении действия Settings:', error);
|
||||
if (messages?.error) {
|
||||
updateStatus(messages.error, 'error');
|
||||
}
|
||||
} finally {
|
||||
$button.prop('disabled', false).text(originalText);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
try {
|
||||
const [config, repoTypes] = await Promise.all([
|
||||
JSONRPC.System.getConfig(),
|
||||
JSONRPC.System.getRepositoryTypes()
|
||||
]);
|
||||
currentConfig = config || {};
|
||||
repositoryTypes = Array.isArray(repoTypes) ? repoTypes : [];
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки настроек:', error);
|
||||
currentConfig = {};
|
||||
repositoryTypes = [];
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getConfigValue(key, fallback) {
|
||||
return key in currentConfig ? currentConfig[key] : fallback;
|
||||
}
|
||||
|
||||
function getTypeValue(type) {
|
||||
if (!type) {
|
||||
return '';
|
||||
}
|
||||
return typeof type === 'string' ? type : type?.name || '';
|
||||
}
|
||||
|
||||
function getTypeLabel(value) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
const parts = value.split('.');
|
||||
return parts.length ? parts[parts.length - 1] : value;
|
||||
}
|
||||
|
||||
function renderRepositoryOptions(selected) {
|
||||
const options = repositoryTypes.map(type => {
|
||||
const value = getTypeValue(type);
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
const isSelected = value === selected ? 'selected' : '';
|
||||
const label = getTypeLabel(value);
|
||||
return `<option value="${value}" ${isSelected}>${label}</option>`;
|
||||
}).join('');
|
||||
|
||||
return `<option value="" ${selected ? '' : 'selected'} disabled hidden>Выберите тип</option>${options}`;
|
||||
}
|
||||
|
||||
function createSubscriptionRow(subscription = {}, index = 0) {
|
||||
const name = subscription.name || '';
|
||||
const type = subscription.type || '';
|
||||
const source = subscription.source || '';
|
||||
const uid = `${Date.now()}-${index}`;
|
||||
|
||||
return `
|
||||
<div class="${CLASS_NAMES.subscriptionEntry}" style="border: 1px solid var(--color-border); padding: 20px; border-radius: 12px; margin-bottom: 20px; background: var(--color-surface, #1f2333);">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
|
||||
<h4 style="margin: 0;">Репозиторий</h4>
|
||||
<button type="button" class="btn-link ${CLASS_NAMES.subscriptionRemove}">Удалить</button>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="subscription-name-${uid}">Название</label>
|
||||
<input type="text" id="subscription-name-${uid}" class="form-control ${CLASS_NAMES.subscriptionName}" value="${name}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="subscription-type-${uid}">Тип</label>
|
||||
<select id="subscription-type-${uid}" class="form-control ${CLASS_NAMES.subscriptionType}">
|
||||
${renderRepositoryOptions(type)}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="subscription-source-${uid}">Источник</label>
|
||||
<input type="text" id="subscription-source-${uid}" class="form-control ${CLASS_NAMES.subscriptionSource}" value="${source}">
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function populateSubscriptions() {
|
||||
const $list = $(`#${FIELD_IDS.subscriptionsList}`);
|
||||
$list.empty();
|
||||
const subscriptions = Array.isArray(currentConfig.subscriptions) ? currentConfig.subscriptions : [];
|
||||
subscriptions.forEach((subscription, index) => {
|
||||
$list.append(createSubscriptionRow(subscription, index));
|
||||
});
|
||||
}
|
||||
|
||||
function textareaFromArray(array) {
|
||||
if (!Array.isArray(array) || !array.length) {
|
||||
return '';
|
||||
}
|
||||
return array.join('\n');
|
||||
}
|
||||
|
||||
function renderSettingsForm() {
|
||||
const $container = $(`#${FIELD_IDS.container}`);
|
||||
const updateSubscriptionsInterval = getConfigValue('updateSubscriptionsInterval', 1);
|
||||
const cachingAS = !!getConfigValue('cachingAS', false);
|
||||
const updateASInterval = getConfigValue('updateASInterval', 1);
|
||||
const host = getConfigValue('host', '');
|
||||
const cacheDirectory = getConfigValue('cacheDirectory', '');
|
||||
const httpPort = getConfigValue('httpPort', 8080);
|
||||
const mergeSubnets = !!getConfigValue('mergeSubnets', false);
|
||||
const displayDebuggingInfo = !!getConfigValue('displayDebuggingInfo', false);
|
||||
const mergeSubnetsWithUsage = getConfigValue('mergeSubnetsWithUsage', 80);
|
||||
|
||||
const customResources = getConfigValue('customResources', {});
|
||||
const filteredResources = getConfigValue('filteredResources', {});
|
||||
|
||||
$container.html(`
|
||||
<div class="component-config-form">
|
||||
<div class="action-buttons" style="justify-content: flex-start; margin-bottom: 30px;">
|
||||
<button id="${FIELD_IDS.changePasswordButton}" class="btn-secondary" style="width: 220px;">Сменить пароль</button>
|
||||
</div>
|
||||
<h3 class="config-section-title">Обновление ресурсов</h3>
|
||||
<div class="form-group">
|
||||
<label for="${FIELD_IDS.updateSubscriptionsInterval}">Интервал обновления подписок (часы)</label>
|
||||
<input type="number" min="1" max="24" id="${FIELD_IDS.updateSubscriptionsInterval}" class="form-control" value="${updateSubscriptionsInterval}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="${FIELD_IDS.updateASInterval}">Интервал обновления ASN (часы)</label>
|
||||
<input type="number" min="1" max="24" id="${FIELD_IDS.updateASInterval}" class="form-control" value="${updateASInterval}">
|
||||
</div>
|
||||
<div class="form-group checkbox-group">
|
||||
<input type="checkbox" id="${FIELD_IDS.cachingAS}" ${cachingAS ? 'checked' : ''}>
|
||||
<label for="${FIELD_IDS.cachingAS}" style="margin-bottom: 0;">Кэшировать ASN</label>
|
||||
</div>
|
||||
|
||||
<h3 class="config-section-title" style="margin-top: 40px;">Сервис</h3>
|
||||
<p class="hint-text" style="margin-bottom: 16px;">После изменения параметров сервиса необходимо перезапустить сервис на вкладке «Статистика», чтобы настройки вступили в силу.</p>
|
||||
<div class="form-group">
|
||||
<label for="${FIELD_IDS.host}">Хост сервиса</label>
|
||||
<input type="text" id="${FIELD_IDS.host}" class="form-control" value="${host}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="${FIELD_IDS.httpPort}">HTTP порт</label>
|
||||
<input type="number" min="1" max="65535" id="${FIELD_IDS.httpPort}" class="form-control" value="${httpPort}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="${FIELD_IDS.cacheDirectory}">Путь к кэшу</label>
|
||||
<input type="text" id="${FIELD_IDS.cacheDirectory}" class="form-control" value="${cacheDirectory}">
|
||||
</div>
|
||||
<div class="form-group checkbox-group">
|
||||
<input type="checkbox" id="${FIELD_IDS.displayDebuggingInfo}" ${displayDebuggingInfo ? 'checked' : ''}>
|
||||
<label for="${FIELD_IDS.displayDebuggingInfo}" style="margin-bottom: 0;">Отображать отладочную информацию</label>
|
||||
</div>
|
||||
|
||||
<h3 class="config-section-title" style="margin-top: 40px;">Сетевые ресурсы</h3>
|
||||
<div class="form-group checkbox-group">
|
||||
<input type="checkbox" id="${FIELD_IDS.mergeSubnets}" ${mergeSubnets ? 'checked' : ''}>
|
||||
<label for="${FIELD_IDS.mergeSubnets}" style="margin-bottom: 0;">Объединять подсети</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="${FIELD_IDS.mergeSubnetsWithUsage}">Объединять подсети с заполнением >= %</label>
|
||||
<input type="number" min="51" max="99" id="${FIELD_IDS.mergeSubnetsWithUsage}" class="form-control" value="${mergeSubnetsWithUsage}">
|
||||
</div>
|
||||
<div style="margin-top: 30px;">
|
||||
<h4 style="margin-bottom: 15px;">Дополнительные ресурсы</h4>
|
||||
<div class="form-group">
|
||||
<label for="${FIELD_IDS.customASN}">Пользовательские ASN</label>
|
||||
<textarea id="${FIELD_IDS.customASN}" class="form-control" rows="3" placeholder="Каждое значение с новой строки">${textareaFromArray(customResources.ASN)}</textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="${FIELD_IDS.customSubnets}">Пользовательские подсети</label>
|
||||
<textarea id="${FIELD_IDS.customSubnets}" class="form-control" rows="3" placeholder="Каждая подсеть с новой строки">${textareaFromArray(customResources.subnets)}</textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="${FIELD_IDS.customDomains}">Пользовательские домены</label>
|
||||
<textarea id="${FIELD_IDS.customDomains}" class="form-control" rows="3" placeholder="Каждый домен с новой строки">${textareaFromArray(customResources.domains)}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top: 30px;">
|
||||
<h4 style="margin-bottom: 15px;">Фильтр ресурсов</h4>
|
||||
<div class="form-group">
|
||||
<label for="${FIELD_IDS.filteredASN}">Исключаемые ASN</label>
|
||||
<textarea id="${FIELD_IDS.filteredASN}" class="form-control" rows="3">${textareaFromArray(filteredResources.ASN)}</textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="${FIELD_IDS.filteredSubnets}">Исключаемые подсети</label>
|
||||
<textarea id="${FIELD_IDS.filteredSubnets}" class="form-control" rows="3">${textareaFromArray(filteredResources.subnets)}</textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="${FIELD_IDS.filteredDomains}">Исключаемые домены</label>
|
||||
<textarea id="${FIELD_IDS.filteredDomains}" class="form-control" rows="3">${textareaFromArray(filteredResources.domains)}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="config-section-title" style="margin-top: 40px;">Репозитории подписок</h3>
|
||||
<div id="${FIELD_IDS.subscriptionsList}"></div>
|
||||
<button type="button" id="${FIELD_IDS.addSubscriptionButton}" class="btn-secondary" style="width: 220px;">Добавить репозиторий</button>
|
||||
|
||||
<div class="action-buttons" style="margin-top: 40px;">
|
||||
<button id="${FIELD_IDS.saveButton}" class="btn-primary" style="width: 240px;">Применить Настройки</button>
|
||||
</div>
|
||||
<div id="${FIELD_IDS.status}" class="error-message" style="display: none; margin-top: 20px;"></div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
populateSubscriptions();
|
||||
attachEventHandlers();
|
||||
}
|
||||
|
||||
function addSubscriptionRow(subscription = {}) {
|
||||
const $list = $(`#${FIELD_IDS.subscriptionsList}`);
|
||||
const index = $list.children(`.${CLASS_NAMES.subscriptionEntry}`).length;
|
||||
$list.append(createSubscriptionRow(subscription, index));
|
||||
}
|
||||
|
||||
function parseNumberInRange(value, min, max, fieldName) {
|
||||
const num = parseInt(value, 10);
|
||||
if (Number.isNaN(num) || num < min || num > max) {
|
||||
throw new Error(`${fieldName} должно быть числом от ${min} до ${max}.`);
|
||||
}
|
||||
return num;
|
||||
}
|
||||
|
||||
function parseTextAreaLines($element, transform) {
|
||||
const lines = $element.val().split('\n').map(line => line.trim()).filter(Boolean);
|
||||
if (transform) {
|
||||
return lines.map(value => transform(value));
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
function parseASNLines($element) {
|
||||
const lines = $element.val().split('\n').map(line => line.trim()).filter(Boolean);
|
||||
return lines.map(value => {
|
||||
const num = parseInt(value, 10);
|
||||
if (Number.isNaN(num) || num < 0) {
|
||||
throw new Error(`Некорректный ASN: ${value}`);
|
||||
}
|
||||
return num;
|
||||
});
|
||||
}
|
||||
|
||||
function collectSubscriptions() {
|
||||
const subscriptions = [];
|
||||
let hasEmptyType = false;
|
||||
$(`#${FIELD_IDS.subscriptionsList}`).find(`.${CLASS_NAMES.subscriptionEntry}`).each((_, element) => {
|
||||
const $entry = $(element);
|
||||
const name = $entry.find(`.${CLASS_NAMES.subscriptionName}`).val().trim();
|
||||
const type = $entry.find(`.${CLASS_NAMES.subscriptionType}`).val();
|
||||
const source = $entry.find(`.${CLASS_NAMES.subscriptionSource}`).val().trim();
|
||||
|
||||
if (!type) {
|
||||
hasEmptyType = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (name || type || source) {
|
||||
subscriptions.push({ name, type, source });
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (hasEmptyType) {
|
||||
throw new Error('Для каждого репозитория необходимо выбрать тип.');
|
||||
}
|
||||
|
||||
return subscriptions;
|
||||
}
|
||||
|
||||
function buildNetworkResourceBundle({ asnSelector, subnetsSelector, domainsSelector }) {
|
||||
const $asn = $(`#${asnSelector}`);
|
||||
const $subnets = $(`#${subnetsSelector}`);
|
||||
const $domains = $(`#${domainsSelector}`);
|
||||
|
||||
return {
|
||||
ASN: parseASNLines($asn),
|
||||
subnets: parseTextAreaLines($subnets),
|
||||
domains: parseTextAreaLines($domains)
|
||||
};
|
||||
}
|
||||
|
||||
function collectSettingsFromForm() {
|
||||
const updateSubscriptionsInterval = parseNumberInRange($(`#${FIELD_IDS.updateSubscriptionsInterval}`).val(), 1, 24, 'Интервал обновления подписок');
|
||||
const updateASInterval = parseNumberInRange($(`#${FIELD_IDS.updateASInterval}`).val(), 1, 24, 'Интервал обновления ASN');
|
||||
const mergeSubnetsWithUsage = parseNumberInRange($(`#${FIELD_IDS.mergeSubnetsWithUsage}`).val(), 51, 99, 'Процент объединения подсетей');
|
||||
|
||||
const httpPortValue = parseInt($(`#${FIELD_IDS.httpPort}`).val(), 10);
|
||||
if (Number.isNaN(httpPortValue) || httpPortValue < 1 || httpPortValue > 65535) {
|
||||
throw new Error('HTTP порт должен быть от 1 до 65535.');
|
||||
}
|
||||
|
||||
const subscriptions = collectSubscriptions();
|
||||
const customResources = buildNetworkResourceBundle({
|
||||
asnSelector: FIELD_IDS.customASN,
|
||||
subnetsSelector: FIELD_IDS.customSubnets,
|
||||
domainsSelector: FIELD_IDS.customDomains
|
||||
});
|
||||
const filteredResources = buildNetworkResourceBundle({
|
||||
asnSelector: FIELD_IDS.filteredASN,
|
||||
subnetsSelector: FIELD_IDS.filteredSubnets,
|
||||
domainsSelector: FIELD_IDS.filteredDomains
|
||||
});
|
||||
|
||||
return {
|
||||
...currentConfig,
|
||||
updateSubscriptionsInterval,
|
||||
cachingAS: $(`#${FIELD_IDS.cachingAS}`).prop('checked'),
|
||||
updateASInterval,
|
||||
host: $(`#${FIELD_IDS.host}`).val().trim(),
|
||||
cacheDirectory: $(`#${FIELD_IDS.cacheDirectory}`).val().trim(),
|
||||
httpPort: httpPortValue,
|
||||
mergeSubnets: $(`#${FIELD_IDS.mergeSubnets}`).prop('checked'),
|
||||
displayDebuggingInfo: $(`#${FIELD_IDS.displayDebuggingInfo}`).prop('checked'),
|
||||
mergeSubnetsWithUsage,
|
||||
subscriptions,
|
||||
customResources,
|
||||
filteredResources
|
||||
};
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
const $button = $(`#${FIELD_IDS.saveButton}`);
|
||||
|
||||
let newConfig;
|
||||
try {
|
||||
newConfig = collectSettingsFromForm();
|
||||
} catch (error) {
|
||||
updateStatus(error.message || 'Ошибка подготовки настроек.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
await runAction($button, 'Применение...', async () => {
|
||||
await JSONRPC.System.setConfig(newConfig);
|
||||
currentConfig = newConfig;
|
||||
}, {
|
||||
success: 'Настройки успешно сохранены.',
|
||||
error: 'Ошибка при сохранении настроек.',
|
||||
log: 'Ошибка сохранения настроек'
|
||||
});
|
||||
}
|
||||
|
||||
async function handleChangePassword() {
|
||||
const newPassword = prompt('Введите новый пароль администратора:');
|
||||
if (newPassword === null) {
|
||||
return;
|
||||
}
|
||||
if (!newPassword.trim()) {
|
||||
updateStatus('Пароль не может быть пустым.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const $button = $(`#${FIELD_IDS.changePasswordButton}`);
|
||||
await runAction($button, 'Сохранение...', async () => {
|
||||
await JSONRPC.Auth.changePassword(newPassword.trim());
|
||||
}, {
|
||||
success: 'Пароль успешно изменён.',
|
||||
error: 'Ошибка при смене пароля.',
|
||||
log: 'Ошибка смены пароля'
|
||||
});
|
||||
}
|
||||
|
||||
function attachEventHandlers() {
|
||||
$(`#${FIELD_IDS.saveButton}`).off('click').on('click', handleSave);
|
||||
$(`#${FIELD_IDS.addSubscriptionButton}`).off('click').on('click', () => addSubscriptionRow());
|
||||
$(`#${FIELD_IDS.subscriptionsList}`).off('click', `.${CLASS_NAMES.subscriptionRemove}`).on('click', `.${CLASS_NAMES.subscriptionRemove}`, function (event) {
|
||||
event.preventDefault();
|
||||
$(this).closest(`.${CLASS_NAMES.subscriptionEntry}`).remove();
|
||||
});
|
||||
$(`#${FIELD_IDS.changePasswordButton}`).off('click').on('click', handleChangePassword);
|
||||
}
|
||||
|
||||
function detachEventHandlers() {
|
||||
$(`#${FIELD_IDS.saveButton}`).off('click');
|
||||
$(`#${FIELD_IDS.addSubscriptionButton}`).off('click');
|
||||
$(`#${FIELD_IDS.subscriptionsList}`).off('click', `.${CLASS_NAMES.subscriptionRemove}`);
|
||||
$(`#${FIELD_IDS.changePasswordButton}`).off('click');
|
||||
}
|
||||
|
||||
export const SettingsPage = {
|
||||
render: () => `
|
||||
<h1 class="page-title">Системные Настройки</h1>
|
||||
<div id="${FIELD_IDS.container}">
|
||||
<p>Загрузка настроек...</p>
|
||||
</div>
|
||||
`,
|
||||
mount: async () => {
|
||||
const success = await loadSettings();
|
||||
if (success) {
|
||||
renderSettingsForm();
|
||||
} else {
|
||||
$(`#${FIELD_IDS.container}`).html('<p class="error-message">Не удалось загрузить настройки.</p>');
|
||||
}
|
||||
},
|
||||
unmount: () => {
|
||||
detachEventHandlers();
|
||||
clearStatus();
|
||||
currentConfig = {};
|
||||
repositoryTypes = [];
|
||||
}
|
||||
};
|
||||
|
|
@ -3,10 +3,11 @@ import { JSONRPC } from '@/json-rpc.js';
|
|||
|
||||
const POLLING_INTERVAL = 5000;
|
||||
let updateInterval = null;
|
||||
let configChanged = false;
|
||||
|
||||
const $content = () => $('#statistics-content');
|
||||
|
||||
const createStatusCard = (title, status, buttons = [], resourceStatsHtml = '', customStatusContent = null) => {
|
||||
const createStatusCard = (title, status, buttons = [], resourceStatsHtml = '', customStatusContent = null, extraClass = '') => {
|
||||
const warningStatuses = ['Обновляется', 'Обнаружено', 'Готово к установке'];
|
||||
const statusClass = warningStatuses.includes(status) ? 'text-yellow-500' : 'text-green-500';
|
||||
const statusSection = customStatusContent || `
|
||||
|
|
@ -23,7 +24,7 @@ const createStatusCard = (title, status, buttons = [], resourceStatsHtml = '', c
|
|||
: '';
|
||||
|
||||
return `
|
||||
<div class="stat-card">
|
||||
<div class="stat-card ${extraClass}">
|
||||
<h3>${title}</h3>
|
||||
${statusSection}
|
||||
${buttonsHtml}
|
||||
|
|
@ -85,7 +86,7 @@ async function renderSystemAndManagerStatus() {
|
|||
const availableVersion = (versionInfo && versionInfo.available) || '';
|
||||
const currentVersion = (versionInfo && versionInfo.current) || '';
|
||||
const downloadedVersion = (versionInfo && versionInfo.downloaded) || '';
|
||||
const isConfigChanged = await JSONRPC.System.isConfigChanged();
|
||||
configChanged = await JSONRPC.System.isConfigChanged();
|
||||
const hasAvailableUpdate = availableVersion && availableVersion !== currentVersion;
|
||||
const isDownloaded = hasAvailableUpdate && downloadedVersion === availableVersion;
|
||||
const updateButtonDisabled = !hasAvailableUpdate || isDownloaded;
|
||||
|
|
@ -103,8 +104,8 @@ async function renderSystemAndManagerStatus() {
|
|||
<span>Текущая версия: <span class="${hasAvailableUpdate ? 'text-yellow-500' : 'text-green-500'}">${currentVersion || '-'}</span></span><br />
|
||||
<span class="last-version">Последняя версия: <span class="${hasAvailableUpdate ? 'text-yellow-500' : 'text-green-500'}">${availableVersion || '-'}</span></span>
|
||||
</div>
|
||||
<p class="status-line ${isConfigChanged ? 'text-yellow-500' : 'text-green-500'}">
|
||||
Конфигурация: ${isConfigChanged ? 'изменена' : 'актуальна'}
|
||||
<p class="status-line ${configChanged ? 'text-yellow-500' : 'text-green-500'}">
|
||||
Конфигурация: ${configChanged ? 'изменена' : 'актуальна'}
|
||||
</p>
|
||||
`;
|
||||
const isSubUpdating = await JSONRPC.SubscriptionManager.isUpdating();
|
||||
|
|
@ -128,13 +129,14 @@ async function renderSystemAndManagerStatus() {
|
|||
},
|
||||
{
|
||||
id: 'save-config-btn',
|
||||
label: 'Сохранить',
|
||||
disabled: !isConfigChanged,
|
||||
label: 'Сохранить настройки',
|
||||
disabled: !configChanged,
|
||||
loadingLabel: 'Сохранение...'
|
||||
}
|
||||
],
|
||||
'',
|
||||
versionStatusHtml
|
||||
versionStatusHtml,
|
||||
configChanged ? 'stat-card-highlight' : ''
|
||||
));
|
||||
|
||||
const subStatsHtml = `<div class="resource-group" style="margin-top: 20px;">
|
||||
|
|
@ -263,7 +265,7 @@ function attachEventHandlers() {
|
|||
await renderSystemAndManagerStatus();
|
||||
} catch (e) {
|
||||
alert('Ошибка при сохранении конфигурации!');
|
||||
$btn.prop('disabled', false).text('Сохранить');
|
||||
$btn.prop('disabled', false).text('Сохранить настройки');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,262 @@
|
|||
import $ from 'jquery';
|
||||
import { JSONRPC } from '@/json-rpc.js';
|
||||
|
||||
const TDNS_COMPONENT_NAME = 'ru.kirillius.pf.sdn.External.API.Components.TDNS';
|
||||
|
||||
const FIELD_IDS = {
|
||||
container: 'tdns-config-container',
|
||||
instancesList: 'tdns-instances-list',
|
||||
addInstanceButton: 'tdns-add-instance-btn',
|
||||
saveButton: 'save-tdns-btn',
|
||||
status: 'tdns-status-message'
|
||||
};
|
||||
|
||||
const CLASS_NAMES = {
|
||||
instance: 'tdns-instance-entry',
|
||||
removeButton: 'tdns-remove-instance-btn',
|
||||
serverInput: 'tdns-server-input',
|
||||
tokenInput: 'tdns-token-input',
|
||||
forwarderInput: 'tdns-forwarder-input'
|
||||
};
|
||||
|
||||
const SELECTORS = {
|
||||
container: `#${FIELD_IDS.container}`,
|
||||
instancesList: `#${FIELD_IDS.instancesList}`,
|
||||
addInstanceButton: `#${FIELD_IDS.addInstanceButton}`,
|
||||
saveButton: `#${FIELD_IDS.saveButton}`,
|
||||
status: `#${FIELD_IDS.status}`
|
||||
};
|
||||
|
||||
let currentConfig = { instances: [] };
|
||||
let statusTimeoutId = null;
|
||||
let instanceCounter = 0;
|
||||
|
||||
const getStatusElement = () => $(SELECTORS.status);
|
||||
|
||||
function normalizeConfig(config) {
|
||||
if (!config || !Array.isArray(config.instances)) {
|
||||
return { instances: [] };
|
||||
}
|
||||
return {
|
||||
instances: config.instances.map(instance => ({
|
||||
server: instance?.server || '',
|
||||
token: instance?.token || '',
|
||||
forwarder: instance?.forwarder || ''
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
function clearStatus() {
|
||||
const $status = getStatusElement();
|
||||
if (!$status.length) {
|
||||
return;
|
||||
}
|
||||
if (statusTimeoutId) {
|
||||
clearTimeout(statusTimeoutId);
|
||||
statusTimeoutId = null;
|
||||
}
|
||||
$status.stop(true, true).hide().text('').removeClass('success-message error-message');
|
||||
}
|
||||
|
||||
function updateStatus(message, type) {
|
||||
const $status = getStatusElement();
|
||||
if (!$status.length) {
|
||||
return;
|
||||
}
|
||||
if (statusTimeoutId) {
|
||||
clearTimeout(statusTimeoutId);
|
||||
}
|
||||
$status
|
||||
.removeClass('success-message error-message')
|
||||
.addClass(type === 'success' ? 'success-message' : 'error-message')
|
||||
.text(message)
|
||||
.show();
|
||||
|
||||
statusTimeoutId = window.setTimeout(() => {
|
||||
$status.fadeOut();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
async function runAction($button, pendingText, action, messages) {
|
||||
if (!$button.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const originalText = $button.text();
|
||||
$button.prop('disabled', true).text(pendingText);
|
||||
clearStatus();
|
||||
|
||||
try {
|
||||
const result = await action();
|
||||
if (messages?.success) {
|
||||
const successMessage = typeof messages.success === 'function'
|
||||
? messages.success(result)
|
||||
: messages.success;
|
||||
if (successMessage) {
|
||||
updateStatus(successMessage, 'success');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(messages?.log || 'Ошибка выполнения действия TDNS:', error);
|
||||
if (messages?.error) {
|
||||
updateStatus(messages.error, 'error');
|
||||
}
|
||||
} finally {
|
||||
$button.prop('disabled', false).text(originalText);
|
||||
}
|
||||
}
|
||||
|
||||
function resetInstanceCounter() {
|
||||
instanceCounter = 0;
|
||||
}
|
||||
|
||||
function getNextInstanceId() {
|
||||
instanceCounter += 1;
|
||||
return instanceCounter;
|
||||
}
|
||||
|
||||
function createInstanceRow(instance = {}) {
|
||||
const uid = getNextInstanceId();
|
||||
const serverId = `tdns-server-${uid}`;
|
||||
const tokenId = `tdns-token-${uid}`;
|
||||
const forwarderId = `tdns-forwarder-${uid}`;
|
||||
|
||||
return `
|
||||
<div class="${CLASS_NAMES.instance}" style="border: 1px solid var(--color-border); padding: 20px; border-radius: 12px; margin-bottom: 20px; background: var(--color-surface, #1f2333);">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
|
||||
<h4 style="margin: 0;">Инстанс</h4>
|
||||
<button type="button" class="btn-link ${CLASS_NAMES.removeButton}">Удалить</button>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="${serverId}">TDNS сервер</label>
|
||||
<input type="text" id="${serverId}" class="form-control ${CLASS_NAMES.serverInput}" placeholder="https://tdns.example.com" value="${instance.server || ''}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="${tokenId}">Токен доступа</label>
|
||||
<input type="text" id="${tokenId}" class="form-control ${CLASS_NAMES.tokenInput}" placeholder="Сгенерируйте токен на странице API" value="${instance.token || ''}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="${forwarderId}">Forwarder</label>
|
||||
<input type="text" id="${forwarderId}" class="form-control ${CLASS_NAMES.forwarderInput}" placeholder="http://127.0.0.1:8080" value="${instance.forwarder || ''}">
|
||||
<small class="hint-text">Адрес DNS сервера, на который будут перенаправляться запросы.</small>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function populateInstances() {
|
||||
const instances = currentConfig.instances.length ? currentConfig.instances : [];
|
||||
const $list = $(SELECTORS.instancesList);
|
||||
resetInstanceCounter();
|
||||
$list.empty();
|
||||
instances.forEach(instance => {
|
||||
$list.append(createInstanceRow(instance));
|
||||
});
|
||||
}
|
||||
|
||||
function renderTDNSForm() {
|
||||
const $container = $(SELECTORS.container);
|
||||
$container.html(`
|
||||
<div class="component-config-form">
|
||||
<h3 class="config-section-title">Инстансы TDNS</h3>
|
||||
<p class="hint-text" style="margin-bottom: 20px;">Настройте подключения TDNS. Можно указать несколько серверов с собственными токенами.</p>
|
||||
<div id="${FIELD_IDS.instancesList}"></div>
|
||||
<div class="form-group" style="margin-top: 10px;">
|
||||
<button type="button" id="${FIELD_IDS.addInstanceButton}" class="btn-secondary" style="width: 220px;">Добавить инстанс</button>
|
||||
</div>
|
||||
<div class="action-buttons" style="margin-top: 40px;">
|
||||
<button id="${FIELD_IDS.saveButton}" class="btn-primary" style="width: 220px;">Применить Конфигурацию</button>
|
||||
</div>
|
||||
<div id="${FIELD_IDS.status}" class="error-message" style="display: none; margin-top: 20px;"></div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
populateInstances();
|
||||
attachEventHandlers();
|
||||
}
|
||||
|
||||
function collectConfigFromForm() {
|
||||
const instances = [];
|
||||
$(SELECTORS.instancesList).find(`.${CLASS_NAMES.instance}`).each((_, element) => {
|
||||
const $row = $(element);
|
||||
const server = $row.find(`.${CLASS_NAMES.serverInput}`).val().trim();
|
||||
const token = $row.find(`.${CLASS_NAMES.tokenInput}`).val().trim();
|
||||
const forwarder = $row.find(`.${CLASS_NAMES.forwarderInput}`).val().trim();
|
||||
|
||||
if (server || token || forwarder) {
|
||||
instances.push({ server, token, forwarder });
|
||||
}
|
||||
});
|
||||
|
||||
return { instances };
|
||||
}
|
||||
|
||||
async function loadConfig() {
|
||||
try {
|
||||
const fullConfig = await JSONRPC.System.getComponentConfig(TDNS_COMPONENT_NAME);
|
||||
currentConfig = normalizeConfig(fullConfig);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Ошибка при загрузке конфига TDNS:', error);
|
||||
currentConfig = { instances: [] };
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleAddInstance() {
|
||||
const $list = $(SELECTORS.instancesList);
|
||||
$list.append(createInstanceRow({}));
|
||||
}
|
||||
|
||||
function handleRemoveInstance(event) {
|
||||
event.preventDefault();
|
||||
$(event.currentTarget).closest(`.${CLASS_NAMES.instance}`).remove();
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
const $button = $(SELECTORS.saveButton);
|
||||
await runAction($button, 'Применение...', async () => {
|
||||
const newConfig = collectConfigFromForm();
|
||||
await JSONRPC.System.setComponentConfig(TDNS_COMPONENT_NAME, newConfig);
|
||||
currentConfig = normalizeConfig(newConfig);
|
||||
populateInstances();
|
||||
}, {
|
||||
success: 'Конфигурация TDNS успешно сохранена.',
|
||||
error: 'Ошибка при сохранении конфигурации TDNS.',
|
||||
log: 'Ошибка сохранения конфига TDNS'
|
||||
});
|
||||
}
|
||||
|
||||
function attachEventHandlers() {
|
||||
$(SELECTORS.saveButton).off('click').on('click', handleSave);
|
||||
$(SELECTORS.addInstanceButton).off('click').on('click', handleAddInstance);
|
||||
$(SELECTORS.instancesList).off('click', `.${CLASS_NAMES.removeButton}`).on('click', `.${CLASS_NAMES.removeButton}`, handleRemoveInstance);
|
||||
}
|
||||
|
||||
function detachEventHandlers() {
|
||||
$(SELECTORS.saveButton).off('click');
|
||||
$(SELECTORS.addInstanceButton).off('click');
|
||||
$(SELECTORS.instancesList).off('click', `.${CLASS_NAMES.removeButton}`);
|
||||
}
|
||||
|
||||
export const TDNSConfig = {
|
||||
render: () => `
|
||||
<h1 class="page-title">Настройка TDNS</h1>
|
||||
<div id="${FIELD_IDS.container}">
|
||||
<p>Загрузка конфигурации...</p>
|
||||
</div>
|
||||
`,
|
||||
mount: async () => {
|
||||
const success = await loadConfig();
|
||||
if (success) {
|
||||
renderTDNSForm();
|
||||
} else {
|
||||
$(SELECTORS.container).html('<p class="error-message">Не удалось загрузить конфигурацию TDNS.</p>');
|
||||
}
|
||||
},
|
||||
unmount: () => {
|
||||
detachEventHandlers();
|
||||
clearStatus();
|
||||
currentConfig = { instances: [] };
|
||||
}
|
||||
};
|
||||
|
|
@ -128,6 +128,7 @@ html.dark-theme, body {
|
|||
text-decoration: none;
|
||||
transition: background-color 0.2s, color 0.2s;
|
||||
font-size: 15px;
|
||||
position: relative;
|
||||
}
|
||||
.menu-item:hover {
|
||||
background-color: #2e3a4e; /* Чуть светлее, чем сайдбар */
|
||||
|
|
@ -137,6 +138,16 @@ html.dark-theme, body {
|
|||
background-color: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.menu-indicator {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background-color: #ef4444;
|
||||
border-radius: 50%;
|
||||
margin-left: 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.logout-btn {
|
||||
margin-top: 15px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
|
|
@ -178,6 +189,11 @@ html.dark-theme, body {
|
|||
transition: box-shadow 0.3s;
|
||||
}
|
||||
|
||||
.stat-card-highlight {
|
||||
border-color: #ef4444;
|
||||
box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
|
@ -228,6 +244,57 @@ html.dark-theme, body {
|
|||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
.btn-link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-error);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: var(--border-radius);
|
||||
transition: color 0.2s, background-color 0.2s;
|
||||
}
|
||||
|
||||
.btn-link:hover:not(:disabled) {
|
||||
color: #f87171;
|
||||
background-color: rgba(239, 68, 68, 0.12);
|
||||
}
|
||||
|
||||
.btn-link:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
background-color: transparent;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.log-entry .log-entry-level {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.log-entry .log-entry-message {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.log-level-info {
|
||||
background-color: rgba(59, 130, 246, 0.08);
|
||||
}
|
||||
|
||||
.log-level-warning {
|
||||
background-color: rgba(245, 158, 11, 0.12);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.log-level-severe {
|
||||
background-color: rgba(239, 68, 68, 0.16);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* --- Стили для страницы Статистики (Дополнение: Ресурсы) --- */
|
||||
|
||||
.resource-group {
|
||||
|
|
|
|||
|
|
@ -24,6 +24,10 @@ export default defineConfig({
|
|||
path.resolve(__dirname, 'node_modules')
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
build: {
|
||||
outDir: path.resolve(__dirname, '../app/src/main/resources/htdocs')
|
||||
}
|
||||
// -----------------------------------------------------------------
|
||||
})
|
||||
Loading…
Reference in New Issue