banger/internal/cli/tui.go
Thales Maciel fcedacba5c
Make runtime defaults portable
Stop assuming one workstation layout for runtime artifacts, mapdns, and host tooling. The daemon and shell helpers now use portable mapdns configuration, and runtime bundles can carry bundle.json metadata for their default kernel, initrd, modules, rootfs, and helper paths.

Load bundle metadata through config with a legacy layout fallback, thread mapdns_bin/mapdns_data_file through the Go and shell paths, and add command-scoped preflight checks for VM start, NAT, image build, work-disk resize, and SSH so missing tools or artifacts fail with actionable errors.

Update the runtime-bundle manifest, docs, and tests to match the new model. Verified with go test ./..., make build, and bash -n customize.sh interactive.sh dns.sh make-rootfs.sh verify.sh.
2026-03-16 15:30:08 -03:00

1388 lines
36 KiB
Go

package cli
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
"banger/internal/api"
"banger/internal/model"
"banger/internal/paths"
"banger/internal/rpc"
"banger/internal/system"
"github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/table"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/mattn/go-isatty"
"github.com/spf13/cobra"
)
const tuiRefreshInterval = 3 * time.Second
type tuiMode int
const (
tuiModeBrowse tuiMode = iota
tuiModeForm
tuiModeConfirmDelete
)
type actionKind string
const (
actionCreate actionKind = "create"
actionEdit actionKind = "edit"
actionStart actionKind = "start"
actionStop actionKind = "stop"
actionRestart actionKind = "restart"
actionDelete actionKind = "delete"
actionSSH actionKind = "ssh"
actionLogs actionKind = "logs"
)
type dataLoadedMsg struct {
vms []model.VMRecord
images []model.Image
focusID string
err error
}
type statsLoadedMsg struct {
id string
stats model.VMStats
err error
}
type actionResultMsg struct {
action actionRequest
focusID string
status string
err error
refresh bool
}
type externalPreparedMsg struct {
action actionRequest
command *exec.Cmd
doneStatus string
refresh bool
err error
}
type sudoValidatedMsg struct {
err error
}
type refreshTickMsg struct{}
type actionRequest struct {
kind actionKind
id string
name string
create api.VMCreateParams
set api.VMSetParams
}
type browseKeyMap struct {
refresh key.Binding
create key.Binding
edit key.Binding
start key.Binding
stop key.Binding
restart key.Binding
delete key.Binding
ssh key.Binding
logs key.Binding
help key.Binding
quit key.Binding
}
func newBrowseKeyMap() browseKeyMap {
return browseKeyMap{
refresh: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "refresh")),
create: key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "create")),
edit: key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "edit")),
start: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "start")),
stop: key.NewBinding(key.WithKeys("x"), key.WithHelp("x", "stop")),
restart: key.NewBinding(key.WithKeys("R"), key.WithHelp("R", "restart")),
delete: key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "delete")),
ssh: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "ssh")),
logs: key.NewBinding(key.WithKeys("l"), key.WithHelp("l", "logs")),
help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")),
quit: key.NewBinding(key.WithKeys("q"), key.WithHelp("q", "quit")),
}
}
func (k browseKeyMap) ShortHelp() []key.Binding {
return []key.Binding{k.refresh, k.create, k.edit, k.start, k.stop, k.delete, k.ssh, k.logs, k.quit}
}
func (k browseKeyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{
{k.refresh, k.create, k.edit, k.start, k.stop, k.restart, k.delete},
{k.ssh, k.logs, k.help, k.quit},
}
}
type formKeyMap struct {
next key.Binding
prev key.Binding
change key.Binding
toggle key.Binding
submit key.Binding
cancel key.Binding
}
func newFormKeyMap() formKeyMap {
return formKeyMap{
next: key.NewBinding(key.WithKeys("tab", "down"), key.WithHelp("tab", "next")),
prev: key.NewBinding(key.WithKeys("shift+tab", "up"), key.WithHelp("shift+tab", "prev")),
change: key.NewBinding(key.WithKeys("left", "right"), key.WithHelp("left/right", "change")),
toggle: key.NewBinding(key.WithKeys(" "), key.WithHelp("space", "toggle")),
submit: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "save")),
cancel: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "cancel")),
}
}
func (k formKeyMap) ShortHelp() []key.Binding {
return []key.Binding{k.next, k.change, k.toggle, k.submit, k.cancel}
}
func (k formKeyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{{k.next, k.prev, k.change}, {k.toggle, k.submit, k.cancel}}
}
type confirmKeyMap struct {
confirm key.Binding
cancel key.Binding
}
func newConfirmKeyMap() confirmKeyMap {
return confirmKeyMap{
confirm: key.NewBinding(key.WithKeys("enter", "y"), key.WithHelp("enter", "confirm")),
cancel: key.NewBinding(key.WithKeys("esc", "n"), key.WithHelp("esc", "cancel")),
}
}
func (k confirmKeyMap) ShortHelp() []key.Binding {
return []key.Binding{k.confirm, k.cancel}
}
func (k confirmKeyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{{k.confirm, k.cancel}}
}
type formFieldKind int
const (
formFieldText formFieldKind = iota
formFieldSelect
)
type formField struct {
label string
kind formFieldKind
input textinput.Model
options []string
index int
}
func newTextField(label, value string) formField {
input := textinput.New()
input.Prompt = ""
input.SetValue(value)
input.CharLimit = 128
return formField{
label: label,
kind: formFieldText,
input: input,
}
}
func newSelectField(label string, options []string, index int) formField {
if len(options) == 0 {
options = []string{"<none>"}
index = 0
}
if index < 0 || index >= len(options) {
index = 0
}
return formField{
label: label,
kind: formFieldSelect,
options: options,
index: index,
}
}
func (f formField) value() string {
if f.kind == formFieldText {
return f.input.Value()
}
if len(f.options) == 0 {
return ""
}
return f.options[f.index]
}
type vmForm struct {
mode actionKind
title string
submitLabel string
targetID string
fields []formField
focus int
}
func newCreateVMForm(images []model.Image, cfg model.DaemonConfig) *vmForm {
imageOptions := imageNames(images)
selectedImage := 0
if cfg.DefaultImageName != "" {
for i, name := range imageOptions {
if name == cfg.DefaultImageName {
selectedImage = i
break
}
}
}
form := &vmForm{
mode: actionCreate,
title: "Create VM",
submitLabel: "Create",
fields: []formField{
newTextField("Name", ""),
newSelectField("Image", imageOptions, selectedImage),
newTextField("VCPU", strconv.Itoa(model.DefaultVCPUCount)),
newTextField("Memory (MiB)", strconv.Itoa(model.DefaultMemoryMiB)),
newTextField("System Overlay", model.FormatSizeBytes(model.DefaultSystemOverlaySize)),
newTextField("Work Disk", model.FormatSizeBytes(model.DefaultWorkDiskSize)),
newSelectField("NAT Enabled", []string{"no", "yes"}, 0),
newSelectField("No Start", []string{"no", "yes"}, 0),
},
}
form.focusField(0)
return form
}
func newEditVMForm(vm model.VMRecord) *vmForm {
form := &vmForm{
mode: actionEdit,
title: "Edit VM",
submitLabel: "Save",
targetID: vm.ID,
fields: []formField{
newTextField("VCPU", strconv.Itoa(vm.Spec.VCPUCount)),
newTextField("Memory (MiB)", strconv.Itoa(vm.Spec.MemoryMiB)),
newTextField("Work Disk", model.FormatSizeBytes(vm.Spec.WorkDiskSizeBytes)),
newSelectField("NAT Enabled", []string{"no", "yes"}, boolToIndex(vm.Spec.NATEnabled)),
},
}
form.focusField(0)
return form
}
func (f *vmForm) focusField(index int) tea.Cmd {
if len(f.fields) == 0 {
f.focus = 0
return nil
}
if f.focus >= 0 && f.focus < len(f.fields) && f.fields[f.focus].kind == formFieldText {
f.fields[f.focus].input.Blur()
}
f.focus = wrapIndex(index, len(f.fields))
if f.fields[f.focus].kind == formFieldText {
return f.fields[f.focus].input.Focus()
}
return nil
}
func (f *vmForm) move(delta int) tea.Cmd {
return f.focusField(f.focus + delta)
}
func (f *vmForm) setWidth(width int) {
inputWidth := maxInt(12, width-22)
for i := range f.fields {
if f.fields[i].kind == formFieldText {
f.fields[i].input.Width = inputWidth
}
}
}
func (f *vmForm) update(msg tea.Msg) tea.Cmd {
if len(f.fields) == 0 {
return nil
}
if f.fields[f.focus].kind != formFieldText {
return nil
}
var cmd tea.Cmd
f.fields[f.focus].input, cmd = f.fields[f.focus].input.Update(msg)
return cmd
}
func (f *vmForm) change(delta int) {
if len(f.fields) == 0 {
return
}
field := &f.fields[f.focus]
if field.kind != formFieldSelect || len(field.options) == 0 {
return
}
field.index = wrapIndex(field.index+delta, len(field.options))
}
func (f *vmForm) view(width int) string {
f.setWidth(width)
titleStyle := lipgloss.NewStyle().Bold(true)
labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("245"))
activeStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("212")).Bold(true)
valueStyle := lipgloss.NewStyle().Bold(true)
lines := []string{titleStyle.Render(f.title), ""}
for i := range f.fields {
marker := " "
lbl := labelStyle.Render(f.fields[i].label)
if i == f.focus {
marker = "> "
lbl = activeStyle.Render(f.fields[i].label)
}
value := ""
if f.fields[i].kind == formFieldText {
value = f.fields[i].input.View()
} else {
value = valueStyle.Render(f.fields[i].value())
}
lines = append(lines, fmt.Sprintf("%s%-16s %s", marker, lbl, value))
}
lines = append(lines, "", fmt.Sprintf("Enter %s Esc cancel", strings.ToLower(f.submitLabel)))
style := lipgloss.NewStyle().
Border(lipgloss.NormalBorder()).
Padding(1, 2).
Width(maxInt(36, width))
return style.Render(strings.Join(lines, "\n"))
}
func (f *vmForm) submit() (actionRequest, error) {
switch f.mode {
case actionCreate:
return f.createRequest()
case actionEdit:
return f.editRequest()
default:
return actionRequest{}, errors.New("unsupported form mode")
}
}
func (f *vmForm) createRequest() (actionRequest, error) {
vcpu, err := parsePositiveInt("vcpu", f.fields[2].value())
if err != nil {
return actionRequest{}, err
}
memory, err := parsePositiveInt("memory", f.fields[3].value())
if err != nil {
return actionRequest{}, err
}
params := api.VMCreateParams{
Name: strings.TrimSpace(f.fields[0].value()),
ImageName: strings.TrimSpace(f.fields[1].value()),
VCPUCount: vcpu,
MemoryMiB: memory,
SystemOverlaySize: strings.TrimSpace(f.fields[4].value()),
WorkDiskSize: strings.TrimSpace(f.fields[5].value()),
NATEnabled: isYes(f.fields[6].value()),
NoStart: isYes(f.fields[7].value()),
}
if params.ImageName == "" || params.ImageName == "<none>" {
return actionRequest{}, errors.New("create requires an image")
}
return actionRequest{kind: actionCreate, create: params}, nil
}
func (f *vmForm) editRequest() (actionRequest, error) {
vcpu, err := parsePositiveInt("vcpu", f.fields[0].value())
if err != nil {
return actionRequest{}, err
}
memory, err := parsePositiveInt("memory", f.fields[1].value())
if err != nil {
return actionRequest{}, err
}
params, err := vmSetParamsFromFlags(
f.targetID,
vcpu,
memory,
strings.TrimSpace(f.fields[2].value()),
isYes(f.fields[3].value()),
false,
)
if err != nil {
return actionRequest{}, err
}
return actionRequest{
kind: actionEdit,
id: f.targetID,
set: params,
}, nil
}
type tuiModel struct {
layout paths.Layout
cfg model.DaemonConfig
width int
height int
ready bool
loading bool
busy string
sudoValidated bool
mode tuiMode
form *vmForm
pendingAction *actionRequest
vms []model.VMRecord
images []model.Image
selectedID string
selectedStats model.VMStats
statsID string
statsErr string
table table.Model
detail viewport.Model
help help.Model
spinner spinner.Model
browseKeys browseKeyMap
formKeys formKeyMap
confirmKeys confirmKeyMap
lastRefresh time.Time
statusText string
statusErr bool
}
func newTUIModel(layout paths.Layout, cfg model.DaemonConfig) tuiModel {
vmTable := table.New(
table.WithColumns([]table.Column{
{Title: "NAME", Width: 18},
{Title: "STATE", Width: 9},
{Title: "IP", Width: 14},
{Title: "VCPU", Width: 4},
{Title: "MEM", Width: 8},
{Title: "DISK", Width: 8},
{Title: "AGE", Width: 12},
}),
table.WithRows(nil),
table.WithFocused(true),
table.WithHeight(10),
table.WithWidth(60),
table.WithKeyMap(tuiTableKeyMap()),
)
tableStyles := table.DefaultStyles()
tableStyles.Header = lipgloss.NewStyle().Bold(true).Padding(0, 1)
tableStyles.Cell = lipgloss.NewStyle().Padding(0, 1)
tableStyles.Selected = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("230")).Background(lipgloss.Color("62"))
vmTable.SetStyles(tableStyles)
detail := viewport.New(0, 0)
detail.Style = lipgloss.NewStyle()
spin := spinner.New(spinner.WithSpinner(spinner.Line))
spin.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("69"))
helpView := help.New()
return tuiModel{
layout: layout,
cfg: cfg,
table: vmTable,
detail: detail,
help: helpView,
spinner: spin,
browseKeys: newBrowseKeyMap(),
formKeys: newFormKeyMap(),
confirmKeys: newConfirmKeyMap(),
loading: true,
statusText: "Loading VMs...",
}
}
func newTUICommand() *cobra.Command {
return &cobra.Command{
Use: "tui",
Short: "Launch a terminal UI to manage VMs",
Args: noArgsUsage("usage: banger tui"),
RunE: func(cmd *cobra.Command, args []string) error {
if !isatty.IsTerminal(os.Stdin.Fd()) || !isatty.IsTerminal(os.Stdout.Fd()) {
return errors.New("tui requires an interactive terminal")
}
layout, cfg, err := ensureDaemon(cmd.Context())
if err != nil {
return err
}
program := tea.NewProgram(newTUIModel(layout, cfg), tea.WithAltScreen())
return program.Start()
},
}
}
func (m tuiModel) Init() tea.Cmd {
return tea.Batch(m.spinner.Tick, refreshTickCmd(), fetchDataCmd(m.layout, ""))
}
func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
if m.loading || m.busy != "" {
var spinCmd tea.Cmd
m.spinner, spinCmd = m.spinner.Update(msg)
cmds = append(cmds, spinCmd)
}
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.ready = true
m.width = msg.Width
m.height = msg.Height
m.resize()
case dataLoadedMsg:
m.loading = false
if msg.err != nil {
m.setStatus(msg.err.Error(), true)
break
}
m.images = msg.images
m.vms = msg.vms
targetID := m.selectedID
if msg.focusID != "" {
targetID = msg.focusID
}
m.selectedID = resolveSelectedID(targetID, msg.vms)
m.lastRefresh = time.Now()
m.rebuildTable()
m.setStatus(fmt.Sprintf("Loaded %d VM(s)", len(msg.vms)), false)
if m.selectedID != "" {
cmds = append(cmds, fetchStatsCmd(m.layout, m.selectedID))
} else {
m.selectedStats = model.VMStats{}
m.statsID = ""
m.statsErr = ""
}
case statsLoadedMsg:
if msg.id != m.selectedID {
break
}
if msg.err != nil {
m.statsErr = msg.err.Error()
m.setStatus(msg.err.Error(), true)
break
}
m.selectedStats = msg.stats
m.statsID = msg.id
m.statsErr = ""
case actionResultMsg:
m.busy = ""
if msg.err != nil {
if looksLikeSudoExpiry(msg.err) {
m.sudoValidated = false
}
m.setStatus(msg.err.Error(), true)
break
}
if msg.action.kind == actionCreate || msg.action.kind == actionEdit {
m.form = nil
m.mode = tuiModeBrowse
}
if msg.action.kind == actionDelete {
m.mode = tuiModeBrowse
}
m.setStatus(msg.status, false)
if msg.refresh {
m.loading = true
cmds = append(cmds, m.spinner.Tick, fetchDataCmd(m.layout, msg.focusID))
}
case externalPreparedMsg:
if msg.err != nil {
m.setStatus(msg.err.Error(), true)
break
}
cmds = append(cmds, tea.ExecProcess(msg.command, func(err error) tea.Msg {
return actionResultMsg{
action: msg.action,
status: msg.doneStatus,
err: normalizeExecError(err),
refresh: msg.refresh,
focusID: m.selectedID,
}
}))
case sudoValidatedMsg:
if msg.err != nil {
m.pendingAction = nil
m.busy = ""
m.setStatus(msg.err.Error(), true)
break
}
m.sudoValidated = true
if m.pendingAction != nil {
action := *m.pendingAction
m.pendingAction = nil
m.busy = action.activity()
cmds = append(cmds, m.spinner.Tick, m.runActionCmd(action))
}
case refreshTickMsg:
cmds = append(cmds, refreshTickCmd())
if m.busy == "" && m.mode == tuiModeBrowse {
m.loading = true
cmds = append(cmds, m.spinner.Tick, fetchDataCmd(m.layout, m.selectedID))
}
case tea.KeyMsg:
switch m.mode {
case tuiModeBrowse:
nextModel, extraCmds := m.updateBrowse(msg)
m = nextModel
cmds = append(cmds, extraCmds...)
case tuiModeForm:
nextModel, extraCmds := m.updateForm(msg)
m = nextModel
cmds = append(cmds, extraCmds...)
case tuiModeConfirmDelete:
nextModel, extraCmds := m.updateConfirmDelete(msg)
m = nextModel
cmds = append(cmds, extraCmds...)
}
}
m.refreshDetail()
return m, tea.Batch(cmds...)
}
func (m tuiModel) updateBrowse(msg tea.KeyMsg) (tuiModel, []tea.Cmd) {
var cmds []tea.Cmd
switch {
case key.Matches(msg, m.browseKeys.quit):
cmds = append(cmds, tea.Quit)
case key.Matches(msg, m.browseKeys.help):
m.help.ShowAll = !m.help.ShowAll
case key.Matches(msg, m.browseKeys.refresh):
m.loading = true
cmds = append(cmds, m.spinner.Tick, fetchDataCmd(m.layout, m.selectedID))
case key.Matches(msg, m.browseKeys.create):
if len(m.images) == 0 {
m.setStatus("Create requires at least one image", true)
return m, cmds
}
m.form = newCreateVMForm(m.images, m.cfg)
m.mode = tuiModeForm
cmds = append(cmds, m.form.focusField(m.form.focus))
case key.Matches(msg, m.browseKeys.edit):
vm, ok := m.selectedVM()
if !ok {
m.setStatus("No VM selected", true)
return m, cmds
}
if vm.State == model.VMStateRunning {
m.setStatus("Stop the VM before editing it", true)
return m, cmds
}
m.form = newEditVMForm(vm)
m.mode = tuiModeForm
cmds = append(cmds, m.form.focusField(m.form.focus))
case key.Matches(msg, m.browseKeys.delete):
if _, ok := m.selectedVM(); !ok {
m.setStatus("No VM selected", true)
return m, cmds
}
m.mode = tuiModeConfirmDelete
case key.Matches(msg, m.browseKeys.start):
vm, ok := m.selectedVM()
if !ok {
m.setStatus("No VM selected", true)
return m, cmds
}
if vm.State == model.VMStateRunning {
m.setStatus("VM is already running", true)
return m, cmds
}
cmds = append(cmds, m.dispatchAction(actionRequest{kind: actionStart, id: vm.ID, name: vm.Name}))
case key.Matches(msg, m.browseKeys.stop):
vm, ok := m.selectedVM()
if !ok {
m.setStatus("No VM selected", true)
return m, cmds
}
if vm.State != model.VMStateRunning {
m.setStatus("VM is not running", true)
return m, cmds
}
cmds = append(cmds, m.dispatchAction(actionRequest{kind: actionStop, id: vm.ID, name: vm.Name}))
case key.Matches(msg, m.browseKeys.restart):
vm, ok := m.selectedVM()
if !ok {
m.setStatus("No VM selected", true)
return m, cmds
}
cmds = append(cmds, m.dispatchAction(actionRequest{kind: actionRestart, id: vm.ID, name: vm.Name}))
case key.Matches(msg, m.browseKeys.ssh):
vm, ok := m.selectedVM()
if !ok {
m.setStatus("No VM selected", true)
return m, cmds
}
if vm.State != model.VMStateRunning {
m.setStatus("SSH requires a running VM", true)
return m, cmds
}
cmds = append(cmds, m.runActionCmd(actionRequest{kind: actionSSH, id: vm.ID, name: vm.Name}))
case key.Matches(msg, m.browseKeys.logs):
vm, ok := m.selectedVM()
if !ok {
m.setStatus("No VM selected", true)
return m, cmds
}
cmds = append(cmds, m.runActionCmd(actionRequest{kind: actionLogs, id: vm.ID, name: vm.Name}))
default:
if len(m.vms) == 0 {
return m, cmds
}
oldCursor := m.table.Cursor()
var tableCmd tea.Cmd
m.table, tableCmd = m.table.Update(msg)
cmds = append(cmds, tableCmd)
if m.table.Cursor() != oldCursor {
if vm, ok := m.selectedVMByCursor(); ok {
m.selectedID = vm.ID
m.selectedStats = model.VMStats{}
m.statsID = ""
m.statsErr = ""
cmds = append(cmds, fetchStatsCmd(m.layout, vm.ID))
}
}
}
return m, cmds
}
func (m tuiModel) updateForm(msg tea.KeyMsg) (tuiModel, []tea.Cmd) {
var cmds []tea.Cmd
switch {
case key.Matches(msg, m.formKeys.cancel):
m.form = nil
m.mode = tuiModeBrowse
case key.Matches(msg, m.formKeys.next):
cmds = append(cmds, m.form.move(1))
case key.Matches(msg, m.formKeys.prev):
cmds = append(cmds, m.form.move(-1))
case key.Matches(msg, m.formKeys.change):
switch msg.String() {
case "left":
m.form.change(-1)
case "right":
m.form.change(1)
}
case key.Matches(msg, m.formKeys.toggle):
m.form.change(1)
case key.Matches(msg, m.formKeys.submit):
action, err := m.form.submit()
if err != nil {
m.setStatus(err.Error(), true)
return m, cmds
}
cmds = append(cmds, m.dispatchAction(action))
default:
cmds = append(cmds, m.form.update(msg))
}
return m, cmds
}
func (m tuiModel) updateConfirmDelete(msg tea.KeyMsg) (tuiModel, []tea.Cmd) {
switch {
case key.Matches(msg, m.confirmKeys.cancel):
m.mode = tuiModeBrowse
return m, nil
case key.Matches(msg, m.confirmKeys.confirm):
vm, ok := m.selectedVM()
if !ok {
m.mode = tuiModeBrowse
m.setStatus("No VM selected", true)
return m, nil
}
return m, []tea.Cmd{m.dispatchAction(actionRequest{kind: actionDelete, id: vm.ID, name: vm.Name})}
default:
return m, nil
}
}
func (m tuiModel) View() string {
if !m.ready {
return "Loading..."
}
header := m.renderHeader()
body := m.renderBody()
status := m.renderStatus()
m.help.Width = m.width
helpView := m.help.View(m.currentKeyMap())
return lipgloss.JoinVertical(lipgloss.Left, header, body, status, helpView)
}
func (m tuiModel) currentKeyMap() help.KeyMap {
switch m.mode {
case tuiModeForm:
return m.formKeys
case tuiModeConfirmDelete:
return m.confirmKeys
default:
return m.browseKeys
}
}
func (m *tuiModel) resize() {
bodyHeight := m.bodyHeight()
leftWidth := maxInt(42, (m.width*55)/100)
if leftWidth > m.width-24 {
leftWidth = maxInt(24, m.width/2)
}
rightWidth := maxInt(24, m.width-leftWidth-1)
leftInnerWidth := maxInt(20, leftWidth-4)
rightInnerWidth := maxInt(20, rightWidth-4)
panelInnerHeight := maxInt(8, bodyHeight-2)
m.table.SetWidth(leftInnerWidth)
m.table.SetHeight(maxInt(4, panelInnerHeight-2))
m.detail.Width = rightInnerWidth
m.detail.Height = maxInt(4, panelInnerHeight-2)
if m.form != nil {
m.form.setWidth(maxInt(28, minInt(58, m.width-10)))
}
}
func (m tuiModel) bodyHeight() int {
return maxInt(8, m.height-4)
}
func (m tuiModel) renderHeader() string {
status := "idle"
if m.loading {
status = m.spinner.View() + " loading"
} else if m.busy != "" {
status = m.spinner.View() + " " + m.busy
}
refreshed := "never"
if !m.lastRefresh.IsZero() {
refreshed = relativeTime(m.lastRefresh)
}
header := fmt.Sprintf("banger tui socket %s %s last refresh %s", filepath.Base(m.layout.SocketPath), status, refreshed)
return lipgloss.NewStyle().Bold(true).Width(m.width).Render(header)
}
func (m tuiModel) renderBody() string {
bodyHeight := m.bodyHeight()
if m.mode == tuiModeForm && m.form != nil {
return lipgloss.Place(m.width, bodyHeight, lipgloss.Center, lipgloss.Center, m.form.view(minInt(58, m.width-10)))
}
if m.mode == tuiModeConfirmDelete {
return lipgloss.Place(m.width, bodyHeight, lipgloss.Center, lipgloss.Center, m.confirmDeleteView())
}
leftWidth := maxInt(42, (m.width*55)/100)
if leftWidth > m.width-24 {
leftWidth = maxInt(24, m.width/2)
}
rightWidth := maxInt(24, m.width-leftWidth-1)
leftInnerWidth := maxInt(20, leftWidth-4)
rightInnerWidth := maxInt(20, rightWidth-4)
panelHeight := maxInt(8, bodyHeight-1)
leftContent := m.table.View()
if len(m.vms) == 0 {
leftContent = "No VMs.\n\nPress c to create one."
}
leftPanel := lipgloss.NewStyle().
Border(lipgloss.NormalBorder()).
Padding(0, 1).
Width(leftWidth).
Height(panelHeight).
Render(lipgloss.NewStyle().Width(leftInnerWidth).Render(leftContent))
rightPanel := lipgloss.NewStyle().
Border(lipgloss.NormalBorder()).
Padding(0, 1).
Width(rightWidth).
Height(panelHeight).
Render(lipgloss.NewStyle().Width(rightInnerWidth).Render(m.detail.View()))
return lipgloss.JoinHorizontal(lipgloss.Top, leftPanel, rightPanel)
}
func (m tuiModel) confirmDeleteView() string {
vm, ok := m.selectedVM()
name := "this VM"
if ok {
name = vm.Name
}
content := fmt.Sprintf("Delete %s?\n\nThis removes the VM and its persistent state.\n\nEnter confirm Esc cancel", name)
return lipgloss.NewStyle().
Border(lipgloss.NormalBorder()).
Padding(1, 2).
Width(54).
Render(content)
}
func (m tuiModel) renderStatus() string {
if m.statusText == "" {
return " "
}
style := lipgloss.NewStyle().Foreground(lipgloss.Color("70"))
if m.statusErr {
style = lipgloss.NewStyle().Foreground(lipgloss.Color("160"))
}
return style.Width(m.width).Render(m.statusText)
}
func (m *tuiModel) rebuildTable() {
rows := make([]table.Row, 0, len(m.vms))
cursor := 0
for i, vm := range m.vms {
rows = append(rows, table.Row{
vm.Name,
string(vm.State),
vm.Runtime.GuestIP,
strconv.Itoa(vm.Spec.VCPUCount),
fmt.Sprintf("%dM", vm.Spec.MemoryMiB),
model.FormatSizeBytes(vm.Spec.WorkDiskSizeBytes),
relativeTime(vm.CreatedAt),
})
if vm.ID == m.selectedID {
cursor = i
}
}
m.table.SetRows(rows)
if len(rows) > 0 {
m.table.SetCursor(cursor)
}
}
func (m *tuiModel) refreshDetail() {
vm, ok := m.selectedVM()
if !ok {
m.detail.SetContent("No VM selected.\n\nUse c to create a VM.")
return
}
stats := vm.Stats
if m.statsID == vm.ID && !m.selectedStats.CollectedAt.IsZero() {
stats = m.selectedStats
}
lines := []string{
fmt.Sprintf("Name: %s", vm.Name),
fmt.Sprintf("State: %s", vm.State),
fmt.Sprintf("IP: %s", orDash(vm.Runtime.GuestIP)),
fmt.Sprintf("DNS: %s", orDash(vm.Runtime.DNSName)),
fmt.Sprintf("Image: %s", shortID(vm.ImageID)),
"",
"Config",
fmt.Sprintf(" vCPU: %d", vm.Spec.VCPUCount),
fmt.Sprintf(" Memory: %d MiB", vm.Spec.MemoryMiB),
fmt.Sprintf(" Overlay: %s", model.FormatSizeBytes(vm.Spec.SystemOverlaySizeByte)),
fmt.Sprintf(" Work disk: %s", model.FormatSizeBytes(vm.Spec.WorkDiskSizeBytes)),
fmt.Sprintf(" NAT: %s", yesNo(vm.Spec.NATEnabled)),
"",
"Usage",
fmt.Sprintf(" Overlay: %s", formatBytes(stats.SystemOverlayBytes)),
fmt.Sprintf(" Work disk: %s", formatBytes(stats.WorkDiskBytes)),
fmt.Sprintf(" CPU: %.1f%%", stats.CPUPercent),
fmt.Sprintf(" RSS: %s", formatBytes(stats.RSSBytes)),
fmt.Sprintf(" VSZ: %s", formatBytes(stats.VSZBytes)),
}
if !stats.CollectedAt.IsZero() {
lines = append(lines, fmt.Sprintf(" Updated: %s", stats.CollectedAt.Format(time.RFC3339)))
}
lines = append(lines,
"",
"Timestamps",
fmt.Sprintf(" Created: %s", vm.CreatedAt.Format(time.RFC3339)),
fmt.Sprintf(" Updated: %s", vm.UpdatedAt.Format(time.RFC3339)),
fmt.Sprintf(" Touched: %s", vm.LastTouchedAt.Format(time.RFC3339)),
)
if vm.Runtime.LastError != "" {
lines = append(lines, "", "Last error", " "+vm.Runtime.LastError)
}
if m.statsErr != "" && m.statsID == vm.ID {
lines = append(lines, "", "Stats error", " "+m.statsErr)
}
m.detail.SetContent(strings.Join(lines, "\n"))
}
func (m *tuiModel) setStatus(text string, isErr bool) {
m.statusText = text
m.statusErr = isErr
}
func (m tuiModel) selectedVM() (model.VMRecord, bool) {
for _, vm := range m.vms {
if vm.ID == m.selectedID {
return vm, true
}
}
return model.VMRecord{}, false
}
func (m tuiModel) selectedVMByCursor() (model.VMRecord, bool) {
cursor := m.table.Cursor()
if cursor < 0 || cursor >= len(m.vms) {
return model.VMRecord{}, false
}
return m.vms[cursor], true
}
func (m *tuiModel) dispatchAction(action actionRequest) tea.Cmd {
if action.needsSudo() && !m.sudoValidated {
m.pendingAction = &action
m.busy = "Authorizing sudo..."
return tea.ExecProcess(exec.Command("sudo", "-v"), func(err error) tea.Msg {
return sudoValidatedMsg{err: err}
})
}
m.busy = action.activity()
return tea.Batch(m.spinner.Tick, m.runActionCmd(action))
}
func (m tuiModel) runActionCmd(action actionRequest) tea.Cmd {
switch action.kind {
case actionCreate:
return createActionCmd(m.layout, action)
case actionEdit:
return editActionCmd(m.layout, action)
case actionStart, actionStop, actionRestart:
return lifecycleActionCmd(m.layout, action)
case actionDelete:
return deleteActionCmd(m.layout, action)
case actionSSH:
return prepareSSHCmd(m.layout, m.cfg, action)
case actionLogs:
return prepareLogsCmd(m.layout, action)
default:
return func() tea.Msg {
return actionResultMsg{action: action, err: fmt.Errorf("unsupported action %s", action.kind)}
}
}
}
func createActionCmd(layout paths.Layout, action actionRequest) tea.Cmd {
return func() tea.Msg {
result, err := rpc.Call[api.VMShowResult](context.Background(), layout.SocketPath, "vm.create", action.create)
if err != nil {
return actionResultMsg{action: action, err: err}
}
return actionResultMsg{
action: action,
focusID: result.VM.ID,
status: fmt.Sprintf("created %s", result.VM.Name),
refresh: true,
}
}
}
func editActionCmd(layout paths.Layout, action actionRequest) tea.Cmd {
return func() tea.Msg {
result, err := rpc.Call[api.VMShowResult](context.Background(), layout.SocketPath, "vm.set", action.set)
if err != nil {
return actionResultMsg{action: action, err: err}
}
return actionResultMsg{
action: action,
focusID: result.VM.ID,
status: fmt.Sprintf("updated %s", result.VM.Name),
refresh: true,
}
}
}
func lifecycleActionCmd(layout paths.Layout, action actionRequest) tea.Cmd {
method := ""
status := ""
switch action.kind {
case actionStart:
method = "vm.start"
status = "started"
case actionStop:
method = "vm.stop"
status = "stopped"
case actionRestart:
method = "vm.restart"
status = "restarted"
}
return func() tea.Msg {
result, err := rpc.Call[api.VMShowResult](context.Background(), layout.SocketPath, method, api.VMRefParams{IDOrName: action.id})
if err != nil {
return actionResultMsg{action: action, err: err}
}
return actionResultMsg{
action: action,
focusID: result.VM.ID,
status: fmt.Sprintf("%s %s", status, result.VM.Name),
refresh: true,
}
}
}
func deleteActionCmd(layout paths.Layout, action actionRequest) tea.Cmd {
return func() tea.Msg {
_, err := rpc.Call[api.VMShowResult](context.Background(), layout.SocketPath, "vm.delete", api.VMRefParams{IDOrName: action.id})
if err != nil {
return actionResultMsg{action: action, err: err}
}
return actionResultMsg{
action: action,
status: fmt.Sprintf("deleted %s", action.name),
refresh: true,
}
}
}
func prepareSSHCmd(layout paths.Layout, cfg model.DaemonConfig, action actionRequest) tea.Cmd {
return func() tea.Msg {
if err := validateSSHPrereqs(cfg); err != nil {
return externalPreparedMsg{action: action, err: err}
}
result, err := rpc.Call[api.VMSSHResult](context.Background(), layout.SocketPath, "vm.ssh", api.VMRefParams{IDOrName: action.id})
if err != nil {
return externalPreparedMsg{action: action, err: err}
}
args, err := sshCommandArgs(cfg, result.GuestIP, nil)
if err != nil {
return externalPreparedMsg{action: action, err: err}
}
return externalPreparedMsg{
action: action,
command: exec.Command("ssh", args...),
doneStatus: fmt.Sprintf("ssh session ended for %s", result.Name),
refresh: true,
}
}
}
func prepareLogsCmd(layout paths.Layout, action actionRequest) tea.Cmd {
return func() tea.Msg {
result, err := rpc.Call[api.VMLogsResult](context.Background(), layout.SocketPath, "vm.logs", api.VMRefParams{IDOrName: action.id})
if err != nil {
return externalPreparedMsg{action: action, err: err}
}
if result.LogPath == "" {
return externalPreparedMsg{action: action, err: errors.New("vm has no log path")}
}
return externalPreparedMsg{
action: action,
command: system.TailCommand(result.LogPath, true),
doneStatus: fmt.Sprintf("closed log view for %s", action.name),
refresh: false,
}
}
}
func fetchDataCmd(layout paths.Layout, focusID string) tea.Cmd {
return func() tea.Msg {
vms, err := rpc.Call[api.VMListResult](context.Background(), layout.SocketPath, "vm.list", api.Empty{})
if err != nil {
return dataLoadedMsg{err: err, focusID: focusID}
}
images, err := rpc.Call[api.ImageListResult](context.Background(), layout.SocketPath, "image.list", api.Empty{})
if err != nil {
return dataLoadedMsg{vms: vms.VMs, err: err, focusID: focusID}
}
return dataLoadedMsg{vms: vms.VMs, images: images.Images, focusID: focusID}
}
}
func fetchStatsCmd(layout paths.Layout, id string) tea.Cmd {
return func() tea.Msg {
result, err := rpc.Call[api.VMStatsResult](context.Background(), layout.SocketPath, "vm.stats", api.VMRefParams{IDOrName: id})
if err != nil {
return statsLoadedMsg{id: id, err: err}
}
return statsLoadedMsg{id: id, stats: result.Stats}
}
}
func refreshTickCmd() tea.Cmd {
return tea.Tick(tuiRefreshInterval, func(time.Time) tea.Msg {
return refreshTickMsg{}
})
}
func resolveSelectedID(targetID string, vms []model.VMRecord) string {
if len(vms) == 0 {
return ""
}
if targetID != "" {
for _, vm := range vms {
if vm.ID == targetID {
return targetID
}
}
}
return vms[0].ID
}
func imageNames(images []model.Image) []string {
names := make([]string, 0, len(images))
for _, image := range images {
names = append(names, image.Name)
}
return names
}
func tuiTableKeyMap() table.KeyMap {
return table.KeyMap{
LineUp: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("up", "up")),
LineDown: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("down", "down")),
PageUp: key.NewBinding(key.WithKeys("pgup"), key.WithHelp("pgup", "page up")),
PageDown: key.NewBinding(key.WithKeys("pgdown"), key.WithHelp("pgdn", "page down")),
HalfPageUp: key.NewBinding(key.WithKeys("ctrl+u"), key.WithHelp("ctrl+u", "half up")),
HalfPageDown: key.NewBinding(key.WithKeys("ctrl+d"), key.WithHelp("ctrl+d", "half down")),
GotoTop: key.NewBinding(key.WithKeys("home"), key.WithHelp("home", "top")),
GotoBottom: key.NewBinding(key.WithKeys("end"), key.WithHelp("end", "bottom")),
}
}
func (a actionRequest) needsSudo() bool {
switch a.kind {
case actionCreate, actionEdit, actionStart, actionStop, actionRestart, actionDelete:
return true
default:
return false
}
}
func (a actionRequest) activity() string {
switch a.kind {
case actionCreate:
return "Creating VM..."
case actionEdit:
return "Saving VM..."
case actionStart:
return "Starting VM..."
case actionStop:
return "Stopping VM..."
case actionRestart:
return "Restarting VM..."
case actionDelete:
return "Deleting VM..."
case actionSSH:
return "Opening SSH..."
case actionLogs:
return "Opening logs..."
default:
return "Working..."
}
}
func parsePositiveInt(label, raw string) (int, error) {
value, err := strconv.Atoi(strings.TrimSpace(raw))
if err != nil || value <= 0 {
return 0, fmt.Errorf("%s must be a positive integer", label)
}
return value, nil
}
func normalizeExecError(err error) error {
if err == nil {
return nil
}
var exitErr *exec.ExitError
if errors.As(err, &exitErr) && exitErr.ExitCode() == 130 {
return nil
}
return err
}
func looksLikeSudoExpiry(err error) bool {
if err == nil {
return false
}
text := err.Error()
return strings.Contains(text, "sudo") || strings.Contains(text, "password is required")
}
func formatBytes(bytes int64) string {
if bytes <= 0 {
return "0"
}
const (
kib = 1024
mib = 1024 * kib
gib = 1024 * mib
)
switch {
case bytes >= gib:
return fmt.Sprintf("%.1fG", float64(bytes)/gib)
case bytes >= mib:
return fmt.Sprintf("%.1fM", float64(bytes)/mib)
case bytes >= kib:
return fmt.Sprintf("%.1fK", float64(bytes)/kib)
default:
return strconv.FormatInt(bytes, 10) + "B"
}
}
func boolToIndex(value bool) int {
if value {
return 1
}
return 0
}
func yesNo(value bool) string {
if value {
return "yes"
}
return "no"
}
func isYes(value string) bool {
return strings.EqualFold(strings.TrimSpace(value), "yes")
}
func orDash(value string) string {
if strings.TrimSpace(value) == "" {
return "-"
}
return value
}
func wrapIndex(value, length int) int {
if length <= 0 {
return 0
}
for value < 0 {
value += length
}
return value % length
}
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}