mirror of
https://github.com/bolucat/Archive.git
synced 2025-12-24 13:28:37 +08:00
Update On Fri Sep 6 20:35:08 CEST 2024
This commit is contained in:
@@ -39,7 +39,8 @@ type Cmgr interface {
|
||||
Start(ctx context.Context, errCH chan error)
|
||||
|
||||
// Metrics related
|
||||
QueryNodeMetrics(ctx context.Context, req *ms.QueryNodeMetricsReq) (*ms.QueryNodeMetricsResp, error)
|
||||
QueryNodeMetrics(ctx context.Context, req *ms.QueryNodeMetricsReq, refresh bool) (*ms.QueryNodeMetricsResp, error)
|
||||
QueryRuleMetrics(ctx context.Context, req *ms.QueryRuleMetricsReq, refresh bool) (*ms.QueryRuleMetricsResp, error)
|
||||
}
|
||||
|
||||
type cmgrImpl struct {
|
||||
@@ -201,20 +202,30 @@ func (cm *cmgrImpl) Start(ctx context.Context, errCH chan error) {
|
||||
}
|
||||
}
|
||||
|
||||
func (cm *cmgrImpl) QueryNodeMetrics(ctx context.Context, req *ms.QueryNodeMetricsReq) (*ms.QueryNodeMetricsResp, error) {
|
||||
num := -1 // default to return all metrics
|
||||
if req.Latest {
|
||||
m, err := cm.mr.ReadOnce(ctx)
|
||||
func (cm *cmgrImpl) QueryNodeMetrics(ctx context.Context, req *ms.QueryNodeMetricsReq, refresh bool) (*ms.QueryNodeMetricsResp, error) {
|
||||
if refresh {
|
||||
nm, _, err := cm.mr.ReadOnce(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := cm.ms.AddNodeMetric(m); err != nil {
|
||||
if err := cm.ms.AddNodeMetric(ctx, nm); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
num = 1
|
||||
}
|
||||
|
||||
startTime := time.Unix(req.StartTimestamp, 0)
|
||||
endTime := time.Unix(req.EndTimestamp, 0)
|
||||
return cm.ms.QueryNodeMetric(startTime, endTime, num)
|
||||
return cm.ms.QueryNodeMetric(ctx, req)
|
||||
}
|
||||
|
||||
func (cm *cmgrImpl) QueryRuleMetrics(ctx context.Context, req *ms.QueryRuleMetricsReq, refresh bool) (*ms.QueryRuleMetricsResp, error) {
|
||||
if refresh {
|
||||
_, rm, err := cm.mr.ReadOnce(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, m := range rm {
|
||||
if err := cm.ms.AddRuleMetric(ctx, m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
return cm.ms.QueryRuleMetric(ctx, req)
|
||||
}
|
||||
|
||||
163
echo/internal/cmgr/ms/handler.go
Normal file
163
echo/internal/cmgr/ms/handler.go
Normal file
@@ -0,0 +1,163 @@
|
||||
package ms
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/Ehco1996/ehco/pkg/metric_reader"
|
||||
)
|
||||
|
||||
type NodeMetrics struct {
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
|
||||
CPUUsage float64 `json:"cpu_usage"`
|
||||
MemoryUsage float64 `json:"memory_usage"`
|
||||
DiskUsage float64 `json:"disk_usage"`
|
||||
NetworkIn float64 `json:"network_in"` // bytes per second
|
||||
NetworkOut float64 `json:"network_out"` // bytes per second
|
||||
}
|
||||
|
||||
type QueryNodeMetricsReq struct {
|
||||
StartTimestamp int64
|
||||
EndTimestamp int64
|
||||
Num int64
|
||||
}
|
||||
|
||||
type QueryNodeMetricsResp struct {
|
||||
TOTAL int `json:"total"`
|
||||
Data []NodeMetrics `json:"data"`
|
||||
}
|
||||
|
||||
type RuleMetricsData struct {
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
Label string `json:"label"`
|
||||
Remote string `json:"remote"`
|
||||
PingLatency int64 `json:"ping_latency"`
|
||||
TCPConnectionCount int64 `json:"tcp_connection_count"`
|
||||
TCPHandshakeDuration int64 `json:"tcp_handshake_duration"`
|
||||
TCPNetworkTransmitBytes int64 `json:"tcp_network_transmit_bytes"`
|
||||
UDPConnectionCount int64 `json:"udp_connection_count"`
|
||||
UDPHandshakeDuration int64 `json:"udp_handshake_duration"`
|
||||
UDPNetworkTransmitBytes int64 `json:"udp_network_transmit_bytes"`
|
||||
}
|
||||
|
||||
type QueryRuleMetricsReq struct {
|
||||
RuleLabel string
|
||||
Remote string
|
||||
|
||||
StartTimestamp int64
|
||||
EndTimestamp int64
|
||||
Num int64
|
||||
}
|
||||
|
||||
type QueryRuleMetricsResp struct {
|
||||
TOTAL int `json:"total"`
|
||||
Data []RuleMetricsData `json:"data"`
|
||||
}
|
||||
|
||||
func (ms *MetricsStore) AddNodeMetric(ctx context.Context, m *metric_reader.NodeMetrics) error {
|
||||
_, err := ms.db.ExecContext(ctx, `
|
||||
INSERT OR REPLACE INTO node_metrics (timestamp, cpu_usage, memory_usage, disk_usage, network_in, network_out)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`, m.SyncTime.Unix(), m.CpuUsagePercent, m.MemoryUsagePercent, m.DiskUsagePercent, m.NetworkReceiveBytesRate, m.NetworkTransmitBytesRate)
|
||||
return err
|
||||
}
|
||||
|
||||
func (ms *MetricsStore) AddRuleMetric(ctx context.Context, rm *metric_reader.RuleMetrics) error {
|
||||
tx, err := ms.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck
|
||||
|
||||
stmt, err := tx.PrepareContext(ctx, `
|
||||
INSERT OR REPLACE INTO rule_metrics
|
||||
(timestamp, label, remote, ping_latency,
|
||||
tcp_connection_count, tcp_handshake_duration, tcp_network_transmit_bytes,
|
||||
udp_connection_count, udp_handshake_duration, udp_network_transmit_bytes)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stmt.Close() //nolint:errcheck
|
||||
|
||||
for remote, pingMetric := range rm.PingMetrics {
|
||||
_, err := stmt.ExecContext(ctx, rm.SyncTime.Unix(), rm.Label, remote, pingMetric.Latency,
|
||||
rm.TCPConnectionCount[remote], rm.TCPHandShakeDuration[remote], rm.TCPNetworkTransmitBytes[remote],
|
||||
rm.UDPConnectionCount[remote], rm.UDPHandShakeDuration[remote], rm.UDPNetworkTransmitBytes[remote])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (ms *MetricsStore) QueryNodeMetric(ctx context.Context, req *QueryNodeMetricsReq) (*QueryNodeMetricsResp, error) {
|
||||
rows, err := ms.db.QueryContext(ctx, `
|
||||
SELECT timestamp, cpu_usage, memory_usage, disk_usage, network_in, network_out
|
||||
FROM node_metrics
|
||||
WHERE timestamp >= ? AND timestamp <= ?
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT ?
|
||||
`, req.StartTimestamp, req.EndTimestamp, req.Num)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close() //nolint:errcheck
|
||||
|
||||
var resp QueryNodeMetricsResp
|
||||
for rows.Next() {
|
||||
var m NodeMetrics
|
||||
if err := rows.Scan(&m.Timestamp, &m.CPUUsage, &m.MemoryUsage, &m.DiskUsage, &m.NetworkIn, &m.NetworkOut); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp.Data = append(resp.Data, m)
|
||||
}
|
||||
resp.TOTAL = len(resp.Data)
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
func (ms *MetricsStore) QueryRuleMetric(ctx context.Context, req *QueryRuleMetricsReq) (*QueryRuleMetricsResp, error) {
|
||||
query := `
|
||||
SELECT timestamp, label, remote, ping_latency,
|
||||
tcp_connection_count, tcp_handshake_duration, tcp_network_transmit_bytes,
|
||||
udp_connection_count, udp_handshake_duration, udp_network_transmit_bytes
|
||||
FROM rule_metrics
|
||||
WHERE timestamp >= ? AND timestamp <= ?
|
||||
`
|
||||
args := []interface{}{req.StartTimestamp, req.EndTimestamp}
|
||||
|
||||
if req.RuleLabel != "" {
|
||||
query += " AND label = ?"
|
||||
args = append(args, req.RuleLabel)
|
||||
}
|
||||
if req.Remote != "" {
|
||||
query += " AND remote = ?"
|
||||
args = append(args, req.Remote)
|
||||
}
|
||||
|
||||
query += `
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT ?
|
||||
`
|
||||
args = append(args, req.Num)
|
||||
|
||||
rows, err := ms.db.Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close() //nolint:errcheck
|
||||
var resp QueryRuleMetricsResp
|
||||
for rows.Next() {
|
||||
var m RuleMetricsData
|
||||
if err := rows.Scan(&m.Timestamp, &m.Label, &m.Remote, &m.PingLatency,
|
||||
&m.TCPConnectionCount, &m.TCPHandshakeDuration, &m.TCPNetworkTransmitBytes,
|
||||
&m.UDPConnectionCount, &m.UDPHandshakeDuration, &m.UDPNetworkTransmitBytes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp.Data = append(resp.Data, m)
|
||||
}
|
||||
resp.TOTAL = len(resp.Data)
|
||||
return &resp, nil
|
||||
}
|
||||
@@ -8,31 +8,8 @@ import (
|
||||
|
||||
"go.uber.org/zap"
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"github.com/Ehco1996/ehco/pkg/metric_reader"
|
||||
)
|
||||
|
||||
type NodeMetrics struct {
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
|
||||
CPUUsage float64 `json:"cpu_usage"`
|
||||
MemoryUsage float64 `json:"memory_usage"`
|
||||
DiskUsage float64 `json:"disk_usage"`
|
||||
NetworkIn float64 `json:"network_in"`
|
||||
NetworkOut float64 `json:"network_out"`
|
||||
}
|
||||
|
||||
type QueryNodeMetricsReq struct {
|
||||
StartTimestamp int64 `json:"start_ts"`
|
||||
EndTimestamp int64 `json:"end_ts"`
|
||||
|
||||
Latest bool `json:"latest"` // whether to refresh the cache and get the latest data
|
||||
}
|
||||
type QueryNodeMetricsResp struct {
|
||||
TOTAL int `json:"total"`
|
||||
Data []NodeMetrics `json:"data"`
|
||||
}
|
||||
|
||||
type MetricsStore struct {
|
||||
db *sql.DB
|
||||
dbPath string
|
||||
@@ -65,12 +42,34 @@ func NewMetricsStore(dbPath string) (*MetricsStore, error) {
|
||||
if err := ms.initDB(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := ms.cleanOldData(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ms, nil
|
||||
}
|
||||
|
||||
func (ms *MetricsStore) cleanOldData() error {
|
||||
thirtyDaysAgo := time.Now().AddDate(0, 0, -30).Unix()
|
||||
|
||||
// 清理 node_metrics 表
|
||||
_, err := ms.db.Exec("DELETE FROM node_metrics WHERE timestamp < ?", thirtyDaysAgo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 清理 rule_metrics 表
|
||||
_, err = ms.db.Exec("DELETE FROM rule_metrics WHERE timestamp < ?", thirtyDaysAgo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ms.l.Infof("Cleaned data older than 30 days")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ms *MetricsStore) initDB() error {
|
||||
// init NodeMetrics table
|
||||
_, err := ms.db.Exec(`
|
||||
if _, err := ms.db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS node_metrics (
|
||||
timestamp INTEGER,
|
||||
cpu_usage REAL,
|
||||
@@ -80,39 +79,27 @@ func (ms *MetricsStore) initDB() error {
|
||||
network_out REAL,
|
||||
PRIMARY KEY (timestamp)
|
||||
)
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func (ms *MetricsStore) AddNodeMetric(m *metric_reader.NodeMetrics) error {
|
||||
_, err := ms.db.Exec(`
|
||||
INSERT OR REPLACE INTO node_metrics (timestamp, cpu_usage, memory_usage, disk_usage, network_in, network_out)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`, m.SyncTime.Unix(), m.CpuUsagePercent, m.MemoryUsagePercent, m.DiskUsagePercent, m.NetworkReceiveBytesRate, m.NetworkTransmitBytesRate)
|
||||
return err
|
||||
}
|
||||
|
||||
func (ms *MetricsStore) QueryNodeMetric(startTime, endTime time.Time, num int) (*QueryNodeMetricsResp, error) {
|
||||
rows, err := ms.db.Query(`
|
||||
SELECT timestamp, cpu_usage, memory_usage, disk_usage, network_in, network_out
|
||||
FROM node_metrics
|
||||
WHERE timestamp >= ? AND timestamp <= ?
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT ?
|
||||
`, startTime.Unix(), endTime.Unix(), num)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
`); err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close() //nolint:errcheck
|
||||
|
||||
var resp QueryNodeMetricsResp
|
||||
for rows.Next() {
|
||||
var m NodeMetrics
|
||||
if err := rows.Scan(&m.Timestamp, &m.CPUUsage, &m.MemoryUsage, &m.DiskUsage, &m.NetworkIn, &m.NetworkOut); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp.Data = append(resp.Data, m)
|
||||
// init rule_metrics
|
||||
if _, err := ms.db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS rule_metrics (
|
||||
timestamp INTEGER,
|
||||
label TEXT,
|
||||
remote TEXT,
|
||||
ping_latency INTEGER,
|
||||
tcp_connection_count INTEGER,
|
||||
tcp_handshake_duration INTEGER,
|
||||
tcp_network_transmit_bytes INTEGER,
|
||||
udp_connection_count INTEGER,
|
||||
udp_handshake_duration INTEGER,
|
||||
udp_network_transmit_bytes INTEGER,
|
||||
PRIMARY KEY (timestamp, label, remote)
|
||||
)
|
||||
`); err != nil {
|
||||
return err
|
||||
}
|
||||
resp.TOTAL = len(resp.Data)
|
||||
return &resp, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -45,14 +45,19 @@ func (cm *cmgrImpl) syncOnce(ctx context.Context) error {
|
||||
}
|
||||
|
||||
if cm.cfg.NeedMetrics() {
|
||||
metrics, err := cm.mr.ReadOnce(ctx)
|
||||
nm, rmm, err := cm.mr.ReadOnce(ctx)
|
||||
if err != nil {
|
||||
cm.l.Errorf("read metrics failed: %v", err)
|
||||
} else {
|
||||
req.Node = *metrics
|
||||
if err := cm.ms.AddNodeMetric(metrics); err != nil {
|
||||
req.Node = *nm
|
||||
if err := cm.ms.AddNodeMetric(ctx, nm); err != nil {
|
||||
cm.l.Errorf("add metrics to store failed: %v", err)
|
||||
}
|
||||
for _, rm := range rmm {
|
||||
if err := cm.ms.AddRuleMetric(ctx, rm); err != nil {
|
||||
cm.l.Errorf("add rule metrics to store failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -209,14 +209,12 @@ func (c *innerConn) recordStats(n int, isRead bool) {
|
||||
return
|
||||
}
|
||||
if isRead {
|
||||
metrics.NetWorkTransmitBytes.WithLabelValues(
|
||||
c.rc.remote.Label, metrics.METRIC_CONN_TYPE_TCP, metrics.METRIC_CONN_FLOW_READ,
|
||||
).Add(float64(n))
|
||||
labels := []string{c.rc.RelayLabel, c.rc.ConnType, metrics.METRIC_FLOW_READ, c.rc.remote.Address}
|
||||
metrics.NetWorkTransmitBytes.WithLabelValues(labels...).Add(float64(n))
|
||||
c.rc.Stats.Record(0, int64(n))
|
||||
} else {
|
||||
metrics.NetWorkTransmitBytes.WithLabelValues(
|
||||
c.rc.remote.Label, metrics.METRIC_CONN_TYPE_TCP, metrics.METRIC_CONN_FLOW_WRITE,
|
||||
).Add(float64(n))
|
||||
labels := []string{c.rc.RelayLabel, c.rc.ConnType, metrics.METRIC_FLOW_WRITE, c.rc.remote.Address}
|
||||
metrics.NetWorkTransmitBytes.WithLabelValues(labels...).Add(float64(n))
|
||||
c.rc.Stats.Record(int64(n), 0)
|
||||
}
|
||||
}
|
||||
@@ -236,7 +234,7 @@ func (c *innerConn) Read(p []byte) (n int, err error) {
|
||||
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
|
||||
since := time.Since(c.lastActive)
|
||||
if since > c.rc.Options.IdleTimeout {
|
||||
c.l.Debugf("Read idle, close remote: %s", c.rc.remote.Label)
|
||||
c.l.Debugf("Read idle, close remote: %s", c.rc.remote.Address)
|
||||
return 0, ErrIdleTimeout
|
||||
}
|
||||
continue
|
||||
|
||||
@@ -24,7 +24,7 @@ func TestInnerConn_ReadWrite(t *testing.T) {
|
||||
serverConn.SetDeadline(time.Now().Add(1 * time.Second))
|
||||
defer clientConn.Close()
|
||||
defer serverConn.Close()
|
||||
rc := relayConnImpl{Stats: &Stats{}, remote: &lb.Node{Label: "client"}, Options: &testOptions}
|
||||
rc := relayConnImpl{Stats: &Stats{}, remote: &lb.Node{}, Options: &testOptions}
|
||||
innerC := newInnerConn(clientConn, &rc)
|
||||
errChan := make(chan error, 1)
|
||||
go func() {
|
||||
@@ -100,7 +100,7 @@ func TestCopyTCPConn(t *testing.T) {
|
||||
assert.NoError(t, err)
|
||||
defer remoteConn.Close()
|
||||
testOptions := conf.Options{IdleTimeout: time.Second, ReadTimeout: time.Second}
|
||||
rc := relayConnImpl{Stats: &Stats{}, remote: &lb.Node{Label: "client"}, Options: &testOptions}
|
||||
rc := relayConnImpl{Stats: &Stats{}, remote: &lb.Node{}, Options: &testOptions}
|
||||
c1 := newInnerConn(clientConn, &rc)
|
||||
c2 := newInnerConn(remoteConn, &rc)
|
||||
|
||||
@@ -161,7 +161,7 @@ func TestCopyUDPConn(t *testing.T) {
|
||||
defer remoteConn.Close()
|
||||
|
||||
testOptions := conf.Options{IdleTimeout: time.Second, ReadTimeout: time.Second}
|
||||
rc := relayConnImpl{Stats: &Stats{}, remote: &lb.Node{Label: "client"}, Options: &testOptions}
|
||||
rc := relayConnImpl{Stats: &Stats{}, remote: &lb.Node{}, Options: &testOptions}
|
||||
c1 := newInnerConn(clientConn, &rc)
|
||||
c2 := newInnerConn(remoteConn, &rc)
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package lb
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/atomic"
|
||||
@@ -8,21 +10,38 @@ import (
|
||||
|
||||
type Node struct {
|
||||
Address string
|
||||
Label string
|
||||
HandShakeDuration time.Duration
|
||||
}
|
||||
|
||||
func (n *Node) Clone() *Node {
|
||||
return &Node{
|
||||
Address: n.Address,
|
||||
Label: n.Label,
|
||||
HandShakeDuration: n.HandShakeDuration,
|
||||
}
|
||||
}
|
||||
|
||||
func extractHost(input string) (string, error) {
|
||||
// Check if the input string has a scheme, if not, add "http://"
|
||||
if !strings.Contains(input, "://") {
|
||||
input = "http://" + input
|
||||
}
|
||||
// Parse the URL
|
||||
u, err := url.Parse(input)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return u.Hostname(), nil
|
||||
}
|
||||
|
||||
// NOTE for (https/ws/wss)://xxx.com -> xxx.com
|
||||
func (n *Node) GetAddrHost() (string, error) {
|
||||
return extractHost(n.Address)
|
||||
}
|
||||
|
||||
// RoundRobin is an interface for representing round-robin balancing.
|
||||
type RoundRobin interface {
|
||||
Next() *Node
|
||||
GetAll() []*Node
|
||||
}
|
||||
|
||||
type roundrobin struct {
|
||||
@@ -42,3 +61,7 @@ func (r *roundrobin) Next() *Node {
|
||||
next := r.nodeList[(int(n)-1)%r.len]
|
||||
return next
|
||||
}
|
||||
|
||||
func (r *roundrobin) GetAll() []*Node {
|
||||
return r.nodeList
|
||||
}
|
||||
|
||||
@@ -13,53 +13,43 @@ const (
|
||||
METRIC_SUBSYSTEM_TRAFFIC = "traffic"
|
||||
METRIC_SUBSYSTEM_PING = "ping"
|
||||
|
||||
METRIC_LABEL_REMOTE = "remote"
|
||||
|
||||
METRIC_LABEL_CONN_FLOW = "flow"
|
||||
METRIC_CONN_FLOW_WRITE = "write"
|
||||
METRIC_CONN_FLOW_READ = "read"
|
||||
|
||||
METRIC_LABEL_CONN_TYPE = "type"
|
||||
METRIC_CONN_TYPE_TCP = "tcp"
|
||||
METRIC_CONN_TYPE_UDP = "udp"
|
||||
METRIC_CONN_TYPE_TCP = "tcp"
|
||||
METRIC_CONN_TYPE_UDP = "udp"
|
||||
METRIC_FLOW_READ = "read"
|
||||
METRIC_FLOW_WRITE = "write"
|
||||
|
||||
EhcoAliveStateInit = 0
|
||||
EhcoAliveStateRunning = 1
|
||||
)
|
||||
|
||||
var (
|
||||
Hostname, _ = os.Hostname()
|
||||
ConstLabels = map[string]string{
|
||||
"ehco_runner_hostname": Hostname,
|
||||
}
|
||||
|
||||
// 1ms ~ 5s (1ms 到 437ms )
|
||||
msBuckets = prometheus.ExponentialBuckets(1, 1.5, 16)
|
||||
)
|
||||
|
||||
// ping metrics
|
||||
var (
|
||||
pingLabelNames = []string{"ip", "host", "label"}
|
||||
pingBuckets = prometheus.ExponentialBuckets(0.001, 2, 12) // 1ms ~ 4s
|
||||
pingInterval = time.Second * 30
|
||||
|
||||
PingResponseDurationSeconds = prometheus.NewHistogramVec(
|
||||
pingInterval = time.Second * 30
|
||||
PingResponseDurationMilliseconds = prometheus.NewHistogramVec(
|
||||
prometheus.HistogramOpts{
|
||||
Namespace: METRIC_NS,
|
||||
Subsystem: METRIC_SUBSYSTEM_PING,
|
||||
Name: "response_duration_seconds",
|
||||
Name: "response_duration_milliseconds",
|
||||
Help: "A histogram of latencies for ping responses.",
|
||||
Buckets: pingBuckets,
|
||||
Buckets: msBuckets,
|
||||
ConstLabels: ConstLabels,
|
||||
},
|
||||
pingLabelNames,
|
||||
)
|
||||
PingRequestTotal = prometheus.NewDesc(
|
||||
prometheus.BuildFQName(METRIC_NS, METRIC_SUBSYSTEM_PING, "requests_total"),
|
||||
"Number of ping requests sent",
|
||||
pingLabelNames,
|
||||
ConstLabels,
|
||||
[]string{"label", "remote", "ip"},
|
||||
)
|
||||
)
|
||||
|
||||
// traffic metrics
|
||||
var (
|
||||
Hostname, _ = os.Hostname()
|
||||
|
||||
ConstLabels = map[string]string{
|
||||
"ehco_runner_hostname": Hostname,
|
||||
}
|
||||
|
||||
EhcoAlive = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Namespace: METRIC_NS,
|
||||
Subsystem: "",
|
||||
@@ -74,7 +64,15 @@ var (
|
||||
Name: "current_connection_count",
|
||||
Help: "当前链接数",
|
||||
ConstLabels: ConstLabels,
|
||||
}, []string{METRIC_LABEL_REMOTE, METRIC_LABEL_CONN_TYPE})
|
||||
}, []string{"label", "conn_type", "remote"})
|
||||
|
||||
HandShakeDurationMilliseconds = prometheus.NewHistogramVec(prometheus.HistogramOpts{
|
||||
Subsystem: METRIC_SUBSYSTEM_TRAFFIC,
|
||||
Namespace: METRIC_NS,
|
||||
Name: "handshake_duration_milliseconds",
|
||||
Help: "握手时间ms",
|
||||
ConstLabels: ConstLabels,
|
||||
}, []string{"label", "conn_type", "remote"})
|
||||
|
||||
NetWorkTransmitBytes = prometheus.NewCounterVec(prometheus.CounterOpts{
|
||||
Namespace: METRIC_NS,
|
||||
@@ -82,15 +80,7 @@ var (
|
||||
Name: "network_transmit_bytes",
|
||||
Help: "传输流量总量bytes",
|
||||
ConstLabels: ConstLabels,
|
||||
}, []string{METRIC_LABEL_REMOTE, METRIC_LABEL_CONN_TYPE, METRIC_LABEL_CONN_FLOW})
|
||||
|
||||
HandShakeDuration = prometheus.NewHistogramVec(prometheus.HistogramOpts{
|
||||
Subsystem: METRIC_SUBSYSTEM_TRAFFIC,
|
||||
Namespace: METRIC_NS,
|
||||
Name: "handshake_duration",
|
||||
Help: "握手时间ms",
|
||||
ConstLabels: ConstLabels,
|
||||
}, []string{METRIC_LABEL_REMOTE})
|
||||
}, []string{"label", "conn_type", "flow", "remote"})
|
||||
)
|
||||
|
||||
func RegisterEhcoMetrics(cfg *config.Config) error {
|
||||
@@ -98,15 +88,14 @@ func RegisterEhcoMetrics(cfg *config.Config) error {
|
||||
prometheus.MustRegister(EhcoAlive)
|
||||
prometheus.MustRegister(CurConnectionCount)
|
||||
prometheus.MustRegister(NetWorkTransmitBytes)
|
||||
prometheus.MustRegister(HandShakeDuration)
|
||||
prometheus.MustRegister(HandShakeDurationMilliseconds)
|
||||
|
||||
EhcoAlive.Set(EhcoAliveStateInit)
|
||||
|
||||
// ping
|
||||
if cfg.EnablePing {
|
||||
pg := NewPingGroup(cfg)
|
||||
prometheus.MustRegister(PingResponseDurationSeconds)
|
||||
prometheus.MustRegister(pg)
|
||||
prometheus.MustRegister(PingResponseDurationMilliseconds)
|
||||
go pg.Run()
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"net/url"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Ehco1996/ehco/internal/config"
|
||||
"github.com/go-ping/ping"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (pg *PingGroup) newPinger(addr string) (*ping.Pinger, error) {
|
||||
func (pg *PingGroup) newPinger(ruleLabel string, remote string, addr string) (*ping.Pinger, error) {
|
||||
pinger := ping.New(addr)
|
||||
if err := pinger.Resolve(); err != nil {
|
||||
pg.logger.Error("failed to resolve pinger", zap.String("addr", addr), zap.Error(err))
|
||||
@@ -26,6 +22,13 @@ func (pg *PingGroup) newPinger(addr string) (*ping.Pinger, error) {
|
||||
if runtime.GOOS != "darwin" {
|
||||
pinger.SetPrivileged(true)
|
||||
}
|
||||
pinger.OnRecv = func(pkt *ping.Packet) {
|
||||
ip := pkt.IPAddr.String()
|
||||
PingResponseDurationMilliseconds.WithLabelValues(
|
||||
ruleLabel, remote, ip).Observe(float64(pkt.Rtt.Milliseconds()))
|
||||
pg.logger.Sugar().Infof("%d bytes from %s icmp_seq=%d time=%v ttl=%v",
|
||||
pkt.Nbytes, pkt.Addr, pkt.Seq, pkt.Rtt, pkt.Ttl)
|
||||
}
|
||||
return pinger, nil
|
||||
}
|
||||
|
||||
@@ -34,89 +37,29 @@ type PingGroup struct {
|
||||
|
||||
// k: addr
|
||||
Pingers map[string]*ping.Pinger
|
||||
|
||||
// k: addr v:relay rule label joined by ","
|
||||
PingerLabels map[string]string
|
||||
}
|
||||
|
||||
func extractHost(input string) (string, error) {
|
||||
// Check if the input string has a scheme, if not, add "http://"
|
||||
if !strings.Contains(input, "://") {
|
||||
input = "http://" + input
|
||||
}
|
||||
// Parse the URL
|
||||
u, err := url.Parse(input)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return u.Hostname(), nil
|
||||
}
|
||||
|
||||
func NewPingGroup(cfg *config.Config) *PingGroup {
|
||||
logger := zap.L().Named("pinger")
|
||||
|
||||
pg := &PingGroup{
|
||||
logger: logger,
|
||||
Pingers: make(map[string]*ping.Pinger),
|
||||
PingerLabels: map[string]string{},
|
||||
logger: zap.L().Named("pinger"),
|
||||
Pingers: make(map[string]*ping.Pinger),
|
||||
}
|
||||
|
||||
// parse addr from rule
|
||||
for _, relayCfg := range cfg.RelayConfigs {
|
||||
// NOTE for (https/ws/wss)://xxx.com -> xxx.com
|
||||
for _, remote := range relayCfg.Remotes {
|
||||
addr, err := extractHost(remote)
|
||||
for _, remote := range relayCfg.GetAllRemotes() {
|
||||
addr, err := remote.GetAddrHost()
|
||||
if err != nil {
|
||||
pg.logger.Error("try parse host error", zap.Error(err))
|
||||
}
|
||||
if _, ok := pg.Pingers[addr]; ok {
|
||||
// append rule label when remote host is same
|
||||
pg.PingerLabels[addr] += fmt.Sprintf(",%s", relayCfg.Label)
|
||||
continue
|
||||
}
|
||||
if pinger, err := pg.newPinger(addr); err != nil {
|
||||
if pinger, err := pg.newPinger(relayCfg.Label, remote.Address, addr); err != nil {
|
||||
pg.logger.Error("new pinger meet error", zap.Error(err))
|
||||
} else {
|
||||
pg.Pingers[pinger.Addr()] = pinger
|
||||
pg.PingerLabels[addr] = relayCfg.Label
|
||||
pg.Pingers[addr] = pinger
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// update metrics
|
||||
for addr, pinger := range pg.Pingers {
|
||||
pinger.OnRecv = func(pkt *ping.Packet) {
|
||||
PingResponseDurationSeconds.WithLabelValues(
|
||||
pkt.IPAddr.String(), pkt.Addr, pg.PingerLabels[addr]).Observe(pkt.Rtt.Seconds())
|
||||
pg.logger.Sugar().Infof("%d bytes from %s icmp_seq=%d time=%v ttl=%v",
|
||||
pkt.Nbytes, pkt.Addr, pkt.Seq, pkt.Rtt, pkt.Ttl)
|
||||
}
|
||||
pinger.OnDuplicateRecv = func(pkt *ping.Packet) {
|
||||
pg.logger.Sugar().Infof("%d bytes from %s icmp_seq=%d time=%v ttl=%v (DUP!)",
|
||||
pkt.Nbytes, pkt.IPAddr, pkt.Seq, pkt.Rtt, pkt.Ttl)
|
||||
}
|
||||
}
|
||||
return pg
|
||||
}
|
||||
|
||||
func (pg *PingGroup) Describe(ch chan<- *prometheus.Desc) {
|
||||
ch <- PingRequestTotal
|
||||
}
|
||||
|
||||
func (pg *PingGroup) Collect(ch chan<- prometheus.Metric) {
|
||||
for addr, pinger := range pg.Pingers {
|
||||
stats := pinger.Statistics()
|
||||
ch <- prometheus.MustNewConstMetric(
|
||||
PingRequestTotal,
|
||||
prometheus.CounterValue,
|
||||
float64(stats.PacketsSent),
|
||||
stats.IPAddr.String(),
|
||||
stats.Addr,
|
||||
pg.PingerLabels[addr],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func (pg *PingGroup) Run() {
|
||||
if len(pg.Pingers) <= 0 {
|
||||
return
|
||||
|
||||
@@ -179,14 +179,16 @@ func (r *Config) DefaultLabel() string {
|
||||
func (r *Config) ToRemotesLB() lb.RoundRobin {
|
||||
tcpNodeList := make([]*lb.Node, len(r.Remotes))
|
||||
for idx, addr := range r.Remotes {
|
||||
tcpNodeList[idx] = &lb.Node{
|
||||
Address: addr,
|
||||
Label: fmt.Sprintf("%s-%s", r.Label, addr),
|
||||
}
|
||||
tcpNodeList[idx] = &lb.Node{Address: addr}
|
||||
}
|
||||
return lb.NewRoundRobin(tcpNodeList)
|
||||
}
|
||||
|
||||
func (r *Config) GetAllRemotes() []*lb.Node {
|
||||
lb := r.ToRemotesLB()
|
||||
return lb.GetAll()
|
||||
}
|
||||
|
||||
func (r *Config) GetLoggerName() string {
|
||||
return fmt.Sprintf("%s(%s<->%s)", r.Label, r.ListenType, r.TransportType)
|
||||
}
|
||||
|
||||
@@ -44,8 +44,9 @@ func newBaseRelayServer(cfg *conf.Config, cmgr cmgr.Cmgr) (*BaseRelayServer, err
|
||||
}
|
||||
|
||||
func (b *BaseRelayServer) RelayTCPConn(ctx context.Context, c net.Conn, remote *lb.Node) error {
|
||||
metrics.CurConnectionCount.WithLabelValues(remote.Label, metrics.METRIC_CONN_TYPE_TCP).Inc()
|
||||
defer metrics.CurConnectionCount.WithLabelValues(remote.Label, metrics.METRIC_CONN_TYPE_TCP).Dec()
|
||||
labels := []string{b.cfg.Label, metrics.METRIC_CONN_TYPE_TCP, remote.Address}
|
||||
metrics.CurConnectionCount.WithLabelValues(labels...).Inc()
|
||||
defer metrics.CurConnectionCount.WithLabelValues(labels...).Dec()
|
||||
|
||||
if err := b.checkConnectionLimit(); err != nil {
|
||||
return err
|
||||
@@ -68,8 +69,9 @@ func (b *BaseRelayServer) RelayTCPConn(ctx context.Context, c net.Conn, remote *
|
||||
}
|
||||
|
||||
func (b *BaseRelayServer) RelayUDPConn(ctx context.Context, c net.Conn, remote *lb.Node) error {
|
||||
metrics.CurConnectionCount.WithLabelValues(remote.Label, metrics.METRIC_CONN_TYPE_UDP).Inc()
|
||||
defer metrics.CurConnectionCount.WithLabelValues(remote.Label, metrics.METRIC_CONN_TYPE_UDP).Dec()
|
||||
labels := []string{b.cfg.Label, metrics.METRIC_CONN_TYPE_UDP, remote.Address}
|
||||
metrics.CurConnectionCount.WithLabelValues(labels...).Inc()
|
||||
defer metrics.CurConnectionCount.WithLabelValues(labels...).Dec()
|
||||
|
||||
rc, err := b.relayer.HandShake(ctx, remote, false)
|
||||
if err != nil {
|
||||
|
||||
@@ -47,7 +47,12 @@ func (raw *RawClient) HandShake(ctx context.Context, remote *lb.Node, isTCP bool
|
||||
return nil, err
|
||||
}
|
||||
latency := time.Since(t1)
|
||||
metrics.HandShakeDuration.WithLabelValues(remote.Label).Observe(float64(latency.Milliseconds()))
|
||||
connType := metrics.METRIC_CONN_TYPE_TCP
|
||||
if !isTCP {
|
||||
connType = metrics.METRIC_CONN_TYPE_UDP
|
||||
}
|
||||
labels := []string{raw.cfg.Label, connType, remote.Address}
|
||||
metrics.HandShakeDurationMilliseconds.WithLabelValues(labels...).Observe(float64(latency.Milliseconds()))
|
||||
remote.HandShakeDuration = latency
|
||||
return rc, nil
|
||||
}
|
||||
|
||||
@@ -67,7 +67,12 @@ func (s *WsClient) HandShake(ctx context.Context, remote *lb.Node, isTCP bool) (
|
||||
return nil, err
|
||||
}
|
||||
latency := time.Since(t1)
|
||||
metrics.HandShakeDuration.WithLabelValues(remote.Label).Observe(float64(latency.Milliseconds()))
|
||||
connType := metrics.METRIC_CONN_TYPE_TCP
|
||||
if !isTCP {
|
||||
connType = metrics.METRIC_CONN_TYPE_UDP
|
||||
}
|
||||
labels := []string{s.cfg.Label, connType, remote.Address}
|
||||
metrics.HandShakeDurationMilliseconds.WithLabelValues(labels...).Observe(float64(latency.Milliseconds()))
|
||||
remote.HandShakeDuration = latency
|
||||
c := conn.NewWSConn(wsc, false)
|
||||
return c, nil
|
||||
@@ -97,7 +102,7 @@ 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}
|
||||
remote = &lb.Node{Address: addr}
|
||||
} else {
|
||||
remote = s.remotes.Next()
|
||||
}
|
||||
|
||||
134
echo/internal/web/handler_api.go
Normal file
134
echo/internal/web/handler_api.go
Normal file
@@ -0,0 +1,134 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/Ehco1996/ehco/internal/cmgr/ms"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultTimeRange = 60 // seconds
|
||||
errInvalidParam = "invalid parameter: %s"
|
||||
)
|
||||
|
||||
type queryParams struct {
|
||||
startTS int64
|
||||
endTS int64
|
||||
refresh bool
|
||||
}
|
||||
|
||||
func parseQueryParams(c echo.Context) (*queryParams, error) {
|
||||
now := time.Now().Unix()
|
||||
params := &queryParams{
|
||||
startTS: now - defaultTimeRange,
|
||||
endTS: now,
|
||||
refresh: false,
|
||||
}
|
||||
|
||||
if start, err := parseTimestamp(c.QueryParam("start_ts")); err == nil {
|
||||
params.startTS = start
|
||||
}
|
||||
|
||||
if end, err := parseTimestamp(c.QueryParam("end_ts")); err == nil {
|
||||
params.endTS = end
|
||||
}
|
||||
|
||||
if refresh, err := strconv.ParseBool(c.QueryParam("latest")); err == nil {
|
||||
params.refresh = refresh
|
||||
}
|
||||
|
||||
if params.startTS >= params.endTS {
|
||||
return nil, fmt.Errorf(errInvalidParam, "time range")
|
||||
}
|
||||
|
||||
return params, nil
|
||||
}
|
||||
|
||||
func parseTimestamp(s string) (int64, error) {
|
||||
if s == "" {
|
||||
return 0, fmt.Errorf("empty timestamp")
|
||||
}
|
||||
return strconv.ParseInt(s, 10, 64)
|
||||
}
|
||||
|
||||
func (s *Server) GetNodeMetrics(c echo.Context) error {
|
||||
params, err := parseQueryParams(c)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
||||
}
|
||||
req := &ms.QueryNodeMetricsReq{StartTimestamp: params.startTS, EndTimestamp: params.endTS, Num: -1}
|
||||
if params.refresh {
|
||||
req.Num = 1
|
||||
}
|
||||
metrics, err := s.connMgr.QueryNodeMetrics(c.Request().Context(), req, params.refresh)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
return c.JSON(http.StatusOK, metrics)
|
||||
}
|
||||
|
||||
func (s *Server) GetRuleMetrics(c echo.Context) error {
|
||||
params, err := parseQueryParams(c)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
||||
}
|
||||
req := &ms.QueryRuleMetricsReq{
|
||||
StartTimestamp: params.startTS,
|
||||
EndTimestamp: params.endTS,
|
||||
Num: -1,
|
||||
RuleLabel: c.QueryParam("label"),
|
||||
Remote: c.QueryParam("remote"),
|
||||
}
|
||||
if params.refresh {
|
||||
req.Num = 1
|
||||
}
|
||||
|
||||
metrics, err := s.connMgr.QueryRuleMetrics(c.Request().Context(), req, params.refresh)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
return c.JSON(http.StatusOK, metrics)
|
||||
}
|
||||
|
||||
func (s *Server) CurrentConfig(c echo.Context) error {
|
||||
ret, err := json.Marshal(s.cfg)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
||||
}
|
||||
|
||||
return c.JSONBlob(http.StatusOK, ret)
|
||||
}
|
||||
|
||||
func (s *Server) HandleReload(c echo.Context) error {
|
||||
if s.Reloader == nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "reload not support")
|
||||
}
|
||||
err := s.Reloader.Reload(true)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
||||
}
|
||||
|
||||
if _, err := c.Response().Write([]byte("reload success")); err != nil {
|
||||
s.l.Errorf("write response meet err=%v", err)
|
||||
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) HandleHealthCheck(c echo.Context) error {
|
||||
relayLabel := c.QueryParam("relay_label")
|
||||
if relayLabel == "" {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "relay_label is required")
|
||||
}
|
||||
latency, err := s.HealthCheck(c.Request().Context(), relayLabel)
|
||||
if err != nil {
|
||||
res := HealthCheckResp{Message: err.Error(), ErrorCode: -1}
|
||||
return c.JSON(http.StatusBadRequest, res)
|
||||
}
|
||||
return c.JSON(http.StatusOK, HealthCheckResp{Message: "connect success", Latency: latency})
|
||||
}
|
||||
@@ -1,13 +1,10 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/Ehco1996/ehco/internal/cmgr/ms"
|
||||
"github.com/Ehco1996/ehco/internal/config"
|
||||
"github.com/Ehco1996/ehco/internal/constant"
|
||||
"github.com/labstack/echo/v4"
|
||||
@@ -42,44 +39,6 @@ func (s *Server) index(c echo.Context) error {
|
||||
return c.Render(http.StatusOK, "index.html", data)
|
||||
}
|
||||
|
||||
func (s *Server) HandleReload(c echo.Context) error {
|
||||
if s.Reloader == nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "reload not support")
|
||||
}
|
||||
err := s.Reloader.Reload(true)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
||||
}
|
||||
|
||||
if _, err := c.Response().Write([]byte("reload success")); err != nil {
|
||||
s.l.Errorf("write response meet err=%v", err)
|
||||
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) HandleHealthCheck(c echo.Context) error {
|
||||
relayLabel := c.QueryParam("relay_label")
|
||||
if relayLabel == "" {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "relay_label is required")
|
||||
}
|
||||
latency, err := s.HealthCheck(c.Request().Context(), relayLabel)
|
||||
if err != nil {
|
||||
res := HealthCheckResp{Message: err.Error(), ErrorCode: -1}
|
||||
return c.JSON(http.StatusBadRequest, res)
|
||||
}
|
||||
return c.JSON(http.StatusOK, HealthCheckResp{Message: "connect success", Latency: latency})
|
||||
}
|
||||
|
||||
func (s *Server) CurrentConfig(c echo.Context) error {
|
||||
ret, err := json.Marshal(s.cfg)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
||||
}
|
||||
|
||||
return c.JSONBlob(http.StatusOK, ret)
|
||||
}
|
||||
|
||||
func (s *Server) ListConnections(c echo.Context) error {
|
||||
pageStr := c.QueryParam("page")
|
||||
page, err := strconv.Atoi(pageStr)
|
||||
@@ -126,36 +85,12 @@ func (s *Server) ListRules(c echo.Context) error {
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) GetNodeMetrics(c echo.Context) error {
|
||||
startTS := time.Now().Unix() - 60
|
||||
if c.QueryParam("start_ts") != "" {
|
||||
star, err := strconv.ParseInt(c.QueryParam("start_ts"), 10, 64)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
||||
}
|
||||
startTS = star
|
||||
}
|
||||
endTS := time.Now().Unix()
|
||||
if c.QueryParam("end_ts") != "" {
|
||||
end, err := strconv.ParseInt(c.QueryParam("end_ts"), 10, 64)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
||||
}
|
||||
endTS = end
|
||||
}
|
||||
req := &ms.QueryNodeMetricsReq{StartTimestamp: startTS, EndTimestamp: endTS}
|
||||
latest := c.QueryParam("latest")
|
||||
if latest != "" {
|
||||
r, err := strconv.ParseBool(latest)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
||||
}
|
||||
req.Latest = r
|
||||
}
|
||||
|
||||
metrics, err := s.connMgr.QueryNodeMetrics(c.Request().Context(), req)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
||||
}
|
||||
return c.JSON(http.StatusOK, metrics)
|
||||
func (s *Server) RuleMetrics(c echo.Context) error {
|
||||
return c.Render(http.StatusOK, "rule_metrics.html", map[string]interface{}{
|
||||
"Configs": s.cfg.RelayConfigs,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) LogsPage(c echo.Context) error {
|
||||
return c.Render(http.StatusOK, "logs.html", nil)
|
||||
}
|
||||
33
echo/internal/web/handlers_ws.go
Normal file
33
echo/internal/web/handlers_ws.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net"
|
||||
|
||||
"github.com/Ehco1996/ehco/pkg/log"
|
||||
"github.com/gobwas/ws"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
func (s *Server) handleWebSocketLogs(c echo.Context) error {
|
||||
conn, _, _, err := ws.UpgradeHTTP(c.Request(), c.Response())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
log.SetWebSocketConn(conn)
|
||||
|
||||
// 保持连接打开并处理可能的入站消息
|
||||
for {
|
||||
_, err := ws.ReadFrame(conn)
|
||||
if err != nil {
|
||||
if _, ok := err.(net.Error); ok {
|
||||
// 处理网络错误
|
||||
s.l.Errorf("WebSocket read error: %v", err)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
log.SetWebSocketConn(nil)
|
||||
return nil
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -165,15 +165,22 @@ func setupRoutes(s *Server) {
|
||||
e.GET(metricsPath, echo.WrapHandler(promhttp.Handler()))
|
||||
e.GET("/debug/pprof/*", echo.WrapHandler(http.DefaultServeMux))
|
||||
|
||||
// web pages
|
||||
e.GET(indexPath, s.index)
|
||||
e.GET(connectionsPath, s.ListConnections)
|
||||
e.GET(rulesPath, s.ListRules)
|
||||
e.GET("/rule_metrics/", s.RuleMetrics)
|
||||
e.GET("/logs/", s.LogsPage)
|
||||
|
||||
api := e.Group(apiPrefix)
|
||||
api.GET("/config/", s.CurrentConfig)
|
||||
api.POST("/config/reload/", s.HandleReload)
|
||||
api.GET("/health_check/", s.HandleHealthCheck)
|
||||
api.GET("/node_metrics/", s.GetNodeMetrics)
|
||||
api.GET("/rule_metrics/", s.GetRuleMetrics)
|
||||
|
||||
// ws
|
||||
e.GET("/ws/logs", s.handleWebSocketLogs)
|
||||
}
|
||||
|
||||
func (s *Server) Start() error {
|
||||
|
||||
@@ -16,18 +16,20 @@
|
||||
<div class="navbar-start">
|
||||
<a href="/rules/" class="navbar-item">
|
||||
<span class="icon"><i class="fas fa-list"></i></span>
|
||||
<span>Rule List</span>
|
||||
<span>Rules</span>
|
||||
</a>
|
||||
<a href="/rule_metrics/" class="navbar-item">
|
||||
<span class="icon"><i class="fas fa-chart-line"></i></span>
|
||||
<span>Metrics</span>
|
||||
</a>
|
||||
<a href="/logs/" class="navbar-item">
|
||||
<span class="icon"><i class="fas fa-file-alt"></i></span>
|
||||
<span>Logs</span>
|
||||
</a>
|
||||
<a href="/connections/?conn_type=active/" class="navbar-item">
|
||||
<span class="icon"><i class="fas fa-link"></i></span>
|
||||
<span>Connections</span>
|
||||
</a>
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a class="navbar-link">
|
||||
<span class="icon"><i class="fas fa-link"></i></span>
|
||||
<span>Connections</span>
|
||||
</a>
|
||||
<div class="navbar-dropdown">
|
||||
<a href="/connections/?conn_type=active" class="navbar-item">Active</a>
|
||||
<a href="/connections/?conn_type=closed" class="navbar-item">Closed</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="navbar-end">
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- </div> -->
|
||||
<script src="js/metrics.js"></script>
|
||||
</div>
|
||||
|
||||
<script src="/js/node_metrics.js"></script>
|
||||
</script>
|
||||
82
echo/internal/web/templates/_rule_metrics_dash.html
Normal file
82
echo/internal/web/templates/_rule_metrics_dash.html
Normal file
@@ -0,0 +1,82 @@
|
||||
<div class="card" id="rule-metrics-card">
|
||||
<header class="card-header is-flex is-flex-wrap-wrap">
|
||||
<p class="card-header-title has-text-centered">Rule Metrics</p>
|
||||
<div class="card-header-icon is-flex-grow-1 is-flex is-justify-content-space-between">
|
||||
<div class="field is-horizontal mr-2">
|
||||
<div class="field-label is-small mr-2">
|
||||
<label class="label" for="labelFilter">Label:</label>
|
||||
</div>
|
||||
<div class="field-body">
|
||||
<div class="field">
|
||||
<div class="control">
|
||||
<div class="select is-small">
|
||||
<select id="labelFilter" aria-label="Filter by label">
|
||||
<option value="">All Labels</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field is-horizontal mr-2">
|
||||
<div class="field-label is-small mr-2">
|
||||
<label class="label" for="remoteFilter">Remote:</label>
|
||||
</div>
|
||||
<div class="field-body">
|
||||
<div class="field">
|
||||
<div class="control">
|
||||
<div class="select is-small">
|
||||
<select id="remoteFilter" aria-label="Filter by remote">
|
||||
<option value="">All Remotes</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dropdown" id="dateRangeDropdown">
|
||||
<div class="dropdown-trigger">
|
||||
<button class="button is-small" 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">Custom range</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="card-content">
|
||||
<div class="content">
|
||||
<div class="columns is-multiline">
|
||||
<div class="column is-6">
|
||||
<canvas id="connectionCountChart"></canvas>
|
||||
</div>
|
||||
<div class="column is-6">
|
||||
<canvas id="handshakeDurationChart"></canvas>
|
||||
</div>
|
||||
<div class="column is-12">
|
||||
<canvas id="pingLatencyChart"></canvas>
|
||||
</div>
|
||||
<div class="column is-12">
|
||||
<canvas id="networkTransmitBytesChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/rule_metrics.js"></script>
|
||||
@@ -1,4 +1,4 @@
|
||||
<!doctype html>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
{{template "_head.html" .}}
|
||||
<body>
|
||||
@@ -32,7 +32,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<!-- metrics -->
|
||||
{{template "_metrics.html" .}}
|
||||
{{template "_node_metrics_dash.html" .}}
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
|
||||
176
echo/internal/web/templates/logs.html
Normal file
176
echo/internal/web/templates/logs.html
Normal file
@@ -0,0 +1,176 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
{{template "_head.html" .}}
|
||||
<style>
|
||||
.logs-container {
|
||||
height: 700px;
|
||||
overflow-y: auto;
|
||||
font-family: 'Fira Code', monospace;
|
||||
border-radius: 6px;
|
||||
background-color: #f5f5f5;
|
||||
padding: 1rem;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
margin-bottom: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
</style>
|
||||
|
||||
<body>
|
||||
{{ template "_navbar.html" . }}
|
||||
<section class="section">
|
||||
<div class="container-fluid">
|
||||
<h1 class="title is-2 mb-6">Real-time Logs</h1>
|
||||
<div class="card">
|
||||
<div class="card-content">
|
||||
<div class="field has-addons mb-4">
|
||||
<div class="control is-expanded">
|
||||
<input class="input is-medium" type="text" id="filterInput" placeholder="Filter logs..." />
|
||||
</div>
|
||||
<div class="control">
|
||||
<button class="button is-info is-medium" id="filterButton">
|
||||
<span class="icon">
|
||||
<i class="fas fa-filter"></i>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="buttons mb-4">
|
||||
<button class="button is-warning is-medium" id="pauseButton">
|
||||
<span class="icon">
|
||||
<i class="fas fa-pause"></i>
|
||||
</span>
|
||||
<span>Pause</span>
|
||||
</button>
|
||||
<button class="button is-danger is-medium" id="clearButton">
|
||||
<span class="icon">
|
||||
<i class="fas fa-trash"></i>
|
||||
</span>
|
||||
<span>Clear</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="logsContainer" class="logs-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
const logsContainer = document.getElementById('logsContainer');
|
||||
const filterInput = document.getElementById('filterInput');
|
||||
const filterButton = document.getElementById('filterButton');
|
||||
const pauseButton = document.getElementById('pauseButton');
|
||||
const clearButton = document.getElementById('clearButton');
|
||||
let isPaused = false;
|
||||
let ws;
|
||||
let filterTerm = '';
|
||||
|
||||
function connectWebSocket() {
|
||||
ws = new WebSocket('ws://' + window.location.host + '/ws/logs');
|
||||
ws.onopen = function (event) {
|
||||
console.log('WebSocket connection established');
|
||||
};
|
||||
|
||||
ws.onerror = function (event) {
|
||||
console.error('WebSocket error observed:', event);
|
||||
};
|
||||
|
||||
ws.onmessage = function (event) {
|
||||
if (!isPaused) {
|
||||
const logEntry = document.createElement('div');
|
||||
logEntry.innerHTML = formatLogMessage(event.data);
|
||||
if (shouldDisplayLog(logEntry.textContent)) {
|
||||
logsContainer.appendChild(logEntry);
|
||||
logsContainer.scrollTop = logsContainer.scrollHeight;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = function (event) {
|
||||
console.log('WebSocket connection closed. Reconnecting...');
|
||||
setTimeout(connectWebSocket, 1000);
|
||||
};
|
||||
}
|
||||
|
||||
function formatLogMessage(message) {
|
||||
try {
|
||||
const logEntry = JSON.parse(message);
|
||||
const timestamp = logEntry.ts;
|
||||
const level = logEntry.level;
|
||||
const msg = logEntry.msg;
|
||||
const logger = logEntry.logger;
|
||||
console.log('Log entry:', logEntry);
|
||||
const levelClass = getLevelClass(level);
|
||||
|
||||
return `
|
||||
<div class="columns is-mobile">
|
||||
<div class="column is-3"><span class="has-text-grey-light is-small">${timestamp}</span></div>
|
||||
<div class="column is-1"><span class="tag ${levelClass} is-small">${level.toUpperCase()}</span></div>
|
||||
<div class="column is-2"><span class="is-info is-light is-small">${logger}</span></div>
|
||||
<div class="column"><span>${msg}</span></div>
|
||||
</div>
|
||||
`;
|
||||
} catch (e) {
|
||||
console.error('Error parsing log message:', e);
|
||||
return `<div class="has-text-danger">${message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function getLevelClass(level) {
|
||||
switch (level) {
|
||||
case 'debug':
|
||||
return 'is-light';
|
||||
case 'info':
|
||||
return 'is-info';
|
||||
case 'warn':
|
||||
return 'is-warning';
|
||||
case 'error':
|
||||
return 'is-danger';
|
||||
case 'fatal':
|
||||
return 'is-dark';
|
||||
default:
|
||||
return 'is-light';
|
||||
}
|
||||
}
|
||||
|
||||
function shouldDisplayLog(logText) {
|
||||
return filterTerm === '' || logText.toLowerCase().includes(filterTerm.toLowerCase());
|
||||
}
|
||||
|
||||
function applyFilter() {
|
||||
filterTerm = filterInput.value;
|
||||
const logEntries = logsContainer.getElementsByClassName('log-entry');
|
||||
for (let entry of logEntries) {
|
||||
if (shouldDisplayLog(entry.textContent)) {
|
||||
entry.style.display = '';
|
||||
} else {
|
||||
entry.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
connectWebSocket();
|
||||
|
||||
filterButton.addEventListener('click', applyFilter);
|
||||
filterInput.addEventListener('keyup', function (event) {
|
||||
if (event.key === 'Enter') {
|
||||
applyFilter();
|
||||
}
|
||||
});
|
||||
|
||||
pauseButton.addEventListener('click', function () {
|
||||
isPaused = !isPaused;
|
||||
pauseButton.textContent = isPaused ? 'Resume' : 'Pause';
|
||||
pauseButton.classList.toggle('is-warning');
|
||||
pauseButton.classList.toggle('is-success');
|
||||
});
|
||||
|
||||
clearButton.addEventListener('click', function () {
|
||||
logsContainer.innerHTML = '';
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,4 +1,4 @@
|
||||
<!doctype html>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
{{template "_head.html" .}}
|
||||
<body>
|
||||
@@ -53,7 +53,7 @@
|
||||
response.msg + // Use 'msg' as per Go struct
|
||||
' (Latency: ' +
|
||||
response.latency + // Ensure this matches the Go struct field name
|
||||
'ms)',
|
||||
'ms)'
|
||||
);
|
||||
} else {
|
||||
// If error code is not 0, show error message
|
||||
|
||||
14
echo/internal/web/templates/rule_metrics.html
Normal file
14
echo/internal/web/templates/rule_metrics.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
{{template "_head.html" .}}
|
||||
<body>
|
||||
{{ template "_navbar.html" . }}
|
||||
<section class="section">
|
||||
<div class="container">
|
||||
<h1 class="title">Rule Metrics</h1>
|
||||
|
||||
{{template "_rule_metrics_dash.html" .}}
|
||||
</div>
|
||||
</section>
|
||||
</body>
|
||||
</html>
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
var (
|
||||
doOnce sync.Once
|
||||
globalInitd bool
|
||||
|
||||
globalWebSocketSyncher *WebSocketLogSyncher
|
||||
)
|
||||
|
||||
func initLogger(logLevel string, replaceGlobal bool) (*zap.Logger, error) {
|
||||
@@ -18,8 +20,8 @@ func initLogger(logLevel string, replaceGlobal bool) (*zap.Logger, error) {
|
||||
if err := level.UnmarshalText([]byte(logLevel)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
writers := []zapcore.WriteSyncer{zapcore.AddSync(os.Stdout)}
|
||||
encoder := zapcore.EncoderConfig{
|
||||
|
||||
consoleEncoder := zapcore.NewConsoleEncoder(zapcore.EncoderConfig{
|
||||
TimeKey: "ts",
|
||||
LevelKey: "level",
|
||||
MessageKey: "msg",
|
||||
@@ -27,12 +29,29 @@ func initLogger(logLevel string, replaceGlobal bool) (*zap.Logger, error) {
|
||||
EncodeLevel: zapcore.LowercaseColorLevelEncoder,
|
||||
EncodeTime: zapcore.RFC3339TimeEncoder,
|
||||
EncodeName: zapcore.FullNameEncoder,
|
||||
}
|
||||
core := zapcore.NewCore(
|
||||
zapcore.NewConsoleEncoder(encoder),
|
||||
zapcore.NewMultiWriteSyncer(writers...),
|
||||
level,
|
||||
)
|
||||
})
|
||||
stdoutCore := zapcore.NewCore(consoleEncoder, zapcore.AddSync(os.Stdout), level)
|
||||
|
||||
jsonEncoder := zapcore.NewJSONEncoder(zapcore.EncoderConfig{
|
||||
TimeKey: "ts",
|
||||
LevelKey: "level",
|
||||
NameKey: "logger",
|
||||
CallerKey: "caller",
|
||||
MessageKey: "msg",
|
||||
StacktraceKey: "stacktrace",
|
||||
LineEnding: zapcore.DefaultLineEnding,
|
||||
EncodeLevel: zapcore.LowercaseLevelEncoder,
|
||||
EncodeTime: zapcore.ISO8601TimeEncoder,
|
||||
EncodeDuration: zapcore.SecondsDurationEncoder,
|
||||
EncodeCaller: zapcore.ShortCallerEncoder,
|
||||
})
|
||||
|
||||
globalWebSocketSyncher = NewWebSocketLogSyncher()
|
||||
wsCore := zapcore.NewCore(jsonEncoder, globalWebSocketSyncher, level)
|
||||
|
||||
// 合并两个 core
|
||||
core := zapcore.NewTee(stdoutCore, wsCore)
|
||||
|
||||
l := zap.New(core)
|
||||
if replaceGlobal {
|
||||
zap.ReplaceGlobals(l)
|
||||
|
||||
52
echo/pkg/log/ws.go
Normal file
52
echo/pkg/log/ws.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package log
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net"
|
||||
"sync"
|
||||
|
||||
"github.com/gobwas/ws"
|
||||
)
|
||||
|
||||
type WebSocketLogSyncher struct {
|
||||
conn net.Conn
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func NewWebSocketLogSyncher() *WebSocketLogSyncher {
|
||||
return &WebSocketLogSyncher{}
|
||||
}
|
||||
|
||||
func (wsSync *WebSocketLogSyncher) Write(p []byte) (n int, err error) {
|
||||
wsSync.mu.Lock()
|
||||
defer wsSync.mu.Unlock()
|
||||
|
||||
if wsSync.conn != nil {
|
||||
var logEntry map[string]interface{}
|
||||
if err := json.Unmarshal(p, &logEntry); err == nil {
|
||||
jsonData, _ := json.Marshal(logEntry)
|
||||
_ = ws.WriteFrame(wsSync.conn, ws.NewTextFrame(jsonData))
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func (wsSync *WebSocketLogSyncher) Sync() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (wsSync *WebSocketLogSyncher) SetWSConn(conn net.Conn) {
|
||||
wsSync.mu.Lock()
|
||||
defer wsSync.mu.Unlock()
|
||||
wsSync.conn = conn
|
||||
}
|
||||
|
||||
func SetWebSocketConn(conn net.Conn) {
|
||||
if globalWebSocketSyncher != nil {
|
||||
globalWebSocketSyncher.SetWSConn(conn)
|
||||
}
|
||||
}
|
||||
165
echo/pkg/metric_reader/node.go
Normal file
165
echo/pkg/metric_reader/node.go
Normal file
@@ -0,0 +1,165 @@
|
||||
package metric_reader
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
dto "github.com/prometheus/client_model/go"
|
||||
)
|
||||
|
||||
const (
|
||||
metricCPUSecondsTotal = "node_cpu_seconds_total"
|
||||
metricLoad1 = "node_load1"
|
||||
metricLoad5 = "node_load5"
|
||||
metricLoad15 = "node_load15"
|
||||
metricMemoryTotalBytes = "node_memory_total_bytes"
|
||||
metricMemoryActiveBytes = "node_memory_active_bytes"
|
||||
metricMemoryWiredBytes = "node_memory_wired_bytes"
|
||||
metricMemoryMemTotalBytes = "node_memory_MemTotal_bytes"
|
||||
metricMemoryMemAvailableBytes = "node_memory_MemAvailable_bytes"
|
||||
metricFilesystemSizeBytes = "node_filesystem_size_bytes"
|
||||
metricFilesystemAvailBytes = "node_filesystem_avail_bytes"
|
||||
metricNetworkReceiveBytesTotal = "node_network_receive_bytes_total"
|
||||
metricNetworkTransmitBytesTotal = "node_network_transmit_bytes_total"
|
||||
)
|
||||
|
||||
type NodeMetrics struct {
|
||||
// cpu
|
||||
CpuCoreCount int `json:"cpu_core_count"`
|
||||
CpuLoadInfo string `json:"cpu_load_info"`
|
||||
CpuUsagePercent float64 `json:"cpu_usage_percent"`
|
||||
|
||||
// memory
|
||||
MemoryTotalBytes int64 `json:"memory_total_bytes"`
|
||||
MemoryUsageBytes int64 `json:"memory_usage_bytes"`
|
||||
MemoryUsagePercent float64 `json:"memory_usage_percent"`
|
||||
|
||||
// disk
|
||||
DiskTotalBytes int64 `json:"disk_total_bytes"`
|
||||
DiskUsageBytes int64 `json:"disk_usage_bytes"`
|
||||
DiskUsagePercent float64 `json:"disk_usage_percent"`
|
||||
|
||||
// network
|
||||
NetworkReceiveBytesTotal int64 `json:"network_receive_bytes_total"`
|
||||
NetworkTransmitBytesTotal int64 `json:"network_transmit_bytes_total"`
|
||||
NetworkReceiveBytesRate float64 `json:"network_receive_bytes_rate"`
|
||||
NetworkTransmitBytesRate float64 `json:"network_transmit_bytes_rate"`
|
||||
|
||||
SyncTime time.Time
|
||||
}
|
||||
type cpuStats struct {
|
||||
totalTime float64
|
||||
idleTime float64
|
||||
cores int
|
||||
}
|
||||
|
||||
func (b *readerImpl) ParseNodeMetrics(metricMap map[string]*dto.MetricFamily, nm *NodeMetrics) error {
|
||||
isMac := metricMap[metricMemoryTotalBytes] != nil
|
||||
cpu := &cpuStats{}
|
||||
|
||||
b.processCPUMetrics(metricMap, cpu)
|
||||
b.processMemoryMetrics(metricMap, nm, isMac)
|
||||
b.processDiskMetrics(metricMap, nm)
|
||||
b.processNetworkMetrics(metricMap, nm)
|
||||
b.processLoadMetrics(metricMap, nm)
|
||||
|
||||
b.calculateFinalMetrics(nm, cpu)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *readerImpl) processCPUMetrics(metricMap map[string]*dto.MetricFamily, cpu *cpuStats) {
|
||||
if cpuMetric, ok := metricMap[metricCPUSecondsTotal]; ok {
|
||||
for _, metric := range cpuMetric.Metric {
|
||||
value := getMetricValue(metric, cpuMetric.GetType())
|
||||
cpu.totalTime += value
|
||||
if getLabel(metric, "mode") == "idle" {
|
||||
cpu.idleTime += value
|
||||
cpu.cores++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *readerImpl) processMemoryMetrics(metricMap map[string]*dto.MetricFamily, nm *NodeMetrics, isMac bool) {
|
||||
if isMac {
|
||||
nm.MemoryTotalBytes = sumInt64Metric(metricMap, metricMemoryTotalBytes)
|
||||
nm.MemoryUsageBytes = sumInt64Metric(metricMap, metricMemoryActiveBytes) + sumInt64Metric(metricMap, metricMemoryWiredBytes)
|
||||
} else {
|
||||
nm.MemoryTotalBytes = sumInt64Metric(metricMap, metricMemoryMemTotalBytes)
|
||||
availableMemory := sumInt64Metric(metricMap, metricMemoryMemAvailableBytes)
|
||||
nm.MemoryUsageBytes = nm.MemoryTotalBytes - availableMemory
|
||||
}
|
||||
}
|
||||
|
||||
func (b *readerImpl) processDiskMetrics(metricMap map[string]*dto.MetricFamily, nm *NodeMetrics) {
|
||||
nm.DiskTotalBytes = sumInt64Metric(metricMap, metricFilesystemSizeBytes)
|
||||
availableDisk := sumInt64Metric(metricMap, metricFilesystemAvailBytes)
|
||||
nm.DiskUsageBytes = nm.DiskTotalBytes - availableDisk
|
||||
}
|
||||
|
||||
func (b *readerImpl) processNetworkMetrics(metricMap map[string]*dto.MetricFamily, nm *NodeMetrics) {
|
||||
nm.NetworkReceiveBytesTotal = sumInt64Metric(metricMap, metricNetworkReceiveBytesTotal)
|
||||
nm.NetworkTransmitBytesTotal = sumInt64Metric(metricMap, metricNetworkTransmitBytesTotal)
|
||||
}
|
||||
|
||||
func (b *readerImpl) processLoadMetrics(metricMap map[string]*dto.MetricFamily, nm *NodeMetrics) {
|
||||
loads := []string{metricLoad1, metricLoad5, metricLoad15}
|
||||
for _, load := range loads {
|
||||
value := sumFloat64Metric(metricMap, load)
|
||||
nm.CpuLoadInfo += fmt.Sprintf("%.2f|", value)
|
||||
}
|
||||
nm.CpuLoadInfo = strings.TrimRight(nm.CpuLoadInfo, "|")
|
||||
}
|
||||
|
||||
func (b *readerImpl) calculateFinalMetrics(nm *NodeMetrics, cpu *cpuStats) {
|
||||
nm.CpuCoreCount = cpu.cores
|
||||
nm.CpuUsagePercent = 100 * (cpu.totalTime - cpu.idleTime) / cpu.totalTime
|
||||
nm.MemoryUsagePercent = 100 * float64(nm.MemoryUsageBytes) / float64(nm.MemoryTotalBytes)
|
||||
nm.DiskUsagePercent = 100 * float64(nm.DiskUsageBytes) / float64(nm.DiskTotalBytes)
|
||||
|
||||
nm.CpuUsagePercent = math.Round(nm.CpuUsagePercent*100) / 100
|
||||
nm.MemoryUsagePercent = math.Round(nm.MemoryUsagePercent*100) / 100
|
||||
nm.DiskUsagePercent = math.Round(nm.DiskUsagePercent*100) / 100
|
||||
|
||||
if b.lastMetrics != nil {
|
||||
duration := time.Since(b.lastMetrics.SyncTime).Seconds()
|
||||
if duration > 0.1 {
|
||||
nm.NetworkReceiveBytesRate = math.Max(0, float64(nm.NetworkReceiveBytesTotal-b.lastMetrics.NetworkReceiveBytesTotal)/duration)
|
||||
nm.NetworkTransmitBytesRate = math.Max(0, float64(nm.NetworkTransmitBytesTotal-b.lastMetrics.NetworkTransmitBytesTotal)/duration)
|
||||
nm.NetworkReceiveBytesRate = math.Round(nm.NetworkReceiveBytesRate)
|
||||
nm.NetworkTransmitBytesRate = math.Round(nm.NetworkTransmitBytesRate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sumInt64Metric(metricMap map[string]*dto.MetricFamily, metricName string) int64 {
|
||||
ret := int64(0)
|
||||
if metric, ok := metricMap[metricName]; ok && len(metric.Metric) > 0 {
|
||||
for _, m := range metric.Metric {
|
||||
ret += int64(getMetricValue(m, metric.GetType()))
|
||||
}
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func sumFloat64Metric(metricMap map[string]*dto.MetricFamily, metricName string) float64 {
|
||||
ret := float64(0)
|
||||
if metric, ok := metricMap[metricName]; ok && len(metric.Metric) > 0 {
|
||||
for _, m := range metric.Metric {
|
||||
ret += getMetricValue(m, metric.GetType())
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func getLabel(metric *dto.Metric, name string) string {
|
||||
for _, label := range metric.Label {
|
||||
if label.GetName() == name {
|
||||
return label.GetValue()
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -2,25 +2,28 @@ package metric_reader
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
dto "github.com/prometheus/client_model/go"
|
||||
"github.com/prometheus/common/expfmt"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type Reader interface {
|
||||
ReadOnce(ctx context.Context) (*NodeMetrics, error)
|
||||
ReadOnce(ctx context.Context) (*NodeMetrics, map[string]*RuleMetrics, error)
|
||||
}
|
||||
|
||||
type readerImpl struct {
|
||||
metricsURL string
|
||||
httpClient *http.Client
|
||||
lastMetrics *NodeMetrics
|
||||
metricsURL string
|
||||
httpClient *http.Client
|
||||
|
||||
lastMetrics *NodeMetrics
|
||||
lastRuleMetrics map[string]*RuleMetrics // key: label value: RuleMetrics
|
||||
l *zap.SugaredLogger
|
||||
}
|
||||
|
||||
func NewReader(metricsURL string) *readerImpl {
|
||||
@@ -28,267 +31,47 @@ func NewReader(metricsURL string) *readerImpl {
|
||||
return &readerImpl{
|
||||
httpClient: c,
|
||||
metricsURL: metricsURL,
|
||||
l: zap.S().Named("metric_reader"),
|
||||
}
|
||||
}
|
||||
|
||||
func (b *readerImpl) parsePingInfo(metricMap map[string]*dto.MetricFamily, nm *NodeMetrics) error {
|
||||
metric, ok := metricMap["ehco_ping_response_duration_seconds"]
|
||||
if !ok {
|
||||
// this metric is optional when enable_ping = false
|
||||
zap.S().Debug("ping metric not found")
|
||||
return nil
|
||||
}
|
||||
for _, m := range metric.Metric {
|
||||
g := m.GetHistogram()
|
||||
ip := ""
|
||||
val := float64(g.GetSampleSum()) / float64(g.GetSampleCount()) * 1000 // to ms
|
||||
for _, label := range m.GetLabel() {
|
||||
if label.GetName() == "ip" {
|
||||
ip = label.GetValue()
|
||||
}
|
||||
}
|
||||
nm.PingMetrics = append(nm.PingMetrics, PingMetric{Latency: val, Target: ip})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *readerImpl) parseCpuInfo(metricMap map[string]*dto.MetricFamily, nm *NodeMetrics) error {
|
||||
handleMetric := func(metricName string, handleValue func(float64, string)) error {
|
||||
metric, ok := metricMap[metricName]
|
||||
if !ok {
|
||||
return fmt.Errorf("%s not found", metricName)
|
||||
}
|
||||
|
||||
for _, m := range metric.Metric {
|
||||
g := m.GetCounter()
|
||||
mode := ""
|
||||
for _, label := range m.GetLabel() {
|
||||
if label.GetName() == "mode" {
|
||||
mode = label.GetValue()
|
||||
}
|
||||
}
|
||||
handleValue(g.GetValue(), mode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var (
|
||||
totalIdleTime float64
|
||||
totalCpuTime float64
|
||||
cpuCores int
|
||||
)
|
||||
|
||||
err := handleMetric("node_cpu_seconds_total", func(val float64, mode string) {
|
||||
totalCpuTime += val
|
||||
if mode == "idle" {
|
||||
totalIdleTime += val
|
||||
cpuCores++
|
||||
}
|
||||
})
|
||||
func (b *readerImpl) ReadOnce(ctx context.Context) (*NodeMetrics, map[string]*RuleMetrics, error) {
|
||||
metricMap, err := b.fetchMetrics(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, nil, errors.Wrap(err, "failed to fetch metrics")
|
||||
}
|
||||
nm := &NodeMetrics{SyncTime: time.Now()}
|
||||
if err := b.ParseNodeMetrics(metricMap, nm); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
nm.CpuCoreCount = cpuCores
|
||||
nm.CpuUsagePercent = 100 * (totalCpuTime - totalIdleTime) / totalCpuTime
|
||||
for _, load := range []string{"1", "5", "15"} {
|
||||
loadMetricName := fmt.Sprintf("node_load%s", load)
|
||||
loadMetric, ok := metricMap[loadMetricName]
|
||||
if !ok {
|
||||
return fmt.Errorf("%s not found", loadMetricName)
|
||||
}
|
||||
for _, m := range loadMetric.Metric {
|
||||
g := m.GetGauge()
|
||||
nm.CpuLoadInfo += fmt.Sprintf("%.2f|", g.GetValue())
|
||||
}
|
||||
}
|
||||
nm.CpuLoadInfo = strings.TrimRight(nm.CpuLoadInfo, "|")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *readerImpl) parseMemoryInfo(metricMap map[string]*dto.MetricFamily, nm *NodeMetrics) error {
|
||||
handleMetric := func(metricName string, handleValue func(float64)) error {
|
||||
metric, ok := metricMap[metricName]
|
||||
if !ok {
|
||||
return fmt.Errorf("%s not found", metricName)
|
||||
}
|
||||
for _, m := range metric.Metric {
|
||||
g := m.GetGauge()
|
||||
handleValue(g.GetValue())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
isMac := false
|
||||
if _, ok := metricMap["node_memory_total_bytes"]; ok {
|
||||
isMac = true
|
||||
}
|
||||
|
||||
if isMac {
|
||||
err := handleMetric("node_memory_total_bytes", func(val float64) {
|
||||
nm.MemoryTotalBytes = val
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = handleMetric("node_memory_active_bytes", func(val float64) {
|
||||
nm.MemoryUsageBytes += val
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = handleMetric("node_memory_wired_bytes", func(val float64) {
|
||||
nm.MemoryUsageBytes += val
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
err := handleMetric("node_memory_MemTotal_bytes", func(val float64) {
|
||||
nm.MemoryTotalBytes = val
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = handleMetric("node_memory_MemAvailable_bytes", func(val float64) {
|
||||
nm.MemoryUsageBytes = nm.MemoryTotalBytes - val
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if nm.MemoryTotalBytes != 0 {
|
||||
nm.MemoryUsagePercent = 100 * nm.MemoryUsageBytes / nm.MemoryTotalBytes
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *readerImpl) parseDiskInfo(metricMap map[string]*dto.MetricFamily, nm *NodeMetrics) error {
|
||||
handleMetric := func(metricName string, handleValue func(float64)) error {
|
||||
forMac := false
|
||||
diskMap := make(map[string]float64)
|
||||
metric, ok := metricMap[metricName]
|
||||
if !ok {
|
||||
return fmt.Errorf("%s not found", metricName)
|
||||
}
|
||||
for _, m := range metric.Metric {
|
||||
g := m.GetGauge()
|
||||
disk := ""
|
||||
for _, label := range m.GetLabel() {
|
||||
if label.GetName() == "device" {
|
||||
disk = getDiskName(label.GetValue())
|
||||
}
|
||||
if label.GetName() == "fstype" && label.GetValue() == "apfs" {
|
||||
forMac = true
|
||||
}
|
||||
}
|
||||
diskMap[disk] = g.GetValue()
|
||||
}
|
||||
// 对于 macos 的 apfs 文件系统,可能会有多个相同大小的磁盘,这是因为 apfs 磁盘(卷)会共享物理磁盘
|
||||
seenVal := map[float64]bool{}
|
||||
for _, val := range diskMap {
|
||||
if seenVal[val] && forMac {
|
||||
continue
|
||||
}
|
||||
handleValue(val)
|
||||
seenVal[val] = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
err := handleMetric("node_filesystem_size_bytes", func(val float64) {
|
||||
nm.DiskTotalBytes += val
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var availBytes float64
|
||||
err = handleMetric("node_filesystem_avail_bytes", func(val float64) {
|
||||
availBytes += val
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
nm.DiskUsageBytes = nm.DiskTotalBytes - availBytes
|
||||
if nm.DiskTotalBytes != 0 {
|
||||
nm.DiskUsagePercent = 100 * nm.DiskUsageBytes / nm.DiskTotalBytes
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *readerImpl) parseNetworkInfo(metricMap map[string]*dto.MetricFamily, nm *NodeMetrics) error {
|
||||
now := time.Now()
|
||||
handleMetric := func(metricName string, handleValue func(float64)) error {
|
||||
metric, ok := metricMap[metricName]
|
||||
if !ok {
|
||||
return fmt.Errorf("%s not found", metricName)
|
||||
}
|
||||
for _, m := range metric.Metric {
|
||||
g := m.GetCounter()
|
||||
handleValue(g.GetValue())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
err := handleMetric("node_network_receive_bytes_total", func(val float64) {
|
||||
nm.NetworkReceiveBytesTotal += val
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = handleMetric("node_network_transmit_bytes_total", func(val float64) {
|
||||
nm.NetworkTransmitBytesTotal += val
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if b.lastMetrics != nil {
|
||||
passedTime := now.Sub(b.lastMetrics.SyncTime).Seconds()
|
||||
nm.NetworkReceiveBytesRate = (nm.NetworkReceiveBytesTotal - b.lastMetrics.NetworkReceiveBytesTotal) / passedTime
|
||||
nm.NetworkTransmitBytesRate = (nm.NetworkTransmitBytesTotal - b.lastMetrics.NetworkTransmitBytesTotal) / passedTime
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *readerImpl) ReadOnce(ctx context.Context) (*NodeMetrics, error) {
|
||||
response, err := b.httpClient.Get(b.metricsURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var parser expfmt.TextParser
|
||||
parsed, err := parser.TextToMetricFamilies(strings.NewReader(string(body)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nm := &NodeMetrics{SyncTime: time.Now(), PingMetrics: []PingMetric{}}
|
||||
if err := b.parseCpuInfo(parsed, nm); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := b.parseMemoryInfo(parsed, nm); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := b.parseDiskInfo(parsed, nm); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := b.parseNetworkInfo(parsed, nm); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := b.parsePingInfo(parsed, nm); err != nil {
|
||||
return nil, err
|
||||
rm := make(map[string]*RuleMetrics)
|
||||
if err := b.ParseRuleMetrics(metricMap, rm); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
b.lastMetrics = nm
|
||||
return nm, nil
|
||||
b.lastRuleMetrics = rm
|
||||
return nm, rm, nil
|
||||
}
|
||||
|
||||
func (r *readerImpl) fetchMetrics(ctx context.Context) (map[string]*dto.MetricFamily, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", r.metricsURL, nil)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to create request")
|
||||
}
|
||||
|
||||
resp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to send request")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to read response body")
|
||||
}
|
||||
|
||||
var parser expfmt.TextParser
|
||||
return parser.TextToMetricFamilies(strings.NewReader(string(body)))
|
||||
}
|
||||
|
||||
146
echo/pkg/metric_reader/rule.go
Normal file
146
echo/pkg/metric_reader/rule.go
Normal file
@@ -0,0 +1,146 @@
|
||||
package metric_reader
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
dto "github.com/prometheus/client_model/go"
|
||||
)
|
||||
|
||||
const (
|
||||
metricConnectionCount = "ehco_traffic_current_connection_count"
|
||||
metricNetworkTransmit = "ehco_traffic_network_transmit_bytes"
|
||||
metricPingResponse = "ehco_ping_response_duration_milliseconds"
|
||||
metricHandshakeDuration = "ehco_traffic_handshake_duration_milliseconds"
|
||||
|
||||
labelKey = "label"
|
||||
remoteKey = "remote"
|
||||
connTypeKey = "conn_type"
|
||||
flowKey = "flow"
|
||||
ipKey = "ip"
|
||||
)
|
||||
|
||||
type PingMetric struct {
|
||||
Latency int64 `json:"latency"` // in ms
|
||||
Target string `json:"target"`
|
||||
}
|
||||
|
||||
type RuleMetrics struct {
|
||||
Label string // rule label
|
||||
|
||||
PingMetrics map[string]*PingMetric // key: remote
|
||||
|
||||
TCPConnectionCount map[string]int64 // key: remote
|
||||
TCPHandShakeDuration map[string]int64 // key: remote in ms
|
||||
TCPNetworkTransmitBytes map[string]int64 // key: remote
|
||||
|
||||
UDPConnectionCount map[string]int64 // key: remote
|
||||
UDPHandShakeDuration map[string]int64 // key: remote in ms
|
||||
UDPNetworkTransmitBytes map[string]int64 // key: remote
|
||||
|
||||
SyncTime time.Time
|
||||
}
|
||||
|
||||
func (b *readerImpl) ParseRuleMetrics(metricMap map[string]*dto.MetricFamily, rm map[string]*RuleMetrics) error {
|
||||
requiredMetrics := []string{
|
||||
metricConnectionCount,
|
||||
metricNetworkTransmit,
|
||||
metricPingResponse,
|
||||
metricHandshakeDuration,
|
||||
}
|
||||
|
||||
for _, metricName := range requiredMetrics {
|
||||
metricFamily, ok := metricMap[metricName]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, metric := range metricFamily.Metric {
|
||||
labels := getLabelMap(metric)
|
||||
value := int64(getMetricValue(metric, metricFamily.GetType()))
|
||||
label, ok := labels[labelKey]
|
||||
if !ok || label == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
ruleMetric := b.ensureRuleMetric(rm, label)
|
||||
|
||||
switch metricName {
|
||||
case metricConnectionCount:
|
||||
b.updateConnectionCount(ruleMetric, labels, value)
|
||||
case metricNetworkTransmit:
|
||||
b.updateNetworkTransmit(ruleMetric, labels, value)
|
||||
case metricPingResponse:
|
||||
b.updatePingMetrics(ruleMetric, labels, value)
|
||||
case metricHandshakeDuration:
|
||||
b.updateHandshakeDuration(ruleMetric, labels, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *readerImpl) ensureRuleMetric(rm map[string]*RuleMetrics, label string) *RuleMetrics {
|
||||
if _, ok := rm[label]; !ok {
|
||||
rm[label] = &RuleMetrics{
|
||||
Label: label,
|
||||
PingMetrics: make(map[string]*PingMetric),
|
||||
TCPConnectionCount: make(map[string]int64),
|
||||
TCPHandShakeDuration: make(map[string]int64),
|
||||
TCPNetworkTransmitBytes: make(map[string]int64),
|
||||
UDPConnectionCount: make(map[string]int64),
|
||||
UDPHandShakeDuration: make(map[string]int64),
|
||||
UDPNetworkTransmitBytes: make(map[string]int64),
|
||||
|
||||
SyncTime: time.Now(),
|
||||
}
|
||||
}
|
||||
return rm[label]
|
||||
}
|
||||
|
||||
func (b *readerImpl) updateConnectionCount(rm *RuleMetrics, labels map[string]string, value int64) {
|
||||
key := labels[remoteKey]
|
||||
switch labels[connTypeKey] {
|
||||
case "tcp":
|
||||
rm.TCPConnectionCount[key] = value
|
||||
default:
|
||||
rm.UDPConnectionCount[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
func (b *readerImpl) updateNetworkTransmit(rm *RuleMetrics, labels map[string]string, value int64) {
|
||||
if labels[flowKey] == "read" {
|
||||
key := labels[remoteKey]
|
||||
switch labels[connTypeKey] {
|
||||
case "tcp":
|
||||
rm.TCPNetworkTransmitBytes[key] += value
|
||||
default:
|
||||
rm.UDPNetworkTransmitBytes[key] += value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *readerImpl) updatePingMetrics(rm *RuleMetrics, labels map[string]string, value int64) {
|
||||
remote := labels[remoteKey]
|
||||
rm.PingMetrics[remote] = &PingMetric{
|
||||
Latency: value,
|
||||
Target: labels[ipKey],
|
||||
}
|
||||
}
|
||||
|
||||
func (b *readerImpl) updateHandshakeDuration(rm *RuleMetrics, labels map[string]string, value int64) {
|
||||
key := labels[remoteKey]
|
||||
switch labels[connTypeKey] {
|
||||
case "tcp":
|
||||
rm.TCPHandShakeDuration[key] = value
|
||||
default:
|
||||
rm.UDPHandShakeDuration[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
func getLabelMap(metric *dto.Metric) map[string]string {
|
||||
labels := make(map[string]string)
|
||||
for _, label := range metric.Label {
|
||||
labels[label.GetName()] = label.GetValue()
|
||||
}
|
||||
return labels
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
package metric_reader
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type NodeMetrics struct {
|
||||
// cpu
|
||||
CpuCoreCount int `json:"cpu_core_count"`
|
||||
CpuLoadInfo string `json:"cpu_load_info"`
|
||||
CpuUsagePercent float64 `json:"cpu_usage_percent"`
|
||||
|
||||
// memory
|
||||
MemoryTotalBytes float64 `json:"memory_total_bytes"`
|
||||
MemoryUsageBytes float64 `json:"memory_usage_bytes"`
|
||||
MemoryUsagePercent float64 `json:"memory_usage_percent"`
|
||||
|
||||
// disk
|
||||
DiskTotalBytes float64 `json:"disk_total_bytes"`
|
||||
DiskUsageBytes float64 `json:"disk_usage_bytes"`
|
||||
DiskUsagePercent float64 `json:"disk_usage_percent"`
|
||||
|
||||
// network
|
||||
NetworkReceiveBytesTotal float64 `json:"network_receive_bytes_total"`
|
||||
NetworkTransmitBytesTotal float64 `json:"network_transmit_bytes_total"`
|
||||
NetworkReceiveBytesRate float64 `json:"network_receive_bytes_rate"`
|
||||
NetworkTransmitBytesRate float64 `json:"network_transmit_bytes_rate"`
|
||||
|
||||
// ping
|
||||
PingMetrics []PingMetric `json:"ping_metrics"`
|
||||
|
||||
SyncTime time.Time
|
||||
}
|
||||
|
||||
type PingMetric struct {
|
||||
Latency float64 `json:"latency"` // in ms
|
||||
Target string `json:"target"`
|
||||
}
|
||||
@@ -1,22 +1,46 @@
|
||||
package metric_reader
|
||||
|
||||
import "regexp"
|
||||
import (
|
||||
"math"
|
||||
|
||||
// parse disk name from device path,such as:
|
||||
// e.g. /dev/disk1s1 -> disk1
|
||||
// e.g. /dev/disk1s2 -> disk1
|
||||
// e.g. ntfs://disk1s1 -> disk1
|
||||
// e.g. ntfs://disk1s2 -> disk1
|
||||
// e.g. /dev/sda1 -> sda
|
||||
// e.g. /dev/sda2 -> sda
|
||||
var diskNameRegex = regexp.MustCompile(`/dev/disk(\d+)|ntfs://disk(\d+)|/dev/sd[a-zA-Z]`)
|
||||
dto "github.com/prometheus/client_model/go"
|
||||
)
|
||||
|
||||
func getDiskName(devicePath string) string {
|
||||
matches := diskNameRegex.FindStringSubmatch(devicePath)
|
||||
for _, match := range matches {
|
||||
if match != "" {
|
||||
return match
|
||||
func calculatePercentile(histogram *dto.Histogram, percentile float64) float64 {
|
||||
if histogram == nil {
|
||||
return 0
|
||||
}
|
||||
totalSamples := histogram.GetSampleCount()
|
||||
targetSample := percentile * float64(totalSamples)
|
||||
cumulativeCount := uint64(0)
|
||||
var lastBucketBound float64
|
||||
|
||||
for _, bucket := range histogram.Bucket {
|
||||
cumulativeCount += bucket.GetCumulativeCount()
|
||||
if float64(cumulativeCount) >= targetSample {
|
||||
// Linear interpolation between bucket boundaries
|
||||
if bucket.GetCumulativeCount() > 0 && lastBucketBound != bucket.GetUpperBound() {
|
||||
return lastBucketBound + (float64(targetSample-float64(cumulativeCount-bucket.GetCumulativeCount()))/float64(bucket.GetCumulativeCount()))*(bucket.GetUpperBound()-lastBucketBound)
|
||||
} else {
|
||||
return bucket.GetUpperBound()
|
||||
}
|
||||
}
|
||||
lastBucketBound = bucket.GetUpperBound()
|
||||
}
|
||||
return math.NaN()
|
||||
}
|
||||
|
||||
func getMetricValue(metric *dto.Metric, metricType dto.MetricType) float64 {
|
||||
switch metricType {
|
||||
case dto.MetricType_COUNTER:
|
||||
return metric.Counter.GetValue()
|
||||
case dto.MetricType_GAUGE:
|
||||
return metric.Gauge.GetValue()
|
||||
case dto.MetricType_HISTOGRAM:
|
||||
histogram := metric.Histogram
|
||||
if histogram != nil {
|
||||
return calculatePercentile(histogram, 0.9)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
package echo
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
@@ -118,6 +119,10 @@ func (s *EchoServer) handleTCPConn(conn net.Conn) {
|
||||
}
|
||||
}
|
||||
|
||||
func isClosedConnError(err error) bool {
|
||||
return errors.Is(err, net.ErrClosed)
|
||||
}
|
||||
|
||||
func (s *EchoServer) serveUDP() {
|
||||
defer s.wg.Done()
|
||||
buf := make([]byte, 1024)
|
||||
@@ -128,6 +133,9 @@ func (s *EchoServer) serveUDP() {
|
||||
default:
|
||||
n, remoteAddr, err := s.udpConn.ReadFromUDP(buf)
|
||||
if err != nil {
|
||||
if isClosedConnError(err) {
|
||||
break
|
||||
}
|
||||
s.logger.Errorf("Error reading UDP: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user