Make iterating on a Firecracker-friendly Void guest practical without replacing the Debian default image path. Add local Void rootfs build/register/verify plumbing, a language-agnostic dev package baseline, and guest SSH/work-disk hardening so new images use the runtime bundle key, keep a normal root bash environment, and repair stale nested /root layouts on restart. Replace the guest PING/PONG responder with an HTTP /healthz agent over vsock, rename the runtime bundle and config surface from ping helper to agent while still accepting the legacy keys, and route the post-SSH reminder through the new vm.health path. Validated with GOCACHE=/tmp/banger-gocache go test ./..., make build, bash -n customize.sh make-rootfs-void.sh, and git diff --check.
1833 lines
47 KiB
Go
1833 lines
47 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"
|
|
"banger/internal/vsockagent"
|
|
|
|
"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
|
|
|
|
var (
|
|
tuiEnsureDaemonFunc = ensureDaemon
|
|
tuiProgramRunner = func(model tuiModel) error {
|
|
program := tea.NewProgram(model, tea.WithAltScreen())
|
|
return program.Start()
|
|
}
|
|
tuiIsTerminal = func(fd uintptr) bool {
|
|
return isatty.IsTerminal(fd)
|
|
}
|
|
)
|
|
|
|
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 daemonReadyMsg struct {
|
|
generation int
|
|
layout paths.Layout
|
|
cfg model.DaemonConfig
|
|
duration time.Duration
|
|
err error
|
|
}
|
|
|
|
type vmListLoadedMsg struct {
|
|
generation int
|
|
vms []model.VMRecord
|
|
focusID string
|
|
duration time.Duration
|
|
err error
|
|
}
|
|
|
|
type imageListLoadedMsg struct {
|
|
generation int
|
|
images []model.Image
|
|
duration time.Duration
|
|
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
|
|
done func(error) tea.Msg
|
|
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
|
|
|
|
loadGeneration int
|
|
loading bool
|
|
busy string
|
|
sudoValidated bool
|
|
daemonReady bool
|
|
daemonPending bool
|
|
vmListPending bool
|
|
imagePending bool
|
|
imagesLoaded bool
|
|
daemonLoadDur time.Duration
|
|
vmListDur time.Duration
|
|
imageListDur time.Duration
|
|
|
|
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
|
|
hostCPUCount int
|
|
hostMemoryBytes int64
|
|
hostDiskBytes int64
|
|
}
|
|
|
|
func newTUIModel(layout paths.Layout, cfg model.DaemonConfig) tuiModel {
|
|
hostResources, err := system.ReadHostResources()
|
|
hostCPUCount := 0
|
|
hostMemoryBytes := int64(0)
|
|
hostDiskBytes := int64(0)
|
|
if err == nil {
|
|
hostCPUCount = hostResources.CPUCount
|
|
hostMemoryBytes = hostResources.TotalMemoryBytes
|
|
}
|
|
if diskUsage, err := readTUIFilesystemUsage(layout); err == nil {
|
|
hostDiskBytes = diskUsage.TotalBytes
|
|
}
|
|
|
|
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()
|
|
|
|
model := tuiModel{
|
|
layout: layout,
|
|
cfg: cfg,
|
|
width: 120,
|
|
height: 32,
|
|
ready: true,
|
|
table: vmTable,
|
|
detail: detail,
|
|
help: helpView,
|
|
spinner: spin,
|
|
browseKeys: newBrowseKeyMap(),
|
|
formKeys: newFormKeyMap(),
|
|
confirmKeys: newConfirmKeyMap(),
|
|
loadGeneration: 1,
|
|
loading: true,
|
|
daemonPending: true,
|
|
statusText: "Starting daemon...",
|
|
hostCPUCount: hostCPUCount,
|
|
hostMemoryBytes: hostMemoryBytes,
|
|
hostDiskBytes: hostDiskBytes,
|
|
}
|
|
model.resize()
|
|
return model
|
|
}
|
|
|
|
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 !tuiIsTerminal(os.Stdin.Fd()) || !tuiIsTerminal(os.Stdout.Fd()) {
|
|
return errors.New("tui requires an interactive terminal")
|
|
}
|
|
return tuiProgramRunner(newTUIModel(paths.Layout{}, model.DaemonConfig{}))
|
|
},
|
|
}
|
|
}
|
|
|
|
func (m tuiModel) Init() tea.Cmd {
|
|
return tea.Batch(m.spinner.Tick, refreshTickCmd(), ensureDaemonCmd(m.loadGeneration))
|
|
}
|
|
|
|
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 daemonReadyMsg:
|
|
if msg.generation != m.loadGeneration {
|
|
break
|
|
}
|
|
m.daemonPending = false
|
|
if msg.err != nil {
|
|
m.syncLoadingState()
|
|
m.setStatus(fmt.Sprintf("starting daemon: %v", msg.err), true)
|
|
break
|
|
}
|
|
m.layout = msg.layout
|
|
m.cfg = msg.cfg
|
|
m.daemonReady = true
|
|
m.daemonLoadDur = msg.duration
|
|
if diskUsage, err := readTUIFilesystemUsage(m.layout); err == nil {
|
|
m.hostDiskBytes = diskUsage.TotalBytes
|
|
}
|
|
m.beginListLoad("")
|
|
cmds = append(cmds, m.spinner.Tick, fetchVMListCmd(m.layout, "", m.loadGeneration), fetchImageListCmd(m.layout, m.loadGeneration))
|
|
case vmListLoadedMsg:
|
|
if msg.generation != m.loadGeneration {
|
|
break
|
|
}
|
|
m.vmListPending = false
|
|
if msg.err != nil {
|
|
m.syncLoadingState()
|
|
m.setStatus(fmt.Sprintf("loading vms: %v", msg.err), true)
|
|
break
|
|
}
|
|
m.vms = msg.vms
|
|
m.vmListDur = msg.duration
|
|
targetID := m.selectedID
|
|
if msg.focusID != "" {
|
|
targetID = msg.focusID
|
|
}
|
|
m.selectedID = resolveSelectedID(targetID, msg.vms)
|
|
m.lastRefresh = time.Now()
|
|
m.rebuildTable()
|
|
m.syncLoadingState()
|
|
m.setLoadStatus(false)
|
|
if m.selectedID != "" {
|
|
cmds = append(cmds, fetchStatsCmd(m.layout, m.selectedID))
|
|
} else {
|
|
m.selectedStats = model.VMStats{}
|
|
m.statsID = ""
|
|
m.statsErr = ""
|
|
}
|
|
case imageListLoadedMsg:
|
|
if msg.generation != m.loadGeneration {
|
|
break
|
|
}
|
|
m.imagePending = false
|
|
if msg.err != nil {
|
|
m.syncLoadingState()
|
|
m.setStatus(fmt.Sprintf("loading images: %v", msg.err), true)
|
|
break
|
|
}
|
|
m.images = msg.images
|
|
m.imagesLoaded = true
|
|
m.imageListDur = msg.duration
|
|
m.syncLoadingState()
|
|
m.setLoadStatus(false)
|
|
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 {
|
|
cmds = append(cmds, m.beginRefreshLoad(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 {
|
|
err = normalizeExecError(err)
|
|
if msg.done != nil {
|
|
return msg.done(err)
|
|
}
|
|
return actionResultMsg{
|
|
action: msg.action,
|
|
status: msg.doneStatus,
|
|
err: 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.daemonReady && !m.vmListPending && !m.imagePending {
|
|
cmds = append(cmds, m.beginRefreshLoad(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):
|
|
if !m.daemonReady {
|
|
cmds = append(cmds, m.beginBootstrapLoad()...)
|
|
} else {
|
|
cmds = append(cmds, m.beginRefreshLoad(m.selectedID)...)
|
|
}
|
|
case key.Matches(msg, m.browseKeys.create):
|
|
if !m.imagesLoaded {
|
|
m.setStatus("Images are still loading", true)
|
|
return m, cmds
|
|
}
|
|
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 {
|
|
header := m.renderHeader()
|
|
resourceBar := m.renderResourceBar()
|
|
body := m.renderBody()
|
|
status := m.renderStatus()
|
|
m.help.Width = m.width
|
|
helpView := m.help.View(m.currentKeyMap())
|
|
return lipgloss.JoinVertical(lipgloss.Left, header, resourceBar, 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-5)
|
|
}
|
|
|
|
func (m tuiModel) renderHeader() string {
|
|
status := "idle"
|
|
if m.busy != "" {
|
|
status = m.spinner.View() + " " + m.busy
|
|
} else if phase := m.loadingPhase(); phase != "" {
|
|
status = m.spinner.View() + " " + phase
|
|
}
|
|
refreshed := "never"
|
|
if !m.lastRefresh.IsZero() {
|
|
refreshed = relativeTime(m.lastRefresh)
|
|
}
|
|
socketLabel := filepath.Base(m.layout.SocketPath)
|
|
if socketLabel == "." || socketLabel == "" {
|
|
socketLabel = "pending"
|
|
}
|
|
header := fmt.Sprintf("banger tui socket %s %s last refresh %s", socketLabel, status, refreshed)
|
|
return lipgloss.NewStyle().Bold(true).Width(m.width).Render(header)
|
|
}
|
|
|
|
func (m tuiModel) renderResourceBar() string {
|
|
runningVMs, totalVCPUs, totalMemoryBytes := aggregateRunningVMResources(m.vms)
|
|
totalDiskBytes := aggregateVMDiskUsage(m.vms)
|
|
labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("245"))
|
|
runningStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("69")).Bold(true)
|
|
|
|
parts := []string{
|
|
labelStyle.Render("VMs") + " " + runningStyle.Render(fmt.Sprintf("%d/%d", runningVMs, len(m.vms))),
|
|
renderUsageMeter("CPU", int64(totalVCPUs), int64(m.hostCPUCount), strconv.Itoa(totalVCPUs), totalLabel(m.hostCPUCount)),
|
|
renderUsageMeter("RAM", totalMemoryBytes, m.hostMemoryBytes, formatBytes(totalMemoryBytes), bytesTotalLabel(m.hostMemoryBytes)),
|
|
renderUsageMeter("Disk", totalDiskBytes, m.hostDiskBytes, formatBytes(totalDiskBytes), bytesTotalLabel(m.hostDiskBytes)),
|
|
}
|
|
|
|
return lipgloss.NewStyle().
|
|
Width(m.width).
|
|
Render(lipgloss.JoinHorizontal(lipgloss.Left, parts...))
|
|
}
|
|
|
|
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 = m.vmListPlaceholder()
|
|
}
|
|
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(m.detailPlaceholder())
|
|
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) beginBootstrapLoad() []tea.Cmd {
|
|
m.loadGeneration++
|
|
m.daemonReady = false
|
|
m.daemonPending = true
|
|
m.vmListPending = false
|
|
m.imagePending = false
|
|
m.imagesLoaded = false
|
|
m.images = nil
|
|
m.daemonLoadDur = 0
|
|
m.vmListDur = 0
|
|
m.imageListDur = 0
|
|
m.syncLoadingState()
|
|
m.setLoadStatus(false)
|
|
return []tea.Cmd{m.spinner.Tick, ensureDaemonCmd(m.loadGeneration)}
|
|
}
|
|
|
|
func (m *tuiModel) beginListLoad(focusID string) {
|
|
m.vmListPending = true
|
|
m.imagePending = true
|
|
if len(m.images) == 0 {
|
|
m.imagesLoaded = false
|
|
}
|
|
m.vmListDur = 0
|
|
m.imageListDur = 0
|
|
m.syncLoadingState()
|
|
m.setLoadStatus(false)
|
|
if focusID == "" {
|
|
return
|
|
}
|
|
m.selectedID = focusID
|
|
}
|
|
|
|
func (m *tuiModel) beginRefreshLoad(focusID string) []tea.Cmd {
|
|
if !m.daemonReady {
|
|
return m.beginBootstrapLoad()
|
|
}
|
|
m.loadGeneration++
|
|
m.beginListLoad(focusID)
|
|
return []tea.Cmd{
|
|
m.spinner.Tick,
|
|
fetchVMListCmd(m.layout, focusID, m.loadGeneration),
|
|
fetchImageListCmd(m.layout, m.loadGeneration),
|
|
}
|
|
}
|
|
|
|
func (m *tuiModel) syncLoadingState() {
|
|
m.loading = m.daemonPending || m.vmListPending || m.imagePending
|
|
}
|
|
|
|
func (m tuiModel) loadingPhase() string {
|
|
switch {
|
|
case m.daemonPending:
|
|
return "starting daemon"
|
|
case m.vmListPending && m.imagePending:
|
|
return "loading vms and images"
|
|
case m.vmListPending:
|
|
return "loading vms"
|
|
case m.imagePending:
|
|
return "loading images"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func (m *tuiModel) setLoadStatus(isErr bool) {
|
|
if phase := m.loadingPhase(); phase != "" {
|
|
durations := m.stageDurations()
|
|
switch phase {
|
|
case "loading images":
|
|
prefix := fmt.Sprintf("Loaded %d VM(s); loading images", len(m.vms))
|
|
if durations != "" {
|
|
m.setStatus(fmt.Sprintf("%s (%s)", prefix, durations), isErr)
|
|
return
|
|
}
|
|
m.setStatus(prefix+"...", isErr)
|
|
return
|
|
case "loading vms":
|
|
prefix := fmt.Sprintf("Loaded %d image(s); loading vms", len(m.images))
|
|
if durations != "" {
|
|
m.setStatus(fmt.Sprintf("%s (%s)", prefix, durations), isErr)
|
|
return
|
|
}
|
|
m.setStatus(prefix+"...", isErr)
|
|
return
|
|
}
|
|
if durations != "" {
|
|
m.setStatus(fmt.Sprintf("%s (%s)", capitalizePhase(phase), durations), isErr)
|
|
return
|
|
}
|
|
m.setStatus(capitalizePhase(phase)+"...", isErr)
|
|
return
|
|
}
|
|
if m.daemonReady && m.vmListDur > 0 && m.imageListDur > 0 {
|
|
m.setStatus(fmt.Sprintf("Loaded %d VM(s) (%s)", len(m.vms), m.stageDurations()), isErr)
|
|
}
|
|
}
|
|
|
|
func (m tuiModel) stageDurations() string {
|
|
parts := make([]string, 0, 3)
|
|
if m.daemonLoadDur > 0 {
|
|
parts = append(parts, "daemon "+formatTUIDuration(m.daemonLoadDur))
|
|
}
|
|
if m.vmListDur > 0 {
|
|
parts = append(parts, "vm list "+formatTUIDuration(m.vmListDur))
|
|
}
|
|
if m.imageListDur > 0 {
|
|
parts = append(parts, "image list "+formatTUIDuration(m.imageListDur))
|
|
}
|
|
return strings.Join(parts, ", ")
|
|
}
|
|
|
|
func (m tuiModel) vmListPlaceholder() string {
|
|
switch {
|
|
case m.daemonPending:
|
|
return "Starting daemon...\n\nWaiting for bangerd to become ready."
|
|
case m.vmListPending:
|
|
return "Loading VMs..."
|
|
default:
|
|
return "No VMs.\n\nPress c to create one."
|
|
}
|
|
}
|
|
|
|
func (m tuiModel) detailPlaceholder() string {
|
|
switch {
|
|
case m.daemonPending:
|
|
return "Starting daemon...\n\nThe TUI will populate once bangerd is ready."
|
|
case m.vmListPending:
|
|
return "Loading VMs..."
|
|
case len(m.vms) == 0:
|
|
if m.imagePending {
|
|
return "No VM selected.\n\nImages are still loading."
|
|
}
|
|
return "No VM selected.\n\nUse c to create a VM."
|
|
default:
|
|
return "No VM selected."
|
|
}
|
|
}
|
|
|
|
func ensureDaemonCmd(generation int) tea.Cmd {
|
|
return func() tea.Msg {
|
|
start := time.Now()
|
|
layout, cfg, err := tuiEnsureDaemonFunc(context.Background())
|
|
return daemonReadyMsg{
|
|
generation: generation,
|
|
layout: layout,
|
|
cfg: cfg,
|
|
duration: time.Since(start),
|
|
err: err,
|
|
}
|
|
}
|
|
}
|
|
|
|
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...),
|
|
done: func(execErr error) tea.Msg {
|
|
return sshDoneMsg(layout, action, result.Name, execErr)
|
|
},
|
|
refresh: true,
|
|
}
|
|
}
|
|
}
|
|
|
|
func sshDoneMsg(layout paths.Layout, action actionRequest, name string, execErr error) tea.Msg {
|
|
if execErr != nil {
|
|
return actionResultMsg{
|
|
action: action,
|
|
err: execErr,
|
|
refresh: true,
|
|
focusID: action.id,
|
|
}
|
|
}
|
|
pingCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
defer cancel()
|
|
health, err := vmHealthFunc(pingCtx, layout.SocketPath, name)
|
|
if err != nil {
|
|
return actionResultMsg{
|
|
action: action,
|
|
status: vsockagent.WarningMessage(name, err),
|
|
refresh: true,
|
|
focusID: action.id,
|
|
}
|
|
}
|
|
if health.Healthy {
|
|
if strings.TrimSpace(health.Name) != "" {
|
|
name = health.Name
|
|
}
|
|
return actionResultMsg{
|
|
action: action,
|
|
status: vsockagent.ReminderMessage(name),
|
|
refresh: true,
|
|
focusID: action.id,
|
|
}
|
|
}
|
|
return actionResultMsg{
|
|
action: action,
|
|
status: fmt.Sprintf("ssh session ended for %s", name),
|
|
refresh: true,
|
|
focusID: action.id,
|
|
}
|
|
}
|
|
|
|
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 fetchVMListCmd(layout paths.Layout, focusID string, generation int) tea.Cmd {
|
|
return func() tea.Msg {
|
|
start := time.Now()
|
|
vms, err := rpc.Call[api.VMListResult](context.Background(), layout.SocketPath, "vm.list", api.Empty{})
|
|
if err != nil {
|
|
return vmListLoadedMsg{generation: generation, err: err, focusID: focusID}
|
|
}
|
|
return vmListLoadedMsg{
|
|
generation: generation,
|
|
vms: vms.VMs,
|
|
focusID: focusID,
|
|
duration: time.Since(start),
|
|
}
|
|
}
|
|
}
|
|
|
|
func fetchImageListCmd(layout paths.Layout, generation int) tea.Cmd {
|
|
return func() tea.Msg {
|
|
start := time.Now()
|
|
images, err := rpc.Call[api.ImageListResult](context.Background(), layout.SocketPath, "image.list", api.Empty{})
|
|
if err != nil {
|
|
return imageListLoadedMsg{generation: generation, err: err}
|
|
}
|
|
return imageListLoadedMsg{
|
|
generation: generation,
|
|
images: images.Images,
|
|
duration: time.Since(start),
|
|
}
|
|
}
|
|
}
|
|
|
|
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 capitalizePhase(value string) string {
|
|
if value == "" {
|
|
return ""
|
|
}
|
|
return strings.ToUpper(value[:1]) + value[1:]
|
|
}
|
|
|
|
func formatTUIDuration(value time.Duration) string {
|
|
switch {
|
|
case value >= time.Second:
|
|
return value.Round(100 * time.Millisecond).String()
|
|
case value >= 100*time.Millisecond:
|
|
return value.Round(10 * time.Millisecond).String()
|
|
default:
|
|
return value.Round(time.Millisecond).String()
|
|
}
|
|
}
|
|
|
|
func aggregateRunningVMResources(vms []model.VMRecord) (runningCount, totalVCPUs int, totalMemoryBytes int64) {
|
|
for _, vm := range vms {
|
|
if vm.State != model.VMStateRunning {
|
|
continue
|
|
}
|
|
runningCount++
|
|
totalVCPUs += vm.Spec.VCPUCount
|
|
totalMemoryBytes += int64(vm.Spec.MemoryMiB) * 1024 * 1024
|
|
}
|
|
return runningCount, totalVCPUs, totalMemoryBytes
|
|
}
|
|
|
|
func aggregateVMDiskUsage(vms []model.VMRecord) int64 {
|
|
var total int64
|
|
for _, vm := range vms {
|
|
total += system.AllocatedBytes(vm.Runtime.SystemOverlay)
|
|
total += system.AllocatedBytes(vm.Runtime.WorkDiskPath)
|
|
}
|
|
return total
|
|
}
|
|
|
|
func renderUsageMeter(label string, used, total int64, usedText, totalText string) string {
|
|
labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("245"))
|
|
valueStyle := lipgloss.NewStyle().Bold(true)
|
|
bar := renderProgressBar(used, total, 12)
|
|
return fmt.Sprintf(" %s %s %s", labelStyle.Render(label), bar, valueStyle.Render(usedText+"/"+totalText))
|
|
}
|
|
|
|
func renderProgressBar(used, total int64, width int) string {
|
|
if width <= 0 {
|
|
return ""
|
|
}
|
|
if total <= 0 {
|
|
unknownStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
|
|
return unknownStyle.Render("[" + strings.Repeat("?", width) + "]")
|
|
}
|
|
ratio := float64(used) / float64(total)
|
|
if ratio < 0 {
|
|
ratio = 0
|
|
}
|
|
if ratio > 1 {
|
|
ratio = 1
|
|
}
|
|
filled := int(ratio * float64(width))
|
|
if used > 0 && filled == 0 {
|
|
filled = 1
|
|
}
|
|
if filled > width {
|
|
filled = width
|
|
}
|
|
empty := width - filled
|
|
|
|
barColor := lipgloss.Color("70")
|
|
switch {
|
|
case ratio >= 0.9:
|
|
barColor = lipgloss.Color("160")
|
|
case ratio >= 0.75:
|
|
barColor = lipgloss.Color("214")
|
|
}
|
|
|
|
filledStyle := lipgloss.NewStyle().Foreground(barColor)
|
|
emptyStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("238"))
|
|
return "[" + filledStyle.Render(strings.Repeat("█", filled)) + emptyStyle.Render(strings.Repeat("░", empty)) + "]"
|
|
}
|
|
|
|
func totalLabel(total int) string {
|
|
if total <= 0 {
|
|
return "-"
|
|
}
|
|
return strconv.Itoa(total)
|
|
}
|
|
|
|
func bytesTotalLabel(total int64) string {
|
|
if total <= 0 {
|
|
return "-"
|
|
}
|
|
return formatBytes(total)
|
|
}
|
|
|
|
func readTUIFilesystemUsage(layout paths.Layout) (system.FilesystemUsage, error) {
|
|
target := strings.TrimSpace(layout.StateDir)
|
|
if target == "" {
|
|
resolved, err := paths.Resolve()
|
|
if err != nil {
|
|
return system.FilesystemUsage{}, err
|
|
}
|
|
target = resolved.StateDir
|
|
}
|
|
return system.ReadFilesystemUsage(target)
|
|
}
|
|
|
|
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
|
|
}
|