Files
webui4frpc/internal/httpapi/handlers_logs.go
JianFeeeee 2eaa26ba95 feat: worker 日志 CSV 导出 + 集群日志导出菜单完善
- handleAuditWorkerLogsCsv: 全环 fan-out 收集每节点 frpc 日志,
  逐行展开为 CSV (node/worker_key/remote/worker_state/log_line),
  Excel 可直接过滤/pivot 审计
- gatherClusterWorkerLogs: 提取 JSON 与 CSV 共用的日志聚合逻辑
- 路由: GET /audit/worker-logs.csv (read 级)
- ClusterView: 导出 worker 日志改下拉菜单 (JSON/CSV 两格式)
- api.ts: downloadAuditCsv 支持 worker-logs 类型
- gofmt: handlers_audit.go 格式对齐
2026-08-24 23:09:00 +08:00

161 lines
4.7 KiB
Go

package httpapi
import (
"encoding/json"
"io"
"net/http"
"sync"
"time"
"webui4frpc/internal/process"
)
// workerLog is one frpc worker's log tail on a node.
type workerLog struct {
Name string `json:"name"`
Remote string `json:"remote"` // worker key = remote name
State string `json:"state"`
Lines string `json:"lines"`
}
// nodeLogsResp is the body of GET /node/logs: all frpc workers handling
// forwards on THIS node (cluster-owned workers that run here + local-only ones).
type nodeLogsResp struct {
Node string `json:"node"`
Workers []workerLog `json:"workers"`
}
// localWorkerLogs gathers this node's frpc worker logs. Shared by /node/logs
// and the /cluster/logs/export self entry so the requesting node doesn't HTTP
// back to itself.
func (h *Handler) localWorkerLogs() nodeLogsResp {
out := nodeLogsResp{Node: h.SelfAddr, Workers: []workerLog{}}
if h.Process == nil {
return out
}
for _, name := range h.Process.ListWorkers() {
st, _ := h.Process.Status(name)
lines, _ := tailFile(h.Process.LogPath(name), 64*1024)
// Parse the worker key to get the real remote name for display.
remoteName := name
if _, r, _, ok := process.ParseWorkerKey(name); ok {
remoteName = r
}
out.Workers = append(out.Workers, workerLog{
Name: name, Remote: remoteName, State: st.State, Lines: lines,
})
}
return out
}
// handleNodeLogs returns the frpc worker logs of THIS node only. Read-level:
// auditors can pull it directly on any node, and the cluster export fans it out.
func (h *Handler) handleNodeLogs(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
methodNotAllowed(w)
return
}
writeJSON(w, http.StatusOK, h.localWorkerLogs())
}
// clusterWorkerLogNode is one node's entry in the export bundle.
type clusterWorkerLogNode struct {
ID string `json:"id"`
Addr string `json:"addr"`
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
Logs *nodeLogsResp `json:"logs,omitempty"`
}
// gatherClusterWorkerLogs fans out GET /node/logs to every alive ring node
// (using the flag Basic creds, which resolve to admin on each peer via the
// flag fast path) and aggregates the results. Unreachable nodes are marked
// error but don't abort the export. The requester's own logs are gathered
// locally without an HTTP round-trip. Shared by the JSON bundle and the CSV
// audit export.
func (h *Handler) gatherClusterWorkerLogs(r *http.Request) (string, []clusterWorkerLogNode) {
snap := h.Ring.Snapshot()
selfID := snap.SelfID
type target struct{ id, addr string }
var targets []target
for _, n := range snap.Nodes {
if !n.Alive || n.Addr == "" {
continue
}
targets = append(targets, target{n.ID, n.Addr})
}
results := make([]clusterWorkerLogNode, len(targets))
var wg sync.WaitGroup
cli := &http.Client{Timeout: 8 * time.Second}
for i, t := range targets {
wg.Add(1)
go func(i int, t target) {
defer wg.Done()
entry := clusterWorkerLogNode{ID: t.id, Addr: t.addr}
// Self: gather locally, no HTTP round-trip.
if t.id == selfID {
logs := h.localWorkerLogs()
entry.OK = true
entry.Logs = &logs
results[i] = entry
return
}
req, err := http.NewRequest(http.MethodGet, "http://"+t.addr+apiPrefix+"/node/logs", nil)
if err != nil {
entry.Error = err.Error()
results[i] = entry
return
}
req.SetBasicAuth(h.User, h.Password)
resp, err := cli.Do(req)
if err != nil {
entry.Error = "unreachable: " + err.Error()
results[i] = entry
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 16<<20))
if resp.StatusCode != http.StatusOK {
entry.Error = "HTTP " + resp.Status
results[i] = entry
return
}
var logs nodeLogsResp
if err := json.Unmarshal(body, &logs); err != nil {
entry.Error = "parse: " + err.Error()
results[i] = entry
return
}
entry.OK = true
entry.Logs = &logs
results[i] = entry
}(i, t)
}
wg.Wait()
return selfID, results
}
// handleClusterLogsExport serves the aggregated worker-log bundle as JSON.
func (h *Handler) handleClusterLogsExport(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
methodNotAllowed(w)
return
}
if h.Ring == nil {
http.Error(w, "ring engine not enabled", http.StatusNotFound)
return
}
selfID, results := h.gatherClusterWorkerLogs(r)
bundle := map[string]any{
"_type": "webui4frpc-worker-logs",
"version": 1,
"exportedAt": time.Now().Unix(),
"requester": selfID,
"nodes": results,
}
body, _ := json.MarshalIndent(bundle, "", " ")
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Disposition", `attachment; filename="worker-logs.json"`)
_, _ = w.Write(body)
}