feat: add support bundle and raw audit export
This commit is contained in:
@@ -3,10 +3,15 @@ package webui
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"bee/audit/internal/app"
|
||||
"reanimator/chart/viewer"
|
||||
chartweb "reanimator/chart/web"
|
||||
)
|
||||
@@ -16,6 +21,7 @@ const defaultTitle = "Bee Hardware Audit"
|
||||
type HandlerOptions struct {
|
||||
Title string
|
||||
AuditPath string
|
||||
ExportDir string
|
||||
}
|
||||
|
||||
func NewHandler(opts HandlerOptions) http.Handler {
|
||||
@@ -25,6 +31,10 @@ func NewHandler(opts HandlerOptions) http.Handler {
|
||||
}
|
||||
|
||||
auditPath := strings.TrimSpace(opts.AuditPath)
|
||||
exportDir := strings.TrimSpace(opts.ExportDir)
|
||||
if exportDir == "" {
|
||||
exportDir = app.DefaultExportDir
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("GET /static/", http.StripPrefix("/static/", chartweb.Static()))
|
||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -46,14 +56,67 @@ func NewHandler(opts HandlerOptions) http.Handler {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_, _ = w.Write(data)
|
||||
})
|
||||
mux.HandleFunc("GET /export/support.tar.gz", func(w http.ResponseWriter, r *http.Request) {
|
||||
archive, err := app.BuildSupportBundle(exportDir)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("build support bundle: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Content-Type", "application/gzip")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filepath.Base(archive)))
|
||||
http.ServeFile(w, r, archive)
|
||||
})
|
||||
mux.HandleFunc("GET /runtime-health.json", func(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := loadSnapshot(filepath.Join(exportDir, "runtime-health.json"))
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
http.Error(w, "runtime health not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
http.Error(w, fmt.Sprintf("read runtime health: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_, _ = w.Write(data)
|
||||
})
|
||||
mux.HandleFunc("GET /export/", func(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := renderExportIndex(exportDir)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("render export index: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write([]byte(body))
|
||||
})
|
||||
mux.HandleFunc("GET /export/file", func(w http.ResponseWriter, r *http.Request) {
|
||||
rel := strings.TrimSpace(r.URL.Query().Get("path"))
|
||||
if rel == "" {
|
||||
http.Error(w, "path is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
clean := filepath.Clean(rel)
|
||||
if clean == "." || strings.HasPrefix(clean, "..") {
|
||||
http.Error(w, "invalid path", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
http.ServeFile(w, r, filepath.Join(exportDir, clean))
|
||||
})
|
||||
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
|
||||
snapshot, err := loadSnapshot(auditPath)
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
http.Error(w, fmt.Sprintf("read audit snapshot: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
html, err := viewer.RenderHTML(snapshot, title)
|
||||
noticeTitle, noticeBody := runtimeNotice(filepath.Join(exportDir, "runtime-health.json"))
|
||||
html, err := viewer.RenderHTMLWithOptions(snapshot, title, viewer.RenderOptions{
|
||||
DownloadArchiveURL: "/export/support.tar.gz",
|
||||
DownloadArchiveLabel: "Download support bundle",
|
||||
NoticeTitle: noticeTitle,
|
||||
NoticeBody: noticeBody,
|
||||
})
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("render snapshot: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
@@ -75,3 +138,67 @@ func loadSnapshot(path string) ([]byte, error) {
|
||||
}
|
||||
return os.ReadFile(path)
|
||||
}
|
||||
|
||||
func runtimeNotice(path string) (string, string) {
|
||||
health, err := app.ReadRuntimeHealth(path)
|
||||
if err != nil {
|
||||
return "Runtime Health", "No runtime health snapshot found yet."
|
||||
}
|
||||
body := fmt.Sprintf("Status: %s. Export dir: %s. Driver ready: %t. CUDA ready: %t. Network: %s. Export files: /export/",
|
||||
firstNonEmpty(health.Status, "UNKNOWN"),
|
||||
firstNonEmpty(health.ExportDir, app.DefaultExportDir),
|
||||
health.DriverReady,
|
||||
health.CUDAReady,
|
||||
firstNonEmpty(health.NetworkStatus, "UNKNOWN"),
|
||||
)
|
||||
if len(health.Issues) > 0 {
|
||||
body += " Issues: "
|
||||
parts := make([]string, 0, len(health.Issues))
|
||||
for _, issue := range health.Issues {
|
||||
parts = append(parts, issue.Code)
|
||||
}
|
||||
body += strings.Join(parts, ", ")
|
||||
}
|
||||
return "Runtime Health", body
|
||||
}
|
||||
|
||||
func renderExportIndex(exportDir string) (string, error) {
|
||||
var entries []string
|
||||
err := filepath.Walk(strings.TrimSpace(exportDir), func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
rel, err := filepath.Rel(exportDir, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
entries = append(entries, rel)
|
||||
return nil
|
||||
})
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return "", err
|
||||
}
|
||||
sort.Strings(entries)
|
||||
var body strings.Builder
|
||||
body.WriteString("<!DOCTYPE html><html><head><meta charset=\"utf-8\"><title>Bee Export Files</title></head><body>")
|
||||
body.WriteString("<h1>Bee Export Files</h1><ul>")
|
||||
for _, entry := range entries {
|
||||
body.WriteString("<li><a href=\"/export/file?path=" + url.QueryEscape(entry) + "\">" + html.EscapeString(entry) + "</a></li>")
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
body.WriteString("<li>No export files found.</li>")
|
||||
}
|
||||
body.WriteString("</ul></body></html>")
|
||||
return body.String(), nil
|
||||
}
|
||||
|
||||
func firstNonEmpty(value, fallback string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user