banger/internal/cli/printers.go
Thales Maciel 003b0488ce
cli,docs: trivial polish for v0.1.0
A pre-release audit collected ~12 trivial-effort UX and code-hygiene
items. Rolling them up here so the v0.1.0 commit log isn't littered
with one-line tweaks.

CLI help / completion:
  * commands_image.go: drop dangling reference to a `banger image
    catalog` subcommand that doesn't exist; replace with a pointer
    to `banger image list`.
  * commands_image.go: --size flag example was "4GiB" but the parser
    rejects that suffix. Change example to "4G". (Parser-side fix
    is in a separate concern.)
  * commands_image.go + completion.go: image pull now wires a
    catalog completer (falls back to local image names since there's
    no image-catalog RPC yet); image show / delete / promote already
    completed local names.
  * commands_kernel.go + completion.go: kernel pull now wires a new
    completeKernelCatalogNameOnlyAtPos0 backed by the kernel.catalog
    RPC, so tab-complete suggests pullable kernels.
  * commands_vm.go: vm stats and vm set now have Long + Example
    blocks (peers all do); --from flag description updated to spell
    out the relationship to --branch.

README:
  * Define "golden image" inline at first use.
  * Add a one-line Requirements block above Quick Start so users
    hit the firecracker / KVM dependency before `make build`.

Code hygiene:
  * dashIfEmpty / emptyDash were the same function. Deleted
    emptyDash, retargeted three call sites.
  * formatBytes (introduced today in image cache prune) duplicated
    humanSize. Consolidated to humanSize, now with a space ("1.2
    GiB" not "1.2GiB"). formatters_test.go expectations updated.

Logging chattiness:
  * "operation started" (logger.go), "daemon request canceled"
    (daemon.go), and "helper rpc completed" (roothelper.go) all
    fired at INFO per RPC. Downgraded to DEBUG so routine shell
    completions don't spam syslog.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:31:54 -03:00

299 lines
6.9 KiB
Go

package cli
import (
"encoding/json"
"fmt"
"io"
"os"
"sort"
"strings"
"text/tabwriter"
"banger/internal/api"
"banger/internal/cli/style"
"banger/internal/model"
"banger/internal/system"
)
// anyWriter is the minimal writer surface every printer needs. Split
// out from io.Writer because some of our callers already hold a
// tabwriter/bytes.Buffer by value.
type anyWriter interface {
Write(p []byte) (n int, err error)
}
// -- small helpers --------------------------------------------------
func humanSize(bytes int64) string {
if bytes <= 0 {
return "-"
}
const (
kib = 1024
mib = 1024 * kib
gib = 1024 * mib
)
switch {
case bytes >= gib:
return fmt.Sprintf("%.1f GiB", float64(bytes)/float64(gib))
case bytes >= mib:
return fmt.Sprintf("%.1f MiB", float64(bytes)/float64(mib))
case bytes >= kib:
return fmt.Sprintf("%.1f KiB", float64(bytes)/float64(kib))
default:
return fmt.Sprintf("%d B", bytes)
}
}
func dashIfEmpty(s string) string {
if strings.TrimSpace(s) == "" {
return "-"
}
return s
}
// -- generic printers -----------------------------------------------
func printJSON(out anyWriter, v any) error {
data, err := json.MarshalIndent(v, "", " ")
if err != nil {
return err
}
_, err = fmt.Fprintln(out, string(data))
return err
}
// -- VM printers ----------------------------------------------------
func printVMSummary(out anyWriter, vm model.VMRecord) error {
_, err := fmt.Fprintf(
out,
"%s\t%s\t%s\t%s\t%s\t%s\n",
shortID(vm.ID),
vm.Name,
vm.State,
vm.Runtime.GuestIP,
model.FormatSizeBytes(vm.Spec.WorkDiskSizeBytes),
vm.Runtime.DNSName,
)
return err
}
func printVMIDList(out anyWriter, vms []model.VMRecord) error {
for _, vm := range vms {
if _, err := fmt.Fprintln(out, vm.ID); err != nil {
return err
}
}
return nil
}
func printVMListTable(out anyWriter, vms []model.VMRecord, imageNames map[string]string) error {
w := tabwriter.NewWriter(out, 0, 8, 2, ' ', 0)
if _, err := fmt.Fprintln(w, "ID\tNAME\tSTATE\tIMAGE\tIP\tVCPU\tMEM\tDISK\tWORKSPACE\tCREATED"); err != nil {
return err
}
for _, vm := range vms {
if _, err := fmt.Fprintf(
w,
"%s\t%s\t%s\t%s\t%s\t%d\t%d MiB\t%s\t%s\t%s\n",
shortID(vm.ID),
vm.Name,
vm.State,
vmImageLabel(vm.ImageID, imageNames),
vm.Runtime.GuestIP,
vm.Spec.VCPUCount,
vm.Spec.MemoryMiB,
model.FormatSizeBytes(vm.Spec.WorkDiskSizeBytes),
dashIfEmpty(vm.Workspace.GuestPath),
relativeTime(vm.CreatedAt),
); err != nil {
return err
}
}
return w.Flush()
}
func printVMPortsTable(out anyWriter, result api.VMPortsResult) error {
type portRow struct {
Proto string
Endpoint string
Process string
Command string
Port int
}
rows := make([]portRow, 0, len(result.Ports))
for _, port := range result.Ports {
rows = append(rows, portRow{
Proto: port.Proto,
Endpoint: port.Endpoint,
Process: port.Process,
Command: port.Command,
Port: port.Port,
})
}
sort.Slice(rows, func(i, j int) bool {
if rows[i].Proto != rows[j].Proto {
return rows[i].Proto < rows[j].Proto
}
if rows[i].Port != rows[j].Port {
return rows[i].Port < rows[j].Port
}
if rows[i].Process != rows[j].Process {
return rows[i].Process < rows[j].Process
}
return rows[i].Command < rows[j].Command
})
if len(rows) == 0 {
return nil
}
w := tabwriter.NewWriter(out, 0, 8, 2, ' ', 0)
if _, err := fmt.Fprintln(w, "PROTO\tENDPOINT\tPROCESS\tCOMMAND"); err != nil {
return err
}
for _, row := range rows {
if _, err := fmt.Fprintf(
w,
"%s\t%s\t%s\t%s\n",
row.Proto,
dashIfEmpty(row.Endpoint),
dashIfEmpty(row.Process),
dashIfEmpty(row.Command),
); err != nil {
return err
}
}
return w.Flush()
}
// -- image printers -------------------------------------------------
func printImageSummary(out anyWriter, image model.Image) error {
_, err := fmt.Fprintf(out, "%s\t%s\t%t\t%s\n", shortID(image.ID), image.Name, image.Managed, image.RootfsPath)
return err
}
func imageNameIndex(images []model.Image) map[string]string {
index := make(map[string]string, len(images))
for _, image := range images {
index[image.ID] = image.Name
}
return index
}
func vmImageLabel(imageID string, imageNames map[string]string) string {
if name := strings.TrimSpace(imageNames[imageID]); name != "" {
return name
}
return shortID(imageID)
}
func printImageListTable(out anyWriter, images []model.Image) error {
w := tabwriter.NewWriter(out, 0, 8, 2, ' ', 0)
if _, err := fmt.Fprintln(w, "ID\tNAME\tMANAGED\tROOTFS SIZE\tCREATED"); err != nil {
return err
}
for _, image := range images {
if _, err := fmt.Fprintf(
w,
"%s\t%s\t%t\t%s\t%s\n",
shortID(image.ID),
image.Name,
image.Managed,
rootfsSizeLabel(image.RootfsPath),
relativeTime(image.CreatedAt),
); err != nil {
return err
}
}
return w.Flush()
}
func rootfsSizeLabel(path string) string {
info, err := os.Stat(path)
if err != nil {
return "-"
}
if info.Size() <= 0 {
return "0"
}
return model.FormatSizeBytes(info.Size())
}
// -- kernel printers ------------------------------------------------
func printKernelListTable(out anyWriter, entries []api.KernelEntry) error {
w := tabwriter.NewWriter(out, 0, 8, 2, ' ', 0)
if _, err := fmt.Fprintln(w, "NAME\tDISTRO\tARCH\tKERNEL\tIMPORTED"); err != nil {
return err
}
for _, entry := range entries {
if _, err := fmt.Fprintf(
w,
"%s\t%s\t%s\t%s\t%s\n",
entry.Name,
dashIfEmpty(entry.Distro),
dashIfEmpty(entry.Arch),
dashIfEmpty(entry.KernelVersion),
dashIfEmpty(entry.ImportedAt),
); err != nil {
return err
}
}
return w.Flush()
}
func printKernelCatalogTable(out anyWriter, entries []api.KernelCatalogEntry) error {
w := tabwriter.NewWriter(out, 0, 8, 2, ' ', 0)
if _, err := fmt.Fprintln(w, "NAME\tDISTRO\tARCH\tKERNEL\tSIZE\tSTATE"); err != nil {
return err
}
for _, entry := range entries {
state := "available"
if entry.Pulled {
state = "pulled"
}
if _, err := fmt.Fprintf(
w,
"%s\t%s\t%s\t%s\t%s\t%s\n",
entry.Name,
dashIfEmpty(entry.Distro),
dashIfEmpty(entry.Arch),
dashIfEmpty(entry.KernelVersion),
humanSize(entry.SizeBytes),
state,
); err != nil {
return err
}
}
return w.Flush()
}
// -- doctor printer -------------------------------------------------
func printDoctorReport(out anyWriter, report system.Report) error {
colorWriter, _ := out.(io.Writer)
for _, check := range report.Checks {
status := strings.ToUpper(string(check.Status))
if colorWriter != nil {
switch check.Status {
case system.CheckStatusPass:
status = style.Pass(colorWriter, status)
case system.CheckStatusFail:
status = style.Fail(colorWriter, status)
case system.CheckStatusWarn:
status = style.Warn(colorWriter, status)
}
}
if _, err := fmt.Fprintf(out, "%s\t%s\n", status, check.Name); err != nil {
return err
}
for _, detail := range check.Details {
if _, err := fmt.Fprintf(out, " - %s\n", detail); err != nil {
return err
}
}
}
return nil
}