Compare commits
1 Commits
main
...
serverless
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e19a21a3cc |
3
api/index.js
Normal file
3
api/index.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
const { app } = require('../server/index');
|
||||||
|
|
||||||
|
module.exports = app;
|
||||||
@@ -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
|
||||||
initWebSocket();
|
if (prefersPollingRealtime) {
|
||||||
|
startRealtimePolling();
|
||||||
|
} else {
|
||||||
|
initWebSocket();
|
||||||
|
backgroundIntervals.push(setInterval(fetchLatency, REFRESH_INTERVAL));
|
||||||
|
}
|
||||||
backgroundIntervals.push(setInterval(fetchNetworkHistory, NETWORK_HISTORY_INTERVAL));
|
backgroundIntervals.push(setInterval(fetchNetworkHistory, NETWORK_HISTORY_INTERVAL));
|
||||||
backgroundIntervals.push(setInterval(fetchLatency, REFRESH_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' : ''}`;
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
57
server/db.js
57
server/db.js
@@ -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
|
||||||
};
|
};
|
||||||
|
|||||||
242
server/index.js
242
server/index.js
@@ -15,13 +15,23 @@ 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());
|
||||||
const fs = require('fs');
|
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] : {};
|
||||||
@@ -298,21 +323,49 @@ function getCookie(req, name) {
|
|||||||
return matches ? decodeURIComponent(matches[1]) : undefined;
|
return matches ? decodeURIComponent(matches[1]) : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
}
|
||||||
}
|
const [rows] = await db.query("SHOW TABLES LIKE 'prometheus_sources'");
|
||||||
const [rows] = await db.query("SHOW TABLES LIKE 'prometheus_sources'");
|
|
||||||
isDbInitialized = rows.length > 0;
|
isDbInitialized = rows.length > 0;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
isDbInitialized = false;
|
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 ---
|
// --- Health API ---
|
||||||
app.get('/health', async (req, res) => {
|
app.get('/health', async (req, res) => {
|
||||||
@@ -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);
|
||||||
@@ -977,7 +1031,7 @@ app.post('/api/settings', requireAuth, async (req, res) => {
|
|||||||
// ==================== Metrics Aggregation ====================
|
// ==================== Metrics Aggregation ====================
|
||||||
|
|
||||||
// Reusable function to get overview metrics
|
// 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"');
|
const [sources] = await db.query('SELECT * FROM prometheus_sources WHERE is_server_source = 1 AND type != "blackbox"');
|
||||||
if (sources.length === 0) {
|
if (sources.length === 0) {
|
||||||
return {
|
return {
|
||||||
@@ -1101,12 +1155,43 @@ async function getOverview(force = false) {
|
|||||||
return safeServer;
|
return safeServer;
|
||||||
}));
|
}));
|
||||||
|
|
||||||
overview.servers = geoServers;
|
overview.servers = geoServers;
|
||||||
return overview;
|
return overview;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all aggregated metrics from all Prometheus sources
|
async function getLatencyResults() {
|
||||||
app.get('/api/metrics/overview', async (req, res) => {
|
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 {
|
try {
|
||||||
const force = req.query.force === 'true';
|
const force = req.query.force === 'true';
|
||||||
const overview = await getOverview(force);
|
const overview = await getOverview(force);
|
||||||
@@ -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) => {
|
||||||
@@ -1291,50 +1394,25 @@ 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(`
|
const results = await getLatencyResults();
|
||||||
SELECT r.*, s.url, s.type as source_type
|
res.json({ routes: results });
|
||||||
FROM latency_routes r
|
} catch (err) {
|
||||||
JOIN prometheus_sources s ON r.source_id = s.id
|
console.error('Error fetching latencies:', err);
|
||||||
`);
|
res.status(500).json({ error: 'Failed to fetch latency' });
|
||||||
|
|
||||||
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' });
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ==================== 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) {
|
function broadcast(data) {
|
||||||
|
if (!wss) return;
|
||||||
const message = JSON.stringify(data);
|
const message = JSON.stringify(data);
|
||||||
wss.clients.forEach(client => {
|
wss.clients.forEach(client => {
|
||||||
if (client.readyState === WebSocket.OPEN) {
|
if (client.readyState === WebSocket.OPEN) {
|
||||||
@@ -1344,33 +1422,15 @@ 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();
|
||||||
|
const latencyResults = await getLatencyResults();
|
||||||
// Also include latencies in the broadcast to make map lines real-time
|
|
||||||
const [routes] = await db.query(`
|
broadcast({
|
||||||
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',
|
type: 'overview',
|
||||||
data: {
|
data: {
|
||||||
...overview,
|
...overview,
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -4,6 +4,115 @@ const db = require('./db');
|
|||||||
|
|
||||||
const POLL_INTERVAL = 10000; // 10 seconds
|
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() {
|
async function pollLatency() {
|
||||||
try {
|
try {
|
||||||
const [routes] = await db.query(`
|
const [routes] = await db.query(`
|
||||||
@@ -18,99 +127,7 @@ async function pollLatency() {
|
|||||||
// Poll each route
|
// Poll each route
|
||||||
await Promise.allSettled(routes.map(async (route) => {
|
await Promise.allSettled(routes.map(async (route) => {
|
||||||
try {
|
try {
|
||||||
// Blackbox exporter probe URL
|
await resolveLatencyForRoute({ ...route, source_type: 'blackbox' });
|
||||||
// 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);
|
|
||||||
} 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
43
vercel.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user