The TUI should show VM capacity pressure at a glance instead of making users read raw numbers or drill into per-VM details. Add a compact colored status row under the header that renders CPU, RAM, and disk usage as progress bars. CPU and RAM reflect reserved resources for running VMs, while disk reflects actual allocated overlay and work-disk bytes across all VMs against the filesystem backing banger state. Add host resource and filesystem helpers in the system package and cover the new aggregation and rendering behavior with TUI and system tests. Verified with GOCACHE=/tmp/banger-gocache go test ./... and GOCACHE=/tmp/banger-gocache make build.
1786 lines
46 KiB
Go
1786 lines
46 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
|
|
|
|
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
|
|
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 {
|
|
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.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...),
|
|
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 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
|
|
}
|