Replace raw systemctl output in table cell with: - state badge (active/failed/inactive) — click to expand - full systemctl status in collapsible pre block (max 200px scroll) Fixes layout explosion from multi-line status text in table. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
436 lines
12 KiB
Go
436 lines
12 KiB
Go
package webui
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"bee/audit/internal/app"
|
|
"bee/audit/internal/platform"
|
|
)
|
|
|
|
// ── Job ID counter ────────────────────────────────────────────────────────────
|
|
|
|
var jobCounter atomic.Uint64
|
|
|
|
func newJobID(prefix string) string {
|
|
return fmt.Sprintf("%s-%d", prefix, jobCounter.Add(1))
|
|
}
|
|
|
|
// ── SSE helpers ───────────────────────────────────────────────────────────────
|
|
|
|
func sseWrite(w http.ResponseWriter, event, data string) bool {
|
|
f, ok := w.(http.Flusher)
|
|
if !ok {
|
|
return false
|
|
}
|
|
if event != "" {
|
|
fmt.Fprintf(w, "event: %s\n", event)
|
|
}
|
|
fmt.Fprintf(w, "data: %s\n\n", data)
|
|
f.Flush()
|
|
return true
|
|
}
|
|
|
|
func sseStart(w http.ResponseWriter) bool {
|
|
_, ok := w.(http.Flusher)
|
|
if !ok {
|
|
http.Error(w, "streaming not supported", http.StatusInternalServerError)
|
|
return false
|
|
}
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
w.Header().Set("Connection", "keep-alive")
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
return true
|
|
}
|
|
|
|
// streamJob streams lines from a jobState to a SSE response.
|
|
func streamJob(w http.ResponseWriter, r *http.Request, j *jobState) {
|
|
if !sseStart(w) {
|
|
return
|
|
}
|
|
existing, ch := j.subscribe()
|
|
for _, line := range existing {
|
|
sseWrite(w, "", line)
|
|
}
|
|
if ch == nil {
|
|
// Job already finished
|
|
sseWrite(w, "done", j.err)
|
|
return
|
|
}
|
|
for {
|
|
select {
|
|
case line, ok := <-ch:
|
|
if !ok {
|
|
sseWrite(w, "done", j.err)
|
|
return
|
|
}
|
|
sseWrite(w, "", line)
|
|
case <-r.Context().Done():
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// runCmdJob runs an exec.Cmd as a background job, streaming stdout+stderr lines.
|
|
func runCmdJob(j *jobState, cmd *exec.Cmd) {
|
|
pr, pw := io.Pipe()
|
|
cmd.Stdout = pw
|
|
cmd.Stderr = pw
|
|
|
|
if err := cmd.Start(); err != nil {
|
|
j.finish(err.Error())
|
|
return
|
|
}
|
|
|
|
go func() {
|
|
scanner := bufio.NewScanner(pr)
|
|
for scanner.Scan() {
|
|
j.append(scanner.Text())
|
|
}
|
|
}()
|
|
|
|
err := cmd.Wait()
|
|
_ = pw.Close()
|
|
if err != nil {
|
|
j.finish(err.Error())
|
|
} else {
|
|
j.finish("")
|
|
}
|
|
}
|
|
|
|
// ── Audit ─────────────────────────────────────────────────────────────────────
|
|
|
|
func (h *handler) handleAPIAuditRun(w http.ResponseWriter, r *http.Request) {
|
|
if h.opts.App == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "app not configured")
|
|
return
|
|
}
|
|
id := newJobID("audit")
|
|
j := globalJobs.create(id)
|
|
go func() {
|
|
j.append("Running audit...")
|
|
result, err := h.opts.App.RunAuditNow(h.opts.RuntimeMode)
|
|
if err != nil {
|
|
j.append("ERROR: " + err.Error())
|
|
j.finish(err.Error())
|
|
return
|
|
}
|
|
for _, line := range strings.Split(result.Body, "\n") {
|
|
if line != "" {
|
|
j.append(line)
|
|
}
|
|
}
|
|
j.finish("")
|
|
}()
|
|
writeJSON(w, map[string]string{"job_id": id})
|
|
}
|
|
|
|
func (h *handler) handleAPIAuditStream(w http.ResponseWriter, r *http.Request) {
|
|
id := r.URL.Query().Get("job_id")
|
|
j, ok := globalJobs.get(id)
|
|
if !ok {
|
|
http.Error(w, "job not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
streamJob(w, r, j)
|
|
}
|
|
|
|
// ── SAT ───────────────────────────────────────────────────────────────────────
|
|
|
|
func (h *handler) handleAPISATRun(target string) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if h.opts.App == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "app not configured")
|
|
return
|
|
}
|
|
id := newJobID("sat-" + target)
|
|
j := globalJobs.create(id)
|
|
|
|
go func() {
|
|
j.append(fmt.Sprintf("Starting %s acceptance test...", target))
|
|
var (
|
|
archive string
|
|
err error
|
|
)
|
|
|
|
// Parse optional parameters
|
|
var body struct {
|
|
Duration int `json:"duration"`
|
|
DiagLevel int `json:"diag_level"`
|
|
GPUIndices []int `json:"gpu_indices"`
|
|
}
|
|
body.DiagLevel = 1
|
|
if r.ContentLength > 0 {
|
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
|
}
|
|
|
|
switch target {
|
|
case "nvidia":
|
|
if len(body.GPUIndices) > 0 || body.DiagLevel > 0 {
|
|
result, e := h.opts.App.RunNvidiaAcceptancePackWithOptions(
|
|
context.Background(), "", body.DiagLevel, body.GPUIndices,
|
|
)
|
|
if e != nil {
|
|
err = e
|
|
} else {
|
|
archive = result.Body
|
|
}
|
|
} else {
|
|
archive, err = h.opts.App.RunNvidiaAcceptancePack("")
|
|
}
|
|
case "memory":
|
|
archive, err = h.opts.App.RunMemoryAcceptancePack("")
|
|
case "storage":
|
|
archive, err = h.opts.App.RunStorageAcceptancePack("")
|
|
case "cpu":
|
|
dur := body.Duration
|
|
if dur <= 0 {
|
|
dur = 60
|
|
}
|
|
archive, err = h.opts.App.RunCPUAcceptancePack("", dur)
|
|
}
|
|
|
|
if err != nil {
|
|
j.append("ERROR: " + err.Error())
|
|
j.finish(err.Error())
|
|
return
|
|
}
|
|
j.append(fmt.Sprintf("Archive written: %s", archive))
|
|
j.finish("")
|
|
}()
|
|
|
|
writeJSON(w, map[string]string{"job_id": id})
|
|
}
|
|
}
|
|
|
|
func (h *handler) handleAPISATStream(w http.ResponseWriter, r *http.Request) {
|
|
id := r.URL.Query().Get("job_id")
|
|
j, ok := globalJobs.get(id)
|
|
if !ok {
|
|
http.Error(w, "job not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
streamJob(w, r, j)
|
|
}
|
|
|
|
// ── Services ──────────────────────────────────────────────────────────────────
|
|
|
|
func (h *handler) handleAPIServicesList(w http.ResponseWriter, r *http.Request) {
|
|
if h.opts.App == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "app not configured")
|
|
return
|
|
}
|
|
names, err := h.opts.App.ListBeeServices()
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
type serviceInfo struct {
|
|
Name string `json:"name"`
|
|
State string `json:"state"`
|
|
Body string `json:"body"`
|
|
}
|
|
result := make([]serviceInfo, 0, len(names))
|
|
for _, name := range names {
|
|
state := h.opts.App.ServiceState(name)
|
|
body, _ := h.opts.App.ServiceStatus(name)
|
|
result = append(result, serviceInfo{Name: name, State: state, Body: body})
|
|
}
|
|
writeJSON(w, result)
|
|
}
|
|
|
|
func (h *handler) handleAPIServicesAction(w http.ResponseWriter, r *http.Request) {
|
|
if h.opts.App == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "app not configured")
|
|
return
|
|
}
|
|
var req struct {
|
|
Name string `json:"name"`
|
|
Action string `json:"action"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
var action platform.ServiceAction
|
|
switch req.Action {
|
|
case "start":
|
|
action = platform.ServiceStart
|
|
case "stop":
|
|
action = platform.ServiceStop
|
|
case "restart":
|
|
action = platform.ServiceRestart
|
|
default:
|
|
writeError(w, http.StatusBadRequest, "action must be start|stop|restart")
|
|
return
|
|
}
|
|
result, err := h.opts.App.ServiceActionResult(req.Name, action)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, map[string]string{"status": "ok", "output": result.Body})
|
|
}
|
|
|
|
// ── Network ───────────────────────────────────────────────────────────────────
|
|
|
|
func (h *handler) handleAPINetworkStatus(w http.ResponseWriter, r *http.Request) {
|
|
if h.opts.App == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "app not configured")
|
|
return
|
|
}
|
|
ifaces, err := h.opts.App.ListInterfaces()
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, map[string]any{
|
|
"interfaces": ifaces,
|
|
"default_route": h.opts.App.DefaultRoute(),
|
|
})
|
|
}
|
|
|
|
func (h *handler) handleAPINetworkDHCP(w http.ResponseWriter, r *http.Request) {
|
|
if h.opts.App == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "app not configured")
|
|
return
|
|
}
|
|
var req struct {
|
|
Interface string `json:"interface"`
|
|
}
|
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
|
|
|
var result app.ActionResult
|
|
var err error
|
|
if req.Interface == "" || req.Interface == "all" {
|
|
result, err = h.opts.App.DHCPAllResult()
|
|
} else {
|
|
result, err = h.opts.App.DHCPOneResult(req.Interface)
|
|
}
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, map[string]string{"status": "ok", "output": result.Body})
|
|
}
|
|
|
|
func (h *handler) handleAPINetworkStatic(w http.ResponseWriter, r *http.Request) {
|
|
if h.opts.App == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "app not configured")
|
|
return
|
|
}
|
|
var req struct {
|
|
Interface string `json:"interface"`
|
|
Address string `json:"address"`
|
|
Prefix string `json:"prefix"`
|
|
Gateway string `json:"gateway"`
|
|
DNS []string `json:"dns"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
cfg := platform.StaticIPv4Config{
|
|
Interface: req.Interface,
|
|
Address: req.Address,
|
|
Prefix: req.Prefix,
|
|
Gateway: req.Gateway,
|
|
DNS: req.DNS,
|
|
}
|
|
result, err := h.opts.App.SetStaticIPv4Result(cfg)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, map[string]string{"status": "ok", "output": result.Body})
|
|
}
|
|
|
|
// ── Export ────────────────────────────────────────────────────────────────────
|
|
|
|
func (h *handler) handleAPIExportList(w http.ResponseWriter, r *http.Request) {
|
|
entries, err := listExportFiles(h.opts.ExportDir)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, entries)
|
|
}
|
|
|
|
func (h *handler) handleAPIExportBundle(w http.ResponseWriter, r *http.Request) {
|
|
archive, err := app.BuildSupportBundle(h.opts.ExportDir)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, map[string]string{
|
|
"status": "ok",
|
|
"path": archive,
|
|
"url": "/export/support.tar.gz",
|
|
})
|
|
}
|
|
|
|
// ── Tools ─────────────────────────────────────────────────────────────────────
|
|
|
|
var standardTools = []string{
|
|
"dmidecode", "smartctl", "nvme", "lspci", "ipmitool",
|
|
"nvidia-smi", "memtester", "stress-ng", "nvtop",
|
|
"mstflint", "qrencode",
|
|
}
|
|
|
|
func (h *handler) handleAPIToolsCheck(w http.ResponseWriter, r *http.Request) {
|
|
if h.opts.App == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "app not configured")
|
|
return
|
|
}
|
|
statuses := h.opts.App.CheckTools(standardTools)
|
|
writeJSON(w, statuses)
|
|
}
|
|
|
|
// ── Preflight ─────────────────────────────────────────────────────────────────
|
|
|
|
func (h *handler) handleAPIPreflight(w http.ResponseWriter, r *http.Request) {
|
|
data, err := loadSnapshot(filepath.Join(h.opts.ExportDir, "runtime-health.json"))
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "runtime health not found")
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
_, _ = w.Write(data)
|
|
}
|
|
|
|
// ── Metrics SSE ───────────────────────────────────────────────────────────────
|
|
|
|
func (h *handler) handleAPIMetricsStream(w http.ResponseWriter, r *http.Request) {
|
|
if !sseStart(w) {
|
|
return
|
|
}
|
|
ticker := time.NewTicker(time.Second)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-r.Context().Done():
|
|
return
|
|
case <-ticker.C:
|
|
sample := platform.SampleLiveMetrics()
|
|
b, err := json.Marshal(sample)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if !sseWrite(w, "metrics", string(b)) {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|