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

This commit is contained in:
2026-02-17 14:14:09 +07:00
parent ba0557e877
commit 3514276855
3 changed files with 532 additions and 9 deletions
+13 -8
View File
@@ -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>
);
+516
View File
@@ -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>
);
}
+3 -1
View File
@@ -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'] },
]
// Фильтрация команд по поисковому запросу