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 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() {
|
||||
<KeyboardShortcutsButton className="nav-link px-2" />
|
||||
</div>
|
||||
<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} />
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -388,13 +393,13 @@ function MainLayout() {
|
||||
<Route path="/mikrotik-backups" element={<MikrotikBackupsManager />} />
|
||||
<Route path="/mikrotik-tools" element={<MikrotikTools />} />
|
||||
<Route path="/ping-services" element={<PingServicesManager />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
</Routes>
|
||||
</main>
|
||||
<footer className="footer mt-auto py-2">
|
||||
<div className="container-xl text-muted small">Версия UI: {import.meta?.env?.VITE_APP_VERSION || 'dev'}</div>
|
||||
</footer>
|
||||
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
||||
<CommandPalette />
|
||||
</div>
|
||||
);
|
||||
@@ -515,7 +520,7 @@ function MainLayout() {
|
||||
<KeyboardShortcutsButton className="nav-link btn-icon rounded" title="Горячие клавиши" />
|
||||
</div>
|
||||
<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} />
|
||||
</a>
|
||||
</div>
|
||||
@@ -545,6 +550,7 @@ function MainLayout() {
|
||||
<Route path="/mikrotik-backups" element={<MikrotikBackupsManager />} />
|
||||
<Route path="/mikrotik-tools" element={<MikrotikTools />} />
|
||||
<Route path="/ping-services" element={<PingServicesManager />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
</Routes>
|
||||
</main>
|
||||
@@ -558,7 +564,6 @@ function MainLayout() {
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
||||
<CommandPalette />
|
||||
</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,
|
||||
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'] },
|
||||
]
|
||||
|
||||
// Фильтрация команд по поисковому запросу
|
||||
|
||||
Reference in New Issue
Block a user