feat(web): 仪表盘补系统资源/中国 IP 段/通知通道与公网穿透卡,修复 frpc 版本读取

仪表盘:修「服务」卡写死的「正常」——改为按 Fonu/Nginx/DDNS/证书四项实际状态聚合,异常时点名并降级徽标

仪表盘:新增「性能」面板(今日异常/平均响应,/api/status 早已返回但从未展示)与「流量 Top 5」(复用已有逐规则流量轮询);/api/status 每 15 秒刷新并在页面隐藏时暂停(连同流量轮询)

仪表盘:新增「公网穿透」统计卡;运行状态面板增加公网反代行,数据来自新增的 GET /api/frpmulti/routes/summary(逐台服务端聚合,单台失败不中断)

仪表盘:新增一行三卡「系统资源 / 中国 IP 段 / 通知通道」;系统资源为新增跨平台采集 internal/sysinfo(Linux /proc、Windows kernel32,无新依赖)

FRP:修复 frpc 版本读不出来——RuntimeStatus.Version 带 omitempty,--version 探测失败时字段被省略,前端只能显示未检测;现回落按 <FrpDir>/bin/<版本>/ 目录名推导(与「FRP 应用配置」弹窗的 active_version 同源),并逐台服务端探测版本

清理:删除 DashboardView 死样式(.dashboard/.stat-highlight/.proxy-metric*)
This commit is contained in:
2026-09-23 19:10:43 +08:00
parent 1b30be45a0
commit 8f6fac802f
14 changed files with 1168 additions and 124 deletions
+105
View File
@@ -0,0 +1,105 @@
// Package sysinfo 采集主机资源占用(CPU / 内存 / 磁盘),供仪表盘展示。
//
// 注意:在容器(Docker)里运行时,/proc 与根文件系统通常反映的是宿主机的总量与占用
// (除非挂载了 lxcfs 之类的视图隔离),因此这里的数值是「宿主机视角」,不是容器配额。
package sysinfo
import (
"strconv"
"strings"
)
// CPUSample 是 CPU 累计计数器的一次快照;占用率需要两次采样求差值
type CPUSample struct {
Total uint64
Idle uint64
}
// Stats 是主机资源快照;当前平台采集不到的字段保持 0
type Stats struct {
CPUPercent float64
MemUsedBytes uint64
MemTotalBytes uint64
DiskUsedBytes uint64
DiskTotalBytes uint64
}
// Read 采集一次主机资源。cpuPrev 为上次采样的 CPU 计数器(零值表示首次采集,
// 此时 CPUPercent 为 0),返回的 sample 供下次调用传入。
func Read(cpuPrev CPUSample) (Stats, CPUSample) {
stats, sample := readPlatform()
stats.CPUPercent = CPUPercent(cpuPrev, sample)
return stats, sample
}
// CPUPercent 由两次采样的差值计算占用率;首次采样或计数器未前进时返回 0
func CPUPercent(prev, cur CPUSample) float64 {
if prev.Total == 0 || cur.Total <= prev.Total {
return 0
}
totalDelta := cur.Total - prev.Total
var idleDelta uint64
if cur.Idle > prev.Idle {
idleDelta = cur.Idle - prev.Idle
}
if idleDelta >= totalDelta {
return 0
}
return float64(totalDelta-idleDelta) / float64(totalDelta) * 100
}
// parseProcStat 解析 /proc/stat 的 cpu 汇总行;第 4、5 列(idle、iowait)都计入空闲
func parseProcStat(raw string) CPUSample {
for _, line := range strings.Split(raw, "\n") {
fields := strings.Fields(line)
if len(fields) < 5 || fields[0] != "cpu" {
continue
}
var total, idle uint64
for i, field := range fields[1:] {
value, err := strconv.ParseUint(field, 10, 64)
if err != nil {
return CPUSample{}
}
total += value
if i == 3 || i == 4 {
idle += value
}
}
return CPUSample{Total: total, Idle: idle}
}
return CPUSample{}
}
// parseMemInfo 解析 /proc/meminfo(值以 kB 为单位),返回总内存与已用内存字节数。
// 已用内存优先按 MemAvailable 计算,缺失时回落到 MemFree + Buffers + Cached。
func parseMemInfo(raw string) (total, used uint64) {
values := make(map[string]uint64, 16)
for _, line := range strings.Split(raw, "\n") {
key, value, ok := strings.Cut(line, ":")
if !ok {
continue
}
fields := strings.Fields(value)
if len(fields) == 0 {
continue
}
amount, err := strconv.ParseUint(fields[0], 10, 64)
if err != nil {
continue
}
values[strings.TrimSpace(key)] = amount * 1024
}
total = values["MemTotal"]
if total == 0 {
return 0, 0
}
available := values["MemAvailable"]
if available == 0 {
available = values["MemFree"] + values["Buffers"] + values["Cached"]
}
if available > total {
return total, 0
}
return total, total - available
}
+36
View File
@@ -0,0 +1,36 @@
//go:build linux
package sysinfo
import (
"os"
"syscall"
)
func readPlatform() (Stats, CPUSample) {
var (
stats Stats
sample CPUSample
)
if raw, err := os.ReadFile("/proc/stat"); err == nil {
sample = parseProcStat(string(raw))
}
if raw, err := os.ReadFile("/proc/meminfo"); err == nil {
stats.MemTotalBytes, stats.MemUsedBytes = parseMemInfo(string(raw))
}
stats.DiskTotalBytes, stats.DiskUsedBytes = diskUsage("/")
return stats, sample
}
func diskUsage(path string) (total, used uint64) {
var stat syscall.Statfs_t
if err := syscall.Statfs(path, &stat); err != nil {
return 0, 0
}
total = stat.Blocks * uint64(stat.Bsize)
free := stat.Bavail * uint64(stat.Bsize)
if free > total {
return total, 0
}
return total, total - free
}
+8
View File
@@ -0,0 +1,8 @@
//go:build !linux && !windows
package sysinfo
// readPlatform 在未适配的平台(macOS / BSD 等)上返回空快照,前端按「不可用」处理
func readPlatform() (Stats, CPUSample) {
return Stats{}, CPUSample{}
}
+61
View File
@@ -0,0 +1,61 @@
package sysinfo
import "testing"
func TestCPUPercent(t *testing.T) {
prev := CPUSample{Total: 1000, Idle: 400}
if got := CPUPercent(prev, CPUSample{Total: 1200, Idle: 500}); got != 50 {
t.Fatalf("expected 50%%, got %v", got)
}
// 首次采样(prev 为零值)不给出占用率
if got := CPUPercent(CPUSample{}, CPUSample{Total: 1200, Idle: 500}); got != 0 {
t.Fatalf("expected 0 on first sample, got %v", got)
}
// 计数器未前进
if got := CPUPercent(prev, prev); got != 0 {
t.Fatalf("expected 0 when counters stalled, got %v", got)
}
}
func TestParseProcStat(t *testing.T) {
raw := "cpu 100 20 80 400 10 0 5 0 0 0\ncpu0 1 2 3 4 5 6 7 8 9 10\n"
sample := parseProcStat(raw)
// 总时间 100+20+80+400+10+0+5 = 615,空闲 400(idle)+10(iowait) = 410
if sample.Total != 615 || sample.Idle != 410 {
t.Fatalf("unexpected sample: %+v", sample)
}
}
func TestParseMemInfo(t *testing.T) {
raw := "MemTotal: 16384000 kB\nMemFree: 1000000 kB\nMemAvailable: 8192000 kB\n"
total, used := parseMemInfo(raw)
if total != 16384000*1024 {
t.Fatalf("unexpected total: %d", total)
}
if used != (16384000-8192000)*1024 {
t.Fatalf("unexpected used: %d", used)
}
// 缺少 MemAvailable 时回落到 MemFree + Buffers + Cached
fallback := "MemTotal: 1000 kB\nMemFree: 200 kB\nBuffers: 100 kB\nCached: 300 kB\n"
total, used = parseMemInfo(fallback)
if total != 1000*1024 || used != 400*1024 {
t.Fatalf("unexpected fallback: total=%d used=%d", total, used)
}
}
func TestReadReportsHostResources(t *testing.T) {
stats, sample := Read(CPUSample{})
if stats.MemTotalBytes == 0 && stats.DiskTotalBytes == 0 {
t.Skip("当前平台未实现资源采集")
}
if stats.MemTotalBytes == 0 || stats.MemUsedBytes > stats.MemTotalBytes {
t.Fatalf("unexpected memory: used=%d total=%d", stats.MemUsedBytes, stats.MemTotalBytes)
}
if stats.DiskTotalBytes == 0 || stats.DiskUsedBytes > stats.DiskTotalBytes {
t.Fatalf("unexpected disk: used=%d total=%d", stats.DiskUsedBytes, stats.DiskTotalBytes)
}
if sample.Total == 0 {
t.Fatalf("expected non-zero cpu counters")
}
}
+93
View File
@@ -0,0 +1,93 @@
//go:build windows
package sysinfo
import (
"os"
"syscall"
"unsafe"
)
var (
kernel32 = syscall.NewLazyDLL("kernel32.dll")
procGetSystemTimes = kernel32.NewProc("GetSystemTimes")
procGlobalMemoryStatusEx = kernel32.NewProc("GlobalMemoryStatusEx")
procGetDiskFreeSpaceExW = kernel32.NewProc("GetDiskFreeSpaceExW")
)
type memoryStatusEx struct {
Length uint32
MemoryLoad uint32
TotalPhys uint64
AvailPhys uint64
TotalPageFile uint64
AvailPageFile uint64
TotalVirtual uint64
AvailVirtual uint64
AvailExtendedVirtual uint64
}
func readPlatform() (Stats, CPUSample) {
var (
stats Stats
sample CPUSample
)
var idle, kernel, user syscall.Filetime
if ret, _, _ := procGetSystemTimes.Call(
uintptr(unsafe.Pointer(&idle)),
uintptr(unsafe.Pointer(&kernel)),
uintptr(unsafe.Pointer(&user)),
); ret != 0 {
// kernel 时间包含 idle,因此总时间 = kernel + user
sample = CPUSample{
Total: filetimeTicks(kernel) + filetimeTicks(user),
Idle: filetimeTicks(idle),
}
}
var mem memoryStatusEx
mem.Length = uint32(unsafe.Sizeof(mem))
if ret, _, _ := procGlobalMemoryStatusEx.Call(uintptr(unsafe.Pointer(&mem))); ret != 0 {
stats.MemTotalBytes = mem.TotalPhys
stats.MemUsedBytes = mem.TotalPhys - mem.AvailPhys
}
stats.DiskTotalBytes, stats.DiskUsedBytes = diskUsage(volumeRoot())
return stats, sample
}
// volumeRoot 取当前工作目录所在盘符(Fonu 的数据目录与工作目录同盘),失败时回落到 C:\
func volumeRoot() string {
wd, err := os.Getwd()
if err != nil || len(wd) < 2 || wd[1] != ':' {
return `C:\`
}
return wd[:3]
}
func diskUsage(root string) (total, used uint64) {
rootPtr, err := syscall.UTF16PtrFromString(root)
if err != nil {
return 0, 0
}
var freeToCaller, totalBytes, totalFree uint64
ret, _, _ := procGetDiskFreeSpaceExW.Call(
uintptr(unsafe.Pointer(rootPtr)),
uintptr(unsafe.Pointer(&freeToCaller)),
uintptr(unsafe.Pointer(&totalBytes)),
uintptr(unsafe.Pointer(&totalFree)),
)
if ret == 0 || totalBytes == 0 {
return 0, 0
}
if totalFree > totalBytes {
return totalBytes, 0
}
return totalBytes, totalBytes - totalFree
}
// filetimeTicks 把 FILETIME(100ns 单位)折算为 10ms 刻度,避免累加时溢出
func filetimeTicks(ft syscall.Filetime) uint64 {
return (uint64(ft.HighDateTime)<<32 | uint64(ft.LowDateTime)) / 10000
}