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() {
@@ -388,13 +393,13 @@ function MainLayout() {
} />
} />
} />
+ } />
} />
- 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 && (
+
+
+
+ )}
+
+
+
+ {/* Секция: 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'] },
]
// Фильтрация команд по поисковому запросу