Mount chart submodule static assets at /static/ (matching the template's hardcoded href), fix nav to include Audit Snapshot tab, remove dead renderViewerPage code and iframe from Dashboard. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
415 lines
12 KiB
Go
415 lines
12 KiB
Go
package webui
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"bee/audit/internal/app"
|
|
"bee/audit/internal/runtimeenv"
|
|
gocharts "github.com/go-analyze/charts"
|
|
"reanimator/chart/viewer"
|
|
"reanimator/chart/web"
|
|
)
|
|
|
|
const defaultTitle = "Bee Hardware Audit"
|
|
|
|
// HandlerOptions configures the web UI handler.
|
|
type HandlerOptions struct {
|
|
Title string
|
|
AuditPath string
|
|
ExportDir string
|
|
App *app.App
|
|
RuntimeMode runtimeenv.Mode
|
|
}
|
|
|
|
// metricsRing holds a rolling window of live metric samples.
|
|
type metricsRing struct {
|
|
mu sync.Mutex
|
|
vals []float64
|
|
labels []string
|
|
size int
|
|
}
|
|
|
|
func newMetricsRing(size int) *metricsRing {
|
|
return &metricsRing{size: size, vals: make([]float64, 0, size), labels: make([]string, 0, size)}
|
|
}
|
|
|
|
func (r *metricsRing) push(v float64) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
if len(r.vals) >= r.size {
|
|
r.vals = r.vals[1:]
|
|
r.labels = r.labels[1:]
|
|
}
|
|
r.vals = append(r.vals, v)
|
|
r.labels = append(r.labels, time.Now().Format("15:04"))
|
|
}
|
|
|
|
func (r *metricsRing) snapshot() ([]float64, []string) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
v := make([]float64, len(r.vals))
|
|
l := make([]string, len(r.labels))
|
|
copy(v, r.vals)
|
|
copy(l, r.labels)
|
|
return v, l
|
|
}
|
|
|
|
// gpuRings holds per-GPU ring buffers.
|
|
type gpuRings struct {
|
|
Temp *metricsRing
|
|
Util *metricsRing
|
|
MemUtil *metricsRing
|
|
Power *metricsRing
|
|
}
|
|
|
|
// handler is the HTTP handler for the web UI.
|
|
type handler struct {
|
|
opts HandlerOptions
|
|
mux *http.ServeMux
|
|
// server rings
|
|
ringCPUTemp *metricsRing
|
|
ringCPULoad *metricsRing
|
|
ringMemLoad *metricsRing
|
|
ringPower *metricsRing
|
|
ringFans []*metricsRing
|
|
fanNames []string
|
|
// per-GPU rings (index = GPU index)
|
|
gpuRings []*gpuRings
|
|
ringsMu sync.Mutex
|
|
}
|
|
|
|
// NewHandler creates the HTTP mux with all routes.
|
|
func NewHandler(opts HandlerOptions) http.Handler {
|
|
if strings.TrimSpace(opts.Title) == "" {
|
|
opts.Title = defaultTitle
|
|
}
|
|
if strings.TrimSpace(opts.ExportDir) == "" {
|
|
opts.ExportDir = app.DefaultExportDir
|
|
}
|
|
if opts.RuntimeMode == "" {
|
|
opts.RuntimeMode = runtimeenv.ModeAuto
|
|
}
|
|
|
|
h := &handler{
|
|
opts: opts,
|
|
ringCPUTemp: newMetricsRing(120),
|
|
ringCPULoad: newMetricsRing(120),
|
|
ringMemLoad: newMetricsRing(120),
|
|
ringPower: newMetricsRing(120),
|
|
}
|
|
mux := http.NewServeMux()
|
|
|
|
// ── Infrastructure ──────────────────────────────────────────────────────
|
|
mux.HandleFunc("GET /healthz", h.handleHealthz)
|
|
|
|
// ── Existing read-only endpoints (preserved for compatibility) ──────────
|
|
mux.HandleFunc("GET /audit.json", h.handleAuditJSON)
|
|
mux.HandleFunc("GET /runtime-health.json", h.handleRuntimeHealthJSON)
|
|
mux.HandleFunc("GET /export/support.tar.gz", h.handleSupportBundleDownload)
|
|
mux.HandleFunc("GET /export/file", h.handleExportFile)
|
|
mux.HandleFunc("GET /export/", h.handleExportIndex)
|
|
mux.HandleFunc("GET /viewer", h.handleViewer)
|
|
|
|
// ── API ──────────────────────────────────────────────────────────────────
|
|
// Audit
|
|
mux.HandleFunc("POST /api/audit/run", h.handleAPIAuditRun)
|
|
mux.HandleFunc("GET /api/audit/stream", h.handleAPIAuditStream)
|
|
|
|
// SAT
|
|
mux.HandleFunc("POST /api/sat/nvidia/run", h.handleAPISATRun("nvidia"))
|
|
mux.HandleFunc("POST /api/sat/memory/run", h.handleAPISATRun("memory"))
|
|
mux.HandleFunc("POST /api/sat/storage/run", h.handleAPISATRun("storage"))
|
|
mux.HandleFunc("POST /api/sat/cpu/run", h.handleAPISATRun("cpu"))
|
|
mux.HandleFunc("GET /api/sat/stream", h.handleAPISATStream)
|
|
|
|
// Services
|
|
mux.HandleFunc("GET /api/services", h.handleAPIServicesList)
|
|
mux.HandleFunc("POST /api/services/action", h.handleAPIServicesAction)
|
|
|
|
// Network
|
|
mux.HandleFunc("GET /api/network", h.handleAPINetworkStatus)
|
|
mux.HandleFunc("POST /api/network/dhcp", h.handleAPINetworkDHCP)
|
|
mux.HandleFunc("POST /api/network/static", h.handleAPINetworkStatic)
|
|
|
|
// Export
|
|
mux.HandleFunc("GET /api/export/list", h.handleAPIExportList)
|
|
mux.HandleFunc("POST /api/export/bundle", h.handleAPIExportBundle)
|
|
|
|
// Tools
|
|
mux.HandleFunc("GET /api/tools/check", h.handleAPIToolsCheck)
|
|
|
|
// Preflight
|
|
mux.HandleFunc("GET /api/preflight", h.handleAPIPreflight)
|
|
|
|
// Metrics — SSE stream of live sensor data + server-side SVG charts
|
|
mux.HandleFunc("GET /api/metrics/stream", h.handleAPIMetricsStream)
|
|
mux.HandleFunc("GET /api/metrics/chart/", h.handleMetricsChartSVG)
|
|
|
|
// Reanimator chart static assets (viewer template expects /static/*)
|
|
mux.Handle("GET /static/", http.StripPrefix("/static/", web.Static()))
|
|
|
|
// ── Pages ────────────────────────────────────────────────────────────────
|
|
mux.HandleFunc("GET /", h.handlePage)
|
|
|
|
h.mux = mux
|
|
return mux
|
|
}
|
|
|
|
// ListenAndServe starts the HTTP server.
|
|
func ListenAndServe(addr string, opts HandlerOptions) error {
|
|
return http.ListenAndServe(addr, NewHandler(opts))
|
|
}
|
|
|
|
// ── Infrastructure handlers ──────────────────────────────────────────────────
|
|
|
|
func (h *handler) handleHealthz(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("ok"))
|
|
}
|
|
|
|
// ── Compatibility endpoints ──────────────────────────────────────────────────
|
|
|
|
func (h *handler) handleAuditJSON(w http.ResponseWriter, r *http.Request) {
|
|
data, err := loadSnapshot(h.opts.AuditPath)
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
http.Error(w, "audit snapshot not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
http.Error(w, fmt.Sprintf("read audit snapshot: %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)
|
|
}
|
|
|
|
func (h *handler) handleRuntimeHealthJSON(w http.ResponseWriter, r *http.Request) {
|
|
data, err := loadSnapshot(filepath.Join(h.opts.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)
|
|
}
|
|
|
|
func (h *handler) handleSupportBundleDownload(w http.ResponseWriter, r *http.Request) {
|
|
archive, err := app.BuildSupportBundle(h.opts.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)
|
|
}
|
|
|
|
func (h *handler) handleExportFile(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(h.opts.ExportDir, clean))
|
|
}
|
|
|
|
func (h *handler) handleExportIndex(w http.ResponseWriter, r *http.Request) {
|
|
body, err := renderExportIndex(h.opts.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))
|
|
}
|
|
|
|
func (h *handler) handleViewer(w http.ResponseWriter, r *http.Request) {
|
|
snapshot, _ := loadSnapshot(h.opts.AuditPath)
|
|
body, err := viewer.RenderHTML(snapshot, h.opts.Title)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
_, _ = w.Write(body)
|
|
}
|
|
|
|
func (h *handler) handleMetricsChartSVG(w http.ResponseWriter, r *http.Request) {
|
|
path := strings.TrimPrefix(r.URL.Path, "/api/metrics/chart/")
|
|
path = strings.TrimSuffix(path, ".svg")
|
|
|
|
var datasets [][]float64
|
|
var names []string
|
|
var labels []string
|
|
var title string
|
|
|
|
switch {
|
|
case path == "server":
|
|
title = "Server"
|
|
vCPUTemp, l := h.ringCPUTemp.snapshot()
|
|
vCPULoad, _ := h.ringCPULoad.snapshot()
|
|
vMemLoad, _ := h.ringMemLoad.snapshot()
|
|
vPower, _ := h.ringPower.snapshot()
|
|
labels = l
|
|
datasets = [][]float64{vCPUTemp, vCPULoad, vMemLoad, vPower}
|
|
names = []string{"CPU Temp °C", "CPU Load %", "Mem Load %", "Power W"}
|
|
|
|
h.ringsMu.Lock()
|
|
for i, fr := range h.ringFans {
|
|
fv, _ := fr.snapshot()
|
|
datasets = append(datasets, fv)
|
|
name := "Fan"
|
|
if i < len(h.fanNames) {
|
|
name = h.fanNames[i]
|
|
}
|
|
names = append(names, name+" RPM")
|
|
}
|
|
h.ringsMu.Unlock()
|
|
|
|
case strings.HasPrefix(path, "gpu/"):
|
|
idxStr := strings.TrimPrefix(path, "gpu/")
|
|
idx := 0
|
|
fmt.Sscanf(idxStr, "%d", &idx)
|
|
h.ringsMu.Lock()
|
|
var gr *gpuRings
|
|
if idx < len(h.gpuRings) {
|
|
gr = h.gpuRings[idx]
|
|
}
|
|
h.ringsMu.Unlock()
|
|
if gr == nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
vTemp, l := gr.Temp.snapshot()
|
|
vUtil, _ := gr.Util.snapshot()
|
|
vMemUtil, _ := gr.MemUtil.snapshot()
|
|
vPower, _ := gr.Power.snapshot()
|
|
labels = l
|
|
title = fmt.Sprintf("GPU %d", idx)
|
|
datasets = [][]float64{vTemp, vUtil, vMemUtil, vPower}
|
|
names = []string{"Temp °C", "Load %", "Mem %", "Power W"}
|
|
|
|
default:
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
// Ensure all datasets same length as labels
|
|
n := len(labels)
|
|
if n == 0 {
|
|
n = 1
|
|
labels = []string{""}
|
|
}
|
|
for i := range datasets {
|
|
if len(datasets[i]) == 0 {
|
|
datasets[i] = make([]float64, n)
|
|
}
|
|
}
|
|
|
|
sparse := sparseLabels(labels, 6)
|
|
|
|
opt := gocharts.NewLineChartOptionWithData(datasets)
|
|
opt.Title = gocharts.TitleOption{Text: title}
|
|
opt.XAxis.Labels = sparse
|
|
opt.Legend = gocharts.LegendOption{SeriesNames: names}
|
|
|
|
p := gocharts.NewPainter(gocharts.PainterOptions{
|
|
OutputFormat: gocharts.ChartOutputSVG,
|
|
Width: 1400,
|
|
Height: 280,
|
|
}, gocharts.PainterThemeOption(gocharts.GetTheme("grafana")))
|
|
if err := p.LineChart(opt); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
buf, err := p.Bytes()
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "image/svg+xml")
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
_, _ = w.Write(buf)
|
|
}
|
|
|
|
func safeIdx(s []float64, i int) float64 {
|
|
if i < len(s) {
|
|
return s[i]
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func sparseLabels(labels []string, n int) []string {
|
|
out := make([]string, len(labels))
|
|
step := len(labels) / n
|
|
if step < 1 {
|
|
step = 1
|
|
}
|
|
for i, l := range labels {
|
|
if i%step == 0 {
|
|
out[i] = l
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ── Page handler ─────────────────────────────────────────────────────────────
|
|
|
|
func (h *handler) handlePage(w http.ResponseWriter, r *http.Request) {
|
|
page := strings.TrimPrefix(r.URL.Path, "/")
|
|
if page == "" {
|
|
page = "dashboard"
|
|
}
|
|
body := renderPage(page, h.opts)
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
_, _ = w.Write([]byte(body))
|
|
}
|
|
|
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
func loadSnapshot(path string) ([]byte, error) {
|
|
if strings.TrimSpace(path) == "" {
|
|
return nil, os.ErrNotExist
|
|
}
|
|
return os.ReadFile(path)
|
|
}
|
|
|
|
// writeJSON sends v as JSON with status 200.
|
|
func writeJSON(w http.ResponseWriter, v any) {
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
_ = json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
// writeError sends a JSON error response.
|
|
func writeError(w http.ResponseWriter, status int, msg string) {
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
|
}
|