feat: Добавить API для управления настройками пользовательского интерфейса, включая получение и обновление конфигурации из S3; интегрировать модальное окно настроек в интерфейс
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m33s

This commit is contained in:
2025-08-26 17:07:26 +07:00
parent f11374880c
commit 03d24e8746
3 changed files with 204 additions and 1 deletions
+66 -1
View File
@@ -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) {
+8
View File
@@ -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>
);
}
+130
View File
@@ -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>
)
}