mirror of
https://github.com/bolucat/Archive.git
synced 2025-10-06 16:48:17 +08:00
Update On Fri Sep 6 20:35:08 CEST 2024
This commit is contained in:
@@ -1,393 +0,0 @@
|
||||
const MetricsModule = (function () {
|
||||
// Constants
|
||||
const API_BASE_URL = '/api/v1';
|
||||
const NODE_METRICS_PATH = '/node_metrics/';
|
||||
const BYTE_TO_MB = 1024 * 1024;
|
||||
|
||||
const handleError = (error) => {
|
||||
console.error('Error:', error);
|
||||
};
|
||||
|
||||
// API functions
|
||||
const fetchData = async (path, params = {}) => {
|
||||
const url = new URL(API_BASE_URL + path, window.location.origin);
|
||||
Object.entries(params).forEach(([key, value]) => url.searchParams.append(key, value));
|
||||
try {
|
||||
const response = await fetch(url.toString());
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchLatestMetric = () => fetchData(NODE_METRICS_PATH, { latest: true }).then((data) => data?.data[0]);
|
||||
const fetchMetrics = (startTs, endTs) => fetchData(NODE_METRICS_PATH, { start_ts: startTs, end_ts: endTs }).then((data) => data?.data);
|
||||
|
||||
// Chart functions
|
||||
const initChart = (canvasId, type, datasets, legendPosition = '', yDisplayText = '', title = '', unit = '') => {
|
||||
const ctx = $(`#${canvasId}`)[0].getContext('2d');
|
||||
const colors = {
|
||||
cpu: 'rgba(255, 99, 132, 1)',
|
||||
memory: 'rgba(54, 162, 235, 1)',
|
||||
disk: 'rgba(255, 206, 86, 1)',
|
||||
receive: 'rgba(0, 150, 255, 1)',
|
||||
transmit: 'rgba(255, 140, 0, 1)',
|
||||
};
|
||||
|
||||
const getDatasetConfig = (label) => {
|
||||
const color = colors[label.toLowerCase()] || 'rgba(0, 0, 0, 1)';
|
||||
return {
|
||||
label,
|
||||
borderColor: color,
|
||||
backgroundColor: color.replace('1)', '0.2)'),
|
||||
borderWidth: 2,
|
||||
pointRadius: 2,
|
||||
pointHoverRadius: 2,
|
||||
fill: true,
|
||||
data: [],
|
||||
};
|
||||
};
|
||||
|
||||
const data = {
|
||||
labels: [],
|
||||
datasets: $.isArray(datasets) ? datasets.map((dataset) => getDatasetConfig(dataset.label)) : [getDatasetConfig(datasets.label)],
|
||||
};
|
||||
|
||||
return new Chart(ctx, {
|
||||
type,
|
||||
data,
|
||||
options: {
|
||||
line: {
|
||||
spanGaps: false, // 设置为 false,不连接空值
|
||||
},
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: { position: legendPosition },
|
||||
title: {
|
||||
display: !!title,
|
||||
text: title,
|
||||
position: 'bottom',
|
||||
font: { size: 14, weight: 'bold' },
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
title: function (tooltipItems) {
|
||||
return new Date(tooltipItems[0].label).toLocaleString();
|
||||
},
|
||||
label: function (context) {
|
||||
let label = context.dataset.label || '';
|
||||
if (label) {
|
||||
label += ': ';
|
||||
}
|
||||
if (context.parsed.y !== null) {
|
||||
label += context.parsed.y.toFixed(2) + ' ' + unit;
|
||||
}
|
||||
return label;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
type: 'time',
|
||||
time: {
|
||||
unit: 'minute',
|
||||
displayFormats: {
|
||||
minute: 'HH:mm',
|
||||
},
|
||||
},
|
||||
ticks: {
|
||||
maxRotation: 0,
|
||||
autoSkip: true,
|
||||
maxTicksLimit: 10,
|
||||
},
|
||||
adapters: {
|
||||
date: {
|
||||
locale: 'en',
|
||||
},
|
||||
},
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
title: { display: true, text: yDisplayText, font: { weight: 'bold' } },
|
||||
},
|
||||
},
|
||||
elements: { line: { tension: 0.4 } },
|
||||
downsample: {
|
||||
enabled: true,
|
||||
threshold: 200,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const updateChart = (chart, newData, labels) => {
|
||||
if (!newData || !labels) {
|
||||
console.error('Invalid data or labels provided');
|
||||
return;
|
||||
}
|
||||
|
||||
if ($.isArray(newData) && $.isArray(newData[0])) {
|
||||
$.each(chart.data.datasets, (index, dataset) => {
|
||||
if (newData[index]) {
|
||||
dataset.data = newData[index].map((value, i) => ({ x: moment(labels[i]), y: value }));
|
||||
}
|
||||
});
|
||||
} else {
|
||||
chart.data.datasets[0].data = newData.map((value, i) => ({ x: moment(labels[i]), y: value }));
|
||||
}
|
||||
|
||||
chart.options.scales.x.min = moment(labels[0]);
|
||||
chart.options.scales.x.max = moment(labels[labels.length - 1]);
|
||||
chart.update();
|
||||
};
|
||||
|
||||
const updateCharts = (charts, metrics, startTs, endTs) => {
|
||||
console.log('Raw metrics data:', metrics);
|
||||
|
||||
const generateTimestamps = (start, end) => {
|
||||
const timestamps = [];
|
||||
let current = moment.unix(start);
|
||||
const endMoment = moment.unix(end);
|
||||
while (current.isSameOrBefore(endMoment)) {
|
||||
timestamps.push(current.toISOString());
|
||||
current.add(1, 'minute');
|
||||
}
|
||||
return timestamps;
|
||||
};
|
||||
|
||||
const timestamps = generateTimestamps(startTs, endTs);
|
||||
|
||||
const processData = (dataKey) => {
|
||||
const data = new Array(timestamps.length).fill(null);
|
||||
metrics.forEach((metric) => {
|
||||
const index = Math.floor((metric.timestamp - startTs) / 60);
|
||||
if (index >= 0 && index < data.length) {
|
||||
data[index] = metric[dataKey];
|
||||
}
|
||||
});
|
||||
return data;
|
||||
};
|
||||
|
||||
updateChart(charts.cpu, processData('cpu_usage'), timestamps);
|
||||
updateChart(charts.memory, processData('memory_usage'), timestamps);
|
||||
updateChart(charts.disk, processData('disk_usage'), timestamps);
|
||||
updateChart(
|
||||
charts.network,
|
||||
[
|
||||
processData('network_in').map((v) => (v === null ? null : v / BYTE_TO_MB)),
|
||||
processData('network_out').map((v) => (v === null ? null : v / BYTE_TO_MB)),
|
||||
],
|
||||
timestamps
|
||||
);
|
||||
};
|
||||
|
||||
const addLatestDataToCharts = (charts, latestMetric) => {
|
||||
console.log('Raw latestMetric data:', latestMetric);
|
||||
const timestamp = moment.unix(latestMetric.timestamp);
|
||||
|
||||
$.each(charts, (key, chart) => {
|
||||
// 检查是否已经有这个时间戳的数据
|
||||
const existingDataIndex = chart.data.labels.findIndex((label) => label.isSame(timestamp));
|
||||
|
||||
if (existingDataIndex === -1) {
|
||||
// 如果是新数据,添加到末尾
|
||||
chart.data.labels.push(timestamp);
|
||||
if (key === 'network') {
|
||||
chart.data.datasets[0].data.push({ x: timestamp, y: latestMetric.network_in / BYTE_TO_MB });
|
||||
chart.data.datasets[1].data.push({ x: timestamp, y: latestMetric.network_out / BYTE_TO_MB });
|
||||
} else {
|
||||
chart.data.datasets[0].data.push({ x: timestamp, y: latestMetric[`${key}_usage`] });
|
||||
}
|
||||
|
||||
// 更新x轴范围,但保持一定的时间窗口
|
||||
const timeWindow = moment.duration(30, 'minutes'); // 设置显示的时间窗口,例如30分钟
|
||||
const oldestAllowedTime = moment(timestamp).subtract(timeWindow);
|
||||
|
||||
chart.options.scales.x.min = oldestAllowedTime;
|
||||
chart.options.scales.x.max = timestamp;
|
||||
|
||||
// 开启图表的平移和缩放功能
|
||||
chart.options.plugins.zoom = {
|
||||
pan: {
|
||||
enabled: true,
|
||||
mode: 'x',
|
||||
},
|
||||
zoom: {
|
||||
wheel: {
|
||||
enabled: true,
|
||||
},
|
||||
pinch: {
|
||||
enabled: true,
|
||||
},
|
||||
mode: 'x',
|
||||
},
|
||||
};
|
||||
|
||||
chart.update();
|
||||
}
|
||||
// 如果数据已存在,我们不做任何操作,保持现有数据
|
||||
});
|
||||
};
|
||||
|
||||
// Chart initialization
|
||||
const initializeCharts = async () => {
|
||||
const metric = await fetchLatestMetric();
|
||||
if (!metric) return null;
|
||||
return {
|
||||
cpu: initChart('cpuChart', 'line', { label: 'CPU' }, 'top', 'Usage (%)', `CPU`, '%'),
|
||||
memory: initChart('memoryChart', 'line', { label: 'Memory' }, 'top', 'Usage (%)', `Memory`, '%'),
|
||||
disk: initChart('diskChart', 'line', { label: 'Disk' }, 'top', 'Usage (%)', `Disk`, '%'),
|
||||
network: initChart(
|
||||
'networkChart',
|
||||
'line',
|
||||
[{ label: 'Receive' }, { label: 'Transmit' }],
|
||||
'top',
|
||||
'Rate (MB/s)',
|
||||
'Network Rate',
|
||||
'MB/s'
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
// Date range functions
|
||||
const setupDateRangeDropdown = (charts) => {
|
||||
const $dateRangeDropdown = $('#dateRangeDropdown');
|
||||
const $dateRangeButton = $('#dateRangeButton');
|
||||
const $dateRangeText = $('#dateRangeText');
|
||||
const $dateRangeInput = $('#dateRangeInput');
|
||||
|
||||
$dateRangeDropdown.find('.dropdown-item[data-range]').on('click', function (e) {
|
||||
e.preventDefault();
|
||||
const range = $(this).data('range');
|
||||
const now = new Date();
|
||||
let start, end;
|
||||
switch (range) {
|
||||
case '30m':
|
||||
start = new Date(now - 30 * 60 * 1000);
|
||||
break;
|
||||
case '1h':
|
||||
start = new Date(now - 60 * 60 * 1000);
|
||||
break;
|
||||
case '3h':
|
||||
start = new Date(now - 3 * 60 * 60 * 1000);
|
||||
break;
|
||||
case '6h':
|
||||
start = new Date(now - 6 * 60 * 60 * 1000);
|
||||
break;
|
||||
case '12h':
|
||||
start = new Date(now - 12 * 60 * 60 * 1000);
|
||||
break;
|
||||
case '24h':
|
||||
start = new Date(now - 24 * 60 * 60 * 1000);
|
||||
break;
|
||||
case '7d':
|
||||
start = new Date(now - 7 * 24 * 60 * 60 * 1000);
|
||||
break;
|
||||
}
|
||||
end = now;
|
||||
|
||||
const startTs = Math.floor(start.getTime() / 1000);
|
||||
const endTs = Math.floor(end.getTime() / 1000);
|
||||
fetchDataForRange(charts, startTs, endTs);
|
||||
$dateRangeText.text($(this).text());
|
||||
$dateRangeDropdown.removeClass('is-active');
|
||||
});
|
||||
|
||||
$dateRangeButton.on('click', (event) => {
|
||||
event.stopPropagation();
|
||||
$dateRangeDropdown.toggleClass('is-active');
|
||||
});
|
||||
|
||||
$(document).on('click', (event) => {
|
||||
if (!$dateRangeDropdown.has(event.target).length) {
|
||||
$dateRangeDropdown.removeClass('is-active');
|
||||
}
|
||||
});
|
||||
|
||||
const picker = flatpickr($dateRangeInput[0], {
|
||||
mode: 'range',
|
||||
enableTime: true,
|
||||
dateFormat: 'Y-m-d H:i',
|
||||
onChange: function (selectedDates) {
|
||||
if (selectedDates.length === 2) {
|
||||
const startTs = Math.floor(selectedDates[0].getTime() / 1000);
|
||||
const endTs = Math.floor(selectedDates[1].getTime() / 1000);
|
||||
fetchDataForRange(charts, startTs, endTs);
|
||||
|
||||
const formattedStart = selectedDates[0].toLocaleString();
|
||||
const formattedEnd = selectedDates[1].toLocaleString();
|
||||
$dateRangeText.text(`${formattedStart} - ${formattedEnd}`);
|
||||
|
||||
// 关闭下拉菜单
|
||||
$dateRangeDropdown.removeClass('is-active');
|
||||
}
|
||||
},
|
||||
onClose: function () {
|
||||
// 确保在日期选择器关闭时也关闭下拉菜单
|
||||
$dateRangeDropdown.removeClass('is-active');
|
||||
},
|
||||
});
|
||||
|
||||
// 防止点击日期选择器时关闭下拉菜单
|
||||
$dateRangeInput.on('click', (event) => {
|
||||
event.stopPropagation();
|
||||
});
|
||||
};
|
||||
|
||||
const fetchDataForRange = async (charts, startTs, endTs) => {
|
||||
const metrics = await fetchMetrics(startTs, endTs);
|
||||
if (metrics) {
|
||||
console.log('Raw metrics data:', metrics);
|
||||
updateCharts(charts, metrics, startTs, endTs);
|
||||
}
|
||||
};
|
||||
|
||||
// Auto refresh functions
|
||||
const setupAutoRefresh = (charts) => {
|
||||
let autoRefreshInterval;
|
||||
let isAutoRefreshing = false;
|
||||
$('#refreshButton').click(function () {
|
||||
if (isAutoRefreshing) {
|
||||
clearInterval(autoRefreshInterval);
|
||||
$(this).removeClass('is-info');
|
||||
$(this).find('span:last').text('Auto Refresh');
|
||||
isAutoRefreshing = false;
|
||||
} else {
|
||||
$(this).addClass('is-info');
|
||||
$(this).find('span:last').text('Stop Refresh');
|
||||
isAutoRefreshing = true;
|
||||
refreshData(charts);
|
||||
autoRefreshInterval = setInterval(() => refreshData(charts), 5000);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const refreshData = async (charts) => {
|
||||
const latestMetric = await fetchLatestMetric();
|
||||
if (latestMetric) {
|
||||
addLatestDataToCharts(charts, latestMetric);
|
||||
}
|
||||
};
|
||||
|
||||
// Main initialization function
|
||||
const init = async () => {
|
||||
const charts = await initializeCharts();
|
||||
if (charts) {
|
||||
setupDateRangeDropdown(charts);
|
||||
setupAutoRefresh(charts);
|
||||
}
|
||||
};
|
||||
|
||||
// Public API
|
||||
return {
|
||||
init: init,
|
||||
};
|
||||
})();
|
||||
|
||||
// Initialize when the DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', MetricsModule.init);
|
404
echo/internal/web/js/node_metrics.js
Normal file
404
echo/internal/web/js/node_metrics.js
Normal file
@@ -0,0 +1,404 @@
|
||||
const Config = {
|
||||
API_BASE_URL: '/api/v1',
|
||||
NODE_METRICS_PATH: '/node_metrics/',
|
||||
BYTE_TO_MB: 1024 * 1024,
|
||||
CHART_COLORS: {
|
||||
cpu: 'rgba(255, 99, 132, 1)',
|
||||
memory: 'rgba(54, 162, 235, 1)',
|
||||
disk: 'rgba(255, 206, 86, 1)',
|
||||
receive: 'rgba(0, 150, 255, 1)',
|
||||
transmit: 'rgba(255, 140, 0, 1)',
|
||||
},
|
||||
TIME_WINDOW: 30, // minutes
|
||||
AUTO_REFRESH_INTERVAL: 5000, // milliseconds
|
||||
};
|
||||
|
||||
class ApiService {
|
||||
static async fetchData(path, params = {}) {
|
||||
const url = new URL(Config.API_BASE_URL + path, window.location.origin);
|
||||
Object.entries(params).forEach(([key, value]) => url.searchParams.append(key, value));
|
||||
try {
|
||||
const response = await fetch(url.toString());
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static async fetchLatestMetric() {
|
||||
const data = await this.fetchData(Config.NODE_METRICS_PATH, { latest: true });
|
||||
return data?.data[0];
|
||||
}
|
||||
|
||||
static async fetchMetrics(startTs, endTs) {
|
||||
const data = await this.fetchData(Config.NODE_METRICS_PATH, { start_ts: startTs, end_ts: endTs });
|
||||
return data?.data;
|
||||
}
|
||||
}
|
||||
|
||||
class ChartManager {
|
||||
constructor() {
|
||||
this.charts = {};
|
||||
}
|
||||
|
||||
initializeCharts() {
|
||||
this.charts = {
|
||||
cpu: this.initChart('cpuChart', 'line', { label: 'CPU' }, 'top', 'Usage (%)', 'CPU', '%'),
|
||||
memory: this.initChart('memoryChart', 'line', { label: 'Memory' }, 'top', 'Usage (%)', 'Memory', '%'),
|
||||
disk: this.initChart('diskChart', 'line', { label: 'Disk' }, 'top', 'Usage (%)', 'Disk', '%'),
|
||||
network: this.initChart(
|
||||
'networkChart',
|
||||
'line',
|
||||
[{ label: 'Receive' }, { label: 'Transmit' }],
|
||||
'top',
|
||||
'Rate (MB/s)',
|
||||
'Network Rate',
|
||||
'MB/s'
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
initChart(canvasId, type, datasets, legendPosition, yDisplayText, title, unit) {
|
||||
const ctx = $(`#${canvasId}`)[0].getContext('2d');
|
||||
const data = {
|
||||
labels: [],
|
||||
datasets: Array.isArray(datasets)
|
||||
? datasets.map((dataset) => this.getDatasetConfig(dataset.label))
|
||||
: [this.getDatasetConfig(datasets.label)],
|
||||
};
|
||||
|
||||
return new Chart(ctx, {
|
||||
type,
|
||||
data,
|
||||
options: this.getChartOptions(legendPosition, yDisplayText, title, unit),
|
||||
});
|
||||
}
|
||||
|
||||
getDatasetConfig(label) {
|
||||
const color = Config.CHART_COLORS[label.toLowerCase()] || 'rgba(0, 0, 0, 1)';
|
||||
return {
|
||||
label,
|
||||
borderColor: color,
|
||||
backgroundColor: color.replace('1)', '0.2)'),
|
||||
borderWidth: 2,
|
||||
pointRadius: 2,
|
||||
pointHoverRadius: 2,
|
||||
fill: true,
|
||||
data: [],
|
||||
};
|
||||
}
|
||||
|
||||
getChartOptions(legendPosition, yDisplayText, title, unit) {
|
||||
return {
|
||||
line: { spanGaps: false },
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: { position: legendPosition },
|
||||
title: {
|
||||
display: !!title,
|
||||
text: title,
|
||||
position: 'bottom',
|
||||
font: { size: 14, weight: 'bold' },
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
title: (tooltipItems) => new Date(tooltipItems[0].label).toLocaleString(),
|
||||
label: (context) => {
|
||||
let label = context.dataset.label || '';
|
||||
if (label) {
|
||||
label += ': ';
|
||||
}
|
||||
if (context.parsed.y !== null) {
|
||||
label += context.parsed.y.toFixed(2) + ' ' + unit;
|
||||
}
|
||||
return label;
|
||||
},
|
||||
},
|
||||
},
|
||||
zoom: {
|
||||
pan: { enabled: true, mode: 'x' },
|
||||
zoom: {
|
||||
wheel: { enabled: true },
|
||||
pinch: { enabled: true },
|
||||
mode: 'x',
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
type: 'time',
|
||||
time: {
|
||||
unit: 'minute',
|
||||
displayFormats: { minute: 'HH:mm' },
|
||||
},
|
||||
ticks: {
|
||||
maxRotation: 0,
|
||||
autoSkip: true,
|
||||
maxTicksLimit: 10,
|
||||
},
|
||||
adapters: {
|
||||
date: { locale: 'en' },
|
||||
},
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
title: { display: true, text: yDisplayText, font: { weight: 'bold' } },
|
||||
},
|
||||
},
|
||||
elements: { line: { tension: 0.4 } },
|
||||
downsample: {
|
||||
enabled: true,
|
||||
threshold: 200,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
updateCharts(metrics, startTs, endTs) {
|
||||
const timestamps = this.generateTimestamps(startTs, endTs);
|
||||
const processData = (dataKey) => {
|
||||
const data = new Array(timestamps.length).fill(null);
|
||||
metrics.forEach((metric) => {
|
||||
const index = Math.floor((metric.timestamp - startTs) / 60);
|
||||
if (index >= 0 && index < data.length) {
|
||||
data[index] = metric[dataKey];
|
||||
}
|
||||
});
|
||||
return data;
|
||||
};
|
||||
|
||||
this.updateChart(this.charts.cpu, processData('cpu_usage'), timestamps);
|
||||
this.updateChart(this.charts.memory, processData('memory_usage'), timestamps);
|
||||
this.updateChart(this.charts.disk, processData('disk_usage'), timestamps);
|
||||
this.updateChart(
|
||||
this.charts.network,
|
||||
[
|
||||
processData('network_in').map((v) => (v === null ? null : v / Config.BYTE_TO_MB)),
|
||||
processData('network_out').map((v) => (v === null ? null : v / Config.BYTE_TO_MB)),
|
||||
],
|
||||
timestamps
|
||||
);
|
||||
}
|
||||
|
||||
updateChart(chart, newData, labels) {
|
||||
if (!newData || !labels) {
|
||||
console.error('Invalid data or labels provided');
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(newData) && Array.isArray(newData[0])) {
|
||||
chart.data.datasets.forEach((dataset, index) => {
|
||||
if (newData[index]) {
|
||||
dataset.data = newData[index].map((value, i) => ({ x: moment(labels[i]), y: value }));
|
||||
}
|
||||
});
|
||||
} else {
|
||||
chart.data.datasets[0].data = newData.map((value, i) => ({ x: moment(labels[i]), y: value }));
|
||||
}
|
||||
|
||||
chart.options.scales.x.min = moment(labels[0]);
|
||||
chart.options.scales.x.max = moment(labels[labels.length - 1]);
|
||||
chart.update();
|
||||
}
|
||||
|
||||
addLatestDataToCharts(latestMetric) {
|
||||
const timestamp = moment.unix(latestMetric.timestamp);
|
||||
|
||||
Object.entries(this.charts).forEach(([key, chart]) => {
|
||||
const existingDataIndex = chart.data.labels.findIndex((label) => label.isSame(timestamp));
|
||||
|
||||
if (existingDataIndex === -1) {
|
||||
chart.data.labels.push(timestamp);
|
||||
if (key === 'network') {
|
||||
chart.data.datasets[0].data.push({ x: timestamp, y: latestMetric.network_in / Config.BYTE_TO_MB });
|
||||
chart.data.datasets[1].data.push({ x: timestamp, y: latestMetric.network_out / Config.BYTE_TO_MB });
|
||||
} else {
|
||||
chart.data.datasets[0].data.push({ x: timestamp, y: latestMetric[`${key}_usage`] });
|
||||
}
|
||||
|
||||
const timeWindow = moment.duration(Config.TIME_WINDOW, 'minutes');
|
||||
const oldestAllowedTime = moment(timestamp).subtract(timeWindow);
|
||||
|
||||
chart.options.scales.x.min = oldestAllowedTime;
|
||||
chart.options.scales.x.max = timestamp;
|
||||
|
||||
chart.update();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
generateTimestamps(start, end) {
|
||||
const timestamps = [];
|
||||
let current = moment.unix(start);
|
||||
const endMoment = moment.unix(end);
|
||||
while (current.isSameOrBefore(endMoment)) {
|
||||
timestamps.push(current.toISOString());
|
||||
current.add(1, 'minute');
|
||||
}
|
||||
return timestamps;
|
||||
}
|
||||
}
|
||||
|
||||
class DateRangeManager {
|
||||
constructor(chartManager) {
|
||||
this.chartManager = chartManager;
|
||||
this.$dateRangeDropdown = $('#dateRangeDropdown');
|
||||
this.$dateRangeButton = $('#dateRangeButton');
|
||||
this.$dateRangeText = $('#dateRangeText');
|
||||
this.$dateRangeInput = $('#dateRangeInput');
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
this.$dateRangeDropdown.find('.dropdown-item[data-range]').on('click', (e) => this.handlePresetDateRange(e));
|
||||
this.$dateRangeButton.on('click', (event) => this.toggleDropdown(event));
|
||||
$(document).on('click', (event) => this.closeDropdownOnOutsideClick(event));
|
||||
this.initializeDatePicker();
|
||||
}
|
||||
|
||||
handlePresetDateRange(e) {
|
||||
e.preventDefault();
|
||||
const range = $(e.currentTarget).data('range');
|
||||
const [start, end] = this.calculateDateRange(range);
|
||||
this.fetchAndUpdateCharts(start, end);
|
||||
this.$dateRangeText.text($(e.currentTarget).text());
|
||||
this.$dateRangeDropdown.removeClass('is-active');
|
||||
}
|
||||
|
||||
calculateDateRange(range) {
|
||||
const now = new Date();
|
||||
let start;
|
||||
switch (range) {
|
||||
case '30m':
|
||||
start = new Date(now - 30 * 60 * 1000);
|
||||
break;
|
||||
case '1h':
|
||||
start = new Date(now - 60 * 60 * 1000);
|
||||
break;
|
||||
case '3h':
|
||||
start = new Date(now - 3 * 60 * 60 * 1000);
|
||||
break;
|
||||
case '6h':
|
||||
start = new Date(now - 6 * 60 * 60 * 1000);
|
||||
break;
|
||||
case '12h':
|
||||
start = new Date(now - 12 * 60 * 60 * 1000);
|
||||
break;
|
||||
case '24h':
|
||||
start = new Date(now - 24 * 60 * 60 * 1000);
|
||||
break;
|
||||
case '7d':
|
||||
start = new Date(now - 7 * 24 * 60 * 60 * 1000);
|
||||
break;
|
||||
}
|
||||
return [start, now];
|
||||
}
|
||||
|
||||
toggleDropdown(event) {
|
||||
event.stopPropagation();
|
||||
this.$dateRangeDropdown.toggleClass('is-active');
|
||||
}
|
||||
|
||||
closeDropdownOnOutsideClick(event) {
|
||||
if (!this.$dateRangeDropdown.has(event.target).length) {
|
||||
this.$dateRangeDropdown.removeClass('is-active');
|
||||
}
|
||||
}
|
||||
|
||||
initializeDatePicker() {
|
||||
flatpickr(this.$dateRangeInput[0], {
|
||||
mode: 'range',
|
||||
enableTime: true,
|
||||
dateFormat: 'Y-m-d H:i',
|
||||
onChange: (selectedDates) => this.handleDatePickerChange(selectedDates),
|
||||
onClose: () => this.$dateRangeDropdown.removeClass('is-active'),
|
||||
});
|
||||
|
||||
this.$dateRangeInput.on('click', (event) => event.stopPropagation());
|
||||
}
|
||||
|
||||
handleDatePickerChange(selectedDates) {
|
||||
if (selectedDates.length === 2) {
|
||||
const [start, end] = selectedDates;
|
||||
this.fetchAndUpdateCharts(start, end);
|
||||
const formattedStart = start.toLocaleString();
|
||||
const formattedEnd = end.toLocaleString();
|
||||
this.$dateRangeText.text(`${formattedStart} - ${formattedEnd}`);
|
||||
this.$dateRangeDropdown.removeClass('is-active');
|
||||
}
|
||||
}
|
||||
|
||||
async fetchAndUpdateCharts(start, end) {
|
||||
const startTs = Math.floor(start.getTime() / 1000);
|
||||
const endTs = Math.floor(end.getTime() / 1000);
|
||||
const metrics = await ApiService.fetchMetrics(startTs, endTs);
|
||||
if (metrics) {
|
||||
this.chartManager.updateCharts(metrics, startTs, endTs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class AutoRefreshManager {
|
||||
constructor(chartManager) {
|
||||
this.chartManager = chartManager;
|
||||
this.autoRefreshInterval = null;
|
||||
this.isAutoRefreshing = false;
|
||||
this.$refreshButton = $('#refreshButton');
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
this.$refreshButton.click(() => this.toggleAutoRefresh());
|
||||
}
|
||||
|
||||
toggleAutoRefresh() {
|
||||
if (this.isAutoRefreshing) {
|
||||
this.stopAutoRefresh();
|
||||
} else {
|
||||
this.startAutoRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
startAutoRefresh() {
|
||||
this.isAutoRefreshing = true;
|
||||
this.$refreshButton.addClass('is-info');
|
||||
this.$refreshButton.find('span:last').text('Stop Refresh');
|
||||
this.refreshData();
|
||||
this.autoRefreshInterval = setInterval(() => this.refreshData(), Config.AUTO_REFRESH_INTERVAL);
|
||||
}
|
||||
|
||||
stopAutoRefresh() {
|
||||
this.isAutoRefreshing = false;
|
||||
clearInterval(this.autoRefreshInterval);
|
||||
this.$refreshButton.removeClass('is-info');
|
||||
this.$refreshButton.find('span:last').text('Auto Refresh');
|
||||
}
|
||||
|
||||
async refreshData() {
|
||||
const latestMetric = await ApiService.fetchLatestMetric();
|
||||
if (latestMetric) {
|
||||
this.chartManager.addLatestDataToCharts(latestMetric);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class MetricsModule {
|
||||
constructor() {
|
||||
this.chartManager = new ChartManager();
|
||||
this.dateRangeManager = new DateRangeManager(this.chartManager);
|
||||
this.autoRefreshManager = new AutoRefreshManager(this.chartManager);
|
||||
}
|
||||
|
||||
async init() {
|
||||
this.chartManager.initializeCharts();
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize when the DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const metricsModule = new MetricsModule();
|
||||
metricsModule.init();
|
||||
});
|
402
echo/internal/web/js/rule_metrics.js
Normal file
402
echo/internal/web/js/rule_metrics.js
Normal file
@@ -0,0 +1,402 @@
|
||||
const Config = {
|
||||
API_BASE_URL: '/api/v1',
|
||||
RULE_METRICS_PATH: '/rule_metrics/',
|
||||
BYTE_TO_MB: 1024 * 1024,
|
||||
CHART_COLORS: {
|
||||
connectionCount: 'rgba(255, 99, 132, 1)',
|
||||
handshakeDuration: 'rgba(54, 162, 235, 1)',
|
||||
pingLatency: 'rgba(255, 206, 86, 1)',
|
||||
networkTransmitBytes: 'rgba(75, 192, 192, 1)',
|
||||
},
|
||||
TIME_WINDOW: 30, // minutes
|
||||
AUTO_REFRESH_INTERVAL: 5000, // milliseconds
|
||||
};
|
||||
|
||||
class ApiService {
|
||||
static async fetchData(path, params = {}) {
|
||||
const url = new URL(Config.API_BASE_URL + path, window.location.origin);
|
||||
Object.entries(params).forEach(([key, value]) => url.searchParams.append(key, value));
|
||||
try {
|
||||
const response = await fetch(url.toString());
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static async fetchRuleMetrics(startTs, endTs, label = '', remote = '') {
|
||||
const params = { start_ts: startTs, end_ts: endTs };
|
||||
if (label) params.label = label;
|
||||
if (remote) params.remote = remote;
|
||||
return await this.fetchData(Config.RULE_METRICS_PATH, params);
|
||||
}
|
||||
|
||||
static async fetchConfig() {
|
||||
return await this.fetchData('/config/');
|
||||
}
|
||||
static async fetchLabelsAndRemotes() {
|
||||
const config = await this.fetchConfig();
|
||||
if (!config || !config.relay_configs) {
|
||||
return { labels: [], remotes: [] };
|
||||
}
|
||||
|
||||
const labels = new Set();
|
||||
const remotes = new Set();
|
||||
|
||||
config.relay_configs.forEach((relayConfig) => {
|
||||
if (relayConfig.label) labels.add(relayConfig.label);
|
||||
if (relayConfig.remotes) {
|
||||
relayConfig.remotes.forEach((remote) => remotes.add(remote));
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
labels: Array.from(labels),
|
||||
remotes: Array.from(remotes),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class ChartManager {
|
||||
constructor() {
|
||||
this.charts = {};
|
||||
}
|
||||
|
||||
initializeCharts() {
|
||||
this.charts = {
|
||||
connectionCount: this.initChart('connectionCountChart', 'line', 'Connection Count', 'Count'),
|
||||
handshakeDuration: this.initChart('handshakeDurationChart', 'line', 'Handshake Duration', 'ms'),
|
||||
pingLatency: this.initChart('pingLatencyChart', 'line', 'Ping Latency', 'ms'),
|
||||
networkTransmitBytes: this.initChart('networkTransmitBytesChart', 'line', 'Network Transmit', 'MB'),
|
||||
};
|
||||
}
|
||||
|
||||
initChart(canvasId, type, title, unit) {
|
||||
const ctx = $(`#${canvasId}`)[0].getContext('2d');
|
||||
const color = Config.CHART_COLORS[canvasId.replace('Chart', '')];
|
||||
|
||||
return new Chart(ctx, {
|
||||
type: type,
|
||||
data: {
|
||||
labels: [],
|
||||
datasets: [
|
||||
{
|
||||
label: title,
|
||||
borderColor: color,
|
||||
backgroundColor: color.replace('1)', '0.2)'),
|
||||
borderWidth: 2,
|
||||
data: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
options: this.getChartOptions(title, unit),
|
||||
});
|
||||
}
|
||||
|
||||
getChartOptions(title, unit) {
|
||||
return {
|
||||
responsive: true,
|
||||
plugins: {
|
||||
title: {
|
||||
display: true,
|
||||
text: title,
|
||||
font: { size: 16, weight: 'bold' },
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (context) => `${context.dataset.label}: ${context.parsed.y.toFixed(2)} ${unit}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
type: 'time',
|
||||
time: { unit: 'minute', displayFormats: { minute: 'HH:mm' } },
|
||||
title: { display: true, text: 'Time' },
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
title: { display: true, text: unit },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
fillMissingDataPoints(data, startTime, endTime) {
|
||||
const filledData = [];
|
||||
let currentTime = new Date(startTime);
|
||||
const endTimeDate = new Date(endTime);
|
||||
|
||||
while (currentTime <= endTimeDate) {
|
||||
const existingPoint = data.find((point) => Math.abs(point.x.getTime() - currentTime.getTime()) < 60000);
|
||||
if (existingPoint) {
|
||||
filledData.push(existingPoint);
|
||||
} else {
|
||||
filledData.push({ x: new Date(currentTime), y: null });
|
||||
}
|
||||
currentTime.setMinutes(currentTime.getMinutes() + 1);
|
||||
}
|
||||
|
||||
return filledData;
|
||||
}
|
||||
|
||||
updateCharts(metrics, startTime, endTime) {
|
||||
// 检查metrics是否为null或undefined
|
||||
if (!metrics) {
|
||||
// 如果为null,则更新所有图表为空
|
||||
Object.values(this.charts).forEach((chart) => {
|
||||
chart.data.datasets = [
|
||||
{
|
||||
label: 'No Data',
|
||||
data: [],
|
||||
},
|
||||
];
|
||||
chart.update();
|
||||
});
|
||||
return;
|
||||
}
|
||||
// 首先按时间正序排列数据
|
||||
metrics.sort((a, b) => a.timestamp - b.timestamp);
|
||||
// 按 label-remote 分组
|
||||
const groupedMetrics = this.groupMetricsByLabelRemote(metrics);
|
||||
console.log('groupedMetrics', groupedMetrics);
|
||||
|
||||
// 预处理所有指标的数据
|
||||
const processedData = {};
|
||||
|
||||
Object.keys(this.charts).forEach((key) => {
|
||||
processedData[key] = groupedMetrics.map((group, index) => {
|
||||
const data = group.metrics.map((m) => ({
|
||||
x: new Date(m.timestamp * 1000),
|
||||
y: this.getMetricValue(key, m),
|
||||
}));
|
||||
const filledData = this.fillMissingDataPoints(data, startTime, endTime);
|
||||
return {
|
||||
label: `${group.label} - ${group.remote}`,
|
||||
borderColor: this.getColor(index),
|
||||
backgroundColor: this.getColor(index, 0.2),
|
||||
borderWidth: 2,
|
||||
data: filledData,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
// 更新每个图表
|
||||
Object.entries(this.charts).forEach(([key, chart]) => {
|
||||
chart.data.datasets = processedData[key];
|
||||
chart.update();
|
||||
});
|
||||
}
|
||||
|
||||
groupMetricsByLabelRemote(metrics) {
|
||||
const groups = {};
|
||||
metrics.forEach((metric) => {
|
||||
const key = `${metric.label}-${metric.remote}`;
|
||||
if (!groups[key]) {
|
||||
groups[key] = { label: metric.label, remote: metric.remote, metrics: [] };
|
||||
}
|
||||
groups[key].metrics.push(metric);
|
||||
});
|
||||
return Object.values(groups);
|
||||
}
|
||||
|
||||
getMetricValue(metricType, metric) {
|
||||
switch (metricType) {
|
||||
case 'connectionCount':
|
||||
return metric.tcp_connection_count + metric.udp_connection_count;
|
||||
case 'handshakeDuration':
|
||||
return Math.max(metric.tcp_handshake_duration, metric.udp_handshake_duration);
|
||||
case 'pingLatency':
|
||||
return metric.ping_latency;
|
||||
case 'networkTransmitBytes':
|
||||
return (metric.tcp_network_transmit_bytes + metric.udp_network_transmit_bytes) / Config.BYTE_TO_MB;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
getColor(index, alpha = 1) {
|
||||
const colors = [
|
||||
`rgba(255, 99, 132, ${alpha})`,
|
||||
`rgba(54, 162, 235, ${alpha})`,
|
||||
`rgba(255, 206, 86, ${alpha})`,
|
||||
`rgba(75, 192, 192, ${alpha})`,
|
||||
`rgba(153, 102, 255, ${alpha})`,
|
||||
`rgba(255, 159, 64, ${alpha})`,
|
||||
];
|
||||
return colors[index % colors.length];
|
||||
}
|
||||
}
|
||||
|
||||
class FilterManager {
|
||||
constructor(chartManager, dateRangeManager) {
|
||||
this.chartManager = chartManager;
|
||||
this.dateRangeManager = dateRangeManager;
|
||||
this.$labelFilter = $('#labelFilter');
|
||||
this.$remoteFilter = $('#remoteFilter');
|
||||
this.relayConfigs = [];
|
||||
this.currentStartDate = null;
|
||||
this.currentEndDate = null;
|
||||
this.setupEventListeners();
|
||||
this.loadFilters();
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
this.$labelFilter.on('change', () => this.onLabelChange());
|
||||
this.$remoteFilter.on('change', () => this.applyFilters());
|
||||
}
|
||||
|
||||
async loadFilters() {
|
||||
const config = await ApiService.fetchConfig();
|
||||
if (config && config.relay_configs) {
|
||||
this.relayConfigs = config.relay_configs;
|
||||
this.populateLabelFilter();
|
||||
this.onLabelChange(); // Initialize remotes for the first label
|
||||
}
|
||||
}
|
||||
|
||||
populateLabelFilter() {
|
||||
const labels = [...new Set(this.relayConfigs.map((config) => config.label))];
|
||||
this.populateFilter(this.$labelFilter, labels);
|
||||
}
|
||||
|
||||
onLabelChange() {
|
||||
const selectedLabel = this.$labelFilter.val();
|
||||
const remotes = this.getRemotesForLabel(selectedLabel);
|
||||
this.populateFilter(this.$remoteFilter, remotes);
|
||||
this.applyFilters();
|
||||
}
|
||||
|
||||
getRemotesForLabel(label) {
|
||||
const config = this.relayConfigs.find((c) => c.label === label);
|
||||
return config ? config.remotes : [];
|
||||
}
|
||||
|
||||
populateFilter($select, options) {
|
||||
$select.empty().append($('<option>', { value: '', text: 'All' }));
|
||||
options.forEach((option) => {
|
||||
$select.append($('<option>', { value: option, text: option }));
|
||||
});
|
||||
}
|
||||
|
||||
async applyFilters() {
|
||||
const label = this.$labelFilter.val();
|
||||
const remote = this.$remoteFilter.val();
|
||||
|
||||
// 使用当前保存的日期范围,如果没有则使用默认的30分钟
|
||||
const endDate = this.currentEndDate || new Date();
|
||||
const startDate = this.currentStartDate || new Date(endDate - Config.TIME_WINDOW * 60 * 1000);
|
||||
|
||||
const metrics = await ApiService.fetchRuleMetrics(
|
||||
Math.floor(startDate.getTime() / 1000),
|
||||
Math.floor(endDate.getTime() / 1000),
|
||||
label,
|
||||
remote
|
||||
);
|
||||
|
||||
this.chartManager.updateCharts(metrics.data, startDate, endDate);
|
||||
}
|
||||
|
||||
setDateRange(start, end) {
|
||||
this.currentStartDate = start;
|
||||
this.currentEndDate = end;
|
||||
}
|
||||
}
|
||||
|
||||
class DateRangeManager {
|
||||
constructor(chartManager, filterManager) {
|
||||
this.chartManager = chartManager;
|
||||
this.filterManager = filterManager;
|
||||
this.$dateRangeDropdown = $('#dateRangeDropdown');
|
||||
this.$dateRangeButton = $('#dateRangeButton');
|
||||
this.$dateRangeText = $('#dateRangeText');
|
||||
this.$dateRangeInput = $('#dateRangeInput');
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
this.$dateRangeDropdown.find('.dropdown-item[data-range]').on('click', (e) => this.handlePresetDateRange(e));
|
||||
this.$dateRangeButton.on('click', () => this.$dateRangeDropdown.toggleClass('is-active'));
|
||||
$(document).on('click', (e) => {
|
||||
if (!this.$dateRangeDropdown.has(e.target).length) {
|
||||
this.$dateRangeDropdown.removeClass('is-active');
|
||||
}
|
||||
});
|
||||
this.initializeDatePicker();
|
||||
}
|
||||
|
||||
handlePresetDateRange(e) {
|
||||
e.preventDefault();
|
||||
const range = $(e.currentTarget).data('range');
|
||||
const [start, end] = this.calculateDateRange(range);
|
||||
this.fetchAndUpdateCharts(start, end);
|
||||
this.$dateRangeText.text($(e.currentTarget).text());
|
||||
this.$dateRangeDropdown.removeClass('is-active');
|
||||
}
|
||||
|
||||
calculateDateRange(range) {
|
||||
const now = new Date();
|
||||
const start = new Date(now - this.getMillisecondsFromRange(range));
|
||||
return [start, now];
|
||||
}
|
||||
|
||||
getMillisecondsFromRange(range) {
|
||||
const rangeMap = {
|
||||
'30m': 30 * 60 * 1000,
|
||||
'1h': 60 * 60 * 1000,
|
||||
'3h': 3 * 60 * 60 * 1000,
|
||||
'6h': 6 * 60 * 60 * 1000,
|
||||
'12h': 12 * 60 * 60 * 1000,
|
||||
'24h': 24 * 60 * 60 * 1000,
|
||||
'7d': 7 * 24 * 60 * 60 * 1000,
|
||||
};
|
||||
return rangeMap[range] || 30 * 60 * 1000; // Default to 30 minutes
|
||||
}
|
||||
|
||||
initializeDatePicker() {
|
||||
flatpickr(this.$dateRangeInput[0], {
|
||||
mode: 'range',
|
||||
enableTime: true,
|
||||
dateFormat: 'Y-m-d H:i',
|
||||
onChange: (selectedDates) => this.handleDatePickerChange(selectedDates),
|
||||
});
|
||||
}
|
||||
|
||||
handleDatePickerChange(selectedDates) {
|
||||
if (selectedDates.length === 2) {
|
||||
const [start, end] = selectedDates;
|
||||
this.fetchAndUpdateCharts(start, end);
|
||||
this.$dateRangeText.text(`${start.toLocaleString()} - ${end.toLocaleString()}`);
|
||||
this.$dateRangeDropdown.removeClass('is-active');
|
||||
}
|
||||
}
|
||||
|
||||
async fetchAndUpdateCharts(start, end) {
|
||||
this.filterManager.setDateRange(start, end);
|
||||
await this.filterManager.applyFilters();
|
||||
}
|
||||
}
|
||||
|
||||
class RuleMetricsModule {
|
||||
constructor() {
|
||||
this.chartManager = new ChartManager();
|
||||
this.filterManager = new FilterManager(this.chartManager);
|
||||
this.dateRangeManager = new DateRangeManager(this.chartManager, this.filterManager);
|
||||
this.filterManager.dateRangeManager = this.dateRangeManager;
|
||||
}
|
||||
|
||||
init() {
|
||||
this.chartManager.initializeCharts();
|
||||
this.filterManager.applyFilters();
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize when the DOM is ready
|
||||
$(document).ready(() => {
|
||||
const ruleMetricsModule = new RuleMetricsModule();
|
||||
ruleMetricsModule.init();
|
||||
});
|
Reference in New Issue
Block a user