Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9e55728053 | |||
| 4b8023c1cb | |||
| 4c8417d20a | |||
| 0755374dd2 | |||
| c70ae274fa | |||
| 23ad7ff534 | |||
| de130966f7 | |||
| c6fbfc8306 | |||
| 35ad1c74d9 | |||
| 4a02e74b17 | |||
| cd2853ad99 | |||
| 6caf771d6e | |||
| 14fa87b7d7 | |||
| 600ece911b | |||
| 2d424c63cb | |||
| 50f28d1ee6 | |||
| 3579747ae3 | |||
| 09dc7d2613 | |||
| ec0b7f7ff9 | |||
| e7a7ff54b9 | |||
| b4371e291e | |||
| c22b53a406 |
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 ./...
|
||||||
@@ -1023,3 +1023,62 @@ func (a *App) ListInstallDisks() ([]platform.InstallDisk, error) {
|
|||||||
func (a *App) InstallToDisk(ctx context.Context, device string, logFile string) error {
|
func (a *App) InstallToDisk(ctx context.Context, device string, logFile string) error {
|
||||||
return a.installer.InstallToDisk(ctx, device, logFile)
|
return a.installer.InstallToDisk(ctx, device, logFile)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func formatSATDetail(raw string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
kv := parseKeyValueSummary(raw)
|
||||||
|
|
||||||
|
if t, ok := kv["run_at_utc"]; ok {
|
||||||
|
fmt.Fprintf(&b, "Run: %s\n\n", t)
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := strings.Split(raw, "\n")
|
||||||
|
var stepKeys []string
|
||||||
|
seenStep := map[string]bool{}
|
||||||
|
for _, line := range lines {
|
||||||
|
if idx := strings.Index(line, "_status="); idx >= 0 {
|
||||||
|
key := line[:idx]
|
||||||
|
if !seenStep[key] && key != "overall" {
|
||||||
|
seenStep[key] = true
|
||||||
|
stepKeys = append(stepKeys, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, key := range stepKeys {
|
||||||
|
status := kv[key+"_status"]
|
||||||
|
display := cleanSummaryKey(key)
|
||||||
|
switch status {
|
||||||
|
case "OK":
|
||||||
|
fmt.Fprintf(&b, "PASS %s\n", display)
|
||||||
|
case "FAILED":
|
||||||
|
fmt.Fprintf(&b, "FAIL %s\n", display)
|
||||||
|
case "UNSUPPORTED":
|
||||||
|
fmt.Fprintf(&b, "SKIP %s\n", display)
|
||||||
|
default:
|
||||||
|
fmt.Fprintf(&b, "? %s\n", display)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if overall, ok := kv["overall_status"]; ok {
|
||||||
|
ok2 := kv["job_ok"]
|
||||||
|
failed := kv["job_failed"]
|
||||||
|
fmt.Fprintf(&b, "\nOverall: %s (ok=%s failed=%s)", overall, ok2, failed)
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimSpace(b.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanSummaryKey(key string) string {
|
||||||
|
idx := strings.Index(key, "-")
|
||||||
|
if idx <= 0 {
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
prefix := key[:idx]
|
||||||
|
for _, c := range prefix {
|
||||||
|
if c < '0' || c > '9' {
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return key[idx+1:]
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,387 +0,0 @@
|
|||||||
package app
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"bee/audit/internal/schema"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ComponentRow is one line in the hardware panel.
|
|
||||||
type ComponentRow struct {
|
|
||||||
Key string // "CPU", "MEM", "GPU", "DISK", "PSU"
|
|
||||||
Status string // "PASS", "FAIL", "CANCEL", "N/A"
|
|
||||||
Detail string // compact one-liner
|
|
||||||
}
|
|
||||||
|
|
||||||
// HardwarePanelData holds everything the TUI right panel needs.
|
|
||||||
type HardwarePanelData struct {
|
|
||||||
Header []string
|
|
||||||
Rows []ComponentRow
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoadHardwarePanel reads the latest audit JSON and SAT summaries.
|
|
||||||
// Returns empty panel if no audit data exists yet.
|
|
||||||
func (a *App) LoadHardwarePanel() HardwarePanelData {
|
|
||||||
raw, err := os.ReadFile(DefaultAuditJSONPath)
|
|
||||||
if err != nil {
|
|
||||||
return HardwarePanelData{Header: []string{"No audit data — run audit first."}}
|
|
||||||
}
|
|
||||||
var snap schema.HardwareIngestRequest
|
|
||||||
if err := json.Unmarshal(raw, &snap); err != nil {
|
|
||||||
return HardwarePanelData{Header: []string{"Audit data unreadable."}}
|
|
||||||
}
|
|
||||||
|
|
||||||
statuses := satStatuses()
|
|
||||||
|
|
||||||
var header []string
|
|
||||||
if sys := formatSystemLine(snap.Hardware.Board); sys != "" {
|
|
||||||
header = append(header, sys)
|
|
||||||
}
|
|
||||||
for _, fw := range snap.Hardware.Firmware {
|
|
||||||
if fw.DeviceName == "BIOS" && fw.Version != "" {
|
|
||||||
header = append(header, "BIOS: "+fw.Version)
|
|
||||||
}
|
|
||||||
if fw.DeviceName == "BMC" && fw.Version != "" {
|
|
||||||
header = append(header, "BMC: "+fw.Version)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ip := formatIPLine(a.network.ListInterfaces); ip != "" {
|
|
||||||
header = append(header, ip)
|
|
||||||
}
|
|
||||||
|
|
||||||
var rows []ComponentRow
|
|
||||||
|
|
||||||
if cpu := formatCPULine(snap.Hardware.CPUs); cpu != "" {
|
|
||||||
rows = append(rows, ComponentRow{
|
|
||||||
Key: "CPU",
|
|
||||||
Status: statuses["cpu"],
|
|
||||||
Detail: strings.TrimPrefix(cpu, "CPU: "),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if mem := formatMemoryLine(snap.Hardware.Memory); mem != "" {
|
|
||||||
rows = append(rows, ComponentRow{
|
|
||||||
Key: "MEM",
|
|
||||||
Status: statuses["memory"],
|
|
||||||
Detail: strings.TrimPrefix(mem, "Memory: "),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if gpu := formatGPULine(snap.Hardware.PCIeDevices); gpu != "" {
|
|
||||||
rows = append(rows, ComponentRow{
|
|
||||||
Key: "GPU",
|
|
||||||
Status: statuses["gpu"],
|
|
||||||
Detail: strings.TrimPrefix(gpu, "GPU: "),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if disk := formatStorageLine(snap.Hardware.Storage); disk != "" {
|
|
||||||
rows = append(rows, ComponentRow{
|
|
||||||
Key: "DISK",
|
|
||||||
Status: statuses["storage"],
|
|
||||||
Detail: strings.TrimPrefix(disk, "Storage: "),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if psu := formatPSULine(snap.Hardware.PowerSupplies); psu != "" {
|
|
||||||
rows = append(rows, ComponentRow{
|
|
||||||
Key: "PSU",
|
|
||||||
Status: "N/A",
|
|
||||||
Detail: psu,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return HardwarePanelData{Header: header, Rows: rows}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ComponentDetailResult returns detail text for a component shown in the panel.
|
|
||||||
func (a *App) ComponentDetailResult(key string) ActionResult {
|
|
||||||
switch key {
|
|
||||||
case "CPU":
|
|
||||||
return a.cpuDetailResult(false)
|
|
||||||
case "MEM":
|
|
||||||
return a.satDetailResult("memory", "memory-", "MEM detail")
|
|
||||||
case "GPU":
|
|
||||||
// Prefer whichever GPU SAT was run most recently.
|
|
||||||
nv, _ := filepath.Glob(filepath.Join(DefaultSATBaseDir, "gpu-nvidia-*/summary.txt"))
|
|
||||||
am, _ := filepath.Glob(filepath.Join(DefaultSATBaseDir, "gpu-amd-*/summary.txt"))
|
|
||||||
sort.Strings(nv)
|
|
||||||
sort.Strings(am)
|
|
||||||
latestNV := ""
|
|
||||||
if len(nv) > 0 {
|
|
||||||
latestNV = nv[len(nv)-1]
|
|
||||||
}
|
|
||||||
latestAM := ""
|
|
||||||
if len(am) > 0 {
|
|
||||||
latestAM = am[len(am)-1]
|
|
||||||
}
|
|
||||||
if latestAM > latestNV {
|
|
||||||
return a.satDetailResult("gpu", "gpu-amd-", "GPU detail")
|
|
||||||
}
|
|
||||||
return a.satDetailResult("gpu", "gpu-nvidia-", "GPU detail")
|
|
||||||
case "DISK":
|
|
||||||
return a.satDetailResult("storage", "storage-", "DISK detail")
|
|
||||||
case "PSU":
|
|
||||||
return a.psuDetailResult()
|
|
||||||
default:
|
|
||||||
return ActionResult{Title: key, Body: "No detail available."}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) cpuDetailResult(satOnly bool) ActionResult {
|
|
||||||
var b strings.Builder
|
|
||||||
|
|
||||||
// Show latest SAT summary if available.
|
|
||||||
satResult := a.satDetailResult("cpu", "cpu-", "CPU SAT")
|
|
||||||
if satResult.Body != "No test results found. Run a test first." {
|
|
||||||
fmt.Fprintln(&b, "=== Last SAT ===")
|
|
||||||
fmt.Fprintln(&b, satResult.Body)
|
|
||||||
fmt.Fprintln(&b)
|
|
||||||
}
|
|
||||||
|
|
||||||
if satOnly {
|
|
||||||
body := strings.TrimSpace(b.String())
|
|
||||||
if body == "" {
|
|
||||||
body = "No CPU SAT results found. Run a test first."
|
|
||||||
}
|
|
||||||
return ActionResult{Title: "CPU SAT", Body: body}
|
|
||||||
}
|
|
||||||
|
|
||||||
raw, err := os.ReadFile(DefaultAuditJSONPath)
|
|
||||||
if err != nil {
|
|
||||||
return ActionResult{Title: "CPU", Body: strings.TrimSpace(b.String())}
|
|
||||||
}
|
|
||||||
var snap schema.HardwareIngestRequest
|
|
||||||
if err := json.Unmarshal(raw, &snap); err != nil {
|
|
||||||
return ActionResult{Title: "CPU", Body: strings.TrimSpace(b.String())}
|
|
||||||
}
|
|
||||||
if len(snap.Hardware.CPUs) == 0 {
|
|
||||||
return ActionResult{Title: "CPU", Body: strings.TrimSpace(b.String())}
|
|
||||||
}
|
|
||||||
fmt.Fprintln(&b, "=== Audit ===")
|
|
||||||
for i, cpu := range snap.Hardware.CPUs {
|
|
||||||
fmt.Fprintf(&b, "CPU %d\n", i)
|
|
||||||
if cpu.Model != nil {
|
|
||||||
fmt.Fprintf(&b, " Model: %s\n", *cpu.Model)
|
|
||||||
}
|
|
||||||
if cpu.Manufacturer != nil {
|
|
||||||
fmt.Fprintf(&b, " Vendor: %s\n", *cpu.Manufacturer)
|
|
||||||
}
|
|
||||||
if cpu.Cores != nil {
|
|
||||||
fmt.Fprintf(&b, " Cores: %d\n", *cpu.Cores)
|
|
||||||
}
|
|
||||||
if cpu.Threads != nil {
|
|
||||||
fmt.Fprintf(&b, " Threads: %d\n", *cpu.Threads)
|
|
||||||
}
|
|
||||||
if cpu.MaxFrequencyMHz != nil {
|
|
||||||
fmt.Fprintf(&b, " Max freq: %d MHz\n", *cpu.MaxFrequencyMHz)
|
|
||||||
}
|
|
||||||
if cpu.TemperatureC != nil {
|
|
||||||
fmt.Fprintf(&b, " Temp: %.1f°C\n", *cpu.TemperatureC)
|
|
||||||
}
|
|
||||||
if cpu.Throttled != nil {
|
|
||||||
fmt.Fprintf(&b, " Throttled: %v\n", *cpu.Throttled)
|
|
||||||
}
|
|
||||||
if cpu.CorrectableErrorCount != nil && *cpu.CorrectableErrorCount > 0 {
|
|
||||||
fmt.Fprintf(&b, " ECC correctable: %d\n", *cpu.CorrectableErrorCount)
|
|
||||||
}
|
|
||||||
if cpu.UncorrectableErrorCount != nil && *cpu.UncorrectableErrorCount > 0 {
|
|
||||||
fmt.Fprintf(&b, " ECC uncorrectable: %d\n", *cpu.UncorrectableErrorCount)
|
|
||||||
}
|
|
||||||
if i < len(snap.Hardware.CPUs)-1 {
|
|
||||||
fmt.Fprintln(&b)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ActionResult{Title: "CPU", Body: strings.TrimSpace(b.String())}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) satDetailResult(statusKey, prefix, title string) ActionResult {
|
|
||||||
matches, err := filepath.Glob(filepath.Join(DefaultSATBaseDir, prefix+"*/summary.txt"))
|
|
||||||
if err != nil || len(matches) == 0 {
|
|
||||||
return ActionResult{Title: title, Body: "No test results found. Run a test first."}
|
|
||||||
}
|
|
||||||
sort.Strings(matches)
|
|
||||||
raw, err := os.ReadFile(matches[len(matches)-1])
|
|
||||||
if err != nil {
|
|
||||||
return ActionResult{Title: title, Body: "Could not read test results."}
|
|
||||||
}
|
|
||||||
return ActionResult{Title: title, Body: formatSATDetail(strings.TrimSpace(string(raw)))}
|
|
||||||
}
|
|
||||||
|
|
||||||
// formatSATDetail converts raw summary.txt key=value content to a human-readable per-step display.
|
|
||||||
func formatSATDetail(raw string) string {
|
|
||||||
var b strings.Builder
|
|
||||||
kv := parseKeyValueSummary(raw)
|
|
||||||
|
|
||||||
if t, ok := kv["run_at_utc"]; ok {
|
|
||||||
fmt.Fprintf(&b, "Run: %s\n\n", t)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collect step names in order they appear in the file
|
|
||||||
lines := strings.Split(raw, "\n")
|
|
||||||
var stepKeys []string
|
|
||||||
seenStep := map[string]bool{}
|
|
||||||
for _, line := range lines {
|
|
||||||
if idx := strings.Index(line, "_status="); idx >= 0 {
|
|
||||||
key := line[:idx]
|
|
||||||
if !seenStep[key] && key != "overall" {
|
|
||||||
seenStep[key] = true
|
|
||||||
stepKeys = append(stepKeys, key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, key := range stepKeys {
|
|
||||||
status := kv[key+"_status"]
|
|
||||||
display := cleanSummaryKey(key)
|
|
||||||
switch status {
|
|
||||||
case "OK":
|
|
||||||
fmt.Fprintf(&b, "PASS %s\n", display)
|
|
||||||
case "FAILED":
|
|
||||||
fmt.Fprintf(&b, "FAIL %s\n", display)
|
|
||||||
case "UNSUPPORTED":
|
|
||||||
fmt.Fprintf(&b, "SKIP %s\n", display)
|
|
||||||
default:
|
|
||||||
fmt.Fprintf(&b, "? %s\n", display)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if overall, ok := kv["overall_status"]; ok {
|
|
||||||
ok2 := kv["job_ok"]
|
|
||||||
failed := kv["job_failed"]
|
|
||||||
fmt.Fprintf(&b, "\nOverall: %s (ok=%s failed=%s)", overall, ok2, failed)
|
|
||||||
}
|
|
||||||
|
|
||||||
return strings.TrimSpace(b.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
// cleanSummaryKey strips the leading numeric prefix from a SAT step key.
|
|
||||||
// "1-lscpu" → "lscpu", "3-stress-ng" → "stress-ng"
|
|
||||||
func cleanSummaryKey(key string) string {
|
|
||||||
idx := strings.Index(key, "-")
|
|
||||||
if idx <= 0 {
|
|
||||||
return key
|
|
||||||
}
|
|
||||||
prefix := key[:idx]
|
|
||||||
for _, c := range prefix {
|
|
||||||
if c < '0' || c > '9' {
|
|
||||||
return key
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return key[idx+1:]
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) psuDetailResult() ActionResult {
|
|
||||||
raw, err := os.ReadFile(DefaultAuditJSONPath)
|
|
||||||
if err != nil {
|
|
||||||
return ActionResult{Title: "PSU", Body: "No audit data."}
|
|
||||||
}
|
|
||||||
var snap schema.HardwareIngestRequest
|
|
||||||
if err := json.Unmarshal(raw, &snap); err != nil {
|
|
||||||
return ActionResult{Title: "PSU", Body: "Audit data unreadable."}
|
|
||||||
}
|
|
||||||
if len(snap.Hardware.PowerSupplies) == 0 {
|
|
||||||
return ActionResult{Title: "PSU", Body: "No PSU data in last audit."}
|
|
||||||
}
|
|
||||||
var b strings.Builder
|
|
||||||
for i, psu := range snap.Hardware.PowerSupplies {
|
|
||||||
fmt.Fprintf(&b, "PSU %d\n", i)
|
|
||||||
if psu.Model != nil {
|
|
||||||
fmt.Fprintf(&b, " Model: %s\n", *psu.Model)
|
|
||||||
}
|
|
||||||
if psu.Vendor != nil {
|
|
||||||
fmt.Fprintf(&b, " Vendor: %s\n", *psu.Vendor)
|
|
||||||
}
|
|
||||||
if psu.WattageW != nil {
|
|
||||||
fmt.Fprintf(&b, " Rated: %d W\n", *psu.WattageW)
|
|
||||||
}
|
|
||||||
if psu.InputPowerW != nil {
|
|
||||||
fmt.Fprintf(&b, " Input: %.1f W\n", *psu.InputPowerW)
|
|
||||||
}
|
|
||||||
if psu.OutputPowerW != nil {
|
|
||||||
fmt.Fprintf(&b, " Output: %.1f W\n", *psu.OutputPowerW)
|
|
||||||
}
|
|
||||||
if psu.TemperatureC != nil {
|
|
||||||
fmt.Fprintf(&b, " Temp: %.1f°C\n", *psu.TemperatureC)
|
|
||||||
}
|
|
||||||
if i < len(snap.Hardware.PowerSupplies)-1 {
|
|
||||||
fmt.Fprintln(&b)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ActionResult{Title: "PSU", Body: strings.TrimSpace(b.String())}
|
|
||||||
}
|
|
||||||
|
|
||||||
// satStatuses reads the latest summary.txt for each SAT type and returns
|
|
||||||
// a map of component key ("gpu","memory","storage") → status ("PASS","FAIL","CANCEL","N/A").
|
|
||||||
func satStatuses() map[string]string {
|
|
||||||
result := map[string]string{
|
|
||||||
"gpu": "N/A",
|
|
||||||
"memory": "N/A",
|
|
||||||
"storage": "N/A",
|
|
||||||
"cpu": "N/A",
|
|
||||||
}
|
|
||||||
patterns := []struct {
|
|
||||||
key string
|
|
||||||
prefix string
|
|
||||||
}{
|
|
||||||
{"gpu", "gpu-nvidia-"},
|
|
||||||
{"gpu", "gpu-amd-"},
|
|
||||||
{"memory", "memory-"},
|
|
||||||
{"storage", "storage-"},
|
|
||||||
{"cpu", "cpu-"},
|
|
||||||
}
|
|
||||||
for _, item := range patterns {
|
|
||||||
matches, err := filepath.Glob(filepath.Join(DefaultSATBaseDir, item.prefix+"*/summary.txt"))
|
|
||||||
if err != nil || len(matches) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
sort.Strings(matches)
|
|
||||||
raw, err := os.ReadFile(matches[len(matches)-1])
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
values := parseKeyValueSummary(string(raw))
|
|
||||||
switch strings.ToUpper(strings.TrimSpace(values["overall_status"])) {
|
|
||||||
case "OK":
|
|
||||||
result[item.key] = "PASS"
|
|
||||||
case "FAILED":
|
|
||||||
result[item.key] = "FAIL"
|
|
||||||
case "CANCELED", "CANCELLED":
|
|
||||||
result[item.key] = "CANCEL"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatPSULine(psus []schema.HardwarePowerSupply) string {
|
|
||||||
var present []schema.HardwarePowerSupply
|
|
||||||
for _, psu := range psus {
|
|
||||||
if psu.Present != nil && !*psu.Present {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
present = append(present, psu)
|
|
||||||
}
|
|
||||||
if len(present) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
firstW := 0
|
|
||||||
if present[0].WattageW != nil {
|
|
||||||
firstW = *present[0].WattageW
|
|
||||||
}
|
|
||||||
allSame := firstW > 0
|
|
||||||
for _, p := range present[1:] {
|
|
||||||
w := 0
|
|
||||||
if p.WattageW != nil {
|
|
||||||
w = *p.WattageW
|
|
||||||
}
|
|
||||||
if w != firstW {
|
|
||||||
allSame = false
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if allSame && firstW > 0 {
|
|
||||||
return fmt.Sprintf("%dx %dW", len(present), firstW)
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%d PSU", len(present))
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
@@ -332,7 +334,7 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// RenderGPUTerminalChart returns ANSI line charts (asciigraph-style) per GPU.
|
// RenderGPUTerminalChart returns ANSI line charts (asciigraph-style) per GPU.
|
||||||
// Suitable for display in the TUI screenOutput.
|
// Used in SAT stress-test logs.
|
||||||
func RenderGPUTerminalChart(rows []GPUMetricRow) string {
|
func RenderGPUTerminalChart(rows []GPUMetricRow) string {
|
||||||
seen := make(map[int]bool)
|
seen := make(map[int]bool)
|
||||||
var order []int
|
var order []int
|
||||||
@@ -375,162 +377,6 @@ func RenderGPUTerminalChart(rows []GPUMetricRow) string {
|
|||||||
return strings.TrimRight(b.String(), "\n")
|
return strings.TrimRight(b.String(), "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
// RenderGPULiveChart renders all GPU metrics on a single combined chart per GPU.
|
|
||||||
// Each series is normalised to its own min–max and drawn in a different colour.
|
|
||||||
// chartWidth controls the width of the plot area (Y-axis label uses 5 extra chars).
|
|
||||||
func RenderGPULiveChart(rows []GPUMetricRow, chartWidth int) string {
|
|
||||||
if chartWidth < 20 {
|
|
||||||
chartWidth = 70
|
|
||||||
}
|
|
||||||
const chartHeight = 14
|
|
||||||
|
|
||||||
seen := make(map[int]bool)
|
|
||||||
var order []int
|
|
||||||
gpuMap := make(map[int][]GPUMetricRow)
|
|
||||||
for _, r := range rows {
|
|
||||||
if !seen[r.GPUIndex] {
|
|
||||||
seen[r.GPUIndex] = true
|
|
||||||
order = append(order, r.GPUIndex)
|
|
||||||
}
|
|
||||||
gpuMap[r.GPUIndex] = append(gpuMap[r.GPUIndex], r)
|
|
||||||
}
|
|
||||||
|
|
||||||
type seriesDef struct {
|
|
||||||
label string
|
|
||||||
color string
|
|
||||||
unit string
|
|
||||||
fn func(GPUMetricRow) float64
|
|
||||||
}
|
|
||||||
defs := []seriesDef{
|
|
||||||
{"Usage", ansiBlue, "%", func(r GPUMetricRow) float64 { return r.UsagePct }},
|
|
||||||
{"Temp", ansiRed, "°C", func(r GPUMetricRow) float64 { return r.TempC }},
|
|
||||||
{"Power", ansiGreen, "W", func(r GPUMetricRow) float64 { return r.PowerW }},
|
|
||||||
}
|
|
||||||
|
|
||||||
var b strings.Builder
|
|
||||||
for _, gpuIdx := range order {
|
|
||||||
gr := gpuMap[gpuIdx]
|
|
||||||
if len(gr) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
elapsed := gr[len(gr)-1].ElapsedSec
|
|
||||||
|
|
||||||
// Build value slices for each series.
|
|
||||||
type seriesData struct {
|
|
||||||
seriesDef
|
|
||||||
vals []float64
|
|
||||||
mn float64
|
|
||||||
mx float64
|
|
||||||
}
|
|
||||||
var series []seriesData
|
|
||||||
for _, d := range defs {
|
|
||||||
vals := extractGPUField(gr, d.fn)
|
|
||||||
mn, mx := gpuMinMax(vals)
|
|
||||||
if mn == mx {
|
|
||||||
mx = mn + 1
|
|
||||||
}
|
|
||||||
series = append(series, seriesData{d, vals, mn, mx})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shared character grid: row 0 = top (max), row chartHeight = bottom (min).
|
|
||||||
type cell struct {
|
|
||||||
ch rune
|
|
||||||
color string
|
|
||||||
}
|
|
||||||
grid := make([][]cell, chartHeight+1)
|
|
||||||
for r := range grid {
|
|
||||||
grid[r] = make([]cell, chartWidth)
|
|
||||||
for c := range grid[r] {
|
|
||||||
grid[r][c] = cell{' ', ""}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Plot each series onto the shared grid.
|
|
||||||
for _, s := range series {
|
|
||||||
w := chartWidth
|
|
||||||
if len(s.vals) < w {
|
|
||||||
w = len(s.vals)
|
|
||||||
}
|
|
||||||
data := gpuDownsample(s.vals, w)
|
|
||||||
prevRow := -1
|
|
||||||
for x, v := range data {
|
|
||||||
row := chartHeight - int(math.Round((v-s.mn)/(s.mx-s.mn)*float64(chartHeight)))
|
|
||||||
if row < 0 {
|
|
||||||
row = 0
|
|
||||||
}
|
|
||||||
if row > chartHeight {
|
|
||||||
row = chartHeight
|
|
||||||
}
|
|
||||||
if prevRow < 0 || prevRow == row {
|
|
||||||
grid[row][x] = cell{'─', s.color}
|
|
||||||
} else {
|
|
||||||
lo, hi := prevRow, row
|
|
||||||
if lo > hi {
|
|
||||||
lo, hi = hi, lo
|
|
||||||
}
|
|
||||||
for y := lo + 1; y < hi; y++ {
|
|
||||||
grid[y][x] = cell{'│', s.color}
|
|
||||||
}
|
|
||||||
if prevRow < row {
|
|
||||||
grid[prevRow][x] = cell{'╮', s.color}
|
|
||||||
grid[row][x] = cell{'╰', s.color}
|
|
||||||
} else {
|
|
||||||
grid[prevRow][x] = cell{'╯', s.color}
|
|
||||||
grid[row][x] = cell{'╭', s.color}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
prevRow = row
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render: Y axis + data rows.
|
|
||||||
fmt.Fprintf(&b, "GPU %d (%.0fs) each series normalised to its range\n", gpuIdx, elapsed)
|
|
||||||
for r := 0; r <= chartHeight; r++ {
|
|
||||||
// Y axis label: 100% at top, 50% in middle, 0% at bottom.
|
|
||||||
switch r {
|
|
||||||
case 0:
|
|
||||||
fmt.Fprintf(&b, "%4s┤", "100%")
|
|
||||||
case chartHeight / 2:
|
|
||||||
fmt.Fprintf(&b, "%4s┤", "50%")
|
|
||||||
case chartHeight:
|
|
||||||
fmt.Fprintf(&b, "%4s┤", "0%")
|
|
||||||
default:
|
|
||||||
fmt.Fprintf(&b, "%4s│", "")
|
|
||||||
}
|
|
||||||
for c := 0; c < chartWidth; c++ {
|
|
||||||
cl := grid[r][c]
|
|
||||||
if cl.color != "" {
|
|
||||||
b.WriteString(cl.color)
|
|
||||||
b.WriteRune(cl.ch)
|
|
||||||
b.WriteString(ansiReset)
|
|
||||||
} else {
|
|
||||||
b.WriteRune(' ')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
b.WriteRune('\n')
|
|
||||||
}
|
|
||||||
// Bottom axis.
|
|
||||||
b.WriteString(" └")
|
|
||||||
b.WriteString(strings.Repeat("─", chartWidth))
|
|
||||||
b.WriteRune('\n')
|
|
||||||
|
|
||||||
// Legend with current (last) values.
|
|
||||||
b.WriteString(" ")
|
|
||||||
for i, s := range series {
|
|
||||||
last := s.vals[len(s.vals)-1]
|
|
||||||
b.WriteString(s.color)
|
|
||||||
fmt.Fprintf(&b, "▐ %s: %.0f%s", s.label, last, s.unit)
|
|
||||||
b.WriteString(ansiReset)
|
|
||||||
if i < len(series)-1 {
|
|
||||||
b.WriteString(" ")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
b.WriteRune('\n')
|
|
||||||
}
|
|
||||||
|
|
||||||
return strings.TrimRight(b.String(), "\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// renderLineChart draws a single time-series line chart using box-drawing characters.
|
// renderLineChart draws a single time-series line chart using box-drawing characters.
|
||||||
// Produces output in the style of asciigraph: ╭─╮ │ ╰─╯ with a Y axis and caption.
|
// Produces output in the style of asciigraph: ╭─╮ │ ╰─╯ with a Y axis and caption.
|
||||||
func renderLineChart(vals []float64, color, caption string, height, width int) string {
|
func renderLineChart(vals []float64, color, caption string, height, width int) string {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package platform
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -10,13 +11,17 @@ import (
|
|||||||
|
|
||||||
// InstallDisk describes a candidate disk for installation.
|
// InstallDisk describes a candidate disk for installation.
|
||||||
type InstallDisk struct {
|
type InstallDisk struct {
|
||||||
Device string // e.g. /dev/sda
|
Device string // e.g. /dev/sda
|
||||||
Model string
|
Model string
|
||||||
Size string // human-readable, e.g. "500G"
|
Size string // human-readable, e.g. "500G"
|
||||||
|
SizeBytes int64 // raw byte count from lsblk
|
||||||
|
MountedParts []string // partition mount points currently active
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const squashfsPath = "/run/live/medium/live/filesystem.squashfs"
|
||||||
|
|
||||||
// ListInstallDisks returns block devices suitable for installation.
|
// ListInstallDisks returns block devices suitable for installation.
|
||||||
// Excludes USB drives and the current live boot medium.
|
// Excludes the current live boot medium but includes USB drives.
|
||||||
func (s *System) ListInstallDisks() ([]InstallDisk, error) {
|
func (s *System) ListInstallDisks() ([]InstallDisk, error) {
|
||||||
out, err := exec.Command("lsblk", "-dn", "-o", "NAME,MODEL,SIZE,TYPE,TRAN").Output()
|
out, err := exec.Command("lsblk", "-dn", "-o", "NAME,MODEL,SIZE,TYPE,TRAN").Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -33,7 +38,6 @@ func (s *System) ListInstallDisks() ([]InstallDisk, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Last field: TRAN, second-to-last: TYPE, third-to-last: SIZE
|
// Last field: TRAN, second-to-last: TYPE, third-to-last: SIZE
|
||||||
tran := fields[len(fields)-1]
|
|
||||||
typ := fields[len(fields)-2]
|
typ := fields[len(fields)-2]
|
||||||
size := fields[len(fields)-3]
|
size := fields[len(fields)-3]
|
||||||
name := fields[0]
|
name := fields[0]
|
||||||
@@ -42,24 +46,58 @@ func (s *System) ListInstallDisks() ([]InstallDisk, error) {
|
|||||||
if typ != "disk" {
|
if typ != "disk" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if strings.EqualFold(tran, "usb") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
device := "/dev/" + name
|
device := "/dev/" + name
|
||||||
if device == bootDev {
|
if device == bootDev {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sizeBytes := diskSizeBytes(device)
|
||||||
|
mounted := mountedParts(device)
|
||||||
|
|
||||||
disks = append(disks, InstallDisk{
|
disks = append(disks, InstallDisk{
|
||||||
Device: device,
|
Device: device,
|
||||||
Model: strings.TrimSpace(model),
|
Model: strings.TrimSpace(model),
|
||||||
Size: size,
|
Size: size,
|
||||||
|
SizeBytes: sizeBytes,
|
||||||
|
MountedParts: mounted,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return disks, nil
|
return disks, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// diskSizeBytes returns the byte size of a block device using lsblk.
|
||||||
|
func diskSizeBytes(device string) int64 {
|
||||||
|
out, err := exec.Command("lsblk", "-bdn", "-o", "SIZE", device).Output()
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
n, _ := strconv.ParseInt(strings.TrimSpace(string(out)), 10, 64)
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// mountedParts returns a list of "<part> at <mountpoint>" strings for any
|
||||||
|
// mounted partitions on the given device.
|
||||||
|
func mountedParts(device string) []string {
|
||||||
|
out, err := exec.Command("lsblk", "-n", "-o", "NAME,MOUNTPOINT", device).Output()
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var result []string
|
||||||
|
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mp := fields[1]
|
||||||
|
if mp == "" || mp == "[SWAP]" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result = append(result, "/dev/"+strings.TrimLeft(fields[0], "└─├─")+" at "+mp)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
// findLiveBootDevice returns the block device backing /run/live/medium (if any).
|
// findLiveBootDevice returns the block device backing /run/live/medium (if any).
|
||||||
func findLiveBootDevice() string {
|
func findLiveBootDevice() string {
|
||||||
out, err := exec.Command("findmnt", "-n", "-o", "SOURCE", "/run/live/medium").Output()
|
out, err := exec.Command("findmnt", "-n", "-o", "SOURCE", "/run/live/medium").Output()
|
||||||
@@ -79,6 +117,80 @@ func findLiveBootDevice() string {
|
|||||||
return "/dev/" + strings.TrimSpace(string(out2))
|
return "/dev/" + strings.TrimSpace(string(out2))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MinInstallBytes returns the minimum recommended disk size for installation:
|
||||||
|
// squashfs size × 1.5 to allow for extracted filesystem and bootloader.
|
||||||
|
// Returns 0 if the squashfs is not available (non-live environment).
|
||||||
|
func MinInstallBytes() int64 {
|
||||||
|
fi, err := os.Stat(squashfsPath)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return fi.Size() * 3 / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
// toramActive returns true when the live system was booted with toram.
|
||||||
|
func toramActive() bool {
|
||||||
|
data, err := os.ReadFile("/proc/cmdline")
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.Contains(string(data), "toram")
|
||||||
|
}
|
||||||
|
|
||||||
|
// freeMemBytes returns MemAvailable from /proc/meminfo.
|
||||||
|
func freeMemBytes() int64 {
|
||||||
|
data, err := os.ReadFile("/proc/meminfo")
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(string(data), "\n") {
|
||||||
|
if strings.HasPrefix(line, "MemAvailable:") {
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) >= 2 {
|
||||||
|
n, _ := strconv.ParseInt(fields[1], 10, 64)
|
||||||
|
return n * 1024 // kB → bytes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// DiskWarnings returns advisory warning strings for a disk candidate.
|
||||||
|
func DiskWarnings(d InstallDisk) []string {
|
||||||
|
var w []string
|
||||||
|
if len(d.MountedParts) > 0 {
|
||||||
|
w = append(w, "has mounted partitions: "+strings.Join(d.MountedParts, ", "))
|
||||||
|
}
|
||||||
|
min := MinInstallBytes()
|
||||||
|
if min > 0 && d.SizeBytes > 0 && d.SizeBytes < min {
|
||||||
|
w = append(w, fmt.Sprintf("disk may be too small (need ≥ %s, have %s)",
|
||||||
|
humanBytes(min), humanBytes(d.SizeBytes)))
|
||||||
|
}
|
||||||
|
if toramActive() {
|
||||||
|
sqFi, err := os.Stat(squashfsPath)
|
||||||
|
if err == nil {
|
||||||
|
free := freeMemBytes()
|
||||||
|
if free > 0 && free < sqFi.Size()*2 {
|
||||||
|
w = append(w, "toram mode — low RAM, extraction may be slow or fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
func humanBytes(b int64) string {
|
||||||
|
const unit = 1024
|
||||||
|
if b < unit {
|
||||||
|
return fmt.Sprintf("%d B", b)
|
||||||
|
}
|
||||||
|
div, exp := int64(unit), 0
|
||||||
|
for n := b / unit; n >= unit; n /= unit {
|
||||||
|
div *= unit
|
||||||
|
exp++
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "KMGTPE"[exp])
|
||||||
|
}
|
||||||
|
|
||||||
// InstallToDisk runs bee-install <device> <logfile> and streams output to logFile.
|
// InstallToDisk runs bee-install <device> <logfile> and streams output to logFile.
|
||||||
// The context can be used to cancel.
|
// The context can be used to cancel.
|
||||||
func (s *System) InstallToDisk(ctx context.Context, device string, logFile string) error {
|
func (s *System) InstallToDisk(ctx context.Context, device string, logFile string) error {
|
||||||
@@ -92,14 +204,11 @@ func InstallLogPath(device string) string {
|
|||||||
return "/tmp/bee-install" + safe + ".log"
|
return "/tmp/bee-install" + safe + ".log"
|
||||||
}
|
}
|
||||||
|
|
||||||
// DiskLabel returns a display label for a disk.
|
// Label returns a display label for a disk.
|
||||||
func (d InstallDisk) Label() string {
|
func (d InstallDisk) Label() string {
|
||||||
model := d.Model
|
model := d.Model
|
||||||
if model == "" {
|
if model == "" {
|
||||||
model = "Unknown"
|
model = "Unknown"
|
||||||
}
|
}
|
||||||
sizeBytes, err := strconv.ParseInt(strings.TrimSuffix(d.Size, "B"), 10, 64)
|
|
||||||
_ = sizeBytes
|
|
||||||
_ = err
|
|
||||||
return fmt.Sprintf("%s %s %s", d.Device, d.Size, model)
|
return fmt.Sprintf("%s %s %s", d.Device, d.Size, model)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -409,6 +409,101 @@ func (h *handler) handleAPIPreflight(w http.ResponseWriter, r *http.Request) {
|
|||||||
_, _ = w.Write(data)
|
_, _ = 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
|
||||||
|
}
|
||||||
|
|
||||||
|
h.installMu.Lock()
|
||||||
|
if h.installJob != nil && !h.installJob.isDone() {
|
||||||
|
h.installMu.Unlock()
|
||||||
|
writeError(w, http.StatusConflict, "install already running")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
j := &jobState{}
|
||||||
|
h.installJob = j
|
||||||
|
h.installMu.Unlock()
|
||||||
|
|
||||||
|
logFile := platform.InstallLogPath(req.Device)
|
||||||
|
go runCmdJob(j, exec.CommandContext(r.Context(), "bee-install", req.Device, logFile))
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handler) handleAPIInstallStream(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.installMu.Lock()
|
||||||
|
j := h.installJob
|
||||||
|
h.installMu.Unlock()
|
||||||
|
if j == nil {
|
||||||
|
if !sseStart(w) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sseWrite(w, "done", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
streamJob(w, r, j)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Metrics SSE ───────────────────────────────────────────────────────────────
|
// ── Metrics SSE ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func (h *handler) handleAPIMetricsStream(w http.ResponseWriter, r *http.Request) {
|
func (h *handler) handleAPIMetricsStream(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -424,7 +519,7 @@ func (h *handler) handleAPIMetricsStream(w http.ResponseWriter, r *http.Request)
|
|||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
sample := platform.SampleLiveMetrics()
|
sample := platform.SampleLiveMetrics()
|
||||||
|
|
||||||
// Feed ring buffers for server-side SVG charts
|
// Feed server ring buffers
|
||||||
for _, t := range sample.Temps {
|
for _, t := range sample.Temps {
|
||||||
if t.Name == "CPU" {
|
if t.Name == "CPU" {
|
||||||
h.ringCPUTemp.push(t.Celsius)
|
h.ringCPUTemp.push(t.Celsius)
|
||||||
@@ -432,6 +527,35 @@ func (h *handler) handleAPIMetricsStream(w http.ResponseWriter, r *http.Request)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
h.ringPower.push(sample.PowerW)
|
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 {
|
||||||
|
|||||||
@@ -76,6 +76,13 @@ func (m *jobManager) create(id string) *jobState {
|
|||||||
return j
|
return j
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isDone returns true if the job has finished (either successfully or with error).
|
||||||
|
func (j *jobState) isDone() bool {
|
||||||
|
j.mu.Lock()
|
||||||
|
defer j.mu.Unlock()
|
||||||
|
return j.done
|
||||||
|
}
|
||||||
|
|
||||||
func (m *jobManager) get(id string) (*jobState, bool) {
|
func (m *jobManager) get(id string) (*jobState, bool) {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|||||||
@@ -21,62 +21,62 @@ func layoutHead(title string) string {
|
|||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
<title>` + html.EscapeString(title) + `</title>
|
<title>` + html.EscapeString(title) + `</title>
|
||||||
<style>
|
<style>
|
||||||
|
:root{--bg:#fff;--surface:#fff;--surface-2:#f9fafb;--border:rgba(34,36,38,.15);--border-lite:rgba(34,36,38,.1);--ink:rgba(0,0,0,.87);--muted:rgba(0,0,0,.6);--accent:#2185d0;--accent-dark:#1678c2;--crit-bg:#fff6f6;--crit-fg:#9f3a38;--crit-border:#e0b4b4;--ok-bg:#fcfff5;--ok-fg:#2c662d;--warn-bg:#fffaf3;--warn-fg:#573a08}
|
||||||
*{box-sizing:border-box;margin:0;padding:0}
|
*{box-sizing:border-box;margin:0;padding:0}
|
||||||
body{font-family:system-ui,-apple-system,sans-serif;background:#0f1117;color:#e2e8f0;display:flex;min-height:100vh}
|
body{font:14px/1.5 Lato,"Helvetica Neue",Arial,Helvetica,sans-serif;background:var(--bg);color:var(--ink);display:flex;min-height:100vh}
|
||||||
a{color:inherit;text-decoration:none}
|
a{color:var(--accent);text-decoration:none}
|
||||||
/* Sidebar */
|
/* Sidebar */
|
||||||
.sidebar{width:200px;min-height:100vh;background:#161b25;border-right:1px solid #252d3d;flex-shrink:0;display:flex;flex-direction:column}
|
.sidebar{width:210px;min-height:100vh;background:#1b1c1d;flex-shrink:0;display:flex;flex-direction:column}
|
||||||
.sidebar-logo{padding:20px 16px 12px;font-size:20px;font-weight:700;color:#60a5fa;letter-spacing:-0.5px}
|
.sidebar-logo{padding:18px 16px 12px;font-size:18px;font-weight:700;color:#fff;letter-spacing:-.5px}
|
||||||
.sidebar-logo span{color:#94a3b8;font-weight:400;font-size:13px;display:block;margin-top:2px}
|
.sidebar-logo span{color:rgba(255,255,255,.5);font-weight:400;font-size:12px;display:block;margin-top:2px}
|
||||||
.nav{flex:1}
|
.nav{flex:1}
|
||||||
.nav-item{display:block;padding:10px 16px;color:#94a3b8;font-size:14px;border-left:3px solid transparent;transition:all .15s}
|
.nav-item{display:block;padding:10px 16px;color:rgba(255,255,255,.7);font-size:13px;border-left:3px solid transparent;transition:all .15s}
|
||||||
.nav-item:hover,.nav-item.active{background:#1e2535;color:#e2e8f0;border-left-color:#3b82f6}
|
.nav-item:hover{color:#fff;background:rgba(255,255,255,.08)}
|
||||||
.nav-icon{margin-right:8px;opacity:.7}
|
.nav-item.active{color:#fff;background:rgba(33,133,208,.25);border-left-color:var(--accent)}
|
||||||
/* Content */
|
/* Content */
|
||||||
.main{flex:1;display:flex;flex-direction:column;overflow:auto}
|
.main{flex:1;display:flex;flex-direction:column;overflow:auto}
|
||||||
.topbar{padding:16px 24px;border-bottom:1px solid #1e2535;display:flex;align-items:center;gap:12px}
|
.topbar{padding:13px 24px;background:#1b1c1d;display:flex;align-items:center;gap:12px}
|
||||||
.topbar h1{font-size:18px;font-weight:600}
|
.topbar h1{font-size:16px;font-weight:700;color:rgba(255,255,255,.9)}
|
||||||
.content{padding:24px;flex:1}
|
.content{padding:24px;flex:1}
|
||||||
/* Cards */
|
/* Cards */
|
||||||
.card{background:#161b25;border:1px solid #1e2535;border-radius:10px;margin-bottom:16px}
|
.card{background:var(--surface);border:1px solid var(--border);border-radius:4px;box-shadow:0 1px 2px rgba(34,36,38,.15);margin-bottom:16px;overflow:hidden}
|
||||||
.card-head{padding:14px 18px;border-bottom:1px solid #1e2535;font-weight:600;font-size:14px;display:flex;align-items:center;gap:8px}
|
.card-head{padding:11px 16px;background:var(--surface-2);border-bottom:1px solid var(--border);font-weight:700;font-size:13px;display:flex;align-items:center;gap:8px}
|
||||||
.card-body{padding:18px}
|
.card-body{padding:16px}
|
||||||
/* Buttons */
|
/* Buttons */
|
||||||
.btn{display:inline-flex;align-items:center;gap:6px;padding:8px 16px;border-radius:6px;font-size:13px;font-weight:600;cursor:pointer;border:none;transition:background .15s}
|
.btn{display:inline-flex;align-items:center;gap:6px;padding:8px 16px;border-radius:4px;font-size:13px;font-weight:700;cursor:pointer;border:none;transition:background .1s;font-family:inherit}
|
||||||
.btn-primary{background:#3b82f6;color:#fff}.btn-primary:hover{background:#2563eb}
|
.btn-primary{background:var(--accent);color:#fff}.btn-primary:hover{background:var(--accent-dark)}
|
||||||
.btn-danger{background:#ef4444;color:#fff}.btn-danger:hover{background:#dc2626}
|
.btn-danger{background:#db2828;color:#fff}.btn-danger:hover{background:#b91c1c}
|
||||||
.btn-secondary{background:#1e2535;color:#94a3b8;border:1px solid #252d3d}.btn-secondary:hover{background:#252d3d;color:#e2e8f0}
|
.btn-secondary{background:var(--surface-2);color:var(--ink);border:1px solid var(--border)}.btn-secondary:hover{background:#eee}
|
||||||
.btn-sm{padding:5px 10px;font-size:12px}
|
.btn-sm{padding:5px 10px;font-size:12px}
|
||||||
/* Tables */
|
/* Tables */
|
||||||
table{width:100%;border-collapse:collapse;font-size:13px}
|
table{width:100%;border-collapse:collapse;font-size:13px;background:var(--surface)}
|
||||||
th{text-align:left;padding:8px 12px;color:#64748b;font-weight:600;border-bottom:1px solid #1e2535}
|
th{text-align:left;padding:9px 14px;color:var(--ink);font-weight:700;background:var(--surface-2);border-bottom:1px solid var(--border-lite)}
|
||||||
td{padding:8px 12px;border-bottom:1px solid #1a2030}
|
td{padding:9px 14px;border-top:1px solid var(--border-lite)}
|
||||||
tr:last-child td{border:none}
|
tr:first-child td{border-top:0}
|
||||||
tr:hover td{background:#1a2030}
|
tbody tr:hover td{background:rgba(0,0,0,.03)}
|
||||||
/* Status badges */
|
/* Status badges */
|
||||||
.badge{display:inline-block;padding:2px 8px;border-radius:999px;font-size:11px;font-weight:600}
|
.badge{display:inline-block;padding:2px 9px;border-radius:4px;font-size:11px;font-weight:700}
|
||||||
.badge-ok{background:#166534;color:#86efac}
|
.badge-ok{background:var(--ok-bg);color:var(--ok-fg);border:1px solid #a3c293}
|
||||||
.badge-warn{background:#713f12;color:#fde68a}
|
.badge-warn{background:var(--warn-bg);color:var(--warn-fg);border:1px solid #c9ba9b}
|
||||||
.badge-err{background:#7f1d1d;color:#fca5a5}
|
.badge-err{background:var(--crit-bg);color:var(--crit-fg);border:1px solid var(--crit-border)}
|
||||||
.badge-unknown{background:#1e293b;color:#64748b}
|
.badge-unknown{background:var(--surface-2);color:var(--muted);border:1px solid var(--border)}
|
||||||
/* Output terminal */
|
/* Output terminal */
|
||||||
.terminal{background:#0a0d14;border:1px solid #1e2535;border-radius:8px;padding:14px;font-family:monospace;font-size:12px;color:#86efac;max-height:400px;overflow-y:auto;white-space:pre-wrap;word-break:break-all}
|
.terminal{background:#1b1c1d;border:1px solid rgba(0,0,0,.2);border-radius:4px;padding:14px;font-family:monospace;font-size:12px;color:#b5cea8;max-height:400px;overflow-y:auto;white-space:pre-wrap;word-break:break-all}
|
||||||
/* Forms */
|
/* Forms */
|
||||||
.form-row{margin-bottom:14px}
|
.form-row{margin-bottom:14px}
|
||||||
.form-row label{display:block;font-size:12px;color:#64748b;margin-bottom:5px}
|
.form-row label{display:block;font-size:12px;color:var(--muted);margin-bottom:5px;font-weight:700}
|
||||||
.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:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--ink);font-size:13px;outline:none;font-family:inherit}
|
||||||
.form-row input:focus,.form-row select:focus{border-color:#3b82f6}
|
.form-row input:focus,.form-row select:focus{border-color:var(--accent);box-shadow:0 0 0 2px rgba(33,133,208,.2)}
|
||||||
.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}
|
||||||
.grid3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:16px}
|
.grid3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:16px}
|
||||||
@media(max-width:900px){.grid2,.grid3{grid-template-columns:1fr}}
|
@media(max-width:900px){.grid2,.grid3{grid-template-columns:1fr}}
|
||||||
/* iframe viewer */
|
/* iframe viewer */
|
||||||
.viewer-frame{width:100%;height:calc(100vh - 160px);border:0;border-radius:8px;background:#1a1f2e}
|
.viewer-frame{width:100%;height:calc(100vh - 160px);border:0;border-radius:4px;background:var(--surface-2)}
|
||||||
/* Alerts */
|
/* Alerts */
|
||||||
.alert{padding:10px 14px;border-radius:8px;font-size:13px;margin-bottom:14px}
|
.alert{padding:10px 14px;border-radius:4px;font-size:13px;margin-bottom:14px}
|
||||||
.alert-info{background:#1e3a5f;border:1px solid #2563eb;color:#93c5fd}
|
.alert-info{background:#dff0ff;border:1px solid #a9d4f5;color:#1e3a5f}
|
||||||
.alert-warn{background:#451a03;border:1px solid #d97706;color:#fde68a}
|
.alert-warn{background:var(--warn-bg);border:1px solid #c9ba9b;color:var(--warn-fg)}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -84,15 +84,17 @@ tr:hover td{background:#1a2030}
|
|||||||
}
|
}
|
||||||
|
|
||||||
func layoutNav(active string) string {
|
func layoutNav(active string) string {
|
||||||
items := []struct{ id, icon, label string }{
|
items := []struct{ id, label, href string }{
|
||||||
{"dashboard", "", "Dashboard"},
|
{"dashboard", "Dashboard", "/"},
|
||||||
{"metrics", "", "Metrics"},
|
{"viewer", "Audit Snapshot", "/viewer"},
|
||||||
{"tests", "", "Acceptance Tests"},
|
{"metrics", "Metrics", "/metrics"},
|
||||||
{"burn-in", "", "Burn-in"},
|
{"tests", "Acceptance Tests", "/tests"},
|
||||||
{"network", "", "Network"},
|
{"burn-in", "Burn-in", "/burn-in"},
|
||||||
{"services", "", "Services"},
|
{"network", "Network", "/network"},
|
||||||
{"export", "", "Export"},
|
{"services", "Services", "/services"},
|
||||||
{"tools", "", "Tools"},
|
{"export", "Export", "/export"},
|
||||||
|
{"tools", "Tools", "/tools"},
|
||||||
|
{"install", "Install to Disk", "/install"},
|
||||||
}
|
}
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString(`<aside class="sidebar">`)
|
b.WriteString(`<aside class="sidebar">`)
|
||||||
@@ -103,12 +105,8 @@ func layoutNav(active string) string {
|
|||||||
if item.id == active {
|
if item.id == active {
|
||||||
cls += " active"
|
cls += " active"
|
||||||
}
|
}
|
||||||
href := "/"
|
|
||||||
if item.id != "dashboard" {
|
|
||||||
href = "/" + item.id
|
|
||||||
}
|
|
||||||
b.WriteString(fmt.Sprintf(`<a class="%s" href="%s">%s</a>`,
|
b.WriteString(fmt.Sprintf(`<a class="%s" href="%s">%s</a>`,
|
||||||
cls, href, item.label))
|
cls, item.href, item.label))
|
||||||
}
|
}
|
||||||
b.WriteString(`</nav></aside>`)
|
b.WriteString(`</nav></aside>`)
|
||||||
return b.String()
|
return b.String()
|
||||||
@@ -150,6 +148,10 @@ func renderPage(page string, opts HandlerOptions) string {
|
|||||||
pageID = "tools"
|
pageID = "tools"
|
||||||
title = "Tools"
|
title = "Tools"
|
||||||
body = renderTools()
|
body = renderTools()
|
||||||
|
case "install":
|
||||||
|
pageID = "install"
|
||||||
|
title = "Install to Disk"
|
||||||
|
body = renderInstall()
|
||||||
default:
|
default:
|
||||||
pageID = "dashboard"
|
pageID = "dashboard"
|
||||||
title = "Not Found"
|
title = "Not Found"
|
||||||
@@ -182,11 +184,6 @@ func renderDashboard(opts HandlerOptions) string {
|
|||||||
b.WriteString(`</div></div>`)
|
b.WriteString(`</div></div>`)
|
||||||
b.WriteString(`</div>`)
|
b.WriteString(`</div>`)
|
||||||
b.WriteString(`</div>`)
|
b.WriteString(`</div>`)
|
||||||
// Audit viewer iframe
|
|
||||||
b.WriteString(`<div class="card"><div class="card-head">Audit Snapshot</div><div class="card-body" style="padding:0">`)
|
|
||||||
b.WriteString(`<iframe class="viewer-frame" src="/viewer" loading="eager" referrerpolicy="same-origin"></iframe>`)
|
|
||||||
b.WriteString(`</div></div>`)
|
|
||||||
|
|
||||||
// Audit run output div
|
// Audit run output div
|
||||||
b.WriteString(`<div id="audit-output" style="display:none" class="card"><div class="card-head">Audit Output</div><div class="card-body"><div id="audit-terminal" class="terminal"></div></div></div>`)
|
b.WriteString(`<div id="audit-output" style="display:none" class="card"><div class="card-head">Audit Output</div><div class="card-body"><div id="audit-terminal" class="terminal"></div></div></div>`)
|
||||||
|
|
||||||
@@ -242,28 +239,27 @@ 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, charts updated every 2 seconds.</p>
|
return `<p style="color:var(--muted);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">System</div>
|
<div class="card-head">Server</div>
|
||||||
<div class="card-body">
|
<div class="card-body" style="padding:8px">
|
||||||
<img id="chart-cpu-temp" src="/api/metrics/chart/cpu-temp.svg" style="width:100%;border-radius:6px" alt="CPU Temp">
|
<img id="chart-server" src="/api/metrics/chart/server.svg" style="width:100%;display:block;border-radius:6px" alt="Server metrics">
|
||||||
<img id="chart-power" src="/api/metrics/chart/power.svg" style="width:100%;border-radius:6px;margin-top:8px" alt="Power">
|
<div id="sys-table" style="margin-top:8px;font-size:12px"></div>
|
||||||
<div id="sys-table" style="margin-top:8px"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-head">GPU</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div id="gpu-table"><p style="color:#64748b;font-size:12px">Waiting for data...</p></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="gpu-charts"></div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
let knownGPUs = [];
|
||||||
|
|
||||||
function refreshCharts() {
|
function refreshCharts() {
|
||||||
const t = '?t=' + Date.now();
|
const t = '?t=' + Date.now();
|
||||||
['chart-cpu-temp','chart-power'].forEach(id => {
|
const srv = document.getElementById('chart-server');
|
||||||
const el = document.getElementById(id);
|
if (srv) srv.src = srv.src.split('?')[0] + t;
|
||||||
|
knownGPUs.forEach(idx => {
|
||||||
|
const el = document.getElementById('chart-gpu-' + idx);
|
||||||
if (el) el.src = el.src.split('?')[0] + t;
|
if (el) el.src = el.src.split('?')[0] + t;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -272,21 +268,42 @@ 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);
|
||||||
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>';
|
|
||||||
|
|
||||||
|
// Add GPU chart cards as GPUs appear
|
||||||
|
(d.gpus||[]).forEach(g => {
|
||||||
|
if (knownGPUs.includes(g.index)) return;
|
||||||
|
knownGPUs.push(g.index);
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'card';
|
||||||
|
div.style.marginBottom = '16px';
|
||||||
|
div.innerHTML = '<div class="card-head">GPU ' + g.index + '</div>' +
|
||||||
|
'<div class="card-body" style="padding:8px">' +
|
||||||
|
'<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 + '">' +
|
||||||
|
'<div id="gpu-table-' + g.index + '" style="margin-top:8px;font-size:12px"></div>' +
|
||||||
|
'</div>';
|
||||||
|
document.getElementById('gpu-charts').appendChild(div);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update numeric tables
|
||||||
let sysHTML = '';
|
let sysHTML = '';
|
||||||
const cpuTemp = (d.temps||[]).find(t => t.name==='CPU');
|
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>'+sysHTML+'</table>' :
|
if (st) st.innerHTML = sysHTML ? '<table>'+sysHTML+'</table>' : '<p style="color:var(--muted)">No sensor data (ipmitool/sensors required)</p>';
|
||||||
'<p style="color:#64748b;font-size:12px">No sensor data (ipmitool/sensors required)</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 = () => {};
|
es.onerror = () => {};
|
||||||
</script>`
|
</script>`
|
||||||
@@ -295,7 +312,7 @@ es.onerror = () => {};
|
|||||||
// ── Acceptance Tests ──────────────────────────────────────────────────────────
|
// ── Acceptance Tests ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func renderTests() string {
|
func renderTests() string {
|
||||||
return `<p style="color:#64748b;font-size:13px;margin-bottom:16px">Run hardware acceptance tests and view results.</p>
|
return `<p style="color:var(--muted);font-size:13px;margin-bottom:16px">Run hardware acceptance tests and view results.</p>
|
||||||
<div class="grid2">
|
<div class="grid2">
|
||||||
` + renderSATCard("nvidia", "NVIDIA GPU", `<div class="form-row"><label>Diag Level</label><select id="sat-nvidia-level"><option value="1">Level 1 — Quick</option><option value="2">Level 2 — Standard</option><option value="3">Level 3 — Extended</option><option value="4">Level 4 — Full</option></select></div>`) +
|
` + renderSATCard("nvidia", "NVIDIA GPU", `<div class="form-row"><label>Diag Level</label><select id="sat-nvidia-level"><option value="1">Level 1 — Quick</option><option value="2">Level 2 — Standard</option><option value="3">Level 3 — Extended</option><option value="4">Level 4 — Full</option></select></div>`) +
|
||||||
renderSATCard("memory", "Memory", "") +
|
renderSATCard("memory", "Memory", "") +
|
||||||
@@ -336,7 +353,7 @@ func renderSATCard(id, label, extra string) string {
|
|||||||
// ── Burn-in ───────────────────────────────────────────────────────────────────
|
// ── Burn-in ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func renderBurnIn() string {
|
func renderBurnIn() string {
|
||||||
return `<p style="color:#64748b;font-size:13px;margin-bottom:16px">Long-running GPU and system stress tests. Check <a href="/metrics" style="color:#60a5fa">Metrics</a> page for live telemetry.</p>
|
return `<p style="color:var(--muted);font-size:13px;margin-bottom:16px">Long-running GPU and system stress tests. Check <a href="/metrics" style="color:var(--accent)">Metrics</a> page for live telemetry.</p>
|
||||||
<div class="grid2">
|
<div class="grid2">
|
||||||
<div class="card"><div class="card-head">GPU Platform Stress</div><div class="card-body">
|
<div class="card"><div class="card-head">GPU Platform Stress</div><div class="card-body">
|
||||||
<div class="form-row"><label>Duration</label><select id="bi-dur"><option value="600">10 minutes</option><option value="3600">1 hour</option><option value="28800">8 hours</option><option value="86400">24 hours</option></select></div>
|
<div class="form-row"><label>Duration</label><select id="bi-dur"><option value="600">10 minutes</option><option value="3600">1 hour</option><option value="28800">8 hours</option><option value="86400">24 hours</option></select></div>
|
||||||
@@ -376,13 +393,13 @@ function runBurnIn(target) {
|
|||||||
|
|
||||||
func renderNetwork() string {
|
func renderNetwork() string {
|
||||||
return `<div class="card"><div class="card-head">Network Interfaces</div><div class="card-body">
|
return `<div class="card"><div class="card-head">Network Interfaces</div><div class="card-body">
|
||||||
<div id="iface-table"><p style="color:#64748b;font-size:13px">Loading...</p></div>
|
<div id="iface-table"><p style="color:var(--muted);font-size:13px">Loading...</p></div>
|
||||||
</div></div>
|
</div></div>
|
||||||
<div class="grid2">
|
<div class="grid2">
|
||||||
<div class="card"><div class="card-head">DHCP</div><div class="card-body">
|
<div class="card"><div class="card-head">DHCP</div><div class="card-body">
|
||||||
<div class="form-row"><label>Interface (leave empty for all)</label><input type="text" id="dhcp-iface" placeholder="eth0"></div>
|
<div class="form-row"><label>Interface (leave empty for all)</label><input type="text" id="dhcp-iface" placeholder="eth0"></div>
|
||||||
<button class="btn btn-primary" onclick="runDHCP()">▶ Run DHCP</button>
|
<button class="btn btn-primary" onclick="runDHCP()">▶ Run DHCP</button>
|
||||||
<div id="dhcp-out" style="margin-top:10px;font-size:12px;color:#86efac"></div>
|
<div id="dhcp-out" style="margin-top:10px;font-size:12px;color:var(--ok-fg)"></div>
|
||||||
</div></div>
|
</div></div>
|
||||||
<div class="card"><div class="card-head">Static IPv4</div><div class="card-body">
|
<div class="card"><div class="card-head">Static IPv4</div><div class="card-body">
|
||||||
<div class="form-row"><label>Interface</label><input type="text" id="st-iface" placeholder="eth0"></div>
|
<div class="form-row"><label>Interface</label><input type="text" id="st-iface" placeholder="eth0"></div>
|
||||||
@@ -391,7 +408,7 @@ func renderNetwork() string {
|
|||||||
<div class="form-row"><label>Gateway</label><input type="text" id="st-gw" placeholder="192.168.1.1"></div>
|
<div class="form-row"><label>Gateway</label><input type="text" id="st-gw" placeholder="192.168.1.1"></div>
|
||||||
<div class="form-row"><label>DNS (comma-separated)</label><input type="text" id="st-dns" placeholder="8.8.8.8,8.8.4.4"></div>
|
<div class="form-row"><label>DNS (comma-separated)</label><input type="text" id="st-dns" placeholder="8.8.8.8,8.8.4.4"></div>
|
||||||
<button class="btn btn-primary" onclick="setStatic()">Apply Static IP</button>
|
<button class="btn btn-primary" onclick="setStatic()">Apply Static IP</button>
|
||||||
<div id="static-out" style="margin-top:10px;font-size:12px;color:#86efac"></div>
|
<div id="static-out" style="margin-top:10px;font-size:12px;color:var(--ok-fg)"></div>
|
||||||
</div></div>
|
</div></div>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
@@ -402,7 +419,7 @@ function loadNetwork() {
|
|||||||
).join('');
|
).join('');
|
||||||
document.getElementById('iface-table').innerHTML =
|
document.getElementById('iface-table').innerHTML =
|
||||||
'<table><tr><th>Interface</th><th>State</th><th>Addresses</th></tr>'+rows+'</table>' +
|
'<table><tr><th>Interface</th><th>State</th><th>Addresses</th></tr>'+rows+'</table>' +
|
||||||
(d.default_route ? '<p style="font-size:12px;color:#64748b;margin-top:8px">Default route: '+d.default_route+'</p>' : '');
|
(d.default_route ? '<p style="font-size:12px;color:var(--muted);margin-top:8px">Default route: '+d.default_route+'</p>' : '');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
function runDHCP() {
|
function runDHCP() {
|
||||||
@@ -435,7 +452,7 @@ loadNetwork();
|
|||||||
func renderServices() string {
|
func renderServices() string {
|
||||||
return `<div class="card"><div class="card-head">Bee Services <button class="btn btn-sm btn-secondary" onclick="loadServices()" style="margin-left:auto">↻ Refresh</button></div>
|
return `<div class="card"><div class="card-head">Bee Services <button class="btn btn-sm btn-secondary" onclick="loadServices()" style="margin-left:auto">↻ Refresh</button></div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div id="svc-table"><p style="color:#64748b;font-size:13px">Loading...</p></div>
|
<div id="svc-table"><p style="color:var(--muted);font-size:13px">Loading...</p></div>
|
||||||
</div></div>
|
</div></div>
|
||||||
<div id="svc-out" style="display:none;margin-top:8px" class="card">
|
<div id="svc-out" style="display:none;margin-top:8px" class="card">
|
||||||
<div class="card-head">Output</div>
|
<div class="card-head">Output</div>
|
||||||
@@ -452,7 +469,7 @@ function loadServices() {
|
|||||||
return '<tr>' +
|
return '<tr>' +
|
||||||
'<td style="white-space:nowrap">'+s.name+'</td>' +
|
'<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>' +
|
'<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>' +
|
'<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:#1b1c1d;padding:8px;border-radius:4px;color:#b5cea8">'+body+'</pre></div>' +
|
||||||
'</td>' +
|
'</td>' +
|
||||||
'<td style="white-space:nowrap">' +
|
'<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> ' +
|
||||||
@@ -490,11 +507,11 @@ func renderExport(exportDir string) string {
|
|||||||
url.QueryEscape(e), html.EscapeString(e)))
|
url.QueryEscape(e), html.EscapeString(e)))
|
||||||
}
|
}
|
||||||
if len(entries) == 0 {
|
if len(entries) == 0 {
|
||||||
rows.WriteString(`<tr><td style="color:#64748b">No export files found.</td></tr>`)
|
rows.WriteString(`<tr><td style="color:var(--muted)">No export files found.</td></tr>`)
|
||||||
}
|
}
|
||||||
return `<div class="grid2">
|
return `<div class="grid2">
|
||||||
<div class="card"><div class="card-head">Support Bundle</div><div class="card-body">
|
<div class="card"><div class="card-head">Support Bundle</div><div class="card-body">
|
||||||
<p style="font-size:13px;color:#94a3b8;margin-bottom:12px">Creates a tar.gz archive of all audit files, SAT results, and logs.</p>
|
<p style="font-size:13px;color:var(--muted);margin-bottom:12px">Creates a tar.gz archive of all audit files, SAT results, and logs.</p>
|
||||||
<a class="btn btn-primary" href="/export/support.tar.gz">⬇ Download Support Bundle</a>
|
<a class="btn btn-primary" href="/export/support.tar.gz">⬇ Download Support Bundle</a>
|
||||||
</div></div>
|
</div></div>
|
||||||
<div class="card"><div class="card-head">Export Files</div><div class="card-body">
|
<div class="card"><div class="card-head">Export Files</div><div class="card-body">
|
||||||
@@ -530,10 +547,10 @@ func listExportFiles(exportDir string) ([]string, error) {
|
|||||||
|
|
||||||
func renderTools() string {
|
func renderTools() string {
|
||||||
return `<div class="card"><div class="card-head">Tool Check <button class="btn btn-sm btn-secondary" onclick="checkTools()" style="margin-left:auto">↻ Check</button></div>
|
return `<div class="card"><div class="card-head">Tool Check <button class="btn btn-sm btn-secondary" onclick="checkTools()" style="margin-left:auto">↻ Check</button></div>
|
||||||
<div class="card-body"><div id="tools-table"><p style="color:#64748b;font-size:13px">Click Check to verify installed tools.</p></div></div></div>
|
<div class="card-body"><div id="tools-table"><p style="color:var(--muted);font-size:13px">Click Check to verify installed tools.</p></div></div></div>
|
||||||
<script>
|
<script>
|
||||||
function checkTools() {
|
function checkTools() {
|
||||||
document.getElementById('tools-table').innerHTML = '<p style="color:#64748b;font-size:13px">Checking...</p>';
|
document.getElementById('tools-table').innerHTML = '<p style="color:var(--muted);font-size:13px">Checking...</p>';
|
||||||
fetch('/api/tools/check').then(r=>r.json()).then(tools => {
|
fetch('/api/tools/check').then(r=>r.json()).then(tools => {
|
||||||
const rows = tools.map(t =>
|
const rows = tools.map(t =>
|
||||||
'<tr><td>'+t.Name+'</td><td><span class="badge '+(t.OK ? 'badge-ok' : 'badge-err')+'">'+(t.OK ? '✓ '+t.Path : '✗ missing')+'</span></td></tr>'
|
'<tr><td>'+t.Name+'</td><td><span class="badge '+(t.OK ? 'badge-ok' : 'badge-err')+'">'+(t.OK ? '✓ '+t.Path : '✗ missing')+'</span></td></tr>'
|
||||||
@@ -546,100 +563,209 @@ checkTools();
|
|||||||
</script>`
|
</script>`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Viewer (compatibility) ────────────────────────────────────────────────────
|
// ── Install to Disk ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// renderViewerPage renders the audit snapshot as a styled HTML page.
|
func renderInstall() string {
|
||||||
// This endpoint is embedded as an iframe on the Dashboard page.
|
return `
|
||||||
func renderViewerPage(title string, snapshot []byte) string {
|
<div class="card">
|
||||||
var b strings.Builder
|
<div class="card-head">Install Live System to Disk</div>
|
||||||
b.WriteString(`<!DOCTYPE html><html><head><meta charset="utf-8">`)
|
<div class="card-body">
|
||||||
b.WriteString(`<title>` + html.EscapeString(title) + `</title>`)
|
<div class="alert alert-warn" style="margin-bottom:16px">
|
||||||
b.WriteString(`<style>
|
<strong>Warning:</strong> Installing will <strong>completely erase</strong> the selected
|
||||||
*{box-sizing:border-box;margin:0;padding:0}
|
disk and write the live system onto it. All existing data on the target disk will be lost.
|
||||||
body{font-family:system-ui,sans-serif;background:#0f1117;color:#e2e8f0;padding:20px}
|
This operation cannot be undone.
|
||||||
h2{font-size:14px;color:#64748b;margin-bottom:8px;margin-top:16px;text-transform:uppercase;letter-spacing:.05em}
|
</div>
|
||||||
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px}
|
<div id="install-loading" style="color:var(--muted);font-size:13px">Loading disk list…</div>
|
||||||
.card{background:#161b25;border:1px solid #1e2535;border-radius:8px;padding:14px}
|
<div id="install-disk-section" style="display:none">
|
||||||
.card-title{font-size:12px;color:#64748b;margin-bottom:6px}
|
<div class="card" style="margin-bottom:0">
|
||||||
.card-value{font-size:15px;font-weight:600}
|
<table id="install-disk-table">
|
||||||
.badge{display:inline-block;padding:2px 8px;border-radius:999px;font-size:11px;font-weight:600}
|
<thead><tr><th></th><th>Device</th><th>Model</th><th>Size</th><th>Status</th></tr></thead>
|
||||||
.ok{background:#166534;color:#86efac}.warn{background:#713f12;color:#fde68a}.err{background:#7f1d1d;color:#fca5a5}
|
<tbody id="install-disk-tbody"></tbody>
|
||||||
pre{background:#0a0d14;border:1px solid #1e2535;border-radius:6px;padding:12px;font-size:11px;overflow-x:auto;color:#94a3b8;white-space:pre-wrap;word-break:break-word;max-height:400px;overflow-y:auto}
|
</table>
|
||||||
</style></head><body>
|
</div>
|
||||||
`)
|
<div style="margin-top:12px">
|
||||||
if len(snapshot) == 0 {
|
<button class="btn btn-secondary btn-sm" onclick="installRefreshDisks()">↻ Refresh</button>
|
||||||
b.WriteString(`<p style="color:#64748b">No audit snapshot available yet. Re-run audit from the Dashboard.</p>`)
|
</div>
|
||||||
b.WriteString(`</body></html>`)
|
</div>
|
||||||
return b.String()
|
<div id="install-confirm-section" style="display:none;margin-top:20px">
|
||||||
}
|
<div id="install-confirm-warn" class="alert" style="background:#fff6f6;border:1px solid #e0b4b4;color:#9f3a38;font-size:13px"></div>
|
||||||
|
<div class="form-row" style="max-width:360px">
|
||||||
|
<label>Type the device name to confirm (e.g. /dev/sda)</label>
|
||||||
|
<input type="text" id="install-confirm-input" placeholder="/dev/..." oninput="installCheckConfirm()" autocomplete="off" spellcheck="false">
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-danger" id="install-start-btn" disabled onclick="installStart()">Install to Disk</button>
|
||||||
|
<button class="btn btn-secondary" style="margin-left:8px" onclick="installDeselect()">Cancel</button>
|
||||||
|
</div>
|
||||||
|
<div id="install-progress-section" style="display:none;margin-top:20px">
|
||||||
|
<div class="card-head" style="margin-bottom:8px">Installation Progress</div>
|
||||||
|
<div id="install-terminal" class="terminal" style="max-height:500px"></div>
|
||||||
|
<div id="install-status" style="margin-top:12px;font-size:13px"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
var data map[string]any
|
<style>
|
||||||
if err := json.Unmarshal(snapshot, &data); err != nil {
|
#install-disk-tbody tr{cursor:pointer}
|
||||||
// Fallback: render raw JSON
|
#install-disk-tbody tr.selected td{background:rgba(33,133,208,.1)}
|
||||||
b.WriteString(`<pre>` + html.EscapeString(string(snapshot)) + `</pre>`)
|
#install-disk-tbody tr:hover td{background:rgba(33,133,208,.07)}
|
||||||
b.WriteString(`</body></html>`)
|
</style>
|
||||||
return b.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collected at
|
<script>
|
||||||
if t, ok := data["collected_at"].(string); ok {
|
var _installSelected = null;
|
||||||
b.WriteString(`<p style="font-size:12px;color:#64748b;margin-bottom:16px">Collected: ` + html.EscapeString(t) + `</p>`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hardware section
|
function installRefreshDisks() {
|
||||||
hw, _ := data["hardware"].(map[string]any)
|
document.getElementById('install-loading').style.display = '';
|
||||||
if hw == nil {
|
document.getElementById('install-disk-section').style.display = 'none';
|
||||||
hw = data
|
document.getElementById('install-confirm-section').style.display = 'none';
|
||||||
}
|
_installSelected = null;
|
||||||
|
fetch('/api/install/disks').then(function(r){ return r.json(); }).then(function(disks){
|
||||||
renderHWCards(&b, hw)
|
document.getElementById('install-loading').style.display = 'none';
|
||||||
|
var tbody = document.getElementById('install-disk-tbody');
|
||||||
// Full JSON below
|
tbody.innerHTML = '';
|
||||||
b.WriteString(`<h2>Raw JSON</h2>`)
|
if (!disks || disks.length === 0) {
|
||||||
pretty, _ := json.MarshalIndent(data, "", " ")
|
tbody.innerHTML = '<tr><td colspan="5" style="color:var(--muted);text-align:center">No installable disks found</td></tr>';
|
||||||
b.WriteString(`<pre>` + html.EscapeString(string(pretty)) + `</pre>`)
|
} else {
|
||||||
b.WriteString(`</body></html>`)
|
disks.forEach(function(d) {
|
||||||
return b.String()
|
var warnings = (d.warnings || []);
|
||||||
|
var statusHtml;
|
||||||
|
if (warnings.length === 0) {
|
||||||
|
statusHtml = '<span class="badge badge-ok">OK</span>';
|
||||||
|
} else {
|
||||||
|
var hasSmall = warnings.some(function(w){ return w.indexOf('too small') >= 0; });
|
||||||
|
statusHtml = warnings.map(function(w){
|
||||||
|
var cls = hasSmall ? 'badge-err' : 'badge-warn';
|
||||||
|
return '<span class="badge ' + cls + '" title="' + w.replace(/"/g,'"') + '">' +
|
||||||
|
(w.length > 40 ? w.substring(0,38)+'…' : w) + '</span>';
|
||||||
|
}).join(' ');
|
||||||
|
}
|
||||||
|
var mountedNote = (d.mounted_parts && d.mounted_parts.length > 0)
|
||||||
|
? ' <span style="color:var(--warn-fg);font-size:11px">(mounted)</span>' : '';
|
||||||
|
var tr = document.createElement('tr');
|
||||||
|
tr.dataset.device = d.device;
|
||||||
|
tr.dataset.model = d.model || 'Unknown';
|
||||||
|
tr.dataset.size = d.size;
|
||||||
|
tr.dataset.warnings = JSON.stringify(warnings);
|
||||||
|
tr.innerHTML =
|
||||||
|
'<td><input type="radio" name="install-disk" value="' + d.device + '"></td>' +
|
||||||
|
'<td><code>' + d.device + '</code>' + mountedNote + '</td>' +
|
||||||
|
'<td>' + (d.model || '—') + '</td>' +
|
||||||
|
'<td>' + d.size + '</td>' +
|
||||||
|
'<td>' + statusHtml + '</td>';
|
||||||
|
tr.addEventListener('click', function(){ installSelectDisk(this); });
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
document.getElementById('install-disk-section').style.display = '';
|
||||||
|
}).catch(function(e){
|
||||||
|
document.getElementById('install-loading').textContent = 'Failed to load disk list: ' + e;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderHWCards(b *strings.Builder, hw map[string]any) {
|
function installSelectDisk(tr) {
|
||||||
sections := []struct{ key, label string }{
|
document.querySelectorAll('#install-disk-tbody tr').forEach(function(r){ r.classList.remove('selected'); });
|
||||||
{"board", "Board"},
|
tr.classList.add('selected');
|
||||||
{"cpus", "CPUs"},
|
var radio = tr.querySelector('input[type=radio]');
|
||||||
{"memory", "Memory"},
|
if (radio) radio.checked = true;
|
||||||
{"storage", "Storage"},
|
_installSelected = {
|
||||||
{"gpus", "GPUs"},
|
device: tr.dataset.device,
|
||||||
{"nics", "NICs"},
|
model: tr.dataset.model,
|
||||||
{"psus", "Power Supplies"},
|
size: tr.dataset.size,
|
||||||
}
|
warnings: JSON.parse(tr.dataset.warnings || '[]')
|
||||||
for _, s := range sections {
|
};
|
||||||
v, ok := hw[s.key]
|
var warnBox = document.getElementById('install-confirm-warn');
|
||||||
if !ok {
|
var warnLines = '<strong>⚠ DANGER:</strong> ' + _installSelected.device +
|
||||||
continue
|
' (' + _installSelected.model + ', ' + _installSelected.size + ')' +
|
||||||
}
|
' will be <strong>completely erased</strong> and repartitioned. All data will be lost.<br>';
|
||||||
b.WriteString(`<h2>` + s.label + `</h2><div class="grid">`)
|
if (_installSelected.warnings.length > 0) {
|
||||||
renderValue(b, v)
|
warnLines += '<br>' + _installSelected.warnings.map(function(w){ return '• ' + w; }).join('<br>');
|
||||||
b.WriteString(`</div>`)
|
}
|
||||||
}
|
warnBox.innerHTML = warnLines;
|
||||||
|
document.getElementById('install-confirm-input').value = '';
|
||||||
|
document.getElementById('install-start-btn').disabled = true;
|
||||||
|
document.getElementById('install-confirm-section').style.display = '';
|
||||||
|
document.getElementById('install-progress-section').style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderValue(b *strings.Builder, v any) {
|
function installDeselect() {
|
||||||
switch val := v.(type) {
|
_installSelected = null;
|
||||||
case []any:
|
document.querySelectorAll('#install-disk-tbody tr').forEach(function(r){ r.classList.remove('selected'); });
|
||||||
for _, item := range val {
|
document.querySelectorAll('#install-disk-tbody input[type=radio]').forEach(function(r){ r.checked = false; });
|
||||||
renderValue(b, item)
|
document.getElementById('install-confirm-section').style.display = 'none';
|
||||||
}
|
|
||||||
case map[string]any:
|
|
||||||
b.WriteString(`<div class="card">`)
|
|
||||||
for k, vv := range val {
|
|
||||||
b.WriteString(fmt.Sprintf(`<div class="card-title">%s</div><div class="card-value">%s</div>`,
|
|
||||||
html.EscapeString(k), html.EscapeString(fmt.Sprintf("%v", vv))))
|
|
||||||
}
|
|
||||||
b.WriteString(`</div>`)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Export index (compatibility) ──────────────────────────────────────────────
|
function installCheckConfirm() {
|
||||||
|
var val = document.getElementById('install-confirm-input').value.trim();
|
||||||
|
var ok = _installSelected && val === _installSelected.device;
|
||||||
|
document.getElementById('install-start-btn').disabled = !ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
function installStart() {
|
||||||
|
if (!_installSelected) return;
|
||||||
|
document.getElementById('install-confirm-section').style.display = 'none';
|
||||||
|
document.getElementById('install-disk-section').style.display = 'none';
|
||||||
|
document.getElementById('install-loading').style.display = 'none';
|
||||||
|
var prog = document.getElementById('install-progress-section');
|
||||||
|
var term = document.getElementById('install-terminal');
|
||||||
|
var status = document.getElementById('install-status');
|
||||||
|
prog.style.display = '';
|
||||||
|
term.textContent = '';
|
||||||
|
status.textContent = 'Starting installation…';
|
||||||
|
status.style.color = 'var(--muted)';
|
||||||
|
|
||||||
|
fetch('/api/install/run', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({device: _installSelected.device})
|
||||||
|
}).then(function(r){
|
||||||
|
if (r.status === 204) {
|
||||||
|
installStreamLog();
|
||||||
|
} else {
|
||||||
|
return r.json().then(function(j){ throw new Error(j.error || r.statusText); });
|
||||||
|
}
|
||||||
|
}).catch(function(e){
|
||||||
|
status.textContent = 'Error: ' + e;
|
||||||
|
status.style.color = 'var(--crit-fg)';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function installStreamLog() {
|
||||||
|
var term = document.getElementById('install-terminal');
|
||||||
|
var status = document.getElementById('install-status');
|
||||||
|
var es = new EventSource('/api/install/stream');
|
||||||
|
es.onmessage = function(e) {
|
||||||
|
term.textContent += e.data + '\n';
|
||||||
|
term.scrollTop = term.scrollHeight;
|
||||||
|
};
|
||||||
|
es.addEventListener('done', function(e) {
|
||||||
|
es.close();
|
||||||
|
if (!e.data) {
|
||||||
|
status.innerHTML = '<span style="color:var(--ok-fg);font-weight:700">✓ Installation complete.</span> Remove the ISO and reboot.';
|
||||||
|
var rebootBtn = document.createElement('button');
|
||||||
|
rebootBtn.className = 'btn btn-primary btn-sm';
|
||||||
|
rebootBtn.style.marginLeft = '12px';
|
||||||
|
rebootBtn.textContent = 'Reboot now';
|
||||||
|
rebootBtn.onclick = function(){
|
||||||
|
fetch('/api/services/action', {method:'POST',headers:{'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify({name:'', action:'reboot'})});
|
||||||
|
};
|
||||||
|
status.appendChild(rebootBtn);
|
||||||
|
} else {
|
||||||
|
status.textContent = '✗ Installation failed: ' + e.data;
|
||||||
|
status.style.color = 'var(--crit-fg)';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
es.onerror = function() {
|
||||||
|
es.close();
|
||||||
|
status.textContent = '✗ Stream disconnected.';
|
||||||
|
status.style.color = 'var(--crit-fg)';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-load on page open.
|
||||||
|
installRefreshDisks();
|
||||||
|
</script>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
func renderExportIndex(exportDir string) (string, error) {
|
func renderExportIndex(exportDir string) (string, error) {
|
||||||
entries, err := listExportFiles(exportDir)
|
entries, err := listExportFiles(exportDir)
|
||||||
|
|||||||
@@ -62,16 +62,31 @@ func (r *metricsRing) snapshot() ([]float64, []string) {
|
|||||||
return v, l
|
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
|
ringCPUTemp *metricsRing
|
||||||
|
ringCPULoad *metricsRing
|
||||||
|
ringMemLoad *metricsRing
|
||||||
ringPower *metricsRing
|
ringPower *metricsRing
|
||||||
ringFans []*metricsRing
|
ringFans []*metricsRing
|
||||||
ringGPUTemp []*metricsRing
|
fanNames []string
|
||||||
ringGPUUtil []*metricsRing
|
// per-GPU rings (index = GPU index)
|
||||||
|
gpuRings []*gpuRings
|
||||||
ringsMu sync.Mutex
|
ringsMu sync.Mutex
|
||||||
|
// install job (at most one at a time)
|
||||||
|
installJob *jobState
|
||||||
|
installMu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHandler creates the HTTP mux with all routes.
|
// NewHandler creates the HTTP mux with all routes.
|
||||||
@@ -89,6 +104,8 @@ func NewHandler(opts HandlerOptions) http.Handler {
|
|||||||
h := &handler{
|
h := &handler{
|
||||||
opts: opts,
|
opts: opts,
|
||||||
ringCPUTemp: newMetricsRing(120),
|
ringCPUTemp: newMetricsRing(120),
|
||||||
|
ringCPULoad: newMetricsRing(120),
|
||||||
|
ringMemLoad: newMetricsRing(120),
|
||||||
ringPower: newMetricsRing(120),
|
ringPower: newMetricsRing(120),
|
||||||
}
|
}
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
@@ -135,12 +152,17 @@ func NewHandler(opts HandlerOptions) http.Handler {
|
|||||||
// Preflight
|
// Preflight
|
||||||
mux.HandleFunc("GET /api/preflight", h.handleAPIPreflight)
|
mux.HandleFunc("GET /api/preflight", h.handleAPIPreflight)
|
||||||
|
|
||||||
|
// Install
|
||||||
|
mux.HandleFunc("GET /api/install/disks", h.handleAPIInstallDisks)
|
||||||
|
mux.HandleFunc("POST /api/install/run", h.handleAPIInstallRun)
|
||||||
|
mux.HandleFunc("GET /api/install/stream", h.handleAPIInstallStream)
|
||||||
|
|
||||||
// Metrics — SSE stream of live sensor data + server-side SVG charts
|
// 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)
|
mux.HandleFunc("GET /api/metrics/chart/", h.handleMetricsChartSVG)
|
||||||
|
|
||||||
// Reanimator chart static assets
|
// Reanimator chart static assets (viewer template expects /static/*)
|
||||||
mux.Handle("GET /chart/static/", http.StripPrefix("/chart/static/", web.Static()))
|
mux.Handle("GET /static/", http.StripPrefix("/static/", web.Static()))
|
||||||
|
|
||||||
// ── Pages ────────────────────────────────────────────────────────────────
|
// ── Pages ────────────────────────────────────────────────────────────────
|
||||||
mux.HandleFunc("GET /", h.handlePage)
|
mux.HandleFunc("GET /", h.handlePage)
|
||||||
@@ -244,48 +266,88 @@ func (h *handler) handleViewer(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *handler) handleMetricsChartSVG(w http.ResponseWriter, r *http.Request) {
|
func (h *handler) handleMetricsChartSVG(w http.ResponseWriter, r *http.Request) {
|
||||||
name := strings.TrimPrefix(r.URL.Path, "/api/metrics/chart/")
|
path := strings.TrimPrefix(r.URL.Path, "/api/metrics/chart/")
|
||||||
name = strings.TrimSuffix(name, ".svg")
|
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"}
|
||||||
|
|
||||||
var ring *metricsRing
|
|
||||||
var title, unit string
|
|
||||||
switch name {
|
|
||||||
case "cpu-temp":
|
|
||||||
ring, title, unit = h.ringCPUTemp, "CPU Temperature", "°C"
|
|
||||||
case "power":
|
|
||||||
ring, title, unit = h.ringPower, "System Power", "W"
|
|
||||||
default:
|
default:
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
vals, labels := ring.snapshot()
|
// Ensure all datasets same length as labels
|
||||||
if len(vals) == 0 {
|
n := len(labels)
|
||||||
vals = []float64{0}
|
if n == 0 {
|
||||||
|
n = 1
|
||||||
labels = []string{""}
|
labels = []string{""}
|
||||||
}
|
}
|
||||||
|
for i := range datasets {
|
||||||
// Sparse x-axis labels
|
if len(datasets[i]) == 0 {
|
||||||
sparse := make([]string, len(labels))
|
datasets[i] = make([]float64, n)
|
||||||
step := len(labels) / 6
|
|
||||||
if step < 1 {
|
|
||||||
step = 1
|
|
||||||
}
|
|
||||||
for i := range labels {
|
|
||||||
if i%step == 0 {
|
|
||||||
sparse[i] = labels[i]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
opt := gocharts.NewLineChartOptionWithData([][]float64{vals})
|
sparse := sparseLabels(labels, 6)
|
||||||
opt.Title = gocharts.TitleOption{Text: title + " (" + unit + ")"}
|
|
||||||
|
opt := gocharts.NewLineChartOptionWithData(datasets)
|
||||||
|
opt.Title = gocharts.TitleOption{Text: title}
|
||||||
opt.XAxis.Labels = sparse
|
opt.XAxis.Labels = sparse
|
||||||
opt.Legend = gocharts.LegendOption{Show: gocharts.Ptr(false)}
|
opt.Legend = gocharts.LegendOption{SeriesNames: names}
|
||||||
|
|
||||||
p := gocharts.NewPainter(gocharts.PainterOptions{
|
p := gocharts.NewPainter(gocharts.PainterOptions{
|
||||||
OutputFormat: gocharts.ChartOutputSVG,
|
OutputFormat: gocharts.ChartOutputSVG,
|
||||||
Width: 600,
|
Width: 1400,
|
||||||
Height: 180,
|
Height: 280,
|
||||||
}, gocharts.PainterThemeOption(gocharts.GetTheme("grafana")))
|
}, gocharts.PainterThemeOption(gocharts.GetTheme("grafana")))
|
||||||
if err := p.LineChart(opt); err != nil {
|
if err := p.LineChart(opt); err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
@@ -301,6 +363,27 @@ func (h *handler) handleMetricsChartSVG(w http.ResponseWriter, r *http.Request)
|
|||||||
_, _ = w.Write(buf)
|
_, _ = 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 ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func (h *handler) handlePage(w http.ResponseWriter, r *http.Request) {
|
func (h *handler) handlePage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
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.
|
||||||
Submodule internal/chart updated: 05db6994d4...ac8120c8ab
@@ -32,6 +32,7 @@ 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 \
|
||||||
|
--compression zstd \
|
||||||
"${@}"
|
"${@}"
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ BUILDER_PLATFORM="${BEE_BUILDER_PLATFORM:-linux/amd64}"
|
|||||||
CACHE_DIR="${BEE_BUILDER_CACHE_DIR:-${REPO_ROOT}/dist/container-cache}"
|
CACHE_DIR="${BEE_BUILDER_CACHE_DIR:-${REPO_ROOT}/dist/container-cache}"
|
||||||
AUTH_KEYS=""
|
AUTH_KEYS=""
|
||||||
REBUILD_IMAGE=0
|
REBUILD_IMAGE=0
|
||||||
|
CLEAN_CACHE=0
|
||||||
|
|
||||||
. "${BUILDER_DIR}/VERSIONS"
|
. "${BUILDER_DIR}/VERSIONS"
|
||||||
|
|
||||||
@@ -28,14 +29,31 @@ while [ $# -gt 0 ]; do
|
|||||||
AUTH_KEYS="$2"
|
AUTH_KEYS="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
|
--clean-build)
|
||||||
|
CLEAN_CACHE=1
|
||||||
|
REBUILD_IMAGE=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
*)
|
*)
|
||||||
echo "unknown arg: $1" >&2
|
echo "unknown arg: $1" >&2
|
||||||
echo "usage: $0 [--cache-dir /path] [--rebuild-image] [--authorized-keys /path/to/authorized_keys]" >&2
|
echo "usage: $0 [--cache-dir /path] [--rebuild-image] [--clean-build] [--authorized-keys /path/to/authorized_keys]" >&2
|
||||||
exit 1
|
exit 1
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
|
if [ "$CLEAN_CACHE" = "1" ]; then
|
||||||
|
echo "=== cleaning build cache: ${CACHE_DIR} ==="
|
||||||
|
rm -rf "${CACHE_DIR:?}/go-build" \
|
||||||
|
"${CACHE_DIR:?}/go-mod" \
|
||||||
|
"${CACHE_DIR:?}/tmp" \
|
||||||
|
"${CACHE_DIR:?}/bee" \
|
||||||
|
"${CACHE_DIR:?}/lb-packages"
|
||||||
|
echo "=== cleaning live-build work dir: ${REPO_ROOT}/dist/live-build-work ==="
|
||||||
|
rm -rf "${REPO_ROOT}/dist/live-build-work"
|
||||||
|
echo "=== caches cleared, proceeding with build ==="
|
||||||
|
fi
|
||||||
|
|
||||||
if ! command -v "$CONTAINER_TOOL" >/dev/null 2>&1; then
|
if ! command -v "$CONTAINER_TOOL" >/dev/null 2>&1; then
|
||||||
echo "container tool not found: $CONTAINER_TOOL" >&2
|
echo "container tool not found: $CONTAINER_TOOL" >&2
|
||||||
exit 1
|
exit 1
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ done
|
|||||||
|
|
||||||
. "${BUILDER_DIR}/VERSIONS"
|
. "${BUILDER_DIR}/VERSIONS"
|
||||||
export PATH="$PATH:/usr/local/go/bin"
|
export PATH="$PATH:/usr/local/go/bin"
|
||||||
|
|
||||||
|
# Allow git to read the bind-mounted repo (different UID inside container).
|
||||||
|
git config --global safe.directory "${REPO_ROOT}"
|
||||||
mkdir -p "${DIST_DIR}"
|
mkdir -p "${DIST_DIR}"
|
||||||
mkdir -p "${CACHE_ROOT}"
|
mkdir -p "${CACHE_ROOT}"
|
||||||
: "${GOCACHE:=${CACHE_ROOT}/go-build}"
|
: "${GOCACHE:=${CACHE_ROOT}/go-build}"
|
||||||
@@ -42,7 +45,7 @@ resolve_audit_version() {
|
|||||||
|
|
||||||
tag="$(git -C "${REPO_ROOT}" describe --tags --match 'audit/v*' --abbrev=7 --dirty 2>/dev/null || true)"
|
tag="$(git -C "${REPO_ROOT}" describe --tags --match 'audit/v*' --abbrev=7 --dirty 2>/dev/null || true)"
|
||||||
if [ -z "${tag}" ]; then
|
if [ -z "${tag}" ]; then
|
||||||
tag="$(git -C "${REPO_ROOT}" describe --tags --match 'v*' --abbrev=7 --dirty 2>/dev/null || true)"
|
tag="$(git -C "${REPO_ROOT}" describe --tags --match 'v[0-9]*' --abbrev=7 --dirty 2>/dev/null || true)"
|
||||||
fi
|
fi
|
||||||
case "${tag}" in
|
case "${tag}" in
|
||||||
audit/v*)
|
audit/v*)
|
||||||
@@ -76,6 +79,16 @@ resolve_iso_version() {
|
|||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Plain v* tags (e.g. v2.7) take priority — this is the current tagging scheme
|
||||||
|
tag="$(git -C "${REPO_ROOT}" describe --tags --match 'v[0-9]*' --abbrev=7 --dirty 2>/dev/null || true)"
|
||||||
|
case "${tag}" in
|
||||||
|
v*)
|
||||||
|
echo "${tag#v}"
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Legacy iso/v* tags fallback
|
||||||
tag="$(git -C "${REPO_ROOT}" describe --tags --match 'iso/v*' --abbrev=7 --dirty 2>/dev/null || true)"
|
tag="$(git -C "${REPO_ROOT}" describe --tags --match 'iso/v*' --abbrev=7 --dirty 2>/dev/null || true)"
|
||||||
case "${tag}" in
|
case "${tag}" in
|
||||||
iso/v*)
|
iso/v*)
|
||||||
@@ -187,9 +200,27 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo "=== preparing staged overlay ==="
|
echo "=== preparing staged overlay ==="
|
||||||
rm -rf "${BUILD_WORK_DIR}" "${OVERLAY_STAGE_DIR}"
|
# Sync builder config into work dir, preserving lb cache (chroot + packages).
|
||||||
|
# We do NOT rm -rf BUILD_WORK_DIR so lb can reuse its chroot on repeat builds.
|
||||||
mkdir -p "${BUILD_WORK_DIR}" "${OVERLAY_STAGE_DIR}"
|
mkdir -p "${BUILD_WORK_DIR}" "${OVERLAY_STAGE_DIR}"
|
||||||
rsync -a "${BUILDER_DIR}/" "${BUILD_WORK_DIR}/"
|
rsync -a --delete \
|
||||||
|
--exclude='cache/' \
|
||||||
|
--exclude='chroot/' \
|
||||||
|
--exclude='.build/' \
|
||||||
|
--exclude='*.iso' \
|
||||||
|
--exclude='*.packages' \
|
||||||
|
--exclude='*.contents' \
|
||||||
|
--exclude='*.files' \
|
||||||
|
"${BUILDER_DIR}/" "${BUILD_WORK_DIR}/"
|
||||||
|
# Also persist package cache to CACHE_ROOT so it survives a manual wipe of BUILD_WORK_DIR.
|
||||||
|
LB_PKG_CACHE="${CACHE_ROOT}/lb-packages"
|
||||||
|
mkdir -p "${LB_PKG_CACHE}"
|
||||||
|
if [ -d "${BUILD_WORK_DIR}/cache/packages.chroot" ]; then
|
||||||
|
rsync -a --delete "${BUILD_WORK_DIR}/cache/packages.chroot/" "${LB_PKG_CACHE}/"
|
||||||
|
elif [ -d "${LB_PKG_CACHE}" ] && [ "$(ls -A "${LB_PKG_CACHE}" 2>/dev/null)" ]; then
|
||||||
|
mkdir -p "${BUILD_WORK_DIR}/cache/packages.chroot"
|
||||||
|
rsync -a "${LB_PKG_CACHE}/" "${BUILD_WORK_DIR}/cache/packages.chroot/"
|
||||||
|
fi
|
||||||
rsync -a "${OVERLAY_DIR}/" "${OVERLAY_STAGE_DIR}/"
|
rsync -a "${OVERLAY_DIR}/" "${OVERLAY_STAGE_DIR}/"
|
||||||
rm -f \
|
rm -f \
|
||||||
"${OVERLAY_STAGE_DIR}/etc/bee-ssh-password-fallback" \
|
"${OVERLAY_STAGE_DIR}/etc/bee-ssh-password-fallback" \
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
32
iso/builder/config/hooks/normal/9999-slim.hook.chroot
Executable file
32
iso/builder/config/hooks/normal/9999-slim.hook.chroot
Executable file
@@ -0,0 +1,32 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# 9999-slim.hook.chroot — strip non-essential files to reduce squashfs size.
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# ── Man pages and documentation ───────────────────────────────────────────────
|
||||||
|
find /usr/share/man -mindepth 1 -delete 2>/dev/null || true
|
||||||
|
find /usr/share/doc -mindepth 1 ! -name 'copyright' -delete 2>/dev/null || true
|
||||||
|
find /usr/share/info -mindepth 1 -delete 2>/dev/null || true
|
||||||
|
find /usr/share/groff -mindepth 1 -delete 2>/dev/null || true
|
||||||
|
find /usr/share/lintian -mindepth 1 -delete 2>/dev/null || true
|
||||||
|
|
||||||
|
# ── Locales — keep only C and en_US ──────────────────────────────────────────
|
||||||
|
find /usr/share/locale -mindepth 1 -maxdepth 1 \
|
||||||
|
! -name 'en' ! -name 'en_US' ! -name 'locale.alias' \
|
||||||
|
-exec rm -rf {} + 2>/dev/null || true
|
||||||
|
find /usr/share/i18n/locales -mindepth 1 \
|
||||||
|
! -name 'en_US' ! -name 'i18n' ! -name 'iso14651_t1' ! -name 'iso14651_t1_common' \
|
||||||
|
-delete 2>/dev/null || true
|
||||||
|
|
||||||
|
# ── Python cache ──────────────────────────────────────────────────────────────
|
||||||
|
find /usr /opt -name '__pycache__' -type d -exec rm -rf {} + 2>/dev/null || true
|
||||||
|
find /usr /opt -name '*.pyc' -delete 2>/dev/null || true
|
||||||
|
|
||||||
|
# ── APT cache and lists ───────────────────────────────────────────────────────
|
||||||
|
apt-get clean
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# ── Misc ──────────────────────────────────────────────────────────────────────
|
||||||
|
rm -rf /tmp/* /var/tmp/* 2>/dev/null || true
|
||||||
|
find /var/log -type f -delete 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "=== slim: done ==="
|
||||||
@@ -7,4 +7,5 @@ EndSection
|
|||||||
Section "Screen"
|
Section "Screen"
|
||||||
Identifier "screen0"
|
Identifier "screen0"
|
||||||
Device "fbdev"
|
Device "fbdev"
|
||||||
|
DefaultDepth 24
|
||||||
EndSection
|
EndSection
|
||||||
|
|||||||
@@ -12,6 +12,6 @@
|
|||||||
Export dir: /appdata/bee/export
|
Export dir: /appdata/bee/export
|
||||||
Self-check: /appdata/bee/export/runtime-health.json
|
Self-check: /appdata/bee/export/runtime-health.json
|
||||||
|
|
||||||
Open TUI: bee-tui
|
Web UI: http://<ip>/
|
||||||
|
|
||||||
SSH access: key auth (developers) or bee/eeb (password fallback)
|
SSH access: key auth (developers) or bee/eeb (password fallback)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
[Journal]
|
[Journal]
|
||||||
# Do not forward service logs to the console — bee-tui runs on tty1
|
# Do not forward service logs to the console — prevents log spam on
|
||||||
# and log spam makes the screen unusable on physical monitors.
|
# physical monitors and the local openbox desktop.
|
||||||
ForwardToConsole=no
|
ForwardToConsole=no
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
[Service]
|
||||||
|
# On server hardware without a usable framebuffer X may fail to start.
|
||||||
|
# Limit restarts so the console is not flooded on headless deployments.
|
||||||
|
RestartSec=10
|
||||||
|
StartLimitIntervalSec=60
|
||||||
|
StartLimitBurst=3
|
||||||
@@ -1,50 +1,163 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# Quick network configurator for the local console.
|
# Quick network configurator for the local console.
|
||||||
|
# Type 'a' at any prompt to abort, 'b' to go back.
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
# List interfaces (exclude lo)
|
abort() { echo "Aborted."; exit 0; }
|
||||||
IFACES=$(ip -o link show | awk -F': ' '$2 != "lo" {print $2}' | cut -d@ -f1)
|
|
||||||
|
|
||||||
echo "Interfaces:"
|
ask() {
|
||||||
i=1
|
# ask VARNAME "prompt" [default]
|
||||||
for iface in $IFACES; do
|
# Sets VARNAME. Returns 1 on 'b' (back), calls abort on 'a'.
|
||||||
ip=$(ip -4 addr show "$iface" 2>/dev/null | awk '/inet /{print $2}' | head -1)
|
_var="$1"; _prompt="$2"; _default="$3"
|
||||||
echo " $i) $iface ${ip:-no IP}"
|
while true; do
|
||||||
i=$((i+1))
|
if [ -n "$_default" ]; then
|
||||||
done
|
printf "%s [%s] (b=back a=abort): " "$_prompt" "$_default"
|
||||||
echo ""
|
else
|
||||||
printf "Interface name [or Enter to pick first]: "
|
printf "%s (b=back a=abort): " "$_prompt"
|
||||||
read IFACE
|
fi
|
||||||
if [ -z "$IFACE" ]; then
|
read _input
|
||||||
IFACE=$(echo "$IFACES" | head -1)
|
case "$_input" in
|
||||||
fi
|
a|A) abort ;;
|
||||||
echo "Selected: $IFACE"
|
b|B) return 1 ;;
|
||||||
echo ""
|
"")
|
||||||
echo " 1) DHCP"
|
if [ -n "$_default" ]; then
|
||||||
echo " 2) Static"
|
eval "$_var=\"\$_default\""
|
||||||
printf "Mode [1]: "
|
return 0
|
||||||
read MODE
|
else
|
||||||
MODE=${MODE:-1}
|
echo " Required — please enter a value."
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
eval "$_var=\"\$_input\""
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
if [ "$MODE" = "1" ]; then
|
# ── Step 1: choose interface ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
choose_iface() {
|
||||||
|
IFACES=$(ip -o link show | awk -F': ' '$2 != "lo" {print $2}' | cut -d@ -f1)
|
||||||
|
if [ -z "$IFACES" ]; then
|
||||||
|
echo "No network interfaces found."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Interfaces:"
|
||||||
|
i=1
|
||||||
|
for iface in $IFACES; do
|
||||||
|
ip=$(ip -4 addr show "$iface" 2>/dev/null | awk '/inet /{print $2}' | head -1)
|
||||||
|
echo " $i) $iface ${ip:-no IP}"
|
||||||
|
i=$((i+1))
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
FIRST=$(echo "$IFACES" | head -1)
|
||||||
|
while true; do
|
||||||
|
printf "Interface number or name [%s] (a=abort): " "$FIRST"
|
||||||
|
read INPUT
|
||||||
|
case "$INPUT" in
|
||||||
|
a|A) abort ;;
|
||||||
|
"")
|
||||||
|
IFACE="$FIRST"
|
||||||
|
break
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
if echo "$INPUT" | grep -qE '^[0-9]+$'; then
|
||||||
|
IFACE=$(echo "$IFACES" | awk "NR==$INPUT")
|
||||||
|
if [ -z "$IFACE" ]; then
|
||||||
|
echo " No interface #$INPUT — try again."
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# Validate name exists
|
||||||
|
if ! echo "$IFACES" | grep -qx "$INPUT"; then
|
||||||
|
echo " Unknown interface '$INPUT' — try again."
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
IFACE="$INPUT"
|
||||||
|
fi
|
||||||
|
break
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
echo "Selected: $IFACE"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Step 2: choose mode ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
choose_mode() {
|
||||||
|
echo ""
|
||||||
|
echo " 1) DHCP"
|
||||||
|
echo " 2) Static IP"
|
||||||
|
echo ""
|
||||||
|
while true; do
|
||||||
|
printf "Mode [1] (b=back a=abort): "
|
||||||
|
read INPUT
|
||||||
|
case "$INPUT" in
|
||||||
|
a|A) abort ;;
|
||||||
|
b|B) return 1 ;;
|
||||||
|
""|1) MODE=dhcp; break ;;
|
||||||
|
2) MODE=static; break ;;
|
||||||
|
*) echo " Enter 1 or 2." ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Step 3a: DHCP ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
run_dhcp() {
|
||||||
echo "Running DHCP on $IFACE..."
|
echo "Running DHCP on $IFACE..."
|
||||||
dhclient -v "$IFACE"
|
dhclient -v "$IFACE"
|
||||||
else
|
}
|
||||||
printf "IP address (e.g. 192.168.1.100/24): "
|
|
||||||
read ADDR
|
# ── Step 3b: Static ───────────────────────────────────────────────────────────
|
||||||
printf "Gateway (e.g. 192.168.1.1): "
|
|
||||||
read GW
|
run_static() {
|
||||||
printf "DNS [8.8.8.8]: "
|
while true; do
|
||||||
read DNS
|
ask ADDR "IP address (e.g. 192.168.1.100/24)" || return 1
|
||||||
DNS=${DNS:-8.8.8.8}
|
# Basic format check: must contain a dot and a /
|
||||||
|
if ! echo "$ADDR" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/[0-9]+$'; then
|
||||||
|
echo " Invalid format — use x.x.x.x/prefix (e.g. 192.168.1.10/24)."
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
break
|
||||||
|
done
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
ask GW "Gateway (e.g. 192.168.1.1)" || return 1
|
||||||
|
if ! echo "$GW" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||||
|
echo " Invalid IP address."
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
break
|
||||||
|
done
|
||||||
|
|
||||||
|
ask DNS "DNS server" "8.8.8.8" || return 1
|
||||||
|
|
||||||
ip addr flush dev "$IFACE"
|
ip addr flush dev "$IFACE"
|
||||||
ip addr add "$ADDR" dev "$IFACE"
|
ip addr add "$ADDR" dev "$IFACE"
|
||||||
ip link set "$IFACE" up
|
ip link set "$IFACE" up
|
||||||
ip route add default via "$GW"
|
ip route add default via "$GW" 2>/dev/null || true
|
||||||
echo "nameserver $DNS" > /etc/resolv.conf
|
echo "nameserver $DNS" > /etc/resolv.conf
|
||||||
echo "Done."
|
echo "Done."
|
||||||
fi
|
}
|
||||||
|
|
||||||
|
# ── Main loop ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
choose_iface
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
choose_mode || { choose_iface; continue; }
|
||||||
|
|
||||||
|
if [ "$MODE" = "dhcp" ]; then
|
||||||
|
run_dhcp && break
|
||||||
|
else
|
||||||
|
run_static && break || continue
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
ip -4 addr show "$IFACE"
|
ip -4 addr show "$IFACE"
|
||||||
|
|||||||
Reference in New Issue
Block a user