From e19a21a3cc43ea74da3f4f0ae46ae359b108e9d8 Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Fri, 10 Apr 2026 14:40:24 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84serverless=E9=83=A8=E7=BD=B2?= =?UTF-8?q?=E7=8E=AF=E5=A2=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/index.js | 3 + public/js/app.js | 40 ++++++- server/db-schema-check.js | 21 +++- server/db.js | 57 +++++++-- server/index.js | 242 +++++++++++++++++++++++++------------- server/latency-service.js | 205 +++++++++++++++++--------------- vercel.json | 43 +++++++ 7 files changed, 415 insertions(+), 196 deletions(-) create mode 100644 api/index.js create mode 100644 vercel.json diff --git a/api/index.js b/api/index.js new file mode 100644 index 0000000..3486eb7 --- /dev/null +++ b/api/index.js @@ -0,0 +1,3 @@ +const { app } = require('../server/index'); + +module.exports = app; diff --git a/public/js/app.js b/public/js/app.js index 161b2a9..84be3a6 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -138,7 +138,10 @@ let siteThemeQuery = null; // For media query cleanup let siteThemeHandler = null; let backgroundIntervals = []; // To track setIntervals + let realtimeIntervalId = null; let lastMapDataHash = ''; // Cache for map rendering optimization + const appRuntime = window.APP_RUNTIME || {}; + const prefersPollingRealtime = appRuntime.realtimeMode === 'polling'; // Load sort state from localStorage or use default let currentSort = { column: 'up', direction: 'desc' }; @@ -544,12 +547,30 @@ loadSiteSettings(); // Track intervals for resource management - initWebSocket(); + if (prefersPollingRealtime) { + startRealtimePolling(); + } else { + initWebSocket(); + backgroundIntervals.push(setInterval(fetchLatency, REFRESH_INTERVAL)); + } backgroundIntervals.push(setInterval(fetchNetworkHistory, NETWORK_HISTORY_INTERVAL)); - backgroundIntervals.push(setInterval(fetchLatency, REFRESH_INTERVAL)); } // ---- Real-time WebSocket ---- + function stopRealtimePolling() { + if (realtimeIntervalId) { + clearInterval(realtimeIntervalId); + realtimeIntervalId = null; + } + } + + function startRealtimePolling() { + if (realtimeIntervalId) return; + fetchRealtimeOverview(); + realtimeIntervalId = setInterval(fetchRealtimeOverview, REFRESH_INTERVAL); + backgroundIntervals.push(realtimeIntervalId); + } + function initWebSocket() { if (isWsConnecting) return; isWsConnecting = true; @@ -567,6 +588,7 @@ ws.onopen = () => { isWsConnecting = false; + stopRealtimePolling(); console.log('WS connection established'); }; @@ -587,6 +609,7 @@ ws.onclose = () => { isWsConnecting = false; + startRealtimePolling(); console.log('WS connection closed. Reconnecting in 5s...'); setTimeout(initWebSocket, 5000); }; @@ -791,6 +814,19 @@ } // ---- Fetch Metrics ---- + async function fetchRealtimeOverview(force = false) { + try { + const url = `/api/realtime/overview${force ? '?force=true' : ''}`; + const response = await fetch(url); + const data = await response.json(); + allServersData = data.servers || []; + currentLatencies = data.latencies || []; + updateDashboard(data); + } catch (err) { + console.error('Error fetching realtime overview:', err); + } + } + async function fetchMetrics(force = false) { try { const url = `/api/metrics/overview${force ? '?force=true' : ''}`; diff --git a/server/db-schema-check.js b/server/db-schema-check.js index 30a7796..f84db01 100644 --- a/server/db-schema-check.js +++ b/server/db-schema-check.js @@ -4,8 +4,15 @@ */ require('dotenv').config(); const db = require('./db'); -const path = require('path'); -const fs = require('fs'); + +const IS_SERVERLESS = [ + process.env.SERVERLESS, + process.env.VERCEL, + process.env.AWS_LAMBDA_FUNCTION_NAME, + process.env.NETLIFY, + process.env.FUNCTION_TARGET, + process.env.K_SERVICE +].some(Boolean); const SCHEMA = { users: { @@ -210,8 +217,14 @@ async function ensureTable(tableName, tableSchema) { } async function checkAndFixDatabase() { - const envPath = path.join(__dirname, '..', '.env'); - if (!fs.existsSync(envPath)) return; + const autoSchemaSync = process.env.DB_AUTO_SCHEMA_SYNC + ? process.env.DB_AUTO_SCHEMA_SYNC === 'true' + : !IS_SERVERLESS; + const hasDbConfig = Boolean(process.env.MYSQL_HOST && process.env.MYSQL_USER && process.env.MYSQL_DATABASE); + + if (!hasDbConfig || !autoSchemaSync) { + return; + } try { for (const [tableName, tableSchema] of Object.entries(SCHEMA)) { diff --git a/server/db.js b/server/db.js index 4f9d48c..e67f91a 100644 --- a/server/db.js +++ b/server/db.js @@ -1,37 +1,70 @@ const mysql = require('mysql2/promise'); let pool; +const IS_SERVERLESS = [ + process.env.SERVERLESS, + process.env.VERCEL, + process.env.AWS_LAMBDA_FUNCTION_NAME, + process.env.NETLIFY, + process.env.FUNCTION_TARGET, + process.env.K_SERVICE +].some(Boolean); -function initPool() { - if (pool) { - pool.end().catch(e => console.error('Error closing pool:', e)); +function getConnectionLimit() { + const parsed = parseInt(process.env.MYSQL_CONNECTION_LIMIT, 10); + if (!Number.isNaN(parsed) && parsed > 0) { + return parsed; } - pool = mysql.createPool({ + return IS_SERVERLESS ? 2 : 10; +} + +function createPool() { + return mysql.createPool({ host: process.env.MYSQL_HOST || 'localhost', - port: parseInt(process.env.MYSQL_PORT) || 3306, + port: parseInt(process.env.MYSQL_PORT, 10) || 3306, user: process.env.MYSQL_USER || 'root', password: process.env.MYSQL_PASSWORD || '', database: process.env.MYSQL_DATABASE || 'display_wall', waitForConnections: true, - connectionLimit: 10, - queueLimit: 0 + connectionLimit: getConnectionLimit(), + queueLimit: 0, + enableKeepAlive: true, + keepAliveInitialDelay: 0 }); } +function getPool() { + if (!pool) { + pool = createPool(); + } + return pool; +} + +function initPool({ force = false } = {}) { + if (pool && !force) { + return pool; + } + + if (pool) { + pool.end().catch(e => console.error('Error closing pool:', e)); + } + + pool = createPool(); + return pool; +} + async function checkHealth() { try { - if (!pool) return { status: 'down', error: 'Database pool not initialized' }; - await pool.query('SELECT 1'); + await getPool().query('SELECT 1'); return { status: 'up' }; } catch (err) { return { status: 'down', error: err.message }; } } -initPool(); - module.exports = { - query: (...args) => pool.query(...args), + query: (...args) => getPool().query(...args), + getPool, initPool, checkHealth }; diff --git a/server/index.js b/server/index.js index 9bc374e..2387d7d 100644 --- a/server/index.js +++ b/server/index.js @@ -15,13 +15,23 @@ const net = require('net'); const app = express(); const PORT = process.env.PORT || 3000; const HOST = process.env.HOST || '0.0.0.0'; - +const IS_SERVERLESS = [ + process.env.SERVERLESS, + process.env.VERCEL, + process.env.AWS_LAMBDA_FUNCTION_NAME, + process.env.NETLIFY, + process.env.FUNCTION_TARGET, + process.env.K_SERVICE +].some(Boolean); + app.use(cors()); app.use(express.json()); const fs = require('fs'); const crypto = require('crypto'); let isDbInitialized = false; +let bootstrapPromise = null; +let backgroundServicesStarted = false; const sessions = new Map(); // Fallback session store when Valkey is unavailable const requestBuckets = new Map(); const SESSION_TTL_SECONDS = parseInt(process.env.SESSION_TTL_SECONDS, 10) || 86400; @@ -148,6 +158,21 @@ function getPublicSiteSettings(settings = {}) { }; } +function getRuntimeConfig() { + return { + serverless: IS_SERVERLESS, + realtimeMode: IS_SERVERLESS ? 'polling' : 'websocket' + }; +} + +function hasDatabaseConfig() { + return Boolean( + process.env.MYSQL_HOST && + process.env.MYSQL_USER && + process.env.MYSQL_DATABASE + ); +} + async function getSiteSettingsRow() { const [rows] = await db.query('SELECT * FROM site_settings WHERE id = 1'); return rows.length > 0 ? rows[0] : {}; @@ -298,21 +323,49 @@ function getCookie(req, name) { return matches ? decodeURIComponent(matches[1]) : undefined; } -async function checkDb() { - try { - const fs = require('fs'); - if (!fs.existsSync(path.join(__dirname, '..', '.env'))) { - isDbInitialized = false; - return; - } - const [rows] = await db.query("SHOW TABLES LIKE 'prometheus_sources'"); +async function checkDb() { + try { + if (!hasDatabaseConfig()) { + isDbInitialized = false; + return; + } + const [rows] = await db.query("SHOW TABLES LIKE 'prometheus_sources'"); isDbInitialized = rows.length > 0; } catch (err) { isDbInitialized = false; } } -checkDb(); +checkDb(); + +async function bootstrapServices({ enableBackgroundTasks = !IS_SERVERLESS } = {}) { + if (!bootstrapPromise) { + bootstrapPromise = (async () => { + await checkAndFixDatabase(); + await checkDb(); + })().catch((err) => { + bootstrapPromise = null; + throw err; + }); + } + + await bootstrapPromise; + + if (enableBackgroundTasks && !backgroundServicesStarted) { + latencyService.start(); + backgroundServicesStarted = true; + } +} + +app.use(async (req, res, next) => { + try { + await bootstrapServices({ enableBackgroundTasks: !IS_SERVERLESS }); + next(); + } catch (err) { + console.error('Service bootstrap failed:', err); + res.status(500).json({ error: 'Service initialization failed' }); + } +}); // --- Health API --- app.get('/health', async (req, res) => { @@ -741,7 +794,8 @@ const serveIndex = async (req, res) => { // Inject settings const settingsJson = escapeJsonForInlineScript(getPublicSiteSettings(settings)); - const injection = ``; + const runtimeJson = escapeJsonForInlineScript(getRuntimeConfig()); + const injection = ``; // Replace with + injection html = html.replace('', '' + injection); @@ -977,7 +1031,7 @@ app.post('/api/settings', requireAuth, async (req, res) => { // ==================== Metrics Aggregation ==================== // Reusable function to get overview metrics -async function getOverview(force = false) { +async function getOverview(force = false) { const [sources] = await db.query('SELECT * FROM prometheus_sources WHERE is_server_source = 1 AND type != "blackbox"'); if (sources.length === 0) { return { @@ -1101,12 +1155,43 @@ async function getOverview(force = false) { return safeServer; })); - overview.servers = geoServers; - return overview; -} - -// Get all aggregated metrics from all Prometheus sources -app.get('/api/metrics/overview', async (req, res) => { + overview.servers = geoServers; + return overview; +} + +async function getLatencyResults() { + const [routes] = await db.query(` + SELECT r.*, s.url, s.type as source_type + FROM latency_routes r + JOIN prometheus_sources s ON r.source_id = s.id + `); + + if (routes.length === 0) { + return []; + } + + return Promise.all(routes.map(async (route) => { + let latency = await cache.get(`latency:route:${route.id}`); + + if (latency === null) { + if (route.source_type === 'prometheus') { + latency = await prometheusService.getLatency(route.url, route.latency_target); + } else if (route.source_type === 'blackbox') { + latency = await latencyService.resolveLatencyForRoute(route); + } + } + + return { + id: route.id, + source: route.latency_source, + dest: route.latency_dest, + latency + }; + })); +} + +// Get all aggregated metrics from all Prometheus sources +app.get('/api/metrics/overview', async (req, res) => { try { const force = req.query.force === 'true'; const overview = await getOverview(force); @@ -1234,6 +1319,24 @@ app.get('/api/metrics/server-history', requireServerDetailsAccess, async (req, r res.status(500).json({ error: err.message }); } }); + +app.get('/api/realtime/overview', async (req, res) => { + try { + const force = req.query.force === 'true'; + const [overview, latencies] = await Promise.all([ + getOverview(force), + getLatencyResults() + ]); + + res.json({ + ...overview, + latencies + }); + } catch (err) { + console.error('Error fetching realtime overview:', err); + res.status(500).json({ error: 'Failed to fetch realtime overview' }); + } +}); // SPA fallback app.get('*', (req, res, next) => { if (req.path.startsWith('/api/') || req.path.includes('.')) return next(); @@ -1291,50 +1394,25 @@ app.put('/api/latency-routes/:id', requireAuth, async (req, res) => { // ==================== Metrics Latency ==================== -app.get('/api/metrics/latency', async (req, res) => { - try { - const [routes] = await db.query(` - SELECT r.*, s.url, s.type as source_type - FROM latency_routes r - JOIN prometheus_sources s ON r.source_id = s.id - `); - - if (routes.length === 0) { - return res.json({ routes: [] }); - } - - const results = await Promise.all(routes.map(async (route) => { - // Try to get from Valkey first (filled by background latencyService) - let latency = await cache.get(`latency:route:${route.id}`); - - // Fallback if not in cache (only for prometheus sources, blackbox sources rely on the background service) - if (latency === null && route.source_type === 'prometheus') { - latency = await prometheusService.getLatency(route.url, route.latency_target); - } - - return { - id: route.id, - source: route.latency_source, - dest: route.latency_dest, - latency: latency - }; - })); - - res.json({ routes: results }); - } catch (err) { - console.error('Error fetching latencies:', err); - res.status(500).json({ error: 'Failed to fetch latency' }); - } +app.get('/api/metrics/latency', async (req, res) => { + try { + const results = await getLatencyResults(); + res.json({ routes: results }); + } catch (err) { + console.error('Error fetching latencies:', err); + res.status(500).json({ error: 'Failed to fetch latency' }); + } }); // ==================== WebSocket Server ==================== -const server = http.createServer(app); -const wss = new WebSocket.Server({ server }); +const server = IS_SERVERLESS ? null : http.createServer(app); +const wss = IS_SERVERLESS ? null : new WebSocket.Server({ server }); let isBroadcastRunning = false; -function broadcast(data) { - const message = JSON.stringify(data); +function broadcast(data) { + if (!wss) return; + const message = JSON.stringify(data); wss.clients.forEach(client => { if (client.readyState === WebSocket.OPEN) { client.send(message); @@ -1344,33 +1422,15 @@ function broadcast(data) { // Broadcast loop async function broadcastMetrics() { + if (IS_SERVERLESS || !wss) return; if (isBroadcastRunning) return; isBroadcastRunning = true; try { const overview = await getOverview(); - - // Also include latencies in the broadcast to make map lines real-time - const [routes] = await db.query(` - SELECT r.*, s.url, s.type as source_type - FROM latency_routes r - JOIN prometheus_sources s ON r.source_id = s.id - `); - - const latencyResults = await Promise.all(routes.map(async (route) => { - let latency = await cache.get(`latency:route:${route.id}`); - if (latency === null && route.source_type === 'prometheus') { - latency = await prometheusService.getLatency(route.url, route.latency_target); - } - return { - id: route.id, - source: route.latency_source, - dest: route.latency_dest, - latency: latency - }; - })); - - broadcast({ - type: 'overview', + const latencyResults = await getLatencyResults(); + + broadcast({ + type: 'overview', data: { ...overview, latencies: latencyResults @@ -1387,11 +1447,7 @@ async function broadcastMetrics() { async function start() { try { console.log('🔧 Initializing services...'); - // Ensure DB is ready before starting anything else - await checkAndFixDatabase(); - - // Start services - latencyService.start(); + await bootstrapServices({ enableBackgroundTasks: true }); const REFRESH_INT = parseInt(process.env.REFRESH_INTERVAL) || 5000; setInterval(broadcastMetrics, REFRESH_INT); @@ -1407,4 +1463,20 @@ async function start() { } } -start(); +if (require.main === module) { + if (IS_SERVERLESS) { + bootstrapServices({ enableBackgroundTasks: false }).catch((err) => { + console.error('Service bootstrap failed:', err.message); + process.exit(1); + }); + } else { + start(); + } +} + +module.exports = { + app, + start, + bootstrapServices, + isServerless: IS_SERVERLESS +}; diff --git a/server/latency-service.js b/server/latency-service.js index 8a68e1a..98e7148 100644 --- a/server/latency-service.js +++ b/server/latency-service.js @@ -4,6 +4,115 @@ const db = require('./db'); const POLL_INTERVAL = 10000; // 10 seconds +async function resolveBlackboxLatency(route) { + // Blackbox exporter probe URL + // We assume ICMP module for now. If target is a URL, maybe use http_2xx + let module = 'icmp'; + const target = route.latency_target; + + if (target.startsWith('http://') || target.startsWith('https://')) { + module = 'http_2xx'; + } + + const probeUrl = `${route.url.replace(/\/+$/, '')}/probe?module=${module}&target=${encodeURIComponent(target)}`; + const response = await axios.get(probeUrl, { + timeout: 5000, + responseType: 'text', + validateStatus: false + }); + + if (typeof response.data !== 'string') { + throw new Error('Response data is not a string'); + } + + const lines = response.data.split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('#')); + + // 1. Check if the probe was successful + let isProbeSuccess = false; + for (const line of lines) { + if (/^probe_success(\{.*\})?\s+1/.test(line)) { + isProbeSuccess = true; + break; + } + } + + // 2. Extract latency from priority metrics + const targetMetrics = [ + 'probe_icmp_duration_seconds', + 'probe_http_duration_seconds', + 'probe_duration_seconds' + ]; + + let foundLatency = null; + for (const metricName of targetMetrics) { + let bestLine = null; + + // First pass: look for phase="rtt" which is the most accurate "ping" + for (const line of lines) { + if (line.startsWith(metricName) && line.includes('phase="rtt"')) { + bestLine = line; + break; + } + } + + // Second pass: if no rtt phase, look for a line without phases (legacy format) or just the first line + if (!bestLine) { + for (const line of lines) { + if (line.startsWith(metricName)) { + // Prefer lines without {} if possible, otherwise take the first one + if (!line.includes('{')) { + bestLine = line; + break; + } + if (!bestLine) bestLine = line; + } + } + } + + if (bestLine) { + // Regex to capture the number, including scientific notation + const regex = new RegExp(`^${metricName}(?:\\{[^}]*\\})?\\s+([\\d.eE+-]+)`); + const match = bestLine.match(regex); + + if (match) { + const val = parseFloat(match[1]); + if (!isNaN(val)) { + foundLatency = val * 1000; // convert to ms + break; + } + } + } + } + + // 3. Final decision + // If it's a success, use found latency. If success=0 or missing, handle carefully. + if (isProbeSuccess && foundLatency !== null) { + return foundLatency; + } + + // If probe failed or metrics missing, do not show 0, show null (Measurement in progress/Error) + return null; +} + +async function resolveLatencyForRoute(route) { + try { + if (route.source_type === 'blackbox' || route.type === 'blackbox') { + const latency = await resolveBlackboxLatency(route); + if (route.id !== undefined) { + await cache.set(`latency:route:${route.id}`, latency, 60); + } + return latency; + } + + return null; + } catch (err) { + if (route.id !== undefined) { + await cache.set(`latency:route:${route.id}`, null, 60); + } + return null; + } +} + async function pollLatency() { try { const [routes] = await db.query(` @@ -18,99 +127,7 @@ async function pollLatency() { // Poll each route await Promise.allSettled(routes.map(async (route) => { try { - // Blackbox exporter probe URL - // We assume ICMP module for now. If target is a URL, maybe use http_2xx - let module = 'icmp'; - let target = route.latency_target; - - if (target.startsWith('http://') || target.startsWith('https://')) { - module = 'http_2xx'; - } - - const probeUrl = `${route.url.replace(/\/+$/, '')}/probe?module=${module}&target=${encodeURIComponent(target)}`; - - const startTime = Date.now(); - const response = await axios.get(probeUrl, { - timeout: 5000, - responseType: 'text', - validateStatus: false - }); - - if (typeof response.data !== 'string') { - throw new Error('Response data is not a string'); - } - - const lines = response.data.split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('#')); - - // 1. Check if the probe was successful - let isProbeSuccess = false; - for (const line of lines) { - if (/^probe_success(\{.*\})?\s+1/.test(line)) { - isProbeSuccess = true; - break; - } - } - - // 2. Extract latency from priority metrics - const targetMetrics = [ - 'probe_icmp_duration_seconds', - 'probe_http_duration_seconds', - 'probe_duration_seconds' - ]; - - let foundLatency = null; - for (const metricName of targetMetrics) { - let bestLine = null; - - // First pass: look for phase="rtt" which is the most accurate "ping" - for (const line of lines) { - if (line.startsWith(metricName) && line.includes('phase="rtt"')) { - bestLine = line; - break; - } - } - - // Second pass: if no rtt phase, look for a line without phases (legacy format) or just the first line - if (!bestLine) { - for (const line of lines) { - if (line.startsWith(metricName)) { - // Prefer lines without {} if possible, otherwise take the first one - if (!line.includes('{')) { - bestLine = line; - break; - } - if (!bestLine) bestLine = line; - } - } - } - - if (bestLine) { - // Regex to capture the number, including scientific notation - const regex = new RegExp(`^${metricName}(?:\\{[^}]*\\})?\\s+([\\d.eE+-]+)`); - const match = bestLine.match(regex); - - if (match) { - const val = parseFloat(match[1]); - if (!isNaN(val)) { - foundLatency = val * 1000; // convert to ms - break; - } - } - } - } - - // 3. Final decision - // If it's a success, use found latency. If success=0 or missing, handle carefully. - let latency; - if (isProbeSuccess && foundLatency !== null) { - latency = foundLatency; - } else { - // If probe failed or metrics missing, do not show 0, show null (Measurement in progress/Error) - latency = null; - } - - // Save to Valkey - await cache.set(`latency:route:${route.id}`, latency, 60); + await resolveLatencyForRoute({ ...route, source_type: 'blackbox' }); } catch (err) { await cache.set(`latency:route:${route.id}`, null, 60); } @@ -130,5 +147,7 @@ function start() { } module.exports = { + pollLatency, + resolveLatencyForRoute, start }; diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..38d3210 --- /dev/null +++ b/vercel.json @@ -0,0 +1,43 @@ +{ + "version": 2, + "functions": { + "api/index.js": { + "runtime": "@vercel/node", + "includeFiles": "public/**" + } + }, + "routes": [ + { + "src": "/api/(.*)", + "dest": "/api/index.js" + }, + { + "src": "/health", + "dest": "/api/index.js" + }, + { + "src": "/init.html", + "dest": "/api/index.js" + }, + { + "src": "/css/(.*)", + "dest": "/public/css/$1" + }, + { + "src": "/js/(.*)", + "dest": "/public/js/$1" + }, + { + "src": "/vendor/(.*)", + "dest": "/public/vendor/$1" + }, + { + "src": "/(.*\\.(?:ico|png|jpg|jpeg|svg|webp|json|txt|xml))", + "dest": "/public/$1" + }, + { + "src": "/(.*)", + "dest": "/api/index.js" + } + ] +}