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

View File

@ -172,6 +172,48 @@ func (h *Handler) handleAuditClusterLogCsv(w http.ResponseWriter, r *http.Reques
rows) 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) // rawJSON renders the log entry's payload as compact JSON (empty when absent)
// for the lossless audit column. // for the lossless audit column.
func rawJSON(data json.RawMessage) string { func rawJSON(data json.RawMessage) string {

View File

@ -67,20 +67,13 @@ type clusterWorkerLogNode struct {
Logs *nodeLogsResp `json:"logs,omitempty"` 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 // (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. // flag fast path) and aggregates the results. Unreachable nodes are marked
// Unreachable nodes are marked error but don't abort the export. The // error but don't abort the export. The requester's own logs are gathered
// requester's own logs are gathered locally without an HTTP round-trip. // locally without an HTTP round-trip. Shared by the JSON bundle and the CSV
func (h *Handler) handleClusterLogsExport(w http.ResponseWriter, r *http.Request) { // audit export.
if r.Method != http.MethodGet { func (h *Handler) gatherClusterWorkerLogs(r *http.Request) (string, []clusterWorkerLogNode) {
methodNotAllowed(w)
return
}
if h.Ring == nil {
http.Error(w, "ring engine not enabled", http.StatusNotFound)
return
}
snap := h.Ring.Snapshot() snap := h.Ring.Snapshot()
selfID := snap.SelfID selfID := snap.SelfID
type target struct{ id, addr string } type target struct{ id, addr string }
@ -139,6 +132,20 @@ func (h *Handler) handleClusterLogsExport(w http.ResponseWriter, r *http.Request
}(i, t) }(i, t)
} }
wg.Wait() 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{ bundle := map[string]any{
"_type": "webui4frpc-worker-logs", "_type": "webui4frpc-worker-logs",
"version": 1, "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/users.csv", h.auth("read")(h.handleAuditUsersCsv))
mux.HandleFunc(apiPrefix+"/audit/apikeys.csv", h.auth("read")(h.handleAuditApiKeysCsv)) 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/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/token", h.auth("write")(h.handleClusterToken))
mux.HandleFunc(apiPrefix+"/cluster/join", h.auth("write")(h.handleClusterJoin)) mux.HandleFunc(apiPrefix+"/cluster/join", h.auth("write")(h.handleClusterJoin))
mux.HandleFunc(apiPrefix+"/cluster/task", h.auth("write")(h.handleClusterTask)) 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. // downloadAuditCsv streams one of the /audit/*.csv endpoints to a file.
export async function downloadAuditCsv( export async function downloadAuditCsv(
kind: "users" | "apikeys" | "cluster-log", kind: "users" | "apikeys" | "cluster-log" | "worker-logs",
): Promise<void> { ): Promise<void> {
const resp = await fetch(`/api/manager/audit/${kind}.csv`, { const resp = await fetch(`/api/manager/audit/${kind}.csv`, {
credentials: "same-origin", credentials: "same-origin",

View File

@ -154,9 +154,15 @@
</el-dropdown-menu> </el-dropdown-menu>
</template> </template>
</el-dropdown> </el-dropdown>
<button class="w4f-btn ghost small" :disabled="workerLogBusy" @click="exportWorkerLogs"> <el-dropdown trigger="click" :disabled="workerLogBusy" @command="exportWorkerLogs">
导出 worker 日志 <button class="w4f-btn ghost small" :disabled="workerLogBusy"> 导出 worker 日志</button>
</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> </div>
</h3> </h3>
<div class="log-list"> <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. // logs (the actual forwards running on each node) into one downloadable JSON.
// Read-level: auditors use this to inspect worker output cluster-wide. // Read-level: auditors use this to inspect worker output cluster-wide.
const workerLogBusy = ref(false) const workerLogBusy = ref(false)
const exportWorkerLogs = async () => { const exportWorkerLogs = async (fmt = 'json') => {
workerLogBusy.value = true workerLogBusy.value = true
try { 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 bundle = await api.exportWorkerLogs()
const blob = new Blob([JSON.stringify(bundle, null, 2)], { type: 'application/json' }) const blob = new Blob([JSON.stringify(bundle, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob) const url = URL.createObjectURL(blob)