From 33a79a13d9051b124f690a01b9624d440953eb43 Mon Sep 17 00:00:00 2001 From: kirillius Date: Tue, 30 Sep 2025 22:14:18 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9F=D1=80=D0=BE=D0=BC=D0=B5=D0=B6=D1=83?= =?UTF-8?q?=D1=82=D0=BE=D1=87=D0=BD=D1=8B=D0=B9=20=D0=BA=D0=BE=D0=BC=D0=BC?= =?UTF-8?q?=D0=B8=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + .../ru/kirillius/pf/sdn/web/RPC/System.java | 17 ++ .../pf/sdn/core/AbstractComponent.java | 2 +- ...orage.java => ComponentConfigStorage.java} | 32 ++-- .../java/ru/kirillius/pf/sdn/core/Config.java | 2 +- pom.xml | 2 +- webui/package.json | 4 +- webui/src/modules/app.js | 37 ++-- webui/src/modules/auth.js | 9 +- webui/src/modules/router.js | 46 +++-- webui/src/modules/ui.js | 10 +- webui/src/pages/OVPN.js | 5 + webui/src/styles/app.css | 52 ++++++ webui/src/ui/ovpn.js | 167 ++++++++++++++++++ webui/vite.config.js | 7 +- 15 files changed, 336 insertions(+), 57 deletions(-) rename core/src/main/java/ru/kirillius/pf/sdn/core/{PluginConfigStorage.java => ComponentConfigStorage.java} (57%) create mode 100644 webui/src/pages/OVPN.js create mode 100644 webui/src/ui/ovpn.js diff --git a/.gitignore b/.gitignore index 791b6fc..fc18f83 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,4 @@ build/ /web-server/src/main/webui/src/json-rpc.js /.cache/ ovpn-connector.json +/webui/src/json-rpc.js diff --git a/app/src/main/java/ru/kirillius/pf/sdn/web/RPC/System.java b/app/src/main/java/ru/kirillius/pf/sdn/web/RPC/System.java index d46544d..7df3837 100644 --- a/app/src/main/java/ru/kirillius/pf/sdn/web/RPC/System.java +++ b/app/src/main/java/ru/kirillius/pf/sdn/web/RPC/System.java @@ -77,4 +77,21 @@ public class System implements RPC { } + @SuppressWarnings({"unchecked", "rawtypes"}) + @ProtectedMethod + @JRPCMethod + public JSONObject getComponentConfig(@JRPCArgument(name = "component") String componentName) throws ClassNotFoundException { + var config = context.getConfig().getComponentsConfig().getConfig((Class) Class.forName(componentName)); + return JSONUtility.serializeStructure(config); + } + + + @SuppressWarnings({"rawtypes", "unchecked"}) + @ProtectedMethod + @JRPCMethod + public void setComponentConfig(@JRPCArgument(name = "component") String componentName, @JRPCArgument(name = "config") JSONObject config) throws ClassNotFoundException { + var cls = (Class) Class.forName(componentName); + var configClass = Component.getConfigClass(cls); + context.getConfig().getComponentsConfig().setConfig(cls, JSONUtility.deserializeStructure(config, configClass)); + } } diff --git a/core/src/main/java/ru/kirillius/pf/sdn/core/AbstractComponent.java b/core/src/main/java/ru/kirillius/pf/sdn/core/AbstractComponent.java index 4bea1a1..f13a9eb 100644 --- a/core/src/main/java/ru/kirillius/pf/sdn/core/AbstractComponent.java +++ b/core/src/main/java/ru/kirillius/pf/sdn/core/AbstractComponent.java @@ -6,7 +6,7 @@ public abstract class AbstractComponent implements Component { @SuppressWarnings({"unchecked", "rawtypes"}) public AbstractComponent(Context context) { - config = (CT) context.getConfig().getPluginsConfig().getConfig((Class) getClass()); + config = (CT) context.getConfig().getComponentsConfig().getConfig((Class) getClass()); this.context = context; } } diff --git a/core/src/main/java/ru/kirillius/pf/sdn/core/PluginConfigStorage.java b/core/src/main/java/ru/kirillius/pf/sdn/core/ComponentConfigStorage.java similarity index 57% rename from core/src/main/java/ru/kirillius/pf/sdn/core/PluginConfigStorage.java rename to core/src/main/java/ru/kirillius/pf/sdn/core/ComponentConfigStorage.java index b8e7db1..3b96c4d 100644 --- a/core/src/main/java/ru/kirillius/pf/sdn/core/PluginConfigStorage.java +++ b/core/src/main/java/ru/kirillius/pf/sdn/core/ComponentConfigStorage.java @@ -12,15 +12,15 @@ import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @NoArgsConstructor -@JSONSerializable(PluginConfigStorage.Serializer.class) -public class PluginConfigStorage { +@JSONSerializable(ComponentConfigStorage.Serializer.class) +public class ComponentConfigStorage { - public final static class Serializer implements JSONSerializer { + public final static class Serializer implements JSONSerializer { @Override - public Object serialize(PluginConfigStorage pluginConfigStorage) throws SerializationException { + public Object serialize(ComponentConfigStorage componentConfigStorage) throws SerializationException { var json = new JSONObject(); - pluginConfigStorage.configs.forEach((key, value) -> { + componentConfigStorage.configs.forEach((key, value) -> { json.put(key.getName(), JSONUtility.serializeStructure(value)); }); return json; @@ -28,16 +28,16 @@ public class PluginConfigStorage { @SuppressWarnings({"rawtypes", "unchecked"}) @Override - public PluginConfigStorage deserialize(Object o, Class aClass) throws SerializationException { + public ComponentConfigStorage deserialize(Object o, Class aClass) throws SerializationException { var loader = getClass().getClassLoader(); var json = (JSONObject) o; - var storage = new PluginConfigStorage(); + var storage = new ComponentConfigStorage(); json.keySet().forEach(key -> { try { var pluginClass = loader.loadClass(key); var value = json.getJSONObject(key); var configClass = Component.getConfigClass((Class) pluginClass); - storage.configs.put((Class)pluginClass, JSONUtility.deserializeStructure(value, configClass)); + storage.configs.put((Class) pluginClass, JSONUtility.deserializeStructure(value, configClass)); } catch (ClassNotFoundException e) { throw new RuntimeException(e); } @@ -50,12 +50,18 @@ public class PluginConfigStorage { @SuppressWarnings("unchecked") @SneakyThrows - public CT getConfig(Class> pluginClass) { - if (!configs.containsKey(pluginClass)) { - var configClass = Component.getConfigClass(pluginClass); + public CT getConfig(Class> componentClass) { + if (!configs.containsKey(componentClass)) { + var configClass = Component.getConfigClass(componentClass); var instance = configClass.getConstructor().newInstance(); - configs.put(pluginClass, instance); + configs.put(componentClass, instance); } - return (CT) configs.get(pluginClass); + return (CT) configs.get(componentClass); + } + + @SneakyThrows + public void setConfig(Class> componentClass, CT config) { + var configClass = Component.getConfigClass(componentClass); + configs.put(componentClass, config); } } diff --git a/core/src/main/java/ru/kirillius/pf/sdn/core/Config.java b/core/src/main/java/ru/kirillius/pf/sdn/core/Config.java index 7ff875d..ce264bc 100644 --- a/core/src/main/java/ru/kirillius/pf/sdn/core/Config.java +++ b/core/src/main/java/ru/kirillius/pf/sdn/core/Config.java @@ -62,7 +62,7 @@ public class Config { @Setter @Getter @JSONProperty(required = false) - private PluginConfigStorage pluginsConfig = new PluginConfigStorage(); + private ComponentConfigStorage componentsConfig = new ComponentConfigStorage(); @Setter @Getter diff --git a/pom.xml b/pom.xml index 6874599..0d4b7b0 100644 --- a/pom.xml +++ b/pom.xml @@ -91,7 +91,7 @@ ru.kirillius json-rpc-servlet - 2.1.5.0 + 2.1.4.0 diff --git a/webui/package.json b/webui/package.json index 9f50c12..733fc16 100644 --- a/webui/package.json +++ b/webui/package.json @@ -15,9 +15,7 @@ "jquery": "^3.7.1" }, "devDependencies": { - "@vitejs/plugin-vue": "^6.0.1", "sass": "^1.93.2", - "vite": "^7.1.7", - "vite-plugin-vue-devtools": "^8.0.2" + "vite": "^7.1.7" } } diff --git a/webui/src/modules/app.js b/webui/src/modules/app.js index 4a2eebe..59b6c00 100644 --- a/webui/src/modules/app.js +++ b/webui/src/modules/app.js @@ -1,23 +1,36 @@ import { renderLoginForm, renderDashboard } from './ui.js'; -import { checkAuthOnStartup } from './auth.js'; // 🔥 Теперь импортируем здесь +import { checkAuthStatus } from './auth.js'; +import { JSONRPC } from '@/json-rpc.js'; -// Глобальный статус приложения -export let isAuthenticated = false; +let isAuthenticated = false; +let enabledComponents = []; -// Главная функция инициализации -export async function initApp() { - // 1. Проверяем авторизацию - const isInitialAuth = await checkAuthOnStartup(); - setAuthenticated(isInitialAuth); // Устанавливаем состояние и рендерим +async function loadEnabledComponents() { + try { + enabledComponents = await JSONRPC.System.getEnabledComponents(); + } catch(e) { + console.error("Ошибка при загрузке включенных компонентов:", e); + enabledComponents = []; + } } -// Функция для смены состояния (например, после успешного логина/выхода) -export function setAuthenticated(status) { - isAuthenticated = status; +export async function initApp() { + const authSuccess = await checkAuthStatus(); + setAuthenticated(authSuccess); +} - if (status) { +export async function setAuthenticated(status) { + isAuthenticated = status; + if (isAuthenticated) { + // Сначала загружаем компоненты + await loadEnabledComponents(); renderDashboard(); } else { renderLoginForm(); } +} + +// Новый экспорт для доступа роутера к списку компонентов +export function getEnabledComponents() { + return enabledComponents; } \ No newline at end of file diff --git a/webui/src/modules/auth.js b/webui/src/modules/auth.js index 02f1d48..f98e8bb 100644 --- a/webui/src/modules/auth.js +++ b/webui/src/modules/auth.js @@ -1,6 +1,7 @@ +// src/modules/auth.js + import { getAuthToken, setAuthToken, removeAuthToken } from '../utils/cookies.js'; -// ⚠️ ОБНОВИ ПУТЬ! Этот импорт должен указывать на твой модуль для работы с RPC. // Предполагается, что JSONRPC.Auth содержит методы: // - isAuthenticated() // - startSessionByToken(token) @@ -11,11 +12,13 @@ import { JSONRPC } from '@/json-rpc.js'; /** - * @private + * @public * Проверяет, активна ли сессия (через API) или сохранен ли токен в cookies. + * + * 🔥 ПЕРЕИМЕНОВАНА для соответствия импорту в app.js! * @returns {Promise} */ -export async function checkAuthOnStartup() { +export async function checkAuthStatus() { try { // 1. Проверяем активную сессию (самый надежный способ) const isAuthenticated = await JSONRPC.Auth.isAuthenticated(); diff --git a/webui/src/modules/router.js b/webui/src/modules/router.js index 28e6569..1d90d1f 100644 --- a/webui/src/modules/router.js +++ b/webui/src/modules/router.js @@ -1,23 +1,26 @@ // src/modules/router.js import $ from 'jquery'; -// 🔥 Импортируем только объект StatisticsPage import { StatisticsPage } from '../pages/Statistics.js'; import { Subscriptions } from '../pages/Subscriptions.js'; import { Components } from '../pages/Components.js'; import { APITokens } from '../pages/APITokens.js'; +import { getEnabledComponents } from './app.js'; +import { OVPNConfig } from '../pages/OVPN.js'; + // Переменная для отслеживания текущего активного хеша (для корректного unmount) let currentRouteHash = ''; -// 1. Определение меню и путей -const menuItems = [ - { label: 'Статистика', path: 'stats' }, - { label: 'Подписки', path: 'subscriptions' }, - { label: 'Настройки', path: 'settings' }, - { label: 'Компоненты', path: 'components' }, - { label: 'API', path: 'api' }, +// 1. Определение ВСЕХ возможных пунктов меню +const allMenuItems = [ + { label: 'Статистика', path: 'stats', component: null }, + { label: 'Подписки', path: 'subscriptions', component: null }, + { label: 'Настройки', path: 'settings', component: null }, + { label: 'Компоненты', path: 'components', component: null }, + { label: 'API', path: 'api', component: null }, + { label: 'Настройка OVPN', path: 'ovpn', component: 'ru.kirillius.pf.sdn.External.API.Components.OVPN' }, ]; // 2. Определение страниц @@ -25,7 +28,6 @@ const routes = { '#stats': { render: StatisticsPage.render, mount: StatisticsPage.mount, - // 🔥 Используем unmount из объекта страницы unmount: StatisticsPage.unmount }, '#subscriptions': { @@ -47,22 +49,38 @@ const routes = { render: APITokens.render, mount: APITokens.mount, unmount: APITokens.unmount + }, + '#ovpn': { + render: OVPNConfig.render, + mount: OVPNConfig.mount, + unmount: OVPNConfig.unmount } }; -// 3. Функция рендеринга страницы +// 🔥 Убедитесь, что здесь НЕТ слова 'export' +function getFilteredMenuItems() { + const enabled = getEnabledComponents(); + + return allMenuItems.filter(item => { + if (item.component === null) { + return true; + } + return enabled.includes(item.component); + }); +} + +// 3. Функция рендеринга страницы (без изменений) export function renderPage(hash) { const $contentArea = $('#content-area'); const key = hash.startsWith('#') ? hash : '#' + hash; - // 🔥 НОВАЯ ПРОВЕРКА: Если страница уже открыта, просто обновляем меню и выходим + // Если страница уже открыта, просто обновляем меню и выходим if (currentRouteHash === key) { $('.menu-item').removeClass('active'); $(`.menu-item[data-path="${key.substring(1)}"]`).addClass('active'); return; } - // Определяем, какой хеш был активным до этого. Используем 'stats' как дефолт const previousKey = currentRouteHash || '#stats'; // Шаг 1: Если мы меняем страницу, и предыдущая страница имеет unmount, вызываем его @@ -100,5 +118,5 @@ $(window).on('hashchange', function() { renderPage(window.location.hash); }); -// Экспорт menuItems для построения сайдбара в ui.js -export { menuItems }; \ No newline at end of file +// 🔥 Оставляем ТОЛЬКО ОДИН экспорт в конце файла +export { getFilteredMenuItems }; \ No newline at end of file diff --git a/webui/src/modules/ui.js b/webui/src/modules/ui.js index 879c7de..01e2c79 100644 --- a/webui/src/modules/ui.js +++ b/webui/src/modules/ui.js @@ -1,7 +1,8 @@ import $ from 'jquery'; import { handleLogin, handleLogout } from './auth.js'; import { setAuthenticated } from './app.js'; -import { renderPage, menuItems } from './router.js'; +import { renderPage, getFilteredMenuItems } from './router.js'; + // Функция рендеринга формы авторизации export function renderLoginForm() { @@ -36,12 +37,14 @@ export function renderLoginForm() { const $button = $('#login-button'); const $error = $('#login-error'); const password = $('#password-input').val(); + // Получение флага rememberMe const rememberMe = $('#remember-me').prop('checked'); $error.hide().text(''); $button.prop('disabled', true).text('Вход...'); try { + // Передача флага rememberMe const success = await handleLogin(password, rememberMe); if (success) { setAuthenticated(true); @@ -59,7 +62,9 @@ export function renderLoginForm() { // Функция рендеринга рабочего стола (Dashboard) export function renderDashboard() { - // Используем menuItems из router.js для построения сайдбара + // Используем функцию из роутера для получения актуального списка меню + const menuItems = getFilteredMenuItems(); + const sidebarHtml = menuItems.map(item => { return `${item.label}`; }).join(''); @@ -91,7 +96,6 @@ export function renderDashboard() { // Прикрепляем события для меню (роутер) $('#main-nav').on('click', '.menu-item', function(e) { - // Предотвращаем стандартный переход, чтобы обработать его через JS e.preventDefault(); const path = $(this).data('path'); diff --git a/webui/src/pages/OVPN.js b/webui/src/pages/OVPN.js new file mode 100644 index 0000000..45d4b40 --- /dev/null +++ b/webui/src/pages/OVPN.js @@ -0,0 +1,5 @@ +// src/pages/OVPN.js + +import { OVPNConfigPage } from '../ui/ovpn.js'; + +export const OVPNConfig = OVPNConfigPage; \ No newline at end of file diff --git a/webui/src/styles/app.css b/webui/src/styles/app.css index 51b6562..131f884 100644 --- a/webui/src/styles/app.css +++ b/webui/src/styles/app.css @@ -409,3 +409,55 @@ html.dark-theme, body { font-size: 18px; line-height: 1; } + + + +/* --- Стили для формы конфигурации (OVPN) --- */ + +.component-config-form { + background-color: var(--color-bg-card); + padding: 30px; + border-radius: var(--border-radius); + border: 1px solid var(--color-border); +} + +.config-section-title { + color: var(--color-primary); + border-bottom: 1px solid var(--color-border); + padding-bottom: 8px; + margin-bottom: 25px; + font-size: 18px; +} + +.form-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 20px; +} + +.checkbox-group { + display: flex; + align-items: center; + gap: 10px; + margin-top: 10px; +} + +.hint-text { + display: block; + margin-top: 5px; + font-size: 12px; + color: var(--color-text-secondary); +} + +.action-buttons { + margin-top: 30px; + padding-top: 20px; + border-top: 1px solid var(--color-border); + display: flex; + gap: 15px; +} + +.action-buttons .btn-secondary { + /* Дополнительная настройка, чтобы соответствовать ширине primary */ + padding: 12px; +} \ No newline at end of file diff --git a/webui/src/ui/ovpn.js b/webui/src/ui/ovpn.js new file mode 100644 index 0000000..b10caaf --- /dev/null +++ b/webui/src/ui/ovpn.js @@ -0,0 +1,167 @@ +// src/ui/ovpn.js + +import $ from 'jquery'; +import { JSONRPC } from '@/json-rpc.js'; + +const OVPN_COMPONENT_NAME = 'ru.kirillius.pf.sdn.External.API.Components.OVPN'; + +// --- Глобальное состояние --- +let config = {}; + +/** Загрузка конфигурации OVPN */ +async function loadConfig() { + try { + const fullConfig = await JSONRPC.System.getComponentConfig(OVPN_COMPONENT_NAME); + config = fullConfig || {}; + return true; + } catch(e) { + console.error("Ошибка при загрузке конфига OVPN:", e); + return false; + } +} + +/** Рендеринг формы */ +function renderOVPNForm() { + const $container = $('#ovpn-config-container'); + + // Деструктурируем для удобства + const shellConfig = config.shellConfig || {}; + + const formHtml = ` +
+

Конфигурация Shell (ShellConfig)

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +

Команда перезапуска

+
+ + + Команда, которая будет выполнена через Shell для перезапуска сервиса OVPN. +
+ +
+ + +
+ +
+ `; + + $container.html(formHtml); + attachEventHandlers(); +} + +/** Сбор данных из формы */ +function collectConfigFromForm() { + // Получаем текущий пароль, если поле ввода пустое (чтобы не отправлять пустую строку) + const newPassword = $('#ovpn-password').val(); + const oldPassword = config.shellConfig ? config.shellConfig.password : ''; + + const newConfig = { + shellConfig: { + useSSH: $('#ovpn-use-ssh').prop('checked'), + host: $('#ovpn-host').val(), + port: parseInt($('#ovpn-port').val()) || 22, + username: $('#ovpn-username').val(), + // Если пароль не введен, используем старый пароль, иначе - новый + password: newPassword ? newPassword : oldPassword, + }, + restartCommand: $('#ovpn-restart-command').val() + }; + return newConfig; +} + +/** Сохранение конфига */ +async function saveConfig() { + const newConfig = collectConfigFromForm(); + const $btn = $('#save-ovpn-btn'); + const $message = $('#ovpn-status-message'); + + $message.hide().removeClass('success-message').addClass('error-message'); + $btn.prop('disabled', true).text('Сохранение...'); + + try { + await JSONRPC.System.setComponentConfig(OVPN_COMPONENT_NAME, newConfig); + config = newConfig; // Обновляем локальное состояние + $message.text('Конфигурация OVPN успешно сохранена.').addClass('success-message').show(); + + } catch(e) { + console.error('Ошибка сохранения конфига:', e); + $message.text('Ошибка при сохранении конфигурации.').show(); + } finally { + $btn.prop('disabled', false).text('Сохранить Конфигурацию'); + setTimeout(() => $message.fadeOut(), 5000); + } +} + +/** Перезапуск сервиса */ +async function restartService() { + const $btn = $('#restart-ovpn-btn'); + const $message = $('#ovpn-status-message'); + + $message.hide().removeClass('success-message').addClass('error-message'); + $btn.prop('disabled', true).text('Перезапуск...'); + + try { + await JSONRPC.OVPN.restartSystemService(); + $message.text('Сервис OVPN успешно перезапущен.').addClass('success-message').show(); + } catch(e) { + console.error('Ошибка перезапуска:', e); + $message.text('Ошибка при перезапуске сервиса.').show(); + } finally { + $btn.prop('disabled', false).text('Перезапустить Сервис'); + setTimeout(() => $message.fadeOut(), 5000); + } +} + + +/** Прикрепление обработчиков */ +function attachEventHandlers() { + $('#save-ovpn-btn').off('click').on('click', saveConfig); + $('#restart-ovpn-btn').off('click').on('click', restartService); +} + +// --- Объект страницы для роутера --- +export const OVPNConfigPage = { + render: () => { + return ` +

Настройка OVPN

+
+

Загрузка конфигурации...

+
+ `; + }, + mount: async () => { + const success = await loadConfig(); + if (success) { + renderOVPNForm(); + } else { + $('#ovpn-config-container').html('

Не удалось загрузить конфигурацию OVPN.

'); + } + }, + unmount: () => { + config = {}; + $('#ovpn-config-container').off(); + } +}; \ No newline at end of file diff --git a/webui/vite.config.js b/webui/vite.config.js index 28f4c1e..3f435f5 100644 --- a/webui/vite.config.js +++ b/webui/vite.config.js @@ -3,15 +3,10 @@ import { fileURLToPath, URL } from 'node:url' import path from 'node:path' import { defineConfig } from 'vite' -import vue from '@vitejs/plugin-vue' -import vueDevTools from 'vite-plugin-vue-devtools' // https://vite.dev/config/ export default defineConfig({ - plugins: [ - vue(), - vueDevTools(), - ], + plugins: [], resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url))