Добавил новый функционал в вэбку

This commit is contained in:
kirillius 2025-10-06 14:22:04 +03:00
parent d9aec2e986
commit c072256b33
18 changed files with 1635 additions and 1868 deletions

2
.gitignore vendored
View File

@ -43,3 +43,5 @@ build/
/.cache/
ovpn-connector.json
/webui/src/json-rpc.js
app/src/main/resources/htdocs/
*.pfapp

View File

@ -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">

1826
webui/package-lock.json generated

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

BIN
webui/public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

BIN
webui/public/icon100.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@ -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';

View File

@ -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
}
};

View File

@ -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();

View File

@ -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);
}
});
}

448
webui/src/pages/FRR.js Normal file
View File

@ -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: [] };
}
};

215
webui/src/pages/Logs.js Normal file
View File

@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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;
}
}
};

View File

@ -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 = {

494
webui/src/pages/Settings.js Normal file
View File

@ -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}">Объединять подсети с заполнением &gt;= %</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 = [];
}
};

View File

@ -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('Сохранить настройки');
}
});
}

262
webui/src/pages/TDNS.js Normal file
View File

@ -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: [] };
}
};

View File

@ -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 {

View File

@ -24,6 +24,10 @@ export default defineConfig({
path.resolve(__dirname, 'node_modules')
]
}
},
build: {
outDir: path.resolve(__dirname, '../app/src/main/resources/htdocs')
}
// -----------------------------------------------------------------
})