добавлен редактор локальных репозиториев и удалено лишнее событие

This commit is contained in:
kirillius 2025-10-19 11:38:49 +03:00
parent aa06678220
commit 1bcf317c8f
8 changed files with 710 additions and 74 deletions

View File

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

View File

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

View File

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

View File

@ -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<NetworkResourceBundle> subscriptionsUpdateEvent = new ConcurrentEventHandler<>();
/**
* Event fired after the RPC servlet is initialised and ready.
*/
@Getter
private final EventHandler<JSONRPCServlet> RPCInitEvent = new ConcurrentEventHandler<>();
@Getter
private final EventHandler<ConfigChangeContext> configChangeEvent = new ConcurrentEventHandler<>();

View File

@ -46,6 +46,14 @@ public class SubscriptionService extends AppService {
private final Map<String, NetworkResourceBundle> availableResources = new ConcurrentHashMap<>();
@SuppressWarnings("unchecked")
private <T extends SubscriptionProvider> T getProvider(Class<T> 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<? extends SubscriptionProvider>) providerType, context);
//noinspection unchecked
providerCache.put((Class<? extends SubscriptionProvider>) providerType, provider);
}
var resources = provider.getResources(repoConfig);
resources.keySet().forEach(key -> {
var resourceName = repoConfig.getName() + ":" + key;
//добавляем только выбранные ресурсы

View File

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

View File

@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
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('<option value="">Нет доступных хранилищ</option>')
.prop('disabled', true);
$(SELECTORS.createButton).prop('disabled', true);
renderResourceList();
return;
}
const optionsHtml = repositories
.map(repo => `<option value="${escapeHtml(repo)}"${repo === selectedRepository ? ' selected' : ''}>${escapeHtml(repo)}</option>`)
.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('<p>Загрузка содержимого...</p>');
}
function renderUpdatingPlaceholder() {
const $list = $(SELECTORS.listContainer);
$list.html('<p>Обновление...</p>');
}
function renderResourceList() {
const $list = $(SELECTORS.listContainer);
if (!$list.length) {
return;
}
if (!repositories.length) {
$list.html('<p>Нет локальных хранилищ.</p>');
return;
}
if (!selectedRepository) {
$list.html('<p>Выберите хранилище, чтобы просмотреть содержимое.</p>');
return;
}
const resources = getResourcesForStorage(selectedRepository);
if (!resources.length) {
$list.html('<p>В хранилище нет ресурсов.</p>');
return;
}
const rowsHtml = resources.map(resource => {
const safeName = escapeHtml(resource.name);
return `
<div class="subscription-item" style="display: flex; align-items: center; justify-content: space-between; gap: 12px;">
<span style="flex-grow: 1;">${safeName}</span>
<div style="display: flex; gap: 8px;">
<button type="button" class="btn-secondary local-storage-edit" data-name="${safeName}">Редактировать</button>
<button type="button" class="btn-link local-storage-delete" data-name="${safeName}">Удалить</button>
</div>
</div>
`;
}).join('');
$list.html(`
<div class="subscription-list" style="padding: 0; margin: 0; border: none; background: transparent; display: flex; flex-direction: column; gap: 12px;">${rowsHtml}</div>
`);
}
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('<p class="error-message">Не удалось загрузить содержимое.</p>');
}
}
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: () => `
<div>
<h1 class="page-title">Локальные хранилища</h1>
<div style="display: flex; flex-direction: column; gap: 20px;">
<div class="card" style="padding: 20px; max-width: 480px;">
<div class="form-group" style="margin-bottom: 0;">
<label for="${FIELD_IDS.storageSelect}">Репозиторий</label>
<select id="${FIELD_IDS.storageSelect}" class="form-control" disabled>
<option>Загрузка...</option>
</select>
</div>
<div id="${FIELD_IDS.storageStatus}" class="error-message" style="display: none; margin-top: 16px;"></div>
</div>
<div style="display: flex; justify-content: flex-end;">
<button type="button" id="${FIELD_IDS.createButton}" class="btn-primary" style="width: auto; padding: 10px 18px;" disabled>Создать</button>
</div>
<div id="${FIELD_IDS.listContainer}" class="card" style="padding: 20px;">
<p>Загрузка содержимого...</p>
</div>
</div>
<div id="${FIELD_IDS.modalOverlay}" style="display: none; position: fixed; inset: 0; background: rgba(0, 0, 0, 0.6); z-index: 999; align-items: center; justify-content: center;">
<div class="card" style="max-width: 640px; width: 90%; padding: 24px; position: relative;">
<form id="${FIELD_IDS.modalForm}" style="display: flex; flex-direction: column; gap: 16px;">
<h2 id="${FIELD_IDS.modalTitle}" style="margin: 0 0 10px 0;">Создание ресурса</h2>
<div class="form-group" style="margin-bottom: 0;">
<label for="${FIELD_IDS.nameInput}">Имя</label>
<input type="text" id="${FIELD_IDS.nameInput}" class="form-control" placeholder="example-resource">
</div>
<div class="form-group" style="margin-bottom: 0;">
<label for="${FIELD_IDS.descriptionInput}">Описание</label>
<textarea id="${FIELD_IDS.descriptionInput}" class="form-control" rows="3" placeholder="Описание ресурса"></textarea>
</div>
<div class="form-group" style="margin-bottom: 0;">
<label for="${FIELD_IDS.domainsInput}">Домены (каждый с новой строки)</label>
<textarea id="${FIELD_IDS.domainsInput}" class="form-control" rows="4" placeholder="example.com"></textarea>
</div>
<div class="form-group" style="margin-bottom: 0;">
<label for="${FIELD_IDS.asnInput}">ASN (каждый с новой строки)</label>
<textarea id="${FIELD_IDS.asnInput}" class="form-control" rows="3" placeholder="12345"></textarea>
</div>
<div class="form-group" style="margin-bottom: 0;">
<label for="${FIELD_IDS.subnetsInput}">Подсети (каждая с новой строки)</label>
<textarea id="${FIELD_IDS.subnetsInput}" class="form-control" rows="4" placeholder="192.0.2.0/24"></textarea>
</div>
<div id="${FIELD_IDS.modalStatus}" class="error-message" style="display: none;"></div>
<div style="display: flex; gap: 12px; justify-content: flex-end; margin-top: 8px;">
<button type="button" id="${FIELD_IDS.modalCancelButton}" class="btn-secondary" style="padding: 10px 18px;">Отмена</button>
<button type="submit" id="${FIELD_IDS.modalSaveButton}" class="btn-primary" style="width: auto; padding: 10px 18px;">Сохранить</button>
</div>
</form>
</div>
</div>
</div>
`,
mount: async () => {
attachEventHandlers();
await loadRepositories();
if (repositories.length && !selectedRepository) {
selectedRepository = repositories[0];
renderStorageOptions();
}
await refreshResourceList();
},
unmount: () => {
detachEventHandlers();
resetState();
}
};

View File

@ -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 `<div style="margin-top: 12px;">${items.map(item => escapeHtml(item)).join(', ')}</div>`;
}
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 => `<option value="${escapeHtml(storage)}"${storage === currentStorage ? ' selected' : ''}>${escapeHtml(storage)}</option>`).join('')
: '<option value="">Нет доступных хранилищ</option>';
const storageHint = storageOptions.length ? '' : '<div class="hint-text" style="margin-top: 8px;">Не найдено доступных хранилищ.</div>';
const addressesDisabled = addresses.length === 0;
const subnetsDisabled = subnets.length === 0;
@ -158,6 +182,13 @@ function renderResults(domainValue) {
</label>
${buildAsnItems(ASN)}
</div>
<div class="form-group">
<label for="${FIELD_IDS.storageSelect}">Хранилище</label>
<select id="${FIELD_IDS.storageSelect}" class="form-control" ${storageSelectDisabled ? 'disabled' : ''}>
${storageOptionsHtml}
</select>
${storageHint}
</div>
<div class="form-group">
<label for="${FIELD_IDS.nameInput}">Имя списка</label>
<input type="text" id="${FIELD_IDS.nameInput}" class="form-control" value="${escapeHtml(domainValue)}">
@ -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('', '');
}