chore: sync local updates

This commit is contained in:
2026-05-16 19:03:34 +08:00
parent 9c086c3301
commit 80cf54a201
89 changed files with 10622 additions and 2443 deletions

17
.gitignore vendored
View File

@@ -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/

View 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

View 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

View 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)
}

View 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
}

View 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",
}

View 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
)

View 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=

View 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
}
}
}
}

View 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
}

View 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)
}

View 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"]
// 优先使用 MemAvailableLinux 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, " + ")
}

View File

@@ -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
}

View File

@@ -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"` // 镜像名列表
}
// 操作系统相关信息

View File

@@ -0,0 +1,9 @@
data/
.git
.gitignore
*.md
.env
.env.*
*.exe
mengyamonitor-backend-server
__debug_bin

View File

@@ -0,0 +1,21 @@
# 中心服务HTTP/WebSocket + gRPC纯 Go / modernc SQLiteCGO_ENABLED=0
# 镜像内进程为明文 HTTPHTTPS 请在宿主机 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"]

View 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"]

View 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

View File

@@ -0,0 +1,20 @@
# 使用 release/mengyamonitor-central-linux-amd64 打包镜像(先在 Windows 运行 build-linux-amd64.bat
# 容器内仍是 HTTP:9393 / 9394HTTPS 仅公网 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

View File

@@ -0,0 +1,23 @@
# 中心服务在容器内仅为 HTTP9393 Web/WS、9394 gRPCTLS 由公网 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

View 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
)

View 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=

View 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
}

View 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
}

View 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",
}

View 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)
}

View 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})
}

View 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",
},
})
}

View 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
}
}
}
}

View 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()
}

View 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"},
},
})
}
}
}

View 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()
}
}

View 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"`
}

View 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
}

View 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, &notnull, &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
}

View 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)
}
}

View 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;
}

View File

@@ -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

View File

@@ -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

View File

@@ -1,3 +0,0 @@
module mengyamonitor-backend
go 1.22

View File

@@ -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
}

View File

@@ -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)
})
}

View File

@@ -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"]
// 优先使用 MemAvailableLinux 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
}

View 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勿与混合内容冲突

View File

@@ -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 轮询。
## 部署

View File

@@ -0,0 +1 @@
if('serviceWorker' in navigator) navigator.serviceWorker.register('/dev-sw.js?dev-sw', { scope: '/', type: 'classic' })

View 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} didnt 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');
}));

File diff suppressed because it is too large Load Diff

View File

@@ -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="萌芽监控" />

View File

@@ -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",

View File

@@ -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"
},

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

View File

@@ -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

View File

@@ -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;

View File

@@ -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>

View 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';

View File

@@ -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;
}
};

View 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;
}

View 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_IDAGENT_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> IDSERVER_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>
);
};

View File

@@ -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;
}

View File

@@ -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>
);
};

View File

@@ -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%);
}

View File

@@ -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
);
};

View File

@@ -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);
}

View File

@@ -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>

View File

@@ -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">

View 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 };
};

View File

@@ -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;
};

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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`;
};

View File

@@ -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/, ''),
},
},
},
})

View File

@@ -1,6 +1,6 @@
@echo off
chcp 65001 >nul
echo 启动后端服务器...
cd mengyadriftbottle-backend
cd mengyamonitor-backend-server
go mod tidy
go run main.go

View File

@@ -0,0 +1,28 @@
# =============================================================================
# 萌芽监控 — 采集端 gRPCTCP 四层透传,与 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 指向该 VPSTCP 无 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;
}

View 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;
}

View File

@@ -0,0 +1,47 @@
# 萌芽监控 — 浏览器 / 前端对接 APIHTTPS
# 仅 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;
}
}

View File

@@ -0,0 +1,42 @@
# 萌芽监控 — 前端 monitor.shumengya.topHTTPS
# 内网静态源:按需修改 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;
}
}

View File

@@ -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.前端页面风格偏白色柔和风