feat(webui): replace TUI with full web UI + local openbox desktop
- Remove audit/internal/tui/ (~3000 LOC, bubbletea/lipgloss/reanimator deps) - Add /api/* REST+SSE endpoints: audit, SAT (nvidia/memory/storage/cpu), services, network, export, tools, live metrics stream - Add async job manager with SSE streaming for long-running operations - Add platform.SampleLiveMetrics() for live fan/temp/power/GPU polling - Add multi-page web UI (vanilla JS): Dashboard, Metrics charts, Tests, Burn-in, Network, Services, Export, Tools - Add bee-desktop.service: openbox + Xorg + Chromium opening http://localhost/ - Add openbox/tint2/xorg/xinit/xterm/chromium to ISO package list - Update .profile, bee.sh, and bible-local docs Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -11,7 +11,6 @@ import (
|
|||||||
"bee/audit/internal/app"
|
"bee/audit/internal/app"
|
||||||
"bee/audit/internal/platform"
|
"bee/audit/internal/platform"
|
||||||
"bee/audit/internal/runtimeenv"
|
"bee/audit/internal/runtimeenv"
|
||||||
"bee/audit/internal/tui"
|
|
||||||
"bee/audit/internal/webui"
|
"bee/audit/internal/webui"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -40,8 +39,6 @@ func run(args []string, stdout, stderr io.Writer) int {
|
|||||||
return 0
|
return 0
|
||||||
case "audit":
|
case "audit":
|
||||||
return runAudit(args[1:], stdout, stderr)
|
return runAudit(args[1:], stdout, stderr)
|
||||||
case "tui":
|
|
||||||
return runTUI(args[1:], stdout, stderr)
|
|
||||||
case "export":
|
case "export":
|
||||||
return runExport(args[1:], stdout, stderr)
|
return runExport(args[1:], stdout, stderr)
|
||||||
case "preflight":
|
case "preflight":
|
||||||
@@ -66,7 +63,6 @@ func printRootUsage(w io.Writer) {
|
|||||||
fmt.Fprintln(w, `bee commands:
|
fmt.Fprintln(w, `bee commands:
|
||||||
bee audit --runtime auto|local|livecd --output stdout|file:<path>
|
bee audit --runtime auto|local|livecd --output stdout|file:<path>
|
||||||
bee preflight --output stdout|file:<path>
|
bee preflight --output stdout|file:<path>
|
||||||
bee tui --runtime auto|local|livecd
|
|
||||||
bee export --target <device>
|
bee export --target <device>
|
||||||
bee support-bundle --output stdout|file:<path>
|
bee support-bundle --output stdout|file:<path>
|
||||||
bee web --listen :80 --audit-path `+app.DefaultAuditJSONPath+`
|
bee web --listen :80 --audit-path `+app.DefaultAuditJSONPath+`
|
||||||
@@ -79,8 +75,6 @@ func runHelp(args []string, stdout, stderr io.Writer) int {
|
|||||||
switch args[0] {
|
switch args[0] {
|
||||||
case "audit":
|
case "audit":
|
||||||
return runAudit([]string{"--help"}, stdout, stdout)
|
return runAudit([]string{"--help"}, stdout, stdout)
|
||||||
case "tui":
|
|
||||||
return runTUI([]string{"--help"}, stdout, stdout)
|
|
||||||
case "export":
|
case "export":
|
||||||
return runExport([]string{"--help"}, stdout, stdout)
|
return runExport([]string{"--help"}, stdout, stdout)
|
||||||
case "preflight":
|
case "preflight":
|
||||||
@@ -145,42 +139,6 @@ func runAudit(args []string, stdout, stderr io.Writer) int {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func runTUI(args []string, stdout, stderr io.Writer) int {
|
|
||||||
fs := flag.NewFlagSet("tui", flag.ContinueOnError)
|
|
||||||
fs.SetOutput(stderr)
|
|
||||||
runtimeFlag := fs.String("runtime", "auto", "runtime environment: auto, local, livecd")
|
|
||||||
fs.Usage = func() {
|
|
||||||
fmt.Fprintln(stderr, "usage: bee tui [--runtime auto|local|livecd]")
|
|
||||||
fs.PrintDefaults()
|
|
||||||
}
|
|
||||||
if err := fs.Parse(args); err != nil {
|
|
||||||
if err == flag.ErrHelp {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return 2
|
|
||||||
}
|
|
||||||
if fs.NArg() != 0 {
|
|
||||||
fs.Usage()
|
|
||||||
return 2
|
|
||||||
}
|
|
||||||
|
|
||||||
runtimeInfo, err := runtimeenv.Detect(*runtimeFlag)
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("resolve runtime", "err", err)
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
slog.SetDefault(slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{
|
|
||||||
Level: slog.LevelInfo,
|
|
||||||
})))
|
|
||||||
|
|
||||||
application := app.New(platform.New())
|
|
||||||
if err := tui.Run(application, runtimeInfo.Mode); err != nil {
|
|
||||||
slog.Error("run tui", "err", err)
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func runExport(args []string, stdout, stderr io.Writer) int {
|
func runExport(args []string, stdout, stderr io.Writer) int {
|
||||||
fs := flag.NewFlagSet("export", flag.ContinueOnError)
|
fs := flag.NewFlagSet("export", flag.ContinueOnError)
|
||||||
@@ -333,10 +291,18 @@ func runWeb(args []string, stdout, stderr io.Writer) int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
slog.Info("starting bee web", "listen", *listenAddr, "audit_path", *auditPath)
|
slog.Info("starting bee web", "listen", *listenAddr, "audit_path", *auditPath)
|
||||||
|
|
||||||
|
runtimeInfo, err := runtimeenv.Detect("auto")
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("resolve runtime for web", "err", err)
|
||||||
|
}
|
||||||
|
|
||||||
if err := webui.ListenAndServe(*listenAddr, webui.HandlerOptions{
|
if err := webui.ListenAndServe(*listenAddr, webui.HandlerOptions{
|
||||||
Title: *title,
|
Title: *title,
|
||||||
AuditPath: *auditPath,
|
AuditPath: *auditPath,
|
||||||
ExportDir: *exportDir,
|
ExportDir: *exportDir,
|
||||||
|
App: app.New(platform.New()),
|
||||||
|
RuntimeMode: runtimeInfo.Mode,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
slog.Error("run web", "err", err)
|
slog.Error("run web", "err", err)
|
||||||
return 1
|
return 1
|
||||||
|
|||||||
25
audit/go.mod
25
audit/go.mod
@@ -1,28 +1,3 @@
|
|||||||
module bee/audit
|
module bee/audit
|
||||||
|
|
||||||
go 1.24.0
|
go 1.24.0
|
||||||
|
|
||||||
replace reanimator/chart => ../internal/chart
|
|
||||||
|
|
||||||
require github.com/charmbracelet/bubbletea v1.3.4
|
|
||||||
require github.com/charmbracelet/lipgloss v1.0.0
|
|
||||||
require reanimator/chart v0.0.0
|
|
||||||
|
|
||||||
require (
|
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
|
||||||
github.com/charmbracelet/lipgloss v1.0.0 // promoted to direct — used for TUI colors
|
|
||||||
github.com/charmbracelet/x/ansi v0.8.0 // indirect
|
|
||||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
|
||||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
|
||||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
|
||||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
|
||||||
github.com/muesli/termenv v0.15.2 // indirect
|
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
|
||||||
golang.org/x/sync v0.11.0 // indirect
|
|
||||||
golang.org/x/sys v0.30.0 // indirect
|
|
||||||
golang.org/x/text v0.3.8 // indirect
|
|
||||||
)
|
|
||||||
|
|||||||
37
audit/go.sum
37
audit/go.sum
@@ -1,37 +0,0 @@
|
|||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
|
||||||
github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI=
|
|
||||||
github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo=
|
|
||||||
github.com/charmbracelet/lipgloss v1.0.0 h1:O7VkGDvqEdGi93X+DeqsQ7PKHDgtQfF8j8/O2qFMQNg=
|
|
||||||
github.com/charmbracelet/lipgloss v1.0.0/go.mod h1:U5fy9Z+C38obMs+T+tJqst9VGzlOYGj4ri9reL3qUlo=
|
|
||||||
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
|
|
||||||
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
|
|
||||||
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
|
||||||
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
|
||||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
|
||||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
|
||||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
|
||||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
|
||||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
|
||||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
|
||||||
github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
|
|
||||||
github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
|
|
||||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
|
||||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
|
||||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
|
||||||
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
|
|
||||||
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
|
||||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
|
||||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
|
||||||
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
|
|
||||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
|
||||||
45
audit/internal/platform/live_metrics.go
Normal file
45
audit/internal/platform/live_metrics.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
package platform
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// LiveMetricSample is a single point-in-time snapshot of server metrics
|
||||||
|
// collected for the web UI metrics page.
|
||||||
|
type LiveMetricSample struct {
|
||||||
|
Timestamp time.Time `json:"ts"`
|
||||||
|
Fans []FanReading `json:"fans"`
|
||||||
|
Temps []TempReading `json:"temps"`
|
||||||
|
PowerW float64 `json:"power_w"`
|
||||||
|
GPUs []GPUMetricRow `json:"gpus"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TempReading is a named temperature sensor value.
|
||||||
|
type TempReading struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Celsius float64 `json:"celsius"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SampleLiveMetrics collects a single metrics snapshot from all available
|
||||||
|
// sources: GPU (via nvidia-smi), fans and temperatures (via ipmitool/sensors),
|
||||||
|
// and system power (via ipmitool dcmi). Missing sources are silently skipped.
|
||||||
|
func SampleLiveMetrics() LiveMetricSample {
|
||||||
|
s := LiveMetricSample{Timestamp: time.Now().UTC()}
|
||||||
|
|
||||||
|
// GPU metrics — skipped silently if nvidia-smi unavailable
|
||||||
|
gpus, _ := SampleGPUMetrics(nil)
|
||||||
|
s.GPUs = gpus
|
||||||
|
|
||||||
|
// Fan speeds — skipped silently if ipmitool unavailable
|
||||||
|
fans, _ := sampleFanSpeeds()
|
||||||
|
s.Fans = fans
|
||||||
|
|
||||||
|
// CPU/system temperature — returns 0 if unavailable
|
||||||
|
cpuTemp := sampleCPUMaxTemp()
|
||||||
|
if cpuTemp > 0 {
|
||||||
|
s.Temps = append(s.Temps, TempReading{Name: "CPU", Celsius: cpuTemp})
|
||||||
|
}
|
||||||
|
|
||||||
|
// System power — returns 0 if unavailable
|
||||||
|
s.PowerW = sampleSystemPower()
|
||||||
|
|
||||||
|
return s
|
||||||
|
}
|
||||||
@@ -1,220 +0,0 @@
|
|||||||
package tui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"bee/audit/internal/platform"
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (m model) updateStaticForm(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
||||||
switch msg.String() {
|
|
||||||
case "esc":
|
|
||||||
m.screen = screenNetwork
|
|
||||||
m.formFields = nil
|
|
||||||
m.formIndex = 0
|
|
||||||
return m, nil
|
|
||||||
case "up", "shift+tab":
|
|
||||||
if m.formIndex > 0 {
|
|
||||||
m.formIndex--
|
|
||||||
}
|
|
||||||
case "down", "tab":
|
|
||||||
if m.formIndex < len(m.formFields)-1 {
|
|
||||||
m.formIndex++
|
|
||||||
}
|
|
||||||
case "enter":
|
|
||||||
if m.formIndex < len(m.formFields)-1 {
|
|
||||||
m.formIndex++
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
cfg := m.app.ParseStaticIPv4Config(m.selectedIface, []string{
|
|
||||||
m.formFields[0].Value,
|
|
||||||
m.formFields[1].Value,
|
|
||||||
m.formFields[2].Value,
|
|
||||||
m.formFields[3].Value,
|
|
||||||
})
|
|
||||||
m.busy = true
|
|
||||||
m.busyTitle = "Static IPv4: " + m.selectedIface
|
|
||||||
return m, func() tea.Msg {
|
|
||||||
result, err := m.app.SetStaticIPv4Result(cfg)
|
|
||||||
return resultMsg{title: result.Title, body: result.Body, err: err, back: screenNetwork}
|
|
||||||
}
|
|
||||||
case "backspace":
|
|
||||||
field := &m.formFields[m.formIndex]
|
|
||||||
if len(field.Value) > 0 {
|
|
||||||
field.Value = field.Value[:len(field.Value)-1]
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
if msg.Type == tea.KeyRunes && len(msg.Runes) > 0 {
|
|
||||||
m.formFields[m.formIndex].Value += string(msg.Runes)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m model) updateConfirm(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
||||||
switch msg.String() {
|
|
||||||
case "left", "up", "tab":
|
|
||||||
if m.cursor > 0 {
|
|
||||||
m.cursor--
|
|
||||||
}
|
|
||||||
case "right", "down":
|
|
||||||
if m.cursor < 1 {
|
|
||||||
m.cursor++
|
|
||||||
}
|
|
||||||
case "esc":
|
|
||||||
m.screen = m.confirmCancelTarget()
|
|
||||||
m.cursor = 0
|
|
||||||
m.pendingAction = actionNone
|
|
||||||
return m, nil
|
|
||||||
case "enter":
|
|
||||||
if m.cursor == 1 { // Cancel
|
|
||||||
m.screen = m.confirmCancelTarget()
|
|
||||||
m.cursor = 0
|
|
||||||
m.pendingAction = actionNone
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
m.busy = true
|
|
||||||
switch m.pendingAction {
|
|
||||||
case actionExportBundle:
|
|
||||||
m.busyTitle = "Export support bundle"
|
|
||||||
target := *m.selectedTarget
|
|
||||||
return m, func() tea.Msg {
|
|
||||||
result, err := m.app.ExportSupportBundleResult(target)
|
|
||||||
return resultMsg{title: result.Title, body: result.Body, err: err, back: screenMain}
|
|
||||||
}
|
|
||||||
case actionRunAll:
|
|
||||||
return m.executeRunAll()
|
|
||||||
case actionRunMemorySAT:
|
|
||||||
m.busyTitle = "Memory test"
|
|
||||||
m.progressPrefix = "memory"
|
|
||||||
m.progressSince = time.Now()
|
|
||||||
m.progressLines = nil
|
|
||||||
since := m.progressSince
|
|
||||||
return m, tea.Batch(
|
|
||||||
func() tea.Msg {
|
|
||||||
result, err := m.app.RunMemoryAcceptancePackResult("")
|
|
||||||
return resultMsg{title: result.Title, body: result.Body, err: err, back: screenHealthCheck}
|
|
||||||
},
|
|
||||||
pollSATProgress("memory", since),
|
|
||||||
)
|
|
||||||
case actionRunStorageSAT:
|
|
||||||
m.busyTitle = "Storage test"
|
|
||||||
m.progressPrefix = "storage"
|
|
||||||
m.progressSince = time.Now()
|
|
||||||
m.progressLines = nil
|
|
||||||
since := m.progressSince
|
|
||||||
return m, tea.Batch(
|
|
||||||
func() tea.Msg {
|
|
||||||
result, err := m.app.RunStorageAcceptancePackResult("")
|
|
||||||
return resultMsg{title: result.Title, body: result.Body, err: err, back: screenHealthCheck}
|
|
||||||
},
|
|
||||||
pollSATProgress("storage", since),
|
|
||||||
)
|
|
||||||
case actionRunCPUSAT:
|
|
||||||
m.busyTitle = "CPU test"
|
|
||||||
m.progressPrefix = "cpu"
|
|
||||||
m.progressSince = time.Now()
|
|
||||||
m.progressLines = nil
|
|
||||||
since := m.progressSince
|
|
||||||
durationSec := hcCPUDurations[m.hcMode]
|
|
||||||
return m, tea.Batch(
|
|
||||||
func() tea.Msg {
|
|
||||||
result, err := m.app.RunCPUAcceptancePackResult("", durationSec)
|
|
||||||
return resultMsg{title: result.Title, body: result.Body, err: err, back: screenHealthCheck}
|
|
||||||
},
|
|
||||||
pollSATProgress("cpu", since),
|
|
||||||
)
|
|
||||||
case actionRunAMDGPUSAT:
|
|
||||||
m.busyTitle = "AMD GPU test"
|
|
||||||
m.progressPrefix = "gpu-amd"
|
|
||||||
m.progressSince = time.Now()
|
|
||||||
m.progressLines = nil
|
|
||||||
since := m.progressSince
|
|
||||||
return m, tea.Batch(
|
|
||||||
func() tea.Msg {
|
|
||||||
result, err := m.app.RunAMDAcceptancePackResult("")
|
|
||||||
return resultMsg{title: result.Title, body: result.Body, err: err, back: screenHealthCheck}
|
|
||||||
},
|
|
||||||
pollSATProgress("gpu-amd", since),
|
|
||||||
)
|
|
||||||
case actionRunFanStress:
|
|
||||||
return m.startGPUStressTest()
|
|
||||||
case actionInstallToDisk:
|
|
||||||
return m.startInstall()
|
|
||||||
case actionRunNCCLTests:
|
|
||||||
m.busy = true
|
|
||||||
m.busyTitle = "NCCL bandwidth test"
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
m.ncclCancel = cancel
|
|
||||||
return m, func() tea.Msg {
|
|
||||||
result, err := m.app.RunNCCLTestsResult(ctx)
|
|
||||||
return resultMsg{title: result.Title, body: result.Body, err: err, back: screenBurnInTests}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case "ctrl+c":
|
|
||||||
return m, tea.Quit
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m model) confirmCancelTarget() screen {
|
|
||||||
switch m.pendingAction {
|
|
||||||
case actionExportBundle:
|
|
||||||
return screenExportTargets
|
|
||||||
case actionRunAll, actionRunMemorySAT, actionRunStorageSAT, actionRunCPUSAT, actionRunAMDGPUSAT:
|
|
||||||
return screenHealthCheck
|
|
||||||
case actionRunFanStress, actionRunNCCLTests:
|
|
||||||
return screenBurnInTests
|
|
||||||
case actionInstallToDisk:
|
|
||||||
return screenInstallDiskPick
|
|
||||||
default:
|
|
||||||
return screenMain
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// hcFanStressOpts builds FanStressOptions for the selected mode, auto-detecting all GPUs.
|
|
||||||
func hcFanStressOpts(hcMode int, application interface {
|
|
||||||
ListNvidiaGPUs() ([]platform.NvidiaGPU, error)
|
|
||||||
}) platform.FanStressOptions {
|
|
||||||
// Phase durations per mode: [baseline, load1, pause, load2]
|
|
||||||
type durations struct{ baseline, load1, pause, load2 int }
|
|
||||||
modes := [3]durations{
|
|
||||||
{30, 120, 30, 120}, // Quick: ~5 min total
|
|
||||||
{60, 300, 60, 300}, // Standard: ~12 min total
|
|
||||||
{60, 600, 120, 600}, // Express: ~24 min total
|
|
||||||
}
|
|
||||||
if hcMode < 0 || hcMode >= len(modes) {
|
|
||||||
hcMode = 0
|
|
||||||
}
|
|
||||||
d := modes[hcMode]
|
|
||||||
|
|
||||||
// Use all detected NVIDIA GPUs.
|
|
||||||
var indices []int
|
|
||||||
if gpus, err := application.ListNvidiaGPUs(); err == nil {
|
|
||||||
for _, g := range gpus {
|
|
||||||
indices = append(indices, g.Index)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use nearly full GPU memory on the smallest GPU (leave 512 MB for driver overhead).
|
|
||||||
sizeMB := 64
|
|
||||||
if gpus, err := application.ListNvidiaGPUs(); err == nil {
|
|
||||||
for _, g := range gpus {
|
|
||||||
free := g.MemoryMB - 512
|
|
||||||
if free > 0 && (sizeMB == 64 || free < sizeMB) {
|
|
||||||
sizeMB = free
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return platform.FanStressOptions{
|
|
||||||
BaselineSec: d.baseline,
|
|
||||||
Phase1DurSec: d.load1,
|
|
||||||
PauseSec: d.pause,
|
|
||||||
Phase2DurSec: d.load2,
|
|
||||||
SizeMB: sizeMB,
|
|
||||||
GPUIndices: indices,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
package tui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bee/audit/internal/app"
|
|
||||||
"bee/audit/internal/platform"
|
|
||||||
)
|
|
||||||
|
|
||||||
type resultMsg struct {
|
|
||||||
title string
|
|
||||||
body string
|
|
||||||
err error
|
|
||||||
back screen
|
|
||||||
}
|
|
||||||
|
|
||||||
type servicesMsg struct {
|
|
||||||
services []string
|
|
||||||
err error
|
|
||||||
}
|
|
||||||
|
|
||||||
type interfacesMsg struct {
|
|
||||||
ifaces []platform.InterfaceInfo
|
|
||||||
err error
|
|
||||||
}
|
|
||||||
|
|
||||||
type exportTargetsMsg struct {
|
|
||||||
targets []platform.RemovableTarget
|
|
||||||
err error
|
|
||||||
}
|
|
||||||
|
|
||||||
type snapshotMsg struct {
|
|
||||||
banner string
|
|
||||||
panel app.HardwarePanelData
|
|
||||||
}
|
|
||||||
|
|
||||||
type nvtopClosedMsg struct{}
|
|
||||||
|
|
||||||
type nvidiaSATDoneMsg struct {
|
|
||||||
title string
|
|
||||||
body string
|
|
||||||
err error
|
|
||||||
}
|
|
||||||
|
|
||||||
type gpuStressDoneMsg struct {
|
|
||||||
title string
|
|
||||||
body string
|
|
||||||
err error
|
|
||||||
}
|
|
||||||
|
|
||||||
type gpuLiveTickMsg struct {
|
|
||||||
rows []platform.GPUMetricRow
|
|
||||||
indices []int
|
|
||||||
}
|
|
||||||
|
|
||||||
type installDisksMsg struct {
|
|
||||||
disks []platform.InstallDisk
|
|
||||||
err error
|
|
||||||
}
|
|
||||||
|
|
||||||
type installDoneMsg struct {
|
|
||||||
err error
|
|
||||||
}
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
package tui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"bee/audit/internal/app"
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
|
||||||
)
|
|
||||||
|
|
||||||
type satProgressMsg struct {
|
|
||||||
lines []string
|
|
||||||
}
|
|
||||||
|
|
||||||
// pollSATProgress returns a Cmd that waits 300ms then reads the latest verbose.log
|
|
||||||
// for the given SAT prefix and returns parsed step progress lines.
|
|
||||||
func pollSATProgress(prefix string, since time.Time) tea.Cmd {
|
|
||||||
return tea.Tick(300*time.Millisecond, func(_ time.Time) tea.Msg {
|
|
||||||
return satProgressMsg{lines: readSATProgressLines(prefix, since)}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func readSATProgressLines(prefix string, since time.Time) []string {
|
|
||||||
pattern := filepath.Join(app.DefaultSATBaseDir, prefix+"-*/verbose.log")
|
|
||||||
matches, err := filepath.Glob(pattern)
|
|
||||||
if err != nil || len(matches) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
sort.Strings(matches)
|
|
||||||
// Find the latest file created at or after (since - 5s) to account for clock skew.
|
|
||||||
cutoff := since.Add(-5 * time.Second)
|
|
||||||
candidate := ""
|
|
||||||
for _, m := range matches {
|
|
||||||
info, statErr := os.Stat(m)
|
|
||||||
if statErr == nil && info.ModTime().After(cutoff) {
|
|
||||||
candidate = m
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if candidate == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
raw, err := os.ReadFile(candidate)
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return parseSATVerboseProgress(string(raw))
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseSATVerboseProgress parses verbose.log content and returns display lines like:
|
|
||||||
//
|
|
||||||
// "PASS lscpu (234ms)"
|
|
||||||
// "FAIL stress-ng (60.0s)"
|
|
||||||
// "... sensors-after"
|
|
||||||
func parseSATVerboseProgress(content string) []string {
|
|
||||||
type step struct {
|
|
||||||
name string
|
|
||||||
rc int
|
|
||||||
durationMs int
|
|
||||||
done bool
|
|
||||||
}
|
|
||||||
|
|
||||||
lines := strings.Split(content, "\n")
|
|
||||||
var steps []step
|
|
||||||
stepIdx := map[string]int{}
|
|
||||||
|
|
||||||
for i, line := range lines {
|
|
||||||
line = strings.TrimSpace(line)
|
|
||||||
if idx := strings.Index(line, "] start "); idx >= 0 {
|
|
||||||
name := strings.TrimSpace(line[idx+len("] start "):])
|
|
||||||
if _, exists := stepIdx[name]; !exists {
|
|
||||||
stepIdx[name] = len(steps)
|
|
||||||
steps = append(steps, step{name: name})
|
|
||||||
}
|
|
||||||
} else if idx := strings.Index(line, "] finish "); idx >= 0 {
|
|
||||||
name := strings.TrimSpace(line[idx+len("] finish "):])
|
|
||||||
si, exists := stepIdx[name]
|
|
||||||
if !exists {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
steps[si].done = true
|
|
||||||
for j := i + 1; j < len(lines) && j <= i+3; j++ {
|
|
||||||
l := strings.TrimSpace(lines[j])
|
|
||||||
if strings.HasPrefix(l, "rc: ") {
|
|
||||||
steps[si].rc, _ = strconv.Atoi(strings.TrimPrefix(l, "rc: "))
|
|
||||||
} else if strings.HasPrefix(l, "duration_ms: ") {
|
|
||||||
steps[si].durationMs, _ = strconv.Atoi(strings.TrimPrefix(l, "duration_ms: "))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var result []string
|
|
||||||
for _, s := range steps {
|
|
||||||
display := cleanSATStepName(s.name)
|
|
||||||
if s.done {
|
|
||||||
status := "PASS"
|
|
||||||
if s.rc != 0 {
|
|
||||||
status = "FAIL"
|
|
||||||
}
|
|
||||||
result = append(result, fmt.Sprintf("%-4s %s (%s)", status, display, fmtDurMs(s.durationMs)))
|
|
||||||
} else {
|
|
||||||
result = append(result, fmt.Sprintf("... %s", display))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// cleanSATStepName strips leading digits and dash: "01-lscpu.log" → "lscpu".
|
|
||||||
func cleanSATStepName(name string) string {
|
|
||||||
name = strings.TrimSuffix(name, ".log")
|
|
||||||
i := 0
|
|
||||||
for i < len(name) && name[i] >= '0' && name[i] <= '9' {
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
if i < len(name) && name[i] == '-' {
|
|
||||||
name = name[i+1:]
|
|
||||||
}
|
|
||||||
return name
|
|
||||||
}
|
|
||||||
|
|
||||||
// pollInstallProgress tails the install log file and returns recent lines as progress.
|
|
||||||
func pollInstallProgress(logFile string) tea.Cmd {
|
|
||||||
return tea.Tick(500*time.Millisecond, func(_ time.Time) tea.Msg {
|
|
||||||
return satProgressMsg{lines: readInstallProgressLines(logFile)}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func readInstallProgressLines(logFile string) []string {
|
|
||||||
raw, err := os.ReadFile(logFile)
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
lines := strings.Split(strings.TrimSpace(string(raw)), "\n")
|
|
||||||
// Show last 12 lines
|
|
||||||
if len(lines) > 12 {
|
|
||||||
lines = lines[len(lines)-12:]
|
|
||||||
}
|
|
||||||
return lines
|
|
||||||
}
|
|
||||||
|
|
||||||
func fmtDurMs(ms int) string {
|
|
||||||
if ms < 1000 {
|
|
||||||
return fmt.Sprintf("%dms", ms)
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%.1fs", float64(ms)/1000)
|
|
||||||
}
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
package tui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
burnCurGPUStress = 0
|
|
||||||
burnCurModeQuick = 1
|
|
||||||
burnCurModeStd = 2
|
|
||||||
burnCurModeExpr = 3
|
|
||||||
burnCurRun = 4
|
|
||||||
burnCurNCCLTests = 5
|
|
||||||
burnCurTotal = 6
|
|
||||||
)
|
|
||||||
|
|
||||||
func (m model) enterBurnInTests() (tea.Model, tea.Cmd) {
|
|
||||||
m.screen = screenBurnInTests
|
|
||||||
m.cursor = 0
|
|
||||||
if !m.burnInitialized {
|
|
||||||
m.burnMode = 0
|
|
||||||
m.burnCursor = 0
|
|
||||||
m.burnInitialized = true
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m model) updateBurnInTests(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
||||||
switch msg.String() {
|
|
||||||
case "up", "k":
|
|
||||||
if m.burnCursor > 0 {
|
|
||||||
m.burnCursor--
|
|
||||||
}
|
|
||||||
case "down", "j":
|
|
||||||
if m.burnCursor < burnCurTotal-1 {
|
|
||||||
m.burnCursor++
|
|
||||||
}
|
|
||||||
case " ":
|
|
||||||
switch m.burnCursor {
|
|
||||||
case burnCurModeQuick, burnCurModeStd, burnCurModeExpr:
|
|
||||||
m.burnMode = m.burnCursor - burnCurModeQuick
|
|
||||||
}
|
|
||||||
case "enter":
|
|
||||||
switch m.burnCursor {
|
|
||||||
case burnCurGPUStress, burnCurRun:
|
|
||||||
return m.burnRunSelected()
|
|
||||||
case burnCurModeQuick, burnCurModeStd, burnCurModeExpr:
|
|
||||||
m.burnMode = m.burnCursor - burnCurModeQuick
|
|
||||||
case burnCurNCCLTests:
|
|
||||||
return m.burnRunNCCL()
|
|
||||||
}
|
|
||||||
case "f", "F", "r", "R":
|
|
||||||
return m.burnRunSelected()
|
|
||||||
case "n", "N":
|
|
||||||
return m.burnRunNCCL()
|
|
||||||
case "1":
|
|
||||||
m.burnMode = 0
|
|
||||||
case "2":
|
|
||||||
m.burnMode = 1
|
|
||||||
case "3":
|
|
||||||
m.burnMode = 2
|
|
||||||
case "esc":
|
|
||||||
m.screen = screenMain
|
|
||||||
m.cursor = 1
|
|
||||||
case "q", "ctrl+c":
|
|
||||||
return m, tea.Quit
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m model) burnRunSelected() (tea.Model, tea.Cmd) {
|
|
||||||
return m.hcRunFanStress()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m model) burnRunNCCL() (tea.Model, tea.Cmd) {
|
|
||||||
m.pendingAction = actionRunNCCLTests
|
|
||||||
m.screen = screenConfirm
|
|
||||||
m.cursor = 0
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func renderBurnInTests(m model) string {
|
|
||||||
var b strings.Builder
|
|
||||||
|
|
||||||
fmt.Fprintln(&b, "BURN-IN TESTS")
|
|
||||||
fmt.Fprintln(&b)
|
|
||||||
fmt.Fprintln(&b, " Stress tests:")
|
|
||||||
fmt.Fprintln(&b)
|
|
||||||
|
|
||||||
pfx := " "
|
|
||||||
if m.burnCursor == burnCurGPUStress {
|
|
||||||
pfx = "> "
|
|
||||||
}
|
|
||||||
fmt.Fprintf(&b, "%s[ GPU PLATFORM STRESS TEST [F] ] (thermal cycling, fan lag, throttle check)\n", pfx)
|
|
||||||
|
|
||||||
fmt.Fprintln(&b)
|
|
||||||
fmt.Fprintln(&b, " Mode:")
|
|
||||||
modes := []struct{ label, key string }{
|
|
||||||
{"Quick", "1"},
|
|
||||||
{"Standard", "2"},
|
|
||||||
{"Express", "3"},
|
|
||||||
}
|
|
||||||
for i, mode := range modes {
|
|
||||||
pfx := " "
|
|
||||||
if m.burnCursor == burnCurModeQuick+i {
|
|
||||||
pfx = "> "
|
|
||||||
}
|
|
||||||
radio := "( )"
|
|
||||||
if m.burnMode == i {
|
|
||||||
radio = "(*)"
|
|
||||||
}
|
|
||||||
fmt.Fprintf(&b, "%s%s %-10s [%s]\n", pfx, radio, mode.label, mode.key)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Fprintln(&b)
|
|
||||||
pfx = " "
|
|
||||||
if m.burnCursor == burnCurRun {
|
|
||||||
pfx = "> "
|
|
||||||
}
|
|
||||||
fmt.Fprintf(&b, "%s[ RUN SELECTED [R] ]\n", pfx)
|
|
||||||
|
|
||||||
fmt.Fprintln(&b)
|
|
||||||
pfx = " "
|
|
||||||
if m.burnCursor == burnCurNCCLTests {
|
|
||||||
pfx = "> "
|
|
||||||
}
|
|
||||||
fmt.Fprintf(&b, "%s[ NCCL BANDWIDTH TEST [N] ] (all_reduce_perf, NVLink/PCIe bandwidth)\n", pfx)
|
|
||||||
|
|
||||||
fmt.Fprintln(&b)
|
|
||||||
fmt.Fprintln(&b, "─────────────────────────────────────────────────────────────────")
|
|
||||||
fmt.Fprint(&b, "[↑↓] move [space/enter] select [1/2/3] mode [R/F] run [N] nccl [Esc] back")
|
|
||||||
return b.String()
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
package tui
|
|
||||||
|
|
||||||
import tea "github.com/charmbracelet/bubbletea"
|
|
||||||
|
|
||||||
func (m model) handleExportTargetsMenu() (tea.Model, tea.Cmd) {
|
|
||||||
if len(m.targets) == 0 {
|
|
||||||
return m, resultCmd(
|
|
||||||
"Export support bundle",
|
|
||||||
"No writable removable filesystems found.\n\nRead-only or boot media are hidden from this list.",
|
|
||||||
nil,
|
|
||||||
screenMain,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
target := m.targets[m.cursor]
|
|
||||||
m.selectedTarget = &target
|
|
||||||
m.pendingAction = actionExportBundle
|
|
||||||
m.screen = screenConfirm
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
@@ -1,366 +0,0 @@
|
|||||||
package tui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"bee/audit/internal/platform"
|
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Component indices.
|
|
||||||
const (
|
|
||||||
hcGPU = 0
|
|
||||||
hcMemory = 1
|
|
||||||
hcStorage = 2
|
|
||||||
hcCPU = 3
|
|
||||||
)
|
|
||||||
|
|
||||||
// Cursor positions in Health Check screen.
|
|
||||||
const (
|
|
||||||
hcCurGPU = 0
|
|
||||||
hcCurMemory = 1
|
|
||||||
hcCurStorage = 2
|
|
||||||
hcCurCPU = 3
|
|
||||||
hcCurSelectAll = 4
|
|
||||||
hcCurModeQuick = 5
|
|
||||||
hcCurModeStd = 6
|
|
||||||
hcCurModeExpr = 7
|
|
||||||
hcCurRunAll = 8
|
|
||||||
hcCurTotal = 9
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
// hcCPUDurations maps mode index to CPU stress-ng seconds.
|
|
||||||
var hcCPUDurations = [3]int{60, 300, 900}
|
|
||||||
|
|
||||||
func (m model) enterHealthCheck() (tea.Model, tea.Cmd) {
|
|
||||||
m.screen = screenHealthCheck
|
|
||||||
if !m.hcInitialized {
|
|
||||||
m.hcSel = [4]bool{true, true, true, true}
|
|
||||||
m.hcMode = 0
|
|
||||||
m.hcCursor = 0
|
|
||||||
m.hcInitialized = true
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m model) updateHealthCheck(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
||||||
switch msg.String() {
|
|
||||||
case "up", "k":
|
|
||||||
if m.hcCursor > 0 {
|
|
||||||
m.hcCursor--
|
|
||||||
}
|
|
||||||
case "down", "j":
|
|
||||||
if m.hcCursor < hcCurTotal-1 {
|
|
||||||
m.hcCursor++
|
|
||||||
}
|
|
||||||
case " ":
|
|
||||||
switch m.hcCursor {
|
|
||||||
case hcCurGPU, hcCurMemory, hcCurStorage, hcCurCPU:
|
|
||||||
m.hcSel[m.hcCursor] = !m.hcSel[m.hcCursor]
|
|
||||||
case hcCurSelectAll:
|
|
||||||
allOn := m.hcSel[0] && m.hcSel[1] && m.hcSel[2] && m.hcSel[3]
|
|
||||||
for i := range m.hcSel {
|
|
||||||
m.hcSel[i] = !allOn
|
|
||||||
}
|
|
||||||
case hcCurModeQuick, hcCurModeStd, hcCurModeExpr:
|
|
||||||
m.hcMode = m.hcCursor - hcCurModeQuick
|
|
||||||
}
|
|
||||||
case "enter":
|
|
||||||
switch m.hcCursor {
|
|
||||||
case hcCurGPU, hcCurMemory, hcCurStorage, hcCurCPU:
|
|
||||||
return m.hcRunSingle(m.hcCursor)
|
|
||||||
case hcCurSelectAll:
|
|
||||||
allOn := m.hcSel[0] && m.hcSel[1] && m.hcSel[2] && m.hcSel[3]
|
|
||||||
for i := range m.hcSel {
|
|
||||||
m.hcSel[i] = !allOn
|
|
||||||
}
|
|
||||||
case hcCurModeQuick, hcCurModeStd, hcCurModeExpr:
|
|
||||||
m.hcMode = m.hcCursor - hcCurModeQuick
|
|
||||||
case hcCurRunAll:
|
|
||||||
return m.hcRunAll()
|
|
||||||
}
|
|
||||||
case "g", "G":
|
|
||||||
return m.hcRunSingle(hcGPU)
|
|
||||||
case "m", "M":
|
|
||||||
return m.hcRunSingle(hcMemory)
|
|
||||||
case "s", "S":
|
|
||||||
return m.hcRunSingle(hcStorage)
|
|
||||||
case "c", "C":
|
|
||||||
return m.hcRunSingle(hcCPU)
|
|
||||||
case "r", "R":
|
|
||||||
return m.hcRunAll()
|
|
||||||
case "a", "A":
|
|
||||||
allOn := m.hcSel[0] && m.hcSel[1] && m.hcSel[2] && m.hcSel[3]
|
|
||||||
for i := range m.hcSel {
|
|
||||||
m.hcSel[i] = !allOn
|
|
||||||
}
|
|
||||||
case "1":
|
|
||||||
m.hcMode = 0
|
|
||||||
case "2":
|
|
||||||
m.hcMode = 1
|
|
||||||
case "3":
|
|
||||||
m.hcMode = 2
|
|
||||||
case "esc":
|
|
||||||
m.screen = screenMain
|
|
||||||
m.cursor = 0
|
|
||||||
case "q", "ctrl+c":
|
|
||||||
return m, tea.Quit
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m model) hcRunSingle(idx int) (tea.Model, tea.Cmd) {
|
|
||||||
switch idx {
|
|
||||||
case hcGPU:
|
|
||||||
if m.app.DetectGPUVendor() == "amd" {
|
|
||||||
m.pendingAction = actionRunAMDGPUSAT
|
|
||||||
m.screen = screenConfirm
|
|
||||||
m.cursor = 0
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
m.nvidiaDurIdx = m.hcMode
|
|
||||||
return m.enterNvidiaSATSetup()
|
|
||||||
case hcMemory:
|
|
||||||
m.pendingAction = actionRunMemorySAT
|
|
||||||
m.screen = screenConfirm
|
|
||||||
m.cursor = 0
|
|
||||||
return m, nil
|
|
||||||
case hcStorage:
|
|
||||||
m.pendingAction = actionRunStorageSAT
|
|
||||||
m.screen = screenConfirm
|
|
||||||
m.cursor = 0
|
|
||||||
return m, nil
|
|
||||||
case hcCPU:
|
|
||||||
m.pendingAction = actionRunCPUSAT
|
|
||||||
m.screen = screenConfirm
|
|
||||||
m.cursor = 0
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m model) hcRunFanStress() (tea.Model, tea.Cmd) {
|
|
||||||
m.pendingAction = actionRunFanStress
|
|
||||||
m.screen = screenConfirm
|
|
||||||
m.cursor = 0
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// startGPUStressTest launches the GPU Platform Stress Test with a live in-TUI chart.
|
|
||||||
func (m model) startGPUStressTest() (tea.Model, tea.Cmd) {
|
|
||||||
opts := hcFanStressOpts(m.burnMode, m.app)
|
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
m.gpuStressCancel = cancel
|
|
||||||
m.gpuStressAborted = false
|
|
||||||
m.gpuLiveRows = nil
|
|
||||||
m.gpuLiveIndices = opts.GPUIndices
|
|
||||||
m.gpuLiveStart = time.Now()
|
|
||||||
m.screen = screenGPUStressRunning
|
|
||||||
m.nvidiaSATCursor = 0
|
|
||||||
|
|
||||||
stressCmd := func() tea.Msg {
|
|
||||||
result, err := m.app.RunFanStressTestResult(ctx, opts)
|
|
||||||
return gpuStressDoneMsg{title: result.Title, body: result.Body, err: err}
|
|
||||||
}
|
|
||||||
|
|
||||||
return m, tea.Batch(stressCmd, pollGPULive(opts.GPUIndices))
|
|
||||||
}
|
|
||||||
|
|
||||||
// pollGPULive samples nvidia-smi once after one second and returns a gpuLiveTickMsg.
|
|
||||||
// The update handler reschedules it to achieve continuous 1s polling.
|
|
||||||
func pollGPULive(indices []int) tea.Cmd {
|
|
||||||
return tea.Tick(time.Second, func(_ time.Time) tea.Msg {
|
|
||||||
rows, _ := platform.SampleGPUMetrics(indices)
|
|
||||||
return gpuLiveTickMsg{rows: rows, indices: indices}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateGPUStressRunning handles keys on the GPU stress running screen.
|
|
||||||
func (m model) updateGPUStressRunning(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
||||||
switch msg.String() {
|
|
||||||
case "a", "A":
|
|
||||||
if m.gpuStressCancel != nil {
|
|
||||||
m.gpuStressCancel()
|
|
||||||
m.gpuStressCancel = nil
|
|
||||||
}
|
|
||||||
m.gpuStressAborted = true
|
|
||||||
m.screen = screenBurnInTests
|
|
||||||
m.burnCursor = burnCurGPUStress
|
|
||||||
m.cursor = 0
|
|
||||||
case "ctrl+c":
|
|
||||||
return m, tea.Quit
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func renderGPUStressRunning(m model) string {
|
|
||||||
var b strings.Builder
|
|
||||||
fmt.Fprintln(&b, "GPU PLATFORM STRESS TEST")
|
|
||||||
fmt.Fprintln(&b)
|
|
||||||
if len(m.gpuLiveRows) == 0 {
|
|
||||||
fmt.Fprintln(&b, "Collecting metrics...")
|
|
||||||
} else {
|
|
||||||
chartWidth := m.width - 8
|
|
||||||
if chartWidth < 40 {
|
|
||||||
chartWidth = 70
|
|
||||||
}
|
|
||||||
b.WriteString(platform.RenderGPULiveChart(m.gpuLiveRows, chartWidth))
|
|
||||||
}
|
|
||||||
fmt.Fprintln(&b)
|
|
||||||
b.WriteString("[a] Abort test [ctrl+c] quit")
|
|
||||||
return b.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m model) hcRunAll() (tea.Model, tea.Cmd) {
|
|
||||||
for _, sel := range m.hcSel {
|
|
||||||
if sel {
|
|
||||||
m.pendingAction = actionRunAll
|
|
||||||
m.screen = screenConfirm
|
|
||||||
m.cursor = 0
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m model) executeRunAll() (tea.Model, tea.Cmd) {
|
|
||||||
durationIdx := m.hcMode
|
|
||||||
sel := m.hcSel
|
|
||||||
app := m.app
|
|
||||||
m.busy = true
|
|
||||||
m.busyTitle = "Health Check"
|
|
||||||
return m, func() tea.Msg {
|
|
||||||
var parts []string
|
|
||||||
if sel[hcGPU] {
|
|
||||||
vendor := app.DetectGPUVendor()
|
|
||||||
if vendor == "amd" {
|
|
||||||
r, err := app.RunAMDAcceptancePackResult("")
|
|
||||||
body := r.Body
|
|
||||||
if err != nil {
|
|
||||||
body += "\nERROR: " + err.Error()
|
|
||||||
}
|
|
||||||
parts = append(parts, "=== GPU (AMD) ===\n"+body)
|
|
||||||
} else {
|
|
||||||
// Map hcMode (0=Quick,1=Standard,2=Express) to DCGM level (1,2,3)
|
|
||||||
diagLevel := durationIdx + 1
|
|
||||||
r, err := app.RunNvidiaAcceptancePackWithOptions(context.Background(), "", diagLevel, nil)
|
|
||||||
body := r.Body
|
|
||||||
if err != nil {
|
|
||||||
body += "\nERROR: " + err.Error()
|
|
||||||
}
|
|
||||||
parts = append(parts, "=== GPU (DCGM) ===\n"+body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if sel[hcMemory] {
|
|
||||||
r, err := app.RunMemoryAcceptancePackResult("")
|
|
||||||
body := r.Body
|
|
||||||
if err != nil {
|
|
||||||
body += "\nERROR: " + err.Error()
|
|
||||||
}
|
|
||||||
parts = append(parts, "=== MEMORY ===\n"+body)
|
|
||||||
}
|
|
||||||
if sel[hcStorage] {
|
|
||||||
r, err := app.RunStorageAcceptancePackResult("")
|
|
||||||
body := r.Body
|
|
||||||
if err != nil {
|
|
||||||
body += "\nERROR: " + err.Error()
|
|
||||||
}
|
|
||||||
parts = append(parts, "=== STORAGE ===\n"+body)
|
|
||||||
}
|
|
||||||
if sel[hcCPU] {
|
|
||||||
cpuDur := hcCPUDurations[durationIdx]
|
|
||||||
r, err := app.RunCPUAcceptancePackResult("", cpuDur)
|
|
||||||
body := r.Body
|
|
||||||
if err != nil {
|
|
||||||
body += "\nERROR: " + err.Error()
|
|
||||||
}
|
|
||||||
parts = append(parts, "=== CPU ===\n"+body)
|
|
||||||
}
|
|
||||||
combined := strings.Join(parts, "\n\n")
|
|
||||||
if combined == "" {
|
|
||||||
combined = "No components selected."
|
|
||||||
}
|
|
||||||
return resultMsg{title: "Health Check", body: combined, back: screenHealthCheck}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func renderHealthCheck(m model) string {
|
|
||||||
var b strings.Builder
|
|
||||||
|
|
||||||
fmt.Fprintln(&b, "HEALTH CHECK")
|
|
||||||
fmt.Fprintln(&b)
|
|
||||||
fmt.Fprintln(&b, " Diagnostics:")
|
|
||||||
fmt.Fprintln(&b)
|
|
||||||
|
|
||||||
type comp struct{ name, desc, key string }
|
|
||||||
comps := []comp{
|
|
||||||
{"GPU", "nvidia/amd auto-detect", "G"},
|
|
||||||
{"MEMORY", "memtester", "M"},
|
|
||||||
{"STORAGE", "smartctl + NVMe self-test", "S"},
|
|
||||||
{"CPU", "audit diagnostics", "C"},
|
|
||||||
}
|
|
||||||
for i, c := range comps {
|
|
||||||
pfx := " "
|
|
||||||
if m.hcCursor == i {
|
|
||||||
pfx = "> "
|
|
||||||
}
|
|
||||||
ch := "[ ]"
|
|
||||||
if m.hcSel[i] {
|
|
||||||
ch = "[x]"
|
|
||||||
}
|
|
||||||
fmt.Fprintf(&b, "%s%s %-8s %-28s [%s]\n", pfx, ch, c.name, c.desc, c.key)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Fprintln(&b, " ─────────────────────────────────────────────────")
|
|
||||||
{
|
|
||||||
pfx := " "
|
|
||||||
if m.hcCursor == hcCurSelectAll {
|
|
||||||
pfx = "> "
|
|
||||||
}
|
|
||||||
allOn := m.hcSel[0] && m.hcSel[1] && m.hcSel[2] && m.hcSel[3]
|
|
||||||
ch := "[ ]"
|
|
||||||
if allOn {
|
|
||||||
ch = "[x]"
|
|
||||||
}
|
|
||||||
fmt.Fprintf(&b, "%s%s Select / Deselect All [A]\n", pfx, ch)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Fprintln(&b)
|
|
||||||
fmt.Fprintln(&b, " Mode:")
|
|
||||||
modes := []struct{ label, key string }{
|
|
||||||
{"Quick", "1"},
|
|
||||||
{"Standard", "2"},
|
|
||||||
{"Express", "3"},
|
|
||||||
}
|
|
||||||
for i, mode := range modes {
|
|
||||||
pfx := " "
|
|
||||||
if m.hcCursor == hcCurModeQuick+i {
|
|
||||||
pfx = "> "
|
|
||||||
}
|
|
||||||
radio := "( )"
|
|
||||||
if m.hcMode == i {
|
|
||||||
radio = "(*)"
|
|
||||||
}
|
|
||||||
fmt.Fprintf(&b, "%s%s %-10s [%s]\n", pfx, radio, mode.label, mode.key)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Fprintln(&b)
|
|
||||||
{
|
|
||||||
pfx := " "
|
|
||||||
if m.hcCursor == hcCurRunAll {
|
|
||||||
pfx = "> "
|
|
||||||
}
|
|
||||||
fmt.Fprintf(&b, "%s[ RUN ALL [R] ]\n", pfx)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Fprintln(&b)
|
|
||||||
fmt.Fprintln(&b, "─────────────────────────────────────────────────────────────────")
|
|
||||||
fmt.Fprint(&b, "[↑↓] move [space/enter] toggle [letter] single test [R] run all [Esc] back")
|
|
||||||
return b.String()
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
package tui
|
|
||||||
|
|
||||||
import (
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (m model) handleMainMenu() (tea.Model, tea.Cmd) {
|
|
||||||
switch m.cursor {
|
|
||||||
case 0: // Health Check
|
|
||||||
return m.enterHealthCheck()
|
|
||||||
case 1: // Burn-in tests
|
|
||||||
return m.enterBurnInTests()
|
|
||||||
case 2: // Export support bundle
|
|
||||||
m.pendingAction = actionExportBundle
|
|
||||||
m.busy = true
|
|
||||||
m.busyTitle = "Export support bundle"
|
|
||||||
return m, func() tea.Msg {
|
|
||||||
targets, err := m.app.ListRemovableTargets()
|
|
||||||
return exportTargetsMsg{targets: targets, err: err}
|
|
||||||
}
|
|
||||||
case 3: // Settings
|
|
||||||
m.screen = screenSettings
|
|
||||||
m.cursor = 0
|
|
||||||
return m, nil
|
|
||||||
case 4: // Exit
|
|
||||||
return m, tea.Quit
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
package tui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (m model) handleNetworkMenu() (tea.Model, tea.Cmd) {
|
|
||||||
switch m.cursor {
|
|
||||||
case 0:
|
|
||||||
m.busy = true
|
|
||||||
m.busyTitle = "Network status"
|
|
||||||
return m, func() tea.Msg {
|
|
||||||
result, err := m.app.NetworkStatus()
|
|
||||||
return resultMsg{title: result.Title, body: result.Body, err: err, back: screenNetwork}
|
|
||||||
}
|
|
||||||
case 1:
|
|
||||||
m.busy = true
|
|
||||||
m.busyTitle = "DHCP all interfaces"
|
|
||||||
return m, func() tea.Msg {
|
|
||||||
result, err := m.app.DHCPAllResult()
|
|
||||||
return resultMsg{title: result.Title, body: result.Body, err: err, back: screenNetwork}
|
|
||||||
}
|
|
||||||
case 2:
|
|
||||||
m.pendingAction = actionDHCPOne
|
|
||||||
m.busy = true
|
|
||||||
m.busyTitle = "Interfaces"
|
|
||||||
return m, func() tea.Msg {
|
|
||||||
ifaces, err := m.app.ListInterfaces()
|
|
||||||
return interfacesMsg{ifaces: ifaces, err: err}
|
|
||||||
}
|
|
||||||
case 3:
|
|
||||||
m.pendingAction = actionStaticIPv4
|
|
||||||
m.busy = true
|
|
||||||
m.busyTitle = "Interfaces"
|
|
||||||
return m, func() tea.Msg {
|
|
||||||
ifaces, err := m.app.ListInterfaces()
|
|
||||||
return interfacesMsg{ifaces: ifaces, err: err}
|
|
||||||
}
|
|
||||||
case 4:
|
|
||||||
m.screen = screenSettings
|
|
||||||
m.cursor = 0
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m model) handleInterfacePickMenu() (tea.Model, tea.Cmd) {
|
|
||||||
if len(m.interfaces) == 0 {
|
|
||||||
return m, resultCmd("interfaces", "No physical interfaces found", nil, screenNetwork)
|
|
||||||
}
|
|
||||||
m.selectedIface = m.interfaces[m.cursor].Name
|
|
||||||
switch m.pendingAction {
|
|
||||||
case actionDHCPOne:
|
|
||||||
m.busy = true
|
|
||||||
m.busyTitle = "DHCP on " + m.selectedIface
|
|
||||||
return m, func() tea.Msg {
|
|
||||||
result, err := m.app.DHCPOneResult(m.selectedIface)
|
|
||||||
return resultMsg{title: result.Title, body: result.Body, err: err, back: screenNetwork}
|
|
||||||
}
|
|
||||||
case actionStaticIPv4:
|
|
||||||
defaults := m.app.DefaultStaticIPv4FormFields(m.selectedIface)
|
|
||||||
m.formFields = []formField{
|
|
||||||
{Label: "IPv4 address", Value: defaults[0]},
|
|
||||||
{Label: "Prefix", Value: defaults[1]},
|
|
||||||
{Label: "Gateway", Value: strings.TrimSpace(defaults[2])},
|
|
||||||
{Label: "DNS (space-separated)", Value: defaults[3]},
|
|
||||||
}
|
|
||||||
m.formIndex = 0
|
|
||||||
m.screen = screenStaticForm
|
|
||||||
return m, nil
|
|
||||||
default:
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
package tui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
|
||||||
)
|
|
||||||
|
|
||||||
var nvidiaDCGMOptions = []struct {
|
|
||||||
label string
|
|
||||||
level int
|
|
||||||
note string
|
|
||||||
}{
|
|
||||||
{"Level 1 — Quick", 1, "~1 min, configuration check"},
|
|
||||||
{"Level 2 — Medium", 2, "~2 min, memory test"},
|
|
||||||
{"Level 3 — Targeted stress", 3, "~10 min, SM + memory + PCIe [recommended]"},
|
|
||||||
{"Level 4 — Extended stress", 4, "~30 min, extended burn-in"},
|
|
||||||
}
|
|
||||||
|
|
||||||
// enterNvidiaSATSetup resets and shows the DCGM level selection screen.
|
|
||||||
func (m model) enterNvidiaSATSetup() (tea.Model, tea.Cmd) {
|
|
||||||
m.screen = screenNvidiaSATSetup
|
|
||||||
m.nvidiaDurIdx = 2 // default: Level 3
|
|
||||||
m.nvidiaSATCursor = 2
|
|
||||||
m.busy = false
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateNvidiaSATSetup handles keys on the DCGM setup screen.
|
|
||||||
func (m model) updateNvidiaSATSetup(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
||||||
numOpts := len(nvidiaDCGMOptions)
|
|
||||||
totalItems := numOpts + 2 // +2: Start, Cancel
|
|
||||||
switch msg.String() {
|
|
||||||
case "up", "k":
|
|
||||||
if m.nvidiaSATCursor > 0 {
|
|
||||||
m.nvidiaSATCursor--
|
|
||||||
}
|
|
||||||
case "down", "j":
|
|
||||||
if m.nvidiaSATCursor < totalItems-1 {
|
|
||||||
m.nvidiaSATCursor++
|
|
||||||
}
|
|
||||||
case " ", "enter":
|
|
||||||
startIdx := numOpts
|
|
||||||
cancelIdx := startIdx + 1
|
|
||||||
switch {
|
|
||||||
case m.nvidiaSATCursor < numOpts:
|
|
||||||
m.nvidiaDurIdx = m.nvidiaSATCursor
|
|
||||||
case m.nvidiaSATCursor == startIdx:
|
|
||||||
return m.startNvidiaSAT()
|
|
||||||
case m.nvidiaSATCursor == cancelIdx:
|
|
||||||
m.screen = screenHealthCheck
|
|
||||||
m.cursor = 0
|
|
||||||
}
|
|
||||||
case "esc":
|
|
||||||
m.screen = screenHealthCheck
|
|
||||||
m.cursor = 0
|
|
||||||
case "ctrl+c", "q":
|
|
||||||
return m, tea.Quit
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// startNvidiaSAT launches the DCGM diagnostic.
|
|
||||||
func (m model) startNvidiaSAT() (tea.Model, tea.Cmd) {
|
|
||||||
diagLevel := nvidiaDCGMOptions[m.nvidiaDurIdx].level
|
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
m.nvidiaSATCancel = cancel
|
|
||||||
m.nvidiaSATAborted = false
|
|
||||||
m.screen = screenNvidiaSATRunning
|
|
||||||
m.nvidiaSATCursor = 0
|
|
||||||
|
|
||||||
satCmd := func() tea.Msg {
|
|
||||||
result, err := m.app.RunNvidiaAcceptancePackWithOptions(ctx, "", diagLevel, nil)
|
|
||||||
return nvidiaSATDoneMsg{title: result.Title, body: result.Body, err: err}
|
|
||||||
}
|
|
||||||
|
|
||||||
return m, satCmd
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateNvidiaSATRunning handles keys on the running screen.
|
|
||||||
func (m model) updateNvidiaSATRunning(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
||||||
switch msg.String() {
|
|
||||||
case "a", "A":
|
|
||||||
if m.nvidiaSATCancel != nil {
|
|
||||||
m.nvidiaSATCancel()
|
|
||||||
m.nvidiaSATCancel = nil
|
|
||||||
}
|
|
||||||
m.nvidiaSATAborted = true
|
|
||||||
m.screen = screenHealthCheck
|
|
||||||
m.cursor = 0
|
|
||||||
case "ctrl+c":
|
|
||||||
return m, tea.Quit
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// renderNvidiaSATSetup renders the DCGM level selection screen.
|
|
||||||
func renderNvidiaSATSetup(m model) string {
|
|
||||||
var b strings.Builder
|
|
||||||
fmt.Fprintln(&b, "NVIDIA Diagnostics (DCGM)")
|
|
||||||
fmt.Fprintln(&b)
|
|
||||||
fmt.Fprintln(&b, "Diagnostic level:")
|
|
||||||
for i, opt := range nvidiaDCGMOptions {
|
|
||||||
radio := "( )"
|
|
||||||
if i == m.nvidiaDurIdx {
|
|
||||||
radio = "(*)"
|
|
||||||
}
|
|
||||||
prefix := " "
|
|
||||||
if m.nvidiaSATCursor == i {
|
|
||||||
prefix = "> "
|
|
||||||
}
|
|
||||||
fmt.Fprintf(&b, "%s%s %s (%s)\n", prefix, radio, opt.label, opt.note)
|
|
||||||
}
|
|
||||||
fmt.Fprintln(&b)
|
|
||||||
startIdx := len(nvidiaDCGMOptions)
|
|
||||||
startPfx := " "
|
|
||||||
cancelPfx := " "
|
|
||||||
if m.nvidiaSATCursor == startIdx {
|
|
||||||
startPfx = "> "
|
|
||||||
}
|
|
||||||
if m.nvidiaSATCursor == startIdx+1 {
|
|
||||||
cancelPfx = "> "
|
|
||||||
}
|
|
||||||
fmt.Fprintf(&b, "%sStart\n", startPfx)
|
|
||||||
fmt.Fprintf(&b, "%sCancel\n", cancelPfx)
|
|
||||||
fmt.Fprintln(&b)
|
|
||||||
b.WriteString("[↑/↓] move [space/enter] select [esc] cancel\n")
|
|
||||||
return b.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// renderNvidiaSATRunning renders the running screen.
|
|
||||||
func renderNvidiaSATRunning() string {
|
|
||||||
return "NVIDIA Diagnostics (DCGM)\n\nTest is running...\n\n[a] Abort test [ctrl+c] quit\n"
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
package tui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bee/audit/internal/platform"
|
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (m model) handleServicesMenu() (tea.Model, tea.Cmd) {
|
|
||||||
if len(m.services) == 0 {
|
|
||||||
return m, resultCmd("Services", "No bee-* services found.", nil, screenSettings)
|
|
||||||
}
|
|
||||||
m.selectedService = m.services[m.cursor]
|
|
||||||
m.screen = screenServiceAction
|
|
||||||
m.cursor = 0
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m model) handleServiceActionMenu() (tea.Model, tea.Cmd) {
|
|
||||||
action := m.serviceMenu[m.cursor]
|
|
||||||
if action == "back" {
|
|
||||||
m.screen = screenServices
|
|
||||||
m.cursor = 0
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
m.busy = true
|
|
||||||
m.busyTitle = "service: " + m.selectedService
|
|
||||||
return m, func() tea.Msg {
|
|
||||||
switch action {
|
|
||||||
case "Status":
|
|
||||||
result, err := m.app.ServiceStatusResult(m.selectedService)
|
|
||||||
return resultMsg{title: result.Title, body: result.Body, err: err, back: screenServiceAction}
|
|
||||||
case "Restart":
|
|
||||||
result, err := m.app.ServiceActionResult(m.selectedService, platform.ServiceRestart)
|
|
||||||
return resultMsg{title: result.Title, body: result.Body, err: err, back: screenServiceAction}
|
|
||||||
case "Start":
|
|
||||||
result, err := m.app.ServiceActionResult(m.selectedService, platform.ServiceStart)
|
|
||||||
return resultMsg{title: result.Title, body: result.Body, err: err, back: screenServiceAction}
|
|
||||||
case "Stop":
|
|
||||||
result, err := m.app.ServiceActionResult(m.selectedService, platform.ServiceStop)
|
|
||||||
return resultMsg{title: result.Title, body: result.Body, err: err, back: screenServiceAction}
|
|
||||||
default:
|
|
||||||
return resultMsg{title: "Service", body: "Unknown action.", back: screenServiceAction}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
package tui
|
|
||||||
|
|
||||||
import tea "github.com/charmbracelet/bubbletea"
|
|
||||||
|
|
||||||
func (m model) handleSettingsMenu() (tea.Model, tea.Cmd) {
|
|
||||||
switch m.cursor {
|
|
||||||
case 0: // Network
|
|
||||||
m.screen = screenNetwork
|
|
||||||
m.cursor = 0
|
|
||||||
return m, nil
|
|
||||||
case 1: // Services
|
|
||||||
m.busy = true
|
|
||||||
m.busyTitle = "Services"
|
|
||||||
return m, func() tea.Msg {
|
|
||||||
services, err := m.app.ListBeeServices()
|
|
||||||
return servicesMsg{services: services, err: err}
|
|
||||||
}
|
|
||||||
case 2: // Re-run audit
|
|
||||||
m.busy = true
|
|
||||||
m.busyTitle = "Re-run audit"
|
|
||||||
runtimeMode := m.runtimeMode
|
|
||||||
return m, func() tea.Msg {
|
|
||||||
result, err := m.app.RunAuditNow(runtimeMode)
|
|
||||||
return resultMsg{title: result.Title, body: result.Body, err: err, back: screenSettings}
|
|
||||||
}
|
|
||||||
case 3: // Run self-check
|
|
||||||
m.busy = true
|
|
||||||
m.busyTitle = "Self-check"
|
|
||||||
return m, func() tea.Msg {
|
|
||||||
result, err := m.app.RunRuntimePreflightResult()
|
|
||||||
return resultMsg{title: result.Title, body: result.Body, err: err, back: screenSettings}
|
|
||||||
}
|
|
||||||
case 4: // Runtime issues
|
|
||||||
m.busy = true
|
|
||||||
m.busyTitle = "Runtime issues"
|
|
||||||
return m, func() tea.Msg {
|
|
||||||
result := m.app.RuntimeHealthResult()
|
|
||||||
return resultMsg{title: result.Title, body: result.Body, back: screenSettings}
|
|
||||||
}
|
|
||||||
case 5: // Audit logs
|
|
||||||
m.busy = true
|
|
||||||
m.busyTitle = "Audit logs"
|
|
||||||
return m, func() tea.Msg {
|
|
||||||
result := m.app.AuditLogTailResult()
|
|
||||||
return resultMsg{title: result.Title, body: result.Body, back: screenSettings}
|
|
||||||
}
|
|
||||||
case 6: // Tools
|
|
||||||
m.screen = screenTools
|
|
||||||
m.cursor = 0
|
|
||||||
return m, nil
|
|
||||||
case 7: // Back
|
|
||||||
m.screen = screenMain
|
|
||||||
m.cursor = 0
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
package tui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"bee/audit/internal/platform"
|
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
|
||||||
)
|
|
||||||
|
|
||||||
// handleToolsMenu handles the Tools submenu selection.
|
|
||||||
func (m model) handleToolsMenu() (tea.Model, tea.Cmd) {
|
|
||||||
switch m.cursor {
|
|
||||||
case 0: // Install to disk
|
|
||||||
m.busy = true
|
|
||||||
m.busyTitle = "Scanning disks"
|
|
||||||
return m, func() tea.Msg {
|
|
||||||
disks, err := m.app.ListInstallDisks()
|
|
||||||
return installDisksMsg{disks: disks, err: err}
|
|
||||||
}
|
|
||||||
case 1: // Check tools
|
|
||||||
m.busy = true
|
|
||||||
m.busyTitle = "Check tools"
|
|
||||||
return m, func() tea.Msg {
|
|
||||||
result := m.app.ToolCheckResult([]string{
|
|
||||||
"dmidecode", "smartctl", "nvme", "ipmitool", "lspci",
|
|
||||||
"ethtool", "bee", "nvidia-smi", "bee-gpu-stress",
|
|
||||||
"memtester", "dhclient", "lsblk", "mount",
|
|
||||||
"unsquashfs", "parted", "grub-install", "bee-install",
|
|
||||||
"all_reduce_perf", "dcgmi",
|
|
||||||
})
|
|
||||||
return resultMsg{title: result.Title, body: result.Body, back: screenTools}
|
|
||||||
}
|
|
||||||
case 2: // Back
|
|
||||||
m.screen = screenSettings
|
|
||||||
m.cursor = 0
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleInstallDiskPickMenu handles disk selection for installation.
|
|
||||||
func (m model) handleInstallDiskPickMenu() (tea.Model, tea.Cmd) {
|
|
||||||
if m.cursor >= len(m.installDisks) {
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
m.selectedDisk = m.installDisks[m.cursor].Device
|
|
||||||
m.pendingAction = actionInstallToDisk
|
|
||||||
m.screen = screenConfirm
|
|
||||||
m.cursor = 0
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// startInstall launches the bee-install script and polls its log for progress.
|
|
||||||
func (m model) startInstall() (tea.Model, tea.Cmd) {
|
|
||||||
device := m.selectedDisk
|
|
||||||
logFile := DefaultInstallLogFile
|
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
m.installCancel = cancel
|
|
||||||
m.installSince = time.Now()
|
|
||||||
m.busy = true
|
|
||||||
m.busyTitle = "Install to disk: " + device
|
|
||||||
m.progressLines = nil
|
|
||||||
|
|
||||||
installCmd := func() tea.Msg {
|
|
||||||
err := m.app.InstallToDisk(ctx, device, logFile)
|
|
||||||
return installDoneMsg{err: err}
|
|
||||||
}
|
|
||||||
|
|
||||||
return m, tea.Batch(installCmd, pollInstallProgress(logFile))
|
|
||||||
}
|
|
||||||
|
|
||||||
func renderInstallDiskPick(m model) string {
|
|
||||||
var b strings.Builder
|
|
||||||
fmt.Fprintln(&b, "INSTALL TO DISK")
|
|
||||||
fmt.Fprintln(&b)
|
|
||||||
fmt.Fprintln(&b, " WARNING: the selected disk will be completely WIPED.")
|
|
||||||
fmt.Fprintln(&b)
|
|
||||||
fmt.Fprintln(&b, " Available disks (USB and boot media excluded):")
|
|
||||||
fmt.Fprintln(&b)
|
|
||||||
if len(m.installDisks) == 0 {
|
|
||||||
fmt.Fprintln(&b, " (no suitable disks found)")
|
|
||||||
} else {
|
|
||||||
for i, d := range m.installDisks {
|
|
||||||
pfx := " "
|
|
||||||
if m.cursor == i {
|
|
||||||
pfx = "> "
|
|
||||||
}
|
|
||||||
fmt.Fprintf(&b, "%s%s\n", pfx, diskLabel(d))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fmt.Fprintln(&b)
|
|
||||||
fmt.Fprintln(&b, "─────────────────────────────────────────────────────────────────")
|
|
||||||
fmt.Fprint(&b, "[↑/↓] move [enter] select [esc] back [ctrl+c] quit")
|
|
||||||
return b.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func diskLabel(d platform.InstallDisk) string {
|
|
||||||
model := d.Model
|
|
||||||
if model == "" {
|
|
||||||
model = "Unknown"
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%-12s %-8s %s", d.Device, d.Size, model)
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
package tui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bee/audit/internal/app"
|
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (m model) refreshSnapshotCmd() tea.Cmd {
|
|
||||||
if m.app == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return func() tea.Msg {
|
|
||||||
return snapshotMsg{
|
|
||||||
banner: m.app.MainBanner(),
|
|
||||||
panel: m.app.LoadHardwarePanel(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func shouldRefreshSnapshot(prev, next model) bool {
|
|
||||||
return prev.screen != next.screen || prev.busy != next.busy
|
|
||||||
}
|
|
||||||
|
|
||||||
func emptySnapshot() snapshotMsg {
|
|
||||||
return snapshotMsg{
|
|
||||||
banner: "",
|
|
||||||
panel: app.HardwarePanelData{},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,721 +0,0 @@
|
|||||||
package tui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"bee/audit/internal/app"
|
|
||||||
"bee/audit/internal/platform"
|
|
||||||
"bee/audit/internal/runtimeenv"
|
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
|
||||||
)
|
|
||||||
|
|
||||||
func newTestModel() model {
|
|
||||||
return newModel(app.New(platform.New()), runtimeenv.ModeLocal)
|
|
||||||
}
|
|
||||||
|
|
||||||
func sendKey(t *testing.T, m model, key tea.KeyType) model {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
next, _ := m.Update(tea.KeyMsg{Type: key})
|
|
||||||
return next.(model)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpdateMainMenuCursorNavigation(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
m := newTestModel()
|
|
||||||
|
|
||||||
m = sendKey(t, m, tea.KeyDown)
|
|
||||||
if m.cursor != 1 {
|
|
||||||
t.Fatalf("cursor=%d want 1 after down", m.cursor)
|
|
||||||
}
|
|
||||||
|
|
||||||
m = sendKey(t, m, tea.KeyDown)
|
|
||||||
if m.cursor != 2 {
|
|
||||||
t.Fatalf("cursor=%d want 2 after second down", m.cursor)
|
|
||||||
}
|
|
||||||
|
|
||||||
m = sendKey(t, m, tea.KeyUp)
|
|
||||||
if m.cursor != 1 {
|
|
||||||
t.Fatalf("cursor=%d want 1 after up", m.cursor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpdateMainMenuEnterActions(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
cursor int
|
|
||||||
wantScreen screen
|
|
||||||
wantBusy bool
|
|
||||||
wantCmd bool
|
|
||||||
}{
|
|
||||||
{name: "health_check", cursor: 0, wantScreen: screenHealthCheck, wantCmd: true},
|
|
||||||
{name: "burn_in_tests", cursor: 1, wantScreen: screenBurnInTests, wantCmd: true},
|
|
||||||
{name: "export", cursor: 2, wantScreen: screenMain, wantBusy: true, wantCmd: true},
|
|
||||||
{name: "settings", cursor: 3, wantScreen: screenSettings, wantCmd: true},
|
|
||||||
{name: "exit", cursor: 4, wantScreen: screenMain, wantCmd: true},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, test := range tests {
|
|
||||||
test := test
|
|
||||||
t.Run(test.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
m := newTestModel()
|
|
||||||
m.cursor = test.cursor
|
|
||||||
|
|
||||||
next, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
|
||||||
got := next.(model)
|
|
||||||
|
|
||||||
if got.screen != test.wantScreen {
|
|
||||||
t.Fatalf("screen=%q want %q", got.screen, test.wantScreen)
|
|
||||||
}
|
|
||||||
if got.busy != test.wantBusy {
|
|
||||||
t.Fatalf("busy=%v want %v", got.busy, test.wantBusy)
|
|
||||||
}
|
|
||||||
if (cmd != nil) != test.wantCmd {
|
|
||||||
t.Fatalf("cmd present=%v want %v", cmd != nil, test.wantCmd)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpdateConfirmCancelViaKeys(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
m := newTestModel()
|
|
||||||
m.screen = screenConfirm
|
|
||||||
m.pendingAction = actionRunMemorySAT
|
|
||||||
|
|
||||||
next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRight})
|
|
||||||
got := next.(model)
|
|
||||||
if got.cursor != 1 {
|
|
||||||
t.Fatalf("cursor=%d want 1 after right", got.cursor)
|
|
||||||
}
|
|
||||||
|
|
||||||
next, _ = got.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
|
||||||
got = next.(model)
|
|
||||||
if got.screen != screenHealthCheck {
|
|
||||||
t.Fatalf("screen=%q want %q", got.screen, screenHealthCheck)
|
|
||||||
}
|
|
||||||
if got.cursor != 0 {
|
|
||||||
t.Fatalf("cursor=%d want 0 after cancel", got.cursor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMainMenuSimpleTransitions(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
cursor int
|
|
||||||
wantScreen screen
|
|
||||||
}{
|
|
||||||
{name: "health_check", cursor: 0, wantScreen: screenHealthCheck},
|
|
||||||
{name: "burn_in_tests", cursor: 1, wantScreen: screenBurnInTests},
|
|
||||||
{name: "settings", cursor: 3, wantScreen: screenSettings},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, test := range tests {
|
|
||||||
test := test
|
|
||||||
t.Run(test.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
m := newTestModel()
|
|
||||||
m.cursor = test.cursor
|
|
||||||
|
|
||||||
next, cmd := m.handleMainMenu()
|
|
||||||
got := next.(model)
|
|
||||||
|
|
||||||
if cmd != nil {
|
|
||||||
t.Fatalf("expected nil cmd for %s", test.name)
|
|
||||||
}
|
|
||||||
if got.screen != test.wantScreen {
|
|
||||||
t.Fatalf("screen=%q want %q", got.screen, test.wantScreen)
|
|
||||||
}
|
|
||||||
if got.cursor != 0 {
|
|
||||||
t.Fatalf("cursor=%d want 0", got.cursor)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMainMenuExportSetsBusy(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
m := newTestModel()
|
|
||||||
m.cursor = 2 // Export support bundle
|
|
||||||
|
|
||||||
next, cmd := m.handleMainMenu()
|
|
||||||
got := next.(model)
|
|
||||||
|
|
||||||
if !got.busy {
|
|
||||||
t.Fatal("busy=false for export")
|
|
||||||
}
|
|
||||||
if cmd == nil {
|
|
||||||
t.Fatal("expected async cmd for export")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMainViewRendersTwoColumns(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
m := newTestModel()
|
|
||||||
m.cursor = 2
|
|
||||||
|
|
||||||
view := m.View()
|
|
||||||
for _, want := range []string{
|
|
||||||
"bee",
|
|
||||||
"Health Check",
|
|
||||||
"Burn-in tests",
|
|
||||||
"> Export support bundle",
|
|
||||||
"Settings",
|
|
||||||
"Exit",
|
|
||||||
"│",
|
|
||||||
"[↑↓] move",
|
|
||||||
} {
|
|
||||||
if !strings.Contains(view, want) {
|
|
||||||
t.Fatalf("view missing %q\nview:\n%s", want, view)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEscapeNavigation(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
screen screen
|
|
||||||
wantScreen screen
|
|
||||||
}{
|
|
||||||
{name: "network to settings", screen: screenNetwork, wantScreen: screenSettings},
|
|
||||||
{name: "services to settings", screen: screenServices, wantScreen: screenSettings},
|
|
||||||
{name: "settings to main", screen: screenSettings, wantScreen: screenMain},
|
|
||||||
{name: "service action to services", screen: screenServiceAction, wantScreen: screenServices},
|
|
||||||
{name: "export targets to main", screen: screenExportTargets, wantScreen: screenMain},
|
|
||||||
{name: "interface pick to network", screen: screenInterfacePick, wantScreen: screenNetwork},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, test := range tests {
|
|
||||||
test := test
|
|
||||||
t.Run(test.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
m := newTestModel()
|
|
||||||
m.screen = test.screen
|
|
||||||
m.cursor = 3
|
|
||||||
|
|
||||||
next, _ := m.updateKey(tea.KeyMsg{Type: tea.KeyEsc})
|
|
||||||
got := next.(model)
|
|
||||||
|
|
||||||
if got.screen != test.wantScreen {
|
|
||||||
t.Fatalf("screen=%q want %q", got.screen, test.wantScreen)
|
|
||||||
}
|
|
||||||
if got.cursor != 0 {
|
|
||||||
t.Fatalf("cursor=%d want 0", got.cursor)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHealthCheckEscReturnsToMain(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
m := newTestModel()
|
|
||||||
m.screen = screenHealthCheck
|
|
||||||
m.hcCursor = 3
|
|
||||||
|
|
||||||
next, _ := m.updateHealthCheck(tea.KeyMsg{Type: tea.KeyEsc})
|
|
||||||
got := next.(model)
|
|
||||||
|
|
||||||
if got.screen != screenMain {
|
|
||||||
t.Fatalf("screen=%q want %q", got.screen, screenMain)
|
|
||||||
}
|
|
||||||
if got.cursor != 0 {
|
|
||||||
t.Fatalf("cursor=%d want 0", got.cursor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestOutputScreenReturnsToPreviousScreen(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
m := newTestModel()
|
|
||||||
m.screen = screenOutput
|
|
||||||
m.prevScreen = screenNetwork
|
|
||||||
m.title = "title"
|
|
||||||
m.body = "body"
|
|
||||||
|
|
||||||
next, _ := m.updateKey(tea.KeyMsg{Type: tea.KeyEnter})
|
|
||||||
got := next.(model)
|
|
||||||
|
|
||||||
if got.screen != screenNetwork {
|
|
||||||
t.Fatalf("screen=%q want %q", got.screen, screenNetwork)
|
|
||||||
}
|
|
||||||
if got.title != "" || got.body != "" {
|
|
||||||
t.Fatalf("expected output state cleared, got title=%q body=%q", got.title, got.body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHealthCheckGPUOpensNvidiaSATSetup(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
m := newTestModel()
|
|
||||||
m.screen = screenHealthCheck
|
|
||||||
m.hcInitialized = true
|
|
||||||
m.hcSel = [4]bool{true, true, true, true}
|
|
||||||
|
|
||||||
next, _ := m.hcRunSingle(hcGPU)
|
|
||||||
got := next.(model)
|
|
||||||
|
|
||||||
if got.screen != screenNvidiaSATSetup {
|
|
||||||
t.Fatalf("screen=%q want %q", got.screen, screenNvidiaSATSetup)
|
|
||||||
}
|
|
||||||
|
|
||||||
// esc from setup returns to health check
|
|
||||||
next, _ = got.updateNvidiaSATSetup(tea.KeyMsg{Type: tea.KeyEsc})
|
|
||||||
got = next.(model)
|
|
||||||
if got.screen != screenHealthCheck {
|
|
||||||
t.Fatalf("screen after esc=%q want %q", got.screen, screenHealthCheck)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHealthCheckRunSingleMapsActions(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
idx int
|
|
||||||
want actionKind
|
|
||||||
}{
|
|
||||||
{idx: hcMemory, want: actionRunMemorySAT},
|
|
||||||
{idx: hcStorage, want: actionRunStorageSAT},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, test := range tests {
|
|
||||||
m := newTestModel()
|
|
||||||
m.screen = screenHealthCheck
|
|
||||||
m.hcInitialized = true
|
|
||||||
|
|
||||||
next, _ := m.hcRunSingle(test.idx)
|
|
||||||
got := next.(model)
|
|
||||||
if got.pendingAction != test.want {
|
|
||||||
t.Fatalf("idx=%d pendingAction=%q want %q", test.idx, got.pendingAction, test.want)
|
|
||||||
}
|
|
||||||
if got.screen != screenConfirm {
|
|
||||||
t.Fatalf("idx=%d screen=%q want %q", test.idx, got.screen, screenConfirm)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExportTargetSelectionOpensConfirm(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
m := newTestModel()
|
|
||||||
m.screen = screenExportTargets
|
|
||||||
m.targets = []platform.RemovableTarget{{Device: "/dev/sdb1", FSType: "vfat", Size: "16G"}}
|
|
||||||
|
|
||||||
next, cmd := m.handleExportTargetsMenu()
|
|
||||||
got := next.(model)
|
|
||||||
|
|
||||||
if cmd != nil {
|
|
||||||
t.Fatal("expected nil cmd")
|
|
||||||
}
|
|
||||||
if got.screen != screenConfirm {
|
|
||||||
t.Fatalf("screen=%q want %q", got.screen, screenConfirm)
|
|
||||||
}
|
|
||||||
if got.pendingAction != actionExportBundle {
|
|
||||||
t.Fatalf("pendingAction=%q want %q", got.pendingAction, actionExportBundle)
|
|
||||||
}
|
|
||||||
if got.selectedTarget == nil || got.selectedTarget.Device != "/dev/sdb1" {
|
|
||||||
t.Fatalf("selectedTarget=%+v want /dev/sdb1", got.selectedTarget)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInterfacePickStaticIPv4OpensForm(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
m := newTestModel()
|
|
||||||
m.pendingAction = actionStaticIPv4
|
|
||||||
m.interfaces = []platform.InterfaceInfo{{Name: "eth0"}}
|
|
||||||
|
|
||||||
next, cmd := m.handleInterfacePickMenu()
|
|
||||||
got := next.(model)
|
|
||||||
|
|
||||||
if cmd != nil {
|
|
||||||
t.Fatal("expected nil cmd")
|
|
||||||
}
|
|
||||||
if got.screen != screenStaticForm {
|
|
||||||
t.Fatalf("screen=%q want %q", got.screen, screenStaticForm)
|
|
||||||
}
|
|
||||||
if got.selectedIface != "eth0" {
|
|
||||||
t.Fatalf("selectedIface=%q want eth0", got.selectedIface)
|
|
||||||
}
|
|
||||||
if len(got.formFields) != 4 {
|
|
||||||
t.Fatalf("len(formFields)=%d want 4", len(got.formFields))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResultMsgUsesExplicitBackScreen(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
m := newTestModel()
|
|
||||||
m.screen = screenConfirm
|
|
||||||
|
|
||||||
next, _ := m.Update(resultMsg{title: "done", body: "ok", back: screenNetwork})
|
|
||||||
got := next.(model)
|
|
||||||
|
|
||||||
if got.screen != screenOutput {
|
|
||||||
t.Fatalf("screen=%q want %q", got.screen, screenOutput)
|
|
||||||
}
|
|
||||||
if got.prevScreen != screenNetwork {
|
|
||||||
t.Fatalf("prevScreen=%q want %q", got.prevScreen, screenNetwork)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConfirmCancelTarget(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
m := newTestModel()
|
|
||||||
|
|
||||||
m.pendingAction = actionExportBundle
|
|
||||||
if got := m.confirmCancelTarget(); got != screenExportTargets {
|
|
||||||
t.Fatalf("export cancel target=%q want %q", got, screenExportTargets)
|
|
||||||
}
|
|
||||||
|
|
||||||
m.pendingAction = actionRunAll
|
|
||||||
if got := m.confirmCancelTarget(); got != screenHealthCheck {
|
|
||||||
t.Fatalf("run all cancel target=%q want %q", got, screenHealthCheck)
|
|
||||||
}
|
|
||||||
|
|
||||||
m.pendingAction = actionRunMemorySAT
|
|
||||||
if got := m.confirmCancelTarget(); got != screenHealthCheck {
|
|
||||||
t.Fatalf("memory sat cancel target=%q want %q", got, screenHealthCheck)
|
|
||||||
}
|
|
||||||
|
|
||||||
m.pendingAction = actionRunStorageSAT
|
|
||||||
if got := m.confirmCancelTarget(); got != screenHealthCheck {
|
|
||||||
t.Fatalf("storage sat cancel target=%q want %q", got, screenHealthCheck)
|
|
||||||
}
|
|
||||||
|
|
||||||
m.pendingAction = actionRunFanStress
|
|
||||||
if got := m.confirmCancelTarget(); got != screenBurnInTests {
|
|
||||||
t.Fatalf("fan stress cancel target=%q want %q", got, screenBurnInTests)
|
|
||||||
}
|
|
||||||
|
|
||||||
m.pendingAction = actionNone
|
|
||||||
if got := m.confirmCancelTarget(); got != screenMain {
|
|
||||||
t.Fatalf("default cancel target=%q want %q", got, screenMain)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestViewBusyStateIsMinimal(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
m := newTestModel()
|
|
||||||
m.busy = true
|
|
||||||
|
|
||||||
view := m.View()
|
|
||||||
want := "bee\n\nWorking...\n\n[ctrl+c] quit\n"
|
|
||||||
if view != want {
|
|
||||||
t.Fatalf("busy view mismatch\nwant:\n%s\ngot:\n%s", want, view)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestViewBusyStateUsesBusyTitle(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
m := newTestModel()
|
|
||||||
m.busy = true
|
|
||||||
m.busyTitle = "Export support bundle"
|
|
||||||
|
|
||||||
view := m.View()
|
|
||||||
|
|
||||||
for _, want := range []string{
|
|
||||||
"Export support bundle",
|
|
||||||
"Working...",
|
|
||||||
"[ctrl+c] quit",
|
|
||||||
} {
|
|
||||||
if !strings.Contains(view, want) {
|
|
||||||
t.Fatalf("view missing %q\nview:\n%s", want, view)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBurnInTestsEscReturnsToMain(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
m := newTestModel()
|
|
||||||
m.screen = screenBurnInTests
|
|
||||||
m.burnCursor = 3
|
|
||||||
|
|
||||||
next, _ := m.updateBurnInTests(tea.KeyMsg{Type: tea.KeyEsc})
|
|
||||||
got := next.(model)
|
|
||||||
|
|
||||||
if got.screen != screenMain {
|
|
||||||
t.Fatalf("screen=%q want %q", got.screen, screenMain)
|
|
||||||
}
|
|
||||||
if got.cursor != 1 {
|
|
||||||
t.Fatalf("cursor=%d want 1", got.cursor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBurnInTestsRunOpensConfirm(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
m := newTestModel()
|
|
||||||
m.screen = screenBurnInTests
|
|
||||||
m.burnInitialized = true
|
|
||||||
m.burnMode = 2
|
|
||||||
|
|
||||||
next, _ := m.burnRunSelected()
|
|
||||||
got := next.(model)
|
|
||||||
|
|
||||||
if got.screen != screenConfirm {
|
|
||||||
t.Fatalf("screen=%q want %q", got.screen, screenConfirm)
|
|
||||||
}
|
|
||||||
if got.pendingAction != actionRunFanStress {
|
|
||||||
t.Fatalf("pendingAction=%q want %q", got.pendingAction, actionRunFanStress)
|
|
||||||
}
|
|
||||||
if got.cursor != 0 {
|
|
||||||
t.Fatalf("cursor=%d want 0", got.cursor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestViewBurnInTestsRendersGPUStressEntry(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
m := newTestModel()
|
|
||||||
m.screen = screenBurnInTests
|
|
||||||
|
|
||||||
view := m.View()
|
|
||||||
|
|
||||||
for _, want := range []string{
|
|
||||||
"BURN-IN TESTS",
|
|
||||||
"GPU PLATFORM STRESS TEST",
|
|
||||||
"Quick",
|
|
||||||
"Standard",
|
|
||||||
"Express",
|
|
||||||
"[ RUN SELECTED [R] ]",
|
|
||||||
} {
|
|
||||||
if !strings.Contains(view, want) {
|
|
||||||
t.Fatalf("view missing %q\nview:\n%s", want, view)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestViewOutputScreenRendersBodyAndBackHint(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
m := newTestModel()
|
|
||||||
m.screen = screenOutput
|
|
||||||
m.title = "Run audit"
|
|
||||||
m.body = "audit output: /appdata/bee/export/bee-audit.json\n"
|
|
||||||
|
|
||||||
view := m.View()
|
|
||||||
|
|
||||||
for _, want := range []string{
|
|
||||||
"Run audit",
|
|
||||||
"audit output: /appdata/bee/export/bee-audit.json",
|
|
||||||
"[enter/esc] back [ctrl+c] quit",
|
|
||||||
} {
|
|
||||||
if !strings.Contains(view, want) {
|
|
||||||
t.Fatalf("view missing %q\nview:\n%s", want, view)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestViewRendersBannerModuleAboveScreenBody(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
m := newTestModel()
|
|
||||||
m.banner = "System: Demo Server\nIP: 10.0.0.10"
|
|
||||||
m.width = 60
|
|
||||||
|
|
||||||
view := m.View()
|
|
||||||
|
|
||||||
for _, want := range []string{
|
|
||||||
"┌ MOTD ",
|
|
||||||
"System: Demo Server",
|
|
||||||
"IP: 10.0.0.10",
|
|
||||||
"Health Check",
|
|
||||||
"Export support bundle",
|
|
||||||
} {
|
|
||||||
if !strings.Contains(view, want) {
|
|
||||||
t.Fatalf("view missing %q\nview:\n%s", want, view)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSnapshotMsgUpdatesBannerAndPanel(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
m := newTestModel()
|
|
||||||
|
|
||||||
next, cmd := m.Update(snapshotMsg{
|
|
||||||
banner: "System: Demo",
|
|
||||||
panel: app.HardwarePanelData{
|
|
||||||
Header: []string{"Demo header"},
|
|
||||||
Rows: []app.ComponentRow{
|
|
||||||
{Key: "CPU", Status: "PASS", Detail: "ok"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
got := next.(model)
|
|
||||||
|
|
||||||
if cmd != nil {
|
|
||||||
t.Fatal("expected nil cmd")
|
|
||||||
}
|
|
||||||
if got.banner != "System: Demo" {
|
|
||||||
t.Fatalf("banner=%q want %q", got.banner, "System: Demo")
|
|
||||||
}
|
|
||||||
if len(got.panel.Rows) != 1 || got.panel.Rows[0].Key != "CPU" {
|
|
||||||
t.Fatalf("panel rows=%+v", got.panel.Rows)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestViewExportTargetsRendersDeviceMetadata(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
m := newTestModel()
|
|
||||||
m.screen = screenExportTargets
|
|
||||||
m.targets = []platform.RemovableTarget{
|
|
||||||
{
|
|
||||||
Device: "/dev/sdb1",
|
|
||||||
FSType: "vfat",
|
|
||||||
Size: "29G",
|
|
||||||
Label: "BEEUSB",
|
|
||||||
Mountpoint: "/media/bee",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
view := m.View()
|
|
||||||
|
|
||||||
for _, want := range []string{
|
|
||||||
"Export support bundle",
|
|
||||||
"Select writable removable filesystem (read-only/boot media hidden)",
|
|
||||||
"> /dev/sdb1 [vfat 29G] label=BEEUSB mounted=/media/bee",
|
|
||||||
} {
|
|
||||||
if !strings.Contains(view, want) {
|
|
||||||
t.Fatalf("view missing %q\nview:\n%s", want, view)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExportTargetsMsgEmptyShowsHiddenBootMediaHint(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
m := newTestModel()
|
|
||||||
m.busy = true
|
|
||||||
m.busyTitle = "Export support bundle"
|
|
||||||
|
|
||||||
next, _ := m.Update(exportTargetsMsg{})
|
|
||||||
got := next.(model)
|
|
||||||
|
|
||||||
if got.screen != screenOutput {
|
|
||||||
t.Fatalf("screen=%q want %q", got.screen, screenOutput)
|
|
||||||
}
|
|
||||||
if got.title != "Export support bundle" {
|
|
||||||
t.Fatalf("title=%q want %q", got.title, "Export support bundle")
|
|
||||||
}
|
|
||||||
for _, want := range []string{
|
|
||||||
"No writable removable filesystems found.",
|
|
||||||
"Read-only or boot media are hidden from this list.",
|
|
||||||
} {
|
|
||||||
if !strings.Contains(got.body, want) {
|
|
||||||
t.Fatalf("body missing %q\nbody:\n%s", want, got.body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestViewStaticFormRendersFields(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
m := newTestModel()
|
|
||||||
m.screen = screenStaticForm
|
|
||||||
m.selectedIface = "enp1s0"
|
|
||||||
m.formFields = []formField{
|
|
||||||
{Label: "Address", Value: "192.0.2.10/24"},
|
|
||||||
{Label: "Gateway", Value: "192.0.2.1"},
|
|
||||||
{Label: "DNS", Value: "1.1.1.1"},
|
|
||||||
}
|
|
||||||
m.formIndex = 1
|
|
||||||
|
|
||||||
view := m.View()
|
|
||||||
|
|
||||||
for _, want := range []string{
|
|
||||||
"Static IPv4: enp1s0",
|
|
||||||
" Address: 192.0.2.10/24",
|
|
||||||
"> Gateway: 192.0.2.1",
|
|
||||||
" DNS: 1.1.1.1",
|
|
||||||
"[tab/↑/↓] move [enter] next/submit [backspace] delete [esc] cancel",
|
|
||||||
} {
|
|
||||||
if !strings.Contains(view, want) {
|
|
||||||
t.Fatalf("view missing %q\nview:\n%s", want, view)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestViewConfirmScreenMatchesPendingExport(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
m := newTestModel()
|
|
||||||
m.screen = screenConfirm
|
|
||||||
m.pendingAction = actionExportBundle
|
|
||||||
m.selectedTarget = &platform.RemovableTarget{Device: "/dev/sdb1"}
|
|
||||||
|
|
||||||
view := m.View()
|
|
||||||
|
|
||||||
for _, want := range []string{
|
|
||||||
"Export support bundle",
|
|
||||||
"Copy support bundle to /dev/sdb1?",
|
|
||||||
"> Confirm",
|
|
||||||
" Cancel",
|
|
||||||
} {
|
|
||||||
if !strings.Contains(view, want) {
|
|
||||||
t.Fatalf("view missing %q\nview:\n%s", want, view)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResultMsgClearsBusyAndPendingAction(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
m := newTestModel()
|
|
||||||
m.busy = true
|
|
||||||
m.busyTitle = "Export support bundle"
|
|
||||||
m.pendingAction = actionExportBundle
|
|
||||||
m.screen = screenConfirm
|
|
||||||
|
|
||||||
next, _ := m.Update(resultMsg{title: "Export support bundle", body: "done", back: screenMain})
|
|
||||||
got := next.(model)
|
|
||||||
|
|
||||||
if got.busy {
|
|
||||||
t.Fatal("busy=true want false")
|
|
||||||
}
|
|
||||||
if got.busyTitle != "" {
|
|
||||||
t.Fatalf("busyTitle=%q want empty", got.busyTitle)
|
|
||||||
}
|
|
||||||
if got.pendingAction != actionNone {
|
|
||||||
t.Fatalf("pendingAction=%q want empty", got.pendingAction)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResultMsgErrorWithoutBodyFormatsCleanly(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
m := newTestModel()
|
|
||||||
|
|
||||||
next, _ := m.Update(resultMsg{title: "Export support bundle", err: assertErr("boom"), back: screenMain})
|
|
||||||
got := next.(model)
|
|
||||||
|
|
||||||
if got.body != "ERROR: boom" {
|
|
||||||
t.Fatalf("body=%q want %q", got.body, "ERROR: boom")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type assertErr string
|
|
||||||
|
|
||||||
func (e assertErr) Error() string { return string(e) }
|
|
||||||
@@ -1,243 +0,0 @@
|
|||||||
package tui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"bee/audit/internal/app"
|
|
||||||
"bee/audit/internal/platform"
|
|
||||||
"bee/audit/internal/runtimeenv"
|
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
|
||||||
)
|
|
||||||
|
|
||||||
// DefaultInstallLogFile is where bee-install writes its progress log.
|
|
||||||
const DefaultInstallLogFile = "/tmp/bee-install.log"
|
|
||||||
|
|
||||||
type screen string
|
|
||||||
|
|
||||||
const (
|
|
||||||
screenMain screen = "main"
|
|
||||||
screenHealthCheck screen = "health_check"
|
|
||||||
screenBurnInTests screen = "burn_in_tests"
|
|
||||||
screenSettings screen = "settings"
|
|
||||||
screenNetwork screen = "network"
|
|
||||||
screenInterfacePick screen = "interface_pick"
|
|
||||||
screenServices screen = "services"
|
|
||||||
screenServiceAction screen = "service_action"
|
|
||||||
screenExportTargets screen = "export_targets"
|
|
||||||
screenOutput screen = "output"
|
|
||||||
screenStaticForm screen = "static_form"
|
|
||||||
screenConfirm screen = "confirm"
|
|
||||||
screenNvidiaSATSetup screen = "nvidia_sat_setup"
|
|
||||||
screenNvidiaSATRunning screen = "nvidia_sat_running"
|
|
||||||
screenGPUStressRunning screen = "gpu_stress_running"
|
|
||||||
screenTools screen = "tools"
|
|
||||||
screenInstallDiskPick screen = "install_disk_pick"
|
|
||||||
)
|
|
||||||
|
|
||||||
type actionKind string
|
|
||||||
|
|
||||||
const (
|
|
||||||
actionNone actionKind = ""
|
|
||||||
actionDHCPOne actionKind = "dhcp_one"
|
|
||||||
actionStaticIPv4 actionKind = "static_ipv4"
|
|
||||||
actionExportBundle actionKind = "export_bundle"
|
|
||||||
actionRunAll actionKind = "run_all"
|
|
||||||
actionRunMemorySAT actionKind = "run_memory_sat"
|
|
||||||
actionRunStorageSAT actionKind = "run_storage_sat"
|
|
||||||
actionRunCPUSAT actionKind = "run_cpu_sat"
|
|
||||||
actionRunAMDGPUSAT actionKind = "run_amd_gpu_sat"
|
|
||||||
actionRunFanStress actionKind = "run_fan_stress"
|
|
||||||
actionRunNCCLTests actionKind = "run_nccl_tests"
|
|
||||||
actionInstallToDisk actionKind = "install_to_disk"
|
|
||||||
)
|
|
||||||
|
|
||||||
type model struct {
|
|
||||||
app *app.App
|
|
||||||
runtimeMode runtimeenv.Mode
|
|
||||||
|
|
||||||
screen screen
|
|
||||||
prevScreen screen
|
|
||||||
cursor int
|
|
||||||
busy bool
|
|
||||||
busyTitle string
|
|
||||||
title string
|
|
||||||
body string
|
|
||||||
mainMenu []string
|
|
||||||
settingsMenu []string
|
|
||||||
networkMenu []string
|
|
||||||
serviceMenu []string
|
|
||||||
toolsMenu []string
|
|
||||||
|
|
||||||
services []string
|
|
||||||
interfaces []platform.InterfaceInfo
|
|
||||||
targets []platform.RemovableTarget
|
|
||||||
installDisks []platform.InstallDisk
|
|
||||||
selectedService string
|
|
||||||
selectedIface string
|
|
||||||
selectedTarget *platform.RemovableTarget
|
|
||||||
selectedDisk string
|
|
||||||
pendingAction actionKind
|
|
||||||
|
|
||||||
formFields []formField
|
|
||||||
formIndex int
|
|
||||||
|
|
||||||
// Hardware panel (right column)
|
|
||||||
panel app.HardwarePanelData
|
|
||||||
panelFocus bool
|
|
||||||
panelCursor int
|
|
||||||
banner string
|
|
||||||
|
|
||||||
// Health Check screen
|
|
||||||
hcSel [4]bool
|
|
||||||
hcMode int
|
|
||||||
hcCursor int
|
|
||||||
hcInitialized bool
|
|
||||||
|
|
||||||
// Burn-in tests screen
|
|
||||||
burnMode int
|
|
||||||
burnCursor int
|
|
||||||
burnInitialized bool
|
|
||||||
|
|
||||||
// NVIDIA SAT setup
|
|
||||||
nvidiaDurIdx int
|
|
||||||
nvidiaSATCursor int
|
|
||||||
|
|
||||||
// NVIDIA SAT running
|
|
||||||
nvidiaSATCancel func()
|
|
||||||
nvidiaSATAborted bool
|
|
||||||
|
|
||||||
// NCCL tests running
|
|
||||||
ncclCancel func()
|
|
||||||
|
|
||||||
// Install to disk
|
|
||||||
installCancel func()
|
|
||||||
installSince time.Time
|
|
||||||
|
|
||||||
// GPU Platform Stress Test running
|
|
||||||
gpuStressCancel func()
|
|
||||||
gpuStressAborted bool
|
|
||||||
gpuLiveRows []platform.GPUMetricRow
|
|
||||||
gpuLiveIndices []int
|
|
||||||
gpuLiveStart time.Time
|
|
||||||
|
|
||||||
// SAT verbose progress (CPU / Memory / Storage / AMD GPU)
|
|
||||||
progressLines []string
|
|
||||||
progressPrefix string
|
|
||||||
progressSince time.Time
|
|
||||||
|
|
||||||
// Terminal size
|
|
||||||
width int
|
|
||||||
}
|
|
||||||
|
|
||||||
type formField struct {
|
|
||||||
Label string
|
|
||||||
Value string
|
|
||||||
}
|
|
||||||
|
|
||||||
func Run(application *app.App, runtimeMode runtimeenv.Mode) error {
|
|
||||||
options := []tea.ProgramOption{}
|
|
||||||
if runtimeMode != runtimeenv.ModeLiveCD {
|
|
||||||
options = append(options, tea.WithAltScreen())
|
|
||||||
}
|
|
||||||
program := tea.NewProgram(newModel(application, runtimeMode), options...)
|
|
||||||
_, err := program.Run()
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func newModel(application *app.App, runtimeMode runtimeenv.Mode) model {
|
|
||||||
return model{
|
|
||||||
app: application,
|
|
||||||
runtimeMode: runtimeMode,
|
|
||||||
screen: screenMain,
|
|
||||||
mainMenu: []string{
|
|
||||||
"Health Check",
|
|
||||||
"Burn-in tests",
|
|
||||||
"Export support bundle",
|
|
||||||
"Settings",
|
|
||||||
"Exit",
|
|
||||||
},
|
|
||||||
settingsMenu: []string{
|
|
||||||
"Network",
|
|
||||||
"Services",
|
|
||||||
"Re-run audit",
|
|
||||||
"Run self-check",
|
|
||||||
"Runtime issues",
|
|
||||||
"Audit logs",
|
|
||||||
"Tools",
|
|
||||||
"Back",
|
|
||||||
},
|
|
||||||
toolsMenu: []string{
|
|
||||||
"Install to disk",
|
|
||||||
"Check tools",
|
|
||||||
"Back",
|
|
||||||
},
|
|
||||||
networkMenu: []string{
|
|
||||||
"Show status",
|
|
||||||
"DHCP on all interfaces",
|
|
||||||
"DHCP on one interface",
|
|
||||||
"Set static IPv4",
|
|
||||||
"Back",
|
|
||||||
},
|
|
||||||
serviceMenu: []string{
|
|
||||||
"Status",
|
|
||||||
"Restart",
|
|
||||||
"Start",
|
|
||||||
"Stop",
|
|
||||||
"Back",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m model) Init() tea.Cmd {
|
|
||||||
return m.refreshSnapshotCmd()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m model) confirmBody() (string, string) {
|
|
||||||
switch m.pendingAction {
|
|
||||||
case actionExportBundle:
|
|
||||||
if m.selectedTarget == nil {
|
|
||||||
return "Export support bundle", "No target selected"
|
|
||||||
}
|
|
||||||
return "Export support bundle", "Copy support bundle to " + m.selectedTarget.Device + "?"
|
|
||||||
case actionRunAll:
|
|
||||||
modes := []string{"Quick", "Standard", "Express"}
|
|
||||||
mode := modes[m.hcMode]
|
|
||||||
var sel []string
|
|
||||||
names := []string{"GPU", "Memory", "Storage", "CPU"}
|
|
||||||
for i, on := range m.hcSel {
|
|
||||||
if on {
|
|
||||||
sel = append(sel, names[i])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(sel) == 0 {
|
|
||||||
return "Health Check", "No components selected."
|
|
||||||
}
|
|
||||||
return "Health Check", "Run: " + strings.Join(sel, " + ") + "\nMode: " + mode
|
|
||||||
case actionRunMemorySAT:
|
|
||||||
return "Memory test", "Run memtester?"
|
|
||||||
case actionRunStorageSAT:
|
|
||||||
return "Storage test", "Run storage diagnostic pack?"
|
|
||||||
case actionRunCPUSAT:
|
|
||||||
modes := []string{"Quick (60s)", "Standard (300s)", "Express (900s)"}
|
|
||||||
return "CPU test", "Run stress-ng? Mode: " + modes[m.hcMode]
|
|
||||||
case actionRunAMDGPUSAT:
|
|
||||||
return "AMD GPU test", "Run AMD GPU diagnostic pack (rocm-smi)?"
|
|
||||||
case actionInstallToDisk:
|
|
||||||
dev := m.selectedDisk
|
|
||||||
if dev == "" {
|
|
||||||
dev = "unknown"
|
|
||||||
}
|
|
||||||
return "Install to disk", "WARNING: " + dev + " will be completely WIPED.\n\nAll data on the disk will be lost!\n\nInstall bee live system to " + dev + "?"
|
|
||||||
case actionRunNCCLTests:
|
|
||||||
return "NCCL bandwidth test", "Run all_reduce_perf across all GPUs?\n\nMeasures collective bandwidth over NVLink/PCIe.\nRequires 2+ GPUs for meaningful results."
|
|
||||||
case actionRunFanStress:
|
|
||||||
modes := []string{"Quick (2×2min)", "Standard (2×5min)", "Express (2×10min)"}
|
|
||||||
return "GPU Platform Stress Test", "Two-phase GPU thermal cycling test.\n" +
|
|
||||||
"Monitors fans, temps, power — detects throttling.\n" +
|
|
||||||
"Mode: " + modes[m.burnMode] + "\n\nAll NVIDIA GPUs will be stressed."
|
|
||||||
default:
|
|
||||||
return "Confirm", "Proceed?"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,362 +0,0 @@
|
|||||||
package tui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
||||||
switch msg := msg.(type) {
|
|
||||||
case tea.WindowSizeMsg:
|
|
||||||
m.width = msg.Width
|
|
||||||
return m, nil
|
|
||||||
case tea.KeyMsg:
|
|
||||||
if m.busy {
|
|
||||||
if msg.String() == "ctrl+c" {
|
|
||||||
return m, tea.Quit
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
next, cmd := m.updateKey(msg)
|
|
||||||
nextModel := next.(model)
|
|
||||||
if shouldRefreshSnapshot(m, nextModel) {
|
|
||||||
return nextModel, tea.Batch(cmd, nextModel.refreshSnapshotCmd())
|
|
||||||
}
|
|
||||||
return nextModel, cmd
|
|
||||||
case satProgressMsg:
|
|
||||||
if m.busy && m.progressPrefix != "" {
|
|
||||||
if len(msg.lines) > 0 {
|
|
||||||
m.progressLines = msg.lines
|
|
||||||
}
|
|
||||||
return m, pollSATProgress(m.progressPrefix, m.progressSince)
|
|
||||||
}
|
|
||||||
if m.busy && m.installCancel != nil {
|
|
||||||
if len(msg.lines) > 0 {
|
|
||||||
m.progressLines = msg.lines
|
|
||||||
}
|
|
||||||
return m, pollInstallProgress(DefaultInstallLogFile)
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
case snapshotMsg:
|
|
||||||
m.banner = msg.banner
|
|
||||||
m.panel = msg.panel
|
|
||||||
return m, nil
|
|
||||||
case resultMsg:
|
|
||||||
m.busy = false
|
|
||||||
m.busyTitle = ""
|
|
||||||
m.progressLines = nil
|
|
||||||
m.progressPrefix = ""
|
|
||||||
m.title = msg.title
|
|
||||||
if msg.err != nil {
|
|
||||||
body := strings.TrimSpace(msg.body)
|
|
||||||
if body == "" {
|
|
||||||
m.body = fmt.Sprintf("ERROR: %v", msg.err)
|
|
||||||
} else {
|
|
||||||
m.body = fmt.Sprintf("%s\n\nERROR: %v", body, msg.err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
m.body = msg.body
|
|
||||||
}
|
|
||||||
m.pendingAction = actionNone
|
|
||||||
if msg.back != "" {
|
|
||||||
m.prevScreen = msg.back
|
|
||||||
} else {
|
|
||||||
m.prevScreen = m.screen
|
|
||||||
}
|
|
||||||
m.screen = screenOutput
|
|
||||||
m.cursor = 0
|
|
||||||
return m, m.refreshSnapshotCmd()
|
|
||||||
case servicesMsg:
|
|
||||||
m.busy = false
|
|
||||||
m.busyTitle = ""
|
|
||||||
if msg.err != nil {
|
|
||||||
m.title = "Services"
|
|
||||||
m.body = msg.err.Error()
|
|
||||||
m.prevScreen = screenSettings
|
|
||||||
m.screen = screenOutput
|
|
||||||
return m, m.refreshSnapshotCmd()
|
|
||||||
}
|
|
||||||
m.services = msg.services
|
|
||||||
m.screen = screenServices
|
|
||||||
m.cursor = 0
|
|
||||||
return m, m.refreshSnapshotCmd()
|
|
||||||
case interfacesMsg:
|
|
||||||
m.busy = false
|
|
||||||
m.busyTitle = ""
|
|
||||||
if msg.err != nil {
|
|
||||||
m.title = "interfaces"
|
|
||||||
m.body = msg.err.Error()
|
|
||||||
m.prevScreen = screenNetwork
|
|
||||||
m.screen = screenOutput
|
|
||||||
return m, m.refreshSnapshotCmd()
|
|
||||||
}
|
|
||||||
m.interfaces = msg.ifaces
|
|
||||||
m.screen = screenInterfacePick
|
|
||||||
m.cursor = 0
|
|
||||||
return m, m.refreshSnapshotCmd()
|
|
||||||
case exportTargetsMsg:
|
|
||||||
m.busy = false
|
|
||||||
m.busyTitle = ""
|
|
||||||
if msg.err != nil {
|
|
||||||
m.title = "export"
|
|
||||||
m.body = msg.err.Error()
|
|
||||||
m.prevScreen = screenMain
|
|
||||||
m.screen = screenOutput
|
|
||||||
return m, m.refreshSnapshotCmd()
|
|
||||||
}
|
|
||||||
if len(msg.targets) == 0 {
|
|
||||||
m.title = "Export support bundle"
|
|
||||||
m.body = "No writable removable filesystems found.\n\nRead-only or boot media are hidden from this list."
|
|
||||||
m.prevScreen = screenMain
|
|
||||||
m.screen = screenOutput
|
|
||||||
return m, m.refreshSnapshotCmd()
|
|
||||||
}
|
|
||||||
m.targets = msg.targets
|
|
||||||
m.screen = screenExportTargets
|
|
||||||
m.cursor = 0
|
|
||||||
return m, m.refreshSnapshotCmd()
|
|
||||||
case installDisksMsg:
|
|
||||||
m.busy = false
|
|
||||||
m.busyTitle = ""
|
|
||||||
if msg.err != nil {
|
|
||||||
m.title = "Install to disk"
|
|
||||||
m.body = msg.err.Error()
|
|
||||||
m.prevScreen = screenTools
|
|
||||||
m.screen = screenOutput
|
|
||||||
return m, m.refreshSnapshotCmd()
|
|
||||||
}
|
|
||||||
if len(msg.disks) == 0 {
|
|
||||||
m.title = "Install to disk"
|
|
||||||
m.body = "No suitable disks found.\n\nOnly non-USB, non-boot disks are shown.\nAttach a target disk and try again."
|
|
||||||
m.prevScreen = screenTools
|
|
||||||
m.screen = screenOutput
|
|
||||||
return m, m.refreshSnapshotCmd()
|
|
||||||
}
|
|
||||||
m.installDisks = msg.disks
|
|
||||||
m.screen = screenInstallDiskPick
|
|
||||||
m.cursor = 0
|
|
||||||
return m, m.refreshSnapshotCmd()
|
|
||||||
case installDoneMsg:
|
|
||||||
if m.installCancel != nil {
|
|
||||||
m.installCancel()
|
|
||||||
m.installCancel = nil
|
|
||||||
}
|
|
||||||
m.busy = false
|
|
||||||
m.busyTitle = ""
|
|
||||||
m.progressLines = nil
|
|
||||||
m.prevScreen = screenTools
|
|
||||||
m.screen = screenOutput
|
|
||||||
m.title = "Install to disk"
|
|
||||||
if msg.err != nil {
|
|
||||||
m.body = fmt.Sprintf("Installation FAILED.\n\nLog: %s\n\nERROR: %v", DefaultInstallLogFile, msg.err)
|
|
||||||
} else {
|
|
||||||
m.body = fmt.Sprintf("Installation complete.\n\nRemove the ISO and reboot to start the installed system.\n\nLog: %s", DefaultInstallLogFile)
|
|
||||||
}
|
|
||||||
return m, m.refreshSnapshotCmd()
|
|
||||||
case nvtopClosedMsg:
|
|
||||||
return m, nil
|
|
||||||
case gpuStressDoneMsg:
|
|
||||||
if m.gpuStressAborted {
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
if m.gpuStressCancel != nil {
|
|
||||||
m.gpuStressCancel()
|
|
||||||
m.gpuStressCancel = nil
|
|
||||||
}
|
|
||||||
m.prevScreen = screenBurnInTests
|
|
||||||
m.screen = screenOutput
|
|
||||||
m.title = msg.title
|
|
||||||
if msg.err != nil {
|
|
||||||
body := strings.TrimSpace(msg.body)
|
|
||||||
if body == "" {
|
|
||||||
m.body = fmt.Sprintf("ERROR: %v", msg.err)
|
|
||||||
} else {
|
|
||||||
m.body = fmt.Sprintf("%s\n\nERROR: %v", body, msg.err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
m.body = msg.body
|
|
||||||
}
|
|
||||||
return m, m.refreshSnapshotCmd()
|
|
||||||
case gpuLiveTickMsg:
|
|
||||||
if m.screen == screenGPUStressRunning {
|
|
||||||
if len(msg.rows) > 0 {
|
|
||||||
elapsed := time.Since(m.gpuLiveStart).Seconds()
|
|
||||||
for i := range msg.rows {
|
|
||||||
msg.rows[i].ElapsedSec = elapsed
|
|
||||||
}
|
|
||||||
m.gpuLiveRows = append(m.gpuLiveRows, msg.rows...)
|
|
||||||
n := max(1, len(msg.indices))
|
|
||||||
if len(m.gpuLiveRows) > 60*n {
|
|
||||||
m.gpuLiveRows = m.gpuLiveRows[len(m.gpuLiveRows)-60*n:]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return m, pollGPULive(msg.indices)
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
case nvidiaSATDoneMsg:
|
|
||||||
if m.nvidiaSATAborted {
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
if m.nvidiaSATCancel != nil {
|
|
||||||
m.nvidiaSATCancel()
|
|
||||||
m.nvidiaSATCancel = nil
|
|
||||||
}
|
|
||||||
m.prevScreen = screenHealthCheck
|
|
||||||
m.screen = screenOutput
|
|
||||||
m.title = msg.title
|
|
||||||
if msg.err != nil {
|
|
||||||
body := strings.TrimSpace(msg.body)
|
|
||||||
if body == "" {
|
|
||||||
m.body = fmt.Sprintf("ERROR: %v", msg.err)
|
|
||||||
} else {
|
|
||||||
m.body = fmt.Sprintf("%s\n\nERROR: %v", body, msg.err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
m.body = msg.body
|
|
||||||
}
|
|
||||||
return m, m.refreshSnapshotCmd()
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
||||||
switch m.screen {
|
|
||||||
case screenMain:
|
|
||||||
return m.updateMain(msg)
|
|
||||||
case screenHealthCheck:
|
|
||||||
return m.updateHealthCheck(msg)
|
|
||||||
case screenBurnInTests:
|
|
||||||
return m.updateBurnInTests(msg)
|
|
||||||
case screenSettings:
|
|
||||||
return m.updateMenu(msg, len(m.settingsMenu), m.handleSettingsMenu)
|
|
||||||
case screenNetwork:
|
|
||||||
return m.updateMenu(msg, len(m.networkMenu), m.handleNetworkMenu)
|
|
||||||
case screenServices:
|
|
||||||
return m.updateMenu(msg, len(m.services), m.handleServicesMenu)
|
|
||||||
case screenServiceAction:
|
|
||||||
return m.updateMenu(msg, len(m.serviceMenu), m.handleServiceActionMenu)
|
|
||||||
case screenNvidiaSATSetup:
|
|
||||||
return m.updateNvidiaSATSetup(msg)
|
|
||||||
case screenNvidiaSATRunning:
|
|
||||||
return m.updateNvidiaSATRunning(msg)
|
|
||||||
case screenGPUStressRunning:
|
|
||||||
return m.updateGPUStressRunning(msg)
|
|
||||||
case screenExportTargets:
|
|
||||||
return m.updateMenu(msg, len(m.targets), m.handleExportTargetsMenu)
|
|
||||||
case screenInterfacePick:
|
|
||||||
return m.updateMenu(msg, len(m.interfaces), m.handleInterfacePickMenu)
|
|
||||||
case screenTools:
|
|
||||||
return m.updateMenu(msg, len(m.toolsMenu), m.handleToolsMenu)
|
|
||||||
case screenInstallDiskPick:
|
|
||||||
return m.updateMenu(msg, len(m.installDisks), m.handleInstallDiskPickMenu)
|
|
||||||
case screenOutput:
|
|
||||||
switch msg.String() {
|
|
||||||
case "esc", "enter", "q":
|
|
||||||
m.screen = m.prevScreen
|
|
||||||
m.body = ""
|
|
||||||
m.title = ""
|
|
||||||
m.pendingAction = actionNone
|
|
||||||
return m, nil
|
|
||||||
case "ctrl+c":
|
|
||||||
return m, tea.Quit
|
|
||||||
}
|
|
||||||
case screenStaticForm:
|
|
||||||
return m.updateStaticForm(msg)
|
|
||||||
case screenConfirm:
|
|
||||||
return m.updateConfirm(msg)
|
|
||||||
}
|
|
||||||
if msg.String() == "ctrl+c" {
|
|
||||||
return m, tea.Quit
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateMain handles keys on the main (two-column) screen.
|
|
||||||
func (m model) updateMain(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
||||||
if m.panelFocus {
|
|
||||||
return m.updateMainPanel(msg)
|
|
||||||
}
|
|
||||||
// Switch focus to right panel.
|
|
||||||
if (msg.String() == "tab" || msg.String() == "right" || msg.String() == "l") && len(m.panel.Rows) > 0 {
|
|
||||||
m.panelFocus = true
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
return m.updateMenu(msg, len(m.mainMenu), m.handleMainMenu)
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateMainPanel handles keys when right panel has focus.
|
|
||||||
func (m model) updateMainPanel(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
||||||
switch msg.String() {
|
|
||||||
case "up", "k":
|
|
||||||
if m.panelCursor > 0 {
|
|
||||||
m.panelCursor--
|
|
||||||
}
|
|
||||||
case "down", "j":
|
|
||||||
if m.panelCursor < len(m.panel.Rows)-1 {
|
|
||||||
m.panelCursor++
|
|
||||||
}
|
|
||||||
case "enter":
|
|
||||||
if m.panelCursor < len(m.panel.Rows) {
|
|
||||||
key := m.panel.Rows[m.panelCursor].Key
|
|
||||||
m.busy = true
|
|
||||||
m.busyTitle = key
|
|
||||||
return m, func() tea.Msg {
|
|
||||||
r := m.app.ComponentDetailResult(key)
|
|
||||||
return resultMsg{title: r.Title, body: r.Body, back: screenMain}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case "tab", "left", "h", "esc":
|
|
||||||
m.panelFocus = false
|
|
||||||
case "q", "ctrl+c":
|
|
||||||
return m, tea.Quit
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m model) updateMenu(msg tea.KeyMsg, size int, onEnter func() (tea.Model, tea.Cmd)) (tea.Model, tea.Cmd) {
|
|
||||||
if size == 0 {
|
|
||||||
size = 1
|
|
||||||
}
|
|
||||||
switch msg.String() {
|
|
||||||
case "up", "k":
|
|
||||||
if m.cursor > 0 {
|
|
||||||
m.cursor--
|
|
||||||
}
|
|
||||||
case "down", "j":
|
|
||||||
if m.cursor < size-1 {
|
|
||||||
m.cursor++
|
|
||||||
}
|
|
||||||
case "enter":
|
|
||||||
return onEnter()
|
|
||||||
case "esc":
|
|
||||||
switch m.screen {
|
|
||||||
case screenNetwork, screenServices:
|
|
||||||
m.screen = screenSettings
|
|
||||||
m.cursor = 0
|
|
||||||
case screenSettings:
|
|
||||||
m.screen = screenMain
|
|
||||||
m.cursor = 0
|
|
||||||
case screenServiceAction:
|
|
||||||
m.screen = screenServices
|
|
||||||
m.cursor = 0
|
|
||||||
case screenExportTargets:
|
|
||||||
m.screen = screenMain
|
|
||||||
m.cursor = 0
|
|
||||||
case screenInterfacePick:
|
|
||||||
m.screen = screenNetwork
|
|
||||||
m.cursor = 0
|
|
||||||
case screenTools:
|
|
||||||
m.screen = screenSettings
|
|
||||||
m.cursor = 0
|
|
||||||
case screenInstallDiskPick:
|
|
||||||
m.screen = screenTools
|
|
||||||
m.cursor = 0
|
|
||||||
}
|
|
||||||
case "q", "ctrl+c":
|
|
||||||
return m, tea.Quit
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
@@ -1,307 +0,0 @@
|
|||||||
package tui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"bee/audit/internal/platform"
|
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Column widths for two-column main layout.
|
|
||||||
const leftColWidth = 30
|
|
||||||
|
|
||||||
var (
|
|
||||||
stylePass = lipgloss.NewStyle().Foreground(lipgloss.Color("10")) // bright green
|
|
||||||
styleFail = lipgloss.NewStyle().Foreground(lipgloss.Color("9")) // bright red
|
|
||||||
styleCancel = lipgloss.NewStyle().Foreground(lipgloss.Color("11")) // bright yellow
|
|
||||||
styleNA = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) // dark gray
|
|
||||||
)
|
|
||||||
|
|
||||||
func colorStatus(status string) string {
|
|
||||||
switch status {
|
|
||||||
case "PASS":
|
|
||||||
return stylePass.Render("PASS")
|
|
||||||
case "FAIL":
|
|
||||||
return styleFail.Render("FAIL")
|
|
||||||
case "CANCEL":
|
|
||||||
return styleCancel.Render("CANC")
|
|
||||||
default:
|
|
||||||
return styleNA.Render("N/A ")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m model) View() string {
|
|
||||||
var body string
|
|
||||||
if m.busy {
|
|
||||||
title := "bee"
|
|
||||||
if m.busyTitle != "" {
|
|
||||||
title = m.busyTitle
|
|
||||||
}
|
|
||||||
if len(m.progressLines) > 0 {
|
|
||||||
var b strings.Builder
|
|
||||||
fmt.Fprintf(&b, "%s\n\n", title)
|
|
||||||
for _, l := range m.progressLines {
|
|
||||||
fmt.Fprintf(&b, " %s\n", l)
|
|
||||||
}
|
|
||||||
b.WriteString("\n[ctrl+c] quit\n")
|
|
||||||
body = b.String()
|
|
||||||
} else {
|
|
||||||
body = fmt.Sprintf("%s\n\nWorking...\n\n[ctrl+c] quit\n", title)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
switch m.screen {
|
|
||||||
case screenMain:
|
|
||||||
body = renderTwoColumnMain(m)
|
|
||||||
case screenHealthCheck:
|
|
||||||
body = renderHealthCheck(m)
|
|
||||||
case screenBurnInTests:
|
|
||||||
body = renderBurnInTests(m)
|
|
||||||
case screenSettings:
|
|
||||||
body = renderMenu("Settings", "Select action", m.settingsMenu, m.cursor)
|
|
||||||
case screenNetwork:
|
|
||||||
body = renderMenu("Network", "Select action", m.networkMenu, m.cursor)
|
|
||||||
case screenServices:
|
|
||||||
body = renderMenu("Services", "Select service", m.services, m.cursor)
|
|
||||||
case screenServiceAction:
|
|
||||||
body = renderMenu("Service: "+m.selectedService, "Select action", m.serviceMenu, m.cursor)
|
|
||||||
case screenExportTargets:
|
|
||||||
body = renderMenu(
|
|
||||||
"Export support bundle",
|
|
||||||
"Select writable removable filesystem (read-only/boot media hidden)",
|
|
||||||
renderTargetItems(m.targets),
|
|
||||||
m.cursor,
|
|
||||||
)
|
|
||||||
case screenInterfacePick:
|
|
||||||
body = renderMenu("Interfaces", "Select interface", renderInterfaceItems(m.interfaces), m.cursor)
|
|
||||||
case screenTools:
|
|
||||||
body = renderMenu("Tools", "Select action", m.toolsMenu, m.cursor)
|
|
||||||
case screenInstallDiskPick:
|
|
||||||
body = renderInstallDiskPick(m)
|
|
||||||
case screenStaticForm:
|
|
||||||
body = renderForm("Static IPv4: "+m.selectedIface, m.formFields, m.formIndex)
|
|
||||||
case screenConfirm:
|
|
||||||
title, confirmBody := m.confirmBody()
|
|
||||||
body = renderConfirm(title, confirmBody, m.cursor)
|
|
||||||
case screenNvidiaSATSetup:
|
|
||||||
body = renderNvidiaSATSetup(m)
|
|
||||||
case screenNvidiaSATRunning:
|
|
||||||
body = renderNvidiaSATRunning()
|
|
||||||
case screenGPUStressRunning:
|
|
||||||
body = renderGPUStressRunning(m)
|
|
||||||
case screenOutput:
|
|
||||||
body = fmt.Sprintf("%s\n\n%s\n\n[enter/esc] back [ctrl+c] quit\n", m.title, strings.TrimSpace(m.body))
|
|
||||||
default:
|
|
||||||
body = "bee\n"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return m.renderWithBanner(body)
|
|
||||||
}
|
|
||||||
|
|
||||||
// renderTwoColumnMain renders the main screen with menu on the left and hardware panel on the right.
|
|
||||||
func renderTwoColumnMain(m model) string {
|
|
||||||
// Left column lines
|
|
||||||
leftLines := []string{"bee", ""}
|
|
||||||
for i, item := range m.mainMenu {
|
|
||||||
pfx := " "
|
|
||||||
if !m.panelFocus && m.cursor == i {
|
|
||||||
pfx = "> "
|
|
||||||
}
|
|
||||||
leftLines = append(leftLines, pfx+item)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Right column lines
|
|
||||||
rightLines := buildPanelLines(m)
|
|
||||||
|
|
||||||
// Render side by side
|
|
||||||
var b strings.Builder
|
|
||||||
maxRows := max(len(leftLines), len(rightLines))
|
|
||||||
for i := 0; i < maxRows; i++ {
|
|
||||||
l := ""
|
|
||||||
if i < len(leftLines) {
|
|
||||||
l = leftLines[i]
|
|
||||||
}
|
|
||||||
r := ""
|
|
||||||
if i < len(rightLines) {
|
|
||||||
r = rightLines[i]
|
|
||||||
}
|
|
||||||
w := lipgloss.Width(l)
|
|
||||||
if w < leftColWidth {
|
|
||||||
l += strings.Repeat(" ", leftColWidth-w)
|
|
||||||
}
|
|
||||||
b.WriteString(l + " │ " + r + "\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
sep := strings.Repeat("─", leftColWidth) + "─┴─" + strings.Repeat("─", 46)
|
|
||||||
b.WriteString(sep + "\n")
|
|
||||||
|
|
||||||
if m.panelFocus {
|
|
||||||
b.WriteString("[↑↓] move [enter] details [tab/←] menu [ctrl+c] quit\n")
|
|
||||||
} else {
|
|
||||||
b.WriteString("[↑↓] move [enter] select [tab/→] panel [ctrl+c] quit\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
return b.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildPanelLines(m model) []string {
|
|
||||||
p := m.panel
|
|
||||||
var lines []string
|
|
||||||
|
|
||||||
for _, h := range p.Header {
|
|
||||||
lines = append(lines, h)
|
|
||||||
}
|
|
||||||
if len(p.Header) > 0 && len(p.Rows) > 0 {
|
|
||||||
lines = append(lines, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
for i, row := range p.Rows {
|
|
||||||
pfx := " "
|
|
||||||
if m.panelFocus && m.panelCursor == i {
|
|
||||||
pfx = "> "
|
|
||||||
}
|
|
||||||
status := colorStatus(row.Status)
|
|
||||||
lines = append(lines, fmt.Sprintf("%s%s %-4s %s", pfx, status, row.Key, row.Detail))
|
|
||||||
}
|
|
||||||
|
|
||||||
return lines
|
|
||||||
}
|
|
||||||
|
|
||||||
func renderTargetItems(targets []platform.RemovableTarget) []string {
|
|
||||||
items := make([]string, 0, len(targets))
|
|
||||||
for _, target := range targets {
|
|
||||||
desc := fmt.Sprintf("%s [%s %s]", target.Device, target.FSType, target.Size)
|
|
||||||
if target.Label != "" {
|
|
||||||
desc += " label=" + target.Label
|
|
||||||
}
|
|
||||||
if target.Mountpoint != "" {
|
|
||||||
desc += " mounted=" + target.Mountpoint
|
|
||||||
}
|
|
||||||
items = append(items, desc)
|
|
||||||
}
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
|
|
||||||
func renderInterfaceItems(interfaces []platform.InterfaceInfo) []string {
|
|
||||||
items := make([]string, 0, len(interfaces))
|
|
||||||
for _, iface := range interfaces {
|
|
||||||
label := iface.Name
|
|
||||||
if len(iface.IPv4) > 0 {
|
|
||||||
label += " [" + strings.Join(iface.IPv4, ", ") + "]"
|
|
||||||
}
|
|
||||||
items = append(items, label)
|
|
||||||
}
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
|
|
||||||
func renderMenu(title, subtitle string, items []string, cursor int) string {
|
|
||||||
var body strings.Builder
|
|
||||||
fmt.Fprintf(&body, "%s\n\n%s\n\n", title, subtitle)
|
|
||||||
if len(items) == 0 {
|
|
||||||
body.WriteString("(no items)\n")
|
|
||||||
} else {
|
|
||||||
for i, item := range items {
|
|
||||||
prefix := " "
|
|
||||||
if i == cursor {
|
|
||||||
prefix = "> "
|
|
||||||
}
|
|
||||||
fmt.Fprintf(&body, "%s%s\n", prefix, item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
body.WriteString("\n[↑/↓] move [enter] select [esc] back [ctrl+c] quit\n")
|
|
||||||
return body.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func renderForm(title string, fields []formField, idx int) string {
|
|
||||||
var body strings.Builder
|
|
||||||
fmt.Fprintf(&body, "%s\n\n", title)
|
|
||||||
for i, field := range fields {
|
|
||||||
prefix := " "
|
|
||||||
if i == idx {
|
|
||||||
prefix = "> "
|
|
||||||
}
|
|
||||||
fmt.Fprintf(&body, "%s%s: %s\n", prefix, field.Label, field.Value)
|
|
||||||
}
|
|
||||||
body.WriteString("\n[tab/↑/↓] move [enter] next/submit [backspace] delete [esc] cancel\n")
|
|
||||||
return body.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func renderConfirm(title, body string, cursor int) string {
|
|
||||||
options := []string{"Confirm", "Cancel"}
|
|
||||||
var out strings.Builder
|
|
||||||
fmt.Fprintf(&out, "%s\n\n%s\n\n", title, body)
|
|
||||||
for i, option := range options {
|
|
||||||
prefix := " "
|
|
||||||
if i == cursor {
|
|
||||||
prefix = "> "
|
|
||||||
}
|
|
||||||
fmt.Fprintf(&out, "%s%s\n", prefix, option)
|
|
||||||
}
|
|
||||||
out.WriteString("\n[←/→/↑/↓] move [enter] select [esc] cancel\n")
|
|
||||||
return out.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func resultCmd(title, body string, err error, back screen) tea.Cmd {
|
|
||||||
return func() tea.Msg {
|
|
||||||
return resultMsg{title: title, body: body, err: err, back: back}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m model) renderWithBanner(body string) string {
|
|
||||||
body = strings.TrimRight(body, "\n")
|
|
||||||
banner := renderBannerModule(m.banner, m.width)
|
|
||||||
if banner == "" {
|
|
||||||
if body == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return body + "\n"
|
|
||||||
}
|
|
||||||
if body == "" {
|
|
||||||
return banner + "\n"
|
|
||||||
}
|
|
||||||
return banner + "\n\n" + body + "\n"
|
|
||||||
}
|
|
||||||
|
|
||||||
func renderBannerModule(banner string, width int) string {
|
|
||||||
banner = strings.TrimSpace(banner)
|
|
||||||
if banner == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
lines := strings.Split(banner, "\n")
|
|
||||||
contentWidth := 0
|
|
||||||
for _, line := range lines {
|
|
||||||
if w := lipgloss.Width(line); w > contentWidth {
|
|
||||||
contentWidth = w
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if width > 0 && width-4 > contentWidth {
|
|
||||||
contentWidth = width - 4
|
|
||||||
}
|
|
||||||
if contentWidth < 20 {
|
|
||||||
contentWidth = 20
|
|
||||||
}
|
|
||||||
|
|
||||||
label := " MOTD "
|
|
||||||
topFill := contentWidth + 2 - lipgloss.Width(label)
|
|
||||||
if topFill < 0 {
|
|
||||||
topFill = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
var b strings.Builder
|
|
||||||
b.WriteString("┌" + label + strings.Repeat("─", topFill) + "┐\n")
|
|
||||||
for _, line := range lines {
|
|
||||||
b.WriteString("│ " + padRight(line, contentWidth) + " │\n")
|
|
||||||
}
|
|
||||||
b.WriteString("└" + strings.Repeat("─", contentWidth+2) + "┘")
|
|
||||||
return b.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func padRight(value string, width int) string {
|
|
||||||
if gap := width - lipgloss.Width(value); gap > 0 {
|
|
||||||
return value + strings.Repeat(" ", gap)
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
433
audit/internal/webui/api.go
Normal file
433
audit/internal/webui/api.go
Normal file
@@ -0,0 +1,433 @@
|
|||||||
|
package webui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"bee/audit/internal/app"
|
||||||
|
"bee/audit/internal/platform"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Job ID counter ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
var jobCounter atomic.Uint64
|
||||||
|
|
||||||
|
func newJobID(prefix string) string {
|
||||||
|
return fmt.Sprintf("%s-%d", prefix, jobCounter.Add(1))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SSE helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func sseWrite(w http.ResponseWriter, event, data string) bool {
|
||||||
|
f, ok := w.(http.Flusher)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if event != "" {
|
||||||
|
fmt.Fprintf(w, "event: %s\n", event)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "data: %s\n\n", data)
|
||||||
|
f.Flush()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func sseStart(w http.ResponseWriter) bool {
|
||||||
|
_, ok := w.(http.Flusher)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "streaming not supported", http.StatusInternalServerError)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
w.Header().Set("Connection", "keep-alive")
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// streamJob streams lines from a jobState to a SSE response.
|
||||||
|
func streamJob(w http.ResponseWriter, r *http.Request, j *jobState) {
|
||||||
|
if !sseStart(w) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
existing, ch := j.subscribe()
|
||||||
|
for _, line := range existing {
|
||||||
|
sseWrite(w, "", line)
|
||||||
|
}
|
||||||
|
if ch == nil {
|
||||||
|
// Job already finished
|
||||||
|
sseWrite(w, "done", j.err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case line, ok := <-ch:
|
||||||
|
if !ok {
|
||||||
|
sseWrite(w, "done", j.err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sseWrite(w, "", line)
|
||||||
|
case <-r.Context().Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runCmdJob runs an exec.Cmd as a background job, streaming stdout+stderr lines.
|
||||||
|
func runCmdJob(j *jobState, cmd *exec.Cmd) {
|
||||||
|
pr, pw := io.Pipe()
|
||||||
|
cmd.Stdout = pw
|
||||||
|
cmd.Stderr = pw
|
||||||
|
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
j.finish(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
scanner := bufio.NewScanner(pr)
|
||||||
|
for scanner.Scan() {
|
||||||
|
j.append(scanner.Text())
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
err := cmd.Wait()
|
||||||
|
_ = pw.Close()
|
||||||
|
if err != nil {
|
||||||
|
j.finish(err.Error())
|
||||||
|
} else {
|
||||||
|
j.finish("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Audit ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (h *handler) handleAPIAuditRun(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if h.opts.App == nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "app not configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id := newJobID("audit")
|
||||||
|
j := globalJobs.create(id)
|
||||||
|
go func() {
|
||||||
|
j.append("Running audit...")
|
||||||
|
result, err := h.opts.App.RunAuditNow(h.opts.RuntimeMode)
|
||||||
|
if err != nil {
|
||||||
|
j.append("ERROR: " + err.Error())
|
||||||
|
j.finish(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(result.Body, "\n") {
|
||||||
|
if line != "" {
|
||||||
|
j.append(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
j.finish("")
|
||||||
|
}()
|
||||||
|
writeJSON(w, map[string]string{"job_id": id})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handler) handleAPIAuditStream(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
|
||||||
|
}
|
||||||
|
streamJob(w, r, j)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SAT ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (h *handler) handleAPISATRun(target string) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if h.opts.App == nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "app not configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id := newJobID("sat-" + target)
|
||||||
|
j := globalJobs.create(id)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
j.append(fmt.Sprintf("Starting %s acceptance test...", target))
|
||||||
|
var (
|
||||||
|
archive string
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
|
||||||
|
// Parse optional parameters
|
||||||
|
var body struct {
|
||||||
|
Duration int `json:"duration"`
|
||||||
|
DiagLevel int `json:"diag_level"`
|
||||||
|
GPUIndices []int `json:"gpu_indices"`
|
||||||
|
}
|
||||||
|
body.DiagLevel = 1
|
||||||
|
if r.ContentLength > 0 {
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch target {
|
||||||
|
case "nvidia":
|
||||||
|
if len(body.GPUIndices) > 0 || body.DiagLevel > 0 {
|
||||||
|
result, e := h.opts.App.RunNvidiaAcceptancePackWithOptions(
|
||||||
|
context.Background(), "", body.DiagLevel, body.GPUIndices,
|
||||||
|
)
|
||||||
|
if e != nil {
|
||||||
|
err = e
|
||||||
|
} else {
|
||||||
|
archive = result.Body
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
archive, err = h.opts.App.RunNvidiaAcceptancePack("")
|
||||||
|
}
|
||||||
|
case "memory":
|
||||||
|
archive, err = h.opts.App.RunMemoryAcceptancePack("")
|
||||||
|
case "storage":
|
||||||
|
archive, err = h.opts.App.RunStorageAcceptancePack("")
|
||||||
|
case "cpu":
|
||||||
|
dur := body.Duration
|
||||||
|
if dur <= 0 {
|
||||||
|
dur = 60
|
||||||
|
}
|
||||||
|
archive, err = h.opts.App.RunCPUAcceptancePack("", dur)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
j.append("ERROR: " + err.Error())
|
||||||
|
j.finish(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
j.append(fmt.Sprintf("Archive written: %s", archive))
|
||||||
|
j.finish("")
|
||||||
|
}()
|
||||||
|
|
||||||
|
writeJSON(w, map[string]string{"job_id": id})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handler) handleAPISATStream(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
|
||||||
|
}
|
||||||
|
streamJob(w, r, j)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Services ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (h *handler) handleAPIServicesList(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if h.opts.App == nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "app not configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
names, err := h.opts.App.ListBeeServices()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
type serviceInfo struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
result := make([]serviceInfo, 0, len(names))
|
||||||
|
for _, name := range names {
|
||||||
|
status, _ := h.opts.App.ServiceStatus(name)
|
||||||
|
result = append(result, serviceInfo{Name: name, Status: status})
|
||||||
|
}
|
||||||
|
writeJSON(w, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handler) handleAPIServicesAction(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if h.opts.App == nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "app not configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Action string `json:"action"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var action platform.ServiceAction
|
||||||
|
switch req.Action {
|
||||||
|
case "start":
|
||||||
|
action = platform.ServiceStart
|
||||||
|
case "stop":
|
||||||
|
action = platform.ServiceStop
|
||||||
|
case "restart":
|
||||||
|
action = platform.ServiceRestart
|
||||||
|
default:
|
||||||
|
writeError(w, http.StatusBadRequest, "action must be start|stop|restart")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := h.opts.App.ServiceActionResult(req.Name, action)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]string{"status": "ok", "output": result.Body})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Network ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (h *handler) handleAPINetworkStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if h.opts.App == nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "app not configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ifaces, err := h.opts.App.ListInterfaces()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]any{
|
||||||
|
"interfaces": ifaces,
|
||||||
|
"default_route": h.opts.App.DefaultRoute(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handler) handleAPINetworkDHCP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if h.opts.App == nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "app not configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
Interface string `json:"interface"`
|
||||||
|
}
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
|
||||||
|
var result app.ActionResult
|
||||||
|
var err error
|
||||||
|
if req.Interface == "" || req.Interface == "all" {
|
||||||
|
result, err = h.opts.App.DHCPAllResult()
|
||||||
|
} else {
|
||||||
|
result, err = h.opts.App.DHCPOneResult(req.Interface)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]string{"status": "ok", "output": result.Body})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handler) handleAPINetworkStatic(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if h.opts.App == nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "app not configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
Interface string `json:"interface"`
|
||||||
|
Address string `json:"address"`
|
||||||
|
Prefix string `json:"prefix"`
|
||||||
|
Gateway string `json:"gateway"`
|
||||||
|
DNS []string `json:"dns"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg := platform.StaticIPv4Config{
|
||||||
|
Interface: req.Interface,
|
||||||
|
Address: req.Address,
|
||||||
|
Prefix: req.Prefix,
|
||||||
|
Gateway: req.Gateway,
|
||||||
|
DNS: req.DNS,
|
||||||
|
}
|
||||||
|
result, err := h.opts.App.SetStaticIPv4Result(cfg)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]string{"status": "ok", "output": result.Body})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Export ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (h *handler) handleAPIExportList(w http.ResponseWriter, r *http.Request) {
|
||||||
|
entries, err := listExportFiles(h.opts.ExportDir)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handler) handleAPIExportBundle(w http.ResponseWriter, r *http.Request) {
|
||||||
|
archive, err := app.BuildSupportBundle(h.opts.ExportDir)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]string{
|
||||||
|
"status": "ok",
|
||||||
|
"path": archive,
|
||||||
|
"url": "/export/support.tar.gz",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tools ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
var standardTools = []string{
|
||||||
|
"dmidecode", "smartctl", "nvme", "lspci", "ipmitool",
|
||||||
|
"nvidia-smi", "memtester", "stress-ng", "nvtop",
|
||||||
|
"mstflint", "qrencode",
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handler) handleAPIToolsCheck(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if h.opts.App == nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "app not configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
statuses := h.opts.App.CheckTools(standardTools)
|
||||||
|
writeJSON(w, statuses)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Preflight ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (h *handler) handleAPIPreflight(w http.ResponseWriter, r *http.Request) {
|
||||||
|
data, err := loadSnapshot(filepath.Join(h.opts.ExportDir, "runtime-health.json"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "runtime health not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
_, _ = w.Write(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Metrics SSE ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (h *handler) handleAPIMetricsStream(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !sseStart(w) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ticker := time.NewTicker(time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-r.Context().Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
sample := platform.SampleLiveMetrics()
|
||||||
|
b, err := json.Marshal(sample)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !sseWrite(w, "metrics", string(b)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
84
audit/internal/webui/jobs.go
Normal file
84
audit/internal/webui/jobs.go
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
package webui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// jobState holds the output lines and completion status of an async job.
|
||||||
|
type jobState struct {
|
||||||
|
lines []string
|
||||||
|
done bool
|
||||||
|
err string
|
||||||
|
mu sync.Mutex
|
||||||
|
// subs is a list of channels that receive new lines as they arrive.
|
||||||
|
subs []chan string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *jobState) append(line string) {
|
||||||
|
j.mu.Lock()
|
||||||
|
defer j.mu.Unlock()
|
||||||
|
j.lines = append(j.lines, line)
|
||||||
|
for _, ch := range j.subs {
|
||||||
|
select {
|
||||||
|
case ch <- line:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *jobState) finish(errMsg string) {
|
||||||
|
j.mu.Lock()
|
||||||
|
defer j.mu.Unlock()
|
||||||
|
j.done = true
|
||||||
|
j.err = errMsg
|
||||||
|
for _, ch := range j.subs {
|
||||||
|
close(ch)
|
||||||
|
}
|
||||||
|
j.subs = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// subscribe returns a channel that receives all future lines.
|
||||||
|
// Existing lines are returned first, then the channel streams new ones.
|
||||||
|
func (j *jobState) subscribe() ([]string, <-chan string) {
|
||||||
|
j.mu.Lock()
|
||||||
|
defer j.mu.Unlock()
|
||||||
|
existing := make([]string, len(j.lines))
|
||||||
|
copy(existing, j.lines)
|
||||||
|
if j.done {
|
||||||
|
return existing, nil
|
||||||
|
}
|
||||||
|
ch := make(chan string, 256)
|
||||||
|
j.subs = append(j.subs, ch)
|
||||||
|
return existing, ch
|
||||||
|
}
|
||||||
|
|
||||||
|
// jobManager manages async jobs identified by string IDs.
|
||||||
|
type jobManager struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
jobs map[string]*jobState
|
||||||
|
}
|
||||||
|
|
||||||
|
var globalJobs = &jobManager{jobs: make(map[string]*jobState)}
|
||||||
|
|
||||||
|
func (m *jobManager) create(id string) *jobState {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
j := &jobState{}
|
||||||
|
m.jobs[id] = j
|
||||||
|
// Schedule cleanup after 30 minutes
|
||||||
|
go func() {
|
||||||
|
time.Sleep(30 * time.Minute)
|
||||||
|
m.mu.Lock()
|
||||||
|
delete(m.jobs, id)
|
||||||
|
m.mu.Unlock()
|
||||||
|
}()
|
||||||
|
return j
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *jobManager) get(id string) (*jobState, bool) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
j, ok := m.jobs[id]
|
||||||
|
return j, ok
|
||||||
|
}
|
||||||
719
audit/internal/webui/pages.go
Normal file
719
audit/internal/webui/pages.go
Normal file
@@ -0,0 +1,719 @@
|
|||||||
|
package webui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"html"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Layout ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func layoutHead(title string) string {
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>` + html.EscapeString(title) + `</title>
|
||||||
|
<style>
|
||||||
|
*{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}
|
||||||
|
a{color:inherit;text-decoration:none}
|
||||||
|
/* Sidebar */
|
||||||
|
.sidebar{width:200px;min-height:100vh;background:#161b25;border-right:1px solid #252d3d;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 span{color:#94a3b8;font-weight:400;font-size:13px;display:block;margin-top:2px}
|
||||||
|
.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:hover,.nav-item.active{background:#1e2535;color:#e2e8f0;border-left-color:#3b82f6}
|
||||||
|
.nav-icon{margin-right:8px;opacity:.7}
|
||||||
|
/* Content */
|
||||||
|
.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 h1{font-size:18px;font-weight:600}
|
||||||
|
.content{padding:24px;flex:1}
|
||||||
|
/* Cards */
|
||||||
|
.card{background:#161b25;border:1px solid #1e2535;border-radius:10px;margin-bottom:16px}
|
||||||
|
.card-head{padding:14px 18px;border-bottom:1px solid #1e2535;font-weight:600;font-size:14px;display:flex;align-items:center;gap:8px}
|
||||||
|
.card-body{padding:18px}
|
||||||
|
/* 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-primary{background:#3b82f6;color:#fff}.btn-primary:hover{background:#2563eb}
|
||||||
|
.btn-danger{background:#ef4444;color:#fff}.btn-danger:hover{background:#dc2626}
|
||||||
|
.btn-secondary{background:#1e2535;color:#94a3b8;border:1px solid #252d3d}.btn-secondary:hover{background:#252d3d;color:#e2e8f0}
|
||||||
|
.btn-sm{padding:5px 10px;font-size:12px}
|
||||||
|
/* Tables */
|
||||||
|
table{width:100%;border-collapse:collapse;font-size:13px}
|
||||||
|
th{text-align:left;padding:8px 12px;color:#64748b;font-weight:600;border-bottom:1px solid #1e2535}
|
||||||
|
td{padding:8px 12px;border-bottom:1px solid #1a2030}
|
||||||
|
tr:last-child td{border:none}
|
||||||
|
tr:hover td{background:#1a2030}
|
||||||
|
/* Status badges */
|
||||||
|
.badge{display:inline-block;padding:2px 8px;border-radius:999px;font-size:11px;font-weight:600}
|
||||||
|
.badge-ok{background:#166534;color:#86efac}
|
||||||
|
.badge-warn{background:#713f12;color:#fde68a}
|
||||||
|
.badge-err{background:#7f1d1d;color:#fca5a5}
|
||||||
|
.badge-unknown{background:#1e293b;color:#64748b}
|
||||||
|
/* 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}
|
||||||
|
/* Forms */
|
||||||
|
.form-row{margin-bottom:14px}
|
||||||
|
.form-row label{display:block;font-size:12px;color:#64748b;margin-bottom:5px}
|
||||||
|
.form-row input,.form-row select{width:100%;padding:8px 10px;background:#0f1117;border:1px solid #252d3d;border-radius:6px;color:#e2e8f0;font-size:13px;outline:none}
|
||||||
|
.form-row input:focus,.form-row select:focus{border-color:#3b82f6}
|
||||||
|
/* Metrics chart */
|
||||||
|
.chart-wrap{position:relative;height:140px;background:#0a0d14;border-radius:8px;overflow:hidden;margin-bottom:8px}
|
||||||
|
canvas.chart{width:100%;height:100%}
|
||||||
|
.chart-legend{font-size:11px;color:#64748b;padding:4px 0}
|
||||||
|
/* Grid */
|
||||||
|
.grid2{display:grid;grid-template-columns: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}}
|
||||||
|
/* iframe viewer */
|
||||||
|
.viewer-frame{width:100%;height:calc(100vh - 160px);border:0;border-radius:8px;background:#1a1f2e}
|
||||||
|
/* Alerts */
|
||||||
|
.alert{padding:10px 14px;border-radius:8px;font-size:13px;margin-bottom:14px}
|
||||||
|
.alert-info{background:#1e3a5f;border:1px solid #2563eb;color:#93c5fd}
|
||||||
|
.alert-warn{background:#451a03;border:1px solid #d97706;color:#fde68a}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
func layoutNav(active string) string {
|
||||||
|
items := []struct{ id, icon, label string }{
|
||||||
|
{"dashboard", "📊", "Dashboard"},
|
||||||
|
{"metrics", "📈", "Metrics"},
|
||||||
|
{"tests", "🧪", "Acceptance Tests"},
|
||||||
|
{"burn-in", "🔥", "Burn-in"},
|
||||||
|
{"network", "🌐", "Network"},
|
||||||
|
{"services", "⚙️", "Services"},
|
||||||
|
{"export", "📦", "Export"},
|
||||||
|
{"tools", "🔧", "Tools"},
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(`<aside class="sidebar">`)
|
||||||
|
b.WriteString(`<div class="sidebar-logo">🐝 bee<span>hardware audit</span></div>`)
|
||||||
|
b.WriteString(`<nav class="nav">`)
|
||||||
|
for _, item := range items {
|
||||||
|
cls := "nav-item"
|
||||||
|
if item.id == active {
|
||||||
|
cls += " active"
|
||||||
|
}
|
||||||
|
href := "/"
|
||||||
|
if item.id != "dashboard" {
|
||||||
|
href = "/" + item.id
|
||||||
|
}
|
||||||
|
b.WriteString(fmt.Sprintf(`<a class="%s" href="%s"><span class="nav-icon">%s</span>%s</a>`,
|
||||||
|
cls, href, item.icon, item.label))
|
||||||
|
}
|
||||||
|
b.WriteString(`</nav></aside>`)
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderPage dispatches to the appropriate page renderer.
|
||||||
|
func renderPage(page string, opts HandlerOptions) string {
|
||||||
|
var pageID, title, body string
|
||||||
|
switch page {
|
||||||
|
case "dashboard", "":
|
||||||
|
pageID = "dashboard"
|
||||||
|
title = "Dashboard"
|
||||||
|
body = renderDashboard(opts)
|
||||||
|
case "metrics":
|
||||||
|
pageID = "metrics"
|
||||||
|
title = "Live Metrics"
|
||||||
|
body = renderMetrics()
|
||||||
|
case "tests":
|
||||||
|
pageID = "tests"
|
||||||
|
title = "Acceptance Tests"
|
||||||
|
body = renderTests()
|
||||||
|
case "burn-in":
|
||||||
|
pageID = "burn-in"
|
||||||
|
title = "Burn-in Tests"
|
||||||
|
body = renderBurnIn()
|
||||||
|
case "network":
|
||||||
|
pageID = "network"
|
||||||
|
title = "Network"
|
||||||
|
body = renderNetwork()
|
||||||
|
case "services":
|
||||||
|
pageID = "services"
|
||||||
|
title = "Services"
|
||||||
|
body = renderServices()
|
||||||
|
case "export":
|
||||||
|
pageID = "export"
|
||||||
|
title = "Export"
|
||||||
|
body = renderExport(opts.ExportDir)
|
||||||
|
case "tools":
|
||||||
|
pageID = "tools"
|
||||||
|
title = "Tools"
|
||||||
|
body = renderTools()
|
||||||
|
default:
|
||||||
|
pageID = "dashboard"
|
||||||
|
title = "Not Found"
|
||||||
|
body = `<div class="alert alert-warn">Page not found.</div>`
|
||||||
|
}
|
||||||
|
|
||||||
|
return layoutHead(opts.Title+" — "+title) +
|
||||||
|
layoutNav(pageID) +
|
||||||
|
`<div class="main"><div class="topbar"><h1>` + html.EscapeString(title) + `</h1></div><div class="content">` +
|
||||||
|
body +
|
||||||
|
`</div></div></body></html>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Dashboard ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func renderDashboard(opts HandlerOptions) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(`<div class="grid2">`)
|
||||||
|
// Left: health summary
|
||||||
|
b.WriteString(`<div>`)
|
||||||
|
b.WriteString(renderHealthCard(opts))
|
||||||
|
b.WriteString(`</div>`)
|
||||||
|
// Right: quick actions
|
||||||
|
b.WriteString(`<div>`)
|
||||||
|
b.WriteString(`<div class="card"><div class="card-head">Quick Actions</div><div class="card-body">`)
|
||||||
|
b.WriteString(`<a class="btn btn-primary" href="/export/support.tar.gz" style="display:block;margin-bottom:10px">⬇ Download Support Bundle</a>`)
|
||||||
|
b.WriteString(`<a class="btn btn-secondary" href="/audit.json" style="display:block;margin-bottom:10px" target="_blank">📄 Open audit.json</a>`)
|
||||||
|
b.WriteString(`<a class="btn btn-secondary" href="/export/" style="display:block">📁 Browse Export Files</a>`)
|
||||||
|
b.WriteString(`<div style="margin-top:14px"><button class="btn btn-secondary" onclick="runAudit()">▶ Re-run Audit</button></div>`)
|
||||||
|
b.WriteString(`</div></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
|
||||||
|
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(`<script>
|
||||||
|
function runAudit() {
|
||||||
|
document.getElementById('audit-output').style.display='block';
|
||||||
|
const term = document.getElementById('audit-terminal');
|
||||||
|
term.textContent = 'Starting audit...\n';
|
||||||
|
fetch('/api/audit/run', {method:'POST'})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => {
|
||||||
|
const es = new EventSource('/api/audit/stream?job_id=' + d.job_id);
|
||||||
|
es.onmessage = e => { term.textContent += e.data + '\n'; term.scrollTop = term.scrollHeight; };
|
||||||
|
es.addEventListener('done', e => { es.close(); term.textContent += (e.data ? '\\nERROR: ' + e.data : '\\nDone.') + '\n'; location.reload(); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>`)
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderHealthCard(opts HandlerOptions) string {
|
||||||
|
data, err := loadSnapshot(filepath.Join(opts.ExportDir, "runtime-health.json"))
|
||||||
|
if err != nil {
|
||||||
|
return `<div class="card"><div class="card-head">Runtime Health</div><div class="card-body"><span class="badge badge-unknown">No data</span></div></div>`
|
||||||
|
}
|
||||||
|
var health map[string]any
|
||||||
|
if err := json.Unmarshal(data, &health); err != nil {
|
||||||
|
return `<div class="card"><div class="card-head">Runtime Health</div><div class="card-body"><span class="badge badge-err">Parse error</span></div></div>`
|
||||||
|
}
|
||||||
|
status := fmt.Sprintf("%v", health["status"])
|
||||||
|
badge := "badge-ok"
|
||||||
|
if status == "PARTIAL" {
|
||||||
|
badge = "badge-warn"
|
||||||
|
} else if status == "FAIL" || status == "FAILED" {
|
||||||
|
badge = "badge-err"
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(`<div class="card"><div class="card-head">Runtime Health</div><div class="card-body">`)
|
||||||
|
b.WriteString(fmt.Sprintf(`<div style="margin-bottom:10px"><span class="badge %s">%s</span></div>`, badge, html.EscapeString(status)))
|
||||||
|
if issues, ok := health["issues"].([]any); ok && len(issues) > 0 {
|
||||||
|
b.WriteString(`<div style="font-size:12px;color:#f87171">Issues:<br>`)
|
||||||
|
for _, issue := range issues {
|
||||||
|
if m, ok := issue.(map[string]any); ok {
|
||||||
|
b.WriteString(html.EscapeString(fmt.Sprintf("%v: %v", m["code"], m["message"])) + "<br>")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.WriteString(`</div>`)
|
||||||
|
}
|
||||||
|
b.WriteString(`</div></div>`)
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Metrics ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func renderMetrics() string {
|
||||||
|
return `<p style="color:#64748b;font-size:13px;margin-bottom:16px">Live server metrics updated every second.</p>
|
||||||
|
<div class="grid2">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-head">GPU Metrics</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="chart-wrap"><canvas id="chart-gpu-temp" class="chart"></canvas></div>
|
||||||
|
<div class="chart-legend">Temperature °C</div>
|
||||||
|
<div class="chart-wrap"><canvas id="chart-gpu-usage" class="chart"></canvas></div>
|
||||||
|
<div class="chart-legend">Usage %</div>
|
||||||
|
<div class="chart-wrap"><canvas id="chart-gpu-power" class="chart"></canvas></div>
|
||||||
|
<div class="chart-legend">Power W</div>
|
||||||
|
<div id="gpu-table"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-head">System Metrics</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="chart-wrap"><canvas id="chart-cpu-temp" class="chart"></canvas></div>
|
||||||
|
<div class="chart-legend">CPU Temperature °C</div>
|
||||||
|
<div class="chart-wrap"><canvas id="chart-fans" class="chart"></canvas></div>
|
||||||
|
<div class="chart-legend">Fan Speed RPM</div>
|
||||||
|
<div class="chart-wrap"><canvas id="chart-power" class="chart"></canvas></div>
|
||||||
|
<div class="chart-legend">System Power W</div>
|
||||||
|
<div id="sys-table"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
const WINDOW = 120;
|
||||||
|
const history = {gpuTemp:[],gpuUsage:[],gpuPower:[],cpuTemp:[],fans:[],power:[]};
|
||||||
|
const colors = ['#60a5fa','#34d399','#f87171','#fbbf24','#a78bfa','#fb7185'];
|
||||||
|
|
||||||
|
function push(arr, val) { arr.push(val); if (arr.length > WINDOW) arr.shift(); }
|
||||||
|
|
||||||
|
function drawChart(canvasId, datasets, maxY) {
|
||||||
|
const c = document.getElementById(canvasId);
|
||||||
|
if (!c) return;
|
||||||
|
const W = c.offsetWidth, H = c.offsetHeight;
|
||||||
|
c.width = W; c.height = H;
|
||||||
|
const ctx = c.getContext('2d');
|
||||||
|
ctx.clearRect(0,0,W,H);
|
||||||
|
ctx.fillStyle = '#0a0d14';
|
||||||
|
ctx.fillRect(0,0,W,H);
|
||||||
|
// grid
|
||||||
|
ctx.strokeStyle = '#1e2535';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
for (let y = 0; y <= 4; y++) {
|
||||||
|
const py = H * y / 4;
|
||||||
|
ctx.beginPath(); ctx.moveTo(0,py); ctx.lineTo(W,py); ctx.stroke();
|
||||||
|
}
|
||||||
|
const max = maxY || datasets.reduce((m,d) => Math.max(m,...d.map(v=>v||0)), 1) || 1;
|
||||||
|
datasets.forEach((data, i) => {
|
||||||
|
if (!data.length) return;
|
||||||
|
ctx.strokeStyle = colors[i % colors.length];
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
ctx.beginPath();
|
||||||
|
data.forEach((v, j) => {
|
||||||
|
const x = W * j / Math.max(data.length - 1, 1);
|
||||||
|
const y = H - H * (v || 0) / max;
|
||||||
|
j === 0 ? ctx.moveTo(x,y) : ctx.lineTo(x,y);
|
||||||
|
});
|
||||||
|
ctx.stroke();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const es = new EventSource('/api/metrics/stream');
|
||||||
|
es.addEventListener('metrics', e => {
|
||||||
|
const d = JSON.parse(e.data);
|
||||||
|
|
||||||
|
// GPU
|
||||||
|
const gpuTemps = (d.gpus||[]).map(g => g.temp_c||0);
|
||||||
|
const gpuUsages = (d.gpus||[]).map(g => g.usage_pct||0);
|
||||||
|
const gpuPowers = (d.gpus||[]).map(g => g.power_w||0);
|
||||||
|
if (!history.gpuTemp.length && gpuTemps.length) {
|
||||||
|
history.gpuTemp = gpuTemps.map(() => []);
|
||||||
|
history.gpuUsage = gpuUsages.map(() => []);
|
||||||
|
history.gpuPower = gpuPowers.map(() => []);
|
||||||
|
}
|
||||||
|
gpuTemps.forEach((v,i) => push(history.gpuTemp[i]||[], v));
|
||||||
|
gpuUsages.forEach((v,i) => push(history.gpuUsage[i]||[], v));
|
||||||
|
gpuPowers.forEach((v,i) => push(history.gpuPower[i]||[], v));
|
||||||
|
drawChart('chart-gpu-temp', history.gpuTemp.length ? history.gpuTemp : [history.cpuTemp]);
|
||||||
|
drawChart('chart-gpu-usage', history.gpuUsage, 100);
|
||||||
|
drawChart('chart-gpu-power', history.gpuPower);
|
||||||
|
|
||||||
|
// GPU table
|
||||||
|
const gpuRows = (d.gpus||[]).map(g =>
|
||||||
|
'<tr><td>GPU '+g.index+'</td><td>'+g.temp_c+'°C</td><td>'+g.usage_pct+'%</td><td>'+g.power_w+'W</td><td>'+g.clock_mhz+'MHz</td></tr>'
|
||||||
|
).join('');
|
||||||
|
document.getElementById('gpu-table').innerHTML = gpuRows ?
|
||||||
|
'<table><tr><th>GPU</th><th>Temp</th><th>Usage</th><th>Power</th><th>Clock</th></tr>'+gpuRows+'</table>' :
|
||||||
|
'<p style="color:#64748b;font-size:12px">No NVIDIA GPU detected</p>';
|
||||||
|
|
||||||
|
// System
|
||||||
|
const cpuTemp = (d.temps||[]).find(t => t.name==='CPU');
|
||||||
|
push(history.cpuTemp, cpuTemp ? cpuTemp.celsius : 0);
|
||||||
|
const fanRPMs = (d.fans||[]).map(f => f.rpm||0);
|
||||||
|
if (!history.fans.length && fanRPMs.length) history.fans = fanRPMs.map(() => []);
|
||||||
|
fanRPMs.forEach((v,i) => push(history.fans[i]||[], v));
|
||||||
|
push(history.power, d.power_w||0);
|
||||||
|
drawChart('chart-cpu-temp', [history.cpuTemp]);
|
||||||
|
drawChart('chart-fans', history.fans.length ? history.fans : [[]]);
|
||||||
|
drawChart('chart-power', [history.power]);
|
||||||
|
|
||||||
|
// Sys table
|
||||||
|
let sysHTML = '';
|
||||||
|
if (cpuTemp) sysHTML += '<tr><td>CPU Temp</td><td>'+cpuTemp.celsius.toFixed(1)+'°C</td></tr>';
|
||||||
|
(d.fans||[]).forEach(f => sysHTML += '<tr><td>'+f.name+'</td><td>'+f.rpm+' RPM</td></tr>');
|
||||||
|
if (d.power_w) sysHTML += '<tr><td>System Power</td><td>'+d.power_w.toFixed(0)+'W</td></tr>';
|
||||||
|
document.getElementById('sys-table').innerHTML = sysHTML ?
|
||||||
|
'<table style="margin-top:8px">'+sysHTML+'</table>' :
|
||||||
|
'<p style="color:#64748b;font-size:12px">No sensor data (requires ipmitool/sensors)</p>';
|
||||||
|
});
|
||||||
|
es.onerror = () => { /* reconnect automatically */ };
|
||||||
|
</script>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Acceptance Tests ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func renderTests() string {
|
||||||
|
return `<p style="color:#64748b;font-size:13px;margin-bottom:16px">Run hardware acceptance tests and view results.</p>
|
||||||
|
<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("memory", "Memory", "") +
|
||||||
|
renderSATCard("storage", "Storage", "") +
|
||||||
|
renderSATCard("cpu", "CPU", `<div class="form-row"><label>Duration (seconds)</label><input type="number" id="sat-cpu-dur" value="60" min="10"></div>`) +
|
||||||
|
`</div>
|
||||||
|
<div id="sat-output" style="display:none;margin-top:16px" class="card">
|
||||||
|
<div class="card-head">Test Output <span id="sat-title"></span></div>
|
||||||
|
<div class="card-body"><div id="sat-terminal" class="terminal"></div></div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
let satES = null;
|
||||||
|
function runSAT(target) {
|
||||||
|
if (satES) satES.close();
|
||||||
|
const body = {};
|
||||||
|
if (target === 'nvidia') body.diag_level = parseInt(document.getElementById('sat-nvidia-level').value)||1;
|
||||||
|
if (target === 'cpu') body.duration = parseInt(document.getElementById('sat-cpu-dur').value)||60;
|
||||||
|
document.getElementById('sat-output').style.display='block';
|
||||||
|
document.getElementById('sat-title').textContent = '— ' + target;
|
||||||
|
const term = document.getElementById('sat-terminal');
|
||||||
|
term.textContent = 'Starting ' + target + ' test...\n';
|
||||||
|
fetch('/api/sat/'+target+'/run', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => {
|
||||||
|
satES = new EventSource('/api/sat/stream?job_id='+d.job_id);
|
||||||
|
satES.onmessage = e => { term.textContent += e.data+'\n'; term.scrollTop=term.scrollHeight; };
|
||||||
|
satES.addEventListener('done', e => { satES.close(); term.textContent += (e.data ? '\nERROR: '+e.data : '\nCompleted.')+'\n'; });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>`
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderSATCard(id, label, extra string) string {
|
||||||
|
return fmt.Sprintf(`<div class="card"><div class="card-head">%s</div><div class="card-body">%s<button class="btn btn-primary" onclick="runSAT('%s')">▶ Run Test</button></div></div>`,
|
||||||
|
label, extra, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Burn-in ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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>
|
||||||
|
<div class="grid2">
|
||||||
|
<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>
|
||||||
|
<button class="btn btn-primary" onclick="runBurnIn('nvidia')">▶ Start GPU Stress</button>
|
||||||
|
</div></div>
|
||||||
|
<div class="card"><div class="card-head">CPU Stress</div><div class="card-body">
|
||||||
|
<div class="form-row"><label>Duration (seconds)</label><input type="number" id="bi-cpu-dur" value="300" min="60"></div>
|
||||||
|
<button class="btn btn-primary" onclick="runBurnIn('cpu')">▶ Start CPU Stress</button>
|
||||||
|
</div></div>
|
||||||
|
</div>
|
||||||
|
<div id="bi-output" style="display:none;margin-top:16px" class="card">
|
||||||
|
<div class="card-head">Output</div>
|
||||||
|
<div class="card-body"><div id="bi-terminal" class="terminal"></div></div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
let biES = null;
|
||||||
|
function runBurnIn(target) {
|
||||||
|
if (biES) biES.close();
|
||||||
|
const body = {};
|
||||||
|
if (target === 'nvidia') body.duration = parseInt(document.getElementById('bi-dur').value)||600;
|
||||||
|
if (target === 'cpu') body.duration = parseInt(document.getElementById('bi-cpu-dur').value)||300;
|
||||||
|
document.getElementById('bi-output').style.display='block';
|
||||||
|
const term = document.getElementById('bi-terminal');
|
||||||
|
term.textContent = 'Starting ' + target + ' burn-in...\n';
|
||||||
|
fetch('/api/sat/'+target+'/run', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => {
|
||||||
|
biES = new EventSource('/api/sat/stream?job_id='+d.job_id);
|
||||||
|
biES.onmessage = e => { term.textContent += e.data+'\n'; term.scrollTop=term.scrollHeight; };
|
||||||
|
biES.addEventListener('done', e => { biES.close(); term.textContent += (e.data ? '\nERROR: '+e.data : '\nCompleted.')+'\n'; });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Network ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func renderNetwork() string {
|
||||||
|
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></div>
|
||||||
|
<div class="grid2">
|
||||||
|
<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>
|
||||||
|
<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></div>
|
||||||
|
<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>Address</label><input type="text" id="st-addr" placeholder="192.168.1.100"></div>
|
||||||
|
<div class="form-row"><label>Prefix length</label><input type="text" id="st-prefix" placeholder="24"></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>
|
||||||
|
<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></div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
function loadNetwork() {
|
||||||
|
fetch('/api/network').then(r=>r.json()).then(d => {
|
||||||
|
const rows = (d.interfaces||[]).map(i =>
|
||||||
|
'<tr><td>'+i.Name+'</td><td><span class="badge '+(i.State==='up'?'badge-ok':'badge-warn')+'">'+i.State+'</span></td><td>'+(i.IPv4||[]).join(', ')+'</td></tr>'
|
||||||
|
).join('');
|
||||||
|
document.getElementById('iface-table').innerHTML =
|
||||||
|
'<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>' : '');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function runDHCP() {
|
||||||
|
const iface = document.getElementById('dhcp-iface').value.trim();
|
||||||
|
fetch('/api/network/dhcp',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({interface:iface||'all'})})
|
||||||
|
.then(r=>r.json()).then(d => {
|
||||||
|
document.getElementById('dhcp-out').textContent = d.output || d.error || 'Done.';
|
||||||
|
loadNetwork();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function setStatic() {
|
||||||
|
const dns = document.getElementById('st-dns').value.split(',').map(s=>s.trim()).filter(Boolean);
|
||||||
|
fetch('/api/network/static',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({
|
||||||
|
interface: document.getElementById('st-iface').value,
|
||||||
|
address: document.getElementById('st-addr').value,
|
||||||
|
prefix: document.getElementById('st-prefix').value,
|
||||||
|
gateway: document.getElementById('st-gw').value,
|
||||||
|
dns: dns,
|
||||||
|
})}).then(r=>r.json()).then(d => {
|
||||||
|
document.getElementById('static-out').textContent = d.output || d.error || 'Done.';
|
||||||
|
loadNetwork();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
loadNetwork();
|
||||||
|
</script>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Services ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="svc-table"><p style="color:#64748b;font-size:13px">Loading...</p></div>
|
||||||
|
</div></div>
|
||||||
|
<div id="svc-out" style="display:none;margin-top:8px" class="card">
|
||||||
|
<div class="card-head">Output</div>
|
||||||
|
<div class="card-body" style="padding:10px"><div id="svc-terminal" class="terminal" style="max-height:150px"></div></div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
function loadServices() {
|
||||||
|
fetch('/api/services').then(r=>r.json()).then(svcs => {
|
||||||
|
const rows = svcs.map(s => {
|
||||||
|
const st = s.status||'unknown';
|
||||||
|
const badge = st.includes('active') ? 'badge-ok' : st.includes('failed') ? 'badge-err' : 'badge-warn';
|
||||||
|
return '<tr><td>'+s.name+'</td><td><span class="badge '+badge+'">'+st+'</span></td><td>' +
|
||||||
|
'<button class="btn btn-sm btn-secondary" onclick="svcAction(\''+s.name+'\',\'start\')">Start</button> ' +
|
||||||
|
'<button class="btn btn-sm btn-secondary" onclick="svcAction(\''+s.name+'\',\'stop\')">Stop</button> ' +
|
||||||
|
'<button class="btn btn-sm btn-secondary" onclick="svcAction(\''+s.name+'\',\'restart\')">Restart</button>' +
|
||||||
|
'</td></tr>';
|
||||||
|
}).join('');
|
||||||
|
document.getElementById('svc-table').innerHTML =
|
||||||
|
'<table><tr><th>Service</th><th>Status</th><th>Actions</th></tr>'+rows+'</table>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function svcAction(name, action) {
|
||||||
|
fetch('/api/services/action',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name,action})})
|
||||||
|
.then(r=>r.json()).then(d => {
|
||||||
|
document.getElementById('svc-out').style.display='block';
|
||||||
|
document.getElementById('svc-terminal').textContent = d.output || d.error || action+' '+name;
|
||||||
|
setTimeout(loadServices, 1000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
loadServices();
|
||||||
|
</script>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Export ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func renderExport(exportDir string) string {
|
||||||
|
entries, _ := listExportFiles(exportDir)
|
||||||
|
var rows strings.Builder
|
||||||
|
for _, e := range entries {
|
||||||
|
rows.WriteString(fmt.Sprintf(`<tr><td><a href="/export/file?path=%s" target="_blank">%s</a></td></tr>`,
|
||||||
|
url.QueryEscape(e), html.EscapeString(e)))
|
||||||
|
}
|
||||||
|
if len(entries) == 0 {
|
||||||
|
rows.WriteString(`<tr><td style="color:#64748b">No export files found.</td></tr>`)
|
||||||
|
}
|
||||||
|
return `<div class="grid2">
|
||||||
|
<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>
|
||||||
|
<a class="btn btn-primary" href="/export/support.tar.gz">⬇ Download Support Bundle</a>
|
||||||
|
</div></div>
|
||||||
|
<div class="card"><div class="card-head">Export Files</div><div class="card-body">
|
||||||
|
<table><tr><th>File</th></tr>` + rows.String() + `</table>
|
||||||
|
</div></div>
|
||||||
|
</div>`
|
||||||
|
}
|
||||||
|
|
||||||
|
func listExportFiles(exportDir string) ([]string, error) {
|
||||||
|
var entries []string
|
||||||
|
err := filepath.Walk(strings.TrimSpace(exportDir), func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rel, err := filepath.Rel(exportDir, path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
entries = append(entries, rel)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil && !os.IsNotExist(err) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sort.Strings(entries)
|
||||||
|
return entries, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tools ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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>
|
||||||
|
<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>
|
||||||
|
<script>
|
||||||
|
function checkTools() {
|
||||||
|
document.getElementById('tools-table').innerHTML = '<p style="color:#64748b;font-size:13px">Checking...</p>';
|
||||||
|
fetch('/api/tools/check').then(r=>r.json()).then(tools => {
|
||||||
|
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>'
|
||||||
|
).join('');
|
||||||
|
document.getElementById('tools-table').innerHTML =
|
||||||
|
'<table><tr><th>Tool</th><th>Status</th></tr>'+rows+'</table>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
checkTools();
|
||||||
|
</script>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Viewer (compatibility) ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// renderViewerPage renders the audit snapshot as a styled HTML page.
|
||||||
|
// This endpoint is embedded as an iframe on the Dashboard page.
|
||||||
|
func renderViewerPage(title string, snapshot []byte) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(`<!DOCTYPE html><html><head><meta charset="utf-8">`)
|
||||||
|
b.WriteString(`<title>` + html.EscapeString(title) + `</title>`)
|
||||||
|
b.WriteString(`<style>
|
||||||
|
*{box-sizing:border-box;margin:0;padding:0}
|
||||||
|
body{font-family:system-ui,sans-serif;background:#0f1117;color:#e2e8f0;padding:20px}
|
||||||
|
h2{font-size:14px;color:#64748b;margin-bottom:8px;margin-top:16px;text-transform:uppercase;letter-spacing:.05em}
|
||||||
|
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px}
|
||||||
|
.card{background:#161b25;border:1px solid #1e2535;border-radius:8px;padding:14px}
|
||||||
|
.card-title{font-size:12px;color:#64748b;margin-bottom:6px}
|
||||||
|
.card-value{font-size:15px;font-weight:600}
|
||||||
|
.badge{display:inline-block;padding:2px 8px;border-radius:999px;font-size:11px;font-weight:600}
|
||||||
|
.ok{background:#166534;color:#86efac}.warn{background:#713f12;color:#fde68a}.err{background:#7f1d1d;color:#fca5a5}
|
||||||
|
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}
|
||||||
|
</style></head><body>
|
||||||
|
`)
|
||||||
|
if len(snapshot) == 0 {
|
||||||
|
b.WriteString(`<p style="color:#64748b">No audit snapshot available yet. Re-run audit from the Dashboard.</p>`)
|
||||||
|
b.WriteString(`</body></html>`)
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
var data map[string]any
|
||||||
|
if err := json.Unmarshal(snapshot, &data); err != nil {
|
||||||
|
// Fallback: render raw JSON
|
||||||
|
b.WriteString(`<pre>` + html.EscapeString(string(snapshot)) + `</pre>`)
|
||||||
|
b.WriteString(`</body></html>`)
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collected at
|
||||||
|
if t, ok := data["collected_at"].(string); ok {
|
||||||
|
b.WriteString(`<p style="font-size:12px;color:#64748b;margin-bottom:16px">Collected: ` + html.EscapeString(t) + `</p>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hardware section
|
||||||
|
hw, _ := data["hardware"].(map[string]any)
|
||||||
|
if hw == nil {
|
||||||
|
hw = data
|
||||||
|
}
|
||||||
|
|
||||||
|
renderHWCards(&b, hw)
|
||||||
|
|
||||||
|
// Full JSON below
|
||||||
|
b.WriteString(`<h2>Raw JSON</h2>`)
|
||||||
|
pretty, _ := json.MarshalIndent(data, "", " ")
|
||||||
|
b.WriteString(`<pre>` + html.EscapeString(string(pretty)) + `</pre>`)
|
||||||
|
b.WriteString(`</body></html>`)
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderHWCards(b *strings.Builder, hw map[string]any) {
|
||||||
|
sections := []struct{ key, label string }{
|
||||||
|
{"board", "Board"},
|
||||||
|
{"cpus", "CPUs"},
|
||||||
|
{"memory", "Memory"},
|
||||||
|
{"storage", "Storage"},
|
||||||
|
{"gpus", "GPUs"},
|
||||||
|
{"nics", "NICs"},
|
||||||
|
{"psus", "Power Supplies"},
|
||||||
|
}
|
||||||
|
for _, s := range sections {
|
||||||
|
v, ok := hw[s.key]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.WriteString(`<h2>` + s.label + `</h2><div class="grid">`)
|
||||||
|
renderValue(b, v)
|
||||||
|
b.WriteString(`</div>`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderValue(b *strings.Builder, v any) {
|
||||||
|
switch val := v.(type) {
|
||||||
|
case []any:
|
||||||
|
for _, item := range val {
|
||||||
|
renderValue(b, item)
|
||||||
|
}
|
||||||
|
case map[string]any:
|
||||||
|
b.WriteString(`<div class="card">`)
|
||||||
|
for k, vv := range val {
|
||||||
|
b.WriteString(fmt.Sprintf(`<div class="card-title">%s</div><div class="card-value">%s</div>`,
|
||||||
|
html.EscapeString(k), html.EscapeString(fmt.Sprintf("%v", vv))))
|
||||||
|
}
|
||||||
|
b.WriteString(`</div>`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Export index (compatibility) ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
func renderExportIndex(exportDir string) (string, error) {
|
||||||
|
entries, err := listExportFiles(exportDir)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
var body strings.Builder
|
||||||
|
body.WriteString(`<!DOCTYPE html><html><head><meta charset="utf-8"><title>Bee Export Files</title></head><body>`)
|
||||||
|
body.WriteString(`<h1>Bee Export Files</h1><ul>`)
|
||||||
|
for _, entry := range entries {
|
||||||
|
body.WriteString(`<li><a href="/export/file?path=` + url.QueryEscape(entry) + `">` + html.EscapeString(entry) + `</a></li>`)
|
||||||
|
}
|
||||||
|
if len(entries) == 0 {
|
||||||
|
body.WriteString(`<li>No export files found.</li>`)
|
||||||
|
}
|
||||||
|
body.WriteString(`</ul></body></html>`)
|
||||||
|
return body.String(), nil
|
||||||
|
}
|
||||||
@@ -1,49 +1,119 @@
|
|||||||
package webui
|
package webui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"bee/audit/internal/app"
|
"bee/audit/internal/app"
|
||||||
"reanimator/chart/viewer"
|
"bee/audit/internal/runtimeenv"
|
||||||
chartweb "reanimator/chart/web"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const defaultTitle = "Bee Hardware Audit"
|
const defaultTitle = "Bee Hardware Audit"
|
||||||
|
|
||||||
|
// HandlerOptions configures the web UI handler.
|
||||||
type HandlerOptions struct {
|
type HandlerOptions struct {
|
||||||
Title string
|
Title string
|
||||||
AuditPath string
|
AuditPath string
|
||||||
ExportDir string
|
ExportDir string
|
||||||
|
App *app.App
|
||||||
|
RuntimeMode runtimeenv.Mode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handler is the HTTP handler for the web UI.
|
||||||
|
type handler struct {
|
||||||
|
opts HandlerOptions
|
||||||
|
mux *http.ServeMux
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHandler creates the HTTP mux with all routes.
|
||||||
func NewHandler(opts HandlerOptions) http.Handler {
|
func NewHandler(opts HandlerOptions) http.Handler {
|
||||||
title := strings.TrimSpace(opts.Title)
|
if strings.TrimSpace(opts.Title) == "" {
|
||||||
if title == "" {
|
opts.Title = defaultTitle
|
||||||
title = defaultTitle
|
}
|
||||||
|
if strings.TrimSpace(opts.ExportDir) == "" {
|
||||||
|
opts.ExportDir = app.DefaultExportDir
|
||||||
|
}
|
||||||
|
if opts.RuntimeMode == "" {
|
||||||
|
opts.RuntimeMode = runtimeenv.ModeAuto
|
||||||
}
|
}
|
||||||
|
|
||||||
auditPath := strings.TrimSpace(opts.AuditPath)
|
h := &handler{opts: opts}
|
||||||
exportDir := strings.TrimSpace(opts.ExportDir)
|
|
||||||
if exportDir == "" {
|
|
||||||
exportDir = app.DefaultExportDir
|
|
||||||
}
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.Handle("GET /static/", http.StripPrefix("/static/", chartweb.Static()))
|
|
||||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
|
// ── Infrastructure ──────────────────────────────────────────────────────
|
||||||
|
mux.HandleFunc("GET /healthz", h.handleHealthz)
|
||||||
|
|
||||||
|
// ── Existing read-only endpoints (preserved for compatibility) ──────────
|
||||||
|
mux.HandleFunc("GET /audit.json", h.handleAuditJSON)
|
||||||
|
mux.HandleFunc("GET /runtime-health.json", h.handleRuntimeHealthJSON)
|
||||||
|
mux.HandleFunc("GET /export/support.tar.gz", h.handleSupportBundleDownload)
|
||||||
|
mux.HandleFunc("GET /export/file", h.handleExportFile)
|
||||||
|
mux.HandleFunc("GET /export/", h.handleExportIndex)
|
||||||
|
mux.HandleFunc("GET /viewer", h.handleViewer)
|
||||||
|
|
||||||
|
// ── API ──────────────────────────────────────────────────────────────────
|
||||||
|
// Audit
|
||||||
|
mux.HandleFunc("POST /api/audit/run", h.handleAPIAuditRun)
|
||||||
|
mux.HandleFunc("GET /api/audit/stream", h.handleAPIAuditStream)
|
||||||
|
|
||||||
|
// SAT
|
||||||
|
mux.HandleFunc("POST /api/sat/nvidia/run", h.handleAPISATRun("nvidia"))
|
||||||
|
mux.HandleFunc("POST /api/sat/memory/run", h.handleAPISATRun("memory"))
|
||||||
|
mux.HandleFunc("POST /api/sat/storage/run", h.handleAPISATRun("storage"))
|
||||||
|
mux.HandleFunc("POST /api/sat/cpu/run", h.handleAPISATRun("cpu"))
|
||||||
|
mux.HandleFunc("GET /api/sat/stream", h.handleAPISATStream)
|
||||||
|
|
||||||
|
// Services
|
||||||
|
mux.HandleFunc("GET /api/services", h.handleAPIServicesList)
|
||||||
|
mux.HandleFunc("POST /api/services/action", h.handleAPIServicesAction)
|
||||||
|
|
||||||
|
// Network
|
||||||
|
mux.HandleFunc("GET /api/network", h.handleAPINetworkStatus)
|
||||||
|
mux.HandleFunc("POST /api/network/dhcp", h.handleAPINetworkDHCP)
|
||||||
|
mux.HandleFunc("POST /api/network/static", h.handleAPINetworkStatic)
|
||||||
|
|
||||||
|
// Export
|
||||||
|
mux.HandleFunc("GET /api/export/list", h.handleAPIExportList)
|
||||||
|
mux.HandleFunc("POST /api/export/bundle", h.handleAPIExportBundle)
|
||||||
|
|
||||||
|
// Tools
|
||||||
|
mux.HandleFunc("GET /api/tools/check", h.handleAPIToolsCheck)
|
||||||
|
|
||||||
|
// Preflight
|
||||||
|
mux.HandleFunc("GET /api/preflight", h.handleAPIPreflight)
|
||||||
|
|
||||||
|
// Metrics — SSE stream of live sensor data
|
||||||
|
mux.HandleFunc("GET /api/metrics/stream", h.handleAPIMetricsStream)
|
||||||
|
|
||||||
|
// ── Pages ────────────────────────────────────────────────────────────────
|
||||||
|
mux.HandleFunc("GET /", h.handlePage)
|
||||||
|
|
||||||
|
h.mux = mux
|
||||||
|
return mux
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListenAndServe starts the HTTP server.
|
||||||
|
func ListenAndServe(addr string, opts HandlerOptions) error {
|
||||||
|
return http.ListenAndServe(addr, NewHandler(opts))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Infrastructure handlers ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (h *handler) handleHealthz(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
_, _ = w.Write([]byte("ok"))
|
_, _ = w.Write([]byte("ok"))
|
||||||
})
|
}
|
||||||
mux.HandleFunc("GET /audit.json", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
data, err := loadSnapshot(auditPath)
|
// ── Compatibility endpoints ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (h *handler) handleAuditJSON(w http.ResponseWriter, r *http.Request) {
|
||||||
|
data, err := loadSnapshot(h.opts.AuditPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
http.Error(w, "audit snapshot not found", http.StatusNotFound)
|
http.Error(w, "audit snapshot not found", http.StatusNotFound)
|
||||||
@@ -55,20 +125,10 @@ func NewHandler(opts HandlerOptions) http.Handler {
|
|||||||
w.Header().Set("Cache-Control", "no-store")
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
_, _ = w.Write(data)
|
_, _ = w.Write(data)
|
||||||
})
|
}
|
||||||
mux.HandleFunc("GET /export/support.tar.gz", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
archive, err := app.BuildSupportBundle(exportDir)
|
func (h *handler) handleRuntimeHealthJSON(w http.ResponseWriter, r *http.Request) {
|
||||||
if err != nil {
|
data, err := loadSnapshot(filepath.Join(h.opts.ExportDir, "runtime-health.json"))
|
||||||
http.Error(w, fmt.Sprintf("build support bundle: %v", err), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
|
||||||
w.Header().Set("Content-Type", "application/gzip")
|
|
||||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filepath.Base(archive)))
|
|
||||||
http.ServeFile(w, r, archive)
|
|
||||||
})
|
|
||||||
mux.HandleFunc("GET /runtime-health.json", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
data, err := loadSnapshot(filepath.Join(exportDir, "runtime-health.json"))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
http.Error(w, "runtime health not found", http.StatusNotFound)
|
http.Error(w, "runtime health not found", http.StatusNotFound)
|
||||||
@@ -80,18 +140,21 @@ func NewHandler(opts HandlerOptions) http.Handler {
|
|||||||
w.Header().Set("Cache-Control", "no-store")
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
_, _ = w.Write(data)
|
_, _ = w.Write(data)
|
||||||
})
|
}
|
||||||
mux.HandleFunc("GET /export/", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
body, err := renderExportIndex(exportDir)
|
func (h *handler) handleSupportBundleDownload(w http.ResponseWriter, r *http.Request) {
|
||||||
|
archive, err := app.BuildSupportBundle(h.opts.ExportDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, fmt.Sprintf("render export index: %v", err), http.StatusInternalServerError)
|
http.Error(w, fmt.Sprintf("build support bundle: %v", err), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "application/gzip")
|
||||||
_, _ = w.Write([]byte(body))
|
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filepath.Base(archive)))
|
||||||
})
|
http.ServeFile(w, r, archive)
|
||||||
mux.HandleFunc("GET /export/file", func(w http.ResponseWriter, r *http.Request) {
|
}
|
||||||
|
|
||||||
|
func (h *handler) handleExportFile(w http.ResponseWriter, r *http.Request) {
|
||||||
rel := strings.TrimSpace(r.URL.Query().Get("path"))
|
rel := strings.TrimSpace(r.URL.Query().Get("path"))
|
||||||
if rel == "" {
|
if rel == "" {
|
||||||
http.Error(w, "path is required", http.StatusBadRequest)
|
http.Error(w, "path is required", http.StatusBadRequest)
|
||||||
@@ -102,37 +165,43 @@ func NewHandler(opts HandlerOptions) http.Handler {
|
|||||||
http.Error(w, "invalid path", http.StatusBadRequest)
|
http.Error(w, "invalid path", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
http.ServeFile(w, r, filepath.Join(exportDir, clean))
|
http.ServeFile(w, r, filepath.Join(h.opts.ExportDir, clean))
|
||||||
})
|
}
|
||||||
mux.HandleFunc("GET /viewer", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
snapshot, err := loadSnapshot(auditPath)
|
func (h *handler) handleExportIndex(w http.ResponseWriter, r *http.Request) {
|
||||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
body, err := renderExportIndex(h.opts.ExportDir)
|
||||||
http.Error(w, fmt.Sprintf("read audit snapshot: %v", err), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
html, err := viewer.RenderHTML(snapshot, title)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, fmt.Sprintf("render snapshot: %v", err), http.StatusInternalServerError)
|
http.Error(w, fmt.Sprintf("render export index: %v", err), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
_, _ = w.Write(html)
|
|
||||||
})
|
|
||||||
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
noticeTitle, noticeBody := runtimeNotice(filepath.Join(exportDir, "runtime-health.json"))
|
|
||||||
body := renderShellPage(title, noticeTitle, noticeBody)
|
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
_, _ = w.Write([]byte(body))
|
_, _ = w.Write([]byte(body))
|
||||||
})
|
|
||||||
return mux
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func ListenAndServe(addr string, opts HandlerOptions) error {
|
func (h *handler) handleViewer(w http.ResponseWriter, r *http.Request) {
|
||||||
return http.ListenAndServe(addr, NewHandler(opts))
|
snapshot, _ := loadSnapshot(h.opts.AuditPath)
|
||||||
|
body := renderViewerPage(h.opts.Title, snapshot)
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
_, _ = w.Write([]byte(body))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Page handler ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (h *handler) handlePage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
page := strings.TrimPrefix(r.URL.Path, "/")
|
||||||
|
if page == "" {
|
||||||
|
page = "dashboard"
|
||||||
|
}
|
||||||
|
body := renderPage(page, h.opts)
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
_, _ = w.Write([]byte(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func loadSnapshot(path string) ([]byte, error) {
|
func loadSnapshot(path string) ([]byte, error) {
|
||||||
if strings.TrimSpace(path) == "" {
|
if strings.TrimSpace(path) == "" {
|
||||||
return nil, os.ErrNotExist
|
return nil, os.ErrNotExist
|
||||||
@@ -140,101 +209,17 @@ func loadSnapshot(path string) ([]byte, error) {
|
|||||||
return os.ReadFile(path)
|
return os.ReadFile(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
func runtimeNotice(path string) (string, string) {
|
// writeJSON sends v as JSON with status 200.
|
||||||
health, err := app.ReadRuntimeHealth(path)
|
func writeJSON(w http.ResponseWriter, v any) {
|
||||||
if err != nil {
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
return "Runtime Health", "No runtime health snapshot found yet."
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
}
|
_ = json.NewEncoder(w).Encode(v)
|
||||||
body := fmt.Sprintf("Status: %s. Export dir: %s. Driver ready: %t. CUDA ready: %t. Network: %s. Export files: /export/",
|
|
||||||
firstNonEmpty(health.Status, "UNKNOWN"),
|
|
||||||
firstNonEmpty(health.ExportDir, app.DefaultExportDir),
|
|
||||||
health.DriverReady,
|
|
||||||
health.CUDAReady,
|
|
||||||
firstNonEmpty(health.NetworkStatus, "UNKNOWN"),
|
|
||||||
)
|
|
||||||
if len(health.Issues) > 0 {
|
|
||||||
body += " Issues: "
|
|
||||||
parts := make([]string, 0, len(health.Issues))
|
|
||||||
for _, issue := range health.Issues {
|
|
||||||
parts = append(parts, issue.Code)
|
|
||||||
}
|
|
||||||
body += strings.Join(parts, ", ")
|
|
||||||
}
|
|
||||||
return "Runtime Health", body
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderExportIndex(exportDir string) (string, error) {
|
// writeError sends a JSON error response.
|
||||||
var entries []string
|
func writeError(w http.ResponseWriter, status int, msg string) {
|
||||||
err := filepath.Walk(strings.TrimSpace(exportDir), func(path string, info os.FileInfo, err error) error {
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
if err != nil {
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
return err
|
w.WriteHeader(status)
|
||||||
}
|
_ = json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
||||||
if info.IsDir() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
rel, err := filepath.Rel(exportDir, path)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
entries = append(entries, rel)
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
sort.Strings(entries)
|
|
||||||
var body strings.Builder
|
|
||||||
body.WriteString("<!DOCTYPE html><html><head><meta charset=\"utf-8\"><title>Bee Export Files</title></head><body>")
|
|
||||||
body.WriteString("<h1>Bee Export Files</h1><ul>")
|
|
||||||
for _, entry := range entries {
|
|
||||||
body.WriteString("<li><a href=\"/export/file?path=" + url.QueryEscape(entry) + "\">" + html.EscapeString(entry) + "</a></li>")
|
|
||||||
}
|
|
||||||
if len(entries) == 0 {
|
|
||||||
body.WriteString("<li>No export files found.</li>")
|
|
||||||
}
|
|
||||||
body.WriteString("</ul></body></html>")
|
|
||||||
return body.String(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func renderShellPage(title, noticeTitle, noticeBody string) string {
|
|
||||||
var body strings.Builder
|
|
||||||
body.WriteString("<!DOCTYPE html><html><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">")
|
|
||||||
body.WriteString("<title>" + html.EscapeString(title) + "</title>")
|
|
||||||
body.WriteString(`<style>
|
|
||||||
body{margin:0;font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:#f4f1ea;color:#1b1b18}
|
|
||||||
.shell{min-height:100vh;display:grid;grid-template-rows:auto auto 1fr}
|
|
||||||
.header{padding:18px 20px 12px;border-bottom:1px solid rgba(0,0,0,.08);background:#fbf8f2}
|
|
||||||
.header h1{margin:0;font-size:24px}
|
|
||||||
.header p{margin:6px 0 0;color:#5a5a52}
|
|
||||||
.actions{display:flex;flex-wrap:wrap;gap:10px;padding:12px 20px;background:#fbf8f2}
|
|
||||||
.actions a{display:inline-block;text-decoration:none;padding:10px 14px;border-radius:999px;background:#1f5f4a;color:#fff;font-weight:600}
|
|
||||||
.actions a.secondary{background:#d8e5dd;color:#17372b}
|
|
||||||
.notice{margin:16px 20px 0;padding:14px 16px;border-radius:14px;background:#fff7df;border:1px solid #ead9a4}
|
|
||||||
.notice h2{margin:0 0 6px;font-size:16px}
|
|
||||||
.notice p{margin:0;color:#4f4a37}
|
|
||||||
.viewer-wrap{padding:16px 20px 20px}
|
|
||||||
.viewer{width:100%;height:calc(100vh - 170px);border:0;border-radius:18px;background:#fff;box-shadow:0 12px 40px rgba(0,0,0,.08)}
|
|
||||||
@media (max-width:720px){.viewer{height:calc(100vh - 240px)}}
|
|
||||||
</style></head><body><div class="shell">`)
|
|
||||||
body.WriteString("<header class=\"header\"><h1>" + html.EscapeString(title) + "</h1><p>Audit viewer with support bundle and raw export access.</p></header>")
|
|
||||||
body.WriteString("<nav class=\"actions\">")
|
|
||||||
body.WriteString("<a href=\"/export/support.tar.gz\">Download support bundle</a>")
|
|
||||||
body.WriteString("<a class=\"secondary\" href=\"/audit.json\">Open audit.json</a>")
|
|
||||||
body.WriteString("<a class=\"secondary\" href=\"/runtime-health.json\">Open runtime-health.json</a>")
|
|
||||||
body.WriteString("<a class=\"secondary\" href=\"/export/\">Browse export files</a>")
|
|
||||||
body.WriteString("</nav>")
|
|
||||||
if strings.TrimSpace(noticeTitle) != "" {
|
|
||||||
body.WriteString("<section class=\"notice\"><h2>" + html.EscapeString(noticeTitle) + "</h2><p>" + html.EscapeString(noticeBody) + "</p></section>")
|
|
||||||
}
|
|
||||||
body.WriteString("<main class=\"viewer-wrap\"><iframe class=\"viewer\" src=\"/viewer\" loading=\"eager\" referrerpolicy=\"same-origin\"></iframe></main>")
|
|
||||||
body.WriteString("</div></body></html>")
|
|
||||||
return body.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func firstNonEmpty(value, fallback string) string {
|
|
||||||
value = strings.TrimSpace(value)
|
|
||||||
if value == "" {
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ local-fs.target
|
|||||||
│ creates /dev/nvidia* nodes)
|
│ creates /dev/nvidia* nodes)
|
||||||
├── bee-audit.service (runs `bee audit` → /var/log/bee-audit.json,
|
├── bee-audit.service (runs `bee audit` → /var/log/bee-audit.json,
|
||||||
│ never blocks boot on partial collector failures)
|
│ never blocks boot on partial collector failures)
|
||||||
└── bee-web.service (runs `bee web` on :80,
|
├── bee-web.service (runs `bee web` on :80 — full interactive web UI)
|
||||||
reads the latest audit snapshot on each request)
|
└── bee-desktop.service (startx → openbox + chromium http://localhost/)
|
||||||
```
|
```
|
||||||
|
|
||||||
**Critical invariants:**
|
**Critical invariants:**
|
||||||
@@ -44,17 +44,21 @@ Local-console behavior:
|
|||||||
```text
|
```text
|
||||||
tty1
|
tty1
|
||||||
└── live-config autologin → bee
|
└── live-config autologin → bee
|
||||||
└── /home/bee/.profile
|
└── /home/bee/.profile (prints web UI URLs)
|
||||||
└── exec menu
|
|
||||||
└── /usr/local/bin/bee-tui
|
display :0
|
||||||
└── sudo -n /usr/local/bin/bee tui --runtime livecd
|
└── bee-desktop.service (User=bee)
|
||||||
|
└── startx /usr/local/bin/bee-openbox-session -- :0
|
||||||
|
├── tint2 (taskbar)
|
||||||
|
├── chromium http://localhost/
|
||||||
|
└── openbox (WM)
|
||||||
```
|
```
|
||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
- local `tty1` lands in user `bee`, not directly in `root`
|
- local `tty1` lands in user `bee`, not directly in `root`
|
||||||
- `menu` must work without typing `sudo`
|
- `bee-desktop.service` starts X11 + openbox + Chromium automatically after `bee-web.service`
|
||||||
- TUI actions still run as `root` via `sudo -n`
|
- Chromium opens `http://localhost/` — the full interactive web UI
|
||||||
- SSH is independent from the tty1 path
|
- SSH is independent from the desktop path
|
||||||
- serial console support is enabled for VM boot debugging
|
- serial console support is enabled for VM boot debugging
|
||||||
|
|
||||||
## ISO build sequence
|
## ISO build sequence
|
||||||
|
|||||||
@@ -26,9 +26,9 @@ Fills gaps where Redfish/logpile is blind:
|
|||||||
- Automatic boot audit with operator-facing local console and SSH access
|
- Automatic boot audit with operator-facing local console and SSH access
|
||||||
- NVIDIA proprietary driver loaded at boot for GPU enrichment via `nvidia-smi`
|
- NVIDIA proprietary driver loaded at boot for GPU enrichment via `nvidia-smi`
|
||||||
- SSH access (OpenSSH) always available for inspection and debugging
|
- SSH access (OpenSSH) always available for inspection and debugging
|
||||||
- Interactive Go TUI via `bee tui` for network setup, service management, and acceptance tests
|
- Full web UI via `bee web` on port 80: interactive control panel with live metrics, SAT tests, network config, service management, export, and tools
|
||||||
- Read-only web viewer via `bee web`, rendering the latest audit snapshot through the embedded Reanimator Chart
|
- Local operator desktop: openbox + Xorg + Chromium auto-opening `http://localhost/`
|
||||||
- Local `tty1` operator UX: `bee` autologin, `menu` auto-start, privileged actions via `sudo -n`
|
- Local `tty1` operator UX: `bee` autologin, openbox desktop auto-starts with Chromium on `http://localhost/`
|
||||||
|
|
||||||
## Network isolation — CRITICAL
|
## Network isolation — CRITICAL
|
||||||
|
|
||||||
@@ -76,9 +76,10 @@ Fills gaps where Redfish/logpile is blind:
|
|||||||
## Operator UX
|
## Operator UX
|
||||||
|
|
||||||
- On the live ISO, `tty1` autologins as `bee`
|
- On the live ISO, `tty1` autologins as `bee`
|
||||||
- The login profile auto-runs `menu`, which enters the Go TUI
|
- `bee-desktop.service` starts X11 + openbox + Chromium on display `:0`
|
||||||
- The TUI itself executes privileged actions as `root` via `sudo -n`
|
- Chromium opens `http://localhost/` — the full web UI
|
||||||
- SSH remains available independently of the local console path
|
- SSH remains available independently of the local console path
|
||||||
|
- Remote operators can open `http://<ip>/` in any browser on the same LAN
|
||||||
- VM-oriented builds also include `qemu-guest-agent` and serial console support for debugging
|
- VM-oriented builds also include `qemu-guest-agent` and serial console support for debugging
|
||||||
- The ISO boots with `toram`, so loss of the original USB/BMC virtual media after boot should not break already-installed runtime binaries
|
- The ISO boots with `toram`, so loss of the original USB/BMC virtual media after boot should not break already-installed runtime binaries
|
||||||
|
|
||||||
@@ -103,7 +104,10 @@ Fills gaps where Redfish/logpile is blind:
|
|||||||
| `internal/chart/` | Git submodule with `reanimator/chart`, embedded into `bee web` |
|
| `internal/chart/` | Git submodule with `reanimator/chart`, embedded into `bee web` |
|
||||||
| `iso/builder/VERSIONS` | Pinned versions: Debian, Go, NVIDIA driver, kernel ABI |
|
| `iso/builder/VERSIONS` | Pinned versions: Debian, Go, NVIDIA driver, kernel ABI |
|
||||||
| `iso/builder/smoketest.sh` | Post-boot smoke test — run via SSH to verify live ISO |
|
| `iso/builder/smoketest.sh` | Post-boot smoke test — run via SSH to verify live ISO |
|
||||||
| `iso/overlay/etc/profile.d/bee.sh` | `menu` helper + tty1 auto-start policy |
|
| `iso/overlay/etc/profile.d/bee.sh` | tty1 welcome message with web UI URLs |
|
||||||
| `iso/overlay/home/bee/.profile` | `bee` shell profile for local console startup |
|
| `iso/overlay/home/bee/.profile` | `bee` shell profile (PATH only) |
|
||||||
|
| `iso/overlay/etc/systemd/system/bee-desktop.service` | starts X11 + openbox + chromium |
|
||||||
|
| `iso/overlay/usr/local/bin/bee-desktop` | startx wrapper for bee-desktop.service |
|
||||||
|
| `iso/overlay/usr/local/bin/bee-openbox-session` | xinitrc: tint2 + chromium + openbox |
|
||||||
| `dist/` | Build outputs (gitignored) |
|
| `dist/` | Build outputs (gitignored) |
|
||||||
| `iso/out/` | Downloaded ISO files (gitignored) |
|
| `iso/out/` | Downloaded ISO files (gitignored) |
|
||||||
|
|||||||
@@ -48,6 +48,14 @@ stress-ng
|
|||||||
# QR codes (for displaying audit results)
|
# QR codes (for displaying audit results)
|
||||||
qrencode
|
qrencode
|
||||||
|
|
||||||
|
# Local desktop (openbox + chromium kiosk)
|
||||||
|
openbox
|
||||||
|
tint2
|
||||||
|
xorg
|
||||||
|
xinit
|
||||||
|
xterm
|
||||||
|
chromium
|
||||||
|
|
||||||
# Firmware
|
# Firmware
|
||||||
firmware-linux-free
|
firmware-linux-free
|
||||||
firmware-amd-graphics
|
firmware-amd-graphics
|
||||||
|
|||||||
@@ -1,21 +1,18 @@
|
|||||||
export PATH="$PATH:/usr/local/bin:/opt/rocm/bin:/opt/rocm/sbin"
|
export PATH="$PATH:/usr/local/bin:/opt/rocm/bin:/opt/rocm/sbin"
|
||||||
|
|
||||||
menu() {
|
# Print web UI URLs on the local console at login.
|
||||||
if [ -x /usr/local/bin/bee-tui ]; then
|
|
||||||
/usr/local/bin/bee-tui "$@"
|
|
||||||
else
|
|
||||||
echo "bee-tui is not installed"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# On the local console, keep the shell visible and let the operator
|
|
||||||
# start the TUI explicitly. This avoids black-screen failures if the
|
|
||||||
# terminal implementation does not support the TUI well.
|
|
||||||
if [ -z "${SSH_CONNECTION:-}" ] \
|
if [ -z "${SSH_CONNECTION:-}" ] \
|
||||||
&& [ -z "${SSH_TTY:-}" ] \
|
&& [ -z "${SSH_TTY:-}" ]; then
|
||||||
&& [ "$(tty 2>/dev/null)" = "/dev/tty1" ]; then
|
|
||||||
echo "Bee live environment ready."
|
echo "Bee live environment ready."
|
||||||
echo "Run 'menu' to open the TUI."
|
echo ""
|
||||||
echo "Kernel logs: Alt+F2 | Extra shell: Alt+F3"
|
echo " Web UI (local): http://localhost/"
|
||||||
|
# Print IP addresses for remote access
|
||||||
|
_ips=$(ip -4 addr show scope global 2>/dev/null | awk '/inet /{print $2}' | cut -d/ -f1)
|
||||||
|
for _ip in $_ips; do
|
||||||
|
echo " Web UI (remote): http://$_ip/"
|
||||||
|
done
|
||||||
|
unset _ips _ip
|
||||||
|
echo ""
|
||||||
|
echo " Local desktop starts automatically on display :0"
|
||||||
|
echo " Kernel logs: Alt+F2 | Extra shell: Alt+F3"
|
||||||
fi
|
fi
|
||||||
|
|||||||
16
iso/overlay/etc/systemd/system/bee-desktop.service
Normal file
16
iso/overlay/etc/systemd/system/bee-desktop.service
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Bee: local desktop (openbox + chromium)
|
||||||
|
After=bee-web.service
|
||||||
|
Wants=bee-web.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
User=bee
|
||||||
|
Environment=DISPLAY=:0
|
||||||
|
ExecStart=/usr/local/bin/bee-desktop
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=3
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -1,13 +1 @@
|
|||||||
export PATH="/usr/local/bin:$PATH"
|
export PATH="/usr/local/bin:$PATH"
|
||||||
|
|
||||||
if [ -z "${SSH_CONNECTION:-}" ] \
|
|
||||||
&& [ -z "${SSH_TTY:-}" ] \
|
|
||||||
&& [ "$(tty 2>/dev/null)" = "/dev/tty1" ]; then
|
|
||||||
if command -v menu >/dev/null 2>&1; then
|
|
||||||
menu
|
|
||||||
elif [ -x /usr/local/bin/bee-tui ]; then
|
|
||||||
/usr/local/bin/bee-tui
|
|
||||||
else
|
|
||||||
echo "Bee menu is unavailable."
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|||||||
4
iso/overlay/usr/local/bin/bee-desktop
Executable file
4
iso/overlay/usr/local/bin/bee-desktop
Executable file
@@ -0,0 +1,4 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Start X11 + openbox + chromium for the local operator console.
|
||||||
|
# Runs as the bee user on display :0.
|
||||||
|
exec startx /usr/local/bin/bee-openbox-session -- :0 -nolisten tcp
|
||||||
25
iso/overlay/usr/local/bin/bee-openbox-session
Executable file
25
iso/overlay/usr/local/bin/bee-openbox-session
Executable file
@@ -0,0 +1,25 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# openbox session: launch tint2 taskbar + chromium, then openbox as WM.
|
||||||
|
# This file is used as an xinitrc by bee-desktop.
|
||||||
|
|
||||||
|
# Wait for bee-web to be accepting connections (up to 15 seconds)
|
||||||
|
i=0
|
||||||
|
while [ $i -lt 15 ]; do
|
||||||
|
if curl -sf http://localhost/healthz >/dev/null 2>&1; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
i=$((i+1))
|
||||||
|
done
|
||||||
|
|
||||||
|
tint2 &
|
||||||
|
chromium \
|
||||||
|
--no-sandbox \
|
||||||
|
--disable-infobars \
|
||||||
|
--disable-translate \
|
||||||
|
--no-first-run \
|
||||||
|
--disable-session-crashed-bubble \
|
||||||
|
--disable-features=TranslateUI \
|
||||||
|
http://localhost/ &
|
||||||
|
|
||||||
|
exec openbox
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
|
|
||||||
clear
|
|
||||||
|
|
||||||
if [ "$(id -u)" -ne 0 ]; then
|
|
||||||
exec sudo -n /usr/local/bin/bee tui --runtime livecd "$@"
|
|
||||||
fi
|
|
||||||
|
|
||||||
exec /usr/local/bin/bee tui --runtime livecd "$@"
|
|
||||||
Reference in New Issue
Block a user