Добавил новый функционал в вэбку
This commit is contained in:
parent
d9aec2e986
commit
c072256b33
|
|
@ -43,3 +43,5 @@ build/
|
||||||
/.cache/
|
/.cache/
|
||||||
ovpn-connector.json
|
ovpn-connector.json
|
||||||
/webui/src/json-rpc.js
|
/webui/src/json-rpc.js
|
||||||
|
app/src/main/resources/htdocs/
|
||||||
|
*.pfapp
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,9 @@
|
||||||
<html lang="en" class="dark-theme">
|
<html lang="en" class="dark-theme">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<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">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>SDN Control</title>
|
<title>pfSDN</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app">
|
<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';
|
import { JSONRPC } from '@/json-rpc.js';
|
||||||
|
|
||||||
// 🔥 Указываем URL сервера, как ты и сделал
|
// 🔥 Указываем URL сервера, как ты и сделал
|
||||||
JSONRPC.url = "http://localhost:8080" + JSONRPC.url;
|
//JSONRPC.url = "http://localhost:8080" + JSONRPC.url;
|
||||||
|
|
||||||
// 2. Импорт главного модуля приложения
|
// 2. Импорт главного модуля приложения
|
||||||
import { initApp } from './modules/app.js';
|
import { initApp } from './modules/app.js';
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,10 @@ import { Components } from '../pages/Components.js';
|
||||||
import { APITokens } from '../pages/APITokens.js';
|
import { APITokens } from '../pages/APITokens.js';
|
||||||
import { getEnabledComponents } from './app.js';
|
import { getEnabledComponents } from './app.js';
|
||||||
import { OVPNConfig } from '../pages/OVPN.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)
|
// Переменная для отслеживания текущего активного хеша (для корректного unmount)
|
||||||
|
|
@ -21,6 +25,9 @@ const allMenuItems = [
|
||||||
{ label: 'Компоненты', path: 'components', component: null },
|
{ label: 'Компоненты', path: 'components', component: null },
|
||||||
{ label: 'API', path: 'api', component: null },
|
{ label: 'API', path: 'api', component: null },
|
||||||
{ label: 'Настройка OVPN', path: 'ovpn', component: 'ru.kirillius.pf.sdn.External.API.Components.OVPN' },
|
{ 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. Определение страниц
|
// 2. Определение страниц
|
||||||
|
|
@ -36,9 +43,9 @@ const routes = {
|
||||||
unmount: Subscriptions.unmount
|
unmount: Subscriptions.unmount
|
||||||
},
|
},
|
||||||
'#settings': {
|
'#settings': {
|
||||||
render: () => '<h1 class="page-title">Системные Настройки</h1><p>Конфигурация сети, пользователя и безопасности.</p>',
|
render: SettingsPage.render,
|
||||||
mount: () => {},
|
mount: SettingsPage.mount,
|
||||||
unmount: () => {}
|
unmount: SettingsPage.unmount
|
||||||
},
|
},
|
||||||
'#components': {
|
'#components': {
|
||||||
render: Components.render,
|
render: Components.render,
|
||||||
|
|
@ -54,6 +61,21 @@ const routes = {
|
||||||
render: OVPNConfig.render,
|
render: OVPNConfig.render,
|
||||||
mount: OVPNConfig.mount,
|
mount: OVPNConfig.mount,
|
||||||
unmount: OVPNConfig.unmount
|
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 { handleLogin, handleLogout } from './auth.js';
|
||||||
import { setAuthenticated } from './app.js';
|
import { setAuthenticated } from './app.js';
|
||||||
import { renderPage, getFilteredMenuItems } from './router.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 id="login-container" class="full-screen flex-center">
|
||||||
<div class="card" style="width: 380px;">
|
<div class="card" style="width: 380px;">
|
||||||
<h2 style="margin-top: 0; color: var(--color-text);">Авторизация</h2>
|
<h2 style="margin-top: 0; color: var(--color-text);">Авторизация</h2>
|
||||||
<nav id="main-nav">
|
<form id="login-form">
|
||||||
${sidebarHtml}
|
<div class="form-group">
|
||||||
<a href="#" id="logout-btn" class="menu-item logout-btn">Выход</a>
|
|
||||||
</nav>
|
|
||||||
<label for="password-input">Пароль</label>
|
<label for="password-input">Пароль</label>
|
||||||
<input type="password" id="password-input" class="form-control" placeholder="Введите пароль">
|
<input type="password" id="password-input" class="form-control" placeholder="Введите пароль">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group" style="display: flex; align-items: center;">
|
<div class="form-group" style="display: flex; align-items: center;">
|
||||||
<input type="checkbox" id="remember-me" style="margin-right: 10px;">
|
<input type="checkbox" id="remember-me" style="margin-right: 10px;">
|
||||||
<label for="remember-me" style="margin-bottom: 0; font-weight: normal; cursor: pointer;">Запомнить меня</label>
|
<label for="remember-me" style="margin-bottom: 0; font-weight: normal; cursor: pointer;">Запомнить меня</label>
|
||||||
|
|
@ -63,18 +63,52 @@ export function renderLoginForm() {
|
||||||
|
|
||||||
|
|
||||||
// Функция рендеринга рабочего стола (Dashboard)
|
// Функция рендеринга рабочего стола (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 menuItems = getFilteredMenuItems();
|
||||||
|
const configChanged = await fetchConfigChanged();
|
||||||
|
|
||||||
const sidebarHtml = menuItems.map(item => {
|
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('');
|
}).join('');
|
||||||
|
|
||||||
const html = `
|
const html = `
|
||||||
<div id="dashboard-container" style="display: flex; min-height: 100vh;">
|
<div id="dashboard-container" style="display: flex; min-height: 100vh;">
|
||||||
<div id="sidebar" class="sidebar">
|
<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">
|
<nav id="main-nav">
|
||||||
${sidebarHtml}
|
${sidebarHtml}
|
||||||
<a href="#" id="logout-btn" class="menu-item logout-btn">Выход</a>
|
<a href="#" id="logout-btn" class="menu-item logout-btn">Выход</a>
|
||||||
|
|
@ -88,6 +122,11 @@ export function renderDashboard() {
|
||||||
|
|
||||||
$('#app').html(html);
|
$('#app').html(html);
|
||||||
|
|
||||||
|
if (configIndicatorInterval) {
|
||||||
|
clearInterval(configIndicatorInterval);
|
||||||
|
}
|
||||||
|
configIndicatorInterval = setInterval(updateStatisticsIndicator, 3000);
|
||||||
|
|
||||||
// Прикрепляем события для выхода
|
// Прикрепляем события для выхода
|
||||||
$('#logout-btn').on('click', async function(e) {
|
$('#logout-btn').on('click', async function(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
|
||||||
|
|
@ -74,13 +74,21 @@ function attachEventHandlers() {
|
||||||
try {
|
try {
|
||||||
await JSONRPC.System.setEnabledComponents(newEnabledComponents);
|
await JSONRPC.System.setEnabledComponents(newEnabledComponents);
|
||||||
enabledComponents = newEnabledComponents;
|
enabledComponents = newEnabledComponents;
|
||||||
$message.text('Настройки компонентов успешно сохранены!').addClass('success-message').show();
|
$message.text('Настройки компонентов успешно сохранены! Перезагрузка...').addClass('success-message').show();
|
||||||
|
window.setTimeout(() => {
|
||||||
|
window.location.reload();
|
||||||
|
}, 800);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Ошибка сохранения компонентов:', e);
|
console.error('Ошибка сохранения компонентов:', e);
|
||||||
$message.text('Ошибка при сохранении компонентов.').show();
|
$message.text('Ошибка при сохранении компонентов.').show();
|
||||||
} finally {
|
} finally {
|
||||||
$btn.prop('disabled', false).text('Сохранить');
|
$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',
|
password: 'ovpn-password',
|
||||||
useSSH: 'ovpn-use-ssh',
|
useSSH: 'ovpn-use-ssh',
|
||||||
restartCommand: 'ovpn-restart-command',
|
restartCommand: 'ovpn-restart-command',
|
||||||
|
restartOnUpdate: 'ovpn-restart-on-update',
|
||||||
saveButton: 'save-ovpn-btn',
|
saveButton: 'save-ovpn-btn',
|
||||||
restartButton: 'restart-ovpn-btn',
|
restartButton: 'restart-ovpn-btn',
|
||||||
status: 'ovpn-status-message',
|
status: 'ovpn-status-message',
|
||||||
|
|
@ -25,11 +26,16 @@ const SELECTORS = {
|
||||||
password: `#${FIELD_IDS.password}`,
|
password: `#${FIELD_IDS.password}`,
|
||||||
useSSH: `#${FIELD_IDS.useSSH}`,
|
useSSH: `#${FIELD_IDS.useSSH}`,
|
||||||
restartCommand: `#${FIELD_IDS.restartCommand}`,
|
restartCommand: `#${FIELD_IDS.restartCommand}`,
|
||||||
|
restartOnUpdate: `#${FIELD_IDS.restartOnUpdate}`,
|
||||||
saveButton: `#${FIELD_IDS.saveButton}`,
|
saveButton: `#${FIELD_IDS.saveButton}`,
|
||||||
restartButton: `#${FIELD_IDS.restartButton}`,
|
restartButton: `#${FIELD_IDS.restartButton}`,
|
||||||
status: `#${FIELD_IDS.status}`
|
status: `#${FIELD_IDS.status}`
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const CLASS_NAMES = {
|
||||||
|
shellFields: 'ovpn-shell-fields'
|
||||||
|
};
|
||||||
|
|
||||||
let currentConfig = {};
|
let currentConfig = {};
|
||||||
let statusTimeoutId = null;
|
let statusTimeoutId = null;
|
||||||
|
|
||||||
|
|
@ -109,6 +115,7 @@ async function loadConfig() {
|
||||||
function renderOVPNForm() {
|
function renderOVPNForm() {
|
||||||
const $container = $(SELECTORS.container);
|
const $container = $(SELECTORS.container);
|
||||||
const shellConfig = currentConfig.shellConfig || {};
|
const shellConfig = currentConfig.shellConfig || {};
|
||||||
|
const shellFieldsStyle = shellConfig.useSSH ? '' : 'display: none;';
|
||||||
|
|
||||||
$container.html(`
|
$container.html(`
|
||||||
<div class="component-config-form">
|
<div class="component-config-form">
|
||||||
|
|
@ -117,21 +124,23 @@ function renderOVPNForm() {
|
||||||
<input type="checkbox" id="${FIELD_IDS.useSSH}" ${shellConfig.useSSH ? 'checked' : ''}>
|
<input type="checkbox" id="${FIELD_IDS.useSSH}" ${shellConfig.useSSH ? 'checked' : ''}>
|
||||||
<label for="${FIELD_IDS.useSSH}" style="margin-bottom: 0;">Использовать SSH</label>
|
<label for="${FIELD_IDS.useSSH}" style="margin-bottom: 0;">Использовать SSH</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="${CLASS_NAMES.shellFields}" style="${shellFieldsStyle}">
|
||||||
<label for="${FIELD_IDS.host}">Хост</label>
|
<div class="form-group">
|
||||||
<input type="text" id="${FIELD_IDS.host}" class="form-control" value="${shellConfig.host || ''}">
|
<label for="${FIELD_IDS.host}">Хост</label>
|
||||||
</div>
|
<input type="text" id="${FIELD_IDS.host}" class="form-control" value="${shellConfig.host || ''}">
|
||||||
<div class="form-group">
|
</div>
|
||||||
<label for="${FIELD_IDS.port}">Порт</label>
|
<div class="form-group">
|
||||||
<input type="number" id="${FIELD_IDS.port}" class="form-control" value="${shellConfig.port || DEFAULT_PORT}">
|
<label for="${FIELD_IDS.port}">Порт</label>
|
||||||
</div>
|
<input type="number" id="${FIELD_IDS.port}" class="form-control" value="${shellConfig.port || DEFAULT_PORT}">
|
||||||
<div class="form-group">
|
</div>
|
||||||
<label for="${FIELD_IDS.username}">Имя пользователя</label>
|
<div class="form-group">
|
||||||
<input type="text" id="${FIELD_IDS.username}" class="form-control" value="${shellConfig.username || ''}">
|
<label for="${FIELD_IDS.username}">Имя пользователя</label>
|
||||||
</div>
|
<input type="text" id="${FIELD_IDS.username}" class="form-control" value="${shellConfig.username || ''}">
|
||||||
<div class="form-group">
|
</div>
|
||||||
<label for="${FIELD_IDS.password}">Пароль</label>
|
<div class="form-group">
|
||||||
<input type="password" id="${FIELD_IDS.password}" class="form-control" placeholder="Оставьте пустым для сохранения текущего пароля">
|
<label for="${FIELD_IDS.password}">Пароль</label>
|
||||||
|
<input type="password" id="${FIELD_IDS.password}" class="form-control" placeholder="Оставьте пустым для сохранения текущего пароля">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 class="config-section-title" style="margin-top: 40px;">Перезапуск сервиса</h3>
|
<h3 class="config-section-title" style="margin-top: 40px;">Перезапуск сервиса</h3>
|
||||||
|
|
@ -140,12 +149,28 @@ function renderOVPNForm() {
|
||||||
<input type="text" id="${FIELD_IDS.restartCommand}" class="form-control" value="${currentConfig.restartCommand || 'systemctl restart openvpn@server'}">
|
<input type="text" id="${FIELD_IDS.restartCommand}" class="form-control" value="${currentConfig.restartCommand || 'systemctl restart openvpn@server'}">
|
||||||
<small class="hint-text">Команда, которая будет выполнена через Shell для перезапуска сервиса OVPN.</small>
|
<small class="hint-text">Команда, которая будет выполнена через Shell для перезапуска сервиса OVPN.</small>
|
||||||
</div>
|
</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">
|
<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>
|
<button id="${FIELD_IDS.restartButton}" class="btn-secondary" style="width: 200px; margin-left: 20px;">Перезапустить Сервис</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="${FIELD_IDS.status}" class="error-message" style="display: none;"></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>
|
</div>
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
|
@ -165,13 +190,14 @@ function collectConfigFromForm() {
|
||||||
username: $(SELECTORS.username).val(),
|
username: $(SELECTORS.username).val(),
|
||||||
password: newPassword ? newPassword : (shellConfig.password || ''),
|
password: newPassword ? newPassword : (shellConfig.password || ''),
|
||||||
},
|
},
|
||||||
restartCommand: $(SELECTORS.restartCommand).val()
|
restartCommand: $(SELECTORS.restartCommand).val(),
|
||||||
|
restartOnUpdate: $(SELECTORS.restartOnUpdate).prop('checked')
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
const $button = $(SELECTORS.saveButton);
|
const $button = $(SELECTORS.saveButton);
|
||||||
await runAction($button, 'Сохранение...', async () => {
|
await runAction($button, 'Применение...', async () => {
|
||||||
const newConfig = collectConfigFromForm();
|
const newConfig = collectConfigFromForm();
|
||||||
await JSONRPC.System.setComponentConfig(OVPN_COMPONENT_NAME, newConfig);
|
await JSONRPC.System.setComponentConfig(OVPN_COMPONENT_NAME, newConfig);
|
||||||
currentConfig = newConfig;
|
currentConfig = newConfig;
|
||||||
|
|
@ -208,10 +234,12 @@ function detachEventHandlers() {
|
||||||
|
|
||||||
function toggleSSHFields() {
|
function toggleSSHFields() {
|
||||||
const enabled = $(SELECTORS.useSSH).prop('checked');
|
const enabled = $(SELECTORS.useSSH).prop('checked');
|
||||||
|
const $shellFields = $(SELECTORS.container).find(`.${CLASS_NAMES.shellFields}`);
|
||||||
$(SELECTORS.host).prop('disabled', !enabled);
|
$(SELECTORS.host).prop('disabled', !enabled);
|
||||||
$(SELECTORS.port).prop('disabled', !enabled);
|
$(SELECTORS.port).prop('disabled', !enabled);
|
||||||
$(SELECTORS.username).prop('disabled', !enabled);
|
$(SELECTORS.username).prop('disabled', !enabled);
|
||||||
$(SELECTORS.password).prop('disabled', !enabled);
|
$(SELECTORS.password).prop('disabled', !enabled);
|
||||||
|
$shellFields.toggle(enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const OVPNConfig = {
|
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;
|
const POLLING_INTERVAL = 5000;
|
||||||
let updateInterval = null;
|
let updateInterval = null;
|
||||||
|
let configChanged = false;
|
||||||
|
|
||||||
const $content = () => $('#statistics-content');
|
const $content = () => $('#statistics-content');
|
||||||
|
|
||||||
const createStatusCard = (title, status, buttons = [], resourceStatsHtml = '', customStatusContent = null) => {
|
const createStatusCard = (title, status, buttons = [], resourceStatsHtml = '', customStatusContent = null, extraClass = '') => {
|
||||||
const warningStatuses = ['Обновляется', 'Обнаружено', 'Готово к установке'];
|
const warningStatuses = ['Обновляется', 'Обнаружено', 'Готово к установке'];
|
||||||
const statusClass = warningStatuses.includes(status) ? 'text-yellow-500' : 'text-green-500';
|
const statusClass = warningStatuses.includes(status) ? 'text-yellow-500' : 'text-green-500';
|
||||||
const statusSection = customStatusContent || `
|
const statusSection = customStatusContent || `
|
||||||
|
|
@ -23,7 +24,7 @@ const createStatusCard = (title, status, buttons = [], resourceStatsHtml = '', c
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="stat-card">
|
<div class="stat-card ${extraClass}">
|
||||||
<h3>${title}</h3>
|
<h3>${title}</h3>
|
||||||
${statusSection}
|
${statusSection}
|
||||||
${buttonsHtml}
|
${buttonsHtml}
|
||||||
|
|
@ -85,7 +86,7 @@ async function renderSystemAndManagerStatus() {
|
||||||
const availableVersion = (versionInfo && versionInfo.available) || '';
|
const availableVersion = (versionInfo && versionInfo.available) || '';
|
||||||
const currentVersion = (versionInfo && versionInfo.current) || '';
|
const currentVersion = (versionInfo && versionInfo.current) || '';
|
||||||
const downloadedVersion = (versionInfo && versionInfo.downloaded) || '';
|
const downloadedVersion = (versionInfo && versionInfo.downloaded) || '';
|
||||||
const isConfigChanged = await JSONRPC.System.isConfigChanged();
|
configChanged = await JSONRPC.System.isConfigChanged();
|
||||||
const hasAvailableUpdate = availableVersion && availableVersion !== currentVersion;
|
const hasAvailableUpdate = availableVersion && availableVersion !== currentVersion;
|
||||||
const isDownloaded = hasAvailableUpdate && downloadedVersion === availableVersion;
|
const isDownloaded = hasAvailableUpdate && downloadedVersion === availableVersion;
|
||||||
const updateButtonDisabled = !hasAvailableUpdate || isDownloaded;
|
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>Текущая версия: <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>
|
<span class="last-version">Последняя версия: <span class="${hasAvailableUpdate ? 'text-yellow-500' : 'text-green-500'}">${availableVersion || '-'}</span></span>
|
||||||
</div>
|
</div>
|
||||||
<p class="status-line ${isConfigChanged ? 'text-yellow-500' : 'text-green-500'}">
|
<p class="status-line ${configChanged ? 'text-yellow-500' : 'text-green-500'}">
|
||||||
Конфигурация: ${isConfigChanged ? 'изменена' : 'актуальна'}
|
Конфигурация: ${configChanged ? 'изменена' : 'актуальна'}
|
||||||
</p>
|
</p>
|
||||||
`;
|
`;
|
||||||
const isSubUpdating = await JSONRPC.SubscriptionManager.isUpdating();
|
const isSubUpdating = await JSONRPC.SubscriptionManager.isUpdating();
|
||||||
|
|
@ -128,13 +129,14 @@ async function renderSystemAndManagerStatus() {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'save-config-btn',
|
id: 'save-config-btn',
|
||||||
label: 'Сохранить',
|
label: 'Сохранить настройки',
|
||||||
disabled: !isConfigChanged,
|
disabled: !configChanged,
|
||||||
loadingLabel: 'Сохранение...'
|
loadingLabel: 'Сохранение...'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
'',
|
'',
|
||||||
versionStatusHtml
|
versionStatusHtml,
|
||||||
|
configChanged ? 'stat-card-highlight' : ''
|
||||||
));
|
));
|
||||||
|
|
||||||
const subStatsHtml = `<div class="resource-group" style="margin-top: 20px;">
|
const subStatsHtml = `<div class="resource-group" style="margin-top: 20px;">
|
||||||
|
|
@ -263,7 +265,7 @@ function attachEventHandlers() {
|
||||||
await renderSystemAndManagerStatus();
|
await renderSystemAndManagerStatus();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert('Ошибка при сохранении конфигурации!');
|
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;
|
text-decoration: none;
|
||||||
transition: background-color 0.2s, color 0.2s;
|
transition: background-color 0.2s, color 0.2s;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
.menu-item:hover {
|
.menu-item:hover {
|
||||||
background-color: #2e3a4e; /* Чуть светлее, чем сайдбар */
|
background-color: #2e3a4e; /* Чуть светлее, чем сайдбар */
|
||||||
|
|
@ -137,6 +138,16 @@ html.dark-theme, body {
|
||||||
background-color: var(--color-primary);
|
background-color: var(--color-primary);
|
||||||
color: white;
|
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 {
|
.logout-btn {
|
||||||
margin-top: 15px;
|
margin-top: 15px;
|
||||||
border-top: 1px solid var(--color-border);
|
border-top: 1px solid var(--color-border);
|
||||||
|
|
@ -178,6 +189,11 @@ html.dark-theme, body {
|
||||||
transition: box-shadow 0.3s;
|
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 {
|
.stat-card:hover {
|
||||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
|
||||||
}
|
}
|
||||||
|
|
@ -228,6 +244,57 @@ html.dark-theme, body {
|
||||||
border-color: var(--color-border);
|
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 {
|
.resource-group {
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,10 @@ export default defineConfig({
|
||||||
path.resolve(__dirname, 'node_modules')
|
path.resolve(__dirname, 'node_modules')
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
build: {
|
||||||
|
outDir: path.resolve(__dirname, '../app/src/main/resources/htdocs')
|
||||||
}
|
}
|
||||||
// -----------------------------------------------------------------
|
// -----------------------------------------------------------------
|
||||||
})
|
})
|
||||||
Loading…
Reference in New Issue