Files
router-lists-ui/frontend/src/components/SettingsModal.jsx
T
denozord 6a90f4079d
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 2m42s
feat(ping): add ping caching functionality with configurable duration in UI settings
2026-02-11 11:48:32 +07:00

287 lines
10 KiB
React

import FormField from './FormField';
import { useEffect, useState } from 'react';
import api from '../lib/api.js';
import { IconSettings, IconDeviceFloppy } from '@tabler/icons-react';
import ErrorAlert from './ErrorAlert';
/**
* SettingsModal - модальное окно настроек интерфейса
* Простой подход со встроенным backdrop (как в HistoryModal)
*/
export default function SettingsModal({ open, onClose }) {
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('');
useEffect(() => {
if (!open) return;
setError('');
setSuccess('');
setLoading(true);
(async () => {
try {
const res = await api.get('/ui-settings');
const data = res?.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 || ''));
const e = res?.headers?.etag || res?.headers?.ETag || '';
setEtag(e ? String(e) : '');
} catch (e) {
setError('Не удалось загрузить настройки');
} finally {
setLoading(false);
}
})();
}, [open]);
if (!open) return null;
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(),
};
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);
}
};
const handleBackdropClick = (e) => {
if (e.target === e.currentTarget) {
onClose?.();
}
};
return (
<>
{/* Modal Backdrop */}
<div className="modal-backdrop show" onClick={handleBackdropClick} />
{/* Modal */}
<div
className="modal show d-block"
role="dialog"
aria-modal="true"
tabIndex={-1}
onKeyDown={(e) => { if (e.key === 'Escape') onClose?.(); }}
>
<div className="modal-dialog modal-dialog-centered" role="document">
<div className="modal-content" tabIndex={-1}>
<div className="modal-header">
<h5 className="modal-title d-flex align-items-center">
<IconSettings className="me-2" size={24} />
Настройки интерфейса
</h5>
<button type="button" className="btn-close" onClick={onClose} aria-label="Закрыть" />
</div>
<div className="modal-body">
{error && <ErrorAlert message={error} onClose={() => setError('')} />}
{success && (
<div className="alert alert-success alert-dismissible" role="alert">
<div className="d-flex">
<div className="flex-grow-1">{success}</div>
</div>
<button
type="button"
className="btn-close"
onClick={() => setSuccess('')}
/>
</div>
)}
<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={loading || saving}
autoFocus
/>
<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={loading || saving}
/>
<FormField
label="Базовая AS"
name="baseAS"
type="text"
value={baseAS}
onChange={setBaseAS}
placeholder="65001"
helpText="AS по умолчанию для community (например, 65001). Используется при поиске и нормализации community."
disabled={loading || saving}
/>
<FormField
label="Домен для пинга"
name="pingDomain"
type="text"
value={pingDomain}
onChange={setPingDomain}
placeholder="8.8.8.8 или ya.ru"
helpText="Домен или IP для проверки пинга через MikroTik (например, 8.8.8.8, ya.ru). Если не задан, используется значение по умолчанию."
disabled={loading || saving}
/>
<FormField
label="Срок кеша пинга (мин)"
name="pingCacheMinutes"
type="number"
value={pingCacheMinutes}
onChange={setPingCacheMinutes}
placeholder="0"
helpText="0 — без кеша. При значении больше 0 результаты пинга кешируются в S3 на указанное количество минут."
disabled={loading || saving}
min={0}
/>
<div className="mt-4 pt-3 border-top">
<h6 className="mb-3">Настройка PTR зоны</h6>
<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={loading || saving}
/>
</div>
<div className="col-md-6">
<FormField
label="Заменить на"
name="ptrZoneReplaceTo"
type="text"
value={ptrZoneReplaceTo}
onChange={setPtrZoneReplaceTo}
placeholder="shrt"
helpText='На что заменить (например, "shrt"). Результат: DNS "selectel.msk.rt.shx.su" → PTR "selectel.msk.shrt.su"'
disabled={loading || saving}
/>
</div>
</div>
</div>
</div>
<div className="modal-footer">
<button
type="button"
className="btn btn-secondary"
onClick={onClose}
disabled={saving}
>
Закрыть
</button>
<button
type="button"
className="btn btn-primary"
onClick={onSave}
disabled={saving || loading}
>
{saving && <span className="spinner-border spinner-border-sm me-2" />}
<IconDeviceFloppy size={16} className="me-1" />
Сохранить
</button>
</div>
</div>
</div>
</div>
</>
);
}