1064 lines
30 KiB
Go
1064 lines
30 KiB
Go
package webui
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"sync/atomic"
|
|
"syscall"
|
|
"time"
|
|
|
|
"bee/audit/internal/app"
|
|
"bee/audit/internal/platform"
|
|
)
|
|
|
|
var ansiEscapeRE = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]|\x1b[()][A-Z0-9]|\x1b[DABC]`)
|
|
|
|
// ── 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
|
|
}
|
|
streamSubscribedJob(w, r, j)
|
|
}
|
|
|
|
func streamSubscribedJob(w http.ResponseWriter, r *http.Request, j *jobState) {
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
// streamCmdJob runs an exec.Cmd and streams stdout+stderr lines into j.
|
|
func streamCmdJob(j *jobState, cmd *exec.Cmd) error {
|
|
pr, pw := io.Pipe()
|
|
cmd.Stdout = pw
|
|
cmd.Stderr = pw
|
|
|
|
if err := cmd.Start(); err != nil {
|
|
_ = pw.Close()
|
|
_ = pr.Close()
|
|
return err
|
|
}
|
|
// Lower the CPU scheduling priority of stress/audit subprocesses to nice+10
|
|
// so the X server and kernel interrupt handling remain responsive under load
|
|
// (prevents KVM/IPMI graphical console from freezing during GPU stress tests).
|
|
if cmd.Process != nil {
|
|
_ = syscall.Setpriority(syscall.PRIO_PROCESS, cmd.Process.Pid, 10)
|
|
}
|
|
|
|
scanDone := make(chan error, 1)
|
|
go func() {
|
|
defer func() {
|
|
if rec := recover(); rec != nil {
|
|
scanDone <- fmt.Errorf("stream scanner panic: %v", rec)
|
|
}
|
|
}()
|
|
scanner := bufio.NewScanner(pr)
|
|
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
|
for scanner.Scan() {
|
|
// Split on \r to handle progress-bar style output (e.g. \r overwrites)
|
|
// and strip ANSI escape codes so logs are readable in the browser.
|
|
parts := strings.Split(scanner.Text(), "\r")
|
|
for _, part := range parts {
|
|
line := ansiEscapeRE.ReplaceAllString(part, "")
|
|
if line != "" {
|
|
j.append(line)
|
|
}
|
|
}
|
|
}
|
|
if err := scanner.Err(); err != nil && !errors.Is(err, io.ErrClosedPipe) {
|
|
scanDone <- err
|
|
return
|
|
}
|
|
scanDone <- nil
|
|
}()
|
|
|
|
err := cmd.Wait()
|
|
_ = pw.Close()
|
|
scanErr := <-scanDone
|
|
_ = pr.Close()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return scanErr
|
|
}
|
|
|
|
// ── Audit ─────────────────────────────────────────────────────────────────────
|
|
|
|
func (h *handler) handleAPIAuditRun(w http.ResponseWriter, _ *http.Request) {
|
|
if h.opts.App == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "app not configured")
|
|
return
|
|
}
|
|
t := &Task{
|
|
ID: newJobID("audit"),
|
|
Name: "Audit",
|
|
Target: "audit",
|
|
Status: TaskPending,
|
|
CreatedAt: time.Now(),
|
|
}
|
|
globalQueue.enqueue(t)
|
|
writeJSON(w, map[string]string{"task_id": t.ID, "job_id": t.ID})
|
|
}
|
|
|
|
func (h *handler) handleAPIAuditStream(w http.ResponseWriter, r *http.Request) {
|
|
id := r.URL.Query().Get("job_id")
|
|
if id == "" {
|
|
id = r.URL.Query().Get("task_id")
|
|
}
|
|
// Try task queue first, then legacy job manager
|
|
if j, ok := globalQueue.findJob(id); ok {
|
|
streamJob(w, r, j)
|
|
return
|
|
}
|
|
if j, ok := globalJobs.get(id); ok {
|
|
streamJob(w, r, j)
|
|
return
|
|
}
|
|
http.Error(w, "job not found", http.StatusNotFound)
|
|
}
|
|
|
|
// ── 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
|
|
}
|
|
|
|
var body struct {
|
|
Duration int `json:"duration"`
|
|
DiagLevel int `json:"diag_level"`
|
|
GPUIndices []int `json:"gpu_indices"`
|
|
ExcludeGPUIndices []int `json:"exclude_gpu_indices"`
|
|
Loader string `json:"loader"`
|
|
Profile string `json:"profile"`
|
|
DisplayName string `json:"display_name"`
|
|
PlatformComponents []string `json:"platform_components"`
|
|
}
|
|
if r.Body != nil {
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil && !errors.Is(err, io.EOF) {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
}
|
|
|
|
name := taskDisplayName(target, body.Profile, body.Loader)
|
|
t := &Task{
|
|
ID: newJobID("sat-" + target),
|
|
Name: name,
|
|
Target: target,
|
|
Status: TaskPending,
|
|
CreatedAt: time.Now(),
|
|
params: taskParams{
|
|
Duration: body.Duration,
|
|
DiagLevel: body.DiagLevel,
|
|
GPUIndices: body.GPUIndices,
|
|
ExcludeGPUIndices: body.ExcludeGPUIndices,
|
|
Loader: body.Loader,
|
|
BurnProfile: body.Profile,
|
|
DisplayName: body.DisplayName,
|
|
PlatformComponents: body.PlatformComponents,
|
|
},
|
|
}
|
|
if strings.TrimSpace(body.DisplayName) != "" {
|
|
t.Name = body.DisplayName
|
|
}
|
|
globalQueue.enqueue(t)
|
|
writeJSON(w, map[string]string{"task_id": t.ID, "job_id": t.ID})
|
|
}
|
|
}
|
|
|
|
func (h *handler) handleAPISATStream(w http.ResponseWriter, r *http.Request) {
|
|
id := r.URL.Query().Get("job_id")
|
|
if id == "" {
|
|
id = r.URL.Query().Get("task_id")
|
|
}
|
|
if j, ok := globalQueue.findJob(id); ok {
|
|
streamJob(w, r, j)
|
|
return
|
|
}
|
|
if j, ok := globalJobs.get(id); ok {
|
|
streamJob(w, r, j)
|
|
return
|
|
}
|
|
http.Error(w, "job not found", http.StatusNotFound)
|
|
}
|
|
|
|
func (h *handler) handleAPISATAbort(w http.ResponseWriter, r *http.Request) {
|
|
id := r.URL.Query().Get("job_id")
|
|
if id == "" {
|
|
id = r.URL.Query().Get("task_id")
|
|
}
|
|
if t, ok := globalQueue.findByID(id); ok {
|
|
globalQueue.mu.Lock()
|
|
switch t.Status {
|
|
case TaskPending:
|
|
t.Status = TaskCancelled
|
|
now := time.Now()
|
|
t.DoneAt = &now
|
|
case TaskRunning:
|
|
if t.job != nil {
|
|
t.job.abort()
|
|
}
|
|
t.Status = TaskCancelled
|
|
now := time.Now()
|
|
t.DoneAt = &now
|
|
}
|
|
globalQueue.mu.Unlock()
|
|
writeJSON(w, map[string]string{"status": "aborted"})
|
|
return
|
|
}
|
|
if j, ok := globalJobs.get(id); ok {
|
|
if j.abort() {
|
|
writeJSON(w, map[string]string{"status": "aborted"})
|
|
} else {
|
|
writeJSON(w, map[string]string{"status": "not_running"})
|
|
}
|
|
return
|
|
}
|
|
http.Error(w, "job not found", http.StatusNotFound)
|
|
}
|
|
|
|
// ── 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(),
|
|
"pending_change": h.hasPendingNetworkChange(),
|
|
"rollback_in": h.pendingNetworkRollbackIn(),
|
|
})
|
|
}
|
|
|
|
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)
|
|
|
|
result, err := h.applyPendingNetworkChange(func() (app.ActionResult, error) {
|
|
if req.Interface == "" || req.Interface == "all" {
|
|
return h.opts.App.DHCPAllResult()
|
|
}
|
|
return h.opts.App.DHCPOneResult(req.Interface)
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, map[string]any{
|
|
"status": "ok",
|
|
"output": result.Body,
|
|
"rollback_in": int(netRollbackTimeout.Seconds()),
|
|
})
|
|
}
|
|
|
|
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.applyPendingNetworkChange(func() (app.ActionResult, error) {
|
|
return h.opts.App.SetStaticIPv4Result(cfg)
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, map[string]any{
|
|
"status": "ok",
|
|
"output": result.Body,
|
|
"rollback_in": int(netRollbackTimeout.Seconds()),
|
|
})
|
|
}
|
|
|
|
// ── 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) handleAPIExportUSBTargets(w http.ResponseWriter, _ *http.Request) {
|
|
if h.opts.App == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "app not configured")
|
|
return
|
|
}
|
|
targets, err := h.opts.App.ListRemovableTargets()
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
if targets == nil {
|
|
targets = []platform.RemovableTarget{}
|
|
}
|
|
writeJSON(w, targets)
|
|
}
|
|
|
|
func (h *handler) handleAPIExportUSBAudit(w http.ResponseWriter, r *http.Request) {
|
|
if h.opts.App == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "app not configured")
|
|
return
|
|
}
|
|
var target platform.RemovableTarget
|
|
if err := json.NewDecoder(r.Body).Decode(&target); err != nil || target.Device == "" {
|
|
writeError(w, http.StatusBadRequest, "device is required")
|
|
return
|
|
}
|
|
result, err := h.opts.App.ExportLatestAuditResult(target)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, map[string]string{"status": "ok", "message": result.Body})
|
|
}
|
|
|
|
func (h *handler) handleAPIExportUSBBundle(w http.ResponseWriter, r *http.Request) {
|
|
if h.opts.App == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "app not configured")
|
|
return
|
|
}
|
|
var target platform.RemovableTarget
|
|
if err := json.NewDecoder(r.Body).Decode(&target); err != nil || target.Device == "" {
|
|
writeError(w, http.StatusBadRequest, "device is required")
|
|
return
|
|
}
|
|
result, err := h.opts.App.ExportSupportBundleResult(target)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, map[string]string{"status": "ok", "message": result.Body})
|
|
}
|
|
|
|
// ── GPU presence ──────────────────────────────────────────────────────────────
|
|
|
|
func (h *handler) handleAPIGPUPresence(w http.ResponseWriter, r *http.Request) {
|
|
if h.opts.App == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "app not configured")
|
|
return
|
|
}
|
|
gp := h.opts.App.DetectGPUPresence()
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]bool{
|
|
"nvidia": gp.Nvidia,
|
|
"amd": gp.AMD,
|
|
})
|
|
}
|
|
|
|
// ── GPU tools ─────────────────────────────────────────────────────────────────
|
|
|
|
func (h *handler) handleAPIGPUTools(w http.ResponseWriter, _ *http.Request) {
|
|
type toolEntry struct {
|
|
ID string `json:"id"`
|
|
Available bool `json:"available"`
|
|
Vendor string `json:"vendor"` // "nvidia" | "amd"
|
|
}
|
|
_, nvidiaErr := os.Stat("/dev/nvidia0")
|
|
_, amdErr := os.Stat("/dev/kfd")
|
|
nvidiaUp := nvidiaErr == nil
|
|
amdUp := amdErr == nil
|
|
writeJSON(w, []toolEntry{
|
|
{ID: "bee-gpu-burn", Available: nvidiaUp, Vendor: "nvidia"},
|
|
{ID: "john", Available: nvidiaUp, Vendor: "nvidia"},
|
|
{ID: "nccl", Available: nvidiaUp, Vendor: "nvidia"},
|
|
{ID: "rvs", Available: amdUp, Vendor: "amd"},
|
|
})
|
|
}
|
|
|
|
// ── System ────────────────────────────────────────────────────────────────────
|
|
|
|
func (h *handler) handleAPIRAMStatus(w http.ResponseWriter, r *http.Request) {
|
|
if h.opts.App == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "app not configured")
|
|
return
|
|
}
|
|
status := h.opts.App.LiveBootSource()
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(status)
|
|
}
|
|
|
|
func (h *handler) handleAPIInstallToRAM(w http.ResponseWriter, r *http.Request) {
|
|
if h.opts.App == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "app not configured")
|
|
return
|
|
}
|
|
if globalQueue.hasActiveTarget("install") {
|
|
writeError(w, http.StatusConflict, "install to disk is already running")
|
|
return
|
|
}
|
|
t := &Task{
|
|
ID: newJobID("install-to-ram"),
|
|
Name: "Install to RAM",
|
|
Target: "install-to-ram",
|
|
Priority: 10,
|
|
Status: TaskPending,
|
|
CreatedAt: time.Now(),
|
|
}
|
|
globalQueue.enqueue(t)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]string{"task_id": t.ID})
|
|
}
|
|
|
|
// ── 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)
|
|
}
|
|
|
|
// ── Install ───────────────────────────────────────────────────────────────────
|
|
|
|
func (h *handler) handleAPIInstallDisks(w http.ResponseWriter, r *http.Request) {
|
|
if h.opts.App == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "app not configured")
|
|
return
|
|
}
|
|
disks, err := h.opts.App.ListInstallDisks()
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
type diskJSON struct {
|
|
Device string `json:"device"`
|
|
Model string `json:"model"`
|
|
Size string `json:"size"`
|
|
SizeBytes int64 `json:"size_bytes"`
|
|
MountedParts []string `json:"mounted_parts"`
|
|
Warnings []string `json:"warnings"`
|
|
}
|
|
result := make([]diskJSON, 0, len(disks))
|
|
for _, d := range disks {
|
|
result = append(result, diskJSON{
|
|
Device: d.Device,
|
|
Model: d.Model,
|
|
Size: d.Size,
|
|
SizeBytes: d.SizeBytes,
|
|
MountedParts: d.MountedParts,
|
|
Warnings: platform.DiskWarnings(d),
|
|
})
|
|
}
|
|
writeJSON(w, result)
|
|
}
|
|
|
|
func (h *handler) handleAPIInstallRun(w http.ResponseWriter, r *http.Request) {
|
|
if h.opts.App == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "app not configured")
|
|
return
|
|
}
|
|
var req struct {
|
|
Device string `json:"device"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Device == "" {
|
|
writeError(w, http.StatusBadRequest, "device is required")
|
|
return
|
|
}
|
|
|
|
// Whitelist: only allow devices that ListInstallDisks() returns.
|
|
disks, err := h.opts.App.ListInstallDisks()
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
allowed := false
|
|
for _, d := range disks {
|
|
if d.Device == req.Device {
|
|
allowed = true
|
|
break
|
|
}
|
|
}
|
|
if !allowed {
|
|
writeError(w, http.StatusBadRequest, "device not in install candidate list")
|
|
return
|
|
}
|
|
if globalQueue.hasActiveTarget("install-to-ram") {
|
|
writeError(w, http.StatusConflict, "install to RAM task is already pending or running")
|
|
return
|
|
}
|
|
if globalQueue.hasActiveTarget("install") {
|
|
writeError(w, http.StatusConflict, "install task is already pending or running")
|
|
return
|
|
}
|
|
t := &Task{
|
|
ID: newJobID("install"),
|
|
Name: "Install to Disk",
|
|
Target: "install",
|
|
Priority: 20,
|
|
Status: TaskPending,
|
|
CreatedAt: time.Now(),
|
|
params: taskParams{
|
|
Device: req.Device,
|
|
},
|
|
}
|
|
globalQueue.enqueue(t)
|
|
writeJSON(w, map[string]string{"task_id": t.ID, "job_id": t.ID})
|
|
}
|
|
|
|
// ── Metrics SSE ───────────────────────────────────────────────────────────────
|
|
|
|
func (h *handler) handleAPIMetricsLatest(w http.ResponseWriter, r *http.Request) {
|
|
sample, ok := h.latestMetric()
|
|
if !ok {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte("{}"))
|
|
return
|
|
}
|
|
b, err := json.Marshal(sample)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write(b)
|
|
}
|
|
|
|
func (h *handler) handleAPIMetricsStream(w http.ResponseWriter, r *http.Request) {
|
|
if !sseStart(w) {
|
|
return
|
|
}
|
|
ticker := time.NewTicker(1 * time.Second)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-r.Context().Done():
|
|
return
|
|
case <-ticker.C:
|
|
sample, ok := h.latestMetric()
|
|
if !ok {
|
|
continue
|
|
}
|
|
b, err := json.Marshal(sample)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if !sseWrite(w, "metrics", string(b)) {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// feedRings pushes one sample into all in-memory ring buffers.
|
|
func (h *handler) feedRings(sample platform.LiveMetricSample) {
|
|
for _, t := range sample.Temps {
|
|
switch t.Group {
|
|
case "cpu":
|
|
h.pushNamedMetricRing(&h.cpuTempRings, t.Name, t.Celsius)
|
|
case "ambient":
|
|
h.pushNamedMetricRing(&h.ambientTempRings, t.Name, t.Celsius)
|
|
}
|
|
}
|
|
h.ringPower.push(sample.PowerW)
|
|
h.ringCPULoad.push(sample.CPULoadPct)
|
|
h.ringMemLoad.push(sample.MemLoadPct)
|
|
|
|
h.ringsMu.Lock()
|
|
h.pushFanRings(sample.Fans)
|
|
for _, gpu := range sample.GPUs {
|
|
idx := gpu.GPUIndex
|
|
for len(h.gpuRings) <= idx {
|
|
h.gpuRings = append(h.gpuRings, &gpuRings{
|
|
Temp: newMetricsRing(120),
|
|
Util: newMetricsRing(120),
|
|
MemUtil: newMetricsRing(120),
|
|
Power: newMetricsRing(120),
|
|
})
|
|
}
|
|
h.gpuRings[idx].Temp.push(gpu.TempC)
|
|
h.gpuRings[idx].Util.push(gpu.UsagePct)
|
|
h.gpuRings[idx].MemUtil.push(gpu.MemUsagePct)
|
|
h.gpuRings[idx].Power.push(gpu.PowerW)
|
|
}
|
|
h.ringsMu.Unlock()
|
|
}
|
|
|
|
func (h *handler) pushFanRings(fans []platform.FanReading) {
|
|
if len(fans) == 0 && len(h.ringFans) == 0 {
|
|
return
|
|
}
|
|
fanValues := make(map[string]float64, len(fans))
|
|
for _, fan := range fans {
|
|
if fan.Name == "" {
|
|
continue
|
|
}
|
|
fanValues[fan.Name] = fan.RPM
|
|
found := false
|
|
for i, name := range h.fanNames {
|
|
if name == fan.Name {
|
|
found = true
|
|
if i >= len(h.ringFans) {
|
|
h.ringFans = append(h.ringFans, newMetricsRing(120))
|
|
}
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
h.fanNames = append(h.fanNames, fan.Name)
|
|
h.ringFans = append(h.ringFans, newMetricsRing(120))
|
|
}
|
|
}
|
|
for i, ring := range h.ringFans {
|
|
if ring == nil {
|
|
continue
|
|
}
|
|
name := ""
|
|
if i < len(h.fanNames) {
|
|
name = h.fanNames[i]
|
|
}
|
|
if rpm, ok := fanValues[name]; ok {
|
|
ring.push(rpm)
|
|
continue
|
|
}
|
|
if last, ok := ring.latest(); ok {
|
|
ring.push(last)
|
|
continue
|
|
}
|
|
ring.push(0)
|
|
}
|
|
}
|
|
|
|
func (h *handler) pushNamedMetricRing(dst *[]*namedMetricsRing, name string, value float64) {
|
|
if name == "" {
|
|
return
|
|
}
|
|
for _, item := range *dst {
|
|
if item != nil && item.Name == name && item.Ring != nil {
|
|
item.Ring.push(value)
|
|
return
|
|
}
|
|
}
|
|
*dst = append(*dst, &namedMetricsRing{
|
|
Name: name,
|
|
Ring: newMetricsRing(120),
|
|
})
|
|
(*dst)[len(*dst)-1].Ring.push(value)
|
|
}
|
|
|
|
// ── Network toggle ────────────────────────────────────────────────────────────
|
|
|
|
const netRollbackTimeout = 60 * time.Second
|
|
|
|
func (h *handler) handleAPINetworkToggle(w http.ResponseWriter, r *http.Request) {
|
|
if h.opts.App == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "app not configured")
|
|
return
|
|
}
|
|
var req struct {
|
|
Iface string `json:"iface"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Iface == "" {
|
|
writeError(w, http.StatusBadRequest, "iface is required")
|
|
return
|
|
}
|
|
|
|
wasUp, err := h.opts.App.GetInterfaceState(req.Iface)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
if _, err := h.applyPendingNetworkChange(func() (app.ActionResult, error) {
|
|
err := h.opts.App.SetInterfaceState(req.Iface, !wasUp)
|
|
return app.ActionResult{}, err
|
|
}); err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
newState := "up"
|
|
if wasUp {
|
|
newState = "down"
|
|
}
|
|
writeJSON(w, map[string]any{
|
|
"iface": req.Iface,
|
|
"new_state": newState,
|
|
"rollback_in": int(netRollbackTimeout.Seconds()),
|
|
})
|
|
}
|
|
|
|
func (h *handler) applyPendingNetworkChange(apply func() (app.ActionResult, error)) (app.ActionResult, error) {
|
|
if h.opts.App == nil {
|
|
return app.ActionResult{}, fmt.Errorf("app not configured")
|
|
}
|
|
|
|
if err := h.rollbackPendingNetworkChange(); err != nil && err.Error() != "no pending network change" {
|
|
return app.ActionResult{}, err
|
|
}
|
|
|
|
snapshot, err := h.opts.App.CaptureNetworkSnapshot()
|
|
if err != nil {
|
|
return app.ActionResult{}, err
|
|
}
|
|
|
|
result, err := apply()
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
|
|
pnc := &pendingNetChange{
|
|
snapshot: snapshot,
|
|
deadline: time.Now().Add(netRollbackTimeout),
|
|
}
|
|
pnc.timer = time.AfterFunc(netRollbackTimeout, func() {
|
|
_ = h.opts.App.RestoreNetworkSnapshot(snapshot)
|
|
h.pendingNetMu.Lock()
|
|
if h.pendingNet == pnc {
|
|
h.pendingNet = nil
|
|
}
|
|
h.pendingNetMu.Unlock()
|
|
})
|
|
|
|
h.pendingNetMu.Lock()
|
|
h.pendingNet = pnc
|
|
h.pendingNetMu.Unlock()
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func (h *handler) hasPendingNetworkChange() bool {
|
|
h.pendingNetMu.Lock()
|
|
defer h.pendingNetMu.Unlock()
|
|
return h.pendingNet != nil
|
|
}
|
|
|
|
func (h *handler) pendingNetworkRollbackIn() int {
|
|
h.pendingNetMu.Lock()
|
|
defer h.pendingNetMu.Unlock()
|
|
if h.pendingNet == nil {
|
|
return 0
|
|
}
|
|
remaining := int(time.Until(h.pendingNet.deadline).Seconds())
|
|
if remaining < 1 {
|
|
return 1
|
|
}
|
|
return remaining
|
|
}
|
|
|
|
func (h *handler) handleAPINetworkConfirm(w http.ResponseWriter, _ *http.Request) {
|
|
h.pendingNetMu.Lock()
|
|
pnc := h.pendingNet
|
|
h.pendingNet = nil
|
|
h.pendingNetMu.Unlock()
|
|
if pnc != nil {
|
|
pnc.mu.Lock()
|
|
pnc.timer.Stop()
|
|
pnc.mu.Unlock()
|
|
}
|
|
writeJSON(w, map[string]string{"status": "confirmed"})
|
|
}
|
|
|
|
func (h *handler) handleAPINetworkRollback(w http.ResponseWriter, _ *http.Request) {
|
|
if err := h.rollbackPendingNetworkChange(); err != nil {
|
|
if err.Error() == "no pending network change" {
|
|
writeError(w, http.StatusConflict, err.Error())
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, map[string]string{"status": "rolled back"})
|
|
}
|
|
|
|
func (h *handler) rollbackPendingNetworkChange() error {
|
|
h.pendingNetMu.Lock()
|
|
pnc := h.pendingNet
|
|
h.pendingNet = nil
|
|
h.pendingNetMu.Unlock()
|
|
if pnc == nil {
|
|
return fmt.Errorf("no pending network change")
|
|
}
|
|
pnc.mu.Lock()
|
|
pnc.timer.Stop()
|
|
pnc.mu.Unlock()
|
|
if h.opts.App != nil {
|
|
return h.opts.App.RestoreNetworkSnapshot(pnc.snapshot)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ── Display / Screen Resolution ───────────────────────────────────────────────
|
|
|
|
type displayMode struct {
|
|
Output string `json:"output"`
|
|
Mode string `json:"mode"`
|
|
Current bool `json:"current"`
|
|
}
|
|
|
|
type displayInfo struct {
|
|
Output string `json:"output"`
|
|
Modes []displayMode `json:"modes"`
|
|
Current string `json:"current"`
|
|
}
|
|
|
|
var xrandrOutputRE = regexp.MustCompile(`^(\S+)\s+connected`)
|
|
var xrandrModeRE = regexp.MustCompile(`^\s{3}(\d+x\d+)\s`)
|
|
var xrandrCurrentRE = regexp.MustCompile(`\*`)
|
|
|
|
func parseXrandrOutput(out string) []displayInfo {
|
|
var infos []displayInfo
|
|
var cur *displayInfo
|
|
for _, line := range strings.Split(out, "\n") {
|
|
if m := xrandrOutputRE.FindStringSubmatch(line); m != nil {
|
|
if cur != nil {
|
|
infos = append(infos, *cur)
|
|
}
|
|
cur = &displayInfo{Output: m[1]}
|
|
continue
|
|
}
|
|
if cur == nil {
|
|
continue
|
|
}
|
|
if m := xrandrModeRE.FindStringSubmatch(line); m != nil {
|
|
isCurrent := xrandrCurrentRE.MatchString(line)
|
|
mode := displayMode{Output: cur.Output, Mode: m[1], Current: isCurrent}
|
|
cur.Modes = append(cur.Modes, mode)
|
|
if isCurrent {
|
|
cur.Current = m[1]
|
|
}
|
|
}
|
|
}
|
|
if cur != nil {
|
|
infos = append(infos, *cur)
|
|
}
|
|
return infos
|
|
}
|
|
|
|
func xrandrCommand(args ...string) *exec.Cmd {
|
|
cmd := exec.Command("xrandr", args...)
|
|
env := append([]string{}, os.Environ()...)
|
|
hasDisplay := false
|
|
hasXAuthority := false
|
|
for _, kv := range env {
|
|
if strings.HasPrefix(kv, "DISPLAY=") && strings.TrimPrefix(kv, "DISPLAY=") != "" {
|
|
hasDisplay = true
|
|
}
|
|
if strings.HasPrefix(kv, "XAUTHORITY=") && strings.TrimPrefix(kv, "XAUTHORITY=") != "" {
|
|
hasXAuthority = true
|
|
}
|
|
}
|
|
if !hasDisplay {
|
|
env = append(env, "DISPLAY=:0")
|
|
}
|
|
if !hasXAuthority {
|
|
env = append(env, "XAUTHORITY=/home/bee/.Xauthority")
|
|
}
|
|
cmd.Env = env
|
|
return cmd
|
|
}
|
|
|
|
func (h *handler) handleAPIDisplayResolutions(w http.ResponseWriter, _ *http.Request) {
|
|
out, err := xrandrCommand().Output()
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "xrandr: "+err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, parseXrandrOutput(string(out)))
|
|
}
|
|
|
|
func (h *handler) handleAPIDisplaySet(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Output string `json:"output"`
|
|
Mode string `json:"mode"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Output == "" || req.Mode == "" {
|
|
writeError(w, http.StatusBadRequest, "output and mode are required")
|
|
return
|
|
}
|
|
// Validate mode looks like WxH to prevent injection
|
|
if !regexp.MustCompile(`^\d+x\d+$`).MatchString(req.Mode) {
|
|
writeError(w, http.StatusBadRequest, "invalid mode format")
|
|
return
|
|
}
|
|
// Validate output name (no special chars)
|
|
if !regexp.MustCompile(`^[A-Za-z0-9_\-]+$`).MatchString(req.Output) {
|
|
writeError(w, http.StatusBadRequest, "invalid output name")
|
|
return
|
|
}
|
|
if out, err := xrandrCommand("--output", req.Output, "--mode", req.Mode).CombinedOutput(); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "xrandr: "+strings.TrimSpace(string(out)))
|
|
return
|
|
}
|
|
writeJSON(w, map[string]string{"status": "ok", "output": req.Output, "mode": req.Mode})
|
|
}
|