Copies the live system to a local disk via unsquashfs — no debootstrap, no network required. Supports UEFI (GPT+EFI) and BIOS (MBR) layouts. ISO: - Add squashfs-tools, parted, grub-pc, grub-efi-amd64 to package list - New overlay script bee-install: partitions, formats, unsquashfs, writes fstab, runs grub-install+update-grub in chroot Go TUI: - Settings → Tools submenu (Install to disk, Check tools) - Disk picker screen: lists non-USB, non-boot disks via lsblk - Confirm screen warns about data loss - Runs with live progress tail of /tmp/bee-install.log - platform/install.go: ListInstallDisks, InstallToDisk, findLiveBootDevice Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
308 lines
7.9 KiB
Go
308 lines
7.9 KiB
Go
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
|
|
}
|