diff --git a/cmd/mesh-control/board.go b/cmd/mesh-control/board.go index 1b8ae60..43b7856 100644 --- a/cmd/mesh-control/board.go +++ b/cmd/mesh-control/board.go @@ -94,7 +94,8 @@ func board() http.Handler { http.Error(w, err.Error(), http.StatusServiceUnavailable) return } - body, err := statusAsJSON(asked.wrong, asked.nodes, asked.quiet, asked.behind, asked.sources) + body, err := statusAsJSON(asked.wrong, asked.nodes, asked.quiet, asked.behind, asked.sources, + asked.waiting) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -127,9 +128,17 @@ type view struct { Broken []brokenMachine Quiet []quietMachine Behind []staleModule + Waiting []waitingMachine At string } +type waitingMachine struct { + Node string + // Never told is not out of date: nobody has ever asked this machine to be anything. Same + // remedy, different situation, and the page says which. + Never bool +} + type brokenMachine struct { Node string // Outcome is refused or failed, and stays distinct all the way to the page. **Refused means @@ -173,6 +182,9 @@ func viewOf(asked answers) view { for _, n := range asked.quiet { out.Quiet = append(out.Quiet, quietMachine{Node: n.Name, Heard: heardFrom(n)}) } + for _, m := range asked.waiting { + out.Waiting = append(out.Waiting, waitingMachine{Node: m.Node, Never: m.Never}) + } for module, on := range asked.behind { from := asked.sources[module] out.Behind = append(out.Behind, staleModule{ @@ -242,6 +254,19 @@ new, switched off, or unreachable.
{{end}} {{else}}No. Every module is what its source last had.
{{end}} + +{{if .Waiting}} +Never told is not out of date: nobody has asked that machine to be anything yet.
+Both are sent by push --behind.
Every machine is running what the mesh would send it.
{{end}} {{end}} diff --git a/cmd/mesh-control/board_test.go b/cmd/mesh-control/board_test.go index e257080..85462c3 100644 --- a/cmd/mesh-control/board_test.go +++ b/cmd/mesh-control/board_test.go @@ -119,3 +119,36 @@ func render(t *testing.T, v view) string { } return out.String() } + +// The board and the command must answer the same question the same way. +// +// A machine not running what the mesh would send it was added to `status` and would have been +// missing here — which is the one thing this page's design forbids: two answers to the same +// question, disagreeing, with a person in front of each. +func TestTheBoardSaysWhichMachinesHaveNotBeenSentWhatTheyShouldBe(t *testing.T) { + rendered := render(t, viewOf(answers{ + nodes: []inventory.Node{{Name: "anchor"}, {Name: "laptop"}}, + waiting: []inventory.Machine{ + {Node: "anchor"}, + {Node: "laptop", Never: true}, + }, + })) + if !strings.Contains(rendered, "is not running what the mesh would send it") { + t.Fatalf("the page does not say a machine is out of date:\n%s", rendered) + } + // Never told is a different situation with the same remedy, and the page keeps them apart. + if !strings.Contains(rendered, "has never been sent anything") { + t.Fatalf("the page does not separate never told from out of date:\n%s", rendered) + } + if !strings.Contains(rendered, "push --behind") { + t.Fatalf("the page does not say what sends them:\n%s", rendered) + } +} + +// And says so when there is nothing waiting, rather than leaving the question unanswered. +func TestAMeshWithNothingWaitingSaysSo(t *testing.T) { + rendered := render(t, viewOf(answers{nodes: []inventory.Node{{Name: "anchor"}}})) + if !strings.Contains(rendered, "Every machine is running what the mesh would send it") { + t.Fatalf("the page leaves the question unanswered:\n%s", rendered) + } +} diff --git a/cmd/mesh-control/main.go b/cmd/mesh-control/main.go index dd06c67..036d557 100644 --- a/cmd/mesh-control/main.go +++ b/cmd/mesh-control/main.go @@ -1848,7 +1848,7 @@ func statusCommand(ctx context.Context, args []string) error { behind, sources := asked.behind, asked.sources if *asJSON { - body, err := statusAsJSON(wrong, nodes, quiet, behind, sources) + body, err := statusAsJSON(wrong, nodes, quiet, behind, sources, asked.waiting) if err != nil { return err } diff --git a/cmd/mesh-control/readable.go b/cmd/mesh-control/readable.go index 06197bc..9b17a0d 100644 --- a/cmd/mesh-control/readable.go +++ b/cmd/mesh-control/readable.go @@ -31,6 +31,10 @@ type meshStatus struct { Quiet []machineQuiet `json:"quiet"` // Behind is every module built from something older than its source has. Behind []moduleBehind `json:"behind"` + // Waiting is every machine not running what the mesh would send it. The same question as + // Behind one level down: that says the catalogue is old, this says a machine is — and only + // this one has somebody's change waiting inside it. + Waiting []machineWaiting `json:"waiting"` // Machines is how many the mesh knows about, so a reader can tell "none wrong" from // "none at all". Machines int `json:"machines"` @@ -50,6 +54,14 @@ type machineDoing struct { At time.Time `json:"at"` } +type machineWaiting struct { + Node string `json:"node"` + // Never is true when nothing has ever been sent to it. Not out of date: nobody has ever asked + // this machine to be anything, and the two read differently to whoever is looking. + Never bool `json:"never"` + Sent *time.Time `json:"sent,omitempty"` +} + type machineQuiet struct { Node string `json:"node"` // LastSeen is absent when the machine has never spoken, which is a different thing from @@ -66,10 +78,16 @@ type moduleBehind struct { // statusAsJSON answers the same three questions as the text form, from the same calls. func statusAsJSON(wrong []inventory.Doing, nodes []inventory.Node, quiet []inventory.Node, - behind map[string][]string, sources map[string]inventory.Source) ([]byte, error) { + behind map[string][]string, sources map[string]inventory.Source, + waiting []inventory.Machine) ([]byte, error) { out := meshStatus{Machines: len(nodes), Wrong: []machineDoing{}, - Quiet: []machineQuiet{}, Behind: []moduleBehind{}} + Quiet: []machineQuiet{}, Behind: []moduleBehind{}, Waiting: []machineWaiting{}} + + for _, m := range waiting { + out.Waiting = append(out.Waiting, machineWaiting{ + Node: m.Node, Never: m.Never, Sent: m.SentAt}) + } for _, d := range wrong { row := machineDoing{ diff --git a/cmd/mesh-control/readable_test.go b/cmd/mesh-control/readable_test.go index 136dcd0..f4705d0 100644 --- a/cmd/mesh-control/readable_test.go +++ b/cmd/mesh-control/readable_test.go @@ -17,7 +17,7 @@ import ( func statusOf(t *testing.T, wrong []inventory.Doing, nodes, quiet []inventory.Node, behind map[string][]string, sources map[string]inventory.Source) map[string]any { t.Helper() - body, err := statusAsJSON(wrong, nodes, quiet, behind, sources) + body, err := statusAsJSON(wrong, nodes, quiet, behind, sources, nil) if err != nil { t.Fatal(err) } @@ -126,7 +126,7 @@ func TestNoSecretIsInWhatABoardReads(t *testing.T) { body, err := statusAsJSON( []inventory.Doing{{Node: "a", Outcome: inventory.OutcomeRefused, Refused: "resource \"x\": a file needs a path"}}, - []inventory.Node{{Name: "a"}}, nil, nil, nil) + []inventory.Node{{Name: "a"}}, nil, nil, nil, nil) if err != nil { t.Fatal(err) }