初始化提交

This commit is contained in:
2025-12-14 15:25:31 +08:00
commit 4fa42f7115
48 changed files with 8718 additions and 0 deletions

47
.gitignore vendored Normal file
View File

@@ -0,0 +1,47 @@
# General
.DS_Store
.AppleDouble
.LSOverride
Icon
._*
Thumbs.db
ehthumbs.db
Desktop.ini
*.log
*.tmp
*.bak
*.swp
*.swo
*~
.idea/
.vscode/
*.sublime-project
*.sublime-workspace
# Backend (Go)
/mengyamonitor-backend/mengyamonitor-backend
/mengyamonitor-backend/mengyamonitor-backend.exe
/mengyamonitor-backend/*.exe
/mengyamonitor-backend/*.out
/mengyamonitor-backend/bin/
/mengyamonitor-backend/dist/
/mengyamonitor-backend/vendor/
/mengyamonitor-backend/.env
/mengyamonitor-backend/debug
/mengyamonitor-backend/__debug_bin
# Frontend (Node/React/Vite)
/mengyamonitor-frontend/node_modules/
/mengyamonitor-frontend/dist/
/mengyamonitor-frontend/build/
/mengyamonitor-frontend/coverage/
/mengyamonitor-frontend/.env
/mengyamonitor-frontend/.env.local
/mengyamonitor-frontend/.env.development.local
/mengyamonitor-frontend/.env.test.local
/mengyamonitor-frontend/.env.production.local
/mengyamonitor-frontend/npm-debug.log*
/mengyamonitor-frontend/yarn-debug.log*
/mengyamonitor-frontend/yarn-error.log*
/mengyamonitor-frontend/pnpm-debug.log*
/mengyamonitor-frontend/.eslintcache

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 萌芽监控面板
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

156
README.md Normal file
View File

@@ -0,0 +1,156 @@
# 萌芽监控面板
一个简洁高效的 Linux 服务器监控面板,采用前后端分离架构。
## 项目概述
### 功能
- 实时监控 Linux 服务器的 CPU、内存、存储、GPU 使用情况
- 支持同时监控多个服务器
- 卡片式展示,直观清晰
- 详情弹窗查看完整信息
### 技术栈
- **前端**: React 19 + TypeScript + Vite
- **后端**: Go (原生 net/http 库)
- **风格**: 白色柔和风格界面
## 快速开始
### 后端部署
1. 进入后端目录
```bash
cd mengyamonitor-backend
```
2. 编译(在 Linux 服务器上)
```bash
go build -o mengyamonitor-backend
```
3. 运行
```bash
./mengyamonitor-backend
```
默认监听端口: 9292
4. 可选:配置环境变量
```bash
PORT=8080 ./mengyamonitor-backend
```
### 前端开发
1. 进入前端目录
```bash
cd mengyamonitor-frontend
```
2. 安装依赖
```bash
npm install
```
3. 启动开发服务器
```bash
npm run dev
```
默认访问端口: 2929
4. 构建生产版本
```bash
npm run build
```
## 使用说明
### 1. 部署后端到服务器
将编译好的 `mengyamonitor-backend` 二进制文件上传到需要监控的 Linux 服务器并运行。
### 2. 配置前端
在前端界面点击"添加服务器",输入:
- 服务器名称:例如"生产服务器1"
- 服务器地址:例如"http://192.168.1.100:9292"
### 3. 查看监控数据
- 主界面显示所有服务器的基本信息CPU、内存使用率
- 点击"查看详情"可查看完整的系统信息
- 数据每2秒自动刷新
## 项目结构
```
萌芽监控面板/
├── mengyamonitor-backend/ # 后端服务
│ ├── main.go # 主程序和HTTP服务器
│ ├── systeminfo.go # 系统信息采集
│ ├── go.mod # Go模块配置
│ └── README.md # 后端文档
├── mengyamonitor-frontend/ # 前端应用
│ ├── src/
│ │ ├── api/ # API调用层
│ │ ├── components/ # React组件
│ │ ├── hooks/ # 自定义Hooks
│ │ ├── types/ # TypeScript类型
│ │ ├── utils/ # 工具函数
│ │ ├── App.tsx # 主应用
│ │ └── main.tsx # 入口文件
│ ├── package.json
│ ├── vite.config.ts
│ └── README.md # 前端文档
└── 需求.txt # 需求文档
```
## API 接口
### GET /api/health
健康检查
### GET /api/metrics
获取系统监控指标
```json
{
"data": {
"hostname": "server1",
"timestamp": "2025-12-10T10:00:00Z",
"cpu": { "usagePercent": 23.45, ... },
"memory": { "usedPercent": 50.0, ... },
"storage": [...],
"gpu": [...],
"os": { ... },
"uptimeSeconds": 864000.5
}
}
```
## 注意事项
1. **系统支持**: 后端仅支持 Linux 系统
2. **权限要求**: 需要读取 `/proc` 文件系统的权限
3. **GPU 监控**: 需要安装 `nvidia-smi` 工具(可选)
4. **网络访问**: 确保前端可以访问后端的 9292 端口
5. **CORS**: 后端已配置允许跨域访问
## 常见问题
**Q: 前端无法连接后端?**
A: 检查服务器防火墙是否开放 9292 端口,确保后端服务正在运行。
**Q: GPU 信息显示不可用?**
A: 如果服务器没有 NVIDIA GPU 或未安装 nvidia-smiGPU 信息会显示为"不可用",这是正常的。
**Q: 如何将前端打包成桌面应用?**
A: 可以使用 Electron 或 Tauri 框架将前端打包成桌面应用,详见前端 README。
## 开发者
- 前后端分离架构,代码结构清晰
- 符合企业级开发规范
- 易于扩展和维护
## License
MIT

View File

@@ -0,0 +1,88 @@
# 编译说明 - 兼容旧版本系统
## 问题说明
如果在 Debian 11 或其他旧版本系统上运行时出现 GLIBC 版本错误:
```
./mengyamonitor-backend: /lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.34' not found
```
这是因为编译时使用了较新版本的 GLIBC而目标系统的 GLIBC 版本较旧。
## 解决方案
### 方案 1在目标系统上编译推荐
在 Debian 11 服务器上直接编译:
```bash
cd mengyamonitor-backend
# 禁用 CGO静态链接
export CGO_ENABLED=0
go build -ldflags="-s -w" -o mengyamonitor-backend .
# 或者使用提供的脚本
chmod +x build.sh
./build.sh
```
### 方案 2使用静态链接编译
在任何系统上编译,但使用静态链接:
```bash
cd mengyamonitor-backend
# 禁用 CGO
export CGO_ENABLED=0
export GOOS=linux
export GOARCH=amd64
# 编译(静态链接,不依赖系统库)
go build -ldflags="-s -w" -o mengyamonitor-backend .
```
### 方案 3使用 Docker 编译
使用 Docker 在 Debian 11 环境中编译:
```bash
# 使用 Debian 11 镜像编译
docker run --rm -v $(pwd):/app -w /app golang:1.21-bullseye sh -c "
export CGO_ENABLED=0
go build -ldflags='-s -w' -o mengyamonitor-backend .
"
# 或者使用多阶段构建
docker build -t mengyamonitor-backend -f Dockerfile.build .
```
## 验证编译结果
编译完成后,检查二进制文件:
```bash
# 检查文件类型
file mengyamonitor-backend
# 检查依赖(如果是静态链接,应该显示 "not a dynamic executable"
ldd mengyamonitor-backend
# 检查 GLIBC 依赖(应该没有或很少)
objdump -T mengyamonitor-backend | grep GLIBC
```
## 编译参数说明
- `CGO_ENABLED=0`: 禁用 CGO使用纯 Go 实现,不依赖系统 C 库
- `-ldflags="-s -w"`: 减小二进制文件大小
- `-s`: 省略符号表和调试信息
- `-w`: 省略 DWARF 符号表
## 注意事项
1. 禁用 CGO 后,某些需要 C 库的功能可能不可用,但本项目不依赖 CGO
2. 静态链接的二进制文件会稍大一些,但兼容性更好
3. 如果必须使用 CGO需要在目标系统上编译或使用相同 GLIBC 版本的系统编译

View File

@@ -0,0 +1,121 @@
# 萌芽监控面板 - 后端服务
## 概述
Linux 服务器监控后端服务,使用 Go 原生 net/http 库实现。
## 功能
- CPU 使用率和负载监控
- 内存使用情况
- 磁盘存储监控
- GPU 监控(支持 NVIDIA
- 操作系统信息
- 系统运行时间
## API 端点
### `GET /api/health`
健康检查端点
```json
{
"status": "ok",
"timestamp": "2025-12-10T10:00:00Z"
}
```
### `GET /api/metrics`
获取系统监控指标
```json
{
"data": {
"hostname": "server1",
"timestamp": "2025-12-10T10:00:00Z",
"cpu": {
"model": "Intel(R) Xeon(R) CPU E5-2680 v4 @ 2.40GHz",
"cores": 8,
"usagePercent": 23.45,
"loadAverages": [1.2, 1.5, 1.8]
},
"memory": {
"totalBytes": 16777216000,
"usedBytes": 8388608000,
"freeBytes": 8388608000,
"usedPercent": 50.0
},
"storage": [{
"mount": "/",
"totalBytes": 107374182400,
"usedBytes": 53687091200,
"freeBytes": 53687091200,
"usedPercent": 50.0
}],
"gpu": [{
"name": "Tesla T4",
"memoryTotalMB": 15360,
"memoryUsedMB": 512,
"utilizationPercent": 15.0,
"status": "ok"
}],
"os": {
"kernel": "Linux version 5.15.0",
"distro": "Ubuntu 22.04 LTS",
"architecture": "amd64"
},
"uptimeSeconds": 864000.5
}
}
```
## 运行方式
### 开发环境
```bash
go run .
```
### 生产环境
#### 标准编译
```bash
# 编译
go build -o mengyamonitor-backend
# 运行
./mengyamonitor-backend
```
#### 兼容旧版本系统编译(推荐)
如果需要在 Debian 11 或其他旧版本系统上运行,使用静态链接编译:
```bash
# 禁用 CGO静态链接不依赖系统 GLIBC 版本)
export CGO_ENABLED=0
go build -ldflags="-s -w" -o mengyamonitor-backend .
# 或使用提供的脚本
chmod +x build.sh
./build.sh
```
这样可以避免 GLIBC 版本兼容性问题。详细说明请参考 [BUILD.md](./BUILD.md)。
### 环境变量
- `HOST`: 监听地址,默认 `0.0.0.0`
- `PORT`: 监听端口,默认 `9292`
示例:
```bash
PORT=8080 ./mengyamonitor-backend
```
## 部署到服务器
1. 将编译好的二进制文件上传到目标服务器
2. 赋予执行权限:`chmod +x mengyamonitor-backend`
3. 运行服务:`./mengyamonitor-backend`
4. 可选:使用 systemd 或 supervisor 管理服务进程
## 注意事项
- 仅支持 Linux 系统
- GPU 监控需要安装 nvidia-smi 工具
- 需要读取 /proc 文件系统的权限

View File

@@ -0,0 +1,29 @@
#!/bin/bash
# 编译脚本 - 兼容旧版本 GLIBC
echo "开始编译 mengyamonitor-backend..."
# 禁用 CGO使用纯 Go 编译(不依赖系统 C 库)
export CGO_ENABLED=0
# 设置目标平台
export GOOS=linux
export GOARCH=amd64
# 编译(静态链接)
go build -ldflags="-s -w" -o mengyamonitor-backend .
if [ $? -eq 0 ]; then
echo "编译成功!"
echo "二进制文件: mengyamonitor-backend"
echo ""
echo "检查文件信息:"
file mengyamonitor-backend
echo ""
echo "检查依赖库:"
ldd mengyamonitor-backend 2>/dev/null || echo "静态链接,无外部依赖"
else
echo "编译失败!"
exit 1
fi

View File

@@ -0,0 +1,103 @@
package main
import (
"bufio"
"os"
"runtime"
"strconv"
"strings"
"time"
)
// CollectMetrics 收集所有系统监控指标
func CollectMetrics() (*Metrics, error) {
hostname, _ := os.Hostname()
cpuModel := firstMatchInFile("/proc/cpuinfo", "model name")
cpuUsage, err := readCPUUsage()
if err != nil {
return nil, err
}
cpuTemp := readCPUTemperature()
perCoreUsage := readPerCoreUsage()
mem, err := readMemory()
if err != nil {
return nil, err
}
storage, err := readAllStorage()
if err != nil {
return nil, err
}
gpu := readGPU()
network := readNetworkInterfaces()
systemStats := readSystemStats()
systemStats.DockerStats = readDockerStats()
osInfo := readOSInfo()
uptime := readUptime()
loads := readLoadAverages()
return &Metrics{
Hostname: hostname,
Timestamp: time.Now().UTC(),
CPU: CPUMetrics{
Model: cpuModel,
Cores: runtime.NumCPU(),
UsagePercent: round(cpuUsage, 2),
LoadAverages: loads,
Temperature: cpuTemp,
PerCoreUsage: perCoreUsage,
},
Memory: mem,
Storage: storage,
GPU: gpu,
Network: network,
System: systemStats,
OS: osInfo,
UptimeSeconds: uptime,
}, nil
}
func readOSInfo() OSInfo {
distro := readOSRelease()
kernel := strings.TrimSpace(readFirstLine("/proc/version"))
arch := runtime.GOARCH
return OSInfo{
Kernel: kernel,
Distro: distro,
Architecture: arch,
}
}
func readOSRelease() string {
f, err := os.Open("/etc/os-release")
if err != nil {
return runtime.GOOS
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "PRETTY_NAME=") {
return strings.Trim(line[len("PRETTY_NAME="):], "\"")
}
}
return runtime.GOOS
}
func readUptime() float64 {
line := readFirstLine("/proc/uptime")
fields := strings.Fields(line)
if len(fields) == 0 {
return 0
}
v, err := strconv.ParseFloat(fields[0], 64)
if err != nil {
return 0
}
return v
}

View File

@@ -0,0 +1,182 @@
package main
import (
"bufio"
"errors"
"os"
"strconv"
"strings"
"time"
)
// readCPUUsage 读取CPU整体使用率
func readCPUUsage() (float64, error) {
idle1, total1, err := readCPUTicks()
if err != nil {
return 0, err
}
time.Sleep(250 * time.Millisecond)
idle2, total2, err := readCPUTicks()
if err != nil {
return 0, err
}
if total2 == total1 {
return 0, errors.New("cpu totals unchanged")
}
idleDelta := float64(idle2 - idle1)
totalDelta := float64(total2 - total1)
usage := (1.0 - idleDelta/totalDelta) * 100
if usage < 0 {
usage = 0
}
return usage, nil
}
// readCPUTicks 读取CPU的idle和total ticks
func readCPUTicks() (idle, total uint64, err error) {
f, err := os.Open("/proc/stat")
if err != nil {
return 0, 0, err
}
defer f.Close()
scanner := bufio.NewScanner(f)
if !scanner.Scan() {
return 0, 0, errors.New("failed to scan /proc/stat")
}
fields := strings.Fields(scanner.Text())
if len(fields) < 5 {
return 0, 0, errors.New("unexpected /proc/stat format")
}
// fields[0] is "cpu"
var vals []uint64
for _, f := range fields[1:] {
v, err := strconv.ParseUint(f, 10, 64)
if err != nil {
return 0, 0, err
}
vals = append(vals, v)
}
for _, v := range vals {
total += v
}
if len(vals) > 3 {
idle = vals[3] // idle time
// 注意:不包含 iowait因为 iowait 不算真正的空闲时间
// 如果系统有 iowait它会在 total 中,但不应该算作 idle
}
return idle, total, nil
}
// readPerCoreUsage 读取每个CPU核心的使用率
func readPerCoreUsage() []CoreUsage {
coreUsages := []CoreUsage{}
// 第一次读取
cores1 := readPerCoreTicks()
time.Sleep(100 * time.Millisecond) // 减少到100ms
// 第二次读取
cores2 := readPerCoreTicks()
for i := 0; i < len(cores1) && i < len(cores2); i++ {
idle1, total1 := cores1[i][0], cores1[i][1]
idle2, total2 := cores2[i][0], cores2[i][1]
if total2 == total1 {
continue
}
idleDelta := float64(idle2 - idle1)
totalDelta := float64(total2 - total1)
usage := (1.0 - idleDelta/totalDelta) * 100
if usage < 0 {
usage = 0
}
coreUsages = append(coreUsages, CoreUsage{
Core: i,
Percent: round(usage, 1),
})
}
return coreUsages
}
// readPerCoreTicks 读取每个CPU核心的ticks
func readPerCoreTicks() [][2]uint64 {
var result [][2]uint64
f, err := os.Open("/proc/stat")
if err != nil {
return result
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
if !strings.HasPrefix(line, "cpu") {
continue
}
if strings.HasPrefix(line, "cpu ") {
continue // 跳过总的cpu行
}
fields := strings.Fields(line)
if len(fields) < 5 {
continue
}
var vals []uint64
for _, f := range fields[1:] {
v, err := strconv.ParseUint(f, 10, 64)
if err != nil {
break
}
vals = append(vals, v)
}
if len(vals) < 5 {
continue
}
var total, idle uint64
for _, v := range vals {
total += v
}
idle = vals[3] // idle time only, not including iowait
result = append(result, [2]uint64{idle, total})
}
return result
}
// readCPUTemperature 读取CPU温度
func readCPUTemperature() float64 {
// 尝试从常见位置读取温度
paths := []string{
"/sys/class/thermal/thermal_zone0/temp",
"/sys/class/hwmon/hwmon0/temp1_input",
"/sys/class/hwmon/hwmon1/temp1_input",
}
for _, path := range paths {
if temp := readTempFromFile(path); temp > 0 {
return temp
}
}
return 0
}
// readLoadAverages 读取系统负载平均值
func readLoadAverages() []float64 {
line := readFirstLine("/proc/loadavg")
fields := strings.Fields(line)
res := make([]float64, 0, 3)
for i := 0; i < len(fields) && i < 3; i++ {
v, err := strconv.ParseFloat(fields[i], 64)
if err == nil {
res = append(res, v)
}
}
return res
}

View File

@@ -0,0 +1,78 @@
package main
import (
"context"
"os/exec"
"strings"
"time"
)
// readDockerStats 读取 Docker 统计信息(简化版)
func readDockerStats() DockerStats {
stats := DockerStats{
Available: false,
RunningNames: []string{},
StoppedNames: []string{},
ImageNames: []string{},
}
// 检查 docker 是否可用
if _, err := exec.LookPath("docker"); err != nil {
return stats
}
stats.Available = true
// 获取 Docker 版本
cmd := exec.Command("docker", "--version")
if out, err := cmd.Output(); err == nil {
stats.Version = strings.TrimSpace(string(out))
}
// 获取运行中的容器名
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
cmd = exec.CommandContext(ctx, "docker", "ps", "--format", "{{.Names}}")
if out, err := cmd.Output(); err == nil {
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line != "" {
stats.RunningNames = append(stats.RunningNames, line)
stats.Running++
}
}
}
// 获取停止的容器名
ctx, cancel = context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
cmd = exec.CommandContext(ctx, "docker", "ps", "-a", "--filter", "status=exited", "--format", "{{.Names}}")
if out, err := cmd.Output(); err == nil {
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line != "" {
stats.StoppedNames = append(stats.StoppedNames, line)
stats.Stopped++
}
}
}
// 获取镜像名只获取前20个避免数据过多
ctx, cancel = context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
cmd = exec.CommandContext(ctx, "docker", "images", "--format", "{{.Repository}}:{{.Tag}}")
if out, err := cmd.Output(); err == nil {
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line != "" && line != "<none>:<none>" {
stats.ImageNames = append(stats.ImageNames, line)
stats.ImageCount++
}
}
}
return stats
}

View File

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

View File

@@ -0,0 +1,48 @@
package main
import (
"os/exec"
"strconv"
"strings"
)
// readGPU 读取GPU信息
func readGPU() []GPUMetrics {
_, err := exec.LookPath("nvidia-smi")
if err != nil {
return []GPUMetrics{{Status: "not_available"}}
}
// Query GPU info including temperature
cmd := exec.Command("nvidia-smi", "--query-gpu=name,memory.total,memory.used,utilization.gpu,temperature.gpu", "--format=csv,noheader,nounits")
out, err := cmd.Output()
if err != nil {
return []GPUMetrics{{Status: "error", Name: "nvidia-smi", UtilizationPercent: 0}}
}
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
gpus := make([]GPUMetrics, 0, len(lines))
for _, line := range lines {
parts := strings.Split(line, ",")
for i := range parts {
parts[i] = strings.TrimSpace(parts[i])
}
if len(parts) < 5 {
continue
}
total, _ := strconv.ParseInt(parts[1], 10, 64)
used, _ := strconv.ParseInt(parts[2], 10, 64)
util, _ := strconv.ParseFloat(parts[3], 64)
temp, _ := strconv.ParseFloat(parts[4], 64)
gpus = append(gpus, GPUMetrics{
Name: parts[0],
MemoryTotalMB: total,
MemoryUsedMB: used,
UtilizationPercent: round(util, 2),
Temperature: temp,
Status: "ok",
})
}
if len(gpus) == 0 {
return []GPUMetrics{{Status: "not_available"}}
}
return gpus
}

View File

@@ -0,0 +1,56 @@
package main
import (
"fmt"
"net"
"time"
)
// LatencyInfo 延迟信息
type LatencyInfo struct {
ClientToServer float64 `json:"clientToServer"` // 客户端到服务器延迟ms由前端计算
External map[string]string `json:"external"` // 外部网站延迟
}
// checkExternalLatency 检测外部网站延迟
func checkExternalLatency(host string, timeout time.Duration) string {
start := time.Now()
// 尝试 TCP 连接 80 端口
conn, err := net.DialTimeout("tcp", host+":80", timeout)
if err != nil {
// 如果 80 端口失败,尝试 443 (HTTPS)
conn, err = net.DialTimeout("tcp", host+":443", timeout)
if err != nil {
return "超时"
}
}
defer conn.Close()
latency := time.Since(start).Milliseconds()
// 检查是否超时(超过超时时间的一半就认为可能有问题)
if latency > timeout.Milliseconds()/2 {
return "超时"
}
return fmt.Sprintf("%d ms", latency)
}
// readExternalLatencies 读取外部网站延迟
func readExternalLatencies() map[string]string {
latencies := make(map[string]string)
timeout := 3 * time.Second
// 检测百度
latencies["baidu.com"] = checkExternalLatency("baidu.com", timeout)
// 检测谷歌
latencies["google.com"] = checkExternalLatency("google.com", timeout)
// 检测 GitHub
latencies["github.com"] = checkExternalLatency("github.com", timeout)
return latencies
}

View File

@@ -0,0 +1,265 @@
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"runtime"
"time"
)
// envelope keeps JSON responses consistent.
type envelope map[string]any
func main() {
host := getenv("HOST", "0.0.0.0")
port := getenv("PORT", "9292")
mux := http.NewServeMux()
mux.HandleFunc("/", rootHandler)
mux.HandleFunc("/api/health", healthHandler)
// 拆分的细粒度API端点
mux.HandleFunc("/api/metrics/cpu", cpuMetricsHandler)
mux.HandleFunc("/api/metrics/memory", memoryMetricsHandler)
mux.HandleFunc("/api/metrics/storage", storageMetricsHandler)
mux.HandleFunc("/api/metrics/gpu", gpuMetricsHandler)
mux.HandleFunc("/api/metrics/network", networkMetricsHandler)
mux.HandleFunc("/api/metrics/system", systemMetricsHandler)
mux.HandleFunc("/api/metrics/docker", dockerMetricsHandler)
mux.HandleFunc("/api/metrics/latency", latencyMetricsHandler)
srv := &http.Server{
Addr: fmt.Sprintf("%s:%s", host, port),
Handler: loggingMiddleware(corsMiddleware(mux)),
ReadHeaderTimeout: 5 * time.Second,
}
log.Printf("server starting on http://%s:%s", host, port)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("server error: %v", err)
}
}
func rootHandler(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, envelope{
"service": "萌芽监控面板 API",
"version": "1.0.0",
"endpoints": []map[string]string{
{
"path": "/",
"description": "API 信息和可用端点列表",
},
{
"path": "/api/health",
"description": "健康检查",
},
{
"path": "/api/metrics/cpu",
"description": "获取 CPU 监控数据",
},
{
"path": "/api/metrics/memory",
"description": "获取内存监控数据",
},
{
"path": "/api/metrics/storage",
"description": "获取存储监控数据",
},
{
"path": "/api/metrics/gpu",
"description": "获取 GPU 监控数据",
},
{
"path": "/api/metrics/network",
"description": "获取网络接口监控数据",
},
{
"path": "/api/metrics/system",
"description": "获取系统统计信息(进程、包、磁盘速度等)",
},
{
"path": "/api/metrics/docker",
"description": "获取 Docker 容器监控数据",
},
},
})
}
func healthHandler(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, envelope{
"status": "ok",
"timestamp": time.Now().UTC(),
})
}
// CPU监控数据
func cpuMetricsHandler(w http.ResponseWriter, r *http.Request) {
cpuModel := firstMatchInFile("/proc/cpuinfo", "model name")
cpuUsage, err := readCPUUsage()
if err != nil {
cpuUsage = 0
}
cpuTemp := readCPUTemperature()
perCoreUsage := readPerCoreUsage()
loads := readLoadAverages()
cores := runtime.NumCPU()
respondJSON(w, http.StatusOK, envelope{
"data": CPUMetrics{
Model: cpuModel,
Cores: cores,
UsagePercent: round(cpuUsage, 2),
LoadAverages: loads,
Temperature: cpuTemp,
PerCoreUsage: perCoreUsage,
},
})
}
// 内存监控数据
func memoryMetricsHandler(w http.ResponseWriter, r *http.Request) {
mem, err := readMemory()
if err != nil {
respondJSON(w, http.StatusInternalServerError, envelope{
"error": "failed to read memory",
})
return
}
respondJSON(w, http.StatusOK, envelope{
"data": mem,
})
}
// 存储监控数据
func storageMetricsHandler(w http.ResponseWriter, r *http.Request) {
storage, err := readAllStorage()
if err != nil {
respondJSON(w, http.StatusInternalServerError, envelope{
"error": "failed to read storage",
})
return
}
respondJSON(w, http.StatusOK, envelope{
"data": storage,
})
}
// GPU监控数据
func gpuMetricsHandler(w http.ResponseWriter, r *http.Request) {
gpu := readGPU()
respondJSON(w, http.StatusOK, envelope{
"data": gpu,
})
}
// 网络监控数据
func networkMetricsHandler(w http.ResponseWriter, r *http.Request) {
network := readNetworkInterfaces()
respondJSON(w, http.StatusOK, envelope{
"data": network,
})
}
// 系统统计信息
func systemMetricsHandler(w http.ResponseWriter, r *http.Request) {
stats := readSystemStats()
// 读取系统基本信息
hostname, _ := os.Hostname()
osInfo := readOSInfo()
uptime := readUptime()
// 计算总网络速度(汇总所有接口)
networkInterfaces := readNetworkInterfaces()
var totalRxSpeed, totalTxSpeed float64
for _, iface := range networkInterfaces {
totalRxSpeed += iface.RxSpeed // bytes/s
totalTxSpeed += iface.TxSpeed // bytes/s
}
// 转换为 MB/s
stats.NetworkRxSpeed = round(totalRxSpeed/1024/1024, 2)
stats.NetworkTxSpeed = round(totalTxSpeed/1024/1024, 2)
respondJSON(w, http.StatusOK, envelope{
"data": map[string]interface{}{
"hostname": hostname,
"os": osInfo,
"uptimeSeconds": uptime,
"processCount": stats.ProcessCount,
"packageCount": stats.PackageCount,
"packageManager": stats.PackageManager,
"temperature": stats.Temperature,
"diskReadSpeed": stats.DiskReadSpeed,
"diskWriteSpeed": stats.DiskWriteSpeed,
"networkRxSpeed": stats.NetworkRxSpeed,
"networkTxSpeed": stats.NetworkTxSpeed,
"topProcesses": stats.TopProcesses,
"systemLogs": stats.SystemLogs,
},
})
}
// Docker监控数据
func dockerMetricsHandler(w http.ResponseWriter, r *http.Request) {
docker := readDockerStats()
respondJSON(w, http.StatusOK, envelope{
"data": docker,
})
}
// 延迟监控数据
func latencyMetricsHandler(w http.ResponseWriter, r *http.Request) {
// 记录请求开始时间,用于计算客户端到服务器的延迟
startTime := time.Now()
// 读取外部网站延迟
externalLatencies := readExternalLatencies()
// 计算服务器处理时间(这个可以作为参考,实际客户端延迟由前端计算)
serverProcessTime := time.Since(startTime).Milliseconds()
respondJSON(w, http.StatusOK, envelope{
"data": map[string]interface{}{
"serverProcessTime": serverProcessTime, // 服务器处理时间(参考)
"external": externalLatencies,
},
})
}
func respondJSON(w http.ResponseWriter, status int, body envelope) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(body); err != nil {
log.Printf("write json error: %v", err)
}
}
func getenv(key, fallback string) string {
if v, ok := os.LookupEnv(key); ok && v != "" {
return v
}
return fallback
}
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start))
})
}
func corsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}

View File

@@ -0,0 +1,64 @@
package main
import (
"bufio"
"os"
"strconv"
"strings"
)
// readMemory 读取内存信息
func readMemory() (MemoryMetrics, error) {
totals := map[string]uint64{}
f, err := os.Open("/proc/meminfo")
if err != nil {
return MemoryMetrics{}, err
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
parts := strings.Split(line, ":")
if len(parts) < 2 {
continue
}
key := strings.TrimSpace(parts[0])
fields := strings.Fields(strings.TrimSpace(parts[1]))
if len(fields) == 0 {
continue
}
value, err := strconv.ParseUint(fields[0], 10, 64)
if err != nil {
continue
}
totals[key] = value * 1024 // kB to bytes
}
total := totals["MemTotal"]
// 优先使用 MemAvailableLinux 3.14+),如果没有则计算
var free uint64
if available, ok := totals["MemAvailable"]; ok && available > 0 {
free = available
} else {
// 回退到 MemFree + Buffers + Cached适用于较老的系统
free = totals["MemFree"]
if buffers, ok := totals["Buffers"]; ok {
free += buffers
}
if cached, ok := totals["Cached"]; ok {
free += cached
}
}
used := total - free
usedPercent := 0.0
if total > 0 {
usedPercent = (float64(used) / float64(total)) * 100
}
return MemoryMetrics{
TotalBytes: total,
UsedBytes: used,
FreeBytes: free,
UsedPercent: round(usedPercent, 2),
}, nil
}

View File

@@ -0,0 +1,110 @@
package main
import (
"os"
"os/exec"
"strconv"
"strings"
"time"
)
// readNetworkInterfaces 读取网络接口信息(包含瞬时流量速度) - 优化版:所有接口并行采样
func readNetworkInterfaces() []NetworkInterface {
interfaces := []NetworkInterface{}
// 读取网络接口列表
entries, err := os.ReadDir("/sys/class/net")
if err != nil {
return interfaces
}
// 收集要监控的接口名称
var validIfaces []string
for _, entry := range entries {
ifName := entry.Name()
if ifName == "lo" { // 跳过回环接口
continue
}
// 跳过 Docker 相关网络接口
if strings.HasPrefix(ifName, "docker") ||
strings.HasPrefix(ifName, "br-") ||
strings.HasPrefix(ifName, "veth") {
continue
}
validIfaces = append(validIfaces, ifName)
}
// 第一次批量读取所有接口的流量
firstReadings := make(map[string][2]uint64) // [rx, tx]
for _, ifName := range validIfaces {
rxPath := "/sys/class/net/" + ifName + "/statistics/rx_bytes"
txPath := "/sys/class/net/" + ifName + "/statistics/tx_bytes"
var rxBytes, txBytes uint64
if rxStr := readFirstLine(rxPath); rxStr != "" {
rxBytes, _ = strconv.ParseUint(strings.TrimSpace(rxStr), 10, 64)
}
if txStr := readFirstLine(txPath); txStr != "" {
txBytes, _ = strconv.ParseUint(strings.TrimSpace(txStr), 10, 64)
}
firstReadings[ifName] = [2]uint64{rxBytes, txBytes}
}
// 等待500ms所有接口一起等待而不是每个接口单独等待
time.Sleep(500 * time.Millisecond)
// 第二次批量读取并构建结果
for _, ifName := range validIfaces {
iface := NetworkInterface{
Name: ifName,
}
// 读取 MAC 地址
macPath := "/sys/class/net/" + ifName + "/address"
iface.MACAddress = strings.TrimSpace(readFirstLine(macPath))
// 读取 IP 地址 (使用 ip addr show)
cmd := exec.Command("ip", "addr", "show", ifName)
if out, err := cmd.Output(); err == nil {
lines := strings.Split(string(out), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "inet ") {
fields := strings.Fields(line)
if len(fields) >= 2 {
iface.IPAddress = strings.Split(fields[1], "/")[0]
break
}
}
}
}
// 读取第二次流量统计
rxPath := "/sys/class/net/" + ifName + "/statistics/rx_bytes"
txPath := "/sys/class/net/" + ifName + "/statistics/tx_bytes"
var rxBytes2, txBytes2 uint64
if rxStr := readFirstLine(rxPath); rxStr != "" {
rxBytes2, _ = strconv.ParseUint(strings.TrimSpace(rxStr), 10, 64)
}
if txStr := readFirstLine(txPath); txStr != "" {
txBytes2, _ = strconv.ParseUint(strings.TrimSpace(txStr), 10, 64)
}
// 设置累计流量
iface.RxBytes = rxBytes2
iface.TxBytes = txBytes2
// 计算瞬时速度 (bytes/s乘以2因为采样间隔是0.5秒)
if first, ok := firstReadings[ifName]; ok {
iface.RxSpeed = float64(rxBytes2-first[0]) * 2
iface.TxSpeed = float64(txBytes2-first[1]) * 2
}
interfaces = append(interfaces, iface)
}
return interfaces
}

View File

@@ -0,0 +1,390 @@
package main
import (
"bufio"
"context"
"os"
"os/exec"
"strconv"
"strings"
"time"
)
// readSystemStats 读取系统统计信息
func readSystemStats() SystemStats {
stats := SystemStats{}
// 读取进程数量
stats.ProcessCount = countProcesses()
// 读取已安装包数量和包管理器类型
stats.PackageCount, stats.PackageManager = countPackages()
// 读取系统温度
stats.Temperature = readSystemTemperature()
// 读取磁盘读写速度
stats.DiskReadSpeed, stats.DiskWriteSpeed = readDiskSpeed()
// 读取 Top 5 进程
stats.TopProcesses = readTopProcesses()
// 读取系统日志
stats.SystemLogs = readSystemLogs(10)
return stats
}
func countProcesses() int {
entries, err := os.ReadDir("/proc")
if err != nil {
return 0
}
count := 0
for _, entry := range entries {
if entry.IsDir() {
// 进程目录是数字命名的
if _, err := strconv.Atoi(entry.Name()); err == nil {
count++
}
}
}
return count
}
func countPackages() (int, string) {
// 尝试不同的包管理器
// dpkg (Debian/Ubuntu)
if _, err := exec.LookPath("dpkg"); err == nil {
cmd := exec.Command("dpkg", "-l")
out, err := cmd.Output()
if err == nil {
lines := strings.Split(string(out), "\n")
count := 0
for _, line := range lines {
if strings.HasPrefix(line, "ii ") {
count++
}
}
return count, "dpkg (apt)"
}
}
// rpm (RedHat/CentOS/Fedora)
if _, err := exec.LookPath("rpm"); err == nil {
cmd := exec.Command("rpm", "-qa")
out, err := cmd.Output()
if err == nil {
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
return len(lines), "rpm (yum/dnf)"
}
}
// pacman (Arch Linux)
if _, err := exec.LookPath("pacman"); err == nil {
cmd := exec.Command("pacman", "-Q")
out, err := cmd.Output()
if err == nil {
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
return len(lines), "pacman"
}
}
return 0, "unknown"
}
func readSystemTemperature() float64 {
var cpuTemp float64 = 0
var fallbackTemp float64 = 0
// 1. 优先读取 thermal_zone (通常是 CPU 温度)
thermalDir := "/sys/class/thermal"
entries, err := os.ReadDir(thermalDir)
if err == nil {
for _, entry := range entries {
if !strings.HasPrefix(entry.Name(), "thermal_zone") {
continue
}
tempPath := thermalDir + "/" + entry.Name() + "/temp"
if temp := readTempFromFile(tempPath); temp > 0 && temp > 20 && temp < 120 {
// thermal_zone0 通常是 CPU
if entry.Name() == "thermal_zone0" {
cpuTemp = temp
break
} else if fallbackTemp == 0 {
fallbackTemp = temp
}
}
}
}
// 2. 扫描所有 hwmon 设备,查找 CPU 温度
hwmonDir := "/sys/class/hwmon"
entries, err = os.ReadDir(hwmonDir)
if err == nil {
for _, entry := range entries {
hwmonPath := hwmonDir + "/" + entry.Name()
// 读取 name 文件,检查是否是 CPU 相关
namePath := hwmonPath + "/name"
name := strings.ToLower(strings.TrimSpace(readFirstLine(namePath)))
// 检查是否是 CPU 温度传感器
isCPU := strings.Contains(name, "cpu") ||
strings.Contains(name, "core") ||
strings.Contains(name, "k10temp") ||
strings.Contains(name, "coretemp") ||
strings.Contains(name, "zenpower")
// 尝试读取 temp1_input (通常是 CPU)
temp1Path := hwmonPath + "/temp1_input"
if temp := readTempFromFile(temp1Path); temp > 0 && temp > 20 && temp < 120 {
if isCPU {
cpuTemp = temp
break
} else if fallbackTemp == 0 {
fallbackTemp = temp
}
}
// 也尝试 temp2_input
temp2Path := hwmonPath + "/temp2_input"
if temp := readTempFromFile(temp2Path); temp > 0 && temp > 20 && temp < 120 {
if isCPU && cpuTemp == 0 {
cpuTemp = temp
} else if fallbackTemp == 0 {
fallbackTemp = temp
}
}
}
}
// 优先返回 CPU 温度,如果没有则返回其他温度
if cpuTemp > 0 {
return cpuTemp
}
return fallbackTemp
}
// readDiskSpeed 读取磁盘瞬时读写速度 (MB/s)
func readDiskSpeed() (float64, float64) {
// 第一次读取
readSectors1, writeSectors1 := getDiskSectors()
if readSectors1 == 0 && writeSectors1 == 0 {
return 0, 0
}
// 等待1秒
time.Sleep(1 * time.Second)
// 第二次读取
readSectors2, writeSectors2 := getDiskSectors()
// 计算差值(扇区数)
readDiff := readSectors2 - readSectors1
writeDiff := writeSectors2 - writeSectors1
// 扇区大小通常是 512 字节,转换为 MB/s
readSpeed := float64(readDiff) * 512 / 1024 / 1024
writeSpeed := float64(writeDiff) * 512 / 1024 / 1024
return round(readSpeed, 2), round(writeSpeed, 2)
}
func getDiskSectors() (uint64, uint64) {
f, err := os.Open("/proc/diskstats")
if err != nil {
return 0, 0
}
defer f.Close()
scanner := bufio.NewScanner(f)
var maxRead uint64 = 0
var mainDevice string
// 第一次遍历:找到读写量最大的主磁盘(通常是系统盘)
for scanner.Scan() {
line := scanner.Text()
fields := strings.Fields(line)
if len(fields) < 14 {
continue
}
deviceName := fields[2]
// 跳过分区(分区名通常包含数字,如 sda1, vda1, nvme0n1p1
if strings.ContainsAny(deviceName, "0123456789") &&
!strings.HasPrefix(deviceName, "nvme") &&
!strings.HasPrefix(deviceName, "loop") {
continue
}
// 跳过虚拟设备
if strings.HasPrefix(deviceName, "loop") ||
strings.HasPrefix(deviceName, "ram") ||
strings.HasPrefix(deviceName, "zram") {
continue
}
readSectors, _ := strconv.ParseUint(fields[5], 10, 64)
// 选择读写量最大的作为主磁盘
if readSectors > maxRead {
maxRead = readSectors
mainDevice = deviceName
}
}
// 第二次遍历:读取主磁盘的数据
f.Close()
f, err = os.Open("/proc/diskstats")
if err != nil {
return 0, 0
}
scanner = bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
fields := strings.Fields(line)
if len(fields) < 14 {
continue
}
if fields[2] == mainDevice {
readSectors, _ := strconv.ParseUint(fields[5], 10, 64)
writeSectors, _ := strconv.ParseUint(fields[9], 10, 64)
return readSectors, writeSectors
}
}
// 如果没找到,尝试常见的设备名(向后兼容)
f.Close()
f, err = os.Open("/proc/diskstats")
if err != nil {
return 0, 0
}
scanner = bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
fields := strings.Fields(line)
if len(fields) < 14 {
continue
}
deviceName := fields[2]
if deviceName == "sda" || deviceName == "vda" || deviceName == "nvme0n1" {
readSectors, _ := strconv.ParseUint(fields[5], 10, 64)
writeSectors, _ := strconv.ParseUint(fields[9], 10, 64)
return readSectors, writeSectors
}
}
return 0, 0
}
// readTopProcesses 读取 Top 5 进程 (按 CPU 使用率)
func readTopProcesses() []ProcessInfo {
processes := []ProcessInfo{}
// 读取系统总内存
memInfo, _ := readMemory()
totalMemGB := float64(memInfo.TotalBytes) / 1024 / 1024 / 1024
// 使用 ps 命令获取进程信息,添加超时控制
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "ps", "aux", "--sort=-%cpu", "--no-headers")
out, err := cmd.Output()
if err != nil {
return processes
}
lines := strings.Split(string(out), "\n")
count := 0
for _, line := range lines {
if count >= 5 { // 只取前5个
break
}
line = strings.TrimSpace(line)
if line == "" {
continue
}
fields := strings.Fields(line)
if len(fields) < 11 {
continue
}
pid, _ := strconv.Atoi(fields[1])
cpu, _ := strconv.ParseFloat(fields[2], 64)
mem, _ := strconv.ParseFloat(fields[3], 64)
// 计算内存MB数
memoryMB := (mem / 100) * totalMemGB * 1024
// 命令可能包含空格从第11个字段开始拼接
command := strings.Join(fields[10:], " ")
if len(command) > 50 {
command = command[:50] + "..."
}
processes = append(processes, ProcessInfo{
PID: pid,
Name: fields[10],
CPU: round(cpu, 1),
Memory: round(mem, 1),
MemoryMB: round(memoryMB, 1),
Command: command,
})
count++
}
return processes
}
// readSystemLogs 读取系统最新日志
func readSystemLogs(count int) []string {
logs := []string{}
// 尝试使用 journalctl 读取系统日志
if _, err := exec.LookPath("journalctl"); err == nil {
cmd := exec.Command("journalctl", "-n", strconv.Itoa(count), "--no-pager", "-o", "short")
out, err := cmd.Output()
if err == nil {
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
for _, line := range lines {
if line != "" {
logs = append(logs, line)
}
}
return logs
}
}
// 如果 journalctl 不可用,尝试读取 /var/log/syslog 或 /var/log/messages
logFiles := []string{"/var/log/syslog", "/var/log/messages"}
for _, logFile := range logFiles {
f, err := os.Open(logFile)
if err != nil {
continue
}
defer f.Close()
// 读取最后几行
var lines []string
scanner := bufio.NewScanner(f)
for scanner.Scan() {
lines = append(lines, scanner.Text())
}
// 取最后count行
start := len(lines) - count
if start < 0 {
start = 0
}
logs = lines[start:]
break
}
return logs
}

View File

@@ -0,0 +1,113 @@
//go:build linux
// +build linux
package main
import (
"bufio"
"os"
"strings"
"syscall"
)
func readStorage() ([]StorageMetrics, error) {
// For simplicity, report root mount. Can be extended to iterate mounts.
var stat syscall.Statfs_t
if err := syscall.Statfs("/", &stat); err != nil {
return nil, err
}
total := stat.Blocks * uint64(stat.Bsize)
free := stat.Bfree * uint64(stat.Bsize)
used := total - free
usedPercent := 0.0
if total > 0 {
usedPercent = (float64(used) / float64(total)) * 100
}
return []StorageMetrics{{
Mount: "/",
TotalBytes: total,
UsedBytes: used,
FreeBytes: free,
UsedPercent: round(usedPercent, 2),
}}, nil
}
// readAllStorage 读取所有挂载的存储设备
func readAllStorage() ([]StorageMetrics, error) {
storages := []StorageMetrics{}
// 读取 /proc/mounts 获取所有挂载点
f, err := os.Open("/proc/mounts")
if err != nil {
return readStorage() // 降级到只读根目录
}
defer f.Close()
scanner := bufio.NewScanner(f)
seen := make(map[string]bool)
for scanner.Scan() {
line := scanner.Text()
fields := strings.Fields(line)
if len(fields) < 3 {
continue
}
device := fields[0]
mountPoint := fields[1]
fsType := fields[2]
// 跳过虚拟文件系统
if strings.HasPrefix(device, "/dev/") == false {
continue
}
// 跳过特殊文件系统类型
skipTypes := map[string]bool{
"tmpfs": true, "devtmpfs": true, "squashfs": true,
"overlay": true, "aufs": true, "proc": true,
"sysfs": true, "devpts": true, "cgroup": true,
}
if skipTypes[fsType] {
continue
}
// 避免重复挂载点
if seen[mountPoint] {
continue
}
seen[mountPoint] = true
// 获取该挂载点的统计信息
var stat syscall.Statfs_t
if err := syscall.Statfs(mountPoint, &stat); err != nil {
continue
}
total := stat.Blocks * uint64(stat.Bsize)
free := stat.Bfree * uint64(stat.Bsize)
used := total - free
usedPercent := 0.0
if total > 0 {
usedPercent = (float64(used) / float64(total)) * 100
}
// 只添加有容量的存储
if total > 0 {
storages = append(storages, StorageMetrics{
Mount: mountPoint,
TotalBytes: total,
UsedBytes: used,
FreeBytes: free,
UsedPercent: round(usedPercent, 2),
})
}
}
// 如果没有找到任何存储,至少返回根目录
if len(storages) == 0 {
return readStorage()
}
return storages, nil
}

View File

@@ -0,0 +1,14 @@
//go:build !linux
// +build !linux
package main
import "errors"
func readStorage() ([]StorageMetrics, error) {
return nil, errors.New("storage monitoring is only supported on Linux")
}
func readAllStorage() ([]StorageMetrics, error) {
return nil, errors.New("storage monitoring is only supported on Linux")
}

View File

@@ -0,0 +1,114 @@
package main
import "time"
// CPU相关信息
type CPUMetrics struct {
Model string `json:"model"`
Cores int `json:"cores"`
UsagePercent float64 `json:"usagePercent"`
LoadAverages []float64 `json:"loadAverages,omitempty"`
Temperature float64 `json:"temperature,omitempty"` // CPU温度(摄氏度)
PerCoreUsage []CoreUsage `json:"perCoreUsage,omitempty"` // 每个核心使用率
}
type CoreUsage struct {
Core int `json:"core"`
Percent float64 `json:"percent"`
}
// 内存相关信息
type MemoryMetrics struct {
TotalBytes uint64 `json:"totalBytes"`
UsedBytes uint64 `json:"usedBytes"`
FreeBytes uint64 `json:"freeBytes"`
UsedPercent float64 `json:"usedPercent"`
}
// 储存相关信息
type StorageMetrics struct {
Mount string `json:"mount"`
TotalBytes uint64 `json:"totalBytes"`
UsedBytes uint64 `json:"usedBytes"`
FreeBytes uint64 `json:"freeBytes"`
UsedPercent float64 `json:"usedPercent"`
}
// GPU相关信息
type GPUMetrics struct {
Name string `json:"name"`
MemoryTotalMB int64 `json:"memoryTotalMB"`
MemoryUsedMB int64 `json:"memoryUsedMB"`
UtilizationPercent float64 `json:"utilizationPercent"`
Temperature float64 `json:"temperature,omitempty"` // GPU温度(摄氏度)
Status string `json:"status"`
}
// 网络相关信息
type NetworkInterface struct {
Name string `json:"name"`
IPAddress string `json:"ipAddress"`
MACAddress string `json:"macAddress"`
RxBytes uint64 `json:"rxBytes"` // 接收字节数
TxBytes uint64 `json:"txBytes"` // 发送字节数
RxSpeed float64 `json:"rxSpeed"` // 接收速度 bytes/s
TxSpeed float64 `json:"txSpeed"` // 发送速度 bytes/s
}
// 系统状态相关信息
type SystemStats struct {
ProcessCount int `json:"processCount"` // 进程数量
PackageCount int `json:"packageCount"` // 已安装软件包数量
PackageManager string `json:"packageManager"` // 包管理器类型
Temperature float64 `json:"temperature,omitempty"` // 系统温度(摄氏度)
DiskReadSpeed float64 `json:"diskReadSpeed"` // 磁盘读取速度 MB/s
DiskWriteSpeed float64 `json:"diskWriteSpeed"` // 磁盘写入速度 MB/s
NetworkRxSpeed float64 `json:"networkRxSpeed"` // 网络下载速度 MB/s
NetworkTxSpeed float64 `json:"networkTxSpeed"` // 网络上传速度 MB/s
TopProcesses []ProcessInfo `json:"topProcesses"` // Top 5 进程
DockerStats DockerStats `json:"dockerStats"` // Docker 统计信息
SystemLogs []string `json:"systemLogs"` // 系统最新日志
}
// 服务器进程相关信息
type ProcessInfo struct {
PID int `json:"pid"`
Name string `json:"name"`
CPU float64 `json:"cpu"`
Memory float64 `json:"memory"`
MemoryMB float64 `json:"memoryMB"` // 内存占用MB
Command string `json:"command"`
}
// Docker相关信息简化版
type DockerStats struct {
Available bool `json:"available"` // Docker是否可用
Version string `json:"version"` // Docker版本
Running int `json:"running"` // 运行中的容器数
Stopped int `json:"stopped"` // 停止的容器数
ImageCount int `json:"imageCount"` // 镜像数量
RunningNames []string `json:"runningNames"` // 运行中的容器名列表
StoppedNames []string `json:"stoppedNames"` // 停止的容器名列表
ImageNames []string `json:"imageNames"` // 镜像名列表
}
// 操作系统相关信息
type OSInfo struct {
Kernel string `json:"kernel"`
Distro string `json:"distro"`
Architecture string `json:"architecture"`
}
// 所有注册指标
type Metrics struct {
Hostname string `json:"hostname"`
Timestamp time.Time `json:"timestamp"`
CPU CPUMetrics `json:"cpu"`
Memory MemoryMetrics `json:"memory"`
Storage []StorageMetrics `json:"storage"`
GPU []GPUMetrics `json:"gpu"`
Network []NetworkInterface `json:"network"`
System SystemStats `json:"system"`
OS OSInfo `json:"os"`
UptimeSeconds float64 `json:"uptimeSeconds"`
}

View File

@@ -0,0 +1,66 @@
package main
import (
"bufio"
"math"
"os"
"strconv"
"strings"
)
// round 将浮点数四舍五入到指定小数位
func round(v float64, places int) float64 {
factor := math.Pow(10, float64(places))
return math.Round(v*factor) / factor
}
// readFirstLine 读取文件的第一行
func readFirstLine(path string) string {
f, err := os.Open(path)
if err != nil {
return ""
}
defer f.Close()
scanner := bufio.NewScanner(f)
if scanner.Scan() {
return scanner.Text()
}
return ""
}
// firstMatchInFile 在文件中查找第一个匹配指定前缀的行,并返回冒号后的内容
func firstMatchInFile(path, prefix string) string {
f, err := os.Open(path)
if err != nil {
return ""
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(strings.TrimSpace(line), prefix) {
parts := strings.SplitN(line, ":", 2)
if len(parts) == 2 {
return strings.TrimSpace(parts[1])
}
}
}
return ""
}
// readTempFromFile 从文件读取温度值
func readTempFromFile(path string) float64 {
content := readFirstLine(path)
if content == "" {
return 0
}
val, err := strconv.ParseFloat(strings.TrimSpace(content), 64)
if err != nil {
return 0
}
// 温度通常以毫度为单位
if val > 1000 {
return round(val/1000, 1)
}
return round(val, 1)
}

View File

@@ -0,0 +1,110 @@
# 萌芽监控面板 - 前端
## 概述
服务器监控面板前端应用,使用 React + TypeScript + Vite 构建。
## 功能特性
- 🖥️ 多服务器监控支持
- 📊 实时数据展示CPU、内存、存储、GPU
- 🔄 自动刷新2秒轮询
- 📱 响应式设计
- 🎨 白色柔和风格界面
- 💾 本地存储配置
## 开发
### 安装依赖
```bash
npm install
```
### 启动开发服务器
```bash
npm run dev
```
访问 http://localhost:2929
### 构建生产版本
```bash
npm run build
```
生产文件将输出到 `dist` 目录
### 预览生产版本
```bash
npm run preview
```
## 使用说明
### 添加服务器
1. 点击右上角"添加服务器"按钮
2. 输入服务器名称生产服务器1
3. 输入服务器地址http://192.168.1.100:9292
4. 点击"添加"按钮
### 查看详情
点击服务器卡片上的"查看详情"按钮,可以查看完整的系统信息:
- 系统信息(主机名、操作系统、内核、架构、运行时间)
- CPU 详细信息(型号、核心数、使用率、负载)
- 内存详细信息(总容量、已使用、可用、使用率)
- 存储详细信息(挂载点、容量、使用情况)
- GPU 信息(名称、显存、利用率)
### 移除服务器
点击服务器卡片右上角的"×"按钮,确认后即可移除。
## 项目结构
```
src/
├── api/ # API 调用
│ └── monitor.ts # 监控 API
├── components/ # React 组件
│ ├── ServerCard/ # 服务器卡片组件
│ └── ServerDetail/ # 服务器详情弹窗
├── hooks/ # 自定义 Hooks
│ └── useServerMonitor.ts # 服务器监控 Hook
├── types/ # TypeScript 类型定义
│ └── index.ts
├── utils/ # 工具函数
│ ├── format.ts # 格式化函数
│ └── storage.ts # 本地存储
├── App.tsx # 主应用组件
├── App.css # 主应用样式
├── main.tsx # 应用入口
└── index.css # 全局样式
```
## 配置说明
### 修改端口
编辑 `vite.config.ts` 文件中的 `server.port` 配置:
```typescript
server: {
port: 2929, // 修改为你想要的端口
}
```
### 修改轮询间隔
编辑 `src/App.tsx` 文件中的 `useServerMonitor` 第二个参数:
```typescript
const statuses = useServerMonitor(servers, 2000); // 2000ms = 2秒
```
## 部署
### 静态文件部署
1. 构建项目:`npm run build`
2.`dist` 目录部署到 Web 服务器(如 Nginx、Apache
3. 或使用静态托管服务(如 Vercel、Netlify
### 桌面应用
可以使用 Electron 或 Tauri 将前端打包成桌面应用:
- Electron: https://www.electronjs.org/
- Tauri: https://tauri.app/
## 注意事项
- 确保后端服务器已启动并可访问
- 后端服务器需要配置 CORS 允许跨域访问
- 服务器地址需要包含协议http:// 或 https://
- 本地存储的服务器配置保存在浏览器 localStorage 中

View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>萌芽监控面板</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3224
mengyamonitor-frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,30 @@
{
"name": "mengyamonitor-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 MiB

View File

@@ -0,0 +1,178 @@
.app {
width: 100%;
max-width: 100%;
margin: 0 auto;
padding: 1rem;
min-height: 100vh;
}
.app-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
padding: 0.5rem 1rem;
background: var(--surface-color);
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
border: 1px solid var(--border-color);
}
.app-header h1 {
margin: 0;
font-size: 0.75rem;
color: var(--text-light);
display: flex;
align-items: center;
gap: 0.25rem;
font-weight: 400;
}
.app-logo {
width: 16px;
height: 16px;
border-radius: 4px;
display: inline-block;
}
.btn-add {
background: var(--primary-color);
color: white;
border: none;
padding: 0.5rem;
width: 32px;
height: 32px;
border-radius: var(--radius-md);
font-size: 1.25rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: var(--shadow-sm);
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
}
.btn-add:hover {
background: var(--primary-hover);
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
.add-form {
background: var(--surface-color);
padding: 1.5rem;
border-radius: var(--radius-md);
margin-bottom: 1rem;
box-shadow: var(--shadow-sm);
display: flex;
gap: 1rem;
flex-wrap: wrap;
border: 1px solid var(--border-color);
animation: slideDown 0.3s ease-out;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.add-form input {
flex: 1;
min-width: 250px;
padding: 0.75rem 1rem;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
font-size: 0.875rem;
transition: all 0.2s;
background: var(--bg-color);
color: var(--text-main);
}
.add-form input:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(134, 239, 172, 0.1);
background: var(--surface-color);
}
.btn-submit {
background: var(--primary-color);
color: white;
border: none;
padding: 0.75rem 2rem;
border-radius: var(--radius-md);
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.btn-submit:hover {
background: var(--primary-hover);
}
.server-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 1.5rem;
}
.empty-state {
grid-column: 1 / -1;
text-align: center;
padding: 4rem 2rem;
background: var(--surface-color);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
border: 1px dashed var(--border-color);
}
.empty-state p {
margin: 0.5rem 0;
font-size: 1.125rem;
color: var(--text-secondary);
}
.empty-state .hint {
color: var(--text-light);
font-size: 0.875rem;
}
@media (max-width: 768px) {
.app {
padding: 1rem;
}
.app-header {
flex-direction: column;
gap: 1rem;
align-items: stretch;
text-align: center;
padding: 1rem;
}
.app-header h1 {
justify-content: center;
}
.server-grid {
grid-template-columns: 1fr;
}
.add-form {
flex-direction: column;
padding: 1.5rem;
}
.add-form input {
min-width: 100%;
}
}

View File

@@ -0,0 +1,130 @@
import { useState, useEffect } from 'react';
import type { ServerConfig } from './types';
import { loadServers, saveServers, removeServer } from './utils/storage';
import { useServerMonitor } from './hooks/useServerMonitor';
import { ServerCard } from './components/ServerCard/ServerCard';
import { ServerDetail } from './components/ServerDetail/ServerDetail';
import './App.css';
function App() {
const [servers, setServers] = useState<ServerConfig[]>([]);
const [showAddForm, setShowAddForm] = useState(false);
const [selectedServerId, setSelectedServerId] = useState<string | null>(null);
const [newServerForm, setNewServerForm] = useState({ name: '', url: '' });
const statuses = useServerMonitor(servers, 2000);
useEffect(() => {
const loaded = loadServers();
setServers(loaded);
}, []);
const handleAddServer = () => {
if (!newServerForm.name || !newServerForm.url) {
alert('请填写服务器名称和地址');
return;
}
const newServer: ServerConfig = {
id: Date.now().toString(),
name: newServerForm.name,
url: newServerForm.url,
enabled: true,
};
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 selectedStatus = selectedServerId ? statuses[selectedServerId] : null;
const selectedServer = servers.find(s => s.id === selectedServerId);
return (
<div className="app">
<header className="app-header">
<h1>
<img className="app-logo" src="/logo.png" alt="萌芽监控面板" />
</h1>
<button className="btn-add" onClick={() => setShowAddForm(!showAddForm)} title={showAddForm ? '取消' : '添加服务器'}>
{showAddForm ? '×' : '+'}
</button>
</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 })}
/>
<button className="btn-submit" onClick={handleAddServer}>
</button>
</div>
)}
<main className="server-grid">
{servers.length === 0 ? (
<div className="empty-state">
<p></p>
<p className="hint">"添加服务器"使</p>
</div>
) : (
servers.map((server) => {
const status = statuses[server.id];
// Calculate storage usage (max of all mounts)
const storageUsage = status?.metrics?.storage?.reduce((max, s) => Math.max(max, s.usedPercent), 0) || 0;
return (
<ServerCard
key={server.id}
server={server}
online={status?.online || false}
metrics={status?.metrics}
cpuUsage={status?.metrics?.cpu.usagePercent || 0}
memoryUsage={status?.metrics?.memory.usedPercent || 0}
storageUsage={storageUsage} // Pass max storage usage
uptime={status?.metrics?.uptimeSeconds}
onDetail={handleShowDetail}
onRemove={handleRemoveServer}
/>
);
})
)}
</main>
{selectedStatus?.metrics && selectedServer && (
<ServerDetail
metrics={selectedStatus.metrics}
serverName={selectedServer.name}
onClose={() => setSelectedServerId(null)}
/>
)}
</div>
);
}
export default App;

View File

@@ -0,0 +1,101 @@
import type { ServerMetrics } from '../types';
export const fetchServerMetrics = async (serverUrl: string): Promise<ServerMetrics> => {
// 测量客户端到服务器的延迟(使用健康检查端点)
const clientToServerLatency = await measureLatency(`${serverUrl}/api/health`);
// 并行请求各个端点
const endpoints = [
'/api/metrics/cpu',
'/api/metrics/memory',
'/api/metrics/storage',
'/api/metrics/gpu',
'/api/metrics/network',
'/api/metrics/system',
'/api/metrics/docker',
'/api/metrics/latency',
];
const results = await Promise.all(
endpoints.map(endpoint =>
fetch(`${serverUrl}${endpoint}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
})
.then(res => {
if (!res.ok) throw new Error(`Failed to fetch ${endpoint}`);
return res.json();
})
.catch(err => {
console.error(`Error fetching ${endpoint}:`, err);
return null;
})
)
);
// 合并所有结果
const [cpuRes, memRes, storageRes, gpuRes, networkRes, systemRes, dockerRes, latencyRes] = results;
const system = systemRes?.data || {};
const docker = dockerRes?.data || {};
const latency = latencyRes?.data || {};
// 将 docker 数据合并到 system.dockerStats
return {
hostname: system.hostname || 'Unknown',
timestamp: new Date().toISOString(),
cpu: cpuRes?.data || {},
memory: memRes?.data || {},
storage: storageRes?.data || [],
gpu: gpuRes?.data || [],
network: networkRes?.data || [],
system: {
...system,
dockerStats: docker
},
os: system.os || { kernel: '', distro: '', architecture: '' },
uptimeSeconds: system.uptimeSeconds || 0,
latency: {
clientToServer: clientToServerLatency,
external: latency.external || {},
},
};
};
// 测量延迟
async function measureLatency(url: string): Promise<number> {
try {
const startTime = performance.now();
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
const endTime = performance.now();
if (response.ok) {
return Math.round(endTime - startTime);
}
return -1; // 表示失败
} catch {
return -1; // 表示超时或失败
}
}
export const checkServerHealth = async (serverUrl: string): Promise<boolean> => {
try {
const url = `${serverUrl}/api/health`;
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
return response.ok;
} catch {
return false;
}
};

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,41 @@
.circular-progress {
position: relative;
display: inline-block;
}
.circular-progress svg {
transform: rotate(-90deg);
}
.progress-ring-bg {
fill: none;
stroke: #e2e8f0;
transition: stroke 0.3s ease;
}
.progress-ring-circle {
fill: none;
stroke-linecap: round;
transition: stroke-dashoffset 0.5s ease;
}
.progress-value {
font-weight: 700;
fill: var(--text-main);
transform: rotate(90deg);
transform-origin: center;
}
.progress-label {
fill: var(--text-secondary);
font-weight: 500;
transform: rotate(90deg);
transform-origin: center;
}
.progress-sublabel {
fill: var(--text-light);
font-weight: 400;
transform: rotate(90deg);
transform-origin: center;
}

View File

@@ -0,0 +1,83 @@
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={valueFontSize}
>
{subLabel}
</text>
) : (
<text
x={center}
y={center}
className="progress-value"
textAnchor="middle"
dy="0.3em"
fontSize={valueFontSize}
>
{Math.round(value)}%
</text>
)}
<text
x={center}
y={center + size * 0.22}
className="progress-label"
textAnchor="middle"
fontSize={labelFontSize}
>
{label}
</text>
</svg>
</div>
);
};

View File

@@ -0,0 +1,381 @@
.server-card {
background: var(--surface-color);
border-radius: var(--radius-lg);
padding: 1.5rem;
box-shadow: var(--shadow-sm);
transition: all 0.3s ease;
border: 1px solid var(--border-color);
display: flex;
flex-direction: column;
height: 100%;
}
.server-card:hover {
box-shadow: var(--shadow-lg);
transform: translateY(-4px);
border-color: var(--primary-color);
}
.server-card.online {
border-top: 4px solid var(--success-color);
}
.server-card.offline {
border-top: 4px solid var(--text-light);
opacity: 0.9;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0.5rem;
}
.server-info {
display: flex;
align-items: center;
gap: 0.75rem;
}
.status-indicator {
width: 10px;
height: 10px;
border-radius: 50%;
position: relative;
}
.status-online {
background: var(--success-color);
box-shadow: 0 0 0 4px rgba(134, 239, 172, 0.2);
}
.status-online::after {
content: '';
position: absolute;
top: -4px;
left: -4px;
right: -4px;
bottom: -4px;
border-radius: 50%;
animation: pulse 2s infinite;
border: 1px solid var(--success-color);
}
@keyframes pulse {
0% { transform: scale(1); opacity: 0.5; }
70% { transform: scale(1.5); opacity: 0; }
100% { transform: scale(1); opacity: 0; }
}
.status-offline {
background: var(--text-light);
}
.server-name-group {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.server-name {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: var(--text-main);
}
.server-hostname {
font-size: 0.75rem;
color: var(--text-light);
font-weight: 400;
}
.btn-remove {
background: transparent;
border: none;
font-size: 1.5rem;
color: var(--text-light);
cursor: pointer;
padding: 0;
width: 24px;
height: 24px;
line-height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.btn-remove:hover {
background: var(--danger-color);
color: white;
}
/* Main Metrics */
.card-main-metrics {
flex: 1;
display: flex;
flex-direction: column;
gap: 1rem;
}
.metrics-grid-main {
display: flex;
justify-content: space-around;
align-items: center;
padding: 0.5rem 0;
}
.circular-progress-small {
display: flex;
align-items: center;
justify-content: center;
}
.circular-progress-small .progress-ring-bg {
fill: none;
stroke: #e2e8f0;
stroke-width: 6;
}
.circular-progress-small .progress-ring-circle {
fill: none;
stroke-width: 6;
stroke-linecap: round;
transition: stroke-dashoffset 0.5s ease;
transform: rotate(-90deg);
transform-origin: center;
}
.circular-progress-small .progress-value-small {
font-size: 14px;
font-weight: 600;
fill: var(--text-main);
}
.circular-progress-small .progress-label-small {
font-size: 10px;
fill: var(--text-secondary);
}
/* Info Grid */
.card-info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
padding: 0.75rem;
background: var(--bg-color);
border-radius: var(--radius-md);
}
.info-section {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.info-row {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.75rem;
writing-mode: horizontal-tb;
text-orientation: mixed;
}
.info-label {
color: var(--text-light);
font-weight: 500;
writing-mode: horizontal-tb;
text-orientation: mixed;
white-space: nowrap;
}
.info-value {
color: var(--text-main);
font-weight: 600;
font-family: monospace;
font-size: 0.7rem;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: right;
}
/* Performance Grid */
.card-performance-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
padding: 0.75rem;
background: var(--bg-color);
border-radius: var(--radius-md);
}
.performance-section {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.performance-header {
margin-bottom: 0.25rem;
}
.performance-label {
font-size: 0.75rem;
font-weight: 600;
color: var(--text-secondary);
}
.performance-row {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.performance-item {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.7rem;
}
.performance-item-label {
color: var(--text-light);
font-weight: 500;
}
.performance-item-value {
color: var(--primary-color);
font-weight: 600;
font-family: monospace;
text-align: right;
}
/* Footer Info */
.card-footer-info {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem;
background: var(--bg-color);
border-radius: var(--radius-md);
margin-top: 0.5rem;
}
.footer-row {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.75rem;
}
.footer-label {
color: var(--text-light);
font-weight: 500;
}
.footer-value {
color: var(--text-main);
font-weight: 600;
font-family: monospace;
}
/* Metrics List */
.metrics-list {
display: flex;
flex-direction: column;
gap: 1rem;
margin-bottom: 1.5rem;
}
.metric-item {
width: 100%;
}
.metric-header {
display: flex;
justify-content: space-between;
margin-bottom: 0.25rem;
font-size: 0.875rem;
}
.metric-label {
color: var(--text-secondary);
font-weight: 500;
}
.metric-value {
font-weight: 600;
color: var(--text-main);
}
.progress-bar-bg {
width: 100%;
height: 8px;
background: var(--bg-color);
border-radius: 4px;
overflow: hidden;
}
.progress-bar-fill {
height: 100%;
border-radius: 4px;
transition: width 0.5s ease;
}
.uptime-info {
display: flex;
justify-content: space-between;
font-size: 0.875rem;
padding-top: 0.5rem;
border-top: 1px solid var(--border-color);
}
.uptime-label {
color: var(--text-light);
}
.uptime-value {
color: var(--text-secondary);
font-family: monospace;
}
.offline-state {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-color);
border-radius: var(--radius-md);
margin-bottom: 1.5rem;
color: var(--text-light);
}
.card-footer {
margin-top: auto;
}
.btn-detail {
width: 100%;
background: var(--surface-hover);
border: 1px solid var(--border-color);
padding: 0.75rem;
border-radius: var(--radius-md);
font-size: 0.875rem;
font-weight: 600;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s;
}
.btn-detail:hover {
background: var(--primary-color);
color: white;
border-color: var(--primary-color);
}

View File

@@ -0,0 +1,281 @@
import type { ServerConfig, ServerMetrics } from '../../types';
import { formatUptime, formatBytes } from '../../utils/format';
import './ServerCard.css';
const CircularProgress = ({ value, label, color, size = 60 }: { value: number; label: string; color: string; size?: number }) => {
const radius = size * 0.36;
const circumference = 2 * Math.PI * radius;
const offset = circumference - (value / 100) * circumference;
return (
<div className="circular-progress-small">
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
<circle
className="progress-ring-bg"
cx={size / 2}
cy={size / 2}
r={radius}
/>
<circle
className="progress-ring-circle"
stroke={color}
strokeDasharray={`${circumference} ${circumference}`}
strokeDashoffset={offset}
cx={size / 2}
cy={size / 2}
r={radius}
/>
<text x={size / 2} y={size / 2 - 8} className="progress-value-small" textAnchor="middle" dy="7px">
{Math.round(value)}%
</text>
<text x={size / 2} y={size / 2 + 12} className="progress-label-small" textAnchor="middle">
{label}
</text>
</svg>
</div>
);
};
const LoadProgress = ({ value, maxValue = 1.0, size = 80 }: { value: number; maxValue?: number; size?: number }) => {
const percentage = Math.min((value / maxValue) * 100, 100);
const radius = size * 0.36;
const circumference = 2 * Math.PI * radius;
const offset = circumference - (percentage / 100) * circumference;
const color = percentage > 80 ? '#f87171' : percentage > 50 ? '#fde047' : '#86efac';
return (
<div className="circular-progress-small">
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
<circle
className="progress-ring-bg"
cx={size / 2}
cy={size / 2}
r={radius}
/>
<circle
className="progress-ring-circle"
stroke={color}
strokeDasharray={`${circumference} ${circumference}`}
strokeDashoffset={offset}
cx={size / 2}
cy={size / 2}
r={radius}
/>
<text x={size / 2} y={size / 2 - 8} className="progress-value-small" textAnchor="middle" dy="7px">
{value.toFixed(2)}
</text>
<text x={size / 2} y={size / 2 + 12} className="progress-label-small" textAnchor="middle">
</text>
</svg>
</div>
);
};
export interface ServerCardProps {
server: ServerConfig;
online: boolean;
metrics?: ServerMetrics;
cpuUsage?: number;
memoryUsage?: number;
storageUsage?: number;
uptime?: number;
onDetail: (serverId: string) => void;
onRemove: (serverId: string) => void;
}
export const ServerCard = ({
server,
online,
metrics,
cpuUsage = 0,
memoryUsage = 0,
storageUsage = 0,
uptime,
onDetail,
onRemove,
}: ServerCardProps) => {
// 从 URL 提取 IP 地址
const extractIP = (url: string): string => {
try {
const urlObj = new URL(url);
return urlObj.hostname;
} catch {
return url;
}
};
// 获取主 IP 地址(从网络接口)
const getMainIP = (): string => {
if (metrics?.network && metrics.network.length > 0) {
const mainInterface = metrics.network.find(iface =>
iface.ipAddress && iface.ipAddress !== 'N/A' && !iface.ipAddress.startsWith('127.')
);
return mainInterface?.ipAddress || extractIP(server.url);
}
return extractIP(server.url);
};
const mainIP = getMainIP();
const loadAverage = metrics?.cpu?.loadAverages?.[0] || 0;
const cpuCores = metrics?.cpu?.cores || 1;
const temperature = metrics?.system?.temperature || metrics?.cpu?.temperature || 0;
const hostname = metrics?.hostname || 'Unknown';
const osDistro = metrics?.os?.distro || 'Unknown';
const architecture = metrics?.os?.architecture || 'Unknown';
const diskReadSpeed = metrics?.system?.diskReadSpeed || 0;
const diskWriteSpeed = metrics?.system?.diskWriteSpeed || 0;
const networkRxSpeed = metrics?.system?.networkRxSpeed || 0;
const networkTxSpeed = metrics?.system?.networkTxSpeed || 0;
const latency = metrics?.latency?.clientToServer || -1;
const processCount = metrics?.system?.processCount || 0;
const packageCount = metrics?.system?.packageCount || 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);
return (
<div className={`server-card ${online ? 'online' : 'offline'}`}>
<div className="card-header">
<div className="server-info">
<div className={`status-indicator ${online ? 'status-online' : 'status-offline'}`} />
<div className="server-name-group">
<h3 className="server-name">{server.name}</h3>
{online && hostname !== 'Unknown' && (
<span className="server-hostname">{hostname}</span>
)}
</div>
</div>
<button className="btn-remove" onClick={() => onRemove(server.id)} title="移除服务器">
×
</button>
</div>
{online && metrics ? (
<>
<div className="card-main-metrics">
<div className="metrics-grid-main">
{loadAverage > 0 && (
<LoadProgress value={loadAverage} maxValue={cpuCores} size={80} />
)}
<CircularProgress
value={cpuUsage}
label="CPU"
color={cpuUsage > 80 ? '#f87171' : '#86efac'}
size={80}
/>
<CircularProgress
value={memoryUsage}
label="内存"
color={memoryUsage > 80 ? '#fde047' : '#6ee7b7'}
size={80}
/>
<CircularProgress
value={storageUsage}
label="存储"
color={storageUsage > 90 ? '#f87171' : '#a7f3d0'}
size={80}
/>
</div>
<div className="card-info-grid">
<div className="info-section">
<div className="info-row">
<span className="info-label"></span>
<span className="info-value">{osDistro}</span>
</div>
<div className="info-row">
<span className="info-label"></span>
<span className="info-value">{architecture}</span>
</div>
<div className="info-row">
<span className="info-label">IP</span>
<span className="info-value">{mainIP}</span>
</div>
</div>
<div className="info-section">
<div className="info-row">
<span className="info-label"></span>
<span className="info-value">{temperature > 0 ? `${temperature.toFixed(1)}°C` : 'N/A'}</span>
</div>
<div className="info-row">
<span className="info-label"></span>
<span className="info-value">{processCount}</span>
</div>
<div className="info-row">
<span className="info-label"></span>
<span className="info-value">{packageCount}</span>
</div>
</div>
</div>
<div className="card-performance-grid">
<div className="performance-section">
<div className="performance-header">
<span className="performance-label"></span>
</div>
<div className="performance-row">
<span className="performance-item">
<span className="performance-item-label"></span>
<span className="performance-item-value">{diskReadSpeed > 0 ? formatBytes(diskReadSpeed * 1024 * 1024) + '/s' : '0 B/s'}</span>
</span>
<span className="performance-item">
<span className="performance-item-label"></span>
<span className="performance-item-value">{diskWriteSpeed > 0 ? formatBytes(diskWriteSpeed * 1024 * 1024) + '/s' : '0 B/s'}</span>
</span>
</div>
</div>
<div className="performance-section">
<div className="performance-header">
<span className="performance-label"></span>
</div>
<div className="performance-row">
<span className="performance-item">
<span className="performance-item-label"></span>
<span className="performance-item-value">{networkRxSpeed > 0 ? formatBytes(networkRxSpeed * 1024 * 1024) + '/s' : '0 B/s'}</span>
</span>
<span className="performance-item">
<span className="performance-item-label"></span>
<span className="performance-item-value">{networkTxSpeed > 0 ? formatBytes(networkTxSpeed * 1024 * 1024) + '/s' : '0 B/s'}</span>
</span>
</div>
</div>
</div>
<div className="card-footer-info">
<div className="footer-row">
<span className="footer-label"></span>
<span className="footer-value">{uptime !== undefined ? formatUptime(uptime) : 'N/A'}</span>
</div>
<div className="footer-row">
<span className="footer-label"></span>
<span className="footer-value" style={{ color: latencyColor }}>
{latency > 0 ? `${latency} ms` : latency === -1 ? '超时' : 'N/A'}
</span>
</div>
</div>
</div>
</>
) : (
<div className="offline-state">
<p></p>
</div>
)}
<div className="card-footer">
<button className="btn-detail" onClick={() => onDetail(server.id)}>
</button>
</div>
</div>
);
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,521 @@
import type { ServerMetrics } from '../../types';
import { formatBytes, formatUptime, formatPercent } from '../../utils/format';
import { CircularProgress } from '../Common/CircularProgress';
import './ServerDetail.css';
interface ServerDetailProps {
metrics: ServerMetrics;
serverName: string;
onClose: () => void;
}
export const ServerDetail = ({ metrics, serverName, onClose }: ServerDetailProps) => {
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2>{serverName} - </h2>
<button className="btn-close" onClick={onClose}>×</button>
</div>
<div className="modal-body">
<section className="detail-section">
<h3></h3>
<div className="system-info-container">
{/* 系统基本信息 */}
<div className="system-info-group">
<h4 className="info-group-title"></h4>
<div className="system-info-grid">
<div className="system-info-item">
<div className="system-info-content">
<span className="system-info-label"></span>
<span className="system-info-value">{metrics.hostname}</span>
</div>
</div>
<div className="system-info-item">
<div className="system-info-content">
<span className="system-info-label"></span>
<span className="system-info-value">{metrics.os.distro}</span>
</div>
</div>
<div className="system-info-item">
<div className="system-info-content">
<span className="system-info-label"></span>
<span className="system-info-value">{metrics.os.architecture}</span>
</div>
</div>
<div className="system-info-item system-info-item-full">
<div className="system-info-content">
<span className="system-info-label"></span>
<span className="system-info-value system-info-value-long">{metrics.os.kernel}</span>
</div>
</div>
</div>
</div>
{/* 运行时信息 */}
<div className="system-info-group">
<h4 className="info-group-title"></h4>
<div className="system-info-grid">
<div className="system-info-item system-info-item-highlight">
<div className="system-info-content">
<span className="system-info-label"></span>
<span className="system-info-value highlight">{formatUptime(metrics.uptimeSeconds)}</span>
</div>
</div>
<div className="system-info-item">
<div className="system-info-content">
<span className="system-info-label"></span>
<span className="system-info-value">{metrics.system.processCount}</span>
</div>
</div>
{metrics.system.packageCount > 0 && (
<div className="system-info-item">
<div className="system-info-content">
<span className="system-info-label"></span>
<span className="system-info-value">{metrics.system.packageCount} <span className="system-info-sub">({metrics.system.packageManager})</span></span>
</div>
</div>
)}
</div>
</div>
{/* 性能指标 */}
<div className="system-info-group">
<h4 className="info-group-title"></h4>
<div className="system-info-grid">
<div className="system-info-item">
<div className="system-info-content">
<span className="system-info-label"></span>
<span className="system-info-value">{metrics.system.diskReadSpeed.toFixed(2)} MB/s</span>
</div>
</div>
<div className="system-info-item">
<div className="system-info-content">
<span className="system-info-label"></span>
<span className="system-info-value">{metrics.system.diskWriteSpeed.toFixed(2)} MB/s</span>
</div>
</div>
{metrics.system.temperature && metrics.system.temperature > 0 && (
<div className="system-info-item system-info-item-temperature">
<div className="system-info-content">
<span className="system-info-label"></span>
<span className="system-info-value temperature-value">{metrics.system.temperature.toFixed(1)}°C</span>
</div>
</div>
)}
{(metrics.system.networkRxSpeed !== undefined || metrics.system.networkTxSpeed !== undefined) && (
<>
<div className="system-info-item">
<div className="system-info-content">
<span className="system-info-label"></span>
<span className="system-info-value">{(metrics.system.networkRxSpeed || 0).toFixed(2)} MB/s</span>
</div>
</div>
<div className="system-info-item">
<div className="system-info-content">
<span className="system-info-label"></span>
<span className="system-info-value">{(metrics.system.networkTxSpeed || 0).toFixed(2)} MB/s</span>
</div>
</div>
</>
)}
</div>
</div>
{/* 延迟信息 */}
{metrics.latency && (
<div className="system-info-group">
<h4 className="info-group-title"></h4>
<div className="system-info-grid">
<div className="system-info-item system-info-item-highlight">
<div className="system-info-content">
<span className="system-info-label"></span>
<span className="system-info-value highlight">
{metrics.latency.clientToServer >= 0 ? `${metrics.latency.clientToServer} ms` : '超时'}
</span>
</div>
</div>
{metrics.latency.external && (
<>
<div className="system-info-item">
<div className="system-info-content">
<span className="system-info-label"> (baidu.com)</span>
<span className="system-info-value">{metrics.latency.external['baidu.com'] || '超时'}</span>
</div>
</div>
<div className="system-info-item">
<div className="system-info-content">
<span className="system-info-label"> (google.com)</span>
<span className="system-info-value">{metrics.latency.external['google.com'] || '超时'}</span>
</div>
</div>
<div className="system-info-item">
<div className="system-info-content">
<span className="system-info-label">GitHub (github.com)</span>
<span className="system-info-value">{metrics.latency.external['github.com'] || '超时'}</span>
</div>
</div>
</>
)}
</div>
</div>
)}
</div>
</section>
<section className="detail-section">
<h3>CPU</h3>
<div className="cpu-section-content">
<div className="cpu-circular-group">
<div className="cpu-circular">
<CircularProgress
value={metrics.cpu.usagePercent}
label=""
size={140}
color={metrics.cpu.usagePercent > 80 ? '#f87171' : '#86efac'}
subLabel={formatPercent(metrics.cpu.usagePercent)}
/>
</div>
{metrics.cpu.loadAverages && metrics.cpu.loadAverages.length > 0 && metrics.cpu.cores > 0 && (
<div className="cpu-load-circular">
<CircularProgress
value={Math.min((metrics.cpu.loadAverages[0] / metrics.cpu.cores) * 100, 100)}
label="负载"
size={140}
color={metrics.cpu.loadAverages[0] / metrics.cpu.cores > 0.8 ? '#fde047' : '#a7f3d0'}
subLabel={metrics.cpu.loadAverages[0].toFixed(2)}
/>
</div>
)}
</div>
<div className="detail-grid">
<div className="detail-item">
<span className="item-label"></span>
<span className="item-value">{metrics.cpu.model}</span>
</div>
<div className="detail-item">
<span className="item-label"></span>
<span className="item-value">{metrics.cpu.cores}</span>
</div>
<div className="detail-item">
<span className="item-label">使</span>
<span className="item-value highlight">{formatPercent(metrics.cpu.usagePercent)}</span>
</div>
{metrics.cpu.temperature && metrics.cpu.temperature > 0 && (
<div className="detail-item">
<span className="item-label"></span>
<span className="item-value">{metrics.cpu.temperature.toFixed(1)}°C</span>
</div>
)}
{metrics.cpu.loadAverages && metrics.cpu.loadAverages.length > 0 && (
<div className="detail-item">
<span className="item-label"></span>
<span className="item-value">{metrics.cpu.loadAverages.join(' / ')}</span>
</div>
)}
</div>
</div>
{metrics.cpu.perCoreUsage && metrics.cpu.perCoreUsage.length > 0 && (
<div className="per-core-section">
<h4>使</h4>
<div className="core-grid-circular">
{metrics.cpu.perCoreUsage.map((core) => (
<div key={core.core} className="core-item-circular">
<CircularProgress
value={core.percent}
label={`Core ${core.core}`}
size={140}
color={core.percent > 80 ? '#f87171' : '#86efac'}
subLabel={formatPercent(core.percent)}
/>
</div>
))}
</div>
</div>
)}
</section>
<section className="detail-section">
<h3></h3>
<div className="memory-section-content">
<div className="memory-circular">
<CircularProgress
value={metrics.memory.usedPercent}
label=""
size={140}
color={metrics.memory.usedPercent > 80 ? '#fde047' : '#6ee7b7'}
subLabel={formatPercent(metrics.memory.usedPercent)}
/>
</div>
<div className="detail-grid">
<div className="detail-item">
<span className="item-label"></span>
<span className="item-value">{formatBytes(metrics.memory.totalBytes)}</span>
</div>
<div className="detail-item">
<span className="item-label">使</span>
<span className="item-value">{formatBytes(metrics.memory.usedBytes)}</span>
</div>
<div className="detail-item">
<span className="item-label"></span>
<span className="item-value">{formatBytes(metrics.memory.freeBytes)}</span>
</div>
<div className="detail-item">
<span className="item-label">使</span>
<span className="item-value highlight">{formatPercent(metrics.memory.usedPercent)}</span>
</div>
</div>
</div>
</section>
<section className="detail-section">
<h3></h3>
<div className="storage-grid-circular">
{metrics.storage.map((disk, index) => (
<div key={index} className="storage-item-circular">
<CircularProgress
value={disk.usedPercent}
label={disk.mount}
size={160}
color={disk.usedPercent > 90 ? '#f87171' : '#a7f3d0'}
subLabel={formatPercent(disk.usedPercent)}
/>
<div className="storage-details-mini">
<div>: {formatBytes(disk.totalBytes)}</div>
<div>: {formatBytes(disk.usedBytes)}</div>
</div>
</div>
))}
</div>
</section>
{metrics.gpu && metrics.gpu.length > 0 && metrics.gpu[0].status !== 'not_available' && (
<section className="detail-section">
<h3>GPU</h3>
{metrics.gpu.map((gpu, index) => (
<div key={index} className="gpu-item">
<div className="gpu-header">
<strong>{gpu.name}</strong>
<span className={`gpu-status ${gpu.status}`}>{gpu.status}</span>
</div>
<div className="gpu-bar-container">
<div className="gpu-bar-label"></div>
<div className="gpu-bar-bg">
<div
className="gpu-bar"
style={{ width: `${gpu.utilizationPercent}%` }}
/>
</div>
<div className="gpu-bar-value">{formatPercent(gpu.utilizationPercent)}</div>
</div>
<div className="detail-grid">
<div className="detail-item">
<span className="item-label"></span>
<span className="item-value">{gpu.memoryTotalMB} MB</span>
</div>
<div className="detail-item">
<span className="item-label">使</span>
<span className="item-value">{gpu.memoryUsedMB} MB</span>
</div>
{gpu.temperature && gpu.temperature > 0 && (
<div className="detail-item">
<span className="item-label"></span>
<span className="item-value">{gpu.temperature.toFixed(1)}°C</span>
</div>
)}
</div>
</div>
))}
</section>
)}
{metrics.network && metrics.network.length > 0 && (
<section className="detail-section">
<h3></h3>
<div className="network-list">
{metrics.network.map((iface, index) => (
<div key={index} className="network-item">
<div className="network-header">
<div className="network-name-group">
<div className="network-name-info">
<strong className="network-name">{iface.name}</strong>
{iface.ipAddress && (
<span className="network-ip has-ip">
{iface.ipAddress}
</span>
)}
</div>
</div>
</div>
<div className="network-details">
{(iface.macAddress || iface.ipAddress) && (
<div className="network-detail-row">
{iface.macAddress && (
<div className="network-detail-item">
<span className="network-detail-label">MAC </span>
<span className="network-detail-value">{iface.macAddress}</span>
</div>
)}
{iface.ipAddress && (
<div className="network-detail-item">
<span className="network-detail-label">IP </span>
<span className="network-detail-value">{iface.ipAddress}</span>
</div>
)}
</div>
)}
<div className="network-traffic">
<div className="traffic-item">
<div className="traffic-header">
<span className="traffic-label"></span>
<span className="traffic-value">{formatBytes(iface.rxBytes)}</span>
</div>
</div>
<div className="traffic-item">
<div className="traffic-header">
<span className="traffic-label"></span>
<span className="traffic-value">{formatBytes(iface.txBytes)}</span>
</div>
</div>
</div>
</div>
</div>
))}
</div>
</section>
)}
{metrics.system.topProcesses && metrics.system.topProcesses.length > 0 && (
<section className="detail-section">
<h3>Top 5 </h3>
<div className="process-list">
{metrics.system.topProcesses.map((proc, index) => (
<div key={index} className="process-item">
<div className="process-rank">{index + 1}</div>
<div className="process-content">
<div className="process-header">
<div className="process-name-group">
<strong className="process-name">{proc.name}</strong>
<span className="process-pid">PID: {proc.pid}</span>
</div>
</div>
<div className="process-metrics">
<div className="process-metric-item">
<div className="metric-header">
<span className="metric-label">CPU</span>
<span className="metric-value">{proc.cpu}%</span>
</div>
<div className="metric-bar-container">
<div
className="metric-bar cpu-bar"
style={{ width: `${Math.min(proc.cpu, 100)}%` }}
/>
</div>
</div>
<div className="process-metric-item">
<div className="metric-header">
<span className="metric-label"></span>
<span className="metric-value">{proc.memory}% ({proc.memoryMB.toFixed(1)} MB)</span>
</div>
<div className="metric-bar-container">
<div
className="metric-bar memory-bar"
style={{ width: `${Math.min(proc.memory, 100)}%` }}
/>
</div>
</div>
</div>
</div>
</div>
))}
</div>
</section>
)}
{metrics.system.dockerStats && metrics.system.dockerStats.available && (
<section className="detail-section">
<h3>Docker </h3>
{metrics.system.dockerStats.version && (
<div className="detail-grid">
<div className="detail-item">
<span className="item-label">Docker </span>
<span className="item-value">{metrics.system.dockerStats.version}</span>
</div>
<div className="detail-item">
<span className="item-label"></span>
<span className="item-value">{metrics.system.dockerStats.running}</span>
</div>
<div className="detail-item">
<span className="item-label"></span>
<span className="item-value">{metrics.system.dockerStats.stopped}</span>
</div>
<div className="detail-item">
<span className="item-label"></span>
<span className="item-value">{metrics.system.dockerStats.imageCount}</span>
</div>
</div>
)}
{metrics.system.dockerStats.runningNames && metrics.system.dockerStats.runningNames.length > 0 && (
<div className="docker-containers-section">
<h4></h4>
<div className="docker-names-list">
{metrics.system.dockerStats.runningNames.map((name, index) => (
<span key={index} className="docker-name-tag running">
{name}
</span>
))}
</div>
</div>
)}
{metrics.system.dockerStats.stoppedNames && metrics.system.dockerStats.stoppedNames.length > 0 && (
<div className="docker-containers-section">
<h4></h4>
<div className="docker-names-list">
{metrics.system.dockerStats.stoppedNames.map((name, index) => (
<span key={index} className="docker-name-tag stopped">
{name}
</span>
))}
</div>
</div>
)}
{metrics.system.dockerStats.imageNames && metrics.system.dockerStats.imageNames.length > 0 && (
<div className="docker-containers-section">
<h4></h4>
<div className="docker-names-list">
{metrics.system.dockerStats.imageNames.map((name, index) => (
<span key={index} className="docker-name-tag image">
{name}
</span>
))}
</div>
</div>
)}
</section>
)}
{metrics.system.systemLogs && metrics.system.systemLogs.length > 0 && (
<section className="detail-section">
<h3></h3>
<div className="logs-container">
{metrics.system.systemLogs.map((log, index) => (
<div key={index} className="log-line">
{log}
</div>
))}
</div>
</section>
)}
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,58 @@
import { useState, useEffect, useCallback } from 'react';
import type { ServerConfig, ServerStatus } from '../types';
import { fetchServerMetrics } from '../api/monitor';
export const useServerMonitor = (servers: ServerConfig[], interval: number = 2000) => {
const [statuses, setStatuses] = useState<Record<string, ServerStatus>>({});
const fetchMetrics = useCallback(async (server: ServerConfig) => {
if (!server.enabled) {
return;
}
try {
const metrics = await fetchServerMetrics(server.url);
setStatuses(prev => ({
...prev,
[server.id]: {
serverId: server.id,
online: true,
metrics,
lastUpdate: Date.now(),
},
}));
} catch (error) {
setStatuses(prev => ({
...prev,
[server.id]: {
serverId: server.id,
online: false,
error: error instanceof Error ? error.message : 'Unknown error',
lastUpdate: Date.now(),
},
}));
}
}, []);
useEffect(() => {
// Initial fetch
servers.forEach(server => {
if (server.enabled) {
fetchMetrics(server);
}
});
// Set up polling
const timer = setInterval(() => {
servers.forEach(server => {
if (server.enabled) {
fetchMetrics(server);
}
});
}, interval);
return () => clearInterval(timer);
}, [servers, interval, fetchMetrics]);
return statuses;
};

View File

@@ -0,0 +1,74 @@
* {
box-sizing: border-box;
}
/* 隐藏所有滚动条 */
* {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE and Edge */
}
*::-webkit-scrollbar {
display: none; /* Chrome, Safari, Opera */
}
:root {
/* Theme Colors - 淡绿色/淡黄绿色渐变 */
--primary-color: #86efac;
--primary-hover: #6ee7b7;
--secondary-color: #a7f3d0;
--warning-color: #fde047;
--danger-color: #f87171;
--success-color: #86efac;
/* Backgrounds - 纯白色 */
--bg-color: #ffffff;
--surface-color: #ffffff;
--surface-hover: #ffffff;
/* Text */
--text-main: #0f172a;
--text-secondary: #64748b;
--text-light: #94a3b8;
/* Borders & Shadows - 灰白色边框 */
--border-color: #e2e8f0;
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
/* Radius */
--radius-sm: 0.375rem;
--radius-md: 0.5rem;
--radius-lg: 1rem;
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light;
color: var(--text-main);
background-color: var(--bg-color);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
background-color: var(--bg-color);
}
h1, h2, h3, h4, h5, h6 {
color: var(--text-main);
font-weight: 600;
line-height: 1.2;
}
button {
font-family: inherit;
}

View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@@ -0,0 +1,125 @@
export interface ServerConfig {
id: string;
name: string;
url: string;
enabled: boolean;
}
export interface CPUMetrics {
model: string;
cores: number;
usagePercent: number;
loadAverages?: number[];
temperature?: number;
perCoreUsage?: CoreUsage[];
}
export interface CoreUsage {
core: number;
percent: number;
}
export interface MemoryMetrics {
totalBytes: number;
usedBytes: number;
freeBytes: number;
usedPercent: number;
}
export interface StorageMetrics {
mount: string;
totalBytes: number;
usedBytes: number;
freeBytes: number;
usedPercent: number;
}
export interface GPUMetrics {
name: string;
memoryTotalMB: number;
memoryUsedMB: number;
utilizationPercent: number;
temperature?: number;
status: string;
}
export interface NetworkInterface {
name: string;
ipAddress: string;
macAddress: string;
rxBytes: number;
txBytes: number;
rxSpeed: number;
txSpeed: number;
}
export interface SystemStats {
processCount: number;
packageCount: number;
packageManager: string;
temperature?: number;
diskReadSpeed: number;
diskWriteSpeed: number;
networkRxSpeed?: number;
networkTxSpeed?: number;
topProcesses: ProcessInfo[];
dockerStats: DockerStats;
systemLogs?: string[];
}
export interface ProcessInfo {
pid: number;
name: string;
cpu: number;
memory: number;
memoryMB: number;
command: string;
}
export interface DockerStats {
available: boolean;
version?: string;
running: number;
stopped: number;
imageCount: number;
runningNames?: string[];
stoppedNames?: string[];
imageNames?: string[];
}
export interface OSInfo {
kernel: string;
distro: string;
architecture: string;
}
export interface LatencyInfo {
clientToServer: number;
external: {
'baidu.com'?: string;
'google.com'?: string;
'github.com'?: string;
};
}
export interface ServerMetrics {
hostname: string;
timestamp: string;
cpu: CPUMetrics;
memory: MemoryMetrics;
storage: StorageMetrics[];
gpu: GPUMetrics[];
network: NetworkInterface[];
system: SystemStats;
os: OSInfo;
uptimeSeconds: number;
latency?: LatencyInfo;
}
export interface ServerStatus {
serverId: string;
online: boolean;
metrics?: ServerMetrics;
error?: string;
lastUpdate: number;
}

View File

@@ -0,0 +1,45 @@
export const formatBytes = (bytes: number): string => {
if (bytes === 0 || bytes === null || bytes === undefined || isNaN(bytes) || !isFinite(bytes)) {
return '0 B';
}
// 处理负数
if (bytes < 0) {
bytes = 0;
}
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
// 处理小于 1 的情况
if (bytes < 1) {
return bytes.toFixed(2) + ' ' + sizes[0];
}
const i = Math.floor(Math.log(bytes) / Math.log(k));
// 确保索引在有效范围内
const sizeIndex = Math.max(0, Math.min(i, sizes.length - 1));
const value = Math.round((bytes / Math.pow(k, sizeIndex)) * 100) / 100;
// 确保 sizes[sizeIndex] 存在
const unit = sizes[sizeIndex] || sizes[0];
return value + ' ' + unit;
};
export const formatUptime = (seconds: number): string => {
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const parts: string[] = [];
if (days > 0) parts.push(`${days}`);
if (hours > 0) parts.push(`${hours}小时`);
if (minutes > 0) parts.push(`${minutes}分钟`);
return parts.length > 0 ? parts.join(' ') : '刚刚启动';
};
export const formatPercent = (value: number): string => {
return `${Math.round(value * 10) / 10}%`;
};

View File

@@ -0,0 +1,53 @@
import type { ServerConfig } from '../types';
const STORAGE_KEY = 'mengya_monitor_servers';
export const loadServers = (): ServerConfig[] => {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored);
// Ensure it's an array
if (Array.isArray(parsed)) {
return parsed;
}
}
} catch (error) {
console.error('Failed to load servers:', error);
}
return [];
};
export const saveServers = (servers: ServerConfig[]): void => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(servers));
} catch (error) {
console.error('Failed to save servers:', error);
}
};
export const addServer = (server: Omit<ServerConfig, 'id'>): ServerConfig => {
const servers = loadServers();
const newServer: ServerConfig = {
...server,
id: Date.now().toString(),
};
servers.push(newServer);
saveServers(servers);
return newServer;
};
export const updateServer = (id: string, updates: Partial<ServerConfig>): void => {
const servers = loadServers();
const index = servers.findIndex(s => s.id === id);
if (index !== -1) {
servers[index] = { ...servers[index], ...updates };
saveServers(servers);
}
};
export const removeServer = (id: string): void => {
const servers = loadServers();
const filtered = servers.filter(s => s.id !== id);
saveServers(filtered);
};

View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,11 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
server: {
port: 2929,
host: true,
},
})

8
需求.txt Normal file
View File

@@ -0,0 +1,8 @@
1.实现一个服务器监控面板目前初步支持Linux服务器监控服务器基本信息比如cpu/GPU,内存,储存,操作系统等
2.采用企业级前后端分离架构,前端使用React框架后端使用golang原版自带的net库
3.原理十分简单后端go获取Linux相关硬件信息并封装成相关后端json API路由前端每隔1秒或者2秒获取后端信息
4.对外访问使用默认端口2929 后端默认端口为9292
5.由于是监控面板 前端可以一次性对接多个服务器后端卡片式展示相关信息,前端面板可以展示一些基本信息,用户可以点击详情按钮查看更多详细信息
6.前端项目已经初始化 前后端代码架构必须分类整齐符合标准方便我阅读修改
7.注意前端相当于一个只是客户端用于请求可以配置后端服务器这两个是分开的前端可以打包部署在用户电脑上比如做成软件app而后端构建打包后放到各个服务器上
8.前端页面风格偏白色柔和风