From 935cc87bdca419a8fb2c392ca868380b8777ad3b Mon Sep 17 00:00:00 2001 From: lemo-o <21benjaminhall@gmail.com> Date: Mon, 19 Oct 2026 06:52:27 -0600 Subject: [PATCH 1/4] 0.4.1 --- examples/fixtures.yaml | 13 ++ internal/dataview/builtin.yaml | 48 +++++ internal/dataview/compare.go | 138 ++++++++++++++ internal/dataview/compare_test.go | 295 ++++++++++++++++++++++++++++++ internal/dataview/fetch.go | 15 +- internal/dataview/render.go | 54 ++++++ internal/dataview/spec.go | 69 +++++++ internal/tui/compare.go | 236 ++++++++++++++++++++++++ internal/tui/compare_test.go | 237 ++++++++++++++++++++++++ internal/tui/data.go | 9 +- internal/tui/data_test.go | 4 +- internal/tui/tui.go | 7 +- 12 files changed, 1118 insertions(+), 7 deletions(-) create mode 100644 internal/dataview/compare.go create mode 100644 internal/dataview/compare_test.go create mode 100644 internal/tui/compare.go create mode 100644 internal/tui/compare_test.go diff --git a/examples/fixtures.yaml b/examples/fixtures.yaml index c3f0b8c..8b8bc59 100644 --- a/examples/fixtures.yaml +++ b/examples/fixtures.yaml @@ -33,11 +33,23 @@ tenants: # fixture with no site shows a raw identifier exactly like the real API. site: Store 0003 connected: true + # Puts it in a spoke HA cluster with the site's other HA element, so + # the HA view and its comparison have something to answer. + ha: true + # What the CLI answers. "c" on a rich-data page runs the read-only + # commands the views ask for and marks each answer against the + # portal's: Model and Version agree here, and Serial does not, since + # the fixture derives the portal's serial from the element name. responses: "dump device status": | Serial : FAKE0001 Model : ion 1200 + Version : 6.5.1 Uptime : 4 days + "dump spoke-ha status": | + State : active + Priority : 160 + Peer : not connected "dump interface status 1": | 1: up, 1000Mb/s, full duplex @@ -46,6 +58,7 @@ tenants: software: 6.3.4-b2 site: Store 0003 connected: true + ha: true # Makes long output stop on the pager prompt an ion 1200-s running # 6.3.4-b2 prints, and wait for a space. "set paging off" turns it off # for the rest of the session, which is what --pager-off sends. diff --git a/internal/dataview/builtin.yaml b/internal/dataview/builtin.yaml index a707368..fb5f693 100644 --- a/internal/dataview/builtin.yaml +++ b/internal/dataview/builtin.yaml @@ -11,10 +11,18 @@ # runs once per row with {{ .item }} in scope, so a table costs one # request per row -- max_items bounds it. # when: the section is dropped when this renders empty or false. +# compare: the GUI-vs-CLI check, which "c" runs on an open page. It is the +# only part of a view that logs in to the appliance, so nothing here +# happens until that keypress. Its own fetch: reads the portal again +# at the same moment, commands: are read-only CLI commands (checked +# at load), and each row renders both answers and is marked by +# whether they agree. One session serves the whole page. # # Values are Go templates. Helpers: utc and ago for the API's timestamp # integers (never divide those by hand -- the unit varies by field), unwrap to # take one record out of an items envelope, first, join, count, yesno, dash. +# For CLI text: kv reads a "Key : value" line, grep keeps the lines holding a +# string, lines splits them all, field takes the nth column. views: - name: Overview @@ -67,6 +75,27 @@ views: - label: Tracking value: "{{ count .el.spoke_ha_config.track.interfaces }} interface(s)" + # What the portal holds about this appliance against what the appliance + # says about itself. The portal side is confirmed; the CLI key names are + # not -- `dump device status` has not been captured, and a key that is + # spelled differently reads as "·", nothing to compare, rather than a + # disagreement. Open a shell tab, run the command, and fix the names here. + compare: + fetch: + el: /sdwan/v3.2/api/elements/{{ .element_id }} + commands: + dev: dump device status + rows: + - label: Serial + portal: "{{ .el.serial_number }}" + device: "{{ kv .cli.dev \"Serial\" }}" + - label: Model + portal: "{{ .el.model_name }}" + device: "{{ kv .cli.dev \"Model\" }}" + - label: Software + portal: "{{ .el.software_version }}" + device: "{{ kv .cli.dev \"Version\" }}" + - name: Interfaces fetch: ifs: /sdwan/v4.21/api/sites/{{ .site_id }}/elements/{{ .element_id }}/interfaces @@ -199,3 +228,22 @@ views: width: 14 - header: WHY value: "{{ range .item.adjustments }}-{{ .adjustment }} {{ .kind }} {{ .name }} {{ end }}" + + # HA is the comparison worth having: the controller's view of which member + # is active is the thing most likely to be out of date, and the appliance + # is the authority on its own state. Gated on the element being clustered, + # so an ordinary spoke is never dialled for this. Unverified like the one + # above: `dump spoke-ha status` has not been captured either. + compare: + when: "{{ .cluster_id }}" + fetch: + cl: /sdwan/v2.0/api/sites/{{ .site_id }}/spokeclusters/{{ .cluster_id }}/status + commands: + ha: dump spoke-ha status + rows: + - label: Active + portal: "{{ with lookup .cl.cluster_members \"element_id\" .element_id }}{{ .status }}{{ end }}" + device: "{{ kv .cli.ha \"State\" }}" + - label: Priority + portal: "{{ with lookup .cl.switch_over_events \"element_id\" .element_id }}{{ .effective_priority }}{{ end }}" + device: "{{ kv .cli.ha \"Priority\" }}" diff --git a/internal/dataview/compare.go b/internal/dataview/compare.go new file mode 100644 index 0000000..19f5caa --- /dev/null +++ b/internal/dataview/compare.go @@ -0,0 +1,138 @@ +package dataview + +import ( + "context" + "strings" +) + +// DeviceOutput is what the appliance printed, keyed by the command itself, so +// two views that ask the same question share one run of it. +type DeviceOutput map[string]string + +// Verdict is what the two sources said about one row. +type Verdict int + +const ( + // Unknown is one side saying nothing at all: a field this firmware does + // not print, or a record that has not got it. That is not a disagreement, + // and marking it as one would cry wolf on every element. + Unknown Verdict = iota + Agree + Differ +) + +// Compared is one row: both answers and the verdict between them. +type Compared struct { + Label string + Portal string + Device string + Verdict Verdict +} + +// Comparison is one view's worth of comparison. +type Comparison struct { + View string + Rows []Compared + // Commands is what the device was asked, so a row that reads wrong can be + // run by hand. + Commands []string + // Errs is the portal fetches that failed, which is why a portal side may + // be blank. + Errs []string +} + +// CompareViews are the views whose comparison applies to this element. The +// gate is checked before anything is dialled: asking an unclustered appliance +// about HA costs the same session as asking a clustered one. +func CompareViews(views []View, vars Vars) []View { + var out []View + for _, v := range views { + if v.Compare == nil { + continue + } + if v.Compare.When != "" && !truthy(eval(v.Compare.When, varsCtx(vars))) { + continue + } + out = append(out, v) + } + return out +} + +// CompareCommands is every command those views ask for, deduplicated. The +// whole page's device half is one session: a dial and a login cost seconds and +// a session slot on the appliance. +func CompareCommands(views []View) []string { + seen := map[string]bool{} + var out []string + for _, v := range views { + if v.Compare == nil { + continue + } + for _, name := range sortedKeys(v.Compare.Commands) { + cmd := v.Compare.Commands[name] + if seen[cmd] { + continue + } + seen[cmd] = true + out = append(out, cmd) + } + } + return out +} + +// FetchCompare reads the portal side of a comparison. +func FetchCompare(ctx context.Context, g Getter, v View, vars Vars) (*Data, error) { + spec := View{Name: v.Name} + if v.Compare != nil { + spec.Fetch = v.Compare.Fetch + } + return Fetch(ctx, g, spec, vars) +} + +// RenderCompare puts the two sides of each row beside each other. The device +// output is in scope as .cli., as text: the appliance prints +// "Key : value" lines, which the kv helper reads. +func RenderCompare(v View, d *Data, out DeviceOutput) Comparison { + c := Comparison{View: v.Name} + if v.Compare == nil { + return c + } + for _, name := range sortedKeys(d.Errs) { + c.Errs = append(c.Errs, name+": "+d.Errs[name]) + } + + ctx := varsCtx(d.Vars) + for k, val := range d.Bodies { + ctx[k] = val + } + cli := map[string]string{} + for name, cmd := range v.Compare.Commands { + cli[name] = out[cmd] + } + ctx["cli"] = cli + c.Commands = CompareCommands([]View{v}) + + for _, r := range v.Compare.Rows { + row := Compared{Label: r.Label, Portal: eval(r.Portal, ctx), Device: eval(r.Device, ctx)} + row.Verdict = verdict(row.Portal, row.Device) + c.Rows = append(c.Rows, row) + } + return c +} + +// verdict folds case and runs of whitespace before comparing: a portal +// "Active" and a device "active" are the same answer. Anything beyond that is +// the row's own job, which is why both sides are templates. +func verdict(portal, device string) Verdict { + if portal == "" || device == "" { + return Unknown + } + if fold(portal) == fold(device) { + return Agree + } + return Differ +} + +func fold(s string) string { + return strings.ToLower(strings.Join(strings.Fields(s), " ")) +} diff --git a/internal/dataview/compare_test.go b/internal/dataview/compare_test.go new file mode 100644 index 0000000..85edd7f --- /dev/null +++ b/internal/dataview/compare_test.go @@ -0,0 +1,295 @@ +package dataview + +import ( + "strings" + "testing" + + "tyrtop.com/iontk/internal/sase/ioncli" +) + +// The one captured piece of real CLI output, from an ion running 6.3.4-b2: +// keys and values separated by a tab and a colon, which is what kv reads. +const dumpInterfaceStatus4 = `Interface : 4 +Device : e382 +ID : 1724777596590008596 +MAC Address : 04:47:2a:18:0e:05 +State : down +Last Change : 2026-07-18 13:35:26.686 (2190h12m13s ago) +Status : Not connected +VRF Name : Global` + +func TestCLIHelpersReadDeviceOutput(t *testing.T) { + tests := []struct { + name string + tmpl string + want string + }{ + {"a key and its value", `{{ kv .out "State" }}`, "down"}, + {"a key holding a space", `{{ kv .out "MAC Address" }}`, "04:47:2a:18:0e:05"}, + // Anchored at the line start, so "State" cannot match the "Not + // connected" that "Status" holds. + {"a key that is a prefix of another", `{{ kv .out "Status" }}`, "Not connected"}, + {"a key the firmware did not print", `{{ kv .out "Speed" }}`, ""}, + {"case does not matter", `{{ kv .out "state" }}`, "down"}, + {"grep keeps the lines holding it", `{{ count (grep .out "VRF") }}`, "1"}, + {"grep finds nothing", `{{ count (grep .out "tunnel") }}`, "0"}, + {"lines are every non-blank one", `{{ count (lines .out) }}`, "8"}, + {"field counts from one", `{{ field (kv .out "Last Change") 1 }}`, "2026-07-18"}, + {"field past the end", `{{ field (kv .out "State") 9 }}`, ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := eval(tt.tmpl, map[string]any{"out": dumpInterfaceStatus4}) + if got != tt.want { + t.Errorf("%s = %q, want %q", tt.tmpl, got, tt.want) + } + }) + } +} + +func TestVerdictIgnoresCaseAndSpacing(t *testing.T) { + tests := []struct { + name string + portal, device string + want Verdict + }{ + {"the same answer", "up", "up", Agree}, + {"spelled differently", "Active", "active", Agree}, + {"padded by the device", "6.3.4-b2", " 6.3.4-b2 ", Agree}, + {"columns of space", "ion 1200", "ion 1200", Agree}, + {"two answers", "up", "down", Differ}, + // A key this firmware does not print is not a disagreement: marking + // it as one would cry wolf on every element. + {"the device said nothing", "up", "", Unknown}, + {"the portal has not got it", "", "up", Unknown}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := verdict(tt.portal, tt.device); got != tt.want { + t.Errorf("verdict(%q, %q) = %v, want %v", tt.portal, tt.device, got, tt.want) + } + }) + } +} + +// One session for the whole page, so the same command asked for by two views +// is run once. +func TestCompareCommandsAreDeduplicated(t *testing.T) { + views := []View{ + {Name: "A", Compare: &Compare{Commands: map[string]string{"dev": "dump device status"}}}, + {Name: "B", Compare: &Compare{Commands: map[string]string{ + "same": "dump device status", + "other": "dump spoke-ha status", + }}}, + {Name: "C"}, + } + got := CompareCommands(views) + want := []string{"dump device status", "dump spoke-ha status"} + if strings.Join(got, "|") != strings.Join(want, "|") { + t.Errorf("CompareCommands() = %v, want %v", got, want) + } +} + +// The gate is checked before anything is dialled: asking an unclustered +// appliance about HA costs the same session as asking a clustered one. +func TestCompareViewsSkipWhatTheElementHasNotGot(t *testing.T) { + views := []View{ + {Name: "Always", Compare: &Compare{Commands: map[string]string{"a": "dump device status"}}}, + {Name: "HA", Compare: &Compare{ + When: "{{ .cluster_id }}", + Commands: map[string]string{"a": "dump spoke-ha status"}, + }}, + {Name: "No comparison"}, + } + + clustered := CompareViews(views, Vars{"cluster_id": "123"}) + if len(clustered) != 2 { + t.Errorf("clustered element compares %d views, want both", len(clustered)) + } + spoke := CompareViews(views, Vars{"cluster_id": ""}) + if len(spoke) != 1 || spoke[0].Name != "Always" { + t.Errorf("unclustered element compares %v, want only the ungated view", names(spoke)) + } + if cmds := CompareCommands(spoke); len(cmds) != 1 { + t.Errorf("commands for an unclustered element = %v, want only the ungated one", cmds) + } +} + +func names(views []View) []string { + out := make([]string, 0, len(views)) + for _, v := range views { + out = append(out, v.Name) + } + return out +} + +// The whole comparison: a portal fetch of its own, the device output keyed by +// command, and a verdict per row. +func TestRenderCompareMarksEachRow(t *testing.T) { + set, err := Parse([]byte(` +views: + - name: Check + fetch: + el: /sdwan/v3.2/api/elements/{{ .element_id }} + sections: + - title: Device + rows: + - label: Name + value: "{{ .el.name }}" + compare: + fetch: + el: /sdwan/v3.2/api/elements/{{ .element_id }} + commands: + dev: dump device status + rows: + - label: Serial + portal: "{{ .el.serial_number }}" + device: "{{ kv .cli.dev \"Serial\" }}" + - label: Software + portal: "{{ .el.software_version }}" + device: "{{ kv .cli.dev \"Version\" }}" + - label: Uptime + portal: "{{ .el.uptime }}" + device: "{{ kv .cli.dev \"Uptime\" }}" +`)) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + v := set.Views[0] + + f := &fake{bodies: map[string]string{ + "/sdwan/v3.2/api/elements/1": `{"name":"S0100-RTR-02","serial_number":"ION0001","software_version":"6.3.4-b2"}`, + }} + vars := Vars{"element_id": "1"} + d, err := FetchCompare(t.Context(), f, v, vars) + if err != nil { + t.Fatalf("FetchCompare() error = %v", err) + } + // The comparison reads the portal itself rather than reusing the page: a + // page can be minutes old, and two moments are not a comparison. + if len(f.asked) != 1 { + t.Errorf("asked %v, want the compare block's own fetch", f.asked) + } + + out := DeviceOutput{"dump device status": "Serial\t: ION0001\nVersion\t: 6.3.4-b1\n"} + cmp := RenderCompare(v, d, out) + if len(cmp.Commands) != 1 || cmp.Commands[0] != "dump device status" { + t.Errorf("Commands = %v, want what the device was asked", cmp.Commands) + } + + want := []Compared{ + {Label: "Serial", Portal: "ION0001", Device: "ION0001", Verdict: Agree}, + {Label: "Software", Portal: "6.3.4-b2", Device: "6.3.4-b1", Verdict: Differ}, + {Label: "Uptime", Portal: "", Device: "", Verdict: Unknown}, + } + if len(cmp.Rows) != len(want) { + t.Fatalf("rows = %+v, want %d", cmp.Rows, len(want)) + } + for i, w := range want { + if cmp.Rows[i] != w { + t.Errorf("row %d = %+v, want %+v", i, cmp.Rows[i], w) + } + } +} + +// A portal fetch that fails leaves the device side readable and says why the +// other half is blank. +func TestRenderCompareReportsAFailedFetch(t *testing.T) { + v := View{ + Name: "Check", + Compare: &Compare{ + Fetch: map[string]string{"el": "/sdwan/v3.2/api/elements/{{ .element_id }}"}, + Commands: map[string]string{"dev": "dump device status"}, + Rows: []CompareRow{{ + Label: "Serial", + Portal: "{{ .el.serial_number }}", + Device: `{{ kv .cli.dev "Serial" }}`, + }}, + }, + } + f := &fake{bodies: map[string]string{}} + d, err := FetchCompare(t.Context(), f, v, Vars{"element_id": "1"}) + if err == nil { + t.Fatal("FetchCompare() error = nil, want the 404 reported") + } + + cmp := RenderCompare(v, d, DeviceOutput{"dump device status": "Serial\t: ION0001"}) + if len(cmp.Errs) != 1 || !strings.Contains(cmp.Errs[0], "404") { + t.Errorf("Errs = %v, want the fetch failure", cmp.Errs) + } + row := cmp.Rows[0] + if row.Device != "ION0001" || row.Verdict != Unknown { + t.Errorf("row = %+v, want the device answer kept and nothing to compare it to", row) + } +} + +// A view is editable data and iontk's session can type anything into a +// production appliance, so only the read-only verbs load. +func TestLoadRejectsABadCompareBlock(t *testing.T) { + tests := []struct { + name string + yaml string + want string + }{ + { + name: "a command that writes", + yaml: `views: [{name: X, sections: [{title: T, rows: [{label: L, value: V}]}], compare: {commands: {a: "config interface 1 enabled=false"}, rows: [{label: L, portal: P, device: D}]}}]`, + want: "not read-only", + }, + { + name: "no rows", + yaml: `views: [{name: X, sections: [{title: T, rows: [{label: L, value: V}]}], compare: {commands: {a: "dump device status"}}}]`, + want: "compares nothing", + }, + { + name: "nothing asked of the device", + yaml: `views: [{name: X, sections: [{title: T, rows: [{label: L, value: V}]}], compare: {rows: [{label: L, portal: P, device: D}]}}]`, + want: "without asking the device", + }, + { + name: "one side only", + yaml: `views: [{name: X, sections: [{title: T, rows: [{label: L, value: V}]}], compare: {commands: {a: help}, rows: [{label: Serial, portal: P}]}}]`, + want: "both a portal and a device side", + }, + { + name: "a fetch outside the api", + yaml: `views: [{name: X, sections: [{title: T, rows: [{label: L, value: V}]}], compare: {fetch: {a: /iam/v1/tokens}, commands: {a: help}, rows: [{label: L, portal: P, device: D}]}}]`, + want: "does not start with /sdwan/", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := Parse([]byte(tt.yaml)) + if err == nil { + t.Fatalf("Parse() error = nil, want %q", tt.want) + } + if !strings.Contains(err.Error(), tt.want) { + t.Errorf("Parse() error = %q, want it to mention %q", err, tt.want) + } + }) + } +} + +// The shipped comparisons are data like any other view, and a write verb in +// one would reach an appliance. +func TestBuiltinComparisonsOnlyRead(t *testing.T) { + set, err := Builtin() + if err != nil { + t.Fatalf("Builtin() error = %v", err) + } + var withCompare int + for _, v := range set.Views { + if v.Compare == nil { + continue + } + withCompare++ + for name, cmd := range v.Compare.Commands { + if !ioncli.IsReadOnly(cmd) { + t.Errorf("view %q command %q runs %q, which writes", v.Name, name, cmd) + } + } + } + if withCompare == 0 { + t.Error("no built-in view compares anything, so the keypress has nothing to run") + } +} diff --git a/internal/dataview/fetch.go b/internal/dataview/fetch.go index bafca4b..aac20c5 100644 --- a/internal/dataview/fetch.go +++ b/internal/dataview/fetch.go @@ -53,10 +53,7 @@ func Fetch(ctx context.Context, g Getter, v View, vars Vars) (*Data, error) { d := newData() d.Vars = vars - ctxMap := map[string]any{} - for k, val := range vars { - ctxMap[k] = val - } + ctxMap := varsCtx(vars) for _, name := range sortedKeys(v.Fetch) { path, err := expand(v.Fetch[name], ctxMap) @@ -182,6 +179,16 @@ func itemsAt(ctx map[string]any, path string) ([]any, error) { } } +// varsCtx is the element's ids as a template context, which every template in +// a view starts from. +func varsCtx(vars Vars) map[string]any { + out := make(map[string]any, len(vars)) + for k, val := range vars { + out[k] = val + } + return out +} + func sortedKeys(m map[string]string) []string { out := make([]string, 0, len(m)) for k := range m { diff --git a/internal/dataview/render.go b/internal/dataview/render.go index a4e5a31..0b48007 100644 --- a/internal/dataview/render.go +++ b/internal/dataview/render.go @@ -254,6 +254,60 @@ var funcs = template.FuncMap{ } return list[0] }, + + // The four below read CLI text rather than JSON, for the device side of a + // comparison. An appliance prints "Key: value" lines, so kv is the + // one most rows want. + "lines": func(v any) []any { + return textLines(v, "") + }, + // grep is the lines holding pat, case-insensitively: count over it answers + // "how many tunnels are up". + "grep": func(v any, pat string) []any { + return textLines(v, pat) + }, + // kv is the value of the first line that starts with key, with whatever + // separator the firmware used taken off. Anchored at the line start so a + // key cannot match inside another line's value. + "kv": func(v any, key string) string { + k := strings.ToLower(strings.TrimSpace(key)) + for _, line := range strings.Split(fmt.Sprint(v), "\n") { + line = strings.TrimSpace(line) + if !strings.HasPrefix(strings.ToLower(line), k) { + continue + } + return strings.TrimSpace(strings.TrimLeft(line[len(k):], " \t:=")) + } + return "" + }, + // field is the nth whitespace-separated field, counting from 1, for output + // that is columns rather than keys. + "field": func(v any, n int) string { + f := strings.Fields(fmt.Sprint(v)) + if n < 1 || n > len(f) { + return "" + } + return f[n-1] + }, +} + +// textLines splits CLI output into its non-blank lines, keeping only those +// holding want. The elements are any so count and range treat them the way +// they treat a fetched list. +func textLines(v any, want string) []any { + want = strings.ToLower(want) + var out []any + for _, line := range strings.Split(fmt.Sprint(v), "\n") { + line = strings.TrimRight(line, " \t\r") + if strings.TrimSpace(line) == "" { + continue + } + if want != "" && !strings.Contains(strings.ToLower(line), want) { + continue + } + out = append(out, line) + } + return out } // sameValue compares what came out of two JSON documents. Ids are strings in diff --git a/internal/dataview/spec.go b/internal/dataview/spec.go index 6d0cd6b..a98fa5f 100644 --- a/internal/dataview/spec.go +++ b/internal/dataview/spec.go @@ -16,6 +16,7 @@ import ( "strings" "gopkg.in/yaml.v3" + "tyrtop.com/iontk/internal/sase/ioncli" ) //go:embed builtin.yaml @@ -32,6 +33,35 @@ type View struct { // templates refer to them by. Fetch map[string]string `yaml:"fetch"` Sections []Section `yaml:"sections"` + // Compare is the GUI-vs-CLI half, and the only part of a view that costs a + // device login. It runs on a keypress, never with the page. + Compare *Compare `yaml:"compare"` +} + +// Compare puts what the portal says beside what the appliance's own CLI says. +// It reads the portal again rather than reusing the page: a comparison is only +// worth something if both answers were taken at the same moment. +type Compare struct { + // When gates the whole comparison, same template rules as a section's: an + // element that is not clustered is not worth dialling to ask about HA. + When string `yaml:"when"` + // Fetch is the portal side, read when the comparison runs. + Fetch map[string]string `yaml:"fetch"` + // Commands is name -> CLI command, which .cli. holds the output of. + // Not templated, unlike a path: a command built out of fetched data could + // expand into one that writes, and the read-only check here happens at + // load. + Commands map[string]string `yaml:"commands"` + Rows []CompareRow `yaml:"rows"` +} + +// CompareRow is one question asked of both sources. Each side is a template, +// which is where a difference in spelling between the two gets reconciled; +// the engine only decides whether the answers match. +type CompareRow struct { + Label string `yaml:"label"` + Portal string `yaml:"portal"` + Device string `yaml:"device"` } // Section is either a list of label/value rows or, with for_each, a table over @@ -196,6 +226,9 @@ func (s *Set) normalize() error { return err } } + if err := v.Compare.normalize(v.Name); err != nil { + return err + } } return nil } @@ -226,6 +259,42 @@ func (sec *Section) normalize(view string) error { return nil } +// normalize checks the comparison. The commands are the part that matters: +// they run on an appliance, and a view is the one piece of iontk a person is +// expected to edit, so only the read-only verbs are allowed. +func (c *Compare) normalize(view string) error { + if c == nil { + return nil + } + if len(c.Rows) == 0 { + return fmt.Errorf("view %q compares nothing: a compare block with no rows is a dial for no answer", view) + } + if len(c.Commands) == 0 { + return fmt.Errorf("view %q compares without asking the device anything", view) + } + cmds := make([]string, 0, len(c.Commands)) + for _, name := range sortedKeys(c.Commands) { + cmds = append(cmds, c.Commands[name]) + } + if err := ioncli.CheckReadOnly(cmds); err != nil { + return fmt.Errorf("view %q compare: %w", view, err) + } + for i, r := range c.Rows { + switch { + case r.Label == "": + return fmt.Errorf("view %q compare row %d has no label", view, i+1) + case r.Portal == "" || r.Device == "": + return fmt.Errorf("view %q compare row %q needs both a portal and a device side", view, r.Label) + } + } + for name, p := range c.Fetch { + if err := checkPath(p); err != nil { + return fmt.Errorf("view %q compare fetch %q: %w", view, name, err) + } + } + return nil +} + // checkPath is the gate on what a view may call. A view is user-editable data // and iontk's token can write to production, so the path has to be a read of // the SASE SD-WAN API and nothing else: no host of its own, no method, no diff --git a/internal/tui/compare.go b/internal/tui/compare.go new file mode 100644 index 0000000..79c5ee0 --- /dev/null +++ b/internal/tui/compare.go @@ -0,0 +1,236 @@ +package tui + +import ( + "context" + "fmt" + "strings" + "time" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "tyrtop.com/iontk/internal/app" + "tyrtop.com/iontk/internal/dataview" + "tyrtop.com/iontk/internal/ionterm" + "tyrtop.com/iontk/internal/sase/api" + "tyrtop.com/iontk/internal/sase/ioncli" + "tyrtop.com/iontk/internal/ui" +) + +// compareState is a tab's device half. It is tab-wide rather than per pane +// because every view's commands run in one session, and keyed by view for +// rendering, because each view asks its own questions. +type compareState struct { + loading bool + err string + at time.Time + byView map[string]dataview.Comparison +} + +func (c compareState) ran() bool { return !c.at.IsZero() } + +type compareDoneMsg struct { + eid string + err string + byView map[string]dataview.Comparison +} + +// deviceRunner is the CLI half of a comparison. ToolkitSession is what a batch +// run uses, so this inherits the dial retry and the pager handling instead of +// growing a second copy of either. +type deviceRunner interface { + ToolkitSession(ctx context.Context, eid, prompt, user, pass string, cmds []string) ([]ioncli.CommandOutput, error) +} + +// runnerFor is fixture mode's one substitution, the same place dialerFor makes +// it: the playback device, reached through the real ioncli code, so autologin +// and the pager scraper are exercised rather than skipped. +func (m tuiModel) runnerFor(c *api.Client) deviceRunner { + if app.Fixture != nil { + return app.Fixture.SessionClient(app.Defaults.PagerOff, app.Defaults.PagerPrompts) + } + return c +} + +// startCompare is the explicit keypress. The rest of rich-data mode asks for +// no device login at all, which is the point of the mode; this one action +// dials the appliance and logs in, so it never happens on its own. +func (m tuiModel) startCompare() (tea.Model, tea.Cmd) { + tab, ok := m.activeData() + if !ok || tab.cmp.loading || m.views == nil { + return m, nil + } + + views := dataview.CompareViews(m.views.Views, tab.vars()) + if len(views) == 0 { + m.status = "no view asks this element's CLI anything" + return m, nil + } + c := m.tenantClient(tab.tsg) + if c == nil { + m.status = "could not reach " + m.tenantName(tab.tsg) + return m, nil + } + creds := m.credsFor(tab.tsg) + if creds.User == "" || creds.Pass == "" { + m.status = "no ION login saved for " + m.tenantName(tab.tsg) + " — alt+u adds one" + return m, nil + } + + tab.cmp.loading = true + tab.cmp.err = "" + m.data[m.dataActive] = tab + m.status = "" + return m, tea.Batch(compareCmd(m.ctx, m.runnerFor(c), c, views, tab, creds), m.spinner.Tick) +} + +// compareCmd runs the device commands in one session, then reads the portal +// side. The portal is fetched again rather than taken off the page: a page can +// be minutes old, and a comparison between two different moments is not one. +func compareCmd(ctx context.Context, r deviceRunner, g dataview.Getter, views []dataview.View, t dataTab, creds ionterm.Credentials) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(ctx, app.Defaults.ElementTimeout) + defer cancel() + + msg := compareDoneMsg{eid: t.eid, byView: map[string]dataview.Comparison{}} + outs, err := r.ToolkitSession(ctx, t.eid, t.name+"#", creds.User, creds.Pass, dataview.CompareCommands(views)) + if err != nil { + msg.err = err.Error() + return msg + } + + out := dataview.DeviceOutput{} + for _, o := range outs { + out[o.Command] = o.Output + } + for _, v := range views { + d, err := dataview.FetchCompare(ctx, g, v, t.vars()) + cmp := dataview.RenderCompare(v, d, out) + if err != nil { + cmp.Errs = append(cmp.Errs, err.Error()) + } + msg.byView[v.Name] = cmp + } + return msg + } +} + +func (m tuiModel) onCompareDone(msg compareDoneMsg) (tea.Model, tea.Cmd) { + for i := range m.data { + if m.data[i].eid != msg.eid { + continue + } + m.data[i].cmp = compareState{err: msg.err, at: time.Now(), byView: msg.byView} + if msg.err == "" { + m.status = compareSummary(msg.byView) + } + return m, nil + } + return m, nil +} + +// compareSummary is what the footer says once a comparison lands, because the +// marks that matter may be further down the page than the window reaches. +func compareSummary(byView map[string]dataview.Comparison) string { + var differ, unknown, total int + for _, cmp := range byView { + for _, row := range cmp.Rows { + total++ + switch row.Verdict { + case dataview.Differ: + differ++ + case dataview.Unknown: + unknown++ + } + } + } + switch { + case total == 0: + return "" + case differ > 0: + return fmt.Sprintf("%d of %d CLI checks disagree with the portal", differ, total) + case unknown == total: + return "the device answered none of the CLI checks" + default: + return fmt.Sprintf("the device agrees on all %d CLI checks", total-unknown) + } +} + +// comparePane is the device half under a view's own sections, so a reading and +// its check are read together. Its heading is a jump target like any other. +func (m tuiModel) comparePane(p *dataPage, tab dataTab, view string, width int) { + if m.views == nil { + return + } + v, ok := m.views.View(view) + if !ok || len(dataview.CompareViews([]dataview.View{v}, tab.vars())) == 0 { + return + } + + head := " " + ui.StyleCmd.Render("CLI check") + switch { + case tab.cmp.loading: + p.head(head + " " + ui.StyleDim.Render(m.spinner.View()+" asking the device")) + return + case tab.cmp.err != "": + p.head(head) + p.lines = append(p.lines, " "+ui.StyleErr.Render(ui.Truncate(tab.cmp.err, width-2))) + return + } + + cmp, ok := tab.cmp.byView[view] + if !ok || !tab.cmp.ran() { + p.head(head + " " + ui.StyleDim.Render("· c asks the device and compares")) + return + } + + detail := "· " + strings.Join(cmp.Commands, " · ") + if age := ui.Age(time.Since(tab.cmp.at)); age != "" { + detail += " · " + age + " old" + } + p.head(head + " " + ui.StyleDim.Render(ui.Truncate(detail, max(10, width-14)))) + p.lines = append(p.lines, m.compareLines(cmp, width)...) +} + +// compareLines lays each row out as portal then device with a mark. Padded +// plain and styled afterwards: padding a styled cell counts its escape bytes +// toward the width and the columns drift apart row by row. +func (m tuiModel) compareLines(cmp dataview.Comparison, width int) []string { + colw := max(10, (width-ui.LabelWidth-8)/2) + cell := func(s string, w int) string { return fmt.Sprintf("%-*s", w, ui.Truncate(s, w)) } + + // Four columns of gutter: the mark and its space sit where the header has + // nothing to say. + out := []string{ui.StyleDim.Render(fmt.Sprintf(" %s %s %s", + cell("CHECK", ui.LabelWidth), cell("PORTAL", colw), "DEVICE"))} + for _, row := range cmp.Rows { + mark, style := compareMark(row.Verdict) + // Only the device cell takes the verdict's colour: it is the answer + // the mark is about, and colouring both makes a row shout twice. + out = append(out, fmt.Sprintf(" %s %s %s %s", mark, + ui.StyleDim.Render(cell(row.Label, ui.LabelWidth)), + cell(dashed(row.Portal), colw), + style.Render(ui.Truncate(dashed(row.Device), colw)))) + } + for _, e := range cmp.Errs { + out = append(out, " "+ui.StyleWarn.Render(ui.Truncate(e, width-2))) + } + return out +} + +func compareMark(v dataview.Verdict) (string, lipgloss.Style) { + switch v { + case dataview.Agree: + return ui.MarkCheck.String(), ui.StyleOK + case dataview.Differ: + return ui.MarkCross.String(), ui.StyleErr + } + // Nothing to compare, which is not a disagreement. + return ui.StyleDim.Render("·"), ui.StyleDim +} + +func dashed(s string) string { + if strings.TrimSpace(s) == "" { + return "—" + } + return s +} diff --git a/internal/tui/compare_test.go b/internal/tui/compare_test.go new file mode 100644 index 0000000..e0dcd14 --- /dev/null +++ b/internal/tui/compare_test.go @@ -0,0 +1,237 @@ +package tui + +import ( + "os" + "path/filepath" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "tyrtop.com/iontk/internal/config" + "tyrtop.com/iontk/internal/dataview" +) + +// A device that answers one question the way the portal does and one it does +// not, which is the whole point of the comparison. +const compareFixtureYAML = ` +tenants: + - name: Acme Co + tsg_id: "000000000" + users: + - name: admin + password: ion + elements: + - name: store-0003-rtr-01 + model: ion 1200 + site: Store 0003 + connected: true + responses: + "dump device status": | + Name : store-0003-rtr-01 + Model : ion 9999 +` + +// The view the test compares with. It takes the name of a built-in, which +// replaces it: the shipped Overview comparison asks about fields the fixture +// derives from an element's name, and this one asks about the two the fixture +// file above states outright. +const compareViewYAML = ` +views: + - name: Overview + fetch: + el: /sdwan/v3.2/api/elements/{{ .element_id }} + sections: + - title: Device + rows: + - label: Name + value: "{{ .el.name }}" + compare: + fetch: + el: /sdwan/v3.2/api/elements/{{ .element_id }} + commands: + dev: dump device status + rows: + - label: Name + portal: "{{ .el.name }}" + device: "{{ kv .cli.dev \"Name\" }}" + - label: Model + portal: "{{ .el.model_name }}" + device: "{{ kv .cli.dev \"Model\" }}" + - label: Serial + portal: "{{ .el.serial_number }}" + device: "{{ kv .cli.dev \"Serial\" }}" +` + +// The explicit keypress end to end: one session to the playback device, the +// portal read again beside it, and a mark per row. +func TestCompareAsksTheDeviceAndMarksEachRow(t *testing.T) { + useViews(t, compareViewYAML) + m, set := fixtureTUIWith(t, compareFixtureYAML) + m.phase = phasePicking + m.mode = modeData + m.rebuildRows() + + el := set.Tenants[0].Elements[0] + next, cmd := m.openDataTabs([]sessionTarget{{eid: el.ID, name: el.Name, tsg: m.tenant.TSGID}}) + m = runDataLoads(t, next.(tuiModel), cmd) + + // Nothing has been asked of the device yet: the portal half of the mode + // is credential-free, and the page says what the key is for. + if got := plain(m.dataPage(m.data[0]).String()); !strings.Contains(got, "c asks the device") { + t.Errorf("the page does not offer the comparison:\n%s", got) + } + + next, cmd = m.onDataKey("c") + m = next.(tuiModel) + if !m.data[0].cmp.loading { + t.Fatal("c did not start a comparison") + } + m = runCompare(t, m, cmd) + + if m.data[0].cmp.err != "" { + t.Fatalf("comparison failed: %s", m.data[0].cmp.err) + } + page := plain(m.dataPage(m.data[0]).String()) + for _, want := range []string{ + "CLI check", "dump device status", + "CHECK", "PORTAL", "DEVICE", + "✓ Name", // both sides say store-0003-rtr-01 + "✗ Model", // the portal says ion 1200, the device ion 9999 + "· Serial", // the device prints no serial, which is not a disagreement + "ion 9999", + } { + if !strings.Contains(page, want) { + t.Errorf("the comparison is missing %q:\n%s", want, page) + } + } + // The marks may be below the window, so the footer carries the count. + if got := m.status; !strings.Contains(got, "1 of 3") { + t.Errorf("status = %q, want the number of disagreements", got) + } +} + +// A comparison logs in to the appliance, so it is the one action in this mode +// that needs a device credential, and it says so rather than dialling. +func TestCompareWithoutADeviceLoginAsksForOne(t *testing.T) { + useViews(t, compareViewYAML) + m, set := fixtureTUIWith(t, compareFixtureYAML) + m.phase = phasePicking + m.mode = modeData + m.rebuildRows() + + el := set.Tenants[0].Elements[0] + next, cmd := m.openDataTabs([]sessionTarget{{eid: el.ID, name: el.Name, tsg: m.tenant.TSGID}}) + m = runDataLoads(t, next.(tuiModel), cmd) + + if err := m.store.Secrets.Delete(config.IONPassKey(m.tenant.TSGID, "admin")); err != nil { + t.Fatal(err) + } + + next, cmd = m.onDataKey("c") + m = next.(tuiModel) + if cmd != nil { + t.Error("c dialled the element with no login saved") + } + if m.data[0].cmp.loading { + t.Error("the tab is waiting for a comparison that was never started") + } + if !strings.Contains(m.status, "no ION login") { + t.Errorf("status = %q, want it to name what is missing", m.status) + } +} + +// The gate is checked before the dial: an element that is not clustered is +// never asked about HA, and the heading does not appear on its page either. +func TestComparisonsTheElementCannotAnswerAreNotOffered(t *testing.T) { + m, set := fixtureTUIWith(t, haFixtureYAML) + m.phase = phasePicking + m.mode = modeData + m.rebuildRows() + + // The HA element of the pair, then a plain spoke in another tenant-less + // site: the fixture file marks only the first two as clustered. + clustered := set.Tenants[0].Elements[0] + next, cmd := m.openDataTabs([]sessionTarget{{eid: clustered.ID, name: clustered.Name, tsg: m.tenant.TSGID}}) + m = runDataLoads(t, next.(tuiModel), cmd) + if got := strings.Count(plain(m.dataPage(m.data[0]).String()), "CLI check"); got != 2 { + t.Errorf("%d comparisons offered on a clustered element, want both built-ins", got) + } + + m2, set2 := fixtureTUIWith(t, fixtureYAML) + m2.phase = phasePicking + m2.mode = modeData + m2.rebuildRows() + spoke := set2.Tenants[0].Elements[0] + next, cmd = m2.openDataTabs([]sessionTarget{{eid: spoke.ID, name: spoke.Name, tsg: m2.tenant.TSGID}}) + m2 = runDataLoads(t, next.(tuiModel), cmd) + + page := plain(m2.dataPage(m2.data[0]).String()) + if got := strings.Count(page, "CLI check"); got != 1 { + t.Errorf("%d comparisons offered on an unclustered element, want only the one that applies:\n%s", got, page) + } + applies := dataview.CompareViews(m2.views.Views, m2.data[0].vars()) + if len(dataview.CompareCommands(applies)) != 1 { + t.Error("the HA command would still be run on an element with no cluster") + } +} + +// A dial that fails is reported on the page rather than taking it down: the +// portal half is still good. +func TestAFailedComparisonLeavesThePageReadable(t *testing.T) { + useViews(t, compareViewYAML) + m, set := fixtureTUIWith(t, compareFixtureYAML) + m.phase = phasePicking + m.mode = modeData + m.rebuildRows() + + el := set.Tenants[0].Elements[0] + next, cmd := m.openDataTabs([]sessionTarget{{eid: el.ID, name: el.Name, tsg: m.tenant.TSGID}}) + m = runDataLoads(t, next.(tuiModel), cmd) + + next, _ = m.Update(compareDoneMsg{eid: el.ID, err: "dial: expected handshake response status 101 but got 404"}) + m = next.(tuiModel) + + page := plain(m.dataPage(m.data[0]).String()) + for _, want := range []string{"store-0003-rtr-01", "CLI check", "got 404"} { + if !strings.Contains(page, want) { + t.Errorf("the page is missing %q:\n%s", want, page) + } + } +} + +// useViews points --views at a file for the length of one test, which is also +// how a --fixtures run gets views of its own. +func useViews(t *testing.T, body string) { + t.Helper() + path := filepath.Join(t.TempDir(), "views.yaml") + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + Views = path + t.Cleanup(func() { Views = "" }) +} + +// runCompare runs the comparison the keypress queued and feeds the answer +// back in, the way the runtime would when the session finishes. +func runCompare(t *testing.T, m tuiModel, cmd tea.Cmd) tuiModel { + t.Helper() + if cmd == nil { + t.Fatal("no command to run") + } + // Run them one at a time and stop at the comparison: the batch also holds + // the spinner tick, which would sit out a frame for nothing. + cmds := []tea.Cmd{cmd} + if batch, ok := cmd().(tea.BatchMsg); ok { + cmds = batch + } + for _, c := range cmds { + done, ok := c().(compareDoneMsg) + if !ok { + continue + } + next, _ := m.Update(done) + return next.(tuiModel) + } + t.Fatal("nothing the keypress queued compared anything") + return m +} diff --git a/internal/tui/data.go b/internal/tui/data.go index f8b85eb..3dd254f 100644 --- a/internal/tui/data.go +++ b/internal/tui/data.go @@ -50,6 +50,10 @@ type dataTab struct { panes []dataPane at time.Time scroll int + + // The GUI-vs-CLI comparison, which exists only once asked for: it is the + // one thing in this mode that dials the element. + cmp compareState } type dataPane struct { @@ -298,6 +302,8 @@ func (m tuiModel) onDataKey(key string) (tea.Model, tea.Cmd) { return m, nil case "r": return m.refreshData() + case "c": + return m.startCompare() } tab, ok := m.activeData() @@ -416,6 +422,7 @@ func (m tuiModel) dataPage(tab dataTab) dataPage { for _, e := range pane.rendered.Errs { p.lines = append(p.lines, " "+ui.StyleWarn.Render(ui.Truncate(e, width-2))) } + m.comparePane(&p, tab, pane.view, width) } return p } @@ -536,7 +543,7 @@ func (m tuiModel) viewData() string { } status = scrolled + status } - hint := "alt+↑↓ next heading · ↑↓ scroll · r refresh · alt+←/→ tab · alt+k close · ctrl+] elements" + hint := "alt+↑↓ next heading · ↑↓ scroll · r refresh · c CLI check · alt+←/→ tab · alt+k close · ctrl+] elements" width := max(10, m.width-1) b.WriteString(ui.Footer(ui.Truncate(status, width), ui.Truncate(hint, width))) return b.String() diff --git a/internal/tui/data_test.go b/internal/tui/data_test.go index 926a959..77348b1 100644 --- a/internal/tui/data_test.go +++ b/internal/tui/data_test.go @@ -87,7 +87,9 @@ func TestRichDataModeLoadsEveryViewOntoOnePage(t *testing.T) { // The pane shows the top of it, says which element and how much there is. got := plain(m.viewData()) - for _, want := range []string{"store-0003-rtr-01", "Store 0003", "4 views", "Overview", "of 5"} { + // The window onto the page, not its length: the page grows whenever a + // view does. + for _, want := range []string{"store-0003-rtr-01", "Store 0003", "4 views", "Overview", "1-24 of"} { if !strings.Contains(got, want) { t.Errorf("the pane is missing %q:\n%s", want, got) } diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 9d59ac8..9b78bdb 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -856,6 +856,9 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case dataLoadedMsg: return m.onDataLoaded(msg) + case compareDoneMsg: + return m.onCompareDone(msg) + case tea.KeyPressMsg: return m.onKey(msg) @@ -922,8 +925,10 @@ func (m tuiModel) wantsSpinner() bool { return m.connectingAny() case phaseData: // Only while a pane is actually reading, or it repaints for nothing. + // A comparison counts: it is a dial and a login, and the slowest + // thing the mode does. for _, t := range m.data { - if t.loading() { + if t.loading() || t.cmp.loading { return true } } From 7dc42cb9c8f849896653fed59559154728a3b555 Mon Sep 17 00:00:00 2001 From: lemo-o <21benjaminhall@gmail.com> Date: Mon, 19 Oct 2026 10:23:22 -0600 Subject: [PATCH 2/4] see pr notes --- internal/config/commands.go | 281 +++++++++++++++++ internal/config/commands_test.go | 292 ++++++++++++++++++ internal/config/reset.go | 3 +- internal/config/reset_test.go | 12 + internal/dataview/builtin.yaml | 13 +- internal/ionterm/manager.go | 51 ++++ internal/ionterm/session.go | 11 + internal/tui/commands.go | 179 +++++++++++ internal/tui/commands_test.go | 282 ++++++++++++++++++ internal/tui/connecting.go | 57 +++- internal/tui/connecting_test.go | 97 ++++++ internal/tui/data.go | 2 +- internal/tui/data_test.go | 32 +- internal/tui/favourites.go | 203 +++++++++++++ internal/tui/favourites_test.go | 247 +++++++++++++++ internal/tui/filter_test.go | 130 ++++++++ internal/tui/ha.go | 149 ++++++++++ internal/tui/ha_test.go | 111 +++++++ internal/tui/macros.go | 461 ++++++++++++++++++++++++++++ internal/tui/macros_test.go | 496 +++++++++++++++++++++++++++++++ internal/tui/quitkeys_test.go | 4 + internal/tui/results.go | 155 ++++++++-- internal/tui/results_test.go | 48 ++- internal/tui/session.go | 112 +++++-- internal/tui/session_test.go | 195 +++++++++++- internal/tui/slash.go | 84 +++++- internal/tui/tui.go | 266 +++++++++++++++-- internal/tui/tuimenu.go | 46 ++- internal/tui/tuimenu_test.go | 6 +- internal/ui/styles.go | 9 + internal/ui/text.go | 4 + 31 files changed, 3915 insertions(+), 123 deletions(-) create mode 100644 internal/config/commands.go create mode 100644 internal/config/commands_test.go create mode 100644 internal/tui/commands.go create mode 100644 internal/tui/commands_test.go create mode 100644 internal/tui/favourites.go create mode 100644 internal/tui/favourites_test.go create mode 100644 internal/tui/ha.go create mode 100644 internal/tui/ha_test.go create mode 100644 internal/tui/macros.go create mode 100644 internal/tui/macros_test.go diff --git a/internal/config/commands.go b/internal/config/commands.go new file mode 100644 index 0000000..263c9a5 --- /dev/null +++ b/internal/config/commands.go @@ -0,0 +1,281 @@ +package config + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +// HistoryMax bounds the file. History is written on every command a session +// sends, so without a cap it grows for the life of the install; the least +// recently used entries go first, which keeps a rarely-run favourite that is +// still in use over a one-off typed last month. +const HistoryMax = 200 + +// FavouriteSlots is alt+1 to alt+9. +const FavouriteSlots = 9 + +// CommandStore is history, favourites and macros in one file. They share it +// because they are the same thing at different lifetimes: something typed +// once, something worth a key, and something worth a name. +// +// It is separate from config.json because it churns -- a line per command sent +// -- and a tenant list should not be rewritten for that. +type CommandStore struct { + History []CommandUse `json:"history,omitempty"` + Favourites []Favourite `json:"favourites,omitempty"` + Macros []Macro `json:"macros,omitempty"` + // Acknowledged is the warnings dismissed for good, by ID rather than a + // field apiece: this is the first of several, and a factory reset takes + // them with the rest of the file. + Acknowledged []string `json:"acknowledged,omitempty"` +} + +// CommandUse is one command as run on one model. The model is part of the +// identity rather than a label: `dump interface status 5` is a different +// command on an ion 1200, which has no port 5. +type CommandUse struct { + Command string `json:"command"` + Model string `json:"model,omitempty"` + Uses int `json:"uses"` + LastUsed time.Time `json:"last_used"` +} + +type Favourite struct { + Slot int `json:"slot"` + Command string `json:"command"` + // Model is what the command was captured on, so one that only exists on + // that model can be flagged rather than silently failing elsewhere. + Model string `json:"model,omitempty"` +} + +type Macro struct { + Name string `json:"name"` + Commands []string `json:"commands"` + // Model is kept for the same reason a favourite's is. + Model string `json:"model,omitempty"` +} + +// Record counts one use. The clock is passed in so a caller can be tested +// without sleeping. +func (c *CommandStore) Record(command, model string, at time.Time) { + command = strings.TrimSpace(command) + if c == nil || command == "" { + return + } + for i := range c.History { + if c.History[i].Command == command && c.History[i].Model == model { + c.History[i].Uses++ + c.History[i].LastUsed = at + return + } + } + c.History = append(c.History, CommandUse{Command: command, Model: model, Uses: 1, LastUsed: at}) + c.trimHistory() +} + +func (c *CommandStore) trimHistory() { + if len(c.History) <= HistoryMax { + return + } + sort.SliceStable(c.History, func(i, j int) bool { + return c.History[i].LastUsed.After(c.History[j].LastUsed) + }) + c.History = c.History[:HistoryMax] +} + +// Ranked is the history to offer on a model: what has been run on this one +// most often first, then what was run on another. The second group is still +// worth offering -- most commands are the same across models -- and is what +// Model is kept to tell apart. +func (c *CommandStore) Ranked(model string) []CommandUse { + if c == nil { + return nil + } + out := make([]CommandUse, len(c.History)) + copy(out, c.History) + sort.SliceStable(out, func(i, j int) bool { + a, b := out[i], out[j] + if mine := a.Model == model; mine != (b.Model == model) { + return mine + } + if a.Uses != b.Uses { + return a.Uses > b.Uses + } + if !a.LastUsed.Equal(b.LastUsed) { + return a.LastUsed.After(b.LastUsed) + } + return a.Command < b.Command + }) + return out +} + +// Forget drops one entry, which is what makes a list of mistyped commands +// recoverable. +func (c *CommandStore) Forget(command, model string) bool { + if c == nil { + return false + } + for i := range c.History { + if c.History[i].Command == command && c.History[i].Model == model { + c.History = append(c.History[:i], c.History[i+1:]...) + return true + } + } + return false +} + +func (c *CommandStore) SetFavourite(slot int, command, model string) error { + command = strings.TrimSpace(command) + switch { + case c == nil: + return fmt.Errorf("no command store") + case slot < 1 || slot > FavouriteSlots: + return fmt.Errorf("slot %d is outside 1-%d", slot, FavouriteSlots) + case command == "": + return fmt.Errorf("slot %d: nothing to save", slot) + } + for i := range c.Favourites { + if c.Favourites[i].Slot == slot { + c.Favourites[i] = Favourite{Slot: slot, Command: command, Model: model} + return nil + } + } + c.Favourites = append(c.Favourites, Favourite{Slot: slot, Command: command, Model: model}) + return nil +} + +func (c *CommandStore) Favourite(slot int) (Favourite, bool) { + if c == nil { + return Favourite{}, false + } + for _, f := range c.Favourites { + if f.Slot == slot { + return f, true + } + } + return Favourite{}, false +} + +func (c *CommandStore) ClearFavourite(slot int) bool { + if c == nil { + return false + } + for i := range c.Favourites { + if c.Favourites[i].Slot == slot { + c.Favourites = append(c.Favourites[:i], c.Favourites[i+1:]...) + return true + } + } + return false +} + +// FavouriteList is every slot that is filled, in slot order: the file keeps +// them in the order they were set. +func (c *CommandStore) FavouriteList() []Favourite { + if c == nil { + return nil + } + out := make([]Favourite, len(c.Favourites)) + copy(out, c.Favourites) + sort.Slice(out, func(i, j int) bool { return out[i].Slot < out[j].Slot }) + return out +} + +func (c *CommandStore) SetMacro(m Macro) error { + m.Name = strings.TrimSpace(m.Name) + switch { + case c == nil: + return fmt.Errorf("no command store") + case m.Name == "": + return fmt.Errorf("a macro needs a name") + case len(m.Commands) == 0: + return fmt.Errorf("macro %q has no commands", m.Name) + } + for i := range c.Macros { + if strings.EqualFold(c.Macros[i].Name, m.Name) { + c.Macros[i] = m + return nil + } + } + c.Macros = append(c.Macros, m) + return nil +} + +func (c *CommandStore) Macro(name string) (Macro, bool) { + if c == nil { + return Macro{}, false + } + for _, m := range c.Macros { + if strings.EqualFold(m.Name, name) { + return m, true + } + } + return Macro{}, false +} + +func (c *CommandStore) RemoveMacro(name string) bool { + if c == nil { + return false + } + for i := range c.Macros { + if strings.EqualFold(c.Macros[i].Name, name) { + c.Macros = append(c.Macros[:i], c.Macros[i+1:]...) + return true + } + } + return false +} + +// Ack records a warning as answered for good. +func (c *CommandStore) Ack(id string) { + if c == nil || id == "" || c.Acked(id) { + return + } + c.Acknowledged = append(c.Acknowledged, id) +} + +func (c *CommandStore) Acked(id string) bool { + if c == nil { + return false + } + for _, a := range c.Acknowledged { + if a == id { + return true + } + } + return false +} + +func (s *Store) commandsPath() string { return filepath.Join(s.Root, "commands.json") } + +// Commands returns the saved store, or an empty one. A first run has no file, +// which is not an error. +func (s *Store) Commands() (*CommandStore, error) { + b, err := os.ReadFile(s.commandsPath()) + if os.IsNotExist(err) { + return &CommandStore{}, nil + } + if err != nil { + return &CommandStore{}, fmt.Errorf("read commands: %w", err) + } + var c CommandStore + if err := json.Unmarshal(b, &c); err != nil { + // The caller gets a usable store either way: a corrupt history should + // cost the history, not the session someone is trying to open. + return &CommandStore{}, fmt.Errorf("parse commands: %w", err) + } + return &c, nil +} + +func (s *Store) SaveCommands(c *CommandStore) error { + b, err := json.MarshalIndent(c, "", " ") + if err != nil { + return fmt.Errorf("encode commands: %w", err) + } + return writeAtomic(s.commandsPath(), append(b, '\n'), 0o600) +} diff --git a/internal/config/commands_test.go b/internal/config/commands_test.go new file mode 100644 index 0000000..0dc6a01 --- /dev/null +++ b/internal/config/commands_test.go @@ -0,0 +1,292 @@ +package config + +import ( + "os" + "strconv" + "strings" + "testing" + "time" +) + +var epoch = time.Date(2026, 10, 19, 12, 0, 0, 0, time.UTC) + +func at(min int) time.Time { return epoch.Add(time.Duration(min) * time.Minute) } + +// The same command on the same model is one entry with a count; on another +// model it is a different command, since the ports and subsystems differ. +func TestRecordCountsUsesPerModel(t *testing.T) { + var c CommandStore + c.Record("dump device status", "ion 1200", at(0)) + c.Record("dump device status", "ion 1200", at(1)) + c.Record("dump device status", "ion 3200", at(2)) + // Typed with trailing space, which is what a device line looks like after + // the palette inserts a completion. + c.Record(" dump device status ", "ion 1200", at(3)) + c.Record("", "ion 1200", at(4)) + + if len(c.History) != 2 { + t.Fatalf("history = %+v, want one entry per model", c.History) + } + got, ok := find(c.History, "dump device status", "ion 1200") + if !ok { + t.Fatal("the 1200's entry is missing") + } + if got.Uses != 3 { + t.Errorf("uses = %d, want 3 — the padded one is the same command", got.Uses) + } + if !got.LastUsed.Equal(at(3)) { + t.Errorf("last used = %v, want the most recent run at %v", got.LastUsed, at(3)) + } +} + +func TestRankedPutsThisModelsCommandsFirst(t *testing.T) { + var c CommandStore + // Run far more often, but on another model. + for i := 0; i < 5; i++ { + c.Record("dump interface status 8", "ion 3200", at(i)) + } + c.Record("dump device status", "ion 1200", at(10)) + c.Record("dump device status", "ion 1200", at(11)) + c.Record("ping 10.0.0.1", "ion 1200", at(12)) + + want := []string{ + "dump device status", // this model, two uses + "ping 10.0.0.1", // this model, one + "dump interface status 8", // another model, however often it was run + } + var got []string + for _, u := range c.Ranked("ion 1200") { + got = append(got, u.Command) + } + if strings.Join(got, "|") != strings.Join(want, "|") { + t.Errorf("Ranked(ion 1200) = %v, want %v", got, want) + } + + // Ranking does not reorder what is stored, so the file does not churn on + // a read. + if c.History[0].Command != "dump interface status 8" { + t.Errorf("Ranked rewrote the history order: %+v", c.History) + } +} + +// A history that grows for the life of the install would be a file nobody can +// read and a palette nobody can use. +func TestHistoryIsBounded(t *testing.T) { + var c CommandStore + for i := 0; i < HistoryMax+10; i++ { + c.Record("ping 10.0.0."+strconv.Itoa(i), "ion 1200", at(i)) + } + if len(c.History) > HistoryMax { + t.Fatalf("history = %d entries, want at most %d", len(c.History), HistoryMax) + } + // The oldest went, not the newest: an entry recorded a moment ago is the + // one most likely to be wanted again. + if _, ok := find(c.History, "ping 10.0.0.0", "ion 1200"); ok { + t.Error("the oldest entry survived the trim") + } + if _, ok := find(c.History, "ping 10.0.0."+strconv.Itoa(HistoryMax+9), "ion 1200"); !ok { + t.Error("the newest entry was trimmed") + } +} + +func TestForgetDropsOneEntry(t *testing.T) { + var c CommandStore + c.Record("dump device status", "ion 1200", at(0)) + c.Record("dump device status", "ion 3200", at(1)) + + if !c.Forget("dump device status", "ion 1200") { + t.Fatal("Forget() = false, want the entry removed") + } + if _, ok := find(c.History, "dump device status", "ion 1200"); ok { + t.Error("the entry survived") + } + // The other model's entry is a different command and stays. + if _, ok := find(c.History, "dump device status", "ion 3200"); !ok { + t.Error("forgetting one model's entry took the other's") + } + if c.Forget("never typed", "ion 1200") { + t.Error("Forget() = true for something that was never recorded") + } +} + +func TestFavouriteSlots(t *testing.T) { + var c CommandStore + if err := c.SetFavourite(1, "dump device status", "ion 1200"); err != nil { + t.Fatalf("SetFavourite() error = %v", err) + } + // Setting an occupied slot replaces it rather than stacking. + if err := c.SetFavourite(1, "dump vpn count", "ion 3200"); err != nil { + t.Fatalf("SetFavourite() error = %v", err) + } + if err := c.SetFavourite(9, "uptime", ""); err != nil { + t.Fatalf("SetFavourite() error = %v", err) + } + + f, ok := c.Favourite(1) + if !ok || f.Command != "dump vpn count" || f.Model != "ion 3200" { + t.Errorf("slot 1 = %+v, want the command that replaced it", f) + } + if list := c.FavouriteList(); len(list) != 2 || list[0].Slot != 1 || list[1].Slot != 9 { + t.Errorf("FavouriteList() = %+v, want slot order", list) + } + if _, ok := c.Favourite(4); ok { + t.Error("an empty slot answered") + } + if !c.ClearFavourite(1) { + t.Error("ClearFavourite() = false, want the slot emptied") + } + if _, ok := c.Favourite(1); ok { + t.Error("the cleared slot still answers") + } + + for _, tc := range []struct { + name string + slot int + cmd string + want string + }{ + {"slot zero", 0, "uptime", "outside 1-9"}, + {"past the last slot", 10, "uptime", "outside 1-9"}, + {"nothing to save", 2, " ", "nothing to save"}, + } { + t.Run(tc.name, func(t *testing.T) { + err := c.SetFavourite(tc.slot, tc.cmd, "") + if err == nil { + t.Fatalf("SetFavourite(%d, %q) error = nil, want %q", tc.slot, tc.cmd, tc.want) + } + if !strings.Contains(err.Error(), tc.want) { + t.Errorf("error = %q, want it to mention %q", err, tc.want) + } + }) + } +} + +func TestMacrosAreKeptByName(t *testing.T) { + var c CommandStore + m := Macro{Name: "wan check", Commands: []string{"dump interface status all"}, Model: "ion 1200"} + if err := c.SetMacro(m); err != nil { + t.Fatalf("SetMacro() error = %v", err) + } + // Same name, different case: one macro, replaced. + m.Name = "WAN Check" + m.Commands = append(m.Commands, "dump vpn count") + if err := c.SetMacro(m); err != nil { + t.Fatalf("SetMacro() error = %v", err) + } + if len(c.Macros) != 1 { + t.Fatalf("macros = %+v, want one", c.Macros) + } + got, ok := c.Macro("wan check") + if !ok || len(got.Commands) != 2 { + t.Errorf("Macro() = %+v, %v, want the replacement", got, ok) + } + + for _, tc := range []struct { + name string + macro Macro + want string + }{ + {"no name", Macro{Commands: []string{"uptime"}}, "needs a name"}, + {"no commands", Macro{Name: "empty"}, "no commands"}, + } { + t.Run(tc.name, func(t *testing.T) { + if err := c.SetMacro(tc.macro); err == nil || !strings.Contains(err.Error(), tc.want) { + t.Errorf("SetMacro(%+v) error = %v, want %q", tc.macro, err, tc.want) + } + }) + } + + if !c.RemoveMacro("WAN CHECK") { + t.Error("RemoveMacro() = false, want it gone") + } + if _, ok := c.Macro("wan check"); ok { + t.Error("the macro survived") + } +} + +// Keyed by ID rather than a field per warning, because the cross-model one is +// the first of several. +func TestAcknowledgedWarningsAreKeptByID(t *testing.T) { + var c CommandStore + if c.Acked("favourite/model") { + t.Error("an unanswered warning reads as acknowledged") + } + c.Ack("favourite/model") + c.Ack("favourite/model") + if !c.Acked("favourite/model") { + t.Error("the warning was not recorded") + } + if len(c.Acknowledged) != 1 { + t.Errorf("acknowledged = %v, want one entry", c.Acknowledged) + } + if c.Acked("macro/writes") { + t.Error("acknowledging one warning answered another") + } +} + +func TestCommandsRoundTripThroughTheStore(t *testing.T) { + root := t.TempDir() + st := OpenAt(root, NewMemorySecrets()) + + // A first run has no file, which is not an error. + c, err := st.Commands() + if err != nil { + t.Fatalf("Commands() on a fresh root error = %v", err) + } + if len(c.History) != 0 { + t.Errorf("history = %+v, want empty", c.History) + } + + c.Record("dump device status", "ion 1200", at(0)) + if err := c.SetFavourite(3, "dump vpn count", "ion 1200"); err != nil { + t.Fatal(err) + } + if err := c.SetMacro(Macro{Name: "checks", Commands: []string{"uptime"}}); err != nil { + t.Fatal(err) + } + c.Ack("favourite/model") + if err := st.SaveCommands(c); err != nil { + t.Fatalf("SaveCommands() error = %v", err) + } + + back, err := st.Commands() + if err != nil { + t.Fatalf("Commands() error = %v", err) + } + if len(back.History) != 1 || back.History[0].Uses != 1 { + t.Errorf("history = %+v, want the recorded use", back.History) + } + if f, ok := back.Favourite(3); !ok || f.Command != "dump vpn count" { + t.Errorf("slot 3 = %+v, %v, want what was saved", f, ok) + } + if _, ok := back.Macro("checks"); !ok { + t.Error("the macro did not survive the round trip") + } + if !back.Acked("favourite/model") { + t.Error("the acknowledgement did not survive the round trip") + } + + // A corrupt file costs the history, not the session someone is opening. + if err := os.WriteFile(st.commandsPath(), []byte("{not json"), 0o600); err != nil { + t.Fatal(err) + } + broken, err := st.Commands() + if err == nil { + t.Error("Commands() error = nil on a corrupt file") + } + if broken == nil { + t.Fatal("Commands() = nil on a corrupt file, want an empty store") + } + if err := broken.SetFavourite(1, "uptime", ""); err != nil { + t.Errorf("the store handed back after a parse failure is unusable: %v", err) + } +} + +func find(hist []CommandUse, command, model string) (CommandUse, bool) { + for _, u := range hist { + if u.Command == command && u.Model == model { + return u, true + } + } + return CommandUse{}, false +} diff --git a/internal/config/reset.go b/internal/config/reset.go index 83a4bbf..78053c2 100644 --- a/internal/config/reset.go +++ b/internal/config/reset.go @@ -34,7 +34,8 @@ func (s *Store) Reset() error { // The whole directory rather than each tenant's file: a cache left behind // for a tenant that is no longer in the config would survive the reset. - errs = append(errs, removePath(s.cacheDir()), removePath(s.tenantsPath())) + errs = append(errs, removePath(s.cacheDir()), removePath(s.tenantsPath()), + removePath(s.commandsPath())) // Keys this store does not know about are still in the fallback file, and a // factory reset has to take them too. diff --git a/internal/config/reset_test.go b/internal/config/reset_test.go index 6dac277..56c8832 100644 --- a/internal/config/reset_test.go +++ b/internal/config/reset_test.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" "testing" + "time" ) // savedStore is a store with something in every place a reset has to reach. @@ -35,6 +36,14 @@ func savedStore(t *testing.T) (*Store, *Config) { if err := st.SaveCache(&ElementCache{TSGID: "111"}); err != nil { t.Fatal(err) } + cmds := &CommandStore{} + cmds.Record("dump device status", "ion 1200", time.Now()) + if err := cmds.SetFavourite(1, "dump interface status all", "ion 1200"); err != nil { + t.Fatal(err) + } + if err := st.SaveCommands(cmds); err != nil { + t.Fatal(err) + } if err := st.SaveTenantCache(&TenantCache{Account: "someone"}); err != nil { t.Fatal(err) } @@ -77,6 +86,9 @@ func TestResetLeavesNothingBehind(t *testing.T) { st.configPath(), st.tenantsPath(), st.cacheDir(), + // History and favourites name the commands someone runs on their + // fleet, so a factory reset takes them too. + st.commandsPath(), // Keeping this would leave the identity provider's session cookie, so // the reset would not have signed anyone out. st.BrowserDir(), diff --git a/internal/dataview/builtin.yaml b/internal/dataview/builtin.yaml index fb5f693..033091c 100644 --- a/internal/dataview/builtin.yaml +++ b/internal/dataview/builtin.yaml @@ -114,13 +114,20 @@ views: width: 24 - header: TYPE value: "{{ .item.type }}" - width: 13 + width: 12 - header: USED FOR value: "{{ .item.used_for }}" width: 8 - header: STATE value: "{{ (unwrap .st).operational_state }}{{ with (unwrap .st).extended_state }} ({{ . }}){{ end }}" - width: 22 + width: 18 + # From the status, not the record: the interface record has a + # mac_address field and it is null on every interface a real element + # answered with. A subinterface reports its parent's, and each half + # of a bypass pair reports its own. + - header: MAC + value: "{{ dash (unwrap .st).mac_address }}" + width: 17 - header: ADDRESS value: "{{ dash (first (unwrap .st).ipv4_addresses) }}" width: 18 @@ -131,7 +138,7 @@ views: # site_wan_interface_ids back to the site's circuit list. - header: CIRCUIT value: "{{ range .item.site_wan_interface_ids }}{{ with lookup $.wans.items \"id\" . }}{{ .name }} {{ end }}{{ end }}" - width: 26 + width: 20 # Circuits are the site's, not the element's, so a hub pair shares them. # This is the other direction of the same join: each circuit says which of diff --git a/internal/ionterm/manager.go b/internal/ionterm/manager.go index b398d81..9fdcd53 100644 --- a/internal/ionterm/manager.go +++ b/internal/ionterm/manager.go @@ -176,6 +176,57 @@ func (m *Manager) CloseActive() { m.notify(s.ElementID) } +// CloseDeadOn closes any session on this element that has already failed, +// ended or stopped answering, keeping the live ones. It is how a redial +// replaces the tab it was asked from, once the new session is actually up: +// the dead tab is the only record of what the session said, so it stays on +// screen until there is something to put in its place. +func (m *Manager) CloseDeadOn(eid string) int { + if m == nil { + return 0 + } + m.mu.Lock() + var gone []*Session + kept := m.sessions[:0:0] + for _, s := range m.sessions { + if s.ElementID == eid && (s.Err() != nil || s.Ended() || s.Hung()) { + m.lastEID, m.lastName, m.hasLast = s.ElementID, s.Name, true + gone = append(gone, s) + continue + } + kept = append(kept, s) + } + m.sessions = kept + // The survivor on that element is what the redial was for, so land on it + // rather than wherever the indices happen to leave the cursor. + m.active = clampActive(len(m.sessions), m.active) + for i, s := range m.sessions { + if s.ElementID == eid { + m.active = i + break + } + } + m.mu.Unlock() + + for _, s := range gone { + s.Close() + } + if len(gone) > 0 { + m.notify(eid) + } + return len(gone) +} + +func clampActive(n, at int) int { + if at >= n { + at = n - 1 + } + if at < 0 { + at = 0 + } + return at +} + // Reap records the element as it goes, so alt+t can reopen it. func (m *Manager) Reap() int { if m == nil { diff --git a/internal/ionterm/session.go b/internal/ionterm/session.go index 0abaf2d..1ce8c00 100644 --- a/internal/ionterm/session.go +++ b/internal/ionterm/session.go @@ -255,6 +255,17 @@ func (s *Session) markHung() { s.markDirty() } +// Answered reports whether the device has sent anything at all. The socket +// coming up proves nothing about the appliance: the SASE side accepts a +// toolkit websocket for an element that is not there, and such a session sits +// mute until the handshake deadline fails it. The first byte is the only +// evidence there is an ION on the other end. +func (s *Session) Answered() bool { + s.mu.Lock() + defer s.mu.Unlock() + return s.gotData +} + // Hung is a session whose connection stopped answering pings. func (s *Session) Hung() bool { s.mu.Lock() diff --git a/internal/tui/commands.go b/internal/tui/commands.go new file mode 100644 index 0000000..db2452e --- /dev/null +++ b/internal/tui/commands.go @@ -0,0 +1,179 @@ +package tui + +import ( + "strconv" + "strings" + "time" + + tea "charm.land/bubbletea/v2" + "tyrtop.com/iontk/internal/config" + "tyrtop.com/iontk/internal/ioncmd" +) + +// commandsSavedMsg is a save that failed. A successful one says nothing: the +// store is written for the next run, and nothing on screen depends on it. +type commandsSavedMsg struct{ err string } + +// countUse is one use, in memory only, for a caller recording several before +// it saves. +func (m *tuiModel) countUse(command, model string) { + if m.cmds == nil { + m.cmds = &config.CommandStore{} + } + m.cmds.Record(command, model, time.Now()) +} + +// recordUse counts a command against the model it was run on and hands back +// the save. The save is a command rather than a call because it touches the +// disk and this runs on a keypress. +// +// Named apart from m.record, which builds a search.Record for the matcher. +func (m *tuiModel) recordUse(command, model string) tea.Cmd { + m.countUse(command, model) + return saveCommands(m.store, m.cmds) +} + +func saveCommands(st *config.Store, c *config.CommandStore) tea.Cmd { + if st == nil || c == nil { + return nil + } + // Copied: the model goes on being edited while this runs. + snapshot := *c + return func() tea.Msg { + if err := st.SaveCommands(&snapshot); err != nil { + return commandsSavedMsg{err: err.Error()} + } + return commandsSavedMsg{} + } +} + +func (m tuiModel) onCommandsSaved(msg commandsSavedMsg) (tea.Model, tea.Cmd) { + // Said once, in the footer, and not repeated: a history that cannot be + // written is worth knowing about but must not interrupt anything. + if msg.err != "" && m.status == "" { + m.status = "could not save command history: " + msg.err + } + return m, nil +} + +// modelOf is the hardware model of an element, which is what makes history +// and favourites model-aware. Empty for an element the list has not got -- +// a session reattached after the cache was dropped, say. +func (m tuiModel) modelOf(tsg, eid string) string { + if el, ok := m.elementByKey(tsg + "/" + eid); ok { + return el.Model + } + return "" +} + +// recordSessionLine counts what a shell session is about to send. It is taken +// before the key is forwarded, because the device echoes the line away. +func (m *tuiModel) recordSessionLine() tea.Cmd { + s := m.sessions.Active() + if s == nil { + return nil + } + line := s.Line() + if line == "" { + return nil + } + return m.recordUse(line, m.modelOf(m.tsgForElement(s.ElementID), s.ElementID)) +} + +// recordRun counts a batch run's commands once per model in it. Per model +// rather than per element: ten 1200s running the same command is one fact +// about the 1200, not ten. +func (m *tuiModel) recordRun(cmds []string, rows []elementRow) tea.Cmd { + seen := map[string]bool{} + for _, r := range rows { + if seen[r.Model] { + continue + } + seen[r.Model] = true + for _, c := range cmds { + m.countUse(c, r.Model) + } + } + return saveCommands(m.store, m.cmds) +} + +// activeModel is the model of the element the session in front of the user is +// on, which is what /fc ranks for. +func (m tuiModel) activeModel() string { + s := m.sessions.Active() + if s == nil { + return "" + } + return m.modelOf(m.tsgForElement(s.ElementID), s.ElementID) +} + +// setFreq fills /fc from the history. The query decides membership and the +// ranking decides order: most-used first is the point of the list, so a fuzzy +// score must not reorder it. +func (m tuiModel) setFreq(q string) tuiModel { + model := m.activeModel() + ranked := m.cmds.Ranked(model) + + if q = strings.TrimSpace(q); q != "" { + cmds := make([]string, 0, len(ranked)) + for _, u := range ranked { + cmds = append(cmds, u.Command) + } + keep := map[string]bool{} + for _, hit := range ioncmd.Fuzzy(cmds, q) { + keep[hit] = true + } + kept := ranked[:0:0] + for _, u := range ranked { + if keep[u.Command] { + kept = append(kept, u) + } + } + ranked = kept + } + + m.freq = ranked + m.slashItems = make([]string, 0, len(ranked)) + for _, u := range ranked { + m.slashItems = append(m.slashItems, freqRow(u, model)) + } + if m.slashCursor >= len(m.slashItems) { + m.slashCursor = max(0, len(m.slashItems)-1) + } + return m +} + +// freqRow is one line of the list: the command, how often it has been run, +// and the model when it was not this one -- a 1200 command offered on a 3200 +// is what the stored model is there to show. With no model to compare +// against, every row would carry a tag that says nothing, so none do. +func freqRow(u config.CommandUse, model string) string { + row := u.Command + " \u00d7" + strconv.Itoa(u.Uses) + if model != "" && u.Model != "" && u.Model != model { + row += " \u00b7 " + u.Model + } + return row +} + +func (m tuiModel) freqAt(i int) (config.CommandUse, bool) { + if i < 0 || i >= len(m.freq) { + return config.CommandUse{}, false + } + return m.freq[i], true +} + +// forgetFreq drops the highlighted entry. A history nobody can prune fills +// with typos and stops being a shortlist. +func (m tuiModel) forgetFreq() (tea.Model, tea.Cmd) { + use, ok := m.freqAt(m.slashCursor) + if !ok { + return m, nil + } + if !m.cmds.Forget(use.Command, use.Model) { + return m, nil + } + save := saveCommands(m.store, m.cmds) + m = m.setFreq(m.slashInput.Value()) + m.status = "forgot " + use.Command + return m, save +} diff --git a/internal/tui/commands_test.go b/internal/tui/commands_test.go new file mode 100644 index 0000000..c18d723 --- /dev/null +++ b/internal/tui/commands_test.go @@ -0,0 +1,282 @@ +package tui + +import ( + "context" + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "tyrtop.com/iontk/internal/config" + "tyrtop.com/iontk/internal/ionterm" + "tyrtop.com/iontk/internal/sase/api" +) + +// A model whose elements carry hardware models, since that is what history is +// keyed on. +func historyModel(t *testing.T) tuiModel { + t.Helper() + m := pickModel(t) + setTestElements(&m, []api.Element{ + {ID: "1", Name: "S0100-RTR-01", Model: "ion 1200", Connected: true}, + {ID: "2", Name: "S0100-RTR-02", Model: "ion 3200", Connected: true}, + }) + return m +} + +// What a shell sends is counted as it is sent, which is what fills /fc +// without anyone having to save anything. +func TestEnterInAShellCountsWhatWasTyped(t *testing.T) { + m := historyModel(t) + m.phase = phaseSession + t.Cleanup(m.sessions.CloseAll) + + conn := newRecConn() + if _, err := m.sessions.Open(context.Background(), recDialer{conn}, "1", "S0100-RTR-01", 200, 50, ionterm.Credentials{}); err != nil { + t.Fatal(err) + } + + m = typeString(t, m, "dump device status") + next, cmd := m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyEnter})) + m = next.(tuiModel) + + got := m.cmds.Ranked("ion 1200") + if len(got) != 1 { + t.Fatalf("history = %+v, want the line that was sent", got) + } + if got[0].Command != "dump device status" { + t.Errorf("command = %q, want what was typed", got[0].Command) + } + // The model it ran on, not just the command: the same text means + // different things on different hardware. + if got[0].Model != "ion 1200" { + t.Errorf("model = %q, want the element's", got[0].Model) + } + + // And the keypress hands back the save, so the next run has it. + if cmd == nil { + t.Fatal("enter queued no save") + } + runSave(t, cmd) + saved, err := m.store.Commands() + if err != nil { + t.Fatalf("Commands() error = %v", err) + } + if len(saved.History) != 1 || saved.History[0].Uses != 1 { + t.Errorf("saved history = %+v, want the one use", saved.History) + } +} + +// Enter on an empty line is the device's own prompt being nudged, not a +// command. +func TestEnterOnAnEmptyLineCountsNothing(t *testing.T) { + m := historyModel(t) + m.phase = phaseSession + t.Cleanup(m.sessions.CloseAll) + + conn := newRecConn() + if _, err := m.sessions.Open(context.Background(), recDialer{conn}, "1", "S0100-RTR-01", 200, 50, ionterm.Credentials{}); err != nil { + t.Fatal(err) + } + + next, cmd := m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyEnter})) + if got := next.(tuiModel).cmds.History; len(got) != 0 { + t.Errorf("history = %+v, want nothing recorded", got) + } + if cmd != nil { + t.Error("an empty line queued a save") + } +} + +// A run of ten 1200s doing the same thing is one fact about the 1200, not +// ten, so a batch counts per model rather than per element. +func TestABatchRunCountsOncePerModel(t *testing.T) { + m := historyModel(t) + rows := []elementRow{ + {Element: api.Element{ID: "1", Model: "ion 1200"}}, + {Element: api.Element{ID: "3", Model: "ion 1200"}}, + {Element: api.Element{ID: "2", Model: "ion 3200"}}, + } + cmd := m.recordRun([]string{"uptime", "dump device status"}, rows) + if cmd == nil { + t.Fatal("recordRun queued no save") + } + + if len(m.cmds.History) != 4 { + t.Fatalf("history = %+v, want two commands on each of two models", m.cmds.History) + } + for _, model := range []string{"ion 1200", "ion 3200"} { + for _, want := range []string{"uptime", "dump device status"} { + found := false + for _, u := range m.cmds.History { + if u.Command == want && u.Model == model { + found = true + if u.Uses != 1 { + t.Errorf("%q on %s has %d uses, want 1 for the whole run", want, model, u.Uses) + } + } + } + if !found { + t.Errorf("%q on %s was not counted", want, model) + } + } + } +} + +// /fc is the history: this model's commands first, most-used first inside +// that, and a command from another model named as such rather than hidden. +func TestFrequentCommandsAreRankedForThisModel(t *testing.T) { + m := historyModel(t) + m.phase = phaseSession + t.Cleanup(m.sessions.CloseAll) + + conn := newRecConn() + if _, err := m.sessions.Open(context.Background(), recDialer{conn}, "1", "S0100-RTR-01", 200, 50, ionterm.Credentials{}); err != nil { + t.Fatal(err) + } + + now := time.Now() + m.cmds = &config.CommandStore{} + m.cmds.Record("dump device status", "ion 1200", now) + m.cmds.Record("dump device status", "ion 1200", now) + m.cmds.Record("ping 10.0.0.1", "ion 1200", now) + for i := 0; i < 5; i++ { + m.cmds.Record("dump interface status 8", "ion 3200", now) + } + + next, _ := m.openSlash() + m = typeString(t, next.(tuiModel), "fc") + next, _ = m.activateSlash() + m = next.(tuiModel) + if m.slash != slashFreq { + t.Fatalf("mode = %d, want the frequent list", m.slash) + } + + if len(m.slashItems) != 3 { + t.Fatalf("items = %v, want every entry", m.slashItems) + } + if !strings.HasPrefix(m.slashItems[0], "dump device status") { + t.Errorf("first row = %q, want this model's most-used", m.slashItems[0]) + } + if !strings.Contains(m.slashItems[0], "×2") { + t.Errorf("first row = %q, want the count on it", m.slashItems[0]) + } + // No tag on a command captured on the model in front of the user: it + // would be on every row and say nothing. + if strings.Contains(m.slashItems[0], "ion 1200") { + t.Errorf("first row = %q, want no model tag for this model", m.slashItems[0]) + } + // Run five times, but on other hardware, so it comes last and says so. + last := m.slashItems[2] + if !strings.HasPrefix(last, "dump interface status 8") || !strings.Contains(last, "ion 3200") { + t.Errorf("last row = %q, want the other model's command, named", last) + } + + // The query decides which rows show; the ranking still decides the order. + m.slashInput.SetValue("dump") + m = m.refilterSlash() + if len(m.slashItems) != 2 || !strings.HasPrefix(m.slashItems[0], "dump device status") { + t.Errorf("filtered items = %v, want the two dumps, this model's first", m.slashItems) + } + + // Enter inserts without sending, like the reference palette. + next, _ = m.activateSlash() + m = next.(tuiModel) + if m.slash != slashOff { + t.Errorf("slash mode = %d, want closed", m.slash) + } + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if strings.Contains(conn.sent(), "dump device status") { + return + } + time.Sleep(5 * time.Millisecond) + } + t.Fatalf("the command never reached the session; sent=%q", conn.sent()) +} + +// A history nobody can prune fills with typos and stops being a shortlist. +func TestCtrlDForgetsAHistoryEntry(t *testing.T) { + m := historyModel(t) + m.phase = phaseSession + t.Cleanup(m.sessions.CloseAll) + + conn := newRecConn() + if _, err := m.sessions.Open(context.Background(), recDialer{conn}, "1", "S0100-RTR-01", 200, 50, ionterm.Credentials{}); err != nil { + t.Fatal(err) + } + + m.cmds = &config.CommandStore{} + m.cmds.Record("dmup device status", "ion 1200", time.Now()) + m.cmds.Record("dump device status", "ion 1200", time.Now()) + if err := m.store.SaveCommands(m.cmds); err != nil { + t.Fatal(err) + } + + m = m.enterVerb("fc", "dmup") + if len(m.slashItems) != 1 { + t.Fatalf("items = %v, want the typo alone", m.slashItems) + } + + next, cmd := m.onSlashKey(tea.KeyPressMsg(tea.Key{Code: 'd', Mod: tea.ModCtrl}), "ctrl+d") + m = next.(tuiModel) + if len(m.cmds.History) != 1 || m.cmds.History[0].Command != "dump device status" { + t.Errorf("history = %+v, want only the typo gone", m.cmds.History) + } + if !strings.Contains(m.status, "forgot") { + t.Errorf("status = %q, want it to say what went", m.status) + } + // The list it is showing goes with it, or the next enter inserts a + // command that no longer exists. + if len(m.slashItems) != 0 { + t.Errorf("items = %v, want the row gone too", m.slashItems) + } + + if cmd == nil { + t.Fatal("ctrl+d queued no save") + } + runSave(t, cmd) + saved, err := m.store.Commands() + if err != nil { + t.Fatal(err) + } + if len(saved.History) != 1 { + t.Errorf("saved history = %+v, want the deletion written through", saved.History) + } +} + +// The verbs are resolved by prefix before anything fuzzy, so one letter still +// picks one: "c" is a subsequence of both "commands" and "fc". +func TestOneLetterStillPicksAVerb(t *testing.T) { + tests := map[string]string{ + "c": "commands", + "f": "fc", + "l": "login", + "h": "help", + "d": "debug", + } + for typed, want := range tests { + t.Run(typed, func(t *testing.T) { + got, ok := resolveVerb(typed) + if !ok || got != want { + t.Errorf("resolveVerb(%q) = %q, %v, want %q", typed, got, ok, want) + } + }) + } +} + +func runSave(t *testing.T, cmd tea.Cmd) { + t.Helper() + msg := cmd() + if batch, ok := msg.(tea.BatchMsg); ok { + for _, c := range batch { + if saved, ok := c().(commandsSavedMsg); ok && saved.err != "" { + t.Fatalf("save failed: %s", saved.err) + } + } + return + } + if saved, ok := msg.(commandsSavedMsg); ok && saved.err != "" { + t.Fatalf("save failed: %s", saved.err) + } +} diff --git a/internal/tui/connecting.go b/internal/tui/connecting.go index cddeba2..59a6c16 100644 --- a/internal/tui/connecting.go +++ b/internal/tui/connecting.go @@ -96,6 +96,11 @@ func (m *tuiModel) dialFailed(eid, reason string) { st.failed = true st.lastErr = reason m.setDial(eid, st) + // A redial that failed leaves the tab it was asked from exactly where it + // was, with its own error back in the bar. + if m.redialing == eid { + m.redialing = "" + } } // connectingAny reports whether anything is mid-dial, which is what gates the @@ -131,7 +136,7 @@ func (m tuiModel) retryPrompt() string { lines = append(lines, ui.StyleDim.Render(reason)) } lines = append(lines, - m.onlineLabel(m.askRetry.eid)+ui.StyleDim.Render(" in the portal"), + m.ionLabel(m.askRetry.eid)+ui.StyleDim.Render(" in the portal"), "", "try again", m.retryChoices(), @@ -240,8 +245,14 @@ func (m tuiModel) connectBar() string { var parts []string if st, ok := m.dialing[s.ElementID]; ok && !st.failed { + // Said on the dead tab itself, which is the one being looked at while + // its replacement is dialled. + what := "dial" + if m.redialing == s.ElementID { + what = "redial" + } parts = append(parts, m.spinner.View()+" "+ - ui.StyleWarn.Render(fmt.Sprintf("dial %d/%d", st.attempt, st.of))) + ui.StyleWarn.Render(fmt.Sprintf("%s %d/%d", what, st.attempt, st.of))) if st.lastErr != "" { parts = append(parts, ui.StyleDim.Render("last: "+ui.Truncate(shortDialErr(st.lastErr), 40))) } @@ -251,33 +262,55 @@ func (m tuiModel) connectBar() string { parts = append(parts, ui.StyleErr.Render(ui.MarkCross.String()+" failed")) case s.Hung(): parts = append(parts, ui.StyleWarn.Render("~ not answering")) + case !s.Answered(): + // The socket is up and the appliance has said nothing. That is + // what dialling an element that is not there looks like, and it + // used to read as "successfully dialed" until the handshake + // deadline failed it twenty seconds later. + parts = append(parts, m.spinner.View()+" "+ui.StyleWarn.Render("waiting for the ion")) default: - parts = append(parts, ui.StyleOK.Render(ui.MarkCheck.String()+" connected")) + // What this half is about is the dial, not the appliance: the two + // read as a contradiction while the session half said "connected", + // and naming the socket it runs over only moved the confusion. + // "successfully dialed · ion offline" is a state that happens and + // now says so. + parts = append(parts, ui.StyleOK.Render(ui.MarkCheck.String()+" successfully dialed")) } } - parts = append(parts, m.onlineLabel(s.ElementID)) + parts = append(parts, m.ionLabel(s.ElementID)) - if s.Hung() || s.Err() != nil { + // Not while one is already in flight: the bar says "redial n/m" there, and + // offering the key again reads as though nothing is happening. + if (s.Hung() || s.Err() != nil) && m.redialing != s.ElementID { parts = append(parts, ui.StyleDim.Render("alt+r retry")) } return " " + strings.Join(parts, ui.StyleDim.Render(" · ")) } -// onlineLabel is what the portal last said about the element, which is worth -// showing next to a failed dial: an offline ION will not answer however many -// times it is tried. -func (m tuiModel) onlineLabel(eid string) string { +// awaitingDevice is a live session that has not heard from its appliance yet, +// which is the state the spinner has to keep ticking for. +func (m tuiModel) awaitingDevice() bool { + s := m.sessions.Active() + return s != nil && s.Err() == nil && !s.Hung() && !s.Ended() && !s.Answered() +} + +// ionLabel is what the portal last said about the appliance, said as such. +// Next to a dial it is the only evidence there is -- an offline ION will not +// come up however many times it is tried -- and next to a live session it is +// the disagreement that matters: a stale cached list, or a box that has lost +// its controller channel while its CLI is perfectly well. +func (m tuiModel) ionLabel(eid string) string { for _, r := range m.all { if r.ID != eid { continue } if r.Connected { - return ui.StyleOK.Render("online") + return ui.StyleOK.Render("ion online") } - return ui.StyleErr.Render("offline") + return ui.StyleWarn.Render("ion offline") } - return ui.StyleDim.Render("unknown") + return ui.StyleDim.Render("ion unknown") } // shortDialErr trims the layers a failed handshake arrives wrapped in. diff --git a/internal/tui/connecting_test.go b/internal/tui/connecting_test.go index 261a7a8..4080cb7 100644 --- a/internal/tui/connecting_test.go +++ b/internal/tui/connecting_test.go @@ -1,10 +1,12 @@ package tui import ( + "context" "strings" "testing" tea "charm.land/bubbletea/v2" + "github.com/coder/websocket" "tyrtop.com/iontk/internal/ionterm" "tyrtop.com/iontk/internal/sase/api" @@ -382,3 +384,98 @@ func TestThePromptSaysWhatThePortalThinks(t *testing.T) { t.Errorf("prompt = %q, want it to say what the portal thinks", got) } } + +// "connected · offline" read as a contradiction when it is two facts about +// different things: whether the dial came up, and what the portal last said +// about the appliance. Both halves say which they are about now. +func TestTheConnectBarNamesBothHalves(t *testing.T) { + tests := []struct { + name string + connected bool + dialing bool + kill bool + // answered is the device having sent something, which is the only + // evidence there is an ION on the other end of the socket. + answered bool + want []string + }{ + { + name: "answering, on an ion the portal agrees about", + connected: true, + answered: true, + want: []string{"successfully dialed", "ion online"}, + }, + { + name: "answering, on an ion the portal still calls offline", + answered: true, + want: []string{"successfully dialed", "ion offline"}, + }, + { + // What dialling an element that is not there looks like: the SASE + // side gives you a socket and nothing ever comes down it. + name: "socket up and the ion has said nothing", + want: []string{"waiting for the ion", "ion offline"}, + }, + { + name: "dialling, where the portal is the only evidence", + connected: true, + dialing: true, + want: []string{"dial 2/3", "ion online"}, + }, + { + name: "failed, where it says why a retry will not help", + kill: true, + want: []string{"failed", "ion offline", "alt+r retry"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := pickModel(t) + setTestElements(&m, []api.Element{ + {ID: "1", Name: "S0100-RTR-01", Model: "ion 1200", Connected: tt.connected}, + }) + m.phase = phaseSession + t.Cleanup(m.sessions.CloseAll) + + conn := newRecConn() + s, err := m.sessions.Open(context.Background(), recDialer{conn}, "1", "S0100-RTR-01", 200, 50, ionterm.Credentials{}) + if err != nil { + t.Fatal(err) + } + if tt.answered { + conn.feed("S0100-RTR-01# ") + waitForTUI(t, s.Answered) + } + if tt.dialing { + m.dialing = map[string]dialState{"1": {attempt: 2, of: 3}} + } + if tt.kill { + conn.Close(websocket.StatusAbnormalClosure, "torn") + waitForTUI(t, func() bool { return s.Err() != nil }) + } + + bar := plain(m.connectBar()) + for _, want := range tt.want { + if !strings.Contains(bar, want) { + t.Errorf("bar = %q, want it to say %q", bar, want) + } + } + }) + } +} + +// An element the list has not got says so rather than claiming either way. +func TestTheConnectBarSaysUnknownForAnUntrackedElement(t *testing.T) { + m := pickModel(t) + setTestElements(&m, []api.Element{{ID: "9", Name: "elsewhere"}}) + m.phase = phaseSession + t.Cleanup(m.sessions.CloseAll) + + conn := newRecConn() + if _, err := m.sessions.Open(context.Background(), recDialer{conn}, "1", "S0100-RTR-01", 200, 50, ionterm.Credentials{}); err != nil { + t.Fatal(err) + } + if bar := plain(m.connectBar()); !strings.Contains(bar, "ion unknown") { + t.Errorf("bar = %q, want it to admit it does not know", bar) + } +} diff --git a/internal/tui/data.go b/internal/tui/data.go index 3dd254f..5c7b583 100644 --- a/internal/tui/data.go +++ b/internal/tui/data.go @@ -543,7 +543,7 @@ func (m tuiModel) viewData() string { } status = scrolled + status } - hint := "alt+↑↓ next heading · ↑↓ scroll · r refresh · c CLI check · alt+←/→ tab · alt+k close · ctrl+] elements" + hint := "alt+↑↓ next heading · ↑↓ scroll · r refresh · c CLI check · alt+←→/hl tab · alt+k close · ctrl+] elements" width := max(10, m.width-1) b.WriteString(ui.Footer(ui.Truncate(status, width), ui.Truncate(hint, width))) return b.String() diff --git a/internal/tui/data_test.go b/internal/tui/data_test.go index 77348b1..f46551c 100644 --- a/internal/tui/data_test.go +++ b/internal/tui/data_test.go @@ -77,7 +77,7 @@ func TestRichDataModeLoadsEveryViewOntoOnePage(t *testing.T) { "Overview", "Device", "ion 1200", "SPOKE", // the record "Controller", "Rebooted", "2026-10-01", // status, through the utc helper "HA", "Cluster", // gated on the element being clustered, which it is - "Interfaces", "PORT", "STATE", "CIRCUIT", // the per-row fetch + "Interfaces", "PORT", "STATE", "MAC", "CIRCUIT", // the per-row fetch "Circuits", "LABEL", "NETWORK", "ISP 1", // the circuits view } { if !strings.Contains(page, want) { @@ -101,6 +101,34 @@ func TestRichDataModeLoadsEveryViewOntoOnePage(t *testing.T) { } } +// The MAC comes off the interface status, since the interface record carries +// the field and leaves it null on every real element. +func TestInterfacesShowTheMACFromTheStatus(t *testing.T) { + m, set := fixtureTUIWith(t, haFixtureYAML) + m.phase = phasePicking + m.mode = modeData + m.rebuildRows() + + el := set.Tenants[0].Elements[0] + next, cmd := m.openDataTabs([]sessionTarget{{eid: el.ID, name: el.Name, tsg: m.tenant.TSGID}}) + m = runDataLoads(t, next.(tuiModel), cmd) + + port := tableRow(t, paneFor(t, m.data[0], "Interfaces"), "1") + joined := strings.Join(port, " | ") + mac := "" + for _, cell := range port { + if strings.Count(cell, ":") == 5 { + mac = cell + } + } + if mac == "" { + t.Fatalf("no MAC on the port row: %s", joined) + } + if len(mac) != len("ec:b9:07:09:cb:e9") { + t.Errorf("MAC = %q, want the shape a real status answers with", mac) + } +} + // Which port carries which circuit, from both sides of the join. func TestThePageTiesPortsToCircuits(t *testing.T) { m, set := fixtureTUIWith(t, haFixtureYAML) @@ -317,7 +345,7 @@ func TestDataTabsJoinTheTabRing(t *testing.T) { } bar := plain(m.tabBar()) - for _, want := range []string{"1 elements", "2 S0100-RTR-01"} { + for _, want := range []string{"elements", "S0100-RTR-01 · data"} { if !strings.Contains(bar, want) { t.Errorf("tab bar missing %q: %q", want, bar) } diff --git a/internal/tui/favourites.go b/internal/tui/favourites.go new file mode 100644 index 0000000..4a2d929 --- /dev/null +++ b/internal/tui/favourites.go @@ -0,0 +1,203 @@ +package tui + +import ( + "fmt" + "strconv" + "strings" + + tea "charm.land/bubbletea/v2" + "tyrtop.com/iontk/internal/config" + "tyrtop.com/iontk/internal/ui" +) + +// altDigit is alt+1 to alt+9, which used to select a tab and now reaches a +// favourite. Tabs kept alt+←/alt+→: alt+shift+N is layout-dependent and +// ctrl+N mostly untransmittable, so there was no third chord to move them to. +func altDigit(key string) (int, bool) { + if len(key) != 5 || !strings.HasPrefix(key, "alt+") || key[4] < '1' || key[4] > '9' { + return 0, false + } + return int(key[4] - '0'), true +} + +func plainDigit(key string) (int, bool) { + if len(key) != 1 || key[0] < '1' || key[0] > '9' { + return 0, false + } + return int(key[0] - '0'), true +} + +// useFavourite puts a slot's command on the device line. Inserted and not +// sent, like the palette: a stored command is read before it runs, which is +// also what makes a cross-model favourite safe to offer at all. +func (m tuiModel) useFavourite(slot int) (tea.Model, tea.Cmd) { + f, ok := m.cmds.Favourite(slot) + if !ok { + m.status = fmt.Sprintf("alt+%d is empty — alt+f saves the line you are on", slot) + return m, nil + } + + note := "" + if model := m.activeModel(); model != "" && f.Model != "" && f.Model != model { + note = fmt.Sprintf("alt+%d was saved on %s and this is %s — check it before sending", slot, f.Model, model) + } + + next, cmd := m.insertCommand(f.Command) + nm := next.(tuiModel) + // A refusal says more than the model note does. + if nm.status == "" { + nm.status = note + } + return nm, cmd +} + +func (m tuiModel) openFavourites() (tea.Model, tea.Cmd) { + m.favPick = true + if m.favCursor < 1 || m.favCursor > config.FavouriteSlots { + m.favCursor = 1 + } + m.status = "" + return m, nil +} + +func (m tuiModel) onFavouriteKey(key string) (tea.Model, tea.Cmd) { + switch key { + case "esc", "q", "alt+w", "alt+f": + m.favPick = false + m.status = "" + return m, nil + case "up", "k": + m.favCursor = max(1, m.favCursor-1) + return m, nil + case "down", "j": + m.favCursor = min(config.FavouriteSlots, m.favCursor+1) + return m, nil + case "enter": + return m.saveFavourite(m.favCursor) + case "ctrl+d", "x": + return m.clearFavourite(m.favCursor) + case "a": + return m.openFavouriteForm(m.favCursor) + } + // A digit picks the slot and saves in one keypress, which is the whole + // point of a slot picker. + if n, ok := plainDigit(key); ok { + m.favCursor = n + return m.saveFavourite(n) + } + if n, ok := altDigit(key); ok { + m.favCursor = n + return m.saveFavourite(n) + } + return m, nil +} + +// saveFavourite captures the line the session is on. The prompt line rather +// than the last command run: what is being typed is what someone has just +// decided is worth keeping. +func (m tuiModel) saveFavourite(slot int) (tea.Model, tea.Cmd) { + line := "" + if s := m.sessions.Active(); s != nil { + line = strings.TrimSpace(s.Line()) + } + if line == "" { + m.status = "nothing on the line to save — a adds one by hand" + return m, nil + } + if err := m.cmds.SetFavourite(slot, line, m.activeModel()); err != nil { + m.status = err.Error() + return m, nil + } + m.favPick = false + m.status = fmt.Sprintf("alt+%d is now %s", slot, line) + return m, saveCommands(m.store, m.cmds) +} + +func (m tuiModel) clearFavourite(slot int) (tea.Model, tea.Cmd) { + if !m.cmds.ClearFavourite(slot) { + m.status = fmt.Sprintf("alt+%d is already empty", slot) + return m, nil + } + m.status = fmt.Sprintf("alt+%d cleared", slot) + return m, saveCommands(m.store, m.cmds) +} + +// openFavouriteForm is the by-hand route: a command for a model that is not in +// front of you, or one too long to want to type at a prompt first. +func (m tuiModel) openFavouriteForm(slot int) (tea.Model, tea.Cmd) { + cmd := newField("command", "dump device status", false) + if f, ok := m.cmds.Favourite(slot); ok { + cmd.input.SetValue(f.Command) + } else if s := m.sessions.Active(); s != nil { + cmd.input.SetValue(strings.TrimSpace(s.Line())) + } + at := newField("slot", "1-9", false) + at.input.SetValue(strconv.Itoa(slot)) + + m.favPick = false + return m.openForm(formFavourite, fmt.Sprintf("Favourite alt+%d", slot), phaseAddUser, + []formField{cmd, at}) +} + +// submitFavourite is the form's enter. The model is the one the session is on, +// which is empty for a favourite typed in with nothing open -- better than +// claiming it was captured on whatever happens to be in front of the user. +func (m tuiModel) submitFavourite() (tea.Model, tea.Cmd) { + command := m.formValue(0) + slot, err := strconv.Atoi(m.formValue(1)) + if err != nil { + m.status = "slot is a number from 1 to " + strconv.Itoa(config.FavouriteSlots) + return m, nil + } + if err := m.cmds.SetFavourite(slot, command, m.activeModel()); err != nil { + m.status = err.Error() + return m, nil + } + m.form, m.formKind = nil, formNone + m.phase = m.formReturn + m.status = fmt.Sprintf("alt+%d is now %s", slot, command) + return m, saveCommands(m.store, m.cmds) +} + +// overlayFavourites draws the picker over the bottom of the session pane, the +// same way the retry question does, so what is on screen behind it still says +// which element this is. +func (m tuiModel) overlayFavourites(content string) string { + lines := []string{ + ui.StyleCmd.Render("favourites") + ui.StyleDim.Render(" alt+1-9 inserts one"), + } + for slot := 1; slot <= config.FavouriteSlots; slot++ { + cursor := " " + if slot == m.favCursor { + cursor = ui.StyleCmd.Render("> ") + } + row := cursor + ui.StyleDim.Render(strconv.Itoa(slot)+" ") + + f, ok := m.cmds.Favourite(slot) + if !ok { + lines = append(lines, row+ui.StyleDim.Render("empty")) + continue + } + // Padded before styling, or the model tags drift row by row. + cmd := fmt.Sprintf("%-34s", ui.Truncate(f.Command, 34)) + tag := "" + if f.Model != "" { + style := ui.StyleDim + if model := m.activeModel(); model != "" && f.Model != model { + style = ui.StyleWarn + } + tag = style.Render(f.Model) + } + lines = append(lines, row+cmd+" "+tag) + } + lines = append(lines, "", + ui.StyleDim.Render("1-9/enter saves this line · a by hand · ctrl+d clears · esc close")) + + modal := ui.Modal(lines, m.width) + keep := max(0, m.height-ui.ModalHeight(len(lines))-1) + rows := strings.Split(content, "\n") + if len(rows) > keep { + rows = rows[:keep] + } + return strings.Join(rows, "\n") + "\n" + modal +} diff --git a/internal/tui/favourites_test.go b/internal/tui/favourites_test.go new file mode 100644 index 0000000..a212f83 --- /dev/null +++ b/internal/tui/favourites_test.go @@ -0,0 +1,247 @@ +package tui + +import ( + "context" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "tyrtop.com/iontk/internal/ionterm" +) + +// favModel is a shell open on an ion 1200, which is what a favourite's model +// is compared against. +func favModel(t *testing.T) (tuiModel, *recConn) { + t.Helper() + m := historyModel(t) + m.phase = phaseSession + t.Cleanup(m.sessions.CloseAll) + + conn := newRecConn() + if _, err := m.sessions.Open(context.Background(), recDialer{conn}, "1", "S0100-RTR-01", 200, 50, ionterm.Credentials{}); err != nil { + t.Fatal(err) + } + return m, conn +} + +func pressSessionKey(t *testing.T, m tuiModel, key tea.Key) (tuiModel, tea.Cmd) { + t.Helper() + next, cmd := m.Update(tea.KeyPressMsg(key)) + return next.(tuiModel), cmd +} + +// No Text: with it set, KeyPressMsg.String() reports the character and drops +// the modifier, so an alt chord built that way arrives as a plain keypress. +func altKey(r rune) tea.Key { return tea.Key{Code: r, Mod: tea.ModAlt} } + +// alt+N puts the command on the line without sending it, the same discipline +// the palette has: a stored command is read before it runs. +func TestAltDigitInsertsAFavourite(t *testing.T) { + m, conn := favModel(t) + if err := m.cmds.SetFavourite(1, "dump device status", "ion 1200"); err != nil { + t.Fatal(err) + } + + m, _ = pressSessionKey(t, m, altKey('1')) + if m.status != "" { + t.Errorf("status = %q, want nothing to say for a favourite from this model", m.status) + } + waitForTUI(t, func() bool { return strings.Contains(conn.sent(), "dump device status") }) + // Inserted, not sent. + if strings.Contains(conn.sent(), "\r") { + t.Errorf("the favourite was sent, not inserted: %q", conn.sent()) + } +} + +func TestAnEmptySlotSaysHowToFillIt(t *testing.T) { + m, _ := favModel(t) + m, cmd := pressSessionKey(t, m, altKey('3')) + if cmd != nil { + t.Error("an empty slot queued work") + } + if !strings.Contains(m.status, "alt+f") { + t.Errorf("status = %q, want it to name the key that fills a slot", m.status) + } +} + +// The model a favourite was captured on is why it is stored: a 1200 command +// is flagged on a 3200 rather than silently failing there. +func TestAFavouriteFromAnotherModelIsFlagged(t *testing.T) { + m, conn := favModel(t) + if err := m.cmds.SetFavourite(2, "dump interface status 8", "ion 3200"); err != nil { + t.Fatal(err) + } + + m, _ = pressSessionKey(t, m, altKey('2')) + for _, want := range []string{"ion 3200", "ion 1200"} { + if !strings.Contains(m.status, want) { + t.Errorf("status = %q, want it to name %q", m.status, want) + } + } + // Flagged, not blocked: it is on the line for the user to read. + waitForTUI(t, func() bool { return strings.Contains(conn.sent(), "dump interface status 8") }) +} + +// alt+f captures what is being typed, which is the moment someone decides a +// command is worth keeping. +func TestAltFSavesThePromptLine(t *testing.T) { + m, _ := favModel(t) + m = typeString(t, m, "dump vpn count") + + m, _ = pressSessionKey(t, m, altKey('f')) + if !m.favPick { + t.Fatal("alt+f did not open the picker") + } + if got := plain(m.viewSession()); !strings.Contains(got, "favourites") { + t.Errorf("the picker is not on screen:\n%s", got) + } + + // A digit picks the slot and saves in one keypress. + m, cmd := pressSessionKey(t, m, tea.Key{Code: '4', Text: "4"}) + if m.favPick { + t.Error("the picker stayed open after saving") + } + f, ok := m.cmds.Favourite(4) + if !ok || f.Command != "dump vpn count" { + t.Fatalf("slot 4 = %+v, %v, want the line that was typed", f, ok) + } + if f.Model != "ion 1200" { + t.Errorf("model = %q, want the element's", f.Model) + } + if cmd == nil { + t.Fatal("saving queued no write") + } + runSave(t, cmd) + saved, err := m.store.Commands() + if err != nil { + t.Fatal(err) + } + if _, ok := saved.Favourite(4); !ok { + t.Error("the favourite did not reach the disk") + } +} + +func TestThePickerListsAndClearsSlots(t *testing.T) { + m, _ := favModel(t) + if err := m.cmds.SetFavourite(1, "dump device status", "ion 1200"); err != nil { + t.Fatal(err) + } + if err := m.cmds.SetFavourite(9, "dump interface status 8", "ion 3200"); err != nil { + t.Fatal(err) + } + + m, _ = pressSessionKey(t, m, altKey('f')) + got := plain(m.viewSession()) + for _, want := range []string{"favourites", "dump device status", "ion 3200", "empty"} { + if !strings.Contains(got, want) { + t.Errorf("the picker is missing %q:\n%s", want, got) + } + } + + // The cursor starts on slot 1, which is the one ctrl+d clears. + cleared, cmd := m.onFavouriteKey("ctrl+d") + after := cleared.(tuiModel) + if _, ok := after.cmds.Favourite(1); ok { + t.Error("ctrl+d left the slot filled") + } + if cmd == nil { + t.Error("clearing queued no write") + } + + // Nothing on the line, so a digit has nothing to save and says so. + next, _ := after.onFavouriteKey("2") + empty := next.(tuiModel) + if !strings.Contains(empty.status, "nothing on the line") { + t.Errorf("status = %q, want it to say there is nothing to save", empty.status) + } + if !empty.favPick { + t.Error("the picker closed without saving anything") + } + + closed, _ := empty.onFavouriteKey("esc") + if closed.(tuiModel).favPick { + t.Error("esc did not close the picker") + } +} + +// The by-hand route: a command for hardware that is not in front of you. +func TestTheFavouriteFormSaves(t *testing.T) { + m, _ := favModel(t) + m, _ = pressSessionKey(t, m, altKey('f')) + + next, _ := m.onFavouriteKey("a") + m = next.(tuiModel) + if m.formKind != formFavourite { + t.Fatalf("formKind = %v, want the favourite form", m.formKind) + } + if m.favPick { + t.Error("the picker stayed open behind the form") + } + + m.form[0].input.SetValue("dump spoke-ha status") + m.form[1].input.SetValue("7") + next, cmd := m.submitForm() + m = next.(tuiModel) + + f, ok := m.cmds.Favourite(7) + if !ok || f.Command != "dump spoke-ha status" { + t.Fatalf("slot 7 = %+v, %v, want what the form said", f, ok) + } + if m.phase != phaseSession { + t.Errorf("phase = %v, want back to the session the form was opened from", m.phase) + } + runSave(t, cmd) + + // A slot outside the range is refused rather than written somewhere else. + m.form = []formField{newField("command", "", false), newField("slot", "", false)} + m.formKind = formFavourite + m.form[0].input.SetValue("uptime") + m.form[1].input.SetValue("12") + next, _ = m.submitForm() + if !strings.Contains(next.(tuiModel).status, "outside 1-9") { + t.Errorf("status = %q, want the slot refused", next.(tuiModel).status) + } +} + +// alt+N was tab selection. It is a favourite now, and the tab bar no longer +// advertises numbers that do nothing. +func TestNumericTabSelectionIsRetired(t *testing.T) { + m, _ := favModel(t) + conn2 := newRecConn() + if _, err := m.sessions.Open(context.Background(), recDialer{conn2}, "2", "S0100-RTR-02", 200, 50, ionterm.Credentials{}); err != nil { + t.Fatal(err) + } + m.sessions.Select(1) + + before := m.currentTab() + m, _ = pressSessionKey(t, m, altKey('1')) + if m.currentTab() != before { + t.Errorf("alt+1 moved the tab from %d to %d, want it to reach a favourite instead", before, m.currentTab()) + } + // alt+←/→ is what moves tabs now. + m, _ = pressSessionKey(t, m, tea.Key{Code: tea.KeyLeft, Mod: tea.ModAlt}) + if m.currentTab() == before { + t.Error("alt+left did not move the tab") + } +} + +func TestFavouriteSlotsSurviveARestart(t *testing.T) { + m, _ := favModel(t) + if err := m.cmds.SetFavourite(5, "dump device status", "ion 1200"); err != nil { + t.Fatal(err) + } + if err := m.store.SaveCommands(m.cmds); err != nil { + t.Fatal(err) + } + + // What a fresh process does: read the store back. + cfg, err := m.store.Config() + if err != nil { + t.Fatal(err) + } + restarted := newTUIModel(context.Background(), m.store, cfg) + f, ok := restarted.cmds.Favourite(5) + if !ok || f.Command != "dump device status" { + t.Errorf("slot 5 after a restart = %+v, %v", f, ok) + } +} diff --git a/internal/tui/filter_test.go b/internal/tui/filter_test.go index 14e3199..1ebb06c 100644 --- a/internal/tui/filter_test.go +++ b/internal/tui/filter_test.go @@ -1,6 +1,7 @@ package tui import ( + "fmt" "strings" "testing" @@ -756,3 +757,132 @@ func TestArrowsStillMoveTheListWhileSearching(t *testing.T) { t.Errorf("filter.Value() = %q, want the query intact", got) } } + +// Deleting the last character of a query leaves the filter. An empty query +// with the cursor still in it is a list where j and k type letters. +func TestDeletingTheQueryLeavesTheFilter(t *testing.T) { + m := pickModel(t) + m.phase = phasePicking + + next, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: '/', Text: "/"})) + m = typeString(t, next.(tuiModel), "s0") + if !m.filterOn { + t.Fatal("/ did not open the filter") + } + + for range "s0" { + next, _ = m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyBackspace})) + m = next.(tuiModel) + } + if m.filterOn { + t.Error("the filter is still open with an empty query") + } + if m.filter.Value() != "" { + t.Errorf("query = %q, want it cleared", m.filter.Value()) + } + // The whole list is back, and the keys move the cursor again. + if len(m.matches) != len(m.all) { + t.Errorf("%d matches, want the whole list of %d", len(m.matches), len(m.all)) + } + next, _ = m.Update(tea.KeyPressMsg(tea.Key{Code: 'j', Text: "j"})) + if got := next.(tuiModel).table.Cursor(); got != 1 { + t.Errorf("cursor = %d after j, want the key to move the list again", got) + } +} + +// The same rule on the macro page, which has a filter of its own. +func TestDeletingTheMacroQueryLeavesTheFilter(t *testing.T) { + m := macroModel(t) + + next, _ := m.onMacroKey(tea.KeyPressMsg(tea.Key{Code: '/', Text: "/"}), "/") + m = typeString(t, next.(tuiModel), "ha") + if !m.macroFilterOn || len(m.macroRows()) != 1 { + t.Fatalf("filter on = %v, rows = %d, want a narrowed list", m.macroFilterOn, len(m.macroRows())) + } + + for range "ha" { + next, _ = m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyBackspace})) + m = next.(tuiModel) + } + if m.macroFilterOn { + t.Error("the macro filter is still open with an empty query") + } + if len(m.macroRows()) != 2 { + t.Errorf("%d rows, want every macro back", len(m.macroRows())) + } +} + +// The widget's own MoveUp scrolls its viewport away from the cursor, so a held +// k used to walk the highlight off the top of the window and the list stopped +// appearing to move. Both directions go through moveCursorTo now. +func TestHoldingKWalksBackUpTheList(t *testing.T) { + m := pickModel(t) + m.phase = phasePicking + m.height = 12 + // More rows than the window, which is when the viewport arithmetic + // mattered. + els := make([]api.Element, 40) + for i := range els { + els[i] = api.Element{ID: fmt.Sprint(i), Name: fmt.Sprintf("S%04d-RTR-01", i), Connected: true} + } + setTestElements(&m, els) + + for i := 0; i < 30; i++ { + next, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: 'j', Text: "j"})) + m = next.(tuiModel) + } + if got := m.table.Cursor(); got != 30 { + t.Fatalf("cursor = %d after 30 j, want 30", got) + } + if !strings.Contains(plain(m.table.View()), m.all[30].Name) { + t.Errorf("the row the cursor is on is not in the window:\n%s", plain(m.table.View())) + } + + // Back up one at a time: the cursor and the window move together the + // whole way. + for i := 29; i >= 0; i-- { + next, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: 'k', Text: "k"})) + m = next.(tuiModel) + if got := m.table.Cursor(); got != i { + t.Fatalf("cursor = %d after k, want %d", got, i) + } + if view := plain(m.table.View()); !strings.Contains(view, m.all[i].Name) { + t.Fatalf("row %d is highlighted but not in the window:\n%s", i, view) + } + } +} + +// alt+j and alt+k jump by the page step, like alt+↓ and alt+↑. +func TestAltJAndAltKJumpTheList(t *testing.T) { + m := pickModel(t) + m.cfg.PageStep = 2 + m.table.SetCursor(0) + + next, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: 'j', Mod: tea.ModAlt})) + m = next.(tuiModel) + if got := m.table.Cursor(); got != 2 { + t.Errorf("cursor = %d after alt+j with a step of 2, want 2", got) + } + next, _ = m.Update(tea.KeyPressMsg(tea.Key{Code: 'k', Mod: tea.ModAlt})) + if got := next.(tuiModel).table.Cursor(); got != 0 { + t.Errorf("cursor = %d after alt+k, want back at 0", got) + } +} + +// alt+h and alt+l walk the tab ring, like alt+← and alt+→. +func TestAltHAndAltLSwitchTabs(t *testing.T) { + m := pickModel(t) + m.data = []dataTab{{eid: "1", name: "S0100-RTR-01", tsg: "1"}} + m.dataActive = 0 + m.phase = phasePicking + + next, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: 'l', Mod: tea.ModAlt})) + m = next.(tuiModel) + if m.phase != phaseData { + t.Fatalf("phase = %v after alt+l, want the data tab", m.phase) + } + next, _ = m.Update(tea.KeyPressMsg(tea.Key{Code: 'h', Mod: tea.ModAlt})) + if got := next.(tuiModel).phase; got != phasePicking { + t.Errorf("phase = %v after alt+h, want back on the list", got) + } +} diff --git a/internal/tui/ha.go b/internal/tui/ha.go new file mode 100644 index 0000000..8444525 --- /dev/null +++ b/internal/tui/ha.go @@ -0,0 +1,149 @@ +package tui + +import ( + "context" + "sync" + + tea "charm.land/bubbletea/v2" + "tyrtop.com/iontk/internal/sase/api" + "tyrtop.com/iontk/internal/ui" +) + +// HA state as the element list shows it. The portal has no "standby": a +// cluster's members read "active" on the one carrying traffic and "unknown" +// on anything the controller cannot see. So standby is derived -- a member +// that is not active, in a cluster that has an active member, is the standby +// by elimination -- and a cluster with nobody active keeps the API's own word, +// because that is a cluster in trouble rather than a quiet second node. +const ( + haActive = "active" + haStandby = "standby" + haUnknown = "unknown" +) + +// haClusters is the distinct (site, cluster) pairs among these elements. One +// request answers for every member of a cluster, so a pair costs one call and +// not two. +func haClusters(els []api.Element) [][2]string { + seen := map[string]bool{} + var out [][2]string + for _, el := range els { + cid := el.ClusterID() + if cid == "" || el.SiteID == "" || seen[cid] { + continue + } + seen[cid] = true + out = append(out, [2]string{el.SiteID, cid}) + } + return out +} + +// haFetchLimit bounds one refresh. A fleet of HA pairs is a request per pair, +// and the client's rate limit is five a second, so an unbounded sweep would +// still be filling the column minutes later. Past the limit the cells stay +// empty rather than wrong. +const haFetchLimit = 64 + +// haFetchWorkers is how many run at once. The client rate-limits anyway; this +// only stops a thousand goroutines queueing behind it. +const haFetchWorkers = 4 + +type haStatesMsg struct { + tsg string + states map[string]string + // capped is set when there were more clusters than one refresh will read. + capped bool +} + +// loadHA reads each cluster's status and reduces it to one word per element. +// It runs after the list is on screen: the elements are what someone is +// looking for, and HA is what they want to know about the one they found. +func loadHA(ctx context.Context, c *api.Client, tsg string, pairs [][2]string) tea.Cmd { + if c == nil || len(pairs) == 0 { + return nil + } + capped := false + if len(pairs) > haFetchLimit { + pairs, capped = pairs[:haFetchLimit], true + } + + return func() tea.Msg { + var mu sync.Mutex + states := map[string]string{} + + var wg sync.WaitGroup + work := make(chan [2]string) + for i := 0; i < min(haFetchWorkers, len(pairs)); i++ { + wg.Add(1) + go func() { + defer wg.Done() + for p := range work { + st, err := c.SpokeClusterStatus(ctx, p[0], p[1]) + if err != nil { + continue + } + _, hasActive := st.Active() + + words := make(map[string]string, len(st.Members)) + for _, mem := range st.Members { + switch { + case mem.Status == haActive: + words[mem.ElementID] = haActive + case hasActive: + words[mem.ElementID] = haStandby + case mem.Status != "": + words[mem.ElementID] = mem.Status + default: + words[mem.ElementID] = haUnknown + } + } + + mu.Lock() + for eid, w := range words { + states[tsg+"/"+eid] = w + } + mu.Unlock() + } + }() + } + for _, p := range pairs { + work <- p + } + close(work) + wg.Wait() + + return haStatesMsg{tsg: tsg, states: states, capped: capped} + } +} + +func (m tuiModel) onHAStates(msg haStatesMsg) (tea.Model, tea.Cmd) { + if m.ha == nil { + m.ha = map[string]string{} + } + for k, v := range msg.states { + m.ha[k] = v + } + if msg.capped && m.status == "" { + m.status = "HA read for the first 64 clusters; alt+r for another pass" + } + m.rebuildRows() + return m, nil +} + +// haCell is the column. Blank for an element that is not in a cluster, which +// is most of a fleet: a dash on every standalone row would be noise. +func (m tuiModel) haCell(r elementRow) string { + if r.ClusterID() == "" { + return "" + } + switch m.ha[r.key()] { + case haActive: + return ui.StyleOK.Render(haActive) + case haStandby: + return ui.StyleDim.Render(haStandby) + case haUnknown: + return ui.StyleWarn.Render(haUnknown) + } + // In a cluster, but its status has not come back yet. + return ui.StyleDim.Render("ha") +} diff --git a/internal/tui/ha_test.go b/internal/tui/ha_test.go new file mode 100644 index 0000000..61fb538 --- /dev/null +++ b/internal/tui/ha_test.go @@ -0,0 +1,111 @@ +package tui + +import ( + "strings" + "testing" + + "tyrtop.com/iontk/internal/sase/api" +) + +// The portal has no "standby": a cluster reads "active" on the member +// carrying traffic and "unknown" on anything the controller cannot see. The +// second node is therefore derived, and only where there is an active member +// to derive it from. +func TestHAStatesAreFilledIntoTheList(t *testing.T) { + m, set := fixtureTUIWith(t, haFixtureYAML) + m.phase = phasePicking + m.rebuildRows() + + // Both fixture elements are in the site's cluster, so the column shows. + if !m.showHAColumn() { + t.Fatal("the HA column is hidden on a list with a cluster in it") + } + if got := plain(m.viewPicking()); !strings.Contains(got, "ha") { + t.Errorf("the list has no ha column:\n%s", got) + } + // Before the status lands, a clustered element says it is in a cluster + // and nothing more. + first := set.Tenants[0].Elements[0] + if got := plain(m.haCell(rowFor(t, m, first.ID))); got != "ha" { + t.Errorf("cell before the fetch = %q, want it to admit only membership", got) + } + + pairs := haClusters(elementsOf(m)) + if len(pairs) != 1 { + t.Fatalf("%d clusters to read for a pair sharing one, want 1", len(pairs)) + } + + cmd := loadHA(t.Context(), m.pool.get(m.tenant.TSGID), m.tenant.TSGID, pairs) + if cmd == nil { + t.Fatal("nothing queued for a tenant with a cluster") + } + msg, ok := cmd().(haStatesMsg) + if !ok { + t.Fatalf("message = %T, want the HA states", msg) + } + next, _ := m.Update(msg) + m = next.(tuiModel) + + // The fixture's connected element is the active one; its peer is not + // connected, so the cluster reports it as unknown -- and with an active + // member in the cluster that is the standby. + active := plain(m.haCell(rowFor(t, m, first.ID))) + peer := plain(m.haCell(rowFor(t, m, set.Tenants[0].Elements[1].ID))) + if active != "active" { + t.Errorf("the connected member reads %q, want active", active) + } + if peer != "standby" { + t.Errorf("the other member reads %q, want standby derived from the active one", peer) + } + if got := plain(m.viewPicking()); !strings.Contains(got, "active") { + t.Errorf("the list does not show the state:\n%s", got) + } +} + +// A list with no clusters pays nothing for the column. +func TestTheHAColumnStaysOffAListWithoutClusters(t *testing.T) { + m, _ := fixtureTUI(t) + m.phase = phasePicking + m.rebuildRows() + + if m.showHAColumn() { + t.Error("the HA column is on a list with no clusters in it") + } + if cmd := loadHA(t.Context(), m.pool.get(m.tenant.TSGID), m.tenant.TSGID, nil); cmd != nil { + t.Error("something was queued for a tenant with no clusters") + } +} + +// One request per cluster, not per element: a pair shares a cluster. +func TestHAClustersAreCountedOncePerCluster(t *testing.T) { + els := []api.Element{ + {ID: "1", SiteID: "s1", SpokeHA: &api.SpokeHA{ClusterID: "c1"}}, + {ID: "2", SiteID: "s1", SpokeHA: &api.SpokeHA{ClusterID: "c1"}}, + {ID: "3", SiteID: "s2", SpokeHA: &api.SpokeHA{ClusterID: "c2"}}, + {ID: "4", SiteID: "s3"}, + // In a cluster but with no site, so there is no path to ask about. + {ID: "5", SpokeHA: &api.SpokeHA{ClusterID: "c3"}}, + } + if got := haClusters(els); len(got) != 2 { + t.Errorf("haClusters() = %v, want one entry per cluster that can be asked about", got) + } +} + +func rowFor(t *testing.T, m tuiModel, eid string) elementRow { + t.Helper() + for _, r := range m.all { + if r.ID == eid { + return r + } + } + t.Fatalf("no row for %s", eid) + return elementRow{} +} + +func elementsOf(m tuiModel) []api.Element { + out := make([]api.Element, 0, len(m.all)) + for _, r := range m.all { + out = append(out, r.Element) + } + return out +} diff --git a/internal/tui/macros.go b/internal/tui/macros.go new file mode 100644 index 0000000..2604fc5 --- /dev/null +++ b/internal/tui/macros.go @@ -0,0 +1,461 @@ +package tui + +import ( + "fmt" + "strings" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "tyrtop.com/iontk/internal/app" + "tyrtop.com/iontk/internal/config" + "tyrtop.com/iontk/internal/sase/ioncli" + "tyrtop.com/iontk/internal/ui" +) + +// warnMacroModel is the ID of the cross-model warning. Warnings are keyed by +// ID rather than given a field apiece, so the next one costs a constant. +const warnMacroModel = "macro/model" + +func newMacroFilter() textinput.Model { + in := textinput.New() + in.Prompt = "/" + in.Placeholder = "filter" + in.SetWidth(26) + return in +} + +func (m tuiModel) openMacros() (tea.Model, tea.Cmd) { + m.macroFilter = newMacroFilter() + m.macroFilterOn = false + m.status = "" + m.phase = phaseMacros + return m.resetMacroCursor(), nil +} + +// macroRows is the list as filtered. Name and commands both match: a macro is +// remembered by what it does as often as by what it was called. +func (m tuiModel) macroRows() []config.Macro { + all := m.cmds.Macros + q := strings.ToLower(strings.TrimSpace(m.macroFilter.Value())) + if q == "" { + return all + } + var out []config.Macro + for _, mac := range all { + hay := strings.ToLower(mac.Name + " " + strings.Join(mac.Commands, " ")) + if strings.Contains(hay, q) { + out = append(out, mac) + } + } + return out +} + +// macroAt maps the cursor to a macro. Cursor 0 is the "+ new macro" row, an +// action rather than a macro, so it answers nothing. +func (m tuiModel) macroAt(cursor int) (config.Macro, bool) { + rows := m.macroRows() + if cursor < 1 || cursor > len(rows) { + return config.Macro{}, false + } + return rows[cursor-1], true +} + +// resetMacroCursor puts the cursor on the first macro, or on the + row when +// there is none: filtering is done to find a macro, not to make one. +func (m tuiModel) resetMacroCursor() tuiModel { + m.macroCursor = 0 + if len(m.macroRows()) > 0 { + m.macroCursor = 1 + } + return m +} + +func (m tuiModel) onMacroKey(msg tea.KeyPressMsg, key string) (tea.Model, tea.Cmd) { + if m.macroFilterOn { + switch key { + case "esc": + m.macroFilterOn = false + m.macroFilter.SetValue("") + m.macroFilter.Blur() + return m.resetMacroCursor(), nil + case "enter": + m.macroFilterOn = false + m.macroFilter.Blur() + return m, nil + } + var cmd tea.Cmd + m.macroFilter, cmd = m.macroFilter.Update(msg) + // Deleting the last character leaves the filter, the same as on the + // element list: an empty query with the cursor in it is a page whose + // keys all type letters. + if key == "backspace" && m.macroFilter.Value() == "" { + m.macroFilterOn = false + m.macroFilter.Blur() + } + return m.resetMacroCursor(), cmd + } + + switch key { + case "esc", "q": + m.phase = phaseMenu + m.status = "" + return m, nil + case "/": + m.macroFilterOn = true + return m, m.macroFilter.Focus() + case "up", "k": + m.macroCursor = max(0, m.macroCursor-1) + return m, nil + case "down", "j": + m.macroCursor = min(len(m.macroRows()), m.macroCursor+1) + return m, nil + case "e": + if mac, ok := m.macroAt(m.macroCursor); ok { + return m.openMacroForm(mac) + } + return m, nil + case "ctrl+d", "x": + return m.deleteMacro() + case "enter": + // The top row makes one; everything below it is picked up and taken + // to the element list. + if mac, ok := m.macroAt(m.macroCursor); ok { + return m.armMacro(mac) + } + return m.openMacroForm(config.Macro{}) + } + return m, nil +} + +func (m tuiModel) deleteMacro() (tea.Model, tea.Cmd) { + mac, ok := m.macroAt(m.macroCursor) + if !ok { + return m, nil + } + m.cmds.RemoveMacro(mac.Name) + m.macroCursor = max(0, min(m.macroCursor, len(m.macroRows()))) + m.status = "deleted " + mac.Name + return m, saveCommands(m.store, m.cmds) +} + +// armMacro picks a macro up and goes to the element list to choose what to +// run it on, which is batch mode's order of doing things: the list is where +// elements are found, filtered and selected, and a macro page could only ever +// be a worse copy of it. +func (m tuiModel) armMacro(mac config.Macro) (tea.Model, tea.Cmd) { + m.armedMacro = mac.Name + m.mode = modeMacro + m.status = mac.Name + ": select elements, then enter" + + // Reached from the menu, the list may never have been loaded. + if len(m.all) == 0 { + return m.startContinue() + } + m.phase = phasePicking + m.rebuildRows() + return m, nil +} + +// runArmedMacro is enter in macro mode. +func (m tuiModel) runArmedMacro(rows []elementRow) (tea.Model, tea.Cmd) { + mac, ok := m.cmds.Macro(m.armedMacro) + if !ok { + m.mode = modeShell + m.armedMacro = "" + m.status = "that macro is gone — pick another from the menu" + return m, nil + } + return m.runMacro(mac, rows) +} + +// runMacro runs the sequence against the rows enter acted on, through the +// same batch engine a typed command goes through. +func (m tuiModel) runMacro(mac config.Macro, rows []elementRow) (tea.Model, tea.Cmd) { + // As a unit, before anything is dialled. Command by command would leave a + // macro half run, with the write it was refused for sitting after + // whatever already took effect. + if !app.Defaults.AllowWrites && !m.cfg.AllowWrites { + if err := ioncli.CheckReadOnly(mac.Commands); err != nil { + m.status = mac.Name + ": " + err.Error() + return m, nil + } + } + + if len(rows) == 0 { + m.status = "nothing selected — pick elements in the list, then run the macro" + return m, nil + } + + // Across models, once. Afterwards it runs without asking: the standing + // signal is the model tag on the macro page, which a status line could + // not be -- a run clears the status as it starts. + if macroMismatch(mac, rows) != "" && !m.cmds.Acked(warnMacroModel) { + m.askMacro = mac.Name + m.askMacroOn = rows + return m, nil + } + + ids := make([]string, 0, len(rows)) + for _, r := range rows { + ids = append(ids, r.ID) + } + return m.startRunOn(mac.Commands, rows, ids) +} + +// macroMismatch names the first selected model the macro was not built +// against. Empty for a macro with no model recorded: that is unknown, not +// wrong. Kept short enough to fit a modal line, since that is where it is +// read. +func macroMismatch(mac config.Macro, rows []elementRow) string { + if mac.Model == "" { + return "" + } + for _, r := range rows { + if r.Model != "" && r.Model != mac.Model { + return fmt.Sprintf("built on %s, selection has %s", mac.Model, r.Model) + } + } + return "" +} + +// macroWarnPrompt asks once. Command syntax differs by model and version, and +// a sequence written for one appliance can fail halfway through another. +func (m tuiModel) macroWarnPrompt() string { + mac, _ := m.cmds.Macro(m.askMacro) + rows := m.askMacroOn + + lines := []string{ + ui.StyleWarn.Render(ui.Truncate(mac.Name, 30)) + ui.StyleWarn.Render(" was built on other hardware"), + ui.StyleDim.Render(ui.Truncate(macroMismatch(mac, rows), 52)), + ui.StyleDim.Render("command syntax differs by model and version"), + "", + "run it anyway", + m.retryChoices(), + "", + ui.StyleDim.Render("answered once, then never asked again"), + } + return ui.Modal(lines, m.width) +} + +func (m tuiModel) overlayMacroWarn(content string) string { + prompt := m.macroWarnPrompt() + keep := max(0, m.height-ui.ModalHeight(len(strings.Split(prompt, "\n")))-1) + lines := strings.Split(content, "\n") + if len(lines) > keep { + lines = lines[:keep] + } + return strings.Join(lines, "\n") + "\n" + prompt +} + +// onMacroWarnKey answers it. Yes acknowledges for good, as the warning says: +// the alternative is a modal between the user and every cross-model run. +func (m tuiModel) onMacroWarnKey(key string) (tea.Model, tea.Cmd) { + switch key { + case "left", "right", "tab", "shift+tab", "h", "l": + m.askRetryNo = !m.askRetryNo + return m, nil + } + + name, rows := m.askMacro, m.askMacroOn + m.askMacro, m.askMacroOn = "", nil + yes := key == "y" || key == "Y" || (key == "enter" && !m.askRetryNo) + m.askRetryNo = false + if !yes { + m.status = "not run" + return m, nil + } + + m.cmds.Ack(warnMacroModel) + save := saveCommands(m.store, m.cmds) + mac, ok := m.cmds.Macro(name) + if !ok { + return m, save + } + next, cmd := m.runMacro(mac, rows) + return next, tea.Batch(save, cmd) +} + +// macroCommandLabel numbers the command lines from one, which is the order +// they run in. +func macroCommandLabel(i int) string { return fmt.Sprintf("command %d", i+1) } + +// macroPlaceholder puts an example on the first line only. On the ones below +// it, a placeholder reads like a command that is already there. +func macroPlaceholder(i int) string { + if i == 0 { + return "dump device status" + } + return "" +} + +func newMacroCommand(i int) formField { + return newField(macroCommandLabel(i), macroPlaceholder(i), false) +} + +// openMacroForm is one field per command rather than one comma-separated +// line: a command is not a list item, and a sequence read down the page is +// the thing being built. +func (m tuiModel) openMacroForm(mac config.Macro) (tea.Model, tea.Cmd) { + name := newField("name", "wan check", false) + name.input.SetValue(mac.Name) + fields := []formField{name} + for i, c := range mac.Commands { + f := newMacroCommand(i) + f.input.SetValue(c) + fields = append(fields, f) + } + + // Always an empty line to type into, which is what the ghost row under + // the form is pointing at. + fields = append(fields, newMacroCommand(len(mac.Commands))) + + m.formMacroName = mac.Name + title := "New macro" + if mac.Name != "" { + title = "Edit " + mac.Name + } + return m.openForm(formMacro, title, phaseAddUser, fields) +} + +// addMacroCommand grows the form by a line and moves to it. +func (m tuiModel) addMacroCommand() (tea.Model, tea.Cmd) { + m.form[m.formCursor].input.Blur() + m.form = append(m.form, newMacroCommand(len(m.form)-1)) + m.formCursor = len(m.form) - 1 + return m, m.form[m.formCursor].input.Focus() +} + +// removeMacroCommand takes an empty command line out and lands on the one +// above it, the way an editor does. The form is the whole editor, so without +// this a line in the middle could only be removed by retyping everything +// after it. +func (m tuiModel) removeMacroCommand() (tea.Model, tea.Cmd) { + m.form[m.formCursor].input.Blur() + m.form = append(m.form[:m.formCursor], m.form[m.formCursor+1:]...) + // The labels are the run order, so they renumber. + for i := 1; i < len(m.form); i++ { + m.form[i].label = macroCommandLabel(i - 1) + m.form[i].input.Placeholder = macroPlaceholder(i - 1) + } + + m.formCursor = max(1, min(m.formCursor, len(m.form)-1)) + in := &m.form[m.formCursor].input + in.SetCursor(len(in.Value())) + return m, in.Focus() +} + +// macroFormCommands is the command lines, in order, with the empty ones +// dropped: a line left blank in the middle is a line not filled in, not a +// command. +func (m tuiModel) macroFormCommands() []string { + var out []string + for i := 1; i < len(m.form); i++ { + if v := m.formValue(i); v != "" { + out = append(out, v) + } + } + return out +} + +// submitMacro saves the form. The model is the selection's, when the whole +// selection is one: that is what the sequence was written against, and it is +// what the cross-model warning compares. +func (m tuiModel) submitMacro() (tea.Model, tea.Cmd) { + name := m.formValue(0) + mac := config.Macro{Name: name, Commands: m.macroFormCommands(), Model: m.selectionModel()} + + // Renaming replaces rather than leaving the old name behind. + if m.formMacroName != "" && !strings.EqualFold(m.formMacroName, name) { + m.cmds.RemoveMacro(m.formMacroName) + } + if err := m.cmds.SetMacro(mac); err != nil { + m.status = err.Error() + return m, nil + } + m.form, m.formKind = nil, formNone + m.phase = m.formReturn + m.status = fmt.Sprintf("saved %s, %d command(s)", mac.Name, len(mac.Commands)) + return m, saveCommands(m.store, m.cmds) +} + +// selectionModel is the model of the selection when it is all one, and empty +// when it is mixed or empty: a macro that claims a model it was not built for +// would warn about the wrong thing. +func (m tuiModel) selectionModel() string { + rows, _ := m.selectedRows() + model := "" + for _, r := range rows { + switch { + case r.Model == "": + continue + case model == "": + model = r.Model + case model != r.Model: + return "" + } + } + return model +} + +func (m tuiModel) viewMacros() string { + var b strings.Builder + b.WriteString(ui.StyleCmd.Render("macros")) + + rows, _ := m.selectedRows() + b.WriteString(ui.StyleDim.Render(fmt.Sprintf(" %d element(s) selected", len(rows)))) + if m.macroFilterOn || m.macroFilter.Value() != "" { + b.WriteString(" " + m.macroFilter.View()) + } + b.WriteString("\n\n") + + // An action rather than a row, so it stays at the top whatever the filter + // says and is where the cursor rests when there is nothing to run. + newRow := "+ new macro" + if m.macroCursor == 0 { + b.WriteString(ui.StyleCmd.Render("> ") + ui.StyleOK.Render(newRow) + "\n") + } else { + b.WriteString(" " + ui.StyleGhost.Render(newRow) + "\n") + } + + list := m.macroRows() + if len(m.cmds.Macros) > 0 && len(list) == 0 { + b.WriteString(ui.StyleDim.Render(" nothing matches the filter") + "\n") + } + + for i, mac := range list { + cursor := " " + if i+1 == m.macroCursor { + cursor = ui.StyleCmd.Render("> ") + } + // Padded plain, then styled, so the counts line up down the page. + name := fmt.Sprintf("%-22s", ui.Truncate(mac.Name, 22)) + count := fmt.Sprintf("%-14s", fmt.Sprintf("%d command(s)", len(mac.Commands))) + // The model is the standing cross-model signal: warn-coloured when it + // is not what the selection is, since the question is only asked once. + tag := "" + if mac.Model != "" { + style := ui.StyleDim + if sel := m.selectionModel(); sel != "" && sel != mac.Model { + style = ui.StyleWarn + } + tag = style.Render(mac.Model + " ") + } + b.WriteString(cursor + ui.StyleElement.Render(name) + ui.StyleDim.Render(count) + tag + + ui.StyleDim.Render(ui.Truncate(strings.Join(mac.Commands, " · "), max(10, m.width-58))) + "\n") + } + + b.WriteString("\n") + b.WriteString(ui.Footer(m.status, + "↑↓ move · enter pick, then choose elements · e edit · ctrl+d delete · / filter · esc menu")) + return b.String() +} + +// modeLabel names the mode on the element list. Macro mode carries the macro +// with it: the mode means nothing without saying which one is armed. +func (m tuiModel) modeLabel() string { + label := ui.StyleElement.Render(m.mode.String() + " mode") + if m.mode == modeMacro && m.armedMacro != "" { + label += ui.StyleCmd.Render(" " + m.armedMacro) + } + return label +} diff --git a/internal/tui/macros_test.go b/internal/tui/macros_test.go new file mode 100644 index 0000000..0b8a61b --- /dev/null +++ b/internal/tui/macros_test.go @@ -0,0 +1,496 @@ +package tui + +import ( + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "tyrtop.com/iontk/internal/config" +) + +func macroModel(t *testing.T) tuiModel { + t.Helper() + m := historyModel(t) + for _, mac := range []config.Macro{ + {Name: "wan check", Commands: []string{"dump interface status all", "dump vpn count"}, Model: "ion 1200"}, + {Name: "ha check", Commands: []string{"dump spoke-ha status"}, Model: "ion 1200"}, + } { + if err := m.cmds.SetMacro(mac); err != nil { + t.Fatal(err) + } + } + // Wide enough for the command preview, which is the first thing a narrow + // terminal drops. + m.width = 120 + next, _ := m.openMacros() + return next.(tuiModel) +} + +func TestTheMacroPageListsFiltersAndDeletes(t *testing.T) { + m := macroModel(t) + if m.phase != phaseMacros { + t.Fatalf("phase = %v, want the macro page", m.phase) + } + // The cursor opens on the first macro, not on the row that makes one: + // the page is opened to run something far more often than to add one. + if m.macroCursor != 1 { + t.Errorf("cursor = %d, want the first macro", m.macroCursor) + } + + got := plain(m.viewMacros()) + for _, want := range []string{"macros", "+ new macro", "wan check", "2 command(s)", "ion 1200", "dump vpn count"} { + if !strings.Contains(got, want) { + t.Errorf("the page is missing %q:\n%s", want, got) + } + } + + // A macro is as often remembered by what it does as by its name, so the + // filter matches both. + next, _ := m.onMacroKey(tea.KeyPressMsg(tea.Key{Code: '/', Text: "/"}), "/") + m = next.(tuiModel) + if !m.macroFilterOn { + t.Fatal("/ did not open the filter") + } + m = typeString(t, m, "spoke") + if rows := m.macroRows(); len(rows) != 1 || rows[0].Name != "ha check" { + t.Errorf("filtered rows = %+v, want the macro whose command matches", rows) + } + // Filtering is done to find a macro, so the cursor lands on the match + // rather than on the row above it. + if mac, ok := m.macroAt(m.macroCursor); !ok || mac.Name != "ha check" { + t.Errorf("cursor %d = %+v, %v, want the match", m.macroCursor, mac, ok) + } + + next, _ = m.onMacroKey(tea.KeyPressMsg(tea.Key{Code: tea.KeyEsc}), "esc") + m = next.(tuiModel) + if m.macroFilterOn || len(m.macroRows()) != 2 { + t.Errorf("esc did not clear the filter: on=%v rows=%d", m.macroFilterOn, len(m.macroRows())) + } + + m.macroCursor = 1 + next, cmd := m.onMacroKey(tea.KeyPressMsg(tea.Key{Code: 'd', Mod: tea.ModCtrl}), "ctrl+d") + m = next.(tuiModel) + if _, ok := m.cmds.Macro("wan check"); ok { + t.Error("ctrl+d left the macro in place") + } + if cmd == nil { + t.Fatal("deleting queued no write") + } + runSave(t, cmd) + saved, err := m.store.Commands() + if err != nil { + t.Fatal(err) + } + if _, ok := saved.Macro("wan check"); ok { + t.Error("the deletion was not written through") + } +} + +// A macro that writes is refused as a unit, before anything is dialled: +// command by command would leave it half run, with the write it was refused +// for sitting after whatever already took effect. +func TestAMacroIsGatedAsAUnit(t *testing.T) { + m := macroModel(t) + mac := config.Macro{Name: "risky", Commands: []string{ + "dump device status", + "config banner hello", + }} + if err := m.cmds.SetMacro(mac); err != nil { + t.Fatal(err) + } + next, cmd := m.runMacro(mac, m.all) + got := next.(tuiModel) + if got.phase == phaseRunning { + t.Fatal("a macro with a write in it started running") + } + if cmd != nil { + t.Error("the refused macro queued work") + } + for _, want := range []string{"risky", "not read-only", "config banner hello"} { + if !strings.Contains(got.status, want) { + t.Errorf("status = %q, want it to name %q", got.status, want) + } + } + // The read-only command before it must not have run either, which is what + // "as a unit" means: nothing was dialled at all. + if got.events != nil { + t.Error("the run stream was opened for a refused macro") + } + + // Allowing writes lifts it, as it does for a typed command. + m.cfg.AllowWrites = true + next, _ = m.runMacro(mac, m.all) + if status := next.(tuiModel).status; strings.Contains(status, "not read-only") { + t.Errorf("AllowWrites did not lift the guard: %q", status) + } +} + +func TestAMacroNeedsSomethingToRunOn(t *testing.T) { + m := macroModel(t) + mac, _ := m.cmds.Macro("ha check") + + next, cmd := m.runMacro(mac, nil) + got := next.(tuiModel) + if cmd != nil || got.phase == phaseRunning { + t.Error("the macro ran with nothing selected") + } + if !strings.Contains(got.status, "nothing selected") { + t.Errorf("status = %q, want it to say what is missing", got.status) + } +} + +// Across models, warn once: syntax differs by model and version, and a +// sequence written for one appliance can fail halfway through another. +func TestACrossModelMacroAsksOnceAndThenNeverAgain(t *testing.T) { + m := macroModel(t) + mac, _ := m.cmds.Macro("ha check") // built on an ion 1200 + on := rowsOfModel(m, "ion 3200") + + next, cmd := m.runMacro(mac, on) + m = next.(tuiModel) + if m.askMacro != "ha check" { + t.Fatalf("askMacro = %q, want the macro held on the question", m.askMacro) + } + if cmd != nil { + t.Error("the macro ran before the question was answered") + } + prompt := plain(m.macroWarnPrompt()) + for _, want := range []string{"other hardware", "ion 1200", "ion 3200", "run it anyway"} { + if !strings.Contains(prompt, want) { + t.Errorf("the question is missing %q:\n%s", want, prompt) + } + } + + // No is no, and nothing is acknowledged by it. + next, _ = m.onMacroWarnKey("n") + declined := next.(tuiModel) + if declined.askMacro != "" || declined.cmds.Acked(warnMacroModel) { + t.Errorf("declining acknowledged the warning: askMacro=%q acked=%v", + declined.askMacro, declined.cmds.Acked(warnMacroModel)) + } + + // Asking again, and answering yes, acknowledges it for good. + next, _ = declined.runMacro(mac, on) + m = next.(tuiModel) + next, cmd = m.onMacroWarnKey("y") + m = next.(tuiModel) + if !m.cmds.Acked(warnMacroModel) { + t.Error("yes did not acknowledge the warning") + } + if cmd == nil { + t.Fatal("the acknowledgement queued no write") + } + runSave(t, cmd) + saved, err := m.store.Commands() + if err != nil { + t.Fatal(err) + } + if !saved.Acked(warnMacroModel) { + t.Error("the acknowledgement was not written through, so it would ask again next run") + } + + // Acknowledged: the next cross-model run does not stop to ask. The + // standing signal is the model tag on the row, which the run cannot clear + // the way it clears the status line. + next, _ = m.runMacro(mac, on) + again := next.(tuiModel) + if again.askMacro != "" { + t.Error("the warning was asked a second time after being acknowledged") + } + if !strings.Contains(plain(again.viewMacros()), "ion 1200") { + t.Errorf("the row does not say what the macro was built on:\n%s", plain(again.viewMacros())) + } +} + +// A macro with no model recorded is unknown, not wrong, so it does not ask. +func TestAMacroWithNoModelDoesNotWarn(t *testing.T) { + m := macroModel(t) + mac := config.Macro{Name: "anywhere", Commands: []string{"dump device status"}} + if err := m.cmds.SetMacro(mac); err != nil { + t.Fatal(err) + } + next, _ := m.runMacro(mac, m.all) + if got := next.(tuiModel); got.askMacro != "" { + t.Errorf("askMacro = %q, want no question for a macro with no model", got.askMacro) + } +} + +func rowsOfModel(m tuiModel, model string) []elementRow { + var out []elementRow + for _, r := range m.all { + if r.Model == model { + out = append(out, r) + } + } + return out +} + +// Picking a macro takes you to the element list with it in hand, which is +// batch mode's order: the list is where elements are found and selected. +func TestPickingAMacroArmsTheElementList(t *testing.T) { + m := macroModel(t) + m.macroCursor = 1 + mac, _ := m.macroAt(1) + + next, _ := m.onMacroKey(tea.KeyPressMsg(tea.Key{Code: tea.KeyEnter}), "enter") + m = next.(tuiModel) + if m.phase != phasePicking { + t.Fatalf("phase = %v, want the element list", m.phase) + } + if m.mode != modeMacro || m.armedMacro != mac.Name { + t.Fatalf("mode = %v, armed = %q, want macro mode holding %q", m.mode, m.armedMacro, mac.Name) + } + // The header and the hint both name it: the mode means nothing without + // saying which macro is in hand. + got := plain(m.viewPicking()) + for _, want := range []string{"macro mode", mac.Name, "enter run " + mac.Name} { + if !strings.Contains(got, want) { + t.Errorf("the list is missing %q:\n%s", want, got) + } + } + + // Cycling the mode puts it down, or the list would hold a macro with + // nothing on screen saying so. + next, _ = m.Update(tea.KeyPressMsg(tea.Key{Code: 'm', Mod: tea.ModAlt})) + after := next.(tuiModel) + if after.mode == modeMacro || after.armedMacro != "" { + t.Errorf("alt+m left mode %v armed with %q", after.mode, after.armedMacro) + } +} + +// Enter in macro mode runs the armed macro on what is selected, or on the +// highlighted row when nothing is, like every other mode. +func TestEnterInMacroModeRunsTheArmedMacro(t *testing.T) { + m := macroModel(t) + mac := config.Macro{Name: "risky", Commands: []string{"config banner hello"}} + if err := m.cmds.SetMacro(mac); err != nil { + t.Fatal(err) + } + m.mode = modeMacro + m.armedMacro = "risky" + m.phase = phasePicking + m.rebuildRows() + + // Nothing ticked: the highlighted row is the target, and the read-only + // gate is what stops this one. + next, _ := m.onPickEnter() + got := next.(tuiModel) + if got.phase == phaseRunning { + t.Fatal("a macro with a write in it ran from the element list") + } + if !strings.Contains(got.status, "not read-only") { + t.Errorf("status = %q, want the macro refused by name", got.status) + } + + // A macro deleted while armed says so rather than running nothing. + m.cmds.RemoveMacro("risky") + next, _ = m.onPickEnter() + gone := next.(tuiModel) + if gone.mode == modeMacro || !strings.Contains(gone.status, "gone") { + t.Errorf("mode = %v, status = %q, want macro mode dropped", gone.mode, gone.status) + } +} + +// The top row is how a macro is made, and the form grows a line at a time +// rather than taking a comma-separated list. +func TestTheMacroFormGrowsALineAtATime(t *testing.T) { + m := macroModel(t) + // One model across the selection, which is what the macro records. + for _, r := range m.all { + if r.Model == "ion 1200" { + m.selected[r.key()] = true + } + } + + m.macroCursor = 0 + next, _ := m.onMacroKey(tea.KeyPressMsg(tea.Key{Code: tea.KeyEnter}), "enter") + m = next.(tuiModel) + if m.formKind != formMacro { + t.Fatalf("formKind = %v, want the macro form", m.formKind) + } + // A name and one empty command line, with the ghost row pointing at the + // next one. + if len(m.form) != 2 { + t.Fatalf("form = %d fields, want a name and one command line", len(m.form)) + } + if got := plain(m.viewForm()); !strings.Contains(got, "+ another command") { + t.Errorf("the form does not offer another command:\n%s", got) + } + + m.form[0].input.SetValue("boot check") + m.form[1].input.SetValue("dump device status") + + // Tab on the last line, with something on it, adds the next one. + m.formCursor = 1 + next, _ = m.onFormKey(tea.KeyPressMsg(tea.Key{Code: tea.KeyTab}), "tab") + m = next.(tuiModel) + if len(m.form) != 3 || m.formCursor != 2 { + t.Fatalf("form = %d fields, cursor %d, want a third field and the cursor on it", len(m.form), m.formCursor) + } + if got := m.form[2].label; got != "command 2" { + t.Errorf("label = %q, want the commands numbered in run order", got) + } + m.form[2].input.SetValue("dump interface status all") + + // And on an empty line it does not: a form that grew on every tab would + // leave blanks to walk through. + next, _ = m.onFormKey(tea.KeyPressMsg(tea.Key{Code: tea.KeyTab}), "tab") + m = next.(tuiModel) + if len(m.form) != 4 { + t.Fatalf("form = %d fields, want the third command line", len(m.form)) + } + next, _ = m.onFormKey(tea.KeyPressMsg(tea.Key{Code: tea.KeyTab}), "tab") + m = next.(tuiModel) + if len(m.form) != 4 { + t.Errorf("form = %d fields after tab on an empty line, want it unchanged", len(m.form)) + } + + next, cmd := m.submitForm() + m = next.(tuiModel) + mac, ok := m.cmds.Macro("boot check") + if !ok { + t.Fatal("the macro was not saved") + } + want := []string{"dump device status", "dump interface status all"} + if strings.Join(mac.Commands, "|") != strings.Join(want, "|") { + t.Errorf("commands = %v, want %v in order, with the empty line dropped", mac.Commands, want) + } + if mac.Model != "ion 1200" { + t.Errorf("model = %q, want the selection's", mac.Model) + } + runSave(t, cmd) +} + +// Backspace on an empty command line takes it out: the form is the whole +// editor, so otherwise a line in the middle could only go by retyping +// everything after it. +func TestBackspaceOnAnEmptyLineRemovesIt(t *testing.T) { + m := macroModel(t) + m.macroCursor = 1 + next, _ := m.onMacroKey(tea.KeyPressMsg(tea.Key{Code: 'e', Text: "e"}), "e") + m = next.(tuiModel) + // name, two commands, and the empty line for a third. + if len(m.form) != 4 { + t.Fatalf("form = %d fields, want the macro's two commands plus an empty line", len(m.form)) + } + + // The trailing empty line goes, and the cursor lands on the one above. + m.formCursor = 3 + next, _ = m.onFormKey(tea.KeyPressMsg(tea.Key{Code: tea.KeyBackspace}), "backspace") + m = next.(tuiModel) + if len(m.form) != 3 { + t.Fatalf("form = %d fields, want the empty line removed", len(m.form)) + } + if m.formCursor != 2 { + t.Errorf("cursor = %d, want the line above the one removed", m.formCursor) + } + + // A line in the middle: empty it, backspace, and what was below moves up + // and renumbers. + m.formCursor = 1 + m.form[1].input.SetValue("") + next, _ = m.onFormKey(tea.KeyPressMsg(tea.Key{Code: tea.KeyBackspace}), "backspace") + m = next.(tuiModel) + if len(m.form) != 2 { + t.Fatalf("form = %d fields, want the emptied line removed", len(m.form)) + } + if got := m.formValue(1); got != "dump vpn count" { + t.Errorf("command 1 = %q, want what was below the removed line", got) + } + if got := m.form[1].label; got != "command 1" { + t.Errorf("label = %q, want the lines renumbered", got) + } + + // The last command line stays, so there is always somewhere to type. + m.formCursor = 1 + m.form[1].input.SetValue("") + next, _ = m.onFormKey(tea.KeyPressMsg(tea.Key{Code: tea.KeyBackspace}), "backspace") + m = next.(tuiModel) + if len(m.form) != 2 { + t.Errorf("form = %d fields, want the last command line kept", len(m.form)) + } + + // And the name is never a line to remove. + m.formCursor = 0 + m.form[0].input.SetValue("") + next, _ = m.onFormKey(tea.KeyPressMsg(tea.Key{Code: tea.KeyBackspace}), "backspace") + if got := len(next.(tuiModel).form); got != 2 { + t.Errorf("form = %d fields after backspace on the name, want it untouched", got) + } +} + +func TestEditingAMacroReopensItsCommands(t *testing.T) { + m := macroModel(t) + m.macroCursor = 1 + + next, _ := m.onMacroKey(tea.KeyPressMsg(tea.Key{Code: 'e', Text: "e"}), "e") + m = next.(tuiModel) + if m.formKind != formMacro { + t.Fatalf("formKind = %v, want the macro form", m.formKind) + } + // Name, its two commands, and an empty line for a third. + if len(m.form) != 4 { + t.Fatalf("form = %d fields, want the macro's commands plus an empty line", len(m.form)) + } + if got := m.formValue(1); got != "dump interface status all" { + t.Errorf("first command = %q, want the saved one", got) + } + + // Renaming replaces rather than leaving the old name behind. + old := m.formMacroName + m.form[0].input.SetValue(old + " v2") + next, _ = m.submitForm() + m = next.(tuiModel) + if _, ok := m.cmds.Macro(old); ok { + t.Errorf("%q survived the rename", old) + } + if mac, ok := m.cmds.Macro(old + " v2"); !ok || len(mac.Commands) != 2 { + t.Errorf("renamed macro = %+v, %v, want its commands kept", mac, ok) + } + + // A macro needs a name and something to run. + m.form = []formField{newField("name", "", false), newMacroCommand(0)} + m.formKind = formMacro + next, _ = m.submitForm() + if !strings.Contains(next.(tuiModel).status, "needs a name") { + t.Errorf("status = %q, want the empty form refused", next.(tuiModel).status) + } +} + +// A mixed selection records no model: claiming one would warn about the wrong +// thing later. +func TestAMixedSelectionRecordsNoModel(t *testing.T) { + m := macroModel(t) + for _, r := range m.all { + m.selected[r.key()] = true + } + if got := m.selectionModel(); got != "" { + t.Errorf("selectionModel() = %q, want empty for a mixed selection", got) + } +} + +// Macro mode is only reachable by picking a macro, so the element list opened +// from the menu is never in it: it used to still be holding one from earlier +// in the session. +func TestTheMenuDoesNotOpenTheListInMacroMode(t *testing.T) { + m := macroModel(t) + m.macroCursor = 1 + next, _ := m.onMacroKey(tea.KeyPressMsg(tea.Key{Code: tea.KeyEnter}), "enter") + m = next.(tuiModel) + if m.mode != modeMacro { + t.Fatalf("mode = %v, want macro mode after picking one", m.mode) + } + + // Back to the menu, then into the list the ordinary way. + m.phase = phaseMenu + m.menuCursor = 0 + next, _ = m.onMenuKey("enter") + got := next.(tuiModel) + if got.mode == modeMacro || got.armedMacro != "" { + t.Errorf("mode = %v armed with %q, want the macro put down", got.mode, got.armedMacro) + } + // And it comes back as whatever the default mode is, not something new. + if got.mode != modeShell { + t.Errorf("mode = %v, want the saved default", got.mode) + } +} diff --git a/internal/tui/quitkeys_test.go b/internal/tui/quitkeys_test.go index f30bc6f..8a13399 100644 --- a/internal/tui/quitkeys_test.go +++ b/internal/tui/quitkeys_test.go @@ -43,6 +43,10 @@ func TestQClosesThePageLikeEsc(t *testing.T) { m.phase = phaseRunning return m }, phaseResults}, + {"macros", func(t *testing.T) tuiModel { + next, _ := pickModel(t).openMacros() + return next.(tuiModel) + }, phaseMenu}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/tui/results.go b/internal/tui/results.go index ea9b3a2..0676f36 100644 --- a/internal/tui/results.go +++ b/internal/tui/results.go @@ -14,7 +14,7 @@ import ( ) func newResultsTable() table.Model { - return table.New( + t := table.New( table.WithColumns([]table.Column{ {Title: "", Width: 2}, {Title: "element", Width: 26}, @@ -24,6 +24,62 @@ func newResultsTable() table.Model { }), table.WithFocused(true), ) + t.SetStyles(tableStyles()) + return t +} + +// moveResultCursor has the same reason as the element list's: the widget's +// MoveUp scrolls its viewport away from the cursor, so moving up rebuilds the +// scroll state and walks down to the row. +func (m *tuiModel) moveResultCursor(delta int) { + rows := m.resTable.Rows() + if len(rows) == 0 { + return + } + n := max(0, min(m.resTable.Cursor()+delta, len(rows)-1)) + if at := m.resTable.Cursor(); n >= at { + m.resTable.MoveDown(n - at) + return + } + + fresh := newResultsTable() + fresh.SetColumns(m.resTable.Columns()) + fresh.SetRows(rows) + fresh.SetWidth(m.resTable.Width()) + fresh.SetHeight(m.resTableHeight()) + fresh.MoveDown(n) + m.resTable = fresh +} + +// resOutputRoom is how many lines of an open result fit under the table. The +// keys and the view both ask, so neither can disagree about where the window +// ends. +func (m tuiModel) resOutputRoom() int { + // header, filter line, the table, the divider and the element name. + used := 4 + m.resTableHeight() + return max(2, m.height-used-2-m.statsRows()) +} + +// moveResLine moves the highlight through an open result's output and keeps +// the window on it. The output scrolls like the list above it rather than +// being a block of text with no cursor in it. +func (m tuiModel) moveResLine(delta int) tuiModel { + idx, ok := m.currentResult() + if !ok { + return m + } + n := len(m.resultDetail(idx)) + room := m.resOutputRoom() + + m.resLine = max(0, min(m.resLine+delta, n-1)) + if m.resLine < m.resScroll { + m.resScroll = m.resLine + } + if m.resLine >= m.resScroll+room { + m.resScroll = m.resLine - room + 1 + } + m.resScroll = max(0, min(m.resScroll, max(0, n-room))) + return m } func newResultsFilter() textinput.Model { @@ -131,7 +187,7 @@ func (m tuiModel) onResultsKey(msg tea.KeyPressMsg, key string) (tea.Model, tea. switch { case m.resOpen: m.resOpen = false - m.resScroll = 0 + m.resScroll, m.resLine = 0, 0 m.rebuildResultRows() case m.resFilterOn || m.resFilter.Value() != "": m.resFilterOn = false @@ -143,31 +199,57 @@ func (m tuiModel) onResultsKey(msg tea.KeyPressMsg, key string) (tea.Model, tea. } return m, nil - case "up", "down", "home", "end": + case "up", "down", "k", "j", "home", "end": + if key == "k" || key == "j" { + // Letters while the query is being typed are text. + if m.resFilterOn { + break + } + } + // An open result scrolls on the same keys the list moves on, with the + // highlight on the line it is scrolled to. if m.resOpen { - m.resOpen = false - m.resScroll = 0 - m.rebuildResultRows() + switch key { + case "up", "k": + return m.moveResLine(-1), nil + case "down", "j": + return m.moveResLine(1), nil + case "home": + return m.moveResLine(-1 << 30), nil + case "end": + return m.moveResLine(1 << 30), nil + } } - var cmd tea.Cmd - m.resTable, cmd = m.resTable.Update(msg) - return m, cmd + switch key { + case "up", "k": + m.moveResultCursor(-1) + case "down", "j": + m.moveResultCursor(1) + case "home": + m.moveResultCursor(-1 << 30) + case "end": + m.moveResultCursor(1 << 30) + } + return m, nil - case "pgup": - if m.resOpen { - m.resScroll = max(0, m.resScroll-5) - return m, nil + case "pgup", "pgdown": + step := max(1, m.resOutputRoom()-1) + if !m.resOpen { + step = max(1, m.resTable.Height()-1) + } + if key == "pgup" { + step = -step } - case "pgdown": if m.resOpen { - m.resScroll += 5 - return m, nil + return m.moveResLine(step), nil } + m.moveResultCursor(step) + return m, nil case "enter": if _, ok := m.currentResult(); ok { m.resOpen = !m.resOpen - m.resScroll = 0 + m.resScroll, m.resLine = 0, 0 m.rebuildResultRows() } return m, nil @@ -190,6 +272,12 @@ func (m tuiModel) onResultsKey(msg tea.KeyPressMsg, key string) (tea.Model, tea. var cmd tea.Cmd m.resFilter, cmd = m.resFilter.Update(msg) m.applyResultFilter() + // Deleting the last character leaves the filter, as on every other + // page that has one. + if key == "backspace" && m.resFilter.Value() == "" { + m.resFilterOn = false + m.resFilter.Blur() + } return m, cmd } @@ -301,14 +389,19 @@ func (m tuiModel) viewResults() string { b.WriteString(ui.StyleElement.Render(name) + "\n") lines := m.resultDetail(idx) - used := len(strings.Split(strings.TrimRight(b.String(), "\n"), "\n")) - room := max(2, m.height-used-2-m.statsRows()) + room := m.resOutputRoom() + width := max(10, m.width-1) start := min(m.resScroll, max(0, len(lines)-room)) for i := start; i < len(lines) && i-start < room; i++ { - b.WriteString(ui.Truncate(lines[i], max(10, m.width-1)) + "\n") - } - if rest := len(lines) - start - room; rest > 0 { - b.WriteString(ui.StyleDim.Render(fmt.Sprintf(" … %d more lines (pgdn)", rest)) + "\n") + if i == m.resLine { + // Padded plain and restyled: the line already carries its own + // colours, and a highlight wrapped around them would end at + // the first reset inside it. + row := fmt.Sprintf("%-*s", width, ui.Truncate(ui.Plain(lines[i]), width)) + b.WriteString(ui.StyleRow.Render(row) + "\n") + continue + } + b.WriteString(ui.Truncate(lines[i], width) + "\n") } } @@ -321,13 +414,23 @@ func (m tuiModel) viewResults() string { body += "\n" + m.statsPane() } - hint := "↑↓ move · / filter · enter open · f/o format · s save · esc/q back" + status := m.status + hint := "↑↓/jk move · / filter · enter open · f/o format · s save · esc/q back" if m.resOpen { - hint = "↑↓ close and move · pgup/pgdn scroll · esc/q close" + hint = "↑↓/jk scroll · pgup/pgdn page · home/end ends · esc/q close" + // Where in the output the highlight is, since the pane holds a + // fraction of a long run's output. + if idx, ok := m.currentResult(); ok { + where := fmt.Sprintf("line %d of %d", m.resLine+1, len(m.resultDetail(idx))) + if status != "" { + where += " · " + } + status = where + status + } } // Truncated rather than wrapped: a hint that takes two rows on a narrow // terminal moves the whole frame. - return body + "\n" + ui.Footer(m.status, ui.Truncate(hint, max(10, m.width-1))) + return body + "\n" + ui.Footer(status, ui.Truncate(hint, max(10, m.width-1))) } func (m tuiModel) seedResultsForTest(rs []sase.Result, el []time.Duration) tuiModel { diff --git a/internal/tui/results_test.go b/internal/tui/results_test.go index 0b0f9eb..9e0bcd9 100644 --- a/internal/tui/results_test.go +++ b/internal/tui/results_test.go @@ -95,23 +95,61 @@ func TestResultsEnterExpandsInlineNotANewTab(t *testing.T) { } } -func TestResultsScrollingDownClosesTheExpansion(t *testing.T) { +// An open result scrolls on the keys the list moves on, with the highlight on +// the line it is scrolled to. It used to close on the first ↑↓, which made +// reading a screen of output a matter of pgdn and hope. +func TestAnOpenResultScrollsWithAHighlight(t *testing.T) { m := resultsModel(t) + m.height = 20 mm, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyEnter})) open := mm.(tuiModel) if !open.resOpen { t.Fatal("expected an open row") } + if open.resLine != 0 { + t.Errorf("resLine = %d, want the top of the output", open.resLine) + } mm, _ = open.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyDown})) moved := mm.(tuiModel) + if !moved.resOpen { + t.Error("↓ closed the output instead of scrolling it") + } + if moved.resLine != 1 { + t.Errorf("resLine = %d, want the highlight moved down one", moved.resLine) + } + if moved.resTable.Cursor() != 0 { + t.Errorf("cursor = %d, want the list left where it was", moved.resTable.Cursor()) + } + // j and k do the same, and the highlight is drawn on the line. + mm, _ = moved.Update(tea.KeyPressMsg(tea.Key{Code: 'j', Text: "j"})) + moved = mm.(tuiModel) + if moved.resLine != 2 { + t.Errorf("resLine = %d after j, want 2", moved.resLine) + } + mm, _ = moved.Update(tea.KeyPressMsg(tea.Key{Code: 'k', Text: "k"})) + moved = mm.(tuiModel) + if moved.resLine != 1 { + t.Errorf("resLine = %d after k, want it back at 1", moved.resLine) + } + if got := moved.viewResults(); !strings.Contains(got, ui.StyleRow.Render("")[:4]) { + t.Error("no highlight anywhere in the open output") + } + // The footer says where in the output the highlight is. + if got := plain(moved.viewResults()); !strings.Contains(got, "line 2 of") { + t.Errorf("the footer does not say where the highlight is:\n%s", got) + } - if moved.resOpen { - t.Error("moving down should close the expansion") + // Closing hands the keys back to the list. + mm, _ = moved.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyEsc})) + closed := mm.(tuiModel) + if closed.resOpen { + t.Fatal("esc did not close the output") } - if moved.resTable.Cursor() != 1 { - t.Errorf("cursor = %d, want it to have moved to 1", moved.resTable.Cursor()) + mm, _ = closed.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyDown})) + if got := mm.(tuiModel).resTable.Cursor(); got != 1 { + t.Errorf("cursor = %d once the output is closed, want it to move", got) } } diff --git a/internal/tui/session.go b/internal/tui/session.go index 187221c..bbcbacd 100644 --- a/internal/tui/session.go +++ b/internal/tui/session.go @@ -211,9 +211,10 @@ func (m tuiModel) selectTab(i int) tuiModel { func (m tuiModel) onTabKey(key string) (tuiModel, bool) { switch key { - case "alt+right": + // hjkl's own pair for the tab ring, since the lists move on hjkl too. + case "alt+right", "alt+l": return m.selectTab(m.currentTab() + 1), true - case "alt+left": + case "alt+left", "alt+h": return m.selectTab(m.currentTab() - 1), true case "alt+w", "alt+k": // A data pane has no connection to detach, so both keys close it. @@ -235,9 +236,6 @@ func (m tuiModel) onTabKey(key string) (tuiModel, bool) { } return m, true } - if len(key) == 5 && strings.HasPrefix(key, "alt+") && key[4] >= '1' && key[4] <= '9' { - return m.selectTab(int(key[4] - '1')), true - } return m, false } @@ -245,6 +243,9 @@ func (m tuiModel) onSessionKey(msg tea.KeyPressMsg, key string) (tea.Model, tea. if m.slash != slashOff { return m.onSlashKey(msg, key) } + if m.favPick { + return m.onFavouriteKey(key) + } if next, handled := m.onTabKey(key); handled { return next, nil @@ -258,19 +259,38 @@ func (m tuiModel) onSessionKey(msg tea.KeyPressMsg, key string) (tea.Model, tea. case "ctrl+]": m.phase = phasePicking return m, nil + case "alt+f": + return m.openFavourites() + } + + // Before the session checks below: a favourite is worth reaching on a tab + // whose connection has died, if only to copy it into a fresh one. + if n, ok := altDigit(key); ok { + return m.useFavourite(n) } s := m.sessions.Active() - if s == nil || s.Err() != nil { + if s == nil { return m, nil } - // A redial is a new CLI session: enable and config context are gone, so it - // is never automatic for a shell someone is sitting in front of. - if key == "alt+r" && s.Hung() { - return m.redialActive() + // A redial is a new CLI session: enable and config context are gone, so + // it is never automatic for a shell someone is sitting in front of. It + // has to cover a tab that failed as well as one that went quiet -- a torn + // connection is the common way to lose a session, and alt+r used to do + // nothing at all on one. + if key == "alt+r" { + if s.Err() != nil || s.Hung() || s.Ended() { + return m.redialActive() + } + // Swallowed rather than typed at the device: the footer offers this + // key, so sending it as an escape sequence is the one thing it must + // not do. + m.status = "this session is still answering — alt+k closes it" + return m, nil } - if s.Hung() { + + if s.Err() != nil || s.Hung() { return m, nil } @@ -285,8 +305,15 @@ func (m tuiModel) onSessionKey(msg tea.KeyPressMsg, key string) (tea.Model, tea. // Anything typed belongs at the live bottom: the device is about to answer // it, and a pane held above that would not show what it said. m.sesScroll = 0 + + // Counted before the key is forwarded: sending it clears the line, and + // the device echoes it away. + var save tea.Cmd + if key == "enter" { + save = m.recordSessionLine() + } s.Type(uv.KeyPressEvent(uv.Key(tea.Key(msg)))) - return m, nil + return m, save } // onScrollKey holds the pane above its live bottom. The device has no @@ -339,9 +366,41 @@ func (m tuiModel) redialActive() (tea.Model, tea.Cmd) { } eid, name := s.ElementID, s.Name tsg := m.tsgForElement(eid) - m.sessions.CloseActive() + if tsg == "" { + // An element the list no longer holds -- a reattached tab after a + // cache drop -- would otherwise redial with no tenant and report that + // nothing could be reached. + tsg = m.tenant.TSGID + } + + // The dead tab stays until the new session is actually up. It is the only + // record of what the session said and how it failed, and closing it first + // left the user looking at another ION's pane with nothing to say a dial + // was even running. onSessionOpened swaps them over. m.status = "" - return m.openSession(eid, name, tsg) + next, cmd := m.openSession(eid, name, tsg) + nm, ok := next.(tuiModel) + if !ok || cmd == nil { + return next, cmd + } + nm.redialing = eid + nm.status = "redialling " + name + return nm, cmd +} + +// onSessionOpened finishes a redial by taking the dead tab away, now that +// there is a live one on the same element to put in its place. +func (m tuiModel) onSessionOpened(eid string) tuiModel { + m.connecting = "" + m.dialDone(eid) + if m.redialing == eid { + m.redialing = "" + m.status = "" + m.sessions.CloseDeadOn(eid) + m.sesScroll = 0 + } + m.rebuildRows() + return m } func (m tuiModel) tabBar() string { @@ -350,7 +409,9 @@ func (m tuiModel) tabBar() string { return styleTabAt(i, i == cur).Render(label) } - parts := []string{render(0, fmt.Sprintf(" 1 elements (%s) ", m.mode))} + // No numbers any more: alt+N is a favourite command, and a number on a + // tab is an invitation to press the chord that no longer selects it. + parts := []string{render(0, fmt.Sprintf(" elements (%s) ", m.mode))} for i, s := range m.sessions.All() { mark := "" switch { @@ -359,7 +420,7 @@ func (m tuiModel) tabBar() string { case s.Hung(): mark = "~ " } - parts = append(parts, render(i+1, fmt.Sprintf(" %d %s%s ", i+2, mark, s.Name))) + parts = append(parts, render(i+1, fmt.Sprintf(" %s%s ", mark, s.Name))) } for i, t := range m.data { n := 1 + m.sessions.Count() + i @@ -367,7 +428,7 @@ func (m tuiModel) tabBar() string { if t.failed() { mark = ui.MarkCross.String() + " " } - parts = append(parts, render(n, fmt.Sprintf(" %d %s%s · data ", n+1, mark, t.name))) + parts = append(parts, render(n, fmt.Sprintf(" %s%s · data ", mark, t.name))) } bar := strings.Join(parts, " ") if n := m.sessions.DetachedCount(); n > 0 { @@ -410,12 +471,17 @@ func (m tuiModel) renderSession() (string, *tea.Cursor) { return b.String(), nil } - const hint = "ctrl+] elements · alt+←/→ tab · alt+w detach · alt+t reattach · alt+k close · shift+pgup scroll" + const hint = "ctrl+] elements · alt+←→/hl tab · alt+1-9 favourite · alt+f save · alt+w detach · alt+k close · shift+pgup scroll" status := m.status switch { + // While a redial is running, the footer says that rather than offering + // the key that started it again. + case m.redialing == s.ElementID: case s.Err() != nil: - status = s.Err().Error() + // The key is on the error too: a torn connection is the usual way to + // lose a session, and the error alone said nothing about what to do. + status = s.Err().Error() + " — alt+r opens a new session on this ION" case s.Hung(): status = "connection stopped answering — alt+r opens a new session on this ION" } @@ -436,8 +502,12 @@ func (m tuiModel) renderSession() (string, *tea.Cursor) { width := max(10, m.width-1) b.WriteString(ui.Footer(ui.Truncate(status, width), ui.Truncate(hint, width))) - // No cursor unless the pane is live. Pointing it at frozen output, or at a - // session that has stopped answering, says something is being typed there. + // No cursor unless the pane is live. Pointing it at frozen output, at a + // session that has stopped answering, or at a pane with the slot picker + // over it says something is being typed there. + if m.favPick { + return m.overlayFavourites(b.String()), nil + } if view.Offset > 0 || s.Err() != nil || s.Hung() { return b.String(), nil } diff --git a/internal/tui/session_test.go b/internal/tui/session_test.go index bd8cfc9..cd8bfd3 100644 --- a/internal/tui/session_test.go +++ b/internal/tui/session_test.go @@ -143,9 +143,10 @@ func TestSessionEscapeHatches(t *testing.T) { func TestTabBarAlwaysHasElementsTab(t *testing.T) { m := pickModel(t) + // No number on it: alt+N is a favourite command now, not a tab. got := plain(m.tabBar()) - if !strings.Contains(got, "1 elements") { - t.Errorf("tabBar = %q, want an elements tab", got) + if !strings.Contains(got, "elements") || strings.Contains(got, "1 elements") { + t.Errorf("tabBar = %q, want an unnumbered elements tab", got) } if m.currentTab() != 0 { t.Errorf("currentTab() = %d in the picker, want 0", m.currentTab()) @@ -174,7 +175,7 @@ func TestNilManagerBehavesAsEmpty(t *testing.T) { if m.tabCount() != 1 { t.Errorf("tabCount() = %d with a nil manager, want 1", m.tabCount()) } - if got := plain(m.tabBar()); !strings.Contains(got, "1 elements") { + if got := plain(m.tabBar()); !strings.Contains(got, "elements") { t.Errorf("tabBar = %q", got) } m.sessions.CloseAll() @@ -685,10 +686,20 @@ type recConn struct { mu sync.Mutex written []byte done chan struct{} - once sync.Once + // data is what the device says. A socket with nothing on it is a real + // state -- the SASE side accepts one for an element that is not there -- + // so a test has to be able to leave it mute. + data chan []byte + once sync.Once +} + +func newRecConn() *recConn { + return &recConn{done: make(chan struct{}), data: make(chan []byte, 8)} } -func newRecConn() *recConn { return &recConn{done: make(chan struct{})} } +// feed is the device speaking, which is what tells a session there is an ION +// on the other end. +func (c *recConn) feed(s string) { c.data <- []byte(s) } func (c *recConn) Read(ctx context.Context) (websocket.MessageType, []byte, error) { select { @@ -696,6 +707,8 @@ func (c *recConn) Read(ctx context.Context) (websocket.MessageType, []byte, erro return 0, nil, ctx.Err() case <-c.done: return 0, nil, context.Canceled + case b := <-c.data: + return websocket.MessageText, b, nil } } @@ -1396,13 +1409,41 @@ func TestAltROnAHungSessionOpensAFreshOne(t *testing.T) { if cmd == nil { t.Fatal("alt+r produced no command, want it to dial the element again") } - // The hung tab goes: a redial is a new CLI session, not a resumption. - if s := got.sessions.Find("1"); s == hung { - t.Error("the hung session was kept, want it replaced") + // The hung tab stays while the dial runs, and says so: it is the only + // record of what the session said, and a tab that vanishes on the + // keypress makes a 30-second dial look like nothing happened. + if s := got.sessions.Find("1"); s != hung { + t.Error("the hung tab went before its replacement was up") + } + if got.redialing != "1" { + t.Errorf("redialing = %q, want the element being dialled", got.redialing) + } + if !strings.Contains(got.status, "redialling") { + t.Errorf("status = %q, want it to say a dial is running", got.status) + } + // The footer says the redial rather than re-offering the key that started + // it, and the bar does not either. + pane := plain(got.viewSession()) + if !strings.Contains(pane, "redialling e1") || strings.Contains(pane, "alt+r retry") { + t.Errorf("the pane still offers the key it is already running:\n%s", pane) + } + if bar := plain(got.connectBar()); !strings.Contains(bar, "redial") { + t.Errorf("connect bar = %q, want the redial on it", bar) } if got.phase != phaseSession { t.Errorf("phase = %v, want phaseSession while the new session connects", got.phase) } + + // The swap happens when the new session is actually up, which is the + // message the runtime delivers. + next, _ := got.Update(sessionOpenedMsg("1")) + after := next.(tuiModel) + if after.redialing != "" { + t.Errorf("redialing = %q once the session opened, want it cleared", after.redialing) + } + if s := after.sessions.Find("1"); s == hung { + t.Error("the hung tab survived the swap") + } } func TestAltRIsIgnoredWhileASessionIsHealthy(t *testing.T) { @@ -1419,8 +1460,144 @@ func TestAltRIsIgnoredWhileASessionIsHealthy(t *testing.T) { if got.sessions.Count() != 1 { t.Fatalf("Count() = %d, want the session untouched", got.sessions.Count()) } - // alt+r reaches the shell as an ordinary keystroke when nothing is wrong. + // The key is swallowed rather than sent on: the footer offers it, so the + // one thing it must not do is reach the device as an escape sequence. if got.sessions.Find("1") == nil { t.Error("the healthy session was replaced by alt+r") } } + +// alt+r used to be gated on a session having gone quiet, so a tab whose +// connection tore -- the usual way to lose one -- ate the key and did +// nothing at all. +func TestAltRRedialsAFailedSession(t *testing.T) { + m, set := fixtureTUI(t) + m.phase = phaseSession + t.Cleanup(m.sessions.CloseAll) + + el := set.Tenants[0].Elements[0] + next, cmd := m.openSession(el.ID, el.Name, m.tenant.TSGID) + m = next.(tuiModel) + runCmd(t, cmd) + waitForTUI(t, func() bool { return m.sessions.Find(el.ID) != nil }) + + s := m.sessions.Find(el.ID) + waitForTUI(t, func() bool { return strings.Contains(s.Render(), el.Name+"#") }) + + // Kill it the way the device does, by being told to exit: the tab is dead + // and the only way back to that ION is a new session. + s.SendText("exit\r") + waitForTUI(t, func() bool { return s.Ended() || s.Err() != nil }) + m.sessions.Select(m.sessions.IndexOf(el.ID)) + + next, cmd = m.Update(tea.KeyPressMsg(tea.Key{Code: 'r', Mod: tea.ModAlt})) + m = next.(tuiModel) + if cmd == nil { + t.Fatal("alt+r on a dead session queued nothing") + } + // While it dials, the dead tab is still there to read and the pane says + // what is going on. + if m.sessions.Find(el.ID) != s { + t.Error("the dead tab went before its replacement was up") + } + if got := plain(m.viewSession()); !strings.Contains(got, "redial") { + t.Errorf("nothing on screen says a redial is running:\n%s", got) + } + + m = deliverSessionOpened(t, m, cmd) + if m.sessions.Count() != 1 { + t.Fatalf("%d tabs after the redial, want the dead one replaced", m.sessions.Count()) + } + again := m.sessions.Find(el.ID) + if again == nil || again == s { + t.Fatal("the live session did not take the dead tab's place") + } + waitForTUI(t, func() bool { return strings.Contains(again.Render(), el.Name+"#") }) +} + +// openSessionCmd reports through the runtime; a test has to deliver that +// message itself, and it is what finishes a redial. +func deliverSessionOpened(t *testing.T, m tuiModel, cmd tea.Cmd) tuiModel { + t.Helper() + msgs := make(chan tea.Msg, 8) + cmds := []tea.Cmd{cmd} + if batch, ok := cmd().(tea.BatchMsg); ok { + cmds = batch + } + for _, c := range cmds { + go func(c tea.Cmd) { + if msg := c(); msg != nil { + select { + case msgs <- msg: + default: + } + } + }(c) + } + + deadline := time.After(5 * time.Second) + for { + select { + case msg := <-msgs: + if opened, ok := msg.(sessionOpenedMsg); ok { + next, _ := m.Update(opened) + return next.(tuiModel) + } + case <-deadline: + t.Fatal("no session opened") + } + } +} + +// A redial that cannot start must not also take away the tab that was there +// to read: it is the only record of what the session said. +func TestAFailedRedialKeepsTheTab(t *testing.T) { + m := pickModel(t) // no client in the pool, so nothing can be dialled + m.phase = phaseSession + t.Cleanup(m.sessions.CloseAll) + + conn := newRecConn() + s, err := m.sessions.Open(context.Background(), recDialer{conn}, "1", "S0100-RTR-01", 200, 50, ionterm.Credentials{}) + if err != nil { + t.Fatal(err) + } + conn.Close(websocket.StatusAbnormalClosure, "torn") + waitForTUI(t, func() bool { return s.Err() != nil }) + + next, cmd := m.Update(tea.KeyPressMsg(tea.Key{Code: 'r', Mod: tea.ModAlt})) + after := next.(tuiModel) + if cmd != nil { + t.Fatal("a redial started with no client to dial with") + } + if after.sessions.Count() != 1 { + t.Errorf("%d tabs after a redial that could not start, want the dead one kept", after.sessions.Count()) + } + if !strings.Contains(after.status, "could not reach") { + t.Errorf("status = %q, want it to say why nothing happened", after.status) + } +} + +// alt+r on a session that is still answering says so rather than sending the +// chord to the device, which the footer's own hint would otherwise do. +func TestAltROnALiveSessionSaysSo(t *testing.T) { + m := pickModel(t) + m.phase = phaseSession + t.Cleanup(m.sessions.CloseAll) + + conn := newRecConn() + if _, err := m.sessions.Open(context.Background(), recDialer{conn}, "1", "S0100-RTR-01", 200, 50, ionterm.Credentials{}); err != nil { + t.Fatal(err) + } + + next, cmd := m.Update(tea.KeyPressMsg(tea.Key{Code: 'r', Mod: tea.ModAlt})) + after := next.(tuiModel) + if cmd != nil || after.sessions.Count() != 1 { + t.Error("alt+r redialled a session that was still answering") + } + if !strings.Contains(after.status, "still answering") { + t.Errorf("status = %q, want it to say why nothing happened", after.status) + } + if strings.Contains(conn.sent(), "\x1b") { + t.Errorf("alt+r reached the device as an escape sequence: %q", conn.sent()) + } +} diff --git a/internal/tui/slash.go b/internal/tui/slash.go index a21b32c..2e466c2 100644 --- a/internal/tui/slash.go +++ b/internal/tui/slash.go @@ -18,18 +18,19 @@ const ( slashOff slashMode = iota slashMenu slashCommands + slashFreq slashHelp slashDebug ) const slashRows = 6 -var slashVerbs = []string{"login", "commands", "help", "debug"} +var slashVerbs = []string{"login", "commands", "fc", "help", "debug"} func newSlashInput() textinput.Model { in := textinput.New() in.Prompt = "/" - in.Placeholder = "commands du interf · login · help · debug" + in.Placeholder = "commands du interf · fc · login · help · debug" in.SetWidth(40) return in } @@ -68,6 +69,12 @@ func resolveVerb(word string) (string, bool) { return v, true } } + // A prefix before a fuzzy match, so one letter still picks a verb: "c" is + // a subsequence of both "commands" and "fc", and the fuzzy match alone + // would call that ambiguous and resolve nothing. + if hits := prefixVerbs(word); len(hits) == 1 { + return hits[0], true + } hits := ioncmd.Fuzzy(slashVerbs, word) if len(hits) == 1 { return hits[0], true @@ -75,6 +82,16 @@ func resolveVerb(word string) (string, bool) { return "", false } +func prefixVerbs(word string) []string { + var out []string + for _, v := range slashVerbs { + if strings.HasPrefix(v, word) { + out = append(out, v) + } + } + return out +} + func (m tuiModel) refilterSlash() tuiModel { q := m.slashInput.Value() @@ -91,6 +108,8 @@ func (m tuiModel) refilterSlash() tuiModel { m.slashItems = ioncmd.Fuzzy(slashVerbs, q) case slashCommands: m.slashItems = ioncmd.Fuzzy(m.catalog(), q) + case slashFreq: + m = m.setFreq(q) default: m.slashItems = nil } @@ -112,6 +131,16 @@ func (m tuiModel) enterVerb(name, query string) tuiModel { _ = m.slashInput.Focus() m.slashCursor = 0 m.slashItems = ioncmd.Fuzzy(m.catalog(), query) + case "fc": + m.slash = slashFreq + m.slashInput = newSlashInput() + m.slashInput.Prompt = "/fc " + m.slashInput.Placeholder = "most used first" + m.slashInput.SetValue(query) + m.slashInput.SetCursor(len(query)) + _ = m.slashInput.Focus() + m.slashCursor = 0 + m = m.setFreq(query) case "help": m.slash = slashHelp m.slashItems = nil @@ -137,9 +166,9 @@ func (m tuiModel) activateSlash() (tea.Model, tea.Cmd) { } switch pick { - case "commands", "help", "debug": + case "commands", "fc", "help", "debug": m = m.enterVerb(pick, "") - if m.slash == slashCommands { + if m.slash == slashCommands || m.slash == slashFreq { return m, m.slashInput.Focus() } m.slashInput.Blur() @@ -161,6 +190,15 @@ func (m tuiModel) activateSlash() (tea.Model, tea.Cmd) { } return m, nil + case slashFreq: + // The command itself, not the row: a row carries its count and, when + // it was run on another model, which one. + use, ok := m.freqAt(m.slashCursor) + if !ok { + return m.closeSlash(), nil + } + return m.insertCommand(use.Command) + case slashCommands: text := strings.TrimSpace(m.slashInput.Value()) if m.slashCursor < len(m.slashItems) { @@ -169,15 +207,22 @@ func (m tuiModel) activateSlash() (tea.Model, tea.Cmd) { if text == "" { return m.closeSlash(), nil } - if !app.Defaults.AllowWrites && !m.cfg.AllowWrites && !ioncli.IsReadOnly(text) { - m.status = "not read-only: " + text - return m.closeSlash(), nil - } - if s := m.sessions.Active(); s != nil && s.Err() == nil { - s.Insert(text + " ") - } + return m.insertCommand(text) + } + return m.closeSlash(), nil +} + +// insertCommand puts a command on the device line without sending it, which is +// what makes the palette safe to browse: nothing runs until the user presses +// enter at the device prompt. +func (m tuiModel) insertCommand(text string) (tea.Model, tea.Cmd) { + if !app.Defaults.AllowWrites && !m.cfg.AllowWrites && !ioncli.IsReadOnly(text) { + m.status = "not read-only: " + text return m.closeSlash(), nil } + if s := m.sessions.Active(); s != nil && s.Err() == nil { + s.Insert(text + " ") + } return m.closeSlash(), nil } @@ -208,6 +253,14 @@ func (m tuiModel) onSlashKey(msg tea.KeyPressMsg, key string) (tea.Model, tea.Cm } return m, nil + case "ctrl+d": + // Only here: the reference catalog is not the user's to delete from, + // but a history of typos is worth being rid of. + if m.slash == slashFreq { + return m.forgetFreq() + } + return m, nil + case "up", "ctrl+p": m.slashCursor = max(0, m.slashCursor-1) return m, nil @@ -231,7 +284,7 @@ func (m tuiModel) slashWanted() int { case slashOff: return 0 case slashHelp: - return 11 + return 12 case slashDebug: return 8 default: @@ -256,6 +309,7 @@ func (m tuiModel) viewSlash() string { b.WriteString(ui.StyleCmd.Render("/help") + "\n\n") b.WriteString(" " + ui.StyleElement.Render("/login") + ui.StyleDim.Render(" fill the stored ION user and password") + "\n") b.WriteString(" " + ui.StyleElement.Render("/commands") + ui.StyleDim.Render(" browse the ION command reference, enter inserts") + "\n") + b.WriteString(" " + ui.StyleElement.Render("/fc") + ui.StyleDim.Render(" commands you run most on this model, ctrl+d forgets one") + "\n") b.WriteString(" " + ui.StyleElement.Render("/debug") + ui.StyleDim.Render(" buffer and pane geometry") + "\n") b.WriteString(" " + ui.StyleElement.Render("/help") + ui.StyleDim.Render(" this list") + "\n\n") b.WriteString(ui.StyleDim.Render(" tab completes the selection · enter inserts without sending") + "\n") @@ -307,6 +361,10 @@ func (m tuiModel) viewSlash() string { b.WriteString("\n") } - b.WriteString(ui.StyleDim.Render("↑↓ select · tab complete · enter use · esc close")) + hint := "↑↓ select · tab complete · enter use · esc close" + if m.slash == slashFreq { + hint = "↑↓ select · enter use · ctrl+d forget · esc close" + } + b.WriteString(ui.StyleDim.Render(hint)) return b.String() } diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 9b78bdb..d9b2c5d 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -51,6 +51,7 @@ const ( phaseDiscover phaseTenantPick phaseData + phaseMacros ) type pickMode int @@ -59,6 +60,10 @@ const ( modeShell pickMode = iota modeBatch modeData + // modeMacro is the element list with a macro already chosen. It is not in + // the cycle below: it means nothing without a macro, so it is entered + // from the macro page and left by cycling out. + modeMacro ) // pickModes is the cycle alt+m walks. Adding a mode means adding it here. @@ -70,6 +75,8 @@ func (p pickMode) String() string { return "batch" case modeData: return "rich data" + case modeMacro: + return "macro" } return "shell" } @@ -114,6 +121,8 @@ const ( formEditTenant formEditUser formGeometry + formFavourite + formMacro ) const cacheTTL = time.Hour @@ -230,8 +239,12 @@ type tuiModel struct { // The factory reset asks twice, and this is whether it has been asked once. resetArmed bool - all []elementRow - sites map[string]string + all []elementRow + sites map[string]string + // HA state per element, keyed tsg/eid, filled in behind the list: it is a + // request per cluster and the list must not wait on it. + ha map[string]string + matches []int selected map[string]bool @@ -260,7 +273,10 @@ type tuiModel struct { resTable table.Model resOpen bool resScroll int - elapsed []time.Duration + // The line an open result is scrolled to, highlighted the way the list + // above it highlights its row. + resLine int + elapsed []time.Duration sessions *ionterm.Manager // How far the session pane is held above its live bottom. Reset on a tab @@ -270,6 +286,9 @@ type tuiModel struct { // Per-element dial progress, and the channel the clients report it on. dialing map[string]dialState dialReports chan dialProgressMsg + // The element a redial is in flight for. Its dead tab stays on screen, + // saying so, until the replacement is up. + redialing string // The element a failed connection is asking about, empty when nothing is. // askRetryNo is which answer is highlighted; the zero value picks yes, // since that is what someone who hit enter almost certainly meant. @@ -280,6 +299,12 @@ type tuiModel struct { stats bool statsTicking bool + // Command history, favourites and macros, loaded once and saved as they + // change. cmdsErr is a commands.json that would not parse, reported where + // the history is used rather than at startup. + cmds *config.CommandStore + cmdsErr string + mode pickMode // The rich-data views and the panes open on them. viewsErr is a broken // views.yaml, reported where the mode is used rather than at startup. @@ -296,6 +321,25 @@ type tuiModel struct { slashInput textinput.Model slashItems []string slashCursor int + // The history behind the rows /fc is showing, kept alongside them so a + // row can be acted on rather than re-parsed. + freq []config.CommandUse + + // The favourite slot picker, drawn over the session pane. + favPick bool + favCursor int + + macroCursor int + macroFilter textinput.Model + macroFilterOn bool + formMacroName string + // The macro chosen for the element list, which is what macro mode runs. + armedMacro string + // The macro held on the cross-model warning, empty when nothing is, and + // the rows it would run on. It shares askRetryNo, since only one of the + // two questions can be open. + askMacro string + askMacroOn []elementRow width int height int @@ -394,6 +438,16 @@ func newTUIModel(ctx context.Context, store *config.Store, cfg *config.Config) t m.viewsErr = err.Error() } m.views = views + + // Nil in a test that only renders: ViewsPath guards the same way. + m.cmds = &config.CommandStore{} + if store != nil { + cmds, err := store.Commands() + if err != nil { + m.cmdsErr = err.Error() + } + m.cmds = cmds + } if am, ok := parseAuthMode(cfg.AuthMode); ok { m.authMode = am } else if t, ok := app.ResolveTenant(cfg, Tenant); ok && !t.UsesBrowser() { @@ -419,8 +473,16 @@ func newTUIModel(ctx context.Context, store *config.Store, cfg *config.Config) t return m } +// tableStyles is the widget's own, with the selected row replaced: its +// default is a 256-colour pink, and only 0-15 survives a terminal theme. +func tableStyles() table.Styles { + s := table.DefaultStyles() + s.Selected = ui.StyleRow + return s +} + func newElementTable() table.Model { - return table.New( + t := table.New( table.WithColumns([]table.Column{ {Title: "", Width: 3}, {Title: "", Width: 3}, @@ -431,6 +493,40 @@ func newElementTable() table.Model { }), table.WithFocused(true), ) + t.SetStyles(tableStyles()) + return t +} + +// moveCursorTo puts the element list on a row. +// +// Moving down goes through the widget's MoveDown, which keeps its viewport +// under the cursor. Moving up cannot: table.MoveUp raises the viewport offset +// while the cursor falls, so the highlight walks off the top of the window +// and a held k stops appearing to do anything. Rebuilding the scroll state +// and walking down to the row is the way to an offset that is right by +// construction. +func (m *tuiModel) moveCursorTo(n int) { + rows := m.table.Rows() + if len(rows) == 0 { + return + } + n = max(0, min(n, len(rows)-1)) + if at := m.table.Cursor(); n >= at { + m.table.MoveDown(n - at) + return + } + + fresh := newElementTable() + fresh.SetColumns(m.table.Columns()) + fresh.SetRows(rows) + fresh.SetWidth(m.table.Width()) + fresh.SetHeight(m.listHeight()) + fresh.MoveDown(n) + m.table = fresh +} + +func (m *tuiModel) moveCursor(delta int) { + m.moveCursorTo(m.table.Cursor() + delta) } // cacheFresh is how long cached elements are treated as current. The CLI's @@ -737,6 +833,7 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, watch case tenantElementsMsg: + var ha tea.Cmd if m.pendingTenants > 0 { m.pendingTenants-- } @@ -754,6 +851,11 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.sites[k] = v } m.mergeTenantElements(msg.tsg, msg.tenant, msg.elements) + // Behind the list, not in front of it: HA is a request per + // cluster, and the rows are what someone is looking for. + if pairs := haClusters(msg.elements); len(pairs) > 0 { + ha = loadHA(m.ctx, m.pool.get(msg.tsg), msg.tsg, pairs) + } } if m.pendingTenants == 0 { @@ -768,7 +870,7 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.phase == phaseLoading && len(m.all) > 0 { m.phase = phasePicking } - return m, nil + return m, ha case spinner.TickMsg: if !m.wantsSpinner() { @@ -789,9 +891,7 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(waitForDial(m.dialReports), m.spinner.Tick) case sessionOpenedMsg: - m.connecting = "" - m.dialDone(string(msg)) - m.rebuildRows() + m = m.onSessionOpened(string(msg)) return m, waitForSession(m.sessions.Dirty()) case sessionErrMsg: @@ -859,6 +959,12 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case compareDoneMsg: return m.onCompareDone(msg) + case commandsSavedMsg: + return m.onCommandsSaved(msg) + + case haStatesMsg: + return m.onHAStates(msg) + case tea.KeyPressMsg: return m.onKey(msg) @@ -893,6 +999,14 @@ func (m tuiModel) forwardToInput(msg tea.Msg) (tea.Model, tea.Cmd) { m.cmdInput, cmd = m.cmdInput.Update(msg) return m, cmd + case phaseMacros: + if !m.macroFilterOn { + return m, nil + } + var cmd tea.Cmd + m.macroFilter, cmd = m.macroFilter.Update(msg) + return m, cmd + case phaseResults: if m.resFilterOn { var cmd tea.Cmd @@ -922,7 +1036,10 @@ func (m tuiModel) wantsSpinner() bool { // repainted several times a second for nothing. return m.cacheState == cacheUpdating case phaseSession: - return m.connectingAny() + // A session whose socket is up but whose device has not spoken is + // still moving: the bar spins on "waiting for the ion" until the + // first byte or the handshake deadline. + return m.connectingAny() || m.awaitingDevice() case phaseData: // Only while a pane is actually reading, or it repaints for nothing. // A comparison counts: it is a dial and a login, and the slowest @@ -972,9 +1089,22 @@ func (m tuiModel) record(el elementRow) search.Record { // tenant the column is just noise. func (m tuiModel) showTenantColumn() bool { return m.tenantCount > 1 } +// showHAColumn keeps the column off a list with no clusters in it, the way +// the tenant column stays off a single-tenant list: most fleets are all HA or +// none, and an empty column is paid for in width on every row. +func (m tuiModel) showHAColumn() bool { + for _, r := range m.all { + if r.ClusterID() != "" { + return true + } + } + return false +} + func (m *tuiModel) rebuildRows() { rows := make([]table.Row, 0, len(m.matches)) tenants := m.showTenantColumn() + ha := m.showHAColumn() for _, i := range m.matches { el := m.all[i] mark := " " @@ -994,6 +1124,9 @@ func (m *tuiModel) rebuildRows() { ui.Truncate(el.Model, 11), state, } + if ha { + row = append(row, m.haCell(el)) + } if tenants { row = append(row, ui.Truncate(el.Tenant, 20)) } @@ -1009,9 +1142,13 @@ func (m *tuiModel) rebuildRows() { {Title: "state", Width: 9}, } width := 86 + if ha { + cols = append(cols, table.Column{Title: "ha", Width: 8}) + width += 10 + } if tenants { cols = append(cols, table.Column{Title: "tenant", Width: 20}) - width = 108 + width += 22 } // Columns and rows have to agree at every moment: the table re-renders // inside each setter, so changing the column count while the old rows are @@ -1085,6 +1222,12 @@ func (m tuiModel) onKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { return m.toggleStats() } + // Both overlays own the keyboard while they are up, the macro warning + // first: only one can be open, and it is the one that was just asked. + if m.askMacro != "" { + return m.onMacroWarnKey(key) + } + if m.askRetry.eid != "" { return m.onRetryKey(key) } @@ -1094,6 +1237,8 @@ func (m tuiModel) onKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { return m.onSessionKey(msg, key) case phaseData: return m.onDataKey(key) + case phaseMacros: + return m.onMacroKey(msg, key) case phaseMenu: return m.onMenuKey(key) case phaseSwitch: @@ -1174,19 +1319,31 @@ func (m tuiModel) onPickKey(msg tea.KeyPressMsg, key string) (tea.Model, tea.Cmd m.phase = phaseMenu return m, nil - case "up", "down", "pgup", "pgdown": - var cmd tea.Cmd - m.table, cmd = m.table.Update(msg) - return m, cmd + case "up", "down": + step := 1 + if key == "up" { + step = -1 + } + m.moveCursor(step) + return m, nil + case "pgup", "pgdown": + step := max(1, m.table.Height()-1) + if key == "pgup" { + step = -step + } + m.moveCursor(step) + return m, nil // A jump through a long list, between one row and a whole page. The step // is a setting because the right size depends on the list: ten is a lot // in a lab and nothing in a fleet of thousands. - case "alt+up": - m.table.MoveUp(m.pageStep()) + // alt+k and alt+j are the same jump: hjkl is how the rest of the list + // moves, so the chorded version of it should be too. + case "alt+up", "alt+k": + m.moveCursor(-m.pageStep()) return m, nil - case "alt+down": - m.table.MoveDown(m.pageStep()) + case "alt+down", "alt+j": + m.moveCursor(m.pageStep()) return m, nil case "k", "j", "home", "end": @@ -1195,9 +1352,17 @@ func (m tuiModel) onPickKey(msg tea.KeyPressMsg, key string) (tea.Model, tea.Cmd // belong to the text while it has focus; the arrows above stay with // the table either way, so a narrowed list is still navigable mid-query. if !m.filterOn { - var cmd tea.Cmd - m.table, cmd = m.table.Update(msg) - return m, cmd + switch key { + case "k": + m.moveCursor(-1) + case "j": + m.moveCursor(1) + case "home": + m.moveCursorTo(0) + case "end": + m.moveCursorTo(len(m.table.Rows()) - 1) + } + return m, nil } case "alt+c": @@ -1276,6 +1441,9 @@ func (m tuiModel) onPickKey(msg tea.KeyPressMsg, key string) (tea.Model, tea.Cmd case "alt+m": m.mode = m.mode.next() + // Cycling out of macro mode puts the macro down: the list would + // otherwise still be holding one with nothing saying so. + m.armedMacro = "" m.status = "" return m, nil @@ -1308,7 +1476,7 @@ func (m tuiModel) onPickKey(msg tea.KeyPressMsg, key string) (tea.Model, tea.Cmd // selecting it and staying, since a command still has to be chosen. if len(m.matches) == 1 { el := m.all[m.matches[0]] - if m.mode == modeBatch { + if m.mode == modeBatch || m.mode == modeMacro { m.selected[el.key()] = true m.rebuildRows() return m, nil @@ -1329,6 +1497,13 @@ func (m tuiModel) onPickKey(msg tea.KeyPressMsg, key string) (tea.Model, tea.Cmd var cmd tea.Cmd m.filter, cmd = m.filter.Update(msg) m.applyFilter() + // Deleting the last character leaves the filter. An empty query with + // the cursor still in it is a page where j and k type letters, which + // is not what anyone who just cleared a search wants. + if key == "backspace" && m.filter.Value() == "" { + m.filterOn = false + m.filter.Blur() + } return m, cmd } @@ -1429,6 +1604,11 @@ func (m tuiModel) onPickTargets(targets []sessionTarget) (tea.Model, tea.Cmd) { if m.mode == modeShell { return m.openSessions(targets) } + // Macro mode is batch mode with the commands already chosen, so it skips + // the command box and runs. + if m.mode == modeMacro { + return m.runArmedMacro(m.rowsFor(targets)) + } m.status = "" m.phase = phaseCommand @@ -1465,7 +1645,22 @@ func (m tuiModel) onCommandKey(msg tea.KeyPressMsg, key string) (tea.Model, tea. return m, cmd } -func (m tuiModel) startRun(cmds []string) (tea.Model, tea.Cmd) { +// rowsFor maps what enter acted on back to element rows, which is what a run +// needs. The highlighted row when nothing is ticked is a target like any +// other, so a macro can be run on one element without selecting it first. +func (m tuiModel) rowsFor(targets []sessionTarget) []elementRow { + out := make([]elementRow, 0, len(targets)) + for _, t := range targets { + if el, ok := m.elementByKey(t.tsg + "/" + t.eid); ok { + out = append(out, el) + } + } + return out +} + +// selectedRows is what a run acts on: the elements ticked in the list, with +// their IDs in the same order. +func (m tuiModel) selectedRows() ([]elementRow, []string) { var picked []elementRow var ids []string for _, el := range m.all { @@ -1474,6 +1669,18 @@ func (m tuiModel) startRun(cmds []string) (tea.Model, tea.Cmd) { ids = append(ids, el.ID) } } + return picked, ids +} + +func (m tuiModel) startRun(cmds []string) (tea.Model, tea.Cmd) { + picked, ids := m.selectedRows() + return m.startRunOn(cmds, picked, ids) +} + +// startRunOn is the run itself, against rows the caller chose. Macro mode +// passes what enter acted on, which may be the highlighted row rather than +// the ticked selection. +func (m tuiModel) startRunOn(cmds []string, picked []elementRow, ids []string) (tea.Model, tea.Cmd) { if !app.Defaults.AllowWrites && !m.cfg.AllowWrites { if err := ioncli.CheckReadOnly(cmds); err != nil { @@ -1520,6 +1727,7 @@ func (m tuiModel) startRun(cmds []string) (tea.Model, tea.Cmd) { m.status = "" m.flags = f m.ran = ids + save := m.recordRun(cmds, picked) m.results = make([]sase.Result, len(ids)) m.elapsed = make([]time.Duration, len(ids)) m.resMatches = nil @@ -1532,7 +1740,7 @@ func (m tuiModel) startRun(cmds []string) (tea.Model, tea.Cmd) { m.phase = phaseRunning m.events = sase.RunStream(m.ctx, opts, targets) - return m, tea.Batch(waitForEvent(m.events), m.spinner.Tick) + return m, tea.Batch(waitForEvent(m.events), m.spinner.Tick, save) } func (m *tuiModel) applyFilter() { @@ -1609,10 +1817,15 @@ func (m tuiModel) View() tea.View { v.Cursor = cur case phaseData: v = tea.NewView(m.viewData()) + case phaseMacros: + v = tea.NewView(m.viewMacros()) } if m.askRetry.eid != "" { v = tea.NewView(m.overlayRetry(v.Content)) } + if m.askMacro != "" { + v = tea.NewView(m.overlayMacroWarn(v.Content)) + } v.Content = ui.ClipFrame(v.Content, m.height) v.AltScreen = true v.MouseMode = m.mouseMode() @@ -1625,8 +1838,7 @@ func (m tuiModel) viewPicking() string { if m.sessions.Count() > 0 { b.WriteString(m.tabBar() + "\n") } - modeLabel := ui.StyleElement.Render(m.mode.String() + " mode") - b.WriteString(ui.StyleCmd.Render("iontk") + " " + modeLabel + + b.WriteString(ui.StyleCmd.Render("iontk") + " " + m.modeLabel() + ui.StyleDim.Render(" "+m.scopeLabel()+" · "+m.countLabel()) + m.selectedNote() + m.refreshNote() + "\n") @@ -1661,6 +1873,8 @@ func (m tuiModel) viewPicking() string { act = fmt.Sprintf("enter command on %d", n) case modeData: act = fmt.Sprintf("enter data on %d", n) + case modeMacro: + act = fmt.Sprintf("enter run %s on %d", m.armedMacro, n) } // While typing a query the only keys that matter are the ones that end // it, so the hint says just those. diff --git a/internal/tui/tuimenu.go b/internal/tui/tuimenu.go index bc5031e..4185f6c 100644 --- a/internal/tui/tuimenu.go +++ b/internal/tui/tuimenu.go @@ -21,6 +21,7 @@ const ( actSignIn actRefreshToken actSwitch + actMacros actSettings actQuit ) @@ -57,6 +58,7 @@ func (m tuiModel) menuEntries() []menuEntry { } e = append(e, + menuEntry{"macros", actMacros}, menuEntry{"Settings", actSettings}, menuEntry{"Quit", actQuit}, ) @@ -135,6 +137,16 @@ func (m tuiModel) onMenuKey(key string) (tea.Model, tea.Cmd) { } switch entries[m.menuCursor].action { case actElements: + // Macro mode is only reachable by picking a macro, so the list + // opened from here is never in it: it would otherwise still be + // holding one from earlier in the session. + if m.mode == modeMacro { + m.mode = modeShell + if saved, ok := parseMode(m.cfg.DefaultMode); ok { + m.mode = saved + } + } + m.armedMacro = "" return m.startContinue() case actSwitch: m.switchCursor = 0 @@ -153,6 +165,8 @@ func (m tuiModel) onMenuKey(key string) (tea.Model, tea.Cmd) { return m, nil } return m.startDiscover() + case actMacros: + return m.openMacros() case actSettings: m.setCursor = 0 m.phase = phaseSettings @@ -332,6 +346,12 @@ func (m tuiModel) onFormKey(msg tea.KeyPressMsg, key string) (tea.Model, tea.Cmd return m, nil case "tab", "down": + // A macro is as many commands as it takes, so tab on the last line + // adds the next one. Only with something on that line: growing on + // every tab would leave empty lines behind to walk through. + if m.formKind == formMacro && m.formCursor == len(m.form)-1 && m.formValue(m.formCursor) != "" { + return m.addMacroCommand() + } m.form[m.formCursor].input.Blur() m.formCursor = (m.formCursor + 1) % len(m.form) return m, m.form[m.formCursor].input.Focus() @@ -343,6 +363,14 @@ func (m tuiModel) onFormKey(msg tea.KeyPressMsg, key string) (tea.Model, tea.Cmd case "enter": return m.submitForm() + + case "backspace": + // On an empty command line, the key that would have emptied it takes + // the line out instead. The last one stays: the form always has + // somewhere to type. + if m.formKind == formMacro && m.formCursor >= 1 && len(m.form) > 2 && m.formValue(m.formCursor) == "" { + return m.removeMacroCommand() + } } var cmd tea.Cmd @@ -551,6 +579,12 @@ func (m tuiModel) submitForm() (tea.Model, tea.Cmd) { m.phase = m.formReturn return m, nil + case formFavourite: + return m.submitFavourite() + + case formMacro: + return m.submitMacro() + case formGeometry: n, err := strconv.Atoi(m.formValue(0)) if err != nil { @@ -751,12 +785,22 @@ func (m tuiModel) viewForm() string { b.WriteString(" " + label + f.input.View() + "\n") } + // The macro form grows, and the row that says so is the only thing that + // tells you tab will add one. + if m.formKind == formMacro { + b.WriteString(" " + strings.Repeat(" ", 16) + ui.StyleGhost.Render("+ another command") + "\n") + } + if w := m.plaintextWarning(); w != "" { b.WriteString("\n " + w + "\n") } b.WriteString("\n") - b.WriteString(ui.Footer(m.status, "tab next · enter save · esc cancel")) + hint := "tab next · enter save · esc cancel" + if m.formKind == formMacro { + hint = "tab next/add · backspace on an empty line removes it · enter save · esc cancel" + } + b.WriteString(ui.Footer(m.status, hint)) return b.String() } diff --git a/internal/tui/tuimenu_test.go b/internal/tui/tuimenu_test.go index 99c3df0..1131958 100644 --- a/internal/tui/tuimenu_test.go +++ b/internal/tui/tuimenu_test.go @@ -20,9 +20,9 @@ var testANSI = regexp.MustCompile(`\x1b\[[0-9;]*m`) func plain(s string) string { return testANSI.ReplaceAllString(s, "") } func TestMenuEntries(t *testing.T) { - // The menu is now the same four entries whatever is configured: the element - // list, the way in to a browser account, settings, quit. - want := []string{"elements", "sign in with browser", "Settings", "Quit"} + // The menu is the same entries whatever is configured: the element list, + // the way in to a browser account, macros, settings, quit. + want := []string{"elements", "sign in with browser", "macros", "Settings", "Quit"} cases := map[string]struct { cfg *config.Config diff --git a/internal/ui/styles.go b/internal/ui/styles.go index d570aa5..d18c309 100644 --- a/internal/ui/styles.go +++ b/internal/ui/styles.go @@ -11,6 +11,15 @@ var ( StyleDim = lipgloss.NewStyle().Faint(true) // The nearest thing to orange in the 0-15 range, for a session about to go. StyleHot = lipgloss.NewStyle().Foreground(lipgloss.Color("9")) + // StyleRow is the highlight on the row a list is on, and on the line an + // open result is scrolled to. Defined here rather than taken from the + // table widget's default, which is a 256-colour pink a terminal theme + // cannot remap. + StyleRow = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("5")) + // StyleGhost is a line that is not there yet: the next command a macro + // would take, or the macro that would be made, before anyone has typed + // anything. + StyleGhost = lipgloss.NewStyle().Faint(true).Italic(true) MarkCheck = StyleOK.SetString("✓") MarkCross = StyleErr.SetString("✗") diff --git a/internal/ui/text.go b/internal/ui/text.go index a2d0d27..7da3852 100644 --- a/internal/ui/text.go +++ b/internal/ui/text.go @@ -16,6 +16,10 @@ func Label(s string) string { return StyleDim.Render(fmt.Sprintf("%-*s", LabelWi // Width is the printed width of a string, ignoring its escape codes. func Width(s string) int { return ansi.StringWidth(s) } +// Plain is text with its styling taken off, for a line that is about to be +// restyled: wrapping a highlight around escapes ends it at the inner reset. +func Plain(s string) string { return ansi.Strip(s) } + func Truncate(s string, n int) string { if n <= 0 { return "" From b48839dd7fd0186e271b488725cd033e6cdd1f8b Mon Sep 17 00:00:00 2001 From: lemo-o <21benjaminhall@gmail.com> Date: Mon, 19 Oct 2026 15:16:52 -0600 Subject: [PATCH 3/4] themes, clipboard fixes, HA column, search fixes, rich data improvements --- .github/workflows/build.yml | 15 +- .github/workflows/release.yml | 14 +- .gitignore | 6 + cmd/iontk/creds.go | 11 +- cmd/iontk/flagdoc.go | 3 + cmd/iontk/inspect.go | 8 +- cmd/iontk/root.go | 4 + cmd/iontk/search.go | 6 + go.mod | 2 +- internal/app/cli.go | 33 ++ internal/app/cli_test.go | 53 +++ internal/app/flags.go | 7 +- internal/clock/clock.go | 93 +++++ internal/clock/clock_test.go | 105 +++++ internal/config/cache.go | 22 + internal/config/config.go | 6 +- internal/dataview/builtin.yaml | 15 + internal/dataview/dataview_test.go | 6 +- internal/dataview/render.go | 8 +- internal/fixture/portal.go | 115 ++++++ internal/fixture/portal_test.go | 21 +- internal/fixture/rest.go | 18 + internal/ionterm/pane.go | 22 + internal/ionterm/scrollback_test.go | 22 + internal/sase/api/endpoint.go | 6 + internal/sase/api/status.go | 156 ++++++- internal/sase/api/status_test.go | 152 +++++++ internal/saseauth/browser_test.go | 36 +- internal/search/search.go | 5 + internal/tui/appearance.go | 245 +++++++++++ internal/tui/appearance_test.go | 341 ++++++++++++++++ internal/tui/batchui.go | 3 +- internal/tui/bench_test.go | 82 ++++ internal/tui/clipboard.go | 162 ++++++++ internal/tui/clipboard_test.go | 274 +++++++++++++ internal/tui/connecting.go | 26 +- internal/tui/data.go | 166 +++++++- internal/tui/data_test.go | 130 +++++- internal/tui/favourites.go | 9 +- internal/tui/filter_test.go | 192 ++++++++- internal/tui/ha.go | 503 ++++++++++++++++++++--- internal/tui/ha_test.go | 439 +++++++++++++++++++- internal/tui/macros.go | 82 +++- internal/tui/macros_test.go | 83 ++++ internal/tui/mouse.go | 78 +--- internal/tui/mouse_test.go | 98 ----- internal/tui/multitenant.go | 21 +- internal/tui/multitenant_test.go | 2 +- internal/tui/results.go | 48 ++- internal/tui/results_test.go | 92 +++++ internal/tui/scrollback_test.go | 28 +- internal/tui/searchui.go | 3 +- internal/tui/secrets_warning_test.go | 4 +- internal/tui/session.go | 22 +- internal/tui/session_test.go | 12 - internal/tui/settings.go | 79 +++- internal/tui/settings_test.go | 35 +- internal/tui/spinners.go | 7 +- internal/tui/stats_test.go | 4 +- internal/tui/themeedit.go | 180 ++++++++ internal/tui/themeedit_test.go | 208 ++++++++++ internal/tui/tui.go | 547 +++++++++++++++++++++---- internal/tui/tuimenu.go | 6 + internal/tui/tuimenu_test.go | 7 +- internal/ui/modal.go | 2 +- internal/ui/spinners.go | 37 +- internal/ui/spinners_test.go | 32 +- internal/ui/styles.go | 26 -- internal/ui/theme.go | 591 +++++++++++++++++++++++++++ internal/ui/theme_test.go | 305 ++++++++++++++ internal/ui/themes.yaml | 190 +++++++++ 71 files changed, 5823 insertions(+), 548 deletions(-) create mode 100644 internal/clock/clock.go create mode 100644 internal/clock/clock_test.go create mode 100644 internal/tui/appearance.go create mode 100644 internal/tui/appearance_test.go create mode 100644 internal/tui/bench_test.go create mode 100644 internal/tui/clipboard.go create mode 100644 internal/tui/clipboard_test.go delete mode 100644 internal/tui/mouse_test.go create mode 100644 internal/tui/themeedit.go create mode 100644 internal/tui/themeedit_test.go delete mode 100644 internal/ui/styles.go create mode 100644 internal/ui/theme.go create mode 100644 internal/ui/theme_test.go create mode 100644 internal/ui/themes.yaml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 822bfcd..46f0253 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -48,9 +48,18 @@ jobs: path: docs/flags.md - name: Test - # Discover's first sign-in opens a real window, which chromedp will not - # do without a display. Xvfb comes with the runner image. - run: xvfb-run -a go test -race -cover -timeout 5m ./... + # Xvfb where the image has it, since Discover's first sign-in opens a + # real window and chromedp will not without a display. Not required: + # the browser test skips itself when there is no DISPLAY. + # + # -p 1 because two packages reach config.DebugRoot(), one fixed path + # shared by every test binary. + run: | + if command -v xvfb-run >/dev/null 2>&1; then + xvfb-run -a go test -race -cover -p 1 -timeout 5m ./... + else + go test -race -cover -p 1 -timeout 5m ./... + fi - name: Build Linux run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d2bdf17..2b7d6f1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,9 +24,17 @@ jobs: go-version-file: go.mod - name: Test - # Discover's first sign-in opens a real window, which chromedp will not - # do without a display. Xvfb comes with the runner image. - run: xvfb-run -a go test -timeout 5m ./... + # -short, which is what broke the v0.5.0 release: the browser + # integration test drove chromedp on the runner, Chrome never came up, + # and 45s later the tag had no binaries. A release gate must not depend + # on whether a browser starts on a VM -- the full suite, that test + # included, runs in build.yml on every push to master, so the commit + # being tagged has already been through it. + # + # -p 1 because two packages reach config.DebugRoot(), one fixed path + # shared by every test binary; run in parallel they clear each other's + # store and the release fails at random. + run: go test -short -p 1 -timeout 5m ./... - name: Build Linux run: | diff --git a/.gitignore b/.gitignore index 0f88d0d..f1489bf 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,9 @@ *.txt *.json *.md +# The release job builds into dist/, and a local reproduction of it leaves +# 38MB of binaries here that git add -A would happily commit. +dist/ +# go test -c and -cpuprofile leave these behind. +*.test +*.out diff --git a/cmd/iontk/creds.go b/cmd/iontk/creds.go index 6ab989b..55312e1 100644 --- a/cmd/iontk/creds.go +++ b/cmd/iontk/creds.go @@ -5,17 +5,26 @@ import ( "io" "tyrtop.com/iontk/internal/app" + "tyrtop.com/iontk/internal/ui" ) // Batch and shell both go through here, so they can't end up using different // tenants. func resolveFlags(w io.Writer) (app.Flags, error) { - creds, err := app.ResolveCredentials(app.OpenStore(), flagTenant) + st := app.OpenStore() + app.ApplyDisplaySettings(st, nil) + + creds, err := app.ResolveCredentials(st, flagTenant) if err != nil { return app.Flags{}, err } for _, line := range creds.Lines() { fmt.Fprintln(w, line) } + // A themes.yaml that would not parse is as worth saying here as a wrong + // tenant is: the alternative is a scheme that silently did not load. + if err := ui.ThemesErr(); err != "" { + fmt.Fprintln(w, "themes: "+err) + } return creds.Flags, nil } diff --git a/cmd/iontk/flagdoc.go b/cmd/iontk/flagdoc.go index 7fc0372..e19dcd8 100644 --- a/cmd/iontk/flagdoc.go +++ b/cmd/iontk/flagdoc.go @@ -78,6 +78,9 @@ var flagGroups = []struct { {"Rich data", "The TUI's third element mode reads the portal instead of opening a session. Its panes are defined in YAML: `--views` points at a file instead of `views.yaml` in the config directory, which is also how a `--fixtures` run gets views of its own.", []string{ "views", }}, + {"Appearance", "Colour schemes are YAML too: `--themes` points at a file whose schemes are added to the built-in ones, or replace one of the same name, instead of `themes.yaml` in the config directory. The same escape hatch as `--views`, for the same reason.", []string{ + "themes", + }}, } func writeFlagDocs(w io.Writer, root *cobra.Command) error { diff --git a/cmd/iontk/inspect.go b/cmd/iontk/inspect.go index da29df6..39416dc 100644 --- a/cmd/iontk/inspect.go +++ b/cmd/iontk/inspect.go @@ -5,10 +5,10 @@ import ( "encoding/json" "fmt" "os" - "time" "github.com/spf13/cobra" "tyrtop.com/iontk/internal/app" + "tyrtop.com/iontk/internal/clock" "tyrtop.com/iontk/internal/sase/api" ) @@ -164,9 +164,5 @@ func inspectOut(snap api.Snapshot) inspectReport { } func utcString(v int64) string { - t := api.UTCTime(v) - if t.IsZero() { - return "" - } - return t.Format(time.RFC3339) + return clock.RFC3339(api.UTCTime(v)) } diff --git a/cmd/iontk/root.go b/cmd/iontk/root.go index 48410ad..0920327 100644 --- a/cmd/iontk/root.go +++ b/cmd/iontk/root.go @@ -24,6 +24,7 @@ var ( flagNoValidate bool flagNoLogin bool flagViews string + flagThemes string ) var rootCmd = &cobra.Command{ @@ -62,6 +63,8 @@ login from the keyring, so --cmd needs none of these once one is signed in.`, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { _ = godotenv.Load() + // Before anything resolves a store: the themes are read when one is. + app.ThemesFile = flagThemes return loadFixtures(cmd.ErrOrStderr()) }, @@ -130,6 +133,7 @@ func init() { pf.BoolVar(&app.Debug, "debug", false, "run against a throwaway config with no saved tenants and secrets held only in memory") pf.StringVar(&flagFixtures, "fixtures", "", "YAML file of fake tenants, elements and CLI responses to run against instead of real hardware") pf.StringVar(&flagViews, "views", "", "YAML file of rich-data views to use instead of views.yaml in the config directory") + pf.StringVar(&flagThemes, "themes", "", "YAML file of colour schemes to add to the built-in ones, instead of themes.yaml in the config directory") pf.StringVar(&app.Defaults.TSGID, "tsg", "", "tenant service group ID (overrides SCM_TSG_ID)") pf.StringVar(&flagFormat, "format", "", `batch output format: "json", "jsonl", "text", "table" or "csv" (default from settings, else json)`) // Only the tabular formats have rows to sort, but the flag is accepted diff --git a/cmd/iontk/search.go b/cmd/iontk/search.go index 0053c8a..a9dba5a 100644 --- a/cmd/iontk/search.go +++ b/cmd/iontk/search.go @@ -23,6 +23,7 @@ import ( "tyrtop.com/iontk/internal/saseauth" "tyrtop.com/iontk/internal/search" "tyrtop.com/iontk/internal/tui" + "tyrtop.com/iontk/internal/ui" ) // Search output formats. These are not the batch formats: --search prints @@ -63,6 +64,11 @@ func runSearch(ctx context.Context) error { if st == nil { return fmt.Errorf("--search needs the config store, which could not be opened") } + app.ApplyDisplaySettings(st, nil) + if err := ui.ThemesErr(); err != "" { + fmt.Fprintln(os.Stderr, "themes: "+err) + } + tenants, err := searchScope(st) if err != nil { return err diff --git a/go.mod b/go.mod index 0032aa9..4dd5275 100644 --- a/go.mod +++ b/go.mod @@ -17,7 +17,7 @@ require ( require ( github.com/atotto/clipboard v0.1.4 // indirect - github.com/charmbracelet/colorprofile v0.4.3 // indirect + github.com/charmbracelet/colorprofile v0.4.3 github.com/charmbracelet/harmonica v0.2.0 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4 github.com/charmbracelet/x/ansi v0.11.9 diff --git a/internal/app/cli.go b/internal/app/cli.go index 2e42857..08b20fa 100644 --- a/internal/app/cli.go +++ b/internal/app/cli.go @@ -3,11 +3,44 @@ package app import ( "fmt" "os" + "path/filepath" "strings" + "tyrtop.com/iontk/internal/clock" "tyrtop.com/iontk/internal/config" + "tyrtop.com/iontk/internal/ui" ) +// --themes, read by every command: a batch prints marks and a bar too +var ThemesFile string + +func ThemesPath(st *config.Store) string { + if ThemesFile != "" { + return ThemesFile + } + if st == nil { + return "" + } + return filepath.Join(st.Root, ui.ThemesFileName) +} + +// Called by every command, not just the TUI. Nothing here can fail a run; a +// nil cfg is read from the store. +func ApplyDisplaySettings(st *config.Store, cfg *config.Config) { + _ = ui.LoadThemes(ThemesPath(st)) + if cfg == nil { + if st == nil { + return + } + var err error + if cfg, err = st.Config(); err != nil { + return + } + } + _ = clock.Use(cfg.Zone) + ui.UseTheme(cfg.Theme) +} + // CredSource is which rule picked the credentials. It gets printed, because // otherwise the first sign of a wrong tenant is a fleet of dead elements. type CredSource string diff --git a/internal/app/cli_test.go b/internal/app/cli_test.go index bdf787c..c5dc53f 100644 --- a/internal/app/cli_test.go +++ b/internal/app/cli_test.go @@ -5,7 +5,9 @@ import ( "strings" "testing" + "tyrtop.com/iontk/internal/clock" "tyrtop.com/iontk/internal/config" + "tyrtop.com/iontk/internal/ui" ) // Tests have to ignore whatever the developer has exported. Six variables feed @@ -323,3 +325,54 @@ func TestHeadlessFlagsCarryThePagerHandling(t *testing.T) { t.Error("PagerPrompts is empty, want the scraper armed for firmware that ignores --pager-off") } } + +// Every command draws something -- marks, a progress bar, a time -- so every +// command takes the appearance and the zone out of the store it just opened. +func TestApplyDisplaySettingsReadsTheStore(t *testing.T) { + clearCredEnv(t) + t.Cleanup(func() { + _ = clock.Use(clock.UTC) + ui.Apply(ui.Default()) + }) + + st := seedStore(t, &config.Config{ + LastTenant: "Acme", + Tenants: []config.Tenant{serviceTenant("Acme", "1")}, + Zone: "America/Chicago", + Theme: "mono", + }) + if err := st.Secrets.Set(config.ClientSecretKey("1"), "shh"); err != nil { + t.Fatal(err) + } + ApplyDisplaySettings(st, nil) + + if got := clock.Name(); got != "America/Chicago" { + t.Errorf("clock.Name() = %q, want the saved zone", got) + } + if got := ui.CurrentTheme().Name; got != "mono" { + t.Errorf("CurrentTheme() = %q, want the saved theme", got) + } +} + +// A zone that will not load must not stop a batch run: the times print in UTC +// and say so. +func TestABadZoneDoesNotStopACommand(t *testing.T) { + clearCredEnv(t) + t.Cleanup(func() { _ = clock.Use(clock.UTC) }) + + st := seedStore(t, &config.Config{ + LastTenant: "Acme", + Tenants: []config.Tenant{serviceTenant("Acme", "1")}, + Zone: "Mars/Olympus_Mons", + }) + if err := st.Secrets.Set(config.ClientSecretKey("1"), "shh"); err != nil { + t.Fatal(err) + } + ApplyDisplaySettings(st, nil) + if got := clock.Name(); got != clock.UTC { + t.Errorf("clock.Name() = %q, want the UTC fallback", got) + } + if clock.Problem() == "" { + t.Error("nothing recorded why the zone was not used") + } +} diff --git a/internal/app/flags.go b/internal/app/flags.go index 6083e85..d99f483 100644 --- a/internal/app/flags.go +++ b/internal/app/flags.go @@ -37,11 +37,12 @@ var Defaults = RootFlags{ SessionTimeout: 20 * time.Second, RefreshMargin: 75 * time.Second, Concurrency: 10, - Burst: 10, + Burst: 20, DialAttempts: 3, DialTimeout: 10 * time.Second, - RPS: 5, - PagerPrompts: ioncli.DefaultPagers, + // the portal's own captures peak at 8-9 a second, and a 429 is waited out + RPS: 10, + PagerPrompts: ioncli.DefaultPagers, // Works on an ion 1200-s running 6.3.4. "terminal length 0", "no paging" // and "set cli pager off" all get rejected there. If a device rejects this // one too, the pager scraper handles the run. diff --git a/internal/clock/clock.go b/internal/clock/clock.go new file mode 100644 index 0000000..a51ab78 --- /dev/null +++ b/internal/clock/clock.go @@ -0,0 +1,93 @@ +// Package clock is the zone printed times are read in. Conversion stays in +// api.UTCTime; durations are relative, so they do not belong here. +package clock + +import ( + "fmt" + "strings" + "sync" + "time" +) + +const Local = "local" + +const UTC = "UTC" + +// zone named, or the reading needs a setting to interpret +const layout = "2006-01-02 15:04 MST" + +var ( + mu sync.RWMutex + loc = time.UTC + name = UTC + // read on the settings page, long after the config was + problem string +) + +// a typo in config must not stop iontk starting +func Use(spec string) error { + l, resolved, err := load(spec) + + mu.Lock() + defer mu.Unlock() + if err != nil { + loc, name, problem = time.UTC, UTC, err.Error() + return err + } + loc, name, problem = l, resolved, "" + return nil +} + +func load(spec string) (*time.Location, string, error) { + switch s := strings.TrimSpace(spec); { + case s == "", strings.EqualFold(s, UTC): + return time.UTC, UTC, nil + case strings.EqualFold(s, Local): + return time.Local, Local, nil + default: + l, err := time.LoadLocation(s) + if err != nil { + return nil, "", fmt.Errorf("zone %q: %w", s, err) + } + return l, s, nil + } +} + +func Valid(spec string) error { + _, _, err := load(spec) + return err +} + +func Zone() *time.Location { + mu.RLock() + defer mu.RUnlock() + return loc +} + +// as spelled, not as resolved: callers compare against Local +func Name() string { + mu.RLock() + defer mu.RUnlock() + return name +} + +func Problem() string { + mu.RLock() + defer mu.RUnlock() + return problem +} + +// zero is blank: a field never set is not a date in 1970 +func Stamp(t time.Time) string { + if t.IsZero() { + return "" + } + return t.In(Zone()).Format(layout) +} + +func RFC3339(t time.Time) string { + if t.IsZero() { + return "" + } + return t.In(Zone()).Format(time.RFC3339) +} diff --git a/internal/clock/clock_test.go b/internal/clock/clock_test.go new file mode 100644 index 0000000..4170e04 --- /dev/null +++ b/internal/clock/clock_test.go @@ -0,0 +1,105 @@ +package clock + +import ( + "strings" + "testing" + "time" +) + +// pin puts the zone back afterwards. The zone is process state, so a test +// that left it set would decide what every later test printed. +func pin(t *testing.T, spec string) error { + t.Helper() + t.Cleanup(func() { _ = Use(UTC) }) + return Use(spec) +} + +func TestUseResolvesTheZonesTheSettingOffers(t *testing.T) { + at := time.Date(2026, 10, 18, 13, 25, 0, 0, time.UTC) + + tests := []struct { + name string + spec string + want string + }{ + {"nothing saved is UTC", "", "2026-10-18 13:25 UTC"}, + {"UTC by name", "UTC", "2026-10-18 13:25 UTC"}, + {"a named zone shifts the reading", "America/Chicago", "2026-10-18 08:25 CDT"}, + // Not an IANA name, so it cannot go through LoadLocation. + {"local is whatever this machine is", Local, ""}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if err := pin(t, tc.spec); err != nil { + t.Fatalf("Use(%q) = %v", tc.spec, err) + } + got := Stamp(at) + if tc.want == "" { + if got == "" { + t.Error("Stamp() is empty for a real time") + } + return + } + if got != tc.want { + t.Errorf("Stamp() = %q, want %q", got, tc.want) + } + }) + } +} + +// A zone that will not load must not take the program with it: the reading is +// labelled UTC, which is honest, and the settings page can say what happened. +func TestAZoneThatWillNotLoadFallsBackToUTCAndSaysSo(t *testing.T) { + err := pin(t, "Mars/Olympus_Mons") + if err == nil { + t.Fatal("Use() accepted a zone that does not exist") + } + if Name() != UTC { + t.Errorf("Name() = %q, want UTC after a bad zone", Name()) + } + if Problem() == "" { + t.Error("Problem() is empty after a zone that would not load") + } + if got := Stamp(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)); got != "2026-01-01 00:00 UTC" { + t.Errorf("Stamp() = %q, want the UTC fallback", got) + } + if Valid("Mars/Olympus_Mons") == nil { + t.Error("Valid() accepted a zone that does not exist") + } +} + +// Every reading says which zone it is in. A time with no zone on it is a +// number you have to remember a setting to read. +func TestStampNamesItsZone(t *testing.T) { + if err := pin(t, "Europe/London"); err != nil { + t.Fatal(err) + } + got := Stamp(time.Date(2026, 1, 15, 9, 0, 0, 0, time.UTC)) + if !strings.HasSuffix(got, " GMT") { + t.Errorf("Stamp() = %q, want the zone named", got) + } +} + +// A field the appliance never set arrives as zero, and a date in 1970 would +// read like one it did set. +func TestAZeroTimeIsBlank(t *testing.T) { + if got := Stamp(time.Time{}); got != "" { + t.Errorf("Stamp(zero) = %q, want empty", got) + } + if got := RFC3339(time.Time{}); got != "" { + t.Errorf("RFC3339(zero) = %q, want empty", got) + } +} + +// RFC3339 carries its offset, so machine-readable output stays unambiguous +// whatever the display zone is. +func TestRFC3339CarriesTheOffset(t *testing.T) { + if err := pin(t, "America/Chicago"); err != nil { + t.Fatal(err) + } + got := RFC3339(time.Date(2026, 10, 18, 13, 25, 0, 0, time.UTC)) + if got != "2026-10-18T08:25:00-05:00" { + t.Errorf("RFC3339() = %q, want the Chicago offset", got) + } +} diff --git a/internal/config/cache.go b/internal/config/cache.go index d24fa81..4a1766c 100644 --- a/internal/config/cache.go +++ b/internal/config/cache.go @@ -19,6 +19,28 @@ type ElementCache struct { FetchedAt time.Time `json:"fetched_at"` Elements []api.Element `json:"elements"` Sites map[string]string `json:"sites,omitempty"` + // last sweep's states, shown with an asterisk: as old as this file + HA map[string]string `json:"ha,omitempty"` +} + +// a sweep finishes long after the elements were written +func (s *Store) SaveHA(tsg string, states map[string]string) error { + c, err := s.Cache(tsg) + if err != nil { + return err + } + if c == nil { + // Nothing to attach it to: the elements are what the file is for, and + // HA state without them has nothing to key against. + return nil + } + if c.HA == nil { + c.HA = map[string]string{} + } + for eid, w := range states { + c.HA[eid] = w + } + return s.SaveCache(c) } func (s *Store) cacheDir() string { return filepath.Join(s.Root, "cache") } diff --git a/internal/config/config.go b/internal/config/config.go index cf7f261..8d74ed1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -79,7 +79,11 @@ type Config struct { SessionCols int `json:"session_cols,omitempty"` SessionRows int `json:"session_rows,omitempty"` // PageStep is how far alt+up and alt+down move through the element list. - PageStep int `json:"page_step,omitempty"` + PageStep int `json:"page_step,omitempty"` + // Theme names the colour scheme; Zone is the IANA zone every printed time + // is read in, or "local", or empty for UTC. + Theme string `json:"theme,omitempty"` + Zone string `json:"zone,omitempty"` BatchFormat string `json:"batch_format,omitempty"` BatchOrient string `json:"batch_orient,omitempty"` BatchToFile bool `json:"batch_to_file,omitempty"` diff --git a/internal/dataview/builtin.yaml b/internal/dataview/builtin.yaml index 033091c..3e657e6 100644 --- a/internal/dataview/builtin.yaml +++ b/internal/dataview/builtin.yaml @@ -102,6 +102,14 @@ views: # The site's circuits, so a port can name the one it carries rather # than printing the id out of site_wan_interface_ids. wans: /sdwan/v2.10/api/sites/{{ .site_id }}/waninterfaces + # Security zones in two parts, the same way circuits work: the tenant's + # zone list has the names, and the element's own bindings say which + # interfaces are in which zone. UNVERIFIED against hardware -- no + # capture of either body exists, so the field names below are read from + # the resource map and the CloudGenix SDK. A name that is wrong prints a + # blank cell, not an error, so run it and look at the ZONE column. + zones: /sdwan/v2.2/api/securityzones + elzones: /sdwan/v2.1/api/sites/{{ .site_id }}/elements/{{ .element_id }}/securityzones sections: - title: Interfaces for_each: ifs.items @@ -139,6 +147,13 @@ views: - header: CIRCUIT value: "{{ range .item.site_wan_interface_ids }}{{ with lookup $.wans.items \"id\" . }}{{ .name }} {{ end }}{{ end }}" width: 20 + # The binding holds a list of interface ids, which is what lookup + # matches either way round; its zone_id then names the zone. + - header: ZONE + value: "{{ with lookup $.elzones.items \"interface_ids\" .item.id }}{{ with lookup $.zones.items \"id\" .zone_id }}{{ .name }}{{ end }}{{ end }}" + # Real zone names are sentences: "TSC Store - Network Management" + # is 30 characters, and a name cut in half names nothing. + width: 32 # Circuits are the site's, not the element's, so a hub pair shares them. # This is the other direction of the same join: each circuit says which of diff --git a/internal/dataview/dataview_test.go b/internal/dataview/dataview_test.go index db5ad67..87e4b8c 100644 --- a/internal/dataview/dataview_test.go +++ b/internal/dataview/dataview_test.go @@ -407,19 +407,19 @@ func TestTemplateHelpers(t *testing.T) { name: "utc reads 100ns ticks", tmpl: "{{ utc .n }}", ctx: map[string]any{"n": float64(17923299413050015)}, - want: "2026-10-18 13:25", + want: "2026-10-18 13:25 UTC", }, { name: "utc reads milliseconds", tmpl: "{{ utc .n }}", ctx: map[string]any{"n": float64(1792307867657)}, - want: "2026-10-18 07:17", + want: "2026-10-18 07:17 UTC", }, { name: "utc reads seconds", tmpl: "{{ utc .n }}", ctx: map[string]any{"n": float64(1784373284)}, - want: "2026-07-18 11:14", + want: "2026-07-18 11:14 UTC", }, { name: "a field that was never set is blank, not a date", diff --git a/internal/dataview/render.go b/internal/dataview/render.go index 0b48007..f7400c6 100644 --- a/internal/dataview/render.go +++ b/internal/dataview/render.go @@ -7,6 +7,7 @@ import ( "text/template" "time" + "tyrtop.com/iontk/internal/clock" "tyrtop.com/iontk/internal/sase/api" ) @@ -145,12 +146,17 @@ var funcs = template.FuncMap{ // utc is the API's timestamp integers, whose unit varies by field. Always // go through this: the scale is three orders of magnitude apart between // two fields of the same object. + // + // The name is older than the setting: it reads the integer as a time and + // prints it in the configured zone, which is UTC unless someone changed + // it. Views in the wild call it, so it keeps the name and says which zone + // it used. "utc": func(v any) string { n := toInt64(v) if n == 0 { return "" } - return api.UTCTime(n).Format("2006-01-02 15:04") + return clock.Stamp(api.UTCTime(n)) }, "ago": func(v any) string { n := toInt64(v) diff --git a/internal/fixture/portal.go b/internal/fixture/portal.go index e78903d..604931d 100644 --- a/internal/fixture/portal.go +++ b/internal/fixture/portal.go @@ -59,14 +59,47 @@ func (e Element) apiInterfaces() []api.Interface { } out = append(out, in) } + + // a real element carries its security zone on the vlans, not the ports + out = append(out, api.Interface{ + ID: e.vlanInterfaceID(), + Name: "vlan1", + Type: "vlan", + UsedFor: "lan", + AdminUp: true, + MTU: 1500, + Scope: "local", + }) return out } +func (e Element) vlanInterfaceID() string { + return syntheticID("interface/"+e.ID+"/vlan1", 19) +} + // apiInterfaceStatus mirrors a real one: the status record has an ID of its // own rather than the interface's, negotiated_mtu is 0 even on a port that is // up, and extended_state is empty on an ordinary port -- only a service link // explains itself there. func (e Element) apiInterfaceStatus(iid string) *api.InterfaceStatus { + if iid == e.vlanInterfaceID() { + st := api.InterfaceStatus{ + ID: syntheticID("interface-status/"+iid, 19), + ElementID: e.ID, + Name: "vlan1", + Device: "vlan1", + OperationalState: "down", + // No port block on anything that is not a physical port, which is + // what a real element answers with. + VRF: &api.VRF{ContextID: syntheticID("vrf/"+e.tsg, 19), Name: "Global"}, + LastStateChange: bootedAt.Add(time.Minute).UnixMilli(), + } + if e.Connected { + st.OperationalState = "up" + st.IPv4Addresses = []string{"10.99.1.1/24"} + } + return &st + } for p := 1; p <= e.ports(); p++ { if e.interfaceID(p) != iid { continue @@ -295,3 +328,85 @@ func fakeMAC(seed string) string { d := syntheticID("mac/"+seed, 12) return fmt.Sprintf("02:%s:%s:%s:%s:%s", d[0:2], d[2:4], d[4:6], d[6:8], d[8:10]) } + +// a flat row per element, "active" or "backup", as the capture shows +func (s *Set) apiClusterStatuses(tsg string) []map[string]any { + ten := s.tenant(tsg) + if ten == nil { + return nil + } + var out []map[string]any + for i := range ten.Elements { + el := &ten.Elements[i] + if !el.HA { + continue + } + // The first HA element on a site carries it, as the per-cluster + // fixture decides too. + status, priority := "backup", 100 + if s.activeMember(tsg, el.clusterID()) == el.ID { + status, priority = "active", 150 + } + out = append(out, map[string]any{ + "id": syntheticID("clusterstatus/"+el.ID, 17), + "site_id": el.siteID(), + "spoke_cluster_id": el.clusterID(), + "element_id": el.ID, + "status": status, + "priority": priority, + "last_status_update_ts": bootedAt.UnixMilli(), + }) + } + return out +} + +// the per-cluster fixture and the aggregate have to agree on this +func (s *Set) activeMember(tsg, cid string) string { + ten := s.tenant(tsg) + if ten == nil { + return "" + } + for i := range ten.Elements { + el := &ten.Elements[i] + if el.HA && el.clusterID() == cid && el.Connected { + return el.ID + } + } + return "" +} + +// names like a real tenant's, so the join looks like it would in front of one +func fixtureZones() []map[string]any { + return []map[string]any{ + {"id": syntheticID("zone/trusted", 18), "name": "Store - LAN", + "description": "LAN side", "tcp_allow_non_syn": false, "is_l2": false}, + {"id": syntheticID("zone/guest", 18), "name": "Store - Guest", + "description": "guest wifi", "tcp_allow_non_syn": false, "is_l2": false}, + {"id": syntheticID("zone/internet", 18), "name": "internet", + "description": "circuits", "tcp_allow_non_syn": false, "is_l2": false}, + } +} + +// one record per zone, holding its interface ids; verified in interface.har +func (e Element) apiZoneBindings() []map[string]any { + return []map[string]any{ + { + "id": syntheticID("elzone/lan/"+e.ID, 18), + "zone_id": syntheticID("zone/trusted", 18), + "interface_ids": []string{e.vlanInterfaceID()}, + "lannetwork_ids": nil, + "waninterface_ids": nil, + "wanoverlay_ids": nil, + }, + { + // A zone with nothing of this element in it, which a tenant always + // has: the join has to skip it rather than name it. + "id": syntheticID("elzone/guest/"+e.ID, 18), + "zone_id": syntheticID("zone/guest", 18), + "interface_ids": nil, + "lannetwork_ids": nil, + "waninterface_ids": nil, + "wanoverlay_ids": nil, + }, + } +} diff --git a/internal/fixture/portal_test.go b/internal/fixture/portal_test.go index b1f6293..f767f6c 100644 --- a/internal/fixture/portal_test.go +++ b/internal/fixture/portal_test.go @@ -26,8 +26,9 @@ func TestTheRealClientTakesAPortalSnapshotOfAFixtureElement(t *testing.T) { t.Errorf("channel = %+v, want it connected with a timestamp", ch) } - if len(snap.Interfaces) != el.ports() { - t.Fatalf("got %d interfaces, want %d", len(snap.Interfaces), el.ports()) + // The ports, plus the one vlan interface that carries the security zone. + if want := el.ports() + 1; len(snap.Interfaces) != want { + t.Fatalf("got %d interfaces, want %d", len(snap.Interfaces), want) } first := snap.Interfaces[0] if first.Interface.Name != "1" || first.Interface.UsedFor != "public" { @@ -45,9 +46,19 @@ func TestTheRealClientTakesAPortalSnapshotOfAFixtureElement(t *testing.T) { } // A port nobody wired up is down, which is what makes an interface list // worth drawing at all. - last := snap.Interfaces[len(snap.Interfaces)-1] - if last.Status[0].OperationalState != "down" { - t.Errorf("last interface status = %+v, want it down", last.Status[0]) + lastPort := snap.Interfaces[el.ports()-1] + if lastPort.Status[0].OperationalState != "down" { + t.Errorf("last port status = %+v, want it down", lastPort.Status[0]) + } + + // The vlan interface comes last, and its status has no port block: a real + // element answers with port null on anything that is not a physical port. + vlan := snap.Interfaces[len(snap.Interfaces)-1] + if vlan.Interface.Type != "vlan" || vlan.Interface.UsedFor != "lan" { + t.Errorf("last interface = %+v, want the vlan", vlan.Interface) + } + if vlan.Status[0].Port != nil { + t.Errorf("vlan port = %+v, want nothing: it is not a physical port", vlan.Status[0].Port) } } diff --git a/internal/fixture/rest.go b/internal/fixture/rest.go index b268020..a43286d 100644 --- a/internal/fixture/rest.go +++ b/internal/fixture/rest.go @@ -42,9 +42,27 @@ func (t *transport) RoundTrip(req *http.Request) (*http.Response, error) { case strings.HasSuffix(p, "/api/wannetworks"): return t.json(req, http.StatusOK, items(fixtureNetworks())) + // Security zones in two parts, as the real API has them: the tenant's + // zone names, and one element's bindings of interfaces to zones. + case strings.HasSuffix(p, "/api/securityzones"): + return t.json(req, http.StatusOK, items(fixtureZones())) + + case strings.Contains(p, "/elements/") && strings.HasSuffix(p, "/securityzones"): + eid := path.Base(strings.TrimSuffix(p, "/securityzones")) + el := t.element(eid) + if el == nil { + return t.noElement(req, eid) + } + return t.json(req, http.StatusOK, items(el.apiZoneBindings())) + case strings.Contains(p, "/waninterfaces"): return t.circuits(req, p) + // The aggregate, which is tenant-wide: one POST for every cluster. Before + // the per-cluster route below, which its path would also match. + case strings.HasSuffix(p, "/spokeclusters/status/query"): + return t.json(req, http.StatusOK, items(t.set.apiClusterStatuses(t.tsg))) + case strings.Contains(p, "/spokeclusters/") && strings.HasSuffix(p, "/status"): cid := path.Base(strings.TrimSuffix(p, "/status")) st := t.set.apiClusterStatus(t.tsg, cid) diff --git a/internal/ionterm/pane.go b/internal/ionterm/pane.go index 8a5cd41..81c143e 100644 --- a/internal/ionterm/pane.go +++ b/internal/ionterm/pane.go @@ -158,3 +158,25 @@ func (s *Session) Scrollback() int { defer s.emuMu.Unlock() return s.emu.ScrollbackLen() } + +// What a copy takes: scrollback and screen, stripped, trailing blanks +// dropped. One lock, like ScrolledView, so the halves agree. +func (s *Session) Buffer() string { + s.emuMu.Lock() + defer s.emuMu.Unlock() + + sb := s.emu.Scrollback() + lines := screenLines(s.emu.Render(), s.emu.CursorPosition().Y) + + out := make([]string, 0, sb.Len()+len(lines)) + for i := 0; i < sb.Len(); i++ { + out = append(out, strings.TrimRight(ansi.Strip(sb.Line(i).Render()), " \t")) + } + for _, ln := range lines { + out = append(out, strings.TrimRight(ansi.Strip(ln), " \t")) + } + for len(out) > 0 && out[len(out)-1] == "" { + out = out[:len(out)-1] + } + return strings.Join(out, "\n") +} diff --git a/internal/ionterm/scrollback_test.go b/internal/ionterm/scrollback_test.go index 609e954..588bcd0 100644 --- a/internal/ionterm/scrollback_test.go +++ b/internal/ionterm/scrollback_test.go @@ -123,3 +123,25 @@ func TestScrolledViewDoesNotRaceDeviceOutput(t *testing.T) { } } } + +// Buffer is what a copy takes, so it has to reach past the screen: the whole +// point of copying a session is the output that has already scrolled away. +func TestBufferHoldsTheScrollbackAndTheScreen(t *testing.T) { + s, _ := scrolledSession(t, 30) + + got := s.Buffer() + for _, want := range []string{"L001", "L015", "L030"} { + if !strings.Contains(got, want) { + t.Errorf("Buffer() is missing %s:\n%s", want, got) + } + } + if strings.Contains(got, "\x1b[") { + t.Errorf("Buffer() carries escape codes, which a clipboard should not:\n%q", got) + } + if lines := strings.Split(got, "\n"); len(lines) < 30 { + t.Errorf("Buffer() has %d lines, want every line written", len(lines)) + } + if strings.HasSuffix(got, "\n") { + t.Error("Buffer() ends with the blank prompt rows the screen pads with") + } +} diff --git a/internal/sase/api/endpoint.go b/internal/sase/api/endpoint.go index 7901fee..aa93b7a 100644 --- a/internal/sase/api/endpoint.go +++ b/internal/sase/api/endpoint.go @@ -49,6 +49,12 @@ func clusterStatusURL(siteID, cid string) string { apiHost, clusterStatusVer, siteID, cid) } +// query_status_spokeclusters: tenant-wide, because the token carries the +// tenant. One POST where clusterStatusURL is one GET per cluster. +func clusterQueryURL() string { + return fmt.Sprintf("%s/sdwan/%s/api/spokeclusters/status/query", apiHost, clusterStatusVer) +} + func interfaceStatusURL(siteID, eid, iid string) string { return fmt.Sprintf("%s/sdwan/%s/api/sites/%s/elements/%s/interfaces/%s/status", apiHost, ifaceStatusVer, siteID, eid, iid) diff --git a/internal/sase/api/status.go b/internal/sase/api/status.go index d2a2226..19508e9 100644 --- a/internal/sase/api/status.go +++ b/internal/sase/api/status.go @@ -1,6 +1,7 @@ package api import ( + "bytes" "context" "encoding/json" "fmt" @@ -8,6 +9,7 @@ import ( "net/http" "os" "sort" + "strconv" "strings" "time" ) @@ -381,25 +383,67 @@ func (s *Client) GetJSON(ctx context.Context, path string) ([]byte, error) { // endpoints are modelled from the portal's own field names rather than from a // published schema, so a snapshot can show what was actually sent. func (s *Client) get(ctx context.Context, what, url string) ([]byte, error) { + return s.do(ctx, what, http.MethodGet, url, nil) +} + +// post is the same path for the query_* endpoints, which are POSTs carrying a +// filter rather than GETs with it in the path. +func (s *Client) post(ctx context.Context, what, url string, body []byte) ([]byte, error) { + return s.do(ctx, what, http.MethodPost, url, body) +} + +// the limiter is our guess; Retry-After is the server's own answer +const rateRetries = 2 + +// a Retry-After of a minute is longer than anyone will wait +const rateWaitCap = 5 * time.Second + +func (s *Client) do(ctx context.Context, what, method, url string, body []byte) ([]byte, error) { + for attempt := 0; ; attempt++ { + answer, wait, err := s.attempt(ctx, what, method, url, body) + if wait <= 0 || attempt >= rateRetries { + return answer, err + } + if s.verbose { + fmt.Fprintf(os.Stderr, "%s: rate limited, waiting %s\n", what, wait) + } + select { + case <-ctx.Done(): + return nil, fmt.Errorf("%s: %w", what, ctx.Err()) + case <-time.After(wait): + } + } +} + +// attempt is one request. A non-zero wait means the server asked for one and +// the call is worth repeating; everything else is the answer or the error. +func (s *Client) attempt(ctx context.Context, what, method, url string, body []byte) ([]byte, time.Duration, error) { if err := s.limiter.Wait(ctx); err != nil { - return nil, fmt.Errorf("%s: rate limit wait: %w", what, err) + return nil, 0, fmt.Errorf("%s: rate limit wait: %w", what, err) } if s.verbose { - fmt.Fprintln(os.Stderr, "GET", url) + fmt.Fprintln(os.Stderr, method, url) } - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + var rdr io.Reader + if body != nil { + rdr = bytes.NewReader(body) + } + req, err := http.NewRequestWithContext(ctx, method, url, rdr) if err != nil { - return nil, fmt.Errorf("%s: build request: %w", what, err) + return nil, 0, fmt.Errorf("%s: build request: %w", what, err) } req.Header.Set("Authorization", "Bearer "+s.getToken()) req.Header.Set("Accept", "application/json") req.Header.Set("User-Agent", browserUA) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } resp, err := s.client.Do(req) if err != nil { - return nil, fmt.Errorf("%s: response: %w", what, err) + return nil, 0, fmt.Errorf("%s: response: %w", what, err) } // The rate limit is the thing a fleet-wide run hits first, and the headers @@ -417,12 +461,106 @@ func (s *Client) get(ctx context.Context, what, url string) ([]byte, error) { resp.Body.Close() }() - body, err := io.ReadAll(resp.Body) + answer, err := io.ReadAll(resp.Body) if err != nil { - return nil, fmt.Errorf("%s: read body: %w", what, err) + return nil, 0, fmt.Errorf("%s: read body: %w", what, err) + } + if resp.StatusCode == http.StatusTooManyRequests { + return nil, retryAfter(resp), fmt.Errorf("%s: status %s: %s", what, resp.Status, answer) } if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("%s: status %s: %s", what, resp.Status, body) + return nil, 0, fmt.Errorf("%s: status %s: %s", what, resp.Status, answer) + } + return answer, 0, nil +} + +// seconds only; a bad header means a short wait, not no retry +func retryAfter(resp *http.Response) time.Duration { + wait := time.Second + if v := resp.Header.Get("Retry-After"); v != "" { + if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil && n > 0 { + wait = time.Duration(n) * time.Second + } + } + return min(wait, rateWaitCap) +} + +// The envelope the portal's query_* POSTs take, from its own calls. +// dest_page counts from one, not zero. +func queryBody(filter string, limit, page int) []byte { + return []byte(fmt.Sprintf( + `{"query_params":%s,"dest_page":%d,"limit":%d,"getDeleted":false,`+ + `"retrieved_fields_mask":false,"retrieved_fields":[]}`, filter, page, limit)) +} + +// query_params as the portal sends it for the element-id queries +func elementFilter(ids []string) string { + quoted := make([]string, 0, len(ids)) + for _, id := range ids { + quoted = append(quoted, `"`+id+`"`) } - return body, nil + return `{"element_id":{"in":[` + strings.Join(quoted, ",") + `]}}` +} + +// 200 is what the portal asks for +const ClusterQueryLimit = 200 + +// a server that ignores dest_page cannot spin here +const clusterQueryPages = 20 + +// One element's place in its cluster, from the tenant-wide query. Unlike the +// per-cluster endpoint it names the second node: "backup". +type ClusterMemberStatus struct { + ID string `json:"id"` + SiteID string `json:"site_id"` + ClusterID string `json:"spoke_cluster_id"` + ElementID string `json:"element_id"` + Status string `json:"status"` + Priority int `json:"priority"` + // UpdatedAt is milliseconds, like the other cluster timestamps. + UpdatedAt int64 `json:"last_status_update_ts"` +} + +// The HA state of the elements named, in one call, which is what the portal +// does. More than ClusterQueryLimit of them means asking again. +func (s *Client) SpokeClusterStatuses(ctx context.Context, elementIDs []string) ([]ClusterMemberStatus, error) { + if len(elementIDs) == 0 { + return nil, fmt.Errorf("spoke cluster status query: no elements to ask about") + } + + out, err := s.clusterQuery(ctx, elementFilter(elementIDs)) + if err != nil { + return nil, err + } + for _, st := range out { + if st.ElementID != "" { + return out, nil + } + } + // Either none of these elements is in a cluster or the shape has moved. + // They are indistinguishable here, and only one of them is safe to act on. + return nil, fmt.Errorf("spoke cluster status query: no members in %d item(s)", len(out)) +} + +func (s *Client) clusterQuery(ctx context.Context, filter string) ([]ClusterMemberStatus, error) { + var out []ClusterMemberStatus + for page := 1; page <= clusterQueryPages; page++ { + body, err := s.post(ctx, "spoke cluster status query", clusterQueryURL(), + queryBody(filter, ClusterQueryLimit, page)) + if err != nil { + return nil, err + } + var env struct { + Count int `json:"count"` + Items []ClusterMemberStatus `json:"items"` + } + if err := json.Unmarshal(body, &env); err != nil { + return nil, fmt.Errorf("spoke cluster status query: json decode: %w", err) + } + out = append(out, env.Items...) + if len(env.Items) < ClusterQueryLimit { + break + } + } + return out, nil } diff --git a/internal/sase/api/status_test.go b/internal/sase/api/status_test.go index da5fd29..a5a0393 100644 --- a/internal/sase/api/status_test.go +++ b/internal/sase/api/status_test.go @@ -2,6 +2,7 @@ package api import ( "bytes" + "context" "encoding/json" "io" "net/http" @@ -467,3 +468,154 @@ func TestSnapshotWithNoSiteDoesNotAskForInterfaces(t *testing.T) { t.Errorf("StatusErr = %v, want the status still fetched", snap.StatusErr) } } + +// The aggregate, as the portal's own capture of 2026-10-19 shows it: a POST +// filtered by element id, dest_page counting from one, answered with a flat +// row per element whose status is "active" or "backup". Nothing about this was +// guessable -- the first implementation read it as clusters carrying +// cluster_members and would have fallen back for ever. +func TestSpokeClusterStatusesReadsTheAggregate(t *testing.T) { + const captured = `{"count":2,"items":[ + {"id":"163241838","site_id":"1632417734","spoke_cluster_id":"1632417740", + "element_id":"1632412620","status":"active","priority":150, + "last_status_update_ts":1790721802930}, + {"id":"163241896","site_id":"1632417734","spoke_cluster_id":"1632417740", + "element_id":"1632412630","status":"backup","priority":100, + "last_status_update_ts":1790721802295}]}` + + tests := []struct { + name string + status int + body string + want int + wantErr string + }{ + {name: "the shape the portal answers with", status: http.StatusOK, body: captured, want: 2}, + { + // A tenant with no clustered elements and a shape this cannot read + // are the same answer here, and only one is safe to act on. + name: "nothing usable in it", + status: http.StatusOK, + body: `{"count":0,"items":[]}`, + wantErr: "no members", + }, + { + name: "rows with no element id", + status: http.StatusOK, + body: `{"count":1,"items":[{"status":"active"}]}`, + wantErr: "no members", + }, + {name: "refused", status: http.StatusForbidden, body: `{"error":"no"}`, wantErr: "Forbidden"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var seen *http.Request + var sent []byte + c := snapshotClient(t, routes{ + "/spokeclusters/status/query": func() (int, string) { return tc.status, tc.body }, + }) + c.client.Transport = recorder{ + inner: c.client.Transport, + saw: func(r *http.Request, b []byte) { seen, sent = r, b }, + } + + got, err := c.SpokeClusterStatuses(context.Background(), []string{"1632412620", "1632412630"}) + if tc.wantErr != "" { + if err == nil { + t.Fatalf("SpokeClusterStatuses() = %v, want an error so the caller falls back", got) + } + if !strings.Contains(err.Error(), tc.wantErr) { + t.Errorf("error = %q, want it to mention %q", err, tc.wantErr) + } + return + } + if err != nil { + t.Fatalf("SpokeClusterStatuses() error = %v", err) + } + if len(got) != tc.want { + t.Fatalf("%d rows, want %d", len(got), tc.want) + } + // The fields the column and the data pane read. + if got[0].Status != "active" || got[0].Priority != 150 { + t.Errorf("first row = %+v, want the active member at 150", got[0]) + } + if got[1].Status != "backup" { + t.Errorf("second row = %+v, want the portal's own word for standby", got[1]) + } + if got[0].ClusterID == "" || got[0].SiteID == "" || got[0].ElementID == "" { + t.Errorf("first row = %+v, want its ids decoded", got[0]) + } + + if seen == nil { + t.Fatal("no request was made") + } + if seen.Method != http.MethodPost { + t.Errorf("method = %s, want POST", seen.Method) + } + if !strings.HasSuffix(seen.URL.Path, "/spokeclusters/status/query") { + t.Errorf("path = %s, want the tenant-wide query", seen.URL.Path) + } + // The body the portal sends: the elements named, and a first page + // of one rather than zero. + for _, want := range []string{ + `"query_params":{"element_id":{"in":["1632412620","1632412630"]}}`, + `"dest_page":1`, + `"limit":200`, + `"getDeleted":false`, + } { + if !strings.Contains(string(sent), want) { + t.Errorf("request body\n%s\nis missing %s", sent, want) + } + } + }) + } +} + +// The call asks about exactly the elements it is given: the chunking is the +// caller's, so that each chunk can paint as it lands and the first one can be +// the rows on screen. +func TestTheAggregateAsksAboutTheElementsItIsGiven(t *testing.T) { + var sent []byte + c := snapshotClient(t, routes{ + "/spokeclusters/status/query": func() (int, string) { + return http.StatusOK, + `{"count":1,"items":[{"element_id":"e2","spoke_cluster_id":"c1","status":"active"}]}` + }, + }) + calls := 0 + c.client.Transport = recorder{ + inner: c.client.Transport, + saw: func(_ *http.Request, b []byte) { sent, calls = b, calls+1 }, + } + + if _, err := c.SpokeClusterStatuses(context.Background(), []string{"e1", "e2", "e3"}); err != nil { + t.Fatalf("SpokeClusterStatuses() error = %v", err) + } + if calls != 1 { + t.Errorf("%d requests, want one for one chunk", calls) + } + if !strings.Contains(string(sent), `"in":["e1","e2","e3"]`) { + t.Errorf("request body %s does not name exactly the elements asked about", sent) + } + if _, err := c.SpokeClusterStatuses(context.Background(), nil); err == nil { + t.Error("asking about no elements should be an error, not a request") + } +} + +// recorder keeps the request a test wants to assert on, since the body is +// gone by the time the response comes back. +type recorder struct { + inner http.RoundTripper + saw func(*http.Request, []byte) +} + +func (r recorder) RoundTrip(req *http.Request) (*http.Response, error) { + var body []byte + if req.Body != nil { + body, _ = io.ReadAll(req.Body) + req.Body = io.NopCloser(bytes.NewReader(body)) + } + r.saw(req, body) + return r.inner.RoundTrip(req) +} diff --git a/internal/saseauth/browser_test.go b/internal/saseauth/browser_test.go index 5ed1979..ab1eae1 100644 --- a/internal/saseauth/browser_test.go +++ b/internal/saseauth/browser_test.go @@ -2,11 +2,13 @@ package saseauth import ( "context" + "fmt" "net/http" "net/http/httptest" "net/url" "os" "os/exec" + "path/filepath" "strings" "sync" "testing" @@ -17,20 +19,46 @@ import ( // when the machine has none. The tests run it without its sandbox: they drive // a throwaway profile against a fake server, and CI images forbid the user // namespaces Chrome would need. +// +// Packaged builds come first and chromium-browser last. On Ubuntu 24.04 that +// name is a snap shim, and a snap Chromium on a CI runner is how the v0.5.0 +// release died: chromedp waited out its whole budget on a browser that never +// came up, and the failure said only "context deadline exceeded". func findChrome(t *testing.T) string { t.Helper() if testing.Short() { t.Skip("skipping browser test in short mode") } - for _, name := range []string{"chromium-browser", "chromium", "google-chrome", "google-chrome-stable", "chrome"} { - if path, err := exec.LookPath(name); err == nil { - return path + for _, name := range []string{"google-chrome", "google-chrome-stable", "chrome", "chromium", "chromium-browser"} { + path, err := exec.LookPath(name) + if err != nil { + continue + } + if err := startable(path); err != nil { + t.Logf("skipping %s: %v", path, err) + continue } + return path } - t.Skip("no chrome or chromium on PATH") + t.Skip("no browser on PATH that will start") return "" } +// startable is the cheapest question worth asking of a browser binary: can it +// print its own version. A wrapper that cannot is one chromedp would sit on +// until its deadline, and a test that skips with a reason beats a build that +// fails 45 seconds later without one. +func startable(path string) error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + out, err := exec.CommandContext(ctx, path, "--version").CombinedOutput() + if err != nil { + return fmt.Errorf("%s --version: %w: %s", filepath.Base(path), err, strings.TrimSpace(string(out))) + } + return nil +} + // needsDisplay skips a test that opens a real browser window. Only the first // sign-in of a Discover opens one; chromedp refuses to without a display, and // CI gives it one with xvfb. diff --git a/internal/search/search.go b/internal/search/search.go index 6010e84..074508a 100644 --- a/internal/search/search.go +++ b/internal/search/search.go @@ -34,6 +34,7 @@ type Record struct { Model string Software string HWID string + Serial string Connected bool } @@ -46,6 +47,7 @@ func Of(el api.Element, tsg, tenant string, sites SiteNamer) Record { Model: el.Model, Software: el.Software, HWID: el.HWID, + Serial: el.Serial, Connected: el.Connected, } if sites != nil { @@ -94,6 +96,9 @@ func (r Record) Fields() []Field { {Value: strings.ToLower(r.Tenant)}, {Value: strings.ToLower(r.Model)}, {Value: strings.ToLower(r.HWID), Exact: true}, + // Exact like the hardware id: a serial is quoted off a label or a + // support case, never half-remembered. + {Value: strings.ToLower(r.Serial), Exact: true}, {Value: strings.ToLower(r.Software)}, {Value: strings.ToLower(r.TSG), Exact: true}, {Value: r.State()}, diff --git a/internal/tui/appearance.go b/internal/tui/appearance.go new file mode 100644 index 0000000..7442a64 --- /dev/null +++ b/internal/tui/appearance.go @@ -0,0 +1,245 @@ +package tui + +import ( + "fmt" + "strings" + "time" + + tea "charm.land/bubbletea/v2" + "tyrtop.com/iontk/internal/clock" + "tyrtop.com/iontk/internal/ui" +) + +// prints the clock too: a zone set months ago is only checkable against it +func (m tuiModel) zoneLine() string { + if p := clock.Problem(); p != "" { + return ui.StyleWarn.Render(ui.Truncate(p, 44)) + ui.StyleDim.Render(" times are UTC") + } + return ui.StyleElement.Render(clock.Name()) + + ui.StyleDim.Render(" now "+clock.Stamp(time.Now())) +} + +// 600 IANA names have no order to walk, so only the typed one is kept +func (m tuiModel) cycleZone() (tea.Model, tea.Cmd) { + order := []string{clock.UTC, clock.Local} + if m.typedZone != "" { + order = append(order, m.typedZone) + } + + next := order[0] + for i, z := range order { + if strings.EqualFold(z, clock.Name()) { + next = order[(i+1)%len(order)] + break + } + } + nm, _ := m.applyZone(next) + return nm, nil +} + +// a zone that will not resolve is a typo, so the saved value stays +func (m tuiModel) applyZone(spec string) (tuiModel, bool) { + if err := clock.Valid(spec); err != nil { + m.status = err.Error() + return m, false + } + m.cfg.Zone = spec + if err := m.store.SaveConfig(m.cfg); err != nil { + m.status = err.Error() + return m, false + } + _ = clock.Use(spec) + if !strings.EqualFold(spec, clock.UTC) && !strings.EqualFold(spec, clock.Local) { + m.typedZone = spec + } + m.status = "times are now " + clock.Stamp(time.Now()) + return m, true +} + +func (m tuiModel) openZoneForm() (tea.Model, tea.Cmd) { + f := newField("zone", "America/Chicago", false) + f.input.SetValue(m.cfg.Zone) + return m.openForm(formZone, "Zone for printed times", phaseAddUser, []formField{f}) +} + +func (m tuiModel) submitZone() (tea.Model, tea.Cmd) { + nm, ok := m.applyZone(m.formValue(0)) + if !ok { + // the form stays up with the reason on it + return nm, nil + } + nm.form, nm.formKind = nil, formNone + nm.phase = nm.formReturn + return nm, nil +} + +func (m tuiModel) themeContent() []string { + if m.themeEdit { + return append(m.themeEditContent(), m.themePreview(m.themeDraft)...) + } + + themes := ui.Themes() + cur := ui.CurrentTheme() + + out := make([]string, 0, len(themes)+10) + for _, t := range themes { + // padded plain, or the swatches drift row by row + name := ui.StyleElement.Render(fmt.Sprintf("%-12s", ui.Truncate(t.Name, 12))) + tag := "" + if strings.EqualFold(t.Name, cur.Name) { + tag = ui.StyleOK.Render(" (in use)") + } + // nothing to say about a scheme this terminal can show + if ui.ThemeNote(t) != "" { + tag += ui.StyleWarn.Render(" approximated here") + } + out = append(out, name+t.Swatch()+ui.StyleDim.Render(" "+t.Blurb)+tag) + } + + if err := ui.ThemesErr(); err != "" { + out = append(out, "", ui.StyleErr.Render(ui.Truncate(err, 60))) + } + + // under the cursor, not in use: a preview is for before choosing + at := min(max(m.setCursor, 0), len(themes)-1) + if m.setFocus != focusContent { + at = max(0, indexOfTheme(themes, cur.Name)) + } + return append(out, m.themePreview(themes[at])...) +} + +func indexOfTheme(themes []ui.Theme, name string) int { + for i, t := range themes { + if strings.EqualFold(t.Name, name) { + return i + } + } + return -1 +} + +// fixture-shaped rows: a swatch says what the colours are, a pane says +// what they look like. +var previewRows = []struct { + name, site, model, state string + ok, selected bool +}{ + {"store-0003-rtr-01", "Store 0003", "ion 1200", "connected", true, true}, + {"store-0003-rtr-02", "Store 0003", "ion 1200-s", "connected", true, false}, + {"store-0100-rtr-01", "Store 0100", "ion 3200", "offline", false, false}, +} + +// a page of iontk drawn in one scheme +func (m tuiModel) themePreview(t ui.Theme) []string { + s := t.Styles() + width := max(40, min(m.width-20, 64)) + + out := []string{ + "", + ui.StyleDim.Render("preview · ") + ui.StyleElement.Render(t.Name), + // tab colours are identity, not status + previewTabs(t), + } + + for i, r := range previewRows { + mark := s.OK.SetString("✓").String() + state := s.OK.Render(r.state) + if !r.ok { + mark = s.Err.SetString("✗").String() + state = s.Warn.Render(r.state) + } + box := "[ ]" + if r.selected { + box = s.OK.Render("[x]") + } + // styled on its own: a highlight around the mark ends at its reset + block := fmt.Sprintf("%-20s%-12s%-11s", r.name, r.site, r.model) + style := s.Element + if i == 0 { + style = s.Row + } + out = append(out, " "+mark+" "+box+" "+style.Render(block)+" "+state) + } + + out = append(out, + " "+s.Cmd.Render("dump device status")+ + s.Dim.Render(" 3 elements · 1 selected"), + " "+s.Warn.Render("ion offline")+ + s.Dim.Render(" · alt+y copy · ")+s.Hot.Render("session closing"), + " "+s.Spinner.Render(spinnerFrame(t))+s.Dim.Render(" reading interfaces")+ + s.Dim.Render(" spinner: ")+s.Element.Render(spinnerLabel(t)), + ) + if note := ui.ThemeNote(t); note != "" { + out = append(out, " "+ui.StyleWarn.Render(ui.Truncate(note, width))) + } + + depth := ui.ProfileName() + if depth == "" { + depth = "not reported yet" + } + return append(out, "", ui.Label("terminal")+ui.StyleDim.Render(depth)) +} + +func previewTabs(t ui.Theme) string { + s := t.Styles() + labels := []string{" elements (shell) ", " store-0003-rtr-01 ", " store-0100-rtr-01 · data "} + parts := make([]string, 0, len(labels)) + for i, label := range labels { + st := s.Tabs[i%len(s.Tabs)] + if i == 0 { + st = st.Reverse(true).Bold(true) + } + parts = append(parts, st.Render(label)) + } + return " " + strings.Join(parts, " ") +} + +func spinnerFrame(t ui.Theme) string { + return ui.SpinnerFrame(t.Spinner) +} + +func spinnerLabel(t ui.Theme) string { return ui.SpinnerName(t.Spinner) } + +func (m tuiModel) onThemeKey(key string) (tea.Model, tea.Cmd) { + if m.themeEdit { + return m.onThemeEditKey(key) + } + if key == "e" { + return m.openThemeEdit() + } + if key != "enter" && key != " " && key != "space" { + return m, nil + } + themes := ui.Themes() + if m.setCursor < 0 || m.setCursor >= len(themes) { + return m, nil + } + t := themes[m.setCursor] + + m.cfg.Theme = t.Name + if err := m.store.SaveConfig(m.cfg); err != nil { + m.status = err.Error() + return m, nil + } + next, cmd := m.applyTheme(t) + nm := next.(tuiModel) + nm.status = t.Name + if note := ui.ThemeNote(t); note != "" { + nm.status += " — " + note + } + return nm, cmd +} + +// the tables and the spinner copied a style when built, so rebuild them +func (m tuiModel) applyTheme(t ui.Theme) (tea.Model, tea.Cmd) { + ui.Apply(t) + m.table.SetStyles(tableStyles()) + m.resTable.SetStyles(tableStyles()) + return m.withSpinner() +} + +func profileLabel() string { + if n := ui.ProfileName(); n != "" { + return n + } + return "depth not reported" +} diff --git a/internal/tui/appearance_test.go b/internal/tui/appearance_test.go new file mode 100644 index 0000000..1e09480 --- /dev/null +++ b/internal/tui/appearance_test.go @@ -0,0 +1,341 @@ +package tui + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "tyrtop.com/iontk/internal/app" + "tyrtop.com/iontk/internal/clock" + "tyrtop.com/iontk/internal/config" + "tyrtop.com/iontk/internal/ui" +) + +// pinZone and pinTheme put the process state back: both are global, so a test +// that left one set would decide what every later test rendered. +func pinZone(t *testing.T) { + t.Helper() + t.Cleanup(func() { _ = clock.Use(clock.UTC) }) +} + +func pinTheme(t *testing.T) { + t.Helper() + // LoadThemes("") is also how the set goes back to the built-ins, which a + // test that saved an override has to do. + t.Cleanup(func() { + _ = ui.LoadThemes("") + ui.Apply(ui.Default()) + }) +} + +// The times row is where someone finds out their panes are five hours out, so +// it has to show the clock and not only the zone's name. +func TestTimesRowShowsTheZoneAndTheClock(t *testing.T) { + pinZone(t) + m := settingsModel(t) + m.setSection = secGeneral + m.setFocus = focusContent + + out := plain(m.viewSettings()) + if !strings.Contains(out, "times") || !strings.Contains(out, "UTC") { + t.Errorf("the General section does not say what zone times are in:\n%s", out) + } + + // space cycles; enter on the same row types a name instead. + m.setCursor = zoneRow + mm, _ := m.onGeneralKey("space") + got := mm.(tuiModel) + if got.cfg.Zone != clock.Local { + t.Errorf("cfg.Zone = %q, want the cycle to reach local", got.cfg.Zone) + } + saved, err := got.store.Config() + if err != nil { + t.Fatal(err) + } + if saved.Zone != clock.Local { + t.Errorf("saved zone = %q, want it persisted", saved.Zone) + } + if clock.Name() != clock.Local { + t.Errorf("clock.Name() = %q, want the change to apply now", clock.Name()) + } +} + +// Cycling has to be able to get back to a typed zone: there are 600 IANA +// names and no order to walk them in. +func TestTheCycleKeepsTheTypedZone(t *testing.T) { + pinZone(t) + m := settingsModel(t) + m.setSection, m.setFocus, m.setCursor = secGeneral, focusContent, zoneRow + + m, ok := m.applyZone("America/Chicago") + if !ok { + t.Fatalf("applyZone() refused a real zone: %s", m.status) + } + + seen := map[string]bool{} + for i := 0; i < 3; i++ { + mm, _ := m.onGeneralKey("space") + m = mm.(tuiModel) + seen[m.cfg.Zone] = true + } + for _, want := range []string{clock.UTC, clock.Local, "America/Chicago"} { + if !seen[want] { + t.Errorf("the cycle never reached %q: %v", want, seen) + } + } +} + +func TestEnterOnTheTimesRowOpensTheZoneForm(t *testing.T) { + pinZone(t) + m := settingsModel(t) + m.setSection, m.setFocus, m.setCursor = secGeneral, focusContent, zoneRow + + mm, _ := m.onGeneralKey("enter") + m = mm.(tuiModel) + if m.formKind != formZone { + t.Fatalf("formKind = %v, want the zone form", m.formKind) + } + + m.form[0].input.SetValue("Europe/London") + mm, _ = m.submitZone() + m = mm.(tuiModel) + if m.formKind != formNone { + t.Error("the form stayed open after a zone that loads") + } + if clock.Name() != "Europe/London" { + t.Errorf("clock.Name() = %q, want the typed zone", clock.Name()) + } +} + +// A zone nobody can resolve is a typo. The form keeps it and says why rather +// than saving it and printing UTC from then on. +func TestAZoneThatWillNotLoadIsRefusedByTheForm(t *testing.T) { + pinZone(t) + m := settingsModel(t) + m.setSection, m.setFocus, m.setCursor = secGeneral, focusContent, zoneRow + + mm, _ := m.onGeneralKey("enter") + m = mm.(tuiModel) + m.form[0].input.SetValue("Mars/Olympus_Mons") + mm, _ = m.submitZone() + m = mm.(tuiModel) + + if m.formKind != formZone { + t.Error("the form closed on a zone that does not exist") + } + if m.status == "" { + t.Error("nothing said why the zone was refused") + } + if m.cfg.Zone != "" { + t.Errorf("cfg.Zone = %q, want the saved value left alone", m.cfg.Zone) + } +} + +func TestThemeSectionListsEveryThemeAndMarksTheOneInUse(t *testing.T) { + pinTheme(t) + m := settingsModel(t) + m.setSection = secTheme + m.setFocus = focusContent + + out := plain(m.viewSettings()) + for _, th := range ui.Themes() { + if !strings.Contains(out, th.Name) { + t.Errorf("the Theme section is missing %q:\n%s", th.Name, out) + } + } + if !strings.Contains(out, "(in use)") { + t.Errorf("nothing marks the theme in use:\n%s", out) + } + if got := m.sectionRows(); got != len(ui.Themes()) { + t.Errorf("sectionRows() = %d, want one per theme", got) + } + t.Log("\n" + m.viewSettings()) +} + +func TestPickingAThemeAppliesAndPersistsIt(t *testing.T) { + pinTheme(t) + m := settingsModel(t) + m.setSection, m.setFocus = secTheme, focusContent + + // mono is the one whose effect is visible without colour support. + want := 0 + for i, th := range ui.Themes() { + if th.Name == "mono" { + want = i + } + } + m.setCursor = want + + mm, cmd := m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyEnter})) + m = mm.(tuiModel) + if ui.CurrentTheme().Name != "mono" { + t.Errorf("CurrentTheme() = %q, want mono", ui.CurrentTheme().Name) + } + saved, err := m.store.Config() + if err != nil { + t.Fatal(err) + } + if saved.Theme != "mono" { + t.Errorf("saved theme = %q, want it persisted", saved.Theme) + } + // The spinner carries a style built when it was made, so the change has to + // rebuild it -- and that comes with a tick. + if cmd == nil { + t.Error("picking a theme issued no command, so the spinner kept the old one") + } + if ui.StyleOK.Render("x") != "x" { + t.Errorf("mono still colours StyleOK: %q", ui.StyleOK.Render("x")) + } +} + +// A theme saved by a later version, or deleted from one, must not stop the TUI +// opening. +func TestAnUnknownSavedThemeOpensInTheDefault(t *testing.T) { + pinTheme(t) + m := settingsModel(t) + m.cfg.Theme = "something from the future" + if err := m.store.SaveConfig(m.cfg); err != nil { + t.Fatal(err) + } + + ui.UseTheme(m.cfg.Theme) + if ui.CurrentTheme().Name != "classic" { + t.Errorf("CurrentTheme() = %q, want classic", ui.CurrentTheme().Name) + } +} + +// A swatch says what the colours are; only a pane says what they look like in +// the thing people read. The preview follows the cursor, not the scheme in +// use, because its job is showing one before you pick it. +func TestThePreviewFollowsTheCursorNotTheThemeInUse(t *testing.T) { + pinTheme(t) + m := settingsModel(t) + m.setSection, m.setFocus = secTheme, focusContent + + themes := ui.Themes() + last := len(themes) - 1 + m.setCursor = last + + out := plain(m.viewSettings()) + if !strings.Contains(out, "preview · "+themes[last].Name) { + t.Errorf("the preview is not of the scheme under the cursor:\n%s", out) + } + // Something recognisable from a real page, with fixture-shaped data. + for _, want := range []string{"store-0003-rtr-01", "Store 0003", "ion 1200", + "connected", "offline", "dump device status", "elements (shell)"} { + if !strings.Contains(out, want) { + t.Errorf("the preview is missing %q:\n%s", want, out) + } + } + if ui.CurrentTheme().Name == themes[last].Name { + t.Fatal("this test needs the cursor on a scheme that is not in use") + } + + // On the nav pane there is no cursor in the list, so it shows the one in use. + m.setFocus = focusNav + if out := plain(m.viewSettings()); !strings.Contains(out, "preview · "+ui.CurrentTheme().Name) { + t.Errorf("with the nav focused the preview should be the scheme in use:\n%s", out) + } +} + +// The preview must draw in the previewed scheme, not the one applied, or it +// would show the same thing for every row. +func TestThePreviewRendersInTheSchemeItNames(t *testing.T) { + pinTheme(t) + m := settingsModel(t) + m.width, m.height = 110, 40 + + mono, _ := ui.ThemeByName("mono") + nord, _ := ui.ThemeByName("nord") + + monoLines := strings.Join(m.themePreview(mono), "\n") + nordLines := strings.Join(m.themePreview(nord), "\n") + if monoLines == nordLines { + t.Error("two schemes previewed identically") + } + // nord's green is #a6e3a1-adjacent hex, which lands on an RGB escape. + if !strings.Contains(nordLines, "38;2;") { + t.Errorf("the nord preview has no truecolor in it:\n%q", nordLines) + } + if strings.Contains(monoLines, "38;2;") { + t.Errorf("the mono preview emitted colour:\n%q", monoLines) + } + // Each scheme names its own spinner shape, and shows a frame of it. + if !strings.Contains(plain(monoLines), mono.Spinner) { + t.Errorf("the preview does not name mono's spinner %q", mono.Spinner) + } +} + +// The spinner moved into the theme, so picking one has to change the shape the +// model is actually ticking. +func TestPickingAThemeChangesTheSpinnerShape(t *testing.T) { + pinTheme(t) + m := settingsModel(t) + m.setSection, m.setFocus = secTheme, focusContent + + themes := ui.Themes() + want := -1 + for i, th := range themes { + if th.Name == "Ben's Theme" { + want = i + } + } + if want < 0 { + t.Fatal("no theme called Ben's Theme") + } + m.setCursor = want + + mm, cmd := m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyEnter})) + m = mm.(tuiModel) + if cmd == nil { + t.Error("picking a theme issued no command, so the spinner was not rebuilt") + } + if got := ui.CurrentTheme().Spinner; got != "monkey" { + t.Errorf("spinner = %q, want the monkey the scheme asks for", got) + } + // The model's own spinner is the one that ticks, so it has to have changed. + if got := plain(m.spinner.View()); got == "" { + t.Error("the model's spinner renders nothing after the change") + } +} + +// A themes.yaml beside the config adds schemes, which is what makes the format +// worth having: --themes is the same escape hatch --views is. +func TestAUserThemesFileReachesThePicker(t *testing.T) { + pinTheme(t) + t.Cleanup(func() { app.ThemesFile = "" }) + + path := filepath.Join(t.TempDir(), ui.ThemesFileName) + err := os.WriteFile(path, []byte(` +themes: + - name: nocturne + blurb: mine + ok: "2" + element: "4" + row: "5" + spinner: globe +`), 0o600) + if err != nil { + t.Fatal(err) + } + app.ThemesFile = path + + root := t.TempDir() + st := config.OpenAt(root, config.NewFileSecrets(root+"/secrets.json")) + m := newTUIModel(context.Background(), st, &config.Config{Theme: "nocturne"}) + m.width, m.height = 110, 40 + m.phase, m.setSection = phaseSettings, secTheme + + if got := ui.CurrentTheme().Name; got != "nocturne" { + t.Errorf("CurrentTheme() = %q, want the scheme from the file", got) + } + if got := plain(m.viewSettings()); !strings.Contains(got, "nocturne") { + t.Errorf("the picker does not list the file's scheme:\n%s", got) + } + if got := app.ThemesPath(st); got != path { + t.Errorf("ThemesPath() = %q, want --themes to win", got) + } +} diff --git a/internal/tui/batchui.go b/internal/tui/batchui.go index 535c492..97a579a 100644 --- a/internal/tui/batchui.go +++ b/internal/tui/batchui.go @@ -11,7 +11,6 @@ import ( "charm.land/bubbles/v2/progress" "charm.land/bubbles/v2/spinner" tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" "golang.org/x/term" "tyrtop.com/iontk/internal/output" "tyrtop.com/iontk/internal/sase" @@ -74,7 +73,7 @@ type streamClosedMsg struct{} func newBatchModel(events <-chan sase.Event, commands []string, total int, sink output.Sink) batchModel { s := spinner.New() - s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("5")) + s.Style = ui.StyleSpinner return batchModel{ events: events, commands: commands, diff --git a/internal/tui/bench_test.go b/internal/tui/bench_test.go new file mode 100644 index 0000000..cd3b752 --- /dev/null +++ b/internal/tui/bench_test.go @@ -0,0 +1,82 @@ +package tui + +import ( + "context" + "fmt" + "testing" + + "tyrtop.com/iontk/internal/config" + "tyrtop.com/iontk/internal/sase/api" +) + +// These exist because a search went from snappy to unusable and nobody could +// say which change did it. Measured on the fleet this is actually used on: +// 5600 elements, nearly all of them in HA pairs. +// +// keystroke 40ms before any of this +// 137ms after the green line and the content-sized columns +// 13ms after the per-row helpers stopped copying tuiModel +// +// A fleet the size of the one this is used on: 5600 elements, nearly all of +// them in HA pairs. +func fleetModel(b *testing.B, n int) tuiModel { + b.Helper() + m := benchModel(b) + els := make([]api.Element, 0, n) + for i := range n { + els = append(els, api.Element{ + ID: fmt.Sprint(i), Name: fmt.Sprintf("store-%04d-rtr-%02d", i/2, i%2+1), + Model: "ion 1200", Serial: fmt.Sprintf("ION1200%07d", i), + SiteID: fmt.Sprint(i / 2), Connected: i%7 != 0, + SpokeHA: &api.SpokeHA{ClusterID: fmt.Sprint(i / 2), Enable: true}, + }) + } + setTestElements(&m, els) + m.sites = map[string]string{} + for i := range n / 2 { + m.sites["1/"+fmt.Sprint(i)] = fmt.Sprintf("Store %04d", i) + } + m.phase = phasePicking + m.width, m.height = 160, 40 + m.rebuildRows() + return m +} + +func BenchmarkFilterKeystroke(b *testing.B) { + m := fleetModel(b, 5600) + queries := []string{"s", "st", "sto", "stor", "store-0", "store-01", "store-012"} + b.ResetTimer() + for i := 0; b.Loop(); i++ { + m.filter.SetValue(queries[i%len(queries)]) + m.applyFilter() + } +} + +func BenchmarkRebuildRows(b *testing.B) { + m := fleetModel(b, 5600) + b.ResetTimer() + for b.Loop() { + m.rebuildRows() + } +} + +func BenchmarkCursorMove(b *testing.B) { + m := fleetModel(b, 5600) + b.ResetTimer() + for i := 0; b.Loop(); i++ { + m.moveCursorTo(i % 200) + } +} + +// benchModel is pickModel's model without the *testing.T. +func benchModel(b *testing.B) tuiModel { + b.Helper() + root := b.TempDir() + st := config.OpenAt(root, config.NewFileSecrets(root+"/secrets.json")) + cfg := &config.Config{Tenants: []config.Tenant{{ + Name: "Acme Co", TSGID: "1", Users: []config.IONUser{{Name: "u", Default: true}}, + }}} + m := newTUIModel(context.Background(), st, cfg) + m.tenant, m.hasTenant = cfg.Tenants[0], true + return m +} diff --git a/internal/tui/clipboard.go b/internal/tui/clipboard.go new file mode 100644 index 0000000..ab30060 --- /dev/null +++ b/internal/tui/clipboard.go @@ -0,0 +1,162 @@ +package tui + +import ( + "fmt" + "strings" + + tea "charm.land/bubbletea/v2" + "tyrtop.com/iontk/internal/output" + "tyrtop.com/iontk/internal/ui" +) + +// terminals cap OSC 52 and truncate in silence; cutting here can say so +const clipLimit = 100 << 10 + +const copyKeyHint = "alt+y copy" + +// OSC 52 never answers, so the footer has to admit that silence means +// the terminal refused it. +func (m tuiModel) copyText(what, text string) (tuiModel, tea.Cmd) { + text = strings.TrimRight(text, "\n") + if strings.TrimSpace(text) == "" { + m.status = "nothing to copy here" + return m, nil + } + + sent, dropped := clipTail(text) + if dropped > 0 { + m.status = fmt.Sprintf("copied the last %s of %s — %s would not fit", + size(len(sent)), what, size(dropped)) + } else { + m.status = fmt.Sprintf("copied %s, %s", what, size(len(sent))) + } + m.status += " · nothing arrived? your terminal refused OSC 52" + return m, tea.SetClipboard(sent) +} + +// the newest output is the part being copied, and half a line is noise +func clipTail(text string) (string, int) { + if len(text) <= clipLimit { + return text, 0 + } + cut := text[len(text)-clipLimit:] + if i := strings.IndexByte(cut, '\n'); i >= 0 && i+1 < len(cut) { + cut = cut[i+1:] + } + // a byte cut can land inside a rune + cut = strings.ToValidUTF8(cut, "") + return cut, len(text) - len(cut) +} + +func size(n int) string { + if n < 1024 { + return fmt.Sprintf("%d bytes", n) + } + return fmt.Sprintf("%.1f kB", float64(n)/1024) +} + +// ids are what gets pasted into --element; a ticked selection wins +func (m tuiModel) copyElements() (tea.Model, tea.Cmd) { + rows, ids := m.selectedRows() + if len(ids) > 0 { + what := fmt.Sprintf("%d element ids", len(rows)) + // one per line, because that is what -f reads + return m.copyText(what, strings.Join(ids, "\n")) + } + el, ok := m.current() + if !ok { + m.status = "nothing to copy here" + return m, nil + } + return m.copyText(el.label()+"'s id", el.ID) +} + +// the whole buffer, not the window; a dead tab copies too +func (m tuiModel) copySession() (tea.Model, tea.Cmd) { + s := m.sessions.Active() + if s == nil { + m.status = "nothing to copy here" + return m, nil + } + return m.copyText(s.Name+" scrollback", s.Buffer()) +} + +// the page as shown: the pane has a scroll offset and no cursor +func (m tuiModel) copyDataPage() (tea.Model, tea.Cmd) { + tab, ok := m.activeData() + if !ok { + m.status = "nothing to copy here" + return m, nil + } + // what a filter has narrowed it to, not the whole page + page := m.visiblePage(tab) + lines := make([]string, 0, len(page.lines)+1) + lines = append(lines, tab.name) + for _, ln := range page.lines { + lines = append(lines, strings.TrimRight(ui.Plain(ln), " ")) + } + return m.copyText(tab.name+" data", strings.Join(lines, "\n")) +} + +// the open result, or the whole run when nothing is open +func (m tuiModel) copyResults() (tea.Model, tea.Cmd) { + idx, ok := m.currentResult() + if ok && m.resOpen { + r := m.results[idx] + name := r.Name + if name == "" { + name = r.ElementID + } + lines := make([]string, 0, len(m.resultDetail(idx))) + for _, ln := range m.resultDetail(idx) { + lines = append(lines, strings.TrimRight(ui.Plain(ln), " ")) + } + return m.copyText(name+" output", strings.Join(lines, "\n")) + } + return m.copyRunJSON() +} + +// half a JSON document parses as nothing, so an oversize run is refused +func (m tuiModel) copyRunJSON() (tea.Model, tea.Cmd) { + var b strings.Builder + sink := output.NewSink(&b, output.FormatJSON, output.WithOrientation(m.batchOrient())) + for i, r := range m.results { + if r.ElementID == "" { + continue + } + if err := sink.Add(i, r); err != nil { + m.status = "copy failed: " + err.Error() + return m, nil + } + } + if err := sink.Close(); err != nil { + m.status = "copy failed: " + err.Error() + return m, nil + } + if b.Len() > clipLimit { + m.status = fmt.Sprintf("the run is %s, too big for a clipboard — s saves it to a file", size(b.Len())) + return m, nil + } + return m.copyText(fmt.Sprintf("%d results as json", m.completedCount()), b.String()) +} + +// one per line, so one macro can be pasted into another +func (m tuiModel) copyMacro() (tea.Model, tea.Cmd) { + mac, ok := m.macroAt(m.macroCursor) + if !ok { + m.status = "nothing to copy here" + return m, nil + } + return m.copyText(mac.Name, strings.Join(mac.Commands, "\n")) +} + +// a pasted block is a list, like app.SplitCommands with commas +func pasteCommands(text string) []string { + var out []string + for _, line := range strings.FieldsFunc(text, func(r rune) bool { return r == '\n' || r == '\r' }) { + if line = strings.TrimSpace(line); line != "" { + out = append(out, line) + } + } + return out +} diff --git a/internal/tui/clipboard_test.go b/internal/tui/clipboard_test.go new file mode 100644 index 0000000..ddbd17b --- /dev/null +++ b/internal/tui/clipboard_test.go @@ -0,0 +1,274 @@ +package tui + +import ( + "encoding/json" + "fmt" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "tyrtop.com/iontk/internal/app" + "tyrtop.com/iontk/internal/config" +) + +// clipboardOf reads the OSC 52 payload back out of the command a copy +// returned. Bubble Tea's clipboard message is its own unexported type, so the +// text comes back through fmt rather than a type assertion. +func clipboardOf(t *testing.T, cmd tea.Cmd) string { + t.Helper() + if cmd == nil { + t.Fatal("the copy issued no command, so nothing reached the clipboard") + } + return fmt.Sprint(cmd()) +} + +// The id is the thing people paste into --element, so that is what the list +// copies. +func TestCopyingFromTheElementListTakesTheId(t *testing.T) { + m := pickModel(t) + + mm, cmd := m.Update(tea.KeyPressMsg(altKey('y'))) + m = mm.(tuiModel) + if got := clipboardOf(t, cmd); got != "1" { + t.Errorf("clipboard = %q, want the highlighted element's id", got) + } + if !strings.Contains(m.status, "copied") { + t.Errorf("status = %q, want it to say what was copied", m.status) + } + // OSC 52 is never acknowledged, so the report has to admit that silence + // means the terminal refused it. + if !strings.Contains(m.status, "OSC 52") { + t.Errorf("status = %q, want it to say a copy can be refused", m.status) + } +} + +// A ticked selection is a list someone built on purpose, and one id per line +// is the shape --elements-path reads. +func TestCopyingAselectionTakesEveryIdOnePerLine(t *testing.T) { + m := pickModel(t) + m.selected = map[string]bool{"1/1": true, "1/3": true} + m.rebuildRows() + + mm, cmd := m.Update(tea.KeyPressMsg(altKey('y'))) + m = mm.(tuiModel) + got := clipboardOf(t, cmd) + if got != "1\n3" { + t.Errorf("clipboard = %q, want both ids one per line", got) + } + if !strings.Contains(m.status, "2 element ids") { + t.Errorf("status = %q, want it to count what was copied", m.status) + } +} + +func TestCopyingAnOpenResultTakesItsOutput(t *testing.T) { + m := resultsModel(t) + m.resOpen = true + + mm, cmd := m.Update(tea.KeyPressMsg(altKey('y'))) + m = mm.(tuiModel) + got := clipboardOf(t, cmd) + if !strings.Contains(got, "dump device status") || !strings.Contains(got, "up 5 days") { + t.Errorf("clipboard = %q, want the command and its output", got) + } + if strings.Contains(got, "\x1b[") { + t.Errorf("clipboard = %q, want the styling stripped", got) + } +} + +// With nothing open the page is showing the run, so that is what it copies -- +// in the format a saved file would have had. +func TestCopyingTheResultsPageTakesTheRunAsJSON(t *testing.T) { + m := resultsModel(t) + m.resOpen = false + + mm, cmd := m.Update(tea.KeyPressMsg(altKey('y'))) + m = mm.(tuiModel) + got := clipboardOf(t, cmd) + + var rows []map[string]any + if err := json.Unmarshal([]byte(got), &rows); err != nil { + t.Fatalf("clipboard is not JSON: %v\n%s", err, got) + } + if len(rows) != m.completedCount() { + t.Errorf("clipboard has %d results, want the %d completed", len(rows), m.completedCount()) + } + if !strings.Contains(m.status, "json") { + t.Errorf("status = %q, want it to say the run went as json", m.status) + } +} + +func TestCopyingAMacroTakesItsCommands(t *testing.T) { + m := macroModel(t) + m.macroCursor = 1 + + mm, cmd := m.Update(tea.KeyPressMsg(altKey('y'))) + m = mm.(tuiModel) + if got := clipboardOf(t, cmd); got != "dump interface status all\ndump vpn count" { + t.Errorf("clipboard = %q, want one command per line", got) + } +} + +func TestCopyingWithNothingToCopySaysSo(t *testing.T) { + m := macroModel(t) + m.macroCursor = 0 // the "+ new macro" row, which is an action not a macro + + mm, cmd := m.Update(tea.KeyPressMsg(altKey('y'))) + m = mm.(tuiModel) + if cmd != nil { + t.Error("something was sent to the clipboard from the new-macro row") + } + if m.status == "" { + t.Error("nothing said why the copy did nothing") + } +} + +// A payload past the cap is cut at a line boundary, keeps the end, and says +// so: terminals and tmux cap OSC 52 and the far end truncates silently. +func TestAnOversizeCopyKeepsTheTailAndSaysItWasCut(t *testing.T) { + m := pickModel(t) + var b strings.Builder + for b.Len() < clipLimit*2 { + fmt.Fprintf(&b, "line %06d\n", b.Len()) + } + full := b.String() + + nm, cmd := m.copyText("scrollback", full) + got := clipboardOf(t, cmd) + if len(got) > clipLimit { + t.Errorf("clipboard is %d bytes, want it capped at %d", len(got), clipLimit) + } + if !strings.HasSuffix(strings.TrimRight(full, "\n"), got) { + t.Error("the copy kept the start rather than the newest output") + } + if strings.HasPrefix(got, "line") == false { + t.Errorf("the copy starts mid-line: %q", got[:20]) + } + if !strings.Contains(nm.status, "would not fit") { + t.Errorf("status = %q, want it to admit the copy was cut", nm.status) + } +} + +// A run too big to paste is pointed at the thing that can hold it: half a JSON +// document parses as nothing. +func TestARunTooBigForTheClipboardOffersTheFileInstead(t *testing.T) { + m := resultsModel(t) + big := strings.Repeat("x", clipLimit) + m.results[0].Commands[0].Output = big + + nm, cmd := m.copyRunJSON() + if cmd != nil { + t.Error("an oversize run was sent to the clipboard anyway") + } + if !strings.Contains(nm.(tuiModel).status, "saves it to a file") { + t.Errorf("status = %q, want it to offer the save", nm.(tuiModel).status) + } +} + +// A shell copies what it has said rather than the window: the output that +// scrolled away is most of the reason to copy one at all. +func TestCopyingAShellTakesItsScrollback(t *testing.T) { + m, conn := favModel(t) + conn.feed("S0100-RTR-01# dump device status\r\nSerial: 1234\r\n") + + s := m.sessions.Active() + waitForTUI(t, func() bool { return strings.Contains(s.Buffer(), "Serial: 1234") }) + + m, cmd := pressSessionKey(t, m, altKey('y')) + got := clipboardOf(t, cmd) + if !strings.Contains(got, "dump device status") || !strings.Contains(got, "Serial: 1234") { + t.Errorf("clipboard = %q, want what the device printed", got) + } + if strings.Contains(got, "\x1b[") { + t.Errorf("clipboard = %q, want the escape codes stripped", got) + } + if !strings.Contains(m.status, "scrollback") { + t.Errorf("status = %q, want it to name what was copied", m.status) + } + // Nothing was typed at the device: the key is iontk's, not the shell's. + if strings.Contains(conn.sent(), "y") { + t.Errorf("alt+y reached the device: %q", conn.sent()) + } +} + +// The batch box is comma separated, so a pasted block of commands is a list. +// textinput would otherwise flatten the newlines into spaces and make one +// long command of the lot. +func TestPastingCommandsIntoTheBatchBoxSplitsThem(t *testing.T) { + m := pickModel(t) + m.phase = phaseCommand + // textinput ignores input unless something focused it, which entering the + // command page does. + m.cmdInput.Focus() + + mm, _ := m.Update(tea.PasteMsg{Content: "dump device status\ndump interface status all\n\n uptime \n"}) + m = mm.(tuiModel) + + got := app.SplitCommands([]string{m.cmdInput.Value()}) + want := []string{"dump device status", "dump interface status all", "uptime"} + if len(got) != len(want) { + t.Fatalf("commands = %q, want %q", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("command %d = %q, want %q", i, got[i], want[i]) + } + } +} + +// The macro form is already one field per command, so a pasted block fills a +// line each and leaves the empty line the ghost row points at. +func TestPastingCommandsIntoTheMacroFormFillsALinePerCommand(t *testing.T) { + m := macroModel(t) + next, _ := m.openMacroForm(config.Macro{}) + m = next.(tuiModel) + m.formCursor = 1 + + mm, _ := m.Update(tea.PasteMsg{Content: "one\ntwo\nthree"}) + m = mm.(tuiModel) + + if got := m.macroFormCommands(); len(got) != 3 { + t.Fatalf("commands = %q, want three", got) + } + if m.formValue(len(m.form)-1) != "" { + t.Error("the form has no empty line left to type the next command on") + } + if m.formCursor != len(m.form)-1 { + t.Errorf("formCursor = %d, want the empty line at the bottom", m.formCursor) + } + // The labels are the run order, and a paste must not leave them wrong. + for i := 1; i < len(m.form); i++ { + if want := macroCommandLabel(i - 1); m.form[i].label != want { + t.Errorf("field %d is labelled %q, want %q", i, m.form[i].label, want) + } + } +} + +// A data pane copies the whole page, not the window: the page is what the +// refetch built and what someone wants in a ticket. +func TestCopyingADataPaneTakesTheWholePagePlain(t *testing.T) { + m, set := fixtureTUIWith(t, haFixtureYAML) + m.phase = phasePicking + m.mode = modeData + m.rebuildRows() + + el := set.Tenants[0].Elements[0] + next, cmd := m.openDataTabs([]sessionTarget{{eid: el.ID, name: el.Name, tsg: m.tenant.TSGID}}) + m = runDataLoads(t, next.(tuiModel), cmd) + + mm, cmd := m.Update(tea.KeyPressMsg(altKey('y'))) + m = mm.(tuiModel) + got := clipboardOf(t, cmd) + + for _, want := range []string{el.Name, "Overview", "Interfaces", "Circuits"} { + if !strings.Contains(got, want) { + t.Errorf("clipboard is missing %q:\n%s", want, got) + } + } + if strings.Contains(got, "\x1b[") { + t.Error("the copied page carries escape codes") + } + // The window is 24 rows; the page is longer than that. + if n := strings.Count(got, "\n"); n < 24 { + t.Errorf("the copy has %d lines, want the whole page rather than the window", n) + } +} diff --git a/internal/tui/connecting.go b/internal/tui/connecting.go index 59a6c16..d862121 100644 --- a/internal/tui/connecting.go +++ b/internal/tui/connecting.go @@ -5,6 +5,7 @@ import ( "strings" tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" "tyrtop.com/iontk/internal/sase/api" "tyrtop.com/iontk/internal/ui" @@ -206,29 +207,38 @@ func (m tuiModel) onRetryKey(key string) (tea.Model, tea.Cmd) { // rowStatus is the no-header column beside each element: what this element is // doing, and whether it already has a tab. -func (m tuiModel) rowStatus(el elementRow) string { +func (m *tuiModel) rowStatus(el elementRow) string { + glyph, style := m.rowStatusOf(el) + return style.Render(glyph) +} + +// rowStatusOf is the glyph and how to paint it, kept apart for the same reason +// haState is: the list builds this for every matched row and paints only the +// handful it shows. +func (m *tuiModel) rowStatusOf(el elementRow) (string, lipgloss.Style) { if st, ok := m.dialing[el.ID]; ok { if st.failed { - return ui.StyleErr.Render(ui.MarkCross.String()) + return ui.MarkCross.String(), ui.StyleErr } - return m.spinner.View() + // The spinner is already a styled frame of its own. + return m.spinner.View(), lipgloss.NewStyle() } s := m.sessions.Find(el.ID) if s == nil { for _, d := range m.sessions.Detached() { if d.ElementID == el.ID { - return ui.StyleDim.Render("○") + return "○", ui.StyleDim } } - return " " + return " ", lipgloss.NewStyle() } switch { case s.Err() != nil: - return ui.StyleErr.Render(ui.MarkCross.String()) + return ui.MarkCross.String(), ui.StyleErr case s.Hung(): - return ui.StyleWarn.Render("~") + return "~", ui.StyleWarn } - return ui.StyleOK.Render("●") + return "●", ui.StyleOK } // connectBar is the line under the tab bar. It keeps its row whatever the diff --git a/internal/tui/data.go b/internal/tui/data.go index 5c7b583..5d05ecc 100644 --- a/internal/tui/data.go +++ b/internal/tui/data.go @@ -6,6 +6,7 @@ import ( "strings" "time" + "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" "tyrtop.com/iontk/internal/config" "tyrtop.com/iontk/internal/dataview" @@ -298,20 +299,57 @@ func (m tuiModel) onDataKey(key string) (tea.Model, tea.Cmd) { switch key { case "esc", "q", "ctrl+]": + if m.dataFilterOn || m.dataFilter.Value() != "" { + m.dataFilterOn = false + m.dataFilter.SetValue("") + m.dataFilter.Blur() + return m, nil + } m.phase = phasePicking return m, nil + case "/": + if !m.dataFilterOn { + m.dataFilterOn = true + return m, m.dataFilter.Focus() + } + case "enter": + if m.dataFilterOn { + // Applied and left, like the element list: the page's own keys + // are letters, so the query has to be finished with. + m.dataFilterOn = false + m.dataFilter.Blur() + return m, nil + } case "r": - return m.refreshData() + if !m.dataFilterOn { + return m.refreshData() + } case "c": - return m.startCompare() + if !m.dataFilterOn { + return m.startCompare() + } + case "alt+y": + return m.copyDataPage() } tab, ok := m.activeData() if !ok { return m, nil } - page := m.dataPage(tab) + page := m.visiblePage(tab) _, rows := m.dataPaneSize() + + // Scrolling keys still work mid-query -- the arrows and the paging keys + // are not letters -- but anything that could be typed belongs to the + // input. + if m.dataFilterOn { + switch key { + case "down", "up", "pgdown", "pgup", "home", "end", "alt+down", "alt+up": + default: + return m, nil + } + } + switch key { case "down", "j": tab.scroll++ @@ -366,6 +404,62 @@ func clampScroll(at, total, rows int) int { return max(0, min(at, max(0, total-rows))) } +// visiblePage is the page as the pane shows it: the whole thing, or only the +// lines matching the query with enough around them to read -- the view heading +// above them and the table header, if they came out of a table. +// +// Narrowing rather than jumping to the next match, because that is what every +// other `/` in iontk does, and because "where is vlan1000" is answered better +// by one line than by a cursor somewhere in thirty. +func (m *tuiModel) visiblePage(tab dataTab) dataPage { + page := m.dataPage(tab) + q := strings.ToLower(strings.TrimSpace(m.dataFilter.Value())) + if q == "" { + return page + } + + // Which heading and which table header each line sits under. + head, hdr := make([]int, len(page.lines)), make([]int, len(page.lines)) + at, under := -1, -1 + headAt, hdrAt := 0, 0 + for i := range page.lines { + for headAt < len(page.heads) && page.heads[headAt] <= i { + at = page.heads[headAt] + headAt++ + } + for hdrAt < len(page.hdrs) && page.hdrs[hdrAt] <= i { + under = page.hdrs[hdrAt] + hdrAt++ + } + head[i], hdr[i] = at, under + } + + var out dataPage + kept := map[int]bool{} + add := func(i int) { + if i < 0 || kept[i] { + return + } + kept[i] = true + out.lines = append(out.lines, page.lines[i]) + } + for i, line := range page.lines { + if !strings.Contains(strings.ToLower(ui.Plain(line)), q) { + continue + } + if h := head[i]; h >= 0 && !kept[h] { + out.heads = append(out.heads, len(out.lines)) + add(h) + } + // The header only belongs with a row of the table it heads. + if d := hdr[i]; d >= 0 && d != i && d > head[i] { + add(d) + } + add(i) + } + return out +} + func (m tuiModel) dataPaneSize() (int, int) { return max(20, m.width), max(3, m.height-6-m.statsRows()) } @@ -376,6 +470,10 @@ func (m tuiModel) dataPaneSize() (int, int) { type dataPage struct { lines []string heads []int + // hdrs is where a table's header row is, so a filtered page can keep the + // one above a matching row: a port row with no PORT/TYPE/STATE line above + // it is a row of unlabelled columns. + hdrs []int } func (p dataPage) String() string { return strings.Join(p.lines, "\n") } @@ -414,7 +512,10 @@ func (m tuiModel) dataPage(tab dataTab) dataPage { } p.lines = append(p.lines, " "+ui.Label(row.Label)+" "+ui.Truncate(row.Value, width-ui.LabelWidth-4)) } - p.lines = append(p.lines, m.tableLines(sec, width)...) + if table := m.tableLines(sec, width); len(table) > 0 { + p.hdrs = append(p.hdrs, len(p.lines)) + p.lines = append(p.lines, table...) + } if sec.Truncated > 0 { p.lines = append(p.lines, " "+ui.StyleDim.Render(fmt.Sprintf("… %d more not shown", sec.Truncated))) } @@ -518,7 +619,19 @@ func (m tuiModel) viewData() string { b.WriteString(ui.StyleElement.Render(where) + ui.StyleDim.Render(fmt.Sprintf(" %d views%s", len(tab.panes), state)) + "\n") - page := m.dataPage(tab) + if m.dataFilterOn || m.dataFilter.Value() != "" { + line := m.dataFilter.View() + if m.dataFilter.Value() != "" { + whole := len(m.dataPage(tab).lines) + shown := len(m.visiblePage(tab).lines) + line += ui.StyleDim.Render(fmt.Sprintf(" %d of %d lines", shown, whole)) + } + b.WriteString(line + "\n") + } else { + b.WriteString(ui.StyleDim.Render("press / to filter") + "\n") + } + + page := m.visiblePage(tab) lines := page.lines _, rows := m.dataPaneSize() at := clampScroll(tab.scroll, len(lines), rows) @@ -543,8 +656,49 @@ func (m tuiModel) viewData() string { } status = scrolled + status } - hint := "alt+↑↓ next heading · ↑↓ scroll · r refresh · c CLI check · alt+←→/hl tab · alt+k close · ctrl+] elements" + hint := "alt+↑↓ next heading · ↑↓ scroll · / filter · r refresh · c CLI check · " + + copyKeyHint + " page · alt+←→/hl tab · alt+k close · ctrl+] elements" + if m.dataFilterOn { + hint = "type to narrow the page · ↑↓ scroll · enter apply · esc clear" + } width := max(10, m.width-1) b.WriteString(ui.Footer(ui.Truncate(status, width), ui.Truncate(hint, width))) return b.String() } + +func newDataFilter() textinput.Model { + in := textinput.New() + in.Prompt = "/" + in.Placeholder = "filter lines" + in.SetWidth(26) + return in +} + +// onDataFilterKey is the typing half of the data pane's filter. It reports +// whether it took the key: the page's own scrolling keys are not letters, so +// they go through to onDataKey. +func (m tuiModel) onDataFilterKey(msg tea.KeyPressMsg, key string) (tea.Model, tea.Cmd, bool) { + switch key { + case "esc", "enter", "down", "up", "pgdown", "pgup", "home", "end", + "alt+down", "alt+up", "alt+left", "alt+right", "alt+k", "alt+w", "alt+y": + return m, nil, false + case "backspace": + // On an already-empty query this is the press that closes the filter, + // the same two-step as every other one in iontk. + if m.dataFilter.Value() == "" { + m.dataFilterOn = false + m.dataFilter.Blur() + return m, nil, true + } + } + + var cmd tea.Cmd + m.dataFilter, cmd = m.dataFilter.Update(msg) + // The page shrank or grew under the scroll position. + if tab, ok := m.activeData(); ok { + _, rows := m.dataPaneSize() + tab.scroll = clampScroll(tab.scroll, len(m.visiblePage(tab).lines), rows) + m.data[m.dataActive] = tab + } + return m, cmd, true +} diff --git a/internal/tui/data_test.go b/internal/tui/data_test.go index f46551c..ef47083 100644 --- a/internal/tui/data_test.go +++ b/internal/tui/data_test.go @@ -492,7 +492,7 @@ func TestThePageStepSettingCycles(t *testing.T) { m.phase = phaseSettings m.setSection = secGeneral m.setFocus = focusContent - m.setCursor = 3 + m.setCursor = jumpRow first := m.pageStep() next, _ := m.onGeneralKey("enter") @@ -519,3 +519,131 @@ func TestThePageStepSettingCycles(t *testing.T) { t.Errorf("pageStep() = %d after a full cycle, want %d", got, first) } } + +// The security zone a port is in, joined through two fetches: the element's +// bindings hold the interface ids, and the tenant's zone list has the names. +// The join is the same one the circuit column uses. +func TestInterfacesShowTheSecurityZone(t *testing.T) { + m, set := fixtureTUIWith(t, haFixtureYAML) + m.phase = phasePicking + m.mode = modeData + m.rebuildRows() + + el := set.Tenants[0].Elements[0] + next, cmd := m.openDataTabs([]sessionTarget{{eid: el.ID, name: el.Name, tsg: m.tenant.TSGID}}) + m = runDataLoads(t, next.(tuiModel), cmd) + + pane := paneFor(t, m.data[0], "Interfaces") + if pane.err != "" { + t.Fatalf("the Interfaces view failed: %s", pane.err) + } + // The zone fetches must not have failed either: a broken one shows as a + // warn line rather than taking the view down. + for _, e := range pane.rendered.Errs { + t.Errorf("a fetch failed: %s", e) + } + + var headers []string + for _, sec := range pane.rendered.Sections { + if len(sec.Headers) > 0 { + headers = sec.Headers + } + } + var zoneAt = -1 + for i, h := range headers { + if h == "ZONE" { + zoneAt = i + } + } + if zoneAt < 0 { + t.Fatalf("no ZONE column in %v", headers) + } + + // The zone hangs off the vlan interface, which is where a real element + // carries it: in interface.har every vlan row has a zone and every port, + // switch_port and bypasspair row has none. + vlan := tableRow(t, pane, "vlan1") + if got := vlan[zoneAt]; got != "Store - LAN" { + t.Errorf("vlan1 zone = %q, want the zone name", got) + } + for _, port := range []string{"1", "3"} { + if got := tableRow(t, pane, port)[zoneAt]; got != "" { + t.Errorf("port %s zone = %q, want nothing: zones are on the vlans", port, got) + } + } +} + +// The data page is long -- four views, a 27-row interface table on a real +// element -- so `/` narrows it to the lines that match, keeping each line's +// view heading and its table header so a row is still readable. +func TestTheDataPageCanBeFiltered(t *testing.T) { + m, set := fixtureTUIWith(t, haFixtureYAML) + m.phase, m.mode = phasePicking, modeData + m.width, m.height = 120, 30 + m.rebuildRows() + + el := set.Tenants[0].Elements[0] + next, cmd := m.openDataTabs([]sessionTarget{{eid: el.ID, name: el.Name, tsg: m.tenant.TSGID}}) + m = runDataLoads(t, next.(tuiModel), cmd) + + whole := len(m.dataPage(m.data[0]).lines) + if whole < 20 { + t.Fatalf("the page is %d lines, want a long one to filter", whole) + } + + // / opens it and typing narrows the page. + mm, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: '/', Text: "/"})) + m = mm.(tuiModel) + if !m.dataFilterOn { + t.Fatal("/ did not open the data filter") + } + for _, r := range "vlan1" { + mm, _ = m.Update(tea.KeyPressMsg(tea.Key{Code: r, Text: string(r)})) + m = mm.(tuiModel) + } + if got := m.dataFilter.Value(); got != "vlan1" { + t.Fatalf("query = %q, want the typing to reach the input", got) + } + + page := m.visiblePage(m.data[0]) + if len(page.lines) >= whole { + t.Errorf("%d lines of %d after filtering, want fewer", len(page.lines), whole) + } + text := plain(page.String()) + if !strings.Contains(text, "vlan1") { + t.Errorf("the filtered page lost its match:\n%s", text) + } + // The view heading and the table's header come with it, or the row is a + // line of unlabelled columns. + for _, want := range []string{"Interfaces", "PORT"} { + if !strings.Contains(text, want) { + t.Errorf("the filtered page is missing %q:\n%s", want, text) + } + } + // And nothing from the other views is left. + if strings.Contains(text, "Rebooted") { + t.Errorf("the filtered page kept lines that do not match:\n%s", text) + } + + // The pane says how much matched. + if got := plain(m.viewData()); !strings.Contains(got, "of") || !strings.Contains(got, "lines") { + t.Errorf("the pane does not say how much matched:\n%s", got) + } + + // Backspacing to nothing keeps the filter open; one more closes it. + for range "vlan1" { + mm, _ = m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyBackspace})) + m = mm.(tuiModel) + } + if !m.dataFilterOn { + t.Error("the filter closed on the press that emptied the query") + } + mm, _ = m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyBackspace})) + if mm.(tuiModel).dataFilterOn { + t.Error("the filter is still open after backspacing the empty query") + } + // The whole page is back. + if got := len(m.visiblePage(m.data[0]).lines); got != whole { + t.Errorf("%d lines with an empty query, want the whole page of %d", got, whole) + } +} diff --git a/internal/tui/favourites.go b/internal/tui/favourites.go index 4a2d929..9412f3c 100644 --- a/internal/tui/favourites.go +++ b/internal/tui/favourites.go @@ -78,6 +78,13 @@ func (m tuiModel) onFavouriteKey(key string) (tea.Model, tea.Cmd) { return m.clearFavourite(m.favCursor) case "a": return m.openFavouriteForm(m.favCursor) + case "alt+y": + f, ok := m.cmds.Favourite(m.favCursor) + if !ok { + m.status = fmt.Sprintf("alt+%d is empty", m.favCursor) + return m, nil + } + return m.copyText(fmt.Sprintf("alt+%d", m.favCursor), f.Command) } // A digit picks the slot and saves in one keypress, which is the whole // point of a slot picker. @@ -191,7 +198,7 @@ func (m tuiModel) overlayFavourites(content string) string { lines = append(lines, row+cmd+" "+tag) } lines = append(lines, "", - ui.StyleDim.Render("1-9/enter saves this line · a by hand · ctrl+d clears · esc close")) + ui.StyleDim.Render("1-9/enter saves this line · a by hand · "+copyKeyHint+" · ctrl+d clears · esc close")) modal := ui.Modal(lines, m.width) keep := max(0, m.height-ui.ModalHeight(len(lines))-1) diff --git a/internal/tui/filter_test.go b/internal/tui/filter_test.go index 1ebb06c..1874cec 100644 --- a/internal/tui/filter_test.go +++ b/internal/tui/filter_test.go @@ -193,11 +193,11 @@ func TestGeneralSectionRowsAreAllReachable(t *testing.T) { m.width, m.height = 110, 30 m.spinner = ui.NewSpinner("") - if got := m.sectionRows(); got != 5 { - t.Fatalf("sectionRows() = %d, want 5", got) + if got := m.sectionRows(); got != generalRows { + t.Fatalf("sectionRows() = %d, want %d", got, generalRows) } out := plain(m.viewSettings()) - for _, want := range []string{"spinner", "commands", "mode", "jump", "elements", "views"} { + for _, want := range []string{"commands", "mode", "jump", "times", "elements", "views"} { if !strings.Contains(out, want) { t.Errorf("the General section is missing %q:\n%s", want, out) } @@ -758,9 +758,11 @@ func TestArrowsStillMoveTheListWhileSearching(t *testing.T) { } } -// Deleting the last character of a query leaves the filter. An empty query -// with the cursor still in it is a list where j and k type letters. -func TestDeletingTheQueryLeavesTheFilter(t *testing.T) { +// Backspacing a query down to nothing leaves the filter **open**, so typing +// can carry straight on; it is the next backspace -- the one that deletes the +// "/" itself -- that goes back to the list. Leaving on the press that emptied +// the query was one too early. +func TestBackspacingTheQueryEmptiesItThenLeaves(t *testing.T) { m := pickModel(t) m.phase = phasePicking @@ -770,20 +772,40 @@ func TestDeletingTheQueryLeavesTheFilter(t *testing.T) { t.Fatal("/ did not open the filter") } + backspace := func(m tuiModel) tuiModel { + t.Helper() + next, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyBackspace})) + return next.(tuiModel) + } + for range "s0" { - next, _ = m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyBackspace})) - m = next.(tuiModel) + m = backspace(m) } - if m.filterOn { - t.Error("the filter is still open with an empty query") + // Empty, still in the filter, whole list back. + if !m.filterOn { + t.Error("the filter closed on the press that emptied the query") } if m.filter.Value() != "" { - t.Errorf("query = %q, want it cleared", m.filter.Value()) + t.Errorf("query = %q, want it empty", m.filter.Value()) } - // The whole list is back, and the keys move the cursor again. if len(m.matches) != len(m.all) { t.Errorf("%d matches, want the whole list of %d", len(m.matches), len(m.all)) } + + // Typing carries on from there rather than needing / again. + m = typeString(t, m, "s9") + if m.filter.Value() != "s9" { + t.Errorf("query = %q, want typing to continue in the filter", m.filter.Value()) + } + for range "s9" { + m = backspace(m) + } + + // One more deletes the "/" and hands the keys back to the list. + m = backspace(m) + if m.filterOn { + t.Error("the filter is still open after backspacing the empty query") + } next, _ = m.Update(tea.KeyPressMsg(tea.Key{Code: 'j', Text: "j"})) if got := next.(tuiModel).table.Cursor(); got != 1 { t.Errorf("cursor = %d after j, want the key to move the list again", got) @@ -791,7 +813,7 @@ func TestDeletingTheQueryLeavesTheFilter(t *testing.T) { } // The same rule on the macro page, which has a filter of its own. -func TestDeletingTheMacroQueryLeavesTheFilter(t *testing.T) { +func TestBackspacingTheMacroQueryEmptiesItThenLeaves(t *testing.T) { m := macroModel(t) next, _ := m.onMacroKey(tea.KeyPressMsg(tea.Key{Code: '/', Text: "/"}), "/") @@ -804,12 +826,18 @@ func TestDeletingTheMacroQueryLeavesTheFilter(t *testing.T) { next, _ = m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyBackspace})) m = next.(tuiModel) } - if m.macroFilterOn { - t.Error("the macro filter is still open with an empty query") + if !m.macroFilterOn { + t.Error("the macro filter closed on the press that emptied the query") } if len(m.macroRows()) != 2 { t.Errorf("%d rows, want every macro back", len(m.macroRows())) } + + // The press that deletes the "/" is the one that leaves. + next, _ = m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyBackspace})) + if next.(tuiModel).macroFilterOn { + t.Error("the macro filter is still open after backspacing the empty query") + } } // The widget's own MoveUp scrolls its viewport away from the cursor, so a held @@ -886,3 +914,137 @@ func TestAltHAndAltLSwitchTabs(t *testing.T) { t.Errorf("phase = %v after alt+h, want back on the list", got) } } + +// The serial is on the row and matched by the filter: a column you can read is +// one people will type into, and a serial is quoted off a label or a case. +func TestTheSerialIsOnTheRowAndSearchable(t *testing.T) { + m := multiModel(t) + m.mergeTenantElements("111", "Tractor Supply", []api.Element{ + {ID: "e1", Name: "s5024-rtr-01", Model: "ion1200", Serial: "ION1200ABC123", Connected: true}, + {ID: "e2", Name: "s0500-rtr-01", Model: "ion1200", Serial: "ION1200XYZ789"}, + }) + m.phase = phasePicking + m.width, m.height = 160, 30 + m.rebuildRows() + + if !m.showSerialColumn() { + t.Fatal("the sn column is hidden on a list whose records carry serials") + } + out := plain(m.viewPicking()) + for _, want := range []string{"sn", "ION1200ABC123", "ION1200XYZ789"} { + if !strings.Contains(out, want) { + t.Errorf("the list is missing %q:\n%s", want, out) + } + } + + m.filter.SetValue("ION1200XYZ789") + m.applyFilter() + if len(m.matches) != 1 { + t.Fatalf("%d matches for a serial, want the one element", len(m.matches)) + } + if got := m.all[m.matches[0]].Name; got != "s0500-rtr-01" { + t.Errorf("matched %s, want the element with that serial", got) + } +} + +// A connected element reads green across the line, an offline one keeps red on +// the state alone, and the row under the cursor gives both up: the table paints +// its highlight around the whole row, and a highlight wrapped around coloured +// cells ends at the first reset inside them. +func TestConnectedRowsAreGreenAndOfflineKeepsRed(t *testing.T) { + m := multiModel(t) + m.mergeTenantElements("111", "Tractor Supply", []api.Element{ + // Sorted by name, so the offline one is row 0 and starts under the + // cursor. + {ID: "e2", Name: "s0500-rtr-01", Model: "ion1200"}, + {ID: "e1", Name: "s5024-rtr-01", Model: "ion1200", Connected: true}, + }) + m.phase = phasePicking + m.width, m.height = 140, 30 + m.rebuildRows() + + // Cursor on the offline row: the connected one is green, name and all. + out := m.viewPicking() + if !strings.Contains(out, ui.StyleOK.Render("s5024-rtr-01")) { + t.Errorf("a connected element is not green across the line:\n%q", out) + } + if !strings.Contains(out, ui.StyleOK.Render("connected")) { + t.Errorf("the state of a connected element is not green:\n%q", out) + } + if strings.Contains(plain(out), "offline") && strings.Contains(out, ui.StyleErr.Render("offline")) { + t.Error("the row under the cursor kept its own colour, which breaks the highlight") + } + + // Move off it: now offline is red and the connected row is the plain one. + m.moveCursorTo(1) + out = m.viewPicking() + if !strings.Contains(out, ui.StyleErr.Render("offline")) { + t.Errorf("offline is not red once the cursor is elsewhere:\n%q", out) + } + if strings.Contains(out, ui.StyleOK.Render("s5024-rtr-01")) { + t.Error("the row under the cursor is still green, which breaks the highlight") + } + // Connected is never red, wherever the cursor is. + if strings.Contains(out, ui.StyleErr.Render("connected")) { + t.Error("connected is rendered in the error colour") + } +} + +// Columns are sized to what is in them, which is why site stopped being wider +// than its names while a serial was cut off. +func TestColumnsAreSizedToTheirContent(t *testing.T) { + cols := []elementColumn{ + {title: "element", min: 12, max: 28, flex: true}, + {title: "site", min: 8, max: 22, flex: true}, + {title: "sn", min: 10, max: 24, flex: true}, + {title: "state", min: 9, max: 9}, + } + values := [][]string{ + {"store-0003-rtr-01", "Store 0003", "ION1200ABCDEF123456", "connected"}, + } + + wide := fitColumns(cols, values, 200) + if wide[1] != len("Store 0003") { + t.Errorf("site = %d, want the width of its widest name (%d)", wide[1], len("Store 0003")) + } + if wide[2] != len("ION1200ABCDEF123456") { + t.Errorf("sn = %d, want the whole serial (%d)", wide[2], len("ION1200ABCDEF123456")) + } + + // On a narrow terminal the flexible columns give way, widest first, and + // the fixed ones never do. + narrow := fitColumns(cols, values, 60) + total := 0 + for _, w := range narrow { + total += w + colGap + } + if total > 60 { + t.Errorf("columns total %d, want them shaved to 60", total) + } + if narrow[3] != 9 { + t.Errorf("state = %d, want a fixed column left alone", narrow[3]) + } + for i, c := range cols { + if narrow[i] < c.min { + t.Errorf("%s = %d, want never below its minimum %d", c.title, narrow[i], c.min) + } + } +} + +func TestRenderTheElementListWithSerials(t *testing.T) { + m := multiModel(t) + m.mergeTenantElements("111", "Tractor Supply", []api.Element{ + {ID: "e1", Name: "s5024-rtr-01", Model: "ion 1200", Serial: "ION1200ABC123", Connected: true, + SpokeHA: &api.SpokeHA{ClusterID: "c1"}, SiteID: "s1"}, + {ID: "e2", Name: "s5024-rtr-02", Model: "ion 1200", Serial: "ION1200DEF456", Connected: true, + SpokeHA: &api.SpokeHA{ClusterID: "c1"}, SiteID: "s1"}, + {ID: "e3", Name: "s0500-rtr-01", Model: "ion 3200", Serial: "ION3200XYZ789"}, + }) + m.phase = phasePicking + m.width, m.height = 170, 24 + m.ha = map[string]string{"111/e1": haActive} + m.haCached = map[string]string{"111/e2": haStandby} + m.haTotal, m.haDone = 2, 1 + m.rebuildRows() + t.Log("\n" + m.viewPicking()) +} diff --git a/internal/tui/ha.go b/internal/tui/ha.go index 8444525..7dc7cfb 100644 --- a/internal/tui/ha.go +++ b/internal/tui/ha.go @@ -2,74 +2,184 @@ package tui import ( "context" + "fmt" + "sort" + "strings" "sync" + "time" tea "charm.land/bubbletea/v2" + "tyrtop.com/iontk/internal/app" + "tyrtop.com/iontk/internal/config" "tyrtop.com/iontk/internal/sase/api" "tyrtop.com/iontk/internal/ui" ) -// HA state as the element list shows it. The portal has no "standby": a -// cluster's members read "active" on the one carrying traffic and "unknown" -// on anything the controller cannot see. So standby is derived -- a member -// that is not active, in a cluster that has an active member, is the standby -// by elimination -- and a cluster with nobody active keeps the API's own word, -// because that is a cluster in trouble rather than a quiet second node. +// The per-cluster endpoint says only "active" or "unknown", so standby is +// derived. No active member keeps "unknown": that cluster is in trouble. const ( haActive = "active" haStandby = "standby" haUnknown = "unknown" ) -// haClusters is the distinct (site, cluster) pairs among these elements. One -// request answers for every member of a cluster, so a pair costs one call and -// not two. -func haClusters(els []api.Element) [][2]string { +const haAnswered = "ok" + +// The aggregate names elements; the fallback needs a site in the path, so an +// element with no site id can only be read by the aggregate. +type haWork struct { + // one request answers for every member, so a pair is one call + pairs [][2]string + elements []string + noSite []string +} + +func (w haWork) clusters() int { return len(w.pairs) + len(w.noSite) } + +func haClusters(els []api.Element) haWork { + var w haWork seen := map[string]bool{} - var out [][2]string for _, el := range els { cid := el.ClusterID() - if cid == "" || el.SiteID == "" || seen[cid] { + if cid == "" { + continue + } + w.elements = append(w.elements, el.ID) + if seen[cid] { continue } seen[cid] = true - out = append(out, [2]string{el.SiteID, cid}) + if el.SiteID == "" { + w.noSite = append(w.noSite, cid) + continue + } + w.pairs = append(w.pairs, [2]string{el.SiteID, cid}) } - return out + return w } -// haFetchLimit bounds one refresh. A fleet of HA pairs is a request per pair, -// and the client's rate limit is five a second, so an unbounded sweep would -// still be filling the column minutes later. Past the limit the cells stay -// empty rather than wrong. -const haFetchLimit = 64 +// slower than the limiter allows, so a search can jump the queue +const haPace = 400 * time.Millisecond -// haFetchWorkers is how many run at once. The client rate-limits anyway; this -// only stops a thousand goroutines queueing behind it. -const haFetchWorkers = 4 +type haTickMsg struct{ tsg string } + +func haTick(tsg string) tea.Cmd { + return tea.Tick(haPace, func(time.Time) tea.Msg { return haTickMsg{tsg: tsg} }) +} + +// was a hard 64 with the rest dropped, so nothing past it ever resolved +const haBatch = 16 + +// the client rate-limits anyway; this keeps the limiter saturated +const haFetchWorkers = 8 type haStatesMsg struct { tsg string states map[string]string - // capped is set when there were more clusters than one refresh will read. - capped bool + // "" not tried, "used" answered, otherwise why it did not + bulk string + // an element the reply did not name is not one still waiting + answered []string + // cluster -> why, for the cells that cannot be filled + failed map[string]string + // what this pass did not reach; the aggregate's half asks by element + rest [][2]string + restElements []string +} + +// anything unexpected falls back, rather than showing a fleet with no HA +func loadHABulk(ctx context.Context, c *api.Client, tsg string, w haWork) tea.Cmd { + return loadHAChunk(ctx, c, tsg, w, w.elements) } -// loadHA reads each cluster's status and reduces it to one word per element. -// It runs after the list is on screen: the elements are what someone is -// looking for, and HA is what they want to know about the one they found. +func loadHAChunk(ctx context.Context, c *api.Client, tsg string, w haWork, ids []string) tea.Cmd { + if len(ids) == 0 { + return nil + } + // saying so beats a column stuck on "loading" + if c == nil { + return func() tea.Msg { + return haStatesMsg{tsg: tsg, bulk: "no client for this tenant", failed: allFailed(w, "tenant unreachable")} + } + } + + ask, rest := ids, []string(nil) + if len(ask) > api.ClusterQueryLimit { + ask, rest = ids[:api.ClusterQueryLimit], ids[api.ClusterQueryLimit:] + } + + return func() tea.Msg { + sts, err := c.SpokeClusterStatuses(ctx, ask) + if err != nil { + // clusters the fallback cannot reach either are marked, not left + msg := haStatesMsg{tsg: tsg, bulk: shortTenantErr(err), rest: w.pairs} + if len(w.noSite) > 0 { + msg.failed = map[string]string{} + for _, cid := range w.noSite { + msg.failed[cid] = "no site id in the element record" + } + } + return msg + } + + // this endpoint names the second node itself + states := make(map[string]string, len(sts)) + answered := make([]string, 0, len(sts)) + seen := map[string]bool{} + for _, st := range sts { + states[tsg+"/"+st.ElementID] = bulkWord(st.Status) + if st.ClusterID != "" && !seen[st.ClusterID] { + seen[st.ClusterID] = true + answered = append(answered, st.ClusterID) + } + } + return haStatesMsg{ + tsg: tsg, states: states, answered: answered, + bulk: "used", restElements: rest, + } + } +} + +// "backup" is the portal's own word; the per-cluster endpoint has none +func bulkWord(status string) string { + switch status { + case haActive: + return haActive + case "backup": + return haStandby + case "": + return haUnknown + } + return status +} + +func memberWord(mem api.ClusterMember, hasActive bool) string { + switch { + case mem.Status == haActive: + return haActive + case hasActive: + return haStandby + case mem.Status != "": + return mem.Status + } + return haUnknown +} + +// runs behind the list: the elements are what someone is looking for func loadHA(ctx context.Context, c *api.Client, tsg string, pairs [][2]string) tea.Cmd { if c == nil || len(pairs) == 0 { return nil } - capped := false - if len(pairs) > haFetchLimit { - pairs, capped = pairs[:haFetchLimit], true + var rest [][2]string + if len(pairs) > haBatch { + pairs, rest = pairs[:haBatch], pairs[haBatch:] } return func() tea.Msg { var mu sync.Mutex states := map[string]string{} + answered := make([]string, 0, len(pairs)) + failed := map[string]string{} var wg sync.WaitGroup work := make(chan [2]string) @@ -80,25 +190,21 @@ func loadHA(ctx context.Context, c *api.Client, tsg string, pairs [][2]string) t for p := range work { st, err := c.SpokeClusterStatus(ctx, p[0], p[1]) if err != nil { + // swallowing this left the column loading forever + mu.Lock() + failed[p[1]] = shortTenantErr(err) + mu.Unlock() continue } _, hasActive := st.Active() words := make(map[string]string, len(st.Members)) for _, mem := range st.Members { - switch { - case mem.Status == haActive: - words[mem.ElementID] = haActive - case hasActive: - words[mem.ElementID] = haStandby - case mem.Status != "": - words[mem.ElementID] = mem.Status - default: - words[mem.ElementID] = haUnknown - } + words[mem.ElementID] = memberWord(mem, hasActive) } mu.Lock() + answered = append(answered, p[1]) for eid, w := range words { states[tsg+"/"+eid] = w } @@ -112,7 +218,7 @@ func loadHA(ctx context.Context, c *api.Client, tsg string, pairs [][2]string) t close(work) wg.Wait() - return haStatesMsg{tsg: tsg, states: states, capped: capped} + return haStatesMsg{tsg: tsg, states: states, answered: answered, failed: failed, rest: rest} } } @@ -120,30 +226,319 @@ func (m tuiModel) onHAStates(msg haStatesMsg) (tea.Model, tea.Cmd) { if m.ha == nil { m.ha = map[string]string{} } + if m.haCluster == nil { + m.haCluster = map[string]string{} + } for k, v := range msg.states { m.ha[k] = v } - if msg.capped && m.status == "" { - m.status = "HA read for the first 64 clusters; alt+r for another pass" + if msg.bulk != "" { + m.haBulk = msg.bulk + } + delete(m.haInFlight, msg.tsg) + m.haDone += len(msg.answered) + len(msg.failed) + for _, cid := range msg.answered { + m.haCluster[msg.tsg+"/"+cid] = haAnswered + } + for cid, why := range msg.failed { + m.haCluster[msg.tsg+"/"+cid] = why + m.haFailed++ + if m.haErr == "" { + m.haErr = why + } + } + // once the sweep is done, so the count is the whole of it + if len(msg.rest) == 0 && m.haFailed > 0 && m.status == "" { + m.status = fmt.Sprintf("HA state unread for %d cluster(s): %s", m.haFailed, m.haErr) + } + // a fleet one at a time is minutes, so say so up front + if msg.bulk != "" && msg.bulk != "used" && m.status == "" { + m.status = fmt.Sprintf("HA one cluster at a time (%s): the tenant-wide query was refused — %s", + sweepETA(m.haTotal), msg.bulk) } m.rebuildRows() - return m, nil + + // queued, not chased: the rate budget stays free for the user + if len(msg.restElements) > 0 { + if m.haQueue == nil { + m.haQueue = map[string][]string{} + } + m.haQueue[msg.tsg] = msg.restElements + return m, tea.Batch(haTick(msg.tsg), saveHA(m.store, msg.tsg, msg.states)) + } + delete(m.haQueue, msg.tsg) + + if len(msg.rest) > 0 { + return m, loadHA(m.ctx, m.pool.get(msg.tsg), msg.tsg, m.nextPairs(msg.tsg, msg.rest)) + } + return m, saveHA(m.store, msg.tsg, msg.states) } -// haCell is the column. Blank for an element that is not in a cluster, which -// is most of a fleet: a dash on every standalone row would be noise. -func (m tuiModel) haCell(r elementRow) string { - if r.ClusterID() == "" { +func (m tuiModel) onHATick(tsg string) (tea.Model, tea.Cmd) { + left := m.haQueue[tsg] + if len(left) == 0 { + return m, nil + } + w := haWork{elements: m.visibleFirst(tsg, left)} + return m, loadHAChunk(m.ctx, m.pool.get(tsg), tsg, w, w.elements) +} + +// the ask that must not wait behind the fleet +func (m tuiModel) haWanted() map[string][]string { + out := map[string][]string{} + for _, pos := range m.visibleMatches() { + el := m.all[pos] + if el.ClusterID() == "" { + continue + } + if m.ha[el.key()] != "" || m.haCluster[el.TSG+"/"+el.ClusterID()] != "" { + continue + } + out[el.TSG] = append(out[el.TSG], el.ID) + } + return out +} + +func (m tuiModel) visibleMatches() []int { + if len(m.matches) == 0 { + return nil + } + h := max(4, m.listHeight()) + at := m.table.Cursor() + from, to := max(0, at-h), min(len(m.matches), at+h) + out := make([]int, 0, to-from) + for _, i := range m.matches[from:to] { + out = append(out, i) + } + return out +} + +// ahead of the paced fill; one per tenant, so a search cannot double up +func (m *tuiModel) askVisibleHA() tea.Cmd { + if m.haInFlight == nil { + m.haInFlight = map[string]bool{} + } + var cmds []tea.Cmd + for tsg, ids := range m.haWanted() { + if m.haInFlight[tsg] { + continue + } + m.haInFlight[tsg] = true + w := haWork{elements: ids} + if cmd := loadHAChunk(m.ctx, m.pool.get(tsg), tsg, w, ids); cmd != nil { + cmds = append(cmds, cmd) + } + } + if len(cmds) == 0 { + return nil + } + return tea.Batch(cmds...) +} + +// so the next launch opens with something while it reads again +func saveHA(st *config.Store, tsg string, states map[string]string) tea.Cmd { + if st == nil || len(states) == 0 { + return nil + } + // the cache keys on element id; the model scopes by tenant + bare := make(map[string]string, len(states)) + for k, w := range states { + bare[strings.TrimPrefix(k, tsg+"/")] = w + } + return func() tea.Msg { + _ = st.SaveHA(tsg, bare) + return nil + } +} + +// blank off a cluster: a dash on every standalone row is noise +func (m *tuiModel) haCell(r elementRow) string { + word, cached := m.haState(r) + if word == "" { return "" } - switch m.ha[r.key()] { + return haWord(word, cached) +} + +// unstyled: the list builds every row per keystroke, paints a few +func (m *tuiModel) haState(r elementRow) (string, bool) { + if r.ClusterID() == "" { + return "", false + } + if word := m.ha[r.key()]; word != "" { + return word, false + } + // the asterisk says last sweep; stale and labelled beats empty + if word := m.haCached[r.key()]; word != "" { + return word, true + } + + // three different nothings; "loading" has to mean only the first + switch { + case r.SiteID == "": + // the status path is per site, so this one cannot be asked + return "ha?", false + case m.haCluster[r.TSG+"/"+r.ClusterID()] == haAnswered: + // answered and did not name it: the API's word for unseen + return haUnknown, false + case m.haCluster[r.TSG+"/"+r.ClusterID()] != "": + return "ha?", false + } + return "loading", false +} + +// the asterisk is dim so the word still reads at a glance +func haWord(word string, cached bool) string { + var out string + switch word { + case "": + return "" + case "loading": + out = ui.StyleDim.Render(word) + case "ha?": + out = ui.StyleWarn.Render(word) case haActive: - return ui.StyleOK.Render(haActive) + // a connected row is already green + out = ui.StyleOK.Bold(true).Render(haActive) case haStandby: - return ui.StyleDim.Render(haStandby) + // dim against green read as the same colour + out = ui.StyleElement.Render(haStandby) case haUnknown: - return ui.StyleWarn.Render(haUnknown) + out = ui.StyleWarn.Render(haUnknown) + default: + out = ui.StyleDim.Render(word) + } + if cached { + out += ui.StyleDim.Render("*") + } + return out +} + +// counts the list, not the sweep: a refresh resets haTotal +func (m tuiModel) haPath() string { + clustered, clusters := 0, map[string]bool{} + for _, r := range m.all { + if cid := r.ClusterID(); cid != "" { + clustered++ + clusters[r.TSG+"/"+cid] = true + } + } + + switch { + case clustered == 0: + return "no clustered elements in the list" + case m.haTotal == 0: + return fmt.Sprintf("%d clustered elements in %d clusters — nothing asked yet", + clustered, len(clusters)) + case m.haBulk == "used": + return fmt.Sprintf("%d clusters in one tenant-wide query", m.haTotal) + case m.haBulk != "": + return fmt.Sprintf("%d/%d clusters one at a time (%s) — the query was refused: %s", + m.haDone, m.haTotal, sweepETA(m.haTotal-m.haDone), m.haBulk) + } + return fmt.Sprintf("%d/%d clusters", m.haDone, m.haTotal) +} + +// one request per cluster through the limiter +func sweepETA(left int) string { + if left <= 0 { + return "done" + } + rps := app.Defaults.RPS + if rps <= 0 { + rps = 1 + } + return ui.Age(time.Duration(float64(left)/rps*float64(time.Second))) + " to go" +} + +// on screen first, outside the filter last: the order is what feels fast +func (m tuiModel) nextPairs(tsg string, rest [][2]string) [][2]string { + if len(rest) < 2 { + return rest } - // In a cluster, but its status has not come back yet. - return ui.StyleDim.Render("ha") + + at := make(map[string]int, len(rest)) + for pos, i := range m.matches { + el := m.all[i] + if el.TSG != tsg { + continue + } + if cid := el.ClusterID(); cid != "" { + if _, seen := at[cid]; !seen { + at[cid] = pos + } + } + } + + cursor := m.table.Cursor() + rank := func(p [2]string) int { + pos, ok := at[p[1]] + if !ok { + return 1 << 30 + } + // the window is around the cursor, not at the top + if pos >= cursor { + return pos - cursor + } + return cursor - pos + } + + out := append([][2]string(nil), rest...) + sort.SliceStable(out, func(i, j int) bool { return rank(out[i]) < rank(out[j]) }) + return out +} + +func clusteredElements(els []api.Element) []string { + out := make([]string, 0, len(els)) + for _, el := range els { + if el.ClusterID() != "" { + out = append(out, el.ID) + } + } + return out +} + +func clusterIDs(pairs [][2]string) []string { + out := make([]string, 0, len(pairs)) + for _, p := range pairs { + out = append(out, p[1]) + } + return out +} + +func allFailed(w haWork, why string) map[string]string { + out := make(map[string]string, w.clusters()) + for _, cid := range append(clusterIDs(w.pairs), w.noSite...) { + out[cid] = why + } + return out +} + +// the portal asks about the 25 it shows, not the 5600 it has +func (m tuiModel) visibleFirst(tsg string, ids []string) []string { + if len(ids) < 2 { + return ids + } + at := make(map[string]int, len(m.matches)) + for pos, i := range m.matches { + if el := m.all[i]; el.TSG == tsg { + at[el.ID] = pos + } + } + + cursor := m.table.Cursor() + rank := func(id string) int { + pos, ok := at[id] + if !ok { + return 1 << 30 + } + if pos >= cursor { + return pos - cursor + } + return cursor - pos + } + + out := append([]string(nil), ids...) + sort.SliceStable(out, func(i, j int) bool { return rank(out[i]) < rank(out[j]) }) + return out } diff --git a/internal/tui/ha_test.go b/internal/tui/ha_test.go index 61fb538..04a3d5f 100644 --- a/internal/tui/ha_test.go +++ b/internal/tui/ha_test.go @@ -1,9 +1,12 @@ package tui import ( + "fmt" "strings" "testing" + tea "charm.land/bubbletea/v2" + "tyrtop.com/iontk/internal/sase/api" ) @@ -23,19 +26,19 @@ func TestHAStatesAreFilledIntoTheList(t *testing.T) { if got := plain(m.viewPicking()); !strings.Contains(got, "ha") { t.Errorf("the list has no ha column:\n%s", got) } - // Before the status lands, a clustered element says it is in a cluster - // and nothing more. + // Before the status lands, a clustered element says the state is coming + // rather than naming one. first := set.Tenants[0].Elements[0] - if got := plain(m.haCell(rowFor(t, m, first.ID))); got != "ha" { - t.Errorf("cell before the fetch = %q, want it to admit only membership", got) + if got := plain(m.haCell(rowFor(t, m, first.ID))); got != "loading" { + t.Errorf("cell before the fetch = %q, want it to say it is loading", got) } - pairs := haClusters(elementsOf(m)) - if len(pairs) != 1 { - t.Fatalf("%d clusters to read for a pair sharing one, want 1", len(pairs)) + w := haClusters(elementsOf(m)) + if w.clusters() != 1 { + t.Fatalf("%d clusters to read for a pair sharing one, want 1", w.clusters()) } - cmd := loadHA(t.Context(), m.pool.get(m.tenant.TSGID), m.tenant.TSGID, pairs) + cmd := loadHA(t.Context(), m.pool.get(m.tenant.TSGID), m.tenant.TSGID, w.pairs) if cmd == nil { t.Fatal("nothing queued for a tenant with a cluster") } @@ -86,8 +89,17 @@ func TestHAClustersAreCountedOncePerCluster(t *testing.T) { // In a cluster but with no site, so there is no path to ask about. {ID: "5", SpokeHA: &api.SpokeHA{ClusterID: "c3"}}, } - if got := haClusters(els); len(got) != 2 { - t.Errorf("haClusters() = %v, want one entry per cluster that can be asked about", got) + got := haClusters(els) + if len(got.pairs) != 2 { + t.Errorf("pairs = %v, want one per cluster that can be read one at a time", got.pairs) + } + // The one with no site can only ever be read by the aggregate, which asks + // by element, so it is kept apart rather than dropped. + if len(got.noSite) != 1 { + t.Errorf("noSite = %v, want the cluster with no site in it", got.noSite) + } + if len(got.elements) != 4 { + t.Errorf("elements = %v, want every clustered element for the aggregate", got.elements) } } @@ -109,3 +121,410 @@ func elementsOf(m tuiModel) []api.Element { } return out } + +// manyClusterFixture is a fleet of HA pairs, one cluster per site, with more +// clusters than a single pass reads. +func manyClusterFixture(sites int) string { + var b strings.Builder + b.WriteString("tenants:\n - name: Acme Co\n tsg_id: \"000000000\"\n users:\n - name: admin\n password: ion\n elements:\n") + for i := 1; i <= sites; i++ { + fmt.Fprintf(&b, " - name: store-%04d-rtr-01\n model: ion 1200\n site: Store %04d\n connected: true\n ha: true\n", i, i) + fmt.Fprintf(&b, " - name: store-%04d-rtr-02\n model: ion 1200\n site: Store %04d\n connected: true\n ha: true\n", i, i) + } + return b.String() +} + +// Every cluster resolves, however many there are. This is the bug that put +// "ha" on most of a fleet: the sweep read the first 64 clusters and dropped +// the rest, and the status line's "alt+r for another pass" rebuilt the same +// list and read the same 64 again. +func TestEveryClusterResolvesNotJustTheFirstBatch(t *testing.T) { + sites := haBatch + 2 + m, _ := fixtureTUIWith(t, manyClusterFixture(sites)) + m.phase = phasePicking + m.rebuildRows() + + pairs := haClusters(elementsOf(m)).pairs + if len(pairs) != sites { + t.Fatalf("%d clusters, want one per site (%d)", len(pairs), sites) + } + + // Follow the passes the way the program does: each message carries what + // it did not reach, and the next command takes it. + cmd := loadHA(t.Context(), m.pool.get(m.tenant.TSGID), m.tenant.TSGID, pairs) + passes := 0 + for { + passes++ + if passes > 10 { + t.Fatal("the sweep did not finish") + } + msg, ok := cmd().(haStatesMsg) + if !ok { + t.Fatalf("message = %T, want the HA states", msg) + } + next, c := m.Update(msg) + m = next.(tuiModel) + if len(msg.rest) == 0 { + // The last pass hands back the cache write, not another read. + break + } + if c == nil { + t.Fatalf("the chain stopped with %d clusters left", len(msg.rest)) + } + cmd = c + } + if passes < 2 { + t.Fatalf("%d pass for %d clusters, want the batches to chain", passes, sites) + } + + for _, r := range m.all { + got := plain(m.haCell(r)) + if got != "active" && got != "standby" { + t.Errorf("%s reads %q after the whole sweep, want a state", r.Name, got) + } + } +} + +// A cluster that refuses is why a row says nothing, so the row says that it +// could not be told rather than that it is still loading, and the line says +// how many and why. +func TestARefusingClusterIsReportedNotSwallowed(t *testing.T) { + m, set := fixtureTUIWith(t, haFixtureYAML) + m.phase = phasePicking + m.rebuildRows() + + first := rowFor(t, m, set.Tenants[0].Elements[0].ID) + next, _ := m.Update(haStatesMsg{ + tsg: m.tenant.TSGID, + failed: map[string]string{first.ClusterID(): "403 Forbidden"}, + }) + m = next.(tuiModel) + + if got := plain(m.haCell(first)); got != "ha?" { + t.Errorf("cell = %q for a cluster that refused, want it to stop looking like it is loading", got) + } + if !strings.Contains(m.status, "403 Forbidden") || !strings.Contains(m.status, "1 cluster") { + t.Errorf("status = %q, want the count and the reason", m.status) + } +} + +// A cluster that answered without naming a member means the controller cannot +// see it, which is the API's own word rather than something still in flight. +func TestAnAnsweredClusterThatNamesNobodyReadsUnknown(t *testing.T) { + m, set := fixtureTUIWith(t, haFixtureYAML) + m.phase = phasePicking + m.rebuildRows() + + first := rowFor(t, m, set.Tenants[0].Elements[0].ID) + next, _ := m.Update(haStatesMsg{ + tsg: m.tenant.TSGID, + answered: []string{first.ClusterID()}, + }) + m = next.(tuiModel) + + if got := plain(m.haCell(first)); got != "unknown" { + t.Errorf("cell = %q, want the API's word for a member it did not name", got) + } + if m.status != "" { + t.Errorf("status = %q; a cluster that answered is not a failure", m.status) + } +} + +// An element whose record carries no site id can never be asked about: the +// status path is per site. It used to show "ha" for good. +func TestAnElementWithNoSiteSaysItCannotBeAsked(t *testing.T) { + m, _ := fixtureTUI(t) + r := elementRow{Element: api.Element{ + ID: "9", + Name: "no-site-rtr-01", + SpokeHA: &api.SpokeHA{ClusterID: "c9"}, + }, TSG: m.tenant.TSGID} + + if got := plain(m.haCell(r)); got != "ha?" { + t.Errorf("cell = %q for an element with no site, want it to admit it cannot be read", got) + } +} + +// One POST for the whole tenant instead of one GET per cluster. This is what +// the portal's own resource map calls query_status_spokeclusters, and on a +// fleet of pairs it is the difference between a column that is there with the +// list and one that fills at five requests a second. +func TestTheAggregateQueryFillsTheWholeColumnAtOnce(t *testing.T) { + sites := haBatch + 2 + m, _ := fixtureTUIWith(t, manyClusterFixture(sites)) + m.phase = phasePicking + m.rebuildRows() + + w := haClusters(elementsOf(m)) + m.haTotal = w.clusters() + cmd := loadHABulk(t.Context(), m.pool.get(m.tenant.TSGID), m.tenant.TSGID, w) + if cmd == nil { + t.Fatal("nothing queued for a tenant with clusters") + } + msg, ok := cmd().(haStatesMsg) + if !ok { + t.Fatalf("message = %T, want the HA states", msg) + } + if msg.bulk != "used" { + t.Fatalf("bulk = %q, want the aggregate to have answered", msg.bulk) + } + if len(msg.rest) != 0 { + t.Errorf("%d clusters left over, want the one call to have covered them", len(msg.rest)) + } + + next, _ := m.Update(msg) + m = next.(tuiModel) + for _, r := range m.all { + if got := plain(m.haCell(r)); got != "active" && got != "standby" { + t.Errorf("%s reads %q after the aggregate, want a state", r.Name, got) + } + } + if got := m.haPath(); !strings.Contains(got, "one tenant-wide query") { + t.Errorf("haPath() = %q, want it to say which path filled the column", got) + } +} + +// A tenant whose aggregate is refused has to fall back, not come up empty: +// the per-cluster sweep is the only path proved against hardware. +func TestARefusedAggregateFallsBackToThePerClusterSweep(t *testing.T) { + m, set := fixtureTUIWith(t, haFixtureYAML) + m.phase = phasePicking + m.rebuildRows() + + next, cmd := m.Update(haStatesMsg{ + tsg: m.tenant.TSGID, + bulk: "status 403 Forbidden", + rest: haClusters(elementsOf(m)).pairs, + }) + m = next.(tuiModel) + if cmd == nil { + t.Fatal("the refusal queued nothing, so the column would never fill") + } + if got := m.haPath(); !strings.Contains(got, "one at a time") || !strings.Contains(got, "403") { + t.Errorf("haPath() = %q, want it to say the query was refused and what took over", got) + } + + // And the sweep it queued does fill the column. + msg, ok := cmd().(haStatesMsg) + if !ok { + t.Fatalf("message = %T, want the per-cluster states", msg) + } + next, _ = m.Update(msg) + m = next.(tuiModel) + if got := plain(m.haCell(rowFor(t, m, set.Tenants[0].Elements[0].ID))); got != "active" { + t.Errorf("cell = %q after the fallback sweep, want the state", got) + } +} + +// Without an aggregate the sweep is slow, so the last one is kept and shown +// with an asterisk rather than leaving the column empty on every launch. +func TestCachedHAIsShownWithAnAsteriskUntilItIsRefreshed(t *testing.T) { + m, set := fixtureTUIWith(t, haFixtureYAML) + m.phase = phasePicking + m.rebuildRows() + + first := rowFor(t, m, set.Tenants[0].Elements[0].ID) + m.haCached = map[string]string{first.key(): haActive} + + if got := plain(m.haCell(first)); got != "active*" { + t.Errorf("cell = %q, want the cached state marked", got) + } + + // This run's own answer replaces it, asterisk and all. + next, _ := m.Update(haStatesMsg{ + tsg: m.tenant.TSGID, + states: map[string]string{first.key(): haStandby}, + answered: []string{first.ClusterID()}, + }) + m = next.(tuiModel) + if got := plain(m.haCell(first)); got != "standby" { + t.Errorf("cell = %q after a fresh sweep, want it unmarked", got) + } +} + +// The sweep is written to the element cache, which is what the next launch +// reads: a value saved per element id, keyed off the tenant's file. +func TestASweepIsSavedForTheNextLaunch(t *testing.T) { + m, set := fixtureTUIWith(t, haFixtureYAML) + tsg := m.tenant.TSGID + eid := set.Tenants[0].Elements[0].ID + + cmd := saveHA(m.store, tsg, map[string]string{tsg + "/" + eid: haActive}) + if cmd == nil { + t.Fatal("nothing was queued to save the sweep") + } + cmd() + + c, err := m.store.Cache(tsg) + if err != nil { + t.Fatal(err) + } + if c == nil { + t.Fatal("no cache file for the tenant") + } + if got := c.HA[eid]; got != haActive { + t.Errorf("cached HA for %s = %q, want %q", eid, got, haActive) + } + // The elements it was attached to are still there. + if len(c.Elements) == 0 { + t.Error("saving the sweep dropped the cached elements") + } +} + +// The portal's trick, which is the whole reason its page is quick: ask about +// the rows you are showing, not the fleet you have. The first request covers +// what is on screen, and a search reprioritises what is asked next -- the +// matched rows are the only ones ranked at all. +func TestTheAggregateAsksAboutTheVisibleRowsFirst(t *testing.T) { + m, _ := fixtureTUIWith(t, manyClusterFixture(20)) + m.phase = phasePicking + m.width, m.height = 120, 24 + m.rebuildRows() + + w := haClusters(elementsOf(m)) + if len(w.elements) < 20 { + t.Fatalf("%d clustered elements, want the fleet", len(w.elements)) + } + + // With the cursor at the top, the first ids asked about are the top rows. + ordered := m.visibleFirst(m.tenant.TSGID, w.elements) + top := m.all[m.matches[0]].ID + if ordered[0] != top { + t.Errorf("first id asked about = %s, want the row under the cursor (%s)", ordered[0], top) + } + + // Move down the list and the order follows the cursor. + m.moveCursorTo(len(m.matches) - 1) + ordered = m.visibleFirst(m.tenant.TSGID, w.elements) + bottom := m.all[m.matches[len(m.matches)-1]].ID + if ordered[0] != bottom { + t.Errorf("first id asked about = %s, want the row the cursor moved to (%s)", ordered[0], bottom) + } + + // A search narrows it: the matched rows are asked about first, and + // everything else goes behind them. + m.filter.SetValue("store-0007") + m.applyFilter() + if len(m.matches) == 0 || len(m.matches) == len(m.all) { + t.Fatalf("%d matches, want the query to have narrowed the list", len(m.matches)) + } + wanted := map[string]bool{} + for _, i := range m.matches { + wanted[m.all[i].ID] = true + } + ordered = m.visibleFirst(m.tenant.TSGID, w.elements) + for i := range len(wanted) { + if !wanted[ordered[i]] { + t.Errorf("id %d asked about (%s) is not one of the searched rows", i, ordered[i]) + } + } +} + +// The background fill is paced on purpose, and a search does not wait for it: +// the rows a query brings on screen are asked about immediately, which is only +// possible because the fill leaves the rate budget alone between chunks. +func TestASearchAsksForItsRowsWithoutWaitingForTheFill(t *testing.T) { + m, _ := fixtureTUIWith(t, manyClusterFixture(20)) + m.phase = phasePicking + m.width, m.height = 120, 24 + m.rebuildRows() + + // A chunk lands with the fleet still queued, which is the paced state. + w := haClusters(elementsOf(m)) + next, cmd := m.Update(haStatesMsg{ + tsg: m.tenant.TSGID, + bulk: "used", + states: map[string]string{}, + restElements: w.elements, + }) + m = next.(tuiModel) + if cmd == nil { + t.Fatal("the fill queued nothing to continue with") + } + if len(m.haQueue[m.tenant.TSGID]) == 0 { + t.Fatal("the rest of the fleet was not queued") + } + + // Searching asks for what it put on screen, straight away. + m.filter.SetValue("store-0012") + m.applyFilter() + if len(m.matches) == 0 { + t.Fatal("the query matched nothing") + } + wanted := m.haWanted()[m.tenant.TSGID] + if len(wanted) == 0 { + t.Fatal("nothing was wanted for the searched rows") + } + for _, id := range wanted { + var found bool + for _, i := range m.matches { + if m.all[i].ID == id { + found = true + } + } + if !found { + t.Errorf("%s is wanted but is not one of the searched rows", id) + } + } + + ask := m.askVisibleHA() + if ask == nil { + t.Fatal("the search did not ask for its rows") + } + // One request per tenant at a time: a second ask while that one is out + // must not duplicate it. + if again := m.askVisibleHA(); again != nil { + t.Error("a second ask went out while the first was still in flight") + } + + // And the answer fills exactly those rows. + msg, ok := ask().(haStatesMsg) + if !ok { + t.Fatalf("message = %T, want the HA states", msg) + } + next, _ = m.Update(msg) + m = next.(tuiModel) + for _, i := range m.matches { + r := m.all[i] + if r.ClusterID() == "" { + continue + } + if got := plain(m.haCell(r)); got == "loading" { + t.Errorf("%s still reads loading after the search asked for it", r.Name) + } + } +} + +// Each chunk is written to the cache as it lands, not only at the end: a fill +// that is paced across a fleet would otherwise save nothing until it finished. +func TestEachChunkIsCachedAsItLands(t *testing.T) { + m, set := fixtureTUIWith(t, haFixtureYAML) + tsg := m.tenant.TSGID + eid := set.Tenants[0].Elements[0].ID + + _, cmd := m.Update(haStatesMsg{ + tsg: tsg, + bulk: "used", + states: map[string]string{tsg + "/" + eid: haActive}, + restElements: []string{"something-else"}, + }) + if cmd == nil { + t.Fatal("nothing came back from a chunk with more to do") + } + // The batch carries the next tick and the cache write; run them. + if b, ok := cmd().(tea.BatchMsg); ok { + for _, c := range b { + if c != nil { + c() + } + } + } + + c, err := m.store.Cache(tsg) + if err != nil || c == nil { + t.Fatalf("cache = %v, %v", c, err) + } + if got := c.HA[eid]; got != haActive { + t.Errorf("cached HA = %q after one chunk, want it saved as it landed", got) + } +} diff --git a/internal/tui/macros.go b/internal/tui/macros.go index 2604fc5..3f51066 100644 --- a/internal/tui/macros.go +++ b/internal/tui/macros.go @@ -83,15 +83,15 @@ func (m tuiModel) onMacroKey(msg tea.KeyPressMsg, key string) (tea.Model, tea.Cm m.macroFilter.Blur() return m, nil } - var cmd tea.Cmd - m.macroFilter, cmd = m.macroFilter.Update(msg) - // Deleting the last character leaves the filter, the same as on the - // element list: an empty query with the cursor in it is a page whose - // keys all type letters. + // Backspace on an already-empty query leaves, the same as on the + // element list: the last press deletes the "/". if key == "backspace" && m.macroFilter.Value() == "" { m.macroFilterOn = false m.macroFilter.Blur() + return m.resetMacroCursor(), nil } + var cmd tea.Cmd + m.macroFilter, cmd = m.macroFilter.Update(msg) return m.resetMacroCursor(), cmd } @@ -114,6 +114,8 @@ func (m tuiModel) onMacroKey(msg tea.KeyPressMsg, key string) (tea.Model, tea.Cm return m.openMacroForm(mac) } return m, nil + case "alt+y": + return m.copyMacro() case "ctrl+d", "x": return m.deleteMacro() case "enter": @@ -408,14 +410,22 @@ func (m tuiModel) viewMacros() string { } b.WriteString("\n\n") + // The line the cursor is on is highlighted whole, the way a row of the + // element list is, and built plain to do it: a highlight wrapped around + // styled text ends at the first reset inside it, which is why a marker + // was all this page used to manage. + width := max(20, m.width-2) + line := func(here bool, plain, styled string) string { + if here { + return ui.StyleRow.Render(fmt.Sprintf("%-*s", width, ui.Truncate(plain, width))) + "\n" + } + return " " + styled + "\n" + } + // An action rather than a row, so it stays at the top whatever the filter // says and is where the cursor rests when there is nothing to run. - newRow := "+ new macro" - if m.macroCursor == 0 { - b.WriteString(ui.StyleCmd.Render("> ") + ui.StyleOK.Render(newRow) + "\n") - } else { - b.WriteString(" " + ui.StyleGhost.Render(newRow) + "\n") - } + const newRow = "+ new macro" + b.WriteString(line(m.macroCursor == 0, newRow, ui.StyleGhost.Render(newRow))) list := m.macroRows() if len(m.cmds.Macros) > 0 && len(list) == 0 { @@ -423,30 +433,32 @@ func (m tuiModel) viewMacros() string { } for i, mac := range list { - cursor := " " - if i+1 == m.macroCursor { - cursor = ui.StyleCmd.Render("> ") - } // Padded plain, then styled, so the counts line up down the page. name := fmt.Sprintf("%-22s", ui.Truncate(mac.Name, 22)) count := fmt.Sprintf("%-14s", fmt.Sprintf("%d command(s)", len(mac.Commands))) + cmds := ui.Truncate(strings.Join(mac.Commands, " · "), max(10, m.width-58)) + // The model is the standing cross-model signal: warn-coloured when it // is not what the selection is, since the question is only asked once. - tag := "" + model, tag := "", "" if mac.Model != "" { + model = mac.Model + " " style := ui.StyleDim if sel := m.selectionModel(); sel != "" && sel != mac.Model { style = ui.StyleWarn } - tag = style.Render(mac.Model + " ") + tag = style.Render(model) } - b.WriteString(cursor + ui.StyleElement.Render(name) + ui.StyleDim.Render(count) + tag + - ui.StyleDim.Render(ui.Truncate(strings.Join(mac.Commands, " · "), max(10, m.width-58))) + "\n") + + b.WriteString(line(i+1 == m.macroCursor, + name+count+model+cmds, + ui.StyleElement.Render(name)+ui.StyleDim.Render(count)+tag+ui.StyleDim.Render(cmds))) } b.WriteString("\n") b.WriteString(ui.Footer(m.status, - "↑↓ move · enter pick, then choose elements · e edit · ctrl+d delete · / filter · esc menu")) + "↑↓ move · enter pick, then choose elements · e edit · ctrl+d delete · "+ + copyKeyHint+" · / filter · esc menu")) return b.String() } @@ -459,3 +471,33 @@ func (m tuiModel) modeLabel() string { } return label } + +// pasteMacroCommands fills a line per pasted command, growing the form as it +// goes, and leaves an empty line at the bottom for the next one. +func (m tuiModel) pasteMacroCommands(cmds []string) (tea.Model, tea.Cmd) { + m.form[m.formCursor].input.Blur() + + at := m.formCursor + for i, c := range cmds { + if at+i >= len(m.form) { + m.form = append(m.form, newMacroCommand(len(m.form)-1)) + } + m.form[at+i].input.SetValue(c) + } + m.form = append(m.form, newMacroCommand(len(m.form)-1)) + + m.formCursor = len(m.form) - 1 + in := &m.form[m.formCursor].input + in.SetCursor(len(in.Value())) + return m, in.Focus() +} + +// listReturn is where esc, q and alt+w go from the element list: back to the +// macro page when a macro is what brought us here, since that is where the +// choice was made and where another one is made. The main menu otherwise. +func (m tuiModel) listReturn() tuiPhase { + if m.mode == modeMacro && m.armedMacro != "" { + return phaseMacros + } + return phaseMenu +} diff --git a/internal/tui/macros_test.go b/internal/tui/macros_test.go index 0b8a61b..833e4ec 100644 --- a/internal/tui/macros_test.go +++ b/internal/tui/macros_test.go @@ -6,6 +6,7 @@ import ( tea "charm.land/bubbletea/v2" "tyrtop.com/iontk/internal/config" + "tyrtop.com/iontk/internal/sase/api" ) func macroModel(t *testing.T) tuiModel { @@ -494,3 +495,85 @@ func TestTheMenuDoesNotOpenTheListInMacroMode(t *testing.T) { t.Errorf("mode = %v, want the saved default", got.mode) } } + +// The line the cursor is on is highlighted whole, like a row of the element +// list. A marker alone was all this page used to manage, because the line is +// built from styled pieces and a highlight wrapped around those ends at the +// first reset inside them. +func TestTheMacroCursorHighlightsTheWholeLine(t *testing.T) { + m := macroModel(t) + m.width = 120 + + m.macroCursor = 1 + out := m.viewMacros() + rows := m.macroRows() + if len(rows) < 2 { + t.Fatalf("%d macros, want at least two to tell the lines apart", len(rows)) + } + + // The highlighted line is one styled run: plain text inside ui.StyleRow. + var want string + for _, line := range strings.Split(out, "\n") { + if strings.Contains(plain(line), rows[0].Name) { + want = line + break + } + } + if want == "" { + t.Fatalf("no line for %s:\n%s", rows[0].Name, out) + } + if !strings.Contains(want, "\x1b[1;35m") { + t.Errorf("the cursor line is not highlighted:\n%q", want) + } + // And it is one run rather than a highlight that dies at the first reset. + if n := strings.Count(want, "\x1b[0m") + strings.Count(want, "\x1b[m"); n > 1 { + t.Errorf("the highlight is broken into %d runs:\n%q", n, want) + } + + // Moving the cursor moves the highlight; the "+ new macro" row takes it too. + m.macroCursor = 0 + out = m.viewMacros() + for _, line := range strings.Split(out, "\n") { + if strings.Contains(plain(line), "+ new macro") { + if !strings.Contains(line, "\x1b[1;35m") { + t.Errorf("the new-macro row is not highlighted:\n%q", line) + } + } + } +} + +// A macro brought you to the element list, so leaving it goes back to the +// macro page rather than to the main menu: that is where the choice was made +// and where the next one is. +func TestLeavingTheElementListGoesBackToTheMacroPage(t *testing.T) { + m := macroModel(t) + m.all = []elementRow{{Element: api.Element{ID: "1", Name: "s0100-rtr-01"}, TSG: "1"}} + m.matches = []int{0} + + next, _ := m.armMacro(m.cmds.Macros[0]) + m = next.(tuiModel) + if m.phase != phasePicking || m.mode != modeMacro { + t.Fatalf("phase = %v mode = %v, want the element list in macro mode", m.phase, m.mode) + } + + for _, key := range []string{"q", "esc", "alt+w"} { + back, _ := m.onPickKey(tea.KeyPressMsg(tea.Key{Code: 'q', Text: key}), key) + if got := back.(tuiModel).phase; got != phaseMacros { + t.Errorf("%s from a macro run went to phase %v, want the macro page", key, got) + } + } + + // Without a macro it still goes to the menu. + m.mode, m.armedMacro = modeShell, "" + back, _ := m.onPickKey(tea.KeyPressMsg(tea.Key{Code: 'q', Text: "q"}), "q") + if got := back.(tuiModel).phase; got != phaseMenu { + t.Errorf("q from the plain list went to phase %v, want the menu", got) + } + // And the hint says which. + m.mode, m.armedMacro = modeMacro, m.cmds.Macros[0].Name + m.phase, m.width, m.height = phasePicking, 140, 24 + m.rebuildRows() + if got := plain(m.viewPicking()); !strings.Contains(got, "alt+w macros") { + t.Errorf("the hint does not say where back goes:\n%s", got) + } +} diff --git a/internal/tui/mouse.go b/internal/tui/mouse.go index a0694a4..699bf65 100644 --- a/internal/tui/mouse.go +++ b/internal/tui/mouse.go @@ -4,78 +4,6 @@ import ( tea "charm.land/bubbletea/v2" ) -// One notch moves three rows, which is what a terminal's own scrollback does. -const wheelRows = 3 - -// mouseMode is on only where something scrolls. Mouse reporting takes -// click-to-select away from the terminal, which is why the pages that scroll -// nothing leave it off. -func (m tuiModel) mouseMode() tea.MouseMode { - switch m.phase { - case phasePicking, phaseResults, phaseSession, phaseData: - return tea.MouseModeCellMotion - } - return tea.MouseModeNone -} - -func (m tuiModel) onWheel(msg tea.MouseWheelMsg) (tea.Model, tea.Cmd) { - up := msg.Button == tea.MouseWheelUp - if !up && msg.Button != tea.MouseWheelDown { - return m, nil - } - - switch m.phase { - case phasePicking: - if up { - m.table.MoveUp(wheelRows) - } else { - m.table.MoveDown(wheelRows) - } - - case phaseResults: - // An open detail pane is what the pointer is over, so the wheel scrolls - // it instead of moving the row underneath. - if m.resOpen { - if up { - m.resScroll = max(0, m.resScroll-wheelRows) - } else { - m.resScroll += wheelRows - } - return m, nil - } - if up { - m.resTable.MoveUp(wheelRows) - } else { - m.resTable.MoveDown(wheelRows) - } - - case phaseSession: - s := m.sessions.Active() - if s == nil { - return m, nil - } - _, rows := m.paneSize() - if up { - m.sesScroll = min(m.sesScroll+wheelRows, s.MaxScroll(rows)) - } else { - m.sesScroll = max(0, m.sesScroll-wheelRows) - } - - // A data pane scrolls down the page, not back through history, so up - // means up here where a session's wheel means the other way. - case phaseData: - tab, ok := m.activeData() - if !ok { - return m, nil - } - _, rows := m.dataPaneSize() - if up { - tab.scroll -= wheelRows - } else { - tab.scroll += wheelRows - } - tab.scroll = clampScroll(tab.scroll, len(m.dataPage(tab).lines), rows) - m.data[m.dataActive] = tab - } - return m, nil -} +// Reporting costs the terminal's own select-and-copy and buys only the wheel, +// which works anyway: the alt screen sends arrow keys for a notch. +func (m tuiModel) mouseMode() tea.MouseMode { return tea.MouseModeNone } diff --git a/internal/tui/mouse_test.go b/internal/tui/mouse_test.go deleted file mode 100644 index c3f2013..0000000 --- a/internal/tui/mouse_test.go +++ /dev/null @@ -1,98 +0,0 @@ -package tui - -import ( - "fmt" - "testing" - - tea "charm.land/bubbletea/v2" - "tyrtop.com/iontk/internal/sase" - "tyrtop.com/iontk/internal/sase/api" - "tyrtop.com/iontk/internal/sase/ioncli" -) - -func wheel(b tea.MouseButton) tea.Msg { return tea.MouseWheelMsg{Button: b} } - -func TestWheelMovesTheElementCursor(t *testing.T) { - m := pickModel(t) - els := make([]api.Element, 0, 20) - for i := range 20 { - els = append(els, api.Element{ID: fmt.Sprint(i), Name: fmt.Sprintf("s0100-rtr-%02d", i)}) - } - setTestElements(&m, els) - - next, _ := m.Update(wheel(tea.MouseWheelDown)) - down := next.(tuiModel) - if down.table.Cursor() != wheelRows { - t.Fatalf("cursor = %d after one notch down, want %d", down.table.Cursor(), wheelRows) - } - - next, _ = down.Update(wheel(tea.MouseWheelUp)) - if c := next.(tuiModel).table.Cursor(); c != 0 { - t.Errorf("cursor = %d after scrolling back up, want 0", c) - } -} - -func wheelResultsModel(t *testing.T) tuiModel { - t.Helper() - m := pickModel(t) - m.width, m.height = 100, 30 - - rs := make([]sase.Result, 0, 6) - for i := range 6 { - rs = append(rs, sase.Result{ - ElementID: fmt.Sprint(i), - Name: fmt.Sprintf("S0100-RTR-%02d", i), - Model: "ion 2000", - Commands: []ioncli.CommandOutput{{Command: "dump device status", Output: "up 5 days"}}, - }) - } - return m.seedResultsForTest(rs, nil) -} - -func TestWheelScrollsAnOpenResultInsteadOfTheRow(t *testing.T) { - m := wheelResultsModel(t) - m.resOpen = true - - next, _ := m.Update(wheel(tea.MouseWheelDown)) - got := next.(tuiModel) - if got.resScroll != wheelRows { - t.Errorf("resScroll = %d, want %d — the wheel must scroll the open pane", got.resScroll, wheelRows) - } - if got.resTable.Cursor() != 0 { - t.Errorf("row cursor moved to %d; the open pane is what the pointer was over", got.resTable.Cursor()) - } - - // At the top it stops rather than going negative, which would render the - // pane from before its first line. - up, _ := m.Update(wheel(tea.MouseWheelUp)) - if c := up.(tuiModel).resScroll; c != 0 { - t.Errorf("resScroll = %d at the top of the pane, want 0", c) - } -} - -func TestWheelMovesTheResultsRowWhenNothingIsOpen(t *testing.T) { - m := wheelResultsModel(t) - - next, _ := m.Update(wheel(tea.MouseWheelDown)) - if c := next.(tuiModel).resTable.Cursor(); c != wheelRows { - t.Errorf("results cursor = %d, want %d", c, wheelRows) - } -} - -func TestMouseReportingIsOffWhereNothingScrolls(t *testing.T) { - cases := map[tuiPhase]tea.MouseMode{ - phasePicking: tea.MouseModeCellMotion, - phaseResults: tea.MouseModeCellMotion, - // On since the pane gained a scrollback to move; it costs the terminal's - // own click-to-select, which is why the pages that scroll nothing are - // still off. - phaseSession: tea.MouseModeCellMotion, - phaseMenu: tea.MouseModeNone, - } - for phase, want := range cases { - m := tuiModel{phase: phase} - if got := m.mouseMode(); got != want { - t.Errorf("phase %d mouseMode() = %v, want %v", phase, got, want) - } - } -} diff --git a/internal/tui/multitenant.go b/internal/tui/multitenant.go index a39e238..6b18bda 100644 --- a/internal/tui/multitenant.go +++ b/internal/tui/multitenant.go @@ -187,6 +187,14 @@ func (m *tuiModel) loadFromCache() { } m.sites[k] = v } + // Kept apart from the fresh states: the column marks these with an + // asterisk, because they are as old as the file they came out of. + for eid, w := range c.HA { + if m.haCached == nil { + m.haCached = map[string]string{} + } + m.haCached[t.TSGID+"/"+eid] = w + } if a := c.Age(); a > oldest { oldest = a } @@ -441,7 +449,18 @@ func (m tuiModel) refreshNote() string { case cacheFailed: return " " + ui.StyleErr.Render("refresh failed") } - return "" + return m.haNote() +} + +// haNote is how far the HA sweep has got. One request per cluster at five a +// second is tens of seconds on a fleet of pairs, and a column filling in +// silently looks like one that has given up. +func (m tuiModel) haNote() string { + if m.haTotal == 0 || m.haDone >= m.haTotal { + return "" + } + return " " + m.spinner.View() + " " + + ui.StyleDim.Render(fmt.Sprintf("ha %d/%d clusters", m.haDone, m.haTotal)) } // runAsFor is the device login chosen for a tenant: an explicit choice made in diff --git a/internal/tui/multitenant_test.go b/internal/tui/multitenant_test.go index 0889050..7392673 100644 --- a/internal/tui/multitenant_test.go +++ b/internal/tui/multitenant_test.go @@ -549,7 +549,7 @@ func TestDefaultModeSettingPersistsAndApplies(t *testing.T) { m.phase = phaseSettings m.setSection = secGeneral m.setFocus = focusContent - m.setCursor = 2 + m.setCursor = modeRow m.spinner = ui.NewSpinner("") if got := m.defaultMode(); got != modeShell { diff --git a/internal/tui/results.go b/internal/tui/results.go index 0676f36..2ec66d7 100644 --- a/internal/tui/results.go +++ b/internal/tui/results.go @@ -102,6 +102,13 @@ func (m tuiModel) completedCount() int { } func (m *tuiModel) applyResultFilter() { + // The ION the cursor is on, not the row it is on. Results arrive in + // whatever order the elements finish and the table is in run order, so an + // earlier element landing after a later one moves every row below it -- + // which used to slide the highlight onto a different ION under the user's + // hands, and take an open output pane with it. + was := m.cursorResultID() + q := strings.ToLower(strings.TrimSpace(m.resFilter.Value())) m.resMatches = m.resMatches[:0] @@ -125,7 +132,29 @@ func (m *tuiModel) applyResultFilter() { } m.resFilter.SetSuggestions(names) m.rebuildResultRows() + m.keepResultCursorOn(was) +} + +// cursorResultID is the element under the cursor, empty when there is none. +func (m tuiModel) cursorResultID() string { + if idx, ok := m.currentResult(); ok { + return m.results[idx].ElementID + } + return "" +} +// keepResultCursorOn puts the cursor back on an element wherever its row has +// moved to, and clamps when that element is no longer listed at all -- which a +// filter can do. +func (m *tuiModel) keepResultCursorOn(eid string) { + if eid != "" { + for row, i := range m.resMatches { + if m.results[i].ElementID == eid { + m.resTable.SetCursor(row) + return + } + } + } if c := m.resTable.Cursor(); c >= len(m.resMatches) { m.resTable.SetCursor(max(0, len(m.resMatches)-1)) } @@ -254,6 +283,9 @@ func (m tuiModel) onResultsKey(msg tea.KeyPressMsg, key string) (tea.Model, tea. } return m, nil + case "alt+y": + return m.copyResults() + case "f", "o", "s": if !m.resFilterOn { return m.onResultsOutputKey(key) @@ -269,15 +301,17 @@ func (m tuiModel) onResultsKey(msg tea.KeyPressMsg, key string) (tea.Model, tea. } if m.resFilterOn { - var cmd tea.Cmd - m.resFilter, cmd = m.resFilter.Update(msg) - m.applyResultFilter() - // Deleting the last character leaves the filter, as on every other - // page that has one. + // Backspace on an already-empty query leaves, as on every other page + // that has a filter: the last press deletes the "/". if key == "backspace" && m.resFilter.Value() == "" { m.resFilterOn = false m.resFilter.Blur() + m.applyResultFilter() + return m, nil } + var cmd tea.Cmd + m.resFilter, cmd = m.resFilter.Update(msg) + m.applyResultFilter() return m, cmd } @@ -415,9 +449,9 @@ func (m tuiModel) viewResults() string { } status := m.status - hint := "↑↓/jk move · / filter · enter open · f/o format · s save · esc/q back" + hint := "↑↓/jk move · / filter · enter open · f/o format · s save · " + copyKeyHint + " run as json · esc/q back" if m.resOpen { - hint = "↑↓/jk scroll · pgup/pgdn page · home/end ends · esc/q close" + hint = "↑↓/jk scroll · pgup/pgdn page · home/end ends · " + copyKeyHint + " output · esc/q close" // Where in the output the highlight is, since the pane holds a // fraction of a long run's output. if idx, ok := m.currentResult(); ok { diff --git a/internal/tui/results_test.go b/internal/tui/results_test.go index 9e0bcd9..284e95e 100644 --- a/internal/tui/results_test.go +++ b/internal/tui/results_test.go @@ -1,6 +1,7 @@ package tui import ( + "fmt" "os" "path/filepath" "strings" @@ -335,3 +336,94 @@ func TestResultsHeaderNamesTheSaveFormat(t *testing.T) { t.Errorf("results header claims an orientation json does not use:\n%s", got) } } + +// Results arrive in whatever order the elements finish, and the table is in +// run order, so a later element finishing first and an earlier one landing +// after it moves every row below it. The cursor has to follow the ION it was +// on, not the row number it was on -- otherwise the highlight lands on a +// different element under the user's hands, and an open pane shows one ION's +// output under another's name. +func TestTheResultsCursorFollowsItsElementAsResultsArrive(t *testing.T) { + m := pickModel(t) + m.width, m.height = 100, 30 + m.ran = []string{"1", "2", "3", "4"} + m.results = make([]sase.Result, 4) + m.elapsed = make([]time.Duration, 4) + m.phase = phaseResults + + done := func(i int, name string) { + m.results[i] = sase.Result{ + ElementID: fmt.Sprint(i + 1), Name: name, Model: "ion 2000", + Commands: []ioncli.CommandOutput{{Command: "dump device status", Output: name + " output"}}, + } + m.applyResultFilter() + } + + // The last element finishes first, so it is the only row. + done(3, "S0100-RTR-04") + idx, ok := m.currentResult() + if !ok || m.results[idx].Name != "S0100-RTR-04" { + t.Fatalf("cursor is on %+v, want the only finished element", m.results[idx]) + } + + // Now an earlier one lands, which pushes that row down. + done(1, "S0100-RTR-02") + idx, ok = m.currentResult() + if !ok { + t.Fatal("no result under the cursor") + } + if got := m.results[idx].Name; got != "S0100-RTR-04" { + t.Errorf("cursor moved to %s, want it to stay on S0100-RTR-04", got) + } + + // And an open pane still shows the element the row names. + m.resOpen = true + out := plain(m.viewResults()) + if !strings.Contains(out, "S0100-RTR-04 output") { + t.Errorf("the open pane is not the element under the cursor:\n%s", out) + } + + // Moving down then having another arrive keeps the new choice too. + m.moveResultCursor(-1) + before, _ := m.currentResult() + name := m.results[before].Name + done(0, "S0100-RTR-01") + after, _ := m.currentResult() + if got := m.results[after].Name; got != name { + t.Errorf("cursor moved from %s to %s when a result arrived", name, got) + } +} + +// The results filter follows the same rule as the others: empty first, leave +// second. +func TestBackspacingTheResultsQueryEmptiesItThenLeaves(t *testing.T) { + m := resultsModel(t) + next, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: '/', Text: "/"})) + m = next.(tuiModel) + if !m.resFilterOn { + t.Fatal("/ did not open the results filter") + } + for _, r := range "S9999" { + next, _ = m.Update(tea.KeyPressMsg(tea.Key{Code: r, Text: string(r)})) + m = next.(tuiModel) + } + if len(m.resMatches) != 1 { + t.Fatalf("%d matches, want the query to have narrowed the list", len(m.resMatches)) + } + + for range "S9999" { + next, _ = m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyBackspace})) + m = next.(tuiModel) + } + if !m.resFilterOn { + t.Error("the results filter closed on the press that emptied the query") + } + if len(m.resMatches) != m.completedCount() { + t.Errorf("%d matches, want every completed result back", len(m.resMatches)) + } + + next, _ = m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyBackspace})) + if next.(tuiModel).resFilterOn { + t.Error("the results filter is still open after backspacing the empty query") + } +} diff --git a/internal/tui/scrollback_test.go b/internal/tui/scrollback_test.go index 3e93077..0529ffb 100644 --- a/internal/tui/scrollback_test.go +++ b/internal/tui/scrollback_test.go @@ -161,25 +161,17 @@ func TestTypingReturnsThePaneToLive(t *testing.T) { } } -func TestWheelScrollsTheSessionPane(t *testing.T) { +// iontk never asks for the mouse, on any page: reporting would take the +// terminal's own select-and-copy away, and the keys scroll every pane. The +// wheel keeps working because terminals send arrow keys for a notch in the +// alt screen. +func TestNoPageAsksForTheMouse(t *testing.T) { m, _ := scrolledPane(t) - - up, _ := m.Update(tea.MouseWheelMsg{Button: tea.MouseWheelUp}) - m = up.(tuiModel) - if m.sesScroll != wheelRows { - t.Errorf("sesScroll = %d after one notch, want %d", m.sesScroll, wheelRows) - } - - down, _ := m.Update(tea.MouseWheelMsg{Button: tea.MouseWheelDown}) - if got := down.(tuiModel).sesScroll; got != 0 { - t.Errorf("sesScroll = %d back at the bottom, want 0", got) - } -} - -func TestMouseReportingIsOnInTheSessionPane(t *testing.T) { - m, _ := scrolledPane(t) - if m.mouseMode() != tea.MouseModeCellMotion { - t.Error("the session pane scrolls but reports no mouse") + for _, phase := range []tuiPhase{phaseSession, phasePicking, phaseResults, phaseData, phaseMenu} { + m.phase = phase + if got := m.mouseMode(); got != tea.MouseModeNone { + t.Errorf("phase %d mouseMode() = %v, want the terminal to keep the mouse", phase, got) + } } } diff --git a/internal/tui/searchui.go b/internal/tui/searchui.go index a73ddf6..c7b8054 100644 --- a/internal/tui/searchui.go +++ b/internal/tui/searchui.go @@ -11,7 +11,6 @@ import ( "charm.land/bubbles/v2/progress" "charm.land/bubbles/v2/spinner" tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" "tyrtop.com/iontk/internal/app" "tyrtop.com/iontk/internal/ui" ) @@ -72,7 +71,7 @@ type loadClosedMsg struct{} func newLoadModel(events <-chan TenantLoad, total int) loadModel { s := spinner.New() - s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("5")) + s.Style = ui.StyleSpinner return loadModel{ events: events, total: total, diff --git a/internal/tui/secrets_warning_test.go b/internal/tui/secrets_warning_test.go index 80cd538..c5672fe 100644 --- a/internal/tui/secrets_warning_test.go +++ b/internal/tui/secrets_warning_test.go @@ -147,8 +147,8 @@ func TestGeneralSectionHasARefreshRow(t *testing.T) { } m.spinner = ui.NewSpinner("") - if got := m.sectionRows(); got != 5 { - t.Fatalf("sectionRows() = %d, want 5 so the refresh row is reachable", got) + if got := m.sectionRows(); got != generalRows { + t.Fatalf("sectionRows() = %d, want %d so the refresh row is reachable", got, generalRows) } if out := m.viewSettings(); !strings.Contains(out, "elements") { t.Errorf("the General section has no elements row:\n%s", out) diff --git a/internal/tui/session.go b/internal/tui/session.go index bbcbacd..316602b 100644 --- a/internal/tui/session.go +++ b/internal/tui/session.go @@ -6,7 +6,6 @@ import ( "strings" tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" uv "github.com/charmbracelet/ultraviolet" "tyrtop.com/iontk/internal/app" "tyrtop.com/iontk/internal/ionterm" @@ -19,16 +18,6 @@ const ( defaultSessionRows = 50 ) -var tabColors = []string{"7", "4", "6", "5", "12", "14", "13"} - -func styleTabAt(i int, active bool) lipgloss.Style { - s := lipgloss.NewStyle().Foreground(lipgloss.Color(tabColors[i%len(tabColors)])) - if active { - return s.Reverse(true).Bold(true) - } - return s -} - type toolkitDialer struct{ c *api.Client } func (d toolkitDialer) DialToolkit(ctx context.Context, eid string, cols, rows int) (ionterm.Conn, error) { @@ -274,6 +263,12 @@ func (m tuiModel) onSessionKey(msg tea.KeyPressMsg, key string) (tea.Model, tea. return m, nil } + // Before the checks below, like the favourites: what a tab last said is + // the reason anyone is looking at a dead one. + if key == "alt+y" { + return m.copySession() + } + // A redial is a new CLI session: enable and config context are gone, so // it is never automatic for a shell someone is sitting in front of. It // has to cover a tab that failed as well as one that went quiet -- a torn @@ -406,7 +401,7 @@ func (m tuiModel) onSessionOpened(eid string) tuiModel { func (m tuiModel) tabBar() string { cur := m.currentTab() render := func(i int, label string) string { - return styleTabAt(i, i == cur).Render(label) + return ui.StyleTab(i, i == cur).Render(label) } // No numbers any more: alt+N is a favourite command, and a number on a @@ -471,7 +466,8 @@ func (m tuiModel) renderSession() (string, *tea.Cursor) { return b.String(), nil } - const hint = "ctrl+] elements · alt+←→/hl tab · alt+1-9 favourite · alt+f save · alt+w detach · alt+k close · shift+pgup scroll" + const hint = "ctrl+] elements · alt+←→/hl tab · alt+1-9 favourite · alt+f save · " + copyKeyHint + + " scrollback · alt+w detach · alt+k close · shift+pgup scroll" status := m.status switch { diff --git a/internal/tui/session_test.go b/internal/tui/session_test.go index cd8bfd3..a9f9bdd 100644 --- a/internal/tui/session_test.go +++ b/internal/tui/session_test.go @@ -403,18 +403,6 @@ func TestPickTargetsFallsBackToTheHighlightedElement(t *testing.T) { } } -func TestTabColorsAreNeutral(t *testing.T) { - reserved := map[string]string{"1": "red", "2": "green", "3": "yellow", "9": "bright red", "10": "bright green", "11": "bright yellow"} - for _, c := range tabColors { - if name, bad := reserved[c]; bad { - t.Errorf("tab palette uses %s (%s), which carries status meaning", c, name) - } - } - if len(tabColors) < 4 { - t.Errorf("palette has %d colors, want enough to tell tabs apart", len(tabColors)) - } -} - func TestFirstLoginAsksForCredentialsThenProceeds(t *testing.T) { root := t.TempDir() st := config.OpenAt(root, config.NewFileSecrets(root+"/secrets.json")) diff --git a/internal/tui/settings.go b/internal/tui/settings.go index 3c2b5f9..c93c17a 100644 --- a/internal/tui/settings.go +++ b/internal/tui/settings.go @@ -9,6 +9,7 @@ import ( tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "tyrtop.com/iontk/internal/app" + "tyrtop.com/iontk/internal/clock" "tyrtop.com/iontk/internal/config" "tyrtop.com/iontk/internal/ioncmd" "tyrtop.com/iontk/internal/output" @@ -22,10 +23,33 @@ const ( focusContent ) -var setSections = []string{"General", "Batch", "Tenants", "Users", "Session", "Debug", "Reset"} +// The General rows, named because the key handler, the content and the tests +// all have to agree on the order. The refresh is last: it is the one entry +// here that does something rather than setting something. +// The order here is the order sectionContent prints them in, and +// TestGeneralRowsMatchTheirLabels is what keeps the two from drifting: a row +// that moves without its constant sends the cursor to someone else's setting. +const ( + guardRow = iota + modeRow + jumpRow + zoneRow + refreshRow + generalRows +) + +// generalRowLabels is each row's gutter label, for that test and for nothing +// else. +var generalRowLabels = map[int]string{ + guardRow: "commands", modeRow: "mode", jumpRow: "jump", + zoneRow: "times", refreshRow: "elements", +} + +var setSections = []string{"General", "Theme", "Batch", "Tenants", "Users", "Session", "Debug", "Reset"} const ( secGeneral = iota + secTheme secBatch secTenants secUsers @@ -37,7 +61,14 @@ const ( func (m tuiModel) sectionRows() int { switch m.setSection { case secGeneral: - return 5 + return generalRows + case secTheme: + if m.themeEdit { + // The editor has its own cursor; the section's would otherwise + // move the theme list underneath it. + return 0 + } + return len(ui.Themes()) case secBatch: return 4 case secTenants: @@ -115,6 +146,8 @@ func (m tuiModel) onSettingsKey(key string) (tea.Model, tea.Cmd) { switch m.setSection { case secGeneral: return m.onGeneralKey(key) + case secTheme: + return m.onThemeKey(key) case secBatch: return m.onBatchKey(key) case secTenants: @@ -181,23 +214,24 @@ func (m tuiModel) onGeneralKey(key string) (tea.Model, tea.Cmd) { if key != "enter" && key != " " && key != "space" { return m, nil } - if m.setCursor == 4 { + // The refresh stays the last row of the section: it is the one entry here + // that does something rather than setting something. + if m.setCursor == refreshRow { return m.refreshNow() } switch m.setCursor { - case 2: + case zoneRow: + // Typed rather than cycled, since there are 600 IANA names. Space + // still reaches the two anyone wants without a form. + if key == "enter" { + return m.openZoneForm() + } + return m.cycleZone() + case modeRow: return m.cycleDefaultMode() - case 3: + case jumpRow: return m.cyclePageStep() - case 0: - next := ui.NextSpinnerName(m.spinnerName) - m.cfg.Spinner = next - if err := m.store.SaveConfig(m.cfg); err != nil { - m.status = err.Error() - return m, nil - } - return m.withSpinner(next) - case 1: + case guardRow: m.cfg.AllowWrites = !m.cfg.AllowWrites if err := m.store.SaveConfig(m.cfg); err != nil { m.status = err.Error() @@ -387,12 +421,12 @@ func (m tuiModel) sectionContent() []string { guard = ui.StyleWarn.Render("writes allowed") } out = append(out, - ui.Label("spinner")+m.spinner.View()+" "+ui.StyleElement.Render(m.spinnerName), ui.Label("commands")+guard, ui.Label("mode")+ui.StyleElement.Render(m.defaultMode().String())+ ui.StyleDim.Render(" what the element list opens in"), ui.Label("jump")+ui.StyleElement.Render(fmt.Sprintf("%d rows", m.pageStep()))+ ui.StyleDim.Render(" how far alt+↑/↓ moves in the element list"), + ui.Label("times")+m.zoneLine(), ui.Label("elements")+m.cacheLine(), ui.Label("views")+m.viewsLine(), "", @@ -405,6 +439,9 @@ func (m tuiModel) sectionContent() []string { out = append(out, ui.Label("")+ui.StyleWarn.Render("ION passwords are readable by anything that can read that file")) } + case secTheme: + out = append(out, m.themeContent()...) + case secTenants: if len(m.cfg.Tenants) == 0 { out = append(out, ui.StyleDim.Render("no tenants — add one from the main menu")) @@ -485,6 +522,8 @@ func (m tuiModel) sectionContent() []string { ui.Label("secrets")+ui.StyleDim.Render(m.store.Secrets.Backend()), ui.Label("version")+ui.StyleDim.Render(Version), ui.Label("stats")+ui.StyleDim.Render("alt+i overlays live counters"), + ui.Label("colours")+ui.StyleDim.Render(ui.CurrentTheme().Name+" "+profileLabel()), + ui.Label("times")+ui.StyleDim.Render(clock.Name()), "", ui.Label("tenant")+ui.StyleDim.Render(m.tenant.Name+" tsg "+m.tenant.TSGID), ui.Label("ion user")+ui.StyleDim.Render(m.ionUser), @@ -492,6 +531,7 @@ func (m tuiModel) sectionContent() []string { "", ui.Label("elements")+ui.StyleDim.Render(fmt.Sprintf("%d cached, %d shown", m.cacheCount, len(m.matches))), ui.Label("sessions")+ui.StyleDim.Render(fmt.Sprintf("%d open, %d detached", m.sessions.Count(), m.sessions.DetachedCount())), + ui.Label("ha")+ui.StyleDim.Render(m.haPath()), ui.Label("catalog")+ui.StyleDim.Render(fmt.Sprintf("%d commands, %d offered", len(ioncmd.Embedded()), len(m.catalog()))), ui.Label("terminal")+ui.StyleDim.Render(fmt.Sprintf("%d x %d", m.width, m.height)), ) @@ -514,7 +554,12 @@ func (m tuiModel) sectionHint() string { } switch m.setSection { case secGeneral: - return "↑↓ row · enter change/refresh · tab back · esc back" + return "↑↓ row · enter change/refresh/type · space cycle · tab back · esc back" + case secTheme: + if m.themeEdit { + return "↑↓ role · ←→ cycle · enter type · ctrl+d built-in · esc done" + } + return "↑↓ theme · enter use it · e edit its colours · tab back · esc back" case secBatch: return "↑↓ row · enter change · tab back · esc back" case secTenants: @@ -565,7 +610,7 @@ func (m tuiModel) viewSettings() string { } navPane := lipgloss.NewStyle().Width(navWidth).Height(bodyHeight).Render(nav.String()) - divider := lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Height(bodyHeight). + divider := ui.StyleDivider.Height(bodyHeight). Render(strings.TrimRight(strings.Repeat("│\n", bodyHeight), "\n")) contentPane := lipgloss.NewStyle().Width(contentWidth).Height(bodyHeight).Render(content.String()) diff --git a/internal/tui/settings_test.go b/internal/tui/settings_test.go index 393f443..0563ddc 100644 --- a/internal/tui/settings_test.go +++ b/internal/tui/settings_test.go @@ -88,7 +88,7 @@ func TestSettingsNavAndContentFocus(t *testing.T) { func TestSettingsEverySectionRenders(t *testing.T) { m := settingsModel(t) wants := map[int]string{ - secGeneral: "spinner", + secGeneral: "commands", secTenants: "Acme Co", secUsers: "readonly", secSession: "buffer cols", @@ -111,7 +111,7 @@ func TestSettingsGeneralTogglesWriteGuard(t *testing.T) { m := settingsModel(t) m.setSection = secGeneral m.setFocus = focusContent - m.setCursor = 1 + m.setCursor = guardRow mm, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyEnter})) m = mm.(tuiModel) @@ -272,7 +272,7 @@ func TestRenderSettingsPanes(t *testing.T) { } func TestLabelsPadVisibleWidthNotEscapeBytes(t *testing.T) { - for _, s := range []string{"spinner", "commands", "a"} { + for _, s := range []string{"momentum", "commands", "a"} { if got := len(plain(ui.Label(s))); got != 13 { t.Errorf("ui.Label(%q) visible width = %d, want 13", s, got) } @@ -436,7 +436,7 @@ func TestEnterInContentStillActs(t *testing.T) { m := settingsModel(t) m.setSection = secGeneral m.setFocus = focusContent - m.setCursor = 1 + m.setCursor = guardRow mm, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyEnter})) if !mm.(tuiModel).cfg.AllowWrites { @@ -618,3 +618,30 @@ func TestGeometryFormTypesAValue(t *testing.T) { t.Errorf("status = %q, want a complaint about the value", got) } } + +// The General rows are reached by number from the key handler and printed in +// order by sectionContent, so the two have to agree. They did not once: mouse +// was third in the constants and fourth on screen, which pointed its key at +// the jump setting. +func TestGeneralRowsMatchTheirLabels(t *testing.T) { + m := settingsModel(t) + m.setSection = secGeneral + m.spinner = ui.NewSpinner("") + + rows := m.sectionContent() + if len(rows) < generalRows { + t.Fatalf("%d content lines, want at least one per row (%d)", len(rows), generalRows) + } + for row := range generalRows { + want, ok := generalRowLabels[row] + if !ok { + t.Fatalf("row %d has no label in generalRowLabels", row) + } + if got := plain(rows[row]); !strings.HasPrefix(strings.TrimSpace(got), want) { + t.Errorf("row %d is %q, want it to start with %q", row, got, want) + } + } + if got := m.sectionRows(); got != generalRows { + t.Errorf("sectionRows() = %d, want %d", got, generalRows) + } +} diff --git a/internal/tui/spinners.go b/internal/tui/spinners.go index 89910e8..d30dc4c 100644 --- a/internal/tui/spinners.go +++ b/internal/tui/spinners.go @@ -5,8 +5,9 @@ import ( "tyrtop.com/iontk/internal/ui" ) -func (m tuiModel) withSpinner(name string) (tuiModel, tea.Cmd) { - m.spinnerName = ui.SpinnerName(name) - m.spinner = ui.NewSpinner(m.spinnerName) +// withSpinner rebuilds the spinner from the theme, which owns its shape as +// well as its colour. +func (m tuiModel) withSpinner() (tuiModel, tea.Cmd) { + m.spinner = ui.NewSpinner(ui.CurrentTheme().Spinner) return m, m.spinner.Tick } diff --git a/internal/tui/stats_test.go b/internal/tui/stats_test.go index 8c32bc2..3db5e4f 100644 --- a/internal/tui/stats_test.go +++ b/internal/tui/stats_test.go @@ -21,10 +21,10 @@ func TestStatsOverlayKeepsTheFrameHeight(t *testing.T) { return m, func(m tuiModel) string { return m.viewPicking() } }, "results": func(t *testing.T) (tuiModel, func(tuiModel) string) { - return wheelResultsModel(t), func(m tuiModel) string { return m.viewResults() } + return resultsModel(t), func(m tuiModel) string { return m.viewResults() } }, "open result": func(t *testing.T) (tuiModel, func(tuiModel) string) { - m := wheelResultsModel(t) + m := resultsModel(t) m.resOpen = true return m, func(m tuiModel) string { return m.viewResults() } }, diff --git a/internal/tui/themeedit.go b/internal/tui/themeedit.go new file mode 100644 index 0000000..75e5f7f --- /dev/null +++ b/internal/tui/themeedit.go @@ -0,0 +1,180 @@ +package tui + +import ( + "fmt" + "strings" + + tea "charm.land/bubbletea/v2" + "tyrtop.com/iontk/internal/app" + "tyrtop.com/iontk/internal/ui" +) + +const spinnerRole = "spinner" + +func editRoles() []string { return append(ui.RoleNames(), spinnerRole) } + +// editing a built-in writes an override; the embedded file is not writable +func (m tuiModel) openThemeEdit() (tea.Model, tea.Cmd) { + themes := ui.Themes() + if m.setCursor < 0 || m.setCursor >= len(themes) { + return m, nil + } + m.themeEdit = true + m.themeDraft = themes[m.setCursor] + m.themeRow = 0 + m.status = "" + return m, nil +} + +func (m tuiModel) onThemeEditKey(key string) (tea.Model, tea.Cmd) { + roles := editRoles() + role := roles[min(max(m.themeRow, 0), len(roles)-1)] + + switch key { + case "esc", "q", "tab": + m.themeEdit = false + m.status = "" + return m, nil + + case "up", "k": + m.themeRow = max(0, m.themeRow-1) + return m, nil + case "down", "j": + m.themeRow = min(len(roles)-1, m.themeRow+1) + return m, nil + + // no typing, and the preview shows each value as it passes + case "left", "h", "-": + return m.stepRole(role, -1) + case "right", "l", "+", " ", "space": + return m.stepRole(role, 1) + + case "enter": + if role == spinnerRole { + return m.stepRole(role, 1) + } + return m.openColourForm(role) + + // the only way back once a built-in is overridden + case "ctrl+d", "x": + return m.resetRole(role) + } + return m, nil +} + +func (m tuiModel) stepRole(role string, delta int) (tea.Model, tea.Cmd) { + if role == spinnerRole { + m.themeDraft.Spinner = ui.NextSpinner(m.themeDraft.Spinner, delta) + } else { + next := ui.NextInPalette(m.themeDraft.Role(role), delta) + if err := m.themeDraft.SetRole(role, next); err != nil { + m.status = err.Error() + return m, nil + } + } + return m.saveDraft() +} + +func (m tuiModel) resetRole(role string) (tea.Model, tea.Cmd) { + base, ok := ui.BuiltinTheme(m.themeDraft.Name) + if !ok { + m.status = m.themeDraft.Name + " is not a built-in, so there is nothing to go back to" + return m, nil + } + if role == spinnerRole { + m.themeDraft.Spinner = base.Spinner + } else if err := m.themeDraft.SetRole(role, base.Role(role)); err != nil { + m.status = err.Error() + return m, nil + } + return m.saveDraft() +} + +func (m tuiModel) openColourForm(role string) (tea.Model, tea.Cmd) { + f := newField(role, "#89b4fa or 0-255", false) + f.input.SetValue(m.themeDraft.Role(role)) + m.formThemeRole = role + return m.openForm(formColour, "Colour for "+role, phaseAddUser, []formField{f}) +} + +// a colour that will not parse would render as nothing +func (m tuiModel) submitColour() (tea.Model, tea.Cmd) { + if err := m.themeDraft.SetRole(m.formThemeRole, m.formValue(0)); err != nil { + m.status = err.Error() + return m, nil + } + next, cmd := m.saveDraft() + nm := next.(tuiModel) + if nm.status != "" && strings.Contains(nm.status, "save") { + return nm, cmd + } + nm.form, nm.formKind = nil, formNone + nm.phase = phaseSettings + return nm, cmd +} + +// applied as well as saved, so the whole frame shows the edit +func (m tuiModel) saveDraft() (tea.Model, tea.Cmd) { + path := app.ThemesPath(m.store) + if err := ui.SaveUserTheme(path, m.themeDraft); err != nil { + m.status = "save failed: " + err.Error() + return m, nil + } + m.cfg.Theme = m.themeDraft.Name + if err := m.store.SaveConfig(m.cfg); err != nil { + m.status = err.Error() + return m, nil + } + return m.applyTheme(m.themeDraft) +} + +func (m tuiModel) themeEditContent() []string { + roles := editRoles() + s := m.themeDraft.Styles() + + out := []string{ + ui.StyleCmd.Render("editing "+m.themeDraft.Name) + + ui.StyleDim.Render(" saved to "+shortenPath(app.ThemesPath(m.store), 34)), + "", + } + for i, role := range roles { + cursor := " " + if i == m.themeRow { + cursor = ui.StyleCmd.Render("> ") + } + if role == spinnerRole { + out = append(out, cursor+ui.Label(role)+" "+ + s.Spinner.Render(ui.SpinnerFrame(m.themeDraft.Spinner))+" "+ + ui.StyleElement.Render(m.themeDraft.Spinner)) + continue + } + + value := m.themeDraft.Role(role) + shown := value + if shown == "" { + shown = "unset" + } + // the only way to tell #585b70 from #45475a + sample := m.themeDraft.RoleStyle(role).Render("████") + // "spinner_color" fills LabelWidth exactly, leaving no gap + out = append(out, cursor+ui.Label(role)+" "+sample+" "+ + ui.StyleElement.Render(fmt.Sprintf("%-9s", shown))+ + ui.StyleDim.Render(roleHelp[role])) + } + return append(out, "", ui.StyleDim.Render( + "←→ cycle · enter type one · ctrl+d back to the built-in · esc done")) +} + +// "hot" and "row" mean nothing until you know where they are drawn +var roleHelp = map[string]string{ + "ok": "something worked, and the ✓", + "err": "something failed, and the ✗", + "warn": "worth noticing, not a failure", + "cmd": "headings and command text", + "element": "an element, tenant or value", + "row": "the row a list is on", + "hot": "a session about to go", + "border": "a modal's frame", + "divider": "the rule between panes", + "spinner_color": "every spinner", +} diff --git a/internal/tui/themeedit_test.go b/internal/tui/themeedit_test.go new file mode 100644 index 0000000..9eb4ba5 --- /dev/null +++ b/internal/tui/themeedit_test.go @@ -0,0 +1,208 @@ +package tui + +import ( + "os" + "strings" + "testing" + + "tyrtop.com/iontk/internal/app" + "tyrtop.com/iontk/internal/ui" +) + +// editorOn is the Theme section with the editor open on one scheme. +func editorOn(t *testing.T, name string) tuiModel { + t.Helper() + pinTheme(t) + t.Cleanup(func() { app.ThemesFile = "" }) + + m := settingsModel(t) + m.width, m.height = 110, 44 + m.setSection, m.setFocus = secTheme, focusContent + for i, th := range ui.Themes() { + if th.Name == name { + m.setCursor = i + } + } + + next, _ := m.onThemeKey("e") + m = next.(tuiModel) + if !m.themeEdit { + t.Fatalf("e did not open the editor on %s", name) + } + return m +} + +// Editing a built-in writes an override of it into the config root's +// themes.yaml, which is the only place anything can be written: the shipped +// set is embedded. +func TestEditingAThemeWritesAnOverrideAndAppliesIt(t *testing.T) { + m := editorOn(t, "classic") + + // The first role, stepped one along the palette. + before := m.themeDraft.OK + next, _ := m.onThemeEditKey("right") + m = next.(tuiModel) + if m.themeDraft.OK == before { + t.Fatalf("ok is still %q after a step along the palette", before) + } + if m.status != "" { + t.Errorf("status = %q, want the edit to have gone through", m.status) + } + + // Applied to the whole frame, not just the preview. + if got := ui.CurrentTheme().OK; got != m.themeDraft.OK { + t.Errorf("CurrentTheme().OK = %q, want the edit %q", got, m.themeDraft.OK) + } + if ui.MarkCheck.String() == "✓" { + t.Error("the ✓ was not restyled, so Apply did not reach the marks") + } + + // Written where the config root loads from, and it survives a reload. + path := app.ThemesPath(m.store) + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("themes.yaml was not written: %v", err) + } + if !strings.Contains(string(b), "classic") { + t.Errorf("themes.yaml does not hold the override:\n%s", b) + } + if err := ui.LoadThemes(path); err != nil { + t.Fatalf("the file it wrote will not load: %v", err) + } + reloaded, _ := ui.ThemeByName("classic") + if reloaded.OK != m.themeDraft.OK { + t.Errorf("reloaded ok = %q, want %q", reloaded.OK, m.themeDraft.OK) + } + // Overriding one scheme must not drop the others. + if _, ok := ui.ThemeByName("nord"); !ok { + t.Error("the override took the rest of the set with it") + } + // And the theme in use is the one that was edited, so a restart keeps it. + saved, err := m.store.Config() + if err != nil { + t.Fatal(err) + } + if saved.Theme != "classic" { + t.Errorf("saved theme = %q, want the edited one", saved.Theme) + } +} + +// Typing a colour is the other route, and it has to refuse one that will not +// parse: lipgloss renders an unparseable colour as nothing at all. +func TestTypingAColourIsValidated(t *testing.T) { + m := editorOn(t, "nord") + // Move to a role worth typing into, then open the form. + next, _ := m.onThemeEditKey("down") + m = next.(tuiModel) + next, _ = m.onThemeEditKey("enter") + m = next.(tuiModel) + if m.formKind != formColour { + t.Fatalf("formKind = %v, want the colour form", m.formKind) + } + + m.form[0].input.SetValue("chartreuse") + next, _ = m.submitColour() + m = next.(tuiModel) + if m.formKind != formColour { + t.Error("the form closed on a colour that will not parse") + } + if m.status == "" { + t.Error("nothing said why the colour was refused") + } + + m.form[0].input.SetValue("#ff00aa") + next, _ = m.submitColour() + m = next.(tuiModel) + if m.formKind != formNone { + t.Errorf("the form stayed open on a good colour: %s", m.status) + } + if got := m.themeDraft.Err; got != "#ff00aa" { + t.Errorf("err = %q, want the typed colour", got) + } + if got := ui.CurrentTheme().Err; got != "#ff00aa" { + t.Errorf("CurrentTheme().Err = %q, want the typed colour applied", got) + } +} + +// Once a built-in is overridden, going back to what it shipped with is the +// one thing a user cannot do by hand without knowing the old value. +func TestAnEditedRoleCanGoBackToTheBuiltin(t *testing.T) { + m := editorOn(t, "classic") + original := m.themeDraft.OK + + next, _ := m.onThemeEditKey("right") + m = next.(tuiModel) + if m.themeDraft.OK == original { + t.Fatal("nothing changed, so there is nothing to undo") + } + + next, _ = m.onThemeEditKey("ctrl+d") + m = next.(tuiModel) + if m.themeDraft.OK != original { + t.Errorf("ok = %q after ctrl+d, want the built-in's %q", m.themeDraft.OK, original) + } +} + +// The spinner is part of the scheme, so it is edited in the same list. +func TestTheEditorCyclesTheSpinnerShape(t *testing.T) { + m := editorOn(t, "classic") + roles := editRoles() + m.themeRow = len(roles) - 1 + if roles[m.themeRow] != spinnerRole { + t.Fatalf("last row is %q, want the spinner", roles[m.themeRow]) + } + + before := m.themeDraft.Spinner + next, _ := m.onThemeEditKey("right") + m = next.(tuiModel) + if m.themeDraft.Spinner == before { + t.Errorf("spinner is still %q", before) + } + if !ui.KnownSpinner(m.themeDraft.Spinner) { + t.Errorf("spinner = %q, which is not a shape", m.themeDraft.Spinner) + } +} + +// The editor has to say what each role is for and show the colour it holds, +// and the preview under it follows the draft rather than the saved scheme. +func TestTheEditorNamesEveryRoleAndPreviewsTheDraft(t *testing.T) { + m := editorOn(t, "Ben's Theme") + + out := plain(m.viewSettings()) + for _, role := range ui.RoleNames() { + if !strings.Contains(out, role) { + t.Errorf("the editor does not list %q:\n%s", role, out) + } + } + for _, help := range []string{"something worked", "a session about to go", "the row a list is on"} { + if !strings.Contains(out, help) { + t.Errorf("the editor does not say what the roles are for (%q missing)", help) + } + } + if !strings.Contains(out, "preview · Ben's Theme") { + t.Errorf("the editor has no preview of the draft:\n%s", out) + } + // The theme list is out of the way while the editor is open, so the + // section's own cursor cannot move it underneath. + if got := m.sectionRows(); got != 0 { + t.Errorf("sectionRows() = %d while editing, want 0", got) + } + if !strings.Contains(out, "←→ cycle") { + t.Errorf("the hint does not say how to change a colour:\n%s", out) + } + + // esc leaves the editor and goes back to the list. + next, _ := m.onThemeKey("esc") + m = next.(tuiModel) + if m.themeEdit { + t.Error("esc did not close the editor") + } + if got := plain(m.viewSettings()); !strings.Contains(got, "(in use)") { + t.Errorf("the list did not come back:\n%s", got) + } +} + +func TestRenderTheThemeEditor(t *testing.T) { + m := editorOn(t, "Ben's Theme") + t.Log("\n" + m.viewSettings()) +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index d9b2c5d..e3f2519 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -19,6 +19,7 @@ import ( tea "charm.land/bubbletea/v2" "golang.org/x/term" "tyrtop.com/iontk/internal/app" + "tyrtop.com/iontk/internal/clock" "tyrtop.com/iontk/internal/config" "tyrtop.com/iontk/internal/dataview" "tyrtop.com/iontk/internal/ionterm" @@ -123,6 +124,8 @@ const ( formGeometry formFavourite formMacro + formZone + formColour ) const cacheTTL = time.Hour @@ -179,8 +182,7 @@ type tuiModel struct { phase tuiPhase status string - spinner spinner.Model - spinnerName string + spinner spinner.Model cacheState cacheState cacheAt time.Time @@ -236,14 +238,48 @@ type tuiModel struct { setSection int setFocus int + // The theme editor, which is the Theme section with a scheme open rather + // than a page of its own: the preview under it has to stay visible. + themeEdit bool + themeRow int + themeDraft ui.Theme + // Which role the colour form is typing into. + formThemeRole string + // The last IANA zone typed in, so the times row can cycle back to it + // rather than only between UTC and local. + typedZone string // The factory reset asks twice, and this is whether it has been asked once. resetArmed bool all []elementRow sites map[string]string // HA state per element, keyed tsg/eid, filled in behind the list: it is a - // request per cluster and the list must not wait on it. - ha map[string]string + // request per cluster and the list must not wait on it. haCluster is the + // per-cluster side, keyed tsg/cluster: "ok" for one that answered, the + // reason for one that refused, absent for one still in flight -- which is + // what tells a row that will never resolve from one that is loading. + ha map[string]string + haCluster map[string]string + haFailed int + haErr string + // How far through the sweep we are, so the list can say. It is one + // request per cluster at the client's five a second, which on a fleet of + // HA pairs is long enough that "nothing yet" needs to be told apart from + // "stuck". + haTotal int + haDone int + // The background fill's queue per tenant, and which tenants have a + // request out. The queue is paced so an interactive ask -- the rows + // someone just searched for -- is not stuck behind the fleet. + haQueue map[string][]string + haInFlight map[string]bool + // The last sweep's states, read from the element cache at launch and + // shown with an asterisk until this run's sweep replaces them. + haCached map[string]string + // What the tenant-wide query did, for the Debug section: the aggregate is + // unverified against hardware, so which path filled the column is the + // first thing worth knowing about it. + haBulk string matches []int selected map[string]bool @@ -255,6 +291,12 @@ type tuiModel struct { table table.Model filter textinput.Model filterOn bool + // The element list as rebuildRows last worked it out: which columns are + // in it, their widths, and every row's plain values. Kept so a cursor + // move can repaint one row instead of rebuilding the fleet. + listKeep []int + listWidths []int + listValues [][]string cmdInput textinput.Model @@ -312,6 +354,9 @@ type tuiModel struct { viewsErr string data []dataTab dataActive int + // The data pane's own filter, which narrows the page to matching lines. + dataFilter textinput.Model + dataFilterOn bool // What to reopen once a tenant connection or sign-in finishes, the way // resumeSessions does for shells. resumeData []sessionTarget @@ -385,11 +430,15 @@ func Run(ctx context.Context) error { } func newTUIModel(ctx context.Context, store *config.Store, cfg *config.Config) tuiModel { - spinnerPick := cfg.Spinner - if spinnerPick == "" { - spinnerPick = ui.DefaultSpinner - } - s := ui.NewSpinner(spinnerPick) + // Appearance and the display zone are process-wide, and this is where the + // saved settings first exist. None of it can fail the launch: a zone that + // will not load reports itself on the settings page and the times stay + // UTC, a themes.yaml that will not parse leaves the built-ins, and an + // unknown theme name opens in the default. + app.ApplyDisplaySettings(store, cfg) + + // The shape as well as the colour: the theme owns both. + s := ui.NewSpinner(ui.CurrentTheme().Spinner) fi := textinput.New() fi.Prompt = "/" @@ -405,12 +454,12 @@ func newTUIModel(ctx context.Context, store *config.Store, cfg *config.Config) t ci.SetWidth(60) m := tuiModel{ - ctx: ctx, - store: store, - cfg: cfg, - phase: phaseMenu, - spinner: s, - spinnerName: ui.SpinnerName(spinnerPick), + ctx: ctx, + store: store, + cfg: cfg, + phase: phaseMenu, + spinner: s, + selected: map[string]bool{}, filter: fi, cmdInput: ci, @@ -428,6 +477,9 @@ func newTUIModel(ctx context.Context, store *config.Store, cfg *config.Config) t if mode, ok := parseMode(cfg.DefaultMode); ok { m.mode = mode } + if z := cfg.Zone; z != "" && !strings.EqualFold(z, clock.UTC) && !strings.EqualFold(z, clock.Local) { + m.typedZone = z + } // The views load here rather than when the mode is first used: a broken // views.yaml should be reported once, at the start, and never leave the @@ -440,6 +492,7 @@ func newTUIModel(ctx context.Context, store *config.Store, cfg *config.Config) t m.views = views // Nil in a test that only renders: ViewsPath guards the same way. + m.dataFilter = newDataFilter() m.cmds = &config.CommandStore{} if store != nil { cmds, err := store.Commands() @@ -473,8 +526,9 @@ func newTUIModel(ctx context.Context, store *config.Store, cfg *config.Config) t return m } -// tableStyles is the widget's own, with the selected row replaced: its -// default is a 256-colour pink, and only 0-15 survives a terminal theme. +// tableStyles is the widget's own, with the selected row replaced: its default +// is a 256-colour pink that no theme of ours and no terminal's own palette has +// anything to do with. func tableStyles() table.Styles { s := table.DefaultStyles() s.Selected = ui.StyleRow @@ -511,8 +565,12 @@ func (m *tuiModel) moveCursorTo(n int) { return } n = max(0, min(n, len(rows)-1)) - if at := m.table.Cursor(); n >= at { - m.table.MoveDown(n - at) + prev := m.table.Cursor() + if n >= prev { + m.table.MoveDown(n - prev) + // The row the cursor left goes back to its own colour and the row it + // landed on gives it up. Two rows, not the list. + m.repaintCursorRows(prev) return } @@ -523,6 +581,7 @@ func (m *tuiModel) moveCursorTo(n int) { fresh.SetHeight(m.listHeight()) fresh.MoveDown(n) m.table = fresh + m.repaintCursorRows(prev) } func (m *tuiModel) moveCursor(delta int) { @@ -562,6 +621,11 @@ func (m tuiModel) refreshNow() (tea.Model, tea.Cmd) { } m.flags = f m.status = "" + // The failure tally belongs to one sweep. Left standing it would double + // on the next refresh and report a count nobody could account for; the + // per-cluster states stay, so the column does not blank while it refills. + m.haFailed, m.haErr = 0, "" + m.haTotal, m.haDone, m.haBulk = 0, 0, "" return m.refreshActive() } @@ -631,6 +695,18 @@ func watchToken(ch <-chan error) tea.Cmd { func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { + case tea.ColorProfileMsg: + // What the terminal can show, which the renderer downsamples to. Held + // so the theme picker can say a scheme is being approximated rather + // than leaving someone to wonder why nord looks like classic. + ui.SetProfile(msg.Profile) + if ui.ShouldQueryDepth() { + // Bubble Tea upgrades the profile and sends this message again if + // the terminal answers, so this asks once and does not loop. + return m, tea.Batch(tea.RequestCapability("RGB"), tea.RequestCapability("Tc")) + } + return m, nil + case tea.WindowSizeMsg: m.width, m.height = msg.Width, msg.Height m.resFilter.SetWidth(min(26, max(10, m.width-30))) @@ -853,8 +929,13 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.mergeTenantElements(msg.tsg, msg.tenant, msg.elements) // Behind the list, not in front of it: HA is a request per // cluster, and the rows are what someone is looking for. - if pairs := haClusters(msg.elements); len(pairs) > 0 { - ha = loadHA(m.ctx, m.pool.get(msg.tsg), msg.tsg, pairs) + // Started on the clustered elements, not on the (site, cluster) + // pairs: the aggregate asks by element and needs no site, so + // gating the whole sweep on a site id -- which the fallback alone + // needs -- could leave a fleet on "loading" for ever. + if w := haClusters(msg.elements); len(w.elements) > 0 { + m.haTotal += w.clusters() + ha = loadHABulk(m.ctx, m.pool.get(msg.tsg), msg.tsg, w) } } @@ -945,14 +1026,6 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, statsTick() - case tea.MouseWheelMsg: - return m.onWheel(msg) - - case tea.MouseMsg: - // Clicks and motion arrive with cell-motion reporting but address - // nothing, and must not reach the focused input. - return m, nil - case dataLoadedMsg: return m.onDataLoaded(msg) @@ -965,6 +1038,9 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case haStatesMsg: return m.onHAStates(msg) + case haTickMsg: + return m.onHATick(msg.tsg) + case tea.KeyPressMsg: return m.onKey(msg) @@ -979,6 +1055,15 @@ func (m tuiModel) forwardToInput(msg tea.Msg) (tea.Model, tea.Cmd) { if len(m.form) == 0 || m.formCursor >= len(m.form) { return m, nil } + // A block of commands pasted into the macro form is one per line, + // which is the shape the form already has. The field the cursor is in + // takes the first, so pasting into the empty line at the bottom + // appends rather than overwriting what is above it. + if p, ok := msg.(tea.PasteMsg); ok && m.formKind == formMacro && m.formCursor >= 1 { + if cmds := pasteCommands(p.Content); len(cmds) > 1 { + return m.pasteMacroCommands(cmds) + } + } var cmd tea.Cmd m.form[m.formCursor].input, cmd = m.form[m.formCursor].input.Update(msg) return m, cmd @@ -988,13 +1073,21 @@ func (m tuiModel) forwardToInput(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd m.filter, cmd = m.filter.Update(msg) m.applyFilter() - return m, cmd + return m, tea.Batch(cmd, m.askVisibleHA()) } var cmd tea.Cmd m.table, cmd = m.table.Update(msg) return m, cmd case phaseCommand: + // The box is comma separated, so a multi-line paste is a list of + // commands. Rewritten before the input sees it: textinput replaces a + // newline with a space, which would make one long command of the lot. + if p, ok := msg.(tea.PasteMsg); ok { + if cmds := pasteCommands(p.Content); len(cmds) > 1 { + msg = tea.PasteMsg{Content: strings.Join(cmds, ", ")} + } + } var cmd tea.Cmd m.cmdInput, cmd = m.cmdInput.Update(msg) return m, cmd @@ -1075,13 +1168,13 @@ func (m *tuiModel) setElements(els []elementRow) { // siteName looks up a site within its own tenant, since two tenants can use the // same site identifier for different places. -func (m tuiModel) siteName(el elementRow) string { +func (m *tuiModel) siteName(el elementRow) string { return m.record(el).SiteLabel() } // record flattens a row into the values --search matches against, so the // filter here and the one on the command line cannot disagree. -func (m tuiModel) record(el elementRow) search.Record { +func (m *tuiModel) record(el elementRow) search.Record { return search.Of(el.Element, el.TSG, el.Tenant, search.SiteMap(m.sites)) } @@ -1101,74 +1194,324 @@ func (m tuiModel) showHAColumn() bool { return false } -func (m *tuiModel) rebuildRows() { - rows := make([]table.Row, 0, len(m.matches)) - tenants := m.showTenantColumn() - ha := m.showHAColumn() - for _, i := range m.matches { - el := m.all[i] - mark := " " - if m.selected[el.key()] { - mark = "x" +func (m tuiModel) showSerialColumn() bool { + for _, r := range m.all { + if r.Serial != "" { + return true } - name := el.label() - state := "offline" - if el.Connected { - state = "connected" + } + return false +} + +// elementColumn is one column of the element list: how wide it would like to +// be, and how narrow it will go. Sizing to content is why site stopped being +// 22 columns wide for names like "Store 0003" while a serial was cut off at +// 16 -- and why adding sn no longer pushes state off an 80-column terminal. +type elementColumn struct { + title string + min int + max int + // flex is false for a column whose content is a known width: the marks, + // the state words, the HA words. Those are never shaved. + flex bool +} + +// The order asked for: element · site · model · sn · state · ha. The serial +// sits with the model as part of what the box is; state and HA are the pair +// that change. +var elementColumns = []elementColumn{ + {title: "", min: 3, max: 3}, + {title: "", min: 3, max: 3}, + {title: "element", min: 12, max: 28, flex: true}, + {title: "site", min: 8, max: 22, flex: true}, + {title: "model", min: 8, max: 11, flex: true}, + {title: "sn", min: 10, max: 24, flex: true}, + {title: "state", min: 9, max: 9}, + {title: "ha", min: 8, max: 8}, + {title: "tenant", min: 8, max: 20, flex: true}, +} + +// Which of the optional columns are in this list, by index into +// elementColumns. +const ( + colMark = iota + colStatus + colName + colSite + colModel + colSerial + colState + colHA + colTenant +) + +// colGap is what the table puts either side of a cell, which has to be +// counted or the last column is the one that falls off the terminal. +const colGap = 2 + +// fitColumns sizes each column to its widest value, capped at its max, then +// shaves the flexible ones -- widest first, never below their min -- until the +// set fits the terminal. Widest first because the cost of a column losing two +// of its twenty columns is nothing next to one losing two of its ten. +func fitColumns(cols []elementColumn, values [][]string, avail int) []int { + w := make([]int, len(cols)) + for i, c := range cols { + w[i] = max(c.min, ui.Width(c.title)) + for _, row := range values { + if i < len(row) { + w[i] = max(w[i], ui.Width(row[i])) + } } - row := table.Row{ - mark, - m.rowStatus(el), - ui.Truncate(name, 26), - ui.Truncate(m.siteName(el), 22), - ui.Truncate(el.Model, 11), - state, + w[i] = min(w[i], c.max) + } + return shaveColumns(cols, w, avail) +} + +// shaveColumns is the second half, for a caller that measured as it built. +func shaveColumns(cols []elementColumn, w []int, avail int) []int { + total := func() int { + n := 0 + for _, x := range w { + n += x + colGap } - if ha { - row = append(row, m.haCell(el)) + return n + } + for total() > avail { + widest, at := 0, -1 + for i, c := range cols { + if c.flex && w[i] > c.min && w[i] > widest { + widest, at = w[i], i + } } - if tenants { - row = append(row, ui.Truncate(el.Tenant, 20)) + if at < 0 { + break // nothing left to give: the frame clips the rest } - rows = append(rows, row) + w[at]-- } + return w +} - cols := []table.Column{ - {Title: "", Width: 3}, - {Title: "", Width: 3}, - {Title: "element", Width: 26}, - {Title: "site", Width: 22}, - {Title: "model", Width: 11}, - {Title: "state", Width: 9}, +func (m *tuiModel) rebuildRows() { + tenants := m.showTenantColumn() + ha := m.showHAColumn() + serials := m.showSerialColumn() + + keep := []int{colMark, colStatus, colName, colSite, colModel} + if serials { + keep = append(keep, colSerial) } - width := 86 + keep = append(keep, colState) if ha { - cols = append(cols, table.Column{Title: "ha", Width: 8}) - width += 10 + keep = append(keep, colHA) } if tenants { - cols = append(cols, table.Column{Title: "tenant", Width: 20}) - width += 22 + keep = append(keep, colTenant) + } + + cols := make([]elementColumn, 0, len(keep)) + widths := make([]int, len(keep)) + for j, c := range keep { + cols = append(cols, elementColumns[c]) + widths[j] = max(elementColumns[c].min, ui.Width(elementColumns[c].title)) + } + + // Values and widths in one pass, plain, so the columns can be sized to + // what is in them before anything is truncated or coloured -- and so a + // cursor move can repaint one row without working the whole list out + // again. This used to build a map per row, which on a fleet is an + // allocation per element per keystroke of a search. + values := make([][]string, 0, len(m.matches)) + for _, i := range m.matches { + el := m.all[i] + row := make([]string, len(keep)) + for j, c := range keep { + row[j] = m.cellValue(el, c) + if w := ui.Width(row[j]); w > widths[j] { + widths[j] = w + } + } + values = append(values, row) + } + for j, c := range keep { + widths[j] = min(widths[j], elementColumns[c].max) + } + + m.listKeep = keep + m.listValues = values + m.listWidths = shaveColumns(cols, widths, max(20, m.width)) + + // Painted only where the table will render: it draws a window around the + // cursor and nothing else, so styling the other five thousand rows is work + // nobody sees -- and it was happening on every keystroke of a search. + rows := make([]table.Row, len(values)) + lo, hi := m.paintWindow(len(values)) + for n := range values { + if n >= lo && n < hi { + rows[n] = m.listRow(n) + } else { + rows[n] = m.listRowPlain(n) + } } + + tcols := make([]table.Column, 0, len(keep)) + total := 0 + for i, c := range cols { + tcols = append(tcols, table.Column{Title: c.title, Width: m.listWidths[i]}) + total += m.listWidths[i] + colGap + } + // Columns and rows have to agree at every moment: the table re-renders // inside each setter, so changing the column count while the old rows are // still in place indexes past the end of them. Clearing the rows first is // safe but resets the cursor, so only do it when the shape really changed. - if len(m.table.Columns()) != len(cols) { - cursor := m.table.Cursor() + if len(m.table.Columns()) != len(tcols) { + at := m.table.Cursor() m.table.SetRows(nil) - m.table.SetColumns(cols) + m.table.SetColumns(tcols) m.table.SetRows(rows) - if cursor > 0 && cursor < len(rows) { - m.table.SetCursor(cursor) + if at > 0 && at < len(rows) { + m.table.SetCursor(at) } } else { + m.table.SetColumns(tcols) m.table.SetRows(rows) } - m.table.SetWidth(min(m.width, width)) + m.table.SetWidth(min(m.width, total)) m.table.SetHeight(m.listHeight()) } +// cellValue is one cell, plain. A switch rather than a map: this runs for +// every column of every matched row on every keystroke of a search, and a map +// literal per row is an allocation per element. +func (m *tuiModel) cellValue(el elementRow, col int) string { + switch col { + case colMark: + if m.selected[el.key()] { + return "x" + } + return " " + case colStatus: + glyph, _ := m.rowStatusOf(el) + return glyph + case colName: + return el.label() + case colSite: + return m.siteName(el) + case colModel: + return el.Model + case colSerial: + return el.Serial + case colState: + if el.Connected { + return "connected" + } + return "offline" + case colHA: + word, cached := m.haState(el) + if cached { + // The asterisk is part of the text, so it is measured with it. + return word + "*" + } + return word + case colTenant: + return el.Tenant + } + return "" +} + +// listRow paints one row from the values rebuildRows worked out. The row under +// the cursor is painted plain: the table paints its highlight around the whole +// row, and a highlight wrapped around coloured cells ends at the first reset +// inside them -- so on a green line the cursor would all but disappear. One +// rule: the cursor row is the highlight, every other row carries its own +// colour. +func (m *tuiModel) listRow(n int) table.Row { + vals := m.listValues[n] + el := m.all[m.matches[n]] + here := n == m.table.Cursor() + + // A connected element reads green across the whole line, so a fleet's + // state is scannable without reading one column; offline keeps red on the + // state alone, since red across a row reads as an error rather than a + // fact about the appliance. + paint := func(s string) string { return s } + if el.Connected && !here { + paint = func(s string) string { return ui.StyleOK.Render(s) } + } + + row := make(table.Row, 0, len(vals)) + for i, c := range m.listKeep { + v := ui.Truncate(vals[i], m.listWidths[i]) + switch c { + case colStatus, colHA: + // Already styled, and styled per state rather than per row. + v = plainIf(here, v) + case colState: + switch { + case here: + // The highlight is this row's colour. + case el.Connected: + v = paint(v) + default: + v = ui.StyleErr.Render(v) + } + default: + v = paint(v) + } + row = append(row, v) + } + return row +} + +// listRowPlain is the same row with no styling, for the rows outside the +// window: they are never rendered, so painting them is wasted work. +func (m *tuiModel) listRowPlain(n int) table.Row { + vals := m.listValues[n] + row := make(table.Row, len(vals)) + for i := range vals { + row[i] = ui.Truncate(vals[i], m.listWidths[i]) + } + return row +} + +// paintWindow is the rows the table can render: it draws from cursor-height to +// cursor+height, so a margin either side of that is everything that can be +// seen. +func (m *tuiModel) paintWindow(n int) (int, int) { + return m.windowAround(m.table.Cursor(), n) +} + +func (m *tuiModel) windowAround(at, n int) (int, int) { + h := max(8, m.listHeight()) + 4 + return max(0, at-h), min(n, at+h) +} + +// repaintCursorRows paints the two rows a move touched, instead of the whole +// list. Rebuilding every row to move a highlight one line is what made a held +// key and a wheel notch feel stuck on a fleet: the work was proportional to +// the list, not to what changed. +func (m *tuiModel) repaintCursorRows(prev int) { + rows := m.table.Rows() + if len(rows) != len(m.listValues) { + // The list changed under us; the next rebuild will paint it. + return + } + // The window the cursor left goes back to plain and the one it moved into + // is painted -- and only those two ranges are touched. Walking the whole + // slice to find them was O(fleet) on a keypress, which is the mistake + // this file has now made twice. + lo, hi := m.paintWindow(len(rows)) + plo, phi := m.windowAround(prev, len(rows)) + for i := min(lo, plo); i < max(hi, phi); i++ { + if i >= lo && i < hi { + rows[i] = m.listRow(i) + continue + } + rows[i] = m.listRowPlain(i) + } + m.table.SetRows(rows) +} + func (m tuiModel) onEvent(ev sase.Event) (tea.Model, tea.Cmd) { if !ev.Done { m.running = append(m.running, inflight{element: ev.Element, started: time.Now()}) @@ -1236,6 +1579,11 @@ func (m tuiModel) onKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { case phaseSession: return m.onSessionKey(msg, key) case phaseData: + if m.dataFilterOn { + if next, cmd, done := m.onDataFilterKey(msg, key); done { + return next, cmd + } + } return m.onDataKey(key) case phaseMacros: return m.onMacroKey(msg, key) @@ -1316,7 +1664,7 @@ func (m tuiModel) onPickKey(msg tea.KeyPressMsg, key string) (tea.Model, tea.Cmd m.applyFilter() return m, nil } - m.phase = phaseMenu + m.phase = m.listReturn() return m, nil case "up", "down": @@ -1427,6 +1775,9 @@ func (m tuiModel) onPickKey(msg tea.KeyPressMsg, key string) (tea.Model, tea.Cmd case "alt+r": return m.refreshNow() + case "alt+y": + return m.copyElements() + case "alt+u": m.userCursor = 0 m.status = "" @@ -1494,17 +1845,23 @@ func (m tuiModel) onPickKey(msg tea.KeyPressMsg, key string) (tea.Model, tea.Cmd } if m.filterOn { - var cmd tea.Cmd - m.filter, cmd = m.filter.Update(msg) - m.applyFilter() - // Deleting the last character leaves the filter. An empty query with - // the cursor still in it is a page where j and k type letters, which - // is not what anyone who just cleared a search wants. + // Backspace on an **already** empty query is what leaves: the last + // press deletes the "/" itself. Checked before the input sees the key, + // because doing it after meant the press that emptied the query was + // also the one that left -- one too early to carry on typing. if key == "backspace" && m.filter.Value() == "" { m.filterOn = false m.filter.Blur() + m.applyFilter() + return m, nil } - return m, cmd + var cmd tea.Cmd + m.filter, cmd = m.filter.Update(msg) + m.applyFilter() + // The rows a search just brought on screen are asked about now rather + // than when the background fill reaches them. That is what the pacing + // leaves room for. + return m, tea.Batch(cmd, m.askVisibleHA()) } if key == "alt+t" { @@ -1512,10 +1869,11 @@ func (m tuiModel) onPickKey(msg tea.KeyPressMsg, key string) (tea.Model, tea.Cmd } if key == "alt+w" { // alt+w closes what you are in. On the element list that is the list - // itself, so it goes back to the menu; only the menu's alt+w quits. - // Any sessions keep running and are still reachable with alt+t. + // itself, so it goes back where the list was opened from; only the + // menu's alt+w quits. Any sessions keep running and are still + // reachable with alt+t. m.status = "" - m.phase = phaseMenu + m.phase = m.listReturn() return m, nil } if next, handled := m.onTabKey(key); handled { @@ -1882,9 +2240,13 @@ func (m tuiModel) viewPicking() string { if len(m.lastCleared) > 0 { clear = "alt+z undo clear" } - hint := fmt.Sprintf("↑↓/jk move · / filter · space select · alt+s selected · %s · alt+r refresh · alt+m mode · alt+u user · %s · esc/q/alt+w menu", clear, act) + back := "menu" + if m.listReturn() == phaseMacros { + back = "macros" + } + hint := fmt.Sprintf("↑↓/jk move · / filter · space select · alt+s selected · %s · alt+r refresh · alt+m mode · %s id · %s · esc/q/alt+w %s", clear, copyKeyHint, act, back) if m.showSelected { - hint = fmt.Sprintf("↑↓/jk move · alt+s all · alt+c clear · space unselect · %s · esc/q/alt+w menu", act) + hint = fmt.Sprintf("↑↓/jk move · alt+s all · alt+c clear · space unselect · %s · esc/q/alt+w %s", act, back) } if m.filterOn { // With one match left, enter does the thing rather than just @@ -1971,3 +2333,12 @@ func (m tuiModel) viewRunning() string { return b.String() } + +// plainIf strips a cell's own styling on the row the cursor is on, so the +// table's highlight is the only thing colouring it. +func plainIf(here bool, cell string) string { + if here { + return ui.Plain(cell) + } + return cell +} diff --git a/internal/tui/tuimenu.go b/internal/tui/tuimenu.go index 4185f6c..fe7adeb 100644 --- a/internal/tui/tuimenu.go +++ b/internal/tui/tuimenu.go @@ -579,6 +579,12 @@ func (m tuiModel) submitForm() (tea.Model, tea.Cmd) { m.phase = m.formReturn return m, nil + case formZone: + return m.submitZone() + + case formColour: + return m.submitColour() + case formFavourite: return m.submitFavourite() diff --git a/internal/tui/tuimenu_test.go b/internal/tui/tuimenu_test.go index 1131958..d2409aa 100644 --- a/internal/tui/tuimenu_test.go +++ b/internal/tui/tuimenu_test.go @@ -328,10 +328,9 @@ func TestRenderSettings(t *testing.T) { {Name: "Lab", TSGID: "9876543210"}, }, }, - spinnerName: ui.DefaultSpinner, - spinner: ui.NewSpinner(ui.DefaultSpinner), - width: 80, - height: 24, + spinner: ui.NewSpinner(ui.DefaultSpinner), + width: 80, + height: 24, } t.Log("\n" + m.viewSettings()) } diff --git a/internal/ui/modal.go b/internal/ui/modal.go index 3ed2c06..64bdbad 100644 --- a/internal/ui/modal.go +++ b/internal/ui/modal.go @@ -30,7 +30,7 @@ func Modal(lines []string, width int) string { box := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("3")). + BorderForeground(BorderColor()). Padding(0, 1). Render(b.String()) diff --git a/internal/ui/spinners.go b/internal/ui/spinners.go index c1f2f0c..a62d726 100644 --- a/internal/ui/spinners.go +++ b/internal/ui/spinners.go @@ -1,9 +1,6 @@ package ui -import ( - "charm.land/bubbles/v2/spinner" - "charm.land/lipgloss/v2" -) +import "charm.land/bubbles/v2/spinner" type spinnerChoice struct { name string @@ -40,13 +37,39 @@ func SpinnerName(name string) string { return spinnerChoices[spinnerIndex(name)].name } -func NextSpinnerName(name string) string { - return spinnerChoices[(spinnerIndex(name)+1)%len(spinnerChoices)].name +// KnownSpinner and SpinnerNames are for validating a theme's shape at load: +// spinnerIndex falls back to the default, which in a config file would look +// like the name being ignored. +func KnownSpinner(name string) bool { + for _, c := range spinnerChoices { + if c.name == name { + return true + } + } + return false +} + +func SpinnerNames() []string { + out := make([]string, 0, len(spinnerChoices)) + for _, c := range spinnerChoices { + out = append(out, c.name) + } + return out } func NewSpinner(name string) spinner.Model { s := spinner.New() s.Spinner = spinnerChoices[spinnerIndex(name)].spin - s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("5")) + s.Style = StyleSpinner return s } + +// SpinnerFrame is one frame of a shape, for a preview that has to show a +// spinner without running one. +func SpinnerFrame(name string) string { + frames := spinnerChoices[spinnerIndex(name)].spin.Frames + if len(frames) == 0 { + return "" + } + return frames[0] +} diff --git a/internal/ui/spinners_test.go b/internal/ui/spinners_test.go index ba9e820..bcd42ca 100644 --- a/internal/ui/spinners_test.go +++ b/internal/ui/spinners_test.go @@ -2,20 +2,32 @@ package ui import "testing" -func TestSpinnerCycle(t *testing.T) { +// A theme names its spinner, so a name that is not one has to be catchable at +// load: SpinnerName itself falls back to the default, which in a config file +// would look like the name being ignored. +func TestSpinnerNamesAreCheckable(t *testing.T) { if got := SpinnerName("nonsense"); got != DefaultSpinner { t.Errorf("unknown name = %q, want %q", got, DefaultSpinner) } + if KnownSpinner("nonsense") { + t.Error("KnownSpinner() accepted a shape that does not exist") + } + if !KnownSpinner(DefaultSpinner) { + t.Errorf("KnownSpinner(%q) = false", DefaultSpinner) + } + + names := SpinnerNames() + if len(names) != len(spinnerChoices) { + t.Fatalf("SpinnerNames() = %d, want one per shape", len(names)) + } seen := map[string]bool{} - name := DefaultSpinner - for range spinnerChoices { - if seen[name] { - t.Fatalf("cycle repeated %q early", name) + for _, n := range names { + if seen[n] { + t.Errorf("two shapes are called %q", n) + } + seen[n] = true + if !KnownSpinner(n) { + t.Errorf("SpinnerNames() offered %q, which KnownSpinner rejects", n) } - seen[name] = true - name = NextSpinnerName(name) - } - if name != DefaultSpinner { - t.Errorf("cycle ended on %q, want to wrap to %q", name, DefaultSpinner) } } diff --git a/internal/ui/styles.go b/internal/ui/styles.go deleted file mode 100644 index d18c309..0000000 --- a/internal/ui/styles.go +++ /dev/null @@ -1,26 +0,0 @@ -package ui - -import "charm.land/lipgloss/v2" - -var ( - StyleOK = lipgloss.NewStyle().Foreground(lipgloss.Color("2")) - StyleErr = lipgloss.NewStyle().Foreground(lipgloss.Color("1")) - StyleWarn = lipgloss.NewStyle().Foreground(lipgloss.Color("3")) - StyleCmd = lipgloss.NewStyle().Foreground(lipgloss.Color("6")).Bold(true) - StyleElement = lipgloss.NewStyle().Foreground(lipgloss.Color("4")) - StyleDim = lipgloss.NewStyle().Faint(true) - // The nearest thing to orange in the 0-15 range, for a session about to go. - StyleHot = lipgloss.NewStyle().Foreground(lipgloss.Color("9")) - // StyleRow is the highlight on the row a list is on, and on the line an - // open result is scrolled to. Defined here rather than taken from the - // table widget's default, which is a 256-colour pink a terminal theme - // cannot remap. - StyleRow = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("5")) - // StyleGhost is a line that is not there yet: the next command a macro - // would take, or the macro that would be made, before anyone has typed - // anything. - StyleGhost = lipgloss.NewStyle().Faint(true).Italic(true) - - MarkCheck = StyleOK.SetString("✓") - MarkCross = StyleErr.SetString("✗") -) diff --git a/internal/ui/theme.go b/internal/ui/theme.go new file mode 100644 index 0000000..c780ce5 --- /dev/null +++ b/internal/ui/theme.go @@ -0,0 +1,591 @@ +package ui + +import ( + "embed" + "fmt" + "image/color" + "os" + "path/filepath" + "strconv" + "strings" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/colorprofile" + "gopkg.in/yaml.v3" +) + +//go:embed themes.yaml +var builtinFS embed.FS + +const ThemesFileName = "themes.yaml" + +// colour per role, not per widget: nothing here asks for "blue" +type Theme struct { + Name string `yaml:"name"` + Blurb string `yaml:"blurb"` + + OK string `yaml:"ok"` + Err string `yaml:"err"` + Warn string `yaml:"warn"` + Cmd string `yaml:"cmd"` + Element string `yaml:"element"` + Row string `yaml:"row"` + Hot string `yaml:"hot"` + Border string `yaml:"border"` + Divider string `yaml:"divider"` + // the spinner is part of the scheme too + Spinner string `yaml:"spinner"` + SpinnerColor string `yaml:"spinner_color"` + Tabs []string `yaml:"tabs"` + + // attributes instead of colour: roles still have to differ + Mono bool `yaml:"mono"` +} + +// Dim and Ghost are attributes: a fixed grey is wrong on half of +// the backgrounds there are. +type Styles struct { + OK lipgloss.Style + Err lipgloss.Style + Warn lipgloss.Style + Cmd lipgloss.Style + Element lipgloss.Style + Hot lipgloss.Style + Row lipgloss.Style + Dim lipgloss.Style + Ghost lipgloss.Style + Divider lipgloss.Style + Spinner lipgloss.Style + + Border color.Color + Tabs []lipgloss.Style +} + +// something has to draw if themes.yaml breaks +var fallback = Theme{ + Name: "classic", + Blurb: "your terminal's own 16 colours", + OK: "2", Err: "1", Warn: "3", Cmd: "6", Element: "4", + Row: "5", Hot: "9", Border: "3", Divider: "8", + Spinner: DefaultSpinner, SpinnerColor: "5", + Tabs: []string{"7", "4", "6", "5", "12", "14", "13"}, +} + +var ( + loaded []Theme + // only ever a themes.yaml that would not parse + loadErr string +) + +type themeSet struct { + Themes []Theme `yaml:"themes"` +} + +// picker order, default first +func Themes() []Theme { + if loaded == nil { + set, err := builtin() + if err != nil { + loaded, loadErr = []Theme{fallback}, err.Error() + } else { + loaded = set + } + } + return loaded +} + +func ThemesErr() string { + Themes() + return loadErr +} + +func builtin() ([]Theme, error) { + b, err := builtinFS.ReadFile(ThemesFileName) + if err != nil { + return nil, fmt.Errorf("read built-in themes: %w", err) + } + set, err := ParseThemes(b) + if err != nil { + return nil, fmt.Errorf("built-in themes: %w", err) + } + return set, nil +} + +// a broken file leaves the built-ins; a missing one is normal +func LoadThemes(path string) error { + set, err := builtin() + if err != nil { + loaded, loadErr = []Theme{fallback}, err.Error() + return err + } + loaded, loadErr = set, "" + + if path == "" { + return nil + } + b, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + loadErr = fmt.Sprintf("read %s: %s", filepath.Base(path), err) + return fmt.Errorf("read %s: %w", path, err) + } + user, err := ParseThemes(b) + if err != nil { + loadErr = fmt.Sprintf("%s: %s", filepath.Base(path), err) + return fmt.Errorf("%s: %w", filepath.Base(path), err) + } + + for _, t := range user { + if i := indexOf(loaded, t.Name); i >= 0 { + loaded[i] = t + continue + } + loaded = append(loaded, t) + } + return nil +} + +func ParseThemes(b []byte) ([]Theme, error) { + var set themeSet + dec := yaml.NewDecoder(strings.NewReader(string(b))) + // a misspelled key otherwise looks like the scheme not working + dec.KnownFields(true) + if err := dec.Decode(&set); err != nil { + return nil, fmt.Errorf("parse: %w", err) + } + for _, t := range set.Themes { + if err := t.validate(); err != nil { + return nil, err + } + } + return set.Themes, nil +} + +// lipgloss renders an unparseable colour as nothing, not as wrong +func (t Theme) validate() error { + if strings.TrimSpace(t.Name) == "" { + return fmt.Errorf("a theme needs a name") + } + for _, role := range [][2]string{ + {"ok", t.OK}, {"err", t.Err}, {"warn", t.Warn}, {"cmd", t.Cmd}, + {"element", t.Element}, {"row", t.Row}, {"hot", t.Hot}, + {"border", t.Border}, {"divider", t.Divider}, + {"spinner_color", t.SpinnerColor}, + } { + if err := checkColour(role[1]); err != nil { + return fmt.Errorf("theme %s: %s: %w", t.Name, role[0], err) + } + } + for i, c := range t.Tabs { + if err := checkColour(c); err != nil { + return fmt.Errorf("theme %s: tab %d: %w", t.Name, i+1, err) + } + } + if t.Spinner != "" && !KnownSpinner(t.Spinner) { + return fmt.Errorf("theme %s: spinner %q is not one of %s", + t.Name, t.Spinner, strings.Join(SpinnerNames(), ", ")) + } + return nil +} + +func checkColour(spec string) error { + switch { + case spec == "": + return nil + case strings.HasPrefix(spec, "#"): + if len(spec) != 4 && len(spec) != 7 { + return fmt.Errorf("%q is not #rgb or #rrggbb", spec) + } + if _, err := strconv.ParseUint(spec[1:], 16, 32); err != nil { + return fmt.Errorf("%q is not a hex colour", spec) + } + return nil + } + n, err := strconv.Atoi(spec) + if err != nil || n < 0 || n > 255 { + return fmt.Errorf("%q is neither an ANSI index 0-255 nor a #rrggbb", spec) + } + return nil +} + +func indexOf(set []Theme, name string) int { + for i, t := range set { + if strings.EqualFold(t.Name, name) { + return i + } + } + return -1 +} + +func Default() Theme { + set := Themes() + if i := indexOf(set, fallback.Name); i >= 0 { + return set[i] + } + if len(set) > 0 { + return set[0] + } + return fallback +} + +func ThemeByName(name string) (Theme, bool) { + set := Themes() + if i := indexOf(set, name); i >= 0 { + return set[i], true + } + return Default(), false +} + +func (t Theme) fg(spec string) lipgloss.Style { + if spec == "" { + return lipgloss.NewStyle() + } + return lipgloss.NewStyle().Foreground(lipgloss.Color(spec)) +} + +// built once, so no view asks the theme anything at render time +func (t Theme) Styles() Styles { + s := Styles{ + Dim: lipgloss.NewStyle().Faint(true), + Ghost: lipgloss.NewStyle().Faint(true).Italic(true), + } + + if t.Mono { + s.OK = lipgloss.NewStyle() + s.Err = lipgloss.NewStyle().Bold(true) + s.Warn = lipgloss.NewStyle().Italic(true) + s.Cmd = lipgloss.NewStyle().Bold(true) + s.Element = lipgloss.NewStyle() + s.Hot = lipgloss.NewStyle().Bold(true).Italic(true) + s.Row = lipgloss.NewStyle().Reverse(true) + s.Divider = lipgloss.NewStyle().Faint(true) + s.Spinner = lipgloss.NewStyle() + s.Border = lipgloss.NoColor{} + // StyleTab reverses the active one + s.Tabs = []lipgloss.Style{lipgloss.NewStyle()} + return s + } + + s.OK = t.fg(t.OK) + s.Err = t.fg(t.Err) + s.Warn = t.fg(t.Warn) + s.Cmd = t.fg(t.Cmd).Bold(true) + s.Element = t.fg(t.Element) + s.Hot = t.fg(t.Hot) + s.Row = t.fg(t.Row).Bold(true) + s.Divider = t.fg(t.Divider) + s.Spinner = t.fg(t.SpinnerColor) + s.Border = lipgloss.Color(t.Border) + for _, c := range t.Tabs { + s.Tabs = append(s.Tabs, t.fg(c)) + } + if len(s.Tabs) == 0 { + s.Tabs = []lipgloss.Style{lipgloss.NewStyle()} + } + return s +} + +// outside ANSI 0-15, so it depends on the terminal's depth +func (t Theme) Deep() bool { + for _, c := range append([]string{t.OK, t.Err, t.Warn, t.Cmd, t.Element, + t.Row, t.Hot, t.Border, t.Divider, t.SpinnerColor}, t.Tabs...) { + if strings.HasPrefix(c, "#") { + return true + } + if n, err := strconv.Atoi(c); err == nil && n >= 16 { + return true + } + } + return false +} + +func (t Theme) Swatch() string { + s := t.Styles() + if t.Mono { + return s.Cmd.Render("bold") + " " + s.Dim.Render("dim") + " " + s.Row.Render(" row ") + } + var b strings.Builder + for _, st := range []lipgloss.Style{s.Element, s.Cmd, s.OK, s.Warn, s.Err, s.Hot, s.Row} { + b.WriteString(st.Render("██")) + } + return b.String() +} + +// One process, one theme, so package state rather than threaded through +// every view. Written from Update, read by the next render. +var ( + current = fallback + styles = fallback.Styles() + StyleOK = styles.OK + StyleErr = styles.Err + // StyleWarn is a thing worth noticing that is not a failure: an offline + // ION next to a live session, a tenant that keeps refusing. + StyleWarn = styles.Warn + StyleCmd = styles.Cmd + StyleElement = styles.Element + StyleDim = styles.Dim + // StyleHot is a session about to go. + StyleHot = styles.Hot + // StyleRow is the highlight on the row a list is on, and on the line an + // open result is scrolled to. Defined here rather than taken from the + // table widget's default, which is a 256-colour pink a terminal theme + // cannot remap. + StyleRow = styles.Row + // StyleGhost is a line that is not there yet: the next command a macro + // would take, or the macro that would be made, before anyone has typed + // anything. + StyleGhost = styles.Ghost + StyleDivider = styles.Divider + // Set into the bubbles widget when a spinner is made, so a theme change + // has to make it again. + StyleSpinner = styles.Spinner + + MarkCheck = StyleOK.SetString("✓") + MarkCross = StyleErr.SetString("✗") +) + +// unknown name lands on the default: a stale preference still draws +func UseTheme(name string) bool { + t, ok := ThemeByName(name) + Apply(t) + return ok +} + +func Apply(t Theme) { + current = t + styles = t.Styles() + StyleOK, StyleErr, StyleWarn = styles.OK, styles.Err, styles.Warn + StyleCmd, StyleElement, StyleHot = styles.Cmd, styles.Element, styles.Hot + StyleRow, StyleDim, StyleGhost = styles.Row, styles.Dim, styles.Ghost + StyleDivider, StyleSpinner = styles.Divider, styles.Spinner + MarkCheck = StyleOK.SetString("✓") + MarkCross = StyleErr.SetString("✗") +} + +func CurrentTheme() Theme { return current } + +// reversed, not recoloured: legible in a scheme with few colours +func StyleTab(i int, active bool) lipgloss.Style { + s := styles.Tabs[i%len(styles.Tabs)] + if active { + return s.Reverse(true).Bold(true) + } + return s +} + +func BorderColor() color.Color { return styles.Border } + +// the renderer downsamples for us; this only says a scheme is approximated +var profile colorprofile.Profile + +func SetProfile(p colorprofile.Profile) { profile = p } + +func Profile() colorprofile.Profile { return profile } + +func ProfileName() string { + switch profile { + case colorprofile.TrueColor: + return "truecolor" + case colorprofile.ANSI256: + return "256 colours" + case colorprofile.ANSI: + return "16 colours" + case colorprofile.ASCII, colorprofile.NoTTY: + return "no colour" + } + return "" +} + +// XTGETTCAP corrupts the frame on some terminals, so only a deep scheme asks +func ShouldQueryDepth() bool { + return current.Deep() && profile != colorprofile.Unknown && profile < colorprofile.TrueColor +} + +// empty unless a deep scheme is being approximated here +func ThemeNote(t Theme) string { + if !t.Deep() || profile == colorprofile.Unknown || profile >= colorprofile.TrueColor { + return "" + } + if n := ProfileName(); n != "" { + return "this terminal reports " + n + ", so these are approximated" + } + return "" +} + +func RoleNames() []string { + return []string{"ok", "err", "warn", "cmd", "element", "row", "hot", "border", "divider", "spinner_color"} +} + +func (t Theme) Role(name string) string { + switch name { + case "ok": + return t.OK + case "err": + return t.Err + case "warn": + return t.Warn + case "cmd": + return t.Cmd + case "element": + return t.Element + case "row": + return t.Row + case "hot": + return t.Hot + case "border": + return t.Border + case "divider": + return t.Divider + case "spinner_color": + return t.SpinnerColor + } + return "" +} + +// an unparseable colour would store as invisible +func (t *Theme) SetRole(name, spec string) error { + spec = strings.TrimSpace(spec) + if err := checkColour(spec); err != nil { + return err + } + switch name { + case "ok": + t.OK = spec + case "err": + t.Err = spec + case "warn": + t.Warn = spec + case "cmd": + t.Cmd = spec + case "element": + t.Element = spec + case "row": + t.Row = spec + case "hot": + t.Hot = spec + case "border": + t.Border = spec + case "divider": + t.Divider = spec + case "spinner_color": + t.SpinnerColor = spec + default: + return fmt.Errorf("no colour called %q", name) + } + return nil +} + +// what left/right cycles through +func Palette() []string { + out := make([]string, 0, 64) + seen := map[string]bool{} + add := func(c string) { + if c == "" || seen[c] { + return + } + seen[c] = true + out = append(out, c) + } + for i := range 16 { + add(strconv.Itoa(i)) + } + for _, t := range Themes() { + for _, r := range RoleNames() { + add(t.Role(r)) + } + for _, c := range t.Tabs { + add(c) + } + } + return out +} + +// a typed colour is not in the palette, so it starts at the top +func NextInPalette(spec string, delta int) string { + p := Palette() + at := 0 + for i, c := range p { + if c == spec { + at = i + break + } + } + return p[((at+delta)%len(p)+len(p))%len(p)] +} + +func NextSpinner(name string, delta int) string { + names := SpinnerNames() + at := 0 + for i, n := range names { + if n == name { + at = i + break + } + } + return names[((at+delta)%len(names)+len(names))%len(names)] +} + +// editing a built-in writes an override; the file's comments do not survive +func SaveUserTheme(path string, t Theme) error { + if path == "" { + return fmt.Errorf("no themes file to write to") + } + if err := t.validate(); err != nil { + return err + } + + set := themeSet{} + if b, err := os.ReadFile(path); err == nil { + existing, perr := ParseThemes(b) + if perr != nil { + // the file is the user's, maybe half-written by hand + return fmt.Errorf("%s will not parse, so it was left alone: %w", filepath.Base(path), perr) + } + set.Themes = existing + } else if !os.IsNotExist(err) { + return fmt.Errorf("read %s: %w", path, err) + } + + if i := indexOf(set.Themes, t.Name); i >= 0 { + set.Themes[i] = t + } else { + set.Themes = append(set.Themes, t) + } + + b, err := yaml.Marshal(set) + if err != nil { + return fmt.Errorf("encode themes: %w", err) + } + header := "# Written by iontk's theme editor. A scheme here replaces the built-in\n" + + "# one of the same name; anything not set falls back to no colour.\n" + // the editor writes on every keypress + tmp := path + ".tmp" + if err := os.WriteFile(tmp, append([]byte(header), b...), 0o600); err != nil { + return fmt.Errorf("write %s: %w", tmp, err) + } + if err := os.Rename(tmp, path); err != nil { + return fmt.Errorf("write %s: %w", path, err) + } + return LoadThemes(path) +} + +// the editor's way back from an override +func BuiltinTheme(name string) (Theme, bool) { + set, err := builtin() + if err != nil { + return fallback, strings.EqualFold(name, fallback.Name) + } + if i := indexOf(set, name); i >= 0 { + return set[i], true + } + return Theme{}, false +} + +func (t Theme) RoleStyle(role string) lipgloss.Style { + if t.Mono { + return t.Styles().Dim + } + return t.fg(t.Role(role)) +} diff --git a/internal/ui/theme_test.go b/internal/ui/theme_test.go new file mode 100644 index 0000000..560e237 --- /dev/null +++ b/internal/ui/theme_test.go @@ -0,0 +1,305 @@ +package ui + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/charmbracelet/colorprofile" +) + +// restore puts the shipped set and the default scheme back. Both are process +// state, so a test that left one changed would decide what every later test +// rendered. +func restore(t *testing.T) { + t.Helper() + t.Cleanup(func() { + loaded, loadErr = nil, "" + Apply(Default()) + }) +} + +func TestTheShippedThemesParse(t *testing.T) { + restore(t) + loaded, loadErr = nil, "" + + set := Themes() + if err := ThemesErr(); err != "" { + t.Fatalf("themes.yaml: %s", err) + } + if len(set) < 8 { + t.Errorf("%d themes shipped, want the vim-flavoured set too", len(set)) + } + for _, want := range []string{"classic", "mono", "nord", "desert", "morning", "Ben's Theme"} { + if _, ok := ThemeByName(want); !ok { + t.Errorf("no theme called %q", want) + } + } + if Default().Name != "classic" { + t.Errorf("Default() = %q, want classic", Default().Name) + } +} + +func TestEveryThemeIsUsable(t *testing.T) { + seen := map[string]bool{} + for _, th := range Themes() { + if th.Name == "" || th.Blurb == "" { + t.Errorf("theme %q has no name or no blurb, so the picker cannot list it", th.Name) + } + if seen[strings.ToLower(th.Name)] { + t.Errorf("two themes are called %q", th.Name) + } + seen[strings.ToLower(th.Name)] = true + + // Every scheme names its spinner: the shape is part of the theme, and + // one that named nothing would quietly be somebody else's default. + if !KnownSpinner(th.Spinner) { + t.Errorf("%s names spinner %q, which is not a shape", th.Name, th.Spinner) + } + + s := th.Styles() + if len(s.Tabs) == 0 { + t.Errorf("%s has no tab styles, so StyleTab would divide by zero", th.Name) + } + if th.Swatch() == "" { + t.Errorf("%s renders no swatch, so the picker shows nothing", th.Name) + } + if s.Row.Render("x") == "x" && !th.Mono { + t.Errorf("%s leaves the row highlight unstyled", th.Name) + } + } +} + +// Tabs are identity, not status: a tab coloured like an error reads as a failed +// one. The ANSI check is by index; the general one is that no tab may reuse the +// scheme's own ok, err or warn, which catches a hex palette too. +func TestTabPalettesCarryNoStatusMeaning(t *testing.T) { + reserved := map[string]string{ + "1": "red", "2": "green", "3": "yellow", + "9": "bright red", "10": "bright green", "11": "bright yellow", + } + for _, th := range Themes() { + if th.Mono { + continue // nothing to confuse: the active tab is reversed, not coloured + } + if len(th.Tabs) < 4 { + t.Errorf("%s has %d tab colours, want enough to tell tabs apart", th.Name, len(th.Tabs)) + } + status := map[string]string{th.OK: "ok", th.Err: "err", th.Warn: "warn"} + for _, c := range th.Tabs { + if name, bad := reserved[c]; bad { + t.Errorf("%s uses %s (%s) for a tab, which carries status meaning", th.Name, c, name) + } + if role, clash := status[c]; clash { + t.Errorf("%s uses its %s colour (%s) for a tab", th.Name, role, c) + } + } + } +} + +// The default has to be the one scheme that cannot clash with a terminal's own +// palette, which is the only reason the 0-15 rule existed. +func TestOnlyAnOptedInSchemeLeavesTheAnsiRange(t *testing.T) { + if Default().Deep() { + t.Errorf("the default scheme %q leaves ANSI 0-15", Default().Name) + } + for _, name := range []string{"vivid", "mono"} { + th, _ := ThemeByName(name) + if th.Deep() { + t.Errorf("%s leaves ANSI 0-15, so it depends on the terminal's depth", name) + } + } + for _, name := range []string{"nord", "Ben's Theme"} { + th, _ := ThemeByName(name) + if !th.Deep() { + t.Errorf("%s is hex and should report itself as deep", name) + } + } +} + +// A themes.yaml in the config root is the whole point of the format: it adds +// schemes, and replaces a built-in by name. +func TestAUserFileAddsAndReplaces(t *testing.T) { + restore(t) + path := filepath.Join(t.TempDir(), ThemesFileName) + err := os.WriteFile(path, []byte(` +themes: + - name: classic + blurb: mine, not yours + ok: "4" + spinner: monkey + - name: office + blurb: for the projector + ok: "#00ff00" + spinner: globe +`), 0o600) + if err != nil { + t.Fatal(err) + } + if err := LoadThemes(path); err != nil { + t.Fatalf("LoadThemes() error = %v", err) + } + + got, ok := ThemeByName("classic") + if !ok || got.Blurb != "mine, not yours" { + t.Errorf("classic = %+v, want the file's own", got) + } + if got.Spinner != "monkey" { + t.Errorf("classic spinner = %q, want the file's monkey", got.Spinner) + } + if _, ok := ThemeByName("office"); !ok { + t.Error("the file's new scheme was not added") + } + // Replacing one must not drop the rest. + if _, ok := ThemeByName("nord"); !ok { + t.Error("a user file replaced the whole built-in set") + } + if ThemesErr() != "" { + t.Errorf("ThemesErr() = %q for a file that parsed", ThemesErr()) + } +} + +// A scheme that will not load has to say why: lipgloss reads a colour it +// cannot parse as no colour, so the failure would otherwise be invisible text. +func TestABrokenThemeFileIsRefusedAndTheBuiltinsSurvive(t *testing.T) { + tests := []struct { + name string + yaml string + want string + }{ + {"a colour that is not one", "themes:\n - name: x\n ok: \"blue\"\n", "ANSI index"}, + {"an index past 255", "themes:\n - name: x\n ok: \"300\"\n", "ANSI index"}, + {"half a hex colour", "themes:\n - name: x\n ok: \"#12345\"\n", "#rrggbb"}, + {"a spinner nobody has", "themes:\n - name: x\n spinner: wheel\n", "spinner"}, + {"a misspelled key", "themes:\n - name: x\n colour: \"2\"\n", "field colour"}, + {"a theme with no name", "themes:\n - blurb: nameless\n", "needs a name"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + restore(t) + path := filepath.Join(t.TempDir(), ThemesFileName) + if err := os.WriteFile(path, []byte(tc.yaml), 0o600); err != nil { + t.Fatal(err) + } + + err := LoadThemes(path) + if err == nil { + t.Fatal("LoadThemes() accepted it") + } + if !strings.Contains(err.Error(), tc.want) { + t.Errorf("error = %q, want it to mention %q", err, tc.want) + } + if ThemesErr() == "" { + t.Error("ThemesErr() is empty, so the settings page would not say") + } + if _, ok := ThemeByName("classic"); !ok { + t.Error("a broken file took the built-ins with it") + } + }) + } +} + +// A missing file is the normal case, not an error. +func TestNoUserFileIsNotAnError(t *testing.T) { + restore(t) + if err := LoadThemes(filepath.Join(t.TempDir(), "nothing.yaml")); err != nil { + t.Errorf("LoadThemes() error = %v for a file that is not there", err) + } + if ThemesErr() != "" { + t.Errorf("ThemesErr() = %q", ThemesErr()) + } +} + +func TestUseThemeFallsBackToTheDefault(t *testing.T) { + restore(t) + if !UseTheme("nord") { + t.Error("UseTheme(nord) reported the name as unknown") + } + if CurrentTheme().Name != "nord" { + t.Errorf("CurrentTheme() = %q, want nord", CurrentTheme().Name) + } + if UseTheme("a scheme from a later version") { + t.Error("UseTheme() claimed to know a name it does not") + } + if CurrentTheme().Name != Default().Name { + t.Errorf("CurrentTheme() = %q, want the default after an unknown name", CurrentTheme().Name) + } + // Names are matched the way a hand-edited config spells them. + if !UseTheme("BEN'S THEME") { + t.Error("UseTheme() is case-sensitive, which a hand-written config will not be") + } +} + +// Apply has to rebuild everything derived from the palette, the marks +// included: they were built from StyleOK and StyleErr at package load. +func TestApplyRestylesTheMarks(t *testing.T) { + restore(t) + mono, _ := ThemeByName("mono") + Apply(mono) + if got := MarkCheck.String(); strings.Contains(got, "[3") { + t.Errorf("MarkCheck = %q, want no colour under mono", got) + } + nord, _ := ThemeByName("nord") + Apply(nord) + // nord's green is #a3be8c, which lands on an RGB escape. + if got := MarkCheck.String(); !strings.Contains(got, "163") { + t.Errorf("MarkCheck = %q, want nord's green", got) + } +} + +// Mono is not the palette with the colours taken out: a role that was a +// colour has to become an attribute or it stops being distinguishable. +func TestMonoKeepsRolesApartWithoutColour(t *testing.T) { + mono, _ := ThemeByName("mono") + s := mono.Styles() + if s.Err.Render("x") == s.Warn.Render("x") { + t.Error("mono renders errors and warnings identically") + } + if s.Cmd.Render("x") == s.Element.Render("x") { + t.Error("mono renders commands and element names identically") + } + for _, st := range []string{s.Err.Render("x"), s.Cmd.Render("x"), s.Row.Render("x")} { + if !strings.Contains(st, "\x1b[") { + t.Errorf("mono rendered %q with no attribute at all", st) + } + } +} + +// The renderer downsamples what we emit, so the only thing to do about a +// shallow terminal is say that a deep scheme is being approximated. +func TestThemeNoteOnlyWarnsWhenItMatters(t *testing.T) { + t.Cleanup(func() { SetProfile(colorprofile.Unknown) }) + nord, _ := ThemeByName("nord") + + SetProfile(colorprofile.Unknown) + if got := ThemeNote(nord); got != "" { + t.Errorf("ThemeNote() = %q before the terminal has said anything", got) + } + + SetProfile(colorprofile.TrueColor) + if got := ThemeNote(nord); got != "" { + t.Errorf("ThemeNote() = %q on a terminal that can show it", got) + } + + SetProfile(colorprofile.ANSI256) + if got := ThemeNote(nord); got == "" { + t.Error("ThemeNote() says nothing about nord on a 256-colour terminal") + } + if got := ThemeNote(Default()); got != "" { + t.Errorf("ThemeNote() = %q for a 0-15 scheme, which needs no warning", got) + } + if got := ProfileName(); got != "256 colours" { + t.Errorf("ProfileName() = %q", got) + } +} + +func TestStyleTabMarksTheActiveOne(t *testing.T) { + restore(t) + Apply(Default()) + if StyleTab(0, true).Render("x") == StyleTab(0, false).Render("x") { + t.Error("the active tab renders the same as an inactive one") + } + // Past the end of the palette rather than out of range: tabs wrap. + StyleTab(len(styles.Tabs)+3, false) +} diff --git a/internal/ui/themes.yaml b/internal/ui/themes.yaml new file mode 100644 index 0000000..0e8acda --- /dev/null +++ b/internal/ui/themes.yaml @@ -0,0 +1,190 @@ +# Colour schemes. A theme is a table of roles, not of widgets: nothing in iontk +# asks for "blue", it asks for the colour an element name is drawn in. +# +# A themes.yaml in the config root is appended to these, and a scheme of the +# same name replaces the built-in one; --themes points somewhere else, which is +# how a --fixtures run gets a scheme of its own. Every field is optional except +# the name. +# +# ok something worked, and the ✓ mark +# err something failed, and the ✗ mark +# warn worth noticing but not a failure: an offline ION, a stale tenant +# cmd headings and command text; rendered bold +# element an element, tenant or value the page is about +# row the highlight on the row a list is on; rendered bold +# hot a session about to go +# border a modal's frame +# divider the rule between two panes +# spinner the spinner's shape, one of the names iontk ships +# spinner_color what it is drawn in, including in the bubbles widget +# tabs cycled one per open tab, so none of them may be a status colour +# mono true drops colour entirely for bold, faint, italic and reverse +# +# A colour is an ANSI index 0-255 or a #rrggbb. An index under 16 is remapped by +# the terminal's own theme, which is why classic stays there; anything else is +# emitted as asked and downsampled by the renderer to what the terminal has. +# "dim" is not in the table: it is the faint attribute, and a scheme that made +# it a fixed grey would be wrong on half the terminal backgrounds there are. + +themes: + - name: classic + blurb: your terminal's own 16 colours + ok: "2" + err: "1" + warn: "3" + cmd: "6" + element: "4" + row: "5" + hot: "9" + border: "3" + divider: "8" + spinner: minidot + spinner_color: "5" + tabs: ["7", "4", "6", "5", "12", "14", "13"] + + - name: vivid + blurb: the bright 8, for a dark palette + ok: "10" + err: "9" + warn: "11" + cmd: "14" + element: "12" + row: "13" + hot: "11" + border: "11" + divider: "8" + spinner: jump + spinner_color: "13" + tabs: ["15", "12", "14", "13", "4", "6", "7"] + + - name: mono + blurb: "no colour: bold, faint, reverse" + spinner: line + mono: true + + - name: nord + blurb: truecolor, downsampled if needed + ok: "#a3be8c" + err: "#bf616a" + warn: "#ebcb8b" + cmd: "#88c0d0" + element: "#81a1c1" + row: "#b48ead" + hot: "#d08770" + border: "#ebcb8b" + divider: "#4c566a" + spinner: dot + spinner_color: "#b48ead" + tabs: ["#d8dee9", "#81a1c1", "#88c0d0", "#b48ead", "#5e81ac", "#8fbcbb", "#eceff4"] + + # The six below are after vim's own schemes rather than ports of them: vim + # colours syntax groups and iontk colours roles, so what carries over is the + # palette and the mood, not a mapping. + - name: desert + blurb: wheat and khaki on warm grey, after vim's desert + ok: "#98fb98" + err: "#ff6060" + warn: "#cd853f" + cmd: "#f0e68c" + element: "#87ceeb" + row: "#f5deb3" + hot: "#ffa07a" + border: "#cd853f" + divider: "#6b6b6b" + spinner: ellipsis + spinner_color: "#bdb76b" + tabs: ["#f5deb3", "#87ceeb", "#b0c4de", "#d8bfd8", "#deb887", "#c0c0c0", "#add8e6"] + + - name: evening + blurb: saturated accents on dark grey, after vim's evening + ok: "#60ff60" + err: "#ffa0a0" + warn: "#ffff60" + cmd: "#60ffff" + element: "#80a0ff" + row: "#ff80ff" + hot: "#ffa500" + border: "#ffff60" + divider: "#4e4e4e" + spinner: moon + spinner_color: "#ff80ff" + tabs: ["#ffffff", "#80a0ff", "#60ffff", "#ff80ff", "#c0c0ff", "#a0a0ff", "#d0d0d0"] + + - name: morning + blurb: dark ink for a LIGHT terminal, after vim's morning + ok: "#2e8b57" + err: "#a00000" + warn: "#a0522d" + cmd: "#005f5f" + element: "#00008b" + row: "#8b008b" + hot: "#b85c00" + border: "#a0522d" + divider: "#9e9e9e" + spinner: points + spinner_color: "#005f5f" + tabs: ["#303030", "#00008b", "#005f5f", "#8b008b", "#4b0082", "#2f4f4f", "#555555"] + + - name: slate + blurb: muted slate greys, after vim's slate + ok: "#b5bd68" + err: "#cc6666" + warn: "#f0c674" + cmd: "#8abeb7" + element: "#7aa6da" + row: "#b294bb" + hot: "#de935f" + border: "#8abeb7" + divider: "#5a5a5a" + spinner: meter + spinner_color: "#b294bb" + tabs: ["#c5c8c6", "#7aa6da", "#8abeb7", "#b294bb", "#81a2be", "#a3a3a3", "#d0d0d0"] + + - name: industry + blurb: high contrast on black, after vim's industry + ok: "#00af5f" + err: "#d70000" + warn: "#d7af00" + cmd: "#00d7d7" + element: "#5f87ff" + row: "#af5fd7" + hot: "#ff8700" + border: "#d7af00" + divider: "#585858" + spinner: hamburger + spinner_color: "#af5fd7" + tabs: ["#ffffff", "#5f87ff", "#00d7d7", "#af5fd7", "#8787ff", "#00afaf", "#bcbcbc"] + + - name: habamax + blurb: soft accents on near-black, after vim's habamax + ok: "#afd7af" + err: "#d78787" + warn: "#d7af87" + cmd: "#d7d7af" + element: "#afafd7" + row: "#d7afd7" + hot: "#d7875f" + border: "#d7af87" + divider: "#585858" + spinner: pulse + spinner_color: "#afafd7" + tabs: ["#bcbcbc", "#afafd7", "#87afaf", "#d7afd7", "#a8a8a8", "#8787af", "#d7d7d7"] + + # catppuccin mocha, lifted from the palette the user's own nvim loads + # (lemo-o/nvim -> catppuccin-mocha). Roles follow catppuccin's own use of it: + # green/red/yellow for state, blue for the "doing" colour, mauve for the + # selection, peach for the one that means "about to go". + - name: "Ben's Theme" + blurb: catppuccin mocha, from Ben's nvim + ok: "#a6e3a1" + err: "#f38ba8" + warn: "#f9e2af" + cmd: "#89b4fa" + element: "#b4befe" + row: "#cba6f7" + hot: "#fab387" + border: "#f9e2af" + divider: "#585b70" + spinner: monkey + spinner_color: "#cba6f7" + tabs: ["#cdd6f4", "#89b4fa", "#94e2d5", "#cba6f7", "#74c7ec", "#f5c2e7", "#b4befe"] From cb137ae4c2763b6da9ff90f0277b21204d266295 Mon Sep 17 00:00:00 2001 From: lemo-o <21benjaminhall@gmail.com> Date: Mon, 19 Oct 2026 21:44:53 -0600 Subject: [PATCH 4/4] misc optimizations, theme fixes, data mode fixes, copy paste and scroll fixes --- cmd/iontk/batch.go | 27 +- cmd/iontk/batch_test.go | 4 - cmd/iontk/creds.go | 4 - cmd/iontk/fixtures.go | 8 - cmd/iontk/flagdoc.go | 14 - cmd/iontk/flagdoc_test.go | 9 - cmd/iontk/inspect.go | 8 - cmd/iontk/root.go | 12 - cmd/iontk/search.go | 75 +--- cmd/iontk/search_test.go | 44 +-- cmd/iontk/shell.go | 2 - internal/app/auth.go | 51 --- internal/app/auth_test.go | 19 - internal/app/cli.go | 39 +- internal/app/cli_test.go | 20 - internal/app/client.go | 11 - internal/app/flags.go | 20 +- internal/app/signin.go | 24 -- internal/clock/clock.go | 13 +- internal/clock/clock_test.go | 11 - internal/config/cache.go | 10 +- internal/config/commands.go | 47 +-- internal/config/commands_test.go | 24 +- internal/config/config.go | 74 ++-- internal/config/reset.go | 14 - internal/config/reset_test.go | 9 - internal/config/secrets.go | 7 - internal/config/secrets_test.go | 1 - internal/config/tenants.go | 7 - internal/dataview/compare.go | 31 +- internal/dataview/compare_test.go | 20 - internal/dataview/dataview_test.go | 26 -- internal/dataview/fetch.go | 36 +- internal/dataview/render.go | 78 +--- internal/dataview/spec.go | 80 +--- internal/fixture/client.go | 8 +- internal/fixture/device.go | 37 +- internal/fixture/device_test.go | 12 - internal/fixture/fixture.go | 66 +--- internal/fixture/fixture_test.go | 5 - internal/fixture/portal.go | 59 +-- internal/fixture/portal_test.go | 17 - internal/fixture/rest.go | 26 -- internal/fixture/rest_test.go | 7 - internal/ionterm/manager.go | 14 - internal/ionterm/manager_test.go | 1 - internal/ionterm/pane.go | 23 -- internal/ionterm/scrollback_test.go | 5 - internal/ionterm/session.go | 39 +- internal/ionterm/session_test.go | 5 - internal/output/output.go | 20 - internal/output/output_test.go | 2 - internal/output/tabular.go | 30 +- internal/output/tabular_test.go | 15 - internal/sase/api/auth.go | 20 +- internal/sase/api/auth_test.go | 2 - internal/sase/api/client.go | 19 +- internal/sase/api/element.go | 26 +- internal/sase/api/endpoint.go | 19 +- internal/sase/api/profile.go | 7 - internal/sase/api/snapshot.go | 17 - internal/sase/api/status.go | 209 +++------- internal/sase/api/status_test.go | 52 +-- internal/sase/api/websocket.go | 35 +- internal/sase/api/websocket_test.go | 8 - internal/sase/ioncli/run_test.go | 21 - internal/sase/ioncli/session.go | 51 +-- internal/sase/options.go | 5 - internal/sase/options_test.go | 4 - internal/sase/result.go | 3 - internal/sase/result_test.go | 5 - internal/sase/run.go | 13 +- internal/sase/run_test.go | 6 +- internal/saseauth/browser.go | 45 +-- internal/saseauth/browser_test.go | 40 +- internal/saseauth/identity.go | 31 +- internal/saseauth/identity_test.go | 3 - internal/saseauth/oauth.go | 13 - internal/saseauth/oauth_test.go | 1 - internal/saseauth/seed.go | 9 - internal/saseauth/seed_test.go | 1 - internal/saseauth/source.go | 29 -- internal/saseauth/source_test.go | 17 - internal/saseauth/tenants.go | 24 +- internal/saseauth/tenants_test.go | 2 - internal/search/search.go | 24 -- internal/search/search_test.go | 22 -- internal/tui/appearance.go | 252 ++++++++++-- internal/tui/appearance_test.go | 241 ++++++++++-- internal/tui/authline.go | 29 -- internal/tui/authline_test.go | 25 +- internal/tui/authmode.go | 8 - internal/tui/batchui.go | 3 - internal/tui/bench_test.go | 11 - internal/tui/clipboard.go | 37 -- internal/tui/clipboard_test.go | 81 +--- internal/tui/colourpick.go | 360 +++++++++++++++++ internal/tui/colourpick_test.go | 368 +++++++++++++++++ internal/tui/commands.go | 31 -- internal/tui/commands_test.go | 23 -- internal/tui/compare.go | 27 -- internal/tui/compare_test.go | 31 +- internal/tui/connecting.go | 66 +--- internal/tui/connecting_test.go | 32 +- internal/tui/data.go | 98 +---- internal/tui/data_test.go | 88 +---- internal/tui/discover.go | 77 ++-- internal/tui/discover_test.go | 164 ++++++-- internal/tui/favourites.go | 21 - internal/tui/favourites_test.go | 21 - internal/tui/filter_test.go | 189 ++++++--- internal/tui/fixture_test.go | 8 - internal/tui/ha.go | 74 ++-- internal/tui/ha_test.go | 65 +-- internal/tui/macros.go | 129 +++--- internal/tui/macros_test.go | 176 +++++---- internal/tui/mouse.go | 43 +- internal/tui/mouse_test.go | 36 ++ internal/tui/multitenant.go | 165 ++------ internal/tui/multitenant_test.go | 116 ++---- internal/tui/quitkeys_test.go | 4 - internal/tui/refresh_test.go | 21 +- internal/tui/reset_test.go | 6 - internal/tui/results.go | 44 +-- internal/tui/results_test.go | 20 - internal/tui/save.go | 5 - internal/tui/save_test.go | 7 +- internal/tui/scrollback_test.go | 24 +- internal/tui/searchui.go | 24 +- internal/tui/secrets_warning_test.go | 4 +- internal/tui/session.go | 84 +--- internal/tui/session_test.go | 47 +-- internal/tui/settings.go | 175 ++++---- internal/tui/settings_test.go | 72 +++- internal/tui/signin_test.go | 9 - internal/tui/slash.go | 20 +- internal/tui/spinners.go | 2 - internal/tui/stats.go | 11 - internal/tui/stats_test.go | 7 - internal/tui/tabcolours.go | 304 ++++++++++++++ internal/tui/tabcolours_test.go | 224 +++++++++++ internal/tui/tenantflag_test.go | 2 - internal/tui/termcolour.go | 99 +++++ internal/tui/themeedit.go | 277 ++++++++++++- internal/tui/themeedit_test.go | 384 ++++++++++++++++-- internal/tui/tui.go | 567 ++++++++------------------ internal/tui/tuimenu.go | 198 +++++----- internal/tui/tuimenu_test.go | 17 +- internal/ui/colour.go | 301 ++++++++++++++ internal/ui/colour_test.go | 163 ++++++++ internal/ui/modal.go | 9 - internal/ui/spinners.go | 11 +- internal/ui/spinners_test.go | 3 - internal/ui/text.go | 25 +- internal/ui/theme.go | 569 ++++++++++++++++++++++----- internal/ui/theme_test.go | 444 +++++++++++++++++++-- internal/ui/themes.yaml | 49 ++- 157 files changed, 5122 insertions(+), 4258 deletions(-) create mode 100644 internal/tui/colourpick.go create mode 100644 internal/tui/colourpick_test.go create mode 100644 internal/tui/mouse_test.go create mode 100644 internal/tui/tabcolours.go create mode 100644 internal/tui/tabcolours_test.go create mode 100644 internal/tui/termcolour.go create mode 100644 internal/ui/colour.go create mode 100644 internal/ui/colour_test.go diff --git a/cmd/iontk/batch.go b/cmd/iontk/batch.go index b1a3096..9a80720 100644 --- a/cmd/iontk/batch.go +++ b/cmd/iontk/batch.go @@ -16,22 +16,15 @@ import ( "tyrtop.com/iontk/internal/tui" ) -// selection is what a batch run acts on. A --search resolves its own targets, -// each carrying the client and device login of the tenant it came from, -// because one search spans the whole working set; everything else hands over -// element IDs and lets one client serve them all. type selection struct { elements []string targets []sase.Target clients []*api.Client - // stdin says the IDs arrived down a pipe, which changes what the progress - // display may do with the terminal. - stdin bool + stdin bool } func (s selection) crossTenant() bool { return len(s.targets) > 0 } -// flagSelection is the ordinary --element / -f target list. func flagSelection() (selection, error) { els, err := gatherElements(flagElement, flagElementsPath) if err != nil { @@ -48,8 +41,6 @@ func runBatch(ctx context.Context, sel selection) error { var f app.Flags var err error if sel.crossTenant() { - // Nothing left to resolve: every target already holds the credential - // it will sign in with. f = app.BaseFlags() } else if f, err = resolveFlags(os.Stderr); err != nil { return err @@ -91,20 +82,13 @@ func runBatch(ctx context.Context, sel selection) error { return err } - // So a run that outlives its credential can stop, rather than spend an - // element timeout each on everything left. ctx, stopRun := context.WithCancel(ctx) defer stopRun() - // The api package no longer prints refresh failures itself, so a batch run - // has to say them out loud or a token dying mid-run looks like the devices - // going quiet. for _, c := range clients { go watchCredential(c.TokenErrors(), c.RefreshStopped, stopRun, os.Stderr) } - // The file is only created once the run is ready to produce something, so a - // failure before this point leaves no empty report behind. out, where, err := output.Open(flagOut, dest, format, time.Now()) if err != nil { return err @@ -114,8 +98,6 @@ func runBatch(ctx context.Context, sel selection) error { fmt.Fprintln(os.Stderr, "writing", format, "results to", where) } sinkOpts := []output.Option{output.WithOrientation(orient)} - // A table only has to fit when it is being read on the way past. A file - // holds the only copy of what it is given, so nothing is trimmed into one. if where == "stdout" { if cols, _, serr := term.GetSize(int(os.Stdout.Fd())); serr == nil && cols > 0 { sinkOpts = append(sinkOpts, output.WithWidth(cols)) @@ -123,9 +105,6 @@ func runBatch(ctx context.Context, sel selection) error { } sink := output.NewSink(out, format, sinkOpts...) - // Fixture mode swaps the client only here: the lookup and the session come - // from the playback device, and everything either side of them is the real - // run, including ioncli's autologin and pager handling. if app.Fixture != nil { playback := app.Fixture.SessionClient(f.PagerOff, f.PagerPrompts) for i := range targets { @@ -159,8 +138,6 @@ func runBatch(ctx context.Context, sel selection) error { return nil } -// batchOutput resolves the format, row orientation and destination folder, -// with flags winning over the saved settings. func batchOutput() (format, orient, dir string, err error) { format, orient, dir = output.FormatJSON, output.OrientION, "" if st := app.OpenStore(); st != nil { @@ -200,8 +177,6 @@ func drainPlain(events <-chan sase.Event, total int, sink output.Sink) (int, int return total, failed, nil } -// dead separates a spent credential from a run that merely finished; both -// close the channel. func watchCredential(ch <-chan error, dead func() bool, stop func(), w io.Writer) { for err := range ch { fmt.Fprintln(w, err) diff --git a/cmd/iontk/batch_test.go b/cmd/iontk/batch_test.go index 1d8513b..6e2357b 100644 --- a/cmd/iontk/batch_test.go +++ b/cmd/iontk/batch_test.go @@ -72,8 +72,6 @@ func TestWatchCredentialStopsTheRunWhenTheCredentialIsSpent(t *testing.T) { } } -// The CLI and the TUI read the same settings, so a flag has to be the only -// thing that moves one of them off what the other would have done. func TestBatchOutputPrefersFlagsOverSettings(t *testing.T) { app.Debug = true t.Cleanup(func() { @@ -109,8 +107,6 @@ func TestBatchOutputPrefersFlagsOverSettings(t *testing.T) { t.Errorf("batchOutput() = %q/%q, want the flags to win", format, orient) } - // A typo is an error before the run starts, not a silent fall back to the - // default -- a fleet written out the wrong way round is a re-run. flagOrient = "sideways" if _, _, _, err := batchOutput(); err == nil { t.Error("batchOutput() error = nil, want an unknown orientation to fail") diff --git a/cmd/iontk/creds.go b/cmd/iontk/creds.go index 55312e1..5774bae 100644 --- a/cmd/iontk/creds.go +++ b/cmd/iontk/creds.go @@ -8,8 +8,6 @@ import ( "tyrtop.com/iontk/internal/ui" ) -// Batch and shell both go through here, so they can't end up using different -// tenants. func resolveFlags(w io.Writer) (app.Flags, error) { st := app.OpenStore() app.ApplyDisplaySettings(st, nil) @@ -21,8 +19,6 @@ func resolveFlags(w io.Writer) (app.Flags, error) { for _, line := range creds.Lines() { fmt.Fprintln(w, line) } - // A themes.yaml that would not parse is as worth saying here as a wrong - // tenant is: the alternative is a scheme that silently did not load. if err := ui.ThemesErr(); err != "" { fmt.Fprintln(w, "themes: "+err) } diff --git a/cmd/iontk/fixtures.go b/cmd/iontk/fixtures.go index e8afd2e..f7e8e14 100644 --- a/cmd/iontk/fixtures.go +++ b/cmd/iontk/fixtures.go @@ -9,8 +9,6 @@ import ( "tyrtop.com/iontk/internal/fixture" ) -// loadFixtures runs before every command, so --fixtures works on the root and -// on the subcommands alike. func loadFixtures(w io.Writer) error { if flagFixtures == "" { return nil @@ -21,13 +19,9 @@ func loadFixtures(w io.Writer) error { } app.Fixture = set - // Said out loud, because a fixture run that looked like a real one would be - // the worst possible confusion in a tool that writes to appliances. fmt.Fprintf(w, "fixtures: %s — no network, no saved state\n", flagFixtures) for _, t := range set.Tenants { fmt.Fprintf(w, "fixtures: %s (tsg %s), %d elements\n", t.Name, t.TSGID, len(t.Elements)) - // IDs are generated from the names unless the file gives them, and - // --element needs one, so -v is how you find out what they are. if !app.Defaults.Verbose { continue } @@ -36,8 +30,6 @@ func loadFixtures(w io.Writer) error { } } - // The environment would otherwise win over the fixture's own logins and - // token, and every session would answer "Login incorrect". for _, k := range []string{"ION_USER", "ION_PASS", "SCM_TOKEN", "SCM_CLIENT_ID", "SCM_CLIENT_SECRET", "SCM_TSG_ID"} { if os.Getenv(k) == "" { continue diff --git a/cmd/iontk/flagdoc.go b/cmd/iontk/flagdoc.go index e19dcd8..c577eb6 100644 --- a/cmd/iontk/flagdoc.go +++ b/cmd/iontk/flagdoc.go @@ -12,9 +12,6 @@ import ( "github.com/spf13/pflag" ) -// Flags are documented in docs/flags.md, which this generates. A hand-written -// reference went stale within a release; three flags were added and one -// deprecated in a single sitting. var genFlagDocsCmd = &cobra.Command{ Use: "gen-flag-docs [file]", Short: "Write the flag reference to a file, or to stdout", @@ -25,11 +22,7 @@ var genFlagDocsCmd = &cobra.Command{ if err := writeFlagDocs(&b, rootCmd); err != nil { return err } - // Writing the file here rather than redirecting stdout keeps the - // go:generate line free of a shell, so it runs on Windows too. if len(args) == 1 { - // docs/ is gitignored, so a fresh checkout has no directory to - // write into. if dir := filepath.Dir(args[0]); dir != "." { if err := os.MkdirAll(dir, 0o755); err != nil { return fmt.Errorf("create %s: %w", dir, err) @@ -45,9 +38,6 @@ var genFlagDocsCmd = &cobra.Command{ }, } -// flagGroups is the running order. A flag missing from here fails generation -// rather than landing in a catch-all: being forced to classify a new flag is -// the only thing that keeps the grouping meaningful. var flagGroups = []struct { name string intro string @@ -157,8 +147,6 @@ func writeFlagTable(b *strings.Builder, flags []*pflag.Flag) { } } -// groupFlags sorts every registered flag into its group, and reports any that -// no group claims. func groupFlags(root *cobra.Command) (map[string][]*pflag.Flag, error) { want := map[string]string{} for _, g := range flagGroups { @@ -187,8 +175,6 @@ func groupFlags(root *cobra.Command) (map[string][]*pflag.Flag, error) { strings.Join(ungrouped, ", ")) } - // Within a group, the order flagGroups lists them in, not alphabetical: - // related timeouts read better together than apart. for _, g := range flagGroups { rank := map[string]int{} for i, name := range g.flags { diff --git a/cmd/iontk/flagdoc_test.go b/cmd/iontk/flagdoc_test.go index f29efd0..db288ce 100644 --- a/cmd/iontk/flagdoc_test.go +++ b/cmd/iontk/flagdoc_test.go @@ -11,8 +11,6 @@ import ( ) func TestEveryFlagIsGrouped(t *testing.T) { - // The guard that makes the generated doc worth having: a new flag cannot - // quietly land in a catch-all section nobody reads. if _, err := groupFlags(rootCmd); err != nil { t.Errorf("groupFlags() error = %v", err) } @@ -26,7 +24,6 @@ func TestAnUngroupedFlagFailsGeneration(t *testing.T) { if err == nil { t.Fatal("groupFlags() error = nil, want an ungrouped flag to fail") } - // The message has to name the flag and where to put it, or it is a riddle. for _, want := range []string{"brand-new", "flagdoc.go"} { if !strings.Contains(err.Error(), want) { t.Errorf("error = %q, want it to mention %q", err, want) @@ -40,7 +37,6 @@ func TestGroupedFlagsKeepTheirListedOrder(t *testing.T) { t.Fatalf("groupFlags() error = %v", err) } - // Related timeouts read better together than alphabetically. var want []string for _, g := range flagGroups { if g.name == "Connection" { @@ -72,8 +68,6 @@ func TestDeprecatedFlagsAreSeparated(t *testing.T) { t.Errorf("--%s is in Deprecated with no deprecation message", f.Name) } } - // A deprecated flag must not also appear in a live group, or it reads as - // current. for _, g := range flagGroups { for _, f := range grouped[g.name] { if f.Deprecated != "" { @@ -95,7 +89,6 @@ func TestTheDocMentionsEveryFlagAndEnvVar(t *testing.T) { t.Errorf("--%s is missing from the generated doc", f.Name) } }) - // These are read in BaseFlags() and documented nowhere else. for _, env := range []string{ "SCM_CLIENT_ID", "SCM_CLIENT_SECRET", "SCM_TSG_ID", "SCM_TOKEN", "ION_USER", "ION_PASS", @@ -107,7 +100,6 @@ func TestTheDocMentionsEveryFlagAndEnvVar(t *testing.T) { } func TestGeneratingIntoAMissingDirectory(t *testing.T) { - // docs/ is gitignored, so CI generates into a directory that is not there. out := filepath.Join(t.TempDir(), "docs", "flags.md") if err := genFlagDocsCmd.RunE(genFlagDocsCmd, []string{out}); err != nil { @@ -124,7 +116,6 @@ func TestGeneratingIntoAMissingDirectory(t *testing.T) { } func TestTheGeneratorIsHidden(t *testing.T) { - // It would otherwise show up in --help beside run and shell. if !genFlagDocsCmd.Hidden { t.Error("gen-flag-docs is not hidden") } diff --git a/cmd/iontk/inspect.go b/cmd/iontk/inspect.go index 39416dc..a3ef353 100644 --- a/cmd/iontk/inspect.go +++ b/cmd/iontk/inspect.go @@ -63,8 +63,6 @@ func runInspect(ctx context.Context, eid string) error { } snap := client.ElementSnapshot(ctx, el) - // The record is the one body the snapshot does not fetch for itself, and - // the one the widened element fields are read off. snap.Raw[api.ElementURL(eid)] = body enc := json.NewEncoder(os.Stdout) @@ -72,9 +70,6 @@ func runInspect(ctx context.Context, eid string) error { if flagInspectRaw { return enc.Encode(snap.Raw) } - // Every part that failed is reported on stderr as well as in the JSON: a - // snapshot of an element whose status endpoint refused is still worth - // printing, but not worth mistaking for a complete one. if err := snap.Err(); err != nil { fmt.Fprintln(os.Stderr, err) } @@ -102,9 +97,6 @@ type statusReport struct { Channels []channelReport `json:"channels"` } -// The utc integers are printed as both the number and a time, because the -// unit is the part of these shapes least proven against hardware: a wrong -// scale shows up as a date in the wrong century. type channelReport struct { Name string `json:"name"` Connected bool `json:"connected"` diff --git a/cmd/iontk/root.go b/cmd/iontk/root.go index 0920327..5da84d9 100644 --- a/cmd/iontk/root.go +++ b/cmd/iontk/root.go @@ -63,7 +63,6 @@ login from the keyring, so --cmd needs none of these once one is signed in.`, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { _ = godotenv.Load() - // Before anything resolves a store: the themes are read when one is. app.ThemesFile = flagThemes return loadFixtures(cmd.ErrOrStderr()) }, @@ -107,20 +106,11 @@ func init() { pf.IntVarP(&app.Defaults.Concurrency, "concurrency", "n", app.Defaults.Concurrency, "elements worked in parallel") pf.DurationVar(&app.Defaults.ElementTimeout, "element-timeout", app.Defaults.ElementTimeout, "per-element deadline including the CLI session") pf.DurationVar(&app.Defaults.SessionTimeout, "session-timeout", app.Defaults.SessionTimeout, "deadline for a single login attempt") - // Defaults to 1 to prevent executing config changes unintentionally. - // The toolkit socket sometimes does not come up at all, so a dial is - // retried. A session that connected is never re-run: its commands may - // already have taken effect on the device. pf.IntVar(&app.Defaults.DialAttempts, "dial-attempts", app.Defaults.DialAttempts, "times to dial an element before dropping it") pf.DurationVar(&app.Defaults.DialTimeout, "dial-timeout", app.Defaults.DialTimeout, "how long one dial may take") pf.IntVar(&app.Defaults.DialAttempts, "attempts", app.Defaults.DialAttempts, "deprecated alias for --dial-attempts") _ = pf.MarkDeprecated("attempts", "use --dial-attempts; the retry now re-dials rather than re-running the session") - // Better to turn paging off than to scrape the pager, where the device - // allows it. The default works on 6.3.4; the flag is for trying another - // firmware's syntax without a rebuild. pf.StringVar(&app.Defaults.PagerOff, "pager-off", app.Defaults.PagerOff, "command sent after login to stop the device paging (empty sends nothing)") - // For firmware that pages anyway. The prompt text varies, so it's config - // rather than a constant. pf.StringArrayVar(&app.Defaults.PagerPrompts, "pager-prompt", app.Defaults.PagerPrompts, "line to answer with a space when the device pages; repeatable, and empty turns it off") pf.DurationVar(&app.Defaults.HTTPTimeout, "http-timeout", app.Defaults.HTTPTimeout, "timeout for the element lookup") pf.Float64Var(&app.Defaults.RPS, "rps", app.Defaults.RPS, "API requests per second") @@ -136,8 +126,6 @@ func init() { pf.StringVar(&flagThemes, "themes", "", "YAML file of colour schemes to add to the built-in ones, instead of themes.yaml in the config directory") pf.StringVar(&app.Defaults.TSGID, "tsg", "", "tenant service group ID (overrides SCM_TSG_ID)") pf.StringVar(&flagFormat, "format", "", `batch output format: "json", "jsonl", "text", "table" or "csv" (default from settings, else json)`) - // Only the tabular formats have rows to sort, but the flag is accepted - // whatever the format so a saved orientation is not a reason to re-run. pf.StringVar(&flagOrient, "orientation", "", `row order for "table" and "csv": "ion" groups each element's commands, "command" puts one command across the fleet together (default from settings, else ion)`) pf.StringVar(&flagOut, "out", "", `write batch output to this file ("-" is stdout; default from settings)`) diff --git a/cmd/iontk/search.go b/cmd/iontk/search.go index a9dba5a..10c41d5 100644 --- a/cmd/iontk/search.go +++ b/cmd/iontk/search.go @@ -26,9 +26,6 @@ import ( "tyrtop.com/iontk/internal/ui" ) -// Search output formats. These are not the batch formats: --search prints -// elements, not command results, so "table" and "json" mean different shapes -// here and nothing saved in settings applies. const ( searchTable = "table" searchIDs = "ids" @@ -37,7 +34,6 @@ const ( var searchFormats = []string{searchTable, searchIDs, searchJSON} -// hit is one matching element with the tenant it was found in. type hit struct { element api.Element tenant config.Tenant @@ -45,13 +41,10 @@ type hit struct { } func runSearch(ctx context.Context) error { - // Two selectors would quietly disagree about what the run touches. if flagElement != "" || flagElementsPath != "" { return fmt.Errorf("--search selects the elements itself; drop --element and --elements-path") } - // --format means the list format only while there is a list to print. - // With --cmd this is an ordinary batch run, and --format is its own. var format string var err error if len(flagCommands) == 0 { @@ -97,22 +90,15 @@ func runSearch(ctx context.Context) error { return runBatch(ctx, sel) } -// canSignIn allows --search to open a browser window. Only where somebody is -// there to answer it: a scripted run reports the failure instead, the way it -// always has. func canSignIn() bool { return !flagNoLogin && term.IsTerminal(int(os.Stderr.Fd())) } -// showProgress matches the batch bar's rule: a live display only where there -// is a terminal to draw on and nobody asked for quiet. func showProgress() bool { return !flagNoProgress && term.IsTerminal(int(os.Stderr.Fd())) } func searchFormat() (string, error) { - // Unlike a batch run this ignores the saved BatchFormat: a tenant that - // prefers CSV results did not thereby ask for its element list in CSV. switch f := strings.ToLower(strings.TrimSpace(flagFormat)); f { case "": return searchTable, nil @@ -127,9 +113,6 @@ func searchFormat() (string, error) { } } -// searchScope is the tenants a search spans: the working set the TUI -// aggregates over, so that a query finding less on the command line than in -// the TUI is not possible. --tenant and --tsg narrow it. func searchScope(st *config.Store) ([]config.Tenant, error) { cfg, err := st.Config() if err != nil { @@ -146,8 +129,6 @@ func searchScope(st *config.Store) ([]config.Tenant, error) { scope := cfg.AddedTenants() if len(scope) == 0 { - // Nothing added yet: whatever a batch run would have used is the whole - // scope, so a fresh setup can still search. t, ok := app.ResolveTenant(cfg, "") if !ok { return nil, fmt.Errorf("no tenants to search: %s", app.TenantChoices(cfg)) @@ -178,15 +159,10 @@ func tenantNames(ts []config.Tenant) string { return strings.Join(names, ", ") } -// fleet loads the working set's elements once and hands out the clients it -// built, so running on what a search found does not sign in all over again. type fleet struct { - store *config.Store - log io.Writer - // progress draws the load on stderr instead of printing a line per tenant. - progress bool - // interactive means somebody is there to answer a question, and in is - // where their answer comes from. + store *config.Store + log io.Writer + progress bool interactive bool in io.Reader @@ -201,35 +177,23 @@ func newFleet(st *config.Store, in io.Reader, log io.Writer, progress, interacti } } -// loaded is one tenant's answer, kept in the tenant's own slot so a concurrent -// load still produces a stable order. type loaded struct { tenant config.Tenant els []api.Element sites search.SiteMap note string err error - // stale is the refresh failure behind an answer that came from an - // out-of-date cache. Such a tenant did produce elements, so it is not an - // err -- but it is still a tenant a sign-in would fix. - stale error + stale error } -// wantsSignIn reports a tenant that a browser sign-in might rescue, whether it -// produced nothing at all or only an out-of-date list. func (l loaded) wantsSignIn() bool { return errors.Is(l.err, saseauth.ErrInteractiveRequired) || errors.Is(l.stale, saseauth.ErrInteractiveRequired) } -// find collects every tenant's elements and keeps the ones the query matches. -// Tenants load together: a cold one is thousands of elements behind a sign-in, -// and waiting for them in turn is the difference between a pause and a hang. func (fl *fleet) find(ctx context.Context, tenants []config.Tenant, q search.Query) ([]hit, error) { results := fl.load(ctx, tenants) - // One browser sign-in covers the account, so the tenants that need one are - // collected and retried together rather than a window each. var again []config.Tenant for _, r := range results { if r.wantsSignIn() { @@ -274,10 +238,6 @@ func (fl *fleet) find(ctx context.Context, tenants []config.Tenant, q search.Que return hits, nil } -// load fetches each tenant's elements at the same time: a cold tenant is -// thousands of elements behind a sign-in, and waiting for them in turn is the -// difference between a pause and a hang. Results land in the tenant's own slot -// so the order does not depend on who finishes first. func (fl *fleet) load(ctx context.Context, tenants []config.Tenant) []loaded { results := make([]loaded, len(tenants)) events := make(chan tui.TenantLoad, 2*len(tenants)) @@ -309,11 +269,6 @@ func (fl *fleet) load(ctx context.Context, tenants []config.Tenant) []loaded { return results } -// renew offers to sign in for the tenants whose credential has run out, and -// reports whether anything is worth loading again. Asking is the point: a -// sign-in a search takes on its own is one it can take on every run, and one -// it refuses outright leaves the person reading an error about a thing the -// tool could have just done. func (fl *fleet) renew(ctx context.Context, needed []config.Tenant) bool { if !fl.interactive { fmt.Fprintf(fl.log, "%s need a credential — run `iontk login`, or this on a terminal\n", @@ -342,9 +297,6 @@ func (fl *fleet) renew(ctx context.Context, needed []config.Tenant) bool { return true } -// report is the plain-text load log, for a pipe or --no-progress. Each tenant -// is reported as it finishes rather than collected to the end, so a slow one -// is visibly slow rather than silently missing. func (fl *fleet) report(events <-chan tui.TenantLoad) { for ev := range events { switch { @@ -357,9 +309,6 @@ func (fl *fleet) report(events <-chan tui.TenantLoad) { } } -// elements answers from the cache while it is current and fetches otherwise. A -// fetch that fails falls back to whatever is on disk rather than dropping the -// tenant, since stale names still identify an element. func (fl *fleet) elements(ctx context.Context, t config.Tenant) loaded { out := loaded{tenant: t} cached, cerr := fl.store.Cache(t.TSGID) @@ -398,9 +347,6 @@ func (fl *fleet) fetch(ctx context.Context, t config.Tenant, cached *config.Elem return nil, nil, err } - // A failed site lookup is not fatal, but the names already on disk are - // worth more than the empty map it would otherwise write over them: - // elements showing a raw ID instead of a site is a reported bug. sites := search.SiteMap{} ss, siteErr := c.ListSites(ctx) for _, s := range ss { @@ -416,10 +362,7 @@ func (fl *fleet) fetch(ctx context.Context, t config.Tenant, cached *config.Elem return els, sites, nil } -// client builds a tenant's client once. Signing in twice for one command is -// two token mints and, for a browser tenant, two chances to be asked again. func (fl *fleet) client(ctx context.Context, t config.Tenant) (*api.Client, error) { - // Tenants load concurrently, so this map has several writers. fl.mu.Lock() defer fl.mu.Unlock() if c, ok := fl.clients[t.TSGID]; ok { @@ -437,8 +380,6 @@ func (fl *fleet) client(ctx context.Context, t config.Tenant) (*api.Client, erro return c, nil } -// flags passes ION_USER through rather than falling back to the tenant's -// default user, the way a batch run does. func (fl *fleet) flags(t config.Tenant) app.Flags { return app.TenantFlags(fl.store, t, os.Getenv("ION_USER")) } @@ -527,9 +468,6 @@ func multiTenant(hits []hit) bool { return false } -// askYesNo puts a question to whoever is running the command. Anything but an -// explicit yes is a no, because both questions a search asks are about doing -// something the person may not want done. func askYesNo(in io.Reader, w io.Writer, question string) (bool, error) { fmt.Fprintf(w, "%s [y/N] ", question) line, err := bufio.NewReader(in).ReadString('\n') @@ -543,9 +481,6 @@ func askYesNo(in io.Reader, w io.Writer, question string) (bool, error) { return false, nil } -// confirmRun is the gate between a query and a fleet of live appliances. -// --no-validate skips it; a pipe cannot answer it, so a run with neither is -// refused rather than left hanging on a question nobody will see. func confirmRun(in io.Reader, w io.Writer, tty bool, hits []hit) error { if flagNoValidate { return nil @@ -581,8 +516,6 @@ func plural(n int, word string) string { return fmt.Sprintf("%d %ss", n, word) } -// selection pairs each match with its own tenant's client and device login, -// which is what lets one search run across the working set. func (fl *fleet) selection(ctx context.Context, hits []hit) (selection, error) { sel := selection{elements: make([]string, 0, len(hits))} seen := map[string]bool{} diff --git a/cmd/iontk/search_test.go b/cmd/iontk/search_test.go index c0fc5ba..624f90a 100644 --- a/cmd/iontk/search_test.go +++ b/cmd/iontk/search_test.go @@ -17,9 +17,8 @@ import ( "tyrtop.com/iontk/internal/search" ) -// searchStore is a store of its own -- not config.OpenDebug, whose root is one -// fixed path that every test binary clears on first open -- holding two -// tenants in the working set and a third left out of it. +// not config.OpenDebug, whose root is one fixed path that every test binary +// clears on first open func searchStore(t *testing.T) *config.Store { t.Helper() t.Cleanup(func() { @@ -52,8 +51,6 @@ func seedCache(t *testing.T, st *config.Store, tsg string, at time.Time, els []a func TestSearchScopeIsTheWorkingSet(t *testing.T) { st := searchStore(t) - // The default is every added tenant: finding less here than the TUI finds - // is the bug this scope exists to prevent. got, err := searchScope(st) if err != nil { t.Fatalf("searchScope() error = %v", err) @@ -81,7 +78,6 @@ func TestSearchScopeIsTheWorkingSet(t *testing.T) { t.Errorf("searchScope() = %q, want --tsg to narrow the set", names) } - // A --tsg outside the set is a typo, not an empty fleet. app.Defaults.TSGID = "999" if _, err := searchScope(st); err == nil { t.Error("searchScope() error = nil, want an unknown --tsg to fail") @@ -107,8 +103,6 @@ func TestSearchScopeFallsBackBeforeAnythingIsAdded(t *testing.T) { func TestSearchAnswersFromAFreshCacheWithoutDialling(t *testing.T) { st := searchStore(t) - // No credentials are saved, so anything reaching for the network fails: - // a result here proves the cache answered. seedCache(t, st, "111", time.Now().Add(-time.Minute), []api.Element{ {ID: "e1", Name: "s0100-rtr-01", SiteID: "s1", Model: "ion 1200", Connected: true}, {ID: "e2", Name: "s0200-rtr-01", SiteID: "s2", Model: "ion 3200"}, @@ -126,7 +120,6 @@ func TestSearchAnswersFromAFreshCacheWithoutDialling(t *testing.T) { if len(hits) != 2 { t.Fatalf("matched %d elements, want the query to span both tenants: %v", len(hits), names(hits)) } - // Name order, so the list does not shuffle between runs. if got := names(hits); got[0] != "ord-gw-01" || got[1] != "s0100-rtr-01" { t.Errorf("matched %v, want them in name order", got) } @@ -142,8 +135,6 @@ func TestSearchSaysWhenAnswerIsFromAStaleCache(t *testing.T) { }}); err != nil { t.Fatalf("SaveConfig() error = %v", err) } - // Older than the freshness window, and with no credential to refresh it: - // the stale answer is better than no answer, but it has to be labelled. seedCache(t, st, "111", time.Now().Add(-2*config.CacheFresh), []api.Element{ {ID: "e1", Name: "s0100-rtr-01", Model: "ion 1200"}, }, nil) @@ -158,8 +149,6 @@ func TestSearchSaysWhenAnswerIsFromAStaleCache(t *testing.T) { t.Fatalf("matched %d, want the stale cache used rather than dropped", len(hits)) } out := log.String() - // An answer this old has to say so, and say why it could not be renewed: - // a stale list that looks live is the whole risk of reading a cache. if !strings.Contains(out, "stale cache") { t.Errorf("a stale answer was not called one:\n%s", out) } @@ -190,8 +179,6 @@ func TestSearchFormatIsNotTheBatchFormat(t *testing.T) { {"table", searchTable, false}, {"IDS", searchIDs, false}, {" json ", searchJSON, false}, - // Valid for results, meaningless for a list of elements. Silently - // falling back would print a table the caller did not ask for. {"csv", "", true}, {"jsonl", "", true}, {"nonsense", "", true}, @@ -216,8 +203,6 @@ func TestPrintHitsIDsAreBareSoTheyPipe(t *testing.T) { if err := printHits(&buf, twoTenantHits(), searchIDs); err != nil { t.Fatalf("printHits() error = %v", err) } - // `--search X --format ids | iontk run -f -` is the whole point: anything - // but one ID per line breaks it. if got := buf.String(); got != "e1\ne3\n" { t.Errorf("printHits(ids) = %q, want one bare ID per line", got) } @@ -256,8 +241,6 @@ func TestPrintHitsJSONCarriesTheTenant(t *testing.T) { if len(rows) != 2 { t.Fatalf("decoded %d rows, want 2", len(rows)) } - // An element ID is only unique within its tenant, so the pair has to - // survive into anything machine-readable. for _, want := range []string{"id", "tenant", "tsg_id"} { if _, ok := rows[0][want]; !ok { t.Errorf("the JSON row has no %q: %v", want, rows[0]) @@ -281,7 +264,6 @@ func TestConfirmRunRefusesRatherThanHangOnAPipe(t *testing.T) { t.Errorf("a prompt was written to a pipe that cannot answer it:\n%s", w.String()) } - // And with the flag, the same run goes ahead without asking. flagNoValidate = true if err := confirmRun(strings.NewReader(""), &w, false, twoTenantHits()); err != nil { t.Errorf("confirmRun() with --no-validate error = %v, want it skipped", err) @@ -311,7 +293,6 @@ func TestConfirmRunNeedsAnExplicitYes(t *testing.T) { if (err == nil) != tt.want { t.Errorf("confirmRun(%q) error = %v, want proceed = %v", tt.answer, err, tt.want) } - // Whatever the answer, it has to have said what it was about to do. out := w.String() for _, want := range []string{"2 commands on 2 elements", "$ uptime", "s0100-rtr-01", "ord-gw-01", "continue?"} { if !strings.Contains(out, want) { @@ -352,9 +333,6 @@ func twoTenantHits() []hit { } func TestAnUnreachableTenantDoesNotSinkTheSearch(t *testing.T) { - // One tenant with a usable cache and two with neither cache nor - // credential. A fleet-wide query has to answer from what it can reach and - // say plainly what it could not. st := searchStore(t) seedCache(t, st, "111", time.Now().Add(-time.Minute), []api.Element{ {ID: "e1", Name: "s0100-rtr-01", Model: "ion 1200"}, @@ -375,16 +353,12 @@ func TestAnUnreachableTenantDoesNotSinkTheSearch(t *testing.T) { if !strings.Contains(out, "Niagara Bottling: unreachable") { t.Errorf("an unreachable tenant was not reported:\n%s", out) } - // Silently searching two tenants when three were asked for is how a - // search comes back wrongly empty. if !strings.Contains(out, "Tractor Supply: 1 elements") { t.Errorf("the reachable tenant was not reported:\n%s", out) } } func TestResultsKeepTenantOrderThoughTenantsLoadTogether(t *testing.T) { - // Tenants load concurrently now, so the answer must not depend on which - // one finishes first. st := searchStore(t) seedCache(t, st, "111", time.Now().Add(-time.Minute), []api.Element{ {ID: "e1", Name: "rtr-b"}, {ID: "e2", Name: "rtr-d"}, @@ -425,10 +399,6 @@ func TestWhichTenantsABrowserSignInWouldRescue(t *testing.T) { {"nothing wrong", loaded{note: "refreshed"}, false}, {"no credential at all", loaded{err: interactive}, true}, { - // The subtle one: falling back to a stale cache leaves err nil, - // because the tenant did produce elements. It is still a tenant a - // sign-in would bring up to date, and treating it as fine is how - // an 18-hour-old answer survives a successful sign-in. name: "answered, but only from a stale cache", l: loaded{els: []api.Element{{ID: "e1"}}, stale: interactive}, want: true, @@ -446,8 +416,6 @@ func TestWhichTenantsABrowserSignInWouldRescue(t *testing.T) { } func TestAStaleAnswerIsMarkedForRetryNotTakenAsGood(t *testing.T) { - // End to end through the loader: no credential, a cache past the window, - // so the search answers but records that a sign-in would help. st := searchStore(t) if err := st.SaveConfig(&config.Config{Tenants: []config.Tenant{ {Name: "Tractor Supply", TSGID: "111", Added: true, Auth: config.AuthBrowser}, @@ -474,7 +442,6 @@ func TestAStaleAnswerIsMarkedForRetryNotTakenAsGood(t *testing.T) { } func TestRenewAsksBeforeOpeningABrowser(t *testing.T) { - // A sign-in a search takes on its own is one it can take on every run. st := searchStore(t) tenants := mustScope(t, st) @@ -512,7 +479,6 @@ func TestRenewAsksBeforeOpeningABrowser(t *testing.T) { func TestAScriptIsToldWhatToRunRatherThanAsked(t *testing.T) { st := searchStore(t) var log bytes.Buffer - // Nothing to read from: a question here would hang a cron job for good. fl := newFleet(st, nil, &log, false, false) if fl.renew(context.Background(), mustScope(t, st)) { t.Error("renew() = true for a scripted run") @@ -528,8 +494,6 @@ func TestAScriptIsToldWhatToRunRatherThanAsked(t *testing.T) { func TestABrowserIsNeverOpenedWithoutATerminal(t *testing.T) { t.Cleanup(func() { flagNoLogin = false }) - // stderr under `go test` is not a terminal, which is the condition a - // piped or cron-driven run meets. if canSignIn() { t.Error("canSignIn() = true with no terminal, want a scripted run left alone") } @@ -541,8 +505,6 @@ func TestABrowserIsNeverOpenedWithoutATerminal(t *testing.T) { func TestTenantsNeedingACredentialAreNamedWithTheWayOut(t *testing.T) { st := searchStore(t) - // Browser tenants with no stored credential and no browser profile on - // disk, which fails as ErrNoSession before anything is launched. if err := st.SaveConfig(&config.Config{Tenants: []config.Tenant{ {Name: "Tractor Supply", TSGID: "111", Added: true, Auth: config.AuthBrowser}, }}); err != nil { @@ -553,8 +515,6 @@ func TestTenantsNeedingACredentialAreNamedWithTheWayOut(t *testing.T) { flagSearch = "s0100" var log bytes.Buffer - // Not interactive, so no window: the message is all a scripted run gets, - // and it has to be the one that actually fixes things. hits, err := newFleet(st, nil, &log, false, false).find( context.Background(), mustScope(t, st), search.Parse(flagSearch)) if err != nil { diff --git a/cmd/iontk/shell.go b/cmd/iontk/shell.go index 3dc229d..d1b0045 100644 --- a/cmd/iontk/shell.go +++ b/cmd/iontk/shell.go @@ -44,8 +44,6 @@ func runShell(ctx context.Context, eid string) error { return err } - // The handshake is the only authentication, so an open shell outlives its - // token; only the next dial breaks. go func() { for err := range client.TokenErrors() { fmt.Fprintln(os.Stderr, err) diff --git a/internal/app/auth.go b/internal/app/auth.go index 3fe0e12..efec695 100644 --- a/internal/app/auth.go +++ b/internal/app/auth.go @@ -16,9 +16,6 @@ import ( var _ api.TokenSource = (*saseauth.Source)(nil) -// BrowserOptions builds the browser settings for a store. The profile directory -// is the important part: the session cookie it holds is what lets later tenants -// be scoped without a second window. func BrowserOptions(httpClient *http.Client, st *config.Store) saseauth.Options { opt := saseauth.Options{Client: httpClient} if st != nil { @@ -27,14 +24,6 @@ func BrowserOptions(httpClient *http.Client, st *config.Store) saseauth.Options return opt } -// BrowserSource builds a browser-backed token source for the tenant in f. A nil -// store is not fatal: the sign-in still works, it just will not be remembered -// for the next run. -// -// Nothing here opens a browser. Source.Token only spends credentials it already -// has, so a batch run against a browser tenant either works from the stored -// refresh token or fails asking to be signed in from the TUI, which is the only -// place a window is ever shown. func BrowserSource(f Flags, httpClient *http.Client, st *config.Store) *saseauth.Source { opt := BrowserOptions(httpClient, st) if st == nil { @@ -42,12 +31,6 @@ func BrowserSource(f Flags, httpClient *http.Client, st *config.Store) *saseauth } stored, _ := st.Secrets.Get(config.RefreshTokenKey(f.TSGID)) - // Narrowing the account token's scope is what makes a new tenant usable - // without another trip through the browser. It goes in as a shared seed, - // not this tenant's own: one refresh token redeemed from several tenants - // at once loses all but one. Attached even when this tenant has a stored - // token, because that token may be the thing the server rejects -- a - // tenant whose credential expired is exactly who the account one is for. if acct, _ := st.Secrets.Get(config.AccountRefreshTokenKey()); acct != "" { opt.Seed = accountSeed(acct) } @@ -57,9 +40,6 @@ func BrowserSource(f Flags, httpClient *http.Client, st *config.Store) *saseauth return saseauth.NewSource(f.TSGID, stored, opt, save) } -// seeds is process-wide because a client is built per tenant, each opening its -// own store: there is nowhere else for tenants to meet. Keyed by token, so a -// fresh sign-in gets a fresh seed. var seeds sync.Map func accountSeed(token string) *saseauth.Seed { @@ -67,18 +47,10 @@ func accountSeed(token string) *saseauth.Seed { return v.(*saseauth.Seed) } -// Debug sends every store this process opens to a throwaway root with -// in-memory secrets. It is a process-wide switch because the TUI, the batch -// path and BrowserSource each open their own store, and a run with one of them -// still pointed at the real config would not be isolated at all. var Debug bool -// Fixture is the loaded --fixtures file, or nil for a real run. It isolates the -// store for the same reason --debug does, and seeds it so the ordinary tenant -// flow is what gets exercised. var Fixture *fixture.Set -// Store opens the config store, honouring --debug and --fixtures. func Store() (*config.Store, error) { if Fixture != nil { st, err := config.OpenDebug() @@ -98,9 +70,6 @@ var ( seededAs *fixture.Set ) -// seed writes the fixture's tenants and device logins into the store. Every -// entry point opens its own store, so it runs once per fixture rather than once -// per store: later opens have to see the settings the run has changed since. func seed(st *config.Store, f *fixture.Set) error { seedMu.Lock() defer seedMu.Unlock() @@ -108,16 +77,12 @@ func seed(st *config.Store, f *fixture.Set) error { return nil } - // Written from scratch rather than merged, so changing which fixture a - // process runs cannot leave the previous one's tenants in the list. tenants := f.SavedTenants() cfg := &config.Config{Tenants: tenants} if len(tenants) > 0 { cfg.LastTenant = tenants[0].Name } for _, t := range tenants { - // A service account tenant with no client secret fails ValidateCreds - // before anything ever reaches the fixture transport. if err := st.Secrets.Set(config.ClientSecretKey(t.TSGID), "fixture"); err != nil { return fmt.Errorf("seed fixture tenant %s: %w", t.Name, err) } @@ -134,7 +99,6 @@ func seed(st *config.Store, f *fixture.Set) error { return nil } -// OpenStore returns the config store, or nil when it cannot be opened. func OpenStore() *config.Store { st, err := Store() if err != nil { @@ -143,23 +107,14 @@ func OpenStore() *config.Store { return st } -// ShortAuthError turns a tenant's sign-in failure into the one thing to do -// about it. The OAuth legs each report their own refusal, so the raw text runs -// to three clauses and buries the difference between a tenant this account -// cannot reach and one whose stored credential simply aged out. func ShortAuthError(err error) string { if err == nil { return "" } text := err.Error() switch { - // A sign-in that ran out of time proves nothing about access, and it - // arrives wrapped in ErrInteractiveRequired like a refusal does, so it has - // to be read before the access wording below claims it. case strings.Contains(text, "did not complete within"): return "sign-in timed out" - // The server rejecting a stored refresh token outright means this tenant - // did work once: the credential expired rather than access being denied. case strings.Contains(text, "invalid_grant"), strings.Contains(text, "stored refresh token rejected"): return "sign-in expired" @@ -173,8 +128,6 @@ func ShortAuthError(err error) string { if i := strings.Index(text, "\n"); i > 0 { text = text[:i] } - // The SD-WAN API answers failures with a JSON document whose only useful - // part is the error code; printed whole it buries the line it is on. if code := apiErrorCode(text); code != "" { return code } @@ -188,14 +141,10 @@ func apiErrorCode(text string) string { if m == nil { return "" } - // Keep whatever came before the document, which says which call failed. prefix, _, _ := strings.Cut(text, "{") return strings.TrimRight(strings.TrimSpace(prefix), ":") + ": " + m[1] } -// IsAuthFailure guesses whether a failure was the credential rather than the -// device. The wire gives us only text: a rejected websocket handshake reports -// the status it got instead of 101, and the REST calls report resp.Status. func IsAuthFailure(msg string) bool { low := strings.ToLower(msg) for _, s := range []string{ diff --git a/internal/app/auth_test.go b/internal/app/auth_test.go index b75ed54..fe4524a 100644 --- a/internal/app/auth_test.go +++ b/internal/app/auth_test.go @@ -66,8 +66,6 @@ func TestBrowserSourcePersistsRotatedToken(t *testing.T) { t.Errorf("RefreshToken() = %q, want empty with nothing stored", got) } - // The save hook is what the token source calls after an exchange; invoking it - // through the source keeps this honest about the wiring. if err := st.Secrets.Set(config.RefreshTokenKey("1984021017"), "rotated"); err != nil { t.Fatalf("Set() error = %v", err) } @@ -156,7 +154,6 @@ func TestIsAuthFailure(t *testing.T) { {"rest unauthorized", `element 123: status 401 Unauthorized: {"message":"nope"}`, true}, {"rest forbidden", "profile: status 403 Forbidden: ", true}, {"refresh grant rejected", `token: status 400: {"error":"invalid_grant"}`, true}, - // Things that are not the credential must not trigger a browser. {"element genuinely missing", "element 123: status 404 Not Found: ", false}, {"device unreachable", "expected handshake response status code 101 but got 502", false}, {"rate limited", "element 123: status 429 Too Many Requests: ", false}, @@ -205,9 +202,6 @@ func TestATenantsOwnTokenComesFirstButTheSeedIsStillThere(t *testing.T) { if got := src.RefreshToken(); got != "tenant-rt" { t.Errorf("RefreshToken() = %q, want the tenant's own %q", got, "tenant-rt") } - // The seed is attached but unreached while the tenant's own token works, - // so it is not spent; withholding it instead sent a tenant whose token - // had merely expired all the way to the browser. if src.Seed() == nil { t.Error("Seed() = nil, want the account token available for when this tenant's own is rejected") } @@ -221,8 +215,6 @@ func TestDebugRedirectsEveryStoreThisProcessOpens(t *testing.T) { if err != nil { t.Fatalf("Store() error = %v", err) } - // BrowserSource opens its own store, so a --debug run that left this one - // pointed at the real root would still write a refresh token to the keyring. if got := st.Secrets.Backend(); got != "memory" { t.Errorf("secrets backend = %q, want memory", got) } @@ -256,8 +248,6 @@ func TestFixturesSeedTenantsThatResolveWithoutASignIn(t *testing.T) { t.Errorf("secrets backend = %q, want a fixture run to keep nothing", st.Secrets.Backend()) } - // The whole point of seeding: the ordinary credential path is what runs, - // rather than a fixture-only branch through the tenant resolution. creds, err := ResolveCredentials(st, "Acme Co") if err != nil { t.Fatalf("ResolveCredentials() error = %v", err) @@ -269,8 +259,6 @@ func TestFixturesSeedTenantsThatResolveWithoutASignIn(t *testing.T) { t.Errorf("ValidateCreds() = %v; a seeded tenant has to look usable", err) } - // Opening a second store must not double up or fail: every entry point - // opens its own. again, err := Store() if err != nil { t.Fatalf("second Store() error = %v", err) @@ -282,8 +270,6 @@ func TestFixturesSeedTenantsThatResolveWithoutASignIn(t *testing.T) { if len(cfg.Tenants) != 1 { t.Errorf("config holds %d tenants after two opens, want 1", len(cfg.Tenants)) } - // Seeding happens once, so the secrets have to be shared the way the OS - // keyring is or the second store would find none. if v, err := again.Secrets.Get(config.IONPassKey("000000000", "admin")); err != nil || v != "ion" { t.Errorf("second store's device password = %q, %v; want the seeded one", v, err) } @@ -296,9 +282,6 @@ func TestShortAuthErrorSaysWhatToDoAboutIt(t *testing.T) { want string }{ { - // The one that matters most: the server rejecting a stored - // refresh token means this tenant worked once, so the fix is to - // sign in again -- not to go looking for an access problem. name: "expired refresh token", err: fmt.Errorf("%w: stored refresh token rejected: token: status 400 Bad Request: "+ `{"error_description":"grant is invalid","error":"invalid_grant"}; `+ @@ -313,7 +296,6 @@ func TestShortAuthErrorSaysWhatToDoAboutIt(t *testing.T) { want: "not accessible with this sign-in", }, { - // Proves nothing about access, and arrives wrapped the same way. name: "sign-in timed out", err: fmt.Errorf("%w: sign-in did not complete within 2m0s", saseauth.ErrInteractiveRequired), want: "sign-in timed out", @@ -328,7 +310,6 @@ func TestShortAuthErrorSaysWhatToDoAboutIt(t *testing.T) { if !strings.Contains(got, tt.want) { t.Errorf("ShortAuthError() = %q, want it to say %q", got, tt.want) } - // The point is to replace the wall of OAuth text, not to reprint it. if tt.err != nil && len(got) >= len(tt.err.Error()) && len(tt.err.Error()) > 80 { t.Errorf("ShortAuthError() = %q, no shorter than the original", got) } diff --git a/internal/app/cli.go b/internal/app/cli.go index 08b20fa..543bc84 100644 --- a/internal/app/cli.go +++ b/internal/app/cli.go @@ -1,6 +1,7 @@ package app import ( + "errors" "fmt" "os" "path/filepath" @@ -11,7 +12,6 @@ import ( "tyrtop.com/iontk/internal/ui" ) -// --themes, read by every command: a batch prints marks and a bar too var ThemesFile string func ThemesPath(st *config.Store) string { @@ -21,11 +21,18 @@ func ThemesPath(st *config.Store) string { if st == nil { return "" } - return filepath.Join(st.Root, ui.ThemesFileName) + return filepath.Join(st.Root, ui.ThemesDirName) +} + +func RemoveThemes(st *config.Store) error { + if st == nil { + return nil + } + dir := filepath.Join(st.Root, ui.ThemesDirName) + legacy := filepath.Join(st.Root, ui.ThemesFileName) + return errors.Join(os.RemoveAll(dir), os.RemoveAll(legacy)) } -// Called by every command, not just the TUI. Nothing here can fail a run; a -// nil cfg is read from the store. func ApplyDisplaySettings(st *config.Store, cfg *config.Config) { _ = ui.LoadThemes(ThemesPath(st)) if cfg == nil { @@ -38,11 +45,10 @@ func ApplyDisplaySettings(st *config.Store, cfg *config.Config) { } } _ = clock.Use(cfg.Zone) + ui.HideThemes(cfg.HiddenThemes) ui.UseTheme(cfg.Theme) } -// CredSource is which rule picked the credentials. It gets printed, because -// otherwise the first sign of a wrong tenant is a fleet of dead elements. type CredSource string const ( @@ -52,22 +58,15 @@ const ( FromOnly CredSource = "the only saved tenant" ) -// Credentials is what a run with no TUI signs in with, and where that came -// from. type Credentials struct { Flags Flags Tenant config.Tenant From CredSource - // An environment credential that --tenant overrode. Printed, so an ignored - // SCM_TOKEN isn't a surprise. dropped string - // Kept so we can tell a keyring from the fallback file. Nil for an - // environment run, which opens no store. secrets config.Secrets } -// No secret values in here. It's for showing which tenant a run picked. func (c Credentials) Lines() []string { var out []string if c.From == FromEnv { @@ -80,7 +79,6 @@ func (c Credentials) Lines() []string { } if c.secrets != nil && config.Plaintext(c.secrets) { w := "secrets: " + config.SecretsWarning(c.secrets) - // Only the fallback store has a file to name. if p, ok := c.secrets.(interface{ Path() string }); ok { w += " at " + p.Path() } @@ -89,12 +87,6 @@ func (c Credentials) Lines() []string { return out } -// ResolveCredentials reads the store and the keyring the same way the TUI -// does. Environment variables come before the saved tenants, so CI and -// SCM_TOKEN still work on a machine that has signed in. -// -// The secrets only go into the returned Flags. They never reach the -// environment and never get printed. func ResolveCredentials(st *config.Store, name string) (Credentials, error) { env := BaseFlags() @@ -134,27 +126,20 @@ func ResolveCredentials(st *config.Store, name string) (Credentials, error) { } func tenantCredentials(st *config.Store, t config.Tenant, from CredSource) (Credentials, error) { - // Pass ION_USER through instead of falling back to the tenant's default - // user. If ION_PASS is set too, it goes with the user named next to it. f := TenantFlags(st, t, os.Getenv("ION_USER")) c := Credentials{Tenant: t, From: from, secrets: st.Secrets} if from == FromFlag && f.Token != "" { - // Otherwise NewClient picks up SCM_TOKEN, and the run hits whatever - // tenant issued that token while reporting this one. f.Token = "" c.dropped = "ignoring SCM_TOKEN: --tenant chose a saved tenant" } if err := f.ValidateCreds(); err != nil { - // A tenant can be in config.json with its keyring entry gone. return Credentials{}, fmt.Errorf("tenant %s has no usable credential: %w", t.Name, err) } c.Flags = f return c, nil } -// TenantChoices lists the names, so a credential error can say what the -// options are. func TenantChoices(cfg *config.Config) string { var names []string for _, t := range cfg.Tenants { diff --git a/internal/app/cli_test.go b/internal/app/cli_test.go index c5dc53f..4bde3d4 100644 --- a/internal/app/cli_test.go +++ b/internal/app/cli_test.go @@ -10,8 +10,6 @@ import ( "tyrtop.com/iontk/internal/ui" ) -// Tests have to ignore whatever the developer has exported. Six variables feed -// the resolution and any of them changes the answer. func clearCredEnv(t *testing.T) { t.Helper() for _, k := range []string{ @@ -36,8 +34,6 @@ func serviceTenant(name, tsg string) config.Tenant { return config.Tenant{Name: name, TSGID: tsg, ClientID: "client-" + tsg, Auth: config.AuthServiceAccount} } -// The order is the point. A run that picks a different tenant than the user -// meant shows up as elements that won't answer. func TestResolveCredentialsOrder(t *testing.T) { tests := []struct { name string @@ -131,8 +127,6 @@ func TestResolveCredentialsReadsTheKeyring(t *testing.T) { t.Fatalf("SaveConfig() error = %v", err) } - // The CLI used to read the environment only, so a signed-in tenant - // couldn't run a single command headless. got, err := ResolveCredentials(st, "") if err != nil { t.Fatalf("ResolveCredentials() error = %v", err) @@ -168,8 +162,6 @@ func TestResolveCredentialsPrefersTheEnvironmentDeviceLogin(t *testing.T) { if err != nil { t.Fatalf("ResolveCredentials() error = %v", err) } - // ION_USER naming a different login than the tenant's default must not end - // up paired with the default's password. if got.Flags.IONUsername != "bob" { t.Errorf("IONUsername = %q, want ION_USER to win over the tenant default", got.Flags.IONUsername) } @@ -190,8 +182,6 @@ func TestResolveCredentialsDropsAnEnvironmentTokenForAnExplicitTenant(t *testing if err != nil { t.Fatalf("ResolveCredentials() error = %v", err) } - // Keeping the token would sign in as whatever tenant issued it while the - // run reports Acme. if got.Flags.Token != "" { t.Errorf("Token = %q, want --tenant to override SCM_TOKEN", got.Flags.Token) } @@ -265,8 +255,6 @@ func TestResolveCredentialsWithoutAStore(t *testing.T) { } } -// A keyring needs no warning. The fallback file does, and only the TUI used to -// print it. func TestCredentialLinesNameThePlaintextStore(t *testing.T) { clearCredEnv(t) cfg := &config.Config{Tenants: []config.Tenant{serviceTenant("Acme", "1")}} @@ -285,7 +273,6 @@ func TestCredentialLinesNameThePlaintextStore(t *testing.T) { t.Errorf("Lines() = %q, want it to mention %q", lines, want) } } - // Naming the store must not print what's in it. if strings.Contains(lines, "s3cr3t") { t.Errorf("Lines() = %q, want no secret in it", lines) } @@ -304,7 +291,6 @@ func TestCredentialLinesNameThePlaintextStore(t *testing.T) { } } -// An OS keyring may not exist on the machine running the tests. type fakeKeyring struct{} func (fakeKeyring) Backend() string { return "keyring" } @@ -312,8 +298,6 @@ func (fakeKeyring) Get(string) (string, error) { return "kept", nil } func (fakeKeyring) Set(string, string) error { return nil } func (fakeKeyring) Delete(string) error { return nil } -// Both halves of the paging fix reach a headless run through Defaults. Lose -// either one and long output hangs until the session times out. func TestHeadlessFlagsCarryThePagerHandling(t *testing.T) { clearCredEnv(t) f := BaseFlags() @@ -326,8 +310,6 @@ func TestHeadlessFlagsCarryThePagerHandling(t *testing.T) { } } -// Every command draws something -- marks, a progress bar, a time -- so every -// command takes the appearance and the zone out of the store it just opened. func TestApplyDisplaySettingsReadsTheStore(t *testing.T) { clearCredEnv(t) t.Cleanup(func() { @@ -354,8 +336,6 @@ func TestApplyDisplaySettingsReadsTheStore(t *testing.T) { } } -// A zone that will not load must not stop a batch run: the times print in UTC -// and say so. func TestABadZoneDoesNotStopACommand(t *testing.T) { clearCredEnv(t) t.Cleanup(func() { _ = clock.Use(clock.UTC) }) diff --git a/internal/app/client.go b/internal/app/client.go index fc55192..09c5993 100644 --- a/internal/app/client.go +++ b/internal/app/client.go @@ -11,11 +11,6 @@ import ( "tyrtop.com/iontk/internal/sase/api" ) -// NewClient builds an authenticated client for the tenant in f. The store is -// passed rather than opened here: a browser tenant reads and writes refresh -// tokens through it, and a function that reaches for the real one on its own -// cannot be pointed anywhere else -- a test that touched this path spent live -// credentials and wrote into the real keyring. func NewClient(ctx context.Context, st *config.Store, f Flags, logw io.Writer) (*api.Client, error) { wsClient := &http.Client{Transport: &http.Transport{ MaxConnsPerHost: f.Concurrency, @@ -33,14 +28,8 @@ func NewClient(ctx context.Context, st *config.Store, f Flags, logw io.Writer) ( } if Fixture != nil { - // Nothing here may reach the network, so the transport answers the REST - // calls and the token is never minted. The request building, status - // handling and decoding are still the production ones. fmt.Fprintln(logw, "using fixtures") fake := &http.Client{Transport: Fixture.Transport(f.TSGID)} - // Not rate limited: --rps exists to stay inside the real API's limit, - // and holding a fixture run to five requests a second makes a page - // that fetches a dozen endpoints take seconds for nothing. const fixtureRPS = 1000 return setup(api.NewClient(fake, fake, "fixture", f.Verbose, fixtureRPS, fixtureRPS)), nil } diff --git a/internal/app/flags.go b/internal/app/flags.go index d99f483..daad44e 100644 --- a/internal/app/flags.go +++ b/internal/app/flags.go @@ -9,9 +9,6 @@ import ( "tyrtop.com/iontk/internal/sase/ioncli" ) -// RootFlags are the knobs the root command binds its command-line flags to. -// They live here, not in the command, because the TUI reads the same numbers -// without ever having parsed a flag. type RootFlags struct { HTTPTimeout time.Duration ElementTimeout time.Duration @@ -29,8 +26,6 @@ type RootFlags struct { PagerPrompts []string } -// Defaults doubles as the flag defaults the root command declares, so the -// numbers are stated once and a run that never parsed a flag still works. var Defaults = RootFlags{ HTTPTimeout: 15 * time.Second, ElementTimeout: 60 * time.Second, @@ -40,12 +35,10 @@ var Defaults = RootFlags{ Burst: 20, DialAttempts: 3, DialTimeout: 10 * time.Second, - // the portal's own captures peak at 8-9 a second, and a 429 is waited out - RPS: 10, - PagerPrompts: ioncli.DefaultPagers, - // Works on an ion 1200-s running 6.3.4. "terminal length 0", "no paging" - // and "set cli pager off" all get rejected there. If a device rejects this - // one too, the pager scraper handles the run. + RPS: 10, + PagerPrompts: ioncli.DefaultPagers, + // works on an ion 1200-s running 6.3.4, where "terminal length 0", "no paging" + // and "set cli pager off" are all rejected PagerOff: "set paging off", } @@ -107,8 +100,6 @@ func (f Flags) ValidateCreds() error { if f.ClientID != "" && f.ClientSecret != "" && f.TSGID != "" { return nil } - // Browser authentication carries the tenant in the token scope, so the - // tenant is the only thing it still needs up front. if f.BrowserAuth && f.TSGID != "" { return nil } @@ -122,9 +113,6 @@ func (f Flags) Validate() error { return f.ValidateRun() } -// ValidateRun checks everything but the credentials, for a run whose targets -// carry their own: a --search can span tenants, and each one signs in for -// itself. func (f Flags) ValidateRun() error { if f.RefreshMargin <= 0 { return fmt.Errorf("refresh margin must be greater than 0") diff --git a/internal/app/signin.go b/internal/app/signin.go index aa7f3e6..e93a499 100644 --- a/internal/app/signin.go +++ b/internal/app/signin.go @@ -11,13 +11,6 @@ import ( "tyrtop.com/iontk/internal/saseauth" ) -// SignIn is the account-level browser sign-in: it stores the tenant-less -// credential and leaves a session in the browser profile, which is what lets -// each tenant be scoped afterwards with no further window. -// -// It lives here rather than in either front end because the TUI and the CLI -// have to sign in the same way. Two sign-in paths would be two sets of keys to -// keep straight. func SignIn(ctx context.Context, st *config.Store, w io.Writer) (saseauth.Discovery, error) { opt := BrowserOptions(&http.Client{Timeout: Defaults.HTTPTimeout}, st) d, err := saseauth.Discover(ctx, opt, "") @@ -29,8 +22,6 @@ func SignIn(ctx context.Context, st *config.Store, w io.Writer) (saseauth.Discov return d, fmt.Errorf("save the account credential: %w", err) } } - // The TUI's switcher reads this list, so a sign-in that left it behind - // would be a sign-in the TUI cannot see. known := make([]config.KnownTenant, 0, len(d.Tenants)) for _, t := range d.Tenants { known = append(known, config.KnownTenant{TSGID: t.ID, Name: t.Name}) @@ -44,13 +35,6 @@ func SignIn(ctx context.Context, st *config.Store, w io.Writer) (saseauth.Discov return d, nil } -// WarmTenants gives each tenant a refresh token of its own, which is the only -// credential that lasts: a per-tenant token is rotated and re-stored on every -// use, while the account one is spent by whichever tenant narrows it first and -// cannot be both that tenant's credential and the next tenant's seed. -// -// One at a time, scoped against the browser session a SignIn has just left: -// that session is one profile and cannot be driven in parallel. func WarmTenants(ctx context.Context, st *config.Store, tenants []config.Tenant, w io.Writer) error { httpClient := &http.Client{Timeout: Defaults.HTTPTimeout} var failed int @@ -60,9 +44,6 @@ func WarmTenants(ctx context.Context, st *config.Store, tenants []config.Tenant, fmt.Fprintf(w, " %-28s service account, nothing to sign in\n", t.Name) continue } - // Started with no stored token and no seed: whatever was stored for - // this tenant is why we are here, and the account credential is left - // unspent for a run that has nothing else. src := saseauth.NewSource(t.TSGID, "", BrowserOptions(httpClient, st), func(rt string) error { return st.Secrets.Set(config.RefreshTokenKey(t.TSGID), rt) }) if _, _, err := src.Token(ctx); err != nil { @@ -77,17 +58,12 @@ func WarmTenants(ctx context.Context, st *config.Store, tenants []config.Tenant, return fmt.Errorf("no tenant could be signed in") } if failed > 0 { - // Not an error: the tenants that did work are usable, and a tenant - // the account cannot reach will not start working on a retry. fmt.Fprintf(w, "%d of %d tenants could not be signed in; this account may not have access to them\n", failed, len(tenants)) } return nil } -// SessionLeft is how long a tenant's stored credential can keep minting -// tokens. Reading the expiry out of the token costs nothing and spends -// nothing, so both front ends can say "expired" without a round trip. func SessionLeft(st *config.Store, tsg string) (time.Duration, bool) { if st == nil { return 0, false diff --git a/internal/clock/clock.go b/internal/clock/clock.go index a51ab78..e4116f5 100644 --- a/internal/clock/clock.go +++ b/internal/clock/clock.go @@ -1,5 +1,3 @@ -// Package clock is the zone printed times are read in. Conversion stays in -// api.UTCTime; durations are relative, so they do not belong here. package clock import ( @@ -13,18 +11,15 @@ const Local = "local" const UTC = "UTC" -// zone named, or the reading needs a setting to interpret const layout = "2006-01-02 15:04 MST" var ( - mu sync.RWMutex - loc = time.UTC - name = UTC - // read on the settings page, long after the config was + mu sync.RWMutex + loc = time.UTC + name = UTC problem string ) -// a typo in config must not stop iontk starting func Use(spec string) error { l, resolved, err := load(spec) @@ -64,7 +59,6 @@ func Zone() *time.Location { return loc } -// as spelled, not as resolved: callers compare against Local func Name() string { mu.RLock() defer mu.RUnlock() @@ -77,7 +71,6 @@ func Problem() string { return problem } -// zero is blank: a field never set is not a date in 1970 func Stamp(t time.Time) string { if t.IsZero() { return "" diff --git a/internal/clock/clock_test.go b/internal/clock/clock_test.go index 4170e04..8a3bcb9 100644 --- a/internal/clock/clock_test.go +++ b/internal/clock/clock_test.go @@ -6,8 +6,6 @@ import ( "time" ) -// pin puts the zone back afterwards. The zone is process state, so a test -// that left it set would decide what every later test printed. func pin(t *testing.T, spec string) error { t.Helper() t.Cleanup(func() { _ = Use(UTC) }) @@ -25,7 +23,6 @@ func TestUseResolvesTheZonesTheSettingOffers(t *testing.T) { {"nothing saved is UTC", "", "2026-10-18 13:25 UTC"}, {"UTC by name", "UTC", "2026-10-18 13:25 UTC"}, {"a named zone shifts the reading", "America/Chicago", "2026-10-18 08:25 CDT"}, - // Not an IANA name, so it cannot go through LoadLocation. {"local is whatever this machine is", Local, ""}, } @@ -48,8 +45,6 @@ func TestUseResolvesTheZonesTheSettingOffers(t *testing.T) { } } -// A zone that will not load must not take the program with it: the reading is -// labelled UTC, which is honest, and the settings page can say what happened. func TestAZoneThatWillNotLoadFallsBackToUTCAndSaysSo(t *testing.T) { err := pin(t, "Mars/Olympus_Mons") if err == nil { @@ -69,8 +64,6 @@ func TestAZoneThatWillNotLoadFallsBackToUTCAndSaysSo(t *testing.T) { } } -// Every reading says which zone it is in. A time with no zone on it is a -// number you have to remember a setting to read. func TestStampNamesItsZone(t *testing.T) { if err := pin(t, "Europe/London"); err != nil { t.Fatal(err) @@ -81,8 +74,6 @@ func TestStampNamesItsZone(t *testing.T) { } } -// A field the appliance never set arrives as zero, and a date in 1970 would -// read like one it did set. func TestAZeroTimeIsBlank(t *testing.T) { if got := Stamp(time.Time{}); got != "" { t.Errorf("Stamp(zero) = %q, want empty", got) @@ -92,8 +83,6 @@ func TestAZeroTimeIsBlank(t *testing.T) { } } -// RFC3339 carries its offset, so machine-readable output stays unambiguous -// whatever the display zone is. func TestRFC3339CarriesTheOffset(t *testing.T) { if err := pin(t, "America/Chicago"); err != nil { t.Fatal(err) diff --git a/internal/config/cache.go b/internal/config/cache.go index 4a1766c..b4a1c7a 100644 --- a/internal/config/cache.go +++ b/internal/config/cache.go @@ -10,8 +10,6 @@ import ( "tyrtop.com/iontk/internal/sase/api" ) -// CacheFresh is how long cached elements are treated as current, by the TUI -// on launch and by a --search that would rather answer from disk. const CacheFresh = 15 * time.Minute type ElementCache struct { @@ -19,19 +17,15 @@ type ElementCache struct { FetchedAt time.Time `json:"fetched_at"` Elements []api.Element `json:"elements"` Sites map[string]string `json:"sites,omitempty"` - // last sweep's states, shown with an asterisk: as old as this file - HA map[string]string `json:"ha,omitempty"` + HA map[string]string `json:"ha,omitempty"` } -// a sweep finishes long after the elements were written func (s *Store) SaveHA(tsg string, states map[string]string) error { c, err := s.Cache(tsg) if err != nil { return err } if c == nil { - // Nothing to attach it to: the elements are what the file is for, and - // HA state without them has nothing to key against. return nil } if c.HA == nil { @@ -91,6 +85,4 @@ func (c *ElementCache) Stale(ttl time.Duration) bool { return c == nil || c.FetchedAt.IsZero() || c.Age() > ttl } -// Usable is a cache worth reading: one that was written and has something in -// it. An empty one is indistinguishable from a tenant that answered nothing. func (c *ElementCache) Usable() bool { return c != nil && len(c.Elements) > 0 } diff --git a/internal/config/commands.go b/internal/config/commands.go index 263c9a5..e5d56fc 100644 --- a/internal/config/commands.go +++ b/internal/config/commands.go @@ -10,34 +10,17 @@ import ( "time" ) -// HistoryMax bounds the file. History is written on every command a session -// sends, so without a cap it grows for the life of the install; the least -// recently used entries go first, which keeps a rarely-run favourite that is -// still in use over a one-off typed last month. const HistoryMax = 200 -// FavouriteSlots is alt+1 to alt+9. const FavouriteSlots = 9 -// CommandStore is history, favourites and macros in one file. They share it -// because they are the same thing at different lifetimes: something typed -// once, something worth a key, and something worth a name. -// -// It is separate from config.json because it churns -- a line per command sent -// -- and a tenant list should not be rewritten for that. type CommandStore struct { - History []CommandUse `json:"history,omitempty"` - Favourites []Favourite `json:"favourites,omitempty"` - Macros []Macro `json:"macros,omitempty"` - // Acknowledged is the warnings dismissed for good, by ID rather than a - // field apiece: this is the first of several, and a factory reset takes - // them with the rest of the file. - Acknowledged []string `json:"acknowledged,omitempty"` + History []CommandUse `json:"history,omitempty"` + Favourites []Favourite `json:"favourites,omitempty"` + Macros []Macro `json:"macros,omitempty"` + Acknowledged []string `json:"acknowledged,omitempty"` } -// CommandUse is one command as run on one model. The model is part of the -// identity rather than a label: `dump interface status 5` is a different -// command on an ion 1200, which has no port 5. type CommandUse struct { Command string `json:"command"` Model string `json:"model,omitempty"` @@ -48,20 +31,15 @@ type CommandUse struct { type Favourite struct { Slot int `json:"slot"` Command string `json:"command"` - // Model is what the command was captured on, so one that only exists on - // that model can be flagged rather than silently failing elsewhere. - Model string `json:"model,omitempty"` + Model string `json:"model,omitempty"` } type Macro struct { Name string `json:"name"` Commands []string `json:"commands"` - // Model is kept for the same reason a favourite's is. - Model string `json:"model,omitempty"` + Model string `json:"model,omitempty"` } -// Record counts one use. The clock is passed in so a caller can be tested -// without sleeping. func (c *CommandStore) Record(command, model string, at time.Time) { command = strings.TrimSpace(command) if c == nil || command == "" { @@ -88,10 +66,6 @@ func (c *CommandStore) trimHistory() { c.History = c.History[:HistoryMax] } -// Ranked is the history to offer on a model: what has been run on this one -// most often first, then what was run on another. The second group is still -// worth offering -- most commands are the same across models -- and is what -// Model is kept to tell apart. func (c *CommandStore) Ranked(model string) []CommandUse { if c == nil { return nil @@ -114,8 +88,6 @@ func (c *CommandStore) Ranked(model string) []CommandUse { return out } -// Forget drops one entry, which is what makes a list of mistyped commands -// recoverable. func (c *CommandStore) Forget(command, model string) bool { if c == nil { return false @@ -174,8 +146,6 @@ func (c *CommandStore) ClearFavourite(slot int) bool { return false } -// FavouriteList is every slot that is filled, in slot order: the file keeps -// them in the order they were set. func (c *CommandStore) FavouriteList() []Favourite { if c == nil { return nil @@ -231,7 +201,6 @@ func (c *CommandStore) RemoveMacro(name string) bool { return false } -// Ack records a warning as answered for good. func (c *CommandStore) Ack(id string) { if c == nil || id == "" || c.Acked(id) { return @@ -253,8 +222,6 @@ func (c *CommandStore) Acked(id string) bool { func (s *Store) commandsPath() string { return filepath.Join(s.Root, "commands.json") } -// Commands returns the saved store, or an empty one. A first run has no file, -// which is not an error. func (s *Store) Commands() (*CommandStore, error) { b, err := os.ReadFile(s.commandsPath()) if os.IsNotExist(err) { @@ -265,8 +232,6 @@ func (s *Store) Commands() (*CommandStore, error) { } var c CommandStore if err := json.Unmarshal(b, &c); err != nil { - // The caller gets a usable store either way: a corrupt history should - // cost the history, not the session someone is trying to open. return &CommandStore{}, fmt.Errorf("parse commands: %w", err) } return &c, nil diff --git a/internal/config/commands_test.go b/internal/config/commands_test.go index 0dc6a01..388dcef 100644 --- a/internal/config/commands_test.go +++ b/internal/config/commands_test.go @@ -12,15 +12,11 @@ var epoch = time.Date(2026, 10, 19, 12, 0, 0, 0, time.UTC) func at(min int) time.Time { return epoch.Add(time.Duration(min) * time.Minute) } -// The same command on the same model is one entry with a count; on another -// model it is a different command, since the ports and subsystems differ. func TestRecordCountsUsesPerModel(t *testing.T) { var c CommandStore c.Record("dump device status", "ion 1200", at(0)) c.Record("dump device status", "ion 1200", at(1)) c.Record("dump device status", "ion 3200", at(2)) - // Typed with trailing space, which is what a device line looks like after - // the palette inserts a completion. c.Record(" dump device status ", "ion 1200", at(3)) c.Record("", "ion 1200", at(4)) @@ -41,7 +37,6 @@ func TestRecordCountsUsesPerModel(t *testing.T) { func TestRankedPutsThisModelsCommandsFirst(t *testing.T) { var c CommandStore - // Run far more often, but on another model. for i := 0; i < 5; i++ { c.Record("dump interface status 8", "ion 3200", at(i)) } @@ -50,9 +45,9 @@ func TestRankedPutsThisModelsCommandsFirst(t *testing.T) { c.Record("ping 10.0.0.1", "ion 1200", at(12)) want := []string{ - "dump device status", // this model, two uses - "ping 10.0.0.1", // this model, one - "dump interface status 8", // another model, however often it was run + "dump device status", + "ping 10.0.0.1", + "dump interface status 8", } var got []string for _, u := range c.Ranked("ion 1200") { @@ -62,15 +57,11 @@ func TestRankedPutsThisModelsCommandsFirst(t *testing.T) { t.Errorf("Ranked(ion 1200) = %v, want %v", got, want) } - // Ranking does not reorder what is stored, so the file does not churn on - // a read. if c.History[0].Command != "dump interface status 8" { t.Errorf("Ranked rewrote the history order: %+v", c.History) } } -// A history that grows for the life of the install would be a file nobody can -// read and a palette nobody can use. func TestHistoryIsBounded(t *testing.T) { var c CommandStore for i := 0; i < HistoryMax+10; i++ { @@ -79,8 +70,6 @@ func TestHistoryIsBounded(t *testing.T) { if len(c.History) > HistoryMax { t.Fatalf("history = %d entries, want at most %d", len(c.History), HistoryMax) } - // The oldest went, not the newest: an entry recorded a moment ago is the - // one most likely to be wanted again. if _, ok := find(c.History, "ping 10.0.0.0", "ion 1200"); ok { t.Error("the oldest entry survived the trim") } @@ -100,7 +89,6 @@ func TestForgetDropsOneEntry(t *testing.T) { if _, ok := find(c.History, "dump device status", "ion 1200"); ok { t.Error("the entry survived") } - // The other model's entry is a different command and stays. if _, ok := find(c.History, "dump device status", "ion 3200"); !ok { t.Error("forgetting one model's entry took the other's") } @@ -114,7 +102,6 @@ func TestFavouriteSlots(t *testing.T) { if err := c.SetFavourite(1, "dump device status", "ion 1200"); err != nil { t.Fatalf("SetFavourite() error = %v", err) } - // Setting an occupied slot replaces it rather than stacking. if err := c.SetFavourite(1, "dump vpn count", "ion 3200"); err != nil { t.Fatalf("SetFavourite() error = %v", err) } @@ -167,7 +154,6 @@ func TestMacrosAreKeptByName(t *testing.T) { if err := c.SetMacro(m); err != nil { t.Fatalf("SetMacro() error = %v", err) } - // Same name, different case: one macro, replaced. m.Name = "WAN Check" m.Commands = append(m.Commands, "dump vpn count") if err := c.SetMacro(m); err != nil { @@ -204,8 +190,6 @@ func TestMacrosAreKeptByName(t *testing.T) { } } -// Keyed by ID rather than a field per warning, because the cross-model one is -// the first of several. func TestAcknowledgedWarningsAreKeptByID(t *testing.T) { var c CommandStore if c.Acked("favourite/model") { @@ -228,7 +212,6 @@ func TestCommandsRoundTripThroughTheStore(t *testing.T) { root := t.TempDir() st := OpenAt(root, NewMemorySecrets()) - // A first run has no file, which is not an error. c, err := st.Commands() if err != nil { t.Fatalf("Commands() on a fresh root error = %v", err) @@ -266,7 +249,6 @@ func TestCommandsRoundTripThroughTheStore(t *testing.T) { t.Error("the acknowledgement did not survive the round trip") } - // A corrupt file costs the history, not the session someone is opening. if err := os.WriteFile(st.commandsPath(), []byte("{not json"), 0o600); err != nil { t.Fatal(err) } diff --git a/internal/config/config.go b/internal/config/config.go index 8d74ed1..62270e0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -14,26 +14,20 @@ type IONUser struct { Default bool `json:"default,omitempty"` } -// How a tenant proves who it is. An empty Auth means a service account, so -// tenants written before browser sign-in existed keep working. const ( AuthServiceAccount = "service_account" AuthBrowser = "browser" ) type Tenant struct { - Name string `json:"name"` - TSGID string `json:"tsg_id"` - ClientID string `json:"client_id"` - Auth string `json:"auth,omitempty"` - // Added means this tenant is part of the working set: its elements are in - // the list and searchable across tenants. The set is chosen from the - // discovered tenants when signing in, and changed in settings. - Added bool `json:"added,omitempty"` - Users []IONUser `json:"users,omitempty"` + Name string `json:"name"` + TSGID string `json:"tsg_id"` + ClientID string `json:"client_id"` + Auth string `json:"auth,omitempty"` + Added bool `json:"added,omitempty"` + Users []IONUser `json:"users,omitempty"` } -// AddedTenants are the tenants in the working set. func (c *Config) AddedTenants() []Tenant { if c == nil { return nil @@ -47,7 +41,6 @@ func (c *Config) AddedTenants() []Tenant { return out } -// IsAdded reports whether a tenant ID is in the working set. func (c *Config) IsAdded(tsg string) bool { if c == nil { return false @@ -62,7 +55,6 @@ func (c *Config) IsAdded(tsg string) bool { func (t Tenant) UsesBrowser() bool { return t.Auth == AuthBrowser } -// AuthLabel names the tenant's sign-in method for display. func (t Tenant) AuthLabel() string { if t.UsesBrowser() { return "browser" @@ -71,29 +63,24 @@ func (t Tenant) AuthLabel() string { } type Config struct { - LastTenant string `json:"last_tenant,omitempty"` - Spinner string `json:"spinner,omitempty"` - DefaultMode string `json:"default_mode,omitempty"` - AuthMode string `json:"auth_mode,omitempty"` - AllowWrites bool `json:"allow_writes,omitempty"` - SessionCols int `json:"session_cols,omitempty"` - SessionRows int `json:"session_rows,omitempty"` - // PageStep is how far alt+up and alt+down move through the element list. - PageStep int `json:"page_step,omitempty"` - // Theme names the colour scheme; Zone is the IANA zone every printed time - // is read in, or "local", or empty for UTC. - Theme string `json:"theme,omitempty"` - Zone string `json:"zone,omitempty"` - BatchFormat string `json:"batch_format,omitempty"` - BatchOrient string `json:"batch_orient,omitempty"` - BatchToFile bool `json:"batch_to_file,omitempty"` - BatchDir string `json:"batch_dir,omitempty"` - Tenants []Tenant `json:"tenants,omitempty"` + LastTenant string `json:"last_tenant,omitempty"` + Spinner string `json:"spinner,omitempty"` + DefaultMode string `json:"default_mode,omitempty"` + AuthMode string `json:"auth_mode,omitempty"` + AllowWrites bool `json:"allow_writes,omitempty"` + SessionCols int `json:"session_cols,omitempty"` + SessionRows int `json:"session_rows,omitempty"` + PageStep int `json:"page_step,omitempty"` + Theme string `json:"theme,omitempty"` + HiddenThemes []string `json:"hidden_themes,omitempty"` + Zone string `json:"zone,omitempty"` + BatchFormat string `json:"batch_format,omitempty"` + BatchOrient string `json:"batch_orient,omitempty"` + BatchToFile bool `json:"batch_to_file,omitempty"` + BatchDir string `json:"batch_dir,omitempty"` + Tenants []Tenant `json:"tenants,omitempty"` } -// BatchOutDir is the folder batch results are written to, or empty for stdout. -// A folder is only used when output to a file is turned on, so turning it off -// does not lose the path. func (c *Config) BatchOutDir() string { if c == nil || !c.BatchToFile { return "" @@ -122,27 +109,14 @@ func OpenAt(root string, s Secrets) *Store { return &Store{Root: root, Secrets: s} } -// DebugRoot is where --debug keeps its throwaway config. Fixed rather than -// random so a debug run can be looked at afterwards. func DebugRoot() string { return filepath.Join(os.TempDir(), "iontk-debug") } -// Set up once per process, not per open: the memory store stands in for the OS -// keyring, which every store in a run shares, and the root has to be cleared -// before the first open rather than at every one. var ( debugOnce sync.Once debugSecrets Secrets debugErr error ) -// OpenDebug is the store --debug and --fixtures run against: a root outside the -// real config directory and secrets that never leave memory, so nothing such a -// run does can reach a saved tenant or the keyring. -// -// The root is fixed so it can be looked at afterwards, which means an earlier -// run's tenants would still be in it. Clearing it here is what makes "no saved -// state" true; doing that at startup rather than at exit is what leaves the run -// inspectable. func OpenDebug() (*Store, error) { root := DebugRoot() debugOnce.Do(func() { @@ -163,8 +137,6 @@ func OpenDebug() (*Store, error) { func (s *Store) configPath() string { return filepath.Join(s.Root, "config.json") } -// BrowserDir is the browser profile kept for sign-in. Holding the identity -// provider's session cookie here is what lets a later sign-in skip the window. func (s *Store) BrowserDir() string { return filepath.Join(s.Root, "browser") } func (s *Store) Config() (*Config, error) { @@ -315,6 +287,4 @@ func IONPassKey(tsg, user string) string { return "tsg/" + tsg + "/ion_user/" + func RefreshTokenKey(tsg string) string { return "tsg/" + tsg + "/refresh_token" } -// AccountRefreshTokenKey holds the tenant-less credential a browser sign-in -// produces. It seeds every tenant that has no credential of its own. func AccountRefreshTokenKey() string { return "account/refresh_token" } diff --git a/internal/config/reset.go b/internal/config/reset.go index 78053c2..ea8e893 100644 --- a/internal/config/reset.go +++ b/internal/config/reset.go @@ -8,12 +8,6 @@ import ( "github.com/zalando/go-keyring" ) -// Reset deletes everything iontk has saved: keyring entries, element and tenant -// caches, the browser profile and the configuration itself. -// -// The order is the point. Keyring entries are per-key and can only be found -// through the tenant list in config.json, so deleting the config first would -// orphan every one of them with no way left to name them. func (s *Store) Reset() error { cfg, err := s.Config() if err != nil { @@ -32,25 +26,17 @@ func (s *Store) Reset() error { } errs = append(errs, s.dropSecret(AccountRefreshTokenKey())) - // The whole directory rather than each tenant's file: a cache left behind - // for a tenant that is no longer in the config would survive the reset. errs = append(errs, removePath(s.cacheDir()), removePath(s.tenantsPath()), removePath(s.commandsPath())) - // Keys this store does not know about are still in the fallback file, and a - // factory reset has to take them too. if p, ok := s.Secrets.(interface{ Path() string }); ok { errs = append(errs, removePath(p.Path())) } - // The browser profile holds the identity provider's session cookie, so a - // reset that kept it would not have signed the user out. errs = append(errs, removePath(s.BrowserDir()), removePath(s.configPath())) return errors.Join(errs...) } -// A key that was never stored is not a failure; a keyring that will not answer -// is, and has to be said rather than swallowed. func (s *Store) dropSecret(key string) error { err := s.Secrets.Delete(key) if err == nil || errors.Is(err, ErrNotFound) || errors.Is(err, keyring.ErrNotFound) { diff --git a/internal/config/reset_test.go b/internal/config/reset_test.go index 56c8832..4035177 100644 --- a/internal/config/reset_test.go +++ b/internal/config/reset_test.go @@ -7,7 +7,6 @@ import ( "time" ) -// savedStore is a store with something in every place a reset has to reach. func savedStore(t *testing.T) (*Store, *Config) { t.Helper() root := t.TempDir() @@ -63,8 +62,6 @@ func TestResetLeavesNothingBehind(t *testing.T) { t.Fatalf("Reset() error = %v", err) } - // Every key the tenant list named. These can only be found through - // config.json, which is why it is deleted last. for _, t2 := range cfg.Tenants { for _, u := range t2.Users { if v, err := st.Secrets.Get(IONPassKey(t2.TSGID, u.Name)); err == nil { @@ -86,11 +83,7 @@ func TestResetLeavesNothingBehind(t *testing.T) { st.configPath(), st.tenantsPath(), st.cacheDir(), - // History and favourites name the commands someone runs on their - // fleet, so a factory reset takes them too. st.commandsPath(), - // Keeping this would leave the identity provider's session cookie, so - // the reset would not have signed anyone out. st.BrowserDir(), filepath.Join(st.Root, "secrets.json"), } { @@ -119,8 +112,6 @@ func TestResetOnAFreshStoreIsNotAnError(t *testing.T) { func TestResetDeletesSecretsBeforeTheConfigThatNamesThem(t *testing.T) { st, _ := savedStore(t) - // The order is the whole correctness argument: a reset that removed - // config.json first could never name the keyring entries again. st.Secrets = &orderSpy{Secrets: st.Secrets, configPath: st.configPath()} if err := st.Reset(); err != nil { t.Fatalf("Reset() error = %v", err) diff --git a/internal/config/secrets.go b/internal/config/secrets.go index 3e978c1..97585ec 100644 --- a/internal/config/secrets.go +++ b/internal/config/secrets.go @@ -19,12 +19,8 @@ type Secrets interface { Backend() string } -// Plaintext reports whether secrets sit unencrypted on disk, which happens -// whenever no OS keyring answered the probe. func Plaintext(s Secrets) bool { return s.Backend() == "file" } -// SecretsWarning describes the store in a way that does not understate it, so -// a plaintext fallback is never mistaken for a keyring. func SecretsWarning(s Secrets) string { switch s.Backend() { case "file": @@ -83,7 +79,6 @@ func NewFileSecrets(path string) Secrets { return &fileSecrets{path: path} } func (f *fileSecrets) Backend() string { return "file" } -// Path is where the plaintext secrets live, so the UI can name the file. func (f *fileSecrets) Path() string { return f.path } func (f *fileSecrets) load() (map[string]string, error) { @@ -150,8 +145,6 @@ func (f *fileSecrets) Delete(key string) error { var ErrNotFound = fmt.Errorf("secret not found") -// memSecrets is what --debug stores credentials in: a run that must not read or -// write the real keyring still has to be able to hold a password it was typed. type memSecrets struct { mu sync.Mutex m map[string]string diff --git a/internal/config/secrets_test.go b/internal/config/secrets_test.go index cc60683..08ad1c7 100644 --- a/internal/config/secrets_test.go +++ b/internal/config/secrets_test.go @@ -139,7 +139,6 @@ func TestMemorySecretsNeverTouchDisk(t *testing.T) { t.Errorf("Get() after Delete error = %v, want ErrNotFound", err) } - // --debug exists so a run cannot leave credentials behind. entries, err := os.ReadDir(root) if err != nil { t.Fatal(err) diff --git a/internal/config/tenants.go b/internal/config/tenants.go index 22c0990..55b874d 100644 --- a/internal/config/tenants.go +++ b/internal/config/tenants.go @@ -10,16 +10,11 @@ import ( "time" ) -// KnownTenant is a tenant the signed-in account can reach. These are discovered -// rather than configured: the portal decides what the account can see, so there -// is nothing for the user to add. type KnownTenant struct { TSGID string `json:"tsg_id"` Name string `json:"name"` } -// TenantCache remembers the last discovered tenant list so the switcher works -// without a round trip, and so a restart does not need the browser. type TenantCache struct { FetchedAt time.Time `json:"fetched_at"` Account string `json:"account,omitempty"` @@ -28,8 +23,6 @@ type TenantCache struct { func (s *Store) tenantsPath() string { return filepath.Join(s.Root, "tenants.json") } -// TenantCache reads the last discovered list. A nil store has none, which keeps -// rendering safe before a store exists. func (s *Store) TenantCache() (*TenantCache, error) { if s == nil { return nil, nil diff --git a/internal/dataview/compare.go b/internal/dataview/compare.go index 19f5caa..bf910a0 100644 --- a/internal/dataview/compare.go +++ b/internal/dataview/compare.go @@ -5,23 +5,16 @@ import ( "strings" ) -// DeviceOutput is what the appliance printed, keyed by the command itself, so -// two views that ask the same question share one run of it. type DeviceOutput map[string]string -// Verdict is what the two sources said about one row. type Verdict int const ( - // Unknown is one side saying nothing at all: a field this firmware does - // not print, or a record that has not got it. That is not a disagreement, - // and marking it as one would cry wolf on every element. Unknown Verdict = iota Agree Differ ) -// Compared is one row: both answers and the verdict between them. type Compared struct { Label string Portal string @@ -29,21 +22,13 @@ type Compared struct { Verdict Verdict } -// Comparison is one view's worth of comparison. type Comparison struct { - View string - Rows []Compared - // Commands is what the device was asked, so a row that reads wrong can be - // run by hand. + View string + Rows []Compared Commands []string - // Errs is the portal fetches that failed, which is why a portal side may - // be blank. - Errs []string + Errs []string } -// CompareViews are the views whose comparison applies to this element. The -// gate is checked before anything is dialled: asking an unclustered appliance -// about HA costs the same session as asking a clustered one. func CompareViews(views []View, vars Vars) []View { var out []View for _, v := range views { @@ -58,9 +43,6 @@ func CompareViews(views []View, vars Vars) []View { return out } -// CompareCommands is every command those views ask for, deduplicated. The -// whole page's device half is one session: a dial and a login cost seconds and -// a session slot on the appliance. func CompareCommands(views []View) []string { seen := map[string]bool{} var out []string @@ -80,7 +62,6 @@ func CompareCommands(views []View) []string { return out } -// FetchCompare reads the portal side of a comparison. func FetchCompare(ctx context.Context, g Getter, v View, vars Vars) (*Data, error) { spec := View{Name: v.Name} if v.Compare != nil { @@ -89,9 +70,6 @@ func FetchCompare(ctx context.Context, g Getter, v View, vars Vars) (*Data, erro return Fetch(ctx, g, spec, vars) } -// RenderCompare puts the two sides of each row beside each other. The device -// output is in scope as .cli., as text: the appliance prints -// "Key : value" lines, which the kv helper reads. func RenderCompare(v View, d *Data, out DeviceOutput) Comparison { c := Comparison{View: v.Name} if v.Compare == nil { @@ -120,9 +98,6 @@ func RenderCompare(v View, d *Data, out DeviceOutput) Comparison { return c } -// verdict folds case and runs of whitespace before comparing: a portal -// "Active" and a device "active" are the same answer. Anything beyond that is -// the row's own job, which is why both sides are templates. func verdict(portal, device string) Verdict { if portal == "" || device == "" { return Unknown diff --git a/internal/dataview/compare_test.go b/internal/dataview/compare_test.go index 85edd7f..83c1f16 100644 --- a/internal/dataview/compare_test.go +++ b/internal/dataview/compare_test.go @@ -7,8 +7,6 @@ import ( "tyrtop.com/iontk/internal/sase/ioncli" ) -// The one captured piece of real CLI output, from an ion running 6.3.4-b2: -// keys and values separated by a tab and a colon, which is what kv reads. const dumpInterfaceStatus4 = `Interface : 4 Device : e382 ID : 1724777596590008596 @@ -26,8 +24,6 @@ func TestCLIHelpersReadDeviceOutput(t *testing.T) { }{ {"a key and its value", `{{ kv .out "State" }}`, "down"}, {"a key holding a space", `{{ kv .out "MAC Address" }}`, "04:47:2a:18:0e:05"}, - // Anchored at the line start, so "State" cannot match the "Not - // connected" that "Status" holds. {"a key that is a prefix of another", `{{ kv .out "Status" }}`, "Not connected"}, {"a key the firmware did not print", `{{ kv .out "Speed" }}`, ""}, {"case does not matter", `{{ kv .out "state" }}`, "down"}, @@ -58,8 +54,6 @@ func TestVerdictIgnoresCaseAndSpacing(t *testing.T) { {"padded by the device", "6.3.4-b2", " 6.3.4-b2 ", Agree}, {"columns of space", "ion 1200", "ion 1200", Agree}, {"two answers", "up", "down", Differ}, - // A key this firmware does not print is not a disagreement: marking - // it as one would cry wolf on every element. {"the device said nothing", "up", "", Unknown}, {"the portal has not got it", "", "up", Unknown}, } @@ -72,8 +66,6 @@ func TestVerdictIgnoresCaseAndSpacing(t *testing.T) { } } -// One session for the whole page, so the same command asked for by two views -// is run once. func TestCompareCommandsAreDeduplicated(t *testing.T) { views := []View{ {Name: "A", Compare: &Compare{Commands: map[string]string{"dev": "dump device status"}}}, @@ -90,8 +82,6 @@ func TestCompareCommandsAreDeduplicated(t *testing.T) { } } -// The gate is checked before anything is dialled: asking an unclustered -// appliance about HA costs the same session as asking a clustered one. func TestCompareViewsSkipWhatTheElementHasNotGot(t *testing.T) { views := []View{ {Name: "Always", Compare: &Compare{Commands: map[string]string{"a": "dump device status"}}}, @@ -123,8 +113,6 @@ func names(views []View) []string { return out } -// The whole comparison: a portal fetch of its own, the device output keyed by -// command, and a verdict per row. func TestRenderCompareMarksEachRow(t *testing.T) { set, err := Parse([]byte(` views: @@ -165,8 +153,6 @@ views: if err != nil { t.Fatalf("FetchCompare() error = %v", err) } - // The comparison reads the portal itself rather than reusing the page: a - // page can be minutes old, and two moments are not a comparison. if len(f.asked) != 1 { t.Errorf("asked %v, want the compare block's own fetch", f.asked) } @@ -192,8 +178,6 @@ views: } } -// A portal fetch that fails leaves the device side readable and says why the -// other half is blank. func TestRenderCompareReportsAFailedFetch(t *testing.T) { v := View{ Name: "Check", @@ -223,8 +207,6 @@ func TestRenderCompareReportsAFailedFetch(t *testing.T) { } } -// A view is editable data and iontk's session can type anything into a -// production appliance, so only the read-only verbs load. func TestLoadRejectsABadCompareBlock(t *testing.T) { tests := []struct { name string @@ -270,8 +252,6 @@ func TestLoadRejectsABadCompareBlock(t *testing.T) { } } -// The shipped comparisons are data like any other view, and a write verb in -// one would reach an appliance. func TestBuiltinComparisonsOnlyRead(t *testing.T) { set, err := Builtin() if err != nil { diff --git a/internal/dataview/dataview_test.go b/internal/dataview/dataview_test.go index 87e4b8c..70bc326 100644 --- a/internal/dataview/dataview_test.go +++ b/internal/dataview/dataview_test.go @@ -8,8 +8,6 @@ import ( "testing" ) -// fake answers by exact path and records what was asked, which is how the -// per-row fetching is checked without a tenant. type fake struct { bodies map[string]string asked []string @@ -28,12 +26,8 @@ type errNotFound string func (e errNotFound) Error() string { return "status 404 Not Found: " + string(e) } -// builtinNames is what ships, so adding a view does not mean editing every -// count in this file. var builtinNames = []string{"Overview", "Interfaces", "Circuits", "HA"} -// The built-in views are shipped data, so a typo in them is a bug that only -// shows up in the mode being empty. func TestBuiltinViewsLoad(t *testing.T) { set, err := Builtin() if err != nil { @@ -105,9 +99,6 @@ func TestLoadRejectsWhatAViewMayNotCall(t *testing.T) { } } -// A user file adds its views and replaces a built-in of the same name, so -// somebody who dislikes the shipped Overview can have their own without -// losing the rest. func TestLoadMergesAUserFileOverTheBuiltins(t *testing.T) { dir := t.TempDir() path := dir + "/views.yaml" @@ -147,7 +138,6 @@ views: t.Errorf("Overview = %+v, want the user's", v.Sections) } - // A missing file is the ordinary case, not an error. plain, err := Load(dir + "/nothing.yaml") if err != nil { t.Fatalf("Load() on a missing file error = %v", err) @@ -157,8 +147,6 @@ views: } } -// A broken user file still leaves the built-ins usable: one bad view should -// not take the mode down. func TestLoadKeepsTheBuiltinsWhenTheUserFileIsBroken(t *testing.T) { path := t.TempDir() + "/views.yaml" if err := writeFile(path, "views: [{name: X, fetch: {a: \"/iam/v1/tokens\"}, sections: [{title: T, rows: [{label: L, value: V}]}]}]"); err != nil { @@ -199,12 +187,9 @@ func TestATableFetchesOncePerRowAndUnwrapsAnEnvelope(t *testing.T) { {"id":"18","name":"45"}, {"id":"19","name":"3"}]}`, "/sdwan/v3.9/api/sites/16/elements/15/interfaces/17/status": `{"operational_state":"up","ipv4_addresses":["192.0.2.1/24"]}`, - // A bypass pair answers with the envelope, which unwrap takes the - // first record out of. "/sdwan/v3.9/api/sites/16/elements/15/interfaces/18/status": `{"count":2,"items":[ {"operational_state":"up","ipv4_addresses":null}, {"operational_state":"up"}]}`, - // 19 is missing, standing in for a status endpoint that refuses. }} set, err := Parse([]byte(ifaceView)) @@ -217,7 +202,6 @@ func TestATableFetchesOncePerRowAndUnwrapsAnEnvelope(t *testing.T) { if err != nil { t.Fatalf("Fetch() error = %v", err) } - // One list plus one status per row. if len(f.asked) != 4 { t.Errorf("asked %d endpoints, want 4: %v", len(f.asked), f.asked) } @@ -236,7 +220,6 @@ func TestATableFetchesOncePerRowAndUnwrapsAnEnvelope(t *testing.T) { if got := sec.Table[1].Cells; got[1] != "up" || got[2] != "-" { t.Errorf("row 1 = %q, want the envelope unwrapped", got) } - // The row whose status refused still draws, and says why it is thin. last := sec.Table[2] if last.Cells[0] != "3" || last.Cells[1] != "" { t.Errorf("row 2 = %q", last.Cells) @@ -277,8 +260,6 @@ func TestMaxItemsBoundsTheRequestsATableMakes(t *testing.T) { } } -// An id a view needs but the element has not got would otherwise fetch -// /sites//elements/15/interfaces, which is a different element's business. func TestAnEmptyIdIsNotFetched(t *testing.T) { f := &fake{bodies: map[string]string{}} set, err := Parse([]byte(ifaceView)) @@ -298,8 +279,6 @@ func TestAnEmptyIdIsNotFetched(t *testing.T) { } } -// A section for something an element has not got stays out of the way, and -// one whose fetch failed says so rather than drawing blank rows. func TestSectionsAreGatedAndErrorsSurface(t *testing.T) { const view = ` views: @@ -343,9 +322,6 @@ views: } } -// The join both built-in views rely on: a port names the circuit it carries, -// and a circuit names the port carrying it. One side holds the id in a list, -// the other holds it plain, and lookup has to match either way round. func TestLookupJoinsBothWaysRound(t *testing.T) { ctx := map[string]any{ "wans": map[string]any{"items": []any{ @@ -402,8 +378,6 @@ func TestTemplateHelpers(t *testing.T) { want string }{ { - // Three scales live in one real response, so this is the helper - // that stops a view dividing by hand. name: "utc reads 100ns ticks", tmpl: "{{ utc .n }}", ctx: map[string]any{"n": float64(17923299413050015)}, diff --git a/internal/dataview/fetch.go b/internal/dataview/fetch.go index aac20c5..bbd7057 100644 --- a/internal/dataview/fetch.go +++ b/internal/dataview/fetch.go @@ -9,31 +9,17 @@ import ( "strings" ) -// Getter is one authenticated GET of a SASE path, which is all a view is -// allowed to do. The api client satisfies it. type Getter interface { GetJSON(ctx context.Context, path string) ([]byte, error) } -// Vars are the element's identifiers, which is what a view's paths are -// written against: {{ .element_id }}, {{ .site_id }}, {{ .cluster_id }}. type Vars map[string]string -// Data is one view's answers for one element. type Data struct { - // Vars are the ids the view was fetched for. Kept because the templates - // use them too: a section gated on {{ .cluster_id }} is asking whether the - // element is in a cluster at all. - Vars Vars - // Bodies is each view-level fetch, decoded, keyed by its name. - Bodies map[string]any - // Errs is the fetches that failed, keyed the same way. A view whose - // optional half is refused still renders the half that answered. - Errs map[string]string - // Items is each table section's rows, keyed by section index, with any - // per-item fetches already merged in. - Items map[int][]map[string]any - // Truncated is the sections whose for_each was longer than max_items. + Vars Vars + Bodies map[string]any + Errs map[string]string + Items map[int][]map[string]any Truncated map[int]int } @@ -46,9 +32,6 @@ func newData() *Data { } } -// Fetch reads everything one view needs. It does not stop at the first -// failure: a view is several unrelated endpoints, and one of them being -// refused says nothing about the others. func Fetch(ctx context.Context, g Getter, v View, vars Vars) (*Data, error) { d := newData() d.Vars = vars @@ -110,19 +93,14 @@ func Fetch(ctx context.Context, g Getter, v View, vars Vars) (*Data, error) { d.Items[i] = rows } - // Nothing at all answered: that is a tenant or a token problem rather than - // a view problem, and saying so beats a pane of blank rows. if len(v.Fetch) > 0 && len(d.Bodies) == 0 { return d, fmt.Errorf("%s: %s", v.Name, firstErr(d.Errs)) } return d, nil } -// RowErr is what a per-item fetch failure is stored under, so a row can show -// why its cells are empty. func errKey(name string) string { return "_err_" + name } -// RowErr returns the first per-item fetch error on a row. func RowErr(row map[string]any) string { for _, k := range sortedAnyKeys(row) { if strings.HasPrefix(k, "_err_") { @@ -146,10 +124,6 @@ func get(ctx context.Context, g Getter, path string) (any, error) { return out, nil } -// itemsAt walks a dotted path to the list a table iterates. A path that lands -// on nothing is an empty table, not an error: an element with no subinterfaces -// has no subinterfaces. A path that lands on something that is not a list is a -// mistake in the view. func itemsAt(ctx map[string]any, path string) ([]any, error) { cur := any(ctx) for _, part := range strings.Split(path, ".") { @@ -179,8 +153,6 @@ func itemsAt(ctx map[string]any, path string) ([]any, error) { } } -// varsCtx is the element's ids as a template context, which every template in -// a view starts from. func varsCtx(vars Vars) map[string]any { out := make(map[string]any, len(vars)) for k, val := range vars { diff --git a/internal/dataview/render.go b/internal/dataview/render.go index f7400c6..6884904 100644 --- a/internal/dataview/render.go +++ b/internal/dataview/render.go @@ -11,24 +11,19 @@ import ( "tyrtop.com/iontk/internal/sase/api" ) -// Rendered is a view's text, laid out but not styled: the TUI paints it, and a -// test can read it without a terminal. type Rendered struct { View string Sections []RenderedSection - // Errs is the fetches that failed, so a pane can say why a section is - // thin rather than leaving it blank. - Errs []string + Errs []string } type RenderedSection struct { - Title string - Rows []RenderedRow - Headers []string - Widths []int - Table []RenderedTableRow - Empty string - // Truncated is how many rows max_items dropped. + Title string + Rows []RenderedRow + Headers []string + Widths []int + Table []RenderedTableRow + Empty string Truncated int } @@ -39,14 +34,9 @@ type RenderedRow struct { type RenderedTableRow struct { Cells []string - // Err is a per-item fetch that failed, which is why the cells after the - // first may be empty. - Err string + Err string } -// Render turns one view's data into text. A template that fails renders as the -// error in place of the value: a broken view should say so where the value -// would have been, not take the pane down. func Render(v View, d *Data) Rendered { out := Rendered{View: v.Name} for _, name := range sortedKeys(d.Errs) { @@ -89,12 +79,9 @@ func Render(v View, d *Data) Rendered { return out } -// eval renders one template. A field the element has not got comes out empty, -// not "" and not an error: half of what a view asks for is optional -// on a given appliance -- an element with no HA has no spoke_ha_config for -// {{ .el.spoke_ha_config.cluster_id }} to walk into. A view's own mistakes, a -// bad function or unparseable text, do show, prefixed with "!", because -// nothing else would tell the person who wrote it. +// a field the element has not got comes out empty, not "": half of +// what a view asks for is optional on a given appliance. A view's own mistakes +// do show, prefixed "!", because nothing else would tell whoever wrote it. func eval(text string, ctx map[string]any) string { t, err := template.New("v").Funcs(funcs).Option("missingkey=zero").Parse(text) if err != nil { @@ -110,16 +97,12 @@ func eval(text string, ctx map[string]any) string { return strings.TrimSpace(strings.ReplaceAll(b.String(), "", "")) } -// missingField is text/template's way of saying the path ran out: walking -// into a field of something absent, or of a type that has no such field. func missingField(err error) bool { s := err.Error() return strings.Contains(s, "nil pointer evaluating") || strings.Contains(s, "can't evaluate field") } -// expand is eval for a path, where an empty result is a bug rather than a -// blank: a path with an unresolved id in it would fetch the wrong thing. func expand(text string, ctx map[string]any) (string, error) { got := eval(text, ctx) if strings.HasPrefix(got, "!") { @@ -143,14 +126,6 @@ func truthy(s string) bool { } var funcs = template.FuncMap{ - // utc is the API's timestamp integers, whose unit varies by field. Always - // go through this: the scale is three orders of magnitude apart between - // two fields of the same object. - // - // The name is older than the setting: it reads the integer as a time and - // prints it in the configured zone, which is UTC unless someone changed - // it. Views in the wild call it, so it keeps the name and says which zone - // it used. "utc": func(v any) string { n := toInt64(v) if n == 0 { @@ -169,9 +144,6 @@ var funcs = template.FuncMap{ } return ageString(time.Since(t)) }, - // unwrap takes the single record out of an items envelope, which is how a - // bypass pair's status arrives where every other interface sends a bare - // object. "unwrap": func(v any) any { m, ok := v.(map[string]any) if !ok { @@ -218,14 +190,6 @@ var funcs = template.FuncMap{ } return s }, - // lookup is the join the format would otherwise be missing: find the - // record in a list whose field matches a value. The field may hold the - // value or be a list containing it, so it works both ways round -- a - // circuit found by the id a port is bound to, and the port found by the - // circuit id in its site_wan_interface_ids. - // - // It returns the record, not one field, so a caller can use several: - // {{ with lookup $.wans.items "id" .item.circuit }}{{ .name }}{{ end }} "lookup": func(list any, field string, want any) any { items, ok := list.([]any) if !ok || want == nil || want == "" { @@ -251,8 +215,6 @@ var funcs = template.FuncMap{ } return nil }, - // first is the first element of a list, for a view that wants one address - // out of ipv4_addresses. "first": func(v any) any { list, ok := v.([]any) if !ok || len(list) == 0 { @@ -261,20 +223,12 @@ var funcs = template.FuncMap{ return list[0] }, - // The four below read CLI text rather than JSON, for the device side of a - // comparison. An appliance prints "Key: value" lines, so kv is the - // one most rows want. "lines": func(v any) []any { return textLines(v, "") }, - // grep is the lines holding pat, case-insensitively: count over it answers - // "how many tunnels are up". "grep": func(v any, pat string) []any { return textLines(v, pat) }, - // kv is the value of the first line that starts with key, with whatever - // separator the firmware used taken off. Anchored at the line start so a - // key cannot match inside another line's value. "kv": func(v any, key string) string { k := strings.ToLower(strings.TrimSpace(key)) for _, line := range strings.Split(fmt.Sprint(v), "\n") { @@ -286,8 +240,6 @@ var funcs = template.FuncMap{ } return "" }, - // field is the nth whitespace-separated field, counting from 1, for output - // that is columns rather than keys. "field": func(v any, n int) string { f := strings.Fields(fmt.Sprint(v)) if n < 1 || n > len(f) { @@ -297,9 +249,6 @@ var funcs = template.FuncMap{ }, } -// textLines splits CLI output into its non-blank lines, keeping only those -// holding want. The elements are any so count and range treat them the way -// they treat a fetched list. func textLines(v any, want string) []any { want = strings.ToLower(want) var out []any @@ -316,9 +265,6 @@ func textLines(v any, want string) []any { return out } -// sameValue compares what came out of two JSON documents. Ids are strings in -// this API, but comparing them as text keeps a number on one side of a join -// from silently never matching. func sameValue(a, b any) bool { if a == nil || b == nil { return false @@ -341,8 +287,6 @@ func toInt64(v any) int64 { return 0 } -// ageString is coarse on purpose: a reboot three weeks ago does not need -// minutes. func ageString(d time.Duration) string { switch { case d < 0: diff --git a/internal/dataview/spec.go b/internal/dataview/spec.go index a98fa5f..80959a4 100644 --- a/internal/dataview/spec.go +++ b/internal/dataview/spec.go @@ -1,11 +1,3 @@ -// Package dataview is rich-data mode's YAML: which portal endpoints to read -// for an element and how to lay the answers out. -// -// It is data, not code. A view names GET paths and Go templates over what they -// returned, so a new pane of element data is a file edit rather than a build. -// The paths are checked at load: GET only, on the SASE host, under /sdwan. -// iontk holds credentials that can write to production appliances, and a view -// is the one part of it a person is expected to edit. package dataview import ( @@ -22,67 +14,40 @@ import ( //go:embed builtin.yaml var builtinFS embed.FS -// Set is the views available, in the order they are offered. type Set struct { Views []View `yaml:"views"` } type View struct { - Name string `yaml:"name"` - // Fetch is the endpoints read once per element, keyed by the name the - // templates refer to them by. + Name string `yaml:"name"` Fetch map[string]string `yaml:"fetch"` Sections []Section `yaml:"sections"` - // Compare is the GUI-vs-CLI half, and the only part of a view that costs a - // device login. It runs on a keypress, never with the page. - Compare *Compare `yaml:"compare"` + Compare *Compare `yaml:"compare"` } -// Compare puts what the portal says beside what the appliance's own CLI says. -// It reads the portal again rather than reusing the page: a comparison is only -// worth something if both answers were taken at the same moment. type Compare struct { - // When gates the whole comparison, same template rules as a section's: an - // element that is not clustered is not worth dialling to ask about HA. - When string `yaml:"when"` - // Fetch is the portal side, read when the comparison runs. - Fetch map[string]string `yaml:"fetch"` - // Commands is name -> CLI command, which .cli. holds the output of. - // Not templated, unlike a path: a command built out of fetched data could - // expand into one that writes, and the read-only check here happens at - // load. + When string `yaml:"when"` + Fetch map[string]string `yaml:"fetch"` Commands map[string]string `yaml:"commands"` Rows []CompareRow `yaml:"rows"` } -// CompareRow is one question asked of both sources. Each side is a template, -// which is where a difference in spelling between the two gets reconciled; -// the engine only decides whether the answers match. type CompareRow struct { Label string `yaml:"label"` Portal string `yaml:"portal"` Device string `yaml:"device"` } -// Section is either a list of label/value rows or, with for_each, a table over -// something one of the fetches returned. type Section struct { Title string `yaml:"title"` - // When is a template; the section is dropped when it renders empty, - // "false" or "". It is how a section for a thing an element may - // not have -- an HA cluster, a cellular module -- stays out of the way. - When string `yaml:"when"` - Rows []Row `yaml:"rows"` + When string `yaml:"when"` + Rows []Row `yaml:"rows"` - // ForEach is a dotted path into the fetched data, "interfaces.items". - ForEach string `yaml:"for_each"` - // Fetch runs once per item, with .item in scope. One request per row, so - // MaxItems bounds it. + ForEach string `yaml:"for_each"` Fetch map[string]string `yaml:"fetch"` MaxItems int `yaml:"max_items"` Columns []Column `yaml:"columns"` - // Empty is what to say when ForEach yields nothing. - Empty string `yaml:"empty"` + Empty string `yaml:"empty"` } type Row struct { @@ -93,18 +58,11 @@ type Row struct { type Column struct { Header string `yaml:"header"` Value string `yaml:"value"` - // Width caps the column. Columns are sized to their contents; this stops - // one long cell -- a 40-character tunnel name among the port numbers -- - // pushing the rest off the pane. Zero is no cap. - Width int `yaml:"width"` + Width int `yaml:"width"` } -// defaultMaxItems bounds a table's per-item fetches. A view is one GET per row -// beyond the first, and an element with 24 interfaces is already 25 requests. const defaultMaxItems = 60 -// Builtin is the views iontk ships, which are also the worked example of the -// format. func Builtin() (*Set, error) { b, err := builtinFS.ReadFile("builtin.yaml") if err != nil { @@ -117,15 +75,8 @@ func Builtin() (*Set, error) { return s, nil } -// FileName is the file a config root is searched for. const FileName = "views.yaml" -// Load returns the built-in views with the file's own appended, or replacing a -// built-in of the same name. A missing file is not an error: most runs have -// none. -// -// The error it can return is a broken file, and the caller gets the built-ins -// anyway: a typo in one view should not leave the mode with nothing. func Load(path string) (*Set, error) { set, err := Builtin() if err != nil { @@ -166,8 +117,6 @@ func (s *Set) index(name string) int { return -1 } -// View returns one by name, for a saved preference that may since have been -// renamed away. func (s *Set) View(name string) (View, bool) { if i := s.index(name); i >= 0 { return s.Views[i], true @@ -186,8 +135,6 @@ func (s *Set) Names() []string { func Parse(b []byte) (*Set, error) { var set Set dec := yaml.NewDecoder(strings.NewReader(string(b))) - // A misspelled key is the whole failure mode of a hand-written view, and - // ignoring it silently looks like the view not working. dec.KnownFields(true) if err := dec.Decode(&set); err != nil { return nil, fmt.Errorf("parse: %w", err) @@ -259,9 +206,6 @@ func (sec *Section) normalize(view string) error { return nil } -// normalize checks the comparison. The commands are the part that matters: -// they run on an appliance, and a view is the one piece of iontk a person is -// expected to edit, so only the read-only verbs are allowed. func (c *Compare) normalize(view string) error { if c == nil { return nil @@ -295,10 +239,8 @@ func (c *Compare) normalize(view string) error { return nil } -// checkPath is the gate on what a view may call. A view is user-editable data -// and iontk's token can write to production, so the path has to be a read of -// the SASE SD-WAN API and nothing else: no host of its own, no method, no -// escaping /sdwan with "..". +// a read of the SASE SD-WAN API and nothing else: no host of its own, no +// method, no escaping /sdwan with ".." func checkPath(p string) error { if p == "" { return fmt.Errorf("is empty") diff --git a/internal/fixture/client.go b/internal/fixture/client.go index 1d4982e..ceb7d50 100644 --- a/internal/fixture/client.go +++ b/internal/fixture/client.go @@ -8,14 +8,8 @@ import ( "tyrtop.com/iontk/internal/sase/ioncli" ) -// SessionClient is what a batch run uses in fixture mode. It satisfies -// sase.Client, and its ToolkitSession goes through the real ioncli code against -// a playback device, so autologin and the pager scraper are exercised rather -// than skipped. type SessionClient struct { - set *Set - // Where paging settings come from, since the client is built before the - // flags reach a session. + set *Set pagerOff string pagers []string } diff --git a/internal/fixture/device.go b/internal/fixture/device.go index 772d665..1a6c84e 100644 --- a/internal/fixture/device.go +++ b/internal/fixture/device.go @@ -10,8 +10,6 @@ import ( "tyrtop.com/iontk/internal/ionterm" ) -// Dialer hands out playback devices. It satisfies ionterm.Dialer, so a TUI -// session in fixture mode goes through the real pane, autologin and emulator. func (s *Set) Dialer() ionterm.Dialer { return dialer{s} } type dialer struct{ set *Set } @@ -23,8 +21,6 @@ func (d dialer) DialToolkit(ctx context.Context, eid string, cols, rows int) (io func (s *Set) dial(eid string) (*conn, error) { el := s.element(eid) if el == nil { - // The shape a rejected handshake takes, so the dial error paths are the - // ones being exercised. return nil, fmt.Errorf("expected handshake response status 101 but got 404") } t := s.tenant(el.tsg) @@ -38,14 +34,8 @@ func (s *Set) dial(eid string) (*conn, error) { return c, nil } -// What a read or write gets after the device hung up. The real client reports a -// websocket close error, and ionterm tells a clean hang-up from a failure by -// inspecting it. var errClosed = websocket.CloseError{Code: websocket.StatusNormalClosure} -// conn is a websocket-shaped pipe to a playback device. It satisfies both -// ionterm.Conn and ioncli.Conn, which is what lets one device serve the TUI's -// sessions and a batch run's. type conn struct { out chan []byte in chan []byte @@ -69,7 +59,6 @@ func (c *conn) Read(ctx context.Context) (websocket.MessageType, []byte, error) } func (c *conn) Write(ctx context.Context, _ websocket.MessageType, p []byte) error { - // Copied: the caller owns the buffer, and the device reads it later. b := append([]byte(nil), p...) select { case <-ctx.Done(): @@ -81,8 +70,6 @@ func (c *conn) Write(ctx context.Context, _ websocket.MessageType, p []byte) err } } -// A playback device always answers, so there is nothing to time out. Returning -// an error once closed is what stops ionterm calling it a hang. func (c *conn) Ping(ctx context.Context) error { select { case <-c.closed: @@ -110,8 +97,6 @@ func (c *conn) send(s string) bool { } } -// line collects typed bytes up to a carriage return, which is how the device -// sees a command: iontk writes keystrokes, not lines. func (c *conn) line() (string, bool) { var b strings.Builder for { @@ -124,7 +109,6 @@ func (c *conn) line() (string, bool) { case '\r', '\n': return b.String(), true case 0x7f, '\b': - // Backspace, so a typed-then-corrected command still works. if s := b.String(); s != "" { b.Reset() b.WriteString(s[:len(s)-1]) @@ -142,13 +126,10 @@ func (c *conn) serve(d *device) { d.run(c) } -// device is one appliance's CLI: the login exchange, then a command loop. type device struct { - set *Set - tenant *Tenant - el *Element - // pagingOff is set by the command --pager-off sends, so the fixture honours - // the thing iontk does to avoid the pager. + set *Set + tenant *Tenant + el *Element pagingOff bool } @@ -167,8 +148,6 @@ func (d *device) run(c *conn) { if !ok { return } - // The device echoes what is typed; without this the pane stays blank - // while autologin works, which is the bug a fixture should catch. if !c.send(user+"\r\n") || !c.send("Password: ") { return } @@ -196,9 +175,6 @@ func (d *device) run(c *conn) { if !ok { return } - // The echo redraws the prompt and the command from column zero, which - // is the frame shape the appliance produces: the pane overwrites the - // prompt it already has, and ioncli finds the marker it cuts output at. if !c.send("\r" + d.prompt() + " " + cmd + "\r\n") { return } @@ -229,8 +205,6 @@ func (d *device) response(cmd string) string { return strings.ReplaceAll(d.set.UnknownResponse, "{command}", cmd) + "\n" } -// write sends a command's output, pausing on the pager prompt when the element -// is set to page and paging has not been turned off. func (d *device) write(c *conn, out string) bool { out = strings.TrimRight(out, "\n") if out == "" { @@ -250,15 +224,12 @@ func (d *device) write(c *conn, out string) bool { if end == len(lines) { break } - // No newline after the prompt: the device stops there and waits, which - // is what makes the scraper's last-line matching necessary. if !c.send(pagerPrompt) { return false } if _, ok := d.awaitSpace(c); !ok { return false } - // Erased in place, the way the appliance does it. if !c.send(strings.Repeat("\b", len(pagerPrompt))) { return false } @@ -266,8 +237,6 @@ func (d *device) write(c *conn, out string) bool { return true } -// awaitSpace waits for the keypress the pager asked for. A q gives up on the -// rest, which is the other half of what the prompt offers. func (d *device) awaitSpace(c *conn) (quit bool, ok bool) { for { select { diff --git a/internal/fixture/device_test.go b/internal/fixture/device_test.go index 9abe02b..442bc7a 100644 --- a/internal/fixture/device_test.go +++ b/internal/fixture/device_test.go @@ -42,8 +42,6 @@ func TestASessionRunsTheRealLoginAndReturnsFixtureOutput(t *testing.T) { if len(outs) != 1 { t.Fatalf("got %d command outputs, want 1", len(outs)) } - // Exactly the fixture's text: the echoed command and the prompt must not - // leak into the output, which is what the clean-up code is for. want := "Serial : FAKE0001\nUptime : 4 days" if outs[0].Output != want { t.Errorf("output = %q, want %q", outs[0].Output, want) @@ -57,8 +55,6 @@ func TestTheWrongPasswordIsRejectedRatherThanAccepted(t *testing.T) { ctx, cancel := contextWithTimeout(t, 2*time.Second) defer cancel() - // A fixture that let any password through would make autologin look right - // when it was sending the wrong thing. _, err := s.SessionClient("", nil). ToolkitSession(ctx, el.ID, el.Name+"#", "admin", "wrong", []string{"dump device status"}) if err == nil { @@ -103,8 +99,6 @@ func TestWithNothingAnsweringThePagerTheReadTimesOutCarryingTheTail(t *testing.T ctx, cancel := contextWithTimeout(t, 500*time.Millisecond) defer cancel() - // The bug phase 0b fixed: without either defence a paging command uses up - // the whole session timeout, and the tail is the only clue why. _, err := s.SessionClient("", []string{}). ToolkitSession(ctx, el.ID, el.Name+"#", "admin", "ion", []string{"help"}) if err == nil { @@ -124,8 +118,6 @@ func TestAnUnknownCommandAnswersPlausibly(t *testing.T) { if err != nil { t.Fatalf("ToolkitSession() error = %v", err) } - // Empty output would teach a tutorial nothing; the failure case is one of - // the things a fixture exists to show. if !strings.Contains(outs[0].Output, "unknown command") || !strings.Contains(outs[0].Output, "dump nonsense") { t.Errorf("output = %q, want it to name the command it rejected", outs[0].Output) } @@ -153,8 +145,6 @@ func TestATUISessionLogsInThroughTheRealAutologin(t *testing.T) { } t.Cleanup(sess.Close) - // The pane is what the user sees, so the prompt arriving there is the proof - // that autologin answered both prompts. waitFor(t, func() bool { return strings.Contains(pane(sess), el.Name+"#") }) if got := pane(sess); strings.Contains(got, "Login incorrect") { t.Errorf("autologin was rejected:\n%s", got) @@ -172,8 +162,6 @@ func TestATUISessionLogsInThroughTheRealAutologin(t *testing.T) { func pane(s *ionterm.Session) string { return s.Render() } -// waitFor polls because the device answers on its own goroutine; a session has -// no "settled" to wait on. func waitFor(t *testing.T, cond func() bool) { t.Helper() deadline := time.Now().Add(3 * time.Second) diff --git a/internal/fixture/fixture.go b/internal/fixture/fixture.go index fe4355a..5a9191a 100644 --- a/internal/fixture/fixture.go +++ b/internal/fixture/fixture.go @@ -1,11 +1,3 @@ -// Package fixture is iontk's testing mode: fake tenants, elements and CLI -// sessions read from a YAML file, so the TUI and batch runs can be exercised -// with no appliance and no credentials. -// -// It is deliberately not a mock. The REST calls go through the real client over -// a transport that answers them, and a session goes through the real autologin -// and pager code against a device that plays back the login exchange. What is -// fake is the appliance, not iontk. package fixture import ( @@ -20,13 +12,9 @@ import ( "tyrtop.com/iontk/internal/sase/api" ) -// Set is a loaded fixture file. type Set struct { - Tenants []Tenant `yaml:"tenants"` - // UnknownResponse is what the device answers a command it has no response - // for. "{command}" is replaced with what was typed. The default text is - // invented, not observed on an appliance. - UnknownResponse string `yaml:"unknown_response"` + Tenants []Tenant `yaml:"tenants"` + UnknownResponse string `yaml:"unknown_response"` } type Tenant struct { @@ -42,21 +30,15 @@ type User struct { } type Element struct { - Name string `yaml:"name"` - ID string `yaml:"id"` - HWID string `yaml:"hw_id"` - Model string `yaml:"model"` - Software string `yaml:"software"` - Site string `yaml:"site"` - Connected bool `yaml:"connected"` - // HA puts the element in a spoke HA cluster with the site's other HA - // elements, so the cluster status endpoint has something to answer. - HA bool `yaml:"ha"` - // Pages makes long output stop on a pager prompt and wait for a space, - // which is the one device behaviour that cost a hardware round trip. The - // prompt is the one an ion 1200-s running 6.3.4-b2 printed. - Pages bool `yaml:"pages"` - // PageLines is how many lines go by before it pages. Zero means the default. + Name string `yaml:"name"` + ID string `yaml:"id"` + HWID string `yaml:"hw_id"` + Model string `yaml:"model"` + Software string `yaml:"software"` + Site string `yaml:"site"` + Connected bool `yaml:"connected"` + HA bool `yaml:"ha"` + Pages bool `yaml:"pages"` PageLines int `yaml:"page_lines"` Responses map[string]string `yaml:"responses"` @@ -71,8 +53,6 @@ const ( defaultPageLines = 20 defaultUnknown = "% unknown command: {command}" - // Verified on an ion 1200-s running 6.3.4-b2. Two of ioncli.DefaultPagers - // match it, which is why the scraper cuts the earliest match in the line. pagerPrompt = "--More--(4%)[Press space to continue, 'q' to quit.]" ) @@ -83,8 +63,6 @@ func Load(path string) (*Set, error) { } var s Set dec := yaml.NewDecoder(strings.NewReader(string(b))) - // A misspelled key is the whole failure mode of a hand-written fixture, and - // silently ignoring it looks like the fixture not working. dec.KnownFields(true) if err := dec.Decode(&s); err != nil { return nil, fmt.Errorf("parse %s: %w", path, err) @@ -129,8 +107,6 @@ func (s *Set) normalize() error { return fmt.Errorf("tenant %s: element %d has no name", t.Name, j+1) } if el.ID == "" { - // 19 digits, because that is what the API returns and what the - // filter's own tests are built around. el.ID = syntheticID("element/"+t.TSGID+"/"+el.Name, 19) } if where, dup := seen[el.ID]; dup { @@ -155,8 +131,6 @@ func (s *Set) normalize() error { return nil } -// syntheticID is a stable digit string for a name, so an element keeps its ID -// across runs and a cached fixture still resolves. func syntheticID(seed string, digits int) string { sum := sha256.Sum256([]byte(seed)) var b strings.Builder @@ -164,22 +138,18 @@ func syntheticID(seed string, digits int) string { fmt.Fprintf(&b, "%019d", binary.BigEndian.Uint64(sum[i%24:])%1e19) } out := b.String()[:digits] - // A leading zero would read as a shorter number than the API ever returns. if out[0] == '0' { out = "1" + out[1:] } return out } -// SavedTenants are the tenants to write into the config store, so the TUI's -// ordinary tenant flow is what runs. func (s *Set) SavedTenants() []config.Tenant { out := make([]config.Tenant, 0, len(s.Tenants)) for _, t := range s.Tenants { ct := config.Tenant{ - Name: t.Name, - TSGID: t.TSGID, - // A service account, because the browser legs would need a browser. + Name: t.Name, + TSGID: t.TSGID, Auth: config.AuthServiceAccount, ClientID: "fixture", Added: true, @@ -192,8 +162,6 @@ func (s *Set) SavedTenants() []config.Tenant { return out } -// Passwords are the device logins to put in the store's secrets, keyed the way -// the rest of iontk keys them. func (s *Set) Passwords() map[string]string { out := map[string]string{} for _, t := range s.Tenants { @@ -213,8 +181,6 @@ func (s *Set) tenant(tsg string) *Tenant { return nil } -// element finds an element by ID in any tenant: a session is opened with an ID -// and nothing else. func (s *Set) element(eid string) *Element { for i := range s.Tenants { for j := range s.Tenants[i].Elements { @@ -258,8 +224,6 @@ func (e Element) apiElement() api.Element { FIPSMode: "non_fips", } if e.HA { - // A real clustered spoke left the record's own cluster_id null and - // carried the cluster here, so the fixture does the same. el.SpokeHA = &api.SpokeHA{ ClusterID: e.clusterID(), Enable: true, @@ -273,9 +237,6 @@ func (e Element) apiElement() api.Element { return el } -// siteID is derived from the site name, because the portal gives elements a -// site ID and names are only resolvable through the sites endpoint. An element -// with no site keeps the API's own "unassigned" marker. func (e Element) siteID() string { if e.Site == "" { return "" @@ -301,7 +262,6 @@ func (s *Set) apiSites(tsg string) []api.Site { return out } -// login reports whether a user and password pair is one of the tenant's. func (t *Tenant) login(user, pass string) bool { for _, u := range t.Users { if u.Name == user { diff --git a/internal/fixture/fixture_test.go b/internal/fixture/fixture_test.go index b1941bf..50d200b 100644 --- a/internal/fixture/fixture_test.go +++ b/internal/fixture/fixture_test.go @@ -63,8 +63,6 @@ func TestLoadFillsInWhatTheFileLeavesOut(t *testing.T) { el := s.Tenants[0].Elements[1] if len(el.ID) != 19 { - // A shorter ID would not exercise the filter's deliberate refusal to - // substring-match 19-digit identifiers. t.Errorf("generated element id %q is %d digits, want 19", el.ID, len(el.ID)) } if el.Model != defaultModel || el.Software != defaultSoftware || el.PageLines != defaultPageLines { @@ -76,8 +74,6 @@ func TestLoadFillsInWhatTheFileLeavesOut(t *testing.T) { } func TestGeneratedIDsAreStableAcrossLoads(t *testing.T) { - // A fixture whose IDs moved every run would invalidate the element cache and - // break any --element someone wrote down. first, second := load(t, demo), load(t, demo) for i := range first.Tenants { for j := range first.Tenants[i].Elements { @@ -98,7 +94,6 @@ func TestLoadRejectsFixturesThatWouldLookLikeABug(t *testing.T) { "duplicate element id": { "tenants:\n - name: T\n elements:\n - name: a\n id: \"7\"\n - name: b\n id: \"7\"\n", "used by both"}, - // A typo in a key is the whole failure mode of a hand-written fixture. "misspelled key": { "tenants:\n - name: T\n elementz:\n - name: a\n", "field elementz"}, } diff --git a/internal/fixture/portal.go b/internal/fixture/portal.go index 604931d..e0013cb 100644 --- a/internal/fixture/portal.go +++ b/internal/fixture/portal.go @@ -8,14 +8,6 @@ import ( "tyrtop.com/iontk/internal/sase/api" ) -// The portal side of a fixture element: back-channel status and interfaces. -// None of it is in the YAML. An element's ports are derived from its model the -// way its ID is derived from its name, so the detail page has something -// shaped like an appliance to draw without every fixture file listing five -// ports. - -// ports is how many an element of this model has. Unknown models get the -// 1200's five. func (e Element) ports() int { switch e.Model { case "ion 1000", "ion 2000": @@ -27,7 +19,6 @@ func (e Element) ports() int { } } -// interfaceID keeps a port's ID stable across runs, as element IDs are. func (e Element) interfaceID(port int) string { return syntheticID(fmt.Sprintf("interface/%s/%d", e.ID, port), 19) } @@ -43,9 +34,6 @@ func (e Element) apiInterfaces() []api.Interface { MTU: 1500, Scope: "local", } - // Port 1 is the controller port on an ION, 2 the second circuit, 3 the - // LAN, and the rest unused. The values are the ones a real element - // answers with: "lan", not "private". switch p { case 1, 2: in.UsedFor = "public" @@ -60,7 +48,6 @@ func (e Element) apiInterfaces() []api.Interface { out = append(out, in) } - // a real element carries its security zone on the vlans, not the ports out = append(out, api.Interface{ ID: e.vlanInterfaceID(), Name: "vlan1", @@ -77,10 +64,6 @@ func (e Element) vlanInterfaceID() string { return syntheticID("interface/"+e.ID+"/vlan1", 19) } -// apiInterfaceStatus mirrors a real one: the status record has an ID of its -// own rather than the interface's, negotiated_mtu is 0 even on a port that is -// up, and extended_state is empty on an ordinary port -- only a service link -// explains itself there. func (e Element) apiInterfaceStatus(iid string) *api.InterfaceStatus { if iid == e.vlanInterfaceID() { st := api.InterfaceStatus{ @@ -89,10 +72,8 @@ func (e Element) apiInterfaceStatus(iid string) *api.InterfaceStatus { Name: "vlan1", Device: "vlan1", OperationalState: "down", - // No port block on anything that is not a physical port, which is - // what a real element answers with. - VRF: &api.VRF{ContextID: syntheticID("vrf/"+e.tsg, 19), Name: "Global"}, - LastStateChange: bootedAt.Add(time.Minute).UnixMilli(), + VRF: &api.VRF{ContextID: syntheticID("vrf/"+e.tsg, 19), Name: "Global"}, + LastStateChange: bootedAt.Add(time.Minute).UnixMilli(), } if e.Connected { st.OperationalState = "up" @@ -115,8 +96,6 @@ func (e Element) apiInterfaceStatus(iid string) *api.InterfaceStatus { VRF: &api.VRF{ContextID: syntheticID("vrf/"+e.tsg, 19), Name: "Global"}, LastStateChange: bootedAt.Add(time.Minute).UnixMilli(), } - // A disconnected element's ports are all down: the portal cannot see - // past an element that is not talking to it. if e.Connected && p <= 3 { st.OperationalState = "up" st.IPv4Addresses = []string{fmt.Sprintf("10.%d.%d.1/24", p, p*7%256)} @@ -128,10 +107,6 @@ func (e Element) apiInterfaceStatus(iid string) *api.InterfaceStatus { return nil } -// Circuits are the site's, two per site, with the first carrying the -// controller session the way a real site's usually does. Field names are the -// portal's own model: name, type, network_id, label_id, link_bw_down/up, -// use_for_controller_connections. func (e Element) circuitID(n int) string { return syntheticID(fmt.Sprintf("waninterface/%s/%s/%d", e.tsg, e.Site, n), 19) } @@ -159,10 +134,6 @@ func (e Element) apiCircuits() []map[string]any { return out } -// apiCircuitStatus is the circuit as the site sees it; apiCircuitState is the -// same circuit as this element sees it, which is the pair that makes a -// one-sided failure visible. Both shapes are unverified against hardware -- -// the resource map gave the paths, not the bodies. func (e Element) apiCircuitStatus(cid string) map[string]any { for n := 1; n <= fixtureCircuits; n++ { if e.circuitID(n) != cid { @@ -219,16 +190,10 @@ func fixtureNetworks() []map[string]any { return out } -// clusterID is the site's HA cluster, shared by every HA element on it, so a -// pair reports the same cluster the way a real one does. func (e Element) clusterID() string { return syntheticID("spokecluster/"+e.tsg+"/"+e.Site, 19) } -// apiClusterStatus answers the spoke cluster status endpoint, in the shape a -// real cluster answered in: a member is "active" or "unknown" -- there is no -// "standby" -- and a switchover carries the adjustments that moved the -// effective priority off the configured one. func (s *Set) apiClusterStatus(tsg, cid string) map[string]any { t := s.tenant(tsg) if t == nil { @@ -241,8 +206,6 @@ func (s *Set) apiClusterStatus(tsg, cid string) map[string]any { if !el.HA || el.clusterID() != cid { continue } - // A member the controller cannot see reads as "unknown", which is what - // a dead peer looks like. status := "unknown" if el.Connected { status = "active" @@ -271,9 +234,6 @@ func (s *Set) apiClusterStatus(tsg, cid string) map[string]any { return map[string]any{"cluster_members": members, "switch_over_events": events} } -// apiStatus is the flat shape the element status endpoint answers with: one -// group of fields per back-channel. A disconnected element has every channel -// down, which is what makes the portal call it disconnected. func (e Element) apiStatus() map[string]any { out := map[string]any{ "element_id": e.ID, @@ -282,8 +242,6 @@ func (e Element) apiStatus() map[string]any { } out["device_mode"] = "l3-mode" out["last_rebooted_info"] = "Requested by API" - // Seconds, where the channels below are not: a real response carries all - // three scales at once, and a fixture that used one would hide it. out["last_rebooted_time"] = bootedAt.Unix() if e.Connected { out["controller_connection_intf"] = e.interfaceID(1) @@ -298,8 +256,6 @@ func (e Element) apiStatus() map[string]any { out[ch+"_from"] = "" out[ch+"_connected_on_utc"] = 0 out[ch+"_disconnected_on_utc"] = 0 - // config_and_events counts in 100ns ticks and the rest in - // milliseconds, which is what a real element status does. stamp := on.UnixMilli() if ch == "config_and_events" { stamp = utcTicks(on) @@ -315,11 +271,8 @@ func (e Element) apiStatus() map[string]any { return out } -// bootedAt fixes the fixture's clock so a run is reproducible: the day the -// first ION in the demo file was imagined to have come up. var bootedAt = time.Date(2026, 10, 1, 9, 0, 0, 0, time.UTC) -// utcTicks is one of the API's timestamp units, 100ns since the epoch. func utcTicks(t time.Time) int64 { return t.UnixNano() / 100 } @@ -329,7 +282,6 @@ func fakeMAC(seed string) string { return fmt.Sprintf("02:%s:%s:%s:%s:%s", d[0:2], d[2:4], d[4:6], d[6:8], d[8:10]) } -// a flat row per element, "active" or "backup", as the capture shows func (s *Set) apiClusterStatuses(tsg string) []map[string]any { ten := s.tenant(tsg) if ten == nil { @@ -341,8 +293,6 @@ func (s *Set) apiClusterStatuses(tsg string) []map[string]any { if !el.HA { continue } - // The first HA element on a site carries it, as the per-cluster - // fixture decides too. status, priority := "backup", 100 if s.activeMember(tsg, el.clusterID()) == el.ID { status, priority = "active", 150 @@ -360,7 +310,6 @@ func (s *Set) apiClusterStatuses(tsg string) []map[string]any { return out } -// the per-cluster fixture and the aggregate have to agree on this func (s *Set) activeMember(tsg, cid string) string { ten := s.tenant(tsg) if ten == nil { @@ -375,7 +324,6 @@ func (s *Set) activeMember(tsg, cid string) string { return "" } -// names like a real tenant's, so the join looks like it would in front of one func fixtureZones() []map[string]any { return []map[string]any{ {"id": syntheticID("zone/trusted", 18), "name": "Store - LAN", @@ -387,7 +335,6 @@ func fixtureZones() []map[string]any { } } -// one record per zone, holding its interface ids; verified in interface.har func (e Element) apiZoneBindings() []map[string]any { return []map[string]any{ { @@ -399,8 +346,6 @@ func (e Element) apiZoneBindings() []map[string]any { "wanoverlay_ids": nil, }, { - // A zone with nothing of this element in it, which a tenant always - // has: the join has to skip it rather than name it. "id": syntheticID("elzone/guest/"+e.ID, 18), "zone_id": syntheticID("zone/guest", 18), "interface_ids": nil, diff --git a/internal/fixture/portal_test.go b/internal/fixture/portal_test.go index f767f6c..b6223d0 100644 --- a/internal/fixture/portal_test.go +++ b/internal/fixture/portal_test.go @@ -20,13 +20,10 @@ func TestTheRealClientTakesAPortalSnapshotOfAFixtureElement(t *testing.T) { if !ok { t.Fatalf("no config_and_events channel in %+v", snap.Status.Channels) } - // The element is connected in the demo file, so the channel that decides - // what the portal calls connected has to agree. if !ch.Connected || ch.On == 0 { t.Errorf("channel = %+v, want it connected with a timestamp", ch) } - // The ports, plus the one vlan interface that carries the security zone. if want := el.ports() + 1; len(snap.Interfaces) != want { t.Fatalf("got %d interfaces, want %d", len(snap.Interfaces), want) } @@ -44,15 +41,11 @@ func TestTheRealClientTakesAPortalSnapshotOfAFixtureElement(t *testing.T) { if up.Port == nil || !up.Port.Connected { t.Errorf("port = %+v, want the link up", up.Port) } - // A port nobody wired up is down, which is what makes an interface list - // worth drawing at all. lastPort := snap.Interfaces[el.ports()-1] if lastPort.Status[0].OperationalState != "down" { t.Errorf("last port status = %+v, want it down", lastPort.Status[0]) } - // The vlan interface comes last, and its status has no port block: a real - // element answers with port null on anything that is not a physical port. vlan := snap.Interfaces[len(snap.Interfaces)-1] if vlan.Interface.Type != "vlan" || vlan.Interface.UsedFor != "lan" { t.Errorf("last interface = %+v, want the vlan", vlan.Interface) @@ -89,8 +82,6 @@ func TestADisconnectedFixtureElementHasEveryChannelDown(t *testing.T) { t.Skip("no disconnected element in the demo fixture") } -// A pair, because a cluster of one would not show that both members come back -// from the one endpoint. const haPair = ` tenants: - name: Acme Co @@ -110,8 +101,6 @@ func TestAClusteredElementReportsItsClusterStatus(t *testing.T) { c := fixtureClient(s, tenant.TSGID) el := tenant.Elements[0].apiElement() - // The cluster id lives in spoke_ha_config, not the record's own - // cluster_id, which a real clustered spoke left null. if el.Cluster != "" || el.ClusterID() == "" { t.Fatalf("cluster_id = %q, spoke_ha cluster = %q", el.Cluster, el.ClusterID()) } @@ -126,8 +115,6 @@ func TestAClusteredElementReportsItsClusterStatus(t *testing.T) { if snap.Cluster == nil { t.Fatal("no cluster status for a clustered element") } - // Both members of the pair, from the one call, and only the connected one - // is active. if len(snap.Cluster.Members) != 2 { t.Fatalf("%d cluster members, want 2: %+v", len(snap.Cluster.Members), snap.Cluster.Members) } @@ -139,8 +126,6 @@ func TestAClusteredElementReportsItsClusterStatus(t *testing.T) { t.Errorf("the offline peer reads %q, want unknown", got) } - // An element outside a cluster is not asked about at all, so nothing - // fails and nothing is reported. alone := load(t, demo) c2 := fixtureClient(alone, alone.Tenants[0].TSGID) snap2 := c2.ElementSnapshot(t.Context(), alone.Tenants[0].Elements[0].apiElement()) @@ -159,8 +144,6 @@ func TestPortalEndpointsAreTenantScopedToo(t *testing.T) { t.Error("another tenant's element status resolved") } - // Through our own site, because the site in the path is beside the point: - // the element not being this tenant's is what makes it a 404. ours := acme.Elements[0].apiElement() _, err := c.ListInterfaces(t.Context(), ours.SiteID, theirs.ID) if err == nil { diff --git a/internal/fixture/rest.go b/internal/fixture/rest.go index a43286d..4a283dc 100644 --- a/internal/fixture/rest.go +++ b/internal/fixture/rest.go @@ -10,9 +10,6 @@ import ( "strings" ) -// Transport answers the SASE REST calls for one tenant. Handing this to the -// real client means the request building, status handling and decoding under -// test are the production ones; only the server is fake. func (s *Set) Transport(tsg string) http.RoundTripper { return &transport{set: s, tsg: tsg} } @@ -34,16 +31,12 @@ func (t *transport) RoundTrip(req *http.Request) (*http.Response, error) { case strings.HasSuffix(p, "/api/elements"): return t.json(req, http.StatusOK, items(t.set.apiElements(t.tsg))) - // Circuits. These come before the element routes because a circuit's - // element-side state lives under /elements/{id}/waninterfaces/... case strings.HasSuffix(p, "/api/waninterfacelabels"): return t.json(req, http.StatusOK, items(fixtureLabels())) case strings.HasSuffix(p, "/api/wannetworks"): return t.json(req, http.StatusOK, items(fixtureNetworks())) - // Security zones in two parts, as the real API has them: the tenant's - // zone names, and one element's bindings of interfaces to zones. case strings.HasSuffix(p, "/api/securityzones"): return t.json(req, http.StatusOK, items(fixtureZones())) @@ -58,8 +51,6 @@ func (t *transport) RoundTrip(req *http.Request) (*http.Response, error) { case strings.Contains(p, "/waninterfaces"): return t.circuits(req, p) - // The aggregate, which is tenant-wide: one POST for every cluster. Before - // the per-cluster route below, which its path would also match. case strings.HasSuffix(p, "/spokeclusters/status/query"): return t.json(req, http.StatusOK, items(t.set.apiClusterStatuses(t.tsg))) @@ -72,8 +63,6 @@ func (t *transport) RoundTrip(req *http.Request) (*http.Response, error) { } return t.json(req, http.StatusOK, st) - // Interfaces and their status hang off the site in the path, so these are - // matched on the element segment rather than on what the path ends with. case strings.Contains(p, "/elements/") && strings.Contains(p, "/interfaces"): return t.interfaces(req, p) @@ -98,9 +87,6 @@ func (t *transport) RoundTrip(req *http.Request) (*http.Response, error) { map[string]string{"error": fmt.Sprintf("fixture mode serves no %s", p)}) } -// interfaces answers both interface endpoints, whose paths are -// /sites/{site}/elements/{element}/interfaces and that path plus -// /{interface}/status. func (t *transport) interfaces(req *http.Request, p string) (*http.Response, error) { seg := strings.Split(strings.Trim(p, "/"), "/") var eid, iid string @@ -132,8 +118,6 @@ func (t *transport) interfaces(req *http.Request, p string) (*http.Response, err return t.json(req, http.StatusOK, st) } -// circuits answers the three circuit endpoints: the site's list, one -// circuit's status, and one circuit's state as seen by an element. func (t *transport) circuits(req *http.Request, p string) (*http.Response, error) { seg := strings.Split(strings.Trim(p, "/"), "/") var eid, cid string @@ -150,8 +134,6 @@ func (t *transport) circuits(req *http.Request, p string) (*http.Response, error } } - // The list and the status are the site's, so any element on the site can - // answer them; the fixture picks the first one it has there. el := t.element(eid) if el == nil { el = t.siteElementFor(p) @@ -177,8 +159,6 @@ func (t *transport) circuits(req *http.Request, p string) (*http.Response, error map[string]string{"error": "no circuit " + cid + " on that site"}) } -// siteElementFor finds any of the tenant's elements on the site in the path, -// for the endpoints that are the site's rather than an element's. func (t *transport) siteElementFor(p string) *Element { seg := strings.Split(strings.Trim(p, "/"), "/") site := "" @@ -202,10 +182,6 @@ func (t *transport) siteElementFor(p string) *Element { return nil } -// element is the tenant's own element, or nil. Outside the tenant is a 404 -// too: a tenant's token cannot see another tenant's elements, and a fixture -// that pretended otherwise would hide exactly the bug that looks like a site -// ID mistaken for an element. func (t *transport) element(eid string) *Element { el := t.set.element(eid) if el == nil || el.tsg != t.tsg { @@ -219,8 +195,6 @@ func (t *transport) noElement(req *http.Request, eid string) (*http.Response, er map[string]string{"error": "element " + eid + " not found in this tenant"}) } -// The profile is what proves a token is accepted, so fixture mode has to answer -// it or every tenant looks unreachable. func (t *transport) profile() map[string]any { name := t.tsg if ten := t.set.tenant(t.tsg); ten != nil { diff --git a/internal/fixture/rest_test.go b/internal/fixture/rest_test.go index 2b9f6c3..ac6b929 100644 --- a/internal/fixture/rest_test.go +++ b/internal/fixture/rest_test.go @@ -8,8 +8,6 @@ import ( "tyrtop.com/iontk/internal/sase/api" ) -// fixtureClient is the production client pointed at the fixture transport, -// which is how a run in fixture mode reaches the REST endpoints. func fixtureClient(s *Set, tsg string) *api.Client { c := &http.Client{Transport: s.Transport(tsg)} return api.NewClient(c, c, "fixture", false, 100, 100) @@ -38,8 +36,6 @@ func TestTheRealClientListsFixtureElements(t *testing.T) { t.Errorf("got %d sites, want 2: %+v", len(sites), sites) } - // The call that proves a token is accepted; without it every tenant looks - // unreachable. if _, err := c.Profile(t.Context()); err != nil { t.Errorf("Profile() error = %v", err) } @@ -54,9 +50,6 @@ func TestLookupAcrossTenantsIsA404(t *testing.T) { t.Fatalf("LookupElement() on its own tenant error = %v", err) } - // A token scoped to one tenant cannot see another's elements, and a fixture - // that pretended otherwise would hide the case where a site id is mistaken - // for an element id. _, err := c.LookupElement(t.Context(), lab.Elements[0].ID) if err == nil { t.Fatal("an element from another tenant resolved") diff --git a/internal/ionterm/manager.go b/internal/ionterm/manager.go index 9fdcd53..9291680 100644 --- a/internal/ionterm/manager.go +++ b/internal/ionterm/manager.go @@ -35,7 +35,6 @@ func (m *Manager) notify(eid string) { } func (m *Manager) Open(ctx context.Context, d Dialer, eid, name string, cols, rows int, creds Credentials) (*Session, error) { - s, err := Open(ctx, d, eid, name, cols, rows, creds, m.notify) if err != nil { return nil, err @@ -176,11 +175,6 @@ func (m *Manager) CloseActive() { m.notify(s.ElementID) } -// CloseDeadOn closes any session on this element that has already failed, -// ended or stopped answering, keeping the live ones. It is how a redial -// replaces the tab it was asked from, once the new session is actually up: -// the dead tab is the only record of what the session said, so it stays on -// screen until there is something to put in its place. func (m *Manager) CloseDeadOn(eid string) int { if m == nil { return 0 @@ -197,8 +191,6 @@ func (m *Manager) CloseDeadOn(eid string) int { kept = append(kept, s) } m.sessions = kept - // The survivor on that element is what the redial was for, so land on it - // rather than wherever the indices happen to leave the cursor. m.active = clampActive(len(m.sessions), m.active) for i, s := range m.sessions { if s.ElementID == eid { @@ -227,7 +219,6 @@ func clampActive(n, at int) int { return at } -// Reap records the element as it goes, so alt+t can reopen it. func (m *Manager) Reap() int { if m == nil { return 0 @@ -240,7 +231,6 @@ func (m *Manager) Reap() int { kept = append(kept, s) continue } - // The active tab is the one worth offering back. if i == m.active || !m.hasLast { m.lastEID, m.lastName, m.hasLast = s.ElementID, s.Name, true } @@ -283,8 +273,6 @@ func (m *Manager) Detach() (*Session, bool) { s := m.sessions[m.active] m.sessions = append(m.sessions[:m.active], m.sessions[m.active+1:]...) m.detached = append(m.detached, s) - // So alt+t can still dial the element if this session is unusable by the - // time it is asked for. m.lastEID, m.lastName, m.hasLast = s.ElementID, s.Name, true if m.active >= len(m.sessions) { m.active = len(m.sessions) - 1 @@ -308,8 +296,6 @@ func (m *Manager) Reattach() (*Session, bool) { for len(m.detached) > 0 { cand := m.detached[len(m.detached)-1] m.detached = m.detached[:len(m.detached)-1] - // Handing back a session that died unwatched would restore a tab - // showing only how it failed. if cand.Closed() || cand.Ended() || cand.Err() != nil { discard = append(discard, cand) continue diff --git a/internal/ionterm/manager_test.go b/internal/ionterm/manager_test.go index 4cfe3d4..f82f387 100644 --- a/internal/ionterm/manager_test.go +++ b/internal/ionterm/manager_test.go @@ -72,7 +72,6 @@ func TestReapKeepsTheActiveTabInRange(t *testing.T) { liveSession(t, m, "a", "a") endedSession(t, m, "b", "b") - // The ended session is the active one, being the most recently opened. if got := m.ActiveIndex(); got != 1 { t.Fatalf("ActiveIndex() = %d, want the newest tab active", got) } diff --git a/internal/ionterm/pane.go b/internal/ionterm/pane.go index 81c143e..94a7ded 100644 --- a/internal/ionterm/pane.go +++ b/internal/ionterm/pane.go @@ -11,10 +11,6 @@ type View struct { CursorX int CursorY int - // Offset is how far above the live bottom the window was taken, after - // clamping to what there is to scroll; Total is every line the session - // holds, screen and scrollback together. A marker needs both, or a pane - // frozen part way up reads as a hung one. Offset int Total int } @@ -23,8 +19,6 @@ func blank(line string) bool { return strings.TrimSpace(ansi.Strip(line)) == "" } -// The blank row the cursor sits on stays: an empty prompt line is where the -// typing goes. func screenLines(screen string, cursorY int) []string { lines := strings.Split(strings.TrimRight(screen, "\n"), "\n") last := len(lines) @@ -34,8 +28,6 @@ func screenLines(screen string, cursorY int) []string { return lines[:last] } -// The window is padded out to its full height, or the frame changes size as -// output arrives. func fitLines(lines []string, left, width, height int) string { out := make([]string, height) for i := range out { @@ -94,13 +86,6 @@ func (s *Session) Window(width, height int) string { return s.View(width, height).Content } -// ScrolledView clamps off to what there is to scroll and reports what it used, -// so a caller can hold an offset without tracking how much output arrived -// behind it. -// -// Everything it reads is taken under one lock: the screen, the cursor and the -// scrollback have to agree, or a line shows up twice in the frame that catches -// them mid-write. func (s *Session) ScrolledView(width, height, off int) View { if width <= 0 || height <= 0 { return View{} @@ -123,8 +108,6 @@ func (s *Session) ScrolledView(width, height, off int) View { return v } - // Window over the scrollback and the screen as one buffer, bottom - // anchored and then lifted. end := total - off start := max(0, end-height) window := make([]string, 0, height) @@ -135,8 +118,6 @@ func (s *Session) ScrolledView(width, height, off int) View { window = append(window, lines[max(0, start-sb.Len()):end-sb.Len()]...) } - // No cursor: it belongs to the live bottom, and drawing it in the middle - // of frozen output would point at a line nothing is being typed on. return View{ Content: fitLines(window, 0, width, height), Offset: off, @@ -144,8 +125,6 @@ func (s *Session) ScrolledView(width, height, off int) View { } } -// MaxScroll is what a key has to clamp to: without it a held key runs the -// offset up into nothing and the way back is as many presses as it took. func (s *Session) MaxScroll(height int) int { s.emuMu.Lock() defer s.emuMu.Unlock() @@ -159,8 +138,6 @@ func (s *Session) Scrollback() int { return s.emu.ScrollbackLen() } -// What a copy takes: scrollback and screen, stripped, trailing blanks -// dropped. One lock, like ScrolledView, so the halves agree. func (s *Session) Buffer() string { s.emuMu.Lock() defer s.emuMu.Unlock() diff --git a/internal/ionterm/scrollback_test.go b/internal/ionterm/scrollback_test.go index 588bcd0..4403246 100644 --- a/internal/ionterm/scrollback_test.go +++ b/internal/ionterm/scrollback_test.go @@ -15,8 +15,6 @@ func feedLines(t *testing.T, c *fakeConn, from, to int) { } } -// A session whose output has outrun a six-row screen, which is the only state -// where scrolling back shows anything. func scrolledSession(t *testing.T, lines int) (*Session, *fakeConn) { t.Helper() d := &fakeDialer{conn: newFakeConn(nil)} @@ -52,7 +50,6 @@ func TestScrolledViewReachesLinesThatLeftTheScreen(t *testing.T) { t.Fatalf("the first line is still on screen, so nothing scrolled off:\n%s", plain(live.Content)) } - // Far enough back to reach the first line ever written. back := s.ScrolledView(40, 6, live.Total) if want := live.Total - 6; back.Offset != want { t.Errorf("Offset = %d, want it clamped to %d", back.Offset, want) @@ -124,8 +121,6 @@ func TestScrolledViewDoesNotRaceDeviceOutput(t *testing.T) { } } -// Buffer is what a copy takes, so it has to reach past the screen: the whole -// point of copying a session is the output that has already scrolled away. func TestBufferHoldsTheScrollbackAndTheScreen(t *testing.T) { s, _ := scrolledSession(t, 30) diff --git a/internal/ionterm/session.go b/internal/ionterm/session.go index 1ce8c00..6009131 100644 --- a/internal/ionterm/session.go +++ b/internal/ionterm/session.go @@ -16,8 +16,6 @@ import ( type Conn interface { Read(ctx context.Context) (websocket.MessageType, []byte, error) Write(ctx context.Context, typ websocket.MessageType, p []byte) error - // Ping must be answered by the peer. An idle ION shell sends nothing for - // hours, so silence alone cannot tell a hang from a quiet prompt. Ping(ctx context.Context) error Close(code websocket.StatusCode, reason string) error } @@ -35,10 +33,6 @@ type Session struct { emu *vt.SafeEmulator conn Conn - // SafeEmulator locks each of its own methods, but Scrollback() hands back - // the buffer itself and the lock is gone by the time its lines are read. - // This covers a write against the reads that walk them, which is all that - // is needed: ionterm owns every emulator call site. emuMu sync.Mutex keys chan uv.KeyEvent @@ -56,27 +50,17 @@ type Session struct { line []rune lastSeen time.Time - // Snapshotted at Open so a session's cadence cannot change under its own - // liveness loop. pingEvery time.Duration pongWait time.Duration handshake time.Duration } -// A dial that answers nothing at all is still bounded, but once the socket is -// up liveness comes from pongs rather than the clock: a slow device that keeps -// answering is not hung, and one that stops is, whatever the elapsed time. var ( - pingEvery = 15 * time.Second - pongWait = 10 * time.Second - // A socket that pongs but never sends a prompt still has to be given up - // on: autologin has nothing to answer, and there is no traffic to measure. + pingEvery = 15 * time.Second + pongWait = 10 * time.Second handshakeDeadline = 20 * time.Second ) -// SetLivenessForTest shortens the ping cadence so another package's tests can -// reach a hang without waiting out the real one, and restores it afterwards. -// Sessions snapshot these at Open, so it must be called before one is opened. func SetLivenessForTest(ping, pong time.Duration) (restore func()) { oldPing, oldPong := pingEvery, pongWait pingEvery, pongWait = ping, pong @@ -189,11 +173,7 @@ func (s *Session) pumpKeys(ctx context.Context) { } } -// liveness replaces a flat deadline. Traffic is proof enough; only a quiet -// connection is pinged, and only an unanswered ping is a hang. func (s *Session) liveness(ctx context.Context) { - // The handshake is a one-shot deadline because there is no traffic yet to - // measure; everything after it is paced by pongs. hs := time.NewTimer(s.handshake) defer hs.Stop() t := time.NewTicker(s.pingEvery) @@ -243,9 +223,6 @@ func (s *Session) liveness(ctx context.Context) { } } -// markHung records a connection that stopped answering. It is kept apart from -// err because it is recoverable: the user can dial the element again, which -// err offers no route to. func (s *Session) markHung() { s.mu.Lock() if s.err == nil && !s.closed && !s.ended { @@ -255,18 +232,14 @@ func (s *Session) markHung() { s.markDirty() } -// Answered reports whether the device has sent anything at all. The socket -// coming up proves nothing about the appliance: the SASE side accepts a -// toolkit websocket for an element that is not there, and such a session sits -// mute until the handshake deadline fails it. The first byte is the only -// evidence there is an ION on the other end. +// the SASE side accepts a toolkit websocket for an element that is not there, +// and that session sits mute until the handshake deadline fails it func (s *Session) Answered() bool { s.mu.Lock() defer s.mu.Unlock() return s.gotData } -// Hung is a session whose connection stopped answering pings. func (s *Session) Hung() bool { s.mu.Lock() defer s.mu.Unlock() @@ -279,8 +252,6 @@ func (s *Session) markDirty() { } } -// cleanHangUp is what typing "exit" looks like from here: the device closes the -// socket. A torn connection is a failure the user needs to see. func cleanHangUp(err error) bool { switch websocket.CloseStatus(err) { case websocket.StatusNormalClosure, websocket.StatusGoingAway, websocket.StatusNoStatusRcvd: @@ -289,8 +260,6 @@ func cleanHangUp(err error) bool { return errors.Is(err, io.EOF) } -// end records no error, so the tab closes instead of reporting a failure the -// user caused on purpose. func (s *Session) end() { s.mu.Lock() if s.err == nil && !s.closed { diff --git a/internal/ionterm/session_test.go b/internal/ionterm/session_test.go index 094ad5d..46cd523 100644 --- a/internal/ionterm/session_test.go +++ b/internal/ionterm/session_test.go @@ -777,8 +777,6 @@ func TestAQuietButLiveSessionIsNotHung(t *testing.T) { eventually(t, "the session to be pinged", func() bool { return conn.pings.Load() > 0 }) time.Sleep(80 * time.Millisecond) - // An idle ION prompt sends nothing for hours; a flat timer would have - // killed this session. if s.Hung() { t.Error("Hung() = true for a connection answering pings") } @@ -801,7 +799,6 @@ func TestASessionThatStopsAnsweringIsHung(t *testing.T) { defer s.Close() eventually(t, "the hang to be noticed", s.Hung) - // Hung is apart from Err because it is recoverable: the user redials. if err := s.Err(); err != nil { t.Errorf("Err() = %v, want a hang not to read as a failure", err) } @@ -813,7 +810,6 @@ func TestASessionThatStopsAnsweringIsHung(t *testing.T) { func TestAHangIsNoticedWithoutWaitingOutAFlatDeadline(t *testing.T) { oldPing, oldPong, oldHS := pingEvery, pongWait, handshakeDeadline pingEvery, pongWait = 20*time.Millisecond, 30*time.Millisecond - // The old behaviour waited this out regardless; the ping answers sooner. handshakeDeadline = 10 * time.Second t.Cleanup(func() { pingEvery, pongWait, handshakeDeadline = oldPing, oldPong, oldHS }) @@ -844,7 +840,6 @@ func TestTrafficAloneKeepsASessionAliveWithoutPinging(t *testing.T) { } defer s.Close() - // Output arriving is proof enough, so a busy session costs no pings. deadline := time.Now().Add(200 * time.Millisecond) for time.Now().Before(deadline) { conn.feed <- []byte(".") diff --git a/internal/output/output.go b/internal/output/output.go index 7f9a048..cdca500 100644 --- a/internal/output/output.go +++ b/internal/output/output.go @@ -77,21 +77,13 @@ func NextFormat(f string) string { return FormatJSON } -// Tabular is asked before offering an orientation control: on the other -// formats it would change nothing. func Tabular(f string) bool { return f == FormatTable || f == FormatCSV } -// Sink receives each element's result as it finishes and writes it out. -// The streaming formats write immediately; json and the tabular formats -// buffer, because an array has to keep the order the elements were given in -// and a row sort cannot start until every row exists. type Sink interface { Add(index int, r sase.Result) error Close() error } -// Only the tabular formats read these, so a caller can pass them without -// knowing which format it ended up with. type Option func(*sinkOpts) type sinkOpts struct { @@ -101,8 +93,6 @@ type sinkOpts struct { func WithOrientation(o string) Option { return func(s *sinkOpts) { s.orient = o } } -// WithWidth fits table rows into n columns. Zero leaves every cell whole, -// which is what a file wants -- truncating one would lose the only copy. func WithWidth(n int) Option { return func(s *sinkOpts) { s.width = n } } func NewSink(w io.Writer, format string, opts ...Option) Sink { @@ -138,8 +128,6 @@ func (s *jsonSink) Add(index int, r sase.Result) error { return nil } -// addAt exists because results arrive in completion order and have to be -// written in the order the elements were given. func addAt(results []sase.Result, index int, r sase.Result) []sase.Result { for len(results) <= index { results = append(results, sase.Result{}) @@ -153,15 +141,12 @@ func (s *jsonSink) Close() error { s.results = []sase.Result{} } enc := json.NewEncoder(s.w) - // Command output stays \n-escaped inside the JSON strings whatever the - // indentation, which is why text and table exist. enc.SetIndent("", " ") return enc.Encode(s.results) } type jsonlSink struct{ enc *json.Encoder } -// Encode already ends each value with a newline, which is the whole format. func (s *jsonlSink) Add(_ int, r sase.Result) error { return s.enc.Encode(r) } func (s *jsonlSink) Close() error { return nil } @@ -222,14 +207,10 @@ type nopCloser struct{ io.Writer } func (nopCloser) Close() error { return nil } -// outputName is the file a run writes to when output goes to a folder. The -// timestamp keeps repeated runs from overwriting each other. func outputName(format string, now time.Time) string { return "iontk-" + now.Format("20060102-150405") + "." + formatExt(format) } -// Open decides where results go. An explicit path wins; "-" and an empty -// folder both mean stdout. It returns the destination and a label to report. func Open(explicit, dir, format string, now time.Time) (io.WriteCloser, string, error) { path := strings.TrimSpace(explicit) switch { @@ -253,7 +234,6 @@ func Open(explicit, dir, format string, now time.Time) (io.WriteCloser, string, return f, path, nil } -// ExpandHome resolves a leading ~ so a typed-in folder behaves the way it looks. func ExpandHome(path string) (string, error) { path = strings.TrimSpace(path) if path != "~" && !strings.HasPrefix(path, "~/") { diff --git a/internal/output/output_test.go b/internal/output/output_test.go index 927fc04..58ea680 100644 --- a/internal/output/output_test.go +++ b/internal/output/output_test.go @@ -108,7 +108,6 @@ func TestJSONSinkKeepsElementOrder(t *testing.T) { var buf bytes.Buffer sink := NewSink(&buf, FormatJSON) - // Added out of order, the way elements actually finish. if err := sink.Add(2, sampleResult("c", "s0003")); err != nil { t.Fatalf("Add() error = %v", err) } @@ -151,7 +150,6 @@ func TestJSONSinkIndents(t *testing.T) { } got := buf.String() - // Without an indent the whole array is one line, which is what this guards. if !strings.Contains(got, "[\n {\n \"element_id\"") { t.Errorf("json array is not indented two spaces:\n%s", got) } diff --git a/internal/output/tabular.go b/internal/output/tabular.go index da1b205..ffc3b6a 100644 --- a/internal/output/tabular.go +++ b/internal/output/tabular.go @@ -13,7 +13,6 @@ import ( "tyrtop.com/iontk/internal/ui" ) -// Orientation is a sort over the same rows, never a pivot. const ( OrientION = "ion" OrientCommand = "command" @@ -21,8 +20,6 @@ const ( var orientations = []string{OrientION, OrientCommand} -// OrientationLabel names an orientation for a header or a status line, where -// "ion" alone does not say what it did to the rows. func OrientationLabel(o string) string { if o == OrientCommand { return "command-first" @@ -56,9 +53,6 @@ func NextOrientation(o string) string { return OrientION } -// tabularHeader is the column order for both tabular formats. Orientation -// sorts rows; it never moves a column, so one run stays diffable against -// another taken the other way round. var tabularHeader = []string{"element", "element_id", "model", "software", "connected", "command", "output", "error"} const ( @@ -68,8 +62,6 @@ const ( ) type tabularRow struct { - // The order given rather than the order finished: elements complete - // concurrently, and a run has to sort the same way every time. elem, cmd int cells []string } @@ -78,12 +70,10 @@ func tabularRows(results []sase.Result) []tabularRow { var rows []tabularRow for i, r := range results { if r.ElementID == "" && r.Error == "" && len(r.Commands) == 0 { - continue // a gap Add's index padding left for an element that never reported + continue } meta := []string{r.Name, r.ElementID, r.Model, r.Software, strconv.FormatBool(r.Connected)} - // An element that failed before any command ran still gets a row, or a - // run where every dial failed would write nothing but a header. if len(r.Commands) == 0 { rows = append(rows, tabularRow{elem: i, cmd: -1, cells: append(slices.Clone(meta), "", "", r.Error)}) continue @@ -95,9 +85,6 @@ func tabularRows(results []sase.Result) []tabularRow { return rows } -// An element that failed before any command has no command to group under, so -// it leads in command-first: a run's failures read better together than -// scattered between commands they never reached. func orderRows(rows []tabularRow, orient string) { sort.SliceStable(rows, func(i, j int) bool { a, b := rows[i], rows[j] @@ -163,8 +150,6 @@ func (s *tableSink) Close() error { grid = append(grid, tabularHeader) for _, r := range rows { cells := slices.Clone(r.cells) - // A table is one line per command. The full text is what json, jsonl - // and text are for, and csv keeps it too. cells[colOutput] = flattenCell(cells[colOutput]) cells[colError] = flattenCell(cells[colError]) grid = append(grid, cells) @@ -182,9 +167,6 @@ func (s *tableSink) Close() error { return err } -// elasticMin is the floor the shrinkable columns stop at. Below it a cell says -// nothing at all, and the row is better cut by the line truncation than padded -// out with columns of one character each. const elasticMin = 12 const colGap = " " @@ -205,10 +187,6 @@ func columnWidths(grid [][]string, width int) []int { return widths } -// Zero width is how the renderers are told to skip a column. A clean run's -// error column is the case that matters: five columns and a gap that the -// output was going to be read in. Nothing is dropped without a width to fit, -// where the full header is worth more than the space. func dropEmptyColumns(grid [][]string, widths []int, width int) { if width <= 0 || len(grid) < 2 { return @@ -227,10 +205,6 @@ func dropEmptyColumns(grid [][]string, widths []int, width int) { } } -// Output gives up its width before error does, because json and csv still hold -// the full output while a cut-off error is all a failed row has. The -// identifying columns never shrink: a truncated 19-digit element ID reads as a -// different, valid ID. func fitWidths(widths []int, width int) { if width <= 0 { return @@ -276,8 +250,6 @@ func renderRule(widths []int, width int) string { return fitLine(strings.Join(parts, colGap), width) } -// fitLine trims the padding off the last column before truncating, so a narrow -// terminal spends its columns on content rather than on trailing blanks. func fitLine(line string, width int) string { line = strings.TrimRight(line, " ") if width > 0 { diff --git a/internal/output/tabular_test.go b/internal/output/tabular_test.go index 726893f..ac2c66d 100644 --- a/internal/output/tabular_test.go +++ b/internal/output/tabular_test.go @@ -10,8 +10,6 @@ import ( "tyrtop.com/iontk/internal/sase/ioncli" ) -// Two elements running the same two commands, plus one that never connected: -// the only shape where the two orientations differ observably. func fleet() []sase.Result { el := func(id, name string, outs ...string) sase.Result { return sase.Result{ @@ -29,7 +27,6 @@ func fleet() []sase.Result { } } -// fill adds the fleet out of order, the way elements actually finish. func fill(sink Sink, t *testing.T) { t.Helper() rs := fleet() @@ -65,8 +62,6 @@ func TestTabularOrientationOrdersRows(t *testing.T) { orient string want []string }{ - // ION-first groups an element's commands; command-first puts one - // command across the fleet together, which is the comparison. {OrientION, []string{ "s0001/dump device status", "s0001/dump interface status", "s0002/dump device status", "s0002/dump interface status", @@ -105,8 +100,6 @@ func TestTabularHeaderIsTheSameBothWays(t *testing.T) { } } -// The columns the code indexes by number have to stay where it thinks they -// are; inserting one above them would silently shift every row. func TestTabularColumnIndexesMatchHeader(t *testing.T) { for col, want := range map[int]string{colCommand: "command", colOutput: "output", colError: "error"} { if got := tabularHeader[col]; got != want { @@ -127,8 +120,6 @@ func TestCSVKeepsMultilineAndCommaOutputIntact(t *testing.T) { if got := recs[2][colOutput]; got != "eth0 up, eth1 down" { t.Errorf("output cell = %q, want the comma to stay inside one field", got) } - // Quoted newlines make the file longer than its record count. Escaping - // them to \n instead would parse and still be the wrong format. if lines := strings.Count(strings.TrimRight(buf.String(), "\n"), "\n") + 1; lines <= len(recs) { t.Errorf("csv has %d lines for %d records, want the quoted output to span lines", lines, len(recs)) } @@ -170,7 +161,6 @@ func TestEmptyRunWritesAHeaderAndNothingElse(t *testing.T) { } } -// Padding over an element that never reported must not become a blank row. func TestTabularSkipsUnreportedElements(t *testing.T) { var buf bytes.Buffer sink := NewSink(&buf, FormatCSV) @@ -190,7 +180,6 @@ func TestTableFlattensOutputToOneLinePerCommand(t *testing.T) { fill(NewSink(&buf, FormatTable, WithOrientation(OrientION)), t) lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n") - // header, rule, two commands each for two elements, one failure if len(lines) != 7 { t.Fatalf("table has %d lines, want 7:\n%s", len(lines), buf.String()) } @@ -211,7 +200,6 @@ func TestTableFitsTheGivenWidth(t *testing.T) { } } -// A file holds the only copy of what it is given, so nothing is capped there. func TestTableWithoutAWidthKeepsWholeCells(t *testing.T) { long := strings.Repeat("x", 200) var buf bytes.Buffer @@ -282,7 +270,6 @@ func TestNextOrientationCyclesThroughEveryOrientation(t *testing.T) { } } -// An unknown orientation reaching a sink must not silently sort by nothing. func TestNewSinkFallsBackToIONFirst(t *testing.T) { var buf bytes.Buffer fill(NewSink(&buf, FormatCSV, WithOrientation("sideways")), t) @@ -324,14 +311,12 @@ func TestTableDropsAnEmptyColumnToFitAWidth(t *testing.T) { t.Errorf("the error column survived a width with nothing in it:\n%s", fitted.String()) } - // The header is worth more than the space when there is no width to fit. var whole bytes.Buffer add(NewSink(&whole, FormatTable)) if !strings.Contains(whole.String(), "error") { t.Errorf("the error column was dropped from a file:\n%s", whole.String()) } - // And it stays as soon as one row needs it. var failed bytes.Buffer fill(NewSink(&failed, FormatTable, WithWidth(110)), t) if !strings.Contains(failed.String(), "error") { diff --git a/internal/sase/api/auth.go b/internal/sase/api/auth.go index b424ed4..cdce806 100644 --- a/internal/sase/api/auth.go +++ b/internal/sase/api/auth.go @@ -14,9 +14,6 @@ import ( const authURL = "https://auth.apps.paloaltonetworks.com/oauth2/access_token" -// TokenSource produces access tokens and says how long each one lasts, so that -// refreshLoop can schedule the next one. Implementations may be anything from a -// service account to a browser sign-in. type TokenSource interface { Token(ctx context.Context) (string, time.Duration, error) } @@ -89,14 +86,12 @@ func (s *Client) mintLocked(ctx context.Context) (time.Duration, error) { return ttl, nil } -// So an expensive operation fails here once rather than on every element. func (s *Client) EnsureToken(ctx context.Context, need time.Duration) error { if s.src == nil || s.TokenGood(need) { return nil } s.mintMu.Lock() defer s.mintMu.Unlock() - // A batch spanning tenants arrives here at once; one renewal serves all. if s.TokenGood(need) { return nil } @@ -106,13 +101,8 @@ func (s *Client) EnsureToken(ctx context.Context, need time.Duration) error { return nil } -// retryAfterFailure is how long to wait before trying again after a refresh -// that could plausibly succeed later. Waiting out the whole token lifetime -// turns a momentary network problem into a quarter hour of 401s. const retryAfterFailure = 30 * time.Second -// temporary is implemented by errors that know whether retrying could help. A -// TokenSource uses it to say that nothing will change without a human. type temporary interface{ Temporary() bool } func retryable(err error) bool { @@ -123,11 +113,6 @@ func retryable(err error) bool { return true } -// TokenErrors reports refresh failures so the caller can decide what to say -// about them. The channel is buffered and sends are dropped rather than -// blocked, so a caller that never reads it costs nothing. It closes when -// refreshing stops for good, which is the signal that the credential needs -// human attention. func (s *Client) TokenErrors() <-chan error { return s.tokenErrs } func (s *Client) reportTokenError(err error) { @@ -155,9 +140,6 @@ func (s *Client) refreshLoop(ctx context.Context, ttl, margin time.Duration) { next, err := s.mint(ctx) if err != nil { s.reportTokenError(fmt.Errorf("token refresh: %w", err)) - // A credential that needs a human back at the keyboard will not - // start working on its own, so stop instead of spinning. Signing - // in again builds a new client with a new loop. if !retryable(err) { s.stopRefreshing() return @@ -179,7 +161,7 @@ func (s *Client) refreshLoop(ctx context.Context, ttl, margin time.Duration) { const profileURL = "https://api.sase.paloaltonetworks.com/sdwan/v2.1/api/profile" -// Palo Alto requires an extra call to prime the token. Without this call, subsequent calls return 403 errors. +// Palo Alto needs an extra call to prime the token, or everything after it 403s func (s *Client) activate(ctx context.Context) error { url := profileURL if s.profileBase != "" { diff --git a/internal/sase/api/auth_test.go b/internal/sase/api/auth_test.go index 716b7d6..ec81620 100644 --- a/internal/sase/api/auth_test.go +++ b/internal/sase/api/auth_test.go @@ -45,10 +45,8 @@ func TestTokenGood(t *testing.T) { {"plenty of life left", "t", 15 * time.Minute, false, 90 * time.Second, true}, {"inside the pre-flight window", "t", 30 * time.Second, false, 90 * time.Second, false}, {"already expired", "t", -time.Second, false, 90 * time.Second, false}, - // A static SCM_TOKEN has no lifetime; refusing to try is worse. {"unknown lifetime", "t", 0, false, 90 * time.Second, true}, {"no token at all", "", 0, false, 90 * time.Second, false}, - // The last token there will be, whatever its clock says. {"refreshing stopped", "t", 15 * time.Minute, true, 90 * time.Second, false}, } for _, tt := range tests { diff --git a/internal/sase/api/client.go b/internal/sase/api/client.go index 88cfad7..99e7e70 100644 --- a/internal/sase/api/client.go +++ b/internal/sase/api/client.go @@ -1,4 +1,3 @@ -// Package api is a client for the Prisma SD-WAN endpoints of the SASE API. package api import ( @@ -21,13 +20,11 @@ type Client struct { limiter *rate.Limiter verbose bool - tokenMu sync.RWMutex - token string - // Zero means the source never said, not expired. + tokenMu sync.RWMutex + token string expires time.Time refreshStopped bool - // Tests only. profileBase string dialMu sync.RWMutex @@ -38,12 +35,9 @@ type Client struct { src TokenSource tokenErrs chan error - // Keeps a caller's mint from racing refreshLoop's. - mintMu sync.Mutex + mintMu sync.Mutex } -// An unknown expiry counts as good: a static SCM_TOKEN has no lifetime to -// check, and refusing to try would be worse. func (s *Client) TokenGood(need time.Duration) bool { s.tokenMu.RLock() defer s.tokenMu.RUnlock() @@ -59,7 +53,6 @@ func (s *Client) TokenGood(need time.Duration) bool { return time.Until(s.expires) >= need } -// RefreshStopped means every request from here fails until a human signs in. func (s *Client) RefreshStopped() bool { s.tokenMu.RLock() defer s.tokenMu.RUnlock() @@ -85,8 +78,6 @@ func NewClient(client, wsClient *http.Client, token string, verbose bool, rps fl } } -// ElementRecord is LookupElement keeping the body, for callers that want to -// show what the server actually sent. func (s *Client) ElementRecord(ctx context.Context, eid string) ([]byte, Element, error) { body, err := s.get(ctx, "element "+eid, ElementURL(eid)) if err != nil { @@ -126,8 +117,6 @@ func (s *Client) setExpiry(ttl time.Duration) { s.tokenMu.Unlock() } -// SetExpiryForTest exists because the fields are unexported and other -// packages' tests need a near-dead token. func (s *Client) SetExpiryForTest(ttl time.Duration) { s.setExpiry(ttl) } func (s *Client) stopRefreshing() { @@ -156,8 +145,6 @@ func NewServiceAccountClient(ctx context.Context, client, wsClient *http.Client, return s, nil } -// NewTokenSourceClient builds a client around any TokenSource, priming a token -// before it returns and keeping it fresh afterwards. func NewTokenSourceClient(ctx context.Context, client, wsClient *http.Client, src TokenSource, margin time.Duration, verbose bool, rps float64, burst int) (*Client, error) { s := &Client{ client: client, diff --git a/internal/sase/api/element.go b/internal/sase/api/element.go index 94f5f81..424016a 100644 --- a/internal/sase/api/element.go +++ b/internal/sase/api/element.go @@ -10,24 +10,18 @@ type Element struct { Connected bool `json:"connected"` State string `json:"state"` - Description string `json:"description"` - Serial string `json:"serial_number"` - Role string `json:"role"` - // AllowedRoles is what this element could be, where Role is what it is: - // ["HUB","SPOKE"] on a spoke that could be promoted. + Description string `json:"description"` + Serial string `json:"serial_number"` + Role string `json:"role"` AllowedRoles []string `json:"allowed_roles"` Tags []string `json:"tags"` - // Cluster is the record's own cluster_id, which was null on a spoke that - // is in a cluster -- read ClusterID() rather than this. + // null on a spoke that is in a cluster -- read ClusterID() instead Cluster string `json:"cluster_id"` SpokeHA *SpokeHA `json:"spoke_ha_config"` AdminSuspend string `json:"admin_suspend_state"` FIPSMode string `json:"fips_mode"` } -// ClusterID is the HA cluster the element is in, from whichever field carries -// it: a clustered spoke had spoke_ha_config.cluster_id set and the record's -// own cluster_id null. func (e Element) ClusterID() string { if e.SpokeHA != nil && e.SpokeHA.ClusterID != "" { return e.SpokeHA.ClusterID @@ -35,15 +29,6 @@ func (e Element) ClusterID() string { return e.Cluster } -// The record also carries a device_mode, and it is not the one in -// ElementStatus: an element answering "none" here answered "l3-mode" there on -// the same request. Whatever this one means, the status is the live answer, so -// the field is deliberately not modelled on both. - -// SpokeHA is the element's half of a spoke HA pair: which cluster it belongs -// to and what it watches to decide it should take over. Whether it is the -// active member is not in here -- that is the cluster's status, not the -// element's config. type SpokeHA struct { ClusterID string `json:"cluster_id"` Enable bool `json:"enable"` @@ -57,9 +42,6 @@ type HATrack struct { WANInterfaces []HATrackItem `json:"waninterfaces"` } -// One type for both lists: the portal spells out the LAN entry -// ({interface_id, reduce_priority}) and not the WAN one, so the WAN id is -// carried as a second tag rather than guessed at in a type of its own. type HATrackItem struct { InterfaceID string `json:"interface_id"` WANInterfaceID string `json:"wan_interface_id"` diff --git a/internal/sase/api/endpoint.go b/internal/sase/api/endpoint.go index aa93b7a..3e61f21 100644 --- a/internal/sase/api/endpoint.go +++ b/internal/sase/api/endpoint.go @@ -2,15 +2,8 @@ package api import "fmt" -// Paths and versions come from the portal's own resource map, which it fetches -// from GET /sdwan/v2.0/api/permissions: resource_uri_map gives each resource a -// template of the form /{ver}/api/tenants/{tsg}/ and -// resource_version_map lists the versions the server accepts. The SASE form -// drops the tenant segment -- the token carries it -- so it is -// /sdwan/{ver}/api/, with monitor resources under /sdwan/monitor. The -// toolkit socket iontk already dials against hardware is that same -// derivation, which is what makes the rest of this table credible; the -// versions below are the highest each map offered, confirmed on pan.dev. +// paths and versions come from GET /sdwan/v2.0/api/permissions, the portal's own +// resource map; the SASE form drops the map's tenant segment, the token carries it const apiHost = "https://api.sase.paloaltonetworks.com" const ( @@ -24,13 +17,9 @@ const ( const scmBase = apiHost + "/sdwan/" + elementsVer + "/api" -// Sites are on their own version. The portal's own traffic asks v4.13 for them -// while using v3.2 for elements, and v3.2/sites does not answer, which left -// every site name unresolved and showing a raw id instead. +// its own version because v3.2/sites does not answer, which left site names raw const sitesBase = apiHost + "/sdwan/" + sitesVer + "/api" -// ElementURL is exported for the one caller that wants to label a raw body -// with where it came from. func ElementURL(eid string) string { return scmBase + "/elements/" + eid } @@ -49,8 +38,6 @@ func clusterStatusURL(siteID, cid string) string { apiHost, clusterStatusVer, siteID, cid) } -// query_status_spokeclusters: tenant-wide, because the token carries the -// tenant. One POST where clusterStatusURL is one GET per cluster. func clusterQueryURL() string { return fmt.Sprintf("%s/sdwan/%s/api/spokeclusters/status/query", apiHost, clusterStatusVer) } diff --git a/internal/sase/api/profile.go b/internal/sase/api/profile.go index 4eb830f..64c2928 100644 --- a/internal/sase/api/profile.go +++ b/internal/sase/api/profile.go @@ -8,10 +8,6 @@ import ( "net/http" ) -// Profile is the operator the current token acts as. For an SSO-backed sign-in -// the name fields hold the subject rather than a person's name, so the readable -// identity comes from the token instead; what is useful here is proof the token -// works, plus the region and roles it resolved to. type Profile struct { ID string `json:"id"` Email string `json:"email"` @@ -21,7 +17,6 @@ type Profile struct { Roles []string `json:"-"` } -// Fetch decodes roles separately: they arrive as objects, not strings. func (p *Profile) UnmarshalJSON(b []byte) error { type plain Profile var raw struct { @@ -42,8 +37,6 @@ func (p *Profile) UnmarshalJSON(b []byte) error { return nil } -// Profile fetches the operator profile. It doubles as a connection test: this -// is the call that proves a token is accepted by the SD-WAN API. func (s *Client) Profile(ctx context.Context) (Profile, error) { if err := s.limiter.Wait(ctx); err != nil { return Profile{}, fmt.Errorf("profile: rate limit wait: %w", err) diff --git a/internal/sase/api/snapshot.go b/internal/sase/api/snapshot.go index 35de200..acc7795 100644 --- a/internal/sase/api/snapshot.go +++ b/internal/sase/api/snapshot.go @@ -7,32 +7,18 @@ import ( "fmt" ) -// Snapshot is what the portal knows about one element: the record, its -// back-channels to the controller, and every interface with its live state. -// One part failing does not cancel the others -- an element with no site has -// no interfaces to list, and an old controller may not answer for status at -// all -- so each part carries its own error and the caller renders what -// arrived. type Snapshot struct { Element Element Status ElementStatus StatusErr error Interfaces []InterfaceState IfacesErr error - // Cluster is nil unless the element is in a spoke HA cluster, which its - // own record is what says. Cluster *SpokeClusterStatus ClusterErr error - // Raw is each response body as it arrived, keyed by URL. These shapes are - // modelled from the portal's own field names rather than a published - // schema, so the bytes are kept: without them a field that never decodes - // looks exactly like a field the server left empty. Raw map[string]json.RawMessage } -// InterfaceState pairs what was configured with what the port is doing. The -// status is a slice because a bypass pair reports one per member. type InterfaceState struct { Interface Interface Status []InterfaceStatus @@ -47,9 +33,6 @@ func (s Snapshot) Err() error { return errors.Join(errs...) } -// ElementSnapshot gathers the portal side of one element. It takes the -// element record rather than an ID because the interface endpoints are -// site-scoped, and the site only comes from the record. func (s *Client) ElementSnapshot(ctx context.Context, el Element) Snapshot { snap := Snapshot{Element: el, Raw: map[string]json.RawMessage{}} diff --git a/internal/sase/api/status.go b/internal/sase/api/status.go index 19508e9..17ebb06 100644 --- a/internal/sase/api/status.go +++ b/internal/sase/api/status.go @@ -14,47 +14,30 @@ import ( "time" ) -// ElementStatus is the controller's view of an element: its back-channels and -// a handful of facts about the device itself. The API spells every channel out -// flat -- config_and_events_connected, analytics_live_connected_on_utc, -// logs_backlog_from and so on -- so they are grouped here by prefix instead, -// and a channel the server adds later needs no field. type ElementStatus struct { ElementID string SiteID string Channels []Channel - // DeviceMode is "l3-mode" on a router. - DeviceMode string - // ControllerInterface is the interface ID the controller session runs - // over, which is not otherwise derivable from the interface list. + DeviceMode string ControllerInterface string LastRebooted int64 - // LastRebootedInfo is free text: "Requested by API" on a reboot iontk's - // own sort of tooling asked for. - LastRebootedInfo string - LastDisconnected int64 + LastRebootedInfo string + LastDisconnected int64 } -// Channel is one of those back-channels. On and Off are the API's "utc" -// integers, whose unit varies by field: see UTCTime. type Channel struct { Name string Connected bool On int64 Off int64 - // From is the controller address the element is talking to. - From string -} - -// UTCTime converts one of the API's timestamp integers. The unit cannot be -// assumed from the field name, because one element-status body carries three -// of them: last_rebooted_time in seconds, analytics_live_connected_on_utc in -// milliseconds, and config_and_events_connected_on_utc and -// last_disconnected_time in 100ns ticks -- as do the _created_on_utc and -// _updated_on_utc fields on every record. So the magnitude decides: an epoch -// date is around 1.8e9 seconds, and each scale shifts that by three digits or -// seven. Zero means the field was never set. + From string +} + +// the unit cannot be assumed from the field name: one element-status body +// carries seconds (last_rebooted_time), milliseconds (analytics_live_...) and +// 100ns ticks (config_and_events_...), as do every record's _on_utc fields. So +// the magnitude decides, an epoch date being around 1.8e9 seconds. Zero is unset. func UTCTime(v int64) time.Time { switch { case v == 0: @@ -64,16 +47,12 @@ func UTCTime(v int64) time.Time { case v < 1e14: return time.UnixMilli(v).UTC() case v < 1e16: - // Not observed, unlike the other three. Guessing 100ns here would be a - // date a century out, which is worse than guessing microseconds. return time.UnixMicro(v).UTC() default: return time.Unix(v/1e7, (v%1e7)*100).UTC() } } -// Channel returns one channel by name, "config_and_events" being the one that -// decides whether the portal calls an element connected at all. func (s ElementStatus) Channel(name string) (Channel, bool) { for _, c := range s.Channels { if c.Name == name { @@ -91,8 +70,6 @@ func (s *Client) ElementStatus(ctx context.Context, eid string) (ElementStatus, return decodeElementStatus(body) } -// Suffixes that make up one channel. The field name up to the suffix is the -// channel's name. const ( sufConnected = "_connected" sufConnectedOn = "_connected_on_utc" @@ -116,14 +93,9 @@ func decodeElementStatus(body []byte) (ElementStatus, error) { "last_rebooted_info": &out.LastRebootedInfo, "last_disconnected_time": &out.LastDisconnected, } { - // A field the server sends as null, or does not send at all, keeps the - // zero value: this response has a dozen fields that are null on an - // appliance without the hardware for them. _ = json.Unmarshal(raw[field], into) } - // _connected_on_utc also ends in _connected's suffix, so the longer - // suffixes are tested first or every timestamp names its own channel. chans := map[string]*Channel{} pick := func(name string) *Channel { c, ok := chans[name] @@ -153,28 +125,21 @@ func decodeElementStatus(body []byte) (ElementStatus, error) { return out, nil } -// Interface is one configured interface. The live address and state are not -// here but in InterfaceStatus: this is what somebody configured, which on a -// down port is a different thing from what the port is doing. type Interface struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - Type string `json:"type"` - UsedFor string `json:"used_for"` - AdminUp bool `json:"admin_up"` - MTU int `json:"mtu"` - MACAddress string `json:"mac_address"` - Parent string `json:"parent"` - Scope string `json:"scope"` - Tags []string `json:"tags"` - // WAN interfaces the port is bound to, which is how a port maps to a - // circuit. + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Type string `json:"type"` + UsedFor string `json:"used_for"` + AdminUp bool `json:"admin_up"` + MTU int `json:"mtu"` + MACAddress string `json:"mac_address"` + Parent string `json:"parent"` + Scope string `json:"scope"` + Tags []string `json:"tags"` SiteWANInterfaceIDs []string `json:"site_wan_interface_ids"` } -// ListInterfaces needs the site as well as the element: interfaces hang off -// the site in the path, and an element with no site has none to list. func (s *Client) ListInterfaces(ctx context.Context, siteID, eid string) ([]Interface, error) { if siteID == "" { return nil, fmt.Errorf("element %s: interfaces need a site id", eid) @@ -192,45 +157,27 @@ func (s *Client) ListInterfaces(ctx context.Context, siteID, eid string) ([]Inte return wrapped.Items, nil } -// InterfaceStatus is the live state of one interface. Its ID is the status -// record's own, which is not the interface's: pair a status with its interface -// by the request that fetched it, or by Name. -// -// The fields an ion 1200-s leaves null on every interface -- cellular_state, -// stp_state, svi_state, poe_state, lacp_state, port_channel_member_state, -// fec_state, effective_vlans, secondary_ipv4_addresses, link_local_address, -// routes_v6 -- are left out rather than modelled from a shape nothing has -// seen filled in. type InterfaceStatus struct { - ID string `json:"id"` - ElementID string `json:"element_id"` - Name string `json:"name"` - OperationalState string `json:"operational_state"` - // ExtendedState says why, and only a service link sets it: "tunnel_up", - // "lowerlayer_down". A port that is simply up leaves it null. - ExtendedState string `json:"extended_state"` - Device string `json:"device"` - MACAddress string `json:"mac_address"` - NegotiatedMTU int `json:"negotiated_mtu"` - IPv4Addresses []string `json:"ipv4_addresses"` - IPv6Addresses []string `json:"ipv6_addresses"` - RemoteV4Addr string `json:"remote_v4_addr"` - RemoteHostName string `json:"remote_host_name"` - // LastStateChange is milliseconds, where the element status's own - // timestamps are not. UTCTime works it out from the magnitude. - LastStateChange int64 `json:"last_state_change"` - // Port is nil on anything that is not a physical port -- a subinterface, - // a service link, a bypass pair. - Port *PortState `json:"port"` - VRF *VRF `json:"vrf"` - Routes []Route `json:"routes"` - ServiceLink *ServiceLinkState `json:"service_link"` - LLDPEnabled bool `json:"lldp_enabled"` -} - -// PortState is the physical link. Speed is Mbps, and is 0 on a port that is -// down -- negotiated_mtu is 0 even on one that is up, so this is where the -// link detail actually lives. + ID string `json:"id"` + ElementID string `json:"element_id"` + Name string `json:"name"` + OperationalState string `json:"operational_state"` + ExtendedState string `json:"extended_state"` + Device string `json:"device"` + MACAddress string `json:"mac_address"` + NegotiatedMTU int `json:"negotiated_mtu"` + IPv4Addresses []string `json:"ipv4_addresses"` + IPv6Addresses []string `json:"ipv6_addresses"` + RemoteV4Addr string `json:"remote_v4_addr"` + RemoteHostName string `json:"remote_host_name"` + LastStateChange int64 `json:"last_state_change"` + Port *PortState `json:"port"` + VRF *VRF `json:"vrf"` + Routes []Route `json:"routes"` + ServiceLink *ServiceLinkState `json:"service_link"` + LLDPEnabled bool `json:"lldp_enabled"` +} + type PortState struct { Connected bool `json:"connected"` Speed int `json:"speed"` @@ -247,8 +194,6 @@ type Route struct { Via string `json:"via"` } -// ServiceLinkState is a Prisma Access tunnel's crypto and rekey schedule. The -// rekey timestamps are milliseconds. type ServiceLinkState struct { IPsecAlgo string `json:"ipsec_algo"` IKEAlgo string `json:"ike_algo"` @@ -259,9 +204,8 @@ type ServiceLinkState struct { IPsecNextRekey int64 `json:"ipsec_next_rekey"` } -// InterfaceStatus returns a slice because a bypass pair answers with one -// status per member, wrapped in the usual items/count envelope, where every -// other interface type answers with a bare object. +// a slice because a bypass pair answers with one status per member, wrapped in +// the usual items/count envelope where every other type sends a bare object func (s *Client) InterfaceStatus(ctx context.Context, siteID, eid, iid string) ([]InterfaceStatus, error) { body, err := s.get(ctx, "interface "+iid+" status", interfaceStatusURL(siteID, eid, iid)) if err != nil { @@ -287,43 +231,28 @@ func decodeInterfaceStatus(iid string, body []byte) ([]InterfaceStatus, error) { return []InterfaceStatus{st}, nil } -// SpokeClusterStatus is HA state: who is in the cluster, and every switchover -// with the reason behind it. The element's own spoke_ha_config says only that -// it belongs to a cluster and at what priority; which member is carrying -// traffic is here. type SpokeClusterStatus struct { Members []ClusterMember `json:"cluster_members"` Events []SwitchOver `json:"switch_over_events"` } -// ClusterMember is one element in the cluster. Status is "active" on the one -// carrying traffic and "unknown" for a member the controller cannot see -- -// which is what a dead peer looks like, not a distinct "standby". +// Status is "active" on the one carrying traffic and "unknown" for a member the +// controller cannot see -- what a dead peer looks like, not a distinct "standby" type ClusterMember struct { ElementID string `json:"element_id"` Status string `json:"status"` } -// SwitchOver is one member becoming active or giving it up, newest first. -// Time is milliseconds. type SwitchOver struct { - ElementID string `json:"element_id"` - Active bool `json:"active"` - Time int64 `json:"switch_over_time"` - EffectivePriority int `json:"effective_priority"` - PeerConnected bool `json:"peer_connected"` - // Adjustments is why EffectivePriority is not the configured priority. - Adjustments []PriorityAdjustment `json:"adjustments"` - // ClusterEvents is the priority's history since this switchover, newest - // first: the same adjustments arriving and clearing without the active - // member changing hands. - ClusterEvents []ClusterEvent `json:"cluster_events"` -} - -// PriorityAdjustment is what took points off the configured priority. Seen on -// real hardware: kind "process" with the process name ("radvd", "adem", 25 -// points), "interface" with the interface name ("3", 150), "force" for the -// slow start after a boot, and "system" while initialising. + ElementID string `json:"element_id"` + Active bool `json:"active"` + Time int64 `json:"switch_over_time"` + EffectivePriority int `json:"effective_priority"` + PeerConnected bool `json:"peer_connected"` + Adjustments []PriorityAdjustment `json:"adjustments"` + ClusterEvents []ClusterEvent `json:"cluster_events"` +} + type PriorityAdjustment struct { Adjustment int `json:"adjustment"` Kind string `json:"kind"` @@ -337,7 +266,6 @@ type ClusterEvent struct { Adjustments []PriorityAdjustment `json:"adjustments"` } -// Active is the member carrying traffic, if the cluster says. func (s SpokeClusterStatus) Active() (ClusterMember, bool) { for _, m := range s.Members { if m.Status == "active" { @@ -359,10 +287,6 @@ func (s *Client) SpokeClusterStatus(ctx context.Context, siteID, cid string) (Sp return st, nil } -// GetJSON is a GET of one SASE path, for a caller whose paths come from -// configuration rather than from this package: rich-data mode's views. The -// host is fixed here, and the path has to be a /sdwan read -- a view is -// user-editable data, and this token can write to production appliances. func (s *Client) GetJSON(ctx context.Context, path string) ([]byte, error) { if !strings.HasPrefix(path, "/sdwan/") { return nil, fmt.Errorf("path %q is not a /sdwan read", path) @@ -378,24 +302,16 @@ func (s *Client) GetJSON(ctx context.Context, path string) ([]byte, error) { return s.get(ctx, strings.TrimPrefix(path, "/"), apiHost+path) } -// get is one rate-limited authenticated GET returning the whole body. The -// callers here keep the raw bytes: the response shapes of the status -// endpoints are modelled from the portal's own field names rather than from a -// published schema, so a snapshot can show what was actually sent. func (s *Client) get(ctx context.Context, what, url string) ([]byte, error) { return s.do(ctx, what, http.MethodGet, url, nil) } -// post is the same path for the query_* endpoints, which are POSTs carrying a -// filter rather than GETs with it in the path. func (s *Client) post(ctx context.Context, what, url string, body []byte) ([]byte, error) { return s.do(ctx, what, http.MethodPost, url, body) } -// the limiter is our guess; Retry-After is the server's own answer const rateRetries = 2 -// a Retry-After of a minute is longer than anyone will wait const rateWaitCap = 5 * time.Second func (s *Client) do(ctx context.Context, what, method, url string, body []byte) ([]byte, error) { @@ -415,8 +331,6 @@ func (s *Client) do(ctx context.Context, what, method, url string, body []byte) } } -// attempt is one request. A non-zero wait means the server asked for one and -// the call is worth repeating; everything else is the answer or the error. func (s *Client) attempt(ctx context.Context, what, method, url string, body []byte) ([]byte, time.Duration, error) { if err := s.limiter.Wait(ctx); err != nil { return nil, 0, fmt.Errorf("%s: rate limit wait: %w", what, err) @@ -446,8 +360,6 @@ func (s *Client) attempt(ctx context.Context, what, method, url string, body []b return nil, 0, fmt.Errorf("%s: response: %w", what, err) } - // The rate limit is the thing a fleet-wide run hits first, and the headers - // are the only warning before a 429. if s.verbose { for k, v := range resp.Header { if strings.HasPrefix(strings.ToLower(k), "x-ratelimit") || strings.EqualFold(k, "retry-after") { @@ -474,7 +386,6 @@ func (s *Client) attempt(ctx context.Context, what, method, url string, body []b return answer, 0, nil } -// seconds only; a bad header means a short wait, not no retry func retryAfter(resp *http.Response) time.Duration { wait := time.Second if v := resp.Header.Get("Retry-After"); v != "" { @@ -493,7 +404,6 @@ func queryBody(filter string, limit, page int) []byte { `"retrieved_fields_mask":false,"retrieved_fields":[]}`, filter, page, limit)) } -// query_params as the portal sends it for the element-id queries func elementFilter(ids []string) string { quoted := make([]string, 0, len(ids)) for _, id := range ids { @@ -502,14 +412,10 @@ func elementFilter(ids []string) string { return `{"element_id":{"in":[` + strings.Join(quoted, ",") + `]}}` } -// 200 is what the portal asks for const ClusterQueryLimit = 200 -// a server that ignores dest_page cannot spin here const clusterQueryPages = 20 -// One element's place in its cluster, from the tenant-wide query. Unlike the -// per-cluster endpoint it names the second node: "backup". type ClusterMemberStatus struct { ID string `json:"id"` SiteID string `json:"site_id"` @@ -517,12 +423,9 @@ type ClusterMemberStatus struct { ElementID string `json:"element_id"` Status string `json:"status"` Priority int `json:"priority"` - // UpdatedAt is milliseconds, like the other cluster timestamps. - UpdatedAt int64 `json:"last_status_update_ts"` + UpdatedAt int64 `json:"last_status_update_ts"` } -// The HA state of the elements named, in one call, which is what the portal -// does. More than ClusterQueryLimit of them means asking again. func (s *Client) SpokeClusterStatuses(ctx context.Context, elementIDs []string) ([]ClusterMemberStatus, error) { if len(elementIDs) == 0 { return nil, fmt.Errorf("spoke cluster status query: no elements to ask about") @@ -537,8 +440,6 @@ func (s *Client) SpokeClusterStatuses(ctx context.Context, elementIDs []string) return out, nil } } - // Either none of these elements is in a cluster or the shape has moved. - // They are indistinguishable here, and only one of them is safe to act on. return nil, fmt.Errorf("spoke cluster status query: no members in %d item(s)", len(out)) } diff --git a/internal/sase/api/status_test.go b/internal/sase/api/status_test.go index a5a0393..481d4de 100644 --- a/internal/sase/api/status_test.go +++ b/internal/sase/api/status_test.go @@ -61,8 +61,6 @@ func TestElementStatusGroupsTheFlatFieldsIntoChannels(t *testing.T) { Name: "config_and_events", Connected: true, On: 17923299413050015, Off: 17923299316100154, From: "203.0.113.9", }}, - // A backlog channel that is down still carries both times: it - // connects, drains and disconnects, so "down" is normal for it. {"logs_backlog", Channel{ Name: "logs_backlog", On: 1792307964995, Off: 1792308036585, From: "198.51.100.24", @@ -81,8 +79,6 @@ func TestElementStatusGroupsTheFlatFieldsIntoChannels(t *testing.T) { } } -// The device facts in the same body, which are the ones worth a hot stat: -// when it last rebooted and why, and which port the controller talks over. func TestElementStatusKeepsTheDeviceFactsBesideTheChannels(t *testing.T) { st, err := decodeElementStatus([]byte(elementStatusBody)) if err != nil { @@ -102,9 +98,6 @@ func TestElementStatusKeepsTheDeviceFactsBesideTheChannels(t *testing.T) { } } -// A bypass pair answers with one status per member in the items envelope, -// where every other interface type answers with a bare object. Decoding only -// the bare form dropped both members. func TestInterfaceStatusTakesEitherTheBareObjectOrTheEnvelope(t *testing.T) { tests := []struct { name string @@ -127,9 +120,6 @@ func TestInterfaceStatusTakesEitherTheBareObjectOrTheEnvelope(t *testing.T) { state: "up", }, { - // Only a service link fills extended_state in, and it is the - // difference between "down" and "down because the port under it - // is". name: "service link", body: `{"id":"97","name":"tunnel","operational_state":"down","extended_state":"lowerlayer_down"}`, want: 1, @@ -152,10 +142,6 @@ func TestInterfaceStatusTakesEitherTheBareObjectOrTheEnvelope(t *testing.T) { } } -// One element-status body carries three scales at once, so these are every -// one of them, each from a record whose real date is known. A wrong unit -// comes out centuries adrift, which is why the dates are asserted rather than -// the arithmetic. func TestUTCTimeWorksTheUnitOutFromTheMagnitude(t *testing.T) { tests := []struct { name string @@ -208,9 +194,6 @@ func TestPortalURLsCarryTheVersionEachResourceAnswersOn(t *testing.T) { } } -// routes answers by path suffix, standing in for the SASE endpoints so a -// snapshot can be taken without a tenant. The longest matching suffix wins, -// since several of these paths end in /status. type routes map[string]func() (int, string) func (r routes) RoundTrip(req *http.Request) (*http.Response, error) { @@ -239,8 +222,6 @@ func snapshotClient(t *testing.T, r routes) *Client { return NewClient(c, c, "tok", false, 100, 100) } -// A status endpoint that refuses must not cost the interfaces: an element -// whose portal half is half-answered is still worth a page. func TestSnapshotKeepsTheHalfThatAnswered(t *testing.T) { c := snapshotClient(t, routes{ "/elements/15/status": func() (int, string) { @@ -249,8 +230,6 @@ func TestSnapshotKeepsTheHalfThatAnswered(t *testing.T) { "/interfaces": func() (int, string) { return http.StatusOK, `{"items":[{"id":"17","name":"1","used_for":"public","admin_up":true}]}` }, - // port is an object, not a string, and the status record's own id is - // not the interface's. Both cost a decode when they were guessed. "/interfaces/17/status": func() (int, string) { return http.StatusOK, `{"id":"99","name":"1","operational_state":"up", "extended_state":null,"negotiated_mtu":0,"device":"eth0", @@ -283,7 +262,6 @@ func TestSnapshotKeepsTheHalfThatAnswered(t *testing.T) { if got := len(st.IPv4Addresses); got != 1 { t.Errorf("%d addresses, want 1", got) } - // The link detail is in port, since negotiated_mtu is 0 even when up. if st.Port == nil || !st.Port.Connected || st.Port.Speed != 1000 { t.Errorf("port = %+v, want the link up at 1000", st.Port) } @@ -293,8 +271,6 @@ func TestSnapshotKeepsTheHalfThatAnswered(t *testing.T) { if len(st.Routes) != 1 || st.Routes[0].Destination != "0.0.0.0/0" { t.Errorf("routes = %+v", st.Routes) } - // The bodies that did arrive are kept, so a field that never decoded can - // be told from one the server left empty. if len(snap.Raw) != 2 { t.Errorf("%d raw bodies, want the 2 that answered: %v", len(snap.Raw), snap.Raw) } @@ -343,8 +319,6 @@ func TestSpokeClusterStatusCarriesWhoIsActiveAndWhy(t *testing.T) { t.Fatalf("unmarshal cluster status: %v", err) } - // "unknown" is what a peer the controller cannot see looks like; there is - // no separate "standby". if len(st.Members) != 2 || st.Members[1].Status != "unknown" { t.Fatalf("members = %+v", st.Members) } @@ -357,7 +331,6 @@ func TestSpokeClusterStatusCarriesWhoIsActiveAndWhy(t *testing.T) { t.Fatalf("got %d switchovers, want 2", len(st.Events)) } - // The slow start after a boot is a forced adjustment on the event itself. boot := st.Events[1] if len(boot.Adjustments) != 1 || boot.Adjustments[0].Kind != "force" || boot.Adjustments[0].Adjustment != 159 { t.Errorf("boot adjustments = %+v", boot.Adjustments) @@ -366,8 +339,6 @@ func TestSpokeClusterStatusCarriesWhoIsActiveAndWhy(t *testing.T) { t.Errorf("switch_over_time reads as %s, want 2026-10-06 — milliseconds", got) } - // A process dying takes points off without changing who is active, and - // that only shows up in the nested history. now := st.Events[0] if len(now.ClusterEvents) != 2 { t.Fatalf("got %d cluster events, want 2", len(now.ClusterEvents)) @@ -381,9 +352,6 @@ func TestSpokeClusterStatusCarriesWhoIsActiveAndWhy(t *testing.T) { } } -// The cluster is only fetched for an element whose record says it is in one, -// and the cluster id comes from spoke_ha_config rather than the record's own -// cluster_id, which a real clustered spoke left null. func TestSnapshotFetchesTheClusterOnlyForAClusteredElement(t *testing.T) { const body = `{"cluster_members":[{"element_id":"15","status":"active"}], "switch_over_events":[{"element_id":"15","active":true, @@ -444,8 +412,6 @@ func TestSnapshotFetchesTheClusterOnlyForAClusteredElement(t *testing.T) { } } -// Interfaces are site-scoped in the path, so an element with no site cannot -// have them fetched. Saying so beats a request to .../sites//elements/15. func TestSnapshotWithNoSiteDoesNotAskForInterfaces(t *testing.T) { asked := false c := snapshotClient(t, routes{ @@ -469,11 +435,9 @@ func TestSnapshotWithNoSiteDoesNotAskForInterfaces(t *testing.T) { } } -// The aggregate, as the portal's own capture of 2026-10-19 shows it: a POST -// filtered by element id, dest_page counting from one, answered with a flat -// row per element whose status is "active" or "backup". Nothing about this was -// guessable -- the first implementation read it as clusters carrying -// cluster_members and would have fallen back for ever. +// the portal's own capture of 2026-10-19: a POST filtered by element id, +// dest_page counting from one, answered with a flat row per element whose +// status is "active" or "backup" func TestSpokeClusterStatusesReadsTheAggregate(t *testing.T) { const captured = `{"count":2,"items":[ {"id":"163241838","site_id":"1632417734","spoke_cluster_id":"1632417740", @@ -492,8 +456,6 @@ func TestSpokeClusterStatusesReadsTheAggregate(t *testing.T) { }{ {name: "the shape the portal answers with", status: http.StatusOK, body: captured, want: 2}, { - // A tenant with no clustered elements and a shape this cannot read - // are the same answer here, and only one is safe to act on. name: "nothing usable in it", status: http.StatusOK, body: `{"count":0,"items":[]}`, @@ -536,7 +498,6 @@ func TestSpokeClusterStatusesReadsTheAggregate(t *testing.T) { if len(got) != tc.want { t.Fatalf("%d rows, want %d", len(got), tc.want) } - // The fields the column and the data pane read. if got[0].Status != "active" || got[0].Priority != 150 { t.Errorf("first row = %+v, want the active member at 150", got[0]) } @@ -556,8 +517,6 @@ func TestSpokeClusterStatusesReadsTheAggregate(t *testing.T) { if !strings.HasSuffix(seen.URL.Path, "/spokeclusters/status/query") { t.Errorf("path = %s, want the tenant-wide query", seen.URL.Path) } - // The body the portal sends: the elements named, and a first page - // of one rather than zero. for _, want := range []string{ `"query_params":{"element_id":{"in":["1632412620","1632412630"]}}`, `"dest_page":1`, @@ -572,9 +531,6 @@ func TestSpokeClusterStatusesReadsTheAggregate(t *testing.T) { } } -// The call asks about exactly the elements it is given: the chunking is the -// caller's, so that each chunk can paint as it lands and the first one can be -// the rows on screen. func TestTheAggregateAsksAboutTheElementsItIsGiven(t *testing.T) { var sent []byte c := snapshotClient(t, routes{ @@ -603,8 +559,6 @@ func TestTheAggregateAsksAboutTheElementsItIsGiven(t *testing.T) { } } -// recorder keeps the request a test wants to assert on, since the body is -// gone by the time the response comes back. type recorder struct { inner http.RoundTripper saw func(*http.Request, []byte) diff --git a/internal/sase/api/websocket.go b/internal/sase/api/websocket.go index b9513c4..1246b0c 100644 --- a/internal/sase/api/websocket.go +++ b/internal/sase/api/websocket.go @@ -12,22 +12,14 @@ import ( "tyrtop.com/iontk/internal/sase/ioncli" ) -// DialPolicy bounds one dial and says how many to try. The toolkit socket -// sometimes just does not come up, and a single long wait cannot tell that from -// a device being slow. type DialPolicy struct { Attempts int - // Timeout bounds each attempt. A dial that is going to work does so - // quickly, so this is short and tried again rather than long and once. - Timeout time.Duration - Backoff time.Duration + Timeout time.Duration + Backoff time.Duration } var DefaultDialPolicy = DialPolicy{Attempts: 3, Timeout: 10 * time.Second, Backoff: time.Second} -// DialAttempt is one try, reported as it begins. Err is what the previous try -// failed with, so a caller can show both which attempt is running and why the -// last one did not take. type DialAttempt struct { ElementID string Attempt int @@ -35,8 +27,6 @@ type DialAttempt struct { Err error } -// SetPagerOff sets the command sent once after login to stop the device paging. -// Empty sends nothing, which is the behaviour this had before. func (s *Client) SetPagerOff(cmd string) { s.dialMu.Lock() s.pager = cmd @@ -49,8 +39,6 @@ func (s *Client) pagerOff() string { return s.pager } -// SetPagerPrompts sets the lines a session answers with a space. Nil keeps the -// built-in list; empty turns the scraper off. func (s *Client) SetPagerPrompts(p []string) { s.dialMu.Lock() s.pagers = p @@ -63,9 +51,6 @@ func (s *Client) pagerPrompts() []string { return s.pagers } -// SetDialReporter watches the dial without disturbing the policy. It is how a -// UI shows a retry as it happens; dialling is otherwise silent until it either -// works or runs out. func (s *Client) SetDialReporter(f func(DialAttempt)) { s.dialMu.Lock() s.onDial = f @@ -92,8 +77,6 @@ func (p DialPolicy) withDefaults() DialPolicy { return d } -// SetDialPolicy overrides the dial retry for this client. Zero fields keep the -// default. func (s *Client) SetDialPolicy(p DialPolicy) { s.dialMu.Lock() s.dial = p.withDefaults() @@ -158,8 +141,6 @@ func (s *Client) dialOnce(ctx context.Context, url string, timeout time.Duration return conn, nil } -// dialError carries the handshake status, so a rejected credential is told from -// a socket that would not come up without parsing the message. type dialError struct { err error status int @@ -175,9 +156,6 @@ func statusOf(resp *http.Response) int { return resp.StatusCode } -// retryableDial reports whether dialling again could plausibly work. A refused -// credential and a missing element will not change on their own; everything -// else is the socket not coming up, which is what the retry is for. func retryableDial(err error) bool { var de dialError if !errors.As(err, &de) { @@ -195,10 +173,11 @@ func (s *Client) ToolkitSession(ctx context.Context, eid, prompt, user, pass str if err != nil { return nil, fmt.Errorf("dial: %w", err) } - // this line lets you do more than ~150 devices at once. If you don't "gracefully close your websocket connection" - // the ION hijacks it, and it seems like PA keeps track of the # of sessions you have open. - // Sacrificing a frame lets you utilize more socket connections overall. - // if you just use CloseNow() you will start getting limited at ~500 connections opened. + // this line lets you do more than ~150 devices at once. If you don't + // "gracefully close your websocket connection" the ION hijacks it, and it + // seems like PA keeps track of the # of sessions you have open. Sacrificing + // a frame lets you utilize more socket connections overall; with CloseNow() + // you start getting limited at ~500 connections opened. defer conn.Close(websocket.StatusNormalClosure, "") return ioncli.Run(ctx, conn, ioncli.RunOptions{ diff --git a/internal/sase/api/websocket_test.go b/internal/sase/api/websocket_test.go index e20ac72..45de811 100644 --- a/internal/sase/api/websocket_test.go +++ b/internal/sase/api/websocket_test.go @@ -14,8 +14,6 @@ import ( "github.com/coder/websocket" ) -// wsServer counts handshake attempts and answers with status until accept is -// reached, standing in for a toolkit socket that does not come up at first. func wsServer(t *testing.T, status int, acceptOn int32) (*httptest.Server, *atomic.Int32) { t.Helper() var tries atomic.Int32 @@ -60,7 +58,6 @@ func TestDialStopsOnceItSucceeds(t *testing.T) { srv, tries := wsServer(t, http.StatusBadGateway, 2) c := testClient(t, DialPolicy{Attempts: 5, Timeout: time.Second, Backoff: time.Millisecond}) - // The second handshake is accepted, so the remaining attempts are not spent. _, _ = c.dialURL(context.Background(), strings.Replace(srv.URL, "http://", "ws://", 1), "e1") if got := tries.Load(); got != 2 { t.Errorf("%d handshake attempts, want it to stop at 2", got) @@ -74,7 +71,6 @@ func TestDialDoesNotRetryARejectedCredential(t *testing.T) { }{ {"unauthorized", http.StatusUnauthorized}, {"forbidden", http.StatusForbidden}, - // A missing element will not appear by trying again either. {"no such element", http.StatusNotFound}, } for _, tt := range tests { @@ -110,7 +106,6 @@ func TestDialGivesUpWithTheContext(t *testing.T) { } func TestDialPolicyDefaults(t *testing.T) { - // Zero fields must not produce a zero timeout or a single silent attempt. got := DialPolicy{}.withDefaults() if got != DefaultDialPolicy { t.Errorf("withDefaults() = %+v, want %+v", got, DefaultDialPolicy) @@ -165,8 +160,6 @@ func TestDialReporterSeesEachAttemptAsItStarts(t *testing.T) { if len(seen) != 3 { t.Fatalf("%d attempts reported, want 3", len(seen)) } - // Reported as each begins, so a UI can show the retry while it runs rather - // than only once dialling has given up. for i, a := range seen { if a.Attempt != i+1 || a.Of != 3 { t.Errorf("attempt %d reported as %d/%d, want %d/3", i, a.Attempt, a.Of, i+1) @@ -178,7 +171,6 @@ func TestDialReporterSeesEachAttemptAsItStarts(t *testing.T) { if seen[0].Err != nil { t.Errorf("first attempt carried err %v, want nil", seen[0].Err) } - // Each later attempt carries why the one before it did not take. for i, a := range seen[1:] { if a.Err == nil { t.Errorf("attempt %d carried no previous error", i+2) diff --git a/internal/sase/ioncli/run_test.go b/internal/sase/ioncli/run_test.go index 319b707..7c1b738 100644 --- a/internal/sase/ioncli/run_test.go +++ b/internal/sase/ioncli/run_test.go @@ -12,9 +12,6 @@ import ( "github.com/coder/websocket" ) -// scriptServer plays a device: it writes the first chunk unprompted, then one -// more each time the client says something, recording everything it is sent. -// Run takes a concrete *websocket.Conn, which is why it had no tests. type scriptServer struct { srv *httptest.Server @@ -63,8 +60,6 @@ func dialTestConn(t *testing.T, chunks []string) (*scriptServer, *websocket.Conn return } } - // Anything said after the script is still recorded, so a test can see - // a command it did not expect. for { _, data, err := c.Read(ctx) if err != nil { @@ -127,11 +122,9 @@ func TestRunSendsThePagerOffCommandBeforeAnythingElse(t *testing.T) { if i < 0 { t.Fatalf("the pager-off command was not sent: %q", sent) } - // After the first command it would be too late: that one pages. if j >= 0 && i > j { t.Errorf("pager-off was sent after the first command: %q", sent) } - // It is setup, not a result. if len(outs) != 1 || outs[0].Command != "uptime" { t.Errorf("outputs = %+v, want only the uptime result", outs) } @@ -154,8 +147,6 @@ func TestRunSendsNothingWhenNoPagerCommandIsSet(t *testing.T) { } func TestATimedOutReadNamesWhatTheDeviceSent(t *testing.T) { - // Otherwise a paging device and a prompt we don't match give the same - // error, and telling them apart needs a live session. srv, conn := dialTestConn(t, []string{ "login: ", "Password: ", "ion#", "ion# help\r\nlots of text\r\n--More--", }) @@ -176,8 +167,6 @@ func TestATimedOutReadNamesWhatTheDeviceSent(t *testing.T) { } func TestATimedOutReadKeepsThePasswordOutOfTheError(t *testing.T) { - // Some firmware echoes the login exchange back, and batch errors get - // written to a report file. srv, conn := dialTestConn(t, []string{ "login: ", "Password: ", "sw0rdf1sh\r\nnot a prompt", }) @@ -230,9 +219,6 @@ func TestTailOfAReadIsBoundedAndReadable(t *testing.T) { } func TestRunAnswersThePagerAndStitchesTheOutput(t *testing.T) { - // What the scraper is for. The ION pages `help` and waits; if nothing - // answers, the read waits out the session timeout. Firmware that ignores - // `set paging off` ends up here. srv, conn := dialTestConn(t, []string{ "login: ", "Password: ", "ion#", "ion# help\r\nfirst page\r\n--More--(4%)[Press space to continue, 'q' to quit.]", @@ -255,20 +241,15 @@ func TestRunAnswersThePagerAndStitchesTheOutput(t *testing.T) { t.Errorf("output = %q, want it to contain %q", got, want) } } - // The pager prompt is the device talking to the user, not output. if strings.Contains(strings.ToLower(got), "press space") { t.Errorf("output = %q, want the pager line stripped", got) } - // A bare space: a carriage return would be read as an empty command. if sent := srv.sent(); !strings.Contains(sent, "help\r ") { t.Errorf("sent = %q, want a bare space after the command", sent) } } func TestRunAnswersEachPageExactlyOnce(t *testing.T) { - // The device doesn't erase its prompt right away. Match the whole buffer, - // or forget which one was answered, and the extra space ends up in the - // next command. srv, conn := dialTestConn(t, []string{ "login: ", "Password: ", "ion#", "ion# help\r\npage one\r\n--More--(4%)[Press space to continue, 'q' to quit.]", @@ -294,8 +275,6 @@ func TestRunAnswersEachPageExactlyOnce(t *testing.T) { } func TestRunStopsWhenThePagerIsNotRecognised(t *testing.T) { - // Firmware with a different prompt still hangs. The error has to name what - // it stopped on, or the next one needs hardware to diagnose again. srv, conn := dialTestConn(t, []string{ "login: ", "Password: ", "ion#", "ion# help\r\nlots of text\r\n<<>>", }) diff --git a/internal/sase/ioncli/session.go b/internal/sase/ioncli/session.go index 6bdf761..33939fb 100644 --- a/internal/sase/ioncli/session.go +++ b/internal/sase/ioncli/session.go @@ -11,16 +11,11 @@ import ( var ansi = regexp.MustCompile(`\x1b\[[0-9;?]*[a-zA-Z]`) -// Conn is the half of a websocket a CLI session uses. Declared here rather than -// taking *websocket.Conn so the playback device in internal/fixture runs the -// real login and pager code. type Conn interface { Read(ctx context.Context) (websocket.MessageType, []byte, error) Write(ctx context.Context, typ websocket.MessageType, p []byte) error } -// DefaultPagers are lines a device stops on to wait for a keypress. The text -// is firmware-specific, so these are only the ones we've seen. var DefaultPagers = []string{ "press space to continue", "press any key to continue", @@ -28,27 +23,23 @@ var DefaultPagers = []string{ "-- more --", } -// A pager prompt is whatever comes after the last line break, since the device -// stops there without a newline. The offset matters because the next page -// prints the same prompt text. func lastLineAt(s string) (string, int) { s = strings.TrimRight(s, " \t\b") i := strings.LastIndexAny(s, "\r\n") + 1 return strings.TrimSpace(s[i:]), i } -// Takes the earliest match in the line, not the first pattern in the list. -// ION 6.3.4 prints "--More--(4%)[Press space to continue, 'q' to quit.]", -// which matches two of them, and cutting the later one left "--More--(4%)[" in -// every paged command's output. +// the earliest match in the line, not the first pattern in the list: ION 6.3.4 +// prints "--More--(4%)[Press space to continue, 'q' to quit.]", which matches +// two of them, and cutting the later one left "--More--(4%)[" in the output. // -// Whatever follows the prompt is the device's own progress and hint, so it goes -// too. Backspaces are the exception: the device erased the prompt in place, and -// what comes after them is the next page. +// What follows the prompt is the device's own progress, so it goes too. +// Backspaces are the exception: the prompt was erased in place and what comes +// after them is the next page. func stripPager(line string, pagers []string) (string, bool) { low := strings.ToLower(line) if len(low) != len(line) { - return line, false // not ASCII, so the offsets would not line up + return line, false } start, end := -1, -1 for _, p := range pagers { @@ -98,8 +89,6 @@ func clean(out, prompt, cmd string, pagers []string) string { line := strings.TrimRight(lines[i], " \t") if rest, hit := stripPager(line, pagers); hit { if rest == "" { - // The device erases its own prompt, and by now those carriage - // returns have turned into blank lines. for i+1 < len(lines) && strings.TrimSpace(lines[i+1]) == "" { i++ } @@ -130,14 +119,8 @@ func readUntil(ctx context.Context, conn Conn, pattern string) (string, error) { } } -// A paging device waits for a keypress and never sends the prompt. If nothing -// answers it, any command with more than a screen of output uses up the whole -// session timeout. func readUntilPrompt(ctx context.Context, conn Conn, prompt string, pagers []string) (string, error) { var buf strings.Builder - // The device doesn't erase its prompt right away, and a second space would - // end up in whatever runs next. Tracked by offset, not by text, because - // consecutive pages print the same line. answeredAt := -1 for { _, data, err := conn.Read(ctx) @@ -151,8 +134,6 @@ func readUntilPrompt(ctx context.Context, conn Conn, prompt string, pagers []str return buf.String(), nil } - // The tail only, never the whole buffer. Once a pager line has gone - // past, a Contains match over the buffer is true from then on. last, at := lastLineAt(seen) if at > answeredAt && isPager(last, pagers) { // A plain space, no carriage return. The device wants a keypress, @@ -165,19 +146,14 @@ func readUntilPrompt(ctx context.Context, conn Conn, prompt string, pagers []str } } -// What the device had sent when a read gave up. Without it, a paging device -// and a prompt we don't match both look like a device that went quiet. func tail(raw, redact string) string { s := strings.TrimRight(strip(raw), " \b\r\n") - // Some firmware echoes the password back, and batch errors get written to - // a report file. if redact != "" { s = strings.ReplaceAll(s, redact, "***") } if s == "" { return "nothing" } - // A paged read can be a screen or more of output. const max = 120 if r := []rune(s); len(r) > max { s = "\u2026" + string(r[len(r)-max:]) @@ -185,21 +161,13 @@ func tail(raw, redact string) string { return fmt.Sprintf("%q", s) } -// RunOptions is one session's worth of settings. A struct rather than more -// positional strings: there were already six and they were all strings. type RunOptions struct { Prompt string User string Pass string Commands []string - // PagerOff is sent once after login to stop the device paging its output. - // Empty sends nothing. The syntax differs by firmware and is unproven, so - // it is a setting rather than a constant. PagerOff string - // Pagers get answered with a space. Nil means DefaultPagers, empty turns - // it off. Still used when PagerOff is set, since some firmware ignores - // that. - Pagers []string + Pagers []string } func (o RunOptions) pagers() []string { @@ -234,9 +202,6 @@ func Run(ctx context.Context, conn Conn, o RunOptions) ([]CommandOutput, error) if err := conn.Write(ctx, websocket.MessageText, []byte(o.PagerOff+"\r")); err != nil { return nil, fmt.Errorf("write %q: %w", o.PagerOff, err) } - // A firmware that does not know the command answers with an error and - // its prompt, which is indistinguishable from success here. Whether it - // worked shows up in the commands that follow. if raw, err := readUntilPrompt(ctx, conn, prompt, o.pagers()); err != nil { return nil, fmt.Errorf("read after %q: %w; last saw %s", o.PagerOff, err, tail(raw, o.Pass)) } diff --git a/internal/sase/options.go b/internal/sase/options.go index 8c74847..62229e5 100644 --- a/internal/sase/options.go +++ b/internal/sase/options.go @@ -18,17 +18,12 @@ type Options struct { func (o Options) Validate() error { if len(o.Commands) > 0 { if o.Username == "" || o.Password == "" { - // The login can come from the tenant's keyring entry now, so the - // environment isn't the only thing worth suggesting. return fmt.Errorf("--cmd needs a device login: set ION_USER and ION_PASS, or save one for the tenant in iontk") } } return o.validateRest() } -// ValidateFor is Validate for a run whose targets carry their own logins, -// which is what lets one run span tenants. The element is named, because with -// several tenants in play "no device login" does not say which one is missing. func (o Options) ValidateFor(targets []Target) error { if len(o.Commands) > 0 { for _, t := range targets { diff --git a/internal/sase/options_test.go b/internal/sase/options_test.go index 21f8048..a928931 100644 --- a/internal/sase/options_test.go +++ b/internal/sase/options_test.go @@ -45,8 +45,6 @@ func TestOptionsValidate(t *testing.T) { } func TestValidateForAcceptsATargetsOwnLogin(t *testing.T) { - // A cross-tenant run has no single login in the Options: each target - // carries the one its own tenant saved. base := Options{ Commands: []string{"dump device status"}, Concurrency: 10, @@ -93,8 +91,6 @@ func TestValidateForAcceptsATargetsOwnLogin(t *testing.T) { if err == nil { t.Fatalf("ValidateFor() = nil, want an error naming %q", tc.wantErr) } - // With several tenants in play, "no device login" does not say - // which element is the problem. if !strings.Contains(err.Error(), tc.wantErr) { t.Errorf("ValidateFor() = %v, want it to name %q", err, tc.wantErr) } diff --git a/internal/sase/result.go b/internal/sase/result.go index a89987d..76b38a3 100644 --- a/internal/sase/result.go +++ b/internal/sase/result.go @@ -30,9 +30,6 @@ func runElement(ctx context.Context, client Client, o Options, target Target) (R prompt := el.Name + "#" - // Re-running a session that connected would repeat commands that may - // already have taken effect. The retry that matters is the dial, which the - // client does for itself. outs, err := attemptSession(ctx, client, o, target, eid, prompt) if err != nil { return Result{}, fmt.Errorf("element %s: run: %w", eid, err) diff --git a/internal/sase/result_test.go b/internal/sase/result_test.go index 9f03b8d..458290e 100644 --- a/internal/sase/result_test.go +++ b/internal/sase/result_test.go @@ -30,7 +30,6 @@ func TestRunElementSuccess(t *testing.T) { t.Fatal(err) } - // The contract between result.go:32 and ioncli's prompt matching. if gotPrompt != "S5032-RTR-01#" { t.Errorf("prompt = %q, want %q", gotPrompt, "S5032-RTR-01#") } @@ -48,11 +47,9 @@ func TestRunElementLookupError(t *testing.T) { lookup: func(context.Context, string) (api.Element, error) { return api.Element{}, want }, - // session deliberately nil: it must not be reached } _, err := runElement(context.Background(), f, testOpts(), Target{ElementID: "123"}) - // result.go:29 returns the error unwrapped, unlike the session path below. if !errors.Is(err, want) { t.Errorf("err = %v, want %v", err, want) } @@ -75,8 +72,6 @@ func TestRunElementDoesNotRerunASessionThatConnected(t *testing.T) { if _, err := runElement(context.Background(), f, o, Target{ElementID: "123"}); err == nil { t.Fatal("runElement() error = nil, want the failure reported") } - // Commands may already have taken effect on the device, so a connected - // session is never replayed; the dial is what retries. if calls != 1 { t.Errorf("session calls = %d, want 1", calls) } diff --git a/internal/sase/run.go b/internal/sase/run.go index 233023e..d173649 100644 --- a/internal/sase/run.go +++ b/internal/sase/run.go @@ -15,19 +15,13 @@ type Event struct { Elapsed time.Duration } -// Target is one element with everything needed to reach it. The client and the -// device login both travel per element, which is what lets a single run span -// tenants: each tenant has its own token and its own ION credentials. type Target struct { ElementID string Client Client - // User and Pass override the Options credentials for this element. Empty - // means fall back to the Options, which is the single-tenant case. - User string - Pass string + User string + Pass string } -// creds returns the login to use for a target, preferring its own. func (t Target) creds(o Options) (string, string) { user, pass := t.User, t.Pass if user == "" { @@ -41,14 +35,11 @@ func (t Target) creds(o Options) (string, string) { type credential interface{ RefreshStopped() bool } -// A spent token fails every remaining element identically, each for a full -// element timeout, burying the one cause in a report full of 401s. func credentialDead(c Client) bool { cd, ok := c.(credential) return ok && cd.RefreshStopped() } -// Targets pairs every element with the same client, for a run inside one tenant. func Targets(client Client, elements []string) []Target { out := make([]Target, 0, len(elements)) for _, eid := range elements { diff --git a/internal/sase/run_test.go b/internal/sase/run_test.go index 3972393..1520920 100644 --- a/internal/sase/run_test.go +++ b/internal/sase/run_test.go @@ -40,7 +40,6 @@ func TestRunPreservesOrderAndRecordsErrors(t *testing.T) { if len(results) != len(elements) { t.Fatalf("len(results) = %d, want %d", len(results), len(elements)) } - // results[i] must correspond to elements[i] regardless of completion order. for i, r := range results { if r.ElementID != elements[i] { t.Fatalf("results[%d].ElementID = %q, want %q", i, r.ElementID, elements[i]) @@ -71,7 +70,7 @@ func TestRunRespectsConcurrency(t *testing.T) { break } } - time.Sleep(2 * time.Millisecond) // hold the slot so workers overlap + time.Sleep(2 * time.Millisecond) return api.Element{Name: eid}, nil }, session: func(ctx context.Context, eid, prompt, user, pass string, cmds []string) ([]ioncli.CommandOutput, error) { @@ -120,7 +119,6 @@ func TestTargetCredsPreferTheirOwn(t *testing.T) { } } -// credRecorder notes which login each element was dialled with. type credRecorder struct { mu sync.Mutex seen map[string][2]string @@ -141,8 +139,6 @@ func (c *credRecorder) ToolkitSession(ctx context.Context, eid, prompt, user, pa } func TestRunStreamDialsEachElementWithItsOwnLogin(t *testing.T) { - // A run spanning tenants must not send one tenant's device login to - // another's routers. rec := &credRecorder{} o := testOpts() o.Commands = []string{"dump device status"} diff --git a/internal/saseauth/browser.go b/internal/saseauth/browser.go index 618fcfe..1ebe413 100644 --- a/internal/saseauth/browser.go +++ b/internal/saseauth/browser.go @@ -14,28 +14,20 @@ import ( "github.com/chromedp/chromedp" ) -// ErrNoSession reports that a silent attempt found no usable browser session, so -// the caller has to show a window. var ErrNoSession = errors.New("no existing browser session") type Options struct { - // Chrome overrides the browser binary; empty lets chromedp search. - Chrome string - // DataDir is a browser profile to keep between runs. Holding the identity - // provider's session cookie there is what lets later logins stay silent. + Chrome string DataDir string - // Timeout bounds an interactive sign-in, including however long the human takes. Timeout time.Duration - // NoSandbox starts the browser with its sandbox off. Hardened Linux images - // forbid the unprivileged user namespaces Chrome needs and it cannot start - // there at all; anything that can sandbox keeps the sandbox. + // hardened Linux images forbid the user namespaces Chrome needs and it cannot + // start there at all; anything that can sandbox keeps the sandbox NoSandbox bool AuthorizeURL string TokenURL string Client *http.Client - // Shared across tenants deliberately; see Seed. Seed *Seed } @@ -67,9 +59,6 @@ func (o Options) timeout() time.Duration { return 5 * time.Minute } -// Login obtains a token for tsg, opening a browser window only when it has to. -// When a profile from an earlier run still holds a session, a headless attempt -// with prompt=none gets a token without the user seeing anything. func Login(ctx context.Context, tsg string, opt Options) (*Token, error) { if opt.DataDir != "" { if fi, err := os.Stat(opt.DataDir); err == nil && fi.IsDir() { @@ -85,8 +74,6 @@ func Login(ctx context.Context, tsg string, opt Options) (*Token, error) { return login(ctx, tsg, opt, false) } -// LoginSilent attempts a token without ever showing a window, and reports -// ErrNoSession when that is not possible. func LoginSilent(ctx context.Context, tsg string, opt Options) (*Token, error) { if opt.DataDir == "" { return nil, ErrNoSession @@ -117,13 +104,8 @@ func loginToken(ctx context.Context, tsg string, opt Options, silent bool) (*Tok return ExchangeCode(ctx, opt.httpClient(), opt.token(), code, p.verifier) } -// cleanUpGrace is how long a browser gets to shut down before it is abandoned. var cleanUpGrace = 5 * time.Second -// stopWaiting runs one of chromedp's cancel functions and gives up on it after -// cleanUpGrace. Each of them ends by waiting for the browser process to be -// reaped, on a channel nothing closes when the browser never started, so a -// browser that died on launch would otherwise hang the sign-in for good. func stopWaiting(cancel func()) { done := make(chan struct{}) go func() { cancel(); close(done) }() @@ -133,13 +115,10 @@ func stopWaiting(cancel func()) { } } -// profileLocks serialises sign-ins sharing a browser profile, one channel per -// data dir. Chrome locks a user-data-dir: a second launch hands its URL to the -// running instance and exits, leaving chromedp waiting until it times out. +// one channel per data dir: Chrome locks a user-data-dir, so a second launch +// hands its URL to the running instance and exits, leaving chromedp waiting var profileLocks sync.Map -// lockProfile waits outside the attempt's deadline, so a queued tenant still -// gets a full budget. func lockProfile(ctx context.Context, dir string) (func(), error) { if dir == "" { return func() {}, nil @@ -154,10 +133,6 @@ func lockProfile(ctx context.Context, dir string) (func(), error) { } } -// codeFromBrowser drives the browser through the authorization page and watches -// the network for the redirect back to RedirectURI. That redirect is never -// fetched for its content: it only carries the code in its query string, so -// watching requests is enough and no local listener is needed. func codeFromBrowser(ctx context.Context, authorizeURL, state string, opt Options, silent bool) (string, error) { unlock, err := lockProfile(ctx, opt.DataDir) if err != nil { @@ -190,7 +165,6 @@ func codeFromBrowser(ctx context.Context, authorizeURL, state string, opt Option defer stopWaiting(cancelAlloc) browserCtx, cancelBrowser := chromedp.NewContext(allocCtx) defer stopWaiting(cancelBrowser) - // Close the profile cleanly so a following attempt can reuse the data dir. defer stopWaiting(func() { shutdown, stop := context.WithTimeout(context.WithoutCancel(browserCtx), 5*time.Second) defer stop() @@ -199,11 +173,6 @@ func codeFromBrowser(ctx context.Context, authorizeURL, state string, opt Option events := chromedp.Events(browserCtx, network.RequestWillBeSent) - // The navigation runs alongside the watch rather than before it. Its final - // hop is to RedirectURI, a page this tool never wants to load, so it often - // ends in a navigation error even though the redirect already carried the - // code past the watcher. Events queues from the moment it returns, so - // nothing is lost by not awaiting the navigation first. navErr := make(chan error, 1) go func() { navErr <- chromedp.Do(browserCtx, chromedp.Navigate(authorizeURL)) }() @@ -230,10 +199,6 @@ func codeFromBrowser(ctx context.Context, authorizeURL, state string, opt Option return "", fmt.Errorf("browser closed before sign-in completed") } -// ManualURL returns an authorization URL for the paste-back flow, alongside the -// verifier and state needed to finish it. It is the fallback for machines with -// no usable browser: the user signs in elsewhere, then pastes the address bar -// contents into CompleteManual. func ManualURL(tsg string, opt Options) (authURL string, verifier, state string, err error) { p, err := newPKCE() if err != nil { diff --git a/internal/saseauth/browser_test.go b/internal/saseauth/browser_test.go index ab1eae1..8f015fd 100644 --- a/internal/saseauth/browser_test.go +++ b/internal/saseauth/browser_test.go @@ -15,15 +15,10 @@ import ( "time" ) -// findChrome locates a browser for the integration tests, which are skipped -// when the machine has none. The tests run it without its sandbox: they drive -// a throwaway profile against a fake server, and CI images forbid the user -// namespaces Chrome would need. -// -// Packaged builds come first and chromium-browser last. On Ubuntu 24.04 that -// name is a snap shim, and a snap Chromium on a CI runner is how the v0.5.0 -// release died: chromedp waited out its whole budget on a browser that never -// came up, and the failure said only "context deadline exceeded". +// packaged builds first, chromium-browser last: on Ubuntu 24.04 that name is a +// snap shim, and a snap Chromium on a CI runner is how the v0.5.0 release died +// -- chromedp waited out its whole budget and said only "context deadline +// exceeded". CI images also forbid the user namespaces its sandbox would need. func findChrome(t *testing.T) string { t.Helper() if testing.Short() { @@ -44,10 +39,6 @@ func findChrome(t *testing.T) string { return "" } -// startable is the cheapest question worth asking of a browser binary: can it -// print its own version. A wrapper that cannot is one chromedp would sit on -// until its deadline, and a test that skips with a reason beats a build that -// fails 45 seconds later without one. func startable(path string) error { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() @@ -59,9 +50,6 @@ func startable(path string) error { return nil } -// needsDisplay skips a test that opens a real browser window. Only the first -// sign-in of a Discover opens one; chromedp refuses to without a display, and -// CI gives it one with xvfb. func needsDisplay(t *testing.T) { t.Helper() if os.Getenv("DISPLAY") == "" && os.Getenv("WAYLAND_DISPLAY") == "" { @@ -69,9 +57,6 @@ func needsDisplay(t *testing.T) { } } -// fakeAuthServer stands in for the authorization server. Its authorize endpoint -// redirects to RedirectURI, the real address, which the browser cannot load. -// That is the point: the code has to be captured from the redirect itself. func fakeAuthServer(t *testing.T, redirect func(state string) string) (*httptest.Server, *url.Values) { t.Helper() var form url.Values @@ -176,11 +161,6 @@ func TestLoginSurfacesAuthorizationServerError(t *testing.T) { } } -// TestSignInSurvivesRestart is the property that matters in use: sign in once, -// quit, come back, and get a working token with no browser. The second Source -// stands in for a fresh process, and its authorization server has no authorize -// endpoint at all, so any attempt to open a browser fails the test rather than -// quietly succeeding. func TestSignInSurvivesRestart(t *testing.T) { chrome := findChrome(t) @@ -212,7 +192,6 @@ func TestSignInSurvivesRestart(t *testing.T) { srv := httptest.NewServer(mux) defer srv.Close() - // Stand-in for the keyring: whatever the first run saves, the second reads. var saved string profile := t.TempDir() @@ -236,8 +215,6 @@ func TestSignInSurvivesRestart(t *testing.T) { t.Fatalf("persisted refresh token = %q, want %q", saved, "stored-rt") } - // A fresh process: same stored token, but no authorize endpoint and no - // browser binary, so only the refresh grant can possibly work. noBrowser := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/token" { t.Errorf("the restarted session reached %s, want only the token endpoint", r.URL.Path) @@ -272,10 +249,6 @@ func TestSignInSurvivesRestart(t *testing.T) { } } -// TestDiscoverThenSwitchTenantsSilently is the whole point of signing in with a -// browser: one window, then every tenant scoped without another. The second and -// third tokens are obtained headlessly with prompt=none, which only works -// because the first sign-in left a session in the browser profile. func TestDiscoverThenSwitchTenantsSilently(t *testing.T) { chrome := findChrome(t) needsDisplay(t) @@ -324,7 +297,6 @@ func TestDiscoverThenSwitchTenantsSilently(t *testing.T) { t.Fatalf("Discover() found %d tenants, want 4", len(d.Tenants)) } - // Scoping to two different tenants, each without a window. for _, tsg := range []string{"1984021017", "1538107912"} { src := NewSource(tsg, "", opt, nil) tok, _, err := src.Token(ctx) @@ -341,7 +313,6 @@ func TestDiscoverThenSwitchTenantsSilently(t *testing.T) { if len(prompts) != 3 { t.Fatalf("authorize was called %d times, want 3 (discovery plus two tenants)", len(prompts)) } - // Discovery is interactive; both tenant scopings must be silent. if prompts[0] != "" { t.Errorf("discovery used prompt=%q, want an interactive sign-in", prompts[0]) } @@ -362,9 +333,6 @@ func TestDiscoverThenSwitchTenantsSilently(t *testing.T) { } func TestCleanUpDoesNotWaitForABrowserThatWillNeverExit(t *testing.T) { - // chromedp's cancel functions end by waiting for the browser process on a - // channel that stays open when the browser never started. Without a bound, - // a failed launch wedges the sign-in until the caller is killed. defer func(d time.Duration) { cleanUpGrace = d }(cleanUpGrace) cleanUpGrace = 50 * time.Millisecond diff --git a/internal/saseauth/identity.go b/internal/saseauth/identity.go index 56e9445..3452d19 100644 --- a/internal/saseauth/identity.go +++ b/internal/saseauth/identity.go @@ -7,23 +7,15 @@ import ( "time" ) -// Identity is who a sign-in turned out to be, read from the tokens for display -// and for confirming the tenant scope. type Identity struct { - Name string - Email string - Subject string - Scope string - TSG string - // SessionExpires is when the refresh token stops working, which is how long - // this machine can mint tokens before a browser is needed again. It is read - // from the token rather than assumed, and is zero when the token does not - // say. + Name string + Email string + Subject string + Scope string + TSG string SessionExpires time.Time } -// SessionLeft is how long until a browser is needed again, and whether the -// token said at all. func (i Identity) SessionLeft() (time.Duration, bool) { if i.SessionExpires.IsZero() { return 0, false @@ -45,10 +37,6 @@ func (i Identity) Display() string { return "unknown" } -// ScopedTo reports whether the token really carries the tenant that was asked -// for. The authorization server can hand back a narrower scope than requested, -// and a token scoped to the wrong tenant fails later in a way that is hard to -// read, so it is worth checking at sign-in. func (i Identity) ScopedTo(tsg string) bool { if tsg == "" { return true @@ -67,8 +55,6 @@ func (i Identity) ScopedTo(tsg string) bool { return false } -// SessionExpiry reads when a stored refresh token stops working, so a session's -// remaining life can be shown before any token has been minted this run. func SessionExpiry(refreshToken string) (time.Time, bool) { c := jwtClaims(refreshToken) if c == nil { @@ -78,10 +64,8 @@ func SessionExpiry(refreshToken string) (time.Time, bool) { return exp, !exp.IsZero() } -// jwtClaims reads a JWT payload without verifying its signature. These tokens -// arrive over TLS from the issuer and are only used to label the session and to -// check the tenant scope, never to make an access decision, so there is nothing -// here for a signature to protect. +// unverified on purpose: these label the session and check the tenant scope, +// never an access decision, so there is nothing here for a signature to protect func jwtClaims(token string) map[string]any { parts := strings.Split(token, ".") if len(parts) != 3 { @@ -105,7 +89,6 @@ func claimString(claims map[string]any, key string) string { return "" } -// claimTime reads a numeric date claim, which json decodes as a float64. func claimTime(claims map[string]any, key string) time.Time { switch v := claims[key].(type) { case float64: diff --git a/internal/saseauth/identity_test.go b/internal/saseauth/identity_test.go index 2f0cc7e..4810b68 100644 --- a/internal/saseauth/identity_test.go +++ b/internal/saseauth/identity_test.go @@ -7,8 +7,6 @@ import ( "time" ) -// jwt builds an unsigned token whose payload is claims. Only the payload is -// read, so the header and signature are placeholders. func jwt(t *testing.T, claims map[string]any) string { t.Helper() body, err := json.Marshal(claims) @@ -113,7 +111,6 @@ func TestIdentityFromMalformedTokens(t *testing.T) { {IDToken: "a.!!!notbase64!!!.c"}, {IDToken: "eyJhbGciOiJSUzI1NiJ9." + base64.RawURLEncoding.EncodeToString([]byte("{not json")) + ".sig"}, } { - // The point is that reading a junk token yields nothing rather than panicking. id := identityFrom(tok) if id.Name != "" || id.Email != "" { t.Errorf("identityFrom(%v) = %+v, want empty", tok, id) diff --git a/internal/saseauth/oauth.go b/internal/saseauth/oauth.go index 1d2800a..d1529e2 100644 --- a/internal/saseauth/oauth.go +++ b/internal/saseauth/oauth.go @@ -31,8 +31,6 @@ type Token struct { ExpiresIn time.Duration } -// pkce carries one authorization attempt's secrets. The verifier never leaves -// the process; only its SHA-256 challenge goes to the authorization server. type pkce struct { verifier string challenge string @@ -71,9 +69,6 @@ func scopeFor(tsg string) string { return "openid profile email tsg_id:" + tsg } -// authorizeURL builds the consent URL. silent adds prompt=none, which makes the -// authorization server answer from an existing session or fail outright rather -// than render a login page. func (p pkce) authorizeURL(base, tsg string, silent bool) string { q := url.Values{ "client_id": {ClientID}, @@ -90,9 +85,6 @@ func (p pkce) authorizeURL(base, tsg string, silent bool) string { return base + "?" + q.Encode() } -// CodeFromCallback extracts the authorization code from a callback URL, which is -// what the browser lands on after consent. want is the state to match; an empty -// want skips the check, for the paste-the-URL fallback where no state is held. func CodeFromCallback(raw, want string) (string, error) { u, err := url.Parse(strings.TrimSpace(raw)) if err != nil { @@ -162,8 +154,6 @@ func exchange(ctx context.Context, client *http.Client, tokenURL string, form ur }, nil } -// ExchangeCode trades an authorization code for tokens. verifier must be the one -// whose challenge was sent to the authorize endpoint. func ExchangeCode(ctx context.Context, client *http.Client, tokenURL, code, verifier string) (*Token, error) { return exchange(ctx, client, tokenURL, url.Values{ "grant_type": {"authorization_code"}, @@ -173,9 +163,6 @@ func ExchangeCode(ctx context.Context, client *http.Client, tokenURL, code, veri }) } -// Refresh mints a new access token from a stored refresh token, with no browser. -// The authorization server may or may not return a replacement refresh token; when -// it does not, the caller should keep using the one it already has. func Refresh(ctx context.Context, client *http.Client, tokenURL, refreshToken, tsg string) (*Token, error) { tok, err := exchange(ctx, client, tokenURL, url.Values{ "grant_type": {"refresh_token"}, diff --git a/internal/saseauth/oauth_test.go b/internal/saseauth/oauth_test.go index 124d16c..d852ec6 100644 --- a/internal/saseauth/oauth_test.go +++ b/internal/saseauth/oauth_test.go @@ -192,7 +192,6 @@ func TestCodeFromCallback(t *testing.T) { } } -// tokenServer records the last posted form and replies with body. func tokenServer(t *testing.T, status int, body string) (*httptest.Server, *url.Values) { t.Helper() var got url.Values diff --git a/internal/saseauth/seed.go b/internal/saseauth/seed.go index 61a703c..49f82f0 100644 --- a/internal/saseauth/seed.go +++ b/internal/saseauth/seed.go @@ -7,9 +7,6 @@ import ( "sync" ) -// Seed is the tenant-less refresh token every tenant without one of its own -// starts from. Tenants load in parallel and the server rotates a refresh token -// as it redeems it, so the second to present the same one is refused. type Seed struct { mu sync.Mutex token string @@ -18,8 +15,6 @@ type Seed struct { func NewSeed(token string) *Seed { return &Seed{token: token} } -// redeem narrows the seed to tsg. tried separates "refused" from "no seed". -// The lock spans the exchange; that is the point. func (s *Seed) redeem(ctx context.Context, client *http.Client, tokenURL, tsg string) (tok *Token, tried bool, err error) { if s == nil { return nil, false, nil @@ -32,15 +27,11 @@ func (s *Seed) redeem(ctx context.Context, client *http.Client, tokenURL, tsg st tok, err = Refresh(ctx, client, tokenURL, s.token, tsg) if err != nil { - // Only a rejected grant condemns the token. A refused scope is this - // tenant's problem, not the next one's. if strings.Contains(err.Error(), "invalid_grant") { s.spent = true } return nil, true, err } - // A different token back means the old one is spent; the same one means - // the server does not rotate and the seed still works. if tok.RefreshToken != s.token { s.spent = true } diff --git a/internal/saseauth/seed_test.go b/internal/saseauth/seed_test.go index 6ce952f..f849e08 100644 --- a/internal/saseauth/seed_test.go +++ b/internal/saseauth/seed_test.go @@ -110,7 +110,6 @@ func TestSeedRetiredOnlyWhenTheTokenItselfIsRefused(t *testing.T) { wantSpent bool }{ {"token rejected", `{"error":"invalid_grant"}`, true}, - // A refused scope says nothing about the token. {"scope refused", `{"error":"invalid_scope"}`, false}, } for _, tt := range tests { diff --git a/internal/saseauth/source.go b/internal/saseauth/source.go index a0574d2..fb10dc4 100644 --- a/internal/saseauth/source.go +++ b/internal/saseauth/source.go @@ -8,16 +8,8 @@ import ( "time" ) -// ErrInteractiveRequired reports that no stored credential could produce a token -// and a human has to sign in. Callers surface it as a prompt rather than an -// error, then call SignIn. var ErrInteractiveRequired = errors.New("interactive sign-in required") -// Source is an api.TokenSource backed by browser authentication. It prefers a -// stored refresh token, falls back to a silent browser attempt against a kept -// profile, and otherwise asks to be signed in. Token never opens a window: an -// unattended refresh must not block on one, so the decision to show a browser -// stays with the caller. type Source struct { tsg string opt Options @@ -29,9 +21,6 @@ type Source struct { lastTTL time.Duration } -// NewSource builds a Source for tsg. refreshToken may be empty. save, when not -// nil, is called with every new refresh token so it can be persisted; its error -// is returned to the caller that triggered the exchange. func NewSource(tsg, refreshToken string, opt Options, save func(string) error) *Source { return &Source{tsg: tsg, opt: opt, save: save, refresh: refreshToken} } @@ -42,16 +31,12 @@ func (s *Source) RefreshToken() string { return s.refresh } -// accept records a freshly issued token and returns its access half. -// LastTTL is how long the most recent access token is good for. func (s *Source) LastTTL() time.Duration { s.mu.Lock() defer s.mu.Unlock() return s.lastTTL } -// Identity is who the most recent token says you are. It is empty until a -// token has been obtained. func (s *Source) Identity() Identity { s.mu.Lock() defer s.mu.Unlock() @@ -77,9 +62,6 @@ func (s *Source) accept(tok *Token) (string, time.Duration, error) { } func (s *Source) Token(ctx context.Context) (string, time.Duration, error) { - // Why the stored credential was not good enough. Without it the only - // error reported is the browser leg's, which says "no existing browser - // session" for a tenant whose real problem is a rejected refresh token. var stored error if rt := s.RefreshToken(); rt != "" { @@ -93,11 +75,6 @@ func (s *Source) Token(ctx context.Context) (string, time.Duration, error) { stored = fmt.Errorf("stored refresh token rejected: %w", err) } - // The account credential, narrowed to this tenant. Reached by a tenant - // with nothing of its own and by one whose own credential the server has - // since rejected -- skipping it in the second case is what sent a tenant - // with a merely expired token all the way to the browser. Only one tenant - // gets it; accept turns what comes back into that tenant's own. if tok, tried, err := s.opt.Seed.redeem(ctx, s.opt.httpClient(), s.opt.token(), s.tsg); tried { if err == nil { return s.accept(tok) @@ -123,16 +100,12 @@ func (s *Source) Token(ctx context.Context) (string, time.Duration, error) { return "", 0, permanentError{fmt.Errorf("%w: %v", ErrInteractiveRequired, err)} } -// permanentError marks a failure that will not resolve without a human, so a -// refresh loop can stop rather than retry forever. type permanentError struct{ err error } func (e permanentError) Error() string { return e.err.Error() } func (e permanentError) Unwrap() error { return e.err } func (e permanentError) Temporary() bool { return false } -// SignIn runs the interactive flow, opening a browser window when no kept -// session can be reused, and stores the resulting refresh token. func (s *Source) SignIn(ctx context.Context) error { tok, err := Login(ctx, s.tsg, s.opt) if err != nil { @@ -142,6 +115,4 @@ func (s *Source) SignIn(ctx context.Context) error { return err } -// Seed is the shared account credential this source falls back on, which is nil -// once the tenant has one of its own. Exposed so the wiring can be checked. func (s *Source) Seed() *Seed { return s.opt.Seed } diff --git a/internal/saseauth/source_test.go b/internal/saseauth/source_test.go index 4710429..66f59a0 100644 --- a/internal/saseauth/source_test.go +++ b/internal/saseauth/source_test.go @@ -65,7 +65,6 @@ func TestSourceTokenReportsInteractiveRequired(t *testing.T) { })) defer srv.Close() - // No DataDir, so the silent browser attempt cannot run either. src := NewSource("123", tt.refresh, Options{TokenURL: srv.URL, Client: srv.Client()}, nil) _, _, err := src.Token(context.Background()) if !errors.Is(err, ErrInteractiveRequired) { @@ -89,7 +88,6 @@ func TestSourceTokenSurfacesSaveFailure(t *testing.T) { if !errors.Is(err, want) { t.Errorf("Token() error = %v, want it to wrap %v", err, want) } - // The token itself is still usable; only persistence failed. if tok != "at" { t.Errorf("token = %q, want %q returned despite the save failure", tok, "at") } @@ -118,8 +116,6 @@ func TestSourceTokenHonoursContextCancellation(t *testing.T) { } func TestSourceSatisfiesTokenSource(t *testing.T) { - // Compile-time shape check without importing api, which would be a cycle - // risk; the signature below is api.TokenSource. var _ interface { Token(ctx context.Context) (string, time.Duration, error) } = (*Source)(nil) @@ -152,7 +148,6 @@ func TestSourceFallsBackToTheSharedSeed(t *testing.T) { if got := form.Get("scope"); got != "openid profile email tsg_id:123" { t.Errorf("sent scope = %q, want it narrowed to this tenant", got) } - // This tenant's own credential now, so the next run skips the seed. if got, _ := saved.Load().(string); got != "tenant-rt" { t.Errorf("saved refresh token = %q, want %q", got, "tenant-rt") } @@ -181,8 +176,6 @@ func TestSourcePrefersItsOwnTokenOverTheSeed(t *testing.T) { } func TestConcurrentTenantsEachGetTheirOwnTokenFromOneSeed(t *testing.T) { - // The real failure: tenants loading at once from one account token, a - // rotating server leaving all but the first with invalid_grant. var mu sync.Mutex n := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -209,7 +202,6 @@ func TestConcurrentTenantsEachGetTheirOwnTokenFromOneSeed(t *testing.T) { wg.Add(1) go func() { defer wg.Done() - // No DataDir, so there is no silent browser to fall back on. _, _, err := NewSource(tsg, "", opt, nil).Token(context.Background()) switch { case err == nil: @@ -223,7 +215,6 @@ func TestConcurrentTenantsEachGetTheirOwnTokenFromOneSeed(t *testing.T) { } wg.Wait() - // The rest must ask for a browser, not report a rejected grant. if got := ok.Load(); got != 1 { t.Errorf("%d tenants got a token from the seed, want 1 from a rotating server", got) } @@ -236,10 +227,6 @@ func TestConcurrentTenantsEachGetTheirOwnTokenFromOneSeed(t *testing.T) { } func TestARejectedTenantTokenFallsBackToTheSeed(t *testing.T) { - // A per-tenant refresh token that the server has aged out used to skip - // the account credential entirely and go straight to the browser, so a - // tenant that merely needed renewing reported "no existing browser - // session" and asked for an interactive sign-in it did not need. var sent []string var mu sync.Mutex srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -281,16 +268,12 @@ func TestARejectedTenantTokenFallsBackToTheSeed(t *testing.T) { t.Fatalf("presented %v, want %v", sent, want) } } - // And the tenant is left with a working credential, so the next run needs - // neither the seed nor a browser. if got, _ := saved.Load().(string); got != "fresh-rt" { t.Errorf("saved refresh token = %q, want the renewed %q", got, "fresh-rt") } } func TestBothRefusalsAreReportedWhenNothingWorks(t *testing.T) { - // With no seed and no browser profile there is nothing left to try, and - // the message has to name the real cause rather than only the last leg. srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusBadRequest) w.Write([]byte(`{"error":"invalid_grant","error_description":"grant is invalid"}`)) diff --git a/internal/saseauth/tenants.go b/internal/saseauth/tenants.go index 20e58c6..168910c 100644 --- a/internal/saseauth/tenants.go +++ b/internal/saseauth/tenants.go @@ -12,17 +12,13 @@ import ( const TenancyURL = "https://api.apps.paloaltonetworks.com/tenancy/v1/tenant_service_groups" -// Tenant is a tenant service group the signed-in account can reach. type Tenant struct { ID string Name string ParentID string - // Depth is how far down the hierarchy this sits, for indenting a list. - Depth int + Depth int } -// tsgNode mirrors the tenancy response, which nests children when asked for a -// hierarchy. type tsgNode struct { ID string `json:"id"` DisplayName string `json:"display_name"` @@ -49,8 +45,6 @@ func flatten(nodes []tsgNode, parent string, depth int, out *[]Tenant, seen map[ } } -// ListTenants asks which tenants the token can act for. A tenant-less token is -// enough: this is how the tenant to scope to is chosen in the first place. func ListTenants(ctx context.Context, client *http.Client, baseURL, accessToken string) ([]Tenant, error) { if baseURL == "" { baseURL = TenancyURL @@ -89,30 +83,18 @@ func ListTenants(ctx context.Context, client *http.Client, baseURL, accessToken if len(tenants) == 0 { return nil, fmt.Errorf("tenants: the account has access to none") } - // Hierarchy order is what the portal shows, but a flat alphabetical list is - // easier to pick from once it is more than a handful. sort.SliceStable(tenants, func(i, j int) bool { return strings.ToLower(tenants[i].Name) < strings.ToLower(tenants[j].Name) }) return tenants, nil } -// Discovery is the result of signing in without naming a tenant. type Discovery struct { - Identity Identity - Tenants []Tenant - // RefreshToken is the tenant-less credential the sign-in produced. It is - // the seed for every tenant: a refresh grant may narrow its scope to one - // tenant, which works where a silent re-authorize does not. + Identity Identity + Tenants []Tenant RefreshToken string } -// Discover opens a browser, signs in with no tenant scope, and reports which -// tenants the account can use. Nothing has to be typed: the tenant ID and its -// name both come back from the portal. -// -// The session cookie this leaves in the browser profile is what lets every -// later tenant be scoped silently, with no second window. func Discover(ctx context.Context, opt Options, tenancyURL string) (Discovery, error) { tok, err := loginToken(ctx, "", opt, false) if err != nil { diff --git a/internal/saseauth/tenants_test.go b/internal/saseauth/tenants_test.go index a8b4f77..5996a73 100644 --- a/internal/saseauth/tenants_test.go +++ b/internal/saseauth/tenants_test.go @@ -44,7 +44,6 @@ func TestListTenantsFlattensTheHierarchy(t *testing.T) { t.Errorf("Authorization = %q, want the bearer token", *auth) } - // Sorted by name, nested entries included. want := []struct{ name, id string }{ {"CIS root", "1643707006"}, {"NIAGARA BOTTLING, LLC-145595", "1538107912"}, @@ -128,7 +127,6 @@ func TestListTenantsEdgeCases(t *testing.T) { } func TestListTenantsDefaultsToTheRealEndpoint(t *testing.T) { - // Passing no base URL must target the portal, not panic or build a bare path. req, err := http.NewRequest(http.MethodGet, TenancyURL+"?hierarchy=true", nil) if err != nil { t.Fatalf("the default endpoint is not a usable URL: %v", err) diff --git a/internal/search/search.go b/internal/search/search.go index 074508a..11410b1 100644 --- a/internal/search/search.go +++ b/internal/search/search.go @@ -1,6 +1,3 @@ -// Package search matches a typed query against elements. The TUI's filter and -// the CLI's --search share it so that the same query cannot find different -// elements depending on where it was typed. package search import ( @@ -9,13 +6,10 @@ import ( "tyrtop.com/iontk/internal/sase/api" ) -// SiteNamer resolves a site ID to its name. Two tenants can use the same site -// identifier for different places, so the tenant is part of the key. type SiteNamer interface { SiteName(tsg, siteID string) (string, bool) } -// SiteMap is the usual SiteNamer: names keyed by "tsg/siteID". type SiteMap map[string]string func (s SiteMap) SiteName(tsg, siteID string) (string, bool) { @@ -23,7 +17,6 @@ func (s SiteMap) SiteName(tsg, siteID string) (string, bool) { return n, ok && n != "" } -// Record is one element flattened into the values a query may match. type Record struct { Name string ID string @@ -66,8 +59,6 @@ func (r Record) State() string { return "offline" } -// SiteLabel is the site column, which falls back to the raw ID and then to a -// placeholder so the column is never blank. func (r Record) SiteLabel() string { if r.Site == "" { return "-" @@ -75,29 +66,19 @@ func (r Record) SiteLabel() string { return r.Site } -// Field is one value a term can match. type Field struct { Value string - // Exact restricts this field to whole-value matches. Opaque identifiers - // are long digit strings, so a partial match in one is almost always - // coincidence: searching "1200" for a model found an ion 2000 whose site - // ID happened to contain those digits. Exact bool } -// Fields is everything a term may match against. func (r Record) Fields() []Field { return []Field{ {Value: strings.ToLower(r.Name)}, {Value: strings.ToLower(r.ID), Exact: true}, - // A site that did not resolve shows its raw ID, which is as opaque as - // any other identifier and must not be matched piecemeal. {Value: strings.ToLower(r.Site), Exact: !r.SiteKnown}, {Value: strings.ToLower(r.Tenant)}, {Value: strings.ToLower(r.Model)}, {Value: strings.ToLower(r.HWID), Exact: true}, - // Exact like the hardware id: a serial is quoted off a label or a - // support case, never half-remembered. {Value: strings.ToLower(r.Serial), Exact: true}, {Value: strings.ToLower(r.Software)}, {Value: strings.ToLower(r.TSG), Exact: true}, @@ -105,17 +86,12 @@ func (r Record) Fields() []Field { } } -// Query is a parsed search string. type Query []string -// Parse splits on whitespace, which is what makes a query like "s50 ion 1200" -// work: each word can land in a different field, so the store number and the -// model are found together without having to know which field holds what. func Parse(s string) Query { return strings.Fields(strings.ToLower(s)) } func (q Query) Empty() bool { return len(q) == 0 } -// Match requires every term to match the record somewhere. func (q Query) Match(r Record) bool { if len(q) == 0 { return true diff --git a/internal/search/search_test.go b/internal/search/search_test.go index 348969b..96be138 100644 --- a/internal/search/search_test.go +++ b/internal/search/search_test.go @@ -7,8 +7,6 @@ import ( "tyrtop.com/iontk/internal/sase/api" ) -// fleet is the shape the filter bug reports came in on: padded store numbers, -// two tenants, and a site that resolves to a name. func fleet() []Record { sites := SiteMap{ "111/s100": "Store 0100", @@ -67,8 +65,6 @@ func TestEveryTermHasToMatchSomeField(t *testing.T) { want []string }{ { - // The case that prompted splitting on whitespace: a term each for - // site, model and tenant. name: "site, model and tenant together", query: "100 1200 tract", want: []string{"s0100-rtr-01"}, @@ -99,8 +95,6 @@ func TestEveryTermHasToMatchSomeField(t *testing.T) { } func TestOnlyNamesThatContainTheTermMatch(t *testing.T) { - // No out-of-order matching: s502 must not reach s1405, and a padded store - // number is found by typing it as written. var records []Record for _, el := range []api.Element{ {ID: "e1", Name: "s1405-rtr-02", Model: "ion 2000"}, @@ -121,8 +115,6 @@ func TestOnlyNamesThatContainTheTermMatch(t *testing.T) { } func TestTwoCharacterTermsStayLiteral(t *testing.T) { - // Two characters are too few to match out of order: nearly everything - // would qualify, which makes the filter useless rather than forgiving. for _, r := range fleet() { if !Parse("or").Match(r) { continue @@ -140,8 +132,6 @@ func TestTwoCharacterTermsStayLiteral(t *testing.T) { } func TestOpaqueIdentifiersNeedTheWholeValue(t *testing.T) { - // An element ID is a 19-digit number, so a partial match in one is - // coincidence. "1200" found an ion 2000 this way. r := Of(api.Element{ ID: "1695401228372008796", Name: "s1405-rtr-01", SiteID: "1695401228372008796", Model: "ion 2000", HWID: "0123456789", @@ -152,7 +142,6 @@ func TestOpaqueIdentifiersNeedTheWholeValue(t *testing.T) { t.Errorf("query %q matched a partial identifier", partial) } } - // The whole value still works, for pasting one in. for _, whole := range []string{"1695401228372008796", "1234567890", "0123456789"} { if !Parse(whole).Match(r) { t.Errorf("query %q did not match the identifier it names in full", whole) @@ -161,8 +150,6 @@ func TestOpaqueIdentifiersNeedTheWholeValue(t *testing.T) { } func TestAnUnresolvedSiteIsTreatedAsOpaque(t *testing.T) { - // When site names do not resolve the column shows a raw ID, which must not - // become a substring haystack. el := api.Element{ID: "e1", Name: "s1405-rtr-01", SiteID: "1695401228372008796", Model: "ion 2000"} unresolved := Of(el, "111", "Tractor Supply", SiteMap{}) @@ -173,7 +160,6 @@ func TestAnUnresolvedSiteIsTreatedAsOpaque(t *testing.T) { t.Error("a term matched inside an unresolved site id") } - // A resolved site name is searchable as normal. resolved := Of(el, "111", "Tractor Supply", SiteMap{"111/1695401228372008796": "Store 1405"}) if !Parse("store 1405").Match(resolved) { t.Error("a resolved site name is not searchable") @@ -181,8 +167,6 @@ func TestAnUnresolvedSiteIsTreatedAsOpaque(t *testing.T) { } func TestASiteResolvedInAnotherTenantDoesNotCountHere(t *testing.T) { - // Two tenants can use the same site identifier for different places, so - // the name must not leak across the pair. sites := SiteMap{"111/s100": "Store 0100"} r := Of(api.Element{ID: "e1", Name: "ord-gw-01", SiteID: "s100"}, "222", "Niagara Bottling", sites) if r.SiteKnown { @@ -194,8 +178,6 @@ func TestASiteResolvedInAnotherTenantDoesNotCountHere(t *testing.T) { } func TestAnEmptySiteNameIsNotAResolvedOne(t *testing.T) { - // A present-but-empty entry used to count as resolved, which turned the - // raw id into a loose haystack. r := Of(api.Element{ID: "e1", Name: "s1405-rtr-01", SiteID: "1695401228372008796"}, "111", "Tractor Supply", SiteMap{"111/1695401228372008796": ""}) if r.SiteKnown { @@ -207,8 +189,6 @@ func TestAnEmptySiteNameIsNotAResolvedOne(t *testing.T) { } func TestTheStoreQueryThatHadToWork(t *testing.T) { - // A store number, a model split over two words, and nothing matching out - // of order. sites := SiteMap{"111/s50": "Store 5024"} var records []Record for _, el := range []api.Element{ @@ -244,8 +224,6 @@ func TestSiteLabelNeverLeavesTheColumnBlank(t *testing.T) { } func TestAnEmptyFieldIsNotAWildcard(t *testing.T) { - // strings.Contains("", term) is false, but an empty haystack with an empty - // term is not: guard it, or an element missing a model matches anything. r := Of(api.Element{ID: "e1", Name: "s0100-rtr-01"}, "111", "Tractor Supply", nil) if Parse("ion1200").Match(r) { t.Error("an element with no model matched a model term") diff --git a/internal/tui/appearance.go b/internal/tui/appearance.go index 7442a64..89b7cd4 100644 --- a/internal/tui/appearance.go +++ b/internal/tui/appearance.go @@ -2,15 +2,16 @@ package tui import ( "fmt" + "slices" "strings" "time" tea "charm.land/bubbletea/v2" + "tyrtop.com/iontk/internal/app" "tyrtop.com/iontk/internal/clock" "tyrtop.com/iontk/internal/ui" ) -// prints the clock too: a zone set months ago is only checkable against it func (m tuiModel) zoneLine() string { if p := clock.Problem(); p != "" { return ui.StyleWarn.Render(ui.Truncate(p, 44)) + ui.StyleDim.Render(" times are UTC") @@ -19,7 +20,6 @@ func (m tuiModel) zoneLine() string { ui.StyleDim.Render(" now "+clock.Stamp(time.Now())) } -// 600 IANA names have no order to walk, so only the typed one is kept func (m tuiModel) cycleZone() (tea.Model, tea.Cmd) { order := []string{clock.UTC, clock.Local} if m.typedZone != "" { @@ -37,7 +37,6 @@ func (m tuiModel) cycleZone() (tea.Model, tea.Cmd) { return nm, nil } -// a zone that will not resolve is a typo, so the saved value stays func (m tuiModel) applyZone(spec string) (tuiModel, bool) { if err := clock.Valid(spec); err != nil { m.status = err.Error() @@ -65,7 +64,6 @@ func (m tuiModel) openZoneForm() (tea.Model, tea.Cmd) { func (m tuiModel) submitZone() (tea.Model, tea.Cmd) { nm, ok := m.applyZone(m.formValue(0)) if !ok { - // the form stays up with the reason on it return nm, nil } nm.form, nm.formKind = nil, formNone @@ -73,6 +71,14 @@ func (m tuiModel) submitZone() (tea.Model, tea.Cmd) { return nm, nil } +func (m tuiModel) themeAt(cursor int) (ui.Theme, bool) { + themes := ui.Themes() + if cursor < 1 || cursor > len(themes) { + return ui.Theme{}, false + } + return themes[cursor-1], true +} + func (m tuiModel) themeContent() []string { if m.themeEdit { return append(m.themeEditContent(), m.themePreview(m.themeDraft)...) @@ -82,14 +88,16 @@ func (m tuiModel) themeContent() []string { cur := ui.CurrentTheme() out := make([]string, 0, len(themes)+10) + const newRow = "+ New Theme" + out = append(out, ui.StyleGhost.Render(newRow)) + for _, t := range themes { - // padded plain, or the swatches drift row by row - name := ui.StyleElement.Render(fmt.Sprintf("%-12s", ui.Truncate(t.Name, 12))) + plain := fmt.Sprintf("%-12s", ui.Truncate(t.Name, 12)) + name := ui.StyleElement.Render(plain) tag := "" if strings.EqualFold(t.Name, cur.Name) { - tag = ui.StyleOK.Render(" (in use)") + name = ui.StyleRow.Render(plain) } - // nothing to say about a scheme this terminal can show if ui.ThemeNote(t) != "" { tag += ui.StyleWarn.Render(" approximated here") } @@ -100,12 +108,15 @@ func (m tuiModel) themeContent() []string { out = append(out, "", ui.StyleErr.Render(ui.Truncate(err, 60))) } - // under the cursor, not in use: a preview is for before choosing - at := min(max(m.setCursor, 0), len(themes)-1) - if m.setFocus != focusContent { - at = max(0, indexOfTheme(themes, cur.Name)) + out = append(out, "", ui.StyleDim.Render(m.themeControls())) + + at := max(0, indexOfTheme(themes, cur.Name)) + if m.setFocus == focusContent { + if _, ok := m.themeAt(m.setCursor); ok { + at = m.setCursor - 1 + } } - return append(out, m.themePreview(themes[at])...) + return append(out, m.themePreview(themes[min(at, len(themes)-1)])...) } func indexOfTheme(themes []ui.Theme, name string) int { @@ -117,8 +128,6 @@ func indexOfTheme(themes []ui.Theme, name string) int { return -1 } -// fixture-shaped rows: a swatch says what the colours are, a pane says -// what they look like. var previewRows = []struct { name, site, model, state string ok, selected bool @@ -128,15 +137,13 @@ var previewRows = []struct { {"store-0100-rtr-01", "Store 0100", "ion 3200", "offline", false, false}, } -// a page of iontk drawn in one scheme func (m tuiModel) themePreview(t ui.Theme) []string { s := t.Styles() width := max(40, min(m.width-20, 64)) out := []string{ "", - ui.StyleDim.Render("preview · ") + ui.StyleElement.Render(t.Name), - // tab colours are identity, not status + ui.StyleDim.Render("Preview · ") + ui.StyleElement.Render(t.Name), previewTabs(t), } @@ -151,7 +158,6 @@ func (m tuiModel) themePreview(t ui.Theme) []string { if r.selected { box = s.OK.Render("[x]") } - // styled on its own: a highlight around the mark ends at its reset block := fmt.Sprintf("%-20s%-12s%-11s", r.name, r.site, r.model) style := s.Element if i == 0 { @@ -165,8 +171,9 @@ func (m tuiModel) themePreview(t ui.Theme) []string { s.Dim.Render(" 3 elements · 1 selected"), " "+s.Warn.Render("ion offline")+ s.Dim.Render(" · alt+y copy · ")+s.Hot.Render("session closing"), - " "+s.Spinner.Render(spinnerFrame(t))+s.Dim.Render(" reading interfaces")+ - s.Dim.Render(" spinner: ")+s.Element.Render(spinnerLabel(t)), + " "+s.Spinner.Render(m.previewFrame(t))+s.Dim.Render(" reading interfaces")+ + s.Dim.Render(" spinner: ")+s.Element.Render(spinnerLabel(t))+ + " "+s.Spinner.Render(m.previewFrame(t)), ) if note := ui.ThemeNote(t); note != "" { out = append(out, " "+ui.StyleWarn.Render(ui.Truncate(note, width))) @@ -176,19 +183,30 @@ func (m tuiModel) themePreview(t ui.Theme) []string { if depth == "" { depth = "not reported yet" } - return append(out, "", ui.Label("terminal")+ui.StyleDim.Render(depth)) + return append(out, "", ui.Label("Terminal")+ui.StyleDim.Render(depth)) } func previewTabs(t ui.Theme) string { - s := t.Styles() - labels := []string{" elements (shell) ", " store-0003-rtr-01 ", " store-0100-rtr-01 · data "} - parts := make([]string, 0, len(labels)) - for i, label := range labels { - st := s.Tabs[i%len(s.Tabs)] - if i == 0 { - st = st.Reverse(true).Bold(true) - } - parts = append(parts, st.Render(label)) + was := ui.CurrentTheme() + ui.Apply(t) + defer ui.Apply(was) + + type tab struct { + kind ui.TabKind + ring, nth int + label string + } + bar := []tab{ + {ui.TabList, 0, 0, " elements "}, + {ui.TabShell, 1, 0, " rtr-01 "}, + {ui.TabShell, 2, 1, " rtr-02 "}, + {ui.TabData, 3, 0, " rtr-01 · data "}, + {ui.TabData, 4, 1, " rtr-02 · data "}, + } + + parts := make([]string, 0, len(bar)) + for _, b := range bar { + parts = append(parts, ui.StyleTab(b.kind, b.ring, b.nth, b.ring == 0).Render(b.label)) } return " " + strings.Join(parts, " ") } @@ -203,17 +221,35 @@ func (m tuiModel) onThemeKey(key string) (tea.Model, tea.Cmd) { if m.themeEdit { return m.onThemeEditKey(key) } + armed := m.themeDel + m.themeDel = "" + + t, onScheme := m.themeAt(m.setCursor) + + if key == "x" || key == "ctrl+d" { + if !onScheme { + return m, nil + } + if strings.EqualFold(armed, t.Name) { + return m.deleteTheme(t) + } + m.themeDel = t.Name + m.status = "x again to " + m.deleteWord(t) + " " + t.Name + return m, nil + } + if key == "e" { + if !onScheme { + return m.openNewThemeForm() + } return m.openThemeEdit() } if key != "enter" && key != " " && key != "space" { return m, nil } - themes := ui.Themes() - if m.setCursor < 0 || m.setCursor >= len(themes) { - return m, nil + if !onScheme { + return m.openNewThemeForm() } - t := themes[m.setCursor] m.cfg.Theme = t.Name if err := m.store.SaveConfig(m.cfg); err != nil { @@ -229,12 +265,19 @@ func (m tuiModel) onThemeKey(key string) (tea.Model, tea.Cmd) { return nm, cmd } -// the tables and the spinner copied a style when built, so rebuild them func (m tuiModel) applyTheme(t ui.Theme) (tea.Model, tea.Cmd) { + cur := ui.CurrentTheme() + repaint := t.Bg != cur.Bg || t.Fg != cur.Fg + ui.Apply(t) m.table.SetStyles(tableStyles()) m.resTable.SetStyles(tableStyles()) - return m.withSpinner() + + next, cmd := m.withSpinner() + if repaint { + return next, tea.Batch(cmd, tea.ClearScreen) + } + return next, cmd } func profileLabel() string { @@ -243,3 +286,138 @@ func profileLabel() string { } return "depth not reported" } + +func (m tuiModel) openNewThemeForm() (tea.Model, tea.Cmd) { + f := newField("name", "my scheme", false) + return m.openForm(formNewTheme, "New scheme, copied from "+ui.CurrentTheme().Name, + phaseAddUser, []formField{f}) +} + +func (m tuiModel) submitNewTheme() (tea.Model, tea.Cmd) { + name := strings.TrimSpace(m.formValue(0)) + switch { + case name == "": + m.status = "a scheme needs a name" + return m, nil + case len(name) > 40: + m.status = "that name is too long for the picker" + return m, nil + } + if _, taken := ui.ThemeByName(name); taken { + m.status = name + " already exists — edit it with e" + return m, nil + } + + t := ui.CurrentTheme() + t.Name, t.Blurb = name, "copied from "+ui.CurrentTheme().Name + t.Tabs = slices.Clone(t.Tabs) + if err := ui.SaveUserTheme(app.ThemesPath(m.store), t); err != nil { + m.status = err.Error() + return m, nil + } + + nm := m + nm.form, nm.formKind = nil, formNone + nm.phase = phaseSettings + nm.setFocus = focusContent + for i, th := range ui.Themes() { + if strings.EqualFold(th.Name, name) { + nm.setCursor = i + 1 + } + } + next, _ := nm.openThemeEdit() + out, cmd := next.(tuiModel).saveDraft() + o := out.(tuiModel) + if o.status == "" || !strings.Contains(o.status, "fail") { + o.status = name + } + return o, cmd +} + +func (m tuiModel) themeControls() string { + if m.themeEdit { + return "" + } + if _, onScheme := m.themeAt(m.setCursor); !onScheme { + return "enter new scheme · ↑↓ move · esc back" + } + return "↑↓ move · enter select · e edit · x delete · esc back" +} + +func (m tuiModel) previewFrame(t ui.Theme) string { + return ui.SpinnerFrameAt(t.Spinner, m.spinTick) +} + +func (m tuiModel) deleteWord(t ui.Theme) string { + _, shipped := ui.BuiltinTheme(t.Name) + switch { + case ui.IsUserTheme(app.ThemesPath(m.store), t.Name) && shipped: + return "drop your changes to" + case ui.IsUserTheme(app.ThemesPath(m.store), t.Name): + return "delete" + } + return "hide" +} + +func (m tuiModel) deleteTheme(t ui.Theme) (tea.Model, tea.Cmd) { + path := app.ThemesPath(m.store) + report := "" + + if ui.IsUserTheme(path, t.Name) { + if err := ui.DeleteUserTheme(path, t.Name); err != nil { + m.status = err.Error() + return m, nil + } + if _, still := ui.ThemeByName(t.Name); still { + report = t.Name + " is back to the one iontk ships" + } else { + report = t.Name + " is gone" + } + } else { + m.cfg.HiddenThemes = append(slices.Clone(m.cfg.HiddenThemes), t.Name) + ui.HideThemes(m.cfg.HiddenThemes) + report = t.Name + " is hidden · settings → Reset brings it back" + } + + if _, still := ui.ThemeByName(m.cfg.Theme); !still || ui.IsHidden(m.cfg.Theme) { + m.cfg.Theme = ui.Default().Name + report += ", so " + m.cfg.Theme + " is in use" + } + if err := m.store.SaveConfig(m.cfg); err != nil { + m.status = err.Error() + return m, nil + } + + m.setCursor = min(m.setCursor, max(0, m.sectionRows()-1)) + now, _ := ui.ThemeByName(m.cfg.Theme) + next, cmd := m.applyTheme(now) + nm := next.(tuiModel) + nm.status = report + return nm, cmd +} + +func (m tuiModel) restoreShippedThemes() (tea.Model, tea.Cmd) { + n, err := ui.RemoveOverrides(app.ThemesPath(m.store)) + if err != nil { + m.status = err.Error() + return m, nil + } + hidden := len(m.cfg.HiddenThemes) + m.cfg.HiddenThemes = nil + ui.HideThemes(nil) + if err := m.store.SaveConfig(m.cfg); err != nil { + m.status = err.Error() + return m, nil + } + + now, ok := ui.ThemeByName(m.cfg.Theme) + if !ok { + m.cfg.Theme = now.Name + _ = m.store.SaveConfig(m.cfg) + } + next, cmd := m.applyTheme(now) + nm := next.(tuiModel) + nm.status = fmt.Sprintf("the shipped schemes are back · %d override(s) removed, %d unhidden", + n, hidden) + return nm, cmd +} diff --git a/internal/tui/appearance_test.go b/internal/tui/appearance_test.go index 1e09480..4a13760 100644 --- a/internal/tui/appearance_test.go +++ b/internal/tui/appearance_test.go @@ -4,6 +4,7 @@ import ( "context" "os" "path/filepath" + "slices" "strings" "testing" @@ -14,8 +15,6 @@ import ( "tyrtop.com/iontk/internal/ui" ) -// pinZone and pinTheme put the process state back: both are global, so a test -// that left one set would decide what every later test rendered. func pinZone(t *testing.T) { t.Helper() t.Cleanup(func() { _ = clock.Use(clock.UTC) }) @@ -23,16 +22,12 @@ func pinZone(t *testing.T) { func pinTheme(t *testing.T) { t.Helper() - // LoadThemes("") is also how the set goes back to the built-ins, which a - // test that saved an override has to do. t.Cleanup(func() { _ = ui.LoadThemes("") ui.Apply(ui.Default()) }) } -// The times row is where someone finds out their panes are five hours out, so -// it has to show the clock and not only the zone's name. func TestTimesRowShowsTheZoneAndTheClock(t *testing.T) { pinZone(t) m := settingsModel(t) @@ -40,11 +35,10 @@ func TestTimesRowShowsTheZoneAndTheClock(t *testing.T) { m.setFocus = focusContent out := plain(m.viewSettings()) - if !strings.Contains(out, "times") || !strings.Contains(out, "UTC") { + if !strings.Contains(out, "Times") || !strings.Contains(out, "UTC") { t.Errorf("the General section does not say what zone times are in:\n%s", out) } - // space cycles; enter on the same row types a name instead. m.setCursor = zoneRow mm, _ := m.onGeneralKey("space") got := mm.(tuiModel) @@ -63,8 +57,6 @@ func TestTimesRowShowsTheZoneAndTheClock(t *testing.T) { } } -// Cycling has to be able to get back to a typed zone: there are 600 IANA -// names and no order to walk them in. func TestTheCycleKeepsTheTypedZone(t *testing.T) { pinZone(t) m := settingsModel(t) @@ -110,8 +102,6 @@ func TestEnterOnTheTimesRowOpensTheZoneForm(t *testing.T) { } } -// A zone nobody can resolve is a typo. The form keeps it and says why rather -// than saving it and printing UTC from then on. func TestAZoneThatWillNotLoadIsRefusedByTheForm(t *testing.T) { pinZone(t) m := settingsModel(t) @@ -146,11 +136,14 @@ func TestThemeSectionListsEveryThemeAndMarksTheOneInUse(t *testing.T) { t.Errorf("the Theme section is missing %q:\n%s", th.Name, out) } } - if !strings.Contains(out, "(in use)") { + if !strings.Contains(out, "") { t.Errorf("nothing marks the theme in use:\n%s", out) } - if got := m.sectionRows(); got != len(ui.Themes()) { - t.Errorf("sectionRows() = %d, want one per theme", got) + if got := m.sectionRows(); got != len(ui.Themes())+1 { + t.Errorf("sectionRows() = %d, want %d", got, len(ui.Themes())+1) + } + if !strings.Contains(out, "+ New Theme") { + t.Errorf("there is no way to make a scheme from the picker:\n%s", out) } t.Log("\n" + m.viewSettings()) } @@ -160,11 +153,10 @@ func TestPickingAThemeAppliesAndPersistsIt(t *testing.T) { m := settingsModel(t) m.setSection, m.setFocus = secTheme, focusContent - // mono is the one whose effect is visible without colour support. want := 0 for i, th := range ui.Themes() { if th.Name == "mono" { - want = i + want = i + 1 } } m.setCursor = want @@ -181,8 +173,6 @@ func TestPickingAThemeAppliesAndPersistsIt(t *testing.T) { if saved.Theme != "mono" { t.Errorf("saved theme = %q, want it persisted", saved.Theme) } - // The spinner carries a style built when it was made, so the change has to - // rebuild it -- and that comes with a tick. if cmd == nil { t.Error("picking a theme issued no command, so the spinner kept the old one") } @@ -191,8 +181,6 @@ func TestPickingAThemeAppliesAndPersistsIt(t *testing.T) { } } -// A theme saved by a later version, or deleted from one, must not stop the TUI -// opening. func TestAnUnknownSavedThemeOpensInTheDefault(t *testing.T) { pinTheme(t) m := settingsModel(t) @@ -207,9 +195,6 @@ func TestAnUnknownSavedThemeOpensInTheDefault(t *testing.T) { } } -// A swatch says what the colours are; only a pane says what they look like in -// the thing people read. The preview follows the cursor, not the scheme in -// use, because its job is showing one before you pick it. func TestThePreviewFollowsTheCursorNotTheThemeInUse(t *testing.T) { pinTheme(t) m := settingsModel(t) @@ -217,15 +202,14 @@ func TestThePreviewFollowsTheCursorNotTheThemeInUse(t *testing.T) { themes := ui.Themes() last := len(themes) - 1 - m.setCursor = last + m.setCursor = last + 1 out := plain(m.viewSettings()) - if !strings.Contains(out, "preview · "+themes[last].Name) { + if !strings.Contains(out, "Preview · "+themes[last].Name) { t.Errorf("the preview is not of the scheme under the cursor:\n%s", out) } - // Something recognisable from a real page, with fixture-shaped data. for _, want := range []string{"store-0003-rtr-01", "Store 0003", "ion 1200", - "connected", "offline", "dump device status", "elements (shell)"} { + "connected", "offline", "dump device status", "elements"} { if !strings.Contains(out, want) { t.Errorf("the preview is missing %q:\n%s", want, out) } @@ -234,15 +218,12 @@ func TestThePreviewFollowsTheCursorNotTheThemeInUse(t *testing.T) { t.Fatal("this test needs the cursor on a scheme that is not in use") } - // On the nav pane there is no cursor in the list, so it shows the one in use. m.setFocus = focusNav - if out := plain(m.viewSettings()); !strings.Contains(out, "preview · "+ui.CurrentTheme().Name) { + if out := plain(m.viewSettings()); !strings.Contains(out, "Preview · "+ui.CurrentTheme().Name) { t.Errorf("with the nav focused the preview should be the scheme in use:\n%s", out) } } -// The preview must draw in the previewed scheme, not the one applied, or it -// would show the same thing for every row. func TestThePreviewRendersInTheSchemeItNames(t *testing.T) { pinTheme(t) m := settingsModel(t) @@ -256,21 +237,17 @@ func TestThePreviewRendersInTheSchemeItNames(t *testing.T) { if monoLines == nordLines { t.Error("two schemes previewed identically") } - // nord's green is #a6e3a1-adjacent hex, which lands on an RGB escape. if !strings.Contains(nordLines, "38;2;") { t.Errorf("the nord preview has no truecolor in it:\n%q", nordLines) } if strings.Contains(monoLines, "38;2;") { t.Errorf("the mono preview emitted colour:\n%q", monoLines) } - // Each scheme names its own spinner shape, and shows a frame of it. if !strings.Contains(plain(monoLines), mono.Spinner) { t.Errorf("the preview does not name mono's spinner %q", mono.Spinner) } } -// The spinner moved into the theme, so picking one has to change the shape the -// model is actually ticking. func TestPickingAThemeChangesTheSpinnerShape(t *testing.T) { pinTheme(t) m := settingsModel(t) @@ -286,7 +263,7 @@ func TestPickingAThemeChangesTheSpinnerShape(t *testing.T) { if want < 0 { t.Fatal("no theme called Ben's Theme") } - m.setCursor = want + m.setCursor = want + 1 mm, cmd := m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyEnter})) m = mm.(tuiModel) @@ -296,14 +273,11 @@ func TestPickingAThemeChangesTheSpinnerShape(t *testing.T) { if got := ui.CurrentTheme().Spinner; got != "monkey" { t.Errorf("spinner = %q, want the monkey the scheme asks for", got) } - // The model's own spinner is the one that ticks, so it has to have changed. if got := plain(m.spinner.View()); got == "" { t.Error("the model's spinner renders nothing after the change") } } -// A themes.yaml beside the config adds schemes, which is what makes the format -// worth having: --themes is the same escape hatch --views is. func TestAUserThemesFileReachesThePicker(t *testing.T) { pinTheme(t) t.Cleanup(func() { app.ThemesFile = "" }) @@ -339,3 +313,190 @@ themes: t.Errorf("ThemesPath() = %q, want --themes to win", got) } } + +func TestDeletingASchemeTakesTwoPresses(t *testing.T) { + pinTheme(t) + t.Cleanup(func() { app.ThemesFile = "" }) + + m := settingsModel(t) + m.width, m.height = 110, 44 + m.setSection, m.setFocus = secTheme, focusContent + + if err := ui.SaveUserTheme(app.ThemesPath(m.store), + ui.Theme{Name: "workbench", OK: "2", Spinner: "dot"}); err != nil { + t.Fatal(err) + } + at := 0 + for i, th := range ui.Themes() { + if th.Name == "workbench" { + at = i + 1 + } + } + if at == 0 { + t.Fatal("the scheme was not written where the picker reads from") + } + m.setCursor = at + + next, _ := m.onSettingsKey("x") + m = next.(tuiModel) + if _, ok := ui.ThemeByName("workbench"); !ok { + t.Fatal("one x deleted the scheme") + } + if !strings.Contains(m.status, "x again") { + t.Errorf("status = %q, want it to ask for the second press", m.status) + } + + next, _ = m.onSettingsKey("up") + m = next.(tuiModel) + m.setCursor = at + next, _ = m.onSettingsKey("x") + m = next.(tuiModel) + if _, ok := ui.ThemeByName("workbench"); !ok { + t.Error("the arm survived a cursor move, so x deleted on one press") + } + + next, _ = m.onSettingsKey("x") + m = next.(tuiModel) + if _, ok := ui.ThemeByName("workbench"); ok { + t.Errorf("the scheme is still there: %s", m.status) + } + if !strings.Contains(m.status, "gone") { + t.Errorf("status = %q after deleting", m.status) + } + if m.setCursor >= m.sectionRows() { + t.Errorf("the cursor is past the end of a shorter list: %d of %d", + m.setCursor, m.sectionRows()) + } +} + +func TestAShippedSchemeIsHiddenRatherThanRemoved(t *testing.T) { + pinTheme(t) + t.Cleanup(func() { app.ThemesFile = ""; ui.HideThemes(nil) }) + + m := settingsModel(t) + m.setSection, m.setFocus = secTheme, focusContent + for i, th := range ui.Themes() { + if th.Name == "desert" { + m.setCursor = i + 1 + } + } + + next, _ := m.onSettingsKey("x") + m = next.(tuiModel) + if !strings.Contains(m.status, "hide") { + t.Errorf("status = %q, want it to offer to hide the scheme", m.status) + } + next, _ = m.onSettingsKey("x") + m = next.(tuiModel) + + if _, ok := ui.ThemeByName("desert"); ok { + t.Error("desert is still in the picker after being hidden") + } + if !strings.Contains(m.status, "Reset") { + t.Errorf("status = %q, want it to say where it comes back from", m.status) + } + saved, err := m.store.Config() + if err != nil { + t.Fatal(err) + } + if !slices.Contains(saved.HiddenThemes, "desert") { + t.Errorf("hidden = %v, want desert in it", saved.HiddenThemes) + } + + next, _ = m.restoreShippedThemes() + m = next.(tuiModel) + if _, ok := ui.ThemeByName("desert"); !ok { + t.Errorf("desert did not come back: %s", m.status) + } + saved, err = m.store.Config() + if err != nil { + t.Fatal(err) + } + if len(saved.HiddenThemes) != 0 { + t.Errorf("hidden = %v after restoring, want none", saved.HiddenThemes) + } +} + +func TestRestoringTheShippedSchemesKeepsYourOwn(t *testing.T) { + pinTheme(t) + t.Cleanup(func() { app.ThemesFile = ""; ui.HideThemes(nil) }) + + m := settingsModel(t) + m.setSection, m.setFocus = secTheme, focusContent + dir := app.ThemesPath(m.store) + + if err := ui.SaveUserTheme(dir, ui.Theme{Name: "nord", OK: "#ffffff", Spinner: "dot"}); err != nil { + t.Fatal(err) + } + if err := ui.SaveUserTheme(dir, ui.Theme{Name: "workbench", OK: "2", Spinner: "dot"}); err != nil { + t.Fatal(err) + } + + next, _ := m.restoreShippedThemes() + m = next.(tuiModel) + + got, _ := ui.ThemeByName("nord") + if got.OK == "#ffffff" { + t.Error("the override of a shipped scheme survived the restore") + } + if _, ok := ui.ThemeByName("workbench"); !ok { + t.Errorf("a scheme of the user's own was taken with it: %s", m.status) + } + if !strings.Contains(m.status, "override") { + t.Errorf("status = %q, want it to say what it did", m.status) + } +} + +func TestTheThemeSectionPrintsItsOwnControls(t *testing.T) { + pinTheme(t) + m := settingsModel(t) + m.width, m.height = 110, 44 + m.setSection, m.setFocus = secTheme, focusContent + m.setCursor = 1 + + out := plain(m.viewSettings()) + for _, want := range []string{"enter select", "e edit", "x delete"} { + if !strings.Contains(out, want) { + t.Errorf("the controls do not offer %q:\n%s", want, out) + } + } + if strings.Contains(out, "tab back") { + t.Errorf("the controls still mention tab:\n%s", out) + } + if got := strings.Count(out, "enter select"); got != 1 { + t.Errorf("the controls are printed %d times, want once", got) + } + + m.setCursor = 0 + if out := plain(m.viewSettings()); !strings.Contains(out, "enter new scheme") { + t.Errorf("the new-scheme row does not say what enter does:\n%s", out) + } +} + +func TestThePreviewSpinnerAnimatesAndNamesItsShapeFirst(t *testing.T) { + pinTheme(t) + m := settingsModel(t) + m.width, m.height = 110, 44 + m.setSection, m.setFocus = secTheme, focusContent + for i, th := range ui.Themes() { + if th.Name == "Ben's Theme" { + m.setCursor = i + 1 + } + } + + out := plain(m.viewSettings()) + at := strings.Index(out, "spinner: monkey") + if at < 0 { + t.Fatalf("the preview does not name the shape:\n%s", out) + } + rest := out[at+len("spinner: monkey"):] + if frame := strings.TrimSpace(strings.SplitN(rest, "\n", 2)[0]); frame == "" { + t.Errorf("nothing is drawn after the shape's name: %q", rest) + } + + first := m.previewFrame(ui.Theme{Spinner: "minidot"}) + m.spinTick += 2 + if second := m.previewFrame(ui.Theme{Spinner: "minidot"}); second == first { + t.Errorf("the preview frame did not advance: %q twice", first) + } +} diff --git a/internal/tui/authline.go b/internal/tui/authline.go index 5907e49..a47455c 100644 --- a/internal/tui/authline.go +++ b/internal/tui/authline.go @@ -13,15 +13,11 @@ import ( "tyrtop.com/iontk/internal/ui" ) -// How long is left before a browser session is worth warning about. const ( sessionWarn = time.Hour sessionUrgent = 15 * time.Minute ) -// sessionLeft is how long the current tenant can keep minting tokens without a -// browser. It prefers what this run actually saw, and otherwise reads the -// stored refresh token, so the figure is right from launch. func (m tuiModel) sessionLeft() (time.Duration, bool) { if !m.hasTenant || m.authMode != authBrowser { return 0, false @@ -43,9 +39,6 @@ func (m tuiModel) sessionLeft() (time.Duration, bool) { return time.Until(exp), true } -// authLine says which credential is in use and, for a browser account, how much -// of its session is left. The colour is the whole point: it has to be obvious -// at a glance whether a sign-in is about to be needed. func (m tuiModel) authLine() string { if !m.hasTenant { return ui.StyleDim.Render("no tenant selected") @@ -63,7 +56,6 @@ func (m tuiModel) authLine() string { left, ok := m.sessionLeft() switch { case !ok: - // Nothing stored and nothing minted: a sign-in is the next step. return ui.StyleErr.Render(label + " · sign-in needed") case left <= 0: return ui.StyleErr.Render(label + " · expired") @@ -75,15 +67,9 @@ func (m tuiModel) authLine() string { return ui.StyleOK.Render(label) } -// serviceAccountLine says which service account is attached and what it has. -// Which one matters: the tenant name does not name the credential, and a -// service account reaches exactly one tenant, so the figure above it is not -// necessarily this account's. func (m tuiModel) serviceAccountLine() string { who := m.tenant.ClientID if who == "" { - // Records saved before the client ID was kept, and runs whose - // credentials came from the environment. who = "client id not saved" } @@ -92,8 +78,6 @@ func (m tuiModel) serviceAccountLine() string { if n == 1 { count = "1 element cached" } - // More than one tenant is in the working set, so the cache line above is - // the total and this is only this account's share of it. if total := len(m.all); total > n { count = fmt.Sprintf("%d of %d elements cached", n, total) } @@ -102,9 +86,6 @@ func (m tuiModel) serviceAccountLine() string { name := ui.Truncate(m.tenant.Name, 24) tail := " · " + count - // The client id is the long part, and this has to stay one line: a wrapped - // menu line pushes everything below it down a row. A width of zero is a - // model that has not been told the terminal size yet. if m.width > 0 { who = ui.Truncate(who, max(8, m.width-ui.Width(head+name+tail)-3)) } @@ -113,9 +94,6 @@ func (m tuiModel) serviceAccountLine() string { ui.StyleDim.Render(" · "+who+tail) } -// tenantElementCount is how many elements the current tenant has, counted from -// the loaded list rather than re-read from the cache file on every frame. -// Before anything has loaded, the launch-time count is this tenant's. func (m tuiModel) tenantElementCount() int { if len(m.all) == 0 { return m.cacheCount @@ -129,16 +107,12 @@ func (m tuiModel) tenantElementCount() int { return n } -// tokenRefreshedMsg is the outcome of the menu's token refresh. type tokenRefreshedMsg struct { ident saseauth.Identity ttl time.Duration err error } -// refreshToken re-mints without a browser, using the stored refresh token. It -// is the quiet counterpart to signing in: when the stored credential is spent -// it reports that rather than opening a window on its own. func refreshToken(ctx context.Context, f app.Flags, st *config.Store) tea.Cmd { return func() tea.Msg { src := app.BrowserSource(f, httpClientFor(f), st) @@ -161,9 +135,6 @@ func (m tuiModel) startTokenRefresh() (tea.Model, tea.Cmd) { return m, tea.Batch(m.spinner.Tick, refreshToken(m.ctx, f, m.store)) } -// signedIn reports whether a browser credential exists to work from: either a -// discovered tenant list, or a refresh token for the current tenant. A tenant -// record alone is not enough, since it can outlive the credential. func (m tuiModel) signedIn() bool { if c, err := m.store.TenantCache(); err == nil && c != nil && len(c.Tenants) > 0 { return true diff --git a/internal/tui/authline_test.go b/internal/tui/authline_test.go index a6d12be..9d5b81d 100644 --- a/internal/tui/authline_test.go +++ b/internal/tui/authline_test.go @@ -13,7 +13,6 @@ import ( "tyrtop.com/iontk/internal/ui" ) -// refreshTokenExpiring builds a refresh token that says it expires at exp. func refreshTokenExpiring(t *testing.T, exp time.Time) string { t.Helper() body, err := json.Marshal(map[string]any{"exp": float64(exp.Unix())}) @@ -58,8 +57,6 @@ func TestAuthLineNamesTheCredentialKind(t *testing.T) { } func TestAuthLineColourTracksTheSessionLeft(t *testing.T) { - // The colour is what makes this readable at a glance, so assert on the code - // rather than only the text. const ( red = "\x1b[31m" green = "\x1b[32m" @@ -131,7 +128,6 @@ func TestSessionLeftPrefersWhatThisRunSaw(t *testing.T) { t.Fatalf("seed: %v", err) } - // A token minted this run supersedes whatever is on disk. m.ident = saseauth.Identity{TSG: "111", SessionExpires: time.Now().Add(8 * time.Hour)} left, ok := m.sessionLeft() if !ok { @@ -141,7 +137,6 @@ func TestSessionLeftPrefersWhatThisRunSaw(t *testing.T) { t.Errorf("sessionLeft() = %v, want the freshly minted figure", left) } - // An identity for a different tenant must not be used for this one. m.ident = saseauth.Identity{TSG: "999", SessionExpires: time.Now().Add(8 * time.Hour)} left, ok = m.sessionLeft() if !ok { @@ -163,10 +158,10 @@ func TestMenuOffersSignInBeforeAndRefreshAfter(t *testing.T) { t.Run("before any sign-in", func(t *testing.T) { m := authModel(t, config.AuthBrowser) joined := strings.Join(menuLabels(m), " | ") - if !strings.Contains(joined, "sign in with browser") { + if !strings.Contains(joined, "Sign In with Browser") { t.Errorf("want the invitation to sign in: %s", joined) } - if strings.Contains(joined, "refresh browser token") { + if strings.Contains(joined, "Refresh Browser Token") { t.Errorf("want no refresh entry before signing in: %s", joined) } }) @@ -175,10 +170,10 @@ func TestMenuOffersSignInBeforeAndRefreshAfter(t *testing.T) { m := authModel(t, config.AuthBrowser) seedKnown(t, m, config.KnownTenant{TSGID: "111", Name: "Tractor Supply"}) joined := strings.Join(menuLabels(m), " | ") - if strings.Contains(joined, "sign in with browser") { + if strings.Contains(joined, "Sign In with Browser") { t.Errorf("the sign-in invitation survived signing in: %s", joined) } - if !strings.Contains(joined, "refresh browser token") { + if !strings.Contains(joined, "Refresh Browser Token") { t.Errorf("want a refresh entry once signed in: %s", joined) } }) @@ -237,7 +232,7 @@ func TestRenderMenuWithCredentialLine(t *testing.T) { m.cacheState, m.cacheCount, m.cacheAt = cacheUpdated, 5613, time.Now() out := plain(m.viewMenu()) - for _, want := range []string{"5613 elements cached", "Browser Sign-In", "41m left", "refresh browser token"} { + for _, want := range []string{"5613 elements cached", "Browser Sign-In", "41m left", "Refresh Browser Token"} { if !strings.Contains(out, want) { t.Errorf("the menu is missing %q:\n%s", want, out) } @@ -256,8 +251,6 @@ func TestServiceAccountLineNamesWhichAccountIsAttached(t *testing.T) { }) got := plain(m.authLine()) - // The tenant name does not say which credential answered, and a tenant can - // be reached by more than one service account. for _, want := range []string{ "SASE Service Account", "Tractor Supply", "iontk@1234567890.iam.panserviceaccount.com", "2 elements cached", @@ -267,8 +260,6 @@ func TestServiceAccountLineNamesWhichAccountIsAttached(t *testing.T) { } } - // On a narrow terminal the client id gives way rather than wrapping: a - // wrapped line pushes the whole menu down a row. m.width = 72 narrow := plain(m.authLine()) if ui.Width(narrow) > m.width { @@ -286,8 +277,6 @@ func TestServiceAccountLineCountsOnlyItsOwnElements(t *testing.T) { m := authModel(t, config.AuthServiceAccount) m.tenant = m.cfg.Tenants[0] - // A second tenant's elements are in the same working set, so the cache line - // above is the total and this line has to say which part of it is ours. m.mergeTenantElements("111", "Tractor Supply", []api.Element{{ID: "a", Name: "one"}}) m.mergeTenantElements("222", "Niagara Bottling", []api.Element{ {ID: "b", Name: "two"}, @@ -305,8 +294,6 @@ func TestServiceAccountLineCountsOnlyItsOwnElements(t *testing.T) { func TestServiceAccountLineBeforeAnythingHasLoaded(t *testing.T) { m := authModel(t, config.AuthServiceAccount) m.tenant = m.cfg.Tenants[0] - // What newTUIModel puts there from this tenant's own cache file, so the - // figure is right from launch rather than reading the file every frame. m.cacheCount = 143 if got := plain(m.authLine()); !strings.Contains(got, "143 elements cached") { @@ -318,8 +305,6 @@ func TestServiceAccountLineSaysWhenNoClientIDWasSaved(t *testing.T) { m := authModel(t, config.AuthServiceAccount) m.tenant = m.cfg.Tenants[0] - // Records written before the client ID was kept, and runs whose credentials - // came from the environment. Saying nothing would read as "no account". if got := plain(m.authLine()); !strings.Contains(got, "client id not saved") { t.Errorf("authLine() = %q, want it to say the client id is missing", got) } diff --git a/internal/tui/authmode.go b/internal/tui/authmode.go index 8cdeec9..5785a71 100644 --- a/internal/tui/authmode.go +++ b/internal/tui/authmode.go @@ -2,16 +2,10 @@ package tui import "tyrtop.com/iontk/internal/config" -// authMode is which kind of credential the menu is working with. It is a -// different axis from the element list's shell/batch mode: this one decides -// where tenants come from, that one decides what selecting an element does. type authMode int const ( - // authBrowser signs in as a person and reaches every tenant the account is - // entitled to. authBrowser authMode = iota - // authTenant works from a service account, which is one tenant each. authTenant ) @@ -24,7 +18,6 @@ func (a authMode) String() string { return "browser" } -// Label is how the mode is named on screen. func (a authMode) Label() string { if a == authTenant { return "SASE Service Account" @@ -50,7 +43,6 @@ func parseAuthMode(s string) (authMode, bool) { return authBrowser, false } -// tenantAuthFor is the credential a tenant created under this mode should use. func (a authMode) tenantAuthFor() string { if a == authTenant { return config.AuthServiceAccount diff --git a/internal/tui/batchui.go b/internal/tui/batchui.go index 97a579a..e59d275 100644 --- a/internal/tui/batchui.go +++ b/internal/tui/batchui.go @@ -17,8 +17,6 @@ import ( "tyrtop.com/iontk/internal/ui" ) -// DrainLive draws batch progress on stderr. stdinTaken says the element list -// was read from stdin, which the progress UI must then leave alone. func DrainLive(ctx context.Context, events <-chan sase.Event, commands []string, total int, sink output.Sink, stdinTaken bool) (int, int, error) { opts := []tea.ProgramOption{ tea.WithContext(ctx), @@ -106,7 +104,6 @@ func waitForEvent(ch <-chan sase.Event) tea.Cmd { func (m batchModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { - case tea.WindowSizeMsg: m.width, m.height = msg.Width, msg.Height return m, nil diff --git a/internal/tui/bench_test.go b/internal/tui/bench_test.go index cd3b752..55bb92d 100644 --- a/internal/tui/bench_test.go +++ b/internal/tui/bench_test.go @@ -9,16 +9,6 @@ import ( "tyrtop.com/iontk/internal/sase/api" ) -// These exist because a search went from snappy to unusable and nobody could -// say which change did it. Measured on the fleet this is actually used on: -// 5600 elements, nearly all of them in HA pairs. -// -// keystroke 40ms before any of this -// 137ms after the green line and the content-sized columns -// 13ms after the per-row helpers stopped copying tuiModel -// -// A fleet the size of the one this is used on: 5600 elements, nearly all of -// them in HA pairs. func fleetModel(b *testing.B, n int) tuiModel { b.Helper() m := benchModel(b) @@ -68,7 +58,6 @@ func BenchmarkCursorMove(b *testing.B) { } } -// benchModel is pickModel's model without the *testing.T. func benchModel(b *testing.B) tuiModel { b.Helper() root := b.TempDir() diff --git a/internal/tui/clipboard.go b/internal/tui/clipboard.go index ab30060..bc6c08e 100644 --- a/internal/tui/clipboard.go +++ b/internal/tui/clipboard.go @@ -9,13 +9,10 @@ import ( "tyrtop.com/iontk/internal/ui" ) -// terminals cap OSC 52 and truncate in silence; cutting here can say so const clipLimit = 100 << 10 const copyKeyHint = "alt+y copy" -// OSC 52 never answers, so the footer has to admit that silence means -// the terminal refused it. func (m tuiModel) copyText(what, text string) (tuiModel, tea.Cmd) { text = strings.TrimRight(text, "\n") if strings.TrimSpace(text) == "" { @@ -34,7 +31,6 @@ func (m tuiModel) copyText(what, text string) (tuiModel, tea.Cmd) { return m, tea.SetClipboard(sent) } -// the newest output is the part being copied, and half a line is noise func clipTail(text string) (string, int) { if len(text) <= clipLimit { return text, 0 @@ -43,7 +39,6 @@ func clipTail(text string) (string, int) { if i := strings.IndexByte(cut, '\n'); i >= 0 && i+1 < len(cut) { cut = cut[i+1:] } - // a byte cut can land inside a rune cut = strings.ToValidUTF8(cut, "") return cut, len(text) - len(cut) } @@ -55,23 +50,6 @@ func size(n int) string { return fmt.Sprintf("%.1f kB", float64(n)/1024) } -// ids are what gets pasted into --element; a ticked selection wins -func (m tuiModel) copyElements() (tea.Model, tea.Cmd) { - rows, ids := m.selectedRows() - if len(ids) > 0 { - what := fmt.Sprintf("%d element ids", len(rows)) - // one per line, because that is what -f reads - return m.copyText(what, strings.Join(ids, "\n")) - } - el, ok := m.current() - if !ok { - m.status = "nothing to copy here" - return m, nil - } - return m.copyText(el.label()+"'s id", el.ID) -} - -// the whole buffer, not the window; a dead tab copies too func (m tuiModel) copySession() (tea.Model, tea.Cmd) { s := m.sessions.Active() if s == nil { @@ -81,14 +59,12 @@ func (m tuiModel) copySession() (tea.Model, tea.Cmd) { return m.copyText(s.Name+" scrollback", s.Buffer()) } -// the page as shown: the pane has a scroll offset and no cursor func (m tuiModel) copyDataPage() (tea.Model, tea.Cmd) { tab, ok := m.activeData() if !ok { m.status = "nothing to copy here" return m, nil } - // what a filter has narrowed it to, not the whole page page := m.visiblePage(tab) lines := make([]string, 0, len(page.lines)+1) lines = append(lines, tab.name) @@ -98,7 +74,6 @@ func (m tuiModel) copyDataPage() (tea.Model, tea.Cmd) { return m.copyText(tab.name+" data", strings.Join(lines, "\n")) } -// the open result, or the whole run when nothing is open func (m tuiModel) copyResults() (tea.Model, tea.Cmd) { idx, ok := m.currentResult() if ok && m.resOpen { @@ -116,7 +91,6 @@ func (m tuiModel) copyResults() (tea.Model, tea.Cmd) { return m.copyRunJSON() } -// half a JSON document parses as nothing, so an oversize run is refused func (m tuiModel) copyRunJSON() (tea.Model, tea.Cmd) { var b strings.Builder sink := output.NewSink(&b, output.FormatJSON, output.WithOrientation(m.batchOrient())) @@ -140,17 +114,6 @@ func (m tuiModel) copyRunJSON() (tea.Model, tea.Cmd) { return m.copyText(fmt.Sprintf("%d results as json", m.completedCount()), b.String()) } -// one per line, so one macro can be pasted into another -func (m tuiModel) copyMacro() (tea.Model, tea.Cmd) { - mac, ok := m.macroAt(m.macroCursor) - if !ok { - m.status = "nothing to copy here" - return m, nil - } - return m.copyText(mac.Name, strings.Join(mac.Commands, "\n")) -} - -// a pasted block is a list, like app.SplitCommands with commas func pasteCommands(text string) []string { var out []string for _, line := range strings.FieldsFunc(text, func(r rune) bool { return r == '\n' || r == '\r' }) { diff --git a/internal/tui/clipboard_test.go b/internal/tui/clipboard_test.go index ddbd17b..3b41988 100644 --- a/internal/tui/clipboard_test.go +++ b/internal/tui/clipboard_test.go @@ -11,9 +11,6 @@ import ( "tyrtop.com/iontk/internal/config" ) -// clipboardOf reads the OSC 52 payload back out of the command a copy -// returned. Bubble Tea's clipboard message is its own unexported type, so the -// text comes back through fmt rather than a type assertion. func clipboardOf(t *testing.T, cmd tea.Cmd) string { t.Helper() if cmd == nil { @@ -22,41 +19,19 @@ func clipboardOf(t *testing.T, cmd tea.Cmd) string { return fmt.Sprint(cmd()) } -// The id is the thing people paste into --element, so that is what the list -// copies. -func TestCopyingFromTheElementListTakesTheId(t *testing.T) { +func TestTheElementListNoLongerCopiesIds(t *testing.T) { m := pickModel(t) mm, cmd := m.Update(tea.KeyPressMsg(altKey('y'))) m = mm.(tuiModel) - if got := clipboardOf(t, cmd); got != "1" { - t.Errorf("clipboard = %q, want the highlighted element's id", got) + if cmd != nil { + t.Errorf("alt+y on the element list still copied %q", fmt.Sprint(cmd())) } - if !strings.Contains(m.status, "copied") { - t.Errorf("status = %q, want it to say what was copied", m.status) + if strings.Contains(m.status, "copied") { + t.Errorf("status = %q, want nothing copied", m.status) } - // OSC 52 is never acknowledged, so the report has to admit that silence - // means the terminal refused it. - if !strings.Contains(m.status, "OSC 52") { - t.Errorf("status = %q, want it to say a copy can be refused", m.status) - } -} - -// A ticked selection is a list someone built on purpose, and one id per line -// is the shape --elements-path reads. -func TestCopyingAselectionTakesEveryIdOnePerLine(t *testing.T) { - m := pickModel(t) - m.selected = map[string]bool{"1/1": true, "1/3": true} - m.rebuildRows() - - mm, cmd := m.Update(tea.KeyPressMsg(altKey('y'))) - m = mm.(tuiModel) - got := clipboardOf(t, cmd) - if got != "1\n3" { - t.Errorf("clipboard = %q, want both ids one per line", got) - } - if !strings.Contains(m.status, "2 element ids") { - t.Errorf("status = %q, want it to count what was copied", m.status) + if out := plain(m.viewPicking()); strings.Contains(out, "copy id") { + t.Errorf("the element list still advertises copy id:\n%s", out) } } @@ -75,8 +50,6 @@ func TestCopyingAnOpenResultTakesItsOutput(t *testing.T) { } } -// With nothing open the page is showing the run, so that is what it copies -- -// in the format a saved file would have had. func TestCopyingTheResultsPageTakesTheRunAsJSON(t *testing.T) { m := resultsModel(t) m.resOpen = false @@ -97,33 +70,23 @@ func TestCopyingTheResultsPageTakesTheRunAsJSON(t *testing.T) { } } -func TestCopyingAMacroTakesItsCommands(t *testing.T) { +func TestTheMacroPageNoLongerCopies(t *testing.T) { m := macroModel(t) m.macroCursor = 1 - mm, cmd := m.Update(tea.KeyPressMsg(altKey('y'))) - m = mm.(tuiModel) - if got := clipboardOf(t, cmd); got != "dump interface status all\ndump vpn count" { - t.Errorf("clipboard = %q, want one command per line", got) - } -} - -func TestCopyingWithNothingToCopySaysSo(t *testing.T) { - m := macroModel(t) - m.macroCursor = 0 // the "+ new macro" row, which is an action not a macro - mm, cmd := m.Update(tea.KeyPressMsg(altKey('y'))) m = mm.(tuiModel) if cmd != nil { - t.Error("something was sent to the clipboard from the new-macro row") + t.Errorf("alt+y on the macro page still copied %q", fmt.Sprint(cmd())) + } + if strings.Contains(m.status, "copied") { + t.Errorf("status = %q, want nothing copied", m.status) } - if m.status == "" { - t.Error("nothing said why the copy did nothing") + if out := plain(m.viewMacros()); strings.Contains(out, "alt+y") { + t.Errorf("the macro page still offers alt+y:\n%s", out) } } -// A payload past the cap is cut at a line boundary, keeps the end, and says -// so: terminals and tmux cap OSC 52 and the far end truncates silently. func TestAnOversizeCopyKeepsTheTailAndSaysItWasCut(t *testing.T) { m := pickModel(t) var b strings.Builder @@ -148,8 +111,6 @@ func TestAnOversizeCopyKeepsTheTailAndSaysItWasCut(t *testing.T) { } } -// A run too big to paste is pointed at the thing that can hold it: half a JSON -// document parses as nothing. func TestARunTooBigForTheClipboardOffersTheFileInstead(t *testing.T) { m := resultsModel(t) big := strings.Repeat("x", clipLimit) @@ -164,8 +125,6 @@ func TestARunTooBigForTheClipboardOffersTheFileInstead(t *testing.T) { } } -// A shell copies what it has said rather than the window: the output that -// scrolled away is most of the reason to copy one at all. func TestCopyingAShellTakesItsScrollback(t *testing.T) { m, conn := favModel(t) conn.feed("S0100-RTR-01# dump device status\r\nSerial: 1234\r\n") @@ -184,20 +143,14 @@ func TestCopyingAShellTakesItsScrollback(t *testing.T) { if !strings.Contains(m.status, "scrollback") { t.Errorf("status = %q, want it to name what was copied", m.status) } - // Nothing was typed at the device: the key is iontk's, not the shell's. if strings.Contains(conn.sent(), "y") { t.Errorf("alt+y reached the device: %q", conn.sent()) } } -// The batch box is comma separated, so a pasted block of commands is a list. -// textinput would otherwise flatten the newlines into spaces and make one -// long command of the lot. func TestPastingCommandsIntoTheBatchBoxSplitsThem(t *testing.T) { m := pickModel(t) m.phase = phaseCommand - // textinput ignores input unless something focused it, which entering the - // command page does. m.cmdInput.Focus() mm, _ := m.Update(tea.PasteMsg{Content: "dump device status\ndump interface status all\n\n uptime \n"}) @@ -215,8 +168,6 @@ func TestPastingCommandsIntoTheBatchBoxSplitsThem(t *testing.T) { } } -// The macro form is already one field per command, so a pasted block fills a -// line each and leaves the empty line the ghost row points at. func TestPastingCommandsIntoTheMacroFormFillsALinePerCommand(t *testing.T) { m := macroModel(t) next, _ := m.openMacroForm(config.Macro{}) @@ -235,7 +186,6 @@ func TestPastingCommandsIntoTheMacroFormFillsALinePerCommand(t *testing.T) { if m.formCursor != len(m.form)-1 { t.Errorf("formCursor = %d, want the empty line at the bottom", m.formCursor) } - // The labels are the run order, and a paste must not leave them wrong. for i := 1; i < len(m.form); i++ { if want := macroCommandLabel(i - 1); m.form[i].label != want { t.Errorf("field %d is labelled %q, want %q", i, m.form[i].label, want) @@ -243,8 +193,6 @@ func TestPastingCommandsIntoTheMacroFormFillsALinePerCommand(t *testing.T) { } } -// A data pane copies the whole page, not the window: the page is what the -// refetch built and what someone wants in a ticket. func TestCopyingADataPaneTakesTheWholePagePlain(t *testing.T) { m, set := fixtureTUIWith(t, haFixtureYAML) m.phase = phasePicking @@ -267,7 +215,6 @@ func TestCopyingADataPaneTakesTheWholePagePlain(t *testing.T) { if strings.Contains(got, "\x1b[") { t.Error("the copied page carries escape codes") } - // The window is 24 rows; the page is longer than that. if n := strings.Count(got, "\n"); n < 24 { t.Errorf("the copy has %d lines, want the whole page rather than the window", n) } diff --git a/internal/tui/colourpick.go b/internal/tui/colourpick.go new file mode 100644 index 0000000..9c906ec --- /dev/null +++ b/internal/tui/colourpick.go @@ -0,0 +1,360 @@ +package tui + +import ( + "fmt" + "strconv" + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "tyrtop.com/iontk/internal/ui" +) + +type colourPick struct { + on bool + role string + was string + grid bool + + list string + idx int + label string + + x, y int + val float64 + + row, col int +} + +const ( + wheelMinRows = 5 + wheelMaxRows = 11 + previewLines = 13 +) + +func (m tuiModel) wheelSize() (cols, rows int) { + spare := max(6, m.height-6) - previewLines - 5 + rows = min(wheelMaxRows, max(wheelMinRows, spare)) + cols = rows * 2 + if w := max(20, m.width-16) - 4; cols > w { + cols = max(11, w) + } + return cols, rows +} + +func (m tuiModel) openTabPicker(list string, i int, label string) (tea.Model, tea.Cmd) { + next, cmd := m.openPickerOn(colourPick{ + on: true, list: list, idx: i, label: label, + was: m.tabColourAt(list, i), + }) + return next, cmd +} + +func (m tuiModel) openPicker(role string) (tea.Model, tea.Cmd) { + return m.openPickerOn(colourPick{ + on: true, role: role, label: ui.RoleLabel(role), was: m.themeDraft.Role(role), + }) +} + +func (m tuiModel) openPickerOn(p colourPick) (tea.Model, tea.Cmd) { + cols, rows := m.wheelSize() + + p.grid = !ui.TrueColor() + if r, c, ok := ui.GridCell(p.was); ok { + p.grid, p.row, p.col = true, r, c + } + p.x, p.y, p.val = ui.WheelCell(p.was, cols, rows) + + m.pick = p + m.status = "" + return m, nil +} + +func (m tuiModel) onPickerKey(key string) (tea.Model, tea.Cmd) { + cols, rows := m.wheelSize() + + switch key { + case "esc", "q": + label, was := m.pick.label, m.pick.was + nm, err := m.setPicked(was) + if err != nil { + nm.status = err.Error() + } + m = nm + m.pick = colourPick{} + m.status = label + " left as " + describeColour(was) + return m.applyWorking() + + case "enter": + was := m.pick.was + kept := m.themeDraft.Clone() + if back, err := m.setPicked(was); err == nil { + m = m.remember(back.themeDraft) + } + m.themeDraft = kept + m.pick = colourPick{} + return m.saveDraft() + + case "tab": + m.pick.grid = !m.pick.grid + cur := m.themeDraft.Role(m.pick.role) + if m.pick.grid { + idx := cur + if _, _, ok := ui.GridCell(cur); !ok { + idx = strconv.Itoa(ui.NearestIndex(cur)) + } + m.pick.row, m.pick.col, _ = ui.GridCell(idx) + return m, nil + } + m.pick.x, m.pick.y, m.pick.val = ui.WheelCell(cur, cols, rows) + return m, nil + + case "t": + if m.pick.list != "" { + m.status = "tab a colour off the chart, or type one into a role" + return m, nil + } + return m.openColourForm(m.pick.role) + + case "ctrl+d", "x": + if m.pick.list != "" { + return m, nil + } + next, cmd := m.resetRole(m.pick.role) + nm := next.(tuiModel) + if !nm.pick.on { + return nm, cmd + } + nm.pick.x, nm.pick.y, nm.pick.val = + ui.WheelCell(nm.themeDraft.Role(nm.pick.role), cols, rows) + if r, c, ok := ui.GridCell(nm.themeDraft.Role(nm.pick.role)); ok { + nm.pick.row, nm.pick.col = r, c + } + return nm, cmd + } + + if m.pick.grid { + return m.moveInGrid(key) + } + return m.moveOnWheel(key, cols, rows) +} + +func (m tuiModel) moveOnWheel(key string, cols, rows int) (tea.Model, tea.Cmd) { + dx, dy := 0, 0 + switch key { + case "left", "h": + dx = -1 + case "right", "l": + dx = 1 + case "up", "k": + dy = -1 + case "down", "j": + dy = 1 + + case "+", "=", "pgup", "K": + m.pick.val = min(1, m.pick.val+0.08) + return m.applyPicked() + case "-", "_", "pgdown", "J": + m.pick.val = max(0, m.pick.val-0.08) + return m.applyPicked() + default: + return m, nil + } + + x, y := m.pick.x+dx, m.pick.y+dy + if ui.WheelAt(x, y, cols, rows, m.pick.val) == "" { + return m, nil + } + m.pick.x, m.pick.y = x, y + return m.applyPicked() +} + +func (m tuiModel) moveInGrid(key string) (tea.Model, tea.Cmd) { + chart := ui.Grid256() + switch key { + case "left", "h": + m.pick.col = max(0, m.pick.col-1) + case "right", "l": + m.pick.col = min(len(chart[m.pick.row])-1, m.pick.col+1) + case "up", "k": + m.pick.row = max(0, m.pick.row-1) + case "down", "j": + m.pick.row = min(len(chart)-1, m.pick.row+1) + default: + return m, nil + } + m.pick.col = min(m.pick.col, len(chart[m.pick.row])-1) + return m.applyPicked() +} + +func (m tuiModel) pickedValue() string { + if m.pick.grid { + chart := ui.Grid256() + row := min(max(m.pick.row, 0), len(chart)-1) + col := min(max(m.pick.col, 0), len(chart[row])-1) + return strconv.Itoa(chart[row][col]) + } + cols, rows := m.wheelSize() + return ui.WheelAt(m.pick.x, m.pick.y, cols, rows, m.pick.val) +} + +func (m tuiModel) applyPicked() (tea.Model, tea.Cmd) { + v := m.pickedValue() + if v == "" { + return m, nil + } + nm, err := m.setPicked(v) + if err != nil { + m.status = err.Error() + return m, nil + } + return nm.applyWorking() +} + +func (m tuiModel) setPicked(v string) (tuiModel, error) { + if m.pick.list != "" { + return m.setTabColour(m.pick.list, m.pick.idx, v) + } + return m, m.themeDraft.SetRole(m.pick.role, v) +} + +func (m tuiModel) pickedCurrent() string { + if m.pick.list != "" { + return m.tabColourAt(m.pick.list, m.pick.idx) + } + return m.themeDraft.Role(m.pick.role) +} + +func (m tuiModel) applyWorking() (tea.Model, tea.Cmd) { + if m.themeEdit { + return m.applyTheme(m.themeDraft) + } + t, _ := ui.ThemeByName(m.cfg.Theme) + return m.applyTheme(t) +} + +func (m tuiModel) pickedStyle() lipgloss.Style { + if m.pick.list != "" { + return ui.ColourStyle(m.pickedCurrent()) + } + return m.themeDraft.RoleStyle(m.pick.role) +} + +func describeColour(spec string) string { + if strings.TrimSpace(spec) == "" { + return "unset" + } + return spec +} + +func (m tuiModel) pickerContent() []string { + role := m.pick.role + value := m.pickedCurrent() + shown := value + if shown == "" { + shown = "unset" + } + + out := []string{ + ui.StyleCmd.Render("Picking "+m.pick.label) + " " + + m.pickedStyle().Render("████") + " " + + ui.StyleElement.Render(shown) + + ui.StyleDim.Render(" "+roleHelp[role]), + "", + } + + if m.pick.grid { + out = append(out, m.chartLines()...) + } else { + out = append(out, m.wheelLines()...) + out = append(out, "", m.valueRamp()) + } + + where := "disc" + if m.pick.grid { + where = "256 chart" + } + return append(out, "", + ui.StyleDim.Render("↑↓←→ move on the "+where+ + " · tab the other one · enter keep it · esc put it back")) +} + +func (m tuiModel) wheelLines() []string { + cols, rows := m.wheelSize() + cursorRow, cursorCol := m.pick.y/2, m.pick.x + + out := make([]string, 0, rows) + for r := range rows { + var b strings.Builder + for x := range cols { + top := ui.WheelAt(x, r*2, cols, rows, m.pick.val) + bottom := ui.WheelAt(x, r*2+1, cols, rows, m.pick.val) + + if r == cursorRow && x == cursorCol { + under := ui.WheelAt(m.pick.x, m.pick.y, cols, rows, m.pick.val) + b.WriteString(lipgloss.NewStyle(). + Foreground(lipgloss.Color(ui.Contrast(under))). + Background(lipgloss.Color(under)).Render("+")) + continue + } + + switch { + case top == "" && bottom == "": + b.WriteString(" ") + case top == "": + b.WriteString(lipgloss.NewStyle(). + Foreground(lipgloss.Color(bottom)).Render("▄")) + case bottom == "": + b.WriteString(lipgloss.NewStyle(). + Foreground(lipgloss.Color(top)).Render("▀")) + default: + b.WriteString(lipgloss.NewStyle(). + Foreground(lipgloss.Color(top)). + Background(lipgloss.Color(bottom)).Render("▀")) + } + } + out = append(out, " "+b.String()) + } + return out +} + +func (m tuiModel) valueRamp() string { + cols, rows := m.wheelSize() + hue := ui.WheelAt(m.pick.x, m.pick.y, cols, rows, 1) + var b strings.Builder + for i := range 10 { + v := float64(i+1) / 10 + c, _ := ui.RGBOf(hue) + h, s, _ := ui.HSVOf(c) + cell := "██" + if int(m.pick.val*10+0.5) == i+1 { + cell = "▐▌" + } + b.WriteString(lipgloss.NewStyle(). + Foreground(lipgloss.Color(ui.HexFromHSV(h, s, v))).Render(cell)) + } + return " " + b.String() + ui.StyleDim.Render( + fmt.Sprintf(" +/- brightness %d%%", int(m.pick.val*100+0.5))) +} + +func (m tuiModel) chartLines() []string { + chart := ui.Grid256() + out := make([]string, 0, len(chart)+2) + for r, cells := range chart { + var b strings.Builder + for c, idx := range cells { + spec := strconv.Itoa(idx) + if r == m.pick.row && c == m.pick.col { + b.WriteString(lipgloss.NewStyle(). + Foreground(lipgloss.Color(ui.Contrast(spec))). + Background(lipgloss.Color(spec)).Render("+")) + continue + } + b.WriteString(lipgloss.NewStyle(). + Foreground(lipgloss.Color(spec)).Render("█")) + } + out = append(out, " "+b.String()) + } + return append(out, "", + ui.StyleDim.Render(" the first sixteen, then the cube, then the greys")) +} diff --git a/internal/tui/colourpick_test.go b/internal/tui/colourpick_test.go new file mode 100644 index 0000000..caaf90b --- /dev/null +++ b/internal/tui/colourpick_test.go @@ -0,0 +1,368 @@ +package tui + +import ( + "image/color" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + + "tyrtop.com/iontk/internal/app" + "tyrtop.com/iontk/internal/ui" +) + +func pickerOn(t *testing.T, theme, role string) tuiModel { + t.Helper() + m := editorOn(t, theme) + + roles := editRoles() + for i, r := range roles { + if r == role { + m.themeRow = i + } + } + if roles[m.themeRow] != role { + t.Fatalf("there is no %q role to pick for", role) + } + + next, _ := m.onSettingsKey("enter") + m = next.(tuiModel) + if !m.pick.on { + t.Fatalf("enter on %s did not open the picker", role) + } + return m +} + +func TestMovingOnTheDiscAppliesTheColourWithoutSavingIt(t *testing.T) { + m := pickerOn(t, "nord", "ok") + before := m.themeDraft.OK + + next, _ := m.onSettingsKey("left") + m = next.(tuiModel) + if m.themeDraft.OK == before { + t.Fatalf("ok is still %q after moving on the disc", before) + } + if !strings.HasPrefix(m.themeDraft.OK, "#") { + t.Errorf("the disc gave %q, want a hex", m.themeDraft.OK) + } + if got := ui.CurrentTheme().OK; got != m.themeDraft.OK { + t.Errorf("CurrentTheme().OK = %q, want the colour under the cursor %q", + got, m.themeDraft.OK) + } + + path := filepath.Join(app.ThemesPath(m.store), ui.ThemeFileName("nord")) + if b, err := os.ReadFile(path); err == nil && strings.Contains(string(b), m.themeDraft.OK) { + t.Errorf("%s already holds %q before enter:\n%s", path, m.themeDraft.OK, b) + } + + picked := m.themeDraft.OK + next, _ = m.onSettingsKey("enter") + m = next.(tuiModel) + if m.pick.on { + t.Error("enter did not close the picker") + } + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("enter did not write the scheme's file: %v", err) + } + if !strings.Contains(string(b), picked) { + t.Errorf("%s does not hold the picked %q:\n%s", path, picked, b) + } +} + +func TestEscapingThePickerPutsTheOldColourBack(t *testing.T) { + m := pickerOn(t, "nord", "warn") + original := m.themeDraft.Warn + + next, _ := m.onSettingsKey("right") + m = next.(tuiModel) + if m.themeDraft.Warn == original { + t.Fatal("nothing changed, so there is nothing to put back") + } + + next, _ = m.onSettingsKey("esc") + m = next.(tuiModel) + if m.pick.on { + t.Error("esc did not close the picker") + } + if m.themeDraft.Warn != original { + t.Errorf("warn = %q after esc, want the original %q", m.themeDraft.Warn, original) + } + if got := ui.CurrentTheme().Warn; got != original { + t.Errorf("the frame still shows %q, want %q", got, original) + } + if !m.themeEdit { + t.Error("esc out of the picker left the editor too") + } +} + +func TestTheChartStoresAnIndexRatherThanAHex(t *testing.T) { + m := pickerOn(t, "nord", "element") + + next, _ := m.onSettingsKey("tab") + m = next.(tuiModel) + if !m.pick.grid { + t.Fatal("tab did not switch to the chart") + } + + next, _ = m.onSettingsKey("down") + m = next.(tuiModel) + next, _ = m.onSettingsKey("right") + m = next.(tuiModel) + + got := m.themeDraft.Element + n, err := strconv.Atoi(got) + if err != nil || n < 0 || n > 255 { + t.Errorf("element = %q off the chart, want an index 0-255", got) + } + if got := ui.CurrentTheme().Element; got != m.themeDraft.Element { + t.Errorf("the chart colour was not applied: frame has %q", got) + } +} + +func TestBrightnessChangesTheColourUnderTheCursor(t *testing.T) { + m := pickerOn(t, "nord", "cmd") + next, _ := m.onSettingsKey("left") + m = next.(tuiModel) + before := m.themeDraft.Cmd + + next, _ = m.onSettingsKey("-") + m = next.(tuiModel) + if m.themeDraft.Cmd == before { + t.Errorf("cmd is still %q after -", before) + } + if m.pick.val >= 1 { + t.Errorf("brightness = %v, want it below the top", m.pick.val) + } +} + +func TestThePickerDrawsTheDiscAndNamesItsKeys(t *testing.T) { + m := pickerOn(t, "Ben's Theme", "row") + + out := m.viewSettings() + if !strings.Contains(out, "▀") && !strings.Contains(out, "▄") { + t.Errorf("the disc was not drawn:\n%s", plain(out)) + } + flat := plain(out) + for _, want := range []string{"Picking Row", "brightness", "the other one", "Preview · Ben's Theme"} { + if !strings.Contains(flat, want) { + t.Errorf("the picker does not say %q:\n%s", want, flat) + } + } + + next, _ := m.onSettingsKey("tab") + m = next.(tuiModel) + if chart := plain(m.viewSettings()); !strings.Contains(chart, "the cube") { + t.Errorf("the chart was not drawn:\n%s", chart) + } +} + +func TestTheDiscFitsTheTerminalItIsDrawnIn(t *testing.T) { + m := pickerOn(t, "nord", "ok") + for _, h := range []int{24, 30, 44, 60} { + m.height = h + cols, rows := m.wheelSize() + if rows < wheelMinRows || rows > wheelMaxRows { + t.Errorf("height %d gave %d rows, want %d-%d", h, rows, wheelMinRows, wheelMaxRows) + } + if cols < rows { + t.Errorf("height %d gave %dx%d, which is taller than it is wide", h, cols, rows) + } + if lines := len(m.wheelLines()); lines != rows { + t.Errorf("height %d drew %d lines for %d rows", h, lines, rows) + } + } +} + +func TestRenderTheColourPicker(t *testing.T) { + m := pickerOn(t, "Ben's Theme", "element") + t.Log("\n" + m.viewSettings()) + next, _ := m.onSettingsKey("tab") + t.Log("\n" + next.(tuiModel).viewSettings()) +} + +func TestTheFrameCarriesTheSchemesBackground(t *testing.T) { + pinTheme(t) + m := settingsModel(t) + m.width, m.height = 110, 44 + m.setSection, m.setFocus = secTheme, focusContent + + use := func(name string) tuiModel { + t.Helper() + for i, th := range ui.Themes() { + if th.Name == name { + m.setCursor = i + 1 + } + } + next, _ := m.onSettingsKey("enter") + return next.(tuiModel) + } + + dark := use("Ben's Theme") + if got := dark.View().BackgroundColor; got != nil { + t.Errorf("the background went out before the terminal was asked: %v", got) + } + answered, _ := dark.Update(colourProbeDoneMsg{}) + dark = answered.(tuiModel) + + v := dark.View() + if v.BackgroundColor == nil { + t.Error("the frame carries no background for a scheme that sets one") + } + if v.ForegroundColor == nil { + t.Error("the frame carries no default text colour") + } + + plainTheme := use("classic") + if got := plainTheme.View().BackgroundColor; got != nil { + t.Errorf("classic set the terminal background to %v, want it left alone", got) + } +} + +func TestTheBackgroundIsPickedLikeAnyOtherRole(t *testing.T) { + probeAnswered(t) + m := pickerOn(t, "nord", "background") + + next, _ := m.onSettingsKey("left") + m = next.(tuiModel) + if m.themeDraft.Bg == "" { + t.Fatal("the disc did not set a background") + } + if got := ui.CurrentTheme().Bg; got != m.themeDraft.Bg { + t.Errorf("CurrentTheme().Bg = %q, want the picked %q", got, m.themeDraft.Bg) + } + if m.View().BackgroundColor == nil { + t.Error("the picked background never reached the frame") + } + + next, _ = m.onSettingsKey("esc") + m = next.(tuiModel) + out := plain(m.viewSettings()) + for _, want := range []string{"Background", "Other"} { + if !strings.Contains(out, want) { + t.Errorf("the editor does not list %q:\n%s", want, out) + } + } +} + +func TestSwitchingViewsDoesNotChangeTheColour(t *testing.T) { + m := pickerOn(t, "Ben's Theme", "element") + before := m.themeDraft.Element + + next, _ := m.onSettingsKey("tab") + m = next.(tuiModel) + if m.themeDraft.Element != before { + t.Errorf("tab changed element from %q to %q", before, m.themeDraft.Element) + } + + chart := ui.Grid256() + idx := chart[m.pick.row][m.pick.col] + if idx < 16 { + t.Errorf("the chart cursor landed on %d, want it near the hex it came from", idx) + } + want, _ := ui.RGBOf(before) + got, _ := ui.RGBOf(strconv.Itoa(idx)) + for i := range 3 { + if d := int(want[i]) - int(got[i]); d > 40 || d < -40 { + t.Errorf("the chart cursor is on %d (%v), which is nowhere near %q (%v)", + idx, got, before, want) + break + } + } + + next, _ = m.onSettingsKey("tab") + m = next.(tuiModel) + if m.pick.grid { + t.Error("tab did not go back to the disc") + } + if m.themeDraft.Element != before { + t.Errorf("going back changed element to %q", m.themeDraft.Element) + } +} + +func probeAnswered(t *testing.T) { + t.Helper() + before := termColour + t.Cleanup(func() { termColour = before }) + termColour.probed = true +} + +func TestTheTerminalsOwnColoursAreSetRatherThanReset(t *testing.T) { + pinTheme(t) + before := termColour + t.Cleanup(func() { termColour = before }) + + termColour = termColourState{} + if bg, fg := frameColours(); bg != nil || fg != nil { + t.Errorf("frameColours() = %v/%v before the terminal answered", bg, fg) + } + + rememberTermColour(colourOf(0x12, 0x34, 0x56), colourOf(0xab, 0xcd, 0xef)) + if termColour.hexBg != "#123456" || termColour.hexFg != "#abcdef" { + t.Fatalf("the report was not kept: %q / %q", termColour.hexBg, termColour.hexFg) + } + if !termColour.probed { + t.Error("both halves answered, so the frame should stop holding its colours") + } + + if ok := ui.UseTheme("classic"); !ok { + t.Fatal("classic is not in the set") + } + bg, _ := frameColours() + if got := hexOfColour(bg); got != "#123456" { + t.Errorf("on classic the frame carries %q, want the terminal's own #123456", got) + } + + if ok := ui.UseTheme("Ben's Theme"); !ok { + t.Fatal("Ben's Theme is not in the set") + } + bg, _ = frameColours() + if got := hexOfColour(bg); got != "#1e1e2e" { + t.Errorf("on Ben's Theme the frame carries %q, want the scheme's own", got) + } + + var buf strings.Builder + restoreTermColour(&buf) + if got := buf.String(); got != "\x1b]11;#123456\x07\x1b]10;#abcdef\x07" { + t.Errorf("restore wrote %q", got) + } + + rememberTermColour(colourOf(0, 0, 0), nil) + if termColour.hexBg != "#123456" { + t.Errorf("bg = %q, want the colour from before iontk set one", termColour.hexBg) + } + + termColour = termColourState{probed: true} + ui.UseTheme("classic") + buf.Reset() + if bg, fg := frameColours(); bg != nil || fg != nil { + t.Errorf("frameColours() = %v/%v with no answer and a bare scheme", bg, fg) + } + restoreTermColour(&buf) + if got := buf.String(); got != "" { + t.Errorf("restore wrote %q with no answer to go on", got) + } +} + +func TestTheDebugPageSaysWhatTheTerminalReported(t *testing.T) { + before := termColour + t.Cleanup(func() { termColour = before }) + + termColour = termColourState{} + if got := termColourLine(); !strings.Contains(got, "asking") { + t.Errorf("before an answer the line reads %q", got) + } + termColour = termColourState{probed: true} + if got := termColourLine(); !strings.Contains(got, "did not answer") { + t.Errorf("with no answer the line reads %q", got) + } + termColour = termColourState{probed: true, hexBg: "#1a1b26", hexFg: "#c0caf5"} + if got := termColourLine(); !strings.Contains(got, "#1a1b26") || !strings.Contains(got, "#c0caf5") { + t.Errorf("the line reads %q, want both colours", got) + } +} + +func colourOf(r, g, b uint8) color.Color { + return color.RGBA{R: r, G: g, B: b, A: 0xff} +} diff --git a/internal/tui/commands.go b/internal/tui/commands.go index db2452e..41e0fe4 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -10,12 +10,8 @@ import ( "tyrtop.com/iontk/internal/ioncmd" ) -// commandsSavedMsg is a save that failed. A successful one says nothing: the -// store is written for the next run, and nothing on screen depends on it. type commandsSavedMsg struct{ err string } -// countUse is one use, in memory only, for a caller recording several before -// it saves. func (m *tuiModel) countUse(command, model string) { if m.cmds == nil { m.cmds = &config.CommandStore{} @@ -23,11 +19,6 @@ func (m *tuiModel) countUse(command, model string) { m.cmds.Record(command, model, time.Now()) } -// recordUse counts a command against the model it was run on and hands back -// the save. The save is a command rather than a call because it touches the -// disk and this runs on a keypress. -// -// Named apart from m.record, which builds a search.Record for the matcher. func (m *tuiModel) recordUse(command, model string) tea.Cmd { m.countUse(command, model) return saveCommands(m.store, m.cmds) @@ -37,7 +28,6 @@ func saveCommands(st *config.Store, c *config.CommandStore) tea.Cmd { if st == nil || c == nil { return nil } - // Copied: the model goes on being edited while this runs. snapshot := *c return func() tea.Msg { if err := st.SaveCommands(&snapshot); err != nil { @@ -48,17 +38,12 @@ func saveCommands(st *config.Store, c *config.CommandStore) tea.Cmd { } func (m tuiModel) onCommandsSaved(msg commandsSavedMsg) (tea.Model, tea.Cmd) { - // Said once, in the footer, and not repeated: a history that cannot be - // written is worth knowing about but must not interrupt anything. if msg.err != "" && m.status == "" { m.status = "could not save command history: " + msg.err } return m, nil } -// modelOf is the hardware model of an element, which is what makes history -// and favourites model-aware. Empty for an element the list has not got -- -// a session reattached after the cache was dropped, say. func (m tuiModel) modelOf(tsg, eid string) string { if el, ok := m.elementByKey(tsg + "/" + eid); ok { return el.Model @@ -66,8 +51,6 @@ func (m tuiModel) modelOf(tsg, eid string) string { return "" } -// recordSessionLine counts what a shell session is about to send. It is taken -// before the key is forwarded, because the device echoes the line away. func (m *tuiModel) recordSessionLine() tea.Cmd { s := m.sessions.Active() if s == nil { @@ -80,9 +63,6 @@ func (m *tuiModel) recordSessionLine() tea.Cmd { return m.recordUse(line, m.modelOf(m.tsgForElement(s.ElementID), s.ElementID)) } -// recordRun counts a batch run's commands once per model in it. Per model -// rather than per element: ten 1200s running the same command is one fact -// about the 1200, not ten. func (m *tuiModel) recordRun(cmds []string, rows []elementRow) tea.Cmd { seen := map[string]bool{} for _, r := range rows { @@ -97,8 +77,6 @@ func (m *tuiModel) recordRun(cmds []string, rows []elementRow) tea.Cmd { return saveCommands(m.store, m.cmds) } -// activeModel is the model of the element the session in front of the user is -// on, which is what /fc ranks for. func (m tuiModel) activeModel() string { s := m.sessions.Active() if s == nil { @@ -107,9 +85,6 @@ func (m tuiModel) activeModel() string { return m.modelOf(m.tsgForElement(s.ElementID), s.ElementID) } -// setFreq fills /fc from the history. The query decides membership and the -// ranking decides order: most-used first is the point of the list, so a fuzzy -// score must not reorder it. func (m tuiModel) setFreq(q string) tuiModel { model := m.activeModel() ranked := m.cmds.Ranked(model) @@ -143,10 +118,6 @@ func (m tuiModel) setFreq(q string) tuiModel { return m } -// freqRow is one line of the list: the command, how often it has been run, -// and the model when it was not this one -- a 1200 command offered on a 3200 -// is what the stored model is there to show. With no model to compare -// against, every row would carry a tag that says nothing, so none do. func freqRow(u config.CommandUse, model string) string { row := u.Command + " \u00d7" + strconv.Itoa(u.Uses) if model != "" && u.Model != "" && u.Model != model { @@ -162,8 +133,6 @@ func (m tuiModel) freqAt(i int) (config.CommandUse, bool) { return m.freq[i], true } -// forgetFreq drops the highlighted entry. A history nobody can prune fills -// with typos and stops being a shortlist. func (m tuiModel) forgetFreq() (tea.Model, tea.Cmd) { use, ok := m.freqAt(m.slashCursor) if !ok { diff --git a/internal/tui/commands_test.go b/internal/tui/commands_test.go index c18d723..af4c71c 100644 --- a/internal/tui/commands_test.go +++ b/internal/tui/commands_test.go @@ -12,8 +12,6 @@ import ( "tyrtop.com/iontk/internal/sase/api" ) -// A model whose elements carry hardware models, since that is what history is -// keyed on. func historyModel(t *testing.T) tuiModel { t.Helper() m := pickModel(t) @@ -24,8 +22,6 @@ func historyModel(t *testing.T) tuiModel { return m } -// What a shell sends is counted as it is sent, which is what fills /fc -// without anyone having to save anything. func TestEnterInAShellCountsWhatWasTyped(t *testing.T) { m := historyModel(t) m.phase = phaseSession @@ -47,13 +43,10 @@ func TestEnterInAShellCountsWhatWasTyped(t *testing.T) { if got[0].Command != "dump device status" { t.Errorf("command = %q, want what was typed", got[0].Command) } - // The model it ran on, not just the command: the same text means - // different things on different hardware. if got[0].Model != "ion 1200" { t.Errorf("model = %q, want the element's", got[0].Model) } - // And the keypress hands back the save, so the next run has it. if cmd == nil { t.Fatal("enter queued no save") } @@ -67,8 +60,6 @@ func TestEnterInAShellCountsWhatWasTyped(t *testing.T) { } } -// Enter on an empty line is the device's own prompt being nudged, not a -// command. func TestEnterOnAnEmptyLineCountsNothing(t *testing.T) { m := historyModel(t) m.phase = phaseSession @@ -88,8 +79,6 @@ func TestEnterOnAnEmptyLineCountsNothing(t *testing.T) { } } -// A run of ten 1200s doing the same thing is one fact about the 1200, not -// ten, so a batch counts per model rather than per element. func TestABatchRunCountsOncePerModel(t *testing.T) { m := historyModel(t) rows := []elementRow{ @@ -123,8 +112,6 @@ func TestABatchRunCountsOncePerModel(t *testing.T) { } } -// /fc is the history: this model's commands first, most-used first inside -// that, and a command from another model named as such rather than hidden. func TestFrequentCommandsAreRankedForThisModel(t *testing.T) { m := historyModel(t) m.phase = phaseSession @@ -161,25 +148,20 @@ func TestFrequentCommandsAreRankedForThisModel(t *testing.T) { if !strings.Contains(m.slashItems[0], "×2") { t.Errorf("first row = %q, want the count on it", m.slashItems[0]) } - // No tag on a command captured on the model in front of the user: it - // would be on every row and say nothing. if strings.Contains(m.slashItems[0], "ion 1200") { t.Errorf("first row = %q, want no model tag for this model", m.slashItems[0]) } - // Run five times, but on other hardware, so it comes last and says so. last := m.slashItems[2] if !strings.HasPrefix(last, "dump interface status 8") || !strings.Contains(last, "ion 3200") { t.Errorf("last row = %q, want the other model's command, named", last) } - // The query decides which rows show; the ranking still decides the order. m.slashInput.SetValue("dump") m = m.refilterSlash() if len(m.slashItems) != 2 || !strings.HasPrefix(m.slashItems[0], "dump device status") { t.Errorf("filtered items = %v, want the two dumps, this model's first", m.slashItems) } - // Enter inserts without sending, like the reference palette. next, _ = m.activateSlash() m = next.(tuiModel) if m.slash != slashOff { @@ -195,7 +177,6 @@ func TestFrequentCommandsAreRankedForThisModel(t *testing.T) { t.Fatalf("the command never reached the session; sent=%q", conn.sent()) } -// A history nobody can prune fills with typos and stops being a shortlist. func TestCtrlDForgetsAHistoryEntry(t *testing.T) { m := historyModel(t) m.phase = phaseSession @@ -226,8 +207,6 @@ func TestCtrlDForgetsAHistoryEntry(t *testing.T) { if !strings.Contains(m.status, "forgot") { t.Errorf("status = %q, want it to say what went", m.status) } - // The list it is showing goes with it, or the next enter inserts a - // command that no longer exists. if len(m.slashItems) != 0 { t.Errorf("items = %v, want the row gone too", m.slashItems) } @@ -245,8 +224,6 @@ func TestCtrlDForgetsAHistoryEntry(t *testing.T) { } } -// The verbs are resolved by prefix before anything fuzzy, so one letter still -// picks one: "c" is a subsequence of both "commands" and "fc". func TestOneLetterStillPicksAVerb(t *testing.T) { tests := map[string]string{ "c": "commands", diff --git a/internal/tui/compare.go b/internal/tui/compare.go index 79c5ee0..bcefecd 100644 --- a/internal/tui/compare.go +++ b/internal/tui/compare.go @@ -16,9 +16,6 @@ import ( "tyrtop.com/iontk/internal/ui" ) -// compareState is a tab's device half. It is tab-wide rather than per pane -// because every view's commands run in one session, and keyed by view for -// rendering, because each view asks its own questions. type compareState struct { loading bool err string @@ -34,16 +31,10 @@ type compareDoneMsg struct { byView map[string]dataview.Comparison } -// deviceRunner is the CLI half of a comparison. ToolkitSession is what a batch -// run uses, so this inherits the dial retry and the pager handling instead of -// growing a second copy of either. type deviceRunner interface { ToolkitSession(ctx context.Context, eid, prompt, user, pass string, cmds []string) ([]ioncli.CommandOutput, error) } -// runnerFor is fixture mode's one substitution, the same place dialerFor makes -// it: the playback device, reached through the real ioncli code, so autologin -// and the pager scraper are exercised rather than skipped. func (m tuiModel) runnerFor(c *api.Client) deviceRunner { if app.Fixture != nil { return app.Fixture.SessionClient(app.Defaults.PagerOff, app.Defaults.PagerPrompts) @@ -51,9 +42,6 @@ func (m tuiModel) runnerFor(c *api.Client) deviceRunner { return c } -// startCompare is the explicit keypress. The rest of rich-data mode asks for -// no device login at all, which is the point of the mode; this one action -// dials the appliance and logs in, so it never happens on its own. func (m tuiModel) startCompare() (tea.Model, tea.Cmd) { tab, ok := m.activeData() if !ok || tab.cmp.loading || m.views == nil { @@ -83,9 +71,6 @@ func (m tuiModel) startCompare() (tea.Model, tea.Cmd) { return m, tea.Batch(compareCmd(m.ctx, m.runnerFor(c), c, views, tab, creds), m.spinner.Tick) } -// compareCmd runs the device commands in one session, then reads the portal -// side. The portal is fetched again rather than taken off the page: a page can -// be minutes old, and a comparison between two different moments is not one. func compareCmd(ctx context.Context, r deviceRunner, g dataview.Getter, views []dataview.View, t dataTab, creds ionterm.Credentials) tea.Cmd { return func() tea.Msg { ctx, cancel := context.WithTimeout(ctx, app.Defaults.ElementTimeout) @@ -128,8 +113,6 @@ func (m tuiModel) onCompareDone(msg compareDoneMsg) (tea.Model, tea.Cmd) { return m, nil } -// compareSummary is what the footer says once a comparison lands, because the -// marks that matter may be further down the page than the window reaches. func compareSummary(byView map[string]dataview.Comparison) string { var differ, unknown, total int for _, cmp := range byView { @@ -155,8 +138,6 @@ func compareSummary(byView map[string]dataview.Comparison) string { } } -// comparePane is the device half under a view's own sections, so a reading and -// its check are read together. Its heading is a jump target like any other. func (m tuiModel) comparePane(p *dataPage, tab dataTab, view string, width int) { if m.views == nil { return @@ -191,21 +172,14 @@ func (m tuiModel) comparePane(p *dataPage, tab dataTab, view string, width int) p.lines = append(p.lines, m.compareLines(cmp, width)...) } -// compareLines lays each row out as portal then device with a mark. Padded -// plain and styled afterwards: padding a styled cell counts its escape bytes -// toward the width and the columns drift apart row by row. func (m tuiModel) compareLines(cmp dataview.Comparison, width int) []string { colw := max(10, (width-ui.LabelWidth-8)/2) cell := func(s string, w int) string { return fmt.Sprintf("%-*s", w, ui.Truncate(s, w)) } - // Four columns of gutter: the mark and its space sit where the header has - // nothing to say. out := []string{ui.StyleDim.Render(fmt.Sprintf(" %s %s %s", cell("CHECK", ui.LabelWidth), cell("PORTAL", colw), "DEVICE"))} for _, row := range cmp.Rows { mark, style := compareMark(row.Verdict) - // Only the device cell takes the verdict's colour: it is the answer - // the mark is about, and colouring both makes a row shout twice. out = append(out, fmt.Sprintf(" %s %s %s %s", mark, ui.StyleDim.Render(cell(row.Label, ui.LabelWidth)), cell(dashed(row.Portal), colw), @@ -224,7 +198,6 @@ func compareMark(v dataview.Verdict) (string, lipgloss.Style) { case dataview.Differ: return ui.MarkCross.String(), ui.StyleErr } - // Nothing to compare, which is not a disagreement. return ui.StyleDim.Render("·"), ui.StyleDim } diff --git a/internal/tui/compare_test.go b/internal/tui/compare_test.go index e0dcd14..d39ec0b 100644 --- a/internal/tui/compare_test.go +++ b/internal/tui/compare_test.go @@ -11,8 +11,6 @@ import ( "tyrtop.com/iontk/internal/dataview" ) -// A device that answers one question the way the portal does and one it does -// not, which is the whole point of the comparison. const compareFixtureYAML = ` tenants: - name: Acme Co @@ -31,10 +29,6 @@ tenants: Model : ion 9999 ` -// The view the test compares with. It takes the name of a built-in, which -// replaces it: the shipped Overview comparison asks about fields the fixture -// derives from an element's name, and this one asks about the two the fixture -// file above states outright. const compareViewYAML = ` views: - name: Overview @@ -62,8 +56,6 @@ views: device: "{{ kv .cli.dev \"Serial\" }}" ` -// The explicit keypress end to end: one session to the playback device, the -// portal read again beside it, and a mark per row. func TestCompareAsksTheDeviceAndMarksEachRow(t *testing.T) { useViews(t, compareViewYAML) m, set := fixtureTUIWith(t, compareFixtureYAML) @@ -75,8 +67,6 @@ func TestCompareAsksTheDeviceAndMarksEachRow(t *testing.T) { next, cmd := m.openDataTabs([]sessionTarget{{eid: el.ID, name: el.Name, tsg: m.tenant.TSGID}}) m = runDataLoads(t, next.(tuiModel), cmd) - // Nothing has been asked of the device yet: the portal half of the mode - // is credential-free, and the page says what the key is for. if got := plain(m.dataPage(m.data[0]).String()); !strings.Contains(got, "c asks the device") { t.Errorf("the page does not offer the comparison:\n%s", got) } @@ -95,23 +85,20 @@ func TestCompareAsksTheDeviceAndMarksEachRow(t *testing.T) { for _, want := range []string{ "CLI check", "dump device status", "CHECK", "PORTAL", "DEVICE", - "✓ Name", // both sides say store-0003-rtr-01 - "✗ Model", // the portal says ion 1200, the device ion 9999 - "· Serial", // the device prints no serial, which is not a disagreement + "✓ Name", + "✗ Model", + "· Serial", "ion 9999", } { if !strings.Contains(page, want) { t.Errorf("the comparison is missing %q:\n%s", want, page) } } - // The marks may be below the window, so the footer carries the count. if got := m.status; !strings.Contains(got, "1 of 3") { t.Errorf("status = %q, want the number of disagreements", got) } } -// A comparison logs in to the appliance, so it is the one action in this mode -// that needs a device credential, and it says so rather than dialling. func TestCompareWithoutADeviceLoginAsksForOne(t *testing.T) { useViews(t, compareViewYAML) m, set := fixtureTUIWith(t, compareFixtureYAML) @@ -140,16 +127,12 @@ func TestCompareWithoutADeviceLoginAsksForOne(t *testing.T) { } } -// The gate is checked before the dial: an element that is not clustered is -// never asked about HA, and the heading does not appear on its page either. func TestComparisonsTheElementCannotAnswerAreNotOffered(t *testing.T) { m, set := fixtureTUIWith(t, haFixtureYAML) m.phase = phasePicking m.mode = modeData m.rebuildRows() - // The HA element of the pair, then a plain spoke in another tenant-less - // site: the fixture file marks only the first two as clustered. clustered := set.Tenants[0].Elements[0] next, cmd := m.openDataTabs([]sessionTarget{{eid: clustered.ID, name: clustered.Name, tsg: m.tenant.TSGID}}) m = runDataLoads(t, next.(tuiModel), cmd) @@ -175,8 +158,6 @@ func TestComparisonsTheElementCannotAnswerAreNotOffered(t *testing.T) { } } -// A dial that fails is reported on the page rather than taking it down: the -// portal half is still good. func TestAFailedComparisonLeavesThePageReadable(t *testing.T) { useViews(t, compareViewYAML) m, set := fixtureTUIWith(t, compareFixtureYAML) @@ -199,8 +180,6 @@ func TestAFailedComparisonLeavesThePageReadable(t *testing.T) { } } -// useViews points --views at a file for the length of one test, which is also -// how a --fixtures run gets views of its own. func useViews(t *testing.T, body string) { t.Helper() path := filepath.Join(t.TempDir(), "views.yaml") @@ -211,15 +190,11 @@ func useViews(t *testing.T, body string) { t.Cleanup(func() { Views = "" }) } -// runCompare runs the comparison the keypress queued and feeds the answer -// back in, the way the runtime would when the session finishes. func runCompare(t *testing.T, m tuiModel, cmd tea.Cmd) tuiModel { t.Helper() if cmd == nil { t.Fatal("no command to run") } - // Run them one at a time and stop at the comparison: the batch also holds - // the spinner tick, which would sit out a frame for nothing. cmds := []tea.Cmd{cmd} if batch, ok := cmd().(tea.BatchMsg); ok { cmds = batch diff --git a/internal/tui/connecting.go b/internal/tui/connecting.go index d862121..7578dd1 100644 --- a/internal/tui/connecting.go +++ b/internal/tui/connecting.go @@ -11,21 +11,13 @@ import ( "tyrtop.com/iontk/internal/ui" ) -// dialState is what is known about one element's connection while it is being -// made. It exists because dialling retries on its own: without this the list -// sat still for up to three attempts with nothing to show for it. type dialState struct { attempt int of int - // lastErr is what the previous attempt failed with, kept so a retry can say - // why it is retrying. lastErr string - // failed is set once the attempts ran out. - failed bool + failed bool } -// dialProgressMsg is one reported attempt, forwarded from whichever client -// happens to be dialling. type dialProgressMsg struct { eid string attempt int @@ -33,9 +25,6 @@ type dialProgressMsg struct { err string } -// dialReports is the bridge from the clients, which dial on their own -// goroutines, to Update. Buffered and dropped rather than blocked: a dial must -// never wait on the UI. func newDialReports() chan dialProgressMsg { return make(chan dialProgressMsg, 32) } func waitForDial(ch <-chan dialProgressMsg) tea.Cmd { @@ -51,9 +40,6 @@ func waitForDial(ch <-chan dialProgressMsg) tea.Cmd { } } -// reportDialsTo points a client's dial reporter at the model's channel. Every -// client that might open a session needs this, and setting it twice is -// harmless. func reportDialsTo(c *api.Client, ch chan dialProgressMsg) { if c == nil { return @@ -74,8 +60,6 @@ func (m *tuiModel) noteDial(msg dialProgressMsg) { m.setDial(msg.eid, dialState{attempt: msg.attempt, of: msg.of, lastErr: msg.err}) } -// markDialing shows a row as connecting before the first attempt reports, so -// the spinner is there from the first frame rather than after a failure. func (m *tuiModel) markDialing(eid string) { m.setDial(eid, dialState{attempt: 1, of: 1}) } @@ -87,7 +71,6 @@ func (m *tuiModel) setDial(eid string, st dialState) { m.dialing[eid] = st } -// dialDone clears an element's dial state, whatever its outcome. func (m *tuiModel) dialDone(eid string) { delete(m.dialing, eid) } @@ -97,16 +80,11 @@ func (m *tuiModel) dialFailed(eid, reason string) { st.failed = true st.lastErr = reason m.setDial(eid, st) - // A redial that failed leaves the tab it was asked from exactly where it - // was, with its own error back in the bar. if m.redialing == eid { m.redialing = "" } } -// connectingAny reports whether anything is mid-dial, which is what gates the -// spinner: a list that repaints twelve times a second for nothing is worse than -// no spinner at all. func (m tuiModel) connectingAny() bool { for _, st := range m.dialing { if !st.failed { @@ -116,9 +94,6 @@ func (m tuiModel) connectingAny() bool { return m.connecting != "" } -// retryPrompt is the question asked when a connection could not be made. It is -// a modal because the answer decides whether anything happens at all, and the -// status line was being missed. func (m tuiModel) retryPrompt() string { name := m.askRetry.name if name == "" { @@ -145,9 +120,6 @@ func (m tuiModel) retryPrompt() string { return ui.Modal(lines, m.width) } -// retryChoices draws the two answers with the chosen one highlighted. Both are -// padded before styling: padding a styled string counts its escape bytes -// towards the width and the two buttons drift apart. func (m tuiModel) retryChoices() string { yes, no := " yes ", " no " if m.askRetryNo { @@ -156,8 +128,6 @@ func (m tuiModel) retryChoices() string { return ui.StyleOK.Reverse(true).Bold(true).Render(yes) + " " + ui.StyleDim.Render(no) } -// overlayRetry puts the prompt where the content was. The rows above it are -// kept so the list or the tab bar is still visible behind the question. func (m tuiModel) overlayRetry(content string) string { prompt := m.retryPrompt() keep := max(0, m.height-ui.ModalHeight(len(strings.Split(prompt, "\n")))-1) @@ -169,9 +139,6 @@ func (m tuiModel) overlayRetry(content string) string { return strings.Join(lines, "\n") + "\n" + prompt } -// onRetryKey answers the retry prompt. Navigation moves between the answers; -// anything else that is not yes dismisses it, because an unexplained modal that -// will not go away is worse than a missed retry. func (m tuiModel) onRetryKey(key string) (tea.Model, tea.Cmd) { switch key { case "left", "right", "tab", "shift+tab", "h", "l": @@ -185,8 +152,6 @@ func (m tuiModel) onRetryKey(key string) (tea.Model, tea.Cmd) { case "y", "Y": retry = true case "enter": - // Only enter confirms. Space selects rows on the list behind this, and - // muscle memory should not redial an ION. retry = !m.askRetryNo } @@ -205,22 +170,16 @@ func (m tuiModel) onRetryKey(key string) (tea.Model, tea.Cmd) { return m, nil } -// rowStatus is the no-header column beside each element: what this element is -// doing, and whether it already has a tab. func (m *tuiModel) rowStatus(el elementRow) string { glyph, style := m.rowStatusOf(el) return style.Render(glyph) } -// rowStatusOf is the glyph and how to paint it, kept apart for the same reason -// haState is: the list builds this for every matched row and paints only the -// handful it shows. func (m *tuiModel) rowStatusOf(el elementRow) (string, lipgloss.Style) { if st, ok := m.dialing[el.ID]; ok { if st.failed { return ui.MarkCross.String(), ui.StyleErr } - // The spinner is already a styled frame of its own. return m.spinner.View(), lipgloss.NewStyle() } s := m.sessions.Find(el.ID) @@ -241,8 +200,6 @@ func (m *tuiModel) rowStatusOf(el elementRow) (string, lipgloss.Style) { return "●", ui.StyleOK } -// connectBar is the line under the tab bar. It keeps its row whatever the -// state, so the pane below it does not change height as a session connects. func (m tuiModel) connectBar() string { s := m.sessions.Active() if s == nil { @@ -255,8 +212,6 @@ func (m tuiModel) connectBar() string { var parts []string if st, ok := m.dialing[s.ElementID]; ok && !st.failed { - // Said on the dead tab itself, which is the one being looked at while - // its replacement is dialled. what := "dial" if m.redialing == s.ElementID { what = "redial" @@ -273,43 +228,25 @@ func (m tuiModel) connectBar() string { case s.Hung(): parts = append(parts, ui.StyleWarn.Render("~ not answering")) case !s.Answered(): - // The socket is up and the appliance has said nothing. That is - // what dialling an element that is not there looks like, and it - // used to read as "successfully dialed" until the handshake - // deadline failed it twenty seconds later. parts = append(parts, m.spinner.View()+" "+ui.StyleWarn.Render("waiting for the ion")) default: - // What this half is about is the dial, not the appliance: the two - // read as a contradiction while the session half said "connected", - // and naming the socket it runs over only moved the confusion. - // "successfully dialed · ion offline" is a state that happens and - // now says so. parts = append(parts, ui.StyleOK.Render(ui.MarkCheck.String()+" successfully dialed")) } } parts = append(parts, m.ionLabel(s.ElementID)) - // Not while one is already in flight: the bar says "redial n/m" there, and - // offering the key again reads as though nothing is happening. if (s.Hung() || s.Err() != nil) && m.redialing != s.ElementID { parts = append(parts, ui.StyleDim.Render("alt+r retry")) } return " " + strings.Join(parts, ui.StyleDim.Render(" · ")) } -// awaitingDevice is a live session that has not heard from its appliance yet, -// which is the state the spinner has to keep ticking for. func (m tuiModel) awaitingDevice() bool { s := m.sessions.Active() return s != nil && s.Err() == nil && !s.Hung() && !s.Ended() && !s.Answered() } -// ionLabel is what the portal last said about the appliance, said as such. -// Next to a dial it is the only evidence there is -- an offline ION will not -// come up however many times it is tried -- and next to a live session it is -// the disagreement that matters: a stale cached list, or a box that has lost -// its controller channel while its CLI is perfectly well. func (m tuiModel) ionLabel(eid string) string { for _, r := range m.all { if r.ID != eid { @@ -323,7 +260,6 @@ func (m tuiModel) ionLabel(eid string) string { return ui.StyleDim.Render("ion unknown") } -// shortDialErr trims the layers a failed handshake arrives wrapped in. func shortDialErr(s string) string { if i := strings.LastIndex(s, ": "); i >= 0 && i+2 < len(s) { return s[i+2:] diff --git a/internal/tui/connecting_test.go b/internal/tui/connecting_test.go index 4080cb7..85b7ce6 100644 --- a/internal/tui/connecting_test.go +++ b/internal/tui/connecting_test.go @@ -51,7 +51,6 @@ func TestTheStatusColumnHasNoHeader(t *testing.T) { if cols[1].Title != "" { t.Errorf("column 1 title = %q, want no header", cols[1].Title) } - // Rows and columns have to agree or the table indexes past the end. for i, r := range m.table.Rows() { if len(r) != len(cols) { t.Fatalf("row %d has %d cells, want %d", i, len(r), len(cols)) @@ -70,7 +69,6 @@ func TestSpinnerTicksOnlyWhileSomethingIsDialling(t *testing.T) { t.Error("connectingAny() = false while a dial is in flight") } - // A failed dial is not movement: the pane must stop repainting for it. m.dialFailed("e1", "nope") if m.connectingAny() { t.Error("connectingAny() = true for a dial that already failed") @@ -111,8 +109,6 @@ func TestConnectBarSaysOfflineForAnUnreachableION(t *testing.T) { t.Fatal(err) } - // Worth saying next to a failed dial: an offline ION will not answer - // however many times it is tried. if bar := plain(m.connectBar()); !strings.Contains(bar, "offline") { t.Errorf("bar = %q, want it to say the ION is offline", bar) } @@ -125,8 +121,6 @@ func TestTheConnectBarKeepsItsRow(t *testing.T) { t.Fatal(err) } - // A bar that comes and goes moves the pane under it, which makes the - // terminal twitch. connected := len(strings.Split(m.connectBar(), "\n")) m.noteDial(dialProgressMsg{eid: "e1", attempt: 1, of: 3}) dialling := len(strings.Split(m.connectBar(), "\n")) @@ -195,7 +189,6 @@ func TestAnsweringNoDismissesThePrompt(t *testing.T) { if _, ok := got.dialing["e1"]; ok { t.Error("the failed dial was left in the list after being dismissed") } - // Nothing opened, so there is no pane to sit in front of. if got.phase != phasePicking { t.Errorf("phase = %v, want the element list", got.phase) } @@ -207,17 +200,14 @@ func TestThePromptOwnsTheKeyboard(t *testing.T) { m.askRetry = sessionTarget{eid: "e1", name: "rtr", tsg: "1"} before := len(m.selected) - // Space ticks rows on the list behind the modal, and must not reach it. mm, cmd := m.Update(tea.KeyPressMsg(tea.Key{Code: ' ', Text: " "})) got := mm.(tuiModel) if len(got.selected) != before { t.Errorf("selected changed from %d to %d behind the prompt", before, len(got.selected)) } - // Nor may it redial: only enter and y do that. if cmd != nil { t.Error("space dialled the element, want only enter and y to answer yes") } - // Anything that is not an answer still dismisses, so it cannot get stuck. if got.askRetry.eid != "" { t.Error("the prompt survived an unrelated keypress, want it dismissed") } @@ -242,15 +232,12 @@ func TestTheChosenAnswerIsHighlighted(t *testing.T) { m := pickModel(t) m.askRetry = sessionTarget{eid: "e1", name: "rtr", tsg: "1"} - // Reverse video, so the highlight follows the terminal's own colour pair - // rather than a background that might vanish in a light theme. const reverse = "\x1b[1;7" yesChosen := m.retryChoices() if !strings.Contains(yesChosen, reverse) { t.Fatalf("no highlight in %q, want the chosen answer marked", yesChosen) } - // Yes by default: it is what someone who hit enter almost certainly meant. if i, j := strings.Index(yesChosen, "yes"), strings.Index(yesChosen, reverse); i < j { t.Errorf("choices = %q, want yes highlighted by default", yesChosen) } @@ -273,8 +260,6 @@ func TestTheChoicesKeepTheirWidthWhicheverIsChosen(t *testing.T) { m.askRetryNo = true no := plain(m.retryChoices()) - // Padding a styled string counts its escape bytes towards the width, which - // would pull the two buttons apart as the highlight moved. if len(yes) != len(no) { t.Errorf("choices are %d wide with yes chosen and %d with no: %q vs %q", len(yes), len(no), yes, no) } @@ -301,7 +286,6 @@ func TestNavigatingThePromptDoesNotAnswerIt(t *testing.T) { t.Errorf("%v produced a command, want navigation to do nothing else", key) } } - // Five moves from yes lands back on no. if !m.askRetryNo { t.Error("the highlight did not track the moves") } @@ -336,7 +320,6 @@ func TestEnterTakesTheHighlightedAnswer(t *testing.T) { if !tt.wantRetry && cmd != nil { t.Error("enter on no produced a command, want nothing dialled") } - // The next prompt starts on yes however this one was answered. if got.askRetryNo { t.Error("askRetryNo stayed set, so the next prompt would open on no") } @@ -345,7 +328,6 @@ func TestEnterTakesTheHighlightedAnswer(t *testing.T) { } func TestYAndNWorkWhicheverIsHighlighted(t *testing.T) { - // The letters are a shortcut past the highlight, not a second way to move it. for _, tt := range []struct { key string chooseNo bool @@ -378,26 +360,19 @@ func TestThePromptSaysWhatThePortalThinks(t *testing.T) { m.mergeTenantElements("1", "t", []api.Element{{ID: "e1", Name: "rtr", Connected: false}}) m.askRetry = sessionTarget{eid: "e1", name: "rtr", tsg: "1"} - // An offline ION will not answer however many times it is tried, so this is - // the line that says whether retrying is worth anything. if got := plain(m.retryPrompt()); !strings.Contains(got, "offline in the portal") { t.Errorf("prompt = %q, want it to say what the portal thinks", got) } } -// "connected · offline" read as a contradiction when it is two facts about -// different things: whether the dial came up, and what the portal last said -// about the appliance. Both halves say which they are about now. func TestTheConnectBarNamesBothHalves(t *testing.T) { tests := []struct { name string connected bool dialing bool kill bool - // answered is the device having sent something, which is the only - // evidence there is an ION on the other end of the socket. - answered bool - want []string + answered bool + want []string }{ { name: "answering, on an ion the portal agrees about", @@ -411,8 +386,6 @@ func TestTheConnectBarNamesBothHalves(t *testing.T) { want: []string{"successfully dialed", "ion offline"}, }, { - // What dialling an element that is not there looks like: the SASE - // side gives you a socket and nothing ever comes down it. name: "socket up and the ion has said nothing", want: []string{"waiting for the ion", "ion offline"}, }, @@ -464,7 +437,6 @@ func TestTheConnectBarNamesBothHalves(t *testing.T) { } } -// An element the list has not got says so rather than claiming either way. func TestTheConnectBarSaysUnknownForAnUntrackedElement(t *testing.T) { m := pickModel(t) setTestElements(&m, []api.Element{{ID: "9", Name: "elsewhere"}}) diff --git a/internal/tui/data.go b/internal/tui/data.go index 5d05ecc..8314b65 100644 --- a/internal/tui/data.go +++ b/internal/tui/data.go @@ -14,13 +14,8 @@ import ( "tyrtop.com/iontk/internal/ui" ) -// Views is --views: a file of rich-data views to use instead of the one in the -// config directory. The command sets it before Run. var Views string -// ViewsPath is where the views come from: --views if it was given, else -// views.yaml beside the config. A --debug or --fixtures run has its own throwaway -// root and so sees only the built-ins, which is why --views exists. func ViewsPath(st *config.Store) string { if Views != "" { return Views @@ -31,29 +26,18 @@ func ViewsPath(st *config.Store) string { return filepath.Join(st.Root, dataview.FileName) } -// dataTab is one element's rich-data pane. It is a tab of its own rather than -// a panel on the list because the data is per element and worth keeping open -// while the list moves on, which is the same reason a shell is a tab. type dataTab struct { - eid string - name string - tsg string - site string - siteID string - // cluster is the HA cluster the element record named, which the views that - // ask about HA need and cannot derive. + eid string + name string + tsg string + site string + siteID string cluster string - // One pane per view, in view order, all fetched at once: the whole of - // what the portal knows about an element belongs on one scrollable page - // rather than behind a mode switch per view. They arrive separately, so - // each carries its own loading and error state and the page fills in. panes []dataPane at time.Time scroll int - // The GUI-vs-CLI comparison, which exists only once asked for: it is the - // one thing in this mode that dials the element. cmp compareState } @@ -78,10 +62,6 @@ func (m tuiModel) viewNames() []string { return m.views.Names() } -// dataVars is what a view's paths are written against. The cluster is the one -// id that is not on the element row, so it comes from the record the view -// itself fetched on a previous pass -- or empty, and the HA sections stay -// gated off. func (t dataTab) vars() dataview.Vars { return dataview.Vars{ "element_id": t.eid, @@ -105,9 +85,6 @@ func loadDataCmd(ctx contextLike, g dataview.Getter, v dataview.View, t dataTab) } } -// openDataTabs opens a pane per element. No device login is asked for: the -// portal half of iontk is the first useful thing someone has before they have -// ION credentials at all, which is the whole reason this mode exists. func (m tuiModel) openDataTabs(targets []sessionTarget) (tea.Model, tea.Cmd) { if len(targets) == 0 { return m, nil @@ -120,8 +97,6 @@ func (m tuiModel) openDataTabs(targets []sessionTarget) (tea.Model, tea.Cmd) { return m, nil } - // A tenant that has not authenticated yet is connected first and the - // request replayed, the way opening a session does it. if m.needsSignIn(tsgsOf(targets)) { m.resumeData = targets return m.startSignIn() @@ -143,9 +118,6 @@ func (m tuiModel) openDataTabs(targets []sessionTarget) (tea.Model, tea.Cmd) { } tab := dataTab{eid: t.eid, name: t.name, tsg: t.tsg} - // An element the list already knows carries its site and cluster, so - // the first fetch can ask for interfaces without waiting on the - // record to come back first. if el, ok := m.elementByKey(t.tsg + "/" + t.eid); ok { tab.siteID = el.SiteID tab.site = m.siteName(el) @@ -156,8 +128,6 @@ func (m tuiModel) openDataTabs(targets []sessionTarget) (tea.Model, tea.Cmd) { tab.panes = append(tab.panes, dataPane{view: name, loading: true}) } - // Opening an element that already has a tab reuses it rather than - // stacking a second page of the same data. if i := m.dataIndex(t.tsg, t.eid); i >= 0 { tab.scroll = m.data[i].scroll m.data[i] = tab @@ -190,10 +160,6 @@ func (m tuiModel) dataIndex(tsg, eid string) int { return -1 } -// loadTab is one fetch per view. They run as separate commands so the page -// paints as each answers: the whole set is thirty-odd requests on a big -// element, and waiting for the last of them to show the first would be the -// difference between a pause and a hang. func (m tuiModel) loadTab(tab dataTab, c *api.Client) []tea.Cmd { cmds := make([]tea.Cmd, 0, len(m.views.Views)) for _, v := range m.views.Views { @@ -202,8 +168,6 @@ func (m tuiModel) loadTab(tab dataTab, c *api.Client) []tea.Cmd { return cmds } -// contextLike is what the fetch needs of the model's context, which is only -// cancellation. type contextLike = interface { Deadline() (time.Time, bool) Done() <-chan struct{} @@ -239,8 +203,6 @@ func (t dataTab) loading() bool { return false } -// failed marks the tab in the bar: one view refused is worth a mark, since -// the rest of the page looks complete without one. func (t dataTab) failed() bool { for _, p := range t.panes { if p.err != "" { @@ -257,9 +219,6 @@ func (m tuiModel) activeData() (dataTab, bool) { return m.data[m.dataActive], true } -// refreshData refetches the whole page, keeping the scroll: a refresh is for -// watching a value change, and being thrown back to the top to find it again -// defeats that. func (m tuiModel) refreshData() (tea.Model, tea.Cmd) { tab, ok := m.activeData() if !ok { @@ -309,13 +268,12 @@ func (m tuiModel) onDataKey(key string) (tea.Model, tea.Cmd) { return m, nil case "/": if !m.dataFilterOn { + m.dataFilter.SetValue("") m.dataFilterOn = true return m, m.dataFilter.Focus() } case "enter": if m.dataFilterOn { - // Applied and left, like the element list: the page's own keys - // are letters, so the query has to be finished with. m.dataFilterOn = false m.dataFilter.Blur() return m, nil @@ -339,9 +297,6 @@ func (m tuiModel) onDataKey(key string) (tea.Model, tea.Cmd) { page := m.visiblePage(tab) _, rows := m.dataPaneSize() - // Scrolling keys still work mid-query -- the arrows and the paging keys - // are not letters -- but anything that could be typed belongs to the - // input. if m.dataFilterOn { switch key { case "down", "up", "pgdown", "pgup", "home", "end", "alt+down", "alt+up": @@ -363,8 +318,6 @@ func (m tuiModel) onDataKey(key string) (tea.Model, tea.Cmd) { tab.scroll = 0 case "end", "G": tab.scroll = len(page.lines) - // The page is long and its headers are what someone is looking for, so - // these move a heading at a time rather than a line. case "alt+down", "]", "tab": tab.scroll = nextHead(page.heads, tab.scroll, 1) case "alt+up", "[", "shift+tab": @@ -377,9 +330,6 @@ func (m tuiModel) onDataKey(key string) (tea.Model, tea.Cmd) { return m, nil } -// nextHead is the header before or after where the pane is held. Going down -// stops at the last one rather than wrapping: a page that jumps back to the -// top when you press down again has lost your place. func nextHead(heads []int, at, dir int) int { if len(heads) == 0 { return at @@ -404,13 +354,6 @@ func clampScroll(at, total, rows int) int { return max(0, min(at, max(0, total-rows))) } -// visiblePage is the page as the pane shows it: the whole thing, or only the -// lines matching the query with enough around them to read -- the view heading -// above them and the table header, if they came out of a table. -// -// Narrowing rather than jumping to the next match, because that is what every -// other `/` in iontk does, and because "where is vlan1000" is answered better -// by one line than by a cursor somewhere in thirty. func (m *tuiModel) visiblePage(tab dataTab) dataPage { page := m.dataPage(tab) q := strings.ToLower(strings.TrimSpace(m.dataFilter.Value())) @@ -418,7 +361,6 @@ func (m *tuiModel) visiblePage(tab dataTab) dataPage { return page } - // Which heading and which table header each line sits under. head, hdr := make([]int, len(page.lines)), make([]int, len(page.lines)) at, under := -1, -1 headAt, hdrAt := 0, 0 @@ -451,7 +393,6 @@ func (m *tuiModel) visiblePage(tab dataTab) dataPage { out.heads = append(out.heads, len(out.lines)) add(h) } - // The header only belongs with a row of the table it heads. if d := hdr[i]; d >= 0 && d != i && d > head[i] { add(d) } @@ -464,16 +405,10 @@ func (m tuiModel) dataPaneSize() (int, int) { return max(20, m.width), max(3, m.height-6-m.statsRows()) } -// dataPage is the whole pane as text, with where its headers are. Built whole -// and then windowed: a table needs every row's width before it can line up -// the first, and alt+up/down needs to know which lines are headers. type dataPage struct { lines []string heads []int - // hdrs is where a table's header row is, so a filtered page can keep the - // one above a matching row: a port row with no PORT/TYPE/STATE line above - // it is a row of unlabelled columns. - hdrs []int + hdrs []int } func (p dataPage) String() string { return strings.Join(p.lines, "\n") } @@ -484,9 +419,6 @@ func (p *dataPage) head(line string) { p.lines = append(p.lines, line) } -// dataPage lays every view out one after another, so the whole of what the -// portal knows about an element is one page to scroll rather than a mode to -// switch. Each view's own header is a jump target. func (m tuiModel) dataPage(tab dataTab) dataPage { width, _ := m.dataPaneSize() var p dataPage @@ -528,9 +460,6 @@ func (m tuiModel) dataPage(tab dataTab) dataPage { return p } -// tableLines lays a section's table out. Columns are padded plain and styled -// afterwards: padding a styled cell counts its escape bytes toward the width -// and the columns drift apart row by row. func (m tuiModel) tableLines(sec dataview.RenderedSection, width int) []string { if len(sec.Headers) == 0 { return nil @@ -542,9 +471,6 @@ func (m tuiModel) tableLines(sec dataview.RenderedSection, width int) []string { return []string{" " + ui.StyleDim.Render(sec.Empty)} } - // Sized to content, then capped by whatever the view asked for: an - // element's interface names run from "1" to a 40-character Prisma tunnel, - // and sizing to the longest would push every other column off the pane. widths := make([]int, len(sec.Headers)) for i, h := range sec.Headers { widths[i] = ui.Width(h) @@ -601,8 +527,6 @@ func (m tuiModel) viewData() string { if tab.site != "" { where += ui.StyleDim.Render(" · " + tab.site) } - // How much of the page is still arriving, since every view is fetched at - // once and they answer separately. waiting := 0 for _, pane := range tab.panes { if pane.loading { @@ -635,8 +559,6 @@ func (m tuiModel) viewData() string { lines := page.lines _, rows := m.dataPaneSize() at := clampScroll(tab.scroll, len(lines), rows) - // The window is padded to the full height whether or not there is enough - // to fill it: a frame that changes height makes the terminal twitch. for i := 0; i < rows; i++ { if at+i < len(lines) { b.WriteString(lines[at+i]) @@ -674,17 +596,12 @@ func newDataFilter() textinput.Model { return in } -// onDataFilterKey is the typing half of the data pane's filter. It reports -// whether it took the key: the page's own scrolling keys are not letters, so -// they go through to onDataKey. func (m tuiModel) onDataFilterKey(msg tea.KeyPressMsg, key string) (tea.Model, tea.Cmd, bool) { switch key { case "esc", "enter", "down", "up", "pgdown", "pgup", "home", "end", "alt+down", "alt+up", "alt+left", "alt+right", "alt+k", "alt+w", "alt+y": return m, nil, false case "backspace": - // On an already-empty query this is the press that closes the filter, - // the same two-step as every other one in iontk. if m.dataFilter.Value() == "" { m.dataFilterOn = false m.dataFilter.Blur() @@ -694,7 +611,6 @@ func (m tuiModel) onDataFilterKey(msg tea.KeyPressMsg, key string) (tea.Model, t var cmd tea.Cmd m.dataFilter, cmd = m.dataFilter.Update(msg) - // The page shrank or grew under the scroll position. if tab, ok := m.activeData(); ok { _, rows := m.dataPaneSize() tab.scroll = clampScroll(tab.scroll, len(m.visiblePage(tab).lines), rows) diff --git a/internal/tui/data_test.go b/internal/tui/data_test.go index ef47083..6503480 100644 --- a/internal/tui/data_test.go +++ b/internal/tui/data_test.go @@ -28,10 +28,6 @@ tenants: ha: true ` -// The whole mode end to end over the fixture transport: pick an element in -// rich-data mode and read the page. Every view is fetched at once and laid -// out one after another, so this is also the check that nothing is behind a -// view switch any more. func TestRichDataModeLoadsEveryViewOntoOnePage(t *testing.T) { m, set := fixtureTUIWith(t, haFixtureYAML) m.phase = phasePicking @@ -52,15 +48,12 @@ func TestRichDataModeLoadsEveryViewOntoOnePage(t *testing.T) { if len(m.data) != 1 { t.Fatalf("tabs = %+v, want one", m.data) } - // One pane per view, all of them reading. if got, want := len(m.data[0].panes), len(m.viewNames()); got != want { t.Fatalf("%d panes, want %d — one per view", got, want) } if !m.data[0].loading() { t.Error("the tab is not loading, so nothing was queued") } - // The ids the views are written against come off the element row, not a - // second lookup. if m.data[0].siteID == "" || m.data[0].cluster == "" { t.Errorf("tab = %+v, want the site and cluster ids filled in", m.data[0]) } @@ -70,25 +63,20 @@ func TestRichDataModeLoadsEveryViewOntoOnePage(t *testing.T) { t.Fatal("still loading after every fetch answered") } - // The whole page, which is longer than the pane: the window onto it is - // checked below. page := plain(m.dataPage(m.data[0]).String()) for _, want := range []string{ - "Overview", "Device", "ion 1200", "SPOKE", // the record - "Controller", "Rebooted", "2026-10-01", // status, through the utc helper - "HA", "Cluster", // gated on the element being clustered, which it is - "Interfaces", "PORT", "STATE", "MAC", "CIRCUIT", // the per-row fetch - "Circuits", "LABEL", "NETWORK", "ISP 1", // the circuits view + "Overview", "Device", "ion 1200", "SPOKE", + "Controller", "Rebooted", "2026-10-01", + "HA", "Cluster", + "Interfaces", "PORT", "STATE", "MAC", "CIRCUIT", + "Circuits", "LABEL", "NETWORK", "ISP 1", } { if !strings.Contains(page, want) { t.Errorf("the page is missing %q:\n%s", want, page) } } - // The pane shows the top of it, says which element and how much there is. got := plain(m.viewData()) - // The window onto the page, not its length: the page grows whenever a - // view does. for _, want := range []string{"store-0003-rtr-01", "Store 0003", "4 views", "Overview", "1-24 of"} { if !strings.Contains(got, want) { t.Errorf("the pane is missing %q:\n%s", want, got) @@ -101,8 +89,6 @@ func TestRichDataModeLoadsEveryViewOntoOnePage(t *testing.T) { } } -// The MAC comes off the interface status, since the interface record carries -// the field and leaves it null on every real element. func TestInterfacesShowTheMACFromTheStatus(t *testing.T) { m, set := fixtureTUIWith(t, haFixtureYAML) m.phase = phasePicking @@ -129,7 +115,6 @@ func TestInterfacesShowTheMACFromTheStatus(t *testing.T) { } } -// Which port carries which circuit, from both sides of the join. func TestThePageTiesPortsToCircuits(t *testing.T) { m, set := fixtureTUIWith(t, haFixtureYAML) m.phase = phasePicking @@ -140,8 +125,6 @@ func TestThePageTiesPortsToCircuits(t *testing.T) { next, cmd := m.openDataTabs([]sessionTarget{{eid: el.ID, name: el.Name, tsg: m.tenant.TSGID}}) m = runDataLoads(t, next.(tuiModel), cmd) - // The port names the circuit it carries rather than printing the id out - // of site_wan_interface_ids. port := tableRow(t, paneFor(t, m.data[0], "Interfaces"), "1") if !strings.Contains(strings.Join(port, " "), "ISP 1") { t.Errorf("port 1 does not name its circuit: %q", port) @@ -150,24 +133,21 @@ func TestThePageTiesPortsToCircuits(t *testing.T) { circuit := tableRow(t, paneFor(t, m.data[0], "Circuits"), "ISP 1") joined := strings.Join(circuit, " | ") for _, want := range []string{ - "Circuit to ISP 1", // the label, looked up by label_id - "Internet 1", // the network, looked up by network_id - "ctrl", // and that this is the controller's circuit - "up", // the circuit's own status - "operational", // and this element's view of it + "Circuit to ISP 1", + "Internet 1", + "ctrl", + "up", + "operational", } { if !strings.Contains(joined, want) { t.Errorf("the circuit row is missing %q: %s", want, joined) } } - // The other direction of the same join: the circuit names its port. if !containsCell(circuit, "1") { t.Errorf("the circuit does not name the port carrying it: %s", joined) } } -// alt+up and alt+down move a heading at a time, which is how a page this long -// gets read. func TestAltUpDownJumpsBetweenHeadings(t *testing.T) { m, set := fixtureTUIWith(t, haFixtureYAML) m.phase = phasePicking @@ -185,7 +165,6 @@ func TestAltUpDownJumpsBetweenHeadings(t *testing.T) { } rows := paneRows(m) - // Down lands on the first heading past where the pane is held. nx, _ := m.onDataKey("alt+down") m = nx.(tuiModel) if got, want := m.data[0].scroll, clampScroll(page.heads[0], len(page.lines), rows); got != want { @@ -197,15 +176,12 @@ func TestAltUpDownJumpsBetweenHeadings(t *testing.T) { t.Fatalf("two alt+downs put the pane at %d, want the second heading at %d", got, want) } - // And back up again. nx, _ = m.onDataKey("alt+up") m = nx.(tuiModel) if got, want := m.data[0].scroll, clampScroll(page.heads[0], len(page.lines), rows); got != want { t.Errorf("alt+up put the pane at %d, want back at %d", got, want) } - // Past the last heading it holds rather than wrapping: a page that jumps - // to the top when you press down again has lost your place. m.data[0].scroll = len(page.lines) nx, _ = m.onDataKey("alt+down") if got := nx.(tuiModel).data[0].scroll; got == 0 { @@ -213,8 +189,6 @@ func TestAltUpDownJumpsBetweenHeadings(t *testing.T) { } } -// A refresh refetches every view but keeps the place in the page: the point of -// refreshing is to watch one value change. func TestRefreshKeepsTheScroll(t *testing.T) { m, set := fixtureTUIWith(t, haFixtureYAML) m.phase = phasePicking @@ -240,8 +214,6 @@ func TestRefreshKeepsTheScroll(t *testing.T) { } } -// The point of the format: a view nobody shipped, from a file of the user's -// own, on the same page as the built-ins. func TestAUserViewFromTheViewsFlagIsOffered(t *testing.T) { path := filepath.Join(t.TempDir(), "views.yaml") body := ` @@ -282,8 +254,6 @@ views: } } -// Opening the same element twice reuses its tab rather than stacking a second -// page of identical data. func TestReopeningAnElementReusesItsTab(t *testing.T) { m, set := fixtureTUIWith(t, haFixtureYAML) m.phase = phasePicking @@ -309,7 +279,6 @@ func TestReopeningAnElementReusesItsTab(t *testing.T) { t.Error("reopening queued no fetch, so the page would keep stale data") } - // A second element gets a tab of its own. other := set.Tenants[0].Elements[1] next, _ = m.openDataTabs([]sessionTarget{{eid: other.ID, name: other.Name, tsg: m.tenant.TSGID}}) if got := len(next.(tuiModel).data); got != 2 { @@ -317,8 +286,6 @@ func TestReopeningAnElementReusesItsTab(t *testing.T) { } } -// A data pane joins the tab ring after the shells, so alt+←/→ walks all of -// them and the session numbers do not move as data tabs come and go. func TestDataTabsJoinTheTabRing(t *testing.T) { m := pickModel(t) m.data = []dataTab{{eid: "1", name: "S0100-RTR-01", tsg: "1"}} @@ -332,13 +299,11 @@ func TestDataTabsJoinTheTabRing(t *testing.T) { t.Errorf("currentTab() = %d, want 1", got) } - // alt+left from the pane lands on the element list. next, _ := m.onDataKey("alt+left") if got := next.(tuiModel).phase; got != phasePicking { t.Errorf("phase after alt+left = %v, want the list", got) } - // alt+k closes it; a pane has no connection to detach, so alt+w does too. closed := m.closeDataTab() if len(closed.data) != 0 || closed.phase != phasePicking { t.Errorf("after close: %d tabs, phase %v", len(closed.data), closed.phase) @@ -352,13 +317,9 @@ func TestDataTabsJoinTheTabRing(t *testing.T) { } } -// Rich-data mode reads the portal, so it must not ask for a device password -// the way shell and batch do. func TestRichDataModeDoesNotAskForADeviceLogin(t *testing.T) { m := pickModel(t) m.mode = modeData - // No stored device password for this tenant: shell mode would open the - // password form here. if err := m.store.Secrets.Delete(config.IONPassKey("1", "u")); err != nil { t.Fatal(err) } @@ -376,8 +337,6 @@ func TestScrollingAPaneStaysInsideIt(t *testing.T) { m.dataActive = 0 m.phase = phaseData - // Nothing loaded yet, so there is nothing to scroll and end must not run - // the offset off into blank space. next, _ := m.onDataKey("end") if got := next.(tuiModel).data[0].scroll; got != 0 { t.Errorf("scroll = %d with nothing loaded, want 0", got) @@ -408,7 +367,6 @@ func paneFor(t *testing.T, tab dataTab, view string) dataPane { return dataPane{} } -// tableRow is the row of a pane's tables whose first cell is want. func tableRow(t *testing.T, p dataPane, want string) []string { t.Helper() for _, sec := range p.rendered.Sections { @@ -431,8 +389,6 @@ func containsCell(cells []string, want string) bool { return false } -// runDataLoads runs every fetch the tab queued and feeds each answer back in, -// the way the runtime would as they arrive. func runDataLoads(t *testing.T, m tuiModel, cmd tea.Cmd) tuiModel { t.Helper() if cmd == nil { @@ -461,8 +417,6 @@ func runDataLoads(t *testing.T, m tuiModel, cmd tea.Cmd) tuiModel { return m } -// alt+up and alt+down jump through the element list by a configurable step, -// which is what makes a list of thousands navigable without the filter. func TestAltUpDownJumpsTheElementList(t *testing.T) { m := pickModel(t) m.cfg.PageStep = 2 @@ -479,14 +433,12 @@ func TestAltUpDownJumpsTheElementList(t *testing.T) { t.Errorf("cursor = %d after alt+up, want back at 0", got) } - // Nothing saved means the default, not a step of zero. m.cfg.PageStep = 0 if got := m.pageStep(); got != DefaultPageStep { t.Errorf("pageStep() = %d with nothing saved, want %d", got, DefaultPageStep) } } -// The setting cycles and sticks, since the right step depends on the fleet. func TestThePageStepSettingCycles(t *testing.T) { m := pickModel(t) m.phase = phaseSettings @@ -501,7 +453,6 @@ func TestThePageStepSettingCycles(t *testing.T) { t.Fatalf("pageStep() = %d after enter, want it moved on", m.pageStep()) } - // Saved, so the next run opens on it. cfg, err := m.store.Config() if err != nil { t.Fatal(err) @@ -510,7 +461,6 @@ func TestThePageStepSettingCycles(t *testing.T) { t.Errorf("saved step = %d, in-memory %d", cfg.PageStep, m.pageStep()) } - // Round the rest of the cycle and back to where it started. for i := 1; i < len(pageSteps); i++ { nx, _ := m.onGeneralKey("enter") m = nx.(tuiModel) @@ -520,9 +470,6 @@ func TestThePageStepSettingCycles(t *testing.T) { } } -// The security zone a port is in, joined through two fetches: the element's -// bindings hold the interface ids, and the tenant's zone list has the names. -// The join is the same one the circuit column uses. func TestInterfacesShowTheSecurityZone(t *testing.T) { m, set := fixtureTUIWith(t, haFixtureYAML) m.phase = phasePicking @@ -537,8 +484,6 @@ func TestInterfacesShowTheSecurityZone(t *testing.T) { if pane.err != "" { t.Fatalf("the Interfaces view failed: %s", pane.err) } - // The zone fetches must not have failed either: a broken one shows as a - // warn line rather than taking the view down. for _, e := range pane.rendered.Errs { t.Errorf("a fetch failed: %s", e) } @@ -559,9 +504,6 @@ func TestInterfacesShowTheSecurityZone(t *testing.T) { t.Fatalf("no ZONE column in %v", headers) } - // The zone hangs off the vlan interface, which is where a real element - // carries it: in interface.har every vlan row has a zone and every port, - // switch_port and bypasspair row has none. vlan := tableRow(t, pane, "vlan1") if got := vlan[zoneAt]; got != "Store - LAN" { t.Errorf("vlan1 zone = %q, want the zone name", got) @@ -573,9 +515,6 @@ func TestInterfacesShowTheSecurityZone(t *testing.T) { } } -// The data page is long -- four views, a 27-row interface table on a real -// element -- so `/` narrows it to the lines that match, keeping each line's -// view heading and its table header so a row is still readable. func TestTheDataPageCanBeFiltered(t *testing.T) { m, set := fixtureTUIWith(t, haFixtureYAML) m.phase, m.mode = phasePicking, modeData @@ -591,7 +530,6 @@ func TestTheDataPageCanBeFiltered(t *testing.T) { t.Fatalf("the page is %d lines, want a long one to filter", whole) } - // / opens it and typing narrows the page. mm, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: '/', Text: "/"})) m = mm.(tuiModel) if !m.dataFilterOn { @@ -613,24 +551,19 @@ func TestTheDataPageCanBeFiltered(t *testing.T) { if !strings.Contains(text, "vlan1") { t.Errorf("the filtered page lost its match:\n%s", text) } - // The view heading and the table's header come with it, or the row is a - // line of unlabelled columns. for _, want := range []string{"Interfaces", "PORT"} { if !strings.Contains(text, want) { t.Errorf("the filtered page is missing %q:\n%s", want, text) } } - // And nothing from the other views is left. if strings.Contains(text, "Rebooted") { t.Errorf("the filtered page kept lines that do not match:\n%s", text) } - // The pane says how much matched. if got := plain(m.viewData()); !strings.Contains(got, "of") || !strings.Contains(got, "lines") { t.Errorf("the pane does not say how much matched:\n%s", got) } - // Backspacing to nothing keeps the filter open; one more closes it. for range "vlan1" { mm, _ = m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyBackspace})) m = mm.(tuiModel) @@ -642,7 +575,6 @@ func TestTheDataPageCanBeFiltered(t *testing.T) { if mm.(tuiModel).dataFilterOn { t.Error("the filter is still open after backspacing the empty query") } - // The whole page is back. if got := len(m.visiblePage(m.data[0]).lines); got != whole { t.Errorf("%d lines with an empty query, want the whole page of %d", got, whole) } diff --git a/internal/tui/discover.go b/internal/tui/discover.go index 74ad43b..0802c9a 100644 --- a/internal/tui/discover.go +++ b/internal/tui/discover.go @@ -24,8 +24,6 @@ func discoverTenants(ctx context.Context, timeout time.Duration, st *config.Stor } } -// startDiscover signs in and finds out which tenants the account can reach. -// Nothing is typed and nothing is added: the portal decides the list. func (m tuiModel) startDiscover() (tea.Model, tea.Cmd) { m.status = "" m.phase = phaseDiscover @@ -42,24 +40,14 @@ func (m tuiModel) viewDiscover() string { return b.String() } -// tenantChoice is one row of the tenant list, which merges what the signed-in -// account can reach with whatever has a saved record of its own. A service -// account only ever appears through the saved side, since it carries exactly -// one tenant and cannot be discovered. type tenantChoice struct { - name string - tsg string - known bool // the browser account can reach it - saved bool // has a config record, so it may hold ION users or a secret - // serviceAccount means its saved record carries a service account rather - // than a browser credential, which is the only thing reachable without a - // browser sign-in. + name string + tsg string + known bool + saved bool serviceAccount bool } -// mergeKey identifies a tenant across the two sources. The tenant ID is the -// real identity, but a record saved without one must not collapse into every -// other such record, so its name stands in. func mergeKey(tsg, name string) string { if tsg != "" { return "tsg:" + tsg @@ -81,8 +69,6 @@ func (m tuiModel) tenantChoices() []tenantChoice { cur := merged[k] cur.tsg = t.TSGID cur.saved = true - // A saved record names the tenant, since that name is what the user - // sees elsewhere and what the config is keyed by. cur.name = t.Name cur.serviceAccount = !t.UsesBrowser() merged[k] = cur @@ -99,9 +85,6 @@ func (m tuiModel) tenantChoices() []tenantChoice { return out } -// switchChoices is what the switch page offers. In service account mode the -// browser-discovered tenants are left out: a service account credential cannot -// reach them, and listing them gives a page of tenants that all fail to connect. func (m tuiModel) switchChoices() []tenantChoice { all := m.tenantChoices() if m.authMode != authTenant { @@ -113,18 +96,48 @@ func (m tuiModel) switchChoices() []tenantChoice { out = append(out, c) } } + + if m.hasTenant && !m.tenant.UsesBrowser() { + found := false + for _, c := range out { + if c.tsg == m.tenant.TSGID && c.name == m.tenant.Name { + found = true + } + } + if !found { + out = append(out, tenantChoice{ + name: m.tenant.Name, tsg: m.tenant.TSGID, + serviceAccount: true, + }) + } + } return out } -// useTenant makes a tenant active, creating its record the first time it is -// used. Discovered tenants are browser-backed by definition. +func (m tuiModel) switchAt(cursor int) (tenantChoice, bool) { + choices := m.switchChoices() + if m.authMode == authTenant { + cursor-- + } + if cursor < 0 || cursor >= len(choices) { + return tenantChoice{}, false + } + return choices[cursor], true +} + +func (m tuiModel) switchRows() int { + n := len(m.switchChoices()) + if m.authMode == authTenant { + n++ + } + return n +} + func (m tuiModel) useTenant(c tenantChoice) (tuiModel, error) { t, ok := m.cfg.Tenant(c.name) if !ok || t.TSGID != c.tsg { t = config.Tenant{Name: c.name, TSGID: c.tsg, Auth: config.AuthBrowser} } - // Working in a tenant puts it in the set, so the element list and its - // search cover it from then on. t.Added = true m.cfg.Upsert(t) m.cfg.LastTenant = t.Name @@ -152,9 +165,6 @@ func (m tuiModel) useTenant(c tenantChoice) (tuiModel, error) { return m, nil } -// browserTenants are the tenants reachable through the signed-in account, plus -// any that already have a browser-backed record. This is the list "sign in with -// browser" leads to. func (m tuiModel) browserTenants() []tenantChoice { var out []tenantChoice for _, c := range m.tenantChoices() { @@ -165,9 +175,6 @@ func (m tuiModel) browserTenants() []tenantChoice { return out } -// serviceTenants are the tenants that exist only because a service account was -// configured for them. They cannot be discovered, so they are managed under -// settings rather than appearing in the browser account's list. func (m tuiModel) serviceTenants() []tenantChoice { var out []tenantChoice for _, c := range m.tenantChoices() { @@ -190,12 +197,9 @@ func (m tuiModel) tenantAuth(c tenantChoice) string { return "" } -// pickedSet is which discovered tenants are ticked in the picker. func (m tuiModel) startTenantPick() (tuiModel, tea.Cmd) { m.picked = map[string]bool{} for _, t := range m.discovered { - // Anything already in the working set starts ticked, so re-listing is - // not a way to lose it. if m.cfg.IsAdded(t.ID) { m.picked[t.ID] = true } @@ -243,8 +247,6 @@ func (m tuiModel) onTenantPickKey(key string) (tea.Model, tea.Cmd) { return m, nil } -// saveTenantPick turns the ticks into the working set. Records are created for -// newly added tenants, since that is where their ION users will live. func (m tuiModel) saveTenantPick() (tea.Model, tea.Cmd) { if len(m.picked) == 0 { m.status = "pick at least one tenant" @@ -265,7 +267,6 @@ func (m tuiModel) saveTenantPick() (tea.Model, tea.Cmd) { } rec.Added = m.picked[d.ID] if !rec.Added && !has { - // Never added and never configured: nothing worth recording. continue } m.cfg.Upsert(rec) @@ -312,8 +313,6 @@ func (m tuiModel) viewTenantPick() string { if m.picked[t.ID] { box = "[x] " } - // Pad the plain text before styling, or the escape codes count towards - // the width and the id shifts between rows. row := box + fmt.Sprintf("%-32s %s", ui.Truncate(t.Name, 32), t.ID) if i == m.pickCursor { cursor = ui.StyleCmd.Render("> ") diff --git a/internal/tui/discover_test.go b/internal/tui/discover_test.go index 83e34b5..22ff028 100644 --- a/internal/tui/discover_test.go +++ b/internal/tui/discover_test.go @@ -97,7 +97,6 @@ func TestDiscoveryLandsOnTheWorkingSetPicker(t *testing.T) { if len(got.discovered) != 3 { t.Errorf("discovered %d tenants, want 3 offered", len(got.discovered)) } - // Nothing is in the working set until it is ticked. if len(got.cfg.Tenants) != 0 { t.Errorf("%d tenants were saved before anything was picked", len(got.cfg.Tenants)) } @@ -123,8 +122,6 @@ func TestTenantChoicesMergeDiscoveredAndSaved(t *testing.T) { config.KnownTenant{TSGID: "1643707006", Name: "CIS root"}, config.KnownTenant{TSGID: "1984021017", Name: "Tractor Supply Co."}, ) - // A service account tenant the browser account cannot see, plus a saved - // record for one it can. m.cfg = &config.Config{Tenants: []config.Tenant{ {Name: "Legacy Co", TSGID: "999", Auth: config.AuthServiceAccount, ClientID: "svc"}, {Name: "Tractor Supply Co.", TSGID: "1984021017", Auth: config.AuthBrowser}, @@ -148,7 +145,6 @@ func TestTenantChoicesMergeDiscoveredAndSaved(t *testing.T) { if c := byTSG["999"]; c.known || !c.saved { t.Errorf("Legacy Co = %+v, want saved only", c) } - // Sorted by name. if got[0].name != "CIS root" { t.Errorf("first choice = %q, want the list sorted by name", got[0].name) } @@ -182,7 +178,6 @@ func TestUsingADiscoveredTenantCreatesItsRecordLazily(t *testing.T) { if got.cfg.LastTenant != saved.Name { t.Errorf("LastTenant = %q, want %q", got.cfg.LastTenant, saved.Name) } - // A fresh tenant has no elements yet, so a fetch is owed. if got.cacheState != cacheUpdating { t.Errorf("cacheState = %v, want a fetch owed for a tenant never used", got.cacheState) } @@ -302,12 +297,12 @@ func TestMenuNamesTheTwoCredentialPaths(t *testing.T) { } joined := strings.Join(labels, " | ") - for _, gone := range []string{"Add Account", "Add Tenant", "switch tenant", "service accounts", "start ION shell"} { + for _, gone := range []string{"Add Account", "Add Tenant", "switch tenant", "Service Accounts", "start ION shell"} { if strings.Contains(joined, gone) { t.Errorf("the menu still offers %q: %s", gone, joined) } } - for _, want := range []string{"elements", "sign in with browser"} { + for _, want := range []string{"Elements", "Sign In with Browser"} { if !strings.Contains(joined, want) { t.Errorf("the menu is missing %q: %s", want, joined) } @@ -386,8 +381,6 @@ func TestRenderDiscoverWait(t *testing.T) { } func TestTenantChoicesSurviveANilStore(t *testing.T) { - // menuEntries calls this during rendering, so it must not panic before a - // store exists. m := tuiModel{cfg: &config.Config{}} if got := m.tenantChoices(); len(got) != 0 { t.Errorf("tenantChoices() = %+v, want none", got) @@ -403,8 +396,6 @@ func TestTenantChoicesSurviveANilStore(t *testing.T) { } func TestTenantsWithoutAnIDDoNotCollapse(t *testing.T) { - // A tenant ID is the real identity, but records predating one must stay - // distinct rather than merging into a single row. m := discoverModel(t) m.cfg = &config.Config{Tenants: []config.Tenant{ {Name: "Acme Co"}, @@ -448,8 +439,6 @@ func TestSwitchingActuallyFetchesForTheNewTenant(t *testing.T) { t.Run(tt.name, func(t *testing.T) { m := discoverModel(t) seedKnown(t, m, config.KnownTenant{TSGID: "111", Name: "Acme Co"}) - // Browser tenants validate with only a tenant ID, so a fetch is - // genuinely startable here. if tt.cached { if err := m.store.SaveCache(&config.ElementCache{ TSGID: "111", FetchedAt: time.Now().Add(-tt.cacheAge), @@ -481,7 +470,6 @@ func TestSwitchingActuallyFetchesForTheNewTenant(t *testing.T) { func TestSwitchingToATenantWithNoUsableCredentialSaysSo(t *testing.T) { m := discoverModel(t) - // A service account record with no secret stored cannot authenticate. m.cfg = &config.Config{Tenants: []config.Tenant{ {Name: "Legacy Co", TSGID: "999", Auth: config.AuthServiceAccount}, }} @@ -497,8 +485,6 @@ func TestSwitchingToATenantWithNoUsableCredentialSaysSo(t *testing.T) { } } -// setTestElements loads plain elements as if they all came from one tenant, -// which is what the single-tenant tests mean. func setTestElements(m *tuiModel, els []api.Element) { rows := make([]elementRow, 0, len(els)) for _, el := range els { @@ -507,9 +493,6 @@ func setTestElements(m *tuiModel, els []api.Element) { m.setElements(rows) } -// setTestClient makes a client reachable the way production does: in the pool, -// keyed by tenant. Tests that only set m.client would find no client to dial -// with. func setTestClient(m *tuiModel, c *api.Client) { m.client = c if m.pool == nil { @@ -526,7 +509,6 @@ func TestServiceAccountModeOnlyOffersWhatItCanReach(t *testing.T) { {Name: "Saved Browser", TSGID: "222", Auth: config.AuthBrowser}, }} m.tenant, m.hasTenant = m.cfg.Tenants[0], true - // A browser sign-in listed a third tenant that has no saved credential. if err := m.store.SaveTenantCache(&config.TenantCache{ Account: "someone@example.com", Tenants: []config.KnownTenant{{TSGID: "333", Name: "Discovered"}}, @@ -539,8 +521,6 @@ func TestServiceAccountModeOnlyOffersWhatItCanReach(t *testing.T) { t.Errorf("browser mode offers %d tenants, want all 3", got) } - // A service account credential cannot reach a discovered tenant or a - // browser-backed one, and offering them is a page of tenants that all fail. m.authMode = authTenant got := m.switchChoices() if len(got) != 1 || got[0].name != "Saved SA" { @@ -551,7 +531,6 @@ func TestServiceAccountModeOnlyOffersWhatItCanReach(t *testing.T) { if !strings.Contains(view, "1 service account\n") { t.Errorf("the page does not count service accounts:\n%s", view) } - // The browser account that listed those tenants is irrelevant here. if strings.Contains(view, "someone@example.com") { t.Errorf("the page claims browser provenance in service account mode:\n%s", view) } @@ -577,3 +556,142 @@ func TestReListIsRefusedInServiceAccountMode(t *testing.T) { t.Errorf("status = %q, want it to say where service accounts come from", got.status) } } + +func serviceModel(t *testing.T) tuiModel { + t.Helper() + m := multiModel(t) + m.phase, m.authMode = phaseSwitch, authTenant + m.width, m.height = 100, 30 + m.cfg.Tenants = []config.Tenant{ + {Name: "Acme Co", TSGID: "111", Auth: config.AuthServiceAccount, + ClientID: "svc@1234.iam.panserviceaccount.com", Added: true}, + } + m.tenant, m.hasTenant = m.cfg.Tenants[0], true + return m +} + +func TestTheServiceAccountPageAddsAndForgets(t *testing.T) { + m := serviceModel(t) + + out := plain(m.viewSwitch()) + if !strings.Contains(out, "+ New") { + t.Errorf("the page offers no way to add an account:\n%s", out) + } + for _, want := range []string{"x forget", "u undo"} { + if !strings.Contains(out, want) { + t.Errorf("the hint does not offer %q:\n%s", want, out) + } + } + + if _, ok := m.switchAt(0); ok { + t.Error("row 0 answered an account, want the action row") + } + c, ok := m.switchAt(1) + if !ok || c.name != "Acme Co" { + t.Fatalf("switchAt(1) = %+v, %v", c, ok) + } + + next, _ := m.onSwitchKey("enter") + if got := next.(tuiModel).formKind; got != formTenant { + t.Errorf("enter on the action row opened %v, want the tenant form", got) + } + + m.switchCursor = 1 + next, _ = m.onSwitchKey("x") + m = next.(tuiModel) + if _, still := m.cfg.Tenant("Acme Co"); !still { + t.Fatal("one x forgot the account") + } + if !strings.Contains(m.status, "x again") { + t.Errorf("status = %q, want it to ask for the second press", m.status) + } + next, _ = m.onSwitchKey("x") + m = next.(tuiModel) + if _, still := m.cfg.Tenant("Acme Co"); still { + t.Errorf("the second x left it in place: %s", m.status) + } + + next, _ = m.onSwitchKey("u") + m = next.(tuiModel) + if _, back := m.cfg.Tenant("Acme Co"); !back { + t.Errorf("u did not put the record back: %s", m.status) + } + if !strings.Contains(m.status, "secret") { + t.Errorf("status = %q, want it to say the secret has to be typed again", m.status) + } + next, _ = m.onSwitchKey("u") + if got := next.(tuiModel).status; !strings.Contains(got, "nothing to undo") { + t.Errorf("status = %q with nothing left to undo", got) + } +} + +func TestTheAccountInUseIsListedEvenWithNoSavedRecord(t *testing.T) { + m := serviceModel(t) + m.cfg.Tenants = nil + m.tenant, m.hasTenant = config.Tenant{Name: "env", TSGID: "999"}, true + + choices := m.switchChoices() + if len(choices) != 1 || choices[0].tsg != "999" { + t.Fatalf("switchChoices() = %+v, want the account in use", choices) + } + + out := plain(m.viewSwitch()) + if strings.Contains(out, "0 service account") { + t.Errorf("the page says there are none while one is in use:\n%s", out) + } + if !strings.Contains(out, "from the environment") { + t.Errorf("nothing says the account has no saved record:\n%s", out) + } + + m.switchCursor = 1 + next, _ := m.onSwitchKey("x") + if got := next.(tuiModel).status; !strings.Contains(got, "no saved record") { + t.Errorf("status = %q, want it to say there is nothing to forget", got) + } +} + +func TestTheBrowserTenantPageHasNoAddRow(t *testing.T) { + m := serviceModel(t) + m.authMode = authBrowser + + if out := plain(m.viewSwitch()); strings.Contains(out, "+ New") { + t.Errorf("the browser page offers to type one in:\n%s", out) + } + if _, ok := m.switchAt(0); !ok { + t.Error("row 0 is not a tenant on the browser page") + } + m.switchCursor = 0 + next, _ := m.onSwitchKey("x") + if got := next.(tuiModel).status; strings.Contains(got, "x again") { + t.Error("the browser page armed a forget") + } +} + +func TestRenderTheServiceAccountPage(t *testing.T) { + m := serviceModel(t) + t.Log("\n" + m.viewSwitch()) +} + +func TestABrowserTenantIsNeverListedAsAServiceAccount(t *testing.T) { + m := serviceModel(t) + m.cfg.Tenants = []config.Tenant{ + {Name: "Tractor Supply", TSGID: "111", Auth: config.AuthBrowser, Added: true}, + } + m.tenant, m.hasTenant = m.cfg.Tenants[0], true + + for _, c := range m.switchChoices() { + if c.name == "Tractor Supply" { + t.Errorf("a browser tenant is on the service account page: %+v", c) + } + } + out := plain(m.viewSwitch()) + if strings.Contains(out, "Tractor Supply") { + t.Errorf("the page lists it:\n%s", out) + } + if got := m.switchRows(); got != 1 { + t.Errorf("switchRows() = %d, want just the action row", got) + } + if _, ok := m.switchAt(1); ok { + t.Error("there is a selectable row below the action row") + } +} diff --git a/internal/tui/favourites.go b/internal/tui/favourites.go index 9412f3c..9dee98d 100644 --- a/internal/tui/favourites.go +++ b/internal/tui/favourites.go @@ -10,9 +10,6 @@ import ( "tyrtop.com/iontk/internal/ui" ) -// altDigit is alt+1 to alt+9, which used to select a tab and now reaches a -// favourite. Tabs kept alt+←/alt+→: alt+shift+N is layout-dependent and -// ctrl+N mostly untransmittable, so there was no third chord to move them to. func altDigit(key string) (int, bool) { if len(key) != 5 || !strings.HasPrefix(key, "alt+") || key[4] < '1' || key[4] > '9' { return 0, false @@ -27,9 +24,6 @@ func plainDigit(key string) (int, bool) { return int(key[0] - '0'), true } -// useFavourite puts a slot's command on the device line. Inserted and not -// sent, like the palette: a stored command is read before it runs, which is -// also what makes a cross-model favourite safe to offer at all. func (m tuiModel) useFavourite(slot int) (tea.Model, tea.Cmd) { f, ok := m.cmds.Favourite(slot) if !ok { @@ -44,7 +38,6 @@ func (m tuiModel) useFavourite(slot int) (tea.Model, tea.Cmd) { next, cmd := m.insertCommand(f.Command) nm := next.(tuiModel) - // A refusal says more than the model note does. if nm.status == "" { nm.status = note } @@ -86,8 +79,6 @@ func (m tuiModel) onFavouriteKey(key string) (tea.Model, tea.Cmd) { } return m.copyText(fmt.Sprintf("alt+%d", m.favCursor), f.Command) } - // A digit picks the slot and saves in one keypress, which is the whole - // point of a slot picker. if n, ok := plainDigit(key); ok { m.favCursor = n return m.saveFavourite(n) @@ -99,9 +90,6 @@ func (m tuiModel) onFavouriteKey(key string) (tea.Model, tea.Cmd) { return m, nil } -// saveFavourite captures the line the session is on. The prompt line rather -// than the last command run: what is being typed is what someone has just -// decided is worth keeping. func (m tuiModel) saveFavourite(slot int) (tea.Model, tea.Cmd) { line := "" if s := m.sessions.Active(); s != nil { @@ -129,8 +117,6 @@ func (m tuiModel) clearFavourite(slot int) (tea.Model, tea.Cmd) { return m, saveCommands(m.store, m.cmds) } -// openFavouriteForm is the by-hand route: a command for a model that is not in -// front of you, or one too long to want to type at a prompt first. func (m tuiModel) openFavouriteForm(slot int) (tea.Model, tea.Cmd) { cmd := newField("command", "dump device status", false) if f, ok := m.cmds.Favourite(slot); ok { @@ -146,9 +132,6 @@ func (m tuiModel) openFavouriteForm(slot int) (tea.Model, tea.Cmd) { []formField{cmd, at}) } -// submitFavourite is the form's enter. The model is the one the session is on, -// which is empty for a favourite typed in with nothing open -- better than -// claiming it was captured on whatever happens to be in front of the user. func (m tuiModel) submitFavourite() (tea.Model, tea.Cmd) { command := m.formValue(0) slot, err := strconv.Atoi(m.formValue(1)) @@ -166,9 +149,6 @@ func (m tuiModel) submitFavourite() (tea.Model, tea.Cmd) { return m, saveCommands(m.store, m.cmds) } -// overlayFavourites draws the picker over the bottom of the session pane, the -// same way the retry question does, so what is on screen behind it still says -// which element this is. func (m tuiModel) overlayFavourites(content string) string { lines := []string{ ui.StyleCmd.Render("favourites") + ui.StyleDim.Render(" alt+1-9 inserts one"), @@ -185,7 +165,6 @@ func (m tuiModel) overlayFavourites(content string) string { lines = append(lines, row+ui.StyleDim.Render("empty")) continue } - // Padded before styling, or the model tags drift row by row. cmd := fmt.Sprintf("%-34s", ui.Truncate(f.Command, 34)) tag := "" if f.Model != "" { diff --git a/internal/tui/favourites_test.go b/internal/tui/favourites_test.go index a212f83..c3de7d5 100644 --- a/internal/tui/favourites_test.go +++ b/internal/tui/favourites_test.go @@ -9,8 +9,6 @@ import ( "tyrtop.com/iontk/internal/ionterm" ) -// favModel is a shell open on an ion 1200, which is what a favourite's model -// is compared against. func favModel(t *testing.T) (tuiModel, *recConn) { t.Helper() m := historyModel(t) @@ -30,12 +28,8 @@ func pressSessionKey(t *testing.T, m tuiModel, key tea.Key) (tuiModel, tea.Cmd) return next.(tuiModel), cmd } -// No Text: with it set, KeyPressMsg.String() reports the character and drops -// the modifier, so an alt chord built that way arrives as a plain keypress. func altKey(r rune) tea.Key { return tea.Key{Code: r, Mod: tea.ModAlt} } -// alt+N puts the command on the line without sending it, the same discipline -// the palette has: a stored command is read before it runs. func TestAltDigitInsertsAFavourite(t *testing.T) { m, conn := favModel(t) if err := m.cmds.SetFavourite(1, "dump device status", "ion 1200"); err != nil { @@ -47,7 +41,6 @@ func TestAltDigitInsertsAFavourite(t *testing.T) { t.Errorf("status = %q, want nothing to say for a favourite from this model", m.status) } waitForTUI(t, func() bool { return strings.Contains(conn.sent(), "dump device status") }) - // Inserted, not sent. if strings.Contains(conn.sent(), "\r") { t.Errorf("the favourite was sent, not inserted: %q", conn.sent()) } @@ -64,8 +57,6 @@ func TestAnEmptySlotSaysHowToFillIt(t *testing.T) { } } -// The model a favourite was captured on is why it is stored: a 1200 command -// is flagged on a 3200 rather than silently failing there. func TestAFavouriteFromAnotherModelIsFlagged(t *testing.T) { m, conn := favModel(t) if err := m.cmds.SetFavourite(2, "dump interface status 8", "ion 3200"); err != nil { @@ -78,12 +69,9 @@ func TestAFavouriteFromAnotherModelIsFlagged(t *testing.T) { t.Errorf("status = %q, want it to name %q", m.status, want) } } - // Flagged, not blocked: it is on the line for the user to read. waitForTUI(t, func() bool { return strings.Contains(conn.sent(), "dump interface status 8") }) } -// alt+f captures what is being typed, which is the moment someone decides a -// command is worth keeping. func TestAltFSavesThePromptLine(t *testing.T) { m, _ := favModel(t) m = typeString(t, m, "dump vpn count") @@ -96,7 +84,6 @@ func TestAltFSavesThePromptLine(t *testing.T) { t.Errorf("the picker is not on screen:\n%s", got) } - // A digit picks the slot and saves in one keypress. m, cmd := pressSessionKey(t, m, tea.Key{Code: '4', Text: "4"}) if m.favPick { t.Error("the picker stayed open after saving") @@ -138,7 +125,6 @@ func TestThePickerListsAndClearsSlots(t *testing.T) { } } - // The cursor starts on slot 1, which is the one ctrl+d clears. cleared, cmd := m.onFavouriteKey("ctrl+d") after := cleared.(tuiModel) if _, ok := after.cmds.Favourite(1); ok { @@ -148,7 +134,6 @@ func TestThePickerListsAndClearsSlots(t *testing.T) { t.Error("clearing queued no write") } - // Nothing on the line, so a digit has nothing to save and says so. next, _ := after.onFavouriteKey("2") empty := next.(tuiModel) if !strings.Contains(empty.status, "nothing on the line") { @@ -164,7 +149,6 @@ func TestThePickerListsAndClearsSlots(t *testing.T) { } } -// The by-hand route: a command for hardware that is not in front of you. func TestTheFavouriteFormSaves(t *testing.T) { m, _ := favModel(t) m, _ = pressSessionKey(t, m, altKey('f')) @@ -192,7 +176,6 @@ func TestTheFavouriteFormSaves(t *testing.T) { } runSave(t, cmd) - // A slot outside the range is refused rather than written somewhere else. m.form = []formField{newField("command", "", false), newField("slot", "", false)} m.formKind = formFavourite m.form[0].input.SetValue("uptime") @@ -203,8 +186,6 @@ func TestTheFavouriteFormSaves(t *testing.T) { } } -// alt+N was tab selection. It is a favourite now, and the tab bar no longer -// advertises numbers that do nothing. func TestNumericTabSelectionIsRetired(t *testing.T) { m, _ := favModel(t) conn2 := newRecConn() @@ -218,7 +199,6 @@ func TestNumericTabSelectionIsRetired(t *testing.T) { if m.currentTab() != before { t.Errorf("alt+1 moved the tab from %d to %d, want it to reach a favourite instead", before, m.currentTab()) } - // alt+←/→ is what moves tabs now. m, _ = pressSessionKey(t, m, tea.Key{Code: tea.KeyLeft, Mod: tea.ModAlt}) if m.currentTab() == before { t.Error("alt+left did not move the tab") @@ -234,7 +214,6 @@ func TestFavouriteSlotsSurviveARestart(t *testing.T) { t.Fatal(err) } - // What a fresh process does: read the store back. cfg, err := m.store.Config() if err != nil { t.Fatal(err) diff --git a/internal/tui/filter_test.go b/internal/tui/filter_test.go index 1874cec..038449d 100644 --- a/internal/tui/filter_test.go +++ b/internal/tui/filter_test.go @@ -47,7 +47,6 @@ func TestFilterCombinesTermsAcrossFields(t *testing.T) { want []string }{ { - // The case that prompted this: a term each for site, model and tenant. name: "site, model and tenant together", query: "100 1200 tract", want: []string{"s0100-rtr-01"}, @@ -134,8 +133,6 @@ func TestFilterKeepsTheCursorInRange(t *testing.T) { } func TestFilterWithNoTenantColumnStillSearchesTenant(t *testing.T) { - // A single tenant hides the column, but its name should still be a usable - // search term. m := multiModel(t) m.mergeTenantElements("111", "Tractor Supply", []api.Element{{ID: "e1", Name: "s0100-rtr-01"}}) if m.showTenantColumn() { @@ -171,8 +168,6 @@ func TestRenderFilteredCrossTenantList(t *testing.T) { var _ = config.AuthBrowser -// rankModel holds the store numbers that exposed the ranking problem: s5024 is -// what is wanted, and s0500 is the name that an in-order match drags in. func rankModel(t *testing.T) tuiModel { t.Helper() m := multiModel(t) @@ -197,7 +192,7 @@ func TestGeneralSectionRowsAreAllReachable(t *testing.T) { t.Fatalf("sectionRows() = %d, want %d", got, generalRows) } out := plain(m.viewSettings()) - for _, want := range []string{"commands", "mode", "jump", "times", "elements", "views"} { + for _, want := range []string{"Commands", "Mode", "Jump", "Times", "Elements", "views"} { if !strings.Contains(out, want) { t.Errorf("the General section is missing %q:\n%s", want, out) } @@ -219,8 +214,6 @@ func TestResultsStayInNameOrder(t *testing.T) { } } -// searchEnterModel is a connected, credentialled single tenant so that acting -// on a search result reaches the dial rather than a credential prompt. func searchEnterModel(t *testing.T) tuiModel { t.Helper() m := multiModel(t) @@ -326,8 +319,6 @@ func TestSeveralMatchesJustApplyTheFilter(t *testing.T) { } func TestASingleMatchBeatsAnEarlierSelection(t *testing.T) { - // Selections survive filtering, so a search that lands on one ION has to - // act on that one rather than on what was ticked before. m := searchEnterModel(t) m.mode = modeShell m.selected["111/e3"] = true @@ -399,7 +390,6 @@ func TestClearingTheSelection(t *testing.T) { if !strings.Contains(got.status, "cleared 3") { t.Errorf("status = %q, want the count reported", got.status) } - // Every element is listed again. if len(got.matches) != 3 { t.Errorf("matched %d, want the full list back", len(got.matches)) } @@ -429,7 +419,6 @@ func TestShowingOnlyTheSelection(t *testing.T) { t.Errorf("the list does not say it is narrowed:\n%s", out) } - // And back again. mm, _ = got.onPickKey(tea.KeyPressMsg(tea.Key{Code: 's', Mod: tea.ModAlt}), "alt+s") back := mm.(tuiModel) if back.showSelected { @@ -453,8 +442,6 @@ func TestShowingTheSelectionNeedsASelection(t *testing.T) { } func TestClearingWhileShowingTheSelectionRestoresTheList(t *testing.T) { - // Clearing the selection while only it is shown would otherwise leave an - // empty list with no obvious way back. m := searchEnterModel(t) m.selected["111/e2"] = true m.showSelected = true @@ -478,8 +465,6 @@ func TestSelectedOnlyStillHonoursTheQuery(t *testing.T) { m.filter.SetValue("s5024") m.applyFilter() - // e1 is s5024-rtr-01 and is selected; e3 is s0100 and is selected but does - // not match the query. if len(m.matches) != 1 { t.Fatalf("matched %d, want the intersection of query and selection", len(m.matches)) } @@ -508,8 +493,6 @@ func TestJAndKMoveTheList(t *testing.T) { } func TestScopeIsComputedOnceNotPerRender(t *testing.T) { - // Walking every element on each render is what made holding a movement key - // lag, so the label and the tenant count come from the list changing. m := multiModel(t) if got := m.scopeLabel(); got != "Tractor Supply" { t.Errorf("scopeLabel() = %q with nothing loaded, want the current tenant", got) @@ -537,7 +520,6 @@ func TestScopeIsComputedOnceNotPerRender(t *testing.T) { t.Error("the tenant column did not show for two tenants") } - // Emptying one tenant brings it back down. m.mergeTenantElements("222", "Niagara Bottling", nil) if m.tenantCount != 1 { t.Errorf("tenantCount = %d, want 1 after the second tenant emptied", m.tenantCount) @@ -584,7 +566,6 @@ func TestUndoIsOnlyOfferedWhenThereIsSomethingToUndo(t *testing.T) { t.Errorf("status = %q, want it to say so", got.status) } - // And it is spent after one use. m.selected["111/e1"] = true mm, _ = m.onPickKey(tea.KeyPressMsg(tea.Key{Code: 'c', Mod: tea.ModAlt}), "alt+c") mm, _ = mm.(tuiModel).onPickKey(tea.KeyPressMsg(tea.Key{Code: 'z', Mod: tea.ModAlt}), "alt+z") @@ -596,7 +577,6 @@ func TestUndoIsOnlyOfferedWhenThereIsSomethingToUndo(t *testing.T) { } func TestUndoRestoresRatherThanReplaces(t *testing.T) { - // Anything picked after the clear has to survive the undo. m := searchEnterModel(t) m.selected["111/e1"] = true @@ -634,8 +614,6 @@ func TestUndoBringsBackTheSelectionOnlyView(t *testing.T) { } func TestUndoDoesNotAliasTheClearedSelection(t *testing.T) { - // The model is copied by value, so keeping a reference to the live map - // would make the undo see later edits instead of what was cleared. m := searchEnterModel(t) m.selected["111/e1"] = true @@ -745,7 +723,6 @@ func TestArrowsStillMoveTheListWhileSearching(t *testing.T) { m := filterModel(t) m = typeIntoFilter(t, m, "1200") - // Arrows are not text, so a narrowed list stays navigable mid-query. m.table.SetCursor(0) start := m.table.Cursor() next, _ := m.onPickKey(tea.KeyPressMsg(tea.Key{Code: tea.KeyDown}), "down") @@ -758,10 +735,6 @@ func TestArrowsStillMoveTheListWhileSearching(t *testing.T) { } } -// Backspacing a query down to nothing leaves the filter **open**, so typing -// can carry straight on; it is the next backspace -- the one that deletes the -// "/" itself -- that goes back to the list. Leaving on the press that emptied -// the query was one too early. func TestBackspacingTheQueryEmptiesItThenLeaves(t *testing.T) { m := pickModel(t) m.phase = phasePicking @@ -781,7 +754,6 @@ func TestBackspacingTheQueryEmptiesItThenLeaves(t *testing.T) { for range "s0" { m = backspace(m) } - // Empty, still in the filter, whole list back. if !m.filterOn { t.Error("the filter closed on the press that emptied the query") } @@ -792,7 +764,6 @@ func TestBackspacingTheQueryEmptiesItThenLeaves(t *testing.T) { t.Errorf("%d matches, want the whole list of %d", len(m.matches), len(m.all)) } - // Typing carries on from there rather than needing / again. m = typeString(t, m, "s9") if m.filter.Value() != "s9" { t.Errorf("query = %q, want typing to continue in the filter", m.filter.Value()) @@ -801,7 +772,6 @@ func TestBackspacingTheQueryEmptiesItThenLeaves(t *testing.T) { m = backspace(m) } - // One more deletes the "/" and hands the keys back to the list. m = backspace(m) if m.filterOn { t.Error("the filter is still open after backspacing the empty query") @@ -812,7 +782,6 @@ func TestBackspacingTheQueryEmptiesItThenLeaves(t *testing.T) { } } -// The same rule on the macro page, which has a filter of its own. func TestBackspacingTheMacroQueryEmptiesItThenLeaves(t *testing.T) { m := macroModel(t) @@ -833,22 +802,16 @@ func TestBackspacingTheMacroQueryEmptiesItThenLeaves(t *testing.T) { t.Errorf("%d rows, want every macro back", len(m.macroRows())) } - // The press that deletes the "/" is the one that leaves. next, _ = m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyBackspace})) if next.(tuiModel).macroFilterOn { t.Error("the macro filter is still open after backspacing the empty query") } } -// The widget's own MoveUp scrolls its viewport away from the cursor, so a held -// k used to walk the highlight off the top of the window and the list stopped -// appearing to move. Both directions go through moveCursorTo now. func TestHoldingKWalksBackUpTheList(t *testing.T) { m := pickModel(t) m.phase = phasePicking m.height = 12 - // More rows than the window, which is when the viewport arithmetic - // mattered. els := make([]api.Element, 40) for i := range els { els[i] = api.Element{ID: fmt.Sprint(i), Name: fmt.Sprintf("S%04d-RTR-01", i), Connected: true} @@ -866,8 +829,6 @@ func TestHoldingKWalksBackUpTheList(t *testing.T) { t.Errorf("the row the cursor is on is not in the window:\n%s", plain(m.table.View())) } - // Back up one at a time: the cursor and the window move together the - // whole way. for i := 29; i >= 0; i-- { next, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: 'k', Text: "k"})) m = next.(tuiModel) @@ -880,7 +841,6 @@ func TestHoldingKWalksBackUpTheList(t *testing.T) { } } -// alt+j and alt+k jump by the page step, like alt+↓ and alt+↑. func TestAltJAndAltKJumpTheList(t *testing.T) { m := pickModel(t) m.cfg.PageStep = 2 @@ -897,7 +857,6 @@ func TestAltJAndAltKJumpTheList(t *testing.T) { } } -// alt+h and alt+l walk the tab ring, like alt+← and alt+→. func TestAltHAndAltLSwitchTabs(t *testing.T) { m := pickModel(t) m.data = []dataTab{{eid: "1", name: "S0100-RTR-01", tsg: "1"}} @@ -915,8 +874,6 @@ func TestAltHAndAltLSwitchTabs(t *testing.T) { } } -// The serial is on the row and matched by the filter: a column you can read is -// one people will type into, and a serial is quoted off a label or a case. func TestTheSerialIsOnTheRowAndSearchable(t *testing.T) { m := multiModel(t) m.mergeTenantElements("111", "Tractor Supply", []api.Element{ @@ -931,7 +888,7 @@ func TestTheSerialIsOnTheRowAndSearchable(t *testing.T) { t.Fatal("the sn column is hidden on a list whose records carry serials") } out := plain(m.viewPicking()) - for _, want := range []string{"sn", "ION1200ABC123", "ION1200XYZ789"} { + for _, want := range []string{"SN", "ION1200ABC123", "ION1200XYZ789"} { if !strings.Contains(out, want) { t.Errorf("the list is missing %q:\n%s", want, out) } @@ -947,15 +904,9 @@ func TestTheSerialIsOnTheRowAndSearchable(t *testing.T) { } } -// A connected element reads green across the line, an offline one keeps red on -// the state alone, and the row under the cursor gives both up: the table paints -// its highlight around the whole row, and a highlight wrapped around coloured -// cells ends at the first reset inside them. func TestConnectedRowsAreGreenAndOfflineKeepsRed(t *testing.T) { m := multiModel(t) m.mergeTenantElements("111", "Tractor Supply", []api.Element{ - // Sorted by name, so the offline one is row 0 and starts under the - // cursor. {ID: "e2", Name: "s0500-rtr-01", Model: "ion1200"}, {ID: "e1", Name: "s5024-rtr-01", Model: "ion1200", Connected: true}, }) @@ -963,9 +914,8 @@ func TestConnectedRowsAreGreenAndOfflineKeepsRed(t *testing.T) { m.width, m.height = 140, 30 m.rebuildRows() - // Cursor on the offline row: the connected one is green, name and all. out := m.viewPicking() - if !strings.Contains(out, ui.StyleOK.Render("s5024-rtr-01")) { + if !strings.Contains(out, ui.StyleOK.Render(" s5024-rtr-01")) { t.Errorf("a connected element is not green across the line:\n%q", out) } if !strings.Contains(out, ui.StyleOK.Render("connected")) { @@ -975,7 +925,6 @@ func TestConnectedRowsAreGreenAndOfflineKeepsRed(t *testing.T) { t.Error("the row under the cursor kept its own colour, which breaks the highlight") } - // Move off it: now offline is red and the connected row is the plain one. m.moveCursorTo(1) out = m.viewPicking() if !strings.Contains(out, ui.StyleErr.Render("offline")) { @@ -984,19 +933,16 @@ func TestConnectedRowsAreGreenAndOfflineKeepsRed(t *testing.T) { if strings.Contains(out, ui.StyleOK.Render("s5024-rtr-01")) { t.Error("the row under the cursor is still green, which breaks the highlight") } - // Connected is never red, wherever the cursor is. if strings.Contains(out, ui.StyleErr.Render("connected")) { t.Error("connected is rendered in the error colour") } } -// Columns are sized to what is in them, which is why site stopped being wider -// than its names while a serial was cut off. func TestColumnsAreSizedToTheirContent(t *testing.T) { cols := []elementColumn{ {title: "element", min: 12, max: 28, flex: true}, {title: "site", min: 8, max: 22, flex: true}, - {title: "sn", min: 10, max: 24, flex: true}, + {title: "SN", min: 10, max: 24, flex: true}, {title: "state", min: 9, max: 9}, } values := [][]string{ @@ -1011,8 +957,6 @@ func TestColumnsAreSizedToTheirContent(t *testing.T) { t.Errorf("sn = %d, want the whole serial (%d)", wide[2], len("ION1200ABCDEF123456")) } - // On a narrow terminal the flexible columns give way, widest first, and - // the fixed ones never do. narrow := fitColumns(cols, values, 60) total := 0 for _, w := range narrow { @@ -1048,3 +992,128 @@ func TestRenderTheElementListWithSerials(t *testing.T) { m.rebuildRows() t.Log("\n" + m.viewPicking()) } + +func TestATickedRowCarriesABarAgainstItsName(t *testing.T) { + m := searchEnterModel(t) + m.applyFilter() + if len(m.matches) < 2 { + t.Fatalf("need two rows to compare, got %d", len(m.matches)) + } + + nameCell := func(row int) string { + for i, c := range m.listKeep { + if c == colName { + return m.table.Rows()[row][i] + } + } + t.Fatal("no name column") + return "" + } + + el := m.all[m.matches[1]] + m.selected = map[string]bool{el.key(): true} + m.rebuildRows() + + ticked := nameCell(1) + if !strings.HasPrefix(plain(ticked), "▌ ") { + t.Errorf("the name cell is %q, want the bar against the name", plain(ticked)) + } + if !strings.Contains(ticked, "\x1b[1m") { + t.Errorf("the name is %q, want it bold", ticked) + } + + plainCell := plain(nameCell(0)) + if strings.Contains(plainCell, "▌") { + t.Errorf("an unticked row carries the bar: %q", plainCell) + } + if !strings.HasPrefix(plainCell, " ") { + t.Errorf("an unticked name is %q, want it padded into line", plainCell) + } + if a, b := strings.TrimLeft(plainCell, " "), strings.TrimLeft(plain(ticked), "▌ "); a == "" || b == "" { + t.Error("a name cell came out empty") + } + + m.selected = map[string]bool{} + m.rebuildRows() + if got := plain(nameCell(1)); strings.Contains(got, "▌") { + t.Errorf("the bar survived unticking: %q", got) + } +} + +func TestTheListAndItsChromeFitTheFrame(t *testing.T) { + m := searchEnterModel(t) + for _, h := range []int{24, 30, 44, 60} { + m.height, m.width = h, 120 + m.applyFilter() + frame := plain(m.viewPicking()) + lines := strings.Split(strings.TrimRight(frame, "\n"), "\n") + if len(lines) > h-1 { + t.Errorf("at height %d the list draws %d lines, which the frame clips:\n%s", + h, len(lines), frame) + } + } +} + +func TestOpeningTheFilterAgainStartsFresh(t *testing.T) { + m := searchEnterModel(t) + + next, _ := m.onPickKey(tea.KeyPressMsg(tea.Key{Code: '/', Text: "/"}), "/") + m = next.(tuiModel) + for _, r := range "e1" { + next, _ = m.onPickKey(tea.KeyPressMsg(tea.Key{Code: r, Text: string(r)}), string(r)) + m = next.(tuiModel) + } + next, _ = m.onPickKey(tea.KeyPressMsg(tea.Key{Code: tea.KeyEnter}), "enter") + m = next.(tuiModel) + + if m.filterOn { + t.Error("enter did not leave the filter") + } + applied := m.filter.Value() + if applied == "" { + t.Fatal("enter dropped the query, which is what it is meant to keep") + } + narrowed := len(m.matches) + if narrowed >= len(m.all) { + t.Fatalf("the query matched everything (%d of %d), so there is nothing to clear", + narrowed, len(m.all)) + } + + next, _ = m.onPickKey(tea.KeyPressMsg(tea.Key{Code: '/', Text: "/"}), "/") + m = next.(tuiModel) + if got := m.filter.Value(); got != "" { + t.Errorf("the filter reopened on %q, want an empty query", got) + } + if len(m.matches) != len(m.all) { + t.Errorf("%d of %d rows after reopening, want the whole list back", + len(m.matches), len(m.all)) + } + if !m.filterOn { + t.Error("the filter did not open") + } +} + +func TestAltAAlsoCyclesTheMode(t *testing.T) { + m := searchEnterModel(t) + m.phase = phasePicking + + for _, key := range []string{"alt+m", "alt+a"} { + before := m.mode + next, _ := m.onPickKey(tea.KeyPressMsg(tea.Key{Code: 'm', Mod: tea.ModAlt}), key) + m = next.(tuiModel) + if m.mode == before { + t.Errorf("%s left the mode on %v", key, before) + } + } + + m.mode, m.armedMacro = modeMacro, "ha check" + next, _ := m.onPickKey(tea.KeyPressMsg(tea.Key{Code: 'a', Mod: tea.ModAlt}), "alt+a") + m = next.(tuiModel) + if m.armedMacro != "" { + t.Errorf("armedMacro = %q after cycling out of macro mode", m.armedMacro) + } + + if out := plain(m.viewPicking()); !strings.Contains(out, "alt+m/alt+a mode") { + t.Errorf("the hint does not offer both:\n%s", out) + } +} diff --git a/internal/tui/fixture_test.go b/internal/tui/fixture_test.go index 406e8fb..2ae5059 100644 --- a/internal/tui/fixture_test.go +++ b/internal/tui/fixture_test.go @@ -31,8 +31,6 @@ tenants: site: Store 0100 ` -// fixtureTUI is the whole startup path in fixture mode: a seeded store, a real -// client over the fixture transport, and the tenant load the TUI itself runs. func fixtureTUI(t *testing.T) (tuiModel, *fixture.Set) { t.Helper() return fixtureTUIWith(t, fixtureYAML) @@ -86,8 +84,6 @@ func TestFixtureModeFillsTheElementList(t *testing.T) { t.Errorf("the list is missing %q:\n%s", want, got) } } - // Site names come from the sites endpoint, so a fixture that did not answer - // it would show a raw 19-digit id here. if !strings.Contains(got, "Store 0003") { t.Errorf("site name unresolved:\n%s", got) } @@ -106,7 +102,6 @@ func TestFixtureModeOpensASessionThroughTheRealPane(t *testing.T) { if cmd == nil { t.Fatal("openSession queued nothing") } - // The command dials and opens; running it is what a real tick would do. runCmd(t, cmd) waitForTUI(t, func() bool { @@ -121,8 +116,6 @@ func TestFixtureModeOpensASessionThroughTheRealPane(t *testing.T) { if err := s.Err(); err != nil { t.Fatalf("session error = %v", err) } - // Autologin typed both answers; a pane still sitting on the password prompt - // is the failure this is here to catch. if got := s.Render(); strings.Contains(got, "Login incorrect") { t.Errorf("autologin was rejected:\n%s", got) } @@ -131,7 +124,6 @@ func TestFixtureModeOpensASessionThroughTheRealPane(t *testing.T) { waitForTUI(t, func() bool { return strings.Contains(s.Render(), "FAKE0001") }) } -// runCmd runs a tea.Cmd, and any batch it is made of, the way the runtime does. func runCmd(t *testing.T, cmd tea.Cmd) { t.Helper() if cmd == nil { diff --git a/internal/tui/ha.go b/internal/tui/ha.go index 7dc7cfb..a8a66d5 100644 --- a/internal/tui/ha.go +++ b/internal/tui/ha.go @@ -15,8 +15,6 @@ import ( "tyrtop.com/iontk/internal/ui" ) -// The per-cluster endpoint says only "active" or "unknown", so standby is -// derived. No active member keeps "unknown": that cluster is in trouble. const ( haActive = "active" haStandby = "standby" @@ -25,10 +23,7 @@ const ( const haAnswered = "ok" -// The aggregate names elements; the fallback needs a site in the path, so an -// element with no site id can only be read by the aggregate. type haWork struct { - // one request answers for every member, so a pair is one call pairs [][2]string elements []string noSite []string @@ -58,7 +53,6 @@ func haClusters(els []api.Element) haWork { return w } -// slower than the limiter allows, so a search can jump the queue const haPace = 400 * time.Millisecond type haTickMsg struct{ tsg string } @@ -67,27 +61,20 @@ func haTick(tsg string) tea.Cmd { return tea.Tick(haPace, func(time.Time) tea.Msg { return haTickMsg{tsg: tsg} }) } -// was a hard 64 with the rest dropped, so nothing past it ever resolved const haBatch = 16 -// the client rate-limits anyway; this keeps the limiter saturated const haFetchWorkers = 8 type haStatesMsg struct { - tsg string - states map[string]string - // "" not tried, "used" answered, otherwise why it did not - bulk string - // an element the reply did not name is not one still waiting - answered []string - // cluster -> why, for the cells that cannot be filled - failed map[string]string - // what this pass did not reach; the aggregate's half asks by element + tsg string + states map[string]string + bulk string + answered []string + failed map[string]string rest [][2]string restElements []string } -// anything unexpected falls back, rather than showing a fleet with no HA func loadHABulk(ctx context.Context, c *api.Client, tsg string, w haWork) tea.Cmd { return loadHAChunk(ctx, c, tsg, w, w.elements) } @@ -96,7 +83,6 @@ func loadHAChunk(ctx context.Context, c *api.Client, tsg string, w haWork, ids [ if len(ids) == 0 { return nil } - // saying so beats a column stuck on "loading" if c == nil { return func() tea.Msg { return haStatesMsg{tsg: tsg, bulk: "no client for this tenant", failed: allFailed(w, "tenant unreachable")} @@ -111,7 +97,6 @@ func loadHAChunk(ctx context.Context, c *api.Client, tsg string, w haWork, ids [ return func() tea.Msg { sts, err := c.SpokeClusterStatuses(ctx, ask) if err != nil { - // clusters the fallback cannot reach either are marked, not left msg := haStatesMsg{tsg: tsg, bulk: shortTenantErr(err), rest: w.pairs} if len(w.noSite) > 0 { msg.failed = map[string]string{} @@ -122,7 +107,6 @@ func loadHAChunk(ctx context.Context, c *api.Client, tsg string, w haWork, ids [ return msg } - // this endpoint names the second node itself states := make(map[string]string, len(sts)) answered := make([]string, 0, len(sts)) seen := map[string]bool{} @@ -140,7 +124,6 @@ func loadHAChunk(ctx context.Context, c *api.Client, tsg string, w haWork, ids [ } } -// "backup" is the portal's own word; the per-cluster endpoint has none func bulkWord(status string) string { switch status { case haActive: @@ -165,7 +148,6 @@ func memberWord(mem api.ClusterMember, hasActive bool) string { return haUnknown } -// runs behind the list: the elements are what someone is looking for func loadHA(ctx context.Context, c *api.Client, tsg string, pairs [][2]string) tea.Cmd { if c == nil || len(pairs) == 0 { return nil @@ -190,7 +172,6 @@ func loadHA(ctx context.Context, c *api.Client, tsg string, pairs [][2]string) t for p := range work { st, err := c.SpokeClusterStatus(ctx, p[0], p[1]) if err != nil { - // swallowing this left the column loading forever mu.Lock() failed[p[1]] = shortTenantErr(err) mu.Unlock() @@ -237,6 +218,7 @@ func (m tuiModel) onHAStates(msg haStatesMsg) (tea.Model, tea.Cmd) { } delete(m.haInFlight, msg.tsg) m.haDone += len(msg.answered) + len(msg.failed) + m.haAt = time.Now() for _, cid := range msg.answered { m.haCluster[msg.tsg+"/"+cid] = haAnswered } @@ -247,18 +229,15 @@ func (m tuiModel) onHAStates(msg haStatesMsg) (tea.Model, tea.Cmd) { m.haErr = why } } - // once the sweep is done, so the count is the whole of it if len(msg.rest) == 0 && m.haFailed > 0 && m.status == "" { m.status = fmt.Sprintf("HA state unread for %d cluster(s): %s", m.haFailed, m.haErr) } - // a fleet one at a time is minutes, so say so up front if msg.bulk != "" && msg.bulk != "used" && m.status == "" { m.status = fmt.Sprintf("HA one cluster at a time (%s): the tenant-wide query was refused — %s", sweepETA(m.haTotal), msg.bulk) } m.rebuildRows() - // queued, not chased: the rate budget stays free for the user if len(msg.restElements) > 0 { if m.haQueue == nil { m.haQueue = map[string][]string{} @@ -269,6 +248,7 @@ func (m tuiModel) onHAStates(msg haStatesMsg) (tea.Model, tea.Cmd) { delete(m.haQueue, msg.tsg) if len(msg.rest) > 0 { + m.markHAOut(msg.tsg) return m, loadHA(m.ctx, m.pool.get(msg.tsg), msg.tsg, m.nextPairs(msg.tsg, msg.rest)) } return m, saveHA(m.store, msg.tsg, msg.states) @@ -280,10 +260,10 @@ func (m tuiModel) onHATick(tsg string) (tea.Model, tea.Cmd) { return m, nil } w := haWork{elements: m.visibleFirst(tsg, left)} + m.markHAOut(tsg) return m, loadHAChunk(m.ctx, m.pool.get(tsg), tsg, w, w.elements) } -// the ask that must not wait behind the fleet func (m tuiModel) haWanted() map[string][]string { out := map[string][]string{} for _, pos := range m.visibleMatches() { @@ -313,7 +293,25 @@ func (m tuiModel) visibleMatches() []int { return out } -// ahead of the paced fill; one per tenant, so a search cannot double up +func (m *tuiModel) markHAOut(tsg string) { + if m.haInFlight == nil { + m.haInFlight = map[string]bool{} + } + m.haInFlight[tsg] = true +} + +func (m tuiModel) haWorking() bool { + if len(m.haInFlight) > 0 { + return true + } + for _, left := range m.haQueue { + if len(left) > 0 { + return true + } + } + return false +} + func (m *tuiModel) askVisibleHA() tea.Cmd { if m.haInFlight == nil { m.haInFlight = map[string]bool{} @@ -323,7 +321,7 @@ func (m *tuiModel) askVisibleHA() tea.Cmd { if m.haInFlight[tsg] { continue } - m.haInFlight[tsg] = true + m.markHAOut(tsg) w := haWork{elements: ids} if cmd := loadHAChunk(m.ctx, m.pool.get(tsg), tsg, w, ids); cmd != nil { cmds = append(cmds, cmd) @@ -335,12 +333,10 @@ func (m *tuiModel) askVisibleHA() tea.Cmd { return tea.Batch(cmds...) } -// so the next launch opens with something while it reads again func saveHA(st *config.Store, tsg string, states map[string]string) tea.Cmd { if st == nil || len(states) == 0 { return nil } - // the cache keys on element id; the model scopes by tenant bare := make(map[string]string, len(states)) for k, w := range states { bare[strings.TrimPrefix(k, tsg+"/")] = w @@ -351,7 +347,6 @@ func saveHA(st *config.Store, tsg string, states map[string]string) tea.Cmd { } } -// blank off a cluster: a dash on every standalone row is noise func (m *tuiModel) haCell(r elementRow) string { word, cached := m.haState(r) if word == "" { @@ -360,7 +355,6 @@ func (m *tuiModel) haCell(r elementRow) string { return haWord(word, cached) } -// unstyled: the list builds every row per keystroke, paints a few func (m *tuiModel) haState(r elementRow) (string, bool) { if r.ClusterID() == "" { return "", false @@ -368,18 +362,14 @@ func (m *tuiModel) haState(r elementRow) (string, bool) { if word := m.ha[r.key()]; word != "" { return word, false } - // the asterisk says last sweep; stale and labelled beats empty if word := m.haCached[r.key()]; word != "" { return word, true } - // three different nothings; "loading" has to mean only the first switch { case r.SiteID == "": - // the status path is per site, so this one cannot be asked return "ha?", false case m.haCluster[r.TSG+"/"+r.ClusterID()] == haAnswered: - // answered and did not name it: the API's word for unseen return haUnknown, false case m.haCluster[r.TSG+"/"+r.ClusterID()] != "": return "ha?", false @@ -387,7 +377,6 @@ func (m *tuiModel) haState(r elementRow) (string, bool) { return "loading", false } -// the asterisk is dim so the word still reads at a glance func haWord(word string, cached bool) string { var out string switch word { @@ -398,10 +387,8 @@ func haWord(word string, cached bool) string { case "ha?": out = ui.StyleWarn.Render(word) case haActive: - // a connected row is already green out = ui.StyleOK.Bold(true).Render(haActive) case haStandby: - // dim against green read as the same colour out = ui.StyleElement.Render(haStandby) case haUnknown: out = ui.StyleWarn.Render(haUnknown) @@ -414,7 +401,6 @@ func haWord(word string, cached bool) string { return out } -// counts the list, not the sweep: a refresh resets haTotal func (m tuiModel) haPath() string { clustered, clusters := 0, map[string]bool{} for _, r := range m.all { @@ -439,7 +425,6 @@ func (m tuiModel) haPath() string { return fmt.Sprintf("%d/%d clusters", m.haDone, m.haTotal) } -// one request per cluster through the limiter func sweepETA(left int) string { if left <= 0 { return "done" @@ -451,7 +436,6 @@ func sweepETA(left int) string { return ui.Age(time.Duration(float64(left)/rps*float64(time.Second))) + " to go" } -// on screen first, outside the filter last: the order is what feels fast func (m tuiModel) nextPairs(tsg string, rest [][2]string) [][2]string { if len(rest) < 2 { return rest @@ -476,7 +460,6 @@ func (m tuiModel) nextPairs(tsg string, rest [][2]string) [][2]string { if !ok { return 1 << 30 } - // the window is around the cursor, not at the top if pos >= cursor { return pos - cursor } @@ -514,7 +497,6 @@ func allFailed(w haWork, why string) map[string]string { return out } -// the portal asks about the 25 it shows, not the 5600 it has func (m tuiModel) visibleFirst(tsg string, ids []string) []string { if len(ids) < 2 { return ids diff --git a/internal/tui/ha_test.go b/internal/tui/ha_test.go index 04a3d5f..2bfe882 100644 --- a/internal/tui/ha_test.go +++ b/internal/tui/ha_test.go @@ -10,24 +10,17 @@ import ( "tyrtop.com/iontk/internal/sase/api" ) -// The portal has no "standby": a cluster reads "active" on the member -// carrying traffic and "unknown" on anything the controller cannot see. The -// second node is therefore derived, and only where there is an active member -// to derive it from. func TestHAStatesAreFilledIntoTheList(t *testing.T) { m, set := fixtureTUIWith(t, haFixtureYAML) m.phase = phasePicking m.rebuildRows() - // Both fixture elements are in the site's cluster, so the column shows. if !m.showHAColumn() { t.Fatal("the HA column is hidden on a list with a cluster in it") } - if got := plain(m.viewPicking()); !strings.Contains(got, "ha") { + if got := plain(m.viewPicking()); !strings.Contains(got, "HA") { t.Errorf("the list has no ha column:\n%s", got) } - // Before the status lands, a clustered element says the state is coming - // rather than naming one. first := set.Tenants[0].Elements[0] if got := plain(m.haCell(rowFor(t, m, first.ID))); got != "loading" { t.Errorf("cell before the fetch = %q, want it to say it is loading", got) @@ -49,9 +42,6 @@ func TestHAStatesAreFilledIntoTheList(t *testing.T) { next, _ := m.Update(msg) m = next.(tuiModel) - // The fixture's connected element is the active one; its peer is not - // connected, so the cluster reports it as unknown -- and with an active - // member in the cluster that is the standby. active := plain(m.haCell(rowFor(t, m, first.ID))) peer := plain(m.haCell(rowFor(t, m, set.Tenants[0].Elements[1].ID))) if active != "active" { @@ -65,7 +55,6 @@ func TestHAStatesAreFilledIntoTheList(t *testing.T) { } } -// A list with no clusters pays nothing for the column. func TestTheHAColumnStaysOffAListWithoutClusters(t *testing.T) { m, _ := fixtureTUI(t) m.phase = phasePicking @@ -79,22 +68,18 @@ func TestTheHAColumnStaysOffAListWithoutClusters(t *testing.T) { } } -// One request per cluster, not per element: a pair shares a cluster. func TestHAClustersAreCountedOncePerCluster(t *testing.T) { els := []api.Element{ {ID: "1", SiteID: "s1", SpokeHA: &api.SpokeHA{ClusterID: "c1"}}, {ID: "2", SiteID: "s1", SpokeHA: &api.SpokeHA{ClusterID: "c1"}}, {ID: "3", SiteID: "s2", SpokeHA: &api.SpokeHA{ClusterID: "c2"}}, {ID: "4", SiteID: "s3"}, - // In a cluster but with no site, so there is no path to ask about. {ID: "5", SpokeHA: &api.SpokeHA{ClusterID: "c3"}}, } got := haClusters(els) if len(got.pairs) != 2 { t.Errorf("pairs = %v, want one per cluster that can be read one at a time", got.pairs) } - // The one with no site can only ever be read by the aggregate, which asks - // by element, so it is kept apart rather than dropped. if len(got.noSite) != 1 { t.Errorf("noSite = %v, want the cluster with no site in it", got.noSite) } @@ -122,8 +107,6 @@ func elementsOf(m tuiModel) []api.Element { return out } -// manyClusterFixture is a fleet of HA pairs, one cluster per site, with more -// clusters than a single pass reads. func manyClusterFixture(sites int) string { var b strings.Builder b.WriteString("tenants:\n - name: Acme Co\n tsg_id: \"000000000\"\n users:\n - name: admin\n password: ion\n elements:\n") @@ -134,10 +117,6 @@ func manyClusterFixture(sites int) string { return b.String() } -// Every cluster resolves, however many there are. This is the bug that put -// "ha" on most of a fleet: the sweep read the first 64 clusters and dropped -// the rest, and the status line's "alt+r for another pass" rebuilt the same -// list and read the same 64 again. func TestEveryClusterResolvesNotJustTheFirstBatch(t *testing.T) { sites := haBatch + 2 m, _ := fixtureTUIWith(t, manyClusterFixture(sites)) @@ -149,8 +128,6 @@ func TestEveryClusterResolvesNotJustTheFirstBatch(t *testing.T) { t.Fatalf("%d clusters, want one per site (%d)", len(pairs), sites) } - // Follow the passes the way the program does: each message carries what - // it did not reach, and the next command takes it. cmd := loadHA(t.Context(), m.pool.get(m.tenant.TSGID), m.tenant.TSGID, pairs) passes := 0 for { @@ -165,7 +142,6 @@ func TestEveryClusterResolvesNotJustTheFirstBatch(t *testing.T) { next, c := m.Update(msg) m = next.(tuiModel) if len(msg.rest) == 0 { - // The last pass hands back the cache write, not another read. break } if c == nil { @@ -185,9 +161,6 @@ func TestEveryClusterResolvesNotJustTheFirstBatch(t *testing.T) { } } -// A cluster that refuses is why a row says nothing, so the row says that it -// could not be told rather than that it is still loading, and the line says -// how many and why. func TestARefusingClusterIsReportedNotSwallowed(t *testing.T) { m, set := fixtureTUIWith(t, haFixtureYAML) m.phase = phasePicking @@ -208,8 +181,6 @@ func TestARefusingClusterIsReportedNotSwallowed(t *testing.T) { } } -// A cluster that answered without naming a member means the controller cannot -// see it, which is the API's own word rather than something still in flight. func TestAnAnsweredClusterThatNamesNobodyReadsUnknown(t *testing.T) { m, set := fixtureTUIWith(t, haFixtureYAML) m.phase = phasePicking @@ -230,8 +201,6 @@ func TestAnAnsweredClusterThatNamesNobodyReadsUnknown(t *testing.T) { } } -// An element whose record carries no site id can never be asked about: the -// status path is per site. It used to show "ha" for good. func TestAnElementWithNoSiteSaysItCannotBeAsked(t *testing.T) { m, _ := fixtureTUI(t) r := elementRow{Element: api.Element{ @@ -245,10 +214,6 @@ func TestAnElementWithNoSiteSaysItCannotBeAsked(t *testing.T) { } } -// One POST for the whole tenant instead of one GET per cluster. This is what -// the portal's own resource map calls query_status_spokeclusters, and on a -// fleet of pairs it is the difference between a column that is there with the -// list and one that fills at five requests a second. func TestTheAggregateQueryFillsTheWholeColumnAtOnce(t *testing.T) { sites := haBatch + 2 m, _ := fixtureTUIWith(t, manyClusterFixture(sites)) @@ -284,8 +249,6 @@ func TestTheAggregateQueryFillsTheWholeColumnAtOnce(t *testing.T) { } } -// A tenant whose aggregate is refused has to fall back, not come up empty: -// the per-cluster sweep is the only path proved against hardware. func TestARefusedAggregateFallsBackToThePerClusterSweep(t *testing.T) { m, set := fixtureTUIWith(t, haFixtureYAML) m.phase = phasePicking @@ -304,7 +267,6 @@ func TestARefusedAggregateFallsBackToThePerClusterSweep(t *testing.T) { t.Errorf("haPath() = %q, want it to say the query was refused and what took over", got) } - // And the sweep it queued does fill the column. msg, ok := cmd().(haStatesMsg) if !ok { t.Fatalf("message = %T, want the per-cluster states", msg) @@ -316,8 +278,6 @@ func TestARefusedAggregateFallsBackToThePerClusterSweep(t *testing.T) { } } -// Without an aggregate the sweep is slow, so the last one is kept and shown -// with an asterisk rather than leaving the column empty on every launch. func TestCachedHAIsShownWithAnAsteriskUntilItIsRefreshed(t *testing.T) { m, set := fixtureTUIWith(t, haFixtureYAML) m.phase = phasePicking @@ -330,7 +290,6 @@ func TestCachedHAIsShownWithAnAsteriskUntilItIsRefreshed(t *testing.T) { t.Errorf("cell = %q, want the cached state marked", got) } - // This run's own answer replaces it, asterisk and all. next, _ := m.Update(haStatesMsg{ tsg: m.tenant.TSGID, states: map[string]string{first.key(): haStandby}, @@ -342,8 +301,6 @@ func TestCachedHAIsShownWithAnAsteriskUntilItIsRefreshed(t *testing.T) { } } -// The sweep is written to the element cache, which is what the next launch -// reads: a value saved per element id, keyed off the tenant's file. func TestASweepIsSavedForTheNextLaunch(t *testing.T) { m, set := fixtureTUIWith(t, haFixtureYAML) tsg := m.tenant.TSGID @@ -365,16 +322,11 @@ func TestASweepIsSavedForTheNextLaunch(t *testing.T) { if got := c.HA[eid]; got != haActive { t.Errorf("cached HA for %s = %q, want %q", eid, got, haActive) } - // The elements it was attached to are still there. if len(c.Elements) == 0 { t.Error("saving the sweep dropped the cached elements") } } -// The portal's trick, which is the whole reason its page is quick: ask about -// the rows you are showing, not the fleet you have. The first request covers -// what is on screen, and a search reprioritises what is asked next -- the -// matched rows are the only ones ranked at all. func TestTheAggregateAsksAboutTheVisibleRowsFirst(t *testing.T) { m, _ := fixtureTUIWith(t, manyClusterFixture(20)) m.phase = phasePicking @@ -386,14 +338,12 @@ func TestTheAggregateAsksAboutTheVisibleRowsFirst(t *testing.T) { t.Fatalf("%d clustered elements, want the fleet", len(w.elements)) } - // With the cursor at the top, the first ids asked about are the top rows. ordered := m.visibleFirst(m.tenant.TSGID, w.elements) top := m.all[m.matches[0]].ID if ordered[0] != top { t.Errorf("first id asked about = %s, want the row under the cursor (%s)", ordered[0], top) } - // Move down the list and the order follows the cursor. m.moveCursorTo(len(m.matches) - 1) ordered = m.visibleFirst(m.tenant.TSGID, w.elements) bottom := m.all[m.matches[len(m.matches)-1]].ID @@ -401,8 +351,6 @@ func TestTheAggregateAsksAboutTheVisibleRowsFirst(t *testing.T) { t.Errorf("first id asked about = %s, want the row the cursor moved to (%s)", ordered[0], bottom) } - // A search narrows it: the matched rows are asked about first, and - // everything else goes behind them. m.filter.SetValue("store-0007") m.applyFilter() if len(m.matches) == 0 || len(m.matches) == len(m.all) { @@ -420,16 +368,12 @@ func TestTheAggregateAsksAboutTheVisibleRowsFirst(t *testing.T) { } } -// The background fill is paced on purpose, and a search does not wait for it: -// the rows a query brings on screen are asked about immediately, which is only -// possible because the fill leaves the rate budget alone between chunks. func TestASearchAsksForItsRowsWithoutWaitingForTheFill(t *testing.T) { m, _ := fixtureTUIWith(t, manyClusterFixture(20)) m.phase = phasePicking m.width, m.height = 120, 24 m.rebuildRows() - // A chunk lands with the fleet still queued, which is the paced state. w := haClusters(elementsOf(m)) next, cmd := m.Update(haStatesMsg{ tsg: m.tenant.TSGID, @@ -445,7 +389,6 @@ func TestASearchAsksForItsRowsWithoutWaitingForTheFill(t *testing.T) { t.Fatal("the rest of the fleet was not queued") } - // Searching asks for what it put on screen, straight away. m.filter.SetValue("store-0012") m.applyFilter() if len(m.matches) == 0 { @@ -471,13 +414,10 @@ func TestASearchAsksForItsRowsWithoutWaitingForTheFill(t *testing.T) { if ask == nil { t.Fatal("the search did not ask for its rows") } - // One request per tenant at a time: a second ask while that one is out - // must not duplicate it. if again := m.askVisibleHA(); again != nil { t.Error("a second ask went out while the first was still in flight") } - // And the answer fills exactly those rows. msg, ok := ask().(haStatesMsg) if !ok { t.Fatalf("message = %T, want the HA states", msg) @@ -495,8 +435,6 @@ func TestASearchAsksForItsRowsWithoutWaitingForTheFill(t *testing.T) { } } -// Each chunk is written to the cache as it lands, not only at the end: a fill -// that is paced across a fleet would otherwise save nothing until it finished. func TestEachChunkIsCachedAsItLands(t *testing.T) { m, set := fixtureTUIWith(t, haFixtureYAML) tsg := m.tenant.TSGID @@ -511,7 +449,6 @@ func TestEachChunkIsCachedAsItLands(t *testing.T) { if cmd == nil { t.Fatal("nothing came back from a chunk with more to do") } - // The batch carries the next tick and the cache write; run them. if b, ok := cmd().(tea.BatchMsg); ok { for _, c := range b { if c != nil { diff --git a/internal/tui/macros.go b/internal/tui/macros.go index 3f51066..677dc8d 100644 --- a/internal/tui/macros.go +++ b/internal/tui/macros.go @@ -2,6 +2,7 @@ package tui import ( "fmt" + "slices" "strings" "charm.land/bubbles/v2/textinput" @@ -12,8 +13,6 @@ import ( "tyrtop.com/iontk/internal/ui" ) -// warnMacroModel is the ID of the cross-model warning. Warnings are keyed by -// ID rather than given a field apiece, so the next one costs a constant. const warnMacroModel = "macro/model" func newMacroFilter() textinput.Model { @@ -32,8 +31,6 @@ func (m tuiModel) openMacros() (tea.Model, tea.Cmd) { return m.resetMacroCursor(), nil } -// macroRows is the list as filtered. Name and commands both match: a macro is -// remembered by what it does as often as by what it was called. func (m tuiModel) macroRows() []config.Macro { all := m.cmds.Macros q := strings.ToLower(strings.TrimSpace(m.macroFilter.Value())) @@ -50,8 +47,6 @@ func (m tuiModel) macroRows() []config.Macro { return out } -// macroAt maps the cursor to a macro. Cursor 0 is the "+ new macro" row, an -// action rather than a macro, so it answers nothing. func (m tuiModel) macroAt(cursor int) (config.Macro, bool) { rows := m.macroRows() if cursor < 1 || cursor > len(rows) { @@ -60,8 +55,6 @@ func (m tuiModel) macroAt(cursor int) (config.Macro, bool) { return rows[cursor-1], true } -// resetMacroCursor puts the cursor on the first macro, or on the + row when -// there is none: filtering is done to find a macro, not to make one. func (m tuiModel) resetMacroCursor() tuiModel { m.macroCursor = 0 if len(m.macroRows()) > 0 { @@ -71,6 +64,11 @@ func (m tuiModel) resetMacroCursor() tuiModel { } func (m tuiModel) onMacroKey(msg tea.KeyPressMsg, key string) (tea.Model, tea.Cmd) { + armed := m.macroDel + if key != "x" { + m.macroDel = "" + } + if m.macroFilterOn { switch key { case "esc": @@ -83,8 +81,6 @@ func (m tuiModel) onMacroKey(msg tea.KeyPressMsg, key string) (tea.Model, tea.Cm m.macroFilter.Blur() return m, nil } - // Backspace on an already-empty query leaves, the same as on the - // element list: the last press deletes the "/". if key == "backspace" && m.macroFilter.Value() == "" { m.macroFilterOn = false m.macroFilter.Blur() @@ -101,6 +97,8 @@ func (m tuiModel) onMacroKey(msg tea.KeyPressMsg, key string) (tea.Model, tea.Cm m.status = "" return m, nil case "/": + m.macroFilter.SetValue("") + m.macroCursor = 0 m.macroFilterOn = true return m, m.macroFilter.Focus() case "up", "k": @@ -114,13 +112,11 @@ func (m tuiModel) onMacroKey(msg tea.KeyPressMsg, key string) (tea.Model, tea.Cm return m.openMacroForm(mac) } return m, nil - case "alt+y": - return m.copyMacro() - case "ctrl+d", "x": - return m.deleteMacro() + case "x": + return m.armOrDeleteMacro(armed) + case "u": + return m.undoMacroDelete() case "enter": - // The top row makes one; everything below it is picked up and taken - // to the element list. if mac, ok := m.macroAt(m.macroCursor); ok { return m.armMacro(mac) } @@ -129,27 +125,49 @@ func (m tuiModel) onMacroKey(msg tea.KeyPressMsg, key string) (tea.Model, tea.Cm return m, nil } -func (m tuiModel) deleteMacro() (tea.Model, tea.Cmd) { +func (m tuiModel) armOrDeleteMacro(armed string) (tea.Model, tea.Cmd) { mac, ok := m.macroAt(m.macroCursor) if !ok { return m, nil } + if !strings.EqualFold(armed, mac.Name) { + m.macroDel = mac.Name + m.status = "x again to delete " + mac.Name + return m, nil + } + + m.macroUndo = append(slices.Clone(m.macroUndo), mac) m.cmds.RemoveMacro(mac.Name) m.macroCursor = max(0, min(m.macroCursor, len(m.macroRows()))) - m.status = "deleted " + mac.Name + m.status = "deleted " + mac.Name + " · u puts it back" + return m, saveCommands(m.store, m.cmds) +} + +func (m tuiModel) undoMacroDelete() (tea.Model, tea.Cmd) { + if len(m.macroUndo) == 0 { + m.status = "nothing to undo" + return m, nil + } + last := len(m.macroUndo) - 1 + mac := m.macroUndo[last] + m.macroUndo = slices.Clone(m.macroUndo[:last]) + + if err := m.cmds.SetMacro(mac); err != nil { + m.status = err.Error() + return m, nil + } + m.status = mac.Name + " is back" + if n := len(m.macroUndo); n > 0 { + m.status += fmt.Sprintf(" · %d more to undo", n) + } return m, saveCommands(m.store, m.cmds) } -// armMacro picks a macro up and goes to the element list to choose what to -// run it on, which is batch mode's order of doing things: the list is where -// elements are found, filtered and selected, and a macro page could only ever -// be a worse copy of it. func (m tuiModel) armMacro(mac config.Macro) (tea.Model, tea.Cmd) { m.armedMacro = mac.Name m.mode = modeMacro m.status = mac.Name + ": select elements, then enter" - // Reached from the menu, the list may never have been loaded. if len(m.all) == 0 { return m.startContinue() } @@ -158,7 +176,6 @@ func (m tuiModel) armMacro(mac config.Macro) (tea.Model, tea.Cmd) { return m, nil } -// runArmedMacro is enter in macro mode. func (m tuiModel) runArmedMacro(rows []elementRow) (tea.Model, tea.Cmd) { mac, ok := m.cmds.Macro(m.armedMacro) if !ok { @@ -170,12 +187,7 @@ func (m tuiModel) runArmedMacro(rows []elementRow) (tea.Model, tea.Cmd) { return m.runMacro(mac, rows) } -// runMacro runs the sequence against the rows enter acted on, through the -// same batch engine a typed command goes through. func (m tuiModel) runMacro(mac config.Macro, rows []elementRow) (tea.Model, tea.Cmd) { - // As a unit, before anything is dialled. Command by command would leave a - // macro half run, with the write it was refused for sitting after - // whatever already took effect. if !app.Defaults.AllowWrites && !m.cfg.AllowWrites { if err := ioncli.CheckReadOnly(mac.Commands); err != nil { m.status = mac.Name + ": " + err.Error() @@ -188,9 +200,6 @@ func (m tuiModel) runMacro(mac config.Macro, rows []elementRow) (tea.Model, tea. return m, nil } - // Across models, once. Afterwards it runs without asking: the standing - // signal is the model tag on the macro page, which a status line could - // not be -- a run clears the status as it starts. if macroMismatch(mac, rows) != "" && !m.cmds.Acked(warnMacroModel) { m.askMacro = mac.Name m.askMacroOn = rows @@ -204,10 +213,6 @@ func (m tuiModel) runMacro(mac config.Macro, rows []elementRow) (tea.Model, tea. return m.startRunOn(mac.Commands, rows, ids) } -// macroMismatch names the first selected model the macro was not built -// against. Empty for a macro with no model recorded: that is unknown, not -// wrong. Kept short enough to fit a modal line, since that is where it is -// read. func macroMismatch(mac config.Macro, rows []elementRow) string { if mac.Model == "" { return "" @@ -220,8 +225,6 @@ func macroMismatch(mac config.Macro, rows []elementRow) string { return "" } -// macroWarnPrompt asks once. Command syntax differs by model and version, and -// a sequence written for one appliance can fail halfway through another. func (m tuiModel) macroWarnPrompt() string { mac, _ := m.cmds.Macro(m.askMacro) rows := m.askMacroOn @@ -249,8 +252,6 @@ func (m tuiModel) overlayMacroWarn(content string) string { return strings.Join(lines, "\n") + "\n" + prompt } -// onMacroWarnKey answers it. Yes acknowledges for good, as the warning says: -// the alternative is a modal between the user and every cross-model run. func (m tuiModel) onMacroWarnKey(key string) (tea.Model, tea.Cmd) { switch key { case "left", "right", "tab", "shift+tab", "h", "l": @@ -277,12 +278,8 @@ func (m tuiModel) onMacroWarnKey(key string) (tea.Model, tea.Cmd) { return next, tea.Batch(save, cmd) } -// macroCommandLabel numbers the command lines from one, which is the order -// they run in. func macroCommandLabel(i int) string { return fmt.Sprintf("command %d", i+1) } -// macroPlaceholder puts an example on the first line only. On the ones below -// it, a placeholder reads like a command that is already there. func macroPlaceholder(i int) string { if i == 0 { return "dump device status" @@ -294,9 +291,6 @@ func newMacroCommand(i int) formField { return newField(macroCommandLabel(i), macroPlaceholder(i), false) } -// openMacroForm is one field per command rather than one comma-separated -// line: a command is not a list item, and a sequence read down the page is -// the thing being built. func (m tuiModel) openMacroForm(mac config.Macro) (tea.Model, tea.Cmd) { name := newField("name", "wan check", false) name.input.SetValue(mac.Name) @@ -307,8 +301,6 @@ func (m tuiModel) openMacroForm(mac config.Macro) (tea.Model, tea.Cmd) { fields = append(fields, f) } - // Always an empty line to type into, which is what the ghost row under - // the form is pointing at. fields = append(fields, newMacroCommand(len(mac.Commands))) m.formMacroName = mac.Name @@ -319,7 +311,6 @@ func (m tuiModel) openMacroForm(mac config.Macro) (tea.Model, tea.Cmd) { return m.openForm(formMacro, title, phaseAddUser, fields) } -// addMacroCommand grows the form by a line and moves to it. func (m tuiModel) addMacroCommand() (tea.Model, tea.Cmd) { m.form[m.formCursor].input.Blur() m.form = append(m.form, newMacroCommand(len(m.form)-1)) @@ -327,14 +318,9 @@ func (m tuiModel) addMacroCommand() (tea.Model, tea.Cmd) { return m, m.form[m.formCursor].input.Focus() } -// removeMacroCommand takes an empty command line out and lands on the one -// above it, the way an editor does. The form is the whole editor, so without -// this a line in the middle could only be removed by retyping everything -// after it. func (m tuiModel) removeMacroCommand() (tea.Model, tea.Cmd) { m.form[m.formCursor].input.Blur() m.form = append(m.form[:m.formCursor], m.form[m.formCursor+1:]...) - // The labels are the run order, so they renumber. for i := 1; i < len(m.form); i++ { m.form[i].label = macroCommandLabel(i - 1) m.form[i].input.Placeholder = macroPlaceholder(i - 1) @@ -346,9 +332,6 @@ func (m tuiModel) removeMacroCommand() (tea.Model, tea.Cmd) { return m, in.Focus() } -// macroFormCommands is the command lines, in order, with the empty ones -// dropped: a line left blank in the middle is a line not filled in, not a -// command. func (m tuiModel) macroFormCommands() []string { var out []string for i := 1; i < len(m.form); i++ { @@ -359,14 +342,10 @@ func (m tuiModel) macroFormCommands() []string { return out } -// submitMacro saves the form. The model is the selection's, when the whole -// selection is one: that is what the sequence was written against, and it is -// what the cross-model warning compares. func (m tuiModel) submitMacro() (tea.Model, tea.Cmd) { name := m.formValue(0) mac := config.Macro{Name: name, Commands: m.macroFormCommands(), Model: m.selectionModel()} - // Renaming replaces rather than leaving the old name behind. if m.formMacroName != "" && !strings.EqualFold(m.formMacroName, name) { m.cmds.RemoveMacro(m.formMacroName) } @@ -380,9 +359,6 @@ func (m tuiModel) submitMacro() (tea.Model, tea.Cmd) { return m, saveCommands(m.store, m.cmds) } -// selectionModel is the model of the selection when it is all one, and empty -// when it is mixed or empty: a macro that claims a model it was not built for -// would warn about the wrong thing. func (m tuiModel) selectionModel() string { rows, _ := m.selectedRows() model := "" @@ -410,10 +386,6 @@ func (m tuiModel) viewMacros() string { } b.WriteString("\n\n") - // The line the cursor is on is highlighted whole, the way a row of the - // element list is, and built plain to do it: a highlight wrapped around - // styled text ends at the first reset inside it, which is why a marker - // was all this page used to manage. width := max(20, m.width-2) line := func(here bool, plain, styled string) string { if here { @@ -422,9 +394,7 @@ func (m tuiModel) viewMacros() string { return " " + styled + "\n" } - // An action rather than a row, so it stays at the top whatever the filter - // says and is where the cursor rests when there is nothing to run. - const newRow = "+ new macro" + const newRow = "+ New Macro" b.WriteString(line(m.macroCursor == 0, newRow, ui.StyleGhost.Render(newRow))) list := m.macroRows() @@ -433,13 +403,10 @@ func (m tuiModel) viewMacros() string { } for i, mac := range list { - // Padded plain, then styled, so the counts line up down the page. name := fmt.Sprintf("%-22s", ui.Truncate(mac.Name, 22)) count := fmt.Sprintf("%-14s", fmt.Sprintf("%d command(s)", len(mac.Commands))) cmds := ui.Truncate(strings.Join(mac.Commands, " · "), max(10, m.width-58)) - // The model is the standing cross-model signal: warn-coloured when it - // is not what the selection is, since the question is only asked once. model, tag := "", "" if mac.Model != "" { model = mac.Model + " " @@ -457,23 +424,18 @@ func (m tuiModel) viewMacros() string { b.WriteString("\n") b.WriteString(ui.Footer(m.status, - "↑↓ move · enter pick, then choose elements · e edit · ctrl+d delete · "+ - copyKeyHint+" · / filter · esc menu")) + "↑↓ move · e edit · x delete · u undo · / filter · esc menu")) return b.String() } -// modeLabel names the mode on the element list. Macro mode carries the macro -// with it: the mode means nothing without saying which one is armed. func (m tuiModel) modeLabel() string { - label := ui.StyleElement.Render(m.mode.String() + " mode") + label := ui.StyleElement.Render(ui.Title(m.mode.String()) + " Mode") if m.mode == modeMacro && m.armedMacro != "" { label += ui.StyleCmd.Render(" " + m.armedMacro) } return label } -// pasteMacroCommands fills a line per pasted command, growing the form as it -// goes, and leaves an empty line at the bottom for the next one. func (m tuiModel) pasteMacroCommands(cmds []string) (tea.Model, tea.Cmd) { m.form[m.formCursor].input.Blur() @@ -492,9 +454,6 @@ func (m tuiModel) pasteMacroCommands(cmds []string) (tea.Model, tea.Cmd) { return m, in.Focus() } -// listReturn is where esc, q and alt+w go from the element list: back to the -// macro page when a macro is what brought us here, since that is where the -// choice was made and where another one is made. The main menu otherwise. func (m tuiModel) listReturn() tuiPhase { if m.mode == modeMacro && m.armedMacro != "" { return phaseMacros diff --git a/internal/tui/macros_test.go b/internal/tui/macros_test.go index 833e4ec..06240eb 100644 --- a/internal/tui/macros_test.go +++ b/internal/tui/macros_test.go @@ -1,6 +1,7 @@ package tui import ( + "slices" "strings" "testing" @@ -20,8 +21,6 @@ func macroModel(t *testing.T) tuiModel { t.Fatal(err) } } - // Wide enough for the command preview, which is the first thing a narrow - // terminal drops. m.width = 120 next, _ := m.openMacros() return next.(tuiModel) @@ -32,21 +31,17 @@ func TestTheMacroPageListsFiltersAndDeletes(t *testing.T) { if m.phase != phaseMacros { t.Fatalf("phase = %v, want the macro page", m.phase) } - // The cursor opens on the first macro, not on the row that makes one: - // the page is opened to run something far more often than to add one. if m.macroCursor != 1 { t.Errorf("cursor = %d, want the first macro", m.macroCursor) } got := plain(m.viewMacros()) - for _, want := range []string{"macros", "+ new macro", "wan check", "2 command(s)", "ion 1200", "dump vpn count"} { + for _, want := range []string{"macros", "+ New Macro", "wan check", "2 command(s)", "ion 1200", "dump vpn count"} { if !strings.Contains(got, want) { t.Errorf("the page is missing %q:\n%s", want, got) } } - // A macro is as often remembered by what it does as by its name, so the - // filter matches both. next, _ := m.onMacroKey(tea.KeyPressMsg(tea.Key{Code: '/', Text: "/"}), "/") m = next.(tuiModel) if !m.macroFilterOn { @@ -56,8 +51,6 @@ func TestTheMacroPageListsFiltersAndDeletes(t *testing.T) { if rows := m.macroRows(); len(rows) != 1 || rows[0].Name != "ha check" { t.Errorf("filtered rows = %+v, want the macro whose command matches", rows) } - // Filtering is done to find a macro, so the cursor lands on the match - // rather than on the row above it. if mac, ok := m.macroAt(m.macroCursor); !ok || mac.Name != "ha check" { t.Errorf("cursor %d = %+v, %v, want the match", m.macroCursor, mac, ok) } @@ -71,8 +64,25 @@ func TestTheMacroPageListsFiltersAndDeletes(t *testing.T) { m.macroCursor = 1 next, cmd := m.onMacroKey(tea.KeyPressMsg(tea.Key{Code: 'd', Mod: tea.ModCtrl}), "ctrl+d") m = next.(tuiModel) + if _, ok := m.cmds.Macro("wan check"); !ok { + t.Fatal("ctrl+d deleted the macro") + } + if cmd != nil { + t.Error("ctrl+d queued a write") + } + + next, cmd = m.onMacroKey(tea.KeyPressMsg(tea.Key{Code: 'x', Text: "x"}), "x") + m = next.(tuiModel) + if _, ok := m.cmds.Macro("wan check"); !ok { + t.Fatal("one x deleted the macro") + } + if !strings.Contains(m.status, "x again") { + t.Errorf("status = %q, want it to ask for the second press", m.status) + } + next, cmd = m.onMacroKey(tea.KeyPressMsg(tea.Key{Code: 'x', Text: "x"}), "x") + m = next.(tuiModel) if _, ok := m.cmds.Macro("wan check"); ok { - t.Error("ctrl+d left the macro in place") + t.Error("the second x left the macro in place") } if cmd == nil { t.Fatal("deleting queued no write") @@ -87,9 +97,6 @@ func TestTheMacroPageListsFiltersAndDeletes(t *testing.T) { } } -// A macro that writes is refused as a unit, before anything is dialled: -// command by command would leave it half run, with the write it was refused -// for sitting after whatever already took effect. func TestAMacroIsGatedAsAUnit(t *testing.T) { m := macroModel(t) mac := config.Macro{Name: "risky", Commands: []string{ @@ -112,13 +119,10 @@ func TestAMacroIsGatedAsAUnit(t *testing.T) { t.Errorf("status = %q, want it to name %q", got.status, want) } } - // The read-only command before it must not have run either, which is what - // "as a unit" means: nothing was dialled at all. if got.events != nil { t.Error("the run stream was opened for a refused macro") } - // Allowing writes lifts it, as it does for a typed command. m.cfg.AllowWrites = true next, _ = m.runMacro(mac, m.all) if status := next.(tuiModel).status; strings.Contains(status, "not read-only") { @@ -140,11 +144,9 @@ func TestAMacroNeedsSomethingToRunOn(t *testing.T) { } } -// Across models, warn once: syntax differs by model and version, and a -// sequence written for one appliance can fail halfway through another. func TestACrossModelMacroAsksOnceAndThenNeverAgain(t *testing.T) { m := macroModel(t) - mac, _ := m.cmds.Macro("ha check") // built on an ion 1200 + mac, _ := m.cmds.Macro("ha check") on := rowsOfModel(m, "ion 3200") next, cmd := m.runMacro(mac, on) @@ -162,7 +164,6 @@ func TestACrossModelMacroAsksOnceAndThenNeverAgain(t *testing.T) { } } - // No is no, and nothing is acknowledged by it. next, _ = m.onMacroWarnKey("n") declined := next.(tuiModel) if declined.askMacro != "" || declined.cmds.Acked(warnMacroModel) { @@ -170,7 +171,6 @@ func TestACrossModelMacroAsksOnceAndThenNeverAgain(t *testing.T) { declined.askMacro, declined.cmds.Acked(warnMacroModel)) } - // Asking again, and answering yes, acknowledges it for good. next, _ = declined.runMacro(mac, on) m = next.(tuiModel) next, cmd = m.onMacroWarnKey("y") @@ -190,9 +190,6 @@ func TestACrossModelMacroAsksOnceAndThenNeverAgain(t *testing.T) { t.Error("the acknowledgement was not written through, so it would ask again next run") } - // Acknowledged: the next cross-model run does not stop to ask. The - // standing signal is the model tag on the row, which the run cannot clear - // the way it clears the status line. next, _ = m.runMacro(mac, on) again := next.(tuiModel) if again.askMacro != "" { @@ -203,7 +200,6 @@ func TestACrossModelMacroAsksOnceAndThenNeverAgain(t *testing.T) { } } -// A macro with no model recorded is unknown, not wrong, so it does not ask. func TestAMacroWithNoModelDoesNotWarn(t *testing.T) { m := macroModel(t) mac := config.Macro{Name: "anywhere", Commands: []string{"dump device status"}} @@ -226,8 +222,6 @@ func rowsOfModel(m tuiModel, model string) []elementRow { return out } -// Picking a macro takes you to the element list with it in hand, which is -// batch mode's order: the list is where elements are found and selected. func TestPickingAMacroArmsTheElementList(t *testing.T) { m := macroModel(t) m.macroCursor = 1 @@ -241,17 +235,13 @@ func TestPickingAMacroArmsTheElementList(t *testing.T) { if m.mode != modeMacro || m.armedMacro != mac.Name { t.Fatalf("mode = %v, armed = %q, want macro mode holding %q", m.mode, m.armedMacro, mac.Name) } - // The header and the hint both name it: the mode means nothing without - // saying which macro is in hand. got := plain(m.viewPicking()) - for _, want := range []string{"macro mode", mac.Name, "enter run " + mac.Name} { + for _, want := range []string{"Macro Mode", mac.Name, "enter run " + mac.Name} { if !strings.Contains(got, want) { t.Errorf("the list is missing %q:\n%s", want, got) } } - // Cycling the mode puts it down, or the list would hold a macro with - // nothing on screen saying so. next, _ = m.Update(tea.KeyPressMsg(tea.Key{Code: 'm', Mod: tea.ModAlt})) after := next.(tuiModel) if after.mode == modeMacro || after.armedMacro != "" { @@ -259,8 +249,6 @@ func TestPickingAMacroArmsTheElementList(t *testing.T) { } } -// Enter in macro mode runs the armed macro on what is selected, or on the -// highlighted row when nothing is, like every other mode. func TestEnterInMacroModeRunsTheArmedMacro(t *testing.T) { m := macroModel(t) mac := config.Macro{Name: "risky", Commands: []string{"config banner hello"}} @@ -272,8 +260,6 @@ func TestEnterInMacroModeRunsTheArmedMacro(t *testing.T) { m.phase = phasePicking m.rebuildRows() - // Nothing ticked: the highlighted row is the target, and the read-only - // gate is what stops this one. next, _ := m.onPickEnter() got := next.(tuiModel) if got.phase == phaseRunning { @@ -283,7 +269,6 @@ func TestEnterInMacroModeRunsTheArmedMacro(t *testing.T) { t.Errorf("status = %q, want the macro refused by name", got.status) } - // A macro deleted while armed says so rather than running nothing. m.cmds.RemoveMacro("risky") next, _ = m.onPickEnter() gone := next.(tuiModel) @@ -292,11 +277,8 @@ func TestEnterInMacroModeRunsTheArmedMacro(t *testing.T) { } } -// The top row is how a macro is made, and the form grows a line at a time -// rather than taking a comma-separated list. func TestTheMacroFormGrowsALineAtATime(t *testing.T) { m := macroModel(t) - // One model across the selection, which is what the macro records. for _, r := range m.all { if r.Model == "ion 1200" { m.selected[r.key()] = true @@ -309,8 +291,6 @@ func TestTheMacroFormGrowsALineAtATime(t *testing.T) { if m.formKind != formMacro { t.Fatalf("formKind = %v, want the macro form", m.formKind) } - // A name and one empty command line, with the ghost row pointing at the - // next one. if len(m.form) != 2 { t.Fatalf("form = %d fields, want a name and one command line", len(m.form)) } @@ -321,7 +301,6 @@ func TestTheMacroFormGrowsALineAtATime(t *testing.T) { m.form[0].input.SetValue("boot check") m.form[1].input.SetValue("dump device status") - // Tab on the last line, with something on it, adds the next one. m.formCursor = 1 next, _ = m.onFormKey(tea.KeyPressMsg(tea.Key{Code: tea.KeyTab}), "tab") m = next.(tuiModel) @@ -333,8 +312,6 @@ func TestTheMacroFormGrowsALineAtATime(t *testing.T) { } m.form[2].input.SetValue("dump interface status all") - // And on an empty line it does not: a form that grew on every tab would - // leave blanks to walk through. next, _ = m.onFormKey(tea.KeyPressMsg(tea.Key{Code: tea.KeyTab}), "tab") m = next.(tuiModel) if len(m.form) != 4 { @@ -362,20 +339,15 @@ func TestTheMacroFormGrowsALineAtATime(t *testing.T) { runSave(t, cmd) } -// Backspace on an empty command line takes it out: the form is the whole -// editor, so otherwise a line in the middle could only go by retyping -// everything after it. func TestBackspaceOnAnEmptyLineRemovesIt(t *testing.T) { m := macroModel(t) m.macroCursor = 1 next, _ := m.onMacroKey(tea.KeyPressMsg(tea.Key{Code: 'e', Text: "e"}), "e") m = next.(tuiModel) - // name, two commands, and the empty line for a third. if len(m.form) != 4 { t.Fatalf("form = %d fields, want the macro's two commands plus an empty line", len(m.form)) } - // The trailing empty line goes, and the cursor lands on the one above. m.formCursor = 3 next, _ = m.onFormKey(tea.KeyPressMsg(tea.Key{Code: tea.KeyBackspace}), "backspace") m = next.(tuiModel) @@ -386,8 +358,6 @@ func TestBackspaceOnAnEmptyLineRemovesIt(t *testing.T) { t.Errorf("cursor = %d, want the line above the one removed", m.formCursor) } - // A line in the middle: empty it, backspace, and what was below moves up - // and renumbers. m.formCursor = 1 m.form[1].input.SetValue("") next, _ = m.onFormKey(tea.KeyPressMsg(tea.Key{Code: tea.KeyBackspace}), "backspace") @@ -402,7 +372,6 @@ func TestBackspaceOnAnEmptyLineRemovesIt(t *testing.T) { t.Errorf("label = %q, want the lines renumbered", got) } - // The last command line stays, so there is always somewhere to type. m.formCursor = 1 m.form[1].input.SetValue("") next, _ = m.onFormKey(tea.KeyPressMsg(tea.Key{Code: tea.KeyBackspace}), "backspace") @@ -411,7 +380,6 @@ func TestBackspaceOnAnEmptyLineRemovesIt(t *testing.T) { t.Errorf("form = %d fields, want the last command line kept", len(m.form)) } - // And the name is never a line to remove. m.formCursor = 0 m.form[0].input.SetValue("") next, _ = m.onFormKey(tea.KeyPressMsg(tea.Key{Code: tea.KeyBackspace}), "backspace") @@ -429,7 +397,6 @@ func TestEditingAMacroReopensItsCommands(t *testing.T) { if m.formKind != formMacro { t.Fatalf("formKind = %v, want the macro form", m.formKind) } - // Name, its two commands, and an empty line for a third. if len(m.form) != 4 { t.Fatalf("form = %d fields, want the macro's commands plus an empty line", len(m.form)) } @@ -437,7 +404,6 @@ func TestEditingAMacroReopensItsCommands(t *testing.T) { t.Errorf("first command = %q, want the saved one", got) } - // Renaming replaces rather than leaving the old name behind. old := m.formMacroName m.form[0].input.SetValue(old + " v2") next, _ = m.submitForm() @@ -449,7 +415,6 @@ func TestEditingAMacroReopensItsCommands(t *testing.T) { t.Errorf("renamed macro = %+v, %v, want its commands kept", mac, ok) } - // A macro needs a name and something to run. m.form = []formField{newField("name", "", false), newMacroCommand(0)} m.formKind = formMacro next, _ = m.submitForm() @@ -458,8 +423,6 @@ func TestEditingAMacroReopensItsCommands(t *testing.T) { } } -// A mixed selection records no model: claiming one would warn about the wrong -// thing later. func TestAMixedSelectionRecordsNoModel(t *testing.T) { m := macroModel(t) for _, r := range m.all { @@ -470,9 +433,6 @@ func TestAMixedSelectionRecordsNoModel(t *testing.T) { } } -// Macro mode is only reachable by picking a macro, so the element list opened -// from the menu is never in it: it used to still be holding one from earlier -// in the session. func TestTheMenuDoesNotOpenTheListInMacroMode(t *testing.T) { m := macroModel(t) m.macroCursor = 1 @@ -482,7 +442,6 @@ func TestTheMenuDoesNotOpenTheListInMacroMode(t *testing.T) { t.Fatalf("mode = %v, want macro mode after picking one", m.mode) } - // Back to the menu, then into the list the ordinary way. m.phase = phaseMenu m.menuCursor = 0 next, _ = m.onMenuKey("enter") @@ -490,16 +449,11 @@ func TestTheMenuDoesNotOpenTheListInMacroMode(t *testing.T) { if got.mode == modeMacro || got.armedMacro != "" { t.Errorf("mode = %v armed with %q, want the macro put down", got.mode, got.armedMacro) } - // And it comes back as whatever the default mode is, not something new. if got.mode != modeShell { t.Errorf("mode = %v, want the saved default", got.mode) } } -// The line the cursor is on is highlighted whole, like a row of the element -// list. A marker alone was all this page used to manage, because the line is -// built from styled pieces and a highlight wrapped around those ends at the -// first reset inside them. func TestTheMacroCursorHighlightsTheWholeLine(t *testing.T) { m := macroModel(t) m.width = 120 @@ -511,7 +465,6 @@ func TestTheMacroCursorHighlightsTheWholeLine(t *testing.T) { t.Fatalf("%d macros, want at least two to tell the lines apart", len(rows)) } - // The highlighted line is one styled run: plain text inside ui.StyleRow. var want string for _, line := range strings.Split(out, "\n") { if strings.Contains(plain(line), rows[0].Name) { @@ -525,16 +478,14 @@ func TestTheMacroCursorHighlightsTheWholeLine(t *testing.T) { if !strings.Contains(want, "\x1b[1;35m") { t.Errorf("the cursor line is not highlighted:\n%q", want) } - // And it is one run rather than a highlight that dies at the first reset. if n := strings.Count(want, "\x1b[0m") + strings.Count(want, "\x1b[m"); n > 1 { t.Errorf("the highlight is broken into %d runs:\n%q", n, want) } - // Moving the cursor moves the highlight; the "+ new macro" row takes it too. m.macroCursor = 0 out = m.viewMacros() for _, line := range strings.Split(out, "\n") { - if strings.Contains(plain(line), "+ new macro") { + if strings.Contains(plain(line), "+ New Macro") { if !strings.Contains(line, "\x1b[1;35m") { t.Errorf("the new-macro row is not highlighted:\n%q", line) } @@ -542,9 +493,6 @@ func TestTheMacroCursorHighlightsTheWholeLine(t *testing.T) { } } -// A macro brought you to the element list, so leaving it goes back to the -// macro page rather than to the main menu: that is where the choice was made -// and where the next one is. func TestLeavingTheElementListGoesBackToTheMacroPage(t *testing.T) { m := macroModel(t) m.all = []elementRow{{Element: api.Element{ID: "1", Name: "s0100-rtr-01"}, TSG: "1"}} @@ -563,13 +511,11 @@ func TestLeavingTheElementListGoesBackToTheMacroPage(t *testing.T) { } } - // Without a macro it still goes to the menu. m.mode, m.armedMacro = modeShell, "" back, _ := m.onPickKey(tea.KeyPressMsg(tea.Key{Code: 'q', Text: "q"}), "q") if got := back.(tuiModel).phase; got != phaseMenu { t.Errorf("q from the plain list went to phase %v, want the menu", got) } - // And the hint says which. m.mode, m.armedMacro = modeMacro, m.cmds.Macros[0].Name m.phase, m.width, m.height = phasePicking, 140, 24 m.rebuildRows() @@ -577,3 +523,79 @@ func TestLeavingTheElementListGoesBackToTheMacroPage(t *testing.T) { t.Errorf("the hint does not say where back goes:\n%s", got) } } + +func TestDeletingAMacroCanBeUndone(t *testing.T) { + m := macroModel(t) + before := len(m.macroRows()) + if before < 2 { + t.Fatalf("need two macros, got %d", before) + } + + m.macroCursor = 1 + mac, ok := m.macroAt(m.macroCursor) + if !ok { + t.Fatal("no macro under the cursor") + } + + del := func() tea.Cmd { + next, _ := m.onMacroKey(tea.KeyPressMsg(tea.Key{Code: 'x', Text: "x"}), "x") + m = next.(tuiModel) + next, cmd := m.onMacroKey(tea.KeyPressMsg(tea.Key{Code: 'x', Text: "x"}), "x") + m = next.(tuiModel) + return cmd + } + runSave(t, del()) + if _, still := m.cmds.Macro(mac.Name); still { + t.Fatal("the macro survived two presses") + } + + next, cmd := m.onMacroKey(tea.KeyPressMsg(tea.Key{Code: 'u', Text: "u"}), "u") + m = next.(tuiModel) + back, ok := m.cmds.Macro(mac.Name) + if !ok { + t.Fatalf("u did not put it back: %s", m.status) + } + if !slices.Equal(back.Commands, mac.Commands) { + t.Errorf("it came back as %v, want %v", back.Commands, mac.Commands) + } + if cmd == nil { + t.Error("the undo queued no write, so it is back only until a restart") + } + runSave(t, cmd) + saved, err := m.store.Commands() + if err != nil { + t.Fatal(err) + } + if _, ok := saved.Macro(mac.Name); !ok { + t.Error("the undo was not written") + } + + next, _ = m.onMacroKey(tea.KeyPressMsg(tea.Key{Code: 'u', Text: "u"}), "u") + if got := next.(tuiModel).status; !strings.Contains(got, "nothing to undo") { + t.Errorf("status = %q with nothing left to undo", got) + } +} + +func TestTheMacroDeleteArmIsClearedByAnyOtherKey(t *testing.T) { + m := macroModel(t) + m.macroCursor = 1 + mac, _ := m.macroAt(m.macroCursor) + + next, _ := m.onMacroKey(tea.KeyPressMsg(tea.Key{Code: 'x', Text: "x"}), "x") + m = next.(tuiModel) + if m.macroDel == "" { + t.Fatal("x did not arm") + } + + next, _ = m.onMacroKey(tea.KeyPressMsg(tea.Key{Code: tea.KeyUp}), "up") + m = next.(tuiModel) + if m.macroDel != "" { + t.Error("the arm survived a cursor move") + } + m.macroCursor = 1 + next, _ = m.onMacroKey(tea.KeyPressMsg(tea.Key{Code: 'x', Text: "x"}), "x") + m = next.(tuiModel) + if _, still := m.cmds.Macro(mac.Name); !still { + t.Error("a disarmed x deleted on one press") + } +} diff --git a/internal/tui/mouse.go b/internal/tui/mouse.go index 699bf65..b132907 100644 --- a/internal/tui/mouse.go +++ b/internal/tui/mouse.go @@ -4,6 +4,43 @@ import ( tea "charm.land/bubbletea/v2" ) -// Reporting costs the terminal's own select-and-copy and buys only the wheel, -// which works anyway: the alt screen sends arrow keys for a notch. -func (m tuiModel) mouseMode() tea.MouseMode { return tea.MouseModeNone } +func (m tuiModel) mouseMode() tea.MouseMode { + switch m.phase { + case phaseSession, phaseData: + return tea.MouseModeCellMotion + } + return tea.MouseModeNone +} + +const wheelStep = 3 + +func (m tuiModel) onWheel(up bool) (tea.Model, tea.Cmd) { + switch m.phase { + case phaseSession: + s := m.sessions.Active() + if s == nil { + return m, nil + } + key := "alt+down" + if up { + key = "alt+up" + } + next := m + for range wheelStep { + next, _ = next.onScrollKey(s, key) + } + return next, nil + + case phaseData: + key := "down" + if up { + key = "up" + } + var model tea.Model = m + for range wheelStep { + model, _ = model.(tuiModel).onDataKey(key) + } + return model, nil + } + return m, nil +} diff --git a/internal/tui/mouse_test.go b/internal/tui/mouse_test.go new file mode 100644 index 0000000..c63b026 --- /dev/null +++ b/internal/tui/mouse_test.go @@ -0,0 +1,36 @@ +package tui + +import ( + "testing" + + tea "charm.land/bubbletea/v2" +) + +func TestAWheelNotchScrollsTheSessionPane(t *testing.T) { + m, _ := scrolledPane(t) + m.phase = phaseSession + if m.sesScroll != 0 { + t.Fatalf("the pane starts held at %d", m.sesScroll) + } + + next, _ := m.Update(tea.MouseWheelMsg{Button: tea.MouseWheelUp}) + m = next.(tuiModel) + if m.sesScroll == 0 { + t.Fatal("a notch up did not scroll the session pane") + } + held := m.sesScroll + + next, _ = m.Update(tea.MouseWheelMsg{Button: tea.MouseWheelDown}) + m = next.(tuiModel) + if m.sesScroll >= held { + t.Errorf("a notch down left the offset at %d, want below %d", m.sesScroll, held) + } + + for range 20 { + next, _ = m.Update(tea.MouseWheelMsg{Button: tea.MouseWheelDown}) + m = next.(tuiModel) + } + if m.sesScroll != 0 { + t.Errorf("the pane is held at %d after winding down, want the live bottom", m.sesScroll) + } +} diff --git a/internal/tui/multitenant.go b/internal/tui/multitenant.go index 6b18bda..2fb01be 100644 --- a/internal/tui/multitenant.go +++ b/internal/tui/multitenant.go @@ -20,9 +20,6 @@ import ( "tyrtop.com/iontk/internal/ui" ) -// elementRow is an element together with the tenant it belongs to. A browser -// account can reach many tenants at once, so an element is only identified by -// the pair: nothing else says which token to dial it with. type elementRow struct { api.Element TSG string @@ -38,17 +35,12 @@ func (e elementRow) label() string { return e.ID } -// sessionTarget is an element to open, carrying its tenant so the right client -// and the right device credentials are used. type sessionTarget struct { eid string name string tsg string } -// clientPool holds one authenticated client per tenant, built on demand. The -// model is copied by value all over the place, so this is deliberately a -// pointer: every copy shares the same clients. type clientPool struct { mu sync.Mutex clients map[string]*api.Client @@ -94,21 +86,15 @@ func (p *clientPool) count() int { return len(p.clients) } -// tenantElementsMsg is one tenant's share of the aggregated element list. type tenantElementsMsg struct { tsg string tenant string elements []api.Element sites map[string]string - // siteErr is reported separately: elements are still usable without their - // site names. - siteErr error - err error + siteErr error + err error } -// loadTenant authenticates a tenant if needed and fetches its elements. One of -// these runs per active tenant, so the list fills in as each answers rather -// than waiting for the slowest. func loadTenant(ctx context.Context, f app.Flags, t config.Tenant, pool *clientPool, st *config.Store) tea.Cmd { return func() tea.Msg { fail := func(err error) tea.Msg { @@ -130,8 +116,6 @@ func loadTenant(ctx context.Context, f app.Flags, t config.Tenant, pool *clientP return fail(err) } - // A failed site lookup is not fatal, but it must not be silent either: - // swallowing it is why every site showed as a raw id for so long. sites := map[string]string{} ss, siteErr := c.ListSites(ctx) for _, s := range ss { @@ -153,8 +137,6 @@ func loadTenant(ctx context.Context, f app.Flags, t config.Tenant, pool *clientP } } -// mergeTenantElements replaces one tenant's share of the list, leaving the -// other tenants' rows alone. func (m *tuiModel) mergeTenantElements(tsg, tenant string, els []api.Element) { kept := make([]elementRow, 0, len(m.all)+len(els)) for _, r := range m.all { @@ -168,8 +150,6 @@ func (m *tuiModel) mergeTenantElements(tsg, tenant string, els []api.Element) { m.setElements(kept) } -// loadFromCache fills the list from disk so the element page is usable before -// any network call returns. func (m *tuiModel) loadFromCache() { var rows []elementRow oldest := time.Duration(0) @@ -187,8 +167,6 @@ func (m *tuiModel) loadFromCache() { } m.sites[k] = v } - // Kept apart from the fresh states: the column marks these with an - // asterisk, because they are as old as the file they came out of. for eid, w := range c.HA { if m.haCached == nil { m.haCached = map[string]string{} @@ -211,11 +189,7 @@ func (m *tuiModel) loadFromCache() { } } -// refreshActive fetches every active tenant. Tenants with no usable credential -// are reported rather than silently skipped. func (m tuiModel) refreshActive() (tea.Model, tea.Cmd) { - // Start from a clean slate: a message left from an earlier action must not - // have this run's failures appended to it. m.status = "" active := m.aggregateTenants() if len(active) == 0 { @@ -252,8 +226,6 @@ func (m tuiModel) refreshActive() (tea.Model, tea.Cmd) { return m, tea.Batch(cmds...) } -// credsFor returns the device login for a tenant. ION users are stored per -// tenant, so a session in one tenant must not be opened with another's login. func (m tuiModel) credsFor(tsg string) ionterm.Credentials { for _, t := range m.cfg.Tenants { if t.TSGID != tsg { @@ -265,16 +237,11 @@ func (m tuiModel) credsFor(tsg string) ionterm.Credentials { return ionterm.Credentials{} } -// targetsFor pairs selected elements with their own tenant's client, which is -// what lets one batch run span tenants. func (m tuiModel) targetsFor(rows []elementRow) []sase.Target { out := make([]sase.Target, 0, len(rows)) for _, r := range rows { creds := m.credsFor(r.TSG) t := sase.Target{ElementID: r.ID, User: creds.User, Pass: creds.Pass} - // Assigning a nil *api.Client to the interface would produce a non-nil - // interface holding a nil pointer, which defeats every nil check - // downstream and panics on first use. Leave the field zero instead. if c := m.pool.get(r.TSG); c != nil { t.Client = c } @@ -283,8 +250,6 @@ func (m tuiModel) targetsFor(rows []elementRow) []sase.Target { return out } -// tenantClient hands back a tenant's client, building nothing: an element can -// only be dialled once its tenant has been loaded. func (m tuiModel) tenantClient(tsg string) *api.Client { return m.pool.get(tsg) } @@ -293,9 +258,6 @@ func httpClientFor(f app.Flags) *http.Client { return &http.Client{Timeout: f.HTTPTimeout} } -// tenantErr records a per-tenant failure without losing the other tenants' -// results. A credential problem routes to the browser the same way a single -// tenant's would. func (m *tuiModel) tenantErr(tsg, tenant string, err error) { reason := shortTenantErr(err) if m.tenantIssues == nil { @@ -314,20 +276,10 @@ func (m *tuiModel) tenantErr(tsg, tenant string, err error) { } } -// shortTenantErr trims the layers a failed sign-in accumulates. The full chain -// reads "interactive sign-in required: no existing browser session: -// authorization failed: interaction_required: ..." which buries the one fact -// that matters. -// -// A tenant the account cannot act in fails the same way a dead session does: -// the authorization server answers interaction_required either way. A dead -// session would fail every tenant at once, so the likely cause of one tenant -// failing is access, and the wording says so without claiming to be sure. func shortTenantErr(err error) string { return ui.Truncate(app.ShortAuthError(err), 90) } -// tenantOf finds the configured record for a tenant ID. func (m tuiModel) tenantOf(tsg string) (config.Tenant, bool) { for _, t := range m.cfg.Tenants { if t.TSGID == tsg { @@ -337,16 +289,10 @@ func (m tuiModel) tenantOf(tsg string) (config.Tenant, bool) { return config.Tenant{}, false } -// ionUserFor is the device login to use in a tenant. func (m tuiModel) ionUserFor(t config.Tenant) string { return m.runAsFor(t.TSGID) } -// tsgForElement finds which tenant an element belongs to. Sessions and the -// reattach list only carry an element ID, so the tenant has to be recovered -// from the loaded list; the active tenant is the fallback. -// elementByKey is the loaded row for a tenant's element, which is where a -// data tab gets the site and cluster ids its views are written against. func (m tuiModel) elementByKey(key string) (elementRow, bool) { for _, r := range m.all { if r.key() == key { @@ -365,8 +311,6 @@ func (m tuiModel) tsgForElement(eid string) string { return m.tenant.TSGID } -// activeSessionCreds are the device credentials for whichever session is in -// front, which may belong to a different tenant than the one being browsed. func (m tuiModel) activeSessionCreds() ionterm.Credentials { s := m.sessions.Active() if s == nil { @@ -375,11 +319,6 @@ func (m tuiModel) activeSessionCreds() ionterm.Credentials { return m.credsFor(m.tsgForElement(s.ElementID)) } -// scopeLabel names what the element list is showing. Naming a single tenant -// would be a lie once several are loaded. -// scopeLabel names what the element list is showing. It is computed when the -// list changes rather than per render: walking every element on each keystroke -// made holding a movement key lag badly once several tenants were loaded. func (m tuiModel) scopeLabel() string { if m.scope != "" { return m.scope @@ -390,7 +329,6 @@ func (m tuiModel) scopeLabel() string { return "no tenant" } -// computeScope derives the label and the tenant count from the loaded elements. func (m *tuiModel) computeScope() { seen := map[string]string{} for _, r := range m.all { @@ -409,12 +347,6 @@ func (m *tuiModel) computeScope() { } } -// aggregateTenants is the set of tenants whose elements make up the list. -// -// Browser mode spans the whole working set, which is what makes searching -// across tenants possible: the set is chosen from the discovered tenants when -// signing in, and changed in settings. Tenant mode is a single service -// account, so it spans that alone. func (m tuiModel) aggregateTenants() []config.Tenant { if m.authMode == authTenant { if m.hasTenant { @@ -427,45 +359,64 @@ func (m tuiModel) aggregateTenants() []config.Tenant { if len(added) > 0 { return added } - // Nothing picked yet: the current tenant is the list, so a fresh setup - // still works before visiting the picker. if m.hasTenant { return []config.Tenant{m.tenant} } return nil } -// refreshNote is the moving "refreshing" marker beside the element count. An -// element refresh had no visible feedback at all before: the list simply sat -// there until it changed. func (m tuiModel) refreshNote() string { + return m.elementsNote() + "\n" + m.haNote() +} + +func (m tuiModel) elementsNote() string { switch m.cacheState { case cacheUpdating: - note := " " + m.spinner.View() + " " + ui.StyleWarn.Render("refreshing") + note := m.spinner.View() + " " + ui.StyleWarn.Render("refreshing elements") if m.pendingTenants > 1 { note += ui.StyleDim.Render(fmt.Sprintf(" %d tenants", m.pendingTenants)) } return note case cacheFailed: - return " " + ui.StyleErr.Render("refresh failed") + return ui.StyleErr.Render("element refresh failed") + case cacheNone: + return ui.StyleDim.Render("no cached elements") + } + + d := m.cacheAgeNow() + if m.cacheState == cacheUpdated && d < cacheFresh { + return ui.StyleOK.Render("Elements refreshed!") + ago(d) } - return m.haNote() + return ui.StyleDim.Render("Elements cached") + ago(d) } -// haNote is how far the HA sweep has got. One request per cluster at five a -// second is tens of seconds on a fleet of pairs, and a column filling in -// silently looks like one that has given up. func (m tuiModel) haNote() string { - if m.haTotal == 0 || m.haDone >= m.haTotal { + if m.haWorking() { + note := m.spinner.View() + " " + ui.StyleWarn.Render("refreshing HA") + if m.haTotal > 0 { + note += ui.StyleDim.Render(fmt.Sprintf(" %d/%d clusters", + min(m.haDone, m.haTotal), m.haTotal)) + } + return note + } + if m.haAt.IsZero() { return "" } - return " " + m.spinner.View() + " " + - ui.StyleDim.Render(fmt.Sprintf("ha %d/%d clusters", m.haDone, m.haTotal)) + + d := time.Since(m.haAt) + if d < cacheFresh { + return ui.StyleOK.Render("HA refreshed!") + ago(d) + } + return ui.StyleDim.Render("HA cached") + ago(d) +} + +func ago(d time.Duration) string { + if d < time.Second { + return ui.StyleDim.Render(" last updated just now") + } + return ui.StyleDim.Render(" last updated " + ui.Age(d) + " ago") } -// runAsFor is the device login chosen for a tenant: an explicit choice made in -// the users panel, otherwise that tenant's default. Each tenant keeps its own, -// because one account can reach tenants with entirely different ION logins. func (m tuiModel) runAsFor(tsg string) string { if u := m.runAs[tsg]; u != "" { return u @@ -487,13 +438,10 @@ func (m *tuiModel) setRunAs(tsg, user string) { m.runAs[tsg] = user } -// usersPanelTenant is the tenant whose logins the users panel is managing. func (m tuiModel) usersPanelTenant() config.Tenant { if t, ok := m.tenantOf(m.usersTSG); ok { return t } - // A tenant in the list but not yet recorded still needs naming, so the - // panel can offer to add its first user. for _, r := range m.all { if r.TSG == m.usersTSG { return config.Tenant{Name: r.Tenant, TSGID: r.TSG, Auth: config.AuthBrowser} @@ -502,8 +450,6 @@ func (m tuiModel) usersPanelTenant() config.Tenant { return m.tenant } -// usersPanelTenants are the tenants the panel can cycle through: everything -// loaded in the list, plus the current one. func (m tuiModel) usersPanelTenants() []config.Tenant { var out []config.Tenant seen := map[string]bool{} @@ -530,7 +476,6 @@ func (m tuiModel) usersPanelTenants() []config.Tenant { return out } -// cycleUsersTenant moves the panel to the next or previous tenant. func (m tuiModel) cycleUsersTenant(delta int) tuiModel { list := m.usersPanelTenants() if len(list) < 2 { @@ -549,9 +494,6 @@ func (m tuiModel) cycleUsersTenant(delta int) tuiModel { return m } -// countLabel says how many elements are in view. With a filter running it also -// says how many there are in total, so a narrow search does not look like a -// short list. func (m tuiModel) countLabel() string { sel := fmt.Sprintf("%d selected", len(m.selected)) if len(m.matches) != len(m.all) { @@ -560,16 +502,13 @@ func (m tuiModel) countLabel() string { return fmt.Sprintf("%s / %d elements", sel, len(m.all)) } -// tenantConnectedMsg is a tenant that has just authenticated. type tenantConnectedMsg struct { tsg string client *api.Client - // Two watchers on one channel would halve the failures each sees. - fresh bool - err error + fresh bool + err error } -// connectTenant authenticates one tenant without fetching anything. func connectTenant(ctx context.Context, st *config.Store, f app.Flags, tsg string) tea.Cmd { return func() tea.Msg { c, err := app.NewClient(ctx, st, f, io.Discard) @@ -577,8 +516,6 @@ func connectTenant(ctx context.Context, st *config.Store, f app.Flags, tsg strin } } -// recheckToken reports as a connection so the waiting batch or session replays -// unchanged. func recheckToken(ctx context.Context, c *api.Client, tsg string) tea.Cmd { return func() tea.Msg { if err := c.EnsureToken(ctx, preflightTTL); err != nil { @@ -588,20 +525,8 @@ func recheckToken(ctx context.Context, c *api.Client, tsg string) tea.Cmd { } } -// preflightTTL sits above the refresh margin: inside it the background refresh -// is due but may not have happened, or may be backing off. const preflightTTL = 90 * time.Second -// ensureClients returns the commands needed to authenticate any of these -// tenants that has no usable client yet. -// -// A tenant only had a client as a side effect of fetching its elements, so a -// launch that found a fresh cache had none at all: every element then looked -// like it belonged to a disconnected tenant. Connecting on demand is what makes -// a cached list usable. -// -// A token that will not outlast the operation counts as missing. Both expensive -// paths come through here, so one exchange now beats a 401 per element later. func (m tuiModel) ensureClients(tsgs []string) []tea.Cmd { seen := map[string]bool{} var cmds []tea.Cmd @@ -613,14 +538,10 @@ func (m tuiModel) ensureClients(tsgs []string) []tea.Cmd { if c.TokenGood(preflightTTL) { continue } - // Renewed in place: rebuilding would abandon this client's - // refresh loop and start another. seen[tsg] = true cmds = append(cmds, recheckToken(m.ctx, c, tsg)) continue } - // A tenant that already failed this round is not tried again: the - // replay would otherwise connect, fail, replay, forever. if m.tenantIssues[tsg] != "" { continue } @@ -644,7 +565,6 @@ func (m tuiModel) ensureClients(tsgs []string) []tea.Cmd { return cmds } -// tsgsOf is the distinct tenants a set of targets touches. func tsgsOf(targets []sessionTarget) []string { seen := map[string]bool{} var out []string @@ -657,7 +577,6 @@ func tsgsOf(targets []sessionTarget) []string { return out } -// tsgsOfRows is the distinct tenants a set of elements touches. func tsgsOfRows(rows []elementRow) []string { seen := map[string]bool{} var out []string @@ -670,7 +589,6 @@ func tsgsOfRows(rows []elementRow) []string { return out } -// tenantName is a tenant's display name, falling back to its ID. func (m tuiModel) tenantName(tsg string) string { if t, ok := m.tenantOf(tsg); ok { return t.Name @@ -683,7 +601,6 @@ func (m tuiModel) tenantName(tsg string) string { return tsg } -// toggleCurrent selects or deselects the highlighted element. func (m tuiModel) toggleCurrent() tuiModel { el, ok := m.current() if !ok { @@ -698,8 +615,6 @@ func (m tuiModel) toggleCurrent() tuiModel { return m } -// selectedNote marks the list when it has been narrowed to the selection, so -// that view is never mistaken for the whole list. func (m tuiModel) selectedNote() string { if !m.showSelected { return "" diff --git a/internal/tui/multitenant_test.go b/internal/tui/multitenant_test.go index 7392673..daf97f8 100644 --- a/internal/tui/multitenant_test.go +++ b/internal/tui/multitenant_test.go @@ -55,7 +55,6 @@ func TestElementsFromSeveralTenantsAreOneList(t *testing.T) { t.Error("the tenant column did not show with two tenants loaded") } - // Re-fetching one tenant replaces only its own rows. m.mergeTenantElements("111", "Tractor Supply", []api.Element{ {ID: "a", Name: "s0100-rtr-01"}, }) @@ -73,8 +72,6 @@ func TestElementsFromSeveralTenantsAreOneList(t *testing.T) { func TestSameElementIDInTwoTenantsStaysDistinct(t *testing.T) { m := multiModel(t) - // Element IDs are opaque; nothing guarantees they never repeat, and the - // selection map would silently merge them if the tenant were left out. m.mergeTenantElements("111", "Tractor Supply", []api.Element{{ID: "same", Name: "a"}}) m.mergeTenantElements("222", "Niagara Bottling", []api.Element{{ID: "same", Name: "b"}}) @@ -120,8 +117,6 @@ func TestEachElementDialsWithItsOwnTenantsClient(t *testing.T) { if got[1].Client != second { t.Error("the second element did not get its own tenant's client") } - // A tenant that was never loaded has no client; the run reports that per - // element rather than refusing the whole batch. if got[2].Client != nil { t.Error("an unloaded tenant handed out a client") } @@ -185,7 +180,6 @@ func TestLoadFromCacheAggregatesActiveTenantsOnly(t *testing.T) { t.Error("an inactive tenant's elements were loaded") } } - // The age shown is the oldest of the tenants, since that is what is stale. if m.cacheState != cacheCached { t.Errorf("cacheState = %v, want cacheCached", m.cacheState) } @@ -195,8 +189,6 @@ func TestLoadFromCacheAggregatesActiveTenantsOnly(t *testing.T) { } func TestLoadFromCacheKeepsSiteNames(t *testing.T) { - // Site names were fetched and then dropped, so every site showed a raw id - // until the first refresh landed. The cache has to carry them. m := multiModel(t) if err := m.store.SaveCache(&config.ElementCache{ TSGID: "111", FetchedAt: time.Now().Add(-time.Minute), @@ -213,7 +205,6 @@ func TestLoadFromCacheKeepsSiteNames(t *testing.T) { if got := m.siteName(m.all[0]); got != "Store 0100" { t.Errorf("siteName() = %q, want the cached name rather than the raw id", got) } - // And it is searchable, which a raw id deliberately is not. m.filter.SetValue("store 0100") m.applyFilter() if len(m.matches) != 1 { @@ -229,7 +220,6 @@ func TestLoadFromCacheMarksAStaleAggregateAsOwed(t *testing.T) { }); err != nil { t.Fatalf("SaveCache() error = %v", err) } - // One fresh tenant and one stale one means the aggregate is stale. if err := m.store.SaveCache(&config.ElementCache{ TSGID: "222", FetchedAt: time.Now().Add(-40 * time.Minute), Elements: []api.Element{{ID: "c"}}, @@ -245,8 +235,6 @@ func TestLoadFromCacheMarksAStaleAggregateAsOwed(t *testing.T) { func TestRefreshSkipsTenantsWithNoCredentialRatherThanFailing(t *testing.T) { m := multiModel(t) - // Only the first tenant can authenticate; browser tenants need a tenant ID, - // so blank one out to make it unusable. m.cfg.Tenants[1].TSGID = "" m.spinner = ui.NewSpinner("") @@ -296,7 +284,6 @@ func TestAggregateFinishesOnlyWhenEveryTenantHasAnswered(t *testing.T) { if m.cacheState != cacheUpdating { t.Errorf("cacheState = %v, want still updating with one tenant outstanding", m.cacheState) } - // The list is usable before the slowest tenant answers. if len(m.all) != 1 { t.Errorf("got %d elements, want the first tenant's already shown", len(m.all)) } @@ -342,9 +329,6 @@ func TestOneTenantFailingDoesNotLoseTheOthers(t *testing.T) { } func TestATenantTheAccountCannotReachIsNotCalledASignInProblem(t *testing.T) { - // A tenant without rights fails exactly as a dead session does, so the - // wording must not insist a sign-in is the answer, and one bad tenant must - // not mark the whole credential dead. m := multiModel(t) m.spinner = ui.NewSpinner("") m.pendingTenants = 2 @@ -376,7 +360,6 @@ func TestATenantTheAccountCannotReachIsNotCalledASignInProblem(t *testing.T) { t.Error("the problem tenant was not recorded for settings to flag") } - // It clears once that tenant answers. mm, _ = got.Update(tenantElementsMsg{tsg: "222", tenant: "CIS_LAB", elements: nil}) if cleared := mm.(tuiModel); cleared.tenantIssues["222"] != "" { t.Error("the issue survived a successful fetch") @@ -507,8 +490,6 @@ func TestParseMode(t *testing.T) { } } -// The menu has one mode now, the credential one, and both keys reach it. Shell -// versus batch belongs to the element list, where it is visible. func TestTheMenuOnlySwitchesTheCredentialMode(t *testing.T) { for _, key := range []string{"alt+m", "alt+a"} { t.Run(key, func(t *testing.T) { @@ -580,7 +561,7 @@ func TestSettingsChecklistControlsTheAggregate(t *testing.T) { m.phase = phaseSettings m.setSection = secTenants m.setFocus = focusContent - m.setCursor = 1 // Niagara Bottling + m.setCursor = 1 mm, _ := m.onTenantsKey("space") got := mm.(tuiModel) @@ -588,8 +569,6 @@ func TestSettingsChecklistControlsTheAggregate(t *testing.T) { if got.cfg.Tenants[1].Added { t.Error("space did not exclude the tenant") } - // Excluding a tenant has to drop its elements, or the list keeps showing - // devices the user just said they did not want. if len(got.all) != 1 { t.Errorf("got %d elements, want the excluded tenant's dropped", len(got.all)) } @@ -648,8 +627,6 @@ func TestUsingATenantIncludesItInTheAggregate(t *testing.T) { } func TestClientPoolIsSharedAcrossModelCopies(t *testing.T) { - // The model is copied by value constantly; a pool that was not shared would - // silently lose clients between updates. m := multiModel(t) c := liveClient() m.pool.set("111", c) @@ -680,8 +657,6 @@ func TestClientPoolIsSharedAcrossModelCopies(t *testing.T) { } func TestTheTenantColumnCanAppearAndDisappear(t *testing.T) { - // The column count changes with the number of tenants loaded, and the table - // panics if its rows and columns ever disagree. m := multiModel(t) m.width, m.height = 110, 30 @@ -691,13 +666,11 @@ func TestTheTenantColumnCanAppearAndDisappear(t *testing.T) { t.Fatal("two tenants did not produce a tenant column") } - // Back down to one tenant, which removes the column. m.mergeTenantElements("222", "Niagara Bottling", nil) if m.showTenantColumn() { t.Error("the tenant column stayed after the second tenant emptied") } - // And up again. m.mergeTenantElements("222", "Niagara Bottling", []api.Element{{ID: "c", Name: "two"}}) if !m.showTenantColumn() { t.Error("the tenant column did not come back") @@ -710,14 +683,11 @@ func TestMenuSecondEntryFollowsTheCredentialMode(t *testing.T) { want string absent string }{ - {authBrowser, "sign in with browser", "service accounts"}, - // Named for what it lists: one tenant per account, not every tenant. - {authTenant, "service accounts", "browser"}, + {authBrowser, "Sign In with Browser", "Service Accounts"}, + {authTenant, "Service Accounts", "browser"}, } for _, tt := range tests { t.Run(tt.mode.String(), func(t *testing.T) { - // Both element list modes must give the same answer: the two axes - // are independent. for _, listMode := range pickModes { m := multiModel(t) m.authMode = tt.mode @@ -801,7 +771,6 @@ func TestPickingTheWorkingSet(t *testing.T) { next, _ := m.startTenantPick() m = next - // Tick the first and third. mm, _ := m.onTenantPickKey("space") m = mm.(tuiModel) m.pickCursor = 2 @@ -825,7 +794,6 @@ func TestPickingTheWorkingSet(t *testing.T) { if !ids["1643707006"] || !ids["1984021017"] { t.Errorf("working set = %+v, want the two ticked tenants", added) } - // The untouched tenant is not recorded at all. if got.cfg.IsAdded("1538107912") { t.Error("an unticked tenant ended up in the set") } @@ -877,8 +845,6 @@ func TestPickerSelectAllToggles(t *testing.T) { } func TestSearchSpansTheWholeWorkingSet(t *testing.T) { - // The point of the working set: one filter reaches every tenant in it, - // rather than only the tenant that happens to be current. m := multiModel(t) m.authMode = authBrowser m.mergeTenantElements("111", "Tractor Supply", []api.Element{ @@ -914,7 +880,6 @@ func TestAggregateSpansTheWorkingSetNotJustTheCurrentTenant(t *testing.T) { t.Errorf("aggregate has %d tenants, want the whole working set: %+v", len(got), got) } - // Tenant mode is a single service account, so it spans one. m.authMode = authTenant if got := m.aggregateTenants(); len(got) != 1 { t.Errorf("tenant mode aggregate has %d tenants, want 1", len(got)) @@ -926,15 +891,37 @@ func TestRefreshNoteGivesFeedback(t *testing.T) { m.spinner = ui.NewSpinner("") m.cacheState = cacheUpdated - if got := m.refreshNote(); got != "" { - t.Errorf("refreshNote() = %q, want nothing when idle", got) + if got := plain(m.refreshNote()); !strings.Contains(got, "Elements refreshed!") { + t.Errorf("refreshNote() = %q, want it to say the elements are fresh", got) + } + if got := plain(m.refreshNote()); !strings.Contains(got, "last updated") { + t.Errorf("refreshNote() = %q, want it to say how long ago", got) + } + + if n := strings.Count(m.refreshNote(), "\n"); n != 1 { + t.Errorf("refreshNote() has %d newlines, want the two notes stacked", n) } m.cacheState = cacheUpdating note := plain(m.refreshNote()) - if !strings.Contains(note, "refreshing") { - t.Errorf("refreshNote() = %q, want it to say refreshing", note) + if !strings.Contains(note, "refreshing elements") { + t.Errorf("refreshNote() = %q, want it to name the element refresh", note) + } + m.haInFlight = map[string]bool{"1": true} + m.haTotal, m.haDone = 312, 48 + if got := plain(m.refreshNote()); !strings.Contains(got, "refreshing HA 48/312 clusters") { + t.Errorf("refreshNote() = %q, want the HA sweep named and counted", got) + } + m.haInFlight = nil + m.haAt = time.Now() + if got := plain(m.refreshNote()); strings.Contains(got, "refreshing HA") { + t.Errorf("refreshNote() = %q, want the sweep's note gone", got) + } + if got := plain(m.refreshNote()); !strings.Contains(got, "HA refreshed!") { + t.Errorf("refreshNote() = %q, want it to say the sweep finished", got) } + m.haTotal, m.haDone, m.haAt = 0, 0, time.Time{} + m.cacheState = cacheUpdating m.pendingTenants = 3 if got := plain(m.refreshNote()); !strings.Contains(got, "3 tenants") { @@ -948,8 +935,6 @@ func TestRefreshNoteGivesFeedback(t *testing.T) { } func TestTenantRowsDoNotShiftWithSelection(t *testing.T) { - // Padding a styled string counts its escape codes towards the width, which - // made the tenant id jump between rows. m := discoverModel(t) seedKnown(t, m, config.KnownTenant{TSGID: "1643707006", Name: "CIS root"}, @@ -976,7 +961,6 @@ func TestTenantRowsDoNotShiftWithSelection(t *testing.T) { t.Errorf("ids start at columns %d and %d, want them aligned:\n%s", a1, b1, first) } - // Moving the cursor must not move anything. m.switchCursor = 1 second := plain(m.viewSwitch()) if a2, b2 := columnOf(second, "1643707006"), columnOf(second, "1538107912"); a2 != a1 || b2 != b1 { @@ -1030,7 +1014,6 @@ func credsModel(t *testing.T) tuiModel { func TestRunAsIsChosenPerTenant(t *testing.T) { m := credsModel(t) - // Defaults to begin with. if got := m.runAsFor("111"); got != "tsc-ops" { t.Errorf("runAsFor(111) = %q, want the default", got) } @@ -1038,7 +1021,6 @@ func TestRunAsIsChosenPerTenant(t *testing.T) { t.Errorf("runAsFor(222) = %q, want the default", got) } - // Choosing a non-default user in one tenant must not touch the other. m.setRunAs("111", "tsc-ro") if got := m.runAsFor("111"); got != "tsc-ro" { t.Errorf("runAsFor(111) = %q, want the chosen user", got) @@ -1056,7 +1038,6 @@ func TestRunAsIsChosenPerTenant(t *testing.T) { t.Errorf("credsFor(222) = %+v, want its own pair", b) } - // Clearing falls back to the default rather than to nothing. m.setRunAs("111", "") if got := m.runAsFor("111"); got != "tsc-ops" { t.Errorf("runAsFor(111) = %q, want the default back", got) @@ -1066,9 +1047,8 @@ func TestRunAsIsChosenPerTenant(t *testing.T) { func TestUsersPanelOpensOnTheHighlightedElementsTenant(t *testing.T) { m := credsModel(t) m.phase = phasePicking - m.tenant = m.cfg.Tenants[0] // browsing Tractor Supply + m.tenant = m.cfg.Tenants[0] - // Put the cursor on the Niagara element. for i, idx := range m.matches { if m.all[idx].TSG == "222" { m.table.SetCursor(i) @@ -1111,11 +1091,10 @@ func TestUsersPanelCyclesTenants(t *testing.T) { func TestUsersPanelActsOnItsOwnTenant(t *testing.T) { m := credsModel(t) m.phase = phaseUsers - m.usersTSG = "222" // Niagara, while browsing Tractor Supply + m.usersTSG = "222" m.tenant = m.cfg.Tenants[0] m.ionUser = "tsc-ops" - // enter runs as that tenant's user without touching the browsed tenant. mm, _ := m.onUsersKey("enter") got := mm.(tuiModel) if got.runAsFor("222") != "nb-ops" { @@ -1128,7 +1107,6 @@ func TestUsersPanelActsOnItsOwnTenant(t *testing.T) { t.Errorf("status = %q, want the tenant named", got.status) } - // p edits that tenant's password, not the browsed tenant's. mm, _ = m.onUsersKey("p") pw := mm.(tuiModel) if pw.formTenantName != "Niagara Bottling" { @@ -1146,8 +1124,6 @@ func TestPasswordPromptAsksForEveryTenantInTheSelection(t *testing.T) { setTestClient(&m, liveClient()) m.pool.set("222", liveClient()) - // Niagara has no stored password, and it is the second tenant in the - // selection: checking only the first would sail straight past it. if err := m.store.Secrets.Delete(config.IONPassKey("222", "nb-ops")); err != nil { t.Fatalf("clear: %v", err) } @@ -1173,10 +1149,10 @@ func TestPasswordPromptAsksForEveryTenantInTheSelection(t *testing.T) { func TestPasswordIsSavedAgainstTheTenantThatWasAsked(t *testing.T) { m := credsModel(t) - m.tenant = m.cfg.Tenants[0] // browsing Tractor Supply + m.tenant = m.cfg.Tenants[0] m.ionUser = "tsc-ops" - target := m.cfg.Tenants[1] // Niagara + target := m.cfg.Tenants[1] mm, _ := m.openPasswordForm(target, "nb-ops") m = mm.(tuiModel) m.form[0].input.SetValue("brand-new") @@ -1184,11 +1160,9 @@ func TestPasswordIsSavedAgainstTheTenantThatWasAsked(t *testing.T) { mm, _ = m.submitForm() got := mm.(tuiModel) - // The password belongs to the tenant that was asked about. if v, err := got.store.Secrets.Get(config.IONPassKey("222", "nb-ops")); err != nil || v != "brand-new" { t.Errorf("password for 222 = %q (err %v), want it saved there", v, err) } - // And must not have landed under the browsed tenant. if v, _ := got.store.Secrets.Get(config.IONPassKey("111", "nb-ops")); v != "" { t.Errorf("password leaked into the browsed tenant: %q", v) } @@ -1258,8 +1232,6 @@ func TestAltWClosesWhatYouAreIn(t *testing.T) { mm, _ := m.onPickKey(tea.KeyPressMsg(tea.Key{Code: 'w', Mod: tea.ModAlt}), "alt+w") got := mm.(tuiModel) - // Nothing was open here, but the point is that leaving the list is not - // a teardown: alt+t must still be able to bring a session back. if got.sessions == nil { t.Error("the session manager was discarded") } @@ -1295,11 +1267,8 @@ func TestAltWClosesWhatYouAreIn(t *testing.T) { } func TestAddingAUserToATenantWithNoRecordYet(t *testing.T) { - // A discovered tenant has no config record until it is worked in. Pressing - // a in the users panel used to open the form and then fail on save with - // "tenant ... is gone", which blocked ever giving it a login. m := multiModel(t) - m.cfg = &config.Config{} // nothing recorded at all + m.cfg = &config.Config{} m.mergeTenantElements("333", "Synovus", []api.Element{{ID: "s1", Name: "syn-rtr-01"}}) m.phase = phaseUsers m.usersTSG = "333" @@ -1350,8 +1319,6 @@ func TestAddingAUserToATenantWithNoRecordYet(t *testing.T) { } func TestCrossTenantBatchPairsEachElementWithItsTenantsLogin(t *testing.T) { - // The pairing is the part that matters here; that RunStream then dials with - // it is proven in internal/sase against a stub client. m := credsModel(t) m.mode = modeBatch setTestClient(&m, liveClient()) @@ -1393,7 +1360,6 @@ func TestCrossTenantBatchRefusesWhenATenantHasNoLogin(t *testing.T) { m.pool.set("222", liveClient()) m.cmdInput = newField("", "", false).input - // Niagara loses its password, so the run must stop and say which tenant. if err := m.store.Secrets.Delete(config.IONPassKey("222", "nb-ops")); err != nil { t.Fatalf("clear: %v", err) } @@ -1412,8 +1378,6 @@ func TestCrossTenantBatchRefusesWhenATenantHasNoLogin(t *testing.T) { } func TestBatchAcrossTenantsAfterSuppliedPasswords(t *testing.T) { - // The reported sequence: pick routers in two tenants, get prompted for the - // missing login, supply it, and the run should then go ahead. m := credsModel(t) m.mode = modeBatch m.phase = phasePicking @@ -1440,7 +1404,6 @@ func TestBatchAcrossTenantsAfterSuppliedPasswords(t *testing.T) { mm, _ = prompt.submitForm() after := mm.(tuiModel) - // With every tenant now covered, batch mode moves on to the command entry. if after.phase != phaseCommand { t.Fatalf("phase = %v, want the command prompt once all logins are in", after.phase) } @@ -1453,11 +1416,8 @@ func TestBatchAcrossTenantsAfterSuppliedPasswords(t *testing.T) { } func TestSelectingFromACachedListConnectsItsTenant(t *testing.T) { - // A launch that finds a fresh cache never fetches, so no tenant has a - // client. Selecting an element then looked like a disconnected tenant; it - // has to authenticate on demand instead. m := credsModel(t) - m.pool = newClientPool() // nothing connected, as after a cached launch + m.pool = newClientPool() m.phase = phasePicking m.mode = modeShell m.spinner = ui.NewSpinner("") @@ -1609,7 +1569,6 @@ func TestEnsureClientsSkipsWhatIsAlreadyConnected(t *testing.T) { func TestEnsureClientsSkipsTenantsWithNoCredential(t *testing.T) { m := credsModel(t) m.pool = newClientPool() - // A browser tenant needs a tenant ID to validate; blank it out. m.cfg.Tenants[1].TSGID = "" m.cfg.Tenants[1].Name = "Broken" @@ -1620,8 +1579,6 @@ func TestEnsureClientsSkipsTenantsWithNoCredential(t *testing.T) { } func TestAFailedTenantIsNotRetriedForever(t *testing.T) { - // The replay path would otherwise connect, fail, replay and connect again - // without end. m := credsModel(t) m.pool = newClientPool() m.spinner = ui.NewSpinner("") @@ -1631,7 +1588,6 @@ func TestAFailedTenantIsNotRetriedForever(t *testing.T) { if cmds := m.ensureClients([]string{"222"}); len(cmds) != 0 { t.Errorf("got %d connect commands, want none for a tenant that just failed", len(cmds)) } - // The healthy tenant is untouched by its neighbour's failure. if cmds := m.ensureClients([]string{"111"}); len(cmds) != 1 { t.Errorf("got %d connect commands for a healthy tenant, want 1", len(cmds)) } @@ -1668,8 +1624,6 @@ func liveClient() *api.Client { return api.NewClient(nil, nil, "test-token", false, 1, 1) } -// spentClient sits inside the pre-flight window, where a spent token used to -// go unnoticed until a dial came back 401. func spentClient(t *testing.T) *api.Client { t.Helper() c := api.NewClient(nil, nil, "about-to-expire", false, 1, 1) diff --git a/internal/tui/quitkeys_test.go b/internal/tui/quitkeys_test.go index 8a13399..90da5fa 100644 --- a/internal/tui/quitkeys_test.go +++ b/internal/tui/quitkeys_test.go @@ -24,8 +24,6 @@ func pressKey(t *testing.T, m tuiModel, key tea.Key) (tuiModel, tea.Cmd) { var keyQ = tea.Key{Code: 'q', Text: "q"} var keyEsc = tea.Key{Code: tea.KeyEsc} -// q used to quit on the element list, mid-run and on the results page, so the -// key that leaves every other page killed the app on those three. func TestQClosesThePageLikeEsc(t *testing.T) { tests := []struct { name string @@ -66,7 +64,6 @@ func TestQClosesThePageLikeEsc(t *testing.T) { } } -// The menu is the page with nothing behind it. func TestQQuitsFromTheMainMenu(t *testing.T) { m := pickModel(t) m.phase = phaseMenu @@ -92,7 +89,6 @@ func TestQClosesWhatIsOpenBeforeLeavingTheResults(t *testing.T) { closed.resOpen, closed.phase) } - // An applied filter is the next thing q gives back before the page itself. filtered := m filtered.resFilter.SetValue("0100") filtered.applyResultFilter() diff --git a/internal/tui/refresh_test.go b/internal/tui/refresh_test.go index c0918db..22d87d8 100644 --- a/internal/tui/refresh_test.go +++ b/internal/tui/refresh_test.go @@ -93,16 +93,18 @@ func TestInitStartsARefreshOnlyWhenOneIsOwed(t *testing.T) { } m.spinner = ui.NewSpinner("") - // tea.Batch returns nil when it is given only the spinner tick, so the - // shape of the returned command distinguishes the two cases. + const always = 2 got := m.Init() if got == nil { t.Fatal("Init() = nil, want at least the spinner tick") } - msg := got() - _, batched := msg.(tea.BatchMsg) - if batched != tt.wantCmds { - t.Errorf("Init() produced a batch = %v, want %v", batched, tt.wantCmds) + batch, ok := got().(tea.BatchMsg) + if !ok { + t.Fatalf("Init() = %T, want a batch of at least %d", got(), always) + } + if refreshing := len(batch) > always; refreshing != tt.wantCmds { + t.Errorf("Init() returned %d commands, so refreshing = %v, want %v", + len(batch), refreshing, tt.wantCmds) } }) } @@ -231,8 +233,6 @@ func TestFreshCacheSkipsTheRefetchOnceAuthenticated(t *testing.T) { } m.spinner = ui.NewSpinner("") - // This client has no refresh loop, so watchToken contributes nothing and a - // non-nil command means a fetch was started. mm, cmd := m.Update(clientReadyMsg{client: liveClient()}) got := mm.(tuiModel) if cmd != nil { @@ -242,7 +242,6 @@ func TestFreshCacheSkipsTheRefetchOnceAuthenticated(t *testing.T) { t.Errorf("phase = %v, want phasePicking once the client is ready", got.phase) } - // A stale cache must still fetch, which needs a tenant with credentials. if err := st.Secrets.Set(config.ClientSecretKey("1"), "s"); err != nil { t.Fatalf("seed secret: %v", err) } @@ -286,9 +285,6 @@ func TestLaunchRefreshDoesNotHijackTheMenuWithABrowser(t *testing.T) { } func TestWatchTokenDoesNotBlockWithoutARefreshLoop(t *testing.T) { - // api.NewClient takes a fixed token and starts no refresh loop, so it has no - // error channel. Receiving from a nil channel blocks forever, which would - // strand a goroutine for the whole session. if got := watchToken(nil); got != nil { t.Error("watchToken(nil) returned a command, which would block on receive") } @@ -310,7 +306,6 @@ func TestWatchTokenDoesNotBlockWithoutARefreshLoop(t *testing.T) { t.Error("stopped = true for a plain error") } - // A closed channel means refreshing gave up for good. closed := make(chan error) close(closed) msg, ok = watchToken(closed)().(tokenErrMsg) diff --git a/internal/tui/reset_test.go b/internal/tui/reset_test.go index ecd3a12..e4f1b37 100644 --- a/internal/tui/reset_test.go +++ b/internal/tui/reset_test.go @@ -11,7 +11,6 @@ func resetModel(t *testing.T) tuiModel { t.Helper() m := pickModel(t) m.width, m.height = 100, 30 - // pickModel keeps its config in memory; the reset is about what is on disk. if err := m.store.SaveConfig(m.cfg); err != nil { t.Fatal(err) } @@ -32,7 +31,6 @@ func TestFactoryResetAsksTwice(t *testing.T) { t.Errorf("the armed section does not say what y will do:\n%s", got) } - // Still saved: arming is not the reset. if cfg, err := m.store.Config(); err != nil || len(cfg.Tenants) == 0 { t.Errorf("arming already deleted the config (%+v, %v)", cfg, err) } @@ -80,8 +78,6 @@ func TestArmingDoesNotSurviveLeavingTheSection(t *testing.T) { m := resetModel(t) armed, _ := m.onSettingsKey("enter") - // Walking away and back must ask again, or a y typed at the General - // section's spinner row would land on a disarmed-looking reset. away, _ := armed.(tuiModel).onSettingsKey("shift+tab") back, _ := away.(tuiModel).onSettingsKey("tab") if back.(tuiModel).resetArmed { @@ -102,8 +98,6 @@ func TestArmingDoesNotSurviveLeavingTheSection(t *testing.T) { } func TestResetSectionIsLast(t *testing.T) { - // It is the one thing on this page that cannot be undone, so it does not - // sit next to the rows someone is cycling through. if setSections[len(setSections)-1] != "Reset" { t.Errorf("sections end with %q, want Reset at the bottom", setSections[len(setSections)-1]) } diff --git a/internal/tui/results.go b/internal/tui/results.go index 2ec66d7..3beb88d 100644 --- a/internal/tui/results.go +++ b/internal/tui/results.go @@ -28,9 +28,6 @@ func newResultsTable() table.Model { return t } -// moveResultCursor has the same reason as the element list's: the widget's -// MoveUp scrolls its viewport away from the cursor, so moving up rebuilds the -// scroll state and walks down to the row. func (m *tuiModel) moveResultCursor(delta int) { rows := m.resTable.Rows() if len(rows) == 0 { @@ -51,18 +48,11 @@ func (m *tuiModel) moveResultCursor(delta int) { m.resTable = fresh } -// resOutputRoom is how many lines of an open result fit under the table. The -// keys and the view both ask, so neither can disagree about where the window -// ends. func (m tuiModel) resOutputRoom() int { - // header, filter line, the table, the divider and the element name. used := 4 + m.resTableHeight() return max(2, m.height-used-2-m.statsRows()) } -// moveResLine moves the highlight through an open result's output and keeps -// the window on it. The output scrolls like the list above it rather than -// being a block of text with no cursor in it. func (m tuiModel) moveResLine(delta int) tuiModel { idx, ok := m.currentResult() if !ok { @@ -102,11 +92,6 @@ func (m tuiModel) completedCount() int { } func (m *tuiModel) applyResultFilter() { - // The ION the cursor is on, not the row it is on. Results arrive in - // whatever order the elements finish and the table is in run order, so an - // earlier element landing after a later one moves every row below it -- - // which used to slide the highlight onto a different ION under the user's - // hands, and take an open output pane with it. was := m.cursorResultID() q := strings.ToLower(strings.TrimSpace(m.resFilter.Value())) @@ -135,7 +120,6 @@ func (m *tuiModel) applyResultFilter() { m.keepResultCursorOn(was) } -// cursorResultID is the element under the cursor, empty when there is none. func (m tuiModel) cursorResultID() string { if idx, ok := m.currentResult(); ok { return m.results[idx].ElementID @@ -143,9 +127,6 @@ func (m tuiModel) cursorResultID() string { return "" } -// keepResultCursorOn puts the cursor back on an element wherever its row has -// moved to, and clamps when that element is no longer listed at all -- which a -// filter can do. func (m *tuiModel) keepResultCursorOn(eid string) { if eid != "" { for row, i := range m.resMatches { @@ -207,11 +188,9 @@ func (m tuiModel) currentResult() (int, bool) { func (m tuiModel) onResultsKey(msg tea.KeyPressMsg, key string) (tea.Model, tea.Cmd) { switch key { - // Only the main menu's q quits: a key that leaves a page on one screen and - // kills the run on the next is one nobody can press with confidence. case "esc", "q": if key == "q" && m.resFilterOn { - break // a letter while the filter is being typed + break } switch { case m.resOpen: @@ -230,13 +209,10 @@ func (m tuiModel) onResultsKey(msg tea.KeyPressMsg, key string) (tea.Model, tea. case "up", "down", "k", "j", "home", "end": if key == "k" || key == "j" { - // Letters while the query is being typed are text. if m.resFilterOn { break } } - // An open result scrolls on the same keys the list moves on, with the - // highlight on the line it is scrolled to. if m.resOpen { switch key { case "up", "k": @@ -293,16 +269,15 @@ func (m tuiModel) onResultsKey(msg tea.KeyPressMsg, key string) (tea.Model, tea. case "/": if !m.resFilterOn { + m.resFilter.SetValue("") m.resFilterOn = true m.resOpen = false - m.rebuildResultRows() + m.applyResultFilter() return m, m.resFilter.Focus() } } if m.resFilterOn { - // Backspace on an already-empty query leaves, as on every other page - // that has a filter: the last press deletes the "/". if key == "backspace" && m.resFilter.Value() == "" { m.resFilterOn = false m.resFilter.Blur() @@ -320,10 +295,6 @@ func (m tuiModel) onResultsKey(msg tea.KeyPressMsg, key string) (tea.Model, tea. return m, cmd } -// onResultsOutputKey carries the save controls, because changing the format in -// settings means leaving the results to go and find it. Changing the format -// writes nothing by itself -- the run was already saved in the old one -- so -// each of these says that s is what writes a file. func (m tuiModel) onResultsOutputKey(key string) (tea.Model, tea.Cmd) { if key == "s" { m.status = m.saveBatchResults() @@ -387,8 +358,6 @@ func (m tuiModel) viewResults() string { if m.failed > 0 { header += ui.StyleErr.Render(fmt.Sprintf(" %d failed", m.failed)) } - // The save format belongs on the page it is toggled from; f and o are - // otherwise keys with nothing on screen to show they did anything. tag := m.batchFormat() if output.Tabular(m.batchFormat()) { tag += " · " + output.OrientationLabel(m.batchOrient()) @@ -428,9 +397,6 @@ func (m tuiModel) viewResults() string { start := min(m.resScroll, max(0, len(lines)-room)) for i := start; i < len(lines) && i-start < room; i++ { if i == m.resLine { - // Padded plain and restyled: the line already carries its own - // colours, and a highlight wrapped around them would end at - // the first reset inside it. row := fmt.Sprintf("%-*s", width, ui.Truncate(ui.Plain(lines[i]), width)) b.WriteString(ui.StyleRow.Render(row) + "\n") continue @@ -452,8 +418,6 @@ func (m tuiModel) viewResults() string { hint := "↑↓/jk move · / filter · enter open · f/o format · s save · " + copyKeyHint + " run as json · esc/q back" if m.resOpen { hint = "↑↓/jk scroll · pgup/pgdn page · home/end ends · " + copyKeyHint + " output · esc/q close" - // Where in the output the highlight is, since the pane holds a - // fraction of a long run's output. if idx, ok := m.currentResult(); ok { where := fmt.Sprintf("line %d of %d", m.resLine+1, len(m.resultDetail(idx))) if status != "" { @@ -462,8 +426,6 @@ func (m tuiModel) viewResults() string { status = where + status } } - // Truncated rather than wrapped: a hint that takes two rows on a narrow - // terminal moves the whole frame. return body + "\n" + ui.Footer(status, ui.Truncate(hint, max(10, m.width-1))) } diff --git a/internal/tui/results_test.go b/internal/tui/results_test.go index 284e95e..1a233e6 100644 --- a/internal/tui/results_test.go +++ b/internal/tui/results_test.go @@ -96,9 +96,6 @@ func TestResultsEnterExpandsInlineNotANewTab(t *testing.T) { } } -// An open result scrolls on the keys the list moves on, with the highlight on -// the line it is scrolled to. It used to close on the first ↑↓, which made -// reading a screen of output a matter of pgdn and hope. func TestAnOpenResultScrollsWithAHighlight(t *testing.T) { m := resultsModel(t) m.height = 20 @@ -123,7 +120,6 @@ func TestAnOpenResultScrollsWithAHighlight(t *testing.T) { if moved.resTable.Cursor() != 0 { t.Errorf("cursor = %d, want the list left where it was", moved.resTable.Cursor()) } - // j and k do the same, and the highlight is drawn on the line. mm, _ = moved.Update(tea.KeyPressMsg(tea.Key{Code: 'j', Text: "j"})) moved = mm.(tuiModel) if moved.resLine != 2 { @@ -137,12 +133,10 @@ func TestAnOpenResultScrollsWithAHighlight(t *testing.T) { if got := moved.viewResults(); !strings.Contains(got, ui.StyleRow.Render("")[:4]) { t.Error("no highlight anywhere in the open output") } - // The footer says where in the output the highlight is. if got := plain(moved.viewResults()); !strings.Contains(got, "line 2 of") { t.Errorf("the footer does not say where the highlight is:\n%s", got) } - // Closing hands the keys back to the list. mm, _ = moved.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyEsc})) closed := mm.(tuiModel) if closed.resOpen { @@ -325,8 +319,6 @@ func TestResultsHeaderNamesTheSaveFormat(t *testing.T) { t.Errorf("results header does not name the format and orientation:\n%s", got) } - // json has no rows to sort, so naming an orientation would claim something - // the save does not do. m.cfg.BatchFormat = output.FormatJSON got := plain(m.viewResults()) if !strings.Contains(got, "json") { @@ -337,12 +329,6 @@ func TestResultsHeaderNamesTheSaveFormat(t *testing.T) { } } -// Results arrive in whatever order the elements finish, and the table is in -// run order, so a later element finishing first and an earlier one landing -// after it moves every row below it. The cursor has to follow the ION it was -// on, not the row number it was on -- otherwise the highlight lands on a -// different element under the user's hands, and an open pane shows one ION's -// output under another's name. func TestTheResultsCursorFollowsItsElementAsResultsArrive(t *testing.T) { m := pickModel(t) m.width, m.height = 100, 30 @@ -359,14 +345,12 @@ func TestTheResultsCursorFollowsItsElementAsResultsArrive(t *testing.T) { m.applyResultFilter() } - // The last element finishes first, so it is the only row. done(3, "S0100-RTR-04") idx, ok := m.currentResult() if !ok || m.results[idx].Name != "S0100-RTR-04" { t.Fatalf("cursor is on %+v, want the only finished element", m.results[idx]) } - // Now an earlier one lands, which pushes that row down. done(1, "S0100-RTR-02") idx, ok = m.currentResult() if !ok { @@ -376,14 +360,12 @@ func TestTheResultsCursorFollowsItsElementAsResultsArrive(t *testing.T) { t.Errorf("cursor moved to %s, want it to stay on S0100-RTR-04", got) } - // And an open pane still shows the element the row names. m.resOpen = true out := plain(m.viewResults()) if !strings.Contains(out, "S0100-RTR-04 output") { t.Errorf("the open pane is not the element under the cursor:\n%s", out) } - // Moving down then having another arrive keeps the new choice too. m.moveResultCursor(-1) before, _ := m.currentResult() name := m.results[before].Name @@ -394,8 +376,6 @@ func TestTheResultsCursorFollowsItsElementAsResultsArrive(t *testing.T) { } } -// The results filter follows the same rule as the others: empty first, leave -// second. func TestBackspacingTheResultsQueryEmptiesItThenLeaves(t *testing.T) { m := resultsModel(t) next, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: '/', Text: "/"})) diff --git a/internal/tui/save.go b/internal/tui/save.go index 2628bbd..02d9fa6 100644 --- a/internal/tui/save.go +++ b/internal/tui/save.go @@ -6,9 +6,6 @@ import ( "tyrtop.com/iontk/internal/output" ) -// saveBatchResults writes a finished run to the configured folder. Writing to -// stdout is not offered here: the TUI owns the terminal, so only a folder -// produces a file, and the accordion stays the way results are read in-app. func (m tuiModel) saveBatchResults() string { dir := m.cfg.BatchOutDir() if dir == "" { @@ -22,8 +19,6 @@ func (m tuiModel) saveBatchResults() string { } defer out.Close() - // No width: this is a file, and a cell trimmed to a terminal that is not - // reading it would lose the only copy. sink := output.NewSink(out, format, output.WithOrientation(m.batchOrient())) for i, r := range m.results { if r.ElementID == "" { diff --git a/internal/tui/save_test.go b/internal/tui/save_test.go index 1cfb91e..806c00c 100644 --- a/internal/tui/save_test.go +++ b/internal/tui/save_test.go @@ -35,7 +35,6 @@ func TestBatchOutDirIsOnlyUsedWhenFileOutputIsOn(t *testing.T) { if got := cfg.BatchOutDir(); got != "/tmp/reports" { t.Errorf("BatchOutDir() = %q, want the folder once file output is on", got) } - // Turning it back off must not lose the path the user typed. cfg.BatchToFile = false if cfg.BatchDir != "/tmp/reports" { t.Errorf("BatchDir = %q, want the path kept", cfg.BatchDir) @@ -56,7 +55,7 @@ func TestSaveBatchResults(t *testing.T) { store: st, results: []sase.Result{ sampleResult("1", "s0001"), - {}, // an element that never ran + {}, sampleResult("3", "s0003"), }, } @@ -146,7 +145,6 @@ func TestBatchSettingsCycleAndToggle(t *testing.T) { height: 30, } - // Row 0 cycles the format and persists it. m.setCursor = 0 mm, _ := m.onBatchKey("enter") m = mm.(tuiModel) @@ -159,7 +157,6 @@ func TestBatchSettingsCycleAndToggle(t *testing.T) { t.Errorf("saved BatchFormat = %q, want it persisted", saved.BatchFormat) } - // Row 1 cycles the row orientation. m.setCursor = 1 mm, _ = m.onBatchKey("enter") m = mm.(tuiModel) @@ -172,7 +169,6 @@ func TestBatchSettingsCycleAndToggle(t *testing.T) { t.Errorf("saved BatchOrient = %q, want it persisted", saved.BatchOrient) } - // Row 2 toggles the destination. m.setCursor = 2 mm, _ = m.onBatchKey("enter") m = mm.(tuiModel) @@ -185,7 +181,6 @@ func TestBatchSettingsCycleAndToggle(t *testing.T) { t.Error("BatchToFile = true, want the toggle to go back to stdout") } - // Row 3 opens the folder form. m.setCursor = 3 mm, _ = m.onBatchKey("enter") m = mm.(tuiModel) diff --git a/internal/tui/scrollback_test.go b/internal/tui/scrollback_test.go index 0529ffb..0ed3900 100644 --- a/internal/tui/scrollback_test.go +++ b/internal/tui/scrollback_test.go @@ -9,8 +9,6 @@ import ( "tyrtop.com/iontk/internal/fixture" ) -// An element whose answer to one command is longer than any pane, which is the -// only state where scrolling back shows anything. func longResponseFixture(lines int) string { var b strings.Builder b.WriteString(` @@ -33,9 +31,6 @@ tenants: return b.String() } -// scrolledPane is a real session against the playback device, with the pane -// shorter than the emulator so there is output both above the window and in -// the scrollback. func scrolledPane(t *testing.T) (tuiModel, *fixture.Set) { t.Helper() m, set := fixtureTUIWith(t, longResponseFixture(60)) @@ -90,8 +85,6 @@ func TestSessionPaneScrollsBackThroughWhatTheDeviceSaid(t *testing.T) { t.Errorf("a page back and the last line is still showing:\n%s", back) } - // Far enough to reach the first line the device sent, which only the - // scrollback holds. for range 10 { m = press(m, keyPgUp) } @@ -100,7 +93,6 @@ func TestSessionPaneScrollsBackThroughWhatTheDeviceSaid(t *testing.T) { t.Errorf("scrolling back never reached the first line:\n%s", top) } - // And the offset stops where the output does, so coming back is one page. held := m.sesScroll m = press(m, keyPgUp) if m.sesScroll != held { @@ -121,7 +113,6 @@ func TestScrolledPaneSaysItIsNotLive(t *testing.T) { m = press(m, keyPgUp) got := plain(m.viewSession()) - // Without this a pane that stopped moving reads as a session that hung. if !strings.Contains(got, "scrolled back") || !strings.Contains(got, "alt+end") { t.Errorf("a scrolled pane does not say so, or does not say how to get back:\n%s", got) } @@ -154,25 +145,26 @@ func TestTypingReturnsThePaneToLive(t *testing.T) { t.Fatal("shift+pgup did not move the pane") } - // A key goes to the device, which is about to answer it at the bottom. m = press(m, tea.Key{Code: 'd', Text: "d"}) if m.sesScroll != 0 { t.Errorf("sesScroll = %d after typing, want the pane back at the live bottom", m.sesScroll) } } -// iontk never asks for the mouse, on any page: reporting would take the -// terminal's own select-and-copy away, and the keys scroll every pane. The -// wheel keeps working because terminals send arrow keys for a notch in the -// alt screen. -func TestNoPageAsksForTheMouse(t *testing.T) { +func TestOnlyTheScrollingPanesAskForTheMouse(t *testing.T) { m, _ := scrolledPane(t) - for _, phase := range []tuiPhase{phaseSession, phasePicking, phaseResults, phaseData, phaseMenu} { + for _, phase := range []tuiPhase{phasePicking, phaseResults, phaseMenu, phaseSettings, phaseMacros} { m.phase = phase if got := m.mouseMode(); got != tea.MouseModeNone { t.Errorf("phase %d mouseMode() = %v, want the terminal to keep the mouse", phase, got) } } + for _, phase := range []tuiPhase{phaseSession, phaseData} { + m.phase = phase + if got := m.mouseMode(); got == tea.MouseModeNone { + t.Errorf("phase %d asks for no mouse, so the wheel cannot reach it", phase) + } + } } func TestSwitchingTabsReturnsToLive(t *testing.T) { diff --git a/internal/tui/searchui.go b/internal/tui/searchui.go index c7b8054..387a576 100644 --- a/internal/tui/searchui.go +++ b/internal/tui/searchui.go @@ -15,37 +15,24 @@ import ( "tyrtop.com/iontk/internal/ui" ) -// TenantLoad is one step of a --search element load: a tenant starting, then -// the same tenant finishing. A tenant is the unit because that is what takes -// the time -- a cold tenant is thousands of elements and a sign-in. type TenantLoad struct { Tenant string - // Count and Note describe a finished load: how many elements, and where - // they came from. - Count int - Note string - Err error - // Warn marks an answer that arrived but is out of date, which is neither - // a success to tick off nor a failure to cross out. - Warn bool - Done bool + Count int + Note string + Err error + Warn bool + Done bool } -// DrainTenants draws element-loading progress on stderr and returns once the -// channel closes. It never fails the search: the results are collected by the -// producer, and this only says how it is going. func DrainTenants(ctx context.Context, events <-chan TenantLoad, total int) { opts := []tea.ProgramOption{ tea.WithContext(ctx), tea.WithOutput(os.Stderr), tea.WithoutSignalHandler(), - // stdin belongs to the confirmation prompt that may follow, and to - // anything piping IDs in. tea.WithInput(nil), } if _, err := tea.NewProgram(newLoadModel(events, total), opts...).Run(); err != nil && !errors.Is(err, tea.ErrProgramKilled) { - // Drain what is left so the producer is never blocked on a display. for range events { } } @@ -102,7 +89,6 @@ func waitForLoad(ch <-chan TenantLoad) tea.Cmd { func (m loadModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { - case tea.WindowSizeMsg: m.width, m.height = msg.Width, msg.Height return m, nil diff --git a/internal/tui/secrets_warning_test.go b/internal/tui/secrets_warning_test.go index c5672fe..226ed5d 100644 --- a/internal/tui/secrets_warning_test.go +++ b/internal/tui/secrets_warning_test.go @@ -9,7 +9,6 @@ import ( "tyrtop.com/iontk/internal/ui" ) -// fakeKeyring stands in for a working OS keyring, which a test machine may not have. type fakeKeyring struct{ m map[string]string } func (f fakeKeyring) Backend() string { return "keyring" } @@ -73,7 +72,7 @@ func TestFormsWarnBeforeASecretIsTyped(t *testing.T) { t.Run("a form with no secret stays quiet", func(t *testing.T) { m := warnModel(t, plain) - mm, _ := m.openTenantForm(config.AuthBrowser) // name and tsg only + mm, _ := m.openTenantForm(config.AuthBrowser) m = mm.(tuiModel) if got := m.plaintextWarning(); got != "" { t.Errorf("plaintextWarning() = %q, want nothing on a form with no secret", got) @@ -154,7 +153,6 @@ func TestGeneralSectionHasARefreshRow(t *testing.T) { t.Errorf("the General section has no elements row:\n%s", out) } - // The last row is the refresh; it moved when the jump setting was added. m.setCursor = m.sectionRows() - 1 mm, cmd := m.onGeneralKey("enter") got := mm.(tuiModel) diff --git a/internal/tui/session.go b/internal/tui/session.go index 316602b..bc58972 100644 --- a/internal/tui/session.go +++ b/internal/tui/session.go @@ -24,9 +24,6 @@ func (d toolkitDialer) DialToolkit(ctx context.Context, eid string, cols, rows i return d.c.DialToolkit(ctx, eid, cols, rows) } -// dialerFor is how a session reaches an element. Fixture mode substitutes a -// playback device here and nowhere else, so the pane, autologin and the -// emulator are all still the production ones. func (m tuiModel) dialerFor(c *api.Client) ionterm.Dialer { if app.Fixture != nil { return app.Fixture.Dialer() @@ -86,8 +83,6 @@ func (m tuiModel) openSession(eid, name, tsg string) (tea.Model, tea.Cmd) { func (m tuiModel) openOneSession(t sessionTarget) (tea.Model, tea.Cmd) { c := m.tenantClient(t.tsg) if c == nil { - // ensureClients already tried; reaching here means that tenant could - // not authenticate, and tenantErr has said why. if m.status == "" { m.status = "could not reach " + m.tenantName(t.tsg) } @@ -110,15 +105,10 @@ func (m tuiModel) openSessions(targets []sessionTarget) (tea.Model, tea.Cmd) { if len(targets) == 0 { return m, nil } - // Remembered so a credential failure can replay exactly this request after - // signing in. m.lastTargets = targets if m.needsSignIn(tsgsOf(targets)) { return m.signInThenSessions(targets) } - // Any tenant without a client is authenticated first and the request - // replayed. This has to come before the single-target path below, which - // would otherwise report a disconnected tenant instead of connecting it. if conn := m.ensureClients(tsgsOf(targets)); len(conn) > 0 { m.resumeSessions = targets m.connectWait = len(conn) @@ -158,16 +148,11 @@ func (m tuiModel) openSessions(targets []sessionTarget) (tea.Model, tea.Cmd) { m.status = "skipped " + strings.Join(unreachable, ", ") + ": tenant unreachable" } - // Opening several at once stays on the list: the tabs fill in behind, and - // jumping to whichever finished last hid the rest of them. m.connecting = "" m.rebuildRows() return m, tea.Batch(cmds...) } -// The tab ring is the element list, then the shells, then the data panes. -// Sessions keep their numbers as data tabs come and go, which they would not -// if the two kinds interleaved by the order they were opened. func (m tuiModel) tabCount() int { return 1 + m.sessions.Count() + len(m.data) } func (m tuiModel) currentTab() int { @@ -200,13 +185,11 @@ func (m tuiModel) selectTab(i int) tuiModel { func (m tuiModel) onTabKey(key string) (tuiModel, bool) { switch key { - // hjkl's own pair for the tab ring, since the lists move on hjkl too. case "alt+right", "alt+l": return m.selectTab(m.currentTab() + 1), true case "alt+left", "alt+h": return m.selectTab(m.currentTab() - 1), true case "alt+w", "alt+k": - // A data pane has no connection to detach, so both keys close it. if m.phase == phaseData { return m.closeDataTab(), true } @@ -252,8 +235,6 @@ func (m tuiModel) onSessionKey(msg tea.KeyPressMsg, key string) (tea.Model, tea. return m.openFavourites() } - // Before the session checks below: a favourite is worth reaching on a tab - // whose connection has died, if only to copy it into a fresh one. if n, ok := altDigit(key); ok { return m.useFavourite(n) } @@ -263,24 +244,14 @@ func (m tuiModel) onSessionKey(msg tea.KeyPressMsg, key string) (tea.Model, tea. return m, nil } - // Before the checks below, like the favourites: what a tab last said is - // the reason anyone is looking at a dead one. if key == "alt+y" { return m.copySession() } - // A redial is a new CLI session: enable and config context are gone, so - // it is never automatic for a shell someone is sitting in front of. It - // has to cover a tab that failed as well as one that went quiet -- a torn - // connection is the common way to lose a session, and alt+r used to do - // nothing at all on one. if key == "alt+r" { if s.Err() != nil || s.Hung() || s.Ended() { return m.redialActive() } - // Swallowed rather than typed at the device: the footer offers this - // key, so sending it as an escape sequence is the one thing it must - // not do. m.status = "this session is still answering — alt+k closes it" return m, nil } @@ -297,12 +268,8 @@ func (m tuiModel) onSessionKey(msg tea.KeyPressMsg, key string) (tea.Model, tea. return next, nil } - // Anything typed belongs at the live bottom: the device is about to answer - // it, and a pane held above that would not show what it said. m.sesScroll = 0 - // Counted before the key is forwarded: sending it clears the line, and - // the device echoes it away. var save tea.Cmd if key == "enter" { save = m.recordSessionLine() @@ -311,9 +278,6 @@ func (m tuiModel) onSessionKey(msg tea.KeyPressMsg, key string) (tea.Model, tea. return m, save } -// onScrollKey holds the pane above its live bottom. The device has no -// scrollback of its own, so these are iontk's keys and have to be ones a CLI -// would not want: shift and alt chords, never a bare key. func (m tuiModel) onScrollKey(s *ionterm.Session, key string) (tuiModel, bool) { _, rows := m.paneSize() switch key { @@ -334,10 +298,6 @@ func (m tuiModel) onScrollKey(s *ionterm.Session, key string) (tuiModel, bool) { return m, true } -// reopenLastTab brings back the most recent tab: the detached session itself -// when it is still alive, otherwise a fresh session to the same element. A -// shell that was left with "exit", or that died while detached, has nothing to -// reattach to, so dialling again is the only thing that can honour alt+t. func (m tuiModel) reopenLastTab() (tea.Model, tea.Cmd) { if _, ok := m.sessions.Reattach(); ok { m.status = "" @@ -353,7 +313,6 @@ func (m tuiModel) reopenLastTab() (tea.Model, tea.Cmd) { return m.openSession(eid, name, m.tsgForElement(eid)) } -// redialActive replaces a hung tab with a fresh session on the same element. func (m tuiModel) redialActive() (tea.Model, tea.Cmd) { s := m.sessions.Active() if s == nil { @@ -362,16 +321,9 @@ func (m tuiModel) redialActive() (tea.Model, tea.Cmd) { eid, name := s.ElementID, s.Name tsg := m.tsgForElement(eid) if tsg == "" { - // An element the list no longer holds -- a reattached tab after a - // cache drop -- would otherwise redial with no tenant and report that - // nothing could be reached. tsg = m.tenant.TSGID } - // The dead tab stays until the new session is actually up. It is the only - // record of what the session said and how it failed, and closing it first - // left the user looking at another ION's pane with nothing to say a dial - // was even running. onSessionOpened swaps them over. m.status = "" next, cmd := m.openSession(eid, name, tsg) nm, ok := next.(tuiModel) @@ -383,8 +335,6 @@ func (m tuiModel) redialActive() (tea.Model, tea.Cmd) { return nm, cmd } -// onSessionOpened finishes a redial by taking the dead tab away, now that -// there is a live one on the same element to put in its place. func (m tuiModel) onSessionOpened(eid string) tuiModel { m.connecting = "" m.dialDone(eid) @@ -400,13 +350,11 @@ func (m tuiModel) onSessionOpened(eid string) tuiModel { func (m tuiModel) tabBar() string { cur := m.currentTab() - render := func(i int, label string) string { - return ui.StyleTab(i, i == cur).Render(label) + render := func(kind ui.TabKind, ring, nth int, label string) string { + return ui.StyleTab(kind, ring, nth, ring == cur).Render(label) } - // No numbers any more: alt+N is a favourite command, and a number on a - // tab is an invitation to press the chord that no longer selects it. - parts := []string{render(0, fmt.Sprintf(" elements (%s) ", m.mode))} + parts := []string{render(ui.TabList, 0, 0, fmt.Sprintf(" elements (%s) ", m.mode))} for i, s := range m.sessions.All() { mark := "" switch { @@ -415,7 +363,7 @@ func (m tuiModel) tabBar() string { case s.Hung(): mark = "~ " } - parts = append(parts, render(i+1, fmt.Sprintf(" %s%s ", mark, s.Name))) + parts = append(parts, render(ui.TabShell, i+1, i, fmt.Sprintf(" %s%s ", mark, s.Name))) } for i, t := range m.data { n := 1 + m.sessions.Count() + i @@ -423,7 +371,7 @@ func (m tuiModel) tabBar() string { if t.failed() { mark = ui.MarkCross.String() + " " } - parts = append(parts, render(n, fmt.Sprintf(" %s%s · data ", mark, t.name))) + parts = append(parts, render(ui.TabData, n, i, fmt.Sprintf(" %s%s · data ", mark, t.name))) } bar := strings.Join(parts, " ") if n := m.sessions.DetachedCount(); n > 0 { @@ -455,8 +403,6 @@ func (m tuiModel) renderSession() (string, *tea.Cursor) { view := s.ScrolledView(cols, rows, m.sesScroll) b.WriteString(view.Content + "\n") - // Above the palette as well as above the footer, so the frame keeps its - // height whichever is showing. if m.stats { b.WriteString(m.statsPane() + "\n") } @@ -471,20 +417,13 @@ func (m tuiModel) renderSession() (string, *tea.Cursor) { status := m.status switch { - // While a redial is running, the footer says that rather than offering - // the key that started it again. case m.redialing == s.ElementID: case s.Err() != nil: - // The key is on the error too: a torn connection is the usual way to - // lose a session, and the error alone said nothing about what to do. status = s.Err().Error() + " — alt+r opens a new session on this ION" case s.Hung(): status = "connection stopped answering — alt+r opens a new session on this ION" } - // The marker goes in front of whatever else the footer was saying, and - // does not replace it: a hung session is still worth reading while someone - // is scrolled back through what it last said. if view.Offset > 0 { marker := fmt.Sprintf("scrolled back %d of %d lines — alt+end for live", view.Offset, view.Total) if status != "" { @@ -493,14 +432,9 @@ func (m tuiModel) renderSession() (string, *tea.Cursor) { status = marker + status } - // Truncated plain, before the footer styles it: a status or hint that takes - // a second row moves the whole frame. width := max(10, m.width-1) b.WriteString(ui.Footer(ui.Truncate(status, width), ui.Truncate(hint, width))) - // No cursor unless the pane is live. Pointing it at frozen output, at a - // session that has stopped answering, or at a pane with the slot picker - // over it says something is being typed there. if m.favPick { return m.overlayFavourites(b.String()), nil } @@ -515,8 +449,6 @@ func (m tuiModel) viewSession() string { return content } -// signInThenSessions reopens the browser and, once signed in, opens the -// sessions that were interrupted. With nothing to replay it just reports. func (m tuiModel) signInThenSessions(targets []sessionTarget) (tea.Model, tea.Cmd) { if len(targets) == 0 { m.signInResume = true @@ -526,11 +458,6 @@ func (m tuiModel) signInThenSessions(targets []sessionTarget) (tea.Model, tea.Cm return m.startSignIn() } -// needsSignIn reports whether the credential is already known to be dead, so -// an action can re-auth before attempting a dial that would only fail. -// -// It asks the tenants the action touches; the browsed tenant need not be one of -// them. func (m tuiModel) needsSignIn(tsgs []string) bool { if m.tokenDead == "" { return false @@ -545,7 +472,6 @@ func (m tuiModel) needsSignIn(tsgs []string) bool { } continue } - // Unrecorded but in the list: only a browser sign-in found it. return true } return false diff --git a/internal/tui/session_test.go b/internal/tui/session_test.go index a9f9bdd..444f512 100644 --- a/internal/tui/session_test.go +++ b/internal/tui/session_test.go @@ -143,7 +143,6 @@ func TestSessionEscapeHatches(t *testing.T) { func TestTabBarAlwaysHasElementsTab(t *testing.T) { m := pickModel(t) - // No number on it: alt+N is a favourite command now, not a tab. got := plain(m.tabBar()) if !strings.Contains(got, "elements") || strings.Contains(got, "1 elements") { t.Errorf("tabBar = %q, want an unnumbered elements tab", got) @@ -296,9 +295,6 @@ func TestSpinnerOnlyTicksWhereVisible(t *testing.T) { } } - // The element list is the exception: it carries a "refreshing" marker, so - // the spinner has to tick there while a refresh is in flight and stop once - // it is not. m.phase = phasePicking m.cacheState = cacheUpdating if !m.wantsSpinner() { @@ -361,8 +357,6 @@ func TestShellModeEnterOpensOneTabPerSelection(t *testing.T) { if cmd == nil { t.Error("want dial commands") } - // Opening several stays on the list: jumping to whichever tab finished - // last hid the others, and there was nothing to watch them by. if got.phase != phasePicking { t.Errorf("phase = %v, want to stay on the element list", got.phase) } @@ -374,8 +368,6 @@ func TestShellModeEnterOpensOneTabPerSelection(t *testing.T) { } } -// alt+m walks every mode in pickModes and comes back round, so a third mode -// is not a mode you can only leave by restarting. func TestModeCycle(t *testing.T) { m := pickModel(t) if m.mode != modeShell { @@ -649,8 +641,6 @@ func (stubConn) Write(ctx context.Context, typ websocket.MessageType, p []byte) func (stubConn) Close(code websocket.StatusCode, reason string) error { return nil } func TestAltWOnElementsReturnsToTheMenu(t *testing.T) { - // alt+w closes whatever you are in. On the element list that is the list, - // so it falls back to the menu; only the menu's alt+w leaves the app. m := pickModel(t) mm, cmd := m.Update(tea.KeyPressMsg(tea.Key{Code: 'w', Mod: tea.ModAlt})) @@ -674,19 +664,14 @@ type recConn struct { mu sync.Mutex written []byte done chan struct{} - // data is what the device says. A socket with nothing on it is a real - // state -- the SASE side accepts one for an element that is not there -- - // so a test has to be able to leave it mute. - data chan []byte - once sync.Once + data chan []byte + once sync.Once } func newRecConn() *recConn { return &recConn{done: make(chan struct{}), data: make(chan []byte, 8)} } -// feed is the device speaking, which is what tells a session there is an ION -// on the other end. func (c *recConn) feed(s string) { c.data <- []byte(s) } func (c *recConn) Read(ctx context.Context) (websocket.MessageType, []byte, error) { @@ -1306,8 +1291,6 @@ func TestReattachingToAFailedDetachedSessionOpensAFreshOne(t *testing.T) { } } -// hungConn answers reads with a prompt then goes quiet and stops answering -// pings, which is a black-holed toolkit socket. type hungConn struct { done chan struct{} once sync.Once @@ -1375,8 +1358,6 @@ func TestAHungSessionSaysSoAndOffersAReconnect(t *testing.T) { func TestKeystrokesAreNotSentToAHungSession(t *testing.T) { m := hungModel(t) - // Typing into a socket that is not answering looks like the shell has - // swallowed the command. mm, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: 'x', Text: "x"})) got := mm.(tuiModel) if got.sessions.Count() != 1 { @@ -1397,9 +1378,6 @@ func TestAltROnAHungSessionOpensAFreshOne(t *testing.T) { if cmd == nil { t.Fatal("alt+r produced no command, want it to dial the element again") } - // The hung tab stays while the dial runs, and says so: it is the only - // record of what the session said, and a tab that vanishes on the - // keypress makes a 30-second dial look like nothing happened. if s := got.sessions.Find("1"); s != hung { t.Error("the hung tab went before its replacement was up") } @@ -1409,8 +1387,6 @@ func TestAltROnAHungSessionOpensAFreshOne(t *testing.T) { if !strings.Contains(got.status, "redialling") { t.Errorf("status = %q, want it to say a dial is running", got.status) } - // The footer says the redial rather than re-offering the key that started - // it, and the bar does not either. pane := plain(got.viewSession()) if !strings.Contains(pane, "redialling e1") || strings.Contains(pane, "alt+r retry") { t.Errorf("the pane still offers the key it is already running:\n%s", pane) @@ -1422,8 +1398,6 @@ func TestAltROnAHungSessionOpensAFreshOne(t *testing.T) { t.Errorf("phase = %v, want phaseSession while the new session connects", got.phase) } - // The swap happens when the new session is actually up, which is the - // message the runtime delivers. next, _ := got.Update(sessionOpenedMsg("1")) after := next.(tuiModel) if after.redialing != "" { @@ -1448,16 +1422,11 @@ func TestAltRIsIgnoredWhileASessionIsHealthy(t *testing.T) { if got.sessions.Count() != 1 { t.Fatalf("Count() = %d, want the session untouched", got.sessions.Count()) } - // The key is swallowed rather than sent on: the footer offers it, so the - // one thing it must not do is reach the device as an escape sequence. if got.sessions.Find("1") == nil { t.Error("the healthy session was replaced by alt+r") } } -// alt+r used to be gated on a session having gone quiet, so a tab whose -// connection tore -- the usual way to lose one -- ate the key and did -// nothing at all. func TestAltRRedialsAFailedSession(t *testing.T) { m, set := fixtureTUI(t) m.phase = phaseSession @@ -1472,8 +1441,6 @@ func TestAltRRedialsAFailedSession(t *testing.T) { s := m.sessions.Find(el.ID) waitForTUI(t, func() bool { return strings.Contains(s.Render(), el.Name+"#") }) - // Kill it the way the device does, by being told to exit: the tab is dead - // and the only way back to that ION is a new session. s.SendText("exit\r") waitForTUI(t, func() bool { return s.Ended() || s.Err() != nil }) m.sessions.Select(m.sessions.IndexOf(el.ID)) @@ -1483,8 +1450,6 @@ func TestAltRRedialsAFailedSession(t *testing.T) { if cmd == nil { t.Fatal("alt+r on a dead session queued nothing") } - // While it dials, the dead tab is still there to read and the pane says - // what is going on. if m.sessions.Find(el.ID) != s { t.Error("the dead tab went before its replacement was up") } @@ -1503,8 +1468,6 @@ func TestAltRRedialsAFailedSession(t *testing.T) { waitForTUI(t, func() bool { return strings.Contains(again.Render(), el.Name+"#") }) } -// openSessionCmd reports through the runtime; a test has to deliver that -// message itself, and it is what finishes a redial. func deliverSessionOpened(t *testing.T, m tuiModel, cmd tea.Cmd) tuiModel { t.Helper() msgs := make(chan tea.Msg, 8) @@ -1537,10 +1500,8 @@ func deliverSessionOpened(t *testing.T, m tuiModel, cmd tea.Cmd) tuiModel { } } -// A redial that cannot start must not also take away the tab that was there -// to read: it is the only record of what the session said. func TestAFailedRedialKeepsTheTab(t *testing.T) { - m := pickModel(t) // no client in the pool, so nothing can be dialled + m := pickModel(t) m.phase = phaseSession t.Cleanup(m.sessions.CloseAll) @@ -1565,8 +1526,6 @@ func TestAFailedRedialKeepsTheTab(t *testing.T) { } } -// alt+r on a session that is still answering says so rather than sending the -// chord to the device, which the footer's own hint would otherwise do. func TestAltROnALiveSessionSaysSo(t *testing.T) { m := pickModel(t) m.phase = phaseSession diff --git a/internal/tui/settings.go b/internal/tui/settings.go index c93c17a..a767ae9 100644 --- a/internal/tui/settings.go +++ b/internal/tui/settings.go @@ -23,12 +23,6 @@ const ( focusContent ) -// The General rows, named because the key handler, the content and the tests -// all have to agree on the order. The refresh is last: it is the one entry -// here that does something rather than setting something. -// The order here is the order sectionContent prints them in, and -// TestGeneralRowsMatchTheirLabels is what keeps the two from drifting: a row -// that moves without its constant sends the cursor to someone else's setting. const ( guardRow = iota modeRow @@ -38,11 +32,9 @@ const ( generalRows ) -// generalRowLabels is each row's gutter label, for that test and for nothing -// else. var generalRowLabels = map[int]string{ - guardRow: "commands", modeRow: "mode", jumpRow: "jump", - zoneRow: "times", refreshRow: "elements", + guardRow: "Commands", modeRow: "Mode", jumpRow: "Jump", + zoneRow: "Times", refreshRow: "Elements", } var setSections = []string{"General", "Theme", "Batch", "Tenants", "Users", "Session", "Debug", "Reset"} @@ -64,11 +56,9 @@ func (m tuiModel) sectionRows() int { return generalRows case secTheme: if m.themeEdit { - // The editor has its own cursor; the section's would otherwise - // move the theme list underneath it. return 0 } - return len(ui.Themes()) + return len(ui.Themes()) + 1 case secBatch: return 4 case secTenants: @@ -80,17 +70,23 @@ func (m tuiModel) sectionRows() int { case secDebug: return 0 case secReset: - return 1 + return 2 } return 0 } func (m tuiModel) onSettingsKey(key string) (tea.Model, tea.Cmd) { - // Arming the reset lasts exactly one keypress. Leaving the row with it - // still set would mean a later y resets without having been asked. armed := m.resetArmed m.resetArmed = false + if key != "x" && key != "ctrl+d" { + m.themeDel = "" + } + + if m.setSection == secTheme && (m.themeEdit || m.pick.on || m.tabEdit) { + return m.onThemeKey(key) + } + if key == "enter" && m.setFocus == focusNav { if m.sectionRows() > 0 { m.setFocus = focusContent @@ -162,8 +158,6 @@ func (m tuiModel) onSettingsKey(key string) (tea.Model, tea.Cmd) { return m, nil } -// onResetKey arms on enter and fires on y. Two keys, because nothing else in -// iontk destroys credentials that cannot be fetched again. func (m tuiModel) onResetKey(key string, armed bool) (tea.Model, tea.Cmd) { if !armed { if key == "enter" { @@ -173,19 +167,32 @@ func (m tuiModel) onResetKey(key string, armed bool) (tea.Model, tea.Cmd) { return m, nil } if key != "y" { - m.status = "reset cancelled" + m.status = "cancelled" return m, nil } + if m.setCursor == themeRestoreRow { + return m.restoreShippedThemes() + } return m.factoryReset() } -// factoryReset throws away the saved state and leaves the model looking like a -// first run, which is the only honest way to show that it worked. +const ( + factoryResetRow = iota + themeRestoreRow +) + func (m tuiModel) factoryReset() (tea.Model, tea.Cmd) { - // Sessions hold a token that has just been deleted, and a device login that - // cannot be typed again from anywhere in this process. m.sessions.CloseAll() + if err := app.RemoveThemes(m.store); err != nil { + m.status = err.Error() + return m, nil + } + ui.HideThemes(nil) + if err := ui.LoadThemes(app.ThemesPath(m.store)); err != nil { + m.status = err.Error() + } + if err := m.store.Reset(); err != nil { m.status = err.Error() return m, nil @@ -214,15 +221,11 @@ func (m tuiModel) onGeneralKey(key string) (tea.Model, tea.Cmd) { if key != "enter" && key != " " && key != "space" { return m, nil } - // The refresh stays the last row of the section: it is the one entry here - // that does something rather than setting something. if m.setCursor == refreshRow { return m.refreshNow() } switch m.setCursor { case zoneRow: - // Typed rather than cycled, since there are 600 IANA names. Space - // still reaches the two anyone wants without a form. if key == "enter" { return m.openZoneForm() } @@ -258,7 +261,6 @@ func (m tuiModel) onTenantsKey(key string) (tea.Model, tea.Cmd) { if m.tenant.Name == t.Name { m.tenant.Added = added } - // Dropping a tenant from the set has to drop its elements too. if !added { kept := make([]elementRow, 0, len(m.all)) for _, r := range m.all { @@ -396,8 +398,6 @@ func (m tuiModel) sectionContent() []string { if m.cfg.BatchDir != "" { folder = ui.StyleDim.Render(ui.Truncate(m.cfg.BatchDir, 44)) } - // The row stays whether the format sorts rows or not: a section that - // gains and loses a row as the format cycles moves everything under it. order := ui.StyleElement.Render(output.OrientationLabel(m.batchOrient())) + ui.StyleDim.Render(" "+output.OrientationHelp(m.batchOrient())) if !output.Tabular(m.batchFormat()) { @@ -405,11 +405,11 @@ func (m tuiModel) sectionContent() []string { " only table and csv have rows to sort") } out = append(out, - ui.Label("format")+ui.StyleElement.Render(m.batchFormat())+ + ui.Label("Format")+ui.StyleElement.Render(m.batchFormat())+ ui.StyleDim.Render(" "+output.FormatHelp(m.batchFormat())), - ui.Label("rows")+order, - ui.Label("output")+dest, - ui.Label("folder")+folder, + ui.Label("Rows")+order, + ui.Label("Output")+dest, + ui.Label("Folder")+folder, ) if m.cfg.BatchToFile && m.cfg.BatchDir == "" { out = append(out, "", ui.StyleWarn.Render("set a folder, or output falls back to stdout")) @@ -421,16 +421,16 @@ func (m tuiModel) sectionContent() []string { guard = ui.StyleWarn.Render("writes allowed") } out = append(out, - ui.Label("commands")+guard, - ui.Label("mode")+ui.StyleElement.Render(m.defaultMode().String())+ + ui.Label("Commands")+guard, + ui.Label("Mode")+ui.StyleElement.Render(m.defaultMode().String())+ ui.StyleDim.Render(" what the element list opens in"), - ui.Label("jump")+ui.StyleElement.Render(fmt.Sprintf("%d rows", m.pageStep()))+ + ui.Label("Jump")+ui.StyleElement.Render(fmt.Sprintf("%d rows", m.pageStep()))+ ui.StyleDim.Render(" how far alt+↑/↓ moves in the element list"), - ui.Label("times")+m.zoneLine(), - ui.Label("elements")+m.cacheLine(), - ui.Label("views")+m.viewsLine(), + ui.Label("Times")+m.zoneLine(), + ui.Label("Elements")+m.cacheLine(), + ui.Label("Views")+m.viewsLine(), "", - ui.Label("secrets")+m.secretsLine(), + ui.Label("Secrets")+m.secretsLine(), ) if config.Plaintext(m.store.Secrets) { if fp, ok := m.store.Secrets.(interface{ Path() string }); ok { @@ -458,8 +458,6 @@ func (m tuiModel) sectionContent() []string { if t.Added { box = ui.StyleOK.Render("[x] ") } - // A tenant that keeps failing is worth flagging here, since this is - // where it can be taken out of the set. if why, bad := m.tenantIssues[t.TSGID]; bad { tag += ui.StyleWarn.Render(" " + why) } @@ -494,46 +492,44 @@ func (m tuiModel) sectionContent() []string { cols, rows := m.sessionGeometry() warn := ui.StyleWarn.Render(" do not change unless you know what you are doing!") out = append(out, - ui.Label("buffer cols")+fmt.Sprintf("%-6d", cols)+warn, - ui.Label("buffer rows")+fmt.Sprintf("%-6d", rows)+warn, + ui.Label("Buffer Cols")+fmt.Sprintf("%-6d", cols)+warn, + ui.Label("Buffer Rows")+fmt.Sprintf("%-6d", rows)+warn, ) case secReset: - out = append(out, ui.StyleWarn.Render("factory reset")) + out = append(out, ui.StyleWarn.Render("Factory Reset")) + out = append(out, ui.StyleWarn.Render("Restore Themes")+ + ui.StyleDim.Render(" the schemes iontk ships; your own are untouched")) if m.resetArmed { - out = append(out, - "", - ui.StyleErr.Render("press y to delete every tenant, ION password and token"), - ui.StyleDim.Render("the browser profile goes too, so this signs you out"), + what := "delete every tenant, ION password, token and scheme" + if m.setCursor == themeRestoreRow { + what = "put the shipped schemes back" + } + out = append(out, "", + ui.StyleErr.Render("press y to "+what), ui.StyleDim.Render("any other key cancels"), ) - break } - out = append(out, - "", - ui.StyleDim.Render("deletes saved tenants, ION passwords, browser tokens,"), - ui.StyleDim.Render("cached elements and the browser profile"), - ui.StyleDim.Render("nothing here can be undone, and nothing is backed up"), - ) case secDebug: out = append(out, - ui.Label("config")+ui.StyleDim.Render(m.store.Root), - ui.Label("secrets")+ui.StyleDim.Render(m.store.Secrets.Backend()), - ui.Label("version")+ui.StyleDim.Render(Version), - ui.Label("stats")+ui.StyleDim.Render("alt+i overlays live counters"), - ui.Label("colours")+ui.StyleDim.Render(ui.CurrentTheme().Name+" "+profileLabel()), - ui.Label("times")+ui.StyleDim.Render(clock.Name()), + ui.Label("Config")+ui.StyleDim.Render(m.store.Root), + ui.Label("Secrets")+ui.StyleDim.Render(m.store.Secrets.Backend()), + ui.Label("Version")+ui.StyleDim.Render(Version), + ui.Label("Stats")+ui.StyleDim.Render("alt+i overlays live counters"), + ui.Label("Colours")+ui.StyleDim.Render(ui.CurrentTheme().Name+" "+profileLabel()), + ui.Label("Terminal")+ui.StyleDim.Render(termColourLine()), + ui.Label("Times")+ui.StyleDim.Render(clock.Name()), "", - ui.Label("tenant")+ui.StyleDim.Render(m.tenant.Name+" tsg "+m.tenant.TSGID), - ui.Label("ion user")+ui.StyleDim.Render(m.ionUser), - ui.Label("mode")+ui.StyleDim.Render(m.mode.String()), + ui.Label("Tenant")+ui.StyleDim.Render(m.tenant.Name+" tsg "+m.tenant.TSGID), + ui.Label("ION User")+ui.StyleDim.Render(m.ionUser), + ui.Label("Mode")+ui.StyleDim.Render(m.mode.String()), "", - ui.Label("elements")+ui.StyleDim.Render(fmt.Sprintf("%d cached, %d shown", m.cacheCount, len(m.matches))), - ui.Label("sessions")+ui.StyleDim.Render(fmt.Sprintf("%d open, %d detached", m.sessions.Count(), m.sessions.DetachedCount())), - ui.Label("ha")+ui.StyleDim.Render(m.haPath()), - ui.Label("catalog")+ui.StyleDim.Render(fmt.Sprintf("%d commands, %d offered", len(ioncmd.Embedded()), len(m.catalog()))), - ui.Label("terminal")+ui.StyleDim.Render(fmt.Sprintf("%d x %d", m.width, m.height)), + ui.Label("Elements")+ui.StyleDim.Render(fmt.Sprintf("%d cached, %d shown", m.cacheCount, len(m.matches))), + ui.Label("Sessions")+ui.StyleDim.Render(fmt.Sprintf("%d open, %d detached", m.sessions.Count(), m.sessions.DetachedCount())), + ui.Label("HA")+ui.StyleDim.Render(m.haPath()), + ui.Label("Catalog")+ui.StyleDim.Render(fmt.Sprintf("%d commands, %d offered", len(ioncmd.Embedded()), len(m.catalog()))), + ui.Label("Terminal")+ui.StyleDim.Render(fmt.Sprintf("%d x %d", m.width, m.height)), ) if app.Debug { out = append(out, "", ui.StyleWarn.Render("--debug: throwaway config, no saved tenants, secrets held only in memory")) @@ -547,6 +543,9 @@ func (m tuiModel) sectionHint() string { return "read only · ↑↓ section · esc back" } if m.setSection == secReset && m.resetArmed { + if m.setCursor == themeRestoreRow { + return "y restore the shipped themes · any other key cancels" + } return "y reset · any other key cancels" } if m.setFocus == focusNav { @@ -556,10 +555,10 @@ func (m tuiModel) sectionHint() string { case secGeneral: return "↑↓ row · enter change/refresh/type · space cycle · tab back · esc back" case secTheme: - if m.themeEdit { - return "↑↓ role · ←→ cycle · enter type · ctrl+d built-in · esc done" + if m.pick.on { + return "↑↓←→ move · +/- brightness · tab disc/chart · t type · enter keep · esc cancel" } - return "↑↓ theme · enter use it · e edit its colours · tab back · esc back" + return "" case secBatch: return "↑↓ row · enter change · tab back · esc back" case secTenants: @@ -572,9 +571,15 @@ func (m tuiModel) sectionHint() string { return "read only · tab back · esc back" case secReset: if m.resetArmed { + if m.setCursor == themeRestoreRow { + return "y restore the shipped themes · any other key cancels" + } return "y reset · any other key cancels" } - return "enter arm the reset · tab back · esc back" + if m.setCursor == themeRestoreRow { + return "↑↓ row · enter arm the restore · esc back" + } + return "↑↓ row · enter arm the reset · esc back" } return "tab back · esc back" } @@ -594,7 +599,7 @@ func (m tuiModel) viewSettings() string { } else { cursor = ui.StyleDim.Render("· ") } - label = ui.StyleElement.Render(name) + label = ui.StyleRow.Render(name) } nav.WriteString(cursor + label + "\n") } @@ -615,7 +620,7 @@ func (m tuiModel) viewSettings() string { contentPane := lipgloss.NewStyle().Width(contentWidth).Height(bodyHeight).Render(content.String()) var b strings.Builder - b.WriteString(ui.StyleCmd.Render("settings") + "\n\n") + b.WriteString(ui.StyleCmd.Render("Settings") + "\n\n") b.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, navPane, divider+" ", contentPane) + "\n") body := strings.TrimRight(b.String(), "\n") @@ -627,8 +632,6 @@ func (m tuiModel) viewSettings() string { return body + "\n" + ui.Footer(m.status, m.sectionHint()) } -// batchFormat is the saved format, normalized so an unset or stale value still -// displays as something real. func (m tuiModel) batchFormat() string { f, err := output.NormalizeFormat(m.cfg.BatchFormat) if err != nil { @@ -637,8 +640,6 @@ func (m tuiModel) batchFormat() string { return f } -// batchOrient is the saved row orientation, normalized the same way as the -// format so a stale value still displays as something real. func (m tuiModel) batchOrient() string { o, err := output.NormalizeOrientation(m.cfg.BatchOrient) if err != nil { @@ -685,7 +686,6 @@ func (m tuiModel) openBatchDirForm() (tea.Model, tea.Cmd) { return m.openForm(formBatchDir, "Folder for batch output", phaseAddTenant, []formField{dir}) } -// secretsLine names the secret store without softening a plaintext fallback. func (m tuiModel) secretsLine() string { if config.Plaintext(m.store.Secrets) { return ui.StyleWarn.Render(config.SecretsWarning(m.store.Secrets)) @@ -693,8 +693,6 @@ func (m tuiModel) secretsLine() string { return ui.StyleOK.Render(config.SecretsWarning(m.store.Secrets)) } -// shortenPath drops the front of a path rather than the end, since the file -// name is the part worth reading. func shortenPath(p string, n int) string { r := []rune(p) if n <= 1 || len(r) <= n { @@ -703,11 +701,8 @@ func shortenPath(p string, n int) string { return "…" + string(r[len(r)-(n-1):]) } -// pageSteps is what the setting cycles through. Bounded at both ends: one row -// is what the arrows already do, and a step past a screen is what pgdn is. var pageSteps = []int{5, 10, 20, 25, 50} -// DefaultPageStep is how far alt+up and alt+down move with nothing saved. const DefaultPageStep = 10 func (m tuiModel) pageStep() int { @@ -732,9 +727,6 @@ func (m tuiModel) cyclePageStep() (tea.Model, tea.Cmd) { return m, nil } -// viewsLine says what rich-data mode has to show and where a view of one's own -// would go. A configurable feature nobody can find the file for is not -// configurable. func (m tuiModel) viewsLine() string { n := len(m.viewNames()) line := ui.StyleElement.Render(fmt.Sprintf("%d", n)) + ui.StyleDim.Render(" for rich-data mode") @@ -751,7 +743,6 @@ func (m tuiModel) viewsLine() string { return line + ui.StyleDim.Render(" from "+shortenPath(path, 40)) } -// defaultMode is the saved mode the element list opens in. func (m tuiModel) defaultMode() pickMode { if mode, ok := parseMode(m.cfg.DefaultMode); ok { return mode @@ -762,8 +753,6 @@ func (m tuiModel) defaultMode() pickMode { func (m tuiModel) cycleDefaultMode() (tea.Model, tea.Cmd) { next := m.defaultMode().next() m.cfg.DefaultMode = next.String() - // The change applies now as well as next launch, which is what pressing it - // on this screen looks like it should do. m.mode = next if err := m.store.SaveConfig(m.cfg); err != nil { m.status = err.Error() diff --git a/internal/tui/settings_test.go b/internal/tui/settings_test.go index 0563ddc..40a2a3b 100644 --- a/internal/tui/settings_test.go +++ b/internal/tui/settings_test.go @@ -4,6 +4,7 @@ import ( "context" "strings" "testing" + "tyrtop.com/iontk/internal/app" tea "charm.land/bubbletea/v2" "tyrtop.com/iontk/internal/config" @@ -35,8 +36,6 @@ func settingsModel(t *testing.T) tuiModel { return m } -// navTo walks the nav pane down to section want, so these tests do not depend -// on which sections happen to sit next to each other. func navTo(t *testing.T, m tuiModel, want int) tuiModel { t.Helper() for i := 0; i < len(setSections); i++ { @@ -88,10 +87,10 @@ func TestSettingsNavAndContentFocus(t *testing.T) { func TestSettingsEverySectionRenders(t *testing.T) { m := settingsModel(t) wants := map[int]string{ - secGeneral: "commands", + secGeneral: "Commands", secTenants: "Acme Co", secUsers: "readonly", - secSession: "buffer cols", + secSession: "Buffer Cols", } for sec, want := range wants { m.setSection = sec @@ -272,19 +271,19 @@ func TestRenderSettingsPanes(t *testing.T) { } func TestLabelsPadVisibleWidthNotEscapeBytes(t *testing.T) { - for _, s := range []string{"momentum", "commands", "a"} { - if got := len(plain(ui.Label(s))); got != 13 { - t.Errorf("ui.Label(%q) visible width = %d, want 13", s, got) + for _, s := range []string{"momentum", "Commands", "a"} { + if got := len(plain(ui.Label(s))); got != ui.LabelWidth { + t.Errorf("ui.Label(%q) visible width = %d, want %d", s, got, ui.LabelWidth) } } m := settingsModel(t) m.setSection = secGeneral got := plain(m.viewSettings()) - if strings.Contains(got, "commandsread-only") { + if strings.Contains(got, "Commandsread-only") { t.Error("label ran into its value; padding is counting escape bytes") } - if !strings.Contains(got, "commands read-only") { + if !strings.Contains(got, "Commands read-only") { t.Errorf("label not padded:\n%s", got) } } @@ -472,7 +471,7 @@ func TestDebugSectionIsReadOnly(t *testing.T) { } got := plain(m.viewSettings()) - for _, want := range []string{"config", "secrets", "version", "catalog", "terminal", "sessions"} { + for _, want := range []string{"Config", "Secrets", "Version", "Catalog", "Terminal", "Sessions"} { if !strings.Contains(got, want) { t.Errorf("Debug missing %q:\n%s", want, got) } @@ -619,10 +618,6 @@ func TestGeometryFormTypesAValue(t *testing.T) { } } -// The General rows are reached by number from the key handler and printed in -// order by sectionContent, so the two have to agree. They did not once: mouse -// was third in the constants and fourth on screen, which pointed its key at -// the jump setting. func TestGeneralRowsMatchTheirLabels(t *testing.T) { m := settingsModel(t) m.setSection = secGeneral @@ -645,3 +640,52 @@ func TestGeneralRowsMatchTheirLabels(t *testing.T) { t.Errorf("sectionRows() = %d, want %d", got, generalRows) } } + +func TestTheResetSectionsTwoRowsArmSeparately(t *testing.T) { + pinTheme(t) + t.Cleanup(func() { app.ThemesFile = ""; ui.HideThemes(nil) }) + + m := settingsModel(t) + m.width, m.height = 110, 44 + m.setSection, m.setFocus = secReset, focusContent + + if got := m.sectionRows(); got != 2 { + t.Fatalf("sectionRows() = %d, want the reset and the theme restore", got) + } + out := plain(m.viewSettings()) + for _, want := range []string{"Factory Reset", "Restore Themes"} { + if !strings.Contains(out, want) { + t.Errorf("the Reset section does not offer %q:\n%s", want, out) + } + } + + m.setCursor = themeRestoreRow + next, _ := m.onSettingsKey("enter") + m = next.(tuiModel) + if !m.resetArmed { + t.Fatal("enter did not arm the row") + } + if hint := m.sectionHint(); !strings.Contains(hint, "restore") { + t.Errorf("hint = %q, want it to name what y will do", hint) + } + next, _ = m.onSettingsKey("y") + m = next.(tuiModel) + if !strings.Contains(m.status, "shipped schemes are back") { + t.Errorf("status = %q, want the restore to have run", m.status) + } + if len(m.cfg.Tenants) == 0 { + t.Error("restoring the themes took the tenants with it") + } + + m.setCursor = factoryResetRow + next, _ = m.onSettingsKey("enter") + m = next.(tuiModel) + next, _ = m.onSettingsKey("n") + m = next.(tuiModel) + if !strings.Contains(m.status, "cancel") { + t.Errorf("status = %q, want the reset cancelled", m.status) + } + if len(m.cfg.Tenants) == 0 { + t.Error("a cancelled reset still wiped the tenants") + } +} diff --git a/internal/tui/signin_test.go b/internal/tui/signin_test.go index 1dd9223..f56ec57 100644 --- a/internal/tui/signin_test.go +++ b/internal/tui/signin_test.go @@ -42,8 +42,6 @@ func menuLabels(m tuiModel) []string { } func TestSignInEntryIsAlwaysOffered(t *testing.T) { - // It is the way in to the tenant list, not a per-tenant re-auth, so it does - // not depend on which tenant happens to be active. for _, tenant := range []config.Tenant{ {Name: "Acme Co", TSGID: "1", Auth: config.AuthBrowser}, {Name: "Acme Co", TSGID: "1", Auth: config.AuthServiceAccount}, @@ -131,7 +129,6 @@ func TestSignedInLandsOnAConfirmation(t *testing.T) { m.phase = phaseSignIn m.spinner = ui.NewSpinner("") - // An explicit sign-in has nothing to replay, so the result is shown. mm, cmd := m.Update(signedInMsg{ client: liveClient(), ident: saseauth.Identity{Name: "Ben Hall", Email: "ben@example.test", TSG: "1"}, @@ -206,8 +203,6 @@ func TestDismissingTheConfirmation(t *testing.T) { wantPhase tuiPhase }{ {"an explicit sign-in returns to the menu", false, phaseMenu}, - // A browser tenant needs no stored secret to validate, so resuming goes - // on to load elements rather than bouncing back to the menu. {"an interrupted action resumes", true, phaseLoading}, } for _, tt := range tests { @@ -358,8 +353,6 @@ func TestForgettingATenantDropsItsRefreshToken(t *testing.T) { } func TestLabelKeysFitTheGutter(t *testing.T) { - // Every key rendered through ui.Label() has to fit ui.LabelWidth, or it collides - // with its value instead of being padded away from it. keys := []string{ "account", "tenant", "region", "roles", "api token", "sign-in", "spinner", "commands", "elements", "secrets", @@ -432,8 +425,6 @@ func TestMenuKeybinds(t *testing.T) { mm, _ := m.onMenuKey("alt+t") got := mm.(tuiModel) - // Nothing was open to reattach to, but the elements that own tab 1 must - // still have been restored, or a reattach would land on an empty list. if len(got.all) != len(els) { t.Errorf("loaded %d elements, want %d restored from cache", len(got.all), len(els)) } diff --git a/internal/tui/slash.go b/internal/tui/slash.go index 2e466c2..6fe63ce 100644 --- a/internal/tui/slash.go +++ b/internal/tui/slash.go @@ -69,9 +69,6 @@ func resolveVerb(word string) (string, bool) { return v, true } } - // A prefix before a fuzzy match, so one letter still picks a verb: "c" is - // a subsequence of both "commands" and "fc", and the fuzzy match alone - // would call that ambiguous and resolve nothing. if hits := prefixVerbs(word); len(hits) == 1 { return hits[0], true } @@ -191,8 +188,6 @@ func (m tuiModel) activateSlash() (tea.Model, tea.Cmd) { return m, nil case slashFreq: - // The command itself, not the row: a row carries its count and, when - // it was run on another model, which one. use, ok := m.freqAt(m.slashCursor) if !ok { return m.closeSlash(), nil @@ -212,9 +207,6 @@ func (m tuiModel) activateSlash() (tea.Model, tea.Cmd) { return m.closeSlash(), nil } -// insertCommand puts a command on the device line without sending it, which is -// what makes the palette safe to browse: nothing runs until the user presses -// enter at the device prompt. func (m tuiModel) insertCommand(text string) (tea.Model, tea.Cmd) { if !app.Defaults.AllowWrites && !m.cfg.AllowWrites && !ioncli.IsReadOnly(text) { m.status = "not read-only: " + text @@ -254,8 +246,6 @@ func (m tuiModel) onSlashKey(msg tea.KeyPressMsg, key string) (tea.Model, tea.Cm return m, nil case "ctrl+d": - // Only here: the reference catalog is not the user's to delete from, - // but a history of typos is worth being rid of. if m.slash == slashFreq { return m.forgetFreq() } @@ -326,11 +316,11 @@ func (m tuiModel) viewSlash() string { } else { bc, br := s.Size() cx, cy := s.Cursor() - b.WriteString(" " + ui.Label("buffer") + fmt.Sprintf("%d x %d\n", bc, br)) - b.WriteString(" " + ui.Label("pane") + fmt.Sprintf("%d x %d\n", cols, rows)) - b.WriteString(" " + ui.Label("cursor") + fmt.Sprintf("%d, %d\n", cx, cy)) - b.WriteString(" " + ui.Label("terminal") + fmt.Sprintf("%d x %d\n", m.width, m.height)) - b.WriteString(" " + ui.Label("line") + fmt.Sprintf("%q\n", s.Line())) + b.WriteString(" " + ui.Label("Buffer") + fmt.Sprintf("%d x %d\n", bc, br)) + b.WriteString(" " + ui.Label("Pane") + fmt.Sprintf("%d x %d\n", cols, rows)) + b.WriteString(" " + ui.Label("Cursor") + fmt.Sprintf("%d, %d\n", cx, cy)) + b.WriteString(" " + ui.Label("Terminal") + fmt.Sprintf("%d x %d\n", m.width, m.height)) + b.WriteString(" " + ui.Label("Line") + fmt.Sprintf("%q\n", s.Line())) } b.WriteString(ui.StyleDim.Render("esc back")) return b.String() diff --git a/internal/tui/spinners.go b/internal/tui/spinners.go index d30dc4c..2956546 100644 --- a/internal/tui/spinners.go +++ b/internal/tui/spinners.go @@ -5,8 +5,6 @@ import ( "tyrtop.com/iontk/internal/ui" ) -// withSpinner rebuilds the spinner from the theme, which owns its shape as -// well as its colour. func (m tuiModel) withSpinner() (tuiModel, tea.Cmd) { m.spinner = ui.NewSpinner(ui.CurrentTheme().Spinner) return m, m.spinner.Tick diff --git a/internal/tui/stats.go b/internal/tui/stats.go index 6d11c74..cb6e74e 100644 --- a/internal/tui/stats.go +++ b/internal/tui/stats.go @@ -10,12 +10,8 @@ import ( "tyrtop.com/iontk/internal/ui" ) -// statsRows is how much room the overlay takes when it is on: a rule and a -// line of numbers. Fixed, so a number growing a digit never moves the pane. const statsRows = 2 -// The numbers move on their own, so the overlay has to repaint without a -// keypress. Once a second, and only while it is on. type statsTickMsg time.Time func statsTick() tea.Cmd { @@ -29,12 +25,8 @@ func (m tuiModel) statsRows() int { return statsRows } -// toggleStats turns the overlay on or off, starting the repaint only when there -// is not already one running. func (m tuiModel) toggleStats() (tea.Model, tea.Cmd) { m.stats = !m.stats - // Both tables are sized where their rows are built, not per render, so the - // pane the overlay takes its lines from has to be resized here. m.rebuildRows() m.rebuildResultRows() if !m.stats || m.statsTicking { @@ -44,7 +36,6 @@ func (m tuiModel) toggleStats() (tea.Model, tea.Cmd) { return m, statsTick() } -// statsPane is exactly statsRows lines, with no trailing newline. func (m tuiModel) statsPane() string { if !m.stats { return "" @@ -61,8 +52,6 @@ func (m tuiModel) statsPane() string { sessions, fmt.Sprintf("elements %d/%d", len(m.matches), len(m.all)), } - // A static SCM_TOKEN has no lifetime to report, so this row comes and goes - // with the credential rather than with the overlay. if c := m.pool.get(m.tenant.TSGID); c != nil { if left, ok := c.TokenLeft(); ok { parts = append(parts, "token "+ui.Age(left)) diff --git a/internal/tui/stats_test.go b/internal/tui/stats_test.go index 3db5e4f..9e1077c 100644 --- a/internal/tui/stats_test.go +++ b/internal/tui/stats_test.go @@ -11,8 +11,6 @@ import ( func lineCount(s string) int { return len(strings.Split(strings.TrimRight(s, "\n"), "\n")) } func TestStatsOverlayKeepsTheFrameHeight(t *testing.T) { - // A pane that does not give up the rows the overlay takes pushes the footer - // off the bottom, or leaves a gap where it used to be. cases := map[string]func(t *testing.T) (tuiModel, func(tuiModel) string){ "element list": func(t *testing.T) (tuiModel, func(tuiModel) string) { m := pickModel(t) @@ -70,8 +68,6 @@ func TestStatsOverlayReportsWhatIsMoving(t *testing.T) { } func TestAltITogglesStatsEverywhere(t *testing.T) { - // Including in a session, where an unhandled alt+i would be typed at the - // device instead. for _, phase := range []tuiPhase{phasePicking, phaseResults, phaseSession, phaseMenu} { m := pickModel(t) m.phase = phase @@ -95,8 +91,6 @@ func TestStatsRepaintStopsWithTheOverlay(t *testing.T) { if cmd == nil { t.Fatal("turning the overlay on started no repaint, so the numbers would freeze") } - // Toggling again while the tick is still in flight must not start a second - // chain: the pane would then repaint twice a second for nothing. again, cmd := on.(tuiModel).toggleStats() if cmd != nil { t.Error("toggling off scheduled another repaint") @@ -106,7 +100,6 @@ func TestStatsRepaintStopsWithTheOverlay(t *testing.T) { t.Error("toggling back on started a second repaint while the first was still running") } - // The in-flight tick is what clears the flag once the overlay is off. off, _ := third.(tuiModel).toggleStats() done, cmd := off.(tuiModel).Update(statsTickMsg(time.Time{})) if cmd != nil { diff --git a/internal/tui/tabcolours.go b/internal/tui/tabcolours.go new file mode 100644 index 0000000..6980d56 --- /dev/null +++ b/internal/tui/tabcolours.go @@ -0,0 +1,304 @@ +package tui + +import ( + "fmt" + "slices" + + tea "charm.land/bubbletea/v2" + "tyrtop.com/iontk/internal/ui" +) + +const ( + tabsRow = "Tab Colours" + tabModeRow = "Mode" + listTabs = "tabs" + listShll = "shell" + listData = "data" +) + +type tabTarget struct { + label string + list string + idx int +} + +func (m tuiModel) tabTargets() []tabTarget { + out := []tabTarget{{label: tabModeRow}} + + entries := func(list, word string, colours []string, grow bool) { + for i := range colours { + label := word + if len(colours) > 1 || grow { + label = fmt.Sprintf("%s %d", word, i+1) + } + out = append(out, tabTarget{label: label, list: list, idx: i}) + } + if grow { + out = append(out, tabTarget{label: "+ Another " + word, list: list, idx: -1}) + } + } + + switch m.themeDraft.TabMode { + case ui.TabByType: + entries(listShll, "Shell", m.tabColours(listShll)[:1], false) + entries(listData, "Rich Data", m.tabColours(listData)[:1], false) + case ui.TabBoth: + entries(listShll, "Shell", m.tabColours(listShll), true) + entries(listData, "Rich Data", m.tabColours(listData), true) + default: + entries(listTabs, "Tab", m.tabColours(listTabs), true) + } + return out +} + +func (m tuiModel) tabColours(list string) []string { + var out []string + switch list { + case listShll: + out = m.themeDraft.TabShell + case listData: + out = m.themeDraft.TabData + default: + out = m.themeDraft.Tabs + } + if len(out) == 0 { + base := m.themeDraft.Tabs + if len(base) == 0 { + return []string{""} + } + at := ui.TabFallback(ui.TabShell) + if list == listData { + at = ui.TabFallback(ui.TabData) + } + return []string{base[at%len(base)]} + } + return out +} + +func (m tuiModel) tabColourAt(list string, i int) string { + set := m.tabColours(list) + if i < 0 || i >= len(set) { + return "" + } + return set[i] +} + +func (m tuiModel) setTabColour(list string, i int, spec string) (tuiModel, error) { + if err := ui.CheckColour(spec); err != nil { + return m, err + } + set := slices.Clone(m.tabColours(list)) + if i < 0 || i >= len(set) { + return m, fmt.Errorf("no tab colour %d", i+1) + } + set[i] = spec + + switch list { + case listShll: + m.themeDraft.TabShell = set + case listData: + m.themeDraft.TabData = set + default: + m.themeDraft.Tabs = set + } + return m, nil +} + +func (m tuiModel) openTabColours() (tea.Model, tea.Cmd) { + m.tabEdit = true + m.tabRow = 0 + m.status = "" + return m, nil +} + +func (m tuiModel) onTabColoursKey(key string) (tea.Model, tea.Cmd) { + if m.pick.on { + return m.onPickerKey(key) + } + + rows := m.tabTargets() + at := rows[min(max(m.tabRow, 0), len(rows)-1)] + + switch key { + case "esc", "q", "tab": + m.tabEdit = false + m.status = "" + return m, nil + + case "up", "k": + m.tabRow = max(0, m.tabRow-1) + return m, nil + case "down", "j": + m.tabRow = min(len(rows)-1, m.tabRow+1) + return m, nil + + case "left", "h", "-": + if at.list == "" { + return m.cycleTabMode(-1) + } + return m, nil + case "right", "l", "+", " ", "space": + if at.list == "" { + return m.cycleTabMode(1) + } + return m, nil + + case "enter", "e": + switch { + case at.list == "": + return m.cycleTabMode(1) + case at.idx < 0: + return m.growTabList(at.list) + } + return m.openTabPicker(at.list, at.idx, at.label) + + case "S": + if at.list == "" { + return m, nil + } + return m.nudgeTabColour(at, false) + case "R": + if at.list == "" { + return m, nil + } + return m.nudgeTabColour(at, true) + + case "ctrl+d", "x", "backspace": + if at.list == "" || at.idx < 0 { + return m, nil + } + return m.dropTabColour(at) + } + return m, nil +} + +func (m tuiModel) cycleTabMode(delta int) (tea.Model, tea.Cmd) { + modes := ui.TabModes() + at := max(0, slices.Index(modes, m.tabModeName())) + next := modes[((at+delta)%len(modes)+len(modes))%len(modes)] + + m = m.remember(m.themeDraft) + m.themeDraft = m.themeDraft.Clone() + m.themeDraft.TabMode = next + m.tabRow = 0 + out, cmd := m.saveDraft() + nm := out.(tuiModel) + if nm.status == "" { + nm.status = next + " · " + ui.TabModeBlurb[next] + } + return nm, cmd +} + +func (m tuiModel) tabModeName() string { + if m.themeDraft.TabMode == "" { + return ui.TabRotate + } + return m.themeDraft.TabMode +} + +func (m tuiModel) growTabList(list string) (tea.Model, tea.Cmd) { + set := slices.Clone(m.tabColours(list)) + if len(set) >= maxTabColours { + m.status = fmt.Sprintf("%d is as many as a rotation holds", maxTabColours) + return m, nil + } + set = append(set, ui.SimilarColour(set[len(set)-1], 1)) + + m = m.remember(m.themeDraft) + m.themeDraft = m.themeDraft.Clone() + switch list { + case listShll: + m.themeDraft.TabShell = set + case listData: + m.themeDraft.TabData = set + default: + m.themeDraft.Tabs = set + } + return m.saveDraft() +} + +const maxTabColours = 12 + +func (m tuiModel) dropTabColour(at tabTarget) (tea.Model, tea.Cmd) { + set := slices.Clone(m.tabColours(at.list)) + if len(set) <= 1 { + m.status = "a rotation needs one colour" + return m, nil + } + set = slices.Delete(set, at.idx, at.idx+1) + + m = m.remember(m.themeDraft) + m.themeDraft = m.themeDraft.Clone() + switch at.list { + case listShll: + m.themeDraft.TabShell = set + case listData: + m.themeDraft.TabData = set + default: + m.themeDraft.Tabs = set + } + m.tabRow = max(1, min(m.tabRow, len(m.tabTargets())-1)) + return m.saveDraft() +} + +func (m tuiModel) nudgeTabColour(at tabTarget, roll bool) (tea.Model, tea.Cmd) { + if at.idx < 0 { + return m, nil + } + cur := m.tabColourAt(at.list, at.idx) + next := ui.SimilarColour(cur, 1) + if roll || cur == "" { + next = ui.RandomColour(m.draftIsLight()) + } + + m = m.remember(m.themeDraft) + nm, err := m.setTabColour(at.list, at.idx, next) + if err != nil { + m.status = err.Error() + return m, nil + } + return nm.saveDraft() +} + +func (m tuiModel) tabColoursContent() []string { + if m.pick.on { + return m.pickerContent() + } + + mode := m.tabModeName() + out := []string{ + ui.StyleCmd.Render("Tab Colours · "+m.themeDraft.Name) + + ui.StyleDim.Render(" the bar across the top, once something is open"), + "", + } + + for i, at := range m.tabTargets() { + cursor := " " + if i == m.tabRow { + cursor = ui.StyleCmd.Render("> ") + } + + if at.list == "" { + out = append(out, cursor+ui.Label(tabModeRow)+ + ui.StyleElement.Render(fmt.Sprintf("%-8s", mode))+ + ui.StyleDim.Render(ui.TabModeBlurb[mode])) + continue + } + if at.idx < 0 { + out = append(out, cursor+ui.StyleGhost.Render(at.label)) + continue + } + + spec := m.tabColourAt(at.list, at.idx) + shown := spec + if shown == "" { + shown = "unset" + } + out = append(out, cursor+ui.Label(at.label)+" "+ + ui.ColourStyle(spec).Render("████")+" "+ + ui.StyleElement.Render(shown)) + } + + return append(out, "", ui.StyleDim.Render( + "↑↓ row · ←→ mode · enter pick · S/R nudge or roll · ctrl+d drop · esc back")) +} diff --git a/internal/tui/tabcolours_test.go b/internal/tui/tabcolours_test.go new file mode 100644 index 0000000..737e5e9 --- /dev/null +++ b/internal/tui/tabcolours_test.go @@ -0,0 +1,224 @@ +package tui + +import ( + "slices" + "strings" + "testing" + + "tyrtop.com/iontk/internal/ui" +) + +func tabPageOn(t *testing.T, theme string) tuiModel { + t.Helper() + m := editorOn(t, theme) + m.themeRow = slices.Index(editRows(), tabsRow) + if m.themeRow < 0 { + t.Fatal("there is no tab colours row in the editor") + } + next, _ := m.onSettingsKey("enter") + m = next.(tuiModel) + if !m.tabEdit { + t.Fatal("enter did not open the tab colours page") + } + return m +} + +func TestTheTabPageListsTheRowsItsModeNeeds(t *testing.T) { + m := tabPageOn(t, "nord") + + out := plain(m.viewSettings()) + for _, want := range []string{"Tab Colours · nord", "Mode", "rotate", "Tab 1", "+ Another Tab"} { + if !strings.Contains(out, want) { + t.Errorf("the page does not show %q:\n%s", want, out) + } + } + + next, _ := m.onSettingsKey("right") + m = next.(tuiModel) + if got := m.themeDraft.TabMode; got != ui.TabByType { + t.Fatalf("mode = %q after one step, want %q", got, ui.TabByType) + } + out = plain(m.viewSettings()) + for _, want := range []string{"Shell", "Rich Data"} { + if !strings.Contains(out, want) { + t.Errorf("the type mode does not list %q:\n%s", want, out) + } + } + if strings.Contains(out, "+ Another") { + t.Errorf("the type mode offers to grow a list of one:\n%s", out) + } + + next, _ = m.onSettingsKey("right") + m = next.(tuiModel) + out = plain(m.viewSettings()) + for _, want := range []string{"Shell 1", "Rich Data 1", "+ Another Shell", "+ Another Rich Data"} { + if !strings.Contains(out, want) { + t.Errorf("the both mode does not list %q:\n%s", want, out) + } + } + + next, _ = m.onSettingsKey("right") + m = next.(tuiModel) + if got := m.themeDraft.TabMode; got != ui.TabShared { + t.Errorf("mode = %q, want %q", got, ui.TabShared) + } + next, _ = m.onSettingsKey("right") + m = next.(tuiModel) + if got := m.themeDraft.TabMode; got != ui.TabRotate { + t.Errorf("mode = %q after a full cycle, want %q", got, ui.TabRotate) + } + reloaded, _ := ui.ThemeByName("nord") + if reloaded.TabMode != ui.TabRotate { + t.Errorf("the file holds mode %q", reloaded.TabMode) + } +} + +func TestTheRotationGrowsAndShrinks(t *testing.T) { + m := tabPageOn(t, "nord") + before := len(m.themeDraft.Tabs) + + m.tabRow = len(m.tabTargets()) - 1 + next, _ := m.onSettingsKey("enter") + m = next.(tuiModel) + if got := len(m.themeDraft.Tabs); got != before+1 { + t.Fatalf("the rotation has %d colours, want %d", got, before+1) + } + if last := m.themeDraft.Tabs[len(m.themeDraft.Tabs)-1]; last == "" { + t.Error("the new entry is unset, which renders as no colour") + } + if at := m.tabTargets()[m.tabRow]; at.idx != before { + t.Errorf("the cursor is on %+v, want the row that appeared", at) + } + + next, _ = m.onSettingsKey("ctrl+d") + m = next.(tuiModel) + if got := len(m.themeDraft.Tabs); got != before { + t.Errorf("the rotation has %d colours after a drop, want %d", got, before) + } + + for range before + 2 { + m.tabRow = 1 + next, _ = m.onSettingsKey("ctrl+d") + m = next.(tuiModel) + } + if got := len(m.themeDraft.Tabs); got != 1 { + t.Errorf("the rotation has %d colours, want it stopped at one", got) + } + if !strings.Contains(m.status, "needs one colour") { + t.Errorf("status = %q, want it to say why", m.status) + } +} + +func TestSettingAShellColourMaterialisesTheList(t *testing.T) { + m := tabPageOn(t, "nord") + next, _ := m.onSettingsKey("right") + m = next.(tuiModel) + if len(m.themeDraft.TabShell) != 0 { + t.Fatal("the shell list is set before anything touched it") + } + + m.tabRow = 1 + next, _ = m.onSettingsKey("R") + m = next.(tuiModel) + if len(m.themeDraft.TabShell) == 0 { + t.Fatal("rolling the shell colour did not write the list") + } + if m.themeDraft.TabShell[0] == "" { + t.Error("the shell colour is unset") + } + reloaded, _ := ui.ThemeByName("nord") + if !slices.Equal(reloaded.Tabs, m.themeDraft.Tabs) { + t.Error("setting a shell colour changed the rotation") + } +} + +func TestATabColourIsPickedWithTheSamePicker(t *testing.T) { + m := tabPageOn(t, "nord") + m.tabRow = 1 + before := m.themeDraft.Tabs[0] + + next, _ := m.onSettingsKey("enter") + m = next.(tuiModel) + if !m.pick.on { + t.Fatal("enter did not open the picker") + } + if m.pick.list != listTabs || m.pick.idx != 0 { + t.Fatalf("the picker is on %+v, want the first tab colour", m.pick) + } + if out := plain(m.viewSettings()); !strings.Contains(out, "Picking Tab 1") { + t.Errorf("the picker does not name what it is on:\n%s", out) + } + + for range 3 { + next, _ = m.onSettingsKey("left") + m = next.(tuiModel) + } + if m.themeDraft.Tabs[0] == before { + t.Error("moving on the disc did not change the tab colour") + } + next, _ = m.onSettingsKey("enter") + m = next.(tuiModel) + if m.pick.on { + t.Error("enter did not close the picker") + } + if !m.tabEdit { + t.Error("closing the picker left the tab page too") + } + + m.tabRow = 1 + kept := m.themeDraft.Tabs[0] + next, _ = m.onSettingsKey("enter") + m = next.(tuiModel) + next, _ = m.onSettingsKey("left") + m = next.(tuiModel) + next, _ = m.onSettingsKey("esc") + m = next.(tuiModel) + if m.themeDraft.Tabs[0] != kept { + t.Errorf("tab 1 = %q after esc, want %q", m.themeDraft.Tabs[0], kept) + } +} + +func TestLeavingTheTabPageLandsInTheEditor(t *testing.T) { + m := tabPageOn(t, "nord") + + next, _ := m.onSettingsKey("esc") + m = next.(tuiModel) + if m.tabEdit { + t.Fatal("esc did not close the tab page") + } + if !m.themeEdit { + t.Error("esc left the editor as well") + } + if m.phase != phaseSettings { + t.Errorf("phase = %v, want to still be in settings", m.phase) + } +} + +func TestThePreviewBarShowsTwoOfEachKind(t *testing.T) { + pinTheme(t) + m := settingsModel(t) + m.width, m.height = 110, 44 + m.setSection, m.setFocus = secTheme, focusContent + m.setCursor = 1 + + out := plain(m.viewSettings()) + if n := strings.Count(out, "· data"); n < 2 { + t.Errorf("the preview bar has %d data tabs, want two:\n%s", n, out) + } + if !strings.Contains(out, "store-0003-rtr-02") { + t.Errorf("the preview bar has only one shell tab:\n%s", out) + } + if ui.CurrentTheme().Name != m.cfg.Theme && m.cfg.Theme != "" { + t.Errorf("the preview left %q applied", ui.CurrentTheme().Name) + } +} + +func TestRenderTheTabColoursPage(t *testing.T) { + m := tabPageOn(t, "Ben's Theme") + t.Log("\n" + m.viewSettings()) + for range 2 { + next, _ := m.onSettingsKey("right") + m = next.(tuiModel) + } + t.Log("\n" + m.viewSettings()) +} diff --git a/internal/tui/tenantflag_test.go b/internal/tui/tenantflag_test.go index 254a116..58f73b4 100644 --- a/internal/tui/tenantflag_test.go +++ b/internal/tui/tenantflag_test.go @@ -26,8 +26,6 @@ func TestTenantFlagOpensOnThatTenant(t *testing.T) { if !m.hasTenant { t.Fatal("hasTenant = false, want --tenant to resolve") } - // Otherwise --tenant gets accepted and ignored, which is more confusing - // than just rejecting it. if m.tenant.Name != "Acme Co" { t.Errorf("tenant = %q, want --tenant to win over last_tenant %q", m.tenant.Name, cfg.LastTenant) } diff --git a/internal/tui/termcolour.go b/internal/tui/termcolour.go new file mode 100644 index 0000000..15c2e92 --- /dev/null +++ b/internal/tui/termcolour.go @@ -0,0 +1,99 @@ +package tui + +import ( + "fmt" + "image/color" + "io" + "time" + + tea "charm.land/bubbletea/v2" + "tyrtop.com/iontk/internal/ui" +) + +type termColourState struct { + bg, fg color.Color + hexBg string + hexFg string + probed bool + applied bool +} + +var termColour termColourState + +const colourProbeWait = 120 * time.Millisecond + +type colourProbeDoneMsg struct{} + +func probeTermColour() tea.Cmd { + return tea.Batch( + tea.RequestBackgroundColor, + tea.RequestForegroundColor, + tea.Tick(colourProbeWait, func(time.Time) tea.Msg { return colourProbeDoneMsg{} }), + ) +} + +func rememberTermColour(bg, fg color.Color) { + if termColour.applied { + return + } + if h := hexOfColour(bg); h != "" { + termColour.bg, termColour.hexBg = bg, h + } + if h := hexOfColour(fg); h != "" { + termColour.fg, termColour.hexFg = fg, h + } + if termColour.hexBg != "" && termColour.hexFg != "" { + termColour.probed = true + } +} + +func hexOfColour(c color.Color) string { + if c == nil { + return "" + } + r, g, b, _ := c.RGBA() + return ui.HexFromRGB([3]uint8{uint8(r >> 8), uint8(g >> 8), uint8(b >> 8)}) +} + +func frameColours() (bg, fg color.Color) { + if !termColour.probed { + return nil, nil + } + bg, fg = ui.Background(), ui.Foreground() + if bg == nil { + bg = termColour.bg + } + if fg == nil { + fg = termColour.fg + } + if bg != nil || fg != nil { + termColour.applied = true + } + return bg, fg +} + +func restoreTermColour(w io.Writer) { + if !termColour.applied { + return + } + if termColour.hexBg != "" { + fmt.Fprintf(w, "\x1b]11;%s\x07", termColour.hexBg) + } + if termColour.hexFg != "" { + fmt.Fprintf(w, "\x1b]10;%s\x07", termColour.hexFg) + } +} + +func termColourLine() string { + switch { + case !termColour.probed: + return "asking the terminal" + case termColour.hexBg == "" && termColour.hexFg == "": + return "terminal did not answer; iontk cannot put its colours back" + case termColour.hexFg == "": + return termColour.hexBg + " bg, no fg answer" + case termColour.hexBg == "": + return termColour.hexFg + " fg, no bg answer" + } + return termColour.hexBg + " bg · " + termColour.hexFg + " fg" +} diff --git a/internal/tui/themeedit.go b/internal/tui/themeedit.go index 75e5f7f..53c34e0 100644 --- a/internal/tui/themeedit.go +++ b/internal/tui/themeedit.go @@ -2,6 +2,7 @@ package tui import ( "fmt" + "slices" "strings" tea "charm.land/bubbletea/v2" @@ -11,22 +12,55 @@ import ( const spinnerRole = "spinner" -func editRoles() []string { return append(ui.RoleNames(), spinnerRole) } +const ( + randomRow = "Randomize Everything!" + similarRow = "Similar Theme" + undoRow = "Undo Last Change" + animRow = "Animation" +) + +func actionRows() []string { return []string{randomRow, similarRow, undoRow} } + +func editRows() []string { + return append(append(ui.RoleNames(), spinnerRole, tabsRow, animRow), actionRows()...) +} + +func editRoles() []string { return editRows() } + +func isAction(row string) bool { return slices.Contains(actionRows(), row) } -// editing a built-in writes an override; the embedded file is not writable func (m tuiModel) openThemeEdit() (tea.Model, tea.Cmd) { - themes := ui.Themes() - if m.setCursor < 0 || m.setCursor >= len(themes) { + t, ok := m.themeAt(m.setCursor) + if !ok { return m, nil } m.themeEdit = true - m.themeDraft = themes[m.setCursor] + m.themeDraft = t.Clone() + m.themeStart = t.Clone() + m.themeUndo = nil m.themeRow = 0 m.status = "" return m, nil } +const undoDepth = 64 + +func (m tuiModel) remember(before ui.Theme) tuiModel { + m.themeUndo = append(slices.Clone(m.themeUndo), before.Clone()) + if len(m.themeUndo) > undoDepth { + m.themeUndo = m.themeUndo[len(m.themeUndo)-undoDepth:] + } + return m +} + func (m tuiModel) onThemeEditKey(key string) (tea.Model, tea.Cmd) { + if m.tabEdit { + return m.onTabColoursKey(key) + } + if m.pick.on { + return m.onPickerKey(key) + } + roles := editRoles() role := roles[min(max(m.themeRow, 0), len(roles)-1)] @@ -34,7 +68,7 @@ func (m tuiModel) onThemeEditKey(key string) (tea.Model, tea.Cmd) { case "esc", "q", "tab": m.themeEdit = false m.status = "" - return m, nil + return m.applyWorking() case "up", "k": m.themeRow = max(0, m.themeRow-1) @@ -43,26 +77,175 @@ func (m tuiModel) onThemeEditKey(key string) (tea.Model, tea.Cmd) { m.themeRow = min(len(roles)-1, m.themeRow+1) return m, nil - // no typing, and the preview shows each value as it passes case "left", "h", "-": + if isAction(role) || role == animRow || role == tabsRow { + return m, nil + } return m.stepRole(role, -1) case "right", "l", "+", " ", "space": + if isAction(role) || role == animRow || role == tabsRow { + return m, nil + } return m.stepRole(role, 1) - case "enter": - if role == spinnerRole { + case "enter", "e": + switch role { + case spinnerRole: return m.stepRole(role, 1) + case tabsRow: + return m.openTabColours() + case animRow: + m.status = "animations are not wired up yet" + return m, nil + case randomRow: + return m.randomiseDraft() + case similarRow: + return m.nudgeDraft() + case undoRow: + return m.undoDraft() + } + return m.openPicker(role) + + case "r": + return m.randomiseDraft() + case "s": + return m.nudgeDraft() + case "R": + if isAction(role) || role == animRow || role == tabsRow { + return m.randomiseDraft() + } + return m.randomiseRole(role) + case "S": + if isAction(role) || role == animRow || role == tabsRow { + return m.nudgeDraft() + } + return m.nudgeRole(role) + case "u": + return m.undoDraft() + + case "t": + if role == spinnerRole || role == animRow || role == tabsRow || isAction(role) { + return m, nil } return m.openColourForm(role) - // the only way back once a built-in is overridden case "ctrl+d", "x": + if isAction(role) || role == animRow || role == tabsRow { + return m, nil + } return m.resetRole(role) } return m, nil } +func (m tuiModel) randomiseDraft() (tea.Model, tea.Cmd) { + m = m.remember(m.themeDraft) + m.themeDraft = m.themeDraft.Clone() + bg, fg, onLight := ui.RandomBackground() + m.themeDraft.Mono = false + for _, role := range ui.RoleNames() { + switch role { + case "background": + _ = m.themeDraft.SetRole(role, bg) + case "other": + _ = m.themeDraft.SetRole(role, fg) + default: + _ = m.themeDraft.SetRole(role, ui.RandomColour(onLight)) + } + } + for i := range m.themeDraft.Tabs { + m.themeDraft.Tabs[i] = ui.RandomColour(onLight) + } + m.themeDraft.Spinner = ui.RandomSpinner() + return m.saveDraft() +} + +func (m tuiModel) randomiseRole(role string) (tea.Model, tea.Cmd) { + m = m.remember(m.themeDraft) + if role == spinnerRole { + m.themeDraft.Spinner = ui.RandomSpinner() + return m.saveDraft() + } + if err := m.themeDraft.SetRole(role, ui.RandomColour(m.draftIsLight())); err != nil { + m.status = err.Error() + return m, nil + } + return m.saveDraft() +} + +func (m tuiModel) nudgeDraft() (tea.Model, tea.Cmd) { + m = m.remember(m.themeDraft) + m.themeDraft = m.themeDraft.Clone() + for _, role := range ui.RoleNames() { + cur := m.themeDraft.Role(role) + if cur == "" { + continue + } + next := ui.SimilarColour(cur, 1) + if role == "background" { + next = ui.SimilarBackground(cur, 1) + } + _ = m.themeDraft.SetRole(role, next) + } + for i, c := range m.themeDraft.Tabs { + m.themeDraft.Tabs[i] = ui.SimilarColour(c, 1) + } + return m.saveDraft() +} + +func (m tuiModel) nudgeRole(role string) (tea.Model, tea.Cmd) { + m = m.remember(m.themeDraft) + if role == spinnerRole { + m.themeDraft.Spinner = ui.NextSpinner(m.themeDraft.Spinner, 1) + return m.saveDraft() + } + cur := m.themeDraft.Role(role) + if cur == "" { + return m.randomiseRole(role) + } + next := ui.SimilarColour(cur, 1) + if role == "background" { + next = ui.SimilarBackground(cur, 1) + } + if err := m.themeDraft.SetRole(role, next); err != nil { + m.status = err.Error() + return m, nil + } + return m.saveDraft() +} + +func (m tuiModel) undoDraft() (tea.Model, tea.Cmd) { + if len(m.themeUndo) == 0 { + m.status = "nothing to undo" + return m, nil + } + last := len(m.themeUndo) - 1 + m.themeDraft = m.themeUndo[last].Clone() + m.themeUndo = m.themeUndo[:last] + + next, cmd := m.saveDraft() + nm := next.(tuiModel) + if nm.status == "" { + left := "nothing left to undo" + if n := len(nm.themeUndo); n > 0 { + left = fmt.Sprintf("%d more to undo", n) + } + nm.status = "undone · " + left + } + return nm, cmd +} + +func (m tuiModel) draftIsLight() bool { + c, ok := ui.RGBOf(m.themeDraft.Bg) + if !ok { + return false + } + _, _, v := ui.HSVOf(c) + return v > 0.5 +} + func (m tuiModel) stepRole(role string, delta int) (tea.Model, tea.Cmd) { + m = m.remember(m.themeDraft) if role == spinnerRole { m.themeDraft.Spinner = ui.NextSpinner(m.themeDraft.Spinner, delta) } else { @@ -76,6 +259,7 @@ func (m tuiModel) stepRole(role string, delta int) (tea.Model, tea.Cmd) { } func (m tuiModel) resetRole(role string) (tea.Model, tea.Cmd) { + m = m.remember(m.themeDraft) base, ok := ui.BuiltinTheme(m.themeDraft.Name) if !ok { m.status = m.themeDraft.Name + " is not a built-in, so there is nothing to go back to" @@ -97,8 +281,8 @@ func (m tuiModel) openColourForm(role string) (tea.Model, tea.Cmd) { return m.openForm(formColour, "Colour for "+role, phaseAddUser, []formField{f}) } -// a colour that will not parse would render as nothing func (m tuiModel) submitColour() (tea.Model, tea.Cmd) { + m = m.remember(m.themeDraft) if err := m.themeDraft.SetRole(m.formThemeRole, m.formValue(0)); err != nil { m.status = err.Error() return m, nil @@ -110,10 +294,16 @@ func (m tuiModel) submitColour() (tea.Model, tea.Cmd) { } nm.form, nm.formKind = nil, formNone nm.phase = phaseSettings + if nm.pick.on { + cols, rows := nm.wheelSize() + nm.pick.x, nm.pick.y, nm.pick.val = ui.WheelCell(nm.formValue(0), cols, rows) + if r, c, ok := ui.GridCell(nm.formValue(0)); ok { + nm.pick.row, nm.pick.col = r, c + } + } return nm, cmd } -// applied as well as saved, so the whole frame shows the edit func (m tuiModel) saveDraft() (tea.Model, tea.Cmd) { path := app.ThemesPath(m.store) if err := ui.SaveUserTheme(path, m.themeDraft); err != nil { @@ -129,11 +319,18 @@ func (m tuiModel) saveDraft() (tea.Model, tea.Cmd) { } func (m tuiModel) themeEditContent() []string { + if m.tabEdit { + return m.tabColoursContent() + } + if m.pick.on { + return m.pickerContent() + } + roles := editRoles() s := m.themeDraft.Styles() out := []string{ - ui.StyleCmd.Render("editing "+m.themeDraft.Name) + + ui.StyleCmd.Render("Editing "+m.themeDraft.Name) + ui.StyleDim.Render(" saved to "+shortenPath(app.ThemesPath(m.store), 34)), "", } @@ -143,29 +340,65 @@ func (m tuiModel) themeEditContent() []string { cursor = ui.StyleCmd.Render("> ") } if role == spinnerRole { - out = append(out, cursor+ui.Label(role)+" "+ + out = append(out, cursor+ui.Label("Spinner")+" "+ s.Spinner.Render(ui.SpinnerFrame(m.themeDraft.Spinner))+" "+ ui.StyleElement.Render(m.themeDraft.Spinner)) continue } + if role == tabsRow { + mode := m.tabModeName() + out = append(out, cursor+ui.Label(role)+ + ui.StyleElement.Render(fmt.Sprintf("%-8s", mode))+ + ui.StyleDim.Render(fmt.Sprintf("%d colour(s) · enter opens them", + len(m.tabColours(listTabs))))) + continue + } + + if role == animRow { + out = append(out, cursor+ui.StyleGhost.Render(ui.Label(role))+ + ui.StyleGhost.Render("placeholder")+ + ui.StyleDim.Render(" nothing animates yet")) + continue + } + + if isAction(role) { + if role == actionRows()[0] { + out = append(out, "") + } + out = append(out, cursor+m.actionRow(role)) + continue + } + value := m.themeDraft.Role(role) shown := value if shown == "" { shown = "unset" } - // the only way to tell #585b70 from #45475a sample := m.themeDraft.RoleStyle(role).Render("████") - // "spinner_color" fills LabelWidth exactly, leaving no gap - out = append(out, cursor+ui.Label(role)+" "+sample+" "+ - ui.StyleElement.Render(fmt.Sprintf("%-9s", shown))+ - ui.StyleDim.Render(roleHelp[role])) + out = append(out, cursor+ui.Label(ui.RoleLabel(role))+" "+sample+" "+ + ui.StyleElement.Render(shown)) } return append(out, "", ui.StyleDim.Render( - "←→ cycle · enter type one · ctrl+d back to the built-in · esc done")) + "enter pick · ←→ cycle · t type · s/r whole theme · S/R this row · ctrl+d built-in · esc save")) +} + +func (m tuiModel) actionRow(row string) string { + switch row { + case randomRow: + return ui.StyleCmd.Render(row) + + ui.StyleDim.Render(" every colour, the tabs and the spinner") + case similarRow: + return ui.StyleCmd.Render(row) + + ui.StyleDim.Render(" the same scheme, shifted") + } + if len(m.themeUndo) == 0 { + return ui.StyleGhost.Render(row) + ui.StyleDim.Render(" nothing to undo yet") + } + return ui.StyleWarn.Render(row) + + ui.StyleDim.Render(fmt.Sprintf(" u · %d step(s) back", len(m.themeUndo))) } -// "hot" and "row" mean nothing until you know where they are drawn var roleHelp = map[string]string{ "ok": "something worked, and the ✓", "err": "something failed, and the ✗", @@ -176,5 +409,7 @@ var roleHelp = map[string]string{ "hot": "a session about to go", "border": "a modal's frame", "divider": "the rule between panes", + "background": "the terminal's own background", + "other": "text no role has coloured", "spinner_color": "every spinner", } diff --git a/internal/tui/themeedit_test.go b/internal/tui/themeedit_test.go index 9eb4ba5..ff99e39 100644 --- a/internal/tui/themeedit_test.go +++ b/internal/tui/themeedit_test.go @@ -2,6 +2,8 @@ package tui import ( "os" + "path/filepath" + "slices" "strings" "testing" @@ -9,7 +11,6 @@ import ( "tyrtop.com/iontk/internal/ui" ) -// editorOn is the Theme section with the editor open on one scheme. func editorOn(t *testing.T, name string) tuiModel { t.Helper() pinTheme(t) @@ -20,11 +21,11 @@ func editorOn(t *testing.T, name string) tuiModel { m.setSection, m.setFocus = secTheme, focusContent for i, th := range ui.Themes() { if th.Name == name { - m.setCursor = i + m.setCursor = i + 1 } } - next, _ := m.onThemeKey("e") + next, _ := m.onSettingsKey("e") m = next.(tuiModel) if !m.themeEdit { t.Fatalf("e did not open the editor on %s", name) @@ -32,15 +33,11 @@ func editorOn(t *testing.T, name string) tuiModel { return m } -// Editing a built-in writes an override of it into the config root's -// themes.yaml, which is the only place anything can be written: the shipped -// set is embedded. func TestEditingAThemeWritesAnOverrideAndAppliesIt(t *testing.T) { m := editorOn(t, "classic") - // The first role, stepped one along the palette. before := m.themeDraft.OK - next, _ := m.onThemeEditKey("right") + next, _ := m.onSettingsKey("right") m = next.(tuiModel) if m.themeDraft.OK == before { t.Fatalf("ok is still %q after a step along the palette", before) @@ -49,7 +46,6 @@ func TestEditingAThemeWritesAnOverrideAndAppliesIt(t *testing.T) { t.Errorf("status = %q, want the edit to have gone through", m.status) } - // Applied to the whole frame, not just the preview. if got := ui.CurrentTheme().OK; got != m.themeDraft.OK { t.Errorf("CurrentTheme().OK = %q, want the edit %q", got, m.themeDraft.OK) } @@ -57,27 +53,25 @@ func TestEditingAThemeWritesAnOverrideAndAppliesIt(t *testing.T) { t.Error("the ✓ was not restyled, so Apply did not reach the marks") } - // Written where the config root loads from, and it survives a reload. - path := app.ThemesPath(m.store) + dir := app.ThemesPath(m.store) + path := filepath.Join(dir, ui.ThemeFileName("classic")) b, err := os.ReadFile(path) if err != nil { - t.Fatalf("themes.yaml was not written: %v", err) + t.Fatalf("%s was not written: %v", ui.ThemeFileName("classic"), err) } if !strings.Contains(string(b), "classic") { - t.Errorf("themes.yaml does not hold the override:\n%s", b) + t.Errorf("the file does not hold the override:\n%s", b) } - if err := ui.LoadThemes(path); err != nil { + if err := ui.LoadThemes(dir); err != nil { t.Fatalf("the file it wrote will not load: %v", err) } reloaded, _ := ui.ThemeByName("classic") if reloaded.OK != m.themeDraft.OK { t.Errorf("reloaded ok = %q, want %q", reloaded.OK, m.themeDraft.OK) } - // Overriding one scheme must not drop the others. if _, ok := ui.ThemeByName("nord"); !ok { t.Error("the override took the rest of the set with it") } - // And the theme in use is the one that was edited, so a restart keeps it. saved, err := m.store.Config() if err != nil { t.Fatal(err) @@ -87,14 +81,11 @@ func TestEditingAThemeWritesAnOverrideAndAppliesIt(t *testing.T) { } } -// Typing a colour is the other route, and it has to refuse one that will not -// parse: lipgloss renders an unparseable colour as nothing at all. func TestTypingAColourIsValidated(t *testing.T) { m := editorOn(t, "nord") - // Move to a role worth typing into, then open the form. - next, _ := m.onThemeEditKey("down") + next, _ := m.onSettingsKey("down") m = next.(tuiModel) - next, _ = m.onThemeEditKey("enter") + next, _ = m.onSettingsKey("t") m = next.(tuiModel) if m.formKind != formColour { t.Fatalf("formKind = %v, want the colour form", m.formKind) @@ -124,36 +115,33 @@ func TestTypingAColourIsValidated(t *testing.T) { } } -// Once a built-in is overridden, going back to what it shipped with is the -// one thing a user cannot do by hand without knowing the old value. func TestAnEditedRoleCanGoBackToTheBuiltin(t *testing.T) { m := editorOn(t, "classic") original := m.themeDraft.OK - next, _ := m.onThemeEditKey("right") + next, _ := m.onSettingsKey("right") m = next.(tuiModel) if m.themeDraft.OK == original { t.Fatal("nothing changed, so there is nothing to undo") } - next, _ = m.onThemeEditKey("ctrl+d") + next, _ = m.onSettingsKey("ctrl+d") m = next.(tuiModel) if m.themeDraft.OK != original { t.Errorf("ok = %q after ctrl+d, want the built-in's %q", m.themeDraft.OK, original) } } -// The spinner is part of the scheme, so it is edited in the same list. func TestTheEditorCyclesTheSpinnerShape(t *testing.T) { m := editorOn(t, "classic") roles := editRoles() - m.themeRow = len(roles) - 1 - if roles[m.themeRow] != spinnerRole { - t.Fatalf("last row is %q, want the spinner", roles[m.themeRow]) + m.themeRow = slices.Index(roles, spinnerRole) + if m.themeRow < 0 { + t.Fatalf("there is no spinner row in %v", roles) } before := m.themeDraft.Spinner - next, _ := m.onThemeEditKey("right") + next, _ := m.onSettingsKey("right") m = next.(tuiModel) if m.themeDraft.Spinner == before { t.Errorf("spinner is still %q", before) @@ -163,41 +151,39 @@ func TestTheEditorCyclesTheSpinnerShape(t *testing.T) { } } -// The editor has to say what each role is for and show the colour it holds, -// and the preview under it follows the draft rather than the saved scheme. func TestTheEditorNamesEveryRoleAndPreviewsTheDraft(t *testing.T) { m := editorOn(t, "Ben's Theme") out := plain(m.viewSettings()) for _, role := range ui.RoleNames() { - if !strings.Contains(out, role) { - t.Errorf("the editor does not list %q:\n%s", role, out) + if !strings.Contains(out, ui.RoleLabel(role)) { + t.Errorf("the editor does not list %q:\n%s", ui.RoleLabel(role), out) } } for _, help := range []string{"something worked", "a session about to go", "the row a list is on"} { - if !strings.Contains(out, help) { - t.Errorf("the editor does not say what the roles are for (%q missing)", help) + if strings.Contains(out, help) { + t.Errorf("the editor still carries the help column (%q):\n%s", help, out) } } - if !strings.Contains(out, "preview · Ben's Theme") { + if i, j := strings.Index(out, "Other"), strings.Index(out, "Background"); i < 0 || j < 0 || i > j { + t.Errorf("other is not listed above background (%d, %d):\n%s", i, j, out) + } + if !strings.Contains(out, "Preview · Ben's Theme") { t.Errorf("the editor has no preview of the draft:\n%s", out) } - // The theme list is out of the way while the editor is open, so the - // section's own cursor cannot move it underneath. if got := m.sectionRows(); got != 0 { t.Errorf("sectionRows() = %d while editing, want 0", got) } - if !strings.Contains(out, "←→ cycle") { + if !strings.Contains(out, "enter pick") { t.Errorf("the hint does not say how to change a colour:\n%s", out) } - // esc leaves the editor and goes back to the list. - next, _ := m.onThemeKey("esc") + next, _ := m.onSettingsKey("esc") m = next.(tuiModel) if m.themeEdit { t.Error("esc did not close the editor") } - if got := plain(m.viewSettings()); !strings.Contains(got, "(in use)") { + if got := plain(m.viewSettings()); !strings.Contains(got, "enter select") { t.Errorf("the list did not come back:\n%s", got) } } @@ -206,3 +192,315 @@ func TestRenderTheThemeEditor(t *testing.T) { m := editorOn(t, "Ben's Theme") t.Log("\n" + m.viewSettings()) } + +func TestTheEditorAndPickerGetTheirKeysBeforeTheSettingsPage(t *testing.T) { + m := editorOn(t, "nord") + onNord := m.setCursor + + next, _ := m.onSettingsKey("down") + m = next.(tuiModel) + if m.themeRow != 1 { + t.Errorf("themeRow = %d after down, want the role cursor to have moved", m.themeRow) + } + if m.setCursor != onNord { + t.Errorf("setCursor = %d, want the theme list left on %d", m.setCursor, onNord) + } + + next, _ = m.onSettingsKey("q") + m = next.(tuiModel) + if m.themeEdit { + t.Fatal("q did not close the editor") + } + if m.phase != phaseSettings { + t.Errorf("phase = %v after leaving the editor, want to still be in settings", m.phase) + } + if m.setFocus != focusContent { + t.Errorf("setFocus = %v, want the theme list still focused", m.setFocus) + } + + next, _ = m.onSettingsKey("down") + m = next.(tuiModel) + if m.setCursor != onNord+1 { + t.Errorf("setCursor = %d after down, want the theme list to scroll to %d", + m.setCursor, onNord+1) + } +} + +func TestLeavingThePickerLandsInTheEditor(t *testing.T) { + m := pickerOn(t, "nord", "cmd") + + next, _ := m.onSettingsKey("esc") + m = next.(tuiModel) + if m.pick.on { + t.Fatal("esc did not close the picker") + } + if !m.themeEdit { + t.Error("esc left the editor as well as the picker") + } + if m.phase != phaseSettings { + t.Errorf("phase = %v, want to still be in settings", m.phase) + } + + next, _ = m.onSettingsKey("esc") + m = next.(tuiModel) + if m.themeEdit { + t.Fatal("the editor did not close") + } + next, _ = m.onSettingsKey("esc") + m = next.(tuiModel) + if m.setFocus != focusNav { + t.Errorf("setFocus = %v, want the section nav", m.setFocus) + } + next, _ = m.onSettingsKey("esc") + if got := next.(tuiModel).phase; got != phaseMenu { + t.Errorf("phase = %v, want the main menu", got) + } +} + +func TestTheEditorsActionRowsAreListedAndSayWhatTheyDo(t *testing.T) { + m := editorOn(t, "nord") + out := plain(m.viewSettings()) + for _, want := range []string{randomRow, similarRow, undoRow, "nothing to undo yet", "placeholder"} { + if !strings.Contains(out, want) { + t.Errorf("the editor does not offer %q:\n%s", want, out) + } + } +} + +func TestUndoWalksBackOneChangeAtATime(t *testing.T) { + m := editorOn(t, "nord") + opened := m.themeDraft.Clone() + + m.themeRow = slices.Index(editRows(), randomRow) + next, _ := m.onSettingsKey("enter") + m = next.(tuiModel) + rolled := m.themeDraft.Clone() + if rolled.Equal(opened) { + t.Fatal("randomize changed nothing") + } + + m.themeRow = slices.Index(editRows(), "warn") + next, _ = m.onSettingsKey("s") + m = next.(tuiModel) + if m.themeDraft.Equal(rolled) { + t.Fatal("the nudge changed nothing") + } + + if out := plain(m.viewSettings()); !strings.Contains(out, "step(s) back") { + t.Errorf("the undo row does not say what it will do:\n%s", out) + } + + next, _ = m.onSettingsKey("u") + m = next.(tuiModel) + if !m.themeDraft.Equal(rolled) { + t.Errorf("warn = %q after one undo, want the rolled %q", + m.themeDraft.Warn, rolled.Warn) + } + if got := ui.CurrentTheme(); !got.Equal(rolled) { + t.Error("undo did not reach the frame, only the draft") + } + + next, _ = m.onSettingsKey("u") + m = next.(tuiModel) + if !m.themeDraft.Equal(opened) { + t.Errorf("ok = %q after the second undo, want the opened %q", + m.themeDraft.OK, opened.OK) + } + reloaded, _ := ui.ThemeByName("nord") + if !reloaded.Equal(opened) { + t.Errorf("the file holds %q for ok, want %q", reloaded.OK, opened.OK) + } + + next, _ = m.onSettingsKey("u") + if got := next.(tuiModel).status; !strings.Contains(got, "nothing to undo") { + t.Errorf("status = %q with the stack empty", got) + } +} + +func TestPickingAColourIsOneUndoStep(t *testing.T) { + m := pickerOn(t, "nord", "cmd") + before := m.themeDraft.Cmd + + for range 4 { + next, _ := m.onSettingsKey("left") + m = next.(tuiModel) + } + next, _ := m.onSettingsKey("enter") + m = next.(tuiModel) + if m.themeDraft.Cmd == before { + t.Fatal("the picker kept nothing") + } + if got := len(m.themeUndo); got != 1 { + t.Errorf("the picker left %d undo steps, want 1", got) + } + + next, _ = m.onSettingsKey("u") + m = next.(tuiModel) + if m.themeDraft.Cmd != before { + t.Errorf("cmd = %q after undo, want %q", m.themeDraft.Cmd, before) + } +} + +func TestRandomizeRollsEveryColourAndKeepsItReadable(t *testing.T) { + m := editorOn(t, "classic") + m.themeRow = slices.Index(editRoles(), randomRow) + + next, _ := m.onSettingsKey("enter") + m = next.(tuiModel) + + bg, ok := ui.RGBOf(m.themeDraft.Bg) + if !ok { + t.Fatalf("randomize left bg = %q", m.themeDraft.Bg) + } + _, _, bgv := ui.HSVOf(bg) + + for _, role := range ui.RoleNames() { + spec := m.themeDraft.Role(role) + if spec == "" { + t.Errorf("%s was left unset by randomize", role) + continue + } + c, ok := ui.RGBOf(spec) + if !ok { + t.Errorf("%s = %q does not resolve", role, spec) + continue + } + if role == "background" { + continue + } + _, _, v := ui.HSVOf(c) + if d := v - bgv; d < 0.18 && d > -0.18 { + t.Errorf("%s (%s) is as bright as the background it rolled (%s)", + role, spec, m.themeDraft.Bg) + } + } + if !ui.KnownSpinner(m.themeDraft.Spinner) { + t.Errorf("spinner = %q, which is not a shape", m.themeDraft.Spinner) + } +} + +func TestSimilarShiftsEveryColourWithoutLosingTheScheme(t *testing.T) { + m := editorOn(t, "Ben's Theme") + start := m.themeDraft + m.themeRow = slices.Index(editRoles(), similarRow) + + next, _ := m.onSettingsKey("enter") + m = next.(tuiModel) + + for _, role := range []string{"ok", "err", "cmd", "element"} { + before, _ := ui.RGBOf(start.Role(role)) + after, ok := ui.RGBOf(m.themeDraft.Role(role)) + if !ok { + t.Fatalf("%s = %q", role, m.themeDraft.Role(role)) + } + bh, _, _ := ui.HSVOf(before) + ah, _, _ := ui.HSVOf(after) + d := bh - ah + if d < 0 { + d = -d + } + if d > 60 && d < 300 { + t.Errorf("%s moved %.0f degrees of hue, which is a different colour", role, d) + } + } + + bg, _ := ui.RGBOf(m.themeDraft.Bg) + if _, _, v := ui.HSVOf(bg); v > 0.4 { + t.Errorf("the background came out at value %.2f, want it still dark", v) + } +} + +func TestSimilarAndRandomWorkOnTheRoleUnderTheCursor(t *testing.T) { + m := editorOn(t, "nord") + roles := editRoles() + m.themeRow = slices.Index(roles, "warn") + + start := m.themeDraft + next, _ := m.onSettingsKey("S") + m = next.(tuiModel) + if m.themeDraft.Warn == start.Warn { + t.Error("S left the role under the cursor alone") + } + if m.themeDraft.OK != start.OK || m.themeDraft.Err != start.Err { + t.Error("S on one role changed the others too") + } + + start = m.themeDraft + next, _ = m.onSettingsKey("R") + m = next.(tuiModel) + if m.themeDraft.Warn == start.Warn { + t.Error("R left the role under the cursor alone") + } + if m.themeDraft.OK != start.OK { + t.Error("R on one role changed the others too") + } + + start = m.themeDraft + next, _ = m.onSettingsKey("s") + m = next.(tuiModel) + if m.themeDraft.OK == start.OK { + t.Error("s on a role row did not change the whole scheme") + } +} + +func TestANewSchemeIsCopiedFromTheOneInUseAndOpensInTheEditor(t *testing.T) { + pinTheme(t) + t.Cleanup(func() { app.ThemesFile = "" }) + + m := settingsModel(t) + m.width, m.height = 110, 44 + m.setSection, m.setFocus = secTheme, focusContent + m.setCursor = 0 + + next, _ := m.onSettingsKey("enter") + m = next.(tuiModel) + if m.formKind != formNewTheme { + t.Fatalf("formKind = %v, want the new-scheme form", m.formKind) + } + + m.form[0].input.SetValue("nord") + next, _ = m.submitNewTheme() + m = next.(tuiModel) + if m.formKind != formNewTheme { + t.Error("the form closed on a name that is already taken") + } + if !strings.Contains(m.status, "already exists") { + t.Errorf("status = %q, want it to say the name is taken", m.status) + } + + m.form[0].input.SetValue(" ") + next, _ = m.submitNewTheme() + if got := next.(tuiModel).status; !strings.Contains(got, "needs a name") { + t.Errorf("status = %q on an empty name", got) + } + + m.form[0].input.SetValue("workbench") + next, _ = m.submitNewTheme() + m = next.(tuiModel) + if m.formKind != formNone { + t.Fatalf("the form stayed open: %s", m.status) + } + if !m.themeEdit { + t.Error("a new scheme did not open in the editor") + } + if m.themeDraft.Name != "workbench" { + t.Errorf("the editor is on %q", m.themeDraft.Name) + } + if m.themeDraft.OK == "" || m.themeDraft.Cmd == "" { + t.Errorf("the new scheme is empty: %+v", m.themeDraft) + } + if _, ok := ui.ThemeByName("workbench"); !ok { + t.Error("the new scheme was not written where the picker reads from") + } + if ui.CurrentTheme().Name != "workbench" { + t.Errorf("CurrentTheme() = %q, want the new scheme in use", ui.CurrentTheme().Name) + } + m.themeRow = slices.Index(editRoles(), randomRow) + next, _ = m.onSettingsKey("enter") + m = next.(tuiModel) + next, _ = m.onSettingsKey("u") + m = next.(tuiModel) + if !m.themeDraft.Equal(m.themeStart) { + t.Error("undo in a new scheme did not go back to the copy it started as") + } +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index e3f2519..167b91c 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -61,13 +61,9 @@ const ( modeShell pickMode = iota modeBatch modeData - // modeMacro is the element list with a macro already chosen. It is not in - // the cycle below: it means nothing without a macro, so it is entered - // from the macro page and left by cycling out. modeMacro ) -// pickModes is the cycle alt+m walks. Adding a mode means adding it here. var pickModes = []pickMode{modeShell, modeBatch, modeData} func (p pickMode) String() string { @@ -126,6 +122,7 @@ const ( formMacro formZone formColour + formNewTheme ) const cacheTTL = time.Hour @@ -134,21 +131,13 @@ type elementsErrMsg string type clientReadyMsg struct{ client *api.Client } type clientErrMsg string -// needsSignInMsg says the tenant's stored credentials are spent and only a -// browser can renew them. type needsSignInMsg struct{} -// discoveredMsg carries the tenants a browser sign-in found. type discoveredMsg struct { d saseauth.Discovery err error } -// signedInMsg carries the outcome of a browser sign-in, including the facts -// that prove it worked: a client that authenticated from the stored refresh -// token, and a profile fetched with it. -// tokenErrMsg is a refresh failure reported by the client. A closed channel -// means refreshing has stopped for good. type tokenErrMsg struct { err error stopped bool @@ -188,34 +177,25 @@ type tuiModel struct { cacheAt time.Time cacheCount int - menuCursor int - switchCursor int - setCursor int - tokenDead string - authMode authMode - tenantIssues map[string]string - // The device login chosen per tenant, keyed by tenant ID, and which tenant - // the users panel is managing. - runAs map[string]string - usersTSG string - // Derived when the element list changes, not per render. - scope string - tenantCount int - // showSelected narrows the list to what has been picked. - showSelected bool - // The last selection alt+c threw away, so it can be put back. + menuCursor int + switchCursor int + setCursor int + tokenDead string + authMode authMode + tenantIssues map[string]string + runAs map[string]string + usersTSG string + scope string + tenantCount int + showSelected bool lastCleared map[string]bool lastClearedView bool - // Outstanding tenant connections, and the batch commands waiting on them. - connectWait int - resumeRun []string - discovered []saseauth.Tenant - picked map[string]bool - pickCursor int - - // What to replay once an unplanned sign-in finishes. At most one is set. - // A sign-in with something to replay skips the confirmation screen, since - // it is plumbing in the middle of an action the user already chose. + connectWait int + resumeRun []string + discovered []saseauth.Tenant + picked map[string]bool + pickCursor int + signInResume bool resumeSessions []sessionTarget lastTargets []sessionTarget @@ -236,67 +216,51 @@ type tuiModel struct { formOldTSG string formGeoRow int - setSection int - setFocus int - // The theme editor, which is the Theme section with a scheme open rather - // than a page of its own: the preview under it has to stay visible. - themeEdit bool - themeRow int - themeDraft ui.Theme - // Which role the colour form is typing into. + setSection int + setFocus int + themeEdit bool + themeRow int + themeDraft ui.Theme + themeStart ui.Theme + themeUndo []ui.Theme + themeDel string + macroDel string + macroUndo []config.Macro + switchDel string + switchUndo []config.Tenant + tabEdit bool + tabRow int + pick colourPick + spinTick int formThemeRole string - // The last IANA zone typed in, so the times row can cycle back to it - // rather than only between UTC and local. - typedZone string - // The factory reset asks twice, and this is whether it has been asked once. - resetArmed bool - - all []elementRow - sites map[string]string - // HA state per element, keyed tsg/eid, filled in behind the list: it is a - // request per cluster and the list must not wait on it. haCluster is the - // per-cluster side, keyed tsg/cluster: "ok" for one that answered, the - // reason for one that refused, absent for one still in flight -- which is - // what tells a row that will never resolve from one that is loading. - ha map[string]string - haCluster map[string]string - haFailed int - haErr string - // How far through the sweep we are, so the list can say. It is one - // request per cluster at the client's five a second, which on a fleet of - // HA pairs is long enough that "nothing yet" needs to be told apart from - // "stuck". - haTotal int - haDone int - // The background fill's queue per tenant, and which tenants have a - // request out. The queue is paced so an interactive ask -- the rows - // someone just searched for -- is not stuck behind the fleet. + typedZone string + resetArmed bool + + all []elementRow + sites map[string]string + ha map[string]string + haCluster map[string]string + haFailed int + haErr string + haTotal int + haDone int + haAt time.Time haQueue map[string][]string haInFlight map[string]bool - // The last sweep's states, read from the element cache at launch and - // shown with an asterisk until this run's sweep replaces them. - haCached map[string]string - // What the tenant-wide query did, for the Debug section: the aggregate is - // unverified against hardware, so which path filled the column is the - // first thing worth knowing about it. - haBulk string + haCached map[string]string + haBulk string matches []int selected map[string]bool - // One client per tenant, built on demand. A browser account can reach many - // tenants, so there is no single "the" client any more. pool *clientPool pendingTenants int table table.Model filter textinput.Model filterOn bool - // The element list as rebuildRows last worked it out: which columns are - // in it, their widths, and every row's plain values. Kept so a cursor - // move can repaint one row instead of rebuilding the fleet. - listKeep []int - listWidths []int - listValues [][]string + listKeep []int + listWidths []int + listValues [][]string cmdInput textinput.Model @@ -315,62 +279,40 @@ type tuiModel struct { resTable table.Model resOpen bool resScroll int - // The line an open result is scrolled to, highlighted the way the list - // above it highlights its row. - resLine int - elapsed []time.Duration - - sessions *ionterm.Manager - // How far the session pane is held above its live bottom. Reset on a tab - // switch: an offset into one session's output means nothing in another's. - sesScroll int - connecting string - // Per-element dial progress, and the channel the clients report it on. + resLine int + elapsed []time.Duration + + sessions *ionterm.Manager + sesScroll int + connecting string dialing map[string]dialState dialReports chan dialProgressMsg - // The element a redial is in flight for. Its dead tab stays on screen, - // saying so, until the replacement is up. - redialing string - // The element a failed connection is asking about, empty when nothing is. - // askRetryNo is which answer is highlighted; the zero value picks yes, - // since that is what someone who hit enter almost certainly meant. - askRetry sessionTarget - askRetryNo bool - - // The stats overlay, and whether its repaint is already running. + redialing string + askRetry sessionTarget + askRetryNo bool + stats bool statsTicking bool - // Command history, favourites and macros, loaded once and saved as they - // change. cmdsErr is a commands.json that would not parse, reported where - // the history is used rather than at startup. cmds *config.CommandStore cmdsErr string - mode pickMode - // The rich-data views and the panes open on them. viewsErr is a broken - // views.yaml, reported where the mode is used rather than at startup. - views *dataview.Set - viewsErr string - data []dataTab - dataActive int - // The data pane's own filter, which narrows the page to matching lines. + mode pickMode + views *dataview.Set + viewsErr string + data []dataTab + dataActive int dataFilter textinput.Model dataFilterOn bool - // What to reopen once a tenant connection or sign-in finishes, the way - // resumeSessions does for shells. - resumeData []sessionTarget + resumeData []sessionTarget userCursor int slash slashMode slashInput textinput.Model slashItems []string slashCursor int - // The history behind the rows /fc is showing, kept alongside them so a - // row can be acted on rather than re-parsed. - freq []config.CommandUse + freq []config.CommandUse - // The favourite slot picker, drawn over the session pane. favPick bool favCursor int @@ -378,24 +320,16 @@ type tuiModel struct { macroFilter textinput.Model macroFilterOn bool formMacroName string - // The macro chosen for the element list, which is what macro mode runs. - armedMacro string - // The macro held on the cross-model warning, empty when nothing is, and - // the rows it would run on. It shares askRetryNo, since only one of the - // two questions can be open. - askMacro string - askMacroOn []elementRow + armedMacro string + askMacro string + askMacroOn []elementRow width int height int } -// Version is what the settings page reports. The command resolves it, -// because the release build sets it through main. var Version = "dev" -// Tenant is --tenant. The command checks the name before Run, so empty here -// just means the user didn't ask for one. var Tenant string func Run(ctx context.Context) error { @@ -411,8 +345,6 @@ func Run(ctx context.Context) error { if err != nil { return err } - // Fail instead of opening with no tenant at all. A typo would otherwise - // look the same as a first run. if Tenant != "" { if _, ok := app.ResolveTenant(cfg, Tenant); !ok { return fmt.Errorf("no saved tenant named %q; %s", Tenant, app.TenantChoices(cfg)) @@ -421,6 +353,8 @@ func Run(ctx context.Context) error { model := newTUIModel(ctx, store, cfg) defer model.sessions.CloseAll() + // bubbletea's own reset is OSC 111, which not every terminal implements + defer restoreTermColour(os.Stdout) _, err = tea.NewProgram(model, tea.WithContext(ctx)).Run() if err != nil && !errors.Is(err, tea.ErrProgramKilled) { @@ -430,14 +364,8 @@ func Run(ctx context.Context) error { } func newTUIModel(ctx context.Context, store *config.Store, cfg *config.Config) tuiModel { - // Appearance and the display zone are process-wide, and this is where the - // saved settings first exist. None of it can fail the launch: a zone that - // will not load reports itself on the settings page and the times stay - // UTC, a themes.yaml that will not parse leaves the built-ins, and an - // unknown theme name opens in the default. app.ApplyDisplaySettings(store, cfg) - // The shape as well as the colour: the theme owns both. s := ui.NewSpinner(ui.CurrentTheme().Spinner) fi := textinput.New() @@ -481,17 +409,12 @@ func newTUIModel(ctx context.Context, store *config.Store, cfg *config.Config) t m.typedZone = z } - // The views load here rather than when the mode is first used: a broken - // views.yaml should be reported once, at the start, and never leave the - // mode with nothing to show. Load returns the built-ins even when the - // user's file is the thing that failed. views, err := dataview.Load(ViewsPath(store)) if err != nil { m.viewsErr = err.Error() } m.views = views - // Nil in a test that only renders: ViewsPath guards the same way. m.dataFilter = newDataFilter() m.cmds = &config.CommandStore{} if store != nil { @@ -504,16 +427,11 @@ func newTUIModel(ctx context.Context, store *config.Store, cfg *config.Config) t if am, ok := parseAuthMode(cfg.AuthMode); ok { m.authMode = am } else if t, ok := app.ResolveTenant(cfg, Tenant); ok && !t.UsesBrowser() { - // Nothing saved: follow whatever the tenant already uses, so an existing - // service account setup does not open on a browser prompt. m.authMode = authTenant } m.tenant, m.hasTenant = app.ResolveTenant(cfg, Tenant) if m.hasTenant { m.ionUser = m.tenant.DefaultUser() - // Elements are refreshed on every launch so the list keeps up with the - // tenant; the cache only exists to reach the menu without waiting. A - // cache younger than cacheFresh is taken as current and left alone. m.cacheState = cacheUpdating if c, err := store.Cache(m.tenant.TSGID); err == nil && c != nil && len(c.Elements) > 0 { m.cacheCount = len(c.Elements) @@ -526,9 +444,6 @@ func newTUIModel(ctx context.Context, store *config.Store, cfg *config.Config) t return m } -// tableStyles is the widget's own, with the selected row replaced: its default -// is a 256-colour pink that no theme of ours and no terminal's own palette has -// anything to do with. func tableStyles() table.Styles { s := table.DefaultStyles() s.Selected = ui.StyleRow @@ -538,12 +453,13 @@ func tableStyles() table.Styles { func newElementTable() table.Model { t := table.New( table.WithColumns([]table.Column{ + {Title: "", Width: 2}, {Title: "", Width: 3}, {Title: "", Width: 3}, - {Title: "element", Width: 26}, - {Title: "site", Width: 22}, - {Title: "model", Width: 11}, - {Title: "state", Width: 9}, + {Title: "Element", Width: 26}, + {Title: "Site", Width: 22}, + {Title: "Model", Width: 11}, + {Title: "State", Width: 9}, }), table.WithFocused(true), ) @@ -551,14 +467,8 @@ func newElementTable() table.Model { return t } -// moveCursorTo puts the element list on a row. -// -// Moving down goes through the widget's MoveDown, which keeps its viewport -// under the cursor. Moving up cannot: table.MoveUp raises the viewport offset -// while the cursor falls, so the highlight walks off the top of the window -// and a held k stops appearing to do anything. Rebuilding the scroll state -// and walking down to the row is the way to an offset that is right by -// construction. +// table.MoveUp raises the viewport offset while the cursor falls, so the +// highlight walks off the top and a held k stops appearing to do anything. func (m *tuiModel) moveCursorTo(n int) { rows := m.table.Rows() if len(rows) == 0 { @@ -568,8 +478,6 @@ func (m *tuiModel) moveCursorTo(n int) { prev := m.table.Cursor() if n >= prev { m.table.MoveDown(n - prev) - // The row the cursor left goes back to its own colour and the row it - // landed on gives it up. Two rows, not the list. m.repaintCursorRows(prev) return } @@ -588,9 +496,6 @@ func (m *tuiModel) moveCursor(delta int) { m.moveCursorTo(m.table.Cursor() + delta) } -// cacheFresh is how long cached elements are treated as current. The CLI's -// --search reads the same window, so the two cannot disagree about what is -// current enough to skip a round trip. const cacheFresh = config.CacheFresh func (m tuiModel) Init() tea.Cmd { @@ -603,11 +508,10 @@ func (m tuiModel) Init() tea.Cmd { cmds = cmds[:1] } } + cmds = append(cmds, probeTermColour()) return tea.Batch(cmds...) } -// refreshNow re-fetches elements, authenticating first if that has not happened -// yet. It is what the refresh keybind and the settings entry both call. func (m tuiModel) refreshNow() (tea.Model, tea.Cmd) { if !m.hasTenant { m.status = "no tenant selected" @@ -621,9 +525,6 @@ func (m tuiModel) refreshNow() (tea.Model, tea.Cmd) { } m.flags = f m.status = "" - // The failure tally belongs to one sweep. Left standing it would double - // on the next refresh and report a count nobody could account for; the - // per-cluster states stay, so the column does not blank while it refills. m.haFailed, m.haErr = 0, "" m.haTotal, m.haDone, m.haBulk = 0, 0, "" return m.refreshActive() @@ -642,14 +543,6 @@ func connect(ctx context.Context, st *config.Store, f app.Flags) tea.Cmd { } } -// signIn opens the browser and waits for the sign-in to finish. It is the only -// path in iontk that ever shows a browser window. -// -// Finishing the exchange is not the same as working, so this goes on to build a -// client the ordinary way and fetch a profile with it. That exercises the -// stored refresh token, which is what decides whether the next run needs a -// browser at all, and the profile call is the one that proves the SD-WAN API -// accepts the token. func signIn(ctx context.Context, f app.Flags, st *config.Store) tea.Cmd { return func() tea.Msg { httpClient := &http.Client{Timeout: f.HTTPTimeout} @@ -674,12 +567,7 @@ func signIn(ctx context.Context, f app.Flags, st *config.Store) tea.Cmd { } } -// watchToken surfaces refresh failures instead of letting them print into the -// frame. The api package no longer writes to stderr, which the TUI never -// redirected, so a failed refresh used to land in the middle of the display. func watchToken(ch <-chan error) tea.Cmd { - // A static token has no refresh loop, so there is no channel and nothing to - // watch. Receiving from a nil channel would block this goroutine forever. if ch == nil { return nil } @@ -694,19 +582,34 @@ func watchToken(ch <-chan error) tea.Cmd { func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { - case tea.ColorProfileMsg: - // What the terminal can show, which the renderer downsamples to. Held - // so the theme picker can say a scheme is being approximated rather - // than leaving someone to wonder why nord looks like classic. ui.SetProfile(msg.Profile) if ui.ShouldQueryDepth() { - // Bubble Tea upgrades the profile and sends this message again if - // the terminal answers, so this asks once and does not loop. return m, tea.Batch(tea.RequestCapability("RGB"), tea.RequestCapability("Tc")) } return m, nil + case tea.BackgroundColorMsg: + rememberTermColour(msg.Color, nil) + return m, nil + + case tea.ForegroundColorMsg: + rememberTermColour(nil, msg.Color) + return m, nil + + case colourProbeDoneMsg: + termColour.probed = true + return m, nil + + case tea.MouseWheelMsg: + switch msg.Button { + case tea.MouseWheelUp: + return m.onWheel(true) + case tea.MouseWheelDown: + return m.onWheel(false) + } + return m, nil + case tea.WindowSizeMsg: m.width, m.height = msg.Width, msg.Height m.resFilter.SetWidth(min(26, max(10, m.width-30))) @@ -727,8 +630,6 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tokenErrMsg: if msg.stopped { - // Refreshing gave up. Whatever is on screen will start failing, so - // say so rather than letting it look like the device is at fault. if m.tokenDead == "" { m.tokenDead = "token expired — sign in again" } @@ -786,8 +687,6 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tokenRefreshedMsg: if msg.err != nil { if errors.Is(msg.err, saseauth.ErrInteractiveRequired) { - // The stored credential is spent, so the quiet path cannot - // finish; fall through to the browser. m.tokenDead = "sign-in expired" m.signInResume = false return m.startSignIn() @@ -799,8 +698,6 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.tokenTTL = msg.ttl m.tokenDead = "" m.status = "token refreshed" - // The old client holds the previous token, so let the next action build - // one with the new. m.pool.drop(m.tenant.TSGID) m.client = nil return m, nil @@ -819,8 +716,6 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.tokenDead = "" watch := watchToken(msg.client.TokenErrors()) - // Something was interrupted to get here, so go straight back to it - // rather than making the user acknowledge a screen first. if targets := m.resumeSessions; len(targets) > 0 { m.resumeSessions = nil next, cmd := m.openSessions(targets) @@ -841,8 +736,6 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.profile = msg.profile m.tokenTTL = msg.ttl m.phase = phaseSignedIn - // Elements are fetched behind the confirmation, so dismissing it lands on - // a list that is already current. next, cmd := m.refreshActive() return next, tea.Batch(m.spinner.Tick, watch, cmd) @@ -881,8 +774,6 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.tenantErr(msg.tsg, m.tenantName(msg.tsg), msg.err) } else { m.pool.set(msg.tsg, msg.client) - // Only the browsed tenant was watched, so a background tenant's - // token died unnoticed until a dial came back 401. if msg.fresh { watch = watchToken(msg.client.TokenErrors()) } @@ -890,7 +781,6 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.connectWait > 0 { return m, watch } - // Everything that was waiting on a connection can go ahead now. if cmds := m.resumeRun; len(cmds) > 0 { m.resumeRun = nil next, cmd := m.startRun(cmds) @@ -927,14 +817,9 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.sites[k] = v } m.mergeTenantElements(msg.tsg, msg.tenant, msg.elements) - // Behind the list, not in front of it: HA is a request per - // cluster, and the rows are what someone is looking for. - // Started on the clustered elements, not on the (site, cluster) - // pairs: the aggregate asks by element and needs no site, so - // gating the whole sweep on a site id -- which the fallback alone - // needs -- could leave a fleet on "loading" for ever. if w := haClusters(msg.elements); len(w.elements) > 0 { m.haTotal += w.clusters() + m.markHAOut(msg.tsg) ha = loadHABulk(m.ctx, m.pool.get(msg.tsg), msg.tsg, w) } } @@ -957,6 +842,7 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if !m.wantsSpinner() { return m, nil } + m.spinTick++ var cmd tea.Cmd m.spinner, cmd = m.spinner.Update(msg) return m, cmd @@ -985,8 +871,6 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.dialFailed(msg.target.eid, msg.err) m.status = msg.err m.rebuildRows() - // Every dial has already been retried, so the only thing left to offer - // is trying the whole thing again. if msg.target.eid != "" { m.askRetry = msg.target return m, nil @@ -997,8 +881,6 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case sessionDirtyMsg: - // A shell the user left with "exit" closes its own tab rather than - // sitting there showing an error for something they asked for. if n := m.sessions.Reap(); n > 0 && m.phase == phaseSession && m.sessions.Count() == 0 { m.status = "" m.phase = phasePicking @@ -1055,10 +937,6 @@ func (m tuiModel) forwardToInput(msg tea.Msg) (tea.Model, tea.Cmd) { if len(m.form) == 0 || m.formCursor >= len(m.form) { return m, nil } - // A block of commands pasted into the macro form is one per line, - // which is the shape the form already has. The field the cursor is in - // takes the first, so pasting into the empty line at the bottom - // appends rather than overwriting what is above it. if p, ok := msg.(tea.PasteMsg); ok && m.formKind == formMacro && m.formCursor >= 1 { if cmds := pasteCommands(p.Content); len(cmds) > 1 { return m.pasteMacroCommands(cmds) @@ -1080,9 +958,6 @@ func (m tuiModel) forwardToInput(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd case phaseCommand: - // The box is comma separated, so a multi-line paste is a list of - // commands. Rewritten before the input sees it: textinput replaces a - // newline with a space, which would make one long command of the lot. if p, ok := msg.(tea.PasteMsg); ok { if cmds := pasteCommands(p.Content); len(cmds) > 1 { msg = tea.PasteMsg{Content: strings.Join(cmds, ", ")} @@ -1125,18 +1000,10 @@ func (m tuiModel) wantsSpinner() bool { case phaseLoading, phaseRunning, phaseSettings, phaseSignIn, phaseDiscover: return true case phasePicking: - // Only while a refresh is actually in flight, so the list is not - // repainted several times a second for nothing. return m.cacheState == cacheUpdating case phaseSession: - // A session whose socket is up but whose device has not spoken is - // still moving: the bar spins on "waiting for the ion" until the - // first byte or the handshake deadline. return m.connectingAny() || m.awaitingDevice() case phaseData: - // Only while a pane is actually reading, or it repaints for nothing. - // A comparison counts: it is a dial and a login, and the slowest - // thing the mode does. for _, t := range m.data { if t.loading() || t.cmp.loading { return true @@ -1166,25 +1033,16 @@ func (m *tuiModel) setElements(els []elementRow) { m.applyFilter() } -// siteName looks up a site within its own tenant, since two tenants can use the -// same site identifier for different places. func (m *tuiModel) siteName(el elementRow) string { return m.record(el).SiteLabel() } -// record flattens a row into the values --search matches against, so the -// filter here and the one on the command line cannot disagree. func (m *tuiModel) record(el elementRow) search.Record { return search.Of(el.Element, el.TSG, el.Tenant, search.SiteMap(m.sites)) } -// showTenantColumn is true once more than one tenant is loaded; with a single -// tenant the column is just noise. func (m tuiModel) showTenantColumn() bool { return m.tenantCount > 1 } -// showHAColumn keeps the column off a list with no clusters in it, the way -// the tenant column stays off a single-tenant list: most fleets are all HA or -// none, and an empty column is paid for in width on every row. func (m tuiModel) showHAColumn() bool { for _, r := range m.all { if r.ClusterID() != "" { @@ -1203,38 +1061,27 @@ func (m tuiModel) showSerialColumn() bool { return false } -// elementColumn is one column of the element list: how wide it would like to -// be, and how narrow it will go. Sizing to content is why site stopped being -// 22 columns wide for names like "Store 0003" while a serial was cut off at -// 16 -- and why adding sn no longer pushes state off an 80-column terminal. type elementColumn struct { title string min int max int - // flex is false for a column whose content is a known width: the marks, - // the state words, the HA words. Those are never shaved. - flex bool + flex bool } -// The order asked for: element · site · model · sn · state · ha. The serial -// sits with the model as part of what the box is; state and HA are the pair -// that change. var elementColumns = []elementColumn{ + {title: "", min: 2, max: 2}, {title: "", min: 3, max: 3}, - {title: "", min: 3, max: 3}, - {title: "element", min: 12, max: 28, flex: true}, - {title: "site", min: 8, max: 22, flex: true}, - {title: "model", min: 8, max: 11, flex: true}, - {title: "sn", min: 10, max: 24, flex: true}, - {title: "state", min: 9, max: 9}, - {title: "ha", min: 8, max: 8}, - {title: "tenant", min: 8, max: 20, flex: true}, + {title: "Element", min: 14, max: 30, flex: true}, + {title: "Site", min: 8, max: 22, flex: true}, + {title: "Model", min: 8, max: 11, flex: true}, + {title: "SN", min: 10, max: 24, flex: true}, + {title: "State", min: 9, max: 9}, + {title: "HA", min: 8, max: 8}, + {title: "Tenant", min: 8, max: 20, flex: true}, } -// Which of the optional columns are in this list, by index into -// elementColumns. const ( - colMark = iota + colCaret = iota colStatus colName colSite @@ -1245,14 +1092,8 @@ const ( colTenant ) -// colGap is what the table puts either side of a cell, which has to be -// counted or the last column is the one that falls off the terminal. const colGap = 2 -// fitColumns sizes each column to its widest value, capped at its max, then -// shaves the flexible ones -- widest first, never below their min -- until the -// set fits the terminal. Widest first because the cost of a column losing two -// of its twenty columns is nothing next to one losing two of its ten. func fitColumns(cols []elementColumn, values [][]string, avail int) []int { w := make([]int, len(cols)) for i, c := range cols { @@ -1267,7 +1108,6 @@ func fitColumns(cols []elementColumn, values [][]string, avail int) []int { return shaveColumns(cols, w, avail) } -// shaveColumns is the second half, for a caller that measured as it built. func shaveColumns(cols []elementColumn, w []int, avail int) []int { total := func() int { n := 0 @@ -1284,7 +1124,7 @@ func shaveColumns(cols []elementColumn, w []int, avail int) []int { } } if at < 0 { - break // nothing left to give: the frame clips the rest + break } w[at]-- } @@ -1296,7 +1136,7 @@ func (m *tuiModel) rebuildRows() { ha := m.showHAColumn() serials := m.showSerialColumn() - keep := []int{colMark, colStatus, colName, colSite, colModel} + keep := []int{colCaret, colStatus, colName, colSite, colModel} if serials { keep = append(keep, colSerial) } @@ -1315,11 +1155,6 @@ func (m *tuiModel) rebuildRows() { widths[j] = max(elementColumns[c].min, ui.Width(elementColumns[c].title)) } - // Values and widths in one pass, plain, so the columns can be sized to - // what is in them before anything is truncated or coloured -- and so a - // cursor move can repaint one row without working the whole list out - // again. This used to build a map per row, which on a fleet is an - // allocation per element per keystroke of a search. values := make([][]string, 0, len(m.matches)) for _, i := range m.matches { el := m.all[i] @@ -1340,9 +1175,6 @@ func (m *tuiModel) rebuildRows() { m.listValues = values m.listWidths = shaveColumns(cols, widths, max(20, m.width)) - // Painted only where the table will render: it draws a window around the - // cursor and nothing else, so styling the other five thousand rows is work - // nobody sees -- and it was happening on every keystroke of a search. rows := make([]table.Row, len(values)) lo, hi := m.paintWindow(len(values)) for n := range values { @@ -1360,16 +1192,12 @@ func (m *tuiModel) rebuildRows() { total += m.listWidths[i] + colGap } - // Columns and rows have to agree at every moment: the table re-renders - // inside each setter, so changing the column count while the old rows are - // still in place indexes past the end of them. Clearing the rows first is - // safe but resets the cursor, so only do it when the shape really changed. if len(m.table.Columns()) != len(tcols) { at := m.table.Cursor() m.table.SetRows(nil) m.table.SetColumns(tcols) m.table.SetRows(rows) - if at > 0 && at < len(rows) { + if at >= 0 && at < len(rows) { m.table.SetCursor(at) } } else { @@ -1380,21 +1208,26 @@ func (m *tuiModel) rebuildRows() { m.table.SetHeight(m.listHeight()) } -// cellValue is one cell, plain. A switch rather than a map: this runs for -// every column of every matched row on every keystroke of a search, and a map -// literal per row is an allocation per element. +const selectedMark = "▌ " + +func splitMark(cell string) (mark, name string) { + r := []rune(cell) + if len(r) < 2 { + return "", cell + } + return string(r[:2]), string(r[2:]) +} + func (m *tuiModel) cellValue(el elementRow, col int) string { switch col { - case colMark: - if m.selected[el.key()] { - return "x" - } - return " " case colStatus: glyph, _ := m.rowStatusOf(el) return glyph case colName: - return el.label() + if m.selected[el.key()] { + return selectedMark + el.label() + } + return " " + el.label() case colSite: return m.siteName(el) case colModel: @@ -1409,7 +1242,6 @@ func (m *tuiModel) cellValue(el elementRow, col int) string { case colHA: word, cached := m.haState(el) if cached { - // The asterisk is part of the text, so it is measured with it. return word + "*" } return word @@ -1419,37 +1251,38 @@ func (m *tuiModel) cellValue(el elementRow, col int) string { return "" } -// listRow paints one row from the values rebuildRows worked out. The row under -// the cursor is painted plain: the table paints its highlight around the whole -// row, and a highlight wrapped around coloured cells ends at the first reset -// inside them -- so on a green line the cursor would all but disappear. One -// rule: the cursor row is the highlight, every other row carries its own -// colour. func (m *tuiModel) listRow(n int) table.Row { vals := m.listValues[n] el := m.all[m.matches[n]] here := n == m.table.Cursor() - // A connected element reads green across the whole line, so a fleet's - // state is scannable without reading one column; offline keeps red on the - // state alone, since red across a row reads as an error rather than a - // fact about the appliance. paint := func(s string) string { return s } if el.Connected && !here { paint = func(s string) string { return ui.StyleOK.Render(s) } } + ticked := m.selected[el.key()] row := make(table.Row, 0, len(vals)) for i, c := range m.listKeep { v := ui.Truncate(vals[i], m.listWidths[i]) switch c { + case colCaret: + if here { + v = ">" + } + case colName: + if ticked && !here { + mark, name := splitMark(v) + v = ui.StyleRow.Render(mark) + ui.StyleBold.Render(paint(name)) + row = append(row, v) + continue + } + v = paint(v) case colStatus, colHA: - // Already styled, and styled per state rather than per row. v = plainIf(here, v) case colState: switch { case here: - // The highlight is this row's colour. case el.Connected: v = paint(v) default: @@ -1463,8 +1296,6 @@ func (m *tuiModel) listRow(n int) table.Row { return row } -// listRowPlain is the same row with no styling, for the rows outside the -// window: they are never rendered, so painting them is wasted work. func (m *tuiModel) listRowPlain(n int) table.Row { vals := m.listValues[n] row := make(table.Row, len(vals)) @@ -1474,9 +1305,6 @@ func (m *tuiModel) listRowPlain(n int) table.Row { return row } -// paintWindow is the rows the table can render: it draws from cursor-height to -// cursor+height, so a margin either side of that is everything that can be -// seen. func (m *tuiModel) paintWindow(n int) (int, int) { return m.windowAround(m.table.Cursor(), n) } @@ -1486,20 +1314,11 @@ func (m *tuiModel) windowAround(at, n int) (int, int) { return max(0, at-h), min(n, at+h) } -// repaintCursorRows paints the two rows a move touched, instead of the whole -// list. Rebuilding every row to move a highlight one line is what made a held -// key and a wheel notch feel stuck on a fleet: the work was proportional to -// the list, not to what changed. func (m *tuiModel) repaintCursorRows(prev int) { rows := m.table.Rows() if len(rows) != len(m.listValues) { - // The list changed under us; the next rebuild will paint it. return } - // The window the cursor left goes back to plain and the one it moved into - // is painted -- and only those two ranges are touched. Walking the whole - // slice to find them was O(fleet) on a keypress, which is the mistake - // this file has now made twice. lo, hi := m.paintWindow(len(rows)) plo, phi := m.windowAround(prev, len(rows)) for i := min(lo, plo); i < max(hi, phi); i++ { @@ -1559,14 +1378,10 @@ func (m tuiModel) onKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { return m, tea.Quit } - // The overlay belongs to the frame rather than to any one page, and the - // session pane would otherwise type this at the device. if key == "alt+i" { return m.toggleStats() } - // Both overlays own the keyboard while they are up, the macro warning - // first: only one can be open, and it is the one that was just asked. if m.askMacro != "" { return m.onMacroWarnKey(key) } @@ -1607,7 +1422,6 @@ func (m tuiModel) onKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { } return m, nil case phaseSignIn: - // The browser owns the keyboard until it is done; esc only stops waiting. if key == "esc" || key == "q" { m.signInResume = false m.status = "sign-in cancelled" @@ -1634,8 +1448,6 @@ func (m tuiModel) onKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { case phaseCommand: return m.onCommandKey(msg, key) case phaseRunning: - // q used to quit here, which made the key that leaves every other page - // the one that abandons a run mid-flight. ctrl+c does that now. switch key { case "esc", "q", "tab", "/": if m.completedCount() > 0 { @@ -1655,7 +1467,7 @@ func (m tuiModel) onPickKey(msg tea.KeyPressMsg, key string) (tea.Model, tea.Cmd switch key { case "esc", "q": if key == "q" && m.filterOn { - break // a letter while the filter is being typed + break } if m.filterOn || m.filter.Value() != "" { m.filterOn = false @@ -1682,11 +1494,6 @@ func (m tuiModel) onPickKey(msg tea.KeyPressMsg, key string) (tea.Model, tea.Cmd m.moveCursor(step) return m, nil - // A jump through a long list, between one row and a whole page. The step - // is a setting because the right size depends on the list: ten is a lot - // in a lab and nothing in a fleet of thousands. - // alt+k and alt+j are the same jump: hjkl is how the rest of the list - // moves, so the chorded version of it should be too. case "alt+up", "alt+k": m.moveCursor(-m.pageStep()) return m, nil @@ -1695,10 +1502,6 @@ func (m tuiModel) onPickKey(msg tea.KeyPressMsg, key string) (tea.Model, tea.Cmd return m, nil case "k", "j", "home", "end": - // These move the cursor only when the query is not being typed. A - // search for a "j" in a hostname has to reach the input, and home/end - // belong to the text while it has focus; the arrows above stay with - // the table either way, so a narrowed list is still navigable mid-query. if !m.filterOn { switch key { case "k": @@ -1719,9 +1522,6 @@ func (m tuiModel) onPickKey(msg tea.KeyPressMsg, key string) (tea.Model, tea.Cmd return m, nil } n := len(m.selected) - // Kept so alt+z can put it back. The map is copied rather than - // aliased: the model is passed by value and the live selection is - // about to be replaced. m.lastCleared = make(map[string]bool, n) for k := range m.selected { m.lastCleared[k] = true @@ -1729,8 +1529,6 @@ func (m tuiModel) onPickKey(msg tea.KeyPressMsg, key string) (tea.Model, tea.Cmd m.lastClearedView = m.showSelected m.selected = map[string]bool{} - // Showing only the selection with nothing selected would be an empty - // list and no way back, so that view is dropped too. m.showSelected = false m.applyFilter() m.status = fmt.Sprintf("cleared %d selected — alt+z to undo", n) @@ -1741,8 +1539,6 @@ func (m tuiModel) onPickKey(msg tea.KeyPressMsg, key string) (tea.Model, tea.Cmd m.status = "nothing to undo" return m, nil } - // Anything selected since the clear is kept: undo restores, it does - // not replace. for k := range m.lastCleared { m.selected[k] = true } @@ -1765,9 +1561,6 @@ func (m tuiModel) onPickKey(msg tea.KeyPressMsg, key string) (tea.Model, tea.Cmd return m, nil case "space": - // Filtering and selecting are separate modes: while the query is being - // typed every space belongs to it, and selection resumes once the - // filter is applied with enter. if !m.filterOn { return m.toggleCurrent(), nil } @@ -1775,14 +1568,9 @@ func (m tuiModel) onPickKey(msg tea.KeyPressMsg, key string) (tea.Model, tea.Cmd case "alt+r": return m.refreshNow() - case "alt+y": - return m.copyElements() - case "alt+u": m.userCursor = 0 m.status = "" - // Open on the tenant of whatever is highlighted, which is the one the - // user is looking at; the browsed tenant is the fallback. m.usersTSG = m.tenant.TSGID if el, ok := m.current(); ok { m.usersTSG = el.TSG @@ -1790,10 +1578,8 @@ func (m tuiModel) onPickKey(msg tea.KeyPressMsg, key string) (tea.Model, tea.Cmd m.phase = phaseUsers return m, nil - case "alt+m": + case "alt+m", "alt+a": m.mode = m.mode.next() - // Cycling out of macro mode puts the macro down: the list would - // otherwise still be holding one with nothing saying so. m.armedMacro = "" m.status = "" return m, nil @@ -1821,10 +1607,6 @@ func (m tuiModel) onPickKey(msg tea.KeyPressMsg, key string) (tea.Model, tea.Cmd m.filterOn = false m.filter.Blur() - // A query that has narrowed to a single ION is unambiguous, so - // enter acts on it instead of only applying the filter. In shell - // mode that means going straight in; in batch mode it means - // selecting it and staying, since a command still has to be chosen. if len(m.matches) == 1 { el := m.all[m.matches[0]] if m.mode == modeBatch || m.mode == modeMacro { @@ -1838,17 +1620,12 @@ func (m tuiModel) onPickKey(msg tea.KeyPressMsg, key string) (tea.Model, tea.Cmd }) } - // Otherwise the query is applied and the narrowed list selected from. return m, nil } return m.onPickEnter() } if m.filterOn { - // Backspace on an **already** empty query is what leaves: the last - // press deletes the "/" itself. Checked before the input sees the key, - // because doing it after meant the press that emptied the query was - // also the one that left -- one too early to carry on typing. if key == "backspace" && m.filter.Value() == "" { m.filterOn = false m.filter.Blur() @@ -1858,9 +1635,6 @@ func (m tuiModel) onPickKey(msg tea.KeyPressMsg, key string) (tea.Model, tea.Cmd var cmd tea.Cmd m.filter, cmd = m.filter.Update(msg) m.applyFilter() - // The rows a search just brought on screen are asked about now rather - // than when the background fill reaches them. That is what the pacing - // leaves room for. return m, tea.Batch(cmd, m.askVisibleHA()) } @@ -1868,10 +1642,6 @@ func (m tuiModel) onPickKey(msg tea.KeyPressMsg, key string) (tea.Model, tea.Cmd return m.reopenLastTab() } if key == "alt+w" { - // alt+w closes what you are in. On the element list that is the list - // itself, so it goes back where the list was opened from; only the - // menu's alt+w quits. Any sessions keep running and are still - // reachable with alt+t. m.status = "" m.phase = m.listReturn() return m, nil @@ -1882,6 +1652,10 @@ func (m tuiModel) onPickKey(msg tea.KeyPressMsg, key string) (tea.Model, tea.Cmd switch key { case "/": + if m.filter.Value() != "" { + m.filter.SetValue("") + m.applyFilter() + } m.filterOn = true return m, m.filter.Focus() case "ctrl+]": @@ -1896,8 +1670,6 @@ func (m tuiModel) onPickKey(msg tea.KeyPressMsg, key string) (tea.Model, tea.Cmd return m, cmd } -// pickTargets is what enter acts on: everything selected, or the highlighted -// row when nothing is. func (m tuiModel) pickTargets() []sessionTarget { var out []sessionTarget for _, el := range m.all { @@ -1919,30 +1691,19 @@ func (m tuiModel) onPickEnter() (tea.Model, tea.Cmd) { return m.onPickTargets(m.pickTargets()) } -// onPickTargets acts on an explicit set of elements, running the per-tenant -// credential and connection checks first. Separate from onPickEnter so a search -// that narrows to a single ION can act on that one rather than on whatever -// happens to be selected. func (m tuiModel) onPickTargets(targets []sessionTarget) (tea.Model, tea.Cmd) { if len(targets) == 0 { return m, nil } - // An explicit action is a fresh attempt, so an earlier connection failure - // stops blocking the tenants it affected. for _, want := range targets { delete(m.tenantIssues, want.tsg) } - // Rich-data mode reads the portal and never opens a session, so it comes - // before the device-login gate: asking for an ION password to look at - // portal data would be asking for a credential the answer does not need. if m.mode == modeData { return m.openDataTabs(targets) } - // A selection can span tenants, and each keeps its own device login, so - // every tenant involved has to be asked about rather than only the first. for _, want := range targets { t, ok := m.tenantOf(want.tsg) if !ok { @@ -1962,8 +1723,6 @@ func (m tuiModel) onPickTargets(targets []sessionTarget) (tea.Model, tea.Cmd) { if m.mode == modeShell { return m.openSessions(targets) } - // Macro mode is batch mode with the commands already chosen, so it skips - // the command box and runs. if m.mode == modeMacro { return m.runArmedMacro(m.rowsFor(targets)) } @@ -2003,9 +1762,6 @@ func (m tuiModel) onCommandKey(msg tea.KeyPressMsg, key string) (tea.Model, tea. return m, cmd } -// rowsFor maps what enter acted on back to element rows, which is what a run -// needs. The highlighted row when nothing is ticked is a target like any -// other, so a macro can be run on one element without selecting it first. func (m tuiModel) rowsFor(targets []sessionTarget) []elementRow { out := make([]elementRow, 0, len(targets)) for _, t := range targets { @@ -2016,8 +1772,6 @@ func (m tuiModel) rowsFor(targets []sessionTarget) []elementRow { return out } -// selectedRows is what a run acts on: the elements ticked in the list, with -// their IDs in the same order. func (m tuiModel) selectedRows() ([]elementRow, []string) { var picked []elementRow var ids []string @@ -2035,11 +1789,7 @@ func (m tuiModel) startRun(cmds []string) (tea.Model, tea.Cmd) { return m.startRunOn(cmds, picked, ids) } -// startRunOn is the run itself, against rows the caller chose. Macro mode -// passes what enter acted on, which may be the highlighted row rather than -// the ticked selection. func (m tuiModel) startRunOn(cmds []string, picked []elementRow, ids []string) (tea.Model, tea.Cmd) { - if !app.Defaults.AllowWrites && !m.cfg.AllowWrites { if err := ioncli.CheckReadOnly(cmds); err != nil { m.status = err.Error() @@ -2047,9 +1797,6 @@ func (m tuiModel) startRunOn(cmds []string, picked []elementRow, ids []string) ( } } - // Credentials are resolved per element, since a run can span tenants. The - // flags still carry one pair so the shared validation has something to - // check, but it is only a fallback for targets that supply none. targets := m.targetsFor(picked) for i, t := range targets { if t.User == "" || t.Pass == "" { @@ -2129,7 +1876,7 @@ func (m tuiModel) current() (elementRow, bool) { func (m tuiModel) total() int { return len(m.ran) } func (m tuiModel) listHeight() int { - h := m.height - 11 - m.statsRows() + h := m.height - 13 - m.statsRows() if m.sessions.Count() > 0 { h-- } @@ -2187,6 +1934,7 @@ func (m tuiModel) View() tea.View { v.Content = ui.ClipFrame(v.Content, m.height) v.AltScreen = true v.MouseMode = m.mouseMode() + v.BackgroundColor, v.ForegroundColor = frameColours() return v } @@ -2198,7 +1946,8 @@ func (m tuiModel) viewPicking() string { } b.WriteString(ui.StyleCmd.Render("iontk") + " " + m.modeLabel() + ui.StyleDim.Render(" "+m.scopeLabel()+" · "+m.countLabel()) + - m.selectedNote() + m.refreshNote() + "\n") + m.selectedNote() + "\n") + b.WriteString(m.refreshNote() + "\n") if m.filterOn || m.filter.Value() != "" { line := m.filter.View() @@ -2234,8 +1983,6 @@ func (m tuiModel) viewPicking() string { case modeMacro: act = fmt.Sprintf("enter run %s on %d", m.armedMacro, n) } - // While typing a query the only keys that matter are the ones that end - // it, so the hint says just those. clear := "alt+c clear" if len(m.lastCleared) > 0 { clear = "alt+z undo clear" @@ -2244,13 +1991,11 @@ func (m tuiModel) viewPicking() string { if m.listReturn() == phaseMacros { back = "macros" } - hint := fmt.Sprintf("↑↓/jk move · / filter · space select · alt+s selected · %s · alt+r refresh · alt+m mode · %s id · %s · esc/q/alt+w %s", clear, copyKeyHint, act, back) + hint := fmt.Sprintf("↑↓/jk move · / filter · space select · alt+s selected · %s · alt+r refresh · alt+m/alt+a mode · %s · esc/q/alt+w %s", clear, act, back) if m.showSelected { hint = fmt.Sprintf("↑↓/jk move · alt+s all · alt+c clear · space unselect · %s · esc/q/alt+w %s", act, back) } if m.filterOn { - // With one match left, enter does the thing rather than just - // applying the query, so the hint says which. enter := "enter apply" if len(m.matches) == 1 { enter = "enter open" @@ -2334,8 +2079,6 @@ func (m tuiModel) viewRunning() string { return b.String() } -// plainIf strips a cell's own styling on the row the cursor is on, so the -// table's highlight is the only thing colouring it. func plainIf(here bool, cell string) string { if here { return ui.Plain(cell) diff --git a/internal/tui/tuimenu.go b/internal/tui/tuimenu.go index fe7adeb..f1b7fe6 100644 --- a/internal/tui/tuimenu.go +++ b/internal/tui/tuimenu.go @@ -2,6 +2,7 @@ package tui import ( "fmt" + "slices" "strconv" "strings" "time" @@ -37,28 +38,22 @@ func (m tuiModel) menuEntries() []menuEntry { return []menuEntry{{"Quit", actQuit}} } - // One entry for the list, because shell versus batch is a property of the - // list rather than a different thing to start. - e = append(e, menuEntry{"elements", actElements}) + e = append(e, menuEntry{"Elements", actElements}) - // What follows depends on where tenants come from, which is the credential - // mode and a separate axis from shell versus batch. switch { case m.authMode == authTenant: - // Named for what it lists: a service account reaches one tenant each, - // so this is the saved accounts rather than every tenant. - e = append(e, menuEntry{"service accounts", actSwitch}) + e = append(e, menuEntry{"Service Accounts", actSwitch}) case m.signedIn(): e = append(e, - menuEntry{"browser tenants", actSignIn}, - menuEntry{"refresh browser token", actRefreshToken}, + menuEntry{"Browser Tenants", actSignIn}, + menuEntry{"Refresh Browser Token", actRefreshToken}, ) default: - e = append(e, menuEntry{"sign in with browser", actSignIn}) + e = append(e, menuEntry{"Sign In with Browser", actSignIn}) } e = append(e, - menuEntry{"macros", actMacros}, + menuEntry{"Macros", actMacros}, menuEntry{"Settings", actSettings}, menuEntry{"Quit", actQuit}, ) @@ -81,8 +76,6 @@ func (m tuiModel) cacheLine() string { return ui.StyleDim.Render("no cached elements") } - // cacheUpdated and cacheCached differ only in how recent the fetch was, and - // an "updated" that has since gone stale must stop claiming to be current. d := m.cacheAgeNow() if m.cacheState == cacheUpdated && d < cacheFresh { line := ui.StyleOK.Render(fmt.Sprintf("%d elements cached (updated)", m.cacheCount)) @@ -114,8 +107,6 @@ func (m tuiModel) onMenuKey(key string) (tea.Model, tea.Cmd) { case "alt+t": return m.reopenFromMenu() case "alt+m", "alt+a": - // The only mode the menu has. Shell versus batch belongs to the element - // list, and switching it from here changed nothing you could see. m.authMode = m.authMode.next() m.cfg.AuthMode = m.authMode.String() if err := m.store.SaveConfig(m.cfg); err != nil { @@ -137,9 +128,6 @@ func (m tuiModel) onMenuKey(key string) (tea.Model, tea.Cmd) { } switch entries[m.menuCursor].action { case actElements: - // Macro mode is only reachable by picking a macro, so the list - // opened from here is never in it: it would otherwise still be - // holding one from earlier in the session. if m.mode == modeMacro { m.mode = modeShell if saved, ok := parseMode(m.cfg.DefaultMode); ok { @@ -158,8 +146,6 @@ func (m tuiModel) onMenuKey(key string) (tea.Model, tea.Cmd) { case actSignIn: m.switchCursor = 0 m.status = "" - // A list already discovered is shown straight away; r re-lists it. - // Only an account that has never signed in needs the browser now. if len(m.browserTenants()) > 0 { m.phase = phaseSwitch return m, nil @@ -205,7 +191,11 @@ func (m tuiModel) startContinue() (tea.Model, tea.Cmd) { } func (m tuiModel) onSwitchKey(key string) (tea.Model, tea.Cmd) { - choices := m.switchChoices() + armed := m.switchDel + if key != "x" { + m.switchDel = "" + } + switch key { case "esc", "q": m.phase = phaseMenu @@ -213,22 +203,27 @@ func (m tuiModel) onSwitchKey(key string) (tea.Model, tea.Cmd) { case "up", "k": m.switchCursor = max(0, m.switchCursor-1) case "down", "j": - m.switchCursor = min(len(choices)-1, m.switchCursor+1) + m.switchCursor = min(m.switchRows()-1, m.switchCursor+1) + + case "x": + return m.armOrForgetAccount(armed) + case "u": + return m.undoForgetAccount() case "alt+r": if m.authMode == authTenant { - // There is nothing to ask: a service account is one tenant, and its - // record is the only thing that says the tenant exists. m.status = "service accounts are added in settings, not discovered" return m, nil } - // Re-ask the account what it can reach, in case tenants were added or - // revoked since the last sign-in. return m.startDiscover() case "enter": - if m.switchCursor >= len(choices) { + c, ok := m.switchAt(m.switchCursor) + if !ok { + if m.authMode == authTenant { + return m.openTenantForm(config.AuthServiceAccount) + } return m, nil } - next, err := m.useTenant(choices[m.switchCursor]) + next, err := m.useTenant(c) if err != nil { m.status = err.Error() return m, nil @@ -236,8 +231,6 @@ func (m tuiModel) onSwitchKey(key string) (tea.Model, tea.Cmd) { m = next m.menuCursor = 0 m.phase = phaseMenu - // useTenant decides whether the new tenant owes a fetch; something has - // to actually start it, or the cache line claims to be updating forever. if m.cacheState == cacheUpdating { return m.refreshNow() } @@ -272,9 +265,6 @@ func (m tuiModel) openForm(kind formKind, title string, phase tuiPhase, fields [ return m, m.form[0].input.Focus() } -// openTenantForm adds a service account. A browser account is never typed in: -// signing in reveals its tenants, so this form exists only for the credential -// that cannot be discovered. func (m tuiModel) openTenantForm(auth string) (tea.Model, tea.Cmd) { m.formAuth = auth fields := []formField{ @@ -290,9 +280,6 @@ func (m tuiModel) openTenantForm(auth string) (tea.Model, tea.Cmd) { return m.openForm(formTenant, "Add service account", phaseAddTenant, fields) } -// openUserFormFor adds a device login to a named tenant. The tenant ID travels -// with it: a tenant the account can reach may have no record yet, and saving -// has to be able to create one rather than failing. func (m tuiModel) openUserFormFor(t config.Tenant) (tea.Model, tea.Cmd) { m.formTenantName = t.Name m.formTenantTSG = t.TSGID @@ -306,7 +293,6 @@ func (m tuiModel) openEditPasswordForm(user string) (tea.Model, tea.Cmd) { return m.openEditPasswordFormFor(m.tenant, user) } -// openEditPasswordFormFor changes a password for one named tenant's user. func (m tuiModel) openEditPasswordFormFor(t config.Tenant, user string) (tea.Model, tea.Cmd) { m.formTenantName = t.Name m.formTenantTSG = t.TSGID @@ -316,9 +302,6 @@ func (m tuiModel) openEditPasswordFormFor(t config.Tenant, user string) (tea.Mod }) } -// openPasswordForm asks for a device login for one named tenant. The tenant is -// explicit because the element being opened may belong to a different tenant -// than the one being browsed, and its credentials are stored separately. func (m tuiModel) openPasswordForm(t config.Tenant, user string) (tea.Model, tea.Cmd) { m.formTenantName = t.Name m.formTenantTSG = t.TSGID @@ -346,9 +329,6 @@ func (m tuiModel) onFormKey(msg tea.KeyPressMsg, key string) (tea.Model, tea.Cmd return m, nil case "tab", "down": - // A macro is as many commands as it takes, so tab on the last line - // adds the next one. Only with something on that line: growing on - // every tab would leave empty lines behind to walk through. if m.formKind == formMacro && m.formCursor == len(m.form)-1 && m.formValue(m.formCursor) != "" { return m.addMacroCommand() } @@ -365,9 +345,6 @@ func (m tuiModel) onFormKey(msg tea.KeyPressMsg, key string) (tea.Model, tea.Cmd return m.submitForm() case "backspace": - // On an empty command line, the key that would have emptied it takes - // the line out instead. The last one stays: the form always has - // somewhere to type. if m.formKind == formMacro && m.formCursor >= 1 && len(m.form) > 2 && m.formValue(m.formCursor) == "" { return m.removeMacroCommand() } @@ -387,7 +364,6 @@ func (m tuiModel) formValue(i int) string { func (m tuiModel) submitForm() (tea.Model, tea.Cmd) { switch m.formKind { - case formBatchDir: dir, err := output.ExpandHome(m.formValue(0)) if err != nil { @@ -440,8 +416,6 @@ func (m tuiModel) submitForm() (tea.Model, tea.Cmd) { } t, ok := m.cfg.Tenant(m.formTenantName) if !ok { - // A discovered tenant has no record until it is worked in, and - // giving it a login is exactly that. if m.formTenantTSG == "" { m.status = "tenant " + m.formTenantName + " is gone" return m, nil @@ -464,8 +438,6 @@ func (m tuiModel) submitForm() (tea.Model, tea.Cmd) { return m, nil } } - // A tenant's first user becomes what it runs as, so adding one is - // enough to make that tenant usable. if m.runAsFor(t.TSGID) == "" || len(t.Users) == 1 { m.setRunAs(t.TSGID, user) } @@ -585,6 +557,9 @@ func (m tuiModel) submitForm() (tea.Model, tea.Cmd) { case formColour: return m.submitColour() + case formNewTheme: + return m.submitNewTheme() + case formFavourite: return m.submitFavourite() @@ -643,8 +618,6 @@ func (m tuiModel) submitForm() (tea.Model, tea.Cmd) { return m, nil } - // The credential belongs to the tenant that was asked about, which is - // not necessarily the one being browsed. t, ok := m.cfg.Tenant(m.formTenantName) if !ok { t = config.Tenant{Name: m.formTenantName, TSGID: m.formTenantTSG, Auth: config.AuthBrowser, Added: true} @@ -662,8 +635,6 @@ func (m tuiModel) submitForm() (tea.Model, tea.Cmd) { m.status = err.Error() return m, nil } - // Only follow the change when it was the browsed tenant; otherwise the - // current tenant and its user are left alone. if t.Name == m.tenant.Name { m.tenant, m.ionUser = t, user } @@ -671,7 +642,6 @@ func (m tuiModel) submitForm() (tea.Model, tea.Cmd) { m.formUserName = "" m.status = "" m.phase = phasePicking - // Re-enter so any further tenant still missing a login is asked next. return m.onPickEnter() } @@ -690,7 +660,7 @@ func (m tuiModel) viewMenu() string { label := e.label if i == m.menuCursor { cursor = ui.StyleCmd.Render("> ") - label = ui.StyleElement.Render(label) + label = ui.StyleRow.Render(label) } b.WriteString(cursor + label + "\n") } @@ -704,9 +674,6 @@ func (m tuiModel) viewMenu() string { return b.String() } -// reopenFromMenu brings back the state the menu was left from. A reattached -// session sits behind tab 2 onwards, so the elements list that owns tab 1 is -// restored from cache first; otherwise reattaching lands on an empty list. func (m tuiModel) reopenFromMenu() (tea.Model, tea.Cmd) { if len(m.all) == 0 { m.loadFromCache() @@ -730,48 +697,52 @@ func (m tuiModel) viewSwitch() string { b.WriteString(ui.StyleCmd.Render(count) + "\n") if m.authMode == authTenant { - // No provenance line: these were typed in, not discovered, and the - // browser account that listed other tenants cannot reach them. - b.WriteString(ui.StyleDim.Render("one tenant each, added in settings") + "\n") + b.WriteString(ui.StyleDim.Render("one tenant each") + "\n") } else if c, err := m.store.TenantCache(); err == nil && c != nil && c.Account != "" { b.WriteString(ui.StyleDim.Render("from "+c.Account+", listed "+ui.Age(c.Age())+" ago") + "\n") } b.WriteString("\n") - if len(choices) == 0 { - empty := "none yet — add an account from the menu" - if m.authMode == authTenant { - empty = "none yet — add one in settings, or alt+m to sign in with a browser" + row := 0 + if m.authMode == authTenant { + const newRow = "+ New" + cursor := " " + if m.switchCursor == 0 { + cursor = ui.StyleCmd.Render("> ") } - b.WriteString(" " + ui.StyleDim.Render(empty) + "\n") + b.WriteString(cursor + ui.StyleGhost.Render(newRow) + "\n") + row = 1 + } + + if len(choices) == 0 && m.authMode != authTenant { + b.WriteString(" " + ui.StyleDim.Render("none yet — add an account from the menu") + "\n") } for i, c := range choices { cursor := " " marker := "" - if c.tsg == m.tenant.TSGID && m.hasTenant { + switch { + case c.tsg == m.tenant.TSGID && m.hasTenant: marker = ui.StyleOK.Render(" (current)") - } else if !c.known && c.saved { - // A tenant with no discovered counterpart is reachable only through - // its own saved credential. + if !c.saved { + marker += ui.StyleDim.Render(" from the environment") + } + case !c.known && c.saved: marker = ui.StyleDim.Render(" (service account)") } - // The whole row is padded as plain text and then coloured in one piece. - // Styling the name first would make its escape codes count towards the - // column width, which shifts the id from row to row. - row := fmt.Sprintf("%-30s %s", ui.Truncate(c.name, 30), c.tsg) - if i == m.switchCursor { + line := fmt.Sprintf("%-30s %s", ui.Truncate(c.name, 30), c.tsg) + if i+row == m.switchCursor { cursor = ui.StyleCmd.Render("> ") - row = ui.StyleElement.Render(row) + line = ui.StyleRow.Render(line) } else { - row = ui.StyleDim.Render(row) + line = ui.StyleDim.Render(line) } - b.WriteString(cursor + row + marker + "\n") + b.WriteString(cursor + line + marker + "\n") } b.WriteString("\n") hint := "↑↓ move · enter use · alt+r re-list · esc back" if m.authMode == authTenant { - hint = "↑↓ move · enter use · esc back" + hint = "↑↓ move · enter use · x forget · u undo · esc back" } b.WriteString(ui.Footer(m.status, hint)) return b.String() @@ -791,8 +762,6 @@ func (m tuiModel) viewForm() string { b.WriteString(" " + label + f.input.View() + "\n") } - // The macro form grows, and the row that says so is the only thing that - // tells you tab will add one. if m.formKind == formMacro { b.WriteString(" " + strings.Repeat(" ", 16) + ui.StyleGhost.Render("+ another command") + "\n") } @@ -810,8 +779,6 @@ func (m tuiModel) viewForm() string { return b.String() } -// plaintextWarning is shown on any form that takes a secret, before it is -// typed, whenever there is no OS keyring to put it in. func (m tuiModel) plaintextWarning() string { if m.store == nil || !config.Plaintext(m.store.Secrets) { return "" @@ -839,8 +806,6 @@ func (m tuiModel) tenantIndex() int { } func (m tuiModel) onUsersKey(key string) (tea.Model, tea.Cmd) { - // The panel manages one tenant at a time, chosen with left/right, because a - // browser account can reach tenants with completely different ION logins. t := m.usersPanelTenant() users := t.Users @@ -1010,7 +975,6 @@ func (m tuiModel) moveUserSecret(tsg, from, to string) { } } -// startSignIn shows the waiting screen and opens the browser. func (m tuiModel) startSignIn() (tea.Model, tea.Cmd) { if !m.hasTenant { m.status = "no tenant to sign in to" @@ -1034,10 +998,6 @@ func (m tuiModel) viewSignIn() string { return b.String() } -// viewSignedIn reports what a sign-in actually achieved. Each line is something -// that was checked, not something assumed: the identity comes from the tokens, -// the region and roles from a profile call that the API had to accept, and the -// session expiry from the refresh token itself. func (m tuiModel) viewSignedIn() string { var b strings.Builder b.WriteString(ui.Banner(m.width, m.height)) @@ -1085,3 +1045,55 @@ func (m tuiModel) viewSignedIn() string { b.WriteString(ui.Footer(m.status, "enter continue · esc menu")) return b.String() } + +func (m tuiModel) armOrForgetAccount(armed string) (tea.Model, tea.Cmd) { + if m.authMode != authTenant { + return m, nil + } + c, ok := m.switchAt(m.switchCursor) + if !ok { + return m, nil + } + t, saved := m.cfg.Tenant(c.name) + if !saved { + m.status = c.name + " has no saved record to forget" + return m, nil + } + + if !strings.EqualFold(armed, c.name) { + m.switchDel = c.name + m.status = "x again to forget " + c.name + " and its secret" + return m, nil + } + + if err := forgetTenant(m.store, m.cfg, t); err != nil { + m.status = err.Error() + return m, nil + } + m.switchUndo = append(slices.Clone(m.switchUndo), t) + if m.tenant.Name == t.Name { + m.tenant, m.hasTenant = app.ResolveTenant(m.cfg, "") + m.ionUser = m.tenant.DefaultUser() + } + m.switchCursor = max(0, min(m.switchCursor, m.switchRows()-1)) + m.status = "forgot " + t.Name + " · u puts the record back, not the secret" + return m, nil +} + +func (m tuiModel) undoForgetAccount() (tea.Model, tea.Cmd) { + if len(m.switchUndo) == 0 { + m.status = "nothing to undo" + return m, nil + } + last := len(m.switchUndo) - 1 + t := m.switchUndo[last] + m.switchUndo = slices.Clone(m.switchUndo[:last]) + + m.cfg.Upsert(t) + if err := m.store.SaveConfig(m.cfg); err != nil { + m.status = err.Error() + return m, nil + } + m.status = t.Name + " is back · enter its client secret again to use it" + return m, nil +} diff --git a/internal/tui/tuimenu_test.go b/internal/tui/tuimenu_test.go index d2409aa..09832ab 100644 --- a/internal/tui/tuimenu_test.go +++ b/internal/tui/tuimenu_test.go @@ -16,13 +16,10 @@ import ( var testANSI = regexp.MustCompile(`\x1b\[[0-9;]*m`) -// plain strips styling so assertions compare text rather than escape codes. func plain(s string) string { return testANSI.ReplaceAllString(s, "") } func TestMenuEntries(t *testing.T) { - // The menu is the same entries whatever is configured: the element list, - // the way in to a browser account, macros, settings, quit. - want := []string{"elements", "sign in with browser", "macros", "Settings", "Quit"} + want := []string{"Elements", "Sign In with Browser", "Macros", "Settings", "Quit"} cases := map[string]struct { cfg *config.Config @@ -59,16 +56,14 @@ func TestMenuEntries(t *testing.T) { func TestMenuLabelDoesNotCarryTheMode(t *testing.T) { root := t.TempDir() - // The mode belongs to the element list, so the menu reads the same whichever - // mode is set; a menu that renamed itself was the phase 7 state explosion. for _, mode := range []pickMode{modeShell, modeBatch} { m := tuiModel{ cfg: &config.Config{}, store: config.OpenAt(root, config.NewFileSecrets(root+"/secrets.json")), mode: mode, } - if got := m.menuEntries()[0].label; got != "elements" { - t.Errorf("first entry in %v mode = %q, want %q", mode, got, "elements") + if got := m.menuEntries()[0].label; got != "Elements" { + t.Errorf("first entry in %v mode = %q, want %q", mode, got, "Elements") } } } @@ -93,7 +88,6 @@ func TestCacheLine(t *testing.T) { "loaded from a fresh cache": { tuiModel{hasTenant: true, cacheState: cacheCached, cacheCount: 5613, cacheAt: time.Now().Add(-9 * time.Minute)}, "5613 elements cached 9m ago"}, - // A session left open long enough must stop claiming to be current. "an updated cache that went stale stops saying so": { tuiModel{hasTenant: true, cacheState: cacheUpdated, cacheCount: 5613, cacheAt: time.Now().Add(-38*time.Minute - 39*time.Second)}, "5613 elements cached 38m ago"}, @@ -186,7 +180,6 @@ func TestFilterMatchesSubstringAndSite(t *testing.T) { } func TestSiteNameFallsBackToID(t *testing.T) { - // Sites are keyed by tenant, since two tenants can reuse a site identifier. m := tuiModel{sites: map[string]string{"1/s1": "Nashville Hub", "2/s1": "Dallas Hub"}} row := func(tsg, site string) elementRow { return elementRow{Element: api.Element{SiteID: site}, TSG: tsg} @@ -471,8 +464,6 @@ func TestArrowsMoveTableWhileFiltering(t *testing.T) { } func TestFilteringAndSelectingAreSeparateModes(t *testing.T) { - // A query is several terms, so every space while typing belongs to it. - // Selection resumes once the filter is applied. m := pickModel(t) mm, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: '/', Text: "/"})) m = mm.(tuiModel) @@ -487,7 +478,6 @@ func TestFilteringAndSelectingAreSeparateModes(t *testing.T) { t.Errorf("selected = %v, want no selection while filtering", m.selected) } - // Enter applies the query and leaves filter mode, keeping what was typed. mm, _ = m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyEnter})) m = mm.(tuiModel) if m.filterOn { @@ -500,7 +490,6 @@ func TestFilteringAndSelectingAreSeparateModes(t *testing.T) { t.Errorf("phase = %v, want to stay on the list rather than act on it", m.phase) } - // Now space selects again. mm, _ = m.Update(tea.KeyPressMsg(tea.Key{Code: ' ', Text: " "})) m = mm.(tuiModel) if len(m.selected) != 1 { diff --git a/internal/ui/colour.go b/internal/ui/colour.go new file mode 100644 index 0000000..bb3db15 --- /dev/null +++ b/internal/ui/colour.go @@ -0,0 +1,301 @@ +package ui + +import ( + "fmt" + "math" + "math/rand/v2" + "strconv" + "strings" +) + +var base16 = [16][3]uint8{ + {0x00, 0x00, 0x00}, {0x80, 0x00, 0x00}, {0x00, 0x80, 0x00}, {0x80, 0x80, 0x00}, + {0x00, 0x00, 0x80}, {0x80, 0x00, 0x80}, {0x00, 0x80, 0x80}, {0xc0, 0xc0, 0xc0}, + {0x80, 0x80, 0x80}, {0xff, 0x00, 0x00}, {0x00, 0xff, 0x00}, {0xff, 0xff, 0x00}, + {0x00, 0x00, 0xff}, {0xff, 0x00, 0xff}, {0x00, 0xff, 0xff}, {0xff, 0xff, 0xff}, +} + +var cubeLevels = [6]uint8{0, 95, 135, 175, 215, 255} + +func RGBOf(spec string) ([3]uint8, bool) { + spec = strings.TrimSpace(spec) + if spec == "" { + return [3]uint8{}, false + } + if strings.HasPrefix(spec, "#") { + h := spec[1:] + if len(h) == 3 { + h = string([]byte{h[0], h[0], h[1], h[1], h[2], h[2]}) + } + if len(h) != 6 { + return [3]uint8{}, false + } + n, err := strconv.ParseUint(h, 16, 32) + if err != nil { + return [3]uint8{}, false + } + return [3]uint8{uint8(n >> 16), uint8(n >> 8 & 0xff), uint8(n & 0xff)}, true + } + + n, err := strconv.Atoi(spec) + if err != nil || n < 0 || n > 255 { + return [3]uint8{}, false + } + switch { + case n < 16: + return base16[n], true + case n < 232: + i := n - 16 + return [3]uint8{cubeLevels[i/36], cubeLevels[i/6%6], cubeLevels[i%6]}, true + default: + g := uint8(8 + 10*(n-232)) + return [3]uint8{g, g, g}, true + } +} + +func HexOf(spec string) string { + c, ok := RGBOf(spec) + if !ok { + return "" + } + return HexFromRGB(c) +} + +func HexFromRGB(c [3]uint8) string { + return fmt.Sprintf("#%02x%02x%02x", c[0], c[1], c[2]) +} + +func Contrast(spec string) string { + c, ok := RGBOf(spec) + if !ok { + return "15" + } + y := 0.299*float64(c[0]) + 0.587*float64(c[1]) + 0.114*float64(c[2]) + if y > 140 { + return "#000000" + } + return "#ffffff" +} + +func HSVOf(c [3]uint8) (h, s, v float64) { + r, g, b := float64(c[0])/255, float64(c[1])/255, float64(c[2])/255 + maxc := math.Max(r, math.Max(g, b)) + minc := math.Min(r, math.Min(g, b)) + v = maxc + d := maxc - minc + if maxc > 0 { + s = d / maxc + } + if d == 0 { + return 0, s, v + } + switch maxc { + case r: + h = math.Mod((g-b)/d, 6) + case g: + h = (b-r)/d + 2 + default: + h = (r-g)/d + 4 + } + h *= 60 + if h < 0 { + h += 360 + } + return h, s, v +} + +func RGBFromHSV(h, s, v float64) [3]uint8 { + h = math.Mod(math.Mod(h, 360)+360, 360) + s = clamp01(s) + v = clamp01(v) + + c := v * s + x := c * (1 - math.Abs(math.Mod(h/60, 2)-1)) + m := v - c + + var r, g, b float64 + switch int(h / 60) { + case 0: + r, g, b = c, x, 0 + case 1: + r, g, b = x, c, 0 + case 2: + r, g, b = 0, c, x + case 3: + r, g, b = 0, x, c + case 4: + r, g, b = x, 0, c + default: + r, g, b = c, 0, x + } + return [3]uint8{round8(r + m), round8(g + m), round8(b + m)} +} + +func HexFromHSV(h, s, v float64) string { return HexFromRGB(RGBFromHSV(h, s, v)) } + +func clamp01(f float64) float64 { return math.Max(0, math.Min(1, f)) } + +func round8(f float64) uint8 { return uint8(math.Round(clamp01(f) * 255)) } + +func WheelAt(x, y, cols, rows int, value float64) string { + nx, ny, ok := wheelNorm(x, y, cols, rows) + if !ok { + return "" + } + r := math.Hypot(nx, ny) + if r > 1+rimSlack { + return "" + } + h := math.Atan2(ny, nx) * 180 / math.Pi + return HexFromHSV(h, math.Min(1, r), value) +} + +const rimSlack = 0.05 + +func wheelNorm(x, y, cols, rows int) (nx, ny float64, ok bool) { + if cols < 3 || rows < 2 { + return 0, 0, false + } + pixels := rows * 2 + cx, cy := float64(cols-1)/2, float64(pixels-1)/2 + if x < 0 || x >= cols || y < 0 || y >= pixels { + return 0, 0, false + } + return (float64(x) - cx) / cx, (float64(y) - cy) / cy, true +} + +func WheelCell(spec string, cols, rows int) (x, y int, value float64) { + c, ok := RGBOf(spec) + pixels := rows * 2 + cx, cy := float64(cols-1)/2, float64(pixels-1)/2 + if !ok { + return int(math.Round(cx)), int(math.Round(cy)), 1 + } + h, s, v := HSVOf(c) + if v == 0 { + return int(math.Round(cx)), int(math.Round(cy)), 0 + } + rad := h * math.Pi / 180 + x = int(math.Round(cx + s*math.Cos(rad)*cx)) + y = int(math.Round(cy + s*math.Sin(rad)*cy)) + return min(max(x, 0), cols-1), min(max(y, 0), pixels-1), v +} + +func Grid256() [][]int { + out := make([][]int, 0, 8) + + first := make([]int, 16) + for i := range first { + first[i] = i + } + out = append(out, first) + + for r := range 6 { + row := make([]int, 36) + for i := range row { + row[i] = 16 + r*36 + i + } + out = append(out, row) + } + + greys := make([]int, 24) + for i := range greys { + greys[i] = 232 + i + } + return append(out, greys) +} + +func NearestIndex(spec string) int { + c, ok := RGBOf(spec) + if !ok { + return 7 + } + best, bestDist := 16, math.MaxFloat64 + for n := 16; n < 256; n++ { + o, _ := RGBOf(strconv.Itoa(n)) + dr := float64(c[0]) - float64(o[0]) + dg := float64(c[1]) - float64(o[1]) + db := float64(c[2]) - float64(o[2]) + if d := dr*dr + dg*dg + db*db; d < bestDist { + best, bestDist = n, d + } + } + return best +} + +func GridCell(spec string) (row, col int, ok bool) { + n, err := strconv.Atoi(strings.TrimSpace(spec)) + if err != nil || n < 0 || n > 255 { + return 0, 0, false + } + for r, cells := range Grid256() { + for c, v := range cells { + if v == n { + return r, c, true + } + } + } + return 0, 0, false +} + +func RandomColour(onLight bool) string { + h := rand.Float64() * 360 + s := 0.45 + rand.Float64()*0.5 + v := 0.62 + rand.Float64()*0.38 + if onLight { + v = 0.28 + rand.Float64()*0.26 + s = 0.5 + rand.Float64()*0.5 + } + return HexFromHSV(h, s, v) +} + +func RandomBackground() (bg, fg string, onLight bool) { + h := rand.Float64() * 360 + onLight = rand.IntN(4) == 0 + if onLight { + return HexFromHSV(h, 0.04+rand.Float64()*0.1, 0.9+rand.Float64()*0.08), + HexFromHSV(h, 0.1+rand.Float64()*0.2, 0.16+rand.Float64()*0.1), true + } + return HexFromHSV(h, 0.1+rand.Float64()*0.25, 0.07+rand.Float64()*0.09), + HexFromHSV(h, 0.05+rand.Float64()*0.15, 0.82+rand.Float64()*0.14), false +} + +func SimilarColour(spec string, spread float64) string { + c, ok := RGBOf(spec) + if !ok { + return spec + } + h, s, v := HSVOf(c) + h += jitter(28 * spread) + s = clamp01(s + jitter(0.18*spread)) + v = clamp01(v + jitter(0.14*spread)) + + if v < 0.12 { + v = 0.12 + } + return HexFromHSV(h, s, v) +} + +func SimilarBackground(spec string, spread float64) string { + c, ok := RGBOf(spec) + if !ok { + return spec + } + h, s, v := HSVOf(c) + h += jitter(24 * spread) + s = clamp01(s + jitter(0.08*spread)) + v = clamp01(v + jitter(0.05*spread)) + if orig := v; orig > 0.5 { + v = math.Max(0.84, v) + } else { + v = math.Min(0.22, v) + } + return HexFromHSV(h, s, v) +} + +func jitter(by float64) float64 { return (rand.Float64()*2 - 1) * by } + +func RandomSpinner() string { + names := SpinnerNames() + return names[rand.IntN(len(names))] +} diff --git a/internal/ui/colour_test.go b/internal/ui/colour_test.go new file mode 100644 index 0000000..ae45be0 --- /dev/null +++ b/internal/ui/colour_test.go @@ -0,0 +1,163 @@ +package ui + +import ( + "math" + "testing" +) + +func TestRGBOfEveryFormAThemeCanHold(t *testing.T) { + tests := []struct { + name, spec string + want [3]uint8 + ok bool + }{ + {"six hex digits", "#89b4fa", [3]uint8{0x89, 0xb4, 0xfa}, true}, + {"three are doubled", "#abc", [3]uint8{0xaa, 0xbb, 0xcc}, true}, + {"an index under sixteen", "9", base16[9], true}, + {"a cube index", "16", [3]uint8{0, 0, 0}, true}, + {"the far corner of the cube", "231", [3]uint8{255, 255, 255}, true}, + {"the first grey", "232", [3]uint8{8, 8, 8}, true}, + {"the last grey", "255", [3]uint8{238, 238, 238}, true}, + {"unset", "", [3]uint8{}, false}, + {"a word", "chartreuse", [3]uint8{}, false}, + {"past the end", "256", [3]uint8{}, false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, ok := RGBOf(tc.spec) + if ok != tc.ok { + t.Fatalf("RGBOf(%q) ok = %v, want %v", tc.spec, ok, tc.ok) + } + if ok && got != tc.want { + t.Errorf("RGBOf(%q) = %v, want %v", tc.spec, got, tc.want) + } + }) + } +} + +func TestTheCubeIndexMatchesItsComponents(t *testing.T) { + got, ok := RGBOf("111") + if !ok { + t.Fatal("111 did not resolve") + } + want := [3]uint8{cubeLevels[2], cubeLevels[3], cubeLevels[5]} + if got != want { + t.Errorf("RGBOf(111) = %v, want %v", got, want) + } +} + +func TestHSVRoundTrips(t *testing.T) { + for _, spec := range []string{"#89b4fa", "#ff0000", "#00ff00", "#1e1e2e", "#ffffff", "#808080"} { + c, _ := RGBOf(spec) + h, s, v := HSVOf(c) + back := RGBFromHSV(h, s, v) + for i := range 3 { + if d := int(back[i]) - int(c[i]); d > 1 || d < -1 { + t.Errorf("%s went to h=%.1f s=%.2f v=%.2f and came back %v", spec, h, s, v, back) + break + } + } + } +} + +func TestGreyHasNoSaturation(t *testing.T) { + c, _ := RGBOf("#808080") + _, s, v := HSVOf(c) + if s != 0 { + t.Errorf("saturation = %v for a grey, want 0", s) + } + if v < 0.4 || v > 0.6 { + t.Errorf("value = %v for mid grey", v) + } +} + +func TestTheWheelIsADisc(t *testing.T) { + const cols, rows = 22, 11 + + if got := WheelAt(0, 0, cols, rows, 1); got != "" { + t.Errorf("the top left corner is %q, want nothing: it is outside the disc", got) + } + + cx, cy := (cols-1)/2, (rows*2-1)/2 + mid := WheelAt(cx, cy, cols, rows, 1) + c, ok := RGBOf(mid) + if !ok { + t.Fatalf("the middle of the disc is %q", mid) + } + if _, s, _ := HSVOf(c); s > 0.2 { + t.Errorf("the middle has saturation %v, want near white", s) + } + + right := WheelAt(cols-1, cy, cols, rows, 1) + rc, ok := RGBOf(right) + if !ok { + t.Fatalf("the right edge is %q", right) + } + h, s, _ := HSVOf(rc) + if s < 0.5 { + t.Errorf("the rim has saturation %v, want it saturated", s) + } + if h > 20 && h < 340 { + t.Errorf("the hue to the right is %v degrees, want red", h) + } + + dim := WheelAt(cx+2, cy, cols, rows, 0.3) + bright := WheelAt(cx+2, cy, cols, rows, 1) + if dim == bright { + t.Error("the brightness did not change the colour") + } +} + +func TestWheelCellFindsTheColourItWasGiven(t *testing.T) { + const cols, rows = 22, 11 + for _, spec := range []string{"#ff0000", "#00ff00", "#0000ff", "#89b4fa"} { + x, y, v := WheelCell(spec, cols, rows) + at := WheelAt(x, y, cols, rows, v) + c, ok := RGBOf(at) + if !ok { + t.Errorf("WheelCell(%s) pointed at %q, which is off the disc", spec, at) + continue + } + want, _ := RGBOf(spec) + wh, _, _ := HSVOf(want) + gh, _, _ := HSVOf(c) + if d := math.Abs(wh - gh); d > 25 && d < 335 { + t.Errorf("WheelCell(%s) landed on hue %.0f, want near %.0f", spec, gh, wh) + } + } +} + +func TestTheChartHoldsEveryIndexOnce(t *testing.T) { + seen := map[int]bool{} + for _, row := range Grid256() { + for _, idx := range row { + if seen[idx] { + t.Fatalf("index %d is in the chart twice", idx) + } + seen[idx] = true + } + } + if len(seen) != 256 { + t.Errorf("the chart holds %d colours, want 256", len(seen)) + } + + row, col, ok := GridCell("111") + if !ok { + t.Fatal("111 is not in the chart") + } + if got := Grid256()[row][col]; got != 111 { + t.Errorf("GridCell(111) points at %d", got) + } + if _, _, ok := GridCell("#89b4fa"); ok { + t.Error("a hex was found in the chart, which only holds indices") + } +} + +func TestContrastIsReadableOnTheSwatch(t *testing.T) { + if got := Contrast("#ffffff"); got != "#000000" { + t.Errorf("Contrast(white) = %q, want black", got) + } + if got := Contrast("#1e1e2e"); got != "#ffffff" { + t.Errorf("Contrast(a dark blue) = %q, want white", got) + } +} diff --git a/internal/ui/modal.go b/internal/ui/modal.go index 64bdbad..d805539 100644 --- a/internal/ui/modal.go +++ b/internal/ui/modal.go @@ -7,13 +7,8 @@ import ( "github.com/charmbracelet/x/ansi" ) -// ModalWidth is the inside width of a modal, wide enough for a failure reason -// without crowding a narrow terminal. const ModalWidth = 48 -// Modal frames a few lines in a border. Lines are padded before styling and -// measured with ansi.StringWidth, since a styled string's escape bytes would -// otherwise count towards the width and pull the right edge out of line. func Modal(lines []string, width int) string { inner := min(ModalWidth, max(20, width-6)) @@ -34,8 +29,6 @@ func Modal(lines []string, width int) string { Padding(0, 1). Render(b.String()) - // Indented rather than centred: a box that moves with the terminal width is - // harder to find again than one that is always in the same place. out := make([]string, 0, len(lines)+2) for _, line := range strings.Split(box, "\n") { out = append(out, " "+line) @@ -43,6 +36,4 @@ func Modal(lines []string, width int) string { return strings.Join(out, "\n") } -// ModalHeight is how many rows Modal will take for n lines, so a caller can -// keep the frame height constant. func ModalHeight(n int) int { return n + 2 } diff --git a/internal/ui/spinners.go b/internal/ui/spinners.go index a62d726..8f648d6 100644 --- a/internal/ui/spinners.go +++ b/internal/ui/spinners.go @@ -37,9 +37,6 @@ func SpinnerName(name string) string { return spinnerChoices[spinnerIndex(name)].name } -// KnownSpinner and SpinnerNames are for validating a theme's shape at load: -// spinnerIndex falls back to the default, which in a config file would look -// like the name being ignored. func KnownSpinner(name string) bool { for _, c := range spinnerChoices { if c.name == name { @@ -64,12 +61,12 @@ func NewSpinner(name string) spinner.Model { return s } -// SpinnerFrame is one frame of a shape, for a preview that has to show a -// spinner without running one. -func SpinnerFrame(name string) string { +func SpinnerFrame(name string) string { return SpinnerFrameAt(name, 0) } + +func SpinnerFrameAt(name string, at int) string { frames := spinnerChoices[spinnerIndex(name)].spin.Frames if len(frames) == 0 { return "" } - return frames[0] + return frames[((at%len(frames))+len(frames))%len(frames)] } diff --git a/internal/ui/spinners_test.go b/internal/ui/spinners_test.go index bcd42ca..2ba291b 100644 --- a/internal/ui/spinners_test.go +++ b/internal/ui/spinners_test.go @@ -2,9 +2,6 @@ package ui import "testing" -// A theme names its spinner, so a name that is not one has to be catchable at -// load: SpinnerName itself falls back to the default, which in a config file -// would look like the name being ignored. func TestSpinnerNamesAreCheckable(t *testing.T) { if got := SpinnerName("nonsense"); got != DefaultSpinner { t.Errorf("unknown name = %q, want %q", got, DefaultSpinner) diff --git a/internal/ui/text.go b/internal/ui/text.go index 7da3852..c1ca98d 100644 --- a/internal/ui/text.go +++ b/internal/ui/text.go @@ -3,21 +3,17 @@ package ui import ( "fmt" "time" + "unicode" "github.com/charmbracelet/x/ansi" ) -// LabelWidth is the gutter every key is padded into. A longer key overruns it -// and collides with its value. -const LabelWidth = 13 +const LabelWidth = 14 func Label(s string) string { return StyleDim.Render(fmt.Sprintf("%-*s", LabelWidth, s)) } -// Width is the printed width of a string, ignoring its escape codes. func Width(s string) int { return ansi.StringWidth(s) } -// Plain is text with its styling taken off, for a line that is about to be -// restyled: wrapping a highlight around escapes ends it at the inner reset. func Plain(s string) string { return ansi.Strip(s) } func Truncate(s string, n int) string { @@ -30,8 +26,6 @@ func Truncate(s string, n int) string { return ansi.Truncate(s, n, "…") } -// Age formats a cache age coarsely. Sub-second precision is noise for something -// measured in minutes, and "38m38.9s ago" reads like a stopwatch. func Age(d time.Duration) string { switch { case d < time.Minute: @@ -45,3 +39,18 @@ func Age(d time.Duration) string { } return fmt.Sprintf("%dh%dm", h, mins) } + +func Title(s string) string { + out := []rune(s) + up := true + for i, r := range out { + switch { + case r == ' ' || r == '-': + up = true + case up: + out[i] = unicode.ToUpper(r) + up = false + } + } + return string(out) +} diff --git a/internal/ui/theme.go b/internal/ui/theme.go index c780ce5..5b7d7b8 100644 --- a/internal/ui/theme.go +++ b/internal/ui/theme.go @@ -6,6 +6,8 @@ import ( "image/color" "os" "path/filepath" + "regexp" + "slices" "strconv" "strings" @@ -17,33 +19,96 @@ import ( //go:embed themes.yaml var builtinFS embed.FS -const ThemesFileName = "themes.yaml" +const ( + ThemesFileName = "themes.yaml" + ThemesDirName = "themes" +) + +func ThemeFileName(name string) string { + var b strings.Builder + for _, r := range strings.ToLower(name) { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + b.WriteRune(r) + } + } + if b.Len() == 0 { + return "scheme.yaml" + } + return b.String() + ".yaml" +} + +func pathIsFile(path string) bool { + if fi, err := os.Stat(path); err == nil { + return !fi.IsDir() + } + ext := strings.ToLower(filepath.Ext(path)) + return ext == ".yaml" || ext == ".yml" +} + +func userThemeFiles(path string) ([]string, error) { + if pathIsFile(path) { + return []string{path}, nil + } + + var out []string + if legacy := strings.TrimSuffix(path, string(filepath.Separator)) + ".yaml"; legacy != "" { + if fi, err := os.Stat(legacy); err == nil && !fi.IsDir() { + out = append(out, legacy) + } + } + + entries, err := os.ReadDir(path) + if err != nil { + if os.IsNotExist(err) { + return out, nil + } + return out, err + } + names := make([]string, 0, len(entries)) + for _, e := range entries { + if e.IsDir() { + continue + } + switch strings.ToLower(filepath.Ext(e.Name())) { + case ".yaml", ".yml": + names = append(names, e.Name()) + } + } + slices.Sort(names) + for _, n := range names { + out = append(out, filepath.Join(path, n)) + } + return out, nil +} -// colour per role, not per widget: nothing here asks for "blue" type Theme struct { Name string `yaml:"name"` - Blurb string `yaml:"blurb"` - - OK string `yaml:"ok"` - Err string `yaml:"err"` - Warn string `yaml:"warn"` - Cmd string `yaml:"cmd"` - Element string `yaml:"element"` - Row string `yaml:"row"` - Hot string `yaml:"hot"` - Border string `yaml:"border"` - Divider string `yaml:"divider"` - // the spinner is part of the scheme too - Spinner string `yaml:"spinner"` - SpinnerColor string `yaml:"spinner_color"` - Tabs []string `yaml:"tabs"` - - // attributes instead of colour: roles still have to differ - Mono bool `yaml:"mono"` -} - -// Dim and Ghost are attributes: a fixed grey is wrong on half of -// the backgrounds there are. + Blurb string `yaml:"blurb,omitempty"` + + OK string `yaml:"ok,omitempty"` + Err string `yaml:"err,omitempty"` + Warn string `yaml:"warn,omitempty"` + Cmd string `yaml:"cmd,omitempty"` + Element string `yaml:"element,omitempty"` + Row string `yaml:"row,omitempty"` + Hot string `yaml:"hot,omitempty"` + Border string `yaml:"border,omitempty"` + Divider string `yaml:"divider,omitempty"` + Bg string `yaml:"bg,omitempty"` + Fg string `yaml:"fg,omitempty"` + Animation string `yaml:"animation,omitempty"` + + TabMode string `yaml:"tab_mode,omitempty"` + TabShell []string `yaml:"tab_shell,omitempty"` + TabData []string `yaml:"tab_data,omitempty"` + + Spinner string `yaml:"spinner,omitempty"` + SpinnerColor string `yaml:"spinner_color,omitempty"` + Tabs []string `yaml:"tabs,omitempty"` + + Mono bool `yaml:"mono,omitempty"` +} + type Styles struct { OK lipgloss.Style Err lipgloss.Style @@ -57,11 +122,61 @@ type Styles struct { Divider lipgloss.Style Spinner lipgloss.Style - Border color.Color - Tabs []lipgloss.Style + Border color.Color + Tabs []lipgloss.Style + TabsShell []lipgloss.Style + TabsData []lipgloss.Style +} + +type TabKind int + +const ( + TabList TabKind = iota + TabShell + TabData +) + +const ( + TabRotate = "rotate" + TabByType = "type" + TabBoth = "both" + TabShared = "shared" +) + +func TabModes() []string { return []string{TabRotate, TabByType, TabBoth, TabShared} } + +var TabModeBlurb = map[string]string{ + TabRotate: "a colour per position in the bar", + TabByType: "one colour for shells, one for data", + TabBoth: "a rotation of its own per kind", + TabShared: "one rotation, counted within the kind", +} + +func KnownTabMode(mode string) bool { + return mode == "" || slices.Contains(TabModes(), mode) +} + +func TabFallback(kind TabKind) int { + if kind == TabData { + return 1 + } + return 0 +} + +func (t Theme) TabColours(kind TabKind) []string { + switch kind { + case TabShell: + if len(t.TabShell) > 0 { + return t.TabShell + } + case TabData: + if len(t.TabData) > 0 { + return t.TabData + } + } + return t.Tabs } -// something has to draw if themes.yaml breaks var fallback = Theme{ Name: "classic", Blurb: "your terminal's own 16 colours", @@ -72,8 +187,7 @@ var fallback = Theme{ } var ( - loaded []Theme - // only ever a themes.yaml that would not parse + loaded []Theme loadErr string ) @@ -81,8 +195,39 @@ type themeSet struct { Themes []Theme `yaml:"themes"` } -// picker order, default first +var hidden []string + +func HideThemes(names []string) { hidden = slices.Clone(names) } + +func HiddenThemes() []string { return slices.Clone(hidden) } + +func IsHidden(name string) bool { + for _, h := range hidden { + if strings.EqualFold(h, name) { + return true + } + } + return false +} + func Themes() []Theme { + all := allThemes() + if len(hidden) == 0 { + return all + } + out := make([]Theme, 0, len(all)) + for _, t := range all { + if !IsHidden(t.Name) { + out = append(out, t) + } + } + if len(out) == 0 { + return []Theme{fallback} + } + return out +} + +func allThemes() []Theme { if loaded == nil { set, err := builtin() if err != nil { @@ -111,7 +256,6 @@ func builtin() ([]Theme, error) { return set, nil } -// a broken file leaves the built-ins; a missing one is normal func LoadThemes(path string) error { set, err := builtin() if err != nil { @@ -123,34 +267,58 @@ func LoadThemes(path string) error { if path == "" { return nil } - b, err := os.ReadFile(path) + files, err := userThemeFiles(path) if err != nil { - if os.IsNotExist(err) { - return nil - } loadErr = fmt.Sprintf("read %s: %s", filepath.Base(path), err) return fmt.Errorf("read %s: %w", path, err) } - user, err := ParseThemes(b) - if err != nil { - loadErr = fmt.Sprintf("%s: %s", filepath.Base(path), err) - return fmt.Errorf("%s: %w", filepath.Base(path), err) - } - for _, t := range user { - if i := indexOf(loaded, t.Name); i >= 0 { - loaded[i] = t + for _, f := range files { + b, rerr := os.ReadFile(f) + if rerr != nil { + if os.IsNotExist(rerr) { + continue + } + loadErr = fmt.Sprintf("read %s: %s", filepath.Base(f), rerr) + return fmt.Errorf("read %s: %w", f, rerr) + } + user, perr := ParseThemes(b) + if perr != nil { + loadErr = fmt.Sprintf("%s: %s", filepath.Base(f), perr) continue } - loaded = append(loaded, t) + for _, t := range user { + if i := indexOf(loaded, t.Name); i >= 0 { + loaded[i] = t + continue + } + loaded = append(loaded, t) + } + } + if loadErr != "" { + return fmt.Errorf("%s", loadErr) } return nil } +var setShape = regexp.MustCompile(`(?m)^themes:`) + func ParseThemes(b []byte) ([]Theme, error) { + if !setShape.Match(b) { + var one Theme + dec := yaml.NewDecoder(strings.NewReader(string(b))) + dec.KnownFields(true) + if err := dec.Decode(&one); err != nil { + return nil, fmt.Errorf("parse: %w", err) + } + if err := one.validate(); err != nil { + return nil, err + } + return []Theme{one}, nil + } + var set themeSet dec := yaml.NewDecoder(strings.NewReader(string(b))) - // a misspelled key otherwise looks like the scheme not working dec.KnownFields(true) if err := dec.Decode(&set); err != nil { return nil, fmt.Errorf("parse: %w", err) @@ -168,21 +336,25 @@ func (t Theme) validate() error { if strings.TrimSpace(t.Name) == "" { return fmt.Errorf("a theme needs a name") } - for _, role := range [][2]string{ - {"ok", t.OK}, {"err", t.Err}, {"warn", t.Warn}, {"cmd", t.Cmd}, - {"element", t.Element}, {"row", t.Row}, {"hot", t.Hot}, - {"border", t.Border}, {"divider", t.Divider}, - {"spinner_color", t.SpinnerColor}, - } { - if err := checkColour(role[1]); err != nil { - return fmt.Errorf("theme %s: %s: %w", t.Name, role[0], err) + for _, role := range RoleNames() { + if err := checkColour(t.Role(role)); err != nil { + return fmt.Errorf("theme %s: %s: %w", t.Name, role, err) } } - for i, c := range t.Tabs { - if err := checkColour(c); err != nil { - return fmt.Errorf("theme %s: tab %d: %w", t.Name, i+1, err) + for _, set := range []struct { + what string + colours []string + }{{"tab", t.Tabs}, {"tab_shell", t.TabShell}, {"tab_data", t.TabData}} { + for i, c := range set.colours { + if err := checkColour(c); err != nil { + return fmt.Errorf("theme %s: %s %d: %w", t.Name, set.what, i+1, err) + } } } + if !KnownTabMode(t.TabMode) { + return fmt.Errorf("theme %s: tab_mode %q is not one of %s", + t.Name, t.TabMode, strings.Join(TabModes(), ", ")) + } if t.Spinner != "" && !KnownSpinner(t.Spinner) { return fmt.Errorf("theme %s: spinner %q is not one of %s", t.Name, t.Spinner, strings.Join(SpinnerNames(), ", ")) @@ -190,6 +362,8 @@ func (t Theme) validate() error { return nil } +func CheckColour(spec string) error { return checkColour(spec) } + func checkColour(spec string) error { switch { case spec == "": @@ -245,7 +419,6 @@ func (t Theme) fg(spec string) lipgloss.Style { return lipgloss.NewStyle().Foreground(lipgloss.Color(spec)) } -// built once, so no view asks the theme anything at render time func (t Theme) Styles() Styles { s := Styles{ Dim: lipgloss.NewStyle().Faint(true), @@ -263,7 +436,6 @@ func (t Theme) Styles() Styles { s.Divider = lipgloss.NewStyle().Faint(true) s.Spinner = lipgloss.NewStyle() s.Border = lipgloss.NoColor{} - // StyleTab reverses the active one s.Tabs = []lipgloss.Style{lipgloss.NewStyle()} return s } @@ -281,16 +453,29 @@ func (t Theme) Styles() Styles { for _, c := range t.Tabs { s.Tabs = append(s.Tabs, t.fg(c)) } + for _, c := range t.TabShell { + s.TabsShell = append(s.TabsShell, t.fg(c)) + } + for _, c := range t.TabData { + s.TabsData = append(s.TabsData, t.fg(c)) + } if len(s.Tabs) == 0 { s.Tabs = []lipgloss.Style{lipgloss.NewStyle()} } return s } -// outside ANSI 0-15, so it depends on the terminal's depth func (t Theme) Deep() bool { - for _, c := range append([]string{t.OK, t.Err, t.Warn, t.Cmd, t.Element, - t.Row, t.Hot, t.Border, t.Divider, t.SpinnerColor}, t.Tabs...) { + specs := make([]string, 0, len(RoleNames())+len(t.Tabs)) + for _, role := range RoleNames() { + if role == "background" || role == "other" { + continue + } + specs = append(specs, t.Role(role)) + } + specs = append(specs, t.TabShell...) + specs = append(specs, t.TabData...) + for _, c := range append(specs, t.Tabs...) { if strings.HasPrefix(c, "#") { return true } @@ -313,40 +498,26 @@ func (t Theme) Swatch() string { return b.String() } -// One process, one theme, so package state rather than threaded through -// every view. Written from Update, read by the next render. var ( - current = fallback - styles = fallback.Styles() - StyleOK = styles.OK - StyleErr = styles.Err - // StyleWarn is a thing worth noticing that is not a failure: an offline - // ION next to a live session, a tenant that keeps refusing. + current = fallback + styles = fallback.Styles() + StyleOK = styles.OK + StyleErr = styles.Err StyleWarn = styles.Warn StyleCmd = styles.Cmd StyleElement = styles.Element StyleDim = styles.Dim - // StyleHot is a session about to go. - StyleHot = styles.Hot - // StyleRow is the highlight on the row a list is on, and on the line an - // open result is scrolled to. Defined here rather than taken from the - // table widget's default, which is a 256-colour pink a terminal theme - // cannot remap. - StyleRow = styles.Row - // StyleGhost is a line that is not there yet: the next command a macro - // would take, or the macro that would be made, before anyone has typed - // anything. + StyleHot = styles.Hot + StyleRow = styles.Row StyleGhost = styles.Ghost + StyleBold = lipgloss.NewStyle().Bold(true) StyleDivider = styles.Divider - // Set into the bubbles widget when a spinner is made, so a theme change - // has to make it again. StyleSpinner = styles.Spinner MarkCheck = StyleOK.SetString("✓") MarkCross = StyleErr.SetString("✗") ) -// unknown name lands on the default: a stale preference still draws func UseTheme(name string) bool { t, ok := ThemeByName(name) Apply(t) @@ -366,24 +537,71 @@ func Apply(t Theme) { func CurrentTheme() Theme { return current } -// reversed, not recoloured: legible in a scheme with few colours -func StyleTab(i int, active bool) lipgloss.Style { - s := styles.Tabs[i%len(styles.Tabs)] +func StyleTab(kind TabKind, ring, nth int, active bool) lipgloss.Style { + s := tabStyle(kind, ring, nth) if active { return s.Reverse(true).Bold(true) } return s } +func tabStyle(kind TabKind, ring, nth int) lipgloss.Style { + if kind == TabList { + return atTab(styles.Tabs, 0) + } + + set, at := styles.TabsShell, 0 + if kind == TabData { + set, at = styles.TabsData, TabFallback(TabData) + } + if len(set) == 0 { + set, nth = styles.Tabs, nth+at + } else { + at = 0 + } + + switch current.TabMode { + case TabByType: + return atTab(set, at) + case TabBoth: + return atTab(set, nth) + case TabShared: + return atTab(styles.Tabs, nth) + } + return atTab(styles.Tabs, ring) +} + +func atTab(set []lipgloss.Style, i int) lipgloss.Style { + if len(set) == 0 { + return lipgloss.NewStyle() + } + return set[((i%len(set))+len(set))%len(set)] +} + func BorderColor() color.Color { return styles.Border } -// the renderer downsamples for us; this only says a scheme is approximated +func Background() color.Color { return terminalColour(current.Bg) } + +func Foreground() color.Color { return terminalColour(current.Fg) } + +func terminalColour(spec string) color.Color { + c, ok := RGBOf(spec) + if !ok { + return nil + } + return color.RGBA{R: c[0], G: c[1], B: c[2], A: 0xff} +} + var profile colorprofile.Profile func SetProfile(p colorprofile.Profile) { profile = p } func Profile() colorprofile.Profile { return profile } +func TrueColor() bool { + return profile == colorprofile.Unknown || profile >= colorprofile.TrueColor +} + func ProfileName() string { switch profile { case colorprofile.TrueColor: @@ -398,12 +616,10 @@ func ProfileName() string { return "" } -// XTGETTCAP corrupts the frame on some terminals, so only a deep scheme asks func ShouldQueryDepth() bool { return current.Deep() && profile != colorprofile.Unknown && profile < colorprofile.TrueColor } -// empty unless a deep scheme is being approximated here func ThemeNote(t Theme) string { if !t.Deep() || profile == colorprofile.Unknown || profile >= colorprofile.TrueColor { return "" @@ -415,7 +631,22 @@ func ThemeNote(t Theme) string { } func RoleNames() []string { - return []string{"ok", "err", "warn", "cmd", "element", "row", "hot", "border", "divider", "spinner_color"} + return []string{"ok", "err", "warn", "cmd", "element", "row", "hot", + "border", "divider", "other", "background", "spinner_color"} +} + +var roleLabels = map[string]string{ + "ok": "OK", "err": "Err", "warn": "Warn", "cmd": "Cmd", + "element": "Element", "row": "Row", "hot": "Hot", "border": "Border", + "divider": "Divider", "other": "Other", "background": "Background", + "spinner_color": "Spinner Colour", +} + +func RoleLabel(role string) string { + if l, ok := roleLabels[role]; ok { + return l + } + return role } func (t Theme) Role(name string) string { @@ -438,13 +669,16 @@ func (t Theme) Role(name string) string { return t.Border case "divider": return t.Divider + case "background": + return t.Bg + case "other": + return t.Fg case "spinner_color": return t.SpinnerColor } return "" } -// an unparseable colour would store as invisible func (t *Theme) SetRole(name, spec string) error { spec = strings.TrimSpace(spec) if err := checkColour(spec); err != nil { @@ -469,6 +703,10 @@ func (t *Theme) SetRole(name, spec string) error { t.Border = spec case "divider": t.Divider = spec + case "background": + t.Bg = spec + case "other": + t.Fg = spec case "spinner_color": t.SpinnerColor = spec default: @@ -477,7 +715,6 @@ func (t *Theme) SetRole(name, spec string) error { return nil } -// what left/right cycles through func Palette() []string { out := make([]string, 0, 64) seen := map[string]bool{} @@ -502,7 +739,6 @@ func Palette() []string { return out } -// a typed colour is not in the palette, so it starts at the top func NextInPalette(spec string, delta int) string { p := Palette() at := 0 @@ -527,7 +763,6 @@ func NextSpinner(name string, delta int) string { return names[((at+delta)%len(names)+len(names))%len(names)] } -// editing a built-in writes an override; the file's comments do not survive func SaveUserTheme(path string, t Theme) error { if path == "" { return fmt.Errorf("no themes file to write to") @@ -536,11 +771,14 @@ func SaveUserTheme(path string, t Theme) error { return err } + if !pathIsFile(path) { + return saveThemeFile(path, t) + } + set := themeSet{} if b, err := os.ReadFile(path); err == nil { existing, perr := ParseThemes(b) if perr != nil { - // the file is the user's, maybe half-written by hand return fmt.Errorf("%s will not parse, so it was left alone: %w", filepath.Base(path), perr) } set.Themes = existing @@ -560,7 +798,6 @@ func SaveUserTheme(path string, t Theme) error { } header := "# Written by iontk's theme editor. A scheme here replaces the built-in\n" + "# one of the same name; anything not set falls back to no colour.\n" - // the editor writes on every keypress tmp := path + ".tmp" if err := os.WriteFile(tmp, append([]byte(header), b...), 0o600); err != nil { return fmt.Errorf("write %s: %w", tmp, err) @@ -571,7 +808,6 @@ func SaveUserTheme(path string, t Theme) error { return LoadThemes(path) } -// the editor's way back from an override func BuiltinTheme(name string) (Theme, bool) { set, err := builtin() if err != nil { @@ -583,9 +819,150 @@ func BuiltinTheme(name string) (Theme, bool) { return Theme{}, false } +func (t Theme) Clone() Theme { + t.Tabs = slices.Clone(t.Tabs) + t.TabShell = slices.Clone(t.TabShell) + t.TabData = slices.Clone(t.TabData) + return t +} + +func (t Theme) Equal(o Theme) bool { + if t.Name != o.Name || t.Blurb != o.Blurb || t.Mono != o.Mono || + t.Spinner != o.Spinner || t.Animation != o.Animation || + t.TabMode != o.TabMode || !slices.Equal(t.Tabs, o.Tabs) || + !slices.Equal(t.TabShell, o.TabShell) || !slices.Equal(t.TabData, o.TabData) { + return false + } + for _, role := range RoleNames() { + if t.Role(role) != o.Role(role) { + return false + } + } + return true +} + +func ColourStyle(spec string) lipgloss.Style { + if spec == "" { + return lipgloss.NewStyle() + } + return lipgloss.NewStyle().Foreground(lipgloss.Color(spec)) +} + func (t Theme) RoleStyle(role string) lipgloss.Style { if t.Mono { return t.Styles().Dim } return t.fg(t.Role(role)) } + +const themeFileHeader = "# Written by iontk's theme editor. One scheme to a file; a scheme here\n" + + "# replaces the built-in of the same name, and deleting the file puts it back.\n" + +func saveThemeFile(dir string, t Theme) error { + if err := os.MkdirAll(dir, 0o700); err != nil { + return fmt.Errorf("make %s: %w", dir, err) + } + b, err := yaml.Marshal(t) + if err != nil { + return fmt.Errorf("encode %s: %w", t.Name, err) + } + + path := filepath.Join(dir, ThemeFileName(t.Name)) + tmp := path + ".tmp" + if err := os.WriteFile(tmp, append([]byte(themeFileHeader), b...), 0o600); err != nil { + return fmt.Errorf("write %s: %w", tmp, err) + } + if err := os.Rename(tmp, path); err != nil { + return fmt.Errorf("write %s: %w", path, err) + } + return LoadThemes(dir) +} + +func DeleteUserTheme(path, name string) error { + file, ok := userThemeSource(path, name) + if !ok { + return fmt.Errorf("%s has no file of yours to delete", name) + } + + b, err := os.ReadFile(file) + if err != nil { + return fmt.Errorf("read %s: %w", filepath.Base(file), err) + } + existing, err := ParseThemes(b) + if err != nil { + return fmt.Errorf("%s will not parse, so it was left alone: %w", filepath.Base(file), err) + } + + i := indexOf(existing, name) + if i < 0 { + return fmt.Errorf("%s has no file of yours to delete", name) + } + rest := slices.Delete(slices.Clone(existing), i, i+1) + + if len(rest) == 0 { + if err := os.Remove(file); err != nil { + return fmt.Errorf("remove %s: %w", filepath.Base(file), err) + } + return LoadThemes(path) + } + + out, err := yaml.Marshal(themeSet{Themes: rest}) + if err != nil { + return fmt.Errorf("encode themes: %w", err) + } + tmp := file + ".tmp" + if err := os.WriteFile(tmp, append([]byte(themeFileHeader), out...), 0o600); err != nil { + return fmt.Errorf("write %s: %w", tmp, err) + } + if err := os.Rename(tmp, file); err != nil { + return fmt.Errorf("write %s: %w", file, err) + } + return LoadThemes(path) +} + +func RemoveOverrides(path string) (int, error) { + shipped, err := builtin() + if err != nil { + return 0, err + } + var n int + for _, t := range shipped { + if !IsUserTheme(path, t.Name) { + continue + } + if derr := DeleteUserTheme(path, t.Name); derr != nil { + return n, derr + } + n++ + } + return n, nil +} + +func IsUserTheme(path, name string) bool { + _, ok := userThemeSource(path, name) + return ok +} + +func userThemeSource(path, name string) (string, bool) { + if path == "" { + return "", false + } + files, err := userThemeFiles(path) + if err != nil { + return "", false + } + for _, f := range files { + b, rerr := os.ReadFile(f) + if rerr != nil { + continue + } + set, perr := ParseThemes(b) + if perr != nil { + continue + } + if indexOf(set, name) >= 0 { + return f, true + } + } + return "", false +} diff --git a/internal/ui/theme_test.go b/internal/ui/theme_test.go index 560e237..0f129a0 100644 --- a/internal/ui/theme_test.go +++ b/internal/ui/theme_test.go @@ -1,17 +1,16 @@ package ui import ( + "charm.land/lipgloss/v2" "os" "path/filepath" + "slices" "strings" "testing" "github.com/charmbracelet/colorprofile" ) -// restore puts the shipped set and the default scheme back. Both are process -// state, so a test that left one changed would decide what every later test -// rendered. func restore(t *testing.T) { t.Helper() t.Cleanup(func() { @@ -52,8 +51,6 @@ func TestEveryThemeIsUsable(t *testing.T) { } seen[strings.ToLower(th.Name)] = true - // Every scheme names its spinner: the shape is part of the theme, and - // one that named nothing would quietly be somebody else's default. if !KnownSpinner(th.Spinner) { t.Errorf("%s names spinner %q, which is not a shape", th.Name, th.Spinner) } @@ -71,9 +68,6 @@ func TestEveryThemeIsUsable(t *testing.T) { } } -// Tabs are identity, not status: a tab coloured like an error reads as a failed -// one. The ANSI check is by index; the general one is that no tab may reuse the -// scheme's own ok, err or warn, which catches a hex palette too. func TestTabPalettesCarryNoStatusMeaning(t *testing.T) { reserved := map[string]string{ "1": "red", "2": "green", "3": "yellow", @@ -81,7 +75,7 @@ func TestTabPalettesCarryNoStatusMeaning(t *testing.T) { } for _, th := range Themes() { if th.Mono { - continue // nothing to confuse: the active tab is reversed, not coloured + continue } if len(th.Tabs) < 4 { t.Errorf("%s has %d tab colours, want enough to tell tabs apart", th.Name, len(th.Tabs)) @@ -98,8 +92,6 @@ func TestTabPalettesCarryNoStatusMeaning(t *testing.T) { } } -// The default has to be the one scheme that cannot clash with a terminal's own -// palette, which is the only reason the 0-15 rule existed. func TestOnlyAnOptedInSchemeLeavesTheAnsiRange(t *testing.T) { if Default().Deep() { t.Errorf("the default scheme %q leaves ANSI 0-15", Default().Name) @@ -118,8 +110,6 @@ func TestOnlyAnOptedInSchemeLeavesTheAnsiRange(t *testing.T) { } } -// A themes.yaml in the config root is the whole point of the format: it adds -// schemes, and replaces a built-in by name. func TestAUserFileAddsAndReplaces(t *testing.T) { restore(t) path := filepath.Join(t.TempDir(), ThemesFileName) @@ -151,7 +141,6 @@ themes: if _, ok := ThemeByName("office"); !ok { t.Error("the file's new scheme was not added") } - // Replacing one must not drop the rest. if _, ok := ThemeByName("nord"); !ok { t.Error("a user file replaced the whole built-in set") } @@ -160,8 +149,6 @@ themes: } } -// A scheme that will not load has to say why: lipgloss reads a colour it -// cannot parse as no colour, so the failure would otherwise be invisible text. func TestABrokenThemeFileIsRefusedAndTheBuiltinsSurvive(t *testing.T) { tests := []struct { name string @@ -200,7 +187,6 @@ func TestABrokenThemeFileIsRefusedAndTheBuiltinsSurvive(t *testing.T) { } } -// A missing file is the normal case, not an error. func TestNoUserFileIsNotAnError(t *testing.T) { restore(t) if err := LoadThemes(filepath.Join(t.TempDir(), "nothing.yaml")); err != nil { @@ -225,14 +211,11 @@ func TestUseThemeFallsBackToTheDefault(t *testing.T) { if CurrentTheme().Name != Default().Name { t.Errorf("CurrentTheme() = %q, want the default after an unknown name", CurrentTheme().Name) } - // Names are matched the way a hand-edited config spells them. if !UseTheme("BEN'S THEME") { t.Error("UseTheme() is case-sensitive, which a hand-written config will not be") } } -// Apply has to rebuild everything derived from the palette, the marks -// included: they were built from StyleOK and StyleErr at package load. func TestApplyRestylesTheMarks(t *testing.T) { restore(t) mono, _ := ThemeByName("mono") @@ -242,14 +225,11 @@ func TestApplyRestylesTheMarks(t *testing.T) { } nord, _ := ThemeByName("nord") Apply(nord) - // nord's green is #a3be8c, which lands on an RGB escape. if got := MarkCheck.String(); !strings.Contains(got, "163") { t.Errorf("MarkCheck = %q, want nord's green", got) } } -// Mono is not the palette with the colours taken out: a role that was a -// colour has to become an attribute or it stops being distinguishable. func TestMonoKeepsRolesApartWithoutColour(t *testing.T) { mono, _ := ThemeByName("mono") s := mono.Styles() @@ -266,8 +246,6 @@ func TestMonoKeepsRolesApartWithoutColour(t *testing.T) { } } -// The renderer downsamples what we emit, so the only thing to do about a -// shallow terminal is say that a deep scheme is being approximated. func TestThemeNoteOnlyWarnsWhenItMatters(t *testing.T) { t.Cleanup(func() { SetProfile(colorprofile.Unknown) }) nord, _ := ThemeByName("nord") @@ -297,9 +275,419 @@ func TestThemeNoteOnlyWarnsWhenItMatters(t *testing.T) { func TestStyleTabMarksTheActiveOne(t *testing.T) { restore(t) Apply(Default()) - if StyleTab(0, true).Render("x") == StyleTab(0, false).Render("x") { + if StyleTab(TabShell, 1, 0, true).Render("x") == StyleTab(TabShell, 1, 0, false).Render("x") { t.Error("the active tab renders the same as an inactive one") } - // Past the end of the palette rather than out of range: tabs wrap. - StyleTab(len(styles.Tabs)+3, false) + StyleTab(TabShell, len(styles.Tabs)+3, len(styles.Tabs)+3, false) +} + +func TestTheTabModesColourDifferently(t *testing.T) { + restore(t) + base := Theme{ + Name: "tabs", OK: "2", + Tabs: []string{"#111111", "#222222", "#333333"}, + TabShell: []string{"#aa0000", "#bb0000"}, + TabData: []string{"#00aa00", "#00bb00"}, + } + tests := []struct { + mode string + want []string + }{ + {TabRotate, []string{"#111111", "#222222", "#333333", "#111111", "#222222"}}, + {TabByType, []string{"#111111", "#aa0000", "#aa0000", "#00aa00", "#00aa00"}}, + {TabBoth, []string{"#111111", "#aa0000", "#bb0000", "#00aa00", "#00bb00"}}, + {TabShared, []string{"#111111", "#111111", "#222222", "#111111", "#222222"}}, + } + + for _, tc := range tests { + t.Run(tc.mode, func(t *testing.T) { + th := base.Clone() + th.TabMode = tc.mode + Apply(th) + + got := []string{ + fgOf(StyleTab(TabList, 0, 0, false)), + fgOf(StyleTab(TabShell, 1, 0, false)), + fgOf(StyleTab(TabShell, 2, 1, false)), + fgOf(StyleTab(TabData, 3, 0, false)), + fgOf(StyleTab(TabData, 4, 1, false)), + } + if !slices.Equal(got, tc.want) { + t.Errorf("mode %s painted %v, want %v", tc.mode, got, tc.want) + } + }) + } + + bare := Theme{Name: "bare", Tabs: []string{"#111111", "#222222"}, TabMode: TabBoth} + Apply(bare) + if got := fgOf(StyleTab(TabShell, 1, 1, false)); got != "#222222" { + t.Errorf("with no shell list the tab is %q, want the rotation", got) + } + + if err := (&Theme{Name: "x", TabMode: "sideways"}).validate(); err == nil { + t.Error("tab_mode sideways was accepted") + } +} + +func fgOf(s lipgloss.Style) string { + c := s.GetForeground() + if c == nil { + return "" + } + r, g, b, _ := c.RGBA() + return HexFromRGB([3]uint8{uint8(r >> 8), uint8(g >> 8), uint8(b >> 8)}) +} + +func TestTheBackgroundIsOnlySetByASchemeThatAsksForIt(t *testing.T) { + restore(t) + + Apply(Theme{Name: "bare", OK: "2"}) + if got := Background(); got != nil { + t.Errorf("Background() = %v for a scheme with no bg, want nil", got) + } + if got := Foreground(); got != nil { + t.Errorf("Foreground() = %v for a scheme with no fg, want nil", got) + } + + Apply(Theme{Name: "mocha", Bg: "#1e1e2e", Fg: "#cdd6f4"}) + bg := Background() + if bg == nil { + t.Fatal("Background() = nil for a scheme that sets bg") + } + r, g, b, a := bg.RGBA() + if r>>8 != 0x1e || g>>8 != 0x1e || b>>8 != 0x2e || a>>8 != 0xff { + t.Errorf("Background() = %04x%04x%04x alpha %04x, want #1e1e2e opaque", r, g, b, a) + } + + Apply(Theme{Name: "indexed", Bg: "4"}) + if got := Background(); got == nil { + t.Error("an index background did not resolve") + } +} + +func TestBackgroundIsARoleLikeTheOthers(t *testing.T) { + restore(t) + + var found int + for _, role := range RoleNames() { + if role == "background" || role == "other" { + found++ + } + } + if found != 2 { + t.Errorf("RoleNames() holds %d of background and other, want both", found) + } + + var th Theme + if err := th.SetRole("background", "#102030"); err != nil { + t.Fatalf("SetRole(background) = %v", err) + } + if th.Bg != "#102030" || th.Role("background") != "#102030" { + t.Errorf("background did not round-trip: %q / %q", th.Bg, th.Role("background")) + } + if err := (&Theme{Name: "x", Bg: "chartreuse"}).validate(); err == nil { + t.Error("a background that will not parse was accepted") + } + + classic := Theme{Name: "c", OK: "2", Err: "1", Bg: "#1e1e2e"} + if classic.Deep() { + t.Error("a scheme in the first sixteen reads as deep because of its bg") + } +} + +func TestEveryShippedSchemeSetsABackgroundExceptTwo(t *testing.T) { + restore(t) + loaded, loadErr = nil, "" + + bare := map[string]string{ + "classic": "the default, and the terminal's own sixteen colours", + "mono": "no colour at all is the whole premise", + } + + for _, th := range Themes() { + why, exempt := bare[th.Name] + switch { + case exempt && (th.Bg != "" || th.Fg != ""): + t.Errorf("%s sets bg %q / fg %q, but %s", th.Name, th.Bg, th.Fg, why) + case exempt: + case th.Bg == "": + t.Errorf("%s sets no bg, so choosing it leaves the terminal unchanged", th.Name) + case th.Fg == "": + t.Errorf("%s sets a bg but no fg, so uncoloured text keeps the old colour", th.Name) + } + + for _, role := range []string{"background", "other"} { + if spec := th.Role(role); spec != "" { + if _, ok := RGBOf(spec); !ok { + t.Errorf("%s: %s = %q does not resolve", th.Name, role, spec) + } + } + } + } +} + +func TestNoShippedSchemeHidesItsTextInItsBackground(t *testing.T) { + restore(t) + loaded, loadErr = nil, "" + + for _, th := range Themes() { + if th.Bg == "" { + continue + } + bg, _ := RGBOf(th.Bg) + _, _, bgv := HSVOf(bg) + for _, role := range RoleNames() { + if role == "background" || role == "divider" { + continue + } + spec := th.Role(role) + if spec == "" { + continue + } + c, ok := RGBOf(spec) + if !ok { + continue + } + _, _, v := HSVOf(c) + if d := v - bgv; d < 0.2 && d > -0.2 { + t.Errorf("%s: %s (%s) is as bright as its background (%s)", + th.Name, role, spec, th.Bg) + } + } + } +} + +func TestASchemeIsWrittenToItsOwnFile(t *testing.T) { + restore(t) + dir := filepath.Join(t.TempDir(), ThemesDirName) + + if got := ThemeFileName("Ben's Theme"); got != "benstheme.yaml" { + t.Errorf("ThemeFileName(Ben's Theme) = %q", got) + } + if got := ThemeFileName("classic"); got != "classic.yaml" { + t.Errorf("ThemeFileName(classic) = %q", got) + } + + mine := Theme{Name: "workbench", OK: "#a6e3a1", Err: "1", Spinner: "dot"} + if err := SaveUserTheme(dir, mine); err != nil { + t.Fatalf("SaveUserTheme: %v", err) + } + b, err := os.ReadFile(filepath.Join(dir, "workbench.yaml")) + if err != nil { + t.Fatalf("the scheme has no file of its own: %v", err) + } + if strings.Contains(string(b), "themes:") { + t.Errorf("the file still holds a list of schemes:\n%s", b) + } + if strings.Contains(string(b), `bg: ""`) { + t.Errorf("an unset role was written out:\n%s", b) + } + + if got, ok := ThemeByName("workbench"); !ok || got.OK != "#a6e3a1" { + t.Errorf("the scheme did not load back: %+v", got) + } + if _, ok := ThemeByName("nord"); !ok { + t.Error("writing one scheme took the shipped set with it") + } +} + +func TestADirectoryOfSchemesLoadsInNameOrder(t *testing.T) { + restore(t) + dir := filepath.Join(t.TempDir(), ThemesDirName) + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + for _, name := range []string{"zinc", "amber", "cobalt"} { + body := "name: " + name + "\nok: \"2\"\n" + if err := os.WriteFile(filepath.Join(dir, name+".yaml"), []byte(body), 0o600); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(dir, "notes.txt"), []byte("hello"), 0o600); err != nil { + t.Fatal(err) + } + + if err := LoadThemes(dir); err != nil { + t.Fatalf("LoadThemes: %v", err) + } + var mine []string + for _, th := range Themes() { + switch th.Name { + case "zinc", "amber", "cobalt": + mine = append(mine, th.Name) + } + } + want := []string{"amber", "cobalt", "zinc"} + if !slices.Equal(mine, want) { + t.Errorf("loaded %v, want %v", mine, want) + } +} + +func TestOneBadFileLeavesTheRestLoaded(t *testing.T) { + restore(t) + dir := filepath.Join(t.TempDir(), ThemesDirName) + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "good.yaml"), + []byte("name: good\nok: \"2\"\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "bad.yaml"), + []byte("name: bad\nok: chartreuse\n"), 0o600); err != nil { + t.Fatal(err) + } + + if err := LoadThemes(dir); err == nil { + t.Error("LoadThemes said nothing about the file that will not parse") + } + if _, ok := ThemeByName("good"); !ok { + t.Error("the good scheme was lost with the bad one") + } + if _, ok := ThemeByName("bad"); ok { + t.Error("the scheme with an unparseable colour was loaded anyway") + } + if ThemesErr() == "" { + t.Error("nothing is on the settings page about it") + } +} + +func TestALegacyThemesFileIsStillRead(t *testing.T) { + restore(t) + root := t.TempDir() + body := "themes:\n - name: heirloom\n ok: \"2\"\n" + if err := os.WriteFile(filepath.Join(root, ThemesFileName), []byte(body), 0o600); err != nil { + t.Fatal(err) + } + + if err := LoadThemes(filepath.Join(root, ThemesDirName)); err != nil { + t.Fatalf("LoadThemes: %v", err) + } + if _, ok := ThemeByName("heirloom"); !ok { + t.Error("the legacy themes.yaml was not read") + } +} + +func TestDeletingASchemeFileRestoresTheShippedOne(t *testing.T) { + restore(t) + dir := filepath.Join(t.TempDir(), ThemesDirName) + + override := Theme{Name: "nord", OK: "#ffffff", Spinner: "dot"} + if err := SaveUserTheme(dir, override); err != nil { + t.Fatal(err) + } + if got, _ := ThemeByName("nord"); got.OK != "#ffffff" { + t.Fatalf("the override did not take: ok = %q", got.OK) + } + if !IsUserTheme(dir, "nord") { + t.Error("IsUserTheme says the override is not the user's") + } + if IsUserTheme(dir, "desert") { + t.Error("IsUserTheme claims a scheme with no file of its own") + } + + if err := DeleteUserTheme(dir, "nord"); err != nil { + t.Fatalf("DeleteUserTheme: %v", err) + } + got, ok := ThemeByName("nord") + if !ok { + t.Fatal("nord went away with the override; it is one of the shipped set") + } + if got.OK == "#ffffff" { + t.Error("the override is still in the set after its file was deleted") + } + + if err := SaveUserTheme(dir, Theme{Name: "workbench", OK: "2"}); err != nil { + t.Fatal(err) + } + if err := DeleteUserTheme(dir, "workbench"); err != nil { + t.Fatal(err) + } + if _, ok := ThemeByName("workbench"); ok { + t.Error("the scheme is still in the set after being deleted") + } + if err := DeleteUserTheme(dir, "workbench"); err == nil { + t.Error("deleting it twice did not say there was nothing to delete") + } +} + +func TestASchemeInTheLegacyFileIsStillTheUsers(t *testing.T) { + restore(t) + root := t.TempDir() + dir := filepath.Join(root, ThemesDirName) + body := "themes:\n - name: test\n ok: \"2\"\n - name: keeper\n ok: \"3\"\n" + if err := os.WriteFile(filepath.Join(root, ThemesFileName), []byte(body), 0o600); err != nil { + t.Fatal(err) + } + if err := LoadThemes(dir); err != nil { + t.Fatal(err) + } + + if !IsUserTheme(dir, "test") { + t.Error("a scheme in the legacy file reads as shipped") + } + if err := DeleteUserTheme(dir, "test"); err != nil { + t.Fatalf("DeleteUserTheme: %v", err) + } + if _, ok := ThemeByName("test"); ok { + t.Error("test is still in the set") + } + if _, ok := ThemeByName("keeper"); !ok { + t.Error("deleting one scheme took the rest of the file with it") + } +} + +func TestHiddenSchemesAreLeftOutOfTheSet(t *testing.T) { + restore(t) + t.Cleanup(func() { HideThemes(nil) }) + + before := len(Themes()) + HideThemes([]string{"desert", "evening"}) + if got := len(Themes()); got != before-2 { + t.Errorf("Themes() has %d, want %d", got, before-2) + } + if _, ok := ThemeByName("desert"); ok { + t.Error("a hidden scheme is still in the set") + } + if !IsHidden("DESERT") { + t.Error("IsHidden is case sensitive") + } + if _, ok := BuiltinTheme("desert"); !ok { + t.Error("hiding a scheme reached the embedded set") + } + + var all []string + for _, th := range allThemes() { + all = append(all, th.Name) + } + HideThemes(all) + if got := Themes(); len(got) != 1 { + t.Errorf("Themes() = %d schemes with everything hidden, want the fallback", len(got)) + } +} + +func TestRemoveOverridesLeavesTheUsersOwnSchemes(t *testing.T) { + restore(t) + dir := filepath.Join(t.TempDir(), ThemesDirName) + + if err := SaveUserTheme(dir, Theme{Name: "nord", OK: "#ffffff"}); err != nil { + t.Fatal(err) + } + if err := SaveUserTheme(dir, Theme{Name: "workbench", OK: "2"}); err != nil { + t.Fatal(err) + } + + n, err := RemoveOverrides(dir) + if err != nil { + t.Fatalf("RemoveOverrides: %v", err) + } + if n != 1 { + t.Errorf("removed %d, want the one override", n) + } + if got, _ := ThemeByName("nord"); got.OK == "#ffffff" { + t.Error("the override survived") + } + if _, ok := ThemeByName("workbench"); !ok { + t.Error("a scheme of the user's own was removed") + } } diff --git a/internal/ui/themes.yaml b/internal/ui/themes.yaml index 0e8acda..acdcbf1 100644 --- a/internal/ui/themes.yaml +++ b/internal/ui/themes.yaml @@ -15,11 +15,38 @@ # hot a session about to go # border a modal's frame # divider the rule between two panes +# bg the terminal's own background, set while iontk runs and put back +# when it exits; unset leaves the terminal as the user has it +# fg the default text colour, for anything no role has coloured # spinner the spinner's shape, one of the names iontk ships # spinner_color what it is drawn in, including in the bubbles widget -# tabs cycled one per open tab, so none of them may be a status colour +# tabs the rotation tabs take their colour from; none of them may be a +# status colour, since a tab is identity rather than state +# tab_mode how that rotation is read, which the theme editor's "tab colours" +# page sets. One of: +# rotate a colour per position in the bar (the default, and +# what iontk has always done) +# type one colour for shells, one for rich-data tabs +# both a rotation of its own per kind +# shared one rotation, counted within the kind -- so the first +# shell and the first data tab are the same colour +# tab_shell, tab_data +# the per-kind rotations the type and both modes draw from. Unset +# borrows the rotation, offset by kind so the two do not start on +# the same colour. The element list's tab is always the first +# colour of the rotation, in every mode # mono true drops colour entirely for bold, faint, italic and reverse # +# bg and fg go out as OSC 11 and 10, which only take a hex: an index written +# there is resolved to xterm's value for it, not the terminal's own. +# +# Every scheme here sets a background except **classic** and **mono**, and +# those two leave it out on purpose: classic is the default and is the +# terminal's own sixteen colours, so it must not repaint the terminal of +# someone who never chose a scheme, and mono's whole premise is no colour at +# all. Everything else carries the background its palette was drawn for -- the +# blurbs were already describing one. +# # A colour is an ANSI index 0-255 or a #rrggbb. An index under 16 is remapped by # the terminal's own theme, which is why classic stays there; anything else is # emitted as asked and downsampled by the renderer to what the terminal has. @@ -53,6 +80,8 @@ themes: hot: "11" border: "11" divider: "8" + bg: "#000000" + fg: "#e4e4e4" spinner: jump spinner_color: "13" tabs: ["15", "12", "14", "13", "4", "6", "7"] @@ -73,6 +102,8 @@ themes: hot: "#d08770" border: "#ebcb8b" divider: "#4c566a" + bg: "#2e3440" + fg: "#d8dee9" spinner: dot spinner_color: "#b48ead" tabs: ["#d8dee9", "#81a1c1", "#88c0d0", "#b48ead", "#5e81ac", "#8fbcbb", "#eceff4"] @@ -91,6 +122,8 @@ themes: hot: "#ffa07a" border: "#cd853f" divider: "#6b6b6b" + bg: "#33302a" + fg: "#f5deb3" spinner: ellipsis spinner_color: "#bdb76b" tabs: ["#f5deb3", "#87ceeb", "#b0c4de", "#d8bfd8", "#deb887", "#c0c0c0", "#add8e6"] @@ -106,6 +139,8 @@ themes: hot: "#ffa500" border: "#ffff60" divider: "#4e4e4e" + bg: "#333333" + fg: "#ffffff" spinner: moon spinner_color: "#ff80ff" tabs: ["#ffffff", "#80a0ff", "#60ffff", "#ff80ff", "#c0c0ff", "#a0a0ff", "#d0d0d0"] @@ -121,6 +156,9 @@ themes: hot: "#b85c00" border: "#a0522d" divider: "#9e9e9e" + # dark ink needs the light paper to go with it, or this one is unreadable + bg: "#f2ead3" + fg: "#303030" spinner: points spinner_color: "#005f5f" tabs: ["#303030", "#00008b", "#005f5f", "#8b008b", "#4b0082", "#2f4f4f", "#555555"] @@ -136,6 +174,8 @@ themes: hot: "#de935f" border: "#8abeb7" divider: "#5a5a5a" + bg: "#262626" + fg: "#c5c8c6" spinner: meter spinner_color: "#b294bb" tabs: ["#c5c8c6", "#7aa6da", "#8abeb7", "#b294bb", "#81a2be", "#a3a3a3", "#d0d0d0"] @@ -151,6 +191,8 @@ themes: hot: "#ff8700" border: "#d7af00" divider: "#585858" + bg: "#000000" + fg: "#ffffff" spinner: hamburger spinner_color: "#af5fd7" tabs: ["#ffffff", "#5f87ff", "#00d7d7", "#af5fd7", "#8787ff", "#00afaf", "#bcbcbc"] @@ -166,6 +208,8 @@ themes: hot: "#d7875f" border: "#d7af87" divider: "#585858" + bg: "#1c1c1c" + fg: "#bcbcbc" spinner: pulse spinner_color: "#afafd7" tabs: ["#bcbcbc", "#afafd7", "#87afaf", "#d7afd7", "#a8a8a8", "#8787af", "#d7d7d7"] @@ -185,6 +229,9 @@ themes: hot: "#fab387" border: "#f9e2af" divider: "#585b70" + # mocha's own base and text, so iontk matches the editor next to it + bg: "#1e1e2e" + fg: "#cdd6f4" spinner: monkey spinner_color: "#cba6f7" tabs: ["#cdd6f4", "#89b4fa", "#94e2d5", "#cba6f7", "#74c7ec", "#f5c2e7", "#b4befe"]