使用Claude优化动画
This commit is contained in:
672
public/js/app.js
672
public/js/app.js
@@ -125,7 +125,7 @@
|
|||||||
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
|
||||||
|
|
||||||
// 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' };
|
||||||
try {
|
try {
|
||||||
@@ -136,7 +136,7 @@
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Failed to load sort state', e);
|
console.warn('Failed to load sort state', e);
|
||||||
}
|
}
|
||||||
|
|
||||||
let myMap2D = null;
|
let myMap2D = null;
|
||||||
let editingRouteId = null;
|
let editingRouteId = null;
|
||||||
|
|
||||||
@@ -157,7 +157,7 @@
|
|||||||
// Event listeners
|
// Event listeners
|
||||||
dom.btnSettings.addEventListener('click', openSettings);
|
dom.btnSettings.addEventListener('click', openSettings);
|
||||||
dom.modalClose.addEventListener('click', closeSettings);
|
dom.modalClose.addEventListener('click', closeSettings);
|
||||||
|
|
||||||
// Toggle server source option based on type
|
// Toggle server source option based on type
|
||||||
if (dom.sourceType) {
|
if (dom.sourceType) {
|
||||||
dom.sourceType.addEventListener('change', () => {
|
dom.sourceType.addEventListener('change', () => {
|
||||||
@@ -172,7 +172,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (dom.btnCancelEditRoute) {
|
if (dom.btnCancelEditRoute) {
|
||||||
dom.btnCancelEditRoute.onclick = cancelEditRoute;
|
dom.btnCancelEditRoute.onclick = cancelEditRoute;
|
||||||
}
|
}
|
||||||
|
|
||||||
dom.settingsModal.addEventListener('click', (e) => {
|
dom.settingsModal.addEventListener('click', (e) => {
|
||||||
@@ -201,7 +201,7 @@
|
|||||||
// Site settings
|
// Site settings
|
||||||
dom.btnSaveSiteSettings.addEventListener('click', saveSiteSettings);
|
dom.btnSaveSiteSettings.addEventListener('click', saveSiteSettings);
|
||||||
dom.btnAddRoute.addEventListener('click', addLatencyRoute);
|
dom.btnAddRoute.addEventListener('click', addLatencyRoute);
|
||||||
|
|
||||||
// Auth password change
|
// Auth password change
|
||||||
if (dom.btnChangePassword) {
|
if (dom.btnChangePassword) {
|
||||||
dom.btnChangePassword.addEventListener('click', saveChangePassword);
|
dom.btnChangePassword.addEventListener('click', saveChangePassword);
|
||||||
@@ -251,7 +251,7 @@
|
|||||||
offset: 1
|
offset: 1
|
||||||
}
|
}
|
||||||
], {
|
], {
|
||||||
duration: 400,
|
duration: 600,
|
||||||
easing: 'cubic-bezier(0.16, 1, 0.3, 1)',
|
easing: 'cubic-bezier(0.16, 1, 0.3, 1)',
|
||||||
fill: 'none'
|
fill: 'none'
|
||||||
});
|
});
|
||||||
@@ -305,7 +305,7 @@
|
|||||||
offset: 1
|
offset: 1
|
||||||
}
|
}
|
||||||
], {
|
], {
|
||||||
duration: 350,
|
duration: 500,
|
||||||
easing: 'cubic-bezier(0.4, 0, 0.2, 1)',
|
easing: 'cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
fill: 'forwards' // Hold final frame until we remove class
|
fill: 'forwards' // Hold final frame until we remove class
|
||||||
});
|
});
|
||||||
@@ -660,7 +660,7 @@
|
|||||||
try {
|
try {
|
||||||
const response = await fetch('/api/metrics/latency');
|
const response = await fetch('/api/metrics/latency');
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
currentLatencies = data.routes || [];
|
currentLatencies = data.routes || [];
|
||||||
if (allServersData.length > 0) {
|
if (allServersData.length > 0) {
|
||||||
updateMap2D(allServersData);
|
updateMap2D(allServersData);
|
||||||
}
|
}
|
||||||
@@ -672,74 +672,74 @@
|
|||||||
// ---- Global 2D Map ----
|
// ---- Global 2D Map ----
|
||||||
async function initMap2D() {
|
async function initMap2D() {
|
||||||
if (!dom.globeContainer) return;
|
if (!dom.globeContainer) return;
|
||||||
|
|
||||||
if (typeof echarts === 'undefined') {
|
if (typeof echarts === 'undefined') {
|
||||||
console.warn('[Map2D] ECharts library not loaded.');
|
console.warn('[Map2D] ECharts library not loaded.');
|
||||||
dom.globeContainer.innerHTML = `<div class="chart-empty">地图库加载失败</div>`;
|
dom.globeContainer.innerHTML = `<div class="chart-empty">地图库加载失败</div>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch map data
|
// Fetch map data
|
||||||
const resp = await fetch('https://cdn.jsdelivr.net/npm/echarts@4.9.0/map/json/world.json');
|
const resp = await fetch('https://cdn.jsdelivr.net/npm/echarts@4.9.0/map/json/world.json');
|
||||||
const worldJSON = await resp.json();
|
const worldJSON = await resp.json();
|
||||||
|
|
||||||
// Transform to Pacific-centered correctly
|
|
||||||
const transformCoords = (coords) => {
|
|
||||||
if (!Array.isArray(coords) || coords.length === 0) return;
|
|
||||||
const first = coords[0];
|
|
||||||
if (!Array.isArray(first)) return;
|
|
||||||
|
|
||||||
if (typeof first[0] === 'number') { // Ring
|
// Transform to Pacific-centered correctly
|
||||||
let sum = 0;
|
const transformCoords = (coords) => {
|
||||||
coords.forEach(pt => sum += pt[0]);
|
if (!Array.isArray(coords) || coords.length === 0) return;
|
||||||
let avg = sum / coords.length;
|
const first = coords[0];
|
||||||
if (avg < -20) {
|
if (!Array.isArray(first)) return;
|
||||||
coords.forEach(pt => pt[0] += 360);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
coords.forEach(transformCoords);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (worldJSON && worldJSON.features) {
|
if (typeof first[0] === 'number') { // Ring
|
||||||
worldJSON.features.forEach(feature => {
|
let sum = 0;
|
||||||
if (feature.geometry && feature.geometry.coordinates) {
|
coords.forEach(pt => sum += pt[0]);
|
||||||
transformCoords(feature.geometry.coordinates);
|
let avg = sum / coords.length;
|
||||||
}
|
if (avg < -20) {
|
||||||
});
|
coords.forEach(pt => pt[0] += 360);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
coords.forEach(transformCoords);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
echarts.registerMap('world', worldJSON);
|
|
||||||
|
|
||||||
if (myMap2D) {
|
if (worldJSON && worldJSON.features) {
|
||||||
myMap2D.dispose();
|
worldJSON.features.forEach(feature => {
|
||||||
if (mapResizeHandler) {
|
if (feature.geometry && feature.geometry.coordinates) {
|
||||||
window.removeEventListener('resize', mapResizeHandler);
|
transformCoords(feature.geometry.coordinates);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
echarts.registerMap('world', worldJSON);
|
||||||
|
|
||||||
|
if (myMap2D) {
|
||||||
|
myMap2D.dispose();
|
||||||
|
if (mapResizeHandler) {
|
||||||
|
window.removeEventListener('resize', mapResizeHandler);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
myMap2D = echarts.init(dom.globeContainer);
|
myMap2D = echarts.init(dom.globeContainer);
|
||||||
|
|
||||||
const isLight = document.documentElement.classList.contains('light-theme');
|
|
||||||
|
|
||||||
// Helper to transform app coordinates to shifted map coordinates
|
|
||||||
const shiftLng = (lng) => (lng < -20 ? lng + 360 : lng);
|
|
||||||
|
|
||||||
const option = {
|
const isLight = document.documentElement.classList.contains('light-theme');
|
||||||
backgroundColor: 'transparent',
|
|
||||||
tooltip: {
|
// Helper to transform app coordinates to shifted map coordinates
|
||||||
show: true,
|
const shiftLng = (lng) => (lng < -20 ? lng + 360 : lng);
|
||||||
trigger: 'item',
|
|
||||||
confine: true,
|
const option = {
|
||||||
transitionDuration: 0,
|
backgroundColor: 'transparent',
|
||||||
backgroundColor: 'rgba(10, 14, 26, 0.9)',
|
tooltip: {
|
||||||
borderColor: 'var(--accent-indigo)',
|
show: true,
|
||||||
textStyle: { color: '#fff', fontSize: 12 },
|
trigger: 'item',
|
||||||
formatter: (params) => {
|
confine: true,
|
||||||
const d = params.data;
|
transitionDuration: 0,
|
||||||
if (!d) return '';
|
backgroundColor: 'rgba(10, 14, 26, 0.9)',
|
||||||
return `
|
borderColor: 'var(--accent-indigo)',
|
||||||
|
textStyle: { color: '#fff', fontSize: 12 },
|
||||||
|
formatter: (params) => {
|
||||||
|
const d = params.data;
|
||||||
|
if (!d) return '';
|
||||||
|
return `
|
||||||
<div style="padding: 4px;">
|
<div style="padding: 4px;">
|
||||||
<div style="font-weight: 700; border-bottom: 1px solid rgba(255,255,255,0.1); margin-bottom: 4px; padding-bottom: 4px;">${escapeHtml(d.job)}</div>
|
<div style="font-weight: 700; border-bottom: 1px solid rgba(255,255,255,0.1); margin-bottom: 4px; padding-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; color: var(--text-secondary);">${escapeHtml(d.city || '')}, ${escapeHtml(d.countryName || d.country || '')}</div>
|
||||||
@@ -748,53 +748,53 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
geo: {
|
geo: {
|
||||||
map: 'world',
|
map: 'world',
|
||||||
roam: true,
|
roam: true,
|
||||||
center: [165, 20], // Centered in Pacific
|
center: [165, 20], // Centered in Pacific
|
||||||
zoom: 1.1,
|
zoom: 1.1,
|
||||||
aspectScale: 0.85,
|
aspectScale: 0.85,
|
||||||
emphasis: {
|
emphasis: {
|
||||||
label: { show: false },
|
label: { show: false },
|
||||||
itemStyle: { areaColor: isLight ? '#f0f2f5' : '#2d334d' }
|
itemStyle: { areaColor: isLight ? '#f0f2f5' : '#2d334d' }
|
||||||
},
|
},
|
||||||
itemStyle: {
|
itemStyle: {
|
||||||
areaColor: isLight ? '#f4f6fa' : '#1a1d2e',
|
areaColor: isLight ? '#f4f6fa' : '#1a1d2e',
|
||||||
borderColor: isLight ? '#cbd5e1' : '#2d334d',
|
borderColor: isLight ? '#cbd5e1' : '#2d334d',
|
||||||
borderWidth: 1
|
borderWidth: 1
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
itemStyle: { areaColor: isLight ? '#f4f8ff' : '#2d334d' }
|
itemStyle: { areaColor: isLight ? '#f4f8ff' : '#2d334d' }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
series: [{
|
series: [{
|
||||||
type: 'scatter',
|
type: 'scatter',
|
||||||
coordinateSystem: 'geo',
|
coordinateSystem: 'geo',
|
||||||
geoIndex: 0,
|
geoIndex: 0,
|
||||||
symbolSize: 5,
|
symbolSize: 5,
|
||||||
itemStyle: {
|
itemStyle: {
|
||||||
color: '#06b6d4',
|
color: '#06b6d4',
|
||||||
shadowBlur: 3,
|
shadowBlur: 3,
|
||||||
shadowColor: 'rgba(6, 182, 212, 0.5)'
|
shadowColor: 'rgba(6, 182, 212, 0.5)'
|
||||||
},
|
},
|
||||||
data: []
|
data: []
|
||||||
}]
|
}]
|
||||||
};
|
};
|
||||||
|
|
||||||
myMap2D.setOption(option);
|
myMap2D.setOption(option);
|
||||||
|
|
||||||
mapResizeHandler = debounce(() => {
|
mapResizeHandler = debounce(() => {
|
||||||
if (myMap2D) myMap2D.resize();
|
if (myMap2D) myMap2D.resize();
|
||||||
}, 100);
|
}, 100);
|
||||||
window.addEventListener('resize', mapResizeHandler);
|
window.addEventListener('resize', mapResizeHandler);
|
||||||
|
|
||||||
if (allServersData.length > 0) {
|
if (allServersData.length > 0) {
|
||||||
updateMap2D(allServersData);
|
updateMap2D(allServersData);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Map2D] Initialization failed:', err);
|
console.error('[Map2D] Initialization failed:', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -802,15 +802,15 @@
|
|||||||
if (!myMap2D) return;
|
if (!myMap2D) return;
|
||||||
const isLight = theme === 'light';
|
const isLight = theme === 'light';
|
||||||
myMap2D.setOption({
|
myMap2D.setOption({
|
||||||
geo: {
|
geo: {
|
||||||
itemStyle: {
|
itemStyle: {
|
||||||
areaColor: isLight ? '#f4f6fa' : '#1a1d2e',
|
areaColor: isLight ? '#f4f6fa' : '#1a1d2e',
|
||||||
borderColor: isLight ? '#cbd5e1' : '#2d334d'
|
borderColor: isLight ? '#cbd5e1' : '#2d334d'
|
||||||
},
|
},
|
||||||
emphasis: {
|
emphasis: {
|
||||||
itemStyle: { areaColor: isLight ? '#f4f8ff' : '#2d334d' }
|
itemStyle: { areaColor: isLight ? '#f4f8ff' : '#2d334d' }
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
function updateMap2D(servers) {
|
function updateMap2D(servers) {
|
||||||
@@ -820,175 +820,175 @@
|
|||||||
const shiftLng = (lng) => (lng < -20 ? lng + 360 : lng);
|
const shiftLng = (lng) => (lng < -20 ? lng + 360 : lng);
|
||||||
|
|
||||||
const geoData = servers
|
const geoData = servers
|
||||||
.filter(s => s.lat && s.lng)
|
.filter(s => s.lat && s.lng)
|
||||||
.map(s => ({
|
.map(s => ({
|
||||||
name: s.job,
|
name: s.job,
|
||||||
value: [shiftLng(s.lng), s.lat],
|
value: [shiftLng(s.lng), s.lat],
|
||||||
job: s.job,
|
job: s.job,
|
||||||
city: s.city,
|
city: s.city,
|
||||||
country: s.country,
|
country: s.country,
|
||||||
countryName: s.countryName,
|
countryName: s.countryName,
|
||||||
netRx: s.netRx,
|
netRx: s.netRx,
|
||||||
netTx: s.netTx
|
netTx: s.netTx
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Combine all series
|
// Combine all series
|
||||||
const finalSeries = [
|
const finalSeries = [
|
||||||
{
|
{
|
||||||
type: 'scatter',
|
type: 'scatter',
|
||||||
coordinateSystem: 'geo',
|
coordinateSystem: 'geo',
|
||||||
geoIndex: 0,
|
geoIndex: 0,
|
||||||
symbolSize: 6,
|
symbolSize: 6,
|
||||||
itemStyle: {
|
itemStyle: {
|
||||||
color: '#06b6d4',
|
color: '#06b6d4',
|
||||||
shadowBlur: 3,
|
shadowBlur: 3,
|
||||||
shadowColor: 'rgba(6, 182, 212, 0.5)'
|
shadowColor: 'rgba(6, 182, 212, 0.5)'
|
||||||
},
|
},
|
||||||
data: geoData,
|
data: geoData,
|
||||||
zlevel: 1
|
zlevel: 1
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
// Add latency routes if configured
|
// Add latency routes if configured
|
||||||
if (currentLatencies && currentLatencies.length > 0) {
|
if (currentLatencies && currentLatencies.length > 0) {
|
||||||
const countryCoords = {
|
const countryCoords = {
|
||||||
'china': [116.4074, 39.9042],
|
'china': [116.4074, 39.9042],
|
||||||
'beijing': [116.4074, 39.9042],
|
'beijing': [116.4074, 39.9042],
|
||||||
'shanghai': [121.4737, 31.2304],
|
'shanghai': [121.4737, 31.2304],
|
||||||
'hong kong': [114.1694, 22.3193],
|
'hong kong': [114.1694, 22.3193],
|
||||||
'taiwan': [120.9605, 23.6978],
|
'taiwan': [120.9605, 23.6978],
|
||||||
'united states': [-95.7129, 37.0902],
|
'united states': [-95.7129, 37.0902],
|
||||||
'us seattle': [-122.3321, 47.6062],
|
'us seattle': [-122.3321, 47.6062],
|
||||||
'seattle': [-122.3321, 47.6062],
|
'seattle': [-122.3321, 47.6062],
|
||||||
'us chicago': [-87.6298, 41.8781],
|
'us chicago': [-87.6298, 41.8781],
|
||||||
'chicago': [-87.6298, 41.8781],
|
'chicago': [-87.6298, 41.8781],
|
||||||
'news york': [-74.0060, 40.7128],
|
'news york': [-74.0060, 40.7128],
|
||||||
'new york corp': [-74.0060, 40.7128],
|
'new york corp': [-74.0060, 40.7128],
|
||||||
'new york': [-74.0060, 40.7128],
|
'new york': [-74.0060, 40.7128],
|
||||||
'san francisco': [-122.4194, 37.7749],
|
'san francisco': [-122.4194, 37.7749],
|
||||||
'los angeles': [-118.2437, 34.0522],
|
'los angeles': [-118.2437, 34.0522],
|
||||||
'japan': [138.2529, 36.2048],
|
'japan': [138.2529, 36.2048],
|
||||||
'tokyo': [139.6917, 35.6895],
|
'tokyo': [139.6917, 35.6895],
|
||||||
'singapore': [103.8198, 1.3521],
|
'singapore': [103.8198, 1.3521],
|
||||||
'germany': [10.4515, 51.1657],
|
'germany': [10.4515, 51.1657],
|
||||||
'frankfurt': [8.6821, 50.1109],
|
'frankfurt': [8.6821, 50.1109],
|
||||||
'united kingdom': [-3.436, 55.3781],
|
'united kingdom': [-3.436, 55.3781],
|
||||||
'london': [-0.1276, 51.5074],
|
'london': [-0.1276, 51.5074],
|
||||||
'france': [2.2137, 46.2276],
|
'france': [2.2137, 46.2276],
|
||||||
'paris': [2.3522, 48.8566],
|
'paris': [2.3522, 48.8566],
|
||||||
'south korea': [127.7669, 35.9078],
|
'south korea': [127.7669, 35.9078],
|
||||||
'korea': [127.7669, 35.9078],
|
'korea': [127.7669, 35.9078],
|
||||||
'seoul': [126.9780, 37.5665]
|
'seoul': [126.9780, 37.5665]
|
||||||
};
|
};
|
||||||
|
|
||||||
const getCoords = (name) => {
|
const getCoords = (name) => {
|
||||||
const lowerName = (name || '').toLowerCase().trim();
|
const lowerName = (name || '').toLowerCase().trim();
|
||||||
if (countryCoords[lowerName]) return countryCoords[lowerName];
|
if (countryCoords[lowerName]) return countryCoords[lowerName];
|
||||||
|
|
||||||
const s = servers.find(sv =>
|
|
||||||
(sv.countryName || '').toLowerCase() === lowerName ||
|
|
||||||
(sv.country || '').toLowerCase() === lowerName ||
|
|
||||||
(sv.city || '').toLowerCase() === lowerName
|
|
||||||
);
|
|
||||||
if (s && s.lng && s.lat) return [shiftLng(s.lng), s.lat];
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getShiftedCoords = (name) => {
|
const s = servers.find(sv =>
|
||||||
const c = countryCoords[(name || '').toLowerCase().trim()];
|
(sv.countryName || '').toLowerCase() === lowerName ||
|
||||||
if (c) return [shiftLng(c[0]), c[1]];
|
(sv.country || '').toLowerCase() === lowerName ||
|
||||||
const raw = getCoords(name);
|
(sv.city || '').toLowerCase() === lowerName
|
||||||
if (raw) return [shiftLng(raw[0]), raw[1]];
|
);
|
||||||
return null;
|
if (s && s.lng && s.lat) return [shiftLng(s.lng), s.lat];
|
||||||
};
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
// Group latency routes by path to handle overlap visually
|
const getShiftedCoords = (name) => {
|
||||||
const routeGroups = {};
|
const c = countryCoords[(name || '').toLowerCase().trim()];
|
||||||
currentLatencies.forEach(route => {
|
if (c) return [shiftLng(c[0]), c[1]];
|
||||||
const start = getShiftedCoords(route.source);
|
const raw = getCoords(name);
|
||||||
const end = getShiftedCoords(route.dest);
|
if (raw) return [shiftLng(raw[0]), raw[1]];
|
||||||
if (start && end) {
|
return null;
|
||||||
// Canonical points for grouping (independent of direction)
|
};
|
||||||
// Sort by longitude then latitude
|
|
||||||
const pts = [start, end].slice().sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
|
||||||
const key = `${pts[0][0].toFixed(4)},${pts[0][1].toFixed(4)}_${pts[1][0].toFixed(4)},${pts[1][1].toFixed(4)}`;
|
|
||||||
if (!routeGroups[key]) routeGroups[key] = [];
|
|
||||||
routeGroups[key].push({ route, start, end });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Object.keys(routeGroups).forEach(key => {
|
// Group latency routes by path to handle overlap visually
|
||||||
const routes = routeGroups[key];
|
const routeGroups = {};
|
||||||
const count = routes.length;
|
currentLatencies.forEach(route => {
|
||||||
|
const start = getShiftedCoords(route.source);
|
||||||
routes.forEach((item, i) => {
|
const end = getShiftedCoords(route.dest);
|
||||||
const { route, start, end } = item;
|
if (start && end) {
|
||||||
|
// Canonical points for grouping (independent of direction)
|
||||||
// Identify if the route is in the "canonical" direction
|
// Sort by longitude then latitude
|
||||||
const pts = [start, end].slice().sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
const pts = [start, end].slice().sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
||||||
const isForward = (start === pts[0]);
|
const key = `${pts[0][0].toFixed(4)},${pts[0][1].toFixed(4)}_${pts[1][0].toFixed(4)},${pts[1][1].toFixed(4)}`;
|
||||||
|
if (!routeGroups[key]) routeGroups[key] = [];
|
||||||
// Calculate curveness: ensure all lines are slightly curved
|
routeGroups[key].push({ route, start, end });
|
||||||
let finalCurve = 0;
|
}
|
||||||
if (count === 1) {
|
});
|
||||||
finalCurve = 0.15; // Default slight curve for single lines
|
|
||||||
} else {
|
|
||||||
// Spread overlapping lines: 0.15, -0.15, 0.3, -0.3...
|
|
||||||
// This creates an "eye" or "fan" effect where no line is straight
|
|
||||||
const magnitude = 0.15 + Math.floor(i / 2) * 0.15;
|
|
||||||
const spread = (i % 2 === 0) ? magnitude : -magnitude;
|
|
||||||
// Adjust sign based on direction so they occupy unique visual slots
|
|
||||||
finalCurve = isForward ? spread : -spread;
|
|
||||||
}
|
|
||||||
|
|
||||||
finalSeries.push({
|
Object.keys(routeGroups).forEach(key => {
|
||||||
type: 'lines',
|
const routes = routeGroups[key];
|
||||||
coordinateSystem: 'geo',
|
const count = routes.length;
|
||||||
zlevel: 2,
|
|
||||||
latencyLine: true,
|
routes.forEach((item, i) => {
|
||||||
effect: {
|
const { route, start, end } = item;
|
||||||
show: true,
|
|
||||||
period: 4,
|
// Identify if the route is in the "canonical" direction
|
||||||
trailLength: 0.1,
|
const pts = [start, end].slice().sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
||||||
color: 'rgba(99, 102, 241, 0.8)',
|
const isForward = (start === pts[0]);
|
||||||
symbol: 'arrow',
|
|
||||||
symbolSize: 6
|
// Calculate curveness: ensure all lines are slightly curved
|
||||||
},
|
let finalCurve = 0;
|
||||||
lineStyle: {
|
if (count === 1) {
|
||||||
color: 'rgba(99, 102, 241, 0.3)',
|
finalCurve = 0.15; // Default slight curve for single lines
|
||||||
width: 2,
|
} else {
|
||||||
curveness: finalCurve
|
// Spread overlapping lines: 0.15, -0.15, 0.3, -0.3...
|
||||||
},
|
// This creates an "eye" or "fan" effect where no line is straight
|
||||||
tooltip: {
|
const magnitude = 0.15 + Math.floor(i / 2) * 0.15;
|
||||||
formatter: () => {
|
const spread = (i % 2 === 0) ? magnitude : -magnitude;
|
||||||
const latVal = (route.latency !== null && route.latency !== undefined) ? `${route.latency.toFixed(2)} ms` : '测量中...';
|
// Adjust sign based on direction so they occupy unique visual slots
|
||||||
return `
|
finalCurve = isForward ? spread : -spread;
|
||||||
|
}
|
||||||
|
|
||||||
|
finalSeries.push({
|
||||||
|
type: 'lines',
|
||||||
|
coordinateSystem: 'geo',
|
||||||
|
zlevel: 2,
|
||||||
|
latencyLine: true,
|
||||||
|
effect: {
|
||||||
|
show: true,
|
||||||
|
period: 4,
|
||||||
|
trailLength: 0.1,
|
||||||
|
color: 'rgba(99, 102, 241, 0.8)',
|
||||||
|
symbol: 'arrow',
|
||||||
|
symbolSize: 6
|
||||||
|
},
|
||||||
|
lineStyle: {
|
||||||
|
color: 'rgba(99, 102, 241, 0.3)',
|
||||||
|
width: 2,
|
||||||
|
curveness: finalCurve
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
formatter: () => {
|
||||||
|
const latVal = (route.latency !== null && route.latency !== undefined) ? `${route.latency.toFixed(2)} ms` : '测量中...';
|
||||||
|
return `
|
||||||
<div style="padding: 4px;">
|
<div style="padding: 4px;">
|
||||||
<div style="font-weight: 700;">${route.source} ↔ ${route.dest}</div>
|
<div style="font-weight: 700;">${route.source} ↔ ${route.dest}</div>
|
||||||
<div style="font-size: 0.75rem; color: var(--accent-indigo); margin-top: 4px;">延时: ${latVal}</div>
|
<div style="font-size: 0.75rem; color: var(--accent-indigo); margin-top: 4px;">延时: ${latVal}</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
data: [{
|
data: [{
|
||||||
fromName: route.source,
|
fromName: route.source,
|
||||||
toName: route.dest,
|
toName: route.dest,
|
||||||
coords: [start, end]
|
coords: [start, end]
|
||||||
}]
|
}]
|
||||||
});
|
});
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
myMap2D.setOption({
|
myMap2D.setOption({
|
||||||
series: finalSeries
|
series: finalSeries
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update footer stats
|
// Update footer stats
|
||||||
if (dom.globeTotalNodes) dom.globeTotalNodes.textContent = geoData.length;
|
if (dom.globeTotalNodes) dom.globeTotalNodes.textContent = geoData.length;
|
||||||
if (dom.globeTotalRegions) {
|
if (dom.globeTotalRegions) {
|
||||||
const regions = new Set(geoData.map(d => d.country)).size;
|
const regions = new Set(geoData.map(d => d.country)).size;
|
||||||
dom.globeTotalRegions.textContent = regions;
|
dom.globeTotalRegions.textContent = regions;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1023,7 +1023,7 @@
|
|||||||
|
|
||||||
// Update server table
|
// Update server table
|
||||||
renderFilteredServers();
|
renderFilteredServers();
|
||||||
|
|
||||||
// Update globe
|
// Update globe
|
||||||
updateMap2D(data.servers || []);
|
updateMap2D(data.servers || []);
|
||||||
|
|
||||||
@@ -1044,8 +1044,8 @@
|
|||||||
// Apply search filter
|
// Apply search filter
|
||||||
const searchTerm = (dom.serverSearchFilter?.value || '').toLowerCase().trim();
|
const searchTerm = (dom.serverSearchFilter?.value || '').toLowerCase().trim();
|
||||||
if (searchTerm) {
|
if (searchTerm) {
|
||||||
filtered = filtered.filter(s =>
|
filtered = filtered.filter(s =>
|
||||||
(s.job || '').toLowerCase().includes(searchTerm) ||
|
(s.job || '').toLowerCase().includes(searchTerm) ||
|
||||||
(s.instance || '').toLowerCase().includes(searchTerm)
|
(s.instance || '').toLowerCase().includes(searchTerm)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1282,11 +1282,11 @@
|
|||||||
async function openServerDetail(instance, job, source) {
|
async function openServerDetail(instance, job, source) {
|
||||||
// Cleanup old charts if any were still present from a previous open (safety)
|
// Cleanup old charts if any were still present from a previous open (safety)
|
||||||
if (currentServerDetail.charts) {
|
if (currentServerDetail.charts) {
|
||||||
Object.values(currentServerDetail.charts).forEach(chart => {
|
Object.values(currentServerDetail.charts).forEach(chart => {
|
||||||
if (chart && chart.destroy) chart.destroy();
|
if (chart && chart.destroy) chart.destroy();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
currentServerDetail = { instance, job, source, charts: {} };
|
currentServerDetail = { instance, job, source, charts: {} };
|
||||||
dom.serverDetailTitle.textContent = `${job}`;
|
dom.serverDetailTitle.textContent = `${job}`;
|
||||||
dom.serverDetailModal.classList.add('active');
|
dom.serverDetailModal.classList.add('active');
|
||||||
@@ -1333,7 +1333,7 @@
|
|||||||
|
|
||||||
// Disk Total
|
// Disk Total
|
||||||
if (dom.detailDiskTotal) {
|
if (dom.detailDiskTotal) {
|
||||||
dom.detailDiskTotal.textContent = formatBytes(data.totalDiskSize || 0);
|
dom.detailDiskTotal.textContent = formatBytes(data.totalDiskSize || 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Define metrics to show
|
// Define metrics to show
|
||||||
@@ -1416,21 +1416,21 @@
|
|||||||
|
|
||||||
// Handle partitions integration: Move the expandable partition section UNDER the Disk Usage metric
|
// Handle partitions integration: Move the expandable partition section UNDER the Disk Usage metric
|
||||||
if (data.partitions && data.partitions.length > 0) {
|
if (data.partitions && data.partitions.length > 0) {
|
||||||
dom.detailPartitionsContainer.style.display = 'block';
|
dom.detailPartitionsContainer.style.display = 'block';
|
||||||
dom.partitionSummary.textContent = `${data.partitions.length} 个本地分区`;
|
dom.partitionSummary.textContent = `${data.partitions.length} 个本地分区`;
|
||||||
|
|
||||||
// Find the disk metric item and insert the partition container after it
|
|
||||||
const diskMetricItem = document.getElementById('metric-rootFsUsedPct');
|
|
||||||
if (diskMetricItem) {
|
|
||||||
diskMetricItem.after(dom.detailPartitionsContainer);
|
|
||||||
}
|
|
||||||
|
|
||||||
dom.partitionHeader.onclick = (e) => {
|
// Find the disk metric item and insert the partition container after it
|
||||||
e.stopPropagation();
|
const diskMetricItem = document.getElementById('metric-rootFsUsedPct');
|
||||||
dom.detailPartitionsContainer.classList.toggle('active');
|
if (diskMetricItem) {
|
||||||
};
|
diskMetricItem.after(dom.detailPartitionsContainer);
|
||||||
|
}
|
||||||
|
|
||||||
dom.detailPartitionsList.innerHTML = data.partitions.map(p => `
|
dom.partitionHeader.onclick = (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
dom.detailPartitionsContainer.classList.toggle('active');
|
||||||
|
};
|
||||||
|
|
||||||
|
dom.detailPartitionsList.innerHTML = data.partitions.map(p => `
|
||||||
<div class="partition-row">
|
<div class="partition-row">
|
||||||
<div class="partition-info">
|
<div class="partition-info">
|
||||||
<span class="partition-mount">${escapeHtml(p.mountpoint)}</span>
|
<span class="partition-mount">${escapeHtml(p.mountpoint)}</span>
|
||||||
@@ -1442,7 +1442,7 @@
|
|||||||
</div>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
} else {
|
} else {
|
||||||
dom.detailPartitionsContainer.style.display = 'none';
|
dom.detailPartitionsContainer.style.display = 'none';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1507,20 +1507,20 @@
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
if (metricKey === 'networkTrend' && data.stats) {
|
if (metricKey === 'networkTrend' && data.stats) {
|
||||||
const stats = data.stats;
|
const stats = data.stats;
|
||||||
const summaryDiv = document.getElementById(`summary-${metricKey}`);
|
const summaryDiv = document.getElementById(`summary-${metricKey}`);
|
||||||
if (summaryDiv) {
|
if (summaryDiv) {
|
||||||
summaryDiv.style.display = 'flex';
|
summaryDiv.style.display = 'flex';
|
||||||
const rxEl = document.getElementById(`stat-${metricKey}-rx`);
|
const rxEl = document.getElementById(`stat-${metricKey}-rx`);
|
||||||
const txEl = document.getElementById(`stat-${metricKey}-tx`);
|
const txEl = document.getElementById(`stat-${metricKey}-tx`);
|
||||||
const p95El = document.getElementById(`stat-${metricKey}-p95`);
|
const p95El = document.getElementById(`stat-${metricKey}-p95`);
|
||||||
const totalEl = document.getElementById(`stat-${metricKey}-total`);
|
const totalEl = document.getElementById(`stat-${metricKey}-total`);
|
||||||
|
|
||||||
if (rxEl) rxEl.textContent = formatBytes(stats.rxTotal);
|
if (rxEl) rxEl.textContent = formatBytes(stats.rxTotal);
|
||||||
if (txEl) txEl.textContent = formatBytes(stats.txTotal);
|
if (txEl) txEl.textContent = formatBytes(stats.txTotal);
|
||||||
if (p95El) p95El.textContent = formatBandwidth(stats.p95);
|
if (p95El) p95El.textContent = formatBandwidth(stats.p95);
|
||||||
if (totalEl) totalEl.textContent = formatBytes(stats.total);
|
if (totalEl) totalEl.textContent = formatBytes(stats.total);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
chart.setData(data);
|
chart.setData(data);
|
||||||
@@ -1582,7 +1582,7 @@
|
|||||||
hideMessage();
|
hideMessage();
|
||||||
hideSiteMessage();
|
hideSiteMessage();
|
||||||
hideChangePasswordMessage();
|
hideChangePasswordMessage();
|
||||||
|
|
||||||
// Reset password fields
|
// Reset password fields
|
||||||
if (dom.oldPasswordInput) dom.oldPasswordInput.value = '';
|
if (dom.oldPasswordInput) dom.oldPasswordInput.value = '';
|
||||||
if (dom.newPasswordInput) dom.newPasswordInput.value = '';
|
if (dom.newPasswordInput) dom.newPasswordInput.value = '';
|
||||||
@@ -1617,7 +1617,7 @@
|
|||||||
if (networkChart) {
|
if (networkChart) {
|
||||||
networkChart.showP95 = !!settings.show_95_bandwidth;
|
networkChart.showP95 = !!settings.show_95_bandwidth;
|
||||||
networkChart.p95Type = settings.p95_type || 'tx';
|
networkChart.p95Type = settings.p95_type || 'tx';
|
||||||
|
|
||||||
if (dom.legendP95) {
|
if (dom.legendP95) {
|
||||||
dom.legendP95.classList.toggle('disabled', !networkChart.showP95);
|
dom.legendP95.classList.toggle('disabled', !networkChart.showP95);
|
||||||
}
|
}
|
||||||
@@ -1639,9 +1639,9 @@
|
|||||||
|
|
||||||
// Listen for system theme changes if set to auto (cleanup existing listener first)
|
// Listen for system theme changes if set to auto (cleanup existing listener first)
|
||||||
if (siteThemeQuery && siteThemeHandler) {
|
if (siteThemeQuery && siteThemeHandler) {
|
||||||
siteThemeQuery.removeEventListener('change', siteThemeHandler);
|
siteThemeQuery.removeEventListener('change', siteThemeHandler);
|
||||||
}
|
}
|
||||||
|
|
||||||
siteThemeQuery = window.matchMedia('(prefers-color-scheme: light)');
|
siteThemeQuery = window.matchMedia('(prefers-color-scheme: light)');
|
||||||
siteThemeHandler = () => {
|
siteThemeHandler = () => {
|
||||||
const currentSavedTheme = localStorage.getItem('theme');
|
const currentSavedTheme = localStorage.getItem('theme');
|
||||||
@@ -1685,22 +1685,22 @@
|
|||||||
</svg>
|
</svg>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// P95 setting
|
// P95 setting
|
||||||
if (settings.show_95_bandwidth !== undefined || settings.p95_type !== undefined) {
|
if (settings.show_95_bandwidth !== undefined || settings.p95_type !== undefined) {
|
||||||
if (networkChart) {
|
if (networkChart) {
|
||||||
if (settings.show_95_bandwidth !== undefined) {
|
if (settings.show_95_bandwidth !== undefined) {
|
||||||
networkChart.showP95 = !!settings.show_95_bandwidth;
|
networkChart.showP95 = !!settings.show_95_bandwidth;
|
||||||
if (dom.legendP95) {
|
if (dom.legendP95) {
|
||||||
dom.legendP95.classList.toggle('disabled', !networkChart.showP95);
|
dom.legendP95.classList.toggle('disabled', !networkChart.showP95);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (settings.p95_type !== undefined) {
|
if (settings.p95_type !== undefined) {
|
||||||
networkChart.p95Type = settings.p95_type;
|
networkChart.p95Type = settings.p95_type;
|
||||||
if (dom.p95LabelText) {
|
if (dom.p95LabelText) {
|
||||||
const types = { tx: '上行', rx: '下行', both: '上行+下行' };
|
const types = { tx: '上行', rx: '下行', both: '上行+下行' };
|
||||||
dom.p95LabelText.textContent = types[settings.p95_type] || '上行';
|
dom.p95LabelText.textContent = types[settings.p95_type] || '上行';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
networkChart.draw();
|
networkChart.draw();
|
||||||
}
|
}
|
||||||
@@ -1785,30 +1785,30 @@
|
|||||||
`).join('');
|
`).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
window.editRoute = function(id, source_id, source, dest, target) {
|
window.editRoute = function (id, source_id, source, dest, target) {
|
||||||
editingRouteId = id;
|
editingRouteId = id;
|
||||||
dom.routeSourceSelect.value = source_id;
|
dom.routeSourceSelect.value = source_id;
|
||||||
dom.routeSourceInput.value = source;
|
dom.routeSourceInput.value = source;
|
||||||
dom.routeDestInput.value = dest;
|
dom.routeDestInput.value = dest;
|
||||||
dom.routeTargetInput.value = target;
|
dom.routeTargetInput.value = target;
|
||||||
|
|
||||||
dom.btnAddRoute.textContent = '保存修改';
|
dom.btnAddRoute.textContent = '保存修改';
|
||||||
dom.btnCancelEditRoute.style.display = 'block';
|
dom.btnCancelEditRoute.style.display = 'block';
|
||||||
|
|
||||||
// Select the tab just in case (though it's already there)
|
// Select the tab just in case (though it's already there)
|
||||||
const tab = Array.from(dom.modalTabs).find(t => t.dataset.tab === 'routes');
|
const tab = Array.from(dom.modalTabs).find(t => t.dataset.tab === 'routes');
|
||||||
if (tab) tab.click();
|
if (tab) tab.click();
|
||||||
};
|
};
|
||||||
|
|
||||||
function cancelEditRoute() {
|
function cancelEditRoute() {
|
||||||
editingRouteId = null;
|
editingRouteId = null;
|
||||||
dom.routeSourceSelect.value = "";
|
dom.routeSourceSelect.value = "";
|
||||||
dom.routeSourceInput.value = "";
|
dom.routeSourceInput.value = "";
|
||||||
dom.routeDestInput.value = "";
|
dom.routeDestInput.value = "";
|
||||||
dom.routeTargetInput.value = "";
|
dom.routeTargetInput.value = "";
|
||||||
|
|
||||||
dom.btnAddRoute.textContent = '添加线路';
|
dom.btnAddRoute.textContent = '添加线路';
|
||||||
dom.btnCancelEditRoute.style.display = 'none';
|
dom.btnCancelEditRoute.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addLatencyRoute() {
|
async function addLatencyRoute() {
|
||||||
@@ -1853,7 +1853,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.deleteLatencyRoute = async function(id) {
|
window.deleteLatencyRoute = async function (id) {
|
||||||
if (!user) {
|
if (!user) {
|
||||||
showSiteMessage('请登录后再操作', 'error');
|
showSiteMessage('请登录后再操作', 'error');
|
||||||
openLoginModal();
|
openLoginModal();
|
||||||
@@ -1973,10 +1973,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (dom.routeSourceSelect) {
|
if (dom.routeSourceSelect) {
|
||||||
const currentVal = dom.routeSourceSelect.value;
|
const currentVal = dom.routeSourceSelect.value;
|
||||||
dom.routeSourceSelect.innerHTML = '<option value="">-- 选择数据源 --</option>' +
|
dom.routeSourceSelect.innerHTML = '<option value="">-- 选择数据源 --</option>' +
|
||||||
sources.map(s => `<option value="${s.id}">${escapeHtml(s.name)}</option>`).join('');
|
sources.map(s => `<option value="${s.id}">${escapeHtml(s.name)}</option>`).join('');
|
||||||
dom.routeSourceSelect.value = currentVal;
|
dom.routeSourceSelect.value = currentVal;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user