Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b0d218174 | ||
|
|
1c14902d7c | ||
|
|
321c69ebaa |
@@ -45,7 +45,7 @@ finds it readable by others.
|
||||
| --- | --- |
|
||||
| *(nothing)* | browse the machines interactively (see below) |
|
||||
| `vm` | the same, spelled out |
|
||||
| `vm -l [-m <re>]` | print them instead; all vCenters unless `-v` names one |
|
||||
| `vm -l [-m <re>] [--sort <order>] [--reverse]` | print them instead; all vCenters unless `-v` names one |
|
||||
| `snap -l <vm>` | list a machine's snapshots |
|
||||
| `snap -n <vm>` | take a snapshot, name printed |
|
||||
| `snap -r <vm> -s <snap>` | remove one snapshot |
|
||||
@@ -84,10 +84,52 @@ are the only part anyone scrolls for. A value too long for the width is carried
|
||||
onto a continuation line under its own label rather than cut off at the edge,
|
||||
including one long word such as a datastore path, so a narrow terminal loses
|
||||
nothing. The machine's name, vCenter, datacenter and host are the title.
|
||||
^o sort the table (see below)
|
||||
^r ask the servers again
|
||||
esc clear the filter, or leave when there is none
|
||||
^c leave
|
||||
|
||||
The columns are name, vCenter, power, address, host, vCPUs, CPU load, memory and
|
||||
memory in use, and the guest's operating system. The two load figures are
|
||||
percentages of what the machine is allowed to use and of what it has configured;
|
||||
a machine that is not running has no load rather than a load of zero and shows a
|
||||
dash. Past 75 % they turn yellow, past 90 % red.
|
||||
|
||||
A terminal too narrow for all of that gives columns up, least useful first: the
|
||||
guest's operating system, then the host, then the address, then the vCPU count —
|
||||
so what survives longest is what a glance is for. Each step only ever takes a
|
||||
column away, never brings one back, so dragging a window narrower does not
|
||||
rearrange the table. An eighty-column terminal keeps everything but the operating
|
||||
system and the host.
|
||||
|
||||
### Sorting
|
||||
|
||||
`^o` puts a legend on the status line and the next key picks the order, so the
|
||||
list stays on screen while it rearranges itself:
|
||||
|
||||
sort: n·name p·power c·cpu% m·mem% s·size u·cpus v·vc h·host a·ip r·reverse
|
||||
|
||||
Each order comes with its own direction, because that is what asking for it
|
||||
means: by name is a to z, by processor load is the busiest first. `r` reverses
|
||||
whatever is current. Anything that is not a choice — Esc, a stray letter — leaves
|
||||
the table as it was.
|
||||
|
||||
The title says which order the table is in (`↓ cpu load`) and the heading of that
|
||||
column is lit in the same colour, so the state is visible without asking. A
|
||||
missing value is never a small one: a stopped machine has no load and a machine
|
||||
whose guest is silent has no address, and both sort to the bottom whichever
|
||||
direction the order runs. Machines that compare equal stay in name order, so
|
||||
flipping the direction on a screen full of identical figures does not reshuffle
|
||||
them.
|
||||
|
||||
The order survives `^r`, and the selection follows the machine it was on. `gvm vm
|
||||
-l --sort cpu% --reverse` takes the same orders by letter or by name.
|
||||
|
||||
A vCenter that does not answer is named in the title in red — `on v308, v108
|
||||
v38 unreachable` — for as long as the list is open, and the reason is on the
|
||||
status line when it opens. Only the servers whose machines are actually there are
|
||||
named as holding them.
|
||||
|
||||
Nothing acts on a machine from the table. Everything that changes one lives in
|
||||
the machine's own sheet, which `⏎` opens — a row of a table of two hundred
|
||||
machines is something the eye runs past, not something anyone has read. In the
|
||||
@@ -206,6 +248,10 @@ things hold for all of them:
|
||||
One power operation per command line; two is a mistake, not a sequence, and gvm
|
||||
says so instead of guessing.
|
||||
|
||||
The printed listing (`vm -l`) is the same table: the same columns, the same
|
||||
cells, the same colours, fitted to the terminal when there is one and written out
|
||||
in full into a pipe, where the colours are left off.
|
||||
|
||||
## Colours
|
||||
|
||||
The palette is [mwxcol](https://git.micw.org/mike/mwxcol), copied into
|
||||
|
||||
+110
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -644,3 +645,112 @@ func renderToPipe(t *testing.T, b *browser, draw func()) string {
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// A control sequence has to be swallowed whole. Reading a fixed number of bytes
|
||||
// left the rest of a longer one in the stream, where the next read took it for
|
||||
// typing: Ctrl-Up put "5A" into the filter and F5 put a tilde in it, having first
|
||||
// jumped to the top of the list.
|
||||
func TestEscapeSequencesAreConsumedWhole(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
send string
|
||||
want specialKey
|
||||
note string
|
||||
}{
|
||||
{"\x1b[A", keyUp, "up"},
|
||||
{"\x1b[B", keyDown, "down"},
|
||||
{"\x1b[C", keyRight, "right"},
|
||||
{"\x1b[D", keyLeft, "left"},
|
||||
{"\x1b[H", keyHome, "home"},
|
||||
{"\x1b[F", keyEnd, "end"},
|
||||
{"\x1b[Z", keyShiftTab, "shift-tab"},
|
||||
{"\x1b[3~", keyDelete, "delete"},
|
||||
{"\x1b[5~", keyPgUp, "page up"},
|
||||
{"\x1b[6~", keyPgDn, "page down"},
|
||||
|
||||
// Modified arrows and function keys: not answered, but not leaked either.
|
||||
{"\x1b[1;5A", keyNone, "ctrl-up"},
|
||||
{"\x1b[1;2D", keyNone, "shift-left"},
|
||||
{"\x1b[15~", keyNone, "F5"},
|
||||
{"\x1b[200~", keyNone, "a bracketed paste opening"},
|
||||
{"\x1b[<0;10;20M", keyNone, "a mouse report"},
|
||||
} {
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
kr := newKeyReader(r)
|
||||
w.WriteString(c.send + "x") // an "x" behind it, to see what was left over
|
||||
|
||||
if got := kr.next(); got.special != c.want {
|
||||
t.Errorf("%s (%q): decoded as %v, want %v", c.note, c.send, got.special, c.want)
|
||||
}
|
||||
// Whatever the sequence was, the very next key must be the x — nothing of
|
||||
// the sequence may arrive as text.
|
||||
next := kr.next()
|
||||
if next.special != keyRune || next.r != 'x' {
|
||||
t.Errorf("%s (%q): the next key is %v/%q, want the x — part of the sequence leaked",
|
||||
c.note, c.send, next.special, next.r)
|
||||
}
|
||||
w.Close()
|
||||
r.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// A lone Esc is still a lone Esc: it is how every dangerous question is abandoned.
|
||||
func TestLoneEscapeIsStillEscape(t *testing.T) {
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer r.Close()
|
||||
defer w.Close()
|
||||
|
||||
kr := newKeyReader(r)
|
||||
w.WriteString("\x1b")
|
||||
if got := kr.next(); got.special != keyEsc {
|
||||
t.Errorf("a lone Esc decoded as %v", got.special)
|
||||
}
|
||||
}
|
||||
|
||||
// Every vCenter task gvm waits for has to be waited for with a bound. This is a
|
||||
// property of the source rather than of anything a test can provoke — a task that
|
||||
// hangs is exactly what no simulator will do — so the source is what is checked.
|
||||
//
|
||||
// Taking a snapshot was the one that got away: it waited on the session's own
|
||||
// context, which has no deadline, and would have frozen the interactive list
|
||||
// mid-draw with no key being read.
|
||||
func TestEveryTaskWaitIsBounded(t *testing.T) {
|
||||
files, err := filepath.Glob("*.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
found := 0
|
||||
for _, name := range files {
|
||||
if strings.HasSuffix(name, "_test.go") {
|
||||
continue
|
||||
}
|
||||
src, err := os.ReadFile(name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, line := range strings.Split(string(src), "\n") {
|
||||
code := strings.TrimSpace(line)
|
||||
if !strings.Contains(code, ".Wait(") || strings.HasPrefix(code, "//") {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(code, "wg.Wait()") {
|
||||
continue // a WaitGroup, not a vCenter task
|
||||
}
|
||||
found++
|
||||
// The one place a task may be waited on is inside waitTask, which
|
||||
// gives it a deadline of its own.
|
||||
if name != "power.go" || !strings.Contains(code, "task.Wait(wctx)") {
|
||||
t.Errorf("%s:%d waits on a task outside waitTask: %s", name, i+1, code)
|
||||
}
|
||||
}
|
||||
}
|
||||
if found == 0 {
|
||||
t.Error("no task wait found at all — this check has stopped checking anything")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,10 +68,12 @@ var (
|
||||
colAside = cGrey.fg() // present, seldom read: host, guest os, uuids
|
||||
colOK = cGreen.fg()
|
||||
colOff = cDark.fg()
|
||||
colBusy = cYellow.fg() // filling up
|
||||
colFull = cRed.fg() // full
|
||||
)
|
||||
|
||||
const (
|
||||
listHelp = "type to filter ↑/↓ move ⏎ details ^r reload esc clear/quit ^c quit"
|
||||
listHelp = "type to filter ↑/↓ move ⏎ details ^o sort ^r reload esc clear/quit"
|
||||
detailHelp = "↑/↓ scroll ^a actions ^s snapshot esc/⏎ back ^c quit"
|
||||
gutter = 2 // the pointer's two columns, in front of every row
|
||||
colSep = 2
|
||||
@@ -89,6 +91,12 @@ type vmRow struct {
|
||||
vm mo.VirtualMachine
|
||||
}
|
||||
|
||||
// id is what makes this machine this machine. A name does not: two vCenters may
|
||||
// each hold a "web01", and following the selection by name alone moved the cursor
|
||||
// to the other server's machine whenever sorting or filtering brought that one up
|
||||
// first. A reference is unique within its server, so the two together are unique.
|
||||
func (r vmRow) id() string { return r.vc.Name + "/" + r.ref.Value }
|
||||
|
||||
func (r vmRow) power() types.VirtualMachinePowerState { return r.vm.Summary.Runtime.PowerState }
|
||||
|
||||
func (r vmRow) powerShort() string {
|
||||
@@ -147,6 +155,51 @@ func (r vmRow) memory() string {
|
||||
return units.ByteSize(int64(r.vm.Summary.Config.MemorySizeMB) * 1024 * 1024).String()
|
||||
}
|
||||
|
||||
// cpuLoad is how busy the machine's processors are, as a share of what it is
|
||||
// allowed to use. A machine that is not running has no load rather than a load of
|
||||
// zero, and the second return value says which of the two it is — printing 0 for a
|
||||
// stopped machine would read as "idle" when it means "not there".
|
||||
func (r vmRow) cpuLoad() (float64, bool) {
|
||||
limit := r.vm.Summary.Runtime.MaxCpuUsage
|
||||
if !r.running() || limit <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return 100.0 / float64(limit) * float64(r.vm.Summary.QuickStats.OverallCpuUsage), true
|
||||
}
|
||||
|
||||
// memLoad is how much of its configured memory the guest is actually working
|
||||
// with — the figure that says whether a machine is sized right, and the same one
|
||||
// the sheet shows as "guest".
|
||||
func (r vmRow) memLoad() (float64, bool) {
|
||||
size := r.vm.Summary.Config.MemorySizeMB
|
||||
if !r.running() || size <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return 100.0 / float64(size) * float64(r.vm.Summary.QuickStats.GuestMemoryUsage), true
|
||||
}
|
||||
|
||||
// loadCell and loadColor turn one of those into a column. Percentages carry no
|
||||
// sign: the header has it, and four characters are worth more than a symbol
|
||||
// repeated down the page.
|
||||
func loadCell(pct float64, known bool) string {
|
||||
if !known {
|
||||
return "-"
|
||||
}
|
||||
return SF("%.0f", pct)
|
||||
}
|
||||
|
||||
func loadColor(pct float64, known bool) string {
|
||||
switch {
|
||||
case !known:
|
||||
return colOff
|
||||
case pct >= 90:
|
||||
return colFull
|
||||
case pct >= 75:
|
||||
return colBusy
|
||||
}
|
||||
return colSize
|
||||
}
|
||||
|
||||
// haystack is what the filter matches against: everything on the line, so typing
|
||||
// an address or a host name narrows the list just as well as a name does.
|
||||
func (r vmRow) haystack() string {
|
||||
@@ -163,6 +216,16 @@ type browseColumn struct {
|
||||
flex bool // may grow into whatever width is left over
|
||||
cell func(vmRow) string // the text
|
||||
color func(vmRow) string // its colour, nil to leave it the row's own
|
||||
|
||||
// expendable is the order in which columns are given up on a terminal too
|
||||
// narrow for all of them: 1 goes first. Zero means never — the name and the
|
||||
// power state are what a list of machines is for.
|
||||
//
|
||||
// It is deliberately separate from the order the columns are shown in. The
|
||||
// two used to be the same thing, so what a narrow terminal lost was simply
|
||||
// whatever happened to be listed last, and the memory figures went before the
|
||||
// guest's operating system did.
|
||||
expendable int
|
||||
}
|
||||
|
||||
// browseColumns in display order, which is also priority order: as many as fit
|
||||
@@ -170,33 +233,68 @@ type browseColumn struct {
|
||||
func fixed(c string) func(vmRow) string { return func(vmRow) string { return c } }
|
||||
|
||||
var browseColumns = []browseColumn{
|
||||
{header: "NAME", width: 24, flex: true, color: fixed(colName),
|
||||
// 22 is the name's minimum, not its width: it grows into whatever is left
|
||||
// over. One notch narrower than it reads, because it buys the address column
|
||||
// its place on a terminal of eighty.
|
||||
{header: "NAME", width: 22, flex: true, color: fixed(colName),
|
||||
cell: func(r vmRow) string { return r.name }},
|
||||
{header: "VC", width: 4, color: fixed(colWhere),
|
||||
{header: "VC", width: 4, color: fixed(colWhere), expendable: 8,
|
||||
cell: func(r vmRow) string { return r.vc.Name }},
|
||||
{header: "PWR", width: 4, cell: vmRow.powerShort, color: vmRow.powerColor},
|
||||
{header: "IP", width: 15, cell: vmRow.ip, color: vmRow.addressColor},
|
||||
{header: "HOST", width: 10, color: fixed(colAside),
|
||||
// The address is the widest thing here that is not a name, and it is a
|
||||
// lookup field: on a terminal this narrow nobody is looking an address up,
|
||||
// they are glancing at what is busy. So it goes before the small figures do.
|
||||
{header: "IP", width: 15, cell: vmRow.ip, color: vmRow.addressColor, expendable: 3},
|
||||
{header: "HOST", width: 10, color: fixed(colAside), expendable: 2,
|
||||
cell: func(r vmRow) string { return r.host }},
|
||||
{header: "CPU", width: 3, color: fixed(colSize),
|
||||
{header: "CPU", width: 3, color: fixed(colSize), expendable: 4,
|
||||
cell: func(r vmRow) string { return Itoa(int(r.vm.Summary.Config.NumCpu)) }},
|
||||
{header: "MEM", width: 7, cell: vmRow.memory, color: fixed(colSize)},
|
||||
{header: "GUEST OS", width: 18, flex: true, cell: vmRow.guestOS, color: fixed(colAside)},
|
||||
{header: "CPU%", width: 4, expendable: 7,
|
||||
cell: func(r vmRow) string { return loadCell(r.cpuLoad()) },
|
||||
color: func(r vmRow) string { return loadColor(r.cpuLoad()) }},
|
||||
{header: "MEM", width: 7, cell: vmRow.memory, color: fixed(colSize), expendable: 5},
|
||||
{header: "MEM%", width: 4, expendable: 6,
|
||||
cell: func(r vmRow) string { return loadCell(r.memLoad()) },
|
||||
color: func(r vmRow) string { return loadColor(r.memLoad()) }},
|
||||
{header: "GUEST OS", width: 18, flex: true, cell: vmRow.guestOS, color: fixed(colAside),
|
||||
expendable: 1},
|
||||
}
|
||||
|
||||
// fitColumns takes columns from the left while they fit, then hands the width
|
||||
// left over to the ones allowed to grow.
|
||||
// fitColumns decides which columns a terminal of this width shows: give up the
|
||||
// least useful until what is left fits, then hand the width left over to the ones
|
||||
// allowed to grow. The columns keep the order they are declared in; what changes
|
||||
// is which of them are there.
|
||||
//
|
||||
// Giving up strictly in the declared order of expendability is what makes the
|
||||
// layout behave when a terminal is dragged narrower: each step is a subset of the
|
||||
// one before, so columns only ever disappear. Taking a dropped column back
|
||||
// because the space freed by a later one now fits it — which sounds like an
|
||||
// improvement — makes a narrower terminal show *more* columns than a wider one,
|
||||
// and a column that appears as the window shrinks is worse than a column that is
|
||||
// simply gone. Where a column deserves to survive longer, its rank says so.
|
||||
func fitColumns(width int) []browseColumn {
|
||||
var chosen []browseColumn
|
||||
used := 0
|
||||
for _, c := range browseColumns {
|
||||
if used+c.width+colSep > width {
|
||||
break
|
||||
}
|
||||
chosen = append(chosen, c)
|
||||
used += c.width + colSep
|
||||
shown := make([]bool, len(browseColumns))
|
||||
for i := range shown {
|
||||
shown[i] = true
|
||||
}
|
||||
if len(chosen) == 0 { // a very narrow terminal still gets the name
|
||||
|
||||
for shownWidth(shown) > width {
|
||||
i := leastUseful(shown)
|
||||
if i < 0 {
|
||||
break // nothing left that may be given up
|
||||
}
|
||||
shown[i] = false
|
||||
}
|
||||
chosen := make([]browseColumn, 0, len(browseColumns))
|
||||
for i, c := range browseColumns {
|
||||
if shown[i] {
|
||||
chosen = append(chosen, c)
|
||||
}
|
||||
}
|
||||
|
||||
// A terminal too narrow even for what may not be given up: the name, cut to
|
||||
// whatever there is. Better one column of truth than a row that wraps.
|
||||
if tableWidth(chosen) > width {
|
||||
first := browseColumns[0]
|
||||
first.width = max(width, 1)
|
||||
return []browseColumn{first}
|
||||
@@ -209,7 +307,7 @@ func fitColumns(width int) []browseColumn {
|
||||
}
|
||||
}
|
||||
if flex > 0 {
|
||||
if extra := (width - used) / flex; extra > 0 {
|
||||
if extra := (width - tableWidth(chosen)) / flex; extra > 0 {
|
||||
for i := range chosen {
|
||||
if chosen[i].flex {
|
||||
chosen[i].width += extra
|
||||
@@ -220,12 +318,72 @@ func fitColumns(width int) []browseColumn {
|
||||
return chosen
|
||||
}
|
||||
|
||||
// tableWidth is what these columns occupy: their own widths plus one gap between
|
||||
// each pair. No trailing gap — the last column ends the line.
|
||||
func tableWidth(cs []browseColumn) int {
|
||||
if len(cs) == 0 {
|
||||
return 0
|
||||
}
|
||||
w := colSep * (len(cs) - 1)
|
||||
for _, c := range cs {
|
||||
w += c.width
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
func shownWidth(shown []bool) int {
|
||||
n, w := 0, 0
|
||||
for i, on := range shown {
|
||||
if on {
|
||||
n++
|
||||
w += browseColumns[i].width
|
||||
}
|
||||
}
|
||||
if n == 0 {
|
||||
return 0
|
||||
}
|
||||
return w + colSep*(n-1)
|
||||
}
|
||||
|
||||
// leastUseful is the column to give up next, or -1 when every one that is left
|
||||
// may not be.
|
||||
func leastUseful(shown []bool) int {
|
||||
at, worst := -1, 0
|
||||
for i, on := range shown {
|
||||
e := browseColumns[i].expendable
|
||||
if !on || e == 0 {
|
||||
continue
|
||||
}
|
||||
if at < 0 || e < worst {
|
||||
at, worst = i, e
|
||||
}
|
||||
}
|
||||
return at
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------- reading
|
||||
|
||||
// gatherVMs asks every server at once and returns the machines of all of them,
|
||||
// along with the sessions they were read over — the caller owns those and closes
|
||||
// them. A server that does not answer costs a warning, not the listing.
|
||||
func gatherVMs(targets []VCenter) ([]vmRow, []*session, error) {
|
||||
// sweep is what one pass over the configured servers found: the machines, the
|
||||
// connections they were read over, which servers answered, and which did not and
|
||||
// why.
|
||||
//
|
||||
// The failures used to be printed on the spot and forgotten. In the printed list
|
||||
// that was fine; in the interactive one the message was wiped by the first screen
|
||||
// a moment later, and the title went on naming the server as though its machines
|
||||
// were in the list. A vCenter that is down has to be visible in the thing that is
|
||||
// meant to show every vCenter — so the sweep hands its failures back and lets the
|
||||
// caller show them where they will be read.
|
||||
type sweep struct {
|
||||
rows []vmRow
|
||||
sessions []*session
|
||||
answered []string // the servers that did
|
||||
failed []string // the ones that did not, each with its reason
|
||||
lost []string // their names alone, for the title
|
||||
}
|
||||
|
||||
// gatherVMs asks every server at once and returns what came back. A server that
|
||||
// does not answer costs a line in the report, not the listing.
|
||||
func gatherVMs(targets []VCenter) (sweep, error) {
|
||||
type result struct {
|
||||
rows []vmRow
|
||||
sess *session
|
||||
@@ -243,32 +401,31 @@ func gatherVMs(targets []VCenter) ([]vmRow, []*session, error) {
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
var rows []vmRow
|
||||
var sessions []*session
|
||||
failed := 0
|
||||
var s sweep
|
||||
for i := range res {
|
||||
if res[i].sess != nil {
|
||||
sessions = append(sessions, res[i].sess)
|
||||
s.sessions = append(s.sessions, res[i].sess)
|
||||
}
|
||||
if res[i].err != nil {
|
||||
PE(res[i].err.Error())
|
||||
failed++
|
||||
s.failed = append(s.failed, res[i].err.Error())
|
||||
s.lost = append(s.lost, targets[i].Name)
|
||||
continue
|
||||
}
|
||||
rows = append(rows, res[i].rows...)
|
||||
s.answered = append(s.answered, targets[i].Name)
|
||||
s.rows = append(s.rows, res[i].rows...)
|
||||
}
|
||||
if failed == len(targets) {
|
||||
return nil, sessions, errf("no vCenter answered")
|
||||
if len(s.answered) == 0 {
|
||||
return s, errf("no vCenter answered")
|
||||
}
|
||||
|
||||
sort.Slice(rows, func(a, b int) bool {
|
||||
x, y := strings.ToLower(rows[a].name), strings.ToLower(rows[b].name)
|
||||
sort.Slice(s.rows, func(a, b int) bool {
|
||||
x, y := strings.ToLower(s.rows[a].name), strings.ToLower(s.rows[b].name)
|
||||
if x != y {
|
||||
return x < y
|
||||
}
|
||||
return rows[a].vc.Name < rows[b].vc.Name
|
||||
return s.rows[a].vc.Name < s.rows[b].vc.Name
|
||||
})
|
||||
return rows, sessions, nil
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// gatherOne is one server: the machines, and the names of the hosts their
|
||||
@@ -328,6 +485,12 @@ type browser struct {
|
||||
sel int // index into view
|
||||
scroll int
|
||||
|
||||
sortBy int // which of sortOrders the rows are in (sort.go)
|
||||
sortDesc bool // that order reversed
|
||||
|
||||
answered []string // the vCenters this list actually holds
|
||||
lost []string // and the ones it does not, because they did not answer
|
||||
|
||||
detail []sheetLine // non-nil while a machine's sheet is on screen
|
||||
dtitle string
|
||||
dvc string // the vCenter, in the title, in the colour its column has
|
||||
@@ -348,7 +511,7 @@ type browser struct {
|
||||
|
||||
status string
|
||||
statusCol string
|
||||
prompt string // a question waiting for one key, in place of the status
|
||||
prompt *prompt // a question waiting for one key, in place of the status
|
||||
edit *editor // a line being typed there, same place
|
||||
|
||||
tty *os.File
|
||||
@@ -370,12 +533,16 @@ func browseVMs(targets []VCenter, filter string) error {
|
||||
defer func() { closeSessions(b.sessions) }()
|
||||
|
||||
PF("asking %s ...\n", vcNames(targets))
|
||||
rows, sessions, err := gatherVMs(targets)
|
||||
b.rows, b.sessions = rows, sessions
|
||||
found, err := gatherVMs(targets)
|
||||
b.rows, b.sessions = found.rows, found.sessions
|
||||
b.answered, b.lost = found.answered, found.lost
|
||||
if err != nil {
|
||||
for _, why := range found.failed {
|
||||
PE(why)
|
||||
}
|
||||
return err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
if len(found.rows) == 0 && len(found.failed) == 0 {
|
||||
return errf("no virtual machines found")
|
||||
}
|
||||
|
||||
@@ -384,7 +551,14 @@ func browseVMs(targets []VCenter, filter string) error {
|
||||
}
|
||||
defer b.close()
|
||||
|
||||
b.refilter()
|
||||
b.applySort() // by name, ascending: sortOrders lists that one first
|
||||
|
||||
// Said once, before the first keystroke clears it. The title keeps the short
|
||||
// version for as long as the list is open.
|
||||
if len(found.failed) > 0 {
|
||||
b.setStatus(colErr, strings.Join(found.failed, " · "))
|
||||
}
|
||||
|
||||
b.loop()
|
||||
return nil
|
||||
}
|
||||
@@ -491,6 +665,8 @@ func (b *browser) listKey(k key) bool {
|
||||
b.move(len(b.view))
|
||||
case keyEnter:
|
||||
b.openDetail()
|
||||
case keyCtrlO:
|
||||
b.sortPrompt()
|
||||
case keyCtrlA, keyCtrlS:
|
||||
// Nothing acts on a machine from the list. The cursor sits on a row that
|
||||
// is one line of a table, and a table of two hundred machines is read by
|
||||
@@ -552,7 +728,7 @@ func (b *browser) detailKey(k key) {
|
||||
func (b *browser) refilter() {
|
||||
keep := ""
|
||||
if cur := b.current(); cur != nil {
|
||||
keep = cur.name
|
||||
keep = cur.id()
|
||||
}
|
||||
|
||||
needle := strings.ToLower(strings.TrimSpace(b.filter))
|
||||
@@ -566,7 +742,7 @@ func (b *browser) refilter() {
|
||||
b.sel, b.scroll = 0, 0
|
||||
if keep != "" {
|
||||
for i, idx := range b.view {
|
||||
if b.rows[idx].name == keep {
|
||||
if b.rows[idx].id() == keep {
|
||||
b.sel = i
|
||||
break
|
||||
}
|
||||
@@ -594,17 +770,23 @@ func (b *browser) reload() {
|
||||
b.setStatus(colWarn, "reloading ...")
|
||||
b.render()
|
||||
|
||||
rows, sessions, err := gatherVMs(b.targets)
|
||||
found, err := gatherVMs(b.targets)
|
||||
if err != nil {
|
||||
closeSessions(sessions)
|
||||
closeSessions(found.sessions)
|
||||
b.setStatus(colErr, err.Error())
|
||||
return
|
||||
}
|
||||
old := b.sessions
|
||||
b.rows, b.sessions = rows, sessions
|
||||
b.refilter()
|
||||
b.rows, b.sessions = found.rows, found.sessions
|
||||
b.answered, b.lost = found.answered, found.lost
|
||||
b.applySort() // which refilters; the order the table was in survives a reload
|
||||
closeSessions(old)
|
||||
b.setStatus(colInfo, SF("reloaded: %d machines", len(rows)))
|
||||
|
||||
if len(found.failed) > 0 {
|
||||
b.setStatus(colErr, strings.Join(found.failed, " · "))
|
||||
return
|
||||
}
|
||||
b.setStatus(colInfo, SF("reloaded: %d machines", len(found.rows)))
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ snapshots
|
||||
@@ -749,13 +931,24 @@ func (b *browser) input(label string) (string, bool) {
|
||||
}
|
||||
}
|
||||
|
||||
// prompt is a question waiting for a single key, shown where the status line
|
||||
// would be. The hint and the colour travel with the question rather than being
|
||||
// assumed by the renderer: the sort legend is also a one-key question, and it is
|
||||
// neither a warning nor answerable with y — which is exactly what the renderer
|
||||
// used to append to it.
|
||||
type prompt struct {
|
||||
text string
|
||||
hint string
|
||||
col string
|
||||
}
|
||||
|
||||
// ask puts one question on the status line and waits for a single key. Only "y"
|
||||
// means yes — every other key, Esc and Ctrl-C included, means no.
|
||||
func (b *browser) ask(question string) bool {
|
||||
b.prompt = question
|
||||
b.prompt = &prompt{text: question, hint: " y = yes, anything else = no", col: colWarn}
|
||||
b.render()
|
||||
k := b.keys.next()
|
||||
b.prompt = ""
|
||||
b.prompt = nil
|
||||
return k.special == keyRune && (k.r == 'y' || k.r == 'Y')
|
||||
}
|
||||
|
||||
@@ -800,12 +993,25 @@ func (b *browser) renderList() {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(scrClear + scrHide)
|
||||
|
||||
segLine(&sb, cols,
|
||||
seg{colTitle, "gvm " + version},
|
||||
// Only the servers whose machines are actually in the list are named as
|
||||
// holding them; the ones that did not answer are named as missing, in red, for
|
||||
// as long as the list is open.
|
||||
title := []seg{
|
||||
{colTitle, "gvm " + version},
|
||||
{colDim, " "},
|
||||
{colInfo, SF("%d machines", len(b.rows))},
|
||||
{colDim, " on "},
|
||||
{colWhere, strings.Join(b.answered, ", ")}, // the colour the VC column has
|
||||
}
|
||||
if len(b.lost) > 0 {
|
||||
title = append(title,
|
||||
seg{colDim, " "},
|
||||
seg{colErr, strings.Join(b.lost, ", ") + " unreachable"})
|
||||
}
|
||||
title = append(title,
|
||||
seg{colDim, " "},
|
||||
seg{colInfo, SF("%d machines", len(b.rows))},
|
||||
seg{colDim, " on "},
|
||||
seg{colWhere, vcNames(b.targets)}) // the colour the VC column has
|
||||
seg{colMatch, b.sortLabel()}) // pink: the one thing here that was chosen
|
||||
segLine(&sb, cols, title...)
|
||||
|
||||
if b.filter == "" {
|
||||
segLine(&sb, cols, seg{colDim, "filter: (type to narrow the list)"})
|
||||
@@ -818,12 +1024,18 @@ func (b *browser) renderList() {
|
||||
seg{colInfo, SF("%d of %d", len(b.view), len(b.rows))})
|
||||
}
|
||||
|
||||
header := make([]string, len(cs))
|
||||
headings := []seg{{colHeader, strings.Repeat(" ", gutter)}}
|
||||
for i, c := range cs {
|
||||
header[i] = padRight(truncate(c.header, c.width), c.width)
|
||||
if i > 0 {
|
||||
headings = append(headings, seg{colHeader, strings.Repeat(" ", colSep)})
|
||||
}
|
||||
col := colHeader
|
||||
if b.sortedColumn(c.header) {
|
||||
col = colMatch // the same pink the title uses for the order
|
||||
}
|
||||
headings = append(headings, seg{col, padRight(truncate(c.header, c.width), c.width)})
|
||||
}
|
||||
segLine(&sb, cols, seg{colHeader, strings.Repeat(" ", gutter) +
|
||||
strings.Join(header, strings.Repeat(" ", colSep))})
|
||||
segLine(&sb, cols, headings...)
|
||||
|
||||
for i := b.scroll; i < end; i++ {
|
||||
b.rowLine(&sb, cols, cs, b.rows[b.view[i]], i == b.sel)
|
||||
@@ -835,10 +1047,10 @@ func (b *browser) renderList() {
|
||||
switch {
|
||||
case b.edit != nil:
|
||||
b.editLine(&sb, cols)
|
||||
case b.prompt != "":
|
||||
case b.prompt != nil:
|
||||
segLine(&sb, cols,
|
||||
seg{colWarn, b.prompt},
|
||||
seg{colDim, " y = yes, anything else = no"})
|
||||
seg{b.prompt.col, b.prompt.text},
|
||||
seg{colDim, b.prompt.hint})
|
||||
case b.status != "":
|
||||
segLine(&sb, cols, seg{b.statusCol, b.status})
|
||||
case len(b.view) == 0:
|
||||
@@ -990,10 +1202,10 @@ func (b *browser) renderDetail() {
|
||||
switch {
|
||||
case b.edit != nil:
|
||||
b.editLine(&sb, cols)
|
||||
case b.prompt != "":
|
||||
case b.prompt != nil:
|
||||
segLine(&sb, cols,
|
||||
seg{colWarn, b.prompt},
|
||||
seg{colDim, " y = yes, anything else = no"})
|
||||
seg{b.prompt.col, b.prompt.text},
|
||||
seg{colDim, b.prompt.hint})
|
||||
case b.status != "":
|
||||
segLine(&sb, cols, seg{b.statusCol, b.status})
|
||||
case len(lines)-end > 0:
|
||||
|
||||
+385
-6
@@ -1,10 +1,14 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/vmware/govmomi/vim25/mo"
|
||||
"github.com/vmware/govmomi/vim25/types"
|
||||
)
|
||||
@@ -57,7 +61,12 @@ func testRow(name string, on bool, ip string) vmRow {
|
||||
func testBrowser(names ...string) *browser {
|
||||
b := &browser{targets: []VCenter{{Name: "v308"}}}
|
||||
for i, n := range names {
|
||||
b.rows = append(b.rows, testRow(n, i%2 == 0, "10.0.0."+Itoa(i+1)))
|
||||
r := testRow(n, i%2 == 0, "10.0.0."+Itoa(i+1))
|
||||
// A reference of its own for each: testRow hands out one fixed vm-42,
|
||||
// which is fine for a machine on its own and useless for a list, where
|
||||
// the selection follows a machine by its reference.
|
||||
r.ref = types.ManagedObjectReference{Type: "VirtualMachine", Value: "vm-" + Itoa(i)}
|
||||
b.rows = append(b.rows, r)
|
||||
}
|
||||
b.refilter()
|
||||
return b
|
||||
@@ -384,16 +393,28 @@ func TestHighlight(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// stripEscapes removes every control sequence, not only the colours. It used to
|
||||
// stop at the first "m", which ends a colour and nothing else — so on a whole
|
||||
// frame it ran past the "\x1b[K" that ends each line, swallowed the line break
|
||||
// with it, and handed back one very long line.
|
||||
//
|
||||
// A sequence is ESC [ then parameters then one final byte in 0x40..0x7e, which is
|
||||
// what "m" (colour), "K" (clear to end of line) and "H" (move the cursor) are.
|
||||
func stripEscapes(s string) string {
|
||||
var out strings.Builder
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == 0x1b {
|
||||
for i < len(s) && s[i] != 'm' {
|
||||
i++
|
||||
}
|
||||
if s[i] != 0x1b {
|
||||
out.WriteByte(s[i])
|
||||
continue
|
||||
}
|
||||
out.WriteByte(s[i])
|
||||
i++
|
||||
if i < len(s) && s[i] == '[' {
|
||||
i++ // the bracket is itself in the final-byte range, so step over it
|
||||
}
|
||||
for i < len(s) && (s[i] < 0x40 || s[i] > 0x7e) {
|
||||
i++
|
||||
}
|
||||
// i is on the final byte; the loop's own i++ moves past it
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
@@ -746,3 +767,361 @@ func TestSheetValuesAreColouredAndWrapKeepsIt(t *testing.T) {
|
||||
t.Fatal("the sheet has no path line")
|
||||
}
|
||||
}
|
||||
|
||||
// What a narrow terminal gives up, and in what order. The guest's operating
|
||||
// system goes first; the name and the power state never go at all.
|
||||
func TestNarrowTerminalGivesUpTheRightColumns(t *testing.T) {
|
||||
headers := func(cs []browseColumn) []string {
|
||||
out := make([]string, len(cs))
|
||||
for i, c := range cs {
|
||||
out[i] = c.header
|
||||
}
|
||||
return out
|
||||
}
|
||||
has := func(cs []browseColumn, header string) bool {
|
||||
return slices.Contains(headers(cs), header)
|
||||
}
|
||||
|
||||
// Everything fits somewhere wide.
|
||||
wide := fitColumns(200)
|
||||
if len(wide) != len(browseColumns) {
|
||||
t.Fatalf("a wide terminal shows %v, not all of them", headers(wide))
|
||||
}
|
||||
|
||||
// The first thing to go is the guest's operating system, before any figure.
|
||||
oneLess := fitColumns(tableWidth(browseColumns) - 1)
|
||||
if has(oneLess, "GUEST OS") {
|
||||
t.Errorf("the first column given up was not GUEST OS: %v", headers(oneLess))
|
||||
}
|
||||
if len(oneLess) != len(browseColumns)-1 {
|
||||
t.Errorf("more than one column was given up: %v", headers(oneLess))
|
||||
}
|
||||
for _, keep := range []string{"CPU%", "MEM%", "MEM", "CPU", "IP", "HOST"} {
|
||||
if !has(oneLess, keep) {
|
||||
t.Errorf("%s was given up before GUEST OS: %v", keep, headers(oneLess))
|
||||
}
|
||||
}
|
||||
|
||||
// The set only ever shrinks as the terminal narrows, and it shrinks in the
|
||||
// order declared.
|
||||
prev := len(browseColumns)
|
||||
for width := 200; width >= 10; width-- {
|
||||
cs := fitColumns(width)
|
||||
if len(cs) > prev {
|
||||
t.Fatalf("%d columns wide shows %d columns, wider showed %d", width, len(cs), prev)
|
||||
}
|
||||
prev = len(cs)
|
||||
|
||||
if tableWidth(cs) > width {
|
||||
t.Errorf("%d columns wide needs %d", width, tableWidth(cs))
|
||||
}
|
||||
if !has(cs, "NAME") {
|
||||
t.Errorf("%d columns wide has no NAME: %v", width, headers(cs))
|
||||
}
|
||||
// Below the point where even the mandatory columns fit, the name alone
|
||||
// stands; above it the power state is always there.
|
||||
if len(cs) > 1 && !has(cs, "PWR") {
|
||||
t.Errorf("%d columns wide gave up PWR: %v", width, headers(cs))
|
||||
}
|
||||
// A percentage is never shown without the header that says it is one.
|
||||
for _, c := range cs {
|
||||
if c.header == "CPU%" || c.header == "MEM%" {
|
||||
if c.width < 4 {
|
||||
t.Errorf("%d columns wide: %s is only %d wide, too narrow for 100", width, c.header, c.width)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The load figures themselves: a running machine has one, a stopped machine has
|
||||
// none, and none is not zero.
|
||||
func TestLoadCells(t *testing.T) {
|
||||
r := testRow("web01", true, "10.0.0.5")
|
||||
// 420 of 8400 MHz = 5 %, and 4096 of 8192 MB = 50 %.
|
||||
if got := loadCell(r.cpuLoad()); got != "5" {
|
||||
t.Errorf("cpu load reads %q, want 5", got)
|
||||
}
|
||||
if got := loadCell(r.memLoad()); got != "50" {
|
||||
t.Errorf("memory load reads %q, want 50", got)
|
||||
}
|
||||
|
||||
off := r
|
||||
off.vm.Summary.Runtime.PowerState = types.VirtualMachinePowerStatePoweredOff
|
||||
for _, got := range []string{loadCell(off.cpuLoad()), loadCell(off.memLoad())} {
|
||||
if got != "-" {
|
||||
t.Errorf("a stopped machine reports a load of %q, want a dash", got)
|
||||
}
|
||||
}
|
||||
if loadColor(off.cpuLoad()) != colOff {
|
||||
t.Error("the dash is not in the disabled colour")
|
||||
}
|
||||
|
||||
// No denominator is also no load, however the machine claims to be running.
|
||||
noLimit := r
|
||||
noLimit.vm.Summary.Runtime.MaxCpuUsage = 0
|
||||
if got := loadCell(noLimit.cpuLoad()); got != "-" {
|
||||
t.Errorf("with no cpu limit the load reads %q, want a dash", got)
|
||||
}
|
||||
noMem := r
|
||||
noMem.vm.Summary.Config.MemorySizeMB = 0
|
||||
if got := loadCell(noMem.memLoad()); got != "-" {
|
||||
t.Errorf("with no configured memory the load reads %q, want a dash", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A machine under pressure has to be visible without reading the number.
|
||||
func TestLoadColoursRiseWithTheLoad(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
pct float64
|
||||
want string
|
||||
note string
|
||||
}{
|
||||
{0, colSize, "idle"},
|
||||
{74, colSize, "busy but not worth a colour"},
|
||||
{75, colBusy, "filling up"},
|
||||
{89, colBusy, "still filling up"},
|
||||
{90, colFull, "full"},
|
||||
{100, colFull, "quite full"},
|
||||
{143, colFull, "over its share"},
|
||||
} {
|
||||
if got := loadColor(c.pct, true); got != c.want {
|
||||
t.Errorf("%.0f %% (%s) is coloured %q, want %q", c.pct, c.note, got, c.want)
|
||||
}
|
||||
}
|
||||
// Both thresholds are palette colours, and neither is the ordinary one.
|
||||
if colBusy == colSize || colFull == colSize || colBusy == colFull {
|
||||
t.Error("the three load colours are not three different colours")
|
||||
}
|
||||
}
|
||||
|
||||
// The ladder of layouts, written out. Dragging a terminal narrower may only ever
|
||||
// take columns away — a column that appears as the window shrinks is worse than
|
||||
// one that is simply gone — and this is where that is pinned down, together with
|
||||
// the width at which each one goes.
|
||||
func TestColumnLadder(t *testing.T) {
|
||||
at := func(width int) string {
|
||||
var h []string
|
||||
for _, c := range fitColumns(width) {
|
||||
h = append(h, c.header)
|
||||
}
|
||||
return strings.Join(h, " ")
|
||||
}
|
||||
|
||||
// The widths are what fitColumns is handed, which is the terminal less the two
|
||||
// columns the pointer occupies: 78 here is a terminal of eighty. Both ends of
|
||||
// each rung are given, so a width change moves the boundary and not only the
|
||||
// example.
|
||||
const (
|
||||
all = "NAME VC PWR IP HOST CPU CPU% MEM MEM% GUEST OS"
|
||||
nine = "NAME VC PWR IP HOST CPU CPU% MEM MEM%"
|
||||
eight = "NAME VC PWR IP CPU CPU% MEM MEM%"
|
||||
seven = "NAME VC PWR CPU CPU% MEM MEM%"
|
||||
six = "NAME VC PWR CPU% MEM MEM%"
|
||||
five = "NAME VC PWR CPU% MEM%"
|
||||
four = "NAME VC PWR CPU%"
|
||||
three = "NAME VC PWR"
|
||||
two = "NAME PWR"
|
||||
one = "NAME"
|
||||
)
|
||||
for _, c := range []struct {
|
||||
width int
|
||||
want string
|
||||
}{
|
||||
{200, all}, {109, all},
|
||||
{108, nine}, {89, nine},
|
||||
{88, eight}, {78, eight}, {77, eight}, // 78 is a terminal of eighty
|
||||
{76, seven}, {60, seven},
|
||||
{59, six}, {55, six},
|
||||
{54, five}, {46, five},
|
||||
{45, four}, {40, four},
|
||||
{39, three}, {34, three},
|
||||
{33, two}, {28, two},
|
||||
{27, one}, {10, one},
|
||||
} {
|
||||
if got := at(c.width); got != c.want {
|
||||
t.Errorf("%d columns wide shows\n %s\nwant\n %s", c.width, got, c.want)
|
||||
}
|
||||
}
|
||||
|
||||
// And the sets form a chain: each one is contained in the next wider one.
|
||||
var wider []string
|
||||
for width := 10; width <= 200; width++ {
|
||||
var here []string
|
||||
for _, c := range fitColumns(width) {
|
||||
here = append(here, c.header)
|
||||
}
|
||||
for _, h := range wider {
|
||||
if !slices.Contains(here, h) && h != "NAME" {
|
||||
t.Fatalf("%d columns wide lost %s, which a narrower terminal showed: %v", width, h, here)
|
||||
}
|
||||
}
|
||||
wider = here
|
||||
}
|
||||
}
|
||||
|
||||
// The printed table and the interactive one come from one definition, so a value
|
||||
// cannot be spelled two ways. It used to show a bare 32 where the list showed
|
||||
// 32.0MB, and had neither the power state, the host, nor the load figures.
|
||||
func TestPrintedListMatchesTheInteractiveOne(t *testing.T) {
|
||||
r := testRow("web01", true, "10.0.0.5")
|
||||
|
||||
for _, c := range fitColumns(200) {
|
||||
want := c.cell(r)
|
||||
// The same cell function feeds both, so this is really a check that the
|
||||
// printed table takes its cells from here rather than formatting its own.
|
||||
if want == "" && c.header != "IP" {
|
||||
t.Errorf("the %s column has nothing to print", c.header)
|
||||
}
|
||||
}
|
||||
|
||||
// The things the printed listing used to be missing.
|
||||
var headers []string
|
||||
for _, c := range fitColumns(200) {
|
||||
headers = append(headers, c.header)
|
||||
}
|
||||
for _, want := range []string{"PWR", "HOST", "CPU%", "MEM%", "MEM"} {
|
||||
if !slices.Contains(headers, want) {
|
||||
t.Errorf("the shared table has no %s column", want)
|
||||
}
|
||||
}
|
||||
|
||||
// Sizes are spelled as sizes in both.
|
||||
var mem browseColumn
|
||||
for _, c := range browseColumns {
|
||||
if c.header == "MEM" {
|
||||
mem = c
|
||||
}
|
||||
}
|
||||
if got := mem.cell(r); got != "8.0GB" {
|
||||
t.Errorf("the memory column reads %q, want 8.0GB and not a bare number", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Padding happens before colouring, or the escapes are counted as columns — which
|
||||
// is what made `gvm host` shift a line three columns for a longer hostname.
|
||||
func TestPrintedCellsArePaddedNotColoured(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
text string
|
||||
width int
|
||||
right bool
|
||||
want string
|
||||
}{
|
||||
{"esx1", 8, false, "esx1 "},
|
||||
{"esx1", 8, true, " esx1"},
|
||||
{"1.46", 6, true, " 1.46"},
|
||||
{"DC0_C0_H0", 6, false, "DC0_C…"}, // too long: cut, never overflowing
|
||||
{"", 4, false, " "},
|
||||
} {
|
||||
got := pad(c.text, c.width, c.right)
|
||||
if got != c.want {
|
||||
t.Errorf("pad(%q, %d, %v) = %q, want %q", c.text, c.width, c.right, got, c.want)
|
||||
}
|
||||
if len([]rune(got)) != c.width {
|
||||
t.Errorf("pad(%q, %d) gave %d columns", c.text, c.width, len([]rune(got)))
|
||||
}
|
||||
}
|
||||
|
||||
// A coloured value still occupies exactly its column: the escapes go on after
|
||||
// the padding, never before it.
|
||||
painted := paint(pad("esx1", 8, false), cWhite.fg())
|
||||
if len([]rune(stripEscapes(painted))) != 8 {
|
||||
t.Errorf("a coloured cell is %d columns wide", len([]rune(stripEscapes(painted))))
|
||||
}
|
||||
}
|
||||
|
||||
// Into a pipe nothing may be cut. Fitting the printed table to the interactive
|
||||
// minimum width truncated machine names at twenty-two characters, silently and
|
||||
// with an ellipsis, in output whose whole purpose is to be read by something
|
||||
// else.
|
||||
func TestPipedListingCutsNothing(t *testing.T) {
|
||||
t.Setenv("NO_COLOR", "1") // read by fatih/color at init, so also forced below
|
||||
noColor := color.NoColor
|
||||
color.NoColor = true
|
||||
defer func() { color.NoColor = noColor }()
|
||||
|
||||
long := "a-machine-with-a-name-of-forty-three-chars"
|
||||
rows := []vmRow{
|
||||
testRow(long, true, "10.0.0.5"),
|
||||
testRow("web01", true, "10.0.0.6"),
|
||||
}
|
||||
|
||||
out := captureStdout(t, func() { printList(rows) })
|
||||
|
||||
if !strings.Contains(out, long) {
|
||||
t.Errorf("the long name was cut:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "…") {
|
||||
t.Errorf("something was truncated:\n%s", out)
|
||||
}
|
||||
// Every column still lines up: the widths grew, they did not go away.
|
||||
var widths []int
|
||||
for _, line := range strings.Split(strings.TrimRight(out, "\n"), "\n") {
|
||||
widths = append(widths, len([]rune(line)))
|
||||
}
|
||||
for i := 1; i < len(widths); i++ {
|
||||
if widths[i] > widths[0]+2 && widths[0] < len(long) {
|
||||
t.Errorf("line %d is %d wide, the heading %d — the columns do not line up",
|
||||
i, widths[i], widths[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// captureStdout runs f with stdout on a pipe and hands back what it wrote.
|
||||
func captureStdout(t *testing.T, f func()) string {
|
||||
t.Helper()
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
saved := os.Stdout
|
||||
os.Stdout = w
|
||||
|
||||
done := make(chan string, 1)
|
||||
go func() {
|
||||
out, _ := io.ReadAll(r)
|
||||
done <- string(out)
|
||||
}()
|
||||
|
||||
f()
|
||||
w.Close()
|
||||
os.Stdout = saved
|
||||
return <-done
|
||||
}
|
||||
|
||||
// The selection follows the machine, not its name: two vCenters may each hold a
|
||||
// "web01", and following the name alone moved the cursor to the other server's
|
||||
// machine whenever sorting brought that one up first.
|
||||
func TestSelectionFollowsTheMachineNotTheName(t *testing.T) {
|
||||
same := func(vc, moref string, on bool) vmRow {
|
||||
r := testRow("web01", on, "10.0.0.5")
|
||||
r.vc = VCenter{Name: vc}
|
||||
r.ref = types.ManagedObjectReference{Type: "VirtualMachine", Value: moref}
|
||||
return r
|
||||
}
|
||||
b := &browser{rows: []vmRow{same("v308", "vm-1", true), same("v108", "vm-9", false)}}
|
||||
b.refilter()
|
||||
|
||||
b.move(1) // the second one: same name, other vCenter
|
||||
cur := b.current()
|
||||
if cur.vc.Name != "v108" {
|
||||
t.Fatalf("the cursor is on %s, expected v108", cur.vc.Name)
|
||||
}
|
||||
if cur.id() == b.rows[0].id() {
|
||||
t.Fatal("two machines on two servers share an identity")
|
||||
}
|
||||
|
||||
// Sorting by power puts the running one first; the cursor must stay on the
|
||||
// stopped machine it was on, not jump to the namesake now under it.
|
||||
for i, o := range sortOrders {
|
||||
if o.key == 'p' {
|
||||
b.sortBy, b.sortDesc = i, o.natural
|
||||
}
|
||||
}
|
||||
b.applySort()
|
||||
|
||||
if got := b.current(); got.vc.Name != "v108" {
|
||||
t.Errorf("after sorting the cursor sits on %s/%s, want the v108 machine",
|
||||
got.vc.Name, got.name)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,8 @@ go 1.26.1
|
||||
|
||||
require (
|
||||
github.com/AlecAivazis/survey/v2 v2.3.7
|
||||
github.com/eknkc/basex v1.0.1
|
||||
github.com/fatih/color v1.19.0
|
||||
github.com/integrii/flaggy v1.8.0
|
||||
github.com/tidwall/gjson v1.18.0
|
||||
github.com/vmware/govmomi v0.53.0
|
||||
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
|
||||
)
|
||||
@@ -18,8 +16,6 @@ require (
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
|
||||
@@ -7,8 +7,6 @@ github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/eknkc/basex v1.0.1 h1:TcyAkqh4oJXgV3WYyL4KEfCMk9W8oJCpmx1bo+jVgKY=
|
||||
github.com/eknkc/basex v1.0.1/go.mod h1:k/F/exNEHFdbs3ZHuasoP2E7zeWwZblG84Y7Z59vQRo=
|
||||
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
|
||||
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
@@ -35,12 +33,6 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
|
||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/vmware/govmomi v0.53.0 h1:e1bZCotAq7wm4xy95ePN2uoWwz28pNp/ewZZhpBY7/4=
|
||||
github.com/vmware/govmomi v0.53.0/go.mod h1:EWfuzPfxT5NV+aS2we02SLFdhvJkgeY7t7+TszgBSMY=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
|
||||
@@ -81,6 +81,10 @@ func run() error {
|
||||
subVM.Bool(&vmList, "l", "list", "Print the machines instead of browsing them")
|
||||
subVM.Bool(&vmInteractive, "i", "interactive", "Browse the machines (the default)")
|
||||
subVM.String(&vmMatch, "m", "match", "Only machines matching: a regexp for -l, plain text in the list")
|
||||
var vmSort string
|
||||
var vmReverse bool
|
||||
subVM.String(&vmSort, "", "sort", "Order for -l: name, power, cpu%, mem%, size, cpus, vc, host, ip")
|
||||
subVM.Bool(&vmReverse, "", "reverse", "Turn that order around")
|
||||
|
||||
// The destructive options deliberately have no short letter: --revert and
|
||||
// --removeall have to be spelled out, so neither can be reached by a slip of
|
||||
@@ -139,7 +143,7 @@ func run() error {
|
||||
return err
|
||||
}
|
||||
if vmList { // -l prints; anything else browses
|
||||
return lsvm(targets, vmMatch)
|
||||
return lsvm(targets, vmMatch, vmSort, vmReverse)
|
||||
}
|
||||
return browseVMs(targets, vmMatch)
|
||||
|
||||
|
||||
@@ -35,40 +35,36 @@ func hoststat(vc VCenter, telemetry string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
P()
|
||||
P("Name CPU Used Mem Total Mem VM ON Status Connected")
|
||||
|
||||
printRow(hostColumns, "", nil) // the heading, from the same widths as the rows
|
||||
for _, host := range hosts {
|
||||
hn := shortHost(host.Name)
|
||||
total := len(host.Vm)
|
||||
on := countOn(host.Vm, running)
|
||||
|
||||
var usedMem, totalMem int64
|
||||
usedMem = int64(host.Summary.QuickStats.OverallMemoryUsage) * 1024 * 1024
|
||||
cpustr := " -"
|
||||
usedMem := int64(host.Summary.QuickStats.OverallMemoryUsage) * 1024 * 1024
|
||||
totalMem := int64(0)
|
||||
cpu, cpuKnown := 0.0, false
|
||||
if hw := host.Summary.Hardware; hw != nil {
|
||||
totalMem = hw.MemorySize
|
||||
if cpu, ok := cpuPercent(hw, host.Summary.QuickStats); ok {
|
||||
cpustr = colorPercent(cpu)
|
||||
}
|
||||
cpu, cpuKnown = cpuPercent(hw, host.Summary.QuickStats)
|
||||
}
|
||||
cpuText, cpuCol := "-", colOff
|
||||
if cpuKnown {
|
||||
cpuText, cpuCol = SF("%.2f", cpu), loadColor(cpu, true)
|
||||
}
|
||||
|
||||
PF("%4s %s %8s %8s %3d %3d %6s %s\n",
|
||||
Cwb(hn),
|
||||
cpustr,
|
||||
units.ByteSize(usedMem),
|
||||
units.ByteSize(totalMem),
|
||||
total,
|
||||
on,
|
||||
colorStatus(host.OverallStatus),
|
||||
host.Runtime.ConnectionState,
|
||||
)
|
||||
printRow(hostColumns, "", []cell{
|
||||
{hn, cWhite.fg()},
|
||||
{cpuText, cpuCol},
|
||||
{units.ByteSize(usedMem).String(), colSize},
|
||||
{units.ByteSize(totalMem).String(), colSize},
|
||||
{Itoa(total), colSize},
|
||||
{Itoa(on), colSize},
|
||||
{string(host.OverallStatus), statusColor(host.OverallStatus)},
|
||||
{string(host.Runtime.ConnectionState), colAside},
|
||||
})
|
||||
|
||||
if telemetry != "" {
|
||||
cpu := 0.0
|
||||
if hw := host.Summary.Hardware; hw != nil {
|
||||
cpu, _ = cpuPercent(hw, host.Summary.QuickStats)
|
||||
}
|
||||
post(telemetry, SF("vm,%s,%.2f,%d,%d,%d,%d,%s,%s",
|
||||
hn, cpu, usedMem, totalMem, total, on,
|
||||
host.OverallStatus, host.Runtime.ConnectionState))
|
||||
@@ -95,10 +91,13 @@ func vmstat(vc VCenter) error {
|
||||
return err
|
||||
}
|
||||
|
||||
PF("%-25s %-10s %-10s\n", "Host", "Total VMs", "PoweredOn")
|
||||
P(SR("-", 47))
|
||||
printRow(countColumns, "", nil)
|
||||
for _, host := range hosts {
|
||||
PF("%-25s %-10d %-10d\n", host.Name, len(host.Vm), countOn(host.Vm, running))
|
||||
printRow(countColumns, "", []cell{
|
||||
{host.Name, cWhite.fg()},
|
||||
{Itoa(len(host.Vm)), colSize},
|
||||
{Itoa(countOn(host.Vm, running)), colSize},
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -143,30 +142,17 @@ func cpuPercent(hw *types.HostHardwareSummary, qs types.HostListSummaryQuickStat
|
||||
return 100.0 / float64(totalMHz) * float64(qs.OverallCpuUsage), true
|
||||
}
|
||||
|
||||
func colorPercent(p float64) string {
|
||||
s := SF("%6.2f", p)
|
||||
switch {
|
||||
case p > 90:
|
||||
return Crb(s)
|
||||
case p > 50:
|
||||
return Cyb(s)
|
||||
case p > 2:
|
||||
return Cwb(s)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func colorStatus(st types.ManagedEntityStatus) string {
|
||||
s := string(st)
|
||||
switch s {
|
||||
// statusColor: vSphere's own words for how a host is doing, in the palette's.
|
||||
func statusColor(st types.ManagedEntityStatus) string {
|
||||
switch string(st) {
|
||||
case "green":
|
||||
return Cgb(s)
|
||||
return colOK
|
||||
case "yellow":
|
||||
return Cyb(s)
|
||||
return colBusy
|
||||
case "red":
|
||||
return Crb(s)
|
||||
return colFull
|
||||
}
|
||||
return s
|
||||
return colAside
|
||||
}
|
||||
|
||||
// shortHost is the hostname without its domain, which is all the first column
|
||||
|
||||
@@ -40,8 +40,9 @@ func vmlog(cfg Config, vc VCenter, minutes int, mail bool) error {
|
||||
return fmt.Errorf("%s: cannot read the event log: %w", vc.Name, err)
|
||||
}
|
||||
|
||||
PF("%-20s | %-15s | %s\n", "Time", "Severity", "Message")
|
||||
P(SR("-", 80))
|
||||
// No heading and no rule: the lines below are comma-separated on purpose —
|
||||
// they are what the mail carries and what a script would cut up — and a
|
||||
// pipe-separated heading above them promised a table that never came.
|
||||
|
||||
msg := ""
|
||||
for _, e := range events {
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
// print.go — columns for the commands that print rather than draw.
|
||||
//
|
||||
// The one rule: pad first, colour afterwards. A colour is a handful of escape
|
||||
// bytes that occupy no columns on screen, but %8s counts them all the same — so a
|
||||
// coloured value handed to a width verb is never padded, and any value longer
|
||||
// than its column shoves everything after it to the right. `gvm host` did exactly
|
||||
// that: a host called DC0_C0_H0 shifted its whole line three columns against the
|
||||
// one above it, and the heading, being written by hand, lined up with neither.
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/fatih/color"
|
||||
)
|
||||
|
||||
// printColumn is one column of a printed table: what it is called, how wide it
|
||||
// is, and whether its values are numbers, which read better against the right.
|
||||
type printColumn struct {
|
||||
header string
|
||||
width int
|
||||
right bool
|
||||
}
|
||||
|
||||
// cell is one value with the colour it is shown in.
|
||||
type cell struct {
|
||||
text string
|
||||
col string
|
||||
}
|
||||
|
||||
var hostColumns = []printColumn{
|
||||
{header: "HOST", width: 10},
|
||||
{header: "CPU%", width: 6, right: true},
|
||||
{header: "USED MEM", width: 9, right: true},
|
||||
{header: "TOTAL MEM", width: 9, right: true},
|
||||
{header: "VM", width: 4, right: true},
|
||||
{header: "ON", width: 4, right: true},
|
||||
{header: "STATUS", width: 7},
|
||||
{header: "CONNECTED", width: 12},
|
||||
}
|
||||
|
||||
var countColumns = []printColumn{
|
||||
{header: "HOST", width: 24},
|
||||
{header: "VM", width: 4, right: true},
|
||||
{header: "ON", width: 4, right: true},
|
||||
}
|
||||
|
||||
// printRow writes one row, or the heading when cells is nil. Every value is
|
||||
// padded to its column as plain text and only then coloured, so a long value is
|
||||
// cut instead of pushing its neighbours along.
|
||||
func printRow(cols []printColumn, indent string, cells []cell) {
|
||||
out := make([]string, 0, len(cols))
|
||||
for i, c := range cols {
|
||||
text, col := c.header, colHeader
|
||||
if cells != nil {
|
||||
if i >= len(cells) {
|
||||
break
|
||||
}
|
||||
text, col = cells[i].text, cells[i].col
|
||||
}
|
||||
out = append(out, paint(pad(text, c.width, c.right), col))
|
||||
}
|
||||
PF("%s%s\n", indent, strings.TrimRight(strings.Join(out, " "), " "))
|
||||
}
|
||||
|
||||
// widen grows every column to the longest thing it holds, header included. For
|
||||
// output that is going somewhere without a width of its own, where cutting a
|
||||
// value would lose it rather than merely hide it.
|
||||
func widen(pcs []printColumn, body [][]cell) {
|
||||
for i := range pcs {
|
||||
w := len([]rune(pcs[i].header))
|
||||
for _, cells := range body {
|
||||
if i >= len(cells) {
|
||||
continue
|
||||
}
|
||||
if n := len([]rune(cells[i].text)); n > w {
|
||||
w = n
|
||||
}
|
||||
}
|
||||
pcs[i].width = w
|
||||
}
|
||||
}
|
||||
|
||||
// pad fits text to width — truncating what is too long — against the left or,
|
||||
// for numbers, the right.
|
||||
func pad(text string, width int, right bool) string {
|
||||
t := truncate(text, width)
|
||||
gap := width - len([]rune(t))
|
||||
if gap <= 0 {
|
||||
return t
|
||||
}
|
||||
if right {
|
||||
return SR(" ", gap) + t
|
||||
}
|
||||
return t + SR(" ", gap)
|
||||
}
|
||||
|
||||
// paint colours text, but only where colour belongs. The escapes the interactive
|
||||
// screen uses are written straight out and would otherwise end up in a pipe;
|
||||
// fatih/color already works out whether that is the case, so its answer is the
|
||||
// one used here rather than a second opinion.
|
||||
func paint(text, col string) string {
|
||||
if col == "" || color.NoColor {
|
||||
return text
|
||||
}
|
||||
return col + text + attrOff
|
||||
}
|
||||
+92
-12
@@ -85,7 +85,8 @@ func powerOf(t *testing.T, s *session, ref types.ManagedObjectReference) types.V
|
||||
// oneRow connects and returns the machine of that name as the list would hold it.
|
||||
func oneRow(t *testing.T, vc VCenter, name string) (*session, vmRow) {
|
||||
t.Helper()
|
||||
rows, sessions, err := gatherVMs([]VCenter{vc})
|
||||
found, err := gatherVMs([]VCenter{vc})
|
||||
rows, sessions := found.rows, found.sessions
|
||||
if err != nil {
|
||||
t.Fatalf("gatherVMs: %v", err)
|
||||
}
|
||||
@@ -116,7 +117,8 @@ func TestSimGatherVMs(t *testing.T) {
|
||||
quiet(t)
|
||||
vc := simVCenter(t)
|
||||
|
||||
rows, sessions, err := gatherVMs([]VCenter{vc})
|
||||
found, err := gatherVMs([]VCenter{vc})
|
||||
rows, sessions := found.rows, found.sessions
|
||||
defer closeSessions(sessions)
|
||||
if err != nil {
|
||||
t.Fatalf("gatherVMs: %v", err)
|
||||
@@ -160,7 +162,8 @@ func TestSimBrowseAndOpenDetail(t *testing.T) {
|
||||
quiet(t)
|
||||
vc := simVCenter(t)
|
||||
|
||||
rows, sessions, err := gatherVMs([]VCenter{vc})
|
||||
found, err := gatherVMs([]VCenter{vc})
|
||||
rows, sessions := found.rows, found.sessions
|
||||
defer closeSessions(sessions)
|
||||
if err != nil {
|
||||
t.Fatalf("gatherVMs: %v", err)
|
||||
@@ -220,7 +223,8 @@ func TestSimSnapshotRoundTrip(t *testing.T) {
|
||||
quiet(t)
|
||||
vc := simVCenter(t)
|
||||
|
||||
rows, sessions, err := gatherVMs([]VCenter{vc})
|
||||
found, err := gatherVMs([]VCenter{vc})
|
||||
rows, sessions := found.rows, found.sessions
|
||||
if err != nil {
|
||||
t.Fatalf("gatherVMs: %v", err)
|
||||
}
|
||||
@@ -235,7 +239,8 @@ func TestSimSnapshotRoundTrip(t *testing.T) {
|
||||
}
|
||||
|
||||
// The sheet has to show it now.
|
||||
rows, sessions, err = gatherVMs([]VCenter{vc})
|
||||
found, err = gatherVMs([]VCenter{vc})
|
||||
rows, sessions = found.rows, found.sessions
|
||||
if err != nil {
|
||||
t.Fatalf("gatherVMs: %v", err)
|
||||
}
|
||||
@@ -264,10 +269,10 @@ func TestSimReadOnlyCommands(t *testing.T) {
|
||||
vc := simVCenter(t)
|
||||
cfg := Config{VCenters: []VCenter{vc}, Default: "sim"}
|
||||
|
||||
if err := lsvm([]VCenter{vc}, ""); err != nil {
|
||||
if err := lsvm([]VCenter{vc}, "", "", false); err != nil {
|
||||
t.Errorf("lsvm: %v", err)
|
||||
}
|
||||
if err := lsvm([]VCenter{vc}, "DC0"); err != nil {
|
||||
if err := lsvm([]VCenter{vc}, "DC0", "", false); err != nil {
|
||||
t.Errorf("lsvm with a pattern: %v", err)
|
||||
}
|
||||
if err := hoststat(vc, ""); err != nil {
|
||||
@@ -281,7 +286,7 @@ func TestSimReadOnlyCommands(t *testing.T) {
|
||||
}
|
||||
|
||||
// A pattern that is not a regexp is a message, not a panic.
|
||||
if err := lsvm([]VCenter{vc}, "web("); err == nil {
|
||||
if err := lsvm([]VCenter{vc}, "web(", "", false); err == nil {
|
||||
t.Error("lsvm accepted a broken pattern")
|
||||
}
|
||||
}
|
||||
@@ -321,7 +326,8 @@ func TestSimInteractiveSnapshot(t *testing.T) {
|
||||
quiet(t)
|
||||
vc := simVCenter(t)
|
||||
|
||||
rows, sessions, err := gatherVMs([]VCenter{vc})
|
||||
found, err := gatherVMs([]VCenter{vc})
|
||||
rows, sessions := found.rows, found.sessions
|
||||
defer closeSessions(sessions)
|
||||
if err != nil {
|
||||
t.Fatalf("gatherVMs: %v", err)
|
||||
@@ -642,7 +648,8 @@ func TestSimBrowserDestructiveNeedsTheName(t *testing.T) {
|
||||
quiet(t)
|
||||
vc := simVCenter(t)
|
||||
|
||||
rows, sessions, err := gatherVMs([]VCenter{vc})
|
||||
found, err := gatherVMs([]VCenter{vc})
|
||||
rows, sessions := found.rows, found.sessions
|
||||
defer closeSessions(sessions)
|
||||
if err != nil {
|
||||
t.Fatalf("gatherVMs: %v", err)
|
||||
@@ -734,7 +741,8 @@ func TestSimBrowserPowerOffNeedsTheName(t *testing.T) {
|
||||
quiet(t)
|
||||
vc := simVCenter(t)
|
||||
|
||||
rows, sessions, err := gatherVMs([]VCenter{vc})
|
||||
found, err := gatherVMs([]VCenter{vc})
|
||||
rows, sessions := found.rows, found.sessions
|
||||
defer closeSessions(sessions)
|
||||
if err != nil {
|
||||
t.Fatalf("gatherVMs: %v", err)
|
||||
@@ -800,7 +808,8 @@ func TestSimMenuRereadsTheRow(t *testing.T) {
|
||||
quiet(t)
|
||||
vc, model := simVCenterModel(t)
|
||||
|
||||
rows, sessions, err := gatherVMs([]VCenter{vc})
|
||||
found, err := gatherVMs([]VCenter{vc})
|
||||
rows, sessions := found.rows, found.sessions
|
||||
defer closeSessions(sessions)
|
||||
if err != nil {
|
||||
t.Fatalf("gatherVMs: %v", err)
|
||||
@@ -882,3 +891,74 @@ func TestBrowserSaysWhenTheRowCannotBeReread(t *testing.T) {
|
||||
t.Error("a stale row is not flagged as a warning")
|
||||
}
|
||||
}
|
||||
|
||||
// A vCenter that does not answer has to be visible in the list that is meant to
|
||||
// show every vCenter. It used to be printed and then wiped by the first screen,
|
||||
// while the title went on naming the server as though its machines were there.
|
||||
func TestSimUnreachableVCenterIsNamed(t *testing.T) {
|
||||
quiet(t)
|
||||
live := simVCenter(t)
|
||||
dead := VCenter{
|
||||
Name: "dead", URL: "http://127.0.0.1:9/",
|
||||
User: "user", Password: "pass", Datacenter: "DC0",
|
||||
}
|
||||
|
||||
found, err := gatherVMs([]VCenter{live, dead})
|
||||
defer closeSessions(found.sessions)
|
||||
if err != nil {
|
||||
t.Fatalf("one server answering should not be a failure: %v", err)
|
||||
}
|
||||
if len(found.rows) == 0 {
|
||||
t.Fatal("the machines of the server that answered were thrown away")
|
||||
}
|
||||
if strings.Join(found.answered, ",") != "sim" {
|
||||
t.Errorf("answered: %v, want only sim", found.answered)
|
||||
}
|
||||
if strings.Join(found.lost, ",") != "dead" {
|
||||
t.Errorf("lost: %v, want only dead", found.lost)
|
||||
}
|
||||
if len(found.failed) != 1 || !strings.Contains(found.failed[0], "dead") {
|
||||
t.Errorf("the reason does not name the server: %v", found.failed)
|
||||
}
|
||||
|
||||
// The title names one as holding the machines and the other as missing, and
|
||||
// does not claim the dead one holds anything.
|
||||
b := &browser{targets: []VCenter{live, dead}, rows: found.rows,
|
||||
answered: found.answered, lost: found.lost}
|
||||
b.applySort()
|
||||
|
||||
t.Setenv("COLUMNS", "120")
|
||||
t.Setenv("LINES", "14")
|
||||
frame := stripEscapes(renderToPipe(t, b, b.renderList))
|
||||
title := strings.SplitN(frame, "\r\n", 2)[0]
|
||||
|
||||
if !strings.Contains(title, "on sim") {
|
||||
t.Errorf("the title does not say whose machines these are: %q", title)
|
||||
}
|
||||
if !strings.Contains(title, "dead unreachable") {
|
||||
t.Errorf("the title does not say that dead is missing: %q", title)
|
||||
}
|
||||
if strings.Contains(title, "on dead") || strings.Contains(title, "sim, dead") {
|
||||
t.Errorf("the title still claims the list covers dead: %q", title)
|
||||
}
|
||||
}
|
||||
|
||||
// Every vCenter failing is a failure of the command; one failing is not.
|
||||
func TestSimAllVCentersUnreachable(t *testing.T) {
|
||||
quiet(t)
|
||||
dead := func(name, port string) VCenter {
|
||||
return VCenter{Name: name, URL: "http://127.0.0.1:" + port + "/",
|
||||
User: "u", Password: "p", Datacenter: "DC0"}
|
||||
}
|
||||
found, err := gatherVMs([]VCenter{dead("a", "9"), dead("b", "9")})
|
||||
closeSessions(found.sessions)
|
||||
if err == nil {
|
||||
t.Fatal("no server answered and that was not an error")
|
||||
}
|
||||
if len(found.failed) != 2 {
|
||||
t.Errorf("%d reasons for two failures", len(found.failed))
|
||||
}
|
||||
if len(found.answered) != 0 {
|
||||
t.Errorf("answered: %v", found.answered)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/vmware/govmomi/object"
|
||||
"github.com/vmware/govmomi/vim25/mo"
|
||||
"github.com/vmware/govmomi/vim25/types"
|
||||
)
|
||||
|
||||
@@ -85,10 +86,23 @@ func snapshotNow(s *session, ref types.ManagedObjectReference, name, desc string
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: cannot start the snapshot %s: %w", s.vc.Name, name, err)
|
||||
}
|
||||
if err := task.Wait(s.ctx); err != nil {
|
||||
return fmt.Errorf("%s: the snapshot %s failed: %w", s.vc.Name, name, err)
|
||||
// Bounded, like every other task gvm waits for. This one was not: it waited
|
||||
// on the session's own context, which has no deadline, so a snapshot that
|
||||
// vCenter never finished froze the interactive list with the screen mid-draw
|
||||
// and no key being read — the one place where waiting for ever is worst.
|
||||
return waitTask(s.ctx, task, snapshotWait, SF("snapshot %s of %s", name, vmName(s, ref)))
|
||||
}
|
||||
|
||||
// vmName is the machine's name for a message, from its reference alone. Cheap:
|
||||
// one property, and only asked for when something has gone wrong enough to be
|
||||
// worth naming.
|
||||
func vmName(s *session, ref types.ManagedObjectReference) string {
|
||||
var mvm mo.VirtualMachine
|
||||
if err := object.NewVirtualMachine(s.client.Client, ref).
|
||||
Properties(s.ctx, ref, []string{"name"}, &mvm); err != nil {
|
||||
return ref.Value
|
||||
}
|
||||
return nil
|
||||
return mvm.Name
|
||||
}
|
||||
|
||||
// snapRemove removes one snapshot, with its children left where they are, and
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
// sort.go — the order the table is in.
|
||||
//
|
||||
// One key opens a legend on the status line and the next key picks the order, so
|
||||
// the list stays on screen while it rearranges itself in front of you. Ordinary
|
||||
// letters cannot be used on their own: the list's filter swallows those.
|
||||
//
|
||||
// Every order has a natural direction, because that is what asking for it means.
|
||||
// Sorting by name means a to z; sorting by processor load means the busiest
|
||||
// first, and having to reverse it every time would be a nuisance dressed up as
|
||||
// consistency. `r` reverses whatever is current.
|
||||
package main
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// sortOrder is one way of arranging the table.
|
||||
type sortOrder struct {
|
||||
key rune // the letter that picks it
|
||||
name string // what it is called, in the title and the legend
|
||||
natural bool // its own direction: true means largest or busiest first
|
||||
cmp func(a, b vmRow) int
|
||||
}
|
||||
|
||||
// sortOrders in the order the legend lists them: the two that identify a machine
|
||||
// first, then what it is doing, then what it is made of, then where it lives.
|
||||
var sortOrders = []sortOrder{
|
||||
{key: 'n', name: "name", cmp: func(a, b vmRow) int { return cmpText(a.name, b.name) }},
|
||||
{key: 'p', name: "power", natural: true,
|
||||
cmp: func(a, b vmRow) int { return cmpInt(powerRank(a), powerRank(b)) }},
|
||||
{key: 'c', name: "cpu load", natural: true,
|
||||
cmp: func(a, b vmRow) int { return cmpLoad(vmRow.cpuLoad, a, b) }},
|
||||
{key: 'm', name: "memory in use", natural: true,
|
||||
cmp: func(a, b vmRow) int { return cmpLoad(vmRow.memLoad, a, b) }},
|
||||
{key: 's', name: "memory size", natural: true,
|
||||
cmp: func(a, b vmRow) int {
|
||||
return cmpInt(int(a.vm.Summary.Config.MemorySizeMB), int(b.vm.Summary.Config.MemorySizeMB))
|
||||
}},
|
||||
{key: 'u', name: "processors", natural: true,
|
||||
cmp: func(a, b vmRow) int {
|
||||
return cmpInt(int(a.vm.Summary.Config.NumCpu), int(b.vm.Summary.Config.NumCpu))
|
||||
}},
|
||||
{key: 'v', name: "vcenter", cmp: func(a, b vmRow) int { return cmpText(a.vc.Name, b.vc.Name) }},
|
||||
{key: 'h', name: "host", cmp: func(a, b vmRow) int { return cmpText(a.host, b.host) }},
|
||||
{key: 'a', name: "address", cmp: func(a, b vmRow) int { return cmpAddress(a, b) }},
|
||||
}
|
||||
|
||||
// sortReverse is the one legend entry that is not an order of its own.
|
||||
const sortReverse = 'r'
|
||||
|
||||
// powerRank puts a running machine above a suspended one above a stopped one, so
|
||||
// that "by power" means what an operator means by it.
|
||||
func powerRank(r vmRow) int {
|
||||
switch r.powerShort() {
|
||||
case "on":
|
||||
return 3
|
||||
case "susp":
|
||||
return 2
|
||||
case "off":
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func cmpText(a, b string) int { return strings.Compare(strings.ToLower(a), strings.ToLower(b)) }
|
||||
|
||||
func cmpInt(a, b int) int {
|
||||
switch {
|
||||
case a < b:
|
||||
return -1
|
||||
case a > b:
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// A value that is not there is not a small value. Wherever one can be missing —
|
||||
// a load figure on a stopped machine, an address on a machine whose guest is not
|
||||
// talking — the machine belongs at the *bottom* of the list, and which end of the
|
||||
// comparison that is depends on which way the order naturally runs.
|
||||
//
|
||||
// Load runs downwards by nature (busiest first), so an unknown load has to
|
||||
// compare as the smallest. An address runs upwards (a to z), so a missing address
|
||||
// has to compare as the largest. Same rule, opposite polarity; the two functions
|
||||
// below say so where it can be checked.
|
||||
|
||||
// cmpLoad orders two machines by a load figure, unknown lowest — which puts it
|
||||
// last under the busiest-first direction this order is asked for with.
|
||||
func cmpLoad(load func(vmRow) (float64, bool), a, b vmRow) int {
|
||||
x, xok := load(a)
|
||||
y, yok := load(b)
|
||||
switch {
|
||||
case !xok && !yok:
|
||||
return 0
|
||||
case !xok:
|
||||
return -1
|
||||
case !yok:
|
||||
return 1
|
||||
case x < y:
|
||||
return -1
|
||||
case x > y:
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// cmpAddress orders by address, unknown highest — which puts it last under the
|
||||
// a-to-z direction this order is asked for with.
|
||||
func cmpAddress(a, b vmRow) int {
|
||||
x, y := a.ip(), b.ip()
|
||||
switch {
|
||||
case x == "-" && y == "-":
|
||||
return 0
|
||||
case x == "-":
|
||||
return 1
|
||||
case y == "-":
|
||||
return -1
|
||||
}
|
||||
return cmpText(x, y)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ the sorting
|
||||
|
||||
func (b *browser) order() sortOrder { return sortOrders[b.sortBy] }
|
||||
|
||||
// applySort rearranges the rows and rebuilds what is on screen. The selection
|
||||
// follows the machine it was on, which refilter already sees to.
|
||||
//
|
||||
// Machines that compare equal are left in name order, always ascending, whichever
|
||||
// way the sort itself runs: a screen full of machines all at 0 % that reshuffles
|
||||
// when the direction is flipped would look like the numbers had changed.
|
||||
func (b *browser) applySort() {
|
||||
sortRows(b.rows, b.sortBy, b.sortDesc)
|
||||
b.refilter()
|
||||
}
|
||||
|
||||
// sortRows is the sorting itself, without a browser: `gvm vm -l` orders the same
|
||||
// rows the same way.
|
||||
func sortRows(rows []vmRow, by int, desc bool) {
|
||||
o := sortOrders[by]
|
||||
sort.SliceStable(rows, func(i, j int) bool {
|
||||
x, y := rows[i], rows[j]
|
||||
n := o.cmp(x, y)
|
||||
if desc {
|
||||
n = -n
|
||||
}
|
||||
if n != 0 {
|
||||
return n < 0
|
||||
}
|
||||
return cmpText(x.name, y.name) < 0
|
||||
})
|
||||
}
|
||||
|
||||
// findOrder resolves what was asked for on the command line — a letter or a name,
|
||||
// as the legend spells them — to one of the orders. Empty means the first, which
|
||||
// is by name.
|
||||
func findOrder(s string) (int, error) {
|
||||
if s == "" {
|
||||
return 0, nil
|
||||
}
|
||||
for i, o := range sortOrders {
|
||||
if s == string(o.key) || s == o.name || s == shortName(o.name) {
|
||||
return i, nil
|
||||
}
|
||||
}
|
||||
names := make([]string, len(sortOrders))
|
||||
for i, o := range sortOrders {
|
||||
names[i] = shortName(o.name)
|
||||
}
|
||||
return 0, errf("cannot sort by %q — try one of: %s", s, strings.Join(names, ", "))
|
||||
}
|
||||
|
||||
// sortLabel is the order as the title shows it: which way, and by what.
|
||||
func (b *browser) sortLabel() string {
|
||||
arrow := "↑"
|
||||
if b.sortDesc {
|
||||
arrow = "↓"
|
||||
}
|
||||
return arrow + " " + b.order().name
|
||||
}
|
||||
|
||||
// sortLegend is the one line offering the choices. Short by necessity — it shares
|
||||
// the status line — and the title says what the order is anyway, so nobody who
|
||||
// misses it is lost.
|
||||
func sortLegend() string {
|
||||
parts := make([]string, 0, len(sortOrders)+1)
|
||||
for _, o := range sortOrders {
|
||||
parts = append(parts, string(o.key)+"·"+shortName(o.name))
|
||||
}
|
||||
parts = append(parts, string(sortReverse)+"·reverse")
|
||||
return "sort: " + strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
// shortName is the legend's spelling: the title has room for the whole name, one
|
||||
// line shared with the status does not.
|
||||
func shortName(name string) string {
|
||||
switch name {
|
||||
case "cpu load":
|
||||
return "cpu%"
|
||||
case "memory in use":
|
||||
return "mem%"
|
||||
case "memory size":
|
||||
return "size"
|
||||
case "processors":
|
||||
return "cpus"
|
||||
case "vcenter":
|
||||
return "vc"
|
||||
case "address":
|
||||
return "ip"
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// sortPrompt puts the legend up and waits for one key. Anything that is not a
|
||||
// choice leaves the order alone: this is the one prompt in the list that is
|
||||
// reached by accident, and doing nothing is the right answer to a stray key.
|
||||
func (b *browser) sortPrompt() {
|
||||
b.prompt = &prompt{text: sortLegend(), col: colValue} // a menu, not a warning
|
||||
b.render()
|
||||
k := b.keys.next()
|
||||
b.prompt = nil
|
||||
|
||||
if k.special != keyRune {
|
||||
return
|
||||
}
|
||||
if k.r == sortReverse {
|
||||
b.sortDesc = !b.sortDesc
|
||||
b.applySort()
|
||||
b.setStatus(colInfo, "sorted "+b.sortLabel())
|
||||
return
|
||||
}
|
||||
for i, o := range sortOrders {
|
||||
if o.key == k.r {
|
||||
b.sortBy, b.sortDesc = i, o.natural
|
||||
b.applySort()
|
||||
b.setStatus(colInfo, "sorted "+b.sortLabel())
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sortedColumn reports whether this column is the one the table is ordered by, so
|
||||
// its heading can be lit up. Matching by name rather than by index keeps the two
|
||||
// tables — the columns and the orders — free to be listed in different orders.
|
||||
func (b *browser) sortedColumn(header string) bool {
|
||||
switch b.order().name {
|
||||
case "name":
|
||||
return header == "NAME"
|
||||
case "power":
|
||||
return header == "PWR"
|
||||
case "cpu load":
|
||||
return header == "CPU%"
|
||||
case "memory in use":
|
||||
return header == "MEM%"
|
||||
case "memory size":
|
||||
return header == "MEM"
|
||||
case "processors":
|
||||
return header == "CPU"
|
||||
case "vcenter":
|
||||
return header == "VC"
|
||||
case "host":
|
||||
return header == "HOST"
|
||||
case "address":
|
||||
return header == "IP"
|
||||
}
|
||||
return false
|
||||
}
|
||||
+287
@@ -0,0 +1,287 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/vmware/govmomi/vim25/mo"
|
||||
"github.com/vmware/govmomi/vim25/types"
|
||||
)
|
||||
|
||||
// sortRow builds a machine with the few figures the orders compare.
|
||||
func sortRow(name, vc, host, ip string, cpu, memMB int32, mhz, memUse int32, on bool) vmRow {
|
||||
state := types.VirtualMachinePowerStatePoweredOff
|
||||
if on {
|
||||
state = types.VirtualMachinePowerStatePoweredOn
|
||||
}
|
||||
r := vmRow{
|
||||
vc: VCenter{Name: vc},
|
||||
ref: types.ManagedObjectReference{Value: "vm-" + name},
|
||||
name: name,
|
||||
host: host,
|
||||
vm: mo.VirtualMachine{Summary: types.VirtualMachineSummary{
|
||||
Config: types.VirtualMachineConfigSummary{Name: name, NumCpu: cpu, MemorySizeMB: memMB},
|
||||
Runtime: types.VirtualMachineRuntimeInfo{
|
||||
PowerState: state, MaxCpuUsage: 1000,
|
||||
},
|
||||
QuickStats: types.VirtualMachineQuickStats{
|
||||
OverallCpuUsage: mhz, GuestMemoryUsage: memUse,
|
||||
},
|
||||
}},
|
||||
}
|
||||
if ip != "" {
|
||||
r.vm.Guest = &types.GuestInfo{IpAddress: ip}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// sortFixture: four machines chosen so that every order puts them in a different
|
||||
// sequence, and one of them is stopped so the "unknown load" rule is exercised.
|
||||
func sortFixture() []vmRow {
|
||||
return []vmRow{
|
||||
// name vc host ip cpu mem mhz memuse on
|
||||
sortRow("delta", "v108", "esx2", "10.0.0.4", 2, 4096, 100, 2048, true), // cpu 10%, mem 50%
|
||||
sortRow("alpha", "v308", "esx1", "10.0.0.1", 8, 16384, 900, 1638, true), // cpu 90%, mem 10%
|
||||
sortRow("charlie", "v38", "esx3", "", 1, 2048, 500, 1536, true), // cpu 50%, mem 75%
|
||||
sortRow("bravo", "v308", "esx1", "10.0.0.2", 4, 8192, 0, 0, false), // stopped
|
||||
}
|
||||
}
|
||||
|
||||
func order(t *testing.T, key rune, desc bool) []string {
|
||||
t.Helper()
|
||||
b := &browser{rows: sortFixture()}
|
||||
for i, o := range sortOrders {
|
||||
if o.key == key {
|
||||
b.sortBy, b.sortDesc = i, desc
|
||||
}
|
||||
}
|
||||
b.applySort()
|
||||
|
||||
out := make([]string, len(b.rows))
|
||||
for i, r := range b.rows {
|
||||
out[i] = r.name
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestSortOrders(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
key rune
|
||||
desc bool
|
||||
want string
|
||||
note string
|
||||
}{
|
||||
{'n', false, "alpha bravo charlie delta", "by name, a to z"},
|
||||
{'n', true, "delta charlie bravo alpha", "by name, reversed"},
|
||||
|
||||
// The busiest first, and the machine that is not running has no load at
|
||||
// all, so it goes last however loud the others are.
|
||||
{'c', true, "alpha charlie delta bravo", "by cpu load, busiest first"},
|
||||
{'c', false, "bravo delta charlie alpha", "by cpu load, quietest first"},
|
||||
{'m', true, "charlie delta alpha bravo", "by memory in use, fullest first"},
|
||||
|
||||
{'s', true, "alpha bravo delta charlie", "by memory size: 16G 8G 4G 2G"},
|
||||
{'u', true, "alpha bravo delta charlie", "by processors, most first"},
|
||||
|
||||
// Running before stopped, and equal machines stay in name order.
|
||||
{'p', true, "alpha charlie delta bravo", "by power, running first"},
|
||||
|
||||
{'v', false, "delta alpha bravo charlie", "by vcenter: v108 v308 v308 v38, as text"},
|
||||
{'h', false, "alpha bravo delta charlie", "by host"},
|
||||
|
||||
// A machine with no address has not got a small one: it goes last, in the
|
||||
// a-to-z direction this order is asked for with.
|
||||
{'a', false, "alpha bravo delta charlie", "by address, the addressless last"},
|
||||
} {
|
||||
if got := strings.Join(order(t, c.key, c.desc), " "); got != c.want {
|
||||
t.Errorf("%s:\n got %s\n want %s", c.note, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Machines that compare equal keep name order whichever way the sort runs. A
|
||||
// screen full of machines all at 0 % that reshuffled when the direction flipped
|
||||
// would look as though the numbers had changed.
|
||||
func TestEqualMachinesKeepNameOrder(t *testing.T) {
|
||||
rows := []vmRow{
|
||||
sortRow("zulu", "v308", "esx1", "", 1, 1024, 0, 0, false),
|
||||
sortRow("mike", "v308", "esx1", "", 1, 1024, 0, 0, false),
|
||||
sortRow("alpha", "v308", "esx1", "", 1, 1024, 0, 0, false),
|
||||
}
|
||||
for _, desc := range []bool{false, true} {
|
||||
b := &browser{rows: append([]vmRow(nil), rows...), sortDesc: desc}
|
||||
for i, o := range sortOrders {
|
||||
if o.key == 'c' {
|
||||
b.sortBy = i
|
||||
}
|
||||
}
|
||||
b.applySort()
|
||||
|
||||
var got []string
|
||||
for _, r := range b.rows {
|
||||
got = append(got, r.name)
|
||||
}
|
||||
if strings.Join(got, " ") != "alpha mike zulu" {
|
||||
t.Errorf("reversed=%v: equal machines came out %v", desc, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Picking an order takes its own direction with it: nobody asking for the
|
||||
// processor load wants the idle machines first.
|
||||
func TestEachOrderHasItsOwnDirection(t *testing.T) {
|
||||
for _, o := range sortOrders {
|
||||
switch o.name {
|
||||
case "name", "vcenter", "host", "address":
|
||||
if o.natural {
|
||||
t.Errorf("%s reads downwards by default", o.name)
|
||||
}
|
||||
default:
|
||||
if !o.natural {
|
||||
t.Errorf("%s does not put the largest first by default", o.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The prompt: a letter picks an order, r reverses, and anything else leaves the
|
||||
// table as it was — it is the one prompt in the list reached by accident.
|
||||
func TestSortPrompt(t *testing.T) {
|
||||
newBrowser := func() (*browser, func(string)) {
|
||||
b := &browser{rows: sortFixture()}
|
||||
b.applySort()
|
||||
screen, keys, _ := headlessBrowser(t)
|
||||
b.tty, b.keys = screen.tty, screen.keys
|
||||
return b, func(s string) { keys.WriteString(s); b.sortPrompt() }
|
||||
}
|
||||
|
||||
b, press := newBrowser()
|
||||
press("c")
|
||||
if b.order().name != "cpu load" || !b.sortDesc {
|
||||
t.Errorf("c gave %q desc=%v, want cpu load busiest first", b.order().name, b.sortDesc)
|
||||
}
|
||||
if b.rows[0].name != "alpha" {
|
||||
t.Errorf("the busiest machine is not first: %s", b.rows[0].name)
|
||||
}
|
||||
|
||||
press("r")
|
||||
if !strings.Contains(b.sortLabel(), "↑") || b.rows[0].name != "bravo" {
|
||||
t.Errorf("r did not reverse: %q, first %s", b.sortLabel(), b.rows[0].name)
|
||||
}
|
||||
|
||||
before := b.order().name
|
||||
press("\x1b") // Esc
|
||||
if b.order().name != before {
|
||||
t.Errorf("Esc changed the order to %q", b.order().name)
|
||||
}
|
||||
press("Z") // not a choice
|
||||
if b.order().name != before {
|
||||
t.Errorf("a stray letter changed the order to %q", b.order().name)
|
||||
}
|
||||
if b.prompt != nil {
|
||||
t.Error("the legend stayed on screen")
|
||||
}
|
||||
}
|
||||
|
||||
// The title says which order the table is in, the heading of that column is lit,
|
||||
// and exactly one column is.
|
||||
func TestSortIsVisibleInTheTable(t *testing.T) {
|
||||
b := &browser{rows: sortFixture()}
|
||||
for i, o := range sortOrders {
|
||||
b.sortBy, b.sortDesc = i, o.natural
|
||||
b.applySort()
|
||||
|
||||
if !strings.Contains(b.sortLabel(), o.name) {
|
||||
t.Errorf("the title says %q for the %s order", b.sortLabel(), o.name)
|
||||
}
|
||||
arrow := "↑"
|
||||
if o.natural {
|
||||
arrow = "↓"
|
||||
}
|
||||
if !strings.HasPrefix(b.sortLabel(), arrow) {
|
||||
t.Errorf("%s: the title shows %q, want it to start %s", o.name, b.sortLabel(), arrow)
|
||||
}
|
||||
|
||||
lit := 0
|
||||
for _, c := range browseColumns {
|
||||
if b.sortedColumn(c.header) {
|
||||
lit++
|
||||
}
|
||||
}
|
||||
if lit != 1 {
|
||||
t.Errorf("the %s order lights %d column headings, want exactly 1", o.name, lit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every order is offered, and the legend fits a terminal of eighty.
|
||||
func TestSortLegend(t *testing.T) {
|
||||
legend := sortLegend()
|
||||
for _, o := range sortOrders {
|
||||
if !strings.Contains(legend, string(o.key)+"·"+shortName(o.name)) {
|
||||
t.Errorf("the legend does not offer %q for %s: %s", string(o.key), o.name, legend)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(legend, string(sortReverse)+"·reverse") {
|
||||
t.Errorf("the legend does not offer the reverse: %s", legend)
|
||||
}
|
||||
if n := len([]rune(legend)); n > 78 { // a terminal of eighty, less the gutter
|
||||
t.Errorf("the legend is %d columns wide: %s", n, legend)
|
||||
}
|
||||
|
||||
// Distinct letters, or one of them would be unreachable.
|
||||
seen := map[rune]bool{sortReverse: true}
|
||||
for _, o := range sortOrders {
|
||||
if seen[o.key] {
|
||||
t.Errorf("%q is the letter for more than one order", string(o.key))
|
||||
}
|
||||
seen[o.key] = true
|
||||
}
|
||||
}
|
||||
|
||||
// The sort legend is a menu, not a question: it must not be dressed as a warning
|
||||
// and must not have the yes/no hint hung on it, which is what happened while the
|
||||
// two shared one string.
|
||||
func TestSortLegendIsNotAYesNoQuestion(t *testing.T) {
|
||||
b := &browser{rows: sortFixture()}
|
||||
b.applySort()
|
||||
screen, keys, cleanup := headlessBrowser(t)
|
||||
defer cleanup()
|
||||
b.tty, b.keys = screen.tty, screen.keys
|
||||
|
||||
t.Setenv("COLUMNS", "100")
|
||||
t.Setenv("LINES", "16")
|
||||
|
||||
// Render the legend by asking for it and reading the frame the prompt puts up,
|
||||
// with the keystroke that answers it queued behind.
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer r.Close()
|
||||
b.tty = w
|
||||
keys.WriteString("n")
|
||||
|
||||
done := make(chan string, 1)
|
||||
go func() {
|
||||
out, _ := io.ReadAll(r)
|
||||
done <- string(out)
|
||||
}()
|
||||
b.sortPrompt()
|
||||
w.Close()
|
||||
frame := stripEscapes(<-done)
|
||||
|
||||
if !strings.Contains(frame, "sort:") {
|
||||
t.Fatalf("the legend was not drawn:\n%s", frame)
|
||||
}
|
||||
if strings.Contains(frame, "y = yes") {
|
||||
t.Error("the yes/no hint was hung on the sort legend")
|
||||
}
|
||||
for _, line := range strings.Split(frame, "\r\n") {
|
||||
if strings.Contains(line, "sort:") && len([]rune(line)) > 100 {
|
||||
t.Errorf("the legend line is %d columns wide: %q", len([]rune(line)), line)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,126 +1,26 @@
|
||||
// ======================================================================================= go toolbox (mwx'2026)
|
||||
//
|
||||
// What gvm actually uses. The toolbox this came from carries a good deal more —
|
||||
// base62 encoders, a mysql row reader, viper and gjson wrappers, an ip-range
|
||||
// access check — and none of it was ever called here. Carried along, it was three
|
||||
// dependencies and a page of code that nothing exercised and no test covered,
|
||||
// including one access check that had never been wired up and would have panicked
|
||||
// on the first malformed entry in its list. It is gone; the toolbox it belongs to
|
||||
// still has it.
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/AlecAivazis/survey/v2/terminal"
|
||||
"github.com/eknkc/basex"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
var tbversion = "0.6.0"
|
||||
|
||||
var CHRS = "VW9IdGJ6eXh1T25DRHdrc2M5MlhOQVNQcEJFWnJhWVY2ZEowaFJLdmoxNUdxVDRJZkZpTTdRZW0zTFc4Z2w="
|
||||
var LR = []rune("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
|
||||
|
||||
// Updating oneself lives in selfupdate.go now: releases of a Gitea instance,
|
||||
// fetched over TLS and probed before they replace the running binary. What used
|
||||
// to be here downloaded a binary over plain http and applied it unverified.
|
||||
|
||||
func checkaccess(NETS []string) { // ------------------------------------------------- check ip net based access
|
||||
addrs, err := net.InterfaceAddrs() // once, not once per configured network
|
||||
if err != nil {
|
||||
PE("Error getting addresses")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
match := 0
|
||||
for _, validnet := range NETS {
|
||||
_, ipNet, err := net.ParseCIDR(validnet)
|
||||
if err != nil { // a typo in the list is a typo, not a nil dereference
|
||||
PE("bad network " + validnet)
|
||||
continue
|
||||
}
|
||||
for _, address := range addrs {
|
||||
if ipnet, ok := address.(*net.IPNet); ok && !ipnet.IP.IsLoopback() {
|
||||
if ipnet.IP.To4() != nil {
|
||||
if ipNet.Contains(ipnet.IP) {
|
||||
match++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if match == 0 {
|
||||
PE("access violation, permission denied")
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func Enc(str string) string { // ----------------------------------------------------------------- encode string
|
||||
enc, err := basex.NewEncoding(Db64(CHRS))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return enc.Encode([]byte(Rndstr(2) + str))
|
||||
}
|
||||
|
||||
func Dec(str string) string { // ----------------------------------------------------------------- decode string
|
||||
enc, err := basex.NewEncoding(Db64(CHRS))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
b, err := enc.Decode(str)
|
||||
if err != nil || len(b) < 2 { // the 2 bytes are the Rndstr(2) prefix Enc puts in front
|
||||
return ""
|
||||
}
|
||||
return string(b[2:])
|
||||
}
|
||||
|
||||
func Db64(txt string) string { // ------------------------------------------------------------- string to base64
|
||||
d, _ := base64.StdEncoding.DecodeString(txt)
|
||||
return string(d)
|
||||
}
|
||||
|
||||
func Rndstr(n int) string { // ------------------------------------------------------- random string with length
|
||||
b := make([]rune, n)
|
||||
for i := range b {
|
||||
n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(LR))))
|
||||
b[i] = LR[n.Int64()]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func Input(msg string, def string) string { // -------------------------------- AlecAivazis/survey: input string
|
||||
tmp := ""
|
||||
err := survey.AskOne(&survey.Input{Message: msg, Default: def}, &tmp)
|
||||
if err != nil {
|
||||
if err == terminal.InterruptErr {
|
||||
P(Crb("Interrupted."))
|
||||
os.Exit(0)
|
||||
}
|
||||
}
|
||||
return tmp
|
||||
}
|
||||
|
||||
func Inputpw(msg string) string { // ---------------------------------------- AlecAivazis/survey: input password
|
||||
tmp := ""
|
||||
err := survey.AskOne(&survey.Password{Message: msg}, &tmp)
|
||||
if err != nil {
|
||||
if err == terminal.InterruptErr {
|
||||
P(Crb("Interrupted."))
|
||||
os.Exit(0)
|
||||
}
|
||||
}
|
||||
return tmp
|
||||
}
|
||||
|
||||
func Yesno(msg string, def bool, overwrite bool) bool { // -------------------------- AlecAivazis/survey: yes/no
|
||||
|
||||
if overwrite {
|
||||
@@ -149,16 +49,6 @@ func Yesno(msg string, def bool, overwrite bool) bool { // ---------------------
|
||||
}
|
||||
}
|
||||
|
||||
func Getid(n int) string { // --------------------------------------------------------- get base62 random string
|
||||
const letters = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
ret := make([]byte, n)
|
||||
for i := 0; i < n; i++ {
|
||||
num, _ := rand.Int(rand.Reader, big.NewInt(int64(len(letters))))
|
||||
ret[i] = letters[num.Int64()]
|
||||
}
|
||||
return string(ret)
|
||||
}
|
||||
|
||||
func GETid(n int) string { // --------------------------------------------------------- get base36 random string
|
||||
const letters = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
ret := make([]byte, n)
|
||||
@@ -169,70 +59,6 @@ func GETid(n int) string { // --------------------------------------------------
|
||||
return string(ret)
|
||||
}
|
||||
|
||||
func Mytable(rows *sql.Rows) []map[string]interface{} { // ----------------------------- load mysql result table
|
||||
defer rows.Close()
|
||||
columns, _ := rows.Columns()
|
||||
count := len(columns)
|
||||
tableData := make([]map[string]interface{}, 0)
|
||||
values := make([]interface{}, count)
|
||||
valuePtrs := make([]interface{}, count)
|
||||
for rows.Next() {
|
||||
for i := 0; i < count; i++ {
|
||||
valuePtrs[i] = &values[i]
|
||||
}
|
||||
rows.Scan(valuePtrs...)
|
||||
entry := make(map[string]interface{})
|
||||
for i, col := range columns {
|
||||
var v interface{}
|
||||
val := values[i]
|
||||
b, ok := val.([]byte)
|
||||
if ok {
|
||||
v = string(b)
|
||||
} else {
|
||||
v = val
|
||||
}
|
||||
entry[col] = v
|
||||
}
|
||||
tableData = append(tableData, entry)
|
||||
}
|
||||
return (tableData)
|
||||
}
|
||||
|
||||
func Checkip(network string, ip string) bool { // ---------- check if ip is in range (cidr address or single ip)
|
||||
if net.ParseIP(ip) == nil {
|
||||
return false
|
||||
}
|
||||
_, subnet, err := net.ParseCIDR(network)
|
||||
if err == nil {
|
||||
if subnet.Contains(net.ParseIP(ip)) {
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
if network == ip {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func Isflagpassed(name string) bool { // -------------------------------------------------- check if flag is set
|
||||
found := false
|
||||
flag.Visit(func(f *flag.Flag) {
|
||||
if f.Name == name {
|
||||
found = true
|
||||
}
|
||||
})
|
||||
return found
|
||||
}
|
||||
|
||||
func Body(r *http.Response) string { // ------------------------------------------------------------ http body
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err == nil {
|
||||
return string(body)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func Atoi(s string) int { // ------------------------------------------------------------------------------ atoi
|
||||
i, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
@@ -245,14 +71,6 @@ func Itoa(i int) string { // ---------------------------------------------------
|
||||
return strconv.Itoa(i)
|
||||
}
|
||||
|
||||
func GJA(j string, k string) []string { // -------------------------------------------------- convert gjson array
|
||||
var ret []string
|
||||
for _, c := range gjson.Get(j, k).Array() {
|
||||
ret = append(ret, c.String())
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------------------ print simple
|
||||
|
||||
func P(a ...any) (n int, err error) { return fmt.Fprintln(os.Stdout, a...) }
|
||||
@@ -276,51 +94,8 @@ func PO(msg ...string) (n int, err error) {
|
||||
// errf is fmt.Errorf under a shorter name, to go with the P/PF/SF family.
|
||||
func errf(format string, a ...any) error { return fmt.Errorf(format, a...) }
|
||||
|
||||
// ------------------------------------------------------------------------------------------ regular expression
|
||||
|
||||
func ReplaceFirst(re *regexp.Regexp, str, replace string) string {
|
||||
loc := re.FindStringIndex(str)
|
||||
if loc == nil {
|
||||
return str
|
||||
}
|
||||
return str[:loc[0]] + replace + str[loc[1]:]
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------- string functions
|
||||
|
||||
func Shortstr(s string, length int) string {
|
||||
runes := []rune(s)
|
||||
if len(runes) <= length {
|
||||
return s
|
||||
}
|
||||
return string(runes[:length-2]) + ".."
|
||||
}
|
||||
|
||||
func SR(str string, n int) string { return strings.Repeat(str, n) }
|
||||
|
||||
func RemoveAllMatches(slice []string, target string) []string { // remove matching string from array
|
||||
// A new slice, not slice[:0]: filtering in place writes through the caller's
|
||||
// backing array, so the caller's own variable is left holding shifted,
|
||||
// duplicated entries — and it looks like a pure function at the call site.
|
||||
result := make([]string, 0, len(slice))
|
||||
for _, v := range slice {
|
||||
if v != target {
|
||||
result = append(result, v)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------- system functions
|
||||
|
||||
func prgname() string { // program name
|
||||
exepath, err := os.Executable()
|
||||
if err != nil {
|
||||
PE(SF("Error getting executable path: %s", err))
|
||||
return ""
|
||||
}
|
||||
exename := filepath.Base(exepath)
|
||||
return exename
|
||||
}
|
||||
|
||||
// ========================================================================================================= END
|
||||
|
||||
@@ -83,6 +83,7 @@ const (
|
||||
keyShiftTab
|
||||
keyEnter
|
||||
keyCtrlA
|
||||
keyCtrlO
|
||||
keyCtrlR
|
||||
keyCtrlS
|
||||
keyCtrlC
|
||||
@@ -136,6 +137,8 @@ func (kr *keyReader) next() key {
|
||||
return key{special: keyCtrlC}
|
||||
case 0x01:
|
||||
return key{special: keyCtrlA}
|
||||
case 0x0f:
|
||||
return key{special: keyCtrlO}
|
||||
case 0x12:
|
||||
return key{special: keyCtrlR}
|
||||
case 0x13:
|
||||
@@ -183,11 +186,39 @@ func (kr *keyReader) readEscape() key {
|
||||
if b2 != '[' {
|
||||
return key{special: keyEsc}
|
||||
}
|
||||
b3, ok := kr.readByte()
|
||||
if !ok {
|
||||
return key{special: keyEsc}
|
||||
|
||||
// Read the whole sequence before deciding what it was. A control sequence is
|
||||
// ESC [ then parameter bytes then one final byte in 0x40..0x7e, and how many
|
||||
// parameters there are depends on the key *and* on which modifiers were held:
|
||||
// the plain up arrow is "ESC [ A", the same key with control is "ESC [ 1;5A".
|
||||
//
|
||||
// Reading a fixed number of bytes instead — one, and for the digits one more
|
||||
// for the "~" — left the rest of a longer sequence in the stream, where the
|
||||
// next read took it for typing. Ctrl-Up put "5A" into the filter and F5 put a
|
||||
// tilde in it, having first jumped to the top of the list.
|
||||
params := make([]byte, 0, 8)
|
||||
var final byte
|
||||
for {
|
||||
b, ok := kr.readByte()
|
||||
if !ok {
|
||||
return key{special: keyEsc}
|
||||
}
|
||||
if b >= 0x40 && b <= 0x7e {
|
||||
final = b
|
||||
break
|
||||
}
|
||||
if len(params) < cap(params) {
|
||||
params = append(params, b)
|
||||
}
|
||||
}
|
||||
switch b3 {
|
||||
|
||||
// Only the unmodified keys are answered. A sequence with modifiers, or one
|
||||
// this does not know, is swallowed whole and ignored — which is the point:
|
||||
// what must not happen is for half of it to arrive as text.
|
||||
if len(params) > 0 && final != '~' {
|
||||
return key{special: keyNone}
|
||||
}
|
||||
switch final {
|
||||
case 'A':
|
||||
return key{special: keyUp}
|
||||
case 'B':
|
||||
@@ -202,22 +233,21 @@ func (kr *keyReader) readEscape() key {
|
||||
return key{special: keyEnd}
|
||||
case 'Z':
|
||||
return key{special: keyShiftTab}
|
||||
case '1', '3', '4', '5', '6':
|
||||
kr.readByte() // consume trailing '~'
|
||||
switch b3 {
|
||||
case '1':
|
||||
case '~':
|
||||
switch string(params) {
|
||||
case "1", "7":
|
||||
return key{special: keyHome}
|
||||
case '3':
|
||||
case "3":
|
||||
return key{special: keyDelete}
|
||||
case '4':
|
||||
case "4", "8":
|
||||
return key{special: keyEnd}
|
||||
case '5':
|
||||
case "5":
|
||||
return key{special: keyPgUp}
|
||||
case '6':
|
||||
case "6":
|
||||
return key{special: keyPgDn}
|
||||
}
|
||||
}
|
||||
return key{special: keyEsc}
|
||||
return key{special: keyNone}
|
||||
}
|
||||
|
||||
func utf8SeqLen(b byte) int {
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
1.0.0
|
||||
1.0.17
|
||||
|
||||
@@ -1,110 +1,96 @@
|
||||
// vm.go — the virtual machine listing.
|
||||
// vm.go — the printed machine listing.
|
||||
//
|
||||
// The same columns, cells and colours as the interactive list, from the same
|
||||
// table: `gvm vm -l` and `gvm vm` used to disagree about the very same machine,
|
||||
// the printed one showing a bare 32 where the other showed 32.0MB, and lacking
|
||||
// the power state, the host and both load figures entirely. There is one
|
||||
// definition of what a row of machines looks like (browse.go) and both views
|
||||
// render it.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"github.com/fatih/color"
|
||||
)
|
||||
|
||||
// lsvm lists the virtual machines of every server it is given, sorted by name
|
||||
// across all of them. One unreachable vCenter is a warning and not the end: the
|
||||
// old version called log.Fatal inside the loop, which threw away everything the
|
||||
// servers before it had already answered.
|
||||
//
|
||||
// The servers are asked at the same time. Three logins one after another are
|
||||
// three round trips to three machines, and there is nothing to be gained by
|
||||
// waiting for each in turn.
|
||||
func lsvm(targets []VCenter, match string) error {
|
||||
// lsvm prints the machines of every server it is given. One unreachable vCenter
|
||||
// is a line of complaint, not the end of the listing.
|
||||
func lsvm(targets []VCenter, match, orderBy string, reverse bool) error {
|
||||
re, err := regexp.Compile("(?i)" + match)
|
||||
if err != nil {
|
||||
return fmt.Errorf("bad pattern %q: %w", match, err)
|
||||
return errf("bad pattern %q: %w", match, err)
|
||||
}
|
||||
by, err := findOrder(orderBy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
const (
|
||||
xfmt = "%-32s %-8s %4s %-15s %3d %8d\n"
|
||||
xfmts = "%-32s %-8s %4s %-15s %3s %8s\n"
|
||||
)
|
||||
PF(xfmts, "VM NAME", "ID", "VM", "IP", "CPU", "MEMORY")
|
||||
PF(xfmts, SR("=", 32), SR("=", 8), SR("=", 4), SR("=", 15), SR("=", 3), SR("=", 8))
|
||||
|
||||
rows := make([][]string, len(targets))
|
||||
errs := make([]error, len(targets))
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i, vc := range targets {
|
||||
wg.Add(1)
|
||||
go func(i int, vc VCenter) {
|
||||
defer wg.Done()
|
||||
rows[i], errs[i] = listOne(vc, re, xfmt)
|
||||
}(i, vc)
|
||||
found, err := gatherVMs(targets)
|
||||
defer closeSessions(found.sessions)
|
||||
for _, why := range found.failed {
|
||||
PE(why) // said before the table, where it will not be scrolled past
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
var all []string
|
||||
failed := 0
|
||||
for i := range targets {
|
||||
if errs[i] != nil {
|
||||
PE(errs[i].Error())
|
||||
failed++
|
||||
continue
|
||||
rows := make([]vmRow, 0, len(found.rows))
|
||||
for _, r := range found.rows {
|
||||
if re.MatchString(r.name) {
|
||||
rows = append(rows, r)
|
||||
}
|
||||
all = append(all, rows[i]...)
|
||||
}
|
||||
sortRows(rows, by, reverse != sortOrders[by].natural)
|
||||
|
||||
sort.Strings(all)
|
||||
for _, line := range all {
|
||||
PN(line)
|
||||
}
|
||||
|
||||
// Nothing at all came back: that is a failure of the command, not a listing
|
||||
// that happens to be empty, and the exit status should say so.
|
||||
if failed == len(targets) {
|
||||
return fmt.Errorf("no vCenter answered")
|
||||
}
|
||||
printList(rows)
|
||||
return nil
|
||||
}
|
||||
|
||||
// listOne collects the formatted lines of a single server.
|
||||
func listOne(vc VCenter, re *regexp.Regexp, xfmt string) ([]string, error) {
|
||||
s, err := connect(vc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer s.close()
|
||||
// printList writes the table. On a terminal it is fitted to the width, the same
|
||||
// as the interactive one; into a pipe every column is written and every column is
|
||||
// made as wide as the longest thing in it.
|
||||
//
|
||||
// That last part is not a nicety. Fitting a pipe to the interactive minimum cut
|
||||
// machine names off at twenty-two characters — silently, with an ellipsis, into
|
||||
// output whose whole purpose is to be read by something else.
|
||||
func printList(rows []vmRow) {
|
||||
cols := fitColumns(printWidth())
|
||||
|
||||
vms, err := s.vms("summary", "guest")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
pcs := make([]printColumn, len(cols))
|
||||
for i, c := range cols {
|
||||
pcs[i] = printColumn{header: c.header, width: c.width}
|
||||
}
|
||||
|
||||
var out []string
|
||||
for _, vm := range vms {
|
||||
name := vm.Summary.Config.Name
|
||||
if name == "" || !re.MatchString(name) {
|
||||
continue
|
||||
body := make([][]cell, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
cells := make([]cell, len(cols))
|
||||
for i, c := range cols {
|
||||
col := ""
|
||||
if c.color != nil {
|
||||
col = c.color(r)
|
||||
}
|
||||
cells[i] = cell{text: c.cell(r), col: col}
|
||||
}
|
||||
|
||||
// Both of these are optional in vSphere and absent for a machine that
|
||||
// is being created or deleted while the listing runs — reading them
|
||||
// unguarded is a nil dereference at exactly the wrong moment.
|
||||
ip := "-"
|
||||
if vm.Guest != nil && vm.Guest.IpAddress != "" {
|
||||
ip = vm.Guest.IpAddress
|
||||
}
|
||||
id := "-"
|
||||
if vm.Summary.Vm != nil {
|
||||
id = vm.Summary.Vm.Value
|
||||
}
|
||||
|
||||
out = append(out, SF(xfmt,
|
||||
Shortstr(name, 32),
|
||||
id,
|
||||
vc.Name,
|
||||
ip,
|
||||
vm.Summary.Config.NumCpu,
|
||||
vm.Summary.Config.MemorySizeMB))
|
||||
body = append(body, cells)
|
||||
}
|
||||
if color.NoColor { // no terminal, so nothing to fit into and nothing to cut for
|
||||
widen(pcs, body)
|
||||
}
|
||||
|
||||
printRow(pcs, "", nil)
|
||||
for _, cells := range body {
|
||||
printRow(pcs, "", cells)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// printWidth is what the table is laid out for. color.NoColor is the answer to
|
||||
// "is this a terminal" that the colours already go by, so the two cannot disagree
|
||||
// about where the output is headed.
|
||||
func printWidth() int {
|
||||
if color.NoColor {
|
||||
return tableWidth(browseColumns)
|
||||
}
|
||||
cols, _ := termSize()
|
||||
return max(cols, 20)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user