499 lines
16 KiB
Go
499 lines
16 KiB
Go
package webui
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"bee/audit/internal/platform"
|
|
)
|
|
|
|
func (h *handler) handleAPIGNVIDIAGPUs(w http.ResponseWriter, _ *http.Request) {
|
|
writeAPIListResponse(w, h.opts.App != nil, func() ([]platform.NvidiaGPU, error) {
|
|
return h.opts.App.ListNvidiaGPUs()
|
|
})
|
|
}
|
|
|
|
func (h *handler) handleAPIGNVIDIAGPUStatuses(w http.ResponseWriter, _ *http.Request) {
|
|
writeAPIListResponse(w, h.opts.App != nil, func() ([]platform.NvidiaGPUStatus, error) {
|
|
return apiListNvidiaGPUStatuses(h.opts.App)
|
|
})
|
|
}
|
|
|
|
func (h *handler) handleAPIGNVIDIAReset(w http.ResponseWriter, r *http.Request) {
|
|
if h.opts.App == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "app not configured")
|
|
return
|
|
}
|
|
var req struct {
|
|
Index int `json:"index"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
result, err := h.opts.App.ResetNvidiaGPU(req.Index)
|
|
status := "ok"
|
|
if err != nil {
|
|
status = "error"
|
|
}
|
|
writeJSON(w, map[string]string{"status": status, "output": result.Body})
|
|
}
|
|
|
|
// ── GPU settings (ECC / power limit) ──────────────────────────────────────────
|
|
|
|
func (h *handler) handleAPIGNVIDIAGPUSettings(w http.ResponseWriter, _ *http.Request) {
|
|
writeAPIListResponse(w, h.opts.App != nil, func() ([]platform.NvidiaGPUSetting, error) {
|
|
return h.opts.App.ListNvidiaGPUSettings()
|
|
})
|
|
}
|
|
|
|
func (h *handler) handleAPIGNVIDIASetECC(w http.ResponseWriter, r *http.Request) {
|
|
h.handleAPIGNVIDIASetBool(w, r, func(index int, enabled bool) (string, error) {
|
|
result, err := h.opts.App.SetNvidiaGPUECC(index, enabled)
|
|
return result.Body, err
|
|
})
|
|
}
|
|
|
|
func (h *handler) handleAPIGNVIDIASetMIG(w http.ResponseWriter, r *http.Request) {
|
|
h.handleAPIGNVIDIASetBool(w, r, func(index int, enabled bool) (string, error) {
|
|
result, err := h.opts.App.SetNvidiaGPUMIG(index, enabled)
|
|
return result.Body, err
|
|
})
|
|
}
|
|
|
|
func (h *handler) handleAPIGNVIDIASetCCMode(w http.ResponseWriter, r *http.Request) {
|
|
h.handleAPIGNVIDIASetBool(w, r, func(index int, enabled bool) (string, error) {
|
|
result, err := h.opts.App.SetNvidiaGPUCCMode(index, enabled)
|
|
return result.Body, err
|
|
})
|
|
}
|
|
|
|
func (h *handler) handleAPIGNVIDIASetBool(w http.ResponseWriter, r *http.Request, apply func(int, bool) (string, error)) {
|
|
if h.opts.App == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "app not configured")
|
|
return
|
|
}
|
|
var req struct {
|
|
Index int `json:"index"`
|
|
Enabled bool `json:"enabled"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
output, err := apply(req.Index, req.Enabled)
|
|
status := "ok"
|
|
if err != nil {
|
|
status = "error"
|
|
}
|
|
writeJSON(w, map[string]string{"status": status, "output": output})
|
|
}
|
|
|
|
func (h *handler) handleAPIGNVIDIASetPowerLimit(w http.ResponseWriter, r *http.Request) {
|
|
if h.opts.App == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "app not configured")
|
|
return
|
|
}
|
|
var req struct {
|
|
Index int `json:"index"`
|
|
Watts float64 `json:"watts"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
if req.Watts <= 0 {
|
|
writeError(w, http.StatusBadRequest, "watts must be > 0")
|
|
return
|
|
}
|
|
result, err := h.opts.App.SetNvidiaGPUPowerLimit(req.Index, req.Watts)
|
|
status := "ok"
|
|
if err != nil {
|
|
status = "error"
|
|
}
|
|
writeJSON(w, map[string]string{"status": status, "output": result.Body})
|
|
}
|
|
|
|
func (h *handler) handleAPIGNVIDIAResetDefaults(w http.ResponseWriter, _ *http.Request) {
|
|
if h.opts.App == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "app not configured")
|
|
return
|
|
}
|
|
result, err := h.opts.App.ResetNvidiaGPUDefaults()
|
|
status := "ok"
|
|
if err != nil {
|
|
status = "error"
|
|
}
|
|
writeJSON(w, map[string]string{"status": status, "output": result.Body})
|
|
}
|
|
|
|
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,
|
|
"nvidia_initializing": gp.NvidiaInitializing,
|
|
"amd_initializing": gp.AMDInitializing,
|
|
})
|
|
}
|
|
|
|
// ── 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"
|
|
}
|
|
// Single source of truth for GPU presence: see app.DetectGPUPresence.
|
|
var nvidiaUp, amdUp bool
|
|
if h.opts.App != nil {
|
|
gp := h.opts.App.DetectGPUPresence()
|
|
nvidiaUp, amdUp = gp.Nvidia, gp.AMD
|
|
} else {
|
|
_, nvidiaErr := os.Stat("/dev/nvidia0")
|
|
_, amdErr := os.Stat("/dev/kfd")
|
|
nvidiaUp, amdUp = nvidiaErr == nil, amdErr == nil
|
|
}
|
|
_, dcgmErr := exec.LookPath("dcgmi")
|
|
_, ncclStressErr := exec.LookPath("bee-nccl-gpu-stress")
|
|
_, johnErr := exec.LookPath("bee-john-gpu-stress")
|
|
_, beeBurnErr := exec.LookPath("bee-gpu-burn")
|
|
_, nvBandwidthErr := exec.LookPath("nvbandwidth")
|
|
profErr := lookPathAny("dcgmproftester", "dcgmproftester13", "dcgmproftester12", "dcgmproftester11")
|
|
writeJSON(w, []toolEntry{
|
|
{ID: "nvidia-compute", Available: nvidiaUp && profErr == nil, Vendor: "nvidia"},
|
|
{ID: "nvidia-targeted-power", Available: nvidiaUp && dcgmErr == nil, Vendor: "nvidia"},
|
|
{ID: "nvidia-pulse", Available: nvidiaUp && dcgmErr == nil, Vendor: "nvidia"},
|
|
{ID: "nvidia-interconnect", Available: nvidiaUp && ncclStressErr == nil, Vendor: "nvidia"},
|
|
{ID: "nvidia-bandwidth", Available: nvidiaUp && dcgmErr == nil && nvBandwidthErr == nil, Vendor: "nvidia"},
|
|
{ID: "bee-gpu-burn", Available: nvidiaUp && beeBurnErr == nil, Vendor: "nvidia"},
|
|
{ID: "john", Available: nvidiaUp && johnErr == nil, Vendor: "nvidia"},
|
|
{ID: "rvs", Available: amdUp, Vendor: "amd"},
|
|
})
|
|
}
|
|
|
|
func lookPathAny(names ...string) error {
|
|
for _, name := range names {
|
|
if _, err := exec.LookPath(name); err == nil {
|
|
return nil
|
|
}
|
|
}
|
|
return exec.ErrNotFound
|
|
}
|
|
|
|
// ── 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.currentRAMStatus()
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(status)
|
|
}
|
|
|
|
type ramStatusResponse struct {
|
|
platform.LiveMediaRAMState
|
|
InstallTaskActive bool `json:"install_task_active,omitempty"`
|
|
CopyTaskActive bool `json:"copy_task_active,omitempty"`
|
|
CanStartTask bool `json:"can_start_task,omitempty"`
|
|
BlockedReason string `json:"blocked_reason,omitempty"`
|
|
}
|
|
|
|
func (h *handler) currentRAMStatus() ramStatusResponse {
|
|
state := h.opts.App.LiveMediaRAMState()
|
|
resp := ramStatusResponse{LiveMediaRAMState: state}
|
|
if globalQueue.hasActiveTarget("install") {
|
|
resp.InstallTaskActive = true
|
|
resp.BlockedReason = "install to disk is already running"
|
|
return resp
|
|
}
|
|
if globalQueue.hasActiveTarget("install-to-ram") {
|
|
resp.CopyTaskActive = true
|
|
resp.BlockedReason = "install to RAM task is already pending or running"
|
|
return resp
|
|
}
|
|
if state.InRAM {
|
|
resp.BlockedReason = "system is already running from RAM"
|
|
return resp
|
|
}
|
|
resp.CanStartTask = state.CanStartCopy
|
|
if !resp.CanStartTask && resp.BlockedReason == "" {
|
|
resp.BlockedReason = state.Message
|
|
}
|
|
return resp
|
|
}
|
|
|
|
func (h *handler) handleAPIInstallToRAM(w http.ResponseWriter, r *http.Request) {
|
|
if h.opts.App == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "app not configured")
|
|
return
|
|
}
|
|
status := h.currentRAMStatus()
|
|
if !status.CanStartTask {
|
|
msg := strings.TrimSpace(status.BlockedReason)
|
|
if msg == "" {
|
|
msg = "install to RAM is not available"
|
|
}
|
|
writeError(w, http.StatusConflict, msg)
|
|
return
|
|
}
|
|
t := &Task{
|
|
ID: newJobID("install-to-ram"),
|
|
Name: "Install to RAM",
|
|
Target: "install-to-ram",
|
|
Priority: defaultTaskPriority("install-to-ram", taskParams{}),
|
|
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})
|
|
}
|
|
|
|
func (h *handler) handleAPISystemReboot(w http.ResponseWriter, r *http.Request) {
|
|
if err := exec.Command("systemctl", "reboot").Start(); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "reboot failed: "+err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, map[string]string{"status": "rebooting"})
|
|
}
|
|
|
|
func (h *handler) handleAPISystemShutdown(w http.ResponseWriter, r *http.Request) {
|
|
if err := exec.Command("systemctl", "poweroff").Start(); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "shutdown failed: "+err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, map[string]string{"status": "shutting down"})
|
|
}
|
|
|
|
// timezoneNameRE matches IANA timezone identifiers like "Europe/Moscow" or "UTC".
|
|
var timezoneNameRE = regexp.MustCompile(`^[A-Za-z0-9_+\-]+(/[A-Za-z0-9_+\-]+)*$`)
|
|
|
|
func validTimezoneName(tz string) bool {
|
|
if tz == "" || !timezoneNameRE.MatchString(tz) {
|
|
return false
|
|
}
|
|
_, err := os.Stat(filepath.Join("/usr/share/zoneinfo", tz))
|
|
return err == nil
|
|
}
|
|
|
|
// handleAPISystemTimeSync sets the host's timezone and wall-clock time from
|
|
// values supplied by the client's browser (used when the appliance has no
|
|
// network/NTP access to keep its own clock in sync).
|
|
func (h *handler) handleAPISystemTimeSync(w http.ResponseWriter, r *http.Request) {
|
|
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
|
defer cancel()
|
|
|
|
var req struct {
|
|
Timezone string `json:"timezone"`
|
|
EpochMS int64 `json:"epoch_ms"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
if req.EpochMS <= 0 {
|
|
writeError(w, http.StatusBadRequest, "epoch_ms required")
|
|
return
|
|
}
|
|
|
|
var out strings.Builder
|
|
|
|
if req.Timezone != "" {
|
|
if !validTimezoneName(req.Timezone) {
|
|
writeError(w, http.StatusBadRequest, "invalid timezone")
|
|
return
|
|
}
|
|
if b, err := exec.CommandContext(ctx, "timedatectl", "set-timezone", req.Timezone).CombinedOutput(); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "set-timezone failed: "+commandFailureDetail(b, err))
|
|
return
|
|
}
|
|
fmt.Fprintf(&out, "timezone set to %s\n", req.Timezone)
|
|
}
|
|
|
|
// Manual time only sticks if NTP sync is off.
|
|
if b, err := exec.CommandContext(ctx, "timedatectl", "set-ntp", "false").CombinedOutput(); err != nil {
|
|
canNTP, canErr := exec.CommandContext(ctx, "timedatectl", "show", "-p", "CanNTP", "--value").Output()
|
|
if canErr != nil || strings.TrimSpace(string(canNTP)) != "no" {
|
|
writeError(w, http.StatusInternalServerError, "disable NTP failed: "+commandFailureDetail(b, err))
|
|
return
|
|
}
|
|
}
|
|
|
|
sec := req.EpochMS / 1000
|
|
if b, err := exec.CommandContext(ctx, "date", "-u", "-s", "@"+strconv.FormatInt(sec, 10)).CombinedOutput(); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "set-time failed: "+commandFailureDetail(b, err))
|
|
return
|
|
}
|
|
out.WriteString("system clock synced\n")
|
|
|
|
writeJSON(w, map[string]string{"status": "ok", "output": out.String()})
|
|
}
|
|
|
|
func commandFailureDetail(output []byte, err error) string {
|
|
if detail := strings.TrimSpace(string(output)); detail != "" {
|
|
return detail
|
|
}
|
|
return err.Error()
|
|
}
|
|
|
|
// handleAPISystemTime reports the host's current wall-clock time and configured
|
|
// timezone so the dashboard can show them next to the browser's own clock and
|
|
// flag a drift.
|
|
func (h *handler) handleAPISystemTime(w http.ResponseWriter, r *http.Request) {
|
|
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
|
defer cancel()
|
|
|
|
tz := ""
|
|
if b, err := exec.CommandContext(ctx, "timedatectl", "show", "-p", "Timezone", "--value").Output(); err == nil {
|
|
tz = strings.TrimSpace(string(b))
|
|
}
|
|
now := time.Now()
|
|
if tz == "" {
|
|
tz, _ = now.Zone()
|
|
}
|
|
|
|
writeJSON(w, map[string]any{
|
|
"epoch_ms": now.UnixMilli(),
|
|
"local_time": now.Format("2006-01-02 15:04:05"),
|
|
"timezone": tz,
|
|
})
|
|
}
|
|
|
|
// ── Tools ─────────────────────────────────────────────────────────────────────
|
|
|
|
var standardTools = []string{
|
|
"dmidecode", "smartctl", "nvme", "lspci", "ipmitool",
|
|
"tpm2_getcap", "tpm2_pcrread", "tpm2_gettestresult",
|
|
"nvidia-smi", "dcgmi", "nv-hostengine", "memtester", "stress-ng", "nvtop",
|
|
"mstflint", "saa",
|
|
}
|
|
|
|
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: defaultTaskPriority("install", taskParams{}),
|
|
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 ───────────────────────────────────────────────────────────────
|