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:
@@ -458,6 +458,16 @@ func (h *FRPMultiHandler) AgentRouteHealth(w http.ResponseWriter, r *http.Reques
|
||||
writeJSON(w, http.StatusOK, health)
|
||||
}
|
||||
|
||||
// AgentRouteHealthSummary 汇总所有已启用服务端的公网反代健康状态(仪表盘用,避免前端逐台请求)
|
||||
func (h *FRPMultiHandler) AgentRouteHealthSummary(w http.ResponseWriter, r *http.Request) {
|
||||
summary, err := h.svc.SummarizeRouteHealth(r.Context())
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusInternalServerError, "汇总公网反代健康失败")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, summary)
|
||||
}
|
||||
|
||||
func (h *FRPMultiHandler) AgentFRPSAction(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseID(r.PathValue("id"))
|
||||
if err != nil {
|
||||
|
||||
@@ -115,6 +115,7 @@ func NewRouter(deps Deps) http.Handler {
|
||||
protect("GET /api/frpmulti/servers/{id}/agent/status", frpHandler.AgentStatus)
|
||||
protect("GET /api/frpmulti/servers/{id}/agent/routes", frpHandler.AgentRoutes)
|
||||
protect("GET /api/frpmulti/servers/{id}/agent/routes/health", frpHandler.AgentRouteHealth)
|
||||
protect("GET /api/frpmulti/routes/summary", frpHandler.AgentRouteHealthSummary)
|
||||
protect("POST /api/frpmulti/servers/{id}/agent/nginx/install", frpHandler.AgentInstallNginx)
|
||||
protect("GET /api/frpmulti/servers/{id}/agent/routes/{proxyID}/conf", frpHandler.AgentRouteConf)
|
||||
protect("GET /api/frpmulti/servers/{id}/agent/route-candidates", frpHandler.AgentRouteCandidates)
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/fonu/fonu/internal/acme"
|
||||
@@ -17,6 +18,7 @@ import (
|
||||
"github.com/fonu/fonu/internal/logstore"
|
||||
"github.com/fonu/fonu/internal/publicip"
|
||||
"github.com/fonu/fonu/internal/service"
|
||||
"github.com/fonu/fonu/internal/sysinfo"
|
||||
)
|
||||
|
||||
type StatusHandler struct {
|
||||
@@ -26,9 +28,13 @@ type StatusHandler struct {
|
||||
acmeSvc *acme.Service
|
||||
startedAt string
|
||||
nginxPIDFile string
|
||||
cpuMu sync.Mutex
|
||||
cpuSample sysinfo.CPUSample
|
||||
}
|
||||
|
||||
func NewStatusHandler(cfg config.Config, proxySvc *service.ProxyService, ddnsSvc *ddns.Service, acmeSvc *acme.Service, startedAt, nginxPIDFile string) *StatusHandler {
|
||||
// 先采一次 CPU 计数器,使首次请求就能算出占用率(否则只能返回 0)
|
||||
_, sample := sysinfo.Read(sysinfo.CPUSample{})
|
||||
return &StatusHandler{
|
||||
cfg: cfg,
|
||||
proxySvc: proxySvc,
|
||||
@@ -36,31 +42,37 @@ func NewStatusHandler(cfg config.Config, proxySvc *service.ProxyService, ddnsSvc
|
||||
acmeSvc: acmeSvc,
|
||||
startedAt: startedAt,
|
||||
nginxPIDFile: nginxPIDFile,
|
||||
cpuSample: sample,
|
||||
}
|
||||
}
|
||||
|
||||
type statusResponse struct {
|
||||
PublicIPv4 string `json:"public_ipv4"`
|
||||
PublicIPv6 string `json:"public_ipv6"`
|
||||
PublicIPSource string `json:"public_ip_source"`
|
||||
DDNSStatus string `json:"ddns_status"`
|
||||
DDNSCount int `json:"ddns_count"`
|
||||
DDNSLastUpdated string `json:"ddns_last_updated,omitempty"`
|
||||
CertificateStatus string `json:"certificate_status"`
|
||||
CertificateDays int `json:"certificate_days"`
|
||||
CertificateCount int `json:"certificate_count"`
|
||||
CertificateSummary string `json:"certificate_summary,omitempty"`
|
||||
ProxyCount int `json:"proxy_count"`
|
||||
NginxStatus string `json:"nginx_status"`
|
||||
PublicIPv4 string `json:"public_ipv4"`
|
||||
PublicIPv6 string `json:"public_ipv6"`
|
||||
PublicIPSource string `json:"public_ip_source"`
|
||||
DDNSStatus string `json:"ddns_status"`
|
||||
DDNSCount int `json:"ddns_count"`
|
||||
DDNSLastUpdated string `json:"ddns_last_updated,omitempty"`
|
||||
CertificateStatus string `json:"certificate_status"`
|
||||
CertificateDays int `json:"certificate_days"`
|
||||
CertificateCount int `json:"certificate_count"`
|
||||
CertificateSummary string `json:"certificate_summary,omitempty"`
|
||||
ProxyCount int `json:"proxy_count"`
|
||||
NginxStatus string `json:"nginx_status"`
|
||||
RequestToday int `json:"request_today"`
|
||||
RequestYesterday int `json:"request_yesterday"`
|
||||
RequestTrend *int `json:"request_trend,omitempty"`
|
||||
RequestsHourly []int `json:"requests_hourly"`
|
||||
RequestsHourlyLabels []string `json:"requests_hourly_labels"`
|
||||
ErrorToday int `json:"error_today"`
|
||||
AvgResponseMs float64 `json:"avg_response_ms"`
|
||||
StartedAt string `json:"started_at"`
|
||||
UptimeSeconds int64 `json:"uptime_seconds"`
|
||||
AvgResponseMs float64 `json:"avg_response_ms"`
|
||||
CPUPercent float64 `json:"cpu_percent"`
|
||||
MemUsedBytes uint64 `json:"mem_used_bytes"`
|
||||
MemTotalBytes uint64 `json:"mem_total_bytes"`
|
||||
DiskUsedBytes uint64 `json:"disk_used_bytes"`
|
||||
DiskTotalBytes uint64 `json:"disk_total_bytes"`
|
||||
StartedAt string `json:"started_at"`
|
||||
UptimeSeconds int64 `json:"uptime_seconds"`
|
||||
}
|
||||
|
||||
func (h *StatusHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -83,6 +95,9 @@ func (h *StatusHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
records, _ := h.acmeSvc.List(r.Context())
|
||||
certStatus, certDays, certCount, certSummary := summarizeCertificates(records)
|
||||
|
||||
resources, cpuSample := sysinfo.Read(h.currentCPUSample())
|
||||
h.setCPUSample(cpuSample)
|
||||
|
||||
accessLog := filepath.Join(h.cfg.LogsDir(), "access.log")
|
||||
total, errors, avgMs := logstore.CountTodayAccess(accessLog)
|
||||
yesterday, _, _ := logstore.CountAccessForDay(accessLog, time.Now().AddDate(0, 0, -1))
|
||||
@@ -92,30 +107,48 @@ func (h *StatusHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
uptime := time.Since(started).Seconds()
|
||||
|
||||
writeJSON(w, http.StatusOK, statusResponse{
|
||||
PublicIPv4: ipv4,
|
||||
PublicIPv6: ipv6,
|
||||
PublicIPSource: ipSource,
|
||||
DDNSStatus: ddnsStatus,
|
||||
DDNSCount: ddnsCount,
|
||||
DDNSLastUpdated: ddnsLastUpdated,
|
||||
CertificateStatus: certStatus,
|
||||
CertificateDays: certDays,
|
||||
CertificateCount: certCount,
|
||||
CertificateSummary: certSummary,
|
||||
ProxyCount: len(rules),
|
||||
NginxStatus: nginxStatus,
|
||||
PublicIPv4: ipv4,
|
||||
PublicIPv6: ipv6,
|
||||
PublicIPSource: ipSource,
|
||||
DDNSStatus: ddnsStatus,
|
||||
DDNSCount: ddnsCount,
|
||||
DDNSLastUpdated: ddnsLastUpdated,
|
||||
CertificateStatus: certStatus,
|
||||
CertificateDays: certDays,
|
||||
CertificateCount: certCount,
|
||||
CertificateSummary: certSummary,
|
||||
ProxyCount: len(rules),
|
||||
NginxStatus: nginxStatus,
|
||||
RequestToday: total,
|
||||
RequestYesterday: yesterday,
|
||||
RequestTrend: requestTrendPercent(total, yesterday),
|
||||
RequestsHourly: hourly,
|
||||
RequestsHourlyLabels: hourlyLabels,
|
||||
ErrorToday: errors,
|
||||
AvgResponseMs: avgMs,
|
||||
StartedAt: h.startedAt,
|
||||
UptimeSeconds: int64(uptime),
|
||||
AvgResponseMs: avgMs,
|
||||
CPUPercent: resources.CPUPercent,
|
||||
MemUsedBytes: resources.MemUsedBytes,
|
||||
MemTotalBytes: resources.MemTotalBytes,
|
||||
DiskUsedBytes: resources.DiskUsedBytes,
|
||||
DiskTotalBytes: resources.DiskTotalBytes,
|
||||
StartedAt: h.startedAt,
|
||||
UptimeSeconds: int64(uptime),
|
||||
})
|
||||
}
|
||||
|
||||
// currentCPUSample 与 setCPUSample 用锁保护上次采样,/api/status 可能被并发调用
|
||||
func (h *StatusHandler) currentCPUSample() sysinfo.CPUSample {
|
||||
h.cpuMu.Lock()
|
||||
defer h.cpuMu.Unlock()
|
||||
return h.cpuSample
|
||||
}
|
||||
|
||||
func (h *StatusHandler) setCPUSample(sample sysinfo.CPUSample) {
|
||||
h.cpuMu.Lock()
|
||||
defer h.cpuMu.Unlock()
|
||||
h.cpuSample = sample
|
||||
}
|
||||
|
||||
func (h *StatusHandler) resolvePublicIPs(ctx context.Context) (ipv4, ipv6, source string) {
|
||||
if ipv4, ipv6, ok := h.ddnsSvc.PublicIPs(ctx); ok {
|
||||
return ipv4, ipv6, "ddns"
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package frp
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSummarizeRouteHealth(t *testing.T) {
|
||||
items := []RouteHealthCandidate{
|
||||
{DNSOK: true, CertOK: true, TunnelOK: true, ServiceOK: true},
|
||||
{DNSOK: true, CertOK: false, TunnelOK: true, ServiceOK: false},
|
||||
{DNSOK: false, CertOK: true, TunnelOK: false, ServiceOK: true},
|
||||
}
|
||||
summary := summarizeRouteHealth(2, []string{"vps-b"}, items)
|
||||
|
||||
if summary.Servers != 2 || summary.Routes != 3 || summary.Healthy != 1 {
|
||||
t.Fatalf("unexpected summary counts: %+v", summary)
|
||||
}
|
||||
if summary.DNSFailed != 1 || summary.CertMissing != 1 || summary.TunnelDown != 1 || summary.ServiceDown != 1 {
|
||||
t.Fatalf("unexpected segment counts: %+v", summary)
|
||||
}
|
||||
if len(summary.FailedServers) != 1 || summary.FailedServers[0] != "vps-b" {
|
||||
t.Fatalf("unexpected failed servers: %+v", summary.FailedServers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummarizeRouteHealthEmpty(t *testing.T) {
|
||||
summary := summarizeRouteHealth(0, nil, nil)
|
||||
if summary.Servers != 0 || summary.Routes != 0 || summary.Healthy != 0 {
|
||||
t.Fatalf("expected empty summary, got %+v", summary)
|
||||
}
|
||||
if len(summary.FailedServers) != 0 {
|
||||
t.Fatalf("expected no failed servers, got %+v", summary.FailedServers)
|
||||
}
|
||||
}
|
||||
+78
-2
@@ -836,6 +836,64 @@ func (s *Service) InstallAgentNginx(ctx context.Context, serverID int64) (map[st
|
||||
return client.InstallNginx(ctx)
|
||||
}
|
||||
|
||||
// RouteHealthSummary 公网反代部署健康的汇总(仪表盘用)。
|
||||
// 健康判定与规则页一致:四段(DNS/证书/隧道/内网服务)全通过才算健康;
|
||||
// 一个域名可同时命中多段失败,因此各段计数之和可能大于 Routes-Healthy。
|
||||
type RouteHealthSummary struct {
|
||||
Servers int `json:"servers"`
|
||||
Routes int `json:"routes"`
|
||||
Healthy int `json:"healthy"`
|
||||
DNSFailed int `json:"dns_failed"`
|
||||
CertMissing int `json:"cert_missing"`
|
||||
TunnelDown int `json:"tunnel_down"`
|
||||
ServiceDown int `json:"service_down"`
|
||||
FailedServers []string `json:"failed_servers,omitempty"`
|
||||
}
|
||||
|
||||
// SummarizeRouteHealth 逐台已启用服务端采集公网反代健康后汇总。
|
||||
// 单台服务端采集失败只记名并继续其它服务端,不阻断整轮汇总。
|
||||
func (s *Service) SummarizeRouteHealth(ctx context.Context) (RouteHealthSummary, error) {
|
||||
servers, err := s.store.ListEnabledServers(ctx)
|
||||
if err != nil {
|
||||
return RouteHealthSummary{}, err
|
||||
}
|
||||
var (
|
||||
items []RouteHealthCandidate
|
||||
failed []string
|
||||
)
|
||||
for _, server := range servers {
|
||||
candidates, err := s.RouteHealth(ctx, server.ID)
|
||||
if err != nil {
|
||||
failed = append(failed, server.Name)
|
||||
continue
|
||||
}
|
||||
items = append(items, candidates...)
|
||||
}
|
||||
return summarizeRouteHealth(len(servers), failed, items), nil
|
||||
}
|
||||
|
||||
func summarizeRouteHealth(servers int, failedServers []string, items []RouteHealthCandidate) RouteHealthSummary {
|
||||
summary := RouteHealthSummary{Servers: servers, Routes: len(items), FailedServers: failedServers}
|
||||
for _, item := range items {
|
||||
if !item.DNSOK {
|
||||
summary.DNSFailed++
|
||||
}
|
||||
if !item.CertOK {
|
||||
summary.CertMissing++
|
||||
}
|
||||
if !item.TunnelOK {
|
||||
summary.TunnelDown++
|
||||
}
|
||||
if !item.ServiceOK {
|
||||
summary.ServiceDown++
|
||||
}
|
||||
if item.DNSOK && item.CertOK && item.TunnelOK && item.ServiceOK {
|
||||
summary.Healthy++
|
||||
}
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
// RouteHealthCandidate 公网反代规则的四段健康状态(DNS/证书/隧道/内网服务)。
|
||||
type RouteHealthCandidate struct {
|
||||
ProxyID int64 `json:"proxy_id"`
|
||||
@@ -1464,11 +1522,14 @@ func (s *Service) Status(ctx context.Context) RuntimeStatus {
|
||||
s.manager.RefreshVersion(versionCtx)
|
||||
servers, err := s.store.ListEnabledServers(ctx)
|
||||
if err != nil {
|
||||
return s.manager.Status()
|
||||
return s.statusWithVersion(s.manager)
|
||||
}
|
||||
statuses := make([]RuntimeStatus, 0, len(servers))
|
||||
for _, server := range servers {
|
||||
current := s.managerForServer(server.ID).Status()
|
||||
manager := s.managerForServer(server.ID)
|
||||
// 每个服务端各自探测版本(共用同一个 2 秒超时上下文)
|
||||
manager.RefreshVersion(versionCtx)
|
||||
current := s.statusWithVersion(manager)
|
||||
current.Configured = s.serverConfigured(server)
|
||||
if proxies, proxyErr := s.store.ListEnabledProxies(ctx, server.ID); proxyErr == nil {
|
||||
current.EnabledProxies = len(proxies)
|
||||
@@ -1478,6 +1539,21 @@ func (s *Service) Status(ctx context.Context) RuntimeStatus {
|
||||
return latestRuntimeStatus(statuses)
|
||||
}
|
||||
|
||||
// statusWithVersion 取服务端运行状态,并在版本探测失败时按二进制目录名推导版本:
|
||||
// frpc 进程未运行或 --version 探测失败时,RuntimeStatus.Version 因带 omitempty 会被省略,
|
||||
// 前端(FRP 穿透页状态卡、仪表盘)就只能显示「未检测 / 未知」。
|
||||
// 这里回落到 <FrpDir>/bin/<版本>/ 的目录约定,与「FRP 应用配置」弹窗的 active_version 同源。
|
||||
func (s *Service) statusWithVersion(manager *Manager) RuntimeStatus {
|
||||
status := manager.Status()
|
||||
if status.Version != "" {
|
||||
return status
|
||||
}
|
||||
if bin, err := manager.BinaryPath(); err == nil {
|
||||
status.Version = versionFromBinaryPath(s.cfg.FrpDir(), bin)
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
func (s *Service) ReadLogs(limit int, serverID int64) ([]string, error) {
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 200
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package frp
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestVersionFromBinaryPath(t *testing.T) {
|
||||
frpDir := filepath.Join("data", "frp")
|
||||
cases := []struct {
|
||||
name string
|
||||
path string
|
||||
want string
|
||||
}{
|
||||
{"带 v 前缀的版本目录", filepath.Join(frpDir, "bin", "v0.71.0", "frpc.exe"), "v0.71.0"},
|
||||
{"纯数字版本目录", filepath.Join(frpDir, "bin", "0.62.1", "frpc"), "0.62.1"},
|
||||
{"不在 bin/<版本>/ 下", filepath.Join(frpDir, "frpc.exe"), ""},
|
||||
{"不在 FrpDir 下", filepath.Join("other", "bin", "v1.0.0", "frpc"), ""},
|
||||
{"多一层子目录", filepath.Join(frpDir, "bin", "v1.0.0", "extra", "frpc"), ""},
|
||||
{"目录名不是版本", filepath.Join(frpDir, "bin", "latest", "frpc"), ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := versionFromBinaryPath(frpDir, tc.path); got != tc.want {
|
||||
t.Fatalf("%s:versionFromBinaryPath(%q) = %q,期望 %q", tc.name, tc.path, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
//go:build !linux && !windows
|
||||
|
||||
package sysinfo
|
||||
|
||||
// readPlatform 在未适配的平台(macOS / BSD 等)上返回空快照,前端按「不可用」处理
|
||||
func readPlatform() (Stats, CPUSample) {
|
||||
return Stats{}, CPUSample{}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user