Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
269bce195d | ||
|
|
313c9ed880 | ||
|
|
d5450d5f46 | ||
|
|
0b0412bd56 | ||
|
|
89b0029340 |
@@ -135,6 +135,8 @@ including one long word such as a datastore path, so a narrow terminal loses
|
||||
nothing. The machine's name, vCenter, datacenter and host are the title.
|
||||
^o sort the table (see below)
|
||||
^w only the machines with something wrong with them (see below)
|
||||
^e the whole estate on one screen (see below)
|
||||
^l live: the list re-reads itself (see below)
|
||||
^r ask the servers again
|
||||
esc clear the filter, or leave when there is none
|
||||
^c leave
|
||||
@@ -177,6 +179,126 @@ column's minimum is one notch narrower than it reads in order to buy the address
|
||||
its place there, and the count is the one column here that has somewhere else to
|
||||
be said — `^w`, `--issues` and `snap --old` all name it and date it.
|
||||
|
||||
### The estate
|
||||
|
||||
`^e` answers the question the machine list cannot: **where is there still room.**
|
||||
Every host of every server that answered, grouped under its cluster, with what
|
||||
it carries set against what it has:
|
||||
|
||||
Estate v308, v309 14 hosts · 212 machines
|
||||
|
||||
CLUSTER / HOST ON/VM vCPU x MEM ALLOC x CPU LOAD % MEM USED %
|
||||
v308 · prod 58/61 196/96 2.0x 1.1TB/768.0GB 1.5x
|
||||
▸ esx01 21/22 72/32 2.2x 384.0GB/256.0GB 1.5x ████······ 41 ███████··· 72
|
||||
esx02 19/20 68/32 2.1x 360.0GB/256.0GB 1.4x ███······· 32 ██████···· 64
|
||||
esx03 18/19 56/32 1.7x 376.0GB/256.0GB 1.4x █████····· 53 ████████·· 81
|
||||
v308 · standalone 4/4 8/16 32.0GB/128.0GB ██········ 12 ██········ 21
|
||||
|
||||
Two kinds of number, and the difference between them is the whole point — which
|
||||
is also what decides what a narrow terminal gives up. Three layouts, each the
|
||||
widest that fits whole: everything with the load drawn as bars; the same figures
|
||||
with the bars down to their percentages; and, narrower still, only the two
|
||||
ratios and the two percentages. Both kinds of number survive all three, because
|
||||
one of them alone says nothing.
|
||||
|
||||
**What is allocated** — every vCPU and every megabyte the machines on a host have
|
||||
been promised, added up. It routinely exceeds the host and is meant to: the ratio
|
||||
is the figure worth having, because 1.5x of memory is a decision somebody made
|
||||
and 8.0x is one somebody forgot. It is left blank where a host has room to
|
||||
spare, since a column of "0.4x" down a screen of healthy hosts is noise where
|
||||
the point is to spot the one that is over.
|
||||
|
||||
**What is in use** — what the host itself reports it is doing, as a bar and a
|
||||
percentage. A host at 2.2x allocation and 41 % load is fine; the same host at
|
||||
90 % is not, and no allocation figure can tell those two apart. A load that is
|
||||
not known draws nothing rather than an empty trough: a host at one per cent
|
||||
fills none of the bar either, and "almost idle" must not look like "I cannot see
|
||||
this host".
|
||||
|
||||
A host vCenter has lost touch with keeps its hardware figures — they do not
|
||||
depend on reaching it — but reports no load at all rather than the nought its
|
||||
cached statistics would give: an empty bar is what an idle host looks like.
|
||||
|
||||
Only running machines are charged to a host. A machine that is switched off has
|
||||
been promised nothing it is using, and counting it would make a host of parked
|
||||
machines look full when the whole point of parking them there was that it is
|
||||
not — they are still in the ON/VM count, which is where that belongs.
|
||||
|
||||
`⏎` on a host is the other half: it goes back to the machine list with the filter
|
||||
set to that host, so the answer to "what is on this one" is the table everybody
|
||||
already knows how to read, and `Esc` undoes it. The count it reports is the
|
||||
host's own, not the filtered list's: the filter is a text match over the whole
|
||||
row and carries no server, so it can also catch another vCenter's host of the
|
||||
same name or a machine named after a host, and a count taken from it would then
|
||||
contradict the screen it came from. `^r` reads the screen again —
|
||||
live mode deliberately does not tick here, because this screen reads the hosts
|
||||
itself and a timer doing that every ten seconds would be paying for a screen
|
||||
somebody is reading rather than watching.
|
||||
|
||||
The allocations are added up from the rows the list already holds, so no machine
|
||||
is read twice for this, and they are matched to hosts by reference rather than by
|
||||
name — the same lesson `gvm host` carries a comment about, where a host added by
|
||||
address and renamed later reported zero machines while running dozens.
|
||||
|
||||
### Live
|
||||
|
||||
`^l` and the list stops being a snapshot. It re-reads itself every ten seconds —
|
||||
every two while vCenter is doing anything at all, so a clone's progress is
|
||||
something one watches finish rather than a figure one waits for — and the title
|
||||
says so, because a screen that moves on its own with nothing to explain it reads
|
||||
as a fault.
|
||||
|
||||
It refreshes over the connections that are already open. `^r` logs in again,
|
||||
which is how a session that has died is recovered; doing that every ten seconds
|
||||
would be three logins a minute for nothing.
|
||||
|
||||
Two things come with it.
|
||||
|
||||
**The line under the table says what just changed.** A table shows what is; this
|
||||
is the only thing on the screen that says what *became*:
|
||||
|
||||
db01 off · web01 +1 snapshot · app07 clone · esx03: disks need consolidating
|
||||
|
||||
Power states, snapshots appearing and going, tasks starting and finishing,
|
||||
machines arriving and leaving, and vCenter starting to complain. Four of them and
|
||||
a count of the rest, because a line that has to be read carefully is a line
|
||||
nobody reads. It is the reason to leave the thing open.
|
||||
|
||||
Where two vCenters hold a machine of the same name the server goes in front of
|
||||
it — "v309 web01 off" — and only there: a name is not what makes a machine that
|
||||
machine, and the width is worth spending on the rare case rather than on every
|
||||
line. A quiet refresh clears that line and nothing else: a message somebody
|
||||
produced by pressing a key is theirs to keep.
|
||||
|
||||
**The `CPU~` column is where each machine has just been.** Six sweeps of its
|
||||
processor load, one character each:
|
||||
|
||||
NAME PWR CPU CPU% CPU~ MEM
|
||||
app07 on 4 18 ▃▅█▆▄▂ 8.0GB
|
||||
web01 on 4 82 ▄▂▁▃▅█ 8.0GB
|
||||
|
||||
The scale is fixed at 0 to 100 and never fitted to the samples: a line that
|
||||
scales itself would make a machine idling between 1 and 2 per cent look exactly
|
||||
like one swinging between 40 and 80. The column is there only once there is
|
||||
something in it — a machine has a history after its second sweep — and it is the
|
||||
first thing after the guest's operating system that a narrow terminal gives up:
|
||||
everything else in the table is a fact about a machine, and this is a shape.
|
||||
|
||||
A machine that is not running has no load rather than a load of zero, so nothing
|
||||
is sampled from it and it draws nothing — and what it had drawn before goes with
|
||||
it, rather than leaving a busy shape beside a CPU% of "-".
|
||||
|
||||
The refresh holds still for every screen that asks something: a menu decides what
|
||||
it offers from the state it was drawn with, the picker holds a list being chosen
|
||||
from, and a half-typed name or a confirmation is an answer in progress. Being ten
|
||||
seconds out of date is better than any of those moving under a hand. A machine's
|
||||
sheet *is* refreshed, at the line it was being read at — watching one machine's
|
||||
memory is a reason to have it open.
|
||||
|
||||
Nothing here acts on a machine. A refresh that could start or stop something
|
||||
would be a timer with the power to do it, and that is the one thing a screen left
|
||||
open unattended must not have.
|
||||
|
||||
### Sorting
|
||||
|
||||
`^o` puts a legend on the status line and the next key picks the order, so the
|
||||
@@ -782,6 +904,21 @@ wrong quietly:
|
||||
* 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 a refresh which half failed still leaves the rows and the view describing
|
||||
the same list, and that the screen draws — it panicked before
|
||||
* that every layout of the estate screen fits the terminal it was drawn for, at
|
||||
nine widths from 60 columns up
|
||||
* that a page step on that screen does not leap twice when it lands on a cluster
|
||||
name, and that an unreachable host draws no load
|
||||
* that the estate screen groups the hosts under their clusters, that a heading
|
||||
is the sum of what is under it, that every placed machine is charged to
|
||||
exactly one host, and that Enter comes back with the list filtered to it
|
||||
* that a ratio says nothing where there is room to spare, and that an unknown
|
||||
load does not draw the same bar as an idle one
|
||||
* that the live refresh reads over the session it already has and not a new
|
||||
login — with the session closed it fails rather than reconnecting — that it
|
||||
notices a machine stopped behind gvm's back and says so, and that the trend
|
||||
keeps its scale, is bounded, and is forgotten when a machine goes
|
||||
* 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
|
||||
|
||||
@@ -82,7 +82,7 @@ var (
|
||||
)
|
||||
|
||||
const (
|
||||
listHelp = "type to filter ↑/↓ move ⏎ details ^o sort ^w issues ^r reload esc clear/quit"
|
||||
listHelp = "type to filter ↑/↓ move ⏎ details ^e estate ^o sort ^w issues ^l live ^r reload esc clear/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
|
||||
@@ -105,6 +105,12 @@ type vmRow struct {
|
||||
// slow in proportion to how many machines are on the screen.
|
||||
snaps []snapEntry // its snapshots, as the sweep found them
|
||||
task *runningTask // what vCenter is doing to it right now, if anything
|
||||
|
||||
// trend is the machine's recent CPU load drawn as one column of text. It is
|
||||
// not read from anywhere: it is written here by live.go out of samples the
|
||||
// browser keeps across sweeps, because a row is thrown away and rebuilt by
|
||||
// every one of them and a history cannot live on something that short-lived.
|
||||
trend string
|
||||
}
|
||||
|
||||
// id is what makes this machine this machine. A name does not: two vCenters may
|
||||
@@ -321,6 +327,11 @@ type browseColumn struct {
|
||||
// narrow for all of them: 1 goes first. Zero means never — the name and the
|
||||
// power state are what a list of machines is for.
|
||||
//
|
||||
// Only the order matters, not the figures, and 2 is the trend column
|
||||
// (live.go) rather than anything here: it is given up before every fact
|
||||
// about a machine but after the guest's operating system, which is the least
|
||||
// read column in the table and the one a shape is worth more than.
|
||||
//
|
||||
// It is deliberately separate from the order the columns are shown in. The
|
||||
// two used to be the same thing, so what a narrow terminal lost was simply
|
||||
// whatever happened to be listed last, and the memory figures went before the
|
||||
@@ -338,7 +349,7 @@ var browseColumns = []browseColumn{
|
||||
// its place on a terminal of eighty.
|
||||
{header: "NAME", width: 22, flex: true, color: fixed(colName),
|
||||
cell: func(r vmRow) string { return r.name }},
|
||||
{header: "VC", width: 4, color: fixed(colWhere), expendable: 10,
|
||||
{header: "VC", width: 4, color: fixed(colWhere), expendable: 11,
|
||||
cell: func(r vmRow) string { return r.vc.Name }},
|
||||
{header: "PWR", width: 4, cell: vmRow.powerShort, color: vmRow.powerColor},
|
||||
// How many rollback points the machine is dragging along, aged by colour.
|
||||
@@ -349,20 +360,20 @@ var browseColumns = []browseColumn{
|
||||
// `vm -l --issues` and in `snap --old`, all of which name it and date it; an
|
||||
// address has no other home. So a terminal of eighty gives up the count and
|
||||
// keeps the address, and the count is back from eighty-five.
|
||||
{header: "SNAP", width: 4, cell: vmRow.snapCell, color: vmRow.snapColor, expendable: 3},
|
||||
{header: "SNAP", width: 4, cell: vmRow.snapCell, color: vmRow.snapColor, expendable: 4},
|
||||
// The address is the widest thing here that is not a name, and it is a
|
||||
// lookup field: on a terminal this narrow nobody is looking an address up,
|
||||
// they are glancing at what is busy. So it goes before the small figures do.
|
||||
{header: "IP", width: 15, cell: vmRow.ip, color: vmRow.addressColor, expendable: 4},
|
||||
{header: "HOST", width: 10, color: fixed(colAside), expendable: 2,
|
||||
{header: "IP", width: 15, cell: vmRow.ip, color: vmRow.addressColor, expendable: 5},
|
||||
{header: "HOST", width: 10, color: fixed(colAside), expendable: 3,
|
||||
cell: func(r vmRow) string { return r.host }},
|
||||
{header: "CPU", width: 3, color: fixed(colSize), expendable: 5,
|
||||
{header: "CPU", width: 3, color: fixed(colSize), expendable: 6,
|
||||
cell: func(r vmRow) string { return Itoa(int(r.vm.Summary.Config.NumCpu)) }},
|
||||
{header: "CPU%", width: 4, expendable: 9,
|
||||
{header: "CPU%", width: 4, expendable: 10,
|
||||
cell: func(r vmRow) string { return loadCell(r.cpuLoad()) },
|
||||
color: func(r vmRow) string { return loadColor(r.cpuLoad()) }},
|
||||
{header: "MEM", width: 7, cell: vmRow.memory, color: fixed(colSize), expendable: 6},
|
||||
{header: "MEM%", width: 4, expendable: 8,
|
||||
{header: "MEM", width: 7, cell: vmRow.memory, color: fixed(colSize), expendable: 7},
|
||||
{header: "MEM%", width: 4, expendable: 9,
|
||||
cell: func(r vmRow) string { return loadCell(r.memLoad()) },
|
||||
color: func(r vmRow) string { return loadColor(r.memLoad()) }},
|
||||
{header: "GUEST OS", width: 18, flex: true, cell: vmRow.guestOS, color: fixed(colAside),
|
||||
@@ -382,7 +393,7 @@ var browseColumns = []browseColumn{
|
||||
// shifting about: it is the news. What must not happen — a column coming back
|
||||
// as the terminal is dragged *narrower* — is fitColumns' business, and this
|
||||
// does not touch it.
|
||||
var taskColumn = browseColumn{header: "TASK", width: 13, expendable: 7,
|
||||
var taskColumn = browseColumn{header: "TASK", width: 13, expendable: 8,
|
||||
cell: vmRow.taskCell, color: fixed(colBusy)}
|
||||
|
||||
// The reason outranks every other column but the machine's name and its power
|
||||
@@ -392,7 +403,7 @@ var taskColumn = browseColumn{header: "TASK", width: 13, expendable: 7,
|
||||
// still expendable rather than fixed, so that a terminal too narrow for it
|
||||
// falls down the same ladder as everything else instead of into the one-column
|
||||
// fallback.
|
||||
var whyColumn = browseColumn{header: "WHY", width: 30, flex: true, expendable: 11,
|
||||
var whyColumn = browseColumn{header: "WHY", width: 30, flex: true, expendable: 12,
|
||||
cell: vmRow.issueCell, color: vmRow.issueColor}
|
||||
|
||||
// The figures the issues listing leaves out. A machine is in that list because
|
||||
@@ -417,7 +428,9 @@ func listColumns(rows []vmRow, why bool) []browseColumn {
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]browseColumn, 0, len(browseColumns)+1)
|
||||
trend := anyTrend(rows)
|
||||
|
||||
out := make([]browseColumn, 0, len(browseColumns)+2)
|
||||
for _, c := range browseColumns {
|
||||
if why {
|
||||
if c.header == "GUEST OS" {
|
||||
@@ -432,6 +445,12 @@ func listColumns(rows []vmRow, why bool) []browseColumn {
|
||||
if busy && c.header == "PWR" {
|
||||
out = append(out, taskColumn)
|
||||
}
|
||||
// Next to the figure it is the history of, which is where the eye
|
||||
// already is when it wonders whether 80 per cent is where the machine
|
||||
// has been sitting all morning or where it went a moment ago.
|
||||
if trend && c.header == "CPU%" {
|
||||
out = append(out, trendColumn)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -638,14 +657,21 @@ func gatherOne(vc VCenter) ([]vmRow, *session, error) {
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
rows, err := sweepOne(vc, s)
|
||||
return rows, s, err
|
||||
}
|
||||
|
||||
// sweepOne is that pass over one server, over a connection that is already
|
||||
// open. It is split out for live mode (live.go), which re-reads the same
|
||||
// properties every few seconds and must not log in again to do it.
|
||||
func sweepOne(vc VCenter, s *session) ([]vmRow, error) {
|
||||
vms, err := s.vms(sweepProps...)
|
||||
if err != nil {
|
||||
return nil, s, err
|
||||
return nil, err
|
||||
}
|
||||
hosts, err := s.hosts("name")
|
||||
if err != nil {
|
||||
return nil, s, err
|
||||
return nil, err
|
||||
}
|
||||
hostName := make(map[types.ManagedObjectReference]string, len(hosts))
|
||||
for _, h := range hosts {
|
||||
@@ -673,7 +699,7 @@ func gatherOne(vc VCenter) ([]vmRow, *session, error) {
|
||||
}
|
||||
rows = append(rows, r)
|
||||
}
|
||||
return rows, s, nil
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// triggeredAlarms is every alarm definition this sweep saw complaining, once
|
||||
@@ -729,6 +755,15 @@ type browser struct {
|
||||
|
||||
ssh string // the command `h` runs, from ~/.gvmrc; empty means plain ssh
|
||||
|
||||
// Live mode (live.go): the list re-reading itself on a timer rather than on
|
||||
// a keystroke. hist is what the trend column is drawn from, kept here
|
||||
// because every sweep throws the rows away and builds new ones.
|
||||
live bool
|
||||
liveNext time.Time
|
||||
liveGap time.Duration // what the pending tick was scheduled with, for the title
|
||||
hist map[string][]float64
|
||||
saidLive string // the last changed line, so a quiet tick clears only its own
|
||||
|
||||
detail []sheetLine // non-nil while a machine's sheet is on screen
|
||||
dtitle string
|
||||
dvc string // the vCenter, in the title, in the colour its column has
|
||||
@@ -747,6 +782,7 @@ type browser struct {
|
||||
menuSel int
|
||||
pick *picker
|
||||
confirm *confirmation
|
||||
estate *estate // the whole estate on one screen (estate.go)
|
||||
|
||||
status string
|
||||
statusCol string
|
||||
@@ -841,7 +877,19 @@ func (b *browser) loop() {
|
||||
for {
|
||||
b.render()
|
||||
|
||||
k := b.keys.next()
|
||||
// A key, or the next refresh, whichever comes first. Only where a tick
|
||||
// is allowed to happen at all (liveReady): with a menu or a half-typed
|
||||
// answer on screen the read blocks the way it always has.
|
||||
k := key{}
|
||||
if b.liveReady() {
|
||||
var ok bool
|
||||
if k, ok = b.keys.nextWithin(b.liveIn()); !ok {
|
||||
b.liveTick()
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
k = b.keys.next()
|
||||
}
|
||||
if k.special == keyCtrlC {
|
||||
return
|
||||
}
|
||||
@@ -853,6 +901,8 @@ func (b *browser) loop() {
|
||||
b.pickerKey(k)
|
||||
case b.menu != nil:
|
||||
b.menuKey(k)
|
||||
case b.estate != nil:
|
||||
b.estateKey(k)
|
||||
case b.detail != nil:
|
||||
b.detailKey(k)
|
||||
default:
|
||||
@@ -874,6 +924,8 @@ func (b *browser) render() {
|
||||
b.renderPicker()
|
||||
case b.menu != nil:
|
||||
b.renderMenu()
|
||||
case b.estate != nil:
|
||||
b.renderEstate()
|
||||
case b.detail != nil:
|
||||
b.renderDetail()
|
||||
default:
|
||||
@@ -923,6 +975,10 @@ func (b *browser) listKey(k key) bool {
|
||||
b.setStatus(colDim, "open the machine first: ⏎ for its details, then ^a")
|
||||
case keyCtrlR:
|
||||
b.reload()
|
||||
case keyCtrlL:
|
||||
b.toggleLive()
|
||||
case keyCtrlE:
|
||||
b.openEstate()
|
||||
case keyBackspace:
|
||||
if r := []rune(b.filter); len(r) > 0 {
|
||||
b.filter = string(r[:len(r)-1])
|
||||
@@ -1061,11 +1117,25 @@ func (b *browser) toggleIssues() {
|
||||
// the list cannot make a column come and go under the cursor.
|
||||
func (b *browser) columns() []browseColumn { return listColumns(b.rows, b.issuesOnly) }
|
||||
|
||||
// current is the machine under the cursor, or nil where there is none.
|
||||
//
|
||||
// Both steps are bounds-checked, and the second is not paranoia: the view holds
|
||||
// indexes into the rows, and there is a moment in every refresh — the rows
|
||||
// replaced, the view not yet rebuilt — when an index of the old list points
|
||||
// past the end of the new one. refilter itself asks this question in that
|
||||
// moment, to remember which machine the cursor was on. Checking only the view's
|
||||
// own length left a panic there that needed nothing but a machine disappearing
|
||||
// from the inventory while the cursor was near the end of the list: rare with
|
||||
// ^r, and every ten seconds with live mode.
|
||||
func (b *browser) current() *vmRow {
|
||||
if b.sel < 0 || b.sel >= len(b.view) {
|
||||
return nil
|
||||
}
|
||||
return &b.rows[b.view[b.sel]]
|
||||
i := b.view[b.sel]
|
||||
if i < 0 || i >= len(b.rows) {
|
||||
return nil
|
||||
}
|
||||
return &b.rows[i]
|
||||
}
|
||||
|
||||
func (b *browser) move(n int) {
|
||||
@@ -1091,6 +1161,7 @@ func (b *browser) reload() {
|
||||
b.rows, b.sessions = found.rows, found.sessions
|
||||
b.answered, b.lost = found.answered, found.lost
|
||||
b.applySort() // which refilters; the order the table was in survives a reload
|
||||
b.sample() // a reload is a sweep, and the trend column is drawn from sweeps
|
||||
closeSessions(old)
|
||||
|
||||
if len(found.failed) > 0 {
|
||||
@@ -1332,6 +1403,18 @@ func (b *browser) renderList() {
|
||||
title = append(title,
|
||||
seg{colDim, " "},
|
||||
seg{colMatch, b.sortLabel()}) // pink: the one thing here that was chosen
|
||||
// A screen that changes on its own has to say so, and how often: a list
|
||||
// that moves under somebody's hands with nothing to explain it reads as a
|
||||
// fault. The busy colour, because that is what the task column wears.
|
||||
if b.live {
|
||||
// The gap the pending tick was scheduled with, not one worked out again
|
||||
// here: a clone that started in the meantime would make the title
|
||||
// promise every two seconds while the refresh that is actually pending
|
||||
// is nine seconds away.
|
||||
title = append(title,
|
||||
seg{colDim, " "},
|
||||
seg{colBusy, SF("live %s", b.liveGap)})
|
||||
}
|
||||
segLine(&sb, cols, title...)
|
||||
|
||||
if b.filter == "" {
|
||||
@@ -1597,6 +1680,13 @@ func sheetFor(sheet []sheetLine, cols int) []sheetLine {
|
||||
func (b *browser) openDetail() {
|
||||
r := b.current()
|
||||
if r == nil {
|
||||
// Nothing to show it of. A sheet that is already open is then a page of
|
||||
// facts about a machine that has gone — deleted, or on a vCenter that
|
||||
// stopped answering — and leaving it on the screen would be the one
|
||||
// thing worse than closing it: an entire screen of stale truth with
|
||||
// nothing to say it is stale. It is reached by a live refresh, which
|
||||
// rebuilds an open sheet without anybody pressing anything.
|
||||
b.detail, b.dscroll = nil, 0
|
||||
return
|
||||
}
|
||||
b.dtitle = r.name
|
||||
|
||||
@@ -316,6 +316,70 @@ func TestSheetLettersReachTheirActions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The view holds indexes into the rows, and every refresh has a moment where
|
||||
// the rows have been replaced and the view has not been rebuilt yet. refilter
|
||||
// asks which machine the cursor was on in exactly that moment, so a machine
|
||||
// disappearing from the inventory while the cursor sat near the end of the list
|
||||
// used to take gvm down with an index out of range — rare with ^r, and every
|
||||
// ten seconds once live mode ticks on its own.
|
||||
func TestASweepThatCameBackShorterDoesNotPanic(t *testing.T) {
|
||||
b := testBrowser("web01", "db01", "app07", "mail02")
|
||||
b.applySort()
|
||||
b.sel = len(b.view) - 1 // where somebody scrolling to the bottom leaves it
|
||||
|
||||
// What a reload and a live tick both do: the rows in place, then sort and
|
||||
// refilter against a view that still describes the longer list.
|
||||
b.rows = b.rows[:1]
|
||||
b.applySort()
|
||||
|
||||
if len(b.view) != 1 || b.sel != 0 {
|
||||
t.Errorf("the shorter list left view %v and the cursor at %d", b.view, b.sel)
|
||||
}
|
||||
if cur := b.current(); cur == nil {
|
||||
t.Error("there is a machine left, and the cursor is on none of them")
|
||||
}
|
||||
|
||||
// And with nothing left at all, the cursor is on nothing rather than on
|
||||
// memory that is no longer there.
|
||||
b.rows = nil
|
||||
b.applySort()
|
||||
if cur := b.current(); cur != nil {
|
||||
t.Errorf("an empty list has a machine under the cursor: %q", cur.name)
|
||||
}
|
||||
|
||||
// A stale view against empty rows is the same question asked the other way
|
||||
// round, and it is the one refilter walks into.
|
||||
b.view = []int{0, 1, 2}
|
||||
if cur := b.current(); cur != nil {
|
||||
t.Errorf("a view pointing past the rows gave back %q", cur.name)
|
||||
}
|
||||
}
|
||||
|
||||
// A sheet is not left on screen for a machine that has gone. It is reached by a
|
||||
// live refresh, which rebuilds an open sheet with nobody pressing anything: a
|
||||
// whole screen of facts about something that no longer exists, with nothing on
|
||||
// it to say so, is worse than no screen.
|
||||
func TestTheSheetClosesWhenItsMachineGoes(t *testing.T) {
|
||||
b := testBrowser("web01", "db01")
|
||||
b.applySort()
|
||||
b.openDetail()
|
||||
if b.detail == nil {
|
||||
t.Fatal("no sheet to begin with")
|
||||
}
|
||||
was := b.dtitle
|
||||
|
||||
b.rows = nil
|
||||
b.refilter()
|
||||
b.openDetail() // what a live tick does
|
||||
|
||||
if b.detail != nil {
|
||||
t.Errorf("the sheet of %q is still on screen with %d lines", was, len(b.detail))
|
||||
}
|
||||
if b.dscroll != 0 {
|
||||
t.Errorf("the closed sheet kept a scroll position of %d", b.dscroll)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetailSheetHasTheParameters(t *testing.T) {
|
||||
sheet := sheetText(vmDetail(testRow("web01", true, "10.0.0.5"), []string{"none"}, colOff))
|
||||
|
||||
|
||||
@@ -0,0 +1,632 @@
|
||||
// estate.go — the whole estate on one screen: every host of every server that
|
||||
// answered, grouped by cluster, with what it carries set against what it has.
|
||||
//
|
||||
// The machine list answers "what is this machine doing". This answers the
|
||||
// question that comes before buying or growing anything and that nothing else
|
||||
// in gvm answers: *where is there still room*. Since `size` can now give a
|
||||
// machine four more processors, gvm itself raises that question, and a tool that
|
||||
// raises a question ought to answer it.
|
||||
//
|
||||
// Two kinds of number, and the difference between them is the point:
|
||||
//
|
||||
// - What is allocated. Every vCPU and every megabyte the machines on a host
|
||||
// have been promised, added up — which routinely exceeds the host, and is
|
||||
// meant to. The ratio is the interesting figure: 2.0x of memory on a host is
|
||||
// a decision somebody made, 8.0x is one somebody forgot.
|
||||
// - What is in use. What the host itself reports it is actually doing. A host
|
||||
// at 4x allocation and 30 % load is fine; the same host at 90 % is not, and
|
||||
// no allocation figure can tell those apart.
|
||||
//
|
||||
// The allocations are added up from the rows the list already holds — no machine
|
||||
// is read twice for this — and matched to hosts by reference, never by name:
|
||||
// host.go has the scar from doing that by name, where a host added by address
|
||||
// and renamed later reported zero machines while running dozens.
|
||||
package main
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/vmware/govmomi/units"
|
||||
"github.com/vmware/govmomi/vim25/mo"
|
||||
"github.com/vmware/govmomi/vim25/types"
|
||||
)
|
||||
|
||||
// estateProps is what a host has to say about itself. "summary" wholesale, the
|
||||
// way host.go asks for it: it is one property that carries both the hardware
|
||||
// and the live figures, and asking for the two paths separately would be two
|
||||
// reads of the same document.
|
||||
// Deliberately without "vm": it is an array of every machine reference on the
|
||||
// host, thousands of them across a large estate, and nothing here reads it. What
|
||||
// each host carries is added up from the rows the list already holds.
|
||||
var estateProps = []string{"name", "parent", "summary", "runtime.connectionState",
|
||||
"runtime.inMaintenanceMode", "overallStatus"}
|
||||
|
||||
// estateRow is one line of the screen: either a cluster heading or a host.
|
||||
type estateRow struct {
|
||||
heading string // a cluster, or the server itself — set only on headings
|
||||
host string
|
||||
|
||||
vms, on int // machines carried, and how many are running
|
||||
cores int32 // physical
|
||||
allocs int32 // vCPUs promised to the machines on it
|
||||
memPhys int64
|
||||
allocMB int64 // memory promised to them
|
||||
|
||||
cpuPct float64
|
||||
cpuKnown bool
|
||||
memPct float64
|
||||
memKnown bool
|
||||
|
||||
note string // maintenance, or a connection state that is not "connected"
|
||||
bad bool // that note is a fault rather than a state of affairs
|
||||
}
|
||||
|
||||
func (e estateRow) isHeading() bool { return e.heading != "" }
|
||||
|
||||
// estate is the screen: the rows in display order and where the cursor is.
|
||||
type estate struct {
|
||||
rows []estateRow
|
||||
sel int
|
||||
scroll int
|
||||
failed []string // servers that could not be read for it
|
||||
}
|
||||
|
||||
// openEstate builds it and puts it on screen. It is read fresh every time: the
|
||||
// screen exists to be looked at when a decision is being made, and a cached
|
||||
// answer to "where is there room" is the wrong kind of wrong.
|
||||
func (b *browser) openEstate() {
|
||||
// What the cursor was on, where this is a second reading of the same screen.
|
||||
// A refresh that puts the cursor back at the top is a refresh one stops
|
||||
// pressing.
|
||||
was := ""
|
||||
if b.estate != nil && b.estate.sel < len(b.estate.rows) {
|
||||
was = b.estate.rows[b.estate.sel].host
|
||||
}
|
||||
|
||||
// The old screen stays up while the hosts are read, so ^r does not flash
|
||||
// the machine list underneath for the half second it takes.
|
||||
b.working("reading the hosts ...")
|
||||
|
||||
e := buildEstate(b.sessions, b.rows)
|
||||
if len(e.rows) == 0 {
|
||||
b.setStatus(colErr, "no host could be read"+said(e.failed))
|
||||
return
|
||||
}
|
||||
e.sel = e.firstHost()
|
||||
for i, r := range e.rows {
|
||||
if !r.isHeading() && r.host == was {
|
||||
e.sel = i
|
||||
break
|
||||
}
|
||||
}
|
||||
b.estate = e
|
||||
|
||||
if len(e.failed) > 0 {
|
||||
b.setStatus(colWarn, "without"+said(e.failed))
|
||||
return
|
||||
}
|
||||
b.setStatus("", "")
|
||||
}
|
||||
|
||||
func said(failed []string) string {
|
||||
if len(failed) == 0 {
|
||||
return ""
|
||||
}
|
||||
return " " + strings.Join(failed, ", ")
|
||||
}
|
||||
|
||||
func (b *browser) closeEstate() { b.estate = nil }
|
||||
|
||||
// buildEstate reads every server that is still connected, in parallel, and adds
|
||||
// up what the list already knows about their machines.
|
||||
func buildEstate(sessions []*session, rows []vmRow) *estate {
|
||||
type result struct {
|
||||
rows []estateRow
|
||||
vc string
|
||||
err error
|
||||
}
|
||||
res := make([]result, len(sessions))
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i, s := range sessions {
|
||||
if s == nil {
|
||||
continue
|
||||
}
|
||||
wg.Add(1)
|
||||
go func(i int, s *session) {
|
||||
defer wg.Done()
|
||||
res[i].vc = s.vc.Name
|
||||
res[i].rows, res[i].err = hostsOf(s, rows)
|
||||
}(i, s)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
e := &estate{}
|
||||
for _, r := range res {
|
||||
if r.vc == "" {
|
||||
continue
|
||||
}
|
||||
if r.err != nil {
|
||||
e.failed = append(e.failed, r.vc)
|
||||
continue
|
||||
}
|
||||
e.rows = append(e.rows, r.rows...)
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// hostsOf is one server's hosts, grouped under their clusters and in a stable
|
||||
// order: clusters by name, hosts by name within them. A standalone host has a
|
||||
// compute resource of its own for a parent rather than a cluster, and is
|
||||
// grouped under the server's own name instead of under a heading that would be
|
||||
// the host's name repeated.
|
||||
func hostsOf(s *session, rows []vmRow) ([]estateRow, error) {
|
||||
hosts, err := s.hosts(estateProps...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// The cluster names, for the headings. A server with no clusters at all
|
||||
// costs one empty read, which is cheaper than deciding whether to ask.
|
||||
var clusters []mo.ClusterComputeResource
|
||||
if err := s.retrieve("ClusterComputeResource", []string{"name"}, &clusters); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clusterName := make(map[types.ManagedObjectReference]string, len(clusters))
|
||||
for _, c := range clusters {
|
||||
clusterName[c.Reference()] = c.Name
|
||||
}
|
||||
|
||||
alloc := allocationsBy(rows, s.vc.Name)
|
||||
|
||||
byCluster := map[string][]estateRow{}
|
||||
for _, h := range hosts {
|
||||
row := hostRow(h)
|
||||
if a, ok := alloc[h.Reference()]; ok {
|
||||
row.vms, row.on, row.allocs, row.allocMB = a.vms, a.on, a.cpus, a.memMB
|
||||
}
|
||||
|
||||
// A host outside a cluster has a compute resource of its own for a
|
||||
// parent, whose name is the host's name again — which would make a
|
||||
// heading that says the same thing as the line under it.
|
||||
group := "standalone"
|
||||
if h.Parent != nil {
|
||||
if name, ok := clusterName[*h.Parent]; ok {
|
||||
group = name
|
||||
}
|
||||
}
|
||||
byCluster[group] = append(byCluster[group], row)
|
||||
}
|
||||
|
||||
groups := make([]string, 0, len(byCluster))
|
||||
for g := range byCluster {
|
||||
groups = append(groups, g)
|
||||
}
|
||||
sort.Strings(groups)
|
||||
|
||||
var out []estateRow
|
||||
for _, g := range groups {
|
||||
hs := byCluster[g]
|
||||
sort.Slice(hs, func(i, j int) bool { return hs[i].host < hs[j].host })
|
||||
|
||||
// The server's name is part of the heading, not just of the title: two
|
||||
// vCenters may each hold a cluster called "prod", and a screen that is
|
||||
// about where there is room must not put the two under one total.
|
||||
head := estateRow{heading: s.vc.Name + " · " + g}
|
||||
for _, h := range hs {
|
||||
head.vms, head.on = head.vms+h.vms, head.on+h.on
|
||||
head.cores, head.allocs = head.cores+h.cores, head.allocs+h.allocs
|
||||
head.memPhys, head.allocMB = head.memPhys+h.memPhys, head.allocMB+h.allocMB
|
||||
}
|
||||
out = append(out, head)
|
||||
out = append(out, hs...)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// hostRow is what one host says about itself, without what it carries — that
|
||||
// is added from the list's own rows. Separate so the rules below can be
|
||||
// exercised without a server.
|
||||
func hostRow(h mo.HostSystem) estateRow {
|
||||
row := estateRow{host: shortHost(h.Name)}
|
||||
|
||||
// What it has survives losing touch with it — vCenter keeps the hardware
|
||||
// summary — but what it is *doing* does not: a host that is not answering
|
||||
// reports zeroed live figures, which would draw an empty bar and a nought.
|
||||
// That is the picture of an idle host, and bar() exists to keep the two
|
||||
// apart.
|
||||
reachable := h.Runtime.ConnectionState == types.HostSystemConnectionStateConnected
|
||||
|
||||
if hw := h.Summary.Hardware; hw != nil {
|
||||
row.cores = int32(hw.NumCpuCores)
|
||||
row.memPhys = hw.MemorySize
|
||||
if reachable {
|
||||
row.cpuPct, row.cpuKnown = cpuPercent(hw, h.Summary.QuickStats)
|
||||
}
|
||||
}
|
||||
used := int64(h.Summary.QuickStats.OverallMemoryUsage) * 1024 * 1024
|
||||
if reachable && row.memPhys > 0 {
|
||||
row.memPct, row.memKnown = 100/float64(row.memPhys)*float64(used), true
|
||||
}
|
||||
|
||||
// What is wrong with it, if anything, in the words vCenter uses. A host in
|
||||
// maintenance is not broken and says so in its own colour; one that is not
|
||||
// connected is the reason its figures are missing.
|
||||
switch {
|
||||
case h.Runtime.InMaintenanceMode:
|
||||
row.note = "maintenance"
|
||||
case !reachable:
|
||||
row.note, row.bad = string(h.Runtime.ConnectionState), true
|
||||
case h.OverallStatus == types.ManagedEntityStatusRed:
|
||||
row.note, row.bad = "red", true
|
||||
case h.OverallStatus == types.ManagedEntityStatusYellow:
|
||||
row.note = "yellow"
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
// allocation is what one host's machines have been promised.
|
||||
type allocation struct {
|
||||
vms, on int
|
||||
cpus int32
|
||||
memMB int64
|
||||
}
|
||||
|
||||
// allocationsBy adds the list's own rows up per host. By reference, not by
|
||||
// name: see the file comment.
|
||||
func allocationsBy(rows []vmRow, vc string) map[types.ManagedObjectReference]allocation {
|
||||
out := map[types.ManagedObjectReference]allocation{}
|
||||
for _, r := range rows {
|
||||
if r.vc.Name != vc {
|
||||
continue
|
||||
}
|
||||
ref := r.vm.Summary.Runtime.Host
|
||||
if ref == nil {
|
||||
continue // a machine vCenter is not currently placing anywhere
|
||||
}
|
||||
a := out[*ref]
|
||||
a.vms++
|
||||
if r.running() {
|
||||
a.on++
|
||||
}
|
||||
// Only what is running is charged against a host. A machine that is
|
||||
// switched off has been promised nothing it is using: counting its
|
||||
// memory would make a host of parked machines look full when the whole
|
||||
// point of parking them there was that it is not.
|
||||
if r.running() {
|
||||
a.cpus += r.vm.Summary.Config.NumCpu
|
||||
a.memMB += int64(r.vm.Summary.Config.MemorySizeMB)
|
||||
}
|
||||
out[*ref] = a
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// firstHost is where the cursor goes: a heading is not selectable, for the same
|
||||
// reason a menu separator is not.
|
||||
func (e *estate) firstHost() int {
|
||||
for i, r := range e.rows {
|
||||
if !r.isHeading() {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// lastHost is the other end, for End and for a page jump that overshoots.
|
||||
func (e *estate) lastHost() int {
|
||||
for i := len(e.rows) - 1; i >= 0; i-- {
|
||||
if !e.rows[i].isHeading() {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// move steps over the headings. The step is taken once and then walked off by
|
||||
// ones until it is on a host — it is not taken again.
|
||||
//
|
||||
// Repeating it is what the menu does, and the menu is only ever stepped by one,
|
||||
// where repeating and walking are the same thing. Here they are not: a page of
|
||||
// five over an estate with a heading every fourth row landed page-up from row
|
||||
// nine on row one and page-down from row three on row eleven, because the
|
||||
// second leap cleared the rest of the screen. A page key that jumps to the top
|
||||
// whenever it lands on a cluster name is worse than one that stops short.
|
||||
//
|
||||
// Running out of rows stops at the end of the travel rather than doing nothing:
|
||||
// from the second host there is no row a whole page above, and "no row" has to
|
||||
// mean the first one.
|
||||
func (e *estate) move(step int) {
|
||||
if step == 0 {
|
||||
return
|
||||
}
|
||||
walk := 1
|
||||
if step < 0 {
|
||||
walk = -1
|
||||
}
|
||||
for i := e.sel + step; i >= 0 && i < len(e.rows); i += walk {
|
||||
if !e.rows[i].isHeading() {
|
||||
e.sel = i
|
||||
return
|
||||
}
|
||||
}
|
||||
if step < 0 {
|
||||
e.sel = e.firstHost()
|
||||
return
|
||||
}
|
||||
e.sel = e.lastHost()
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- the keys
|
||||
|
||||
// estateKey drives the screen. Enter is the only thing here that changes
|
||||
// anything, and what it changes is the filter: the answer to "what is on this
|
||||
// host" is the machine list narrowed to it, which gvm already knows how to
|
||||
// draw.
|
||||
func (b *browser) estateKey(k key) {
|
||||
e := b.estate
|
||||
_, rows := termSize()
|
||||
page := max(rows-6, 1)
|
||||
|
||||
switch k.special {
|
||||
case keyEsc, keyLeft, keyCtrlE:
|
||||
b.closeEstate()
|
||||
case keyUp, keyShiftTab:
|
||||
e.move(-1)
|
||||
case keyDown, keyTab:
|
||||
e.move(1)
|
||||
case keyPgUp:
|
||||
e.move(-page)
|
||||
case keyPgDn:
|
||||
e.move(page)
|
||||
case keyHome:
|
||||
e.move(-len(e.rows))
|
||||
case keyEnd:
|
||||
e.move(len(e.rows))
|
||||
case keyCtrlR:
|
||||
b.openEstate()
|
||||
case keyEnter:
|
||||
b.showHost()
|
||||
}
|
||||
}
|
||||
|
||||
// showHost narrows the machine list to the host under the cursor and goes back
|
||||
// to it. The filter is the mechanism because it is the honest one: it matches
|
||||
// the whole row, so what comes up is what anybody would get by typing the same
|
||||
// thing, and one keystroke — Esc — undoes it.
|
||||
func (b *browser) showHost() {
|
||||
e := b.estate
|
||||
if e.sel < 0 || e.sel >= len(e.rows) {
|
||||
return
|
||||
}
|
||||
row := e.rows[e.sel]
|
||||
if row.isHeading() {
|
||||
return
|
||||
}
|
||||
|
||||
b.closeEstate()
|
||||
b.filter = row.host
|
||||
b.refilter()
|
||||
|
||||
// The count is the host's own, not the filtered view's. The filter is a
|
||||
// text match over the whole row and carries no server, so it can also catch
|
||||
// another vCenter's host of the same name, a host whose name this one is a
|
||||
// prefix of, and a machine named after a host — and a count taken from it
|
||||
// would then contradict the number on the screen this came from. Saying
|
||||
// what the filter is keeps the difference visible where it happens.
|
||||
b.setStatus(colInfo, SF("%s carries %s — filtered on its name, so ^w and esc still apply",
|
||||
row.host, plural(row.vms, "machine")))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- the drawing
|
||||
|
||||
// bar is a load drawn as one block of text. Ten characters, filled to the
|
||||
// percentage — the figure is beside it, so this is for the eye running down the
|
||||
// column rather than for reading a number off.
|
||||
//
|
||||
// A load that is not known draws nothing at all, and deliberately not an empty
|
||||
// trough: a host at one per cent fills none of the ten characters either, and
|
||||
// "almost idle" and "I cannot see this host" must not be the same picture.
|
||||
func bar(pct float64, known bool) string {
|
||||
const width = 10
|
||||
if !known {
|
||||
return SR(" ", width)
|
||||
}
|
||||
full := int(pct / 100 * width)
|
||||
full = min(max(full, 0), width)
|
||||
return strings.Repeat("█", full) + strings.Repeat("·", width-full)
|
||||
}
|
||||
|
||||
// ratio is how much of a host has been promised away: 2.0x means twice what it
|
||||
// has. Under one it is left blank rather than shown as 0.4x — a host with room
|
||||
// to spare is the ordinary case, and a column of small numbers saying so is
|
||||
// noise where the whole point is to find the ones over.
|
||||
func ratio(promised, has float64) string {
|
||||
if has <= 0 || promised <= 0 || promised < has {
|
||||
return ""
|
||||
}
|
||||
return SF("%.1fx", promised/has)
|
||||
}
|
||||
|
||||
// estateWidths is how wide this screen's columns are, which is the only thing
|
||||
// that changes with the terminal: a narrow one gives up the bars and tightens
|
||||
// the two widest columns rather than letting segLine cut the line at the edge
|
||||
// and take half the screen's meaning with it.
|
||||
//
|
||||
// The three lines that make up the table — the header, a cluster's totals and a
|
||||
// host — lay their figures out through one function, because three format
|
||||
// strings kept in step by hand are three format strings that drift.
|
||||
type estateWidths struct {
|
||||
host int
|
||||
mem int
|
||||
pairs bool // the raw allocated/physical pairs, not only their ratios
|
||||
bars bool
|
||||
}
|
||||
|
||||
// The two right-hand layouts, as the format strings they are, so that the
|
||||
// header, the rows and the measurement below cannot disagree about them.
|
||||
const (
|
||||
estateBars = " %-10s %4s %-10s %4s"
|
||||
estatePcts = " %5s %5s"
|
||||
)
|
||||
|
||||
// widthsFor picks the widest layout the terminal can hold whole. Three of them,
|
||||
// giving up the least useful thing first, the way the machine list's columns do:
|
||||
//
|
||||
// - everything, with the load drawn as bars;
|
||||
// - the same figures with the bars dropped to their percentages;
|
||||
// - and, on a genuinely narrow terminal, only the two ratios and the two
|
||||
// percentages — which is still both kinds of number, and both kinds is what
|
||||
// this screen is for. The raw pairs behind them are detail.
|
||||
func widthsFor(cols int) estateWidths {
|
||||
for _, w := range []estateWidths{
|
||||
{host: 20, mem: 17, pairs: true, bars: true},
|
||||
{host: 16, mem: 15, pairs: true},
|
||||
{host: 14},
|
||||
} {
|
||||
if cols >= w.width() {
|
||||
return w
|
||||
}
|
||||
}
|
||||
return estateWidths{host: 10}
|
||||
}
|
||||
|
||||
// width is what a layout needs, measured rather than counted: the first version
|
||||
// of this was a round number picked by eye, and at exactly 100 columns the wide
|
||||
// layout is 105 wide and lost the figure on its right-hand end — which is the
|
||||
// one failure the narrower layouts exist to prevent.
|
||||
func (w estateWidths) width() int {
|
||||
n := 2 + w.host + len([]rune(w.figures("", "", "", "", "")))
|
||||
if w.bars {
|
||||
return n + len([]rune(SF(estateBars, "", "", "", "")))
|
||||
}
|
||||
return n + len([]rune(SF(estatePcts, "", "")))
|
||||
}
|
||||
|
||||
// figures is the middle of every line: the header's, a cluster's totals and a
|
||||
// host's. One function, because three format strings kept in step by hand are
|
||||
// three format strings that drift.
|
||||
func (w estateWidths) figures(onvm, vcpu, vratio, mem, mratio string) string {
|
||||
out := SF(" %6s", onvm)
|
||||
if w.pairs {
|
||||
out += SF(" %9s", vcpu)
|
||||
}
|
||||
out += SF(" %6s", vratio)
|
||||
if w.pairs {
|
||||
out += SF(" %*s", w.mem, mem)
|
||||
}
|
||||
return out + SF(" %6s", mratio)
|
||||
}
|
||||
|
||||
// figuresOf is that for a row, heading or host alike: the numbers are the same
|
||||
// numbers, summed or not.
|
||||
func (w estateWidths) figuresOf(r estateRow) string {
|
||||
return w.figures(
|
||||
SF("%d/%d", r.on, r.vms),
|
||||
SF("%d/%d", r.allocs, r.cores),
|
||||
ratio(float64(r.allocs), float64(r.cores)),
|
||||
SF("%s/%s", units.ByteSize(r.allocMB*1024*1024), units.ByteSize(r.memPhys)),
|
||||
ratio(float64(r.allocMB*1024*1024), float64(r.memPhys)))
|
||||
}
|
||||
|
||||
func (b *browser) renderEstate() {
|
||||
cols, rows := termSize()
|
||||
e := b.estate
|
||||
|
||||
visible := max(rows-5, 1)
|
||||
if e.sel < e.scroll {
|
||||
e.scroll = e.sel
|
||||
}
|
||||
if e.sel >= e.scroll+visible {
|
||||
e.scroll = e.sel - visible + 1
|
||||
}
|
||||
end := min(e.scroll+visible, len(e.rows))
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(scrClear + scrHide)
|
||||
|
||||
hosts, machines := 0, 0
|
||||
for _, r := range e.rows {
|
||||
if !r.isHeading() {
|
||||
hosts++
|
||||
machines += r.vms
|
||||
}
|
||||
}
|
||||
segLine(&sb, cols,
|
||||
seg{colTitle, "Estate"},
|
||||
seg{colDim, " "},
|
||||
seg{colWhere, strings.Join(b.answered, ", ")},
|
||||
seg{colDim, " "},
|
||||
seg{colInfo, SF("%s · %s", plural(hosts, "host"), plural(machines, "machine"))})
|
||||
segLine(&sb, cols)
|
||||
|
||||
w := widthsFor(cols)
|
||||
head := SF(" %-*s%s", w.host, "CLUSTER / HOST",
|
||||
w.figures("ON/VM", "vCPU", "CPU x", "MEM ALLOC", "MEM x"))
|
||||
if w.bars {
|
||||
head += SF(estateBars, "CPU LOAD", "%", "MEM USED", "%")
|
||||
} else {
|
||||
head += SF(estatePcts, "CPU%", "MEM%")
|
||||
}
|
||||
segLine(&sb, cols, seg{colHeader, head})
|
||||
|
||||
for i := e.scroll; i < end; i++ {
|
||||
r := e.rows[i]
|
||||
if r.isHeading() {
|
||||
segLine(&sb, cols,
|
||||
seg{colDim, " "},
|
||||
seg{colLabel, padRight(truncate(r.heading, w.host), w.host)},
|
||||
seg{colDim, w.figuresOf(r)})
|
||||
continue
|
||||
}
|
||||
|
||||
pointer, name := " ", colRow
|
||||
if i == e.sel {
|
||||
pointer, name = "▸ ", colRowSel
|
||||
}
|
||||
line := []seg{
|
||||
{colPointer, pointer},
|
||||
{name, padRight(truncate(r.host, w.host), w.host)},
|
||||
{colSize, w.figuresOf(r)},
|
||||
}
|
||||
if w.bars {
|
||||
line = append(line,
|
||||
seg{colDim, " "},
|
||||
seg{loadColor(r.cpuPct, r.cpuKnown), bar(r.cpuPct, r.cpuKnown)},
|
||||
seg{loadColor(r.cpuPct, r.cpuKnown), SF(" %4s", loadCell(r.cpuPct, r.cpuKnown))},
|
||||
seg{colDim, " "},
|
||||
seg{loadColor(r.memPct, r.memKnown), bar(r.memPct, r.memKnown)},
|
||||
seg{loadColor(r.memPct, r.memKnown), SF(" %4s", loadCell(r.memPct, r.memKnown))})
|
||||
} else {
|
||||
line = append(line,
|
||||
seg{loadColor(r.cpuPct, r.cpuKnown), SF(" %5s", loadCell(r.cpuPct, r.cpuKnown))},
|
||||
seg{loadColor(r.memPct, r.memKnown), SF(" %5s", loadCell(r.memPct, r.memKnown))})
|
||||
}
|
||||
if r.note != "" {
|
||||
col := colWarn
|
||||
if r.bad {
|
||||
col = colErr
|
||||
}
|
||||
line = append(line, seg{colDim, " "}, seg{col, r.note})
|
||||
}
|
||||
segLine(&sb, cols, line...)
|
||||
}
|
||||
for i := end - e.scroll; i < visible; i++ {
|
||||
sb.WriteString(scrEOL + "\r\n")
|
||||
}
|
||||
|
||||
if b.status != "" {
|
||||
segLine(&sb, cols, seg{b.statusCol, b.status})
|
||||
} else {
|
||||
segLine(&sb, cols, seg{colDim,
|
||||
"allocated / physical, and the ratio where more is promised than there is"})
|
||||
}
|
||||
sb.WriteString(colDim + truncate(estateHelp, cols) + attrOff + scrEOL)
|
||||
b.parkCursor(&sb, cols, rows)
|
||||
|
||||
b.write(sb.String())
|
||||
}
|
||||
|
||||
const estateHelp = "↑/↓ move ⏎ its machines ^r read again esc/^e back ^c quit"
|
||||
@@ -0,0 +1,43 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Whatever the terminal, the table fits it: the screen is two kinds of number
|
||||
// set against each other, and a line cut at the right-hand edge takes one of
|
||||
// the two away.
|
||||
func TestTheEstateFitsEveryTerminal(t *testing.T) {
|
||||
e := &estate{rows: []estateRow{
|
||||
{heading: "v308 · a-cluster-with-a-long-name-indeed"},
|
||||
{host: "esx-with-a-very-long-name-01", vms: 22, on: 21, cores: 32, allocs: 72,
|
||||
memPhys: 1 << 38, allocMB: 400000, cpuPct: 41, cpuKnown: true, memPct: 72, memKnown: true},
|
||||
{host: "esx02", vms: 4, on: 0, note: "notResponding", bad: true},
|
||||
}}
|
||||
b := &browser{estate: e, answered: []string{"v308"}}
|
||||
|
||||
for _, cols := range []string{"60", "79", "80", "99", "100", "104", "105", "132", "200"} {
|
||||
t.Setenv("COLUMNS", cols)
|
||||
t.Setenv("LINES", "12")
|
||||
frame := stripEscapes(renderToPipe(t, b, b.renderEstate))
|
||||
want := atoiOr(cols)
|
||||
for _, l := range strings.Split(strings.ReplaceAll(frame, "\r", ""), "\n") {
|
||||
if n := len([]rune(l)); n > want {
|
||||
t.Errorf("%s columns: a line ran to %d characters: %q", cols, n, l)
|
||||
}
|
||||
}
|
||||
// And whichever layout it chose, both kinds of number are on it.
|
||||
if !strings.Contains(frame, "MEM%") && !strings.Contains(frame, "MEM USED") {
|
||||
t.Errorf("%s columns: the used-memory figure is not on the screen:\n%s", cols, frame)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func atoiOr(s string) int {
|
||||
n := 0
|
||||
for _, r := range s {
|
||||
n = n*10 + int(r-'0')
|
||||
}
|
||||
return n
|
||||
}
|
||||
+225
@@ -0,0 +1,225 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/vmware/govmomi/vim25/mo"
|
||||
"github.com/vmware/govmomi/vim25/types"
|
||||
)
|
||||
|
||||
// The ratio is there to find the hosts that have been promised away, so it says
|
||||
// nothing at all about the ones that have room — a column of "0.4x" down a
|
||||
// screen of healthy hosts is noise where the point is to spot the one over.
|
||||
func TestRatioOnlySpeaksWhenThereIsSomethingToSay(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
promised, has float64
|
||||
want string
|
||||
}{
|
||||
{16, 8, "2.0x"},
|
||||
{12, 8, "1.5x"},
|
||||
{8, 8, "1.0x"},
|
||||
{4, 8, ""}, // room to spare, which is the ordinary case
|
||||
{0, 8, ""}, // nothing on it at all
|
||||
{8, 0, ""}, // a host whose hardware could not be read
|
||||
{8, -1, ""}, // and nonsense from the server does not divide
|
||||
} {
|
||||
if got := ratio(c.promised, c.has); got != c.want {
|
||||
t.Errorf("ratio(%.0f, %.0f) = %q, want %q", c.promised, c.has, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A host at one per cent fills none of the bar, and so does a host nobody can
|
||||
// see. The two must not draw the same picture.
|
||||
func TestBarTellsIdleFromUnknown(t *testing.T) {
|
||||
idle := bar(1, true)
|
||||
unknown := bar(0, false)
|
||||
if idle == unknown {
|
||||
t.Errorf("idle and unknown both drew %q", idle)
|
||||
}
|
||||
if strings.TrimSpace(unknown) != "" {
|
||||
t.Errorf("an unknown load drew %q", unknown)
|
||||
}
|
||||
if !strings.HasPrefix(bar(100, true), "██████████") {
|
||||
t.Errorf("a full host drew %q", bar(100, true))
|
||||
}
|
||||
if strings.Contains(bar(50, true), "···········") {
|
||||
t.Errorf("half drew %q", bar(50, true))
|
||||
}
|
||||
// Nonsense from the server does not run off the end of the bar.
|
||||
for _, pct := range []float64{-10, 140} {
|
||||
if n := len([]rune(bar(pct, true))); n != 10 {
|
||||
t.Errorf("%.0f%% drew %d characters", pct, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// What a host carries is added up from the rows the list already holds, and
|
||||
// matched by reference — never by name, which is the mistake host.go carries a
|
||||
// comment about. Only running machines are charged to it: a parked machine has
|
||||
// been promised nothing it is using, and counting it would make a host of
|
||||
// switched-off machines look full.
|
||||
func TestAllocationsAreCountedByReferenceAndOnlyWhatRuns(t *testing.T) {
|
||||
host := types.ManagedObjectReference{Type: "HostSystem", Value: "host-99"}
|
||||
other := types.ManagedObjectReference{Type: "HostSystem", Value: "host-1"}
|
||||
|
||||
on := func(name string, running bool, ref types.ManagedObjectReference) vmRow {
|
||||
r := testRow(name, running, "10.0.0.5")
|
||||
r.ref = types.ManagedObjectReference{Type: "VirtualMachine", Value: name}
|
||||
r.vm.Summary.Runtime.Host = &ref
|
||||
r.vm.Summary.Config.NumCpu = 4
|
||||
r.vm.Summary.Config.MemorySizeMB = 8192
|
||||
return r
|
||||
}
|
||||
rows := []vmRow{
|
||||
on("web01", true, host),
|
||||
on("web02", true, host),
|
||||
on("parked", false, host), // counted as a machine, charged for nothing
|
||||
on("elsewhere", true, other),
|
||||
}
|
||||
// A machine vCenter is not placing anywhere at all.
|
||||
homeless := on("limbo", true, host)
|
||||
homeless.vm.Summary.Runtime.Host = nil
|
||||
rows = append(rows, homeless)
|
||||
|
||||
got := allocationsBy(rows, "v308")
|
||||
a := got[host]
|
||||
if a.vms != 3 || a.on != 2 {
|
||||
t.Errorf("the host carries %d machines, %d on; want 3 and 2", a.vms, a.on)
|
||||
}
|
||||
if a.cpus != 8 {
|
||||
t.Errorf("%d vCPUs charged to it, want 8 — the parked machine is not one", a.cpus)
|
||||
}
|
||||
if a.memMB != 16384 {
|
||||
t.Errorf("%d MB charged to it, want 16384", a.memMB)
|
||||
}
|
||||
if got[other].vms != 1 {
|
||||
t.Errorf("the other host got %d machines", got[other].vms)
|
||||
}
|
||||
|
||||
// Another server's rows are not this server's, even where a reference
|
||||
// repeats: references are unique within a vCenter and not across them.
|
||||
if len(allocationsBy(rows, "v309")) != 0 {
|
||||
t.Error("rows of one server were charged to another")
|
||||
}
|
||||
}
|
||||
|
||||
// Headings are not selectable, the way a menu separator is not, and the cursor
|
||||
// starts on a host rather than on the first line.
|
||||
func TestEstateCursorSkipsTheHeadings(t *testing.T) {
|
||||
e := &estate{rows: []estateRow{
|
||||
{heading: "v308 · prod"},
|
||||
{host: "esx01"},
|
||||
{host: "esx02"},
|
||||
{heading: "v308 · standalone"},
|
||||
{host: "esx09"},
|
||||
}}
|
||||
e.sel = e.firstHost()
|
||||
if e.sel != 1 {
|
||||
t.Fatalf("the cursor starts at %d, want the first host", e.sel)
|
||||
}
|
||||
|
||||
e.move(1)
|
||||
if e.sel != 2 {
|
||||
t.Errorf("down went to %d", e.sel)
|
||||
}
|
||||
e.move(1) // over the heading
|
||||
if e.sel != 4 || e.rows[e.sel].isHeading() {
|
||||
t.Errorf("down landed on %d (%+v)", e.sel, e.rows[e.sel])
|
||||
}
|
||||
e.move(1) // at the end, it stays put
|
||||
if e.sel != 4 {
|
||||
t.Errorf("down past the end moved to %d", e.sel)
|
||||
}
|
||||
e.move(-1)
|
||||
if e.sel != 2 {
|
||||
t.Errorf("up landed on %d", e.sel)
|
||||
}
|
||||
e.move(-10) // past the top, and never onto the heading at 0
|
||||
if e.sel != 1 {
|
||||
t.Errorf("up past the top landed on %d", e.sel)
|
||||
}
|
||||
}
|
||||
|
||||
// A page step is taken once and then walked off by ones. Repeating it — which
|
||||
// is what the menu does, where a step is only ever 1 and the two are the same
|
||||
// thing — made a page that landed on a cluster name leap another whole page:
|
||||
// page-up from the tenth row went to the first, and page-down from the fourth
|
||||
// to the last.
|
||||
func TestAPageStepDoesNotLeapTwice(t *testing.T) {
|
||||
rows := []estateRow{
|
||||
{heading: "A"}, {host: "h1"}, {host: "h2"}, {host: "h3"},
|
||||
{heading: "B"}, {host: "h5"}, {host: "h6"}, {host: "h7"},
|
||||
{heading: "C"}, {host: "h9"}, {host: "h10"}, {host: "h11"},
|
||||
}
|
||||
|
||||
e := &estate{rows: rows, sel: 9}
|
||||
e.move(-5) // lands on the heading at 4, so the row above it
|
||||
if e.sel != 3 {
|
||||
t.Errorf("page up from 9 landed on %d (%q), want 3", e.sel, e.rows[e.sel].host)
|
||||
}
|
||||
|
||||
e = &estate{rows: rows, sel: 3}
|
||||
e.move(5) // lands on the heading at 8, so the row below it
|
||||
if e.sel != 9 {
|
||||
t.Errorf("page down from 3 landed on %d (%q), want 9", e.sel, e.rows[e.sel].host)
|
||||
}
|
||||
|
||||
// A step that lands on a host is not walked at all.
|
||||
e = &estate{rows: rows, sel: 1}
|
||||
e.move(2)
|
||||
if e.sel != 3 {
|
||||
t.Errorf("a step onto a host landed on %d", e.sel)
|
||||
}
|
||||
|
||||
// And the ends still stop at the ends.
|
||||
e = &estate{rows: rows, sel: 1}
|
||||
e.move(-5)
|
||||
if e.sel != 1 {
|
||||
t.Errorf("page up from the first host landed on %d", e.sel)
|
||||
}
|
||||
e = &estate{rows: rows, sel: 11}
|
||||
e.move(5)
|
||||
if e.sel != 11 {
|
||||
t.Errorf("page down from the last host landed on %d", e.sel)
|
||||
}
|
||||
}
|
||||
|
||||
// A host nobody can reach reports nothing, cached hardware or not. vCenter
|
||||
// keeps the hardware summary of a host it has lost touch with but zeroes the
|
||||
// live figures, which would draw an empty bar and a nought — the picture of an
|
||||
// idle host, which is the one thing bar() exists to keep separate.
|
||||
func TestAnUnreachableHostReportsNoLoad(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
state types.HostSystemConnectionState
|
||||
known bool
|
||||
}{
|
||||
{types.HostSystemConnectionStateConnected, true},
|
||||
{types.HostSystemConnectionStateNotResponding, false},
|
||||
{types.HostSystemConnectionStateDisconnected, false},
|
||||
} {
|
||||
h := mo.HostSystem{
|
||||
Summary: types.HostListSummary{
|
||||
Hardware: &types.HostHardwareSummary{NumCpuCores: 32, CpuMhz: 2000,
|
||||
MemorySize: 1 << 38},
|
||||
QuickStats: types.HostListSummaryQuickStats{},
|
||||
},
|
||||
}
|
||||
h.Runtime.ConnectionState = c.state
|
||||
h.Name = "esx01"
|
||||
|
||||
row := hostRow(h)
|
||||
if row.cpuKnown != c.known || row.memKnown != c.known {
|
||||
t.Errorf("%s: cpu known = %v, memory known = %v, want both %v",
|
||||
c.state, row.cpuKnown, row.memKnown, c.known)
|
||||
}
|
||||
// What it has is known either way: that does not depend on reaching it.
|
||||
if row.cores != 32 {
|
||||
t.Errorf("%s: the core count was lost with the connection", c.state)
|
||||
}
|
||||
if !c.known && strings.TrimSpace(bar(row.cpuPct, row.cpuKnown)) != "" {
|
||||
t.Errorf("%s: it drew a load bar anyway", c.state)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.1.0"
|
||||
var version = "1.2.0"
|
||||
|
||||
func main() {
|
||||
// Answered before anything else: an update has to work on a machine that
|
||||
|
||||
@@ -0,0 +1,452 @@
|
||||
// live.go — the list that keeps itself up to date.
|
||||
//
|
||||
// Everything else in the interactive half happens because somebody pressed a
|
||||
// key. This is the part that happens because time passed: `^l` turns it on and
|
||||
// the list re-reads itself every few seconds, which turns gvm from something one
|
||||
// looks at into something one leaves open on a second screen.
|
||||
//
|
||||
// Three things come with it, and they are here rather than in browse.go because
|
||||
// they only mean anything together:
|
||||
//
|
||||
// 1. The refresh itself, over the connections that are already open. `^r` logs
|
||||
// in again — that is how a session that has died is recovered — and doing
|
||||
// that every ten seconds would be three logins a minute for nothing.
|
||||
// 2. What changed. A table says what is; after a sweep it can say what just
|
||||
// became, which is the one thing no column can hold: "db01 off · web01 +1
|
||||
// snapshot". It is the reason to leave the thing open at all.
|
||||
// 3. The trend column: each machine's recent CPU load as one column of text.
|
||||
// The samples cannot live on the rows, which every sweep throws away, so
|
||||
// the browser keeps them and writes the drawing back onto the rows.
|
||||
//
|
||||
// Nothing here acts on a machine. A refresh that could start or stop something
|
||||
// would be a timer with the power to do it, and the one thing a screen left
|
||||
// open unattended must not have is that.
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// How often the list re-reads itself. Faster while vCenter is doing something:
|
||||
// a clone's progress that moves once every ten seconds is a figure one waits
|
||||
// for, and one that moves every two is a thing one watches finish.
|
||||
//
|
||||
// Neither is configurable yet. The sweep is a few properties of every machine
|
||||
// over a connection that is already up, which is cheap enough that ten seconds
|
||||
// is not a number worth tuning per site — and a setting nobody needs is a
|
||||
// setting to keep working for ever.
|
||||
const (
|
||||
liveEvery = 10 * time.Second
|
||||
liveBusy = 2 * time.Second
|
||||
)
|
||||
|
||||
// trendLen is how many sweeps the trend column remembers, and therefore how
|
||||
// wide the column is: a history longer than the drawing would be arithmetic
|
||||
// nobody sees.
|
||||
//
|
||||
// Six rather than eight. The difference is two characters of table, and two
|
||||
// characters is what decides whether this column is on the screen at all on a
|
||||
// terminal of 120 with a task column in it — which is the ordinary case it was
|
||||
// built for. Six sweeps is a minute of history at the quiet interval.
|
||||
const trendLen = 6
|
||||
|
||||
// toggleLive turns it on and off, and says which — a mode that changes what the
|
||||
// screen does on its own has to announce itself, or a list that moves under
|
||||
// somebody's hands looks like a fault.
|
||||
//
|
||||
// Turning it on refreshes at once rather than in ten seconds' time. The
|
||||
// keystroke is a request for the current state, not for a subscription that
|
||||
// begins later.
|
||||
func (b *browser) toggleLive() {
|
||||
b.live = !b.live
|
||||
if !b.live {
|
||||
b.setStatus(colDim, "live off — ^r to reload by hand")
|
||||
return
|
||||
}
|
||||
b.liveNext, b.liveGap = time.Now(), b.liveInterval()
|
||||
b.setStatus(colBusy, "live on — the list re-reads itself; ^l off")
|
||||
}
|
||||
|
||||
// liveReady reports whether a tick may happen now. Only the list and a
|
||||
// machine's sheet are refreshed underneath somebody: the menu decides what it
|
||||
// offers from the state it was drawn with, the picker holds a list of snapshots
|
||||
// that is being chosen from, and both the editor and a question are half-typed
|
||||
// answers. Redrawing any of those from under a hand is worse than being ten
|
||||
// seconds out of date.
|
||||
//
|
||||
// The estate screen is left out for a different reason: it reads the hosts
|
||||
// itself, which is work the machine list does not do, and a timer that did it
|
||||
// every ten seconds would be paying for a screen somebody is reading rather
|
||||
// than watching. ^r reads it again.
|
||||
func (b *browser) liveReady() bool {
|
||||
return b.live && b.menu == nil && b.pick == nil && b.confirm == nil &&
|
||||
b.edit == nil && b.prompt == nil && b.estate == nil
|
||||
}
|
||||
|
||||
// liveIn is how long until the next tick — the remaining time, not the whole
|
||||
// interval, so that somebody arrowing through the list steadily cannot postpone
|
||||
// the refresh for ever.
|
||||
func (b *browser) liveIn() time.Duration {
|
||||
d := time.Until(b.liveNext)
|
||||
if d < 0 {
|
||||
return 0
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// liveInterval is the gap after this tick: short while anything at all is being
|
||||
// done on the cluster, so the task column moves while one watches it.
|
||||
func (b *browser) liveInterval() time.Duration {
|
||||
for _, r := range b.rows {
|
||||
if r.task != nil {
|
||||
return liveBusy
|
||||
}
|
||||
}
|
||||
return liveEvery
|
||||
}
|
||||
|
||||
// liveTick is one refresh. What it must not do is move the screen: the cursor
|
||||
// stays on the machine it was on, and the viewport stays where it was — a list
|
||||
// that jumps to put the selection on the last visible line every ten seconds is
|
||||
// unreadable, and that is what the ordinary refilter would do, since it is
|
||||
// written for a filter being typed, where going back to the top is right.
|
||||
func (b *browser) liveTick() {
|
||||
was := b.rows
|
||||
scroll := b.scroll
|
||||
|
||||
// The error is information, not a reason to stop: resweep may have replaced
|
||||
// the rows and still have something to report — one server of three did not
|
||||
// answer — and the rows it left are a list, just a partly older one.
|
||||
//
|
||||
// What must not happen is returning here with the rows replaced and the
|
||||
// view not rebuilt. The view holds indexes into the rows, and the drawing
|
||||
// follows it without asking: leaving the two disagreeing showed every row
|
||||
// as a different machine, put the cursor on one the operator was not
|
||||
// looking at, and panicked outright as soon as the new list was shorter.
|
||||
err := b.resweep()
|
||||
|
||||
b.applySort() // which refilters, so the view describes the rows again
|
||||
b.scroll = min(scroll, max(len(b.view)-1, 0))
|
||||
b.sample()
|
||||
|
||||
if b.detail != nil {
|
||||
// The sheet is rebuilt from the machine as it is now, at the line it was
|
||||
// being read at. It closes itself where that machine has gone.
|
||||
keep := b.dscroll
|
||||
b.openDetail()
|
||||
b.dscroll = keep
|
||||
}
|
||||
|
||||
// (4) The next tick is timed from here, not from before the sweep: a sweep
|
||||
// that takes longer than the interval would otherwise leave no idle time at
|
||||
// all, and liveIn would hand nextWithin a zero deadline that races every
|
||||
// keystroke against an already expired timer.
|
||||
b.liveGap = b.liveInterval()
|
||||
b.liveNext = time.Now().Add(b.liveGap)
|
||||
|
||||
if err != nil {
|
||||
b.setStatus(colWarn, "live: "+err.Error())
|
||||
b.saidLive = ""
|
||||
return
|
||||
}
|
||||
|
||||
// The changed line is live mode's own, and a quiet tick clears it — but it
|
||||
// clears nothing else. A message somebody produced by pressing a key is
|
||||
// theirs: "copied its hostname web01.example" or the reason a snapshot was
|
||||
// refused must not vanish because ten seconds passed and nothing happened
|
||||
// on the cluster. Every keystroke already clears the status; a timer has no
|
||||
// business doing it.
|
||||
what := changesBetween(was, b.rows)
|
||||
if what != "" {
|
||||
b.setStatus(colInfo, what)
|
||||
b.saidLive = what
|
||||
return
|
||||
}
|
||||
if b.status == b.saidLive {
|
||||
b.setStatus("", "")
|
||||
}
|
||||
b.saidLive = ""
|
||||
}
|
||||
|
||||
// resweep re-reads every machine over the sessions that are already open, in
|
||||
// parallel across the servers the way the first sweep is.
|
||||
//
|
||||
// A server that stops answering costs its own machines, not the screen: its rows
|
||||
// are kept as they were and it is named in the error. Dropping them would empty
|
||||
// half a list because one of three vCenters was restarting.
|
||||
func (b *browser) resweep() error {
|
||||
type result struct {
|
||||
rows []vmRow
|
||||
err error
|
||||
}
|
||||
res := make([]result, len(b.sessions))
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i, s := range b.sessions {
|
||||
if s == nil {
|
||||
continue
|
||||
}
|
||||
wg.Add(1)
|
||||
go func(i int, s *session) {
|
||||
defer wg.Done()
|
||||
res[i].rows, res[i].err = sweepOne(s.vc, s)
|
||||
}(i, s)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
previous := b.rows
|
||||
var rows []vmRow
|
||||
var failed, answered []string
|
||||
for i, s := range b.sessions {
|
||||
if s == nil {
|
||||
continue
|
||||
}
|
||||
if res[i].err != nil {
|
||||
failed = append(failed, s.vc.Name)
|
||||
rows = append(rows, rowsOfVC(previous, s.vc)...)
|
||||
continue
|
||||
}
|
||||
answered = append(answered, s.vc.Name)
|
||||
rows = append(rows, res[i].rows...)
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
if len(failed) == 0 {
|
||||
return errf("no connection left to re-read the machines over")
|
||||
}
|
||||
return errf("no machine could be re-read (%s)", strings.Join(failed, ", "))
|
||||
}
|
||||
b.rows = rows
|
||||
// The title is made of these two, and a refresh that leaves them alone puts
|
||||
// "412 machines on v308, v309" above a status line saying v309 did not
|
||||
// answer. One of the two is then a lie, and the status line is the one that
|
||||
// the next keystroke clears.
|
||||
b.answered, b.lost = answered, failed
|
||||
|
||||
if len(failed) > 0 {
|
||||
return errf("%s did not answer; showing what was last read of it", strings.Join(failed, ", "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// rowsOfVC keeps one server's rows across a sweep it did not survive, so a
|
||||
// vCenter that stops answering for a moment does not empty its half of the list.
|
||||
func rowsOfVC(rows []vmRow, vc VCenter) []vmRow {
|
||||
var out []vmRow
|
||||
for _, r := range rows {
|
||||
if r.vc.Name == vc.Name {
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- what changed
|
||||
|
||||
// changesBetween is what moved between two sweeps, as one line. A table shows
|
||||
// what is; this is the only thing on the screen that says what just became, and
|
||||
// it is why the list is worth leaving open.
|
||||
//
|
||||
// Deliberately short: four things and a count of the rest. A line that has to be
|
||||
// read carefully is one nobody reads at all, and the table underneath it holds
|
||||
// the detail of every one of them.
|
||||
func changesBetween(was, now []vmRow) string {
|
||||
// Indexes, not copies. A vmRow carries the machine's whole property
|
||||
// document — summary, guest, snapshot tree — and this runs every two
|
||||
// seconds on a list of hundreds to compare four scalars.
|
||||
before := make(map[string]int, len(was))
|
||||
for i := range was {
|
||||
before[was[i].id()] = i
|
||||
}
|
||||
seen := make(map[string]bool, len(now))
|
||||
name := namer(was, now)
|
||||
|
||||
var said []string
|
||||
for i := range now {
|
||||
r := &now[i]
|
||||
seen[r.id()] = true
|
||||
at, had := before[r.id()]
|
||||
if !had {
|
||||
said = append(said, name(*r)+" is new")
|
||||
continue
|
||||
}
|
||||
said = append(said, changesOf(&was[at], r, name)...)
|
||||
}
|
||||
for i := range was {
|
||||
if !seen[was[i].id()] {
|
||||
said = append(said, name(was[i])+" is gone")
|
||||
}
|
||||
}
|
||||
|
||||
if len(said) == 0 {
|
||||
return ""
|
||||
}
|
||||
if len(said) > 4 {
|
||||
return strings.Join(said[:4], " · ") + SF(" · and %d more", len(said)-4)
|
||||
}
|
||||
return strings.Join(said, " · ")
|
||||
}
|
||||
|
||||
// namer says how to call a machine on that line. Its name, ordinarily — but a
|
||||
// name is not what makes a machine that machine, and two vCenters may each hold
|
||||
// a "web01" (see vmRow.id). Where they do, the server goes in front, and only
|
||||
// there: "v309 web01 off" is the truth and "web01 off" is a coin toss, while
|
||||
// putting the server in front of every name would spend the width on the
|
||||
// ordinary case to pay for the rare one.
|
||||
//
|
||||
// The table underneath has a column for this, which is why it is worth so
|
||||
// little width up here and so much certainty.
|
||||
func namer(was, now []vmRow) func(vmRow) string {
|
||||
// Both sweeps, because the lines that say a machine has gone are built out
|
||||
// of the old one: deciding ambiguity from the new rows alone left "web01 is
|
||||
// gone" unqualified in exactly the case where one of two web01s went.
|
||||
//
|
||||
// Counted by identity rather than by server, so two machines of one name on
|
||||
// one vCenter — which vSphere allows, in different folders — are ambiguous
|
||||
// too. The server in front does not separate those two; it does say which
|
||||
// server to go and look on, which is more than the bare name does.
|
||||
ids := make(map[string]map[string]bool)
|
||||
for _, rows := range [][]vmRow{was, now} {
|
||||
for i := range rows {
|
||||
n := rows[i].name
|
||||
if ids[n] == nil {
|
||||
ids[n] = make(map[string]bool, 1)
|
||||
}
|
||||
ids[n][rows[i].id()] = true
|
||||
}
|
||||
}
|
||||
return func(r vmRow) string {
|
||||
if len(ids[r.name]) > 1 {
|
||||
return r.vc.Name + " " + r.name
|
||||
}
|
||||
return r.name
|
||||
}
|
||||
}
|
||||
|
||||
// changesOf is what happened to one machine. Only things somebody would want to
|
||||
// be told: the load moving is what the trend column is for, and a line that
|
||||
// reported it would never say anything else.
|
||||
func changesOf(old, now *vmRow, name func(vmRow) string) []string {
|
||||
var said []string
|
||||
|
||||
who := name(*now)
|
||||
|
||||
if old.power() != now.power() {
|
||||
said = append(said, who+" "+now.powerShort())
|
||||
}
|
||||
if d := len(now.snaps) - len(old.snaps); d != 0 {
|
||||
said = append(said, SF("%s %+d snapshot", who, d))
|
||||
}
|
||||
switch {
|
||||
case old.task == nil && now.task != nil:
|
||||
said = append(said, who+" "+now.task.what)
|
||||
case old.task != nil && now.task == nil:
|
||||
said = append(said, who+" "+old.task.what+" done")
|
||||
}
|
||||
// What vCenter is complaining about, by the count: the reasons themselves
|
||||
// are a column away (^w) and several of them at once would fill this line
|
||||
// on their own.
|
||||
if o, n := old.issues(), now.issues(); len(o) != len(n) {
|
||||
switch {
|
||||
case len(n) == 0:
|
||||
said = append(said, who+" is clear")
|
||||
case len(n) > len(o):
|
||||
said = append(said, who+": "+n[0])
|
||||
}
|
||||
}
|
||||
return said
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ the trend column
|
||||
|
||||
// sample adds this sweep's load to what is remembered of each machine and draws
|
||||
// it onto the row. Machines that have gone are forgotten here, which is the one
|
||||
// place that can: a map of every machine ever seen would grow all day.
|
||||
func (b *browser) sample() {
|
||||
if b.hist == nil {
|
||||
b.hist = make(map[string][]float64, len(b.rows))
|
||||
}
|
||||
seen := make(map[string]bool, len(b.rows))
|
||||
|
||||
for i := range b.rows {
|
||||
r := &b.rows[i]
|
||||
id := r.id()
|
||||
seen[id] = true
|
||||
|
||||
// A machine that is not running has no load rather than a load of zero
|
||||
// (cpuLoad says which), and a zero sampled off a stopped machine would
|
||||
// draw a floor that never happened.
|
||||
//
|
||||
// What it had before goes with it. Keeping it left a machine that was
|
||||
// switched off ten minutes ago showing a busy history beside a CPU%
|
||||
// of "-": a shape that was true once, next to a figure saying there is
|
||||
// nothing to be true about.
|
||||
if pct, ok := r.cpuLoad(); ok {
|
||||
h := append(b.hist[id], pct)
|
||||
if len(h) > trendLen {
|
||||
h = h[len(h)-trendLen:]
|
||||
}
|
||||
b.hist[id] = h
|
||||
} else {
|
||||
delete(b.hist, id)
|
||||
}
|
||||
r.trend = sparkline(b.hist[id])
|
||||
}
|
||||
|
||||
for id := range b.hist {
|
||||
if !seen[id] {
|
||||
delete(b.hist, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sparkBlocks are eight levels in one character each, which is what makes a
|
||||
// history fit in a column.
|
||||
var sparkBlocks = []rune("▁▂▃▄▅▆▇█")
|
||||
|
||||
// sparkline draws percentages as one piece of text.
|
||||
//
|
||||
// The scale is fixed at 0 to 100 and not fitted to the samples. A line that
|
||||
// scales itself to what it holds makes a machine idling between 1 and 2 per
|
||||
// cent look exactly like one swinging between 40 and 80 — the shape would be
|
||||
// the news and the size would be invisible, which is the opposite of what a
|
||||
// glance down a column is for.
|
||||
//
|
||||
// One sample draws nothing: a single block is not a trend, and a column that
|
||||
// appears full of them the moment gvm starts would be eight characters of width
|
||||
// spent on saying "hello".
|
||||
func sparkline(samples []float64) string {
|
||||
if len(samples) < 2 {
|
||||
return ""
|
||||
}
|
||||
out := make([]rune, 0, len(samples))
|
||||
for _, pct := range samples {
|
||||
i := int(pct / 100 * float64(len(sparkBlocks)))
|
||||
out = append(out, sparkBlocks[min(max(i, 0), len(sparkBlocks)-1)])
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// trendColumn is the third column that is not always there, for the same reason
|
||||
// the other two are not (see taskColumn): it holds something that is only
|
||||
// sometimes true — a machine has a history once it has been swept twice — and a
|
||||
// column of eight dashes down two hundred rows is width spent on nothing.
|
||||
//
|
||||
// It goes early on a narrow terminal — everything else in the table is a fact
|
||||
// about a machine and this is a shape — but not first: the guest's operating
|
||||
// system is the least read column there is, and eight characters of where a
|
||||
// machine has just been are worth more than "Ubuntu Linux (64-bit)".
|
||||
var trendColumn = browseColumn{header: "CPU~", width: trendLen, expendable: 2,
|
||||
cell: func(r vmRow) string { return r.trend },
|
||||
color: func(r vmRow) string { return loadColor(r.cpuLoad()) }}
|
||||
|
||||
// anyTrend reports whether anything has a history to draw yet.
|
||||
func anyTrend(rows []vmRow) bool {
|
||||
for _, r := range rows {
|
||||
if r.trend != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
+487
@@ -0,0 +1,487 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/vmware/govmomi/vim25/types"
|
||||
)
|
||||
|
||||
// The trend is drawn on a fixed scale, 0 to 100, and not fitted to what it
|
||||
// holds. A line that scales itself makes a machine idling between 1 and 2 per
|
||||
// cent look exactly like one swinging between 40 and 80 — the shape would be
|
||||
// the news and the size invisible, which is the opposite of what a glance down
|
||||
// a column is for.
|
||||
func TestSparklineKeepsItsScale(t *testing.T) {
|
||||
idle := sparkline([]float64{1, 2, 1, 2})
|
||||
busy := sparkline([]float64{40, 80, 40, 80})
|
||||
if idle == busy {
|
||||
t.Errorf("idling and swinging drew the same line: %q", idle)
|
||||
}
|
||||
if strings.Trim(idle, "▁") != "" {
|
||||
t.Errorf("a machine at 1-2%% is not drawn at the floor: %q", idle)
|
||||
}
|
||||
|
||||
// The ends of the scale, and nothing outside it: a percentage over 100 is a
|
||||
// figure vCenter has been known to hand out, and it must not index past the
|
||||
// blocks.
|
||||
for _, c := range []struct {
|
||||
pct float64
|
||||
want rune
|
||||
}{{0, '▁'}, {50, '▅'}, {100, '█'}, {140, '█'}, {-5, '▁'}} {
|
||||
got := sparkline([]float64{c.pct, c.pct})
|
||||
if []rune(got)[0] != c.want {
|
||||
t.Errorf("%.0f%% drew %q, want %q", c.pct, got, string(c.want))
|
||||
}
|
||||
}
|
||||
|
||||
// One sample is not a trend, and a column full of single blocks the moment
|
||||
// gvm starts would be width spent on saying hello.
|
||||
if got := sparkline([]float64{50}); got != "" {
|
||||
t.Errorf("one sample drew %q", got)
|
||||
}
|
||||
if got := sparkline(nil); got != "" {
|
||||
t.Errorf("no samples drew %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The history is kept per machine, bounded, and forgotten when the machine goes
|
||||
// — a map of every machine ever seen would grow all day.
|
||||
func TestSampleRemembersAndForgets(t *testing.T) {
|
||||
b := testBrowser("web01", "db01")
|
||||
b.applySort()
|
||||
|
||||
// web01 is the running one testBrowser makes, so it is the one with a load.
|
||||
for i := 0; i < trendLen+5; i++ {
|
||||
b.sample()
|
||||
}
|
||||
var id string
|
||||
for _, r := range b.rows {
|
||||
if r.running() {
|
||||
id = r.id()
|
||||
}
|
||||
}
|
||||
if id == "" {
|
||||
t.Fatal("no running machine to sample")
|
||||
}
|
||||
if got := len(b.hist[id]); got != trendLen {
|
||||
t.Errorf("the history holds %d samples, want it bounded at %d", got, trendLen)
|
||||
}
|
||||
if !anyTrend(b.rows) {
|
||||
t.Error("nothing was drawn after a dozen sweeps")
|
||||
}
|
||||
|
||||
// A machine that is not running has no load rather than a load of zero, so
|
||||
// nothing is remembered of it: a sampled zero would draw a floor that never
|
||||
// happened.
|
||||
for _, r := range b.rows {
|
||||
if !r.running() && len(b.hist[r.id()]) != 0 {
|
||||
t.Errorf("%s is off and has %d samples", r.name, len(b.hist[r.id()]))
|
||||
}
|
||||
}
|
||||
|
||||
// And the machine going takes its history with it.
|
||||
b.rows = b.rows[:0]
|
||||
b.sample()
|
||||
if len(b.hist) != 0 {
|
||||
t.Errorf("%d histories outlived their machines", len(b.hist))
|
||||
}
|
||||
}
|
||||
|
||||
// The trend column is there only once there is something in it, the way the
|
||||
// task and reason columns are: eight dashes down two hundred rows would be
|
||||
// width spent on nothing.
|
||||
func TestTheTrendColumnComesWithTheHistory(t *testing.T) {
|
||||
b := testBrowser("web01", "db01")
|
||||
b.applySort()
|
||||
|
||||
has := func() bool {
|
||||
for _, c := range b.columns() {
|
||||
if c.header == "CPU~" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
if has() {
|
||||
t.Error("the trend column is there before anything was sampled")
|
||||
}
|
||||
b.sample()
|
||||
if has() {
|
||||
t.Error("the trend column is there after one sweep, which is not a trend")
|
||||
}
|
||||
b.sample()
|
||||
if !has() {
|
||||
t.Error("the trend column is missing after two sweeps")
|
||||
}
|
||||
|
||||
// It is the first thing a narrow terminal gives up: everything else in the
|
||||
// table is a fact about a machine, and this is a shape.
|
||||
wide := fitColumnsOf(b.columns(), 200)
|
||||
if wide[len(wide)-1].header == "CPU~" && len(wide) < 2 {
|
||||
t.Fatal("nothing to compare")
|
||||
}
|
||||
narrow := fitColumnsOf(b.columns(), 100)
|
||||
for _, c := range narrow {
|
||||
if c.header == "CPU~" {
|
||||
t.Error("a hundred columns kept the trend and gave up facts for it")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// What changed between two sweeps is the one thing no column can hold. Each
|
||||
// kind of change has to be named, and the line has to stay short enough to read
|
||||
// at a glance.
|
||||
func TestChangesBetweenSweeps(t *testing.T) {
|
||||
on := func(name string) vmRow {
|
||||
r := testRow(name, true, "10.0.0.5")
|
||||
r.ref = types.ManagedObjectReference{Type: "VirtualMachine", Value: name}
|
||||
return r
|
||||
}
|
||||
|
||||
// Nothing moved.
|
||||
web := on("web01")
|
||||
if got := changesBetween([]vmRow{web}, []vmRow{web}); got != "" {
|
||||
t.Errorf("a sweep with nothing in it said %q", got)
|
||||
}
|
||||
|
||||
// Powered off behind gvm's back.
|
||||
off := web
|
||||
off.vm.Summary.Runtime.PowerState = types.VirtualMachinePowerStatePoweredOff
|
||||
if got := changesBetween([]vmRow{web}, []vmRow{off}); !strings.Contains(got, "web01 off") {
|
||||
t.Errorf("a machine that stopped said %q", got)
|
||||
}
|
||||
|
||||
// A snapshot appearing and one going.
|
||||
snapped := web
|
||||
snapped.snaps = []snapEntry{{name: "s1"}}
|
||||
if got := changesBetween([]vmRow{web}, []vmRow{snapped}); !strings.Contains(got, "+1 snapshot") {
|
||||
t.Errorf("a new snapshot said %q", got)
|
||||
}
|
||||
if got := changesBetween([]vmRow{snapped}, []vmRow{web}); !strings.Contains(got, "-1 snapshot") {
|
||||
t.Errorf("a removed snapshot said %q", got)
|
||||
}
|
||||
|
||||
// A task starting and finishing.
|
||||
busy := web
|
||||
busy.task = &runningTask{what: "clone", progress: 40}
|
||||
if got := changesBetween([]vmRow{web}, []vmRow{busy}); !strings.Contains(got, "web01 clone") {
|
||||
t.Errorf("a task starting said %q", got)
|
||||
}
|
||||
if got := changesBetween([]vmRow{busy}, []vmRow{web}); !strings.Contains(got, "clone done") {
|
||||
t.Errorf("a task finishing said %q", got)
|
||||
}
|
||||
|
||||
// Machines coming and going.
|
||||
if got := changesBetween([]vmRow{web}, []vmRow{web, on("db01")}); !strings.Contains(got, "db01 is new") {
|
||||
t.Errorf("a new machine said %q", got)
|
||||
}
|
||||
if got := changesBetween([]vmRow{web, on("db01")}, []vmRow{web}); !strings.Contains(got, "db01 is gone") {
|
||||
t.Errorf("a machine that went said %q", got)
|
||||
}
|
||||
|
||||
// And the line stays short: four things, then a count.
|
||||
var many []vmRow
|
||||
for _, n := range []string{"a", "b", "c", "d", "e", "f"} {
|
||||
many = append(many, on(n))
|
||||
}
|
||||
got := changesBetween(nil, many)
|
||||
if !strings.Contains(got, "and 2 more") {
|
||||
t.Errorf("six changes said %q, which does not end in a count", got)
|
||||
}
|
||||
if n := strings.Count(got, " · "); n > 4 {
|
||||
t.Errorf("the line runs to %d pieces: %q", n, got)
|
||||
}
|
||||
}
|
||||
|
||||
// A tick may not happen underneath a menu, a picker, a confirmation or
|
||||
// something half-typed: redrawing any of those from under a hand is worse than
|
||||
// being ten seconds out of date.
|
||||
func TestLiveHoldsStillForEveryScreenThatAsksSomething(t *testing.T) {
|
||||
b := testBrowser("web01")
|
||||
b.live = true
|
||||
if !b.liveReady() {
|
||||
t.Fatal("live mode does not tick on the plain list")
|
||||
}
|
||||
|
||||
for _, c := range []struct {
|
||||
what string
|
||||
set func()
|
||||
undo func()
|
||||
}{
|
||||
{"a menu", func() { b.menu = []menuItem{{key: 'n'}} }, func() { b.menu = nil }},
|
||||
{"a picker", func() { b.pick = &picker{} }, func() { b.pick = nil }},
|
||||
{"a confirmation", func() { b.confirm = &confirmation{} }, func() { b.confirm = nil }},
|
||||
{"an editor", func() { b.edit = &editor{} }, func() { b.edit = nil }},
|
||||
{"a question", func() { b.prompt = &prompt{} }, func() { b.prompt = nil }},
|
||||
} {
|
||||
c.set()
|
||||
if b.liveReady() {
|
||||
t.Errorf("live mode ticks with %s on screen", c.what)
|
||||
}
|
||||
c.undo()
|
||||
}
|
||||
|
||||
// The sheet is refreshed, though: watching a machine's memory is a reason
|
||||
// to have it open.
|
||||
b.detail = []sheetLine{{label: "cpu", value: "4 vCPU"}}
|
||||
if !b.liveReady() {
|
||||
t.Error("live mode does not refresh an open sheet")
|
||||
}
|
||||
|
||||
// And off is off.
|
||||
b.live = false
|
||||
if b.liveReady() {
|
||||
t.Error("live mode ticks while it is switched off")
|
||||
}
|
||||
}
|
||||
|
||||
// It looks more often while vCenter is doing something: a clone's progress that
|
||||
// moves every ten seconds is a figure one waits for, and one that moves every
|
||||
// two is a thing one watches finish.
|
||||
func TestLiveLooksFasterWhileSomethingIsRunning(t *testing.T) {
|
||||
b := testBrowser("web01", "db01")
|
||||
if got := b.liveInterval(); got != liveEvery {
|
||||
t.Errorf("a quiet cluster is swept every %s, want %s", got, liveEvery)
|
||||
}
|
||||
b.rows[1].task = &runningTask{what: "clone", progress: 10}
|
||||
if got := b.liveInterval(); got != liveBusy {
|
||||
t.Errorf("a busy cluster is swept every %s, want %s", got, liveBusy)
|
||||
}
|
||||
}
|
||||
|
||||
// Two machines of one name are two machines, and the changed line has to say
|
||||
// which of them stopped. A name is not what makes a machine that machine —
|
||||
// vmRow.id carries the comment — and "web01 off" across three vCenters is a
|
||||
// coin toss. The server goes in front only where the name is ambiguous: paying
|
||||
// the width on every line for the rare case would be the wrong trade, and the
|
||||
// table underneath has a column for it.
|
||||
func TestTheChangedLineSaysWhichServerWhenItHasTo(t *testing.T) {
|
||||
twice := func(vc string) vmRow {
|
||||
r := testRow("web01", true, "10.0.0.5")
|
||||
r.vc = VCenter{Name: vc}
|
||||
r.ref = types.ManagedObjectReference{Type: "VirtualMachine", Value: "vm-" + vc}
|
||||
return r
|
||||
}
|
||||
stopped := func(r vmRow) vmRow {
|
||||
r.vm.Summary.Runtime.PowerState = types.VirtualMachinePowerStatePoweredOff
|
||||
return r
|
||||
}
|
||||
|
||||
a, c := twice("v308"), twice("v309")
|
||||
got := changesBetween([]vmRow{a, c}, []vmRow{a, stopped(c)})
|
||||
if !strings.Contains(got, "v309 web01 off") {
|
||||
t.Errorf("with a web01 on each of two servers it said %q", got)
|
||||
}
|
||||
|
||||
// And one of that name is not dressed up with a server it does not need.
|
||||
got = changesBetween([]vmRow{a}, []vmRow{stopped(a)})
|
||||
if got != "web01 off" {
|
||||
t.Errorf("an unambiguous machine said %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A tick may clear its own line and nothing else. A message somebody produced
|
||||
// by pressing a key is theirs: the address they just copied, or the reason a
|
||||
// change was refused, must not vanish because ten seconds passed with nothing
|
||||
// happening on the cluster.
|
||||
func TestAQuietTickClearsOnlyItsOwnLine(t *testing.T) {
|
||||
b := testBrowser("web01", "db01")
|
||||
b.applySort()
|
||||
|
||||
// What a tick that found something leaves behind, then a quiet one.
|
||||
b.setStatus(colInfo, "db01 off")
|
||||
b.saidLive = "db01 off"
|
||||
if b.status == b.saidLive {
|
||||
b.setStatus("", "")
|
||||
}
|
||||
if b.status != "" {
|
||||
t.Errorf("a quiet tick kept its own stale line: %q", b.status)
|
||||
}
|
||||
|
||||
// And somebody else's message, which it must leave alone.
|
||||
b.setStatus(colInfo, "copied its hostname web01.example to the clipboard (pbcopy)")
|
||||
b.saidLive = "db01 off"
|
||||
if b.status == b.saidLive {
|
||||
b.setStatus("", "")
|
||||
}
|
||||
if !strings.Contains(b.status, "copied") {
|
||||
t.Errorf("a quiet tick wiped a message it did not write: %q", b.status)
|
||||
}
|
||||
}
|
||||
|
||||
// A refresh that half failed still leaves the rows and the view describing the
|
||||
// same list. Returning early with the rows replaced and the view not rebuilt
|
||||
// showed every row as a different machine, put the cursor on one nobody was
|
||||
// looking at, and panicked outright as soon as the new list was shorter — in
|
||||
// renderList, which follows the view without asking.
|
||||
func TestAHalfFailedRefreshLeavesTheScreenConsistent(t *testing.T) {
|
||||
t.Setenv("COLUMNS", "100")
|
||||
t.Setenv("LINES", "20")
|
||||
|
||||
b := testBrowser("web01", "db01", "app07", "mail02")
|
||||
b.applySort()
|
||||
b.sel = len(b.view) - 1
|
||||
b.live = true
|
||||
|
||||
// No sessions: resweep fails outright, which is the harshest version of the
|
||||
// same path. The rows it could not re-read stay, and the screen still draws.
|
||||
b.liveTick()
|
||||
if len(b.view) != len(b.rows) {
|
||||
t.Errorf("the view describes %d rows of %d", len(b.view), len(b.rows))
|
||||
}
|
||||
for _, i := range b.view {
|
||||
if i < 0 || i >= len(b.rows) {
|
||||
t.Fatalf("the view points at row %d of %d", i, len(b.rows))
|
||||
}
|
||||
}
|
||||
_ = stripEscapes(renderToPipe(t, b, b.renderList)) // panicked before the fix
|
||||
|
||||
// And the tick is scheduled from after the sweep, not from before it: a
|
||||
// sweep slower than the interval would otherwise leave no idle time at all
|
||||
// and race every keystroke against an expired timer.
|
||||
if d := b.liveIn(); d <= 0 {
|
||||
t.Errorf("the next tick is already due (%s) the moment this one finished", d)
|
||||
}
|
||||
if b.liveGap == 0 {
|
||||
t.Error("the title has no interval to show")
|
||||
}
|
||||
}
|
||||
|
||||
// A machine that stops stops drawing. Keeping its history left a busy shape
|
||||
// beside a CPU% of "-": true once, and next to a figure saying there is
|
||||
// nothing to be true about.
|
||||
func TestTheTrendGoesWhenTheMachineStops(t *testing.T) {
|
||||
b := testBrowser("web01", "db01")
|
||||
b.applySort()
|
||||
|
||||
var at int
|
||||
for i, r := range b.rows {
|
||||
if r.running() {
|
||||
at = i
|
||||
}
|
||||
}
|
||||
b.sample()
|
||||
b.sample()
|
||||
if b.rows[at].trend == "" {
|
||||
t.Fatal("a running machine drew nothing after two sweeps")
|
||||
}
|
||||
|
||||
b.rows[at].vm.Summary.Runtime.PowerState = types.VirtualMachinePowerStatePoweredOff
|
||||
b.sample()
|
||||
if got := b.rows[at].trend; got != "" {
|
||||
t.Errorf("a machine that was switched off still draws %q", got)
|
||||
}
|
||||
if n := len(b.hist[b.rows[at].id()]); n != 0 {
|
||||
t.Errorf("%d samples outlived the machine being switched off", n)
|
||||
}
|
||||
}
|
||||
|
||||
// The lines that say a machine has gone are built from the old sweep, so
|
||||
// ambiguity has to be judged over both. Deciding it from the new rows alone
|
||||
// left "web01 is gone" unqualified in exactly the case the function exists for.
|
||||
func TestAmbiguityIsJudgedOverBothSweeps(t *testing.T) {
|
||||
at := func(vc string) vmRow {
|
||||
r := testRow("web01", true, "10.0.0.5")
|
||||
r.vc = VCenter{Name: vc}
|
||||
r.ref = types.ManagedObjectReference{Type: "VirtualMachine", Value: "vm-" + vc}
|
||||
return r
|
||||
}
|
||||
a, c := at("v308"), at("v309")
|
||||
|
||||
got := changesBetween([]vmRow{a, c}, []vmRow{a})
|
||||
if !strings.Contains(got, "v309 web01 is gone") {
|
||||
t.Errorf("one of two web01s was deleted and it said %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A refresh that reached nothing at all leaves the title alone, and should:
|
||||
// the rows on screen are still the ones that server gave, so a title naming it
|
||||
// is describing them correctly. It is the *partial* failure that must move the
|
||||
// title, and that one needs a server to answer — see TestSimLiveRefresh.
|
||||
func TestARefreshThatReachedNothingKeepsTheRowsAndTheirTitle(t *testing.T) {
|
||||
b := testBrowser("web01")
|
||||
b.applySort()
|
||||
b.answered = []string{"v308"}
|
||||
|
||||
b.liveTick()
|
||||
if len(b.rows) != 1 || len(b.answered) != 1 {
|
||||
t.Errorf("a failed refresh left %d rows titled %v", len(b.rows), b.answered)
|
||||
}
|
||||
if !strings.Contains(b.status, "live:") {
|
||||
t.Errorf("it did not say the refresh failed: %q", b.status)
|
||||
}
|
||||
}
|
||||
|
||||
// The keystroke reader has to be able to stop waiting, without that breaking
|
||||
// the one thing it must never break: a control sequence arrives in one burst,
|
||||
// and a deadline expiring in the middle of "ESC [ A" would turn one arrow key
|
||||
// into an Esc and a stray letter in the filter.
|
||||
func TestNextWithinStopsWaitingButNotMidSequence(t *testing.T) {
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer r.Close()
|
||||
kr := newKeyReader(r)
|
||||
|
||||
// Nothing to read: it gives up and says so.
|
||||
start := time.Now()
|
||||
if _, ok := kr.nextWithin(50 * time.Millisecond); ok {
|
||||
t.Error("a key was reported with nothing sent")
|
||||
}
|
||||
if waited := time.Since(start); waited < 40*time.Millisecond {
|
||||
t.Errorf("it gave up after %s, before it was asked to", waited)
|
||||
}
|
||||
|
||||
// A key that is there is decoded as usual.
|
||||
w.WriteString("q")
|
||||
k, ok := kr.nextWithin(time.Second)
|
||||
if !ok || k.special != keyRune || k.r != 'q' {
|
||||
t.Errorf("nextWithin gave %+v, %v", k, ok)
|
||||
}
|
||||
|
||||
// An arrow key survives it whole, with what follows still intact.
|
||||
w.WriteString("\x1b[Ax")
|
||||
k, ok = kr.nextWithin(time.Second)
|
||||
if !ok || k.special != keyUp {
|
||||
t.Errorf("the arrow came back as %+v, %v", k, ok)
|
||||
}
|
||||
if next := kr.next(); next.special != keyRune || next.r != 'x' {
|
||||
t.Errorf("what followed the arrow came back as %+v", next)
|
||||
}
|
||||
|
||||
// ^l is the toggle, and nothing else had it.
|
||||
w.WriteString("\x0c")
|
||||
if k, ok = kr.nextWithin(time.Second); !ok || k.special != keyCtrlL {
|
||||
t.Errorf("^l came back as %+v, %v", k, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// Turning it on asks at once rather than in ten seconds' time, and both states
|
||||
// say which they are: a list that moves on its own with nothing to explain it
|
||||
// reads as a fault.
|
||||
func TestToggleLiveSaysSoAndLooksNow(t *testing.T) {
|
||||
b := testBrowser("web01")
|
||||
|
||||
b.toggleLive()
|
||||
if !b.live {
|
||||
t.Fatal("^l did not turn live mode on")
|
||||
}
|
||||
if !strings.Contains(b.status, "live on") {
|
||||
t.Errorf("turning it on said %q", b.status)
|
||||
}
|
||||
if d := b.liveIn(); d > time.Second {
|
||||
t.Errorf("the first refresh is %s away, want it now", d)
|
||||
}
|
||||
|
||||
b.toggleLive()
|
||||
if b.live {
|
||||
t.Fatal("^l did not turn live mode off")
|
||||
}
|
||||
if !strings.Contains(b.status, "live off") {
|
||||
t.Errorf("turning it off said %q", b.status)
|
||||
}
|
||||
}
|
||||
+226
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -1218,6 +1219,231 @@ func TestSimEventsWithoutAConnection(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The estate screen against a server that answers: the hosts come back grouped
|
||||
// under their clusters, what they carry is added up from the rows the list
|
||||
// already holds, and Enter narrows the list to the host under the cursor.
|
||||
func TestSimEstateScreen(t *testing.T) {
|
||||
quiet(t)
|
||||
t.Setenv("COLUMNS", "150")
|
||||
t.Setenv("LINES", "20")
|
||||
vc := simVCenter(t)
|
||||
|
||||
found, err := gatherVMs([]VCenter{vc})
|
||||
defer closeSessions(found.sessions)
|
||||
if err != nil {
|
||||
t.Fatalf("gatherVMs: %v", err)
|
||||
}
|
||||
b := &browser{targets: []VCenter{vc}, rows: found.rows, sessions: found.sessions,
|
||||
answered: found.answered}
|
||||
b.applySort()
|
||||
|
||||
b.openEstate()
|
||||
if b.estate == nil {
|
||||
t.Fatalf("the estate screen did not open: %q", b.status)
|
||||
}
|
||||
e := b.estate
|
||||
|
||||
// Every host of the simulated inventory, under a heading each, and the
|
||||
// cursor on a host rather than on a heading.
|
||||
var hosts, headings int
|
||||
for _, r := range e.rows {
|
||||
if r.isHeading() {
|
||||
headings++
|
||||
continue
|
||||
}
|
||||
hosts++
|
||||
}
|
||||
if hosts == 0 || headings == 0 {
|
||||
t.Fatalf("%d hosts under %d headings", hosts, headings)
|
||||
}
|
||||
if e.rows[e.sel].isHeading() {
|
||||
t.Error("the cursor started on a heading")
|
||||
}
|
||||
|
||||
// The machines are charged to the hosts they are on, and the total matches
|
||||
// what the list holds — nothing counted twice, nothing dropped.
|
||||
charged := 0
|
||||
for _, r := range e.rows {
|
||||
if !r.isHeading() {
|
||||
charged += r.vms
|
||||
}
|
||||
}
|
||||
placed := 0
|
||||
for _, r := range b.rows {
|
||||
if r.vm.Summary.Runtime.Host != nil {
|
||||
placed++
|
||||
}
|
||||
}
|
||||
if charged != placed {
|
||||
t.Errorf("%d machines charged to hosts, %d placed on one", charged, placed)
|
||||
}
|
||||
|
||||
// A heading is the sum of the hosts under it.
|
||||
for i, r := range e.rows {
|
||||
if !r.isHeading() {
|
||||
continue
|
||||
}
|
||||
sum := 0
|
||||
for _, h := range e.rows[i+1:] {
|
||||
if h.isHeading() {
|
||||
break
|
||||
}
|
||||
sum += h.vms
|
||||
}
|
||||
if r.vms != sum {
|
||||
t.Errorf("%q says %d machines, its hosts hold %d", r.heading, r.vms, sum)
|
||||
}
|
||||
}
|
||||
|
||||
// It draws, with the figures on it.
|
||||
frame := stripEscapes(renderToPipe(t, b, b.renderEstate))
|
||||
for _, want := range []string{"Estate", "CLUSTER / HOST", "MEM ALLOC", "DC0_C0"} {
|
||||
if !strings.Contains(frame, want) {
|
||||
t.Errorf("the screen does not show %q:\n%s", want, frame)
|
||||
}
|
||||
}
|
||||
|
||||
// And Enter is the whole point of arrowing to a host: the machine list,
|
||||
// narrowed to it.
|
||||
//
|
||||
// Arrowed to one that carries something, deliberately: the simulator does
|
||||
// not spread its machines evenly and leaves some hosts empty, so starting
|
||||
// from wherever the cursor happens to open would be a test that passes or
|
||||
// fails by placement rather than by behaviour. (It did: one run in three.)
|
||||
for i, r := range e.rows {
|
||||
if !r.isHeading() && r.vms > 0 {
|
||||
e.sel = i
|
||||
break
|
||||
}
|
||||
}
|
||||
host := e.rows[e.sel].host
|
||||
if e.rows[e.sel].vms == 0 {
|
||||
t.Fatal("no host in the simulated inventory carries a machine")
|
||||
}
|
||||
b.showHost()
|
||||
if b.estate != nil {
|
||||
t.Error("the estate screen stayed open after Enter")
|
||||
}
|
||||
if b.filter != host {
|
||||
t.Errorf("the filter is %q, want the host %q", b.filter, host)
|
||||
}
|
||||
if len(b.view) == 0 {
|
||||
t.Errorf("filtering to %s left no machines, though it carries some", host)
|
||||
}
|
||||
for _, i := range b.view {
|
||||
if b.rows[i].host != host {
|
||||
t.Errorf("%s is on %s, not on %s", b.rows[i].name, b.rows[i].host, host)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Live mode against a server that answers: the refresh goes over the connection
|
||||
// that is already open, it notices what happened behind gvm's back, and it says
|
||||
// what changed.
|
||||
func TestSimLiveRefreshUsesTheOpenSession(t *testing.T) {
|
||||
quiet(t)
|
||||
vc := simVCenter(t)
|
||||
|
||||
found, err := gatherVMs([]VCenter{vc})
|
||||
rows, sessions := found.rows, found.sessions
|
||||
defer closeSessions(sessions)
|
||||
if err != nil {
|
||||
t.Fatalf("gatherVMs: %v", err)
|
||||
}
|
||||
|
||||
b := &browser{targets: []VCenter{vc}, rows: rows, sessions: sessions,
|
||||
answered: found.answered}
|
||||
b.applySort()
|
||||
b.sample()
|
||||
|
||||
// The refresh reads the same machines back, and does not touch the
|
||||
// sessions: a reload replaces them, this must not.
|
||||
before := len(b.rows)
|
||||
was := b.rows
|
||||
if err := b.resweep(); err != nil {
|
||||
t.Fatalf("resweep: %v", err)
|
||||
}
|
||||
if len(b.rows) != before {
|
||||
t.Errorf("the refresh came back with %d of %d machines", len(b.rows), before)
|
||||
}
|
||||
if len(b.sessions) != len(sessions) || b.sessions[0] != sessions[0] {
|
||||
t.Error("the refresh replaced the session it was supposed to reuse")
|
||||
}
|
||||
if got := changesBetween(was, b.rows); got != "" {
|
||||
t.Errorf("a refresh with nothing happening in between said %q", got)
|
||||
}
|
||||
|
||||
// Something happens that gvm did not do. A refresh has to notice, and say
|
||||
// so — this is the whole reason to leave the list open.
|
||||
var victim vmRow
|
||||
for _, r := range b.rows {
|
||||
if r.running() {
|
||||
victim = r
|
||||
break
|
||||
}
|
||||
}
|
||||
if victim.name == "" {
|
||||
t.Fatal("no running machine to stop")
|
||||
}
|
||||
if _, err := runPower(victim.sess, victim, opPowerOff); err != nil {
|
||||
t.Fatalf("cannot stop %s: %v", victim.name, err)
|
||||
}
|
||||
|
||||
was = b.rows
|
||||
if err := b.resweep(); err != nil {
|
||||
t.Fatalf("resweep after the change: %v", err)
|
||||
}
|
||||
said := changesBetween(was, b.rows)
|
||||
if !strings.Contains(said, victim.name+" off") {
|
||||
t.Errorf("the refresh did not report the machine stopping: %q", said)
|
||||
}
|
||||
for _, r := range b.rows {
|
||||
if r.name == victim.name && r.running() {
|
||||
t.Error("the refreshed row still says the machine is running")
|
||||
}
|
||||
}
|
||||
|
||||
// One server of two failing moves the title as well as the status line: the
|
||||
// rows of the one that did not answer are kept, and it is named as lost
|
||||
// rather than left in the list of servers holding machines. A title reading
|
||||
// "on v308, v309" above a status line saying v309 did not answer is two
|
||||
// lines contradicting each other, and the next keystroke clears the true
|
||||
// one.
|
||||
dead, stop := context.WithCancel(context.Background())
|
||||
stop()
|
||||
b.sessions = append(b.sessions, &session{vc: VCenter{Name: "v309"}, ctx: dead,
|
||||
client: sessions[0].client, cancel: stop})
|
||||
b.answered, b.lost = []string{vc.Name, "v309"}, nil
|
||||
|
||||
if err := b.resweep(); err == nil {
|
||||
t.Error("a refresh over a dead session reported no trouble")
|
||||
}
|
||||
if contains(b.answered, "v309") {
|
||||
t.Errorf("v309 did not answer and is still named as holding machines: %v", b.answered)
|
||||
}
|
||||
if !contains(b.lost, "v309") {
|
||||
t.Errorf("v309 is not named as lost: %v", b.lost)
|
||||
}
|
||||
if !contains(b.answered, vc.Name) {
|
||||
t.Errorf("%s answered and is not named: %v", vc.Name, b.answered)
|
||||
}
|
||||
b.sessions = b.sessions[:len(b.sessions)-1]
|
||||
|
||||
// And it really is the open session it reads over: with that closed, the
|
||||
// refresh fails instead of quietly logging in again. Three logins a minute
|
||||
// is what live mode exists not to do.
|
||||
closeSessions(sessions)
|
||||
if err := b.resweep(); err == nil {
|
||||
t.Error("the refresh worked with every session closed, so it logged in again")
|
||||
}
|
||||
// The rows of a server that stopped answering are kept rather than dropped:
|
||||
// an empty list because a vCenter is restarting would be worse than one
|
||||
// that is a minute old.
|
||||
if len(b.rows) != before {
|
||||
t.Errorf("a failed refresh left %d of %d machines on screen", len(b.rows), before)
|
||||
}
|
||||
}
|
||||
|
||||
// Changing what a machine has, against a server that answers: the refusal on a
|
||||
// running machine, and the change itself once it is off. The numbers are read
|
||||
// back from the vCenter afterwards rather than assumed — a reconfigure that is
|
||||
|
||||
@@ -83,6 +83,8 @@ const (
|
||||
keyShiftTab
|
||||
keyEnter
|
||||
keyCtrlA
|
||||
keyCtrlE
|
||||
keyCtrlL
|
||||
keyCtrlO
|
||||
keyCtrlR
|
||||
keyCtrlS
|
||||
@@ -133,11 +135,42 @@ func (kr *keyReader) next() key {
|
||||
if !ok {
|
||||
return key{special: keyCtrlC} // input closed - treat like cancel
|
||||
}
|
||||
return kr.decode(b)
|
||||
}
|
||||
|
||||
// nextWithin is next() with a limit on how long it waits — for live mode, which
|
||||
// has to be able to stop waiting and re-read the list.
|
||||
//
|
||||
// The limit is on the *first* byte only, which is why it is here and not around
|
||||
// next() as a whole: a control sequence arrives in one burst, and a deadline
|
||||
// that could expire in the middle of "ESC [ A" would turn one arrow key into an
|
||||
// Esc and a stray letter in the filter.
|
||||
func (kr *keyReader) nextWithin(d time.Duration) (key, bool) {
|
||||
select {
|
||||
case b, ok := <-kr.ch:
|
||||
if !ok {
|
||||
return key{special: keyCtrlC}, true
|
||||
}
|
||||
return kr.decode(b), true
|
||||
case <-time.After(d):
|
||||
return key{}, false
|
||||
}
|
||||
}
|
||||
|
||||
// decode turns one byte, and whatever else belongs with it, into a key.
|
||||
func (kr *keyReader) decode(b byte) key {
|
||||
switch b {
|
||||
case 0x03:
|
||||
return key{special: keyCtrlC}
|
||||
case 0x01:
|
||||
return key{special: keyCtrlA}
|
||||
case 0x05:
|
||||
return key{special: keyCtrlE}
|
||||
// ^l, which in a shell redraws the screen. Nothing is lost by taking it:
|
||||
// gvm redraws the whole screen on every keystroke anyway, so there is
|
||||
// nothing here for a redraw key to fix.
|
||||
case 0x0c:
|
||||
return key{special: keyCtrlL}
|
||||
case 0x0f:
|
||||
return key{special: keyCtrlO}
|
||||
case 0x12:
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
1.1.10
|
||||
1.2.0
|
||||
|
||||
Reference in New Issue
Block a user