Files
2026-09-08 17:15:05 +02:00

375 lines
12 KiB
Go

// sort.go — the order the table is in.
//
// One key opens a legend on the status line and the next key picks the order, so
// the list stays on screen while it rearranges itself in front of you. Ordinary
// letters cannot be used on their own: the list's filter swallows those.
//
// Every order has a natural direction, because that is what asking for it means.
// Sorting by name means a to z; sorting by processor load means the busiest
// first, and having to reverse it every time would be a nuisance dressed up as
// consistency. `r` reverses whatever is current.
package main
import (
"sort"
"strings"
)
// sortOrder is one way of arranging the table.
type sortOrder struct {
key rune // the letter that picks it
name string // what it is called, in the title and the legend
natural bool // its own direction: true means largest or busiest first
cmp func(a, b vmRow) int
// legendBreak starts a new line of the legend at this entry. Thirteen
// orders do not fit across eighty columns, and a legend that ran off the
// edge would hide the very choices it exists to offer — so it is two lines,
// broken where the meaning breaks rather than wherever the width runs out.
legendBreak bool
}
// sortOrders in the order the legend lists them, which is two groups: first
// what a machine is doing and what it wants doing to it, then what it is made
// of and where it lives. The legend breaks between the two.
var sortOrders = []sortOrder{
{key: 'n', name: "name", cmp: func(a, b vmRow) int { return cmpText(a.name, b.name) }},
{key: 'p', name: "power", natural: true,
cmp: func(a, b vmRow) int { return cmpInt(powerRank(a), powerRank(b)) }},
{key: 'c', name: "cpu load", natural: true,
cmp: func(a, b vmRow) int { return cmpLoad(vmRow.cpuLoad, a, b) }},
{key: 'm', name: "memory in use", natural: true,
cmp: func(a, b vmRow) int { return cmpLoad(vmRow.memLoad, a, b) }},
// How many rollback points the machine is carrying, most first. Nought is a
// figure here and not a missing one — nothing to clean up is a fact about
// the machine — so a machine with none sorts where nought belongs, at the
// bottom going down and at the top coming back up.
//
// The key carries no mnemonic — every letter that does was taken — so it is
// simply one that is free and easy to reach. The name is what the command
// line takes: `--sort snapshots`, or `--sort snaps`.
{key: 'z', name: "snapshots", natural: true,
cmp: func(a, b vmRow) int { return cmpInt(a.snapCount(), b.snapCount()) }},
// By how long the machine has been dragging its oldest snapshot along, the
// oldest first — which is the order the housekeeping is done in. A machine
// with no snapshots has no age, and sorts to the bottom either way round.
{key: 'o', name: "snapshot age", natural: true,
cmp: func(a, b vmRow) int { return cmpLoad(vmRow.snapAge, a, b) }},
// By what is wrong with the machine, worst first: broken above wants-a-look
// above nothing to report, and within each the machine with the most to
// answer for first. Sorting the reasons as text would put "alarm" above
// "disks need consolidating" and mean nothing at all.
{key: 'w', name: "issues", natural: true,
cmp: func(a, b vmRow) int { return cmpIssues(a, b) }},
{key: 's', name: "memory size", natural: true, legendBreak: true,
cmp: func(a, b vmRow) int {
return cmpInt(int(a.vm.Summary.Config.MemorySizeMB), int(b.vm.Summary.Config.MemorySizeMB))
}},
{key: 'u', name: "processors", natural: true,
cmp: func(a, b vmRow) int {
return cmpInt(int(a.vm.Summary.Config.NumCpu), int(b.vm.Summary.Config.NumCpu))
}},
{key: 'v', name: "vcenter", cmp: func(a, b vmRow) int { return cmpText(a.vc.Name, b.vc.Name) }},
{key: 'h', name: "host", cmp: func(a, b vmRow) int { return cmpText(a.host, b.host) }},
{key: 'a', name: "address", cmp: func(a, b vmRow) int { return cmpAddress(a, b) }},
}
// sortReverse is the one legend entry that is not an order of its own.
const sortReverse = 'r'
// powerRank puts a running machine above a suspended one above a stopped one, so
// that "by power" means what an operator means by it.
func powerRank(r vmRow) int {
switch r.powerShort() {
case "on":
return 3
case "susp":
return 2
case "off":
return 1
}
return 0
}
func cmpText(a, b string) int { return strings.Compare(strings.ToLower(a), strings.ToLower(b)) }
func cmpInt(a, b int) int {
switch {
case a < b:
return -1
case a > b:
return 1
}
return 0
}
// A value that is not there is not a small value. Wherever one can be missing —
// a load figure on a stopped machine, an address on a machine whose guest is not
// talking — the machine belongs at the *bottom* of the list, and which end of the
// comparison that is depends on which way the order naturally runs.
//
// Load runs downwards by nature (busiest first), so an unknown load has to
// compare as the smallest. An address runs upwards (a to z), so a missing address
// has to compare as the largest. Same rule, opposite polarity; the two functions
// below say so where it can be checked.
// cmpLoad orders two machines by a load figure, unknown lowest — which puts it
// last under the busiest-first direction this order is asked for with.
func cmpLoad(load func(vmRow) (float64, bool), a, b vmRow) int {
x, xok := load(a)
y, yok := load(b)
switch {
case !xok && !yok:
return 0
case !xok:
return -1
case !yok:
return 1
case x < y:
return -1
case x > y:
return 1
}
return 0
}
// issueRank is how bad the machine's worst reason is: two for something broken,
// one for something that wants a look, nought for nothing to report.
func issueRank(r vmRow) int {
rank := 0
for _, i := range r.issueList() {
if i.bad {
return 2
}
rank = 1
}
return rank
}
// cmpIssues orders by that, and within it by how many reasons there are: a
// machine with a full disk *and* no Tools is worse off than one with only the
// disk. Nothing to report is nought and sorts where nought belongs, so the
// order run the other way up is the machines that are fine, by name.
func cmpIssues(a, b vmRow) int {
if n := cmpInt(issueRank(a), issueRank(b)); n != 0 {
return n
}
return cmpInt(len(a.issueList()), len(b.issueList()))
}
// cmpAddress orders by address, unknown highest — which puts it last under the
// a-to-z direction this order is asked for with.
func cmpAddress(a, b vmRow) int {
x, y := a.ip(), b.ip()
switch {
case x == "-" && y == "-":
return 0
case x == "-":
return 1
case y == "-":
return -1
}
return cmpText(x, y)
}
// ------------------------------------------------------------------ the sorting
func (b *browser) order() sortOrder { return sortOrders[b.sortBy] }
// applySort rearranges the rows and rebuilds what is on screen. The selection
// follows the machine it was on, which refilter already sees to.
//
// Machines that compare equal are left in name order, always ascending, whichever
// way the sort itself runs: a screen full of machines all at 0 % that reshuffles
// when the direction is flipped would look like the numbers had changed.
func (b *browser) applySort() {
sortRows(b.rows, b.sortBy, b.sortDesc)
b.refilter()
}
// sortRows is the sorting itself, without a browser: `gvm vm -l` orders the same
// rows the same way.
func sortRows(rows []vmRow, by int, desc bool) {
o := sortOrders[by]
sort.SliceStable(rows, func(i, j int) bool {
x, y := rows[i], rows[j]
n := o.cmp(x, y)
if desc {
n = -n
}
if n != 0 {
return n < 0
}
return cmpText(x.name, y.name) < 0
})
}
// findOrder resolves what was asked for on the command line — a letter or a name,
// as the legend spells them — to one of the orders. Empty means the first, which
// is by name.
func findOrder(s string) (int, error) {
if s == "" {
return 0, nil
}
for i, o := range sortOrders {
if s == string(o.key) || s == o.name || s == shortName(o.name) {
return i, nil
}
}
names := make([]string, len(sortOrders))
for i, o := range sortOrders {
names[i] = shortName(o.name)
}
return 0, errf("cannot sort by %q — try one of: %s", s, strings.Join(names, ", "))
}
// sortLabel is the order as the title shows it: which way, and by what.
func (b *browser) sortLabel() string {
arrow := "↑"
if b.sortDesc {
arrow = "↓"
}
return arrow + " " + b.order().name
}
// sortLegend is the choices, laid out for a terminal of this width: one line
// where they fit on one, and otherwise the two groups they fall into — what the
// machine is doing and wants doing to it, then what it is made of and where it
// lives.
//
// One line is the better answer and the usual one; two is what a narrow
// terminal gets instead of a legend that runs off the right-hand edge, hiding
// the very choices it exists to offer. Decided here, at render time, so a
// window that is dragged wider gets the one line back — the same way the table
// itself is fitted (fitColumns) and the sheet is wrapped.
//
// Terse either way: it shares the bottom of the screen with nothing but itself,
// and the title says what the order is anyway, so nobody who misses it is lost.
func sortLegend(cols int) []string {
const label = "sort: "
entries := make([]string, 0, len(sortOrders)+1)
for _, o := range sortOrders {
entries = append(entries, string(o.key)+"·"+shortName(o.name))
}
// Reverse is not an order of its own and goes at the end.
entries = append(entries, string(sortReverse)+"·reverse")
if one := label + strings.Join(entries, " "); len([]rune(one)) <= cols {
return []string{one}
}
// Two, broken where the meaning breaks. The second line is indented under
// the first one's entries rather than under its label, so the two read as
// one list and not as a sentence continued.
at := len(sortOrders)
for i, o := range sortOrders {
if o.legendBreak {
at = i
break
}
}
return []string{
label + strings.Join(entries[:at], " "),
SR(" ", len(label)) + strings.Join(entries[at:], " "),
}
}
// shortName is the legend's spelling: the title has room for the whole name, one
// line shared with the status does not.
func shortName(name string) string {
switch name {
case "power":
return "pwr" // as the column is headed, and it keeps the legend inside 80
case "cpu load":
return "cpu%"
case "memory in use":
return "mem%"
case "memory size":
return "size"
case "processors":
return "cpus"
case "vcenter":
return "vc"
case "address":
return "ip"
case "snapshot age":
return "old"
case "snapshots":
return "snaps"
case "issues":
return "why" // as the column is headed
}
return name
}
// sortPrompt puts the legend up and waits for one key. Anything that is not a
// choice leaves the order alone: this is the one prompt in the list that is
// reached by accident, and doing nothing is the right answer to a stray key.
func (b *browser) sortPrompt() {
// The colour every question at the foot of the screen has (colPrompt), and
// no yes/no hint: this is a menu and not a question answerable with y, but
// it is still gvm waiting for a key, and that is one thing wearing one
// colour. Where it takes two lines the second goes in place of the help
// line, which says nothing that applies while a menu is up.
cols, _ := termSize()
lines := sortLegend(cols)
b.prompt = &prompt{text: lines[0], col: colPrompt}
if len(lines) > 1 {
b.prompt.more = strings.Join(lines[1:], " ")
}
b.render()
k := b.keys.next()
b.prompt = nil
if k.special != keyRune {
return
}
if k.r == sortReverse {
b.sortDesc = !b.sortDesc
b.applySort()
b.setStatus(colInfo, "sorted "+b.sortLabel())
return
}
for i, o := range sortOrders {
if o.key == k.r {
b.sortBy, b.sortDesc = i, o.natural
b.applySort()
b.setStatus(colInfo, "sorted "+b.sortLabel())
return
}
}
}
// sortedColumn reports whether this column is the one the table is ordered by, so
// its heading can be lit up. Matching by name rather than by index keeps the two
// tables — the columns and the orders — free to be listed in different orders.
func (b *browser) sortedColumn(header string) bool {
switch b.order().name {
case "name":
return header == "NAME"
case "power":
return header == "PWR"
case "cpu load":
return header == "CPU%"
case "memory in use":
return header == "MEM%"
case "memory size":
return header == "MEM"
case "processors":
return header == "CPU"
case "vcenter":
return header == "VC"
case "host":
return header == "HOST"
case "address":
return header == "IP"
case "snapshot age", "snapshots":
return header == "SNAP"
case "issues":
return header == "WHY"
}
return false
}