016 Project 16: Proxmox TUI Manager

016 Build a Proxmox TUI Manager

This project builds an advanced terminal UI that talks to Proxmox API.

Features: - list nodes - list QEMU VMs on selected node - start/stop VM - refresh state

TUI keypress -> API client -> Proxmox endpoint -> update model -> redraw UI

Setup

go mod init example.com/proxmox-tui
go get github.com/charmbracelet/bubbletea@latest
go get github.com/charmbracelet/lipgloss@latest

Set env vars:

export PVE_BASE_URL="https://proxmox.local:8006"
export PVE_TOKEN="user@pam!token=secret"

Full main.go

package main

import (
    "crypto/tls"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "time"

    tea "github.com/charmbracelet/bubbletea"
    "github.com/charmbracelet/lipgloss"
)

type node struct {
    Node string `json:"node"`
}

type vm struct {
    VMID   int    `json:"vmid"`
    Name   string `json:"name"`
    Status string `json:"status"`
}

type apiResp[T any] struct {
    Data T `json:"data"`
}

type client struct {
    base string
    hc   *http.Client
}

func newClient(base string) *client {
    return &client{
        base: base,
        hc: &http.Client{Timeout: 8 * time.Second, Transport: &http.Transport{
            TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
        }},
    }
}

func (c *client) do(method, path string, body io.Reader) (*http.Response, error) {
    req, err := http.NewRequest(method, c.base+path, body)
    if err != nil {
        return nil, err
    }
    req.Header.Set("Authorization", "PVEAPIToken="+os.Getenv("PVE_TOKEN"))
    return c.hc.Do(req)
}

func (c *client) listNodes() ([]node, error) {
    resp, err := c.do(http.MethodGet, "/api2/json/nodes", nil)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()
    var out apiResp[[]node]
    if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
        return nil, err
    }
    return out.Data, nil
}

func (c *client) listVMs(nodeName string) ([]vm, error) {
    resp, err := c.do(http.MethodGet, "/api2/json/nodes/"+nodeName+"/qemu", nil)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()
    var out apiResp[[]vm]
    if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
        return nil, err
    }
    return out.Data, nil
}

func (c *client) vmAction(nodeName string, vmid int, action string) error {
    resp, err := c.do(http.MethodPost, "/api2/json/nodes/"+nodeName+"/qemu/"+strconv.Itoa(vmid)+"/status/"+action, nil)
    if err != nil {
        return err
    }
    defer resp.Body.Close()
    if resp.StatusCode >= 300 {
        b, _ := io.ReadAll(resp.Body)
        return fmt.Errorf("action failed: %s", string(b))
    }
    return nil
}

type loadedMsg struct {
    nodes []node
    vms   []vm
    err   error
}

type actionMsg struct{ err error }

type model struct {
    cli    *client
    nodes  []node
    vms    []vm
    sel    int
    node   string
    status string
}

func loadCmd(c *client, n string) tea.Cmd {
    return func() tea.Msg {
        ns, err := c.listNodes()
        if err != nil {
            return loadedMsg{err: err}
        }
        nodeName := n
        if nodeName == "" && len(ns) > 0 {
            nodeName = ns[0].Node
        }
        vs, err := c.listVMs(nodeName)
        return loadedMsg{nodes: ns, vms: vs, err: err}
    }
}

func (m model) Init() tea.Cmd { return loadCmd(m.cli, "") }

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.KeyMsg:
        switch msg.String() {
        case "q", "ctrl+c":
            return m, tea.Quit
        case "r":
            return m, loadCmd(m.cli, m.node)
        case "down", "j":
            if m.sel < len(m.vms)-1 {
                m.sel++
            }
        case "up", "k":
            if m.sel > 0 {
                m.sel--
            }
        case "s": // start
            if len(m.vms) == 0 {
                return m, nil
            }
            v := m.vms[m.sel]
            return m, func() tea.Msg { return actionMsg{err: m.cli.vmAction(m.node, v.VMID, "start")} }
        case "x": // stop
            if len(m.vms) == 0 {
                return m, nil
            }
            v := m.vms[m.sel]
            return m, func() tea.Msg { return actionMsg{err: m.cli.vmAction(m.node, v.VMID, "stop")} }
        }
    case loadedMsg:
        if msg.err != nil {
            m.status = "load error: " + msg.err.Error()
            return m, nil
        }
        m.nodes = msg.nodes
        m.vms = msg.vms
        if m.node == "" && len(msg.nodes) > 0 {
            m.node = msg.nodes[0].Node
        }
        if m.sel >= len(m.vms) {
            m.sel = len(m.vms) - 1
            if m.sel < 0 {
                m.sel = 0
            }
        }
        m.status = "loaded"
    case actionMsg:
        if msg.err != nil {
            m.status = "action error: " + msg.err.Error()
            return m, nil
        }
        m.status = "action submitted"
        return m, loadCmd(m.cli, m.node)
    }
    return m, nil
}

func (m model) View() string {
    title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("39")).Render("Proxmox TUI Manager")
    s := title + "\n"
    s += fmt.Sprintf("node: %s | status: %s\n", m.node, m.status)
    s += "keys: j/k move | s start | x stop | r refresh | q quit\n\n"
    for i, v := range m.vms {
        cur := " "
        if i == m.sel {
            cur = ">"
        }
        s += fmt.Sprintf("%s vmid=%d name=%s status=%s\n", cur, v.VMID, v.Name, v.Status)
    }
    return s
}

func main() {
    base := os.Getenv("PVE_BASE_URL")
    if base == "" || os.Getenv("PVE_TOKEN") == "" {
        fmt.Println("set PVE_BASE_URL and PVE_TOKEN")
        os.Exit(2)
    }
    m := model{cli: newClient(base), status: "booting"}
    if _, err := tea.NewProgram(m).Run(); err != nil {
        panic(err)
    }
}

Run

go run .

Notes

  • This sample uses InsecureSkipVerify for lab environments. Use proper TLS validation in production.
  • Use API tokens with least privilege.

Step-by-Step Explanation

  1. Collect node/resource metrics from API sources.
  2. Compute deterministic scores per target.
  3. Rank candidates by score and policy constraints.
  4. Produce dry-run placement/migration decisions.
  5. Apply gradually with canary and rollback plan.

Code Anatomy

  • Data collection stage fetches capacity and load.
  • Scoring stage turns metrics into comparable values.
  • Decision stage emits ranked scheduling actions.

Learning Goals

  • Build scheduling logic that is explainable and auditable.
  • Balance utilization, reliability, and operational safety.
  • Prepare for production-grade orchestration workflows.