1634 lines
48 KiB
Go
1634 lines
48 KiB
Go
package webui
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"html"
|
|
"io"
|
|
"log/slog"
|
|
"math"
|
|
"mime"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime/debug"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"bee/audit/internal/app"
|
|
"bee/audit/internal/platform"
|
|
"bee/audit/internal/runtimeenv"
|
|
gocharts "github.com/go-analyze/charts"
|
|
"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
|
|
|
|
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
|
|
}
|
|
|
|
globalQueue.startWorker(&opts)
|
|
mux := http.NewServeMux()
|
|
|
|
// ── Infrastructure ──────────────────────────────────────────────────────
|
|
mux.HandleFunc("GET /healthz", h.handleHealthz)
|
|
mux.HandleFunc("GET /api/ready", h.handleReady)
|
|
|
|
// ── 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-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/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("GET /api/sat/stream", h.handleAPISATStream)
|
|
mux.HandleFunc("POST /api/sat/abort", h.handleAPISATAbort)
|
|
mux.HandleFunc("POST /api/benchmark/nvidia/run", h.handleAPIBenchmarkNvidiaRun)
|
|
|
|
// 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)
|
|
|
|
// 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("POST /api/export/usb/audit", h.handleAPIExportUSBAudit)
|
|
mux.HandleFunc("POST /api/export/usb/bundle", h.handleAPIExportUSBBundle)
|
|
|
|
// Tools
|
|
mux.HandleFunc("GET /api/tools/check", h.handleAPIToolsCheck)
|
|
|
|
// Display
|
|
mux.HandleFunc("GET /api/display/resolutions", h.handleAPIDisplayResolutions)
|
|
mux.HandleFunc("POST /api/display/set", h.handleAPIDisplaySet)
|
|
|
|
// GPU presence / tools
|
|
mux.HandleFunc("GET /api/gpu/presence", h.handleAPIGPUPresence)
|
|
mux.HandleFunc("GET /api/gpu/nvidia", h.handleAPIGNVIDIAGPUs)
|
|
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)
|
|
|
|
// 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)
|
|
|
|
// 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()
|
|
for range ticker.C {
|
|
sample := platform.SampleLiveMetrics()
|
|
if h.metricsDB != nil {
|
|
_ = h.metricsDB.Write(sample)
|
|
}
|
|
h.feedRings(sample)
|
|
h.setLatestMetric(sample)
|
|
}
|
|
})
|
|
}
|
|
|
|
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)
|
|
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")
|
|
|
|
if h.metricsDB == nil {
|
|
http.Error(w, "metrics database not available", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
if idx, sub, ok := parseGPUChartPath(path); ok && sub == "overview" {
|
|
samples, err := h.metricsDB.LoadAll()
|
|
if err != nil || len(samples) == 0 {
|
|
http.Error(w, "metrics history unavailable", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
buf, ok, err := renderGPUOverviewChartSVG(idx, samples)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if !ok {
|
|
http.Error(w, "metrics history unavailable", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "image/svg+xml")
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
_, _ = w.Write(buf)
|
|
return
|
|
}
|
|
datasets, names, labels, title, yMin, yMax, ok := h.chartDataFromDB(path)
|
|
if !ok {
|
|
http.Error(w, "metrics history unavailable", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
buf, err := renderChartSVGWithHeight(title, datasets, names, labels, yMin, yMax, chartCanvasHeightForPath(path, len(names)))
|
|
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 (h *handler) chartDataFromDB(path string) ([][]float64, []string, []string, string, *float64, *float64, bool) {
|
|
samples, err := h.metricsDB.LoadAll()
|
|
if err != nil || len(samples) == 0 {
|
|
return nil, nil, nil, "", nil, nil, false
|
|
}
|
|
return chartDataFromSamples(path, samples)
|
|
}
|
|
|
|
func chartDataFromSamples(path string, samples []platform.LiveMetricSample) ([][]float64, []string, []string, string, *float64, *float64, bool) {
|
|
var datasets [][]float64
|
|
var names []string
|
|
var title string
|
|
var yMin, yMax *float64
|
|
labels := sampleTimeLabels(samples)
|
|
|
|
switch {
|
|
case path == "server-load":
|
|
title = "CPU / Memory Load"
|
|
cpu := make([]float64, len(samples))
|
|
mem := make([]float64, len(samples))
|
|
for i, s := range samples {
|
|
cpu[i] = s.CPULoadPct
|
|
mem[i] = s.MemLoadPct
|
|
}
|
|
datasets = [][]float64{cpu, mem}
|
|
names = []string{"CPU Load %", "Mem Load %"}
|
|
yMin = floatPtr(0)
|
|
yMax = floatPtr(100)
|
|
|
|
case path == "server-temp", path == "server-temp-cpu":
|
|
title = "CPU Temperature"
|
|
datasets, names = namedTempDatasets(samples, "cpu")
|
|
yMin = floatPtr(0)
|
|
yMax = autoMax120(datasets...)
|
|
|
|
case path == "server-temp-gpu":
|
|
title = "GPU Temperature"
|
|
datasets, names = gpuDatasets(samples, func(g platform.GPUMetricRow) float64 { return g.TempC })
|
|
yMin = floatPtr(0)
|
|
yMax = autoMax120(datasets...)
|
|
|
|
case path == "server-temp-ambient":
|
|
title = "Ambient / Other Sensors"
|
|
datasets, names = namedTempDatasets(samples, "ambient")
|
|
yMin = floatPtr(0)
|
|
yMax = autoMax120(datasets...)
|
|
|
|
case path == "server-power":
|
|
title = "System Power"
|
|
power := make([]float64, len(samples))
|
|
for i, s := range samples {
|
|
power[i] = s.PowerW
|
|
}
|
|
power = normalizePowerSeries(power)
|
|
datasets = [][]float64{power}
|
|
names = []string{"Power W"}
|
|
yMin = floatPtr(0)
|
|
yMax = autoMax120(power)
|
|
|
|
case path == "server-fans":
|
|
title = "Fan RPM"
|
|
datasets, names = namedFanDatasets(samples)
|
|
yMin, yMax = autoBounds120(datasets...)
|
|
|
|
case path == "gpu-all-load":
|
|
title = "GPU Compute Load"
|
|
datasets, names = gpuDatasets(samples, func(g platform.GPUMetricRow) float64 { return g.UsagePct })
|
|
yMin = floatPtr(0)
|
|
yMax = floatPtr(100)
|
|
|
|
case path == "gpu-all-memload":
|
|
title = "GPU Memory Load"
|
|
datasets, names = gpuDatasets(samples, func(g platform.GPUMetricRow) float64 { return g.MemUsagePct })
|
|
yMin = floatPtr(0)
|
|
yMax = floatPtr(100)
|
|
|
|
case path == "gpu-all-power":
|
|
title = "GPU Power"
|
|
datasets, names = gpuDatasets(samples, func(g platform.GPUMetricRow) float64 { return g.PowerW })
|
|
yMin, yMax = autoBounds120(datasets...)
|
|
|
|
case path == "gpu-all-temp":
|
|
title = "GPU Temperature"
|
|
datasets, names = gpuDatasets(samples, func(g platform.GPUMetricRow) float64 { return g.TempC })
|
|
yMin = floatPtr(0)
|
|
yMax = autoMax120(datasets...)
|
|
|
|
case path == "gpu-all-clock":
|
|
title = "GPU Core Clock"
|
|
datasets, names = gpuDatasets(samples, func(g platform.GPUMetricRow) float64 { return g.ClockMHz })
|
|
yMin, yMax = autoBounds120(datasets...)
|
|
|
|
case path == "gpu-all-memclock":
|
|
title = "GPU Memory Clock"
|
|
datasets, names = gpuDatasets(samples, func(g platform.GPUMetricRow) float64 { return g.MemClockMHz })
|
|
yMin, yMax = autoBounds120(datasets...)
|
|
|
|
case strings.HasPrefix(path, "gpu/"):
|
|
idx, sub, ok := parseGPUChartPath(path)
|
|
if !ok {
|
|
return nil, nil, nil, "", nil, nil, false
|
|
}
|
|
switch sub {
|
|
case "load":
|
|
title = fmt.Sprintf("GPU %d Load", idx)
|
|
util := gpuDatasetByIndex(samples, idx, func(g platform.GPUMetricRow) float64 { return g.UsagePct })
|
|
mem := gpuDatasetByIndex(samples, idx, func(g platform.GPUMetricRow) float64 { return g.MemUsagePct })
|
|
if util == nil && mem == nil {
|
|
return nil, nil, nil, "", nil, nil, false
|
|
}
|
|
datasets = [][]float64{coalesceDataset(util, len(samples)), coalesceDataset(mem, len(samples))}
|
|
names = []string{"Load %", "Mem %"}
|
|
yMin = floatPtr(0)
|
|
yMax = floatPtr(100)
|
|
case "temp":
|
|
title = fmt.Sprintf("GPU %d Temperature", idx)
|
|
temp := gpuDatasetByIndex(samples, idx, func(g platform.GPUMetricRow) float64 { return g.TempC })
|
|
if temp == nil {
|
|
return nil, nil, nil, "", nil, nil, false
|
|
}
|
|
datasets = [][]float64{temp}
|
|
names = []string{"Temp °C"}
|
|
yMin = floatPtr(0)
|
|
yMax = autoMax120(temp)
|
|
case "clock":
|
|
title = fmt.Sprintf("GPU %d Core Clock", idx)
|
|
clock := gpuDatasetByIndex(samples, idx, func(g platform.GPUMetricRow) float64 { return g.ClockMHz })
|
|
if clock == nil {
|
|
return nil, nil, nil, "", nil, nil, false
|
|
}
|
|
datasets = [][]float64{clock}
|
|
names = []string{"Core Clock MHz"}
|
|
yMin, yMax = autoBounds120(clock)
|
|
case "memclock":
|
|
title = fmt.Sprintf("GPU %d Memory Clock", idx)
|
|
clock := gpuDatasetByIndex(samples, idx, func(g platform.GPUMetricRow) float64 { return g.MemClockMHz })
|
|
if clock == nil {
|
|
return nil, nil, nil, "", nil, nil, false
|
|
}
|
|
datasets = [][]float64{clock}
|
|
names = []string{"Memory Clock MHz"}
|
|
yMin, yMax = autoBounds120(clock)
|
|
default:
|
|
title = fmt.Sprintf("GPU %d Power", idx)
|
|
power := gpuDatasetByIndex(samples, idx, func(g platform.GPUMetricRow) float64 { return g.PowerW })
|
|
if power == nil {
|
|
return nil, nil, nil, "", nil, nil, false
|
|
}
|
|
datasets = [][]float64{power}
|
|
names = []string{"Power W"}
|
|
yMin, yMax = autoBounds120(power)
|
|
}
|
|
|
|
default:
|
|
return nil, nil, nil, "", nil, nil, false
|
|
}
|
|
|
|
return datasets, names, labels, title, yMin, yMax, len(datasets) > 0
|
|
}
|
|
|
|
func parseGPUChartPath(path string) (idx int, sub string, ok bool) {
|
|
if !strings.HasPrefix(path, "gpu/") {
|
|
return 0, "", false
|
|
}
|
|
rest := strings.TrimPrefix(path, "gpu/")
|
|
if rest == "" {
|
|
return 0, "", false
|
|
}
|
|
sub = ""
|
|
if i := strings.LastIndex(rest, "-"); i > 0 {
|
|
sub = rest[i+1:]
|
|
rest = rest[:i]
|
|
}
|
|
n, err := fmt.Sscanf(rest, "%d", &idx)
|
|
if err != nil || n != 1 {
|
|
return 0, "", false
|
|
}
|
|
return idx, sub, true
|
|
}
|
|
|
|
func sampleTimeLabels(samples []platform.LiveMetricSample) []string {
|
|
labels := make([]string, len(samples))
|
|
if len(samples) == 0 {
|
|
return labels
|
|
}
|
|
times := make([]time.Time, len(samples))
|
|
for i, s := range samples {
|
|
times[i] = s.Timestamp
|
|
}
|
|
sameDay := timestampsSameLocalDay(times)
|
|
for i, s := range samples {
|
|
labels[i] = formatTimelineLabel(s.Timestamp.Local(), sameDay)
|
|
}
|
|
return labels
|
|
}
|
|
|
|
func namedTempDatasets(samples []platform.LiveMetricSample, group string) ([][]float64, []string) {
|
|
seen := map[string]bool{}
|
|
var names []string
|
|
for _, s := range samples {
|
|
for _, t := range s.Temps {
|
|
if t.Group == group && !seen[t.Name] {
|
|
seen[t.Name] = true
|
|
names = append(names, t.Name)
|
|
}
|
|
}
|
|
}
|
|
sort.Strings(names)
|
|
datasets := make([][]float64, 0, len(names))
|
|
for _, name := range names {
|
|
ds := make([]float64, len(samples))
|
|
for i, s := range samples {
|
|
for _, t := range s.Temps {
|
|
if t.Group == group && t.Name == name {
|
|
ds[i] = t.Celsius
|
|
break
|
|
}
|
|
}
|
|
}
|
|
datasets = append(datasets, ds)
|
|
}
|
|
return datasets, names
|
|
}
|
|
|
|
func namedFanDatasets(samples []platform.LiveMetricSample) ([][]float64, []string) {
|
|
seen := map[string]bool{}
|
|
var names []string
|
|
for _, s := range samples {
|
|
for _, f := range s.Fans {
|
|
if !seen[f.Name] {
|
|
seen[f.Name] = true
|
|
names = append(names, f.Name)
|
|
}
|
|
}
|
|
}
|
|
sort.Strings(names)
|
|
datasets := make([][]float64, 0, len(names))
|
|
for _, name := range names {
|
|
ds := make([]float64, len(samples))
|
|
for i, s := range samples {
|
|
for _, f := range s.Fans {
|
|
if f.Name == name {
|
|
ds[i] = f.RPM
|
|
break
|
|
}
|
|
}
|
|
}
|
|
datasets = append(datasets, normalizeFanSeries(ds))
|
|
}
|
|
return datasets, names
|
|
}
|
|
|
|
func gpuDatasets(samples []platform.LiveMetricSample, pick func(platform.GPUMetricRow) float64) ([][]float64, []string) {
|
|
seen := map[int]bool{}
|
|
var indices []int
|
|
for _, s := range samples {
|
|
for _, g := range s.GPUs {
|
|
if !seen[g.GPUIndex] {
|
|
seen[g.GPUIndex] = true
|
|
indices = append(indices, g.GPUIndex)
|
|
}
|
|
}
|
|
}
|
|
sort.Ints(indices)
|
|
datasets := make([][]float64, 0, len(indices))
|
|
names := make([]string, 0, len(indices))
|
|
for _, idx := range indices {
|
|
ds := gpuDatasetByIndex(samples, idx, pick)
|
|
if ds == nil {
|
|
continue
|
|
}
|
|
datasets = append(datasets, ds)
|
|
names = append(names, fmt.Sprintf("GPU %d", idx))
|
|
}
|
|
return datasets, names
|
|
}
|
|
|
|
func gpuDatasetByIndex(samples []platform.LiveMetricSample, idx int, pick func(platform.GPUMetricRow) float64) []float64 {
|
|
found := false
|
|
ds := make([]float64, len(samples))
|
|
for i, s := range samples {
|
|
for _, g := range s.GPUs {
|
|
if g.GPUIndex == idx {
|
|
ds[i] = pick(g)
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if !found {
|
|
return nil
|
|
}
|
|
return ds
|
|
}
|
|
|
|
func coalesceDataset(ds []float64, n int) []float64 {
|
|
if ds != nil {
|
|
return ds
|
|
}
|
|
return make([]float64, n)
|
|
}
|
|
|
|
func normalizePowerSeries(ds []float64) []float64 {
|
|
if len(ds) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]float64, len(ds))
|
|
copy(out, ds)
|
|
last := 0.0
|
|
haveLast := false
|
|
for i, v := range out {
|
|
if v > 0 {
|
|
last = v
|
|
haveLast = true
|
|
continue
|
|
}
|
|
if haveLast {
|
|
out[i] = last
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func normalizeFanSeries(ds []float64) []float64 {
|
|
if len(ds) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]float64, len(ds))
|
|
var lastPositive float64
|
|
for i, v := range ds {
|
|
if v > 0 {
|
|
lastPositive = v
|
|
out[i] = v
|
|
continue
|
|
}
|
|
if lastPositive > 0 {
|
|
out[i] = lastPositive
|
|
continue
|
|
}
|
|
out[i] = 0
|
|
}
|
|
return out
|
|
}
|
|
|
|
// floatPtr returns a pointer to a float64 value.
|
|
func floatPtr(v float64) *float64 { return &v }
|
|
|
|
// autoMax120 returns 0→max+20% Y-axis max across all datasets.
|
|
func autoMax120(datasets ...[]float64) *float64 {
|
|
max := 0.0
|
|
for _, ds := range datasets {
|
|
for _, v := range ds {
|
|
if v > max {
|
|
max = v
|
|
}
|
|
}
|
|
}
|
|
if max == 0 {
|
|
return nil // let library auto-scale
|
|
}
|
|
v := max * 1.2
|
|
return &v
|
|
}
|
|
|
|
func autoBounds120(datasets ...[]float64) (*float64, *float64) {
|
|
min := 0.0
|
|
max := 0.0
|
|
first := true
|
|
for _, ds := range datasets {
|
|
for _, v := range ds {
|
|
if first {
|
|
min, max = v, v
|
|
first = false
|
|
continue
|
|
}
|
|
if v < min {
|
|
min = v
|
|
}
|
|
if v > max {
|
|
max = v
|
|
}
|
|
}
|
|
}
|
|
if first {
|
|
return nil, nil
|
|
}
|
|
if max <= 0 {
|
|
return floatPtr(0), nil
|
|
}
|
|
span := max - min
|
|
if span <= 0 {
|
|
span = max * 0.1
|
|
if span <= 0 {
|
|
span = 1
|
|
}
|
|
}
|
|
pad := span * 0.2
|
|
low := min - pad
|
|
if low < 0 {
|
|
low = 0
|
|
}
|
|
high := max + pad
|
|
return floatPtr(low), floatPtr(high)
|
|
}
|
|
|
|
func renderGPUOverviewChartSVG(idx int, samples []platform.LiveMetricSample) ([]byte, bool, error) {
|
|
temp := gpuDatasetByIndex(samples, idx, func(g platform.GPUMetricRow) float64 { return g.TempC })
|
|
power := gpuDatasetByIndex(samples, idx, func(g platform.GPUMetricRow) float64 { return g.PowerW })
|
|
coreClock := gpuDatasetByIndex(samples, idx, func(g platform.GPUMetricRow) float64 { return g.ClockMHz })
|
|
memClock := gpuDatasetByIndex(samples, idx, func(g platform.GPUMetricRow) float64 { return g.MemClockMHz })
|
|
if temp == nil && power == nil && coreClock == nil && memClock == nil {
|
|
return nil, false, nil
|
|
}
|
|
labels := sampleTimeLabels(samples)
|
|
svg, err := drawGPUOverviewChartSVG(
|
|
fmt.Sprintf("GPU %d Overview", idx),
|
|
labels,
|
|
[]gpuOverviewSeries{
|
|
{Name: "Temp C", Values: coalesceDataset(temp, len(samples)), Color: "#f05a5a", AxisTitle: "Temp C"},
|
|
{Name: "Power W", Values: coalesceDataset(power, len(samples)), Color: "#ffb357", AxisTitle: "Power W"},
|
|
{Name: "Core Clock MHz", Values: coalesceDataset(coreClock, len(samples)), Color: "#73bf69", AxisTitle: "Core MHz"},
|
|
{Name: "Memory Clock MHz", Values: coalesceDataset(memClock, len(samples)), Color: "#5794f2", AxisTitle: "Memory MHz"},
|
|
},
|
|
)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
return svg, true, nil
|
|
}
|
|
|
|
type gpuOverviewSeries struct {
|
|
Name string
|
|
AxisTitle string
|
|
Color string
|
|
Values []float64
|
|
}
|
|
|
|
func drawGPUOverviewChartSVG(title string, labels []string, series []gpuOverviewSeries) ([]byte, error) {
|
|
if len(series) != 4 {
|
|
return nil, fmt.Errorf("gpu overview requires 4 series, got %d", len(series))
|
|
}
|
|
const (
|
|
width = 1400
|
|
height = 840
|
|
plotLeft = 180
|
|
plotRight = 1220
|
|
plotTop = 96
|
|
plotBottom = 602
|
|
)
|
|
const (
|
|
leftOuterAxis = 72
|
|
leftInnerAxis = 132
|
|
rightInnerAxis = 1268
|
|
rightOuterAxis = 1328
|
|
)
|
|
axisX := []int{leftOuterAxis, leftInnerAxis, rightInnerAxis, rightOuterAxis}
|
|
plotWidth := plotRight - plotLeft
|
|
plotHeight := plotBottom - plotTop
|
|
|
|
pointCount := len(labels)
|
|
if pointCount == 0 {
|
|
pointCount = 1
|
|
labels = []string{""}
|
|
}
|
|
for i := range series {
|
|
if len(series[i].Values) == 0 {
|
|
series[i].Values = make([]float64, pointCount)
|
|
}
|
|
}
|
|
|
|
type axisScale struct {
|
|
Min float64
|
|
Max float64
|
|
Ticks []float64
|
|
}
|
|
scales := make([]axisScale, len(series))
|
|
for i := range series {
|
|
min, max := gpuChartSeriesBounds(series[i].Values)
|
|
ticks := gpuChartNiceTicks(min, max, 8)
|
|
scales[i] = axisScale{
|
|
Min: ticks[0],
|
|
Max: ticks[len(ticks)-1],
|
|
Ticks: ticks,
|
|
}
|
|
}
|
|
|
|
xFor := func(index int) float64 {
|
|
if pointCount <= 1 {
|
|
return float64(plotLeft + plotWidth/2)
|
|
}
|
|
return float64(plotLeft) + float64(index)*float64(plotWidth)/float64(pointCount-1)
|
|
}
|
|
yFor := func(value float64, scale axisScale) float64 {
|
|
if scale.Max <= scale.Min {
|
|
return float64(plotTop + plotHeight/2)
|
|
}
|
|
return float64(plotBottom) - (value-scale.Min)/(scale.Max-scale.Min)*float64(plotHeight)
|
|
}
|
|
|
|
var b strings.Builder
|
|
b.WriteString(fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" viewBox="0 0 %d %d">`, width, height, width, height))
|
|
b.WriteString("\n")
|
|
b.WriteString(`<rect width="100%" height="100%" rx="10" ry="10" fill="#ffffff" stroke="#d7e0ea"/>` + "\n")
|
|
b.WriteString(`<text x="700" y="28" text-anchor="middle" font-family="sans-serif" font-size="16" font-weight="700" fill="#1f2937">` + sanitizeChartText(title) + `</text>` + "\n")
|
|
|
|
b.WriteString(`<g stroke="#e2e8f0" stroke-width="1">` + "\n")
|
|
for _, tick := range scales[0].Ticks {
|
|
y := yFor(tick, scales[0])
|
|
fmt.Fprintf(&b, `<line x1="%d" y1="%.1f" x2="%d" y2="%.1f"/>`+"\n", plotLeft, y, plotRight, y)
|
|
}
|
|
for _, idx := range gpuChartLabelIndices(pointCount, 8) {
|
|
x := xFor(idx)
|
|
fmt.Fprintf(&b, `<line x1="%.1f" y1="%d" x2="%.1f" y2="%d"/>`+"\n", x, plotTop, x, plotBottom)
|
|
}
|
|
b.WriteString("</g>\n")
|
|
|
|
fmt.Fprintf(&b, `<rect x="%d" y="%d" width="%d" height="%d" fill="none" stroke="#cbd5e1" stroke-width="1"/>`+"\n",
|
|
plotLeft, plotTop, plotWidth, plotHeight)
|
|
|
|
for i, axisLineX := range axisX {
|
|
fmt.Fprintf(&b, `<line x1="%d" y1="%d" x2="%d" y2="%d" stroke="%s" stroke-width="1"/>`+"\n",
|
|
axisLineX, plotTop, axisLineX, plotBottom, series[i].Color)
|
|
fmt.Fprintf(&b, `<text x="%d" y="%d" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="700" fill="%s">%s</text>`+"\n",
|
|
axisLineX, 64, series[i].Color, sanitizeChartText(series[i].AxisTitle))
|
|
for _, tick := range scales[i].Ticks {
|
|
y := yFor(tick, scales[i])
|
|
label := sanitizeChartText(gpuChartFormatTick(tick))
|
|
if i < 2 {
|
|
fmt.Fprintf(&b, `<line x1="%d" y1="%.1f" x2="%d" y2="%.1f" stroke="%s" stroke-width="1"/>`+"\n",
|
|
axisLineX, y, axisLineX+6, y, series[i].Color)
|
|
fmt.Fprintf(&b, `<text x="%d" y="%.1f" text-anchor="end" dy="4" font-family="sans-serif" font-size="10" fill="%s">%s</text>`+"\n",
|
|
axisLineX-8, y, series[i].Color, label)
|
|
continue
|
|
}
|
|
fmt.Fprintf(&b, `<line x1="%d" y1="%.1f" x2="%d" y2="%.1f" stroke="%s" stroke-width="1"/>`+"\n",
|
|
axisLineX, y, axisLineX-6, y, series[i].Color)
|
|
fmt.Fprintf(&b, `<text x="%d" y="%.1f" text-anchor="start" dy="4" font-family="sans-serif" font-size="10" fill="%s">%s</text>`+"\n",
|
|
axisLineX+8, y, series[i].Color, label)
|
|
}
|
|
}
|
|
|
|
b.WriteString(`<g font-family="sans-serif" font-size="11" fill="#64748b" text-anchor="middle">` + "\n")
|
|
for _, idx := range gpuChartLabelIndices(pointCount, 8) {
|
|
x := xFor(idx)
|
|
fmt.Fprintf(&b, `<text x="%.1f" y="%d">%s</text>`+"\n", x, plotBottom+28, sanitizeChartText(labels[idx]))
|
|
}
|
|
b.WriteString(`</g>` + "\n")
|
|
b.WriteString(`<text x="700" y="662" text-anchor="middle" font-family="sans-serif" font-size="12" fill="#64748b">Time</text>` + "\n")
|
|
|
|
for i := range series {
|
|
var points strings.Builder
|
|
for j, value := range series[i].Values {
|
|
if j > 0 {
|
|
points.WriteByte(' ')
|
|
}
|
|
points.WriteString(strconv.FormatFloat(xFor(j), 'f', 1, 64))
|
|
points.WriteByte(',')
|
|
points.WriteString(strconv.FormatFloat(yFor(value, scales[i]), 'f', 1, 64))
|
|
}
|
|
fmt.Fprintf(&b, `<polyline points="%s" fill="none" stroke="%s" stroke-width="2"/>`+"\n",
|
|
points.String(), series[i].Color)
|
|
if len(series[i].Values) == 1 {
|
|
fmt.Fprintf(&b, `<circle cx="%.1f" cy="%.1f" r="3" fill="%s"/>`+"\n",
|
|
xFor(0), yFor(series[i].Values[0], scales[i]), series[i].Color)
|
|
}
|
|
}
|
|
|
|
const legendY = 724
|
|
legendX := []int{190, 470, 790, 1090}
|
|
for i := range series {
|
|
fmt.Fprintf(&b, `<line x1="%d" y1="%d" x2="%d" y2="%d" stroke="%s" stroke-width="3"/>`+"\n",
|
|
legendX[i], legendY, legendX[i]+28, legendY, series[i].Color)
|
|
fmt.Fprintf(&b, `<text x="%d" y="%d" font-family="sans-serif" font-size="12" fill="#1f2937">%s</text>`+"\n",
|
|
legendX[i]+38, legendY+4, sanitizeChartText(series[i].Name))
|
|
}
|
|
|
|
b.WriteString("</svg>\n")
|
|
return []byte(b.String()), nil
|
|
}
|
|
|
|
func gpuChartSeriesBounds(values []float64) (float64, float64) {
|
|
if len(values) == 0 {
|
|
return 0, 1
|
|
}
|
|
min, max := values[0], values[0]
|
|
for _, value := range values[1:] {
|
|
if value < min {
|
|
min = value
|
|
}
|
|
if value > max {
|
|
max = value
|
|
}
|
|
}
|
|
if min == max {
|
|
if max == 0 {
|
|
return 0, 1
|
|
}
|
|
pad := math.Abs(max) * 0.1
|
|
if pad == 0 {
|
|
pad = 1
|
|
}
|
|
min -= pad
|
|
max += pad
|
|
}
|
|
if min > 0 {
|
|
pad := (max - min) * 0.2
|
|
if pad == 0 {
|
|
pad = max * 0.1
|
|
}
|
|
min -= pad
|
|
if min < 0 {
|
|
min = 0
|
|
}
|
|
max += pad
|
|
}
|
|
return min, max
|
|
}
|
|
|
|
func gpuChartNiceTicks(min, max float64, target int) []float64 {
|
|
if min == max {
|
|
max = min + 1
|
|
}
|
|
span := max - min
|
|
step := math.Pow(10, math.Floor(math.Log10(span/float64(target))))
|
|
for _, factor := range []float64{1, 2, 5, 10} {
|
|
if span/(factor*step) <= float64(target)*1.5 {
|
|
step = factor * step
|
|
break
|
|
}
|
|
}
|
|
low := math.Floor(min/step) * step
|
|
high := math.Ceil(max/step) * step
|
|
var ticks []float64
|
|
for value := low; value <= high+step*0.001; value += step {
|
|
ticks = append(ticks, math.Round(value*1e9)/1e9)
|
|
}
|
|
return ticks
|
|
}
|
|
|
|
func gpuChartFormatTick(value float64) string {
|
|
if value == math.Trunc(value) {
|
|
return strconv.Itoa(int(value))
|
|
}
|
|
return strconv.FormatFloat(value, 'f', 1, 64)
|
|
}
|
|
|
|
func gpuChartLabelIndices(total, target int) []int {
|
|
if total <= 0 {
|
|
return nil
|
|
}
|
|
if total == 1 {
|
|
return []int{0}
|
|
}
|
|
step := total / target
|
|
if step < 1 {
|
|
step = 1
|
|
}
|
|
var indices []int
|
|
for i := 0; i < total; i += step {
|
|
indices = append(indices, i)
|
|
}
|
|
if indices[len(indices)-1] != total-1 {
|
|
indices = append(indices, total-1)
|
|
}
|
|
return indices
|
|
}
|
|
|
|
// renderChartSVG renders a line chart SVG with a fixed Y-axis range.
|
|
func renderChartSVG(title string, datasets [][]float64, names []string, labels []string, yMin, yMax *float64) ([]byte, error) {
|
|
return renderChartSVGWithHeight(title, datasets, names, labels, yMin, yMax, chartCanvasHeight(len(names)))
|
|
}
|
|
|
|
func renderChartSVGWithHeight(title string, datasets [][]float64, names []string, labels []string, yMin, yMax *float64, canvasHeight int) ([]byte, error) {
|
|
n := len(labels)
|
|
if n == 0 {
|
|
n = 1
|
|
labels = []string{""}
|
|
}
|
|
for i := range datasets {
|
|
if len(datasets[i]) == 0 {
|
|
datasets[i] = make([]float64, n)
|
|
}
|
|
}
|
|
// Append global min/avg/max to title.
|
|
mn, avg, mx := globalStats(datasets)
|
|
if mx > 0 {
|
|
title = fmt.Sprintf("%s ↓%s ~%s ↑%s",
|
|
title,
|
|
chartLegendNumber(mn),
|
|
chartLegendNumber(avg),
|
|
chartLegendNumber(mx),
|
|
)
|
|
}
|
|
title = sanitizeChartText(title)
|
|
names = sanitizeChartTexts(names)
|
|
sparse := sanitizeChartTexts(sparseLabels(labels, 6))
|
|
|
|
opt := gocharts.NewLineChartOptionWithData(datasets)
|
|
opt.Title = gocharts.TitleOption{Text: title}
|
|
opt.XAxis.Labels = sparse
|
|
opt.Legend = gocharts.LegendOption{SeriesNames: names}
|
|
if chartLegendVisible(len(names)) {
|
|
opt.Legend.Offset = gocharts.OffsetStr{Top: gocharts.PositionBottom}
|
|
opt.Legend.OverlayChart = gocharts.Ptr(false)
|
|
} else {
|
|
opt.Legend.Show = gocharts.Ptr(false)
|
|
}
|
|
opt.Symbol = gocharts.SymbolNone
|
|
// Right padding: reserve space for the MarkLine label (library recommendation).
|
|
opt.Padding = gocharts.NewBox(20, 20, 80, 20)
|
|
if yMin != nil || yMax != nil {
|
|
opt.YAxis = []gocharts.YAxisOption{chartYAxisOption(yMin, yMax)}
|
|
}
|
|
|
|
// Add a single peak mark line on the series that holds the global maximum.
|
|
peakIdx, _ := globalPeakSeries(datasets)
|
|
if peakIdx >= 0 && peakIdx < len(opt.SeriesList) {
|
|
opt.SeriesList[peakIdx].MarkLine = gocharts.NewMarkLine(gocharts.SeriesMarkTypeMax)
|
|
}
|
|
|
|
p := gocharts.NewPainter(gocharts.PainterOptions{
|
|
OutputFormat: gocharts.ChartOutputSVG,
|
|
Width: 1400,
|
|
Height: canvasHeight,
|
|
}, gocharts.PainterThemeOption(gocharts.GetTheme("grafana")))
|
|
if err := p.LineChart(opt); err != nil {
|
|
return nil, err
|
|
}
|
|
return p.Bytes()
|
|
}
|
|
|
|
func chartCanvasHeightForPath(path string, seriesCount int) int {
|
|
height := chartCanvasHeight(seriesCount)
|
|
if isGPUChartPath(path) {
|
|
return height * 2
|
|
}
|
|
return height
|
|
}
|
|
|
|
func isGPUChartPath(path string) bool {
|
|
return strings.HasPrefix(path, "gpu-all-") || strings.HasPrefix(path, "gpu/")
|
|
}
|
|
|
|
func chartLegendVisible(seriesCount int) bool {
|
|
return seriesCount <= 8
|
|
}
|
|
|
|
func chartCanvasHeight(seriesCount int) int {
|
|
if chartLegendVisible(seriesCount) {
|
|
return 360
|
|
}
|
|
return 288
|
|
}
|
|
|
|
func chartYAxisOption(yMin, yMax *float64) gocharts.YAxisOption {
|
|
return gocharts.YAxisOption{
|
|
Min: yMin,
|
|
Max: yMax,
|
|
LabelCount: 11,
|
|
ValueFormatter: chartYAxisNumber,
|
|
}
|
|
}
|
|
|
|
// globalPeakSeries returns the index of the series containing the global maximum
|
|
// value across all datasets, and that maximum value.
|
|
func globalPeakSeries(datasets [][]float64) (idx int, peak float64) {
|
|
idx = -1
|
|
for i, ds := range datasets {
|
|
for _, v := range ds {
|
|
if v > peak {
|
|
peak = v
|
|
idx = i
|
|
}
|
|
}
|
|
}
|
|
return idx, peak
|
|
}
|
|
|
|
// globalStats returns min, average, and max across all values in all datasets.
|
|
func globalStats(datasets [][]float64) (mn, avg, mx float64) {
|
|
var sum float64
|
|
var count int
|
|
first := true
|
|
for _, ds := range datasets {
|
|
for _, v := range ds {
|
|
if first {
|
|
mn, mx = v, v
|
|
first = false
|
|
}
|
|
if v < mn {
|
|
mn = v
|
|
}
|
|
if v > mx {
|
|
mx = v
|
|
}
|
|
sum += v
|
|
count++
|
|
}
|
|
}
|
|
if count > 0 {
|
|
avg = sum / float64(count)
|
|
}
|
|
return mn, avg, mx
|
|
}
|
|
|
|
func sanitizeChartText(s string) string {
|
|
if s == "" {
|
|
return ""
|
|
}
|
|
return html.EscapeString(strings.Map(func(r rune) rune {
|
|
if r < 0x20 && r != '\t' && r != '\n' && r != '\r' {
|
|
return -1
|
|
}
|
|
return r
|
|
}, s))
|
|
}
|
|
|
|
func sanitizeChartTexts(in []string) []string {
|
|
out := make([]string, len(in))
|
|
for i, s := range in {
|
|
out[i] = sanitizeChartText(s)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func safeIdx(s []float64, i int) float64 {
|
|
if i < len(s) {
|
|
return s[i]
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func snapshotNamedRings(rings []*namedMetricsRing) ([][]float64, []string, []string) {
|
|
var datasets [][]float64
|
|
var names []string
|
|
var labels []string
|
|
for _, item := range rings {
|
|
if item == nil || item.Ring == nil {
|
|
continue
|
|
}
|
|
vals, l := item.Ring.snapshot()
|
|
datasets = append(datasets, vals)
|
|
names = append(names, item.Name)
|
|
if len(labels) == 0 {
|
|
labels = l
|
|
}
|
|
}
|
|
return datasets, names, labels
|
|
}
|
|
|
|
func snapshotFanRings(rings []*metricsRing, fanNames []string) ([][]float64, []string, []string) {
|
|
var datasets [][]float64
|
|
var names []string
|
|
var labels []string
|
|
for i, ring := range rings {
|
|
if ring == nil {
|
|
continue
|
|
}
|
|
vals, l := ring.snapshot()
|
|
datasets = append(datasets, normalizeFanSeries(vals))
|
|
name := "Fan"
|
|
if i < len(fanNames) {
|
|
name = fanNames[i]
|
|
}
|
|
names = append(names, name+" RPM")
|
|
if len(labels) == 0 {
|
|
labels = l
|
|
}
|
|
}
|
|
return datasets, names, labels
|
|
}
|
|
|
|
func chartLegendNumber(v float64) string {
|
|
neg := v < 0
|
|
if v < 0 {
|
|
v = -v
|
|
}
|
|
var out string
|
|
switch {
|
|
case v >= 10000:
|
|
out = fmt.Sprintf("%dk", int((v+500)/1000))
|
|
case v >= 1000:
|
|
s := fmt.Sprintf("%.2f", v/1000)
|
|
s = strings.TrimRight(strings.TrimRight(s, "0"), ".")
|
|
out = strings.ReplaceAll(s, ".", ",") + "k"
|
|
default:
|
|
out = fmt.Sprintf("%.0f", v)
|
|
}
|
|
if neg {
|
|
return "-" + out
|
|
}
|
|
return out
|
|
}
|
|
|
|
func chartYAxisNumber(v float64) string {
|
|
neg := v < 0
|
|
if neg {
|
|
v = -v
|
|
}
|
|
var out string
|
|
switch {
|
|
case v >= 10000:
|
|
out = fmt.Sprintf("%dк", int((v+500)/1000))
|
|
case v >= 1000:
|
|
// Use one decimal place so ticks like 1400, 1600, 1800 read as
|
|
// "1,4к", "1,6к", "1,8к" instead of the ambiguous "1к"/"2к".
|
|
s := fmt.Sprintf("%.1f", v/1000)
|
|
s = strings.TrimRight(strings.TrimRight(s, "0"), ".")
|
|
out = strings.ReplaceAll(s, ".", ",") + "к"
|
|
default:
|
|
out = fmt.Sprintf("%.0f", v)
|
|
}
|
|
if neg {
|
|
return "-" + out
|
|
}
|
|
return out
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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 _, 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</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}
|
|
.logo{font-size:13px;line-height:1.4;color:#f6c90e;margin-bottom:48px;white-space:pre}
|
|
.spinner{width:48px;height:48px;border:4px solid #2d3748;border-top-color:#f6c90e;border-radius:50%;animation:spin .8s linear infinite;margin:0 auto 24px}
|
|
@keyframes spin{to{transform:rotate(360deg)}}
|
|
.status{font-size:14px;color:#a0aec0;letter-spacing:.05em}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div style="text-align:center">
|
|
<div class="logo"> ███████╗ █████╗ ███████╗██╗ ██╗ ██████╗ ███████╗███████╗
|
|
██╔════╝██╔══██╗██╔════╝╚██╗ ██╔╝ ██╔══██╗██╔════╝██╔════╝
|
|
█████╗ ███████║███████╗ ╚████╔╝ █████╗██████╔╝█████╗ █████╗
|
|
██╔══╝ ██╔══██║╚════██║ ╚██╔╝ ╚════╝██╔══██╗██╔══╝ ██╔══╝
|
|
███████╗██║ ██║███████║ ██║ ██████╔╝███████╗███████╗
|
|
╚══════╝╚═╝ ╚═╝╚══════╝ ╚═╝ ╚═════╝ ╚══════╝╚══════╝</div>
|
|
<div class="spinner"></div>
|
|
<div class="status" id="s">Starting up...</div>
|
|
</div>
|
|
<script>
|
|
function probe(){
|
|
fetch('/api/ready',{cache:'no-store'})
|
|
.then(function(r){
|
|
if(r.ok){window.location.replace('/');}
|
|
else{setTimeout(probe,1000);}
|
|
})
|
|
.catch(function(){setTimeout(probe,1000);});
|
|
}
|
|
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 old routes to new names
|
|
switch page {
|
|
case "tests":
|
|
http.Redirect(w, r, "/validate", http.StatusMovedPermanently)
|
|
return
|
|
case "burn-in":
|
|
http.Redirect(w, r, "/burn", 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})
|
|
}
|