From 1bcf317c8f17e153fc26824bcbdc579f715cd8a0 Mon Sep 17 00:00:00 2001 From: kirillius Date: Sun, 19 Oct 2025 11:38:49 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=20=D1=80=D0=B5=D0=B4=D0=B0=D0=BA=D1=82=D0=BE=D1=80=20?= =?UTF-8?q?=D0=BB=D0=BE=D0=BA=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B=D1=85=20=D1=80?= =?UTF-8?q?=D0=B5=D0=BF=D0=BE=D0=B7=D0=B8=D1=82=D0=BE=D1=80=D0=B8=D0=B5?= =?UTF-8?q?=D0=B2=20=D0=B8=20=D1=83=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD=D0=BE=20?= =?UTF-8?q?=D0=BB=D0=B8=D1=88=D0=BD=D0=B5=D0=B5=20=D1=81=D0=BE=D0=B1=D1=8B?= =?UTF-8?q?=D1=82=D0=B8=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pf/sdn/web/RPC/NetworkManager.java | 46 -- .../pf/sdn/web/RPC/SubscriptionManager.java | 100 ++++ .../ru/kirillius/pf/sdn/web/WebService.java | 6 - .../pf/sdn/core/ContextEventsHandler.java | 6 - .../Subscription/SubscriptionService.java | 19 +- webui/src/modules/router.js | 7 + webui/src/pages/LocalStorages.js | 535 ++++++++++++++++++ webui/src/pages/UnlockSite.js | 65 ++- 8 files changed, 710 insertions(+), 74 deletions(-) create mode 100644 webui/src/pages/LocalStorages.js diff --git a/app/src/main/java/ru/kirillius/pf/sdn/web/RPC/NetworkManager.java b/app/src/main/java/ru/kirillius/pf/sdn/web/RPC/NetworkManager.java index 9805272..1fde039 100644 --- a/app/src/main/java/ru/kirillius/pf/sdn/web/RPC/NetworkManager.java +++ b/app/src/main/java/ru/kirillius/pf/sdn/web/RPC/NetworkManager.java @@ -68,54 +68,8 @@ public class NetworkManager implements RPC { return JSONUtility.serializeStructure(context.getServiceManager().getService(NetworkingService.class).getOutputResources()); } - @JRPCMethod - @ProtectedMethod - public void createLocalResourceFile( - @JRPCArgument(name = "name") String name, - @JRPCArgument(name = "domain") String domain, - @JRPCArgument(name = "ASN") JSONArray ASN, - @JRPCArgument(name = "subnets") JSONArray subnets, - @JRPCArgument(name = "addresses") JSONArray addresses) throws IOException { - var optional = context.getConfig().getSubscriptions().stream().filter(repositoryConfig -> repositoryConfig.getType().equals(LocalFilesystemSubscription.class)).findFirst(); - if (optional.isEmpty()) { - throw new IllegalStateException("Unable to find any local repository of subscriptions"); - } - - var repositoryConfig = optional.get(); - - var directory = new File(repositoryConfig.getSource()); - if (!directory.exists()) { - throw new FileNotFoundException("Unable to find directory " + directory.getAbsolutePath()); - } - - try (var writer = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(new File(directory, name + ".json"))))) { - var merged = JSONUtility.deserializeCollection(subnets, IPv4Subnet.class, null).collect(Collectors.toList()); - addresses.forEach(address -> { - merged.add(new IPv4Subnet(address.toString(), 32)); - }); - //noinspection rawtypes - @SuppressWarnings("unchecked") - var asnList = (List) JSONUtility.deserializeCollection(ASN, Integer.class, (Class) DefaultPropertySerializer.class).toList(); - writer.write(JSONUtility.serializeStructure( - NetworkResourceBundle.builder() - .ASN(asnList) - .subnets(merged) - .domains(List.of(domain)) - .description("Unlocked resource: " + name + " (" + domain + ")") - .build() - ).toString(2)); - - - } - - var subscribedResources = context.getConfig().getSubscribedResources(); - subscribedResources.add(repositoryConfig.getName() + ":" + name); - - context.getServiceManager().getService(SubscriptionService.class).triggerUpdate(); - } - @JRPCMethod @ProtectedMethod public JSONObject discoverBlockedDomainResources(@JRPCArgument(name = "domain") String domain, @JRPCArgument(name = "server") String nameserver) { diff --git a/app/src/main/java/ru/kirillius/pf/sdn/web/RPC/SubscriptionManager.java b/app/src/main/java/ru/kirillius/pf/sdn/web/RPC/SubscriptionManager.java index 091294f..73b01d4 100644 --- a/app/src/main/java/ru/kirillius/pf/sdn/web/RPC/SubscriptionManager.java +++ b/app/src/main/java/ru/kirillius/pf/sdn/web/RPC/SubscriptionManager.java @@ -2,14 +2,21 @@ package ru.kirillius.pf.sdn.web.RPC; import org.json.JSONArray; import org.json.JSONObject; +import ru.kirillius.json.DefaultPropertySerializer; import ru.kirillius.json.JSONUtility; import ru.kirillius.json.rpc.Annotations.JRPCArgument; import ru.kirillius.json.rpc.Annotations.JRPCMethod; +import ru.kirillius.pf.sdn.External.API.LocalFilesystemSubscription; import ru.kirillius.pf.sdn.core.Context; +import ru.kirillius.pf.sdn.core.Networking.IPv4Subnet; import ru.kirillius.pf.sdn.core.Networking.NetworkResourceBundle; +import ru.kirillius.pf.sdn.core.Subscription.RepositoryConfig; import ru.kirillius.pf.sdn.core.Subscription.SubscriptionService; import ru.kirillius.pf.sdn.web.ProtectedMethod; +import java.io.*; +import java.util.ArrayList; +import java.util.List; import java.util.stream.Collectors; /** @@ -70,4 +77,97 @@ public class SubscriptionManager implements RPC { context.getConfig().setSubscribedResources(JSONUtility.deserializeCollection(subscribedResources, String.class, null).collect(Collectors.toList())); triggerUpdate(); } + + @JRPCMethod + @ProtectedMethod + public void writeLocalResourceFile( + @JRPCArgument(name = "name") String name, + @JRPCArgument(name = "description") String description, + @JRPCArgument(name = "domains") JSONArray domains, + @JRPCArgument(name = "ASN") JSONArray ASN, + @JRPCArgument(name = "subnets") JSONArray subnets, + @JRPCArgument(name = "addresses") JSONArray addresses, + @JRPCArgument(name = "storage") String storage, + @JRPCArgument(name = "subscribe") boolean subscribe) throws IOException { + var repositoryConfig = findLocalRepo(storage); + var directory = new File(repositoryConfig.getSource()); + if (!directory.exists()) { + throw new FileNotFoundException("Unable to find directory " + directory.getAbsolutePath()); + } + + var domainList = new ArrayList(); + + domains.forEach(d->domainList.add(d.toString())); + + try (var writer = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(new File(directory, name + ".json"))))) { + var merged = JSONUtility.deserializeCollection(subnets, IPv4Subnet.class, null).collect(Collectors.toList()); + addresses.forEach(address -> { + merged.add(new IPv4Subnet(address.toString(), 32)); + }); + //noinspection rawtypes + @SuppressWarnings("unchecked") + var asnList = (List) JSONUtility.deserializeCollection(ASN, Integer.class, (Class) DefaultPropertySerializer.class).toList(); + writer.write(JSONUtility.serializeStructure( + NetworkResourceBundle.builder() + .ASN(asnList) + .subnets(merged) + .domains(domainList) + .description(description) + .build() + ).toString(2)); + } + if (subscribe) { + var subscribedResources = context.getConfig().getSubscribedResources(); + subscribedResources.add(repositoryConfig.getName() + ":" + name); + } + + context.getServiceManager().getService(SubscriptionService.class).triggerUpdate(); + } + + + private RepositoryConfig findLocalRepo(String storage) throws FileNotFoundException { + var optional = context.getConfig().getSubscriptions().stream().filter(repositoryConfig -> repositoryConfig.getType().equals(LocalFilesystemSubscription.class) && repositoryConfig.getName().equals(storage)).findFirst(); + if (optional.isEmpty()) { + throw new IllegalStateException("Unable to find local repository with name " + storage); + } + + return optional.get(); + } + + + @JRPCMethod + @ProtectedMethod + public void removeLocalResourceFile( + @JRPCArgument(name = "name") String name, + @JRPCArgument(name = "storage") String storage) throws IOException { + + var repositoryConfig = findLocalRepo(storage); + + var directory = new File(repositoryConfig.getSource()); + if (!directory.exists()) { + throw new FileNotFoundException("Unable to find directory " + directory.getAbsolutePath()); + } + + var localFile = new File(directory, name + ".json"); + if (!localFile.delete()) { + throw new IOException("Unable to delete local file " + localFile.getAbsolutePath()); + } + + var subscribedResources = context.getConfig().getSubscribedResources(); + subscribedResources.remove(repositoryConfig.getName() + ":" + name); + + context.getServiceManager().getService(SubscriptionService.class).triggerUpdate(); + } + + @JRPCMethod + @ProtectedMethod + public JSONArray getLocalRepositories( + ) { + var names = context.getConfig().getSubscriptions().stream() + .filter(repositoryConfig -> + repositoryConfig.getType().equals(LocalFilesystemSubscription.class) + ).map(RepositoryConfig::getName).toList(); + + return JSONUtility.serializeCollection(names, String.class, null); + } } diff --git a/app/src/main/java/ru/kirillius/pf/sdn/web/WebService.java b/app/src/main/java/ru/kirillius/pf/sdn/web/WebService.java index 70fa616..ab18306 100644 --- a/app/src/main/java/ru/kirillius/pf/sdn/web/WebService.java +++ b/app/src/main/java/ru/kirillius/pf/sdn/web/WebService.java @@ -140,12 +140,6 @@ public class WebService extends AppService { } catch (Exception e) { throw new RuntimeException("Unable to start web server", e); } - - try { - context.getEventsHandler().getRPCInitEvent().invoke(JSONRPC); - } catch (Exception e) { - SystemLogger.error("Error on RPC init event", CTX, e); - } } private final static String CTX = WebService.class.getSimpleName(); diff --git a/core/src/main/java/ru/kirillius/pf/sdn/core/ContextEventsHandler.java b/core/src/main/java/ru/kirillius/pf/sdn/core/ContextEventsHandler.java index 16dae10..3c38631 100644 --- a/core/src/main/java/ru/kirillius/pf/sdn/core/ContextEventsHandler.java +++ b/core/src/main/java/ru/kirillius/pf/sdn/core/ContextEventsHandler.java @@ -4,7 +4,6 @@ import lombok.Builder; import lombok.Getter; import ru.kirillius.java.utils.events.ConcurrentEventHandler; import ru.kirillius.java.utils.events.EventHandler; -import ru.kirillius.json.rpc.Servlet.JSONRPCServlet; import ru.kirillius.pf.sdn.core.Networking.NetworkResourceBundle; /** @@ -21,11 +20,6 @@ public final class ContextEventsHandler { */ @Getter private final EventHandler subscriptionsUpdateEvent = new ConcurrentEventHandler<>(); - /** - * Event fired after the RPC servlet is initialised and ready. - */ - @Getter - private final EventHandler RPCInitEvent = new ConcurrentEventHandler<>(); @Getter private final EventHandler configChangeEvent = new ConcurrentEventHandler<>(); diff --git a/core/src/main/java/ru/kirillius/pf/sdn/core/Subscription/SubscriptionService.java b/core/src/main/java/ru/kirillius/pf/sdn/core/Subscription/SubscriptionService.java index c1d9d3c..138bcae 100644 --- a/core/src/main/java/ru/kirillius/pf/sdn/core/Subscription/SubscriptionService.java +++ b/core/src/main/java/ru/kirillius/pf/sdn/core/Subscription/SubscriptionService.java @@ -46,6 +46,14 @@ public class SubscriptionService extends AppService { private final Map availableResources = new ConcurrentHashMap<>(); + @SuppressWarnings("unchecked") + private T getProvider(Class providerType) { + if (!providerCache.containsKey(providerType)) { + providerCache.put(providerType, SubscriptionProvider.instantiate(providerType, context)); + } + return (T) providerCache.get(providerType); + } + /** * Starts a background task that refreshes subscription repositories and aggregates resources. */ @@ -62,18 +70,9 @@ public class SubscriptionService extends AppService { var subscribedResources = config.getSubscribedResources(); for (var repoConfig : config.getSubscriptions()) { var providerType = repoConfig.getType(); - var provider = providerCache.get(providerType); + @SuppressWarnings({"unchecked", "rawtypes"}) var provider = getProvider((Class) providerType); try { - if (provider == null) { - //noinspection unchecked - provider = SubscriptionProvider.instantiate((Class) providerType, context); - //noinspection unchecked - providerCache.put((Class) providerType, provider); - } - - var resources = provider.getResources(repoConfig); - resources.keySet().forEach(key -> { var resourceName = repoConfig.getName() + ":" + key; //добавляем только выбранные ресурсы diff --git a/webui/src/modules/router.js b/webui/src/modules/router.js index 5642531..eff839e 100644 --- a/webui/src/modules/router.js +++ b/webui/src/modules/router.js @@ -13,6 +13,7 @@ import { SettingsPage } from '../pages/Settings.js'; import { LogsPage } from '../pages/Logs.js'; import { NetworkResourcesPage } from '../pages/NetworkResources.js'; import { UnlockSitePage } from '../pages/UnlockSite.js'; +import { LocalStoragesPage } from '../pages/LocalStorages.js'; // Переменная для отслеживания текущего активного хеша (для корректного unmount) @@ -32,6 +33,7 @@ const allMenuItems = [ { label: 'Журнал', path: 'logs', component: null }, { label: 'Сетевые ресурсы', path: 'network-resources', component: null }, { label: 'Разблокировка сайта', path: 'unlock-site', component: null }, + { label: 'Локальные хранилища', path: 'local-storages', component: null }, ]; // 2. Определение страниц @@ -90,6 +92,11 @@ const routes = { render: UnlockSitePage.render, mount: UnlockSitePage.mount, unmount: UnlockSitePage.unmount + }, + '#local-storages': { + render: LocalStoragesPage.render, + mount: LocalStoragesPage.mount, + unmount: LocalStoragesPage.unmount } }; diff --git a/webui/src/pages/LocalStorages.js b/webui/src/pages/LocalStorages.js new file mode 100644 index 0000000..7ca4a6a --- /dev/null +++ b/webui/src/pages/LocalStorages.js @@ -0,0 +1,535 @@ +import $ from 'jquery'; +import { JSONRPC } from '@/json-rpc.js'; + +const FIELD_IDS = { + storageSelect: 'local-storages-storage-select', + storageStatus: 'local-storages-storage-status', + createButton: 'local-storages-create-btn', + listContainer: 'local-storages-list', + modalOverlay: 'local-storages-modal', + modalTitle: 'local-storages-modal-title', + modalForm: 'local-storages-form', + nameInput: 'local-storages-name', + descriptionInput: 'local-storages-description', + domainsInput: 'local-storages-domains', + asnInput: 'local-storages-asn', + subnetsInput: 'local-storages-subnets', + modalStatus: 'local-storages-modal-status', + modalSaveButton: 'local-storages-save-btn', + modalCancelButton: 'local-storages-cancel-btn' +}; + +const SELECTORS = Object.entries(FIELD_IDS).reduce((acc, [key, value]) => { + acc[key] = `#${value}`; + return acc; +}, {}); + +const REFRESH_DELAY_MS = 5000; + +let repositories = []; +let availableResources = {}; +let selectedRepository = ''; +let currentModalMode = 'create'; +let currentModalResourceName = ''; +let resourcesRequestId = 0; + +function escapeHtml(value) { + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function splitLines(text) { + if (!text) { + return []; + } + return text + .split(/\r?\n/) + .map(line => line.trim()) + .filter(line => line.length > 0); +} + +function joinLines(items) { + if (!Array.isArray(items)) { + return ''; + } + return items.map(item => String(item ?? '').trim()).filter(item => item.length > 0).join('\n'); +} + +function parseAsn(list) { + const values = splitLines(list); + const parsed = []; + for (const item of values) { + if (!/^\d+$/.test(item)) { + return { success: false, error: `Некорректное значение ASN: ${item}` }; + } + parsed.push(Number(item)); + } + return { success: true, value: parsed }; +} + +function delay(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +function getResourceKey(storage, name) { + return `${storage}:${name}`; +} + +function getResourcesForStorage(storage) { + if (!storage) { + return []; + } + return Object.keys(availableResources) + .filter(key => key.startsWith(`${storage}:`)) + .map(key => ({ + key, + name: key.substring(storage.length + 1), + data: availableResources[key] + })) + .sort((a, b) => a.name.localeCompare(b.name, 'ru', { sensitivity: 'base' })); +} + +function setStorageStatus(message, type) { + const $status = $(SELECTORS.storageStatus); + if (!$status.length) { + return; + } + if (!message) { + $status.hide().text('').removeClass('error-message success-message'); + return; + } + $status + .text(message) + .removeClass('success-message error-message') + .addClass(type === 'success' ? 'success-message' : 'error-message') + .show(); +} + +function setModalStatus(message, type) { + const $status = $(SELECTORS.modalStatus); + if (!$status.length) { + return; + } + if (!message) { + $status.hide().text('').removeClass('error-message success-message'); + return; + } + $status + .text(message) + .removeClass('success-message error-message') + .addClass(type === 'success' ? 'success-message' : 'error-message') + .show(); +} + +function renderStorageOptions() { + const $select = $(SELECTORS.storageSelect); + if (!$select.length) { + return; + } + + if (!repositories.length) { + $select + .html('') + .prop('disabled', true); + $(SELECTORS.createButton).prop('disabled', true); + renderResourceList(); + return; + } + + const optionsHtml = repositories + .map(repo => ``) + .join(''); + + $select + .html(optionsHtml) + .prop('disabled', false); + + if (!selectedRepository || !repositories.includes(selectedRepository)) { + selectedRepository = repositories[0]; + } + + $(SELECTORS.createButton).prop('disabled', false); +} + +function renderLoadingList() { + const $list = $(SELECTORS.listContainer); + $list.html('

Загрузка содержимого...

'); +} + +function renderUpdatingPlaceholder() { + const $list = $(SELECTORS.listContainer); + $list.html('

Обновление...

'); +} + +function renderResourceList() { + const $list = $(SELECTORS.listContainer); + if (!$list.length) { + return; + } + + if (!repositories.length) { + $list.html('

Нет локальных хранилищ.

'); + return; + } + + if (!selectedRepository) { + $list.html('

Выберите хранилище, чтобы просмотреть содержимое.

'); + return; + } + + const resources = getResourcesForStorage(selectedRepository); + + if (!resources.length) { + $list.html('

В хранилище нет ресурсов.

'); + return; + } + + const rowsHtml = resources.map(resource => { + const safeName = escapeHtml(resource.name); + return ` +
+ ${safeName} +
+ + +
+
+ `; + }).join(''); + + $list.html(` +
${rowsHtml}
+ `); +} + +async function loadRepositories() { + try { + repositories = await JSONRPC.SubscriptionManager.getLocalRepositories(); + if (!Array.isArray(repositories)) { + repositories = []; + } + renderStorageOptions(); + setStorageStatus('', ''); + } catch (error) { + console.error('Ошибка получения хранилищ:', error); + repositories = []; + selectedRepository = ''; + renderStorageOptions(); + setStorageStatus('Не удалось получить список хранилищ.', 'error'); + } +} + +async function loadAvailableResources(currentRequestId) { + try { + const data = await JSONRPC.SubscriptionManager.getAvailableResources(); + if (currentRequestId !== resourcesRequestId) { + return; + } + availableResources = typeof data === 'object' && data !== null ? data : {}; + renderResourceList(); + } catch (error) { + if (currentRequestId !== resourcesRequestId) { + return; + } + console.error('Ошибка получения содержимого хранилища:', error); + availableResources = {}; + const $list = $(SELECTORS.listContainer); + $list.html('

Не удалось загрузить содержимое.

'); + } +} + +function openModal(mode, resourceName = '') { + currentModalMode = mode; + currentModalResourceName = resourceName; + + const $overlay = $(SELECTORS.modalOverlay); + const $title = $(SELECTORS.modalTitle); + const $nameInput = $(SELECTORS.nameInput); + const $descriptionInput = $(SELECTORS.descriptionInput); + const $domainsInput = $(SELECTORS.domainsInput); + const $asnInput = $(SELECTORS.asnInput); + const $subnetsInput = $(SELECTORS.subnetsInput); + const $saveButton = $(SELECTORS.modalSaveButton); + + setModalStatus('', ''); + + if (mode === 'edit') { + const key = getResourceKey(selectedRepository, resourceName); + const resource = availableResources[key] || {}; + $title.text(`Редактирование ресурса ${resourceName}`); + $nameInput.val(resourceName).prop('disabled', true); + $descriptionInput.val(resource.description || ''); + $domainsInput.val(joinLines(resource.domains)); + $asnInput.val(joinLines(resource.ASN)); + $subnetsInput.val(joinLines(resource.subnets)); + } else { + $title.text('Создание ресурса'); + $nameInput.val('').prop('disabled', false); + $descriptionInput.val(''); + $domainsInput.val(''); + $asnInput.val(''); + $subnetsInput.val(''); + } + + $saveButton.prop('disabled', false).text('Сохранить'); + $overlay.css('display', 'flex'); +} + +function closeModal() { + const $overlay = $(SELECTORS.modalOverlay); + const $saveButton = $(SELECTORS.modalSaveButton); + const $form = $(SELECTORS.modalForm); + setModalStatus('', ''); + $saveButton.prop('disabled', false).text('Сохранить'); + if ($form.length) { + $form[0].reset(); + } + $overlay.hide(); +} + +function validateName(name) { + if (!name.trim()) { + return 'Имя обязательно для заполнения.'; + } + if (!/^[A-Za-z0-9.-]+$/.test(name)) { + return 'Допускаются латинские буквы, цифры, точка и дефис.'; + } + return ''; +} + +async function handleModalSubmit(event) { + event.preventDefault(); + if (!selectedRepository) { + setModalStatus('Выберите хранилище.', 'error'); + return; + } + + const $saveButton = $(SELECTORS.modalSaveButton); + const $nameInput = $(SELECTORS.nameInput); + const $descriptionInput = $(SELECTORS.descriptionInput); + const $domainsInput = $(SELECTORS.domainsInput); + const $asnInput = $(SELECTORS.asnInput); + const $subnetsInput = $(SELECTORS.subnetsInput); + + const nameValue = ($nameInput.val() || '').trim(); + const validationError = validateName(nameValue); + if (validationError) { + setModalStatus(validationError, 'error'); + return; + } + + if (currentModalMode === 'create') { + const existing = getResourcesForStorage(selectedRepository).some(resource => resource.name === nameValue); + if (existing) { + setModalStatus('Ресурс с таким именем уже существует.', 'error'); + return; + } + } + + const descriptionValue = ($descriptionInput.val() || '').trim(); + const domainsValue = splitLines($domainsInput.val()); + const subnetsValue = splitLines($subnetsInput.val()); + const parsedAsn = parseAsn($asnInput.val()); + if (!parsedAsn.success) { + setModalStatus(parsedAsn.error, 'error'); + return; + } + + $saveButton.prop('disabled', true).text('Сохранение...'); + setModalStatus('', ''); + + try { + await JSONRPC.SubscriptionManager.writeLocalResourceFile( + nameValue, + descriptionValue, + domainsValue, + parsedAsn.value, + subnetsValue, + [], + selectedRepository, + false + ); + closeModal(); + setStorageStatus('Изменения успешно сохранены.', 'success'); + renderUpdatingPlaceholder(); + await delay(REFRESH_DELAY_MS); + await refreshResourceList(); + setTimeout(() => setStorageStatus('', ''), 3000); + } catch (error) { + console.error('Ошибка сохранения ресурса:', error); + setModalStatus('Не удалось сохранить ресурс.', 'error'); + $saveButton.prop('disabled', false).text('Сохранить'); + } +} + +async function handleDeleteResource(name) { + if (!selectedRepository) { + return; + } + const confirmed = window.confirm(`Удалить ресурс "${name}"?`); + if (!confirmed) { + return; + } + setStorageStatus('', ''); + const nameValue = String(name); + try { + await JSONRPC.SubscriptionManager.removeLocalResourceFile(nameValue, selectedRepository); + setStorageStatus('Ресурс удалён.', 'success'); + renderUpdatingPlaceholder(); + await delay(REFRESH_DELAY_MS); + await refreshResourceList(); + setTimeout(() => setStorageStatus('', ''), 3000); + } catch (error) { + console.error('Ошибка удаления ресурса:', error); + setStorageStatus('Не удалось удалить ресурс.', 'error'); + } +} + +async function refreshResourceList() { + renderLoadingList(); + resourcesRequestId += 1; + const requestId = resourcesRequestId; + await loadAvailableResources(requestId); +} + +function attachEventHandlers() { + $(SELECTORS.storageSelect).on('change', async function () { + selectedRepository = $(this).val() || ''; + if (!selectedRepository) { + renderResourceList(); + $(SELECTORS.createButton).prop('disabled', true); + return; + } + $(SELECTORS.createButton).prop('disabled', false); + await refreshResourceList(); + }); + + $(SELECTORS.createButton).on('click', function () { + if (!selectedRepository) { + setStorageStatus('Выберите хранилище.', 'error'); + return; + } + openModal('create'); + }); + + $(SELECTORS.listContainer).on('click', '.local-storage-edit', function () { + const name = $(this).data('name'); + if (!name) { + return; + } + openModal('edit', name); + }); + + $(SELECTORS.listContainer).on('click', '.local-storage-delete', function () { + const name = $(this).data('name'); + if (!name) { + return; + } + handleDeleteResource(name); + }); + + $(SELECTORS.modalForm).on('submit', handleModalSubmit); + + $(SELECTORS.modalCancelButton).on('click', function (event) { + event.preventDefault(); + closeModal(); + }); + + $(SELECTORS.modalOverlay).on('click', function (event) { + if (event.target === this) { + closeModal(); + } + }); +} + +function detachEventHandlers() { + $(SELECTORS.storageSelect).off('change'); + $(SELECTORS.createButton).off('click'); + $(SELECTORS.listContainer).off('click'); + $(SELECTORS.modalForm).off('submit'); + $(SELECTORS.modalCancelButton).off('click'); + $(SELECTORS.modalOverlay).off('click'); +} + +function resetState() { + repositories = []; + availableResources = {}; + selectedRepository = ''; + currentModalMode = 'create'; + currentModalResourceName = ''; + resourcesRequestId = 0; +} + +export const LocalStoragesPage = { + render: () => ` +
+

Локальные хранилища

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

Загрузка содержимого...

+
+
+ +
+ `, + mount: async () => { + attachEventHandlers(); + await loadRepositories(); + if (repositories.length && !selectedRepository) { + selectedRepository = repositories[0]; + renderStorageOptions(); + } + await refreshResourceList(); + }, + unmount: () => { + detachEventHandlers(); + resetState(); + } +}; diff --git a/webui/src/pages/UnlockSite.js b/webui/src/pages/UnlockSite.js index 303df5d..8b38b1b 100644 --- a/webui/src/pages/UnlockSite.js +++ b/webui/src/pages/UnlockSite.js @@ -13,6 +13,7 @@ const FIELD_IDS = { subnetsCheckbox: 'unlock-site-subnets-checkbox', asnCheckbox: 'unlock-site-asn-checkbox', nameInput: 'unlock-site-name', + storageSelect: 'unlock-site-storage', addButton: 'unlock-site-add-button', nameError: 'unlock-site-name-error', domainError: 'unlock-site-domain-error', @@ -28,6 +29,8 @@ const DEFAULT_DOMAIN = 'google.com'; const DEFAULT_SERVER = '8.8.8.8'; let currentResults = { addresses: [], subnets: [], ASN: [] }; +let availableStorages = []; +let currentStorage = ''; function escapeHtml(value) { return String(value) @@ -47,6 +50,16 @@ function normalizeListing(values) { .filter(item => item.length > 0); } +async function loadLocalRepositories() { + try { + const repositories = await JSONRPC.SubscriptionManager.getLocalRepositories(); + return normalizeListing(repositories); + } catch (error) { + console.error('Ошибка получения списка хранилищ:', error); + return []; + } +} + function setStatus(message, type) { const $status = $(SELECTORS.status); if (!$status.length) { @@ -126,10 +139,21 @@ function buildAsnItems(items) { return `
${items.map(item => escapeHtml(item)).join(', ')}
`; } -function renderResults(domainValue) { +function renderResults(domainValue, storages) { const addresses = normalizeListing(currentResults.addresses); const subnets = normalizeListing(currentResults.subnets); const ASN = normalizeListing(currentResults.ASN); + const storageOptions = Array.isArray(storages) ? storages : []; + + if (!storageOptions.includes(currentStorage)) { + currentStorage = storageOptions.length ? storageOptions[0] : ''; + } + + const storageSelectDisabled = storageOptions.length === 0; + const storageOptionsHtml = storageOptions.length + ? storageOptions.map(storage => ``).join('') + : ''; + const storageHint = storageOptions.length ? '' : '
Не найдено доступных хранилищ.
'; const addressesDisabled = addresses.length === 0; const subnetsDisabled = subnets.length === 0; @@ -158,6 +182,13 @@ function renderResults(domainValue) { ${buildAsnItems(ASN)} +
+ + + ${storageHint} +
@@ -179,13 +210,15 @@ function updateAddButtonState() { return; } const $nameInput = $(SELECTORS.nameInput); + const $storageSelect = $(SELECTORS.storageSelect); const { valid } = validateListName($nameInput.val() || ''); const hasSelection = [ $(SELECTORS.addressesCheckbox), $(SELECTORS.subnetsCheckbox), $(SELECTORS.asnCheckbox) ].some($checkbox => $checkbox.length && !$checkbox.prop('disabled') && $checkbox.prop('checked')); - $addButton.prop('disabled', !(hasSelection && valid)); + const storageValue = $storageSelect.length && !$storageSelect.prop('disabled') ? ($storageSelect.val() || '') : ''; + $addButton.prop('disabled', !(hasSelection && valid && storageValue.length > 0)); } async function handleSearch(event) { @@ -227,7 +260,8 @@ async function handleSearch(event) { subnets: normalizeListing(result?.subnets), ASN: normalizeListing(result?.ASN) }; - renderResults($domainInput.val().trim()); + availableStorages = await loadLocalRepositories(); + renderResults($domainInput.val().trim(), availableStorages); updateAddButtonState(); clearFieldError(SELECTORS.nameError); } catch (error) { @@ -257,13 +291,24 @@ function handleAdd() { const selectedAddresses = $(SELECTORS.addressesCheckbox).prop('checked') ? currentResults.addresses : []; const selectedSubnets = $(SELECTORS.subnetsCheckbox).prop('checked') ? currentResults.subnets : []; const selectedAsn = $(SELECTORS.asnCheckbox).prop('checked') ? currentResults.ASN : []; + const storage = $(SELECTORS.storageSelect).val() || ''; - JSONRPC.NetworkManager.createLocalResourceFile( + if (!storage) { + setStatus('Выберите хранилище.', 'error'); + $addButton.prop('disabled', false).text('Добавить'); + updateAddButtonState(); + return; + } + + JSONRPC.SubscriptionManager.writeLocalResourceFile( $nameInput.val().trim(), - domain, + "Unlocked resource: " + $nameInput.val().trim() + " (" + domain + ")", + [domain], selectedAsn, selectedSubnets, - selectedAddresses + selectedAddresses, + storage, + true ).then(() => { setStatus('Ресурсы успешно добавлены.', 'success'); }).catch(error => { @@ -290,11 +335,17 @@ function handleNameInputChange() { updateAddButtonState(); } +function handleStorageChange() { + currentStorage = $(SELECTORS.storageSelect).val() || ''; + updateAddButtonState(); +} + function attachEventHandlers() { $(SELECTORS.form).on('submit', handleSearch); $(SELECTORS.domainInput).on('input', () => clearFieldError(SELECTORS.domainError)); $(SELECTORS.serverInput).on('input', () => clearFieldError(SELECTORS.serverError)); $(SELECTORS.results).on('change', `input[type="checkbox"]`, handleCheckboxChange); + $(SELECTORS.results).on('change', SELECTORS.storageSelect, handleStorageChange); $(SELECTORS.results).on('input', SELECTORS.nameInput, handleNameInputChange); $(SELECTORS.results).on('click', SELECTORS.addButton, handleAdd); } @@ -310,6 +361,8 @@ function detachEventHandlers() { function resetState() { currentResults = { addresses: [], subnets: [], ASN: [] }; + currentStorage = ''; + availableStorages = []; setStatus('', ''); }