diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 1edad65..1104506 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -40,9 +40,9 @@ import Dashboard from './Dashboard'; import TrafficDashboard from './TrafficDashboard.jsx'; import MikrotikBackupsManager from './MikrotikBackupsManager.jsx'; import PingServicesManager from './PingServicesManager.jsx'; +import SettingsPage from './SettingsPage.jsx'; import './App.css'; import { NotifyProvider } from './components/NotifyProvider.jsx'; -import SettingsModal from './components/SettingsModal.jsx'; import ToastContainer from './components/ToastContainer.jsx'; import CommandPalette, { KeyboardShortcutsButton } from './components/CommandPalette.jsx'; import ErrorBoundary from './components/ErrorBoundary.jsx'; @@ -136,7 +136,6 @@ function MainLayout() { const location = useLocation(); const { lang, setLang, t } = useContext(LanguageContext); const { theme, setTheme } = useContext(ThemeContext); - const [settingsOpen, setSettingsOpen] = useState(false); const [layout, setLayout] = useState(() => localStorage.getItem('layout') || LAYOUT_FLUID); useEffect(() => { @@ -230,7 +229,13 @@ function MainLayout() { { id: 'ping-services', title: t('pingServices'), path: '/ping-services', icon: IconNetwork } ] }, - // Убрали неиспользуемые/неработающие разделы + { + id: 'settings', + title: 'Настройки', + icon: IconSettings, + single: true, + path: '/settings' + } ]; // Определяем активную вкладку по адресу @@ -364,9 +369,9 @@ function MainLayout() {
- { e.preventDefault(); setSettingsOpen(true); }} title="Настройки"> + - +
@@ -388,13 +393,13 @@ function MainLayout() { } /> } /> } /> + } /> } />
Версия UI: {import.meta?.env?.VITE_APP_VERSION || 'dev'}
- setSettingsOpen(false)} /> ); @@ -515,7 +520,7 @@ function MainLayout() { @@ -545,6 +550,7 @@ function MainLayout() { } /> } /> } /> + } /> } /> @@ -558,7 +564,6 @@ function MainLayout() { - setSettingsOpen(false)} /> ); diff --git a/frontend/src/SettingsPage.jsx b/frontend/src/SettingsPage.jsx new file mode 100644 index 0000000..9c86e10 --- /dev/null +++ b/frontend/src/SettingsPage.jsx @@ -0,0 +1,516 @@ +import { useEffect, useState, useMemo } from 'react'; +import api from './lib/api.js'; +import { + IconSettings, + IconDeviceFloppy, + IconPlugConnected, + IconNetwork, + IconCloud, + IconChartPie, + IconDns, + IconChevronDown, +} from '@tabler/icons-react'; +import FormField from './components/FormField'; +import ErrorAlert from './components/ErrorAlert'; +import PageHeader from './components/PageHeader'; +import ServerAutocompleteInput from './components/ServerAutocompleteInput.jsx'; + +/** + * Страница «Настройки интерфейса» — отдельный раздел в стиле Tabler UI и UniFi: + * коллапсируемые карточки-секции, иконки, чёткая структура. + */ +export default function SettingsPage() { + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(''); + const [etag, setEtag] = useState(''); + const [rawSettings, setRawSettings] = useState({}); + const [dohServer, setDohServer] = useState(''); + const [wsUrl, setWsUrl] = useState(''); + const [baseAS, setBaseAS] = useState('65001'); + const [pingDomain, setPingDomain] = useState(''); + const [pingCacheMinutes, setPingCacheMinutes] = useState(''); + const [ptrZoneReplaceFrom, setPtrZoneReplaceFrom] = useState(''); + const [ptrZoneReplaceTo, setPtrZoneReplaceTo] = useState(''); + const [pingServicesSource, setPingServicesSource] = useState('web'); + const [pingServicesServerId, setPingServicesServerId] = useState(''); + const [pingServicesGatewayIp, setPingServicesGatewayIp] = useState(''); + const [pingServicesCacheSeconds, setPingServicesCacheSeconds] = useState(''); + const [serversList, setServersList] = useState([]); + + const routerServersForPing = useMemo(() => { + return (serversList || []).filter( + (s) => + s && + (String(s.type || '').toLowerCase() === 'jumphost' || + String(s.type || '').toLowerCase() === 'home') + ); + }, [serversList]); + + useEffect(() => { + setError(''); + setSuccess(''); + setLoading(true); + + (async () => { + try { + const [settingsRes, serversRes] = await Promise.all([ + api.get('/ui-settings'), + api.get('/servers').catch(() => ({ data: [] })), + ]); + const data = settingsRes?.data || {}; + setRawSettings(data); + setDohServer(String(data?.dohServer || '')); + setWsUrl(String(data?.wsUpdateUrl || '')); + setBaseAS(String(data?.baseAS || '65001')); + setPingDomain(String(data?.pingDomain || '').trim()); + setPingCacheMinutes( + data?.pingCacheMinutes != null ? String(data.pingCacheMinutes) : '' + ); + setPtrZoneReplaceFrom(String(data?.ptrZoneReplaceFrom || '')); + setPtrZoneReplaceTo(String(data?.ptrZoneReplaceTo || '')); + setPingServicesSource( + String(data?.pingServicesSource || 'web').toLowerCase() === 'router' + ? 'router' + : 'web' + ); + setPingServicesServerId(String(data?.pingServicesServerId || '').trim()); + setPingServicesGatewayIp( + String(data?.pingServicesGatewayIp || '').trim() + ); + setPingServicesCacheSeconds( + data?.pingServicesCacheSeconds != null + ? String(data.pingServicesCacheSeconds) + : '' + ); + const e = + settingsRes?.headers?.etag || settingsRes?.headers?.ETag || ''; + setEtag(e ? String(e) : ''); + setServersList(Array.isArray(serversRes?.data) ? serversRes.data : []); + } catch (e) { + setError('Не удалось загрузить настройки'); + } finally { + setLoading(false); + } + })(); + }, []); + + const validateDoh = (value) => { + if (!value) return { valid: true, message: '' }; + try { + const u = new URL(String(value)); + return u.protocol === 'https:' + ? { valid: true, message: 'Корректный HTTPS URL' } + : { valid: false, message: 'Используйте HTTPS' }; + } catch { + return { valid: false, message: 'Некорректный URL' }; + } + }; + + const validateWs = (value) => { + if (!value) return { valid: true, message: '' }; + try { + const u = new URL(String(value)); + return u.protocol === 'ws:' || u.protocol === 'wss:' + ? { valid: true, message: 'Корректный WebSocket URL' } + : { valid: false, message: 'Используйте ws:// или wss://' }; + } catch { + return { valid: false, message: 'Некорректный URL' }; + } + }; + + const onSave = async () => { + setError(''); + setSuccess(''); + + const dohValidation = validateDoh(dohServer); + const wsValidation = validateWs(wsUrl); + + if (!dohValidation.valid) { + setError('Укажите корректный HTTPS URL для DoH'); + return; + } + + if (!wsValidation.valid) { + setError('Укажите корректный WebSocket URL (ws:// или wss://)'); + return; + } + + setSaving(true); + try { + const mergedSettings = { + ...rawSettings, + dohServer: String(dohServer || '').trim(), + wsUpdateUrl: String(wsUrl || '').trim(), + baseAS: String(baseAS || '65001').trim(), + pingDomain: String(pingDomain || '').trim(), + pingCacheMinutes: Math.max(0, parseInt(pingCacheMinutes, 10) || 0), + ptrZoneReplaceFrom: String(ptrZoneReplaceFrom || '').trim(), + ptrZoneReplaceTo: String(ptrZoneReplaceTo || '').trim(), + pingServicesSource: + pingServicesSource === 'router' ? 'router' : 'web', + pingServicesServerId: String(pingServicesServerId || '').trim(), + pingServicesGatewayIp: String(pingServicesGatewayIp || '').trim(), + pingServicesCacheSeconds: Math.max( + 0, + parseInt(pingServicesCacheSeconds, 10) || 0 + ), + }; + const payload = { settings: mergedSettings, etag }; + const res = await api.post('/ui-settings', payload); + const meta = res?.data || {}; + setSuccess('Настройки успешно сохранены'); + setEtag(String(meta?.etag || '')); + setRawSettings(mergedSettings); + setTimeout(() => setSuccess(''), 3000); + } catch (e) { + setError( + e?.response?.data?.message || 'Ошибка при сохранении настроек' + ); + } finally { + setSaving(false); + } + }; + + // Коллапсируемая карточка в стиле UniFi + function SectionCard({ id, title, icon: Icon, defaultOpen = true, children }) { + const [open, setOpen] = useState(defaultOpen); + return ( +
+
setOpen((v) => !v)} + aria-expanded={open} + aria-controls={`settings-section-${id}`} + id={`settings-heading-${id}`} + > +

+ {Icon && ( + + + + )} + {title} +

+ +
+ {open && ( +
+ {children} +
+ )} +
+ ); + } + + if (loading) { + return ( +
+
+
+ } + pretitle="Интерфейс" + /> +
+
+
+

Загрузка настроек…

+
+
+
+
+
+ ); + } + + return ( +
+
+
+ } + pretitle="Интерфейс" + meta="WebSocket, DoH, пинг, PTR зона и другие параметры UI" + actions={ +
+ +
+ } + /> + + {error && ( +
+ setError('')} /> +
+ )} + + {success && ( +
+
+
{success}
+
+
+ )} + +
+
+ {/* Секция: BGP Live и DoH */} + +
+
+ +
+
+ +
+
+
+ + {/* Секция: Сеть и AS */} + +
+
+ +
+
+
+ + {/* Секция: Пинг (домен и кеш) */} + +
+
+ +
+
+ +
+
+
+ + {/* Секция: Пинг сервисов на главной */} + +
+ + +
+ «Веб» — задержка до 8.8.8.8, 1.1.1.1 с сервера. «Роутер» — + пинг через выбранный MikroTik (jumphost). +
+
+
+ +
+ {pingServicesSource === 'router' && ( +
+
+ +
+ + setPingServicesServerId(String(v || '').trim()) + } + servers={routerServersForPing} + placeholder="Выберите роутер (jumphost или входной)" + className="form-control" + maxSuggestions={10} + /> +
+
+ Роутер с MikroTik API для пинга с главной. Пусто — + первый jumphost. +
+
+
+ +
+
+ )} +
+ + {/* Секция: PTR зона */} + +
+
+ +
+
+ +
+
+
+
+
+ + {/* Кнопка сохранения внизу страницы (дублируем для удобства) */} +
+ +
+
+
+
+ ); +} diff --git a/frontend/src/components/CommandPalette.jsx b/frontend/src/components/CommandPalette.jsx index 2cc319b..c522c7f 100644 --- a/frontend/src/components/CommandPalette.jsx +++ b/frontend/src/components/CommandPalette.jsx @@ -11,7 +11,8 @@ import { IconCreditCard, IconDownload, IconKeyboard, - IconChartPie + IconChartPie, + IconSettings } from '@tabler/icons-react' /** @@ -48,6 +49,7 @@ function CommandPalette() { { icon: IconFilter, label: 'Фильтры', description: 'Filter Manager', action: () => navigate('/filters'), keywords: ['фильтры', 'filters', 'mikrotik'] }, { icon: IconCreditCard, label: 'Биллинг', description: 'Управление биллингом', action: () => navigate('/billing'), keywords: ['биллинг', 'billing', 'оплата'] }, { icon: IconDownload, label: 'Авто-URL', description: 'Генератор ссылок', action: () => navigate('/auto-urls'), keywords: ['url', 'ссылки', 'генератор'] }, + { icon: IconSettings, label: 'Настройки интерфейса', description: 'WebSocket, DoH, пинг, PTR зона', action: () => navigate('/settings'), keywords: ['настройки', 'settings', 'интерфейс', 'doh', 'websocket'] }, ] // Фильтрация команд по поисковому запросу