Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 911745e4da | |||
| acfd2010d7 | |||
| e904c13790 | |||
| 24c5c72cee | |||
| 6ff0bcad56 | |||
| 4fef26000c | |||
| a393dcb731 | |||
| 9e55728053 | |||
| 4b8023c1cb | |||
| 4c8417d20a | |||
| 0755374dd2 | |||
| c70ae274fa | |||
| 23ad7ff534 | |||
| de130966f7 | |||
| c6fbfc8306 | |||
| 35ad1c74d9 | |||
| 4a02e74b17 | |||
| cd2853ad99 | |||
| 6caf771d6e | |||
| 14fa87b7d7 | |||
| 600ece911b | |||
| 2d424c63cb | |||
| 50f28d1ee6 | |||
| 3579747ae3 | |||
| 09dc7d2613 |
@@ -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))
|
|
||||||
}
|
|
||||||
@@ -334,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
|
||||||
@@ -377,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"
|
||||||
@@ -13,10 +14,14 @@ 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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -155,8 +155,11 @@ func (h *handler) handleAPISATRun(target string) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
id := newJobID("sat-" + target)
|
id := newJobID("sat-" + target)
|
||||||
j := globalJobs.create(id)
|
j := globalJobs.create(id)
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
j.cancel = cancel
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
|
defer cancel()
|
||||||
j.append(fmt.Sprintf("Starting %s acceptance test...", target))
|
j.append(fmt.Sprintf("Starting %s acceptance test...", target))
|
||||||
var (
|
var (
|
||||||
archive string
|
archive string
|
||||||
@@ -178,7 +181,7 @@ func (h *handler) handleAPISATRun(target string) http.HandlerFunc {
|
|||||||
case "nvidia":
|
case "nvidia":
|
||||||
if len(body.GPUIndices) > 0 || body.DiagLevel > 0 {
|
if len(body.GPUIndices) > 0 || body.DiagLevel > 0 {
|
||||||
result, e := h.opts.App.RunNvidiaAcceptancePackWithOptions(
|
result, e := h.opts.App.RunNvidiaAcceptancePackWithOptions(
|
||||||
context.Background(), "", body.DiagLevel, body.GPUIndices,
|
ctx, "", body.DiagLevel, body.GPUIndices,
|
||||||
)
|
)
|
||||||
if e != nil {
|
if e != nil {
|
||||||
err = e
|
err = e
|
||||||
@@ -201,8 +204,13 @@ func (h *handler) handleAPISATRun(target string) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
j.append("Aborted.")
|
||||||
|
j.finish("aborted")
|
||||||
|
} else {
|
||||||
j.append("ERROR: " + err.Error())
|
j.append("ERROR: " + err.Error())
|
||||||
j.finish(err.Error())
|
j.finish(err.Error())
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
j.append(fmt.Sprintf("Archive written: %s", archive))
|
j.append(fmt.Sprintf("Archive written: %s", archive))
|
||||||
@@ -223,6 +231,20 @@ func (h *handler) handleAPISATStream(w http.ResponseWriter, r *http.Request) {
|
|||||||
streamJob(w, r, j)
|
streamJob(w, r, j)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *handler) handleAPISATAbort(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.URL.Query().Get("job_id")
|
||||||
|
j, ok := globalJobs.get(id)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "job not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if j.abort() {
|
||||||
|
writeJSON(w, map[string]string{"status": "aborted"})
|
||||||
|
} else {
|
||||||
|
writeJSON(w, map[string]string{"status": "not_running"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Services ──────────────────────────────────────────────────────────────────
|
// ── Services ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func (h *handler) handleAPIServicesList(w http.ResponseWriter, r *http.Request) {
|
func (h *handler) handleAPIServicesList(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -409,6 +431,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) {
|
||||||
|
|||||||
@@ -11,8 +11,19 @@ type jobState struct {
|
|||||||
done bool
|
done bool
|
||||||
err string
|
err string
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
// subs is a list of channels that receive new lines as they arrive.
|
|
||||||
subs []chan string
|
subs []chan string
|
||||||
|
cancel func() // optional cancel function; nil if job is not cancellable
|
||||||
|
}
|
||||||
|
|
||||||
|
// abort cancels the job if it has a cancel function and is not yet done.
|
||||||
|
func (j *jobState) abort() bool {
|
||||||
|
j.mu.Lock()
|
||||||
|
defer j.mu.Unlock()
|
||||||
|
if j.done || j.cancel == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
j.cancel()
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (j *jobState) append(line string) {
|
func (j *jobState) append(line string) {
|
||||||
@@ -76,6 +87,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,7 +239,7 @@ 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 metrics — updated every 2 seconds. Charts use go-analyze/charts (grafana theme).</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="card" style="margin-bottom:16px">
|
<div class="card" style="margin-bottom:16px">
|
||||||
<div class="card-head">Server</div>
|
<div class="card-head">Server</div>
|
||||||
@@ -296,7 +293,7 @@ es.addEventListener('metrics', e => {
|
|||||||
(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>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>';
|
||||||
const st = document.getElementById('sys-table');
|
const st = document.getElementById('sys-table');
|
||||||
if (st) st.innerHTML = sysHTML ? '<table>'+sysHTML+'</table>' : '<p style="color:#64748b">No sensor data (ipmitool/sensors required)</p>';
|
if (st) st.innerHTML = sysHTML ? '<table>'+sysHTML+'</table>' : '<p style="color:var(--muted)">No sensor data (ipmitool/sensors required)</p>';
|
||||||
|
|
||||||
(d.gpus||[]).forEach(g => {
|
(d.gpus||[]).forEach(g => {
|
||||||
const t = document.getElementById('gpu-table-' + g.index);
|
const t = document.getElementById('gpu-table-' + g.index);
|
||||||
@@ -315,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", "") +
|
||||||
@@ -356,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>
|
||||||
@@ -396,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>
|
||||||
@@ -411,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>
|
||||||
@@ -422,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() {
|
||||||
@@ -455,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>
|
||||||
@@ -472,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> ' +
|
||||||
@@ -510,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">
|
||||||
@@ -550,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>'
|
||||||
@@ -566,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>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
#install-disk-tbody tr{cursor:pointer}
|
||||||
|
#install-disk-tbody tr.selected td{background:rgba(33,133,208,.1)}
|
||||||
|
#install-disk-tbody tr:hover td{background:rgba(33,133,208,.07)}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
var _installSelected = null;
|
||||||
|
|
||||||
|
function installRefreshDisks() {
|
||||||
|
document.getElementById('install-loading').style.display = '';
|
||||||
|
document.getElementById('install-disk-section').style.display = 'none';
|
||||||
|
document.getElementById('install-confirm-section').style.display = 'none';
|
||||||
|
_installSelected = null;
|
||||||
|
fetch('/api/install/disks').then(function(r){ return r.json(); }).then(function(disks){
|
||||||
|
document.getElementById('install-loading').style.display = 'none';
|
||||||
|
var tbody = document.getElementById('install-disk-tbody');
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
if (!disks || disks.length === 0) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="5" style="color:var(--muted);text-align:center">No installable disks found</td></tr>';
|
||||||
|
} else {
|
||||||
|
disks.forEach(function(d) {
|
||||||
|
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;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
var data map[string]any
|
function installSelectDisk(tr) {
|
||||||
if err := json.Unmarshal(snapshot, &data); err != nil {
|
document.querySelectorAll('#install-disk-tbody tr').forEach(function(r){ r.classList.remove('selected'); });
|
||||||
// Fallback: render raw JSON
|
tr.classList.add('selected');
|
||||||
b.WriteString(`<pre>` + html.EscapeString(string(snapshot)) + `</pre>`)
|
var radio = tr.querySelector('input[type=radio]');
|
||||||
b.WriteString(`</body></html>`)
|
if (radio) radio.checked = true;
|
||||||
return b.String()
|
_installSelected = {
|
||||||
|
device: tr.dataset.device,
|
||||||
|
model: tr.dataset.model,
|
||||||
|
size: tr.dataset.size,
|
||||||
|
warnings: JSON.parse(tr.dataset.warnings || '[]')
|
||||||
|
};
|
||||||
|
var warnBox = document.getElementById('install-confirm-warn');
|
||||||
|
var warnLines = '<strong>⚠ DANGER:</strong> ' + _installSelected.device +
|
||||||
|
' (' + _installSelected.model + ', ' + _installSelected.size + ')' +
|
||||||
|
' will be <strong>completely erased</strong> and repartitioned. All data will be lost.<br>';
|
||||||
|
if (_installSelected.warnings.length > 0) {
|
||||||
|
warnLines += '<br>' + _installSelected.warnings.map(function(w){ return '• ' + w; }).join('<br>');
|
||||||
|
}
|
||||||
|
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';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collected at
|
function installDeselect() {
|
||||||
if t, ok := data["collected_at"].(string); ok {
|
_installSelected = null;
|
||||||
b.WriteString(`<p style="font-size:12px;color:#64748b;margin-bottom:16px">Collected: ` + html.EscapeString(t) + `</p>`)
|
document.querySelectorAll('#install-disk-tbody tr').forEach(function(r){ r.classList.remove('selected'); });
|
||||||
|
document.querySelectorAll('#install-disk-tbody input[type=radio]').forEach(function(r){ r.checked = false; });
|
||||||
|
document.getElementById('install-confirm-section').style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hardware section
|
function installCheckConfirm() {
|
||||||
hw, _ := data["hardware"].(map[string]any)
|
var val = document.getElementById('install-confirm-input').value.trim();
|
||||||
if hw == nil {
|
var ok = _installSelected && val === _installSelected.device;
|
||||||
hw = data
|
document.getElementById('install-start-btn').disabled = !ok;
|
||||||
}
|
}
|
||||||
|
|
||||||
renderHWCards(&b, hw)
|
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)';
|
||||||
|
|
||||||
// Full JSON below
|
fetch('/api/install/run', {
|
||||||
b.WriteString(`<h2>Raw JSON</h2>`)
|
method: 'POST',
|
||||||
pretty, _ := json.MarshalIndent(data, "", " ")
|
headers: {'Content-Type': 'application/json'},
|
||||||
b.WriteString(`<pre>` + html.EscapeString(string(pretty)) + `</pre>`)
|
body: JSON.stringify({device: _installSelected.device})
|
||||||
b.WriteString(`</body></html>`)
|
}).then(function(r){
|
||||||
return b.String()
|
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)';
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderHWCards(b *strings.Builder, hw map[string]any) {
|
function installStreamLog() {
|
||||||
sections := []struct{ key, label string }{
|
var term = document.getElementById('install-terminal');
|
||||||
{"board", "Board"},
|
var status = document.getElementById('install-status');
|
||||||
{"cpus", "CPUs"},
|
var es = new EventSource('/api/install/stream');
|
||||||
{"memory", "Memory"},
|
es.onmessage = function(e) {
|
||||||
{"storage", "Storage"},
|
term.textContent += e.data + '\n';
|
||||||
{"gpus", "GPUs"},
|
term.scrollTop = term.scrollHeight;
|
||||||
{"nics", "NICs"},
|
};
|
||||||
{"psus", "Power Supplies"},
|
es.addEventListener('done', function(e) {
|
||||||
}
|
es.close();
|
||||||
for _, s := range sections {
|
if (!e.data) {
|
||||||
v, ok := hw[s.key]
|
status.innerHTML = '<span style="color:var(--ok-fg);font-weight:700">✓ Installation complete.</span> Remove the ISO and reboot.';
|
||||||
if !ok {
|
var rebootBtn = document.createElement('button');
|
||||||
continue
|
rebootBtn.className = 'btn btn-primary btn-sm';
|
||||||
}
|
rebootBtn.style.marginLeft = '12px';
|
||||||
b.WriteString(`<h2>` + s.label + `</h2><div class="grid">`)
|
rebootBtn.textContent = 'Reboot now';
|
||||||
renderValue(b, v)
|
rebootBtn.onclick = function(){
|
||||||
b.WriteString(`</div>`)
|
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)';
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderValue(b *strings.Builder, v any) {
|
// Auto-load on page open.
|
||||||
switch val := v.(type) {
|
installRefreshDisks();
|
||||||
case []any:
|
</script>
|
||||||
for _, item := range val {
|
`
|
||||||
renderValue(b, item)
|
|
||||||
}
|
}
|
||||||
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) ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
func renderExportIndex(exportDir string) (string, error) {
|
func renderExportIndex(exportDir string) (string, error) {
|
||||||
entries, err := listExportFiles(exportDir)
|
entries, err := listExportFiles(exportDir)
|
||||||
|
|||||||
@@ -84,6 +84,9 @@ type handler struct {
|
|||||||
// per-GPU rings (index = GPU index)
|
// per-GPU rings (index = GPU index)
|
||||||
gpuRings []*gpuRings
|
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.
|
||||||
@@ -129,6 +132,7 @@ func NewHandler(opts HandlerOptions) http.Handler {
|
|||||||
mux.HandleFunc("POST /api/sat/storage/run", h.handleAPISATRun("storage"))
|
mux.HandleFunc("POST /api/sat/storage/run", h.handleAPISATRun("storage"))
|
||||||
mux.HandleFunc("POST /api/sat/cpu/run", h.handleAPISATRun("cpu"))
|
mux.HandleFunc("POST /api/sat/cpu/run", h.handleAPISATRun("cpu"))
|
||||||
mux.HandleFunc("GET /api/sat/stream", h.handleAPISATStream)
|
mux.HandleFunc("GET /api/sat/stream", h.handleAPISATStream)
|
||||||
|
mux.HandleFunc("POST /api/sat/abort", h.handleAPISATAbort)
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
mux.HandleFunc("GET /api/services", h.handleAPIServicesList)
|
mux.HandleFunc("GET /api/services", h.handleAPIServicesList)
|
||||||
@@ -149,12 +153,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)
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ Key checks: NVIDIA modules loaded, `nvidia-smi` sees all GPUs, lib symlinks pres
|
|||||||
systemd services running, audit completed with NVIDIA enrichment, LAN reachability.
|
systemd services running, audit completed with NVIDIA enrichment, LAN reachability.
|
||||||
|
|
||||||
Current validation state:
|
Current validation state:
|
||||||
- local/libvirt VM boot path is validated for `systemd`, SSH, `bee audit`, `bee-network`, and TUI startup
|
- local/libvirt VM boot path is validated for `systemd`, SSH, `bee audit`, `bee-network`, and Web UI startup
|
||||||
- real hardware validation is still required before treating the ISO as release-ready
|
- real hardware validation is still required before treating the ISO as release-ready
|
||||||
|
|
||||||
## Overlay mechanism
|
## Overlay mechanism
|
||||||
@@ -168,33 +168,17 @@ Acceptance flows:
|
|||||||
- `BEE_MEMTESTER_SIZE_MB`
|
- `BEE_MEMTESTER_SIZE_MB`
|
||||||
- `BEE_MEMTESTER_PASSES`
|
- `BEE_MEMTESTER_PASSES`
|
||||||
|
|
||||||
## NVIDIA SAT TUI flow (v1.0.0+)
|
## NVIDIA SAT Web UI flow
|
||||||
|
|
||||||
```
|
```
|
||||||
TUI: Acceptance tests → NVIDIA command pack
|
Web UI: Acceptance Tests page → Run Test button
|
||||||
1. screenNvidiaSATSetup
|
1. POST /api/sat/nvidia/run → returns job_id
|
||||||
a. enumerate GPUs via `nvidia-smi --query-gpu=index,name,memory.total`
|
2. GET /api/sat/stream?job_id=... (SSE) — streams stdout/stderr lines live
|
||||||
b. user selects duration preset: 10 min / 1 h / 8 h / 24 h
|
3. After completion — archive written to /appdata/bee/export/bee-sat/
|
||||||
c. user selects GPUs via checkboxes (all selected by default)
|
summary.txt contains overall_status (OK / FAILED) and per-job status values
|
||||||
d. memory size = max(selected GPU memory) — auto-detected, not exposed to user
|
|
||||||
2. Start → screenNvidiaSATRunning
|
|
||||||
a. CUDA_VISIBLE_DEVICES set to selected GPU indices
|
|
||||||
b. tea.Batch: SAT goroutine + tea.ExecProcess(nvtop) launched concurrently
|
|
||||||
c. nvtop occupies full terminal; SAT result queues in background
|
|
||||||
d. [o] reopen nvtop at any time; [a] abort (cancels context → kills bee-gpu-stress)
|
|
||||||
3. GPU metrics collection (during bee-gpu-stress)
|
|
||||||
- background goroutine polls `nvidia-smi` every second
|
|
||||||
- per-second rows: elapsed, GPU index, temp°C, usage%, power W, clock MHz
|
|
||||||
- outputs: gpu-metrics.csv, gpu-metrics.html (offline SVG chart), gpu-metrics-term.txt
|
|
||||||
4. After SAT completes
|
|
||||||
- result shown in screenOutput with terminal line-chart (gpu-metrics-term.txt)
|
|
||||||
- chart is asciigraph-style: box-drawing chars (╭╮╰╯─│), 4 series per GPU,
|
|
||||||
Y axis with ticks, ANSI colours (red=temp, blue=usage, green=power, yellow=clock)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Critical invariants:**
|
**Critical invariants:**
|
||||||
- `nvtop` must be in `iso/builder/config/package-lists/bee.list.chroot` (baked into ISO).
|
- `bee-gpu-stress` uses `exec.CommandContext` — killed on job context cancel.
|
||||||
- `bee-gpu-stress` uses `exec.CommandContext` — aborted on cancel.
|
|
||||||
- Metric goroutine uses stopCh/doneCh pattern; main goroutine waits `<-doneCh` before reading rows (no mutex needed).
|
- Metric goroutine uses stopCh/doneCh pattern; main goroutine waits `<-doneCh` before reading rows (no mutex needed).
|
||||||
- If `nvtop` is not found on PATH, SAT still runs without it (graceful degradation).
|
|
||||||
- SVG chart is fully offline: no JS, no external CSS, pure inline SVG.
|
- SVG chart is fully offline: no JS, no external CSS, pure inline SVG.
|
||||||
|
|||||||
Submodule internal/chart updated: 05db6994d4...ac8120c8ab
@@ -8,5 +8,8 @@ NCCL_TESTS_VERSION=2.13.10
|
|||||||
NVCC_VERSION=12.8
|
NVCC_VERSION=12.8
|
||||||
CUBLAS_VERSION=13.0.2.14-1
|
CUBLAS_VERSION=13.0.2.14-1
|
||||||
CUDA_USERSPACE_VERSION=13.0.96-1
|
CUDA_USERSPACE_VERSION=13.0.96-1
|
||||||
|
DCGM_VERSION=3.3.9
|
||||||
|
ROCM_VERSION=6.3.4
|
||||||
|
ROCM_SMI_VERSION=7.4.0.60304-76~22.04
|
||||||
GO_VERSION=1.24.0
|
GO_VERSION=1.24.0
|
||||||
AUDIT_VERSION=1.0.0
|
AUDIT_VERSION=1.0.0
|
||||||
|
|||||||
@@ -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 video=1920x1080 console=tty0 console=ttyS0,115200n8 loglevel=3 username=bee user-fullname=Bee modprobe.blacklist=nouveau" \
|
--bootappend-live "boot=live components nomodeset video=1920x1080 console=tty0 console=ttyS0,115200n8 loglevel=7 username=bee user-fullname=Bee modprobe.blacklist=nouveau" \
|
||||||
--apt-recommends false \
|
--apt-recommends false \
|
||||||
|
--chroot-squashfs-compression-type 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
|
||||||
|
|||||||
@@ -16,11 +16,13 @@ NCCL_TESTS_VERSION="$1"
|
|||||||
NCCL_VERSION="$2"
|
NCCL_VERSION="$2"
|
||||||
NCCL_CUDA_VERSION="$3"
|
NCCL_CUDA_VERSION="$3"
|
||||||
DIST_DIR="$4"
|
DIST_DIR="$4"
|
||||||
|
NVCC_VERSION="${5:-}"
|
||||||
|
DEBIAN_VERSION="${6:-12}"
|
||||||
|
|
||||||
[ -n "$NCCL_TESTS_VERSION" ] || { echo "usage: $0 <nccl-tests-version> <nccl-version> <cuda-version> <dist-dir>"; exit 1; }
|
[ -n "$NCCL_TESTS_VERSION" ] || { echo "usage: $0 <nccl-tests-version> <nccl-version> <cuda-version> <dist-dir> [nvcc-version] [debian-version]"; exit 1; }
|
||||||
[ -n "$NCCL_VERSION" ] || { echo "usage: $0 <nccl-tests-version> <nccl-version> <cuda-version> <dist-dir>"; exit 1; }
|
[ -n "$NCCL_VERSION" ] || { echo "usage: $0 <nccl-tests-version> <nccl-version> <cuda-version> <dist-dir> [nvcc-version] [debian-version]"; exit 1; }
|
||||||
[ -n "$NCCL_CUDA_VERSION" ] || { echo "usage: $0 <nccl-tests-version> <nccl-version> <cuda-version> <dist-dir>"; exit 1; }
|
[ -n "$NCCL_CUDA_VERSION" ] || { echo "usage: $0 <nccl-tests-version> <nccl-version> <cuda-version> <dist-dir> [nvcc-version] [debian-version]"; exit 1; }
|
||||||
[ -n "$DIST_DIR" ] || { echo "usage: $0 <nccl-tests-version> <nccl-version> <cuda-version> <dist-dir>"; exit 1; }
|
[ -n "$DIST_DIR" ] || { echo "usage: $0 <nccl-tests-version> <nccl-version> <cuda-version> <dist-dir> [nvcc-version] [debian-version]"; exit 1; }
|
||||||
|
|
||||||
echo "=== nccl-tests ${NCCL_TESTS_VERSION} ==="
|
echo "=== nccl-tests ${NCCL_TESTS_VERSION} ==="
|
||||||
|
|
||||||
@@ -34,15 +36,16 @@ if [ -f "${CACHE_DIR}/bin/all_reduce_perf" ]; then
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Resolve nvcc path (cuda-nvcc-12-8 installs to /usr/local/cuda-12.8/bin/nvcc)
|
# Resolve nvcc path (cuda-nvcc-X-Y installs to /usr/local/cuda-X.Y/bin/nvcc)
|
||||||
|
NVCC_VERSION_PATH="$(echo "${NVCC_VERSION}" | tr '.' '.')"
|
||||||
NVCC=""
|
NVCC=""
|
||||||
for candidate in nvcc /usr/local/cuda-12.8/bin/nvcc /usr/local/cuda-12/bin/nvcc /usr/local/cuda/bin/nvcc; do
|
for candidate in nvcc "/usr/local/cuda-${NVCC_VERSION_PATH}/bin/nvcc" /usr/local/cuda-12/bin/nvcc /usr/local/cuda/bin/nvcc; do
|
||||||
if command -v "$candidate" >/dev/null 2>&1 || [ -x "$candidate" ]; then
|
if command -v "$candidate" >/dev/null 2>&1 || [ -x "$candidate" ]; then
|
||||||
NVCC="$candidate"
|
NVCC="$candidate"
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
[ -n "$NVCC" ] || { echo "ERROR: nvcc not found — install cuda-nvcc-13-0"; exit 1; }
|
[ -n "$NVCC" ] || { echo "ERROR: nvcc not found — install cuda-nvcc-$(echo "${NVCC_VERSION}" | tr '.' '-')"; exit 1; }
|
||||||
echo "nvcc: $NVCC"
|
echo "nvcc: $NVCC"
|
||||||
|
|
||||||
# Determine CUDA_HOME from nvcc location
|
# Determine CUDA_HOME from nvcc location
|
||||||
@@ -50,7 +53,7 @@ CUDA_HOME="$(dirname "$(dirname "$NVCC")")"
|
|||||||
echo "CUDA_HOME: $CUDA_HOME"
|
echo "CUDA_HOME: $CUDA_HOME"
|
||||||
|
|
||||||
# Download libnccl-dev for nccl.h
|
# Download libnccl-dev for nccl.h
|
||||||
REPO_BASE="https://developer.download.nvidia.com/compute/cuda/repos/debian12/x86_64"
|
REPO_BASE="https://developer.download.nvidia.com/compute/cuda/repos/debian${DEBIAN_VERSION}/x86_64"
|
||||||
DEV_PKG="libnccl-dev_${NCCL_VERSION}+cuda${NCCL_CUDA_VERSION}_amd64.deb"
|
DEV_PKG="libnccl-dev_${NCCL_VERSION}+cuda${NCCL_CUDA_VERSION}_amd64.deb"
|
||||||
DEV_URL="${REPO_BASE}/${DEV_PKG}"
|
DEV_URL="${REPO_BASE}/${DEV_PKG}"
|
||||||
|
|
||||||
|
|||||||
@@ -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,19 +79,20 @@ resolve_iso_version() {
|
|||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
tag="$(git -C "${REPO_ROOT}" describe --tags --match 'iso/v*' --abbrev=7 --dirty 2>/dev/null || true)"
|
# 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
|
case "${tag}" in
|
||||||
iso/v*)
|
v*)
|
||||||
echo "${tag#iso/v}"
|
echo "${tag#v}"
|
||||||
return 0
|
return 0
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
# Also accept plain v* tags (e.g. v2, v2.1 used for GUI releases)
|
# Legacy iso/v* tags fallback
|
||||||
tag="$(git -C "${REPO_ROOT}" describe --tags --match '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
|
||||||
v*)
|
iso/v*)
|
||||||
echo "${tag#v}"
|
echo "${tag#iso/v}"
|
||||||
return 0
|
return 0
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
@@ -196,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" \
|
||||||
@@ -315,7 +337,9 @@ sh "${BUILDER_DIR}/build-nccl-tests.sh" \
|
|||||||
"${NCCL_TESTS_VERSION}" \
|
"${NCCL_TESTS_VERSION}" \
|
||||||
"${NCCL_VERSION}" \
|
"${NCCL_VERSION}" \
|
||||||
"${NCCL_CUDA_VERSION}" \
|
"${NCCL_CUDA_VERSION}" \
|
||||||
"${DIST_DIR}"
|
"${DIST_DIR}" \
|
||||||
|
"${NVCC_VERSION}" \
|
||||||
|
"${DEBIAN_VERSION}"
|
||||||
|
|
||||||
NCCL_TESTS_CACHE="${DIST_DIR}/nccl-tests-${NCCL_TESTS_VERSION}"
|
NCCL_TESTS_CACHE="${DIST_DIR}/nccl-tests-${NCCL_TESTS_VERSION}"
|
||||||
cp "${NCCL_TESTS_CACHE}/bin/all_reduce_perf" "${OVERLAY_STAGE_DIR}/usr/local/bin/all_reduce_perf"
|
cp "${NCCL_TESTS_CACHE}/bin/all_reduce_perf" "${OVERLAY_STAGE_DIR}/usr/local/bin/all_reduce_perf"
|
||||||
@@ -349,6 +373,14 @@ if [ -f "${OVERLAY_STAGE_DIR}/etc/motd" ]; then
|
|||||||
mv "${OVERLAY_STAGE_DIR}/etc/motd.patched" "${OVERLAY_STAGE_DIR}/etc/motd"
|
mv "${OVERLAY_STAGE_DIR}/etc/motd.patched" "${OVERLAY_STAGE_DIR}/etc/motd"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# --- substitute version placeholders in package list ---
|
||||||
|
sed -i \
|
||||||
|
-e "s/%%DCGM_VERSION%%/${DCGM_VERSION}/g" \
|
||||||
|
-e "s/%%ROCM_VERSION%%/${ROCM_VERSION}/g" \
|
||||||
|
-e "s/%%ROCM_SMI_VERSION%%/${ROCM_SMI_VERSION}/g" \
|
||||||
|
"${BUILD_WORK_DIR}/config/package-lists/bee.list.chroot" \
|
||||||
|
"${BUILD_WORK_DIR}/config/archives/rocm.list.chroot"
|
||||||
|
|
||||||
# --- sync overlay into live-build includes.chroot ---
|
# --- sync overlay into live-build includes.chroot ---
|
||||||
LB_DIR="${BUILD_WORK_DIR}"
|
LB_DIR="${BUILD_WORK_DIR}"
|
||||||
LB_INCLUDES="${LB_DIR}/config/includes.chroot"
|
LB_INCLUDES="${LB_DIR}/config/includes.chroot"
|
||||||
|
|||||||
29
iso/builder/config/archives/nvidia-cuda.key.chroot
Normal file
29
iso/builder/config/archives/nvidia-cuda.key.chroot
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||||
|
Version: GnuPG v2.0.22 (GNU/Linux)
|
||||||
|
|
||||||
|
mQINBGJYmlEBEAC6nJmeqByeReM+MSy4palACCnfOg4pOxffrrkldxz4jrDOZNK4
|
||||||
|
q8KG+ZbXrkdP0e9qTFRvZzN+A6Jw3ySfoiKXRBw5l2Zp81AYkghV641OpWNjZOyL
|
||||||
|
syKEtST9LR1ttHv1ZI71pj8NVG/EnpimZPOblEJ1OpibJJCXLrbn+qcJ8JNuGTSK
|
||||||
|
6v2aLBmhR8VR/aSJpmkg7fFjcGklweTI8+Ibj72HuY9JRD/+dtUoSh7z037mWo56
|
||||||
|
ee02lPFRD0pHOEAlLSXxFO/SDqRVMhcgHk0a8roCF+9h5Ni7ZUyxlGK/uHkqN7ED
|
||||||
|
/U/ATpGKgvk4t23eTpdRC8FXAlBZQyf/xnhQXsyF/z7+RV5CL0o1zk1LKgo+5K32
|
||||||
|
5ka5uZb6JSIrEPUaCPEMXu6EEY8zSFnCrRS/Vjkfvc9ViYZWzJ387WTjAhMdS7wd
|
||||||
|
PmdDWw2ASGUP4FrfCireSZiFX+ZAOspKpZdh0P5iR5XSx14XDt3jNK2EQQboaJAD
|
||||||
|
uqksItatOEYNu4JsCbc24roJvJtGhpjTnq1/dyoy6K433afU0DS2ZPLthLpGqeyK
|
||||||
|
MKNY7a2WjxhRmCSu5Zok/fGKcO62XF8a3eSj4NzCRv8LM6mG1Oekz6Zz+tdxHg19
|
||||||
|
ufHO0et7AKE5q+5VjE438Xpl4UWbM/Voj6VPJ9uzywDcnZXpeOqeTQh2pQARAQAB
|
||||||
|
tCBjdWRhdG9vbHMgPGN1ZGF0b29sc0BudmlkaWEuY29tPokCOQQTAQIAIwUCYlia
|
||||||
|
UQIbAwcLCQgHAwIBBhUIAgkKCwQWAgMBAh4BAheAAAoJEKS0aZY7+GPM1y4QALKh
|
||||||
|
BqSozrYbe341Qu7SyxHQgjRCGi4YhI3bHCMj5F6vEOHnwiFH6YmFkxCYtqcGjca6
|
||||||
|
iw7cCYMow/hgKLAPwkwSJ84EYpGLWx62+20rMM4OuZwauSUcY/kE2WgnQ74zbh3+
|
||||||
|
MHs56zntJFfJ9G+NYidvwDWeZn5HIzR4CtxaxRgpiykg0s3ps6X0U+vuVcLnutBF
|
||||||
|
7r81astvlVQERFbce/6KqHK+yj843Qrhb3JEolUoOETK06nD25bVtnAxe0QEyA90
|
||||||
|
9MpRNLfR6BdjPpxqhphDcMOhJfyubAroQUxG/7S+Yw+mtEqHrL/dz9iEYqodYiSo
|
||||||
|
zfi0b+HFI59sRkTfOBDBwb3kcARExwnvLJmqijiVqWkoJ3H67oA0XJN2nelucw+A
|
||||||
|
Hb+Jt9BWjyzKWlLFDnVHdGicyRJ0I8yqi32w8hGeXmu3tU58VWJrkXEXadBftmci
|
||||||
|
pemb6oZ/r5SCkW6kxr2PsNWcJoebUdynyOQGbVwpMtJAnjOYp0ObKOANbcIg+tsi
|
||||||
|
kyCIO5TiY3ADbBDPCeZK8xdcugXoW5WFwACGC0z+Cn0mtw8z3VGIPAMSCYmLusgW
|
||||||
|
t2+EpikwrP2inNp5Pc+YdczRAsa4s30Jpyv/UHEG5P9GKnvofaxJgnU56lJIRPzF
|
||||||
|
iCUGy6cVI0Fq777X/ME1K6A/bzZ4vRYNx8rUmVE5
|
||||||
|
=DO7z
|
||||||
|
-----END PGP PUBLIC KEY BLOCK-----
|
||||||
1
iso/builder/config/archives/nvidia-cuda.list.chroot
Normal file
1
iso/builder/config/archives/nvidia-cuda.list.chroot
Normal file
@@ -0,0 +1 @@
|
|||||||
|
deb https://developer.download.nvidia.com/compute/cuda/repos/debian12/x86_64/ /
|
||||||
BIN
iso/builder/config/archives/rocm.key.chroot
Normal file
BIN
iso/builder/config/archives/rocm.key.chroot
Normal file
Binary file not shown.
1
iso/builder/config/archives/rocm.list.chroot
Normal file
1
iso/builder/config/archives/rocm.list.chroot
Normal file
@@ -0,0 +1 @@
|
|||||||
|
deb https://repo.radeon.com/rocm/apt/%%ROCM_VERSION%% jammy main
|
||||||
@@ -46,6 +46,12 @@ chmod +x /usr/local/bin/bee-log-run 2>/dev/null || true
|
|||||||
# Reload udev rules
|
# Reload udev rules
|
||||||
udevadm control --reload-rules 2>/dev/null || true
|
udevadm control --reload-rules 2>/dev/null || true
|
||||||
|
|
||||||
|
# rocm-smi symlink (package installs to /opt/rocm-*/bin/rocm-smi)
|
||||||
|
if [ ! -e /usr/local/bin/rocm-smi ]; then
|
||||||
|
smi_path="$(find /opt -path '*/bin/rocm-smi' -type f 2>/dev/null | sort | tail -1)"
|
||||||
|
[ -n "${smi_path}" ] && ln -sf "${smi_path}" /usr/local/bin/rocm-smi
|
||||||
|
fi
|
||||||
|
|
||||||
# Create export directory
|
# Create export directory
|
||||||
mkdir -p /appdata/bee/export
|
mkdir -p /appdata/bee/export
|
||||||
|
|
||||||
|
|||||||
@@ -1,103 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# 9001-amd-rocm.hook.chroot — install AMD ROCm SMI tool for Instinct GPU monitoring.
|
|
||||||
# Runs inside the live-build chroot. Adds AMD's apt repository and installs
|
|
||||||
# rocm-smi-lib which provides the `rocm-smi` CLI (analogous to nvidia-smi).
|
|
||||||
#
|
|
||||||
# AMD does NOT publish Debian Bookworm packages. The repo uses Ubuntu codenames
|
|
||||||
# (jammy/noble). We use jammy (Ubuntu 22.04) — its packages install cleanly on
|
|
||||||
# Debian 12 (Bookworm) due to compatible glibc/libstdc++.
|
|
||||||
# Tried versions newest-first; falls back if a point release is missing.
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
# Ubuntu codename to use for the AMD repo (Debian has no AMD packages).
|
|
||||||
ROCM_UBUNTU_DIST="jammy"
|
|
||||||
|
|
||||||
# ROCm point-releases to try newest-first. AMD drops old point releases
|
|
||||||
# from the repo, so we walk backwards until one responds 200.
|
|
||||||
ROCM_CANDIDATES="6.3.4 6.3.3 6.3.2 6.3.1 6.3 6.2.4 6.2.3 6.2.2 6.2.1 6.2"
|
|
||||||
|
|
||||||
ROCM_KEYRING="/etc/apt/keyrings/rocm.gpg"
|
|
||||||
ROCM_LIST="/etc/apt/sources.list.d/rocm.list"
|
|
||||||
APT_UPDATED=0
|
|
||||||
|
|
||||||
mkdir -p /etc/apt/keyrings
|
|
||||||
|
|
||||||
ensure_tool() {
|
|
||||||
tool="$1"
|
|
||||||
pkg="$2"
|
|
||||||
if command -v "${tool}" >/dev/null 2>&1; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
if [ "${APT_UPDATED}" -eq 0 ]; then
|
|
||||||
apt-get update -qq
|
|
||||||
APT_UPDATED=1
|
|
||||||
fi
|
|
||||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends "${pkg}"
|
|
||||||
}
|
|
||||||
|
|
||||||
ensure_cert_bundle() {
|
|
||||||
if [ -s /etc/ssl/certs/ca-certificates.crt ]; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
if [ "${APT_UPDATED}" -eq 0 ]; then
|
|
||||||
apt-get update -qq
|
|
||||||
APT_UPDATED=1
|
|
||||||
fi
|
|
||||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends ca-certificates
|
|
||||||
}
|
|
||||||
|
|
||||||
# live-build chroot may not include fetch/signing tools yet
|
|
||||||
if ! ensure_cert_bundle || ! ensure_tool wget wget || ! ensure_tool gpg gpg; then
|
|
||||||
echo "WARN: failed to install wget/gpg/ca-certificates prerequisites — skipping ROCm install"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Download and import AMD GPG key
|
|
||||||
if ! wget -qO- "https://repo.radeon.com/rocm/rocm.gpg.key" \
|
|
||||||
| gpg --dearmor --yes --output "${ROCM_KEYRING}"; then
|
|
||||||
echo "WARN: failed to fetch AMD ROCm GPG key — skipping ROCm install"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Try each ROCm version until apt-get update succeeds.
|
|
||||||
# AMD repo uses Ubuntu codenames; bookworm is not published — use jammy.
|
|
||||||
ROCM_VERSION=""
|
|
||||||
for candidate in ${ROCM_CANDIDATES}; do
|
|
||||||
cat > "${ROCM_LIST}" <<EOF
|
|
||||||
deb [arch=amd64 signed-by=${ROCM_KEYRING}] https://repo.radeon.com/rocm/apt/${candidate} ${ROCM_UBUNTU_DIST} main
|
|
||||||
EOF
|
|
||||||
if apt-get update -qq 2>/dev/null; then
|
|
||||||
ROCM_VERSION="${candidate}"
|
|
||||||
echo "=== AMD ROCm ${ROCM_VERSION} (${ROCM_UBUNTU_DIST}): repository available ==="
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
echo "WARN: ROCm ${candidate} not available, trying next..."
|
|
||||||
rm -f "${ROCM_LIST}"
|
|
||||||
done
|
|
||||||
|
|
||||||
if [ -z "${ROCM_VERSION}" ]; then
|
|
||||||
echo "WARN: no ROCm apt repository available — skipping ROCm install"
|
|
||||||
rm -f "${ROCM_KEYRING}"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# rocm-smi-lib provides the rocm-smi CLI tool for GPU monitoring
|
|
||||||
if DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends rocm-smi-lib; then
|
|
||||||
echo "=== AMD ROCm: rocm-smi-lib installed ==="
|
|
||||||
if [ -x /opt/rocm/bin/rocm-smi ]; then
|
|
||||||
ln -sf /opt/rocm/bin/rocm-smi /usr/local/bin/rocm-smi
|
|
||||||
else
|
|
||||||
smi_path="$(find /opt -path '*/bin/rocm-smi' -type f 2>/dev/null | sort | tail -1)"
|
|
||||||
if [ -n "${smi_path}" ]; then
|
|
||||||
ln -sf "${smi_path}" /usr/local/bin/rocm-smi
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
rocm-smi --version 2>/dev/null || true
|
|
||||||
else
|
|
||||||
echo "WARN: rocm-smi-lib install failed — AMD GPU monitoring unavailable"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Clean up apt lists to keep ISO size down
|
|
||||||
rm -f "${ROCM_LIST}"
|
|
||||||
apt-get clean
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# 9002-nvidia-dcgm.hook.chroot — install NVIDIA DCGM inside the live-build chroot.
|
|
||||||
# DCGM (Data Center GPU Manager) provides dcgmi diag for acceptance testing.
|
|
||||||
# Adds NVIDIA's CUDA apt repository (debian12/x86_64) and installs datacenter-gpu-manager.
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
NVIDIA_KEYRING="/usr/share/keyrings/nvidia-cuda.gpg"
|
|
||||||
NVIDIA_LIST="/etc/apt/sources.list.d/nvidia-cuda.list"
|
|
||||||
NVIDIA_KEY_URL="https://developer.download.nvidia.com/compute/cuda/repos/debian12/x86_64/3bf863cc.pub"
|
|
||||||
NVIDIA_REPO="https://developer.download.nvidia.com/compute/cuda/repos/debian12/x86_64/"
|
|
||||||
APT_UPDATED=0
|
|
||||||
|
|
||||||
mkdir -p /usr/share/keyrings /etc/apt/sources.list.d
|
|
||||||
|
|
||||||
ensure_tool() {
|
|
||||||
tool="$1"
|
|
||||||
pkg="$2"
|
|
||||||
if command -v "${tool}" >/dev/null 2>&1; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
if [ "${APT_UPDATED}" -eq 0 ]; then
|
|
||||||
apt-get update -qq
|
|
||||||
APT_UPDATED=1
|
|
||||||
fi
|
|
||||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends "${pkg}"
|
|
||||||
}
|
|
||||||
|
|
||||||
ensure_cert_bundle() {
|
|
||||||
if [ -s /etc/ssl/certs/ca-certificates.crt ]; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
if [ "${APT_UPDATED}" -eq 0 ]; then
|
|
||||||
apt-get update -qq
|
|
||||||
APT_UPDATED=1
|
|
||||||
fi
|
|
||||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends ca-certificates
|
|
||||||
}
|
|
||||||
|
|
||||||
if ! ensure_cert_bundle || ! ensure_tool wget wget || ! ensure_tool gpg gpg; then
|
|
||||||
echo "WARN: prerequisites missing — skipping DCGM install"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Download and import NVIDIA GPG key
|
|
||||||
if ! wget -qO- "${NVIDIA_KEY_URL}" | gpg --dearmor --yes --output "${NVIDIA_KEYRING}"; then
|
|
||||||
echo "WARN: failed to fetch NVIDIA GPG key — skipping DCGM install"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
cat > "${NVIDIA_LIST}" <<EOF
|
|
||||||
deb [signed-by=${NVIDIA_KEYRING}] ${NVIDIA_REPO} /
|
|
||||||
EOF
|
|
||||||
|
|
||||||
apt-get update -qq
|
|
||||||
|
|
||||||
if DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends datacenter-gpu-manager; then
|
|
||||||
echo "=== DCGM: datacenter-gpu-manager installed ==="
|
|
||||||
dcgmi --version 2>/dev/null || true
|
|
||||||
else
|
|
||||||
echo "WARN: datacenter-gpu-manager install failed — DCGM unavailable"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Clean up apt lists to keep ISO size down
|
|
||||||
rm -f "${NVIDIA_LIST}"
|
|
||||||
apt-get clean
|
|
||||||
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 ==="
|
||||||
@@ -60,7 +60,21 @@ lightdm
|
|||||||
|
|
||||||
# Firmware
|
# Firmware
|
||||||
firmware-linux-free
|
firmware-linux-free
|
||||||
|
firmware-linux-nonfree
|
||||||
|
firmware-misc-nonfree
|
||||||
firmware-amd-graphics
|
firmware-amd-graphics
|
||||||
|
firmware-realtek
|
||||||
|
firmware-intel-sound
|
||||||
|
firmware-bnx2
|
||||||
|
firmware-bnx2x
|
||||||
|
firmware-cavium
|
||||||
|
firmware-qlogic
|
||||||
|
|
||||||
|
# NVIDIA DCGM (Data Center GPU Manager) — dcgmi diag for acceptance testing
|
||||||
|
datacenter-gpu-manager=1:%%DCGM_VERSION%%
|
||||||
|
|
||||||
|
# AMD ROCm SMI — GPU monitoring for Instinct cards (repo: rocm/apt/6.3.4 jammy)
|
||||||
|
rocm-smi-lib=%%ROCM_SMI_VERSION%%
|
||||||
|
|
||||||
# glibc compat helpers (for any external binaries that need it)
|
# glibc compat helpers (for any external binaries that need it)
|
||||||
libc6
|
libc6
|
||||||
|
|||||||
@@ -4,19 +4,8 @@ Section "Device"
|
|||||||
Option "fbdev" "/dev/fb0"
|
Option "fbdev" "/dev/fb0"
|
||||||
EndSection
|
EndSection
|
||||||
|
|
||||||
Section "Monitor"
|
|
||||||
Identifier "monitor0"
|
|
||||||
Modeline "1920x1080" 148.50 1920 2008 2052 2200 1080 1084 1089 1125 +hsync +vsync
|
|
||||||
Option "PreferredMode" "1920x1080"
|
|
||||||
EndSection
|
|
||||||
|
|
||||||
Section "Screen"
|
Section "Screen"
|
||||||
Identifier "screen0"
|
Identifier "screen0"
|
||||||
Device "fbdev"
|
Device "fbdev"
|
||||||
Monitor "monitor0"
|
|
||||||
DefaultDepth 24
|
DefaultDepth 24
|
||||||
SubSection "Display"
|
|
||||||
Depth 24
|
|
||||||
Modes "1920x1080" "1280x1024" "1024x768"
|
|
||||||
EndSubSection
|
|
||||||
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
|
||||||
@@ -14,7 +14,6 @@ done
|
|||||||
|
|
||||||
tint2 &
|
tint2 &
|
||||||
chromium \
|
chromium \
|
||||||
--no-sandbox \
|
|
||||||
--disable-infobars \
|
--disable-infobars \
|
||||||
--disable-translate \
|
--disable-translate \
|
||||||
--no-first-run \
|
--no-first-run \
|
||||||
|
|||||||
@@ -1,10 +1,50 @@
|
|||||||
#!/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)
|
|
||||||
|
|
||||||
|
ask() {
|
||||||
|
# ask VARNAME "prompt" [default]
|
||||||
|
# Sets VARNAME. Returns 1 on 'b' (back), calls abort on 'a'.
|
||||||
|
_var="$1"; _prompt="$2"; _default="$3"
|
||||||
|
while true; do
|
||||||
|
if [ -n "$_default" ]; then
|
||||||
|
printf "%s [%s] (b=back a=abort): " "$_prompt" "$_default"
|
||||||
|
else
|
||||||
|
printf "%s (b=back a=abort): " "$_prompt"
|
||||||
|
fi
|
||||||
|
read _input
|
||||||
|
case "$_input" in
|
||||||
|
a|A) abort ;;
|
||||||
|
b|B) return 1 ;;
|
||||||
|
"")
|
||||||
|
if [ -n "$_default" ]; then
|
||||||
|
eval "$_var=\"\$_default\""
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
echo " Required — please enter a value."
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
eval "$_var=\"\$_input\""
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── 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:"
|
echo "Interfaces:"
|
||||||
i=1
|
i=1
|
||||||
for iface in $IFACES; do
|
for iface in $IFACES; do
|
||||||
@@ -13,38 +53,111 @@ for iface in $IFACES; do
|
|||||||
i=$((i+1))
|
i=$((i+1))
|
||||||
done
|
done
|
||||||
echo ""
|
echo ""
|
||||||
printf "Interface name [or Enter to pick first]: "
|
|
||||||
read IFACE
|
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
|
if [ -z "$IFACE" ]; then
|
||||||
IFACE=$(echo "$IFACES" | head -1)
|
echo " No interface #$INPUT — try again."
|
||||||
|
continue
|
||||||
fi
|
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"
|
echo "Selected: $IFACE"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Step 2: choose mode ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
choose_mode() {
|
||||||
echo ""
|
echo ""
|
||||||
echo " 1) DHCP"
|
echo " 1) DHCP"
|
||||||
echo " 2) Static"
|
echo " 2) Static IP"
|
||||||
printf "Mode [1]: "
|
echo ""
|
||||||
read MODE
|
while true; do
|
||||||
MODE=${MODE:-1}
|
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
|
||||||
|
}
|
||||||
|
|
||||||
if [ "$MODE" = "1" ]; then
|
# ── 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."
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── 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
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
ip -4 addr show "$IFACE"
|
ip -4 addr show "$IFACE"
|
||||||
|
|||||||
Reference in New Issue
Block a user