chore: sync local updates
This commit is contained in:
17
.gitignore
vendored
17
.gitignore
vendored
@@ -15,9 +15,9 @@ Desktop.ini
|
||||
*~
|
||||
.idea/
|
||||
.vscode/
|
||||
*.sublime-project
|
||||
*.sublime-workspace
|
||||
debug-logs/
|
||||
*.sublime-project
|
||||
*.sublime-workspace
|
||||
debug-logs/
|
||||
|
||||
# Backend (Go)
|
||||
/mengyamonitor-backend/mengyamonitor-backend
|
||||
@@ -31,6 +31,17 @@ debug-logs/
|
||||
/mengyamonitor-backend/debug
|
||||
/mengyamonitor-backend/__debug_bin
|
||||
|
||||
# Central server (Gin)
|
||||
/mengyamonitor-backend-server/mengyamonitor-backend-server
|
||||
/mengyamonitor-backend-server/mengyamonitor-backend-server.exe
|
||||
/mengyamonitor-backend-server/*.exe
|
||||
/mengyamonitor-backend-server/data/
|
||||
/mengyamonitor-backend-server/release/
|
||||
|
||||
# Metrics client (采集端)
|
||||
/mengyamonitor-backend-client/dist/
|
||||
/mengyamonitor-backend-client/*.exe
|
||||
|
||||
# Frontend (Node/React/Vite)
|
||||
/mengyamonitor-frontend/node_modules/
|
||||
/mengyamonitor-frontend/dist/
|
||||
|
||||
40
mengyamonitor-backend-client/build_linux.ps1
Normal file
40
mengyamonitor-backend-client/build_linux.ps1
Normal file
@@ -0,0 +1,40 @@
|
||||
# Cross-compile Linux agent (amd64 + arm64). Same as build_linux.sh.
|
||||
# Usage: cd mengyamonitor-backend-client; .\build_linux.ps1
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$Root = $PSScriptRoot
|
||||
Set-Location $Root
|
||||
|
||||
$name = if ($env:BINARY_NAME) { $env:BINARY_NAME } else { "mengyamonitor-agent" }
|
||||
$out = if ($env:OUT_DIR) { $env:OUT_DIR } else { "dist" }
|
||||
|
||||
New-Item -ItemType Directory -Force -Path (Join-Path $out "linux_amd64"), (Join-Path $out "linux_arm64") | Out-Null
|
||||
|
||||
$env:CGO_ENABLED = "0"
|
||||
|
||||
Write-Host "==> linux/amd64 -> $out/linux_amd64/$name"
|
||||
$env:GOOS = "linux"
|
||||
$env:GOARCH = "amd64"
|
||||
try {
|
||||
$destAmd = Join-Path (Join-Path $out "linux_amd64") $name
|
||||
go build -trimpath -ldflags="-s -w" -o $destAmd .
|
||||
}
|
||||
finally {
|
||||
Remove-Item Env:GOOS, Env:GOARCH -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
Write-Host "==> linux/arm64 -> $out/linux_arm64/$name"
|
||||
$env:GOOS = "linux"
|
||||
$env:GOARCH = "arm64"
|
||||
try {
|
||||
$destArm = Join-Path (Join-Path $out "linux_arm64") $name
|
||||
go build -trimpath -ldflags="-s -w" -o $destArm .
|
||||
}
|
||||
finally {
|
||||
Remove-Item Env:GOOS, Env:GOARCH -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
Write-Host "Done."
|
||||
$p1 = Join-Path (Join-Path $out "linux_amd64") $name
|
||||
$p2 = Join-Path (Join-Path $out "linux_arm64") $name
|
||||
Get-Item -LiteralPath $p1, $p2 | Format-Table -Property FullName, Length
|
||||
39
mengyamonitor-backend-client/build_linux.sh
Normal file
39
mengyamonitor-backend-client/build_linux.sh
Normal file
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env bash
|
||||
# 一键交叉编译 Linux 采集端(amd64 + arm64),输出与 dist/install_mengyamonitor.sh 发布路径一致。
|
||||
#
|
||||
# 用法(在 mengyamonitor-backend-client 目录下):
|
||||
# chmod +x build_linux.sh && ./build_linux.sh
|
||||
#
|
||||
# Windows: Git Bash 同上;或在项目目录执行: powershell -ExecutionPolicy Bypass -File .\build_linux.ps1
|
||||
#
|
||||
# 可选环境变量:
|
||||
# BINARY_NAME 默认 mengyamonitor-agent
|
||||
# OUT_DIR 默认 dist(相对本脚本所在目录)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
NAME="${BINARY_NAME:-mengyamonitor-agent}"
|
||||
OUT="${OUT_DIR:-dist}"
|
||||
|
||||
mkdir -p "$OUT/linux_amd64" "$OUT/linux_arm64"
|
||||
|
||||
export CGO_ENABLED=0
|
||||
|
||||
echo "==> linux/amd64 -> $OUT/linux_amd64/$NAME"
|
||||
GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o "$OUT/linux_amd64/$NAME" .
|
||||
|
||||
echo "==> linux/arm64 -> $OUT/linux_arm64/$NAME"
|
||||
GOOS=linux GOARCH=arm64 go build -trimpath -ldflags="-s -w" -o "$OUT/linux_arm64/$NAME" .
|
||||
|
||||
echo "完成。产物:"
|
||||
for arch in amd64 arm64; do
|
||||
f="$OUT/linux_${arch}/$NAME"
|
||||
if command -v ls >/dev/null 2>&1; then
|
||||
ls -la "$f"
|
||||
else
|
||||
echo " $f"
|
||||
fi
|
||||
done
|
||||
25
mengyamonitor-backend-client/dashboard_payload.go
Normal file
25
mengyamonitor-backend-client/dashboard_payload.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package main
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// BuildDashboardPayloadJSON builds the same JSON shape the browser expects (including latency).
|
||||
// collectorToCentralMs is 采集器到监控中心 TCP 建连毫秒数,失败传 -1。
|
||||
func BuildDashboardPayloadJSON(collectorToCentralMs float64) ([]byte, error) {
|
||||
m, err := CollectMetrics()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var o map[string]any
|
||||
if err := json.Unmarshal(b, &o); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
o["latency"] = map[string]any{
|
||||
"clientToServer": collectorToCentralMs,
|
||||
"external": readExternalLatencies(),
|
||||
}
|
||||
return json.Marshal(o)
|
||||
}
|
||||
492
mengyamonitor-backend-client/gen/mengyav1/agent.pb.go
Normal file
492
mengyamonitor-backend-client/gen/mengyav1/agent.pb.go
Normal file
@@ -0,0 +1,492 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.5
|
||||
// protoc v5.28.3
|
||||
// source: mengya/v1/agent.proto
|
||||
|
||||
package mengyav1
|
||||
|
||||
import (
|
||||
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
reflect "reflect"
|
||||
sync "sync"
|
||||
unsafe "unsafe"
|
||||
)
|
||||
|
||||
const (
|
||||
// Verify that this generated code is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
|
||||
// Verify that runtime/protoimpl is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
|
||||
)
|
||||
|
||||
type AgentMessage struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
// Types that are valid to be assigned to Payload:
|
||||
//
|
||||
// *AgentMessage_Hello
|
||||
// *AgentMessage_Heartbeat
|
||||
// *AgentMessage_Metrics
|
||||
Payload isAgentMessage_Payload `protobuf_oneof:"payload"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *AgentMessage) Reset() {
|
||||
*x = AgentMessage{}
|
||||
mi := &file_mengya_v1_agent_proto_msgTypes[0]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *AgentMessage) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*AgentMessage) ProtoMessage() {}
|
||||
|
||||
func (x *AgentMessage) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_mengya_v1_agent_proto_msgTypes[0]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use AgentMessage.ProtoReflect.Descriptor instead.
|
||||
func (*AgentMessage) Descriptor() ([]byte, []int) {
|
||||
return file_mengya_v1_agent_proto_rawDescGZIP(), []int{0}
|
||||
}
|
||||
|
||||
func (x *AgentMessage) GetPayload() isAgentMessage_Payload {
|
||||
if x != nil {
|
||||
return x.Payload
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *AgentMessage) GetHello() *Hello {
|
||||
if x != nil {
|
||||
if x, ok := x.Payload.(*AgentMessage_Hello); ok {
|
||||
return x.Hello
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *AgentMessage) GetHeartbeat() *Heartbeat {
|
||||
if x != nil {
|
||||
if x, ok := x.Payload.(*AgentMessage_Heartbeat); ok {
|
||||
return x.Heartbeat
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *AgentMessage) GetMetrics() *MetricsReport {
|
||||
if x != nil {
|
||||
if x, ok := x.Payload.(*AgentMessage_Metrics); ok {
|
||||
return x.Metrics
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type isAgentMessage_Payload interface {
|
||||
isAgentMessage_Payload()
|
||||
}
|
||||
|
||||
type AgentMessage_Hello struct {
|
||||
Hello *Hello `protobuf:"bytes,1,opt,name=hello,proto3,oneof"`
|
||||
}
|
||||
|
||||
type AgentMessage_Heartbeat struct {
|
||||
Heartbeat *Heartbeat `protobuf:"bytes,2,opt,name=heartbeat,proto3,oneof"`
|
||||
}
|
||||
|
||||
type AgentMessage_Metrics struct {
|
||||
Metrics *MetricsReport `protobuf:"bytes,3,opt,name=metrics,proto3,oneof"`
|
||||
}
|
||||
|
||||
func (*AgentMessage_Hello) isAgentMessage_Payload() {}
|
||||
|
||||
func (*AgentMessage_Heartbeat) isAgentMessage_Payload() {}
|
||||
|
||||
func (*AgentMessage_Metrics) isAgentMessage_Payload() {}
|
||||
|
||||
type Hello struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
ServerId string `protobuf:"bytes,1,opt,name=server_id,json=serverId,proto3" json:"server_id,omitempty"`
|
||||
AgentKey string `protobuf:"bytes,2,opt,name=agent_key,json=agentKey,proto3" json:"agent_key,omitempty"`
|
||||
Hostname string `protobuf:"bytes,3,opt,name=hostname,proto3" json:"hostname,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *Hello) Reset() {
|
||||
*x = Hello{}
|
||||
mi := &file_mengya_v1_agent_proto_msgTypes[1]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *Hello) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*Hello) ProtoMessage() {}
|
||||
|
||||
func (x *Hello) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_mengya_v1_agent_proto_msgTypes[1]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use Hello.ProtoReflect.Descriptor instead.
|
||||
func (*Hello) Descriptor() ([]byte, []int) {
|
||||
return file_mengya_v1_agent_proto_rawDescGZIP(), []int{1}
|
||||
}
|
||||
|
||||
func (x *Hello) GetServerId() string {
|
||||
if x != nil {
|
||||
return x.ServerId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *Hello) GetAgentKey() string {
|
||||
if x != nil {
|
||||
return x.AgentKey
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *Hello) GetHostname() string {
|
||||
if x != nil {
|
||||
return x.Hostname
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type Heartbeat struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *Heartbeat) Reset() {
|
||||
*x = Heartbeat{}
|
||||
mi := &file_mengya_v1_agent_proto_msgTypes[2]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *Heartbeat) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*Heartbeat) ProtoMessage() {}
|
||||
|
||||
func (x *Heartbeat) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_mengya_v1_agent_proto_msgTypes[2]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use Heartbeat.ProtoReflect.Descriptor instead.
|
||||
func (*Heartbeat) Descriptor() ([]byte, []int) {
|
||||
return file_mengya_v1_agent_proto_rawDescGZIP(), []int{2}
|
||||
}
|
||||
|
||||
type MetricsReport struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
PayloadJson string `protobuf:"bytes,1,opt,name=payload_json,json=payloadJson,proto3" json:"payload_json,omitempty"`
|
||||
CollectedAtUnixMs int64 `protobuf:"varint,2,opt,name=collected_at_unix_ms,json=collectedAtUnixMs,proto3" json:"collected_at_unix_ms,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *MetricsReport) Reset() {
|
||||
*x = MetricsReport{}
|
||||
mi := &file_mengya_v1_agent_proto_msgTypes[3]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *MetricsReport) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*MetricsReport) ProtoMessage() {}
|
||||
|
||||
func (x *MetricsReport) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_mengya_v1_agent_proto_msgTypes[3]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use MetricsReport.ProtoReflect.Descriptor instead.
|
||||
func (*MetricsReport) Descriptor() ([]byte, []int) {
|
||||
return file_mengya_v1_agent_proto_rawDescGZIP(), []int{3}
|
||||
}
|
||||
|
||||
func (x *MetricsReport) GetPayloadJson() string {
|
||||
if x != nil {
|
||||
return x.PayloadJson
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *MetricsReport) GetCollectedAtUnixMs() int64 {
|
||||
if x != nil {
|
||||
return x.CollectedAtUnixMs
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type ControlMessage struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
// Types that are valid to be assigned to Payload:
|
||||
//
|
||||
// *ControlMessage_Ack
|
||||
Payload isControlMessage_Payload `protobuf_oneof:"payload"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *ControlMessage) Reset() {
|
||||
*x = ControlMessage{}
|
||||
mi := &file_mengya_v1_agent_proto_msgTypes[4]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *ControlMessage) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*ControlMessage) ProtoMessage() {}
|
||||
|
||||
func (x *ControlMessage) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_mengya_v1_agent_proto_msgTypes[4]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use ControlMessage.ProtoReflect.Descriptor instead.
|
||||
func (*ControlMessage) Descriptor() ([]byte, []int) {
|
||||
return file_mengya_v1_agent_proto_rawDescGZIP(), []int{4}
|
||||
}
|
||||
|
||||
func (x *ControlMessage) GetPayload() isControlMessage_Payload {
|
||||
if x != nil {
|
||||
return x.Payload
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *ControlMessage) GetAck() *Ack {
|
||||
if x != nil {
|
||||
if x, ok := x.Payload.(*ControlMessage_Ack); ok {
|
||||
return x.Ack
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type isControlMessage_Payload interface {
|
||||
isControlMessage_Payload()
|
||||
}
|
||||
|
||||
type ControlMessage_Ack struct {
|
||||
Ack *Ack `protobuf:"bytes,1,opt,name=ack,proto3,oneof"`
|
||||
}
|
||||
|
||||
func (*ControlMessage_Ack) isControlMessage_Payload() {}
|
||||
|
||||
type Ack struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Message string `protobuf:"bytes,1,opt,name=message,proto3" json:"message,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *Ack) Reset() {
|
||||
*x = Ack{}
|
||||
mi := &file_mengya_v1_agent_proto_msgTypes[5]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *Ack) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*Ack) ProtoMessage() {}
|
||||
|
||||
func (x *Ack) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_mengya_v1_agent_proto_msgTypes[5]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use Ack.ProtoReflect.Descriptor instead.
|
||||
func (*Ack) Descriptor() ([]byte, []int) {
|
||||
return file_mengya_v1_agent_proto_rawDescGZIP(), []int{5}
|
||||
}
|
||||
|
||||
func (x *Ack) GetMessage() string {
|
||||
if x != nil {
|
||||
return x.Message
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
var File_mengya_v1_agent_proto protoreflect.FileDescriptor
|
||||
|
||||
var file_mengya_v1_agent_proto_rawDesc = string([]byte{
|
||||
0x0a, 0x15, 0x6d, 0x65, 0x6e, 0x67, 0x79, 0x61, 0x2f, 0x76, 0x31, 0x2f, 0x61, 0x67, 0x65, 0x6e,
|
||||
0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x09, 0x6d, 0x65, 0x6e, 0x67, 0x79, 0x61, 0x2e,
|
||||
0x76, 0x31, 0x22, 0xaf, 0x01, 0x0a, 0x0c, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x4d, 0x65, 0x73, 0x73,
|
||||
0x61, 0x67, 0x65, 0x12, 0x28, 0x0a, 0x05, 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x18, 0x01, 0x20, 0x01,
|
||||
0x28, 0x0b, 0x32, 0x10, 0x2e, 0x6d, 0x65, 0x6e, 0x67, 0x79, 0x61, 0x2e, 0x76, 0x31, 0x2e, 0x48,
|
||||
0x65, 0x6c, 0x6c, 0x6f, 0x48, 0x00, 0x52, 0x05, 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x12, 0x34, 0x0a,
|
||||
0x09, 0x68, 0x65, 0x61, 0x72, 0x74, 0x62, 0x65, 0x61, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b,
|
||||
0x32, 0x14, 0x2e, 0x6d, 0x65, 0x6e, 0x67, 0x79, 0x61, 0x2e, 0x76, 0x31, 0x2e, 0x48, 0x65, 0x61,
|
||||
0x72, 0x74, 0x62, 0x65, 0x61, 0x74, 0x48, 0x00, 0x52, 0x09, 0x68, 0x65, 0x61, 0x72, 0x74, 0x62,
|
||||
0x65, 0x61, 0x74, 0x12, 0x34, 0x0a, 0x07, 0x6d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, 0x18, 0x03,
|
||||
0x20, 0x01, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x65, 0x6e, 0x67, 0x79, 0x61, 0x2e, 0x76, 0x31,
|
||||
0x2e, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x48, 0x00,
|
||||
0x52, 0x07, 0x6d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, 0x42, 0x09, 0x0a, 0x07, 0x70, 0x61, 0x79,
|
||||
0x6c, 0x6f, 0x61, 0x64, 0x22, 0x5d, 0x0a, 0x05, 0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x12, 0x1b, 0x0a,
|
||||
0x09, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
|
||||
0x52, 0x08, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x61, 0x67,
|
||||
0x65, 0x6e, 0x74, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x61,
|
||||
0x67, 0x65, 0x6e, 0x74, 0x4b, 0x65, 0x79, 0x12, 0x1a, 0x0a, 0x08, 0x68, 0x6f, 0x73, 0x74, 0x6e,
|
||||
0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x68, 0x6f, 0x73, 0x74, 0x6e,
|
||||
0x61, 0x6d, 0x65, 0x22, 0x0b, 0x0a, 0x09, 0x48, 0x65, 0x61, 0x72, 0x74, 0x62, 0x65, 0x61, 0x74,
|
||||
0x22, 0x63, 0x0a, 0x0d, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, 0x52, 0x65, 0x70, 0x6f, 0x72,
|
||||
0x74, 0x12, 0x21, 0x0a, 0x0c, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x5f, 0x6a, 0x73, 0x6f,
|
||||
0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64,
|
||||
0x4a, 0x73, 0x6f, 0x6e, 0x12, 0x2f, 0x0a, 0x14, 0x63, 0x6f, 0x6c, 0x6c, 0x65, 0x63, 0x74, 0x65,
|
||||
0x64, 0x5f, 0x61, 0x74, 0x5f, 0x75, 0x6e, 0x69, 0x78, 0x5f, 0x6d, 0x73, 0x18, 0x02, 0x20, 0x01,
|
||||
0x28, 0x03, 0x52, 0x11, 0x63, 0x6f, 0x6c, 0x6c, 0x65, 0x63, 0x74, 0x65, 0x64, 0x41, 0x74, 0x55,
|
||||
0x6e, 0x69, 0x78, 0x4d, 0x73, 0x22, 0x3f, 0x0a, 0x0e, 0x43, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c,
|
||||
0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x22, 0x0a, 0x03, 0x61, 0x63, 0x6b, 0x18, 0x01,
|
||||
0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x6d, 0x65, 0x6e, 0x67, 0x79, 0x61, 0x2e, 0x76, 0x31,
|
||||
0x2e, 0x41, 0x63, 0x6b, 0x48, 0x00, 0x52, 0x03, 0x61, 0x63, 0x6b, 0x42, 0x09, 0x0a, 0x07, 0x70,
|
||||
0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x22, 0x1f, 0x0a, 0x03, 0x41, 0x63, 0x6b, 0x12, 0x18, 0x0a,
|
||||
0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07,
|
||||
0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x32, 0x50, 0x0a, 0x0b, 0x41, 0x67, 0x65, 0x6e, 0x74,
|
||||
0x49, 0x6e, 0x67, 0x65, 0x73, 0x74, 0x12, 0x41, 0x0a, 0x07, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63,
|
||||
0x74, 0x12, 0x17, 0x2e, 0x6d, 0x65, 0x6e, 0x67, 0x79, 0x61, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x67,
|
||||
0x65, 0x6e, 0x74, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x19, 0x2e, 0x6d, 0x65, 0x6e,
|
||||
0x67, 0x79, 0x61, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x4d, 0x65,
|
||||
0x73, 0x73, 0x61, 0x67, 0x65, 0x28, 0x01, 0x30, 0x01, 0x42, 0x3d, 0x5a, 0x3b, 0x6d, 0x65, 0x6e,
|
||||
0x67, 0x79, 0x61, 0x6d, 0x6f, 0x6e, 0x69, 0x74, 0x6f, 0x72, 0x2d, 0x62, 0x61, 0x63, 0x6b, 0x65,
|
||||
0x6e, 0x64, 0x2d, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e,
|
||||
0x61, 0x6c, 0x2f, 0x67, 0x65, 0x6e, 0x2f, 0x6d, 0x65, 0x6e, 0x67, 0x79, 0x61, 0x76, 0x31, 0x3b,
|
||||
0x6d, 0x65, 0x6e, 0x67, 0x79, 0x61, 0x76, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
|
||||
})
|
||||
|
||||
var (
|
||||
file_mengya_v1_agent_proto_rawDescOnce sync.Once
|
||||
file_mengya_v1_agent_proto_rawDescData []byte
|
||||
)
|
||||
|
||||
func file_mengya_v1_agent_proto_rawDescGZIP() []byte {
|
||||
file_mengya_v1_agent_proto_rawDescOnce.Do(func() {
|
||||
file_mengya_v1_agent_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_mengya_v1_agent_proto_rawDesc), len(file_mengya_v1_agent_proto_rawDesc)))
|
||||
})
|
||||
return file_mengya_v1_agent_proto_rawDescData
|
||||
}
|
||||
|
||||
var file_mengya_v1_agent_proto_msgTypes = make([]protoimpl.MessageInfo, 6)
|
||||
var file_mengya_v1_agent_proto_goTypes = []any{
|
||||
(*AgentMessage)(nil), // 0: mengya.v1.AgentMessage
|
||||
(*Hello)(nil), // 1: mengya.v1.Hello
|
||||
(*Heartbeat)(nil), // 2: mengya.v1.Heartbeat
|
||||
(*MetricsReport)(nil), // 3: mengya.v1.MetricsReport
|
||||
(*ControlMessage)(nil), // 4: mengya.v1.ControlMessage
|
||||
(*Ack)(nil), // 5: mengya.v1.Ack
|
||||
}
|
||||
var file_mengya_v1_agent_proto_depIdxs = []int32{
|
||||
1, // 0: mengya.v1.AgentMessage.hello:type_name -> mengya.v1.Hello
|
||||
2, // 1: mengya.v1.AgentMessage.heartbeat:type_name -> mengya.v1.Heartbeat
|
||||
3, // 2: mengya.v1.AgentMessage.metrics:type_name -> mengya.v1.MetricsReport
|
||||
5, // 3: mengya.v1.ControlMessage.ack:type_name -> mengya.v1.Ack
|
||||
0, // 4: mengya.v1.AgentIngest.Connect:input_type -> mengya.v1.AgentMessage
|
||||
4, // 5: mengya.v1.AgentIngest.Connect:output_type -> mengya.v1.ControlMessage
|
||||
5, // [5:6] is the sub-list for method output_type
|
||||
4, // [4:5] is the sub-list for method input_type
|
||||
4, // [4:4] is the sub-list for extension type_name
|
||||
4, // [4:4] is the sub-list for extension extendee
|
||||
0, // [0:4] is the sub-list for field type_name
|
||||
}
|
||||
|
||||
func init() { file_mengya_v1_agent_proto_init() }
|
||||
func file_mengya_v1_agent_proto_init() {
|
||||
if File_mengya_v1_agent_proto != nil {
|
||||
return
|
||||
}
|
||||
file_mengya_v1_agent_proto_msgTypes[0].OneofWrappers = []any{
|
||||
(*AgentMessage_Hello)(nil),
|
||||
(*AgentMessage_Heartbeat)(nil),
|
||||
(*AgentMessage_Metrics)(nil),
|
||||
}
|
||||
file_mengya_v1_agent_proto_msgTypes[4].OneofWrappers = []any{
|
||||
(*ControlMessage_Ack)(nil),
|
||||
}
|
||||
type x struct{}
|
||||
out := protoimpl.TypeBuilder{
|
||||
File: protoimpl.DescBuilder{
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_mengya_v1_agent_proto_rawDesc), len(file_mengya_v1_agent_proto_rawDesc)),
|
||||
NumEnums: 0,
|
||||
NumMessages: 6,
|
||||
NumExtensions: 0,
|
||||
NumServices: 1,
|
||||
},
|
||||
GoTypes: file_mengya_v1_agent_proto_goTypes,
|
||||
DependencyIndexes: file_mengya_v1_agent_proto_depIdxs,
|
||||
MessageInfos: file_mengya_v1_agent_proto_msgTypes,
|
||||
}.Build()
|
||||
File_mengya_v1_agent_proto = out.File
|
||||
file_mengya_v1_agent_proto_goTypes = nil
|
||||
file_mengya_v1_agent_proto_depIdxs = nil
|
||||
}
|
||||
119
mengyamonitor-backend-client/gen/mengyav1/agent_grpc.pb.go
Normal file
119
mengyamonitor-backend-client/gen/mengyav1/agent_grpc.pb.go
Normal file
@@ -0,0 +1,119 @@
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.5.1
|
||||
// - protoc v5.28.3
|
||||
// source: mengya/v1/agent.proto
|
||||
|
||||
package mengyav1
|
||||
|
||||
import (
|
||||
context "context"
|
||||
grpc "google.golang.org/grpc"
|
||||
codes "google.golang.org/grpc/codes"
|
||||
status "google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// This is a compile-time assertion to ensure that this generated file
|
||||
// is compatible with the grpc package it is being compiled against.
|
||||
// Requires gRPC-Go v1.64.0 or later.
|
||||
const _ = grpc.SupportPackageIsVersion9
|
||||
|
||||
const (
|
||||
AgentIngest_Connect_FullMethodName = "/mengya.v1.AgentIngest/Connect"
|
||||
)
|
||||
|
||||
// AgentIngestClient is the client API for AgentIngest service.
|
||||
//
|
||||
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
|
||||
//
|
||||
// AgentIngest: 采集端出站连接,双向流上报指标。
|
||||
type AgentIngestClient interface {
|
||||
Connect(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[AgentMessage, ControlMessage], error)
|
||||
}
|
||||
|
||||
type agentIngestClient struct {
|
||||
cc grpc.ClientConnInterface
|
||||
}
|
||||
|
||||
func NewAgentIngestClient(cc grpc.ClientConnInterface) AgentIngestClient {
|
||||
return &agentIngestClient{cc}
|
||||
}
|
||||
|
||||
func (c *agentIngestClient) Connect(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[AgentMessage, ControlMessage], error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
stream, err := c.cc.NewStream(ctx, &AgentIngest_ServiceDesc.Streams[0], AgentIngest_Connect_FullMethodName, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
x := &grpc.GenericClientStream[AgentMessage, ControlMessage]{ClientStream: stream}
|
||||
return x, nil
|
||||
}
|
||||
|
||||
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
||||
type AgentIngest_ConnectClient = grpc.BidiStreamingClient[AgentMessage, ControlMessage]
|
||||
|
||||
// AgentIngestServer is the server API for AgentIngest service.
|
||||
// All implementations must embed UnimplementedAgentIngestServer
|
||||
// for forward compatibility.
|
||||
//
|
||||
// AgentIngest: 采集端出站连接,双向流上报指标。
|
||||
type AgentIngestServer interface {
|
||||
Connect(grpc.BidiStreamingServer[AgentMessage, ControlMessage]) error
|
||||
mustEmbedUnimplementedAgentIngestServer()
|
||||
}
|
||||
|
||||
// UnimplementedAgentIngestServer must be embedded to have
|
||||
// forward compatible implementations.
|
||||
//
|
||||
// NOTE: this should be embedded by value instead of pointer to avoid a nil
|
||||
// pointer dereference when methods are called.
|
||||
type UnimplementedAgentIngestServer struct{}
|
||||
|
||||
func (UnimplementedAgentIngestServer) Connect(grpc.BidiStreamingServer[AgentMessage, ControlMessage]) error {
|
||||
return status.Errorf(codes.Unimplemented, "method Connect not implemented")
|
||||
}
|
||||
func (UnimplementedAgentIngestServer) mustEmbedUnimplementedAgentIngestServer() {}
|
||||
func (UnimplementedAgentIngestServer) testEmbeddedByValue() {}
|
||||
|
||||
// UnsafeAgentIngestServer may be embedded to opt out of forward compatibility for this service.
|
||||
// Use of this interface is not recommended, as added methods to AgentIngestServer will
|
||||
// result in compilation errors.
|
||||
type UnsafeAgentIngestServer interface {
|
||||
mustEmbedUnimplementedAgentIngestServer()
|
||||
}
|
||||
|
||||
func RegisterAgentIngestServer(s grpc.ServiceRegistrar, srv AgentIngestServer) {
|
||||
// If the following call pancis, it indicates UnimplementedAgentIngestServer was
|
||||
// embedded by pointer and is nil. This will cause panics if an
|
||||
// unimplemented method is ever invoked, so we test this at initialization
|
||||
// time to prevent it from happening at runtime later due to I/O.
|
||||
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
|
||||
t.testEmbeddedByValue()
|
||||
}
|
||||
s.RegisterService(&AgentIngest_ServiceDesc, srv)
|
||||
}
|
||||
|
||||
func _AgentIngest_Connect_Handler(srv interface{}, stream grpc.ServerStream) error {
|
||||
return srv.(AgentIngestServer).Connect(&grpc.GenericServerStream[AgentMessage, ControlMessage]{ServerStream: stream})
|
||||
}
|
||||
|
||||
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
||||
type AgentIngest_ConnectServer = grpc.BidiStreamingServer[AgentMessage, ControlMessage]
|
||||
|
||||
// AgentIngest_ServiceDesc is the grpc.ServiceDesc for AgentIngest service.
|
||||
// It's only intended for direct use with grpc.RegisterService,
|
||||
// and not to be introspected or modified (even as a copy)
|
||||
var AgentIngest_ServiceDesc = grpc.ServiceDesc{
|
||||
ServiceName: "mengya.v1.AgentIngest",
|
||||
HandlerType: (*AgentIngestServer)(nil),
|
||||
Methods: []grpc.MethodDesc{},
|
||||
Streams: []grpc.StreamDesc{
|
||||
{
|
||||
StreamName: "Connect",
|
||||
Handler: _AgentIngest_Connect_Handler,
|
||||
ServerStreams: true,
|
||||
ClientStreams: true,
|
||||
},
|
||||
},
|
||||
Metadata: "mengya/v1/agent.proto",
|
||||
}
|
||||
15
mengyamonitor-backend-client/go.mod
Normal file
15
mengyamonitor-backend-client/go.mod
Normal file
@@ -0,0 +1,15 @@
|
||||
module mengyamonitor-backend
|
||||
|
||||
go 1.22
|
||||
|
||||
require (
|
||||
google.golang.org/grpc v1.70.0
|
||||
google.golang.org/protobuf v1.35.2
|
||||
)
|
||||
|
||||
require (
|
||||
golang.org/x/net v0.32.0 // indirect
|
||||
golang.org/x/sys v0.28.0 // indirect
|
||||
golang.org/x/text v0.21.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20241202173237-19429a94021a // indirect
|
||||
)
|
||||
32
mengyamonitor-backend-client/go.sum
Normal file
32
mengyamonitor-backend-client/go.sum
Normal file
@@ -0,0 +1,32 @@
|
||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
go.opentelemetry.io/otel v1.32.0 h1:WnBN+Xjcteh0zdk01SVqV55d/m62NJLJdIyb4y/WO5U=
|
||||
go.opentelemetry.io/otel v1.32.0/go.mod h1:00DCVSB0RQcnzlwyTfqtxSm+DRr9hpYrHjNGiBHVQIg=
|
||||
go.opentelemetry.io/otel/metric v1.32.0 h1:xV2umtmNcThh2/a/aCP+h64Xx5wsj8qqnkYZktzNa0M=
|
||||
go.opentelemetry.io/otel/metric v1.32.0/go.mod h1:jH7CIbbK6SH2V2wE16W05BHCtIDzauciCRLoc/SyMv8=
|
||||
go.opentelemetry.io/otel/sdk v1.32.0 h1:RNxepc9vK59A8XsgZQouW8ue8Gkb4jpWtJm9ge5lEG4=
|
||||
go.opentelemetry.io/otel/sdk v1.32.0/go.mod h1:LqgegDBjKMmb2GC6/PrTnteJG39I8/vJCAP9LlJXEjU=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.32.0 h1:rZvFnvmvawYb0alrYkjraqJq0Z4ZUJAiyYCU9snn1CU=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.32.0/go.mod h1:PWeZlq0zt9YkYAp3gjKZ0eicRYvOh1Gd+X99x6GHpCQ=
|
||||
go.opentelemetry.io/otel/trace v1.32.0 h1:WIC9mYrXf8TmY/EXuULKc8hR17vE+Hjv2cssQDe03fM=
|
||||
go.opentelemetry.io/otel/trace v1.32.0/go.mod h1:+i4rkvCraA+tG6AzwloGaCtkx53Fa+L+V8e9a7YvhT8=
|
||||
golang.org/x/net v0.32.0 h1:ZqPmj8Kzc+Y6e0+skZsuACbx+wzMgo5MQsJh9Qd6aYI=
|
||||
golang.org/x/net v0.32.0/go.mod h1:CwU0IoeOlnQQWJ6ioyFrfRuomB8GKF6KbYXZVyeXNfs=
|
||||
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20241202173237-19429a94021a h1:hgh8P4EuoxpsuKMXX/To36nOFD7vixReXgn8lPGnt+o=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20241202173237-19429a94021a/go.mod h1:5uTbfoYQed2U9p3KIj2/Zzm02PYhndfdmML0qC3q3FU=
|
||||
google.golang.org/grpc v1.70.0 h1:pWFv03aZoHzlRKHWicjsZytKAiYCtNS0dHbXnIdq7jQ=
|
||||
google.golang.org/grpc v1.70.0/go.mod h1:ofIJqVKDXx/JiXrwr2IG4/zwdH9txy3IlF40RmcJSQw=
|
||||
google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io=
|
||||
google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||
96
mengyamonitor-backend-client/grpc_reporter.go
Normal file
96
mengyamonitor-backend-client/grpc_reporter.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
mengyav1 "mengyamonitor-backend/gen/mengyav1"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
)
|
||||
|
||||
func runGRPCLoop(addr, serverID, key string) {
|
||||
backoff := time.Second
|
||||
const maxBackoff = 30 * time.Second
|
||||
for {
|
||||
err := runGRPCSession(addr, serverID, key)
|
||||
if err != nil {
|
||||
log.Printf("grpc reporter: session ended: %v (reconnect in %v)", err, backoff)
|
||||
time.Sleep(backoff)
|
||||
if backoff < maxBackoff {
|
||||
backoff *= 2
|
||||
}
|
||||
continue
|
||||
}
|
||||
backoff = time.Second
|
||||
}
|
||||
}
|
||||
|
||||
func runGRPCSession(addr, serverID, key string) error {
|
||||
ctx := context.Background()
|
||||
conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
cli := mengyav1.NewAgentIngestClient(conn)
|
||||
stream, err := cli.Connect(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
host, _ := os.Hostname()
|
||||
if err := stream.Send(&mengyav1.AgentMessage{
|
||||
Payload: &mengyav1.AgentMessage_Hello{
|
||||
Hello: &mengyav1.Hello{ServerId: serverID, AgentKey: key, Hostname: host},
|
||||
},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
for {
|
||||
_, err := stream.Recv()
|
||||
if err != nil {
|
||||
errCh <- err
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
tick := time.NewTicker(2 * time.Second)
|
||||
defer tick.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case err := <-errCh:
|
||||
if err == io.EOF {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
case <-tick.C:
|
||||
centralMs := measureCollectorToCentralTCP(addr, 3*time.Second)
|
||||
payload, err := BuildDashboardPayloadJSON(centralMs)
|
||||
if err != nil {
|
||||
log.Printf("grpc reporter: collect: %v", err)
|
||||
continue
|
||||
}
|
||||
if err := stream.Send(&mengyav1.AgentMessage{
|
||||
Payload: &mengyav1.AgentMessage_Metrics{
|
||||
Metrics: &mengyav1.MetricsReport{
|
||||
PayloadJson: string(payload),
|
||||
CollectedAtUnixMs: time.Now().UnixMilli(),
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
78
mengyamonitor-backend-client/latency.go
Normal file
78
mengyamonitor-backend-client/latency.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LatencyInfo 延迟信息(均由采集端在本机测量)
|
||||
type LatencyInfo struct {
|
||||
ClientToServer float64 `json:"clientToServer"` // 采集器 → 监控中心 gRPC 地址的 TCP 建连耗时(ms),失败为 -1
|
||||
External map[string]string `json:"external"` // 采集器对外站点的 TCP 建连耗时(同 checkExternalLatency)
|
||||
}
|
||||
|
||||
// normalizeGRPCDialTarget strips common grpc-go target prefixes; expects host:port or [ipv6]:port.
|
||||
func normalizeGRPCDialTarget(addr string) string {
|
||||
a := strings.TrimSpace(addr)
|
||||
a = strings.TrimPrefix(a, "dns:///")
|
||||
a = strings.TrimPrefix(a, "dns://")
|
||||
return strings.TrimSpace(a)
|
||||
}
|
||||
|
||||
// measureCollectorToCentralTCP returns TCP connect time in ms to the central gRPC dial target, or -1 on failure.
|
||||
func measureCollectorToCentralTCP(grpcAddr string, timeout time.Duration) float64 {
|
||||
target := normalizeGRPCDialTarget(grpcAddr)
|
||||
if target == "" || strings.HasPrefix(strings.ToLower(target), "unix:") {
|
||||
return -1
|
||||
}
|
||||
start := time.Now()
|
||||
conn, err := net.DialTimeout("tcp", target, timeout)
|
||||
if err != nil {
|
||||
return -1
|
||||
}
|
||||
_ = conn.Close()
|
||||
return float64(time.Since(start).Microseconds()) / 1000.0
|
||||
}
|
||||
|
||||
// checkExternalLatency 检测外部网站延迟
|
||||
func checkExternalLatency(host string, timeout time.Duration) string {
|
||||
start := time.Now()
|
||||
|
||||
// 尝试 TCP 连接 80 端口
|
||||
conn, err := net.DialTimeout("tcp", host+":80", timeout)
|
||||
if err != nil {
|
||||
// 如果 80 端口失败,尝试 443 (HTTPS)
|
||||
conn, err = net.DialTimeout("tcp", host+":443", timeout)
|
||||
if err != nil {
|
||||
return "超时"
|
||||
}
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// 使用微秒换算,避免 Milliseconds() 截断成 0 ms 误导用户
|
||||
ms := float64(time.Since(start).Microseconds()) / 1000.0
|
||||
if ms < 1 {
|
||||
return fmt.Sprintf("%.1f ms", ms)
|
||||
}
|
||||
return fmt.Sprintf("%.0f ms", ms)
|
||||
}
|
||||
|
||||
// readExternalLatencies 读取外部网站延迟
|
||||
func readExternalLatencies() map[string]string {
|
||||
latencies := make(map[string]string)
|
||||
timeout := 3 * time.Second
|
||||
|
||||
// 检测百度
|
||||
latencies["baidu.com"] = checkExternalLatency("baidu.com", timeout)
|
||||
|
||||
// 检测谷歌
|
||||
latencies["google.com"] = checkExternalLatency("google.com", timeout)
|
||||
|
||||
// 检测 GitHub
|
||||
latencies["github.com"] = checkExternalLatency("github.com", timeout)
|
||||
|
||||
return latencies
|
||||
}
|
||||
|
||||
17
mengyamonitor-backend-client/main.go
Normal file
17
mengyamonitor-backend-client/main.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() {
|
||||
addr := os.Getenv("CENTRAL_GRPC")
|
||||
serverID := os.Getenv("SERVER_ID")
|
||||
key := os.Getenv("AGENT_KEY")
|
||||
if addr == "" || serverID == "" || key == "" {
|
||||
log.Fatal("mengyamonitor agent requires CENTRAL_GRPC, SERVER_ID, and AGENT_KEY")
|
||||
}
|
||||
log.Printf("agent reporting via gRPC to %s (server_id=%s)", addr, serverID)
|
||||
runGRPCLoop(addr, serverID, key)
|
||||
}
|
||||
127
mengyamonitor-backend-client/memory.go
Normal file
127
mengyamonitor-backend-client/memory.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// readMemory 读取内存信息
|
||||
func readMemory() (MemoryMetrics, error) {
|
||||
totals := map[string]uint64{}
|
||||
f, err := os.Open("/proc/meminfo")
|
||||
if err != nil {
|
||||
return MemoryMetrics{}, err
|
||||
}
|
||||
defer f.Close()
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
parts := strings.Split(line, ":")
|
||||
if len(parts) < 2 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(parts[0])
|
||||
fields := strings.Fields(strings.TrimSpace(parts[1]))
|
||||
if len(fields) == 0 {
|
||||
continue
|
||||
}
|
||||
value, err := strconv.ParseUint(fields[0], 10, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
totals[key] = value * 1024 // kB to bytes
|
||||
}
|
||||
total := totals["MemTotal"]
|
||||
|
||||
// 优先使用 MemAvailable(Linux 3.14+),如果没有则计算
|
||||
var free uint64
|
||||
if available, ok := totals["MemAvailable"]; ok && available > 0 {
|
||||
free = available
|
||||
} else {
|
||||
// 回退到 MemFree + Buffers + Cached(适用于较老的系统)
|
||||
free = totals["MemFree"]
|
||||
if buffers, ok := totals["Buffers"]; ok {
|
||||
free += buffers
|
||||
}
|
||||
if cached, ok := totals["Cached"]; ok {
|
||||
free += cached
|
||||
}
|
||||
}
|
||||
|
||||
used := total - free
|
||||
usedPercent := 0.0
|
||||
if total > 0 {
|
||||
usedPercent = (float64(used) / float64(total)) * 100
|
||||
}
|
||||
|
||||
swapTotal := totals["SwapTotal"]
|
||||
swapFree := totals["SwapFree"]
|
||||
swapUsed := uint64(0)
|
||||
if swapTotal >= swapFree {
|
||||
swapUsed = swapTotal - swapFree
|
||||
}
|
||||
swapUsedPct := 0.0
|
||||
if swapTotal > 0 {
|
||||
swapUsedPct = (float64(swapUsed) / float64(swapTotal)) * 100
|
||||
}
|
||||
swapType := summarizeSwapTypes(swapTotal)
|
||||
|
||||
return MemoryMetrics{
|
||||
TotalBytes: total,
|
||||
UsedBytes: used,
|
||||
FreeBytes: free,
|
||||
UsedPercent: round(usedPercent, 2),
|
||||
SwapTotalBytes: swapTotal,
|
||||
SwapUsedBytes: swapUsed,
|
||||
SwapFreeBytes: swapFree,
|
||||
SwapUsedPercent: round(swapUsedPct, 2),
|
||||
SwapType: swapType,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// summarizeSwapTypes 根据 /proc/swaps 汇总交换区类型(file / partition / zram 等);无 swap 时为「无」
|
||||
func summarizeSwapTypes(swapTotalFromMeminfo uint64) string {
|
||||
if swapTotalFromMeminfo == 0 {
|
||||
return "无"
|
||||
}
|
||||
f, err := os.Open("/proc/swaps")
|
||||
if err != nil {
|
||||
return "未知"
|
||||
}
|
||||
defer f.Close()
|
||||
scanner := bufio.NewScanner(f)
|
||||
lineNum := 0
|
||||
seen := map[string]struct{}{}
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
lineNum++
|
||||
if lineNum == 1 || line == "" {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
filename := fields[0]
|
||||
typ := fields[1]
|
||||
if strings.Contains(strings.ToLower(filename), "zram") {
|
||||
typ = "zram"
|
||||
}
|
||||
seen[typ] = struct{}{}
|
||||
}
|
||||
if len(seen) == 0 {
|
||||
if swapTotalFromMeminfo > 0 {
|
||||
return "未知"
|
||||
}
|
||||
return "无"
|
||||
}
|
||||
list := make([]string, 0, len(seen))
|
||||
for t := range seen {
|
||||
list = append(list, t)
|
||||
}
|
||||
sort.Strings(list)
|
||||
return strings.Join(list, " + ")
|
||||
}
|
||||
@@ -1,390 +1,436 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// readSystemStats 读取系统统计信息
|
||||
func readSystemStats() SystemStats {
|
||||
stats := SystemStats{}
|
||||
|
||||
// 读取进程数量
|
||||
stats.ProcessCount = countProcesses()
|
||||
|
||||
// 读取已安装包数量和包管理器类型
|
||||
stats.PackageCount, stats.PackageManager = countPackages()
|
||||
|
||||
// 读取系统温度
|
||||
stats.Temperature = readSystemTemperature()
|
||||
|
||||
// 读取磁盘读写速度
|
||||
stats.DiskReadSpeed, stats.DiskWriteSpeed = readDiskSpeed()
|
||||
|
||||
// 读取 Top 5 进程
|
||||
stats.TopProcesses = readTopProcesses()
|
||||
|
||||
// 读取系统日志
|
||||
stats.SystemLogs = readSystemLogs(10)
|
||||
|
||||
return stats
|
||||
}
|
||||
|
||||
func countProcesses() int {
|
||||
entries, err := os.ReadDir("/proc")
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
count := 0
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
// 进程目录是数字命名的
|
||||
if _, err := strconv.Atoi(entry.Name()); err == nil {
|
||||
count++
|
||||
}
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func countPackages() (int, string) {
|
||||
// 尝试不同的包管理器
|
||||
// dpkg (Debian/Ubuntu)
|
||||
if _, err := exec.LookPath("dpkg"); err == nil {
|
||||
cmd := exec.Command("dpkg", "-l")
|
||||
out, err := cmd.Output()
|
||||
if err == nil {
|
||||
lines := strings.Split(string(out), "\n")
|
||||
count := 0
|
||||
for _, line := range lines {
|
||||
if strings.HasPrefix(line, "ii ") {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count, "dpkg (apt)"
|
||||
}
|
||||
}
|
||||
|
||||
// rpm (RedHat/CentOS/Fedora)
|
||||
if _, err := exec.LookPath("rpm"); err == nil {
|
||||
cmd := exec.Command("rpm", "-qa")
|
||||
out, err := cmd.Output()
|
||||
if err == nil {
|
||||
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
|
||||
return len(lines), "rpm (yum/dnf)"
|
||||
}
|
||||
}
|
||||
|
||||
// pacman (Arch Linux)
|
||||
if _, err := exec.LookPath("pacman"); err == nil {
|
||||
cmd := exec.Command("pacman", "-Q")
|
||||
out, err := cmd.Output()
|
||||
if err == nil {
|
||||
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
|
||||
return len(lines), "pacman"
|
||||
}
|
||||
}
|
||||
|
||||
return 0, "unknown"
|
||||
}
|
||||
|
||||
func readSystemTemperature() float64 {
|
||||
var cpuTemp float64 = 0
|
||||
var fallbackTemp float64 = 0
|
||||
|
||||
// 1. 优先读取 thermal_zone (通常是 CPU 温度)
|
||||
thermalDir := "/sys/class/thermal"
|
||||
entries, err := os.ReadDir(thermalDir)
|
||||
if err == nil {
|
||||
for _, entry := range entries {
|
||||
if !strings.HasPrefix(entry.Name(), "thermal_zone") {
|
||||
continue
|
||||
}
|
||||
tempPath := thermalDir + "/" + entry.Name() + "/temp"
|
||||
if temp := readTempFromFile(tempPath); temp > 0 && temp > 20 && temp < 120 {
|
||||
// thermal_zone0 通常是 CPU
|
||||
if entry.Name() == "thermal_zone0" {
|
||||
cpuTemp = temp
|
||||
break
|
||||
} else if fallbackTemp == 0 {
|
||||
fallbackTemp = temp
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 扫描所有 hwmon 设备,查找 CPU 温度
|
||||
hwmonDir := "/sys/class/hwmon"
|
||||
entries, err = os.ReadDir(hwmonDir)
|
||||
if err == nil {
|
||||
for _, entry := range entries {
|
||||
hwmonPath := hwmonDir + "/" + entry.Name()
|
||||
|
||||
// 读取 name 文件,检查是否是 CPU 相关
|
||||
namePath := hwmonPath + "/name"
|
||||
name := strings.ToLower(strings.TrimSpace(readFirstLine(namePath)))
|
||||
|
||||
// 检查是否是 CPU 温度传感器
|
||||
isCPU := strings.Contains(name, "cpu") ||
|
||||
strings.Contains(name, "core") ||
|
||||
strings.Contains(name, "k10temp") ||
|
||||
strings.Contains(name, "coretemp") ||
|
||||
strings.Contains(name, "zenpower")
|
||||
|
||||
// 尝试读取 temp1_input (通常是 CPU)
|
||||
temp1Path := hwmonPath + "/temp1_input"
|
||||
if temp := readTempFromFile(temp1Path); temp > 0 && temp > 20 && temp < 120 {
|
||||
if isCPU {
|
||||
cpuTemp = temp
|
||||
break
|
||||
} else if fallbackTemp == 0 {
|
||||
fallbackTemp = temp
|
||||
}
|
||||
}
|
||||
|
||||
// 也尝试 temp2_input
|
||||
temp2Path := hwmonPath + "/temp2_input"
|
||||
if temp := readTempFromFile(temp2Path); temp > 0 && temp > 20 && temp < 120 {
|
||||
if isCPU && cpuTemp == 0 {
|
||||
cpuTemp = temp
|
||||
} else if fallbackTemp == 0 {
|
||||
fallbackTemp = temp
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 优先返回 CPU 温度,如果没有则返回其他温度
|
||||
if cpuTemp > 0 {
|
||||
return cpuTemp
|
||||
}
|
||||
return fallbackTemp
|
||||
}
|
||||
|
||||
// readDiskSpeed 读取磁盘瞬时读写速度 (MB/s)
|
||||
func readDiskSpeed() (float64, float64) {
|
||||
// 第一次读取
|
||||
readSectors1, writeSectors1 := getDiskSectors()
|
||||
if readSectors1 == 0 && writeSectors1 == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
// 等待1秒
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
// 第二次读取
|
||||
readSectors2, writeSectors2 := getDiskSectors()
|
||||
|
||||
// 计算差值(扇区数)
|
||||
readDiff := readSectors2 - readSectors1
|
||||
writeDiff := writeSectors2 - writeSectors1
|
||||
|
||||
// 扇区大小通常是 512 字节,转换为 MB/s
|
||||
readSpeed := float64(readDiff) * 512 / 1024 / 1024
|
||||
writeSpeed := float64(writeDiff) * 512 / 1024 / 1024
|
||||
|
||||
return round(readSpeed, 2), round(writeSpeed, 2)
|
||||
}
|
||||
|
||||
func getDiskSectors() (uint64, uint64) {
|
||||
f, err := os.Open("/proc/diskstats")
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
|
||||
var maxRead uint64 = 0
|
||||
var mainDevice string
|
||||
|
||||
// 第一次遍历:找到读写量最大的主磁盘(通常是系统盘)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 14 {
|
||||
continue
|
||||
}
|
||||
|
||||
deviceName := fields[2]
|
||||
// 跳过分区(分区名通常包含数字,如 sda1, vda1, nvme0n1p1)
|
||||
if strings.ContainsAny(deviceName, "0123456789") &&
|
||||
!strings.HasPrefix(deviceName, "nvme") &&
|
||||
!strings.HasPrefix(deviceName, "loop") {
|
||||
continue
|
||||
}
|
||||
|
||||
// 跳过虚拟设备
|
||||
if strings.HasPrefix(deviceName, "loop") ||
|
||||
strings.HasPrefix(deviceName, "ram") ||
|
||||
strings.HasPrefix(deviceName, "zram") {
|
||||
continue
|
||||
}
|
||||
|
||||
readSectors, _ := strconv.ParseUint(fields[5], 10, 64)
|
||||
// 选择读写量最大的作为主磁盘
|
||||
if readSectors > maxRead {
|
||||
maxRead = readSectors
|
||||
mainDevice = deviceName
|
||||
}
|
||||
}
|
||||
|
||||
// 第二次遍历:读取主磁盘的数据
|
||||
f.Close()
|
||||
f, err = os.Open("/proc/diskstats")
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
scanner = bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 14 {
|
||||
continue
|
||||
}
|
||||
|
||||
if fields[2] == mainDevice {
|
||||
readSectors, _ := strconv.ParseUint(fields[5], 10, 64)
|
||||
writeSectors, _ := strconv.ParseUint(fields[9], 10, 64)
|
||||
return readSectors, writeSectors
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没找到,尝试常见的设备名(向后兼容)
|
||||
f.Close()
|
||||
f, err = os.Open("/proc/diskstats")
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
scanner = bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 14 {
|
||||
continue
|
||||
}
|
||||
|
||||
deviceName := fields[2]
|
||||
if deviceName == "sda" || deviceName == "vda" || deviceName == "nvme0n1" {
|
||||
readSectors, _ := strconv.ParseUint(fields[5], 10, 64)
|
||||
writeSectors, _ := strconv.ParseUint(fields[9], 10, 64)
|
||||
return readSectors, writeSectors
|
||||
}
|
||||
}
|
||||
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
// readTopProcesses 读取 Top 5 进程 (按 CPU 使用率)
|
||||
func readTopProcesses() []ProcessInfo {
|
||||
processes := []ProcessInfo{}
|
||||
|
||||
// 读取系统总内存
|
||||
memInfo, _ := readMemory()
|
||||
totalMemGB := float64(memInfo.TotalBytes) / 1024 / 1024 / 1024
|
||||
|
||||
// 使用 ps 命令获取进程信息,添加超时控制
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "ps", "aux", "--sort=-%cpu", "--no-headers")
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return processes
|
||||
}
|
||||
|
||||
lines := strings.Split(string(out), "\n")
|
||||
count := 0
|
||||
|
||||
for _, line := range lines {
|
||||
if count >= 5 { // 只取前5个
|
||||
break
|
||||
}
|
||||
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 11 {
|
||||
continue
|
||||
}
|
||||
|
||||
pid, _ := strconv.Atoi(fields[1])
|
||||
cpu, _ := strconv.ParseFloat(fields[2], 64)
|
||||
mem, _ := strconv.ParseFloat(fields[3], 64)
|
||||
|
||||
// 计算内存MB数
|
||||
memoryMB := (mem / 100) * totalMemGB * 1024
|
||||
|
||||
// 命令可能包含空格,从第11个字段开始拼接
|
||||
command := strings.Join(fields[10:], " ")
|
||||
if len(command) > 50 {
|
||||
command = command[:50] + "..."
|
||||
}
|
||||
|
||||
processes = append(processes, ProcessInfo{
|
||||
PID: pid,
|
||||
Name: fields[10],
|
||||
CPU: round(cpu, 1),
|
||||
Memory: round(mem, 1),
|
||||
MemoryMB: round(memoryMB, 1),
|
||||
Command: command,
|
||||
})
|
||||
count++
|
||||
}
|
||||
|
||||
return processes
|
||||
}
|
||||
|
||||
// readSystemLogs 读取系统最新日志
|
||||
func readSystemLogs(count int) []string {
|
||||
logs := []string{}
|
||||
|
||||
// 尝试使用 journalctl 读取系统日志
|
||||
if _, err := exec.LookPath("journalctl"); err == nil {
|
||||
cmd := exec.Command("journalctl", "-n", strconv.Itoa(count), "--no-pager", "-o", "short")
|
||||
out, err := cmd.Output()
|
||||
if err == nil {
|
||||
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
|
||||
for _, line := range lines {
|
||||
if line != "" {
|
||||
logs = append(logs, line)
|
||||
}
|
||||
}
|
||||
return logs
|
||||
}
|
||||
}
|
||||
|
||||
// 如果 journalctl 不可用,尝试读取 /var/log/syslog 或 /var/log/messages
|
||||
logFiles := []string{"/var/log/syslog", "/var/log/messages"}
|
||||
for _, logFile := range logFiles {
|
||||
f, err := os.Open(logFile)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// 读取最后几行
|
||||
var lines []string
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
lines = append(lines, scanner.Text())
|
||||
}
|
||||
|
||||
// 取最后count行
|
||||
start := len(lines) - count
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
logs = lines[start:]
|
||||
break
|
||||
}
|
||||
|
||||
return logs
|
||||
}
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// readSystemStats 读取系统统计信息
|
||||
func readSystemStats() SystemStats {
|
||||
stats := SystemStats{}
|
||||
|
||||
// 读取进程数量
|
||||
stats.ProcessCount = countProcesses()
|
||||
stats.LoggedInUsers = countLoggedInSessions()
|
||||
stats.TCPConnections = countTCPConnectionsFromProc()
|
||||
|
||||
// 读取已安装包数量和包管理器类型
|
||||
stats.PackageCount, stats.PackageManager = countPackages()
|
||||
|
||||
// 读取系统温度
|
||||
stats.Temperature = readSystemTemperature()
|
||||
|
||||
// 读取磁盘读写速度
|
||||
stats.DiskReadSpeed, stats.DiskWriteSpeed = readDiskSpeed()
|
||||
|
||||
// 读取 Top 10 进程
|
||||
stats.TopProcesses = readTopProcesses()
|
||||
|
||||
// 读取系统日志
|
||||
stats.SystemLogs = readSystemLogs(10)
|
||||
|
||||
return stats
|
||||
}
|
||||
|
||||
func countProcesses() int {
|
||||
entries, err := os.ReadDir("/proc")
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
count := 0
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
// 进程目录是数字命名的
|
||||
if _, err := strconv.Atoi(entry.Name()); err == nil {
|
||||
count++
|
||||
}
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// countLoggedInSessions 当前登录会话数(与 who 输出行数一致:多个 SSH/终端各占一行)
|
||||
func countLoggedInSessions() int {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "who")
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
s := strings.TrimSpace(string(out))
|
||||
if s == "" {
|
||||
return 0
|
||||
}
|
||||
return len(strings.Split(s, "\n"))
|
||||
}
|
||||
|
||||
// countTCPConnectionsFromProc 统计 /proc/net/tcp 与 tcp6 中的套接字条目数(内核导出的各行状态,非仅 ESTABLISHED)
|
||||
func countTCPConnectionsFromProc() int {
|
||||
return countProcNetTCPFile("/proc/net/tcp") + countProcNetTCPFile("/proc/net/tcp6")
|
||||
}
|
||||
|
||||
func countProcNetTCPFile(path string) int {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
defer f.Close()
|
||||
scanner := bufio.NewScanner(f)
|
||||
n := 0
|
||||
header := true
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if header {
|
||||
header = false
|
||||
continue
|
||||
}
|
||||
n++
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func countPackages() (int, string) {
|
||||
// 尝试不同的包管理器
|
||||
// dpkg (Debian/Ubuntu)
|
||||
if _, err := exec.LookPath("dpkg"); err == nil {
|
||||
cmd := exec.Command("dpkg", "-l")
|
||||
out, err := cmd.Output()
|
||||
if err == nil {
|
||||
lines := strings.Split(string(out), "\n")
|
||||
count := 0
|
||||
for _, line := range lines {
|
||||
if strings.HasPrefix(line, "ii ") {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count, "dpkg (apt)"
|
||||
}
|
||||
}
|
||||
|
||||
// rpm (RedHat/CentOS/Fedora)
|
||||
if _, err := exec.LookPath("rpm"); err == nil {
|
||||
cmd := exec.Command("rpm", "-qa")
|
||||
out, err := cmd.Output()
|
||||
if err == nil {
|
||||
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
|
||||
return len(lines), "rpm (yum/dnf)"
|
||||
}
|
||||
}
|
||||
|
||||
// pacman (Arch Linux)
|
||||
if _, err := exec.LookPath("pacman"); err == nil {
|
||||
cmd := exec.Command("pacman", "-Q")
|
||||
out, err := cmd.Output()
|
||||
if err == nil {
|
||||
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
|
||||
return len(lines), "pacman"
|
||||
}
|
||||
}
|
||||
|
||||
return 0, "unknown"
|
||||
}
|
||||
|
||||
func readSystemTemperature() float64 {
|
||||
var cpuTemp float64 = 0
|
||||
var fallbackTemp float64 = 0
|
||||
|
||||
// 1. 优先读取 thermal_zone (通常是 CPU 温度)
|
||||
thermalDir := "/sys/class/thermal"
|
||||
entries, err := os.ReadDir(thermalDir)
|
||||
if err == nil {
|
||||
for _, entry := range entries {
|
||||
if !strings.HasPrefix(entry.Name(), "thermal_zone") {
|
||||
continue
|
||||
}
|
||||
tempPath := thermalDir + "/" + entry.Name() + "/temp"
|
||||
if temp := readTempFromFile(tempPath); temp > 0 && temp > 20 && temp < 120 {
|
||||
// thermal_zone0 通常是 CPU
|
||||
if entry.Name() == "thermal_zone0" {
|
||||
cpuTemp = temp
|
||||
break
|
||||
} else if fallbackTemp == 0 {
|
||||
fallbackTemp = temp
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 扫描所有 hwmon 设备,查找 CPU 温度
|
||||
hwmonDir := "/sys/class/hwmon"
|
||||
entries, err = os.ReadDir(hwmonDir)
|
||||
if err == nil {
|
||||
for _, entry := range entries {
|
||||
hwmonPath := hwmonDir + "/" + entry.Name()
|
||||
|
||||
// 读取 name 文件,检查是否是 CPU 相关
|
||||
namePath := hwmonPath + "/name"
|
||||
name := strings.ToLower(strings.TrimSpace(readFirstLine(namePath)))
|
||||
|
||||
// 检查是否是 CPU 温度传感器
|
||||
isCPU := strings.Contains(name, "cpu") ||
|
||||
strings.Contains(name, "core") ||
|
||||
strings.Contains(name, "k10temp") ||
|
||||
strings.Contains(name, "coretemp") ||
|
||||
strings.Contains(name, "zenpower")
|
||||
|
||||
// 尝试读取 temp1_input (通常是 CPU)
|
||||
temp1Path := hwmonPath + "/temp1_input"
|
||||
if temp := readTempFromFile(temp1Path); temp > 0 && temp > 20 && temp < 120 {
|
||||
if isCPU {
|
||||
cpuTemp = temp
|
||||
break
|
||||
} else if fallbackTemp == 0 {
|
||||
fallbackTemp = temp
|
||||
}
|
||||
}
|
||||
|
||||
// 也尝试 temp2_input
|
||||
temp2Path := hwmonPath + "/temp2_input"
|
||||
if temp := readTempFromFile(temp2Path); temp > 0 && temp > 20 && temp < 120 {
|
||||
if isCPU && cpuTemp == 0 {
|
||||
cpuTemp = temp
|
||||
} else if fallbackTemp == 0 {
|
||||
fallbackTemp = temp
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 优先返回 CPU 温度,如果没有则返回其他温度
|
||||
if cpuTemp > 0 {
|
||||
return cpuTemp
|
||||
}
|
||||
return fallbackTemp
|
||||
}
|
||||
|
||||
// readDiskSpeed 读取磁盘瞬时读写速度 (MB/s)
|
||||
func readDiskSpeed() (float64, float64) {
|
||||
// 第一次读取
|
||||
readSectors1, writeSectors1 := getDiskSectors()
|
||||
if readSectors1 == 0 && writeSectors1 == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
// 等待1秒
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
// 第二次读取
|
||||
readSectors2, writeSectors2 := getDiskSectors()
|
||||
|
||||
// 计算差值(扇区数)
|
||||
readDiff := readSectors2 - readSectors1
|
||||
writeDiff := writeSectors2 - writeSectors1
|
||||
|
||||
// 扇区大小通常是 512 字节,转换为 MB/s
|
||||
readSpeed := float64(readDiff) * 512 / 1024 / 1024
|
||||
writeSpeed := float64(writeDiff) * 512 / 1024 / 1024
|
||||
|
||||
return round(readSpeed, 2), round(writeSpeed, 2)
|
||||
}
|
||||
|
||||
func getDiskSectors() (uint64, uint64) {
|
||||
f, err := os.Open("/proc/diskstats")
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
|
||||
var maxRead uint64 = 0
|
||||
var mainDevice string
|
||||
|
||||
// 第一次遍历:找到读写量最大的主磁盘(通常是系统盘)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 14 {
|
||||
continue
|
||||
}
|
||||
|
||||
deviceName := fields[2]
|
||||
// 跳过分区(分区名通常包含数字,如 sda1, vda1, nvme0n1p1)
|
||||
if strings.ContainsAny(deviceName, "0123456789") &&
|
||||
!strings.HasPrefix(deviceName, "nvme") &&
|
||||
!strings.HasPrefix(deviceName, "loop") {
|
||||
continue
|
||||
}
|
||||
|
||||
// 跳过虚拟设备
|
||||
if strings.HasPrefix(deviceName, "loop") ||
|
||||
strings.HasPrefix(deviceName, "ram") ||
|
||||
strings.HasPrefix(deviceName, "zram") {
|
||||
continue
|
||||
}
|
||||
|
||||
readSectors, _ := strconv.ParseUint(fields[5], 10, 64)
|
||||
// 选择读写量最大的作为主磁盘
|
||||
if readSectors > maxRead {
|
||||
maxRead = readSectors
|
||||
mainDevice = deviceName
|
||||
}
|
||||
}
|
||||
|
||||
// 第二次遍历:读取主磁盘的数据
|
||||
f.Close()
|
||||
f, err = os.Open("/proc/diskstats")
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
scanner = bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 14 {
|
||||
continue
|
||||
}
|
||||
|
||||
if fields[2] == mainDevice {
|
||||
readSectors, _ := strconv.ParseUint(fields[5], 10, 64)
|
||||
writeSectors, _ := strconv.ParseUint(fields[9], 10, 64)
|
||||
return readSectors, writeSectors
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没找到,尝试常见的设备名(向后兼容)
|
||||
f.Close()
|
||||
f, err = os.Open("/proc/diskstats")
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
scanner = bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 14 {
|
||||
continue
|
||||
}
|
||||
|
||||
deviceName := fields[2]
|
||||
if deviceName == "sda" || deviceName == "vda" || deviceName == "nvme0n1" {
|
||||
readSectors, _ := strconv.ParseUint(fields[5], 10, 64)
|
||||
writeSectors, _ := strconv.ParseUint(fields[9], 10, 64)
|
||||
return readSectors, writeSectors
|
||||
}
|
||||
}
|
||||
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
// readTopProcesses 读取 Top 10 进程 (按 CPU 使用率)
|
||||
func readTopProcesses() []ProcessInfo {
|
||||
processes := []ProcessInfo{}
|
||||
|
||||
// 读取系统总内存
|
||||
memInfo, _ := readMemory()
|
||||
totalMemGB := float64(memInfo.TotalBytes) / 1024 / 1024 / 1024
|
||||
|
||||
// 使用 ps 命令获取进程信息,添加超时控制
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "ps", "aux", "--sort=-%cpu", "--no-headers")
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return processes
|
||||
}
|
||||
|
||||
lines := strings.Split(string(out), "\n")
|
||||
count := 0
|
||||
|
||||
for _, line := range lines {
|
||||
if count >= 10 { // 只取前 10 个
|
||||
break
|
||||
}
|
||||
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 11 {
|
||||
continue
|
||||
}
|
||||
|
||||
pid, _ := strconv.Atoi(fields[1])
|
||||
cpu, _ := strconv.ParseFloat(fields[2], 64)
|
||||
mem, _ := strconv.ParseFloat(fields[3], 64)
|
||||
|
||||
// 计算内存MB数
|
||||
memoryMB := (mem / 100) * totalMemGB * 1024
|
||||
|
||||
// 命令可能包含空格,从第11个字段开始拼接
|
||||
command := strings.Join(fields[10:], " ")
|
||||
if len(command) > 50 {
|
||||
command = command[:50] + "..."
|
||||
}
|
||||
|
||||
processes = append(processes, ProcessInfo{
|
||||
PID: pid,
|
||||
Name: fields[10],
|
||||
CPU: round(cpu, 1),
|
||||
Memory: round(mem, 1),
|
||||
MemoryMB: round(memoryMB, 1),
|
||||
Command: command,
|
||||
})
|
||||
count++
|
||||
}
|
||||
|
||||
return processes
|
||||
}
|
||||
|
||||
// readSystemLogs 读取系统最新日志
|
||||
func readSystemLogs(count int) []string {
|
||||
logs := []string{}
|
||||
|
||||
// 尝试使用 journalctl 读取系统日志
|
||||
if _, err := exec.LookPath("journalctl"); err == nil {
|
||||
cmd := exec.Command("journalctl", "-n", strconv.Itoa(count), "--no-pager", "-o", "short")
|
||||
out, err := cmd.Output()
|
||||
if err == nil {
|
||||
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
|
||||
for _, line := range lines {
|
||||
if line != "" {
|
||||
logs = append(logs, line)
|
||||
}
|
||||
}
|
||||
return logs
|
||||
}
|
||||
}
|
||||
|
||||
// 如果 journalctl 不可用,尝试读取 /var/log/syslog 或 /var/log/messages
|
||||
logFiles := []string{"/var/log/syslog", "/var/log/messages"}
|
||||
for _, logFile := range logFiles {
|
||||
f, err := os.Open(logFile)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// 读取最后几行
|
||||
var lines []string
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
lines = append(lines, scanner.Text())
|
||||
}
|
||||
|
||||
// 取最后count行
|
||||
start := len(lines) - count
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
logs = lines[start:]
|
||||
break
|
||||
}
|
||||
|
||||
return logs
|
||||
}
|
||||
@@ -19,10 +19,15 @@ type CoreUsage struct {
|
||||
|
||||
// 内存相关信息
|
||||
type MemoryMetrics struct {
|
||||
TotalBytes uint64 `json:"totalBytes"`
|
||||
UsedBytes uint64 `json:"usedBytes"`
|
||||
FreeBytes uint64 `json:"freeBytes"`
|
||||
UsedPercent float64 `json:"usedPercent"`
|
||||
TotalBytes uint64 `json:"totalBytes"`
|
||||
UsedBytes uint64 `json:"usedBytes"`
|
||||
FreeBytes uint64 `json:"freeBytes"`
|
||||
UsedPercent float64 `json:"usedPercent"`
|
||||
SwapTotalBytes uint64 `json:"swapTotalBytes"`
|
||||
SwapUsedBytes uint64 `json:"swapUsedBytes"`
|
||||
SwapFreeBytes uint64 `json:"swapFreeBytes"`
|
||||
SwapUsedPercent float64 `json:"swapUsedPercent"`
|
||||
SwapType string `json:"swapType"` // /proc/swaps 类型汇总,如 file、partition、zram;无交换分区时为「无」
|
||||
}
|
||||
|
||||
// 储存相关信息
|
||||
@@ -58,6 +63,8 @@ type NetworkInterface struct {
|
||||
// 系统状态相关信息
|
||||
type SystemStats struct {
|
||||
ProcessCount int `json:"processCount"` // 进程数量
|
||||
LoggedInUsers int `json:"loggedInUsers"` // 当前登录会话数(who 行数,与 ssh/tty 会话对应)
|
||||
TCPConnections int `json:"tcpConnections"` // IPv4+IPv6 套接字行数(/proc/net/tcp + tcp6,含各状态)
|
||||
PackageCount int `json:"packageCount"` // 已安装软件包数量
|
||||
PackageManager string `json:"packageManager"` // 包管理器类型
|
||||
Temperature float64 `json:"temperature,omitempty"` // 系统温度(摄氏度)
|
||||
@@ -65,7 +72,7 @@ type SystemStats struct {
|
||||
DiskWriteSpeed float64 `json:"diskWriteSpeed"` // 磁盘写入速度 MB/s
|
||||
NetworkRxSpeed float64 `json:"networkRxSpeed"` // 网络下载速度 MB/s
|
||||
NetworkTxSpeed float64 `json:"networkTxSpeed"` // 网络上传速度 MB/s
|
||||
TopProcesses []ProcessInfo `json:"topProcesses"` // Top 5 进程
|
||||
TopProcesses []ProcessInfo `json:"topProcesses"` // Top 10 进程
|
||||
DockerStats DockerStats `json:"dockerStats"` // Docker 统计信息
|
||||
SystemLogs []string `json:"systemLogs"` // 系统最新日志
|
||||
}
|
||||
@@ -82,14 +89,14 @@ type ProcessInfo struct {
|
||||
|
||||
// Docker相关信息(简化版)
|
||||
type DockerStats struct {
|
||||
Available bool `json:"available"` // Docker是否可用
|
||||
Version string `json:"version"` // Docker版本
|
||||
Running int `json:"running"` // 运行中的容器数
|
||||
Stopped int `json:"stopped"` // 停止的容器数
|
||||
ImageCount int `json:"imageCount"` // 镜像数量
|
||||
RunningNames []string `json:"runningNames"` // 运行中的容器名列表
|
||||
StoppedNames []string `json:"stoppedNames"` // 停止的容器名列表
|
||||
ImageNames []string `json:"imageNames"` // 镜像名列表
|
||||
Available bool `json:"available"` // Docker是否可用
|
||||
Version string `json:"version"` // Docker版本
|
||||
Running int `json:"running"` // 运行中的容器数
|
||||
Stopped int `json:"stopped"` // 停止的容器数
|
||||
ImageCount int `json:"imageCount"` // 镜像数量
|
||||
RunningNames []string `json:"runningNames"` // 运行中的容器名列表
|
||||
StoppedNames []string `json:"stoppedNames"` // 停止的容器名列表
|
||||
ImageNames []string `json:"imageNames"` // 镜像名列表
|
||||
}
|
||||
|
||||
// 操作系统相关信息
|
||||
9
mengyamonitor-backend-server/.dockerignore
Normal file
9
mengyamonitor-backend-server/.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
||||
data/
|
||||
.git
|
||||
.gitignore
|
||||
*.md
|
||||
.env
|
||||
.env.*
|
||||
*.exe
|
||||
mengyamonitor-backend-server
|
||||
__debug_bin
|
||||
21
mengyamonitor-backend-server/Dockerfile
Normal file
21
mengyamonitor-backend-server/Dockerfile
Normal file
@@ -0,0 +1,21 @@
|
||||
# 中心服务:HTTP/WebSocket + gRPC(纯 Go / modernc SQLite,CGO_ENABLED=0)
|
||||
# 镜像内进程为明文 HTTP;HTTPS 请在宿主机 Nginx 等反代层配置,内网直连本端口即可。
|
||||
FROM golang:1.22-alpine AS build
|
||||
WORKDIR /src
|
||||
RUN apk add --no-cache git
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /central .
|
||||
|
||||
FROM alpine:3.20
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
WORKDIR /app
|
||||
# 与 config 默认一致; compose 中可覆盖
|
||||
ENV HOST=0.0.0.0
|
||||
ENV PORT=9393
|
||||
ENV GRPC_PORT=9394
|
||||
ENV DB_PATH=/app/data/servers.db
|
||||
COPY --from=build /central /app/central
|
||||
EXPOSE 9393 9394
|
||||
ENTRYPOINT ["/app/central"]
|
||||
13
mengyamonitor-backend-server/Dockerfile.binary
Normal file
13
mengyamonitor-backend-server/Dockerfile.binary
Normal file
@@ -0,0 +1,13 @@
|
||||
# 使用本机/CI 已编译好的 Linux amd64 二进制打包镜像(不在 Docker 内 go build)
|
||||
# 先执行: build-linux-amd64.bat 或 release 目录放置同名文件
|
||||
FROM alpine:3.20
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
WORKDIR /app
|
||||
ENV HOST=0.0.0.0
|
||||
ENV PORT=9393
|
||||
ENV GRPC_PORT=9394
|
||||
ENV DB_PATH=/app/data/servers.db
|
||||
COPY release/mengyamonitor-central-linux-amd64 /app/central
|
||||
RUN chmod +x /app/central
|
||||
EXPOSE 9393 9394
|
||||
ENTRYPOINT ["/app/central"]
|
||||
28
mengyamonitor-backend-server/build-linux-amd64.bat
Normal file
28
mengyamonitor-backend-server/build-linux-amd64.bat
Normal file
@@ -0,0 +1,28 @@
|
||||
@echo off
|
||||
setlocal
|
||||
cd /d "%~dp0"
|
||||
|
||||
where go >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo [错误] 未找到 go,请先安装 Go 并加入 PATH
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
if not exist release mkdir release
|
||||
|
||||
set GOOS=linux
|
||||
set GOARCH=amd64
|
||||
set CGO_ENABLED=0
|
||||
|
||||
echo 正在编译 Linux amd64 release\mengyamonitor-central-linux-amd64 ...
|
||||
go build -trimpath -ldflags="-s -w" -o release\mengyamonitor-central-linux-amd64 .
|
||||
if errorlevel 1 (
|
||||
echo [错误] go build 失败
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo 完成: %cd%\release\mengyamonitor-central-linux-amd64
|
||||
echo.
|
||||
echo 可选:用预编译镜像启动(不再在 Docker 里编译)
|
||||
echo docker compose -f docker-compose.binary.yml up -d --build
|
||||
exit /b 0
|
||||
20
mengyamonitor-backend-server/docker-compose.binary.yml
Normal file
20
mengyamonitor-backend-server/docker-compose.binary.yml
Normal file
@@ -0,0 +1,20 @@
|
||||
# 使用 release/mengyamonitor-central-linux-amd64 打包镜像(先在 Windows 运行 build-linux-amd64.bat)
|
||||
# 容器内仍是 HTTP:9393 / 9394;HTTPS 仅公网 Nginx 反代层
|
||||
services:
|
||||
central:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.binary
|
||||
image: mengyamonitor-central:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "9393:9393"
|
||||
- "9394:9394"
|
||||
environment:
|
||||
HOST: "0.0.0.0"
|
||||
PORT: "9393"
|
||||
GRPC_PORT: "9394"
|
||||
ADMIN_TOKEN: ${ADMIN_TOKEN:-shumengya520}
|
||||
DB_PATH: /app/data/servers.db
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
23
mengyamonitor-backend-server/docker-compose.yml
Normal file
23
mengyamonitor-backend-server/docker-compose.yml
Normal file
@@ -0,0 +1,23 @@
|
||||
# 中心服务在容器内仅为 HTTP(9393 Web/WS、9394 gRPC);TLS 由公网 Nginx 做,与内网无关。
|
||||
#
|
||||
# 本文件:在 Docker 内完整执行 go build(适合无本机 Go 或 CI)。
|
||||
# 若已在 Windows 编好 Linux 二进制,避免镜像内再编译:先运行 build-linux-amd64.bat,再
|
||||
# docker compose -f docker-compose.binary.yml up -d --build
|
||||
services:
|
||||
central:
|
||||
build: .
|
||||
image: mengyamonitor-central:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
# 宿主:容器 —— 可按需改为 "127.0.0.1:9393:9393" 仅本机反代
|
||||
- "9393:9393"
|
||||
- "9394:9394"
|
||||
environment:
|
||||
HOST: "0.0.0.0"
|
||||
PORT: "9393"
|
||||
GRPC_PORT: "9394"
|
||||
# 务必在宿主设置强口令:export ADMIN_TOKEN=... 或下方 env_file
|
||||
ADMIN_TOKEN: ${ADMIN_TOKEN:-shumengya520}
|
||||
DB_PATH: /app/data/servers.db
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
47
mengyamonitor-backend-server/go.mod
Normal file
47
mengyamonitor-backend-server/go.mod
Normal file
@@ -0,0 +1,47 @@
|
||||
module mengyamonitor-backend-server
|
||||
|
||||
go 1.22
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
google.golang.org/grpc v1.70.0
|
||||
google.golang.org/protobuf v1.35.2
|
||||
modernc.org/sqlite v1.34.5
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bytedance/sonic v1.11.6 // indirect
|
||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
golang.org/x/crypto v0.30.0 // indirect
|
||||
golang.org/x/net v0.32.0 // indirect
|
||||
golang.org/x/sys v0.28.0 // indirect
|
||||
golang.org/x/text v0.21.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20241202173237-19429a94021a // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
modernc.org/libc v1.55.3 // indirect
|
||||
modernc.org/mathutil v1.6.0 // indirect
|
||||
modernc.org/memory v1.8.0 // indirect
|
||||
)
|
||||
149
mengyamonitor-backend-server/go.sum
Normal file
149
mengyamonitor-backend-server/go.sum
Normal file
@@ -0,0 +1,149 @@
|
||||
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
||||
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
||||
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
|
||||
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
go.opentelemetry.io/otel v1.32.0 h1:WnBN+Xjcteh0zdk01SVqV55d/m62NJLJdIyb4y/WO5U=
|
||||
go.opentelemetry.io/otel v1.32.0/go.mod h1:00DCVSB0RQcnzlwyTfqtxSm+DRr9hpYrHjNGiBHVQIg=
|
||||
go.opentelemetry.io/otel/metric v1.32.0 h1:xV2umtmNcThh2/a/aCP+h64Xx5wsj8qqnkYZktzNa0M=
|
||||
go.opentelemetry.io/otel/metric v1.32.0/go.mod h1:jH7CIbbK6SH2V2wE16W05BHCtIDzauciCRLoc/SyMv8=
|
||||
go.opentelemetry.io/otel/sdk v1.32.0 h1:RNxepc9vK59A8XsgZQouW8ue8Gkb4jpWtJm9ge5lEG4=
|
||||
go.opentelemetry.io/otel/sdk v1.32.0/go.mod h1:LqgegDBjKMmb2GC6/PrTnteJG39I8/vJCAP9LlJXEjU=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.32.0 h1:rZvFnvmvawYb0alrYkjraqJq0Z4ZUJAiyYCU9snn1CU=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.32.0/go.mod h1:PWeZlq0zt9YkYAp3gjKZ0eicRYvOh1Gd+X99x6GHpCQ=
|
||||
go.opentelemetry.io/otel/trace v1.32.0 h1:WIC9mYrXf8TmY/EXuULKc8hR17vE+Hjv2cssQDe03fM=
|
||||
go.opentelemetry.io/otel/trace v1.32.0/go.mod h1:+i4rkvCraA+tG6AzwloGaCtkx53Fa+L+V8e9a7YvhT8=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.30.0 h1:RwoQn3GkWiMkzlX562cLB7OxWvjH1L8xutO2WoJcRoY=
|
||||
golang.org/x/crypto v0.30.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/net v0.32.0 h1:ZqPmj8Kzc+Y6e0+skZsuACbx+wzMgo5MQsJh9Qd6aYI=
|
||||
golang.org/x/net v0.32.0/go.mod h1:CwU0IoeOlnQQWJ6ioyFrfRuomB8GKF6KbYXZVyeXNfs=
|
||||
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20241202173237-19429a94021a h1:hgh8P4EuoxpsuKMXX/To36nOFD7vixReXgn8lPGnt+o=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20241202173237-19429a94021a/go.mod h1:5uTbfoYQed2U9p3KIj2/Zzm02PYhndfdmML0qC3q3FU=
|
||||
google.golang.org/grpc v1.70.0 h1:pWFv03aZoHzlRKHWicjsZytKAiYCtNS0dHbXnIdq7jQ=
|
||||
google.golang.org/grpc v1.70.0/go.mod h1:ofIJqVKDXx/JiXrwr2IG4/zwdH9txy3IlF40RmcJSQw=
|
||||
google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io=
|
||||
google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
|
||||
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
|
||||
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
|
||||
modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
|
||||
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
||||
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
||||
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
|
||||
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
|
||||
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
|
||||
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
|
||||
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
||||
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
||||
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
|
||||
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
|
||||
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
|
||||
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
|
||||
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
|
||||
modernc.org/sqlite v1.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g=
|
||||
modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE=
|
||||
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
|
||||
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
30
mengyamonitor-backend-server/internal/config/config.go
Normal file
30
mengyamonitor-backend-server/internal/config/config.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Host string
|
||||
Port string
|
||||
GRPCPort string
|
||||
AdminToken string
|
||||
DBPath string
|
||||
}
|
||||
|
||||
func Load() *Config {
|
||||
return &Config{
|
||||
Host: getenv("HOST", "0.0.0.0"),
|
||||
Port: getenv("PORT", "9393"),
|
||||
GRPCPort: getenv("GRPC_PORT", "9394"),
|
||||
AdminToken: getenv("ADMIN_TOKEN", "shumengya520"),
|
||||
DBPath: getenv("DB_PATH", "./data/servers.db"),
|
||||
}
|
||||
}
|
||||
|
||||
func getenv(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
492
mengyamonitor-backend-server/internal/gen/mengyav1/agent.pb.go
Normal file
492
mengyamonitor-backend-server/internal/gen/mengyav1/agent.pb.go
Normal file
@@ -0,0 +1,492 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.5
|
||||
// protoc v5.28.3
|
||||
// source: mengya/v1/agent.proto
|
||||
|
||||
package mengyav1
|
||||
|
||||
import (
|
||||
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
reflect "reflect"
|
||||
sync "sync"
|
||||
unsafe "unsafe"
|
||||
)
|
||||
|
||||
const (
|
||||
// Verify that this generated code is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
|
||||
// Verify that runtime/protoimpl is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
|
||||
)
|
||||
|
||||
type AgentMessage struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
// Types that are valid to be assigned to Payload:
|
||||
//
|
||||
// *AgentMessage_Hello
|
||||
// *AgentMessage_Heartbeat
|
||||
// *AgentMessage_Metrics
|
||||
Payload isAgentMessage_Payload `protobuf_oneof:"payload"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *AgentMessage) Reset() {
|
||||
*x = AgentMessage{}
|
||||
mi := &file_mengya_v1_agent_proto_msgTypes[0]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *AgentMessage) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*AgentMessage) ProtoMessage() {}
|
||||
|
||||
func (x *AgentMessage) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_mengya_v1_agent_proto_msgTypes[0]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use AgentMessage.ProtoReflect.Descriptor instead.
|
||||
func (*AgentMessage) Descriptor() ([]byte, []int) {
|
||||
return file_mengya_v1_agent_proto_rawDescGZIP(), []int{0}
|
||||
}
|
||||
|
||||
func (x *AgentMessage) GetPayload() isAgentMessage_Payload {
|
||||
if x != nil {
|
||||
return x.Payload
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *AgentMessage) GetHello() *Hello {
|
||||
if x != nil {
|
||||
if x, ok := x.Payload.(*AgentMessage_Hello); ok {
|
||||
return x.Hello
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *AgentMessage) GetHeartbeat() *Heartbeat {
|
||||
if x != nil {
|
||||
if x, ok := x.Payload.(*AgentMessage_Heartbeat); ok {
|
||||
return x.Heartbeat
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *AgentMessage) GetMetrics() *MetricsReport {
|
||||
if x != nil {
|
||||
if x, ok := x.Payload.(*AgentMessage_Metrics); ok {
|
||||
return x.Metrics
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type isAgentMessage_Payload interface {
|
||||
isAgentMessage_Payload()
|
||||
}
|
||||
|
||||
type AgentMessage_Hello struct {
|
||||
Hello *Hello `protobuf:"bytes,1,opt,name=hello,proto3,oneof"`
|
||||
}
|
||||
|
||||
type AgentMessage_Heartbeat struct {
|
||||
Heartbeat *Heartbeat `protobuf:"bytes,2,opt,name=heartbeat,proto3,oneof"`
|
||||
}
|
||||
|
||||
type AgentMessage_Metrics struct {
|
||||
Metrics *MetricsReport `protobuf:"bytes,3,opt,name=metrics,proto3,oneof"`
|
||||
}
|
||||
|
||||
func (*AgentMessage_Hello) isAgentMessage_Payload() {}
|
||||
|
||||
func (*AgentMessage_Heartbeat) isAgentMessage_Payload() {}
|
||||
|
||||
func (*AgentMessage_Metrics) isAgentMessage_Payload() {}
|
||||
|
||||
type Hello struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
ServerId string `protobuf:"bytes,1,opt,name=server_id,json=serverId,proto3" json:"server_id,omitempty"`
|
||||
AgentKey string `protobuf:"bytes,2,opt,name=agent_key,json=agentKey,proto3" json:"agent_key,omitempty"`
|
||||
Hostname string `protobuf:"bytes,3,opt,name=hostname,proto3" json:"hostname,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *Hello) Reset() {
|
||||
*x = Hello{}
|
||||
mi := &file_mengya_v1_agent_proto_msgTypes[1]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *Hello) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*Hello) ProtoMessage() {}
|
||||
|
||||
func (x *Hello) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_mengya_v1_agent_proto_msgTypes[1]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use Hello.ProtoReflect.Descriptor instead.
|
||||
func (*Hello) Descriptor() ([]byte, []int) {
|
||||
return file_mengya_v1_agent_proto_rawDescGZIP(), []int{1}
|
||||
}
|
||||
|
||||
func (x *Hello) GetServerId() string {
|
||||
if x != nil {
|
||||
return x.ServerId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *Hello) GetAgentKey() string {
|
||||
if x != nil {
|
||||
return x.AgentKey
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *Hello) GetHostname() string {
|
||||
if x != nil {
|
||||
return x.Hostname
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type Heartbeat struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *Heartbeat) Reset() {
|
||||
*x = Heartbeat{}
|
||||
mi := &file_mengya_v1_agent_proto_msgTypes[2]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *Heartbeat) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*Heartbeat) ProtoMessage() {}
|
||||
|
||||
func (x *Heartbeat) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_mengya_v1_agent_proto_msgTypes[2]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use Heartbeat.ProtoReflect.Descriptor instead.
|
||||
func (*Heartbeat) Descriptor() ([]byte, []int) {
|
||||
return file_mengya_v1_agent_proto_rawDescGZIP(), []int{2}
|
||||
}
|
||||
|
||||
type MetricsReport struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
PayloadJson string `protobuf:"bytes,1,opt,name=payload_json,json=payloadJson,proto3" json:"payload_json,omitempty"`
|
||||
CollectedAtUnixMs int64 `protobuf:"varint,2,opt,name=collected_at_unix_ms,json=collectedAtUnixMs,proto3" json:"collected_at_unix_ms,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *MetricsReport) Reset() {
|
||||
*x = MetricsReport{}
|
||||
mi := &file_mengya_v1_agent_proto_msgTypes[3]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *MetricsReport) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*MetricsReport) ProtoMessage() {}
|
||||
|
||||
func (x *MetricsReport) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_mengya_v1_agent_proto_msgTypes[3]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use MetricsReport.ProtoReflect.Descriptor instead.
|
||||
func (*MetricsReport) Descriptor() ([]byte, []int) {
|
||||
return file_mengya_v1_agent_proto_rawDescGZIP(), []int{3}
|
||||
}
|
||||
|
||||
func (x *MetricsReport) GetPayloadJson() string {
|
||||
if x != nil {
|
||||
return x.PayloadJson
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *MetricsReport) GetCollectedAtUnixMs() int64 {
|
||||
if x != nil {
|
||||
return x.CollectedAtUnixMs
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type ControlMessage struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
// Types that are valid to be assigned to Payload:
|
||||
//
|
||||
// *ControlMessage_Ack
|
||||
Payload isControlMessage_Payload `protobuf_oneof:"payload"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *ControlMessage) Reset() {
|
||||
*x = ControlMessage{}
|
||||
mi := &file_mengya_v1_agent_proto_msgTypes[4]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *ControlMessage) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*ControlMessage) ProtoMessage() {}
|
||||
|
||||
func (x *ControlMessage) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_mengya_v1_agent_proto_msgTypes[4]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use ControlMessage.ProtoReflect.Descriptor instead.
|
||||
func (*ControlMessage) Descriptor() ([]byte, []int) {
|
||||
return file_mengya_v1_agent_proto_rawDescGZIP(), []int{4}
|
||||
}
|
||||
|
||||
func (x *ControlMessage) GetPayload() isControlMessage_Payload {
|
||||
if x != nil {
|
||||
return x.Payload
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *ControlMessage) GetAck() *Ack {
|
||||
if x != nil {
|
||||
if x, ok := x.Payload.(*ControlMessage_Ack); ok {
|
||||
return x.Ack
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type isControlMessage_Payload interface {
|
||||
isControlMessage_Payload()
|
||||
}
|
||||
|
||||
type ControlMessage_Ack struct {
|
||||
Ack *Ack `protobuf:"bytes,1,opt,name=ack,proto3,oneof"`
|
||||
}
|
||||
|
||||
func (*ControlMessage_Ack) isControlMessage_Payload() {}
|
||||
|
||||
type Ack struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Message string `protobuf:"bytes,1,opt,name=message,proto3" json:"message,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *Ack) Reset() {
|
||||
*x = Ack{}
|
||||
mi := &file_mengya_v1_agent_proto_msgTypes[5]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *Ack) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*Ack) ProtoMessage() {}
|
||||
|
||||
func (x *Ack) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_mengya_v1_agent_proto_msgTypes[5]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use Ack.ProtoReflect.Descriptor instead.
|
||||
func (*Ack) Descriptor() ([]byte, []int) {
|
||||
return file_mengya_v1_agent_proto_rawDescGZIP(), []int{5}
|
||||
}
|
||||
|
||||
func (x *Ack) GetMessage() string {
|
||||
if x != nil {
|
||||
return x.Message
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
var File_mengya_v1_agent_proto protoreflect.FileDescriptor
|
||||
|
||||
var file_mengya_v1_agent_proto_rawDesc = string([]byte{
|
||||
0x0a, 0x15, 0x6d, 0x65, 0x6e, 0x67, 0x79, 0x61, 0x2f, 0x76, 0x31, 0x2f, 0x61, 0x67, 0x65, 0x6e,
|
||||
0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x09, 0x6d, 0x65, 0x6e, 0x67, 0x79, 0x61, 0x2e,
|
||||
0x76, 0x31, 0x22, 0xaf, 0x01, 0x0a, 0x0c, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x4d, 0x65, 0x73, 0x73,
|
||||
0x61, 0x67, 0x65, 0x12, 0x28, 0x0a, 0x05, 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x18, 0x01, 0x20, 0x01,
|
||||
0x28, 0x0b, 0x32, 0x10, 0x2e, 0x6d, 0x65, 0x6e, 0x67, 0x79, 0x61, 0x2e, 0x76, 0x31, 0x2e, 0x48,
|
||||
0x65, 0x6c, 0x6c, 0x6f, 0x48, 0x00, 0x52, 0x05, 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x12, 0x34, 0x0a,
|
||||
0x09, 0x68, 0x65, 0x61, 0x72, 0x74, 0x62, 0x65, 0x61, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b,
|
||||
0x32, 0x14, 0x2e, 0x6d, 0x65, 0x6e, 0x67, 0x79, 0x61, 0x2e, 0x76, 0x31, 0x2e, 0x48, 0x65, 0x61,
|
||||
0x72, 0x74, 0x62, 0x65, 0x61, 0x74, 0x48, 0x00, 0x52, 0x09, 0x68, 0x65, 0x61, 0x72, 0x74, 0x62,
|
||||
0x65, 0x61, 0x74, 0x12, 0x34, 0x0a, 0x07, 0x6d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, 0x18, 0x03,
|
||||
0x20, 0x01, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x65, 0x6e, 0x67, 0x79, 0x61, 0x2e, 0x76, 0x31,
|
||||
0x2e, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x48, 0x00,
|
||||
0x52, 0x07, 0x6d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, 0x42, 0x09, 0x0a, 0x07, 0x70, 0x61, 0x79,
|
||||
0x6c, 0x6f, 0x61, 0x64, 0x22, 0x5d, 0x0a, 0x05, 0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x12, 0x1b, 0x0a,
|
||||
0x09, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
|
||||
0x52, 0x08, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x61, 0x67,
|
||||
0x65, 0x6e, 0x74, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x61,
|
||||
0x67, 0x65, 0x6e, 0x74, 0x4b, 0x65, 0x79, 0x12, 0x1a, 0x0a, 0x08, 0x68, 0x6f, 0x73, 0x74, 0x6e,
|
||||
0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x68, 0x6f, 0x73, 0x74, 0x6e,
|
||||
0x61, 0x6d, 0x65, 0x22, 0x0b, 0x0a, 0x09, 0x48, 0x65, 0x61, 0x72, 0x74, 0x62, 0x65, 0x61, 0x74,
|
||||
0x22, 0x63, 0x0a, 0x0d, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, 0x52, 0x65, 0x70, 0x6f, 0x72,
|
||||
0x74, 0x12, 0x21, 0x0a, 0x0c, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x5f, 0x6a, 0x73, 0x6f,
|
||||
0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64,
|
||||
0x4a, 0x73, 0x6f, 0x6e, 0x12, 0x2f, 0x0a, 0x14, 0x63, 0x6f, 0x6c, 0x6c, 0x65, 0x63, 0x74, 0x65,
|
||||
0x64, 0x5f, 0x61, 0x74, 0x5f, 0x75, 0x6e, 0x69, 0x78, 0x5f, 0x6d, 0x73, 0x18, 0x02, 0x20, 0x01,
|
||||
0x28, 0x03, 0x52, 0x11, 0x63, 0x6f, 0x6c, 0x6c, 0x65, 0x63, 0x74, 0x65, 0x64, 0x41, 0x74, 0x55,
|
||||
0x6e, 0x69, 0x78, 0x4d, 0x73, 0x22, 0x3f, 0x0a, 0x0e, 0x43, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c,
|
||||
0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x22, 0x0a, 0x03, 0x61, 0x63, 0x6b, 0x18, 0x01,
|
||||
0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x6d, 0x65, 0x6e, 0x67, 0x79, 0x61, 0x2e, 0x76, 0x31,
|
||||
0x2e, 0x41, 0x63, 0x6b, 0x48, 0x00, 0x52, 0x03, 0x61, 0x63, 0x6b, 0x42, 0x09, 0x0a, 0x07, 0x70,
|
||||
0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x22, 0x1f, 0x0a, 0x03, 0x41, 0x63, 0x6b, 0x12, 0x18, 0x0a,
|
||||
0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07,
|
||||
0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x32, 0x50, 0x0a, 0x0b, 0x41, 0x67, 0x65, 0x6e, 0x74,
|
||||
0x49, 0x6e, 0x67, 0x65, 0x73, 0x74, 0x12, 0x41, 0x0a, 0x07, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63,
|
||||
0x74, 0x12, 0x17, 0x2e, 0x6d, 0x65, 0x6e, 0x67, 0x79, 0x61, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x67,
|
||||
0x65, 0x6e, 0x74, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x19, 0x2e, 0x6d, 0x65, 0x6e,
|
||||
0x67, 0x79, 0x61, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x4d, 0x65,
|
||||
0x73, 0x73, 0x61, 0x67, 0x65, 0x28, 0x01, 0x30, 0x01, 0x42, 0x3d, 0x5a, 0x3b, 0x6d, 0x65, 0x6e,
|
||||
0x67, 0x79, 0x61, 0x6d, 0x6f, 0x6e, 0x69, 0x74, 0x6f, 0x72, 0x2d, 0x62, 0x61, 0x63, 0x6b, 0x65,
|
||||
0x6e, 0x64, 0x2d, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e,
|
||||
0x61, 0x6c, 0x2f, 0x67, 0x65, 0x6e, 0x2f, 0x6d, 0x65, 0x6e, 0x67, 0x79, 0x61, 0x76, 0x31, 0x3b,
|
||||
0x6d, 0x65, 0x6e, 0x67, 0x79, 0x61, 0x76, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
|
||||
})
|
||||
|
||||
var (
|
||||
file_mengya_v1_agent_proto_rawDescOnce sync.Once
|
||||
file_mengya_v1_agent_proto_rawDescData []byte
|
||||
)
|
||||
|
||||
func file_mengya_v1_agent_proto_rawDescGZIP() []byte {
|
||||
file_mengya_v1_agent_proto_rawDescOnce.Do(func() {
|
||||
file_mengya_v1_agent_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_mengya_v1_agent_proto_rawDesc), len(file_mengya_v1_agent_proto_rawDesc)))
|
||||
})
|
||||
return file_mengya_v1_agent_proto_rawDescData
|
||||
}
|
||||
|
||||
var file_mengya_v1_agent_proto_msgTypes = make([]protoimpl.MessageInfo, 6)
|
||||
var file_mengya_v1_agent_proto_goTypes = []any{
|
||||
(*AgentMessage)(nil), // 0: mengya.v1.AgentMessage
|
||||
(*Hello)(nil), // 1: mengya.v1.Hello
|
||||
(*Heartbeat)(nil), // 2: mengya.v1.Heartbeat
|
||||
(*MetricsReport)(nil), // 3: mengya.v1.MetricsReport
|
||||
(*ControlMessage)(nil), // 4: mengya.v1.ControlMessage
|
||||
(*Ack)(nil), // 5: mengya.v1.Ack
|
||||
}
|
||||
var file_mengya_v1_agent_proto_depIdxs = []int32{
|
||||
1, // 0: mengya.v1.AgentMessage.hello:type_name -> mengya.v1.Hello
|
||||
2, // 1: mengya.v1.AgentMessage.heartbeat:type_name -> mengya.v1.Heartbeat
|
||||
3, // 2: mengya.v1.AgentMessage.metrics:type_name -> mengya.v1.MetricsReport
|
||||
5, // 3: mengya.v1.ControlMessage.ack:type_name -> mengya.v1.Ack
|
||||
0, // 4: mengya.v1.AgentIngest.Connect:input_type -> mengya.v1.AgentMessage
|
||||
4, // 5: mengya.v1.AgentIngest.Connect:output_type -> mengya.v1.ControlMessage
|
||||
5, // [5:6] is the sub-list for method output_type
|
||||
4, // [4:5] is the sub-list for method input_type
|
||||
4, // [4:4] is the sub-list for extension type_name
|
||||
4, // [4:4] is the sub-list for extension extendee
|
||||
0, // [0:4] is the sub-list for field type_name
|
||||
}
|
||||
|
||||
func init() { file_mengya_v1_agent_proto_init() }
|
||||
func file_mengya_v1_agent_proto_init() {
|
||||
if File_mengya_v1_agent_proto != nil {
|
||||
return
|
||||
}
|
||||
file_mengya_v1_agent_proto_msgTypes[0].OneofWrappers = []any{
|
||||
(*AgentMessage_Hello)(nil),
|
||||
(*AgentMessage_Heartbeat)(nil),
|
||||
(*AgentMessage_Metrics)(nil),
|
||||
}
|
||||
file_mengya_v1_agent_proto_msgTypes[4].OneofWrappers = []any{
|
||||
(*ControlMessage_Ack)(nil),
|
||||
}
|
||||
type x struct{}
|
||||
out := protoimpl.TypeBuilder{
|
||||
File: protoimpl.DescBuilder{
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_mengya_v1_agent_proto_rawDesc), len(file_mengya_v1_agent_proto_rawDesc)),
|
||||
NumEnums: 0,
|
||||
NumMessages: 6,
|
||||
NumExtensions: 0,
|
||||
NumServices: 1,
|
||||
},
|
||||
GoTypes: file_mengya_v1_agent_proto_goTypes,
|
||||
DependencyIndexes: file_mengya_v1_agent_proto_depIdxs,
|
||||
MessageInfos: file_mengya_v1_agent_proto_msgTypes,
|
||||
}.Build()
|
||||
File_mengya_v1_agent_proto = out.File
|
||||
file_mengya_v1_agent_proto_goTypes = nil
|
||||
file_mengya_v1_agent_proto_depIdxs = nil
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.5.1
|
||||
// - protoc v5.28.3
|
||||
// source: mengya/v1/agent.proto
|
||||
|
||||
package mengyav1
|
||||
|
||||
import (
|
||||
context "context"
|
||||
grpc "google.golang.org/grpc"
|
||||
codes "google.golang.org/grpc/codes"
|
||||
status "google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// This is a compile-time assertion to ensure that this generated file
|
||||
// is compatible with the grpc package it is being compiled against.
|
||||
// Requires gRPC-Go v1.64.0 or later.
|
||||
const _ = grpc.SupportPackageIsVersion9
|
||||
|
||||
const (
|
||||
AgentIngest_Connect_FullMethodName = "/mengya.v1.AgentIngest/Connect"
|
||||
)
|
||||
|
||||
// AgentIngestClient is the client API for AgentIngest service.
|
||||
//
|
||||
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
|
||||
//
|
||||
// AgentIngest: 采集端出站连接,双向流上报指标。
|
||||
type AgentIngestClient interface {
|
||||
Connect(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[AgentMessage, ControlMessage], error)
|
||||
}
|
||||
|
||||
type agentIngestClient struct {
|
||||
cc grpc.ClientConnInterface
|
||||
}
|
||||
|
||||
func NewAgentIngestClient(cc grpc.ClientConnInterface) AgentIngestClient {
|
||||
return &agentIngestClient{cc}
|
||||
}
|
||||
|
||||
func (c *agentIngestClient) Connect(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[AgentMessage, ControlMessage], error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
stream, err := c.cc.NewStream(ctx, &AgentIngest_ServiceDesc.Streams[0], AgentIngest_Connect_FullMethodName, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
x := &grpc.GenericClientStream[AgentMessage, ControlMessage]{ClientStream: stream}
|
||||
return x, nil
|
||||
}
|
||||
|
||||
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
||||
type AgentIngest_ConnectClient = grpc.BidiStreamingClient[AgentMessage, ControlMessage]
|
||||
|
||||
// AgentIngestServer is the server API for AgentIngest service.
|
||||
// All implementations must embed UnimplementedAgentIngestServer
|
||||
// for forward compatibility.
|
||||
//
|
||||
// AgentIngest: 采集端出站连接,双向流上报指标。
|
||||
type AgentIngestServer interface {
|
||||
Connect(grpc.BidiStreamingServer[AgentMessage, ControlMessage]) error
|
||||
mustEmbedUnimplementedAgentIngestServer()
|
||||
}
|
||||
|
||||
// UnimplementedAgentIngestServer must be embedded to have
|
||||
// forward compatible implementations.
|
||||
//
|
||||
// NOTE: this should be embedded by value instead of pointer to avoid a nil
|
||||
// pointer dereference when methods are called.
|
||||
type UnimplementedAgentIngestServer struct{}
|
||||
|
||||
func (UnimplementedAgentIngestServer) Connect(grpc.BidiStreamingServer[AgentMessage, ControlMessage]) error {
|
||||
return status.Errorf(codes.Unimplemented, "method Connect not implemented")
|
||||
}
|
||||
func (UnimplementedAgentIngestServer) mustEmbedUnimplementedAgentIngestServer() {}
|
||||
func (UnimplementedAgentIngestServer) testEmbeddedByValue() {}
|
||||
|
||||
// UnsafeAgentIngestServer may be embedded to opt out of forward compatibility for this service.
|
||||
// Use of this interface is not recommended, as added methods to AgentIngestServer will
|
||||
// result in compilation errors.
|
||||
type UnsafeAgentIngestServer interface {
|
||||
mustEmbedUnimplementedAgentIngestServer()
|
||||
}
|
||||
|
||||
func RegisterAgentIngestServer(s grpc.ServiceRegistrar, srv AgentIngestServer) {
|
||||
// If the following call pancis, it indicates UnimplementedAgentIngestServer was
|
||||
// embedded by pointer and is nil. This will cause panics if an
|
||||
// unimplemented method is ever invoked, so we test this at initialization
|
||||
// time to prevent it from happening at runtime later due to I/O.
|
||||
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
|
||||
t.testEmbeddedByValue()
|
||||
}
|
||||
s.RegisterService(&AgentIngest_ServiceDesc, srv)
|
||||
}
|
||||
|
||||
func _AgentIngest_Connect_Handler(srv interface{}, stream grpc.ServerStream) error {
|
||||
return srv.(AgentIngestServer).Connect(&grpc.GenericServerStream[AgentMessage, ControlMessage]{ServerStream: stream})
|
||||
}
|
||||
|
||||
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
||||
type AgentIngest_ConnectServer = grpc.BidiStreamingServer[AgentMessage, ControlMessage]
|
||||
|
||||
// AgentIngest_ServiceDesc is the grpc.ServiceDesc for AgentIngest service.
|
||||
// It's only intended for direct use with grpc.RegisterService,
|
||||
// and not to be introspected or modified (even as a copy)
|
||||
var AgentIngest_ServiceDesc = grpc.ServiceDesc{
|
||||
ServiceName: "mengya.v1.AgentIngest",
|
||||
HandlerType: (*AgentIngestServer)(nil),
|
||||
Methods: []grpc.MethodDesc{},
|
||||
Streams: []grpc.StreamDesc{
|
||||
{
|
||||
StreamName: "Connect",
|
||||
Handler: _AgentIngest_Connect_Handler,
|
||||
ServerStreams: true,
|
||||
ClientStreams: true,
|
||||
},
|
||||
},
|
||||
Metadata: "mengya/v1/agent.proto",
|
||||
}
|
||||
22
mengyamonitor-backend-server/internal/grpcrun/grpcrun.go
Normal file
22
mengyamonitor-backend-server/internal/grpcrun/grpcrun.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package grpcrun
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
|
||||
"mengyamonitor-backend-server/internal/gen/mengyav1"
|
||||
"mengyamonitor-backend-server/internal/ingest"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
// Serve starts a blocking gRPC server for agent ingest.
|
||||
func Serve(listenAddr string, srv *ingest.Server) error {
|
||||
lis, err := net.Listen("tcp", listenAddr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listen: %w", err)
|
||||
}
|
||||
g := grpc.NewServer()
|
||||
mengyav1.RegisterAgentIngestServer(g, srv)
|
||||
return g.Serve(lis)
|
||||
}
|
||||
100
mengyamonitor-backend-server/internal/handler/admin.go
Normal file
100
mengyamonitor-backend-server/internal/handler/admin.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"mengyamonitor-backend-server/internal/config"
|
||||
"mengyamonitor-backend-server/internal/model"
|
||||
"mengyamonitor-backend-server/internal/store"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Admin struct {
|
||||
Store *store.Store
|
||||
Cfg *config.Config
|
||||
}
|
||||
|
||||
func (h *Admin) Auth(c *gin.Context) {
|
||||
var in model.AuthInput
|
||||
if err := c.ShouldBindJSON(&in); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
|
||||
return
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(in.Token), []byte(h.Cfg.AdminToken)) != 1 {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (h *Admin) ListServers(c *gin.Context) {
|
||||
list, err := h.Store.ListAll(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": list})
|
||||
}
|
||||
|
||||
func (h *Admin) CreateServer(c *gin.Context) {
|
||||
var in model.CreateServerInput
|
||||
if err := c.ShouldBindJSON(&in); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
|
||||
return
|
||||
}
|
||||
s, err := h.Store.Create(c.Request.Context(), in)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"data": s})
|
||||
}
|
||||
|
||||
func (h *Admin) UpdateServer(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var in model.UpdateServerInput
|
||||
if err := c.ShouldBindJSON(&in); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
|
||||
return
|
||||
}
|
||||
s, err := h.Store.Update(c.Request.Context(), id, in)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": s})
|
||||
}
|
||||
|
||||
func (h *Admin) DeleteServer(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if err := h.Store.Delete(c.Request.Context(), id); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (h *Admin) Reorder(c *gin.Context) {
|
||||
var in model.ReorderInput
|
||||
if err := c.ShouldBindJSON(&in); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
|
||||
return
|
||||
}
|
||||
if err := h.Store.Reorder(c.Request.Context(), in.IDs); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
70
mengyamonitor-backend-server/internal/handler/public.go
Normal file
70
mengyamonitor-backend-server/internal/handler/public.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"mengyamonitor-backend-server/internal/model"
|
||||
"mengyamonitor-backend-server/internal/store"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Public struct {
|
||||
Store *store.Store
|
||||
}
|
||||
|
||||
// publicServer hides agent_key from unauthenticated API consumers.
|
||||
type publicServer struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
Enabled bool `json:"enabled"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
CreatedAt time.Time `json:"createdAt,omitempty"`
|
||||
}
|
||||
|
||||
func toPublicServers(in []model.MonitoredServer) []publicServer {
|
||||
out := make([]publicServer, 0, len(in))
|
||||
for _, s := range in {
|
||||
out = append(out, publicServer{
|
||||
ID: s.ID, Name: s.Name, URL: s.URL, Enabled: s.Enabled,
|
||||
SortOrder: s.SortOrder, CreatedAt: s.CreatedAt,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (h *Public) Health(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
}
|
||||
|
||||
func (h *Public) ListServers(c *gin.Context) {
|
||||
list, err := h.Store.ListEnabled(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": toPublicServers(list)})
|
||||
}
|
||||
|
||||
func (h *Public) Root(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"service": "萌芽监控 — 中心服务",
|
||||
"version": "1.0.0",
|
||||
"public": []string{
|
||||
"GET /api/health",
|
||||
"GET /api/servers",
|
||||
"GET /api/ws/monitor — WebSocket 指标快照",
|
||||
"gRPC AgentIngest — 见环境变量 GRPC_PORT",
|
||||
},
|
||||
"admin": []string{
|
||||
"POST /api/admin/auth",
|
||||
"GET /api/admin/servers",
|
||||
"POST /api/admin/servers",
|
||||
"PUT /api/admin/servers/:id",
|
||||
"DELETE /api/admin/servers/:id",
|
||||
"PUT /api/admin/servers/reorder",
|
||||
},
|
||||
})
|
||||
}
|
||||
50
mengyamonitor-backend-server/internal/handler/ws.go
Normal file
50
mengyamonitor-backend-server/internal/handler/ws.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"mengyamonitor-backend-server/internal/hub"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
var wsUpgrader = websocket.Upgrader{
|
||||
ReadBufferSize: 1024,
|
||||
WriteBufferSize: 1024,
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true
|
||||
},
|
||||
}
|
||||
|
||||
// MonitorWebSocket upgrades to WebSocket and streams hub snapshots.
|
||||
func MonitorWebSocket(h *hub.Hub) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
conn, err := wsUpgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if err != nil {
|
||||
log.Printf("ws: upgrade: %v", err)
|
||||
return
|
||||
}
|
||||
defer h.Unregister(conn)
|
||||
snap, err := h.BuildSnapshotMessage()
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
_ = conn.SetWriteDeadline(time.Now().Add(15 * time.Second))
|
||||
if err := conn.WriteMessage(websocket.TextMessage, snap); err != nil {
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
h.AddSubscriber(conn)
|
||||
for {
|
||||
_ = conn.SetReadDeadline(time.Now().Add(120 * time.Second))
|
||||
_, _, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
108
mengyamonitor-backend-server/internal/hub/hub.go
Normal file
108
mengyamonitor-backend-server/internal/hub/hub.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package hub
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// WSMessage is sent to browsers (matches frontend ServerStatus shape).
|
||||
type WSMessage struct {
|
||||
Type string `json:"type"`
|
||||
Data map[string]json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
type Hub struct {
|
||||
mu sync.RWMutex
|
||||
// serverId -> latest metrics JSON object (not wrapped)
|
||||
metrics map[string]json.RawMessage
|
||||
|
||||
subsMu sync.Mutex
|
||||
// subscribers for broadcast
|
||||
conns map[*websocket.Conn]struct{}
|
||||
}
|
||||
|
||||
func New() *Hub {
|
||||
return &Hub{
|
||||
metrics: make(map[string]json.RawMessage),
|
||||
conns: make(map[*websocket.Conn]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) SetMetrics(serverID string, metricsJSON []byte) {
|
||||
h.mu.Lock()
|
||||
if len(metricsJSON) == 0 {
|
||||
delete(h.metrics, serverID)
|
||||
} else {
|
||||
h.metrics[serverID] = json.RawMessage(append([]byte(nil), metricsJSON...))
|
||||
}
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
func (h *Hub) snapshotData() map[string]json.RawMessage {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
out := make(map[string]json.RawMessage, len(h.metrics))
|
||||
now := time.Now().UnixMilli()
|
||||
for id, raw := range h.metrics {
|
||||
// Wrap as ServerStatus for frontend compatibility
|
||||
wrap := map[string]any{
|
||||
"serverId": id,
|
||||
"online": true,
|
||||
"metrics": json.RawMessage(raw),
|
||||
"lastUpdate": now,
|
||||
}
|
||||
b, err := json.Marshal(wrap)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out[id] = b
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (h *Hub) BuildSnapshotMessage() ([]byte, error) {
|
||||
msg := WSMessage{
|
||||
Type: "snapshot",
|
||||
Data: h.snapshotData(),
|
||||
}
|
||||
return json.Marshal(msg)
|
||||
}
|
||||
|
||||
// AddSubscriber registers for broadcast fan-out (call after sending the initial snapshot).
|
||||
func (h *Hub) AddSubscriber(c *websocket.Conn) {
|
||||
h.subsMu.Lock()
|
||||
h.conns[c] = struct{}{}
|
||||
h.subsMu.Unlock()
|
||||
}
|
||||
|
||||
func (h *Hub) Unregister(c *websocket.Conn) {
|
||||
h.subsMu.Lock()
|
||||
delete(h.conns, c)
|
||||
h.subsMu.Unlock()
|
||||
}
|
||||
|
||||
// BroadcastSnapshot pushes full snapshot to all subscribers.
|
||||
func (h *Hub) BroadcastSnapshot() {
|
||||
body, err := h.BuildSnapshotMessage()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
h.subsMu.Lock()
|
||||
defer h.subsMu.Unlock()
|
||||
for c := range h.conns {
|
||||
_ = c.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||
if err := c.WriteMessage(websocket.TextMessage, body); err != nil {
|
||||
_ = c.Close()
|
||||
delete(h.conns, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NotifyMetricsUpdate should be called after SetMetrics to push to browsers.
|
||||
func (h *Hub) NotifyMetricsUpdate(serverID string, metricsJSON []byte) {
|
||||
h.SetMetrics(serverID, metricsJSON)
|
||||
h.BroadcastSnapshot()
|
||||
}
|
||||
82
mengyamonitor-backend-server/internal/ingest/server.go
Normal file
82
mengyamonitor-backend-server/internal/ingest/server.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package ingest
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
|
||||
"mengyamonitor-backend-server/internal/gen/mengyav1"
|
||||
"mengyamonitor-backend-server/internal/hub"
|
||||
"mengyamonitor-backend-server/internal/store"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
mengyav1.UnimplementedAgentIngestServer
|
||||
Store *store.Store
|
||||
Hub *hub.Hub
|
||||
}
|
||||
|
||||
func (s *Server) Connect(stream grpc.BidiStreamingServer[mengyav1.AgentMessage, mengyav1.ControlMessage]) error {
|
||||
var authedID string
|
||||
for {
|
||||
msg, err := stream.Recv()
|
||||
if err == io.EOF {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx := stream.Context()
|
||||
|
||||
switch {
|
||||
case msg.GetHello() != nil:
|
||||
h := msg.GetHello()
|
||||
srv, err := s.Store.VerifyAgent(ctx, h.GetServerId(), h.GetAgentKey())
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
log.Printf("ingest: auth failed server_id=%q: no such id (SERVER_ID must be the UUID from admin table, not the display name)", h.GetServerId())
|
||||
} else {
|
||||
log.Printf("ingest: auth failed server_id=%s: %v", h.GetServerId(), err)
|
||||
}
|
||||
return status.Errorf(codes.Unauthenticated, "invalid credentials")
|
||||
}
|
||||
authedID = srv.ID
|
||||
_ = stream.Send(&mengyav1.ControlMessage{
|
||||
Payload: &mengyav1.ControlMessage_Ack{
|
||||
Ack: &mengyav1.Ack{Message: "hello_ok"},
|
||||
},
|
||||
})
|
||||
|
||||
case msg.GetHeartbeat() != nil:
|
||||
if authedID == "" {
|
||||
continue
|
||||
}
|
||||
_ = stream.Send(&mengyav1.ControlMessage{
|
||||
Payload: &mengyav1.ControlMessage_Ack{
|
||||
Ack: &mengyav1.Ack{Message: "pong"},
|
||||
},
|
||||
})
|
||||
|
||||
case msg.GetMetrics() != nil:
|
||||
if authedID == "" {
|
||||
return status.Errorf(codes.FailedPrecondition, "send hello first")
|
||||
}
|
||||
m := msg.GetMetrics()
|
||||
if len(m.GetPayloadJson()) == 0 {
|
||||
continue
|
||||
}
|
||||
s.Hub.NotifyMetricsUpdate(authedID, []byte(m.GetPayloadJson()))
|
||||
_ = stream.Send(&mengyav1.ControlMessage{
|
||||
Payload: &mengyav1.ControlMessage_Ack{
|
||||
Ack: &mengyav1.Ack{Message: "metrics_ok"},
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
23
mengyamonitor-backend-server/internal/middleware/admin.go
Normal file
23
mengyamonitor-backend-server/internal/middleware/admin.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"net/http"
|
||||
|
||||
"mengyamonitor-backend-server/internal/config"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// AdminToken rejects requests without a valid X-Admin-Token header.
|
||||
func AdminToken(cfg *config.Config) gin.HandlerFunc {
|
||||
tok := []byte(cfg.AdminToken)
|
||||
return func(c *gin.Context) {
|
||||
given := []byte(c.GetHeader("X-Admin-Token"))
|
||||
if subtle.ConstantTimeCompare(given, tok) != 1 {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
35
mengyamonitor-backend-server/internal/model/server.go
Normal file
35
mengyamonitor-backend-server/internal/model/server.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// MonitoredServer is persisted and exposed to the frontend display.
|
||||
type MonitoredServer struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
Enabled bool `json:"enabled"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
AgentKey string `json:"agentKey,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt,omitempty"`
|
||||
}
|
||||
|
||||
type CreateServerInput struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
URL string `json:"url"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
|
||||
type UpdateServerInput struct {
|
||||
Name string `json:"name"`
|
||||
URL *string `json:"url"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
AgentKey *string `json:"agentKey"`
|
||||
}
|
||||
|
||||
type ReorderInput struct {
|
||||
IDs []string `json:"ids" binding:"required"`
|
||||
}
|
||||
|
||||
type AuthInput struct {
|
||||
Token string `json:"token" binding:"required"`
|
||||
}
|
||||
71
mengyamonitor-backend-server/internal/router/router.go
Normal file
71
mengyamonitor-backend-server/internal/router/router.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"mengyamonitor-backend-server/internal/config"
|
||||
"mengyamonitor-backend-server/internal/handler"
|
||||
"mengyamonitor-backend-server/internal/hub"
|
||||
"mengyamonitor-backend-server/internal/middleware"
|
||||
"mengyamonitor-backend-server/internal/store"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func New(cfg *config.Config, st *store.Store, h *hub.Hub) *gin.Engine {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
r := gin.New()
|
||||
r.Use(gin.Logger(), gin.Recovery())
|
||||
|
||||
// 公网宽松 CORS:任意 Origin + 透传浏览器请求的 Header(预检)
|
||||
r.Use(func(c *gin.Context) {
|
||||
h := c.Writer.Header()
|
||||
origin := c.GetHeader("Origin")
|
||||
if origin != "" {
|
||||
h.Set("Access-Control-Allow-Origin", origin)
|
||||
h.Set("Access-Control-Allow-Credentials", "true")
|
||||
} else {
|
||||
h.Set("Access-Control-Allow-Origin", "*")
|
||||
}
|
||||
h.Set("Access-Control-Allow-Methods", "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||
if reqH := c.GetHeader("Access-Control-Request-Headers"); reqH != "" {
|
||||
h.Set("Access-Control-Allow-Headers", reqH)
|
||||
} else {
|
||||
h.Set("Access-Control-Allow-Headers", "Content-Type, X-Admin-Token, Authorization, Accept, Origin, X-Requested-With")
|
||||
}
|
||||
h.Set("Access-Control-Max-Age", "86400")
|
||||
if c.Request.Method == http.MethodOptions {
|
||||
c.AbortWithStatus(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
})
|
||||
|
||||
pub := &handler.Public{Store: st}
|
||||
r.GET("/", pub.Root)
|
||||
r.GET("/api/health", pub.Health)
|
||||
r.GET("/api/servers", pub.ListServers)
|
||||
r.GET("/api/ws/monitor", handler.MonitorWebSocket(h))
|
||||
|
||||
adm := &handler.Admin{Store: st, Cfg: cfg}
|
||||
r.POST("/api/admin/auth", adm.Auth)
|
||||
adminG := r.Group("/api/admin")
|
||||
adminG.Use(middleware.AdminToken(cfg))
|
||||
{
|
||||
adminG.GET("/servers", adm.ListServers)
|
||||
adminG.POST("/servers", adm.CreateServer)
|
||||
adminG.PUT("/servers/reorder", adm.Reorder)
|
||||
adminG.PUT("/servers/:id", adm.UpdateServer)
|
||||
adminG.DELETE("/servers/:id", adm.DeleteServer)
|
||||
}
|
||||
|
||||
r.NoRoute(func(c *gin.Context) {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": "not_found",
|
||||
"path": c.Request.URL.Path,
|
||||
"method": c.Request.Method,
|
||||
})
|
||||
})
|
||||
|
||||
return r
|
||||
}
|
||||
307
mengyamonitor-backend-server/internal/store/sqlite.go
Normal file
307
mengyamonitor-backend-server/internal/store/sqlite.go
Normal file
@@ -0,0 +1,307 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"mengyamonitor-backend-server/internal/model"
|
||||
|
||||
"github.com/google/uuid"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func Open(dbPath string) (*Store, error) {
|
||||
dir := filepath.Dir(dbPath)
|
||||
if dir != "." && dir != "" {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
db, err := sql.Open("sqlite", dbPath+"?_pragma=foreign_keys(1)")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := db.Ping(); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
s := &Store{db: db}
|
||||
if err := s.migrate(); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Store) Close() error {
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
func (s *Store) migrate() error {
|
||||
const ddl = `
|
||||
CREATE TABLE IF NOT EXISTS servers (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_servers_sort ON servers(sort_order);
|
||||
`
|
||||
if _, err := s.db.Exec(ddl); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.ensureAgentKeyColumn()
|
||||
}
|
||||
|
||||
func (s *Store) ensureAgentKeyColumn() error {
|
||||
rows, err := s.db.Query(`PRAGMA table_info(servers)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var name, typ string
|
||||
var notnull, pk int
|
||||
var dflt sql.NullString
|
||||
if err := rows.Scan(&cid, &name, &typ, ¬null, &dflt, &pk); err != nil {
|
||||
return err
|
||||
}
|
||||
if name == "agent_key" {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = s.db.Exec(`ALTER TABLE servers ADD COLUMN agent_key TEXT`); err != nil {
|
||||
return err
|
||||
}
|
||||
// Backfill keys for existing rows so gRPC can be enabled without manual SQL.
|
||||
bRows, err := s.db.Query(`SELECT id FROM servers WHERE agent_key IS NULL OR agent_key = ''`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var ids []string
|
||||
for bRows.Next() {
|
||||
var id string
|
||||
if err := bRows.Scan(&id); err != nil {
|
||||
_ = bRows.Close()
|
||||
return err
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
_ = bRows.Close()
|
||||
for _, id := range ids {
|
||||
if _, err := s.db.Exec(`UPDATE servers SET agent_key = ? WHERE id = ?`, uuid.NewString(), id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) ListEnabled(ctx context.Context) ([]model.MonitoredServer, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT id, name, url, enabled, sort_order, agent_key, created_at
|
||||
FROM servers WHERE enabled = 1 ORDER BY sort_order ASC, created_at ASC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanServers(rows)
|
||||
}
|
||||
|
||||
func (s *Store) ListAll(ctx context.Context) ([]model.MonitoredServer, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT id, name, url, enabled, sort_order, agent_key, created_at
|
||||
FROM servers ORDER BY sort_order ASC, created_at ASC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanServers(rows)
|
||||
}
|
||||
|
||||
func scanServers(rows *sql.Rows) ([]model.MonitoredServer, error) {
|
||||
out := make([]model.MonitoredServer, 0)
|
||||
for rows.Next() {
|
||||
var m model.MonitoredServer
|
||||
var enabled int
|
||||
var created, agKey sql.NullString
|
||||
if err := rows.Scan(&m.ID, &m.Name, &m.URL, &enabled, &m.SortOrder, &agKey, &created); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.Enabled = enabled != 0
|
||||
if agKey.Valid {
|
||||
m.AgentKey = agKey.String
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339Nano, created.String); err == nil {
|
||||
m.CreatedAt = t
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) Create(ctx context.Context, in model.CreateServerInput) (*model.MonitoredServer, error) {
|
||||
name := strings.TrimSpace(in.Name)
|
||||
urlStr := normalizeURL(strings.TrimSpace(in.URL))
|
||||
if name == "" {
|
||||
return nil, errors.New("invalid name")
|
||||
}
|
||||
enabled := true
|
||||
if in.Enabled != nil {
|
||||
enabled = *in.Enabled
|
||||
}
|
||||
var maxOrder int
|
||||
_ = s.db.QueryRowContext(ctx, `SELECT COALESCE(MAX(sort_order), -1) FROM servers`).Scan(&maxOrder)
|
||||
id := uuid.NewString()
|
||||
agentKey := uuid.NewString()
|
||||
now := time.Now().UTC()
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO servers (id, name, url, enabled, sort_order, agent_key, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
id, name, urlStr, boolToInt(enabled), maxOrder+1, agentKey, now.Format(time.RFC3339Nano))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &model.MonitoredServer{
|
||||
ID: id, Name: name, URL: urlStr, Enabled: enabled, SortOrder: maxOrder + 1, AgentKey: agentKey, CreatedAt: now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Store) Update(ctx context.Context, id string, in model.UpdateServerInput) (*model.MonitoredServer, error) {
|
||||
cur, err := s.Get(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
name := cur.Name
|
||||
urlStr := cur.URL
|
||||
enabled := cur.Enabled
|
||||
if in.Name != "" {
|
||||
name = strings.TrimSpace(in.Name)
|
||||
}
|
||||
if in.URL != nil {
|
||||
urlStr = normalizeURL(strings.TrimSpace(*in.URL))
|
||||
}
|
||||
if in.Enabled != nil {
|
||||
enabled = *in.Enabled
|
||||
}
|
||||
if name == "" {
|
||||
return nil, errors.New("invalid name")
|
||||
}
|
||||
agentKey := cur.AgentKey
|
||||
if in.AgentKey != nil {
|
||||
agentKey = *in.AgentKey
|
||||
}
|
||||
_, err = s.db.ExecContext(ctx, `UPDATE servers SET name = ?, url = ?, enabled = ?, agent_key = ? WHERE id = ?`,
|
||||
name, urlStr, boolToInt(enabled), nullIfEmpty(agentKey), id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cur.Name, cur.URL, cur.Enabled, cur.AgentKey = name, urlStr, enabled, agentKey
|
||||
return cur, nil
|
||||
}
|
||||
|
||||
func (s *Store) Get(ctx context.Context, id string) (*model.MonitoredServer, error) {
|
||||
var m model.MonitoredServer
|
||||
var enabled int
|
||||
var created, agKey sql.NullString
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
SELECT id, name, url, enabled, sort_order, agent_key, created_at FROM servers WHERE id = ?`, id).
|
||||
Scan(&m.ID, &m.Name, &m.URL, &enabled, &m.SortOrder, &agKey, &created)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.Enabled = enabled != 0
|
||||
if agKey.Valid {
|
||||
m.AgentKey = agKey.String
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339Nano, created.String); err == nil {
|
||||
m.CreatedAt = t
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
// VerifyAgent checks server_id + agent_key for gRPC ingest.
|
||||
func (s *Store) VerifyAgent(ctx context.Context, id, key string) (*model.MonitoredServer, error) {
|
||||
srv, err := s.Get(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !srv.Enabled {
|
||||
return nil, errors.New("disabled")
|
||||
}
|
||||
if srv.AgentKey == "" {
|
||||
return nil, errors.New("agent_key not set")
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(srv.AgentKey), []byte(key)) != 1 {
|
||||
return nil, errors.New("invalid agent_key")
|
||||
}
|
||||
return srv, nil
|
||||
}
|
||||
|
||||
func nullIfEmpty(s string) any {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Store) Delete(ctx context.Context, id string) error {
|
||||
res, err := s.db.ExecContext(ctx, `DELETE FROM servers WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) Reorder(ctx context.Context, ids []string) error {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
for i, id := range ids {
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE servers SET sort_order = ? WHERE id = ?`, i, id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func boolToInt(b bool) int {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func normalizeURL(u string) string {
|
||||
if u == "" {
|
||||
return ""
|
||||
}
|
||||
if !strings.HasPrefix(strings.ToLower(u), "http://") && !strings.HasPrefix(strings.ToLower(u), "https://") {
|
||||
return "http://" + u
|
||||
}
|
||||
return u
|
||||
}
|
||||
39
mengyamonitor-backend-server/main.go
Normal file
39
mengyamonitor-backend-server/main.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"mengyamonitor-backend-server/internal/config"
|
||||
"mengyamonitor-backend-server/internal/grpcrun"
|
||||
"mengyamonitor-backend-server/internal/hub"
|
||||
"mengyamonitor-backend-server/internal/ingest"
|
||||
"mengyamonitor-backend-server/internal/router"
|
||||
"mengyamonitor-backend-server/internal/store"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg := config.Load()
|
||||
st, err := store.Open(cfg.DBPath)
|
||||
if err != nil {
|
||||
log.Fatalf("store: %v", err)
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
metricsHub := hub.New()
|
||||
ingestSrv := &ingest.Server{Store: st, Hub: metricsHub}
|
||||
|
||||
grpcAddr := fmt.Sprintf("%s:%s", cfg.Host, cfg.GRPCPort)
|
||||
go func() {
|
||||
log.Printf("gRPC AgentIngest listening on %s", grpcAddr)
|
||||
if err := grpcrun.Serve(grpcAddr, ingestSrv); err != nil {
|
||||
log.Fatalf("grpc: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
httpAddr := fmt.Sprintf("%s:%s", cfg.Host, cfg.Port)
|
||||
log.Printf("central HTTP/WS on http://%s", httpAddr)
|
||||
if err := router.New(cfg, st, metricsHub).Run(httpAddr); err != nil {
|
||||
log.Fatalf("http: %v", err)
|
||||
}
|
||||
}
|
||||
41
mengyamonitor-backend-server/proto/mengya/v1/agent.proto
Normal file
41
mengyamonitor-backend-server/proto/mengya/v1/agent.proto
Normal file
@@ -0,0 +1,41 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package mengya.v1;
|
||||
|
||||
option go_package = "mengyamonitor-backend-server/internal/gen/mengyav1;mengyav1";
|
||||
|
||||
// AgentIngest: 采集端出站连接,双向流上报指标。
|
||||
service AgentIngest {
|
||||
rpc Connect(stream AgentMessage) returns (stream ControlMessage);
|
||||
}
|
||||
|
||||
message AgentMessage {
|
||||
oneof payload {
|
||||
Hello hello = 1;
|
||||
Heartbeat heartbeat = 2;
|
||||
MetricsReport metrics = 3;
|
||||
}
|
||||
}
|
||||
|
||||
message Hello {
|
||||
string server_id = 1;
|
||||
string agent_key = 2;
|
||||
string hostname = 3;
|
||||
}
|
||||
|
||||
message Heartbeat {}
|
||||
|
||||
message MetricsReport {
|
||||
string payload_json = 1;
|
||||
int64 collected_at_unix_ms = 2;
|
||||
}
|
||||
|
||||
message ControlMessage {
|
||||
oneof payload {
|
||||
Ack ack = 1;
|
||||
}
|
||||
}
|
||||
|
||||
message Ack {
|
||||
string message = 1;
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
#!/bin/bash
|
||||
# 编译脚本 - 兼容旧版本 GLIBC
|
||||
|
||||
echo "开始编译 mengyamonitor-backend..."
|
||||
|
||||
# 禁用 CGO,使用纯 Go 编译(不依赖系统 C 库)
|
||||
export CGO_ENABLED=0
|
||||
|
||||
# 设置目标平台
|
||||
export GOOS=linux
|
||||
export GOARCH=amd64
|
||||
|
||||
# 编译(静态链接)
|
||||
go build -ldflags="-s -w" -o mengyamonitor-backend .
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "编译成功!"
|
||||
echo "二进制文件: mengyamonitor-backend"
|
||||
echo ""
|
||||
echo "检查文件信息:"
|
||||
file mengyamonitor-backend
|
||||
echo ""
|
||||
echo "检查依赖库:"
|
||||
ldd mengyamonitor-backend 2>/dev/null || echo "静态链接,无外部依赖"
|
||||
else
|
||||
echo "编译失败!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Linux ARM64 交叉编译脚本
|
||||
|
||||
echo "开始交叉编译 mengyamonitor-backend (Linux ARM64)..."
|
||||
|
||||
# 禁用 CGO,使用纯 Go 编译(不依赖系统 C 库)
|
||||
export CGO_ENABLED=0
|
||||
|
||||
# 设置目标平台为 Linux ARM64
|
||||
export GOOS=linux
|
||||
export GOARCH=arm64
|
||||
|
||||
# 编译(静态链接)
|
||||
go build -ldflags="-s -w" -o mengyamonitor-backend-arm64 .
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "编译成功!"
|
||||
echo "二进制文件: mengyamonitor-backend-arm64"
|
||||
echo ""
|
||||
echo "目标平台: Linux ARM64"
|
||||
echo "编译模式: 静态链接,无外部依赖"
|
||||
echo ""
|
||||
echo "检查文件信息:"
|
||||
file mengyamonitor-backend-arm64 2>/dev/null || echo "文件已生成: mengyamonitor-backend-arm64"
|
||||
echo ""
|
||||
ls -lh mengyamonitor-backend-arm64
|
||||
else
|
||||
echo "编译失败!"
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,3 +0,0 @@
|
||||
module mengyamonitor-backend
|
||||
|
||||
go 1.22
|
||||
@@ -1,56 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LatencyInfo 延迟信息
|
||||
type LatencyInfo struct {
|
||||
ClientToServer float64 `json:"clientToServer"` // 客户端到服务器延迟(ms),由前端计算
|
||||
External map[string]string `json:"external"` // 外部网站延迟
|
||||
}
|
||||
|
||||
// checkExternalLatency 检测外部网站延迟
|
||||
func checkExternalLatency(host string, timeout time.Duration) string {
|
||||
start := time.Now()
|
||||
|
||||
// 尝试 TCP 连接 80 端口
|
||||
conn, err := net.DialTimeout("tcp", host+":80", timeout)
|
||||
if err != nil {
|
||||
// 如果 80 端口失败,尝试 443 (HTTPS)
|
||||
conn, err = net.DialTimeout("tcp", host+":443", timeout)
|
||||
if err != nil {
|
||||
return "超时"
|
||||
}
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
latency := time.Since(start).Milliseconds()
|
||||
|
||||
// 检查是否超时(超过超时时间的一半就认为可能有问题)
|
||||
if latency > timeout.Milliseconds()/2 {
|
||||
return "超时"
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%d ms", latency)
|
||||
}
|
||||
|
||||
// readExternalLatencies 读取外部网站延迟
|
||||
func readExternalLatencies() map[string]string {
|
||||
latencies := make(map[string]string)
|
||||
timeout := 3 * time.Second
|
||||
|
||||
// 检测百度
|
||||
latencies["baidu.com"] = checkExternalLatency("baidu.com", timeout)
|
||||
|
||||
// 检测谷歌
|
||||
latencies["google.com"] = checkExternalLatency("google.com", timeout)
|
||||
|
||||
// 检测 GitHub
|
||||
latencies["github.com"] = checkExternalLatency("github.com", timeout)
|
||||
|
||||
return latencies
|
||||
}
|
||||
|
||||
@@ -1,265 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"runtime"
|
||||
"time"
|
||||
)
|
||||
|
||||
// envelope keeps JSON responses consistent.
|
||||
type envelope map[string]any
|
||||
|
||||
func main() {
|
||||
host := getenv("HOST", "0.0.0.0")
|
||||
port := getenv("PORT", "9292")
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/", rootHandler)
|
||||
mux.HandleFunc("/api/health", healthHandler)
|
||||
// 拆分的细粒度API端点
|
||||
mux.HandleFunc("/api/metrics/cpu", cpuMetricsHandler)
|
||||
mux.HandleFunc("/api/metrics/memory", memoryMetricsHandler)
|
||||
mux.HandleFunc("/api/metrics/storage", storageMetricsHandler)
|
||||
mux.HandleFunc("/api/metrics/gpu", gpuMetricsHandler)
|
||||
mux.HandleFunc("/api/metrics/network", networkMetricsHandler)
|
||||
mux.HandleFunc("/api/metrics/system", systemMetricsHandler)
|
||||
mux.HandleFunc("/api/metrics/docker", dockerMetricsHandler)
|
||||
mux.HandleFunc("/api/metrics/latency", latencyMetricsHandler)
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: fmt.Sprintf("%s:%s", host, port),
|
||||
Handler: loggingMiddleware(corsMiddleware(mux)),
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
log.Printf("server starting on http://%s:%s", host, port)
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("server error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func rootHandler(w http.ResponseWriter, r *http.Request) {
|
||||
respondJSON(w, http.StatusOK, envelope{
|
||||
"service": "萌芽监控面板 API",
|
||||
"version": "1.0.0",
|
||||
"endpoints": []map[string]string{
|
||||
{
|
||||
"path": "/",
|
||||
"description": "API 信息和可用端点列表",
|
||||
},
|
||||
{
|
||||
"path": "/api/health",
|
||||
"description": "健康检查",
|
||||
},
|
||||
{
|
||||
"path": "/api/metrics/cpu",
|
||||
"description": "获取 CPU 监控数据",
|
||||
},
|
||||
{
|
||||
"path": "/api/metrics/memory",
|
||||
"description": "获取内存监控数据",
|
||||
},
|
||||
{
|
||||
"path": "/api/metrics/storage",
|
||||
"description": "获取存储监控数据",
|
||||
},
|
||||
{
|
||||
"path": "/api/metrics/gpu",
|
||||
"description": "获取 GPU 监控数据",
|
||||
},
|
||||
{
|
||||
"path": "/api/metrics/network",
|
||||
"description": "获取网络接口监控数据",
|
||||
},
|
||||
{
|
||||
"path": "/api/metrics/system",
|
||||
"description": "获取系统统计信息(进程、包、磁盘速度等)",
|
||||
},
|
||||
{
|
||||
"path": "/api/metrics/docker",
|
||||
"description": "获取 Docker 容器监控数据",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func healthHandler(w http.ResponseWriter, r *http.Request) {
|
||||
respondJSON(w, http.StatusOK, envelope{
|
||||
"status": "ok",
|
||||
"timestamp": time.Now().UTC(),
|
||||
})
|
||||
}
|
||||
|
||||
// CPU监控数据
|
||||
func cpuMetricsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
cpuModel := firstMatchInFile("/proc/cpuinfo", "model name")
|
||||
cpuUsage, err := readCPUUsage()
|
||||
if err != nil {
|
||||
cpuUsage = 0
|
||||
}
|
||||
cpuTemp := readCPUTemperature()
|
||||
perCoreUsage := readPerCoreUsage()
|
||||
loads := readLoadAverages()
|
||||
|
||||
cores := runtime.NumCPU()
|
||||
respondJSON(w, http.StatusOK, envelope{
|
||||
"data": CPUMetrics{
|
||||
Model: cpuModel,
|
||||
Cores: cores,
|
||||
UsagePercent: round(cpuUsage, 2),
|
||||
LoadAverages: loads,
|
||||
Temperature: cpuTemp,
|
||||
PerCoreUsage: perCoreUsage,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 内存监控数据
|
||||
func memoryMetricsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
mem, err := readMemory()
|
||||
if err != nil {
|
||||
respondJSON(w, http.StatusInternalServerError, envelope{
|
||||
"error": "failed to read memory",
|
||||
})
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, envelope{
|
||||
"data": mem,
|
||||
})
|
||||
}
|
||||
|
||||
// 存储监控数据
|
||||
func storageMetricsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
storage, err := readAllStorage()
|
||||
if err != nil {
|
||||
respondJSON(w, http.StatusInternalServerError, envelope{
|
||||
"error": "failed to read storage",
|
||||
})
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, envelope{
|
||||
"data": storage,
|
||||
})
|
||||
}
|
||||
|
||||
// GPU监控数据
|
||||
func gpuMetricsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
gpu := readGPU()
|
||||
respondJSON(w, http.StatusOK, envelope{
|
||||
"data": gpu,
|
||||
})
|
||||
}
|
||||
|
||||
// 网络监控数据
|
||||
func networkMetricsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
network := readNetworkInterfaces()
|
||||
respondJSON(w, http.StatusOK, envelope{
|
||||
"data": network,
|
||||
})
|
||||
}
|
||||
|
||||
// 系统统计信息
|
||||
func systemMetricsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
stats := readSystemStats()
|
||||
|
||||
// 读取系统基本信息
|
||||
hostname, _ := os.Hostname()
|
||||
osInfo := readOSInfo()
|
||||
uptime := readUptime()
|
||||
|
||||
// 计算总网络速度(汇总所有接口)
|
||||
networkInterfaces := readNetworkInterfaces()
|
||||
var totalRxSpeed, totalTxSpeed float64
|
||||
for _, iface := range networkInterfaces {
|
||||
totalRxSpeed += iface.RxSpeed // bytes/s
|
||||
totalTxSpeed += iface.TxSpeed // bytes/s
|
||||
}
|
||||
// 转换为 MB/s
|
||||
stats.NetworkRxSpeed = round(totalRxSpeed/1024/1024, 2)
|
||||
stats.NetworkTxSpeed = round(totalTxSpeed/1024/1024, 2)
|
||||
|
||||
respondJSON(w, http.StatusOK, envelope{
|
||||
"data": map[string]interface{}{
|
||||
"hostname": hostname,
|
||||
"os": osInfo,
|
||||
"uptimeSeconds": uptime,
|
||||
"processCount": stats.ProcessCount,
|
||||
"packageCount": stats.PackageCount,
|
||||
"packageManager": stats.PackageManager,
|
||||
"temperature": stats.Temperature,
|
||||
"diskReadSpeed": stats.DiskReadSpeed,
|
||||
"diskWriteSpeed": stats.DiskWriteSpeed,
|
||||
"networkRxSpeed": stats.NetworkRxSpeed,
|
||||
"networkTxSpeed": stats.NetworkTxSpeed,
|
||||
"topProcesses": stats.TopProcesses,
|
||||
"systemLogs": stats.SystemLogs,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Docker监控数据
|
||||
func dockerMetricsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
docker := readDockerStats()
|
||||
respondJSON(w, http.StatusOK, envelope{
|
||||
"data": docker,
|
||||
})
|
||||
}
|
||||
|
||||
// 延迟监控数据
|
||||
func latencyMetricsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// 记录请求开始时间,用于计算客户端到服务器的延迟
|
||||
startTime := time.Now()
|
||||
|
||||
// 读取外部网站延迟
|
||||
externalLatencies := readExternalLatencies()
|
||||
|
||||
// 计算服务器处理时间(这个可以作为参考,实际客户端延迟由前端计算)
|
||||
serverProcessTime := time.Since(startTime).Milliseconds()
|
||||
|
||||
respondJSON(w, http.StatusOK, envelope{
|
||||
"data": map[string]interface{}{
|
||||
"serverProcessTime": serverProcessTime, // 服务器处理时间(参考)
|
||||
"external": externalLatencies,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func respondJSON(w http.ResponseWriter, status int, body envelope) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if err := json.NewEncoder(w).Encode(body); err != nil {
|
||||
log.Printf("write json error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func getenv(key, fallback string) string {
|
||||
if v, ok := os.LookupEnv(key); ok && v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func loggingMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
next.ServeHTTP(w, r)
|
||||
log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start))
|
||||
})
|
||||
}
|
||||
|
||||
func corsMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// readMemory 读取内存信息
|
||||
func readMemory() (MemoryMetrics, error) {
|
||||
totals := map[string]uint64{}
|
||||
f, err := os.Open("/proc/meminfo")
|
||||
if err != nil {
|
||||
return MemoryMetrics{}, err
|
||||
}
|
||||
defer f.Close()
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
parts := strings.Split(line, ":")
|
||||
if len(parts) < 2 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(parts[0])
|
||||
fields := strings.Fields(strings.TrimSpace(parts[1]))
|
||||
if len(fields) == 0 {
|
||||
continue
|
||||
}
|
||||
value, err := strconv.ParseUint(fields[0], 10, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
totals[key] = value * 1024 // kB to bytes
|
||||
}
|
||||
total := totals["MemTotal"]
|
||||
|
||||
// 优先使用 MemAvailable(Linux 3.14+),如果没有则计算
|
||||
var free uint64
|
||||
if available, ok := totals["MemAvailable"]; ok && available > 0 {
|
||||
free = available
|
||||
} else {
|
||||
// 回退到 MemFree + Buffers + Cached(适用于较老的系统)
|
||||
free = totals["MemFree"]
|
||||
if buffers, ok := totals["Buffers"]; ok {
|
||||
free += buffers
|
||||
}
|
||||
if cached, ok := totals["Cached"]; ok {
|
||||
free += cached
|
||||
}
|
||||
}
|
||||
|
||||
used := total - free
|
||||
usedPercent := 0.0
|
||||
if total > 0 {
|
||||
usedPercent = (float64(used) / float64(total)) * 100
|
||||
}
|
||||
return MemoryMetrics{
|
||||
TotalBytes: total,
|
||||
UsedBytes: used,
|
||||
FreeBytes: free,
|
||||
UsedPercent: round(usedPercent, 2),
|
||||
}, nil
|
||||
}
|
||||
4
mengyamonitor-frontend/.env.production
Normal file
4
mengyamonitor-frontend/.env.production
Normal file
@@ -0,0 +1,4 @@
|
||||
# 公网:用户通过 https 打开前端时,API 须为浏览器可访问的 https 地址(经 Nginx 反代的中心)
|
||||
VITE_CENTRAL_API_URL=https://monitor.api.shumengya.top
|
||||
#
|
||||
# 若静态站仅内网 http 部署,请改为 http://内网中心IP:9393 后再 npm run build(勿与混合内容冲突)
|
||||
@@ -57,8 +57,8 @@ npm run preview
|
||||
## 项目结构
|
||||
```
|
||||
src/
|
||||
├── api/ # API 调用
|
||||
│ └── monitor.ts # 监控 API
|
||||
├── api/ # API 调用(中心 REST、管理接口)
|
||||
│ └── central.ts
|
||||
├── components/ # React 组件
|
||||
│ ├── ServerCard/ # 服务器卡片组件
|
||||
│ └── ServerDetail/ # 服务器详情弹窗
|
||||
@@ -85,11 +85,8 @@ server: {
|
||||
}
|
||||
```
|
||||
|
||||
### 修改轮询间隔
|
||||
编辑 `src/App.tsx` 文件中的 `useServerMonitor` 第二个参数:
|
||||
```typescript
|
||||
const statuses = useServerMonitor(servers, 2000); // 2000ms = 2秒
|
||||
```
|
||||
### 实时指标
|
||||
看板通过 `useServerMonitor` 连接中心 WebSocket(`/api/ws/monitor`),无 HTTP 轮询。
|
||||
|
||||
## 部署
|
||||
|
||||
|
||||
1
mengyamonitor-frontend/dev-dist/registerSW.js
Normal file
1
mengyamonitor-frontend/dev-dist/registerSW.js
Normal file
@@ -0,0 +1 @@
|
||||
if('serviceWorker' in navigator) navigator.serviceWorker.register('/dev-sw.js?dev-sw', { scope: '/', type: 'classic' })
|
||||
103
mengyamonitor-frontend/dev-dist/sw.js
Normal file
103
mengyamonitor-frontend/dev-dist/sw.js
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Copyright 2018 Google Inc. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// If the loader is already loaded, just stop.
|
||||
if (!self.define) {
|
||||
let registry = {};
|
||||
|
||||
// Used for `eval` and `importScripts` where we can't get script URL by other means.
|
||||
// In both cases, it's safe to use a global var because those functions are synchronous.
|
||||
let nextDefineUri;
|
||||
|
||||
const singleRequire = (uri, parentUri) => {
|
||||
uri = new URL(uri + ".js", parentUri).href;
|
||||
return registry[uri] || (
|
||||
|
||||
new Promise(resolve => {
|
||||
if ("document" in self) {
|
||||
const script = document.createElement("script");
|
||||
script.src = uri;
|
||||
script.onload = resolve;
|
||||
document.head.appendChild(script);
|
||||
} else {
|
||||
nextDefineUri = uri;
|
||||
importScripts(uri);
|
||||
resolve();
|
||||
}
|
||||
})
|
||||
|
||||
.then(() => {
|
||||
let promise = registry[uri];
|
||||
if (!promise) {
|
||||
throw new Error(`Module ${uri} didn’t register its module`);
|
||||
}
|
||||
return promise;
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
self.define = (depsNames, factory) => {
|
||||
const uri = nextDefineUri || ("document" in self ? document.currentScript.src : "") || location.href;
|
||||
if (registry[uri]) {
|
||||
// Module is already loading or loaded.
|
||||
return;
|
||||
}
|
||||
let exports = {};
|
||||
const require = depUri => singleRequire(depUri, uri);
|
||||
const specialDeps = {
|
||||
module: { uri },
|
||||
exports,
|
||||
require
|
||||
};
|
||||
registry[uri] = Promise.all(depsNames.map(
|
||||
depName => specialDeps[depName] || require(depName)
|
||||
)).then(deps => {
|
||||
factory(...deps);
|
||||
return exports;
|
||||
});
|
||||
};
|
||||
}
|
||||
define(['./workbox-6fc00345'], (function (workbox) { 'use strict';
|
||||
|
||||
self.skipWaiting();
|
||||
workbox.clientsClaim();
|
||||
|
||||
/**
|
||||
* The precacheAndRoute() method efficiently caches and responds to
|
||||
* requests for URLs in the manifest.
|
||||
* See https://goo.gl/S9QRab
|
||||
*/
|
||||
workbox.precacheAndRoute([{
|
||||
"url": "registerSW.js",
|
||||
"revision": "3ca0b8505b4bec776b69afdba2768812"
|
||||
}, {
|
||||
"url": "index.html",
|
||||
"revision": "0.ds91o44ltm"
|
||||
}], {});
|
||||
workbox.cleanupOutdatedCaches();
|
||||
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
|
||||
allowlist: [/^\/$/],
|
||||
denylist: [/^\/central-api(?:\/|$)/]
|
||||
}));
|
||||
workbox.registerRoute(/^https:\/\/.*\/api\//i, new workbox.NetworkFirst({
|
||||
"cacheName": "api-cache",
|
||||
"networkTimeoutSeconds": 10,
|
||||
plugins: [new workbox.ExpirationPlugin({
|
||||
maxEntries: 32,
|
||||
maxAgeSeconds: 300
|
||||
}), new workbox.CacheableResponsePlugin({
|
||||
statuses: [0, 200]
|
||||
})]
|
||||
}), 'GET');
|
||||
|
||||
}));
|
||||
4705
mengyamonitor-frontend/dev-dist/workbox-6fc00345.js
Normal file
4705
mengyamonitor-frontend/dev-dist/workbox-6fc00345.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -2,11 +2,12 @@
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
||||
<link rel="apple-touch-icon" href="/logo.svg" />
|
||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||
<link rel="apple-touch-icon" href="/logo2.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#1a1a2e" />
|
||||
<meta name="description" content="服务器监控面板 - 萌芽监控" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="萌芽监控" />
|
||||
|
||||
62
mengyamonitor-frontend/package-lock.json
generated
62
mengyamonitor-frontend/package-lock.json
generated
@@ -8,9 +8,6 @@
|
||||
"name": "mengyamonitor-frontend",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
},
|
||||
@@ -1586,59 +1583,6 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/accessibility": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/core": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/accessibility": "^3.1.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/sortable": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
|
||||
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@dnd-kit/core": "^6.3.0",
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/utilities": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
||||
@@ -6606,12 +6550,6 @@
|
||||
"typescript": ">=4.8.4"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||
|
||||
@@ -10,9 +10,6 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
},
|
||||
|
||||
BIN
mengyamonitor-frontend/public/favicon.ico
Normal file
BIN
mengyamonitor-frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 87 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 3.4 MiB After Width: | Height: | Size: 3.8 MiB |
@@ -1,16 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="none">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#1a1a2e"/>
|
||||
<stop offset="100%" style="stop-color:#16213e"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="leaf" x1="0%" y1="100%" x2="100%" y2="0%">
|
||||
<stop offset="0%" style="stop-color:#4ecca3"/>
|
||||
<stop offset="100%" style="stop-color:#2d8f7a"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="512" height="512" rx="96" fill="url(#bg)"/>
|
||||
<path fill="url(#leaf)" d="M256 96c-20 80-80 160-80 240 0 53 35.8 96 80 96s80-43 80-96c0-80-60-160-80-240z"/>
|
||||
<path fill="url(#leaf)" opacity=".9" d="M256 96c20 80 60 160 60 240 0 53-35.8 96-80 96s-80-43-80-96c0-80 40-160 60-240z"/>
|
||||
<ellipse cx="256" cy="336" rx="48" ry="24" fill="url(#leaf)" opacity=".7"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 867 B |
@@ -1,8 +1,9 @@
|
||||
.app {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
padding: 0.65rem 0.75rem 0.85rem;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
@@ -10,12 +11,14 @@
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.5rem 1rem;
|
||||
margin-bottom: 0.65rem;
|
||||
padding: 0.4rem 0.85rem;
|
||||
background: var(--surface-color);
|
||||
border-radius: var(--radius-md);
|
||||
backdrop-filter: var(--backdrop-blur);
|
||||
-webkit-backdrop-filter: var(--backdrop-blur);
|
||||
border-radius: var(--radius-glass);
|
||||
box-shadow: var(--shadow-sm);
|
||||
border: 1px solid var(--border-color);
|
||||
border: 1px solid var(--glass-border);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
@@ -27,27 +30,29 @@
|
||||
|
||||
.app-header h1 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-size: 1.2rem;
|
||||
color: var(--text-main);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
font-weight: 600;
|
||||
transform: translateY(-3px);
|
||||
gap: 0.55rem;
|
||||
font-weight: 700;
|
||||
transform: translateY(-1px);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.app-logo {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 6px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--radius-md);
|
||||
display: inline-block;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
background: var(--surface-color);
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
color: var(--text-main);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--glass-border);
|
||||
padding: 0.45rem;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--radius-md);
|
||||
@@ -183,18 +188,20 @@
|
||||
|
||||
.server-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
||||
gap: 1.5rem;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
grid-column: 1 / -1;
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
padding: 2rem 1.25rem;
|
||||
background: var(--surface-color);
|
||||
border-radius: var(--radius-lg);
|
||||
backdrop-filter: var(--backdrop-blur);
|
||||
-webkit-backdrop-filter: var(--backdrop-blur);
|
||||
border-radius: var(--radius-glass);
|
||||
box-shadow: var(--shadow-sm);
|
||||
border: 1px dashed var(--border-color);
|
||||
border: 1px dashed var(--glass-border);
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
@@ -208,6 +215,95 @@
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.logo-hit {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.logo-hit:focus-visible {
|
||||
outline: 2px solid var(--primary-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.token-gate-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1100;
|
||||
background: rgba(10, 12, 22, 0.75);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.token-gate-dialog {
|
||||
width: min(400px, 100%);
|
||||
background: var(--surface-color);
|
||||
backdrop-filter: var(--backdrop-blur);
|
||||
-webkit-backdrop-filter: var(--backdrop-blur);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-glass);
|
||||
padding: 1.1rem 1.25rem;
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.token-gate-dialog h3 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.125rem;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.token-gate-hint {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.token-gate-input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 0.65rem 0.75rem;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-color);
|
||||
color: var(--text-main);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.token-gate-error {
|
||||
color: #f87171;
|
||||
font-size: 0.8125rem;
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
.token-gate-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
background: var(--surface-color);
|
||||
color: var(--text-main);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 0.65rem 1.25rem;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-cancel:hover {
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.app {
|
||||
padding: 1rem;
|
||||
|
||||
@@ -1,247 +1,210 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import type { ServerConfig } from './types';
|
||||
import { loadServers, saveServers, removeServer, exportServersToClipboard, importServersFromClipboard } from './utils/storage';
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useServerMonitor } from './hooks/useServerMonitor';
|
||||
import { usePublicServers } from './hooks/usePublicServers';
|
||||
import { ServerCard } from './components/ServerCard/ServerCard';
|
||||
import { ServerDetail } from './components/ServerDetail/ServerDetail';
|
||||
import { AdminPanel } from './components/AdminPanel/AdminPanel';
|
||||
import { RandomBackdrop } from './components/RandomBackdrop/RandomBackdrop';
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import type { DragEndEvent } from '@dnd-kit/core';
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
rectSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
verifyAdminToken,
|
||||
ADMIN_TOKEN_STORAGE,
|
||||
} from './api/central';
|
||||
import './App.css';
|
||||
|
||||
const formatServerUrl = (url: string) => {
|
||||
let formatted = url.trim();
|
||||
|
||||
// Fix common typo .op -> .top based on user requirement
|
||||
if (formatted.endsWith('.op')) {
|
||||
formatted = formatted.slice(0, -3) + '.top';
|
||||
}
|
||||
|
||||
if (formatted && !/^https?:\/\//i.test(formatted)) {
|
||||
return `http://${formatted}`;
|
||||
}
|
||||
return formatted;
|
||||
};
|
||||
|
||||
function App() {
|
||||
const [servers, setServers] = useState<ServerConfig[]>([]);
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const { servers, loading, error, reload } = usePublicServers(30000);
|
||||
const [selectedServerId, setSelectedServerId] = useState<string | null>(null);
|
||||
const [newServerForm, setNewServerForm] = useState({ name: '', url: '' });
|
||||
const [showHeader, setShowHeader] = useState(true);
|
||||
|
||||
const statuses = useServerMonitor(servers, 2000);
|
||||
const [showTokenGate, setShowTokenGate] = useState(false);
|
||||
const [tokenInput, setTokenInput] = useState('');
|
||||
const [tokenError, setTokenError] = useState<string | null>(null);
|
||||
const [adminOpen, setAdminOpen] = useState(false);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
);
|
||||
const logoClickCount = useRef(0);
|
||||
const logoClickReset = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loaded = loadServers();
|
||||
setServers(loaded);
|
||||
const statuses = useServerMonitor();
|
||||
|
||||
const handleLogoClick = useCallback(() => {
|
||||
if (logoClickReset.current) {
|
||||
clearTimeout(logoClickReset.current);
|
||||
}
|
||||
logoClickCount.current += 1;
|
||||
if (logoClickCount.current >= 5) {
|
||||
logoClickCount.current = 0;
|
||||
const saved = sessionStorage.getItem(ADMIN_TOKEN_STORAGE);
|
||||
if (saved) {
|
||||
setAdminOpen(true);
|
||||
} else {
|
||||
setShowTokenGate(true);
|
||||
setTokenInput('');
|
||||
setTokenError(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
logoClickReset.current = setTimeout(() => {
|
||||
logoClickCount.current = 0;
|
||||
}, 2000);
|
||||
}, []);
|
||||
|
||||
const handleUrlBlur = () => {
|
||||
const formatted = formatServerUrl(newServerForm.url);
|
||||
if (formatted !== newServerForm.url) {
|
||||
setNewServerForm(prev => ({ ...prev, url: formatted }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddServer = () => {
|
||||
if (!newServerForm.name || !newServerForm.url) {
|
||||
alert('请填写服务器名称和地址');
|
||||
const submitToken = async () => {
|
||||
const ok = await verifyAdminToken(tokenInput.trim());
|
||||
if (!ok) {
|
||||
setTokenError('口令错误');
|
||||
return;
|
||||
}
|
||||
sessionStorage.setItem(ADMIN_TOKEN_STORAGE, tokenInput.trim());
|
||||
setShowTokenGate(false);
|
||||
setTokenError(null);
|
||||
setAdminOpen(true);
|
||||
};
|
||||
|
||||
const formattedUrl = formatServerUrl(newServerForm.url);
|
||||
const closeAdmin = () => {
|
||||
setAdminOpen(false);
|
||||
};
|
||||
|
||||
const newServer: ServerConfig = {
|
||||
id: Date.now().toString(),
|
||||
name: newServerForm.name,
|
||||
url: formattedUrl,
|
||||
enabled: true,
|
||||
const invalidateAdminSession = () => {
|
||||
sessionStorage.removeItem(ADMIN_TOKEN_STORAGE);
|
||||
setAdminOpen(false);
|
||||
alert('管理凭证已失效,请重新验证');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (logoClickReset.current) clearTimeout(logoClickReset.current);
|
||||
};
|
||||
|
||||
const updated = [...servers, newServer];
|
||||
setServers(updated);
|
||||
saveServers(updated);
|
||||
setNewServerForm({ name: '', url: '' });
|
||||
setShowAddForm(false);
|
||||
};
|
||||
|
||||
const handleRemoveServer = (serverId: string) => {
|
||||
if (confirm('确定要移除这个服务器吗?')) {
|
||||
const updated = servers.filter(s => s.id !== serverId);
|
||||
setServers(updated);
|
||||
removeServer(serverId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleShowDetail = (serverId: string) => {
|
||||
setSelectedServerId(serverId);
|
||||
};
|
||||
|
||||
const handleExportServers = async () => {
|
||||
try {
|
||||
await exportServersToClipboard();
|
||||
alert('服务器配置已复制到剪贴板!');
|
||||
} catch (error) {
|
||||
alert(error instanceof Error ? error.message : '导出失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportServers = async () => {
|
||||
if (!confirm('导入服务器配置将添加到现有服务器列表中,是否继续?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const importedServers = await importServersFromClipboard();
|
||||
const updated = [...servers, ...importedServers];
|
||||
setServers(updated);
|
||||
saveServers(updated);
|
||||
alert(`成功导入 ${importedServers.length} 个服务器配置!`);
|
||||
} catch (error) {
|
||||
alert(error instanceof Error ? error.message : '导入失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
if (over && active.id !== over.id) {
|
||||
setServers((items) => {
|
||||
const oldIndex = items.findIndex((item) => item.id === active.id);
|
||||
const newIndex = items.findIndex((item) => item.id === over.id);
|
||||
const newOrder = arrayMove(items, oldIndex, newIndex);
|
||||
saveServers(newOrder);
|
||||
return newOrder;
|
||||
});
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const selectedStatus = selectedServerId ? statuses[selectedServerId] : null;
|
||||
const selectedServer = servers.find(s => s.id === selectedServerId);
|
||||
const selectedServer = servers.find((s) => s.id === selectedServerId);
|
||||
|
||||
const adminToken = sessionStorage.getItem(ADMIN_TOKEN_STORAGE) || '';
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<RandomBackdrop />
|
||||
{showTokenGate && (
|
||||
<div className="token-gate-overlay" role="dialog" aria-modal="true">
|
||||
<div className="token-gate-dialog">
|
||||
<h3>管理验证</h3>
|
||||
<p className="token-gate-hint">请输入管理口令(内网默认与中心服务 ADMIN_TOKEN 一致)。</p>
|
||||
<input
|
||||
type="password"
|
||||
className="token-gate-input"
|
||||
placeholder="Token"
|
||||
value={tokenInput}
|
||||
onChange={(e) => {
|
||||
setTokenInput(e.target.value);
|
||||
setTokenError(null);
|
||||
}}
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') void submitToken();
|
||||
}}
|
||||
/>
|
||||
{tokenError && <p className="token-gate-error">{tokenError}</p>}
|
||||
<div className="token-gate-actions">
|
||||
<button type="button" className="btn-submit" onClick={() => void submitToken()}>
|
||||
进入后台
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-cancel"
|
||||
onClick={() => {
|
||||
setShowTokenGate(false);
|
||||
setTokenError(null);
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{adminOpen && adminToken && (
|
||||
<AdminPanel
|
||||
token={adminToken}
|
||||
onClose={closeAdmin}
|
||||
onSessionInvalid={invalidateAdminSession}
|
||||
onSaved={() => void reload()}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!showHeader && (
|
||||
<button
|
||||
className="btn-show-header"
|
||||
onClick={() => setShowHeader(true)}
|
||||
title="显示导航栏"
|
||||
type="button"
|
||||
>
|
||||
👁️
|
||||
</button>
|
||||
)}
|
||||
|
||||
|
||||
{showHeader && (
|
||||
<header className="app-header">
|
||||
<h1>
|
||||
<img className="app-logo" src="/logo.svg" alt="萌芽监控面板" />
|
||||
<button
|
||||
type="button"
|
||||
className="logo-hit"
|
||||
onClick={handleLogoClick}
|
||||
title="萌芽监控"
|
||||
aria-label="萌芽监控"
|
||||
>
|
||||
<img className="app-logo" src="/logo.png" alt="" />
|
||||
</button>
|
||||
萌芽监控面板
|
||||
</h1>
|
||||
<div className="header-actions">
|
||||
<button
|
||||
className="btn-icon"
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon"
|
||||
onClick={() => setShowHeader(false)}
|
||||
title="隐藏导航栏"
|
||||
>
|
||||
👁️
|
||||
</button>
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={handleExportServers}
|
||||
title="导出服务器配置到剪贴板"
|
||||
>
|
||||
📤
|
||||
</button>
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={handleImportServers}
|
||||
title="从剪贴板导入服务器配置"
|
||||
>
|
||||
📥
|
||||
</button>
|
||||
<button className="btn-add" onClick={() => setShowAddForm(!showAddForm)} title={showAddForm ? '取消' : '添加服务器'}>
|
||||
{showAddForm ? '×' : '+'}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
)}
|
||||
|
||||
{showAddForm && (
|
||||
<div className="add-form">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="服务器名称"
|
||||
value={newServerForm.name}
|
||||
onChange={(e) => setNewServerForm({ ...newServerForm, name: e.target.value })}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="服务器地址 (例如: http://192.168.1.100:9292)"
|
||||
value={newServerForm.url}
|
||||
onChange={(e) => setNewServerForm({ ...newServerForm, url: e.target.value })}
|
||||
onBlur={handleUrlBlur}
|
||||
/>
|
||||
<button className="btn-submit" onClick={handleAddServer}>
|
||||
添加
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)}
|
||||
|
||||
<main className="server-grid">
|
||||
{servers.length === 0 ? (
|
||||
{loading ? (
|
||||
<div className="empty-state">
|
||||
<p>还没有添加任何服务器</p>
|
||||
<p className="hint">点击右上角的"添加服务器"按钮开始使用</p>
|
||||
<p>正在从中心服务加载节点列表…</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="empty-state">
|
||||
<p>无法加载监控列表</p>
|
||||
<p className="hint">{error}</p>
|
||||
<p className="hint">请确认 mengyamonitor-backend-server 已启动,且 VITE_CENTRAL_API_URL 或开发代理配置正确。</p>
|
||||
</div>
|
||||
) : servers.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<p>暂无已启用的监控节点</p>
|
||||
<p className="hint">请联系管理员在后台添加采集端地址。</p>
|
||||
</div>
|
||||
) : (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext items={servers.map((s) => s.id)} strategy={rectSortingStrategy}>
|
||||
{servers.map((server) => {
|
||||
const status = statuses[server.id];
|
||||
// Calculate storage usage (max of all mounts)
|
||||
const storageUsage = status?.metrics?.storage?.reduce((max, s) => Math.max(max, s.usedPercent), 0) || 0;
|
||||
|
||||
return (
|
||||
<ServerCard
|
||||
key={server.id}
|
||||
server={server}
|
||||
online={status?.online || false}
|
||||
metrics={status?.metrics}
|
||||
cpuUsage={status?.metrics?.cpu.usagePercent || 0}
|
||||
memoryUsage={status?.metrics?.memory.usedPercent || 0}
|
||||
storageUsage={storageUsage} // Pass max storage usage
|
||||
uptime={status?.metrics?.uptimeSeconds}
|
||||
onDetail={handleShowDetail}
|
||||
onRemove={handleRemoveServer}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
servers.map((server) => {
|
||||
const status = statuses[server.id];
|
||||
const storageUsage =
|
||||
status?.metrics?.storage?.reduce((max, s) => Math.max(max, s.usedPercent), 0) || 0;
|
||||
return (
|
||||
<ServerCard
|
||||
key={server.id}
|
||||
server={server}
|
||||
online={status?.online || false}
|
||||
metrics={status?.metrics}
|
||||
cpuUsage={status?.metrics?.cpu.usagePercent || 0}
|
||||
memoryUsage={status?.metrics?.memory.usedPercent || 0}
|
||||
storageUsage={storageUsage}
|
||||
uptime={status?.metrics?.uptimeSeconds}
|
||||
onDetail={() => setSelectedServerId(server.id)}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</main>
|
||||
|
||||
|
||||
109
mengyamonitor-frontend/src/api/central.ts
Normal file
109
mengyamonitor-frontend/src/api/central.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/** Central dashboard API base (no trailing slash). Dev default uses Vite proxy. */
|
||||
export const centralApiBase = (): string => {
|
||||
const v = import.meta.env.VITE_CENTRAL_API_URL as string | undefined;
|
||||
return (v && v.trim()) || '/central-api';
|
||||
};
|
||||
|
||||
export async function fetchPublicServers(): Promise<
|
||||
{ id: string; name: string; url: string; enabled: boolean }[]
|
||||
> {
|
||||
const base = centralApiBase();
|
||||
const res = await fetch(`${base}/api/servers`);
|
||||
if (!res.ok) {
|
||||
throw new Error(`无法加载服务器列表 (${res.status})`);
|
||||
}
|
||||
const body = await res.json();
|
||||
const data = body.data;
|
||||
if (!Array.isArray(data)) {
|
||||
throw new Error('无效的服务器列表响应');
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function verifyAdminToken(token: string): Promise<boolean> {
|
||||
const base = centralApiBase();
|
||||
const res = await fetch(`${base}/api/admin/auth`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token }),
|
||||
});
|
||||
return res.ok;
|
||||
}
|
||||
|
||||
const adminHeaders = (token: string, json = false): HeadersInit => {
|
||||
const h: Record<string, string> = { 'X-Admin-Token': token };
|
||||
if (json) h['Content-Type'] = 'application/json';
|
||||
return h;
|
||||
};
|
||||
|
||||
export async function adminListServers(token: string) {
|
||||
const base = centralApiBase();
|
||||
const res = await fetch(`${base}/api/admin/servers`, { headers: adminHeaders(token) });
|
||||
if (res.status === 401) throw new Error('UNAUTHORIZED');
|
||||
if (!res.ok) throw new Error(`加载失败 (${res.status})`);
|
||||
const body = await res.json();
|
||||
return body.data as {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
enabled: boolean;
|
||||
sortOrder: number;
|
||||
agentKey?: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export async function adminCreateServer(
|
||||
token: string,
|
||||
payload: { name: string; url?: string; enabled: boolean }
|
||||
) {
|
||||
const base = centralApiBase();
|
||||
const res = await fetch(`${base}/api/admin/servers`, {
|
||||
method: 'POST',
|
||||
headers: adminHeaders(token, true),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error((err as { error?: string }).error || `创建失败 (${res.status})`);
|
||||
}
|
||||
return (await res.json()).data;
|
||||
}
|
||||
|
||||
export async function adminUpdateServer(
|
||||
token: string,
|
||||
id: string,
|
||||
payload: { name: string; url?: string; enabled: boolean; agentKey?: string }
|
||||
) {
|
||||
const base = centralApiBase();
|
||||
const res = await fetch(`${base}/api/admin/servers/${encodeURIComponent(id)}`, {
|
||||
method: 'PUT',
|
||||
headers: adminHeaders(token, true),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error((err as { error?: string }).error || `更新失败 (${res.status})`);
|
||||
}
|
||||
return (await res.json()).data;
|
||||
}
|
||||
|
||||
export async function adminDeleteServer(token: string, id: string) {
|
||||
const base = centralApiBase();
|
||||
const res = await fetch(`${base}/api/admin/servers/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE',
|
||||
headers: adminHeaders(token, false),
|
||||
});
|
||||
if (!res.ok) throw new Error(`删除失败 (${res.status})`);
|
||||
}
|
||||
|
||||
export async function adminReorderServers(token: string, ids: string[]) {
|
||||
const base = centralApiBase();
|
||||
const res = await fetch(`${base}/api/admin/servers/reorder`, {
|
||||
method: 'PUT',
|
||||
headers: adminHeaders(token, true),
|
||||
body: JSON.stringify({ ids }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`排序失败 (${res.status})`);
|
||||
}
|
||||
|
||||
export const ADMIN_TOKEN_STORAGE = 'mengya_monitor_admin_token';
|
||||
@@ -1,101 +0,0 @@
|
||||
import type { ServerMetrics } from '../types';
|
||||
|
||||
export const fetchServerMetrics = async (serverUrl: string): Promise<ServerMetrics> => {
|
||||
// 测量客户端到服务器的延迟(使用健康检查端点)
|
||||
const clientToServerLatency = await measureLatency(`${serverUrl}/api/health`);
|
||||
|
||||
// 并行请求各个端点
|
||||
const endpoints = [
|
||||
'/api/metrics/cpu',
|
||||
'/api/metrics/memory',
|
||||
'/api/metrics/storage',
|
||||
'/api/metrics/gpu',
|
||||
'/api/metrics/network',
|
||||
'/api/metrics/system',
|
||||
'/api/metrics/docker',
|
||||
'/api/metrics/latency',
|
||||
];
|
||||
|
||||
const results = await Promise.all(
|
||||
endpoints.map(endpoint =>
|
||||
fetch(`${serverUrl}${endpoint}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
.then(res => {
|
||||
if (!res.ok) throw new Error(`Failed to fetch ${endpoint}`);
|
||||
return res.json();
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(`Error fetching ${endpoint}:`, err);
|
||||
return null;
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
// 合并所有结果
|
||||
const [cpuRes, memRes, storageRes, gpuRes, networkRes, systemRes, dockerRes, latencyRes] = results;
|
||||
|
||||
const system = systemRes?.data || {};
|
||||
const docker = dockerRes?.data || {};
|
||||
const latency = latencyRes?.data || {};
|
||||
|
||||
// 将 docker 数据合并到 system.dockerStats
|
||||
return {
|
||||
hostname: system.hostname || 'Unknown',
|
||||
timestamp: new Date().toISOString(),
|
||||
cpu: cpuRes?.data || {},
|
||||
memory: memRes?.data || {},
|
||||
storage: storageRes?.data || [],
|
||||
gpu: gpuRes?.data || [],
|
||||
network: networkRes?.data || [],
|
||||
system: {
|
||||
...system,
|
||||
dockerStats: docker
|
||||
},
|
||||
os: system.os || { kernel: '', distro: '', architecture: '' },
|
||||
uptimeSeconds: system.uptimeSeconds || 0,
|
||||
latency: {
|
||||
clientToServer: clientToServerLatency,
|
||||
external: latency.external || {},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// 测量延迟
|
||||
async function measureLatency(url: string): Promise<number> {
|
||||
try {
|
||||
const startTime = performance.now();
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
const endTime = performance.now();
|
||||
|
||||
if (response.ok) {
|
||||
return Math.round(endTime - startTime);
|
||||
}
|
||||
return -1; // 表示失败
|
||||
} catch {
|
||||
return -1; // 表示超时或失败
|
||||
}
|
||||
}
|
||||
|
||||
export const checkServerHealth = async (serverUrl: string): Promise<boolean> => {
|
||||
try {
|
||||
const url = `${serverUrl}/api/health`;
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
202
mengyamonitor-frontend/src/components/AdminPanel/AdminPanel.css
Normal file
202
mengyamonitor-frontend/src/components/AdminPanel/AdminPanel.css
Normal file
@@ -0,0 +1,202 @@
|
||||
.admin-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(10, 12, 22, 0.72);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 2rem 1rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.admin-panel {
|
||||
width: min(1100px, 100%);
|
||||
background: var(--surface-color);
|
||||
backdrop-filter: var(--backdrop-blur);
|
||||
-webkit-backdrop-filter: var(--backdrop-blur);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-glass);
|
||||
box-shadow: var(--shadow-lg);
|
||||
padding: 1rem 1.15rem 1.25rem;
|
||||
}
|
||||
|
||||
.admin-panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.admin-panel-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.admin-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 1.75rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.admin-close:hover {
|
||||
background: var(--bg-color);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.admin-hint {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.admin-loading {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.admin-error {
|
||||
color: #f87171;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.admin-table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.admin-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.admin-table th,
|
||||
.admin-table td {
|
||||
text-align: left;
|
||||
padding: 0.5rem 0.6rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.admin-table th {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.admin-table code {
|
||||
font-size: 0.8rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.admin-table input[type='text'] {
|
||||
width: 100%;
|
||||
max-width: 280px;
|
||||
padding: 0.35rem 0.5rem;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-color);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.admin-url-input {
|
||||
max-width: 360px !important;
|
||||
}
|
||||
|
||||
.admin-order-btns {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.admin-order-btns button {
|
||||
padding: 0.2rem 0.45rem;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-color);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.admin-order-btns button:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.admin-actions {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-actions .admin-btn + .admin-btn {
|
||||
margin-left: 0.35rem;
|
||||
}
|
||||
|
||||
.admin-btn {
|
||||
padding: 0.35rem 0.65rem;
|
||||
font-size: 0.8rem;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--surface-color);
|
||||
color: var(--text-main);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.admin-btn:hover {
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.admin-btn.danger {
|
||||
border-color: #b91c1c;
|
||||
color: #fecaca;
|
||||
}
|
||||
|
||||
.admin-btn.danger:hover {
|
||||
background: rgba(185, 28, 28, 0.2);
|
||||
}
|
||||
|
||||
.admin-server-id-cell,
|
||||
.admin-agent-key-cell {
|
||||
vertical-align: top;
|
||||
max-width: 22rem;
|
||||
}
|
||||
|
||||
.admin-agent-key-cell .admin-key {
|
||||
display: block;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.admin-server-id {
|
||||
display: block;
|
||||
font-size: 0.68rem;
|
||||
line-height: 1.35;
|
||||
word-break: break-all;
|
||||
margin-bottom: 0.35rem;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.admin-btn-ghost {
|
||||
padding: 0.2rem 0.45rem;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.admin-key {
|
||||
font-size: 0.75rem;
|
||||
word-break: break-all;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.admin-muted {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.admin-empty {
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
padding: 1.5rem !important;
|
||||
}
|
||||
279
mengyamonitor-frontend/src/components/AdminPanel/AdminPanel.tsx
Normal file
279
mengyamonitor-frontend/src/components/AdminPanel/AdminPanel.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
adminListServers,
|
||||
adminUpdateServer,
|
||||
adminDeleteServer,
|
||||
adminReorderServers,
|
||||
} from '../../api/central';
|
||||
import './AdminPanel.css';
|
||||
|
||||
type Row = { id: string; name: string; url: string; enabled: boolean; sortOrder: number; agentKey?: string };
|
||||
|
||||
const formatServerUrl = (url: string) => {
|
||||
let formatted = url.trim();
|
||||
if (formatted.endsWith('.op')) {
|
||||
formatted = formatted.slice(0, -3) + '.top';
|
||||
}
|
||||
if (formatted && !/^https?:\/\//i.test(formatted)) {
|
||||
return `http://${formatted}`;
|
||||
}
|
||||
return formatted;
|
||||
};
|
||||
|
||||
export const AdminPanel = ({
|
||||
token,
|
||||
onClose,
|
||||
onSessionInvalid,
|
||||
onSaved,
|
||||
}: {
|
||||
token: string;
|
||||
onClose: () => void;
|
||||
onSessionInvalid: () => void;
|
||||
onSaved?: () => void;
|
||||
}) => {
|
||||
const [rows, setRows] = useState<Row[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [editId, setEditId] = useState<string | null>(null);
|
||||
const [editForm, setEditForm] = useState({ name: '', url: '', enabled: true });
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setErr(null);
|
||||
try {
|
||||
const list = await adminListServers(token);
|
||||
setRows(list);
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message === 'UNAUTHORIZED') {
|
||||
onSessionInvalid();
|
||||
return;
|
||||
}
|
||||
setErr(e instanceof Error ? e.message : '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [token, onSessionInvalid]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const startEdit = (r: Row) => {
|
||||
setEditId(r.id);
|
||||
setEditForm({ name: r.name, url: r.url, enabled: r.enabled });
|
||||
};
|
||||
|
||||
const saveEdit = async () => {
|
||||
if (!editId) return;
|
||||
try {
|
||||
const u = editForm.url.trim();
|
||||
await adminUpdateServer(token, editId, {
|
||||
name: editForm.name.trim(),
|
||||
...(u ? { url: formatServerUrl(u) } : { url: '' }),
|
||||
enabled: editForm.enabled,
|
||||
});
|
||||
setEditId(null);
|
||||
await load();
|
||||
onSaved?.();
|
||||
} catch (e) {
|
||||
alert(e instanceof Error ? e.message : '保存失败');
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (id: string) => {
|
||||
if (!confirm('确定删除该监控节点?')) return;
|
||||
try {
|
||||
await adminDeleteServer(token, id);
|
||||
await load();
|
||||
onSaved?.();
|
||||
} catch (e) {
|
||||
alert(e instanceof Error ? e.message : '删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const copyServerId = useCallback((id: string) => {
|
||||
void navigator.clipboard.writeText(id).catch(() => {
|
||||
window.prompt('请手动复制(环境变量 SERVER_ID)', id);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const copyAgentKey = useCallback((key: string) => {
|
||||
void navigator.clipboard.writeText(key).catch(() => {
|
||||
window.prompt('请手动复制(环境变量 AGENT_KEY)', key);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const move = async (index: number, dir: -1 | 1) => {
|
||||
const j = index + dir;
|
||||
if (j < 0 || j >= rows.length) return;
|
||||
const next = [...rows];
|
||||
[next[index], next[j]] = [next[j], next[index]];
|
||||
const ids = next.map((r) => r.id);
|
||||
setRows(next);
|
||||
try {
|
||||
await adminReorderServers(token, ids);
|
||||
await load();
|
||||
onSaved?.();
|
||||
} catch (e) {
|
||||
alert(e instanceof Error ? e.message : '排序失败');
|
||||
void load();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="admin-overlay" role="dialog" aria-modal="true">
|
||||
<div className="admin-panel">
|
||||
<div className="admin-panel-header">
|
||||
<h2>监控管理后台</h2>
|
||||
<button type="button" className="admin-close" onClick={onClose} title="关闭">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<p className="admin-hint">
|
||||
节点由采集端 gRPC 上报后出现在列表中。环境变量 CENTRAL_GRPC、<strong>SERVER_ID 须为下表「节点 ID」列的完整 UUID</strong>
|
||||
(勿把「名称」当成 SERVER_ID)、AGENT_KEY 与「节点密钥」一致。看板经 WebSocket
|
||||
拉取中心内存快照;「备注链接」可选,可在行内编辑。
|
||||
</p>
|
||||
|
||||
{loading ? (
|
||||
<p className="admin-loading">加载中…</p>
|
||||
) : (
|
||||
<>
|
||||
{err && <p className="admin-error">{err}</p>}
|
||||
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>顺序</th>
|
||||
<th>名称</th>
|
||||
<th>节点 ID(SERVER_ID)</th>
|
||||
<th>备注链接</th>
|
||||
<th>节点密钥 (gRPC)</th>
|
||||
<th>启用</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="admin-empty">
|
||||
暂无节点;请确认采集端已配置并连接中心,成功上报后将显示在此。
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
rows.map((r, i) => (
|
||||
<tr key={r.id}>
|
||||
<td>
|
||||
<div className="admin-order-btns">
|
||||
<button type="button" title="上移" onClick={() => void move(i, -1)} disabled={i === 0}>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="下移"
|
||||
onClick={() => void move(i, 1)}
|
||||
disabled={i === rows.length - 1}
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{editId === r.id ? (
|
||||
<input
|
||||
value={editForm.name}
|
||||
onChange={(e) => setEditForm({ ...editForm, name: e.target.value })}
|
||||
/>
|
||||
) : (
|
||||
r.name
|
||||
)}
|
||||
</td>
|
||||
<td className="admin-server-id-cell">
|
||||
<code className="admin-server-id" title={r.id}>
|
||||
{r.id}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-ghost"
|
||||
title="复制为采集端 SERVER_ID"
|
||||
onClick={() => copyServerId(r.id)}
|
||||
>
|
||||
复制
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
{editId === r.id ? (
|
||||
<input
|
||||
className="admin-url-input"
|
||||
value={editForm.url}
|
||||
onChange={(e) => setEditForm({ ...editForm, url: e.target.value })}
|
||||
/>
|
||||
) : r.url ? (
|
||||
<code>{r.url}</code>
|
||||
) : (
|
||||
<span className="admin-muted">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="admin-agent-key-cell">
|
||||
{r.agentKey ? (
|
||||
<>
|
||||
<code className="admin-key" title={r.agentKey}>
|
||||
{r.agentKey.slice(0, 8)}…
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-ghost"
|
||||
title="复制完整 AGENT_KEY(写入采集端配置)"
|
||||
onClick={() => copyAgentKey(r.agentKey!)}
|
||||
>
|
||||
复制
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<span className="admin-muted">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{editId === r.id ? (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editForm.enabled}
|
||||
onChange={(e) => setEditForm({ ...editForm, enabled: e.target.checked })}
|
||||
/>
|
||||
) : (
|
||||
<span>{r.enabled ? '是' : '否'}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="admin-actions">
|
||||
{editId === r.id ? (
|
||||
<>
|
||||
<button type="button" className="admin-btn" onClick={() => void saveEdit()}>
|
||||
保存
|
||||
</button>
|
||||
<button type="button" className="admin-btn" onClick={() => setEditId(null)}>
|
||||
取消
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button type="button" className="admin-btn" onClick={() => startEdit(r)}>
|
||||
编辑
|
||||
</button>
|
||||
<button type="button" className="admin-btn danger" onClick={() => void remove(r.id)}>
|
||||
删除
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,41 +0,0 @@
|
||||
.circular-progress {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.circular-progress svg {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.progress-ring-bg {
|
||||
fill: none;
|
||||
stroke: #e2e8f0;
|
||||
transition: stroke 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-ring-circle {
|
||||
fill: none;
|
||||
stroke-linecap: round;
|
||||
transition: stroke-dashoffset 0.5s ease;
|
||||
}
|
||||
|
||||
.progress-value {
|
||||
font-weight: 700;
|
||||
fill: var(--text-main);
|
||||
transform: rotate(90deg);
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.progress-label {
|
||||
fill: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
transform: rotate(90deg);
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.progress-sublabel {
|
||||
fill: var(--text-light);
|
||||
font-weight: 400;
|
||||
transform: rotate(90deg);
|
||||
transform-origin: center;
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
import './CircularProgress.css';
|
||||
|
||||
interface CircularProgressProps {
|
||||
value: number;
|
||||
label: string;
|
||||
color?: string;
|
||||
size?: number;
|
||||
subLabel?: string;
|
||||
}
|
||||
|
||||
export const CircularProgress = ({
|
||||
value,
|
||||
label,
|
||||
color = 'var(--primary-color)',
|
||||
size = 150,
|
||||
subLabel
|
||||
}: CircularProgressProps) => {
|
||||
const radius = size * 0.36; // 36% of size
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const offset = circumference - (value / 100) * circumference;
|
||||
const center = size / 2;
|
||||
|
||||
// 根据尺寸动态计算字体大小
|
||||
const valueFontSize = size * 0.12; // 12% of size
|
||||
const subLabelFontSize = size * 0.08; // 8% of size
|
||||
const labelFontSize = size * 0.09; // 9% of size
|
||||
|
||||
return (
|
||||
<div className="circular-progress" style={{ width: size, height: size }}>
|
||||
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
|
||||
<circle
|
||||
className="progress-ring-bg"
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={radius}
|
||||
strokeWidth={size * 0.08}
|
||||
/>
|
||||
<circle
|
||||
className="progress-ring-circle"
|
||||
stroke={color}
|
||||
strokeDasharray={`${circumference} ${circumference}`}
|
||||
strokeDashoffset={offset}
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={radius}
|
||||
strokeWidth={size * 0.08}
|
||||
/>
|
||||
{subLabel ? (
|
||||
<text
|
||||
x={center}
|
||||
y={center}
|
||||
className="progress-value"
|
||||
textAnchor="middle"
|
||||
dy="0.3em"
|
||||
fontSize={subLabelFontSize}
|
||||
>
|
||||
{subLabel}
|
||||
</text>
|
||||
) : (
|
||||
<text
|
||||
x={center}
|
||||
y={center}
|
||||
className="progress-value"
|
||||
textAnchor="middle"
|
||||
dy="0.3em"
|
||||
fontSize={valueFontSize}
|
||||
>
|
||||
{Math.round(value)}%
|
||||
</text>
|
||||
)}
|
||||
<text
|
||||
x={center}
|
||||
y={center + size * 0.22}
|
||||
className="progress-label"
|
||||
textAnchor="middle"
|
||||
fontSize={labelFontSize}
|
||||
>
|
||||
{label}
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
.random-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 0; /* 低于 #root (z-index: 1),保证监控卡片与顶栏在上层 */
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 略放大并外扩,减轻模糊后边缘露底 */
|
||||
.random-backdrop__image {
|
||||
position: absolute;
|
||||
inset: -16px;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
transform: scale(1.04);
|
||||
/* 需求「10%」按常用约定映射为 10px 半径(CSS blur 无百分比) */
|
||||
filter: blur(10px);
|
||||
-webkit-filter: blur(10px);
|
||||
}
|
||||
|
||||
.random-backdrop__fallback {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(120% 80% at 20% 20%, rgba(252, 231, 243, 0.95) 0%, transparent 55%),
|
||||
radial-gradient(100% 90% at 80% 30%, rgba(224, 231, 255, 0.9) 0%, transparent 50%),
|
||||
linear-gradient(160deg, #fdf4ff 0%, #e0f2fe 45%, #fce7f3 100%);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import './RandomBackdrop.css';
|
||||
|
||||
const RANDBG_JSON =
|
||||
'https://randbg.api.smyhub.com/api/random?format=json&mode=auto';
|
||||
|
||||
type RandomBgResponse = {
|
||||
url?: string;
|
||||
};
|
||||
|
||||
/** 挂到 body,避免与 .app 内 filter/层叠上下文叠在一起盖住卡片 */
|
||||
export const RandomBackdrop = () => {
|
||||
const [imageUrl, setImageUrl] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void fetch(RANDBG_JSON)
|
||||
.then((r) => {
|
||||
if (!r.ok) throw new Error(String(r.status));
|
||||
return r.json() as Promise<RandomBgResponse>;
|
||||
})
|
||||
.then((data) => {
|
||||
const u = data?.url;
|
||||
if (!cancelled && typeof u === 'string' && u.length > 0) {
|
||||
setImageUrl(u);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
/* 保持 fallback 底色 */
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return createPortal(
|
||||
<div className="random-backdrop" aria-hidden>
|
||||
{!imageUrl && <div className="random-backdrop__fallback" />}
|
||||
{imageUrl && (
|
||||
<div
|
||||
className="random-backdrop__image"
|
||||
style={{ backgroundImage: `url(${imageUrl})` }}
|
||||
/>
|
||||
)}
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
@@ -1,23 +1,27 @@
|
||||
.server-card {
|
||||
background: var(--surface-color);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1.5rem;
|
||||
backdrop-filter: var(--backdrop-blur);
|
||||
-webkit-backdrop-filter: var(--backdrop-blur);
|
||||
border-radius: var(--radius-glass);
|
||||
padding: 0.85rem 1rem;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: all 0.3s ease;
|
||||
border: 1px solid var(--border-color);
|
||||
border: 1px solid var(--glass-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 248px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.server-card:hover {
|
||||
box-shadow: var(--shadow-lg);
|
||||
transform: translateY(-4px);
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: var(--shadow-md);
|
||||
border-color: rgba(255, 255, 255, 0.75);
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
|
||||
.server-card.online {
|
||||
border-top: 4px solid var(--success-color);
|
||||
border-top: 3px solid var(--primary-color);
|
||||
box-shadow: var(--shadow-sm), inset 0 1px 0 rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.server-card.offline {
|
||||
@@ -29,13 +33,7 @@
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 0.5rem;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.card-header:active {
|
||||
cursor: grabbing;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.server-info {
|
||||
@@ -53,25 +51,6 @@
|
||||
|
||||
.status-online {
|
||||
background: var(--success-color);
|
||||
box-shadow: 0 0 0 4px rgba(134, 239, 172, 0.2);
|
||||
}
|
||||
|
||||
.status-online::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
left: -4px;
|
||||
right: -4px;
|
||||
bottom: -4px;
|
||||
border-radius: 50%;
|
||||
animation: pulse 2s infinite;
|
||||
border: 1px solid var(--success-color);
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { transform: scale(1); opacity: 0.5; }
|
||||
70% { transform: scale(1.5); opacity: 0; }
|
||||
100% { transform: scale(1); opacity: 0; }
|
||||
}
|
||||
|
||||
.status-offline {
|
||||
@@ -86,9 +65,10 @@
|
||||
|
||||
.server-name {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-main);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.server-hostname {
|
||||
@@ -124,62 +104,68 @@
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.metrics-grid-main {
|
||||
.card-stats-compact {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0;
|
||||
flex-direction: column;
|
||||
gap: 0.28rem;
|
||||
padding: 0.45rem 0.55rem;
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.circular-progress-small {
|
||||
.stat-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.circular-progress-small .progress-ring-bg {
|
||||
fill: none;
|
||||
stroke: #e2e8f0;
|
||||
stroke-width: 6;
|
||||
.stat-k {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.circular-progress-small .progress-ring-circle {
|
||||
fill: none;
|
||||
stroke-width: 6;
|
||||
stroke-linecap: round;
|
||||
transition: stroke-dashoffset 0.5s ease;
|
||||
transform: rotate(-90deg);
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.circular-progress-small .progress-value-small {
|
||||
font-size: 14px;
|
||||
.stat-v {
|
||||
color: var(--text-main);
|
||||
font-weight: 600;
|
||||
fill: var(--text-main);
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.circular-progress-small .progress-label-small {
|
||||
font-size: 10px;
|
||||
fill: var(--text-secondary);
|
||||
.lat-good {
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.lat-mid {
|
||||
color: #fde047;
|
||||
}
|
||||
|
||||
.lat-bad {
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
/* Info Grid */
|
||||
.card-info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--bg-color);
|
||||
gap: 0.45rem;
|
||||
padding: 0.45rem 0.5rem;
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
.info-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
@@ -215,10 +201,11 @@
|
||||
.card-performance-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--bg-color);
|
||||
gap: 0.45rem;
|
||||
padding: 0.45rem 0.5rem;
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
.performance-section {
|
||||
@@ -256,9 +243,9 @@
|
||||
}
|
||||
|
||||
.performance-item-value {
|
||||
color: var(--primary-color);
|
||||
color: #0284c7;
|
||||
font-weight: 600;
|
||||
font-family: monospace;
|
||||
font-family: ui-monospace, monospace;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
@@ -267,10 +254,11 @@
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem;
|
||||
background: var(--bg-color);
|
||||
padding: 0.45rem 0.55rem;
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
border-radius: var(--radius-md);
|
||||
margin-top: 0.5rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.45);
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
.footer-row {
|
||||
@@ -325,13 +313,13 @@
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background: var(--bg-color);
|
||||
border-radius: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: width 0.5s ease;
|
||||
}
|
||||
|
||||
@@ -357,31 +345,40 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-color);
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: 1.5rem;
|
||||
margin-bottom: 0.65rem;
|
||||
padding: 0.65rem;
|
||||
color: var(--text-light);
|
||||
border: 1px dashed rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
|
||||
.offline-state.sync-pending {
|
||||
border: 1px dashed var(--primary-color);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
margin-top: auto;
|
||||
margin-top: 0.45rem;
|
||||
}
|
||||
|
||||
.btn-detail {
|
||||
width: 100%;
|
||||
background: var(--surface-hover);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 0.75rem;
|
||||
background: rgba(255, 255, 255, 0.45);
|
||||
border: 1px solid var(--glass-border);
|
||||
padding: 0.5rem 0.65rem;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.875rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.btn-detail:hover {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
border-color: var(--primary-color);
|
||||
background: linear-gradient(135deg, rgba(125, 211, 252, 0.85), rgba(196, 181, 253, 0.75));
|
||||
color: #0f172a;
|
||||
border-color: rgba(255, 255, 255, 0.65);
|
||||
}
|
||||
|
||||
@@ -1,79 +1,7 @@
|
||||
import type { ServerConfig, ServerMetrics } from '../../types';
|
||||
import { formatUptime, formatBytes } from '../../utils/format';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { formatUptime, formatBytes, formatPercent, formatLatencyMs } from '../../utils/format';
|
||||
import './ServerCard.css';
|
||||
|
||||
const CircularProgress = ({ value, label, color, size = 60 }: { value: number; label: string; color: string; size?: number }) => {
|
||||
const radius = size * 0.36;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const offset = circumference - (value / 100) * circumference;
|
||||
|
||||
return (
|
||||
<div className="circular-progress-small">
|
||||
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
|
||||
<circle
|
||||
className="progress-ring-bg"
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
/>
|
||||
<circle
|
||||
className="progress-ring-circle"
|
||||
stroke={color}
|
||||
strokeDasharray={`${circumference} ${circumference}`}
|
||||
strokeDashoffset={offset}
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
/>
|
||||
<text x={size / 2} y={size / 2 - 8} className="progress-value-small" textAnchor="middle" dy="7px">
|
||||
{Math.round(value)}%
|
||||
</text>
|
||||
<text x={size / 2} y={size / 2 + 12} className="progress-label-small" textAnchor="middle">
|
||||
{label}
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const LoadProgress = ({ value, maxValue = 1.0, size = 80 }: { value: number; maxValue?: number; size?: number }) => {
|
||||
const percentage = Math.min((value / maxValue) * 100, 100);
|
||||
const radius = size * 0.36;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const offset = circumference - (percentage / 100) * circumference;
|
||||
const color = percentage > 80 ? '#f87171' : percentage > 50 ? '#fde047' : '#86efac';
|
||||
|
||||
return (
|
||||
<div className="circular-progress-small">
|
||||
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
|
||||
<circle
|
||||
className="progress-ring-bg"
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
/>
|
||||
<circle
|
||||
className="progress-ring-circle"
|
||||
stroke={color}
|
||||
strokeDasharray={`${circumference} ${circumference}`}
|
||||
strokeDashoffset={offset}
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
/>
|
||||
<text x={size / 2} y={size / 2 - 8} className="progress-value-small" textAnchor="middle" dy="7px">
|
||||
{value.toFixed(2)}
|
||||
</text>
|
||||
<text x={size / 2} y={size / 2 + 12} className="progress-label-small" textAnchor="middle">
|
||||
负载
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export interface ServerCardProps {
|
||||
server: ServerConfig;
|
||||
online: boolean;
|
||||
@@ -83,7 +11,6 @@ export interface ServerCardProps {
|
||||
storageUsage?: number;
|
||||
uptime?: number;
|
||||
onDetail: (serverId: string) => void;
|
||||
onRemove: (serverId: string) => void;
|
||||
}
|
||||
|
||||
export const ServerCard = ({
|
||||
@@ -95,24 +22,7 @@ export const ServerCard = ({
|
||||
storageUsage = 0,
|
||||
uptime,
|
||||
onDetail,
|
||||
onRemove,
|
||||
}: ServerCardProps) => {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: server.id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
};
|
||||
|
||||
// 从 URL 提取 IP 地址
|
||||
const extractIP = (url: string): string => {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
@@ -122,11 +32,10 @@ export const ServerCard = ({
|
||||
}
|
||||
};
|
||||
|
||||
// 获取主 IP 地址(从网络接口)
|
||||
const getMainIP = (): string => {
|
||||
if (metrics?.network && metrics.network.length > 0) {
|
||||
const mainInterface = metrics.network.find(iface =>
|
||||
iface.ipAddress && iface.ipAddress !== 'N/A' && !iface.ipAddress.startsWith('127.')
|
||||
const mainInterface = metrics.network.find(
|
||||
(iface) => iface.ipAddress && iface.ipAddress !== 'N/A' && !iface.ipAddress.startsWith('127.')
|
||||
);
|
||||
return mainInterface?.ipAddress || extractIP(server.url);
|
||||
}
|
||||
@@ -144,74 +53,84 @@ export const ServerCard = ({
|
||||
const diskWriteSpeed = metrics?.system?.diskWriteSpeed || 0;
|
||||
const networkRxSpeed = metrics?.system?.networkRxSpeed || 0;
|
||||
const networkTxSpeed = metrics?.system?.networkTxSpeed || 0;
|
||||
const latency = metrics?.latency?.clientToServer || -1;
|
||||
const latency = metrics?.latency?.clientToServer ?? -1;
|
||||
const processCount = metrics?.system?.processCount || 0;
|
||||
const packageCount = metrics?.system?.packageCount || 0;
|
||||
const loggedInUsers = metrics?.system?.loggedInUsers;
|
||||
const tcpConnections = metrics?.system?.tcpConnections;
|
||||
const swapTotalBytes = metrics?.memory?.swapTotalBytes;
|
||||
const swapType = metrics?.memory?.swapType;
|
||||
|
||||
// 延迟颜色判断
|
||||
const getLatencyColor = (latency: number): string => {
|
||||
if (latency < 0) return '#ef4444'; // 超时/失败 - 红色
|
||||
if (latency < 50) return '#86efac'; // 低延迟 - 绿色
|
||||
if (latency < 200) return '#fde047'; // 中延迟 - 黄色
|
||||
return '#f87171'; // 高延迟 - 红色
|
||||
};
|
||||
const loadRatio = cpuCores > 0 ? (loadAverage / cpuCores) * 100 : 0;
|
||||
|
||||
const latencyColor = getLatencyColor(latency);
|
||||
const latencyClass =
|
||||
!Number.isFinite(latency) || latency < 0
|
||||
? 'lat-bad'
|
||||
: latency < 50
|
||||
? 'lat-good'
|
||||
: latency < 200
|
||||
? 'lat-mid'
|
||||
: 'lat-bad';
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={`server-card ${online ? 'online' : 'offline'}`}
|
||||
>
|
||||
<div className="card-header" {...attributes} {...listeners}>
|
||||
<div className={`server-card ${online ? 'online' : 'offline'}`}>
|
||||
<div className="card-header">
|
||||
<div className="server-info">
|
||||
<div className={`status-indicator ${online ? 'status-online' : 'status-offline'}`} />
|
||||
<div className="server-name-group">
|
||||
<h3 className="server-name">{server.name}</h3>
|
||||
{online && hostname !== 'Unknown' && (
|
||||
<span className="server-hostname">{hostname}</span>
|
||||
)}
|
||||
{online && hostname !== 'Unknown' && <span className="server-hostname">{hostname}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="btn-remove"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove(server.id);
|
||||
}}
|
||||
title="移除服务器"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{online && metrics ? (
|
||||
{!online ? (
|
||||
<div className="offline-state">
|
||||
<p>服务器连接断开</p>
|
||||
</div>
|
||||
) : !metrics ? (
|
||||
<div className="offline-state sync-pending">
|
||||
<p>正在从中心同步指标…</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="card-main-metrics">
|
||||
<div className="metrics-grid-main">
|
||||
<div className="card-stats-compact">
|
||||
{loadAverage > 0 && (
|
||||
<LoadProgress value={loadAverage} maxValue={cpuCores} size={80} />
|
||||
<div className="stat-line">
|
||||
<span className="stat-k">负载</span>
|
||||
<span className="stat-v">
|
||||
{loadAverage.toFixed(2)} / {cpuCores} 核(≈ {formatPercent(loadRatio)})
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="stat-line">
|
||||
<span className="stat-k">CPU</span>
|
||||
<span className="stat-v">{formatPercent(cpuUsage)}</span>
|
||||
</div>
|
||||
<div className="stat-line">
|
||||
<span className="stat-k">内存</span>
|
||||
<span className="stat-v">{formatPercent(memoryUsage)}</span>
|
||||
</div>
|
||||
<div className="stat-line">
|
||||
<span className="stat-k">存储峰值</span>
|
||||
<span className="stat-v">{formatPercent(storageUsage)}</span>
|
||||
</div>
|
||||
{swapTotalBytes !== undefined && (
|
||||
<div className="stat-line">
|
||||
<span className="stat-k">Swap</span>
|
||||
<span className="stat-v">
|
||||
{formatBytes(swapTotalBytes)}
|
||||
{swapType ? ` · ${swapType}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{tcpConnections !== undefined && (
|
||||
<div className="stat-line">
|
||||
<span className="stat-k">TCP</span>
|
||||
<span className="stat-v">{tcpConnections}</span>
|
||||
</div>
|
||||
)}
|
||||
<CircularProgress
|
||||
value={cpuUsage}
|
||||
label="CPU"
|
||||
color={cpuUsage > 80 ? '#f87171' : '#86efac'}
|
||||
size={80}
|
||||
/>
|
||||
<CircularProgress
|
||||
value={memoryUsage}
|
||||
label="内存"
|
||||
color={memoryUsage > 80 ? '#fde047' : '#6ee7b7'}
|
||||
size={80}
|
||||
/>
|
||||
<CircularProgress
|
||||
value={storageUsage}
|
||||
label="存储"
|
||||
color={storageUsage > 90 ? '#f87171' : '#a7f3d0'}
|
||||
size={80}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="card-info-grid">
|
||||
@@ -239,6 +158,12 @@ export const ServerCard = ({
|
||||
<span className="info-label">进程</span>
|
||||
<span className="info-value">{processCount}</span>
|
||||
</div>
|
||||
{loggedInUsers !== undefined && (
|
||||
<div className="info-row">
|
||||
<span className="info-label">会话</span>
|
||||
<span className="info-value">{loggedInUsers}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="info-row">
|
||||
<span className="info-label">软件包</span>
|
||||
<span className="info-value">{packageCount}</span>
|
||||
@@ -254,11 +179,15 @@ export const ServerCard = ({
|
||||
<div className="performance-row">
|
||||
<span className="performance-item">
|
||||
<span className="performance-item-label">读取</span>
|
||||
<span className="performance-item-value">{diskReadSpeed > 0 ? formatBytes(diskReadSpeed * 1024 * 1024) + '/s' : '0 B/s'}</span>
|
||||
<span className="performance-item-value">
|
||||
{diskReadSpeed > 0 ? formatBytes(diskReadSpeed * 1024 * 1024) + '/s' : '0 B/s'}
|
||||
</span>
|
||||
</span>
|
||||
<span className="performance-item">
|
||||
<span className="performance-item-label">写入</span>
|
||||
<span className="performance-item-value">{diskWriteSpeed > 0 ? formatBytes(diskWriteSpeed * 1024 * 1024) + '/s' : '0 B/s'}</span>
|
||||
<span className="performance-item-value">
|
||||
{diskWriteSpeed > 0 ? formatBytes(diskWriteSpeed * 1024 * 1024) + '/s' : '0 B/s'}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -270,11 +199,15 @@ export const ServerCard = ({
|
||||
<div className="performance-row">
|
||||
<span className="performance-item">
|
||||
<span className="performance-item-label">下载</span>
|
||||
<span className="performance-item-value">{networkRxSpeed > 0 ? formatBytes(networkRxSpeed * 1024 * 1024) + '/s' : '0 B/s'}</span>
|
||||
<span className="performance-item-value">
|
||||
{networkRxSpeed > 0 ? formatBytes(networkRxSpeed * 1024 * 1024) + '/s' : '0 B/s'}
|
||||
</span>
|
||||
</span>
|
||||
<span className="performance-item">
|
||||
<span className="performance-item-label">上传</span>
|
||||
<span className="performance-item-value">{networkTxSpeed > 0 ? formatBytes(networkTxSpeed * 1024 * 1024) + '/s' : '0 B/s'}</span>
|
||||
<span className="performance-item-value">
|
||||
{networkTxSpeed > 0 ? formatBytes(networkTxSpeed * 1024 * 1024) + '/s' : '0 B/s'}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -285,23 +218,17 @@ export const ServerCard = ({
|
||||
<span className="footer-label">运行时间</span>
|
||||
<span className="footer-value">{uptime !== undefined ? formatUptime(uptime) : 'N/A'}</span>
|
||||
</div>
|
||||
<div className="footer-row">
|
||||
<span className="footer-label">延迟</span>
|
||||
<span className="footer-value" style={{ color: latencyColor }}>
|
||||
{latency > 0 ? `${latency} ms` : latency === -1 ? '超时' : 'N/A'}
|
||||
</span>
|
||||
<div className="footer-row" title="采集器到监控中心的 TCP 建连耗时(由采集端上报)">
|
||||
<span className="footer-label">采集器→中心</span>
|
||||
<span className={`footer-value ${latencyClass}`}>{formatLatencyMs(latency)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="offline-state">
|
||||
<p>服务器连接断开</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
<div className="card-footer">
|
||||
<button className="btn-detail" onClick={() => onDetail(server.id)}>
|
||||
<button type="button" className="btn-detail" onClick={() => onDetail(server.id)}>
|
||||
查看详情
|
||||
</button>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,5 @@
|
||||
import type { ServerMetrics } from '../../types';
|
||||
import { formatBytes, formatUptime, formatPercent } from '../../utils/format';
|
||||
import { CircularProgress } from '../Common/CircularProgress';
|
||||
import { formatBytes, formatUptime, formatPercent, formatLatencyMs } from '../../utils/format';
|
||||
import './ServerDetail.css';
|
||||
|
||||
interface ServerDetailProps {
|
||||
@@ -20,12 +19,12 @@ export const ServerDetail = ({ metrics, serverName, onClose }: ServerDetailProps
|
||||
|
||||
<div className="modal-body">
|
||||
<section className="detail-section">
|
||||
<h3>系统信息</h3>
|
||||
<h3 className="detail-category-heading">系统与环境</h3>
|
||||
<div className="system-info-container">
|
||||
{/* 系统基本信息 */}
|
||||
<div className="system-info-group">
|
||||
<h4 className="info-group-title">基本信息</h4>
|
||||
<div className="system-info-grid">
|
||||
<div className="system-info-grid system-info-grid--cols-3">
|
||||
<div className="system-info-item">
|
||||
<div className="system-info-content">
|
||||
<span className="system-info-label">主机名</span>
|
||||
@@ -59,7 +58,7 @@ export const ServerDetail = ({ metrics, serverName, onClose }: ServerDetailProps
|
||||
{/* 运行时信息 */}
|
||||
<div className="system-info-group">
|
||||
<h4 className="info-group-title">运行时信息</h4>
|
||||
<div className="system-info-grid">
|
||||
<div className="system-info-grid system-info-grid--cols-3">
|
||||
<div className="system-info-item system-info-item-highlight">
|
||||
<div className="system-info-content">
|
||||
<span className="system-info-label">运行时间</span>
|
||||
@@ -72,6 +71,22 @@ export const ServerDetail = ({ metrics, serverName, onClose }: ServerDetailProps
|
||||
<span className="system-info-value">{metrics.system.processCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
{metrics.system.loggedInUsers !== undefined && (
|
||||
<div className="system-info-item">
|
||||
<div className="system-info-content">
|
||||
<span className="system-info-label">登录会话</span>
|
||||
<span className="system-info-value">{metrics.system.loggedInUsers}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{metrics.system.tcpConnections !== undefined && (
|
||||
<div className="system-info-item">
|
||||
<div className="system-info-content">
|
||||
<span className="system-info-label">TCP 连接数</span>
|
||||
<span className="system-info-value">{metrics.system.tcpConnections}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{metrics.system.packageCount > 0 && (
|
||||
<div className="system-info-item">
|
||||
<div className="system-info-content">
|
||||
@@ -86,7 +101,7 @@ export const ServerDetail = ({ metrics, serverName, onClose }: ServerDetailProps
|
||||
{/* 性能指标 */}
|
||||
<div className="system-info-group">
|
||||
<h4 className="info-group-title">性能指标</h4>
|
||||
<div className="system-info-grid">
|
||||
<div className="system-info-grid system-info-grid--cols-3">
|
||||
<div className="system-info-item">
|
||||
<div className="system-info-content">
|
||||
<span className="system-info-label">磁盘读取</span>
|
||||
@@ -129,34 +144,48 @@ export const ServerDetail = ({ metrics, serverName, onClose }: ServerDetailProps
|
||||
{/* 延迟信息 */}
|
||||
{metrics.latency && (
|
||||
<div className="system-info-group">
|
||||
<h4 className="info-group-title">延迟检测</h4>
|
||||
<div className="system-info-grid">
|
||||
<h4 className="info-group-title">网络延迟(采集端测量)</h4>
|
||||
<p className="latency-section-hint">
|
||||
下列指标均在采集器所在机器上探测:第一项为到监控中心 gRPC 地址的 TCP
|
||||
建连时间;外网三项为采集端对公网站点的 TCP(:80 / :443)建连耗时,非 ICMP ping。
|
||||
</p>
|
||||
<div className="system-info-grid system-info-grid--cols-4">
|
||||
<div className="system-info-item system-info-item-highlight">
|
||||
<div className="system-info-content">
|
||||
<span className="system-info-label">客户端到服务器</span>
|
||||
<div className="system-info-content system-info-content--stack">
|
||||
<span className="system-info-label">采集器到监控中心</span>
|
||||
<span className="system-info-sublabel">与 CENTRAL_GRPC 的 TCP 连通</span>
|
||||
<span className="system-info-value highlight">
|
||||
{metrics.latency.clientToServer >= 0 ? `${metrics.latency.clientToServer} ms` : '超时'}
|
||||
{formatLatencyMs(metrics.latency.clientToServer)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{metrics.latency.external && (
|
||||
<>
|
||||
<div className="system-info-item">
|
||||
<div className="system-info-content">
|
||||
<div className="system-info-content system-info-content--stack">
|
||||
<span className="system-info-label">百度 (baidu.com)</span>
|
||||
<span className="system-info-value">{metrics.latency.external['baidu.com'] || '超时'}</span>
|
||||
<span className="system-info-sublabel">采集端 TCP 探测</span>
|
||||
<span className="system-info-value">
|
||||
{metrics.latency.external['baidu.com'] || '超时'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="system-info-item">
|
||||
<div className="system-info-content">
|
||||
<div className="system-info-content system-info-content--stack">
|
||||
<span className="system-info-label">谷歌 (google.com)</span>
|
||||
<span className="system-info-value">{metrics.latency.external['google.com'] || '超时'}</span>
|
||||
<span className="system-info-sublabel">采集端 TCP 探测</span>
|
||||
<span className="system-info-value">
|
||||
{metrics.latency.external['google.com'] || '超时'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="system-info-item">
|
||||
<div className="system-info-content">
|
||||
<div className="system-info-content system-info-content--stack">
|
||||
<span className="system-info-label">GitHub (github.com)</span>
|
||||
<span className="system-info-value">{metrics.latency.external['github.com'] || '超时'}</span>
|
||||
<span className="system-info-sublabel">采集端 TCP 探测</span>
|
||||
<span className="system-info-value">
|
||||
{metrics.latency.external['github.com'] || '超时'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
@@ -168,152 +197,152 @@ export const ServerDetail = ({ metrics, serverName, onClose }: ServerDetailProps
|
||||
</section>
|
||||
|
||||
<section className="detail-section">
|
||||
<h3>CPU</h3>
|
||||
<div className="cpu-section-content">
|
||||
<div className="cpu-circular-group">
|
||||
<div className="cpu-circular">
|
||||
<CircularProgress
|
||||
value={metrics.cpu.usagePercent}
|
||||
label=""
|
||||
size={140}
|
||||
color={metrics.cpu.usagePercent > 80 ? '#f87171' : '#86efac'}
|
||||
subLabel={formatPercent(metrics.cpu.usagePercent)}
|
||||
/>
|
||||
</div>
|
||||
{metrics.cpu.loadAverages && metrics.cpu.loadAverages.length > 0 && metrics.cpu.cores > 0 && (
|
||||
<div className="cpu-load-circular">
|
||||
<CircularProgress
|
||||
value={Math.min((metrics.cpu.loadAverages[0] / metrics.cpu.cores) * 100, 100)}
|
||||
label="负载"
|
||||
size={140}
|
||||
color={metrics.cpu.loadAverages[0] / metrics.cpu.cores > 0.8 ? '#fde047' : '#a7f3d0'}
|
||||
subLabel={metrics.cpu.loadAverages[0].toFixed(2)}
|
||||
/>
|
||||
<h3 className="detail-category-heading">处理器与内存</h3>
|
||||
<div className="detail-subsection">
|
||||
<h4 className="detail-subheading">处理器</h4>
|
||||
<div className="detail-plain">
|
||||
<div className="detail-grid detail-grid--cpu">
|
||||
<div className="detail-item detail-item--span-full">
|
||||
<span className="item-label">型号</span>
|
||||
<span className="item-value">{metrics.cpu.model}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="detail-grid">
|
||||
<div className="detail-item">
|
||||
<span className="item-label">型号</span>
|
||||
<span className="item-value">{metrics.cpu.model}</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="item-label">核心数</span>
|
||||
<span className="item-value">{metrics.cpu.cores}</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="item-label">使用率</span>
|
||||
<span className="item-value highlight">{formatPercent(metrics.cpu.usagePercent)}</span>
|
||||
</div>
|
||||
{metrics.cpu.temperature && metrics.cpu.temperature > 0 && (
|
||||
<div className="detail-item">
|
||||
<span className="item-label">温度</span>
|
||||
<span className="item-value">{metrics.cpu.temperature.toFixed(1)}°C</span>
|
||||
<span className="item-label">核心数</span>
|
||||
<span className="item-value">{metrics.cpu.cores}</span>
|
||||
</div>
|
||||
)}
|
||||
{metrics.cpu.loadAverages && metrics.cpu.loadAverages.length > 0 && (
|
||||
<div className="detail-item">
|
||||
<span className="item-label">负载平均值</span>
|
||||
<span className="item-value">{metrics.cpu.loadAverages.join(' / ')}</span>
|
||||
<span className="item-label">使用率</span>
|
||||
<span className="item-value emphasize">{formatPercent(metrics.cpu.usagePercent)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{metrics.cpu.perCoreUsage && metrics.cpu.perCoreUsage.length > 0 && (
|
||||
<div className="per-core-section">
|
||||
<h4>各核心使用率</h4>
|
||||
<div className="core-grid-circular">
|
||||
{metrics.cpu.perCoreUsage.map((core) => (
|
||||
<div key={core.core} className="core-item-circular">
|
||||
<CircularProgress
|
||||
value={core.percent}
|
||||
label={`Core ${core.core}`}
|
||||
size={140}
|
||||
color={core.percent > 80 ? '#f87171' : '#86efac'}
|
||||
subLabel={formatPercent(core.percent)}
|
||||
/>
|
||||
{metrics.cpu.temperature && metrics.cpu.temperature > 0 && (
|
||||
<div className="detail-item">
|
||||
<span className="item-label">温度</span>
|
||||
<span className="item-value">{metrics.cpu.temperature.toFixed(1)}°C</span>
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
{metrics.cpu.loadAverages && metrics.cpu.loadAverages.length > 0 && (
|
||||
<>
|
||||
<div className="detail-item">
|
||||
<span className="item-label">负载 (1 / 5 / 15 min)</span>
|
||||
<span className="item-value">{metrics.cpu.loadAverages.map((l) => l.toFixed(2)).join(' / ')}</span>
|
||||
</div>
|
||||
{metrics.cpu.cores > 0 && (
|
||||
<div className="detail-item">
|
||||
<span className="item-label">相对核心(1 min 负载)</span>
|
||||
<span className="item-value">
|
||||
{formatPercent(Math.min((metrics.cpu.loadAverages[0] / metrics.cpu.cores) * 100, 100))}{' '}
|
||||
<span className="item-sub">
|
||||
({metrics.cpu.loadAverages[0].toFixed(2)} / {metrics.cpu.cores} 核)
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
{metrics.cpu.perCoreUsage && metrics.cpu.perCoreUsage.length > 0 && (
|
||||
<div className="per-core-plain">
|
||||
<h4 className="detail-subheading detail-subheading--inline">各核心使用率</h4>
|
||||
<div className="per-core-grid">
|
||||
{metrics.cpu.perCoreUsage.map((core) => (
|
||||
<div key={core.core} className="per-core-chip">
|
||||
<span className="per-core-name">核心 {core.core}</span>
|
||||
<span className="per-core-pct">{formatPercent(core.percent)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<section className="detail-section">
|
||||
<h3>内存</h3>
|
||||
<div className="memory-section-content">
|
||||
<div className="memory-circular">
|
||||
<CircularProgress
|
||||
value={metrics.memory.usedPercent}
|
||||
label=""
|
||||
size={140}
|
||||
color={metrics.memory.usedPercent > 80 ? '#fde047' : '#6ee7b7'}
|
||||
subLabel={formatPercent(metrics.memory.usedPercent)}
|
||||
/>
|
||||
</div>
|
||||
<div className="detail-grid">
|
||||
<div className="detail-item">
|
||||
<span className="item-label">总容量</span>
|
||||
<span className="item-value">{formatBytes(metrics.memory.totalBytes)}</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="item-label">已使用</span>
|
||||
<span className="item-value">{formatBytes(metrics.memory.usedBytes)}</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="item-label">可用</span>
|
||||
<span className="item-value">{formatBytes(metrics.memory.freeBytes)}</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="item-label">使用率</span>
|
||||
<span className="item-value highlight">{formatPercent(metrics.memory.usedPercent)}</span>
|
||||
<div className="detail-subsection">
|
||||
<h4 className="detail-subheading">内存</h4>
|
||||
<div className="detail-plain">
|
||||
<div className="detail-grid">
|
||||
<div className="detail-item">
|
||||
<span className="item-label">使用率</span>
|
||||
<span className="item-value emphasize">{formatPercent(metrics.memory.usedPercent)}</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="item-label">总容量</span>
|
||||
<span className="item-value">{formatBytes(metrics.memory.totalBytes)}</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="item-label">已使用</span>
|
||||
<span className="item-value">{formatBytes(metrics.memory.usedBytes)}</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="item-label">可用</span>
|
||||
<span className="item-value">{formatBytes(metrics.memory.freeBytes)}</span>
|
||||
</div>
|
||||
{(metrics.memory.swapTotalBytes !== undefined ||
|
||||
metrics.memory.swapType !== undefined) && (
|
||||
<>
|
||||
<div className="detail-item">
|
||||
<span className="item-label">Swap 总量</span>
|
||||
<span className="item-value">
|
||||
{formatBytes(metrics.memory.swapTotalBytes ?? 0)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="item-label">Swap 已用</span>
|
||||
<span className="item-value">
|
||||
{formatBytes(metrics.memory.swapUsedBytes ?? 0)}
|
||||
{metrics.memory.swapUsedPercent !== undefined && (
|
||||
<span className="item-sub"> ({formatPercent(metrics.memory.swapUsedPercent)})</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="item-label">Swap 可用</span>
|
||||
<span className="item-value">
|
||||
{formatBytes(metrics.memory.swapFreeBytes ?? 0)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="item-label">Swap 类型</span>
|
||||
<span className="item-value">{metrics.memory.swapType ?? '—'}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="detail-section">
|
||||
<h3>存储</h3>
|
||||
<div className="storage-grid-circular">
|
||||
<h3 className="detail-category-heading">存储与磁盘</h3>
|
||||
<ul className="storage-plain-list">
|
||||
{metrics.storage.map((disk, index) => (
|
||||
<div key={index} className="storage-item-circular">
|
||||
<CircularProgress
|
||||
value={disk.usedPercent}
|
||||
label={disk.mount}
|
||||
size={160}
|
||||
color={disk.usedPercent > 90 ? '#f87171' : '#a7f3d0'}
|
||||
subLabel={formatPercent(disk.usedPercent)}
|
||||
/>
|
||||
<div className="storage-details-mini">
|
||||
<div>总: {formatBytes(disk.totalBytes)}</div>
|
||||
<div>用: {formatBytes(disk.usedBytes)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<li key={index} className="storage-plain-row">
|
||||
<div className="storage-plain-head">
|
||||
<span className="storage-mount">{disk.mount}</span>
|
||||
<span className="storage-pct">{formatPercent(disk.usedPercent)}</span>
|
||||
</div>
|
||||
<div className="storage-meta">
|
||||
已用 {formatBytes(disk.usedBytes)} / 共 {formatBytes(disk.totalBytes)} · 可用{' '}
|
||||
{formatBytes(disk.freeBytes)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</div>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{metrics.gpu && metrics.gpu.length > 0 && metrics.gpu[0].status !== 'not_available' && (
|
||||
<section className="detail-section">
|
||||
<h3>GPU</h3>
|
||||
<h3 className="detail-category-heading">图形设备</h3>
|
||||
{metrics.gpu.map((gpu, index) => (
|
||||
<div key={index} className="gpu-item">
|
||||
<div className="gpu-header">
|
||||
<strong>{gpu.name}</strong>
|
||||
<span className={`gpu-status ${gpu.status}`}>{gpu.status}</span>
|
||||
</div>
|
||||
<div className="gpu-bar-container">
|
||||
<div className="gpu-bar-label">利用率</div>
|
||||
<div className="gpu-bar-bg">
|
||||
<div
|
||||
className="gpu-bar"
|
||||
style={{ width: `${gpu.utilizationPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="gpu-bar-value">{formatPercent(gpu.utilizationPercent)}</div>
|
||||
</div>
|
||||
<div className="detail-grid">
|
||||
<div className="detail-item">
|
||||
<span className="item-label">利用率</span>
|
||||
<span className="item-value emphasize">{formatPercent(gpu.utilizationPercent)}</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="item-label">显存总量</span>
|
||||
<span className="item-value">{gpu.memoryTotalMB} MB</span>
|
||||
@@ -336,167 +365,150 @@ export const ServerDetail = ({ metrics, serverName, onClose }: ServerDetailProps
|
||||
|
||||
{metrics.network && metrics.network.length > 0 && (
|
||||
<section className="detail-section">
|
||||
<h3>网络接口</h3>
|
||||
<div className="network-list">
|
||||
<h3 className="detail-category-heading">网络</h3>
|
||||
<div className="network-compact" role="table" aria-label="网络接口">
|
||||
<div className="network-compact__head" role="row">
|
||||
<span role="columnheader">接口</span>
|
||||
<span role="columnheader">IP</span>
|
||||
<span role="columnheader">MAC</span>
|
||||
<span role="columnheader">接收</span>
|
||||
<span role="columnheader">发送</span>
|
||||
</div>
|
||||
{metrics.network.map((iface, index) => (
|
||||
<div key={index} className="network-item">
|
||||
<div className="network-header">
|
||||
<div className="network-name-group">
|
||||
<div className="network-name-info">
|
||||
<strong className="network-name">{iface.name}</strong>
|
||||
{iface.ipAddress && (
|
||||
<span className="network-ip has-ip">
|
||||
{iface.ipAddress}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="network-details">
|
||||
{(iface.macAddress || iface.ipAddress) && (
|
||||
<div className="network-detail-row">
|
||||
{iface.macAddress && (
|
||||
<div className="network-detail-item">
|
||||
<span className="network-detail-label">MAC 地址</span>
|
||||
<span className="network-detail-value">{iface.macAddress}</span>
|
||||
</div>
|
||||
)}
|
||||
{iface.ipAddress && (
|
||||
<div className="network-detail-item">
|
||||
<span className="network-detail-label">IP 地址</span>
|
||||
<span className="network-detail-value">{iface.ipAddress}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="network-traffic">
|
||||
<div className="traffic-item">
|
||||
<div className="traffic-header">
|
||||
<span className="traffic-label">接收流量</span>
|
||||
<span className="traffic-value">{formatBytes(iface.rxBytes)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="traffic-item">
|
||||
<div className="traffic-header">
|
||||
<span className="traffic-label">发送流量</span>
|
||||
<span className="traffic-value">{formatBytes(iface.txBytes)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div key={index} className="network-compact__row" role="row">
|
||||
<strong className="network-compact__iface" role="cell">
|
||||
{iface.name}
|
||||
</strong>
|
||||
<span
|
||||
className={
|
||||
iface.ipAddress && iface.ipAddress !== 'N/A'
|
||||
? 'network-compact__ip network-compact__ip--ok'
|
||||
: 'network-compact__placeholder'
|
||||
}
|
||||
role="cell"
|
||||
>
|
||||
{iface.ipAddress && iface.ipAddress !== 'N/A' ? iface.ipAddress : '—'}
|
||||
</span>
|
||||
<span
|
||||
className="network-compact__mac"
|
||||
role="cell"
|
||||
title={iface.macAddress || undefined}
|
||||
>
|
||||
{iface.macAddress || '—'}
|
||||
</span>
|
||||
<span className="network-compact__num" role="cell" title="接收流量">
|
||||
{formatBytes(iface.rxBytes)}
|
||||
</span>
|
||||
<span className="network-compact__num" role="cell" title="发送流量">
|
||||
{formatBytes(iface.txBytes)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{metrics.system.topProcesses && metrics.system.topProcesses.length > 0 && (
|
||||
{((metrics.system.topProcesses && metrics.system.topProcesses.length > 0) ||
|
||||
(metrics.system.dockerStats && metrics.system.dockerStats.available)) && (
|
||||
<section className="detail-section">
|
||||
<h3>Top 5 进程</h3>
|
||||
<div className="process-list">
|
||||
{metrics.system.topProcesses.map((proc, index) => (
|
||||
<div key={index} className="process-item">
|
||||
<div className="process-rank">{index + 1}</div>
|
||||
<div className="process-content">
|
||||
<div className="process-header">
|
||||
<div className="process-name-group">
|
||||
<strong className="process-name">{proc.name}</strong>
|
||||
<span className="process-pid">PID: {proc.pid}</span>
|
||||
</div>
|
||||
<h3 className="detail-category-heading">进程与服务</h3>
|
||||
{metrics.system.topProcesses && metrics.system.topProcesses.length > 0 && (
|
||||
<div className="detail-subsection">
|
||||
<h4 className="detail-subheading">资源占用较高的进程(前 10)</h4>
|
||||
<div className="process-compact" role="table" aria-label="进程占用">
|
||||
<div className="process-compact__head" role="row">
|
||||
<span role="columnheader">#</span>
|
||||
<span role="columnheader">进程</span>
|
||||
<span role="columnheader">PID</span>
|
||||
<span role="columnheader">CPU</span>
|
||||
<span role="columnheader">内存</span>
|
||||
</div>
|
||||
{metrics.system.topProcesses.map((proc, index) => (
|
||||
<div key={index} className="process-compact__row" role="row">
|
||||
<span className="process-compact__rank" role="cell">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="process-compact__name" role="cell" title={proc.name}>
|
||||
{proc.name}
|
||||
</span>
|
||||
<span className="process-compact__pid" role="cell">
|
||||
{proc.pid}
|
||||
</span>
|
||||
<span className="process-compact__num" role="cell">
|
||||
{proc.cpu}%
|
||||
</span>
|
||||
<span className="process-compact__num process-compact__mem" role="cell">
|
||||
{proc.memory}%
|
||||
<span className="process-compact__mem-abs"> {proc.memoryMB.toFixed(1)} MB</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="process-metrics">
|
||||
<div className="process-metric-item">
|
||||
<div className="metric-header">
|
||||
<span className="metric-label">CPU</span>
|
||||
<span className="metric-value">{proc.cpu}%</span>
|
||||
</div>
|
||||
<div className="metric-bar-container">
|
||||
<div
|
||||
className="metric-bar cpu-bar"
|
||||
style={{ width: `${Math.min(proc.cpu, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="process-metric-item">
|
||||
<div className="metric-header">
|
||||
<span className="metric-label">内存</span>
|
||||
<span className="metric-value">{proc.memory}% ({proc.memoryMB.toFixed(1)} MB)</span>
|
||||
</div>
|
||||
<div className="metric-bar-container">
|
||||
<div
|
||||
className="metric-bar memory-bar"
|
||||
style={{ width: `${Math.min(proc.memory, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{metrics.system.dockerStats && metrics.system.dockerStats.available && (
|
||||
<div className="detail-subsection">
|
||||
<h4 className="detail-subheading">Docker</h4>
|
||||
{metrics.system.dockerStats.version && (
|
||||
<div className="detail-grid">
|
||||
<div className="detail-item">
|
||||
<span className="item-label">Docker 版本</span>
|
||||
<span className="item-value">{metrics.system.dockerStats.version}</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="item-label">运行中的容器</span>
|
||||
<span className="item-value">{metrics.system.dockerStats.running}</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="item-label">停止的容器</span>
|
||||
<span className="item-value">{metrics.system.dockerStats.stopped}</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="item-label">镜像数量</span>
|
||||
<span className="item-value">{metrics.system.dockerStats.imageCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
)}
|
||||
|
||||
{metrics.system.dockerStats && metrics.system.dockerStats.available && (
|
||||
<section className="detail-section">
|
||||
<h3>Docker 信息</h3>
|
||||
{metrics.system.dockerStats.version && (
|
||||
<div className="detail-grid">
|
||||
<div className="detail-item">
|
||||
<span className="item-label">Docker 版本</span>
|
||||
<span className="item-value">{metrics.system.dockerStats.version}</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="item-label">运行中的容器</span>
|
||||
<span className="item-value">{metrics.system.dockerStats.running}</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="item-label">停止的容器</span>
|
||||
<span className="item-value">{metrics.system.dockerStats.stopped}</span>
|
||||
</div>
|
||||
<div className="detail-item">
|
||||
<span className="item-label">镜像数量</span>
|
||||
<span className="item-value">{metrics.system.dockerStats.imageCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{metrics.system.dockerStats.runningNames && metrics.system.dockerStats.runningNames.length > 0 && (
|
||||
<div className="docker-containers-section">
|
||||
<h4>运行中的容器</h4>
|
||||
<div className="docker-names-list">
|
||||
{metrics.system.dockerStats.runningNames.map((name, index) => (
|
||||
<span key={index} className="docker-name-tag running">
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{metrics.system.dockerStats.stoppedNames && metrics.system.dockerStats.stoppedNames.length > 0 && (
|
||||
<div className="docker-containers-section">
|
||||
<h4>停止的容器</h4>
|
||||
<div className="docker-names-list">
|
||||
{metrics.system.dockerStats.stoppedNames.map((name, index) => (
|
||||
<span key={index} className="docker-name-tag stopped">
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{metrics.system.dockerStats.imageNames && metrics.system.dockerStats.imageNames.length > 0 && (
|
||||
<div className="docker-containers-section">
|
||||
<h4>镜像列表</h4>
|
||||
<div className="docker-names-list">
|
||||
{metrics.system.dockerStats.imageNames.map((name, index) => (
|
||||
<span key={index} className="docker-name-tag image">
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{metrics.system.dockerStats.runningNames && metrics.system.dockerStats.runningNames.length > 0 && (
|
||||
<div className="docker-containers-section">
|
||||
<h4 className="detail-subheading detail-subheading--minor">运行中的容器</h4>
|
||||
<div className="docker-names-list">
|
||||
{metrics.system.dockerStats.runningNames.map((name, index) => (
|
||||
<span key={index} className="docker-name-tag running">
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{metrics.system.dockerStats.stoppedNames && metrics.system.dockerStats.stoppedNames.length > 0 && (
|
||||
<div className="docker-containers-section">
|
||||
<h4 className="detail-subheading detail-subheading--minor">停止的容器</h4>
|
||||
<div className="docker-names-list">
|
||||
{metrics.system.dockerStats.stoppedNames.map((name, index) => (
|
||||
<span key={index} className="docker-name-tag stopped">
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{metrics.system.dockerStats.imageNames && metrics.system.dockerStats.imageNames.length > 0 && (
|
||||
<div className="docker-containers-section">
|
||||
<h4 className="detail-subheading detail-subheading--minor">镜像列表</h4>
|
||||
<div className="docker-names-list">
|
||||
{metrics.system.dockerStats.imageNames.map((name, index) => (
|
||||
<span key={index} className="docker-name-tag image">
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
@@ -504,7 +516,8 @@ export const ServerDetail = ({ metrics, serverName, onClose }: ServerDetailProps
|
||||
|
||||
{metrics.system.systemLogs && metrics.system.systemLogs.length > 0 && (
|
||||
<section className="detail-section">
|
||||
<h3>系统日志</h3>
|
||||
<h3 className="detail-category-heading">诊断与日志</h3>
|
||||
<h4 className="detail-subheading">系统日志</h4>
|
||||
<div className="logs-container">
|
||||
{metrics.system.systemLogs.map((log, index) => (
|
||||
<div key={index} className="log-line">
|
||||
|
||||
37
mengyamonitor-frontend/src/hooks/usePublicServers.ts
Normal file
37
mengyamonitor-frontend/src/hooks/usePublicServers.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import type { ServerConfig } from '../types';
|
||||
import { fetchPublicServers } from '../api/central';
|
||||
|
||||
export const usePublicServers = (refreshMs: number = 30000) => {
|
||||
const [servers, setServers] = useState<ServerConfig[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
try {
|
||||
setError(null);
|
||||
const list = await fetchPublicServers();
|
||||
setServers(
|
||||
list.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
url: s.url.replace(/\/$/, ''),
|
||||
enabled: s.enabled,
|
||||
}))
|
||||
);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '加载失败');
|
||||
setServers([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
const t = setInterval(() => void reload(), refreshMs);
|
||||
return () => clearInterval(t);
|
||||
}, [reload, refreshMs]);
|
||||
|
||||
return { servers, loading, error, reload };
|
||||
};
|
||||
@@ -1,58 +1,107 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import type { ServerConfig, ServerStatus } from '../types';
|
||||
import { fetchServerMetrics } from '../api/monitor';
|
||||
import { useState, useEffect } from 'react';
|
||||
import type { ServerStatus } from '../types';
|
||||
import { centralApiBase } from '../api/central';
|
||||
|
||||
export const useServerMonitor = (servers: ServerConfig[], interval: number = 2000) => {
|
||||
function monitorWsURL(): string {
|
||||
const base = centralApiBase();
|
||||
if (base.startsWith('http://') || base.startsWith('https://')) {
|
||||
const u = new URL(base);
|
||||
u.protocol = u.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
u.pathname = (u.pathname.replace(/\/$/, '') + '/api/ws/monitor').replace(/\/+/g, '/');
|
||||
if (!u.pathname.startsWith('/')) u.pathname = '/' + u.pathname;
|
||||
return u.toString();
|
||||
}
|
||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const path = `${base.replace(/\/$/, '')}/api/ws/monitor`.replace(/\/+/g, '/');
|
||||
return `${proto}//${window.location.host}${path.startsWith('/') ? path : '/' + path}`;
|
||||
}
|
||||
|
||||
function detachWebSocket(ws: WebSocket) {
|
||||
ws.onmessage = null;
|
||||
ws.onclose = null;
|
||||
ws.onerror = null;
|
||||
ws.onopen = null;
|
||||
}
|
||||
|
||||
/** Live metrics from the central WebSocket snapshot (gRPC-backed agents only). */
|
||||
export const useServerMonitor = () => {
|
||||
const [statuses, setStatuses] = useState<Record<string, ServerStatus>>({});
|
||||
|
||||
const fetchMetrics = useCallback(async (server: ServerConfig) => {
|
||||
if (!server.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const metrics = await fetchServerMetrics(server.url);
|
||||
setStatuses(prev => ({
|
||||
...prev,
|
||||
[server.id]: {
|
||||
serverId: server.id,
|
||||
online: true,
|
||||
metrics,
|
||||
lastUpdate: Date.now(),
|
||||
},
|
||||
}));
|
||||
} catch (error) {
|
||||
setStatuses(prev => ({
|
||||
...prev,
|
||||
[server.id]: {
|
||||
serverId: server.id,
|
||||
online: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
lastUpdate: Date.now(),
|
||||
},
|
||||
}));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Initial fetch
|
||||
servers.forEach(server => {
|
||||
if (server.enabled) {
|
||||
fetchMetrics(server);
|
||||
let cancelled = false;
|
||||
let pendingTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let socket: WebSocket | null = null;
|
||||
const url = monitorWsURL();
|
||||
|
||||
const clearPending = () => {
|
||||
if (pendingTimer !== undefined) {
|
||||
clearTimeout(pendingTimer);
|
||||
pendingTimer = undefined;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Set up polling
|
||||
const timer = setInterval(() => {
|
||||
servers.forEach(server => {
|
||||
if (server.enabled) {
|
||||
fetchMetrics(server);
|
||||
const scheduleConnect = (delayMs: number) => {
|
||||
clearPending();
|
||||
pendingTimer = window.setTimeout(() => {
|
||||
pendingTimer = undefined;
|
||||
if (!cancelled) openSocket();
|
||||
}, delayMs);
|
||||
};
|
||||
|
||||
const openSocket = () => {
|
||||
if (cancelled) return;
|
||||
clearPending();
|
||||
try {
|
||||
socket = new WebSocket(url);
|
||||
} catch {
|
||||
if (!cancelled) scheduleConnect(2000);
|
||||
return;
|
||||
}
|
||||
|
||||
const ws = socket;
|
||||
ws.onmessage = (ev) => {
|
||||
try {
|
||||
const msg = JSON.parse(ev.data as string) as {
|
||||
type?: string;
|
||||
data?: Record<string, ServerStatus>;
|
||||
};
|
||||
if (msg.type === 'snapshot' && msg.data && typeof msg.data === 'object') {
|
||||
setStatuses((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const [id, st] of Object.entries(msg.data!)) {
|
||||
if (st && typeof st === 'object' && 'serverId' in st) {
|
||||
next[id] = st as ServerStatus;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
/* ignore bad frame */
|
||||
}
|
||||
});
|
||||
}, interval);
|
||||
};
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [servers, interval, fetchMetrics]);
|
||||
ws.onclose = () => {
|
||||
socket = null;
|
||||
if (cancelled) return;
|
||||
scheduleConnect(2000);
|
||||
};
|
||||
};
|
||||
|
||||
// 推迟到下个 macrotask,避免 React StrictMode 开发环境下首次 effect 立即卸载时
|
||||
// 在 CONNECTING 阶段 close() 触发浏览器 “closed before connection is established” 噪声。
|
||||
scheduleConnect(0);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearPending();
|
||||
if (socket) {
|
||||
detachWebSocket(socket);
|
||||
socket.close();
|
||||
socket = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return statuses;
|
||||
};
|
||||
|
||||
@@ -13,42 +13,49 @@
|
||||
}
|
||||
|
||||
:root {
|
||||
/* Theme Colors - 淡绿色/淡黄绿色渐变 */
|
||||
--primary-color: #86efac;
|
||||
--primary-hover: #6ee7b7;
|
||||
--secondary-color: #a7f3d0;
|
||||
/* Pastel / glass accents(参考玻璃拟态面板) */
|
||||
--primary-color: #7dd3fc;
|
||||
--primary-hover: #38bdf8;
|
||||
--secondary-color: #c4b5fd;
|
||||
--accent-rose: #f9a8d4;
|
||||
--warning-color: #fde047;
|
||||
--danger-color: #f87171;
|
||||
--success-color: #86efac;
|
||||
|
||||
/* Backgrounds - 纯白色 */
|
||||
--bg-color: #ffffff;
|
||||
--surface-color: #ffffff;
|
||||
--surface-hover: #ffffff;
|
||||
|
||||
--success-color: #6ee7b7;
|
||||
|
||||
/* Glass surfaces(--bg-color 用于表单、凹槽等需可读底色的区域) */
|
||||
--bg-color: rgba(255, 255, 255, 0.72);
|
||||
--surface-color: rgba(255, 255, 255, 0.52);
|
||||
--surface-hover: rgba(255, 255, 255, 0.64);
|
||||
--glass-border: rgba(255, 255, 255, 0.45);
|
||||
--glass-shadow: 0 8px 32px rgba(99, 102, 241, 0.08), 0 2px 12px rgba(15, 23, 42, 0.06);
|
||||
--backdrop-blur: blur(12px);
|
||||
|
||||
/* Text */
|
||||
--text-main: #0f172a;
|
||||
--text-main: #1e293b;
|
||||
--text-secondary: #64748b;
|
||||
--text-light: #94a3b8;
|
||||
|
||||
/* Borders & Shadows - 灰白色边框 */
|
||||
--border-color: #e2e8f0;
|
||||
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
||||
|
||||
/* Radius */
|
||||
--radius-sm: 0.375rem;
|
||||
--radius-md: 0.5rem;
|
||||
--radius-lg: 1rem;
|
||||
|
||||
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||
|
||||
--border-color: rgba(255, 255, 255, 0.55);
|
||||
--shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.06);
|
||||
--shadow-md: var(--glass-shadow);
|
||||
--shadow-lg: 0 16px 48px rgba(99, 102, 241, 0.12), 0 4px 16px rgba(15, 23, 42, 0.08);
|
||||
|
||||
--radius-sm: 0.25rem;
|
||||
--radius-md: 0.375rem;
|
||||
--radius-lg: 0.5rem;
|
||||
--radius-glass: 0.625rem;
|
||||
|
||||
--font-ui:
|
||||
system-ui, -apple-system, 'Segoe UI', Roboto, 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
|
||||
font-family: var(--font-ui);
|
||||
font-size: 100%;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light;
|
||||
color: var(--text-main);
|
||||
background-color: var(--bg-color);
|
||||
background-color: transparent;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
@@ -60,7 +67,13 @@ body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
background-color: var(--bg-color);
|
||||
background-color: rgba(250, 245, 255, 0.4);
|
||||
}
|
||||
|
||||
#root {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
|
||||
@@ -24,6 +24,13 @@ export interface MemoryMetrics {
|
||||
usedBytes: number;
|
||||
freeBytes: number;
|
||||
usedPercent: number;
|
||||
/** 交换分区 / swap 总量(字节),由新版采集端上报 */
|
||||
swapTotalBytes?: number;
|
||||
swapUsedBytes?: number;
|
||||
swapFreeBytes?: number;
|
||||
swapUsedPercent?: number;
|
||||
/** file / partition / zram 等,来自 /proc/swaps;「无」表示未配置 swap */
|
||||
swapType?: string;
|
||||
}
|
||||
|
||||
export interface StorageMetrics {
|
||||
@@ -55,6 +62,10 @@ export interface NetworkInterface {
|
||||
|
||||
export interface SystemStats {
|
||||
processCount: number;
|
||||
/** 当前登录会话数(who 行数);新版采集端上报 */
|
||||
loggedInUsers?: number;
|
||||
/** TCP 套接字数(/proc/net/tcp + tcp6,含监听与各状态);新版采集端上报 */
|
||||
tcpConnections?: number;
|
||||
packageCount: number;
|
||||
packageManager: string;
|
||||
temperature?: number;
|
||||
|
||||
@@ -43,3 +43,10 @@ export const formatUptime = (seconds: number): string => {
|
||||
export const formatPercent = (value: number): string => {
|
||||
return `${Math.round(value * 10) / 10}%`;
|
||||
};
|
||||
|
||||
/** 采集端上报的毫秒延迟(与 Go 侧 TCP 探测展示一致) */
|
||||
export const formatLatencyMs = (ms: number): string => {
|
||||
if (!Number.isFinite(ms) || ms < 0) return '超时';
|
||||
if (ms < 1) return `${ms.toFixed(1)} ms`;
|
||||
return `${Math.round(ms)} ms`;
|
||||
};
|
||||
|
||||
@@ -8,7 +8,7 @@ export default defineConfig({
|
||||
react(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
includeAssets: ['logo.svg'],
|
||||
includeAssets: ['logo.png', 'logo2.png', 'favicon.ico'],
|
||||
manifest: {
|
||||
name: '萌芽监控面板',
|
||||
short_name: '萌芽监控',
|
||||
@@ -20,12 +20,15 @@ export default defineConfig({
|
||||
scope: '/',
|
||||
start_url: '/',
|
||||
icons: [
|
||||
{ src: '/logo.svg', sizes: 'any', type: 'image/svg+xml', purpose: 'any maskable' }
|
||||
{ src: '/logo.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
|
||||
{ src: '/logo2.png', sizes: '512x512', type: 'image/png', purpose: 'any maskable' },
|
||||
],
|
||||
categories: ['utilities', 'productivity']
|
||||
},
|
||||
workbox: {
|
||||
globPatterns: ['**/*.{js,css,html,ico,svg,woff2}'],
|
||||
// 避免开发/生产 SPA 回退误吞 /central-api 转发
|
||||
navigateFallbackDenylist: [/^\/central-api(?:\/|$)/],
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: /^https:\/\/.*\/api\//i,
|
||||
@@ -47,5 +50,13 @@ export default defineConfig({
|
||||
server: {
|
||||
port: 2929,
|
||||
host: true,
|
||||
proxy: {
|
||||
'/central-api': {
|
||||
target: 'http://127.0.0.1:9393',
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
rewrite: (path) => path.replace(/^\/central-api/, ''),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
echo 启动后端服务器...
|
||||
cd mengyadriftbottle-backend
|
||||
cd mengyamonitor-backend-server
|
||||
go mod tidy
|
||||
go run main.go
|
||||
|
||||
28
公网部署/grpc.monitor.api.shumengya.top.conf
Normal file
28
公网部署/grpc.monitor.api.shumengya.top.conf
Normal file
@@ -0,0 +1,28 @@
|
||||
# =============================================================================
|
||||
# 萌芽监控 — 采集端 gRPC(TCP 四层透传,与 Gitea SSH 反代同写法)
|
||||
# =============================================================================
|
||||
# 本文件只能被 nginx 主配置里**顶层**的 stream { } 引入,不可放进取 http { } 的 sites 目录。
|
||||
#
|
||||
# 在 /etc/nginx/nginx.conf 中与 http { } 平级增加(路径按你实际放置修改):
|
||||
# stream {
|
||||
# include /etc/nginx/stream-enabled/grpc.monitor.api.shumengya.top.conf;
|
||||
# }
|
||||
#
|
||||
# 公网监听端口默认 9394(与中心 GRPC_PORT 一致,采集端好记);若冲突可改为其它端口并放行防火墙。
|
||||
# 上游:内网中心宿主机:9394,或 frp/nps 在本机打开的穿透端口(如 127.0.0.1:7001)。
|
||||
#
|
||||
# 采集端(与当前仓库默认 insecure 明文 gRPC 一致):
|
||||
# CENTRAL_GRPC=公网VPS_IP:9394
|
||||
# 若用域名,须 DNS 指向该 VPS(TCP 无 TLS,安全依赖防火墙 / IP 白名单 / 仅穿透不暴露公网)。
|
||||
# =============================================================================
|
||||
|
||||
upstream mengyamonitor_grpc_backend {
|
||||
server 10.1.1.233:9394;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 9394;
|
||||
proxy_pass mengyamonitor_grpc_backend;
|
||||
proxy_timeout 1h;
|
||||
proxy_connect_timeout 10s;
|
||||
}
|
||||
75
公网部署/mengyamonitor-frontend.conf
Normal file
75
公网部署/mengyamonitor-frontend.conf
Normal file
@@ -0,0 +1,75 @@
|
||||
# 内网萌芽监控静态站(示例:6767,公网 monitor.shumengya.top 反代到本机)。
|
||||
# root 改为你的 Vite 构建产物目录(npm run build 后的 dist 内容)。
|
||||
server
|
||||
{
|
||||
listen 6767;
|
||||
server_name 192.168.1.100_6767;
|
||||
index index.html index.htm default.htm default.html;
|
||||
root /shumengya/www/self-made/mengyamonitor-frontend;
|
||||
include /www/server/panel/vhost/nginx/extension/192.168.1.100_6767/*.conf;
|
||||
#CERT-APPLY-CHECK--START
|
||||
# 用于SSL证书申请时的文件验证相关配置 -- 请勿删除并保持这段设置在优先级高的位置
|
||||
include /www/server/panel/vhost/nginx/well-known/192.168.1.100_6767.conf;
|
||||
#CERT-APPLY-CHECK--END
|
||||
|
||||
#SSL-START SSL相关配置,请勿删除或修改下一行带注释的404规则
|
||||
#error_page 404/404.html;
|
||||
#SSL-END
|
||||
|
||||
#ERROR-PAGE-START 错误页配置,可以注释、删除或修改
|
||||
#error_page 404 /404.html;
|
||||
#error_page 502 /502.html;
|
||||
#ERROR-PAGE-END
|
||||
|
||||
#REWRITE-START URL重写规则引用,修改后将导致面板设置的伪静态规则失效
|
||||
include /www/server/panel/vhost/rewrite/html_192.168.1.100_6767.conf;
|
||||
#REWRITE-END
|
||||
|
||||
# SPA:浏览器刷新 / 深度链接须回到 index.html;勿删,否则前端路由 404
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-cache";
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# 禁止访问的敏感文件
|
||||
location ~* (\.user.ini|\.htaccess|\.htpasswd|\.env.*|\.project|\.bashrc|\.bash_profile|\.bash_logout|\.DS_Store|\.gitignore|\.gitattributes|LICENSE|README\.md|CLAUDE\.md|CHANGELOG\.md|CHANGELOG|CONTRIBUTING\.md|TODO\.md|FAQ\.md|composer\.json|composer\.lock|package(-lock)?\.json|yarn\.lock|pnpm-lock\.yaml|\.\w+~|\.swp|\.swo|\.bak(up)?|\.old|\.tmp|\.temp|\.log|\.sql(\.gz)?|docker-compose\.yml|docker\.env|Dockerfile|\.csproj|\.sln|Cargo\.toml|Cargo\.lock|go\.mod|go\.sum|phpunit\.xml|phpunit\.xml|pom\.xml|build\.gradl|pyproject\.toml|requirements\.txt|application(-\w+)?\.(ya?ml|properties))$
|
||||
{
|
||||
return 404;
|
||||
}
|
||||
|
||||
# 禁止访问的敏感目录
|
||||
location ~* /(\.git|\.svn|\.bzr|\.vscode|\.claude|\.idea|\.ssh|\.github|\.npm|\.yarn|\.pnpm|\.cache|\.husky|\.turbo|\.next|\.nuxt|node_modules|runtime)/ {
|
||||
return 404;
|
||||
}
|
||||
|
||||
#一键申请SSL证书验证目录相关设置
|
||||
location ~ \.well-known{
|
||||
allow all;
|
||||
}
|
||||
|
||||
#禁止在证书验证目录放入敏感文件
|
||||
if ( $uri ~ "^/\.well-known/.*\.(php|jsp|py|js|css|lua|ts|go|zip|tar\.gz|rar|7z|sql|bak)$" ) {
|
||||
return 403;
|
||||
}
|
||||
|
||||
location ~ .*\\.(gif|jpg|jpeg|png|bmp|swf)$
|
||||
{
|
||||
expires 30d;
|
||||
error_log /dev/null;
|
||||
access_log /dev/null;
|
||||
}
|
||||
|
||||
# 带 hash 的前端资源(Vite /assets);短缓存,发版靠文件名变化
|
||||
location ~* \.(js|mjs|css|woff2?|svg|ico)$
|
||||
{
|
||||
expires 12h;
|
||||
add_header Cache-Control "public";
|
||||
error_log /dev/null;
|
||||
access_log /dev/null;
|
||||
}
|
||||
access_log /www/wwwlogs/192.168.1.100_6767.log;
|
||||
error_log /www/wwwlogs/192.168.1.100_6767.error.log;
|
||||
}
|
||||
47
公网部署/monitor.api.shumengya.top.conf
Normal file
47
公网部署/monitor.api.shumengya.top.conf
Normal file
@@ -0,0 +1,47 @@
|
||||
# 萌芽监控 — 浏览器 / 前端对接 API(HTTPS)
|
||||
# 仅 REST + WebSocket → 9393。采集端 gRPC 为 **stream TCP 透传**,见 grpc.monitor.api.shumengya.top.conf(并入 nginx 顶层 stream{})。
|
||||
# 上游 IP 按实际修改(内网中心或可路由到的穿透内网地址)。
|
||||
|
||||
upstream mengyamonitor_http {
|
||||
server 10.1.1.233:9393;
|
||||
keepalive 32;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name monitor.api.shumengya.top;
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
http2 on;
|
||||
server_name monitor.api.shumengya.top;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/monitor.api.shumengya.top/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/monitor.api.shumengya.top/privkey.pem;
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://mengyamonitor_http;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_send_timeout 86400s;
|
||||
}
|
||||
}
|
||||
42
公网部署/monitor.shumengya.top.conf
Normal file
42
公网部署/monitor.shumengya.top.conf
Normal file
@@ -0,0 +1,42 @@
|
||||
# 萌芽监控 — 前端 monitor.shumengya.top(HTTPS)
|
||||
# 内网静态源:按需修改 proxy_pass
|
||||
|
||||
upstream mengyamonitor_frontend_upstream {
|
||||
server 10.1.1.100:6767;
|
||||
keepalive 16;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name monitor.shumengya.top;
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
http2 on;
|
||||
server_name monitor.shumengya.top;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/monitor.shumengya.top/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/monitor.shumengya.top/privkey.pem;
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://mengyamonitor_frontend_upstream;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
}
|
||||
}
|
||||
8
需求.txt
8
需求.txt
@@ -1,8 +0,0 @@
|
||||
1.实现一个服务器监控面板,目前初步支持Linux服务器,监控服务器基本信息,比如:cpu/GPU,内存,储存,操作系统等
|
||||
2.采用企业级前后端分离架构,前端使用React框架,后端使用golang原版自带的net库
|
||||
3.原理十分简单,后端go获取Linux相关硬件信息,并封装成相关后端json API路由,前端每隔1秒或者2秒获取后端信息
|
||||
4.对外访问使用默认端口:2929 后端默认端口为9292
|
||||
5.由于是监控面板 前端可以一次性对接多个服务器后端卡片式展示相关信息,前端面板可以展示一些基本信息,用户可以点击详情按钮查看更多详细信息
|
||||
6.前端项目已经初始化 前后端代码架构必须分类整齐符合标准方便我阅读修改
|
||||
7.注意前端相当于一个只是客户端用于请求可以配置后端服务器,这两个是分开的,前端可以打包部署在用户电脑上比如做成软件app,而后端构建打包后放到各个服务器上
|
||||
8.前端页面风格偏白色柔和风
|
||||
Reference in New Issue
Block a user