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)
|
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) {
|
func (h *FRPMultiHandler) AgentFRPSAction(w http.ResponseWriter, r *http.Request) {
|
||||||
id, err := parseID(r.PathValue("id"))
|
id, err := parseID(r.PathValue("id"))
|
||||||
if err != nil {
|
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/status", frpHandler.AgentStatus)
|
||||||
protect("GET /api/frpmulti/servers/{id}/agent/routes", frpHandler.AgentRoutes)
|
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/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("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/routes/{proxyID}/conf", frpHandler.AgentRouteConf)
|
||||||
protect("GET /api/frpmulti/servers/{id}/agent/route-candidates", frpHandler.AgentRouteCandidates)
|
protect("GET /api/frpmulti/servers/{id}/agent/route-candidates", frpHandler.AgentRouteCandidates)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/fonu/fonu/internal/acme"
|
"github.com/fonu/fonu/internal/acme"
|
||||||
@@ -17,6 +18,7 @@ import (
|
|||||||
"github.com/fonu/fonu/internal/logstore"
|
"github.com/fonu/fonu/internal/logstore"
|
||||||
"github.com/fonu/fonu/internal/publicip"
|
"github.com/fonu/fonu/internal/publicip"
|
||||||
"github.com/fonu/fonu/internal/service"
|
"github.com/fonu/fonu/internal/service"
|
||||||
|
"github.com/fonu/fonu/internal/sysinfo"
|
||||||
)
|
)
|
||||||
|
|
||||||
type StatusHandler struct {
|
type StatusHandler struct {
|
||||||
@@ -26,9 +28,13 @@ type StatusHandler struct {
|
|||||||
acmeSvc *acme.Service
|
acmeSvc *acme.Service
|
||||||
startedAt string
|
startedAt string
|
||||||
nginxPIDFile 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 {
|
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{
|
return &StatusHandler{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
proxySvc: proxySvc,
|
proxySvc: proxySvc,
|
||||||
@@ -36,31 +42,37 @@ func NewStatusHandler(cfg config.Config, proxySvc *service.ProxyService, ddnsSvc
|
|||||||
acmeSvc: acmeSvc,
|
acmeSvc: acmeSvc,
|
||||||
startedAt: startedAt,
|
startedAt: startedAt,
|
||||||
nginxPIDFile: nginxPIDFile,
|
nginxPIDFile: nginxPIDFile,
|
||||||
|
cpuSample: sample,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type statusResponse struct {
|
type statusResponse struct {
|
||||||
PublicIPv4 string `json:"public_ipv4"`
|
PublicIPv4 string `json:"public_ipv4"`
|
||||||
PublicIPv6 string `json:"public_ipv6"`
|
PublicIPv6 string `json:"public_ipv6"`
|
||||||
PublicIPSource string `json:"public_ip_source"`
|
PublicIPSource string `json:"public_ip_source"`
|
||||||
DDNSStatus string `json:"ddns_status"`
|
DDNSStatus string `json:"ddns_status"`
|
||||||
DDNSCount int `json:"ddns_count"`
|
DDNSCount int `json:"ddns_count"`
|
||||||
DDNSLastUpdated string `json:"ddns_last_updated,omitempty"`
|
DDNSLastUpdated string `json:"ddns_last_updated,omitempty"`
|
||||||
CertificateStatus string `json:"certificate_status"`
|
CertificateStatus string `json:"certificate_status"`
|
||||||
CertificateDays int `json:"certificate_days"`
|
CertificateDays int `json:"certificate_days"`
|
||||||
CertificateCount int `json:"certificate_count"`
|
CertificateCount int `json:"certificate_count"`
|
||||||
CertificateSummary string `json:"certificate_summary,omitempty"`
|
CertificateSummary string `json:"certificate_summary,omitempty"`
|
||||||
ProxyCount int `json:"proxy_count"`
|
ProxyCount int `json:"proxy_count"`
|
||||||
NginxStatus string `json:"nginx_status"`
|
NginxStatus string `json:"nginx_status"`
|
||||||
RequestToday int `json:"request_today"`
|
RequestToday int `json:"request_today"`
|
||||||
RequestYesterday int `json:"request_yesterday"`
|
RequestYesterday int `json:"request_yesterday"`
|
||||||
RequestTrend *int `json:"request_trend,omitempty"`
|
RequestTrend *int `json:"request_trend,omitempty"`
|
||||||
RequestsHourly []int `json:"requests_hourly"`
|
RequestsHourly []int `json:"requests_hourly"`
|
||||||
RequestsHourlyLabels []string `json:"requests_hourly_labels"`
|
RequestsHourlyLabels []string `json:"requests_hourly_labels"`
|
||||||
ErrorToday int `json:"error_today"`
|
ErrorToday int `json:"error_today"`
|
||||||
AvgResponseMs float64 `json:"avg_response_ms"`
|
AvgResponseMs float64 `json:"avg_response_ms"`
|
||||||
StartedAt string `json:"started_at"`
|
CPUPercent float64 `json:"cpu_percent"`
|
||||||
UptimeSeconds int64 `json:"uptime_seconds"`
|
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) {
|
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())
|
records, _ := h.acmeSvc.List(r.Context())
|
||||||
certStatus, certDays, certCount, certSummary := summarizeCertificates(records)
|
certStatus, certDays, certCount, certSummary := summarizeCertificates(records)
|
||||||
|
|
||||||
|
resources, cpuSample := sysinfo.Read(h.currentCPUSample())
|
||||||
|
h.setCPUSample(cpuSample)
|
||||||
|
|
||||||
accessLog := filepath.Join(h.cfg.LogsDir(), "access.log")
|
accessLog := filepath.Join(h.cfg.LogsDir(), "access.log")
|
||||||
total, errors, avgMs := logstore.CountTodayAccess(accessLog)
|
total, errors, avgMs := logstore.CountTodayAccess(accessLog)
|
||||||
yesterday, _, _ := logstore.CountAccessForDay(accessLog, time.Now().AddDate(0, 0, -1))
|
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()
|
uptime := time.Since(started).Seconds()
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, statusResponse{
|
writeJSON(w, http.StatusOK, statusResponse{
|
||||||
PublicIPv4: ipv4,
|
PublicIPv4: ipv4,
|
||||||
PublicIPv6: ipv6,
|
PublicIPv6: ipv6,
|
||||||
PublicIPSource: ipSource,
|
PublicIPSource: ipSource,
|
||||||
DDNSStatus: ddnsStatus,
|
DDNSStatus: ddnsStatus,
|
||||||
DDNSCount: ddnsCount,
|
DDNSCount: ddnsCount,
|
||||||
DDNSLastUpdated: ddnsLastUpdated,
|
DDNSLastUpdated: ddnsLastUpdated,
|
||||||
CertificateStatus: certStatus,
|
CertificateStatus: certStatus,
|
||||||
CertificateDays: certDays,
|
CertificateDays: certDays,
|
||||||
CertificateCount: certCount,
|
CertificateCount: certCount,
|
||||||
CertificateSummary: certSummary,
|
CertificateSummary: certSummary,
|
||||||
ProxyCount: len(rules),
|
ProxyCount: len(rules),
|
||||||
NginxStatus: nginxStatus,
|
NginxStatus: nginxStatus,
|
||||||
RequestToday: total,
|
RequestToday: total,
|
||||||
RequestYesterday: yesterday,
|
RequestYesterday: yesterday,
|
||||||
RequestTrend: requestTrendPercent(total, yesterday),
|
RequestTrend: requestTrendPercent(total, yesterday),
|
||||||
RequestsHourly: hourly,
|
RequestsHourly: hourly,
|
||||||
RequestsHourlyLabels: hourlyLabels,
|
RequestsHourlyLabels: hourlyLabels,
|
||||||
ErrorToday: errors,
|
ErrorToday: errors,
|
||||||
AvgResponseMs: avgMs,
|
AvgResponseMs: avgMs,
|
||||||
StartedAt: h.startedAt,
|
CPUPercent: resources.CPUPercent,
|
||||||
UptimeSeconds: int64(uptime),
|
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) {
|
func (h *StatusHandler) resolvePublicIPs(ctx context.Context) (ipv4, ipv6, source string) {
|
||||||
if ipv4, ipv6, ok := h.ddnsSvc.PublicIPs(ctx); ok {
|
if ipv4, ipv6, ok := h.ddnsSvc.PublicIPs(ctx); ok {
|
||||||
return ipv4, ipv6, "ddns"
|
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)
|
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/证书/隧道/内网服务)。
|
// RouteHealthCandidate 公网反代规则的四段健康状态(DNS/证书/隧道/内网服务)。
|
||||||
type RouteHealthCandidate struct {
|
type RouteHealthCandidate struct {
|
||||||
ProxyID int64 `json:"proxy_id"`
|
ProxyID int64 `json:"proxy_id"`
|
||||||
@@ -1464,11 +1522,14 @@ func (s *Service) Status(ctx context.Context) RuntimeStatus {
|
|||||||
s.manager.RefreshVersion(versionCtx)
|
s.manager.RefreshVersion(versionCtx)
|
||||||
servers, err := s.store.ListEnabledServers(ctx)
|
servers, err := s.store.ListEnabledServers(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return s.manager.Status()
|
return s.statusWithVersion(s.manager)
|
||||||
}
|
}
|
||||||
statuses := make([]RuntimeStatus, 0, len(servers))
|
statuses := make([]RuntimeStatus, 0, len(servers))
|
||||||
for _, server := range 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)
|
current.Configured = s.serverConfigured(server)
|
||||||
if proxies, proxyErr := s.store.ListEnabledProxies(ctx, server.ID); proxyErr == nil {
|
if proxies, proxyErr := s.store.ListEnabledProxies(ctx, server.ID); proxyErr == nil {
|
||||||
current.EnabledProxies = len(proxies)
|
current.EnabledProxies = len(proxies)
|
||||||
@@ -1478,6 +1539,21 @@ func (s *Service) Status(ctx context.Context) RuntimeStatus {
|
|||||||
return latestRuntimeStatus(statuses)
|
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) {
|
func (s *Service) ReadLogs(limit int, serverID int64) ([]string, error) {
|
||||||
if limit <= 0 || limit > 500 {
|
if limit <= 0 || limit > 500 {
|
||||||
limit = 200
|
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
|
||||||
|
}
|
||||||
@@ -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' }),
|
request<{ ok: boolean; manager?: string; output?: string; path?: string; error?: string }>(`/api/frpmulti/servers/${serverId}/agent/nginx/install`, { method: 'POST' }),
|
||||||
getFrpAgentRouteHealth: (serverId: number) =>
|
getFrpAgentRouteHealth: (serverId: number) =>
|
||||||
request<import('./types').FrpAgentRouteHealth[]>(`/api/frpmulti/servers/${serverId}/agent/routes/health`),
|
request<import('./types').FrpAgentRouteHealth[]>(`/api/frpmulti/servers/${serverId}/agent/routes/health`),
|
||||||
|
getFrpRouteHealthSummary: () =>
|
||||||
|
request<import('./types').FrpRouteHealthSummary>('/api/frpmulti/routes/summary'),
|
||||||
removeFrpAgentRoute: (serverId: number, proxyId: number) => request<{ ok: boolean }>(`/api/frpmulti/servers/${serverId}/agent/routes/${proxyId}`, { method: 'DELETE' }),
|
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' }),
|
pushFrpConfigToAgent: (id: number) => request<{ ok: boolean }>(`/api/frpmulti/servers/${id}/agent/frps-config`, { method: 'POST' }),
|
||||||
frpsServerAction: (id: number, action: 'start' | 'stop' | 'restart') =>
|
frpsServerAction: (id: number, action: 'start' | 'stop' | 'restart') =>
|
||||||
|
|||||||
@@ -147,6 +147,11 @@ export interface DashboardStatus {
|
|||||||
avg_response_ms: number
|
avg_response_ms: number
|
||||||
started_at: string
|
started_at: string
|
||||||
uptime_seconds: number
|
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 {
|
export interface DDNSDomainRecord {
|
||||||
@@ -720,6 +725,17 @@ export interface FrpAgentRouteHealth {
|
|||||||
service_ok: boolean
|
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 {
|
export interface FrpAgentRouteDetail {
|
||||||
domain: string
|
domain: string
|
||||||
file_exists: boolean
|
file_exists: boolean
|
||||||
|
|||||||
+636
-92
@@ -56,14 +56,25 @@
|
|||||||
|
|
||||||
<StatCard label="服务" tone="teal">
|
<StatCard label="服务" tone="teal">
|
||||||
<template #icon><n-icon :component="GitNetworkOutline" /></template>
|
<template #icon><n-icon :component="GitNetworkOutline" /></template>
|
||||||
<template #extra><StatusBadge value="ok" text="正常" /></template>
|
<template #extra><StatusBadge :value="serviceHealth.badgeValue" :text="serviceHealth.badgeText" /></template>
|
||||||
<template #value>{{ coreServiceCountLabel }}</template>
|
<template #value>{{ serviceHealth.value }}</template>
|
||||||
<p class="stat-desc">核心服务运行正常</p>
|
<p class="stat-desc">{{ serviceHealth.desc }}</p>
|
||||||
<div class="stat-foot">
|
<div class="stat-foot">
|
||||||
<div class="stat-foot__line">Fonu / Nginx / DDNS / 证书</div>
|
<div class="stat-foot__line">Fonu / Nginx / DDNS / 证书</div>
|
||||||
<div class="stat-foot__muted">上次检查 {{ formatRelativeTime(status?.started_at) || '刚刚' }}</div>
|
<div class="stat-foot__muted">上次检查 {{ formatRelativeTime(status?.started_at) || '刚刚' }}</div>
|
||||||
</div>
|
</div>
|
||||||
</StatCard>
|
</StatCard>
|
||||||
|
|
||||||
|
<StatCard label="公网穿透" tone="brand">
|
||||||
|
<template #icon><n-icon :component="SwapVerticalOutline" /></template>
|
||||||
|
<template #extra><StatusBadge :value="frpHealth.badgeValue" :text="frpHealth.badgeText" /></template>
|
||||||
|
<template #value>{{ frpHealth.value }}</template>
|
||||||
|
<p class="stat-desc">{{ frpHealth.desc }}</p>
|
||||||
|
<div class="stat-foot">
|
||||||
|
<div class="stat-foot__line mono">{{ frpHealth.version }}</div>
|
||||||
|
<div class="stat-foot__muted">上次应用 {{ frpHealth.applied }}</div>
|
||||||
|
</div>
|
||||||
|
</StatCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="metrics-row">
|
<div class="metrics-row">
|
||||||
@@ -115,6 +126,15 @@
|
|||||||
:download="trafficChart.download"
|
:download="trafficChart.download"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="trafficTop.length > 0" class="traffic-top">
|
||||||
|
<span class="traffic-top__title">流量 Top {{ trafficTop.length }}</span>
|
||||||
|
<ul class="traffic-top__list">
|
||||||
|
<li v-for="item in trafficTop" :key="item.name" class="traffic-top__item">
|
||||||
|
<span class="traffic-top__name" :title="item.name">{{ item.name }}</span>
|
||||||
|
<span class="traffic-top__rate">{{ item.rate }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</FonuCard>
|
</FonuCard>
|
||||||
|
|
||||||
@@ -143,6 +163,123 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</FonuCard>
|
</FonuCard>
|
||||||
|
|
||||||
|
<FonuCard class="panel">
|
||||||
|
<div class="panel__head">
|
||||||
|
<div class="panel-title">
|
||||||
|
<n-icon :component="FlashOutline" class="panel-title__icon panel-title__icon--amber" />
|
||||||
|
<span>性能</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel__body perf-list">
|
||||||
|
<div class="health-item">
|
||||||
|
<div class="health-item__icon health-item__icon--blue">
|
||||||
|
<n-icon :component="PulseOutline" />
|
||||||
|
</div>
|
||||||
|
<div class="health-item__main">
|
||||||
|
<span class="health-item__name">平均响应</span>
|
||||||
|
<span class="health-item__meta">今日访问平均耗时</span>
|
||||||
|
<span class="health-item__detail">{{ avgResponseMeta }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="perf-value">
|
||||||
|
{{ avgResponseLabel }}<small v-if="requestToday > 0">ms</small>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="health-item">
|
||||||
|
<div
|
||||||
|
class="health-item__icon"
|
||||||
|
:class="errorToday > 0 ? 'health-item__icon--error' : 'health-item__icon--green'"
|
||||||
|
>
|
||||||
|
<n-icon :component="AlertCircleOutline" />
|
||||||
|
</div>
|
||||||
|
<div class="health-item__main">
|
||||||
|
<span class="health-item__name">今日异常</span>
|
||||||
|
<span class="health-item__meta">状态码 ≥ 400 的请求</span>
|
||||||
|
<span class="health-item__detail">{{ errorRateLabel }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="perf-value" :class="{ 'perf-value--error': errorToday > 0 }">{{ errorTodayLabel }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</FonuCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ops-row">
|
||||||
|
<FonuCard class="panel">
|
||||||
|
<div class="panel__head">
|
||||||
|
<div class="panel-title">
|
||||||
|
<n-icon :component="ServerOutline" class="panel-title__icon panel-title__icon--teal" />
|
||||||
|
<span>系统资源</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel__body perf-list">
|
||||||
|
<div v-for="item in resourceItems" :key="item.name" class="health-item">
|
||||||
|
<div class="health-item__icon" :class="`health-item__icon--${item.tone}`">
|
||||||
|
<n-icon :component="item.icon" />
|
||||||
|
</div>
|
||||||
|
<div class="health-item__main">
|
||||||
|
<span class="health-item__name">{{ item.name }}</span>
|
||||||
|
<span class="health-item__meta">{{ item.detail }}</span>
|
||||||
|
<span v-if="item.available" class="res-bar" :class="`res-bar--${item.barTone}`">
|
||||||
|
<span class="res-bar__fill" :style="{ width: item.barWidth }" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span class="perf-value">{{ item.value }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</FonuCard>
|
||||||
|
|
||||||
|
<FonuCard class="panel">
|
||||||
|
<div class="panel__head">
|
||||||
|
<div class="panel-title">
|
||||||
|
<n-icon :component="GlobeOutline" class="panel-title__icon panel-title__icon--teal" />
|
||||||
|
<span>中国 IP 段</span>
|
||||||
|
</div>
|
||||||
|
<div class="head-actions">
|
||||||
|
<StatusBadge :value="cidrBadge.value" :text="cidrBadge.text" />
|
||||||
|
<n-button size="tiny" quaternary :loading="refreshingCIDR" @click="refreshCIDR">刷新</n-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel__body perf-list">
|
||||||
|
<div v-for="item in cidrItems" :key="item.name" class="health-item">
|
||||||
|
<div class="health-item__icon" :class="`health-item__icon--${item.tone}`">
|
||||||
|
<n-icon :component="item.icon" />
|
||||||
|
</div>
|
||||||
|
<div class="health-item__main">
|
||||||
|
<span class="health-item__name">{{ item.name }}</span>
|
||||||
|
<span class="health-item__meta">{{ item.detail }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="perf-value">{{ item.value }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</FonuCard>
|
||||||
|
|
||||||
|
<FonuCard class="panel">
|
||||||
|
<div class="panel__head">
|
||||||
|
<div class="panel-title">
|
||||||
|
<n-icon :component="ShieldCheckmarkOutline" class="panel-title__icon panel-title__icon--teal" />
|
||||||
|
<span>通知通道</span>
|
||||||
|
</div>
|
||||||
|
<div class="head-actions">
|
||||||
|
<StatusBadge :value="notifyChannel.badge" :text="notifyChannel.badgeText" />
|
||||||
|
<router-link :to="{ name: 'settings' }" class="card-link">
|
||||||
|
去设置
|
||||||
|
<n-icon :component="ChevronForwardOutline" />
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel__body perf-list">
|
||||||
|
<div v-for="row in notifyChannel.rows" :key="row.name" class="health-item">
|
||||||
|
<div class="health-item__icon" :class="`health-item__icon--${row.tone}`">
|
||||||
|
<n-icon :component="row.icon" />
|
||||||
|
</div>
|
||||||
|
<div class="health-item__main">
|
||||||
|
<span class="health-item__name">{{ row.name }}</span>
|
||||||
|
<span class="health-item__meta">{{ row.detail }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="perf-value">{{ row.value }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</FonuCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bottom-row">
|
<div class="bottom-row">
|
||||||
@@ -200,8 +337,9 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, h, onMounted, onUnmounted, ref } from 'vue'
|
import { computed, h, onMounted, onUnmounted, ref } from 'vue'
|
||||||
import { NDataTable, NIcon, NSpin, NTag, useMessage, type DataTableColumns } from 'naive-ui'
|
import { NButton, NDataTable, NIcon, NSpin, NTag, useMessage, type DataTableColumns } from 'naive-ui'
|
||||||
import {
|
import {
|
||||||
|
AlertCircleOutline,
|
||||||
AppsOutline,
|
AppsOutline,
|
||||||
CheckmarkCircleOutline,
|
CheckmarkCircleOutline,
|
||||||
ChevronForwardOutline,
|
ChevronForwardOutline,
|
||||||
@@ -222,10 +360,18 @@ import { api, asList } from '../api/client'
|
|||||||
import type {
|
import type {
|
||||||
AccessLogEntry,
|
AccessLogEntry,
|
||||||
CertificateRecord,
|
CertificateRecord,
|
||||||
|
ChinaCIDRStatus,
|
||||||
DashboardStatus,
|
DashboardStatus,
|
||||||
DDNSConfig,
|
DDNSConfig,
|
||||||
|
FrpRouteHealthSummary,
|
||||||
|
FrpStatus,
|
||||||
|
NotifyEmailConfig,
|
||||||
|
NotifyTelegramConfig,
|
||||||
|
NotifyType,
|
||||||
|
NotifyWebhookConfig,
|
||||||
ProxyRule,
|
ProxyRule,
|
||||||
ProxyTraffic,
|
ProxyTraffic,
|
||||||
|
SettingsMap,
|
||||||
SystemLogEntry,
|
SystemLogEntry,
|
||||||
} from '../api/types'
|
} from '../api/types'
|
||||||
import EmptyState from '../components/EmptyState.vue'
|
import EmptyState from '../components/EmptyState.vue'
|
||||||
@@ -240,7 +386,14 @@ import {
|
|||||||
buildProxyBindingIndex,
|
buildProxyBindingIndex,
|
||||||
resolveAccessServiceLabel,
|
resolveAccessServiceLabel,
|
||||||
} from '../utils/accessService'
|
} from '../utils/accessService'
|
||||||
import { formatDate, formatLogTime, formatRate, formatRelativeTime, formatUptime } from '../utils/format'
|
import {
|
||||||
|
formatBytes,
|
||||||
|
formatDate,
|
||||||
|
formatLogTime,
|
||||||
|
formatRate,
|
||||||
|
formatRelativeTime,
|
||||||
|
formatUptime,
|
||||||
|
} from '../utils/format'
|
||||||
import { displaySystemLog } from '../utils/logDisplay'
|
import { displaySystemLog } from '../utils/logDisplay'
|
||||||
import { httpStatusKind } from '../utils/status'
|
import { httpStatusKind } from '../utils/status'
|
||||||
|
|
||||||
@@ -251,6 +404,12 @@ const certificates = ref<CertificateRecord[]>([])
|
|||||||
const accessLogs = ref<AccessLogEntry[]>([])
|
const accessLogs = ref<AccessLogEntry[]>([])
|
||||||
const proxyRules = ref<ProxyRule[]>([])
|
const proxyRules = ref<ProxyRule[]>([])
|
||||||
const systemLogs = ref<SystemLogEntry[]>([])
|
const systemLogs = ref<SystemLogEntry[]>([])
|
||||||
|
const frpStatus = ref<FrpStatus | null>(null)
|
||||||
|
const routeHealth = ref<FrpRouteHealthSummary | null>(null)
|
||||||
|
const loadingRemoteHealth = ref(false)
|
||||||
|
const chinaCIDR = ref<ChinaCIDRStatus | null>(null)
|
||||||
|
const refreshingCIDR = ref(false)
|
||||||
|
const settings = ref<SettingsMap | null>(null)
|
||||||
const proxyBindingIndex = computed(() => buildProxyBindingIndex(proxyRules.value))
|
const proxyBindingIndex = computed(() => buildProxyBindingIndex(proxyRules.value))
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const loadError = ref('')
|
const loadError = ref('')
|
||||||
@@ -259,6 +418,7 @@ type RatePoint = { at: number; upload: number; download: number }
|
|||||||
const rateHistory = ref<RatePoint[]>([])
|
const rateHistory = ref<RatePoint[]>([])
|
||||||
const RATE_HISTORY_MS = 5 * 60 * 1000
|
const RATE_HISTORY_MS = 5 * 60 * 1000
|
||||||
let trafficTimer: ReturnType<typeof setInterval> | null = null
|
let trafficTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
let statusTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
function timeGreeting(hour: number): string {
|
function timeGreeting(hour: number): string {
|
||||||
if (hour >= 5 && hour < 12) return '上午好'
|
if (hour >= 5 && hour < 12) return '上午好'
|
||||||
@@ -335,7 +495,99 @@ const certSubLabel = computed(() => {
|
|||||||
return '证书需要关注'
|
return '证书需要关注'
|
||||||
})
|
})
|
||||||
|
|
||||||
const coreServiceCountLabel = computed(() => `${healthItems.value.length} 个`)
|
// 「服务」卡按四项实际健康状态聚合:任一项非 running/ok 都降级并点名,避免写死「正常」
|
||||||
|
const serviceHealth = computed(() => {
|
||||||
|
const items = healthItems.value
|
||||||
|
const bad = items.filter((item) => item.status !== 'running' && item.status !== 'ok')
|
||||||
|
if (bad.length === 0) {
|
||||||
|
return { value: `${items.length} 个`, desc: '核心服务运行正常', badgeValue: 'ok', badgeText: '正常' }
|
||||||
|
}
|
||||||
|
const severe = bad.some((item) => item.status === 'error' || item.status === 'stopped')
|
||||||
|
return {
|
||||||
|
value: `${items.length - bad.length}/${items.length} 正常`,
|
||||||
|
desc: `异常:${bad.map((item) => item.name).join('、')}`,
|
||||||
|
badgeValue: severe ? 'error' : 'warning',
|
||||||
|
badgeText: '需关注',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 今日异常与平均响应:/api/status 早已返回,此前未展示
|
||||||
|
const requestToday = computed(() => status.value?.request_today ?? 0)
|
||||||
|
const errorToday = computed(() => status.value?.error_today ?? 0)
|
||||||
|
// 今日无请求时不显示 0ms / 0(看起来像真实测量值),统一显示为 —
|
||||||
|
const avgResponseLabel = computed(() =>
|
||||||
|
requestToday.value > 0 ? String(Math.round(status.value?.avg_response_ms ?? 0)) : '—',
|
||||||
|
)
|
||||||
|
const avgResponseMeta = computed(() =>
|
||||||
|
requestToday.value > 0 ? `共 ${requestToday.value.toLocaleString()} 次请求` : '今日暂无请求',
|
||||||
|
)
|
||||||
|
const errorTodayLabel = computed(() => (requestToday.value > 0 ? errorToday.value.toLocaleString() : '—'))
|
||||||
|
const errorRateLabel = computed(() => {
|
||||||
|
if (requestToday.value <= 0) return '今日暂无请求'
|
||||||
|
return `占今日请求 ${((errorToday.value / requestToday.value) * 100).toFixed(1)}%`
|
||||||
|
})
|
||||||
|
|
||||||
|
// 公网穿透(FRP):frpc 客户端侧状态
|
||||||
|
const frpHealth = computed(() => {
|
||||||
|
const frp = frpStatus.value
|
||||||
|
if (!frp) {
|
||||||
|
return {
|
||||||
|
value: '未检测',
|
||||||
|
desc: '未获取到 frpc 状态',
|
||||||
|
badgeValue: 'none',
|
||||||
|
badgeText: '未检测',
|
||||||
|
version: 'frpc 未检测',
|
||||||
|
applied: '暂无',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!frp.configured) {
|
||||||
|
return {
|
||||||
|
value: '未配置',
|
||||||
|
desc: '尚未配置 FRP 服务端',
|
||||||
|
badgeValue: 'disabled',
|
||||||
|
badgeText: '未配置',
|
||||||
|
version: 'frpc 未安装',
|
||||||
|
applied: '暂无',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const running = frp.status === 'running'
|
||||||
|
return {
|
||||||
|
value: `${frp.enabled_proxies} 条`,
|
||||||
|
desc: running ? 'frpc 已连接服务端' : frp.last_error ? `frpc 异常:${frp.last_error}` : 'frpc 已停止',
|
||||||
|
badgeValue: running ? 'running' : frp.status === 'stopped' ? 'stopped' : 'warning',
|
||||||
|
badgeText: running ? '运行中' : frp.status === 'stopped' ? '已停止' : '未检测',
|
||||||
|
version: frp.frpc_version ? `frpc ${frp.frpc_version}` : 'frpc 版本未知',
|
||||||
|
applied: formatRelativeTime(frp.last_reloaded_at) || '暂无',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 公网反代部署健康:逐台服务端联网采集,单独请求(见 loadRouteHealth)
|
||||||
|
const remoteHealth = computed(() => {
|
||||||
|
if (loadingRemoteHealth.value) return { status: 'none', label: '检查中' }
|
||||||
|
const summary = routeHealth.value
|
||||||
|
if (!summary) return { status: 'none', label: '未获取' }
|
||||||
|
if (summary.servers === 0) return { status: 'disabled', label: '未配置' }
|
||||||
|
if (summary.routes === 0) return { status: 'none', label: '暂无路由' }
|
||||||
|
const allHealthy = summary.healthy === summary.routes && !summary.failed_servers?.length
|
||||||
|
return { status: allHealthy ? 'ok' : 'warning', label: allHealthy ? '全部健康' : '需关注' }
|
||||||
|
})
|
||||||
|
|
||||||
|
const remoteHealthMeta = computed(() => {
|
||||||
|
if (loadingRemoteHealth.value) return '正在采集公网侧状态…'
|
||||||
|
const summary = routeHealth.value
|
||||||
|
if (!summary) return '未能获取公网侧状态'
|
||||||
|
if (summary.servers === 0) return '尚未配置 FRP 服务端'
|
||||||
|
if (summary.routes === 0) return '尚无已配置域名的穿透规则'
|
||||||
|
const issues: string[] = []
|
||||||
|
if (summary.dns_failed) issues.push(`DNS ${summary.dns_failed}`)
|
||||||
|
if (summary.cert_missing) issues.push(`证书 ${summary.cert_missing}`)
|
||||||
|
if (summary.tunnel_down) issues.push(`隧道 ${summary.tunnel_down}`)
|
||||||
|
if (summary.service_down) issues.push(`服务 ${summary.service_down}`)
|
||||||
|
const parts = [`${summary.healthy}/${summary.routes} 条健康`]
|
||||||
|
if (issues.length > 0) parts.push(issues.join(' · '))
|
||||||
|
if (summary.failed_servers?.length) parts.push(`采集失败 ${summary.failed_servers.join('、')}`)
|
||||||
|
return parts.join('|')
|
||||||
|
})
|
||||||
|
|
||||||
const trafficTotals = computed(() => {
|
const trafficTotals = computed(() => {
|
||||||
let uploadRate = 0
|
let uploadRate = 0
|
||||||
@@ -368,6 +620,21 @@ const trafficChart = computed(() => {
|
|||||||
return { labels, upload, download }
|
return { labels, upload, download }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 当前流量 Top 5:直接复用已有的逐规则流量轮询,无需额外接口
|
||||||
|
const trafficTop = computed(() =>
|
||||||
|
Object.entries(trafficByRule.value)
|
||||||
|
.map(([key, stats]) => {
|
||||||
|
const id = Number(key)
|
||||||
|
const rule = proxyRules.value.find((item) => item.id === id)
|
||||||
|
const name = rule ? rule.name?.trim() || rule.domain || `规则 #${id}` : `规则 #${id}`
|
||||||
|
return { name, rate: stats.upload_rate + stats.download_rate }
|
||||||
|
})
|
||||||
|
.filter((row) => row.rate > 0)
|
||||||
|
.sort((a, b) => b.rate - a.rate)
|
||||||
|
.slice(0, 5)
|
||||||
|
.map((row) => ({ name: row.name, rate: formatRate(row.rate) })),
|
||||||
|
)
|
||||||
|
|
||||||
const calendarHourLabels = Array.from({ length: 12 }, (_, i) =>
|
const calendarHourLabels = Array.from({ length: 12 }, (_, i) =>
|
||||||
`${String(i * 2).padStart(2, '0')}:00`,
|
`${String(i * 2).padStart(2, '0')}:00`,
|
||||||
)
|
)
|
||||||
@@ -426,6 +693,15 @@ const healthItems = computed(() => [
|
|||||||
icon: ShieldCheckmarkOutline,
|
icon: ShieldCheckmarkOutline,
|
||||||
tone: 'green',
|
tone: 'green',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: '公网反代',
|
||||||
|
desc: '公网侧部署与健康',
|
||||||
|
meta: remoteHealthMeta.value,
|
||||||
|
status: remoteHealth.value.status,
|
||||||
|
label: remoteHealth.value.label,
|
||||||
|
icon: GlobeOutline,
|
||||||
|
tone: 'green',
|
||||||
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
const accessColumns = computed<DataTableColumns<AccessLogEntry>>(() => [
|
const accessColumns = computed<DataTableColumns<AccessLogEntry>>(() => [
|
||||||
@@ -544,22 +820,269 @@ function stopTrafficPoll() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CPU 占用率需要两次采样才有意义,因此 /api/status 每 15 秒刷新一次
|
||||||
|
async function refreshStatus() {
|
||||||
|
try {
|
||||||
|
status.value = await api.getStatus()
|
||||||
|
} catch {
|
||||||
|
// 静默失败:保留上一次快照
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startStatusPoll() {
|
||||||
|
stopStatusPoll()
|
||||||
|
statusTimer = setInterval(refreshStatus, 15000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopStatusPoll() {
|
||||||
|
if (statusTimer) {
|
||||||
|
clearInterval(statusTimer)
|
||||||
|
statusTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 页面隐藏时暂停轮询,避免后台标签页持续请求
|
||||||
|
function handleVisibilityChange() {
|
||||||
|
if (document.hidden) {
|
||||||
|
stopTrafficPoll()
|
||||||
|
stopStatusPoll()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
startTrafficPoll()
|
||||||
|
startStatusPoll()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 系统资源:/api/status 返回的宿主机 CPU / 内存 / 磁盘(容器内为宿主机视角)
|
||||||
|
const resourcesAvailable = computed(
|
||||||
|
() => (status.value?.mem_total_bytes ?? 0) > 0 || (status.value?.disk_total_bytes ?? 0) > 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
function usagePercent(used?: number, total?: number): number {
|
||||||
|
if (!used || !total || total <= 0) return 0
|
||||||
|
return Math.min(100, (used / total) * 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
const resourceItems = computed(() => {
|
||||||
|
const data = status.value
|
||||||
|
const rows = [
|
||||||
|
{
|
||||||
|
name: 'CPU',
|
||||||
|
icon: AppsOutline,
|
||||||
|
tone: 'blue',
|
||||||
|
detail: '整机 CPU 占用率',
|
||||||
|
percent: Math.min(100, Math.max(0, data?.cpu_percent ?? 0)),
|
||||||
|
available: resourcesAvailable.value,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '内存',
|
||||||
|
icon: ServerOutline,
|
||||||
|
tone: 'green',
|
||||||
|
detail: `${formatBytes(data?.mem_used_bytes ?? 0)} / ${formatBytes(data?.mem_total_bytes ?? 0)}`,
|
||||||
|
percent: usagePercent(data?.mem_used_bytes, data?.mem_total_bytes),
|
||||||
|
available: (data?.mem_total_bytes ?? 0) > 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '磁盘',
|
||||||
|
icon: ListOutline,
|
||||||
|
tone: 'teal',
|
||||||
|
detail: `${formatBytes(data?.disk_used_bytes ?? 0)} / ${formatBytes(data?.disk_total_bytes ?? 0)}`,
|
||||||
|
percent: usagePercent(data?.disk_used_bytes, data?.disk_total_bytes),
|
||||||
|
available: (data?.disk_total_bytes ?? 0) > 0,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
return rows.map((row) => ({
|
||||||
|
...row,
|
||||||
|
value: row.available ? `${Math.round(row.percent)}%` : '—',
|
||||||
|
barWidth: `${row.percent.toFixed(1)}%`,
|
||||||
|
barTone: row.percent >= 90 ? 'danger' : row.percent >= 75 ? 'warn' : 'ok',
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
// 中国 IP 段:安全模块(china_only)依赖的国内地址库
|
||||||
|
const cidrBadge = computed(() => {
|
||||||
|
const data = chinaCIDR.value
|
||||||
|
if (!data) return { value: 'none', text: '未获取' }
|
||||||
|
if (data.last_error) return { value: 'error', text: '更新失败' }
|
||||||
|
if (!data.ready) return { value: 'warning', text: '未就绪' }
|
||||||
|
return { value: 'ok', text: '就绪' }
|
||||||
|
})
|
||||||
|
|
||||||
|
const cidrItems = computed(() => {
|
||||||
|
const data = chinaCIDR.value
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: 'IPv4 条目',
|
||||||
|
icon: GlobeOutline,
|
||||||
|
tone: 'blue',
|
||||||
|
detail: '国内 IPv4 地址段',
|
||||||
|
value: (data?.entry_count_v4 ?? 0).toLocaleString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'IPv6 条目',
|
||||||
|
icon: WifiOutline,
|
||||||
|
tone: 'teal',
|
||||||
|
detail: '国内 IPv6 地址段',
|
||||||
|
value: (data?.entry_count_v6 ?? 0).toLocaleString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '上次更新',
|
||||||
|
icon: DocumentTextOutline,
|
||||||
|
tone: 'green',
|
||||||
|
detail: data?.last_error ? `上次错误:${data.last_error}` : '按设置周期自动更新',
|
||||||
|
value: formatRelativeTime(data?.updated_at) || '从未更新',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadChinaCIDR() {
|
||||||
|
try {
|
||||||
|
chinaCIDR.value = await api.getChinaCIDRStatus()
|
||||||
|
} catch {
|
||||||
|
chinaCIDR.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 中国 IP 段更新是后端后台任务:刷新后按 2 秒轮询直到就绪或结束
|
||||||
|
let cidrPollTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
function stopCIDRPoll() {
|
||||||
|
if (cidrPollTimer) {
|
||||||
|
clearInterval(cidrPollTimer)
|
||||||
|
cidrPollTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollCIDR() {
|
||||||
|
await loadChinaCIDR()
|
||||||
|
const data = chinaCIDR.value
|
||||||
|
if (!data || data.ready || !data.updating) {
|
||||||
|
stopCIDRPoll()
|
||||||
|
if (data?.ready) message.success('中国 IP 段已更新')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshCIDR() {
|
||||||
|
refreshingCIDR.value = true
|
||||||
|
stopCIDRPoll()
|
||||||
|
try {
|
||||||
|
chinaCIDR.value = await api.refreshChinaCIDR()
|
||||||
|
if (chinaCIDR.value.ready) {
|
||||||
|
message.success(
|
||||||
|
`中国 IP 段已更新:IPv4 ${chinaCIDR.value.entry_count_v4} 条,IPv6 ${chinaCIDR.value.entry_count_v6} 条`,
|
||||||
|
)
|
||||||
|
} else if (chinaCIDR.value.updating) {
|
||||||
|
cidrPollTimer = setInterval(() => void pollCIDR(), 2000)
|
||||||
|
} else {
|
||||||
|
message.warning('更新完成,但数据尚未就绪,请查看错误信息')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error instanceof Error ? error.message : '更新中国 IP 段失败')
|
||||||
|
} finally {
|
||||||
|
refreshingCIDR.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通知通道:设置里的 notify_type + 各通道 JSON(has_* 标记由后端保存时写入)
|
||||||
|
function parseSettingsJSON<T>(raw?: string): T | null {
|
||||||
|
if (!raw) return null
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as T
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const notifyChannel = computed(() => {
|
||||||
|
const map = settings.value ?? {}
|
||||||
|
const type = (map.notify_type ?? '') as NotifyType
|
||||||
|
const telegram = parseSettingsJSON<NotifyTelegramConfig>(map.notify_telegram_json)
|
||||||
|
const webhook = parseSettingsJSON<NotifyWebhookConfig>(map.notify_webhook_json)
|
||||||
|
const email = parseSettingsJSON<NotifyEmailConfig>(map.notify_email_json)
|
||||||
|
const events = Object.entries(map).filter(
|
||||||
|
([key, value]) => key.startsWith('notify_on_') && (value === '1' || value === 'true'),
|
||||||
|
).length
|
||||||
|
|
||||||
|
let name = '未启用'
|
||||||
|
let secret = '通知凭据'
|
||||||
|
let configured = false
|
||||||
|
if (type === 'telegram') {
|
||||||
|
name = 'Telegram'
|
||||||
|
secret = 'Bot Token'
|
||||||
|
configured = Boolean(telegram?.has_bot_token)
|
||||||
|
} else if (type === 'webhook') {
|
||||||
|
name = `Webhook(${webhook?.provider || '未选择服务商'})`
|
||||||
|
secret = 'Webhook 密钥'
|
||||||
|
configured = Boolean(webhook?.has_secret)
|
||||||
|
} else if (type === 'email') {
|
||||||
|
name = '邮件 SMTP'
|
||||||
|
secret = 'SMTP 密码'
|
||||||
|
configured = Boolean(email?.has_password)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
badge: type ? (configured ? 'ok' : 'warning') : 'disabled',
|
||||||
|
badgeText: type ? (configured ? '已启用' : '待补全') : '未启用',
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
name: '当前通道',
|
||||||
|
icon: CheckmarkCircleOutline,
|
||||||
|
tone: 'blue',
|
||||||
|
detail: type ? '告警通知的发送方式' : '尚未选择通知方式',
|
||||||
|
value: name,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '密钥凭据',
|
||||||
|
icon: ShieldCheckmarkOutline,
|
||||||
|
tone: 'green',
|
||||||
|
detail: secret,
|
||||||
|
value: configured ? '已保存' : '未保存',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '已开启告警',
|
||||||
|
icon: PulseOutline,
|
||||||
|
tone: 'teal',
|
||||||
|
detail: 'DDNS、证书、登录失败等事件',
|
||||||
|
value: `${events} 项`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 公网侧健康需逐台服务端联网采集,单独请求,避免拖慢整页加载
|
||||||
|
async function loadRouteHealth() {
|
||||||
|
loadingRemoteHealth.value = true
|
||||||
|
try {
|
||||||
|
routeHealth.value = await api.getFrpRouteHealthSummary()
|
||||||
|
} catch {
|
||||||
|
routeHealth.value = null
|
||||||
|
} finally {
|
||||||
|
loadingRemoteHealth.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
loadError.value = ''
|
loadError.value = ''
|
||||||
try {
|
try {
|
||||||
const [s, access, system, certs, rules] = await Promise.all([
|
const [s, access, system, certs, rules, frp, cidr, config] = await Promise.all([
|
||||||
api.getStatus(),
|
api.getStatus(),
|
||||||
api.getAccessLogs({ limit: 10 }),
|
api.getAccessLogs({ limit: 10 }),
|
||||||
api.getSystemLogs({ limit: 10 }),
|
api.getSystemLogs({ limit: 10 }),
|
||||||
api.listCertificates(),
|
api.listCertificates(),
|
||||||
api.listProxies().catch((): ProxyRule[] => []),
|
api.listProxies().catch((): ProxyRule[] => []),
|
||||||
|
api.getFrpStatus().catch((): FrpStatus | null => null),
|
||||||
|
api.getChinaCIDRStatus().catch((): ChinaCIDRStatus | null => null),
|
||||||
|
api.getSettings().catch((): SettingsMap | null => null),
|
||||||
])
|
])
|
||||||
status.value = s
|
status.value = s
|
||||||
accessLogs.value = asList(access)
|
accessLogs.value = asList(access)
|
||||||
proxyRules.value = asList(rules)
|
proxyRules.value = asList(rules)
|
||||||
systemLogs.value = asList(system)
|
systemLogs.value = asList(system)
|
||||||
certificates.value = asList(certs)
|
certificates.value = asList(certs)
|
||||||
|
frpStatus.value = frp
|
||||||
|
chinaCIDR.value = cidr
|
||||||
|
settings.value = config
|
||||||
|
void loadRouteHealth()
|
||||||
api.listDDNSLite()
|
api.listDDNSLite()
|
||||||
.then((ddns) => {
|
.then((ddns) => {
|
||||||
ddnsConfigs.value = asList(ddns)
|
ddnsConfigs.value = asList(ddns)
|
||||||
@@ -577,18 +1100,19 @@ onMounted(() => {
|
|||||||
load()
|
load()
|
||||||
refreshTraffic()
|
refreshTraffic()
|
||||||
startTrafficPoll()
|
startTrafficPoll()
|
||||||
|
startStatusPoll()
|
||||||
|
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(stopTrafficPoll)
|
onUnmounted(() => {
|
||||||
|
stopTrafficPoll()
|
||||||
|
stopStatusPoll()
|
||||||
|
stopCIDRPoll()
|
||||||
|
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.dashboard {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--fonu-space-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-banner {
|
.hero-banner {
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -710,7 +1234,7 @@ html.dark .hero-art {
|
|||||||
|
|
||||||
.stats-row {
|
.stats-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, 1fr);
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
gap: var(--fonu-space-4);
|
gap: var(--fonu-space-4);
|
||||||
margin-bottom: var(--fonu-space-4);
|
margin-bottom: var(--fonu-space-4);
|
||||||
}
|
}
|
||||||
@@ -751,40 +1275,6 @@ html.dark .hero-art {
|
|||||||
color: var(--fonu-text-muted);
|
color: var(--fonu-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-highlight {
|
|
||||||
color: var(--fonu-brand);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.proxy-metrics {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--fonu-space-5);
|
|
||||||
margin-top: var(--fonu-space-3);
|
|
||||||
padding-top: var(--fonu-space-3);
|
|
||||||
border-top: 1px solid var(--fonu-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.proxy-metric {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.proxy-metric__val {
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--fonu-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.proxy-metric__val--ok {
|
|
||||||
color: var(--fonu-brand);
|
|
||||||
}
|
|
||||||
|
|
||||||
.proxy-metric__lbl {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--fonu-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.traffic-panel {
|
.traffic-panel {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -855,9 +1345,54 @@ html.dark .hero-art {
|
|||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.traffic-top {
|
||||||
|
margin-top: var(--fonu-space-3);
|
||||||
|
padding-top: var(--fonu-space-3);
|
||||||
|
border-top: 1px solid var(--fonu-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.traffic-top__title {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--fonu-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.traffic-top__list {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.traffic-top__item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: var(--fonu-space-3);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.traffic-top__name {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: var(--fonu-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.traffic-top__rate {
|
||||||
|
color: var(--fonu-text-secondary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: var(--fonu-space-4);
|
||||||
|
margin-bottom: var(--fonu-space-4);
|
||||||
|
}
|
||||||
|
|
||||||
.metrics-row {
|
.metrics-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 3fr 3fr 2fr;
|
grid-template-columns: 3fr 3fr 2fr 2fr;
|
||||||
gap: var(--fonu-space-4);
|
gap: var(--fonu-space-4);
|
||||||
margin-bottom: var(--fonu-space-4);
|
margin-bottom: var(--fonu-space-4);
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
@@ -946,66 +1481,69 @@ html.dark .hero-art {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.perf-panel :deep(.fonu-card__body) {
|
/* 性能面板的行沿用「运行状态」的 .health-item,但不随面板高度被拉高(该面板只有两项指标) */
|
||||||
display: flex;
|
.perf-list {
|
||||||
flex-direction: column;
|
|
||||||
justify-content: stretch;
|
|
||||||
height: 100%;
|
|
||||||
padding: var(--fonu-space-5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.perf-panel {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.perf-block {
|
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: var(--fonu-space-3) 0;
|
gap: 8px;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.perf-block__label {
|
.perf-list .health-item {
|
||||||
font-size: 12px;
|
flex: 0 0 auto;
|
||||||
color: var(--fonu-text-muted);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.perf-block__value {
|
.perf-value {
|
||||||
margin-top: 6px;
|
justify-self: end;
|
||||||
font-size: 30px;
|
font-size: 16px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--fonu-text);
|
color: var(--fonu-text);
|
||||||
line-height: 1.1;
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.perf-block__value--error {
|
.perf-value small {
|
||||||
color: var(--fonu-error);
|
margin-left: 2px;
|
||||||
}
|
font-size: 12px;
|
||||||
|
|
||||||
.perf-block__value small {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--fonu-text-secondary);
|
color: var(--fonu-text-secondary);
|
||||||
margin-left: 2px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.perf-block__meta {
|
.perf-value--error {
|
||||||
margin-top: 4px;
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--fonu-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.perf-block__meta--error {
|
|
||||||
color: var(--fonu-error);
|
color: var(--fonu-error);
|
||||||
opacity: 0.85;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.perf-divider {
|
.head-actions {
|
||||||
height: 1px;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--fonu-space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.res-bar {
|
||||||
|
display: block;
|
||||||
|
height: 4px;
|
||||||
|
margin-top: 6px;
|
||||||
|
border-radius: 2px;
|
||||||
background: var(--fonu-border);
|
background: var(--fonu-border);
|
||||||
margin: var(--fonu-space-2) 0;
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.res-bar__fill {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--fonu-brand);
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.res-bar--warn .res-bar__fill {
|
||||||
|
background: var(--fonu-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.res-bar--danger .res-bar__fill {
|
||||||
|
background: var(--fonu-error);
|
||||||
}
|
}
|
||||||
|
|
||||||
.health-panel .panel__head {
|
.health-panel .panel__head {
|
||||||
@@ -1070,6 +1608,11 @@ html.dark .hero-art {
|
|||||||
color: #10b981;
|
color: #10b981;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.health-item__icon--error {
|
||||||
|
background: color-mix(in srgb, var(--fonu-error) 12%, transparent);
|
||||||
|
color: var(--fonu-error);
|
||||||
|
}
|
||||||
|
|
||||||
.health-item__main {
|
.health-item__main {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1200,15 +1743,16 @@ html.dark .hero-art {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1199px) {
|
@media (max-width: 1199px) {
|
||||||
.stats-row { grid-template-columns: repeat(2, 1fr); }
|
.stats-row { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||||
.metrics-row { grid-template-columns: 1fr 1fr; }
|
.metrics-row { grid-template-columns: 1fr 1fr; }
|
||||||
.health-panel { grid-column: 1 / 3; }
|
.ops-row { grid-template-columns: 1fr 1fr; }
|
||||||
.bottom-row { grid-template-columns: 1fr; }
|
.bottom-row { grid-template-columns: 1fr; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
.stats-row { grid-template-columns: 1fr; }
|
.stats-row { grid-template-columns: 1fr; }
|
||||||
.metrics-row { grid-template-columns: 1fr; }
|
.metrics-row { grid-template-columns: 1fr; }
|
||||||
|
.ops-row { grid-template-columns: 1fr; }
|
||||||
.health-panel { grid-column: auto; }
|
.health-panel { grid-column: auto; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user