From 8f6fac802f07678c94db0d9b9403b2670323fb8a Mon Sep 17 00:00:00 2001 From: mrlee <470349233@qq.com> Date: Wed, 23 Sep 2026 19:10:43 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E4=BB=AA=E8=A1=A8=E7=9B=98?= =?UTF-8?q?=E8=A1=A5=E7=B3=BB=E7=BB=9F=E8=B5=84=E6=BA=90/=E4=B8=AD?= =?UTF-8?q?=E5=9B=BD=20IP=20=E6=AE=B5/=E9=80=9A=E7=9F=A5=E9=80=9A=E9=81=93?= =?UTF-8?q?=E4=B8=8E=E5=85=AC=E7=BD=91=E7=A9=BF=E9=80=8F=E5=8D=A1=EF=BC=8C?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20frpc=20=E7=89=88=E6=9C=AC=E8=AF=BB?= =?UTF-8?q?=E5=8F=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 仪表盘:修「服务」卡写死的「正常」——改为按 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 探测失败时字段被省略,前端只能显示未检测;现回落按 /bin/<版本>/ 目录名推导(与「FRP 应用配置」弹窗的 active_version 同源),并逐台服务端探测版本 清理:删除 DashboardView 死样式(.dashboard/.stat-highlight/.proxy-metric*) --- internal/api/frpmulti_handler.go | 10 + internal/api/router.go | 1 + internal/api/status_handler.go | 93 ++-- internal/frp/route_health_test.go | 32 ++ internal/frp/service.go | 80 ++- internal/frp/version_test.go | 27 ++ internal/sysinfo/sysinfo.go | 105 ++++ internal/sysinfo/sysinfo_linux.go | 36 ++ internal/sysinfo/sysinfo_other.go | 8 + internal/sysinfo/sysinfo_test.go | 61 +++ internal/sysinfo/sysinfo_windows.go | 93 ++++ web/src/api/client.ts | 2 + web/src/api/types.ts | 16 + web/src/views/DashboardView.vue | 728 ++++++++++++++++++++++++---- 14 files changed, 1168 insertions(+), 124 deletions(-) create mode 100644 internal/frp/route_health_test.go create mode 100644 internal/frp/version_test.go create mode 100644 internal/sysinfo/sysinfo.go create mode 100644 internal/sysinfo/sysinfo_linux.go create mode 100644 internal/sysinfo/sysinfo_other.go create mode 100644 internal/sysinfo/sysinfo_test.go create mode 100644 internal/sysinfo/sysinfo_windows.go diff --git a/internal/api/frpmulti_handler.go b/internal/api/frpmulti_handler.go index 75617d9..7298129 100644 --- a/internal/api/frpmulti_handler.go +++ b/internal/api/frpmulti_handler.go @@ -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 { diff --git a/internal/api/router.go b/internal/api/router.go index a78095d..81c8da1 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -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) diff --git a/internal/api/status_handler.go b/internal/api/status_handler.go index bdf5fd8..7de34f3 100644 --- a/internal/api/status_handler.go +++ b/internal/api/status_handler.go @@ -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" diff --git a/internal/frp/route_health_test.go b/internal/frp/route_health_test.go new file mode 100644 index 0000000..dee39f8 --- /dev/null +++ b/internal/frp/route_health_test.go @@ -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) + } +} diff --git a/internal/frp/service.go b/internal/frp/service.go index cb2558a..fb4e6b2 100644 --- a/internal/frp/service.go +++ b/internal/frp/service.go @@ -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 穿透页状态卡、仪表盘)就只能显示「未检测 / 未知」。 +// 这里回落到 /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 diff --git a/internal/frp/version_test.go b/internal/frp/version_test.go new file mode 100644 index 0000000..4400e88 --- /dev/null +++ b/internal/frp/version_test.go @@ -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) + } + } +} diff --git a/internal/sysinfo/sysinfo.go b/internal/sysinfo/sysinfo.go new file mode 100644 index 0000000..b142ff7 --- /dev/null +++ b/internal/sysinfo/sysinfo.go @@ -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 +} diff --git a/internal/sysinfo/sysinfo_linux.go b/internal/sysinfo/sysinfo_linux.go new file mode 100644 index 0000000..44c15b6 --- /dev/null +++ b/internal/sysinfo/sysinfo_linux.go @@ -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 +} diff --git a/internal/sysinfo/sysinfo_other.go b/internal/sysinfo/sysinfo_other.go new file mode 100644 index 0000000..7e08f08 --- /dev/null +++ b/internal/sysinfo/sysinfo_other.go @@ -0,0 +1,8 @@ +//go:build !linux && !windows + +package sysinfo + +// readPlatform 在未适配的平台(macOS / BSD 等)上返回空快照,前端按「不可用」处理 +func readPlatform() (Stats, CPUSample) { + return Stats{}, CPUSample{} +} diff --git a/internal/sysinfo/sysinfo_test.go b/internal/sysinfo/sysinfo_test.go new file mode 100644 index 0000000..4312099 --- /dev/null +++ b/internal/sysinfo/sysinfo_test.go @@ -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") + } +} diff --git a/internal/sysinfo/sysinfo_windows.go b/internal/sysinfo/sysinfo_windows.go new file mode 100644 index 0000000..45efe35 --- /dev/null +++ b/internal/sysinfo/sysinfo_windows.go @@ -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 +} diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 8885dbd..8890dd6 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -299,6 +299,8 @@ export const api = { request<{ ok: boolean; manager?: string; output?: string; path?: string; error?: string }>(`/api/frpmulti/servers/${serverId}/agent/nginx/install`, { method: 'POST' }), getFrpAgentRouteHealth: (serverId: number) => request(`/api/frpmulti/servers/${serverId}/agent/routes/health`), + getFrpRouteHealthSummary: () => + request('/api/frpmulti/routes/summary'), removeFrpAgentRoute: (serverId: number, proxyId: number) => request<{ ok: boolean }>(`/api/frpmulti/servers/${serverId}/agent/routes/${proxyId}`, { method: 'DELETE' }), pushFrpConfigToAgent: (id: number) => request<{ ok: boolean }>(`/api/frpmulti/servers/${id}/agent/frps-config`, { method: 'POST' }), frpsServerAction: (id: number, action: 'start' | 'stop' | 'restart') => diff --git a/web/src/api/types.ts b/web/src/api/types.ts index d452686..d5af4b8 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -147,6 +147,11 @@ export interface DashboardStatus { avg_response_ms: number started_at: string uptime_seconds: number + cpu_percent: number + mem_used_bytes: number + mem_total_bytes: number + disk_used_bytes: number + disk_total_bytes: number } export interface DDNSDomainRecord { @@ -720,6 +725,17 @@ export interface FrpAgentRouteHealth { service_ok: boolean } +export interface FrpRouteHealthSummary { + servers: number + routes: number + healthy: number + dns_failed: number + cert_missing: number + tunnel_down: number + service_down: number + failed_servers?: string[] +} + export interface FrpAgentRouteDetail { domain: string file_exists: boolean diff --git a/web/src/views/DashboardView.vue b/web/src/views/DashboardView.vue index 71cc28c..8139a82 100644 --- a/web/src/views/DashboardView.vue +++ b/web/src/views/DashboardView.vue @@ -56,14 +56,25 @@ - - -

核心服务运行正常

+ + +

{{ serviceHealth.desc }}

Fonu / Nginx / DDNS / 证书
上次检查 {{ formatRelativeTime(status?.started_at) || '刚刚' }}
+ + + + + +

{{ frpHealth.desc }}

+
+
{{ frpHealth.version }}
+
上次应用 {{ frpHealth.applied }}
+
+
@@ -115,6 +126,15 @@ :download="trafficChart.download" />
+
+ 流量 Top {{ trafficTop.length }} +
    +
  • + {{ item.name }} + {{ item.rate }} +
  • +
+
@@ -143,6 +163,123 @@ + + +
+
+ + 性能 +
+
+
+
+
+ +
+
+ 平均响应 + 今日访问平均耗时 + {{ avgResponseMeta }} +
+ + {{ avgResponseLabel }}ms + +
+
+
+ +
+
+ 今日异常 + 状态码 ≥ 400 的请求 + {{ errorRateLabel }} +
+ {{ errorTodayLabel }} +
+
+
+ + +
+ +
+
+ + 系统资源 +
+
+
+
+
+ +
+
+ {{ item.name }} + {{ item.detail }} + + + +
+ {{ item.value }} +
+
+
+ + +
+
+ + 中国 IP 段 +
+
+ + 刷新 +
+
+
+
+
+ +
+
+ {{ item.name }} + {{ item.detail }} +
+ {{ item.value }} +
+
+
+ + +
+
+ + 通知通道 +
+
+ + + 去设置 + + +
+
+
+
+
+ +
+
+ {{ row.name }} + {{ row.detail }} +
+ {{ row.value }} +
+
+
@@ -200,8 +337,9 @@