Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m58s
158 lines
6.5 KiB
React
158 lines
6.5 KiB
React
import { useEffect, useRef, useState } from 'react'
|
|
import api from '../lib/api.js'
|
|
|
|
export default function SettingsModal({ open, onClose }) {
|
|
const ref = useRef(null)
|
|
const [loading, setLoading] = useState(false)
|
|
const [saving, setSaving] = useState(false)
|
|
const [error, setError] = useState('')
|
|
const [success, setSuccess] = useState('')
|
|
const [etag, setEtag] = useState('')
|
|
const [dohServer, setDohServer] = useState('')
|
|
const [wsUrl, setWsUrl] = useState('')
|
|
|
|
useEffect(() => {
|
|
if (!open) return
|
|
setError('')
|
|
setSuccess('')
|
|
setLoading(true)
|
|
;(async () => {
|
|
try {
|
|
const res = await api.get('/ui-settings')
|
|
const data = res?.data || {}
|
|
setDohServer(String(data?.dohServer || ''))
|
|
setWsUrl(String(data?.wsUpdateUrl || ''))
|
|
const e = res?.headers?.etag || res?.headers?.ETag || ''
|
|
setEtag(e ? String(e) : '')
|
|
// фокус на первом поле
|
|
setTimeout(() => { try { ref.current?.querySelector('input[data-primary]')?.focus() } catch {} }, 0)
|
|
} catch (e) {
|
|
setError('Не удалось загрузить настройки')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
})()
|
|
}, [open])
|
|
|
|
if (!open) return null
|
|
|
|
const validateDoh = (value) => {
|
|
if (!value) return true // допускаем пустое значение
|
|
try {
|
|
const u = new URL(String(value))
|
|
return u.protocol === 'https:'
|
|
} catch { return false }
|
|
}
|
|
|
|
const validateWs = (value) => {
|
|
if (!value) return true // допускаем пустое значение
|
|
try {
|
|
const u = new URL(String(value))
|
|
return u.protocol === 'ws:' || u.protocol === 'wss:'
|
|
} catch { return false }
|
|
}
|
|
|
|
const onSave = async () => {
|
|
setError('')
|
|
setSuccess('')
|
|
if (!validateDoh(dohServer)) { setError('Укажите корректный HTTPS URL для DoH'); return }
|
|
if (!validateWs(wsUrl)) { setError('Укажите корректный WebSocket URL (ws:// или wss://)'); return }
|
|
setSaving(true)
|
|
try {
|
|
const payload = { settings: { dohServer: String(dohServer || '').trim(), wsUpdateUrl: String(wsUrl || '').trim() }, etag }
|
|
const res = await api.post('/ui-settings', payload)
|
|
const meta = res?.data || {}
|
|
setSuccess('Настройки сохранены')
|
|
setEtag(String(meta?.etag || ''))
|
|
setTimeout(() => setSuccess(''), 2500)
|
|
} catch (e) {
|
|
setError(e?.response?.data?.message || 'Ошибка при сохранении настроек')
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="modal show d-block" role="dialog" aria-modal="true" aria-labelledby="settings-title" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }} onKeyDown={(e) => { if (e.key === 'Escape') onClose?.() }}>
|
|
<div className="modal-dialog" role="document">
|
|
<div className="modal-content" ref={ref} tabIndex={-1} onKeyDown={(e) => {
|
|
if (e.key === 'Tab') {
|
|
const focusable = ref.current?.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])')
|
|
if (!focusable || focusable.length === 0) return
|
|
const first = focusable[0]
|
|
const last = focusable[focusable.length - 1]
|
|
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
|
|
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
|
|
}
|
|
}}>
|
|
<div className="modal-header">
|
|
<h5 id="settings-title" className="modal-title">Настройки интерфейса</h5>
|
|
<button type="button" className="btn-close" aria-label="Close" onClick={onClose}></button>
|
|
</div>
|
|
<div className="modal-body">
|
|
{error && (
|
|
<div className="alert alert-danger alert-dismissible" role="alert">
|
|
{error}
|
|
<button type="button" className="btn-close" onClick={() => setError('')}></button>
|
|
</div>
|
|
)}
|
|
{success && (
|
|
<div className="alert alert-success alert-dismissible" role="alert">
|
|
{success}
|
|
<button type="button" className="btn-close" onClick={() => setSuccess('')}></button>
|
|
</div>
|
|
)}
|
|
<div className="mb-3">
|
|
<label className="form-label">WebSocket URL (BGP Live)</label>
|
|
<input
|
|
type="text"
|
|
className={`form-control${wsUrl && !validateWs(wsUrl) ? ' is-invalid' : ''}`}
|
|
placeholder="ws://host:port/ws/update_bgp?api_key=..."
|
|
value={wsUrl}
|
|
onChange={(e) => setWsUrl(e.target.value)}
|
|
disabled={loading || saving}
|
|
/>
|
|
<div className="form-hint">URL для Live-обновления BGP (ws:// или wss://). Можно оставить пустым.</div>
|
|
{wsUrl && !validateWs(wsUrl) && (
|
|
<div className="invalid-feedback">Укажите корректный WebSocket URL</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="mb-3">
|
|
<label className="form-label">DoH сервер</label>
|
|
<input
|
|
type="text"
|
|
className={`form-control${dohServer && !validateDoh(dohServer) ? ' is-invalid' : ''}`}
|
|
placeholder="https://dns.google/dns-query"
|
|
value={dohServer}
|
|
onChange={(e) => setDohServer(e.target.value)}
|
|
disabled={loading || saving}
|
|
data-primary
|
|
/>
|
|
<div className="form-hint">HTTPS URL для DNS-over-HTTPS (например, https://dns.google/dns-query)</div>
|
|
{dohServer && !validateDoh(dohServer) && (
|
|
<div className="invalid-feedback">Укажите корректный HTTPS URL</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" role="status" />
|
|
Сохранение...
|
|
</>
|
|
) : (
|
|
'Сохранить'
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
|