feat(App): add settings page and integrate into navigation; update command palette with settings option
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 57s
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 57s
This commit is contained in:
+13
-8
@@ -40,9 +40,9 @@ import Dashboard from './Dashboard';
|
|||||||
import TrafficDashboard from './TrafficDashboard.jsx';
|
import TrafficDashboard from './TrafficDashboard.jsx';
|
||||||
import MikrotikBackupsManager from './MikrotikBackupsManager.jsx';
|
import MikrotikBackupsManager from './MikrotikBackupsManager.jsx';
|
||||||
import PingServicesManager from './PingServicesManager.jsx';
|
import PingServicesManager from './PingServicesManager.jsx';
|
||||||
|
import SettingsPage from './SettingsPage.jsx';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
import { NotifyProvider } from './components/NotifyProvider.jsx';
|
import { NotifyProvider } from './components/NotifyProvider.jsx';
|
||||||
import SettingsModal from './components/SettingsModal.jsx';
|
|
||||||
import ToastContainer from './components/ToastContainer.jsx';
|
import ToastContainer from './components/ToastContainer.jsx';
|
||||||
import CommandPalette, { KeyboardShortcutsButton } from './components/CommandPalette.jsx';
|
import CommandPalette, { KeyboardShortcutsButton } from './components/CommandPalette.jsx';
|
||||||
import ErrorBoundary from './components/ErrorBoundary.jsx';
|
import ErrorBoundary from './components/ErrorBoundary.jsx';
|
||||||
@@ -136,7 +136,6 @@ function MainLayout() {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { lang, setLang, t } = useContext(LanguageContext);
|
const { lang, setLang, t } = useContext(LanguageContext);
|
||||||
const { theme, setTheme } = useContext(ThemeContext);
|
const { theme, setTheme } = useContext(ThemeContext);
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
|
||||||
const [layout, setLayout] = useState(() => localStorage.getItem('layout') || LAYOUT_FLUID);
|
const [layout, setLayout] = useState(() => localStorage.getItem('layout') || LAYOUT_FLUID);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -230,7 +229,13 @@ function MainLayout() {
|
|||||||
{ id: 'ping-services', title: t('pingServices'), path: '/ping-services', icon: IconNetwork }
|
{ 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() {
|
|||||||
<KeyboardShortcutsButton className="nav-link px-2" />
|
<KeyboardShortcutsButton className="nav-link px-2" />
|
||||||
</div>
|
</div>
|
||||||
<div className="nav-item">
|
<div className="nav-item">
|
||||||
<a href="#" className="nav-link px-2" onClick={(e) => { e.preventDefault(); setSettingsOpen(true); }} title="Настройки">
|
<Link to="/settings" className="nav-link px-2" title="Настройки">
|
||||||
<IconSettings size={20} />
|
<IconSettings size={20} />
|
||||||
</a>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -388,13 +393,13 @@ function MainLayout() {
|
|||||||
<Route path="/mikrotik-backups" element={<MikrotikBackupsManager />} />
|
<Route path="/mikrotik-backups" element={<MikrotikBackupsManager />} />
|
||||||
<Route path="/mikrotik-tools" element={<MikrotikTools />} />
|
<Route path="/mikrotik-tools" element={<MikrotikTools />} />
|
||||||
<Route path="/ping-services" element={<PingServicesManager />} />
|
<Route path="/ping-services" element={<PingServicesManager />} />
|
||||||
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
<footer className="footer mt-auto py-2">
|
<footer className="footer mt-auto py-2">
|
||||||
<div className="container-xl text-muted small">Версия UI: {import.meta?.env?.VITE_APP_VERSION || 'dev'}</div>
|
<div className="container-xl text-muted small">Версия UI: {import.meta?.env?.VITE_APP_VERSION || 'dev'}</div>
|
||||||
</footer>
|
</footer>
|
||||||
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
|
||||||
<CommandPalette />
|
<CommandPalette />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -515,7 +520,7 @@ function MainLayout() {
|
|||||||
<KeyboardShortcutsButton className="nav-link btn-icon rounded" title="Горячие клавиши" />
|
<KeyboardShortcutsButton className="nav-link btn-icon rounded" title="Горячие клавиши" />
|
||||||
</div>
|
</div>
|
||||||
<div className="nav-item">
|
<div className="nav-item">
|
||||||
<a href="#" className="nav-link btn-icon rounded" onClick={(e) => { e.preventDefault(); setSettingsOpen(true); }} title="Настройки">
|
<Link to="/settings" className="nav-link btn-icon rounded" title="Настройки">
|
||||||
<IconSettings size={20} />
|
<IconSettings size={20} />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -545,6 +550,7 @@ function MainLayout() {
|
|||||||
<Route path="/mikrotik-backups" element={<MikrotikBackupsManager />} />
|
<Route path="/mikrotik-backups" element={<MikrotikBackupsManager />} />
|
||||||
<Route path="/mikrotik-tools" element={<MikrotikTools />} />
|
<Route path="/mikrotik-tools" element={<MikrotikTools />} />
|
||||||
<Route path="/ping-services" element={<PingServicesManager />} />
|
<Route path="/ping-services" element={<PingServicesManager />} />
|
||||||
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
@@ -558,7 +564,6 @@ function MainLayout() {
|
|||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
|
||||||
<CommandPalette />
|
<CommandPalette />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className="card mb-3">
|
||||||
|
<div
|
||||||
|
className="card-header d-flex align-items-center justify-content-between cursor-pointer py-3"
|
||||||
|
role="button"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-controls={`settings-section-${id}`}
|
||||||
|
id={`settings-heading-${id}`}
|
||||||
|
>
|
||||||
|
<h3 className="card-title m-0 d-flex align-items-center">
|
||||||
|
{Icon && (
|
||||||
|
<span className="me-2 d-flex align-items-center text-muted">
|
||||||
|
<Icon size={20} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
<IconChevronDown
|
||||||
|
className={`icon ms-2 text-muted transition ${open ? 'rotate-180' : ''}`}
|
||||||
|
size={20}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
className="card-body pt-0"
|
||||||
|
id={`settings-section-${id}`}
|
||||||
|
aria-labelledby={`settings-heading-${id}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="page-wrapper">
|
||||||
|
<div className="page-body">
|
||||||
|
<div className="container-xl">
|
||||||
|
<PageHeader
|
||||||
|
title="Настройки интерфейса"
|
||||||
|
icon={<IconSettings size={28} />}
|
||||||
|
pretitle="Интерфейс"
|
||||||
|
/>
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-body text-center py-5">
|
||||||
|
<div className="spinner-border text-primary" role="status" />
|
||||||
|
<p className="mt-2 mb-0 text-muted">Загрузка настроек…</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-wrapper">
|
||||||
|
<div className="page-body">
|
||||||
|
<div className="container-xl">
|
||||||
|
<PageHeader
|
||||||
|
title="Настройки интерфейса"
|
||||||
|
icon={<IconSettings size={28} />}
|
||||||
|
pretitle="Интерфейс"
|
||||||
|
meta="WebSocket, DoH, пинг, PTR зона и другие параметры UI"
|
||||||
|
actions={
|
||||||
|
<div className="btn-list">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={onSave}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{saving && (
|
||||||
|
<span className="spinner-border spinner-border-sm me-2" />
|
||||||
|
)}
|
||||||
|
<IconDeviceFloppy size={18} className="me-1" />
|
||||||
|
Сохранить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<ErrorAlert message={error} onClose={() => setError('')} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{success && (
|
||||||
|
<div
|
||||||
|
className="alert alert-success alert-dismissible mb-3"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
<div className="d-flex">
|
||||||
|
<div className="flex-grow-1">{success}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-close"
|
||||||
|
onClick={() => setSuccess('')}
|
||||||
|
aria-label="Закрыть"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-12">
|
||||||
|
{/* Секция: BGP Live и DoH */}
|
||||||
|
<SectionCard
|
||||||
|
id="live-doh"
|
||||||
|
title="BGP Live и DNS (DoH)"
|
||||||
|
icon={IconPlugConnected}
|
||||||
|
defaultOpen={true}
|
||||||
|
>
|
||||||
|
<div className="row g-3">
|
||||||
|
<div className="col-12">
|
||||||
|
<FormField
|
||||||
|
label="WebSocket URL (BGP Live)"
|
||||||
|
name="wsUrl"
|
||||||
|
type="text"
|
||||||
|
value={wsUrl}
|
||||||
|
onChange={setWsUrl}
|
||||||
|
onValidate={validateWs}
|
||||||
|
placeholder="ws://host:port/ws/update_bgp?api_key=..."
|
||||||
|
helpText="URL для Live-обновления BGP (ws:// или wss://). Можно оставить пустым."
|
||||||
|
disabled={saving}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-12">
|
||||||
|
<FormField
|
||||||
|
label="DoH сервер"
|
||||||
|
name="dohServer"
|
||||||
|
type="text"
|
||||||
|
value={dohServer}
|
||||||
|
onChange={setDohServer}
|
||||||
|
onValidate={validateDoh}
|
||||||
|
placeholder="https://dns.google/dns-query"
|
||||||
|
helpText="HTTPS URL для DNS-over-HTTPS"
|
||||||
|
disabled={saving}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
{/* Секция: Сеть и AS */}
|
||||||
|
<SectionCard
|
||||||
|
id="network-as"
|
||||||
|
title="Сеть и AS"
|
||||||
|
icon={IconNetwork}
|
||||||
|
defaultOpen={true}
|
||||||
|
>
|
||||||
|
<div className="row g-3">
|
||||||
|
<div className="col-md-6">
|
||||||
|
<FormField
|
||||||
|
label="Базовая AS"
|
||||||
|
name="baseAS"
|
||||||
|
type="text"
|
||||||
|
value={baseAS}
|
||||||
|
onChange={setBaseAS}
|
||||||
|
placeholder="65001"
|
||||||
|
helpText="AS по умолчанию для community (например, 65001)."
|
||||||
|
disabled={saving}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
{/* Секция: Пинг (домен и кеш) */}
|
||||||
|
<SectionCard
|
||||||
|
id="ping"
|
||||||
|
title="Пинг через MikroTik"
|
||||||
|
icon={IconCloud}
|
||||||
|
defaultOpen={true}
|
||||||
|
>
|
||||||
|
<div className="row g-3">
|
||||||
|
<div className="col-md-6">
|
||||||
|
<FormField
|
||||||
|
label="Домен для пинга"
|
||||||
|
name="pingDomain"
|
||||||
|
type="text"
|
||||||
|
value={pingDomain}
|
||||||
|
onChange={setPingDomain}
|
||||||
|
placeholder="8.8.8.8 или ya.ru"
|
||||||
|
helpText="Домен или IP для проверки пинга через MikroTik."
|
||||||
|
disabled={saving}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-6">
|
||||||
|
<FormField
|
||||||
|
label="Срок кеша пинга (мин)"
|
||||||
|
name="pingCacheMinutes"
|
||||||
|
type="number"
|
||||||
|
value={pingCacheMinutes}
|
||||||
|
onChange={setPingCacheMinutes}
|
||||||
|
placeholder="0"
|
||||||
|
helpText="0 — без кеша. Иначе результаты пинга кешируются в S3."
|
||||||
|
disabled={saving}
|
||||||
|
min={0}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
{/* Секция: Пинг сервисов на главной */}
|
||||||
|
<SectionCard
|
||||||
|
id="ping-services"
|
||||||
|
title="Пинг сервисов на главной"
|
||||||
|
icon={IconChartPie}
|
||||||
|
defaultOpen={true}
|
||||||
|
>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Источник пинга</label>
|
||||||
|
<select
|
||||||
|
className="form-select"
|
||||||
|
value={pingServicesSource}
|
||||||
|
onChange={(e) => setPingServicesSource(e.target.value)}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
<option value="web">
|
||||||
|
Веб (TCP с сервера приложения)
|
||||||
|
</option>
|
||||||
|
<option value="router">Роутер (RouterOS API)</option>
|
||||||
|
</select>
|
||||||
|
<div className="form-text">
|
||||||
|
«Веб» — задержка до 8.8.8.8, 1.1.1.1 с сервера. «Роутер» —
|
||||||
|
пинг через выбранный MikroTik (jumphost).
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<FormField
|
||||||
|
label="Время кеширования пингов (сек)"
|
||||||
|
name="pingServicesCacheSeconds"
|
||||||
|
type="number"
|
||||||
|
value={pingServicesCacheSeconds}
|
||||||
|
onChange={setPingServicesCacheSeconds}
|
||||||
|
placeholder="0"
|
||||||
|
helpText="0 — без кеша. Результаты пинга кешируются на указанное число секунд."
|
||||||
|
disabled={saving}
|
||||||
|
min={0}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{pingServicesSource === 'router' && (
|
||||||
|
<div className="row g-3">
|
||||||
|
<div className="col-md-6">
|
||||||
|
<label className="form-label">Домашний роутер</label>
|
||||||
|
<div className={saving ? 'opacity-75 pe-none' : ''}>
|
||||||
|
<ServerAutocompleteInput
|
||||||
|
value={pingServicesServerId}
|
||||||
|
onChange={(v) =>
|
||||||
|
setPingServicesServerId(String(v || '').trim())
|
||||||
|
}
|
||||||
|
servers={routerServersForPing}
|
||||||
|
placeholder="Выберите роутер (jumphost или входной)"
|
||||||
|
className="form-control"
|
||||||
|
maxSuggestions={10}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-text">
|
||||||
|
Роутер с MikroTik API для пинга с главной. Пусто —
|
||||||
|
первый jumphost.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-6">
|
||||||
|
<FormField
|
||||||
|
label="IP шлюза (не обязательно)"
|
||||||
|
name="pingServicesGatewayIp"
|
||||||
|
type="text"
|
||||||
|
value={pingServicesGatewayIp}
|
||||||
|
onChange={setPingServicesGatewayIp}
|
||||||
|
placeholder="IP шлюза"
|
||||||
|
helpText="Пусто — первый шлюз выбранного роутера."
|
||||||
|
disabled={saving}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
{/* Секция: PTR зона */}
|
||||||
|
<SectionCard
|
||||||
|
id="ptr-zone"
|
||||||
|
title="Настройка PTR зоны"
|
||||||
|
icon={IconDns}
|
||||||
|
defaultOpen={true}
|
||||||
|
>
|
||||||
|
<div className="row g-3">
|
||||||
|
<div className="col-md-6">
|
||||||
|
<FormField
|
||||||
|
label="Заменить в DNS домене"
|
||||||
|
name="ptrZoneReplaceFrom"
|
||||||
|
type="text"
|
||||||
|
value={ptrZoneReplaceFrom}
|
||||||
|
onChange={setPtrZoneReplaceFrom}
|
||||||
|
placeholder="rt.shx"
|
||||||
|
helpText='Часть DNS домена для замены (например, "rt.shx")'
|
||||||
|
disabled={saving}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-6">
|
||||||
|
<FormField
|
||||||
|
label="Заменить на"
|
||||||
|
name="ptrZoneReplaceTo"
|
||||||
|
type="text"
|
||||||
|
value={ptrZoneReplaceTo}
|
||||||
|
onChange={setPtrZoneReplaceTo}
|
||||||
|
placeholder="shrt"
|
||||||
|
helpText='На что заменить. Пример: "selectel.msk.rt.shx.su" → "selectel.msk.shrt.su"'
|
||||||
|
disabled={saving}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Кнопка сохранения внизу страницы (дублируем для удобства) */}
|
||||||
|
<div className="mt-3 pt-3 border-top">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={onSave}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{saving && (
|
||||||
|
<span className="spinner-border spinner-border-sm me-2" />
|
||||||
|
)}
|
||||||
|
<IconDeviceFloppy size={18} className="me-1" />
|
||||||
|
Сохранить настройки
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,7 +11,8 @@ import {
|
|||||||
IconCreditCard,
|
IconCreditCard,
|
||||||
IconDownload,
|
IconDownload,
|
||||||
IconKeyboard,
|
IconKeyboard,
|
||||||
IconChartPie
|
IconChartPie,
|
||||||
|
IconSettings
|
||||||
} from '@tabler/icons-react'
|
} from '@tabler/icons-react'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -48,6 +49,7 @@ function CommandPalette() {
|
|||||||
{ icon: IconFilter, label: 'Фильтры', description: 'Filter Manager', action: () => navigate('/filters'), keywords: ['фильтры', 'filters', 'mikrotik'] },
|
{ icon: IconFilter, label: 'Фильтры', description: 'Filter Manager', action: () => navigate('/filters'), keywords: ['фильтры', 'filters', 'mikrotik'] },
|
||||||
{ icon: IconCreditCard, label: 'Биллинг', description: 'Управление биллингом', action: () => navigate('/billing'), keywords: ['биллинг', 'billing', 'оплата'] },
|
{ icon: IconCreditCard, label: 'Биллинг', description: 'Управление биллингом', action: () => navigate('/billing'), keywords: ['биллинг', 'billing', 'оплата'] },
|
||||||
{ icon: IconDownload, label: 'Авто-URL', description: 'Генератор ссылок', action: () => navigate('/auto-urls'), keywords: ['url', 'ссылки', 'генератор'] },
|
{ 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'] },
|
||||||
]
|
]
|
||||||
|
|
||||||
// Фильтрация команд по поисковому запросу
|
// Фильтрация команд по поисковому запросу
|
||||||
|
|||||||
Reference in New Issue
Block a user