diff --git a/backend/server.js b/backend/server.js index 5ca970f..891ce7d 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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) => { diff --git a/frontend/src/ASNsNewManager.jsx b/frontend/src/ASNsNewManager.jsx index 28d8651..eba577c 100644 --- a/frontend/src/ASNsNewManager.jsx +++ b/frontend/src/ASNsNewManager.jsx @@ -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" /> @@ -578,8 +590,8 @@ function ASNsNewManager() { ) : ( <> - - + + )} @@ -693,7 +705,7 @@ function ASNsNewManager() { /> setWsOpen(false)} /> - - - - - - - + + + + + + + + + ); } diff --git a/frontend/src/DomainsNewManager.jsx b/frontend/src/DomainsNewManager.jsx index 6ec40cd..4b971c9 100644 --- a/frontend/src/DomainsNewManager.jsx +++ b/frontend/src/DomainsNewManager.jsx @@ -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="Поиск домена" /> @@ -587,8 +596,8 @@ function DomainsNewManager() { ) : ( <> - - + + )} @@ -703,7 +712,7 @@ function DomainsNewManager() { /> setWsOpen(false)} /> /^(\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-диапазона" /> @@ -846,8 +855,8 @@ function IPRangesManager() { ) : ( <> - - + + )} @@ -966,7 +975,7 @@ function IPRangesManager() { /> setWsOpen(false)} /> { 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 }) { )} +
+ + setWsUrl(e.target.value)} + disabled={loading || saving} + /> +
URL для Live-обновления BGP (ws:// или wss://). Можно оставить пустым.
+ {wsUrl && !validateWs(wsUrl) && ( +
Укажите корректный WebSocket URL
+ )} +
+