Every sample was stamped with time.Now(), so a timezone switch or NTP step punched a multi-hour gap into the series: old points collapsed to the left edge, new points bunched at the right, joined by one diagonal. Stamp rows from a monotonic seqClock instead — seeded once from the wall clock (or the newest persisted row) and thereafter advanced only by the monotonic elapsed time between writes. Rebase Downsample/Prune on the newest sample rather than time.Now() so a clock step just before the hourly compaction can't drop fresh data. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GofKhuF9xQHaz3UFfncR6D
830 lines
29 KiB
Go
830 lines
29 KiB
Go
package webui
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"mime"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime/debug"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"bee/audit/internal/app"
|
|
"bee/audit/internal/platform"
|
|
"bee/audit/internal/runtimeenv"
|
|
"reanimator/chart/viewer"
|
|
"reanimator/chart/web"
|
|
)
|
|
|
|
const defaultTitle = "Bee Hardware Audit"
|
|
|
|
func init() {
|
|
// On some LiveCD ramdisk environments, /usr/share/mime/globs2 exists but
|
|
// causes an I/O error mid-read. Go's mime package panics (not errors) in
|
|
// that case, crashing the first HTTP goroutine that serves a static file.
|
|
// Pre-trigger initialization here with recover so subsequent calls are safe.
|
|
func() {
|
|
defer func() { recover() }() //nolint:errcheck
|
|
mime.TypeByExtension(".gz")
|
|
}()
|
|
}
|
|
|
|
// HandlerOptions configures the web UI handler.
|
|
type HandlerOptions struct {
|
|
Title string
|
|
BuildLabel 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
|
|
times []time.Time
|
|
size int
|
|
}
|
|
|
|
func newMetricsRing(size int) *metricsRing {
|
|
return &metricsRing{size: size, vals: make([]float64, 0, size), times: make([]time.Time, 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.times = r.times[1:]
|
|
}
|
|
r.vals = append(r.vals, v)
|
|
r.times = append(r.times, time.Now())
|
|
}
|
|
|
|
func (r *metricsRing) snapshot() ([]float64, []string) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
v := make([]float64, len(r.vals))
|
|
copy(v, r.vals)
|
|
labels := make([]string, len(r.times))
|
|
if len(r.times) == 0 {
|
|
return v, labels
|
|
}
|
|
sameDay := timestampsSameLocalDay(r.times)
|
|
for i, t := range r.times {
|
|
labels[i] = formatTimelineLabel(t.Local(), sameDay)
|
|
}
|
|
return v, labels
|
|
}
|
|
|
|
func (r *metricsRing) latest() (float64, bool) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
if len(r.vals) == 0 {
|
|
return 0, false
|
|
}
|
|
return r.vals[len(r.vals)-1], true
|
|
}
|
|
|
|
func timestampsSameLocalDay(times []time.Time) bool {
|
|
if len(times) == 0 {
|
|
return true
|
|
}
|
|
first := times[0].Local()
|
|
for _, t := range times[1:] {
|
|
local := t.Local()
|
|
if local.Year() != first.Year() || local.YearDay() != first.YearDay() {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func formatTimelineLabel(ts time.Time, sameDay bool) string {
|
|
if sameDay {
|
|
return ts.Format("15:04")
|
|
}
|
|
return ts.Format("01-02 15:04")
|
|
}
|
|
|
|
// gpuRings holds per-GPU ring buffers.
|
|
type gpuRings struct {
|
|
Temp *metricsRing
|
|
Util *metricsRing
|
|
MemUtil *metricsRing
|
|
Power *metricsRing
|
|
}
|
|
|
|
type namedMetricsRing struct {
|
|
Name string
|
|
Ring *metricsRing
|
|
}
|
|
|
|
// metricsChartWindow is the number of samples kept in the live ring buffer.
|
|
// At metricsCollectInterval = 5 s this covers 30 minutes of live history.
|
|
const metricsChartWindow = 360
|
|
|
|
// metricsDownsampleAge is the age after which old metrics rows are downsampled
|
|
// to 1 sample per minute. Data fresher than this is kept at full resolution.
|
|
const metricsDownsampleAge = 2 * time.Hour
|
|
|
|
// metricsRetainWindow is the total retention period for metrics rows.
|
|
// Rows older than this are deleted entirely by the background compactor.
|
|
const metricsRetainWindow = 48 * time.Hour
|
|
|
|
var metricsCollectInterval = 5 * time.Second
|
|
|
|
// pendingNetChange tracks a network state change awaiting confirmation.
|
|
type pendingNetChange struct {
|
|
snapshot platform.NetworkSnapshot
|
|
deadline time.Time
|
|
timer *time.Timer
|
|
mu sync.Mutex
|
|
}
|
|
|
|
// handler is the HTTP handler for the web UI.
|
|
type handler struct {
|
|
opts HandlerOptions
|
|
mux *http.ServeMux
|
|
// server rings
|
|
ringCPULoad *metricsRing
|
|
ringMemLoad *metricsRing
|
|
ringPower *metricsRing
|
|
ringFans []*metricsRing
|
|
fanNames []string
|
|
cpuTempRings []*namedMetricsRing
|
|
ambientTempRings []*namedMetricsRing
|
|
// per-GPU rings (index = GPU index)
|
|
gpuRings []*gpuRings
|
|
ringsMu sync.Mutex
|
|
latestMu sync.RWMutex
|
|
latest *platform.LiveMetricSample
|
|
// metrics persistence (nil if DB unavailable)
|
|
metricsDB *MetricsDB
|
|
// pending network change (rollback on timeout)
|
|
pendingNet *pendingNetChange
|
|
pendingNetMu sync.Mutex
|
|
// kmsg hardware error watcher
|
|
kmsg *kmsgWatcher
|
|
}
|
|
|
|
// 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,
|
|
ringCPULoad: newMetricsRing(120),
|
|
ringMemLoad: newMetricsRing(120),
|
|
ringPower: newMetricsRing(120),
|
|
}
|
|
|
|
// Open metrics DB and pre-fill ring buffers from history.
|
|
if db, err := openMetricsDB(metricsDBPath); err == nil {
|
|
h.metricsDB = db
|
|
if samples, err := db.LoadRecent(metricsChartWindow); err == nil {
|
|
for _, s := range samples {
|
|
h.feedRings(s)
|
|
}
|
|
if len(samples) > 0 {
|
|
h.setLatestMetric(samples[len(samples)-1])
|
|
}
|
|
} else {
|
|
slog.Warn("metrics history unavailable", "path", metricsDBPath, "err", err)
|
|
}
|
|
} else {
|
|
slog.Warn("metrics db disabled", "path", metricsDBPath, "err", err)
|
|
}
|
|
h.startMetricsCollector()
|
|
|
|
// Start kmsg hardware error watcher if the app (and its status DB) is available.
|
|
if opts.App != nil {
|
|
h.kmsg = newKmsgWatcher(opts.App.StatusDB)
|
|
h.kmsg.start()
|
|
globalQueue.kmsgWatcher = h.kmsg
|
|
|
|
// Start periodic health poller for components that don't emit kernel log events (e.g. PSU).
|
|
if opts.App.StatusDB != nil {
|
|
newHealthPoller(opts.App.StatusDB).start()
|
|
}
|
|
}
|
|
|
|
globalQueue.startWorker(&opts)
|
|
mux := http.NewServeMux()
|
|
|
|
// ── Infrastructure ──────────────────────────────────────────────────────
|
|
mux.HandleFunc("GET /healthz", h.handleHealthz)
|
|
mux.HandleFunc("GET /api/ready", h.handleReady)
|
|
mux.HandleFunc("GET /loading", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
_, _ = w.Write([]byte(loadingPageHTML))
|
|
})
|
|
|
|
// ── 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/nvidia-targeted-stress/run", h.handleAPISATRun("nvidia-targeted-stress"))
|
|
mux.HandleFunc("POST /api/sat/nvidia-compute/run", h.handleAPISATRun("nvidia-compute"))
|
|
mux.HandleFunc("POST /api/sat/nvidia-targeted-power/run", h.handleAPISATRun("nvidia-targeted-power"))
|
|
mux.HandleFunc("POST /api/sat/nvidia-pulse/run", h.handleAPISATRun("nvidia-pulse"))
|
|
mux.HandleFunc("POST /api/sat/nvidia-interconnect/run", h.handleAPISATRun("nvidia-interconnect"))
|
|
mux.HandleFunc("POST /api/sat/nvidia-bandwidth/run", h.handleAPISATRun("nvidia-bandwidth"))
|
|
mux.HandleFunc("POST /api/sat/nvidia-stress/run", h.handleAPISATRun("nvidia-stress"))
|
|
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/tpm/run", h.handleAPISATRun("tpm"))
|
|
mux.HandleFunc("POST /api/sat/nvidia-config/run", h.handleAPISATRun("nvidia-config"))
|
|
mux.HandleFunc("POST /api/sat/cpu/run", h.handleAPISATRun("cpu"))
|
|
mux.HandleFunc("POST /api/sat/amd/run", h.handleAPISATRun("amd"))
|
|
mux.HandleFunc("POST /api/sat/amd-mem/run", h.handleAPISATRun("amd-mem"))
|
|
mux.HandleFunc("POST /api/sat/amd-bandwidth/run", h.handleAPISATRun("amd-bandwidth"))
|
|
mux.HandleFunc("POST /api/sat/amd-stress/run", h.handleAPISATRun("amd-stress"))
|
|
mux.HandleFunc("POST /api/sat/memory-stress/run", h.handleAPISATRun("memory-stress"))
|
|
mux.HandleFunc("POST /api/sat/sat-stress/run", h.handleAPISATRun("sat-stress"))
|
|
mux.HandleFunc("POST /api/sat/platform-stress/run", h.handleAPISATRun("platform-stress"))
|
|
mux.HandleFunc("POST /api/sat/run-all", h.handleAPISATRunAll)
|
|
mux.HandleFunc("GET /api/sat/stream", h.handleAPISATStream)
|
|
mux.HandleFunc("POST /api/sat/abort", h.handleAPISATAbort)
|
|
mux.HandleFunc("POST /api/bee-bench/nvidia/perf/run", h.handleAPIBenchmarkNvidiaRunKind("nvidia-bench-perf"))
|
|
mux.HandleFunc("POST /api/bee-bench/nvidia/power/run", h.handleAPIBenchmarkNvidiaRunKind("nvidia-bench-power"))
|
|
mux.HandleFunc("POST /api/bee-bench/nvidia/autotune/run", h.handleAPIBenchmarkAutotuneRun())
|
|
mux.HandleFunc("GET /api/scenario/list", h.handleAPIScenarioList)
|
|
mux.HandleFunc("POST /api/scenario/run", h.handleAPIScenarioRun)
|
|
mux.HandleFunc("GET /api/bee-bench/nvidia/autotune/status", h.handleAPIBenchmarkAutotuneStatus)
|
|
mux.HandleFunc("GET /api/benchmark/results", h.handleAPIBenchmarkResults)
|
|
|
|
// Tasks
|
|
mux.HandleFunc("GET /api/tasks", h.handleAPITasksList)
|
|
mux.HandleFunc("POST /api/tasks/cancel-all", h.handleAPITasksCancelAll)
|
|
mux.HandleFunc("POST /api/tasks/kill-workers", h.handleAPITasksKillWorkers)
|
|
mux.HandleFunc("POST /api/tasks/{id}/cancel", h.handleAPITasksCancel)
|
|
mux.HandleFunc("POST /api/tasks/{id}/priority", h.handleAPITasksPriority)
|
|
mux.HandleFunc("GET /api/tasks/{id}/stream", h.handleAPITasksStream)
|
|
mux.HandleFunc("GET /api/tasks/{id}/charts", h.handleAPITaskChartsIndex)
|
|
mux.HandleFunc("GET /api/tasks/{id}/chart/", h.handleAPITaskChartSVG)
|
|
mux.HandleFunc("GET /tasks/{id}", h.handleTaskPage)
|
|
|
|
// 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)
|
|
mux.HandleFunc("POST /api/network/toggle", h.handleAPINetworkToggle)
|
|
mux.HandleFunc("POST /api/network/confirm", h.handleAPINetworkConfirm)
|
|
mux.HandleFunc("POST /api/network/rollback", h.handleAPINetworkRollback)
|
|
|
|
// Export
|
|
mux.HandleFunc("GET /api/export/list", h.handleAPIExportList)
|
|
mux.HandleFunc("GET /api/export/usb", h.handleAPIExportUSBTargets)
|
|
mux.HandleFunc("GET /api/blackbox/status", h.handleAPIBlackboxStatus)
|
|
mux.HandleFunc("POST /api/blackbox/enable", h.handleAPIBlackboxEnable)
|
|
mux.HandleFunc("POST /api/blackbox/disable", h.handleAPIBlackboxDisable)
|
|
|
|
// Tools
|
|
mux.HandleFunc("GET /api/tools/check", h.handleAPIToolsCheck)
|
|
mux.HandleFunc("GET /api/tools/nvme-formats", h.handleAPINVMeFormats)
|
|
mux.HandleFunc("POST /api/tools/nvme-format/run", h.handleAPINVMeFormatRun)
|
|
mux.HandleFunc("GET /api/tools/saa-dmi", h.handleAPISAADMIRead)
|
|
mux.HandleFunc("POST /api/tools/saa-dmi/write", h.handleAPISAADMIWrite)
|
|
mux.HandleFunc("GET /api/tools/ipmi-fru", h.handleAPIIPMIFRURead)
|
|
mux.HandleFunc("POST /api/tools/ipmi-fru/write", h.handleAPIIPMIFRUWrite)
|
|
mux.HandleFunc("GET /api/tools/huawei-elabel", h.handleAPIHuaweiElabelRead)
|
|
mux.HandleFunc("POST /api/tools/huawei-elabel/write", h.handleAPIHuaweiElabelWrite)
|
|
mux.HandleFunc("GET /api/tools/raid/status", h.handleAPIRAIDStatus)
|
|
mux.HandleFunc("POST /api/tools/raid/foreign", h.handleAPIRAIDForeignAction)
|
|
mux.HandleFunc("POST /api/tools/raid/create-mirror", h.handleAPIRAIDCreateMirror)
|
|
mux.HandleFunc("POST /api/tools/raid/prepare-drive", h.handleAPIRAIDPrepareDrive)
|
|
|
|
// GPU presence / tools
|
|
mux.HandleFunc("GET /api/gpu/presence", h.handleAPIGPUPresence)
|
|
mux.HandleFunc("GET /api/gpu/nvidia", h.handleAPIGNVIDIAGPUs)
|
|
mux.HandleFunc("GET /api/gpu/nvidia-status", h.handleAPIGNVIDIAGPUStatuses)
|
|
mux.HandleFunc("POST /api/gpu/nvidia-reset", h.handleAPIGNVIDIAReset)
|
|
mux.HandleFunc("GET /api/gpu/nvidia-settings", h.handleAPIGNVIDIAGPUSettings)
|
|
mux.HandleFunc("POST /api/gpu/nvidia-ecc", h.handleAPIGNVIDIASetECC)
|
|
mux.HandleFunc("POST /api/gpu/nvidia-mig", h.handleAPIGNVIDIASetMIG)
|
|
mux.HandleFunc("POST /api/gpu/nvidia-cc", h.handleAPIGNVIDIASetCCMode)
|
|
mux.HandleFunc("POST /api/gpu/nvidia-power-limit", h.handleAPIGNVIDIASetPowerLimit)
|
|
mux.HandleFunc("POST /api/gpu/nvidia-reset-defaults", h.handleAPIGNVIDIAResetDefaults)
|
|
mux.HandleFunc("GET /api/gpu/tools", h.handleAPIGPUTools)
|
|
|
|
// System
|
|
mux.HandleFunc("GET /api/system/ram-status", h.handleAPIRAMStatus)
|
|
mux.HandleFunc("POST /api/system/install-to-ram", h.handleAPIInstallToRAM)
|
|
mux.HandleFunc("POST /api/system/reboot", h.handleAPISystemReboot)
|
|
mux.HandleFunc("POST /api/system/shutdown", h.handleAPISystemShutdown)
|
|
mux.HandleFunc("GET /api/system/time", h.handleAPISystemTime)
|
|
mux.HandleFunc("POST /api/system/time-sync", h.handleAPISystemTimeSync)
|
|
|
|
// Preflight
|
|
mux.HandleFunc("GET /api/preflight", h.handleAPIPreflight)
|
|
|
|
// Install
|
|
mux.HandleFunc("GET /api/install/disks", h.handleAPIInstallDisks)
|
|
mux.HandleFunc("POST /api/install/run", h.handleAPIInstallRun)
|
|
|
|
// Hardware component detail (fragment for modal in Hardware Summary card)
|
|
mux.HandleFunc("GET /api/hardware-summary", h.handleAPIHardwareSummary)
|
|
mux.HandleFunc("GET /api/components/{type}", h.handleAPIComponentDetail)
|
|
|
|
// Metrics — SSE stream of live sensor data + server-side SVG charts + CSV export
|
|
mux.HandleFunc("GET /api/metrics/stream", h.handleAPIMetricsStream)
|
|
mux.HandleFunc("GET /api/metrics/latest", h.handleAPIMetricsLatest)
|
|
mux.HandleFunc("GET /api/metrics/chart/", h.handleMetricsChartSVG)
|
|
mux.HandleFunc("GET /api/metrics/export.csv", h.handleAPIMetricsExportCSV)
|
|
|
|
// 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 recoverMiddleware(mux)
|
|
}
|
|
|
|
func (h *handler) startMetricsCollector() {
|
|
goRecoverLoop("metrics collector", 2*time.Second, func() {
|
|
ticker := time.NewTicker(metricsCollectInterval)
|
|
defer ticker.Stop()
|
|
pruneTicker := time.NewTicker(time.Hour)
|
|
defer pruneTicker.Stop()
|
|
for {
|
|
select {
|
|
case <-ticker.C:
|
|
sample := platform.SampleLiveMetrics()
|
|
if h.metricsDB != nil {
|
|
_ = h.metricsDB.Write(sample)
|
|
}
|
|
h.feedRings(sample)
|
|
h.setLatestMetric(sample)
|
|
case <-pruneTicker.C:
|
|
if h.metricsDB != nil {
|
|
_ = h.metricsDB.Downsample(metricsDownsampleAge, metricsRetainWindow)
|
|
_ = h.metricsDB.Prune(metricsRetainWindow)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
func (h *handler) setLatestMetric(sample platform.LiveMetricSample) {
|
|
h.latestMu.Lock()
|
|
defer h.latestMu.Unlock()
|
|
cp := sample
|
|
h.latest = &cp
|
|
}
|
|
|
|
func (h *handler) latestMetric() (platform.LiveMetricSample, bool) {
|
|
h.latestMu.RLock()
|
|
defer h.latestMu.RUnlock()
|
|
if h.latest == nil {
|
|
return platform.LiveMetricSample{}, false
|
|
}
|
|
return *h.latest, true
|
|
}
|
|
|
|
// ListenAndServe starts the HTTP server.
|
|
func ListenAndServe(addr string, opts HandlerOptions) error {
|
|
srv := &http.Server{
|
|
Addr: addr,
|
|
Handler: NewHandler(opts),
|
|
ReadHeaderTimeout: 5 * time.Second,
|
|
ReadTimeout: 30 * time.Second,
|
|
IdleTimeout: 2 * time.Minute,
|
|
}
|
|
return srv.ListenAndServe()
|
|
}
|
|
|
|
type trackingResponseWriter struct {
|
|
http.ResponseWriter
|
|
wroteHeader bool
|
|
}
|
|
|
|
func (w *trackingResponseWriter) WriteHeader(statusCode int) {
|
|
w.wroteHeader = true
|
|
w.ResponseWriter.WriteHeader(statusCode)
|
|
}
|
|
|
|
func (w *trackingResponseWriter) Write(p []byte) (int, error) {
|
|
w.wroteHeader = true
|
|
return w.ResponseWriter.Write(p)
|
|
}
|
|
|
|
func (w *trackingResponseWriter) Flush() {
|
|
w.wroteHeader = true
|
|
if f, ok := w.ResponseWriter.(http.Flusher); ok {
|
|
f.Flush()
|
|
}
|
|
}
|
|
|
|
func (w *trackingResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
|
h, ok := w.ResponseWriter.(http.Hijacker)
|
|
if !ok {
|
|
return nil, nil, fmt.Errorf("hijacking not supported")
|
|
}
|
|
return h.Hijack()
|
|
}
|
|
|
|
func (w *trackingResponseWriter) Push(target string, opts *http.PushOptions) error {
|
|
p, ok := w.ResponseWriter.(http.Pusher)
|
|
if !ok {
|
|
return http.ErrNotSupported
|
|
}
|
|
return p.Push(target, opts)
|
|
}
|
|
|
|
func (w *trackingResponseWriter) ReadFrom(r io.Reader) (int64, error) {
|
|
rf, ok := w.ResponseWriter.(io.ReaderFrom)
|
|
if !ok {
|
|
return io.Copy(w.ResponseWriter, r)
|
|
}
|
|
w.wroteHeader = true
|
|
return rf.ReadFrom(r)
|
|
}
|
|
|
|
func recoverMiddleware(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
tw := &trackingResponseWriter{ResponseWriter: w}
|
|
defer func() {
|
|
if rec := recover(); rec != nil {
|
|
slog.Error("http handler panic",
|
|
"method", r.Method,
|
|
"path", r.URL.Path,
|
|
"panic", fmt.Sprint(rec),
|
|
"stack", string(debug.Stack()),
|
|
)
|
|
if !tw.wroteHeader {
|
|
http.Error(tw, "internal server error", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
}()
|
|
next.ServeHTTP(tw, r)
|
|
})
|
|
}
|
|
|
|
// ── 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
|
|
}
|
|
// Re-apply SAT overlay on every request so that SAT results run after the
|
|
// last audit always appear in the downloaded JSON without needing a re-audit.
|
|
if overlaid, err := app.ApplySATOverlay(data); err == nil {
|
|
data = overlaid
|
|
}
|
|
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
|
|
}
|
|
defer os.Remove(archive)
|
|
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
|
|
}
|
|
// Set Content-Type explicitly to avoid mime.TypeByExtension which panics on
|
|
// LiveCD environments where /usr/share/mime/globs2 has an I/O read error.
|
|
w.Header().Set("Content-Type", mimeByExt(filepath.Ext(clean)))
|
|
http.ServeFile(w, r, filepath.Join(h.opts.ExportDir, clean))
|
|
}
|
|
|
|
// mimeByExt returns a Content-Type for known extensions, falling back to
|
|
// application/octet-stream. Used to avoid calling mime.TypeByExtension.
|
|
func mimeByExt(ext string) string {
|
|
switch strings.ToLower(ext) {
|
|
case ".json":
|
|
return "application/json"
|
|
case ".gz":
|
|
return "application/gzip"
|
|
case ".tar":
|
|
return "application/x-tar"
|
|
case ".log", ".txt":
|
|
return "text/plain; charset=utf-8"
|
|
case ".html":
|
|
return "text/html; charset=utf-8"
|
|
case ".svg":
|
|
return "image/svg+xml"
|
|
default:
|
|
return "application/octet-stream"
|
|
}
|
|
}
|
|
|
|
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)
|
|
snapshot = enrichSnapshotForViewer(snapshot)
|
|
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) handleAPIMetricsExportCSV(w http.ResponseWriter, r *http.Request) {
|
|
if h.metricsDB == nil {
|
|
http.Error(w, "metrics database not available", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
|
w.Header().Set("Content-Disposition", `attachment; filename="bee-metrics.csv"`)
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
_ = h.metricsDB.ExportCSV(w)
|
|
}
|
|
|
|
// ── Page handler ─────────────────────────────────────────────────────────────
|
|
|
|
func (h *handler) handleReady(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
if strings.TrimSpace(h.opts.AuditPath) == "" {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("ready"))
|
|
return
|
|
}
|
|
if _, err := os.Stat(h.opts.AuditPath); err != nil {
|
|
w.WriteHeader(http.StatusServiceUnavailable)
|
|
_, _ = w.Write([]byte("starting"))
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("ready"))
|
|
}
|
|
|
|
const loadingPageHTML = `<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<title>EASY-BEE — Starting</title>
|
|
<style>
|
|
*{margin:0;padding:0;box-sizing:border-box}
|
|
html,body{height:100%;background:#0f1117;display:flex;align-items:center;justify-content:center;font-family:'Courier New',monospace;color:#e2e8f0}
|
|
.wrap{text-align:center;width:420px}
|
|
.brand{font-size:22px;letter-spacing:.18em;color:#f6c90e;margin-bottom:6px;text-align:left}
|
|
.subtitle{font-size:12px;color:#a0aec0;text-align:left;margin-bottom:24px}
|
|
.spinner{width:36px;height:36px;border:3px solid #2d3748;border-top-color:#f6c90e;border-radius:50%;animation:spin .8s linear infinite;margin:0 auto 14px}
|
|
.spinner.hidden{display:none}
|
|
@keyframes spin{to{transform:rotate(360deg)}}
|
|
.status{font-size:13px;color:#a0aec0;margin-bottom:20px;min-height:18px}
|
|
table{width:100%;border-collapse:collapse;font-size:12px;margin-bottom:20px;display:none}
|
|
td{padding:3px 6px;text-align:left}
|
|
td:first-child{color:#718096;width:55%}
|
|
.ok{color:#68d391}
|
|
.run{color:#f6c90e}
|
|
.fail{color:#fc8181}
|
|
.dim{color:#4a5568}
|
|
.btn{background:#1a202c;color:#a0aec0;border:1px solid #2d3748;padding:7px 18px;font-size:12px;cursor:pointer;font-family:inherit;display:none}
|
|
.btn:hover{border-color:#718096;color:#e2e8f0}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="wrap">
|
|
<div class="brand">EASY BEE</div>
|
|
<div class="subtitle">Hardware Audit LiveCD</div>
|
|
<div class="spinner" id="spin"></div>
|
|
<div class="status" id="st">Connecting to bee-web...</div>
|
|
<table id="tbl"></table>
|
|
<button class="btn" id="btn" onclick="go()">Open app now</button>
|
|
</div>
|
|
<script>
|
|
(function(){
|
|
var gone = false;
|
|
var pollStarted = false;
|
|
var fallbackOpenTimer = null;
|
|
var AUTO_OPEN_DELAY_MS = 15000;
|
|
function go(){ if(!gone){gone=true;window.location.replace('/');} }
|
|
|
|
function scheduleFallbackOpen(){
|
|
if(fallbackOpenTimer!==null) return;
|
|
fallbackOpenTimer=setTimeout(function(){
|
|
document.getElementById('spin').className='spinner hidden';
|
|
document.getElementById('st').textContent='Startup checks are taking too long — opening app...';
|
|
go();
|
|
},AUTO_OPEN_DELAY_MS);
|
|
}
|
|
|
|
function icon(s){
|
|
if(s==='active') return '<span class="ok">● active</span>';
|
|
if(s==='failed') return '<span class="fail">✕ failed</span>';
|
|
if(s==='activating'||s==='reloading') return '<span class="run">○ starting</span>';
|
|
if(s==='inactive') return '<span class="dim">○ inactive</span>';
|
|
return '<span class="dim">'+s+'</span>';
|
|
}
|
|
|
|
function allSettled(svcs){
|
|
for(var i=0;i<svcs.length;i++){
|
|
var s=svcs[i].state;
|
|
if(s!=='active'&&s!=='failed'&&s!=='inactive') return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
var pollTimer=null;
|
|
|
|
function pollServices(){
|
|
fetch('/api/services',{cache:'no-store'})
|
|
.then(function(r){return r.json();})
|
|
.then(function(svcs){
|
|
if(!svcs||!svcs.length) return;
|
|
var tbl=document.getElementById('tbl');
|
|
tbl.style.display='';
|
|
var html='';
|
|
for(var i=0;i<svcs.length;i++)
|
|
html+='<tr><td>'+svcs[i].name+'</td><td>'+icon(svcs[i].state)+'</td></tr>';
|
|
tbl.innerHTML=html;
|
|
if(allSettled(svcs)){
|
|
clearInterval(pollTimer);
|
|
if(fallbackOpenTimer!==null) clearTimeout(fallbackOpenTimer);
|
|
document.getElementById('spin').className='spinner hidden';
|
|
document.getElementById('st').textContent='Ready \u2014 opening...';
|
|
setTimeout(go,800);
|
|
}
|
|
})
|
|
.catch(function(){});
|
|
}
|
|
|
|
function probe(){
|
|
fetch('/healthz',{cache:'no-store'})
|
|
.then(function(r){
|
|
if(r.ok){
|
|
document.getElementById('st').textContent='bee-web running \u2014 checking services...';
|
|
document.getElementById('btn').style.display='';
|
|
scheduleFallbackOpen();
|
|
if(!pollStarted){
|
|
pollStarted=true;
|
|
pollServices();
|
|
pollTimer=setInterval(pollServices,1500);
|
|
}
|
|
} else {
|
|
document.getElementById('st').textContent='bee-web starting (status '+r.status+')...';
|
|
setTimeout(probe,500);
|
|
}
|
|
})
|
|
.catch(function(){
|
|
document.getElementById('st').textContent='Waiting for bee-web to start...';
|
|
setTimeout(probe,500);
|
|
});
|
|
}
|
|
probe();
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>`
|
|
|
|
func (h *handler) handlePage(w http.ResponseWriter, r *http.Request) {
|
|
page := strings.TrimPrefix(r.URL.Path, "/")
|
|
if page == "" {
|
|
page = "dashboard"
|
|
}
|
|
// Redirect legacy routes to new named pages
|
|
switch page {
|
|
case "validate", "tests":
|
|
http.Redirect(w, r, "/load", http.StatusMovedPermanently)
|
|
return
|
|
case "burn-in":
|
|
http.Redirect(w, r, "/burn", http.StatusMovedPermanently)
|
|
return
|
|
case "speed", "endurance":
|
|
http.Redirect(w, r, "/benchmark", http.StatusMovedPermanently)
|
|
return
|
|
}
|
|
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})
|
|
}
|
|
|
|
func writeAPIListResponse[T any](w http.ResponseWriter, configured bool, load func() ([]T, error)) {
|
|
if !configured {
|
|
writeError(w, http.StatusServiceUnavailable, "app not configured")
|
|
return
|
|
}
|
|
items, err := load()
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
if items == nil {
|
|
items = []T{}
|
|
}
|
|
writeJSON(w, items)
|
|
}
|