1 Commits

Author SHA1 Message Date
CN-JS-HuiBai
e19a21a3cc 完善serverless部署环境 2026-04-10 14:40:24 +08:00
7 changed files with 415 additions and 196 deletions

3
api/index.js Normal file
View File

@@ -0,0 +1,3 @@
const { app } = require('../server/index');
module.exports = app;

View File

@@ -138,7 +138,10 @@
let siteThemeQuery = null; // For media query cleanup let siteThemeQuery = null; // For media query cleanup
let siteThemeHandler = null; let siteThemeHandler = null;
let backgroundIntervals = []; // To track setIntervals let backgroundIntervals = []; // To track setIntervals
let realtimeIntervalId = null;
let lastMapDataHash = ''; // Cache for map rendering optimization 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 // Load sort state from localStorage or use default
let currentSort = { column: 'up', direction: 'desc' }; let currentSort = { column: 'up', direction: 'desc' };
@@ -544,12 +547,30 @@
loadSiteSettings(); loadSiteSettings();
// Track intervals for resource management // Track intervals for resource management
if (prefersPollingRealtime) {
startRealtimePolling();
} else {
initWebSocket(); initWebSocket();
backgroundIntervals.push(setInterval(fetchNetworkHistory, NETWORK_HISTORY_INTERVAL));
backgroundIntervals.push(setInterval(fetchLatency, REFRESH_INTERVAL)); backgroundIntervals.push(setInterval(fetchLatency, REFRESH_INTERVAL));
} }
backgroundIntervals.push(setInterval(fetchNetworkHistory, NETWORK_HISTORY_INTERVAL));
}
// ---- Real-time WebSocket ---- // ---- 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() { function initWebSocket() {
if (isWsConnecting) return; if (isWsConnecting) return;
isWsConnecting = true; isWsConnecting = true;
@@ -567,6 +588,7 @@
ws.onopen = () => { ws.onopen = () => {
isWsConnecting = false; isWsConnecting = false;
stopRealtimePolling();
console.log('WS connection established'); console.log('WS connection established');
}; };
@@ -587,6 +609,7 @@
ws.onclose = () => { ws.onclose = () => {
isWsConnecting = false; isWsConnecting = false;
startRealtimePolling();
console.log('WS connection closed. Reconnecting in 5s...'); console.log('WS connection closed. Reconnecting in 5s...');
setTimeout(initWebSocket, 5000); setTimeout(initWebSocket, 5000);
}; };
@@ -791,6 +814,19 @@
} }
// ---- Fetch Metrics ---- // ---- 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) { async function fetchMetrics(force = false) {
try { try {
const url = `/api/metrics/overview${force ? '?force=true' : ''}`; const url = `/api/metrics/overview${force ? '?force=true' : ''}`;

View File

@@ -4,8 +4,15 @@
*/ */
require('dotenv').config(); require('dotenv').config();
const db = require('./db'); 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 = { const SCHEMA = {
users: { users: {
@@ -210,8 +217,14 @@ async function ensureTable(tableName, tableSchema) {
} }
async function checkAndFixDatabase() { async function checkAndFixDatabase() {
const envPath = path.join(__dirname, '..', '.env'); const autoSchemaSync = process.env.DB_AUTO_SCHEMA_SYNC
if (!fs.existsSync(envPath)) return; ? 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 { try {
for (const [tableName, tableSchema] of Object.entries(SCHEMA)) { for (const [tableName, tableSchema] of Object.entries(SCHEMA)) {

View File

@@ -1,37 +1,70 @@
const mysql = require('mysql2/promise'); const mysql = require('mysql2/promise');
let pool; 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() { function getConnectionLimit() {
if (pool) { const parsed = parseInt(process.env.MYSQL_CONNECTION_LIMIT, 10);
pool.end().catch(e => console.error('Error closing pool:', e)); 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', 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', user: process.env.MYSQL_USER || 'root',
password: process.env.MYSQL_PASSWORD || '', password: process.env.MYSQL_PASSWORD || '',
database: process.env.MYSQL_DATABASE || 'display_wall', database: process.env.MYSQL_DATABASE || 'display_wall',
waitForConnections: true, waitForConnections: true,
connectionLimit: 10, connectionLimit: getConnectionLimit(),
queueLimit: 0 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() { async function checkHealth() {
try { try {
if (!pool) return { status: 'down', error: 'Database pool not initialized' }; await getPool().query('SELECT 1');
await pool.query('SELECT 1');
return { status: 'up' }; return { status: 'up' };
} catch (err) { } catch (err) {
return { status: 'down', error: err.message }; return { status: 'down', error: err.message };
} }
} }
initPool();
module.exports = { module.exports = {
query: (...args) => pool.query(...args), query: (...args) => getPool().query(...args),
getPool,
initPool, initPool,
checkHealth checkHealth
}; };

View File

@@ -15,6 +15,14 @@ const net = require('net');
const app = express(); const app = express();
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000;
const HOST = process.env.HOST || '0.0.0.0'; 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(cors());
app.use(express.json()); app.use(express.json());
@@ -22,6 +30,8 @@ const fs = require('fs');
const crypto = require('crypto'); const crypto = require('crypto');
let isDbInitialized = false; let isDbInitialized = false;
let bootstrapPromise = null;
let backgroundServicesStarted = false;
const sessions = new Map(); // Fallback session store when Valkey is unavailable const sessions = new Map(); // Fallback session store when Valkey is unavailable
const requestBuckets = new Map(); const requestBuckets = new Map();
const SESSION_TTL_SECONDS = parseInt(process.env.SESSION_TTL_SECONDS, 10) || 86400; 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() { async function getSiteSettingsRow() {
const [rows] = await db.query('SELECT * FROM site_settings WHERE id = 1'); const [rows] = await db.query('SELECT * FROM site_settings WHERE id = 1');
return rows.length > 0 ? rows[0] : {}; return rows.length > 0 ? rows[0] : {};
@@ -300,8 +325,7 @@ function getCookie(req, name) {
async function checkDb() { async function checkDb() {
try { try {
const fs = require('fs'); if (!hasDatabaseConfig()) {
if (!fs.existsSync(path.join(__dirname, '..', '.env'))) {
isDbInitialized = false; isDbInitialized = false;
return; return;
} }
@@ -314,6 +338,35 @@ async function checkDb() {
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 --- // --- Health API ---
app.get('/health', async (req, res) => { app.get('/health', async (req, res) => {
try { try {
@@ -741,7 +794,8 @@ const serveIndex = async (req, res) => {
// Inject settings // Inject settings
const settingsJson = escapeJsonForInlineScript(getPublicSiteSettings(settings)); const settingsJson = escapeJsonForInlineScript(getPublicSiteSettings(settings));
const injection = `<script>window.SITE_SETTINGS = ${settingsJson};</script>`; const runtimeJson = escapeJsonForInlineScript(getRuntimeConfig());
const injection = `<script>window.SITE_SETTINGS = ${settingsJson}; window.APP_RUNTIME = ${runtimeJson};</script>`;
// Replace <head> with <head> + injection // Replace <head> with <head> + injection
html = html.replace('<head>', '<head>' + injection); html = html.replace('<head>', '<head>' + injection);
@@ -1105,6 +1159,37 @@ async function getOverview(force = false) {
return overview; 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 // Get all aggregated metrics from all Prometheus sources
app.get('/api/metrics/overview', async (req, res) => { app.get('/api/metrics/overview', async (req, res) => {
try { try {
@@ -1234,6 +1319,24 @@ app.get('/api/metrics/server-history', requireServerDetailsAccess, async (req, r
} catch (err) { } catch (err) {
res.status(500).json({ error: err.message }); 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 // SPA fallback
app.get('*', (req, res, next) => { app.get('*', (req, res, next) => {
@@ -1293,33 +1396,7 @@ app.put('/api/latency-routes/:id', requireAuth, async (req, res) => {
// ==================== Metrics Latency ==================== // ==================== Metrics Latency ====================
app.get('/api/metrics/latency', async (req, res) => { app.get('/api/metrics/latency', async (req, res) => {
try { 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
};
}));
const results = await getLatencyResults(); const results = await getLatencyResults();
res.json({ routes: results }); res.json({ routes: results });
} catch (err) { } catch (err) {
@@ -1329,11 +1406,12 @@ app.get('/api/metrics/latency', async (req, res) => {
}); });
// ==================== WebSocket Server ==================== // ==================== WebSocket Server ====================
const server = http.createServer(app); const server = IS_SERVERLESS ? null : http.createServer(app);
const wss = IS_SERVERLESS ? null : new WebSocket.Server({ server }); const wss = IS_SERVERLESS ? null : new WebSocket.Server({ server });
let isBroadcastRunning = false; let isBroadcastRunning = false;
function broadcast(data) {
if (!wss) return; if (!wss) return;
const message = JSON.stringify(data); const message = JSON.stringify(data);
wss.clients.forEach(client => { wss.clients.forEach(client => {
@@ -1344,30 +1422,12 @@ function broadcast(data) {
} }
// Broadcast loop // Broadcast loop
async function broadcastMetrics() {
if (IS_SERVERLESS || !wss) return; if (IS_SERVERLESS || !wss) return;
if (isBroadcastRunning) return; if (isBroadcastRunning) return;
isBroadcastRunning = true; isBroadcastRunning = true;
try { try {
const overview = await getOverview(); 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
};
const latencyResults = await getLatencyResults(); const latencyResults = await getLatencyResults();
broadcast({ broadcast({
@@ -1387,11 +1447,7 @@ async function broadcastMetrics() {
// Start server and services // Start server and services
async function start() { async function start() {
try { try {
console.log('🔧 Initializing services...'); console.log('🔧 Initializing services...');
// Ensure DB is ready before starting anything else
await checkAndFixDatabase();
// Start services
await bootstrapServices({ enableBackgroundTasks: true }); await bootstrapServices({ enableBackgroundTasks: true });
const REFRESH_INT = parseInt(process.env.REFRESH_INTERVAL) || 5000; const REFRESH_INT = parseInt(process.env.REFRESH_INTERVAL) || 5000;
@@ -1407,4 +1463,20 @@ async function start() {
process.exit(1); process.exit(1);
} }
} }
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

View File

@@ -4,32 +4,17 @@ const db = require('./db');
const POLL_INTERVAL = 10000; // 10 seconds const POLL_INTERVAL = 10000; // 10 seconds
async function pollLatency() { async function resolveBlackboxLatency(route) {
try {
const [routes] = await db.query(`
SELECT r.*, s.url
FROM latency_routes r
JOIN prometheus_sources s ON r.source_id = s.id
WHERE s.type = 'blackbox'
`);
if (routes.length === 0) return;
// Poll each route
await Promise.allSettled(routes.map(async (route) => {
try {
// Blackbox exporter probe URL // Blackbox exporter probe URL
// We assume ICMP module for now. If target is a URL, maybe use http_2xx // We assume ICMP module for now. If target is a URL, maybe use http_2xx
let module = 'icmp'; let module = 'icmp';
let target = route.latency_target; const target = route.latency_target;
if (target.startsWith('http://') || target.startsWith('https://')) { if (target.startsWith('http://') || target.startsWith('https://')) {
module = 'http_2xx'; module = 'http_2xx';
} }
const probeUrl = `${route.url.replace(/\/+$/, '')}/probe?module=${module}&target=${encodeURIComponent(target)}`; const probeUrl = `${route.url.replace(/\/+$/, '')}/probe?module=${module}&target=${encodeURIComponent(target)}`;
const startTime = Date.now();
const response = await axios.get(probeUrl, { const response = await axios.get(probeUrl, {
timeout: 5000, timeout: 5000,
responseType: 'text', responseType: 'text',
@@ -101,16 +86,48 @@ async function pollLatency() {
// 3. Final decision // 3. Final decision
// If it's a success, use found latency. If success=0 or missing, handle carefully. // If it's a success, use found latency. If success=0 or missing, handle carefully.
let latency;
if (isProbeSuccess && foundLatency !== null) { if (isProbeSuccess && foundLatency !== null) {
latency = foundLatency; return foundLatency;
} else {
// If probe failed or metrics missing, do not show 0, show null (Measurement in progress/Error)
latency = null;
} }
// Save to Valkey // 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); 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(`
SELECT r.*, s.url
FROM latency_routes r
JOIN prometheus_sources s ON r.source_id = s.id
WHERE s.type = 'blackbox'
`);
if (routes.length === 0) return;
// Poll each route
await Promise.allSettled(routes.map(async (route) => {
try {
await resolveLatencyForRoute({ ...route, source_type: 'blackbox' });
} catch (err) { } catch (err) {
await cache.set(`latency:route:${route.id}`, null, 60); await cache.set(`latency:route:${route.id}`, null, 60);
} }
@@ -130,5 +147,7 @@ function start() {
} }
module.exports = { module.exports = {
pollLatency,
resolveLatencyForRoute,
start start
}; };

43
vercel.json Normal file
View File

@@ -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"
}
]
}