Compare commits

..

25 Commits
v2.7 ... v2.9

Author SHA1 Message Date
911745e4da refactor(iso): replace chroot hooks for DCGM/ROCm with live-build apt sources
Move datacenter-gpu-manager and rocm-smi-lib from dynamic chroot hooks
into live-build's config/archives mechanism so lb caches the .deb files
in cache/packages.chroot/ between builds, eliminating repeated 900+ MB
downloads. Versions pinned via VERSIONS and substituted into package
lists at build time.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 13:01:10 +03:00
acfd2010d7 fix(iso): remove firmware-chelsio-t4 (not in Debian bookworm)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 12:43:29 +03:00
e904c13790 fix(iso): remove --no-sandbox from chromium (runs as bee user, not root)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 12:40:42 +03:00
24c5c72cee feat(iso): add NIC firmware packages for broad hardware support
Adds firmware-misc-nonfree (Intel ice/i40e/igc), firmware-bnx2/bnx2x
(Broadcom), firmware-cavium (Marvell/QLogic), firmware-qlogic,
firmware-chelsio-t4, firmware-realtek to fix missing network on
physical servers with modern NICs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 12:38:22 +03:00
6ff0bcad56 feat(iso): show kernel logs on graphical console (remove quiet, loglevel=7)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 11:23:57 +03:00
4fef26000c fix(iso): replace invalid --compression with --chroot-squashfs-compression-type
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 10:23:00 +03:00
a393dcb731 feat(webui): add POST /api/sat/abort + update bible-local runtime-flows
- jobState now has optional cancel func; abort() calls it if job is running
- handleAPISATRun passes cancellable context to RunNvidiaAcceptancePackWithOptions
- POST /api/sat/abort?job_id=... cancels the running SAT job
- bible-local/runtime-flows.md: replace TUI SAT flow with Web UI flow

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 10:23:00 +03:00
9e55728053 feat(iso): replace --clean-cache with --clean-build (cleans + rebuilds)
--clean-build clears all caches (Go, NVIDIA, lb packages, work dir)
and rebuilds the Docker image, then proceeds with a full clean build.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 10:12:21 +03:00
4b8023c1cb feat(iso): add --clean-cache option to build-in-container.sh
Removes all cached build artifacts: Go cache, NVIDIA/NCCL/cuBLAS
downloads, lb package cache, and live-build work dir. Use before
a clean rebuild or when switching Debian/kernel versions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 10:11:31 +03:00
4c8417d20a feat(webui): add Install to Disk page
Expose the existing bee-install script through the web UI:
- platform/install.go: remove USB exclusion, add SizeBytes/MountedParts
  fields, add MinInstallBytes()/DiskWarnings() safety checks (size,
  mounted partitions, toram+low-RAM warning)
- webui: add GET /api/install/disks, POST /api/install/run,
  GET /api/install/stream endpoints
- webui: add Install to Disk page with disk table, warning badges,
  device-name confirmation gate, SSE progress terminal, reboot button

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 10:11:16 +03:00
0755374dd2 perf(iso): speed up builds — zstd squashfs + preserve lb chroot cache
- Switch squashfs compression from xz to zstd (3-5x faster compression,
  ~10-15% larger but decompresses faster at boot)
- Stop rm -rf BUILD_WORK_DIR on each build; rsync only config changes
  so lb can reuse its chroot across builds (skips apt install step)
- Keep lb-packages cache in CACHE_ROOT as fallback if work dir is wiped

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 10:10:29 +03:00
c70ae274fa revert(iso): remove apt-cacher-ng support, use lb package cache instead
apt-cacher-ng requires a separate container; lb's own package cache
persisted in --cache-dir is simpler and sufficient.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 10:02:34 +03:00
23ad7ff534 feat(iso): persist lb package cache across builds in cache dir
Saves cache/packages.chroot before wiping BUILD_WORK_DIR and
restores it after, so apt packages are not re-downloaded on every
build. Cache lives in --cache-dir (same place as Go/NVIDIA cache).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 09:59:55 +03:00
de130966f7 feat(iso): add APT_PROXY support to speed up builds via apt-cacher-ng
Pass APT_PROXY=http://host:3142 to build-in-container.sh to route
all apt traffic through a local cache. Also supports --apt-proxy flag.
Mirrors in auto/config are set from BEE_APT_PROXY env when present.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 09:57:54 +03:00
c6fbfc8306 fix(boot): restore toram as menu option only, not default boot param
toram was incorrectly added to the default bootappend-live causing
every boot to copy the full ISO to RAM (slow on BMC virtual media).
Default boot reads squashfs from media; toram is available as a
separate menu entry.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 09:52:25 +03:00
35ad1c74d9 feat(iso): add slim hook to strip locales/man pages/apt cache from squashfs
Removes ~100-300MB from the squashfs: man pages, non-en locales,
python cache, apt lists and package cache, temp files and logs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 08:44:02 +03:00
4a02e74b17 fix(iso): add git safe.directory so git describe sees v* tags inside container
Without this, git refuses to read the bind-mounted repo (UID mismatch)
and describe returns empty, causing the version to fall back to iso/v1.0.20.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 08:23:37 +03:00
cd2853ad99 fix(webui): fix viewer static path so Reanimator Chart CSS loads correctly
Mount chart submodule static assets at /static/ (matching the template's
hardcoded href), fix nav to include Audit Snapshot tab, remove dead
renderViewerPage code and iframe from Dashboard.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 08:19:17 +03:00
6caf771d6e fix(boot): restore toram kernel parameter
Without toram the squashfs is read from the physical medium at runtime.
Disconnecting the USB/CD after boot causes SQUASHFS I/O errors on any
uncached block, making all X11 apps crash with SIGBUS.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 08:04:37 +03:00
14fa87b7d7 feat(netconf): add input validation, 'b' to go back, 'a' to abort
- All prompts accept 'a' = abort, 'b' = back to previous step
- Interface input: validate numeric range and name existence, re-prompt on bad input
- IP address: regex check x.x.x.x/prefix format
- Gateway: regex check x.x.x.x format
- Main loop: 'b' at mode selection goes back to interface list

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 07:31:23 +03:00
600ece911b fix(desktop): remove forced 1920x1080 modeline, limit LightDM restarts
On real server hardware (IPMI/BMC AST chip + nomodeset) the VESA
framebuffer is set by BIOS at whatever resolution it chooses (often
1024x768 or 1280x1024). The hardcoded 1920x1080 Modeline caused X to
fail → LightDM crash-loop → SOL console flooded with systemd messages.

- Remove Monitor section / Modeline from xorg.conf — fbdev now uses
  whatever framebuffer resolution the kernel provides
- Add lightdm.service.d/bee-limits.conf: RestartSec=10,
  max 3 restarts per 60s so headless hardware doesn't spam the console

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 07:30:51 +03:00
2d424c63cb fix(netconf): accept interface number as input, not just name
User sees a numbered list but could only type the name.
Now numeric input is resolved to the interface name via awk NR==N.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 07:27:49 +03:00
50f28d1ee6 chore: drop legacy TUI/dead code
- Delete audit/internal/app/panel.go (388 lines, zero callers — TUI panel remnant)
- Delete RenderGPULiveChart() from platform/gpu_metrics.go (~155 lines, never called)
- Move formatSATDetail/cleanSummaryKey helpers to app.go (still used)
- Update motd: replace bee-tui with Web UI hint
- Update journald.conf.d comment: remove bee-tui reference

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 07:27:30 +03:00
3579747ae3 fix(iso): prioritise v[0-9]* tags over iso/v* for ISO filename
Plain v2.x tags are now the active tagging scheme; iso/v1.0.x tags
are legacy. Swap priority in resolve_iso_version so the ISO is named
bee-debian12-v2.x-amd64.iso instead of v1.0.x-N-gHASH.
Also tighten the v* pattern to v[0-9]* to avoid accidentally matching
other prefixed tags in both resolve functions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 23:34:09 +03:00
09dc7d2613 feat(webui): apply light theme from chart submodule CSS
Replace dark #0f1117 theme with clean white/Semantic-UI-inspired
design matching the updated internal/chart submodule: white surface,
dark sidebar (#1b1c1d), Lato font, blue accent (#2185d0), subtle
borders. Also update submodule pointer to latest commit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 23:31:29 +03:00
31 changed files with 923 additions and 986 deletions

BIN
audit/bee Executable file

Binary file not shown.

View File

@@ -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:]
}

View File

@@ -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))
}

View File

@@ -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 minmax 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 {

View File

@@ -3,6 +3,7 @@ package platform
import ( import (
"context" "context"
"fmt" "fmt"
"os"
"os/exec" "os/exec"
"strconv" "strconv"
"strings" "strings"
@@ -10,13 +11,17 @@ import (
// InstallDisk describes a candidate disk for installation. // InstallDisk describes a candidate disk for installation.
type InstallDisk struct { type InstallDisk struct {
Device string // e.g. /dev/sda Device string // e.g. /dev/sda
Model string Model string
Size string // human-readable, e.g. "500G" Size string // human-readable, e.g. "500G"
SizeBytes int64 // raw byte count from lsblk
MountedParts []string // partition mount points currently active
} }
const squashfsPath = "/run/live/medium/live/filesystem.squashfs"
// ListInstallDisks returns block devices suitable for installation. // ListInstallDisks returns block devices suitable for installation.
// Excludes USB drives and the current live boot medium. // Excludes the current live boot medium but includes USB drives.
func (s *System) ListInstallDisks() ([]InstallDisk, error) { func (s *System) ListInstallDisks() ([]InstallDisk, error) {
out, err := exec.Command("lsblk", "-dn", "-o", "NAME,MODEL,SIZE,TYPE,TRAN").Output() out, err := exec.Command("lsblk", "-dn", "-o", "NAME,MODEL,SIZE,TYPE,TRAN").Output()
if err != nil { if err != nil {
@@ -33,7 +38,6 @@ func (s *System) ListInstallDisks() ([]InstallDisk, error) {
continue continue
} }
// Last field: TRAN, second-to-last: TYPE, third-to-last: SIZE // Last field: TRAN, second-to-last: TYPE, third-to-last: SIZE
tran := fields[len(fields)-1]
typ := fields[len(fields)-2] typ := fields[len(fields)-2]
size := fields[len(fields)-3] size := fields[len(fields)-3]
name := fields[0] name := fields[0]
@@ -42,24 +46,58 @@ func (s *System) ListInstallDisks() ([]InstallDisk, error) {
if typ != "disk" { if typ != "disk" {
continue continue
} }
if strings.EqualFold(tran, "usb") {
continue
}
device := "/dev/" + name device := "/dev/" + name
if device == bootDev { if device == bootDev {
continue continue
} }
sizeBytes := diskSizeBytes(device)
mounted := mountedParts(device)
disks = append(disks, InstallDisk{ disks = append(disks, InstallDisk{
Device: device, Device: device,
Model: strings.TrimSpace(model), Model: strings.TrimSpace(model),
Size: size, Size: size,
SizeBytes: sizeBytes,
MountedParts: mounted,
}) })
} }
return disks, nil return disks, nil
} }
// diskSizeBytes returns the byte size of a block device using lsblk.
func diskSizeBytes(device string) int64 {
out, err := exec.Command("lsblk", "-bdn", "-o", "SIZE", device).Output()
if err != nil {
return 0
}
n, _ := strconv.ParseInt(strings.TrimSpace(string(out)), 10, 64)
return n
}
// mountedParts returns a list of "<part> at <mountpoint>" strings for any
// mounted partitions on the given device.
func mountedParts(device string) []string {
out, err := exec.Command("lsblk", "-n", "-o", "NAME,MOUNTPOINT", device).Output()
if err != nil {
return nil
}
var result []string
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
fields := strings.Fields(line)
if len(fields) < 2 {
continue
}
mp := fields[1]
if mp == "" || mp == "[SWAP]" {
continue
}
result = append(result, "/dev/"+strings.TrimLeft(fields[0], "└─├─")+" at "+mp)
}
return result
}
// findLiveBootDevice returns the block device backing /run/live/medium (if any). // findLiveBootDevice returns the block device backing /run/live/medium (if any).
func findLiveBootDevice() string { func findLiveBootDevice() string {
out, err := exec.Command("findmnt", "-n", "-o", "SOURCE", "/run/live/medium").Output() out, err := exec.Command("findmnt", "-n", "-o", "SOURCE", "/run/live/medium").Output()
@@ -79,6 +117,80 @@ func findLiveBootDevice() string {
return "/dev/" + strings.TrimSpace(string(out2)) return "/dev/" + strings.TrimSpace(string(out2))
} }
// MinInstallBytes returns the minimum recommended disk size for installation:
// squashfs size × 1.5 to allow for extracted filesystem and bootloader.
// Returns 0 if the squashfs is not available (non-live environment).
func MinInstallBytes() int64 {
fi, err := os.Stat(squashfsPath)
if err != nil {
return 0
}
return fi.Size() * 3 / 2
}
// toramActive returns true when the live system was booted with toram.
func toramActive() bool {
data, err := os.ReadFile("/proc/cmdline")
if err != nil {
return false
}
return strings.Contains(string(data), "toram")
}
// freeMemBytes returns MemAvailable from /proc/meminfo.
func freeMemBytes() int64 {
data, err := os.ReadFile("/proc/meminfo")
if err != nil {
return 0
}
for _, line := range strings.Split(string(data), "\n") {
if strings.HasPrefix(line, "MemAvailable:") {
fields := strings.Fields(line)
if len(fields) >= 2 {
n, _ := strconv.ParseInt(fields[1], 10, 64)
return n * 1024 // kB → bytes
}
}
}
return 0
}
// DiskWarnings returns advisory warning strings for a disk candidate.
func DiskWarnings(d InstallDisk) []string {
var w []string
if len(d.MountedParts) > 0 {
w = append(w, "has mounted partitions: "+strings.Join(d.MountedParts, ", "))
}
min := MinInstallBytes()
if min > 0 && d.SizeBytes > 0 && d.SizeBytes < min {
w = append(w, fmt.Sprintf("disk may be too small (need ≥ %s, have %s)",
humanBytes(min), humanBytes(d.SizeBytes)))
}
if toramActive() {
sqFi, err := os.Stat(squashfsPath)
if err == nil {
free := freeMemBytes()
if free > 0 && free < sqFi.Size()*2 {
w = append(w, "toram mode — low RAM, extraction may be slow or fail")
}
}
}
return w
}
func humanBytes(b int64) string {
const unit = 1024
if b < unit {
return fmt.Sprintf("%d B", b)
}
div, exp := int64(unit), 0
for n := b / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "KMGTPE"[exp])
}
// InstallToDisk runs bee-install <device> <logfile> and streams output to logFile. // InstallToDisk runs bee-install <device> <logfile> and streams output to logFile.
// The context can be used to cancel. // The context can be used to cancel.
func (s *System) InstallToDisk(ctx context.Context, device string, logFile string) error { func (s *System) InstallToDisk(ctx context.Context, device string, logFile string) error {
@@ -92,14 +204,11 @@ func InstallLogPath(device string) string {
return "/tmp/bee-install" + safe + ".log" return "/tmp/bee-install" + safe + ".log"
} }
// DiskLabel returns a display label for a disk. // Label returns a display label for a disk.
func (d InstallDisk) Label() string { func (d InstallDisk) Label() string {
model := d.Model model := d.Model
if model == "" { if model == "" {
model = "Unknown" model = "Unknown"
} }
sizeBytes, err := strconv.ParseInt(strings.TrimSuffix(d.Size, "B"), 10, 64)
_ = sizeBytes
_ = err
return fmt.Sprintf("%s %s %s", d.Device, d.Size, model) return fmt.Sprintf("%s %s %s", d.Device, d.Size, model)
} }

View File

@@ -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 {
j.append("ERROR: " + err.Error()) if ctx.Err() != nil {
j.finish(err.Error()) j.append("Aborted.")
j.finish("aborted")
} else {
j.append("ERROR: " + 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) {

View File

@@ -7,12 +7,23 @@ import (
// jobState holds the output lines and completion status of an async job. // jobState holds the output lines and completion status of an async job.
type jobState struct { type jobState struct {
lines []string lines []string
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()

View File

@@ -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>
var data map[string]any <style>
if err := json.Unmarshal(snapshot, &data); err != nil { #install-disk-tbody tr{cursor:pointer}
// Fallback: render raw JSON #install-disk-tbody tr.selected td{background:rgba(33,133,208,.1)}
b.WriteString(`<pre>` + html.EscapeString(string(snapshot)) + `</pre>`) #install-disk-tbody tr:hover td{background:rgba(33,133,208,.07)}
b.WriteString(`</body></html>`) </style>
return b.String()
}
// Collected at <script>
if t, ok := data["collected_at"].(string); ok { var _installSelected = null;
b.WriteString(`<p style="font-size:12px;color:#64748b;margin-bottom:16px">Collected: ` + html.EscapeString(t) + `</p>`)
}
// Hardware section function installRefreshDisks() {
hw, _ := data["hardware"].(map[string]any) document.getElementById('install-loading').style.display = '';
if hw == nil { document.getElementById('install-disk-section').style.display = 'none';
hw = data document.getElementById('install-confirm-section').style.display = 'none';
} _installSelected = null;
fetch('/api/install/disks').then(function(r){ return r.json(); }).then(function(disks){
renderHWCards(&b, hw) document.getElementById('install-loading').style.display = 'none';
var tbody = document.getElementById('install-disk-tbody');
// Full JSON below tbody.innerHTML = '';
b.WriteString(`<h2>Raw JSON</h2>`) if (!disks || disks.length === 0) {
pretty, _ := json.MarshalIndent(data, "", " ") tbody.innerHTML = '<tr><td colspan="5" style="color:var(--muted);text-align:center">No installable disks found</td></tr>';
b.WriteString(`<pre>` + html.EscapeString(string(pretty)) + `</pre>`) } else {
b.WriteString(`</body></html>`) disks.forEach(function(d) {
return b.String() var warnings = (d.warnings || []);
var statusHtml;
if (warnings.length === 0) {
statusHtml = '<span class="badge badge-ok">OK</span>';
} else {
var hasSmall = warnings.some(function(w){ return w.indexOf('too small') >= 0; });
statusHtml = warnings.map(function(w){
var cls = hasSmall ? 'badge-err' : 'badge-warn';
return '<span class="badge ' + cls + '" title="' + w.replace(/"/g,'&quot;') + '">' +
(w.length > 40 ? w.substring(0,38)+'…' : w) + '</span>';
}).join(' ');
}
var mountedNote = (d.mounted_parts && d.mounted_parts.length > 0)
? ' <span style="color:var(--warn-fg);font-size:11px">(mounted)</span>' : '';
var tr = document.createElement('tr');
tr.dataset.device = d.device;
tr.dataset.model = d.model || 'Unknown';
tr.dataset.size = d.size;
tr.dataset.warnings = JSON.stringify(warnings);
tr.innerHTML =
'<td><input type="radio" name="install-disk" value="' + d.device + '"></td>' +
'<td><code>' + d.device + '</code>' + mountedNote + '</td>' +
'<td>' + (d.model || '—') + '</td>' +
'<td>' + d.size + '</td>' +
'<td>' + statusHtml + '</td>';
tr.addEventListener('click', function(){ installSelectDisk(this); });
tbody.appendChild(tr);
});
}
document.getElementById('install-disk-section').style.display = '';
}).catch(function(e){
document.getElementById('install-loading').textContent = 'Failed to load disk list: ' + e;
});
} }
func renderHWCards(b *strings.Builder, hw map[string]any) { function installSelectDisk(tr) {
sections := []struct{ key, label string }{ document.querySelectorAll('#install-disk-tbody tr').forEach(function(r){ r.classList.remove('selected'); });
{"board", "Board"}, tr.classList.add('selected');
{"cpus", "CPUs"}, var radio = tr.querySelector('input[type=radio]');
{"memory", "Memory"}, if (radio) radio.checked = true;
{"storage", "Storage"}, _installSelected = {
{"gpus", "GPUs"}, device: tr.dataset.device,
{"nics", "NICs"}, model: tr.dataset.model,
{"psus", "Power Supplies"}, size: tr.dataset.size,
} warnings: JSON.parse(tr.dataset.warnings || '[]')
for _, s := range sections { };
v, ok := hw[s.key] var warnBox = document.getElementById('install-confirm-warn');
if !ok { var warnLines = '<strong>⚠ DANGER:</strong> ' + _installSelected.device +
continue ' (' + _installSelected.model + ', ' + _installSelected.size + ')' +
} ' will be <strong>completely erased</strong> and repartitioned. All data will be lost.<br>';
b.WriteString(`<h2>` + s.label + `</h2><div class="grid">`) if (_installSelected.warnings.length > 0) {
renderValue(b, v) warnLines += '<br>' + _installSelected.warnings.map(function(w){ return '• ' + w; }).join('<br>');
b.WriteString(`</div>`) }
} warnBox.innerHTML = warnLines;
document.getElementById('install-confirm-input').value = '';
document.getElementById('install-start-btn').disabled = true;
document.getElementById('install-confirm-section').style.display = '';
document.getElementById('install-progress-section').style.display = 'none';
} }
func renderValue(b *strings.Builder, v any) { function installDeselect() {
switch val := v.(type) { _installSelected = null;
case []any: document.querySelectorAll('#install-disk-tbody tr').forEach(function(r){ r.classList.remove('selected'); });
for _, item := range val { document.querySelectorAll('#install-disk-tbody input[type=radio]').forEach(function(r){ r.checked = false; });
renderValue(b, item) document.getElementById('install-confirm-section').style.display = 'none';
}
case map[string]any:
b.WriteString(`<div class="card">`)
for k, vv := range val {
b.WriteString(fmt.Sprintf(`<div class="card-title">%s</div><div class="card-value">%s</div>`,
html.EscapeString(k), html.EscapeString(fmt.Sprintf("%v", vv))))
}
b.WriteString(`</div>`)
}
} }
// ── Export index (compatibility) ────────────────────────────────────────────── function installCheckConfirm() {
var val = document.getElementById('install-confirm-input').value.trim();
var ok = _installSelected && val === _installSelected.device;
document.getElementById('install-start-btn').disabled = !ok;
}
function installStart() {
if (!_installSelected) return;
document.getElementById('install-confirm-section').style.display = 'none';
document.getElementById('install-disk-section').style.display = 'none';
document.getElementById('install-loading').style.display = 'none';
var prog = document.getElementById('install-progress-section');
var term = document.getElementById('install-terminal');
var status = document.getElementById('install-status');
prog.style.display = '';
term.textContent = '';
status.textContent = 'Starting installation…';
status.style.color = 'var(--muted)';
fetch('/api/install/run', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({device: _installSelected.device})
}).then(function(r){
if (r.status === 204) {
installStreamLog();
} else {
return r.json().then(function(j){ throw new Error(j.error || r.statusText); });
}
}).catch(function(e){
status.textContent = 'Error: ' + e;
status.style.color = 'var(--crit-fg)';
});
}
function installStreamLog() {
var term = document.getElementById('install-terminal');
var status = document.getElementById('install-status');
var es = new EventSource('/api/install/stream');
es.onmessage = function(e) {
term.textContent += e.data + '\n';
term.scrollTop = term.scrollHeight;
};
es.addEventListener('done', function(e) {
es.close();
if (!e.data) {
status.innerHTML = '<span style="color:var(--ok-fg);font-weight:700">✓ Installation complete.</span> Remove the ISO and reboot.';
var rebootBtn = document.createElement('button');
rebootBtn.className = 'btn btn-primary btn-sm';
rebootBtn.style.marginLeft = '12px';
rebootBtn.textContent = 'Reboot now';
rebootBtn.onclick = function(){
fetch('/api/services/action', {method:'POST',headers:{'Content-Type':'application/json'},
body: JSON.stringify({name:'', action:'reboot'})});
};
status.appendChild(rebootBtn);
} else {
status.textContent = '✗ Installation failed: ' + e.data;
status.style.color = 'var(--crit-fg)';
}
});
es.onerror = function() {
es.close();
status.textContent = '✗ Stream disconnected.';
status.style.color = 'var(--crit-fg)';
};
}
// Auto-load on page open.
installRefreshDisks();
</script>
`
}
func renderExportIndex(exportDir string) (string, error) { func renderExportIndex(exportDir string) (string, error) {
entries, err := listExportFiles(exportDir) entries, err := listExportFiles(exportDir)

View File

@@ -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)

View File

@@ -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.

View File

@@ -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

View File

@@ -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 \
"${@}" "${@}"

View File

@@ -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

View File

@@ -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}"

View File

@@ -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"

View 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-----

View File

@@ -0,0 +1 @@
deb https://developer.download.nvidia.com/compute/cuda/repos/debian12/x86_64/ /

Binary file not shown.

View File

@@ -0,0 +1 @@
deb https://repo.radeon.com/rocm/apt/%%ROCM_VERSION%% jammy main

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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 ==="

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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 \

View File

@@ -1,50 +1,163 @@
#!/bin/sh #!/bin/sh
# Quick network configurator for the local console. # Quick network configurator for the local console.
# Type 'a' at any prompt to abort, 'b' to go back.
set -e set -e
# List interfaces (exclude lo) abort() { echo "Aborted."; exit 0; }
IFACES=$(ip -o link show | awk -F': ' '$2 != "lo" {print $2}' | cut -d@ -f1)
echo "Interfaces:" ask() {
i=1 # ask VARNAME "prompt" [default]
for iface in $IFACES; do # Sets VARNAME. Returns 1 on 'b' (back), calls abort on 'a'.
ip=$(ip -4 addr show "$iface" 2>/dev/null | awk '/inet /{print $2}' | head -1) _var="$1"; _prompt="$2"; _default="$3"
echo " $i) $iface ${ip:-no IP}" while true; do
i=$((i+1)) if [ -n "$_default" ]; then
done printf "%s [%s] (b=back a=abort): " "$_prompt" "$_default"
echo "" else
printf "Interface name [or Enter to pick first]: " printf "%s (b=back a=abort): " "$_prompt"
read IFACE fi
if [ -z "$IFACE" ]; then read _input
IFACE=$(echo "$IFACES" | head -1) case "$_input" in
fi a|A) abort ;;
echo "Selected: $IFACE" b|B) return 1 ;;
echo "" "")
echo " 1) DHCP" if [ -n "$_default" ]; then
echo " 2) Static" eval "$_var=\"\$_default\""
printf "Mode [1]: " return 0
read MODE else
MODE=${MODE:-1} echo " Required — please enter a value."
fi
;;
*)
eval "$_var=\"\$_input\""
return 0
;;
esac
done
}
if [ "$MODE" = "1" ]; then # ── Step 1: choose interface ───────────────────────────────────────────────────
choose_iface() {
IFACES=$(ip -o link show | awk -F': ' '$2 != "lo" {print $2}' | cut -d@ -f1)
if [ -z "$IFACES" ]; then
echo "No network interfaces found."
exit 1
fi
echo ""
echo "Interfaces:"
i=1
for iface in $IFACES; do
ip=$(ip -4 addr show "$iface" 2>/dev/null | awk '/inet /{print $2}' | head -1)
echo " $i) $iface ${ip:-no IP}"
i=$((i+1))
done
echo ""
FIRST=$(echo "$IFACES" | head -1)
while true; do
printf "Interface number or name [%s] (a=abort): " "$FIRST"
read INPUT
case "$INPUT" in
a|A) abort ;;
"")
IFACE="$FIRST"
break
;;
*)
if echo "$INPUT" | grep -qE '^[0-9]+$'; then
IFACE=$(echo "$IFACES" | awk "NR==$INPUT")
if [ -z "$IFACE" ]; then
echo " No interface #$INPUT — try again."
continue
fi
else
# Validate name exists
if ! echo "$IFACES" | grep -qx "$INPUT"; then
echo " Unknown interface '$INPUT' — try again."
continue
fi
IFACE="$INPUT"
fi
break
;;
esac
done
echo "Selected: $IFACE"
}
# ── Step 2: choose mode ────────────────────────────────────────────────────────
choose_mode() {
echo ""
echo " 1) DHCP"
echo " 2) Static IP"
echo ""
while true; do
printf "Mode [1] (b=back a=abort): "
read INPUT
case "$INPUT" in
a|A) abort ;;
b|B) return 1 ;;
""|1) MODE=dhcp; break ;;
2) MODE=static; break ;;
*) echo " Enter 1 or 2." ;;
esac
done
}
# ── Step 3a: DHCP ─────────────────────────────────────────────────────────────
run_dhcp() {
echo "Running DHCP on $IFACE..." echo "Running DHCP on $IFACE..."
dhclient -v "$IFACE" dhclient -v "$IFACE"
else }
printf "IP address (e.g. 192.168.1.100/24): "
read ADDR # ── Step 3b: Static ───────────────────────────────────────────────────────────
printf "Gateway (e.g. 192.168.1.1): "
read GW run_static() {
printf "DNS [8.8.8.8]: " while true; do
read DNS ask ADDR "IP address (e.g. 192.168.1.100/24)" || return 1
DNS=${DNS:-8.8.8.8} # Basic format check: must contain a dot and a /
if ! echo "$ADDR" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/[0-9]+$'; then
echo " Invalid format — use x.x.x.x/prefix (e.g. 192.168.1.10/24)."
continue
fi
break
done
while true; do
ask GW "Gateway (e.g. 192.168.1.1)" || return 1
if ! echo "$GW" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$'; then
echo " Invalid IP address."
continue
fi
break
done
ask DNS "DNS server" "8.8.8.8" || return 1
ip addr flush dev "$IFACE" ip addr flush dev "$IFACE"
ip addr add "$ADDR" dev "$IFACE" ip addr add "$ADDR" dev "$IFACE"
ip link set "$IFACE" up ip link set "$IFACE" up
ip route add default via "$GW" ip route add default via "$GW" 2>/dev/null || true
echo "nameserver $DNS" > /etc/resolv.conf echo "nameserver $DNS" > /etc/resolv.conf
echo "Done." echo "Done."
fi }
# ── Main loop ─────────────────────────────────────────────────────────────────
choose_iface
while true; do
choose_mode || { choose_iface; continue; }
if [ "$MODE" = "dhcp" ]; then
run_dhcp && break
else
run_static && break || continue
fi
done
echo "" echo ""
ip -4 addr show "$IFACE" ip -4 addr show "$IFACE"