Конфигурация сети, пользователя и безопасности.
',
+ mount: () => {},
+ unmount: () => {}
+ },
+ '#components': {
+ render: Components.render,
+ mount: Components.mount,
+ unmount: Components.unmount
+ },
+ '#api': {
+ render: APITokens.render,
+ mount: APITokens.mount,
+ unmount: APITokens.unmount
+ }
+};
+
+// 3. Функция рендеринга страницы
+export function renderPage(hash) {
+ const $contentArea = $('#content-area');
+ const key = hash.startsWith('#') ? hash : '#' + hash;
+
+ // 🔥 НОВАЯ ПРОВЕРКА: Если страница уже открыта, просто обновляем меню и выходим
+ if (currentRouteHash === key) {
+ $('.menu-item').removeClass('active');
+ $(`.menu-item[data-path="${key.substring(1)}"]`).addClass('active');
+ return;
+ }
+
+ // Определяем, какой хеш был активным до этого. Используем 'stats' как дефолт
+ const previousKey = currentRouteHash || '#stats';
+
+ // Шаг 1: Если мы меняем страницу, и предыдущая страница имеет unmount, вызываем его
+ if (previousKey !== key && routes[previousKey] && routes[previousKey].unmount) {
+ routes[previousKey].unmount();
+ }
+
+ if (routes[key]) {
+ // Рендерим HTML
+ $contentArea.html(routes[key].render());
+
+ // Вызываем функцию монтирования
+ routes[key].mount();
+
+ // Обновляем активный пункт меню
+ $('.menu-item').removeClass('active');
+ $(`.menu-item[data-path="${key.substring(1)}"]`).addClass('active');
+
+ // Обновляем hash в адресной строке
+ if (window.location.hash !== key) {
+ history.pushState(null, null, key);
+ }
+
+ // Шаг 2: Успешно обновили страницу, сохраняем новый хеш
+ currentRouteHash = key;
+
+ } else {
+ // Если путь не найден, перенаправляем на "Статистику"
+ window.location.hash = 'stats';
+ }
+}
+
+// 4. Обработчик изменения хеша в браузере (для навигации по истории)
+$(window).on('hashchange', function() {
+ renderPage(window.location.hash);
+});
+
+// Экспорт menuItems для построения сайдбара в ui.js
+export { menuItems };
\ No newline at end of file
diff --git a/webui/src/modules/ui.js b/webui/src/modules/ui.js
new file mode 100644
index 0000000..879c7de
--- /dev/null
+++ b/webui/src/modules/ui.js
@@ -0,0 +1,103 @@
+import $ from 'jquery';
+import { handleLogin, handleLogout } from './auth.js';
+import { setAuthenticated } from './app.js';
+import { renderPage, menuItems } from './router.js';
+
+// Функция рендеринга формы авторизации
+export function renderLoginForm() {
+ const html = `
+ }
+ */
+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 = `
+
+
+ `;
+
+ if (tokens.length === 0) {
+ $listContainer.html(createButtonHtml + 'Активных API токенов не найдено.
');
+ attachEventHandlers();
+ return;
+ }
+
+ let listHtml = tokens.map(item => {
+ // Мы НЕ показываем полный токен, показываем только его начало/конец для идентификации
+ const displayToken = item.token ?
+ `${item.token.substring(0, 8)}...${item.token.substring(item.token.length - 4)}` :
+ 'Неизвестный токен';
+
+ return `
+
+ ${item.description}
+
+ Ключ: ${displayToken}
+
+
+
+ `;
+ }).join('');
+
+ const html = `
+ ${createButtonHtml}
+
+ `;
+
+ $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 `
+ API Токены
+
+ `;
+ },
+ mount: async () => {
+ const success = await loadTokens();
+ if (success) {
+ renderTokenList();
+ } else {
+ $('#api-token-container').html('Не удалось загрузить токены. Проверьте соединение с сервером.
');
+ }
+ },
+ unmount: () => {
+ tokens = [];
+ $('#api-token-container').off(); // Удаляем все обработчики
+ }
+};
\ No newline at end of file
diff --git a/webui/src/ui/components.js b/webui/src/ui/components.js
new file mode 100644
index 0000000..365b319
--- /dev/null
+++ b/webui/src/ui/components.js
@@ -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('Нет доступных системных компонентов.
');
+ return;
+ }
+
+ let listHtml = availableComponents.map(fullName => {
+ // 🔥 Применяем новую функцию для отображения
+ const shortName = getShortName(fullName);
+
+ // Проверяем, включен ли компонент
+ const isChecked = enabledComponents.includes(fullName); // Проверка по полному имени!
+
+ return `
+
+
+
+ `;
+ }).join('');
+
+ const html = `
+
+
+
+ `;
+
+ $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 `
+ Управление Компонентами
+
+
Загрузка доступных компонентов...
+
+ `;
+ },
+ mount: async () => {
+ const success = await loadComponentData();
+ if (success) {
+ renderComponentList();
+ } else {
+ $('#component-list-container').html('Не удалось загрузить данные. Проверьте соединение с сервером.
');
+ }
+ },
+ unmount: () => {
+ availableComponents = [];
+ enabledComponents = [];
+ }
+};
\ No newline at end of file
diff --git a/webui/src/ui/statistics.js b/webui/src/ui/statistics.js
new file mode 100644
index 0000000..aadfd6f
--- /dev/null
+++ b/webui/src/ui/statistics.js
@@ -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 `
+
+
${title}
+
+ Статус: ${status}
+
+
+ ${resourceStatsHtml}
+
+ `;
+};
+
+/** Создает HTML-блок для отображения ресурсов сети */
+const createResourceStatsHtml = (resources) => {
+ return `
+
+
Ресурсы:
+
+ ${createResourceBadge("Домены", resources.domains.length)}
+ ${createResourceBadge("ASN", resources.ASN.length)}
+ ${createResourceBadge("Подсети", resources.subnets.length)}
+
+
+ `;
+};
+
+/** Создает HTML-бэйдж для отдельного ресурса (с уменьшенным шрифтом) */
+const createResourceBadge = (title, count) => {
+ return `
+
+ ${count}
+ ${title}
+
+ `;
+};
+
+// --- Основные функции 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 = `
+
Подписки:
+
+ ${createResourceBadge("Активно", subCount)}
+
+
`;
+ 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(`${dataHtml.join('')}
`);
+
+ // Прикрепляем обработчики событий после рендеринга
+ 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("Проверка обновлений ПО запущена...");
+ });
+}
\ No newline at end of file
diff --git a/webui/src/ui/subscriptions.js b/webui/src/ui/subscriptions.js
new file mode 100644
index 0000000..77dbbde
--- /dev/null
+++ b/webui/src/ui/subscriptions.js
@@ -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('Нет доступных ресурсов для подписки.
');
+ 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 `
+
+
+
+ `;
+ }).join('');
+
+ const html = `
+
+
+
+ `;
+
+ $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 `
+ Управление Подписками
+
+
Загрузка доступных ресурсов...
+
+ `;
+ },
+ mount: async () => {
+ const success = await loadSubscriptionData();
+ if (success) {
+ renderSubscriptionList();
+ } else {
+ $('#subscription-list-container').html('Не удалось загрузить данные. Проверьте соединение с сервером.
');
+ }
+ },
+ unmount: () => {
+ availableResources = {};
+ subscribedKeys = [];
+ }
+};
\ No newline at end of file
diff --git a/webui/src/utils/cookies.js b/webui/src/utils/cookies.js
new file mode 100644
index 0000000..7bb42e0
--- /dev/null
+++ b/webui/src/utils/cookies.js
@@ -0,0 +1,42 @@
+// src/utils/cookies.js
+
+const TOKEN_KEY = 'sdn_auth_token';
+
+/**
+ * Получает токен авторизации из куки.
+ * @returns {string|null} Токен или null, если не найден.
+ */
+export function getAuthToken() {
+ const nameEQ = TOKEN_KEY + "=";
+ const ca = document.cookie.split(';');
+ for(let i=0; i < ca.length; i++) {
+ let c = ca[i];
+ while (c.charAt(0) === ' ') c = c.substring(1, c.length);
+ if (c.indexOf(nameEQ) === 0) {
+ return c.substring(nameEQ.length, c.length);
+ }
+ }
+ return null;
+}
+
+/**
+ * Устанавливает токен авторизации в куки на 1 год.
+ * @param {string} token - Токен для сохранения.
+ */
+export function setAuthToken(token) {
+ const expirationDate = new Date();
+ // Устанавливаем срок действия на 1 год
+ expirationDate.setTime(expirationDate.getTime() + (365 * 24 * 60 * 60 * 1000));
+ const expires = "expires=" + expirationDate.toUTCString();
+
+ // Используем secure и samesite=Lax (рекомендуется для современных браузеров)
+ // path=/ делает токен доступным для всего сайта
+ document.cookie = `${TOKEN_KEY}=${token};${expires};path=/;samesite=Lax`;
+}
+
+/**
+ * Удаляет токен авторизации.
+ */
+export function removeAuthToken() {
+ document.cookie = `${TOKEN_KEY}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`;
+}
\ No newline at end of file
diff --git a/webui/vite.config.js b/webui/vite.config.js
new file mode 100644
index 0000000..28f4c1e
--- /dev/null
+++ b/webui/vite.config.js
@@ -0,0 +1,34 @@
+import { fileURLToPath, URL } from 'node:url'
+// 🔥 ДОБАВЛЯЕМ ИМПОРТ node:path
+import path from 'node:path'
+
+import { defineConfig } from 'vite'
+import vue from '@vitejs/plugin-vue'
+import vueDevTools from 'vite-plugin-vue-devtools'
+
+// https://vite.dev/config/
+export default defineConfig({
+ plugins: [
+ vue(),
+ vueDevTools(),
+ ],
+ resolve: {
+ alias: {
+ '@': fileURLToPath(new URL('./src', import.meta.url))
+ },
+ },
+
+ // -----------------------------------------------------------------
+ // 🔥 ДОБАВЛЯЕМ ЭТОТ БЛОК! Явно разрешаем доступ к node_modules
+ server: {
+ fs: {
+ allow: [
+ // Позволяем доступ к корневой папке проекта (для src)
+ '.',
+ // Позволяем доступ к папке node_modules, чтобы Vite мог найти стили
+ path.resolve(__dirname, 'node_modules')
+ ]
+ }
+ }
+ // -----------------------------------------------------------------
+})
\ No newline at end of file