Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 39 additions & 29 deletions .github/workflows/pr-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,35 +20,6 @@ env:
NODE_VERSION: '24'

jobs:
# ===== 后端:编译 + 测试 =====
backend:
name: Backend (build + test)
runs-on: ubuntu-latest
defaults:
run:
working-directory: backend
steps:
- uses: actions/checkout@v4

- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
cache: true
cache-dependency-path: backend/go.sum

- name: Verify go.mod / go.sum consistency
run: go mod verify

- name: Build
run: go build ./...

- name: Vet
run: go vet ./...

- name: Test
run: go test ./... -count=1

# ===== 前端:类型检查 + 构建 =====
frontend:
name: Frontend (typecheck + build)
Expand All @@ -70,5 +41,44 @@ jobs:
run: npm ci

# build 脚本已包含 tsc 类型检查("tsc && vite build")
# vite.config 中 outDir: '../backend/embed/dist',输出到仓库根目录的 backend/embed/dist/
- name: Typecheck & Build
run: npm run build

- name: Upload webpage artifact
uses: actions/upload-artifact@v4
with:
name: webpage-dist
path: ${{ github.workspace }}/backend/embed/dist/
retention-days: 1

# ===== 后端:编译 + 测试 =====
backend:
name: Backend (build + test)
runs-on: ubuntu-latest
needs: frontend
defaults:
run:
working-directory: backend
steps:
- uses: actions/checkout@v4

- name: Download webpage artifact
uses: actions/download-artifact@v4
with:
name: webpage-dist
# download-artifact 的 path 相对于 $GITHUB_WORKSPACE(仓库根),
# 而 go build 的 //go:embed embed/dist 相对 backend/,需落到 backend/embed/dist/
path: backend/embed/dist/

- name: Verify go.mod / go.sum consistency
run: go mod verify

- name: Build
run: go build ./...

- name: Vet
run: go vet ./...

- name: Test
run: go test ./... -count=1
28 changes: 28 additions & 0 deletions backend/service/cftunnel/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,34 @@ func (m *Manager) GetLogs(id uint) []string {
return nil
}

// GetTunnelDetail 获取 Cloudflare Tunnel 诊断信息
func (m *Manager) GetTunnelDetail(id uint) (map[string]interface{}, error) {
var cfg model.CftunnelConfig
if err := m.db.Where("id = ?", id).First(&cfg).Error; err != nil {
return nil, err
}

logs := m.GetLogs(id)

status, err := m.GetStatus(id)
if err != nil {
return nil, err
}

return map[string]interface{}{
"id": cfg.ID,
"name": cfg.Name,
"status": status,
"last_error": cfg.LastError,
"mode": cfg.Mode,
"local_url": cfg.LocalURL,
"tunnel_name": cfg.TunnelName,
"quick_url": cfg.QuickURL,
"protocol": cfg.Protocol,
"recent_logs": logs,
}, nil
}

// buildArgs 根据模式构建 cloudflared 命令行参数与额外环境变量。
//
// quick: cloudflared tunnel --url <local_url> --no-autoupdate
Expand Down
27 changes: 27 additions & 0 deletions backend/service/easytier/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -1005,6 +1005,33 @@ func (m *Manager) GetClientLogs(id uint) []string {
return []string{}
}

// GetClientDetail 获取 EasyTier 客户端诊断信息
func (m *Manager) GetClientDetail(id uint) (map[string]interface{}, error) {
var cfg model.EasytierClient
if err := m.db.Where("id = ?", id).First(&cfg).Error; err != nil {
return nil, err
}

peers, _ := m.GetClientPeers(id)
logs := m.GetClientLogs(id)

result := map[string]interface{}{
"id": cfg.ID,
"name": cfg.Name,
"status": m.GetClientStatus(id),
"last_error": cfg.LastError,
"server_addr": cfg.ServerAddr,
"network_name": cfg.NetworkName,
"virtual_ip": cfg.VirtualIP,
"hostname": cfg.Hostname,
"listen_ports": cfg.ListenPorts,
"no_tun": cfg.NoTun,
"peers": peers,
"recent_logs": logs,
}
return result, nil
}

// GetServerLogs 获取服务端实例的实时日志(最近 maxLogLines 行)
func (m *Manager) GetServerLogs(id uint) []string {
if val, ok := m.servers.Load(id); ok {
Expand Down
86 changes: 86 additions & 0 deletions backend/service/frp/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,92 @@ func (m *Manager) GetClientStatus(id uint) string {
return "stopped"
}

// ClientDetail 客户端详细诊断信息
type ClientDetail struct {
ID uint `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
LastError string `json:"last_error"`
ServerAddr string `json:"server_addr"`
ServerPort int `json:"server_port"`
Proxies []ProxyInfo `json:"proxies"`
RecentLogs []string `json:"recent_logs"`
}

// ProxyInfo 代理信息
type ProxyInfo struct {
Name string `json:"name"`
Type string `json:"type"`
Enable bool `json:"enable"`
LocalAddr string `json:"local_addr"`
RemoteAddr string `json:"remote_addr"`
Status string `json:"status"`
}

// GetClientDetail 获取客户端完整诊断信息(用于 AI 诊断)
func (m *Manager) GetClientDetail(id uint) (*ClientDetail, error) {
var cfg model.FrpcConfig
if err := m.db.Where("id = ?", id).Preload("Proxies").First(&cfg).Error; err != nil {
return nil, err
}

detail := &ClientDetail{
ID: cfg.ID,
Name: cfg.Name,
Status: m.GetClientStatus(id),
LastError: cfg.LastError,
ServerAddr: cfg.ServerAddr,
ServerPort: cfg.ServerPort,
Proxies: make([]ProxyInfo, 0, len(cfg.Proxies)),
}

for _, p := range cfg.Proxies {
proxyAddr := ""
if p.Type == "tcp" || p.Type == "udp" {
proxyAddr = fmt.Sprintf(":%d", p.RemotePort)
} else if p.Type == "http" || p.Type == "https" {
proxyAddr = cfg.ServerAddr
// HTTP/HTTPS 端口从服务端配置获取,这里简化处理
}
detail.Proxies = append(detail.Proxies, ProxyInfo{
Name: p.Name,
Type: p.Type,
Enable: p.Enable,
LocalAddr: fmt.Sprintf("%s:%d", p.LocalIP, p.LocalPort),
RemoteAddr: proxyAddr,
Status: "unknown",
})
}

// 从系统日志表获取最近日志
var logs []model.SystemLog
m.db.Where("service = 'frp' AND message LIKE ?", "%"+cfg.Name+"%").
Order("log_time DESC").Limit(50).Find(&logs)
for _, l := range logs {
detail.RecentLogs = append(detail.RecentLogs, fmt.Sprintf("[%s] %s", l.Level, l.Message))
}

return detail, nil
}

// TailLogs 获取指定客户端最近日志(从系统日志表查询)
func (m *Manager) TailLogs(id uint, tail int) []string {
var cfg model.FrpcConfig
if err := m.db.Where("id = ?", id).First(&cfg).Error; err != nil {
return []string{"错误:找不到该客户端"}
}

var logs []model.SystemLog
m.db.Where("service = 'frp' AND message LIKE ?", "%"+cfg.Name+"%").
Order("log_time DESC").Limit(tail).Find(&logs)

result := make([]string, 0, len(logs))
for _, l := range logs {
result = append(result, fmt.Sprintf("[%s] %s", l.LogTime.Format("15:04:05"), l.Message))
}
return result
}

// runClient 在 goroutine 中运行 FRP 客户端
func (m *Manager) runClient(ctx context.Context, id uint, name string, svc *client.Service) {
defer func() {
Expand Down
130 changes: 130 additions & 0 deletions backend/service/mcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,41 @@ func (s *Server) toolsList() []map[string]interface{} {
"rebind_mode": str("端口层重绑模式:auto / manual / off"),
}, nil),
},
{
"name": "frp_client_detail",
"description": "获取FRP客户端详细诊断信息(状态、代理列表、错误日志)",
"inputSchema": schema(map[string]interface{}{
"id": intg("FRP客户端ID"),
}, []string{"id"}),
},
{
"name": "nps_client_detail",
"description": "获取NPS客户端详细诊断信息",
"inputSchema": schema(map[string]interface{}{
"id": intg("NPS客户端ID"),
}, []string{"id"}),
},
{
"name": "easytier_client_detail",
"description": "获取EasyTier客户端详细诊断信息(含peer列表)",
"inputSchema": schema(map[string]interface{}{
"id": intg("EasyTier客户端ID"),
}, []string{"id"}),
},
{
"name": "wireguard_interface_detail",
"description": "获取WireGuard接口详细诊断信息(含peer列表)",
"inputSchema": schema(map[string]interface{}{
"id": intg("WireGuard接口ID"),
}, []string{"id"}),
},
{
"name": "cftunnel_detail",
"description": "获取Cloudflare Tunnel详细诊断信息",
"inputSchema": schema(map[string]interface{}{
"id": intg("Cloudflare Tunnel ID"),
}, []string{"id"}),
},
{
"name": "tunservice_diag",
"description": "异常诊断:列出全部穿透服务的状态与最近错误,并附带选线快照与待重绑清单",
Expand Down Expand Up @@ -457,6 +492,21 @@ func (s *Server) handleToolCall(raw json.RawMessage) map[string]interface{} {
case "tunservice_diag":
return s.handleTunserviceDiag()

case "frp_client_detail":
return s.handleFRPClientDetail(params.Arguments)

case "nps_client_detail":
return s.handleNPSClientDetail(params.Arguments)

case "easytier_client_detail":
return s.handleEasyTierClientDetail(params.Arguments)

case "wireguard_interface_detail":
return s.handleWireGuardDetail(params.Arguments)

case "cftunnel_detail":
return s.handleCFTunnelDetail(params.Arguments)

default:
return s.textError(fmt.Sprintf("未知工具: %s", params.Name))
}
Expand Down Expand Up @@ -555,6 +605,86 @@ func (s *Server) handleTunserviceDiag() map[string]interface{} {
})
}

// handleFRPClientDetail 获取FRP客户端详细诊断信息
func (s *Server) handleFRPClientDetail(args map[string]interface{}) map[string]interface{} {
if s.frpMgr == nil {
return s.textError("FRP管理器未初始化")
}
id, err := s.argUint(args, "id")
if err != nil {
return s.textError(err.Error())
}
detail, err := s.frpMgr.GetClientDetail(id)
if err != nil {
return s.textError("获取FRP客户端详情失败: " + err.Error())
}
return s.textResult(detail)
}

// handleNPSClientDetail 获取NPS客户端详细诊断信息
func (s *Server) handleNPSClientDetail(args map[string]interface{}) map[string]interface{} {
if s.npsMgr == nil {
return s.textError("NPS管理器未初始化")
}
id, err := s.argUint(args, "id")
if err != nil {
return s.textError(err.Error())
}
detail, err := s.npsMgr.GetClientDetail(id)
if err != nil {
return s.textError("获取NPS客户端详情失败: " + err.Error())
}
return s.textResult(detail)
}

// handleEasyTierClientDetail 获取EasyTier客户端详细诊断信息
func (s *Server) handleEasyTierClientDetail(args map[string]interface{}) map[string]interface{} {
if s.easytierMgr == nil {
return s.textError("EasyTier管理器未初始化")
}
id, err := s.argUint(args, "id")
if err != nil {
return s.textError(err.Error())
}
detail, err := s.easytierMgr.GetClientDetail(id)
if err != nil {
return s.textError("获取EasyTier客户端详情失败: " + err.Error())
}
return s.textResult(detail)
}

// handleWireGuardDetail 获取WireGuard接口详细诊断信息
func (s *Server) handleWireGuardDetail(args map[string]interface{}) map[string]interface{} {
if s.wireguardMgr == nil {
return s.textError("WireGuard管理器未初始化")
}
id, err := s.argUint(args, "id")
if err != nil {
return s.textError(err.Error())
}
detail, err := s.wireguardMgr.GetInterfaceDetail(id)
if err != nil {
return s.textError("获取WireGuard详情失败: " + err.Error())
}
return s.textResult(detail)
}

// handleCFTunnelDetail 获取Cloudflare Tunnel详细诊断信息
func (s *Server) handleCFTunnelDetail(args map[string]interface{}) map[string]interface{} {
if s.cftunnelMgr == nil {
return s.textError("Cloudflare Tunnel管理器未初始化")
}
id, err := s.argUint(args, "id")
if err != nil {
return s.textError(err.Error())
}
detail, err := s.cftunnelMgr.GetTunnelDetail(id)
if err != nil {
return s.textError("获取Cloudflare Tunnel详情失败: " + err.Error())
}
return s.textResult(detail)
}

// cfgInt 从 SystemConfig 读取整数配置;缺失或非法时返回 def。
func (s *Server) cfgInt(key string, def int) int {
var cfg model.SystemConfig
Expand Down
Loading
Loading