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@latestSet 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
InsecureSkipVerifyfor lab environments. Use proper TLS validation in production. - Use API tokens with least privilege.
Step-by-Step Explanation
- Collect node/resource metrics from API sources.
- Compute deterministic scores per target.
- Rank candidates by score and policy constraints.
- Produce dry-run placement/migration decisions.
- 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.