diff --git a/backend/server.js b/backend/server.js index aeb41c4..2ef2fb5 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1049,7 +1049,8 @@ app.get('/api/s3/last-modified', async (req, res) => { { name: 'asns', key: 'bgp_data/asns.txt' }, { name: 'servers', key: 'servers.json' }, { name: 'filters', key: 'filters.json' }, - { name: 'ipRanges', key: 'bgp_data/ips.txt' } + { name: 'ipRanges', key: 'bgp_data/ips.txt' }, + { name: 'uiSettings', key: 'bgp_data/rt_ui_settings.json' } ]; const results = await Promise.allSettled( keys.map(k => s3.headObject({ Bucket: BUCKET_NAME, Key: k.key }).promise()) @@ -1625,6 +1626,70 @@ app.get('/api/auto-urls', async (req, res) => { } }); +// --- UI Settings (rt_ui_settings.json in bgp_data) --- +// Get UI settings +app.get('/api/ui-settings', async (req, res) => { + const params = { + Bucket: BUCKET_NAME, + Key: 'bgp_data/rt_ui_settings.json', + }; + + try { + const data = await s3.getObject(params).promise(); + const jsonText = data.Body.toString('utf-8'); + let settings = {}; + try { + const parsed = JSON.parse(jsonText); + if (parsed && typeof parsed === 'object') settings = parsed; + } catch (parseError) { + settings = {}; + } + if (data.ETag) res.set('ETag', String(data.ETag)); + if (data.LastModified) res.set('Last-Modified', new Date(data.LastModified).toUTCString()); + if (typeof data.ContentLength === 'number') res.set('Content-Length-Source', String(data.ContentLength)); + return res.json(settings); + } catch (error) { + if (error.code === 'NoSuchKey') { + return res.json({}); + } + console.error('Error reading ui settings from S3:', error); + return sendError(res, 500, 'Error reading UI settings from S3', 'E_S3'); + } +}); + +// Update UI settings +app.post('/api/ui-settings', async (req, res) => { + const { settings, etag } = req.body || {}; + const payload = (settings && typeof settings === 'object') ? settings : {}; + + try { + // optimistic concurrency if ETag provided (or If-Match header) + let current = null; + const ifMatch = req.headers['if-match'] ? String(req.headers['if-match']).replace(/\"/g,'"') : null; + try { current = await headS3ObjectEtag('bgp_data/rt_ui_settings.json'); } catch {} + if (current && (ifMatch || etag) && current !== String(ifMatch || etag)) { + const meta = await headMeta('bgp_data/rt_ui_settings.json'); + return sendError(res, 412, 'Precondition Failed: ETag mismatch', 'E_ETAG_MISMATCH', { currentEtag: current, meta }); + } + } catch {} + + const params = { + Bucket: BUCKET_NAME, + Key: 'bgp_data/rt_ui_settings.json', + Body: JSON.stringify(payload, null, 2), + ContentType: 'application/json', + }; + + try { + await s3.putObject(params).promise(); + const meta = await headMeta('bgp_data/rt_ui_settings.json'); + return sendOk(res, meta); + } catch (error) { + console.error('Error writing UI settings to S3:', error); + return sendError(res, 500, 'Error writing UI settings to S3', 'E_S3', { error: String(error?.message || error) }); + } +}); + // --- Servers availability check --- // Optimized TCP check: parallelize ports and hosts, cap per-host time, add in-memory TTL cache function tcpCheck(host, port, timeoutMs) { diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index aed7b3a..73d9ed0 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -22,6 +22,7 @@ import CommunitiesManager from './CommunitiesManager'; import Dashboard from './Dashboard'; import './App.css'; import { NotifyProvider } from './components/NotifyProvider.jsx'; +import SettingsModal from './components/SettingsModal.jsx'; // axios не используется напрямую; сетевые вызовы через src/lib/api.js // --- Simple i18n (RU/EN) --- @@ -96,6 +97,7 @@ function MainLayout() { const { lang, setLang, t } = useContext(LanguageContext); const { theme, setTheme } = useContext(ThemeContext); // Навбар стал лаконичным: без неиспользуемых уведомлений/иконок + const [settingsOpen, setSettingsOpen] = useState(false); // Состояние для управления выпадающими меню const [dropdownStates, setDropdownStates] = useState({ @@ -301,6 +303,11 @@ function MainLayout() { +
+ +
@@ -318,6 +325,7 @@ function MainLayout() { } /> + setSettingsOpen(false)} /> ); } diff --git a/frontend/src/components/SettingsModal.jsx b/frontend/src/components/SettingsModal.jsx new file mode 100644 index 0000000..7bdfc65 --- /dev/null +++ b/frontend/src/components/SettingsModal.jsx @@ -0,0 +1,130 @@ +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('') + + 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 || '')) + 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 onSave = async () => { + setError('') + setSuccess('') + if (!validateDoh(dohServer)) { setError('Укажите корректный HTTPS URL для DoH'); return } + setSaving(true) + try { + const payload = { settings: { dohServer: String(dohServer || '').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 ( +
{ if (e.key === 'Escape') onClose?.() }}> +
+
{ + 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(); } + } + }}> +
+
Настройки интерфейса
+ +
+
+ {error && ( +
+ {error} + +
+ )} + {success && ( +
+ {success} + +
+ )} +
+ + setDohServer(e.target.value)} + disabled={loading || saving} + data-primary + /> +
HTTPS URL для DNS-over-HTTPS (например, https://dns.google/dns-query)
+ {dohServer && !validateDoh(dohServer) && ( +
Укажите корректный HTTPS URL
+ )} +
+
+
+ + +
+
+
+
+ ) +} + +