3 Commits
Author SHA1 Message Date
Michael WesemannandClaude Opus 5 c22a66c470 [mike@mwxm4]
The customisation picker opens on the site's own road.

A domain, a netmask and a gateway in ~/.gvmrc are written down so that machines
get them — that is what writing them down was for — so the line that gives them
to the guest is where the cursor starts, rather than the one that leaves the
guest as the template made it:

      leave the guest as the template made it
    ▸ this site — fhi-berlin.mpg.de, gateway 10.0.0.1
      linux-static
      windows-domain

Where no site is set up it starts at the top as before, on the answer that
cannot be wrong. Nothing is decided by where the cursor sits: the picker picks
on Enter, the address is asked for after it, and the confirmation still says in
full what will happen before anything is made.

What the picker offers is a function of its own now (customChoices), which is
what made it testable: the lines, what each of them means, and where to open
are one answer to one question and were three statements spread through a
method that also asks four others.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 14:57:33 +02:00
Michael WesemannandClaude Opus 5 ae936420e0 [mike@mwxm4]
The address question comes up with "auto" in it.

A site with an address tool hands addresses out from it — that is what the tool
is configured for — so typing the word every time was asking somebody to
confirm the ordinary case by hand. It stands in the line now, and Enter is the
whole answer:

    address for web05, "auto" for one from dns, empty for DHCP: auto

Offered rather than made the meaning of the empty line. Every answer keeps the
meaning it had: erased back to nothing it is DHCP again, or whatever the chosen
specification says, and an address typed by hand is still typed by hand. Enter
commits nothing either — what is fetched here is handed straight back when the
deployment is not confirmed.

The prefilled line is input's own doing (inputWith), with the cursor at the end
of it, so it is a default that can be seen. One that cannot be seen is a
question answered by what was not typed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 14:53:42 +02:00
Michael WesemannandClaude Opus 5 1aab0cf4a5 [mike@mwxm4]
Straight to the list, live, once a machine is being made.

Starting a clone left a sentence on the template's sheet saying that its TASK
column shows how far along it is and that ^l would make that move — three steps
of housekeeping after the one decision that mattered, on a screen which is a
page of facts about the machine being copied rather than about the one being
made.

Now the sheet goes away, the list comes back with the cursor still on the row
the task hangs off, and live mode turns itself on where it was off. The
percentage moves by itself, the new machine turns up in the table when it
exists, and the line under the table announces it.

    web05 is being made at 141.14.140.182 — live on, ubuntu-tpl shows how far
    along it is

Live is said in that line because it is a mode and it stays on: the title says
it is running and ^l ends it. It does not sweep at once the way ^l does — the
deployment re-read its own row a moment earlier — so the first tick is one
interval away, and that interval is already the busy one, because the row it
re-read is the one carrying the clone.

Putting the sheet away is a function of its own now: Esc and this both have to
forget the events that were read during the visit, and that reason belongs in
one place.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 14:41:38 +02:00
8 changed files with 282 additions and 44 deletions
+39 -13
View File
@@ -265,6 +265,10 @@ 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.
`^l` is not the only way in: making a machine from a template turns it on, since
a clone that has just been started is exactly the screen this is for, and says
so in the same line that reports the machine.
Two things come with it.
**The line under the table says what just changed.** A table shows what is; this
@@ -479,10 +483,17 @@ puts it somewhere other than the template's own.
**It does not wait.** A clone copies every disk the template has, which is
minutes to the half hour, and a list frozen for that long is a list nobody would
start one from. The task hangs off the template in vCenter, so the row it was
started from shows how far along it is in its TASK column — `^l` makes that move
on its own, and announces the new machine on the line under the table when it
exists. `gvm new` on the command line does wait: a script that gets its prompt
back wants the machine to be there.
started from shows how far along it is in its TASK column. `gvm new` on the
command line does wait: a script that gets its prompt back wants the machine to
be there.
**And it goes to that column.** The machine's sheet — which is where the menu
was opened and a page of facts about the *template* — is put away, the list
comes back with the cursor still on the row the clone hangs off, and live mode
turns itself on if it was off, so the percentage moves on its own and the line
under the table announces the new machine when it exists. `^l` ends it again.
Naming the key that would make an invisible figure move is three steps of
housekeeping after the one decision that mattered.
The rest of a template's menu is greyed out with "a template" next to it, because
vSphere will not start one, snapshot one or reconfigure one. They are greyed
@@ -534,16 +545,23 @@ are open —
customise web05 how v308
▸ leave the guest as the template made it
this site — fhi-berlin.mpg.de, gateway 10.0.0.1
leave the guest as the template made it
▸ this site — fhi-berlin.mpg.de, gateway 10.0.0.1
linux-static
windows-domain
— and then one line for the address, which says in words what leaving it empty
would do:
The cursor starts on gvm's own road wherever the site is set up: a domain, a
netmask and a gateway written into `~/.gvmrc` are written down so that machines
get them, and the answer wanted nearly every time should not be the one that has
to be arrowed to. Where the site is not set up it starts at the top instead, on
the answer that is never wrong. Nothing is decided by where it sits — the picker
picks on Enter, and the confirmation still says in full what will happen.
address for web05, or empty for DHCP:
address for web05, or empty to leave it to linux-static:
Then one line for the address, which says in words what leaving it empty would
do:
address for web05, empty for DHCP:
address for web05, empty to leave it to linux-static:
Where that picker does **not** appear, the confirmation says why rather than
leaving a step to look broken — neither road is open, because the vCenter holds
@@ -560,9 +578,16 @@ when it will not say.
gvm new --from ubuntu-tpl --name web05 --ip auto
Where the site has a tool that hands addresses out, `--ip auto` asks it instead
of making you look one up first. In the list the same word does it:
of making you look one up first. In the list the same word does it, and it is
already standing in the line — a site with a tool hands addresses out from it,
so that is the answer Enter gives:
address for web05, "auto" for one from dns, empty for DHCP:
address for web05, "auto" for one from dns, empty for DHCP: auto
It is offered rather than made the meaning of the empty line, so every answer
keeps the meaning it had: erased back to nothing it is DHCP again, or whatever
the chosen specification says. And Enter commits nothing — an address fetched
here is handed straight back when the deployment is not confirmed.
It shells out to `dns`, the Infoblox helper — found on the path by that name, or
named as `dnstool` in `~/.gvmrc` where it lives somewhere else. Everything it
@@ -586,7 +611,8 @@ answer short of the same answer.
the line that is left on the screen afterwards — this is a number somebody
writes down:
web05 is being made at 141.14.140.182 — ubuntu-tpl shows how far along it is
web05 is being made at 141.14.140.182 — live on, ubuntu-tpl shows how far
along it is
Three more things about it are deliberate:
+93 -20
View File
@@ -372,7 +372,19 @@ func (b *browser) openPicker(r vmRow, kind pickKind) {
// callback runs after the picker has closed, so that what it does — a
// confirmation, another question — has the screen to itself.
func (b *browser) choose(title string, r vmRow, lines []string, chosen func(int)) {
b.pick = &picker{title: title, row: r, lines: lines, chosen: chosen}
b.chooseAt(title, r, lines, 0, chosen)
}
// chooseAt is the same with the cursor starting somewhere other than the top,
// for a list whose ordinary answer is not its first line. Where the cursor
// starts is the only default a picker has — nothing is picked until Enter — so
// it belongs on the line somebody nearly always wants, and the others are one
// key away.
func (b *browser) chooseAt(title string, r vmRow, lines []string, at int, chosen func(int)) {
if at < 0 || at >= len(lines) {
at = 0
}
b.pick = &picker{title: title, row: r, lines: lines, chosen: chosen, sel: at}
}
func (b *browser) closePicker() { b.pick = nil }
@@ -594,8 +606,14 @@ func sizePrompt(r vmRow, sz sizing, k sizeKind) string {
// minutes to the half hour, and a list that is frozen for that long is a list
// nobody will start one from. vCenter carries the task either way, and it hangs
// off the template — so the row this was started from shows how far along it is
// in its TASK column, and ^l makes that move on its own. The machine itself
// turns up in the list when it exists, which live mode announces.
// in its TASK column. The machine itself turns up in the list when it exists,
// which live mode announces.
//
// Which is why the end of it is a screen and not only a sentence: the sheet
// goes away, the list comes back with the cursor still on the template, and
// live mode turns itself on. Telling somebody that a figure they cannot see
// exists, and which key would make it move, is three steps of housekeeping
// after the one decision that mattered.
//
// The question at the end is the plain one. Nothing is lost here; something is
// made, and what has to be read before it is made is *where* — which is why
@@ -671,21 +689,9 @@ func (b *browser) deploy(r vmRow) {
return
}
lines := []string{"leave the guest as the template made it"}
kinds := []string{""} // what each line means: "" none, "-" gvm's own, else a name
if own {
// What it will do, not where the settings are kept: a picker line that
// is mostly an absolute path says nothing about the choice being made,
// and `gvm config` is where the file is named.
lines = append(lines, SF("this site — %s, gateway %s", b.site.domain, b.site.gateway))
kinds = append(kinds, "-")
}
for _, name := range specs {
lines = append(lines, name)
kinds = append(kinds, name)
}
lines, kinds, at := customChoices(b.site, specs, own)
b.choose(SF("customise %s how", name), r, lines, func(i int) {
b.chooseAt(SF("customise %s how", name), r, lines, at, func(i int) {
switch kinds[i] {
case "":
b.deployAsk(r, src, target, name, deployOpts{}, deployStep{})
@@ -699,13 +705,50 @@ func (b *browser) deploy(r vmRow) {
})
}
// customChoices is what the customisation picker offers, what each line means,
// and where its cursor starts.
//
// kinds is the meaning, carried beside the lines rather than worked out from an
// index: "" leaves the guest alone, "-" is the specification gvm writes from
// ~/.gvmrc, and anything else is the name of one the vCenter holds.
//
// The cursor starts on gvm's own road where the site is set up. A site whose
// domain, netmask and gateway have been written down is a site whose machines
// are meant to be given them — that is what writing them down was for — and the
// answer wanted nearly every time should not be the one that has to be arrowed
// to. Where there is no such road it starts at the top, on the answer that is
// never wrong: leave the guest as the template made it.
//
// Nothing is decided by the cursor sitting there. The picker chooses on Enter,
// the address is asked for after it, and the confirmation still says in full
// what will happen.
func customChoices(st site, specs []string, own bool) (lines, kinds []string, at int) {
lines = []string{"leave the guest as the template made it"}
kinds = []string{""}
if own {
// What it will do, not where the settings are kept: a picker line that
// is mostly an absolute path says nothing about the choice being made,
// and `gvm config` is where the file is named.
lines = append(lines, SF("this site — %s, gateway %s", st.domain, st.gateway))
kinds = append(kinds, "-")
at = len(lines) - 1
}
for _, name := range specs {
lines = append(lines, name)
kinds = append(kinds, name)
}
return lines, kinds, at
}
// deployAddress asks for the address, which is the other half of what a
// specification cannot know: it holds the netmask, the gateway and the domain,
// and the machine holds its own number.
//
// Empty is an answer: it leaves whatever the specification says, which is
// usually DHCP, and that is the ordinary case on a network that hands out
// addresses.
// addresses. Where the site has a tool of its own, the ordinary case is the
// other one — so "auto" is in the line when the question appears, and Enter
// takes it.
func (b *browser) deployAddress(r vmRow, src deploySource, t deployTarget, name string, opts deployOpts) {
// Empty is an answer, and it means two different things: with gvm's own
// specification it leaves the adapter on DHCP, and with one of the
@@ -722,11 +765,24 @@ func (b *browser) deployAddress(r vmRow, src deploySource, t deployTarget, name
leave = "empty to leave it to " + opts.spec
}
ask := SF("address for %s, %s: ", name, leave)
// And where there is a tool, its word is standing in the line already, so
// Enter is the whole answer. That is what the tool is configured for: a
// site that has one hands out addresses from it, and typing "auto" every
// time is asking somebody to confirm the ordinary case by hand.
//
// Offered rather than made the meaning of the empty line, so that every
// answer keeps the meaning it had: erased back to nothing it is DHCP again,
// or whatever the specification says. Nor does pressing Enter commit
// anything — an address fetched here is handed straight back where the
// deployment is not confirmed (deployStep.giveBack).
preset := ""
if opts.dns.there() {
ask = SF("address for %s, %q for one from dns, %s: ", name, autoIP, leave)
preset = autoIP
}
ip, ok := b.input(ask)
ip, ok := b.inputWith(ask, preset)
if !ok {
b.setStatus(colDim, "nothing done")
return
@@ -795,13 +851,30 @@ func (b *browser) deployAsk(r vmRow, src deploySource, t deployTarget, name stri
b.setStatus(colErr, err.Error())
return
}
// And then the screen it can be watched on. This was started from the
// template's sheet — a page of facts about the machine being copied, which
// is not what anybody is here for now. The list is: the clone's progress
// stands in the TASK column of the row the cursor is already on, the new
// machine turns up there when it exists, and the line under the table says
// so. Live mode goes with it, or the percentage would sit where it was
// until somebody pressed a key.
//
// Decided before done, which is what puts the task on the row, and applied
// after it, so that the interval it schedules is the busy one.
watching := ""
if !b.live {
watching = "live on, "
}
b.closeDetail()
// The address is in the line that survives, not only in the question that
// was answered a moment ago: it is the one thing here somebody writes down.
at := ""
if opts.ip != "" && opts.customising() {
at = " at " + opts.ip
}
b.done(SF("%s is being made%s — %s shows how far along it is", name, at, src.name))
b.done(SF("%s is being made%s — %s%s shows how far along it is", name, at, watching, src.name))
b.watchLive()
}
// working puts a line on the screen before an operation that will block the loop
+26 -9
View File
@@ -1012,13 +1012,7 @@ func (b *browser) detailKey(k key) {
switch k.special {
case keyEsc, keyEnter, keyBackspace, keyLeft:
b.detail, b.dscroll = nil, 0
// The events go with the visit, not with the machine: coming back to a
// sheet half an hour later and finding half-hour-old events under a
// label that says nothing about when they were read would be the one
// stale thing on an otherwise freshly read screen. They are one
// keystroke away again.
b.events, b.eventsOf = nil, ""
b.closeDetail()
b.setStatus("", "")
case keyCtrlA:
b.openMenu()
@@ -1306,8 +1300,18 @@ func (e *editor) key(k key) (finished, accepted bool) {
// input reads one line in the status area and reports whether it was finished
// rather than abandoned. It has its own key loop, so nothing that is typed here
// reaches the filter.
func (b *browser) input(label string) (string, bool) {
b.edit = &editor{label: label}
func (b *browser) input(label string) (string, bool) { return b.inputWith(label, "") }
// inputWith is the same with an answer already standing in the line, for a
// question that has one ordinary answer worth offering: Enter takes it, and
// Backspace is how it is refused. It is a default one can see — which is the
// only kind worth having, since a question whose answer is decided by what was
// not typed is one nobody knows they have agreed to.
//
// The cursor sits at the end of it, so typing carries on from the answer rather
// than into the middle of it.
func (b *browser) inputWith(label, preset string) (string, bool) {
b.edit = &editor{label: label, runes: []rune(preset), cursor: len([]rune(preset))}
defer func() { b.edit = nil }()
for {
@@ -1717,6 +1721,19 @@ func (b *browser) openDetail() {
b.dscroll = 0
}
// closeDetail puts the sheet away and leaves the list underneath it. It is what
// Esc does, and also what an action does when what happens next is to be
// watched in the table rather than read on one machine's page (actions.go).
//
// The events go with the visit, not with the machine: coming back to a sheet
// half an hour later and finding half-hour-old events under a label that says
// nothing about when they were read would be the one stale thing on an
// otherwise freshly read screen. They are one keystroke away again.
func (b *browser) closeDetail() {
b.detail, b.dscroll = nil, 0
b.events, b.eventsOf = nil, ""
}
// eventSheet is the history as sheet lines: the label on the first, each line in
// the colour of its own severity. It is appended to the sheet rather than built
// into vmDetail because vmDetail asks nothing of the network and this is the one
+23
View File
@@ -380,6 +380,29 @@ func TestTheSheetClosesWhenItsMachineGoes(t *testing.T) {
}
}
// Closing the sheet leaves nothing of the visit behind. The events are the part
// that would keep: they are read once, on request, and a sheet opened again
// half an hour later must not show them under a label that says nothing about
// when they were read. Esc does this, and so does a deployment, which puts the
// sheet away to leave the list watching the clone (actions.go).
func TestClosingTheSheetForgetsTheVisit(t *testing.T) {
b := testBrowser("ubuntu-tpl", "web01")
b.applySort()
b.events = []eventLine{{text: "08.09. 11:41 Cannot connect to host esx03", col: colFull}}
b.eventsOf = b.current().id()
b.openDetail()
b.dscroll = 4
b.closeDetail()
if b.detail != nil || b.dscroll != 0 {
t.Errorf("the sheet is still there: %d lines, scrolled to %d", len(b.detail), b.dscroll)
}
if b.events != nil || b.eventsOf != "" {
t.Errorf("the events of %q were kept: %v", b.eventsOf, b.events)
}
}
func TestDetailSheetHasTheParameters(t *testing.T) {
sheet := sheetText(vmDetail(testRow("web01", true, "10.0.0.5"), []string{"none"}, colOff))
+45
View File
@@ -571,6 +571,43 @@ func TestTheSiteComesOutOfTheConfiguration(t *testing.T) {
}
}
// The picker opens on the road gvm writes itself where the site has been set
// up: a domain, a netmask and a gateway in ~/.gvmrc are there so that machines
// get them, and the answer wanted nearly every time must not be the one that
// has to be arrowed to. Where there is no such road it opens at the top, on the
// answer that is never wrong.
func TestTheCustomisationPickerOpensOnTheSiteRoad(t *testing.T) {
st := testSite()
lines, kinds, at := customChoices(st, []string{"linux-static", "windows"}, true)
if len(lines) != 4 || len(kinds) != len(lines) {
t.Fatalf("the picker offers %d lines and %d meanings: %v", len(lines), len(kinds), lines)
}
if kinds[at] != "-" {
t.Errorf("it opens on %q, which is %q — want gvm's own road", lines[at], kinds[at])
}
// And that line says what it will do, in the site's own words: a default
// nobody can read is one nobody knows they are taking.
if !strings.Contains(lines[at], st.domain) || !strings.Contains(lines[at], st.gateway) {
t.Errorf("the line it opens on is %q, want the domain and the gateway in it", lines[at])
}
// The vCenter's own are still there, after it and in order.
if lines[2] != "linux-static" || lines[3] != "windows" {
t.Errorf("the vCenter's specifications came out as %v", lines[2:])
}
// No site, and the cursor is at the top rather than on somebody else's
// specification: leaving the guest as the template made it is the one
// answer that cannot be wrong.
lines, kinds, at = customChoices(site{}, []string{"linux-static"}, false)
if at != 0 || kinds[at] != "" {
t.Errorf("with no site it opens on %q (%q)", lines[at], kinds[at])
}
if len(lines) != 2 {
t.Errorf("with no site the picker offers %v", lines)
}
}
// The question that asks for an address has to read as a sentence on both
// roads. It did not: the interactive half settles which road before it asks,
// so at that moment every field is still empty — which read as "no
@@ -581,6 +618,8 @@ func TestTheAddressQuestionReadsAsASentence(t *testing.T) {
t.Setenv("COLUMNS", "100")
t.Setenv("LINES", "24")
tool, _ := fakeInfoblox(t) // for its there(), not for its answers
for _, c := range []struct {
what string
opts deployOpts
@@ -589,6 +628,12 @@ func TestTheAddressQuestionReadsAsASentence(t *testing.T) {
{"gvm's own specification", deployOpts{how: customSite, st: testSite()}, "empty for DHCP"},
{"one the vCenter holds", deployOpts{how: customSpec, spec: "linux-static"},
"leave it to linux-static"},
// With an address tool, the answer it would give is standing in the
// line already: that is what the tool is there for, and the question
// still has to say what the other two answers mean.
{"a site with an address tool",
deployOpts{how: customSite, st: testSite(), dns: tool},
`"auto" for one from dns, empty for DHCP: auto`},
} {
r := templateRow("ubuntu-tpl")
b := &browser{rows: []vmRow{r}, view: []int{0}}
+24 -1
View File
@@ -3,7 +3,9 @@
// 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.
// looks at into something one leaves open on a second screen. Starting a clone
// turns it on as well (watchLive), because that is a screen made to be watched
// whether or not anybody thought to ask for it first.
//
// Three things come with it, and they are here rather than in browse.go because
// they only mean anything together:
@@ -69,6 +71,27 @@ func (b *browser) toggleLive() {
b.setStatus(colBusy, "live on — the list re-reads itself; ^l off")
}
// watchLive turns live mode on because something with a figure to watch was
// just set going, and reports whether it had to. ^l is somebody asking for a
// screen that keeps itself up to date; this is gvm deciding that a clone it has
// just started makes the list one of those, since the progress in the TASK
// column is the whole reason to still be looking at it. It stays on afterwards,
// like the mode it is — ^l ends it, and the title says it is running.
//
// Unlike toggleLive it does not sweep at once. The caller has just re-read the
// row it acted on, so the first tick belongs one interval away rather than now
// — and that interval is already the busy one, because that row is the one
// carrying the task.
func (b *browser) watchLive() bool {
if b.live {
return false
}
b.live = true
b.liveGap = b.liveInterval()
b.liveNext = time.Now().Add(b.liveGap)
return true
}
// 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
+31
View File
@@ -485,3 +485,34 @@ func TestToggleLiveSaysSoAndLooksNow(t *testing.T) {
t.Errorf("turning it off said %q", b.status)
}
}
// A clone that has just been started makes the list a screen worth watching, so
// live mode turns itself on rather than leaving a percentage that moves only
// when somebody remembers ^l. The first tick is an interval away rather than
// now — the action re-read its own row a moment ago — and that interval is the
// busy one, because the row it re-read is the one carrying the task.
func TestStartingAClonePutsTheListOnWatch(t *testing.T) {
b := testBrowser("ubuntu-tpl", "web01")
b.rows[0].task = &runningTask{what: "clone", progress: 2}
if !b.watchLive() {
t.Fatal("watchLive did not report that it had to turn live mode on")
}
if !b.live {
t.Fatal("live mode is off after a clone was started")
}
if d := b.liveIn(); d > liveBusy || d < liveBusy/2 {
t.Errorf("the first refresh is %s away, want about %s", d, liveBusy)
}
// Already on is not a reason to touch it: a tick that was due in half a
// second must not be pushed back to two because a second clone was started.
b.liveNext = time.Now().Add(liveBusy / 4)
due := b.liveNext
if b.watchLive() {
t.Error("watchLive reported turning on a mode that was already on")
}
if !b.liveNext.Equal(due) {
t.Errorf("the pending tick moved from %s to %s", due, b.liveNext)
}
}
+1 -1
View File
@@ -1 +1 @@
1.3.7
1.3.10