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:
2026-09-24 10:49:28 +08:00
parent 2f59f5adc4
commit 828be2ea66
13 changed files with 386 additions and 15 deletions
+18
View File
@@ -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
### 新增 ### 新增
+1 -1
View File
@@ -1 +1 @@
1.2.4 1.2.5
+112
View File
@@ -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)
+4 -1
View File
@@ -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
} }
+30
View File
@@ -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 {
+2
View File
@@ -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)
+19 -1
View File
@@ -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
+18
View File
@@ -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.
+6
View File
@@ -290,6 +290,12 @@ 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`),
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) => 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) =>
+24
View File
@@ -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
View File
@@ -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>