Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fba15b7897 | ||
|
|
4a5477bde7 | ||
|
|
137a799399 | ||
|
|
b902059402 |
@@ -121,6 +121,8 @@ help, as does anything gvm does not recognise:
|
||||
memory in use, uptime, storage, guest filesystems, network
|
||||
adapters, snapshots, uuid and moref
|
||||
↑ ↓ in there scroll the sheet, esc/enter back to the list
|
||||
e h y w in the sheet: recent events, ssh to the guest, copy what
|
||||
ssh would connect to, open it in the vSphere client
|
||||
|
||||
The sheet is one line per thing worth knowing, values that belong together
|
||||
joined with a middle dot and no section headings — an ordinary machine fits a
|
||||
@@ -157,7 +159,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 +180,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 +195,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 +238,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:
|
||||
@@ -248,8 +287,12 @@ sheet:
|
||||
|
||||
^a the action menu (see below)
|
||||
^s take a snapshot: a name, then a confirmation
|
||||
e recent events
|
||||
h ssh to the guest, as root
|
||||
y copy that name or address to the clipboard
|
||||
w open in the vSphere client
|
||||
|
||||
Pressing either in the table says so rather than doing nothing visible.
|
||||
Pressing `^a` or `^s` in the table says so rather than doing nothing visible.
|
||||
|
||||
`^s` takes two steps.
|
||||
|
||||
@@ -264,57 +307,78 @@ nothing but `y`: Enter finishes a name, it never takes a snapshot. Afterwards th
|
||||
name is on the status line, and a sheet that is open jumps to its snapshot
|
||||
section so the new one is there to see.
|
||||
|
||||
### The action menu
|
||||
### The sheet's four letters
|
||||
|
||||
In a machine's sheet, `^a` opens the menu for it. Above the choices it repeats
|
||||
the few lines of the sheet the choice depends on — state, guest, hostname,
|
||||
address — taken from the sheet itself, so the two cannot word the same fact
|
||||
differently. On a terminal too short for both, those lines go one at a time,
|
||||
least useful first: the state stays longest because every choice depends on it,
|
||||
then the address and the hostname, because two of the choices are about them.
|
||||
Everything that changes a
|
||||
machine lives there and nowhere else — the list is arrowed through and its filter
|
||||
swallows every ordinary letter, so a hotkey that powered a machine off would sit
|
||||
one fumbled control key away from an outage, and the sheet has to be opened first
|
||||
anyway.
|
||||
|
||||
n take a snapshot o power on
|
||||
r revert to a snapshot ... s shut down the guest
|
||||
d remove a snapshot ... b reboot the guest
|
||||
D remove ALL snapshots S power off (hard)
|
||||
B reset (hard)
|
||||
────────────────────────────────────────────────────────────
|
||||
e recent events h ssh to the guest
|
||||
y copy the address w open in the vSphere client
|
||||
|
||||
Lowercase asks the guest, uppercase acts at the hypervisor: the violent variant
|
||||
always needs the shift key. What cannot be done right now is greyed out with the
|
||||
reason next to it — "no VMware Tools", "already running", "no address" — rather
|
||||
than left out, and picking it anyway spells the reason out instead of running it.
|
||||
|
||||
The group under the rule changes nothing, on the machine or on the vCenter. It
|
||||
is here rather than on four more control keys of its own because this is where
|
||||
one already looks for "what can I do with this machine", and because the sheet's
|
||||
help line is not the place to learn them:
|
||||
The four letters are the things that change nothing, on the machine or on the
|
||||
vCenter: they read its history, copy its address, open it somewhere else. They
|
||||
are letters of the sheet rather than entries in the menu because nothing they do
|
||||
needs thinking about first, and — unlike the table, whose filter swallows every
|
||||
ordinary letter — the sheet has nothing else to do with them:
|
||||
|
||||
* **`e`** puts the machine's own recent events at the foot of its sheet and
|
||||
scrolls down to them — why is this thing off, who rebooted it, what happened
|
||||
at four this morning. `gvm log` is the whole vCenter over the last hour, which
|
||||
is the right shape for a mail and the wrong one for that question. They are
|
||||
fetched when they are asked for: opening a machine stays one call.
|
||||
* **`h`** logs in to the guest, by its own hostname where it reports one and by
|
||||
its address otherwise. The terminal goes back to what it was for as long as
|
||||
that lasts. `ssh = ssh -l root %h` in `~/.gvmrc` says how; the target is
|
||||
always one argument and never goes through a shell, because it is a name the
|
||||
guest chose for itself.
|
||||
* **`y`** copies that address to the clipboard — the terminal's own, asked for
|
||||
with an escape sequence rather than through `pbcopy`, so it works over ssh and
|
||||
lands where the person actually is.
|
||||
* **`h`** logs in to the guest as root — `ssh root@<name>` — by its own hostname
|
||||
where it reports one and by its address otherwise. The terminal goes back to
|
||||
what it was for as long as that lasts. `ssh = ssh -l someone %h` in `~/.gvmrc`
|
||||
replaces that command whole, root and all; the target is always one argument
|
||||
and never goes through a shell, because it is a name the guest chose for
|
||||
itself. `^C` while it hangs on a machine that is not answering kills the login
|
||||
and no more than that: gvm catches the signal for as long as the child has the
|
||||
screen — the terminal is in its ordinary mode there, where the keystroke goes
|
||||
to every process in the foreground group — and comes back to the sheet saying
|
||||
the login was interrupted.
|
||||
* **`y`** copies exactly what `h` would connect to — the hostname where the
|
||||
guest reports one, the address otherwise — and the status line names which of
|
||||
the two it was and which clipboard it went into, because a clipboard is
|
||||
invisible and "copied" on its own is something one has to go and check.
|
||||
|
||||
Two routes, because neither alone is enough. `pbcopy` (or `wl-copy`, `xclip`,
|
||||
`xsel`) is the one that always works where there is one, and the terminal's own
|
||||
OSC 52 escape sequence is the only one that reaches the right machine from the
|
||||
far end of an ssh login — where a local `pbcopy` would copy into the clipboard
|
||||
of a machine nobody is sitting at. So the sequence is always sent and the
|
||||
command is used as well where there is one; a login is recognised by
|
||||
`SSH_CONNECTION`, and there the sequence is the whole story. It is also the
|
||||
route a terminal is free to ignore — iTerm2 keeps it behind *Applications in
|
||||
terminal may access clipboard*, tmux behind `set-clipboard` — which is why the
|
||||
line says when it was the only one used.
|
||||
* **`w`** opens the machine's page in the vSphere client. The link needs the
|
||||
vCenter's instance UUID, which is the serverGuid that client puts in its URLs
|
||||
and the one thing gvm cannot work out from the configuration; where there is no
|
||||
browser to hand off to, the URL is said and copied instead.
|
||||
|
||||
`h` and `y` need somewhere to connect to: on a machine whose guest is not
|
||||
reporting an address they say so on the status line rather than doing nothing.
|
||||
|
||||
### The action menu
|
||||
|
||||
In a machine's sheet, `^a` opens the menu for it. It is everything that changes a
|
||||
machine, and it lives there and nowhere else — the list is arrowed through and its
|
||||
filter swallows every ordinary letter, so a hotkey that powered a machine off
|
||||
would sit one fumbled control key away from an outage, and the sheet has to be
|
||||
opened first anyway.
|
||||
|
||||
Above the choices the menu repeats the few lines of the sheet the choice depends
|
||||
on — state, guest, hostname, address — taken from the sheet itself, so the two
|
||||
cannot word the same fact differently. On a terminal too short for both, those
|
||||
lines go one at a time, least useful first: the state stays longest because every
|
||||
choice depends on it, then the address and the hostname, which say which machine
|
||||
this is about.
|
||||
|
||||
n take a snapshot o power on
|
||||
r revert to a snapshot ... s shut down the guest
|
||||
d remove a snapshot ... b reboot the guest
|
||||
D remove ALL snapshots S power off (hard)
|
||||
B reset (hard)
|
||||
|
||||
Lowercase asks the guest, uppercase acts at the hypervisor: the violent variant
|
||||
always needs the shift key. What cannot be done right now is greyed out with the
|
||||
reason next to it — "no VMware Tools", "already running", "no snapshots" — rather
|
||||
than left out, and picking it anyway spells the reason out instead of running it.
|
||||
|
||||
Snapshots are drawn as the tree they are — which state descends from which is
|
||||
the whole point of a snapshot list — and the one the machine is running from
|
||||
says so:
|
||||
@@ -553,6 +617,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:
|
||||
|
||||
| | |
|
||||
@@ -641,6 +713,12 @@ wrong quietly:
|
||||
snapshot whose file layout could not be read
|
||||
* that the ssh target is one argument and never shell code — it is a name the
|
||||
guest chose for itself
|
||||
* that `y` names what it copied and that an ssh login uses no local clipboard
|
||||
tool — the tests say they are a login, which also keeps them off the clipboard
|
||||
of whoever is running them
|
||||
* that a `^C` during a login does not take gvm with it, and that the login still
|
||||
dies of it: the signal is caught for as long as the child has the screen, and
|
||||
catching is not ignoring — an ignored one would be inherited by the ssh
|
||||
* that the column ladder still only ever *drops* columns with the task column in
|
||||
the table, and that a terminal of eighty still keeps the address
|
||||
* that the completion cache survives a sweep of one server, forgets a machine
|
||||
|
||||
+49
-55
@@ -59,15 +59,16 @@ var menuFacts = []string{"state", "guest", "hostname", "address"}
|
||||
// most worth keeping first, the same idea as the table's expendable columns.
|
||||
//
|
||||
// The state comes first because every choice below depends on it. The address
|
||||
// and the hostname come next because two of the choices are *about* them: ssh
|
||||
// and copy-the-address. The guest's operating system decides nothing here.
|
||||
// and the hostname come next: they say which machine is about to be powered off,
|
||||
// which is worth having in front of one. The guest's operating system decides
|
||||
// nothing here.
|
||||
var factOrder = []string{"state", "address", "hostname", "guest"}
|
||||
|
||||
// keepFacts is as many of the facts as fit, still in sheet order.
|
||||
//
|
||||
// Giving them up one at a time rather than all at once is what a terminal of
|
||||
// twenty-four rows gets out of this: the menu grew a group of its own, all four
|
||||
// facts no longer fit above it there, and none is a worse answer than three.
|
||||
// Giving them up one at a time rather than all at once is what a short terminal
|
||||
// gets out of this: below twenty rows the menu and all four facts no longer fit
|
||||
// on the screen together, and none of them is a worse answer than three.
|
||||
func keepFacts(info []sheetLine, room int) []sheetLine {
|
||||
if room >= len(info) {
|
||||
return info
|
||||
@@ -189,44 +190,9 @@ func (b *browser) buildMenu(r vmRow, snaps []snapEntry) []menuItem {
|
||||
separator(),
|
||||
pwr('S', "power off (hard, at the hypervisor)", opPowerOff),
|
||||
pwr('B', "reset (hard, at the hypervisor)", opReset),
|
||||
separator(),
|
||||
// The last group changes nothing, on the machine or on the vCenter: it
|
||||
// reads its history, copies its address, opens it somewhere else. It is
|
||||
// here rather than on keys of its own because the sheet's help line is
|
||||
// not the place to learn four more control keys, and because this is
|
||||
// where one already looks for "what can I do with this machine".
|
||||
{key: 'e', label: "recent events", run: func(b *browser, r vmRow) { b.showEvents(r) }},
|
||||
guestItem('h', "ssh to the guest", r, func(b *browser, r vmRow) { b.sshTo(r) }),
|
||||
guestItem('y', "copy the address to the clipboard", r,
|
||||
func(b *browser, r vmRow) { b.copyAddress(r) }),
|
||||
vsphereItem('w', "open in the vSphere client", r),
|
||||
}
|
||||
}
|
||||
|
||||
// guestItem is one of the entries that needs somewhere to connect to. A machine
|
||||
// whose guest is not talking has no address, and saying so where the entry is
|
||||
// beats a keystroke that does nothing.
|
||||
func guestItem(key rune, label string, r vmRow, run func(*browser, vmRow)) menuItem {
|
||||
if r.sshTarget() == "" {
|
||||
return menuItem{key: key, label: label, hint: "no address",
|
||||
why: SF("%s has no address or hostname — its guest is not reporting one", r.name),
|
||||
run: run}
|
||||
}
|
||||
return menuItem{key: key, label: label, run: run}
|
||||
}
|
||||
|
||||
// vsphereItem needs the same, plus a way to open a browser: on a machine with
|
||||
// no desktop the URL is still worth having, so it is offered and printed rather
|
||||
// than left out.
|
||||
func vsphereItem(key rune, label string, r vmRow) menuItem {
|
||||
if vsphereURL(r) == "" {
|
||||
return menuItem{key: key, label: label, hint: "no connection",
|
||||
why: "the vSphere client link needs the connection this machine was read over",
|
||||
run: func(b *browser, r vmRow) { b.openVSphere(r) }}
|
||||
}
|
||||
return menuItem{key: key, label: label, run: func(b *browser, r vmRow) { b.openVSphere(r) }}
|
||||
}
|
||||
|
||||
// menuKey drives the menu. Letters pick an item directly; the arrows and Enter
|
||||
// do the same for anyone who would rather read than remember.
|
||||
func (b *browser) menuKey(k key) {
|
||||
@@ -637,7 +603,7 @@ func (b *browser) renderMenu() {
|
||||
segLine(&sb, cols, seg{b.statusCol, b.status})
|
||||
} else {
|
||||
segLine(&sb, cols, seg{colDim,
|
||||
"lowercase asks the guest, uppercase the hypervisor · the last group only reads"})
|
||||
"lowercase asks the guest, uppercase the hypervisor"})
|
||||
}
|
||||
sb.WriteString(colDim + truncate("a letter or ↑/↓ and ⏎ to choose esc back", cols) + attrOff + scrEOL)
|
||||
b.write(sb.String())
|
||||
@@ -793,13 +759,17 @@ func wrap(s string, width int) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// ------------------------------------------------- the harmless half of the menu
|
||||
// ------------------------------------------------------- the sheet's own keys
|
||||
//
|
||||
// The four below change nothing, on the machine or on the vCenter: they read its
|
||||
// history, copy its address, open it somewhere else. That is why they are on
|
||||
// letters of the sheet itself rather than in the action menu, which is for the
|
||||
// things one has to be sure about before pressing.
|
||||
|
||||
// showEvents puts the machine's recent history at the foot of its sheet and
|
||||
// scrolls down to it. Asked for rather than fetched with the sheet: opening a
|
||||
// machine is one call, and this is another.
|
||||
func (b *browser) showEvents(r vmRow) {
|
||||
b.closeMenu()
|
||||
b.working(SF("reading the events of %s ...", r.name))
|
||||
|
||||
lines, err := eventsOf(r)
|
||||
@@ -818,41 +788,65 @@ func (b *browser) showEvents(r vmRow) {
|
||||
b.scrollToSection("events")
|
||||
}
|
||||
|
||||
// hasAddress reports whether there is somewhere to connect to, and says so where
|
||||
// the key was pressed when there is not. A guest that is not talking reports no
|
||||
// address, and ssh or copy doing nothing at all would read as gvm having hung —
|
||||
// the greyed-out menu entries these two replaced said as much in their own line.
|
||||
func (b *browser) hasAddress(r vmRow) bool {
|
||||
if r.sshTarget() != "" {
|
||||
return true
|
||||
}
|
||||
b.setStatus(colWarn, SF("%s has no address or hostname — its guest is not reporting one", r.name))
|
||||
return false
|
||||
}
|
||||
|
||||
// sshTo logs in to the guest. The terminal goes back to what it was for as long
|
||||
// as that lasts (guest.go), and the list is redrawn afterwards.
|
||||
func (b *browser) sshTo(r vmRow) {
|
||||
b.closeMenu()
|
||||
target := r.sshTarget()
|
||||
argv := sshCommand(b.ssh, target)
|
||||
|
||||
if err := b.runInTerminal(argv); err != nil {
|
||||
err := b.runInTerminal(argv)
|
||||
switch {
|
||||
case err == nil:
|
||||
b.setStatus(colDim, "back from "+target)
|
||||
case interrupted(err):
|
||||
// Ctrl-C during a login is somebody changing their mind, not a fault:
|
||||
// gvm now survives it (holdTerminalSignals) and says so in the colour
|
||||
// of an ordinary remark.
|
||||
b.setStatus(colDim, "the login to "+target+" was interrupted")
|
||||
default:
|
||||
b.setStatus(colWarn, SF("%s: %v", strings.Join(argv, " "), err))
|
||||
return
|
||||
}
|
||||
b.setStatus(colDim, "back from "+target)
|
||||
}
|
||||
|
||||
// copyAddress puts the address where the next paste will find it, by asking the
|
||||
// terminal rather than the operating system — see osc52.
|
||||
// copyAddress puts what `h` would connect to where the next paste will find it.
|
||||
//
|
||||
// What it says is longer than "copied web01.example" was, and deliberately: a
|
||||
// clipboard is invisible, so the line has to name what went into it — the
|
||||
// hostname or the address, since the sheet shows both — and which clipboard it
|
||||
// is. Where the escape sequence was the only route it also says so, because that
|
||||
// is the case where it may quietly not have arrived.
|
||||
func (b *browser) copyAddress(r vmRow) {
|
||||
b.closeMenu()
|
||||
target := r.sshTarget()
|
||||
b.write(osc52(target))
|
||||
b.setStatus(colInfo, "copied "+target)
|
||||
target, kind := r.sshTargetIs()
|
||||
if where := b.toClipboard(target); where != "" {
|
||||
b.setStatus(colInfo, SF("copied its %s %s to the clipboard (%s)", kind, target, where))
|
||||
return
|
||||
}
|
||||
b.setStatus(colWarn, SF("sent its %s %s to the terminal's own clipboard — it has to allow that (OSC 52)", kind, target))
|
||||
}
|
||||
|
||||
// openVSphere opens the machine's page in the vSphere client, and says the URL
|
||||
// either way: a workstation with no browser to hand off to still gets the one
|
||||
// thing that was wanted, and so does anyone running gvm over ssh.
|
||||
func (b *browser) openVSphere(r vmRow) {
|
||||
b.closeMenu()
|
||||
url := vsphereURL(r)
|
||||
if url == "" {
|
||||
b.setStatus(colWarn, "no connection to "+r.vc.Name+" to build the link from")
|
||||
return
|
||||
}
|
||||
if err := openURL(url); err != nil {
|
||||
b.write(osc52(url))
|
||||
b.toClipboard(url) // the same two routes as `y`
|
||||
b.setStatus(colWarn, url+" (copied; "+err.Error()+")")
|
||||
return
|
||||
}
|
||||
|
||||
+3
-3
@@ -606,9 +606,9 @@ func TestMenuDropsItsFactsBeforeItsChoices(t *testing.T) {
|
||||
wantFacts bool
|
||||
}{
|
||||
{"30", true}, // room for both, all four facts
|
||||
{"24", true}, // room for three of them, the hostname among them
|
||||
{"23", false}, // room for two: the state and the address
|
||||
{"18", false}, // no room at all, so the choices have the screen
|
||||
{"19", true}, // room for three of them, the hostname among them
|
||||
{"18", false}, // room for two: the state and the address
|
||||
{"13", false}, // no room at all, so the choices have the screen
|
||||
} {
|
||||
t.Setenv("LINES", c.rows)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -74,7 +83,7 @@ var (
|
||||
|
||||
const (
|
||||
listHelp = "type to filter ↑/↓ move ⏎ details ^o sort ^w issues ^r reload esc clear/quit"
|
||||
detailHelp = "↑/↓ scroll ^a actions (events, ssh, vsphere) ^s snapshot esc/⏎ back ^c quit"
|
||||
detailHelp = "↑/↓ scroll e events h ssh y copy w vsphere ^a actions ^s snapshot esc back ^c quit"
|
||||
gutter = 2 // the pointer's two columns, in front of every row
|
||||
colSep = 2
|
||||
labelWidth = 12
|
||||
@@ -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" {
|
||||
@@ -737,7 +759,7 @@ type browser struct {
|
||||
|
||||
// browseVMs is the command: gather, then hand the terminal over to the loop.
|
||||
//
|
||||
// ssh is the command line the action menu's `h` runs, out of the configuration:
|
||||
// ssh is the command line the sheet's `h` runs, out of the configuration:
|
||||
// the browser is handed it rather than reading it, so nothing in the interactive
|
||||
// half has to know where settings come from.
|
||||
func browseVMs(targets []VCenter, filter, ssh string) error {
|
||||
@@ -935,6 +957,8 @@ func (b *browser) detailKey(k key) {
|
||||
b.openMenu()
|
||||
case keyCtrlS:
|
||||
b.snapshot()
|
||||
case keyRune:
|
||||
b.detailRune(k.r)
|
||||
case keyUp:
|
||||
b.dscroll = max(b.dscroll-1, 0)
|
||||
case keyDown:
|
||||
@@ -950,6 +974,32 @@ func (b *browser) detailKey(k key) {
|
||||
}
|
||||
}
|
||||
|
||||
// detailRune answers the sheet's own letters: the four things that only read the
|
||||
// machine or point somewhere else at it. They are letters here rather than
|
||||
// entries at the foot of the action menu because nothing they do needs thinking
|
||||
// about first, and the sheet — unlike the list — has no filter to swallow them.
|
||||
// A letter that means nothing here is ignored, not complained about.
|
||||
func (b *browser) detailRune(r rune) {
|
||||
row := b.current()
|
||||
if row == nil {
|
||||
return
|
||||
}
|
||||
switch r {
|
||||
case 'e':
|
||||
b.showEvents(*row)
|
||||
case 'h':
|
||||
if b.hasAddress(*row) {
|
||||
b.sshTo(*row)
|
||||
}
|
||||
case 'y':
|
||||
if b.hasAddress(*row) {
|
||||
b.copyAddress(*row)
|
||||
}
|
||||
case 'w':
|
||||
b.openVSphere(*row)
|
||||
}
|
||||
}
|
||||
|
||||
// refilter rebuilds the visible set. The selection stays on the machine it was
|
||||
// on where that machine is still in the list, which is what makes typing a few
|
||||
// letters and pressing enter feel like one motion.
|
||||
@@ -1200,12 +1250,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 +1380,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
|
||||
|
||||
+149
-4
@@ -251,6 +251,71 @@ func TestListViewHasNoActionKeys(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The four that only read a machine are the sheet's own letters now, not entries
|
||||
// at the foot of the action menu. Each one has to reach its own action from
|
||||
// there, and a letter that means nothing must be dropped rather than answered.
|
||||
func TestSheetLettersReachTheirActions(t *testing.T) {
|
||||
// Said to be an ssh login, which keeps the test off the clipboard of whoever
|
||||
// is running it: with no local tool to hand, `y` uses the terminal's own
|
||||
// escape sequence and nothing else.
|
||||
t.Setenv("SSH_CONNECTION", "10.0.0.9 51000 10.0.0.1 22")
|
||||
|
||||
// That sequence goes to the terminal, so the sheet needs one to write to.
|
||||
pipe := func(b *browser) func() string {
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b.tty = w
|
||||
return func() string {
|
||||
w.Close()
|
||||
out, _ := io.ReadAll(r)
|
||||
r.Close()
|
||||
return string(out)
|
||||
}
|
||||
}
|
||||
|
||||
// y copies. It is the one of the four that needs neither a session nor a
|
||||
// browser, so it is checked all the way through.
|
||||
b := testBrowser("web01")
|
||||
read := pipe(b)
|
||||
b.detailRune('y')
|
||||
if got := read(); !strings.Contains(got, "\x1b]52;c;") {
|
||||
t.Errorf("y sent no clipboard sequence: %q", got)
|
||||
}
|
||||
// What the line says about which clipboard is TestCopyAddressSaysWhatWentWhere's
|
||||
// business; here it only has to name the thing that was copied.
|
||||
for _, want := range []string{"clipboard", "web01.example"} {
|
||||
if !strings.Contains(b.status, want) {
|
||||
t.Errorf("y said %q, which does not mention %q", b.status, want)
|
||||
}
|
||||
}
|
||||
|
||||
// e and w need the connection the machine was read over, and these rows have
|
||||
// none: what matters is that the letter arrived at the action, which says so.
|
||||
for _, c := range []struct {
|
||||
k rune
|
||||
want string
|
||||
}{{'e', "v308"}, {'w', "v308"}} {
|
||||
b := testBrowser("web01")
|
||||
read := pipe(b)
|
||||
b.detailRune(c.k)
|
||||
read()
|
||||
if !strings.Contains(b.status, c.want) {
|
||||
t.Errorf("%q on a machine with no connection said %q", string(c.k), b.status)
|
||||
}
|
||||
}
|
||||
|
||||
// A letter nothing is bound to is ignored — silently, because the sheet is
|
||||
// not a filter and there is nothing to correct.
|
||||
b = testBrowser("web01")
|
||||
b.setStatus("", "")
|
||||
b.detailRune('q')
|
||||
if b.status != "" {
|
||||
t.Errorf("an unbound letter said %q", b.status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetailSheetHasTheParameters(t *testing.T) {
|
||||
sheet := sheetText(vmDetail(testRow("web01", true, "10.0.0.5"), []string{"none"}, colOff))
|
||||
|
||||
@@ -1173,8 +1238,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1380,14 +1455,84 @@ func TestEveryScreenDrawsWhatItHasToSay(t *testing.T) {
|
||||
t.Errorf("the sheet does not show %q:\n%s", want, sheet)
|
||||
}
|
||||
}
|
||||
// The four that only read the machine are the sheet's own letters, so the
|
||||
// sheet is where they have to be advertised.
|
||||
for _, want := range []string{"e events", "h ssh", "y copy", "w vsphere", "^a actions"} {
|
||||
if !strings.Contains(sheet, want) {
|
||||
t.Errorf("the sheet's help does not offer %q:\n%s", want, sheet)
|
||||
}
|
||||
}
|
||||
|
||||
b.menu = b.buildMenu(*b.current(), b.current().snaps)
|
||||
b.menuInfo = sheetPick(vmDetail(*b.current(), nil, ""), menuFacts)
|
||||
menu := stripEscapes(renderToPipe(t, b, b.renderMenu))
|
||||
for _, want := range []string{"take a snapshot", "power off", "recent events",
|
||||
"ssh to the guest", "copy the address", "vSphere client"} {
|
||||
for _, want := range []string{"take a snapshot", "power off", "reset"} {
|
||||
if !strings.Contains(menu, want) {
|
||||
t.Errorf("the menu does not show %q:\n%s", want, menu)
|
||||
}
|
||||
}
|
||||
// And the menu is now only the things that change a machine.
|
||||
for _, gone := range []string{"recent events", "ssh to the guest",
|
||||
"copy the address", "vSphere client"} {
|
||||
if strings.Contains(menu, gone) {
|
||||
t.Errorf("the menu still holds %q:\n%s", gone, menu)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ type Config struct {
|
||||
SMTPHost string // relay to hand it to
|
||||
SMTPPort string // its port (default 25)
|
||||
Telemetry string // URL `host -t` posts to; unset turns the posting off
|
||||
SSH string // the command the action menu's `h` runs; %h is the machine
|
||||
SSH string // the command the sheet's `h` runs; %h is the machine
|
||||
}
|
||||
|
||||
// VCenter is one server, configured as a `vcenter.<name>.<field>` block. Name
|
||||
@@ -587,10 +587,10 @@ func writeConfigTemplate(path string) {
|
||||
b.WriteString("# smtpport = 25\n\n")
|
||||
b.WriteString("# --- where `gvm host -t` and `gvm ds -t` post their numbers ---\n")
|
||||
b.WriteString("# telemetry = http://monitor.rz-berlin.mpg.de/telemetry.php\n\n")
|
||||
b.WriteString("# --- how the action menu's 'h' logs in to a guest ---\n")
|
||||
b.WriteString("# --- how the sheet's 'h' logs in to a guest ---\n")
|
||||
b.WriteString("# %h is where the machine's name or address goes; appended when it is\n")
|
||||
b.WriteString("# not written anywhere. Unset means plain 'ssh <machine>'.\n")
|
||||
b.WriteString("# ssh = ssh -l root %h\n")
|
||||
b.WriteString("# not written anywhere. Unset means '" + defaultSSH + "'.\n")
|
||||
b.WriteString("# ssh = ssh -l someone %h\n")
|
||||
|
||||
if err := os.WriteFile(path, []byte(b.String()), configMode); err != nil {
|
||||
PE("could not create "+path, err.Error())
|
||||
|
||||
@@ -4,33 +4,51 @@
|
||||
// take its address away with you, open it in the vSphere client. They are the
|
||||
// keystrokes that stop gvm being a viewer you then have to type an address out
|
||||
// of by hand — and none of them touches the vCenter at all, which is why they
|
||||
// sit in the harmless group at the foot of the action menu.
|
||||
// are letters of the sheet itself rather than entries in the action menu.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// sshTarget is what to connect to: the name the guest calls itself, or its
|
||||
// address. The name is preferred where there is one — it is what is in the known
|
||||
// hosts file, and an address that came out of VMware Tools may be one of several.
|
||||
func (r vmRow) sshTarget() string {
|
||||
func (r vmRow) sshTarget() string { t, _ := r.sshTargetIs(); return t }
|
||||
|
||||
// sshTargetIs is the same, plus which of the two it turned out to be. What was
|
||||
// copied is worth naming — a sheet shows a hostname and an address, and
|
||||
// "copied 10.0.0.5" leaves the person wondering why it was not the name — and
|
||||
// working that out a second time somewhere else is how two answers drift apart.
|
||||
func (r vmRow) sshTargetIs() (target, kind string) {
|
||||
if g := r.vm.Guest; g != nil && strings.TrimSpace(g.HostName) != "" {
|
||||
return strings.TrimSpace(g.HostName)
|
||||
return strings.TrimSpace(g.HostName), "hostname"
|
||||
}
|
||||
if ip := r.ip(); ip != "-" {
|
||||
return ip
|
||||
return ip, "address"
|
||||
}
|
||||
return ""
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// defaultSSH is what `h` runs when the configuration says nothing.
|
||||
const defaultSSH = "ssh root@%h"
|
||||
|
||||
// sshCommand is the command line to run, as argv: the configured template with
|
||||
// the target put where %h stands, or appended when it does not stand anywhere.
|
||||
//
|
||||
// Unset it is `ssh root@%h`. Root is what one logs in to these machines as —
|
||||
// anything else is a second step once the session is up — and having it in the
|
||||
// default means the common case needs no configuration file at all. A template
|
||||
// of one's own overrides it entirely, root and all.
|
||||
//
|
||||
// The target is its own argument and never goes through a shell. It comes from
|
||||
// the guest — a hostname the guest chose for itself, by way of VMware Tools —
|
||||
// and a guest that called itself `; rm -rf ~` would otherwise be running that
|
||||
@@ -39,7 +57,7 @@ func (r vmRow) sshTarget() string {
|
||||
// quoting is not supported, which is a limit worth having here.
|
||||
func sshCommand(template, target string) []string {
|
||||
if strings.TrimSpace(template) == "" {
|
||||
template = "ssh %h"
|
||||
template = defaultSSH
|
||||
}
|
||||
fields := strings.Fields(template)
|
||||
|
||||
@@ -116,16 +134,74 @@ func openURL(url string) error {
|
||||
}
|
||||
|
||||
// osc52 is the escape sequence that puts text in the clipboard of the terminal
|
||||
// that is being looked at, wherever that terminal is running.
|
||||
// that is being looked at, wherever that terminal is running. It is the only way
|
||||
// that reaches the right machine when gvm is run over ssh: a pbcopy on the far
|
||||
// end of a login copies into the clipboard of a machine nobody is sitting at.
|
||||
//
|
||||
// Deliberately not pbcopy or xclip: gvm is run over ssh as often as not, and a
|
||||
// local pbcopy would then copy an address into the clipboard of a machine
|
||||
// nobody is sitting at. This asks the terminal itself, which is the one program
|
||||
// in the chain that knows where the person is.
|
||||
// It is also the way a terminal is free to ignore, and several do until they are
|
||||
// told not to — iTerm2 has it behind a setting, tmux behind set-clipboard — which
|
||||
// is why it is not the only thing tried. See toClipboard.
|
||||
func osc52(text string) string {
|
||||
return "\x1b]52;c;" + base64.StdEncoding.EncodeToString([]byte(text)) + "\a"
|
||||
}
|
||||
|
||||
// toClipboard puts text where the next paste will find it and reports the way it
|
||||
// got there, named — "pbcopy" — or empty when the escape sequence was the only
|
||||
// thing on offer. The caller says so on the status line: a copy nobody can see
|
||||
// happen is one that has to be described, or the only way to find out whether it
|
||||
// worked is to paste somewhere and look.
|
||||
//
|
||||
// Both routes are used, because either alone leaves somebody with nothing: the
|
||||
// local command always works where there is one, and the sequence is what
|
||||
// carries the text home from the far end of an ssh login.
|
||||
func (b *browser) toClipboard(text string) string {
|
||||
b.write(osc52(text))
|
||||
|
||||
argv := clipTool()
|
||||
if argv == nil {
|
||||
return ""
|
||||
}
|
||||
if err := runClipTool(argv, text); err != nil {
|
||||
return ""
|
||||
}
|
||||
return filepath.Base(argv[0])
|
||||
}
|
||||
|
||||
// clipTool is the command that puts something in this machine's clipboard, where
|
||||
// this is the machine the person is sitting at. Over an ssh login it is not:
|
||||
// there the terminal's own sequence is the only route that ends up where the
|
||||
// person can paste it, and a local clipboard would be the wrong machine's.
|
||||
func clipTool() []string {
|
||||
if os.Getenv("SSH_CONNECTION") != "" || os.Getenv("SSH_TTY") != "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
candidates := [][]string{{"wl-copy"}, {"xclip", "-selection", "clipboard"}, {"xsel", "--clipboard", "--input"}}
|
||||
if runtime.GOOS == "darwin" {
|
||||
candidates = [][]string{{"pbcopy"}}
|
||||
} else if os.Getenv("WAYLAND_DISPLAY") == "" && os.Getenv("DISPLAY") == "" {
|
||||
// A Linux console or a machine with no session to speak of: there is
|
||||
// nothing for xclip to hand the text to, and it would sit there waiting.
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, c := range candidates {
|
||||
if path, err := exec.LookPath(c[0]); err == nil {
|
||||
return append([]string{path}, c[1:]...)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// runClipTool feeds the text to it on standard input, which is how all of them
|
||||
// take it. Nothing is added: a trailing newline in the clipboard turns a pasted
|
||||
// hostname into a pasted hostname and a return.
|
||||
func runClipTool(argv []string, text string) error {
|
||||
cmd := exec.Command(argv[0], argv[1:]...)
|
||||
cmd.Stdin = strings.NewReader(text)
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
// runInTerminal gives the terminal back, runs a command in it, and takes it
|
||||
// again. For ssh, which wants the terminal in its ordinary mode, its own screen,
|
||||
// and the keyboard.
|
||||
@@ -139,11 +215,13 @@ func (b *browser) runInTerminal(argv []string) error {
|
||||
}
|
||||
b.close() // clears the screen, puts the cursor back, hands the tty back
|
||||
|
||||
release := holdTerminalSignals()
|
||||
cmd := exec.Command(argv[0], argv[1:]...)
|
||||
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
|
||||
err := cmd.Run()
|
||||
release()
|
||||
|
||||
if err != nil {
|
||||
if err != nil && !interrupted(err) {
|
||||
// Something to read: the message would be wiped by the next frame, so
|
||||
// the screen is held until somebody has seen it.
|
||||
PF("\n%s %v\n", Crb(argv[0]+":"), err)
|
||||
@@ -161,3 +239,42 @@ func (b *browser) runInTerminal(argv []string) error {
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// holdTerminalSignals keeps the keystrokes the terminal turns into signals from
|
||||
// reaching gvm while a child has the screen. In its ordinary mode Ctrl-C is not
|
||||
// a byte gvm reads but a SIGINT to the whole foreground process group — which is
|
||||
// gvm as much as the ssh it is waiting for. Killing the login was meant; killing
|
||||
// the list one was going back to was not.
|
||||
//
|
||||
// They are caught rather than ignored, and the difference matters: exec resets a
|
||||
// caught signal to its default in the child, while an ignored one is inherited.
|
||||
// An ssh that cannot be interrupted while it hangs on a machine that is not
|
||||
// answering would be worse than what this fixes.
|
||||
//
|
||||
// The returned func puts them back the way they were, which is gvm's own raw
|
||||
// mode reading Ctrl-C as a key like any other.
|
||||
func holdTerminalSignals() func() {
|
||||
// Buffered and never read: the signal package sends without blocking and
|
||||
// drops what does not fit, which is the whole intent — these are being
|
||||
// swallowed, not handled.
|
||||
ch := make(chan os.Signal, 4)
|
||||
signal.Notify(ch, os.Interrupt, syscall.SIGQUIT)
|
||||
return func() { signal.Stop(ch) }
|
||||
}
|
||||
|
||||
// interrupted reports whether a child ended because somebody pressed Ctrl-C (or
|
||||
// Ctrl-\) rather than because something went wrong. Nothing is held on the
|
||||
// screen for it: the person who pressed it knows what happened and wants to be
|
||||
// back in the list, not reading that ssh got a signal.
|
||||
func interrupted(err error) bool {
|
||||
var exit *exec.ExitError
|
||||
if !errors.As(err, &exit) {
|
||||
return false
|
||||
}
|
||||
if st, ok := exit.Sys().(syscall.WaitStatus); ok && st.Signaled() {
|
||||
return st.Signal() == syscall.SIGINT || st.Signal() == syscall.SIGQUIT
|
||||
}
|
||||
// A shell between gvm and the signal reports it as its own exit status
|
||||
// instead, in the shells' 128+signal spelling.
|
||||
return exit.ExitCode() == 128+int(syscall.SIGINT) || exit.ExitCode() == 128+int(syscall.SIGQUIT)
|
||||
}
|
||||
|
||||
+159
-31
@@ -2,9 +2,15 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/vmware/govmomi/vim25/types"
|
||||
)
|
||||
@@ -33,8 +39,8 @@ func TestSSHCommand(t *testing.T) {
|
||||
template string
|
||||
want []string
|
||||
}{
|
||||
{"", []string{"ssh", "web01"}},
|
||||
{"ssh %h", []string{"ssh", "web01"}},
|
||||
{"", []string{"ssh", "root@web01"}}, // the default: root, no configuration needed
|
||||
{"ssh %h", []string{"ssh", "web01"}}, // a template of one's own overrides it, root and all
|
||||
{"ssh -l root %h", []string{"ssh", "-l", "root", "web01"}},
|
||||
{"ssh -o StrictHostKeyChecking=no", []string{"ssh", "-o", "StrictHostKeyChecking=no", "web01"}},
|
||||
{"mosh %h", []string{"mosh", "web01"}},
|
||||
@@ -46,6 +52,142 @@ func TestSSHCommand(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A clipboard is invisible, so what the status line says about it has to be
|
||||
// exact: which of the two the machine gave up — the hostname or the address —
|
||||
// and which clipboard it went into.
|
||||
func TestCopyAddressSaysWhatWentWhere(t *testing.T) {
|
||||
// Pretending to be an ssh login does two things: it is the case where the
|
||||
// escape sequence is the only route, and it keeps the tests off the
|
||||
// clipboard of whoever is running them.
|
||||
t.Setenv("SSH_CONNECTION", "10.0.0.9 51000 10.0.0.1 22")
|
||||
|
||||
for _, c := range []struct {
|
||||
what string
|
||||
row func() vmRow
|
||||
want []string
|
||||
}{
|
||||
{"a guest that reports its name", func() vmRow {
|
||||
return testRow("web01", true, "10.0.0.5")
|
||||
}, []string{"hostname", "web01.example"}},
|
||||
{"a guest that reports only an address", func() vmRow {
|
||||
r := testRow("web01", true, "10.0.0.5")
|
||||
r.vm.Guest.HostName = ""
|
||||
return r
|
||||
}, []string{"address", "10.0.0.5"}},
|
||||
} {
|
||||
r := c.row()
|
||||
b := &browser{rows: []vmRow{r}, view: []int{0}}
|
||||
|
||||
pr, pw, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b.tty = pw
|
||||
b.copyAddress(r)
|
||||
pw.Close()
|
||||
sent, _ := io.ReadAll(pr)
|
||||
pr.Close()
|
||||
|
||||
for _, want := range c.want {
|
||||
if !strings.Contains(b.status, want) {
|
||||
t.Errorf("%s: the status line does not say %q: %q", c.what, want, b.status)
|
||||
}
|
||||
}
|
||||
// And the sequence carried the same string, base64 and all.
|
||||
payload := base64.StdEncoding.EncodeToString([]byte(c.want[1]))
|
||||
if !strings.Contains(string(sent), payload) {
|
||||
t.Errorf("%s: the terminal was not sent %q", c.what, c.want[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Over an ssh login there is no local clipboard worth writing to: pbcopy on the
|
||||
// far end of a login copies into the clipboard of a machine nobody is sitting
|
||||
// at, and the terminal's own sequence is the only route home.
|
||||
func TestClipToolStaysOutOfAnSSHSession(t *testing.T) {
|
||||
t.Setenv("SSH_CONNECTION", "10.0.0.9 51000 10.0.0.1 22")
|
||||
if got := clipTool(); got != nil {
|
||||
t.Errorf("an ssh session offered %v as a clipboard", got)
|
||||
}
|
||||
|
||||
t.Setenv("SSH_CONNECTION", "")
|
||||
t.Setenv("SSH_TTY", "/dev/ttys004")
|
||||
if got := clipTool(); got != nil {
|
||||
t.Errorf("an ssh session offered %v as a clipboard", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Whatever the tool is, it takes the text on standard input and gets it verbatim
|
||||
// — no trailing newline, which in a clipboard turns a pasted hostname into a
|
||||
// pasted hostname and a return.
|
||||
func TestClipToolGetsTheTextVerbatim(t *testing.T) {
|
||||
out := filepath.Join(t.TempDir(), "clipboard")
|
||||
if err := runClipTool([]string{"tee", out}, "web01.example"); err != nil {
|
||||
t.Fatalf("runClipTool: %v", err)
|
||||
}
|
||||
got, err := os.ReadFile(out)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != "web01.example" {
|
||||
t.Errorf("the clipboard would get %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Ctrl-C during an ssh login used to take gvm with it. In the terminal's
|
||||
// ordinary mode — which is what a child gets — the keystroke is not a byte gvm
|
||||
// reads but a SIGINT to the whole foreground process group, and gvm is in that
|
||||
// group. While a child has the screen the signal has to be caught and dropped:
|
||||
// were it not, this test would kill the test binary rather than fail.
|
||||
func TestCtrlCDoesNotTakeGvmWithIt(t *testing.T) {
|
||||
release := holdTerminalSignals()
|
||||
defer release()
|
||||
|
||||
for _, sig := range []syscall.Signal{syscall.SIGINT, syscall.SIGQUIT} {
|
||||
if err := syscall.Kill(os.Getpid(), sig); err != nil {
|
||||
t.Fatalf("cannot send myself a %v: %v", sig, err)
|
||||
}
|
||||
}
|
||||
// Delivery is asynchronous: a moment to be killed in, if it is going to be.
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
// And the child must still die of it, which is why the signal is caught and
|
||||
// not ignored: exec resets a caught signal to its default in the child,
|
||||
// while an ignored one is inherited — signal.Ignore here would leave an ssh
|
||||
// that cannot be interrupted while it hangs on a machine that is not
|
||||
// answering.
|
||||
if err := exec.Command("sh", "-c", "kill -INT $$").Run(); err == nil {
|
||||
t.Error("the child shrugged the Ctrl-C off: the signal is being ignored, not caught")
|
||||
}
|
||||
}
|
||||
|
||||
// And a child that died of that keystroke is told apart from one that failed, so
|
||||
// the screen is not held with "signal: interrupt" over something somebody meant
|
||||
// to do.
|
||||
func TestInterruptedTellsTheKeystrokeFromAFault(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
script string
|
||||
want bool
|
||||
}{
|
||||
{"kill -INT $$", true}, // the signal itself, which is what ssh dies of
|
||||
{"kill -QUIT $$", true}, // Ctrl-\, the same keystroke story
|
||||
{"exit 130", true}, // a shell in between, reporting it as 128+SIGINT
|
||||
{"exit 1", false}, // a remote command that failed
|
||||
{"exit 255", false}, // ssh's own "could not connect"
|
||||
{"exit 0", false}, // nothing wrong at all
|
||||
} {
|
||||
err := exec.Command("sh", "-c", c.script).Run()
|
||||
if got := interrupted(err); got != c.want {
|
||||
t.Errorf("sh -c %q gave %v: interrupted = %v, want %v", c.script, err, got, c.want)
|
||||
}
|
||||
}
|
||||
|
||||
// Something that never got as far as a child at all is not an interruption.
|
||||
if interrupted(errf("nothing to run")) {
|
||||
t.Error("a plain error was taken for a Ctrl-C")
|
||||
}
|
||||
}
|
||||
|
||||
// The target is one argument and never a piece of a shell command. It comes
|
||||
// from the guest — a name the guest chose for itself — so a machine that called
|
||||
// itself "; rm -rf ~" must end up as an ssh host that does not resolve, and not
|
||||
@@ -110,47 +252,33 @@ func TestOsc52CarriesTheTextItself(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The menu says why an entry cannot be used rather than leaving it out, and the
|
||||
// two entries that need somewhere to connect to say exactly that.
|
||||
func TestTheMenuGreysOutWhatItCannotDo(t *testing.T) {
|
||||
// The sheet says why a key cannot do anything rather than swallowing it, and the
|
||||
// two that need somewhere to connect to say exactly that.
|
||||
func TestTheSheetSaysWhyItCannotConnect(t *testing.T) {
|
||||
r := testRow("web01", true, "10.0.0.5")
|
||||
r.vm.Guest = nil
|
||||
r.vm.Summary.Guest = &types.VirtualMachineGuestSummary{}
|
||||
|
||||
b := &browser{rows: []vmRow{r}, view: []int{0}}
|
||||
menu := b.buildMenu(r, nil)
|
||||
|
||||
for _, key := range []rune{'h', 'y'} {
|
||||
found := false
|
||||
for _, m := range menu {
|
||||
if m.key != key {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
if m.available() {
|
||||
t.Errorf("%q is offered on a machine with no address", string(key))
|
||||
}
|
||||
if m.hint == "" || m.why == "" {
|
||||
t.Errorf("%q is greyed out without saying why", string(key))
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("the menu has no %q entry", string(key))
|
||||
for _, k := range []rune{'h', 'y'} {
|
||||
b.setStatus("", "")
|
||||
b.detailRune(k)
|
||||
if !strings.Contains(b.status, "no address") {
|
||||
t.Errorf("%q on a machine with no address said %q", string(k), b.status)
|
||||
}
|
||||
}
|
||||
|
||||
// With an address they are there to be used.
|
||||
r = testRow("web01", true, "10.0.0.5")
|
||||
for _, m := range b.buildMenu(r, nil) {
|
||||
if (m.key == 'h' || m.key == 'y') && !m.available() {
|
||||
t.Errorf("%q is greyed out on a machine with an address: %s", string(m.key), m.why)
|
||||
}
|
||||
// With an address there is nothing to object to. Only the check is asked
|
||||
// here — what follows it is an ssh session and a clipboard.
|
||||
b.rows[0] = testRow("web01", true, "10.0.0.5")
|
||||
if !b.hasAddress(b.rows[0]) {
|
||||
t.Errorf("a machine with an address was refused: %s", b.status)
|
||||
}
|
||||
}
|
||||
|
||||
// Every letter in the menu reaches exactly one entry, or one of them is
|
||||
// unreachable — and the harmless group must not have taken a letter the
|
||||
// dangerous half already uses.
|
||||
// unreachable — and the snapshot half must not have taken a letter the
|
||||
// power half already uses.
|
||||
func TestMenuLettersAreDistinct(t *testing.T) {
|
||||
b := &browser{}
|
||||
seen := map[rune]string{}
|
||||
|
||||
@@ -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")
|
||||
|
||||
+5
-3
@@ -69,14 +69,16 @@ smtpport = 25
|
||||
# -t) nothing is sent. The lines are prefixed "vm," and "ds," respectively.
|
||||
telemetry = http://monitor.rz-berlin.mpg.de/telemetry.php
|
||||
|
||||
# --- ssh, for the action menu's 'h' ---
|
||||
# --- ssh, for the sheet's 'h' ---
|
||||
# The command that logs in to a machine's guest from its sheet. "%h" is where
|
||||
# the guest's own hostname — or its address, when it reports no name — is put;
|
||||
# it is appended when %h is not written anywhere. Unset means plain "ssh <machine>".
|
||||
# it is appended when %h is not written anywhere. Unset means "ssh root@%h",
|
||||
# which is what one logs in to these machines as; a line here replaces it whole,
|
||||
# root and all.
|
||||
#
|
||||
# The target is always one argument and never goes through a shell: it is a name
|
||||
# the guest chose for itself, and gvm does not run it as a command.
|
||||
# ssh = ssh -l root %h
|
||||
# ssh = ssh -l someone %h
|
||||
|
||||
# --- the same settings from the environment ---
|
||||
# Every setting above has an environment spelling that wins over the file:
|
||||
|
||||
+1
-1
@@ -1170,7 +1170,7 @@ func TestSimIssuesListing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The machine's own events, which is what the action menu's 'e' fetches.
|
||||
// The machine's own events, which is what 'e' on a machine's sheet fetches.
|
||||
func TestSimEventsOfOneMachine(t *testing.T) {
|
||||
quiet(t)
|
||||
vc := simVCenter(t)
|
||||
|
||||
@@ -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.7
|
||||
|
||||
Reference in New Issue
Block a user