Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ec0b7f7ff9 | |||
| e7a7ff54b9 | |||
| b4371e291e | |||
| c22b53a406 | |||
| ff0acc3698 | |||
| d50760e7c6 | |||
| ed4f8be019 |
18
audit/Makefile
Normal file
18
audit/Makefile
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
LISTEN ?= :8080
|
||||||
|
AUDIT_PATH ?=
|
||||||
|
|
||||||
|
RUN_ARGS := web --listen $(LISTEN)
|
||||||
|
ifneq ($(AUDIT_PATH),)
|
||||||
|
RUN_ARGS += --audit-path $(AUDIT_PATH)
|
||||||
|
endif
|
||||||
|
|
||||||
|
.PHONY: run build test
|
||||||
|
|
||||||
|
run:
|
||||||
|
go run ./cmd/bee $(RUN_ARGS)
|
||||||
|
|
||||||
|
build:
|
||||||
|
go build -o bee ./cmd/bee
|
||||||
|
|
||||||
|
test:
|
||||||
|
go test ./...
|
||||||
14
audit/go.mod
14
audit/go.mod
@@ -1,3 +1,17 @@
|
|||||||
module bee/audit
|
module bee/audit
|
||||||
|
|
||||||
go 1.24.0
|
go 1.24.0
|
||||||
|
|
||||||
|
replace reanimator/chart => ../internal/chart
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/go-analyze/charts v0.5.26
|
||||||
|
reanimator/chart v0.0.0-00010101000000-000000000000
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/go-analyze/bulk v0.1.3 // indirect
|
||||||
|
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
||||||
|
golang.org/x/image v0.24.0 // indirect
|
||||||
|
)
|
||||||
|
|||||||
18
audit/go.sum
Normal file
18
audit/go.sum
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/go-analyze/bulk v0.1.3 h1:pzRdBqzHDAT9PyROt0SlWE0YqPtdmTcEpIJY0C3vF0c=
|
||||||
|
github.com/go-analyze/bulk v0.1.3/go.mod h1:afon/KtFJYnekIyN20H/+XUvcLFjE8sKR1CfpqfClgM=
|
||||||
|
github.com/go-analyze/charts v0.5.26 h1:rSwZikLQuFX6cJzwI8OAgaWZneG1kDYxD857ms00ZxY=
|
||||||
|
github.com/go-analyze/charts v0.5.26/go.mod h1:s1YvQhjiSwtLx1f2dOKfiV9x2TT49nVSL6v2rlRpTbY=
|
||||||
|
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
|
||||||
|
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
|
||||||
|
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
@@ -57,6 +57,7 @@ type networkManager interface {
|
|||||||
|
|
||||||
type serviceManager interface {
|
type serviceManager interface {
|
||||||
ListBeeServices() ([]string, error)
|
ListBeeServices() ([]string, error)
|
||||||
|
ServiceState(name string) string
|
||||||
ServiceStatus(name string) (string, error)
|
ServiceStatus(name string) (string, error)
|
||||||
ServiceDo(name string, action platform.ServiceAction) (string, error)
|
ServiceDo(name string, action platform.ServiceAction) (string, error)
|
||||||
}
|
}
|
||||||
@@ -356,6 +357,10 @@ func (a *App) ListBeeServices() ([]string, error) {
|
|||||||
return a.services.ListBeeServices()
|
return a.services.ListBeeServices()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) ServiceState(name string) string {
|
||||||
|
return a.services.ServiceState(name)
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) ServiceStatus(name string) (string, error) {
|
func (a *App) ServiceStatus(name string) (string, error) {
|
||||||
return a.services.ServiceStatus(name)
|
return a.services.ServiceStatus(name)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,10 @@ func (f fakeServices) ListBeeServices() ([]string, error) {
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f fakeServices) ServiceState(name string) string {
|
||||||
|
return "active"
|
||||||
|
}
|
||||||
|
|
||||||
func (f fakeServices) ServiceStatus(name string) (string, error) {
|
func (f fakeServices) ServiceStatus(name string) (string, error) {
|
||||||
return f.serviceStatusFn(name)
|
return f.serviceStatusFn(name)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,18 +13,19 @@ import (
|
|||||||
|
|
||||||
// GPUMetricRow is one telemetry sample from nvidia-smi during a stress test.
|
// GPUMetricRow is one telemetry sample from nvidia-smi during a stress test.
|
||||||
type GPUMetricRow struct {
|
type GPUMetricRow struct {
|
||||||
ElapsedSec float64
|
ElapsedSec float64 `json:"elapsed_sec"`
|
||||||
GPUIndex int
|
GPUIndex int `json:"index"`
|
||||||
TempC float64
|
TempC float64 `json:"temp_c"`
|
||||||
UsagePct float64
|
UsagePct float64 `json:"usage_pct"`
|
||||||
PowerW float64
|
MemUsagePct float64 `json:"mem_usage_pct"`
|
||||||
ClockMHz float64
|
PowerW float64 `json:"power_w"`
|
||||||
|
ClockMHz float64 `json:"clock_mhz"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// sampleGPUMetrics runs nvidia-smi once and returns current metrics for each GPU.
|
// sampleGPUMetrics runs nvidia-smi once and returns current metrics for each GPU.
|
||||||
func sampleGPUMetrics(gpuIndices []int) ([]GPUMetricRow, error) {
|
func sampleGPUMetrics(gpuIndices []int) ([]GPUMetricRow, error) {
|
||||||
args := []string{
|
args := []string{
|
||||||
"--query-gpu=index,temperature.gpu,utilization.gpu,power.draw,clocks.current.graphics",
|
"--query-gpu=index,temperature.gpu,utilization.gpu,utilization.memory,power.draw,clocks.current.graphics",
|
||||||
"--format=csv,noheader,nounits",
|
"--format=csv,noheader,nounits",
|
||||||
}
|
}
|
||||||
if len(gpuIndices) > 0 {
|
if len(gpuIndices) > 0 {
|
||||||
@@ -45,16 +46,17 @@ func sampleGPUMetrics(gpuIndices []int) ([]GPUMetricRow, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
parts := strings.Split(line, ", ")
|
parts := strings.Split(line, ", ")
|
||||||
if len(parts) < 5 {
|
if len(parts) < 6 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
idx, _ := strconv.Atoi(strings.TrimSpace(parts[0]))
|
idx, _ := strconv.Atoi(strings.TrimSpace(parts[0]))
|
||||||
rows = append(rows, GPUMetricRow{
|
rows = append(rows, GPUMetricRow{
|
||||||
GPUIndex: idx,
|
GPUIndex: idx,
|
||||||
TempC: parseGPUFloat(parts[1]),
|
TempC: parseGPUFloat(parts[1]),
|
||||||
UsagePct: parseGPUFloat(parts[2]),
|
UsagePct: parseGPUFloat(parts[2]),
|
||||||
PowerW: parseGPUFloat(parts[3]),
|
MemUsagePct: parseGPUFloat(parts[3]),
|
||||||
ClockMHz: parseGPUFloat(parts[4]),
|
PowerW: parseGPUFloat(parts[4]),
|
||||||
|
ClockMHz: parseGPUFloat(parts[5]),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return rows, nil
|
return rows, nil
|
||||||
|
|||||||
@@ -1,15 +1,23 @@
|
|||||||
package platform
|
package platform
|
||||||
|
|
||||||
import "time"
|
import (
|
||||||
|
"bufio"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
// LiveMetricSample is a single point-in-time snapshot of server metrics
|
// LiveMetricSample is a single point-in-time snapshot of server metrics
|
||||||
// collected for the web UI metrics page.
|
// collected for the web UI metrics page.
|
||||||
type LiveMetricSample struct {
|
type LiveMetricSample struct {
|
||||||
Timestamp time.Time `json:"ts"`
|
Timestamp time.Time `json:"ts"`
|
||||||
Fans []FanReading `json:"fans"`
|
Fans []FanReading `json:"fans"`
|
||||||
Temps []TempReading `json:"temps"`
|
Temps []TempReading `json:"temps"`
|
||||||
PowerW float64 `json:"power_w"`
|
PowerW float64 `json:"power_w"`
|
||||||
GPUs []GPUMetricRow `json:"gpus"`
|
CPULoadPct float64 `json:"cpu_load_pct"`
|
||||||
|
MemLoadPct float64 `json:"mem_load_pct"`
|
||||||
|
GPUs []GPUMetricRow `json:"gpus"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TempReading is a named temperature sensor value.
|
// TempReading is a named temperature sensor value.
|
||||||
@@ -41,5 +49,91 @@ func SampleLiveMetrics() LiveMetricSample {
|
|||||||
// System power — returns 0 if unavailable
|
// System power — returns 0 if unavailable
|
||||||
s.PowerW = sampleSystemPower()
|
s.PowerW = sampleSystemPower()
|
||||||
|
|
||||||
|
// CPU load — from /proc/stat
|
||||||
|
s.CPULoadPct = sampleCPULoadPct()
|
||||||
|
|
||||||
|
// Memory load — from /proc/meminfo
|
||||||
|
s.MemLoadPct = sampleMemLoadPct()
|
||||||
|
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sampleCPULoadPct reads two /proc/stat snapshots 200ms apart and returns
|
||||||
|
// the overall CPU utilisation percentage.
|
||||||
|
var cpuStatPrev [2]uint64 // [total, idle]
|
||||||
|
|
||||||
|
func sampleCPULoadPct() float64 {
|
||||||
|
total, idle := readCPUStat()
|
||||||
|
if total == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
prevTotal, prevIdle := cpuStatPrev[0], cpuStatPrev[1]
|
||||||
|
cpuStatPrev = [2]uint64{total, idle}
|
||||||
|
if prevTotal == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
dt := float64(total - prevTotal)
|
||||||
|
di := float64(idle - prevIdle)
|
||||||
|
if dt <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
pct := (1 - di/dt) * 100
|
||||||
|
if pct < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if pct > 100 {
|
||||||
|
return 100
|
||||||
|
}
|
||||||
|
return pct
|
||||||
|
}
|
||||||
|
|
||||||
|
func readCPUStat() (total, idle uint64) {
|
||||||
|
f, err := os.Open("/proc/stat")
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
sc := bufio.NewScanner(f)
|
||||||
|
for sc.Scan() {
|
||||||
|
line := sc.Text()
|
||||||
|
if !strings.HasPrefix(line, "cpu ") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fields := strings.Fields(line)[1:] // skip "cpu"
|
||||||
|
var vals [10]uint64
|
||||||
|
for i := 0; i < len(fields) && i < 10; i++ {
|
||||||
|
vals[i], _ = strconv.ParseUint(fields[i], 10, 64)
|
||||||
|
}
|
||||||
|
// idle = idle + iowait
|
||||||
|
idle = vals[3] + vals[4]
|
||||||
|
for _, v := range vals {
|
||||||
|
total += v
|
||||||
|
}
|
||||||
|
return total, idle
|
||||||
|
}
|
||||||
|
return 0, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func sampleMemLoadPct() float64 {
|
||||||
|
f, err := os.Open("/proc/meminfo")
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
vals := map[string]uint64{}
|
||||||
|
sc := bufio.NewScanner(f)
|
||||||
|
for sc.Scan() {
|
||||||
|
fields := strings.Fields(sc.Text())
|
||||||
|
if len(fields) >= 2 {
|
||||||
|
v, _ := strconv.ParseUint(fields[1], 10, 64)
|
||||||
|
vals[strings.TrimSuffix(fields[0], ":")] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
total := vals["MemTotal"]
|
||||||
|
avail := vals["MemAvailable"]
|
||||||
|
if total == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
used := total - avail
|
||||||
|
return float64(used) / float64(total) * 100
|
||||||
|
}
|
||||||
|
|||||||
@@ -236,13 +236,15 @@ func (h *handler) handleAPIServicesList(w http.ResponseWriter, r *http.Request)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
type serviceInfo struct {
|
type serviceInfo struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Status string `json:"status"`
|
State string `json:"state"`
|
||||||
|
Body string `json:"body"`
|
||||||
}
|
}
|
||||||
result := make([]serviceInfo, 0, len(names))
|
result := make([]serviceInfo, 0, len(names))
|
||||||
for _, name := range names {
|
for _, name := range names {
|
||||||
status, _ := h.opts.App.ServiceStatus(name)
|
state := h.opts.App.ServiceState(name)
|
||||||
result = append(result, serviceInfo{Name: name, Status: status})
|
body, _ := h.opts.App.ServiceStatus(name)
|
||||||
|
result = append(result, serviceInfo{Name: name, State: state, Body: body})
|
||||||
}
|
}
|
||||||
writeJSON(w, result)
|
writeJSON(w, result)
|
||||||
}
|
}
|
||||||
@@ -421,6 +423,45 @@ func (h *handler) handleAPIMetricsStream(w http.ResponseWriter, r *http.Request)
|
|||||||
return
|
return
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
sample := platform.SampleLiveMetrics()
|
sample := platform.SampleLiveMetrics()
|
||||||
|
|
||||||
|
// Feed server ring buffers
|
||||||
|
for _, t := range sample.Temps {
|
||||||
|
if t.Name == "CPU" {
|
||||||
|
h.ringCPUTemp.push(t.Celsius)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.ringPower.push(sample.PowerW)
|
||||||
|
h.ringCPULoad.push(sample.CPULoadPct)
|
||||||
|
h.ringMemLoad.push(sample.MemLoadPct)
|
||||||
|
|
||||||
|
// Feed fan ring buffers (grow on first sight)
|
||||||
|
h.ringsMu.Lock()
|
||||||
|
for i, fan := range sample.Fans {
|
||||||
|
for len(h.ringFans) <= i {
|
||||||
|
h.ringFans = append(h.ringFans, newMetricsRing(120))
|
||||||
|
h.fanNames = append(h.fanNames, fan.Name)
|
||||||
|
}
|
||||||
|
h.ringFans[i].push(float64(fan.RPM))
|
||||||
|
}
|
||||||
|
// Feed per-GPU ring buffers (grow on first sight)
|
||||||
|
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()
|
||||||
|
|
||||||
b, err := json.Marshal(sample)
|
b, err := json.Marshal(sample)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -66,9 +66,6 @@ tr:hover td{background:#1a2030}
|
|||||||
.form-row label{display:block;font-size:12px;color:#64748b;margin-bottom:5px}
|
.form-row label{display:block;font-size:12px;color:#64748b;margin-bottom:5px}
|
||||||
.form-row input,.form-row select{width:100%;padding:8px 10px;background:#0f1117;border:1px solid #252d3d;border-radius:6px;color:#e2e8f0;font-size:13px;outline:none}
|
.form-row input,.form-row select{width:100%;padding:8px 10px;background:#0f1117;border:1px solid #252d3d;border-radius:6px;color:#e2e8f0;font-size:13px;outline:none}
|
||||||
.form-row input:focus,.form-row select:focus{border-color:#3b82f6}
|
.form-row input:focus,.form-row select:focus{border-color:#3b82f6}
|
||||||
/* Metrics chart */
|
|
||||||
.chart-wrap{position:relative;height:140px;background:#0a0d14;border-radius:8px;overflow:hidden;margin-bottom:8px}
|
|
||||||
canvas.chart{width:100%;height:100%}
|
|
||||||
.chart-legend{font-size:11px;color:#64748b;padding:4px 0}
|
.chart-legend{font-size:11px;color:#64748b;padding:4px 0}
|
||||||
/* Grid */
|
/* Grid */
|
||||||
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:16px}
|
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:16px}
|
||||||
@@ -88,18 +85,18 @@ canvas.chart{width:100%;height:100%}
|
|||||||
|
|
||||||
func layoutNav(active string) string {
|
func layoutNav(active string) string {
|
||||||
items := []struct{ id, icon, label string }{
|
items := []struct{ id, icon, label string }{
|
||||||
{"dashboard", "📊", "Dashboard"},
|
{"dashboard", "", "Dashboard"},
|
||||||
{"metrics", "📈", "Metrics"},
|
{"metrics", "", "Metrics"},
|
||||||
{"tests", "🧪", "Acceptance Tests"},
|
{"tests", "", "Acceptance Tests"},
|
||||||
{"burn-in", "🔥", "Burn-in"},
|
{"burn-in", "", "Burn-in"},
|
||||||
{"network", "🌐", "Network"},
|
{"network", "", "Network"},
|
||||||
{"services", "⚙️", "Services"},
|
{"services", "", "Services"},
|
||||||
{"export", "📦", "Export"},
|
{"export", "", "Export"},
|
||||||
{"tools", "🔧", "Tools"},
|
{"tools", "", "Tools"},
|
||||||
}
|
}
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString(`<aside class="sidebar">`)
|
b.WriteString(`<aside class="sidebar">`)
|
||||||
b.WriteString(`<div class="sidebar-logo">🐝 bee<span>hardware audit</span></div>`)
|
b.WriteString(`<div class="sidebar-logo">bee<span>hardware audit</span></div>`)
|
||||||
b.WriteString(`<nav class="nav">`)
|
b.WriteString(`<nav class="nav">`)
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
cls := "nav-item"
|
cls := "nav-item"
|
||||||
@@ -110,8 +107,8 @@ func layoutNav(active string) string {
|
|||||||
if item.id != "dashboard" {
|
if item.id != "dashboard" {
|
||||||
href = "/" + item.id
|
href = "/" + item.id
|
||||||
}
|
}
|
||||||
b.WriteString(fmt.Sprintf(`<a class="%s" href="%s"><span class="nav-icon">%s</span>%s</a>`,
|
b.WriteString(fmt.Sprintf(`<a class="%s" href="%s">%s</a>`,
|
||||||
cls, href, item.icon, item.label))
|
cls, href, item.label))
|
||||||
}
|
}
|
||||||
b.WriteString(`</nav></aside>`)
|
b.WriteString(`</nav></aside>`)
|
||||||
return b.String()
|
return b.String()
|
||||||
@@ -245,120 +242,73 @@ func renderHealthCard(opts HandlerOptions) string {
|
|||||||
// ── Metrics ───────────────────────────────────────────────────────────────────
|
// ── Metrics ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func renderMetrics() string {
|
func renderMetrics() string {
|
||||||
return `<p style="color:#64748b;font-size:13px;margin-bottom:16px">Live server metrics updated every second.</p>
|
return `<p style="color:#64748b;font-size:13px;margin-bottom:16px">Live metrics — updated every 2 seconds. Charts use go-analyze/charts (grafana theme).</p>
|
||||||
<div class="grid2">
|
|
||||||
<div class="card">
|
<div class="card" style="margin-bottom:16px">
|
||||||
<div class="card-head">GPU Metrics</div>
|
<div class="card-head">Server</div>
|
||||||
<div class="card-body">
|
<div class="card-body" style="padding:8px">
|
||||||
<div class="chart-wrap"><canvas id="chart-gpu-temp" class="chart"></canvas></div>
|
<img id="chart-server" src="/api/metrics/chart/server.svg" style="width:100%;display:block;border-radius:6px" alt="Server metrics">
|
||||||
<div class="chart-legend">Temperature °C</div>
|
<div id="sys-table" style="margin-top:8px;font-size:12px"></div>
|
||||||
<div class="chart-wrap"><canvas id="chart-gpu-usage" class="chart"></canvas></div>
|
|
||||||
<div class="chart-legend">Usage %</div>
|
|
||||||
<div class="chart-wrap"><canvas id="chart-gpu-power" class="chart"></canvas></div>
|
|
||||||
<div class="chart-legend">Power W</div>
|
|
||||||
<div id="gpu-table"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-head">System Metrics</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="chart-wrap"><canvas id="chart-cpu-temp" class="chart"></canvas></div>
|
|
||||||
<div class="chart-legend">CPU Temperature °C</div>
|
|
||||||
<div class="chart-wrap"><canvas id="chart-fans" class="chart"></canvas></div>
|
|
||||||
<div class="chart-legend">Fan Speed RPM</div>
|
|
||||||
<div class="chart-wrap"><canvas id="chart-power" class="chart"></canvas></div>
|
|
||||||
<div class="chart-legend">System Power W</div>
|
|
||||||
<div id="sys-table"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="gpu-charts"></div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const WINDOW = 120;
|
let knownGPUs = [];
|
||||||
const history = {gpuTemp:[],gpuUsage:[],gpuPower:[],cpuTemp:[],fans:[],power:[]};
|
|
||||||
const colors = ['#60a5fa','#34d399','#f87171','#fbbf24','#a78bfa','#fb7185'];
|
|
||||||
|
|
||||||
function push(arr, val) { arr.push(val); if (arr.length > WINDOW) arr.shift(); }
|
function refreshCharts() {
|
||||||
|
const t = '?t=' + Date.now();
|
||||||
function drawChart(canvasId, datasets, maxY) {
|
const srv = document.getElementById('chart-server');
|
||||||
const c = document.getElementById(canvasId);
|
if (srv) srv.src = srv.src.split('?')[0] + t;
|
||||||
if (!c) return;
|
knownGPUs.forEach(idx => {
|
||||||
const W = c.offsetWidth, H = c.offsetHeight;
|
const el = document.getElementById('chart-gpu-' + idx);
|
||||||
c.width = W; c.height = H;
|
if (el) el.src = el.src.split('?')[0] + t;
|
||||||
const ctx = c.getContext('2d');
|
|
||||||
ctx.clearRect(0,0,W,H);
|
|
||||||
ctx.fillStyle = '#0a0d14';
|
|
||||||
ctx.fillRect(0,0,W,H);
|
|
||||||
// grid
|
|
||||||
ctx.strokeStyle = '#1e2535';
|
|
||||||
ctx.lineWidth = 1;
|
|
||||||
for (let y = 0; y <= 4; y++) {
|
|
||||||
const py = H * y / 4;
|
|
||||||
ctx.beginPath(); ctx.moveTo(0,py); ctx.lineTo(W,py); ctx.stroke();
|
|
||||||
}
|
|
||||||
const max = maxY || datasets.reduce((m,d) => Math.max(m,...d.map(v=>v||0)), 1) || 1;
|
|
||||||
datasets.forEach((data, i) => {
|
|
||||||
if (!data.length) return;
|
|
||||||
ctx.strokeStyle = colors[i % colors.length];
|
|
||||||
ctx.lineWidth = 1.5;
|
|
||||||
ctx.beginPath();
|
|
||||||
data.forEach((v, j) => {
|
|
||||||
const x = W * j / Math.max(data.length - 1, 1);
|
|
||||||
const y = H - H * (v || 0) / max;
|
|
||||||
j === 0 ? ctx.moveTo(x,y) : ctx.lineTo(x,y);
|
|
||||||
});
|
|
||||||
ctx.stroke();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
setInterval(refreshCharts, 2000);
|
||||||
|
|
||||||
const es = new EventSource('/api/metrics/stream');
|
const es = new EventSource('/api/metrics/stream');
|
||||||
es.addEventListener('metrics', e => {
|
es.addEventListener('metrics', e => {
|
||||||
const d = JSON.parse(e.data);
|
const d = JSON.parse(e.data);
|
||||||
|
|
||||||
// GPU
|
// Add GPU chart cards as GPUs appear
|
||||||
const gpuTemps = (d.gpus||[]).map(g => g.temp_c||0);
|
(d.gpus||[]).forEach(g => {
|
||||||
const gpuUsages = (d.gpus||[]).map(g => g.usage_pct||0);
|
if (knownGPUs.includes(g.index)) return;
|
||||||
const gpuPowers = (d.gpus||[]).map(g => g.power_w||0);
|
knownGPUs.push(g.index);
|
||||||
if (!history.gpuTemp.length && gpuTemps.length) {
|
const div = document.createElement('div');
|
||||||
history.gpuTemp = gpuTemps.map(() => []);
|
div.className = 'card';
|
||||||
history.gpuUsage = gpuUsages.map(() => []);
|
div.style.marginBottom = '16px';
|
||||||
history.gpuPower = gpuPowers.map(() => []);
|
div.innerHTML = '<div class="card-head">GPU ' + g.index + '</div>' +
|
||||||
}
|
'<div class="card-body" style="padding:8px">' +
|
||||||
gpuTemps.forEach((v,i) => push(history.gpuTemp[i]||[], v));
|
'<img id="chart-gpu-' + g.index + '" src="/api/metrics/chart/gpu/' + g.index + '.svg" style="width:100%;display:block;border-radius:6px" alt="GPU ' + g.index + '">' +
|
||||||
gpuUsages.forEach((v,i) => push(history.gpuUsage[i]||[], v));
|
'<div id="gpu-table-' + g.index + '" style="margin-top:8px;font-size:12px"></div>' +
|
||||||
gpuPowers.forEach((v,i) => push(history.gpuPower[i]||[], v));
|
'</div>';
|
||||||
drawChart('chart-gpu-temp', history.gpuTemp.length ? history.gpuTemp : [history.cpuTemp]);
|
document.getElementById('gpu-charts').appendChild(div);
|
||||||
drawChart('chart-gpu-usage', history.gpuUsage, 100);
|
});
|
||||||
drawChart('chart-gpu-power', history.gpuPower);
|
|
||||||
|
|
||||||
// GPU table
|
// Update numeric tables
|
||||||
const gpuRows = (d.gpus||[]).map(g =>
|
|
||||||
'<tr><td>GPU '+g.index+'</td><td>'+g.temp_c+'°C</td><td>'+g.usage_pct+'%</td><td>'+g.power_w+'W</td><td>'+g.clock_mhz+'MHz</td></tr>'
|
|
||||||
).join('');
|
|
||||||
document.getElementById('gpu-table').innerHTML = gpuRows ?
|
|
||||||
'<table><tr><th>GPU</th><th>Temp</th><th>Usage</th><th>Power</th><th>Clock</th></tr>'+gpuRows+'</table>' :
|
|
||||||
'<p style="color:#64748b;font-size:12px">No NVIDIA GPU detected</p>';
|
|
||||||
|
|
||||||
// System
|
|
||||||
const cpuTemp = (d.temps||[]).find(t => t.name==='CPU');
|
|
||||||
push(history.cpuTemp, cpuTemp ? cpuTemp.celsius : 0);
|
|
||||||
const fanRPMs = (d.fans||[]).map(f => f.rpm||0);
|
|
||||||
if (!history.fans.length && fanRPMs.length) history.fans = fanRPMs.map(() => []);
|
|
||||||
fanRPMs.forEach((v,i) => push(history.fans[i]||[], v));
|
|
||||||
push(history.power, d.power_w||0);
|
|
||||||
drawChart('chart-cpu-temp', [history.cpuTemp]);
|
|
||||||
drawChart('chart-fans', history.fans.length ? history.fans : [[]]);
|
|
||||||
drawChart('chart-power', [history.power]);
|
|
||||||
|
|
||||||
// Sys table
|
|
||||||
let sysHTML = '';
|
let sysHTML = '';
|
||||||
|
const cpuTemp = (d.temps||[]).find(t => t.name==='CPU');
|
||||||
if (cpuTemp) sysHTML += '<tr><td>CPU Temp</td><td>'+cpuTemp.celsius.toFixed(1)+'°C</td></tr>';
|
if (cpuTemp) sysHTML += '<tr><td>CPU Temp</td><td>'+cpuTemp.celsius.toFixed(1)+'°C</td></tr>';
|
||||||
|
if (d.cpu_load_pct) sysHTML += '<tr><td>CPU Load</td><td>'+d.cpu_load_pct.toFixed(1)+'%</td></tr>';
|
||||||
|
if (d.mem_load_pct) sysHTML += '<tr><td>Mem Load</td><td>'+d.mem_load_pct.toFixed(1)+'%</td></tr>';
|
||||||
(d.fans||[]).forEach(f => sysHTML += '<tr><td>'+f.name+'</td><td>'+f.rpm+' RPM</td></tr>');
|
(d.fans||[]).forEach(f => sysHTML += '<tr><td>'+f.name+'</td><td>'+f.rpm+' RPM</td></tr>');
|
||||||
if (d.power_w) sysHTML += '<tr><td>System Power</td><td>'+d.power_w.toFixed(0)+'W</td></tr>';
|
if (d.power_w) sysHTML += '<tr><td>Power</td><td>'+d.power_w.toFixed(0)+' W</td></tr>';
|
||||||
document.getElementById('sys-table').innerHTML = sysHTML ?
|
const st = document.getElementById('sys-table');
|
||||||
'<table style="margin-top:8px">'+sysHTML+'</table>' :
|
if (st) st.innerHTML = sysHTML ? '<table>'+sysHTML+'</table>' : '<p style="color:#64748b">No sensor data (ipmitool/sensors required)</p>';
|
||||||
'<p style="color:#64748b;font-size:12px">No sensor data (requires ipmitool/sensors)</p>';
|
|
||||||
|
(d.gpus||[]).forEach(g => {
|
||||||
|
const t = document.getElementById('gpu-table-' + g.index);
|
||||||
|
if (!t) return;
|
||||||
|
t.innerHTML = '<table>' +
|
||||||
|
'<tr><td>Temp</td><td>'+g.temp_c+'°C</td>' +
|
||||||
|
'<td>Load</td><td>'+g.usage_pct+'%</td>' +
|
||||||
|
'<td>Mem</td><td>'+g.mem_usage_pct+'%</td>' +
|
||||||
|
'<td>Power</td><td>'+g.power_w+' W</td></tr></table>';
|
||||||
|
});
|
||||||
});
|
});
|
||||||
es.onerror = () => { /* reconnect automatically */ };
|
es.onerror = () => {};
|
||||||
</script>`
|
</script>`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -515,9 +465,16 @@ func renderServices() string {
|
|||||||
function loadServices() {
|
function loadServices() {
|
||||||
fetch('/api/services').then(r=>r.json()).then(svcs => {
|
fetch('/api/services').then(r=>r.json()).then(svcs => {
|
||||||
const rows = svcs.map(s => {
|
const rows = svcs.map(s => {
|
||||||
const st = s.status||'unknown';
|
const st = s.state||'unknown';
|
||||||
const badge = st.includes('active') ? 'badge-ok' : st.includes('failed') ? 'badge-err' : 'badge-warn';
|
const badge = st==='active' ? 'badge-ok' : st==='failed' ? 'badge-err' : 'badge-warn';
|
||||||
return '<tr><td>'+s.name+'</td><td><span class="badge '+badge+'">'+st+'</span></td><td>' +
|
const id = 'svc-body-'+s.name.replace(/[^a-z0-9]/g,'-');
|
||||||
|
const body = (s.body||'').replace(/</g,'<').replace(/>/g,'>');
|
||||||
|
return '<tr>' +
|
||||||
|
'<td style="white-space:nowrap">'+s.name+'</td>' +
|
||||||
|
'<td style="white-space:nowrap"><span class="badge '+badge+'" style="cursor:pointer" onclick="toggleBody(\''+id+'\')">'+st+' ▾</span>' +
|
||||||
|
'<div id="'+id+'" style="display:none;margin-top:6px"><pre style="font-size:11px;white-space:pre-wrap;word-break:break-all;max-height:200px;overflow-y:auto;background:#0a0d14;padding:8px;border-radius:6px;color:#94a3b8">'+body+'</pre></div>' +
|
||||||
|
'</td>' +
|
||||||
|
'<td style="white-space:nowrap">' +
|
||||||
'<button class="btn btn-sm btn-secondary" onclick="svcAction(\''+s.name+'\',\'start\')">Start</button> ' +
|
'<button class="btn btn-sm btn-secondary" onclick="svcAction(\''+s.name+'\',\'start\')">Start</button> ' +
|
||||||
'<button class="btn btn-sm btn-secondary" onclick="svcAction(\''+s.name+'\',\'stop\')">Stop</button> ' +
|
'<button class="btn btn-sm btn-secondary" onclick="svcAction(\''+s.name+'\',\'stop\')">Stop</button> ' +
|
||||||
'<button class="btn btn-sm btn-secondary" onclick="svcAction(\''+s.name+'\',\'restart\')">Restart</button>' +
|
'<button class="btn btn-sm btn-secondary" onclick="svcAction(\''+s.name+'\',\'restart\')">Restart</button>' +
|
||||||
@@ -527,6 +484,10 @@ function loadServices() {
|
|||||||
'<table><tr><th>Service</th><th>Status</th><th>Actions</th></tr>'+rows+'</table>';
|
'<table><tr><th>Service</th><th>Status</th><th>Actions</th></tr>'+rows+'</table>';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
function toggleBody(id) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) el.style.display = el.style.display==='none' ? 'block' : 'none';
|
||||||
|
}
|
||||||
function svcAction(name, action) {
|
function svcAction(name, action) {
|
||||||
fetch('/api/services/action',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name,action})})
|
fetch('/api/services/action',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name,action})})
|
||||||
.then(r=>r.json()).then(d => {
|
.then(r=>r.json()).then(d => {
|
||||||
|
|||||||
@@ -8,9 +8,14 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"bee/audit/internal/app"
|
"bee/audit/internal/app"
|
||||||
"bee/audit/internal/runtimeenv"
|
"bee/audit/internal/runtimeenv"
|
||||||
|
gocharts "github.com/go-analyze/charts"
|
||||||
|
"reanimator/chart/viewer"
|
||||||
|
"reanimator/chart/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
const defaultTitle = "Bee Hardware Audit"
|
const defaultTitle = "Bee Hardware Audit"
|
||||||
@@ -24,10 +29,61 @@ type HandlerOptions struct {
|
|||||||
RuntimeMode runtimeenv.Mode
|
RuntimeMode runtimeenv.Mode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// metricsRing holds a rolling window of live metric samples.
|
||||||
|
type metricsRing struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
vals []float64
|
||||||
|
labels []string
|
||||||
|
size int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMetricsRing(size int) *metricsRing {
|
||||||
|
return &metricsRing{size: size, vals: make([]float64, 0, size), labels: make([]string, 0, size)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *metricsRing) push(v float64) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if len(r.vals) >= r.size {
|
||||||
|
r.vals = r.vals[1:]
|
||||||
|
r.labels = r.labels[1:]
|
||||||
|
}
|
||||||
|
r.vals = append(r.vals, v)
|
||||||
|
r.labels = append(r.labels, time.Now().Format("15:04"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *metricsRing) snapshot() ([]float64, []string) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
v := make([]float64, len(r.vals))
|
||||||
|
l := make([]string, len(r.labels))
|
||||||
|
copy(v, r.vals)
|
||||||
|
copy(l, r.labels)
|
||||||
|
return v, l
|
||||||
|
}
|
||||||
|
|
||||||
|
// gpuRings holds per-GPU ring buffers.
|
||||||
|
type gpuRings struct {
|
||||||
|
Temp *metricsRing
|
||||||
|
Util *metricsRing
|
||||||
|
MemUtil *metricsRing
|
||||||
|
Power *metricsRing
|
||||||
|
}
|
||||||
|
|
||||||
// handler is the HTTP handler for the web UI.
|
// handler is the HTTP handler for the web UI.
|
||||||
type handler struct {
|
type handler struct {
|
||||||
opts HandlerOptions
|
opts HandlerOptions
|
||||||
mux *http.ServeMux
|
mux *http.ServeMux
|
||||||
|
// server rings
|
||||||
|
ringCPUTemp *metricsRing
|
||||||
|
ringCPULoad *metricsRing
|
||||||
|
ringMemLoad *metricsRing
|
||||||
|
ringPower *metricsRing
|
||||||
|
ringFans []*metricsRing
|
||||||
|
fanNames []string
|
||||||
|
// per-GPU rings (index = GPU index)
|
||||||
|
gpuRings []*gpuRings
|
||||||
|
ringsMu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHandler creates the HTTP mux with all routes.
|
// NewHandler creates the HTTP mux with all routes.
|
||||||
@@ -42,7 +98,13 @@ func NewHandler(opts HandlerOptions) http.Handler {
|
|||||||
opts.RuntimeMode = runtimeenv.ModeAuto
|
opts.RuntimeMode = runtimeenv.ModeAuto
|
||||||
}
|
}
|
||||||
|
|
||||||
h := &handler{opts: opts}
|
h := &handler{
|
||||||
|
opts: opts,
|
||||||
|
ringCPUTemp: newMetricsRing(120),
|
||||||
|
ringCPULoad: newMetricsRing(120),
|
||||||
|
ringMemLoad: newMetricsRing(120),
|
||||||
|
ringPower: newMetricsRing(120),
|
||||||
|
}
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
// ── Infrastructure ──────────────────────────────────────────────────────
|
// ── Infrastructure ──────────────────────────────────────────────────────
|
||||||
@@ -87,8 +149,12 @@ func NewHandler(opts HandlerOptions) http.Handler {
|
|||||||
// Preflight
|
// Preflight
|
||||||
mux.HandleFunc("GET /api/preflight", h.handleAPIPreflight)
|
mux.HandleFunc("GET /api/preflight", h.handleAPIPreflight)
|
||||||
|
|
||||||
// Metrics — SSE stream of live sensor data
|
// Metrics — SSE stream of live sensor data + server-side SVG charts
|
||||||
mux.HandleFunc("GET /api/metrics/stream", h.handleAPIMetricsStream)
|
mux.HandleFunc("GET /api/metrics/stream", h.handleAPIMetricsStream)
|
||||||
|
mux.HandleFunc("GET /api/metrics/chart/", h.handleMetricsChartSVG)
|
||||||
|
|
||||||
|
// Reanimator chart static assets
|
||||||
|
mux.Handle("GET /chart/static/", http.StripPrefix("/chart/static/", web.Static()))
|
||||||
|
|
||||||
// ── Pages ────────────────────────────────────────────────────────────────
|
// ── Pages ────────────────────────────────────────────────────────────────
|
||||||
mux.HandleFunc("GET /", h.handlePage)
|
mux.HandleFunc("GET /", h.handlePage)
|
||||||
@@ -181,10 +247,133 @@ func (h *handler) handleExportIndex(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func (h *handler) handleViewer(w http.ResponseWriter, r *http.Request) {
|
func (h *handler) handleViewer(w http.ResponseWriter, r *http.Request) {
|
||||||
snapshot, _ := loadSnapshot(h.opts.AuditPath)
|
snapshot, _ := loadSnapshot(h.opts.AuditPath)
|
||||||
body := renderViewerPage(h.opts.Title, snapshot)
|
body, err := viewer.RenderHTML(snapshot, h.opts.Title)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
_, _ = w.Write([]byte(body))
|
_, _ = w.Write(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handler) handleMetricsChartSVG(w http.ResponseWriter, r *http.Request) {
|
||||||
|
path := strings.TrimPrefix(r.URL.Path, "/api/metrics/chart/")
|
||||||
|
path = strings.TrimSuffix(path, ".svg")
|
||||||
|
|
||||||
|
var datasets [][]float64
|
||||||
|
var names []string
|
||||||
|
var labels []string
|
||||||
|
var title string
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case path == "server":
|
||||||
|
title = "Server"
|
||||||
|
vCPUTemp, l := h.ringCPUTemp.snapshot()
|
||||||
|
vCPULoad, _ := h.ringCPULoad.snapshot()
|
||||||
|
vMemLoad, _ := h.ringMemLoad.snapshot()
|
||||||
|
vPower, _ := h.ringPower.snapshot()
|
||||||
|
labels = l
|
||||||
|
datasets = [][]float64{vCPUTemp, vCPULoad, vMemLoad, vPower}
|
||||||
|
names = []string{"CPU Temp °C", "CPU Load %", "Mem Load %", "Power W"}
|
||||||
|
|
||||||
|
h.ringsMu.Lock()
|
||||||
|
for i, fr := range h.ringFans {
|
||||||
|
fv, _ := fr.snapshot()
|
||||||
|
datasets = append(datasets, fv)
|
||||||
|
name := "Fan"
|
||||||
|
if i < len(h.fanNames) {
|
||||||
|
name = h.fanNames[i]
|
||||||
|
}
|
||||||
|
names = append(names, name+" RPM")
|
||||||
|
}
|
||||||
|
h.ringsMu.Unlock()
|
||||||
|
|
||||||
|
case strings.HasPrefix(path, "gpu/"):
|
||||||
|
idxStr := strings.TrimPrefix(path, "gpu/")
|
||||||
|
idx := 0
|
||||||
|
fmt.Sscanf(idxStr, "%d", &idx)
|
||||||
|
h.ringsMu.Lock()
|
||||||
|
var gr *gpuRings
|
||||||
|
if idx < len(h.gpuRings) {
|
||||||
|
gr = h.gpuRings[idx]
|
||||||
|
}
|
||||||
|
h.ringsMu.Unlock()
|
||||||
|
if gr == nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
vTemp, l := gr.Temp.snapshot()
|
||||||
|
vUtil, _ := gr.Util.snapshot()
|
||||||
|
vMemUtil, _ := gr.MemUtil.snapshot()
|
||||||
|
vPower, _ := gr.Power.snapshot()
|
||||||
|
labels = l
|
||||||
|
title = fmt.Sprintf("GPU %d", idx)
|
||||||
|
datasets = [][]float64{vTemp, vUtil, vMemUtil, vPower}
|
||||||
|
names = []string{"Temp °C", "Load %", "Mem %", "Power W"}
|
||||||
|
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure all datasets same length as labels
|
||||||
|
n := len(labels)
|
||||||
|
if n == 0 {
|
||||||
|
n = 1
|
||||||
|
labels = []string{""}
|
||||||
|
}
|
||||||
|
for i := range datasets {
|
||||||
|
if len(datasets[i]) == 0 {
|
||||||
|
datasets[i] = make([]float64, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sparse := sparseLabels(labels, 6)
|
||||||
|
|
||||||
|
opt := gocharts.NewLineChartOptionWithData(datasets)
|
||||||
|
opt.Title = gocharts.TitleOption{Text: title}
|
||||||
|
opt.XAxis.Labels = sparse
|
||||||
|
opt.Legend = gocharts.LegendOption{SeriesNames: names}
|
||||||
|
|
||||||
|
p := gocharts.NewPainter(gocharts.PainterOptions{
|
||||||
|
OutputFormat: gocharts.ChartOutputSVG,
|
||||||
|
Width: 1400,
|
||||||
|
Height: 280,
|
||||||
|
}, gocharts.PainterThemeOption(gocharts.GetTheme("grafana")))
|
||||||
|
if err := p.LineChart(opt); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
buf, err := p.Bytes()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "image/svg+xml")
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
_, _ = w.Write(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
func safeIdx(s []float64, i int) float64 {
|
||||||
|
if i < len(s) {
|
||||||
|
return s[i]
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func sparseLabels(labels []string, n int) []string {
|
||||||
|
out := make([]string, len(labels))
|
||||||
|
step := len(labels) / n
|
||||||
|
if step < 1 {
|
||||||
|
step = 1
|
||||||
|
}
|
||||||
|
for i, l := range labels {
|
||||||
|
if i%step == 0 {
|
||||||
|
out[i] = l
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Page handler ─────────────────────────────────────────────────────────────
|
// ── Page handler ─────────────────────────────────────────────────────────────
|
||||||
|
|||||||
38
bible-local/architecture/charting.md
Normal file
38
bible-local/architecture/charting.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# Charting architecture
|
||||||
|
|
||||||
|
## Decision: one chart engine for all live metrics
|
||||||
|
|
||||||
|
**Engine:** `github.com/go-analyze/charts` (pure Go, no CGO, SVG output)
|
||||||
|
**Theme:** `grafana` (dark background, coloured lines)
|
||||||
|
|
||||||
|
All live metrics charts in the web UI are server-side SVG images served by Go
|
||||||
|
and polled by the browser every 2 seconds via `<img src="...?t=now">`.
|
||||||
|
There is no client-side canvas or JS chart library.
|
||||||
|
|
||||||
|
### Why go-analyze/charts
|
||||||
|
|
||||||
|
- Pure Go, no CGO — builds cleanly inside the live-build container
|
||||||
|
- SVG output — crisp at any display resolution, full-width without pixelation
|
||||||
|
- Grafana theme matches the dark web UI colour scheme
|
||||||
|
- Active fork of the archived wcharczuk/go-chart
|
||||||
|
|
||||||
|
### SAT stress-test charts
|
||||||
|
|
||||||
|
The `drawGPUChartSVG` function in `platform/gpu_metrics.go` is a separate
|
||||||
|
self-contained SVG renderer used **only** for completed SAT run reports
|
||||||
|
(HTML export, burn-in summaries). It is not used for live metrics.
|
||||||
|
|
||||||
|
### Live metrics chart endpoints
|
||||||
|
|
||||||
|
| Path | Content |
|
||||||
|
|------|---------|
|
||||||
|
| `GET /api/metrics/chart/server.svg` | CPU temp, CPU load %, mem load %, power W, fan RPMs |
|
||||||
|
| `GET /api/metrics/chart/gpu/{idx}.svg` | GPU temp °C, load %, mem %, power W |
|
||||||
|
|
||||||
|
Charts are 1400 × 280 px SVG. The page renders them at `width: 100%` in a
|
||||||
|
single-column layout so they always fill the viewport width.
|
||||||
|
|
||||||
|
### Ring buffers
|
||||||
|
|
||||||
|
Each metric is stored in a 120-sample ring buffer (2 minutes of history at 1 Hz).
|
||||||
|
Buffers are per-server or per-GPU and grow dynamically as new GPUs appear.
|
||||||
@@ -32,6 +32,6 @@ lb config noauto \
|
|||||||
--memtest none \
|
--memtest none \
|
||||||
--iso-volume "EASY-BEE" \
|
--iso-volume "EASY-BEE" \
|
||||||
--iso-application "EASY-BEE" \
|
--iso-application "EASY-BEE" \
|
||||||
--bootappend-live "boot=live components quiet nomodeset console=tty0 console=ttyS0,115200n8 loglevel=3 username=bee user-fullname=Bee modprobe.blacklist=nouveau" \
|
--bootappend-live "boot=live components quiet nomodeset video=1920x1080 console=tty0 console=ttyS0,115200n8 loglevel=3 username=bee user-fullname=Bee modprobe.blacklist=nouveau" \
|
||||||
--apt-recommends false \
|
--apt-recommends false \
|
||||||
"${@}"
|
"${@}"
|
||||||
|
|||||||
@@ -84,6 +84,15 @@ resolve_iso_version() {
|
|||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
# Also accept plain v* tags (e.g. v2, v2.1 used for GUI releases)
|
||||||
|
tag="$(git -C "${REPO_ROOT}" describe --tags --match 'v*' --abbrev=7 --dirty 2>/dev/null || true)"
|
||||||
|
case "${tag}" in
|
||||||
|
v*)
|
||||||
|
echo "${tag#v}"
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
# Fall back to audit version so the name is still meaningful
|
# Fall back to audit version so the name is still meaningful
|
||||||
resolve_audit_version
|
resolve_audit_version
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if loadfont $font ; then
|
if loadfont $font ; then
|
||||||
set gfxmode=800x600
|
set gfxmode=1920x1080,1280x1024,auto
|
||||||
set gfxpayload=keep
|
set gfxpayload=keep
|
||||||
insmod efi_gop
|
insmod efi_gop
|
||||||
insmod efi_uga
|
insmod efi_uga
|
||||||
|
|||||||
@@ -4,7 +4,19 @@ Section "Device"
|
|||||||
Option "fbdev" "/dev/fb0"
|
Option "fbdev" "/dev/fb0"
|
||||||
EndSection
|
EndSection
|
||||||
|
|
||||||
|
Section "Monitor"
|
||||||
|
Identifier "monitor0"
|
||||||
|
Modeline "1920x1080" 148.50 1920 2008 2052 2200 1080 1084 1089 1125 +hsync +vsync
|
||||||
|
Option "PreferredMode" "1920x1080"
|
||||||
|
EndSection
|
||||||
|
|
||||||
Section "Screen"
|
Section "Screen"
|
||||||
Identifier "screen0"
|
Identifier "screen0"
|
||||||
Device "fbdev"
|
Device "fbdev"
|
||||||
|
Monitor "monitor0"
|
||||||
|
DefaultDepth 24
|
||||||
|
SubSection "Display"
|
||||||
|
Depth 24
|
||||||
|
Modes "1920x1080" "1280x1024" "1024x768"
|
||||||
|
EndSubSection
|
||||||
EndSection
|
EndSection
|
||||||
|
|||||||
Reference in New Issue
Block a user