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() { +