添加流量地图
This commit is contained in:
@@ -586,11 +586,17 @@ input:checked+.slider:before {
|
|||||||
/* ---- Charts Section ---- */
|
/* ---- Charts Section ---- */
|
||||||
.charts-section {
|
.charts-section {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 3fr 2fr;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.charts-section {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.chart-card {
|
.chart-card {
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
@@ -748,6 +754,24 @@ input:checked+.slider:before {
|
|||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- Globe Card ---- */
|
||||||
|
.globe-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.globe-body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 280px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.globe-body:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- Gauges ---- */
|
/* ---- Gauges ---- */
|
||||||
.gauges-container {
|
.gauges-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500;600&display=swap"
|
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500;600&display=swap"
|
||||||
rel="stylesheet">
|
rel="stylesheet">
|
||||||
<link rel="stylesheet" href="/css/style.css">
|
<link rel="stylesheet" href="/css/style.css">
|
||||||
|
<script src="//unpkg.com/globe.gl"></script>
|
||||||
<script>
|
<script>
|
||||||
// Prevent theme flicker
|
// Prevent theme flicker
|
||||||
(function () {
|
(function () {
|
||||||
@@ -234,6 +235,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Global Traffic 3D Globe -->
|
||||||
|
<div class="chart-card globe-card" id="globeCard">
|
||||||
|
<div class="chart-card-header">
|
||||||
|
<h2 class="chart-title">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="chart-title-icon">
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
|
||||||
|
</svg>
|
||||||
|
全球节点分布
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div class="globe-body" id="globeContainer"></div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Server List -->
|
<!-- Server List -->
|
||||||
|
|||||||
@@ -77,7 +77,8 @@
|
|||||||
newPasswordInput: document.getElementById('newPassword'),
|
newPasswordInput: document.getElementById('newPassword'),
|
||||||
confirmNewPasswordInput: document.getElementById('confirmNewPassword'),
|
confirmNewPasswordInput: document.getElementById('confirmNewPassword'),
|
||||||
btnChangePassword: document.getElementById('btnChangePassword'),
|
btnChangePassword: document.getElementById('btnChangePassword'),
|
||||||
changePasswordMessage: document.getElementById('changePasswordMessage')
|
changePasswordMessage: document.getElementById('changePasswordMessage'),
|
||||||
|
globeContainer: document.getElementById('globeContainer')
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---- State ----
|
// ---- State ----
|
||||||
@@ -89,6 +90,7 @@
|
|||||||
let currentSourceFilter = 'all';
|
let currentSourceFilter = 'all';
|
||||||
let currentPage = 1;
|
let currentPage = 1;
|
||||||
let pageSize = 20;
|
let pageSize = 20;
|
||||||
|
let myGlobe = null;
|
||||||
|
|
||||||
// ---- Initialize ----
|
// ---- Initialize ----
|
||||||
function init() {
|
function init() {
|
||||||
@@ -101,6 +103,9 @@
|
|||||||
// Network chart
|
// Network chart
|
||||||
networkChart = new AreaChart(dom.networkCanvas);
|
networkChart = new AreaChart(dom.networkCanvas);
|
||||||
|
|
||||||
|
// Initial globe
|
||||||
|
initGlobe();
|
||||||
|
|
||||||
// Event listeners
|
// Event listeners
|
||||||
dom.btnSettings.addEventListener('click', openSettings);
|
dom.btnSettings.addEventListener('click', openSettings);
|
||||||
dom.modalClose.addEventListener('click', closeSettings);
|
dom.modalClose.addEventListener('click', closeSettings);
|
||||||
@@ -353,6 +358,78 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Global Globe ----
|
||||||
|
function initGlobe() {
|
||||||
|
if (!dom.globeContainer) return;
|
||||||
|
|
||||||
|
myGlobe = Globe()
|
||||||
|
(dom.globeContainer)
|
||||||
|
.globeImageUrl('//unpkg.com/three-globe/example/img/earth-dark.jpg')
|
||||||
|
.bumpImageUrl('//unpkg.com/three-globe/example/img/earth-topology.png')
|
||||||
|
.backgroundColor('rgba(0,0,0,0)')
|
||||||
|
.showAtmosphere(true)
|
||||||
|
.atmosphereColor('#6366f1')
|
||||||
|
.atmosphereDaylightAlpha(0.1)
|
||||||
|
.pointsData([])
|
||||||
|
.pointColor(() => '#06b6d4')
|
||||||
|
.pointAltitude(0.05)
|
||||||
|
.pointRadius(0.8)
|
||||||
|
.pointsMerge(true)
|
||||||
|
.pointLabel(d => `
|
||||||
|
<div style="background: rgba(10, 14, 26, 0.9); padding: 8px 12px; border: 1px solid var(--accent-indigo); border-radius: 8px; backdrop-filter: blur(8px);">
|
||||||
|
<div style="font-weight: 700; color: #fff; margin-bottom: 4px;">${escapeHtml(d.job)}</div>
|
||||||
|
<div style="font-size: 0.75rem; color: var(--text-secondary);">${escapeHtml(d.city || '')}, ${escapeHtml(d.countryName || d.country || '')}</div>
|
||||||
|
<div style="font-size: 0.75rem; margin-top: 4px;">
|
||||||
|
<span style="color: var(--accent-indigo);">BW:</span> ↓${formatBandwidth(d.netRx)} ↑${formatBandwidth(d.netTx)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Resizing
|
||||||
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
|
if (myGlobe) {
|
||||||
|
const width = dom.globeContainer.clientWidth;
|
||||||
|
const height = dom.globeContainer.clientHeight;
|
||||||
|
myGlobe.width(width).height(height);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
resizeObserver.observe(dom.globeContainer);
|
||||||
|
|
||||||
|
// Initial view
|
||||||
|
myGlobe.controls().autoRotate = true;
|
||||||
|
myGlobe.controls().autoRotateSpeed = 0.5;
|
||||||
|
myGlobe.controls().enableZoom = false; // Disable zoom to maintain dashboard feel
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateGlobe(servers) {
|
||||||
|
if (!myGlobe) return;
|
||||||
|
|
||||||
|
// Filter servers with lat/lng
|
||||||
|
const geoData = servers
|
||||||
|
.filter(s => s.lat && s.lng)
|
||||||
|
.map(s => ({
|
||||||
|
lat: s.lat,
|
||||||
|
lng: s.lng,
|
||||||
|
job: s.job,
|
||||||
|
city: s.city,
|
||||||
|
country: s.country,
|
||||||
|
countryName: s.countryName,
|
||||||
|
netRx: s.netRx,
|
||||||
|
netTx: s.netTx,
|
||||||
|
size: Math.max(0.2, Math.min(1.5, (s.netRx + s.netTx) / 1024 / 1024 / 5)) // Scale by bandwidth (MB/s)
|
||||||
|
}));
|
||||||
|
|
||||||
|
myGlobe.pointsData(geoData);
|
||||||
|
|
||||||
|
// Also add arcs for "traffic flow" if we have multiple servers
|
||||||
|
// For now, let's just show rings or pulses for active traffic
|
||||||
|
myGlobe.ringsData(geoData.filter(d => (d.netRx + d.netTx) > 1024 * 1024)); // Pulse for servers > 1MB/s
|
||||||
|
myGlobe.ringColor(() => '#6366f1');
|
||||||
|
myGlobe.ringMaxRadius(3);
|
||||||
|
myGlobe.ringPropagationSpeed(1);
|
||||||
|
myGlobe.ringRepeatPeriod(1000);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Update Dashboard ----
|
// ---- Update Dashboard ----
|
||||||
function updateDashboard(data) {
|
function updateDashboard(data) {
|
||||||
// Server count
|
// Server count
|
||||||
@@ -385,6 +462,9 @@
|
|||||||
// Update server table
|
// Update server table
|
||||||
renderFilteredServers();
|
renderFilteredServers();
|
||||||
|
|
||||||
|
// Update globe
|
||||||
|
updateGlobe(data.servers || []);
|
||||||
|
|
||||||
// Flash animation
|
// Flash animation
|
||||||
if (previousMetrics) {
|
if (previousMetrics) {
|
||||||
[dom.cpuPercent, dom.memPercent, dom.diskPercent, dom.totalBandwidth].forEach(el => {
|
[dom.cpuPercent, dom.memPercent, dom.diskPercent, dom.totalBandwidth].forEach(el => {
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ const REQUIRED_TABLES = [
|
|||||||
'users',
|
'users',
|
||||||
'prometheus_sources',
|
'prometheus_sources',
|
||||||
'site_settings',
|
'site_settings',
|
||||||
'traffic_stats'
|
'traffic_stats',
|
||||||
|
'server_locations'
|
||||||
];
|
];
|
||||||
|
|
||||||
async function checkAndFixDatabase() {
|
async function checkAndFixDatabase() {
|
||||||
@@ -115,6 +116,21 @@ async function recreateDatabase(host, port, user, password, dbName) {
|
|||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
console.log(' - Creating table "server_locations"...');
|
||||||
|
await connection.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS server_locations (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
ip VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
country CHAR(2),
|
||||||
|
country_name VARCHAR(100),
|
||||||
|
region VARCHAR(100),
|
||||||
|
city VARCHAR(100),
|
||||||
|
latitude DOUBLE,
|
||||||
|
longitude DOUBLE,
|
||||||
|
last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
`);
|
||||||
|
|
||||||
console.log(`[Database Integrity] ✅ Re-initialization complete.`);
|
console.log(`[Database Integrity] ✅ Re-initialization complete.`);
|
||||||
|
|
||||||
// Refresh db pool in the main app context
|
// Refresh db pool in the main app context
|
||||||
|
|||||||
94
server/geo-service.js
Normal file
94
server/geo-service.js
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
const axios = require('axios');
|
||||||
|
const db = require('./db');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Geo Location Service
|
||||||
|
* Resolves IP addresses to geographical coordinates and country info.
|
||||||
|
* Caches results in the database to minimize API calls.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ipInfoToken = process.env.IPINFO_TOKEN;
|
||||||
|
|
||||||
|
async function getLocation(ip) {
|
||||||
|
// Normalize IP (strip port if present)
|
||||||
|
const cleanIp = ip.split(':')[0];
|
||||||
|
|
||||||
|
// Skip local/reserved IPs
|
||||||
|
if (isLocalIp(cleanIp)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check database first
|
||||||
|
try {
|
||||||
|
const [rows] = await db.query('SELECT * FROM server_locations WHERE ip = ?', [cleanIp]);
|
||||||
|
if (rows.length > 0) {
|
||||||
|
// Check if data is stale (e.g., older than 30 days)
|
||||||
|
const data = rows[0];
|
||||||
|
const age = Date.now() - new Date(data.last_updated).getTime();
|
||||||
|
if (age < 30 * 24 * 60 * 60 * 1000) {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve via ipinfo.io
|
||||||
|
console.log(`[Geo Service] Resolving location for IP: ${cleanIp}`);
|
||||||
|
const url = `https://ipinfo.io/${cleanIp}/json${ipInfoToken ? `?token=${ipInfoToken}` : ''}`;
|
||||||
|
const response = await axios.get(url, { timeout: 5000 });
|
||||||
|
const geo = response.data;
|
||||||
|
|
||||||
|
if (geo && geo.loc) {
|
||||||
|
const [lat, lon] = geo.loc.split(',').map(Number);
|
||||||
|
const locationData = {
|
||||||
|
ip: cleanIp,
|
||||||
|
country: geo.country,
|
||||||
|
country_name: geo.country_name || geo.country, // ipinfo might not have country_name in basic response
|
||||||
|
region: geo.region,
|
||||||
|
city: geo.city,
|
||||||
|
latitude: lat,
|
||||||
|
longitude: lon
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save to DB
|
||||||
|
await db.query(`
|
||||||
|
INSERT INTO server_locations (ip, country, country_name, region, city, latitude, longitude)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
country = VALUES(country),
|
||||||
|
country_name = VALUES(country_name),
|
||||||
|
region = VALUES(region),
|
||||||
|
city = VALUES(city),
|
||||||
|
latitude = VALUES(latitude),
|
||||||
|
longitude = VALUES(longitude)
|
||||||
|
`, [
|
||||||
|
locationData.ip,
|
||||||
|
locationData.country,
|
||||||
|
locationData.country_name,
|
||||||
|
locationData.region,
|
||||||
|
locationData.city,
|
||||||
|
locationData.latitude,
|
||||||
|
locationData.longitude
|
||||||
|
]);
|
||||||
|
|
||||||
|
return locationData;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[Geo Service] Error resolving IP ${cleanIp}:`, err.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLocalIp(ip) {
|
||||||
|
if (ip === 'localhost' || ip === '127.0.0.1' || ip === '::1') return true;
|
||||||
|
|
||||||
|
// RFC1918 private addresses
|
||||||
|
const p1 = /^10\./;
|
||||||
|
const p2 = /^172\.(1[6-9]|2[0-9]|3[0-1])\./;
|
||||||
|
const p3 = /^192\.168\./;
|
||||||
|
|
||||||
|
return p1.test(ip) || p2.test(ip) || p3.test(ip);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getLocation
|
||||||
|
};
|
||||||
@@ -5,6 +5,7 @@ const path = require('path');
|
|||||||
const db = require('./db');
|
const db = require('./db');
|
||||||
const prometheusService = require('./prometheus-service');
|
const prometheusService = require('./prometheus-service');
|
||||||
const cache = require('./cache');
|
const cache = require('./cache');
|
||||||
|
const geoService = require('./geo-service');
|
||||||
const checkAndFixDatabase = require('./db-integrity-check');
|
const checkAndFixDatabase = require('./db-integrity-check');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
@@ -548,7 +549,7 @@ app.get('/api/metrics/overview', async (req, res) => {
|
|||||||
console.error('Error calculating 24h traffic from DB integration:', err);
|
console.error('Error calculating 24h traffic from DB integration:', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({
|
const overview = {
|
||||||
totalServers,
|
totalServers,
|
||||||
activeServers,
|
activeServers,
|
||||||
cpu: {
|
cpu: {
|
||||||
@@ -577,7 +578,35 @@ app.get('/api/metrics/overview', async (req, res) => {
|
|||||||
total: traffic24hRx + traffic24hTx
|
total: traffic24hRx + traffic24hTx
|
||||||
},
|
},
|
||||||
servers: allServers
|
servers: allServers
|
||||||
});
|
};
|
||||||
|
|
||||||
|
// --- Add Geo Information to Servers ---
|
||||||
|
const geoServers = await Promise.all(overview.servers.map(async (server) => {
|
||||||
|
// The original IP is masked in the response from prometheusService.getOverviewMetrics
|
||||||
|
// But we can get it back from serverIdMap in prometheusService if we are on the same process
|
||||||
|
// Actually, prometheusService needs to provide a way to get the real IP back.
|
||||||
|
// Or we can just modify getOverviewMetrics to return the real IP for internal use.
|
||||||
|
|
||||||
|
// Let's look at prometheusService.js's getServerIdMap logic
|
||||||
|
const realInstance = prometheusService.resolveToken(server.instance);
|
||||||
|
const cleanIp = realInstance.split(':')[0];
|
||||||
|
|
||||||
|
// Attempt to get location
|
||||||
|
const location = await geoService.getLocation(cleanIp);
|
||||||
|
if (location) {
|
||||||
|
return {
|
||||||
|
...server,
|
||||||
|
country: location.country,
|
||||||
|
countryName: location.country_name,
|
||||||
|
lat: location.latitude,
|
||||||
|
lng: location.longitude
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return server;
|
||||||
|
}));
|
||||||
|
|
||||||
|
overview.servers = geoServers;
|
||||||
|
res.json(overview);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error fetching overview metrics:', err);
|
console.error('Error fetching overview metrics:', err);
|
||||||
res.status(500).json({ error: 'Failed to fetch metrics' });
|
res.status(500).json({ error: 'Failed to fetch metrics' });
|
||||||
|
|||||||
@@ -67,6 +67,22 @@ async function initDatabase() {
|
|||||||
`);
|
`);
|
||||||
console.log(' ✅ Table "site_settings" ready');
|
console.log(' ✅ Table "site_settings" ready');
|
||||||
|
|
||||||
|
// Create server_locations table
|
||||||
|
await connection.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS server_locations (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
ip VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
country CHAR(2),
|
||||||
|
country_name VARCHAR(100),
|
||||||
|
region VARCHAR(100),
|
||||||
|
city VARCHAR(100),
|
||||||
|
latitude DOUBLE,
|
||||||
|
longitude DOUBLE,
|
||||||
|
last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
`);
|
||||||
|
console.log(' ✅ Table "server_locations" ready');
|
||||||
|
|
||||||
console.log('\n🎉 Database initialization complete!\n');
|
console.log('\n🎉 Database initialization complete!\n');
|
||||||
await connection.end();
|
await connection.end();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -650,5 +650,6 @@ module.exports = {
|
|||||||
getCpuHistory,
|
getCpuHistory,
|
||||||
mergeCpuHistories,
|
mergeCpuHistories,
|
||||||
getServerDetails,
|
getServerDetails,
|
||||||
getServerHistory
|
getServerHistory,
|
||||||
|
resolveToken
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user