mirror of
https://gitcode.com/JianFeeeee/webui4frpc.git
synced 2026-09-20 17:07:57 +00:00
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:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
4
internal/httpapi/dist/index.html
vendored
4
internal/httpapi/dist/index.html
vendored
@ -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>
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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))
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user