feat: Добавить API для управления настройками пользовательского интерфейса, включая получение и обновление конфигурации из S3; интегрировать модальное окно настроек в интерфейс
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m33s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m33s
This commit is contained in:
+66
-1
@@ -1049,7 +1049,8 @@ app.get('/api/s3/last-modified', async (req, res) => {
|
|||||||
{ name: 'asns', key: 'bgp_data/asns.txt' },
|
{ name: 'asns', key: 'bgp_data/asns.txt' },
|
||||||
{ name: 'servers', key: 'servers.json' },
|
{ name: 'servers', key: 'servers.json' },
|
||||||
{ name: 'filters', key: 'filters.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(
|
const results = await Promise.allSettled(
|
||||||
keys.map(k => s3.headObject({ Bucket: BUCKET_NAME, Key: k.key }).promise())
|
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 ---
|
// --- Servers availability check ---
|
||||||
// Optimized TCP check: parallelize ports and hosts, cap per-host time, add in-memory TTL cache
|
// Optimized TCP check: parallelize ports and hosts, cap per-host time, add in-memory TTL cache
|
||||||
function tcpCheck(host, port, timeoutMs) {
|
function tcpCheck(host, port, timeoutMs) {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import CommunitiesManager from './CommunitiesManager';
|
|||||||
import Dashboard from './Dashboard';
|
import Dashboard from './Dashboard';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
import { NotifyProvider } from './components/NotifyProvider.jsx';
|
import { NotifyProvider } from './components/NotifyProvider.jsx';
|
||||||
|
import SettingsModal from './components/SettingsModal.jsx';
|
||||||
// axios не используется напрямую; сетевые вызовы через src/lib/api.js
|
// axios не используется напрямую; сетевые вызовы через src/lib/api.js
|
||||||
|
|
||||||
// --- Simple i18n (RU/EN) ---
|
// --- Simple i18n (RU/EN) ---
|
||||||
@@ -96,6 +97,7 @@ function MainLayout() {
|
|||||||
const { lang, setLang, t } = useContext(LanguageContext);
|
const { lang, setLang, t } = useContext(LanguageContext);
|
||||||
const { theme, setTheme } = useContext(ThemeContext);
|
const { theme, setTheme } = useContext(ThemeContext);
|
||||||
// Навбар стал лаконичным: без неиспользуемых уведомлений/иконок
|
// Навбар стал лаконичным: без неиспользуемых уведомлений/иконок
|
||||||
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
|
|
||||||
// Состояние для управления выпадающими меню
|
// Состояние для управления выпадающими меню
|
||||||
const [dropdownStates, setDropdownStates] = useState({
|
const [dropdownStates, setDropdownStates] = useState({
|
||||||
@@ -301,6 +303,11 @@ function MainLayout() {
|
|||||||
<option value="dark">{t('dark')}</option>
|
<option value="dark">{t('dark')}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="nav-item ms-2">
|
||||||
|
<button className="btn btn-outline-primary btn-sm" onClick={() => setSettingsOpen(true)}>
|
||||||
|
<IconSettings className="me-1" /> Настройки
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -318,6 +325,7 @@ function MainLayout() {
|
|||||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
|
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<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">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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user