Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a5477bde7 | ||
|
|
137a799399 | ||
|
|
b902059402 |
@@ -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:
|
||||
|
||||
| | |
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
@@ -1 +1 @@
|
||||
1.1.0
|
||||
1.1.3
|
||||
|
||||
Reference in New Issue
Block a user