3 Commits
Author SHA1 Message Date
Michael Wesemann 4a5477bde7 [mike@mwxm4] 2026-09-08 17:15:05 +02:00
Michael Wesemann 137a799399 [mike@mwxm4] 2026-09-08 17:08:47 +02:00
Michael Wesemann b902059402 [mike@mwxm4] 2026-09-08 17:00:17 +02:00
7 changed files with 444 additions and 66 deletions
+55 -10
View File
@@ -157,7 +157,11 @@ characters spent on nothing:
when nothing is going on. A column that turns up because somebody started a
clone is not the layout shifting about: it is the news.
* **WHY** takes the guest operating system's place in the issues list (`^w`,
`--issues`), where every row has a reason to be there.
`--issues`), where every row has a reason to be there — and the four figures
go with it, so that the reason has the width. It is the last column that
listing gives up rather than the first: in a list whose every row is there
because of it, dropping the reason first leaves a list of machines with no
reason showing on any of them.
A terminal too narrow for all of that gives columns up, least useful first: the
guest's operating system, then the host, then the snapshot count, then the
@@ -174,7 +178,7 @@ be said — `^w`, `--issues` and `snap --old` all name it and date it.
`^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·pwr c·cpu% m·mem% s·size u·cpus v·vc h·host a·ip o·old r·reverse
sort: n·name p·pwr c·cpu% m·mem% z·snaps o·old w·why 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
@@ -189,9 +193,34 @@ 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.
`o` is by the age of the machine's oldest snapshot, oldest first, which is the
order the housekeeping is done in; a machine with no snapshots has no age and
sorts to the bottom either way round, the same as a stopped machine's load does.
Thirteen choices are ninety-three columns, so a terminal narrower than that
gets them on two lines instead of one that runs off the right-hand edge, hiding
the very choices the legend exists to offer. They break where the meaning
breaks — what the machine is doing and what it wants doing to it, then what it
is made of and where it lives — and the second line takes the help line's row,
which describes keys that do nothing while a menu is waiting for one. Decided
at render time, so a window dragged wider gets the one line back:
sort: n·name p·pwr c·cpu% m·mem% z·snaps o·old w·why
s·size u·cpus v·vc h·host a·ip r·reverse
Three of them are about the two columns that are new:
* `z` is by how many snapshots the machine is carrying, most first. Nought is a
figure here and not a missing one — nothing to clean up is a fact about the
machine — so a machine with none sorts where nought belongs: at the bottom
going down, at the top coming back up. The letter carries no mnemonic because
every letter that does was taken; `--sort snaps` spells it out.
* `o` is by the age of the machine's *oldest* snapshot, oldest first, which is
the order the housekeeping is done in — a different question from `#`, and the
more useful one: one snapshot from March wants attention before six from this
morning. A machine with no snapshots has no age and sorts to the bottom either
way round, the same as a stopped machine's load does.
* `w` is by what is wrong with the machine: broken above wants-a-look above
nothing to report, and within each the machine with the most to answer for
first. Sorting the reasons as text would put "alarm" above "disks need
consolidating" and mean nothing at all. Run the other way up it is the
machines that are fine, by name — a listing worth having too.
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.
@@ -207,16 +236,24 @@ A list of two hundred machines is read by running the eye down it, which is
exactly the wrong way to find the three that are broken. `^w` narrows it to
those, and each one carries the reason in place of its guest operating system:
NAME VC PWR SNAP IP CPU% MEM% WHY
db01 v308 on 3 10.0.0.12 12 64 disks need consolidating
old01 v108 on 1 10.0.0.31 2 18 /var 97 % full · no VMware Tools
win7 v38 on - - 0 9 vCenter says yellow
NAME VC PWR SNAP IP HOST WHY
old01 v108 on 1 10.0.0.31 esx02 /var 97 % full · no VMware Tools
db01 v308 on 3 10.0.0.12 esx01 disks need consolidating · snapshot base is 63 days old
win7 v38 on - - esx07 vCenter says yellow
The four figures — vCPUs, processor load, memory and memory in use — are not
there. A machine is in this list because something is wrong with it, and how
hard its processors happen to be working at this second says nothing about any
of the reasons: they would be four columns of arithmetic between the machine's
name and the answer to the question that was asked. They are one keystroke away
in the ordinary list, and on the machine's own sheet.
Nothing new is asked of the servers: this is a filter over the sweep that is
already on screen, so it costs a keystroke and no waiting. `^w` again gives the
whole list back, the typed filter still applies inside it — `^w web` is the
broken web servers — and the title says `issues only` for as long as it is on,
because a filtered list that does not say so is a lie told by omission.
because a filtered list that does not say so is a lie told by omission. `^o w`
puts the worst of them at the top.
What counts as an issue is deliberately narrow, because a list that cries wolf
is one nobody opens:
@@ -553,6 +590,14 @@ selected row on a `darker` surface with a `violet` pointer, the filter's hits in
`pink`, counts in `green`, questions in `yellow`, errors in `red`, headers and
the help line in `dark`.
Every line at the foot of the screen that wants an answer is that one `yellow`,
whatever kind of question it is: the sort legend, both its lines; a yes/no
question and its hint; the label in front of a snapshot name or the `YES` of a
confirmation. They are different kinds of question and one state — gvm is
waiting for a key — and that state is worth learning once, in one place and one
tone, rather than being worked out per screen. What is typed in answer stays
`white`: it is the operator's, not part of the question.
Inside the table and the sheet every colour is a role, not a decoration:
| | |
+55 -23
View File
@@ -39,20 +39,29 @@ const (
// The screen's colours. The comment on each is the name mwxcol's fzf theme gives
// the same job, so the two stay in step.
var (
colRow = cGrey.fg() // fg
colRowSel = cWhite.fg() // fg+
colSurface = cDarker.bg() // bg+
colMatch = cPink.fg() // hl, hl+
colPointer = cViolet.fg() // pointer
colHeader = cDark.fg() // header, label
colInfo = cGreen.fg() // info
colQuery = cWhite.fg() // query
colErr = cRed.fg() // prompt
colWarn = cYellow.fg() // not in the theme: a question, a wait
colTitle = attrBold + cWhite.fg() //
colLabel = cDark.fg() // the sheet's field names
colValue = cWhite.fg() // its values, where nothing better applies
colDim = cDark.fg() // disabled
colRow = cGrey.fg() // fg
colRowSel = cWhite.fg() // fg+
colSurface = cDarker.bg() // bg+
colMatch = cPink.fg() // hl, hl+
colPointer = cViolet.fg() // pointer
colHeader = cDark.fg() // header, label
colInfo = cGreen.fg() // info
colQuery = cWhite.fg() // query
colErr = cRed.fg() // prompt
colWarn = cYellow.fg() // not in the theme: a question, a wait
// colPrompt is every line at the foot of the screen that wants an answer:
// the sort legend, a yes/no question, the label in front of something being
// typed. One colour for all of them, whatever kind of question it is —
// yellow, which is the job mwxcol's own theme gives it — so that "gvm is
// waiting for me" is learned once, in one place and one tone, rather than
// being a thing to work out per screen. It is the same value as colWarn and
// a name of its own, because the two mean different things and only one of
// them may ever change.
colPrompt = cYellow.fg()
colTitle = attrBold + cWhite.fg() //
colLabel = cDark.fg() // the sheet's field names
colValue = cWhite.fg() // its values, where nothing better applies
colDim = cDark.fg() // disabled
)
// What the columns and the sheet's values are coloured by. Every one of these is
@@ -386,6 +395,14 @@ var taskColumn = browseColumn{header: "TASK", width: 13, expendable: 7,
var whyColumn = browseColumn{header: "WHY", width: 30, flex: true, expendable: 11,
cell: vmRow.issueCell, color: vmRow.issueColor}
// The figures the issues listing leaves out. A machine is in that list because
// something is wrong with it, and how hard its processors happen to be working
// at this second says nothing about any of the reasons — it is four columns of
// arithmetic between the machine's name and the answer to the question that was
// asked. They are one keystroke away in the ordinary list, and on the machine's
// own sheet.
var issueHides = []string{"CPU", "CPU%", "MEM", "MEM%"}
// listColumns is the table for this particular listing: the standing columns,
// with the two conditional ones put in where they belong. The task goes next to
// the power state, because both answer "what is this machine doing"; the reason
@@ -402,9 +419,14 @@ func listColumns(rows []vmRow, why bool) []browseColumn {
out := make([]browseColumn, 0, len(browseColumns)+1)
for _, c := range browseColumns {
if why && c.header == "GUEST OS" {
out = append(out, whyColumn)
continue
if why {
if c.header == "GUEST OS" {
out = append(out, whyColumn)
continue
}
if contains(issueHides, c.header) {
continue
}
}
out = append(out, c)
if busy && c.header == "PWR" {
@@ -1200,12 +1222,17 @@ type prompt struct {
text string
hint string
col string
// more is a second line, drawn where the help line goes. Only the sort
// legend uses it: a menu of thirteen choices does not fit across eighty
// columns, and the help line underneath it is describing keys that do
// nothing while a menu is waiting for one.
more 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 = &prompt{text: question, hint: " y = yes, anything else = no", col: colWarn}
b.prompt = &prompt{text: question, hint: " y = yes, anything else = no", col: colPrompt}
b.render()
k := b.keys.next()
b.prompt = nil
@@ -1325,18 +1352,23 @@ func (b *browser) renderList() {
default:
segLine(&sb, cols)
}
sb.WriteString(colDim + truncate(listHelp, cols) + attrOff + scrEOL)
help, helpCol := listHelp, colDim
if b.prompt != nil && b.prompt.more != "" {
help, helpCol = b.prompt.more, b.prompt.col
}
sb.WriteString(helpCol + truncate(help, cols) + attrOff + scrEOL)
b.parkCursor(&sb, cols, rows)
b.write(sb.String())
}
// editLine draws the input in the status area: the label stays put, the typed
// text is the query colour, and a hint says what Enter alone would do.
// editLine draws the input in the status area: the label stays put in the colour
// every question at the foot of the screen has, and the typed text is the query
// colour, because it is the answer and not part of the question.
func (b *browser) editLine(sb *strings.Builder, cols int) {
segLine(sb, cols,
seg{colWarn, b.edit.label},
seg{colQuery, string(b.edit.runes)})
seg{colPrompt, b.edit.label},
seg{colQuery, string(b.edit.runes)}) // the answer is the operator's, and white
}
// parkCursor puts the terminal's own cursor where the typing happens and shows
+69 -2
View File
@@ -1173,8 +1173,18 @@ func TestTheReasonColumnReplacesTheGuestOS(t *testing.T) {
if !strings.Contains(got, "WHY") {
t.Errorf("the issues listing has no reason column: %s", got)
}
if len(listColumns(rows, true)) != len(browseColumns) {
t.Error("the reason column was added instead of taking a place")
// And the four figures go with it: how hard a machine's processors happen
// to be working says nothing about what is wrong with it, and they are four
// columns between its name and the answer.
for _, gone := range issueHides {
if strings.Contains(got, gone) {
t.Errorf("the issues listing still shows %s: %s", gone, got)
}
}
// What is left is what identifies the machine, plus the reason.
if want := "NAME VC PWR SNAP IP HOST WHY"; got != want {
t.Errorf("the issues listing shows\n %s\nwant\n %s", got, want)
}
}
@@ -1391,3 +1401,60 @@ func TestEveryScreenDrawsWhatItHasToSay(t *testing.T) {
}
}
}
// Every line at the foot of the screen that wants an answer wears one colour.
// The sort legend is a menu, a confirmation is a yes/no question and a snapshot
// name is something typed, and they are three different kinds of question — but
// "gvm is waiting for me" is one thing, and it is learned once rather than
// worked out per screen.
func TestEveryQuestionWearsOneColour(t *testing.T) {
t.Setenv("COLUMNS", "100")
t.Setenv("LINES", "12")
b := testBrowser("web01", "db01")
b.applySort()
for _, c := range []struct {
what string
set func()
text string
lines int // how many of the bottom rows the question occupies
}{
{"the sort legend", func() {
// Narrower than the whole legend, so it is the two-line one: the
// second line is the part that could quietly lose its colour.
lines := sortLegend(60)
b.prompt = &prompt{text: lines[0], col: colPrompt, more: lines[1]}
b.edit = nil
}, "sort:", 2},
{"a yes/no question", func() {
b.prompt = &prompt{text: "power on web01 on v308?", col: colPrompt}
b.edit = nil
}, "power on web01", 1},
{"a name being typed", func() {
b.prompt = nil
b.edit = &editor{label: "name the snapshot of web01: "}
}, "name the snapshot", 1},
} {
c.set()
frame := renderToPipe(t, b, b.renderList)
if !strings.Contains(frame, colPrompt+c.text) {
t.Errorf("%s is not in the colour every question has:\n%s",
c.what, frame[max(len(frame)-400, 0):])
}
// And the second line of a two-line question is in it as well, or the
// menu would fade out halfway down.
if c.lines == 2 {
second := stripEscapes(frame)
if !strings.Contains(second, "r·reverse") {
t.Errorf("%s lost its second line:\n%s", c.what, second)
}
if strings.Count(frame, colPrompt) < 2 {
t.Errorf("%s does not carry the colour onto its second line", c.what)
}
}
}
b.prompt, b.edit = nil, nil
}
+2 -2
View File
@@ -47,7 +47,7 @@ var helpTail = strings.Join([]string{
// number with -ldflags "-X main.version=...". The value here is what a plain
// `go build` produces, and it tracks the line of development rather than the
// latest build: version.txt holds that.
var version = "1.0.0"
var version = "1.1.0"
func main() {
// Answered before anything else: an update has to work on a machine that
@@ -96,7 +96,7 @@ func run() error {
subVM.String(&vmMatch, "m", "match", "Only machines matching: a regexp for -l, plain text in the list")
var vmSort string
var vmReverse, vmIssues, vmJSON bool
subVM.String(&vmSort, "", "sort", "Order for -l: name, pwr, cpu%, mem%, size, cpus, vc, host, ip, old")
subVM.String(&vmSort, "", "sort", "Order for -l: name, pwr, cpu%, mem%, snaps, old, why, size, cpus, vc, host, ip")
subVM.Bool(&vmReverse, "", "reverse", "Turn that order around")
subVM.Bool(&vmIssues, "", "issues", "Only the machines with something wrong with them (^w in the list)")
subVM.Bool(&vmJSON, "", "json", "Print the listing as a JSON document instead of a table")
+113 -18
View File
@@ -21,10 +21,17 @@ type sortOrder struct {
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
// legendBreak starts a new line of the legend at this entry. Thirteen
// orders do not fit across eighty columns, and a legend that ran off the
// edge would hide the very choices it exists to offer — so it is two lines,
// broken where the meaning breaks rather than wherever the width runs out.
legendBreak bool
}
// 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.
// sortOrders in the order the legend lists them, which is two groups: first
// what a machine is doing and what it wants doing to it, then what it is made
// of and where it lives. The legend breaks between the two.
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,
@@ -33,7 +40,29 @@ var sortOrders = []sortOrder{
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,
// How many rollback points the machine is carrying, most first. Nought is a
// figure here and not a missing one — nothing to clean up is a fact about
// the machine — so a machine with none sorts where nought belongs, at the
// bottom going down and at the top coming back up.
//
// The key carries no mnemonic — every letter that does was taken — so it is
// simply one that is free and easy to reach. The name is what the command
// line takes: `--sort snapshots`, or `--sort snaps`.
{key: 'z', name: "snapshots", natural: true,
cmp: func(a, b vmRow) int { return cmpInt(a.snapCount(), b.snapCount()) }},
// By how long the machine has been dragging its oldest snapshot along, the
// oldest first — which is the order the housekeeping is done in. A machine
// with no snapshots has no age, and sorts to the bottom either way round.
{key: 'o', name: "snapshot age", natural: true,
cmp: func(a, b vmRow) int { return cmpLoad(vmRow.snapAge, a, b) }},
// By what is wrong with the machine, worst first: broken above wants-a-look
// above nothing to report, and within each the machine with the most to
// answer for first. Sorting the reasons as text would put "alarm" above
// "disks need consolidating" and mean nothing at all.
{key: 'w', name: "issues", natural: true,
cmp: func(a, b vmRow) int { return cmpIssues(a, b) }},
{key: 's', name: "memory size", natural: true, legendBreak: true,
cmp: func(a, b vmRow) int {
return cmpInt(int(a.vm.Summary.Config.MemorySizeMB), int(b.vm.Summary.Config.MemorySizeMB))
}},
@@ -44,11 +73,6 @@ var sortOrders = []sortOrder{
{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) }},
// By how long the machine has been dragging its oldest snapshot along, the
// oldest first — which is the order the housekeeping is done in. A machine
// with no snapshots has no age, and sorts to the bottom either way round.
{key: 'o', name: "snapshot age", natural: true,
cmp: func(a, b vmRow) int { return cmpLoad(vmRow.snapAge, a, b) }},
}
// sortReverse is the one legend entry that is not an order of its own.
@@ -110,6 +134,30 @@ func cmpLoad(load func(vmRow) (float64, bool), a, b vmRow) int {
return 0
}
// issueRank is how bad the machine's worst reason is: two for something broken,
// one for something that wants a look, nought for nothing to report.
func issueRank(r vmRow) int {
rank := 0
for _, i := range r.issueList() {
if i.bad {
return 2
}
rank = 1
}
return rank
}
// cmpIssues orders by that, and within it by how many reasons there are: a
// machine with a full disk *and* no Tools is worse off than one with only the
// disk. Nothing to report is nought and sorts where nought belongs, so the
// order run the other way up is the machines that are fine, by name.
func cmpIssues(a, b vmRow) int {
if n := cmpInt(issueRank(a), issueRank(b)); n != 0 {
return n
}
return cmpInt(len(a.issueList()), len(b.issueList()))
}
// 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 {
@@ -185,16 +233,47 @@ func (b *browser) sortLabel() string {
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)
// sortLegend is the choices, laid out for a terminal of this width: one line
// where they fit on one, and otherwise the two groups they fall into — what the
// machine is doing and wants doing to it, then what it is made of and where it
// lives.
//
// One line is the better answer and the usual one; two is what a narrow
// terminal gets instead of a legend that runs off the right-hand edge, hiding
// the very choices it exists to offer. Decided here, at render time, so a
// window that is dragged wider gets the one line back — the same way the table
// itself is fitted (fitColumns) and the sheet is wrapped.
//
// Terse either way: it shares the bottom of the screen with nothing but itself,
// and the title says what the order is anyway, so nobody who misses it is lost.
func sortLegend(cols int) []string {
const label = "sort: "
entries := make([]string, 0, len(sortOrders)+1)
for _, o := range sortOrders {
parts = append(parts, string(o.key)+"·"+shortName(o.name))
entries = append(entries, string(o.key)+"·"+shortName(o.name))
}
// Reverse is not an order of its own and goes at the end.
entries = append(entries, string(sortReverse)+"·reverse")
if one := label + strings.Join(entries, " "); len([]rune(one)) <= cols {
return []string{one}
}
// Two, broken where the meaning breaks. The second line is indented under
// the first one's entries rather than under its label, so the two read as
// one list and not as a sentence continued.
at := len(sortOrders)
for i, o := range sortOrders {
if o.legendBreak {
at = i
break
}
}
return []string{
label + strings.Join(entries[:at], " "),
SR(" ", len(label)) + strings.Join(entries[at:], " "),
}
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
@@ -217,6 +296,10 @@ func shortName(name string) string {
return "ip"
case "snapshot age":
return "old"
case "snapshots":
return "snaps"
case "issues":
return "why" // as the column is headed
}
return name
}
@@ -225,7 +308,17 @@ func shortName(name string) string {
// 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
// The colour every question at the foot of the screen has (colPrompt), and
// no yes/no hint: this is a menu and not a question answerable with y, but
// it is still gvm waiting for a key, and that is one thing wearing one
// colour. Where it takes two lines the second goes in place of the help
// line, which says nothing that applies while a menu is up.
cols, _ := termSize()
lines := sortLegend(cols)
b.prompt = &prompt{text: lines[0], col: colPrompt}
if len(lines) > 1 {
b.prompt.more = strings.Join(lines[1:], " ")
}
b.render()
k := b.keys.next()
b.prompt = nil
@@ -272,8 +365,10 @@ func (b *browser) sortedColumn(header string) bool {
return header == "HOST"
case "address":
return header == "IP"
case "snapshot age":
case "snapshot age", "snapshots":
return header == "SNAP"
case "issues":
return header == "WHY"
}
return false
}
+149 -10
View File
@@ -204,8 +204,11 @@ func TestSortIsVisibleInTheTable(t *testing.T) {
t.Errorf("%s: the title shows %q, want it to start %s", o.name, b.sortLabel(), arrow)
}
// In the table the order belongs to: sorting by what is wrong with a
// machine lights the reason column, which only the issues listing has.
table := listColumns(b.rows, o.name == "issues")
lit := 0
for _, c := range browseColumns {
for _, c := range table {
if b.sortedColumn(c.header) {
lit++
}
@@ -218,21 +221,59 @@ func TestSortIsVisibleInTheTable(t *testing.T) {
// Every order is offered, and the legend fits a terminal of eighty.
func TestSortLegend(t *testing.T) {
legend := sortLegend()
lines := sortLegend(80)
legend := strings.Join(lines, "\n")
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)
t.Errorf("the legend does not offer %q for %s:\n%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)
t.Errorf("the legend does not offer the reverse:\n%s", legend)
}
// The legend shares the status line, which begins at the left edge rather
// than behind the pointer's gutter, so the budget is a terminal of eighty
// whole. Nothing follows it on that line — the prompt's yes/no hint belongs
// to the questions, not to this menu.
if n := len([]rune(legend)); n > 80 {
t.Errorf("the legend is %d columns wide: %s", n, legend)
// Every line of it fits a terminal of eighty. The legend has the bottom two
// rows to itself, and they begin at the left edge rather than behind the
// pointer's gutter, so the budget is eighty whole — but a line over it would
// be truncated, and the choices it hid would be unreachable in the only
// place they are offered.
for i, line := range lines {
if n := len([]rune(line)); n > 80 {
t.Errorf("legend line %d is %d columns wide: %s", i+1, n, line)
}
}
// Two rows, and not three: there are only two to spare.
if len(lines) > 2 {
t.Errorf("the legend wants %d lines, and there is room for two:\n%s", len(lines), legend)
}
// One line wherever one line will do — which is every terminal wide enough
// for it, and the usual case. A legend on two lines is what a narrow
// terminal gets instead of one that runs off the edge.
wide := sortLegend(200)
if len(wide) != 1 {
t.Errorf("a wide terminal gets the legend on %d lines:\n%s", len(wide), strings.Join(wide, "\n"))
}
if n := len([]rune(wide[0])); n > 200 {
t.Errorf("the one-line legend is %d columns wide", n)
}
// And every choice is on it, so nothing is reachable only when the terminal
// happens to be narrow.
for _, o := range sortOrders {
if !strings.Contains(wide[0], string(o.key)+"·"+shortName(o.name)) {
t.Errorf("the one-line legend does not offer %q for %s: %s", string(o.key), o.name, wide[0])
}
}
if !strings.Contains(wide[0], string(sortReverse)+"·reverse") {
t.Errorf("the one-line legend does not offer the reverse: %s", wide[0])
}
// The width at which it gives up on one line is the width of the legend
// itself, and not a number written down somewhere.
if got := sortLegend(len([]rune(wide[0]))); len(got) != 1 {
t.Error("the legend broke in two at exactly its own width")
}
if got := sortLegend(len([]rune(wide[0])) - 1); len(got) != 2 {
t.Error("the legend stayed on one line one column too narrow for it")
}
// Distinct letters, or one of them would be unreachable.
@@ -289,3 +330,101 @@ func TestSortLegendIsNotAYesNoQuestion(t *testing.T) {
}
}
}
// By how many snapshots a machine is carrying. Nought is a figure here and not
// a missing one — nothing to clean up is a fact about the machine — so it sorts
// where nought belongs: at the bottom going down, at the top coming back up.
func TestSortBySnapshotCount(t *testing.T) {
rows := []vmRow{
sortRow("none", "v308", "esx1", "10.0.0.1", 1, 1024, 0, 0, true),
sortRow("three", "v308", "esx1", "10.0.0.2", 1, 1024, 0, 0, true),
sortRow("one", "v308", "esx1", "10.0.0.3", 1, 1024, 0, 0, true),
}
rows[1].snaps = []snapEntry{aged("a", 1), aged("b", 2), aged("c", 3)}
rows[2].snaps = []snapEntry{aged("a", 1)}
if got := orderOf(t, rows, 'z', true); got != "three one none" {
t.Errorf("most snapshots first gave %q", got)
}
if got := orderOf(t, rows, 'z', false); got != "none one three" {
t.Errorf("fewest first gave %q", got)
}
}
// By what is wrong with the machine: broken above wants-a-look above nothing to
// report, and within each the machine with the most to answer for first.
func TestSortByIssues(t *testing.T) {
rows := []vmRow{
sortRow("fine", "v308", "esx1", "10.0.0.1", 1, 1024, 0, 0, true),
sortRow("warned", "v308", "esx1", "10.0.0.2", 1, 1024, 0, 0, true),
sortRow("broken", "v308", "esx1", "10.0.0.3", 1, 1024, 0, 0, true),
sortRow("worse", "v308", "esx1", "10.0.0.4", 1, 1024, 0, 0, true),
}
// sortRow builds machines with no guest information at all, which reports
// nothing: the issues that are only true of a running machine need a guest
// to be true of. So each is given exactly what it is named for.
for i := range rows {
rows[i].vm.Guest = &types.GuestInfo{
ToolsRunningStatus: "guestToolsRunning",
IpAddress: rows[i].ip(),
}
}
rows[1].vm.Summary.OverallStatus = types.ManagedEntityStatusYellow // one warning
rows[2].vm.Summary.Runtime.ConsolidationNeeded = true // one breakage
rows[3].vm.Summary.Runtime.ConsolidationNeeded = true // and the same
rows[3].vm.Guest.ToolsRunningStatus = "guestToolsNotRunning" // plus a warning
if got := orderOf(t, rows, 'w', true); got != "worse broken warned fine" {
t.Errorf("worst first gave %q", got)
}
// And the other way up, the machines with nothing wrong come first, which
// is a listing worth having too.
if got := orderOf(t, rows, 'w', false); got != "fine warned broken worse" {
t.Errorf("nothing to report first gave %q", got)
}
}
// orderOf sorts the rows given by one order and returns the names in order.
//
// A key that is not an order at all is fatal here rather than left to sort by
// name: the browser's default order is index nought, so a test naming a letter
// that has been renamed would go on passing while checking the name order.
func orderOf(t *testing.T, rows []vmRow, key rune, desc bool) string {
t.Helper()
b := &browser{rows: append([]vmRow(nil), rows...), sortDesc: desc}
found := false
for i, o := range sortOrders {
if o.key == key {
b.sortBy, found = i, true
}
}
if !found {
t.Fatalf("%q is not one of the sort orders", string(key))
}
b.applySort()
var names []string
for _, r := range b.rows {
names = append(names, r.name)
}
return strings.Join(names, " ")
}
// The two snapshot orders are different questions: how many, and how old. A
// machine with one snapshot from March needs attention before one with six
// from this morning.
func TestTheTwoSnapshotOrdersAskDifferentThings(t *testing.T) {
rows := []vmRow{
sortRow("many-new", "v308", "esx1", "10.0.0.1", 1, 1024, 0, 0, true),
sortRow("one-ancient", "v308", "esx1", "10.0.0.2", 1, 1024, 0, 0, true),
}
rows[0].snaps = []snapEntry{aged("a", 1), aged("b", 1), aged("c", 1), aged("d", 1)}
rows[1].snaps = []snapEntry{aged("march", 200)}
if got := orderOf(t, rows, 'z', true); got != "many-new one-ancient" {
t.Errorf("by count: %q", got)
}
if got := orderOf(t, rows, 'o', true); got != "one-ancient many-new" {
t.Errorf("by age: %q", got)
}
}
+1 -1
View File
@@ -1 +1 @@
1.1.0
1.1.3