Compare commits

..

No commits in common. "b6489a583849f2214428d712265e214198049b19" and "aa726d4653b6283a0e129c080ee69a793cd5d4d1" have entirely different histories.

28 changed files with 964 additions and 1051 deletions

1
.gitignore vendored
View File

@ -42,4 +42,3 @@ build/
/web-server/src/main/webui/src/json-rpc.js /web-server/src/main/webui/src/json-rpc.js
/.cache/ /.cache/
ovpn-connector.json ovpn-connector.json
/webui/src/json-rpc.js

View File

@ -2,7 +2,6 @@ package ru.kirillius.pf.sdn;
import lombok.Getter; import lombok.Getter;
import lombok.SneakyThrows; import lombok.SneakyThrows;
import ru.kirillius.json.rpc.Servlet.JSONRPCServlet;
import ru.kirillius.pf.sdn.External.API.Components.FRR; import ru.kirillius.pf.sdn.External.API.Components.FRR;
import ru.kirillius.pf.sdn.External.API.Components.OVPN; import ru.kirillius.pf.sdn.External.API.Components.OVPN;
import ru.kirillius.pf.sdn.External.API.Components.TDNS; import ru.kirillius.pf.sdn.External.API.Components.TDNS;
@ -10,6 +9,7 @@ import ru.kirillius.pf.sdn.External.API.HEInfoProvider;
import ru.kirillius.pf.sdn.core.Auth.AuthManager; import ru.kirillius.pf.sdn.core.Auth.AuthManager;
import ru.kirillius.pf.sdn.core.Auth.TokenStorage; import ru.kirillius.pf.sdn.core.Auth.TokenStorage;
import ru.kirillius.pf.sdn.core.*; import ru.kirillius.pf.sdn.core.*;
import ru.kirillius.pf.sdn.core.Component;
import ru.kirillius.pf.sdn.core.Networking.ASInfoService; import ru.kirillius.pf.sdn.core.Networking.ASInfoService;
import ru.kirillius.pf.sdn.core.Networking.NetworkManager; import ru.kirillius.pf.sdn.core.Networking.NetworkManager;
import ru.kirillius.pf.sdn.core.Subscription.SubscriptionManager; import ru.kirillius.pf.sdn.core.Subscription.SubscriptionManager;
@ -21,7 +21,6 @@ import java.io.Closeable;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection; import java.util.Collection;
import java.util.List; import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicBoolean;
@ -42,7 +41,7 @@ public class App implements Context, Closeable {
@Getter @Getter
private final NetworkManager networkManager; private final NetworkManager networkManager;
@Getter @Getter
private volatile Config config; private Config config;
@Getter @Getter
private final AuthManager authManager; private final AuthManager authManager;
@Getter @Getter
@ -120,29 +119,19 @@ public class App implements Context, Closeable {
return List.of(FRR.class, OVPN.class, TDNS.class); return List.of(FRR.class, OVPN.class, TDNS.class);
} }
private void unloadComponent(Component<?> component) {
SystemLogger.message("Unloading component: " + component.getClass().getSimpleName(), CTX);
try {
component.close();
} catch (IOException e) {
SystemLogger.error("Error on component unload", CTX, e);
} finally {
loadedComponents.remove(component);
}
}
private void loadComponent(Class<? extends Component<?>> componentClass) {
SystemLogger.message("Loading component: " + componentClass.getSimpleName(), CTX);
var plugin = Component.loadPlugin(componentClass, this);
loadedComponents.add(plugin);
}
public void initComponents() { public void initComponents() {
var enabledPlugins = config.getEnabledComponents(); var enabledPlugins = config.getEnabledComponents();
(List.copyOf(loadedComponents)).forEach(plugin -> { (List.copyOf(loadedComponents)).forEach(plugin -> {
if (!enabledPlugins.contains(plugin.getClass())) { if (!enabledPlugins.contains(plugin.getClass())) {
unloadComponent(plugin); SystemLogger.message("Unloading plugin: " + plugin.getClass().getSimpleName(), CTX);
try {
plugin.close();
} catch (IOException e) {
SystemLogger.error("Error on plugin unload", CTX, e);
} finally {
loadedComponents.remove(plugin);
}
} }
}); });
var loadedClasses = loadedComponents.stream().map(plugin -> plugin.getClass()).toList(); var loadedClasses = loadedComponents.stream().map(plugin -> plugin.getClass()).toList();
@ -150,31 +139,12 @@ public class App implements Context, Closeable {
if (loadedClasses.contains(pluginClass)) { if (loadedClasses.contains(pluginClass)) {
return; return;
} }
loadComponent(pluginClass); SystemLogger.message("Loading plugin: " + pluginClass.getSimpleName(), CTX);
var plugin = Component.loadPlugin(pluginClass, this);
loadedComponents.add(plugin);
}); });
} }
@Override
public void reloadComponents(Class<? extends Component<?>>... classes) {
Arrays.stream(classes)
.forEach(componentClass -> {
loadedComponents.stream()
.filter(component -> componentClass.equals(component.getClass()))
.findFirst().ifPresent(this::unloadComponent);
loadComponent(componentClass);
}
);
}
@Override
public JSONRPCServlet getRPC() {
if (server == null) {
return null;
}
return server.getJSONRPC();
}
private void subscribe() { private void subscribe() {
var eventsHandler = getEventsHandler(); var eventsHandler = getEventsHandler();
eventsHandler.getSubscriptionsUpdateEvent().add(bundle -> { eventsHandler.getSubscriptionsUpdateEvent().add(bundle -> {
@ -188,7 +158,7 @@ public class App implements Context, Closeable {
} }
@Override @Override
public Component<?> getComponentInstance(Class<? extends Component<?>> pluginClass) { public Component<?> getPluginInstance(Class<? extends Component<?>> pluginClass) {
return loadedComponents.stream().filter(plugin -> plugin.getClass().equals(pluginClass)).findFirst().orElse(null); return loadedComponents.stream().filter(plugin -> plugin.getClass().equals(pluginClass)).findFirst().orElse(null);
} }

View File

@ -24,24 +24,17 @@ public final class OVPN extends AbstractComponent<OVPN.OVPNConfig> {
public OVPN(Context context) { public OVPN(Context context) {
super(context); super(context);
var RPC = context.getRPC();
if (RPC != null) {
RPC.addTargetInstance(OVPN.class, this);
subscription = null;
return;
}
subscription = context.getEventsHandler().getRPCInitEvent() subscription = context.getEventsHandler().getRPCInitEvent()
.add(servlet -> servlet.addTargetInstance(OVPN.class, OVPN.this)); .add(servlet -> servlet.addTargetInstance(OVPN.class, OVPN.this));
} }
@JRPCMethod @JRPCMethod
@ProtectedMethod @ProtectedMethod
public String restartSystemService() { public void restartSystemService() {
try (var shell = new ShellExecutor(config.shellConfig)) { try (var shell = new ShellExecutor(config.shellConfig)) {
return shell.executeCommand(new String[]{config.restartCommand}); shell.executeCommand(new String[]{config.restartCommand});
} catch (IOException e) { } catch (IOException e) {
SystemLogger.error("Error when trying to restart OVPN", CTX, e); SystemLogger.error("Error when trying to restart OVPN", CTX, e);
return e.toString();
} }
} }
@ -60,22 +53,20 @@ public final class OVPN extends AbstractComponent<OVPN.OVPNConfig> {
@Override @Override
public void close() { public void close() throws IOException {
if (subscription != null) {
context.getEventsHandler().getRPCInitEvent().remove(subscription); context.getEventsHandler().getRPCInitEvent().remove(subscription);
} }
}
@JSONSerializable @JSONSerializable
public static class OVPNConfig { public static class OVPNConfig {
@Getter @Getter
@Setter @Setter
@JSONProperty @JSONProperty
private volatile ShellExecutor.Config shellConfig = new ShellExecutor.Config(); private ShellExecutor.Config shellConfig = new ShellExecutor.Config();
@Getter @Getter
@Setter @Setter
@JSONProperty @JSONProperty
private volatile String restartCommand = "rc-service openvpn restart"; private String restartCommand = "rc-service openvpn restart";
} }
} }

View File

@ -85,23 +85,23 @@ public class ShellExecutor implements Closeable {
@Getter @Getter
@Setter @Setter
@JSONProperty @JSONProperty
private volatile boolean useSSH = false; private boolean useSSH = false;
@Getter @Getter
@Setter @Setter
@JSONProperty @JSONProperty
private volatile String host = "127.0.0.1"; private String host = "127.0.0.1";
@Getter @Getter
@Setter @Setter
@JSONProperty @JSONProperty
private volatile int port = 22; private int port = 22;
@Getter @Getter
@Setter @Setter
@JSONProperty @JSONProperty
private volatile String username = "root"; private String username = "root";
@Getter @Getter
@Setter @Setter
@JSONProperty @JSONProperty
private volatile String password = "securepassword"; private String password = "securepassword";
@Override @Override
public String toString() { public String toString() {

View File

@ -1,7 +1,6 @@
package ru.kirillius.pf.sdn.web; package ru.kirillius.pf.sdn.web;
import lombok.Getter;
import org.eclipse.jetty.ee10.servlet.DefaultServlet; import org.eclipse.jetty.ee10.servlet.DefaultServlet;
import org.eclipse.jetty.ee10.servlet.ServletContextHandler; import org.eclipse.jetty.ee10.servlet.ServletContextHandler;
import org.eclipse.jetty.server.Server; import org.eclipse.jetty.server.Server;
@ -35,8 +34,6 @@ public class HTTPServer extends Server {
private final static Set<Class<? extends RPC>> RPCHandlerTypes = Set.of(Auth.class, NetworkManager.class, SubscriptionManager.class, System.class); private final static Set<Class<? extends RPC>> RPCHandlerTypes = Set.of(Auth.class, NetworkManager.class, SubscriptionManager.class, System.class);
@Getter
private JSONRPCServlet JSONRPC = new JSONRPCServlet();
public HTTPServer(Context appContext) { public HTTPServer(Context appContext) {
var config = appContext.getConfig(); var config = appContext.getConfig();
@ -49,7 +46,7 @@ public class HTTPServer extends Server {
this.addConnector(connector); this.addConnector(connector);
var servletContext = new ServletContextHandler("/", ServletContextHandler.SESSIONS); var servletContext = new ServletContextHandler("/", ServletContextHandler.SESSIONS);
var JSONRPC = new JSONRPCServlet();
servletContext.addServlet(JSONRPC, JSONRPCServlet.CONTEXT_PATH); servletContext.addServlet(JSONRPC, JSONRPCServlet.CONTEXT_PATH);
var holder = servletContext.addServlet(DefaultServlet.class, "/"); var holder = servletContext.addServlet(DefaultServlet.class, "/");
try { try {

View File

@ -77,24 +77,4 @@ 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));
context.reloadComponents(cls);
Object config1 = context.getConfig().getComponentsConfig().getConfig(cls);
return;
}
} }

View File

@ -6,7 +6,7 @@ public abstract class AbstractComponent<CT> implements Component<CT> {
@SuppressWarnings({"unchecked", "rawtypes"}) @SuppressWarnings({"unchecked", "rawtypes"})
public AbstractComponent(Context context) { public AbstractComponent(Context context) {
config = (CT) context.getConfig().getComponentsConfig().getConfig((Class) getClass()); config = (CT) context.getConfig().getPluginsConfig().getConfig((Class) getClass());
this.context = context; this.context = context;
} }
} }

View File

@ -24,85 +24,85 @@ public class Config {
@Getter @Getter
@Setter @Setter
@JSONProperty @JSONProperty
private volatile int updateSubscriptionsInterval = 6; private int updateSubscriptionsInterval = 6;
@Getter @Getter
@Setter @Setter
@JSONProperty @JSONProperty
private volatile boolean cachingAS = true; private boolean cachingAS = true;
@Getter @Getter
@Setter @Setter
@JSONProperty @JSONProperty
private volatile int updateASInterval = 12; private int updateASInterval = 12;
@Getter @Getter
private volatile File loadedConfigFile = null; private File loadedConfigFile = null;
@Setter @Setter
@Getter @Getter
@JSONProperty @JSONProperty
private volatile String host = "0.0.0.0"; private String host = "0.0.0.0";
@Setter @Setter
@Getter @Getter
@JSONProperty @JSONProperty
private volatile File cacheDirectory = new File("./.cache"); private File cacheDirectory = new File("./.cache");
@Setter @Setter
@Getter @Getter
@JSONArrayProperty(type = RepositoryConfig.class) @JSONArrayProperty(type = RepositoryConfig.class)
private volatile List<RepositoryConfig> subscriptions = Collections.emptyList(); private List<RepositoryConfig> subscriptions = Collections.emptyList();
@Setter @Setter
@Getter @Getter
@JSONArrayProperty(type = String.class) @JSONArrayProperty(type = String.class)
private volatile List<String> subscribedResources = Collections.emptyList(); private List<String> subscribedResources = Collections.emptyList();
@Setter @Setter
@Getter @Getter
@JSONProperty(required = false) @JSONProperty(required = false)
private volatile ComponentConfigStorage componentsConfig = new ComponentConfigStorage(); private PluginConfigStorage pluginsConfig = new PluginConfigStorage();
@Setter @Setter
@Getter @Getter
@JSONArrayProperty(type = Class.class, required = false) @JSONArrayProperty(type = Class.class, required = false)
private volatile List<Class<? extends Component<?>>> enabledComponents = new ArrayList<>(); private List<Class<? extends Component<?>>> enabledComponents = new ArrayList<>();
@Setter @Setter
@Getter @Getter
@JSONProperty @JSONProperty
private volatile String passwordSalt = UUID.randomUUID().toString(); private String passwordSalt = UUID.randomUUID().toString();
@Setter @Setter
@Getter @Getter
@JSONProperty @JSONProperty
private volatile String passwordHash = ""; private String passwordHash = "";
@Setter @Setter
@Getter @Getter
@JSONProperty @JSONProperty
private volatile int httpPort = 8081; private int httpPort = 8081;
@Setter @Setter
@Getter @Getter
@JSONProperty @JSONProperty
private volatile boolean mergeSubnets = true; private boolean mergeSubnets = true;
@Setter @Setter
@Getter @Getter
@JSONProperty @JSONProperty
private volatile int mergeSubnetsWithUsage = 51; private int mergeSubnetsWithUsage = 51;
@Setter @Setter
@Getter @Getter
@JSONProperty @JSONProperty
private volatile NetworkResourceBundle customResources = new NetworkResourceBundle(); private NetworkResourceBundle customResources = new NetworkResourceBundle();
@Setter @Setter
@Getter @Getter
@JSONProperty @JSONProperty
private volatile NetworkResourceBundle filteredResources = new NetworkResourceBundle(); private NetworkResourceBundle filteredResources = new NetworkResourceBundle();
public static void store(Config config, File file) throws IOException { public static void store(Config config, File file) throws IOException {
try (var fileInputStream = new FileOutputStream(file)) { try (var fileInputStream = new FileOutputStream(file)) {

View File

@ -1,6 +1,5 @@
package ru.kirillius.pf.sdn.core; package ru.kirillius.pf.sdn.core;
import ru.kirillius.json.rpc.Servlet.JSONRPCServlet;
import ru.kirillius.pf.sdn.core.Auth.AuthManager; import ru.kirillius.pf.sdn.core.Auth.AuthManager;
import ru.kirillius.pf.sdn.core.Auth.TokenStorage; import ru.kirillius.pf.sdn.core.Auth.TokenStorage;
import ru.kirillius.pf.sdn.core.Networking.ASInfoService; import ru.kirillius.pf.sdn.core.Networking.ASInfoService;
@ -24,7 +23,7 @@ public interface Context {
UpdateManager getUpdateManager(); UpdateManager getUpdateManager();
Component<?> getComponentInstance(Class<? extends Component<?>> pluginClass); Component<?> getPluginInstance(Class<? extends Component<?>> pluginClass);
void triggerRestart(); void triggerRestart();
@ -34,9 +33,5 @@ public interface Context {
void initComponents(); void initComponents();
void reloadComponents(Class<? extends Component<?>>... classes);
JSONRPCServlet getRPC();
Collection<Class<? extends Component<?>>> getComponentClasses(); Collection<Class<? extends Component<?>>> getComponentClasses();
} }

View File

@ -12,15 +12,15 @@ import java.util.Map;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
@NoArgsConstructor @NoArgsConstructor
@JSONSerializable(ComponentConfigStorage.Serializer.class) @JSONSerializable(PluginConfigStorage.Serializer.class)
public class ComponentConfigStorage { public class PluginConfigStorage {
public final static class Serializer implements JSONSerializer<ComponentConfigStorage> { public final static class Serializer implements JSONSerializer<PluginConfigStorage> {
@Override @Override
public Object serialize(ComponentConfigStorage componentConfigStorage) throws SerializationException { public Object serialize(PluginConfigStorage pluginConfigStorage) throws SerializationException {
var json = new JSONObject(); var json = new JSONObject();
componentConfigStorage.configs.forEach((key, value) -> { pluginConfigStorage.configs.forEach((key, value) -> {
json.put(key.getName(), JSONUtility.serializeStructure(value)); json.put(key.getName(), JSONUtility.serializeStructure(value));
}); });
return json; return json;
@ -28,10 +28,10 @@ public class ComponentConfigStorage {
@SuppressWarnings({"rawtypes", "unchecked"}) @SuppressWarnings({"rawtypes", "unchecked"})
@Override @Override
public ComponentConfigStorage deserialize(Object o, Class<?> aClass) throws SerializationException { public PluginConfigStorage deserialize(Object o, Class<?> aClass) throws SerializationException {
var loader = getClass().getClassLoader(); var loader = getClass().getClassLoader();
var json = (JSONObject) o; var json = (JSONObject) o;
var storage = new ComponentConfigStorage(); var storage = new PluginConfigStorage();
json.keySet().forEach(key -> { json.keySet().forEach(key -> {
try { try {
var pluginClass = loader.loadClass(key); var pluginClass = loader.loadClass(key);
@ -50,18 +50,12 @@ public class ComponentConfigStorage {
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
@SneakyThrows @SneakyThrows
public <CT> CT getConfig(Class<? extends Component<CT>> componentClass) { public <CT> CT getConfig(Class<? extends Component<CT>> pluginClass) {
if (!configs.containsKey(componentClass)) { if (!configs.containsKey(pluginClass)) {
var configClass = Component.getConfigClass(componentClass); var configClass = Component.getConfigClass(pluginClass);
var instance = configClass.getConstructor().newInstance(); var instance = configClass.getConstructor().newInstance();
configs.put(componentClass, instance); configs.put(pluginClass, instance);
} }
return (CT) configs.get(componentClass); return (CT) configs.get(pluginClass);
}
@SneakyThrows
public <CT> void setConfig(Class<? extends Component<CT>> componentClass, CT config) {
var configClass = Component.getConfigClass(componentClass);
configs.put(componentClass, config);
} }
} }

View File

@ -91,7 +91,7 @@
<dependency> <dependency>
<groupId>ru.kirillius</groupId> <groupId>ru.kirillius</groupId>
<artifactId>json-rpc-servlet</artifactId> <artifactId>json-rpc-servlet</artifactId>
<version>2.1.4.0</version> <version>2.1.5.0</version>
</dependency> </dependency>
</dependencies> </dependencies>

View File

@ -15,7 +15,9 @@
"jquery": "^3.7.1" "jquery": "^3.7.1"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^6.0.1",
"sass": "^1.93.2", "sass": "^1.93.2",
"vite": "^7.1.7" "vite": "^7.1.7",
"vite-plugin-vue-devtools": "^8.0.2"
} }
} }

167
webui/src/json-rpc.js Normal file
View File

@ -0,0 +1,167 @@
export const JSONRPC = {
url: "/jsonrpc/rpc",
__id: 1,
/**
*
* @param method
* @param params
* @returns Object
* @private
*/
__invoke: async function (method, params) {
const request = await JSONRPC.__performRequest(method, params);
if (!request.success) {
console.error(request.result);
throw new Error("Failed to invoke method " + method + " with params " + JSON.stringify(params));
}
return request.result;
},
__performRequest: async function (method, params) {
const __this = this;
const resp = await fetch(
__this.url,
{
method: "POST",
mode: "cors",
cache: "no-cache",
credentials: "include",
headers: {
"Content-Type": "application/json"
},
redirect: "follow",
referrerPolicy: "no-referrer",
body: JSON.stringify({
jsonrpc: '2.0',
method: method,
params: params,
id: __this.__id++
})
}
);
const success = resp.status === 200;
const result = (success ? (await resp.json()).result : {
"error": true,
"code": resp.status,
"status": resp.statusText,
"body": await resp.text()
});
return {
"result": result,
"success": success
};
}
};
export const TYPES = {};
JSONRPC.SubscriptionManager = {};
JSONRPC.SubscriptionManager.getSubscribedResources=async function(){
return await JSONRPC.__invoke("web.RPC.SubscriptionManager::getSubscribedResources", {});
};
JSONRPC.SubscriptionManager.getAvailableResources=async function(){
return await JSONRPC.__invoke("web.RPC.SubscriptionManager::getAvailableResources", {});
};
JSONRPC.SubscriptionManager.setSubscribedResources=async function(resources){
return await JSONRPC.__invoke("web.RPC.SubscriptionManager::setSubscribedResources", {"resources":resources});
};
JSONRPC.SubscriptionManager.isUpdating=async function(){
return await JSONRPC.__invoke("web.RPC.SubscriptionManager::isUpdating", {});
};
JSONRPC.SubscriptionManager.triggerUpdate=async function(){
return await JSONRPC.__invoke("web.RPC.SubscriptionManager::triggerUpdate", {});
};
JSONRPC.Auth = {};
JSONRPC.Auth.createToken=async function(){
return await JSONRPC.__invoke("web.RPC.Auth::createToken", {});
};
JSONRPC.Auth.listTokens=async function(){
return await JSONRPC.__invoke("web.RPC.Auth::listTokens", {});
};
JSONRPC.Auth.removeToken=async function(token){
return await JSONRPC.__invoke("web.RPC.Auth::removeToken", {"token":token});
};
JSONRPC.Auth.createAPIToken=async function(description){
return await JSONRPC.__invoke("web.RPC.Auth::createAPIToken", {"description":description});
};
JSONRPC.Auth.startSessionByPassword=async function(password){
return await JSONRPC.__invoke("web.RPC.Auth::startSessionByPassword", {"password":password});
};
JSONRPC.Auth.startSessionByToken=async function(token){
return await JSONRPC.__invoke("web.RPC.Auth::startSessionByToken", {"token":token});
};
JSONRPC.Auth.isAuthenticated=async function(){
return await JSONRPC.__invoke("web.RPC.Auth::isAuthenticated", {});
};
JSONRPC.Auth.logout=async function(){
return await JSONRPC.__invoke("web.RPC.Auth::logout", {});
};
JSONRPC.System = {};
JSONRPC.System.shutdown=async function(){
return await JSONRPC.__invoke("web.RPC.System::shutdown", {});
};
JSONRPC.System.getEnabledComponents=async function(){
return await JSONRPC.__invoke("web.RPC.System::getEnabledComponents", {});
};
JSONRPC.System.setEnabledComponents=async function(components){
return await JSONRPC.__invoke("web.RPC.System::setEnabledComponents", {"components":components});
};
JSONRPC.System.isConfigChanged=async function(){
return await JSONRPC.__invoke("web.RPC.System::isConfigChanged", {});
};
JSONRPC.System.hasUpdates=async function(){
return await JSONRPC.__invoke("web.RPC.System::hasUpdates", {});
};
JSONRPC.System.getAvailableComponents=async function(){
return await JSONRPC.__invoke("web.RPC.System::getAvailableComponents", {});
};
JSONRPC.System.getConfig=async function(){
return await JSONRPC.__invoke("web.RPC.System::getConfig", {});
};
JSONRPC.System.restart=async function(){
return await JSONRPC.__invoke("web.RPC.System::restart", {});
};
JSONRPC.NetworkManager = {};
JSONRPC.NetworkManager.getOutputResources=async function(){
return await JSONRPC.__invoke("web.RPC.NetworkManager::getOutputResources", {});
};
JSONRPC.NetworkManager.isUpdating=async function(){
return await JSONRPC.__invoke("web.RPC.NetworkManager::isUpdating", {});
};
JSONRPC.NetworkManager.triggerUpdate=async function(ignoreCache){
return await JSONRPC.__invoke("web.RPC.NetworkManager::triggerUpdate", {"ignoreCache":ignoreCache});
};
JSONRPC.OVPN = {};
JSONRPC.OVPN.restartSystemService=async function(){
return await JSONRPC.__invoke("ru.kirillius.pf.sdn.External.API.Components.OVPN::restartSystemService", {});
};
JSONRPC.OVPN.getManagedRoutes=async function(){
return await JSONRPC.__invoke("ru.kirillius.pf.sdn.External.API.Components.OVPN::getManagedRoutes", {});
};

View File

@ -1,36 +1,23 @@
import { renderLoginForm, renderDashboard } from './ui.js'; import { renderLoginForm, renderDashboard } from './ui.js';
import { checkAuthStatus } from './auth.js'; import { checkAuthOnStartup } from './auth.js'; // 🔥 Теперь импортируем здесь
import { JSONRPC } from '@/json-rpc.js';
let isAuthenticated = false; // Глобальный статус приложения
let enabledComponents = []; export let isAuthenticated = false;
async function loadEnabledComponents() {
try {
enabledComponents = await JSONRPC.System.getEnabledComponents();
} catch(e) {
console.error("Ошибка при загрузке включенных компонентов:", e);
enabledComponents = [];
}
}
// Главная функция инициализации
export async function initApp() { export async function initApp() {
const authSuccess = await checkAuthStatus(); // 1. Проверяем авторизацию
setAuthenticated(authSuccess); const isInitialAuth = await checkAuthOnStartup();
setAuthenticated(isInitialAuth); // Устанавливаем состояние и рендерим
} }
export async function setAuthenticated(status) { // Функция для смены состояния (например, после успешного логина/выхода)
export function setAuthenticated(status) {
isAuthenticated = status; isAuthenticated = status;
if (isAuthenticated) {
// Сначала загружаем компоненты if (status) {
await loadEnabledComponents();
renderDashboard(); renderDashboard();
} else { } else {
renderLoginForm(); renderLoginForm();
} }
} }
// Новый экспорт для доступа роутера к списку компонентов
export function getEnabledComponents() {
return enabledComponents;
}

View File

@ -1,7 +1,6 @@
// src/modules/auth.js
import { getAuthToken, setAuthToken, removeAuthToken } from '../utils/cookies.js'; import { getAuthToken, setAuthToken, removeAuthToken } from '../utils/cookies.js';
// ⚠️ ОБНОВИ ПУТЬ! Этот импорт должен указывать на твой модуль для работы с RPC.
// Предполагается, что JSONRPC.Auth содержит методы: // Предполагается, что JSONRPC.Auth содержит методы:
// - isAuthenticated() // - isAuthenticated()
// - startSessionByToken(token) // - startSessionByToken(token)
@ -12,13 +11,11 @@ import { JSONRPC } from '@/json-rpc.js';
/** /**
* @public * @private
* Проверяет, активна ли сессия (через API) или сохранен ли токен в cookies. * Проверяет, активна ли сессия (через API) или сохранен ли токен в cookies.
*
* 🔥 ПЕРЕИМЕНОВАНА для соответствия импорту в app.js!
* @returns {Promise<boolean>} * @returns {Promise<boolean>}
*/ */
export async function checkAuthStatus() { export async function checkAuthOnStartup() {
try { try {
// 1. Проверяем активную сессию (самый надежный способ) // 1. Проверяем активную сессию (самый надежный способ)
const isAuthenticated = await JSONRPC.Auth.isAuthenticated(); const isAuthenticated = await JSONRPC.Auth.isAuthenticated();

View File

@ -1,26 +1,23 @@
// src/modules/router.js // src/modules/router.js
import $ from 'jquery'; import $ from 'jquery';
// 🔥 Импортируем только объект StatisticsPage
import { StatisticsPage } from '../pages/Statistics.js'; import { StatisticsPage } from '../pages/Statistics.js';
import { Subscriptions } from '../pages/Subscriptions.js'; import { Subscriptions } from '../pages/Subscriptions.js';
import { Components } from '../pages/Components.js'; import { Components } from '../pages/Components.js';
import { APITokens } from '../pages/APITokens.js'; import { APITokens } from '../pages/APITokens.js';
import { getEnabledComponents } from './app.js';
import { OVPNConfig } from '../pages/OVPN.js';
// Переменная для отслеживания текущего активного хеша (для корректного unmount) // Переменная для отслеживания текущего активного хеша (для корректного unmount)
let currentRouteHash = ''; let currentRouteHash = '';
// 1. Определение ВСЕХ возможных пунктов меню // 1. Определение меню и путей
const allMenuItems = [ const menuItems = [
{ label: 'Статистика', path: 'stats', component: null }, { label: 'Статистика', path: 'stats' },
{ label: 'Подписки', path: 'subscriptions', component: null }, { label: 'Подписки', path: 'subscriptions' },
{ label: 'Настройки', path: 'settings', component: null }, { label: 'Настройки', path: 'settings' },
{ label: 'Компоненты', path: 'components', component: null }, { label: 'Компоненты', path: 'components' },
{ label: 'API', path: 'api', component: null }, { label: 'API', path: 'api' },
{ label: 'Настройка OVPN', path: 'ovpn', component: 'ru.kirillius.pf.sdn.External.API.Components.OVPN' },
]; ];
// 2. Определение страниц // 2. Определение страниц
@ -28,6 +25,7 @@ const routes = {
'#stats': { '#stats': {
render: StatisticsPage.render, render: StatisticsPage.render,
mount: StatisticsPage.mount, mount: StatisticsPage.mount,
// 🔥 Используем unmount из объекта страницы
unmount: StatisticsPage.unmount unmount: StatisticsPage.unmount
}, },
'#subscriptions': { '#subscriptions': {
@ -49,38 +47,22 @@ const routes = {
render: APITokens.render, render: APITokens.render,
mount: APITokens.mount, mount: APITokens.mount,
unmount: APITokens.unmount unmount: APITokens.unmount
},
'#ovpn': {
render: OVPNConfig.render,
mount: OVPNConfig.mount,
unmount: OVPNConfig.unmount
} }
}; };
// 🔥 Убедитесь, что здесь НЕТ слова 'export' // 3. Функция рендеринга страницы
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) { export function renderPage(hash) {
const $contentArea = $('#content-area'); const $contentArea = $('#content-area');
const key = hash.startsWith('#') ? hash : '#' + hash; const key = hash.startsWith('#') ? hash : '#' + hash;
// Если страница уже открыта, просто обновляем меню и выходим // 🔥 НОВАЯ ПРОВЕРКА: Если страница уже открыта, просто обновляем меню и выходим
if (currentRouteHash === key) { if (currentRouteHash === key) {
$('.menu-item').removeClass('active'); $('.menu-item').removeClass('active');
$(`.menu-item[data-path="${key.substring(1)}"]`).addClass('active'); $(`.menu-item[data-path="${key.substring(1)}"]`).addClass('active');
return; return;
} }
// Определяем, какой хеш был активным до этого. Используем 'stats' как дефолт
const previousKey = currentRouteHash || '#stats'; const previousKey = currentRouteHash || '#stats';
// Шаг 1: Если мы меняем страницу, и предыдущая страница имеет unmount, вызываем его // Шаг 1: Если мы меняем страницу, и предыдущая страница имеет unmount, вызываем его
@ -118,5 +100,5 @@ $(window).on('hashchange', function() {
renderPage(window.location.hash); renderPage(window.location.hash);
}); });
// 🔥 Оставляем ТОЛЬКО ОДИН экспорт в конце файла // Экспорт menuItems для построения сайдбара в ui.js
export { getFilteredMenuItems }; export { menuItems };

View File

@ -1,8 +1,7 @@
import $ from 'jquery'; 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, menuItems } from './router.js';
// Функция рендеринга формы авторизации // Функция рендеринга формы авторизации
export function renderLoginForm() { export function renderLoginForm() {
@ -10,10 +9,8 @@ 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>
@ -39,14 +36,12 @@ export function renderLoginForm() {
const $button = $('#login-button'); const $button = $('#login-button');
const $error = $('#login-error'); const $error = $('#login-error');
const password = $('#password-input').val(); const password = $('#password-input').val();
// Получение флага rememberMe
const rememberMe = $('#remember-me').prop('checked'); const rememberMe = $('#remember-me').prop('checked');
$error.hide().text(''); $error.hide().text('');
$button.prop('disabled', true).text('Вход...'); $button.prop('disabled', true).text('Вход...');
try { try {
// Передача флага rememberMe
const success = await handleLogin(password, rememberMe); const success = await handleLogin(password, rememberMe);
if (success) { if (success) {
setAuthenticated(true); setAuthenticated(true);
@ -64,9 +59,7 @@ export function renderLoginForm() {
// Функция рендеринга рабочего стола (Dashboard) // Функция рендеринга рабочего стола (Dashboard)
export function renderDashboard() { export function renderDashboard() {
// Используем функцию из роутера для получения актуального списка меню // Используем menuItems из router.js для построения сайдбара
const menuItems = getFilteredMenuItems();
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>`; return `<a href="#${item.path}" class="menu-item" data-path="${item.path}">${item.label}</a>`;
}).join(''); }).join('');
@ -75,10 +68,8 @@ export function renderDashboard() {
<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">SDN Control</h2>
<nav id="main-nav"> <nav id="main-nav">${sidebarHtml}</nav>
${sidebarHtml}
<a href="#" id="logout-btn" class="menu-item logout-btn">Выход</a> <a href="#" id="logout-btn" class="menu-item logout-btn">Выход</a>
</nav>
</div> </div>
<main id="content-area" class="content-area"> <main id="content-area" class="content-area">
@ -100,6 +91,7 @@ export function renderDashboard() {
// Прикрепляем события для меню (роутер) // Прикрепляем события для меню (роутер)
$('#main-nav').on('click', '.menu-item', function(e) { $('#main-nav').on('click', '.menu-item', function(e) {
// Предотвращаем стандартный переход, чтобы обработать его через JS
e.preventDefault(); e.preventDefault();
const path = $(this).data('path'); const path = $(this).data('path');

View File

@ -1,158 +1,5 @@
import $ from 'jquery'; // src/pages/APITokens.js
import { JSONRPC } from '@/json-rpc.js';
let tokens = []; import { APITokensPage } from '../ui/api_tokens.js';
async function loadTokens() { export const APITokens = APITokensPage;
try {
tokens = await JSONRPC.Auth.listTokens();
tokens = tokens || [];
return true;
} catch (e) {
console.error('Ошибка при загрузке токенов:', e);
tokens = [];
return false;
}
}
function renderTokenList() {
const $listContainer = $('#api-token-container');
const createButtonHtml = `
<button id="create-token-btn" class="btn-primary" style="width: 200px; margin-bottom: 30px;">
Создать токен API
</button>
<div id="token-status-message" class="success-message" style="display: none;"></div>
`;
if (tokens.length === 0) {
$listContainer.html(createButtonHtml + '<p>Активных API токенов не найдено.</p>');
attachEventHandlers();
return;
}
const listHtml = tokens.map(item => {
const displayToken = item.token ?
`${item.token.substring(0, 8)}...${item.token.substring(item.token.length - 4)}` :
'Неизвестный токен';
return `
<li class="token-item" data-full-token="${item.token}">
<div class="token-description">${item.description}</div>
<div class="token-details">
<span class="token-value">Ключ: ${displayToken}</span>
<button class="delete-token-btn btn-icon" data-token="${item.token}" title="Удалить токен">
<span class="delete-icon"></span>
</button>
</div>
</li>
`;
}).join('');
const html = `
${createButtonHtml}
<ul class="token-list">${listHtml}</ul>
`;
$listContainer.html(html);
attachEventHandlers();
}
function showCreateTokenModal() {
const description = prompt('Введите описание для нового API токена (например, "Токен для Telegram бота"):');
if (description === null) {
return;
}
if (description.trim() === '') {
alert('Описание токена не может быть пустым.');
return;
}
createToken(description.trim());
}
async function createToken(description) {
const $btn = $('#create-token-btn');
const $message = $('#token-status-message');
$message.hide().removeClass('error-message').addClass('success-message');
$btn.prop('disabled', true).text('Создание...');
try {
const newTokenValue = await JSONRPC.Auth.createAPIToken(description);
if (!newTokenValue) {
throw new Error('Сервер не вернул токен.');
}
prompt('ВАЖНО: Ваш новый токен API. Сохраните его, он больше не будет показан!', newTokenValue);
await loadTokens();
renderTokenList();
$message.text(`Токен "${description}" успешно создан.`).show();
} catch (e) {
console.error('Ошибка создания токена:', e);
$message.removeClass('success-message').addClass('error-message').text('Ошибка при создании токена.').show();
} finally {
$btn.prop('disabled', false).text('Создать токен API');
setTimeout(() => $message.fadeOut(), 8000);
}
}
async function deleteToken(token) {
if (!confirm('Вы уверены, что хотите удалить этот API токен?')) {
return;
}
const $item = $(`li.token-item[data-full-token="${token}"]`);
$item.addClass('deleting').css('opacity', 0.5);
try {
await JSONRPC.Auth.removeToken(token);
tokens = tokens.filter(item => item.token !== token);
renderTokenList();
const $message = $('#token-status-message');
$message.text('Токен успешно удален.').addClass('success-message').show();
} catch (e) {
console.error('Ошибка удаления токена:', e);
$item.removeClass('deleting').css('opacity', 1);
const $message = $('#token-status-message');
$message.removeClass('success-message').addClass('error-message').text('Ошибка при удалении токена.').show();
}
}
function attachEventHandlers() {
$('#create-token-btn').off('click').on('click', showCreateTokenModal);
$('#api-token-container').off('click', '.delete-token-btn').on('click', '.delete-token-btn', function () {
const tokenToDelete = $(this).data('token');
if (tokenToDelete) {
deleteToken(tokenToDelete);
}
});
}
export const APITokens = {
render: () => `
<h1 class="page-title">API Токены</h1>
<div id="api-token-container">
<p>Загрузка токенов...</p>
</div>
`,
mount: async () => {
const success = await loadTokens();
if (success) {
renderTokenList();
} else {
$('#api-token-container').html('<p class="error-message">Не удалось загрузить токены. Проверьте соединение с сервером.</p>');
}
},
unmount: () => {
tokens = [];
$('#api-token-container').off();
}
};

View File

@ -1,107 +1,5 @@
import $ from 'jquery'; // src/pages/Components.js
import { JSONRPC } from '@/json-rpc.js';
let availableComponents = []; import { ComponentsPage } from '../ui/components.js';
let enabledComponents = [];
function getShortName(fullName) { export const Components = ComponentsPage;
const lastDotIndex = fullName.lastIndexOf('.');
if (lastDotIndex > -1 && lastDotIndex < fullName.length - 1) {
return fullName.substring(lastDotIndex + 1);
}
return fullName;
}
async function loadComponentData() {
try {
enabledComponents = await JSONRPC.System.getEnabledComponents();
availableComponents = await JSONRPC.System.getAvailableComponents();
availableComponents.sort();
} catch (e) {
console.error('Ошибка при загрузке данных компонентов:', e);
availableComponents = [];
enabledComponents = [];
return false;
}
return true;
}
function renderComponentList() {
const $listContainer = $('#component-list-container');
if (availableComponents.length === 0) {
$listContainer.html('<p>Нет доступных системных компонентов.</p>');
return;
}
const listHtml = availableComponents.map(fullName => {
const shortName = getShortName(fullName);
const isChecked = enabledComponents.includes(fullName);
return `
<li class="subscription-item">
<label>
<input type="checkbox" class="component-checkbox" value="${fullName}" ${isChecked ? 'checked' : ''}>
<span class="resource-key">${shortName}</span>
<span class="resource-details" style="margin-left: 10px;">(${fullName})</span>
</label>
</li>
`;
}).join('');
const html = `
<ul class="subscription-list">${listHtml}</ul>
<button id="save-components-btn" class="btn-primary" style="width: 250px; margin-top: 30px;">Сохранить</button>
<div id="component-status-message" class="error-message" style="display: none;"></div>
`;
$listContainer.html(html);
attachEventHandlers();
}
function attachEventHandlers() {
$('#save-components-btn').on('click', async function () {
const $btn = $(this);
const $message = $('#component-status-message');
$message.hide().removeClass('success-message').addClass('error-message');
const newEnabledComponents = $('.component-checkbox:checked').map(function () {
return $(this).val();
}).get();
$btn.prop('disabled', true).text('Сохранение...');
try {
await JSONRPC.System.setEnabledComponents(newEnabledComponents);
enabledComponents = newEnabledComponents;
$message.text('Настройки компонентов успешно сохранены!').addClass('success-message').show();
} catch (e) {
console.error('Ошибка сохранения компонентов:', e);
$message.text('Ошибка при сохранении компонентов.').show();
} finally {
$btn.prop('disabled', false).text('Сохранить');
setTimeout(() => $message.fadeOut(), 5000);
}
});
}
export const Components = {
render: () => `
<h1 class="page-title">Управление Компонентами</h1>
<div id="component-list-container">
<p>Загрузка доступных компонентов...</p>
</div>
`,
mount: async () => {
const success = await loadComponentData();
if (success) {
renderComponentList();
} else {
$('#component-list-container').html('<p class="error-message">Не удалось загрузить данные. Проверьте соединение с сервером.</p>');
}
},
unmount: () => {
availableComponents = [];
enabledComponents = [];
}
};

View File

@ -1,237 +0,0 @@
import $ from 'jquery';
import { JSONRPC } from '@/json-rpc.js';
const OVPN_COMPONENT_NAME = 'ru.kirillius.pf.sdn.External.API.Components.OVPN';
const DEFAULT_PORT = 22;
const FIELD_IDS = {
host: 'ovpn-host',
port: 'ovpn-port',
username: 'ovpn-username',
password: 'ovpn-password',
useSSH: 'ovpn-use-ssh',
restartCommand: 'ovpn-restart-command',
saveButton: 'save-ovpn-btn',
restartButton: 'restart-ovpn-btn',
status: 'ovpn-status-message',
container: 'ovpn-config-container'
};
const SELECTORS = {
container: `#${FIELD_IDS.container}`,
host: `#${FIELD_IDS.host}`,
port: `#${FIELD_IDS.port}`,
username: `#${FIELD_IDS.username}`,
password: `#${FIELD_IDS.password}`,
useSSH: `#${FIELD_IDS.useSSH}`,
restartCommand: `#${FIELD_IDS.restartCommand}`,
saveButton: `#${FIELD_IDS.saveButton}`,
restartButton: `#${FIELD_IDS.restartButton}`,
status: `#${FIELD_IDS.status}`
};
let currentConfig = {};
let statusTimeoutId = null;
const getStatusElement = () => $(SELECTORS.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 || 'Ошибка выполнения действия OVPN:', error);
if (messages?.error) {
updateStatus(messages.error, 'error');
}
} finally {
$button.prop('disabled', false).text(originalText);
}
}
async function loadConfig() {
try {
const fullConfig = await JSONRPC.System.getComponentConfig(OVPN_COMPONENT_NAME);
currentConfig = fullConfig || {};
return true;
} catch (error) {
console.error('Ошибка при загрузке конфига OVPN:', error);
return false;
}
}
function renderOVPNForm() {
const $container = $(SELECTORS.container);
const shellConfig = currentConfig.shellConfig || {};
$container.html(`
<div class="component-config-form">
<h3 class="config-section-title">Конфигурация Shell (ShellConfig)</h3>
<div class="form-group checkbox-group">
<input type="checkbox" id="${FIELD_IDS.useSSH}" ${shellConfig.useSSH ? 'checked' : ''}>
<label for="${FIELD_IDS.useSSH}" style="margin-bottom: 0;">Использовать SSH</label>
</div>
<div class="form-group">
<label for="${FIELD_IDS.host}">Хост</label>
<input type="text" id="${FIELD_IDS.host}" class="form-control" value="${shellConfig.host || ''}">
</div>
<div class="form-group">
<label for="${FIELD_IDS.port}">Порт</label>
<input type="number" id="${FIELD_IDS.port}" class="form-control" value="${shellConfig.port || DEFAULT_PORT}">
</div>
<div class="form-group">
<label for="${FIELD_IDS.username}">Имя пользователя</label>
<input type="text" id="${FIELD_IDS.username}" class="form-control" value="${shellConfig.username || ''}">
</div>
<div class="form-group">
<label for="${FIELD_IDS.password}">Пароль</label>
<input type="password" id="${FIELD_IDS.password}" class="form-control" placeholder="Оставьте пустым для сохранения текущего пароля">
</div>
<h3 class="config-section-title" style="margin-top: 40px;">Перезапуск сервиса</h3>
<div class="form-group">
<label for="${FIELD_IDS.restartCommand}">Команда для перезапуска</label>
<input type="text" id="${FIELD_IDS.restartCommand}" class="form-control" value="${currentConfig.restartCommand || 'systemctl restart openvpn@server'}">
<small class="hint-text">Команда, которая будет выполнена через Shell для перезапуска сервиса OVPN.</small>
</div>
<div class="action-buttons">
<button id="${FIELD_IDS.saveButton}" class="btn-primary" style="width: 200px;">Сохранить Конфигурацию</button>
<button id="${FIELD_IDS.restartButton}" class="btn-secondary" style="width: 200px; margin-left: 20px;">Перезапустить Сервис</button>
</div>
<div id="${FIELD_IDS.status}" class="error-message" style="display: none;"></div>
</div>
`);
attachEventHandlers();
}
function collectConfigFromForm() {
const shellConfig = currentConfig.shellConfig || {};
const newPassword = $(SELECTORS.password).val();
const useSSH = $(SELECTORS.useSSH).prop('checked');
return {
shellConfig: {
useSSH,
host: $(SELECTORS.host).val(),
port: parseInt($(SELECTORS.port).val(), 10) || DEFAULT_PORT,
username: $(SELECTORS.username).val(),
password: newPassword ? newPassword : (shellConfig.password || ''),
},
restartCommand: $(SELECTORS.restartCommand).val()
};
}
async function handleSave() {
const $button = $(SELECTORS.saveButton);
await runAction($button, 'Сохранение...', async () => {
const newConfig = collectConfigFromForm();
await JSONRPC.System.setComponentConfig(OVPN_COMPONENT_NAME, newConfig);
currentConfig = newConfig;
}, {
success: 'Конфигурация OVPN успешно сохранена.',
error: 'Ошибка при сохранении конфигурации.',
log: 'Ошибка сохранения конфига OVPN'
});
}
async function handleRestart() {
const $button = $(SELECTORS.restartButton);
await runAction($button, 'Перезапуск...', async () => {
return JSONRPC.OVPN.restartSystemService();
}, {
success: (result) => result || 'Сервис OVPN успешно перезапущен.',
error: 'Ошибка при перезапуске сервиса.',
log: 'Ошибка перезапуска OVPN'
});
}
function attachEventHandlers() {
$(SELECTORS.saveButton).off('click').on('click', handleSave);
$(SELECTORS.restartButton).off('click').on('click', handleRestart);
$(SELECTORS.useSSH).off('change').on('change', toggleSSHFields);
toggleSSHFields();
}
function detachEventHandlers() {
$(SELECTORS.saveButton).off('click');
$(SELECTORS.restartButton).off('click');
$(SELECTORS.useSSH).off('change');
}
function toggleSSHFields() {
const enabled = $(SELECTORS.useSSH).prop('checked');
$(SELECTORS.host).prop('disabled', !enabled);
$(SELECTORS.port).prop('disabled', !enabled);
$(SELECTORS.username).prop('disabled', !enabled);
$(SELECTORS.password).prop('disabled', !enabled);
}
export const OVPNConfig = {
render: () => `
<h1 class="page-title">Настройка OVPN</h1>
<div id="${FIELD_IDS.container}">
<p>Загрузка конфигурации...</p>
</div>
`,
mount: async () => {
const success = await loadConfig();
if (success) {
renderOVPNForm();
} else {
$(SELECTORS.container).html('<p class="error-message">Не удалось загрузить конфигурацию OVPN.</p>');
}
},
unmount: () => {
detachEventHandlers();
clearStatus();
currentConfig = {};
}
};

View File

@ -1,179 +1,22 @@
import $ from 'jquery'; // src/pages/Statistics.js
import { JSONRPC } from '@/json-rpc.js';
const POLLING_INTERVAL = 5000; import { renderStatisticsPage, stopPolling } from '../ui/statistics.js';
let updateInterval = null;
const $content = () => $('#statistics-content');
const createStatusCard = (title, status, buttonId, buttonLabel, isUpdating, resourceStatsHtml = '') => {
const statusClass = status === 'Обновляется' || status === 'Обнаружено' ? 'text-yellow-500' : 'text-green-500';
return `
<div class="stat-card">
<h3>${title}</h3>
<p class="status-line">
Статус: <span class="${statusClass}">${status}</span>
</p>
<button id="${buttonId}" class="btn-secondary" ${isUpdating ? 'disabled' : ''}>
${isUpdating ? 'Обновление...' : buttonLabel}
</button>
${resourceStatsHtml}
</div>
`;
};
const createResourceStatsHtml = (resources) => {
return `
<div class="resource-group">
<h4>Ресурсы:</h4>
<div class="resource-row">
${createResourceBadge("Домены", resources.domains.length)}
${createResourceBadge("ASN", resources.ASN.length)}
${createResourceBadge("Подсети", resources.subnets.length)}
</div>
</div>
`;
};
const createResourceBadge = (title, count) => {
return `
<div class="resource-badge">
<span class="resource-count small-count">${count}</span>
<span class="resource-title">${title}</span>
</div>
`;
};
async function getSubscriptionCount() {
try {
const subscribedResources = await JSONRPC.SubscriptionManager.getSubscribedResources();
return (subscribedResources && subscribedResources.length) || 0;
} catch (e) {
console.error('Ошибка при получении subscribedResources:', e);
return 0;
}
}
async function getNetworkResources() {
try {
const resources = await JSONRPC.NetworkManager.getOutputResources();
return {
domains: resources.domains || [],
ASN: resources.ASN || [],
subnets: resources.subnets || []
};
} catch (e) {
console.error('Ошибка при получении ресурсов:', e);
return { domains: [], ASN: [], subnets: [] };
}
}
async function renderSystemAndManagerStatus() {
const dataHtml = [];
const hasUpdates = await JSONRPC.System.hasUpdates();
const isSubUpdating = await JSONRPC.SubscriptionManager.isUpdating();
const isNetUpdating = await JSONRPC.NetworkManager.isUpdating();
const subCount = await getSubscriptionCount();
const networkResources = await getNetworkResources();
dataHtml.push(createStatusCard(
'Обновление ПО',
hasUpdates ? 'Обнаружено' : 'Нет обновлений',
'update-software-btn',
'Проверить',
false
));
const subStatsHtml = `<div class="resource-group" style="margin-top: 20px;">
<h4>Подписки:</h4>
<div class="resource-row">
${createResourceBadge("Активно", subCount)}
</div>
</div>`;
dataHtml.push(createStatusCard(
'Менеджер Подписок',
isSubUpdating ? 'Обновляется' : 'Активен',
'update-sub-btn',
'Обновить',
isSubUpdating,
subStatsHtml
));
const netResourcesHtml = createResourceStatsHtml(networkResources);
dataHtml.push(createStatusCard(
'Менеджер Сетей',
isNetUpdating ? 'Обновляется' : 'Активен',
'update-net-btn',
'Обновить',
isNetUpdating,
netResourcesHtml
));
$content().html(`<div class="stats-grid status-grid">${dataHtml.join('')}</div>`);
attachEventHandlers();
}
function stopPolling() {
if (updateInterval) {
console.log('clear interval');
clearInterval(updateInterval);
updateInterval = null;
}
}
function attachEventHandlers() {
$('#update-sub-btn').off('click').on('click', async function () {
const $btn = $(this);
$btn.prop('disabled', true).text('Обновление...');
try {
await JSONRPC.SubscriptionManager.triggerUpdate();
alert('Обновление подписок запущено!');
renderSystemAndManagerStatus();
} catch (e) {
alert('Ошибка при запуске обновления подписок!');
$btn.prop('disabled', false).text('Обновить');
}
});
$('#update-net-btn').off('click').on('click', async function () {
const $btn = $(this);
$btn.prop('disabled', true).text('Обновление...');
try {
await JSONRPC.NetworkManager.triggerUpdate(true);
alert('Обновление сетей запущено!');
renderSystemAndManagerStatus();
} catch (e) {
alert('Ошибка при запуске обновления сетей!');
$btn.prop('disabled', false).text('Обновить');
}
});
$('#update-software-btn').off('click').on('click', function () {
alert('Проверка обновлений ПО запущена...');
});
}
async function mountStatisticsPage() {
stopPolling();
await renderSystemAndManagerStatus();
console.log('start interval');
updateInterval = setInterval(renderSystemAndManagerStatus, POLLING_INTERVAL);
}
export const StatisticsPage = { export const StatisticsPage = {
render: () => ` render: () => {
return `
<h1 class="page-title">Статистика</h1> <h1 class="page-title">Статистика</h1>
<div id="statistics-content"> <div id="statistics-content">
Загрузка данных... Загрузка данных...
</div> </div>
`, `;
mount: () => {
mountStatisticsPage();
}, },
mount: () => {
// Вызывает renderStatisticsPage, который запускает опрос
renderStatisticsPage();
},
// 🔥 ВСТРОЕННАЯ ФУНКЦИЯ UNMOUNT: Роутер использует StatisticsPage.unmount
unmount: stopPolling unmount: stopPolling
}; };
// 🔥 УДАЛЕН ЭКСПОРТ { stopPolling }; из этого файла.

View File

@ -1,110 +1,6 @@
import $ from 'jquery'; // src/pages/Subscriptions.js
import { JSONRPC } from '@/json-rpc.js';
let availableResources = {}; import { SubscriptionsPage } from '../ui/subscriptions.js';
let subscribedKeys = [];
async function loadSubscriptionData() { // Просто реэкспортируем объект для роутера
try { export const Subscriptions = SubscriptionsPage;
subscribedKeys = await JSONRPC.SubscriptionManager.getSubscribedResources();
availableResources = await JSONRPC.SubscriptionManager.getAvailableResources();
} catch (e) {
console.error('Ошибка при загрузке данных подписок:', e);
availableResources = {};
subscribedKeys = [];
return false;
}
return true;
}
function renderSubscriptionList() {
const $listContainer = $('#subscription-list-container');
const availableKeys = Object.keys(availableResources).sort();
if (availableKeys.length === 0) {
$listContainer.html('<p>Нет доступных ресурсов для подписки.</p>');
return;
}
const listHtml = availableKeys.map(key => {
const details = availableResources[key];
const isChecked = subscribedKeys.includes(key);
const description = details.description || 'Нет описания';
const detailText = `
Доменов: ${details.domains ? details.domains.length : 0},
ASN: ${details.ASN ? details.ASN.length : 0},
Подсетей: ${details.subnets ? details.subnets.length : 0}
`;
return `
<li class="subscription-item">
<label>
<input type="checkbox" class="subscription-checkbox" value="${key}" ${isChecked ? 'checked' : ''}>
<div class="resource-info">
<div class="resource-main-line">
<span class="resource-description">${description}</span>
<span class="resource-key">(${key})</span>
</div>
<div class="resource-details">${detailText}</div>
</div>
</label>
</li>
`;
}).join('');
const html = `
<ul class="subscription-list">${listHtml}</ul>
<button id="save-subscriptions-btn" class="btn-primary" style="width: 250px; margin-top: 30px;">Сохранить</button>
<div id="subscription-status-message" class="error-message" style="display: none;"></div>
`;
$listContainer.html(html);
attachEventHandlers();
}
function attachEventHandlers() {
$('#save-subscriptions-btn').on('click', async function () {
const $btn = $(this);
const $message = $('#subscription-status-message');
$message.hide().removeClass('success-message').addClass('error-message');
const newSubscribedKeys = $('.subscription-checkbox:checked').map(function () {
return $(this).val();
}).get();
$btn.prop('disabled', true).text('Сохранение...');
try {
await JSONRPC.SubscriptionManager.setSubscribedResources(newSubscribedKeys);
subscribedKeys = newSubscribedKeys;
$message.text('Настройки подписок успешно сохранены!').addClass('success-message').show();
} catch (e) {
console.error('Ошибка сохранения подписок:', e);
$message.text('Ошибка при сохранении подписок.').show();
} finally {
$btn.prop('disabled', false).text('Сохранить');
setTimeout(() => $message.fadeOut(), 5000);
}
});
}
export const Subscriptions = {
render: () => `
<h1 class="page-title">Управление Подписками</h1>
<div id="subscription-list-container">
<p>Загрузка доступных ресурсов...</p>
</div>
`,
mount: async () => {
const success = await loadSubscriptionData();
if (success) {
renderSubscriptionList();
} else {
$('#subscription-list-container').html('<p class="error-message">Не удалось загрузить данные. Проверьте соединение с сервером.</p>');
}
},
unmount: () => {
availableResources = {};
subscribedKeys = [];
}
};

View File

@ -138,9 +138,8 @@ html.dark-theme, body {
color: white; color: white;
} }
.logout-btn { .logout-btn {
margin-top: 15px; margin-top: auto; /* Прижимает к низу */
border-top: 1px solid var(--color-border); border-top: 1px solid var(--color-border);
padding-top: 15px;
} }
.content-area { .content-area {
@ -410,57 +409,3 @@ html.dark-theme, body {
font-size: 18px; font-size: 18px;
line-height: 1; 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);
max-width: 520px;
margin: 0 auto;
}
.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;
}

205
webui/src/ui/api_tokens.js Normal file
View File

@ -0,0 +1,205 @@
import $ from 'jquery';
import { JSONRPC } from '@/json-rpc.js';
// --- Глобальное состояние ---
let tokens = [];
/**
* @private
* Загружает текущий список токенов.
* @returns {Promise<boolean>}
*/
async function loadTokens() {
try {
// Запрос списка токенов
tokens = await JSONRPC.Auth.listTokens();
tokens = tokens || [];
return true;
} catch (e) {
console.error("Ошибка при загрузке токенов:", e);
tokens = [];
return false;
}
}
/**
* @private
* Рендерит список токенов и кнопки управления.
*/
function renderTokenList() {
const $listContainer = $('#api-token-container');
// Кнопка "Создать токен"
const createButtonHtml = `
<button id="create-token-btn" class="btn-primary" style="width: 200px; margin-bottom: 30px;">
Создать токен API
</button>
<div id="token-status-message" class="success-message" style="display: none;"></div>
`;
if (tokens.length === 0) {
$listContainer.html(createButtonHtml + '<p>Активных API токенов не найдено.</p>');
attachEventHandlers();
return;
}
let listHtml = tokens.map(item => {
// Мы НЕ показываем полный токен, показываем только его начало/конец для идентификации
const displayToken = item.token ?
`${item.token.substring(0, 8)}...${item.token.substring(item.token.length - 4)}` :
'Неизвестный токен';
return `
<li class="token-item" data-full-token="${item.token}">
<div class="token-description">${item.description}</div>
<div class="token-details">
<span class="token-value">Ключ: ${displayToken}</span>
<button class="delete-token-btn btn-icon" data-token="${item.token}" title="Удалить токен">
<span class="delete-icon"></span>
</button>
</div>
</li>
`;
}).join('');
const html = `
${createButtonHtml}
<ul class="token-list">${listHtml}</ul>
`;
$listContainer.html(html);
attachEventHandlers();
}
/**
* @private
* Отображает модальное окно для создания токена.
*/
function showCreateTokenModal() {
// Используем простое модальное окно jQuery для минимализма
const description = prompt("Введите описание для нового API токена (например, 'Токен для Telegram бота'):");
if (description === null) {
return; // Пользователь нажал Отмена
}
if (description.trim() === "") {
alert("Описание токена не может быть пустым.");
return;
}
createToken(description.trim());
}
/**
* @private
* Логика создания токена и обновления UI.
* @param {string} description - Описание токена.
*/
async function createToken(description) {
const $btn = $('#create-token-btn');
const $message = $('#token-status-message');
$message.hide().removeClass('error-message').addClass('success-message');
$btn.prop('disabled', true).text('Создание...');
try {
const newTokenValue = await JSONRPC.Auth.createAPIToken(description);
if (!newTokenValue) {
throw new Error("Сервер не вернул токен.");
}
// Показываем токен пользователю ОДИН раз
prompt("ВАЖНО: Ваш новый токен API. Сохраните его, он больше не будет показан!", newTokenValue);
// Перезагружаем список
await loadTokens();
renderTokenList();
$message.text(`Токен "${description}" успешно создан.`).show();
} catch (e) {
console.error('Ошибка создания токена:', e);
$message.removeClass('success-message').addClass('error-message').text('Ошибка при создании токена.').show();
} finally {
$btn.prop('disabled', false).text('Создать токен API');
setTimeout(() => $message.fadeOut(), 8000);
}
}
/**
* @private
* Логика удаления токена.
* @param {string} token - Токен для удаления.
*/
async function deleteToken(token) {
if (!confirm("Вы уверены, что хотите удалить этот API токен?")) {
return;
}
const $item = $(`li.token-item[data-full-token="${token}"]`);
$item.addClass('deleting').css('opacity', 0.5); // Визуальная обратная связь
try {
await JSONRPC.Auth.removeToken(token);
// Удаляем из локального состояния
tokens = tokens.filter(item => item.token !== token);
// Перерисовываем список
renderTokenList();
const $message = $('#token-status-message');
$message.text('Токен успешно удален.').addClass('success-message').show();
} catch (e) {
console.error('Ошибка удаления токена:', e);
$item.removeClass('deleting').css('opacity', 1); // Восстанавливаем, если ошибка
const $message = $('#token-status-message');
$message.removeClass('success-message').addClass('error-message').text('Ошибка при удалении токена.').show();
}
}
/**
* @private
* Прикрепление обработчиков событий.
*/
function attachEventHandlers() {
$('#create-token-btn').off('click').on('click', showCreateTokenModal);
// Делегирование для кнопок удаления (они перерисовываются)
$('#api-token-container').off('click', '.delete-token-btn').on('click', '.delete-token-btn', function() {
const tokenToDelete = $(this).data('token');
if (tokenToDelete) {
deleteToken(tokenToDelete);
}
});
}
// --- Функции для роутера ---
export const APITokensPage = {
render: () => {
return `
<h1 class="page-title">API Токены</h1>
<div id="api-token-container">
<p>Загрузка токенов...</p>
</div>
`;
},
mount: async () => {
const success = await loadTokens();
if (success) {
renderTokenList();
} else {
$('#api-token-container').html('<p class="error-message">Не удалось загрузить токены. Проверьте соединение с сервером.</p>');
}
},
unmount: () => {
tokens = [];
$('#api-token-container').off(); // Удаляем все обработчики
}
};

134
webui/src/ui/components.js Normal file
View File

@ -0,0 +1,134 @@
import $ from 'jquery';
import { JSONRPC } from '@/json-rpc.js';
// --- Глобальное состояние для страницы ---
let availableComponents = [];
let enabledComponents = [];
/**
* @private
* Извлекает короткое имя компонента, беря часть строки после последней точки.
* @param {string} fullName - Полное имя компонента (напр., ru.kirillius.pf.sdn.API).
* @returns {string} Короткое имя (напр., API).
*/
function getShortName(fullName) {
// Находим позицию последней точки
const lastDotIndex = fullName.lastIndexOf('.');
// Если точка найдена, возвращаем подстроку после неё.
// Если точки нет (или она в конце), возвращаем полное имя.
if (lastDotIndex > -1 && lastDotIndex < fullName.length - 1) {
return fullName.substring(lastDotIndex + 1);
}
return fullName;
}
/** Загружает все необходимые данные для страницы (без изменений) */
async function loadComponentData() {
try {
enabledComponents = await JSONRPC.System.getEnabledComponents();
availableComponents = await JSONRPC.System.getAvailableComponents();
availableComponents.sort();
} catch (e) {
console.error("Ошибка при загрузке данных компонентов:", e);
availableComponents = [];
enabledComponents = [];
return false;
}
return true;
}
/** Рендеринг HTML списка компонентов */
function renderComponentList() {
const $listContainer = $('#component-list-container');
if (availableComponents.length === 0) {
$listContainer.html('<p>Нет доступных системных компонентов.</p>');
return;
}
let listHtml = availableComponents.map(fullName => {
// 🔥 Применяем новую функцию для отображения
const shortName = getShortName(fullName);
// Проверяем, включен ли компонент
const isChecked = enabledComponents.includes(fullName); // Проверка по полному имени!
return `
<li class="subscription-item">
<label>
<input type="checkbox" class="component-checkbox" value="${fullName}" ${isChecked ? 'checked' : ''}>
<span class="resource-key">${shortName}</span>
<span class="resource-details" style="margin-left: 10px;">(${fullName})</span>
</label>
</li>
`;
}).join('');
const html = `
<ul class="subscription-list">${listHtml}</ul>
<button id="save-components-btn" class="btn-primary" style="width: 250px; margin-top: 30px;">Сохранить</button>
<div id="component-status-message" class="error-message" style="display: none;"></div>
`;
$listContainer.html(html);
attachEventHandlers();
}
/** Обработчик кнопки "Сохранить" (без изменений, т.к. работаем с полными именами из value) */
function attachEventHandlers() {
$('#save-components-btn').on('click', async function() {
const $btn = $(this);
const $message = $('#component-status-message');
$message.hide().removeClass('success-message').addClass('error-message');
// Собираем все отмеченные ключи компонентов (они содержат полные имена)
const newEnabledComponents = $('.component-checkbox:checked').map(function() {
return $(this).val();
}).get();
$btn.prop('disabled', true).text('Сохранение...');
try {
await JSONRPC.System.setEnabledComponents(newEnabledComponents);
enabledComponents = newEnabledComponents;
$message.text('Настройки компонентов успешно сохранены!').addClass('success-message').show();
} catch (e) {
console.error('Ошибка сохранения компонентов:', e);
$message.text('Ошибка при сохранении компонентов.').show();
} finally {
$btn.prop('disabled', false).text('Сохранить');
setTimeout(() => $message.fadeOut(), 5000);
}
});
}
// --- Функции для роутера (без изменений) ---
export const ComponentsPage = {
render: () => {
return `
<h1 class="page-title">Управление Компонентами</h1>
<div id="component-list-container">
<p>Загрузка доступных компонентов...</p>
</div>
`;
},
mount: async () => {
const success = await loadComponentData();
if (success) {
renderComponentList();
} else {
$('#component-list-container').html('<p class="error-message">Не удалось загрузить данные. Проверьте соединение с сервером.</p>');
}
},
unmount: () => {
availableComponents = [];
enabledComponents = [];
}
};

198
webui/src/ui/statistics.js Normal file
View File

@ -0,0 +1,198 @@
import $ from 'jquery';
import { JSONRPC } from '@/json-rpc.js';
const $content = () => $('#statistics-content');
// 🔥 Переменная для хранения ID интервала опроса
let updateInterval = null;
const POLLING_INTERVAL = 5000; // 5 секунд
// --- Утилитарные функции для рендеринга UI-блоков ---
/** Создает HTML-карточку для отображения статуса и ресурсов */
const createStatusCard = (title, status, buttonId, buttonLabel, isUpdating, resourceStatsHtml = '') => {
const statusClass = status === 'Обновляется' || status === 'Обнаружено' ? 'text-yellow-500' : 'text-green-500';
return `
<div class="stat-card">
<h3>${title}</h3>
<p class="status-line">
Статус: <span class="${statusClass}">${status}</span>
</p>
<button id="${buttonId}" class="btn-secondary" ${isUpdating ? 'disabled' : ''}>
${isUpdating ? 'Обновление...' : buttonLabel}
</button>
${resourceStatsHtml}
</div>
`;
};
/** Создает HTML-блок для отображения ресурсов сети */
const createResourceStatsHtml = (resources) => {
return `
<div class="resource-group">
<h4>Ресурсы:</h4>
<div class="resource-row">
${createResourceBadge("Домены", resources.domains.length)}
${createResourceBadge("ASN", resources.ASN.length)}
${createResourceBadge("Подсети", resources.subnets.length)}
</div>
</div>
`;
};
/** Создает HTML-бэйдж для отдельного ресурса (с уменьшенным шрифтом) */
const createResourceBadge = (title, count) => {
return `
<div class="resource-badge">
<span class="resource-count small-count">${count}</span>
<span class="resource-title">${title}</span>
</div>
`;
};
// --- Основные функции API и DOM ---
/** Получение количества активных подписок. */
async function getSubscriptionCount() {
try {
// Используем обновленный метод getSubscribedResources
let subscribedResources = await JSONRPC.SubscriptionManager.getSubscribedResources();
return (subscribedResources && subscribedResources.length) || 0;
} catch(e) {
console.error("Ошибка при получении subscribedResources:", e);
return 0;
}
}
/** Запрос данных о ресурсах сети. */
async function getNetworkResources() {
try {
let res = await JSONRPC.NetworkManager.getOutputResources();
// Используем ключи, как они приходят с API: domains, ASN, subnets
return {
domains: res.domains || [],
ASN: res.ASN || [],
subnets: res.subnets || []
};
} catch(e) {
console.error("Ошибка при получении ресурсов:", e);
return { domains: [], ASN: [], subnets: [] };
}
}
/** 2. Запрос и рендеринг данных о ПО и Статусах */
async function renderSystemAndManagerStatus() {
const dataHtml = [];
// --- Получение данных ---
const hasUpdates = await JSONRPC.System.hasUpdates();
const isSubUpdating = await JSONRPC.SubscriptionManager.isUpdating();
const isNetUpdating = await JSONRPC.NetworkManager.isUpdating();
const subCount = await getSubscriptionCount();
const networkResources = await getNetworkResources();
// 1. Информация об обновлении ПО
dataHtml.push(createStatusCard(
"Обновление ПО",
hasUpdates ? "Обнаружено" : "Нет обновлений",
"update-software-btn",
"Проверить",
false
));
// 2. Статус менеджера подписок (добавляем статистику)
const subStatsHtml = `<div class="resource-group" style="margin-top: 20px;">
<h4>Подписки:</h4>
<div class="resource-row">
${createResourceBadge("Активно", subCount)}
</div>
</div>`;
dataHtml.push(createStatusCard(
"Менеджер Подписок",
isSubUpdating ? "Обновляется" : "Активен",
"update-sub-btn",
"Обновить",
isSubUpdating,
subStatsHtml
));
// 3. Статус менеджера сетей (ВКЛЮЧАЕМ РЕСУРСЫ)
const netResourcesHtml = createResourceStatsHtml(networkResources);
dataHtml.push(createStatusCard(
"Менеджер Сетей",
isNetUpdating ? "Обновляется" : "Активен",
"update-net-btn",
"Обновить",
isNetUpdating,
netResourcesHtml
));
// Обновляем DOM один раз
$content().html(`<div class="stats-grid status-grid">${dataHtml.join('')}</div>`);
// Прикрепляем обработчики событий после рендеринга
attachEventHandlers();
}
// --- Главная функция монтирования ---
export async function renderStatisticsPage() {
// 1. Очищаем старый интервал (на случай, если он был)
stopPolling();
// 2. Первичный рендеринг
await renderSystemAndManagerStatus();
console.log("start interval")
// 3. Запускаем опрос каждые 5 секунд
updateInterval = setInterval(renderSystemAndManagerStatus, POLLING_INTERVAL);
}
/** 🔥 Остановка интервала опроса при уходе со страницы */
export function stopPolling() {
if (updateInterval) {
console.log("clear interval")
clearInterval(updateInterval);
updateInterval = null;
}
}
/** Прикрепление обработчиков к кнопкам */
function attachEventHandlers() {
// Обработчик для менеджера подписок
$('#update-sub-btn').off('click').on('click', async function() {
const $btn = $(this);
$btn.prop('disabled', true).text('Обновление...');
try {
await JSONRPC.SubscriptionManager.triggerUpdate();
alert("Обновление подписок запущено!");
renderSystemAndManagerStatus(); // Обновляем немедленно
} catch(e) {
alert("Ошибка при запуске обновления подписок!");
$btn.prop('disabled', false).text('Обновить');
}
});
// Обработчик для менеджера сетей
$('#update-net-btn').off('click').on('click', async function() {
const $btn = $(this);
$btn.prop('disabled', true).text('Обновление...');
try {
// Передаем аргумент true в triggerUpdate
await JSONRPC.NetworkManager.triggerUpdate(true);
alert("Обновление сетей запущено!");
renderSystemAndManagerStatus(); // Обновляем немедленно
} catch(e) {
alert("Ошибка при запуске обновления сетей!");
$btn.prop('disabled', false).text('Обновить');
}
});
// Кнопка проверки обновлений ПО
$('#update-software-btn').off('click').on('click', function() {
alert("Проверка обновлений ПО запущена...");
});
}

View File

@ -0,0 +1,126 @@
import $ from 'jquery';
import { JSONRPC } from '@/json-rpc.js';
// --- Глобальное состояние для страницы ---
let availableResources = {};
let subscribedKeys = [];
/** Загружает все необходимые данные для страницы */
async function loadSubscriptionData() {
try {
subscribedKeys = await JSONRPC.SubscriptionManager.getSubscribedResources();
availableResources = await JSONRPC.SubscriptionManager.getAvailableResources();
} catch (e) {
console.error("Ошибка при загрузке данных подписок:", e);
availableResources = {};
subscribedKeys = [];
return false;
}
return true;
}
/** Рендеринг HTML списка */
function renderSubscriptionList() {
const $listContainer = $('#subscription-list-container');
const availableKeys = Object.keys(availableResources).sort();
if (availableKeys.length === 0) {
$listContainer.html('<p>Нет доступных ресурсов для подписки.</p>');
return;
}
let listHtml = availableKeys.map(key => {
const details = availableResources[key];
const isChecked = subscribedKeys.includes(key);
// 🔥 Обновлено: Используем description и меняем структуру
const description = details.description || 'Нет описания';
// Формируем строку с деталями (теперь на новой строке)
const detailText = `
Доменов: ${details.domains ? details.domains.length : 0},
ASN: ${details.ASN ? details.ASN.length : 0},
Подсетей: ${details.subnets ? details.subnets.length : 0}
`;
return `
<li class="subscription-item">
<label>
<input type="checkbox" class="subscription-checkbox" value="${key}" ${isChecked ? 'checked' : ''}>
<div class="resource-info">
<div class="resource-main-line">
<span class="resource-description">${description}</span>
<span class="resource-key">(${key})</span>
</div>
<div class="resource-details">${detailText}</div>
</div>
</label>
</li>
`;
}).join('');
const html = `
<ul class="subscription-list">${listHtml}</ul>
<button id="save-subscriptions-btn" class="btn-primary" style="width: 250px; margin-top: 30px;">Сохранить</button>
<div id="subscription-status-message" class="error-message" style="display: none;"></div>
`;
$listContainer.html(html);
attachEventHandlers();
}
/** Обработчик кнопки "Сохранить" (остается без изменений) */
function attachEventHandlers() {
$('#save-subscriptions-btn').on('click', async function() {
const $btn = $(this);
const $message = $('#subscription-status-message');
$message.hide().removeClass('success-message').addClass('error-message');
const newSubscribedKeys = $('.subscription-checkbox:checked').map(function() {
return $(this).val();
}).get();
$btn.prop('disabled', true).text('Сохранение...');
try {
await JSONRPC.SubscriptionManager.setSubscribedResources(newSubscribedKeys);
subscribedKeys = newSubscribedKeys;
$message.text('Настройки подписок успешно сохранены!').addClass('success-message').show();
} catch (e) {
console.error('Ошибка сохранения подписок:', e);
$message.text('Ошибка при сохранении подписок.').show();
} finally {
$btn.prop('disabled', false).text('Сохранить');
setTimeout(() => $message.fadeOut(), 5000);
}
});
}
// ... (SubscriptionsPage, loadSubscriptionData остаются без изменений) ...
export const SubscriptionsPage = {
render: () => {
return `
<h1 class="page-title">Управление Подписками</h1>
<div id="subscription-list-container">
<p>Загрузка доступных ресурсов...</p>
</div>
`;
},
mount: async () => {
const success = await loadSubscriptionData();
if (success) {
renderSubscriptionList();
} else {
$('#subscription-list-container').html('<p class="error-message">Не удалось загрузить данные. Проверьте соединение с сервером.</p>');
}
},
unmount: () => {
availableResources = {};
subscribedKeys = [];
}
};

View File

@ -3,10 +3,15 @@ import { fileURLToPath, URL } from 'node:url'
import path from 'node:path' import path from 'node:path'
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [], plugins: [
vue(),
vueDevTools(),
],
resolve: { resolve: {
alias: { alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)) '@': fileURLToPath(new URL('./src', import.meta.url))