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 格式对齐
This commit is contained in:
JianFeeeee
2026-08-24 23:09:00 +08:00
parent ef98d9dca1
commit 2eaa26ba95
8 changed files with 102 additions and 40 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<title>webui4frpc</title>
<script type="module" crossorigin src="/assets/index-CepJKoxg.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B4L3dftx.css">
<script type="module" crossorigin src="/assets/index-CaXjZqKm.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-jvouiCgh.css">
</head>
<body>
<div id="app"></div>

View File

@ -172,6 +172,48 @@ func (h *Handler) handleAuditClusterLogCsv(w http.ResponseWriter, r *http.Reques
rows)
}
// handleAuditWorkerLogsCsv serves GET /audit/worker-logs.csv: every alive
// node's frpc worker logs, fanned out over the ring and flattened to one row
// per LOG LINE (node/worker/state columns repeated) so auditors can filter
// and pivot in a spreadsheet. Unreachable nodes contribute an error row.
func (h *Handler) handleAuditWorkerLogsCsv(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
}
_, results := h.gatherClusterWorkerLogs(r)
rows := make([][]string, 0, 64)
for _, n := range results {
if !n.OK || n.Logs == nil {
rows = append(rows, []string{n.ID, "", "ERROR", n.Error, ""})
continue
}
for _, wk := range n.Logs.Workers {
// One row per line keeps the CSV rectangular; the tail's embedded
// newlines would otherwise need multi-line cells (RFC4180 allows
// them but Excel's filtering/pivot works better on flat rows).
for _, line := range strings.Split(strings.TrimRight(wk.Lines, "\n"), "\n") {
if ts, rest, found := strings.Cut(line, "."); found && len(ts) > 10 {
line = rest // strip the ANSI-prefixed timestamp prefix noise
}
line = strings.TrimSpace(line)
if line == "" {
continue
}
rows = append(rows, []string{n.ID, wk.Name, wk.Remote, wk.State, line})
}
}
}
writeCSV(w,
fmt.Sprintf("audit-worker-logs-%s.csv", time.Now().UTC().Format("20060102-150405")),
[]string{"node", "worker_key", "remote", "worker_state", "log_line"},
rows)
}
// rawJSON renders the log entry's payload as compact JSON (empty when absent)
// for the lossless audit column.
func rawJSON(data json.RawMessage) string {

View File

@ -67,20 +67,13 @@ type clusterWorkerLogNode struct {
Logs *nodeLogsResp `json:"logs,omitempty"`
}
// handleClusterLogsExport fans out GET /node/logs to every alive ring node
// 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 into a downloadable bundle.
// Unreachable nodes are marked error but don't abort the export. The
// requester's own logs are gathered locally without an HTTP round-trip.
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
}
// 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 }
@ -139,6 +132,20 @@ func (h *Handler) handleClusterLogsExport(w http.ResponseWriter, r *http.Request
}(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,

View File

@ -117,6 +117,7 @@ func NewServeMux(h *Handler) (http.Handler, error) {
mux.HandleFunc(apiPrefix+"/audit/users.csv", h.auth("read")(h.handleAuditUsersCsv))
mux.HandleFunc(apiPrefix+"/audit/apikeys.csv", h.auth("read")(h.handleAuditApiKeysCsv))
mux.HandleFunc(apiPrefix+"/audit/cluster-log.csv", h.auth("read")(h.handleAuditClusterLogCsv))
mux.HandleFunc(apiPrefix+"/audit/worker-logs.csv", h.auth("read")(h.handleAuditWorkerLogsCsv))
mux.HandleFunc(apiPrefix+"/cluster/token", h.auth("write")(h.handleClusterToken))
mux.HandleFunc(apiPrefix+"/cluster/join", h.auth("write")(h.handleClusterJoin))
mux.HandleFunc(apiPrefix+"/cluster/task", h.auth("write")(h.handleClusterTask))

View File

@ -251,7 +251,7 @@ export const api = {
// downloadAuditCsv streams one of the /audit/*.csv endpoints to a file.
export async function downloadAuditCsv(
kind: "users" | "apikeys" | "cluster-log",
kind: "users" | "apikeys" | "cluster-log" | "worker-logs",
): Promise<void> {
const resp = await fetch(`/api/manager/audit/${kind}.csv`, {
credentials: "same-origin",

View File

@ -154,9 +154,15 @@
</el-dropdown-menu>
</template>
</el-dropdown>
<button class="w4f-btn ghost small" :disabled="workerLogBusy" @click="exportWorkerLogs">
导出 worker 日志
</button>
<el-dropdown trigger="click" :disabled="workerLogBusy" @command="exportWorkerLogs">
<button class="w4f-btn ghost small" :disabled="workerLogBusy"> 导出 worker 日志</button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="json">JSON完整结构</el-dropdown-item>
<el-dropdown-item command="csv">CSV审计用逐行展开</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</h3>
<div class="log-list">
@ -365,9 +371,15 @@ const exportLog = async (fmt: string) => {
// logs (the actual forwards running on each node) into one downloadable JSON.
// Read-level: auditors use this to inspect worker output cluster-wide.
const workerLogBusy = ref(false)
const exportWorkerLogs = async () => {
const exportWorkerLogs = async (fmt = 'json') => {
workerLogBusy.value = true
try {
if (fmt === 'csv') {
// Server-side CSV: ring-wide fan-out, one row per log line.
await downloadAuditCsv('worker-logs')
ElMessage.success('已导出 worker 日志 CSV')
return
}
const bundle = await api.exportWorkerLogs()
const blob = new Blob([JSON.stringify(bundle, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)