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: '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) {
|
||||
|
||||
@@ -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() {
|
||||
<option value="dark">{t('dark')}</option>
|
||||
</select>
|
||||
</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>
|
||||
</header>
|
||||
@@ -318,6 +325,7 @@ function MainLayout() {
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
</Routes>
|
||||
</div>
|
||||
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
||||
</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