mirror of
https://github.com/bolucat/Archive.git
synced 2025-12-24 13:28:37 +08:00
Update On Mon Sep 2 20:34:11 CEST 2024
This commit is contained in:
@@ -64,12 +64,11 @@ type relayConnImpl struct {
|
||||
EndTime time.Time `json:"end_time,omitempty"`
|
||||
|
||||
// options set those fields
|
||||
l *zap.SugaredLogger
|
||||
remote *lb.Node
|
||||
HandshakeDuration time.Duration
|
||||
RelayLabel string `json:"relay_label"`
|
||||
ConnType string `json:"conn_type"`
|
||||
Options *conf.Options
|
||||
l *zap.SugaredLogger
|
||||
remote *lb.Node
|
||||
RelayLabel string `json:"relay_label"`
|
||||
ConnType string `json:"conn_type"`
|
||||
Options *conf.Options
|
||||
}
|
||||
|
||||
func WithRelayLabel(relayLabel string) RelayConnOption {
|
||||
@@ -78,12 +77,6 @@ func WithRelayLabel(relayLabel string) RelayConnOption {
|
||||
}
|
||||
}
|
||||
|
||||
func WithHandshakeDuration(duration time.Duration) RelayConnOption {
|
||||
return func(rci *relayConnImpl) {
|
||||
rci.HandshakeDuration = duration
|
||||
}
|
||||
}
|
||||
|
||||
func WithConnType(connType string) RelayConnOption {
|
||||
return func(rci *relayConnImpl) {
|
||||
rci.ConnType = connType
|
||||
@@ -93,6 +86,7 @@ func WithConnType(connType string) RelayConnOption {
|
||||
func WithRemote(remote *lb.Node) RelayConnOption {
|
||||
return func(rci *relayConnImpl) {
|
||||
rci.remote = remote
|
||||
rci.Stats.HandShakeLatency = remote.HandShakeDuration
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -44,9 +44,6 @@ func newBaseRelayServer(cfg *conf.Config, cmgr cmgr.Cmgr) (*BaseRelayServer, err
|
||||
}
|
||||
|
||||
func (b *BaseRelayServer) RelayTCPConn(ctx context.Context, c net.Conn, remote *lb.Node) error {
|
||||
if remote == nil {
|
||||
remote = b.remotes.Next().Clone()
|
||||
}
|
||||
metrics.CurConnectionCount.WithLabelValues(remote.Label, metrics.METRIC_CONN_TYPE_TCP).Inc()
|
||||
defer metrics.CurConnectionCount.WithLabelValues(remote.Label, metrics.METRIC_CONN_TYPE_TCP).Dec()
|
||||
|
||||
@@ -66,15 +63,11 @@ func (b *BaseRelayServer) RelayTCPConn(ctx context.Context, c net.Conn, remote *
|
||||
return fmt.Errorf("handshake error: %w", err)
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
b.l.Infof("RelayTCPConn from %s to %s", c.LocalAddr(), remote.Address)
|
||||
return b.handleRelayConn(c, rc, remote, metrics.METRIC_CONN_TYPE_TCP)
|
||||
}
|
||||
|
||||
func (b *BaseRelayServer) RelayUDPConn(ctx context.Context, c net.Conn, remote *lb.Node) error {
|
||||
if remote == nil {
|
||||
remote = b.remotes.Next().Clone()
|
||||
}
|
||||
metrics.CurConnectionCount.WithLabelValues(remote.Label, metrics.METRIC_CONN_TYPE_UDP).Inc()
|
||||
defer metrics.CurConnectionCount.WithLabelValues(remote.Label, metrics.METRIC_CONN_TYPE_UDP).Dec()
|
||||
|
||||
@@ -144,7 +137,6 @@ func (b *BaseRelayServer) handleRelayConn(c, rc net.Conn, remote *lb.Node, connT
|
||||
conn.WithConnType(connType),
|
||||
conn.WithRelayLabel(b.cfg.Label),
|
||||
conn.WithRelayOptions(b.cfg.Options),
|
||||
conn.WithHandshakeDuration(remote.HandShakeDuration),
|
||||
}
|
||||
relayConn := conn.NewRelayConn(c, rc, opts...)
|
||||
if b.cmgr != nil {
|
||||
|
||||
@@ -99,7 +99,7 @@ func (s *RawServer) ListenAndServe(ctx context.Context) error {
|
||||
}
|
||||
go func(c net.Conn) {
|
||||
defer c.Close()
|
||||
if err := s.RelayTCPConn(ctx, c, nil); err != nil {
|
||||
if err := s.RelayTCPConn(ctx, c, s.remotes.Next()); err != nil {
|
||||
s.l.Errorf("RelayTCPConn meet error: %s", err.Error())
|
||||
}
|
||||
}(c)
|
||||
@@ -118,7 +118,7 @@ func (s *RawServer) listenUDP(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
go func() {
|
||||
if err := s.RelayUDPConn(ctx, c, nil); err != nil {
|
||||
if err := s.RelayUDPConn(ctx, c, s.remotes.Next()); err != nil {
|
||||
s.l.Errorf("RelayUDPConn meet error: %s", err.Error())
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -98,6 +98,8 @@ func (s *WsServer) handleRequest(w http.ResponseWriter, req *http.Request) {
|
||||
var remote *lb.Node
|
||||
if addr := req.URL.Query().Get(conf.WS_QUERY_REMOTE_ADDR); addr != "" {
|
||||
remote = &lb.Node{Address: addr, Label: addr}
|
||||
} else {
|
||||
remote = s.remotes.Next()
|
||||
}
|
||||
|
||||
if req.URL.Query().Get("type") == "udp" {
|
||||
|
||||
393
echo/internal/web/js/metrics.js
Normal file
393
echo/internal/web/js/metrics.js
Normal file
@@ -0,0 +1,393 @@
|
||||
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);
|
||||
@@ -22,7 +22,7 @@ import (
|
||||
"github.com/Ehco1996/ehco/internal/metrics"
|
||||
)
|
||||
|
||||
//go:embed templates/*.html
|
||||
//go:embed templates/*.html js/*.js
|
||||
var templatesFS embed.FS
|
||||
|
||||
const (
|
||||
@@ -70,7 +70,7 @@ func NewServer(
|
||||
return nil, errors.Wrap(err, "failed to setup middleware")
|
||||
}
|
||||
|
||||
if err := setupTemplates(e, l); err != nil {
|
||||
if err := setupTemplates(e, l, cfg); err != nil {
|
||||
return nil, errors.Wrap(err, "failed to setup templates")
|
||||
}
|
||||
|
||||
@@ -128,10 +128,13 @@ func setupMiddleware(e *echo.Echo, cfg *config.Config, l *zap.SugaredLogger) err
|
||||
return nil
|
||||
}
|
||||
|
||||
func setupTemplates(e *echo.Echo, l *zap.SugaredLogger) error {
|
||||
func setupTemplates(e *echo.Echo, l *zap.SugaredLogger, cfg *config.Config) error {
|
||||
funcMap := template.FuncMap{
|
||||
"sub": func(a, b int) int { return a - b },
|
||||
"add": func(a, b int) int { return a + b },
|
||||
"CurrentCfg": func() *config.Config {
|
||||
return cfg
|
||||
},
|
||||
}
|
||||
tmpl, err := template.New("").Funcs(funcMap).ParseFS(templatesFS, "templates/*.html")
|
||||
if err != nil {
|
||||
@@ -158,6 +161,7 @@ func setupMetrics(cfg *config.Config) error {
|
||||
func setupRoutes(s *Server) {
|
||||
e := s.e
|
||||
|
||||
e.StaticFS("/js", echo.MustSubFS(templatesFS, "js"))
|
||||
e.GET(metricsPath, echo.WrapHandler(promhttp.Handler()))
|
||||
e.GET("/debug/pprof/*", echo.WrapHandler(http.DefaultServeMux))
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<head>
|
||||
<title>Ehco Web</title>
|
||||
<title>Ehco Web({{ (CurrentCfg).NodeLabel}})</title>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="description" content="ehco web" />
|
||||
<meta name="keywords" content="ehco-relay" />
|
||||
@@ -7,7 +7,9 @@
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bulma/1.0.1/css/bulma.min.css" />
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.3/css/all.min.css" />
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/flatpickr/4.6.13/flatpickr.min.css" />
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/flatpickr/4.6.13/flatpickr.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.29.4/moment.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/chartjs-adapter-moment/1.0.1/chartjs-adapter-moment.min.js"></script>
|
||||
</head>
|
||||
|
||||
@@ -1,374 +1,57 @@
|
||||
<div class="card" id="metrics-card">
|
||||
<header class="card-header is-flex is-flex-wrap-wrap">
|
||||
<p class="card-header-title has-text-centered">Node Metrics</p>
|
||||
<div class="card-header-icon is-flex-grow-1 is-flex is-justify-content-flex-end">
|
||||
<div class="dropdown" id="dateRangeDropdown">
|
||||
<div class="dropdown-trigger">
|
||||
<button class="button" aria-haspopup="true" aria-controls="dropdown-menu" id="dateRangeButton">
|
||||
<span id="dateRangeText">Select date range</span>
|
||||
<span class="icon is-small">
|
||||
<i class="fas fa-angle-down" aria-hidden="true"></i>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="dropdown-menu" id="dropdown-menu" role="menu">
|
||||
<div class="dropdown-content">
|
||||
<a href="#" class="dropdown-item" data-range="30m">Last 30 minutes</a>
|
||||
<a href="#" class="dropdown-item" data-range="1h">Last 1 hour</a>
|
||||
<a href="#" class="dropdown-item" data-range="3h">Last 3 hours</a>
|
||||
<a href="#" class="dropdown-item" data-range="6h">Last 6 hours</a>
|
||||
<a href="#" class="dropdown-item" data-range="12h">Last 12 hours</a>
|
||||
<a href="#" class="dropdown-item" data-range="24h">Last 24 hours</a>
|
||||
<a href="#" class="dropdown-item" data-range="7d">Last 7 days</a>
|
||||
<hr class="dropdown-divider" />
|
||||
<a href="#" class="dropdown-item" id="dateRangeInput">Select date range</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button id="refreshButton" class="button ml-2">
|
||||
<span class="icon">
|
||||
<i class="fas fa-sync-alt"></i>
|
||||
</span>
|
||||
<span>Auto Refresh</span>
|
||||
</button>
|
||||
<header class="card-header is-flex is-flex-wrap-wrap">
|
||||
<p class="card-header-title has-text-centered">Node Metrics</p>
|
||||
<div class="card-header-icon is-flex-grow-1 is-flex is-justify-content-flex-end">
|
||||
<div class="dropdown" id="dateRangeDropdown">
|
||||
<div class="dropdown-trigger">
|
||||
<button class="button" aria-haspopup="true" aria-controls="dropdown-menu" id="dateRangeButton">
|
||||
<span id="dateRangeText">Select date range</span>
|
||||
<span class="icon is-small">
|
||||
<i class="fas fa-angle-down" aria-hidden="true"></i>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="card-content">
|
||||
<div class="content">
|
||||
<div class="columns is-multiline">
|
||||
<div class="column is-4">
|
||||
<canvas id="cpuChart"></canvas>
|
||||
</div>
|
||||
<div class="column is-4">
|
||||
<canvas id="memoryChart"></canvas>
|
||||
</div>
|
||||
<div class="column is-4">
|
||||
<canvas id="diskChart"></canvas>
|
||||
</div>
|
||||
|
||||
<div class="column is-12">
|
||||
<canvas id="networkChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dropdown-menu" id="dropdown-menu" role="menu">
|
||||
<div class="dropdown-content">
|
||||
<a href="#" class="dropdown-item" data-range="30m">Last 30 minutes</a>
|
||||
<a href="#" class="dropdown-item" data-range="1h">Last 1 hour</a>
|
||||
<a href="#" class="dropdown-item" data-range="3h">Last 3 hours</a>
|
||||
<a href="#" class="dropdown-item" data-range="6h">Last 6 hours</a>
|
||||
<a href="#" class="dropdown-item" data-range="12h">Last 12 hours</a>
|
||||
<a href="#" class="dropdown-item" data-range="24h">Last 24 hours</a>
|
||||
<a href="#" class="dropdown-item" data-range="7d">Last 7 days</a>
|
||||
<hr class="dropdown-divider" />
|
||||
<a href="#" class="dropdown-item" id="dateRangeInput">Select date range</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button id="refreshButton" class="button ml-2">
|
||||
<span class="icon">
|
||||
<i class="fas fa-sync-alt"></i>
|
||||
</span>
|
||||
<span>Auto Refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
<script>
|
||||
$(document).ready(async function () {
|
||||
// Constants
|
||||
const API_BASE_URL = '/api/v1';
|
||||
const NODE_METRICS_PATH = '/node_metrics/';
|
||||
const BYTE_TO_MB = 1024 * 1024;
|
||||
const BYTE_TO_GB = BYTE_TO_MB * 1024;
|
||||
</header>
|
||||
<div class="card-content">
|
||||
<div class="content">
|
||||
<div class="columns is-multiline">
|
||||
<div class="column is-4">
|
||||
<canvas id="cpuChart"></canvas>
|
||||
</div>
|
||||
<div class="column is-4">
|
||||
<canvas id="memoryChart"></canvas>
|
||||
</div>
|
||||
<div class="column is-4">
|
||||
<canvas id="diskChart"></canvas>
|
||||
</div>
|
||||
|
||||
// Utility functions
|
||||
const handleError = (error) => {
|
||||
console.error('Error:', error);
|
||||
// You can add user notifications here
|
||||
};
|
||||
|
||||
const formatDate = (timeStamp) => {
|
||||
const date = new Date(timeStamp * 1000);
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
};
|
||||
|
||||
const formatBytes = (bytes, decimals = 2) => {
|
||||
return (bytes / BYTE_TO_GB).toFixed(decimals);
|
||||
};
|
||||
|
||||
// API functions
|
||||
const fetchData = async (path, params = {}) => {
|
||||
const url = new URL(API_BASE_URL + path, window.location.origin);
|
||||
$.each(params, (key, value) => url.searchParams.append(key, value));
|
||||
try {
|
||||
const response = await $.ajax({
|
||||
url: url.toString(),
|
||||
method: 'GET',
|
||||
dataType: 'json',
|
||||
});
|
||||
return response;
|
||||
} 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)'; // 默认颜色
|
||||
console.log(label, color);
|
||||
return {
|
||||
label,
|
||||
borderColor: color,
|
||||
backgroundColor: color.replace('1)', '0.2)'),
|
||||
borderWidth: 2,
|
||||
pointRadius: 0,
|
||||
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: {
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: { position: legendPosition },
|
||||
title: {
|
||||
display: !!title,
|
||||
text: title,
|
||||
position: 'bottom',
|
||||
font: { size: 14, weight: 'bold' },
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
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: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
title: { display: true, text: yDisplayText, font: { weight: 'bold' } },
|
||||
},
|
||||
x: {
|
||||
ticks: { maxRotation: 0, autoSkip: true, maxTicksLimit: 10 },
|
||||
},
|
||||
},
|
||||
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;
|
||||
}
|
||||
|
||||
const formattedLabels = labels.map(formatDate);
|
||||
|
||||
if ($.isArray(newData) && $.isArray(newData[0])) {
|
||||
$.each(chart.data.datasets, (index, dataset) => {
|
||||
if (newData[index]) {
|
||||
dataset.data = newData[index];
|
||||
}
|
||||
});
|
||||
} else {
|
||||
chart.data.datasets[0].data = newData;
|
||||
}
|
||||
|
||||
chart.data.labels = formattedLabels;
|
||||
chart.update();
|
||||
};
|
||||
|
||||
const updateCharts = (charts, metrics) => {
|
||||
console.log('Raw metrics data:', metrics);
|
||||
const timestamps = metrics.map((data) => data.timestamp);
|
||||
updateChart(
|
||||
charts.cpu,
|
||||
metrics.map((data) => data.cpu_usage),
|
||||
timestamps,
|
||||
);
|
||||
updateChart(
|
||||
charts.memory,
|
||||
metrics.map((data) => data.memory_usage),
|
||||
timestamps,
|
||||
);
|
||||
updateChart(
|
||||
charts.disk,
|
||||
metrics.map((data) => data.disk_usage),
|
||||
timestamps,
|
||||
);
|
||||
updateChart(
|
||||
charts.network,
|
||||
[metrics.map((data) => data.network_in / BYTE_TO_MB), metrics.map((data) => data.network_out / BYTE_TO_MB)],
|
||||
timestamps,
|
||||
);
|
||||
};
|
||||
|
||||
const addLatestDataToCharts = (charts, latestMetric) => {
|
||||
console.log('Raw latestMetric data:', latestMetric);
|
||||
const timestamp = formatDate(latestMetric.timestamp);
|
||||
$.each(charts, (key, chart) => {
|
||||
chart.data.labels.push(timestamp);
|
||||
if (key === 'network') {
|
||||
chart.data.datasets[0].data.push(latestMetric.network_in / BYTE_TO_MB);
|
||||
chart.data.datasets[1].data.push(latestMetric.network_out / BYTE_TO_MB);
|
||||
} else {
|
||||
chart.data.datasets[0].data.push(latestMetric[`${key}_usage`]);
|
||||
}
|
||||
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}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
const fetchDataForRange = async (charts, startTs, endTs) => {
|
||||
const metrics = await fetchMetrics(startTs, endTs);
|
||||
if (metrics) {
|
||||
console.log('Raw metrics data:', metrics);
|
||||
updateCharts(charts, metrics);
|
||||
}
|
||||
};
|
||||
|
||||
// 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 execution
|
||||
const charts = await initializeCharts();
|
||||
if (charts) {
|
||||
setupDateRangeDropdown(charts);
|
||||
setupAutoRefresh(charts);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<div class="column is-12">
|
||||
<canvas id="networkChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- </div> -->
|
||||
<script src="js/metrics.js"></script>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<a class="navbar-item" href="/">
|
||||
<h1 class="title is-4">Ehco Relay</h1>
|
||||
</a>
|
||||
<a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false">
|
||||
<a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false" data-target="navbarMenu">
|
||||
<span aria-hidden="true"></span>
|
||||
<span aria-hidden="true"></span>
|
||||
<span aria-hidden="true"></span>
|
||||
@@ -12,7 +12,7 @@
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="navbar-menu">
|
||||
<div id="navbarMenu" class="navbar-menu">
|
||||
<div class="navbar-start">
|
||||
<a href="/rules/" class="navbar-item">
|
||||
<span class="icon"><i class="fas fa-list"></i></span>
|
||||
@@ -56,3 +56,28 @@
|
||||
</div>
|
||||
</nav>
|
||||
<hr />
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Get all "navbar-burger" elements
|
||||
const $navbarBurgers = Array.prototype.slice.call(document.querySelectorAll('.navbar-burger'), 0);
|
||||
|
||||
// Add a click event on each of them
|
||||
$navbarBurgers.forEach((el) => {
|
||||
el.addEventListener('click', () => {
|
||||
// Get the target from the "data-target" attribute
|
||||
const target = el.dataset.target;
|
||||
const $target = document.getElementById(target);
|
||||
|
||||
// Check if the target element exists
|
||||
if ($target) {
|
||||
// Toggle the "is-active" class on both the "navbar-burger" and the "navbar-menu"
|
||||
el.classList.toggle('is-active');
|
||||
$target.classList.toggle('is-active');
|
||||
} else {
|
||||
console.error(`Target element with id "${target}" not found`);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user