chore: sync local updates
This commit is contained in:
17
.gitignore
vendored
17
.gitignore
vendored
@@ -15,9 +15,9 @@ Desktop.ini
|
|||||||
*~
|
*~
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
*.sublime-project
|
*.sublime-project
|
||||||
*.sublime-workspace
|
*.sublime-workspace
|
||||||
debug-logs/
|
debug-logs/
|
||||||
|
|
||||||
# Backend (Go)
|
# Backend (Go)
|
||||||
/mengyamonitor-backend/mengyamonitor-backend
|
/mengyamonitor-backend/mengyamonitor-backend
|
||||||
@@ -31,6 +31,17 @@ debug-logs/
|
|||||||
/mengyamonitor-backend/debug
|
/mengyamonitor-backend/debug
|
||||||
/mengyamonitor-backend/__debug_bin
|
/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)
|
# Frontend (Node/React/Vite)
|
||||||
/mengyamonitor-frontend/node_modules/
|
/mengyamonitor-frontend/node_modules/
|
||||||
/mengyamonitor-frontend/dist/
|
/mengyamonitor-frontend/dist/
|
||||||
|
|||||||
40
mengyamonitor-backend-client/build_linux.ps1
Normal file
40
mengyamonitor-backend-client/build_linux.ps1
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# Cross-compile Linux agent (amd64 + arm64). Same as build_linux.sh.
|
||||||
|
# Usage: cd mengyamonitor-backend-client; .\build_linux.ps1
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$Root = $PSScriptRoot
|
||||||
|
Set-Location $Root
|
||||||
|
|
||||||
|
$name = if ($env:BINARY_NAME) { $env:BINARY_NAME } else { "mengyamonitor-agent" }
|
||||||
|
$out = if ($env:OUT_DIR) { $env:OUT_DIR } else { "dist" }
|
||||||
|
|
||||||
|
New-Item -ItemType Directory -Force -Path (Join-Path $out "linux_amd64"), (Join-Path $out "linux_arm64") | Out-Null
|
||||||
|
|
||||||
|
$env:CGO_ENABLED = "0"
|
||||||
|
|
||||||
|
Write-Host "==> linux/amd64 -> $out/linux_amd64/$name"
|
||||||
|
$env:GOOS = "linux"
|
||||||
|
$env:GOARCH = "amd64"
|
||||||
|
try {
|
||||||
|
$destAmd = Join-Path (Join-Path $out "linux_amd64") $name
|
||||||
|
go build -trimpath -ldflags="-s -w" -o $destAmd .
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
Remove-Item Env:GOOS, Env:GOARCH -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "==> linux/arm64 -> $out/linux_arm64/$name"
|
||||||
|
$env:GOOS = "linux"
|
||||||
|
$env:GOARCH = "arm64"
|
||||||
|
try {
|
||||||
|
$destArm = Join-Path (Join-Path $out "linux_arm64") $name
|
||||||
|
go build -trimpath -ldflags="-s -w" -o $destArm .
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
Remove-Item Env:GOOS, Env:GOARCH -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Done."
|
||||||
|
$p1 = Join-Path (Join-Path $out "linux_amd64") $name
|
||||||
|
$p2 = Join-Path (Join-Path $out "linux_arm64") $name
|
||||||
|
Get-Item -LiteralPath $p1, $p2 | Format-Table -Property FullName, Length
|
||||||
39
mengyamonitor-backend-client/build_linux.sh
Normal file
39
mengyamonitor-backend-client/build_linux.sh
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 一键交叉编译 Linux 采集端(amd64 + arm64),输出与 dist/install_mengyamonitor.sh 发布路径一致。
|
||||||
|
#
|
||||||
|
# 用法(在 mengyamonitor-backend-client 目录下):
|
||||||
|
# chmod +x build_linux.sh && ./build_linux.sh
|
||||||
|
#
|
||||||
|
# Windows: Git Bash 同上;或在项目目录执行: powershell -ExecutionPolicy Bypass -File .\build_linux.ps1
|
||||||
|
#
|
||||||
|
# 可选环境变量:
|
||||||
|
# BINARY_NAME 默认 mengyamonitor-agent
|
||||||
|
# OUT_DIR 默认 dist(相对本脚本所在目录)
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
NAME="${BINARY_NAME:-mengyamonitor-agent}"
|
||||||
|
OUT="${OUT_DIR:-dist}"
|
||||||
|
|
||||||
|
mkdir -p "$OUT/linux_amd64" "$OUT/linux_arm64"
|
||||||
|
|
||||||
|
export CGO_ENABLED=0
|
||||||
|
|
||||||
|
echo "==> linux/amd64 -> $OUT/linux_amd64/$NAME"
|
||||||
|
GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o "$OUT/linux_amd64/$NAME" .
|
||||||
|
|
||||||
|
echo "==> linux/arm64 -> $OUT/linux_arm64/$NAME"
|
||||||
|
GOOS=linux GOARCH=arm64 go build -trimpath -ldflags="-s -w" -o "$OUT/linux_arm64/$NAME" .
|
||||||
|
|
||||||
|
echo "完成。产物:"
|
||||||
|
for arch in amd64 arm64; do
|
||||||
|
f="$OUT/linux_${arch}/$NAME"
|
||||||
|
if command -v ls >/dev/null 2>&1; then
|
||||||
|
ls -la "$f"
|
||||||
|
else
|
||||||
|
echo " $f"
|
||||||
|
fi
|
||||||
|
done
|
||||||
25
mengyamonitor-backend-client/dashboard_payload.go
Normal file
25
mengyamonitor-backend-client/dashboard_payload.go
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "encoding/json"
|
||||||
|
|
||||||
|
// BuildDashboardPayloadJSON builds the same JSON shape the browser expects (including latency).
|
||||||
|
// collectorToCentralMs is 采集器到监控中心 TCP 建连毫秒数,失败传 -1。
|
||||||
|
func BuildDashboardPayloadJSON(collectorToCentralMs float64) ([]byte, error) {
|
||||||
|
m, err := CollectMetrics()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(m)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var o map[string]any
|
||||||
|
if err := json.Unmarshal(b, &o); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
o["latency"] = map[string]any{
|
||||||
|
"clientToServer": collectorToCentralMs,
|
||||||
|
"external": readExternalLatencies(),
|
||||||
|
}
|
||||||
|
return json.Marshal(o)
|
||||||
|
}
|
||||||
492
mengyamonitor-backend-client/gen/mengyav1/agent.pb.go
Normal file
492
mengyamonitor-backend-client/gen/mengyav1/agent.pb.go
Normal file
@@ -0,0 +1,492 @@
|
|||||||
|
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// protoc-gen-go v1.36.5
|
||||||
|
// protoc v5.28.3
|
||||||
|
// source: mengya/v1/agent.proto
|
||||||
|
|
||||||
|
package mengyav1
|
||||||
|
|
||||||
|
import (
|
||||||
|
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||||
|
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||||
|
reflect "reflect"
|
||||||
|
sync "sync"
|
||||||
|
unsafe "unsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Verify that this generated code is sufficiently up-to-date.
|
||||||
|
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
|
||||||
|
// Verify that runtime/protoimpl is sufficiently up-to-date.
|
||||||
|
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
|
||||||
|
)
|
||||||
|
|
||||||
|
type AgentMessage struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
// Types that are valid to be assigned to Payload:
|
||||||
|
//
|
||||||
|
// *AgentMessage_Hello
|
||||||
|
// *AgentMessage_Heartbeat
|
||||||
|
// *AgentMessage_Metrics
|
||||||
|
Payload isAgentMessage_Payload `protobuf_oneof:"payload"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *AgentMessage) Reset() {
|
||||||
|
*x = AgentMessage{}
|
||||||
|
mi := &file_mengya_v1_agent_proto_msgTypes[0]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *AgentMessage) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*AgentMessage) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *AgentMessage) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_mengya_v1_agent_proto_msgTypes[0]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use AgentMessage.ProtoReflect.Descriptor instead.
|
||||||
|
func (*AgentMessage) Descriptor() ([]byte, []int) {
|
||||||
|
return file_mengya_v1_agent_proto_rawDescGZIP(), []int{0}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *AgentMessage) GetPayload() isAgentMessage_Payload {
|
||||||
|
if x != nil {
|
||||||
|
return x.Payload
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *AgentMessage) GetHello() *Hello {
|
||||||
|
if x != nil {
|
||||||
|
if x, ok := x.Payload.(*AgentMessage_Hello); ok {
|
||||||
|
return x.Hello
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *AgentMessage) GetHeartbeat() *Heartbeat {
|
||||||
|
if x != nil {
|
||||||
|
if x, ok := x.Payload.(*AgentMessage_Heartbeat); ok {
|
||||||
|
return x.Heartbeat
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *AgentMessage) GetMetrics() *MetricsReport {
|
||||||
|
if x != nil {
|
||||||
|
if x, ok := x.Payload.(*AgentMessage_Metrics); ok {
|
||||||
|
return x.Metrics
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type isAgentMessage_Payload interface {
|
||||||
|
isAgentMessage_Payload()
|
||||||
|
}
|
||||||
|
|
||||||
|
type AgentMessage_Hello struct {
|
||||||
|
Hello *Hello `protobuf:"bytes,1,opt,name=hello,proto3,oneof"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AgentMessage_Heartbeat struct {
|
||||||
|
Heartbeat *Heartbeat `protobuf:"bytes,2,opt,name=heartbeat,proto3,oneof"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AgentMessage_Metrics struct {
|
||||||
|
Metrics *MetricsReport `protobuf:"bytes,3,opt,name=metrics,proto3,oneof"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*AgentMessage_Hello) isAgentMessage_Payload() {}
|
||||||
|
|
||||||
|
func (*AgentMessage_Heartbeat) isAgentMessage_Payload() {}
|
||||||
|
|
||||||
|
func (*AgentMessage_Metrics) isAgentMessage_Payload() {}
|
||||||
|
|
||||||
|
type Hello struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
ServerId string `protobuf:"bytes,1,opt,name=server_id,json=serverId,proto3" json:"server_id,omitempty"`
|
||||||
|
AgentKey string `protobuf:"bytes,2,opt,name=agent_key,json=agentKey,proto3" json:"agent_key,omitempty"`
|
||||||
|
Hostname string `protobuf:"bytes,3,opt,name=hostname,proto3" json:"hostname,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Hello) Reset() {
|
||||||
|
*x = Hello{}
|
||||||
|
mi := &file_mengya_v1_agent_proto_msgTypes[1]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Hello) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*Hello) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *Hello) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_mengya_v1_agent_proto_msgTypes[1]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use Hello.ProtoReflect.Descriptor instead.
|
||||||
|
func (*Hello) Descriptor() ([]byte, []int) {
|
||||||
|
return file_mengya_v1_agent_proto_rawDescGZIP(), []int{1}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Hello) GetServerId() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.ServerId
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Hello) GetAgentKey() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.AgentKey
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Hello) GetHostname() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Hostname
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
type Heartbeat struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Heartbeat) Reset() {
|
||||||
|
*x = Heartbeat{}
|
||||||
|
mi := &file_mengya_v1_agent_proto_msgTypes[2]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Heartbeat) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*Heartbeat) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *Heartbeat) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_mengya_v1_agent_proto_msgTypes[2]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use Heartbeat.ProtoReflect.Descriptor instead.
|
||||||
|
func (*Heartbeat) Descriptor() ([]byte, []int) {
|
||||||
|
return file_mengya_v1_agent_proto_rawDescGZIP(), []int{2}
|
||||||
|
}
|
||||||
|
|
||||||
|
type MetricsReport struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
PayloadJson string `protobuf:"bytes,1,opt,name=payload_json,json=payloadJson,proto3" json:"payload_json,omitempty"`
|
||||||
|
CollectedAtUnixMs int64 `protobuf:"varint,2,opt,name=collected_at_unix_ms,json=collectedAtUnixMs,proto3" json:"collected_at_unix_ms,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *MetricsReport) Reset() {
|
||||||
|
*x = MetricsReport{}
|
||||||
|
mi := &file_mengya_v1_agent_proto_msgTypes[3]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *MetricsReport) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*MetricsReport) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *MetricsReport) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_mengya_v1_agent_proto_msgTypes[3]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use MetricsReport.ProtoReflect.Descriptor instead.
|
||||||
|
func (*MetricsReport) Descriptor() ([]byte, []int) {
|
||||||
|
return file_mengya_v1_agent_proto_rawDescGZIP(), []int{3}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *MetricsReport) GetPayloadJson() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.PayloadJson
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *MetricsReport) GetCollectedAtUnixMs() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.CollectedAtUnixMs
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
type ControlMessage struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
// Types that are valid to be assigned to Payload:
|
||||||
|
//
|
||||||
|
// *ControlMessage_Ack
|
||||||
|
Payload isControlMessage_Payload `protobuf_oneof:"payload"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *ControlMessage) Reset() {
|
||||||
|
*x = ControlMessage{}
|
||||||
|
mi := &file_mengya_v1_agent_proto_msgTypes[4]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *ControlMessage) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*ControlMessage) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *ControlMessage) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_mengya_v1_agent_proto_msgTypes[4]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use ControlMessage.ProtoReflect.Descriptor instead.
|
||||||
|
func (*ControlMessage) Descriptor() ([]byte, []int) {
|
||||||
|
return file_mengya_v1_agent_proto_rawDescGZIP(), []int{4}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *ControlMessage) GetPayload() isControlMessage_Payload {
|
||||||
|
if x != nil {
|
||||||
|
return x.Payload
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *ControlMessage) GetAck() *Ack {
|
||||||
|
if x != nil {
|
||||||
|
if x, ok := x.Payload.(*ControlMessage_Ack); ok {
|
||||||
|
return x.Ack
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type isControlMessage_Payload interface {
|
||||||
|
isControlMessage_Payload()
|
||||||
|
}
|
||||||
|
|
||||||
|
type ControlMessage_Ack struct {
|
||||||
|
Ack *Ack `protobuf:"bytes,1,opt,name=ack,proto3,oneof"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*ControlMessage_Ack) isControlMessage_Payload() {}
|
||||||
|
|
||||||
|
type Ack struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
Message string `protobuf:"bytes,1,opt,name=message,proto3" json:"message,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Ack) Reset() {
|
||||||
|
*x = Ack{}
|
||||||
|
mi := &file_mengya_v1_agent_proto_msgTypes[5]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Ack) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*Ack) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *Ack) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_mengya_v1_agent_proto_msgTypes[5]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use Ack.ProtoReflect.Descriptor instead.
|
||||||
|
func (*Ack) Descriptor() ([]byte, []int) {
|
||||||
|
return file_mengya_v1_agent_proto_rawDescGZIP(), []int{5}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Ack) GetMessage() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Message
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var File_mengya_v1_agent_proto protoreflect.FileDescriptor
|
||||||
|
|
||||||
|
var file_mengya_v1_agent_proto_rawDesc = string([]byte{
|
||||||
|
0x0a, 0x15, 0x6d, 0x65, 0x6e, 0x67, 0x79, 0x61, 0x2f, 0x76, 0x31, 0x2f, 0x61, 0x67, 0x65, 0x6e,
|
||||||
|
0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x09, 0x6d, 0x65, 0x6e, 0x67, 0x79, 0x61, 0x2e,
|
||||||
|
0x76, 0x31, 0x22, 0xaf, 0x01, 0x0a, 0x0c, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x4d, 0x65, 0x73, 0x73,
|
||||||
|
0x61, 0x67, 0x65, 0x12, 0x28, 0x0a, 0x05, 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x18, 0x01, 0x20, 0x01,
|
||||||
|
0x28, 0x0b, 0x32, 0x10, 0x2e, 0x6d, 0x65, 0x6e, 0x67, 0x79, 0x61, 0x2e, 0x76, 0x31, 0x2e, 0x48,
|
||||||
|
0x65, 0x6c, 0x6c, 0x6f, 0x48, 0x00, 0x52, 0x05, 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x12, 0x34, 0x0a,
|
||||||
|
0x09, 0x68, 0x65, 0x61, 0x72, 0x74, 0x62, 0x65, 0x61, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b,
|
||||||
|
0x32, 0x14, 0x2e, 0x6d, 0x65, 0x6e, 0x67, 0x79, 0x61, 0x2e, 0x76, 0x31, 0x2e, 0x48, 0x65, 0x61,
|
||||||
|
0x72, 0x74, 0x62, 0x65, 0x61, 0x74, 0x48, 0x00, 0x52, 0x09, 0x68, 0x65, 0x61, 0x72, 0x74, 0x62,
|
||||||
|
0x65, 0x61, 0x74, 0x12, 0x34, 0x0a, 0x07, 0x6d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, 0x18, 0x03,
|
||||||
|
0x20, 0x01, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x65, 0x6e, 0x67, 0x79, 0x61, 0x2e, 0x76, 0x31,
|
||||||
|
0x2e, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x48, 0x00,
|
||||||
|
0x52, 0x07, 0x6d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, 0x42, 0x09, 0x0a, 0x07, 0x70, 0x61, 0x79,
|
||||||
|
0x6c, 0x6f, 0x61, 0x64, 0x22, 0x5d, 0x0a, 0x05, 0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x12, 0x1b, 0x0a,
|
||||||
|
0x09, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
|
||||||
|
0x52, 0x08, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x61, 0x67,
|
||||||
|
0x65, 0x6e, 0x74, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x61,
|
||||||
|
0x67, 0x65, 0x6e, 0x74, 0x4b, 0x65, 0x79, 0x12, 0x1a, 0x0a, 0x08, 0x68, 0x6f, 0x73, 0x74, 0x6e,
|
||||||
|
0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x68, 0x6f, 0x73, 0x74, 0x6e,
|
||||||
|
0x61, 0x6d, 0x65, 0x22, 0x0b, 0x0a, 0x09, 0x48, 0x65, 0x61, 0x72, 0x74, 0x62, 0x65, 0x61, 0x74,
|
||||||
|
0x22, 0x63, 0x0a, 0x0d, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, 0x52, 0x65, 0x70, 0x6f, 0x72,
|
||||||
|
0x74, 0x12, 0x21, 0x0a, 0x0c, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x5f, 0x6a, 0x73, 0x6f,
|
||||||
|
0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64,
|
||||||
|
0x4a, 0x73, 0x6f, 0x6e, 0x12, 0x2f, 0x0a, 0x14, 0x63, 0x6f, 0x6c, 0x6c, 0x65, 0x63, 0x74, 0x65,
|
||||||
|
0x64, 0x5f, 0x61, 0x74, 0x5f, 0x75, 0x6e, 0x69, 0x78, 0x5f, 0x6d, 0x73, 0x18, 0x02, 0x20, 0x01,
|
||||||
|
0x28, 0x03, 0x52, 0x11, 0x63, 0x6f, 0x6c, 0x6c, 0x65, 0x63, 0x74, 0x65, 0x64, 0x41, 0x74, 0x55,
|
||||||
|
0x6e, 0x69, 0x78, 0x4d, 0x73, 0x22, 0x3f, 0x0a, 0x0e, 0x43, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c,
|
||||||
|
0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x22, 0x0a, 0x03, 0x61, 0x63, 0x6b, 0x18, 0x01,
|
||||||
|
0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x6d, 0x65, 0x6e, 0x67, 0x79, 0x61, 0x2e, 0x76, 0x31,
|
||||||
|
0x2e, 0x41, 0x63, 0x6b, 0x48, 0x00, 0x52, 0x03, 0x61, 0x63, 0x6b, 0x42, 0x09, 0x0a, 0x07, 0x70,
|
||||||
|
0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x22, 0x1f, 0x0a, 0x03, 0x41, 0x63, 0x6b, 0x12, 0x18, 0x0a,
|
||||||
|
0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07,
|
||||||
|
0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x32, 0x50, 0x0a, 0x0b, 0x41, 0x67, 0x65, 0x6e, 0x74,
|
||||||
|
0x49, 0x6e, 0x67, 0x65, 0x73, 0x74, 0x12, 0x41, 0x0a, 0x07, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63,
|
||||||
|
0x74, 0x12, 0x17, 0x2e, 0x6d, 0x65, 0x6e, 0x67, 0x79, 0x61, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x67,
|
||||||
|
0x65, 0x6e, 0x74, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x19, 0x2e, 0x6d, 0x65, 0x6e,
|
||||||
|
0x67, 0x79, 0x61, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x4d, 0x65,
|
||||||
|
0x73, 0x73, 0x61, 0x67, 0x65, 0x28, 0x01, 0x30, 0x01, 0x42, 0x3d, 0x5a, 0x3b, 0x6d, 0x65, 0x6e,
|
||||||
|
0x67, 0x79, 0x61, 0x6d, 0x6f, 0x6e, 0x69, 0x74, 0x6f, 0x72, 0x2d, 0x62, 0x61, 0x63, 0x6b, 0x65,
|
||||||
|
0x6e, 0x64, 0x2d, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e,
|
||||||
|
0x61, 0x6c, 0x2f, 0x67, 0x65, 0x6e, 0x2f, 0x6d, 0x65, 0x6e, 0x67, 0x79, 0x61, 0x76, 0x31, 0x3b,
|
||||||
|
0x6d, 0x65, 0x6e, 0x67, 0x79, 0x61, 0x76, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
|
||||||
|
})
|
||||||
|
|
||||||
|
var (
|
||||||
|
file_mengya_v1_agent_proto_rawDescOnce sync.Once
|
||||||
|
file_mengya_v1_agent_proto_rawDescData []byte
|
||||||
|
)
|
||||||
|
|
||||||
|
func file_mengya_v1_agent_proto_rawDescGZIP() []byte {
|
||||||
|
file_mengya_v1_agent_proto_rawDescOnce.Do(func() {
|
||||||
|
file_mengya_v1_agent_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_mengya_v1_agent_proto_rawDesc), len(file_mengya_v1_agent_proto_rawDesc)))
|
||||||
|
})
|
||||||
|
return file_mengya_v1_agent_proto_rawDescData
|
||||||
|
}
|
||||||
|
|
||||||
|
var file_mengya_v1_agent_proto_msgTypes = make([]protoimpl.MessageInfo, 6)
|
||||||
|
var file_mengya_v1_agent_proto_goTypes = []any{
|
||||||
|
(*AgentMessage)(nil), // 0: mengya.v1.AgentMessage
|
||||||
|
(*Hello)(nil), // 1: mengya.v1.Hello
|
||||||
|
(*Heartbeat)(nil), // 2: mengya.v1.Heartbeat
|
||||||
|
(*MetricsReport)(nil), // 3: mengya.v1.MetricsReport
|
||||||
|
(*ControlMessage)(nil), // 4: mengya.v1.ControlMessage
|
||||||
|
(*Ack)(nil), // 5: mengya.v1.Ack
|
||||||
|
}
|
||||||
|
var file_mengya_v1_agent_proto_depIdxs = []int32{
|
||||||
|
1, // 0: mengya.v1.AgentMessage.hello:type_name -> mengya.v1.Hello
|
||||||
|
2, // 1: mengya.v1.AgentMessage.heartbeat:type_name -> mengya.v1.Heartbeat
|
||||||
|
3, // 2: mengya.v1.AgentMessage.metrics:type_name -> mengya.v1.MetricsReport
|
||||||
|
5, // 3: mengya.v1.ControlMessage.ack:type_name -> mengya.v1.Ack
|
||||||
|
0, // 4: mengya.v1.AgentIngest.Connect:input_type -> mengya.v1.AgentMessage
|
||||||
|
4, // 5: mengya.v1.AgentIngest.Connect:output_type -> mengya.v1.ControlMessage
|
||||||
|
5, // [5:6] is the sub-list for method output_type
|
||||||
|
4, // [4:5] is the sub-list for method input_type
|
||||||
|
4, // [4:4] is the sub-list for extension type_name
|
||||||
|
4, // [4:4] is the sub-list for extension extendee
|
||||||
|
0, // [0:4] is the sub-list for field type_name
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() { file_mengya_v1_agent_proto_init() }
|
||||||
|
func file_mengya_v1_agent_proto_init() {
|
||||||
|
if File_mengya_v1_agent_proto != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
file_mengya_v1_agent_proto_msgTypes[0].OneofWrappers = []any{
|
||||||
|
(*AgentMessage_Hello)(nil),
|
||||||
|
(*AgentMessage_Heartbeat)(nil),
|
||||||
|
(*AgentMessage_Metrics)(nil),
|
||||||
|
}
|
||||||
|
file_mengya_v1_agent_proto_msgTypes[4].OneofWrappers = []any{
|
||||||
|
(*ControlMessage_Ack)(nil),
|
||||||
|
}
|
||||||
|
type x struct{}
|
||||||
|
out := protoimpl.TypeBuilder{
|
||||||
|
File: protoimpl.DescBuilder{
|
||||||
|
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||||
|
RawDescriptor: unsafe.Slice(unsafe.StringData(file_mengya_v1_agent_proto_rawDesc), len(file_mengya_v1_agent_proto_rawDesc)),
|
||||||
|
NumEnums: 0,
|
||||||
|
NumMessages: 6,
|
||||||
|
NumExtensions: 0,
|
||||||
|
NumServices: 1,
|
||||||
|
},
|
||||||
|
GoTypes: file_mengya_v1_agent_proto_goTypes,
|
||||||
|
DependencyIndexes: file_mengya_v1_agent_proto_depIdxs,
|
||||||
|
MessageInfos: file_mengya_v1_agent_proto_msgTypes,
|
||||||
|
}.Build()
|
||||||
|
File_mengya_v1_agent_proto = out.File
|
||||||
|
file_mengya_v1_agent_proto_goTypes = nil
|
||||||
|
file_mengya_v1_agent_proto_depIdxs = nil
|
||||||
|
}
|
||||||
119
mengyamonitor-backend-client/gen/mengyav1/agent_grpc.pb.go
Normal file
119
mengyamonitor-backend-client/gen/mengyav1/agent_grpc.pb.go
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// - protoc-gen-go-grpc v1.5.1
|
||||||
|
// - protoc v5.28.3
|
||||||
|
// source: mengya/v1/agent.proto
|
||||||
|
|
||||||
|
package mengyav1
|
||||||
|
|
||||||
|
import (
|
||||||
|
context "context"
|
||||||
|
grpc "google.golang.org/grpc"
|
||||||
|
codes "google.golang.org/grpc/codes"
|
||||||
|
status "google.golang.org/grpc/status"
|
||||||
|
)
|
||||||
|
|
||||||
|
// This is a compile-time assertion to ensure that this generated file
|
||||||
|
// is compatible with the grpc package it is being compiled against.
|
||||||
|
// Requires gRPC-Go v1.64.0 or later.
|
||||||
|
const _ = grpc.SupportPackageIsVersion9
|
||||||
|
|
||||||
|
const (
|
||||||
|
AgentIngest_Connect_FullMethodName = "/mengya.v1.AgentIngest/Connect"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AgentIngestClient is the client API for AgentIngest service.
|
||||||
|
//
|
||||||
|
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
|
||||||
|
//
|
||||||
|
// AgentIngest: 采集端出站连接,双向流上报指标。
|
||||||
|
type AgentIngestClient interface {
|
||||||
|
Connect(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[AgentMessage, ControlMessage], error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type agentIngestClient struct {
|
||||||
|
cc grpc.ClientConnInterface
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAgentIngestClient(cc grpc.ClientConnInterface) AgentIngestClient {
|
||||||
|
return &agentIngestClient{cc}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *agentIngestClient) Connect(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[AgentMessage, ControlMessage], error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
stream, err := c.cc.NewStream(ctx, &AgentIngest_ServiceDesc.Streams[0], AgentIngest_Connect_FullMethodName, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
x := &grpc.GenericClientStream[AgentMessage, ControlMessage]{ClientStream: stream}
|
||||||
|
return x, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
||||||
|
type AgentIngest_ConnectClient = grpc.BidiStreamingClient[AgentMessage, ControlMessage]
|
||||||
|
|
||||||
|
// AgentIngestServer is the server API for AgentIngest service.
|
||||||
|
// All implementations must embed UnimplementedAgentIngestServer
|
||||||
|
// for forward compatibility.
|
||||||
|
//
|
||||||
|
// AgentIngest: 采集端出站连接,双向流上报指标。
|
||||||
|
type AgentIngestServer interface {
|
||||||
|
Connect(grpc.BidiStreamingServer[AgentMessage, ControlMessage]) error
|
||||||
|
mustEmbedUnimplementedAgentIngestServer()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnimplementedAgentIngestServer must be embedded to have
|
||||||
|
// forward compatible implementations.
|
||||||
|
//
|
||||||
|
// NOTE: this should be embedded by value instead of pointer to avoid a nil
|
||||||
|
// pointer dereference when methods are called.
|
||||||
|
type UnimplementedAgentIngestServer struct{}
|
||||||
|
|
||||||
|
func (UnimplementedAgentIngestServer) Connect(grpc.BidiStreamingServer[AgentMessage, ControlMessage]) error {
|
||||||
|
return status.Errorf(codes.Unimplemented, "method Connect not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedAgentIngestServer) mustEmbedUnimplementedAgentIngestServer() {}
|
||||||
|
func (UnimplementedAgentIngestServer) testEmbeddedByValue() {}
|
||||||
|
|
||||||
|
// UnsafeAgentIngestServer may be embedded to opt out of forward compatibility for this service.
|
||||||
|
// Use of this interface is not recommended, as added methods to AgentIngestServer will
|
||||||
|
// result in compilation errors.
|
||||||
|
type UnsafeAgentIngestServer interface {
|
||||||
|
mustEmbedUnimplementedAgentIngestServer()
|
||||||
|
}
|
||||||
|
|
||||||
|
func RegisterAgentIngestServer(s grpc.ServiceRegistrar, srv AgentIngestServer) {
|
||||||
|
// If the following call pancis, it indicates UnimplementedAgentIngestServer was
|
||||||
|
// embedded by pointer and is nil. This will cause panics if an
|
||||||
|
// unimplemented method is ever invoked, so we test this at initialization
|
||||||
|
// time to prevent it from happening at runtime later due to I/O.
|
||||||
|
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
|
||||||
|
t.testEmbeddedByValue()
|
||||||
|
}
|
||||||
|
s.RegisterService(&AgentIngest_ServiceDesc, srv)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _AgentIngest_Connect_Handler(srv interface{}, stream grpc.ServerStream) error {
|
||||||
|
return srv.(AgentIngestServer).Connect(&grpc.GenericServerStream[AgentMessage, ControlMessage]{ServerStream: stream})
|
||||||
|
}
|
||||||
|
|
||||||
|
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
||||||
|
type AgentIngest_ConnectServer = grpc.BidiStreamingServer[AgentMessage, ControlMessage]
|
||||||
|
|
||||||
|
// AgentIngest_ServiceDesc is the grpc.ServiceDesc for AgentIngest service.
|
||||||
|
// It's only intended for direct use with grpc.RegisterService,
|
||||||
|
// and not to be introspected or modified (even as a copy)
|
||||||
|
var AgentIngest_ServiceDesc = grpc.ServiceDesc{
|
||||||
|
ServiceName: "mengya.v1.AgentIngest",
|
||||||
|
HandlerType: (*AgentIngestServer)(nil),
|
||||||
|
Methods: []grpc.MethodDesc{},
|
||||||
|
Streams: []grpc.StreamDesc{
|
||||||
|
{
|
||||||
|
StreamName: "Connect",
|
||||||
|
Handler: _AgentIngest_Connect_Handler,
|
||||||
|
ServerStreams: true,
|
||||||
|
ClientStreams: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Metadata: "mengya/v1/agent.proto",
|
||||||
|
}
|
||||||
15
mengyamonitor-backend-client/go.mod
Normal file
15
mengyamonitor-backend-client/go.mod
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
module mengyamonitor-backend
|
||||||
|
|
||||||
|
go 1.22
|
||||||
|
|
||||||
|
require (
|
||||||
|
google.golang.org/grpc v1.70.0
|
||||||
|
google.golang.org/protobuf v1.35.2
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
golang.org/x/net v0.32.0 // indirect
|
||||||
|
golang.org/x/sys v0.28.0 // indirect
|
||||||
|
golang.org/x/text v0.21.0 // indirect
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20241202173237-19429a94021a // indirect
|
||||||
|
)
|
||||||
32
mengyamonitor-backend-client/go.sum
Normal file
32
mengyamonitor-backend-client/go.sum
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||||
|
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
|
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
|
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||||
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
go.opentelemetry.io/otel v1.32.0 h1:WnBN+Xjcteh0zdk01SVqV55d/m62NJLJdIyb4y/WO5U=
|
||||||
|
go.opentelemetry.io/otel v1.32.0/go.mod h1:00DCVSB0RQcnzlwyTfqtxSm+DRr9hpYrHjNGiBHVQIg=
|
||||||
|
go.opentelemetry.io/otel/metric v1.32.0 h1:xV2umtmNcThh2/a/aCP+h64Xx5wsj8qqnkYZktzNa0M=
|
||||||
|
go.opentelemetry.io/otel/metric v1.32.0/go.mod h1:jH7CIbbK6SH2V2wE16W05BHCtIDzauciCRLoc/SyMv8=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.32.0 h1:RNxepc9vK59A8XsgZQouW8ue8Gkb4jpWtJm9ge5lEG4=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.32.0/go.mod h1:LqgegDBjKMmb2GC6/PrTnteJG39I8/vJCAP9LlJXEjU=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.32.0 h1:rZvFnvmvawYb0alrYkjraqJq0Z4ZUJAiyYCU9snn1CU=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.32.0/go.mod h1:PWeZlq0zt9YkYAp3gjKZ0eicRYvOh1Gd+X99x6GHpCQ=
|
||||||
|
go.opentelemetry.io/otel/trace v1.32.0 h1:WIC9mYrXf8TmY/EXuULKc8hR17vE+Hjv2cssQDe03fM=
|
||||||
|
go.opentelemetry.io/otel/trace v1.32.0/go.mod h1:+i4rkvCraA+tG6AzwloGaCtkx53Fa+L+V8e9a7YvhT8=
|
||||||
|
golang.org/x/net v0.32.0 h1:ZqPmj8Kzc+Y6e0+skZsuACbx+wzMgo5MQsJh9Qd6aYI=
|
||||||
|
golang.org/x/net v0.32.0/go.mod h1:CwU0IoeOlnQQWJ6ioyFrfRuomB8GKF6KbYXZVyeXNfs=
|
||||||
|
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
||||||
|
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||||
|
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20241202173237-19429a94021a h1:hgh8P4EuoxpsuKMXX/To36nOFD7vixReXgn8lPGnt+o=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20241202173237-19429a94021a/go.mod h1:5uTbfoYQed2U9p3KIj2/Zzm02PYhndfdmML0qC3q3FU=
|
||||||
|
google.golang.org/grpc v1.70.0 h1:pWFv03aZoHzlRKHWicjsZytKAiYCtNS0dHbXnIdq7jQ=
|
||||||
|
google.golang.org/grpc v1.70.0/go.mod h1:ofIJqVKDXx/JiXrwr2IG4/zwdH9txy3IlF40RmcJSQw=
|
||||||
|
google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io=
|
||||||
|
google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||||
96
mengyamonitor-backend-client/grpc_reporter.go
Normal file
96
mengyamonitor-backend-client/grpc_reporter.go
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
mengyav1 "mengyamonitor-backend/gen/mengyav1"
|
||||||
|
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/credentials/insecure"
|
||||||
|
)
|
||||||
|
|
||||||
|
func runGRPCLoop(addr, serverID, key string) {
|
||||||
|
backoff := time.Second
|
||||||
|
const maxBackoff = 30 * time.Second
|
||||||
|
for {
|
||||||
|
err := runGRPCSession(addr, serverID, key)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("grpc reporter: session ended: %v (reconnect in %v)", err, backoff)
|
||||||
|
time.Sleep(backoff)
|
||||||
|
if backoff < maxBackoff {
|
||||||
|
backoff *= 2
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
backoff = time.Second
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runGRPCSession(addr, serverID, key string) error {
|
||||||
|
ctx := context.Background()
|
||||||
|
conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
cli := mengyav1.NewAgentIngestClient(conn)
|
||||||
|
stream, err := cli.Connect(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
host, _ := os.Hostname()
|
||||||
|
if err := stream.Send(&mengyav1.AgentMessage{
|
||||||
|
Payload: &mengyav1.AgentMessage_Hello{
|
||||||
|
Hello: &mengyav1.Hello{ServerId: serverID, AgentKey: key, Hostname: host},
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
_, err := stream.Recv()
|
||||||
|
if err != nil {
|
||||||
|
errCh <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
tick := time.NewTicker(2 * time.Second)
|
||||||
|
defer tick.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case err := <-errCh:
|
||||||
|
if err == io.EOF {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
case <-tick.C:
|
||||||
|
centralMs := measureCollectorToCentralTCP(addr, 3*time.Second)
|
||||||
|
payload, err := BuildDashboardPayloadJSON(centralMs)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("grpc reporter: collect: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := stream.Send(&mengyav1.AgentMessage{
|
||||||
|
Payload: &mengyav1.AgentMessage_Metrics{
|
||||||
|
Metrics: &mengyav1.MetricsReport{
|
||||||
|
PayloadJson: string(payload),
|
||||||
|
CollectedAtUnixMs: time.Now().UnixMilli(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
78
mengyamonitor-backend-client/latency.go
Normal file
78
mengyamonitor-backend-client/latency.go
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LatencyInfo 延迟信息(均由采集端在本机测量)
|
||||||
|
type LatencyInfo struct {
|
||||||
|
ClientToServer float64 `json:"clientToServer"` // 采集器 → 监控中心 gRPC 地址的 TCP 建连耗时(ms),失败为 -1
|
||||||
|
External map[string]string `json:"external"` // 采集器对外站点的 TCP 建连耗时(同 checkExternalLatency)
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeGRPCDialTarget strips common grpc-go target prefixes; expects host:port or [ipv6]:port.
|
||||||
|
func normalizeGRPCDialTarget(addr string) string {
|
||||||
|
a := strings.TrimSpace(addr)
|
||||||
|
a = strings.TrimPrefix(a, "dns:///")
|
||||||
|
a = strings.TrimPrefix(a, "dns://")
|
||||||
|
return strings.TrimSpace(a)
|
||||||
|
}
|
||||||
|
|
||||||
|
// measureCollectorToCentralTCP returns TCP connect time in ms to the central gRPC dial target, or -1 on failure.
|
||||||
|
func measureCollectorToCentralTCP(grpcAddr string, timeout time.Duration) float64 {
|
||||||
|
target := normalizeGRPCDialTarget(grpcAddr)
|
||||||
|
if target == "" || strings.HasPrefix(strings.ToLower(target), "unix:") {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
start := time.Now()
|
||||||
|
conn, err := net.DialTimeout("tcp", target, timeout)
|
||||||
|
if err != nil {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
_ = conn.Close()
|
||||||
|
return float64(time.Since(start).Microseconds()) / 1000.0
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkExternalLatency 检测外部网站延迟
|
||||||
|
func checkExternalLatency(host string, timeout time.Duration) string {
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
// 尝试 TCP 连接 80 端口
|
||||||
|
conn, err := net.DialTimeout("tcp", host+":80", timeout)
|
||||||
|
if err != nil {
|
||||||
|
// 如果 80 端口失败,尝试 443 (HTTPS)
|
||||||
|
conn, err = net.DialTimeout("tcp", host+":443", timeout)
|
||||||
|
if err != nil {
|
||||||
|
return "超时"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
// 使用微秒换算,避免 Milliseconds() 截断成 0 ms 误导用户
|
||||||
|
ms := float64(time.Since(start).Microseconds()) / 1000.0
|
||||||
|
if ms < 1 {
|
||||||
|
return fmt.Sprintf("%.1f ms", ms)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%.0f ms", ms)
|
||||||
|
}
|
||||||
|
|
||||||
|
// readExternalLatencies 读取外部网站延迟
|
||||||
|
func readExternalLatencies() map[string]string {
|
||||||
|
latencies := make(map[string]string)
|
||||||
|
timeout := 3 * time.Second
|
||||||
|
|
||||||
|
// 检测百度
|
||||||
|
latencies["baidu.com"] = checkExternalLatency("baidu.com", timeout)
|
||||||
|
|
||||||
|
// 检测谷歌
|
||||||
|
latencies["google.com"] = checkExternalLatency("google.com", timeout)
|
||||||
|
|
||||||
|
// 检测 GitHub
|
||||||
|
latencies["github.com"] = checkExternalLatency("github.com", timeout)
|
||||||
|
|
||||||
|
return latencies
|
||||||
|
}
|
||||||
|
|
||||||
17
mengyamonitor-backend-client/main.go
Normal file
17
mengyamonitor-backend-client/main.go
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
addr := os.Getenv("CENTRAL_GRPC")
|
||||||
|
serverID := os.Getenv("SERVER_ID")
|
||||||
|
key := os.Getenv("AGENT_KEY")
|
||||||
|
if addr == "" || serverID == "" || key == "" {
|
||||||
|
log.Fatal("mengyamonitor agent requires CENTRAL_GRPC, SERVER_ID, and AGENT_KEY")
|
||||||
|
}
|
||||||
|
log.Printf("agent reporting via gRPC to %s (server_id=%s)", addr, serverID)
|
||||||
|
runGRPCLoop(addr, serverID, key)
|
||||||
|
}
|
||||||
127
mengyamonitor-backend-client/memory.go
Normal file
127
mengyamonitor-backend-client/memory.go
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// readMemory 读取内存信息
|
||||||
|
func readMemory() (MemoryMetrics, error) {
|
||||||
|
totals := map[string]uint64{}
|
||||||
|
f, err := os.Open("/proc/meminfo")
|
||||||
|
if err != nil {
|
||||||
|
return MemoryMetrics{}, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
scanner := bufio.NewScanner(f)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
parts := strings.Split(line, ":")
|
||||||
|
if len(parts) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := strings.TrimSpace(parts[0])
|
||||||
|
fields := strings.Fields(strings.TrimSpace(parts[1]))
|
||||||
|
if len(fields) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
value, err := strconv.ParseUint(fields[0], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
totals[key] = value * 1024 // kB to bytes
|
||||||
|
}
|
||||||
|
total := totals["MemTotal"]
|
||||||
|
|
||||||
|
// 优先使用 MemAvailable(Linux 3.14+),如果没有则计算
|
||||||
|
var free uint64
|
||||||
|
if available, ok := totals["MemAvailable"]; ok && available > 0 {
|
||||||
|
free = available
|
||||||
|
} else {
|
||||||
|
// 回退到 MemFree + Buffers + Cached(适用于较老的系统)
|
||||||
|
free = totals["MemFree"]
|
||||||
|
if buffers, ok := totals["Buffers"]; ok {
|
||||||
|
free += buffers
|
||||||
|
}
|
||||||
|
if cached, ok := totals["Cached"]; ok {
|
||||||
|
free += cached
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
used := total - free
|
||||||
|
usedPercent := 0.0
|
||||||
|
if total > 0 {
|
||||||
|
usedPercent = (float64(used) / float64(total)) * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
swapTotal := totals["SwapTotal"]
|
||||||
|
swapFree := totals["SwapFree"]
|
||||||
|
swapUsed := uint64(0)
|
||||||
|
if swapTotal >= swapFree {
|
||||||
|
swapUsed = swapTotal - swapFree
|
||||||
|
}
|
||||||
|
swapUsedPct := 0.0
|
||||||
|
if swapTotal > 0 {
|
||||||
|
swapUsedPct = (float64(swapUsed) / float64(swapTotal)) * 100
|
||||||
|
}
|
||||||
|
swapType := summarizeSwapTypes(swapTotal)
|
||||||
|
|
||||||
|
return MemoryMetrics{
|
||||||
|
TotalBytes: total,
|
||||||
|
UsedBytes: used,
|
||||||
|
FreeBytes: free,
|
||||||
|
UsedPercent: round(usedPercent, 2),
|
||||||
|
SwapTotalBytes: swapTotal,
|
||||||
|
SwapUsedBytes: swapUsed,
|
||||||
|
SwapFreeBytes: swapFree,
|
||||||
|
SwapUsedPercent: round(swapUsedPct, 2),
|
||||||
|
SwapType: swapType,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// summarizeSwapTypes 根据 /proc/swaps 汇总交换区类型(file / partition / zram 等);无 swap 时为「无」
|
||||||
|
func summarizeSwapTypes(swapTotalFromMeminfo uint64) string {
|
||||||
|
if swapTotalFromMeminfo == 0 {
|
||||||
|
return "无"
|
||||||
|
}
|
||||||
|
f, err := os.Open("/proc/swaps")
|
||||||
|
if err != nil {
|
||||||
|
return "未知"
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
scanner := bufio.NewScanner(f)
|
||||||
|
lineNum := 0
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
lineNum++
|
||||||
|
if lineNum == 1 || line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filename := fields[0]
|
||||||
|
typ := fields[1]
|
||||||
|
if strings.Contains(strings.ToLower(filename), "zram") {
|
||||||
|
typ = "zram"
|
||||||
|
}
|
||||||
|
seen[typ] = struct{}{}
|
||||||
|
}
|
||||||
|
if len(seen) == 0 {
|
||||||
|
if swapTotalFromMeminfo > 0 {
|
||||||
|
return "未知"
|
||||||
|
}
|
||||||
|
return "无"
|
||||||
|
}
|
||||||
|
list := make([]string, 0, len(seen))
|
||||||
|
for t := range seen {
|
||||||
|
list = append(list, t)
|
||||||
|
}
|
||||||
|
sort.Strings(list)
|
||||||
|
return strings.Join(list, " + ")
|
||||||
|
}
|
||||||
@@ -1,390 +1,436 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// readSystemStats 读取系统统计信息
|
// readSystemStats 读取系统统计信息
|
||||||
func readSystemStats() SystemStats {
|
func readSystemStats() SystemStats {
|
||||||
stats := SystemStats{}
|
stats := SystemStats{}
|
||||||
|
|
||||||
// 读取进程数量
|
// 读取进程数量
|
||||||
stats.ProcessCount = countProcesses()
|
stats.ProcessCount = countProcesses()
|
||||||
|
stats.LoggedInUsers = countLoggedInSessions()
|
||||||
// 读取已安装包数量和包管理器类型
|
stats.TCPConnections = countTCPConnectionsFromProc()
|
||||||
stats.PackageCount, stats.PackageManager = countPackages()
|
|
||||||
|
// 读取已安装包数量和包管理器类型
|
||||||
// 读取系统温度
|
stats.PackageCount, stats.PackageManager = countPackages()
|
||||||
stats.Temperature = readSystemTemperature()
|
|
||||||
|
// 读取系统温度
|
||||||
// 读取磁盘读写速度
|
stats.Temperature = readSystemTemperature()
|
||||||
stats.DiskReadSpeed, stats.DiskWriteSpeed = readDiskSpeed()
|
|
||||||
|
// 读取磁盘读写速度
|
||||||
// 读取 Top 5 进程
|
stats.DiskReadSpeed, stats.DiskWriteSpeed = readDiskSpeed()
|
||||||
stats.TopProcesses = readTopProcesses()
|
|
||||||
|
// 读取 Top 10 进程
|
||||||
// 读取系统日志
|
stats.TopProcesses = readTopProcesses()
|
||||||
stats.SystemLogs = readSystemLogs(10)
|
|
||||||
|
// 读取系统日志
|
||||||
return stats
|
stats.SystemLogs = readSystemLogs(10)
|
||||||
}
|
|
||||||
|
return stats
|
||||||
func countProcesses() int {
|
}
|
||||||
entries, err := os.ReadDir("/proc")
|
|
||||||
if err != nil {
|
func countProcesses() int {
|
||||||
return 0
|
entries, err := os.ReadDir("/proc")
|
||||||
}
|
if err != nil {
|
||||||
count := 0
|
return 0
|
||||||
for _, entry := range entries {
|
}
|
||||||
if entry.IsDir() {
|
count := 0
|
||||||
// 进程目录是数字命名的
|
for _, entry := range entries {
|
||||||
if _, err := strconv.Atoi(entry.Name()); err == nil {
|
if entry.IsDir() {
|
||||||
count++
|
// 进程目录是数字命名的
|
||||||
}
|
if _, err := strconv.Atoi(entry.Name()); err == nil {
|
||||||
}
|
count++
|
||||||
}
|
}
|
||||||
return count
|
}
|
||||||
}
|
}
|
||||||
|
return count
|
||||||
func countPackages() (int, string) {
|
}
|
||||||
// 尝试不同的包管理器
|
|
||||||
// dpkg (Debian/Ubuntu)
|
// countLoggedInSessions 当前登录会话数(与 who 输出行数一致:多个 SSH/终端各占一行)
|
||||||
if _, err := exec.LookPath("dpkg"); err == nil {
|
func countLoggedInSessions() int {
|
||||||
cmd := exec.Command("dpkg", "-l")
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
out, err := cmd.Output()
|
defer cancel()
|
||||||
if err == nil {
|
cmd := exec.CommandContext(ctx, "who")
|
||||||
lines := strings.Split(string(out), "\n")
|
out, err := cmd.Output()
|
||||||
count := 0
|
if err != nil {
|
||||||
for _, line := range lines {
|
return 0
|
||||||
if strings.HasPrefix(line, "ii ") {
|
}
|
||||||
count++
|
s := strings.TrimSpace(string(out))
|
||||||
}
|
if s == "" {
|
||||||
}
|
return 0
|
||||||
return count, "dpkg (apt)"
|
}
|
||||||
}
|
return len(strings.Split(s, "\n"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// rpm (RedHat/CentOS/Fedora)
|
// countTCPConnectionsFromProc 统计 /proc/net/tcp 与 tcp6 中的套接字条目数(内核导出的各行状态,非仅 ESTABLISHED)
|
||||||
if _, err := exec.LookPath("rpm"); err == nil {
|
func countTCPConnectionsFromProc() int {
|
||||||
cmd := exec.Command("rpm", "-qa")
|
return countProcNetTCPFile("/proc/net/tcp") + countProcNetTCPFile("/proc/net/tcp6")
|
||||||
out, err := cmd.Output()
|
}
|
||||||
if err == nil {
|
|
||||||
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
|
func countProcNetTCPFile(path string) int {
|
||||||
return len(lines), "rpm (yum/dnf)"
|
f, err := os.Open(path)
|
||||||
}
|
if err != nil {
|
||||||
}
|
return 0
|
||||||
|
}
|
||||||
// pacman (Arch Linux)
|
defer f.Close()
|
||||||
if _, err := exec.LookPath("pacman"); err == nil {
|
scanner := bufio.NewScanner(f)
|
||||||
cmd := exec.Command("pacman", "-Q")
|
n := 0
|
||||||
out, err := cmd.Output()
|
header := true
|
||||||
if err == nil {
|
for scanner.Scan() {
|
||||||
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
|
line := strings.TrimSpace(scanner.Text())
|
||||||
return len(lines), "pacman"
|
if line == "" {
|
||||||
}
|
continue
|
||||||
}
|
}
|
||||||
|
if header {
|
||||||
return 0, "unknown"
|
header = false
|
||||||
}
|
continue
|
||||||
|
}
|
||||||
func readSystemTemperature() float64 {
|
n++
|
||||||
var cpuTemp float64 = 0
|
}
|
||||||
var fallbackTemp float64 = 0
|
return n
|
||||||
|
}
|
||||||
// 1. 优先读取 thermal_zone (通常是 CPU 温度)
|
|
||||||
thermalDir := "/sys/class/thermal"
|
func countPackages() (int, string) {
|
||||||
entries, err := os.ReadDir(thermalDir)
|
// 尝试不同的包管理器
|
||||||
if err == nil {
|
// dpkg (Debian/Ubuntu)
|
||||||
for _, entry := range entries {
|
if _, err := exec.LookPath("dpkg"); err == nil {
|
||||||
if !strings.HasPrefix(entry.Name(), "thermal_zone") {
|
cmd := exec.Command("dpkg", "-l")
|
||||||
continue
|
out, err := cmd.Output()
|
||||||
}
|
if err == nil {
|
||||||
tempPath := thermalDir + "/" + entry.Name() + "/temp"
|
lines := strings.Split(string(out), "\n")
|
||||||
if temp := readTempFromFile(tempPath); temp > 0 && temp > 20 && temp < 120 {
|
count := 0
|
||||||
// thermal_zone0 通常是 CPU
|
for _, line := range lines {
|
||||||
if entry.Name() == "thermal_zone0" {
|
if strings.HasPrefix(line, "ii ") {
|
||||||
cpuTemp = temp
|
count++
|
||||||
break
|
}
|
||||||
} else if fallbackTemp == 0 {
|
}
|
||||||
fallbackTemp = temp
|
return count, "dpkg (apt)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
// rpm (RedHat/CentOS/Fedora)
|
||||||
|
if _, err := exec.LookPath("rpm"); err == nil {
|
||||||
// 2. 扫描所有 hwmon 设备,查找 CPU 温度
|
cmd := exec.Command("rpm", "-qa")
|
||||||
hwmonDir := "/sys/class/hwmon"
|
out, err := cmd.Output()
|
||||||
entries, err = os.ReadDir(hwmonDir)
|
if err == nil {
|
||||||
if err == nil {
|
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
|
||||||
for _, entry := range entries {
|
return len(lines), "rpm (yum/dnf)"
|
||||||
hwmonPath := hwmonDir + "/" + entry.Name()
|
}
|
||||||
|
}
|
||||||
// 读取 name 文件,检查是否是 CPU 相关
|
|
||||||
namePath := hwmonPath + "/name"
|
// pacman (Arch Linux)
|
||||||
name := strings.ToLower(strings.TrimSpace(readFirstLine(namePath)))
|
if _, err := exec.LookPath("pacman"); err == nil {
|
||||||
|
cmd := exec.Command("pacman", "-Q")
|
||||||
// 检查是否是 CPU 温度传感器
|
out, err := cmd.Output()
|
||||||
isCPU := strings.Contains(name, "cpu") ||
|
if err == nil {
|
||||||
strings.Contains(name, "core") ||
|
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
|
||||||
strings.Contains(name, "k10temp") ||
|
return len(lines), "pacman"
|
||||||
strings.Contains(name, "coretemp") ||
|
}
|
||||||
strings.Contains(name, "zenpower")
|
}
|
||||||
|
|
||||||
// 尝试读取 temp1_input (通常是 CPU)
|
return 0, "unknown"
|
||||||
temp1Path := hwmonPath + "/temp1_input"
|
}
|
||||||
if temp := readTempFromFile(temp1Path); temp > 0 && temp > 20 && temp < 120 {
|
|
||||||
if isCPU {
|
func readSystemTemperature() float64 {
|
||||||
cpuTemp = temp
|
var cpuTemp float64 = 0
|
||||||
break
|
var fallbackTemp float64 = 0
|
||||||
} else if fallbackTemp == 0 {
|
|
||||||
fallbackTemp = temp
|
// 1. 优先读取 thermal_zone (通常是 CPU 温度)
|
||||||
}
|
thermalDir := "/sys/class/thermal"
|
||||||
}
|
entries, err := os.ReadDir(thermalDir)
|
||||||
|
if err == nil {
|
||||||
// 也尝试 temp2_input
|
for _, entry := range entries {
|
||||||
temp2Path := hwmonPath + "/temp2_input"
|
if !strings.HasPrefix(entry.Name(), "thermal_zone") {
|
||||||
if temp := readTempFromFile(temp2Path); temp > 0 && temp > 20 && temp < 120 {
|
continue
|
||||||
if isCPU && cpuTemp == 0 {
|
}
|
||||||
cpuTemp = temp
|
tempPath := thermalDir + "/" + entry.Name() + "/temp"
|
||||||
} else if fallbackTemp == 0 {
|
if temp := readTempFromFile(tempPath); temp > 0 && temp > 20 && temp < 120 {
|
||||||
fallbackTemp = temp
|
// thermal_zone0 通常是 CPU
|
||||||
}
|
if entry.Name() == "thermal_zone0" {
|
||||||
}
|
cpuTemp = temp
|
||||||
}
|
break
|
||||||
}
|
} else if fallbackTemp == 0 {
|
||||||
|
fallbackTemp = temp
|
||||||
// 优先返回 CPU 温度,如果没有则返回其他温度
|
}
|
||||||
if cpuTemp > 0 {
|
}
|
||||||
return cpuTemp
|
}
|
||||||
}
|
}
|
||||||
return fallbackTemp
|
|
||||||
}
|
// 2. 扫描所有 hwmon 设备,查找 CPU 温度
|
||||||
|
hwmonDir := "/sys/class/hwmon"
|
||||||
// readDiskSpeed 读取磁盘瞬时读写速度 (MB/s)
|
entries, err = os.ReadDir(hwmonDir)
|
||||||
func readDiskSpeed() (float64, float64) {
|
if err == nil {
|
||||||
// 第一次读取
|
for _, entry := range entries {
|
||||||
readSectors1, writeSectors1 := getDiskSectors()
|
hwmonPath := hwmonDir + "/" + entry.Name()
|
||||||
if readSectors1 == 0 && writeSectors1 == 0 {
|
|
||||||
return 0, 0
|
// 读取 name 文件,检查是否是 CPU 相关
|
||||||
}
|
namePath := hwmonPath + "/name"
|
||||||
|
name := strings.ToLower(strings.TrimSpace(readFirstLine(namePath)))
|
||||||
// 等待1秒
|
|
||||||
time.Sleep(1 * time.Second)
|
// 检查是否是 CPU 温度传感器
|
||||||
|
isCPU := strings.Contains(name, "cpu") ||
|
||||||
// 第二次读取
|
strings.Contains(name, "core") ||
|
||||||
readSectors2, writeSectors2 := getDiskSectors()
|
strings.Contains(name, "k10temp") ||
|
||||||
|
strings.Contains(name, "coretemp") ||
|
||||||
// 计算差值(扇区数)
|
strings.Contains(name, "zenpower")
|
||||||
readDiff := readSectors2 - readSectors1
|
|
||||||
writeDiff := writeSectors2 - writeSectors1
|
// 尝试读取 temp1_input (通常是 CPU)
|
||||||
|
temp1Path := hwmonPath + "/temp1_input"
|
||||||
// 扇区大小通常是 512 字节,转换为 MB/s
|
if temp := readTempFromFile(temp1Path); temp > 0 && temp > 20 && temp < 120 {
|
||||||
readSpeed := float64(readDiff) * 512 / 1024 / 1024
|
if isCPU {
|
||||||
writeSpeed := float64(writeDiff) * 512 / 1024 / 1024
|
cpuTemp = temp
|
||||||
|
break
|
||||||
return round(readSpeed, 2), round(writeSpeed, 2)
|
} else if fallbackTemp == 0 {
|
||||||
}
|
fallbackTemp = temp
|
||||||
|
}
|
||||||
func getDiskSectors() (uint64, uint64) {
|
}
|
||||||
f, err := os.Open("/proc/diskstats")
|
|
||||||
if err != nil {
|
// 也尝试 temp2_input
|
||||||
return 0, 0
|
temp2Path := hwmonPath + "/temp2_input"
|
||||||
}
|
if temp := readTempFromFile(temp2Path); temp > 0 && temp > 20 && temp < 120 {
|
||||||
defer f.Close()
|
if isCPU && cpuTemp == 0 {
|
||||||
|
cpuTemp = temp
|
||||||
scanner := bufio.NewScanner(f)
|
} else if fallbackTemp == 0 {
|
||||||
|
fallbackTemp = temp
|
||||||
var maxRead uint64 = 0
|
}
|
||||||
var mainDevice string
|
}
|
||||||
|
}
|
||||||
// 第一次遍历:找到读写量最大的主磁盘(通常是系统盘)
|
}
|
||||||
for scanner.Scan() {
|
|
||||||
line := scanner.Text()
|
// 优先返回 CPU 温度,如果没有则返回其他温度
|
||||||
fields := strings.Fields(line)
|
if cpuTemp > 0 {
|
||||||
if len(fields) < 14 {
|
return cpuTemp
|
||||||
continue
|
}
|
||||||
}
|
return fallbackTemp
|
||||||
|
}
|
||||||
deviceName := fields[2]
|
|
||||||
// 跳过分区(分区名通常包含数字,如 sda1, vda1, nvme0n1p1)
|
// readDiskSpeed 读取磁盘瞬时读写速度 (MB/s)
|
||||||
if strings.ContainsAny(deviceName, "0123456789") &&
|
func readDiskSpeed() (float64, float64) {
|
||||||
!strings.HasPrefix(deviceName, "nvme") &&
|
// 第一次读取
|
||||||
!strings.HasPrefix(deviceName, "loop") {
|
readSectors1, writeSectors1 := getDiskSectors()
|
||||||
continue
|
if readSectors1 == 0 && writeSectors1 == 0 {
|
||||||
}
|
return 0, 0
|
||||||
|
}
|
||||||
// 跳过虚拟设备
|
|
||||||
if strings.HasPrefix(deviceName, "loop") ||
|
// 等待1秒
|
||||||
strings.HasPrefix(deviceName, "ram") ||
|
time.Sleep(1 * time.Second)
|
||||||
strings.HasPrefix(deviceName, "zram") {
|
|
||||||
continue
|
// 第二次读取
|
||||||
}
|
readSectors2, writeSectors2 := getDiskSectors()
|
||||||
|
|
||||||
readSectors, _ := strconv.ParseUint(fields[5], 10, 64)
|
// 计算差值(扇区数)
|
||||||
// 选择读写量最大的作为主磁盘
|
readDiff := readSectors2 - readSectors1
|
||||||
if readSectors > maxRead {
|
writeDiff := writeSectors2 - writeSectors1
|
||||||
maxRead = readSectors
|
|
||||||
mainDevice = deviceName
|
// 扇区大小通常是 512 字节,转换为 MB/s
|
||||||
}
|
readSpeed := float64(readDiff) * 512 / 1024 / 1024
|
||||||
}
|
writeSpeed := float64(writeDiff) * 512 / 1024 / 1024
|
||||||
|
|
||||||
// 第二次遍历:读取主磁盘的数据
|
return round(readSpeed, 2), round(writeSpeed, 2)
|
||||||
f.Close()
|
}
|
||||||
f, err = os.Open("/proc/diskstats")
|
|
||||||
if err != nil {
|
func getDiskSectors() (uint64, uint64) {
|
||||||
return 0, 0
|
f, err := os.Open("/proc/diskstats")
|
||||||
}
|
if err != nil {
|
||||||
scanner = bufio.NewScanner(f)
|
return 0, 0
|
||||||
for scanner.Scan() {
|
}
|
||||||
line := scanner.Text()
|
defer f.Close()
|
||||||
fields := strings.Fields(line)
|
|
||||||
if len(fields) < 14 {
|
scanner := bufio.NewScanner(f)
|
||||||
continue
|
|
||||||
}
|
var maxRead uint64 = 0
|
||||||
|
var mainDevice string
|
||||||
if fields[2] == mainDevice {
|
|
||||||
readSectors, _ := strconv.ParseUint(fields[5], 10, 64)
|
// 第一次遍历:找到读写量最大的主磁盘(通常是系统盘)
|
||||||
writeSectors, _ := strconv.ParseUint(fields[9], 10, 64)
|
for scanner.Scan() {
|
||||||
return readSectors, writeSectors
|
line := scanner.Text()
|
||||||
}
|
fields := strings.Fields(line)
|
||||||
}
|
if len(fields) < 14 {
|
||||||
|
continue
|
||||||
// 如果没找到,尝试常见的设备名(向后兼容)
|
}
|
||||||
f.Close()
|
|
||||||
f, err = os.Open("/proc/diskstats")
|
deviceName := fields[2]
|
||||||
if err != nil {
|
// 跳过分区(分区名通常包含数字,如 sda1, vda1, nvme0n1p1)
|
||||||
return 0, 0
|
if strings.ContainsAny(deviceName, "0123456789") &&
|
||||||
}
|
!strings.HasPrefix(deviceName, "nvme") &&
|
||||||
scanner = bufio.NewScanner(f)
|
!strings.HasPrefix(deviceName, "loop") {
|
||||||
for scanner.Scan() {
|
continue
|
||||||
line := scanner.Text()
|
}
|
||||||
fields := strings.Fields(line)
|
|
||||||
if len(fields) < 14 {
|
// 跳过虚拟设备
|
||||||
continue
|
if strings.HasPrefix(deviceName, "loop") ||
|
||||||
}
|
strings.HasPrefix(deviceName, "ram") ||
|
||||||
|
strings.HasPrefix(deviceName, "zram") {
|
||||||
deviceName := fields[2]
|
continue
|
||||||
if deviceName == "sda" || deviceName == "vda" || deviceName == "nvme0n1" {
|
}
|
||||||
readSectors, _ := strconv.ParseUint(fields[5], 10, 64)
|
|
||||||
writeSectors, _ := strconv.ParseUint(fields[9], 10, 64)
|
readSectors, _ := strconv.ParseUint(fields[5], 10, 64)
|
||||||
return readSectors, writeSectors
|
// 选择读写量最大的作为主磁盘
|
||||||
}
|
if readSectors > maxRead {
|
||||||
}
|
maxRead = readSectors
|
||||||
|
mainDevice = deviceName
|
||||||
return 0, 0
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// readTopProcesses 读取 Top 5 进程 (按 CPU 使用率)
|
// 第二次遍历:读取主磁盘的数据
|
||||||
func readTopProcesses() []ProcessInfo {
|
f.Close()
|
||||||
processes := []ProcessInfo{}
|
f, err = os.Open("/proc/diskstats")
|
||||||
|
if err != nil {
|
||||||
// 读取系统总内存
|
return 0, 0
|
||||||
memInfo, _ := readMemory()
|
}
|
||||||
totalMemGB := float64(memInfo.TotalBytes) / 1024 / 1024 / 1024
|
scanner = bufio.NewScanner(f)
|
||||||
|
for scanner.Scan() {
|
||||||
// 使用 ps 命令获取进程信息,添加超时控制
|
line := scanner.Text()
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
fields := strings.Fields(line)
|
||||||
defer cancel()
|
if len(fields) < 14 {
|
||||||
cmd := exec.CommandContext(ctx, "ps", "aux", "--sort=-%cpu", "--no-headers")
|
continue
|
||||||
out, err := cmd.Output()
|
}
|
||||||
if err != nil {
|
|
||||||
return processes
|
if fields[2] == mainDevice {
|
||||||
}
|
readSectors, _ := strconv.ParseUint(fields[5], 10, 64)
|
||||||
|
writeSectors, _ := strconv.ParseUint(fields[9], 10, 64)
|
||||||
lines := strings.Split(string(out), "\n")
|
return readSectors, writeSectors
|
||||||
count := 0
|
}
|
||||||
|
}
|
||||||
for _, line := range lines {
|
|
||||||
if count >= 5 { // 只取前5个
|
// 如果没找到,尝试常见的设备名(向后兼容)
|
||||||
break
|
f.Close()
|
||||||
}
|
f, err = os.Open("/proc/diskstats")
|
||||||
|
if err != nil {
|
||||||
line = strings.TrimSpace(line)
|
return 0, 0
|
||||||
if line == "" {
|
}
|
||||||
continue
|
scanner = bufio.NewScanner(f)
|
||||||
}
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
fields := strings.Fields(line)
|
fields := strings.Fields(line)
|
||||||
if len(fields) < 11 {
|
if len(fields) < 14 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
pid, _ := strconv.Atoi(fields[1])
|
deviceName := fields[2]
|
||||||
cpu, _ := strconv.ParseFloat(fields[2], 64)
|
if deviceName == "sda" || deviceName == "vda" || deviceName == "nvme0n1" {
|
||||||
mem, _ := strconv.ParseFloat(fields[3], 64)
|
readSectors, _ := strconv.ParseUint(fields[5], 10, 64)
|
||||||
|
writeSectors, _ := strconv.ParseUint(fields[9], 10, 64)
|
||||||
// 计算内存MB数
|
return readSectors, writeSectors
|
||||||
memoryMB := (mem / 100) * totalMemGB * 1024
|
}
|
||||||
|
}
|
||||||
// 命令可能包含空格,从第11个字段开始拼接
|
|
||||||
command := strings.Join(fields[10:], " ")
|
return 0, 0
|
||||||
if len(command) > 50 {
|
}
|
||||||
command = command[:50] + "..."
|
|
||||||
}
|
// readTopProcesses 读取 Top 10 进程 (按 CPU 使用率)
|
||||||
|
func readTopProcesses() []ProcessInfo {
|
||||||
processes = append(processes, ProcessInfo{
|
processes := []ProcessInfo{}
|
||||||
PID: pid,
|
|
||||||
Name: fields[10],
|
// 读取系统总内存
|
||||||
CPU: round(cpu, 1),
|
memInfo, _ := readMemory()
|
||||||
Memory: round(mem, 1),
|
totalMemGB := float64(memInfo.TotalBytes) / 1024 / 1024 / 1024
|
||||||
MemoryMB: round(memoryMB, 1),
|
|
||||||
Command: command,
|
// 使用 ps 命令获取进程信息,添加超时控制
|
||||||
})
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
count++
|
defer cancel()
|
||||||
}
|
cmd := exec.CommandContext(ctx, "ps", "aux", "--sort=-%cpu", "--no-headers")
|
||||||
|
out, err := cmd.Output()
|
||||||
return processes
|
if err != nil {
|
||||||
}
|
return processes
|
||||||
|
}
|
||||||
// readSystemLogs 读取系统最新日志
|
|
||||||
func readSystemLogs(count int) []string {
|
lines := strings.Split(string(out), "\n")
|
||||||
logs := []string{}
|
count := 0
|
||||||
|
|
||||||
// 尝试使用 journalctl 读取系统日志
|
for _, line := range lines {
|
||||||
if _, err := exec.LookPath("journalctl"); err == nil {
|
if count >= 10 { // 只取前 10 个
|
||||||
cmd := exec.Command("journalctl", "-n", strconv.Itoa(count), "--no-pager", "-o", "short")
|
break
|
||||||
out, err := cmd.Output()
|
}
|
||||||
if err == nil {
|
|
||||||
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
|
line = strings.TrimSpace(line)
|
||||||
for _, line := range lines {
|
if line == "" {
|
||||||
if line != "" {
|
continue
|
||||||
logs = append(logs, line)
|
}
|
||||||
}
|
|
||||||
}
|
fields := strings.Fields(line)
|
||||||
return logs
|
if len(fields) < 11 {
|
||||||
}
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果 journalctl 不可用,尝试读取 /var/log/syslog 或 /var/log/messages
|
pid, _ := strconv.Atoi(fields[1])
|
||||||
logFiles := []string{"/var/log/syslog", "/var/log/messages"}
|
cpu, _ := strconv.ParseFloat(fields[2], 64)
|
||||||
for _, logFile := range logFiles {
|
mem, _ := strconv.ParseFloat(fields[3], 64)
|
||||||
f, err := os.Open(logFile)
|
|
||||||
if err != nil {
|
// 计算内存MB数
|
||||||
continue
|
memoryMB := (mem / 100) * totalMemGB * 1024
|
||||||
}
|
|
||||||
defer f.Close()
|
// 命令可能包含空格,从第11个字段开始拼接
|
||||||
|
command := strings.Join(fields[10:], " ")
|
||||||
// 读取最后几行
|
if len(command) > 50 {
|
||||||
var lines []string
|
command = command[:50] + "..."
|
||||||
scanner := bufio.NewScanner(f)
|
}
|
||||||
for scanner.Scan() {
|
|
||||||
lines = append(lines, scanner.Text())
|
processes = append(processes, ProcessInfo{
|
||||||
}
|
PID: pid,
|
||||||
|
Name: fields[10],
|
||||||
// 取最后count行
|
CPU: round(cpu, 1),
|
||||||
start := len(lines) - count
|
Memory: round(mem, 1),
|
||||||
if start < 0 {
|
MemoryMB: round(memoryMB, 1),
|
||||||
start = 0
|
Command: command,
|
||||||
}
|
})
|
||||||
logs = lines[start:]
|
count++
|
||||||
break
|
}
|
||||||
}
|
|
||||||
|
return processes
|
||||||
return logs
|
}
|
||||||
}
|
|
||||||
|
// readSystemLogs 读取系统最新日志
|
||||||
|
func readSystemLogs(count int) []string {
|
||||||
|
logs := []string{}
|
||||||
|
|
||||||
|
// 尝试使用 journalctl 读取系统日志
|
||||||
|
if _, err := exec.LookPath("journalctl"); err == nil {
|
||||||
|
cmd := exec.Command("journalctl", "-n", strconv.Itoa(count), "--no-pager", "-o", "short")
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err == nil {
|
||||||
|
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
|
||||||
|
for _, line := range lines {
|
||||||
|
if line != "" {
|
||||||
|
logs = append(logs, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return logs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果 journalctl 不可用,尝试读取 /var/log/syslog 或 /var/log/messages
|
||||||
|
logFiles := []string{"/var/log/syslog", "/var/log/messages"}
|
||||||
|
for _, logFile := range logFiles {
|
||||||
|
f, err := os.Open(logFile)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
// 读取最后几行
|
||||||
|
var lines []string
|
||||||
|
scanner := bufio.NewScanner(f)
|
||||||
|
for scanner.Scan() {
|
||||||
|
lines = append(lines, scanner.Text())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 取最后count行
|
||||||
|
start := len(lines) - count
|
||||||
|
if start < 0 {
|
||||||
|
start = 0
|
||||||
|
}
|
||||||
|
logs = lines[start:]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
return logs
|
||||||
|
}
|
||||||
@@ -19,10 +19,15 @@ type CoreUsage struct {
|
|||||||
|
|
||||||
// 内存相关信息
|
// 内存相关信息
|
||||||
type MemoryMetrics struct {
|
type MemoryMetrics struct {
|
||||||
TotalBytes uint64 `json:"totalBytes"`
|
TotalBytes uint64 `json:"totalBytes"`
|
||||||
UsedBytes uint64 `json:"usedBytes"`
|
UsedBytes uint64 `json:"usedBytes"`
|
||||||
FreeBytes uint64 `json:"freeBytes"`
|
FreeBytes uint64 `json:"freeBytes"`
|
||||||
UsedPercent float64 `json:"usedPercent"`
|
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 {
|
type SystemStats struct {
|
||||||
ProcessCount int `json:"processCount"` // 进程数量
|
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"` // 已安装软件包数量
|
PackageCount int `json:"packageCount"` // 已安装软件包数量
|
||||||
PackageManager string `json:"packageManager"` // 包管理器类型
|
PackageManager string `json:"packageManager"` // 包管理器类型
|
||||||
Temperature float64 `json:"temperature,omitempty"` // 系统温度(摄氏度)
|
Temperature float64 `json:"temperature,omitempty"` // 系统温度(摄氏度)
|
||||||
@@ -65,7 +72,7 @@ type SystemStats struct {
|
|||||||
DiskWriteSpeed float64 `json:"diskWriteSpeed"` // 磁盘写入速度 MB/s
|
DiskWriteSpeed float64 `json:"diskWriteSpeed"` // 磁盘写入速度 MB/s
|
||||||
NetworkRxSpeed float64 `json:"networkRxSpeed"` // 网络下载速度 MB/s
|
NetworkRxSpeed float64 `json:"networkRxSpeed"` // 网络下载速度 MB/s
|
||||||
NetworkTxSpeed float64 `json:"networkTxSpeed"` // 网络上传速度 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 统计信息
|
DockerStats DockerStats `json:"dockerStats"` // Docker 统计信息
|
||||||
SystemLogs []string `json:"systemLogs"` // 系统最新日志
|
SystemLogs []string `json:"systemLogs"` // 系统最新日志
|
||||||
}
|
}
|
||||||
@@ -82,14 +89,14 @@ type ProcessInfo struct {
|
|||||||
|
|
||||||
// Docker相关信息(简化版)
|
// Docker相关信息(简化版)
|
||||||
type DockerStats struct {
|
type DockerStats struct {
|
||||||
Available bool `json:"available"` // Docker是否可用
|
Available bool `json:"available"` // Docker是否可用
|
||||||
Version string `json:"version"` // Docker版本
|
Version string `json:"version"` // Docker版本
|
||||||
Running int `json:"running"` // 运行中的容器数
|
Running int `json:"running"` // 运行中的容器数
|
||||||
Stopped int `json:"stopped"` // 停止的容器数
|
Stopped int `json:"stopped"` // 停止的容器数
|
||||||
ImageCount int `json:"imageCount"` // 镜像数量
|
ImageCount int `json:"imageCount"` // 镜像数量
|
||||||
RunningNames []string `json:"runningNames"` // 运行中的容器名列表
|
RunningNames []string `json:"runningNames"` // 运行中的容器名列表
|
||||||
StoppedNames []string `json:"stoppedNames"` // 停止的容器名列表
|
StoppedNames []string `json:"stoppedNames"` // 停止的容器名列表
|
||||||
ImageNames []string `json:"imageNames"` // 镜像名列表
|
ImageNames []string `json:"imageNames"` // 镜像名列表
|
||||||
}
|
}
|
||||||
|
|
||||||
// 操作系统相关信息
|
// 操作系统相关信息
|
||||||
9
mengyamonitor-backend-server/.dockerignore
Normal file
9
mengyamonitor-backend-server/.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
data/
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
*.md
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
*.exe
|
||||||
|
mengyamonitor-backend-server
|
||||||
|
__debug_bin
|
||||||
21
mengyamonitor-backend-server/Dockerfile
Normal file
21
mengyamonitor-backend-server/Dockerfile
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# 中心服务:HTTP/WebSocket + gRPC(纯 Go / modernc SQLite,CGO_ENABLED=0)
|
||||||
|
# 镜像内进程为明文 HTTP;HTTPS 请在宿主机 Nginx 等反代层配置,内网直连本端口即可。
|
||||||
|
FROM golang:1.22-alpine AS build
|
||||||
|
WORKDIR /src
|
||||||
|
RUN apk add --no-cache git
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /central .
|
||||||
|
|
||||||
|
FROM alpine:3.20
|
||||||
|
RUN apk add --no-cache ca-certificates tzdata
|
||||||
|
WORKDIR /app
|
||||||
|
# 与 config 默认一致; compose 中可覆盖
|
||||||
|
ENV HOST=0.0.0.0
|
||||||
|
ENV PORT=9393
|
||||||
|
ENV GRPC_PORT=9394
|
||||||
|
ENV DB_PATH=/app/data/servers.db
|
||||||
|
COPY --from=build /central /app/central
|
||||||
|
EXPOSE 9393 9394
|
||||||
|
ENTRYPOINT ["/app/central"]
|
||||||
13
mengyamonitor-backend-server/Dockerfile.binary
Normal file
13
mengyamonitor-backend-server/Dockerfile.binary
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# 使用本机/CI 已编译好的 Linux amd64 二进制打包镜像(不在 Docker 内 go build)
|
||||||
|
# 先执行: build-linux-amd64.bat 或 release 目录放置同名文件
|
||||||
|
FROM alpine:3.20
|
||||||
|
RUN apk add --no-cache ca-certificates tzdata
|
||||||
|
WORKDIR /app
|
||||||
|
ENV HOST=0.0.0.0
|
||||||
|
ENV PORT=9393
|
||||||
|
ENV GRPC_PORT=9394
|
||||||
|
ENV DB_PATH=/app/data/servers.db
|
||||||
|
COPY release/mengyamonitor-central-linux-amd64 /app/central
|
||||||
|
RUN chmod +x /app/central
|
||||||
|
EXPOSE 9393 9394
|
||||||
|
ENTRYPOINT ["/app/central"]
|
||||||
28
mengyamonitor-backend-server/build-linux-amd64.bat
Normal file
28
mengyamonitor-backend-server/build-linux-amd64.bat
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal
|
||||||
|
cd /d "%~dp0"
|
||||||
|
|
||||||
|
where go >nul 2>&1
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo [错误] 未找到 go,请先安装 Go 并加入 PATH
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
if not exist release mkdir release
|
||||||
|
|
||||||
|
set GOOS=linux
|
||||||
|
set GOARCH=amd64
|
||||||
|
set CGO_ENABLED=0
|
||||||
|
|
||||||
|
echo 正在编译 Linux amd64 release\mengyamonitor-central-linux-amd64 ...
|
||||||
|
go build -trimpath -ldflags="-s -w" -o release\mengyamonitor-central-linux-amd64 .
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo [错误] go build 失败
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo 完成: %cd%\release\mengyamonitor-central-linux-amd64
|
||||||
|
echo.
|
||||||
|
echo 可选:用预编译镜像启动(不再在 Docker 里编译)
|
||||||
|
echo docker compose -f docker-compose.binary.yml up -d --build
|
||||||
|
exit /b 0
|
||||||
20
mengyamonitor-backend-server/docker-compose.binary.yml
Normal file
20
mengyamonitor-backend-server/docker-compose.binary.yml
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# 使用 release/mengyamonitor-central-linux-amd64 打包镜像(先在 Windows 运行 build-linux-amd64.bat)
|
||||||
|
# 容器内仍是 HTTP:9393 / 9394;HTTPS 仅公网 Nginx 反代层
|
||||||
|
services:
|
||||||
|
central:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.binary
|
||||||
|
image: mengyamonitor-central:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "9393:9393"
|
||||||
|
- "9394:9394"
|
||||||
|
environment:
|
||||||
|
HOST: "0.0.0.0"
|
||||||
|
PORT: "9393"
|
||||||
|
GRPC_PORT: "9394"
|
||||||
|
ADMIN_TOKEN: ${ADMIN_TOKEN:-shumengya520}
|
||||||
|
DB_PATH: /app/data/servers.db
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
23
mengyamonitor-backend-server/docker-compose.yml
Normal file
23
mengyamonitor-backend-server/docker-compose.yml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# 中心服务在容器内仅为 HTTP(9393 Web/WS、9394 gRPC);TLS 由公网 Nginx 做,与内网无关。
|
||||||
|
#
|
||||||
|
# 本文件:在 Docker 内完整执行 go build(适合无本机 Go 或 CI)。
|
||||||
|
# 若已在 Windows 编好 Linux 二进制,避免镜像内再编译:先运行 build-linux-amd64.bat,再
|
||||||
|
# docker compose -f docker-compose.binary.yml up -d --build
|
||||||
|
services:
|
||||||
|
central:
|
||||||
|
build: .
|
||||||
|
image: mengyamonitor-central:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
# 宿主:容器 —— 可按需改为 "127.0.0.1:9393:9393" 仅本机反代
|
||||||
|
- "9393:9393"
|
||||||
|
- "9394:9394"
|
||||||
|
environment:
|
||||||
|
HOST: "0.0.0.0"
|
||||||
|
PORT: "9393"
|
||||||
|
GRPC_PORT: "9394"
|
||||||
|
# 务必在宿主设置强口令:export ADMIN_TOKEN=... 或下方 env_file
|
||||||
|
ADMIN_TOKEN: ${ADMIN_TOKEN:-shumengya520}
|
||||||
|
DB_PATH: /app/data/servers.db
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
47
mengyamonitor-backend-server/go.mod
Normal file
47
mengyamonitor-backend-server/go.mod
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
module mengyamonitor-backend-server
|
||||||
|
|
||||||
|
go 1.22
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/gin-gonic/gin v1.10.0
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/gorilla/websocket v1.5.3
|
||||||
|
google.golang.org/grpc v1.70.0
|
||||||
|
google.golang.org/protobuf v1.35.2
|
||||||
|
modernc.org/sqlite v1.34.5
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/bytedance/sonic v1.11.6 // indirect
|
||||||
|
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||||
|
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||||
|
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||||
|
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||||
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
|
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
||||||
|
github.com/goccy/go-json v0.10.2 // indirect
|
||||||
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||||
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
|
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
|
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||||
|
golang.org/x/arch v0.8.0 // indirect
|
||||||
|
golang.org/x/crypto v0.30.0 // indirect
|
||||||
|
golang.org/x/net v0.32.0 // indirect
|
||||||
|
golang.org/x/sys v0.28.0 // indirect
|
||||||
|
golang.org/x/text v0.21.0 // indirect
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20241202173237-19429a94021a // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
modernc.org/libc v1.55.3 // indirect
|
||||||
|
modernc.org/mathutil v1.6.0 // indirect
|
||||||
|
modernc.org/memory v1.8.0 // indirect
|
||||||
|
)
|
||||||
149
mengyamonitor-backend-server/go.sum
Normal file
149
mengyamonitor-backend-server/go.sum
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
||||||
|
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
||||||
|
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||||
|
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||||
|
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||||
|
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||||
|
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||||
|
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||||
|
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||||
|
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||||
|
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||||
|
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||||
|
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||||
|
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
|
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
|
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||||
|
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
|
||||||
|
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||||
|
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||||
|
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||||
|
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
|
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||||
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
|
||||||
|
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||||
|
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||||
|
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
|
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
|
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||||
|
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
|
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||||
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
|
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||||
|
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||||
|
go.opentelemetry.io/otel v1.32.0 h1:WnBN+Xjcteh0zdk01SVqV55d/m62NJLJdIyb4y/WO5U=
|
||||||
|
go.opentelemetry.io/otel v1.32.0/go.mod h1:00DCVSB0RQcnzlwyTfqtxSm+DRr9hpYrHjNGiBHVQIg=
|
||||||
|
go.opentelemetry.io/otel/metric v1.32.0 h1:xV2umtmNcThh2/a/aCP+h64Xx5wsj8qqnkYZktzNa0M=
|
||||||
|
go.opentelemetry.io/otel/metric v1.32.0/go.mod h1:jH7CIbbK6SH2V2wE16W05BHCtIDzauciCRLoc/SyMv8=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.32.0 h1:RNxepc9vK59A8XsgZQouW8ue8Gkb4jpWtJm9ge5lEG4=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.32.0/go.mod h1:LqgegDBjKMmb2GC6/PrTnteJG39I8/vJCAP9LlJXEjU=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.32.0 h1:rZvFnvmvawYb0alrYkjraqJq0Z4ZUJAiyYCU9snn1CU=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.32.0/go.mod h1:PWeZlq0zt9YkYAp3gjKZ0eicRYvOh1Gd+X99x6GHpCQ=
|
||||||
|
go.opentelemetry.io/otel/trace v1.32.0 h1:WIC9mYrXf8TmY/EXuULKc8hR17vE+Hjv2cssQDe03fM=
|
||||||
|
go.opentelemetry.io/otel/trace v1.32.0/go.mod h1:+i4rkvCraA+tG6AzwloGaCtkx53Fa+L+V8e9a7YvhT8=
|
||||||
|
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||||
|
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||||
|
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||||
|
golang.org/x/crypto v0.30.0 h1:RwoQn3GkWiMkzlX562cLB7OxWvjH1L8xutO2WoJcRoY=
|
||||||
|
golang.org/x/crypto v0.30.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||||
|
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
|
||||||
|
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||||
|
golang.org/x/net v0.32.0 h1:ZqPmj8Kzc+Y6e0+skZsuACbx+wzMgo5MQsJh9Qd6aYI=
|
||||||
|
golang.org/x/net v0.32.0/go.mod h1:CwU0IoeOlnQQWJ6ioyFrfRuomB8GKF6KbYXZVyeXNfs=
|
||||||
|
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
||||||
|
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
||||||
|
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||||
|
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||||
|
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
|
||||||
|
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20241202173237-19429a94021a h1:hgh8P4EuoxpsuKMXX/To36nOFD7vixReXgn8lPGnt+o=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20241202173237-19429a94021a/go.mod h1:5uTbfoYQed2U9p3KIj2/Zzm02PYhndfdmML0qC3q3FU=
|
||||||
|
google.golang.org/grpc v1.70.0 h1:pWFv03aZoHzlRKHWicjsZytKAiYCtNS0dHbXnIdq7jQ=
|
||||||
|
google.golang.org/grpc v1.70.0/go.mod h1:ofIJqVKDXx/JiXrwr2IG4/zwdH9txy3IlF40RmcJSQw=
|
||||||
|
google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io=
|
||||||
|
google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
|
||||||
|
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
|
||||||
|
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
|
||||||
|
modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
|
||||||
|
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
||||||
|
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
||||||
|
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
|
||||||
|
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
|
||||||
|
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
|
||||||
|
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
|
||||||
|
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
||||||
|
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
||||||
|
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
|
||||||
|
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
|
||||||
|
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
|
||||||
|
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||||
|
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
|
||||||
|
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
|
||||||
|
modernc.org/sqlite v1.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g=
|
||||||
|
modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE=
|
||||||
|
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
|
||||||
|
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
|
||||||
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
|
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||||
|
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||||
30
mengyamonitor-backend-server/internal/config/config.go
Normal file
30
mengyamonitor-backend-server/internal/config/config.go
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Host string
|
||||||
|
Port string
|
||||||
|
GRPCPort string
|
||||||
|
AdminToken string
|
||||||
|
DBPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load() *Config {
|
||||||
|
return &Config{
|
||||||
|
Host: getenv("HOST", "0.0.0.0"),
|
||||||
|
Port: getenv("PORT", "9393"),
|
||||||
|
GRPCPort: getenv("GRPC_PORT", "9394"),
|
||||||
|
AdminToken: getenv("ADMIN_TOKEN", "shumengya520"),
|
||||||
|
DBPath: getenv("DB_PATH", "./data/servers.db"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getenv(key, fallback string) string {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
492
mengyamonitor-backend-server/internal/gen/mengyav1/agent.pb.go
Normal file
492
mengyamonitor-backend-server/internal/gen/mengyav1/agent.pb.go
Normal file
@@ -0,0 +1,492 @@
|
|||||||
|
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// protoc-gen-go v1.36.5
|
||||||
|
// protoc v5.28.3
|
||||||
|
// source: mengya/v1/agent.proto
|
||||||
|
|
||||||
|
package mengyav1
|
||||||
|
|
||||||
|
import (
|
||||||
|
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||||
|
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||||
|
reflect "reflect"
|
||||||
|
sync "sync"
|
||||||
|
unsafe "unsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Verify that this generated code is sufficiently up-to-date.
|
||||||
|
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
|
||||||
|
// Verify that runtime/protoimpl is sufficiently up-to-date.
|
||||||
|
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
|
||||||
|
)
|
||||||
|
|
||||||
|
type AgentMessage struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
// Types that are valid to be assigned to Payload:
|
||||||
|
//
|
||||||
|
// *AgentMessage_Hello
|
||||||
|
// *AgentMessage_Heartbeat
|
||||||
|
// *AgentMessage_Metrics
|
||||||
|
Payload isAgentMessage_Payload `protobuf_oneof:"payload"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *AgentMessage) Reset() {
|
||||||
|
*x = AgentMessage{}
|
||||||
|
mi := &file_mengya_v1_agent_proto_msgTypes[0]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *AgentMessage) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*AgentMessage) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *AgentMessage) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_mengya_v1_agent_proto_msgTypes[0]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use AgentMessage.ProtoReflect.Descriptor instead.
|
||||||
|
func (*AgentMessage) Descriptor() ([]byte, []int) {
|
||||||
|
return file_mengya_v1_agent_proto_rawDescGZIP(), []int{0}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *AgentMessage) GetPayload() isAgentMessage_Payload {
|
||||||
|
if x != nil {
|
||||||
|
return x.Payload
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *AgentMessage) GetHello() *Hello {
|
||||||
|
if x != nil {
|
||||||
|
if x, ok := x.Payload.(*AgentMessage_Hello); ok {
|
||||||
|
return x.Hello
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *AgentMessage) GetHeartbeat() *Heartbeat {
|
||||||
|
if x != nil {
|
||||||
|
if x, ok := x.Payload.(*AgentMessage_Heartbeat); ok {
|
||||||
|
return x.Heartbeat
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *AgentMessage) GetMetrics() *MetricsReport {
|
||||||
|
if x != nil {
|
||||||
|
if x, ok := x.Payload.(*AgentMessage_Metrics); ok {
|
||||||
|
return x.Metrics
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type isAgentMessage_Payload interface {
|
||||||
|
isAgentMessage_Payload()
|
||||||
|
}
|
||||||
|
|
||||||
|
type AgentMessage_Hello struct {
|
||||||
|
Hello *Hello `protobuf:"bytes,1,opt,name=hello,proto3,oneof"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AgentMessage_Heartbeat struct {
|
||||||
|
Heartbeat *Heartbeat `protobuf:"bytes,2,opt,name=heartbeat,proto3,oneof"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AgentMessage_Metrics struct {
|
||||||
|
Metrics *MetricsReport `protobuf:"bytes,3,opt,name=metrics,proto3,oneof"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*AgentMessage_Hello) isAgentMessage_Payload() {}
|
||||||
|
|
||||||
|
func (*AgentMessage_Heartbeat) isAgentMessage_Payload() {}
|
||||||
|
|
||||||
|
func (*AgentMessage_Metrics) isAgentMessage_Payload() {}
|
||||||
|
|
||||||
|
type Hello struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
ServerId string `protobuf:"bytes,1,opt,name=server_id,json=serverId,proto3" json:"server_id,omitempty"`
|
||||||
|
AgentKey string `protobuf:"bytes,2,opt,name=agent_key,json=agentKey,proto3" json:"agent_key,omitempty"`
|
||||||
|
Hostname string `protobuf:"bytes,3,opt,name=hostname,proto3" json:"hostname,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Hello) Reset() {
|
||||||
|
*x = Hello{}
|
||||||
|
mi := &file_mengya_v1_agent_proto_msgTypes[1]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Hello) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*Hello) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *Hello) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_mengya_v1_agent_proto_msgTypes[1]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use Hello.ProtoReflect.Descriptor instead.
|
||||||
|
func (*Hello) Descriptor() ([]byte, []int) {
|
||||||
|
return file_mengya_v1_agent_proto_rawDescGZIP(), []int{1}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Hello) GetServerId() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.ServerId
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Hello) GetAgentKey() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.AgentKey
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Hello) GetHostname() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Hostname
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
type Heartbeat struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Heartbeat) Reset() {
|
||||||
|
*x = Heartbeat{}
|
||||||
|
mi := &file_mengya_v1_agent_proto_msgTypes[2]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Heartbeat) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*Heartbeat) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *Heartbeat) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_mengya_v1_agent_proto_msgTypes[2]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use Heartbeat.ProtoReflect.Descriptor instead.
|
||||||
|
func (*Heartbeat) Descriptor() ([]byte, []int) {
|
||||||
|
return file_mengya_v1_agent_proto_rawDescGZIP(), []int{2}
|
||||||
|
}
|
||||||
|
|
||||||
|
type MetricsReport struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
PayloadJson string `protobuf:"bytes,1,opt,name=payload_json,json=payloadJson,proto3" json:"payload_json,omitempty"`
|
||||||
|
CollectedAtUnixMs int64 `protobuf:"varint,2,opt,name=collected_at_unix_ms,json=collectedAtUnixMs,proto3" json:"collected_at_unix_ms,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *MetricsReport) Reset() {
|
||||||
|
*x = MetricsReport{}
|
||||||
|
mi := &file_mengya_v1_agent_proto_msgTypes[3]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *MetricsReport) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*MetricsReport) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *MetricsReport) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_mengya_v1_agent_proto_msgTypes[3]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use MetricsReport.ProtoReflect.Descriptor instead.
|
||||||
|
func (*MetricsReport) Descriptor() ([]byte, []int) {
|
||||||
|
return file_mengya_v1_agent_proto_rawDescGZIP(), []int{3}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *MetricsReport) GetPayloadJson() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.PayloadJson
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *MetricsReport) GetCollectedAtUnixMs() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.CollectedAtUnixMs
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
type ControlMessage struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
// Types that are valid to be assigned to Payload:
|
||||||
|
//
|
||||||
|
// *ControlMessage_Ack
|
||||||
|
Payload isControlMessage_Payload `protobuf_oneof:"payload"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *ControlMessage) Reset() {
|
||||||
|
*x = ControlMessage{}
|
||||||
|
mi := &file_mengya_v1_agent_proto_msgTypes[4]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *ControlMessage) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*ControlMessage) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *ControlMessage) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_mengya_v1_agent_proto_msgTypes[4]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use ControlMessage.ProtoReflect.Descriptor instead.
|
||||||
|
func (*ControlMessage) Descriptor() ([]byte, []int) {
|
||||||
|
return file_mengya_v1_agent_proto_rawDescGZIP(), []int{4}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *ControlMessage) GetPayload() isControlMessage_Payload {
|
||||||
|
if x != nil {
|
||||||
|
return x.Payload
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *ControlMessage) GetAck() *Ack {
|
||||||
|
if x != nil {
|
||||||
|
if x, ok := x.Payload.(*ControlMessage_Ack); ok {
|
||||||
|
return x.Ack
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type isControlMessage_Payload interface {
|
||||||
|
isControlMessage_Payload()
|
||||||
|
}
|
||||||
|
|
||||||
|
type ControlMessage_Ack struct {
|
||||||
|
Ack *Ack `protobuf:"bytes,1,opt,name=ack,proto3,oneof"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*ControlMessage_Ack) isControlMessage_Payload() {}
|
||||||
|
|
||||||
|
type Ack struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
Message string `protobuf:"bytes,1,opt,name=message,proto3" json:"message,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Ack) Reset() {
|
||||||
|
*x = Ack{}
|
||||||
|
mi := &file_mengya_v1_agent_proto_msgTypes[5]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Ack) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*Ack) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *Ack) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_mengya_v1_agent_proto_msgTypes[5]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use Ack.ProtoReflect.Descriptor instead.
|
||||||
|
func (*Ack) Descriptor() ([]byte, []int) {
|
||||||
|
return file_mengya_v1_agent_proto_rawDescGZIP(), []int{5}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Ack) GetMessage() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Message
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var File_mengya_v1_agent_proto protoreflect.FileDescriptor
|
||||||
|
|
||||||
|
var file_mengya_v1_agent_proto_rawDesc = string([]byte{
|
||||||
|
0x0a, 0x15, 0x6d, 0x65, 0x6e, 0x67, 0x79, 0x61, 0x2f, 0x76, 0x31, 0x2f, 0x61, 0x67, 0x65, 0x6e,
|
||||||
|
0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x09, 0x6d, 0x65, 0x6e, 0x67, 0x79, 0x61, 0x2e,
|
||||||
|
0x76, 0x31, 0x22, 0xaf, 0x01, 0x0a, 0x0c, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x4d, 0x65, 0x73, 0x73,
|
||||||
|
0x61, 0x67, 0x65, 0x12, 0x28, 0x0a, 0x05, 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x18, 0x01, 0x20, 0x01,
|
||||||
|
0x28, 0x0b, 0x32, 0x10, 0x2e, 0x6d, 0x65, 0x6e, 0x67, 0x79, 0x61, 0x2e, 0x76, 0x31, 0x2e, 0x48,
|
||||||
|
0x65, 0x6c, 0x6c, 0x6f, 0x48, 0x00, 0x52, 0x05, 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x12, 0x34, 0x0a,
|
||||||
|
0x09, 0x68, 0x65, 0x61, 0x72, 0x74, 0x62, 0x65, 0x61, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b,
|
||||||
|
0x32, 0x14, 0x2e, 0x6d, 0x65, 0x6e, 0x67, 0x79, 0x61, 0x2e, 0x76, 0x31, 0x2e, 0x48, 0x65, 0x61,
|
||||||
|
0x72, 0x74, 0x62, 0x65, 0x61, 0x74, 0x48, 0x00, 0x52, 0x09, 0x68, 0x65, 0x61, 0x72, 0x74, 0x62,
|
||||||
|
0x65, 0x61, 0x74, 0x12, 0x34, 0x0a, 0x07, 0x6d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, 0x18, 0x03,
|
||||||
|
0x20, 0x01, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x65, 0x6e, 0x67, 0x79, 0x61, 0x2e, 0x76, 0x31,
|
||||||
|
0x2e, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x48, 0x00,
|
||||||
|
0x52, 0x07, 0x6d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, 0x42, 0x09, 0x0a, 0x07, 0x70, 0x61, 0x79,
|
||||||
|
0x6c, 0x6f, 0x61, 0x64, 0x22, 0x5d, 0x0a, 0x05, 0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x12, 0x1b, 0x0a,
|
||||||
|
0x09, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
|
||||||
|
0x52, 0x08, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x61, 0x67,
|
||||||
|
0x65, 0x6e, 0x74, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x61,
|
||||||
|
0x67, 0x65, 0x6e, 0x74, 0x4b, 0x65, 0x79, 0x12, 0x1a, 0x0a, 0x08, 0x68, 0x6f, 0x73, 0x74, 0x6e,
|
||||||
|
0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x68, 0x6f, 0x73, 0x74, 0x6e,
|
||||||
|
0x61, 0x6d, 0x65, 0x22, 0x0b, 0x0a, 0x09, 0x48, 0x65, 0x61, 0x72, 0x74, 0x62, 0x65, 0x61, 0x74,
|
||||||
|
0x22, 0x63, 0x0a, 0x0d, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x73, 0x52, 0x65, 0x70, 0x6f, 0x72,
|
||||||
|
0x74, 0x12, 0x21, 0x0a, 0x0c, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x5f, 0x6a, 0x73, 0x6f,
|
||||||
|
0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64,
|
||||||
|
0x4a, 0x73, 0x6f, 0x6e, 0x12, 0x2f, 0x0a, 0x14, 0x63, 0x6f, 0x6c, 0x6c, 0x65, 0x63, 0x74, 0x65,
|
||||||
|
0x64, 0x5f, 0x61, 0x74, 0x5f, 0x75, 0x6e, 0x69, 0x78, 0x5f, 0x6d, 0x73, 0x18, 0x02, 0x20, 0x01,
|
||||||
|
0x28, 0x03, 0x52, 0x11, 0x63, 0x6f, 0x6c, 0x6c, 0x65, 0x63, 0x74, 0x65, 0x64, 0x41, 0x74, 0x55,
|
||||||
|
0x6e, 0x69, 0x78, 0x4d, 0x73, 0x22, 0x3f, 0x0a, 0x0e, 0x43, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c,
|
||||||
|
0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x22, 0x0a, 0x03, 0x61, 0x63, 0x6b, 0x18, 0x01,
|
||||||
|
0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x6d, 0x65, 0x6e, 0x67, 0x79, 0x61, 0x2e, 0x76, 0x31,
|
||||||
|
0x2e, 0x41, 0x63, 0x6b, 0x48, 0x00, 0x52, 0x03, 0x61, 0x63, 0x6b, 0x42, 0x09, 0x0a, 0x07, 0x70,
|
||||||
|
0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x22, 0x1f, 0x0a, 0x03, 0x41, 0x63, 0x6b, 0x12, 0x18, 0x0a,
|
||||||
|
0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07,
|
||||||
|
0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x32, 0x50, 0x0a, 0x0b, 0x41, 0x67, 0x65, 0x6e, 0x74,
|
||||||
|
0x49, 0x6e, 0x67, 0x65, 0x73, 0x74, 0x12, 0x41, 0x0a, 0x07, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63,
|
||||||
|
0x74, 0x12, 0x17, 0x2e, 0x6d, 0x65, 0x6e, 0x67, 0x79, 0x61, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x67,
|
||||||
|
0x65, 0x6e, 0x74, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x19, 0x2e, 0x6d, 0x65, 0x6e,
|
||||||
|
0x67, 0x79, 0x61, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x4d, 0x65,
|
||||||
|
0x73, 0x73, 0x61, 0x67, 0x65, 0x28, 0x01, 0x30, 0x01, 0x42, 0x3d, 0x5a, 0x3b, 0x6d, 0x65, 0x6e,
|
||||||
|
0x67, 0x79, 0x61, 0x6d, 0x6f, 0x6e, 0x69, 0x74, 0x6f, 0x72, 0x2d, 0x62, 0x61, 0x63, 0x6b, 0x65,
|
||||||
|
0x6e, 0x64, 0x2d, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e,
|
||||||
|
0x61, 0x6c, 0x2f, 0x67, 0x65, 0x6e, 0x2f, 0x6d, 0x65, 0x6e, 0x67, 0x79, 0x61, 0x76, 0x31, 0x3b,
|
||||||
|
0x6d, 0x65, 0x6e, 0x67, 0x79, 0x61, 0x76, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
|
||||||
|
})
|
||||||
|
|
||||||
|
var (
|
||||||
|
file_mengya_v1_agent_proto_rawDescOnce sync.Once
|
||||||
|
file_mengya_v1_agent_proto_rawDescData []byte
|
||||||
|
)
|
||||||
|
|
||||||
|
func file_mengya_v1_agent_proto_rawDescGZIP() []byte {
|
||||||
|
file_mengya_v1_agent_proto_rawDescOnce.Do(func() {
|
||||||
|
file_mengya_v1_agent_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_mengya_v1_agent_proto_rawDesc), len(file_mengya_v1_agent_proto_rawDesc)))
|
||||||
|
})
|
||||||
|
return file_mengya_v1_agent_proto_rawDescData
|
||||||
|
}
|
||||||
|
|
||||||
|
var file_mengya_v1_agent_proto_msgTypes = make([]protoimpl.MessageInfo, 6)
|
||||||
|
var file_mengya_v1_agent_proto_goTypes = []any{
|
||||||
|
(*AgentMessage)(nil), // 0: mengya.v1.AgentMessage
|
||||||
|
(*Hello)(nil), // 1: mengya.v1.Hello
|
||||||
|
(*Heartbeat)(nil), // 2: mengya.v1.Heartbeat
|
||||||
|
(*MetricsReport)(nil), // 3: mengya.v1.MetricsReport
|
||||||
|
(*ControlMessage)(nil), // 4: mengya.v1.ControlMessage
|
||||||
|
(*Ack)(nil), // 5: mengya.v1.Ack
|
||||||
|
}
|
||||||
|
var file_mengya_v1_agent_proto_depIdxs = []int32{
|
||||||
|
1, // 0: mengya.v1.AgentMessage.hello:type_name -> mengya.v1.Hello
|
||||||
|
2, // 1: mengya.v1.AgentMessage.heartbeat:type_name -> mengya.v1.Heartbeat
|
||||||
|
3, // 2: mengya.v1.AgentMessage.metrics:type_name -> mengya.v1.MetricsReport
|
||||||
|
5, // 3: mengya.v1.ControlMessage.ack:type_name -> mengya.v1.Ack
|
||||||
|
0, // 4: mengya.v1.AgentIngest.Connect:input_type -> mengya.v1.AgentMessage
|
||||||
|
4, // 5: mengya.v1.AgentIngest.Connect:output_type -> mengya.v1.ControlMessage
|
||||||
|
5, // [5:6] is the sub-list for method output_type
|
||||||
|
4, // [4:5] is the sub-list for method input_type
|
||||||
|
4, // [4:4] is the sub-list for extension type_name
|
||||||
|
4, // [4:4] is the sub-list for extension extendee
|
||||||
|
0, // [0:4] is the sub-list for field type_name
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() { file_mengya_v1_agent_proto_init() }
|
||||||
|
func file_mengya_v1_agent_proto_init() {
|
||||||
|
if File_mengya_v1_agent_proto != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
file_mengya_v1_agent_proto_msgTypes[0].OneofWrappers = []any{
|
||||||
|
(*AgentMessage_Hello)(nil),
|
||||||
|
(*AgentMessage_Heartbeat)(nil),
|
||||||
|
(*AgentMessage_Metrics)(nil),
|
||||||
|
}
|
||||||
|
file_mengya_v1_agent_proto_msgTypes[4].OneofWrappers = []any{
|
||||||
|
(*ControlMessage_Ack)(nil),
|
||||||
|
}
|
||||||
|
type x struct{}
|
||||||
|
out := protoimpl.TypeBuilder{
|
||||||
|
File: protoimpl.DescBuilder{
|
||||||
|
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||||
|
RawDescriptor: unsafe.Slice(unsafe.StringData(file_mengya_v1_agent_proto_rawDesc), len(file_mengya_v1_agent_proto_rawDesc)),
|
||||||
|
NumEnums: 0,
|
||||||
|
NumMessages: 6,
|
||||||
|
NumExtensions: 0,
|
||||||
|
NumServices: 1,
|
||||||
|
},
|
||||||
|
GoTypes: file_mengya_v1_agent_proto_goTypes,
|
||||||
|
DependencyIndexes: file_mengya_v1_agent_proto_depIdxs,
|
||||||
|
MessageInfos: file_mengya_v1_agent_proto_msgTypes,
|
||||||
|
}.Build()
|
||||||
|
File_mengya_v1_agent_proto = out.File
|
||||||
|
file_mengya_v1_agent_proto_goTypes = nil
|
||||||
|
file_mengya_v1_agent_proto_depIdxs = nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// - protoc-gen-go-grpc v1.5.1
|
||||||
|
// - protoc v5.28.3
|
||||||
|
// source: mengya/v1/agent.proto
|
||||||
|
|
||||||
|
package mengyav1
|
||||||
|
|
||||||
|
import (
|
||||||
|
context "context"
|
||||||
|
grpc "google.golang.org/grpc"
|
||||||
|
codes "google.golang.org/grpc/codes"
|
||||||
|
status "google.golang.org/grpc/status"
|
||||||
|
)
|
||||||
|
|
||||||
|
// This is a compile-time assertion to ensure that this generated file
|
||||||
|
// is compatible with the grpc package it is being compiled against.
|
||||||
|
// Requires gRPC-Go v1.64.0 or later.
|
||||||
|
const _ = grpc.SupportPackageIsVersion9
|
||||||
|
|
||||||
|
const (
|
||||||
|
AgentIngest_Connect_FullMethodName = "/mengya.v1.AgentIngest/Connect"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AgentIngestClient is the client API for AgentIngest service.
|
||||||
|
//
|
||||||
|
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
|
||||||
|
//
|
||||||
|
// AgentIngest: 采集端出站连接,双向流上报指标。
|
||||||
|
type AgentIngestClient interface {
|
||||||
|
Connect(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[AgentMessage, ControlMessage], error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type agentIngestClient struct {
|
||||||
|
cc grpc.ClientConnInterface
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAgentIngestClient(cc grpc.ClientConnInterface) AgentIngestClient {
|
||||||
|
return &agentIngestClient{cc}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *agentIngestClient) Connect(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[AgentMessage, ControlMessage], error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
stream, err := c.cc.NewStream(ctx, &AgentIngest_ServiceDesc.Streams[0], AgentIngest_Connect_FullMethodName, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
x := &grpc.GenericClientStream[AgentMessage, ControlMessage]{ClientStream: stream}
|
||||||
|
return x, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
||||||
|
type AgentIngest_ConnectClient = grpc.BidiStreamingClient[AgentMessage, ControlMessage]
|
||||||
|
|
||||||
|
// AgentIngestServer is the server API for AgentIngest service.
|
||||||
|
// All implementations must embed UnimplementedAgentIngestServer
|
||||||
|
// for forward compatibility.
|
||||||
|
//
|
||||||
|
// AgentIngest: 采集端出站连接,双向流上报指标。
|
||||||
|
type AgentIngestServer interface {
|
||||||
|
Connect(grpc.BidiStreamingServer[AgentMessage, ControlMessage]) error
|
||||||
|
mustEmbedUnimplementedAgentIngestServer()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnimplementedAgentIngestServer must be embedded to have
|
||||||
|
// forward compatible implementations.
|
||||||
|
//
|
||||||
|
// NOTE: this should be embedded by value instead of pointer to avoid a nil
|
||||||
|
// pointer dereference when methods are called.
|
||||||
|
type UnimplementedAgentIngestServer struct{}
|
||||||
|
|
||||||
|
func (UnimplementedAgentIngestServer) Connect(grpc.BidiStreamingServer[AgentMessage, ControlMessage]) error {
|
||||||
|
return status.Errorf(codes.Unimplemented, "method Connect not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedAgentIngestServer) mustEmbedUnimplementedAgentIngestServer() {}
|
||||||
|
func (UnimplementedAgentIngestServer) testEmbeddedByValue() {}
|
||||||
|
|
||||||
|
// UnsafeAgentIngestServer may be embedded to opt out of forward compatibility for this service.
|
||||||
|
// Use of this interface is not recommended, as added methods to AgentIngestServer will
|
||||||
|
// result in compilation errors.
|
||||||
|
type UnsafeAgentIngestServer interface {
|
||||||
|
mustEmbedUnimplementedAgentIngestServer()
|
||||||
|
}
|
||||||
|
|
||||||
|
func RegisterAgentIngestServer(s grpc.ServiceRegistrar, srv AgentIngestServer) {
|
||||||
|
// If the following call pancis, it indicates UnimplementedAgentIngestServer was
|
||||||
|
// embedded by pointer and is nil. This will cause panics if an
|
||||||
|
// unimplemented method is ever invoked, so we test this at initialization
|
||||||
|
// time to prevent it from happening at runtime later due to I/O.
|
||||||
|
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
|
||||||
|
t.testEmbeddedByValue()
|
||||||
|
}
|
||||||
|
s.RegisterService(&AgentIngest_ServiceDesc, srv)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _AgentIngest_Connect_Handler(srv interface{}, stream grpc.ServerStream) error {
|
||||||
|
return srv.(AgentIngestServer).Connect(&grpc.GenericServerStream[AgentMessage, ControlMessage]{ServerStream: stream})
|
||||||
|
}
|
||||||
|
|
||||||
|
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
||||||
|
type AgentIngest_ConnectServer = grpc.BidiStreamingServer[AgentMessage, ControlMessage]
|
||||||
|
|
||||||
|
// AgentIngest_ServiceDesc is the grpc.ServiceDesc for AgentIngest service.
|
||||||
|
// It's only intended for direct use with grpc.RegisterService,
|
||||||
|
// and not to be introspected or modified (even as a copy)
|
||||||
|
var AgentIngest_ServiceDesc = grpc.ServiceDesc{
|
||||||
|
ServiceName: "mengya.v1.AgentIngest",
|
||||||
|
HandlerType: (*AgentIngestServer)(nil),
|
||||||
|
Methods: []grpc.MethodDesc{},
|
||||||
|
Streams: []grpc.StreamDesc{
|
||||||
|
{
|
||||||
|
StreamName: "Connect",
|
||||||
|
Handler: _AgentIngest_Connect_Handler,
|
||||||
|
ServerStreams: true,
|
||||||
|
ClientStreams: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Metadata: "mengya/v1/agent.proto",
|
||||||
|
}
|
||||||
22
mengyamonitor-backend-server/internal/grpcrun/grpcrun.go
Normal file
22
mengyamonitor-backend-server/internal/grpcrun/grpcrun.go
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
package grpcrun
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
|
||||||
|
"mengyamonitor-backend-server/internal/gen/mengyav1"
|
||||||
|
"mengyamonitor-backend-server/internal/ingest"
|
||||||
|
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Serve starts a blocking gRPC server for agent ingest.
|
||||||
|
func Serve(listenAddr string, srv *ingest.Server) error {
|
||||||
|
lis, err := net.Listen("tcp", listenAddr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("listen: %w", err)
|
||||||
|
}
|
||||||
|
g := grpc.NewServer()
|
||||||
|
mengyav1.RegisterAgentIngestServer(g, srv)
|
||||||
|
return g.Serve(lis)
|
||||||
|
}
|
||||||
100
mengyamonitor-backend-server/internal/handler/admin.go
Normal file
100
mengyamonitor-backend-server/internal/handler/admin.go
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/subtle"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"mengyamonitor-backend-server/internal/config"
|
||||||
|
"mengyamonitor-backend-server/internal/model"
|
||||||
|
"mengyamonitor-backend-server/internal/store"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Admin struct {
|
||||||
|
Store *store.Store
|
||||||
|
Cfg *config.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Admin) Auth(c *gin.Context) {
|
||||||
|
var in model.AuthInput
|
||||||
|
if err := c.ShouldBindJSON(&in); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if subtle.ConstantTimeCompare([]byte(in.Token), []byte(h.Cfg.AdminToken)) != 1 {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Admin) ListServers(c *gin.Context) {
|
||||||
|
list, err := h.Store.ListAll(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": list})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Admin) CreateServer(c *gin.Context) {
|
||||||
|
var in model.CreateServerInput
|
||||||
|
if err := c.ShouldBindJSON(&in); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s, err := h.Store.Create(c.Request.Context(), in)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, gin.H{"data": s})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Admin) UpdateServer(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
var in model.UpdateServerInput
|
||||||
|
if err := c.ShouldBindJSON(&in); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s, err := h.Store.Update(c.Request.Context(), id, in)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": s})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Admin) DeleteServer(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
if err := h.Store.Delete(c.Request.Context(), id); err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Admin) Reorder(c *gin.Context) {
|
||||||
|
var in model.ReorderInput
|
||||||
|
if err := c.ShouldBindJSON(&in); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.Store.Reorder(c.Request.Context(), in.IDs); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
|
}
|
||||||
70
mengyamonitor-backend-server/internal/handler/public.go
Normal file
70
mengyamonitor-backend-server/internal/handler/public.go
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"mengyamonitor-backend-server/internal/model"
|
||||||
|
"mengyamonitor-backend-server/internal/store"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Public struct {
|
||||||
|
Store *store.Store
|
||||||
|
}
|
||||||
|
|
||||||
|
// publicServer hides agent_key from unauthenticated API consumers.
|
||||||
|
type publicServer struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
SortOrder int `json:"sortOrder"`
|
||||||
|
CreatedAt time.Time `json:"createdAt,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func toPublicServers(in []model.MonitoredServer) []publicServer {
|
||||||
|
out := make([]publicServer, 0, len(in))
|
||||||
|
for _, s := range in {
|
||||||
|
out = append(out, publicServer{
|
||||||
|
ID: s.ID, Name: s.Name, URL: s.URL, Enabled: s.Enabled,
|
||||||
|
SortOrder: s.SortOrder, CreatedAt: s.CreatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Public) Health(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Public) ListServers(c *gin.Context) {
|
||||||
|
list, err := h.Store.ListEnabled(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": toPublicServers(list)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Public) Root(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"service": "萌芽监控 — 中心服务",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"public": []string{
|
||||||
|
"GET /api/health",
|
||||||
|
"GET /api/servers",
|
||||||
|
"GET /api/ws/monitor — WebSocket 指标快照",
|
||||||
|
"gRPC AgentIngest — 见环境变量 GRPC_PORT",
|
||||||
|
},
|
||||||
|
"admin": []string{
|
||||||
|
"POST /api/admin/auth",
|
||||||
|
"GET /api/admin/servers",
|
||||||
|
"POST /api/admin/servers",
|
||||||
|
"PUT /api/admin/servers/:id",
|
||||||
|
"DELETE /api/admin/servers/:id",
|
||||||
|
"PUT /api/admin/servers/reorder",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
50
mengyamonitor-backend-server/internal/handler/ws.go
Normal file
50
mengyamonitor-backend-server/internal/handler/ws.go
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"mengyamonitor-backend-server/internal/hub"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
var wsUpgrader = websocket.Upgrader{
|
||||||
|
ReadBufferSize: 1024,
|
||||||
|
WriteBufferSize: 1024,
|
||||||
|
CheckOrigin: func(r *http.Request) bool {
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// MonitorWebSocket upgrades to WebSocket and streams hub snapshots.
|
||||||
|
func MonitorWebSocket(h *hub.Hub) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
conn, err := wsUpgrader.Upgrade(c.Writer, c.Request, nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("ws: upgrade: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer h.Unregister(conn)
|
||||||
|
snap, err := h.BuildSnapshotMessage()
|
||||||
|
if err != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = conn.SetWriteDeadline(time.Now().Add(15 * time.Second))
|
||||||
|
if err := conn.WriteMessage(websocket.TextMessage, snap); err != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.AddSubscriber(conn)
|
||||||
|
for {
|
||||||
|
_ = conn.SetReadDeadline(time.Now().Add(120 * time.Second))
|
||||||
|
_, _, err := conn.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
108
mengyamonitor-backend-server/internal/hub/hub.go
Normal file
108
mengyamonitor-backend-server/internal/hub/hub.go
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
package hub
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WSMessage is sent to browsers (matches frontend ServerStatus shape).
|
||||||
|
type WSMessage struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Data map[string]json.RawMessage `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Hub struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
// serverId -> latest metrics JSON object (not wrapped)
|
||||||
|
metrics map[string]json.RawMessage
|
||||||
|
|
||||||
|
subsMu sync.Mutex
|
||||||
|
// subscribers for broadcast
|
||||||
|
conns map[*websocket.Conn]struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func New() *Hub {
|
||||||
|
return &Hub{
|
||||||
|
metrics: make(map[string]json.RawMessage),
|
||||||
|
conns: make(map[*websocket.Conn]struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Hub) SetMetrics(serverID string, metricsJSON []byte) {
|
||||||
|
h.mu.Lock()
|
||||||
|
if len(metricsJSON) == 0 {
|
||||||
|
delete(h.metrics, serverID)
|
||||||
|
} else {
|
||||||
|
h.metrics[serverID] = json.RawMessage(append([]byte(nil), metricsJSON...))
|
||||||
|
}
|
||||||
|
h.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Hub) snapshotData() map[string]json.RawMessage {
|
||||||
|
h.mu.RLock()
|
||||||
|
defer h.mu.RUnlock()
|
||||||
|
out := make(map[string]json.RawMessage, len(h.metrics))
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
for id, raw := range h.metrics {
|
||||||
|
// Wrap as ServerStatus for frontend compatibility
|
||||||
|
wrap := map[string]any{
|
||||||
|
"serverId": id,
|
||||||
|
"online": true,
|
||||||
|
"metrics": json.RawMessage(raw),
|
||||||
|
"lastUpdate": now,
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(wrap)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[id] = b
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Hub) BuildSnapshotMessage() ([]byte, error) {
|
||||||
|
msg := WSMessage{
|
||||||
|
Type: "snapshot",
|
||||||
|
Data: h.snapshotData(),
|
||||||
|
}
|
||||||
|
return json.Marshal(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddSubscriber registers for broadcast fan-out (call after sending the initial snapshot).
|
||||||
|
func (h *Hub) AddSubscriber(c *websocket.Conn) {
|
||||||
|
h.subsMu.Lock()
|
||||||
|
h.conns[c] = struct{}{}
|
||||||
|
h.subsMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Hub) Unregister(c *websocket.Conn) {
|
||||||
|
h.subsMu.Lock()
|
||||||
|
delete(h.conns, c)
|
||||||
|
h.subsMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// BroadcastSnapshot pushes full snapshot to all subscribers.
|
||||||
|
func (h *Hub) BroadcastSnapshot() {
|
||||||
|
body, err := h.BuildSnapshotMessage()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.subsMu.Lock()
|
||||||
|
defer h.subsMu.Unlock()
|
||||||
|
for c := range h.conns {
|
||||||
|
_ = c.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||||
|
if err := c.WriteMessage(websocket.TextMessage, body); err != nil {
|
||||||
|
_ = c.Close()
|
||||||
|
delete(h.conns, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotifyMetricsUpdate should be called after SetMetrics to push to browsers.
|
||||||
|
func (h *Hub) NotifyMetricsUpdate(serverID string, metricsJSON []byte) {
|
||||||
|
h.SetMetrics(serverID, metricsJSON)
|
||||||
|
h.BroadcastSnapshot()
|
||||||
|
}
|
||||||
82
mengyamonitor-backend-server/internal/ingest/server.go
Normal file
82
mengyamonitor-backend-server/internal/ingest/server.go
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
package ingest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"mengyamonitor-backend-server/internal/gen/mengyav1"
|
||||||
|
"mengyamonitor-backend-server/internal/hub"
|
||||||
|
"mengyamonitor-backend-server/internal/store"
|
||||||
|
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Server struct {
|
||||||
|
mengyav1.UnimplementedAgentIngestServer
|
||||||
|
Store *store.Store
|
||||||
|
Hub *hub.Hub
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) Connect(stream grpc.BidiStreamingServer[mengyav1.AgentMessage, mengyav1.ControlMessage]) error {
|
||||||
|
var authedID string
|
||||||
|
for {
|
||||||
|
msg, err := stream.Recv()
|
||||||
|
if err == io.EOF {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := stream.Context()
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case msg.GetHello() != nil:
|
||||||
|
h := msg.GetHello()
|
||||||
|
srv, err := s.Store.VerifyAgent(ctx, h.GetServerId(), h.GetAgentKey())
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
log.Printf("ingest: auth failed server_id=%q: no such id (SERVER_ID must be the UUID from admin table, not the display name)", h.GetServerId())
|
||||||
|
} else {
|
||||||
|
log.Printf("ingest: auth failed server_id=%s: %v", h.GetServerId(), err)
|
||||||
|
}
|
||||||
|
return status.Errorf(codes.Unauthenticated, "invalid credentials")
|
||||||
|
}
|
||||||
|
authedID = srv.ID
|
||||||
|
_ = stream.Send(&mengyav1.ControlMessage{
|
||||||
|
Payload: &mengyav1.ControlMessage_Ack{
|
||||||
|
Ack: &mengyav1.Ack{Message: "hello_ok"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
case msg.GetHeartbeat() != nil:
|
||||||
|
if authedID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_ = stream.Send(&mengyav1.ControlMessage{
|
||||||
|
Payload: &mengyav1.ControlMessage_Ack{
|
||||||
|
Ack: &mengyav1.Ack{Message: "pong"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
case msg.GetMetrics() != nil:
|
||||||
|
if authedID == "" {
|
||||||
|
return status.Errorf(codes.FailedPrecondition, "send hello first")
|
||||||
|
}
|
||||||
|
m := msg.GetMetrics()
|
||||||
|
if len(m.GetPayloadJson()) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s.Hub.NotifyMetricsUpdate(authedID, []byte(m.GetPayloadJson()))
|
||||||
|
_ = stream.Send(&mengyav1.ControlMessage{
|
||||||
|
Payload: &mengyav1.ControlMessage_Ack{
|
||||||
|
Ack: &mengyav1.Ack{Message: "metrics_ok"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
23
mengyamonitor-backend-server/internal/middleware/admin.go
Normal file
23
mengyamonitor-backend-server/internal/middleware/admin.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/subtle"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"mengyamonitor-backend-server/internal/config"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AdminToken rejects requests without a valid X-Admin-Token header.
|
||||||
|
func AdminToken(cfg *config.Config) gin.HandlerFunc {
|
||||||
|
tok := []byte(cfg.AdminToken)
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
given := []byte(c.GetHeader("X-Admin-Token"))
|
||||||
|
if subtle.ConstantTimeCompare(given, tok) != 1 {
|
||||||
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
35
mengyamonitor-backend-server/internal/model/server.go
Normal file
35
mengyamonitor-backend-server/internal/model/server.go
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// MonitoredServer is persisted and exposed to the frontend display.
|
||||||
|
type MonitoredServer struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
SortOrder int `json:"sortOrder"`
|
||||||
|
AgentKey string `json:"agentKey,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"createdAt,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateServerInput struct {
|
||||||
|
Name string `json:"name" binding:"required"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Enabled *bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateServerInput struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
URL *string `json:"url"`
|
||||||
|
Enabled *bool `json:"enabled"`
|
||||||
|
AgentKey *string `json:"agentKey"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReorderInput struct {
|
||||||
|
IDs []string `json:"ids" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthInput struct {
|
||||||
|
Token string `json:"token" binding:"required"`
|
||||||
|
}
|
||||||
71
mengyamonitor-backend-server/internal/router/router.go
Normal file
71
mengyamonitor-backend-server/internal/router/router.go
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
package router
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"mengyamonitor-backend-server/internal/config"
|
||||||
|
"mengyamonitor-backend-server/internal/handler"
|
||||||
|
"mengyamonitor-backend-server/internal/hub"
|
||||||
|
"mengyamonitor-backend-server/internal/middleware"
|
||||||
|
"mengyamonitor-backend-server/internal/store"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func New(cfg *config.Config, st *store.Store, h *hub.Hub) *gin.Engine {
|
||||||
|
gin.SetMode(gin.ReleaseMode)
|
||||||
|
r := gin.New()
|
||||||
|
r.Use(gin.Logger(), gin.Recovery())
|
||||||
|
|
||||||
|
// 公网宽松 CORS:任意 Origin + 透传浏览器请求的 Header(预检)
|
||||||
|
r.Use(func(c *gin.Context) {
|
||||||
|
h := c.Writer.Header()
|
||||||
|
origin := c.GetHeader("Origin")
|
||||||
|
if origin != "" {
|
||||||
|
h.Set("Access-Control-Allow-Origin", origin)
|
||||||
|
h.Set("Access-Control-Allow-Credentials", "true")
|
||||||
|
} else {
|
||||||
|
h.Set("Access-Control-Allow-Origin", "*")
|
||||||
|
}
|
||||||
|
h.Set("Access-Control-Allow-Methods", "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||||
|
if reqH := c.GetHeader("Access-Control-Request-Headers"); reqH != "" {
|
||||||
|
h.Set("Access-Control-Allow-Headers", reqH)
|
||||||
|
} else {
|
||||||
|
h.Set("Access-Control-Allow-Headers", "Content-Type, X-Admin-Token, Authorization, Accept, Origin, X-Requested-With")
|
||||||
|
}
|
||||||
|
h.Set("Access-Control-Max-Age", "86400")
|
||||||
|
if c.Request.Method == http.MethodOptions {
|
||||||
|
c.AbortWithStatus(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
})
|
||||||
|
|
||||||
|
pub := &handler.Public{Store: st}
|
||||||
|
r.GET("/", pub.Root)
|
||||||
|
r.GET("/api/health", pub.Health)
|
||||||
|
r.GET("/api/servers", pub.ListServers)
|
||||||
|
r.GET("/api/ws/monitor", handler.MonitorWebSocket(h))
|
||||||
|
|
||||||
|
adm := &handler.Admin{Store: st, Cfg: cfg}
|
||||||
|
r.POST("/api/admin/auth", adm.Auth)
|
||||||
|
adminG := r.Group("/api/admin")
|
||||||
|
adminG.Use(middleware.AdminToken(cfg))
|
||||||
|
{
|
||||||
|
adminG.GET("/servers", adm.ListServers)
|
||||||
|
adminG.POST("/servers", adm.CreateServer)
|
||||||
|
adminG.PUT("/servers/reorder", adm.Reorder)
|
||||||
|
adminG.PUT("/servers/:id", adm.UpdateServer)
|
||||||
|
adminG.DELETE("/servers/:id", adm.DeleteServer)
|
||||||
|
}
|
||||||
|
|
||||||
|
r.NoRoute(func(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{
|
||||||
|
"error": "not_found",
|
||||||
|
"path": c.Request.URL.Path,
|
||||||
|
"method": c.Request.Method,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return r
|
||||||
|
}
|
||||||
307
mengyamonitor-backend-server/internal/store/sqlite.go
Normal file
307
mengyamonitor-backend-server/internal/store/sqlite.go
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/subtle"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"mengyamonitor-backend-server/internal/model"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Store struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func Open(dbPath string) (*Store, error) {
|
||||||
|
dir := filepath.Dir(dbPath)
|
||||||
|
if dir != "." && dir != "" {
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
db, err := sql.Open("sqlite", dbPath+"?_pragma=foreign_keys(1)")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := db.Ping(); err != nil {
|
||||||
|
_ = db.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s := &Store{db: db}
|
||||||
|
if err := s.migrate(); err != nil {
|
||||||
|
_ = db.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Close() error {
|
||||||
|
return s.db.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) migrate() error {
|
||||||
|
const ddl = `
|
||||||
|
CREATE TABLE IF NOT EXISTS servers (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_servers_sort ON servers(sort_order);
|
||||||
|
`
|
||||||
|
if _, err := s.db.Exec(ddl); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.ensureAgentKeyColumn()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ensureAgentKeyColumn() error {
|
||||||
|
rows, err := s.db.Query(`PRAGMA table_info(servers)`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var cid int
|
||||||
|
var name, typ string
|
||||||
|
var notnull, pk int
|
||||||
|
var dflt sql.NullString
|
||||||
|
if err := rows.Scan(&cid, &name, &typ, ¬null, &dflt, &pk); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if name == "agent_key" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err = s.db.Exec(`ALTER TABLE servers ADD COLUMN agent_key TEXT`); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Backfill keys for existing rows so gRPC can be enabled without manual SQL.
|
||||||
|
bRows, err := s.db.Query(`SELECT id FROM servers WHERE agent_key IS NULL OR agent_key = ''`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var ids []string
|
||||||
|
for bRows.Next() {
|
||||||
|
var id string
|
||||||
|
if err := bRows.Scan(&id); err != nil {
|
||||||
|
_ = bRows.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
_ = bRows.Close()
|
||||||
|
for _, id := range ids {
|
||||||
|
if _, err := s.db.Exec(`UPDATE servers SET agent_key = ? WHERE id = ?`, uuid.NewString(), id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ListEnabled(ctx context.Context) ([]model.MonitoredServer, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx, `
|
||||||
|
SELECT id, name, url, enabled, sort_order, agent_key, created_at
|
||||||
|
FROM servers WHERE enabled = 1 ORDER BY sort_order ASC, created_at ASC`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
return scanServers(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ListAll(ctx context.Context) ([]model.MonitoredServer, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx, `
|
||||||
|
SELECT id, name, url, enabled, sort_order, agent_key, created_at
|
||||||
|
FROM servers ORDER BY sort_order ASC, created_at ASC`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
return scanServers(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanServers(rows *sql.Rows) ([]model.MonitoredServer, error) {
|
||||||
|
out := make([]model.MonitoredServer, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var m model.MonitoredServer
|
||||||
|
var enabled int
|
||||||
|
var created, agKey sql.NullString
|
||||||
|
if err := rows.Scan(&m.ID, &m.Name, &m.URL, &enabled, &m.SortOrder, &agKey, &created); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
m.Enabled = enabled != 0
|
||||||
|
if agKey.Valid {
|
||||||
|
m.AgentKey = agKey.String
|
||||||
|
}
|
||||||
|
if t, err := time.Parse(time.RFC3339Nano, created.String); err == nil {
|
||||||
|
m.CreatedAt = t
|
||||||
|
}
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Create(ctx context.Context, in model.CreateServerInput) (*model.MonitoredServer, error) {
|
||||||
|
name := strings.TrimSpace(in.Name)
|
||||||
|
urlStr := normalizeURL(strings.TrimSpace(in.URL))
|
||||||
|
if name == "" {
|
||||||
|
return nil, errors.New("invalid name")
|
||||||
|
}
|
||||||
|
enabled := true
|
||||||
|
if in.Enabled != nil {
|
||||||
|
enabled = *in.Enabled
|
||||||
|
}
|
||||||
|
var maxOrder int
|
||||||
|
_ = s.db.QueryRowContext(ctx, `SELECT COALESCE(MAX(sort_order), -1) FROM servers`).Scan(&maxOrder)
|
||||||
|
id := uuid.NewString()
|
||||||
|
agentKey := uuid.NewString()
|
||||||
|
now := time.Now().UTC()
|
||||||
|
_, err := s.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO servers (id, name, url, enabled, sort_order, agent_key, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
id, name, urlStr, boolToInt(enabled), maxOrder+1, agentKey, now.Format(time.RFC3339Nano))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &model.MonitoredServer{
|
||||||
|
ID: id, Name: name, URL: urlStr, Enabled: enabled, SortOrder: maxOrder + 1, AgentKey: agentKey, CreatedAt: now,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Update(ctx context.Context, id string, in model.UpdateServerInput) (*model.MonitoredServer, error) {
|
||||||
|
cur, err := s.Get(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
name := cur.Name
|
||||||
|
urlStr := cur.URL
|
||||||
|
enabled := cur.Enabled
|
||||||
|
if in.Name != "" {
|
||||||
|
name = strings.TrimSpace(in.Name)
|
||||||
|
}
|
||||||
|
if in.URL != nil {
|
||||||
|
urlStr = normalizeURL(strings.TrimSpace(*in.URL))
|
||||||
|
}
|
||||||
|
if in.Enabled != nil {
|
||||||
|
enabled = *in.Enabled
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
return nil, errors.New("invalid name")
|
||||||
|
}
|
||||||
|
agentKey := cur.AgentKey
|
||||||
|
if in.AgentKey != nil {
|
||||||
|
agentKey = *in.AgentKey
|
||||||
|
}
|
||||||
|
_, err = s.db.ExecContext(ctx, `UPDATE servers SET name = ?, url = ?, enabled = ?, agent_key = ? WHERE id = ?`,
|
||||||
|
name, urlStr, boolToInt(enabled), nullIfEmpty(agentKey), id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cur.Name, cur.URL, cur.Enabled, cur.AgentKey = name, urlStr, enabled, agentKey
|
||||||
|
return cur, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Get(ctx context.Context, id string) (*model.MonitoredServer, error) {
|
||||||
|
var m model.MonitoredServer
|
||||||
|
var enabled int
|
||||||
|
var created, agKey sql.NullString
|
||||||
|
err := s.db.QueryRowContext(ctx, `
|
||||||
|
SELECT id, name, url, enabled, sort_order, agent_key, created_at FROM servers WHERE id = ?`, id).
|
||||||
|
Scan(&m.ID, &m.Name, &m.URL, &enabled, &m.SortOrder, &agKey, &created)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
m.Enabled = enabled != 0
|
||||||
|
if agKey.Valid {
|
||||||
|
m.AgentKey = agKey.String
|
||||||
|
}
|
||||||
|
if t, err := time.Parse(time.RFC3339Nano, created.String); err == nil {
|
||||||
|
m.CreatedAt = t
|
||||||
|
}
|
||||||
|
return &m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyAgent checks server_id + agent_key for gRPC ingest.
|
||||||
|
func (s *Store) VerifyAgent(ctx context.Context, id, key string) (*model.MonitoredServer, error) {
|
||||||
|
srv, err := s.Get(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !srv.Enabled {
|
||||||
|
return nil, errors.New("disabled")
|
||||||
|
}
|
||||||
|
if srv.AgentKey == "" {
|
||||||
|
return nil, errors.New("agent_key not set")
|
||||||
|
}
|
||||||
|
if subtle.ConstantTimeCompare([]byte(srv.AgentKey), []byte(key)) != 1 {
|
||||||
|
return nil, errors.New("invalid agent_key")
|
||||||
|
}
|
||||||
|
return srv, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func nullIfEmpty(s string) any {
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Delete(ctx context.Context, id string) error {
|
||||||
|
res, err := s.db.ExecContext(ctx, `DELETE FROM servers WHERE id = ?`, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
if n == 0 {
|
||||||
|
return sql.ErrNoRows
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Reorder(ctx context.Context, ids []string) error {
|
||||||
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
for i, id := range ids {
|
||||||
|
if _, err := tx.ExecContext(ctx, `UPDATE servers SET sort_order = ? WHERE id = ?`, i, id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolToInt(b bool) int {
|
||||||
|
if b {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeURL(u string) string {
|
||||||
|
if u == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(strings.ToLower(u), "http://") && !strings.HasPrefix(strings.ToLower(u), "https://") {
|
||||||
|
return "http://" + u
|
||||||
|
}
|
||||||
|
return u
|
||||||
|
}
|
||||||
39
mengyamonitor-backend-server/main.go
Normal file
39
mengyamonitor-backend-server/main.go
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"mengyamonitor-backend-server/internal/config"
|
||||||
|
"mengyamonitor-backend-server/internal/grpcrun"
|
||||||
|
"mengyamonitor-backend-server/internal/hub"
|
||||||
|
"mengyamonitor-backend-server/internal/ingest"
|
||||||
|
"mengyamonitor-backend-server/internal/router"
|
||||||
|
"mengyamonitor-backend-server/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfg := config.Load()
|
||||||
|
st, err := store.Open(cfg.DBPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("store: %v", err)
|
||||||
|
}
|
||||||
|
defer st.Close()
|
||||||
|
|
||||||
|
metricsHub := hub.New()
|
||||||
|
ingestSrv := &ingest.Server{Store: st, Hub: metricsHub}
|
||||||
|
|
||||||
|
grpcAddr := fmt.Sprintf("%s:%s", cfg.Host, cfg.GRPCPort)
|
||||||
|
go func() {
|
||||||
|
log.Printf("gRPC AgentIngest listening on %s", grpcAddr)
|
||||||
|
if err := grpcrun.Serve(grpcAddr, ingestSrv); err != nil {
|
||||||
|
log.Fatalf("grpc: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
httpAddr := fmt.Sprintf("%s:%s", cfg.Host, cfg.Port)
|
||||||
|
log.Printf("central HTTP/WS on http://%s", httpAddr)
|
||||||
|
if err := router.New(cfg, st, metricsHub).Run(httpAddr); err != nil {
|
||||||
|
log.Fatalf("http: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
41
mengyamonitor-backend-server/proto/mengya/v1/agent.proto
Normal file
41
mengyamonitor-backend-server/proto/mengya/v1/agent.proto
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
package mengya.v1;
|
||||||
|
|
||||||
|
option go_package = "mengyamonitor-backend-server/internal/gen/mengyav1;mengyav1";
|
||||||
|
|
||||||
|
// AgentIngest: 采集端出站连接,双向流上报指标。
|
||||||
|
service AgentIngest {
|
||||||
|
rpc Connect(stream AgentMessage) returns (stream ControlMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
message AgentMessage {
|
||||||
|
oneof payload {
|
||||||
|
Hello hello = 1;
|
||||||
|
Heartbeat heartbeat = 2;
|
||||||
|
MetricsReport metrics = 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message Hello {
|
||||||
|
string server_id = 1;
|
||||||
|
string agent_key = 2;
|
||||||
|
string hostname = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message Heartbeat {}
|
||||||
|
|
||||||
|
message MetricsReport {
|
||||||
|
string payload_json = 1;
|
||||||
|
int64 collected_at_unix_ms = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ControlMessage {
|
||||||
|
oneof payload {
|
||||||
|
Ack ack = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message Ack {
|
||||||
|
string message = 1;
|
||||||
|
}
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# 编译脚本 - 兼容旧版本 GLIBC
|
|
||||||
|
|
||||||
echo "开始编译 mengyamonitor-backend..."
|
|
||||||
|
|
||||||
# 禁用 CGO,使用纯 Go 编译(不依赖系统 C 库)
|
|
||||||
export CGO_ENABLED=0
|
|
||||||
|
|
||||||
# 设置目标平台
|
|
||||||
export GOOS=linux
|
|
||||||
export GOARCH=amd64
|
|
||||||
|
|
||||||
# 编译(静态链接)
|
|
||||||
go build -ldflags="-s -w" -o mengyamonitor-backend .
|
|
||||||
|
|
||||||
if [ $? -eq 0 ]; then
|
|
||||||
echo "编译成功!"
|
|
||||||
echo "二进制文件: mengyamonitor-backend"
|
|
||||||
echo ""
|
|
||||||
echo "检查文件信息:"
|
|
||||||
file mengyamonitor-backend
|
|
||||||
echo ""
|
|
||||||
echo "检查依赖库:"
|
|
||||||
ldd mengyamonitor-backend 2>/dev/null || echo "静态链接,无外部依赖"
|
|
||||||
else
|
|
||||||
echo "编译失败!"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# Linux ARM64 交叉编译脚本
|
|
||||||
|
|
||||||
echo "开始交叉编译 mengyamonitor-backend (Linux ARM64)..."
|
|
||||||
|
|
||||||
# 禁用 CGO,使用纯 Go 编译(不依赖系统 C 库)
|
|
||||||
export CGO_ENABLED=0
|
|
||||||
|
|
||||||
# 设置目标平台为 Linux ARM64
|
|
||||||
export GOOS=linux
|
|
||||||
export GOARCH=arm64
|
|
||||||
|
|
||||||
# 编译(静态链接)
|
|
||||||
go build -ldflags="-s -w" -o mengyamonitor-backend-arm64 .
|
|
||||||
|
|
||||||
if [ $? -eq 0 ]; then
|
|
||||||
echo "编译成功!"
|
|
||||||
echo "二进制文件: mengyamonitor-backend-arm64"
|
|
||||||
echo ""
|
|
||||||
echo "目标平台: Linux ARM64"
|
|
||||||
echo "编译模式: 静态链接,无外部依赖"
|
|
||||||
echo ""
|
|
||||||
echo "检查文件信息:"
|
|
||||||
file mengyamonitor-backend-arm64 2>/dev/null || echo "文件已生成: mengyamonitor-backend-arm64"
|
|
||||||
echo ""
|
|
||||||
ls -lh mengyamonitor-backend-arm64
|
|
||||||
else
|
|
||||||
echo "编译失败!"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
module mengyamonitor-backend
|
|
||||||
|
|
||||||
go 1.22
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// LatencyInfo 延迟信息
|
|
||||||
type LatencyInfo struct {
|
|
||||||
ClientToServer float64 `json:"clientToServer"` // 客户端到服务器延迟(ms),由前端计算
|
|
||||||
External map[string]string `json:"external"` // 外部网站延迟
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkExternalLatency 检测外部网站延迟
|
|
||||||
func checkExternalLatency(host string, timeout time.Duration) string {
|
|
||||||
start := time.Now()
|
|
||||||
|
|
||||||
// 尝试 TCP 连接 80 端口
|
|
||||||
conn, err := net.DialTimeout("tcp", host+":80", timeout)
|
|
||||||
if err != nil {
|
|
||||||
// 如果 80 端口失败,尝试 443 (HTTPS)
|
|
||||||
conn, err = net.DialTimeout("tcp", host+":443", timeout)
|
|
||||||
if err != nil {
|
|
||||||
return "超时"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
defer conn.Close()
|
|
||||||
|
|
||||||
latency := time.Since(start).Milliseconds()
|
|
||||||
|
|
||||||
// 检查是否超时(超过超时时间的一半就认为可能有问题)
|
|
||||||
if latency > timeout.Milliseconds()/2 {
|
|
||||||
return "超时"
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Sprintf("%d ms", latency)
|
|
||||||
}
|
|
||||||
|
|
||||||
// readExternalLatencies 读取外部网站延迟
|
|
||||||
func readExternalLatencies() map[string]string {
|
|
||||||
latencies := make(map[string]string)
|
|
||||||
timeout := 3 * time.Second
|
|
||||||
|
|
||||||
// 检测百度
|
|
||||||
latencies["baidu.com"] = checkExternalLatency("baidu.com", timeout)
|
|
||||||
|
|
||||||
// 检测谷歌
|
|
||||||
latencies["google.com"] = checkExternalLatency("google.com", timeout)
|
|
||||||
|
|
||||||
// 检测 GitHub
|
|
||||||
latencies["github.com"] = checkExternalLatency("github.com", timeout)
|
|
||||||
|
|
||||||
return latencies
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,265 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"runtime"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// envelope keeps JSON responses consistent.
|
|
||||||
type envelope map[string]any
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
host := getenv("HOST", "0.0.0.0")
|
|
||||||
port := getenv("PORT", "9292")
|
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
mux.HandleFunc("/", rootHandler)
|
|
||||||
mux.HandleFunc("/api/health", healthHandler)
|
|
||||||
// 拆分的细粒度API端点
|
|
||||||
mux.HandleFunc("/api/metrics/cpu", cpuMetricsHandler)
|
|
||||||
mux.HandleFunc("/api/metrics/memory", memoryMetricsHandler)
|
|
||||||
mux.HandleFunc("/api/metrics/storage", storageMetricsHandler)
|
|
||||||
mux.HandleFunc("/api/metrics/gpu", gpuMetricsHandler)
|
|
||||||
mux.HandleFunc("/api/metrics/network", networkMetricsHandler)
|
|
||||||
mux.HandleFunc("/api/metrics/system", systemMetricsHandler)
|
|
||||||
mux.HandleFunc("/api/metrics/docker", dockerMetricsHandler)
|
|
||||||
mux.HandleFunc("/api/metrics/latency", latencyMetricsHandler)
|
|
||||||
|
|
||||||
srv := &http.Server{
|
|
||||||
Addr: fmt.Sprintf("%s:%s", host, port),
|
|
||||||
Handler: loggingMiddleware(corsMiddleware(mux)),
|
|
||||||
ReadHeaderTimeout: 5 * time.Second,
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Printf("server starting on http://%s:%s", host, port)
|
|
||||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
||||||
log.Fatalf("server error: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func rootHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
respondJSON(w, http.StatusOK, envelope{
|
|
||||||
"service": "萌芽监控面板 API",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"endpoints": []map[string]string{
|
|
||||||
{
|
|
||||||
"path": "/",
|
|
||||||
"description": "API 信息和可用端点列表",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "/api/health",
|
|
||||||
"description": "健康检查",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "/api/metrics/cpu",
|
|
||||||
"description": "获取 CPU 监控数据",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "/api/metrics/memory",
|
|
||||||
"description": "获取内存监控数据",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "/api/metrics/storage",
|
|
||||||
"description": "获取存储监控数据",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "/api/metrics/gpu",
|
|
||||||
"description": "获取 GPU 监控数据",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "/api/metrics/network",
|
|
||||||
"description": "获取网络接口监控数据",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "/api/metrics/system",
|
|
||||||
"description": "获取系统统计信息(进程、包、磁盘速度等)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "/api/metrics/docker",
|
|
||||||
"description": "获取 Docker 容器监控数据",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func healthHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
respondJSON(w, http.StatusOK, envelope{
|
|
||||||
"status": "ok",
|
|
||||||
"timestamp": time.Now().UTC(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// CPU监控数据
|
|
||||||
func cpuMetricsHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
cpuModel := firstMatchInFile("/proc/cpuinfo", "model name")
|
|
||||||
cpuUsage, err := readCPUUsage()
|
|
||||||
if err != nil {
|
|
||||||
cpuUsage = 0
|
|
||||||
}
|
|
||||||
cpuTemp := readCPUTemperature()
|
|
||||||
perCoreUsage := readPerCoreUsage()
|
|
||||||
loads := readLoadAverages()
|
|
||||||
|
|
||||||
cores := runtime.NumCPU()
|
|
||||||
respondJSON(w, http.StatusOK, envelope{
|
|
||||||
"data": CPUMetrics{
|
|
||||||
Model: cpuModel,
|
|
||||||
Cores: cores,
|
|
||||||
UsagePercent: round(cpuUsage, 2),
|
|
||||||
LoadAverages: loads,
|
|
||||||
Temperature: cpuTemp,
|
|
||||||
PerCoreUsage: perCoreUsage,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 内存监控数据
|
|
||||||
func memoryMetricsHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
mem, err := readMemory()
|
|
||||||
if err != nil {
|
|
||||||
respondJSON(w, http.StatusInternalServerError, envelope{
|
|
||||||
"error": "failed to read memory",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
respondJSON(w, http.StatusOK, envelope{
|
|
||||||
"data": mem,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 存储监控数据
|
|
||||||
func storageMetricsHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
storage, err := readAllStorage()
|
|
||||||
if err != nil {
|
|
||||||
respondJSON(w, http.StatusInternalServerError, envelope{
|
|
||||||
"error": "failed to read storage",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
respondJSON(w, http.StatusOK, envelope{
|
|
||||||
"data": storage,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// GPU监控数据
|
|
||||||
func gpuMetricsHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
gpu := readGPU()
|
|
||||||
respondJSON(w, http.StatusOK, envelope{
|
|
||||||
"data": gpu,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 网络监控数据
|
|
||||||
func networkMetricsHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
network := readNetworkInterfaces()
|
|
||||||
respondJSON(w, http.StatusOK, envelope{
|
|
||||||
"data": network,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 系统统计信息
|
|
||||||
func systemMetricsHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
stats := readSystemStats()
|
|
||||||
|
|
||||||
// 读取系统基本信息
|
|
||||||
hostname, _ := os.Hostname()
|
|
||||||
osInfo := readOSInfo()
|
|
||||||
uptime := readUptime()
|
|
||||||
|
|
||||||
// 计算总网络速度(汇总所有接口)
|
|
||||||
networkInterfaces := readNetworkInterfaces()
|
|
||||||
var totalRxSpeed, totalTxSpeed float64
|
|
||||||
for _, iface := range networkInterfaces {
|
|
||||||
totalRxSpeed += iface.RxSpeed // bytes/s
|
|
||||||
totalTxSpeed += iface.TxSpeed // bytes/s
|
|
||||||
}
|
|
||||||
// 转换为 MB/s
|
|
||||||
stats.NetworkRxSpeed = round(totalRxSpeed/1024/1024, 2)
|
|
||||||
stats.NetworkTxSpeed = round(totalTxSpeed/1024/1024, 2)
|
|
||||||
|
|
||||||
respondJSON(w, http.StatusOK, envelope{
|
|
||||||
"data": map[string]interface{}{
|
|
||||||
"hostname": hostname,
|
|
||||||
"os": osInfo,
|
|
||||||
"uptimeSeconds": uptime,
|
|
||||||
"processCount": stats.ProcessCount,
|
|
||||||
"packageCount": stats.PackageCount,
|
|
||||||
"packageManager": stats.PackageManager,
|
|
||||||
"temperature": stats.Temperature,
|
|
||||||
"diskReadSpeed": stats.DiskReadSpeed,
|
|
||||||
"diskWriteSpeed": stats.DiskWriteSpeed,
|
|
||||||
"networkRxSpeed": stats.NetworkRxSpeed,
|
|
||||||
"networkTxSpeed": stats.NetworkTxSpeed,
|
|
||||||
"topProcesses": stats.TopProcesses,
|
|
||||||
"systemLogs": stats.SystemLogs,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Docker监控数据
|
|
||||||
func dockerMetricsHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
docker := readDockerStats()
|
|
||||||
respondJSON(w, http.StatusOK, envelope{
|
|
||||||
"data": docker,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 延迟监控数据
|
|
||||||
func latencyMetricsHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
// 记录请求开始时间,用于计算客户端到服务器的延迟
|
|
||||||
startTime := time.Now()
|
|
||||||
|
|
||||||
// 读取外部网站延迟
|
|
||||||
externalLatencies := readExternalLatencies()
|
|
||||||
|
|
||||||
// 计算服务器处理时间(这个可以作为参考,实际客户端延迟由前端计算)
|
|
||||||
serverProcessTime := time.Since(startTime).Milliseconds()
|
|
||||||
|
|
||||||
respondJSON(w, http.StatusOK, envelope{
|
|
||||||
"data": map[string]interface{}{
|
|
||||||
"serverProcessTime": serverProcessTime, // 服务器处理时间(参考)
|
|
||||||
"external": externalLatencies,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func respondJSON(w http.ResponseWriter, status int, body envelope) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(status)
|
|
||||||
if err := json.NewEncoder(w).Encode(body); err != nil {
|
|
||||||
log.Printf("write json error: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func getenv(key, fallback string) string {
|
|
||||||
if v, ok := os.LookupEnv(key); ok && v != "" {
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
func loggingMiddleware(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
start := time.Now()
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func corsMiddleware(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
||||||
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
|
|
||||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
|
||||||
if r.Method == http.MethodOptions {
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"os"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// readMemory 读取内存信息
|
|
||||||
func readMemory() (MemoryMetrics, error) {
|
|
||||||
totals := map[string]uint64{}
|
|
||||||
f, err := os.Open("/proc/meminfo")
|
|
||||||
if err != nil {
|
|
||||||
return MemoryMetrics{}, err
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
scanner := bufio.NewScanner(f)
|
|
||||||
for scanner.Scan() {
|
|
||||||
line := scanner.Text()
|
|
||||||
parts := strings.Split(line, ":")
|
|
||||||
if len(parts) < 2 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
key := strings.TrimSpace(parts[0])
|
|
||||||
fields := strings.Fields(strings.TrimSpace(parts[1]))
|
|
||||||
if len(fields) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
value, err := strconv.ParseUint(fields[0], 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
totals[key] = value * 1024 // kB to bytes
|
|
||||||
}
|
|
||||||
total := totals["MemTotal"]
|
|
||||||
|
|
||||||
// 优先使用 MemAvailable(Linux 3.14+),如果没有则计算
|
|
||||||
var free uint64
|
|
||||||
if available, ok := totals["MemAvailable"]; ok && available > 0 {
|
|
||||||
free = available
|
|
||||||
} else {
|
|
||||||
// 回退到 MemFree + Buffers + Cached(适用于较老的系统)
|
|
||||||
free = totals["MemFree"]
|
|
||||||
if buffers, ok := totals["Buffers"]; ok {
|
|
||||||
free += buffers
|
|
||||||
}
|
|
||||||
if cached, ok := totals["Cached"]; ok {
|
|
||||||
free += cached
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
used := total - free
|
|
||||||
usedPercent := 0.0
|
|
||||||
if total > 0 {
|
|
||||||
usedPercent = (float64(used) / float64(total)) * 100
|
|
||||||
}
|
|
||||||
return MemoryMetrics{
|
|
||||||
TotalBytes: total,
|
|
||||||
UsedBytes: used,
|
|
||||||
FreeBytes: free,
|
|
||||||
UsedPercent: round(usedPercent, 2),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
4
mengyamonitor-frontend/.env.production
Normal file
4
mengyamonitor-frontend/.env.production
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# 公网:用户通过 https 打开前端时,API 须为浏览器可访问的 https 地址(经 Nginx 反代的中心)
|
||||||
|
VITE_CENTRAL_API_URL=https://monitor.api.shumengya.top
|
||||||
|
#
|
||||||
|
# 若静态站仅内网 http 部署,请改为 http://内网中心IP:9393 后再 npm run build(勿与混合内容冲突)
|
||||||
@@ -57,8 +57,8 @@ npm run preview
|
|||||||
## 项目结构
|
## 项目结构
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
├── api/ # API 调用
|
├── api/ # API 调用(中心 REST、管理接口)
|
||||||
│ └── monitor.ts # 监控 API
|
│ └── central.ts
|
||||||
├── components/ # React 组件
|
├── components/ # React 组件
|
||||||
│ ├── ServerCard/ # 服务器卡片组件
|
│ ├── ServerCard/ # 服务器卡片组件
|
||||||
│ └── ServerDetail/ # 服务器详情弹窗
|
│ └── ServerDetail/ # 服务器详情弹窗
|
||||||
@@ -85,11 +85,8 @@ server: {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 修改轮询间隔
|
### 实时指标
|
||||||
编辑 `src/App.tsx` 文件中的 `useServerMonitor` 第二个参数:
|
看板通过 `useServerMonitor` 连接中心 WebSocket(`/api/ws/monitor`),无 HTTP 轮询。
|
||||||
```typescript
|
|
||||||
const statuses = useServerMonitor(servers, 2000); // 2000ms = 2秒
|
|
||||||
```
|
|
||||||
|
|
||||||
## 部署
|
## 部署
|
||||||
|
|
||||||
|
|||||||
1
mengyamonitor-frontend/dev-dist/registerSW.js
Normal file
1
mengyamonitor-frontend/dev-dist/registerSW.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
if('serviceWorker' in navigator) navigator.serviceWorker.register('/dev-sw.js?dev-sw', { scope: '/', type: 'classic' })
|
||||||
103
mengyamonitor-frontend/dev-dist/sw.js
Normal file
103
mengyamonitor-frontend/dev-dist/sw.js
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* Copyright 2018 Google Inc. All Rights Reserved.
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// If the loader is already loaded, just stop.
|
||||||
|
if (!self.define) {
|
||||||
|
let registry = {};
|
||||||
|
|
||||||
|
// Used for `eval` and `importScripts` where we can't get script URL by other means.
|
||||||
|
// In both cases, it's safe to use a global var because those functions are synchronous.
|
||||||
|
let nextDefineUri;
|
||||||
|
|
||||||
|
const singleRequire = (uri, parentUri) => {
|
||||||
|
uri = new URL(uri + ".js", parentUri).href;
|
||||||
|
return registry[uri] || (
|
||||||
|
|
||||||
|
new Promise(resolve => {
|
||||||
|
if ("document" in self) {
|
||||||
|
const script = document.createElement("script");
|
||||||
|
script.src = uri;
|
||||||
|
script.onload = resolve;
|
||||||
|
document.head.appendChild(script);
|
||||||
|
} else {
|
||||||
|
nextDefineUri = uri;
|
||||||
|
importScripts(uri);
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
.then(() => {
|
||||||
|
let promise = registry[uri];
|
||||||
|
if (!promise) {
|
||||||
|
throw new Error(`Module ${uri} didn’t register its module`);
|
||||||
|
}
|
||||||
|
return promise;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
self.define = (depsNames, factory) => {
|
||||||
|
const uri = nextDefineUri || ("document" in self ? document.currentScript.src : "") || location.href;
|
||||||
|
if (registry[uri]) {
|
||||||
|
// Module is already loading or loaded.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let exports = {};
|
||||||
|
const require = depUri => singleRequire(depUri, uri);
|
||||||
|
const specialDeps = {
|
||||||
|
module: { uri },
|
||||||
|
exports,
|
||||||
|
require
|
||||||
|
};
|
||||||
|
registry[uri] = Promise.all(depsNames.map(
|
||||||
|
depName => specialDeps[depName] || require(depName)
|
||||||
|
)).then(deps => {
|
||||||
|
factory(...deps);
|
||||||
|
return exports;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
define(['./workbox-6fc00345'], (function (workbox) { 'use strict';
|
||||||
|
|
||||||
|
self.skipWaiting();
|
||||||
|
workbox.clientsClaim();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The precacheAndRoute() method efficiently caches and responds to
|
||||||
|
* requests for URLs in the manifest.
|
||||||
|
* See https://goo.gl/S9QRab
|
||||||
|
*/
|
||||||
|
workbox.precacheAndRoute([{
|
||||||
|
"url": "registerSW.js",
|
||||||
|
"revision": "3ca0b8505b4bec776b69afdba2768812"
|
||||||
|
}, {
|
||||||
|
"url": "index.html",
|
||||||
|
"revision": "0.ds91o44ltm"
|
||||||
|
}], {});
|
||||||
|
workbox.cleanupOutdatedCaches();
|
||||||
|
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
|
||||||
|
allowlist: [/^\/$/],
|
||||||
|
denylist: [/^\/central-api(?:\/|$)/]
|
||||||
|
}));
|
||||||
|
workbox.registerRoute(/^https:\/\/.*\/api\//i, new workbox.NetworkFirst({
|
||||||
|
"cacheName": "api-cache",
|
||||||
|
"networkTimeoutSeconds": 10,
|
||||||
|
plugins: [new workbox.ExpirationPlugin({
|
||||||
|
maxEntries: 32,
|
||||||
|
maxAgeSeconds: 300
|
||||||
|
}), new workbox.CacheableResponsePlugin({
|
||||||
|
statuses: [0, 200]
|
||||||
|
})]
|
||||||
|
}), 'GET');
|
||||||
|
|
||||||
|
}));
|
||||||
4705
mengyamonitor-frontend/dev-dist/workbox-6fc00345.js
Normal file
4705
mengyamonitor-frontend/dev-dist/workbox-6fc00345.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -2,11 +2,12 @@
|
|||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||||
<link rel="apple-touch-icon" href="/logo.svg" />
|
<link rel="apple-touch-icon" href="/logo2.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="theme-color" content="#1a1a2e" />
|
<meta name="theme-color" content="#1a1a2e" />
|
||||||
<meta name="description" content="服务器监控面板 - 萌芽监控" />
|
<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-capable" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<meta name="apple-mobile-web-app-title" content="萌芽监控" />
|
<meta name="apple-mobile-web-app-title" content="萌芽监控" />
|
||||||
|
|||||||
62
mengyamonitor-frontend/package-lock.json
generated
62
mengyamonitor-frontend/package-lock.json
generated
@@ -8,9 +8,6 @@
|
|||||||
"name": "mengyamonitor-frontend",
|
"name": "mengyamonitor-frontend",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0"
|
"react-dom": "^19.2.0"
|
||||||
},
|
},
|
||||||
@@ -1586,59 +1583,6 @@
|
|||||||
"node": ">=6.9.0"
|
"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": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.25.12",
|
"version": "0.25.12",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
||||||
@@ -6606,12 +6550,6 @@
|
|||||||
"typescript": ">=4.8.4"
|
"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": {
|
"node_modules/type-check": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||||
|
|||||||
@@ -10,9 +10,6 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0"
|
"react-dom": "^19.2.0"
|
||||||
},
|
},
|
||||||
|
|||||||
BIN
mengyamonitor-frontend/public/favicon.ico
Normal file
BIN
mengyamonitor-frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 87 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 3.4 MiB After Width: | Height: | Size: 3.8 MiB |
@@ -1,16 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="none">
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
||||||
<stop offset="0%" style="stop-color:#1a1a2e"/>
|
|
||||||
<stop offset="100%" style="stop-color:#16213e"/>
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="leaf" x1="0%" y1="100%" x2="100%" y2="0%">
|
|
||||||
<stop offset="0%" style="stop-color:#4ecca3"/>
|
|
||||||
<stop offset="100%" style="stop-color:#2d8f7a"/>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<rect width="512" height="512" rx="96" fill="url(#bg)"/>
|
|
||||||
<path fill="url(#leaf)" d="M256 96c-20 80-80 160-80 240 0 53 35.8 96 80 96s80-43 80-96c0-80-60-160-80-240z"/>
|
|
||||||
<path fill="url(#leaf)" opacity=".9" d="M256 96c20 80 60 160 60 240 0 53-35.8 96-80 96s-80-43-80-96c0-80 40-160 60-240z"/>
|
|
||||||
<ellipse cx="256" cy="336" rx="48" ry="24" fill="url(#leaf)" opacity=".7"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 867 B |
@@ -1,8 +1,9 @@
|
|||||||
.app {
|
.app {
|
||||||
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 1rem;
|
padding: 0.65rem 0.75rem 0.85rem;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -10,12 +11,14 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 0.65rem;
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.4rem 0.85rem;
|
||||||
background: var(--surface-color);
|
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);
|
box-shadow: var(--shadow-sm);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--glass-border);
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,27 +30,29 @@
|
|||||||
|
|
||||||
.app-header h1 {
|
.app-header h1 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.5rem;
|
font-size: 1.2rem;
|
||||||
color: var(--text-main);
|
color: var(--text-main);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 0.55rem;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
transform: translateY(-3px);
|
transform: translateY(-1px);
|
||||||
|
letter-spacing: -0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-logo {
|
.app-logo {
|
||||||
width: 36px;
|
width: 32px;
|
||||||
height: 36px;
|
height: 32px;
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-md);
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-icon {
|
.btn-icon {
|
||||||
background: var(--surface-color);
|
background: rgba(255, 255, 255, 0.35);
|
||||||
color: var(--text-main);
|
color: var(--text-main);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--glass-border);
|
||||||
padding: 0.5rem;
|
padding: 0.45rem;
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
@@ -183,18 +188,20 @@
|
|||||||
|
|
||||||
.server-grid {
|
.server-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||||
gap: 1.5rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state {
|
.empty-state {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 4rem 2rem;
|
padding: 2rem 1.25rem;
|
||||||
background: var(--surface-color);
|
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);
|
box-shadow: var(--shadow-sm);
|
||||||
border: 1px dashed var(--border-color);
|
border: 1px dashed var(--glass-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state p {
|
.empty-state p {
|
||||||
@@ -208,6 +215,95 @@
|
|||||||
font-size: 0.875rem;
|
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) {
|
@media (max-width: 768px) {
|
||||||
.app {
|
.app {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
|
|||||||
@@ -1,247 +1,210 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import type { ServerConfig } from './types';
|
|
||||||
import { loadServers, saveServers, removeServer, exportServersToClipboard, importServersFromClipboard } from './utils/storage';
|
|
||||||
import { useServerMonitor } from './hooks/useServerMonitor';
|
import { useServerMonitor } from './hooks/useServerMonitor';
|
||||||
|
import { usePublicServers } from './hooks/usePublicServers';
|
||||||
import { ServerCard } from './components/ServerCard/ServerCard';
|
import { ServerCard } from './components/ServerCard/ServerCard';
|
||||||
import { ServerDetail } from './components/ServerDetail/ServerDetail';
|
import { ServerDetail } from './components/ServerDetail/ServerDetail';
|
||||||
|
import { AdminPanel } from './components/AdminPanel/AdminPanel';
|
||||||
|
import { RandomBackdrop } from './components/RandomBackdrop/RandomBackdrop';
|
||||||
import {
|
import {
|
||||||
DndContext,
|
verifyAdminToken,
|
||||||
closestCenter,
|
ADMIN_TOKEN_STORAGE,
|
||||||
KeyboardSensor,
|
} from './api/central';
|
||||||
PointerSensor,
|
|
||||||
useSensor,
|
|
||||||
useSensors,
|
|
||||||
} from '@dnd-kit/core';
|
|
||||||
import type { DragEndEvent } from '@dnd-kit/core';
|
|
||||||
import {
|
|
||||||
arrayMove,
|
|
||||||
SortableContext,
|
|
||||||
sortableKeyboardCoordinates,
|
|
||||||
rectSortingStrategy,
|
|
||||||
} from '@dnd-kit/sortable';
|
|
||||||
import './App.css';
|
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() {
|
function App() {
|
||||||
const [servers, setServers] = useState<ServerConfig[]>([]);
|
const { servers, loading, error, reload } = usePublicServers(30000);
|
||||||
const [showAddForm, setShowAddForm] = useState(false);
|
|
||||||
const [selectedServerId, setSelectedServerId] = useState<string | null>(null);
|
const [selectedServerId, setSelectedServerId] = useState<string | null>(null);
|
||||||
const [newServerForm, setNewServerForm] = useState({ name: '', url: '' });
|
|
||||||
const [showHeader, setShowHeader] = useState(true);
|
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(
|
const logoClickCount = useRef(0);
|
||||||
useSensor(PointerSensor),
|
const logoClickReset = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
useSensor(KeyboardSensor, {
|
|
||||||
coordinateGetter: sortableKeyboardCoordinates,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const statuses = useServerMonitor();
|
||||||
const loaded = loadServers();
|
|
||||||
setServers(loaded);
|
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 submitToken = async () => {
|
||||||
const formatted = formatServerUrl(newServerForm.url);
|
const ok = await verifyAdminToken(tokenInput.trim());
|
||||||
if (formatted !== newServerForm.url) {
|
if (!ok) {
|
||||||
setNewServerForm(prev => ({ ...prev, url: formatted }));
|
setTokenError('口令错误');
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddServer = () => {
|
|
||||||
if (!newServerForm.name || !newServerForm.url) {
|
|
||||||
alert('请填写服务器名称和地址');
|
|
||||||
return;
|
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 = {
|
const invalidateAdminSession = () => {
|
||||||
id: Date.now().toString(),
|
sessionStorage.removeItem(ADMIN_TOKEN_STORAGE);
|
||||||
name: newServerForm.name,
|
setAdminOpen(false);
|
||||||
url: formattedUrl,
|
alert('管理凭证已失效,请重新验证');
|
||||||
enabled: true,
|
};
|
||||||
|
|
||||||
|
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 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 (
|
return (
|
||||||
<div className="app">
|
<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 && (
|
{!showHeader && (
|
||||||
<button
|
<button
|
||||||
className="btn-show-header"
|
className="btn-show-header"
|
||||||
onClick={() => setShowHeader(true)}
|
onClick={() => setShowHeader(true)}
|
||||||
title="显示导航栏"
|
title="显示导航栏"
|
||||||
|
type="button"
|
||||||
>
|
>
|
||||||
👁️
|
👁️
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showHeader && (
|
{showHeader && (
|
||||||
<header className="app-header">
|
<header className="app-header">
|
||||||
<h1>
|
<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>
|
</h1>
|
||||||
<div className="header-actions">
|
<div className="header-actions">
|
||||||
<button
|
<button
|
||||||
className="btn-icon"
|
type="button"
|
||||||
|
className="btn-icon"
|
||||||
onClick={() => setShowHeader(false)}
|
onClick={() => setShowHeader(false)}
|
||||||
title="隐藏导航栏"
|
title="隐藏导航栏"
|
||||||
>
|
>
|
||||||
👁️
|
👁️
|
||||||
</button>
|
</button>
|
||||||
<button
|
</div>
|
||||||
className="btn-icon"
|
</header>
|
||||||
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>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<main className="server-grid">
|
<main className="server-grid">
|
||||||
{servers.length === 0 ? (
|
{loading ? (
|
||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
<p>还没有添加任何服务器</p>
|
<p>正在从中心服务加载节点列表…</p>
|
||||||
<p className="hint">点击右上角的"添加服务器"按钮开始使用</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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<DndContext
|
servers.map((server) => {
|
||||||
sensors={sensors}
|
const status = statuses[server.id];
|
||||||
collisionDetection={closestCenter}
|
const storageUsage =
|
||||||
onDragEnd={handleDragEnd}
|
status?.metrics?.storage?.reduce((max, s) => Math.max(max, s.usedPercent), 0) || 0;
|
||||||
>
|
return (
|
||||||
<SortableContext items={servers.map((s) => s.id)} strategy={rectSortingStrategy}>
|
<ServerCard
|
||||||
{servers.map((server) => {
|
key={server.id}
|
||||||
const status = statuses[server.id];
|
server={server}
|
||||||
// Calculate storage usage (max of all mounts)
|
online={status?.online || false}
|
||||||
const storageUsage = status?.metrics?.storage?.reduce((max, s) => Math.max(max, s.usedPercent), 0) || 0;
|
metrics={status?.metrics}
|
||||||
|
cpuUsage={status?.metrics?.cpu.usagePercent || 0}
|
||||||
return (
|
memoryUsage={status?.metrics?.memory.usedPercent || 0}
|
||||||
<ServerCard
|
storageUsage={storageUsage}
|
||||||
key={server.id}
|
uptime={status?.metrics?.uptimeSeconds}
|
||||||
server={server}
|
onDetail={() => setSelectedServerId(server.id)}
|
||||||
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>
|
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
109
mengyamonitor-frontend/src/api/central.ts
Normal file
109
mengyamonitor-frontend/src/api/central.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
/** Central dashboard API base (no trailing slash). Dev default uses Vite proxy. */
|
||||||
|
export const centralApiBase = (): string => {
|
||||||
|
const v = import.meta.env.VITE_CENTRAL_API_URL as string | undefined;
|
||||||
|
return (v && v.trim()) || '/central-api';
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchPublicServers(): Promise<
|
||||||
|
{ id: string; name: string; url: string; enabled: boolean }[]
|
||||||
|
> {
|
||||||
|
const base = centralApiBase();
|
||||||
|
const res = await fetch(`${base}/api/servers`);
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`无法加载服务器列表 (${res.status})`);
|
||||||
|
}
|
||||||
|
const body = await res.json();
|
||||||
|
const data = body.data;
|
||||||
|
if (!Array.isArray(data)) {
|
||||||
|
throw new Error('无效的服务器列表响应');
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyAdminToken(token: string): Promise<boolean> {
|
||||||
|
const base = centralApiBase();
|
||||||
|
const res = await fetch(`${base}/api/admin/auth`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ token }),
|
||||||
|
});
|
||||||
|
return res.ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminHeaders = (token: string, json = false): HeadersInit => {
|
||||||
|
const h: Record<string, string> = { 'X-Admin-Token': token };
|
||||||
|
if (json) h['Content-Type'] = 'application/json';
|
||||||
|
return h;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function adminListServers(token: string) {
|
||||||
|
const base = centralApiBase();
|
||||||
|
const res = await fetch(`${base}/api/admin/servers`, { headers: adminHeaders(token) });
|
||||||
|
if (res.status === 401) throw new Error('UNAUTHORIZED');
|
||||||
|
if (!res.ok) throw new Error(`加载失败 (${res.status})`);
|
||||||
|
const body = await res.json();
|
||||||
|
return body.data as {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
enabled: boolean;
|
||||||
|
sortOrder: number;
|
||||||
|
agentKey?: string;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function adminCreateServer(
|
||||||
|
token: string,
|
||||||
|
payload: { name: string; url?: string; enabled: boolean }
|
||||||
|
) {
|
||||||
|
const base = centralApiBase();
|
||||||
|
const res = await fetch(`${base}/api/admin/servers`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: adminHeaders(token, true),
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error((err as { error?: string }).error || `创建失败 (${res.status})`);
|
||||||
|
}
|
||||||
|
return (await res.json()).data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function adminUpdateServer(
|
||||||
|
token: string,
|
||||||
|
id: string,
|
||||||
|
payload: { name: string; url?: string; enabled: boolean; agentKey?: string }
|
||||||
|
) {
|
||||||
|
const base = centralApiBase();
|
||||||
|
const res = await fetch(`${base}/api/admin/servers/${encodeURIComponent(id)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: adminHeaders(token, true),
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error((err as { error?: string }).error || `更新失败 (${res.status})`);
|
||||||
|
}
|
||||||
|
return (await res.json()).data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function adminDeleteServer(token: string, id: string) {
|
||||||
|
const base = centralApiBase();
|
||||||
|
const res = await fetch(`${base}/api/admin/servers/${encodeURIComponent(id)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: adminHeaders(token, false),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`删除失败 (${res.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function adminReorderServers(token: string, ids: string[]) {
|
||||||
|
const base = centralApiBase();
|
||||||
|
const res = await fetch(`${base}/api/admin/servers/reorder`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: adminHeaders(token, true),
|
||||||
|
body: JSON.stringify({ ids }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`排序失败 (${res.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ADMIN_TOKEN_STORAGE = 'mengya_monitor_admin_token';
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
import type { ServerMetrics } from '../types';
|
|
||||||
|
|
||||||
export const fetchServerMetrics = async (serverUrl: string): Promise<ServerMetrics> => {
|
|
||||||
// 测量客户端到服务器的延迟(使用健康检查端点)
|
|
||||||
const clientToServerLatency = await measureLatency(`${serverUrl}/api/health`);
|
|
||||||
|
|
||||||
// 并行请求各个端点
|
|
||||||
const endpoints = [
|
|
||||||
'/api/metrics/cpu',
|
|
||||||
'/api/metrics/memory',
|
|
||||||
'/api/metrics/storage',
|
|
||||||
'/api/metrics/gpu',
|
|
||||||
'/api/metrics/network',
|
|
||||||
'/api/metrics/system',
|
|
||||||
'/api/metrics/docker',
|
|
||||||
'/api/metrics/latency',
|
|
||||||
];
|
|
||||||
|
|
||||||
const results = await Promise.all(
|
|
||||||
endpoints.map(endpoint =>
|
|
||||||
fetch(`${serverUrl}${endpoint}`, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.then(res => {
|
|
||||||
if (!res.ok) throw new Error(`Failed to fetch ${endpoint}`);
|
|
||||||
return res.json();
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
console.error(`Error fetching ${endpoint}:`, err);
|
|
||||||
return null;
|
|
||||||
})
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
// 合并所有结果
|
|
||||||
const [cpuRes, memRes, storageRes, gpuRes, networkRes, systemRes, dockerRes, latencyRes] = results;
|
|
||||||
|
|
||||||
const system = systemRes?.data || {};
|
|
||||||
const docker = dockerRes?.data || {};
|
|
||||||
const latency = latencyRes?.data || {};
|
|
||||||
|
|
||||||
// 将 docker 数据合并到 system.dockerStats
|
|
||||||
return {
|
|
||||||
hostname: system.hostname || 'Unknown',
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
cpu: cpuRes?.data || {},
|
|
||||||
memory: memRes?.data || {},
|
|
||||||
storage: storageRes?.data || [],
|
|
||||||
gpu: gpuRes?.data || [],
|
|
||||||
network: networkRes?.data || [],
|
|
||||||
system: {
|
|
||||||
...system,
|
|
||||||
dockerStats: docker
|
|
||||||
},
|
|
||||||
os: system.os || { kernel: '', distro: '', architecture: '' },
|
|
||||||
uptimeSeconds: system.uptimeSeconds || 0,
|
|
||||||
latency: {
|
|
||||||
clientToServer: clientToServerLatency,
|
|
||||||
external: latency.external || {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// 测量延迟
|
|
||||||
async function measureLatency(url: string): Promise<number> {
|
|
||||||
try {
|
|
||||||
const startTime = performance.now();
|
|
||||||
const response = await fetch(url, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const endTime = performance.now();
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
return Math.round(endTime - startTime);
|
|
||||||
}
|
|
||||||
return -1; // 表示失败
|
|
||||||
} catch {
|
|
||||||
return -1; // 表示超时或失败
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const checkServerHealth = async (serverUrl: string): Promise<boolean> => {
|
|
||||||
try {
|
|
||||||
const url = `${serverUrl}/api/health`;
|
|
||||||
const response = await fetch(url, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return response.ok;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
202
mengyamonitor-frontend/src/components/AdminPanel/AdminPanel.css
Normal file
202
mengyamonitor-frontend/src/components/AdminPanel/AdminPanel.css
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
.admin-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(10, 12, 22, 0.72);
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-panel {
|
||||||
|
width: min(1100px, 100%);
|
||||||
|
background: var(--surface-color);
|
||||||
|
backdrop-filter: var(--backdrop-blur);
|
||||||
|
-webkit-backdrop-filter: var(--backdrop-blur);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: var(--radius-glass);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
padding: 1rem 1.15rem 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-panel-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-panel-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-close {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 1.75rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-close:hover {
|
||||||
|
background: var(--bg-color);
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-hint {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-loading {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-error {
|
||||||
|
color: #f87171;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table th,
|
||||||
|
.admin-table td {
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.5rem 0.6rem;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table th {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table code {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table input[type='text'] {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 280px;
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-color);
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-url-input {
|
||||||
|
max-width: 360px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-order-btns {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-order-btns button {
|
||||||
|
padding: 0.2rem 0.45rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-color);
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-order-btns button:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-actions {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-actions .admin-btn + .admin-btn {
|
||||||
|
margin-left: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-btn {
|
||||||
|
padding: 0.35rem 0.65rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--surface-color);
|
||||||
|
color: var(--text-main);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-btn:hover {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-btn.danger {
|
||||||
|
border-color: #b91c1c;
|
||||||
|
color: #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-btn.danger:hover {
|
||||||
|
background: rgba(185, 28, 28, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-server-id-cell,
|
||||||
|
.admin-agent-key-cell {
|
||||||
|
vertical-align: top;
|
||||||
|
max-width: 22rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-agent-key-cell .admin-key {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-server-id {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
word-break: break-all;
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-btn-ghost {
|
||||||
|
padding: 0.2rem 0.45rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-key {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
word-break: break-all;
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-muted {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-empty {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 1.5rem !important;
|
||||||
|
}
|
||||||
279
mengyamonitor-frontend/src/components/AdminPanel/AdminPanel.tsx
Normal file
279
mengyamonitor-frontend/src/components/AdminPanel/AdminPanel.tsx
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
adminListServers,
|
||||||
|
adminUpdateServer,
|
||||||
|
adminDeleteServer,
|
||||||
|
adminReorderServers,
|
||||||
|
} from '../../api/central';
|
||||||
|
import './AdminPanel.css';
|
||||||
|
|
||||||
|
type Row = { id: string; name: string; url: string; enabled: boolean; sortOrder: number; agentKey?: string };
|
||||||
|
|
||||||
|
const formatServerUrl = (url: string) => {
|
||||||
|
let formatted = url.trim();
|
||||||
|
if (formatted.endsWith('.op')) {
|
||||||
|
formatted = formatted.slice(0, -3) + '.top';
|
||||||
|
}
|
||||||
|
if (formatted && !/^https?:\/\//i.test(formatted)) {
|
||||||
|
return `http://${formatted}`;
|
||||||
|
}
|
||||||
|
return formatted;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AdminPanel = ({
|
||||||
|
token,
|
||||||
|
onClose,
|
||||||
|
onSessionInvalid,
|
||||||
|
onSaved,
|
||||||
|
}: {
|
||||||
|
token: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onSessionInvalid: () => void;
|
||||||
|
onSaved?: () => void;
|
||||||
|
}) => {
|
||||||
|
const [rows, setRows] = useState<Row[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
const [editId, setEditId] = useState<string | null>(null);
|
||||||
|
const [editForm, setEditForm] = useState({ name: '', url: '', enabled: true });
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setErr(null);
|
||||||
|
try {
|
||||||
|
const list = await adminListServers(token);
|
||||||
|
setRows(list);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error && e.message === 'UNAUTHORIZED') {
|
||||||
|
onSessionInvalid();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setErr(e instanceof Error ? e.message : '加载失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [token, onSessionInvalid]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const startEdit = (r: Row) => {
|
||||||
|
setEditId(r.id);
|
||||||
|
setEditForm({ name: r.name, url: r.url, enabled: r.enabled });
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveEdit = async () => {
|
||||||
|
if (!editId) return;
|
||||||
|
try {
|
||||||
|
const u = editForm.url.trim();
|
||||||
|
await adminUpdateServer(token, editId, {
|
||||||
|
name: editForm.name.trim(),
|
||||||
|
...(u ? { url: formatServerUrl(u) } : { url: '' }),
|
||||||
|
enabled: editForm.enabled,
|
||||||
|
});
|
||||||
|
setEditId(null);
|
||||||
|
await load();
|
||||||
|
onSaved?.();
|
||||||
|
} catch (e) {
|
||||||
|
alert(e instanceof Error ? e.message : '保存失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async (id: string) => {
|
||||||
|
if (!confirm('确定删除该监控节点?')) return;
|
||||||
|
try {
|
||||||
|
await adminDeleteServer(token, id);
|
||||||
|
await load();
|
||||||
|
onSaved?.();
|
||||||
|
} catch (e) {
|
||||||
|
alert(e instanceof Error ? e.message : '删除失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyServerId = useCallback((id: string) => {
|
||||||
|
void navigator.clipboard.writeText(id).catch(() => {
|
||||||
|
window.prompt('请手动复制(环境变量 SERVER_ID)', id);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const copyAgentKey = useCallback((key: string) => {
|
||||||
|
void navigator.clipboard.writeText(key).catch(() => {
|
||||||
|
window.prompt('请手动复制(环境变量 AGENT_KEY)', key);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const move = async (index: number, dir: -1 | 1) => {
|
||||||
|
const j = index + dir;
|
||||||
|
if (j < 0 || j >= rows.length) return;
|
||||||
|
const next = [...rows];
|
||||||
|
[next[index], next[j]] = [next[j], next[index]];
|
||||||
|
const ids = next.map((r) => r.id);
|
||||||
|
setRows(next);
|
||||||
|
try {
|
||||||
|
await adminReorderServers(token, ids);
|
||||||
|
await load();
|
||||||
|
onSaved?.();
|
||||||
|
} catch (e) {
|
||||||
|
alert(e instanceof Error ? e.message : '排序失败');
|
||||||
|
void load();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="admin-overlay" role="dialog" aria-modal="true">
|
||||||
|
<div className="admin-panel">
|
||||||
|
<div className="admin-panel-header">
|
||||||
|
<h2>监控管理后台</h2>
|
||||||
|
<button type="button" className="admin-close" onClick={onClose} title="关闭">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="admin-hint">
|
||||||
|
节点由采集端 gRPC 上报后出现在列表中。环境变量 CENTRAL_GRPC、<strong>SERVER_ID 须为下表「节点 ID」列的完整 UUID</strong>
|
||||||
|
(勿把「名称」当成 SERVER_ID)、AGENT_KEY 与「节点密钥」一致。看板经 WebSocket
|
||||||
|
拉取中心内存快照;「备注链接」可选,可在行内编辑。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="admin-loading">加载中…</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{err && <p className="admin-error">{err}</p>}
|
||||||
|
|
||||||
|
<div className="admin-table-wrap">
|
||||||
|
<table className="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>顺序</th>
|
||||||
|
<th>名称</th>
|
||||||
|
<th>节点 ID(SERVER_ID)</th>
|
||||||
|
<th>备注链接</th>
|
||||||
|
<th>节点密钥 (gRPC)</th>
|
||||||
|
<th>启用</th>
|
||||||
|
<th />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="admin-empty">
|
||||||
|
暂无节点;请确认采集端已配置并连接中心,成功上报后将显示在此。
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
rows.map((r, i) => (
|
||||||
|
<tr key={r.id}>
|
||||||
|
<td>
|
||||||
|
<div className="admin-order-btns">
|
||||||
|
<button type="button" title="上移" onClick={() => void move(i, -1)} disabled={i === 0}>
|
||||||
|
↑
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="下移"
|
||||||
|
onClick={() => void move(i, 1)}
|
||||||
|
disabled={i === rows.length - 1}
|
||||||
|
>
|
||||||
|
↓
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{editId === r.id ? (
|
||||||
|
<input
|
||||||
|
value={editForm.name}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, name: e.target.value })}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
r.name
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="admin-server-id-cell">
|
||||||
|
<code className="admin-server-id" title={r.id}>
|
||||||
|
{r.id}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="admin-btn admin-btn-ghost"
|
||||||
|
title="复制为采集端 SERVER_ID"
|
||||||
|
onClick={() => copyServerId(r.id)}
|
||||||
|
>
|
||||||
|
复制
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{editId === r.id ? (
|
||||||
|
<input
|
||||||
|
className="admin-url-input"
|
||||||
|
value={editForm.url}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, url: e.target.value })}
|
||||||
|
/>
|
||||||
|
) : r.url ? (
|
||||||
|
<code>{r.url}</code>
|
||||||
|
) : (
|
||||||
|
<span className="admin-muted">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="admin-agent-key-cell">
|
||||||
|
{r.agentKey ? (
|
||||||
|
<>
|
||||||
|
<code className="admin-key" title={r.agentKey}>
|
||||||
|
{r.agentKey.slice(0, 8)}…
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="admin-btn admin-btn-ghost"
|
||||||
|
title="复制完整 AGENT_KEY(写入采集端配置)"
|
||||||
|
onClick={() => copyAgentKey(r.agentKey!)}
|
||||||
|
>
|
||||||
|
复制
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="admin-muted">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{editId === r.id ? (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={editForm.enabled}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, enabled: e.target.checked })}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span>{r.enabled ? '是' : '否'}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="admin-actions">
|
||||||
|
{editId === r.id ? (
|
||||||
|
<>
|
||||||
|
<button type="button" className="admin-btn" onClick={() => void saveEdit()}>
|
||||||
|
保存
|
||||||
|
</button>
|
||||||
|
<button type="button" className="admin-btn" onClick={() => setEditId(null)}>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button type="button" className="admin-btn" onClick={() => startEdit(r)}>
|
||||||
|
编辑
|
||||||
|
</button>
|
||||||
|
<button type="button" className="admin-btn danger" onClick={() => void remove(r.id)}>
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
.circular-progress {
|
|
||||||
position: relative;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.circular-progress svg {
|
|
||||||
transform: rotate(-90deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-ring-bg {
|
|
||||||
fill: none;
|
|
||||||
stroke: #e2e8f0;
|
|
||||||
transition: stroke 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-ring-circle {
|
|
||||||
fill: none;
|
|
||||||
stroke-linecap: round;
|
|
||||||
transition: stroke-dashoffset 0.5s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-value {
|
|
||||||
font-weight: 700;
|
|
||||||
fill: var(--text-main);
|
|
||||||
transform: rotate(90deg);
|
|
||||||
transform-origin: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-label {
|
|
||||||
fill: var(--text-secondary);
|
|
||||||
font-weight: 500;
|
|
||||||
transform: rotate(90deg);
|
|
||||||
transform-origin: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-sublabel {
|
|
||||||
fill: var(--text-light);
|
|
||||||
font-weight: 400;
|
|
||||||
transform: rotate(90deg);
|
|
||||||
transform-origin: center;
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
import './CircularProgress.css';
|
|
||||||
|
|
||||||
interface CircularProgressProps {
|
|
||||||
value: number;
|
|
||||||
label: string;
|
|
||||||
color?: string;
|
|
||||||
size?: number;
|
|
||||||
subLabel?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CircularProgress = ({
|
|
||||||
value,
|
|
||||||
label,
|
|
||||||
color = 'var(--primary-color)',
|
|
||||||
size = 150,
|
|
||||||
subLabel
|
|
||||||
}: CircularProgressProps) => {
|
|
||||||
const radius = size * 0.36; // 36% of size
|
|
||||||
const circumference = 2 * Math.PI * radius;
|
|
||||||
const offset = circumference - (value / 100) * circumference;
|
|
||||||
const center = size / 2;
|
|
||||||
|
|
||||||
// 根据尺寸动态计算字体大小
|
|
||||||
const valueFontSize = size * 0.12; // 12% of size
|
|
||||||
const subLabelFontSize = size * 0.08; // 8% of size
|
|
||||||
const labelFontSize = size * 0.09; // 9% of size
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="circular-progress" style={{ width: size, height: size }}>
|
|
||||||
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
|
|
||||||
<circle
|
|
||||||
className="progress-ring-bg"
|
|
||||||
cx={center}
|
|
||||||
cy={center}
|
|
||||||
r={radius}
|
|
||||||
strokeWidth={size * 0.08}
|
|
||||||
/>
|
|
||||||
<circle
|
|
||||||
className="progress-ring-circle"
|
|
||||||
stroke={color}
|
|
||||||
strokeDasharray={`${circumference} ${circumference}`}
|
|
||||||
strokeDashoffset={offset}
|
|
||||||
cx={center}
|
|
||||||
cy={center}
|
|
||||||
r={radius}
|
|
||||||
strokeWidth={size * 0.08}
|
|
||||||
/>
|
|
||||||
{subLabel ? (
|
|
||||||
<text
|
|
||||||
x={center}
|
|
||||||
y={center}
|
|
||||||
className="progress-value"
|
|
||||||
textAnchor="middle"
|
|
||||||
dy="0.3em"
|
|
||||||
fontSize={subLabelFontSize}
|
|
||||||
>
|
|
||||||
{subLabel}
|
|
||||||
</text>
|
|
||||||
) : (
|
|
||||||
<text
|
|
||||||
x={center}
|
|
||||||
y={center}
|
|
||||||
className="progress-value"
|
|
||||||
textAnchor="middle"
|
|
||||||
dy="0.3em"
|
|
||||||
fontSize={valueFontSize}
|
|
||||||
>
|
|
||||||
{Math.round(value)}%
|
|
||||||
</text>
|
|
||||||
)}
|
|
||||||
<text
|
|
||||||
x={center}
|
|
||||||
y={center + size * 0.22}
|
|
||||||
className="progress-label"
|
|
||||||
textAnchor="middle"
|
|
||||||
fontSize={labelFontSize}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</text>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
.random-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 0; /* 低于 #root (z-index: 1),保证监控卡片与顶栏在上层 */
|
||||||
|
pointer-events: none;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 略放大并外扩,减轻模糊后边缘露底 */
|
||||||
|
.random-backdrop__image {
|
||||||
|
position: absolute;
|
||||||
|
inset: -16px;
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
transform: scale(1.04);
|
||||||
|
/* 需求「10%」按常用约定映射为 10px 半径(CSS blur 无百分比) */
|
||||||
|
filter: blur(10px);
|
||||||
|
-webkit-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.random-backdrop__fallback {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background:
|
||||||
|
radial-gradient(120% 80% at 20% 20%, rgba(252, 231, 243, 0.95) 0%, transparent 55%),
|
||||||
|
radial-gradient(100% 90% at 80% 30%, rgba(224, 231, 255, 0.9) 0%, transparent 50%),
|
||||||
|
linear-gradient(160deg, #fdf4ff 0%, #e0f2fe 45%, #fce7f3 100%);
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import './RandomBackdrop.css';
|
||||||
|
|
||||||
|
const RANDBG_JSON =
|
||||||
|
'https://randbg.api.smyhub.com/api/random?format=json&mode=auto';
|
||||||
|
|
||||||
|
type RandomBgResponse = {
|
||||||
|
url?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 挂到 body,避免与 .app 内 filter/层叠上下文叠在一起盖住卡片 */
|
||||||
|
export const RandomBackdrop = () => {
|
||||||
|
const [imageUrl, setImageUrl] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
void fetch(RANDBG_JSON)
|
||||||
|
.then((r) => {
|
||||||
|
if (!r.ok) throw new Error(String(r.status));
|
||||||
|
return r.json() as Promise<RandomBgResponse>;
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
const u = data?.url;
|
||||||
|
if (!cancelled && typeof u === 'string' && u.length > 0) {
|
||||||
|
setImageUrl(u);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
/* 保持 fallback 底色 */
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className="random-backdrop" aria-hidden>
|
||||||
|
{!imageUrl && <div className="random-backdrop__fallback" />}
|
||||||
|
{imageUrl && (
|
||||||
|
<div
|
||||||
|
className="random-backdrop__image"
|
||||||
|
style={{ backgroundImage: `url(${imageUrl})` }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,23 +1,27 @@
|
|||||||
.server-card {
|
.server-card {
|
||||||
background: var(--surface-color);
|
background: var(--surface-color);
|
||||||
border-radius: var(--radius-lg);
|
backdrop-filter: var(--backdrop-blur);
|
||||||
padding: 1.5rem;
|
-webkit-backdrop-filter: var(--backdrop-blur);
|
||||||
|
border-radius: var(--radius-glass);
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--glass-border);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
min-height: 248px;
|
||||||
|
height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.server-card:hover {
|
.server-card:hover {
|
||||||
box-shadow: var(--shadow-lg);
|
box-shadow: var(--shadow-md);
|
||||||
transform: translateY(-4px);
|
border-color: rgba(255, 255, 255, 0.75);
|
||||||
border-color: var(--primary-color);
|
background: var(--surface-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.server-card.online {
|
.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 {
|
.server-card.offline {
|
||||||
@@ -29,13 +33,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.35rem;
|
||||||
cursor: grab;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-header:active {
|
|
||||||
cursor: grabbing;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.server-info {
|
.server-info {
|
||||||
@@ -53,25 +51,6 @@
|
|||||||
|
|
||||||
.status-online {
|
.status-online {
|
||||||
background: var(--success-color);
|
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 {
|
.status-offline {
|
||||||
@@ -86,9 +65,10 @@
|
|||||||
|
|
||||||
.server-name {
|
.server-name {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.125rem;
|
font-size: 1rem;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
color: var(--text-main);
|
color: var(--text-main);
|
||||||
|
letter-spacing: -0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.server-hostname {
|
.server-hostname {
|
||||||
@@ -124,62 +104,68 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.metrics-grid-main {
|
.card-stats-compact {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-around;
|
flex-direction: column;
|
||||||
align-items: center;
|
gap: 0.28rem;
|
||||||
padding: 0.5rem 0;
|
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;
|
display: flex;
|
||||||
align-items: center;
|
justify-content: space-between;
|
||||||
justify-content: center;
|
align-items: baseline;
|
||||||
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.circular-progress-small .progress-ring-bg {
|
.stat-k {
|
||||||
fill: none;
|
color: var(--text-secondary);
|
||||||
stroke: #e2e8f0;
|
font-weight: 500;
|
||||||
stroke-width: 6;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.circular-progress-small .progress-ring-circle {
|
.stat-v {
|
||||||
fill: none;
|
color: var(--text-main);
|
||||||
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;
|
|
||||||
font-weight: 600;
|
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 {
|
.lat-good {
|
||||||
font-size: 10px;
|
color: var(--success-color);
|
||||||
fill: var(--text-secondary);
|
}
|
||||||
|
|
||||||
|
.lat-mid {
|
||||||
|
color: #fde047;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lat-bad {
|
||||||
|
color: #f87171;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Info Grid */
|
/* Info Grid */
|
||||||
.card-info-grid {
|
.card-info-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: 0.75rem;
|
gap: 0.45rem;
|
||||||
padding: 0.75rem;
|
padding: 0.45rem 0.5rem;
|
||||||
background: var(--bg-color);
|
background: rgba(255, 255, 255, 0.35);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.45);
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-section {
|
.info-section {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.5rem;
|
gap: 0.3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-row {
|
.info-row {
|
||||||
@@ -215,10 +201,11 @@
|
|||||||
.card-performance-grid {
|
.card-performance-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: 0.75rem;
|
gap: 0.45rem;
|
||||||
padding: 0.75rem;
|
padding: 0.45rem 0.5rem;
|
||||||
background: var(--bg-color);
|
background: rgba(255, 255, 255, 0.35);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.45);
|
||||||
}
|
}
|
||||||
|
|
||||||
.performance-section {
|
.performance-section {
|
||||||
@@ -256,9 +243,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.performance-item-value {
|
.performance-item-value {
|
||||||
color: var(--primary-color);
|
color: #0284c7;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-family: monospace;
|
font-family: ui-monospace, monospace;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,10 +254,11 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0.75rem;
|
padding: 0.45rem 0.55rem;
|
||||||
background: var(--bg-color);
|
background: rgba(255, 255, 255, 0.35);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
margin-top: 0.5rem;
|
border: 1px solid rgba(255, 255, 255, 0.45);
|
||||||
|
margin-top: 0.15rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer-row {
|
.footer-row {
|
||||||
@@ -325,13 +313,13 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
background: var(--bg-color);
|
background: var(--bg-color);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-bar-fill {
|
.progress-bar-fill {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
transition: width 0.5s ease;
|
transition: width 0.5s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,31 +345,40 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: var(--bg-color);
|
background: rgba(255, 255, 255, 0.3);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 0.65rem;
|
||||||
|
padding: 0.65rem;
|
||||||
color: var(--text-light);
|
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 {
|
.card-footer {
|
||||||
margin-top: auto;
|
margin-top: 0.45rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-detail {
|
.btn-detail {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: var(--surface-hover);
|
background: rgba(255, 255, 255, 0.45);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--glass-border);
|
||||||
padding: 0.75rem;
|
padding: 0.5rem 0.65rem;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
font-size: 0.875rem;
|
font-size: 0.8rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-detail:hover {
|
.btn-detail:hover {
|
||||||
background: var(--primary-color);
|
background: linear-gradient(135deg, rgba(125, 211, 252, 0.85), rgba(196, 181, 253, 0.75));
|
||||||
color: white;
|
color: #0f172a;
|
||||||
border-color: var(--primary-color);
|
border-color: rgba(255, 255, 255, 0.65);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,79 +1,7 @@
|
|||||||
import type { ServerConfig, ServerMetrics } from '../../types';
|
import type { ServerConfig, ServerMetrics } from '../../types';
|
||||||
import { formatUptime, formatBytes } from '../../utils/format';
|
import { formatUptime, formatBytes, formatPercent, formatLatencyMs } from '../../utils/format';
|
||||||
import { useSortable } from '@dnd-kit/sortable';
|
|
||||||
import { CSS } from '@dnd-kit/utilities';
|
|
||||||
import './ServerCard.css';
|
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 {
|
export interface ServerCardProps {
|
||||||
server: ServerConfig;
|
server: ServerConfig;
|
||||||
online: boolean;
|
online: boolean;
|
||||||
@@ -83,7 +11,6 @@ export interface ServerCardProps {
|
|||||||
storageUsage?: number;
|
storageUsage?: number;
|
||||||
uptime?: number;
|
uptime?: number;
|
||||||
onDetail: (serverId: string) => void;
|
onDetail: (serverId: string) => void;
|
||||||
onRemove: (serverId: string) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ServerCard = ({
|
export const ServerCard = ({
|
||||||
@@ -95,24 +22,7 @@ export const ServerCard = ({
|
|||||||
storageUsage = 0,
|
storageUsage = 0,
|
||||||
uptime,
|
uptime,
|
||||||
onDetail,
|
onDetail,
|
||||||
onRemove,
|
|
||||||
}: ServerCardProps) => {
|
}: 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 => {
|
const extractIP = (url: string): string => {
|
||||||
try {
|
try {
|
||||||
const urlObj = new URL(url);
|
const urlObj = new URL(url);
|
||||||
@@ -122,11 +32,10 @@ export const ServerCard = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 获取主 IP 地址(从网络接口)
|
|
||||||
const getMainIP = (): string => {
|
const getMainIP = (): string => {
|
||||||
if (metrics?.network && metrics.network.length > 0) {
|
if (metrics?.network && metrics.network.length > 0) {
|
||||||
const mainInterface = metrics.network.find(iface =>
|
const mainInterface = metrics.network.find(
|
||||||
iface.ipAddress && iface.ipAddress !== 'N/A' && !iface.ipAddress.startsWith('127.')
|
(iface) => iface.ipAddress && iface.ipAddress !== 'N/A' && !iface.ipAddress.startsWith('127.')
|
||||||
);
|
);
|
||||||
return mainInterface?.ipAddress || extractIP(server.url);
|
return mainInterface?.ipAddress || extractIP(server.url);
|
||||||
}
|
}
|
||||||
@@ -144,74 +53,84 @@ export const ServerCard = ({
|
|||||||
const diskWriteSpeed = metrics?.system?.diskWriteSpeed || 0;
|
const diskWriteSpeed = metrics?.system?.diskWriteSpeed || 0;
|
||||||
const networkRxSpeed = metrics?.system?.networkRxSpeed || 0;
|
const networkRxSpeed = metrics?.system?.networkRxSpeed || 0;
|
||||||
const networkTxSpeed = metrics?.system?.networkTxSpeed || 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 processCount = metrics?.system?.processCount || 0;
|
||||||
const packageCount = metrics?.system?.packageCount || 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 loadRatio = cpuCores > 0 ? (loadAverage / cpuCores) * 100 : 0;
|
||||||
const getLatencyColor = (latency: number): string => {
|
|
||||||
if (latency < 0) return '#ef4444'; // 超时/失败 - 红色
|
|
||||||
if (latency < 50) return '#86efac'; // 低延迟 - 绿色
|
|
||||||
if (latency < 200) return '#fde047'; // 中延迟 - 黄色
|
|
||||||
return '#f87171'; // 高延迟 - 红色
|
|
||||||
};
|
|
||||||
|
|
||||||
const latencyColor = getLatencyColor(latency);
|
const latencyClass =
|
||||||
|
!Number.isFinite(latency) || latency < 0
|
||||||
|
? 'lat-bad'
|
||||||
|
: latency < 50
|
||||||
|
? 'lat-good'
|
||||||
|
: latency < 200
|
||||||
|
? 'lat-mid'
|
||||||
|
: 'lat-bad';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={`server-card ${online ? 'online' : 'offline'}`}>
|
||||||
ref={setNodeRef}
|
<div className="card-header">
|
||||||
style={style}
|
|
||||||
className={`server-card ${online ? 'online' : 'offline'}`}
|
|
||||||
>
|
|
||||||
<div className="card-header" {...attributes} {...listeners}>
|
|
||||||
<div className="server-info">
|
<div className="server-info">
|
||||||
<div className={`status-indicator ${online ? 'status-online' : 'status-offline'}`} />
|
<div className={`status-indicator ${online ? 'status-online' : 'status-offline'}`} />
|
||||||
<div className="server-name-group">
|
<div className="server-name-group">
|
||||||
<h3 className="server-name">{server.name}</h3>
|
<h3 className="server-name">{server.name}</h3>
|
||||||
{online && hostname !== 'Unknown' && (
|
{online && hostname !== 'Unknown' && <span className="server-hostname">{hostname}</span>}
|
||||||
<span className="server-hostname">{hostname}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
className="btn-remove"
|
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onRemove(server.id);
|
|
||||||
}}
|
|
||||||
title="移除服务器"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
</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="card-main-metrics">
|
||||||
<div className="metrics-grid-main">
|
<div className="card-stats-compact">
|
||||||
{loadAverage > 0 && (
|
{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>
|
||||||
|
|
||||||
<div className="card-info-grid">
|
<div className="card-info-grid">
|
||||||
@@ -239,6 +158,12 @@ export const ServerCard = ({
|
|||||||
<span className="info-label">进程</span>
|
<span className="info-label">进程</span>
|
||||||
<span className="info-value">{processCount}</span>
|
<span className="info-value">{processCount}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{loggedInUsers !== undefined && (
|
||||||
|
<div className="info-row">
|
||||||
|
<span className="info-label">会话</span>
|
||||||
|
<span className="info-value">{loggedInUsers}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="info-row">
|
<div className="info-row">
|
||||||
<span className="info-label">软件包</span>
|
<span className="info-label">软件包</span>
|
||||||
<span className="info-value">{packageCount}</span>
|
<span className="info-value">{packageCount}</span>
|
||||||
@@ -254,11 +179,15 @@ export const ServerCard = ({
|
|||||||
<div className="performance-row">
|
<div className="performance-row">
|
||||||
<span className="performance-item">
|
<span className="performance-item">
|
||||||
<span className="performance-item-label">读取</span>
|
<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>
|
||||||
<span className="performance-item">
|
<span className="performance-item">
|
||||||
<span className="performance-item-label">写入</span>
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -270,11 +199,15 @@ export const ServerCard = ({
|
|||||||
<div className="performance-row">
|
<div className="performance-row">
|
||||||
<span className="performance-item">
|
<span className="performance-item">
|
||||||
<span className="performance-item-label">下载</span>
|
<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>
|
||||||
<span className="performance-item">
|
<span className="performance-item">
|
||||||
<span className="performance-item-label">上传</span>
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -285,23 +218,17 @@ export const ServerCard = ({
|
|||||||
<span className="footer-label">运行时间</span>
|
<span className="footer-label">运行时间</span>
|
||||||
<span className="footer-value">{uptime !== undefined ? formatUptime(uptime) : 'N/A'}</span>
|
<span className="footer-value">{uptime !== undefined ? formatUptime(uptime) : 'N/A'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="footer-row">
|
<div className="footer-row" title="采集器到监控中心的 TCP 建连耗时(由采集端上报)">
|
||||||
<span className="footer-label">延迟</span>
|
<span className="footer-label">采集器→中心</span>
|
||||||
<span className="footer-value" style={{ color: latencyColor }}>
|
<span className={`footer-value ${latencyClass}`}>{formatLatencyMs(latency)}</span>
|
||||||
{latency > 0 ? `${latency} ms` : latency === -1 ? '超时' : 'N/A'}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
|
||||||
<div className="offline-state">
|
|
||||||
<p>服务器连接断开</p>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="card-footer">
|
<div className="card-footer">
|
||||||
<button className="btn-detail" onClick={() => onDetail(server.id)}>
|
<button type="button" className="btn-detail" onClick={() => onDetail(server.id)}>
|
||||||
查看详情
|
查看详情
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,5 @@
|
|||||||
import type { ServerMetrics } from '../../types';
|
import type { ServerMetrics } from '../../types';
|
||||||
import { formatBytes, formatUptime, formatPercent } from '../../utils/format';
|
import { formatBytes, formatUptime, formatPercent, formatLatencyMs } from '../../utils/format';
|
||||||
import { CircularProgress } from '../Common/CircularProgress';
|
|
||||||
import './ServerDetail.css';
|
import './ServerDetail.css';
|
||||||
|
|
||||||
interface ServerDetailProps {
|
interface ServerDetailProps {
|
||||||
@@ -20,12 +19,12 @@ export const ServerDetail = ({ metrics, serverName, onClose }: ServerDetailProps
|
|||||||
|
|
||||||
<div className="modal-body">
|
<div className="modal-body">
|
||||||
<section className="detail-section">
|
<section className="detail-section">
|
||||||
<h3>系统信息</h3>
|
<h3 className="detail-category-heading">系统与环境</h3>
|
||||||
<div className="system-info-container">
|
<div className="system-info-container">
|
||||||
{/* 系统基本信息 */}
|
{/* 系统基本信息 */}
|
||||||
<div className="system-info-group">
|
<div className="system-info-group">
|
||||||
<h4 className="info-group-title">基本信息</h4>
|
<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-item">
|
||||||
<div className="system-info-content">
|
<div className="system-info-content">
|
||||||
<span className="system-info-label">主机名</span>
|
<span className="system-info-label">主机名</span>
|
||||||
@@ -59,7 +58,7 @@ export const ServerDetail = ({ metrics, serverName, onClose }: ServerDetailProps
|
|||||||
{/* 运行时信息 */}
|
{/* 运行时信息 */}
|
||||||
<div className="system-info-group">
|
<div className="system-info-group">
|
||||||
<h4 className="info-group-title">运行时信息</h4>
|
<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-item system-info-item-highlight">
|
||||||
<div className="system-info-content">
|
<div className="system-info-content">
|
||||||
<span className="system-info-label">运行时间</span>
|
<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>
|
<span className="system-info-value">{metrics.system.processCount}</span>
|
||||||
</div>
|
</div>
|
||||||
</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 && (
|
{metrics.system.packageCount > 0 && (
|
||||||
<div className="system-info-item">
|
<div className="system-info-item">
|
||||||
<div className="system-info-content">
|
<div className="system-info-content">
|
||||||
@@ -86,7 +101,7 @@ export const ServerDetail = ({ metrics, serverName, onClose }: ServerDetailProps
|
|||||||
{/* 性能指标 */}
|
{/* 性能指标 */}
|
||||||
<div className="system-info-group">
|
<div className="system-info-group">
|
||||||
<h4 className="info-group-title">性能指标</h4>
|
<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-item">
|
||||||
<div className="system-info-content">
|
<div className="system-info-content">
|
||||||
<span className="system-info-label">磁盘读取</span>
|
<span className="system-info-label">磁盘读取</span>
|
||||||
@@ -129,34 +144,48 @@ export const ServerDetail = ({ metrics, serverName, onClose }: ServerDetailProps
|
|||||||
{/* 延迟信息 */}
|
{/* 延迟信息 */}
|
||||||
{metrics.latency && (
|
{metrics.latency && (
|
||||||
<div className="system-info-group">
|
<div className="system-info-group">
|
||||||
<h4 className="info-group-title">延迟检测</h4>
|
<h4 className="info-group-title">网络延迟(采集端测量)</h4>
|
||||||
<div className="system-info-grid">
|
<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-item system-info-item-highlight">
|
||||||
<div className="system-info-content">
|
<div className="system-info-content system-info-content--stack">
|
||||||
<span className="system-info-label">客户端到服务器</span>
|
<span className="system-info-label">采集器到监控中心</span>
|
||||||
|
<span className="system-info-sublabel">与 CENTRAL_GRPC 的 TCP 连通</span>
|
||||||
<span className="system-info-value highlight">
|
<span className="system-info-value highlight">
|
||||||
{metrics.latency.clientToServer >= 0 ? `${metrics.latency.clientToServer} ms` : '超时'}
|
{formatLatencyMs(metrics.latency.clientToServer)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{metrics.latency.external && (
|
{metrics.latency.external && (
|
||||||
<>
|
<>
|
||||||
<div className="system-info-item">
|
<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-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>
|
</div>
|
||||||
<div className="system-info-item">
|
<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-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>
|
</div>
|
||||||
<div className="system-info-item">
|
<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-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>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -168,152 +197,152 @@ export const ServerDetail = ({ metrics, serverName, onClose }: ServerDetailProps
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="detail-section">
|
<section className="detail-section">
|
||||||
<h3>CPU</h3>
|
<h3 className="detail-category-heading">处理器与内存</h3>
|
||||||
<div className="cpu-section-content">
|
<div className="detail-subsection">
|
||||||
<div className="cpu-circular-group">
|
<h4 className="detail-subheading">处理器</h4>
|
||||||
<div className="cpu-circular">
|
<div className="detail-plain">
|
||||||
<CircularProgress
|
<div className="detail-grid detail-grid--cpu">
|
||||||
value={metrics.cpu.usagePercent}
|
<div className="detail-item detail-item--span-full">
|
||||||
label=""
|
<span className="item-label">型号</span>
|
||||||
size={140}
|
<span className="item-value">{metrics.cpu.model}</span>
|
||||||
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)}
|
|
||||||
/>
|
|
||||||
</div>
|
</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">
|
<div className="detail-item">
|
||||||
<span className="item-label">温度</span>
|
<span className="item-label">核心数</span>
|
||||||
<span className="item-value">{metrics.cpu.temperature.toFixed(1)}°C</span>
|
<span className="item-value">{metrics.cpu.cores}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
{metrics.cpu.loadAverages && metrics.cpu.loadAverages.length > 0 && (
|
|
||||||
<div className="detail-item">
|
<div className="detail-item">
|
||||||
<span className="item-label">负载平均值</span>
|
<span className="item-label">使用率</span>
|
||||||
<span className="item-value">{metrics.cpu.loadAverages.join(' / ')}</span>
|
<span className="item-value emphasize">{formatPercent(metrics.cpu.usagePercent)}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
{metrics.cpu.temperature && metrics.cpu.temperature > 0 && (
|
||||||
</div>
|
<div className="detail-item">
|
||||||
</div>
|
<span className="item-label">温度</span>
|
||||||
|
<span className="item-value">{metrics.cpu.temperature.toFixed(1)}°C</span>
|
||||||
{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)}
|
|
||||||
/>
|
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
{metrics.cpu.perCoreUsage && metrics.cpu.perCoreUsage.length > 0 && (
|
||||||
</section>
|
<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">
|
<div className="detail-subsection">
|
||||||
<h3>内存</h3>
|
<h4 className="detail-subheading">内存</h4>
|
||||||
<div className="memory-section-content">
|
<div className="detail-plain">
|
||||||
<div className="memory-circular">
|
<div className="detail-grid">
|
||||||
<CircularProgress
|
<div className="detail-item">
|
||||||
value={metrics.memory.usedPercent}
|
<span className="item-label">使用率</span>
|
||||||
label=""
|
<span className="item-value emphasize">{formatPercent(metrics.memory.usedPercent)}</span>
|
||||||
size={140}
|
</div>
|
||||||
color={metrics.memory.usedPercent > 80 ? '#fde047' : '#6ee7b7'}
|
<div className="detail-item">
|
||||||
subLabel={formatPercent(metrics.memory.usedPercent)}
|
<span className="item-label">总容量</span>
|
||||||
/>
|
<span className="item-value">{formatBytes(metrics.memory.totalBytes)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="detail-grid">
|
<div className="detail-item">
|
||||||
<div className="detail-item">
|
<span className="item-label">已使用</span>
|
||||||
<span className="item-label">总容量</span>
|
<span className="item-value">{formatBytes(metrics.memory.usedBytes)}</span>
|
||||||
<span className="item-value">{formatBytes(metrics.memory.totalBytes)}</span>
|
</div>
|
||||||
</div>
|
<div className="detail-item">
|
||||||
<div className="detail-item">
|
<span className="item-label">可用</span>
|
||||||
<span className="item-label">已使用</span>
|
<span className="item-value">{formatBytes(metrics.memory.freeBytes)}</span>
|
||||||
<span className="item-value">{formatBytes(metrics.memory.usedBytes)}</span>
|
</div>
|
||||||
</div>
|
{(metrics.memory.swapTotalBytes !== undefined ||
|
||||||
<div className="detail-item">
|
metrics.memory.swapType !== undefined) && (
|
||||||
<span className="item-label">可用</span>
|
<>
|
||||||
<span className="item-value">{formatBytes(metrics.memory.freeBytes)}</span>
|
<div className="detail-item">
|
||||||
</div>
|
<span className="item-label">Swap 总量</span>
|
||||||
<div className="detail-item">
|
<span className="item-value">
|
||||||
<span className="item-label">使用率</span>
|
{formatBytes(metrics.memory.swapTotalBytes ?? 0)}
|
||||||
<span className="item-value highlight">{formatPercent(metrics.memory.usedPercent)}</span>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="detail-section">
|
<section className="detail-section">
|
||||||
<h3>存储</h3>
|
<h3 className="detail-category-heading">存储与磁盘</h3>
|
||||||
<div className="storage-grid-circular">
|
<ul className="storage-plain-list">
|
||||||
{metrics.storage.map((disk, index) => (
|
{metrics.storage.map((disk, index) => (
|
||||||
<div key={index} className="storage-item-circular">
|
<li key={index} className="storage-plain-row">
|
||||||
<CircularProgress
|
<div className="storage-plain-head">
|
||||||
value={disk.usedPercent}
|
<span className="storage-mount">{disk.mount}</span>
|
||||||
label={disk.mount}
|
<span className="storage-pct">{formatPercent(disk.usedPercent)}</span>
|
||||||
size={160}
|
</div>
|
||||||
color={disk.usedPercent > 90 ? '#f87171' : '#a7f3d0'}
|
<div className="storage-meta">
|
||||||
subLabel={formatPercent(disk.usedPercent)}
|
已用 {formatBytes(disk.usedBytes)} / 共 {formatBytes(disk.totalBytes)} · 可用{' '}
|
||||||
/>
|
{formatBytes(disk.freeBytes)}
|
||||||
<div className="storage-details-mini">
|
</div>
|
||||||
<div>总: {formatBytes(disk.totalBytes)}</div>
|
</li>
|
||||||
<div>用: {formatBytes(disk.usedBytes)}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{metrics.gpu && metrics.gpu.length > 0 && metrics.gpu[0].status !== 'not_available' && (
|
{metrics.gpu && metrics.gpu.length > 0 && metrics.gpu[0].status !== 'not_available' && (
|
||||||
<section className="detail-section">
|
<section className="detail-section">
|
||||||
<h3>GPU</h3>
|
<h3 className="detail-category-heading">图形设备</h3>
|
||||||
{metrics.gpu.map((gpu, index) => (
|
{metrics.gpu.map((gpu, index) => (
|
||||||
<div key={index} className="gpu-item">
|
<div key={index} className="gpu-item">
|
||||||
<div className="gpu-header">
|
<div className="gpu-header">
|
||||||
<strong>{gpu.name}</strong>
|
<strong>{gpu.name}</strong>
|
||||||
<span className={`gpu-status ${gpu.status}`}>{gpu.status}</span>
|
<span className={`gpu-status ${gpu.status}`}>{gpu.status}</span>
|
||||||
</div>
|
</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-grid">
|
||||||
|
<div className="detail-item">
|
||||||
|
<span className="item-label">利用率</span>
|
||||||
|
<span className="item-value emphasize">{formatPercent(gpu.utilizationPercent)}</span>
|
||||||
|
</div>
|
||||||
<div className="detail-item">
|
<div className="detail-item">
|
||||||
<span className="item-label">显存总量</span>
|
<span className="item-label">显存总量</span>
|
||||||
<span className="item-value">{gpu.memoryTotalMB} MB</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 && (
|
{metrics.network && metrics.network.length > 0 && (
|
||||||
<section className="detail-section">
|
<section className="detail-section">
|
||||||
<h3>网络接口</h3>
|
<h3 className="detail-category-heading">网络</h3>
|
||||||
<div className="network-list">
|
<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) => (
|
{metrics.network.map((iface, index) => (
|
||||||
<div key={index} className="network-item">
|
<div key={index} className="network-compact__row" role="row">
|
||||||
<div className="network-header">
|
<strong className="network-compact__iface" role="cell">
|
||||||
<div className="network-name-group">
|
{iface.name}
|
||||||
<div className="network-name-info">
|
</strong>
|
||||||
<strong className="network-name">{iface.name}</strong>
|
<span
|
||||||
{iface.ipAddress && (
|
className={
|
||||||
<span className="network-ip has-ip">
|
iface.ipAddress && iface.ipAddress !== 'N/A'
|
||||||
{iface.ipAddress}
|
? 'network-compact__ip network-compact__ip--ok'
|
||||||
</span>
|
: 'network-compact__placeholder'
|
||||||
)}
|
}
|
||||||
</div>
|
role="cell"
|
||||||
</div>
|
>
|
||||||
</div>
|
{iface.ipAddress && iface.ipAddress !== 'N/A' ? iface.ipAddress : '—'}
|
||||||
<div className="network-details">
|
</span>
|
||||||
{(iface.macAddress || iface.ipAddress) && (
|
<span
|
||||||
<div className="network-detail-row">
|
className="network-compact__mac"
|
||||||
{iface.macAddress && (
|
role="cell"
|
||||||
<div className="network-detail-item">
|
title={iface.macAddress || undefined}
|
||||||
<span className="network-detail-label">MAC 地址</span>
|
>
|
||||||
<span className="network-detail-value">{iface.macAddress}</span>
|
{iface.macAddress || '—'}
|
||||||
</div>
|
</span>
|
||||||
)}
|
<span className="network-compact__num" role="cell" title="接收流量">
|
||||||
{iface.ipAddress && (
|
{formatBytes(iface.rxBytes)}
|
||||||
<div className="network-detail-item">
|
</span>
|
||||||
<span className="network-detail-label">IP 地址</span>
|
<span className="network-compact__num" role="cell" title="发送流量">
|
||||||
<span className="network-detail-value">{iface.ipAddress}</span>
|
{formatBytes(iface.txBytes)}
|
||||||
</div>
|
</span>
|
||||||
)}
|
|
||||||
</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>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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">
|
<section className="detail-section">
|
||||||
<h3>Top 5 进程</h3>
|
<h3 className="detail-category-heading">进程与服务</h3>
|
||||||
<div className="process-list">
|
{metrics.system.topProcesses && metrics.system.topProcesses.length > 0 && (
|
||||||
{metrics.system.topProcesses.map((proc, index) => (
|
<div className="detail-subsection">
|
||||||
<div key={index} className="process-item">
|
<h4 className="detail-subheading">资源占用较高的进程(前 10)</h4>
|
||||||
<div className="process-rank">{index + 1}</div>
|
<div className="process-compact" role="table" aria-label="进程占用">
|
||||||
<div className="process-content">
|
<div className="process-compact__head" role="row">
|
||||||
<div className="process-header">
|
<span role="columnheader">#</span>
|
||||||
<div className="process-name-group">
|
<span role="columnheader">进程</span>
|
||||||
<strong className="process-name">{proc.name}</strong>
|
<span role="columnheader">PID</span>
|
||||||
<span className="process-pid">PID: {proc.pid}</span>
|
<span role="columnheader">CPU</span>
|
||||||
</div>
|
<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>
|
||||||
<div className="process-metrics">
|
))}
|
||||||
<div className="process-metric-item">
|
</div>
|
||||||
<div className="metric-header">
|
</div>
|
||||||
<span className="metric-label">CPU</span>
|
)}
|
||||||
<span className="metric-value">{proc.cpu}%</span>
|
|
||||||
</div>
|
{metrics.system.dockerStats && metrics.system.dockerStats.available && (
|
||||||
<div className="metric-bar-container">
|
<div className="detail-subsection">
|
||||||
<div
|
<h4 className="detail-subheading">Docker</h4>
|
||||||
className="metric-bar cpu-bar"
|
{metrics.system.dockerStats.version && (
|
||||||
style={{ width: `${Math.min(proc.cpu, 100)}%` }}
|
<div className="detail-grid">
|
||||||
/>
|
<div className="detail-item">
|
||||||
</div>
|
<span className="item-label">Docker 版本</span>
|
||||||
</div>
|
<span className="item-value">{metrics.system.dockerStats.version}</span>
|
||||||
<div className="process-metric-item">
|
</div>
|
||||||
<div className="metric-header">
|
<div className="detail-item">
|
||||||
<span className="metric-label">内存</span>
|
<span className="item-label">运行中的容器</span>
|
||||||
<span className="metric-value">{proc.memory}% ({proc.memoryMB.toFixed(1)} MB)</span>
|
<span className="item-value">{metrics.system.dockerStats.running}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="metric-bar-container">
|
<div className="detail-item">
|
||||||
<div
|
<span className="item-label">停止的容器</span>
|
||||||
className="metric-bar memory-bar"
|
<span className="item-value">{metrics.system.dockerStats.stopped}</span>
|
||||||
style={{ width: `${Math.min(proc.memory, 100)}%` }}
|
</div>
|
||||||
/>
|
<div className="detail-item">
|
||||||
</div>
|
<span className="item-label">镜像数量</span>
|
||||||
</div>
|
<span className="item-value">{metrics.system.dockerStats.imageCount}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{metrics.system.dockerStats && metrics.system.dockerStats.available && (
|
{metrics.system.dockerStats.runningNames && metrics.system.dockerStats.runningNames.length > 0 && (
|
||||||
<section className="detail-section">
|
<div className="docker-containers-section">
|
||||||
<h3>Docker 信息</h3>
|
<h4 className="detail-subheading detail-subheading--minor">运行中的容器</h4>
|
||||||
{metrics.system.dockerStats.version && (
|
<div className="docker-names-list">
|
||||||
<div className="detail-grid">
|
{metrics.system.dockerStats.runningNames.map((name, index) => (
|
||||||
<div className="detail-item">
|
<span key={index} className="docker-name-tag running">
|
||||||
<span className="item-label">Docker 版本</span>
|
{name}
|
||||||
<span className="item-value">{metrics.system.dockerStats.version}</span>
|
</span>
|
||||||
</div>
|
))}
|
||||||
<div className="detail-item">
|
</div>
|
||||||
<span className="item-label">运行中的容器</span>
|
</div>
|
||||||
<span className="item-value">{metrics.system.dockerStats.running}</span>
|
)}
|
||||||
</div>
|
|
||||||
<div className="detail-item">
|
{metrics.system.dockerStats.stoppedNames && metrics.system.dockerStats.stoppedNames.length > 0 && (
|
||||||
<span className="item-label">停止的容器</span>
|
<div className="docker-containers-section">
|
||||||
<span className="item-value">{metrics.system.dockerStats.stopped}</span>
|
<h4 className="detail-subheading detail-subheading--minor">停止的容器</h4>
|
||||||
</div>
|
<div className="docker-names-list">
|
||||||
<div className="detail-item">
|
{metrics.system.dockerStats.stoppedNames.map((name, index) => (
|
||||||
<span className="item-label">镜像数量</span>
|
<span key={index} className="docker-name-tag stopped">
|
||||||
<span className="item-value">{metrics.system.dockerStats.imageCount}</span>
|
{name}
|
||||||
</div>
|
</span>
|
||||||
</div>
|
))}
|
||||||
)}
|
</div>
|
||||||
|
</div>
|
||||||
{metrics.system.dockerStats.runningNames && metrics.system.dockerStats.runningNames.length > 0 && (
|
)}
|
||||||
<div className="docker-containers-section">
|
|
||||||
<h4>运行中的容器</h4>
|
{metrics.system.dockerStats.imageNames && metrics.system.dockerStats.imageNames.length > 0 && (
|
||||||
<div className="docker-names-list">
|
<div className="docker-containers-section">
|
||||||
{metrics.system.dockerStats.runningNames.map((name, index) => (
|
<h4 className="detail-subheading detail-subheading--minor">镜像列表</h4>
|
||||||
<span key={index} className="docker-name-tag running">
|
<div className="docker-names-list">
|
||||||
{name}
|
{metrics.system.dockerStats.imageNames.map((name, index) => (
|
||||||
</span>
|
<span key={index} className="docker-name-tag image">
|
||||||
))}
|
{name}
|
||||||
</div>
|
</span>
|
||||||
</div>
|
))}
|
||||||
)}
|
</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>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
@@ -504,7 +516,8 @@ export const ServerDetail = ({ metrics, serverName, onClose }: ServerDetailProps
|
|||||||
|
|
||||||
{metrics.system.systemLogs && metrics.system.systemLogs.length > 0 && (
|
{metrics.system.systemLogs && metrics.system.systemLogs.length > 0 && (
|
||||||
<section className="detail-section">
|
<section className="detail-section">
|
||||||
<h3>系统日志</h3>
|
<h3 className="detail-category-heading">诊断与日志</h3>
|
||||||
|
<h4 className="detail-subheading">系统日志</h4>
|
||||||
<div className="logs-container">
|
<div className="logs-container">
|
||||||
{metrics.system.systemLogs.map((log, index) => (
|
{metrics.system.systemLogs.map((log, index) => (
|
||||||
<div key={index} className="log-line">
|
<div key={index} className="log-line">
|
||||||
|
|||||||
37
mengyamonitor-frontend/src/hooks/usePublicServers.ts
Normal file
37
mengyamonitor-frontend/src/hooks/usePublicServers.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import type { ServerConfig } from '../types';
|
||||||
|
import { fetchPublicServers } from '../api/central';
|
||||||
|
|
||||||
|
export const usePublicServers = (refreshMs: number = 30000) => {
|
||||||
|
const [servers, setServers] = useState<ServerConfig[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const reload = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setError(null);
|
||||||
|
const list = await fetchPublicServers();
|
||||||
|
setServers(
|
||||||
|
list.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
name: s.name,
|
||||||
|
url: s.url.replace(/\/$/, ''),
|
||||||
|
enabled: s.enabled,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : '加载失败');
|
||||||
|
setServers([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void reload();
|
||||||
|
const t = setInterval(() => void reload(), refreshMs);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, [reload, refreshMs]);
|
||||||
|
|
||||||
|
return { servers, loading, error, reload };
|
||||||
|
};
|
||||||
@@ -1,58 +1,107 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import type { ServerConfig, ServerStatus } from '../types';
|
import type { ServerStatus } from '../types';
|
||||||
import { fetchServerMetrics } from '../api/monitor';
|
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 [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(() => {
|
useEffect(() => {
|
||||||
// Initial fetch
|
let cancelled = false;
|
||||||
servers.forEach(server => {
|
let pendingTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
if (server.enabled) {
|
let socket: WebSocket | null = null;
|
||||||
fetchMetrics(server);
|
const url = monitorWsURL();
|
||||||
|
|
||||||
|
const clearPending = () => {
|
||||||
|
if (pendingTimer !== undefined) {
|
||||||
|
clearTimeout(pendingTimer);
|
||||||
|
pendingTimer = undefined;
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
|
|
||||||
// Set up polling
|
const scheduleConnect = (delayMs: number) => {
|
||||||
const timer = setInterval(() => {
|
clearPending();
|
||||||
servers.forEach(server => {
|
pendingTimer = window.setTimeout(() => {
|
||||||
if (server.enabled) {
|
pendingTimer = undefined;
|
||||||
fetchMetrics(server);
|
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);
|
ws.onclose = () => {
|
||||||
}, [servers, interval, fetchMetrics]);
|
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;
|
return statuses;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,42 +13,49 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
/* Theme Colors - 淡绿色/淡黄绿色渐变 */
|
/* Pastel / glass accents(参考玻璃拟态面板) */
|
||||||
--primary-color: #86efac;
|
--primary-color: #7dd3fc;
|
||||||
--primary-hover: #6ee7b7;
|
--primary-hover: #38bdf8;
|
||||||
--secondary-color: #a7f3d0;
|
--secondary-color: #c4b5fd;
|
||||||
|
--accent-rose: #f9a8d4;
|
||||||
--warning-color: #fde047;
|
--warning-color: #fde047;
|
||||||
--danger-color: #f87171;
|
--danger-color: #f87171;
|
||||||
--success-color: #86efac;
|
--success-color: #6ee7b7;
|
||||||
|
|
||||||
/* Backgrounds - 纯白色 */
|
/* Glass surfaces(--bg-color 用于表单、凹槽等需可读底色的区域) */
|
||||||
--bg-color: #ffffff;
|
--bg-color: rgba(255, 255, 255, 0.72);
|
||||||
--surface-color: #ffffff;
|
--surface-color: rgba(255, 255, 255, 0.52);
|
||||||
--surface-hover: #ffffff;
|
--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 */
|
||||||
--text-main: #0f172a;
|
--text-main: #1e293b;
|
||||||
--text-secondary: #64748b;
|
--text-secondary: #64748b;
|
||||||
--text-light: #94a3b8;
|
--text-light: #94a3b8;
|
||||||
|
|
||||||
/* Borders & Shadows - 灰白色边框 */
|
--border-color: rgba(255, 255, 255, 0.55);
|
||||||
--border-color: #e2e8f0;
|
--shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.06);
|
||||||
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
--shadow-md: var(--glass-shadow);
|
||||||
--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 16px 48px rgba(99, 102, 241, 0.12), 0 4px 16px rgba(15, 23, 42, 0.08);
|
||||||
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
|
||||||
|
--radius-sm: 0.25rem;
|
||||||
/* Radius */
|
--radius-md: 0.375rem;
|
||||||
--radius-sm: 0.375rem;
|
--radius-lg: 0.5rem;
|
||||||
--radius-md: 0.5rem;
|
--radius-glass: 0.625rem;
|
||||||
--radius-lg: 1rem;
|
|
||||||
|
--font-ui:
|
||||||
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
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;
|
line-height: 1.5;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
|
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
color: var(--text-main);
|
color: var(--text-main);
|
||||||
background-color: var(--bg-color);
|
background-color: transparent;
|
||||||
|
|
||||||
font-synthesis: none;
|
font-synthesis: none;
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
@@ -60,7 +67,13 @@ body {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
min-width: 320px;
|
min-width: 320px;
|
||||||
min-height: 100vh;
|
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 {
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
|||||||
@@ -24,6 +24,13 @@ export interface MemoryMetrics {
|
|||||||
usedBytes: number;
|
usedBytes: number;
|
||||||
freeBytes: number;
|
freeBytes: number;
|
||||||
usedPercent: number;
|
usedPercent: number;
|
||||||
|
/** 交换分区 / swap 总量(字节),由新版采集端上报 */
|
||||||
|
swapTotalBytes?: number;
|
||||||
|
swapUsedBytes?: number;
|
||||||
|
swapFreeBytes?: number;
|
||||||
|
swapUsedPercent?: number;
|
||||||
|
/** file / partition / zram 等,来自 /proc/swaps;「无」表示未配置 swap */
|
||||||
|
swapType?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StorageMetrics {
|
export interface StorageMetrics {
|
||||||
@@ -55,6 +62,10 @@ export interface NetworkInterface {
|
|||||||
|
|
||||||
export interface SystemStats {
|
export interface SystemStats {
|
||||||
processCount: number;
|
processCount: number;
|
||||||
|
/** 当前登录会话数(who 行数);新版采集端上报 */
|
||||||
|
loggedInUsers?: number;
|
||||||
|
/** TCP 套接字数(/proc/net/tcp + tcp6,含监听与各状态);新版采集端上报 */
|
||||||
|
tcpConnections?: number;
|
||||||
packageCount: number;
|
packageCount: number;
|
||||||
packageManager: string;
|
packageManager: string;
|
||||||
temperature?: number;
|
temperature?: number;
|
||||||
|
|||||||
@@ -43,3 +43,10 @@ export const formatUptime = (seconds: number): string => {
|
|||||||
export const formatPercent = (value: number): string => {
|
export const formatPercent = (value: number): string => {
|
||||||
return `${Math.round(value * 10) / 10}%`;
|
return `${Math.round(value * 10) / 10}%`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 采集端上报的毫秒延迟(与 Go 侧 TCP 探测展示一致) */
|
||||||
|
export const formatLatencyMs = (ms: number): string => {
|
||||||
|
if (!Number.isFinite(ms) || ms < 0) return '超时';
|
||||||
|
if (ms < 1) return `${ms.toFixed(1)} ms`;
|
||||||
|
return `${Math.round(ms)} ms`;
|
||||||
|
};
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export default defineConfig({
|
|||||||
react(),
|
react(),
|
||||||
VitePWA({
|
VitePWA({
|
||||||
registerType: 'autoUpdate',
|
registerType: 'autoUpdate',
|
||||||
includeAssets: ['logo.svg'],
|
includeAssets: ['logo.png', 'logo2.png', 'favicon.ico'],
|
||||||
manifest: {
|
manifest: {
|
||||||
name: '萌芽监控面板',
|
name: '萌芽监控面板',
|
||||||
short_name: '萌芽监控',
|
short_name: '萌芽监控',
|
||||||
@@ -20,12 +20,15 @@ export default defineConfig({
|
|||||||
scope: '/',
|
scope: '/',
|
||||||
start_url: '/',
|
start_url: '/',
|
||||||
icons: [
|
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']
|
categories: ['utilities', 'productivity']
|
||||||
},
|
},
|
||||||
workbox: {
|
workbox: {
|
||||||
globPatterns: ['**/*.{js,css,html,ico,svg,woff2}'],
|
globPatterns: ['**/*.{js,css,html,ico,svg,woff2}'],
|
||||||
|
// 避免开发/生产 SPA 回退误吞 /central-api 转发
|
||||||
|
navigateFallbackDenylist: [/^\/central-api(?:\/|$)/],
|
||||||
runtimeCaching: [
|
runtimeCaching: [
|
||||||
{
|
{
|
||||||
urlPattern: /^https:\/\/.*\/api\//i,
|
urlPattern: /^https:\/\/.*\/api\//i,
|
||||||
@@ -47,5 +50,13 @@ export default defineConfig({
|
|||||||
server: {
|
server: {
|
||||||
port: 2929,
|
port: 2929,
|
||||||
host: true,
|
host: true,
|
||||||
|
proxy: {
|
||||||
|
'/central-api': {
|
||||||
|
target: 'http://127.0.0.1:9393',
|
||||||
|
changeOrigin: true,
|
||||||
|
ws: true,
|
||||||
|
rewrite: (path) => path.replace(/^\/central-api/, ''),
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
@echo off
|
@echo off
|
||||||
chcp 65001 >nul
|
chcp 65001 >nul
|
||||||
echo 启动后端服务器...
|
echo 启动后端服务器...
|
||||||
cd mengyadriftbottle-backend
|
cd mengyamonitor-backend-server
|
||||||
go mod tidy
|
go mod tidy
|
||||||
go run main.go
|
go run main.go
|
||||||
|
|||||||
28
公网部署/grpc.monitor.api.shumengya.top.conf
Normal file
28
公网部署/grpc.monitor.api.shumengya.top.conf
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# 萌芽监控 — 采集端 gRPC(TCP 四层透传,与 Gitea SSH 反代同写法)
|
||||||
|
# =============================================================================
|
||||||
|
# 本文件只能被 nginx 主配置里**顶层**的 stream { } 引入,不可放进取 http { } 的 sites 目录。
|
||||||
|
#
|
||||||
|
# 在 /etc/nginx/nginx.conf 中与 http { } 平级增加(路径按你实际放置修改):
|
||||||
|
# stream {
|
||||||
|
# include /etc/nginx/stream-enabled/grpc.monitor.api.shumengya.top.conf;
|
||||||
|
# }
|
||||||
|
#
|
||||||
|
# 公网监听端口默认 9394(与中心 GRPC_PORT 一致,采集端好记);若冲突可改为其它端口并放行防火墙。
|
||||||
|
# 上游:内网中心宿主机:9394,或 frp/nps 在本机打开的穿透端口(如 127.0.0.1:7001)。
|
||||||
|
#
|
||||||
|
# 采集端(与当前仓库默认 insecure 明文 gRPC 一致):
|
||||||
|
# CENTRAL_GRPC=公网VPS_IP:9394
|
||||||
|
# 若用域名,须 DNS 指向该 VPS(TCP 无 TLS,安全依赖防火墙 / IP 白名单 / 仅穿透不暴露公网)。
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
upstream mengyamonitor_grpc_backend {
|
||||||
|
server 10.1.1.233:9394;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 9394;
|
||||||
|
proxy_pass mengyamonitor_grpc_backend;
|
||||||
|
proxy_timeout 1h;
|
||||||
|
proxy_connect_timeout 10s;
|
||||||
|
}
|
||||||
75
公网部署/mengyamonitor-frontend.conf
Normal file
75
公网部署/mengyamonitor-frontend.conf
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
# 内网萌芽监控静态站(示例:6767,公网 monitor.shumengya.top 反代到本机)。
|
||||||
|
# root 改为你的 Vite 构建产物目录(npm run build 后的 dist 内容)。
|
||||||
|
server
|
||||||
|
{
|
||||||
|
listen 6767;
|
||||||
|
server_name 192.168.1.100_6767;
|
||||||
|
index index.html index.htm default.htm default.html;
|
||||||
|
root /shumengya/www/self-made/mengyamonitor-frontend;
|
||||||
|
include /www/server/panel/vhost/nginx/extension/192.168.1.100_6767/*.conf;
|
||||||
|
#CERT-APPLY-CHECK--START
|
||||||
|
# 用于SSL证书申请时的文件验证相关配置 -- 请勿删除并保持这段设置在优先级高的位置
|
||||||
|
include /www/server/panel/vhost/nginx/well-known/192.168.1.100_6767.conf;
|
||||||
|
#CERT-APPLY-CHECK--END
|
||||||
|
|
||||||
|
#SSL-START SSL相关配置,请勿删除或修改下一行带注释的404规则
|
||||||
|
#error_page 404/404.html;
|
||||||
|
#SSL-END
|
||||||
|
|
||||||
|
#ERROR-PAGE-START 错误页配置,可以注释、删除或修改
|
||||||
|
#error_page 404 /404.html;
|
||||||
|
#error_page 502 /502.html;
|
||||||
|
#ERROR-PAGE-END
|
||||||
|
|
||||||
|
#REWRITE-START URL重写规则引用,修改后将导致面板设置的伪静态规则失效
|
||||||
|
include /www/server/panel/vhost/rewrite/html_192.168.1.100_6767.conf;
|
||||||
|
#REWRITE-END
|
||||||
|
|
||||||
|
# SPA:浏览器刷新 / 深度链接须回到 index.html;勿删,否则前端路由 404
|
||||||
|
location = /index.html {
|
||||||
|
add_header Cache-Control "no-cache";
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 禁止访问的敏感文件
|
||||||
|
location ~* (\.user.ini|\.htaccess|\.htpasswd|\.env.*|\.project|\.bashrc|\.bash_profile|\.bash_logout|\.DS_Store|\.gitignore|\.gitattributes|LICENSE|README\.md|CLAUDE\.md|CHANGELOG\.md|CHANGELOG|CONTRIBUTING\.md|TODO\.md|FAQ\.md|composer\.json|composer\.lock|package(-lock)?\.json|yarn\.lock|pnpm-lock\.yaml|\.\w+~|\.swp|\.swo|\.bak(up)?|\.old|\.tmp|\.temp|\.log|\.sql(\.gz)?|docker-compose\.yml|docker\.env|Dockerfile|\.csproj|\.sln|Cargo\.toml|Cargo\.lock|go\.mod|go\.sum|phpunit\.xml|phpunit\.xml|pom\.xml|build\.gradl|pyproject\.toml|requirements\.txt|application(-\w+)?\.(ya?ml|properties))$
|
||||||
|
{
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 禁止访问的敏感目录
|
||||||
|
location ~* /(\.git|\.svn|\.bzr|\.vscode|\.claude|\.idea|\.ssh|\.github|\.npm|\.yarn|\.pnpm|\.cache|\.husky|\.turbo|\.next|\.nuxt|node_modules|runtime)/ {
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
|
||||||
|
#一键申请SSL证书验证目录相关设置
|
||||||
|
location ~ \.well-known{
|
||||||
|
allow all;
|
||||||
|
}
|
||||||
|
|
||||||
|
#禁止在证书验证目录放入敏感文件
|
||||||
|
if ( $uri ~ "^/\.well-known/.*\.(php|jsp|py|js|css|lua|ts|go|zip|tar\.gz|rar|7z|sql|bak)$" ) {
|
||||||
|
return 403;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ .*\\.(gif|jpg|jpeg|png|bmp|swf)$
|
||||||
|
{
|
||||||
|
expires 30d;
|
||||||
|
error_log /dev/null;
|
||||||
|
access_log /dev/null;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 带 hash 的前端资源(Vite /assets);短缓存,发版靠文件名变化
|
||||||
|
location ~* \.(js|mjs|css|woff2?|svg|ico)$
|
||||||
|
{
|
||||||
|
expires 12h;
|
||||||
|
add_header Cache-Control "public";
|
||||||
|
error_log /dev/null;
|
||||||
|
access_log /dev/null;
|
||||||
|
}
|
||||||
|
access_log /www/wwwlogs/192.168.1.100_6767.log;
|
||||||
|
error_log /www/wwwlogs/192.168.1.100_6767.error.log;
|
||||||
|
}
|
||||||
47
公网部署/monitor.api.shumengya.top.conf
Normal file
47
公网部署/monitor.api.shumengya.top.conf
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# 萌芽监控 — 浏览器 / 前端对接 API(HTTPS)
|
||||||
|
# 仅 REST + WebSocket → 9393。采集端 gRPC 为 **stream TCP 透传**,见 grpc.monitor.api.shumengya.top.conf(并入 nginx 顶层 stream{})。
|
||||||
|
# 上游 IP 按实际修改(内网中心或可路由到的穿透内网地址)。
|
||||||
|
|
||||||
|
upstream mengyamonitor_http {
|
||||||
|
server 10.1.1.233:9393;
|
||||||
|
keepalive 32;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name monitor.api.shumengya.top;
|
||||||
|
|
||||||
|
location /.well-known/acme-challenge/ {
|
||||||
|
root /var/www/certbot;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
listen [::]:443 ssl;
|
||||||
|
http2 on;
|
||||||
|
server_name monitor.api.shumengya.top;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/monitor.api.shumengya.top/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/monitor.api.shumengya.top/privkey.pem;
|
||||||
|
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||||
|
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://mengyamonitor_http;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto https;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_read_timeout 86400s;
|
||||||
|
proxy_send_timeout 86400s;
|
||||||
|
}
|
||||||
|
}
|
||||||
42
公网部署/monitor.shumengya.top.conf
Normal file
42
公网部署/monitor.shumengya.top.conf
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# 萌芽监控 — 前端 monitor.shumengya.top(HTTPS)
|
||||||
|
# 内网静态源:按需修改 proxy_pass
|
||||||
|
|
||||||
|
upstream mengyamonitor_frontend_upstream {
|
||||||
|
server 10.1.1.100:6767;
|
||||||
|
keepalive 16;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name monitor.shumengya.top;
|
||||||
|
|
||||||
|
location /.well-known/acme-challenge/ {
|
||||||
|
root /var/www/certbot;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
listen [::]:443 ssl;
|
||||||
|
http2 on;
|
||||||
|
server_name monitor.shumengya.top;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/monitor.shumengya.top/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/monitor.shumengya.top/privkey.pem;
|
||||||
|
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||||
|
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://mengyamonitor_frontend_upstream;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto https;
|
||||||
|
}
|
||||||
|
}
|
||||||
8
需求.txt
8
需求.txt
@@ -1,8 +0,0 @@
|
|||||||
1.实现一个服务器监控面板,目前初步支持Linux服务器,监控服务器基本信息,比如:cpu/GPU,内存,储存,操作系统等
|
|
||||||
2.采用企业级前后端分离架构,前端使用React框架,后端使用golang原版自带的net库
|
|
||||||
3.原理十分简单,后端go获取Linux相关硬件信息,并封装成相关后端json API路由,前端每隔1秒或者2秒获取后端信息
|
|
||||||
4.对外访问使用默认端口:2929 后端默认端口为9292
|
|
||||||
5.由于是监控面板 前端可以一次性对接多个服务器后端卡片式展示相关信息,前端面板可以展示一些基本信息,用户可以点击详情按钮查看更多详细信息
|
|
||||||
6.前端项目已经初始化 前后端代码架构必须分类整齐符合标准方便我阅读修改
|
|
||||||
7.注意前端相当于一个只是客户端用于请求可以配置后端服务器,这两个是分开的,前端可以打包部署在用户电脑上比如做成软件app,而后端构建打包后放到各个服务器上
|
|
||||||
8.前端页面风格偏白色柔和风
|
|
||||||
Reference in New Issue
Block a user