The estate on one screen: ^e. estate.go answers what the machine list cannot — where is there still room — which is a question gvm itself started asking the day `size` could give a machine four more processors. Every host of every server that answered, grouped under its cluster, with two kinds of number beside it: * What is allocated: every vCPU and megabyte its machines have been promised, added up. It exceeds the host routinely and is meant to, so the ratio is the figure — 1.5x of memory is a decision somebody made and 8.0x is one somebody forgot. Blank where there is room to spare: a column of 0.4x down a screen of healthy hosts is noise where the point is to find the one that is over. * What is in use, as a bar and a percentage, from the host itself. 2.2x allocation at 41 % load is fine and the same host at 90 % is not, and no allocation figure tells those apart. An unknown load 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". Only running machines are charged to a host: a parked one has been promised nothing it is using, and counting it would make a host of parked machines look full when that is exactly what it is not. They stay in the ON/VM count. The allocations come from the rows the list already holds, so nothing is read twice, and they are matched to hosts by reference rather than by name — the lesson host.go carries a comment about. ⏎ on a host goes back to the list filtered to it, because the answer to "what is on this one" is the table everybody can already read, and Esc undoes it. ^r reads the screen again; live mode does not tick here, since this screen reads the hosts itself. A page jump that would land outside the screen stops at the end of its travel rather than doing nothing — page-up from the second host had no row a whole page above it, and "no row" has to mean the first one. Tested against the simulator: the grouping, that a heading is the sum of its hosts, that every placed machine is charged to exactly one of them, and that Enter comes back with the list narrowed to the host under the cursor. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
338 lines
8.3 KiB
Go
338 lines
8.3 KiB
Go
package main
|
|
|
|
// tty.go — minimal raw-terminal input for the interactive machine list, taken
|
|
// over from fid (see browse.go for the other half).
|
|
//
|
|
// Deliberately Unix-only: it puts the tty into raw, character-at-a-time mode via
|
|
// the external `stty` binary instead of termios/ioctl syscalls, so there is no
|
|
// per-OS code and it works unchanged on Linux and macOS. gvm is built for those
|
|
// two anyway; where there is no /dev/tty the caller falls back to `gvm vm -l`.
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
// haveTerminal reports whether there is a terminal to draw on, without touching
|
|
// its state. It is the question to ask *before* doing any work: finding out
|
|
// afterwards means a full-screen program has logged in to three vCenters only to
|
|
// say that it cannot draw anything.
|
|
func haveTerminal() error {
|
|
f, err := os.OpenFile("/dev/tty", os.O_RDWR, 0)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return f.Close()
|
|
}
|
|
|
|
// enterRawMode opens /dev/tty and switches it to raw, no-echo,
|
|
// character-at-a-time mode. The returned restore func must be called
|
|
// (typically via defer) to put the terminal back the way it was.
|
|
func enterRawMode() (tty *os.File, restore func(), err error) {
|
|
tty, err = os.OpenFile("/dev/tty", os.O_RDWR, 0)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
saved, err := runStty(tty, "-g")
|
|
if err != nil {
|
|
tty.Close()
|
|
return nil, nil, fmt.Errorf("stty: %w", err)
|
|
}
|
|
if _, err := runStty(tty, "raw", "-echo"); err != nil {
|
|
tty.Close()
|
|
return nil, nil, fmt.Errorf("stty: %w", err)
|
|
}
|
|
saved = strings.TrimSpace(saved)
|
|
restore = func() {
|
|
runStty(tty, saved)
|
|
tty.Close()
|
|
}
|
|
return tty, restore, nil
|
|
}
|
|
|
|
func runStty(tty *os.File, args ...string) (string, error) {
|
|
cmd := exec.Command("stty", args...)
|
|
cmd.Stdin = tty
|
|
out, err := cmd.Output()
|
|
return string(out), err
|
|
}
|
|
|
|
// ---- key reading ----
|
|
|
|
type specialKey int
|
|
|
|
const (
|
|
keyNone specialKey = iota
|
|
keyRune
|
|
keyUp
|
|
keyDown
|
|
keyLeft
|
|
keyRight
|
|
keyHome
|
|
keyEnd
|
|
keyPgUp
|
|
keyPgDn
|
|
keyDelete
|
|
keyBackspace
|
|
keyTab
|
|
keyShiftTab
|
|
keyEnter
|
|
keyCtrlA
|
|
keyCtrlE
|
|
keyCtrlL
|
|
keyCtrlO
|
|
keyCtrlR
|
|
keyCtrlS
|
|
keyCtrlW
|
|
keyCtrlC
|
|
keyEsc
|
|
)
|
|
|
|
type key struct {
|
|
special specialKey
|
|
r rune // valid when special == keyRune
|
|
}
|
|
|
|
// keyReader decodes raw tty bytes into keys, including ANSI escape
|
|
// sequences for arrows/home/end/delete and multi-byte UTF-8 runes. Reads
|
|
// happen on a background goroutine so an escape byte can be told apart from
|
|
// a full "ESC [ ..." sequence with a short timeout instead of blocking
|
|
// forever waiting for bytes that may never come.
|
|
type keyReader struct {
|
|
ch chan byte
|
|
}
|
|
|
|
func newKeyReader(r *os.File) *keyReader {
|
|
kr := &keyReader{ch: make(chan byte, 32)}
|
|
go func() {
|
|
buf := make([]byte, 1)
|
|
for {
|
|
n, err := r.Read(buf)
|
|
if n > 0 {
|
|
kr.ch <- buf[0]
|
|
}
|
|
if err != nil {
|
|
close(kr.ch)
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
return kr
|
|
}
|
|
|
|
func (kr *keyReader) readByte() (byte, bool) {
|
|
b, ok := <-kr.ch
|
|
return b, ok
|
|
}
|
|
|
|
func (kr *keyReader) next() key {
|
|
b, ok := kr.readByte()
|
|
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:
|
|
return key{special: keyCtrlR}
|
|
case 0x13:
|
|
return key{special: keyCtrlS}
|
|
// ^w, and not the ^i the mnemonic wants: Ctrl-I *is* Tab (0x09), which the
|
|
// list already moves down with, so an issues filter bound to it would have
|
|
// scrolled the table instead.
|
|
case 0x17:
|
|
return key{special: keyCtrlW}
|
|
case 0x1b:
|
|
return kr.readEscape()
|
|
case '\r', '\n':
|
|
return key{special: keyEnter}
|
|
case 0x7f, 0x08:
|
|
return key{special: keyBackspace}
|
|
case 0x09:
|
|
return key{special: keyTab}
|
|
}
|
|
if b < 0x80 {
|
|
return key{special: keyRune, r: rune(b)}
|
|
}
|
|
n := utf8SeqLen(b)
|
|
buf := []byte{b}
|
|
for i := 1; i < n; i++ {
|
|
nb, ok := kr.readByte()
|
|
if !ok {
|
|
break
|
|
}
|
|
buf = append(buf, nb)
|
|
}
|
|
r, _ := utf8.DecodeRune(buf)
|
|
return key{special: keyRune, r: r}
|
|
}
|
|
|
|
// readEscape is called right after an 0x1b byte. A lone Esc keypress won't
|
|
// be followed by anything (within a keystroke's worth of time), while an
|
|
// arrow/home/end/delete key sends "ESC [ ..." essentially instantaneously -
|
|
// the short timeout is what tells the two apart.
|
|
func (kr *keyReader) readEscape() key {
|
|
var b2 byte
|
|
select {
|
|
case v, ok := <-kr.ch:
|
|
if !ok {
|
|
return key{special: keyEsc}
|
|
}
|
|
b2 = v
|
|
case <-time.After(30 * time.Millisecond):
|
|
return key{special: keyEsc}
|
|
}
|
|
if b2 != '[' {
|
|
return key{special: keyEsc}
|
|
}
|
|
|
|
// Read the whole sequence before deciding what it was. A control sequence is
|
|
// ESC [ then parameter bytes then one final byte in 0x40..0x7e, and how many
|
|
// parameters there are depends on the key *and* on which modifiers were held:
|
|
// the plain up arrow is "ESC [ A", the same key with control is "ESC [ 1;5A".
|
|
//
|
|
// Reading a fixed number of bytes instead — one, and for the digits one more
|
|
// for the "~" — left the rest of a longer sequence in the stream, where the
|
|
// next read took it for typing. Ctrl-Up put "5A" into the filter and F5 put a
|
|
// tilde in it, having first jumped to the top of the list.
|
|
params := make([]byte, 0, 8)
|
|
var final byte
|
|
for {
|
|
b, ok := kr.readByte()
|
|
if !ok {
|
|
return key{special: keyEsc}
|
|
}
|
|
if b >= 0x40 && b <= 0x7e {
|
|
final = b
|
|
break
|
|
}
|
|
if len(params) < cap(params) {
|
|
params = append(params, b)
|
|
}
|
|
}
|
|
|
|
// Only the unmodified keys are answered. A sequence with modifiers, or one
|
|
// this does not know, is swallowed whole and ignored — which is the point:
|
|
// what must not happen is for half of it to arrive as text.
|
|
if len(params) > 0 && final != '~' {
|
|
return key{special: keyNone}
|
|
}
|
|
switch final {
|
|
case 'A':
|
|
return key{special: keyUp}
|
|
case 'B':
|
|
return key{special: keyDown}
|
|
case 'C':
|
|
return key{special: keyRight}
|
|
case 'D':
|
|
return key{special: keyLeft}
|
|
case 'H':
|
|
return key{special: keyHome}
|
|
case 'F':
|
|
return key{special: keyEnd}
|
|
case 'Z':
|
|
return key{special: keyShiftTab}
|
|
case '~':
|
|
switch string(params) {
|
|
case "1", "7":
|
|
return key{special: keyHome}
|
|
case "3":
|
|
return key{special: keyDelete}
|
|
case "4", "8":
|
|
return key{special: keyEnd}
|
|
case "5":
|
|
return key{special: keyPgUp}
|
|
case "6":
|
|
return key{special: keyPgDn}
|
|
}
|
|
}
|
|
return key{special: keyNone}
|
|
}
|
|
|
|
func utf8SeqLen(b byte) int {
|
|
switch {
|
|
case b&0xe0 == 0xc0:
|
|
return 2
|
|
case b&0xf0 == 0xe0:
|
|
return 3
|
|
case b&0xf8 == 0xf0:
|
|
return 4
|
|
default:
|
|
return 1
|
|
}
|
|
}
|
|
|
|
// termSize returns the controlling terminal's (columns, rows), falling back to
|
|
// $COLUMNS/$LINES and then to a modest default, so a redraw never divides by a
|
|
// zero-sized screen.
|
|
func termSize() (cols, rows int) {
|
|
cols, rows = 100, 24
|
|
if tty, err := os.Open("/dev/tty"); err == nil {
|
|
defer tty.Close()
|
|
cmd := exec.Command("stty", "size")
|
|
cmd.Stdin = tty
|
|
if out, err := cmd.Output(); err == nil {
|
|
parts := strings.Fields(string(out))
|
|
if len(parts) == 2 {
|
|
if n, err := strconv.Atoi(parts[0]); err == nil && n > 0 {
|
|
rows = n
|
|
}
|
|
if n, err := strconv.Atoi(parts[1]); err == nil && n > 0 {
|
|
cols = n
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if w := os.Getenv("COLUMNS"); w != "" {
|
|
if n, err := strconv.Atoi(w); err == nil && n > 0 {
|
|
cols = n
|
|
}
|
|
}
|
|
if h := os.Getenv("LINES"); h != "" {
|
|
if n, err := strconv.Atoi(h); err == nil && n > 0 {
|
|
rows = n
|
|
}
|
|
}
|
|
return cols, rows
|
|
}
|