Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 2m42s
287 lines
10 KiB
React
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>
|
|
</>
|
|
);
|
|
}
|