feat: Обновить серверную часть для улучшения обработки запросов и добавления новых метрик; интегрировать поддержку WebSocket URL в менеджеры ASNs, Domains и IPRanges, а также обновить интерфейс настроек для ввода WebSocket URL
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m58s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m58s
This commit is contained in:
+61
-7
@@ -2,6 +2,7 @@
|
||||
require('dotenv').config();
|
||||
const express = require('express');
|
||||
const { S3Client, GetObjectCommand, HeadObjectCommand, PutObjectCommand, DeleteObjectCommand, CopyObjectCommand, ListObjectVersionsCommand } = require('@aws-sdk/client-s3');
|
||||
const { NodeHttpHandler } = require('@smithy/node-http-handler');
|
||||
const cors = require('cors');
|
||||
const path = require('path');
|
||||
const compression = require('compression');
|
||||
@@ -62,7 +63,7 @@ const limiter = rateLimit({
|
||||
legacyHeaders: false,
|
||||
});
|
||||
app.use(limiter);
|
||||
app.use(express.json());
|
||||
app.use(express.json({ limit: process.env.JSON_LIMIT || '1mb' }));
|
||||
app.use(compression());
|
||||
// Disable Express auto-ETag to avoid weak ETags on JSON bodies
|
||||
app.set('etag', false);
|
||||
@@ -77,6 +78,10 @@ app.use((req, res, next) => {
|
||||
promClient.collectDefaultMetrics();
|
||||
const httpDuration = new promClient.Histogram({ name: 'http_request_duration_seconds', help: 'HTTP request duration', labelNames: ['method', 'route', 'code'], buckets: [0.05,0.1,0.2,0.5,1,2,5] });
|
||||
const httpErrors = new promClient.Counter({ name: 'http_errors_total', help: 'HTTP error count', labelNames: ['route','code'] });
|
||||
const s3Duration = new promClient.Histogram({ name: 's3_request_duration_seconds', help: 'S3 request duration', labelNames: ['op'], buckets: [0.01,0.05,0.1,0.2,0.5,1,2] });
|
||||
const http304 = new promClient.Counter({ name: 'http_304_total', help: 'HTTP 304 responses' });
|
||||
const http412 = new promClient.Counter({ name: 'http_412_total', help: 'HTTP 412 responses' });
|
||||
const http423 = new promClient.Counter({ name: 'http_423_total', help: 'HTTP 423 responses' });
|
||||
app.use((req, res, next) => {
|
||||
const start = process.hrtime.bigint();
|
||||
res.on('finish', () => {
|
||||
@@ -100,6 +105,15 @@ app.get('/metrics', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Version endpoint (static env-based)
|
||||
app.get('/api/version', (req, res) => {
|
||||
res.json({
|
||||
version: process.env.APP_VERSION || null,
|
||||
gitSha: process.env.GIT_SHA || null,
|
||||
buildAt: process.env.BUILD_AT || null
|
||||
});
|
||||
});
|
||||
|
||||
// Централизованный обработчик ошибок (должен быть подключён ПОСЛЕ роутов — см. ниже второе use)
|
||||
|
||||
// Helpers: meta and responses
|
||||
@@ -129,6 +143,7 @@ function sendOk(res, meta) {
|
||||
|
||||
function sendError(res, status, message, code, details) {
|
||||
const requestId = res.req?.id;
|
||||
try { if (status === 304) http304.inc(); if (status === 412) http412.inc(); if (status === 423) http423.inc(); } catch {}
|
||||
return res.status(status).json({ code, message, details, requestId });
|
||||
}
|
||||
|
||||
@@ -171,11 +186,24 @@ const s3 = new S3Client({
|
||||
region: process.env.AWS_REGION,
|
||||
forcePathStyle: true,
|
||||
maxAttempts: 3,
|
||||
requestHandler: new NodeHttpHandler({
|
||||
httpAgent: new http.Agent({ keepAlive: true }),
|
||||
httpsAgent: new https.Agent({ keepAlive: true })
|
||||
}),
|
||||
credentials: {
|
||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID || process.env.S3_ACCESS_KEY_ID,
|
||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || process.env.S3_SECRET_ACCESS_KEY,
|
||||
}
|
||||
});
|
||||
// Default Cache-Control for GETs
|
||||
const DEFAULT_CACHE_TTL = Math.max(0, Math.min(300, Number(process.env.CACHE_TTL_SECONDS) || 30));
|
||||
app.use((req, res, next) => {
|
||||
if (req.method === 'GET') {
|
||||
res.set('Cache-Control', `private, max-age=${DEFAULT_CACHE_TTL}`);
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
|
||||
const BUCKET_NAME = process.env.S3_BUCKET_NAME;
|
||||
const FILE_KEY = 'bgp_data/domains.txt';
|
||||
@@ -330,6 +358,7 @@ function setCountOnlyCache(cacheKey, value) {
|
||||
function checkIfNoneMatch(req, res, etag) {
|
||||
const inm = req.headers && (req.headers['if-none-match'] || req.headers['If-None-Match']);
|
||||
if (inm && etag && String(inm) === String(etag)) {
|
||||
try { http304.inc(); } catch {}
|
||||
res.status(304).end();
|
||||
return true;
|
||||
}
|
||||
@@ -353,7 +382,9 @@ async function streamToString(stream) {
|
||||
async function readS3TextObject(key) {
|
||||
const cached = getCache(s3Cache.text, key);
|
||||
if (cached) return cached;
|
||||
const s3Start = Date.now();
|
||||
const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: key }));
|
||||
try { s3Duration.labels('getObject').observe((Date.now() - s3Start)/1000); } catch {}
|
||||
const out = {
|
||||
body: await streamToString(data.Body),
|
||||
etag: data.ETag || undefined,
|
||||
@@ -368,7 +399,9 @@ async function readS3TextObject(key) {
|
||||
async function headS3ObjectEtag(key) {
|
||||
const cached = getCache(s3Cache.head, key);
|
||||
if (cached && cached.etag) return cached.etag;
|
||||
const s3Start = Date.now();
|
||||
const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: key }));
|
||||
try { s3Duration.labels('headObject').observe((Date.now() - s3Start)/1000); } catch {}
|
||||
setCache(s3Cache.head, key, { etag: head.ETag || undefined });
|
||||
return head.ETag || undefined;
|
||||
}
|
||||
@@ -419,6 +452,11 @@ async function streamPaginatedText({ key, mapLine, q, offset = 0, limit = 0 }) {
|
||||
} else {
|
||||
items.push(mapLine(line));
|
||||
}
|
||||
// Раннее завершение при достижении offset+limit
|
||||
if (limit > 0 && sent >= limit) {
|
||||
try { stream.destroy(); } catch {}
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
stream.on('end', () => {
|
||||
@@ -1239,15 +1277,18 @@ app.delete('/api/locks/:resource', (req, res) => {
|
||||
// History endpoints (require bucket versioning to be enabled). If versioning disabled, best effort.
|
||||
app.get('/api/history/:resource', async (req, res) => {
|
||||
const { resource } = req.params;
|
||||
const { countOnly, format } = req.query || {};
|
||||
const key = resourceToKey(resource);
|
||||
if (!key) return sendError(res, 400, 'Unknown resource', 'E_RESOURCE');
|
||||
try {
|
||||
const out = await s3.send(new ListObjectVersionsCommand({ Bucket: BUCKET_NAME, Prefix: key, MaxKeys: 20 }));
|
||||
const versions = (out.Versions || [])
|
||||
.filter(v => v.Key === key)
|
||||
.slice(0, 10)
|
||||
.map(v => ({ versionId: v.VersionId, isLatest: v.IsLatest, lastModified: toIso(v.LastModified), size: v.Size, etag: v.ETag }));
|
||||
return res.json({ items: versions });
|
||||
const out = await s3.send(new ListObjectVersionsCommand({ Bucket: BUCKET_NAME, Prefix: key, MaxKeys: 50 }));
|
||||
const versionsAll = (out.Versions || []).filter(v => v.Key === key);
|
||||
if (countOnly === 'true') {
|
||||
const total = versionsAll.length;
|
||||
return res.json(format === 'std' ? { items: [], total, meta: {} } : { total });
|
||||
}
|
||||
const versions = versionsAll.slice(0, 10).map(v => ({ versionId: v.VersionId, isLatest: v.IsLatest, lastModified: toIso(v.LastModified), size: v.Size, etag: v.ETag }));
|
||||
return res.json(format === 'std' ? { items: versions, total: versionsAll.length, meta: {} } : { items: versions });
|
||||
} catch (e) {
|
||||
console.error('history error', e);
|
||||
return sendError(res, 500, 'Error reading history', 'E_S3', { error: String(e?.message || e) });
|
||||
@@ -2194,6 +2235,19 @@ app.post('/api/update-bgp/background', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Provide ws url to UI from settings/env to avoid exposing keys in bundle
|
||||
app.get('/api/ws/url', async (req, res) => {
|
||||
try {
|
||||
const settings = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'bgp_data/rt_ui_settings.json' })).then(async (d) => {
|
||||
try { return JSON.parse(await streamToString(d.Body)); } catch { return {}; }
|
||||
}).catch(() => ({}));
|
||||
const url = settings?.wsUpdateUrl || process.env.WS_UPDATE_URL || '';
|
||||
return res.json({ url });
|
||||
} catch (e) {
|
||||
return res.json({ url: '' });
|
||||
}
|
||||
});
|
||||
|
||||
// The "catchall" handler: for any request that doesn't
|
||||
// match one above, send back React's index.html file.
|
||||
app.get('*', (req, res) => {
|
||||
|
||||
@@ -39,6 +39,7 @@ function ASNsNewManager() {
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const searchTimer = useRef(null);
|
||||
const [editingAsn, setEditingAsn] = useState(null);
|
||||
const [editingValue, setEditingValue] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -81,6 +82,9 @@ function ASNsNewManager() {
|
||||
// Допускаем как числовые, так и строковые (AS:NNN) community
|
||||
const isValidCommunity = (value) => /^(\d+|\d+:\d+)$/.test(String(value).trim());
|
||||
|
||||
const [wsOpen, setWsOpen] = useState(false);
|
||||
const [wsUrl, setWsUrl] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
// Acquire soft lock
|
||||
const owner = localStorage.getItem('uiOwner') || `ui-${Math.random().toString(36).slice(2,8)}`;
|
||||
@@ -92,6 +96,10 @@ function ASNsNewManager() {
|
||||
acquire();
|
||||
const interval = setInterval(acquire, 60_000);
|
||||
fetchItems();
|
||||
// Load WS URL once
|
||||
(async () => {
|
||||
try { const r = await api.get('/ws/url'); setWsUrl(String(r.data?.url || '')); } catch {}
|
||||
})();
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
api.delete(`/locks/${resource}`).catch(() => {});
|
||||
@@ -123,7 +131,11 @@ function ASNsNewManager() {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchItems();
|
||||
if (searchTimer.current) clearTimeout(searchTimer.current);
|
||||
searchTimer.current = setTimeout(() => {
|
||||
fetchItems();
|
||||
}, 350);
|
||||
return () => { if (searchTimer.current) clearTimeout(searchTimer.current); };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentPage, searchTerm, filterCommunity]);
|
||||
|
||||
@@ -213,7 +225,6 @@ function ASNsNewManager() {
|
||||
const [diff, setDiff] = useState({ added: [], removed: [], changed: [] });
|
||||
const [confirmSaveOpen, setConfirmSaveOpen] = useState(false);
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
const [wsOpen, setWsOpen] = useState(false);
|
||||
const [clearCommunitiesOpen, setClearCommunitiesOpen] = useState(false);
|
||||
|
||||
const handlePreviewDiff = () => {
|
||||
@@ -527,6 +538,7 @@ function ASNsNewManager() {
|
||||
placeholder="Поиск ASN..."
|
||||
value={searchTerm}
|
||||
onChange={e => { setSearchTerm(e.target.value); setCurrentPage(1); }}
|
||||
aria-label="Поиск ASN"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -578,8 +590,8 @@ function ASNsNewManager() {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button className="btn btn-outline-primary btn-icon btn-sm me-1" onClick={() => handleEdit(item)}><IconEdit size={16} /></button>
|
||||
<button className="btn btn-outline-danger btn-icon btn-sm" onClick={() => confirmDelete(item)}><IconTrash size={16} /></button>
|
||||
<button className="btn btn-outline-primary btn-icon btn-sm me-1" aria-label="Редактировать" onClick={() => handleEdit(item)}><IconEdit size={16} /></button>
|
||||
<button className="btn btn-outline-danger btn-icon btn-sm" aria-label="Удалить" onClick={() => confirmDelete(item)}><IconTrash size={16} /></button>
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
@@ -693,7 +705,7 @@ function ASNsNewManager() {
|
||||
/>
|
||||
<WsUpdateModal
|
||||
show={wsOpen}
|
||||
url={'ws://77.232.38.173:8080/ws/update_bgp?api_key=denozord2502'}
|
||||
url={wsUrl}
|
||||
onClose={() => setWsOpen(false)}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
|
||||
+18
-7
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect, createContext, useContext } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import {
|
||||
IconBrandTabler,
|
||||
IconWorld,
|
||||
@@ -79,15 +80,25 @@ import {
|
||||
import { IconMenu2 } from '@tabler/icons-react';
|
||||
|
||||
function App() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 30_000,
|
||||
}
|
||||
}
|
||||
});
|
||||
return (
|
||||
<Router>
|
||||
<LanguageProvider>
|
||||
<ThemeProvider>
|
||||
<NotifyProvider>
|
||||
<MainLayout />
|
||||
</NotifyProvider>
|
||||
</ThemeProvider>
|
||||
</LanguageProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<LanguageProvider>
|
||||
<ThemeProvider>
|
||||
<NotifyProvider>
|
||||
<MainLayout />
|
||||
</NotifyProvider>
|
||||
</ThemeProvider>
|
||||
</LanguageProvider>
|
||||
</QueryClientProvider>
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ function DomainsNewManager() {
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const searchTimer = useRef(null);
|
||||
const [editingDomain, setEditingDomain] = useState(null);
|
||||
const [editingValue, setEditingValue] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -86,6 +87,9 @@ function DomainsNewManager() {
|
||||
// Допускаем как числовые, так и строковые (AS:NNN) community
|
||||
const isValidCommunity = (value) => /^(\d+|\d+:\d+)$/.test(String(value).trim());
|
||||
|
||||
const [wsOpen, setWsOpen] = useState(false);
|
||||
const [wsUrl, setWsUrl] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
// Acquire soft lock
|
||||
const owner = localStorage.getItem('uiOwner') || `ui-${Math.random().toString(36).slice(2,8)}`;
|
||||
@@ -97,6 +101,7 @@ function DomainsNewManager() {
|
||||
acquire();
|
||||
const interval = setInterval(acquire, 60_000);
|
||||
fetchItems();
|
||||
(async () => { try { const r = await api.get('/ws/url'); setWsUrl(String(r.data?.url || '')); } catch {} })();
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
api.delete(`/locks/${resource}`).catch(() => {});
|
||||
@@ -128,7 +133,11 @@ function DomainsNewManager() {
|
||||
|
||||
// Подгрузка при изменении страницы/поиска/фильтра
|
||||
useEffect(() => {
|
||||
fetchItems();
|
||||
if (searchTimer.current) clearTimeout(searchTimer.current);
|
||||
searchTimer.current = setTimeout(() => {
|
||||
fetchItems();
|
||||
}, 350);
|
||||
return () => { if (searchTimer.current) clearTimeout(searchTimer.current); };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentPage, searchTerm, filterCommunity]);
|
||||
|
||||
@@ -218,7 +227,6 @@ function DomainsNewManager() {
|
||||
const [diff, setDiff] = useState({ added: [], removed: [], changed: [] });
|
||||
const [confirmSaveOpen, setConfirmSaveOpen] = useState(false);
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
const [wsOpen, setWsOpen] = useState(false);
|
||||
const [clearCommunitiesOpen, setClearCommunitiesOpen] = useState(false);
|
||||
|
||||
const handlePreviewDiff = () => {
|
||||
@@ -536,6 +544,7 @@ function DomainsNewManager() {
|
||||
placeholder="Поиск домена..."
|
||||
value={searchTerm}
|
||||
onChange={e => { setSearchTerm(e.target.value); setCurrentPage(1); }}
|
||||
aria-label="Поиск домена"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -587,8 +596,8 @@ function DomainsNewManager() {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button className="btn btn-outline-primary btn-icon btn-sm me-1" onClick={() => handleEdit(item)}><IconEdit size={16} /></button>
|
||||
<button className="btn btn-outline-danger btn-icon btn-sm" onClick={() => confirmDelete(item)}><IconTrash size={16} /></button>
|
||||
<button className="btn btn-outline-primary btn-icon btn-sm me-1" aria-label="Редактировать" onClick={() => handleEdit(item)}><IconEdit size={16} /></button>
|
||||
<button className="btn btn-outline-danger btn-icon btn-sm" aria-label="Удалить" onClick={() => confirmDelete(item)}><IconTrash size={16} /></button>
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
@@ -703,7 +712,7 @@ function DomainsNewManager() {
|
||||
/>
|
||||
<WsUpdateModal
|
||||
show={wsOpen}
|
||||
url={'ws://77.232.38.173:8080/ws/update_bgp?api_key=denozord2502'}
|
||||
url={wsUrl}
|
||||
onClose={() => setWsOpen(false)}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
|
||||
@@ -39,6 +39,7 @@ function IPRangesManager() {
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const searchTimer = useRef(null);
|
||||
const [editingIp, setEditingIp] = useState(null);
|
||||
const [editingValue, setEditingValue] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -97,6 +98,9 @@ function IPRangesManager() {
|
||||
// Допускаем как числовые, так и строковые (AS:NNN) community
|
||||
const isValidCommunity = (value) => /^(\d+|\d+:\d+)$/.test(String(value).trim());
|
||||
|
||||
const [wsOpen, setWsOpen] = useState(false);
|
||||
const [wsUrl, setWsUrl] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
// Acquire soft lock
|
||||
const owner = localStorage.getItem('uiOwner') || `ui-${Math.random().toString(36).slice(2,8)}`;
|
||||
@@ -108,6 +112,7 @@ function IPRangesManager() {
|
||||
acquire();
|
||||
const interval = setInterval(acquire, 60_000);
|
||||
fetchItems();
|
||||
(async () => { try { const r = await api.get('/ws/url'); setWsUrl(String(r.data?.url || '')); } catch {} })();
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
api.delete(`/locks/${resource}`).catch(() => {});
|
||||
@@ -138,7 +143,11 @@ function IPRangesManager() {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchItems();
|
||||
if (searchTimer.current) clearTimeout(searchTimer.current);
|
||||
searchTimer.current = setTimeout(() => {
|
||||
fetchItems();
|
||||
}, 350);
|
||||
return () => { if (searchTimer.current) clearTimeout(searchTimer.current); };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentPage, searchTerm, filterCommunity]);
|
||||
|
||||
@@ -228,7 +237,6 @@ function IPRangesManager() {
|
||||
const [diff, setDiff] = useState({ added: [], removed: [], changed: [] });
|
||||
const [confirmSaveOpen, setConfirmSaveOpen] = useState(false);
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
const [wsOpen, setWsOpen] = useState(false);
|
||||
const [clearCommunitiesOpen, setClearCommunitiesOpen] = useState(false);
|
||||
|
||||
// Аналитика
|
||||
@@ -790,6 +798,7 @@ function IPRangesManager() {
|
||||
placeholder="Поиск IP-диапазона..."
|
||||
value={searchTerm}
|
||||
onChange={e => { setSearchTerm(e.target.value); setCurrentPage(1); }}
|
||||
aria-label="Поиск IP-диапазона"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -846,8 +855,8 @@ function IPRangesManager() {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button className="btn btn-outline-primary btn-icon btn-sm me-1" onClick={() => handleEdit(item)}><IconEdit size={16} /></button>
|
||||
<button className="btn btn-outline-danger btn-icon btn-sm" onClick={() => confirmDelete(item)}><IconTrash size={16} /></button>
|
||||
<button className="btn btn-outline-primary btn-icon btn-sm me-1" aria-label="Редактировать" onClick={() => handleEdit(item)}><IconEdit size={16} /></button>
|
||||
<button className="btn btn-outline-danger btn-icon btn-sm" aria-label="Удалить" onClick={() => confirmDelete(item)}><IconTrash size={16} /></button>
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
@@ -966,7 +975,7 @@ function IPRangesManager() {
|
||||
/>
|
||||
<WsUpdateModal
|
||||
show={wsOpen}
|
||||
url={'ws://77.232.38.173:8080/ws/update_bgp?api_key=denozord2502'}
|
||||
url={wsUrl}
|
||||
onClose={() => setWsOpen(false)}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
|
||||
@@ -9,6 +9,7 @@ export default function SettingsModal({ open, onClose }) {
|
||||
const [success, setSuccess] = useState('')
|
||||
const [etag, setEtag] = useState('')
|
||||
const [dohServer, setDohServer] = useState('')
|
||||
const [wsUrl, setWsUrl] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
@@ -20,6 +21,7 @@ export default function SettingsModal({ open, onClose }) {
|
||||
const res = await api.get('/ui-settings')
|
||||
const data = res?.data || {}
|
||||
setDohServer(String(data?.dohServer || ''))
|
||||
setWsUrl(String(data?.wsUpdateUrl || ''))
|
||||
const e = res?.headers?.etag || res?.headers?.ETag || ''
|
||||
setEtag(e ? String(e) : '')
|
||||
// фокус на первом поле
|
||||
@@ -42,13 +44,22 @@ export default function SettingsModal({ open, onClose }) {
|
||||
} catch { return false }
|
||||
}
|
||||
|
||||
const validateWs = (value) => {
|
||||
if (!value) return true // допускаем пустое значение
|
||||
try {
|
||||
const u = new URL(String(value))
|
||||
return u.protocol === 'ws:' || u.protocol === 'wss:'
|
||||
} catch { return false }
|
||||
}
|
||||
|
||||
const onSave = async () => {
|
||||
setError('')
|
||||
setSuccess('')
|
||||
if (!validateDoh(dohServer)) { setError('Укажите корректный HTTPS URL для DoH'); return }
|
||||
if (!validateWs(wsUrl)) { setError('Укажите корректный WebSocket URL (ws:// или wss://)'); return }
|
||||
setSaving(true)
|
||||
try {
|
||||
const payload = { settings: { dohServer: String(dohServer || '').trim() }, etag }
|
||||
const payload = { settings: { dohServer: String(dohServer || '').trim(), wsUpdateUrl: String(wsUrl || '').trim() }, etag }
|
||||
const res = await api.post('/ui-settings', payload)
|
||||
const meta = res?.data || {}
|
||||
setSuccess('Настройки сохранены')
|
||||
@@ -91,6 +102,22 @@ export default function SettingsModal({ open, onClose }) {
|
||||
<button type="button" className="btn-close" onClick={() => setSuccess('')}></button>
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-3">
|
||||
<label className="form-label">WebSocket URL (BGP Live)</label>
|
||||
<input
|
||||
type="text"
|
||||
className={`form-control${wsUrl && !validateWs(wsUrl) ? ' is-invalid' : ''}`}
|
||||
placeholder="ws://host:port/ws/update_bgp?api_key=..."
|
||||
value={wsUrl}
|
||||
onChange={(e) => setWsUrl(e.target.value)}
|
||||
disabled={loading || saving}
|
||||
/>
|
||||
<div className="form-hint">URL для Live-обновления BGP (ws:// или wss://). Можно оставить пустым.</div>
|
||||
{wsUrl && !validateWs(wsUrl) && (
|
||||
<div className="invalid-feedback">Укажите корректный WebSocket URL</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="form-label">DoH сервер</label>
|
||||
<input
|
||||
|
||||
Reference in New Issue
Block a user