361 lines
8.9 KiB
Go
361 lines
8.9 KiB
Go
package webui
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"time"
|
|
|
|
"bee/audit/internal/app"
|
|
"bee/audit/internal/platform"
|
|
)
|
|
|
|
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) handleAPIBenchmarkResults(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
fmt.Fprint(w, renderBenchmarkResultsCard(h.opts.ExportDir))
|
|
}
|
|
|
|
// ── Hardware summary / component detail ──────────────────────────────────────
|
|
|
|
// handleAPIHardwareSummary returns the hardware summary card HTML fragment for
|
|
// htmx polling (hx-get="/api/hardware-summary" hx-swap="outerHTML").
|
|
func (h *handler) handleAPIHardwareSummary(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
fmt.Fprint(w, renderHardwareSummaryCard(h.opts))
|
|
}
|
|
|
|
// handleAPIComponentDetail returns an HTML fragment describing the current and
|
|
// historical status for one component type (cpu, memory, storage, gpu, psu).
|
|
func (h *handler) handleAPIComponentDetail(w http.ResponseWriter, r *http.Request) {
|
|
compType := r.PathValue("type")
|
|
var exact, prefixes []string
|
|
var title string
|
|
switch compType {
|
|
case "cpu":
|
|
title = "CPU"
|
|
exact = []string{"cpu:all"}
|
|
case "memory":
|
|
title = "Memory"
|
|
exact = []string{"memory:all"}
|
|
prefixes = []string{"memory:"}
|
|
case "storage":
|
|
title = "Storage"
|
|
exact = []string{"storage:all"}
|
|
prefixes = []string{"storage:"}
|
|
case "gpu":
|
|
title = "GPU"
|
|
prefixes = []string{"pcie:gpu:"}
|
|
case "nic":
|
|
title = "NIC"
|
|
prefixes = []string{"pcie:nic:"}
|
|
case "psu":
|
|
title = "PSU"
|
|
prefixes = []string{"psu:"}
|
|
case "raid":
|
|
title = "RAID"
|
|
prefixes = []string{"pcie:raid:"}
|
|
default:
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
var records []app.ComponentStatusRecord
|
|
if h.opts.App != nil && h.opts.App.StatusDB != nil {
|
|
all := h.opts.App.StatusDB.All()
|
|
records = matchedRecords(all, exact, prefixes)
|
|
}
|
|
|
|
fromInventory := false
|
|
if len(records) == 0 {
|
|
if fallback := inventoryFallbackRecords(compType, h.opts); len(fallback) > 0 {
|
|
records = fallback
|
|
fromInventory = true
|
|
}
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
fmt.Fprint(w, renderComponentDetail(title, records, fromInventory))
|
|
}
|
|
|
|
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
|
|
}
|