feat(webui): replace TUI with full web UI + local openbox desktop
- Remove audit/internal/tui/ (~3000 LOC, bubbletea/lipgloss/reanimator deps) - Add /api/* REST+SSE endpoints: audit, SAT (nvidia/memory/storage/cpu), services, network, export, tools, live metrics stream - Add async job manager with SSE streaming for long-running operations - Add platform.SampleLiveMetrics() for live fan/temp/power/GPU polling - Add multi-page web UI (vanilla JS): Dashboard, Metrics charts, Tests, Burn-in, Network, Services, Export, Tools - Add bee-desktop.service: openbox + Xorg + Chromium opening http://localhost/ - Add openbox/tint2/xorg/xinit/xterm/chromium to ISO package list - Update .profile, bee.sh, and bible-local docs Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,138 +1,207 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"bee/audit/internal/app"
|
||||
"reanimator/chart/viewer"
|
||||
chartweb "reanimator/chart/web"
|
||||
"bee/audit/internal/runtimeenv"
|
||||
)
|
||||
|
||||
const defaultTitle = "Bee Hardware Audit"
|
||||
|
||||
// HandlerOptions configures the web UI handler.
|
||||
type HandlerOptions struct {
|
||||
Title string
|
||||
AuditPath string
|
||||
ExportDir string
|
||||
Title string
|
||||
AuditPath string
|
||||
ExportDir string
|
||||
App *app.App
|
||||
RuntimeMode runtimeenv.Mode
|
||||
}
|
||||
|
||||
// handler is the HTTP handler for the web UI.
|
||||
type handler struct {
|
||||
opts HandlerOptions
|
||||
mux *http.ServeMux
|
||||
}
|
||||
|
||||
// NewHandler creates the HTTP mux with all routes.
|
||||
func NewHandler(opts HandlerOptions) http.Handler {
|
||||
title := strings.TrimSpace(opts.Title)
|
||||
if title == "" {
|
||||
title = defaultTitle
|
||||
if strings.TrimSpace(opts.Title) == "" {
|
||||
opts.Title = defaultTitle
|
||||
}
|
||||
if strings.TrimSpace(opts.ExportDir) == "" {
|
||||
opts.ExportDir = app.DefaultExportDir
|
||||
}
|
||||
if opts.RuntimeMode == "" {
|
||||
opts.RuntimeMode = runtimeenv.ModeAuto
|
||||
}
|
||||
|
||||
auditPath := strings.TrimSpace(opts.AuditPath)
|
||||
exportDir := strings.TrimSpace(opts.ExportDir)
|
||||
if exportDir == "" {
|
||||
exportDir = app.DefaultExportDir
|
||||
}
|
||||
h := &handler{opts: opts}
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("GET /static/", http.StripPrefix("/static/", chartweb.Static()))
|
||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
})
|
||||
mux.HandleFunc("GET /audit.json", func(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := loadSnapshot(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)
|
||||
})
|
||||
mux.HandleFunc("GET /export/support.tar.gz", func(w http.ResponseWriter, r *http.Request) {
|
||||
archive, err := app.BuildSupportBundle(exportDir)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("build support bundle: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Content-Type", "application/gzip")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filepath.Base(archive)))
|
||||
http.ServeFile(w, r, archive)
|
||||
})
|
||||
mux.HandleFunc("GET /runtime-health.json", func(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := loadSnapshot(filepath.Join(exportDir, "runtime-health.json"))
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
http.Error(w, "runtime health not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
http.Error(w, fmt.Sprintf("read runtime health: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_, _ = w.Write(data)
|
||||
})
|
||||
mux.HandleFunc("GET /export/", func(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := renderExportIndex(exportDir)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("render export index: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write([]byte(body))
|
||||
})
|
||||
mux.HandleFunc("GET /export/file", func(w http.ResponseWriter, r *http.Request) {
|
||||
rel := strings.TrimSpace(r.URL.Query().Get("path"))
|
||||
if rel == "" {
|
||||
http.Error(w, "path is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
clean := filepath.Clean(rel)
|
||||
if clean == "." || strings.HasPrefix(clean, "..") {
|
||||
http.Error(w, "invalid path", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
http.ServeFile(w, r, filepath.Join(exportDir, clean))
|
||||
})
|
||||
mux.HandleFunc("GET /viewer", func(w http.ResponseWriter, r *http.Request) {
|
||||
snapshot, err := loadSnapshot(auditPath)
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
http.Error(w, fmt.Sprintf("read audit snapshot: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
html, err := viewer.RenderHTML(snapshot, title)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("render snapshot: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write(html)
|
||||
})
|
||||
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
|
||||
noticeTitle, noticeBody := runtimeNotice(filepath.Join(exportDir, "runtime-health.json"))
|
||||
body := renderShellPage(title, noticeTitle, noticeBody)
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write([]byte(body))
|
||||
})
|
||||
|
||||
// ── 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
|
||||
mux.HandleFunc("GET /api/metrics/stream", h.handleAPIMetricsStream)
|
||||
|
||||
// ── 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 := renderViewerPage(h.opts.Title, snapshot)
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write([]byte(body))
|
||||
}
|
||||
|
||||
// ── 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
|
||||
@@ -140,101 +209,17 @@ func loadSnapshot(path string) ([]byte, error) {
|
||||
return os.ReadFile(path)
|
||||
}
|
||||
|
||||
func runtimeNotice(path string) (string, string) {
|
||||
health, err := app.ReadRuntimeHealth(path)
|
||||
if err != nil {
|
||||
return "Runtime Health", "No runtime health snapshot found yet."
|
||||
}
|
||||
body := fmt.Sprintf("Status: %s. Export dir: %s. Driver ready: %t. CUDA ready: %t. Network: %s. Export files: /export/",
|
||||
firstNonEmpty(health.Status, "UNKNOWN"),
|
||||
firstNonEmpty(health.ExportDir, app.DefaultExportDir),
|
||||
health.DriverReady,
|
||||
health.CUDAReady,
|
||||
firstNonEmpty(health.NetworkStatus, "UNKNOWN"),
|
||||
)
|
||||
if len(health.Issues) > 0 {
|
||||
body += " Issues: "
|
||||
parts := make([]string, 0, len(health.Issues))
|
||||
for _, issue := range health.Issues {
|
||||
parts = append(parts, issue.Code)
|
||||
}
|
||||
body += strings.Join(parts, ", ")
|
||||
}
|
||||
return "Runtime Health", body
|
||||
// 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)
|
||||
}
|
||||
|
||||
func renderExportIndex(exportDir string) (string, error) {
|
||||
var entries []string
|
||||
err := filepath.Walk(strings.TrimSpace(exportDir), func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
rel, err := filepath.Rel(exportDir, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
entries = append(entries, rel)
|
||||
return nil
|
||||
})
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return "", err
|
||||
}
|
||||
sort.Strings(entries)
|
||||
var body strings.Builder
|
||||
body.WriteString("<!DOCTYPE html><html><head><meta charset=\"utf-8\"><title>Bee Export Files</title></head><body>")
|
||||
body.WriteString("<h1>Bee Export Files</h1><ul>")
|
||||
for _, entry := range entries {
|
||||
body.WriteString("<li><a href=\"/export/file?path=" + url.QueryEscape(entry) + "\">" + html.EscapeString(entry) + "</a></li>")
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
body.WriteString("<li>No export files found.</li>")
|
||||
}
|
||||
body.WriteString("</ul></body></html>")
|
||||
return body.String(), nil
|
||||
}
|
||||
|
||||
func renderShellPage(title, noticeTitle, noticeBody string) string {
|
||||
var body strings.Builder
|
||||
body.WriteString("<!DOCTYPE html><html><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">")
|
||||
body.WriteString("<title>" + html.EscapeString(title) + "</title>")
|
||||
body.WriteString(`<style>
|
||||
body{margin:0;font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:#f4f1ea;color:#1b1b18}
|
||||
.shell{min-height:100vh;display:grid;grid-template-rows:auto auto 1fr}
|
||||
.header{padding:18px 20px 12px;border-bottom:1px solid rgba(0,0,0,.08);background:#fbf8f2}
|
||||
.header h1{margin:0;font-size:24px}
|
||||
.header p{margin:6px 0 0;color:#5a5a52}
|
||||
.actions{display:flex;flex-wrap:wrap;gap:10px;padding:12px 20px;background:#fbf8f2}
|
||||
.actions a{display:inline-block;text-decoration:none;padding:10px 14px;border-radius:999px;background:#1f5f4a;color:#fff;font-weight:600}
|
||||
.actions a.secondary{background:#d8e5dd;color:#17372b}
|
||||
.notice{margin:16px 20px 0;padding:14px 16px;border-radius:14px;background:#fff7df;border:1px solid #ead9a4}
|
||||
.notice h2{margin:0 0 6px;font-size:16px}
|
||||
.notice p{margin:0;color:#4f4a37}
|
||||
.viewer-wrap{padding:16px 20px 20px}
|
||||
.viewer{width:100%;height:calc(100vh - 170px);border:0;border-radius:18px;background:#fff;box-shadow:0 12px 40px rgba(0,0,0,.08)}
|
||||
@media (max-width:720px){.viewer{height:calc(100vh - 240px)}}
|
||||
</style></head><body><div class="shell">`)
|
||||
body.WriteString("<header class=\"header\"><h1>" + html.EscapeString(title) + "</h1><p>Audit viewer with support bundle and raw export access.</p></header>")
|
||||
body.WriteString("<nav class=\"actions\">")
|
||||
body.WriteString("<a href=\"/export/support.tar.gz\">Download support bundle</a>")
|
||||
body.WriteString("<a class=\"secondary\" href=\"/audit.json\">Open audit.json</a>")
|
||||
body.WriteString("<a class=\"secondary\" href=\"/runtime-health.json\">Open runtime-health.json</a>")
|
||||
body.WriteString("<a class=\"secondary\" href=\"/export/\">Browse export files</a>")
|
||||
body.WriteString("</nav>")
|
||||
if strings.TrimSpace(noticeTitle) != "" {
|
||||
body.WriteString("<section class=\"notice\"><h2>" + html.EscapeString(noticeTitle) + "</h2><p>" + html.EscapeString(noticeBody) + "</p></section>")
|
||||
}
|
||||
body.WriteString("<main class=\"viewer-wrap\"><iframe class=\"viewer\" src=\"/viewer\" loading=\"eager\" referrerpolicy=\"same-origin\"></iframe></main>")
|
||||
body.WriteString("</div></body></html>")
|
||||
return body.String()
|
||||
}
|
||||
|
||||
func firstNonEmpty(value, fallback string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
// 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})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user