feat: Обновить серверную часть для улучшения обработки запросов и добавления новых метрик; интегрировать поддержку WebSocket URL в менеджеры ASNs, Domains и IPRanges, а также обновить интерфейс настроек для ввода WebSocket URL
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m58s

This commit is contained in:
2025-08-27 14:46:28 +07:00
parent ce62dad844
commit 49d08dbd14
6 changed files with 152 additions and 30 deletions
+61 -7
View File
@@ -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) => {