feat(agent): agent 0.11.0 新增证书清单与 Nginx 重载,公网 Agent 页并排展示 VPS 资源与证书(v1.2.5)
agent 0.11.0:新增 GET /api/v1/certs(遍历证书目录、crypto/x509 解析 fullchain.pem 到期时间、按剩余天数排序、解析失败记 warnings)与 POST /api/v1/nginx/reload(回传 nginx -t 与 reload 的失败原因,成功后失效可用性探测缓存) 应用侧:AgentClient.Certs / NginxReload + service / handler / router;agent 版本要求提到 ≥ 0.11.0(旧版本仍按 404/405 降级为升级提示) 前端:公网 Agent 页新增「VPS 证书」卡(域名/剩余天数/Fonu 证书库/到期时间,与 Fonu 证书库交叉核对);Nginx 卡新增「重载 Nginx」并显示失败原因;VPS 资源与 VPS 证书改为并排各占半行(dep-grid 6 列 + span 分配,窄屏复位) 二进制:内嵌 fonu-agent-linux-amd64 / arm64 重编;VERSION → 1.2.5
This commit is contained in:
@@ -2,6 +2,24 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## [v1.2.5] - 2026-09-24
|
||||||
|
|
||||||
|
### 新增
|
||||||
|
|
||||||
|
- 公网 Agent 能力扩展(agent 0.11.0):新增 `GET /api/v1/certs`(VPS 证书清单——遍历证书目录、用标准库 `crypto/x509` 解析 `fullchain.pem` 的到期时间、按剩余天数排序、解析失败的单条记入 `warnings` 不中断整轮)与 `POST /api/v1/nginx/reload`(显式校验并重载 Nginx,分别回传 `nginx -t` 与 reload 的失败原因;成功后让可用性探测缓存失效,避免页面继续显示旧结论)
|
||||||
|
- 公网 Agent 页:新增「VPS 证书」卡(域名 / 剩余天数 / **Fonu 证书库** / 到期时间),与 Fonu 证书库交叉核对(含通配符匹配),宝塔自行申请的证书也能看到;剩余不足 30 天转橙、已过期转红加粗
|
||||||
|
- 公网 Agent 页:Nginx 卡片新增「重载 Nginx」按钮,校验 / 重载失败原因(`nginx -t` 输出或 reload 的 stderr)直接显示在卡内
|
||||||
|
|
||||||
|
### 改进
|
||||||
|
|
||||||
|
- 公网 Agent 页布局:「VPS 资源」与「VPS 证书」改为并排各占半行——`dep-grid` 由 3 列改为 6 列并按 span 分配(原有三张卡仍每行三张),半宽下资源三项指标等分、平均负载与运行时长落到卡底一行,证书表列宽收紧;≤1199px 复位跨度避免 `span` 在列数变少后撑破网格
|
||||||
|
- 应用侧对 agent 端点的版本要求提升到 **≥ 0.11.0**(旧 agent 仍按 404/405 统一降级为「请升级 Agent」提示)
|
||||||
|
|
||||||
|
### 变更
|
||||||
|
|
||||||
|
- `AgentVersion` 0.10.0 → **0.11.0**;内嵌 `fonu-agent-linux-amd64`(10.0 MB)与 `-arm64`(9.3 MB)已重编,VPS 上需点「升级 Agent」才会生效
|
||||||
|
- 版本号提升到 1.2.5(版本单一来源仍是根目录 `VERSION`)
|
||||||
|
|
||||||
## [v1.2.4] - 2026-09-24
|
## [v1.2.4] - 2026-09-24
|
||||||
|
|
||||||
### 新增
|
### 新增
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package agent
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/pem"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"math"
|
"math"
|
||||||
@@ -10,6 +12,7 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -197,6 +200,115 @@ func tailFile(path string, lines int, maxLine int) ([]string, bool, error) {
|
|||||||
return out, truncated, nil
|
return out, truncated, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// vpsCertificate VPS 证书目录里的一张证书
|
||||||
|
// 注意:证书按 <CertsDir>/<域名>/fullchain.pem + privkey.pem 存放(与 putCertificate 一致)。
|
||||||
|
type vpsCertificate struct {
|
||||||
|
Domain string `json:"domain"`
|
||||||
|
CertPath string `json:"cert_path"`
|
||||||
|
KeyPath string `json:"key_path"`
|
||||||
|
NotAfter string `json:"not_after,omitempty"`
|
||||||
|
DaysLeft int `json:"days_left"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// certs 列出 VPS 证书目录下的域名与到期时间:用于与 Fonu 证书库交叉核对,也能看到宝塔自行申请的证书
|
||||||
|
func (s *Server) certs(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
dir := strings.TrimSpace(s.cfg.CertsDir)
|
||||||
|
payload := map[string]any{
|
||||||
|
"certs": []vpsCertificate{}, "warnings": []string{}, "dir": dir,
|
||||||
|
"checked_at": time.Now().UTC().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
if dir == "" {
|
||||||
|
writeJSON(w, http.StatusOK, payload)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
writeJSON(w, http.StatusOK, payload)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
status := http.StatusInternalServerError
|
||||||
|
if os.IsPermission(err) {
|
||||||
|
status = http.StatusForbidden
|
||||||
|
}
|
||||||
|
writeJSON(w, status, map[string]string{"error": fmt.Sprintf("读取证书目录失败:%v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
certs := make([]vpsCertificate, 0, len(entries))
|
||||||
|
warnings := make([]string, 0)
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
domain := entry.Name()
|
||||||
|
item := vpsCertificate{
|
||||||
|
Domain: domain,
|
||||||
|
CertPath: filepath.Join(dir, domain, "fullchain.pem"),
|
||||||
|
KeyPath: filepath.Join(dir, domain, "privkey.pem"),
|
||||||
|
}
|
||||||
|
notAfter, err := readCertificateNotAfter(item.CertPath)
|
||||||
|
if err != nil {
|
||||||
|
warnings = append(warnings, fmt.Sprintf("%s:%v", domain, err))
|
||||||
|
} else {
|
||||||
|
item.NotAfter = notAfter.UTC().Format(time.RFC3339)
|
||||||
|
item.DaysLeft = int(notAfter.Sub(now).Hours() / 24)
|
||||||
|
}
|
||||||
|
certs = append(certs, item)
|
||||||
|
}
|
||||||
|
// 到期最快的排在前面,便于直接看出哪张该续了
|
||||||
|
sort.Slice(certs, func(i, j int) bool { return certs[i].DaysLeft < certs[j].DaysLeft })
|
||||||
|
|
||||||
|
payload["certs"] = certs
|
||||||
|
payload["warnings"] = warnings
|
||||||
|
writeJSON(w, http.StatusOK, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// readCertificateNotAfter 解析 PEM 证书的到期时间
|
||||||
|
func readCertificateNotAfter(path string) (time.Time, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, err
|
||||||
|
}
|
||||||
|
block, _ := pem.Decode(data)
|
||||||
|
if block == nil {
|
||||||
|
return time.Time{}, fmt.Errorf("不是有效的 PEM 证书")
|
||||||
|
}
|
||||||
|
cert, err := x509.ParseCertificate(block.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, err
|
||||||
|
}
|
||||||
|
return cert.NotAfter, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// nginxReload 显式校验并重载 Nginx,把校验/重载失败的原因回传。
|
||||||
|
// 这里不加 s.mu:validateNginx / reloadNginx 的调用方约定(见 writeRoute 注释)是外部持锁,
|
||||||
|
// 而本端点是用户显式触发的排查动作,不加锁可避免与路由写入相互阻塞。
|
||||||
|
func (s *Server) nginxReload(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
payload := map[string]any{"checked_at": time.Now().UTC().Format(time.RFC3339)}
|
||||||
|
if err := s.validateNginx(); err != nil {
|
||||||
|
payload["ok"] = false
|
||||||
|
payload["test_ok"] = false
|
||||||
|
payload["test_error"] = err.Error()
|
||||||
|
writeJSON(w, http.StatusOK, payload)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
payload["test_ok"] = true
|
||||||
|
if err := s.reloadNginx(); err != nil {
|
||||||
|
payload["ok"] = false
|
||||||
|
payload["reload_ok"] = false
|
||||||
|
payload["reload_error"] = err.Error()
|
||||||
|
writeJSON(w, http.StatusOK, payload)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 重载成功后让 Nginx 可用性探测的缓存失效,避免页面继续显示旧结论
|
||||||
|
s.invalidateNginxProbe()
|
||||||
|
payload["ok"] = true
|
||||||
|
payload["reload_ok"] = true
|
||||||
|
writeJSON(w, http.StatusOK, payload)
|
||||||
|
}
|
||||||
|
|
||||||
// frpsConfig 回读 VPS 上的 frps.toml,供应用侧与「即将下发的内容」比对,检出被手工修改的情况
|
// frpsConfig 回读 VPS 上的 frps.toml,供应用侧与「即将下发的内容」比对,检出被手工修改的情况
|
||||||
func (s *Server) frpsConfig(w http.ResponseWriter, _ *http.Request) {
|
func (s *Server) frpsConfig(w http.ResponseWriter, _ *http.Request) {
|
||||||
path := strings.TrimSpace(s.cfg.FrpsConfigPath)
|
path := strings.TrimSpace(s.cfg.FrpsConfigPath)
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ import (
|
|||||||
// 0.9.1:status 补充 nginx_running / nginx_version,供页面展示 Nginx 状态卡片。
|
// 0.9.1:status 补充 nginx_running / nginx_version,供页面展示 Nginx 状态卡片。
|
||||||
// 0.9.2:Basic Auth 哈希改为纯 Go apr1(去掉对 VPS openssl 的依赖);安装脚本 token 改用 /dev/urandom。
|
// 0.9.2:Basic Auth 哈希改为纯 Go apr1(去掉对 VPS openssl 的依赖);安装脚本 token 改用 /dev/urandom。
|
||||||
// 0.10.0:新增 /api/v1/metrics 主机资源采集、/api/v1/logs/nginx 日志读取、/api/v1/frps/config 配置回读。
|
// 0.10.0:新增 /api/v1/metrics 主机资源采集、/api/v1/logs/nginx 日志读取、/api/v1/frps/config 配置回读。
|
||||||
const AgentVersion = "0.10.0"
|
// 0.11.0:新增 /api/v1/certs 证书清单与 /api/v1/nginx/reload 显式重载(回传校验/重载失败原因)。
|
||||||
|
const AgentVersion = "0.11.0"
|
||||||
|
|
||||||
// Config 是 fonu-agent 的运行配置。agent 只在 VPS 本地管理自己的 Nginx 片段、证书和 frps 配置。
|
// Config 是 fonu-agent 的运行配置。agent 只在 VPS 本地管理自己的 Nginx 片段、证书和 frps 配置。
|
||||||
type Config struct {
|
type Config struct {
|
||||||
@@ -171,6 +172,8 @@ func (s *Server) Handler() http.Handler {
|
|||||||
mux.HandleFunc("GET /api/v1/metrics", s.auth(s.metrics))
|
mux.HandleFunc("GET /api/v1/metrics", s.auth(s.metrics))
|
||||||
mux.HandleFunc("GET /api/v1/logs/nginx", s.auth(s.nginxLogs))
|
mux.HandleFunc("GET /api/v1/logs/nginx", s.auth(s.nginxLogs))
|
||||||
mux.HandleFunc("GET /api/v1/frps/config", s.auth(s.frpsConfig))
|
mux.HandleFunc("GET /api/v1/frps/config", s.auth(s.frpsConfig))
|
||||||
|
mux.HandleFunc("GET /api/v1/certs", s.auth(s.certs))
|
||||||
|
mux.HandleFunc("POST /api/v1/nginx/reload", s.auth(s.nginxReload))
|
||||||
return mux
|
return mux
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -546,6 +546,36 @@ func (h *FRPMultiHandler) AgentFRPSConfig(w http.ResponseWriter, r *http.Request
|
|||||||
writeJSON(w, http.StatusOK, config)
|
writeJSON(w, http.StatusOK, config)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AgentCerts 列出 VPS 证书目录下的域名与到期时间(需 agent ≥ 0.11.0)
|
||||||
|
func (h *FRPMultiHandler) AgentCerts(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := parseID(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
certs, err := h.svc.AgentCerts(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusBadGateway, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, certs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AgentNginxReload 显式校验并重载 VPS 上的 Nginx(需 agent ≥ 0.11.0)
|
||||||
|
func (h *FRPMultiHandler) AgentNginxReload(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := parseID(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := h.svc.AgentNginxReload(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(r, w, http.StatusBadGateway, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
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 {
|
||||||
|
|||||||
@@ -121,6 +121,8 @@ func NewRouter(deps Deps) http.Handler {
|
|||||||
protect("GET /api/frpmulti/servers/{id}/agent/metrics", frpHandler.AgentMetrics)
|
protect("GET /api/frpmulti/servers/{id}/agent/metrics", frpHandler.AgentMetrics)
|
||||||
protect("GET /api/frpmulti/servers/{id}/agent/logs/nginx", frpHandler.AgentNginxLogs)
|
protect("GET /api/frpmulti/servers/{id}/agent/logs/nginx", frpHandler.AgentNginxLogs)
|
||||||
protect("GET /api/frpmulti/servers/{id}/agent/frps-config", frpHandler.AgentFRPSConfig)
|
protect("GET /api/frpmulti/servers/{id}/agent/frps-config", frpHandler.AgentFRPSConfig)
|
||||||
|
protect("GET /api/frpmulti/servers/{id}/agent/certs", frpHandler.AgentCerts)
|
||||||
|
protect("POST /api/frpmulti/servers/{id}/agent/nginx/reload", frpHandler.AgentNginxReload)
|
||||||
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)
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ func (c *AgentClient) Status(ctx context.Context) (map[string]any, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// agentMinVersionInsights 是 P1 三个只读端点(metrics / logs / frps-config)要求的最低 agent 版本
|
// agentMinVersionInsights 是 P1 三个只读端点(metrics / logs / frps-config)要求的最低 agent 版本
|
||||||
const agentMinVersionInsights = "0.10.0"
|
const agentMinVersionInsights = "0.11.0"
|
||||||
|
|
||||||
// ErrAgentEndpointMissing 表示 VPS 上的 agent 版本过旧、没有该端点(HTTP 404/405)
|
// ErrAgentEndpointMissing 表示 VPS 上的 agent 版本过旧、没有该端点(HTTP 404/405)
|
||||||
var ErrAgentEndpointMissing = errors.New("VPS 上的 agent 版本过低,不支持该接口")
|
var ErrAgentEndpointMissing = errors.New("VPS 上的 agent 版本过低,不支持该接口")
|
||||||
@@ -123,6 +123,24 @@ func (c *AgentClient) NginxLogs(ctx context.Context, kind string, lines int) (ma
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Certs 列出 VPS 证书目录下的域名与到期时间
|
||||||
|
func (c *AgentClient) Certs(ctx context.Context) (map[string]any, error) {
|
||||||
|
var result map[string]any
|
||||||
|
if err := c.request(ctx, http.MethodGet, "/api/v1/certs", nil, &result); err != nil {
|
||||||
|
return nil, wrapEndpointMissing(err)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NginxReload 显式校验并重载 Nginx,返回校验 / 重载结果与失败原因
|
||||||
|
func (c *AgentClient) NginxReload(ctx context.Context) (map[string]any, error) {
|
||||||
|
var result map[string]any
|
||||||
|
if err := c.request(ctx, http.MethodPost, "/api/v1/nginx/reload", nil, &result); err != nil {
|
||||||
|
return nil, wrapEndpointMissing(err)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
// FRPSConfig 回读 VPS 上的 frps.toml(用于与下发内容比对)
|
// FRPSConfig 回读 VPS 上的 frps.toml(用于与下发内容比对)
|
||||||
func (c *AgentClient) FRPSConfig(ctx context.Context) (map[string]any, error) {
|
func (c *AgentClient) FRPSConfig(ctx context.Context) (map[string]any, error) {
|
||||||
var result map[string]any
|
var result map[string]any
|
||||||
|
|||||||
@@ -32,6 +32,24 @@ func (s *Service) AgentNginxLogs(ctx context.Context, serverID int64, kind strin
|
|||||||
return client.NginxLogs(ctx, kind, lines)
|
return client.NginxLogs(ctx, kind, lines)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AgentCerts 列出 VPS 证书目录下的域名与到期时间,供与 Fonu 证书库交叉核对
|
||||||
|
func (s *Service) AgentCerts(ctx context.Context, serverID int64) (map[string]any, error) {
|
||||||
|
client, ok := s.agentClient(ctx, serverID)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("公网 agent 未配置或 Token 未保存")
|
||||||
|
}
|
||||||
|
return client.Certs(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AgentNginxReload 显式校验并重载 VPS 上的 Nginx,返回失败原因
|
||||||
|
func (s *Service) AgentNginxReload(ctx context.Context, serverID int64) (map[string]any, error) {
|
||||||
|
client, ok := s.agentClient(ctx, serverID)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("公网 agent 未配置或 Token 未保存")
|
||||||
|
}
|
||||||
|
return client.NginxReload(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
// AgentFRPSConfig 回读 VPS 上的 frps.toml,供与「即将下发的内容」比对
|
// AgentFRPSConfig 回读 VPS 上的 frps.toml,供与「即将下发的内容」比对
|
||||||
func (s *Service) AgentFRPSConfig(ctx context.Context, serverID int64) (map[string]any, error) {
|
func (s *Service) AgentFRPSConfig(ctx context.Context, serverID int64) (map[string]any, error) {
|
||||||
client, ok := s.agentClient(ctx, serverID)
|
client, ok := s.agentClient(ctx, serverID)
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -290,7 +290,13 @@ export const api = {
|
|||||||
uninstallFrpAgent: (id: number, keepData: boolean) => request<{ ok: boolean; output: string }>(`/api/frpmulti/servers/${id}/agent/uninstall?keep_data=${keepData}`, { method: 'POST' }),
|
uninstallFrpAgent: (id: number, keepData: boolean) => request<{ ok: boolean; output: string }>(`/api/frpmulti/servers/${id}/agent/uninstall?keep_data=${keepData}`, { method: 'POST' }),
|
||||||
sshDiagnoseFrpAgent: (id: number) => request<{ output: string }>(`/api/frpmulti/servers/${id}/agent/ssh-diagnose`, { method: 'POST' }),
|
sshDiagnoseFrpAgent: (id: number) => request<{ output: string }>(`/api/frpmulti/servers/${id}/agent/ssh-diagnose`, { method: 'POST' }),
|
||||||
getFrpAgentStatus: (id: number) => request<import('./types').FrpAgentStatus>(`/api/frpmulti/servers/${id}/agent/status`),
|
getFrpAgentStatus: (id: number) => request<import('./types').FrpAgentStatus>(`/api/frpmulti/servers/${id}/agent/status`),
|
||||||
getFrpAgentMetrics: (id: number) =>
|
getFrpAgentCerts: (id: number) =>
|
||||||
|
request<import('./types').FrpAgentCerts>(`/api/frpmulti/servers/${id}/agent/certs`),
|
||||||
|
reloadFrpAgentNginx: (id: number) =>
|
||||||
|
request<import('./types').FrpAgentNginxReload>(`/api/frpmulti/servers/${id}/agent/nginx/reload`, {
|
||||||
|
method: 'POST',
|
||||||
|
}),
|
||||||
|
getFrpAgentMetrics: (id: number) =>
|
||||||
request<import('./types').FrpAgentMetrics>(`/api/frpmulti/servers/${id}/agent/metrics`),
|
request<import('./types').FrpAgentMetrics>(`/api/frpmulti/servers/${id}/agent/metrics`),
|
||||||
getFrpAgentNginxLogs: (id: number, type: 'access' | 'error', lines = 200) =>
|
getFrpAgentNginxLogs: (id: number, type: 'access' | 'error', lines = 200) =>
|
||||||
request<import('./types').FrpAgentNginxLogs>(
|
request<import('./types').FrpAgentNginxLogs>(
|
||||||
|
|||||||
@@ -736,6 +736,30 @@ export interface FrpRouteHealthSummary {
|
|||||||
failed_servers?: string[]
|
failed_servers?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FrpAgentCertificate {
|
||||||
|
domain: string
|
||||||
|
cert_path: string
|
||||||
|
key_path: string
|
||||||
|
not_after?: string
|
||||||
|
days_left: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FrpAgentCerts {
|
||||||
|
certs: FrpAgentCertificate[]
|
||||||
|
warnings?: string[]
|
||||||
|
dir?: string
|
||||||
|
checked_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FrpAgentNginxReload {
|
||||||
|
ok: boolean
|
||||||
|
test_ok?: boolean
|
||||||
|
test_error?: string
|
||||||
|
reload_ok?: boolean
|
||||||
|
reload_error?: string
|
||||||
|
checked_at: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface FrpAgentMetrics {
|
export interface FrpAgentMetrics {
|
||||||
cpu_percent: number
|
cpu_percent: number
|
||||||
mem_used_bytes: number
|
mem_used_bytes: number
|
||||||
|
|||||||
+151
-11
@@ -103,6 +103,30 @@
|
|||||||
<p v-else class="form-hint">{{ metricsLoading ? '正在采集 VPS 资源…' : '暂无 VPS 资源数据' }}</p>
|
<p v-else class="form-hint">{{ metricsLoading ? '正在采集 VPS 资源…' : '暂无 VPS 资源数据' }}</p>
|
||||||
</div>
|
</div>
|
||||||
</FonuCard>
|
</FonuCard>
|
||||||
|
|
||||||
|
<FonuCard class="section-card vps-resource-card" title="VPS 证书">
|
||||||
|
<div class="section-body">
|
||||||
|
<p v-if="certsError" class="form-hint">{{ certsError }}</p>
|
||||||
|
<div v-else-if="agentCerts.length > 0" class="cert-list">
|
||||||
|
<div class="cert-row cert-row--head">
|
||||||
|
<span>域名</span>
|
||||||
|
<span>剩余天数</span>
|
||||||
|
<span>Fonu 证书库</span>
|
||||||
|
<span>到期时间</span>
|
||||||
|
</div>
|
||||||
|
<div v-for="item in agentCerts" :key="item.domain" class="cert-row">
|
||||||
|
<span class="mono cert-domain">{{ item.domain }}</span>
|
||||||
|
<span :class="{ 'cert-days--warn': item.days_left < 30, 'cert-days--error': item.days_left < 0 }">
|
||||||
|
{{ item.days_left < 0 ? '已过期' : `${item.days_left} 天` }}
|
||||||
|
</span>
|
||||||
|
<span>{{ fonuCertDomains.has(item.domain) ? '有' : '无' }}</span>
|
||||||
|
<span class="cert-muted">{{ item.not_after ? formatDate(item.not_after) : '未解析' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-else class="form-hint">{{ certsLoading ? '正在读取 VPS 证书…' : 'VPS 证书目录为空' }}</p>
|
||||||
|
<p v-if="certWarnings.length > 0" class="form-hint">{{ certWarnings.join(';') }}</p>
|
||||||
|
</div>
|
||||||
|
</FonuCard>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ② 环境与依赖 -->
|
<!-- ② 环境与依赖 -->
|
||||||
@@ -135,7 +159,9 @@
|
|||||||
<div class="field-grid__wide"><label>vhost 配置目录</label><span class="mono mono-wrap">{{ agentStatus?.nginx_conf_dir || '—' }}</span></div>
|
<div class="field-grid__wide"><label>vhost 配置目录</label><span class="mono mono-wrap">{{ agentStatus?.nginx_conf_dir || '—' }}</span></div>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="agentStatus?.nginx_error" class="form-hint nginx-error mono">{{ agentStatus.nginx_error }}</p>
|
<p v-if="agentStatus?.nginx_error" class="form-hint nginx-error mono">{{ agentStatus.nginx_error }}</p>
|
||||||
|
<p v-if="reloadHint" class="form-hint">{{ reloadHint }}</p>
|
||||||
<div class="agent-actions">
|
<div class="agent-actions">
|
||||||
|
<n-button size="small" :loading="reloadingNginx" :disabled="busy !== null" @click="reloadNginx">重载 Nginx</n-button>
|
||||||
<router-link to="/public-proxy"><n-button size="small" quaternary>去「公网反代」处理</n-button></router-link>
|
<router-link to="/public-proxy"><n-button size="small" quaternary>去「公网反代」处理</n-button></router-link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -280,7 +306,7 @@ import PageHeader from '../components/PageHeader.vue'
|
|||||||
import StatusBadge from '../components/StatusBadge.vue'
|
import StatusBadge from '../components/StatusBadge.vue'
|
||||||
import { api } from '../api/client'
|
import { api } from '../api/client'
|
||||||
import type { FrpAgentStatus, FrpRelease, FrpServer } from '../api/types'
|
import type { FrpAgentStatus, FrpRelease, FrpServer } from '../api/types'
|
||||||
import { formatBytes, formatRelativeTime, formatUptime } from '../utils/format'
|
import { formatBytes, formatDate, formatRelativeTime, formatUptime } from '../utils/format'
|
||||||
import { copyText } from '../utils/clipboard'
|
import { copyText } from '../utils/clipboard'
|
||||||
|
|
||||||
// 操作动作:按动作粒度控制 loading,保证"只有被点的按钮转圈"
|
// 操作动作:按动作粒度控制 loading,保证"只有被点的按钮转圈"
|
||||||
@@ -452,6 +478,7 @@ async function load() {
|
|||||||
selectedId.value = servers.value[0]?.id ?? null
|
selectedId.value = servers.value[0]?.id ?? null
|
||||||
}
|
}
|
||||||
if (server.value) await refreshAll({ silent: false })
|
if (server.value) await refreshAll({ silent: false })
|
||||||
|
void loadFonuCerts()
|
||||||
} catch (error) { loadError.value = error instanceof Error ? error.message : '读取服务端失败' }
|
} catch (error) { loadError.value = error instanceof Error ? error.message : '读取服务端失败' }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -509,6 +536,69 @@ const metricsUptimeText = computed(() =>
|
|||||||
agentMetrics.value?.uptime_seconds ? formatUptime(agentMetrics.value.uptime_seconds) : '—',
|
agentMetrics.value?.uptime_seconds ? formatUptime(agentMetrics.value.uptime_seconds) : '—',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// VPS 证书清单(需 agent ≥ 0.11.0):与 Fonu 证书库交叉核对,宝塔自签的证书也能看到
|
||||||
|
const agentCerts = ref<import('../api/types').FrpAgentCertificate[]>([])
|
||||||
|
const certWarnings = ref<string[]>([])
|
||||||
|
const certsError = ref('')
|
||||||
|
const certsLoading = ref(false)
|
||||||
|
const fonuCertDomains = ref<Set<string>>(new Set())
|
||||||
|
|
||||||
|
async function loadAgentCerts(serverID: number) {
|
||||||
|
certsLoading.value = true
|
||||||
|
try {
|
||||||
|
const result = await api.getFrpAgentCerts(serverID)
|
||||||
|
agentCerts.value = result.certs ?? []
|
||||||
|
certWarnings.value = result.warnings ?? []
|
||||||
|
certsError.value = ''
|
||||||
|
} catch (error) {
|
||||||
|
agentCerts.value = []
|
||||||
|
certWarnings.value = []
|
||||||
|
certsError.value = error instanceof Error ? error.message : 'VPS 证书读取失败'
|
||||||
|
} finally {
|
||||||
|
certsLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fonu 证书库里的域名(含通配符),用于判断「VPS 上有但 Fonu 没有」
|
||||||
|
async function loadFonuCerts() {
|
||||||
|
try {
|
||||||
|
const records = await api.listCertificates()
|
||||||
|
const domains = new Set<string>()
|
||||||
|
for (const record of records ?? []) {
|
||||||
|
domains.add(record.domain)
|
||||||
|
for (const item of record.domains ?? []) domains.add(item)
|
||||||
|
}
|
||||||
|
fonuCertDomains.value = domains
|
||||||
|
} catch {
|
||||||
|
fonuCertDomains.value = new Set()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nginx 显式重载(需 agent ≥ 0.11.0):把校验 / 重载失败原因直接显示出来
|
||||||
|
const reloadingNginx = ref(false)
|
||||||
|
const reloadHint = ref('')
|
||||||
|
|
||||||
|
async function reloadNginx() {
|
||||||
|
const target = server.value
|
||||||
|
if (!target) return
|
||||||
|
reloadingNginx.value = true
|
||||||
|
reloadHint.value = ''
|
||||||
|
try {
|
||||||
|
const result = await api.reloadFrpAgentNginx(target.id)
|
||||||
|
if (result.ok) {
|
||||||
|
reloadHint.value = 'Nginx 校验通过并已重载'
|
||||||
|
} else if (!result.test_ok) {
|
||||||
|
reloadHint.value = `Nginx 配置校验失败:${result.test_error || '未返回原因'}`
|
||||||
|
} else {
|
||||||
|
reloadHint.value = `Nginx 重载失败:${result.reload_error || '未返回原因'}`
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
reloadHint.value = error instanceof Error ? error.message : '重载失败'
|
||||||
|
} finally {
|
||||||
|
reloadingNginx.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 轮询走静默(不触碰 busy);首次加载显示 statusLoading
|
// 轮询走静默(不触碰 busy);首次加载显示 statusLoading
|
||||||
async function refreshAll(options: { silent?: boolean } = { silent: true }) {
|
async function refreshAll(options: { silent?: boolean } = { silent: true }) {
|
||||||
const current = server.value
|
const current = server.value
|
||||||
@@ -520,6 +610,7 @@ async function refreshAll(options: { silent?: boolean } = { silent: true }) {
|
|||||||
statusLoaded.value = true
|
statusLoaded.value = true
|
||||||
// 资源采集单独走,不阻断状态加载
|
// 资源采集单独走,不阻断状态加载
|
||||||
void loadMetrics(current.id)
|
void loadMetrics(current.id)
|
||||||
|
void loadAgentCerts(current.id)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
agentStatusOk.value = false
|
agentStatusOk.value = false
|
||||||
statusLoaded.value = true
|
statusLoaded.value = true
|
||||||
@@ -656,7 +747,9 @@ onBeforeUnmount(() => { if (timer) window.clearInterval(timer) })
|
|||||||
.port-item { font-family: var(--fonu-mono); font-size: 12.5px; padding: 1px 6px; border-radius: 999px; background: var(--fonu-bg-muted); color: var(--fonu-text-muted); }
|
.port-item { font-family: var(--fonu-mono); font-size: 12.5px; padding: 1px 6px; border-radius: 999px; background: var(--fonu-bg-muted); color: var(--fonu-text-muted); }
|
||||||
.port-item--on { background: var(--fonu-brand-soft); color: var(--fonu-brand-text); }
|
.port-item--on { background: var(--fonu-brand-soft); color: var(--fonu-brand-text); }
|
||||||
|
|
||||||
.dep-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--fonu-space-4); margin-bottom: var(--fonu-space-4); }
|
.dep-grid { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: var(--fonu-space-4); margin-bottom: var(--fonu-space-4); }
|
||||||
|
/* 前四张卡每行三张(各占 2/6),VPS 资源与 VPS 证书各占 3/6 并排 */
|
||||||
|
.dep-grid > .section-card { grid-column: span 2; }
|
||||||
/* 三列下卡片较窄:短字段(版本/徽标)双列并排,长路径与命令用 .field-grid__wide 整行,控制卡片高度 */
|
/* 三列下卡片较窄:短字段(版本/徽标)双列并排,长路径与命令用 .field-grid__wide 整行,控制卡片高度 */
|
||||||
.dep-grid .field-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--fonu-space-2) var(--fonu-space-3); }
|
.dep-grid .field-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--fonu-space-2) var(--fonu-space-3); }
|
||||||
/* frps 控制与 Agent 生命周期两列并排,卡片等高 */
|
/* frps 控制与 Agent 生命周期两列并排,卡片等高 */
|
||||||
@@ -723,6 +816,8 @@ onBeforeUnmount(() => { if (timer) window.clearInterval(timer) })
|
|||||||
@media (max-width: 1199px) {
|
@media (max-width: 1199px) {
|
||||||
.stats-row { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
.stats-row { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
.dep-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
.dep-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
/* 窄屏恢复每卡一格:span 在列数变少后会撑破网格 */
|
||||||
|
.dep-grid > .section-card, .dep-grid > .vps-resource-card { grid-column: auto; }
|
||||||
}
|
}
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.dep-grid { grid-template-columns: 1fr; }
|
.dep-grid { grid-template-columns: 1fr; }
|
||||||
@@ -736,15 +831,16 @@ onBeforeUnmount(() => { if (timer) window.clearInterval(timer) })
|
|||||||
.entrance-row { flex-direction: column; align-items: flex-start; }
|
.entrance-row { flex-direction: column; align-items: flex-start; }
|
||||||
}
|
}
|
||||||
/* VPS 资源:占满「环境与依赖」整行,避免单独占一格显得突兀 */
|
/* VPS 资源:占满「环境与依赖」整行,避免单独占一格显得突兀 */
|
||||||
.vps-resource-card {
|
/* 选择器带 .dep-grid 前缀,避免被上面的 .dep-grid > .section-card(同权重、更早)压过 */
|
||||||
grid-column: 1 / -1;
|
.dep-grid > .vps-resource-card {
|
||||||
|
grid-column: span 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.res-strip {
|
.res-strip {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr)) auto;
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--fonu-space-5);
|
gap: var(--fonu-space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.res-row {
|
.res-row {
|
||||||
@@ -806,17 +902,61 @@ onBeforeUnmount(() => { if (timer) window.clearInterval(timer) })
|
|||||||
}
|
}
|
||||||
|
|
||||||
.res-meta {
|
.res-meta {
|
||||||
|
grid-column: 1 / -1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
gap: var(--fonu-space-4);
|
||||||
gap: 4px;
|
padding-top: var(--fonu-space-2);
|
||||||
padding-left: var(--fonu-space-4);
|
border-top: 1px solid var(--fonu-border);
|
||||||
border-left: 1px solid var(--fonu-border);
|
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--fonu-text-muted);
|
color: var(--fonu-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.res-strip { grid-template-columns: 1fr; }
|
.res-strip { grid-template-columns: 1fr; }
|
||||||
.res-meta { padding-left: 0; border-left: none; }
|
.res-meta { flex-direction: column; gap: 4px; }
|
||||||
|
}
|
||||||
|
.cert-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cert-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.4fr) 76px 76px 92px;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--fonu-space-2);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cert-row--head {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--fonu-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cert-domain {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cert-muted {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--fonu-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cert-days--warn {
|
||||||
|
color: var(--fonu-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cert-days--error {
|
||||||
|
color: var(--fonu-error);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.cert-row { grid-template-columns: minmax(0, 1fr) 80px; }
|
||||||
|
.cert-row span:nth-child(3),
|
||||||
|
.cert-row span:nth-child(4) { display: none; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user